refactor(core): replace hardcoded agent checks with interface and improve platform-agnostic design

- Replace hardcoded agent binary checks with AgentDoctorInfo interface
- Remove platform-specific logic from help card rendering
- Rename MarkdownToTelegramHTML to MarkdownToSimpleHTML for broader use
- Clean up core engine code to avoid platform/agent name hardcoding
- Add CLIBinaryName and CLIDisplayName methods to agent implementations
- Consolidate help card rendering logic to be platform-agnostic

generated by llmgit

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
chenhg5andClaude committed 2026-03-13 17:21:14 +08:00
1 parent b710179e6f
commit 645aaebd1c
10 files changed
+485 -97

No files matched your search

+200
View File
@@ -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
+200
View File
@@ -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
+3 -1
View File
@@ -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()
+3 -1
View File
@@ -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()
+3 -1
View File
@@ -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()
+23 -32
View File
@@ -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 {
+11 -19
View File
@@ -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":
+4 -5
View File
@@ -5,11 +5,10 @@ import (
"strings"
)
// MarkdownToTelegramHTML converts common Markdown to Telegram-compatible HTML.
// Telegram supports: <b>, <i>, <s>, <code>, <pre>, <a href="">, <blockquote>.
// 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: <b>, <i>, <s>, <code>, <pre>, <a href="">, <blockquote>.
// 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)
+34 -34
View File
@@ -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, "<b>world</b>") {
t.Errorf("expected <b>world</b>, 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, "<i>world</i>") {
t.Errorf("expected <i>world</i>, 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, "<s>world</s>") {
t.Errorf("expected <s>world</s>, 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, "<code>echo hello</code>") {
t.Errorf("expected <code>echo hello</code>, 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, `<pre><code class="language-go">`) {
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, `<a href="https://google.com">Google</a>`) {
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, "<b>Section Title</b>") {
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, "<blockquote>quoted text</blockquote>") {
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, "&lt;") || !strings.Contains(out, "&gt;") || !strings.Contains(out, "&amp;") {
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, "<b>x &lt; y</b>") {
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, "&amp;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, "&lt;") || !strings.Contains(out, "&gt;") {
t.Errorf("code block content should be HTML-escaped, got %q", out)
}
}
func TestMarkdownToTelegramHTML_InlineCodeEscapesHTML(t *testing.T) {
out := MarkdownToTelegramHTML("run `x<y>z`")
func TestMarkdownToSimpleHTML_InlineCodeEscapesHTML(t *testing.T) {
out := MarkdownToSimpleHTML("run `x<y>z`")
if !strings.Contains(out, "<code>x&lt;y&gt;z</code>") {
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, "<b>bold</b>") {
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 <b> must close before enclosing </i> etc.
// Simple check: no </b> inside an <i> block or vice versa
if err := validateHTMLNesting(out); err != nil {
+4 -4
View File
@@ -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),