From 28d8049522da37db135fc451a77a52ff5e96c2e8 Mon Sep 17 00:00:00 2001 From: ammend <12246290+ammend@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:53:48 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(lark):=20=E6=94=AF=E6=8C=81=20AI=20?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E4=BF=AE=E6=94=B9=E5=BD=93=E5=89=8D=E7=BE=A4?= =?UTF-8?q?=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...7-28-lark-chat-rename-skill-requirement.md | 324 ++++++++++++++++++ src/cli.ts | 55 ++- src/core/chat-rename.ts | 38 ++ src/core/dashboard-ipc-server.ts | 49 ++- src/services/groups-store.ts | 69 ++++ src/skills/definitions.ts | 31 ++ test/chat-rename.test.ts | 45 +++ 7 files changed, 609 insertions(+), 2 deletions(-) create mode 100644 docs/design/2026-07-28-lark-chat-rename-skill-requirement.md create mode 100644 src/core/chat-rename.ts create mode 100644 test/chat-rename.test.ts diff --git a/docs/design/2026-07-28-lark-chat-rename-skill-requirement.md b/docs/design/2026-07-28-lark-chat-rename-skill-requirement.md new file mode 100644 index 000000000..a9f528b6d --- /dev/null +++ b/docs/design/2026-07-28-lark-chat-rename-skill-requirement.md @@ -0,0 +1,324 @@ +# botmux Skill 支持 AI 动态修改飞书群名称 + +## 1. 背景 + +botmux 在飞书群内持续运行时,群名称通常由建群者一次性设定。随着任务推进,群的目标、阶段和当前状态可能已经变化,原名称无法及时表达群内正在进行的工作。 + +希望为 botmux 增加一个内置 Skill,使群内 AI 可以根据对话上下文,在满足权限与安全约束时动态修改当前群名称。例如: + +- `支付链路排障` → `支付链路排障|定位中` +- `支付链路排障|定位中` → `支付链路排障|待验证` +- `支付链路排障|待验证` → `支付链路排障|已完成` + +## 2. 目标 + +提供一项由 AI 主动调用的群名称修改能力: + +1. AI 能在群运转过程中,根据任务主题或阶段灵活修改当前飞书群名称。 +2. 发起改名的 bot 必须是目标群的当前成员。 +3. 默认只允许修改当前会话所在群,避免 AI 越权操作其他群。 +4. 操作结果对 AI 可判断、对用户可感知、对系统可审计。 +5. 避免同名写入、频繁抖动、循环改名和恶意输入。 + +## 3. 非目标 + +首期不包含: + +- 修改单聊名称。 +- 修改任意指定群或跨群批量改名。 +- 绕过飞书权限、群角色或租户限制。 +- 根据每一条消息自动强制改名。 +- 自动修改群头像、群描述、公告等其他群属性。 +- 允许 AI 选择其他 bot 的身份执行改名。 + +## 4. 用户故事 + +### 4.1 AI 主动更新任务阶段 + +当 AI 判断群内任务已从“分析”进入“执行”阶段时,可以将当前群名称从 `数据库迁移` 修改为 `数据库迁移|执行中`,无需用户手工操作。 + +### 4.2 用户明确要求改名 + +用户在群内说“把群名改成数据库迁移验收”,AI 识别意图并调用该 Skill。成功后回复最终群名;失败时说明是权限、成员身份、名称校验还是飞书 API 导致。 + +### 4.3 多 bot 群 + +群内存在多个 bot 时,只能由实际调用 Skill 的 bot 使用自己的应用身份修改群名称。该 bot 不在群内时必须拒绝,不能静默切换到群内其他 bot 代为执行。 + +## 5. 功能需求 + +### FR-1:新增内置 Skill + +新增内置 Skill,建议名称为 `botmux-chat-rename`。 + +触发场景包括: + +- 用户明确要求修改、更新或重命名当前群。 +- AI 判断当前群名已明显不能反映任务主题。 +- AI 判断任务进入具有用户价值的关键阶段,例如“执行中”“待确认”“已完成”。 + +Skill 完整说明必须通过现有渐进披露机制提供: + +```bash +botmux skill show botmux-chat-rename +``` + +Skill 应指导 AI: + +- 只处理当前群。 +- 先生成简短、稳定、可读的新名称。 +- 名称未发生实质变化时不调用。 +- 不因细小进度变化反复改名。 +- 成功后简短告知用户;失败时输出可行动的原因。 + +### FR-2:提供受控 CLI + +提供供 Skill 调用的 CLI,建议形式: + +```bash +botmux chat rename "新的群名称" +``` + +CLI 从当前 botmux 会话上下文解析: + +- `chatId` +- `chatType` +- `larkAppId` +- 当前 bot 身份 + +首期不提供 `--chat-id` 和 `--lark-app-id` 给 AI 使用,避免绕过当前会话边界。 + +机器可解析的成功输出至少包含: + +```json +{ + "ok": true, + "chatId": "oc_xxx", + "oldName": "数据库迁移|分析中", + "newName": "数据库迁移|执行中", + "changed": true +} +``` + +同名请求返回 `ok: true`、`changed: false`,且不调用飞书更新接口。 + +失败时使用非零退出码,并返回稳定错误码,例如: + +- `not_group_chat` +- `missing_session_context` +- `bot_not_in_chat` +- `invalid_chat_name` +- `permission_denied` +- `rate_limited` +- `lark_api_error` + +### FR-3:群成员约束 + +执行改名前必须验证当前 `larkAppId` 对应的 bot 确实在目标群中。 + +验证原则: + +1. 目标必须是当前会话所在群。 +2. 当前 bot 必须能以自己的身份读取或更新该群。 +3. 若飞书返回“bot 不在群内”、无权访问或群不存在,统一转成明确、可操作的错误。 +4. 不允许在失败后自动遍历其他 bot 凭据重试。 + +即使某个其他 bot 在群内且具备权限,也不能替代当前调用 bot 执行。 + +### FR-4:飞书群名称更新 + +通过飞书群更新接口修改群名称,使用当前 bot 的应用身份调用: + +```ts +client.im.v1.chat.update({ + path: { chat_id: chatId }, + data: { name: newName }, +}) +``` + +应用需要具备飞书要求的群信息更新权限,当前权限清单中的 `im:chat:update` 应纳入启动检查或能力诊断。 + +群角色和租户策略仍以飞书实际返回为准。botmux 不伪造成功状态。 + +### FR-5:名称校验 + +写入前必须: + +- 去除首尾空白。 +- 拒绝空名称和仅含空白的名称。 +- 按飞书实际限制校验长度;限制值应集中定义,不散落在 Skill 文案中。 +- 拒绝换行、控制字符和不可见格式控制字符。 +- 保留正常的中文、英文、数字、空格及常用标点。 +- 获取当前群名并进行规范化比较,避免重复写入。 + +若名称超长,不应静默截断;返回校验错误,让 AI 重新生成,避免含义被意外改变。 + +### FR-6:防抖与幂等 + +为了避免 AI 在连续对话中频繁修改群名: + +- 同一群同一名称请求必须幂等。 +- 同一 bot 对同一群设置改名冷却时间,建议默认 10 分钟。 +- 用户在当前消息中明确要求改名时,可以绕过 AI 主动改名的冷却限制,但仍受飞书 API 限流约束。 +- 冷却判断应基于持久化审计记录,daemon 重启后仍有效。 +- 并发请求需要按 `chatId` 串行化,避免后到请求被先到响应覆盖。 + +首期不要求 daemon 自动判断阶段并改名;是否调用由 AI 根据 Skill 说明决定。 + +### FR-7:结果同步 + +改名成功后: + +1. 更新 botmux 已缓存的群名称,或使相关缓存立即失效。 +2. Dashboard、群组列表、relay picker 和后续会话上下文应最终展示新名称。 +3. AI 向当前群发送简短结果,例如:`群名称已更新为「数据库迁移|执行中」`。 + +若飞书写入成功但本地缓存刷新失败,操作仍视为成功,同时记录缓存刷新告警。 + +### FR-8:审计 + +每次实际写入记录结构化审计日志: + +- 时间 +- `chatId` +- `larkAppId` +- bot 的 open ID +- 旧名称 +- 新名称 +- 触发类型:`user_explicit` 或 `ai_proactive` +- 结果和飞书错误码 +- 当前 botmux session ID + +日志不得记录应用密钥、access token 或完整对话内容。 + +## 6. AI 命名策略 + +Skill 对 AI 提供以下默认策略: + +1. 优先保持群的核心主题稳定,只在必要时更新阶段后缀。 +2. 名称应让不了解上下文的人也能快速判断群的用途。 +3. 避免写入时间敏感但价值很低的信息,例如精确百分比或每分钟变化的状态。 +4. 避免夸张、评价性、敏感或可能冒犯群成员的措辞。 +5. 以下情况适合主动改名: + - 原名称是无意义的默认名。 + - 群内目标已经明确且与原名称明显不符。 + - 任务跨越关键生命周期阶段。 +6. 以下情况不应主动改名: + - 只是出现了一个临时支线话题。 + - AI 对群的主要目标没有足够把握。 + - 上次改名后没有发生实质阶段变化。 + - 用户明确表示不要自动改名。 + +## 7. 安全与权限 + +- 该能力属于有外部副作用的写操作。 +- 用户明确要求改名时,AI 可直接执行。 +- AI 主动改名仅适用于低风险、可恢复且与当前任务明显相关的名称调整。 +- 部署方应能按 bot 或全局关闭 AI 主动改名,同时保留用户明确触发能力。建议配置: + +```json +{ + "chatRename": { + "enabled": true, + "allowAiProactive": true, + "cooldownMinutes": 10 + } +} +``` + +- `enabled=false` 时 CLI 返回 `feature_disabled`。 +- `allowAiProactive=false` 时只接受能关联到当前用户明确指令的请求。 + +## 8. 兼容性与影响面 + +- 平台:首期仅支持飞书/Lark;其他 IM 返回 `unsupported_platform`。 +- CLI:能力通过 botmux CLI 暴露,与 Claude Code、Codex 等底层 AI CLI 无关。 +- 后端:PTY、tmux、zellij、riff 等会话后端行为一致。 +- 会话:仅群会话可用;单聊、无群绑定的 workflow、脱离飞书上下文的本地会话不可用。 +- 多 bot:每个 bot 独立鉴权、独立审计,不做凭据回退。 +- Sandbox:AI 不直接访问凭据,CLI 通过 daemon/受控服务执行飞书写操作。 + +## 9. 验收标准 + +### AC-1:明确改名成功 + +- Given 当前 bot 在群内且具备权限 +- When 用户要求 AI 将当前群改为合法的新名称 +- Then AI 调用 Skill,飞书群名更新成功,并在群内反馈最终名称。 + +### AC-2:bot 不在群内 + +- Given 调用 bot 不在目标群 +- When 发起改名 +- Then 操作失败,返回 `bot_not_in_chat`,且不尝试使用其他 bot。 + +### AC-3:阻止跨群 + +- Given AI 尝试指定另一个 `chatId` +- When 调用改名 CLI +- Then CLI 不接受目标群参数,只能使用当前会话绑定的群。 + +### AC-4:同名幂等 + +- Given 新名称与当前群名称规范化后相同 +- When 发起改名 +- Then 返回成功且 `changed=false`,飞书更新 API 不被调用。 + +### AC-5:非法名称 + +- Given 名称为空、超长或包含非法控制字符 +- When 发起改名 +- Then 返回 `invalid_chat_name`,群名称保持不变。 + +### AC-6:AI 主动改名防抖 + +- Given AI 刚刚主动修改过群名称 +- When 冷却期内再次主动改名 +- Then 返回 `rate_limited`,并告知剩余冷却时间。 + +### AC-7:缓存同步 + +- Given 飞书群名更新成功 +- When 用户查看 Dashboard 或创建后续会话 +- Then 展示更新后的名称,不长期保留旧名称。 + +### AC-8:权限不足 + +- Given 当前应用缺少 `im:chat:update` 或不满足飞书群角色要求 +- When 发起改名 +- Then 返回 `permission_denied` 及权限修复提示,不显示虚假成功。 + +## 10. 建议测试 + +### 单元测试 + +- 名称规范化与非法字符校验。 +- 同名幂等。 +- 冷却期、用户明确触发绕过和并发串行化。 +- 飞书错误码到稳定错误码的映射。 +- Skill catalog 与 `botmux skill show botmux-chat-rename`。 + +### 集成测试 + +- 当前群、当前 bot 上下文解析。 +- 使用当前 bot 客户端调用 `chat.update`。 +- bot 不在群内时拒绝,且不遍历其他 bot。 +- 成功后的群名缓存失效或刷新。 +- 不同会话后端调用结果一致。 + +### 飞书实测 + +- 普通群内明确要求改名。 +- AI 主动在关键阶段改名。 +- 缺少权限、bot 被移出群、群角色受限。 +- 多 bot 群中确认实际调用身份。 +- Dashboard 和后续消息上下文展示新名称。 + +## 11. 建议实现拆分 + +1. 在 Lark service 层新增 `renameChat(larkAppId, chatId, name)`。 +2. 新增名称校验、错误归一化和审计存储。 +3. 新增 session-scoped `botmux chat rename` CLI/daemon IPC。 +4. 新增 `botmux-chat-rename` 内置 Skill 并加入渐进披露目录。 +5. 接入配置、冷却与缓存刷新。 +6. 补齐单测、集成测试和飞书 live 验证。 diff --git a/src/cli.ts b/src/cli.ts index 29a3e961e..b93dd198d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4077,7 +4077,7 @@ async function cmdSuspend(): Promise { async function postSessionCliIpc( ipcPort: number, sessionId: string, - route: 'slash' | 'cd' | 'close', + route: 'slash' | 'cd' | 'close' | 'chat-rename', payload: Record, ): Promise { const requestBody: Record = { ...payload }; @@ -4108,6 +4108,56 @@ async function postSessionCliIpc( : fetch(`http://127.0.0.1:${ipcPort}${path}`, init); } +async function cmdChat(argv: string[]): Promise { + const sub = argv[0] ?? ''; + if (sub !== 'rename') { + console.error('用法: botmux chat rename <新群名称> [--proactive]'); + process.exitCode = 2; + return; + } + const proactive = argv.includes('--proactive'); + const name = argv.slice(1).filter(arg => arg !== '--proactive').join(' ').trim(); + if (!name) { + console.error('用法: botmux chat rename <新群名称> [--proactive]'); + process.exitCode = 2; + return; + } + const ctx = findAncestorSessionContext(); + const sid = ctx?.sessionId; + if (!sid) { + console.error(JSON.stringify({ ok: false, error: 'missing_session_context' })); + process.exitCode = 1; + return; + } + const sessions = loadSessions(); + const session = [...sessions.values()].find(x => x.sessionId === sid || x.sessionId.startsWith(sid)); + if (!session) { + console.error(JSON.stringify({ ok: false, error: 'missing_session_context' })); + process.exitCode = 1; + return; + } + const daemon = findDaemon(session.larkAppId); + if (!daemon) { + console.error(JSON.stringify({ ok: false, error: 'daemon_offline' })); + process.exitCode = 1; + return; + } + const response = await postSessionCliIpc( + daemon.ipcPort, + session.sessionId, + 'chat-rename', + { name, proactive }, + ); + const body: any = await response.json().catch(() => ({ ok: false, error: `HTTP ${response.status}` })); + const out = JSON.stringify(body, null, 2); + if (response.ok && body?.ok) { + console.log(out); + return; + } + console.error(out); + process.exitCode = 1; +} + /** botmux slash "<斜杠命令>":请求 daemon 在本会话 idle 后把命令敲入自己的 CLI。 * 自识别当前会话(pid marker → BOTMUX_SESSION_ID env),allowlist 由 daemon 侧校验。 */ async function cmdSlash(): Promise { @@ -4841,6 +4891,8 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接 schedule run 标记立即执行 飞书消息(在 CLI 会话内自动推断 session): + chat rename <新群名称> 修改当前会话所在群的名称 + --proactive 标记为 AI 主动改名(应用 10 分钟防抖) send [content] 发消息到当前话题(支持 stdin / --content-file) --images 内联图片(可重复) --files 附件(可重复) @@ -10075,6 +10127,7 @@ switch (command) { break; } case 'send': await cmdSend(process.argv.slice(3)); break; + case 'chat': await cmdChat(process.argv.slice(3)); break; case 'dispatch': await cmdDispatch(process.argv.slice(3)); break; case 'report': await cmdReport(process.argv.slice(3)); break; case 'grant': await cmdExactChatGrant(process.argv.slice(3)); break; diff --git a/src/core/chat-rename.ts b/src/core/chat-rename.ts new file mode 100644 index 000000000..8e18af36c --- /dev/null +++ b/src/core/chat-rename.ts @@ -0,0 +1,38 @@ +export const CHAT_NAME_MAX_CODE_POINTS = 100; +export const CHAT_RENAME_COOLDOWN_MS = 10 * 60_000; + +export function normalizeLarkChatName(input: unknown): + | { ok: true; name: string } + | { ok: false; error: 'invalid_chat_name' } { + if (typeof input !== 'string') return { ok: false, error: 'invalid_chat_name' }; + const name = input.trim(); + if ( + !name + || Array.from(name).length > CHAT_NAME_MAX_CODE_POINTS + || /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f]/u.test(name) + ) { + return { ok: false, error: 'invalid_chat_name' }; + } + return { ok: true, name }; +} + +export class ChatRenameCooldown { + private readonly lastAt = new Map(); + + constructor(private readonly cooldownMs = CHAT_RENAME_COOLDOWN_MS) {} + + check(key: string, now = Date.now()): + | { ok: true } + | { ok: false; retryAfterSeconds: number } { + const last = this.lastAt.get(key) ?? 0; + if (now - last >= this.cooldownMs) return { ok: true }; + return { + ok: false, + retryAfterSeconds: Math.ceil((this.cooldownMs - (now - last)) / 1000), + }; + } + + record(key: string, now = Date.now()): void { + this.lastAt.set(key, now); + } +} diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index cb4cd9f41..856776409 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -138,6 +138,7 @@ import { repinSessionWorkingDir } from './session-cwd.js'; import { authorizeSessionScopedIpc } from './daemon-ipc-session-auth.js'; import { updateSessionTitle } from './session-title.js'; import { requestAgentSessionRename } from './session-rename.js'; +import { ChatRenameCooldown, normalizeLarkChatName } from './chat-rename.js'; import type { DaemonToWorker, ScheduledTask, ParsedSchedule, ScheduleExecutionPosition, Session } from '../types.js'; import { sessionAnchorId, type DaemonSession } from './types.js'; import { attachSkillPolicy, detachSkillPolicy } from './skills/im-command.js'; @@ -399,7 +400,7 @@ function routeHasNarrowUntrustedAuth(method: string, pathname: string): boolean // 该会话的 rotating per-turn // capability 并绑定到 URL 里的 sessionId(同 /api/asks 姿势)——capability 只 // 证明「我是这个会话当前这一轮的 CLI」,选不了别的会话。 - if (method === 'POST' && /^\/api\/sessions\/[^/]+\/(?:slash|cd|close)$/.test(pathname)) return true; + if (method === 'POST' && /^\/api\/sessions\/[^/]+\/(?:slash|cd|close|chat-rename)$/.test(pathname)) return true; if (method === 'POST' && pathname === '/api/hooks/emit') return true; if (method === 'POST' && pathname === '/api/attention') return true; // Workflow v3 mutations carry their own domain-separated full-envelope @@ -785,6 +786,52 @@ ipcRoute('POST', '/api/sessions/:sessionId/slash', async (req, res, params) => { jsonRes(res, 200, { ok: true, sessionId: params.sessionId, queued: v.command }); }); +const proactiveChatRenameCooldown = new ChatRenameCooldown(); + +/** Session-scoped external mutation used by the botmux-chat-rename Skill. */ +ipcRoute('POST', '/api/sessions/:sessionId/chat-rename', async (req, res, params) => { + const body = await readJsonBody<{ name?: unknown; proactive?: unknown } & Record>(req) + .catch(() => ({} as { name?: unknown; proactive?: unknown } & Record)); + const ds = findActiveBySessionId(params.sessionId); + const auth = sessionCliIpcAuth(req, ds, params.sessionId, body); + if (!auth.ok) return jsonRes(res, 403, { ok: false, error: auth.error }); + if (!ds) return jsonRes(res, 404, { ok: false, error: 'session_not_active' }); + if (ds.chatType !== 'group') return jsonRes(res, 400, { ok: false, error: 'not_group_chat' }); + const normalized = normalizeLarkChatName(body.name); + if (!normalized.ok) return jsonRes(res, 400, normalized); + + const proactive = body.proactive === true; + const cooldownKey = `${ds.larkAppId}:${ds.chatId}`; + const now = Date.now(); + const cooldown = proactiveChatRenameCooldown.check(cooldownKey, now); + if (proactive && !cooldown.ok) { + return jsonRes(res, 429, { + ok: false, + error: 'rate_limited', + retryAfterSeconds: cooldown.retryAfterSeconds, + }); + } + + const result = await groupsStore.renameChat(ds.larkAppId, ds.chatId, normalized.name); + if (!result.ok) { + const status = result.error === 'bot_not_in_chat' ? 403 + : result.error === 'permission_denied' ? 403 + : 502; + logger.warn(`[chat-rename:audit] failed session=${ds.session.sessionId} chat=${ds.chatId} app=${ds.larkAppId} proactive=${proactive} error=${result.error} detail=${result.detail ?? '-'}`); + return jsonRes(res, status, result); + } + if (result.changed) { + if (proactive) proactiveChatRenameCooldown.record(cooldownKey, now); + for (const active of getActiveSessionsRegistry()?.values() ?? []) { + if (active.chatId !== ds.chatId) continue; + active.session.chatDisplayName = result.newName; + sessionStore.updateSession(active.session); + } + logger.info(`[chat-rename:audit] success session=${ds.session.sessionId} chat=${ds.chatId} app=${ds.larkAppId} proactive=${proactive} old=${JSON.stringify(result.oldName)} new=${JSON.stringify(result.newName)}`); + } + jsonRes(res, 200, { ...result, chatId: ds.chatId }); +}); + /** 会话内切换工作目录(角色切换专用):硬校验角色库根 → 更新记录落盘(唯一事实源) * → 活 worker 走「带 --resume 的进程重启、respawn 在新 cwd」,无活 worker 杀残留 * pane 让下条消息冷启动。 diff --git a/src/services/groups-store.ts b/src/services/groups-store.ts index c91e8e2e1..d2cb8dd86 100644 --- a/src/services/groups-store.ts +++ b/src/services/groups-store.ts @@ -74,6 +74,75 @@ export async function isInChat(larkAppId: string, chatId: string): Promise { + const client = getBotClient(larkAppId); + if (!await isInChat(larkAppId, chatId)) { + return { ok: false, error: 'bot_not_in_chat' }; + } + try { + const current: any = await larkGet( + client, + `/open-apis/im/v1/chats/${encodeURIComponent(chatId)}`, + { user_id_type: 'open_id' }, + ); + if (current.code !== 0 && current.code !== undefined) { + return { + ok: false, + error: classifyRenameChatError(current.code), + detail: `${current.msg ?? 'unknown'} (code: ${current.code})`, + }; + } + const oldName = typeof current.data?.name === 'string' ? current.data.name.trim() : ''; + if (oldName === newName) return { ok: true, oldName, newName, changed: false }; + + const updated: any = await (client as any).im.v1.chat.update({ + path: { chat_id: chatId }, + data: { name: newName }, + }); + if (updated.code !== 0 && updated.code !== undefined) { + return { + ok: false, + error: classifyRenameChatError(updated.code), + detail: `${updated.msg ?? 'unknown'} (code: ${updated.code})`, + }; + } + return { ok: true, oldName, newName, changed: true }; + } catch (e: any) { + const detail = e?.message ?? String(e); + return { + ok: false, + error: /permission|forbidden|99991672|2300\d/i.test(detail) + ? 'permission_denied' + : 'lark_api_error', + detail, + }; + } +} + +function classifyRenameChatError(code: unknown): 'permission_denied' | 'lark_api_error' { + const numeric = Number(code); + // Lark uses 99991672 for missing app scope; 230xxx errors cover common + // chat-role / operation-permission failures. + return numeric === 99991672 || (numeric >= 230000 && numeric < 231000) + ? 'permission_denied' + : 'lark_api_error'; +} + /** * Create a brand-new chat with `bot_id_list` as initial bot members. The * `creatorLarkAppId` bot becomes the chat's owner and an implicit member; the diff --git a/src/skills/definitions.ts b/src/skills/definitions.ts index f9fdd8cd3..9ffe48c0e 100644 --- a/src/skills/definitions.ts +++ b/src/skills/definitions.ts @@ -111,6 +111,36 @@ botmux schedule add "每日11:00" " 详见 \`botmux-send\` 技能的"顶层广播 / 跨群发布"章节。 `; +const CHAT_RENAME_SKILL = `--- +name: botmux-chat-rename +description: 在当前飞书/Lark 群运转过程中修改群名称。用户明确要求改群名,或 AI 判断群目标/关键阶段已明显变化且群名需要同步时触发。只能修改当前会话所在群,执行改名的 bot 必须在群内。 +--- + +# botmux-chat-rename — 修改当前群名称 + +用受控命令修改当前会话所在的飞书群名称: + +\`\`\`bash +# 用户明确要求改名 +botmux chat rename "新的群名称" + +# AI 根据任务关键阶段主动改名(有 10 分钟防抖) +botmux chat rename "支付链路排障|待验证" --proactive +\`\`\` + +规则: + +1. 只能修改当前会话所在群,不能指定 chat-id 或切换其他 bot 身份。 +2. 用户明确要求时直接执行;AI 主动改名只用于群目标或关键阶段发生明显变化。 +3. 保持核心主题稳定,优先只调整阶段后缀;不要因细小进度反复改名。 +4. 群名应简短、稳定、可读,不写精确百分比、敏感或评价性措辞。 +5. 不确定群的主要目标、只有临时支线话题、或用户要求不要自动改名时,不主动改名。 +6. 命令输出 JSON。成功后简短告知用户最终名称;失败时说明 error 及可行动的修复方式。 + +常见错误:\`not_group_chat\`、\`bot_not_in_chat\`、\`invalid_chat_name\`、 +\`permission_denied\`、\`rate_limited\`、\`lark_api_error\`。 +`; + const HISTORY_SKILL = `--- name: botmux-history description: 需要查看当前飞书会话历史消息时触发。话题/thread 会话默认拉话题内消息;普通群 chat-scope 会话拉整群最近 N 条(默认 50,用 --limit 调节)。普通群通过 /t 或 per-bot 配置开出的 thread 会话也按话题内读取。在 thread 内如果需要 thread 外的群聊上下文,用 --scope ambient。适合"看看之前聊了什么"、"最近的消息"、"上下文"类请求。在 CLI 会话内自动推断 session-id。 @@ -1474,6 +1504,7 @@ export const ASK_SKILL_NAME = 'botmux-ask'; export const WHITEBOARD_SKILL_NAME = 'botmux-whiteboard'; export const BUILTIN_SKILLS: SkillDef[] = [ + { name: 'botmux-chat-rename', content: CHAT_RENAME_SKILL }, { name: 'botmux-schedule', content: SCHEDULE_SKILL }, { name: 'botmux-history', content: HISTORY_SKILL }, { name: 'botmux-quoted', content: QUOTED_SKILL }, diff --git a/test/chat-rename.test.ts b/test/chat-rename.test.ts new file mode 100644 index 000000000..ed746615d --- /dev/null +++ b/test/chat-rename.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { + CHAT_NAME_MAX_CODE_POINTS, + ChatRenameCooldown, + normalizeLarkChatName, +} from '../src/core/chat-rename.js'; +import { BUILTIN_SKILLS } from '../src/skills/definitions.js'; + +describe('chat rename', () => { + it('normalizes valid names and preserves Unicode', () => { + expect(normalizeLarkChatName(' 支付排障|待验证 ')).toEqual({ + ok: true, + name: '支付排障|待验证', + }); + }); + + it('rejects empty, control, invisible and overlong names', () => { + expect(normalizeLarkChatName(' ')).toEqual({ ok: false, error: 'invalid_chat_name' }); + expect(normalizeLarkChatName('bad\nname')).toEqual({ ok: false, error: 'invalid_chat_name' }); + expect(normalizeLarkChatName('bad\u200bname')).toEqual({ ok: false, error: 'invalid_chat_name' }); + expect(normalizeLarkChatName('名'.repeat(CHAT_NAME_MAX_CODE_POINTS + 1))).toEqual({ + ok: false, + error: 'invalid_chat_name', + }); + }); + + it('applies a per-key proactive cooldown with retry metadata', () => { + const cooldown = new ChatRenameCooldown(60_000); + expect(cooldown.check('app:chat', 100_000)).toEqual({ ok: true }); + cooldown.record('app:chat', 100_000); + expect(cooldown.check('app:chat', 110_000)).toEqual({ + ok: false, + retryAfterSeconds: 50, + }); + expect(cooldown.check('other:chat', 110_000)).toEqual({ ok: true }); + expect(cooldown.check('app:chat', 160_000)).toEqual({ ok: true }); + }); + + it('ships an on-demand built-in Skill with the scoped CLI contract', () => { + const skill = BUILTIN_SKILLS.find(item => item.name === 'botmux-chat-rename'); + expect(skill?.content).toContain('botmux chat rename'); + expect(skill?.content).toContain('--proactive'); + expect(skill?.content).toContain('只能修改当前会话所在群'); + }); +}); From c1230cbb989c102e8caf2b0aa05fbf0cac763e41 Mon Sep 17 00:00:00 2001 From: ammend <12246290+ammend@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:11:00 +0800 Subject: [PATCH 2/4] =?UTF-8?q?test(lark):=20=E8=A6=86=E7=9B=96=E7=BE=A4?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E6=9B=B4=E6=96=B0=E4=B8=8E=E6=9D=83=E9=99=90?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/groups-store.test.ts | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/test/groups-store.test.ts b/test/groups-store.test.ts index e791ce84a..44da43ddc 100644 --- a/test/groups-store.test.ts +++ b/test/groups-store.test.ts @@ -65,7 +65,15 @@ vi.mock('../src/bot-registry.js', () => ({ })), })); -import { listChats, isInChat, addBotToChat, createChat, transferChatOwner, getChatShareLink } from '../src/services/groups-store.js'; +import { + listChats, + isInChat, + addBotToChat, + createChat, + transferChatOwner, + getChatShareLink, + renameChat, +} from '../src/services/groups-store.js'; describe('groups-store wrappers', () => { beforeEach(() => { chatCreateStub.mockClear(); chatUpdateStub.mockClear(); chatLinkStub.mockReset(); }); @@ -85,6 +93,29 @@ describe('groups-store wrappers', () => { expect(await isInChat('appA', 'c1')).toBe(true); }); + it('renameChat reads the current chat then updates its name with the same bot identity', async () => { + chatUpdateStub.mockResolvedValueOnce({ code: 0 }); + const result = await renameChat('appA', 'c1', '支付排障|待验证'); + expect(result).toEqual({ + ok: true, + oldName: '', + newName: '支付排障|待验证', + changed: true, + }); + expect(chatUpdateStub).toHaveBeenCalledWith({ + path: { chat_id: 'c1' }, + data: { name: '支付排障|待验证' }, + }); + }); + + it('renameChat maps a missing update scope to permission_denied', async () => { + chatUpdateStub.mockResolvedValueOnce({ code: 99991672, msg: 'scope missing' }); + await expect(renameChat('appA', 'c1', '新群名')).resolves.toMatchObject({ + ok: false, + error: 'permission_denied', + }); + }); + it('addBotToChat marks invalid_id_list as failed and rest as ok', async () => { const r = await addBotToChat('appA', 'c1', ['cli_Y', 'cli_X']); expect(r.find(x => x.id === 'cli_Y')!.ok).toBe(true); From 4a2eec801d715fde1bce6cddda55645991727440 Mon Sep 17 00:00:00 2001 From: ammend <12246290+ammend@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:45:41 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(lark):=20=E4=BF=9D=E8=AF=81=E7=BE=A4?= =?UTF-8?q?=E6=94=B9=E5=90=8D=E5=B9=82=E7=AD=89=E4=B8=8E=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E5=AE=8C=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/chat-rename.ts | 20 ++++++++ src/core/dashboard-ipc-server.ts | 70 +++++++++++++++++----------- src/services/groups-store.ts | 31 +++++++++++-- test/chat-rename.test.ts | 25 ++++++++++ test/groups-store.test.ts | 48 ++++++++++++++++++- test/ipc-chat-rename-route.test.ts | 74 ++++++++++++++++++++++++++++++ 6 files changed, 234 insertions(+), 34 deletions(-) create mode 100644 test/ipc-chat-rename-route.test.ts diff --git a/src/core/chat-rename.ts b/src/core/chat-rename.ts index 8e18af36c..9aad419c3 100644 --- a/src/core/chat-rename.ts +++ b/src/core/chat-rename.ts @@ -36,3 +36,23 @@ export class ChatRenameCooldown { this.lastAt.set(key, now); } } + +/** Serializes the read → cooldown → write transaction for one bot/chat key. */ +export class ChatRenameSerialQueue { + private readonly tails = new Map>(); + + async run(key: string, operation: () => Promise): Promise { + const previous = this.tails.get(key) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { release = resolve; }); + const tail = previous.catch(() => undefined).then(() => current); + this.tails.set(key, tail); + await previous.catch(() => undefined); + try { + return await operation(); + } finally { + release(); + if (this.tails.get(key) === tail) this.tails.delete(key); + } + } +} diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 856776409..237a062b4 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -130,7 +130,7 @@ import { getBotName, type SessionRow, } from './dashboard-rows.js'; -import { getBotBrand, getBot, loadBotConfigs, readBotSkillPolicy, getBotTuiSlashAllow } from '../bot-registry.js'; +import { getBotBrand, getBot, getBotOpenId, loadBotConfigs, readBotSkillPolicy, getBotTuiSlashAllow } from '../bot-registry.js'; import { normalizeKanbanColumn, normalizeKanbanPosition, normalizeSessionTitle } from './session-board.js'; import { validateSlashInjection } from './slash-inject.js'; import { validateRoleLibraryPath } from './role-library.js'; @@ -138,7 +138,7 @@ import { repinSessionWorkingDir } from './session-cwd.js'; import { authorizeSessionScopedIpc } from './daemon-ipc-session-auth.js'; import { updateSessionTitle } from './session-title.js'; import { requestAgentSessionRename } from './session-rename.js'; -import { ChatRenameCooldown, normalizeLarkChatName } from './chat-rename.js'; +import { ChatRenameCooldown, ChatRenameSerialQueue, normalizeLarkChatName } from './chat-rename.js'; import type { DaemonToWorker, ScheduledTask, ParsedSchedule, ScheduleExecutionPosition, Session } from '../types.js'; import { sessionAnchorId, type DaemonSession } from './types.js'; import { attachSkillPolicy, detachSkillPolicy } from './skills/im-command.js'; @@ -787,6 +787,7 @@ ipcRoute('POST', '/api/sessions/:sessionId/slash', async (req, res, params) => { }); const proactiveChatRenameCooldown = new ChatRenameCooldown(); +const chatRenameSerialQueue = new ChatRenameSerialQueue(); /** Session-scoped external mutation used by the botmux-chat-rename Skill. */ ipcRoute('POST', '/api/sessions/:sessionId/chat-rename', async (req, res, params) => { @@ -801,35 +802,48 @@ ipcRoute('POST', '/api/sessions/:sessionId/chat-rename', async (req, res, params if (!normalized.ok) return jsonRes(res, 400, normalized); const proactive = body.proactive === true; + const trigger = proactive ? 'ai_proactive' : 'user_explicit'; const cooldownKey = `${ds.larkAppId}:${ds.chatId}`; - const now = Date.now(); - const cooldown = proactiveChatRenameCooldown.check(cooldownKey, now); - if (proactive && !cooldown.ok) { - return jsonRes(res, 429, { - ok: false, - error: 'rate_limited', - retryAfterSeconds: cooldown.retryAfterSeconds, + await chatRenameSerialQueue.run(cooldownKey, async () => { + const result = await groupsStore.renameChat(ds.larkAppId, ds.chatId, normalized.name, { + beforeUpdate: proactive + ? () => { + const cooldown = proactiveChatRenameCooldown.check(cooldownKey); + return cooldown.ok + ? cooldown + : { ...cooldown, error: 'rate_limited' as const }; + } + : undefined, }); - } - - const result = await groupsStore.renameChat(ds.larkAppId, ds.chatId, normalized.name); - if (!result.ok) { - const status = result.error === 'bot_not_in_chat' ? 403 - : result.error === 'permission_denied' ? 403 - : 502; - logger.warn(`[chat-rename:audit] failed session=${ds.session.sessionId} chat=${ds.chatId} app=${ds.larkAppId} proactive=${proactive} error=${result.error} detail=${result.detail ?? '-'}`); - return jsonRes(res, status, result); - } - if (result.changed) { - if (proactive) proactiveChatRenameCooldown.record(cooldownKey, now); - for (const active of getActiveSessionsRegistry()?.values() ?? []) { - if (active.chatId !== ds.chatId) continue; - active.session.chatDisplayName = result.newName; - sessionStore.updateSession(active.session); + const botOpenId = getBotOpenId(ds.larkAppId) ?? '-'; + if (!result.ok) { + const status = result.error === 'bot_not_in_chat' ? 403 + : result.error === 'permission_denied' ? 403 + : result.error === 'rate_limited' ? 429 + : 502; + logger.warn( + `[chat-rename:audit] result=failed session=${ds.session.sessionId} chat=${ds.chatId} ` + + `app=${ds.larkAppId} botOpenId=${botOpenId} trigger=${trigger} ` + + `old=${JSON.stringify(result.oldName ?? null)} new=${JSON.stringify(result.newName ?? normalized.name)} ` + + `error=${result.error} larkCode=${result.larkCode ?? '-'} detail=${result.detail ?? '-'}`, + ); + return jsonRes(res, status, result); } - logger.info(`[chat-rename:audit] success session=${ds.session.sessionId} chat=${ds.chatId} app=${ds.larkAppId} proactive=${proactive} old=${JSON.stringify(result.oldName)} new=${JSON.stringify(result.newName)}`); - } - jsonRes(res, 200, { ...result, chatId: ds.chatId }); + if (result.changed) { + if (proactive) proactiveChatRenameCooldown.record(cooldownKey); + for (const active of getActiveSessionsRegistry()?.values() ?? []) { + if (active.chatId !== ds.chatId) continue; + active.session.chatDisplayName = result.newName; + sessionStore.updateSession(active.session); + } + logger.info( + `[chat-rename:audit] result=success session=${ds.session.sessionId} chat=${ds.chatId} ` + + `app=${ds.larkAppId} botOpenId=${botOpenId} trigger=${trigger} ` + + `old=${JSON.stringify(result.oldName)} new=${JSON.stringify(result.newName)} larkCode=0`, + ); + } + return jsonRes(res, 200, { ...result, chatId: ds.chatId }); + }); }); /** 会话内切换工作目录(角色切换专用):硬校验角色库根 → 更新记录落盘(唯一事实源) diff --git a/src/services/groups-store.ts b/src/services/groups-store.ts index d2cb8dd86..ffca44595 100644 --- a/src/services/groups-store.ts +++ b/src/services/groups-store.ts @@ -76,7 +76,15 @@ export async function isInChat(larkAppId: string, chatId: string): Promise + | { ok: true } + | { ok: false; error: 'rate_limited'; retryAfterSeconds: number }; + } = {}, ): Promise { const client = getBotClient(larkAppId); if (!await isInChat(larkAppId, chatId)) { return { ok: false, error: 'bot_not_in_chat' }; } + let oldName: string | undefined; try { const current: any = await larkGet( client, @@ -107,8 +121,11 @@ export async function renameChat( detail: `${current.msg ?? 'unknown'} (code: ${current.code})`, }; } - const oldName = typeof current.data?.name === 'string' ? current.data.name.trim() : ''; - if (oldName === newName) return { ok: true, oldName, newName, changed: false }; + const fetchedOldName = typeof current.data?.name === 'string' ? current.data.name.trim() : ''; + oldName = fetchedOldName; + if (fetchedOldName === newName) return { ok: true, oldName: fetchedOldName, newName, changed: false }; + const gate = opts.beforeUpdate?.(); + if (gate && !gate.ok) return { ...gate, oldName: fetchedOldName, newName }; const updated: any = await (client as any).im.v1.chat.update({ path: { chat_id: chatId }, @@ -119,9 +136,12 @@ export async function renameChat( ok: false, error: classifyRenameChatError(updated.code), detail: `${updated.msg ?? 'unknown'} (code: ${updated.code})`, + oldName: fetchedOldName, + newName, + larkCode: Number.isFinite(Number(updated.code)) ? Number(updated.code) : undefined, }; } - return { ok: true, oldName, newName, changed: true }; + return { ok: true, oldName: fetchedOldName, newName, changed: true }; } catch (e: any) { const detail = e?.message ?? String(e); return { @@ -130,6 +150,9 @@ export async function renameChat( ? 'permission_denied' : 'lark_api_error', detail, + oldName, + newName, + larkCode: Number.isFinite(Number(e?.code)) ? Number(e.code) : undefined, }; } } diff --git a/test/chat-rename.test.ts b/test/chat-rename.test.ts index ed746615d..50d959ab8 100644 --- a/test/chat-rename.test.ts +++ b/test/chat-rename.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { CHAT_NAME_MAX_CODE_POINTS, ChatRenameCooldown, + ChatRenameSerialQueue, normalizeLarkChatName, } from '../src/core/chat-rename.js'; import { BUILTIN_SKILLS } from '../src/skills/definitions.js'; @@ -36,6 +37,30 @@ describe('chat rename', () => { expect(cooldown.check('app:chat', 160_000)).toEqual({ ok: true }); }); + it('serializes the complete rename transaction per bot/chat key', async () => { + const queue = new ChatRenameSerialQueue(); + const events: string[] = []; + let releaseFirst!: () => void; + let markFirstStarted!: () => void; + const firstGate = new Promise((resolve) => { releaseFirst = resolve; }); + const firstStarted = new Promise((resolve) => { markFirstStarted = resolve; }); + const first = queue.run('app:chat', async () => { + events.push('first:start'); + markFirstStarted(); + await firstGate; + events.push('first:end'); + }); + await firstStarted; + const second = queue.run('app:chat', async () => { + events.push('second:start'); + events.push('second:end'); + }); + expect(events).toEqual(['first:start']); + releaseFirst(); + await Promise.all([first, second]); + expect(events).toEqual(['first:start', 'first:end', 'second:start', 'second:end']); + }); + it('ships an on-demand built-in Skill with the scoped CLI contract', () => { const skill = BUILTIN_SKILLS.find(item => item.name === 'botmux-chat-rename'); expect(skill?.content).toContain('botmux chat rename'); diff --git a/test/groups-store.test.ts b/test/groups-store.test.ts index 44da43ddc..f83837d33 100644 --- a/test/groups-store.test.ts +++ b/test/groups-store.test.ts @@ -43,7 +43,7 @@ vi.mock('../src/bot-registry.js', () => ({ } // getChatOwner: GET /open-apis/im/v1/chats/ if (/\/open-apis\/im\/v1\/chats\/[^/]+$/.test(url)) { - return { code: 0, data: { owner_id: 'ou_owner' } }; + return { code: 0, data: { owner_id: 'ou_owner', name: 'one' } }; } throw new Error(`unexpected GET url in mock: ${url}`); }), @@ -98,7 +98,7 @@ describe('groups-store wrappers', () => { const result = await renameChat('appA', 'c1', '支付排障|待验证'); expect(result).toEqual({ ok: true, - oldName: '', + oldName: 'one', newName: '支付排障|待验证', changed: true, }); @@ -116,6 +116,50 @@ describe('groups-store wrappers', () => { }); }); + it('renameChat returns same-name success before consulting the proactive write gate', async () => { + const beforeUpdate = vi.fn(() => ({ + ok: false as const, + error: 'rate_limited' as const, + retryAfterSeconds: 600, + })); + await expect(renameChat('appA', 'c1', 'one', { beforeUpdate })).resolves.toEqual({ + ok: true, + oldName: 'one', + newName: 'one', + changed: false, + }); + expect(beforeUpdate).not.toHaveBeenCalled(); + expect(chatUpdateStub).not.toHaveBeenCalled(); + }); + + it('renameChat rate-limits a different-name proactive retry before the write', async () => { + const beforeUpdate = vi.fn(() => ({ + ok: false as const, + error: 'rate_limited' as const, + retryAfterSeconds: 599, + })); + await expect(renameChat('appA', 'c1', 'two', { beforeUpdate })).resolves.toEqual({ + ok: false, + error: 'rate_limited', + retryAfterSeconds: 599, + oldName: 'one', + newName: 'two', + }); + expect(beforeUpdate).toHaveBeenCalledOnce(); + expect(chatUpdateStub).not.toHaveBeenCalled(); + }); + + it('renameChat preserves old/new names and the Lark code on update failure', async () => { + chatUpdateStub.mockResolvedValueOnce({ code: 99991672, msg: 'scope missing' }); + await expect(renameChat('appA', 'c1', 'two')).resolves.toMatchObject({ + ok: false, + error: 'permission_denied', + oldName: 'one', + newName: 'two', + larkCode: 99991672, + }); + }); + it('addBotToChat marks invalid_id_list as failed and rest as ok', async () => { const r = await addBotToChat('appA', 'c1', ['cli_Y', 'cli_X']); expect(r.find(x => x.id === 'cli_Y')!.ok).toBe(true); diff --git a/test/ipc-chat-rename-route.test.ts b/test/ipc-chat-rename-route.test.ts new file mode 100644 index 000000000..4a7ec8c3b --- /dev/null +++ b/test/ipc-chat-rename-route.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + setIpcAuthSecret, + startIpcServer, + type IpcServerHandle, +} from '../src/core/dashboard-ipc-server.js'; +import * as workerPool from '../src/core/worker-pool.js'; +import * as groupsStore from '../src/services/groups-store.js'; +import * as botRegistry from '../src/bot-registry.js'; + +const CAP = 'ab12cd34'.repeat(8); +let handle: IpcServerHandle | null = null; + +afterEach(async () => { + if (handle) await handle.close(); + handle = null; + setIpcAuthSecret(null); + vi.restoreAllMocks(); +}); + +async function postRename(name: string): Promise { + if (!handle) handle = await startIpcServer({ port: 0, host: '127.0.0.1' }); + return fetch(`http://127.0.0.1:${handle.port}/api/sessions/s-chat-rename/chat-rename`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name, proactive: true, originCapability: CAP }), + }); +} + +describe('POST /api/sessions/:sessionId/chat-rename', () => { + it('returns an idempotent success for a proactive same-name retry before applying cooldown', async () => { + vi.spyOn(workerPool, 'findActiveBySessionId').mockReturnValue({ + session: { sessionId: 's-chat-rename', chatDisplayName: 'old' }, + managedTurnOrigin: { capability: CAP }, + larkAppId: 'app-chat-rename-route-test', + chatId: 'oc-chat-rename-route-test', + chatType: 'group', + } as any); + vi.spyOn(workerPool, 'getActiveSessionsRegistry').mockReturnValue(new Map()); + vi.spyOn(botRegistry, 'getBotOpenId').mockReturnValue('ou_test_bot'); + + let currentName = 'old'; + const beforeUpdateCalls: string[] = []; + vi.spyOn(groupsStore, 'renameChat').mockImplementation(async (_appId, _chatId, newName, opts) => { + if (currentName === newName) { + return { ok: true, oldName: currentName, newName, changed: false }; + } + beforeUpdateCalls.push(newName); + const gate = opts?.beforeUpdate?.(); + if (gate && !gate.ok) return { ...gate, oldName: currentName, newName }; + const oldName = currentName; + currentName = newName; + return { ok: true, oldName, newName, changed: true }; + }); + + const first = await postRename('new'); + expect(first.status).toBe(200); + expect(await first.json()).toMatchObject({ ok: true, changed: true, oldName: 'old', newName: 'new' }); + + const sameNameRetry = await postRename('new'); + expect(sameNameRetry.status).toBe(200); + expect(await sameNameRetry.json()).toMatchObject({ ok: true, changed: false, oldName: 'new', newName: 'new' }); + + const differentNameRetry = await postRename('different'); + expect(differentNameRetry.status).toBe(429); + expect(await differentNameRetry.json()).toMatchObject({ + ok: false, + error: 'rate_limited', + oldName: 'new', + newName: 'different', + }); + expect(beforeUpdateCalls).toEqual(['new', 'different']); + }); +}); From ddf7ed06195a856101293dffd40016ed20f588a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Wed, 29 Jul 2026 09:25:52 -0700 Subject: [PATCH 4/4] =?UTF-8?q?fix(lark):=20=E7=BE=A4=E6=94=B9=E5=90=8D?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=88=B7=E6=96=B0=E5=85=9C=E5=BA=95=20+=20?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=90=8D=E7=A7=B0=E9=9D=9E=E6=B3=95=E5=AD=97?= =?UTF-8?q?=E7=AC=A6=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FR-7:飞书群名写入成功后,同步本地会话缓存 chatDisplayName 时若 sessionStore.updateSession() 写盘失败(ENOSPC/EACCES),异常原本会冒泡成 HTTP 500,把「飞书那步已成功」的改名反转成失败——success 审计被跳过、 AI 可能重试已完成的改名。现改为逐 session best-effort:写盘失败只记 cache_refresh_failed warning,改名仍返回 200 成功,符合设计文档 FR-7 「飞书写成功但缓存刷新失败仍视为成功,记缓存告警」。 FR-5:normalizeLarkChatName 正则补上 U+2028/U+2029(Unicode 行/段分隔符) 与 U+FEFF(ZWNBSP/BOM)——此前这三个字符夹在名字中段能绕过校验,与 FR-5「拒绝换行、不可见格式控制字符」矛盾。 测试: - chat-rename.test.ts 新增 mid-name U+2028/U+2029/U+FEFF 拒绝断言 - ipc-chat-rename-route.test.ts 新增 FR-7 路由级用例:mock 缓存写抛 ENOSPC → 断言仍 200 成功且写入被真正尝试过(证 catch 生效非跳过) - build ✅ tsc ✅ 相关 90 测试全绿;与最新 master merge-tree 干净 Co-Authored-By: Riff --- src/core/chat-rename.ts | 2 +- src/core/dashboard-ipc-server.ts | 10 ++++++++- test/chat-rename.test.ts | 8 +++++++ test/ipc-chat-rename-route.test.ts | 36 ++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/core/chat-rename.ts b/src/core/chat-rename.ts index 9aad419c3..368752148 100644 --- a/src/core/chat-rename.ts +++ b/src/core/chat-rename.ts @@ -9,7 +9,7 @@ export function normalizeLarkChatName(input: unknown): if ( !name || Array.from(name).length > CHAT_NAME_MAX_CODE_POINTS - || /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f]/u.test(name) + || /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060-\u206f\ufeff]/u.test(name) ) { return { ok: false, error: 'invalid_chat_name' }; } diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 237a062b4..2c002b85e 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -831,10 +831,18 @@ ipcRoute('POST', '/api/sessions/:sessionId/chat-rename', async (req, res, params } if (result.changed) { if (proactive) proactiveChatRenameCooldown.record(cooldownKey); + // FR-7: the Lark write already succeeded, so a local cache-refresh + // failure (ENOSPC/EACCES on the session store) must NOT reverse the + // outcome into an HTTP 500 — best-effort per session, warn and keep the + // rename a success. Catch per-session so one bad write can't skip the rest. for (const active of getActiveSessionsRegistry()?.values() ?? []) { if (active.chatId !== ds.chatId) continue; active.session.chatDisplayName = result.newName; - sessionStore.updateSession(active.session); + try { + sessionStore.updateSession(active.session); + } catch (e) { + logger.warn(`[chat-rename:audit] cache_refresh_failed session=${active.session.sessionId} chat=${ds.chatId} app=${ds.larkAppId} detail=${e instanceof Error ? e.message : String(e)}`); + } } logger.info( `[chat-rename:audit] result=success session=${ds.session.sessionId} chat=${ds.chatId} ` diff --git a/test/chat-rename.test.ts b/test/chat-rename.test.ts index 50d959ab8..608530cce 100644 --- a/test/chat-rename.test.ts +++ b/test/chat-rename.test.ts @@ -25,6 +25,14 @@ describe('chat rename', () => { }); }); + it('rejects Unicode line/paragraph separators and BOM mid-name (FR-5)', () => { + // Original class trimmed these only at the edges; FR-5 requires rejecting + // line breaks and invisible format controls anywhere in the name. + expect(normalizeLarkChatName('a\u2028b')).toEqual({ ok: false, error: 'invalid_chat_name' }); + expect(normalizeLarkChatName('a\u2029b')).toEqual({ ok: false, error: 'invalid_chat_name' }); + expect(normalizeLarkChatName('a\ufeffb')).toEqual({ ok: false, error: 'invalid_chat_name' }); + }); + it('applies a per-key proactive cooldown with retry metadata', () => { const cooldown = new ChatRenameCooldown(60_000); expect(cooldown.check('app:chat', 100_000)).toEqual({ ok: true }); diff --git a/test/ipc-chat-rename-route.test.ts b/test/ipc-chat-rename-route.test.ts index 4a7ec8c3b..f5462f620 100644 --- a/test/ipc-chat-rename-route.test.ts +++ b/test/ipc-chat-rename-route.test.ts @@ -6,7 +6,9 @@ import { } from '../src/core/dashboard-ipc-server.js'; import * as workerPool from '../src/core/worker-pool.js'; import * as groupsStore from '../src/services/groups-store.js'; +import * as sessionStore from '../src/services/session-store.js'; import * as botRegistry from '../src/bot-registry.js'; +import { logger } from '../src/utils/logger.js'; const CAP = 'ab12cd34'.repeat(8); let handle: IpcServerHandle | null = null; @@ -71,4 +73,38 @@ describe('POST /api/sessions/:sessionId/chat-rename', () => { }); expect(beforeUpdateCalls).toEqual(['new', 'different']); }); + + it('keeps the rename a success (200) when local cache refresh throws (FR-7)', async () => { + const activeSession = { sessionId: 's-chat-rename', chatDisplayName: 'old' }; + vi.spyOn(workerPool, 'findActiveBySessionId').mockReturnValue({ + session: activeSession, + managedTurnOrigin: { capability: CAP }, + larkAppId: 'app-fr7', + chatId: 'oc-fr7', + chatType: 'group', + } as any); + // One active session in the same chat, so the cache-sync loop runs and hits + // the throwing store write below. + vi.spyOn(workerPool, 'getActiveSessionsRegistry').mockReturnValue( + new Map([['s-chat-rename', { chatId: 'oc-fr7', session: activeSession } as any]]), + ); + vi.spyOn(botRegistry, 'getBotOpenId').mockReturnValue('ou_test_bot'); + // Lark write succeeds… + vi.spyOn(groupsStore, 'renameChat').mockResolvedValue({ + ok: true, oldName: 'old', newName: 'new', changed: true, + }); + // …but persisting the refreshed cache blows up (ENOSPC/EACCES surrogate). + const updateSpy = vi.spyOn(sessionStore, 'updateSession').mockImplementation(() => { + throw new Error('ENOSPC: no space left on device'); + }); + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + + const res = await postRename('new'); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ ok: true, changed: true, oldName: 'old', newName: 'new' }); + // The failing write was actually attempted (proving the catch, not a skip). + expect(updateSpy).toHaveBeenCalledOnce(); + // FR-7 requires a cache-refresh warning be recorded on failure. + expect(warnSpy.mock.calls.some(([msg]) => String(msg).includes('cache_refresh_failed'))).toBe(true); + }); });