diff --git a/CHANGELOG.md b/CHANGELOG.md index 29c43a2319..cb64b21eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Changelog +# Changelog ## Unreleased @@ -20,11 +20,25 @@ - **feishu**: coalesce consecutive image messages from the same session into a single multi-image dispatch to fix first-image drop on batch sends (#1395). When the Feishu mobile client sends N images in quick succession, each image arrives as a separate `image` event with very close `create_time` values. Dispatching each immediately caused core/engine's `create_time` watermark (PR #1168) to drop the oldest image, so the agent only saw N-1 images. A per-session image buffer with a 150ms quiet window now merges the burst into one `core.Message` carrying all images, in send order. Single-image sends and quoted-image replies are unaffected. - **claudecode**: fix per-spawn system-prompt temp file EACCES under `run_as_user` (#1429). The per-spawn temp file written by `writeTempAppendPromptFile` (the 1% edge-case path used when the prompt has session-specific platform formatting or user `append_system_prompt`) inherited `os.CreateTemp`'s 0600 mode and was owned by the cc-connect process user (often root under systemd). When the agent was spawned under a different `run_as_user`, it could not read the file and exited before any prompt was loaded. The file is now `chmod 0o644` immediately after write, matching the shared `ensureSharedSystemPromptFile` path. Prompt content is non-secret (a superset of the already-shared base prompt), so 0644 is consistent with the shared file. Does not affect the shared-file path (already 0644 since #1376) or the daemon-mode path resolution (#1419). - **kimi**: gate `--work-dir` flag on `kimiFlagSupport.WorkDir` so the Kimi Code CLI build that dropped the flag no longer exits with `error: unknown option --work-dir` (#1476). Same probe-based pattern as the `--print` fix in #1456 / PR #1461: the agent probes `kimi --help` once at construction, then `buildArgs` emits the flag only when the installed CLI advertises it. The agent still runs in the correct directory because `exec.Command.Dir` is set separately, so legacy kimi-cli users (who still have `--work-dir`) keep non-default workspace support while modern-CLI users get clean startup. +- **kimi**: native Kimi Code CLI (Node.js `kimi-code`) dialect support (#1561). The newer CLI removed `--quiet` and `--resume`, moved the session resume hint from a plain-text line to a stdout JSON meta event (`{"role":"meta","type":"session.resume_hint",...}`), and emits assistant/tool `content` as a plain string instead of typed blocks — causing `error: unknown option`, silently dropped replies, and broken multi-turn continuity. The probe now also gates `--quiet` (emulated locally by suppressing thinking/tool events when unsupported), resume uses `-r ` on the modern dialect (the command the CLI's own hint prints), the meta resume-hint event restores the session ID, and the stream parser accepts both content shapes regardless of probed flavor. Session listing additionally scans `~/.kimi-code/sessions` with its `state.json` schema (`title`/`workDir`, honoring the workDir filter) and counts messages from `agents/main/wire.jsonl` so `/list` and `/delete` work for both CLI flavors. Legacy kimi-cli behavior is unchanged. - **core**: queue post-restart notification and dispatch on platform ready (#1383). Previously `/restart` sent the success notification immediately after engine startup, racing the platform's async connect window (Telegram: ~2.6s). On a not-yet-ready platform the send was silently dropped at debug log level. The notify is now queued on the engine and dispatched when the target platform reaches `OnPlatformReady`, with bounded retry (3 attempts, 0/500/1500 ms backoff) on transient send failure. Failed sends log at warn level. A 10s safety timeout drops the notify with a warning if the target platform never reaches ready, so startup is never blocked indefinitely. Also covers Discord / Weixin / Matrix (other AsyncRecoverablePlatform implementations) for free. - **core**: `SaveFilesToDisk` / `AppendFileRefs` always emit absolute paths (#1459). When a user configured a relative `work_dir` (e.g. `~/project` or `.cc-connect`), `SaveFilesToDisk` joined relative paths into the attachments directory and the resulting paths were passed verbatim into the agent's prompt. The spawned agent process — typically run from a different cwd by the platform adapter — could not resolve them and silently dropped every attachment. `SaveFilesToDisk` now calls `filepath.Abs(workDir)` up front and falls back to the raw value on error, and `AppendFileRefs` defensively absolutizes each entry. Both behaviors are covered by new tests for relative, absolute, and empty workDir; the empty-workDir case falls back to the process cwd so misconfigured deploys still get a writable attachments directory. - **core**: prevent same-name file attachments from overwriting each other. `SaveFilesToDisk` now scopes files by message ID, keeps duplicate names distinct within one message, and uses an atomic no-overwrite fallback for legacy callers without a message ID (#1552). +## Unreleased + +### Fixed +- **/model switch confirmation copy**: `MsgModelChanged` now explicitly states the + new model applies to the current session and all future sessions, removing the + misleading "new sessions" wording that made users think they needed `/new` to + see the change take effect (#1368). + +## Unreleased + +### Fixed +- **DingTalk message list title**: derive the `markdown.title` field on outgoing DingTalk messages from the message content (markdown stripped, first non-empty line, truncated to 20 runes) instead of the hardcoded `"reply"`. The DingTalk chat list now shows the actual reply preview instead of "reply" on every entry. Empty / pure-format / pure-emoji messages still fall back to "reply" so the title is never blank (#1269). + ## v1.3.3 (2026-06-15) First stable release of the 1.3.3 series. Stabilizes the v1.3.3-beta.1 → v1.3.3-beta.5 diff --git a/Makefile b/Makefile index 6feac2a496..c5654c5a99 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ PLATFORMS := \ ALL_AGENTS := acp antigravity claudecode codex copilot cursor devin gemini iflow kimi opencode pi qoder tmux ALL_PLATFORMS := feishu telegram discord slack dingtalk wecom weixin qq qqbot line weibo max matrix webex wps-agentspace tuitui ALL_AGENTS := acp antigravity claudecode codex copilot cursor devin gemini iflow kimi opencode pi qoder reasonix tmux -ALL_PLATFORMS := feishu telegram discord slack dingtalk wecom weixin qq qqbot line weibo max matrix webex cloud_web tuitui +ALL_PLATFORMS := feishu telegram discord slack dingtalk wecom weixin qq qqbot line weibo max matrix webex cloud_web tuitui googlechat ALL_EXTRAS := web COMMA := , diff --git a/README.md b/README.md index 70842c89d7..3a58c031b8 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,91 @@ High-level view of what each **built-in platform** can do in cc-connect.

+## 📋 Prerequisites + +> **Install in this exact order** — cc-connect is a bridge for local AI coding agents, so the agent CLI must be installed and authenticated *before* cc-connect starts. Skipping ahead will cause `cc-connect` to exit with `claudecode: claude CLI not found in PATH` (or similar for your chosen agent), and the Web UI on `:9820` will never come up. + +### 1️⃣ Install your AI Agent CLI + +Pick the agent you want to bridge. You need **at least one**. + +```bash +# Claude Code (most common) +brew install --cask claude-code # macOS / Linux Homebrew +# or +npm install -g @anthropic-ai/claude-code # any platform via npm + +# OpenAI Codex +npm install -g @openai/codex + +# Google Gemini CLI +npm install -g @google/gemini-cli + +# iFlow CLI +npm install -g @iflow-ai/iflow-cli + +# Qoder CLI +curl -fsSL https://qoder.com/install | bash +``` + +For **Cursor Agent** and **OpenCode**, follow the official install pages: +- Cursor Agent: +- OpenCode: + +Verify the binary is on your `PATH`: + +```bash +claude --version # or: codex / gemini / opencode / qodercli / cursor-agent ... +``` + +### 2️⃣ Authenticate the agent + +Each agent has its own login flow — run the agent once interactively so it stores credentials in your home directory: + +```bash +claude login # opens a browser to authenticate +# or +codex login # /gemini / opencode auth — see the agent's docs +``` + +If you skip this step, `cc-connect` will still start, but the agent will reject every prompt with an auth error. + +### 3️⃣ Install cc-connect + +```bash +# npm (any platform) +npm install -g cc-connect + +# Homebrew (macOS / Linux) +brew install cc-connect + +# Or download a binary from https://github.com/chenhg5/cc-connect/releases +``` + +### 4️⃣ Start cc-connect and open the Web UI + +```bash +cc-connect # starts the service; first run auto-creates ~/.cc-connect/config.toml +``` + +On first launch, cc-connect prints something like: + +``` +Web admin: http://localhost:9820 +``` + +Open that URL in your browser. If `9820` is already in use, pass `--web-port 9821` or set `web_port` in `config.toml`. + +> **Note:** `cc-connect web` *only* opens the browser and the config UI — it does **not** start the service. You still need `cc-connect` running in another terminal. + +### 5️⃣ Configure platform bot tokens in the Web UI + +In the Web UI, create a project, then add at least one platform (Feishu / Telegram / Discord / Slack / DingTalk / WeChat Work / QQ / LINE / Weixin) and paste the bot token from that platform's developer console. Save and cc-connect will hot-reload. + +That's it — send a message to your bot and cc-connect will relay it to your local agent. + +--- + ## 🚀 Quick Start ### 🤖 Install & Configure via AI Agent (Recommended) @@ -401,6 +486,7 @@ cc-connect update --pre # Include pre-releases | WPS Xiezuo | [docs/wps-xiezuo.md](docs/wps-xiezuo.md) | WebSocket | No | | Telegram | [docs/telegram.md](docs/telegram.md) | Long Polling | No | | Slack | [docs/slack.md](docs/slack.md) | Socket Mode | No | +| Google Chat | [docs/googlechat.md](docs/googlechat.md) | Cloud Pub/Sub | No | | Discord | [docs/discord.md](docs/discord.md) | Gateway | No | | Weibo | [docs/weibo.md](docs/weibo.md) | WebSocket | No | | WeChat Work | [docs/wecom.md](docs/wecom.md) | WebSocket / Webhook | No (WS) / Yes (Webhook) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 14f9c9e69d..798f1adeec 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -275,6 +275,91 @@

