Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs-site/docs/en/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions docs-site/docs/zh/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 私聊支持;话题群无需设置(本就是话题),命令会被拒绝。
Expand Down
1 change: 1 addition & 0 deletions src/core/passthrough-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,12 @@ export const messages: Record<string, string> = {
'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.',
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,12 @@ export const messages: Record<string, string> = {
'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': '✅ 已开启当前群替身模式。',
Expand Down
13 changes: 13 additions & 0 deletions src/im/lark/event-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand Down
67 changes: 67 additions & 0 deletions src/im/lark/manager-command.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
62 changes: 62 additions & 0 deletions src/services/chat-manager-state.ts
Original file line number Diff line number Diff line change
@@ -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] };
}
Loading
Loading