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:
1 parent
b710179e6f
commit
645aaebd1c
10 files changed
+485
-97
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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":
|
||||
|
||||
@@ -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
@@ -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, "<") || !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, "<b>x < 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, "&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 `x<y>z`")
|
||||
func TestMarkdownToSimpleHTML_InlineCodeEscapesHTML(t *testing.T) {
|
||||
out := MarkdownToSimpleHTML("run `x<y>z`")
|
||||
if !strings.Contains(out, "<code>x<y>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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user