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) && (
+
+ )}