+## 📋 准备工作 + +> **请严格按照以下顺序安装** — cc-connect 是本地 AI 编程 Agent 的桥接工具,因此对应的 Agent CLI 必须先安装并完成登录认证,之后 cc-connect 才能正常启动。如果跳过前面的步骤直接启动 cc-connect,进程会直接退出并报错 `claudecode: claude CLI not found in PATH`(其他 Agent 报错类似),Web UI 在 `:9820` 也就无从访问。 + +### 1️⃣ 安装 AI Agent CLI + +选择你要桥接的 Agent,至少装一个。 + +```bash +# Claude Code(最常用) +brew install --cask claude-code # macOS / Linux Homebrew +# 或 +npm install -g @anthropic-ai/claude-code # 任意平台通过 npm + +# OpenAI Codex +npm install -g @openai/codex + +# Google Gemini CLI +npm install -g @google/gemini-cli + +# iFlow CLI +npm install -g @iflow-ai/iflow-cli + +# Qoder CLI +curl -fsSL https://qoder.com/install | bash +``` + +**Cursor Agent** 和 **OpenCode** 请参考各自的官方安装文档: +- Cursor Agent: +- OpenCode: + +确认可执行文件在 `PATH` 中: + +```bash +claude --version # 或 codex / gemini / opencode / qodercli / cursor-agent ... +``` + +### 2️⃣ 完成 Agent 登录认证 + +每个 Agent 都有自己的登录流程 — 先在终端交互式跑一次,让它把凭据存到你的 home 目录: + +```bash +claude login # 会在浏览器里打开授权页面 +# 或 +codex login # / gemini / opencode 等也类似,请参考各自文档 +``` + +跳过这一步的话,cc-connect 仍能启动,但 Agent 会因为未认证拒绝所有请求。 + +### 3️⃣ 安装 cc-connect + +```bash +# npm(任意平台) +npm install -g cc-connect + +# Homebrew(macOS / Linux) +brew install cc-connect + +# 也可以从 https://github.com/chenhg5/cc-connect/releases 直接下载二进制 +``` + +### 4️⃣ 启动 cc-connect 并打开 Web UI + +```bash +cc-connect # 启动服务;首次运行会自动生成 ~/.cc-connect/config.toml +``` + +首次启动时,cc-connect 会打印类似: + +``` +Web admin: http://localhost:9820 +``` + +在浏览器里打开该地址。如果 `9820` 已被占用,可以传 `--web-port 9821` 或在 `config.toml` 里设置 `web_port`。 + +> **注意:** `cc-connect web` *只* 打开浏览器和配置界面,并**不会**启动服务本身。仍需要在另一个终端里跑 `cc-connect`。 + +### 5️⃣ 在 Web UI 里配置平台 Bot Token + +在 Web UI 里新建一个项目,然后添加至少一个平台(飞书 / Telegram / Discord / Slack / 钉钉 / 企业微信 / QQ / LINE / 微信 ilink),把该平台开发者后台的 Bot Token 粘贴进去。保存后 cc-connect 会热加载。 + +至此完成 — 给你的 Bot 发条消息,cc-connect 就会把它转给本地的 Agent。 + +--- + ## 🚀 快速开始 ### 🤖 通过 AI Agent 安装配置(推荐) diff --git a/agent/antigravity/antigravity.go b/agent/antigravity/antigravity.go index acf732df8a..78935f7aff 100644 --- a/agent/antigravity/antigravity.go +++ b/agent/antigravity/antigravity.go @@ -26,7 +26,7 @@ func init() { // Agent drives the Antigravity CLI (agy) in headless mode. // // Modes (maps to agy approval and sandbox flags): -// - "default": standard approval mode (prompt for each tool use) +// - "default": ask for each tool through the cc-connect permission bridge // - "yolo": auto-approve all tools (--dangerously-skip-permissions) // - "plan": read-only plan mode with terminal sandbox constraints (--sandbox) type Agent struct { @@ -284,7 +284,7 @@ func (a *Agent) GetMode() string { func (a *Agent) PermissionModes() []core.PermissionModeInfo { return []core.PermissionModeInfo{ - {Key: "default", Name: "Default", NameZh: "默认", Desc: "Prompt for approval on each tool use", DescZh: "每次工具调用都需要确认"}, + {Key: "default", Name: "Default", NameZh: "默认", Desc: "Ask for approval through cc-connect on each tool use", DescZh: "每次工具调用都通过 cc-connect 请求确认"}, {Key: "yolo", Name: "YOLO", NameZh: "全自动", Desc: "Auto-approve all tool calls", DescZh: "自动批准所有工具调用"}, {Key: "plan", Name: "Plan", NameZh: "规划模式", Desc: "Read-only plan mode in sandbox", DescZh: "只读沙箱规划模式"}, } diff --git a/agent/antigravity/permission_bridge.go b/agent/antigravity/permission_bridge.go new file mode 100644 index 0000000000..2fce187936 --- /dev/null +++ b/agent/antigravity/permission_bridge.go @@ -0,0 +1,335 @@ +package antigravity + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/chenhg5/cc-connect/agent/antigravityhook" + "github.com/chenhg5/cc-connect/core" +) + +const agyPermissionHookName = "cc-connect-permission-bridge" + +type agyHookInput struct { + ConversationID string `json:"conversationId"` + StepIndex int `json:"stepIdx"` + ToolCall struct { + Name string `json:"name"` + Args map[string]any `json:"args"` + } `json:"toolCall"` +} + +type agyPermissionBridge struct { + ctx context.Context + cancel context.CancelFunc + listener net.Listener + address string + token string + rootDir string + configDir string + events chan<- core.Event + + nextID atomic.Uint64 + pendingMu sync.Mutex + pending map[string]chan core.PermissionResult + closeOnce sync.Once + wg sync.WaitGroup +} + +func newAgyPermissionBridge(ctx context.Context, events chan<- core.Event) (*agyPermissionBridge, error) { + bridgeCtx, cancel := context.WithCancel(ctx) + rootDir, err := os.MkdirTemp("", "cc-connect-agy-permission-") + if err != nil { + cancel() + return nil, fmt.Errorf("create permission bridge directory: %w", err) + } + + configDir, err := createAgyConfigOverlay(rootDir) + if err != nil { + cancel() + _ = os.RemoveAll(rootDir) + return nil, err + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + cancel() + _ = os.RemoveAll(rootDir) + return nil, fmt.Errorf("listen for Agy permission hooks: %w", err) + } + + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + cancel() + _ = listener.Close() + _ = os.RemoveAll(rootDir) + return nil, fmt.Errorf("generate permission bridge token: %w", err) + } + + bridge := &agyPermissionBridge{ + ctx: bridgeCtx, + cancel: cancel, + listener: listener, + address: listener.Addr().String(), + token: base64.RawURLEncoding.EncodeToString(tokenBytes), + rootDir: rootDir, + configDir: configDir, + events: events, + pending: make(map[string]chan core.PermissionResult), + } + bridge.wg.Add(1) + go bridge.acceptLoop() + return bridge, nil +} + +func createAgyConfigOverlay(rootDir string) (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory for Agy permission bridge: %w", err) + } + // Antigravity currently stores its CLI state under .gemini and exposes it + // through the --gemini_dir compatibility flag. + realConfigRoot := filepath.Join(homeDir, ".gemini") + overlayConfigRoot := filepath.Join(rootDir, "agy-config") + overlayConfigDir := filepath.Join(overlayConfigRoot, "config") + if err := os.MkdirAll(overlayConfigDir, 0o700); err != nil { + return "", fmt.Errorf("create Agy permission overlay: %w", err) + } + + if err := mirrorDirectoryEntries(realConfigRoot, overlayConfigRoot, map[string]bool{"config": true}); err != nil { + return "", err + } + realConfigDir := filepath.Join(realConfigRoot, "config") + if err := mirrorDirectoryEntries(realConfigDir, overlayConfigDir, map[string]bool{"hooks.json": true}); err != nil { + return "", err + } + + hooks := make(map[string]json.RawMessage) + hooksPath := filepath.Join(realConfigDir, "hooks.json") + if data, err := os.ReadFile(hooksPath); err == nil { + if err := json.Unmarshal(data, &hooks); err != nil { + return "", fmt.Errorf("parse existing Agy hooks %s: %w", hooksPath, err) + } + if hooks == nil { + hooks = make(map[string]json.RawMessage) + } + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("read existing Agy hooks %s: %w", hooksPath, err) + } + + executable, err := os.Executable() + if err != nil { + return "", fmt.Errorf("resolve cc-connect executable for Agy hook: %w", err) + } + bridgeHook, err := json.Marshal(map[string]any{ + "PreToolUse": []any{ + map[string]any{ + "matcher": "*", + "hooks": []any{ + map[string]any{ + "type": "command", + "command": shellQuote(executable) + " _agy-permission-hook", + "timeout": 86400, + }, + }, + }, + }, + }) + if err != nil { + return "", fmt.Errorf("marshal Agy permission hook: %w", err) + } + hooks[agyPermissionHookName] = bridgeHook + + data, err := json.MarshalIndent(hooks, "", " ") + if err != nil { + return "", fmt.Errorf("marshal Agy hooks overlay: %w", err) + } + data = append(data, '\n') + if err := os.WriteFile(filepath.Join(overlayConfigDir, "hooks.json"), data, 0o600); err != nil { + return "", fmt.Errorf("write Agy hooks overlay: %w", err) + } + return overlayConfigRoot, nil +} + +func mirrorDirectoryEntries(sourceDir, targetDir string, skip map[string]bool) error { + entries, err := os.ReadDir(sourceDir) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("read Agy config directory %s: %w", sourceDir, err) + } + for _, entry := range entries { + if skip[entry.Name()] { + continue + } + source := filepath.Join(sourceDir, entry.Name()) + target := filepath.Join(targetDir, entry.Name()) + if err := os.Symlink(source, target); err != nil { + return fmt.Errorf("link Agy config %s: %w", source, err) + } + } + return nil +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + +func bridgeTokenEqual(got, want string) bool { + if len(got) != len(want) { + return false + } + return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 +} + +func (b *agyPermissionBridge) Env() []string { + return []string{ + antigravityhook.EnvAddress + "=" + b.address, + antigravityhook.EnvToken + "=" + b.token, + } +} + +func (b *agyPermissionBridge) AgyConfigDir() string { return b.configDir } + +func (b *agyPermissionBridge) acceptLoop() { + defer b.wg.Done() + for { + conn, err := b.listener.Accept() + if err != nil { + if b.ctx.Err() == nil { + select { + case b.events <- core.Event{Type: core.EventError, Error: fmt.Errorf("antigravity permission bridge: %w", err)}: + case <-b.ctx.Done(): + } + } + return + } + b.wg.Add(1) + go b.handleConnection(conn) + } +} + +func (b *agyPermissionBridge) handleConnection(conn net.Conn) { + defer b.wg.Done() + defer func() { _ = conn.Close() }() + _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + + var request antigravityhook.BridgeRequest + if err := json.NewDecoder(io.LimitReader(conn, 4<<20)).Decode(&request); err != nil { + b.writeResponse(conn, antigravityhook.BridgeResponse{Decision: "deny", Reason: "invalid cc-connect permission bridge request"}) + return + } + if !bridgeTokenEqual(request.Token, b.token) { + b.writeResponse(conn, antigravityhook.BridgeResponse{Decision: "deny", Reason: "cc-connect permission bridge authentication failed"}) + return + } + _ = conn.SetDeadline(time.Time{}) + + var input agyHookInput + if err := json.Unmarshal(request.HookInput, &input); err != nil || strings.TrimSpace(input.ToolCall.Name) == "" { + b.writeResponse(conn, antigravityhook.BridgeResponse{Decision: "deny", Reason: "invalid Agy tool permission request"}) + return + } + if input.ToolCall.Args == nil { + input.ToolCall.Args = make(map[string]any) + } + + requestID := fmt.Sprintf("agy-perm-%d", b.nextID.Add(1)) + resultCh := make(chan core.PermissionResult, 1) + b.pendingMu.Lock() + b.pending[requestID] = resultCh + b.pendingMu.Unlock() + defer func() { + b.pendingMu.Lock() + delete(b.pending, requestID) + b.pendingMu.Unlock() + }() + + preview := formatAgyToolInput(input.ToolCall.Args) + event := core.Event{ + Type: core.EventPermissionRequest, + RequestID: requestID, + ToolName: input.ToolCall.Name, + ToolInput: preview, + ToolInputRaw: input.ToolCall.Args, + } + select { + case b.events <- event: + case <-b.ctx.Done(): + b.writeResponse(conn, antigravityhook.BridgeResponse{Decision: "deny", Reason: "cc-connect session closed"}) + return + } + + select { + case result := <-resultCh: + response := antigravityhook.BridgeResponse{Decision: "allow"} + if strings.EqualFold(strings.TrimSpace(result.Behavior), "deny") { + response.Decision = "deny" + response.Reason = strings.TrimSpace(result.Message) + if response.Reason == "" { + response.Reason = "User denied this tool use." + } + } + b.writeResponse(conn, response) + case <-b.ctx.Done(): + b.writeResponse(conn, antigravityhook.BridgeResponse{Decision: "deny", Reason: "cc-connect session closed"}) + } +} + +func formatAgyToolInput(input map[string]any) string { + if command, _ := input["CommandLine"].(string); strings.TrimSpace(command) != "" { + return command + } + data, err := json.MarshalIndent(input, "", " ") + if err != nil { + return fmt.Sprintf("%v", input) + } + return string(data) +} + +func (b *agyPermissionBridge) writeResponse(conn net.Conn, response antigravityhook.BridgeResponse) { + _ = json.NewEncoder(conn).Encode(response) +} + +func (b *agyPermissionBridge) RespondPermission(requestID string, result core.PermissionResult) error { + behavior := strings.ToLower(strings.TrimSpace(result.Behavior)) + if behavior != "allow" && behavior != "deny" { + return fmt.Errorf("antigravity: invalid permission behavior %q", result.Behavior) + } + result.Behavior = behavior + + b.pendingMu.Lock() + ch := b.pending[requestID] + b.pendingMu.Unlock() + if ch == nil { + return fmt.Errorf("antigravity: unknown permission request %q", requestID) + } + select { + case ch <- result: + return nil + default: + return fmt.Errorf("antigravity: permission request %q is already resolved", requestID) + } +} + +func (b *agyPermissionBridge) Close() { + b.closeOnce.Do(func() { + b.cancel() + _ = b.listener.Close() + b.wg.Wait() + _ = os.RemoveAll(b.rootDir) + }) +} diff --git a/agent/antigravity/permission_bridge_test.go b/agent/antigravity/permission_bridge_test.go new file mode 100644 index 0000000000..dc7509d69f --- /dev/null +++ b/agent/antigravity/permission_bridge_test.go @@ -0,0 +1,150 @@ +package antigravity + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/chenhg5/cc-connect/agent/antigravityhook" + "github.com/chenhg5/cc-connect/core" +) + +func TestAgyPermissionBridgePreservesHooksAndRelaysDecisions(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + realConfigDir := filepath.Join(homeDir, ".gemini", "config") + if err := os.MkdirAll(realConfigDir, 0o700); err != nil { + t.Fatalf("MkdirAll config: %v", err) + } + originalHooks := []byte(`{ + "existing-hook": { + "PreToolUse": [{"matcher": "read_file", "hooks": [{"type": "command", "command": "existing"}]}] + } +}`) + realHooksPath := filepath.Join(realConfigDir, "hooks.json") + if err := os.WriteFile(realHooksPath, originalHooks, 0o600); err != nil { + t.Fatalf("WriteFile hooks: %v", err) + } + if err := os.WriteFile(filepath.Join(realConfigDir, "keep.json"), []byte("{}\n"), 0o600); err != nil { + t.Fatalf("WriteFile keep config: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + events := make(chan core.Event, 4) + bridge, err := newAgyPermissionBridge(ctx, events) + if err != nil { + t.Fatalf("newAgyPermissionBridge: %v", err) + } + defer bridge.Close() + + gotOriginal, err := os.ReadFile(realHooksPath) + if err != nil { + t.Fatalf("ReadFile original hooks: %v", err) + } + if !bytes.Equal(gotOriginal, originalHooks) { + t.Fatalf("real hooks changed:\n%s", gotOriginal) + } + if target, err := os.Readlink(filepath.Join(bridge.AgyConfigDir(), "config", "keep.json")); err != nil { + t.Fatalf("Readlink preserved config: %v", err) + } else if target != filepath.Join(realConfigDir, "keep.json") { + t.Fatalf("preserved config target = %q", target) + } + + var overlayHooks map[string]json.RawMessage + overlayData, err := os.ReadFile(filepath.Join(bridge.AgyConfigDir(), "config", "hooks.json")) + if err != nil { + t.Fatalf("ReadFile overlay hooks: %v", err) + } + if err := json.Unmarshal(overlayData, &overlayHooks); err != nil { + t.Fatalf("Unmarshal overlay hooks: %v", err) + } + if _, ok := overlayHooks["existing-hook"]; !ok { + t.Fatal("overlay does not preserve existing hook") + } + if _, ok := overlayHooks[agyPermissionHookName]; !ok { + t.Fatal("overlay does not contain cc-connect permission hook") + } + + testBridgeDecision(t, bridge, events, "allow", "", "allow", "") + testBridgeDecision(t, bridge, events, "deny", "not now", "deny", "not now") +} + +func testBridgeDecision(t *testing.T, bridge *agyPermissionBridge, events <-chan core.Event, behavior, message, wantDecision, wantReason string) { + t.Helper() + + hookInput := `{ + "conversationId": "conversation-1", + "stepIdx": 3, + "toolCall": { + "name": "run_command", + "args": {"CommandLine": "touch /tmp/permission-test", "Cwd": "/tmp"} + } +}` + var output bytes.Buffer + relayDone := make(chan error, 1) + go func() { + relayDone <- antigravityhook.Relay(strings.NewReader(hookInput), &output, bridge.address, bridge.token) + }() + + var event core.Event + select { + case event = <-events: + case <-time.After(3 * time.Second): + t.Fatal("timeout waiting for permission event") + } + if event.Type != core.EventPermissionRequest { + t.Fatalf("event type = %q, want permission_request", event.Type) + } + if event.RequestID == "" || event.ToolName != "run_command" { + t.Fatalf("event = %#v, want run_command permission request", event) + } + if event.ToolInput != "touch /tmp/permission-test" { + t.Fatalf("ToolInput = %q", event.ToolInput) + } + + if err := bridge.RespondPermission(event.RequestID, core.PermissionResult{Behavior: behavior, Message: message}); err != nil { + t.Fatalf("RespondPermission: %v", err) + } + select { + case err := <-relayDone: + if err != nil { + t.Fatalf("Relay: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("timeout waiting for hook response") + } + + var response antigravityhook.BridgeResponse + if err := json.Unmarshal(output.Bytes(), &response); err != nil { + t.Fatalf("Unmarshal hook response %q: %v", output.String(), err) + } + if response.Decision != wantDecision || response.Reason != wantReason { + t.Fatalf("response = %#v, want decision=%q reason=%q", response, wantDecision, wantReason) + } +} + +func TestAgyPermissionBridgeRejectsInvalidBehavior(t *testing.T) { + bridge := &agyPermissionBridge{pending: make(map[string]chan core.PermissionResult)} + if err := bridge.RespondPermission("request-1", core.PermissionResult{Behavior: "maybe"}); err == nil { + t.Fatal("RespondPermission() error = nil, want invalid behavior error") + } +} + +func TestBridgeTokenEqualRequiresMatchingLength(t *testing.T) { + if !bridgeTokenEqual("same-length-token", "same-length-token") { + t.Fatal("bridgeTokenEqual() = false, want true") + } + if bridgeTokenEqual("short", "same-length-token") { + t.Fatal("bridgeTokenEqual() = true for length mismatch, want false") + } + if bridgeTokenEqual("same-length-tokem", "same-length-token") { + t.Fatal("bridgeTokenEqual() = true for same-length mismatch, want false") + } +} diff --git a/agent/antigravity/session.go b/agent/antigravity/session.go index d1b5d6adf8..77031d5343 100644 --- a/agent/antigravity/session.go +++ b/agent/antigravity/session.go @@ -11,7 +11,6 @@ import ( "os" "os/exec" "path/filepath" - "regexp" "sort" "strings" "sync" @@ -23,27 +22,23 @@ import ( // antigravitySession manages multi-turn conversations with the Antigravity CLI (agy). type antigravitySession struct { - cmd string - extraArgs []string // extra args from cmd, prepended before agy args - workDir string - model string - mode string - timeout time.Duration - extraEnv []string - events chan core.Event - stdin io.WriteCloser - stdinMu sync.Mutex - closeOnce sync.Once - permReqID atomic.Value // stores string - chatID atomic.Value // stores string - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup - alive atomic.Bool + cmd string + extraArgs []string // extra args from cmd, prepended before agy args + workDir string + model string + mode string + timeout time.Duration + extraEnv []string + events chan core.Event + closeOnce sync.Once + chatID atomic.Value // stores string + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + alive atomic.Bool + permissionBridge *agyPermissionBridge } -var permissionPromptPattern = regexp.MustCompile(`(?is)(allow|approve|permission).{0,400}(\(y/n\)|\(y\/n\)|\(y\/N\)|\(Y\/n\)|\[y\/n\]|\[y\/N\]|\[Y\/n\]|yes\/no)`) - func newAntigravitySession(ctx context.Context, cmd string, extraArgs []string, workDir, model, mode, resumeID string, extraEnv []string, timeout time.Duration) (*antigravitySession, error) { sessionCtx, cancel := context.WithCancel(ctx) @@ -61,6 +56,15 @@ func newAntigravitySession(ctx context.Context, cmd string, extraArgs []string, } as.alive.Store(true) + if mode == "default" { + bridge, err := newAgyPermissionBridge(sessionCtx, as.events) + if err != nil { + cancel() + return nil, fmt.Errorf("antigravity: initialize permission bridge: %w", err) + } + as.permissionBridge = bridge + } + if resumeID != "" && resumeID != core.ContinueSession { as.chatID.Store(resumeID) } @@ -141,7 +145,11 @@ func (as *antigravitySession) Send(prompt string, messageID string, images []cor } fullPrompt += "\n\n[Attached files saved at: " + strings.Join(fileRefs, ", ") + "]" } - args := as.buildAntigravityArgs(chatID, isResume, as.mode, fullPrompt) + agyConfigDir := "" + if as.permissionBridge != nil { + agyConfigDir = as.permissionBridge.AgyConfigDir() + } + args := as.buildAntigravityArgs(chatID, isResume, as.mode, agyConfigDir, fullPrompt) if strings.TrimSpace(as.model) != "" { slog.Warn("antigravitySession: model is configured but ignored because agy does not support --model yet", "model", as.model) } @@ -169,19 +177,17 @@ func (as *antigravitySession) Send(prompt string, messageID string, images []cor if len(as.extraEnv) > 0 { env = core.MergeEnv(env, as.extraEnv) } + if as.permissionBridge != nil { + env = core.MergeEnv(env, as.permissionBridge.Env()) + } cmd.Env = env + // Keep stdin disconnected: agy --print consumes piped stdin to EOF before + // processing the prompt, so an open pipe would deadlock the turn. stdout, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("antigravitySession: stdout pipe: %w", err) } - var stdin io.WriteCloser - if usesInteractivePermission(as.mode) { - stdin, err = cmd.StdinPipe() - if err != nil { - return fmt.Errorf("antigravitySession: stdin pipe: %w", err) - } - } var stderrBuf bytes.Buffer cmd.Stderr = &stderrBuf @@ -189,9 +195,6 @@ func (as *antigravitySession) Send(prompt string, messageID string, images []cor if err := cmd.Start(); err != nil { return fmt.Errorf("antigravitySession: start: %w", err) } - as.stdinMu.Lock() - as.stdin = stdin - as.stdinMu.Unlock() started = true as.wg.Add(1) @@ -203,10 +206,14 @@ func (as *antigravitySession) Send(prompt string, messageID string, images []cor return nil } -func (as *antigravitySession) buildAntigravityArgs(chatID string, isResume bool, mode, fullPrompt string) []string { +func (as *antigravitySession) buildAntigravityArgs(chatID string, isResume bool, mode, agyConfigDir, fullPrompt string) []string { // Prepend extra args from cmd so wrappers like "timeout 3600 agy" work. // Keep "-p " at the very end because agy consumes the immediate next arg. args := append([]string{}, as.extraArgs...) + if agyConfigDir != "" { + // Antigravity currently names this compatibility flag --gemini_dir. + args = append(args, "--gemini_dir="+agyConfigDir, "--print-timeout=24h") + } if isResume { args = append(args, "--conversation", chatID) } @@ -220,10 +227,6 @@ func (as *antigravitySession) buildAntigravityArgs(chatID string, isResume bool, return args } -func usesInteractivePermission(mode string) bool { - return strings.EqualFold(strings.TrimSpace(mode), "default") -} - func (as *antigravitySession) readLoop(ctx context.Context, cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer, tempFiles []string, preEntries map[string]bool, sendStartedAt time.Time) { defer as.wg.Done() defer func() { @@ -282,33 +285,11 @@ func (as *antigravitySession) readLoop(ctx context.Context, cmd *exec.Cmd, stdou reader := bufio.NewReader(stdout) buf := make([]byte, 1024) - permWindow := "" for { n, err := reader.Read(buf) if n > 0 { text := string(buf[:n]) - permWindow += text - if len(permWindow) > 4096 { - permWindow = permWindow[len(permWindow)-4096:] - } - if pending, _ := as.permReqID.Load().(string); pending == "" { - if prompt, ok := extractPermissionPrompt(permWindow); ok { - requestID := fmt.Sprintf("agy-perm-%d", time.Now().UnixNano()) - as.permReqID.Store(requestID) - select { - case as.events <- core.Event{ - Type: core.EventPermissionRequest, - RequestID: requestID, - ToolName: "terminal_permission", - ToolInput: prompt, - ToolInputRaw: map[string]any{"prompt": prompt}, - }: - case <-as.ctx.Done(): - return - } - } - } select { case as.events <- core.Event{Type: core.EventText, Content: text}: case <-as.ctx.Done(): @@ -404,42 +385,14 @@ func (as *antigravitySession) detectNewSessionID(preEntries map[string]bool, sen return candidates[0].sessionID } -func extractPermissionPrompt(text string) (string, bool) { - loc := permissionPromptPattern.FindStringIndex(text) - if loc == nil { - return "", false - } - prompt := strings.TrimSpace(text[loc[0]:loc[1]]) - if prompt == "" { - return "", false - } - return prompt, true -} - func (as *antigravitySession) RespondPermission(requestID string, result core.PermissionResult) error { if !as.alive.Load() { return fmt.Errorf("session is closed") } - if pending, _ := as.permReqID.Load().(string); pending != "" && requestID != "" && requestID != pending { - return fmt.Errorf("permission request mismatch: got %q, pending %q", requestID, pending) - } - as.stdinMu.Lock() - defer as.stdinMu.Unlock() - if as.stdin == nil { - return fmt.Errorf("stdin is not available") - } - // agy permission prompts accept terminal-style responses. - // Keep this conservative until agy exposes a structured permission protocol. - reply := "y\n" - if strings.EqualFold(result.Behavior, "deny") { - reply = "n\n" + if as.permissionBridge == nil { + return fmt.Errorf("antigravity: permission responses are only available in default mode") } - _, err := io.WriteString(as.stdin, reply) - if err != nil { - return fmt.Errorf("write permission response: %w", err) - } - as.permReqID.Store("") - return nil + return as.permissionBridge.RespondPermission(requestID, result) } func (as *antigravitySession) Events() <-chan core.Event { @@ -458,12 +411,9 @@ func (as *antigravitySession) Alive() bool { func (as *antigravitySession) Close() error { as.alive.Store(false) as.cancel() - as.stdinMu.Lock() - if as.stdin != nil { - _ = as.stdin.Close() - as.stdin = nil + if as.permissionBridge != nil { + as.permissionBridge.Close() } - as.stdinMu.Unlock() done := make(chan struct{}) go func() { as.wg.Wait() diff --git a/agent/antigravity/session_test.go b/agent/antigravity/session_test.go index 6bff955a36..db8c5ba359 100644 --- a/agent/antigravity/session_test.go +++ b/agent/antigravity/session_test.go @@ -4,6 +4,7 @@ import ( "context" "io" "os" + "path/filepath" "strings" "testing" "time" @@ -58,6 +59,8 @@ func TestNormalizeMode(t *testing.T) { } func TestSession_ContinueSessionTreatedAsFresh(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s, err := newAntigravitySession(context.Background(), "echo", nil, "/tmp", "", "default", core.ContinueSession, nil, 0) if err != nil { t.Fatalf("newAntigravitySession: %v", err) @@ -70,8 +73,8 @@ func TestSession_ContinueSessionTreatedAsFresh(t *testing.T) { } func TestBuildAntigravityArgs_PromptAtEnd(t *testing.T) { - s, _ := newAntigravitySession(context.Background(), "echo", nil, "/tmp", "", "default", "", nil, 0) - args := s.buildAntigravityArgs("sid-1", true, "plan", "What is 1+1?") + s, _ := newAntigravitySession(context.Background(), "echo", []string{"--verbose"}, "/tmp", "", "default", "", nil, 0) + args := s.buildAntigravityArgs("sid-1", true, "plan", "/tmp/agy-config", "What is 1+1?") if len(args) < 2 { t.Fatalf("args too short: %v", args) } @@ -81,6 +84,12 @@ func TestBuildAntigravityArgs_PromptAtEnd(t *testing.T) { if !contains(args, "--sandbox") { t.Fatalf("expected --sandbox in args, got: %v", args) } + if !contains(args, "--gemini_dir=/tmp/agy-config") || !contains(args, "--print-timeout=24h") { + t.Fatalf("expected isolated Agy config and extended print timeout, got: %v", args) + } + if !contains(args, "--verbose") { + t.Fatalf("expected configured extra args, got: %v", args) + } if contains(args, "-m") || contains(args, "--model") { t.Fatalf("did not expect model flags in args, got: %v", args) } @@ -154,76 +163,94 @@ func TestAntigravitySession_ResumePassesConversationID(t *testing.T) { } } -func TestUsesInteractivePermission(t *testing.T) { - if !usesInteractivePermission("default") { - t.Fatal("default mode should use interactive permission stdin") - } - if usesInteractivePermission("yolo") { - t.Fatal("yolo mode should not use interactive permission stdin") - } - if usesInteractivePermission("plan") { - t.Fatal("plan mode should not use interactive permission stdin") - } -} +func TestDefaultModeCreatesPermissionBridge(t *testing.T) { + t.Setenv("HOME", t.TempDir()) -func TestRespondPermission_WritesTerminalAnswer(t *testing.T) { s, err := newAntigravitySession(context.Background(), "echo", nil, "/tmp", "", "default", "", nil, 0) if err != nil { t.Fatalf("newAntigravitySession: %v", err) } defer func() { _ = s.Close() }() - r, w, err := os.Pipe() - if err != nil { - t.Fatalf("os.Pipe: %v", err) - } - defer func() { _ = r.Close() }() - defer func() { _ = w.Close() }() - s.stdin = w - - s.permReqID.Store("req") - if err := s.RespondPermission("req", core.PermissionResult{Behavior: "allow"}); err != nil { - t.Fatalf("RespondPermission allow: %v", err) - } - buf := make([]byte, 8) - n, err := r.Read(buf) - if err != nil && err != io.EOF { - t.Fatalf("read allow response: %v", err) + if s.permissionBridge == nil { + t.Fatal("permissionBridge = nil, want default-mode permission bridge") } - if got := string(buf[:n]); got != "y\n" { - t.Fatalf("allow response = %q, want %q", got, "y\n") + if _, err := os.Stat(filepath.Join(s.permissionBridge.AgyConfigDir(), "config", "hooks.json")); err != nil { + t.Fatalf("stat Agy hook overlay: %v", err) } +} - s.permReqID.Store("req") - if err := s.RespondPermission("req", core.PermissionResult{Behavior: "deny"}); err != nil { - t.Fatalf("RespondPermission deny: %v", err) +func TestNonDefaultModesDoNotCreatePermissionBridge(t *testing.T) { + for _, mode := range []string{"yolo", "plan"} { + t.Run(mode, func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + s, err := newAntigravitySession(context.Background(), "echo", nil, "/tmp", "", mode, "", nil, 0) + if err != nil { + t.Fatalf("newAntigravitySession: %v", err) + } + defer func() { _ = s.Close() }() + + if s.permissionBridge != nil { + t.Fatalf("permissionBridge = %v, want nil", s.permissionBridge) + } + }) } - n, err = r.Read(buf) - if err != nil && err != io.EOF { - t.Fatalf("read deny response: %v", err) +} + +func TestRespondPermissionRequiresDefaultMode(t *testing.T) { + s, err := newAntigravitySession(context.Background(), "echo", nil, "/tmp", "", "plan", "", nil, 0) + if err != nil { + t.Fatalf("newAntigravitySession: %v", err) } - if got := string(buf[:n]); got != "n\n" { - t.Fatalf("deny response = %q, want %q", got, "n\n") + defer func() { _ = s.Close() }() + + err = s.RespondPermission("req", core.PermissionResult{Behavior: "allow"}) + if err == nil || !strings.Contains(err.Error(), "only available in default mode") { + t.Fatalf("RespondPermission() error = %v, want default-mode error", err) } } -func TestExtractPermissionPrompt(t *testing.T) { - text := "Tool wants to run command. Allow this action? (y/N)" - got, ok := extractPermissionPrompt(text) - if !ok { - t.Fatalf("expected permission prompt to be detected") +func TestSendDoesNotHoldStdinOpen(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + workDir := t.TempDir() + cmdPath := filepath.Join(t.TempDir(), "fake-agy.sh") + script := "#!/bin/sh\ncat >/dev/null\nprintf 'done\\n'\n" + if err := os.WriteFile(cmdPath, []byte(script), 0o755); err != nil { + t.Fatalf("WriteFile fake agy: %v", err) } - if got == "" { - t.Fatalf("detected prompt should not be empty") + + s, err := newAntigravitySession(context.Background(), cmdPath, nil, workDir, "", "default", "", nil, 2*time.Second) + if err != nil { + t.Fatalf("newAntigravitySession: %v", err) + } + defer func() { _ = s.Close() }() + + if err := s.Send("hello", "", nil, nil); err != nil { + t.Fatalf("Send: %v", err) } -} -func TestExtractPermissionPrompt_SplitChunksDetectedInWindow(t *testing.T) { - part1 := "Tool wants to run command. Allow this" - part2 := " action? (y/N)" - got, ok := extractPermissionPrompt(part1 + part2) - if !ok || got == "" { - t.Fatalf("expected split prompt to be detected, got ok=%v prompt=%q", ok, got) + deadline := time.After(3 * time.Second) + var text strings.Builder + for { + select { + case ev := <-s.Events(): + switch ev.Type { + case core.EventPermissionRequest: + t.Fatal("unexpected permission request from unstructured stdout") + case core.EventText: + text.WriteString(ev.Content) + case core.EventResult: + if !strings.Contains(text.String(), "done") { + t.Fatalf("text = %q, want done", text.String()) + } + return + } + case <-deadline: + t.Fatal("timeout waiting for agy process to receive stdin EOF") + } } } diff --git a/agent/antigravityhook/protocol.go b/agent/antigravityhook/protocol.go new file mode 100644 index 0000000000..968fbf46fc --- /dev/null +++ b/agent/antigravityhook/protocol.go @@ -0,0 +1,75 @@ +package antigravityhook + +import ( + "encoding/json" + "fmt" + "io" + "net" + "strings" + "time" +) + +const ( + EnvAddress = "CC_CONNECT_AGY_PERMISSION_ADDR" + EnvToken = "CC_CONNECT_AGY_PERMISSION_TOKEN" + + maxHookInput = 4 << 20 + bridgeDialTimeout = 5 * time.Second + bridgeResponseTimeout = 24 * time.Hour +) + +type BridgeRequest struct { + Token string `json:"token"` + HookInput json.RawMessage `json:"hook_input"` +} + +type BridgeResponse struct { + Decision string `json:"decision"` + Reason string `json:"reason,omitempty"` +} + +// Relay forwards one Agy hook invocation to the owning cc-connect session. +func Relay(in io.Reader, out io.Writer, address, token string) error { + if strings.TrimSpace(address) == "" || strings.TrimSpace(token) == "" { + return fmt.Errorf("permission bridge environment is missing") + } + + input, err := io.ReadAll(io.LimitReader(in, maxHookInput+1)) + if err != nil { + return fmt.Errorf("read hook input: %w", err) + } + if len(input) > maxHookInput { + return fmt.Errorf("hook input exceeds %d bytes", maxHookInput) + } + if !json.Valid(input) { + return fmt.Errorf("hook input is not valid JSON") + } + + conn, err := net.DialTimeout("tcp", address, bridgeDialTimeout) + if err != nil { + return fmt.Errorf("connect permission bridge: %w", err) + } + defer func() { _ = conn.Close() }() + // The listener is started before agy runs this hook, so dial failures should + // fail closed quickly. After connect, wait much longer for a human response. + _ = conn.SetDeadline(time.Now().Add(bridgeResponseTimeout)) + + if err := json.NewEncoder(conn).Encode(BridgeRequest{Token: token, HookInput: input}); err != nil { + return fmt.Errorf("send permission request: %w", err) + } + + var response BridgeResponse + if err := json.NewDecoder(io.LimitReader(conn, 64<<10)).Decode(&response); err != nil { + return fmt.Errorf("read permission response: %w", err) + } + switch response.Decision { + case "allow", "deny": + default: + return fmt.Errorf("invalid permission decision %q", response.Decision) + } + + if err := json.NewEncoder(out).Encode(response); err != nil { + return fmt.Errorf("write hook response: %w", err) + } + return nil +} diff --git a/agent/antigravityhook/protocol_test.go b/agent/antigravityhook/protocol_test.go new file mode 100644 index 0000000000..12d4946e41 --- /dev/null +++ b/agent/antigravityhook/protocol_test.go @@ -0,0 +1,25 @@ +package antigravityhook + +import ( + "bytes" + "strings" + "testing" +) + +func TestRelayRejectsInvalidInputBeforeConnecting(t *testing.T) { + var output bytes.Buffer + err := Relay(strings.NewReader("not-json"), &output, "127.0.0.1:1", "token") + if err == nil || !strings.Contains(err.Error(), "not valid JSON") { + t.Fatalf("Relay() error = %v, want invalid JSON error", err) + } + if output.Len() != 0 { + t.Fatalf("output = %q, want empty", output.String()) + } +} + +func TestRelayRequiresBridgeEnvironment(t *testing.T) { + err := Relay(strings.NewReader("{}"), &bytes.Buffer{}, "", "") + if err == nil || !strings.Contains(err.Error(), "environment is missing") { + t.Fatalf("Relay() error = %v, want missing environment error", err) + } +} diff --git a/agent/claudecode/claudecode.go b/agent/claudecode/claudecode.go index fa3b39768a..cda3ec8c18 100644 --- a/agent/claudecode/claudecode.go +++ b/agent/claudecode/claudecode.go @@ -371,6 +371,7 @@ func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption { } return []core.ModelOption{ {Name: "sonnet", Desc: "Claude Sonnet (balanced)"}, + {Name: "sonnet[1m]", Desc: "Claude Sonnet (1M context)"}, {Name: "opus", Desc: "Claude Opus (most capable)"}, {Name: "opus[1m]", Desc: "Claude Opus (1M context)"}, {Name: "haiku", Desc: "Claude Haiku (fastest)"}, diff --git a/agent/kimi/session_test.go b/agent/kimi/session_test.go index a1cbad9807..ede2b9dcaa 100644 --- a/agent/kimi/session_test.go +++ b/agent/kimi/session_test.go @@ -348,6 +348,35 @@ func TestHandleAssistantStringContent(t *testing.T) { assert.Equal(t, "OK", ks.pendingMsgs[0]) } +// TestHandleAssistantStringContentWithToolCalls pins the Kimi Code CLI shape +// where an assistant message carries plain-string content AND tool_calls in +// the same event (#1561): the text must surface as a thinking event (via +// flushPendingAsThinking) before the tool-use event, not be silently dropped. +// Ported from #1586. +func TestHandleAssistantStringContentWithToolCalls(t *testing.T) { + ctx := context.Background() + ks, _ := newKimiSession(ctx, "kimi", nil, "/tmp", "", "default", "", nil, 0, kimiFlagSupport{}) + defer func() { _ = ks.Close() }() + + ks.handleEvent(map[string]any{ + "role": "assistant", + "content": "Let me check", + "tool_calls": []any{ + map[string]any{ + "id": "tool_1", + "function": map[string]any{"name": "Shell", "arguments": `{"command":"ls"}`}, + }, + }, + }) + + events := drainEvents(ks.events, 2) + require.Len(t, events, 2) + assert.Equal(t, core.EventThinking, events[0].Type) + assert.Equal(t, "Let me check", events[0].Content) + assert.Equal(t, core.EventToolUse, events[1].Type) + assert.Equal(t, "Shell", events[1].ToolName) +} + // TestHandleToolStringContent covers the plain-string content shape for tool // results on the Kimi Code CLI (#1561). func TestHandleToolStringContent(t *testing.T) { diff --git a/agent/pi/pi.go b/agent/pi/pi.go index b90045898b..2e458f491b 100644 --- a/agent/pi/pi.go +++ b/agent/pi/pi.go @@ -122,9 +122,16 @@ func (a *Agent) AvailableModels(_ context.Context) []core.ModelOption { models, err := readSettingsModels() if err != nil { slog.Debug("pi: AvailableModels: read settings", "error", err) - return nil } - return models + if len(models) > 0 { + return models + } + // enabledModels 未配置时,回退到 pi 自身的模型目录 models-store.json, + // 否则 /model 卡片只会显示当前模型、无法列出可切换的模型列表。 + if store := readModelsStore(); len(store) > 0 { + return store + } + return nil } func (a *Agent) SetSessionEnv(env []string) { @@ -141,6 +148,12 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS extraArgs := append([]string{}, a.cliExtraArgs...) extraEnv := append([]string(nil), a.configEnv...) extraEnv = append(extraEnv, a.sessionEnv...) + // 注入权限模式环境变量,供 permission-gate 扩展读取:yolo(全自动)时扩展 + // 自动放行所有工具,不再弹出权限确认卡片。模式切换会触发会话重建(pi 未实现 + // LiveModeSwitcher),新进程拿到新值。core.InjectedAgentEnv 追加在 + // configEnv/sessionEnv 之后,因此用户显式设置的 CC_PERMISSION_MODE 排在前、 + // 优先生效(getenv 返回第一个匹配项)。 + extraEnv = append(extraEnv, core.InjectedAgentEnv(mode)...) rpc := a.rpc a.mu.Unlock() return newPiSession(ctx, a.cmd, extraArgs, a.workDir, model, mode, thinking, rpc, sessionID, extraEnv) @@ -343,6 +356,10 @@ type modelsJSON struct { // (e.g. "deepseek/deepseek-v4-pro") and the fully-qualified // provider/ID (e.g. "my-provider/my-model"). // Returns nil on any error (caller falls back to 200K). +// +// Note: models.json is distinct from models-store.json — models.json carries +// per-model context-window sizes, while models-store.json is the provider +// catalog that readModelsStore uses to build the /model list. func loadModelsContextWindows() map[string]int { dir := piSettingsDir() if dir == "" { @@ -443,6 +460,59 @@ func readSettingsModels() ([]core.ModelOption, error) { return models, nil } +// modelsStoreJSON represents the structure of ~/.pi/agent/models-store.json, +// the provider catalog pi itself maintains (hydrated from the published +// pi-ai package and refreshed as providers are added). Top-level keys are +// provider names, each with a Models list. +// +// { +// "deepseek": { "models": [ { "id": "...", "name": "...", ... } ] } +// } +type modelsStoreJSON map[string]struct { + Models []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"models"` +} + +// readModelsStore reads all models from pi's models-store.json as +// provider-qualified ModelOptions (Name = "provider/id", Alias = short id, +// Desc = display name). Returns nil when the file is missing or unreadable; +// callers fall back to an empty list. +func readModelsStore() []core.ModelOption { + dir := piSettingsDir() + if dir == "" { + return nil + } + path := filepath.Join(dir, "models-store.json") + data, err := os.ReadFile(path) + if err != nil { + slog.Debug("pi: read models-store", "path", path, "error", err) + return nil + } + var store modelsStoreJSON + if err := json.Unmarshal(data, &store); err != nil { + slog.Warn("pi: parse models-store", "path", path, "error", err) + return nil + } + var models []core.ModelOption + for provider, p := range store { + for _, m := range p.Models { + if m.ID == "" { + continue + } + models = append(models, core.ModelOption{ + Name: provider + "/" + m.ID, + Alias: m.ID, + Desc: m.Name, + }) + } + } + // Map iteration order is random — sort for deterministic card display. + sort.Slice(models, func(i, j int) bool { return models[i].Name < models[j].Name }) + return models +} + // readDefaultModel returns the defaultModel from settings.json. func readDefaultModel() (string, error) { s, err := readSettings() diff --git a/agent/pi/pi_test.go b/agent/pi/pi_test.go index 6fc465d829..15bd9cb8dd 100644 --- a/agent/pi/pi_test.go +++ b/agent/pi/pi_test.go @@ -216,6 +216,41 @@ func TestAgent_AvailableModels(t *testing.T) { } } +func TestAgent_AvailableModels_FallsBackToStore(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PI_CODING_AGENT_DIR", tmpDir) + + // settings.json with empty enabledModels → readSettingsModels returns empty. + settings := map[string]any{"enabledModels": []string{}} + data, _ := json.Marshal(settings) + if err := os.WriteFile(filepath.Join(tmpDir, "settings.json"), data, 0o644); err != nil { + t.Fatalf("write settings.json: %v", err) + } + + // models-store.json with two models. + store := map[string]any{ + "deepseek": map[string]any{ + "models": []any{ + map[string]any{"id": "deepseek-chat", "name": "DeepSeek Chat"}, + map[string]any{"id": "deepseek-reasoner", "name": "DeepSeek Reasoner"}, + }, + }, + } + sdata, _ := json.Marshal(store) + if err := os.WriteFile(filepath.Join(tmpDir, "models-store.json"), sdata, 0o644); err != nil { + t.Fatalf("write models-store.json: %v", err) + } + + a := &Agent{} + models := a.AvailableModels(context.Background()) + if len(models) != 2 { + t.Fatalf("AvailableModels() = %d models, want 2 (fallback to models-store.json)", len(models)) + } + if models[0].Name != "deepseek/deepseek-chat" || models[1].Name != "deepseek/deepseek-reasoner" { + t.Errorf("AvailableModels() = %+v, want store models in sorted order", models) + } +} + func TestReadSettingsModels(t *testing.T) { // Save and restore settings path. savedEnv := os.Getenv("PI_CODING_AGENT_DIR") @@ -277,6 +312,86 @@ func TestReadSettingsModels(t *testing.T) { } } +func TestReadModelsStore(t *testing.T) { + writeStore := func(t *testing.T, store any) { + t.Helper() + tmpDir := t.TempDir() + t.Setenv("PI_CODING_AGENT_DIR", tmpDir) + data, _ := json.Marshal(store) + if err := os.WriteFile(filepath.Join(tmpDir, "models-store.json"), data, 0o644); err != nil { + t.Fatalf("write models-store.json: %v", err) + } + } + + t.Run("missing file returns nil", func(t *testing.T) { + t.Setenv("PI_CODING_AGENT_DIR", t.TempDir()) + if got := readModelsStore(); got != nil { + t.Errorf("readModelsStore() = %v, want nil for missing models-store.json", got) + } + }) + + t.Run("valid file returns sorted provider-qualified models", func(t *testing.T) { + writeStore(t, map[string]any{ + "openai": map[string]any{ + "models": []any{ + map[string]any{"id": "gpt-4", "name": "GPT-4"}, + map[string]any{"id": "gpt-4o", "name": "GPT-4o"}, + }, + }, + "deepseek": map[string]any{ + "models": []any{ + map[string]any{"id": "deepseek-chat", "name": "DeepSeek Chat"}, + map[string]any{"id": "deepseek-reasoner", "name": "DeepSeek Reasoner"}, + }, + }, + }) + got := readModelsStore() + want := []core.ModelOption{ + {Name: "deepseek/deepseek-chat", Alias: "deepseek-chat", Desc: "DeepSeek Chat"}, + {Name: "deepseek/deepseek-reasoner", Alias: "deepseek-reasoner", Desc: "DeepSeek Reasoner"}, + {Name: "openai/gpt-4", Alias: "gpt-4", Desc: "GPT-4"}, + {Name: "openai/gpt-4o", Alias: "gpt-4o", Desc: "GPT-4o"}, + } + if len(got) != len(want) { + t.Fatalf("got %d models, want %d: %+v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("got[%d] = %+v, want %+v", i, got[i], want[i]) + } + } + }) + + t.Run("empty id model is skipped", func(t *testing.T) { + writeStore(t, map[string]any{ + "openai": map[string]any{ + "models": []any{ + map[string]any{"id": "", "name": "Empty"}, + map[string]any{"id": "gpt-4", "name": "GPT-4"}, + }, + }, + }) + got := readModelsStore() + if len(got) != 1 { + t.Fatalf("got %d models, want 1 (empty-id skipped): %+v", len(got), got) + } + if got[0].Name != "openai/gpt-4" || got[0].Alias != "gpt-4" || got[0].Desc != "GPT-4" { + t.Errorf("got[0] = %+v, want openai/gpt-4", got[0]) + } + }) + + t.Run("malformed json returns nil", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PI_CODING_AGENT_DIR", tmpDir) + if err := os.WriteFile(filepath.Join(tmpDir, "models-store.json"), []byte("{invalid json"), 0o644); err != nil { + t.Fatalf("write models-store.json: %v", err) + } + if got := readModelsStore(); got != nil { + t.Errorf("readModelsStore() = %v, want nil for malformed models-store.json", got) + } + }) +} + func TestReadDefaultModel(t *testing.T) { savedEnv := os.Getenv("PI_CODING_AGENT_DIR") t.Cleanup(func() { @@ -456,6 +571,73 @@ func TestAgent_StartSession(t *testing.T) { if !ps.Alive() { t.Error("session should be alive") } + // CC_PERMISSION_MODE 必须被注入,permission-gate 扩展才能感知全自动模式。 + found := false + for _, e := range ps.extraEnv { + if e == "CC_PERMISSION_MODE=yolo" { + found = true + break + } + } + if !found { + t.Errorf("extraEnv = %v, want CC_PERMISSION_MODE=yolo", ps.extraEnv) + } +} + +func TestAgent_StartSession_NoModeNoEnv(t *testing.T) { + a := &Agent{cmd: "echo", workDir: "/tmp"} // mode 为空 + + sess, err := a.StartSession(context.Background(), "") + if err != nil { + t.Fatalf("StartSession() error = %v", err) + } + defer func() { + if err := sess.Close(); err != nil { + t.Errorf("Close() error = %v", err) + } + }() + + ps := sess.(*piSession) + for _, e := range ps.extraEnv { + if len(e) >= len("CC_PERMISSION_MODE=") && e[:len("CC_PERMISSION_MODE=")] == "CC_PERMISSION_MODE=" { + t.Errorf("extraEnv = %v, CC_PERMISSION_MODE should be absent when mode is empty", ps.extraEnv) + } + } +} + +func TestAgent_StartSession_UserOverrideWins(t *testing.T) { + // 回归保护:引擎注入的 CC_PERMISSION_MODE 必须追加在 configEnv/sessionEnv + // 之后,这样用户显式设置的 CC_PERMISSION_MODE 排在前面、优先生效(getenv + // 返回第一个匹配项)。若未来重构把它提前,引擎值会反过来覆盖用户的显式设置。 + a := &Agent{cmd: "echo", workDir: "/tmp", mode: "yolo"} + a.SetSessionEnv([]string{"CC_PERMISSION_MODE=default"}) + + sess, err := a.StartSession(context.Background(), "") + if err != nil { + t.Fatalf("StartSession() error = %v", err) + } + defer func() { + if err := sess.Close(); err != nil { + t.Errorf("Close() error = %v", err) + } + }() + + ps := sess.(*piSession) + userIdx, engineIdx := -1, -1 + for i, e := range ps.extraEnv { + switch e { + case "CC_PERMISSION_MODE=default": + userIdx = i + case "CC_PERMISSION_MODE=yolo": + engineIdx = i + } + } + if userIdx == -1 || engineIdx == -1 { + t.Fatalf("extraEnv = %v, want both user (default) and engine (yolo) CC_PERMISSION_MODE entries", ps.extraEnv) + } + if userIdx > engineIdx { + t.Errorf("user CC_PERMISSION_MODE at %d must precede engine value at %d so user override wins", userIdx, engineIdx) + } } // ── extractToolInput ───────────────────────────────────────── diff --git a/cmd/cc-connect/antigravity_hook.go b/cmd/cc-connect/antigravity_hook.go new file mode 100644 index 0000000000..0494fb9aa8 --- /dev/null +++ b/cmd/cc-connect/antigravity_hook.go @@ -0,0 +1,27 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/chenhg5/cc-connect/agent/antigravityhook" +) + +func runAntigravityPermissionHook() { + err := antigravityhook.Relay( + os.Stdin, + os.Stdout, + os.Getenv(antigravityhook.EnvAddress), + os.Getenv(antigravityhook.EnvToken), + ) + if err == nil { + return + } + + fmt.Fprintf(os.Stderr, "cc-connect Agy permission hook: %v\n", err) + _ = json.NewEncoder(os.Stdout).Encode(antigravityhook.BridgeResponse{ + Decision: "deny", + Reason: "cc-connect permission bridge is unavailable", + }) +} diff --git a/cmd/cc-connect/main.go b/cmd/cc-connect/main.go index 96c887aaf6..acdbd3527c 100644 --- a/cmd/cc-connect/main.go +++ b/cmd/cc-connect/main.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "flag" "fmt" "io" @@ -201,69 +202,43 @@ type providerWiringResult struct { canStartInitialRefresh bool } -func main() { - checkUpdateAsync() +var topLevelCommandHandlers = map[string]func([]string){ + "config-example": func(_ []string) { + fmt.Print(ccconnect.ConfigExampleTOML) + }, + "config": runConfig, + "update": func(_ []string) { + runUpdate() + }, + "check-update": func(_ []string) { + checkUpdate() + }, + "provider": runProviderCommand, + "send": runSend, + "cron": runCron, + "timer": runTimer, + "at": runTimer, + "relay": runRelay, + "sessions": runSessions, + "agent-sid": runAgentSID, + "daemon": runDaemon, + "feishu": runFeishu, + "tuitui": runTuiTui, + "weixin": runWeixin, + "yuanbao": runYuanbao, + "doctor": runDoctor, + "web": runWeb, +} - // Handle subcommands before flag parsing - if len(os.Args) > 1 { - switch os.Args[1] { - case "config-example": - fmt.Print(ccconnect.ConfigExampleTOML) - return - case "config": - runConfig(os.Args[2:]) - return - case "update": - runUpdate() - return - case "check-update": - checkUpdate() - return - case "provider": - runProviderCommand(os.Args[2:]) - return - case "send": - runSend(os.Args[2:]) - return - case "cron": - runCron(os.Args[2:]) - return - case "timer", "at": - runTimer(os.Args[2:]) - return - case "relay": - runRelay(os.Args[2:]) - return - case "sessions": - runSessions(os.Args[2:]) - return - case "agent-sid": - runAgentSID(os.Args[2:]) - return - case "daemon": - runDaemon(os.Args[2:]) - return - case "feishu": - runFeishu(os.Args[2:]) - return - case "tuitui": - runTuiTui(os.Args[2:]) - return - case "weixin": - runWeixin(os.Args[2:]) - return - case "yuanbao": - runYuanbao(os.Args[2:]) - return - case "doctor": - runDoctor(os.Args[2:]) - return - case "web": - runWeb(os.Args[2:]) - return - } +func main() { + // Agy hooks require stdout to contain only the final JSON decision. Handle + // this internal command before update checks, logging, or normal CLI setup. + if len(os.Args) > 1 && os.Args[1] == "_agy-permission-hook" { + runAntigravityPermissionHook() + return } + checkUpdateAsync() // When started as a daemon (CC_LOG_FILE set), redirect logs to a rotating file. // Log file setup happens before flag.Parse() so the rotating writer is in // place before any slog output. To still honour --log-max-size, we @@ -285,44 +260,50 @@ func main() { slog.SetDefault(slog.New(slog.NewTextHandler(w, &slog.HandlerOptions{Level: slog.LevelInfo}))) } - configFlag := flag.String("config", "", "path to config file (default: ./config.toml or ~/.cc-connect/config.toml)") - showVersion := flag.Bool("version", false, "print version and exit") - observeFlag := flag.Bool("observe", false, "observe native terminal Claude Code sessions and forward to Slack") - observeChannel := flag.String("observe-channel", "", "Slack channel ID to forward terminal observations to (requires --observe)") - forceFlag := flag.Bool("force", false, "kill any existing instance with the same config before starting") - logMaxSizeFlag := flag.String("log-max-size", "", "max bytes for the rotating log file (e.g. 10MB, 512K, 10485760); overrides CC_LOG_MAX_SIZE env var (default: 10MB)") - logMaxBackupsFlag := flag.Int("log-max-backups", 0, "number of rotated log files to retain (.log.1 .. .log.N); overrides CC_LOG_MAX_BACKUPS env var (default: 3)") - flag.Usage = printUsage - flag.Parse() + rootOpts, err := parseRootCLIOptions(os.Args[1:]) + if err != nil { + if errors.Is(err, flag.ErrHelp) { + return + } + os.Exit(2) + } // Cross-check: the rotating-writer setup above consumed a pre-scanned - // value of --log-max-size, but flag.Parse() may have been called for - // tests or wrappers that pre-scan differently. Validate the parsed flag - // value here so the binding is exercised and a typo caught by - // flag.Parse() surfaces a clear error. - if strings.TrimSpace(*logMaxSizeFlag) != "" { - if _, err := daemon.ParseLogSize(*logMaxSizeFlag); err != nil { - fmt.Fprintf(os.Stderr, "warning: --log-max-size=%q: %v\n", *logMaxSizeFlag, err) + // value of --log-max-size. Validate the parsed value too so malformed + // values surface a clear warning. + if strings.TrimSpace(rootOpts.logMaxSize) != "" { + if _, err := daemon.ParseLogSize(rootOpts.logMaxSize); err != nil { + fmt.Fprintf(os.Stderr, "warning: --log-max-size=%q: %v\n", rootOpts.logMaxSize, err) } } - if *logMaxBackupsFlag < 0 { - fmt.Fprintf(os.Stderr, "warning: --log-max-backups=%d must be >= 0 (0 means use env/default)\n", *logMaxBackupsFlag) + if rootOpts.logMaxBackups < 0 { + fmt.Fprintf(os.Stderr, "warning: --log-max-backups=%d must be >= 0 (0 means use env/default)\n", rootOpts.logMaxBackups) } - if *showVersion { + if rootOpts.showVersion { fmt.Printf("cc-connect %s\ncommit: %s\nbuilt: %s\n", version, commit, buildTime) return } + if runTopLevelCommand(rootOpts.args) { + return + } + + if err := validateNoExtraTopLevelArgs(rootOpts.args); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n\n", err) + printUsage() + os.Exit(1) + } + core.VersionInfo = fmt.Sprintf("cc-connect %s\ncommit: %s\nbuilt: %s", version, commit, buildTime) core.CurrentVersion = version core.CurrentCommit = commit core.CurrentBuildTime = buildTime - configPath := resolveConfigPath(*configFlag) + configPath := resolveConfigPath(rootOpts.configPath) // Handle --force: kill any existing instance before we try to acquire the lock - if *forceFlag { + if rootOpts.force { if KillExistingInstance(configPath) { slog.Info("killed existing instance via --force") } @@ -491,8 +472,8 @@ func main() { } // Wire terminal observation (--observe / [projects.observe]) - observeEnabled := *observeFlag - obsChan := *observeChannel + observeEnabled := rootOpts.observe + obsChan := rootOpts.observeChannel if proj.Observe != nil { if !observeEnabled && proj.Observe.Enabled { observeEnabled = true @@ -1400,6 +1381,65 @@ func main() { slog.Info("bye") } +func runTopLevelCommand(args []string) bool { + if len(args) == 0 { + return false + } + handler, ok := topLevelCommandHandlers[args[0]] + if !ok { + return false + } + handler(args[1:]) + return true +} + +type rootCLIOptions struct { + configPath string + force bool + observe bool + observeChannel string + logMaxSize string + logMaxBackups int + showVersion bool + args []string +} + +func parseRootCLIOptions(args []string) (rootCLIOptions, error) { + fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError) + fs.SetOutput(os.Stderr) + fs.Usage = printUsage + + configPath := fs.String("config", "", "path to config file (default: ./config.toml or ~/.cc-connect/config.toml)") + force := fs.Bool("force", false, "kill any existing instance with the same config before starting") + observe := fs.Bool("observe", false, "observe native terminal Claude Code sessions and forward to Slack") + observeChannel := fs.String("observe-channel", "", "Slack channel ID to forward terminal observations to (requires --observe)") + logMaxSize := fs.String("log-max-size", "", "max bytes for the rotating log file (e.g. 10MB, 512K, 10485760); overrides CC_LOG_MAX_SIZE env var (default: 10MB)") + logMaxBackups := fs.Int("log-max-backups", 0, "number of rotated log files to retain (.log.1 .. .log.N); overrides CC_LOG_MAX_BACKUPS env var (default: 3)") + showVersion := fs.Bool("version", false, "print version and exit") + + if err := fs.Parse(args); err != nil { + return rootCLIOptions{}, err + } + + return rootCLIOptions{ + configPath: *configPath, + force: *force, + observe: *observe, + observeChannel: *observeChannel, + logMaxSize: *logMaxSize, + logMaxBackups: *logMaxBackups, + showVersion: *showVersion, + args: fs.Args(), + }, nil +} + +func validateNoExtraTopLevelArgs(args []string) error { + if len(args) == 0 { + return nil + } + return fmt.Errorf("unknown top-level command: %s", args[0]) +} + // sessionStorePath builds a unique filename from project name + work_dir. // It checks for legacy session files (without the sessions/ subdirectory) in dataDir // for backward compatibility; if found, uses that path. Otherwise uses dataDir/sessions/. diff --git a/cmd/cc-connect/main_test.go b/cmd/cc-connect/main_test.go index d36ed6d91d..e90f647468 100644 --- a/cmd/cc-connect/main_test.go +++ b/cmd/cc-connect/main_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "flag" "io" "os" "path/filepath" @@ -340,3 +341,97 @@ func TestCanonicalCronSubcommand_ManualTriggerAliases(t *testing.T) { } } } + +func TestValidateNoExtraTopLevelArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "no extra args", + args: nil, + }, + { + name: "unknown command", + args: []string{"bind", "--help"}, + wantErr: "unknown top-level command: bind", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateNoExtraTopLevelArgs(tt.args) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("validateNoExtraTopLevelArgs(%v) error = %v, want nil", tt.args, err) + } + return + } + if err == nil { + t.Fatalf("validateNoExtraTopLevelArgs(%v) error = nil, want %q", tt.args, tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("validateNoExtraTopLevelArgs(%v) error = %q, want substring %q", tt.args, err.Error(), tt.wantErr) + } + }) + } +} + +func TestParseRootCLIOptionsGlobalFlagsBeforeSubcommand(t *testing.T) { + opts, err := parseRootCLIOptions([]string{"--config", "/tmp/test-config.toml", "--log-max-size", "12MB", "--log-max-backups", "7", "sessions", "list"}) + if err != nil { + t.Fatalf("parseRootCLIOptions() error = %v", err) + } + if opts.configPath != "/tmp/test-config.toml" { + t.Fatalf("configPath = %q, want %q", opts.configPath, "/tmp/test-config.toml") + } + if opts.logMaxSize != "12MB" { + t.Fatalf("logMaxSize = %q, want %q", opts.logMaxSize, "12MB") + } + if opts.logMaxBackups != 7 { + t.Fatalf("logMaxBackups = %d, want 7", opts.logMaxBackups) + } + if opts.showVersion { + t.Fatal("showVersion = true, want false") + } + wantArgs := []string{"sessions", "list"} + if !reflect.DeepEqual(opts.args, wantArgs) { + t.Fatalf("args = %v, want %v", opts.args, wantArgs) + } +} + +func TestTopLevelCommandHandlersIncludeTimerAliases(t *testing.T) { + for _, command := range []string{"timer", "at"} { + if topLevelCommandHandlers[command] == nil { + t.Fatalf("topLevelCommandHandlers[%q] is nil", command) + } + } +} + +func TestParseRootCLIOptionsPreservesSubcommandHelp(t *testing.T) { + opts, err := parseRootCLIOptions([]string{"--config", "/tmp/test-config.toml", "send", "--help"}) + if err != nil { + t.Fatalf("parseRootCLIOptions() error = %v", err) + } + wantArgs := []string{"send", "--help"} + if !reflect.DeepEqual(opts.args, wantArgs) { + t.Fatalf("args = %v, want %v", opts.args, wantArgs) + } +} + +func TestParseRootCLIOptionsHelp(t *testing.T) { + _, err := parseRootCLIOptions([]string{"--help"}) + if err == nil { + t.Fatal("parseRootCLIOptions(--help) error = nil, want flag.ErrHelp") + } + if !strings.Contains(err.Error(), flag.ErrHelp.Error()) { + t.Fatalf("parseRootCLIOptions(--help) error = %q, want %q", err.Error(), flag.ErrHelp.Error()) + } +} + +func TestRunTopLevelCommandUnknown(t *testing.T) { + if runTopLevelCommand([]string{"bind", "--help"}) { + t.Fatal("runTopLevelCommand() handled unknown command") + } +} diff --git a/cmd/cc-connect/plugin_platform_googlechat.go b/cmd/cc-connect/plugin_platform_googlechat.go new file mode 100644 index 0000000000..fde2e2b8ba --- /dev/null +++ b/cmd/cc-connect/plugin_platform_googlechat.go @@ -0,0 +1,5 @@ +//go:build !no_googlechat + +package main + +import _ "github.com/chenhg5/cc-connect/platform/googlechat" diff --git a/config.example.toml b/config.example.toml index 9ce8db32d0..e61aea9252 100644 --- a/config.example.toml +++ b/config.example.toml @@ -291,6 +291,11 @@ level = "info" # debug, info, warn, error # max_per_second = 25 # [outgoing_rate_limit.platforms.feishu] # max_per_second = 5 +# Weixin (ilink) sendmessage throttles the bot after a short burst (ret=-2 +# "prepare failed", penalty on the order of an hour). Pace sends to avoid it: +# [outgoing_rate_limit.platforms.weixin] +# max_per_second = 0.5 +# burst = 1 # ============================================================================= # Relay Settings / Relay 设置 @@ -1228,6 +1233,41 @@ app_secret = "your-feishu-app-secret" # # a fresh session, replies in the same thread continue it). # # 会话粒度:"user"(默认) | "channel"(整个频道一个会话) | "thread"(每个 Slack 话题串一个会话) +# Google Chat (uncomment to enable / 取消注释以启用) +# Uses a registered Google Chat app whose Cloud Pub/Sub connection publishes +# events to a topic. No public IP is needed and there is NO Workspace Events +# subscription expiry or per-restart resource leak (the subscription is fixed). +# 注册的 Google Chat 应用通过 Cloud Pub/Sub 连接发布事件,无需公网 IP,且订阅固定(无过期/泄漏)。 +# +# - receive: cc-connect pulls the Chat app's Pub/Sub subscription with a native +# streaming pull (no gws binary). 接收:原生流式拉取 Chat 应用的 Pub/Sub 订阅(无需 gws)。 +# - send: replies post via the Chat REST API as the app's service account +# (chat.bot), so replies appear as the bot. 发送:以服务账号(chat.bot)作为机器人回复。 +# Both directions use the same service-account key. 收发使用同一个服务账号密钥。 +# +# Setup / 配置: +# 1. Enable Chat API + Pub/Sub API. / 启用 Chat API 与 Pub/Sub API。 +# 2. Create a Pub/Sub topic; grant Publisher to +# chat-api-push@system.gserviceaccount.com; create a pull subscription. +# 创建 Pub/Sub 主题并授予 chat-api-push@system Publisher,创建拉取订阅。 +# 3. Create a service account + JSON key (chat.bot). Grant it +# roles/pubsub.subscriber on the subscription so it can pull events. +# 创建服务账号及 JSON 密钥,并在订阅上授予 roles/pubsub.subscriber 以拉取事件。 +# 4. Configure a Chat app (Chat API > Configuration): set status LIVE, +# Connection settings = Cloud Pub/Sub (your topic), Visibility = yourself. +# Do NOT check "Build as a Google Workspace add-on". +# 配置 Chat 应用:状态 LIVE,连接方式选 Cloud Pub/Sub(你的主题),可见性=自己;不要勾选 add-on。 +# 5. DM the app (or @mention it in a space). / 私聊该应用(或在空间中 @ 它)。 + +# [[projects.platforms]] +# type = "googlechat" +# +# [projects.platforms.options] +# subscription = "projects/PROJECT/subscriptions/NAME" # Pub/Sub subscription the Chat app publishes to (required) / Chat 应用发布到的 Pub/Sub 订阅(必填) +# credentials_file = "/path/to/service-account.json" # SA key, used to pull events AND reply (chat.bot) (required) / 服务账号密钥,用于拉取事件与回复(必填) +# allow_from = "*" # Allowed sender IDs, e.g. "users/123456789" / 允许的发送者 ID +# session_scope = "space" # "space" (default) | "thread" | "user" / 会话粒度 + # Discord (uncomment to enable / 取消注释以启用) # 1. Create an app at https://discord.com/developers/applications / 创建应用 # 2. Under "Bot", create a bot and copy the token / 创建 Bot 并复制 token @@ -1433,6 +1473,17 @@ app_secret = "your-feishu-app-secret" # proxy = "" # optional HTTP proxy / 可选代理 # proxy_username = "" # proxy_password = "" +# +# Send-volume quota vs ilink's burst throttle (sendMessage ret=-2 "prepare failed"): +# the gateway throttles the bot after roughly 5-6 separate messages within a short +# window (multi-chunk sends are fine), and the penalty escalates with every attempt +# made while active. The quota paces separate messages so the bot stays below the +# trigger. 0 disables the quota. +# 发送量配额(防 ilink 突发节流 ret=-2):网关在短窗口内约 5-6 条独立消息即节流 +# (多切块发送不计数),且节流期内每次尝试都会升级惩罚。配额对独立消息限速, +# 使其低于触发阈值。设为 0 关闭配额。 +# burst_limit = 4 # max separate messages per window / 每窗口最多独立消息数 +# burst_window_secs = 86400 # window length (default 24h) / 窗口时长(秒,默认24小时) # ----------------------------------------------------------------------------- # Tencent Yuanbao (yuanbao.tencent.com bot platform) @@ -1713,6 +1764,16 @@ app_secret = "your-feishu-app-secret" # Optional: specify a model tier / 可选:指定模型级别 # model = "auto" # "auto" | "ultimate" | "performance" | "efficient" | "lite" # +# Optional: override the CLI invocation via the unified `cmd` field. Accepts +# both a whitespace-separated string and a TOML array. Useful for passing +# flags that the IM-platform user expects to take effect, e.g. forcing +# permission bypass without a wrapper script. +# 可选:通过统一的 `cmd` 字段覆盖 CLI 调用方式,同时接受空白分隔字符串 +# 和 TOML 数组两种写法。可用于让 IM 平台用户透传 CLI 参数,例如绕过权限 +# 检查而无需再用 wrapper 脚本。 +# cmd = "qodercli --permission-mode bypass_permissions" +# cmd = ["qodercli", "--permission-mode", "bypass_permissions"] +# # [[projects.platforms]] # type = "telegram" # diff --git a/core/cmdopts.go b/core/cmdopts.go index 420491d008..a67346e31d 100644 --- a/core/cmdopts.go +++ b/core/cmdopts.go @@ -8,20 +8,31 @@ import ( // ParseCmdOpts extracts the command and extra args from agent options. // // Priority (first non-empty wins): -// 1. "cmd" (canonical field, no warning) -// 2. "cli_path" (deprecated — logs a warning) -// 3. "command" (deprecated — logs a warning) +// 1. "cmd" (canonical field, no warning). Accepts both: +// - a string (whitespace-separated, e.g. "my-cli code -t foo") +// - a TOML array of strings (e.g. ["my-cli", "code", "-t", "foo"]) +// 2. "cli_path" (deprecated — logs a warning). String only. +// 3. "command" (deprecated — logs a warning). String only. // 4. defaultBin (fallback when nothing is configured) // // Examples: // -// "my-cli code -t foo" → cmd="my-cli", extraArgs=["code", "-t", "foo"] -// "claude" → cmd="claude", extraArgs=nil -// "" (default "pi") → cmd="pi", extraArgs=nil +// "my-cli code -t foo" → cmd="my-cli", extraArgs=["code", "-t", "foo"] +// ["my-cli", "code", "-t", "foo"] → cmd="my-cli", extraArgs=["code", "-t", "foo"] +// "claude" → cmd="claude", extraArgs=nil +// "" (default "pi") → cmd="pi", extraArgs=nil func ParseCmdOpts(opts map[string]any, defaultBin string) (cmd string, extraArgs []string) { - if v, ok := opts["cmd"].(string); ok && strings.TrimSpace(v) != "" { - parts := strings.Fields(v) - return parts[0], parts[1:] + if v, ok := opts["cmd"]; ok { + if s, ok := v.(string); ok { + if parts := splitCmdString(s); parts != nil { + return parts[0], parts[1:] + } + } else if arr, ok := toStringArray(v); ok { + if parts := filterNonEmpty(arr); len(parts) > 0 { + return parts[0], parts[1:] + } + } + // Non-string non-array types fall through to deprecated keys / default. } if v, ok := opts["cli_path"].(string); ok && strings.TrimSpace(v) != "" { @@ -29,8 +40,9 @@ func ParseCmdOpts(opts map[string]any, defaultBin string) (cmd string, extraArgs "deprecated_key", "cli_path", "new_key", "cmd", "value", v) - parts := strings.Fields(v) - return parts[0], parts[1:] + if parts := splitCmdString(v); parts != nil { + return parts[0], parts[1:] + } } if v, ok := opts["command"].(string); ok && strings.TrimSpace(v) != "" { @@ -38,13 +50,66 @@ func ParseCmdOpts(opts map[string]any, defaultBin string) (cmd string, extraArgs "deprecated_key", "command", "new_key", "cmd", "value", v) - parts := strings.Fields(v) - return parts[0], parts[1:] + if parts := splitCmdString(v); parts != nil { + return parts[0], parts[1:] + } } return defaultBin, nil } +// splitCmdString splits a whitespace-separated cmd string into ordered parts. +// Returns nil when the string is empty or whitespace-only so the caller can +// fall through to the next priority. +func splitCmdString(s string) []string { + parts := strings.Fields(strings.TrimSpace(s)) + if len(parts) == 0 { + return nil + } + return parts +} + +// toStringArray accepts the shape TOML produces for inline arrays +// (i.e. []any of strings) as well as the []string shape some callers may +// pass directly. Returns ok=false for any other type so the caller can +// fall through to its next priority without logging a false-positive. +func toStringArray(v any) ([]string, bool) { + switch arr := v.(type) { + case []string: + return arr, true + case []any: + out := make([]string, 0, len(arr)) + for _, item := range arr { + s, ok := item.(string) + if !ok { + // Mixed-type or non-string arrays are an invalid cmd + // shape; let the caller fall through rather than panic + // or silently drop the user's config. + return nil, false + } + out = append(out, s) + } + return out, true + default: + return nil, false + } +} + +// filterNonEmpty strips empty / whitespace-only entries from a cmd array. +// TOML allows inline arrays with sparse entries (`["cli", "", "--flag"]`) +// and we want the empty slots treated as if the user had not set them +// rather than spawning the CLI with an empty argv entry. +func filterNonEmpty(parts []string) []string { + out := parts[:0:0] + for _, p := range parts { + if strings.TrimSpace(p) == "" { + continue + } + out = append(out, p) + } + return out +} + // ParseConfigEnv parses opts["env"] (set via [projects.agent.options.env] // in config.toml) into a []string of KEY=VALUE pairs. // diff --git a/core/cmdopts_test.go b/core/cmdopts_test.go index dc919b2db3..da7c12b94b 100644 --- a/core/cmdopts_test.go +++ b/core/cmdopts_test.go @@ -63,7 +63,7 @@ func TestParseCmdOpts_CmdField(t *testing.T) { wantArgs: nil, }, { - name: "cmd field non-string falls through to default", + name: "cmd field non-string non-array falls through to default", opts: map[string]any{"cmd": 12345}, defaultBin: "fallback", wantCmd: "fallback", @@ -83,6 +83,65 @@ func TestParseCmdOpts_CmdField(t *testing.T) { wantCmd: "gemini", wantArgs: []string{"--model", "pro"}, }, + // Array form (issue #1670 regression: qoder agent must accept the + // unified cmd array shape so users can pass + // --permission-mode bypass_permissions without a wrapper script). + { + name: "cmd array with no extra args", + opts: map[string]any{"cmd": []any{"qodercli"}}, + defaultBin: "fallback", + wantCmd: "qodercli", + wantArgs: nil, + }, + { + name: "cmd array with permission-mode args (qoder IM use case)", + opts: map[string]any{"cmd": []any{"qodercli", "--permission-mode", "bypass_permissions"}}, + defaultBin: "fallback", + wantCmd: "qodercli", + wantArgs: []string{"--permission-mode", "bypass_permissions"}, + }, + { + name: "cmd []string form", + opts: map[string]any{"cmd": []string{"claude", "--add-dir", "/parent"}}, + defaultBin: "fallback", + wantCmd: "claude", + wantArgs: []string{"--add-dir", "/parent"}, + }, + { + name: "cmd empty array falls through to default", + opts: map[string]any{"cmd": []any{}}, + defaultBin: "fallback", + wantCmd: "fallback", + wantArgs: nil, + }, + { + name: "cmd array of only whitespace falls through to default", + opts: map[string]any{"cmd": []any{"", " ", ""}}, + defaultBin: "fallback", + wantCmd: "fallback", + wantArgs: nil, + }, + { + name: "cmd array with mixed empty entries skips them", + opts: map[string]any{"cmd": []any{"qodercli", "", "--flag", " "}}, + defaultBin: "fallback", + wantCmd: "qodercli", + wantArgs: []string{"--flag"}, + }, + { + name: "cmd array with non-string entry falls through to default", + opts: map[string]any{"cmd": []any{"qodercli", 42, "--flag"}}, + defaultBin: "fallback", + wantCmd: "fallback", + wantArgs: nil, + }, + { + name: "cmd array preserves order (argv order matters for some CLIs)", + opts: map[string]any{"cmd": []any{"qodercli", "-p", "first", "--flag", "second"}}, + defaultBin: "fallback", + wantCmd: "qodercli", + wantArgs: []string{"-p", "first", "--flag", "second"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -92,7 +151,10 @@ func TestParseCmdOpts_CmdField(t *testing.T) { if gotCmd != tt.wantCmd { t.Errorf("cmd: got %q, want %q", gotCmd, tt.wantCmd) } - if !equalStrings(gotArgs, tt.wantArgs) { + // Order-sensitive check for the array form; the string form + // is also order-preserving via strings.Fields, so we just + // compare order here too. + if !slicesEqual(gotArgs, tt.wantArgs) { t.Errorf("extraArgs: got %v, want %v", gotArgs, tt.wantArgs) } if buf.Len() != 0 { @@ -317,3 +379,17 @@ func equalStrings(a, b []string) bool { } return true } + +// slicesEqual compares two string slices in order. Used for argv-style +// checks where position matters (e.g. ParseCmdOpts array form). +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/core/engine.go b/core/engine.go index 5abc4f5971..9b8c5e74f6 100644 --- a/core/engine.go +++ b/core/engine.go @@ -1207,6 +1207,42 @@ var privilegedCommands = map[string]bool{ "diff": true, } +// isPrivilegedCommandInvocation extends the privilegedCommands map to +// also gate specific destructive subcommands. Currently: +// +// - /commands addexec ... — registers a custom shell-exec command +// - /cron addexec ... — schedules a recurring shell-exec +// +// Both effectively create new admin-only commands at runtime; if a +// non-admin can call addexec, they can install arbitrary shell commands +// for any future user to trigger. Sibling subcommands (list, add, del, +// etc.) remain non-privileged. +// +// Returns true when the cmdID itself is in privilegedCommands, or when +// the (cmdID, args[0]) pair matches one of the explicitly-gated +// subcommands above. +func isPrivilegedCommandInvocation(cmdID string, args []string) bool { + if privilegedCommands[cmdID] { + return true + } + if len(args) == 0 { + return false + } + sub := strings.ToLower(args[0]) + switch cmdID { + case "commands": + return matchSubCommand(sub, []string{ + "list", "add", "addexec", "del", "delete", "rm", "remove", + }) == "addexec" + case "cron": + return matchSubCommand(sub, []string{ + "add", "addexec", "list", "del", "delete", "rm", "remove", "enable", "disable", "mute", "unmute", "setup", + }) == "addexec" + default: + return false + } +} + // isAdmin checks whether the given user ID is authorized for privileged commands. // Unlike AllowList, empty adminFrom means deny-all (fail-closed). func (e *Engine) isAdmin(userID string) bool { @@ -4582,7 +4618,7 @@ func (e *Engine) runUnsolicitedReader(ctx context.Context, cancel context.Cancel } if fullResponse != "" { - for _, chunk := range splitMessage(fullResponse, maxPlatformMessageLen) { + for _, chunk := range SplitMessageCodeFenceAware(fullResponse, maxPlatformMessageLen) { e.send(p, replyCtx, chunk) } } @@ -4981,7 +5017,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess } else { segment := strings.Join(textParts[segmentStart:], "") if segment != "" { - for _, chunk := range splitMessage(segment, maxPlatformMessageLen) { + for _, chunk := range SplitMessageCodeFenceAware(segment, maxPlatformMessageLen) { sendWorkspace(p, replyCtx, chunk) } } @@ -5004,7 +5040,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess if !previewActive { segment := strings.Join(textParts[segmentStart:], "") if segment != "" { - for _, chunk := range splitMessage(segment, maxPlatformMessageLen) { + for _, chunk := range SplitMessageCodeFenceAware(segment, maxPlatformMessageLen) { sendWorkspace(p, replyCtx, chunk) } } @@ -5068,7 +5104,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess } else { segment := strings.Join(textParts[segmentStart:], "") if segment != "" { - for _, chunk := range splitMessage(segment, maxPlatformMessageLen) { + for _, chunk := range SplitMessageCodeFenceAware(segment, maxPlatformMessageLen) { sendWorkspace(p, replyCtx, chunk) } } @@ -5112,7 +5148,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess if !previewActive { segment := strings.Join(textParts[segmentStart:], "") if segment != "" { - for _, chunk := range splitMessage(segment, maxPlatformMessageLen) { + for _, chunk := range SplitMessageCodeFenceAware(segment, maxPlatformMessageLen) { sendWorkspace(p, replyCtx, chunk) } } @@ -5142,7 +5178,11 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess } } toolMsg := fmt.Sprintf(e.i18n.T(MsgTool), toolCount, event.ToolName, formattedInput) - if !cp.AppendEvent(ProgressEntryToolUse, toolInput, event.ToolName, toolMsg) { + // Truncate the tool input that goes into the progress card payload so + // tool_max_len applies uniformly to progress_style=card, matching + // the rich-card path. event.ToolInput itself is left untouched. + cardToolInput := truncateIf(toolInput, e.display.ToolMaxLen) + if !cp.AppendEvent(ProgressEntryToolUse, cardToolInput, event.ToolName, toolMsg) { for _, chunk := range SplitMessageCodeFenceAware(toolMsg, maxPlatformMessageLen) { sendWorkspace(p, replyCtx, chunk) } @@ -5360,7 +5400,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess if !previewActive { segment := strings.Join(textParts[segmentStart:], "") if segment != "" { - for _, chunk := range splitMessage(segment, maxPlatformMessageLen) { + for _, chunk := range SplitMessageCodeFenceAware(segment, maxPlatformMessageLen) { sendWorkspace(p, replyCtx, chunk) } } @@ -5625,7 +5665,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess // Fallback: send the response as a normal message — but never // for a silent reply, which has no deliverable content. if !isSilent { - for _, chunk := range splitMessage(fullResponse, maxPlatformMessageLen) { + for _, chunk := range SplitMessageCodeFenceAware(fullResponse, maxPlatformMessageLen) { if err := sendWorkspaceWithError(p, replyCtx, chunk); err != nil { return } @@ -6048,7 +6088,7 @@ channelClosed: if segmentStart < len(textParts) { unsent := strings.Join(textParts[segmentStart:], "") if unsent != "" { - for _, chunk := range splitMessage(unsent, maxPlatformMessageLen) { + for _, chunk := range SplitMessageCodeFenceAware(unsent, maxPlatformMessageLen) { if err := sendWorkspaceWithError(p, replyCtx, chunk); err != nil { return } @@ -6058,7 +6098,7 @@ channelClosed: } else if sp.finish(fullResponse, "") { slog.Debug("stream preview: finalized in-place (process exited)") } else { - for _, chunk := range splitMessage(fullResponse, maxPlatformMessageLen) { + for _, chunk := range SplitMessageCodeFenceAware(fullResponse, maxPlatformMessageLen) { if err := sendWorkspaceWithError(p, replyCtx, chunk); err != nil { return } @@ -6393,7 +6433,7 @@ func (e *Engine) handleCommand(p Platform, msg *Message, raw string) bool { return true } - if cmdID != "" && privilegedCommands[cmdID] && !e.isAdmin(msg.UserID) { + if cmdID != "" && isPrivilegedCommandInvocation(cmdID, args) && !e.isAdmin(msg.UserID) { slog.Info("audit: command_blocked", "user_id", msg.UserID, "platform", msg.Platform, "project", e.name, "command", cmdID, "reason", "unauthorized") @@ -7577,7 +7617,7 @@ func (e *Engine) buildClaudeStatusLineFooter(agent Agent, session AgentSession, // which case caller should bail). sendFn is the workspace-aware send closure // (so the helper picks up workspace transforms like path remapping). func sendChunksWithStatusFooter(ctx context.Context, p Platform, replyCtx any, body, statusFooter string, sendFn func(Platform, any, string) error) bool { - chunks := splitMessage(body, maxPlatformMessageLen) + chunks := SplitMessageCodeFenceAware(body, maxPlatformMessageLen) for i, chunk := range chunks { isLast := i == len(chunks)-1 if isLast && statusFooter != "" { @@ -11678,7 +11718,7 @@ func (e *Engine) sendAlreadyRenderedWithError(p Platform, replyCtx any, content "platform", p.Name(), "error", err, "content_len", len(content), - "hint", "user needs to send a new message to refresh context_token") + "hint", "user needs to send a message to the bot first so a context_token can be captured") } else { slog.Error("platform send failed", "platform", p.Name(), "error", err, "content_len", len(content)) } diff --git a/core/engine_test.go b/core/engine_test.go index ce806d11d4..b68cf7f6b6 100644 --- a/core/engine_test.go +++ b/core/engine_test.go @@ -1904,6 +1904,74 @@ func TestProcessInteractiveEvents_CardProgressUsesStructuredPayloadWhenSupported } } +// TestProcessInteractiveEvents_CardProgressTruncatesToolInputByToolMaxLen verifies +// that when progress_style=card is used (e.g. Feishu), a long tool input is +// truncated to display.ToolMaxLen in the structured card payload. The original +// event.ToolInput must remain unmutated. +func TestProcessInteractiveEvents_CardProgressTruncatesToolInputByToolMaxLen(t *testing.T) { + p := &stubCompactProgressPlatform{ + stubPlatformEngine: stubPlatformEngine{n: "feishu"}, + style: "card", + supportPayload: true, + } + e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) + e.SetDisplayConfig(DisplayCfg{ + ThinkingMessages: true, + ThinkingMaxLen: 300, + ToolMaxLen: 50, + ToolMessages: true, + Mode: "full", + }) + sessionKey := "feishu:user-card-truncate" + session := e.sessions.GetOrCreateActive(sessionKey) + agentSession := newControllableSession("s-card-truncate") + state := &interactiveState{ + agentSession: agentSession, + platform: p, + replyCtx: "ctx-card-truncate", + } + e.interactiveStates[sessionKey] = state + + longInput := strings.Repeat("abcdefghij", 12) // 120 chars + agentSession.events <- Event{Type: EventThinking, Content: "Plan"} + toolEvt := Event{Type: EventToolUse, ToolName: "Bash", ToolInput: longInput} + agentSession.events <- toolEvt + agentSession.events <- Event{Type: EventText, Content: "done"} + agentSession.events <- Event{Type: EventResult, Content: "done", Done: true} + + e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m-card-truncate", time.Now(), nil, nil, state.replyCtx) + + if toolEvt.ToolInput != longInput { + t.Fatalf("event.ToolInput was mutated: got len=%d, want len=%d", len(toolEvt.ToolInput), len(longInput)) + } + + edits := p.getPreviewEdits() + var toolUseText string + for _, edit := range edits { + payload, ok := ParseProgressCardPayload(edit) + if !ok { + continue + } + for _, item := range payload.Items { + if item.Kind == ProgressEntryToolUse { + toolUseText = item.Text + } + } + } + if toolUseText == "" { + t.Fatalf("no ProgressEntryToolUse found in any card edit; edits=%v", edits) + } + if utf8.RuneCountInString(toolUseText) > 50+len("...") { + t.Fatalf("card tool input not truncated: runeCount=%d, content=%q", utf8.RuneCountInString(toolUseText), toolUseText) + } + if !strings.HasSuffix(toolUseText, "...") { + t.Fatalf("expected truncated tool input to end with '...', got %q", toolUseText) + } + if !strings.HasPrefix(toolUseText, "abcdefghij") { + t.Fatalf("expected truncated tool input to start with original prefix, got %q", toolUseText) + } +} + func TestProcessInteractiveEvents_RichCardShowsThinkingContent(t *testing.T) { p := &stubCompactProgressPlatform{ stubPlatformEngine: stubPlatformEngine{n: "feishu"}, diff --git a/core/i18n.go b/core/i18n.go index 410588a041..6822451539 100644 --- a/core/i18n.go +++ b/core/i18n.go @@ -2202,11 +2202,11 @@ var messages = map[MsgKey]map[Language]string{ LangSpanish: "Modelo actual: %s", }, MsgModelChanged: { - LangEnglish: "Model switched to `%s`. New sessions will use this model.", - LangChinese: "模型已切换为 `%s`,新会话将使用此模型。", - LangTraditionalChinese: "模型已切換為 `%s`,新會話將使用此模型。", - LangJapanese: "モデルを `%s` に切り替えました。新しいセッションで使用されます。", - LangSpanish: "Modelo cambiado a `%s`. Las nuevas sesiones usarán este modelo.", + LangEnglish: "Model switched to `%s`. This session and all future sessions will use it.", + LangChinese: "模型已切换为 `%s`,当前会话与后续会话均使用此模型。", + LangTraditionalChinese: "模型已切換為 `%s`,當前會話與後續會話均使用此模型。", + LangJapanese: "モデルを `%s` に切り替えました。このセッションと今後のセッションで使用されます。", + LangSpanish: "Modelo cambiado a `%s`. Esta sesión y las futuras usarán este modelo.", }, MsgModelChangeFailed: { LangEnglish: "❌ Failed to change model: %v", diff --git a/core/message.go b/core/message.go index e9614723d0..fc071c952f 100644 --- a/core/message.go +++ b/core/message.go @@ -34,6 +34,29 @@ func MergeEnv(base, extra []string) []string { return append(merged, extra...) } +// InjectedAgentEnv returns the env vars cc-connect injects into a spawned +// agent process so in-process extensions can learn cc-connect's runtime state. +// The CC_ prefix marks these vars as cc-connect's public extension contract, +// alongside CC_PROJECT / CC_SESSION_KEY / CC_DATA_DIR that the engine injects +// as session env. +// +// Currently only the permission mode is exposed: +// +// CC_PERMISSION_MODE — the session's permission mode ("default" | "yolo"). +// Extensions such as the pi permission-gate read it to auto-approve tool +// calls in yolo mode. An empty mode returns nil, so non-yolo sessions see +// no injected var. +// +// Kept as a single core helper so every agent opts into the same convention +// instead of hardcoding the variable name; extending the contract (e.g. +// CC_MODEL, CC_THINKING) only means extending this function. +func InjectedAgentEnv(mode string) []string { + if mode == "" { + return nil + } + return []string{"CC_PERMISSION_MODE=" + mode} +} + // CheckAllowFrom logs a security warning at startup when allow_from is not // configured (defaults to permit-all). Platforms should call this during init. func CheckAllowFrom(platform, allowFrom string) { diff --git a/core/message_test.go b/core/message_test.go index 000ca68ff9..268cae5f32 100644 --- a/core/message_test.go +++ b/core/message_test.go @@ -49,6 +49,19 @@ func TestUnauthorizedAccessMessage(t *testing.T) { } } +func TestInjectedAgentEnv(t *testing.T) { + // Empty mode must yield no injected var (do-no-harm for non-yolo sessions). + if got := InjectedAgentEnv(""); got != nil { + t.Fatalf("InjectedAgentEnv(\"\") = %v, want nil", got) + } + + // Non-empty mode must produce the single CC_PERMISSION_MODE entry. + got := InjectedAgentEnv("yolo") + if len(got) != 1 || got[0] != "CC_PERMISSION_MODE=yolo" { + t.Fatalf("InjectedAgentEnv(\"yolo\") = %v, want [CC_PERMISSION_MODE=yolo]", got) + } +} + // TestSaveFilesToDisk_RejectsPathTraversal is a regression test for a real // path-traversal vulnerability in SaveFilesToDisk: the attachment FileName // (which comes from user-controlled IM/HTTP upload metadata) was passed diff --git a/core/privileged_test.go b/core/privileged_test.go new file mode 100644 index 0000000000..12d7ae7bb0 --- /dev/null +++ b/core/privileged_test.go @@ -0,0 +1,106 @@ +package core + +import ( + "strings" + "testing" +) + +func TestIsPrivilegedCommandInvocation_StaticListUnchanged(t *testing.T) { + for cmd := range privilegedCommands { + if !isPrivilegedCommandInvocation(cmd, nil) { + t.Errorf("%q should still be privileged even without args", cmd) + } + if !isPrivilegedCommandInvocation(cmd, []string{"whatever"}) { + t.Errorf("%q should still be privileged with arbitrary args", cmd) + } + } +} + +func TestIsPrivilegedCommandInvocation_CommandsSubcommandGate(t *testing.T) { + if isPrivilegedCommandInvocation("commands", nil) { + t.Fatal("/commands without subcommand must not be privileged") + } + for _, sub := range []string{"list", "add", "del", "delete", "rm", "remove"} { + if isPrivilegedCommandInvocation("commands", []string{sub}) { + t.Errorf("/commands %s must NOT require admin", sub) + } + } + for _, sub := range []string{"addexec", "ADDEXEC", "addEx"} { + if !isPrivilegedCommandInvocation("commands", []string{sub}) { + t.Errorf("/commands %s must require admin", sub) + } + } +} + +func TestIsPrivilegedCommandInvocation_CronSubcommandGate(t *testing.T) { + if isPrivilegedCommandInvocation("cron", nil) { + t.Fatal("/cron without subcommand must not be privileged") + } + for _, sub := range []string{"add", "list", "del", "enable", "disable", "mute", "unmute", "setup"} { + if isPrivilegedCommandInvocation("cron", []string{sub}) { + t.Errorf("/cron %s must NOT require admin", sub) + } + } + for _, sub := range []string{"addexec", "ADDEXEC"} { + if !isPrivilegedCommandInvocation("cron", []string{sub}) { + t.Errorf("/cron %s must require admin", sub) + } + } +} + +func TestIsPrivilegedCommandInvocation_UnknownCmdNotPrivileged(t *testing.T) { + if isPrivilegedCommandInvocation("help", []string{"addexec"}) { + t.Error("/help addexec is not a real privileged path; only commands/cron addexec are") + } +} + +func TestHandleCommand_CommandsAddexecBlocksNonAdmin(t *testing.T) { + p := &stubPlatformEngine{n: "test"} + e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) + e.SetAdminFrom("") // no admins → addexec must be denied for everyone + + msg := &Message{UserID: "u", Platform: "test", ReplyCtx: "rctx"} + handled := e.handleCommand(p, msg, "/commands addexec foo bar") + if !handled { + t.Fatal("addexec invocation must be intercepted, not forwarded to agent") + } + sent := p.getSent() + if len(sent) == 0 || !strings.Contains(strings.ToLower(sent[0]), "admin") { + t.Fatalf("expected admin-required reply; got %#v", sent) + } +} + +func TestHandleCommand_CommandsListNoAdmin(t *testing.T) { + p := &stubPlatformEngine{n: "test"} + e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) + e.SetAdminFrom("") + + msg := &Message{UserID: "u", Platform: "test", ReplyCtx: "rctx"} + handled := e.handleCommand(p, msg, "/commands list") + if !handled { + t.Fatal("/commands list must be handled by cc-connect") + } + for _, s := range p.getSent() { + if strings.Contains(strings.ToLower(s), "admin") && + strings.Contains(strings.ToLower(s), "required") { + t.Errorf("/commands list must NOT be admin-gated; got %q", s) + } + } +} + +func TestHandleCommand_CronAddexecBlocksNonAdmin(t *testing.T) { + p := &stubPlatformEngine{n: "test"} + e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) + e.SetAdminFrom("") + + msg := &Message{UserID: "u", Platform: "test", ReplyCtx: "rctx"} + handled := e.handleCommand(p, msg, "/cron addexec daily echo hi") + if !handled { + t.Fatal("cron addexec must be intercepted") + } + sent := p.getSent() + if len(sent) == 0 || !strings.Contains(strings.ToLower(sent[0]), "admin") { + t.Fatalf("expected admin-required reply; got %#v", sent) + } +} + diff --git a/daemon/check_linger_other.go b/daemon/check_linger_other.go new file mode 100644 index 0000000000..38fca19acf --- /dev/null +++ b/daemon/check_linger_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package daemon + +// CheckLinger is a stub for non-Linux platforms where systemd linger does not +// apply. Returns (true, "") so callers skip the linger warning. +func CheckLinger() (enabled bool, user string) { + return true, "" +} diff --git a/docs/googlechat.md b/docs/googlechat.md new file mode 100644 index 0000000000..514c5f9794 --- /dev/null +++ b/docs/googlechat.md @@ -0,0 +1,249 @@ +# Google Chat Setup Guide + +This guide walks you through connecting **cc-connect** to Google Chat, so you can chat with your local Claude Code from a Google Chat space or DM. + +cc-connect uses a registered **Google Chat app** whose **Cloud Pub/Sub connection** publishes events to a topic. cc-connect pulls that topic locally (native Go, no extra binaries) and replies through the Chat REST API as the app's service account. This means: + +- **No public IP / domain / reverse proxy** — events arrive over a Pub/Sub pull. +- **No subscription expiry or per-restart resource leak** — the Pub/Sub subscription is fixed (unlike the Workspace Events API, whose subscriptions expire and are recreated each run). + +## Prerequisites + +- A **Google Workspace** account. The Google Chat API is only available to Workspace users; consumer `@gmail.com` accounts cannot configure a Chat app. (This applies to every Chat-app/REST integration, not just cc-connect.) +- A Google Cloud project (billing enabled). +- `gcloud` CLI installed and authenticated (for the one-time GCP setup below). +- Claude Code installed and configured. + +> ℹ️ **One Chat app per project.** Google Cloud allows exactly one Chat app configuration per project. To run multiple Chat apps, use separate projects. + +--- + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Google Chat │ +│ │ +│ Your message ─→ Chat app (Cloud Pub/Sub connection) │ +│ │ │ +│ ▼ │ +│ Cloud Pub/Sub topic ──→ subscription │ +└───────────────────────────────────────┼──────────────────────┘ + │ streaming pull (no public IP) + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ Your Local Machine │ +│ │ +│ cc-connect ◄──► Claude Code CLI ◄──► Your Project Code │ +│ │ │ +│ └─ reply: Chat REST API as service account (chat.bot) │ +└──────────────────────────────────────────────────────────────┘ +``` + +Both directions are native Go and authenticate with the **same service-account key** (`chat.bot` scope): + +- **Receive**: cc-connect opens a streaming pull on the subscription via the Cloud Pub/Sub client. The service account needs `roles/pubsub.subscriber` on the subscription. +- **Send**: cc-connect posts to the Chat REST API as the service account, so replies appear as the bot. + +--- + +## Step 1: Enable APIs + +In your Google Cloud project, enable: + +- **Google Chat API** (`chat.googleapis.com`) +- **Cloud Pub/Sub API** (`pubsub.googleapis.com`) + +```bash +gcloud services enable chat.googleapis.com pubsub.googleapis.com --project YOUR_PROJECT_ID +``` + +--- + +## Step 2: Create the Pub/Sub topic and subscription + +Create a topic, allow Google Chat to publish to it, and create a pull subscription that cc-connect will read. + +```bash +PROJECT_ID=YOUR_PROJECT_ID + +# Topic the Chat app publishes events to +gcloud pubsub topics create cc-connect-chat --project "$PROJECT_ID" + +# Allow Google Chat's system service account to publish +gcloud pubsub topics add-iam-policy-binding cc-connect-chat --project "$PROJECT_ID" \ + --member='serviceAccount:chat-api-push@system.gserviceaccount.com' \ + --role='roles/pubsub.publisher' + +# Pull subscription cc-connect reads +gcloud pubsub subscriptions create cc-connect-chat-sub --topic cc-connect-chat --project "$PROJECT_ID" +``` + +The subscription resource name is `projects/YOUR_PROJECT_ID/subscriptions/cc-connect-chat-sub` — you'll put this in `config.toml`. + +--- + +## Step 3: Create a service account + +cc-connect uses one service account for **both** pulling events and replying. + +```bash +gcloud iam service-accounts create cc-connect-bot --project "$PROJECT_ID" \ + --display-name "cc-connect Chat bot" + +# Allow the service account to pull from the subscription +gcloud pubsub subscriptions add-iam-policy-binding cc-connect-chat-sub --project "$PROJECT_ID" \ + --member="serviceAccount:cc-connect-bot@${PROJECT_ID}.iam.gserviceaccount.com" \ + --role="roles/pubsub.subscriber" + +# Download a JSON key (this is a secret — store it safely, e.g. ~/.config/cc-connect/) +mkdir -p ~/.config/cc-connect +gcloud iam service-accounts keys create ~/.config/cc-connect/cc-connect-bot-key.json \ + --iam-account="cc-connect-bot@${PROJECT_ID}.iam.gserviceaccount.com" \ + --project "$PROJECT_ID" +chmod 600 ~/.config/cc-connect/cc-connect-bot-key.json +``` + +> ⚠️ The key file grants the bot's identity — keep it private and never commit it. + +- **Send**: no project-level role is needed — a service account calling the Chat API with the `chat.bot` scope acts as the configured Chat app. +- **Receive**: the `roles/pubsub.subscriber` binding above (on the subscription) is what lets the service account pull events. + +--- + +## Step 4: Configure the Chat app + +Go to **[Chat API → Configuration](https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat)** (select your project) and set: + +| Section | Setting | +|---------|---------| +| **App status** | **Live — available to users** (the app must be LIVE or it cannot send replies) | +| Build as a Workspace add-on | **Unchecked** (Cloud Pub/Sub connection requires the classic app model; do **not** convert to add-on — it's one-way) | +| **Application info** | App name (e.g. `Claude`), Avatar URL (HTTPS square image), Description | +| Interactive features | Enable | +| **Functionality** | ☑ Receive 1:1 messages (for DM use) and/or ☑ Join spaces and group conversations (for @mention in spaces) | +| **Connection settings** | **Cloud Pub/Sub** → topic `projects/YOUR_PROJECT_ID/topics/cc-connect-chat` | +| **Visibility** | Make available to specific people → enter **your own email only** (keeps the app private to you; up to 5 people or a group) | + +Click **Save**. + +--- + +## Step 5: Configure cc-connect + +Add a `googlechat` platform to your `config.toml`: + +```toml +[[projects]] +name = "my-project" + +[projects.agent] +type = "claudecode" + +[projects.agent.options] +work_dir = "/path/to/your/project" # must be a real directory +mode = "default" + +[[projects.platforms]] +type = "googlechat" + +[projects.platforms.options] +# Pub/Sub subscription the Chat app publishes to (required) +subscription = "projects/YOUR_PROJECT_ID/subscriptions/cc-connect-chat-sub" +# Service-account key, used to pull events AND reply as the bot (chat.bot) (required) +credentials_file = "/Users/you/.config/cc-connect/cc-connect-bot-key.json" +# Allowed sender IDs (e.g. "users/1234567890"); "*" = everyone (default). +allow_from = "*" +# "space" (default) | "thread" | "user" +session_scope = "space" +``` + +### Options reference + +| Option | Required | Purpose | +|--------|----------|---------| +| `subscription` | ✅ | Pub/Sub subscription the Chat app publishes to | +| `credentials_file` | ✅ | Service-account JSON key, used to pull events and reply (`chat.bot`) | +| `allow_from` | — | Comma-separated allowed sender IDs; `*` = all | +| `session_scope` | — | `space` (default) / `thread` / `user` | + +--- + +## Step 6: Start cc-connect + +```bash +cc-connect +# or: cc-connect --config /path/to/config.toml +``` + +You should see: + +``` +level=INFO msg="googlechat: started" subscription=projects/.../cc-connect-chat-sub scope=space +``` + +--- + +## Step 7: Start chatting + +- **DM**: in Google Chat, search for your app name, open a DM, and send a message. Every message in the DM reaches the bot. +- **Space**: add the app to a space and **@mention** it (in spaces, a Chat app only receives messages that @mention it). + +The bot replies in-thread as the app. + +> 💡 Find your own sender ID for `allow_from` by running with `[log] level = "debug"` and sending a message — the log shows `sender=users/`. Then restrict `allow_from` to that ID. + +--- + +## Key facts + +- **The Chat app must be Live.** If App status is not *Live*, the app neither receives events nor sends replies. Set it to *Live — available to users* in the Chat API Configuration tab. +- **One service account does both.** The key in `credentials_file` pulls events (needs `roles/pubsub.subscriber` on the subscription) and posts replies (`chat.bot`). +- **Fixed subscription = no expiry, no leak.** Unlike the Workspace Events API, the Chat app's Pub/Sub connection uses one stable topic/subscription, so there is no subscription to renew and nothing is recreated on restart. +- **One Chat app per Google Cloud project.** + +--- + +## FAQ + +### Q: I sent a message but the bot doesn't respond at all. + +1. Is the Chat app **App status = Live**? (required for both receiving and sending) +2. Is `cc-connect` running? Check the log for `googlechat: started`. +3. Does the service account have `roles/pubsub.subscriber` on the subscription? (receive path) +4. Is `work_dir` a real directory? The agent can't start otherwise. +5. Give the agent a few seconds on the first message (cold start). + +### Q: It receives but never replies. + +1. Is `credentials_file` readable and a service-account key in the same project? +2. Check logs for `googlechat: send: status ...`. + +### Q: "Google Chat app is inactive" error when sending. + +Set App status to **Live — available to users** in the Chat API Configuration tab. + +### Q: "Google Chat API is only available to Google Workspace users." + +You're signed in with a consumer account. Use a Google Workspace account. + +### Q: Connection settings has no "Cloud Pub/Sub" option. + +The "Build this Chat app as a Google Workspace add-on" checkbox is on. Cloud Pub/Sub is available in the classic model — clear that checkbox (or keep the add-on model, which also supports Pub/Sub but is configured differently). + +--- + +## References + +- [Configure the Google Chat API](https://developers.google.com/workspace/chat/configure-chat-api) +- [Build a Google Chat app behind a firewall with Pub/Sub](https://developers.google.com/workspace/chat/quickstart/pub-sub) +- [Authenticate as a Chat app (service account)](https://developers.google.com/workspace/chat/authenticate-authorize) + +--- + +## See Also + +- [Slack Setup](./slack.md) +- [Feishu Setup](./feishu.md) +- [Telegram Setup](./telegram.md) +- [Back to README](../README.md) diff --git a/go.mod b/go.mod index 1d7bdeb7e9..16ad572bf6 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/chenhg5/cc-connect go 1.25.0 require ( + cloud.google.com/go/pubsub/v2 v2.4.0 github.com/BurntSushi/toml v1.6.0 github.com/bwmarrin/discordgo v0.29.0 github.com/charmbracelet/bubbles v1.0.0 @@ -20,14 +21,22 @@ require ( github.com/slack-go/slack v0.16.0 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.50.0 + golang.org/x/oauth2 v0.36.0 + google.golang.org/api v0.272.0 maunium.net/go/mautrix v0.27.0 modernc.org/sqlite v1.49.1 rsc.io/qr v0.2.0 ) require ( + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.5.3 // indirect filippo.io/edwards25519 v1.2.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect @@ -38,7 +47,13 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect + github.com/googleapis/gax-go/v2 v2.18.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -61,12 +76,29 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.8.2 // indirect + go.einride.tech/aip v0.83.0 // indirect go.mau.fi/util v0.9.8 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/sdk v1.42.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/term v0.42.0 // indirect golang.org/x/text v0.36.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.72.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 6ec8b05740..3b1775fb7e 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,19 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/pubsub/v2 v2.4.0 h1:oMKNiBQpXImRWnHYla9uSU66ZzByZwBSCJOEs/pTKVg= +cloud.google.com/go/pubsub/v2 v2.4.0/go.mod h1:2lS/XQKq5qtOMs6kHBK+WX1ytUC36kLl2ig3zqsGUx8= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= @@ -10,6 +24,9 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -26,33 +43,83 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payR github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-telegram/bot v1.20.0 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc= github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8QA86LI= +github.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -60,6 +127,10 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= github.com/line/line-bot-sdk-go/v8 v8.19.0 h1:5FD/1SprRZ8Y0FiUI6syYiBewOs0ak2tuUBMYN0wzE4= @@ -90,21 +161,32 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/slack-go/slack v0.16.0 h1:khp/WCFv+Hb/B/AJaAwvcxKun0hM6grN0bUZ8xG60P8= github.com/slack-go/slack v0.16.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -123,32 +205,67 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.einride.tech/aip v0.83.0 h1:TI21IdeOnLTwZEJ3BxtImIZk6bsN2Q+sd0x99SLiQ+M= +go.einride.tech/aip v0.83.0/go.mod h1:E8+wdTApA70odnpFzJgsGogHozC2JCIhFJBKPr8bVig= go.mau.fi/util v0.9.8 h1:+/jf8eM2dAT2wx9UidmaneH28r/CSCKCniCyby1qWz8= go.mau.fi/util v0.9.8/go.mod h1:up/5mbzH2M1pSBNXqRxODn8dg/hEKbLJu92W4/SNAX0= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -164,7 +281,13 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= @@ -174,10 +297,47 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= +google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc= +google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI= +google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js= +google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= maunium.net/go/mautrix v0.27.0 h1:yfEYwoIluVWkofUgbZl9gP4i5nQTF+QNsxtb+r5bKlM= maunium.net/go/mautrix v0.27.0/go.mod h1:7QpEQiTy6p4LHkXXaZI+N46tGYy8HMhD0JjzZAFoFWs= modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= diff --git a/platform/cloud-web/gateway_test.go b/platform/cloud-web/gateway_test.go index c46cdd4dd8..272607b324 100644 --- a/platform/cloud-web/gateway_test.go +++ b/platform/cloud-web/gateway_test.go @@ -32,7 +32,11 @@ func TestGatewayWebhook(t *testing.T) { if gt.listener == nil { t.Fatal("gateway listener not started") } - url := "http://" + gt.listener.Addr().String() + gt.webhookPath + // listener.Addr() returns the IPv6 wildcard "[::]:port" on dual-stack + // systems. The wildcard address is not dialable — the test client must + // use the loopback form to actually reach the listener. + addr := strings.Replace(gt.listener.Addr().String(), "[::]", "[::1]", 1) + url := "http://" + addr + gt.webhookPath body, _ := json.Marshal(wireInboundMessage{ Type: "message", MsgID: "g1", SessionKey: "cloud_web:x:y", UserID: "y", Content: "from gateway", ReplyCtx: "ctx", diff --git a/platform/dingtalk/dingtalk.go b/platform/dingtalk/dingtalk.go index c7c04c23cc..2454ec0a5d 100644 --- a/platform/dingtalk/dingtalk.go +++ b/platform/dingtalk/dingtalk.go @@ -14,6 +14,7 @@ import ( "strings" "sync" "time" + "unicode" "github.com/chenhg5/cc-connect/core" @@ -60,6 +61,8 @@ const ( defaultReactionEmoji = "🤔Thinking" customTextEmotionID = "2659900" customTextEmotionBackground = "im_bg_1" + cardTitleMaxRunes = 20 + cardTitleFallback = "reply" ) type downloadResponse struct { @@ -851,7 +854,7 @@ func (p *Platform) Reply(ctx context.Context, rctx any, content string) error { payload := map[string]any{ "msgtype": "markdown", - "markdown": map[string]string{"title": "reply", "text": content}, + "markdown": map[string]string{"title": cardTitleFromContent(content), "text": content}, } if len(atUserIds) > 0 { payload["at"] = map[string]any{ @@ -1661,7 +1664,7 @@ func (p *Platform) sendProactiveMessage(ctx context.Context, rc replyContext, co } else if rc.senderStaffId != "" { // Direct message via /v1.0/robot/oToMessages/batchSend apiURL = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend" - msgParam, _ := json.Marshal(map[string]string{"title": "reply", "text": content}) + msgParam, _ := json.Marshal(map[string]string{"title": cardTitleFromContent(content), "text": content}) requestBody = map[string]any{ "robotCode": p.robotCode, "userIds": []string{rc.senderStaffId}, @@ -1751,3 +1754,57 @@ func preprocessDingTalkMarkdown(s string) string { } return sb.String() } + +// cardTitleFromContent derives a short single-line preview title from message +// content, suitable for the DingTalk chat list and notification previews +// (the `markdown.title` field on sampleMarkdown messages). +// +// Behaviour: +// - Strips markdown formatting via core.StripMarkdown so titles don't show +// leftover ** / # / ` markers. +// - Trims whitespace, takes the first non-empty line, and truncates to +// cardTitleMaxRunes runes (Chinese / emoji counted as 1 rune each so we +// never split a multi-byte character mid-codepoint). +// - Falls back to cardTitleFallback when nothing readable remains — empty +// input, whitespace-only, pure emoji, or content that strips down to +// only orphan markdown markers (e.g. "****", "####"). Pure emoji is +// treated as not-readable on purpose: the user's spec lists emoji-only +// as a fallback case, and showing a lone 🎉 as a DingTalk chat list +// preview is usually less helpful than the previous "reply". +// +// Previously both Reply and sendProactiveMessage hardcoded "reply", which +// made every DingTalk chat list entry look identical regardless of what the +// agent actually said (#1269). +func cardTitleFromContent(content string) string { + if strings.TrimSpace(content) == "" { + return cardTitleFallback + } + stripped := strings.TrimSpace(core.StripMarkdown(content)) + if stripped == "" || !hasReadableChar(stripped) { + return cardTitleFallback + } + if idx := strings.IndexByte(stripped, '\n'); idx >= 0 { + stripped = strings.TrimSpace(stripped[:idx]) + if stripped == "" || !hasReadableChar(stripped) { + return cardTitleFallback + } + } + runes := []rune(stripped) + if len(runes) > cardTitleMaxRunes { + return string(runes[:cardTitleMaxRunes]) + } + return stripped +} + +// hasReadableChar reports whether s contains at least one letter or digit +// (including CJK ideographs). Used to decide whether a stripped title has +// enough semantic content to show in the chat list — pure markdown markers, +// pure emoji, and pure whitespace are treated as "not readable". +func hasReadableChar(s string) bool { + for _, r := range s { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + return true + } + } + return false +} diff --git a/platform/dingtalk/dingtalk_test.go b/platform/dingtalk/dingtalk_test.go index a7476ebbb0..729d426029 100644 --- a/platform/dingtalk/dingtalk_test.go +++ b/platform/dingtalk/dingtalk_test.go @@ -1322,3 +1322,122 @@ func TestReply_NoAtUserIdsWhenNoMention(t *testing.T) { t.Fatal("timed out waiting for reply") } } + +// ────────────────────────────────────────────────────────────── +// Card title derivation (Fixes #1269) +// ────────────────────────────────────────────────────────────── + +func TestCardTitleFromContent(t *testing.T) { + // Long input — 30 ASCII chars. Verifies plain truncation at 20 runes. + longPlain := "012345678901234567890123456789" + if len(longPlain) != 30 { + t.Fatalf("longPlain fixture = %d chars, want 30", len(longPlain)) + } + + // Chinese / CJK fixture — 22 runes. Verifies []rune counting so we never + // split a multi-byte character mid-codepoint. + chinese22 := "你好世界这是一段测试中文超过二十字符测试数据" + if got := len([]rune(chinese22)); got != 22 { + t.Fatalf("chinese22 fixture = %d runes, want 22", got) + } + + tests := []struct { + name string + in string + want string + }{ + // ── Fallback cases (#1269 acceptance: empty / pure formatting) ── + {"empty", "", cardTitleFallback}, + {"whitespace only", " \n\n \t", cardTitleFallback}, + {"pure bold markers", "****", cardTitleFallback}, + {"pure heading markers", "####", cardTitleFallback}, + {"pure italic markers", "**", cardTitleFallback}, + {"pure emoji (fallback per spec)", "🎉🎊✨", cardTitleFallback}, + {"empty fenced code block", "```\n```", cardTitleFallback}, + + // ── Plain text — return as-is (within limit) ── + {"short plain", "hello world", "hello world"}, + {"plain exactly 20 runes", "01234567890123456789", "01234567890123456789"}, + + // ── Plain text truncation (ASCII) ── + {"plain 30 chars truncated to 20", longPlain, "01234567890123456789"}, + + // ── Markdown stripping — format removed, then first line truncated ── + {"bold stripped", "**hello** world", "hello world"}, + {"italic stripped", "this is *italic* text", "this is italic text"}, + {"heading stripped", "## My Title\nbody", "My Title"}, + {"inline code stripped", "use `os.path.join` now", "use os.path.join now"}, + {"link keeps text and url", "[a](b) c", "a (b) c"}, + + // ── First-line wins (DingTalk title is single-line) ── + {"first line wins", "first line\nsecond line", "first line"}, + {"first line wins after markdown strip", "# heading\nbody", "heading"}, + {"markdown strip keeps first non-empty line", "**bold first**\nplain second", "bold first"}, + + // ── Truncation respects markdown stripping (#1269: don't break markdown) ── + // The marker must be removed BEFORE truncation so we never cut mid-**bold**. + {"bold span not split", "**" + strings.Repeat("a", 25) + "**", strings.Repeat("a", 20)}, + + // ── Multi-byte / CJK rune counting ── + {"chinese 22 runes truncated to 20", chinese22, "你好世界这是一段测试中文超过二十字符测试"}, + {"chinese under limit passes through", "你好世界", "你好世界"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cardTitleFromContent(tt.in) + if got != tt.want { + t.Errorf("cardTitleFromContent(%q) = %q, want %q", tt.in, got, tt.want) + } + // Defensive: every returned title must fit within the cap so the + // DingTalk chat list never sees a runaway title. + if got != cardTitleFallback && len([]rune(got)) > cardTitleMaxRunes { + t.Errorf("cardTitleFromContent(%q) returned %d runes, exceeds cap %d", + tt.in, len([]rune(got)), cardTitleMaxRunes) + } + }) + } +} + +// TestCardTitleFromContent_UsedInReplyPayload is a smoke test that the +// title derivation actually flows into the `markdown.title` field of the +// outgoing sessionWebhook payload (Reply path). It captures the POST body +// and asserts the title no longer equals the old hardcoded "reply". +func TestCardTitleFromContent_UsedInReplyPayload(t *testing.T) { + gotPayload := make(chan map[string]any, 1) + sessionWebhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var p map[string]any + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + t.Errorf("decode: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + gotPayload <- p + w.WriteHeader(http.StatusOK) + })) + defer sessionWebhook.Close() + + p := &Platform{} + rc := replyContext{sessionWebhook: sessionWebhook.URL} + if err := p.Reply(context.Background(), rc, "**Standup notes** for today — see PRs."); err != nil { + t.Fatalf("Reply: %v", err) + } + + select { + case payload := <-gotPayload: + markdown, ok := payload["markdown"].(map[string]any) + if !ok { + t.Fatalf("payload[markdown] = %T, want map[string]any", payload["markdown"]) + } + title, _ := markdown["title"].(string) + if title == "reply" { + t.Errorf("title still hardcoded to %q after fix — regression of #1269", title) + } + want := "Standup notes for to" + if title != want { + t.Errorf("title = %q, want %q", title, want) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for reply payload") + } +} diff --git a/platform/discord/discord.go b/platform/discord/discord.go index b45bc980fc..b51619e129 100644 --- a/platform/discord/discord.go +++ b/platform/discord/discord.go @@ -47,7 +47,7 @@ type progressPlatform struct { type Platform struct { token string allowFrom string - guildID string // optional: per-guild registration (instant) vs global (up to 1h propagation) + guildID string // optional: per-guild registration (instant) vs global (up to 1h propagation) progressStyle string groupReplyAllGuilds []string // guild IDs where groupReplyAll is active; "*" = all guilds shareSessionInChannel bool @@ -848,7 +848,7 @@ func (p *Platform) handleInteraction(s *discordgo.Session, i *discordgo.Interact MessageID: i.ID, ChannelID: i.ChannelID, UserID: userID, UserName: userName, - Content: cmdText, ReplyCtx: rctx, + Content: cmdText, ReplyCtx: rctx, } msg.ChatName, _ = p.ResolveChannelName(channelID) p.dispatchMessage(msg) @@ -888,12 +888,12 @@ func reconstructCommand(data discordgo.ApplicationCommandInteractionData) string func (p *Platform) handleComponentInteraction(s *discordgo.Session, i *discordgo.InteractionCreate, userID, userName string) { data := i.MessageComponentData() - if !strings.HasPrefix(data.CustomID, "cmd:") { + command, isPermissionResponse, ok := discordComponentCommand(data.CustomID) + if !ok { slog.Debug("discord: unknown component interaction", "custom_id", data.CustomID) return } - command := strings.TrimPrefix(data.CustomID, "cmd:") origText := "" if i.Message != nil { origText = i.Message.Content @@ -922,18 +922,34 @@ func (p *Platform) handleComponentInteraction(s *discordgo.Session, i *discordgo } chatName, _ := p.ResolveChannelName(channelID) p.dispatchMessage(&core.Message{ - SessionKey: sessionKey, - ChannelKey: channelKey, - Platform: "discord", - MessageID: i.ID, - UserID: userID, - UserName: userName, - Content: command, - ChatName: chatName, - ReplyCtx: rc, + SessionKey: sessionKey, + ChannelKey: channelKey, + Platform: "discord", + MessageID: i.ID, + UserID: userID, + UserName: userName, + Content: command, + ChatName: chatName, + ReplyCtx: rc, + IsPermissionResponse: isPermissionResponse, }) } +func discordComponentCommand(customID string) (command string, isPermissionResponse bool, ok bool) { + if command, found := strings.CutPrefix(customID, "cmd:"); found && command != "" { + return command, false, true + } + if action, found := strings.CutPrefix(customID, "perm:"); found { + switch action { + case "allow", "deny": + return action, true, true + case "allow_all": + return "allow all", true, true + } + } + return "", false, false +} + func (p *Platform) Reply(ctx context.Context, rctx any, content string) error { switch rc := rctx.(type) { case *interactionReplyCtx: @@ -1160,10 +1176,6 @@ func buildDiscordActionRows(rows [][]core.ButtonOption) []discordgo.MessageCompo } func (p *Platform) SendWithButtons(ctx context.Context, rctx any, content string, buttons [][]core.ButtonOption) error { - rc, ok := rctx.(*interactionReplyCtx) - if !ok { - return core.ErrNotSupported - } if len(buttons) == 0 { return fmt.Errorf("discord: no buttons provided") } @@ -1171,17 +1183,32 @@ func (p *Platform) SendWithButtons(ctx context.Context, rctx any, content string if len(components) == 0 { return fmt.Errorf("discord: no buttons provided") } - if err := p.sendInteraction(rc, content); err != nil { - return err - } - _, err := p.session.FollowupMessageCreate(rc.interaction, true, &discordgo.WebhookParams{ - Content: content, - Components: components, - }) - if err != nil { - return fmt.Errorf("discord: send button followup: %w", err) + + switch rc := rctx.(type) { + case *interactionReplyCtx: + if err := p.sendInteraction(rc, content); err != nil { + return err + } + _, err := p.session.FollowupMessageCreate(rc.interaction, true, &discordgo.WebhookParams{ + Content: content, + Components: components, + }) + if err != nil { + return fmt.Errorf("discord: send button followup: %w", err) + } + return nil + case replyContext: + _, err := p.session.ChannelMessageSendComplex(rc.targetChannelID(), &discordgo.MessageSend{ + Content: content, + Components: components, + }) + if err != nil { + return fmt.Errorf("discord: send channel buttons: %w", err) + } + return nil + default: + return core.ErrNotSupported } - return nil } func (p *progressPlatform) ProgressUpdateInterval() time.Duration { diff --git a/platform/discord/discord_test.go b/platform/discord/discord_test.go index d22ff2d2d1..195f3d04eb 100644 --- a/platform/discord/discord_test.go +++ b/platform/discord/discord_test.go @@ -512,6 +512,86 @@ func TestSendWithButtons_PreservesMultipleRows(t *testing.T) { } } +func TestSendWithButtons_UsesChannelComponents(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/channels/ch-1/messages") { + t.Fatalf("request path = %q, want channel message", r.URL.Path) + } + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode request: %v", err) + } + if payload["content"] != "approve tool?" { + t.Fatalf("content = %#v, want approve tool?", payload["content"]) + } + components, ok := payload["components"].([]any) + if !ok || len(components) != 1 { + t.Fatalf("components = %#v, want one row", payload["components"]) + } + row := components[0].(map[string]any)["components"].([]any) + if row[0].(map[string]any)["custom_id"] != "perm:allow" { + t.Fatalf("button = %#v, want perm:allow", row[0]) + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"id":"msg-1","channel_id":"ch-1"}`) + })) + defer server.Close() + + p := &Platform{session: newTestDiscordSession(t, server)} + err := p.SendWithButtons(context.Background(), replyContext{channelID: "ch-1"}, "approve tool?", [][]core.ButtonOption{{ + {Text: "Allow", Data: "perm:allow"}, + {Text: "Deny", Data: "perm:deny"}, + }}) + if err != nil { + t.Fatalf("SendWithButtons() error = %v", err) + } +} + +func TestHandleComponentInteraction_DispatchesPermissionResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{}`) + })) + defer server.Close() + + var got *core.Message + p := &Platform{ + session: newTestDiscordSession(t, server), + handler: func(_ core.Platform, msg *core.Message) { + got = msg + }, + } + interaction := &discordgo.InteractionCreate{Interaction: &discordgo.Interaction{ + ID: "interaction-1", + Token: "token-1", + Type: discordgo.InteractionMessageComponent, + ChannelID: "channel-1", + Message: &discordgo.Message{ID: "message-1", Content: "approve tool?"}, + Data: discordgo.MessageComponentInteractionData{CustomID: "perm:allow"}, + }} + + p.handleComponentInteraction(p.session, interaction, "user-1", "user") + + if got == nil { + t.Fatal("permission button did not dispatch a message") + } + if got.Content != "allow" || !got.IsPermissionResponse { + t.Fatalf("message = %#v, want allow permission response", got) + } + if got.SessionKey != "discord:channel-1:user-1" { + t.Fatalf("SessionKey = %q", got.SessionKey) + } +} + +func TestDiscordComponentCommandRejectsUnknownPermissionAction(t *testing.T) { + if command, isPermission, ok := discordComponentCommand("perm:allow_all"); !ok || !isPermission || command != "allow all" { + t.Fatalf("allow-all component = (%q, %v, %v), want (allow all, true, true)", command, isPermission, ok) + } + if _, _, ok := discordComponentCommand("perm:maybe"); ok { + t.Fatal("discordComponentCommand accepted unknown permission action") + } +} + func TestSendFile_SendsChannelAttachment(t *testing.T) { var contentType string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/platform/feishu/feishu.go b/platform/feishu/feishu.go index e852ac0795..35012dcc2e 100644 --- a/platform/feishu/feishu.go +++ b/platform/feishu/feishu.go @@ -107,9 +107,10 @@ func init() { } type replyContext struct { - messageID string - chatID string - sessionKey string + messageID string + chatID string + sessionKey string + bootstrapThread bool } type Platform struct { @@ -1411,8 +1412,13 @@ func (p *Platform) onMessage(ctx context.Context, event *larkim.P2MessageReceive ) // Mark this thread as bot-engaged so subsequent attachment-only messages - // in the same thread can pass through without re-mentioning the bot. - p.markThreadSessionActive(sessionKey) + // in the same thread can pass through without re-mentioning the bot. When + // this is the first accepted message in an existing thread, remember that + // dispatch must bootstrap the agent context from the parent/root message. + rctx.bootstrapThread = p.markThreadSessionActive(sessionKey) + if rctx.bootstrapThread && parentID == "" { + parentID = stringValue(msg.RootId) + } // Dispatch message handling asynchronously so the SDK event loop is not // blocked by IO-heavy operations (image/audio download, handler HTTP calls). @@ -1451,10 +1457,13 @@ func (p *Platform) dispatchMessage(ctx context.Context, msgType, content string, // If this message is a reply to another message, fetch the quoted content // and prepend it so the agent has full context. // Skip quote injection when thread_isolation is enabled and the message is - // inside a thread — the thread already provides conversational context, and - // long quoted prefixes can drown out the user's actual text (issue #764). + // inside an already-engaged thread — the thread provides conversational + // context, and long quoted prefixes can drown out the user's actual text + // (issue #764). The first accepted message in a pre-existing thread is the + // exception: earlier unmentioned messages were never dispatched to the + // agent, so bootstrap its context from the parent/root reply chain once. var quoted quotedMessage - if parentID != "" && !(p.threadIsolation && isThreadSessionKey(sessionKey)) { + if parentID != "" && (!p.threadIsolation || !isThreadSessionKey(sessionKey) || rctx.bootstrapThread) { quoted = p.fetchQuotedMessage(ctx, parentID) } @@ -1468,7 +1477,15 @@ func (p *Platform) dispatchMessage(ctx context.Context, msgType, content string, return } text := stripMentions(textBody.Text, mentions, p.getBotOpenID()) - if text == "" && quoted.text == "" && len(quoted.images) == 0 { + // On-demand quoted-file retrieval (issue #1560): the filter + // decides whether ANY of the quoted file candidates are eligible + // (gates: @bot mention AND same IM user as the trigger). Only + // then do we actually fetch the bytes — never eagerly. Quote + // without mention, or an ordinary un-quoted message, results in + // zero file-resource API calls. + approvedFileMetas := p.filterQuotedFilesForUser(quoted.files, mentions, userID) + quotedFiles := p.downloadQuotedFiles(ctx, approvedFileMetas) + if text == "" && quoted.text == "" && len(quoted.images) == 0 && len(quotedFiles) == 0 { slog.Debug(p.tag()+": dropping empty text after mention stripping", "message_id", messageID, "raw_text_len", len(textBody.Text), @@ -1480,7 +1497,7 @@ func (p *Platform) dispatchMessage(ctx context.Context, msgType, content string, SessionKey: sessionKey, Platform: p.platformName, MessageID: messageID, UserID: userID, UserName: userName, ChatName: chatName, - Content: text, ExtraContent: quoted.text, Images: quoted.images, ReplyCtx: rctx, + Content: text, ExtraContent: quoted.text, Images: quoted.images, Files: quotedFiles, ReplyCtx: rctx, UserMessageTimeMs: createTimeMs, }) @@ -1929,14 +1946,33 @@ func (p *Platform) resolveMentionsInContent(ctx context.Context, chatID, content type chainMessage struct { senderName string senderType string // "user" or "app" - text string - images []core.ImageAttachment - parentID string + senderID string // Feishu open_id (or app_id for bots) — used by the caller + // to enforce same-user privacy when forwarding quoted files. + text string + images []core.ImageAttachment + files []quotedFileMeta + parentID string +} + +// quotedFileMeta records one downloaded-file candidate from a quoted parent +// message. We deliberately keep this as metadata only (no Data bytes) so +// the file-resource API call can be deferred until the dispatcher is sure +// the trigger actually requires it — issue #1560 acceptance rule: +// "quote without mention → no fetch" and "ordinary message → no fetch". +// The Feishu sender id travels with each meta so the dispatcher can drop +// entries whose sender differs from the user who triggered the @bot +// mention (the privacy rule). +type quotedFileMeta struct { + fileKey string + fileName string + messageID string + senderID string } type quotedMessage struct { text string images []core.ImageAttachment + files []quotedFileMeta } // maxReplyChainDepth is the maximum number of parent messages to traverse @@ -1947,6 +1983,8 @@ const maxReplyChainDepth = 5 // is replying to, and returns formatted context plus downloaded attachments. // For multi-level reply chains, it traces parent_id links up to maxReplyChainDepth // levels and returns the full conversation chain. +// Files in the chain are downloaded on-demand; the per-file sender_id is +// kept so the dispatcher can enforce same-user privacy (issue #1560). // Returns empty content on any failure (graceful degradation — the user's own // message is still delivered without the quote). func (p *Platform) fetchQuotedMessage(ctx context.Context, parentID string) quotedMessage { @@ -1954,7 +1992,11 @@ func (p *Platform) fetchQuotedMessage(ctx context.Context, parentID string) quot if len(chain) == 0 { return quotedMessage{} } - return quotedMessage{text: formatReplyChain(chain), images: collectReplyChainImages(chain)} + return quotedMessage{ + text: formatReplyChain(chain), + images: collectReplyChainImages(chain), + files: collectReplyChainFiles(chain), + } } // resolveBotSenderName returns a display name for a bot sender in a quoted @@ -2012,6 +2054,7 @@ func (p *Platform) fetchSingleMessage(ctx context.Context, messageID string) *ch // Extract plain text based on message type. var text string var images []core.ImageAttachment + var files []quotedFileMeta switch item.MsgType { case "text": var textBody struct { @@ -2040,11 +2083,54 @@ func (p *Platform) fetchSingleMessage(ctx context.Context, messageID string) *ch images = append(images, core.ImageAttachment{MimeType: mimeType, Data: imgData}) } } + case "file": + // Quoted file attachment (issue #1560). We do NOT download the file + // body here — that would defeat the "fetch only when bot is + // mentioned + same user" gate. Instead we capture only the + // metadata (file_key, file_name, message_id, sender_id); the + // dispatcher downloads the bytes later if and only if the trigger + // conditions hold. + text = "[file]" + var fileBody struct { + FileKey string `json:"file_key"` + FileName string `json:"file_name"` + } + if err := json.Unmarshal([]byte(content), &fileBody); err == nil && fileBody.FileKey != "" { + files = append(files, quotedFileMeta{ + fileKey: fileBody.FileKey, + fileName: fileBody.FileName, + messageID: messageID, + senderID: item.Sender.ID, + }) + } + case "media": + // Quoted video/audio — same lazy-download treatment as "file": we + // keep only the metadata so the dispatcher can decide whether to + // pull the bytes based on the @bot + same-user gates. + text = "[media]" + var mediaBody struct { + FileKey string `json:"file_key"` + FileName string `json:"file_name"` + } + if err := json.Unmarshal([]byte(content), &mediaBody); err == nil && mediaBody.FileKey != "" { + files = append(files, quotedFileMeta{ + fileKey: mediaBody.FileKey, + fileName: mediaBody.FileName, + messageID: messageID, + senderID: item.Sender.ID, + }) + } case "interactive": text = extractInteractiveCardText(content) default: text = fmt.Sprintf("[%s]", item.MsgType) } + // Empty quoted payloads (no text, no images, no files) are dropped here: + // keeping a chainMessage with an empty text would otherwise produce an + // empty reply prefix and an empty files slice in the dispatch layer. + if text == "" && len(images) == 0 && len(files) == 0 { + return nil + } if text == "" { return nil } @@ -2068,8 +2154,10 @@ func (p *Platform) fetchSingleMessage(ctx context.Context, messageID string) *ch return &chainMessage{ senderName: senderName, senderType: item.Sender.SenderType, + senderID: item.Sender.ID, text: text, images: images, + files: files, parentID: item.ParentID, } } @@ -2082,6 +2170,19 @@ func collectReplyChainImages(chain []chainMessage) []core.ImageAttachment { return images } +// collectReplyChainFiles flattens file metadata from every chainMessage. +// Only the metadata is returned — actual download of file bytes is the +// dispatcher's job (gated on @bot mention + same-user privacy). Including +// the sender_id per entry is essential: without it the dispatcher cannot +// tell whose file is whose when the chain spans multiple IM users. +func collectReplyChainFiles(chain []chainMessage) []quotedFileMeta { + var metas []quotedFileMeta + for _, msg := range chain { + metas = append(metas, msg.files...) + } + return metas +} + // fetchReplyChain iteratively traverses parent_id links to build a reply chain. // Returns messages in chronological order (oldest first). Stops on any failure, // circular reference, or when maxDepth is reached. @@ -3316,6 +3417,90 @@ func isBotMentioned(mentions []*larkim.MentionEvent, botOpenID string) bool { return false } +// filterQuotedFilesForUser applies the two gating rules for issue #1560 +// without downloading anything yet: +// 1. The triggering message must explicitly @-mention the bot. We never +// pull quoted files for messages that quote a file but do not address +// the bot — avoids silent background work on every chatter message +// and bounds Feishu's high-frequency read path on the file-resource +// API. +// 2. Each quoted file's Feishu sender must match the user who triggered +// the current message. This is the privacy guard: even though the +// reporter said "user A uploaded and user A re-quotes", the +// implementation must refuse to forward a file uploaded by a different +// group member. Sender ids come from Feishu's open_id (user) or +// app_id (bot) — both are stable, comparable strings. +// +// Returns metadata for the surviving entries. The caller is then expected +// to call downloadQuotedFiles once to actually fetch the bytes, so that +// downloads happen strictly *after* both gates have been satisfied. +func (p *Platform) filterQuotedFilesForUser(metas []quotedFileMeta, mentions []*larkim.MentionEvent, userID string) []quotedFileMeta { + if len(metas) == 0 || userID == "" { + return nil + } + if !isBotMentioned(mentions, p.getBotOpenID()) { + return nil + } + var kept []quotedFileMeta + for _, m := range metas { + if m.senderID == "" || m.senderID != userID { + // Either the upstream sender is unknown (defensive — should not + // happen for messages we successfully fetched) or it differs + // from the current user. Either way we drop the file: same-user + // is the explicit privacy default per the reporter's choice (a). + slog.Debug(p.tag()+": dropping quoted file: same-user mismatch", + "file_name", m.fileName, + "file_sender", m.senderID, + "current_user", userID, + ) + continue + } + kept = append(kept, m) + } + return kept +} + +// downloadQuotedFiles performs the actual on-demand downloads for each +// surviving quotedFileMeta entry. Each call hits Feishu's +// /open-apis/im/v1/messages/:message_id/resources/:file_key endpoint. +// We make one call per entry so a single failure cannot break the rest. +// Per the issue #1560 acceptance rules this function MUST be reached only +// after filterQuotedFilesForUser has approved each entry — otherwise the +// on-demand fetch guarantee is violated. +func (p *Platform) downloadQuotedFiles(ctx context.Context, metas []quotedFileMeta) []core.FileAttachment { + if len(metas) == 0 { + return nil + } + var out []core.FileAttachment + for _, m := range metas { + if m.fileKey == "" || m.messageID == "" { + continue + } + data, err := p.downloadResource(m.messageID, m.fileKey, "file") + if err != nil { + slog.Warn(p.tag()+": download quoted file failed; skipping this entry", + "error", err, + "message_id", m.messageID, + "file_key", m.fileKey, + "file_name", m.fileName, + ) + continue + } + out = append(out, core.FileAttachment{ + MimeType: detectMimeType(data), + Data: data, + FileName: m.fileName, + }) + } + if len(out) > 0 { + slog.Info(p.tag()+": downloaded quoted file(s) for same-user quote", + "count", len(out), + "requested", len(metas), + ) + } + return out +} + // isAttachmentMsgType reports whether a Feishu message type carries only an // attachment payload (no free-form text the user could use to address another // human). These are the message types we are willing to admit into an @@ -3330,12 +3515,17 @@ func isAttachmentMsgType(msgType string) bool { // markThreadSessionActive records that a thread sessionKey has been engaged // by an @bot message, enabling attachment-only follow-ups inside the thread. -// No-op when thread isolation is disabled or sessionKey is not a thread key. -func (p *Platform) markThreadSessionActive(sessionKey string) { +// It reports whether this call activated the thread for the first time. It is +// a no-op when thread isolation is disabled or sessionKey is not a thread key. +func (p *Platform) markThreadSessionActive(sessionKey string) bool { if !p.threadIsolation || !isThreadSessionKey(sessionKey) { - return + return false + } + _, loaded := p.activeThreadSessions.LoadOrStore(sessionKey, time.Now()) + if loaded { + p.activeThreadSessions.Store(sessionKey, time.Now()) } - p.activeThreadSessions.Store(sessionKey, time.Now()) + return !loaded } // isActiveThreadSession reports whether the given sessionKey corresponds to a diff --git a/platform/feishu/feishu_test.go b/platform/feishu/feishu_test.go index 5c4b3e7007..5a796ac593 100644 --- a/platform/feishu/feishu_test.go +++ b/platform/feishu/feishu_test.go @@ -304,6 +304,131 @@ func TestDispatchMessageKeepsMentionOnlyQuotedText(t *testing.T) { } } +// TestOnMessageThreadIsolationBootstrapsExistingThreadContext covers the case +// where a thread root was posted without mentioning the bot. The root is not +// dispatched, so the first later @bot reply must fetch the parent/root once +// instead of assuming the new thread session already contains that context. +func TestOnMessageThreadIsolationBootstrapsExistingThreadContext(t *testing.T) { + const ( + appID = "cli_thread_bootstrap" + appSecret = "secret-thread-bootstrap" + botOpenID = "ou_bot" + userOpenID = "ou_user" + chatID = "oc_chat" + rootMsgID = "om_root" + triggerMsgID = "om_trigger" + ) + + got := make(chan *core.Message, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal": + writeJSON(t, w, map[string]any{ + "code": 0, + "msg": "success", + "expire": 7200, + "tenant_access_token": "tenant-token", + }) + case r.URL.Path == "/open-apis/im/v1/messages/"+rootMsgID: + writeJSON(t, w, map[string]any{ + "code": 0, + "msg": "success", + "data": map[string]any{ + "items": []map[string]any{ + { + "msg_type": "post", + "parent_id": "", + "sender": map[string]any{ + "id": "ou_root_author", + "sender_type": "user", + }, + "body": map[string]any{ + "content": `{"title":"环境信息","content":[[{"tag":"text","text":"审核 PBS: yingshi_video_i2v_input-text-cn"}]]}`, + }, + }, + }, + }, + }) + case strings.HasPrefix(r.URL.Path, "/open-apis/contact/v3/users/"): + writeJSON(t, w, map[string]any{"code": 0, "msg": "success"}) + case strings.HasPrefix(r.URL.Path, "/open-apis/im/v1/chats/"): + writeJSON(t, w, map[string]any{"code": 0, "msg": "success"}) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + p := &Platform{ + platformName: "feishu", + domain: srv.URL, + appID: appID, + appSecret: appSecret, + botOpenID: botOpenID, + threadIsolation: true, + dedup: &core.MessageDedup{}, + client: lark.NewClient(appID, appSecret, + lark.WithOpenBaseUrl(srv.URL), + lark.WithHttpClient(srv.Client()), + ), + handler: func(_ core.Platform, msg *core.Message) { + got <- msg + }, + } + + chatType := "group" + senderType := "user" + msgType := "text" + content := `{"text":"@_user_1 看看这个"}` + createTime := strconv.FormatInt(time.Now().Add(time.Second).UnixMilli(), 10) + threadID := "omt_thread" + + err := p.onMessage(context.Background(), &larkim.P2MessageReceiveV1{ + Event: &larkim.P2MessageReceiveV1Data{ + Sender: &larkim.EventSender{ + SenderId: &larkim.UserId{OpenId: strPtr(userOpenID)}, + SenderType: &senderType, + }, + Message: &larkim.EventMessage{ + MessageId: strPtr(triggerMsgID), + RootId: strPtr(rootMsgID), + ThreadId: &threadID, + ChatId: strPtr(chatID), + ChatType: &chatType, + MessageType: &msgType, + Content: &content, + CreateTime: &createTime, + Mentions: []*larkim.MentionEvent{ + { + Key: strPtr("@_user_1"), + Id: &larkim.UserId{OpenId: strPtr(botOpenID)}, + Name: strPtr("Bot"), + }, + }, + }, + }, + }) + if err != nil { + t.Fatalf("onMessage() error = %v", err) + } + + select { + case msg := <-got: + if msg.SessionKey != "feishu:"+chatID+":root:"+rootMsgID { + t.Fatalf("SessionKey = %q, want thread root session", msg.SessionKey) + } + if msg.Content != "看看这个" { + t.Fatalf("Content = %q, want trigger text", msg.Content) + } + if !strings.Contains(msg.ExtraContent, "审核 PBS: yingshi_video_i2v_input-text-cn") { + t.Fatalf("ExtraContent = %q, want existing thread root content", msg.ExtraContent) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for bootstrapped thread message") + } +} + func TestOnMessageRepliesToUnauthorizedMention(t *testing.T) { const appID = "cli_unauthorized" const appSecret = "secret-unauthorized" @@ -1036,7 +1161,9 @@ func TestMarkAndIsActiveThreadSession(t *testing.T) { t.Run("thread isolation disabled is no-op", func(t *testing.T) { p := &Platform{threadIsolation: false} - p.markThreadSessionActive(threadKey) + if p.markThreadSessionActive(threadKey) { + t.Fatal("disabled thread isolation must not report activation") + } if p.isActiveThreadSession(threadKey) { t.Fatal("expected no-op when thread_isolation is off") } @@ -1044,7 +1171,9 @@ func TestMarkAndIsActiveThreadSession(t *testing.T) { t.Run("non-thread sessionKey is ignored", func(t *testing.T) { p := &Platform{threadIsolation: true} - p.markThreadSessionActive(directKey) + if p.markThreadSessionActive(directKey) { + t.Fatal("non-thread session must not report activation") + } if p.isActiveThreadSession(directKey) { t.Fatal("expected non-thread sessionKey to be ignored") } @@ -1055,10 +1184,15 @@ func TestMarkAndIsActiveThreadSession(t *testing.T) { if p.isActiveThreadSession(threadKey) { t.Fatal("thread should not be active before mark") } - p.markThreadSessionActive(threadKey) + if !p.markThreadSessionActive(threadKey) { + t.Fatal("first mark should report activation") + } if !p.isActiveThreadSession(threadKey) { t.Fatal("thread should be active after mark") } + if p.markThreadSessionActive(threadKey) { + t.Fatal("subsequent mark must not report a second activation") + } }) } diff --git a/platform/feishu/quote_file_test.go b/platform/feishu/quote_file_test.go new file mode 100644 index 0000000000..34c0467f48 --- /dev/null +++ b/platform/feishu/quote_file_test.go @@ -0,0 +1,420 @@ +package feishu + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" + lark "github.com/larksuite/oapi-sdk-go/v3" + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" +) + +// TestFilterQuotedFilesForUser covers the two gating rules for issue #1560 +// in isolation: only @-bot-triggers forward files, and only the same IM user +// is allowed to forward them. +func TestFilterQuotedFilesForUser(t *testing.T) { + const botOpenID = "ou_bot" + const currentUser = "ou_alice" + const otherUser = "ou_bob" + + buildMetas := func() []quotedFileMeta { + return []quotedFileMeta{ + {fileKey: "k1", fileName: "doc.txt", messageID: "om1", senderID: currentUser}, + {fileKey: "k2", fileName: "doc2.txt", messageID: "om2", senderID: otherUser}, + } + } + botMention := []*larkim.MentionEvent{ + {Key: strPtr("@bot"), Id: &larkim.UserId{OpenId: strPtr(botOpenID)}, Name: strPtr("Bot")}, + } + + tests := []struct { + name string + mentions []*larkim.MentionEvent + userID string + metas []quotedFileMeta + wantLen int + wantFile string // if wantLen==1, expected fileName + }{ + { + name: "bot mentioned and same-user file is kept", + mentions: botMention, + userID: currentUser, + metas: buildMetas(), + wantLen: 1, + wantFile: "doc.txt", + }, + { + name: "bot mentioned but no metas returns empty", + mentions: botMention, + userID: currentUser, + metas: nil, + wantLen: 0, + }, + { + name: "quote without bot mention returns empty (privacy gate)", + mentions: nil, // no @bot + userID: currentUser, + metas: buildMetas(), + wantLen: 0, + }, + { + name: "empty user id returns empty (defensive)", + mentions: botMention, + userID: "", + metas: buildMetas(), + wantLen: 0, + }, + { + name: "bot mentioned but files belong to other user returns empty", + mentions: botMention, + userID: currentUser, + metas: []quotedFileMeta{ + {fileKey: "sk", fileName: "secret.txt", messageID: "om_s", senderID: otherUser}, + }, + wantLen: 0, + }, + { + name: "bot mentioned but multiple files include foreign users drops the foreign ones", + mentions: botMention, + userID: currentUser, + metas: []quotedFileMeta{ + {fileKey: "k_mine", fileName: "mine.txt", messageID: "om_m", senderID: currentUser}, + {fileKey: "k_theirs", fileName: "theirs.txt", messageID: "om_t", senderID: otherUser}, + }, + wantLen: 1, + wantFile: "mine.txt", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := &Platform{platformName: "feishu", botOpenID: botOpenID} + got := p.filterQuotedFilesForUser(tc.metas, tc.mentions, tc.userID) + if len(got) != tc.wantLen { + t.Fatalf("len = %d, want %d", len(got), tc.wantLen) + } + if tc.wantLen == 1 && got[0].fileName != tc.wantFile { + t.Fatalf("fileName = %q, want %q", got[0].fileName, tc.wantFile) + } + }) + } +} + +// TestDispatchMessageQuotedFileAcceptance verifies the three scenarios from +// the issue #1560 acceptance criteria: +// 1. quote + @bot mention -> on-demand file fetch + attachment forward +// 2. quote without @bot -> no fetch (file resource endpoint not called) +// 3. ordinary message -> no fetch +// +// The mock httptest server records all incoming paths so we can assert that +// the resource endpoint is hit exactly once across the three cases. +func TestDispatchMessageQuotedFileAcceptance(t *testing.T) { + const appID = "cli_quote_file" + const appSecret = "secret-quote-file" + const botOpenID = "ou_bot" + const currentUser = "ou_alice" + const parentMessageID = "om_parent_file" + const fileKey = "file_v1" + const fileName = "report.txt" + + // Fake file payload. Bytes happen to start with "%PDF" so detectMimeType + // classifies it as application/pdf — works for both build and runtime. + fileData := []byte("%PDF-1.4\nfake pdf body\n") + + type hit struct { + path string + q string + } + hits := make(chan hit, 32) + + // servedOnce tracks whether the file resource endpoint was ever hit. + // The test asserts this flag matches the scenario expectations. + var fileResourceCalls int + // Endpoint at which we expect to see the file resource call. + const fileResourcePath = "/open-apis/im/v1/messages/" + parentMessageID + "/resources/" + fileKey + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits <- hit{path: r.URL.Path, q: r.URL.Query().Get("type")} + switch { + case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal": + w.Header().Set("Content-Type", "application/json") + writeJSON(t, w, map[string]any{ + "code": 0, + "msg": "success", + "expire": 7200, + "tenant_access_token": "tenant-token", + }) + case r.URL.Path == "/open-apis/im/v1/messages/"+parentMessageID: + w.Header().Set("Content-Type", "application/json") + // Return a quoted file message whose sender matches the current + // user. The acceptance flow is "user A uploads, user A re-quotes + // with @bot" — same-user privacy rule must allow it through. + writeJSON(t, w, map[string]any{ + "code": 0, + "msg": "success", + "data": map[string]any{ + "items": []map[string]any{ + { + "msg_type": "file", + "parent_id": "", + "sender": map[string]any{ + "id": currentUser, + "sender_type": "user", + }, + "body": map[string]any{ + "content": `{"file_key":"` + fileKey + `","file_name":"` + fileName + `"}`, + }, + }, + }, + }, + }) + case strings.HasSuffix(r.URL.Path, "/contact/v3/users/"+currentUser): + w.Header().Set("Content-Type", "application/json") + writeJSON(t, w, map[string]any{ + "code": 0, + "msg": "success", + "data": map[string]any{ + "user": map[string]any{ + "name": "Alice", + }, + }, + }) + case strings.HasPrefix(r.URL.Path, "/open-apis/im/v1/chats/"): + w.Header().Set("Content-Type", "application/json") + writeJSON(t, w, map[string]any{"code": 0, "msg": "success"}) + case r.URL.Path == fileResourcePath: + fileResourceCalls++ + if r.URL.Query().Get("type") != "file" { + t.Errorf("file resource call type = %q, want file", r.URL.Query().Get("type")) + } + w.Header().Set("Content-Type", "application/pdf") + if _, err := w.Write(fileData); err != nil { + t.Fatalf("write file: %v", err) + } + default: + // Unexpected path: count but don't fail the test here — the + // per-scenario assertions below cover expected behaviour. + t.Logf("unexpected mock path: %s", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + writeJSON(t, w, map[string]any{"code": 0, "msg": "success"}) + } + })) + defer srv.Close() + // Drain hits asynchronously so the handler never blocks. + go func() { + for range hits { + } + }() + + newClient := func(handler func(_ core.Platform, msg *core.Message)) *Platform { + return &Platform{ + platformName: "feishu", + domain: srv.URL, + appID: appID, + appSecret: appSecret, + botOpenID: botOpenID, + handler: handler, + client: lark.NewClient(appID, appSecret, + lark.WithOpenBaseUrl(srv.URL), + lark.WithHttpClient(srv.Client()), + ), + } + } + + botMentionEvents := []*larkim.MentionEvent{ + {Key: strPtr("@bot"), Id: &larkim.UserId{OpenId: strPtr(botOpenID)}, Name: strPtr("Bot")}, + } + + type scenario struct { + name string + parentID string + mentions []*larkim.MentionEvent + expectFiles int + expectFileNameIs string // matcher: "report.txt" or "" (none) + } + + scenarios := []scenario{ + { + name: "mention+quote fetches and forwards file", + parentID: parentMessageID, + mentions: botMentionEvents, + expectFiles: 1, + expectFileNameIs: fileName, + }, + { + name: "quote without mention does not fetch", + parentID: parentMessageID, + mentions: nil, // no @bot + expectFiles: 0, + expectFileNameIs: "", + }, + { + name: "ordinary message does not fetch", + parentID: "", // no quote at all + mentions: botMentionEvents, + expectFiles: 0, + expectFileNameIs: "", + }, + } + + for _, sc := range scenarios { + t.Run(sc.name, func(t *testing.T) { + // Reset the per-scenario counter: the acceptance rules need to + // be matched across the scenarios as a set. + before := fileResourceCalls + + got := make(chan *core.Message, 1) + p := newClient(func(_ core.Platform, msg *core.Message) { + got <- msg + }) + + p.dispatchMessage( + context.Background(), + "text", + `{"text":"@bot 请帮我分析文件"}`, + sc.mentions, + "om_child_"+sc.name, + "feishu:oc_chat:ou_alice", + currentUser, + "oc_chat", + replyContext{messageID: "om_child_" + sc.name, sessionKey: "feishu:oc_chat:ou_alice"}, + sc.parentID, + 0, + ) + + select { + case msg := <-got: + if len(msg.Files) != sc.expectFiles { + t.Fatalf("len(Files) = %d, want %d (Files=%+v)", len(msg.Files), sc.expectFiles, msg.Files) + } + if sc.expectFiles == 1 && msg.Files[0].FileName != sc.expectFileNameIs { + t.Fatalf("Files[0].FileName = %q, want %q", msg.Files[0].FileName, sc.expectFileNameIs) + } + if sc.expectFiles == 1 && string(msg.Files[0].Data) != string(fileData) { + t.Fatalf("Files[0].Data bytes differ from mocked file body") + } + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for dispatched message") + } + + after := fileResourceCalls + scenariosCalled := after - before + // Per-scenario assertion: the file resource endpoint must only + // be hit when the scenario expects it. This is the strongest + // proof of "on-demand only" — the server-side counter is the + // ground truth. + wantCalls := 0 + if sc.expectFiles > 0 { + wantCalls = 1 + } + if scenariosCalled != wantCalls { + t.Fatalf("scenario %q: file resource calls = %d, want %d", sc.name, scenariosCalled, wantCalls) + } + }) + } +} + +// TestDispatchMessageQuotedFileForeignUserDropped verifies the same-user +// privacy guard: when a quoted file was uploaded by a different IM user, +// the file MUST be forwarded only as a [file] marker — the binary payload +// is not attached to the dispatched core.Message. +func TestDispatchMessageQuotedFileForeignUserDropped(t *testing.T) { + const appID = "cli_quote_file_foreign" + const appSecret = "secret-quote-file-foreign" + const botOpenID = "ou_bot" + const currentUser = "ou_alice" + const foreignUser = "ou_bob" + const parentMessageID = "om_parent_foreign" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal": + w.Header().Set("Content-Type", "application/json") + writeJSON(t, w, map[string]any{ + "code": 0, + "msg": "success", + "expire": 7200, + "tenant_access_token": "tenant-token", + }) + case r.URL.Path == "/open-apis/im/v1/messages/"+parentMessageID: + w.Header().Set("Content-Type", "application/json") + // Quoted file is uploaded by `foreignUser`, NOT by currentUser. + writeJSON(t, w, map[string]any{ + "code": 0, "msg": "success", + "data": map[string]any{ + "items": []map[string]any{ + { + "msg_type": "file", + "parent_id": "", + "sender": map[string]any{ + "id": foreignUser, + "sender_type": "user", + }, + "body": map[string]any{ + "content": `{"file_key":"file_foreign","file_name":"secret.txt"}`, + }, + }, + }, + }, + }) + case strings.HasPrefix(r.URL.Path, "/open-apis/im/v1/messages/"+parentMessageID+"/resources/"): + // Even if the resource API succeeds, the same-user guard must + // keep the file out of the dispatched payload. We return the + // bytes anyway; the assertion is purely about the *outcome*. + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("should not be forwarded")) + case strings.HasPrefix(r.URL.Path, "/open-apis/contact/v3/users/"): + w.Header().Set("Content-Type", "application/json") + writeJSON(t, w, map[string]any{"code": 0, "msg": "success"}) + case strings.HasPrefix(r.URL.Path, "/open-apis/im/v1/chats/"): + w.Header().Set("Content-Type", "application/json") + writeJSON(t, w, map[string]any{"code": 0, "msg": "success"}) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + got := make(chan *core.Message, 1) + p := &Platform{ + platformName: "feishu", + domain: srv.URL, + appID: appID, + appSecret: appSecret, + botOpenID: botOpenID, + client: lark.NewClient(appID, appSecret, + lark.WithOpenBaseUrl(srv.URL), + lark.WithHttpClient(srv.Client()), + ), + handler: func(_ core.Platform, msg *core.Message) { got <- msg }, + } + + p.dispatchMessage( + context.Background(), + "text", + `{"text":"@bot 看看这个文件"}`, + []*larkim.MentionEvent{ + {Key: strPtr("@bot"), Id: &larkim.UserId{OpenId: strPtr(botOpenID)}, Name: strPtr("Bot")}, + }, + "om_child_foreign", + "feishu:oc_chat:ou_alice", + currentUser, + "oc_chat", + replyContext{messageID: "om_child_foreign", sessionKey: "feishu:oc_chat:ou_alice"}, + parentMessageID, + 0, + ) + + select { + case msg := <-got: + if len(msg.Files) != 0 { + t.Fatalf("foreign-user quoted file was forwarded (len(Files)=%d) — same-user guard broken", len(msg.Files)) + } + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for dispatched foreign-user message") + } +} diff --git a/platform/googlechat/googlechat.go b/platform/googlechat/googlechat.go new file mode 100644 index 0000000000..99a104dca9 --- /dev/null +++ b/platform/googlechat/googlechat.go @@ -0,0 +1,616 @@ +// Package googlechat connects cc-connect to Google Chat. +// +// Google Chat has no native socket/long-poll inbound for self-hosted apps +// without a public endpoint. This adapter uses a registered Google Chat app +// whose Cloud Pub/Sub connection publishes Chat events to a topic: +// +// - receive: cc-connect pulls the Chat app's Pub/Sub subscription with a +// streaming pull (no public IP needed). The subscription is fixed, so there +// is no Workspace Events subscription expiry or per-restart resource leak. +// - send: the Chat app replies via the Chat REST API +// (spaces.messages.create) authenticated as the app's service account +// (chat.bot scope), so replies appear as the bot. +// +// Both directions are native Go: receive uses cloud.google.com/go/pubsub and +// send uses net/http, authenticated by the same service-account key. +package googlechat + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "mime/multipart" + "net/http" + "net/textproto" + "os" + "strings" + "time" + + "github.com/chenhg5/cc-connect/core" + + "cloud.google.com/go/pubsub/v2" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "google.golang.org/api/option" +) + +// chatBotScope authorizes posting messages as the Chat app (app auth). +const chatBotScope = "https://www.googleapis.com/auth/chat.bot" + +// sessionKeyPrefix and threadSep are used by both buildSessionKey and +// ReconstructReplyCtx so the encode/decode pair stays in sync. +const ( + sessionKeyPrefix = "googlechat:" + threadSep = ":t:" +) + +// chatAPIBase is the Chat REST API base; a var so tests can build URLs against it. +var chatAPIBase = "https://chat.googleapis.com/v1/" + +// chatUploadBase is the Chat media-upload endpoint base; a var so tests can override it. +var chatUploadBase = "https://chat.googleapis.com/upload/v1/" + +func init() { + core.RegisterPlatform("googlechat", New) +} + +// replyContext carries the platform-specific data needed to reply: the space +// to post into and, when known, the thread to reply within. +type replyContext struct { + space string // e.g. "spaces/AAAA" + thread string // e.g. "spaces/AAAA/threads/XXXX" (empty = top-level) +} + +type Platform struct { + subscription string // full resource name: projects/

