Skip to content
Merged
28 changes: 28 additions & 0 deletions docs-site/docs/en/bots-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,34 @@ Run one bot on a GLM Coding Plan (or any Anthropic-compatible provider) while an
| `autoStartOnGroupJoinPrompt` | Paired with the above: the first-round prompt for proactive start; if empty / blank, opens with an empty message and lets the bot read the group context itself. Meaningless when `autoStartOnGroupJoin` is off |
| `autoStartOnNewTopic` | When `true`, the first message of every new topic in a topic group starts working automatically without an @ (no effect in plain groups). Defaults to passive (only @ triggers) |

## Summary command

| Field | Description |
|------|------|
| `summaryRange` | History range used by the explicit `@bot /summary` command. `limit` is the latest N messages in a regular group, defaulting to 50; `sinceHours` is the latest N hours in a regular group, defaulting to 24. Set either field to `0` to remove that limit. Topic groups always read the current topic/thread history, then apply the summary window |

Example:

```json
{
"summaryRange": {
"limit": 50,
"sinceHours": 24
}
}
```

- Only the explicit `@bot /summary` command triggers a summary. Messages that do not mention the bot still follow the existing group/topic routing rules and are not woken up by keywords.
- The dashboard "/summary Range" controls this `summaryRange` field.
- If an earlier `@same bot /summary` exists before the current trigger, the summary window includes only messages after that earlier command and up to the current trigger; otherwise botmux falls back to `limit` / `sinceHours`.
- `limit` and `sinceHours` are also safety caps. If both are `0`, that dimension is not limited.

## Legacy content trigger config

| Field | Description |
|------|------|
| `contentTriggers` | **Legacy / no longer active.** Older builds used this field for keyword / regex triggers without an @mention, but current message routing no longer wakes a bot from `contentTriggers`. The parser keeps this field only for `bots.json` compatibility: if an old dashboard-managed trigger named `dashboard-default-summary-trigger` exists, botmux may read its `limit` / `sinceHours` as a fallback for `summaryRange`. New configs should use `summaryRange` |

## Voice

| Field | Description |
Expand Down
28 changes: 28 additions & 0 deletions docs-site/docs/zh/bots-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,34 @@
| `autoStartOnGroupJoinPrompt` | 配合上面:自动开工的首轮 prompt;留空 / 空白则空消息开场,让 bot 自己读群上下文。`autoStartOnGroupJoin` 关闭时无意义 |
| `autoStartOnNewTopic` | `true` 时,话题群里每个新话题的首条消息无需 @ 也自动开工(普通群无效)。默认被动(仅 @ 触发) |

## 总结命令

| 字段 | 说明 |
|------|------|
| `summaryRange` | 显式总结命令 `@机器人 /summary` 使用的历史读取范围。`limit` 表示普通群最近 N 条消息,默认 50;`sinceHours` 表示普通群最近 N 小时,默认 24。任一字段设为 `0` 表示该维度不限制。话题群始终读取当前话题/thread 历史,再按总结窗口过滤 |

示例:

```json
{
"summaryRange": {
"limit": 50,
"sinceHours": 24
}
}
```

- 只有显式 `@机器人 /summary` 会触发总结;不 @ 机器人时仍按普通群/话题的既有路由规则处理,不会因为关键词自动唤醒。
- dashboard 的「/summary 总结范围」保存的就是 `summaryRange`。
- 如果本次触发前存在上一条 `@同一机器人 /summary`,总结窗口只包含上一条之后到本次触发为止的消息;找不到上一条时回退到 `limit` / `sinceHours`。
- `limit` 与 `sinceHours` 同时也是安全上限;两者都为 `0` 时表示不做该维度限制。

## 旧内容触发配置

| 字段 | 说明 |
|------|------|
| `contentTriggers` | **Legacy / 不再生效。** 旧版本曾用于关键词 / 正则免 @ 触发,但当前消息路由不会再根据 `contentTriggers` 唤醒 bot。保留该字段解析仅用于兼容旧 `bots.json`:如果存在名为 `dashboard-default-summary-trigger` 的旧 dashboard 配置,botmux 会尽量从其中迁移/读取 `limit` 与 `sinceHours` 作为 `summaryRange` 的兜底值。新配置请使用 `summaryRange` |

## 语音

| 字段 | 说明 |
Expand Down
200 changes: 200 additions & 0 deletions src/bot-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,48 @@ import { normalizeStartupCommandList } from './core/startup-commands.js';
import { sanitizePerBotEnv } from './core/per-bot-env.js';

