diff --git a/docs-site/docs/en/slash-commands.md b/docs-site/docs/en/slash-commands.md index 6c271540db..40aa058133 100644 --- a/docs-site/docs/en/slash-commands.md +++ b/docs-site/docs/en/slash-commands.md @@ -47,6 +47,28 @@ See [Session & Topic Model](/en/session-model) for the repository-picker and pin These forms create the topic and select a repository or create a worktree directly, without starting an empty session and switching it afterward. Send the task as the next message in the topic. +## Group Manager (`/manager`) + +Mention exactly one target bot in a top-level message in a regular group: + +| Command | Behavior | +| --- | --- | +| `/manager`, `/manager status` | Show the shared manager claim and whether this bot is enabled locally | +| `/manager set` | Select this bot as manager and append ` · bot name` to the group name | +| `/manager clear` | Clear this manager; restore the original name unless someone manually renamed the group | + +Only the target bot's owner/allowedUsers may set or clear it; an otherwise open bot still requires an explicit administrator allowlist. Talk-authorized users may inspect status. Commands do not create CLI sessions and are not supported in DMs, independent topics, or automatically managed session groups. + +The manager answers unmentioned, top-level regular-group messages from talk-authorized humans, but yields when only another member is mentioned. `@all` is not a redirect. Explicit mentions keep their existing behavior. This grants neither additional talk access nor operational permissions to humans or bots. + +To switch, have the old bot's administrator send `@old-bot /manager clear`, then have the new bot's administrator send `@new-bot /manager set`. There is no forced takeover. + +Cross-host coordination uses `[botmux:manager=app-id]` in the group description plus a local administrator opt-in. Every manager-only unmentioned response reads the current remote marker; failed reads do not enable this extra addressing path. A marker alone cannot enable a bot. Descriptions exceeding 100 characters are rejected without truncating human text. Long display names retain a saved original for restoration; manual group renames are preserved. + +The bot's existing `/reply-mode` still applies. This feature does not create projects, dispatch tasks, share model contexts, or change other bots' reply locations. Independently configured `never`/`ambient` policies and message listeners remain effective after clearing the manager. + +Mutations sharing a data directory use a file lock. Lark descriptions have no cross-host conditional update, so simultaneous cross-host claims are not strongly consistent: perform clear/set serially and check `/manager status`. After network errors or `chat_update_unconfirmed`, inspect status before retrying. Local records live in `chat-managers/` under the data directory with file mode `0600`; back them up with that directory. A lost local record is not silently reconstructed from the remote marker. + ## 💬 Reply Mode (`/reply-mode`) Controls how the bot opens a session when @mentioned. No argument (or `status`) shows the current mode; changing it needs `canOperate`, viewing needs `canTalk`. In group chats you must @ the target bot (in multi-bot groups, @ the specific bot). Only regular groups and 1:1 DMs are supported; topic groups need no setting (they're already topics) and the command is rejected there. diff --git a/docs-site/docs/zh/slash-commands.md b/docs-site/docs/zh/slash-commands.md index 48e623c9b0..acaa38546f 100644 --- a/docs-site/docs/zh/slash-commands.md +++ b/docs-site/docs/zh/slash-commands.md @@ -47,6 +47,28 @@ 这两种组合会直接完成建话题与选仓/创建 worktree,不会先启动空会话再切库;任务在话题内下一条发送。 +## 群负责人(`/manager`) + +在普通群顶层只 @ 一个目标机器人,发送: + +| 命令 | 行为 | +| --- | --- | +| `/manager`、`/manager status` | 查看群描述里的负责人,以及当前机器人是否已在本机启用 | +| `/manager set` | 将目标机器人设为负责人,群名追加 ` · 机器人名` | +| `/manager clear` | 取消当前负责人;若群名未被手动改过,恢复原群名 | + +设置与取消只允许目标机器人的 owner/allowedUsers 操作;没有配置管理员白名单的开放机器人也不能设置负责人。状态查询允许已有对话权限的人使用。命令不会创建 CLI 会话;不支持私聊、独立话题及自动管理的会话群。 + +负责人会响应有对话权限的人的普通群顶层未 @ 消息;消息只点名其他成员时让路,`@all` 不视为点名别人。明确 @ 负责人仍按原有规则处理。不扩大人或机器人的发言权限,也不授予操作权限。 + +切换时,先由旧机器人的管理员发送 `@旧机器人 /manager clear`,成功后再由新机器人的管理员发送 `@新机器人 /manager set`。不会强制接管别人的负责人身份。 + +负责人使用群描述中的 `[botmux:manager=应用ID]` 标记跨机器协调,同时要求该机器存在管理员启用记录。每次负责人免 @ 响应前读取最新标记,读取失败则不提供这条额外的免 @ 通道;直接编辑标记不能独立启用机器人。描述超出 100 字符时拒绝操作,不截断原说明。群名过长时仅缩短展示,取消时仍可恢复保存的原名;手动改过的群名会保留。 + +设置后仍沿用该机器人的 `/reply-mode`;此命令不创建项目、派单、共享模型上下文或改变其他机器人的回复位置。已有 `never`/`ambient`、消息监听等配置独立生效,取消负责人不会关闭这些功能。 + +同一数据目录的修改使用文件锁;飞书群描述接口没有跨机器条件写入,因此不承诺并发跨机器抢占的强一致性。请串行完成取消和设置,并用 `/manager status` 核对;网络异常或 `chat_update_unconfirmed` 后先查状态,不盲目重复写入。持久记录位于数据目录的 `chat-managers/`,文件权限为 `0600`,应随数据目录备份;记录丢失时不会仅凭远端标记恢复启用。 + ## 💬 回复模式(`/reply-mode`) 控制 bot 被 @ 触发时如何开会话。无参数(或 `status`)查看当前模式;带参数修改需 `canOperate`,仅查看需 `canTalk`。群聊中均需 @ 目标 bot 才生效,多 bot 群须 @ 到具体 bot。仅普通群与 1:1 私聊支持;话题群无需设置(本就是话题),命令会被拒绝。 diff --git a/src/core/passthrough-commands.ts b/src/core/passthrough-commands.ts index 942383ccfc..015f707596 100644 --- a/src/core/passthrough-commands.ts +++ b/src/core/passthrough-commands.ts @@ -11,6 +11,7 @@ * `customPassthroughCommands` entries that would shadow a daemon command. */ export const DAEMON_COMMANDS = new Set(['/close', '/restart', '/status', '/retry', '/help', '/cd', '/repo', '/rename', '/schedule', '/role', '/botconfig', '/skills', '/pair', '/login', '/adopt', '/detach', '/disconnect', '/oncall', '/group', '/g', '/relay', '/quote', '/fork', '/forklist', '/card', '/cot', '/term', '/list-slash-command', '/slash', '/subscribe-lark-doc', '/watch-comment', '/vc', '/insight', '/dashboard', '/sessions', '/vc-auth', '/issue', '/cli']); +DAEMON_COMMANDS.add('/manager'); // Human-only, sessionless interception in Lark ingress. /** * Slash commands that are forwarded verbatim to the underlying CLI (e.g. diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 592e6f1194..7fef78ba49 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -343,6 +343,12 @@ export const messages: Record = { 'cmd.mention_mode.owner_only': '⚠️ Only owner/allowedUsers can change the @-mention policy.', 'cmd.mention_mode.usage': 'Usage: /mention-mode always | topic | never | ambient\nalways = @ required to reply (default); topic = @ required at top level, non-@ continues inside topics; never = no @ required, answers all group messages; ambient = like never, but stays quiet when the message @mentions someone else.', 'cmd.mention_mode.failed': '⚠️ Failed to update @-mention policy: {reason}', + 'cmd.manager.status': 'Group manager: {app}\nManager mode active for this bot: {active}\nUsage: @target-bot /manager set | clear | status', + 'cmd.manager.unsupported': 'Manager commands require a top-level message in a regular group, not a DM, topic, or managed session group.', + 'cmd.manager.owner_only': 'Only the target bot owner/allowedUsers can set or clear a manager.', + 'cmd.manager.one_bot': 'Mention only one target bot when setting or clearing a manager.', + 'cmd.manager.usage': 'Usage: @target-bot /manager set | clear | status. To switch, clear the old manager first, then set the new one.', + 'cmd.manager.failed': 'Manager operation not confirmed: {reason}. Check /manager status first. An existing manager must be cleared by its administrator. Overfull descriptions require manual editing; no text is truncated automatically.', 'cmd.substitute.status_on': 'Current substitute mode for this group: ON (default). When a configured substitute target is @mentioned, I will answer on their behalf.', 'cmd.substitute.status_off': 'Current substitute mode for this group: OFF. Use @me /substitute on to enable it again.', 'cmd.substitute.updated_on': '✅ Substitute mode enabled for this group.', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 95a81db510..145d41ee36 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -342,6 +342,12 @@ export const messages: Record = { 'cmd.mention_mode.owner_only': '⚠️ 只有 owner/allowedUsers 可以修改 @ 策略。', 'cmd.mention_mode.usage': '用法:/mention-mode always|topic|never|ambient\nalways=必须 @ 才回复(默认);topic=顶层要 @,话题内免 @ 续话;never=免 @,群内消息都回复;ambient=同 never,但消息 @ 了别人时保持安静。', 'cmd.mention_mode.failed': '⚠️ 切换失败:{reason}', + 'cmd.manager.status': '群负责人:{app}\n当前机器人的负责人模式已生效:{active}\n用法:@目标机器人 /manager set|clear|status', + 'cmd.manager.unsupported': '负责人命令仅支持普通群顶层消息,不支持私聊、话题或自动管理的会话群。', + 'cmd.manager.owner_only': '只有目标机器人的 owner/allowedUsers 可以设置或取消负责人。', + 'cmd.manager.one_bot': '请只 @ 一个目标机器人再设置或取消负责人。', + 'cmd.manager.usage': '用法:@目标机器人 /manager set|clear|status。切换时先 @旧负责人 /manager clear,再 @新负责人 /manager set。', + 'cmd.manager.failed': '负责人操作未确认完成:{reason}。请先 /manager status 核对状态;已有其他负责人时须先由其管理员取消。群描述空间不足时请手动调整,不会自动截断。', 'cmd.substitute.status_on': '当前群替身模式:已开启(默认)。群里 @ 到配置的替身对象时,我会代答。', 'cmd.substitute.status_off': '当前群替身模式:已关闭。可用 @我 /substitute on 重新开启。', 'cmd.substitute.updated_on': '✅ 已开启当前群替身模式。', diff --git a/src/im/lark/event-dispatcher.ts b/src/im/lark/event-dispatcher.ts index 8c76054624..944e935975 100644 --- a/src/im/lark/event-dispatcher.ts +++ b/src/im/lark/event-dispatcher.ts @@ -44,6 +44,8 @@ import { tryHandleInviteCommand } from './invite-command.js'; import { autoInviteOwnerOnGroupJoin } from '../../services/groups-store.js'; import { tryHandleReplyModeCommand } from './reply-mode-command.js'; import { tryHandleMentionModeCommand } from './mention-mode-command.js'; +import { tryHandleManagerCommand } from './manager-command.js'; +import { isChatManager } from '../../services/chat-manager.js'; import { tryHandleSubstituteCommand } from './substitute-command.js'; import { buildGrantCard } from './card-builder.js'; import { openPending, isThrottled, clearPending } from './grant-pending.js'; @@ -2221,6 +2223,13 @@ export async function checkGroupMessageAccess( return isAllowed ? 'allowed' : 'not_allowed'; } + // An owner-selected manager adds top-level addressing only, not permissions + // or ownership of independent topics. Explicit mentions keep their usual path. + if (isAllowed && message.chat_type === 'group' && !message.root_id && !message.thread_id + && !mentionsAnotherMember(larkAppId, message) && await isChatManager(larkAppId, chatId)) { + return 'allowed'; + } + // No @mention — only allow if sender is the sole human in the group // AND this is the only bot in the chat. With multiple bots, require @mention // to disambiguate. @@ -3892,6 +3901,10 @@ export function startLarkEventDispatcher(larkAppId: string, larkAppSecret: strin return; } + if (await tryHandleManagerCommand(larkAppId, message, senderOpenId, isAllowed)) { + return; + } + if (await tryHandleSubstituteCommand(larkAppId, message, senderOpenId)) { return; } diff --git a/src/im/lark/manager-command.ts b/src/im/lark/manager-command.ts new file mode 100644 index 0000000000..f072e46d3f --- /dev/null +++ b/src/im/lark/manager-command.ts @@ -0,0 +1,67 @@ +import { effectiveBotDisplayName, getBot, getBotOpenId } from '../../bot-registry.js'; +import { localeForBot, t } from '../../i18n/index.js'; +import { changeChatManager, getChatManagerStatus } from '../../services/chat-manager.js'; +import { isSessionGroup } from '../../services/session-groups-store.js'; +import { logger } from '../../utils/logger.js'; +import { replyMessage } from './client.js'; +import { canOperate, extractMessageTextForRouting, isBotMentioned } from './event-dispatcher.js'; +import { stripLeadingMentions } from './message-parser.js'; + +/** Human-only pre-routing command; it never borrows a running session's owner. */ +export async function tryHandleManagerCommand( + app: string, message: any, senderOpenId: string | undefined, canTalk: boolean, +): Promise { + const raw = extractMessageTextForRouting(message); + if (!raw) return false; + const text = stripLeadingMentions(raw.trim(), message?.mentions ?? []); + const match = /^\/manager(?:\s+([\s\S]*))?$/i.exec(text); + if (!match) return false; + if (message.chat_type !== 'p2p' && !isBotMentioned(app, message, senderOpenId)) return true; + const loc = localeForBot(app); + const reply = async (content: string) => { + if (!message.message_id) return; + try { await replyMessage(app, message.message_id, content, 'text', false); } + catch { logger.warn('[chat-manager] command reply failed'); } + }; + if (!canTalk) return true; + const chat = message.chat_id; + if (message.chat_type !== 'group' || !chat || message.root_id || message.thread_id || isSessionGroup(chat)) { + await reply(t('cmd.manager.unsupported', undefined, loc)); + return true; + } + const action = match[1]?.trim().toLowerCase() || 'status'; + if (action !== 'status' && action !== 'set' && action !== 'clear') { + await reply(t('cmd.manager.usage', undefined, loc)); + return true; + } + if (action !== 'status') { + // canOperate alone allows everyone on an unconfigured/open bot. Selecting + // a group's default responder must always require an explicit administrator. + if (!senderOpenId || !getBot(app).resolvedAllowedUsers.includes(senderOpenId) + || !canOperate(app, chat, senderOpenId)) { + await reply(t('cmd.manager.owner_only', undefined, loc)); + return true; + } + const self = getBotOpenId(app); + const others = (message.mentions ?? []).some((mention: any) => { + const id = mention.id?.open_id; + return id !== self && id !== 'all'; + }); + const explicitlyMentioned = (message.mentions ?? []).some((mention: any) => mention.id?.open_id === self); + if (!self || !explicitlyMentioned || others) { + await reply(t('cmd.manager.one_bot', undefined, loc)); + return true; + } + } + const result = action === 'status' + ? await getChatManagerStatus(app, chat) + : await changeChatManager(app, chat, action, effectiveBotDisplayName(getBot(app))); + if (!result.ok) { + await reply(t('cmd.manager.failed', { reason: result.reason }, loc)); + } else { + await reply(t('cmd.manager.status', { + app: result.managerAppId ?? '-', active: String(result.locallyEnabled), + }, loc)); + } + return true; +} diff --git a/src/services/chat-manager-state.ts b/src/services/chat-manager-state.ts new file mode 100644 index 0000000000..b83f3ea41b --- /dev/null +++ b/src/services/chat-manager-state.ts @@ -0,0 +1,62 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { config } from '../config.js'; +import { atomicWriteFileSync } from '../utils/atomic-write.js'; + +export interface ChatManagerClaim { + schemaVersion: 1; + larkAppId: string; + chatId: string; + enabled: boolean; + originalName: string; + managedName: string; +} + +function key(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function root(): string { + return join(config.session.dataDir, 'chat-managers'); +} + +function claimPath(app: string, chat: string): string { + return join(root(), `${key(`${app}\0${chat}`)}.json`); +} + +export function readManagerClaim(app: string, chat: string): ChatManagerClaim | undefined { + try { + const claim = JSON.parse(readFileSync(claimPath(app, chat), 'utf8')); + if (claim?.schemaVersion !== 1 || claim.larkAppId !== app || claim.chatId !== chat + || typeof claim.enabled !== 'boolean' || typeof claim.originalName !== 'string' + || typeof claim.managedName !== 'string') throw new Error('invalid_local_claim'); + return claim; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } +} + +export function writeManagerClaim(claim: ChatManagerClaim): void { + mkdirSync(root(), { recursive: true, mode: 0o700 }); + atomicWriteFileSync(claimPath(claim.larkAppId, claim.chatId), JSON.stringify(claim), { + mode: 0o600, durable: true, followTargetSymlink: false, + }); +} + +/** Shared by bots on this host. Remote hosts still arbitrate through Lark. */ +export function managerLockPath(chat: string): string { + mkdirSync(root(), { recursive: true, mode: 0o700 }); + return join(root(), `chat-${key(chat)}`); +} + +export function parseManagerDescription(description: string): { humanText: string; appId?: string } { + const lines = description.split('\n'); + const markers = lines.map((line, index) => ({ index, match: /^\[botmux:manager=([A-Za-z0-9_-]+)\]$/.exec(line) })) + .filter(item => item.match); + if (markers.length > 1) throw new Error('ambiguous_manager_marker'); + const marker = markers[0]; + if (!marker) return { humanText: description }; + return { humanText: lines.filter((_, i) => i !== marker.index).join('\n'), appId: marker.match![1] }; +} diff --git a/src/services/chat-manager.ts b/src/services/chat-manager.ts new file mode 100644 index 0000000000..775c1f7ed4 --- /dev/null +++ b/src/services/chat-manager.ts @@ -0,0 +1,120 @@ +import { getBotClient } from '../bot-registry.js'; +import { larkGet } from '../im/lark/client.js'; +import { withFileLock } from '../utils/file-lock.js'; +import { managerLockPath, parseManagerDescription, readManagerClaim, writeManagerClaim } from './chat-manager-state.js'; + +export async function readManagerChat(app: string, chat: string) { + const res = await larkGet(getBotClient(app), `/open-apis/im/v1/chats/${encodeURIComponent(chat)}`); + if (res.code !== 0) throw new Error('chat_read_failed'); + if (res.data?.chat_mode !== 'group' || res.data?.group_message_type === 'thread') { + throw new Error('regular_group_only'); + } + if (typeof res.data?.name !== 'string' || typeof res.data?.description !== 'string') { + throw new Error('incomplete_chat_metadata'); + } + return { name: res.data.name as string, description: res.data.description as string }; +} + +/** A persisted owner opt-in AND a fresh shared claim are required, never just a marker. */ +export async function isChatManager(app: string, chat: string): Promise { + try { + if (!readManagerClaim(app, chat)?.enabled) return false; + const remote = await readManagerChat(app, chat); + return parseManagerDescription(remote.description).appId === app; + } catch { + // No stale-cache fallback: revocation/handover must work across deployments. + return false; + } +} + +export type ManagerResult = + | { ok: true; changed: boolean; managerAppId?: string; locallyEnabled: boolean } + | { ok: false; reason: string }; + +const publicErrors = new Set([ + 'chat_read_failed', 'regular_group_only', 'incomplete_chat_metadata', 'invalid_local_claim', + 'ambiguous_manager_marker', 'missing_bot_name', 'bot_name_too_long', 'invalid_app_id', + 'manager_already_set', 'not_current_manager', 'unowned_manager_marker', 'description_too_long', + 'chat_update_failed', 'chat_update_unconfirmed', +]); + +function failure(error: unknown): { ok: false; reason: string } { + // Transport/filesystem errors may contain URLs or private paths. Never echo + // their raw message into a group; only expose our bounded public error codes. + const reason = error instanceof Error && publicErrors.has(error.message) + ? error.message : 'manager_operation_failed'; + return { ok: false, reason }; +} + +export async function getChatManagerStatus(app: string, chat: string): Promise { + try { + const remote = await readManagerChat(app, chat); + const managerAppId = parseManagerDescription(remote.description).appId; + return { ok: true, changed: false, managerAppId, + locallyEnabled: managerAppId === app && !!readManagerClaim(app, chat)?.enabled }; + } catch (error) { return failure(error); } +} + +function managedName(original: string, label: string): string { + const suffix = ` · ${label.trim()}`; + if (!label.trim()) throw new Error('missing_bot_name'); + const room = 100 - Array.from(suffix).length; + if (room < 2) throw new Error('bot_name_too_long'); + const chars = Array.from(original); + const base = chars.length <= room ? original : `${chars.slice(0, room - 1).join('')}…`; + return `${base}${suffix}`; +} + +/** + * Called only after the incoming human has passed canOperate. No session-owner + * fallback. Persist intent before the remote update so a lost HTTP response is + * recoverable with status/retry. Intent alone never grants ambient addressing. + * Lark has no compare-and-swap: this lock serializes a shared data directory, + * while cross-host handovers require clear-old then set-new (not force takeover). + */ +export async function changeChatManager( + app: string, chat: string, action: 'set' | 'clear', label: string, +): Promise { + try { + if (!/^[A-Za-z0-9_-]+$/.test(app)) throw new Error('invalid_app_id'); + return await withFileLock(managerLockPath(chat), async (): Promise => { + const remote = await readManagerChat(app, chat); + const parsed = parseManagerDescription(remote.description); + const local = readManagerClaim(app, chat); + if (parsed.appId && parsed.appId !== app) { + throw new Error(action === 'set' ? 'manager_already_set' : 'not_current_manager'); + } + if (parsed.appId === app && !local?.enabled) throw new Error('unowned_manager_marker'); + if (action === 'set' && parsed.appId === app) { + return { ok: true, changed: false, managerAppId: app, locallyEnabled: true }; + } + if (action === 'clear' && !parsed.appId) { + if (local?.enabled) writeManagerClaim({ ...local, enabled: false }); + return { ok: true, changed: false, locallyEnabled: false }; + } + + const description = action === 'set' + ? `${remote.description}${remote.description ? '\n' : ''}[botmux:manager=${app}]` + : parsed.humanText; + if (Array.from(description).length > 100) throw new Error('description_too_long'); + const name = action === 'set' ? managedName(remote.name, label) + : local && remote.name === local.managedName ? local.originalName : remote.name; + if (action === 'set') writeManagerClaim({ + schemaVersion: 1, larkAppId: app, chatId: chat, enabled: true, + originalName: remote.name, managedName: name, + }); + + const result = await getBotClient(app).im.v1.chat.update({ + path: { chat_id: chat }, data: { name, description }, + }); + if (result.code !== 0) throw new Error('chat_update_failed'); + const confirmed = await readManagerChat(app, chat); + if (confirmed.name !== name || confirmed.description !== description) { + throw new Error('chat_update_unconfirmed'); + } + if (action === 'clear' && local) writeManagerClaim({ ...local, enabled: false }); + return { ok: true, changed: true, managerAppId: action === 'set' ? app : undefined, + locallyEnabled: action === 'set' }; + }); + } catch (error) { return failure(error); } +} diff --git a/test/chat-manager-command.test.ts b/test/chat-manager-command.test.ts new file mode 100644 index 0000000000..6ed1e79b1c --- /dev/null +++ b/test/chat-manager-command.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; + +const transport = vi.hoisted(() => ({ + name: 'Group', description: '', mode: 'group', writes: 0, + callbacks: {} as Record void>, + replies: [] as string[], +})); +vi.mock('@larksuiteoapi/node-sdk', () => ({ + Client: class { + request = async () => ({ code: 0, data: { + name: transport.name, description: transport.description, chat_mode: transport.mode, + user_count: 3, bot_count: 2, is_in_chat: true, items: [], + } }); + im = { v1: { + chat: { update: async ({ data }: any) => { + transport.writes++; Object.assign(transport, data); return { code: 0 }; + } }, + message: { reply: async ({ data }: any) => { + transport.replies.push(data.content); return { code: 0, data: { message_id: 'om_reply' } }; + } }, + } }; + }, + EventDispatcher: class { + register(handlers: typeof transport.callbacks) { transport.callbacks = handlers; return this; } + }, + WSClient: class { + start = async () => {}; + getConnectionStatus = () => ({ state: 'connected' }); + }, + Domain: { Feishu: 'feishu', Lark: 'lark' }, LoggerLevel: { info: 2, warn: 3 }, +})); +vi.mock('../src/im/lark/client.js', async (original) => ({ + ...await original(), + isHumanOpenId: async () => true, + getUserProfile: async () => null, +})); + +import { getBot, registerBot } from '../src/bot-registry.js'; +import { config } from '../src/config.js'; +import { startLarkEventDispatcher, rawMessageIngressAnchor } from '../src/im/lark/event-dispatcher.js'; +import { serializeByAnchor } from '../src/utils/anchor-serializer.js'; +import { isChatManager } from '../src/services/chat-manager.js'; +import { tryHandleManagerCommand } from '../src/im/lark/manager-command.js'; +import { normalizePassthroughCommand } from '../src/core/passthrough-commands.js'; + +const APP = 'cli_command'; +const CHAT = 'oc_command'; +const OWNER = 'ou_owner'; +const SELF = 'ou_self'; +const testRoot = config.session.dataDir; +let seq = 0; +const handlers = { + handleNewTopic: vi.fn(async () => {}), handleThreadReply: vi.fn(async () => {}), + handleCardAction: vi.fn(async () => {}), isSessionOwner: vi.fn(() => false), +}; + +async function deliver(text: string, opts: { sender?: string; mentions?: string[]; type?: string; thread?: boolean } = {}) { + const event = { + sender: { sender_type: 'user', sender_id: { open_id: opts.sender ?? OWNER } }, + message: { + message_id: `om_manager_${++seq}`, chat_id: CHAT, chat_type: opts.type ?? 'group', + message_type: 'text', content: JSON.stringify({ text }), + ...(opts.thread ? { root_id: 'om_topic', thread_id: 'omt_topic' } : {}), + mentions: (opts.mentions ?? [SELF]).map((id, i) => ({ key: `@_user_${i}`, name: id, id: { open_id: id } })), + }, + }; + transport.callbacks['im.message.receive_v1'](event); + // The production callback schedules its ingress with FIFO setImmediate. + // Queue a fence after it, then wait for the canonical chat delivery as well. + await new Promise(resolve => setImmediate(resolve)); + await serializeByAnchor(rawMessageIngressAnchor(APP, event.message), async () => {}, 0); + await serializeByAnchor(CHAT, async () => {}, 0); +} + +describe('/manager through the registered Lark event handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + config.session.dataDir = mkdtempSync(join(testRoot, 'manager-command-')); + config.daemon.forwardFollowupWaitMs = 0; + Object.assign(transport, { name: 'Group', description: '', mode: 'group', writes: 0, replies: [] }); + const bot = registerBot({ larkAppId: APP, larkAppSecret: 'test', cliId: 'codex', displayName: 'Manager', + allowedUsers: [OWNER], regularGroupReplyMode: 'chat', autoGrantRequestCards: false }); + bot.botOpenId = SELF; bot.resolvedAllowedUsers = [OWNER]; + bot.config.chatGrants = { [CHAT]: ['ou_guest'] }; + startLarkEventDispatcher(APP, 'test', handlers); + }); + + it('sets and clears without spawning a CLI session', async () => { + await deliver('/manager set'); + expect(await isChatManager(APP, CHAT)).toBe(true); + expect(transport.writes).toBe(1); + expect(handlers.handleNewTopic).not.toHaveBeenCalled(); + expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + await deliver('/manager clear'); + expect(await isChatManager(APP, CHAT)).toBe(false); + expect(transport.name).toBe('Group'); + }); + + it('ignores non-text messages safely and reserves the command against CLI passthrough', async () => { + expect(await tryHandleManagerCommand(APP, { message_type: 'image', content: '{}' }, OWNER, true)).toBe(false); + expect(normalizePassthroughCommand('/manager')).toBeNull(); + }); + + it('uses the actual incoming sender, not a session owner, for mutations', async () => { + await deliver('/manager set', { sender: 'ou_guest' }); + expect(transport.writes).toBe(0); + expect(await isChatManager(APP, CHAT)).toBe(false); + expect(handlers.handleNewTopic).not.toHaveBeenCalled(); + expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + }); + + it('requires an explicit administrator even on an otherwise open bot', async () => { + const bot = getBot(APP); + bot.config.allowedUsers = []; + bot.resolvedAllowedUsers = []; + bot.config.chatGrants = undefined; + await deliver('/manager set', { sender: 'ou_stranger' }); + expect(transport.writes).toBe(0); + expect(await isChatManager(APP, CHAT)).toBe(false); + expect(handlers.handleNewTopic).not.toHaveBeenCalled(); + }); + + it('lets a talk-authorized guest read status without granting management', async () => { + await deliver('/manager status', { sender: 'ou_guest' }); + expect(transport.replies).toHaveLength(1); + expect(transport.writes).toBe(0); + expect(handlers.handleNewTopic).not.toHaveBeenCalled(); + }); + + it('requires exactly one explicitly addressed bot for mutation', async () => { + await deliver('/manager set', { mentions: [] }); + await deliver('/manager set', { mentions: [SELF, 'ou_other_bot'] }); + expect(transport.writes).toBe(0); + expect(handlers.handleNewTopic).not.toHaveBeenCalled(); + }); + + it('rejects DMs, native topics and invalid arguments without CLI fallback', async () => { + await deliver('/manager set', { type: 'p2p' }); + await deliver('/manager set', { thread: true }); + await deliver('/manager set unexpected'); + transport.mode = 'topic'; + await deliver('/manager set'); + expect(transport.writes).toBe(0); + expect(handlers.handleNewTopic).not.toHaveBeenCalled(); + expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + }); + + it('routes unmentioned messages only while the manager is enabled', async () => { + await deliver('/manager set'); + await deliver('hello', { mentions: [] }); + expect(handlers.handleNewTopic).toHaveBeenCalledTimes(1); + expect(handlers.handleNewTopic).toHaveBeenLastCalledWith(expect.anything(), expect.objectContaining({ scope: 'chat', anchor: CHAT })); + await deliver('for someone else', { mentions: ['ou_other_bot'] }); + expect(handlers.handleNewTopic).toHaveBeenCalledTimes(1); + await deliver('/manager clear'); + await deliver('no longer addressed', { mentions: [] }); + expect(handlers.handleNewTopic).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/chat-manager-operation.test.ts b/test/chat-manager-operation.test.ts new file mode 100644 index 0000000000..0775e652c2 --- /dev/null +++ b/test/chat-manager-operation.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const remote = vi.hoisted(() => ({ + name: 'Project', description: 'Human notes\n', failUpdate: false, + lieAboutUpdate: false, failRead: false, writes: 0, mode: 'group', +})); +vi.mock('@larksuiteoapi/node-sdk', () => ({ + Client: class { + request = async () => { + if (remote.failRead) throw new Error('offline'); + return { code: 0, data: { name: remote.name, description: remote.description, chat_mode: remote.mode, is_in_chat: true } }; + }; + im = { v1: { chat: { update: async ({ data }: { data: { name: string; description: string } }) => { + remote.writes++; + if (remote.failUpdate) return { code: 230001, msg: 'denied' }; + if (!remote.lieAboutUpdate) Object.assign(remote, data); + return { code: 0 }; + } } } }; + }, + Domain: { Feishu: 'feishu', Lark: 'lark' }, LoggerLevel: { info: 2 }, +})); + +import { registerBot } from '../src/bot-registry.js'; +import { config } from '../src/config.js'; +import * as manager from '../src/services/chat-manager.js'; + +const APP = 'cli_manager'; +const OTHER = 'cli_other'; +const CHAT = 'oc_project'; +const testRoot = config.session.dataDir; +const change = (action: 'set' | 'clear', app = APP, name = 'Manager') => + manager.changeChatManager(app, CHAT, action, name); + +describe('manager mutations with real durable state and a fake Lark transport', () => { + beforeEach(() => { + config.session.dataDir = mkdtempSync(join(testRoot, 'manager-op-')); + Object.assign(remote, { name: 'Project', description: 'Human notes\n', failUpdate: false, + lieAboutUpdate: false, failRead: false, writes: 0, mode: 'group' }); + for (const app of [APP, OTHER]) registerBot({ larkAppId: app, larkAppSecret: 'test', cliId: 'codex', allowedUsers: [] }); + }); + + it('sets, repeats idempotently and clears without damaging the description', async () => { + expect(await change('set')).toMatchObject({ ok: true, changed: true, managerAppId: APP }); + expect(remote.name).toBe('Project · Manager'); + expect(remote.description).toBe('Human notes\n\n[botmux:manager=cli_manager]'); + expect(await manager.isChatManager(APP, CHAT)).toBe(true); + expect(await change('set')).toMatchObject({ ok: true, changed: false }); + expect(remote.writes).toBe(1); + expect(await change('clear')).toMatchObject({ ok: true, changed: true }); + expect(remote.name).toBe('Project'); + expect(remote.description).toBe('Human notes\n'); + expect(await manager.isChatManager(APP, CHAT)).toBe(false); + }); + + it('requires an explicit clear before another bot can take over', async () => { + await change('set'); + expect(await change('set', OTHER)).toMatchObject({ ok: false, reason: 'manager_already_set' }); + expect(await change('clear', OTHER)).toMatchObject({ ok: false, reason: 'not_current_manager' }); + await change('clear'); + expect(await change('set', OTHER, 'Second')).toMatchObject({ ok: true, managerAppId: OTHER }); + expect(remote.name).toBe('Project · Second'); + expect(await manager.isChatManager(APP, CHAT)).toBe(false); + expect(await manager.isChatManager(OTHER, CHAT)).toBe(true); + }); + + it('serializes contenders on the same host, leaving only one active claim', async () => { + const results = await Promise.all([change('set'), change('set', OTHER)]); + expect(results.filter(result => result.ok)).toHaveLength(1); + const active = await Promise.all([manager.isChatManager(APP, CHAT), manager.isChatManager(OTHER, CHAT)]); + expect(active.filter(Boolean)).toHaveLength(1); + }); + + it('refuses overfull descriptions without truncation or a partial mutation', async () => { + remote.description = '文'.repeat(90); + expect(await change('set')).toMatchObject({ ok: false, reason: 'description_too_long' }); + expect(remote.description).toBe('文'.repeat(90)); + expect(remote.name).toBe('Project'); + expect(remote.writes).toBe(0); + }); + + it('keeps a manual rename and description edit when clearing', async () => { + await change('set'); + remote.name = 'User renamed'; + remote.description = `Updated notes\n[botmux:manager=${APP}]`; + expect(await change('clear')).toMatchObject({ ok: true }); + expect(remote.name).toBe('User renamed'); + expect(remote.description).toBe('Updated notes'); + }); + + it('does not activate or report success after a failed remote update', async () => { + remote.failUpdate = true; + expect(await change('set')).toMatchObject({ ok: false, reason: 'chat_update_failed' }); + expect(await manager.isChatManager(APP, CHAT)).toBe(false); + }); + + it('checks readback instead of treating code=0 as completion', async () => { + remote.lieAboutUpdate = true; + expect(await change('set')).toMatchObject({ ok: false, reason: 'chat_update_unconfirmed' }); + expect(await manager.isChatManager(APP, CHAT)).toBe(false); + }); + + it('does not adopt a manually forged marker without a local opt-in', async () => { + remote.description = `[botmux:manager=${APP}]`; + expect(await change('set')).toMatchObject({ ok: false, reason: 'unowned_manager_marker' }); + expect(remote.writes).toBe(0); + }); + + it('rejects duplicate markers instead of choosing an arbitrary winner', async () => { + remote.description = `[botmux:manager=${APP}]\n[botmux:manager=${OTHER}]`; + expect(await change('set')).toMatchObject({ ok: false, reason: 'ambiguous_manager_marker' }); + }); + + it('does not mutate a topic group or when metadata cannot be read', async () => { + remote.mode = 'topic'; + expect(await change('set')).toMatchObject({ ok: false, reason: 'regular_group_only' }); + remote.failRead = true; + expect(await change('set')).toEqual({ ok: false, reason: 'manager_operation_failed' }); + expect(remote.writes).toBe(0); + }); + + it('restores long Unicode names and persists private, reloadable metadata', async () => { + const original = '🎉'.repeat(95); + remote.name = original; + await change('set'); + expect(Array.from(remote.name).length).toBeLessThanOrEqual(100); + const root = join(config.session.dataDir, 'chat-managers'); + const file = readdirSync(root).find(path => path.endsWith('.json'))!; + const claim = JSON.parse(readFileSync(join(root, file), 'utf8')); + expect(claim).toMatchObject({ enabled: true, originalName: original, larkAppId: APP, chatId: CHAT }); + if (process.platform !== 'win32') expect(statSync(join(root, file)).mode & 0o777).toBe(0o600); + expect(await manager.isChatManager(APP, CHAT)).toBe(true); + await change('clear'); + expect(remote.name).toBe(original); + }); +}); diff --git a/test/chat-manager-routing.test.ts b/test/chat-manager-routing.test.ts new file mode 100644 index 0000000000..a4f12ba239 --- /dev/null +++ b/test/chat-manager-routing.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createHash } from 'node:crypto'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const remote = vi.hoisted(() => ({ + description: '[botmux:manager=cli_manager]', mode: 'group', fail: false, reads: 0, +})); +vi.mock('@larksuiteoapi/node-sdk', () => ({ + Client: class { + request = async () => { + remote.reads++; + if (remote.fail) throw new Error('offline'); + return { code: 0, data: { + description: remote.description, chat_mode: remote.mode, + user_count: 3, bot_count: 2, name: 'Group', + } }; + }; + }, + Domain: { Feishu: 'feishu', Lark: 'lark' }, + LoggerLevel: { info: 2 }, +})); + +import { registerBot } from '../src/bot-registry.js'; +import { config } from '../src/config.js'; +import { checkGroupMessageAccess, canOperate, __resetChatStatsForTest } from '../src/im/lark/event-dispatcher.js'; + +const APP = 'cli_manager'; +const CHAT = 'oc_managed'; +const OWNER = 'ou_owner'; + +function record(app = APP, chat = CHAT, enabled = true) { + const root = join(config.session.dataDir, 'chat-managers'); + mkdirSync(root, { recursive: true }); + const key = createHash('sha256').update(`${app}\0${chat}`).digest('hex'); + writeFileSync(join(root, `${key}.json`), JSON.stringify({ + schemaVersion: 1, larkAppId: app, chatId: chat, enabled, + originalName: 'Group', managedName: 'Group · Manager', + })); +} + +function message(mentions: string[] = [], thread = false) { + return { + chat_id: CHAT, chat_type: 'group', message_id: 'om_input', + ...(thread ? { root_id: 'om_root', thread_id: 'omt_thread' } : {}), + message_type: 'text', content: JSON.stringify({ text: 'hello' }), + mentions: mentions.map((id, i) => ({ key: `@_user_${i}`, id: { open_id: id } })), + }; +} + +describe('chat manager addressing through the real permission gate', () => { + beforeEach(() => { + remote.description = `[botmux:manager=${APP}]`; + remote.mode = 'group'; remote.fail = false; remote.reads = 0; + __resetChatStatsForTest(); + for (const app of [APP, 'cli_other']) { + const bot = registerBot({ larkAppId: app, larkAppSecret: 'test', cliId: 'codex', allowedUsers: [OWNER] }); + bot.botOpenId = `ou_${app}`; + bot.resolvedAllowedUsers = [OWNER]; + } + record(); + }); + + it('lets the designated manager answer an authorized unmentioned group message', async () => { + expect(await checkGroupMessageAccess(APP, message(), CHAT, OWNER)).toBe('allowed'); + }); + + it('does not grant talk or operate permission to an unauthorized sender', async () => { + expect(await checkGroupMessageAccess(APP, message(), CHAT, 'ou_stranger')).toBe('ignore'); + expect(await checkGroupMessageAccess(APP, message([`ou_${APP}`]), CHAT, 'ou_stranger')).toBe('not_allowed'); + expect(canOperate(APP, CHAT, 'ou_stranger')).toBe(false); + }); + + it('yields when only another bot or human is mentioned', async () => { + expect(await checkGroupMessageAccess(APP, message(['ou_other']), CHAT, OWNER)).toBe('ignore'); + }); + + it('still responds when explicitly mentioned together with another member', async () => { + expect(await checkGroupMessageAccess(APP, message([`ou_${APP}`, 'ou_other']), CHAT, OWNER)).toBe('allowed'); + }); + + it('does not treat @all as a redirect', async () => { + expect(await checkGroupMessageAccess(APP, message(['all']), CHAT, OWNER)).toBe('allowed'); + }); + + it('does not activate a bot merely because its app ID was put in the description', async () => { + record(APP, CHAT, false); + expect(await checkGroupMessageAccess(APP, message(), CHAT, OWNER)).toBe('ignore'); + }); + + it('stops the old manager after a cross-machine handover without a daemon restart', async () => { + expect(await checkGroupMessageAccess(APP, message(), CHAT, OWNER)).toBe('allowed'); + remote.description = '[botmux:manager=cli_other]'; + record('cli_other'); + expect(await checkGroupMessageAccess(APP, message(), CHAT, OWNER)).toBe('ignore'); + expect(await checkGroupMessageAccess('cli_other', message(), CHAT, OWNER)).toBe('allowed'); + }); + + it('fails closed when the shared claim cannot be read', async () => { + remote.fail = true; + expect(await checkGroupMessageAccess(APP, message(['all']), CHAT, OWNER)).toBe('ignore'); + }); + + it('does not claim messages inside independent topics or topic-mode groups', async () => { + expect(await checkGroupMessageAccess(APP, message([], true), CHAT, OWNER)).toBe('ignore'); + remote.mode = 'topic'; + expect(await checkGroupMessageAccess(APP, message(), CHAT, OWNER)).toBe('ignore'); + }); + + it('does not change another group', async () => { + expect(await checkGroupMessageAccess(APP, message(), 'oc_other_group', OWNER)).toBe('ignore'); + }); +});