Skip to content
Merged
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
95 changes: 95 additions & 0 deletions docs-site/docs/hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Lifecycle Hooks

botmux 可以在关键生命周期事件发生时**异步调用外部命令**。命令失败、超时或不存在只会写日志,不阻塞 botmux 主流程。

## 配置位置

按优先级从高到低:

1. `BOTMUX_HOOKS_JSON` 环境变量(直接传 JSON 数组)
2. `BOTMUX_HOOKS_FILE` 指定的文件路径
3. 默认 `~/.botmux/data/hooks.json`

## 快速验证:写入本地日志

仓库内置示例脚本,复制即用:

```bash
chmod +x examples/hooks/echo-to-log.sh
HOOK_CMD="$(pwd)/examples/hooks/echo-to-log.sh"
mkdir -p ~/.botmux/data
cat > ~/.botmux/data/hooks.json <<JSON
[
{
"event": "session.requires_attention",
"command": "$HOOK_CMD",
"timeoutMs": 5000
}
]
JSON

tail -f /tmp/botmux-hook.log
```

触发任意 hook 事件后即可在日志里看到 JSON payload。`examples/hooks/` 还附带 macOS Notification Center(`osascript-notify.sh`)和 HTTP webhook(`http-webhook.sh`)示例。

## 配置字段

```json
[
{
"event": "session.requires_attention",
"command": "/absolute/path/to/your-hook --flag value",
"timeoutMs": 5000,
"filter": { "chatId": "oc_xxx" },
"redact": { "fullContentEvents": ["session.requires_attention"] }
}
]
```

| 字段 | 类型 | 说明 |
|------|------|------|
| `event` | string | 必填。订阅的事件名(见下表) |
| `command` | string | 必填。外部可执行命令;支持参数,但不经 shell 执行 |
| `timeoutMs` | number | 可选。默认 5000;超时先 `SIGTERM`,再兜底 `SIGKILL` |
| `filter.chatId` | string|string[] | 可选。只匹配指定飞书群 / 话题所在 chat |
| `filter.senderOpenId` | string|string[] | 可选。只匹配指定发送者 open_id |
| `redact.fullContentEvents` | string[] | 可选。默认截断长文本;列入 allowlist 的事件透传全文 |

## 支持事件

| 事件 | 触发时机 |
|------|----------|
| `topic.new` | 收到新话题 / @mention |
| `thread.reply` | 收到已有话题回复 |
| `outbound.send` | botmux 发送普通消息成功 |
| `outbound.reply` | botmux 回复话题消息成功 |
| `schedule.fired` | 定时任务执行完成 |
| `session.start` | worker / adopt worker 启动成功 |
| `session.exit` | worker 退出、崩溃或会话被关闭(daemon shutdown 默认静音) |
| `session.idle` | session 进入或离开 idle,按 session + 状态 10s 去重 |
| `session.requires_attention` | TUI prompt 或 worker `user_notify` 需要用户处理 |

## Payload 字段

所有 payload 通过 stdin 写入 hook 命令,同时设置环境变量 `BOTMUX_HOOK_EVENT`。每份 payload 都包含 `event`、`emittedAt`;事件上下文可包含 `sessionId`、`chatId`、`chatType`、`larkAppId`、`scope`、`anchor`、`title`、`cliId`、`workingDir`、`hasHistory`、`spawnedAt`、`lastMessageAt`。

不同事件额外携带:

| 事件 | 额外字段 |
|------|----------|
| `topic.new` | `messageId`、`senderOpenId`、`senderType`、`msgType`、`content` |
| `thread.reply` | `messageId`、`rootId`、`parentId`、`senderOpenId`、`senderType`、`msgType`、`content` |
| `outbound.send` | `messageId`、`msgType`、`uuid`、`content` |
| `outbound.reply` | `messageId`、`replyId`、`msgType`、`replyInThread`、`uuid`、`content` |
| `schedule.fired` | `id`、`name`、`schedule`、`status`、`error`、`rootMessageId`、`runAt` |
| `session.start` | `reason`、`pid`、`adoptedFrom` |
| `session.exit` | `reason`、`code`(worker 退出路径;`dashboard_close` 为 `null`) |
| `session.idle` | `prevState`、`newState`、`transition`、`source` |
| `session.requires_attention` | `reason`、`description`、`optionsCount`、`optionsPreview`、`multiSelect`、`message` |

默认会把 `content`、`message`、`description`、`finalOutput`、`lastScreenContent` 截断到 **600 字符**,并补充 `xxxLength` / `xxxTruncated`;只有 `redact.fullContentEvents` 内的事件透传全文。

## 写自己的 hook