export type ChatReplyMode = 'chat' | 'new-topic' | 'shared' | 'chat-topic';
export type ContentTriggerScope = 'topic' | 'regularGroup' | 'both';
export type ContentTriggerMatchType = 'keyword' | 'regex';
export type ContentTriggerActionType = 'start-or-wake-session';

export interface SummaryRangeConfig {
/** 0 means no count limit; omitted defaults to 50. */
limit?: number;
/** 0 means no time limit; omitted defaults to 24 hours. */
sinceHours?: number;
}

export interface ContentTriggerConfig {
name: string;
enabled: boolean;
scope: ContentTriggerScope;
/**
* Default false. When true, this trigger may be matched by non-@ messages
* authored by other bots. The current bot's own messages are still ignored.
*/
allowBotMessages?: boolean;
match: {
type: ContentTriggerMatchType;
pattern: string;
caseSensitive: boolean;
};
history: {
topic: {
mode: 'current-thread';
};
regularGroup: {
mode: 'recent-messages';
/** 0 means no count limit; omitted defaults to 50. */
limit?: number;
/** 0 means no time limit; omitted means no time limit. */
sinceHours?: number;
};
};
action: {
type: ContentTriggerActionType;
prompt: string;
};
}

function normalizeChatReplyModeConfig(raw: unknown): ChatReplyMode | undefined {
if (typeof raw !== 'string') return undefined;
Expand All @@ -24,6 +66,153 @@ function normalizeChatReplyModeConfig(raw: unknown): ChatReplyMode | undefined {
return undefined;
}

function normalizeContentTriggerScope(raw: unknown): ContentTriggerScope | undefined {
if (typeof raw !== 'string') return undefined;
const v = raw.trim().toLowerCase();
if (v === 'both' || v === 'all') return 'both';
if (v === 'topic' || v === 'thread' || v === 'topic-group' || v === 'topic_group') return 'topic';
if (v === 'regulargroup' || v === 'regular-group' || v === 'regular_group' || v === 'group') return 'regularGroup';
return undefined;
}

function normalizeNonNegativeInt(raw: unknown): number | undefined {
if (typeof raw !== 'number') return undefined;
if (!Number.isInteger(raw) || raw < 0) return undefined;
return raw;
}

function normalizeSummaryRange(raw: unknown): SummaryRangeConfig | undefined {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
const entry = raw as Record<string, unknown>;
const out: SummaryRangeConfig = {};
const limit = normalizeNonNegativeInt(entry.limit);
const sinceHours = normalizeNonNegativeInt(entry.sinceHours);
if (limit !== undefined) out.limit = limit;
if (sinceHours !== undefined) out.sinceHours = sinceHours;
return Object.keys(out).length > 0 ? out : undefined;
}

function normalizeContentTriggers(raw: unknown, botIndex: number): ContentTriggerConfig[] | undefined {
if (!Array.isArray(raw)) return undefined;
const out: ContentTriggerConfig[] = [];

raw.forEach((item, triggerIndex) => {
const loc = `Bot config [${botIndex}] contentTriggers[${triggerIndex}]`;
const drop = (reason: string) => logger.warn(`${loc} ignored: ${reason}`);
if (!item || typeof item !== 'object' || Array.isArray(item)) {
drop('must be an object');
return;
}
const entry = item as Record<string, unknown>;
const name = typeof entry.name === 'string' && entry.name.trim()
? entry.name.trim()
: `content-trigger-${triggerIndex + 1}`;
const enabled = entry.enabled !== false;
const scope = normalizeContentTriggerScope(entry.scope);
if (!scope) {
drop(`invalid scope ${JSON.stringify(entry.scope)}`);
return;
}

const matchRaw = entry.match;
if (!matchRaw || typeof matchRaw !== 'object' || Array.isArray(matchRaw)) {
drop('match must be an object');
return;
}
const match = matchRaw as Record<string, unknown>;
const type = match.type === 'keyword' || match.type === 'regex' ? match.type : undefined;
if (!type) {
drop(`invalid match.type ${JSON.stringify(match.type)}`);
return;
}
const pattern = typeof match.pattern === 'string' ? match.pattern : '';
if (!pattern) {
drop('match.pattern must be a non-empty string');
return;
}
const caseSensitive = match.caseSensitive === true;
if (type === 'regex') {
try {
// Validate only. Runtime recompiles defensively in case an in-memory
// config is mutated after startup.
new RegExp(pattern, caseSensitive ? 'u' : 'iu');
} catch (err) {
drop(`invalid regex ${JSON.stringify(pattern)} (${err instanceof Error ? err.message : String(err)})`);
return;
}
}

const actionRaw = entry.action;
if (!actionRaw || typeof actionRaw !== 'object' || Array.isArray(actionRaw)) {
drop('action must be an object');
return;
}
const action = actionRaw as Record<string, unknown>;
if (action.type !== 'start-or-wake-session') {
drop(`invalid action.type ${JSON.stringify(action.type)}`);
return;
}
const prompt = typeof action.prompt === 'string' ? action.prompt.trim() : '';
if (!prompt) {
drop('action.prompt must be a non-empty string');
return;
}

const historyRaw = entry.history && typeof entry.history === 'object' && !Array.isArray(entry.history)
? entry.history as Record<string, unknown>
: {};
const topicRaw = historyRaw.topic && typeof historyRaw.topic === 'object' && !Array.isArray(historyRaw.topic)
? historyRaw.topic as Record<string, unknown>
: {};
const regularRaw = historyRaw.regularGroup && typeof historyRaw.regularGroup === 'object' && !Array.isArray(historyRaw.regularGroup)
? historyRaw.regularGroup as Record<string, unknown>
: {};
const topicMode = topicRaw.mode === undefined || topicRaw.mode === 'current-thread'
? 'current-thread'
: undefined;
if (!topicMode) {
drop(`invalid history.topic.mode ${JSON.stringify(topicRaw.mode)}`);
return;
}
const regularMode = regularRaw.mode === undefined || regularRaw.mode === 'recent-messages'
? 'recent-messages'
: undefined;
if (!regularMode) {
drop(`invalid history.regularGroup.mode ${JSON.stringify(regularRaw.mode)}`);
return;
}
const limit = regularRaw.limit === undefined ? 50 : normalizeNonNegativeInt(regularRaw.limit);
if (limit === undefined) {
drop(`invalid history.regularGroup.limit ${JSON.stringify(regularRaw.limit)}`);
return;
}
const sinceHours = regularRaw.sinceHours === undefined ? undefined : normalizeNonNegativeInt(regularRaw.sinceHours);
if (regularRaw.sinceHours !== undefined && sinceHours === undefined) {
drop(`invalid history.regularGroup.sinceHours ${JSON.stringify(regularRaw.sinceHours)}`);
return;
}

out.push({
name,
enabled,
scope,
...(entry.allowBotMessages === true ? { allowBotMessages: true } : {}),
match: { type, pattern, caseSensitive },
history: {
topic: { mode: 'current-thread' },
regularGroup: {
mode: 'recent-messages',
limit,
sinceHours,
},
},
action: { type: 'start-or-wake-session', prompt },
});
});

return out.length > 0 ? out : undefined;
}

export interface OncallChat {
/** Lark chat_id (oc_xxx) the bot was pulled into. */
chatId: string;
Expand Down Expand Up @@ -362,6 +551,13 @@ export interface BotConfig {
* 单条订阅的触发范围之后可在 dashboard 逐文档改(doc-subscriptions 表)。
*/
docSubscribeDefaultMode?: 'mention-only' | 'all';
/** Per-bot range for explicit `@bot /summary`; defaults to 50 messages / 24h. */
summaryRange?: SummaryRangeConfig;
/**
* Legacy content/keyword trigger config. Kept parseable for config
* compatibility, but message routing no longer fires non-@ content triggers.
*/
contentTriggers?: ContentTriggerConfig[];
/**
* Per-bot voice-engine override for the voice-summary feature. Merged OVER
* the global `voice` block in ~/.botmux/config.json (per-bot wins field by
Expand Down Expand Up @@ -844,6 +1040,8 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] {
const env = Object.keys(sanitizedEnv).length > 0 ? sanitizedEnv : undefined;

const skills = readBotSkillPolicy(entry.skills);
const summaryRange = normalizeSummaryRange(entry.summaryRange ?? entry.summary);
const contentTriggers = normalizeContentTriggers(entry.contentTriggers, i);

// voice:per-bot 语音引擎覆盖。结构化保留(engine ∈ sami|openai,sami/openai
// 为对象,speaker/rate 透传);非对象或 engine 非法 → undefined。深度校验
Expand Down Expand Up @@ -957,6 +1155,8 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] {
// 文档订阅默认触发范围。只 'all' 有意义;'mention-only'(默认)归一化为
// undefined 让 bots.json 保持干净。
docSubscribeDefaultMode: entry.docSubscribeDefaultMode === 'all' ? 'all' : undefined,
summaryRange,
contentTriggers,
voice,
});
}
Expand Down
7 changes: 6 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3388,7 +3388,12 @@ async function resolveSessionAppId(sessionIdArg: string | undefined): Promise<{
}

async function cmdHistory(rest: string[]): Promise<void> {
const limit = parseInt(argValue(rest, '--limit') ?? '50', 10);
// Clamp to a positive count: the underlying list helpers treat pageSize <= 0
// (and non-finite) as "unlimited / read the whole chat", which is reserved for
// internal callers. A stray `--limit 0` or a typo like `--limit abc` (→ NaN)
// must NOT silently dump the entire history.
const parsedLimit = parseInt(argValue(rest, '--limit') ?? '50', 10);
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 50;
const scopeArg = argValue(rest, '--scope') ?? 'session';
const sessionIdArg = argValue(rest, '--session-id');
const { sid, larkAppId: appId, session: s } = await resolveSessionAppId(sessionIdArg);
Expand Down
1 change: 1 addition & 0 deletions src/core/command-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2697,6 +2697,7 @@ export async function handleCommand(
t('help.insight', undefined, loc),
t('help.land', undefined, loc),
t('help.subscribe_doc', undefined, loc),
t('help.summary', undefined, loc),
'',
t('help.heading_passthrough', { cliName }, loc),
// 展示当前 bot 实际生效的透传集合:固定白名单 + adapter 默认 + 有效自定义项。
Expand Down
27 changes: 27 additions & 0 deletions src/core/dashboard-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import * as observedBotsStore from '../services/observed-bots-store.js';
import { getDeploymentIdentity } from '../services/deployment-identity.js';
import * as grantPrefsStore from '../services/grant-prefs-store.js';
import { findConfigField, applyConfigField, coerceConfigValue } from '../services/bot-config-store.js';
import { summaryRangeFromBotConfig, updateDashboardSummaryRange } from '../services/summary-range-store.js';
import { config } from '../config.js';
import { computeSandboxDiff, applySandboxDiff } from '../services/sandbox-land.js';
import { buildSafeInsightConversation, buildSafeInsightOverview, buildSafeInsightReport, buildSafeInsightTurnDetail } from '../services/insight/report.js';
Expand Down Expand Up @@ -1245,6 +1246,7 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => {
startupCommands,
launchShell: getBot(cachedLarkAppId).config.launchShell ?? '',
env,
summaryRange: summaryRangeFromBotConfig(getBot(cachedLarkAppId).config),
skills: getBot(cachedLarkAppId).config.skills ?? null,
});
});
Expand Down Expand Up @@ -1294,6 +1296,31 @@ ipcRoute('PUT', '/api/bot-card-prefs', async (req, res) => {
jsonRes(res, 200, { ok: true, ...r.prefs });
});

// Per-bot explicit `/summary` history range. Body `{ limit, sinceHours }`.
ipcRoute('PUT', '/api/bot-summary-range', async (req, res) => {
if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' });
let raw: unknown;
try { raw = await readJsonBody(req); }
catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); }
const r = await updateDashboardSummaryRange(cachedLarkAppId, raw);
if (!r.ok) return jsonRes(res, 400, { ok: false, error: r.reason });
jsonRes(res, 200, { ok: true, summaryRange: r.summaryRange });
});

// Backward-compatible dashboard endpoint from the short-lived keyword-trigger UI.
ipcRoute('PUT', '/api/bot-summary-trigger', async (req, res) => {
if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' });
let raw: unknown;
try { raw = await readJsonBody(req); }
catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); }
const body = raw && typeof raw === 'object' && !Array.isArray(raw)
? { limit: (raw as Record<string, unknown>).limit, sinceHours: (raw as Record<string, unknown>).sinceHours }
: raw;
const r = await updateDashboardSummaryRange(cachedLarkAppId, body);
if (!r.ok) return jsonRes(res, 400, { ok: false, error: r.reason });
jsonRes(res, 200, { ok: true, summaryRange: r.summaryRange });
});

// Per-bot 授权偏好。Body 任意子集:
// • restrictGrantCommands: boolean — 限制被授权人只能纯对话
// • autoGrantRequestCards: boolean — 未授权 @ 被挡住时是否发 grant 申请卡
Expand Down
Loading