Files
chenhg5-cc-connect/core/interfaces.go
Yu Zhang 1bd0d95294 feat: add agent-driven TTS send support (#1230)
* feat: add agent-driven TTS send support

* test: cover tts error contracts
2026-06-09 23:30:30 +08:00

637 lines
27 KiB
Go

package core
import (
"context"
"errors"
"time"
)
// Platform abstracts a messaging platform (Feishu, DingTalk, Slack, etc.).
type Platform interface {
Name() string
Start(handler MessageHandler) error
Reply(ctx context.Context, replyCtx any, content string) error
Send(ctx context.Context, replyCtx any, content string) error
Stop() error
}
// ErrNotSupported indicates a platform doesn't support a particular operation.
var ErrNotSupported = errors.New("operation not supported by this platform")
// ReplyContextReconstructor is an optional interface for platforms that can
// recreate a reply context from a session key. This is needed for cron jobs
// to send messages to users without an incoming message.
type ReplyContextReconstructor interface {
ReconstructReplyCtx(sessionKey string) (any, error)
}
// MessageRecallDetector is an optional interface for platforms that can check
// whether the message targeted by a reply context was recalled/deleted.
type MessageRecallDetector interface {
IsMessageRecalled(ctx context.Context, replyCtx any) (bool, error)
}
// CronReplyTargetResolver is an optional interface for platforms that need to
// map a logical cron session key to the actual reply target used at execution
// time. This is useful for platforms where proactive replies may need to create
// or switch to a thread before the cron run starts.
//
// Implementations that do not need special handling should return
// ErrNotSupported so callers can fall back to ReconstructReplyCtx(sessionKey).
type CronReplyTargetResolver interface {
ResolveCronReplyTarget(sessionKey string, title string) (resolvedSessionKey string, replyCtx any, err error)
}
// SessionEnvInjector is an optional interface for agents that accept
// per-session environment variables (e.g. CC_PROJECT, CC_SESSION_KEY).
type SessionEnvInjector interface {
SetSessionEnv(env []string)
}
// FormattingInstructionProvider is an optional interface for platforms that
// provide platform-specific formatting instructions for the agent system prompt
// (e.g., Slack mrkdwn vs standard Markdown).
type FormattingInstructionProvider interface {
FormattingInstructions() string
}
// PlatformPromptInjector is an optional interface for agents that can receive
// platform-specific prompt fragments (e.g., formatting instructions).
// The engine calls this before StartSession when the platform provides formatting.
type PlatformPromptInjector interface {
SetPlatformPrompt(prompt string)
}
// AgentSystemPrompt returns the system prompt fragment that informs agents about
// cc-connect capabilities (cron scheduling, etc.).
// The prompt is designed to be appended to the agent's existing system prompt.
func AgentSystemPrompt() string {
return `You are running inside cc-connect, a bridge that connects you to messaging platforms.
Your normal text responses are automatically delivered to the user — just reply normally, do NOT use cc-connect send for ordinary text replies.
## Available tools
### Send generated images, files, or voice messages back to the user
When you generate a local image or file that should be sent to the user, use:
cc-connect send --image /absolute/path/to/image.png
cc-connect send --file /absolute/path/to/report.pdf
cc-connect send --file /absolute/path/to/report.pdf --image /absolute/path/to/chart.png
You may repeat --image / --file multiple times. Use this only for generated attachments that need to be delivered to the user.
If you include --message, do not repeat the exact same sentence again in your normal reply, because your normal reply is also delivered automatically.
When the user explicitly asks you to send a voice/audio reply, synthesize and send it with:
cc-connect send --tts "text to speak"
After this command succeeds, reply only with NO_REPLY unless the user also asked for a visible text confirmation. This prevents sending an extra text message after the voice message.
### Scheduled tasks (cron)
When the user asks you to do something on a schedule (e.g. "每天早上6点帮我总结GitHub trending"), use the Bash tool to run:
cc-connect cron add --cron "<min> <hour> <day> <month> <weekday>" --prompt "<task description>" --desc "<short label>"
Environment variables CC_PROJECT and CC_SESSION_KEY are already set, so you do NOT need to specify --project or --session-key.
Optional flags:
--session-mode <mode> reuse (default) or new-per-run (fresh session each trigger)
--timeout-mins <n> max wait per run in minutes (default 30, 0 = unlimited)
--exec <command> run a shell command directly instead of --prompt
Examples:
cc-connect cron add --cron "0 6 * * *" --prompt "Collect GitHub trending repos and send a summary" --desc "Daily GitHub Trending"
cc-connect cron add --cron "0 9 * * 1" --prompt "Generate a weekly project status report" --desc "Weekly Report"
cc-connect cron add --cron "*/2 * * * *" --exec "ipconfig" --session-mode new-per-run --desc "Every 2 min ipconfig"
You can also list, inspect, run, edit, or delete cron jobs:
cc-connect cron list
cc-connect cron info <job-id> [field]
cc-connect cron exec <job-id>
cc-connect cron edit <job-id> <field> <value>
cc-connect cron del <job-id>
When changing an existing job, first run ` + "`cc-connect cron info <job-id>`" + ` to inspect the current values, then use ` + "`cron edit`" + ` for only the field(s) the user asked to change.
Use ` + "`cron exec <job-id>`" + ` to run an existing scheduled task immediately; this is different from the ` + "`--exec <command>`" + ` flag used when creating a shell-command cron job.
Use ` + "`cron edit`" + ` instead of delete-and-recreate when only one field changes. Do not delete and recreate a job unless the user explicitly asks to replace it.
Common editable fields:
cron_expr new schedule, e.g. "0 9 * * *"
prompt new task prompt (or ` + "`exec`" + ` for shell command)
description short label
enabled true / false (pause without deleting)
mute true / false (silence all messages)
timeout_mins integer minutes (0 = unlimited)
Run ` + "`cc-connect cron edit --help`" + ` for the full field list.
Examples:
cc-connect cron exec abc123
cc-connect cron edit abc123 cron_expr "0 9 * * *"
cc-connect cron edit abc123 enabled false
cc-connect cron edit abc123 prompt "Updated daily summary task"
### Bot-to-bot relay
When you need to communicate with another bot (e.g. ask another AI agent a question), use:
cc-connect relay send --to <target_project> "<message>"
IMPORTANT: <target_project> must be the EXACT project name from the /bind command output.
Do NOT guess or modify the name — use it exactly as shown (e.g. "gemini", not "gemini-bot").
This sends a message to the target bot and waits for its response (printed to stdout).
The conversation is visible in the group chat and each bot maintains its own relay session.
Environment variables CC_PROJECT and CC_SESSION_KEY are already set, so the relay knows which group chat to use.
### Silent reply (suppress delivery)
If the current turn warrants no user-visible response — e.g. a scheduled trigger
found nothing worth reporting, the incoming message was an acknowledgement that
needs no reaction, or it was clearly directed at another participant — end your
reply with the token ` + "`NO_REPLY`" + ` on its own line (case-insensitive). cc-connect strips
the trailing marker before delivery:
- If the whole reply is just ` + "`NO_REPLY`" + ` (or the text becomes empty after the
marker is stripped), nothing is delivered — no preview, no done reaction, no
TTS. Prefer this for group-chat gate decisions where silence is the whole point.
- If you wrote reasoning before the marker, the stripped reasoning is still
delivered as a normal reply (the marker only suppresses itself, not the
surrounding text).
Use this sparingly; when in doubt, send a brief reply instead.
`
}
// SystemPromptSupporter is an optional marker interface for agents that
// natively inject AgentSystemPrompt() (e.g., via --append-system-prompt).
// Agents that do NOT implement this need the instructions written to their
// memory/instruction file for relay and cron to work.
type SystemPromptSupporter interface {
HasSystemPromptSupport() bool
}
// SessionIDValidator is an optional interface for agents that can validate
// whether a stored session ID actually belongs to the current project's
// session store. The engine uses this to prevent cross-project session
// context leakage (issue #599): a stale ID from another project's workspace
// would otherwise resume the wrong conversation history.
//
// Implementations should return false when:
// - the session ID is empty
// - the session file does not exist under the agent's per-project store
// - the agent cannot determine the current project directory
//
// The engine treats a false return as "clear the stored ID and start fresh".
type SessionIDValidator interface {
ValidateSessionID(ctx context.Context, sessionID string) bool
}
// TypingIndicator is an optional interface for platforms that can show a
// "processing" indicator (typing bubble, emoji reaction, etc.) while the
// agent is working. StartTyping is called when processing begins and returns
// a stop function that the caller must invoke when processing ends.
type TypingIndicator interface {
StartTyping(ctx context.Context, replyCtx any) (stop func())
}
// TypingIndicatorDone is an optional interface for platforms that can show a
// "done" reaction after processing completes. The engine calls AddDoneReaction
// when the agent finishes a multi-round turn in quiet mode, so the user gets
// a push notification (e.g. Feishu card edits don't trigger pushes).
type TypingIndicatorDone interface {
AddDoneReaction(replyCtx any)
}
// AtMentionSender is an optional interface for platforms that support @mention in
// reply messages (e.g. DingTalk). Platforms that implement this interface can
// include @user notifications when replying in group chats.
type AtMentionSender interface {
ReplyWithAt(ctx context.Context, replyCtx any, content string, atUsers []string, atAll bool) error
}
// ImageSender is an optional interface for platforms that support sending images.
type ImageSender interface {
SendImage(ctx context.Context, replyCtx any, img ImageAttachment) error
}
// FileSender is an optional interface for platforms that support sending files.
type FileSender interface {
SendFile(ctx context.Context, replyCtx any, file FileAttachment) error
}
// MessageUpdater is an optional interface for platforms that support updating messages.
type MessageUpdater interface {
UpdateMessage(ctx context.Context, replyCtx any, content string) error
}
// StatusFooterSender is an optional Platform extension for sending a reply
// with a structured per-turn status footer rendered using platform-specific
// dim/small styling (e.g. Lark `text_size: "notation"`). Platforms that do
// not implement it fall back to receiving the footer appended inline to the
// content via Send/SendWithButtons/...
type StatusFooterSender interface {
SendWithStatusFooter(ctx context.Context, replyCtx any, content, footer string) error
}
// StatusFooterUpdater is the streaming-preview counterpart of
// StatusFooterSender: it patches an existing preview message with a final
// content + structured status footer block.
type StatusFooterUpdater interface {
UpdateMessageWithStatusFooter(ctx context.Context, replyCtx any, content, footer string) error
}
// ProgressStyleProvider is an optional interface for platforms that expose
// a preferred style for intermediate progress rendering.
// Typical values: "legacy", "compact", "card".
type ProgressStyleProvider interface {
ProgressStyle() string
}
// ProgressCardPayloadSupport is an optional interface for platforms that can
// parse and render structured progress-card payloads.
type ProgressCardPayloadSupport interface {
SupportsProgressCardPayload() bool
}
// ProgressUpdateThrottler is an optional interface for platforms that need
// rate-limited progress edits (e.g. Discord's ~5 edits / 5s per channel).
type ProgressUpdateThrottler interface {
ProgressUpdateInterval() time.Duration
}
// ButtonOption represents a clickable inline button.
type ButtonOption struct {
Text string // display text on the button
Data string // callback data returned when clicked (≤64 bytes for Telegram)
}
// InlineButtonSender is an optional interface for platforms that support
// sending messages with clickable inline buttons (e.g. Telegram Inline Keyboard).
// Buttons is a 2D slice: each inner slice is one row of buttons.
type InlineButtonSender interface {
SendWithButtons(ctx context.Context, replyCtx any, content string, buttons [][]ButtonOption) error
}
// CardSender is an optional interface for platforms that support sending
// structured rich cards (e.g. Feishu Interactive Card). Platforms that do not
// implement this interface will receive a plain-text fallback via Card.RenderText().
type CardSender interface {
SendCard(ctx context.Context, replyCtx any, card *Card) error
ReplyCard(ctx context.Context, replyCtx any, card *Card) error
}
// CardNavigationHandler is called by platforms to render a card for in-place
// card updates (e.g. Feishu card.action.trigger callback). The action string
// uses prefixes like "nav:/model" or "act:/model 3".
type CardNavigationHandler func(action string, sessionKey string) *Card
// CardNavigable is an optional interface for platforms that support in-place
// card navigation (updating the existing card instead of sending a new message).
type CardNavigable interface {
SetCardNavigationHandler(h CardNavigationHandler)
}
// CardRefresher is an optional interface for platforms that can update a
// previously rendered card in-place after the original callback has returned.
// This is used when async operations (e.g. delete-mode deletion) need to
// refresh a "loading" card with the final result. Platforms that implement
// this interface should track the message ID from card action callbacks and
// use it to patch the card content.
type CardRefresher interface {
RefreshCard(ctx context.Context, sessionKey string, card *Card) error
}
// PlatformLifecycleHandler receives readiness state transitions from async
// recoverable platforms.
type PlatformLifecycleHandler interface {
OnPlatformReady(p Platform)
OnPlatformUnavailable(p Platform, err error)
}
// AsyncRecoverablePlatform is an optional interface for platforms that start
// a background recovery loop and later report readiness or unavailability.
//
// Platforms implementing this interface may return from Start() before they are
// actually ready to receive traffic. Callers must treat OnPlatformReady as the
// signal that deferred platform capabilities may be initialized and the
// platform is usable. A nil Start() return therefore means the recovery loop
// was launched successfully, not necessarily that an initial connection was
// established.
type AsyncRecoverablePlatform interface {
Platform
SetLifecycleHandler(h PlatformLifecycleHandler)
}
// MessageHandler is called by platforms when a new message arrives.
type MessageHandler func(p Platform, msg *Message)
// Agent abstracts an AI coding assistant (Claude Code, Cursor, Gemini CLI, etc.).
// All agents must support persistent bidirectional sessions via StartSession.
type Agent interface {
Name() string
// StartSession creates or resumes an interactive session with a persistent process.
StartSession(ctx context.Context, sessionID string) (AgentSession, error)
// ListSessions returns sessions known to the agent backend.
ListSessions(ctx context.Context) ([]AgentSessionInfo, error)
Stop() error
}
// AgentSession represents a running interactive agent session with a persistent process.
type AgentSession interface {
// Send sends a user message (with optional images and files) to the running agent process.
Send(prompt string, images []ImageAttachment, files []FileAttachment) error
// RespondPermission sends a permission decision back to the agent process.
RespondPermission(requestID string, result PermissionResult) error
// Events returns the channel that emits agent events (kept open across turns).
Events() <-chan Event
// CurrentSessionID returns the current agent-side session ID.
CurrentSessionID() string
// Alive returns true if the underlying process is still running.
Alive() bool
// Close terminates the session and its underlying process.
Close() error
}
// PermissionResult represents the user's decision on a permission request.
type PermissionResult struct {
Behavior string `json:"behavior"` // "allow" or "deny"
UpdatedInput map[string]any `json:"updatedInput,omitempty"` // echoed back for allow
Message string `json:"message,omitempty"` // reason for deny
}
// ToolAuthorizer is an optional interface for agents that support dynamic tool authorization.
type ToolAuthorizer interface {
AddAllowedTools(tools ...string) error
GetAllowedTools() []string
}
// HistoryProvider is an optional interface for agents that can retrieve
// conversation history from their backend session files.
type HistoryProvider interface {
GetSessionHistory(ctx context.Context, sessionID string, limit int) ([]HistoryEntry, error)
}
// ProviderConfig holds API provider settings for an agent.
type ProviderConfig struct {
Name string
APIKey string
BaseURL string
Model string
Models []ModelOption // pre-configured list of available models for this provider
Thinking string // override thinking type sent to this provider ("disabled", "enabled", or "" for no rewrite)
Env map[string]string // arbitrary extra env vars (e.g. CLAUDE_CODE_USE_BEDROCK=1)
// Codex-specific provider config (maps to Codex model_providers.<name>)
CodexWireAPI string // wire API format (e.g. "responses")
CodexHTTPHeaders map[string]string // custom HTTP headers
}
// ProviderSwitcher is an optional interface for agents that support multiple API providers.
type ProviderSwitcher interface {
SetProviders(providers []ProviderConfig)
SetActiveProvider(name string) bool
GetActiveProvider() *ProviderConfig
ListProviders() []ProviderConfig
}
// MemoryFileProvider is an optional interface for agents that support
// persistent instruction files (CLAUDE.md, AGENTS.md, GEMINI.md, etc.).
// The engine uses these paths for the /memory command.
type MemoryFileProvider interface {
ProjectMemoryFile() string // project-level instruction file (e.g., <work_dir>/CLAUDE.md)
GlobalMemoryFile() string // user-level instruction file (e.g., ~/.claude/CLAUDE.md)
}
// ModelSwitcher is an optional interface for agents that support runtime model switching.
// Model changes take effect on the next session (existing sessions keep their model).
type ModelSwitcher interface {
SetModel(model string)
GetModel() string
// AvailableModels tries to fetch models from the provider API.
// Falls back to a built-in list on failure.
AvailableModels(ctx context.Context) []ModelOption
}
// ReasoningEffortSwitcher is an optional interface for agents that support
// runtime switching of reasoning effort.
type ReasoningEffortSwitcher interface {
SetReasoningEffort(effort string)
GetReasoningEffort() string
AvailableReasoningEfforts() []string
}
// ModelOption describes a selectable model.
type ModelOption struct {
Name string // model identifier passed to CLI
Desc string // short description (display_name or empty)
Alias string // optional short alias for the /model command (e.g. "codex" for "gpt-5.3-codex")
}
// UsageReporter is an optional interface for agents that can report account or
// model quota usage from their backing provider.
type UsageReporter interface {
GetUsage(ctx context.Context) (*UsageReport, error)
}
// UsageReport is a provider-neutral quota snapshot returned by UsageReporter.
type UsageReport struct {
Provider string
AccountID string
UserID string
Email string
Plan string
Buckets []UsageBucket
Credits *UsageCredits
}
// UsageBucket groups one logical quota, such as standard requests or code review.
type UsageBucket struct {
Name string
Allowed bool
LimitReached bool
Windows []UsageWindow
}
// UsageWindow describes a single quota window.
type UsageWindow struct {
Name string
UsedPercent int
WindowSeconds int
ResetAfterSeconds int
ResetAtUnix int64
}
// UsageCredits contains optional credit/balance metadata.
type UsageCredits struct {
HasCredits bool
Unlimited bool
Balance string
}
// ContextUsageReporter is an optional interface for running agent sessions that
// can report real runtime context usage for the active conversation.
type ContextUsageReporter interface {
GetContextUsage() *ContextUsage
}
// ContextUsage describes runtime context consumption for the active session.
type ContextUsage struct {
// UsedTokens is the current token load to compare against ContextWindow when
// computing remaining context capacity for the next turn.
UsedTokens int
// BaselineTokens is the portion of the context window always occupied by
// fixed runtime/system instructions and therefore excluded from user-visible
// "left" calculations when the agent provides it.
BaselineTokens int
TotalTokens int
InputTokens int
CachedInputTokens int // cache-read tokens (prior context retrieved from cache)
CacheCreationInputTokens int // cache-write tokens (new content written to cache)
OutputTokens int
ReasoningOutputTokens int
ContextWindow int
}
// ContextCompressor is an optional interface for agents that support
// compressing/compacting the conversation context within a running session.
// CompressCommand returns the native slash command (e.g. "/compact", "/compress")
// that will be forwarded to the agent process. Return "" if not supported.
type ContextCompressor interface {
CompressCommand() string
}
// CommandProvider is an optional interface for agents that expose custom slash
// commands via local files (e.g. .claude/commands/*.md). The engine scans the
// returned directories for *.md files and registers them as slash commands.
type CommandProvider interface {
CommandDirs() []string
}
// SkillProvider is an optional interface for agents that expose skills via
// local directories (e.g. .claude/skills/<name>/SKILL.md). Each subdirectory
// containing a SKILL.md is treated as a skill. Skills are project-level and
// agent-specific — they are NOT shared across different agent types.
type SkillProvider interface {
SkillDirs() []string
}
// SessionDeleter is an optional interface for agents that support deleting sessions.
type SessionDeleter interface {
DeleteSession(ctx context.Context, sessionID string) error
}
type SessionTitleProvider interface {
GetSessionTitle(sessionID string) string
}
// WorkDirSwitcher is an optional interface for agents that support runtime
// work directory switching. The change takes effect on the next session start;
// the current running session is terminated automatically by the engine.
type WorkDirSwitcher interface {
SetWorkDir(dir string)
GetWorkDir() string
}
// AgentOptsProvider is an optional interface for agents that need to carry
// their full configuration options when the engine clones a per-workspace
// agent instance in multi-workspace mode. The engine merges the returned map
// into the workspace opts before calling the agent factory, giving workspace
// agents access to agent-specific options (e.g. "session" for the tmux agent)
// that are not covered by the standard GetModel / GetMode accessors.
// work_dir is always overridden by the engine and must not be returned here.
type AgentOptsProvider interface {
BaseOpts() map[string]any
}
// ModeSwitcher is an optional interface for agents that support runtime permission mode switching.
type ModeSwitcher interface {
SetMode(mode string)
GetMode() string
PermissionModes() []PermissionModeInfo
}
// WorkspaceAgentOptionSnapshotter is an optional interface for agents that can
// export reusable constructor options needed to recreate an equivalent agent in
// a different workspace. Snapshot values should omit work_dir; the caller is
// responsible for setting the target workspace explicitly. Provider wiring and
// run_as propagation may still be handled separately by the engine.
type WorkspaceAgentOptionSnapshotter interface {
WorkspaceAgentOptions() map[string]any
}
// LiveModeSwitcher is an optional interface for running agent sessions that can
// apply a mode change immediately without restarting the process.
type LiveModeSwitcher interface {
SetLiveMode(mode string) bool
}
// StartupWarner is an optional interface for agent sessions that need to surface
// a one-time warning to the IM user at session start (e.g. when a requested
// permission mode was silently downgraded due to OS constraints). The engine
// sends the returned message to the IM platform immediately after starting the
// session. Returns empty string when no warning is needed.
type StartupWarner interface {
StartupWarning() string
}
// PermissionModeInfo describes a permission mode for display.
type PermissionModeInfo struct {
Key string
Name string
NameZh string
Desc string
DescZh string
}
// BotCommandInfo represents a command for bot menu registration (e.g. Telegram setMyCommands).
type BotCommandInfo struct {
Command string // command name without leading "/"
Description string // short description for the menu
IsSkill bool // whether this entry comes from a skill
}
// CommandRegistrar is an optional interface for platforms that support
// registering commands to the platform's native menu (e.g. Telegram's setMyCommands).
type CommandRegistrar interface {
RegisterCommands(commands []BotCommandInfo) error
}
// ChannelNameResolver is an optional interface for platforms that can resolve
// channel IDs to human-readable names.
type ChannelNameResolver interface {
ResolveChannelName(channelID string) (string, error)
}
// StreamingCard represents an active streaming card that aggregates
// an entire agent turn (tool calls, thinking, text) into a single
// updatable message.
type StreamingCard interface {
// Update replaces the card content with the given markdown.
// Implementations should throttle calls internally.
Update(ctx context.Context, content string) error
// Finalize sends the final content and marks the card as complete.
Finalize(ctx context.Context, content string) error
// Failed returns true if the card has entered a failed state.
Failed() bool
}
// StreamingCardPlatform is an optional interface for platforms that support
// aggregating an entire agent turn into a single updatable card message
// (e.g. DingTalk AI Card). When the engine detects this interface, it
// creates a streaming card at the start of each turn and routes all
// events through it instead of sending individual messages.
type StreamingCardPlatform interface {
CreateStreamingCard(ctx context.Context, replyCtx any) (StreamingCard, error)
}
// CardStatus represents the visual status of a card header.
type CardStatus string
const (
CardStatusThinking CardStatus = "thinking" // grey
CardStatusWorking CardStatus = "working" // blue
CardStatusDone CardStatus = "done" // green
CardStatusError CardStatus = "error" // red
)
// PreviewStatusUpdater is an optional interface for platforms that support
// updating the visual status of a preview card header.
type PreviewStatusUpdater interface {
SetPreviewStatus(previewHandle any, status CardStatus)
}