/subscriptions/ + projectID string // parsed from subscription, for the Pub/Sub client + credentialsFile string // service-account key, used for both receive and send + tokenSource oauth2.TokenSource + allowFrom string + sessionScope string // "space" (default) | "thread" | "user" + + botClient *http.Client // service-account authed client for sending + psClient *pubsub.Client + + handler core.MessageHandler + cancel context.CancelFunc +} + +// New builds a Google Chat platform from config options. +func New(opts map[string]any) (core.Platform, error) { + subscription, _ := opts["subscription"].(string) + subscription = strings.TrimSpace(subscription) + if subscription == "" { + return nil, fmt.Errorf("googlechat: subscription is required (the Pub/Sub subscription your Chat app publishes to)") + } + projectID, err := projectFromSubscription(subscription) + if err != nil { + return nil, err + } + + credentialsFile, _ := opts["credentials_file"].(string) + credentialsFile = strings.TrimSpace(credentialsFile) + if credentialsFile == "" { + return nil, fmt.Errorf("googlechat: credentials_file is required (the Chat app's service-account key, used to pull events and send replies)") + } + keyBytes, err := os.ReadFile(credentialsFile) + if err != nil { + return nil, fmt.Errorf("googlechat: read credentials_file: %w", err) + } + conf, err := google.JWTConfigFromJSON(keyBytes, + chatBotScope, "https://www.googleapis.com/auth/pubsub") + if err != nil { + return nil, fmt.Errorf("googlechat: parse service account credentials: %w", err) + } + botClient := conf.Client(context.Background()) + + allowFrom, _ := opts["allow_from"].(string) + + core.CheckAllowFrom("googlechat", allowFrom) + + return &Platform{ + subscription: subscription, + projectID: projectID, + credentialsFile: credentialsFile, + tokenSource: conf.TokenSource(context.Background()), + allowFrom: allowFrom, + sessionScope: normalizeSessionScope(opts["session_scope"]), + botClient: botClient, + }, nil +} + +// projectFromSubscription extracts the project ID from a Pub/Sub subscription +// resource name so the Pub/Sub client can be created for the right project. +func projectFromSubscription(sub string) (string, error) { + parts := strings.Split(sub, "/") + if len(parts) == 4 && parts[0] == "projects" && parts[2] == "subscriptions" { + return parts[1], nil + } + return "", fmt.Errorf("googlechat: subscription must be of the form projects//subscriptions/, got %q", sub) +} + +// normalizeSessionScope resolves session_scope to "space" | "thread" | "user", +// defaulting to "space". +func normalizeSessionScope(raw any) string { + s, _ := raw.(string) + switch strings.ToLower(strings.TrimSpace(s)) { + case "thread": + return "thread" + case "user": + return "user" + case "space", "": + return "space" + default: + slog.Warn("googlechat: unknown session_scope, using \"space\"", "value", s) + return "space" + } +} + +func (p *Platform) Name() string { return "googlechat" } + +func (p *Platform) Start(handler core.MessageHandler) error { + p.handler = handler + + ctx, cancel := context.WithCancel(context.Background()) + p.cancel = cancel + + client, err := pubsub.NewClient(ctx, p.projectID, option.WithTokenSource(p.tokenSource)) + if err != nil { + cancel() + return fmt.Errorf("googlechat: create pubsub client: %w", err) + } + p.psClient = client + + go p.receiveLoop(ctx) + slog.Info("googlechat: started", "subscription", p.subscription, "scope", p.sessionScope) + return nil +} + +// receiveLoop runs a streaming pull on the subscription, restarting with a small +// backoff if Receive returns an error while the context is still alive. +func (p *Platform) receiveLoop(ctx context.Context) { + const backoff = 5 * time.Second + sub := p.psClient.Subscriber(p.subscription) + for { + if ctx.Err() != nil { + return + } + err := sub.Receive(ctx, func(_ context.Context, m *pubsub.Message) { + p.handleMessage(m) + }) + if err != nil && ctx.Err() == nil { + slog.Error("googlechat: receive exited, restarting", "error", err, "backoff", backoff) + } + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + } +} + +// ackable is the subset of *pubsub.Message used by dispatchMessage, allowing +// the dispatch logic to be tested without a real Pub/Sub client. +type ackable interface { + Ack() + Nack() +} + +// handleMessage is the Pub/Sub receive callback; it delegates to dispatchMessage. +func (p *Platform) handleMessage(m *pubsub.Message) { + p.dispatchMessage(m, m.Data) +} + +// dispatchMessage parses data and routes it to the handler. +// Non-message events (e.g. ADDED_TO_SPACE) are acked immediately so they are +// not redelivered. For valid messages, ack happens after the handler returns; +// if the handler panics the message is nacked so Pub/Sub can redeliver it. +func (p *Platform) dispatchMessage(m ackable, data []byte) { + msg, ok := p.parseEvent(data) + if !ok { + m.Ack() + return + } + defer func() { + if r := recover(); r != nil { + slog.Error("googlechat: handler panic", "recover", r) + m.Nack() + return + } + m.Ack() + }() + p.handler(p, msg) +} + +// chatAppEvent is the Google Chat app event payload that the Chat app publishes +// to its Pub/Sub topic. It arrives as the Pub/Sub message data directly. +type chatAppEvent struct { + Type string `json:"type"` // MESSAGE, ADDED_TO_SPACE, REMOVED_FROM_SPACE, ... + Message chatAppMessage `json:"message"` +} + +// chatAppMessage is the subset of the Chat Message resource we use. +type chatAppMessage struct { + Name string `json:"name"` + Text string `json:"text"` + ArgumentText string `json:"argumentText"` + CreateTime string `json:"createTime"` + Sender struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Type string `json:"type"` + } `json:"sender"` + Space struct { + Name string `json:"name"` + } `json:"space"` + Thread struct { + Name string `json:"name"` + } `json:"thread"` +} + +// parseEvent converts one Pub/Sub message payload into a core.Message. The bool +// is false when the event should be ignored (not a new message, non-human +// sender, unauthorized, stale, or empty text). +func (p *Platform) parseEvent(data []byte) (*core.Message, bool) { + var ev chatAppEvent + if err := json.Unmarshal(data, &ev); err != nil { + slog.Debug("googlechat: parse event failed", "error", err) + return nil, false + } + if ev.Type != "MESSAGE" { + return nil, false + } + m := ev.Message + // Only react to human messages; skipping app/bot senders prevents the + // adapter from replying to its own posts. + if !strings.EqualFold(m.Sender.Type, "HUMAN") { + return nil, false + } + if !core.AllowList(p.allowFrom, m.Sender.Name) { + slog.Debug("googlechat: message from unauthorized sender", "sender", m.Sender.Name) + return nil, false + } + // Drop messages predating startup so a restart does not replay backlog. + if t, err := time.Parse(time.RFC3339, m.CreateTime); err == nil && core.IsOldMessage(t) { + slog.Debug("googlechat: ignoring old message after restart", "create_time", m.CreateTime) + return nil, false + } + + content, ok := extractContent(m) + if !ok { + return nil, false + } + + space := m.Space.Name + thread := m.Thread.Name + return &core.Message{ + SessionKey: p.buildSessionKey(space, m.Sender.Name, thread), + Platform: "googlechat", + MessageID: m.Name, + UserID: m.Sender.Name, + UserName: m.Sender.DisplayName, + ChatName: space, + Content: content, + ReplyCtx: replyContext{space: space, thread: thread}, + }, true +} + +// extractContent returns the prompt text for a message. argumentText is used +// (Google strips the @mention markup from it), falling back to text. +func extractContent(m chatAppMessage) (string, bool) { + content := strings.TrimSpace(m.ArgumentText) + if content == "" { + content = strings.TrimSpace(m.Text) + } + return content, content != "" +} + +// buildSessionKey derives the engine session key per session_scope: +// - "space": one session per space -> googlechat: +// - "thread": one session per thread -> googlechat::t: +// - "user": one session per (space, sender) -> googlechat:: +// +// "thread" falls back to the space key when the message has no thread. +func (p *Platform) buildSessionKey(space, user, thread string) string { + switch p.sessionScope { + case "thread": + if thread != "" { + return sessionKeyPrefix + space + threadSep + thread + } + return sessionKeyPrefix + space + case "user": + return sessionKeyPrefix + space + ":" + user + default: + return sessionKeyPrefix + space + } +} + +// ReconstructReplyCtx rebuilds a reply context from a session key so proactive +// sends (cron, send-to-session, restart notices) can reach the right space and +// thread. Implements core.ReplyContextReconstructor. +func (p *Platform) ReconstructReplyCtx(sessionKey string) (any, error) { + // googlechat: | googlechat::t: | googlechat:: + rest, ok := strings.CutPrefix(sessionKey, sessionKeyPrefix) + if !ok { + return nil, fmt.Errorf("googlechat: invalid session key %q", sessionKey) + } + if idx := strings.Index(rest, threadSep); idx != -1 { + return replyContext{space: rest[:idx], thread: rest[idx+len(threadSep):]}, nil + } + // User-scoped keys append ":" where user is "users/"; strip a + // trailing "users/..." segment to recover the bare space. + if idx := strings.LastIndex(rest, ":users/"); idx != -1 { + return replyContext{space: rest[:idx]}, nil + } + return replyContext{space: rest}, nil +} + +// httpErrorBody reads up to 2048 bytes from resp.Body, closes it, and returns +// an error combining prefix, status code, and the response snippet. +func httpErrorBody(resp *http.Response, prefix string) error { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + if err := resp.Body.Close(); err != nil { + return fmt.Errorf("%s: status %d: %s (close body: %v)", prefix, resp.StatusCode, strings.TrimSpace(string(b)), err) + } + return fmt.Errorf("%s: status %d: %s", prefix, resp.StatusCode, strings.TrimSpace(string(b))) +} + +// coalesce returns s if non-empty, otherwise def. +func coalesce(s, def string) string { + if s != "" { + return s + } + return def +} + +// messageURL returns the Chat messages endpoint for rc's space, appending the +// messageReplyOption query when a thread is known. +func messageURL(rc replyContext) string { + u := chatAPIBase + rc.space + "/messages" + if rc.thread != "" { + u += "?messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" + } + return u +} + +// applyThread adds the thread field to body when rc has a thread. +func applyThread(body map[string]any, rc replyContext) { + if rc.thread != "" { + body["thread"] = map[string]any{"name": rc.thread} + } +} + +// buildSendRequest builds the Chat REST API URL and JSON body to post content +// into rc's space. When a thread is known the reply is threaded (falling back +// to a new thread if that thread no longer accepts replies). +func buildSendRequest(rc replyContext, content string) (string, []byte, error) { + body := map[string]any{"text": content} + applyThread(body, rc) + b, err := json.Marshal(body) + if err != nil { + return "", nil, fmt.Errorf("googlechat: marshal body: %w", err) + } + return messageURL(rc), b, nil +} + +// doRequest executes req using botClient and returns the response on success. +// On non-2xx it reads the error body, closes it, and returns an error. +// The caller is responsible for draining and closing resp.Body on success. +func (p *Platform) doRequest(req *http.Request) (*http.Response, error) { + resp, err := p.botClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 { + return nil, httpErrorBody(resp, fmt.Sprintf("googlechat: %s %s", req.Method, req.URL.Path)) + } + return resp, nil +} + +func (p *Platform) post(ctx context.Context, rctx any, content string) error { + rc, ok := rctx.(replyContext) + if !ok { + return fmt.Errorf("googlechat: invalid reply context type %T", rctx) + } + if rc.space == "" { + return fmt.Errorf("googlechat: missing space in reply context") + } + url, body, err := buildSendRequest(rc, content) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("googlechat: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := p.doRequest(req) + if err != nil { + return err + } + if _, err := io.Copy(io.Discard, resp.Body); err != nil { + _ = resp.Body.Close() + return fmt.Errorf("googlechat: drain response body: %w", err) + } + if err := resp.Body.Close(); err != nil { + return fmt.Errorf("googlechat: close response body: %w", err) + } + return nil +} + +func (p *Platform) Reply(ctx context.Context, rctx any, content string) error { + return p.post(ctx, rctx, content) +} + +func (p *Platform) Send(ctx context.Context, rctx any, content string) error { + return p.post(ctx, rctx, content) +} + +// uploadAttachment uploads raw bytes to the Chat media endpoint using a +// multipart/related request and returns the attachmentDataRef resource name. +func (p *Platform) uploadAttachment(ctx context.Context, space, filename, mimeType string, data []byte) (string, error) { + buf := bytes.NewBuffer(make([]byte, 0, 256+len(data))) + mw := multipart.NewWriter(buf) + + metaPart, err := mw.CreatePart(textproto.MIMEHeader{"Content-Type": {"application/json; charset=UTF-8"}}) + if err != nil { + return "", fmt.Errorf("googlechat: upload: create metadata part: %w", err) + } + if err := json.NewEncoder(metaPart).Encode(map[string]string{"filename": filename}); err != nil { + return "", fmt.Errorf("googlechat: upload: encode metadata: %w", err) + } + + mediaPart, err := mw.CreatePart(textproto.MIMEHeader{"Content-Type": {mimeType}}) + if err != nil { + return "", fmt.Errorf("googlechat: upload: create media part: %w", err) + } + if _, err := mediaPart.Write(data); err != nil { + return "", fmt.Errorf("googlechat: upload: write media: %w", err) + } + if err := mw.Close(); err != nil { + return "", fmt.Errorf("googlechat: upload: finalize multipart body: %w", err) + } + + uploadURL := chatUploadBase + space + "/attachments:upload?uploadType=multipart" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, buf) + if err != nil { + return "", fmt.Errorf("googlechat: upload: build request: %w", err) + } + req.Header.Set("Content-Type", "multipart/related; boundary="+mw.Boundary()) + + resp, err := p.doRequest(req) + if err != nil { + return "", err + } + defer func() { + if err := resp.Body.Close(); err != nil { + slog.Warn("googlechat: close upload response body", "error", err) + } + }() + + var result struct { + AttachmentDataRef struct { + ResourceName string `json:"resourceName"` + } `json:"attachmentDataRef"` + } + defer func() { _, _ = io.Copy(io.Discard, resp.Body) }() + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("googlechat: upload: decode response: %w", err) + } + if result.AttachmentDataRef.ResourceName == "" { + return "", fmt.Errorf("googlechat: upload: empty resourceName in response") + } + return result.AttachmentDataRef.ResourceName, nil +} + +// buildAttachmentRequest builds the Chat REST API URL and JSON body to post a +// message that references an already-uploaded attachment (by resource name). +// Threading behaviour mirrors buildSendRequest. +func buildAttachmentRequest(rc replyContext, resourceName string) (string, []byte, error) { + body := map[string]any{ + "attachment": []map[string]any{ + {"attachmentDataRef": map[string]any{"resourceName": resourceName}}, + }, + } + applyThread(body, rc) + b, err := json.Marshal(body) + if err != nil { + return "", nil, fmt.Errorf("googlechat: marshal attachment body: %w", err) + } + return messageURL(rc), b, nil +} + +// postAttachment uploads data then creates a Chat message carrying the +// attachmentDataRef. Shared by SendImage and SendFile. +func (p *Platform) postAttachment(ctx context.Context, rc replyContext, filename, mimeType string, data []byte) error { + if rc.space == "" { + return fmt.Errorf("googlechat: missing space in reply context") + } + resourceName, err := p.uploadAttachment(ctx, rc.space, filename, mimeType, data) + if err != nil { + return err + } + url, body, err := buildAttachmentRequest(rc, resourceName) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("googlechat: attachment message: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := p.doRequest(req) + if err != nil { + return err + } + if _, err := io.Copy(io.Discard, resp.Body); err != nil { + _ = resp.Body.Close() + return fmt.Errorf("googlechat: drain attachment create response body: %w", err) + } + if err := resp.Body.Close(); err != nil { + return fmt.Errorf("googlechat: close attachment create response body: %w", err) + } + return nil +} + +// SendImage uploads an image and posts it as a Chat message attachment. +// Implements core.ImageSender. +func (p *Platform) SendImage(ctx context.Context, rctx any, img core.ImageAttachment) error { + rc, ok := rctx.(replyContext) + if !ok { + return fmt.Errorf("googlechat: SendImage: invalid reply context type %T", rctx) + } + return p.postAttachment(ctx, rc, + coalesce(img.FileName, "image.png"), + coalesce(img.MimeType, "image/png"), + img.Data) +} + +// SendFile uploads a file and posts it as a Chat message attachment. +// Implements core.FileSender. +func (p *Platform) SendFile(ctx context.Context, rctx any, file core.FileAttachment) error { + rc, ok := rctx.(replyContext) + if !ok { + return fmt.Errorf("googlechat: SendFile: invalid reply context type %T", rctx) + } + return p.postAttachment(ctx, rc, + coalesce(file.FileName, "attachment"), + coalesce(file.MimeType, "application/octet-stream"), + file.Data) +} + +var _ core.ImageSender = (*Platform)(nil) +var _ core.FileSender = (*Platform)(nil) +var _ core.ReplyContextReconstructor = (*Platform)(nil) + +func (p *Platform) Stop() error { + if p.cancel != nil { + p.cancel() + } + if p.psClient != nil { + return p.psClient.Close() + } + return nil +} + +// FormattingInstructions returns Google Chat text-formatting guidance for the agent. +func (p *Platform) FormattingInstructions() string { + return `You are responding in Google Chat. Use Google Chat's text formatting, NOT standard Markdown: +- Bold: *bold* (single asterisks) +- Italic: _italic_ +- Strikethrough: ~text~ +- Inline code: ` + "`text`" + ` +- Code block: ` + "```text```" + ` +- Block quote: >text +- Lists: use - or * prefix normally +- Do NOT use ## headings — Google Chat does not render them. Use *bold* on its own line instead. +- Do NOT use [text](url) Markdown links. + - To auto-link a URL: paste the raw URL directly — Google Chat will linkify it. + - To link with display text: ` +} + +// compile-time assertion that *Platform implements core.FormattingInstructionProvider. +var _ core.FormattingInstructionProvider = (*Platform)(nil) diff --git a/platform/googlechat/googlechat_test.go b/platform/googlechat/googlechat_test.go new file mode 100644 index 0000000000..fa712c5bab --- /dev/null +++ b/platform/googlechat/googlechat_test.go @@ -0,0 +1,677 @@ +package googlechat + +import ( + "context" + "encoding/json" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +// testServiceAccountJSON is a syntactically valid service-account JSON whose +// private_key is intentionally invalid. JWTConfigFromJSON accepts it (key +// parsing is lazy), so New() succeeds — useful for testing config paths beyond +// credential loading. +const testServiceAccountJSON = `{"type":"service_account","project_id":"p","private_key_id":"k","private_key":"not-a-key","client_email":"x@p.iam.gserviceaccount.com","token_uri":"https://oauth2.googleapis.com/token"}` + +// newTestPlatform builds a Platform directly so tests can exercise parsing and +// routing without a Pub/Sub client or service-account client. +func newTestPlatform(allowFrom, scope string) *Platform { + return &Platform{allowFrom: allowFrom, sessionScope: scope} +} + +// wrapEvent renders a Chat-app event as the Pub/Sub message payload the Chat app +// publishes: {"type":,"message":}. +func wrapEvent(t *testing.T, evType string, msg map[string]any) []byte { + t.Helper() + data, err := json.Marshal(map[string]any{"type": evType, "message": msg}) + if err != nil { + t.Fatalf("marshal event: %v", err) + } + return data +} + +// messageEvent wraps msg as a MESSAGE event line. +func messageEvent(t *testing.T, msg map[string]any) []byte { + return wrapEvent(t, "MESSAGE", msg) +} + +func humanMessage(text, argumentText string) map[string]any { + return map[string]any{ + "name": "spaces/AAA/messages/MMM", + "text": text, + "argumentText": argumentText, + "sender": map[string]any{"name": "users/123", "displayName": "Alice", "type": "HUMAN"}, + "space": map[string]any{"name": "spaces/AAA"}, + "thread": map[string]any{"name": "spaces/AAA/threads/TTT"}, + } +} + +func TestParseEvent_MentionMode(t *testing.T) { + p := newTestPlatform("*", "space") + line := messageEvent(t, humanMessage("@Claude summarize this", "summarize this")) + + msg, ok := p.parseEvent(line) + if !ok { + t.Fatal("expected message to be handled") + } + if msg.Content != "summarize this" { + t.Errorf("Content = %q, want stripped argumentText", msg.Content) + } + if msg.UserID != "users/123" || msg.UserName != "Alice" { + t.Errorf("sender = %q/%q", msg.UserID, msg.UserName) + } + if msg.SessionKey != "googlechat:spaces/AAA" { + t.Errorf("SessionKey = %q", msg.SessionKey) + } + rc, ok := msg.ReplyCtx.(replyContext) + if !ok || rc.space != "spaces/AAA" || rc.thread != "spaces/AAA/threads/TTT" { + t.Errorf("ReplyCtx = %+v", msg.ReplyCtx) + } +} + +func TestParseEvent_MentionModeFallsBackToText(t *testing.T) { + p := newTestPlatform("*", "space") + msg, ok := p.parseEvent(messageEvent(t, humanMessage("hello there", ""))) + if !ok { + t.Fatal("expected message to be handled") + } + if msg.Content != "hello there" { + t.Errorf("Content = %q, want full text fallback", msg.Content) + } +} + +func TestParseEvent_SkipsNonHuman(t *testing.T) { + p := newTestPlatform("*", "space") + data := humanMessage("hi", "hi") + data["sender"] = map[string]any{"name": "users/bot", "type": "BOT"} + + if _, ok := p.parseEvent(messageEvent(t, data)); ok { + t.Error("expected non-human sender to be ignored") + } +} + +func TestParseEvent_IgnoresNonMessageType(t *testing.T) { + p := newTestPlatform("*", "space") + if _, ok := p.parseEvent(wrapEvent(t, "ADDED_TO_SPACE", humanMessage("hi", "hi"))); ok { + t.Error("expected non-MESSAGE event to be ignored") + } +} + +func TestParseEvent_AllowFromEnforced(t *testing.T) { + p := newTestPlatform("users/999", "space") + if _, ok := p.parseEvent(messageEvent(t, humanMessage("hi", "hi"))); ok { + t.Error("expected unauthorized sender to be ignored") + } + + p2 := newTestPlatform("users/123", "space") + if _, ok := p2.parseEvent(messageEvent(t, humanMessage("hi", "hi"))); !ok { + t.Error("expected authorized sender to be handled") + } +} + +func TestParseEvent_DropsOldMessage(t *testing.T) { + p := newTestPlatform("*", "space") + data := humanMessage("hi", "hi") + data["createTime"] = "2000-01-01T00:00:00Z" + if _, ok := p.parseEvent(messageEvent(t, data)); ok { + t.Error("expected message predating startup to be ignored") + } +} + +// fakeAckMsg is a test double for *pubsub.Message that records Ack/Nack calls +// and supports optional callbacks so tests can track call ordering. +type fakeAckMsg struct { + acked bool + nacked bool + ackFn func() + nackFn func() +} + +func (f *fakeAckMsg) Ack() { + f.acked = true + if f.ackFn != nil { + f.ackFn() + } +} + +func (f *fakeAckMsg) Nack() { + f.nacked = true + if f.nackFn != nil { + f.nackFn() + } +} + +func TestDispatchMessage_AcksAfterHandler(t *testing.T) { + p := newTestPlatform("*", "space") + data := messageEvent(t, humanMessage("hello", "hello")) + + var events []string + m := &fakeAckMsg{ackFn: func() { events = append(events, "ack") }} + p.handler = func(_ core.Platform, _ *core.Message) { + events = append(events, "handler") + } + p.dispatchMessage(m, data) + + want := []string{"handler", "ack"} + if len(events) != len(want) || events[0] != want[0] || events[1] != want[1] { + t.Errorf("event order: got %v, want %v", events, want) + } + if m.nacked { + t.Error("Nack should not be called on success") + } +} + +func TestDispatchMessage_NacksOnPanic(t *testing.T) { + p := newTestPlatform("*", "space") + data := messageEvent(t, humanMessage("hello", "hello")) + + m := &fakeAckMsg{} + p.handler = func(_ core.Platform, _ *core.Message) { + panic("simulated handler panic") + } + p.dispatchMessage(m, data) + + if m.acked { + t.Error("Ack should not be called when handler panics") + } + if !m.nacked { + t.Error("Nack should be called when handler panics") + } +} + +func TestDispatchMessage_AcksOnParseFailure(t *testing.T) { + p := newTestPlatform("*", "space") + + handlerCalled := false + m := &fakeAckMsg{} + p.handler = func(_ core.Platform, _ *core.Message) { handlerCalled = true } + p.dispatchMessage(m, []byte("not-json")) + + if !m.acked { + t.Error("Ack should be called for unparseable messages") + } + if m.nacked { + t.Error("Nack should not be called for unparseable messages") + } + if handlerCalled { + t.Error("handler should not be called for unparseable messages") + } +} + +func TestBuildSessionKey(t *testing.T) { + cases := []struct { + scope, space, user, thread, want string + }{ + {"space", "spaces/A", "users/1", "spaces/A/threads/T", "googlechat:spaces/A"}, + {"thread", "spaces/A", "users/1", "spaces/A/threads/T", "googlechat:spaces/A:t:spaces/A/threads/T"}, + {"thread", "spaces/A", "users/1", "", "googlechat:spaces/A"}, + {"user", "spaces/A", "users/1", "spaces/A/threads/T", "googlechat:spaces/A:users/1"}, + } + for _, c := range cases { + p := newTestPlatform("*", c.scope) + if got := p.buildSessionKey(c.space, c.user, c.thread); got != c.want { + t.Errorf("scope=%s buildSessionKey = %q, want %q", c.scope, got, c.want) + } + } +} + +func TestReconstructReplyCtx(t *testing.T) { + p := newTestPlatform("*", "space") + cases := []struct { + key string + space, thread string + }{ + {"googlechat:spaces/A", "spaces/A", ""}, + {"googlechat:spaces/A:t:spaces/A/threads/T", "spaces/A", "spaces/A/threads/T"}, + {"googlechat:spaces/A:users/1", "spaces/A", ""}, + } + for _, c := range cases { + got, err := p.ReconstructReplyCtx(c.key) + if err != nil { + t.Errorf("key=%s: %v", c.key, err) + continue + } + rc := got.(replyContext) + if rc.space != c.space || rc.thread != c.thread { + t.Errorf("key=%s reconstructed = %+v, want space=%q thread=%q", c.key, rc, c.space, c.thread) + } + } + + if _, err := p.ReconstructReplyCtx("slack:foo"); err == nil { + t.Error("expected error for non-googlechat key") + } +} + +func TestBuildAttachmentRequest(t *testing.T) { + // Threaded reply: URL carries reply option, body carries thread + attachment. + u, body, err := buildAttachmentRequest(replyContext{space: "spaces/A", thread: "spaces/A/threads/T"}, "ref/123") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(u, chatAPIBase+"spaces/A/messages") { + t.Errorf("url = %q, want prefix %q", u, chatAPIBase+"spaces/A/messages") + } + parsed, _ := url.Parse(u) + if parsed.Query().Get("messageReplyOption") != "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" { + t.Errorf("url = %q, want messageReplyOption query", u) + } + var b map[string]any + if err := json.Unmarshal(body, &b); err != nil { + t.Fatal(err) + } + attachments, _ := b["attachment"].([]any) + if len(attachments) != 1 { + t.Fatalf("body attachment count = %d, want 1", len(attachments)) + } + att, _ := attachments[0].(map[string]any) + ref, _ := att["attachmentDataRef"].(map[string]any) + if ref["resourceName"] != "ref/123" { + t.Errorf("attachmentDataRef.resourceName = %v, want ref/123", ref["resourceName"]) + } + thread, _ := b["thread"].(map[string]any) + if thread["name"] != "spaces/A/threads/T" { + t.Errorf("body thread = %+v", b["thread"]) + } + + // No thread: no reply option, no thread in body. + u2, body2, err := buildAttachmentRequest(replyContext{space: "spaces/A"}, "ref/456") + if err != nil { + t.Fatal(err) + } + if strings.Contains(u2, "messageReplyOption") { + t.Errorf("unexpected messageReplyOption for top-level: %q", u2) + } + var b2 map[string]any + if err := json.Unmarshal(body2, &b2); err != nil { + t.Fatal(err) + } + if _, ok := b2["thread"]; ok { + t.Errorf("unexpected thread for top-level reply: %+v", b2) + } + attachments2, _ := b2["attachment"].([]any) + if len(attachments2) != 1 { + t.Fatalf("body attachment count = %d, want 1", len(attachments2)) + } + att2, _ := attachments2[0].(map[string]any) + ref2, _ := att2["attachmentDataRef"].(map[string]any) + if ref2["resourceName"] != "ref/456" { + t.Errorf("attachmentDataRef.resourceName = %v, want ref/456", ref2["resourceName"]) + } +} + +func TestFormattingInstructions(t *testing.T) { + p := newTestPlatform("*", "space") + s := p.FormattingInstructions() + if s == "" { + t.Fatal("FormattingInstructions() returned empty string") + } + for _, want := range []string{"*bold*", "_italic_", "##", "[text](url)", ">text", "|display text"} { + if !strings.Contains(s, want) { + t.Errorf("FormattingInstructions() missing expected substring %q", want) + } + } +} + +func TestBuildSendRequest(t *testing.T) { + // Threaded reply: URL carries the reply option, body carries the thread. + u, body, err := buildSendRequest(replyContext{space: "spaces/A", thread: "spaces/A/threads/T"}, "hi") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(u, chatAPIBase+"spaces/A/messages") { + t.Errorf("url = %q, want prefix %q", u, chatAPIBase+"spaces/A/messages") + } + parsed, _ := url.Parse(u) + if parsed.Query().Get("messageReplyOption") != "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" { + t.Errorf("url = %q, want messageReplyOption query", u) + } + var b map[string]any + if err := json.Unmarshal(body, &b); err != nil { + t.Fatal(err) + } + if b["text"] != "hi" { + t.Errorf("body text = %v", b["text"]) + } + thread, _ := b["thread"].(map[string]any) + if thread["name"] != "spaces/A/threads/T" { + t.Errorf("body thread = %+v", b["thread"]) + } + + // No thread: no reply option, no thread in body. + u2, body2, err := buildSendRequest(replyContext{space: "spaces/A"}, "hi") + if err != nil { + t.Fatal(err) + } + if strings.Contains(u2, "messageReplyOption") { + t.Errorf("unexpected messageReplyOption for top-level reply: %q", u2) + } + var b2 map[string]any + if err := json.Unmarshal(body2, &b2); err != nil { + t.Fatal(err) + } + if _, ok := b2["thread"]; ok { + t.Errorf("unexpected thread for top-level reply: %+v", b2) + } +} + +func TestBuildUpdateRequest(t *testing.T) { + msgName := "spaces/AAA/messages/MMM" + u, body, err := buildUpdateRequest(msgName, "updated text") + if err != nil { + t.Fatal(err) + } + + // URL must point at the message resource and carry updateMask=text. + wantPrefix := chatAPIBase + msgName + if !strings.HasPrefix(u, wantPrefix) { + t.Errorf("url = %q, want prefix %q", u, wantPrefix) + } + parsed, _ := url.Parse(u) + if parsed.Query().Get("updateMask") != "text" { + t.Errorf("url = %q, want updateMask=text query param", u) + } + + // Body must carry the updated text. + var b map[string]any + if err := json.Unmarshal(body, &b); err != nil { + t.Fatal(err) + } + if b["text"] != "updated text" { + t.Errorf("body text = %v, want %q", b["text"], "updated text") + } + // No thread or reply-option fields. + if _, ok := b["thread"]; ok { + t.Errorf("unexpected thread field in update body: %+v", b) + } +} + +// testAttachmentServer creates an httptest.Server that handles both the upload +// endpoint and the create-message endpoint. uploadFn and msgFn are called for +// the respective requests; pass nil to use a default no-op that returns 200. +func testAttachmentServer(t *testing.T, uploadFn, msgFn http.HandlerFunc) (p *Platform, restore func()) { + t.Helper() + if uploadFn == nil { + uploadFn = func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if _, err := fmt.Fprintln(w, `{"attachmentDataRef":{"resourceName":"ref/default"}}`); err != nil { + t.Fatalf("write default upload response: %v", err) + } + } + } + if msgFn == nil { + msgFn = func(w http.ResponseWriter, r *http.Request) { + if _, err := fmt.Fprintln(w, "{}"); err != nil { + t.Fatalf("write default message response: %v", err) + } + } + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "attachments") { + uploadFn(w, r) + } else { + msgFn(w, r) + } + })) + origUpload, origAPI := chatUploadBase, chatAPIBase + chatUploadBase = ts.URL + "/upload/" + chatAPIBase = ts.URL + "/api/" + return &Platform{botClient: &http.Client{}}, func() { + ts.Close() + chatUploadBase, chatAPIBase = origUpload, origAPI + } +} + +func TestUploadAttachment_Success(t *testing.T) { + p, restore := testAttachmentServer(t, nil, nil) + defer restore() + name, err := p.uploadAttachment(context.Background(), "spaces/X", "f.txt", "text/plain", []byte("hello")) + if err != nil { + t.Fatal(err) + } + if name != "ref/default" { + t.Errorf("resourceName = %q, want ref/default", name) + } +} + +func TestUploadAttachment_HTTPError(t *testing.T) { + p, restore := testAttachmentServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + if _, err := fmt.Fprintln(w, "access denied"); err != nil { + t.Fatalf("write forbidden response: %v", err) + } + }, nil) + defer restore() + _, err := p.uploadAttachment(context.Background(), "spaces/X", "f.txt", "text/plain", []byte("x")) + if err == nil || !strings.Contains(err.Error(), "403") { + t.Errorf("expected HTTP 403 error, got %v", err) + } +} + +func TestUploadAttachment_EmptyResourceName(t *testing.T) { + p, restore := testAttachmentServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if _, err := fmt.Fprintln(w, `{"attachmentDataRef":{"resourceName":""}}`); err != nil { + t.Fatalf("write empty resourceName response: %v", err) + } + }, nil) + defer restore() + _, err := p.uploadAttachment(context.Background(), "spaces/X", "f.txt", "text/plain", []byte("x")) + if err == nil || !strings.Contains(err.Error(), "empty resourceName") { + t.Errorf("expected empty resourceName error, got %v", err) + } +} + +func TestPostAttachment_MissingSpace(t *testing.T) { + p := &Platform{botClient: &http.Client{}} + err := p.postAttachment(context.Background(), replyContext{}, "f.txt", "text/plain", []byte("x")) + if err == nil || !strings.Contains(err.Error(), "missing space") { + t.Errorf("expected missing space error, got %v", err) + } +} + +func TestPostAttachment_TwoStep(t *testing.T) { + var gotCreateBody []byte + p, restore := testAttachmentServer(t, + func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if _, err := fmt.Fprintln(w, `{"attachmentDataRef":{"resourceName":"ref/xyz"}}`); err != nil { + t.Fatalf("write upload response: %v", err) + } + }, + func(w http.ResponseWriter, r *http.Request) { + gotCreateBody, _ = io.ReadAll(r.Body) + if _, err := fmt.Fprintln(w, "{}"); err != nil { + t.Fatalf("write create response: %v", err) + } + }, + ) + defer restore() + + rc := replyContext{space: "spaces/A", thread: "spaces/A/threads/T"} + if err := p.postAttachment(context.Background(), rc, "doc.pdf", "application/pdf", []byte("pdf")); err != nil { + t.Fatal(err) + } + + var b map[string]any + if err := json.Unmarshal(gotCreateBody, &b); err != nil { + t.Fatal(err) + } + thread, _ := b["thread"].(map[string]any) + if thread["name"] != "spaces/A/threads/T" { + t.Errorf("thread.name = %v, want spaces/A/threads/T", thread["name"]) + } + attachments, _ := b["attachment"].([]any) + if len(attachments) != 1 { + t.Fatalf("attachment count = %d, want 1", len(attachments)) + } + att, _ := attachments[0].(map[string]any) + ref, _ := att["attachmentDataRef"].(map[string]any) + if ref["resourceName"] != "ref/xyz" { + t.Errorf("attachmentDataRef.resourceName = %v, want ref/xyz", ref["resourceName"]) + } +} + +// parseUploadParts parses the multipart/related upload body and returns the +// filename from the metadata part and the MIME type from the media part. +func parseUploadParts(t *testing.T, r *http.Request) (filename, mimeType string) { + t.Helper() + ct := r.Header.Get("Content-Type") + _, params, err := mime.ParseMediaType(ct) + if err != nil { + t.Fatalf("parse Content-Type %q: %v", ct, err) + } + mr := multipart.NewReader(r.Body, params["boundary"]) + for { + part, err := mr.NextPart() + if err != nil { + break + } + partCT := part.Header.Get("Content-Type") + if strings.HasPrefix(partCT, "application/json") { + var meta map[string]string + json.NewDecoder(part).Decode(&meta) //nolint:errcheck + filename = meta["filename"] + } else { + mimeType = partCT + } + } + return +} + +func TestSendImage_Defaults(t *testing.T) { + var gotFilename, gotMIME string + p, restore := testAttachmentServer(t, + func(w http.ResponseWriter, r *http.Request) { + gotFilename, gotMIME = parseUploadParts(t, r) + w.Header().Set("Content-Type", "application/json") + if _, err := fmt.Fprintln(w, `{"attachmentDataRef":{"resourceName":"ref/1"}}`); err != nil { + t.Fatalf("write upload response: %v", err) + } + }, + nil, + ) + defer restore() + + err := p.SendImage(context.Background(), replyContext{space: "spaces/A"}, core.ImageAttachment{Data: []byte("img")}) + if err != nil { + t.Fatal(err) + } + if gotFilename != "image.png" { + t.Errorf("filename = %q, want image.png", gotFilename) + } + if gotMIME != "image/png" { + t.Errorf("MIME = %q, want image/png", gotMIME) + } +} + +func TestSendFile_Defaults(t *testing.T) { + var gotFilename, gotMIME string + p, restore := testAttachmentServer(t, + func(w http.ResponseWriter, r *http.Request) { + gotFilename, gotMIME = parseUploadParts(t, r) + w.Header().Set("Content-Type", "application/json") + if _, err := fmt.Fprintln(w, `{"attachmentDataRef":{"resourceName":"ref/2"}}`); err != nil { + t.Fatalf("write upload response: %v", err) + } + }, + nil, + ) + defer restore() + + err := p.SendFile(context.Background(), replyContext{space: "spaces/A"}, core.FileAttachment{Data: []byte("bin")}) + if err != nil { + t.Fatal(err) + } + if gotFilename != "attachment" { + t.Errorf("filename = %q, want attachment", gotFilename) + } + if gotMIME != "application/octet-stream" { + t.Errorf("MIME = %q, want application/octet-stream", gotMIME) + } +} + +func TestNew_MissingSubscription(t *testing.T) { + for _, opts := range []map[string]any{ + {}, + {"subscription": ""}, + {"subscription": " "}, + } { + _, err := New(opts) + if err == nil || !strings.Contains(err.Error(), "subscription is required") { + t.Errorf("opts %v: want subscription required error, got %v", opts, err) + } + } +} + +func TestNew_MalformedSubscription(t *testing.T) { + _, err := New(map[string]any{"subscription": "bad/format"}) + if err == nil { + t.Error("expected error for malformed subscription") + } +} + +func TestNew_MissingCredentialsFile(t *testing.T) { + for _, opts := range []map[string]any{ + {"subscription": "projects/p/subscriptions/s"}, + {"subscription": "projects/p/subscriptions/s", "credentials_file": ""}, + {"subscription": "projects/p/subscriptions/s", "credentials_file": " "}, + } { + _, err := New(opts) + if err == nil || !strings.Contains(err.Error(), "credentials_file is required") { + t.Errorf("opts %v: want credentials_file required error, got %v", opts, err) + } + } +} + +func TestNew_UnreadableCredentialsFile(t *testing.T) { + _, err := New(map[string]any{ + "subscription": "projects/p/subscriptions/s", + "credentials_file": filepath.Join(t.TempDir(), "nonexistent.json"), + }) + if err == nil { + t.Error("expected error for non-existent credentials file") + } +} + +func TestNew_InvalidCredentialsFile(t *testing.T) { + f := filepath.Join(t.TempDir(), "key.json") + if err := os.WriteFile(f, []byte("not valid json"), 0o600); err != nil { + t.Fatalf("write test file: %v", err) + } + _, err := New(map[string]any{ + "subscription": "projects/p/subscriptions/s", + "credentials_file": f, + }) + if err == nil || !strings.Contains(err.Error(), "parse service account credentials") { + t.Errorf("want parse credentials error, got %v", err) + } +} + +func TestNew_UnknownSessionScope(t *testing.T) { + f := filepath.Join(t.TempDir(), "key.json") + if err := os.WriteFile(f, []byte(testServiceAccountJSON), 0o600); err != nil { + t.Fatalf("write test file: %v", err) + } + p, err := New(map[string]any{ + "subscription": "projects/p/subscriptions/s", + "credentials_file": f, + "session_scope": "invalid_scope", + }) + if err != nil { + t.Fatalf("New() with unknown session_scope should succeed, got: %v", err) + } + if got := p.(*Platform).sessionScope; got != "space" { + t.Errorf("sessionScope = %q, want space", got) + } +} diff --git a/platform/googlechat/streaming.go b/platform/googlechat/streaming.go new file mode 100644 index 0000000000..e1776bae98 --- /dev/null +++ b/platform/googlechat/streaming.go @@ -0,0 +1,109 @@ +package googlechat + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + + "github.com/chenhg5/cc-connect/core" +) + +// previewHandle points at the in-flight streaming-preview message so +// UpdateMessage can patch it in place via spaces.messages.patch. +type previewHandle struct { + name string // e.g. "spaces/AAA/messages/MMM" +} + +// SendPreviewStart posts the initial streaming-preview message (threaded like a +// normal reply) and returns a handle for subsequent edits. Implements +// core.PreviewStarter; together with UpdateMessage it lights up the engine's +// real-time streaming preview for Google Chat. +func (p *Platform) SendPreviewStart(ctx context.Context, rctx any, content string) (any, error) { + rc, ok := rctx.(replyContext) + if !ok { + return nil, fmt.Errorf("googlechat: invalid reply context type %T", rctx) + } + if rc.space == "" { + return nil, fmt.Errorf("googlechat: missing space in reply context") + } + url, body, err := buildSendRequest(rc, content) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("googlechat: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := p.doRequest(req) + if err != nil { + return nil, fmt.Errorf("googlechat: send preview: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + slog.Warn("googlechat: close preview response body", "error", err) + } + }() + var msg struct { + Name string `json:"name"` + } + if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil { + return nil, fmt.Errorf("googlechat: parse create response: %w", err) + } + // drain any bytes json.Decoder left unread so the connection is reusable + _, _ = io.Copy(io.Discard, resp.Body) + if msg.Name == "" { + return nil, fmt.Errorf("googlechat: create response missing message name") + } + return &previewHandle{name: msg.Name}, nil +} + +// buildUpdateRequest builds the PATCH URL and JSON body to update a message's text. +func buildUpdateRequest(msgName, content string) (string, []byte, error) { + u := chatAPIBase + msgName + "?updateMask=text" + b, err := json.Marshal(map[string]any{"text": content}) + if err != nil { + return "", nil, fmt.Errorf("googlechat: marshal update body: %w", err) + } + return u, b, nil +} + +// UpdateMessage patches the preview message text in place. The engine passes the +// handle returned by SendPreviewStart (not the reply context). Implements +// core.MessageUpdater. +func (p *Platform) UpdateMessage(ctx context.Context, handle any, content string) error { + h, ok := handle.(*previewHandle) + if !ok { + return fmt.Errorf("googlechat: invalid preview handle type %T", handle) + } + url, body, err := buildUpdateRequest(h.name, content) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("googlechat: build update request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := p.doRequest(req) + if err != nil { + return err + } + if _, err := io.Copy(io.Discard, resp.Body); err != nil { + _ = resp.Body.Close() + return fmt.Errorf("googlechat: drain update response body: %w", err) + } + if err := resp.Body.Close(); err != nil { + return fmt.Errorf("googlechat: close update response body: %w", err) + } + return nil +} + +var ( + _ core.MessageUpdater = (*Platform)(nil) + _ core.PreviewStarter = (*Platform)(nil) +) diff --git a/platform/weixin/media_outbound.go b/platform/weixin/media_outbound.go index 2c1bdf7500..ff46300bc7 100644 --- a/platform/weixin/media_outbound.go +++ b/platform/weixin/media_outbound.go @@ -11,7 +11,6 @@ import ( "net/url" "path/filepath" "strings" - "time" "github.com/chenhg5/cc-connect/core" ) @@ -109,8 +108,34 @@ func (p *Platform) uploadToWeixinCDN(ctx context.Context, to string, plaintext [ }, nil } +// sendSingleItem sends a media item. If ilink throttles the send (ret=-2 +// "prepare failed"), it fails fast instead of retrying: the penalty is escalated +// by every send attempt made while it is active, so retrying only prolongs the +// outage. func (p *Platform) sendSingleItem(ctx context.Context, rc *replyContext, item messageItem) error { - return p.sendSingleItemWithRetry(ctx, rc, item) + if err := p.checkSendQuota(ctx); err != nil { + return err + } + msg := sendMessageReq{ + Msg: weixinOutboundMsg{ + FromUserID: "", + ToUserID: rc.peerUserID, + ClientID: "cc-" + randomHex(8), + MessageType: messageTypeBot, + MessageState: messageStateFinish, + ItemList: []messageItem{item}, + ContextToken: rc.contextToken, + }, + } + err := p.api.sendMessage(ctx, &msg) + if err == nil { + return nil + } + if isSendThrottled(err) { + return fmt.Errorf("weixin: sendMessage throttled by ilink (ret=-2); "+ + "the bot is rate-limited and sending during the penalty escalates it, retry the message later: %w", err) + } + return err } func mediaFromUploadRef(ref *cdnUploadedRef) *cdnMedia { @@ -131,54 +156,6 @@ func buildVideoMessageItem(ref *cdnUploadedRef) messageItem { } } -// sendSingleItemWithRetry sends a media item with retry mechanism for ret=-2 errors. -func (p *Platform) sendSingleItemWithRetry(ctx context.Context, rc *replyContext, item messageItem) error { - var lastErr error - for attempt := 0; attempt < weixinSendMaxRetries; attempt++ { - msg := sendMessageReq{ - Msg: weixinOutboundMsg{ - FromUserID: "", - ToUserID: rc.peerUserID, - ClientID: "cc-" + randomHex(8), - MessageType: messageTypeBot, - MessageState: messageStateFinish, - ItemList: []messageItem{item}, - ContextToken: rc.contextToken, - }, - } - err := p.api.sendMessage(ctx, &msg) - if err == nil { - return nil - } - lastErr = err - // Check if error is ret=-2 (API declined) - attempt token refresh - if strings.Contains(err.Error(), "ret=-2") { - freshToken := p.getContextToken(rc.peerUserID) - if freshToken == "" || freshToken == rc.contextToken { - slog.Warn("weixin: sendMessage ret=-2 for media, no fresh context_token — "+ - "user must send a new message to refresh session token", - "attempt", attempt+1, "peer", rc.peerUserID) - return fmt.Errorf("weixin: sendMessage ret=-2 (expired context_token); "+ - "user must send a new message to peer %q to refresh the session token: %w", - rc.peerUserID, lastErr) - } - slog.Warn("weixin: sendMessage ret=-2 for media, retrying with fresh context_token", - "attempt", attempt+1, "peer", rc.peerUserID) - rc.contextToken = freshToken - slog.Debug("weixin: using refreshed context_token for media retry", "peer", rc.peerUserID) - // Brief delay before retry - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(weixinSendRetryDelay): - } - continue - } - // For other errors, don't retry - return err - } - return lastErr -} // SendImage implements core.ImageSender. func (p *Platform) SendImage(ctx context.Context, replyCtx any, img core.ImageAttachment) error { diff --git a/platform/weixin/weixin.go b/platform/weixin/weixin.go index df7625d97b..4153493da0 100644 --- a/platform/weixin/weixin.go +++ b/platform/weixin/weixin.go @@ -27,12 +27,18 @@ const ( sessionKeyPrefix = "weixin:dm:" maxWeixinChunk = 3800 // stay under typical IM limits - // weixinSendMaxRetries is the maximum number of retries for sendMessage when API returns ret=-2. - weixinSendMaxRetries = 3 - // weixinSendRetryDelay is the delay between retries when sendMessage fails. - weixinSendRetryDelay = 500 * time.Millisecond // weixinChunkSendDelay is the delay between sending message chunks to avoid rate limiting. weixinChunkSendDelay = 100 * time.Millisecond + + // Send-volume quota that keeps the bot under ilink's burst throttle + // (sendMessage ret=-2 "prepare failed"). Live testing showed the gateway + // throttles the bot after roughly 5-6 separate messages within a short window, + // and that the penalty is escalated by every send attempt made while it is + // active. We pace separate messages (not chunks: multi-chunk sends are fine) + // to stay well below the trigger. Configurable via burst_limit / + // burst_window_secs platform options. + defaultBurstLimit = 4 // max separate messages per window + defaultBurstWindowSecs = 86400 // window length (24h: ilink budgets ~5-6 sends/day) // typingTicketTTL is how long a cached typing ticket remains valid. typingTicketTTL = 10 * time.Minute // typingRepeatInterval is how often to resend the typing status to keep it alive. @@ -87,6 +93,12 @@ type Platform struct { typingMu sync.RWMutex typingTickets map[string]typingTicketEntry // peerUserID → cached ticket + + // Send-volume quota guarding against ilink's burst throttle (see constants). + sendQuotaMu sync.Mutex + sendQuotaTimes []time.Time + sendQuotaLimit int + sendQuotaWindow time.Duration } type typingTicketEntry struct { @@ -135,6 +147,16 @@ func New(opts map[string]any) (core.Platform, error) { } lp := pickInt(opts["long_poll_timeout_ms"]) + // Send-volume quota (see defaultBurstLimit constants). 0 disables the quota. + burstLimit := pickInt(opts["burst_limit"]) + if burstLimit < 0 { + burstLimit = 0 + } + burstWindow := pickInt(opts["burst_window_secs"]) + if burstWindow < 0 { + burstWindow = 0 + } + dataDir, _ := opts["cc_data_dir"].(string) project, _ := opts["cc_project"].(string) stateDir := "" @@ -167,20 +189,29 @@ func New(opts map[string]any) (core.Platform, error) { Transport: &http.Transport{Proxy: nil}, } + if burstLimit <= 0 { + burstLimit = defaultBurstLimit + } + if burstWindow <= 0 { + burstWindow = defaultBurstWindowSecs + } + p := &Platform{ - token: token, - baseURL: baseURL, - cdnBaseURL: cdnBaseURL, - allowFrom: allowFrom, - routeTag: routeTag, - stateDir: stateDir, - longPollMS: lp, - accountLabel: accountLabel, - httpClient: httpClient, - cdnHttpClient: cdnHttpClient, - tokens: make(map[string]string), - dedup: make(map[string]time.Time), - typingTickets: make(map[string]typingTicketEntry), + token: token, + baseURL: baseURL, + cdnBaseURL: cdnBaseURL, + allowFrom: allowFrom, + routeTag: routeTag, + stateDir: stateDir, + longPollMS: lp, + accountLabel: accountLabel, + httpClient: httpClient, + cdnHttpClient: cdnHttpClient, + tokens: make(map[string]string), + dedup: make(map[string]time.Time), + typingTickets: make(map[string]typingTicketEntry), + sendQuotaLimit: burstLimit, + sendQuotaWindow: time.Duration(burstWindow) * time.Second, } p.api = newAPIClient(baseURL, token, routeTag, httpClient) @@ -639,11 +670,49 @@ func (p *Platform) refreshTypingTicket(ctx context.Context, peerID, contextToken }() } +// checkSendQuota enforces the bot's separate-message budget so ilink's +// sendmessage throttle (ret=-2 "prepare failed") is not triggered. Live testing +// showed the gateway throttles a bot after roughly 5-6 separate messages per +// long window (about a day; matches the 24h context TTL), regardless of pacing, +// and that attempts made during the penalty escalate it. Multi-chunk sends do +// not count (a chunked message is one logical message). This quota counts +// logical messages in a sliding window and FAILS FAST once the budget is +// exhausted — waiting for the window to slide (up to a day) is useless, and the +// fail-fast philosophy (see sendChunk) applies: do not keep hammering a +// throttled bot. Configure via burst_limit / burst_window_secs platform +// options. A limit of 0 disables the quota. +func (p *Platform) checkSendQuota(ctx context.Context) error { + if p.sendQuotaLimit <= 0 || p.sendQuotaWindow <= 0 { + return nil + } + p.sendQuotaMu.Lock() + now := time.Now() + cutoff := now.Add(-p.sendQuotaWindow) + kept := p.sendQuotaTimes[:0] + for _, t := range p.sendQuotaTimes { + if t.After(cutoff) { + kept = append(kept, t) + } + } + p.sendQuotaTimes = kept + if len(p.sendQuotaTimes) >= p.sendQuotaLimit { + p.sendQuotaMu.Unlock() + return fmt.Errorf("weixin: send budget exhausted (%d messages in the last %s); "+ + "ilink throttles the bot after roughly 5-6 sends per window — reduce messages or re-login later", p.sendQuotaLimit, p.sendQuotaWindow) + } + p.sendQuotaTimes = append(p.sendQuotaTimes, now) + p.sendQuotaMu.Unlock() + return nil +} + func (p *Platform) sendChunks(ctx context.Context, replyCtx any, content string) error { rc, ok := replyCtx.(*replyContext) if !ok || rc == nil { return fmt.Errorf("weixin: invalid reply context") } + if err := p.checkSendQuota(ctx); err != nil { + return err + } if strings.TrimSpace(rc.contextToken) == "" { rc.contextToken = p.getContextToken(rc.peerUserID) } @@ -651,8 +720,8 @@ func (p *Platform) sendChunks(ctx context.Context, replyCtx any, content string) slog.Error("weixin: cannot send message - missing context_token", "peer", rc.peerUserID, "content_preview", truncatePreview(content, 100), - "hint", "user needs to send a new message to refresh context_token") - return fmt.Errorf("weixin: missing context_token for peer %q - user must send a new message first", rc.peerUserID) + "hint", "user needs to send a message to the bot first so a context_token can be captured") + return fmt.Errorf("weixin: missing context_token for peer %q - user must send a message to the bot first", rc.peerUserID) } if strings.TrimSpace(content) == "" { return nil @@ -668,19 +737,21 @@ func (p *Platform) sendChunks(ctx context.Context, replyCtx any, content string) case <-time.After(weixinChunkSendDelay): } } - // Retry sendText with context_token refresh on failure - err := p.sendChunkWithRetry(ctx, rc, chunk, i+1, total) + err := p.sendChunk(ctx, rc, chunk) if err != nil { slog.Error("weixin: chunk send failed, message incomplete", "peer", rc.peerUserID, "failed_chunk", fmt.Sprintf("%d/%d", i+1, total), "error", err) - // Notify user that message delivery was incomplete. - // Use a short message that is unlikely to fail itself. - notice := "⚠️ 消息发送不完整,请在终端查看完整结果。" - noticeID := "cc-" + randomHex(6) - if nerr := p.api.sendText(ctx, rc.peerUserID, notice, rc.contextToken, noticeID); nerr != nil { - slog.Warn("weixin: failed to send incomplete-delivery notice", "peer", rc.peerUserID, "error", nerr) + // Notify user that message delivery was incomplete, unless the failure + // is the ilink throttle: the notice send would be refused too, only + // adding another throttled request. + if !isSendThrottled(err) { + notice := "⚠️ 消息发送不完整,请在终端查看完整结果。" + noticeID := "cc-" + randomHex(6) + if nerr := p.api.sendText(ctx, rc.peerUserID, notice, rc.contextToken, noticeID); nerr != nil { + slog.Warn("weixin: failed to send incomplete-delivery notice", "peer", rc.peerUserID, "error", nerr) + } } return fmt.Errorf("weixin: send chunk %d/%d: %w", i+1, total, err) } @@ -688,62 +759,28 @@ func (p *Platform) sendChunks(ctx context.Context, replyCtx any, content string) return nil } -// sendChunkWithRetry sends a single chunk with retry mechanism. -// When sendMessage returns ret=-2, it tries to refresh the context_token from -// storage (which is updated by every inbound message) before retrying. -// If the stored token is the same as the current one (no refresh possible), -// it fails fast rather than burning retries on a stale token. -// chunkIdx and totalChunks are 1-based indices used for logging context. -func (p *Platform) sendChunkWithRetry(ctx context.Context, rc *replyContext, chunk string, chunkIdx, totalChunks int) error { - var lastErr error - for attempt := 0; attempt < weixinSendMaxRetries; attempt++ { - clientID := "cc-" + randomHex(6) - err := p.api.sendText(ctx, rc.peerUserID, chunk, rc.contextToken, clientID) - if err == nil { - return nil - } - lastErr = err - // Check if error is ret=-2 (API declined) - attempt token refresh - if strings.Contains(err.Error(), "ret=-2") { - preview := []rune(chunk) - if len(preview) > 50 { - preview = preview[:50] - } - // Refresh context_token from stored tokens (may have been updated by a - // concurrent inbound message while we were waiting). - freshToken := p.getContextToken(rc.peerUserID) - if freshToken == "" || freshToken == rc.contextToken { - // No fresh token available — further retries would use the same stale - // token and all fail. Fail fast with an actionable error. - slog.Warn("weixin: sendMessage ret=-2, no fresh context_token available — "+ - "user must send a new message to refresh the session token", - "attempt", attempt+1, "peer", rc.peerUserID, - "chunk", fmt.Sprintf("%d/%d", chunkIdx, totalChunks), - "chunk_runes", utf8.RuneCountInString(chunk), - "preview", string(preview)) - return fmt.Errorf("weixin: sendMessage ret=-2 (expired context_token); "+ - "user must send a new message to peer %q to refresh the session token: %w", - rc.peerUserID, lastErr) - } - slog.Warn("weixin: sendMessage ret=-2, retrying with fresh context_token", - "attempt", attempt+1, "peer", rc.peerUserID, - "chunk", fmt.Sprintf("%d/%d", chunkIdx, totalChunks), - "chunk_runes", utf8.RuneCountInString(chunk), - "preview", string(preview)) - rc.contextToken = freshToken - slog.Debug("weixin: using refreshed context_token for retry", "peer", rc.peerUserID) - // Brief delay before retry - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(weixinSendRetryDelay): - } - continue - } - // For other errors, don't retry - return err +// isSendThrottled reports whether err is ilink sendmessage's burst-throttle +// response (ret=-2 "prepare failed"). This is a bot-wide rate-limit penalty, not a +// context_token problem: the gateway accepts any (or no) context_token on sends. +func isSendThrottled(err error) bool { + return err != nil && strings.Contains(err.Error(), "ret=-2") +} + +// sendChunk sends a single chunk. If ilink throttles the send (ret=-2 +// "prepare failed"), it fails fast instead of retrying: live testing showed the +// penalty is escalated by every send attempt made while it is active, so retrying +// (e.g. the old 3×500ms loop plus the extra notice send) only prolongs the outage. +func (p *Platform) sendChunk(ctx context.Context, rc *replyContext, chunk string) error { + clientID := "cc-" + randomHex(6) + err := p.api.sendText(ctx, rc.peerUserID, chunk, rc.contextToken, clientID) + if err == nil { + return nil + } + if isSendThrottled(err) { + return fmt.Errorf("weixin: sendMessage throttled by ilink (ret=-2); "+ + "the bot is rate-limited and sending during the penalty escalates it, retry the message later: %w", err) } - return lastErr + return err } func truncatePreview(s string, max int) string { diff --git a/platform/weixin/weixin_test.go b/platform/weixin/weixin_test.go index baf23f0b3d..faabdfef37 100644 --- a/platform/weixin/weixin_test.go +++ b/platform/weixin/weixin_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "os" @@ -458,3 +459,143 @@ func TestReconstructReplyCtx_MissingToken(t *testing.T) { t.Errorf("error = %q, want it to mention 'no stored context_token'", err.Error()) } } + +// TestIsSendThrottled verifies ret=-2 (ilink sendmessage burst throttle) is +// recognized as a throttle and other errors are not. +func TestIsSendThrottled(t *testing.T) { + if !isSendThrottled(fmt.Errorf("weixin: sendMessage: ret=-2 errcode=0 errmsg=prepare failed")) { + t.Fatal("ret=-2 should be recognized as a throttle") + } + if isSendThrottled(fmt.Errorf("weixin: sendMessage: connection reset by peer")) { + t.Fatal("non-ret=-2 error should not be treated as a throttle") + } + if isSendThrottled(nil) { + t.Fatal("nil error should not be treated as a throttle") + } +} + +// TestSendChunk_FailsFastOnThrottle verifies a throttled send fails immediately +// with a single sendmessage call — no retries, because every attempt made during +// the ilink penalty window escalates it. +func TestSendChunk_FailsFastOnThrottle(t *testing.T) { + var sendCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sendCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ret":-2,"errmsg":"prepare failed"}`)) + })) + defer srv.Close() + + p := &Platform{httpClient: &http.Client{}} + p.api = newAPIClient(srv.URL, "tok", "", p.httpClient) + rc := &replyContext{peerUserID: "peer-1", contextToken: "tok-1"} + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := p.sendChunk(ctx, rc, "hello") + if err == nil { + t.Fatal("expected a throttled error, got nil") + } + if !isSendThrottled(err) { + t.Fatalf("error should be recognized as a throttle, got: %v", err) + } + if got := sendCalls.Load(); got != 1 { + t.Fatalf("sendmessage calls = %d, want 1 (fail fast, no retries)", got) + } +} + +// TestSendChunk_Succeeds verifies a normal send returns nil and issues exactly +// one sendmessage call. +func TestSendChunk_Succeeds(t *testing.T) { + var sendCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sendCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"message_id":123}`)) + })) + defer srv.Close() + + p := &Platform{httpClient: &http.Client{}} + p.api = newAPIClient(srv.URL, "tok", "", p.httpClient) + rc := &replyContext{peerUserID: "peer-1", contextToken: "tok-1"} + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := p.sendChunk(ctx, rc, "hello"); err != nil { + t.Fatalf("sendChunk failed: %v", err) + } + if got := sendCalls.Load(); got != 1 { + t.Fatalf("sendmessage calls = %d, want 1", got) + } +} + +// TestCheckSendQuota_AllowsUnderLimit verifies sends under the window limit pass +// through without error. +func TestCheckSendQuota_AllowsUnderLimit(t *testing.T) { + p := &Platform{sendQuotaLimit: 4, sendQuotaWindow: time.Hour} + ctx := context.Background() + for i := 0; i < 4; i++ { + if err := p.checkSendQuota(ctx); err != nil { + t.Fatalf("checkSendQuota(%d) unexpectedly failed: %v", i, err) + } + } +} + +// TestCheckSendQuota_FailsWhenOverLimit verifies the quota fails fast (no +// waiting) once the window budget is exhausted. +func TestCheckSendQuota_FailsWhenOverLimit(t *testing.T) { + p := &Platform{sendQuotaLimit: 1, sendQuotaWindow: time.Hour} + ctx := context.Background() + if err := p.checkSendQuota(ctx); err != nil { + t.Fatalf("first send should pass: %v", err) + } + start := time.Now() + if err := p.checkSendQuota(ctx); err == nil { + t.Fatal("second send should fail (budget exhausted)") + } + if elapsed := time.Since(start); elapsed > 100*time.Millisecond { + t.Fatalf("over-budget send should fail fast, took %v", elapsed) + } +} + +// TestCheckSendQuota_DisabledWhenZero verifies limit 0 disables the quota entirely. +func TestCheckSendQuota_DisabledWhenZero(t *testing.T) { + p := &Platform{sendQuotaLimit: 0, sendQuotaWindow: time.Hour} + ctx := context.Background() + for i := 0; i < 10; i++ { + if err := p.checkSendQuota(ctx); err != nil { + t.Fatalf("disabled quota should never fail: %v", err) + } + } +} + +// TestSendChunks_AppliesQuota verifies the budget is enforced end-to-end through +// sendChunks (httptest server): under budget sends succeed, over budget fails. +func TestSendChunks_AppliesQuota(t *testing.T) { + var sendCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sendCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"message_id":123}`)) + })) + defer srv.Close() + + p := &Platform{httpClient: &http.Client{}, sendQuotaLimit: 1, sendQuotaWindow: time.Hour} + p.api = newAPIClient(srv.URL, "tok", "", p.httpClient) + rc := &replyContext{peerUserID: "peer-1", contextToken: "tok-1"} + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + if err := p.sendChunks(ctx, rc, "first"); err != nil { + t.Fatalf("first send failed: %v", err) + } + if err := p.sendChunks(ctx, rc, "second"); err == nil { + t.Fatal("second send should fail (budget exhausted)") + } + if got := sendCalls.Load(); got != 1 { + t.Fatalf("sendmessage calls = %d, want 1 (over-budget send not attempted)", got) + } +} diff --git a/web/src/pages/Projects/ProjectDetail.tsx b/web/src/pages/Projects/ProjectDetail.tsx index 51d519b5d9..59631bdcc4 100644 --- a/web/src/pages/Projects/ProjectDetail.tsx +++ b/web/src/pages/Projects/ProjectDetail.tsx @@ -32,6 +32,29 @@ const PLATFORM_OPTIONS: { key: string; label: string; color: string; abbr: strin { key: 'cloud_web', label: 'Cloud Web (自建 IM)', abbr: 'CW', color: 'bg-violet-50 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400' }, ]; +// Permission mode options per agent type. The values must match the keys +// emitted by each agent's `normalizeMode` / `PermissionModes` so that +// "save" round-trips correctly. See: +// claudecode: agent/claudecode/claudecode.go:818 (PermissionModes) +// codex: agent/codex/codex.go:129 (normalizeMode) +const CLAUDECODE_MODE_OPTIONS: { value: string; label: string }[] = [ + { value: 'default', label: 'default' }, + { value: 'acceptEdits', label: 'acceptEdits (edit)' }, + { value: 'plan', label: 'plan' }, + { value: 'bypassPermissions', label: 'bypassPermissions (yolo)' }, + { value: 'dontAsk', label: 'dontAsk' }, +]; +const CODEX_MODE_OPTIONS: { value: string; label: string }[] = [ + { value: 'suggest', label: 'suggest (default)' }, + { value: 'auto-edit', label: 'auto-edit' }, + { value: 'full-auto', label: 'full-auto' }, + { value: 'yolo', label: 'yolo (bypass)' }, +]; +const MODE_OPTIONS_BY_AGENT: Record = { + claudecode: CLAUDECODE_MODE_OPTIONS, + codex: CODEX_MODE_OPTIONS, +}; + const isQRPlatform = (type: string) => type === 'feishu' || type === 'lark' || type === 'weixin'; type Tab = 'overview' | 'providers' | 'heartbeat' | 'settings'; @@ -87,6 +110,13 @@ export default function ProjectDetail() { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [deleting, setDeleting] = useState(false); + // Permission mode options track the *effective* agent type: a freshly-picked + // type overrides the saved one so the dropdown matches what would be saved. + // Unknown agent types fall back to ClaudeCode (matches the previous hardcoded + // behavior) so this change is non-breaking for other agents. + const effectiveAgentType = selectedAgentType || project?.agent_type || ''; + const modeOptions = MODE_OPTIONS_BY_AGENT[effectiveAgentType] || CLAUDECODE_MODE_OPTIONS; + const handleDeleteProject = async () => { if (!name) return; setDeleting(true); @@ -522,11 +552,12 @@ export default function ProjectDetail() { onChange={(e) => setAgentMode(e.target.value)} className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-accent/50" > - - - - - + {modeOptions.map((opt) => ( + + ))} + {agentMode && !modeOptions.some((o) => o.value === agentMode) && ( + + )}