hook 命令可以是任意 executable:bash / Python / Node / Go 二进制、公司内部 CLI、HTTP 转发器都行。命令 `exit 0` 视为成功;非 0 / 超时 / 找不到命令只写 botmux 日志,不会影响收发消息、定时任务或 session 生命周期。
4 changes: 4 additions & 0 deletions docs-site/rspress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export default defineConfig({
"text": "定时任务",
"link": "/schedule"
},
{
"text": "Lifecycle Hooks",
"link": "/hooks"
},
{
"text": "Oncall 模式",
"link": "/oncall"
Expand Down
35 changes: 35 additions & 0 deletions examples/hooks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# botmux hook examples

These scripts are minimal hook commands you can copy and adapt. botmux runs hook
commands without a shell, writes one JSON payload to stdin, and sets
`BOTMUX_HOOK_EVENT` to the current event name.

## Quick start

```bash
chmod +x examples/hooks/*.sh
HOOK_CMD="$(pwd)/examples/hooks/echo-to-log.sh"
mkdir -p ~/.botmux/data
cat > ~/.botmux/data/hooks.json <<JSON
[
{
"event": "session.requires_attention",
"command": "$HOOK_CMD",
"timeoutMs": 5000
}
]
JSON
```

Then trigger a matching event and inspect `/tmp/botmux-hook.log`.

## Scripts

| Script | What it does |
|--------|--------------|
| `echo-to-log.sh` | Appends every payload to `/tmp/botmux-hook.log` |
| `osascript-notify.sh` | Shows a macOS Notification Center alert |
| `http-webhook.sh` | POSTs the stdin payload to an HTTP endpoint |

Use absolute paths in `hooks.json`. If the command needs configuration, pass it
as command arguments or environment variables inherited by the botmux daemon.
5 changes: 5 additions & 0 deletions examples/hooks/echo-to-log.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
log="${BOTMUX_HOOK_LOG:-/tmp/botmux-hook.log}"
printf '\n--- %s %s ---\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "${BOTMUX_HOOK_EVENT:-unknown}" >> "$log"
cat >> "$log"
10 changes: 10 additions & 0 deletions examples/hooks/http-webhook.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail

endpoint="${1:?usage: http-webhook.sh <url>}"

curl -fsS \
-X POST \
-H 'Content-Type: application/json' \
--data-binary @- \
"$endpoint" >/dev/null
10 changes: 10 additions & 0 deletions examples/hooks/osascript-notify.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail

payload="$(cat)"
title="botmux ${BOTMUX_HOOK_EVENT:-hook}"
body="$(printf '%s' "$payload" | tr '\n' ' ' | cut -c 1-240)"

/usr/bin/osascript -e 'on run argv
display notification (item 2 of argv) with title (item 1 of argv)
end run' "$title" "$body"
13 changes: 13 additions & 0 deletions src/bot-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,16 @@ export interface BotConfig {
* (undefined) keeps the streaming card. For users who find the live card noisy.
*/
disableStreamingCard?: boolean;
/**
* Conversation mode for 1:1 private chats (DMs) with the bot:
* - 'thread' (default, stored as undefined): every top-level DM message
* starts a fresh thread-scoped session — the official/legacy behavior,
* keeps 1:1 chatter out of one long-running CLI process.
* - 'chat': route DMs as one flat, continuous chat-scoped session (all
* messages share the same context, similar to Hermes/OpenClaw).
* Editable at runtime via `/botconfig p2pMode chat|thread` (owner/admin).
*/
p2pMode?: 'thread' | 'chat';
/** chat_id list: chats where the live streaming card is suppressed (status falls back to master's pending-card morph). Written by `/card off|on`. */
noCardChats?: string[];
/**
Expand Down Expand Up @@ -646,6 +656,9 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] {
// means "use default botmux brand". Don't trim-to-undefined here.
brandLabel: typeof entry.brandLabel === 'string' ? entry.brandLabel : undefined,
disableStreamingCard: entry.disableStreamingCard === true || undefined,
// Only 'chat' is meaningful; 'thread' (and anything else) normalizes to
// undefined — the legacy thread-per-message default. Keeps bots.json clean.
p2pMode: entry.p2pMode === 'chat' ? 'chat' : undefined,
noCardChats: Array.isArray(entry.noCardChats)
? entry.noCardChats.filter((x: any): x is string => typeof x === 'string' && x.trim().length > 0).map((x: string) => x.trim())
: undefined,
Expand Down
44 changes: 28 additions & 16 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { createCliAdapterSync } from './adapters/cli/registry.js';
import { logger } from './utils/logger.js';
import { invalidWorkingDirs } from './utils/working-dir.js';
import { firstPositional } from './cli/arg-utils.js';
import { dispatchPrimaryMessage } from './cli/send-dispatch.js';
import {
formatBotInfoEntriesForCli,
formatChatBotsForCli,
Expand Down Expand Up @@ -3028,13 +3029,20 @@ async function cmdSend(rest: string[]): Promise<void> {
// addressing to go to the last caller in the shared oncall workspace.
const oncallEntry = !sendTopLevel && !overrideChatId && !sendInto && s.chatId
? findOncallChatForAnyBot(s.chatId) : undefined;

const hookContext = {
sessionId: sid,
chatId: s.chatId,
rootMessageId: s.rootMessageId,
title: s.title,
};
// Dispatch helper: top-level / chat-scope send vs reply-in-thread, single
// decision point. Used for file attachments (always plain in chat scope).
const sendTarget = resolveSendTarget({ into: sendInto, topLevel: sendTopLevel, chatScope: isChatScope, chatId: targetChatId, rootMessageId: s.rootMessageId, replyTargetRootId: s.currentReplyTarget?.rootMessageId, replyTargetTurnId: s.currentReplyTarget?.turnId, currentTurnId });
const dispatch = (content: string, msgType: string): Promise<string> =>
sendTarget.mode === 'plain'
? sendMessage(appId, sendTarget.chatId, content, msgType)
: replyMessage(appId, sendTarget.rootMessageId, content, msgType, true);
? sendMessage(appId, sendTarget.chatId, content, msgType, undefined, hookContext)
: replyMessage(appId, sendTarget.rootMessageId, content, msgType, true, undefined, hookContext);
const recordBridgeSendMarker = (sentAtMs: number, messageId: string, sentContent: string): void => {
try {
const markerDir = join(resolveDataDir(), 'turn-sends');
Expand Down Expand Up @@ -3102,20 +3110,24 @@ async function cmdSend(rest: string[]): Promise<void> {
});
let primaryQuotedId: string | null = null;
const dispatchPrimary = async (content: string, msgType: string): Promise<string> => {
if (quoteTargetId) {
try {
const id = await replyMessage(appId, quoteTargetId, content, msgType, false);
primaryQuotedId = quoteTargetId;
return id;
} catch (err: any) {
if (err instanceof MessageWithdrawnError) {
console.error(`引用目标 ${quoteTargetId} 已撤回,改为普通发送`);
return sendMessage(appId, targetChatId, content, msgType);
}
throw err;
}
}
return dispatch(content, msgType);
const result = await dispatchPrimaryMessage(
{ sendMessage, replyMessage },
{
appId,
targetChatId,
quoteTargetId,
content,
msgType,
hookContext,
MessageWithdrawnError,
dispatch,
onQuoteWithdrawn: (id) => {
console.error(`引用目标 ${id} 已撤回,改为普通发送`);
},
},
);
primaryQuotedId = result.primaryQuotedId;
return result.messageId;
};

try {
Expand Down
81 changes: 81 additions & 0 deletions src/cli/send-dispatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
export type SendMessageFn = (
larkAppId: string,
chatId: string,
content: string,
msgType?: string,
uuid?: string,
hookContext?: Record<string, unknown>,
) => Promise<string>;

export type ReplyMessageFn = (
larkAppId: string,
messageId: string,
content: string,
msgType?: string,
replyInThread?: boolean,
uuid?: string,
hookContext?: Record<string, unknown>,
) => Promise<string>;

export type DispatchPrimaryDeps = {
sendMessage: SendMessageFn;
replyMessage: ReplyMessageFn;
};

export type DispatchPrimaryOptions = {
appId: string;
targetChatId: string;
quoteTargetId: string | null | undefined;
content: string;
msgType: string;
hookContext: Record<string, unknown>;
MessageWithdrawnError: new (...args: any[]) => Error;
dispatch: (content: string, msgType: string) => Promise<string>;
onQuoteWithdrawn?: (messageId: string) => void;
};

export type DispatchPrimaryResult = {
messageId: string;
primaryQuotedId: string | null;
};

export async function dispatchPrimaryMessage(
deps: DispatchPrimaryDeps,
opts: DispatchPrimaryOptions,
): Promise<DispatchPrimaryResult> {
if (!opts.quoteTargetId) {
return {
messageId: await opts.dispatch(opts.content, opts.msgType),
primaryQuotedId: null,
};
}

try {
const messageId = await deps.replyMessage(
opts.appId,
opts.quoteTargetId,
opts.content,
opts.msgType,
false,
undefined,
opts.hookContext,
);
return { messageId, primaryQuotedId: opts.quoteTargetId };
} catch (err: any) {
if (err instanceof opts.MessageWithdrawnError) {
opts.onQuoteWithdrawn?.(opts.quoteTargetId);
return {
messageId: await deps.sendMessage(
opts.appId,
opts.targetChatId,
opts.content,
opts.msgType,
undefined,
opts.hookContext,
),
primaryQuotedId: null,
};
}
throw err;
}
}
Loading