diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..93f5e3a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,200 @@ +# CC-Connect Development Guide + +## Project Overview + +CC-Connect is a bridge that connects AI coding agents (Claude Code, Codex, Gemini CLI, Cursor, etc.) with messaging platforms (Feishu/Lark, Telegram, Discord, Slack, DingTalk, WeChat Work, QQ, LINE). Users interact with their coding agent through their preferred messaging app. + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ cmd/cc-connect │ ← entry point, CLI, daemon +├─────────────────────────────────────────────────┤ +│ config/ │ ← TOML config parsing +├─────────────────────────────────────────────────┤ +│ core/ │ ← engine, interfaces, i18n, +│ │ cards, sessions, registry +├──────────────────────┬──────────────────────────┤ +│ agent/ │ platform/ │ +│ ├── claudecode/ │ ├── feishu/ │ +│ ├── codex/ │ ├── telegram/ │ +│ ├── cursor/ │ ├── discord/ │ +│ ├── gemini/ │ ├── slack/ │ +│ ├── iflow/ │ ├── dingtalk/ │ +│ ├── opencode/ │ ├── wecom/ │ +│ └── qoder/ │ ├── qq/ │ +│ │ ├── qqbot/ │ +│ │ └── line/ │ +├──────────────────────┴──────────────────────────┤ +│ daemon/ │ ← systemd/launchd service +└─────────────────────────────────────────────────┘ +``` + +### Key Design Principles + +**`core/` is the nucleus.** It defines all interfaces (`Platform`, `Agent`, `AgentSession`, etc.) and contains the `Engine` that orchestrates message flow. The core package must **never** import from `agent/` or `platform/`. + +**Plugin architecture via registries.** Agents and platforms register themselves through `core.RegisterAgent()` and `core.RegisterPlatform()` in their `init()` functions. The engine creates instances via `core.CreateAgent()` / `core.CreatePlatform()` using string names from config. + +**Dependency direction:** +``` +cmd/ → config/, core/, agent/*, platform/* +agent/* → core/ (never other agents or platforms) +platform/* → core/ (never other platforms or agents) +core/ → stdlib only (never agent/ or platform/) +``` + +### Core Interfaces + +- **`Platform`** — messaging platform adapter (Start, Reply, Send, Stop) +- **`Agent`** — AI coding agent adapter (StartSession, ListSessions, Stop) +- **`AgentSession`** — a running bidirectional session (Send, RespondPermission, Events) +- **`Engine`** — the central orchestrator that routes messages between platforms and agents + +Optional capability interfaces (implement only when needed): +- `CardSender` — rich card messages +- `InlineButtonSender` — inline keyboard buttons +- `ProviderSwitcher` — multi-model switching +- `DoctorChecker` — agent-specific health checks +- `AgentDoctorInfo` — CLI binary metadata for diagnostics + +## Development Rules + +### 1. No Hardcoding Platform or Agent Names in Core + +The `core/` package must remain agnostic. Never write `if p.Name() == "feishu"` or `CreateAgent("claudecode", ...)` in core. Use interfaces and capability checks instead: + +```go +// BAD — hardcodes platform knowledge in core +if p.Name() == "feishu" && supportsCards(p) { + +// GOOD — capability-based check +if supportsCards(p) { +``` + +```go +// BAD — hardcodes agent type +agent, _ := CreateAgent("claudecode", opts) + +// GOOD — derives from current agent +agent, _ := CreateAgent(e.agent.Name(), opts) +``` + +### 2. Prefer Interfaces Over Type Switches + +When behavior differs across platforms/agents, define an optional interface in core and let implementations opt in: + +```go +// In core/ +type AgentDoctorInfo interface { + CLIBinaryName() string + CLIDisplayName() string +} + +// In agent/claudecode/ +func (a *Agent) CLIBinaryName() string { return "claude" } +func (a *Agent) CLIDisplayName() string { return "Claude" } + +// In core/ — query via interface, fallback gracefully +if info, ok := agent.(AgentDoctorInfo); ok { + bin = info.CLIBinaryName() +} +``` + +### 3. Configuration Over Code + +- Features that may vary per deployment should be configurable in `config.toml` +- Use `map[string]any` options for agent/platform factories to stay flexible +- Add new config fields with sensible defaults so existing configs don't break + +### 4. High Cohesion, Low Coupling + +- Each `agent/X/` package is self-contained: it handles process lifecycle, output parsing, and session management for agent X +- Each `platform/X/` package is self-contained: it handles API connection, message receiving/sending, and card rendering for platform X +- Cross-cutting concerns (i18n, cards, streaming, rate limiting) live in `core/` + +### 5. Error Handling + +- Always wrap errors with context: `fmt.Errorf("feishu: reply card: %w", err)` +- Never silently swallow errors; at minimum log them with `slog.Error` / `slog.Warn` +- Use `slog` (structured logging) consistently; never `log.Printf` or `fmt.Printf` for runtime logs +- Redact tokens/secrets in error messages using `core.RedactToken()` + +### 6. Concurrency Safety + +- Agent sessions are accessed from multiple goroutines; protect shared state with `sync.Mutex` or `atomic` types +- Use `context.Context` for cancellation propagation +- Channels should have clear ownership; document who closes them +- Prefer `sync.Once` for one-time teardown (`pendingPermission.resolve()`) + +### 7. i18n + +All user-facing strings must go through `core/i18n.go`: +- Define a `MsgKey` constant +- Add translations for all supported languages (EN, ZH, ZH-TW, JA, ES) +- Use `e.i18n.T(MsgKey)` or `e.i18n.Tf(MsgKey, args...)` + +## Code Style + +- Follow standard Go conventions (`gofmt`, `go vet`) +- Use `strings.EqualFold` for case-insensitive comparisons +- Avoid `init()` for anything other than platform/agent registration +- Keep functions focused; extract helpers when a function exceeds ~80 lines +- Naming: `New()` for constructors, `Get/Set` for accessors, avoid stuttering (`feishu.FeishuPlatform` → `feishu.Platform`) + +## Testing + +### Requirements + +- All new features must include unit tests +- All bug fixes should include a regression test +- Tests must pass before committing: `go test ./...` + +### Running Tests + +```bash +# Full test suite +go test ./... + +# Specific package +go test ./core/ -v + +# Run specific test +go test ./core/ -run TestHandlePendingPermission -v + +# With race detector (CI) +go test -race ./... +``` + +### Test Patterns + +- Use stub types for `Platform` and `Agent` in core tests (see `core/engine_test.go`) +- Test card rendering by inspecting the returned `*Card` struct, not JSON +- For agent session tests, simulate event streams via channels + +## Pre-Commit Checklist + +1. **Build passes**: `go build ./...` +2. **Tests pass**: `go test ./...` +3. **No new hardcoded platform/agent names in core**: grep for platform names in `core/*.go` +4. **i18n complete**: all new user-facing strings have translations for all languages +5. **No secrets in code**: no API keys, tokens, or credentials in source files + +## Adding a New Platform + +1. Create `platform/newplatform/newplatform.go` +2. Implement `core.Platform` interface (and optional interfaces as needed) +3. Register in `init()`: `core.RegisterPlatform("newplatform", factory)` +4. Import in `cmd/cc-connect/main.go`: `_ "github.com/.../platform/newplatform"` +5. Add config example in `config.example.toml` +6. Add unit tests + +## Adding a New Agent + +1. Create `agent/newagent/newagent.go` +2. Implement `core.Agent` and `core.AgentSession` interfaces +3. Register in `init()`: `core.RegisterAgent("newagent", factory)` +4. Import in `cmd/cc-connect/main.go`: `_ "github.com/.../agent/newagent"` +5. Optionally implement `AgentDoctorInfo` for `cc-connect doctor` support +6. Add config example in `config.example.toml` +7. Add unit tests diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..93f5e3a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,200 @@ +# CC-Connect Development Guide + +## Project Overview + +CC-Connect is a bridge that connects AI coding agents (Claude Code, Codex, Gemini CLI, Cursor, etc.) with messaging platforms (Feishu/Lark, Telegram, Discord, Slack, DingTalk, WeChat Work, QQ, LINE). Users interact with their coding agent through their preferred messaging app. + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ cmd/cc-connect │ ← entry point, CLI, daemon +├─────────────────────────────────────────────────┤ +│ config/ │ ← TOML config parsing +├─────────────────────────────────────────────────┤ +│ core/ │ ← engine, interfaces, i18n, +│ │ cards, sessions, registry +├──────────────────────┬──────────────────────────┤ +│ agent/ │ platform/ │ +│ ├── claudecode/ │ ├── feishu/ │ +│ ├── codex/ │ ├── telegram/ │ +│ ├── cursor/ │ ├── discord/ │ +│ ├── gemini/ │ ├── slack/ │ +│ ├── iflow/ │ ├── dingtalk/ │ +│ ├── opencode/ │ ├── wecom/ │ +│ └── qoder/ │ ├── qq/ │ +│ │ ├── qqbot/ │ +│ │ └── line/ │ +├──────────────────────┴──────────────────────────┤ +│ daemon/ │ ← systemd/launchd service +└─────────────────────────────────────────────────┘ +``` + +### Key Design Principles + +**`core/` is the nucleus.** It defines all interfaces (`Platform`, `Agent`, `AgentSession`, etc.) and contains the `Engine` that orchestrates message flow. The core package must **never** import from `agent/` or `platform/`. + +**Plugin architecture via registries.** Agents and platforms register themselves through `core.RegisterAgent()` and `core.RegisterPlatform()` in their `init()` functions. The engine creates instances via `core.CreateAgent()` / `core.CreatePlatform()` using string names from config. + +**Dependency direction:** +``` +cmd/ → config/, core/, agent/*, platform/* +agent/* → core/ (never other agents or platforms) +platform/* → core/ (never other platforms or agents) +core/ → stdlib only (never agent/ or platform/) +``` + +### Core Interfaces + +- **`Platform`** — messaging platform adapter (Start, Reply, Send, Stop) +- **`Agent`** — AI coding agent adapter (StartSession, ListSessions, Stop) +- **`AgentSession`** — a running bidirectional session (Send, RespondPermission, Events) +- **`Engine`** — the central orchestrator that routes messages between platforms and agents + +Optional capability interfaces (implement only when needed): +- `CardSender` — rich card messages +- `InlineButtonSender` — inline keyboard buttons +- `ProviderSwitcher` — multi-model switching +- `DoctorChecker` — agent-specific health checks +- `AgentDoctorInfo` — CLI binary metadata for diagnostics + +## Development Rules + +### 1. No Hardcoding Platform or Agent Names in Core + +The `core/` package must remain agnostic. Never write `if p.Name() == "feishu"` or `CreateAgent("claudecode", ...)` in core. Use interfaces and capability checks instead: + +```go +// BAD — hardcodes platform knowledge in core +if p.Name() == "feishu" && supportsCards(p) { + +// GOOD — capability-based check +if supportsCards(p) { +``` + +```go +// BAD — hardcodes agent type +agent, _ := CreateAgent("claudecode", opts) + +// GOOD — derives from current agent +agent, _ := CreateAgent(e.agent.Name(), opts) +``` + +### 2. Prefer Interfaces Over Type Switches + +When behavior differs across platforms/agents, define an optional interface in core and let implementations opt in: + +```go +// In core/ +type AgentDoctorInfo interface { + CLIBinaryName() string + CLIDisplayName() string +} + +// In agent/claudecode/ +func (a *Agent) CLIBinaryName() string { return "claude" } +func (a *Agent) CLIDisplayName() string { return "Claude" } + +// In core/ — query via interface, fallback gracefully +if info, ok := agent.(AgentDoctorInfo); ok { + bin = info.CLIBinaryName() +} +``` + +### 3. Configuration Over Code + +- Features that may vary per deployment should be configurable in `config.toml` +- Use `map[string]any` options for agent/platform factories to stay flexible +- Add new config fields with sensible defaults so existing configs don't break + +### 4. High Cohesion, Low Coupling + +- Each `agent/X/` package is self-contained: it handles process lifecycle, output parsing, and session management for agent X +- Each `platform/X/` package is self-contained: it handles API connection, message receiving/sending, and card rendering for platform X +- Cross-cutting concerns (i18n, cards, streaming, rate limiting) live in `core/` + +### 5. Error Handling + +- Always wrap errors with context: `fmt.Errorf("feishu: reply card: %w", err)` +- Never silently swallow errors; at minimum log them with `slog.Error` / `slog.Warn` +- Use `slog` (structured logging) consistently; never `log.Printf` or `fmt.Printf` for runtime logs +- Redact tokens/secrets in error messages using `core.RedactToken()` + +### 6. Concurrency Safety + +- Agent sessions are accessed from multiple goroutines; protect shared state with `sync.Mutex` or `atomic` types +- Use `context.Context` for cancellation propagation +- Channels should have clear ownership; document who closes them +- Prefer `sync.Once` for one-time teardown (`pendingPermission.resolve()`) + +### 7. i18n + +All user-facing strings must go through `core/i18n.go`: +- Define a `MsgKey` constant +- Add translations for all supported languages (EN, ZH, ZH-TW, JA, ES) +- Use `e.i18n.T(MsgKey)` or `e.i18n.Tf(MsgKey, args...)` + +## Code Style + +- Follow standard Go conventions (`gofmt`, `go vet`) +- Use `strings.EqualFold` for case-insensitive comparisons +- Avoid `init()` for anything other than platform/agent registration +- Keep functions focused; extract helpers when a function exceeds ~80 lines +- Naming: `New()` for constructors, `Get/Set` for accessors, avoid stuttering (`feishu.FeishuPlatform` → `feishu.Platform`) + +## Testing + +### Requirements + +- All new features must include unit tests +- All bug fixes should include a regression test +- Tests must pass before committing: `go test ./...` + +### Running Tests + +```bash +# Full test suite +go test ./... + +# Specific package +go test ./core/ -v + +# Run specific test +go test ./core/ -run TestHandlePendingPermission -v + +# With race detector (CI) +go test -race ./... +``` + +### Test Patterns + +- Use stub types for `Platform` and `Agent` in core tests (see `core/engine_test.go`) +- Test card rendering by inspecting the returned `*Card` struct, not JSON +- For agent session tests, simulate event streams via channels + +## Pre-Commit Checklist + +1. **Build passes**: `go build ./...` +2. **Tests pass**: `go test ./...` +3. **No new hardcoded platform/agent names in core**: grep for platform names in `core/*.go` +4. **i18n complete**: all new user-facing strings have translations for all languages +5. **No secrets in code**: no API keys, tokens, or credentials in source files + +## Adding a New Platform + +1. Create `platform/newplatform/newplatform.go` +2. Implement `core.Platform` interface (and optional interfaces as needed) +3. Register in `init()`: `core.RegisterPlatform("newplatform", factory)` +4. Import in `cmd/cc-connect/main.go`: `_ "github.com/.../platform/newplatform"` +5. Add config example in `config.example.toml` +6. Add unit tests + +## Adding a New Agent + +1. Create `agent/newagent/newagent.go` +2. Implement `core.Agent` and `core.AgentSession` interfaces +3. Register in `init()`: `core.RegisterAgent("newagent", factory)` +4. Import in `cmd/cc-connect/main.go`: `_ "github.com/.../agent/newagent"` +5. Optionally implement `AgentDoctorInfo` for `cc-connect doctor` support +6. Add config example in `config.example.toml` +7. Add unit tests diff --git a/agent/claudecode/claudecode.go b/agent/claudecode/claudecode.go index 902f777..580835f 100644 --- a/agent/claudecode/claudecode.go +++ b/agent/claudecode/claudecode.go @@ -101,7 +101,9 @@ func normalizePermissionMode(raw string) string { } } -func (a *Agent) Name() string { return "claudecode" } +func (a *Agent) Name() string { return "claudecode" } +func (a *Agent) CLIBinaryName() string { return "claude" } +func (a *Agent) CLIDisplayName() string { return "Claude" } func (a *Agent) SetModel(model string) { a.mu.Lock() diff --git a/agent/cursor/cursor.go b/agent/cursor/cursor.go index a2584ee..7f643cf 100644 --- a/agent/cursor/cursor.go +++ b/agent/cursor/cursor.go @@ -79,7 +79,9 @@ func normalizeMode(raw string) string { } } -func (a *Agent) Name() string { return "cursor" } +func (a *Agent) Name() string { return "cursor" } +func (a *Agent) CLIBinaryName() string { return "agent" } +func (a *Agent) CLIDisplayName() string { return "Cursor Agent" } func (a *Agent) SetModel(model string) { a.mu.Lock() diff --git a/agent/qoder/qoder.go b/agent/qoder/qoder.go index 136f157..fc9422f 100644 --- a/agent/qoder/qoder.go +++ b/agent/qoder/qoder.go @@ -55,7 +55,9 @@ func normalizeMode(raw string) string { } } -func (a *Agent) Name() string { return "qoder" } +func (a *Agent) Name() string { return "qoder" } +func (a *Agent) CLIBinaryName() string { return "qodercli" } +func (a *Agent) CLIDisplayName() string { return "Qoder" } func (a *Agent) SetModel(model string) { a.mu.Lock() diff --git a/core/doctor.go b/core/doctor.go index 33734c8..d36cdff 100644 --- a/core/doctor.go +++ b/core/doctor.go @@ -44,6 +44,14 @@ type DoctorChecker interface { DoctorChecks(ctx context.Context) []DoctorCheckResult } +// AgentDoctorInfo is an optional interface agents can implement to provide +// CLI binary name and display label for doctor checks, avoiding hardcoded +// agent-specific knowledge in core. +type AgentDoctorInfo interface { + CLIBinaryName() string // e.g. "claude", "codex" + CLIDisplayName() string // e.g. "Claude", "Codex" (for display in doctor output) +} + // RunDoctorChecks performs all diagnostic checks. func RunDoctorChecks(ctx context.Context, agent Agent, platforms []Platform) []DoctorCheckResult { var results []DoctorCheckResult @@ -62,22 +70,22 @@ func RunDoctorChecks(ctx context.Context, agent Agent, platforms []Platform) []D return results } -var agentBinMap = map[string]string{ - "claudecode": "claude", - "cursor": "agent", - "codex": "codex", - "gemini": "gemini", - "iflow": "iflow", - "opencode": "opencode", - "qoder": "qodercli", +func agentCLIInfo(agent Agent) (bin, label string) { + if info, ok := agent.(AgentDoctorInfo); ok { + bin = info.CLIBinaryName() + label = info.CLIDisplayName() + } + if bin == "" { + bin = agent.Name() + } + if label == "" { + label = bin + } + return bin, label } func checkAgentBinary(ctx context.Context, agent Agent) []DoctorCheckResult { - name := agent.Name() - bin, ok := agentBinMap[name] - if !ok { - bin = name - } + bin, _ := agentCLIInfo(agent) path, err := exec.LookPath(bin) if err != nil { @@ -106,25 +114,8 @@ func checkAgentBinary(ctx context.Context, agent Agent) []DoctorCheckResult { } func checkAgentAuth(ctx context.Context, agent Agent) []DoctorCheckResult { - name := agent.Name() - - switch name { - case "claudecode": - return checkCLIAuth(ctx, "claude", []string{"--version"}, "Claude") - case "codex": - return checkCLIAuth(ctx, "codex", []string{"--version"}, "Codex") - case "gemini": - return checkCLIAuth(ctx, "gemini", []string{"--version"}, "Gemini") - case "iflow": - return checkCLIAuth(ctx, "iflow", []string{"--version"}, "iFlow") - case "opencode": - return checkCLIAuth(ctx, "opencode", []string{"--version"}, "OpenCode") - case "cursor": - return checkCLIAuth(ctx, "agent", []string{"--version"}, "Cursor Agent") - case "qoder": - return checkCLIAuth(ctx, "qodercli", []string{"--version"}, "Qoder") - } - return nil + bin, label := agentCLIInfo(agent) + return checkCLIAuth(ctx, bin, []string{"--version"}, label) } func checkCLIAuth(ctx context.Context, bin string, args []string, label string) []DoctorCheckResult { diff --git a/core/engine.go b/core/engine.go index a43488d..d25fb88 100644 --- a/core/engine.go +++ b/core/engine.go @@ -1196,7 +1196,7 @@ func (e *Engine) getOrCreateWorkspaceAgent(workspace string) (Agent, *SessionMan } } - agent, err := CreateAgent("claudecode", opts) + agent, err := CreateAgent(e.agent.Name(), opts) if err != nil { return nil, nil, fmt.Errorf("create workspace agent for %s: %w", workspace, err) } @@ -2458,7 +2458,7 @@ func (e *Engine) cmdUsage(p Platform, msg *Message) { return } - if p.Name() == "feishu" && supportsCards(p) { + if supportsCards(p) { e.replyWithCard(p, msg.ReplyCtx, e.renderUsageCard(report)) return } @@ -3017,7 +3017,7 @@ func (e *Engine) cmdHelp(p Platform, msg *Message) { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgHelp)) return } - e.replyWithCard(p, msg.ReplyCtx, e.renderHelpCardForPlatform(p.Name())) + e.replyWithCard(p, msg.ReplyCtx, e.renderHelpCard()) } const defaultHelpGroup = "session" @@ -3091,15 +3091,13 @@ func helpCardGroups() []helpCardGroup { } func (e *Engine) renderHelpCard() *Card { - return e.renderHelpCardForPlatform("") + return e.renderHelpGroupCard(defaultHelpGroup) } -func (e *Engine) renderHelpCardForPlatform(platform string) *Card { - return e.renderHelpGroupCardForPlatform(platform, defaultHelpGroup) -} - -func splitHelpTabRows(platform string, tabs []CardButton) [][]CardButton { - if strings.EqualFold(strings.TrimSpace(platform), "feishu") { +// splitHelpTabRows splits tab buttons into rows. Card-based platforms +// get 2 buttons per row for better layout; others get all in one row. +func splitHelpTabRows(useMultiRow bool, tabs []CardButton) [][]CardButton { + if useMultiRow { rows := make([][]CardButton, 0, (len(tabs)+1)/2) for i := 0; i < len(tabs); i += 2 { end := i + 2 @@ -3113,7 +3111,7 @@ func splitHelpTabRows(platform string, tabs []CardButton) [][]CardButton { return [][]CardButton{tabs} } -func (e *Engine) renderHelpGroupCardForPlatform(platform string, groupKey string) *Card { +func (e *Engine) renderHelpGroupCard(groupKey string) *Card { sectionTitle := func(key MsgKey) string { section := e.i18n.T(key) if idx := strings.IndexByte(section, '\n'); idx >= 0 { @@ -3147,7 +3145,7 @@ func (e *Engine) renderHelpGroupCardForPlatform(platform string, groupKey string } tabs = append(tabs, Btn(tabLabel(group.titleKey), btnType, "nav:/help "+group.key)) } - for _, row := range splitHelpTabRows(platform, tabs) { + for _, row := range splitHelpTabRows(true, tabs) { cb.ButtonsEqual(row...) } for _, item := range current.items { @@ -4267,13 +4265,7 @@ func (e *Engine) handleCardNav(action string, sessionKey string) *Card { switch cmd { case "/help": - platform := "" - if sessionKey != "" { - if p, _, err := parseSessionKeyParts(sessionKey); err == nil { - platform = p - } - } - return e.renderHelpGroupCardForPlatform(platform, args) + return e.renderHelpGroupCard(args) case "/model": return e.renderModelCard() case "/reasoning": diff --git a/core/markdown_html.go b/core/markdown_html.go index a32cca2..cf524b7 100644 --- a/core/markdown_html.go +++ b/core/markdown_html.go @@ -5,11 +5,10 @@ import ( "strings" ) -// MarkdownToTelegramHTML converts common Markdown to Telegram-compatible HTML. -// Telegram supports: , , , ,
, , 
. -// This is more reliable than Telegram's native Markdown parser which frequently -// fails on content with underscores, asterisks in code, etc. -func MarkdownToTelegramHTML(md string) string { +// MarkdownToSimpleHTML converts common Markdown to a simplified HTML subset. +// Supported tags: , , , ,
, , 
. +// Useful for platforms that accept a limited set of HTML (e.g. Telegram). +func MarkdownToSimpleHTML(md string) string { var b strings.Builder b.Grow(len(md) + len(md)/4) diff --git a/core/markdown_html_test.go b/core/markdown_html_test.go index f8e9682..c55a63a 100644 --- a/core/markdown_html_test.go +++ b/core/markdown_html_test.go @@ -6,37 +6,37 @@ import ( "testing" ) -func TestMarkdownToTelegramHTML_Bold(t *testing.T) { - out := MarkdownToTelegramHTML("hello **world**") +func TestMarkdownToSimpleHTML_Bold(t *testing.T) { + out := MarkdownToSimpleHTML("hello **world**") if !strings.Contains(out, "world") { t.Errorf("expected world, got %q", out) } } -func TestMarkdownToTelegramHTML_Italic(t *testing.T) { - out := MarkdownToTelegramHTML("hello *world*") +func TestMarkdownToSimpleHTML_Italic(t *testing.T) { + out := MarkdownToSimpleHTML("hello *world*") if !strings.Contains(out, "world") { t.Errorf("expected world, got %q", out) } } -func TestMarkdownToTelegramHTML_Strikethrough(t *testing.T) { - out := MarkdownToTelegramHTML("hello ~~world~~") +func TestMarkdownToSimpleHTML_Strikethrough(t *testing.T) { + out := MarkdownToSimpleHTML("hello ~~world~~") if !strings.Contains(out, "world") { t.Errorf("expected world, got %q", out) } } -func TestMarkdownToTelegramHTML_InlineCode(t *testing.T) { - out := MarkdownToTelegramHTML("run `echo hello`") +func TestMarkdownToSimpleHTML_InlineCode(t *testing.T) { + out := MarkdownToSimpleHTML("run `echo hello`") if !strings.Contains(out, "echo hello") { t.Errorf("expected echo hello, got %q", out) } } -func TestMarkdownToTelegramHTML_CodeBlock(t *testing.T) { +func TestMarkdownToSimpleHTML_CodeBlock(t *testing.T) { md := "```go\nfmt.Println()\n```" - out := MarkdownToTelegramHTML(md) + out := MarkdownToSimpleHTML(md) if !strings.Contains(out, `
`) {
 		t.Errorf("expected language-go code block, got %q", out)
 	}
@@ -45,43 +45,43 @@ func TestMarkdownToTelegramHTML_CodeBlock(t *testing.T) {
 	}
 }
 
-func TestMarkdownToTelegramHTML_Link(t *testing.T) {
-	out := MarkdownToTelegramHTML("visit [Google](https://google.com)")
+func TestMarkdownToSimpleHTML_Link(t *testing.T) {
+	out := MarkdownToSimpleHTML("visit [Google](https://google.com)")
 	if !strings.Contains(out, `Google`) {
 		t.Errorf("expected link HTML, got %q", out)
 	}
 }
 
-func TestMarkdownToTelegramHTML_Heading(t *testing.T) {
-	out := MarkdownToTelegramHTML("## Section Title")
+func TestMarkdownToSimpleHTML_Heading(t *testing.T) {
+	out := MarkdownToSimpleHTML("## Section Title")
 	if !strings.Contains(out, "Section Title") {
 		t.Errorf("expected heading as bold, got %q", out)
 	}
 }
 
-func TestMarkdownToTelegramHTML_Blockquote(t *testing.T) {
-	out := MarkdownToTelegramHTML("> quoted text")
+func TestMarkdownToSimpleHTML_Blockquote(t *testing.T) {
+	out := MarkdownToSimpleHTML("> quoted text")
 	if !strings.Contains(out, "
quoted text
") { t.Errorf("expected blockquote, got %q", out) } } -func TestMarkdownToTelegramHTML_EscapesHTML(t *testing.T) { - out := MarkdownToTelegramHTML("x < y && y > z") +func TestMarkdownToSimpleHTML_EscapesHTML(t *testing.T) { + out := MarkdownToSimpleHTML("x < y && y > z") if !strings.Contains(out, "<") || !strings.Contains(out, ">") || !strings.Contains(out, "&") { t.Errorf("HTML special chars should be escaped, got %q", out) } } -func TestMarkdownToTelegramHTML_EscapesInsideBold(t *testing.T) { - out := MarkdownToTelegramHTML("**x < y**") +func TestMarkdownToSimpleHTML_EscapesInsideBold(t *testing.T) { + out := MarkdownToSimpleHTML("**x < y**") if !strings.Contains(out, "x < y") { t.Errorf("expected escaped content inside bold, got %q", out) } } -func TestMarkdownToTelegramHTML_LinkWithAmpersand(t *testing.T) { - out := MarkdownToTelegramHTML("click [here](https://example.com?a=1&b=2)") +func TestMarkdownToSimpleHTML_LinkWithAmpersand(t *testing.T) { + out := MarkdownToSimpleHTML("click [here](https://example.com?a=1&b=2)") if !strings.Contains(out, "&b=2") { t.Errorf("URL ampersand should be escaped, got %q", out) } @@ -90,8 +90,8 @@ func TestMarkdownToTelegramHTML_LinkWithAmpersand(t *testing.T) { } } -func TestMarkdownToTelegramHTML_LinkWithQuotesInURL(t *testing.T) { - out := MarkdownToTelegramHTML(`visit [book](https://example.com/q="test")`) +func TestMarkdownToSimpleHTML_LinkWithQuotesInURL(t *testing.T) { + out := MarkdownToSimpleHTML(`visit [book](https://example.com/q="test")`) if strings.Contains(out, `href="https://example.com/q="`) { t.Errorf("unescaped quote in href attribute, got %q", out) } @@ -103,8 +103,8 @@ func TestMarkdownToTelegramHTML_LinkWithQuotesInURL(t *testing.T) { } } -func TestMarkdownToTelegramHTML_EscapesQuotesInText(t *testing.T) { - out := MarkdownToTelegramHTML(`He said "hello" world`) +func TestMarkdownToSimpleHTML_EscapesQuotesInText(t *testing.T) { + out := MarkdownToSimpleHTML(`He said "hello" world`) if strings.Contains(out, `"hello"`) { t.Errorf("quotes in text should be escaped, got %q", out) } @@ -113,23 +113,23 @@ func TestMarkdownToTelegramHTML_EscapesQuotesInText(t *testing.T) { } } -func TestMarkdownToTelegramHTML_CodeBlockEscapesHTML(t *testing.T) { +func TestMarkdownToSimpleHTML_CodeBlockEscapesHTML(t *testing.T) { md := "```\nif a < b && c > d {\n}\n```" - out := MarkdownToTelegramHTML(md) + out := MarkdownToSimpleHTML(md) if !strings.Contains(out, "<") || !strings.Contains(out, ">") { t.Errorf("code block content should be HTML-escaped, got %q", out) } } -func TestMarkdownToTelegramHTML_InlineCodeEscapesHTML(t *testing.T) { - out := MarkdownToTelegramHTML("run `xz`") +func TestMarkdownToSimpleHTML_InlineCodeEscapesHTML(t *testing.T) { + out := MarkdownToSimpleHTML("run `xz`") if !strings.Contains(out, "x<y>z") { t.Errorf("inline code should escape HTML, got %q", out) } } -func TestMarkdownToTelegramHTML_MixedFormattingWithSpecialChars(t *testing.T) { - out := MarkdownToTelegramHTML("**bold** & *italic* < normal") +func TestMarkdownToSimpleHTML_MixedFormattingWithSpecialChars(t *testing.T) { + out := MarkdownToSimpleHTML("**bold** & *italic* < normal") if !strings.Contains(out, "bold") { t.Errorf("expected bold tag, got %q", out) } @@ -141,7 +141,7 @@ func TestMarkdownToTelegramHTML_MixedFormattingWithSpecialChars(t *testing.T) { } } -func TestMarkdownToTelegramHTML_NoCrossedTags(t *testing.T) { +func TestMarkdownToSimpleHTML_NoCrossedTags(t *testing.T) { tests := []struct { name string input string @@ -155,7 +155,7 @@ func TestMarkdownToTelegramHTML_NoCrossedTags(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - out := MarkdownToTelegramHTML(tt.input) + out := MarkdownToSimpleHTML(tt.input) // Check no crossed tags: every must close before enclosing
etc. // Simple check: no inside an block or vice versa if err := validateHTMLNesting(out); err != nil { diff --git a/platform/telegram/telegram.go b/platform/telegram/telegram.go index 7a90066..9f1c7f6 100644 --- a/platform/telegram/telegram.go +++ b/platform/telegram/telegram.go @@ -484,7 +484,7 @@ func (p *Platform) Reply(ctx context.Context, rctx any, content string) error { return fmt.Errorf("telegram: invalid reply context type %T", rctx) } - html := core.MarkdownToTelegramHTML(content) + html := core.MarkdownToSimpleHTML(content) reply := tgbotapi.NewMessage(rc.chatID, html) reply.ReplyToMessageID = rc.messageID reply.ParseMode = tgbotapi.ModeHTML @@ -509,7 +509,7 @@ func (p *Platform) Send(ctx context.Context, rctx any, content string) error { return fmt.Errorf("telegram: invalid reply context type %T", rctx) } - html := core.MarkdownToTelegramHTML(content) + html := core.MarkdownToSimpleHTML(content) msg := tgbotapi.NewMessage(rc.chatID, html) msg.ParseMode = tgbotapi.ModeHTML @@ -542,7 +542,7 @@ func (p *Platform) SendWithButtons(ctx context.Context, rctx any, content string rows = append(rows, btns) } - html := core.MarkdownToTelegramHTML(content) + html := core.MarkdownToSimpleHTML(content) msg := tgbotapi.NewMessage(rc.chatID, html) msg.ParseMode = tgbotapi.ModeHTML msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(rows...) @@ -633,7 +633,7 @@ func (p *Platform) UpdateMessage(ctx context.Context, previewHandle any, content return fmt.Errorf("telegram: invalid preview handle type %T", previewHandle) } - html := core.MarkdownToTelegramHTML(content) + html := core.MarkdownToSimpleHTML(content) slog.Debug("telegram: UpdateMessage", "content_len", len(content), "html_len", len(html), "content_prefix", truncateForLog(content, 80),