agentquad 0.4.5 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/dist-web/assets/index-DdqC2CwH.css +32 -0
- package/dist-web/assets/{index-By--XlP3.js → index-DkI6ZJx_.js} +411 -399
- package/dist-web/assets/logo-Cxw7XzHl.png +0 -0
- package/dist-web/favicon.png +0 -0
- package/dist-web/index.html +2 -2
- package/package.json +7 -1
- package/src/claude-prompt-detector.js +72 -0
- package/src/cli.js +1 -1
- package/src/codex-hook-installer.js +1 -1
- package/src/codex-prompt-detector.js +104 -13
- package/src/config.js +33 -5
- package/src/db.js +77 -31
- package/src/export/todoMarkdown.js +1 -9
- package/src/lark-bot.js +44 -5
- package/src/mcp/tools/destructive/index.js +22 -16
- package/src/mcp/tools/openclaw/index.js +12 -16
- package/src/mcp/tools/read/index.js +7 -7
- package/src/mcp/tools/write/index.js +9 -6
- package/src/openclaw-bridge.js +176 -28
- package/src/openclaw-hook-installer.js +2 -1
- package/src/openclaw-hook.js +127 -9
- package/src/openclaw-wizard.js +168 -191
- package/src/permission-prompt.js +113 -31
- package/src/prompt-render.js +0 -8
- package/src/pty.js +183 -49
- package/src/routes/ai-terminal.js +90 -26
- package/src/routes/telegram-sync.js +7 -5
- package/src/routes/todos.js +8 -14
- package/src/server.js +90 -12
- package/src/session-input-dispatcher.js +48 -4
- package/src/stats/report.js +1 -6
- package/src/telegram-bot.js +82 -15
- package/src/telegram-loading-status.js +1 -1
- package/src/templates/claude-hooks/notify.js +1 -1
- package/src/templates/codex-hooks/notify.js +1 -1
- package/src/wiki/index.js +1 -1
- package/src/wiki/sources.js +0 -1
- package/dist-web/assets/index-8A0oLLcX.css +0 -32
- package/dist-web/assets/logo-D4DDtU-r.png +0 -0
|
@@ -195,12 +195,15 @@ export function registerDestructiveTools(server, { db, audit }) {
|
|
|
195
195
|
'bulk_update',
|
|
196
196
|
{
|
|
197
197
|
description:
|
|
198
|
-
'对一组 todo id 批量 patch。允许字段:
|
|
198
|
+
'对一组 todo id 批量 patch。允许字段:status / archived / dueDate。' +
|
|
199
|
+
'(quadrant 字段保留接口以兼容旧调用方,传入会被忽略。)' +
|
|
200
|
+
'默认 confirm:false 返回 preview(列出将要修改的 todos,最多 20 条)。',
|
|
199
201
|
inputSchema: {
|
|
200
202
|
ids: z.array(z.string().min(1)).min(1),
|
|
201
203
|
patch: z
|
|
202
204
|
.object({
|
|
203
|
-
quadrant: z.number().int().min(1).max(4).optional()
|
|
205
|
+
quadrant: z.number().int().min(1).max(4).optional()
|
|
206
|
+
.describe('【已退役】象限概念已移除,传入会被忽略'),
|
|
204
207
|
status: z.enum(['todo', 'done']).optional(),
|
|
205
208
|
archived: z.boolean().optional(),
|
|
206
209
|
dueDate: z.number().int().nullable().optional(),
|
|
@@ -211,40 +214,43 @@ export function registerDestructiveTools(server, { db, audit }) {
|
|
|
211
214
|
},
|
|
212
215
|
},
|
|
213
216
|
async (args) => {
|
|
217
|
+
// 剥掉退役字段,避免 db 层试图写 quadrant
|
|
218
|
+
const { quadrant: _ignored, ...effectivePatch } = args.patch || {}
|
|
219
|
+
const sanitizedArgs = { ...args, patch: effectivePatch }
|
|
214
220
|
try {
|
|
215
221
|
const preview = {
|
|
216
|
-
ids:
|
|
217
|
-
patch:
|
|
222
|
+
ids: sanitizedArgs.ids,
|
|
223
|
+
patch: sanitizedArgs.patch,
|
|
218
224
|
affected: [],
|
|
219
225
|
missing: [],
|
|
220
226
|
}
|
|
221
|
-
for (const id of
|
|
227
|
+
for (const id of sanitizedArgs.ids.slice(0, 20)) {
|
|
222
228
|
const t = db.getTodo(id)
|
|
223
|
-
if (t) preview.affected.push({ id: t.id, title: t.title,
|
|
229
|
+
if (t) preview.affected.push({ id: t.id, title: t.title, status: t.status })
|
|
224
230
|
else preview.missing.push(id)
|
|
225
231
|
}
|
|
226
|
-
preview.totalTargeted =
|
|
227
|
-
preview.previewTruncated =
|
|
228
|
-
if (!
|
|
229
|
-
const patchKeys = Object.keys(
|
|
232
|
+
preview.totalTargeted = sanitizedArgs.ids.length
|
|
233
|
+
preview.previewTruncated = sanitizedArgs.ids.length > 20
|
|
234
|
+
if (!sanitizedArgs.confirm) {
|
|
235
|
+
const patchKeys = Object.keys(sanitizedArgs.patch).join(', ') || '(空 — quadrant 已退役)'
|
|
230
236
|
return previewResponse({
|
|
231
237
|
toolName: 'bulk_update',
|
|
232
|
-
summary: `将对 ${
|
|
238
|
+
summary: `将对 ${sanitizedArgs.ids.length} 条 todo 批量 patch 以下字段:${patchKeys}。命中 ${preview.affected.length} 条、未找到 ${preview.missing.length} 条。`,
|
|
233
239
|
impact: preview,
|
|
234
|
-
args,
|
|
240
|
+
args: sanitizedArgs,
|
|
235
241
|
})
|
|
236
242
|
}
|
|
237
|
-
const result = db.bulkUpdateTodos({ ids:
|
|
243
|
+
const result = db.bulkUpdateTodos({ ids: sanitizedArgs.ids, patch: sanitizedArgs.patch })
|
|
238
244
|
audit?.append({
|
|
239
245
|
tool: 'bulk_update',
|
|
240
246
|
ok: true,
|
|
241
|
-
args: { ids:
|
|
247
|
+
args: { ids: sanitizedArgs.ids, patch: sanitizedArgs.patch },
|
|
242
248
|
result: { changedCount: result.count, changedIds: result.changedIds },
|
|
243
|
-
confirmNote:
|
|
249
|
+
confirmNote: sanitizedArgs.confirmNote || null,
|
|
244
250
|
})
|
|
245
251
|
return asText({ ok: true, result })
|
|
246
252
|
} catch (e) {
|
|
247
|
-
audit?.append({ tool: 'bulk_update', ok: false, args: { ids:
|
|
253
|
+
audit?.append({ tool: 'bulk_update', ok: false, args: { ids: sanitizedArgs.ids, patch: sanitizedArgs.patch }, error: e?.message })
|
|
248
254
|
return asError(e?.message || 'bulk_update_failed')
|
|
249
255
|
}
|
|
250
256
|
},
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OpenClaw 双向桥接的 MCP
|
|
2
|
+
* OpenClaw 双向桥接的 MCP 工具集:
|
|
3
3
|
*
|
|
4
4
|
* 创建任务向导(OpenClaw skill 调):
|
|
5
5
|
* - list_workdir_options
|
|
6
|
-
* - list_quadrants
|
|
7
|
-
* - list_templates
|
|
6
|
+
* - list_quadrants (已废弃,仅为兼容旧 skill;返回空数组 + deprecated 标记)
|
|
7
|
+
* - list_templates (用户面叫 "agent / 员工",工具名保留向后兼容)
|
|
8
8
|
*
|
|
9
9
|
* 启动 PTY(OpenClaw skill 调):
|
|
10
10
|
* - start_ai_session
|
|
@@ -37,13 +37,6 @@ function asError(message, extra = null) {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
const QUADRANTS = [
|
|
41
|
-
{ id: 1, label: '重要紧急', shortLabel: 'Q1', isDefault: false },
|
|
42
|
-
{ id: 2, label: '重要不紧急', shortLabel: 'Q2', isDefault: true },
|
|
43
|
-
{ id: 3, label: '紧急不重要', shortLabel: 'Q3', isDefault: false },
|
|
44
|
-
{ id: 4, label: '不重要不紧急', shortLabel: 'Q4', isDefault: false },
|
|
45
|
-
]
|
|
46
|
-
|
|
47
40
|
function expandHome(p) {
|
|
48
41
|
if (!p) return p
|
|
49
42
|
if (p === '~' || p.startsWith('~/')) return p === '~' ? homedir() : join(homedir(), p.slice(2))
|
|
@@ -105,22 +98,25 @@ export function registerOpenClawTools(server, deps) {
|
|
|
105
98
|
},
|
|
106
99
|
)
|
|
107
100
|
|
|
108
|
-
// ─── 2. list_quadrants
|
|
101
|
+
// ─── 2. list_quadrants(已废弃;保留以兼容旧版 OpenClaw skill)──
|
|
109
102
|
server.registerTool(
|
|
110
103
|
'list_quadrants',
|
|
111
104
|
{
|
|
112
|
-
description:
|
|
105
|
+
description:
|
|
106
|
+
'【已废弃】象限概念已从 AgentQuad 移除。本工具仅为兼容旧 skill 而保留,' +
|
|
107
|
+
'返回空数组 + deprecated 标记。新创建任务无需再选象限。',
|
|
113
108
|
inputSchema: {},
|
|
114
109
|
},
|
|
115
|
-
async () => asText({ quadrants:
|
|
110
|
+
async () => asText({ quadrants: [], deprecated: true }),
|
|
116
111
|
)
|
|
117
112
|
|
|
118
|
-
// ─── 3. list_templates
|
|
113
|
+
// ─── 3. list_templates (用户面叫 agent / 员工) ─────────────────────
|
|
119
114
|
server.registerTool(
|
|
120
115
|
'list_templates',
|
|
121
116
|
{
|
|
122
117
|
description:
|
|
123
|
-
'
|
|
118
|
+
'返回所有可指派的 agent / 员工(id/name/description/builtin/contentPreview),' +
|
|
119
|
+
'创建向导选 agent 那一步用。' +
|
|
124
120
|
'完整 content 不返回(避免上下文爆);启动会话时按 templateId 自动注入。',
|
|
125
121
|
inputSchema: {},
|
|
126
122
|
},
|
|
@@ -315,7 +311,7 @@ export function registerOpenClawTools(server, deps) {
|
|
|
315
311
|
// 拼按钮失败不阻塞;纯文本兜底(用户照样能数字回复)
|
|
316
312
|
}
|
|
317
313
|
|
|
318
|
-
const sendResult = await openclaw.
|
|
314
|
+
const sendResult = await openclaw.broadcastText({
|
|
319
315
|
sessionId,
|
|
320
316
|
message: lines.join('\n'),
|
|
321
317
|
replyMarkup: askUserMarkup,
|
|
@@ -53,7 +53,8 @@ export function registerReadTools(server, { db, searchService, wikiDir, transcri
|
|
|
53
53
|
description:
|
|
54
54
|
'按过滤条件列出 todos。不做全文搜索——用 search 做模糊搜索。返回元数据数组,不含 AI 会话详情(用 get_todo 取)。',
|
|
55
55
|
inputSchema: {
|
|
56
|
-
quadrant: z.number().int().min(1).max(4).optional()
|
|
56
|
+
quadrant: z.number().int().min(1).max(4).optional()
|
|
57
|
+
.describe('【已退役】象限概念已移除;保留参数用于兼容旧调用方,传入会被忽略'),
|
|
57
58
|
status: z.enum(['todo', 'done', 'all']).optional().describe('默认 all'),
|
|
58
59
|
archived: z.union([z.boolean(), z.literal('all')]).optional().describe('默认 false 只看未归档;"all" 两者都要'),
|
|
59
60
|
parentId: z.string().optional().describe('仅列某 parent 下的子任务'),
|
|
@@ -63,8 +64,8 @@ export function registerReadTools(server, { db, searchService, wikiDir, transcri
|
|
|
63
64
|
async (args = {}) => {
|
|
64
65
|
try {
|
|
65
66
|
const rawStatus = args.status === 'all' ? '' : args.status
|
|
67
|
+
// quadrant 已退役,不再透传 db 层
|
|
66
68
|
const list = db.listTodos({
|
|
67
|
-
quadrant: args.quadrant,
|
|
68
69
|
status: rawStatus,
|
|
69
70
|
archived: args.archived,
|
|
70
71
|
})
|
|
@@ -78,7 +79,6 @@ export function registerReadTools(server, { db, searchService, wikiDir, transcri
|
|
|
78
79
|
id: t.id,
|
|
79
80
|
parentId: t.parentId,
|
|
80
81
|
title: t.title,
|
|
81
|
-
quadrant: t.quadrant,
|
|
82
82
|
status: t.status,
|
|
83
83
|
dueDate: t.dueDate,
|
|
84
84
|
workDir: t.workDir,
|
|
@@ -151,7 +151,7 @@ export function registerReadTools(server, { db, searchService, wikiDir, transcri
|
|
|
151
151
|
'get_stats',
|
|
152
152
|
{
|
|
153
153
|
description:
|
|
154
|
-
'
|
|
154
|
+
'一个当前快照:按 todo 状态分布、今日截止、本周完成、最近 7 天活跃度、归档数量。轻量实时计算。',
|
|
155
155
|
inputSchema: {},
|
|
156
156
|
},
|
|
157
157
|
async () => {
|
|
@@ -162,15 +162,15 @@ export function registerReadTools(server, { db, searchService, wikiDir, transcri
|
|
|
162
162
|
const all = db.listTodos({ archived: 'all' })
|
|
163
163
|
const open = all.filter((t) => t.status !== 'done' && t.status !== 'missed' && t.archivedAt == null)
|
|
164
164
|
const archivedCount = all.filter((t) => t.archivedAt != null).length
|
|
165
|
-
const
|
|
166
|
-
for (const t of open)
|
|
165
|
+
const byStatus = {}
|
|
166
|
+
for (const t of open) byStatus[t.status] = (byStatus[t.status] || 0) + 1
|
|
167
167
|
const overdue = open.filter((t) => t.dueDate && t.dueDate < now)
|
|
168
168
|
const dueToday = open.filter((t) => t.dueDate && t.dueDate >= startOfDay.getTime() && t.dueDate < startOfDay.getTime() + 86_400_000)
|
|
169
169
|
const weekDone = db.listCompletedTodos({ since: startOfWeek.getTime(), until: now + 1 })
|
|
170
170
|
return asText({
|
|
171
171
|
openCount: open.length,
|
|
172
172
|
archivedCount,
|
|
173
|
-
|
|
173
|
+
byStatus,
|
|
174
174
|
overdue: { count: overdue.length, ids: overdue.slice(0, 10).map((t) => t.id) },
|
|
175
175
|
dueToday: { count: dueToday.length, ids: dueToday.map((t) => t.id) },
|
|
176
176
|
completedThisWeek: weekDone.length,
|
|
@@ -23,12 +23,14 @@ export function registerWriteTools(server, { db }) {
|
|
|
23
23
|
'create_todo',
|
|
24
24
|
{
|
|
25
25
|
description:
|
|
26
|
-
'新建一条 todo
|
|
26
|
+
'新建一条 todo。只需 title。' +
|
|
27
|
+
'(quadrant 字段已退役但接口仍接受以兼容旧调用方;传入会被忽略,DB 写入默认值。)',
|
|
27
28
|
inputSchema: {
|
|
28
29
|
title: z.string().min(1).describe('标题,必填'),
|
|
29
|
-
quadrant: z.number().int().min(1).max(4).
|
|
30
|
+
quadrant: z.number().int().min(1).max(4).optional()
|
|
31
|
+
.describe('【已退役】象限概念已移除,传入会被忽略'),
|
|
30
32
|
description: z.string().optional(),
|
|
31
|
-
parentId: z.string().optional().describe('
|
|
33
|
+
parentId: z.string().optional().describe('已退役:子任务概念已从 UI 移除;接口保留但不再分组展示'),
|
|
32
34
|
dueDate: z.number().int().optional().describe('截止时间戳(毫秒 epoch)'),
|
|
33
35
|
workDir: z.string().optional().describe('关联的代码仓路径'),
|
|
34
36
|
brainstorm: z.boolean().optional(),
|
|
@@ -39,7 +41,7 @@ export function registerWriteTools(server, { db }) {
|
|
|
39
41
|
if (!args.title?.trim()) return asError('title_required')
|
|
40
42
|
const created = db.createTodo({
|
|
41
43
|
title: args.title.trim(),
|
|
42
|
-
quadrant
|
|
44
|
+
// quadrant 不再透传 —— 让 db 层用默认值
|
|
43
45
|
description: args.description || '',
|
|
44
46
|
parentId: args.parentId,
|
|
45
47
|
dueDate: args.dueDate,
|
|
@@ -63,7 +65,8 @@ export function registerWriteTools(server, { db }) {
|
|
|
63
65
|
id: z.string().min(1),
|
|
64
66
|
title: z.string().optional(),
|
|
65
67
|
description: z.string().optional(),
|
|
66
|
-
quadrant: z.number().int().min(1).max(4).optional()
|
|
68
|
+
quadrant: z.number().int().min(1).max(4).optional()
|
|
69
|
+
.describe('【已退役】象限概念已移除,传入会被忽略'),
|
|
67
70
|
dueDate: z.number().int().nullable().optional().describe('传 null 显式清除'),
|
|
68
71
|
workDir: z.string().nullable().optional(),
|
|
69
72
|
parentId: z.string().nullable().optional().describe('传 null 升为顶层 todo'),
|
|
@@ -71,7 +74,7 @@ export function registerWriteTools(server, { db }) {
|
|
|
71
74
|
},
|
|
72
75
|
async (args) => {
|
|
73
76
|
try {
|
|
74
|
-
const { id, ...patch } = args
|
|
77
|
+
const { id, quadrant: _ignored, ...patch } = args
|
|
75
78
|
if (!id) return asError('id_required')
|
|
76
79
|
// 禁止通过此工具改 status,status 变更有专用工具(complete/archive)
|
|
77
80
|
const cleanPatch = {}
|
package/src/openclaw-bridge.js
CHANGED
|
@@ -132,6 +132,7 @@ export function createOpenClawBridge({
|
|
|
132
132
|
telegramSender = sendViaTelegramAPI, // 测试用:可 mock fake fetch
|
|
133
133
|
telegramBot: initialTelegramBot = null, // 可选:用于 sendDocument 附件
|
|
134
134
|
larkBot: initialLarkBot = null,
|
|
135
|
+
getRoutesForSession = null, // 注入:读 db 双路由(telegram + lark),broadcastEcho 用
|
|
135
136
|
} = {}) {
|
|
136
137
|
let telegramBot = initialTelegramBot
|
|
137
138
|
let larkBot = initialLarkBot
|
|
@@ -140,7 +141,8 @@ export function createOpenClawBridge({
|
|
|
140
141
|
|
|
141
142
|
// 出站限流环形缓冲:每分钟 ≤ rateLimitPerMin 条
|
|
142
143
|
const sendTimestamps = []
|
|
143
|
-
// sessionId →
|
|
144
|
+
// sessionId → Map<channel, route> —— 一个 session 可同时绑 telegram + lark + weixin
|
|
145
|
+
// Map 保留插入顺序,"最新注册的那条"可以靠遍历最后一个值拿到,用于无 channel hint 的 fallback。
|
|
144
146
|
const sessionRoutes = new Map()
|
|
145
147
|
// peerUserId → { sessionId, sentAt } — 最近一次推到该 peer 的 session
|
|
146
148
|
// 用于 PTY stdin proxy:用户在微信回话时知道往哪个 PTY 写
|
|
@@ -168,18 +170,38 @@ export function createOpenClawBridge({
|
|
|
168
170
|
sendTimestamps.push(nowMs())
|
|
169
171
|
}
|
|
170
172
|
|
|
173
|
+
function getRoutesInner(sessionId) {
|
|
174
|
+
return sessionRoutes.get(sessionId) || null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function ensureRoutesInner(sessionId) {
|
|
178
|
+
let inner = sessionRoutes.get(sessionId)
|
|
179
|
+
if (!inner) {
|
|
180
|
+
inner = new Map()
|
|
181
|
+
sessionRoutes.set(sessionId, inner)
|
|
182
|
+
}
|
|
183
|
+
return inner
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function allRoutesForSession(sessionId) {
|
|
187
|
+
const inner = getRoutesInner(sessionId)
|
|
188
|
+
return inner ? Array.from(inner.values()) : []
|
|
189
|
+
}
|
|
190
|
+
|
|
171
191
|
function registerSessionRoute(sessionId, { targetUserId, account, channel, threadId, rootMessageId, topicName, triggerMessageId, messageAppLink } = {}) {
|
|
172
192
|
if (!sessionId || !targetUserId) return
|
|
173
|
-
|
|
193
|
+
const effectiveChannel = channel || getOpenClawConfig().channel || 'openclaw-weixin'
|
|
194
|
+
const route = {
|
|
174
195
|
targetUserId,
|
|
175
196
|
account: account || null,
|
|
176
|
-
channel:
|
|
177
|
-
threadId: threadId != null ? threadId : null,
|
|
197
|
+
channel: effectiveChannel,
|
|
198
|
+
threadId: threadId != null ? threadId : null,
|
|
178
199
|
rootMessageId: rootMessageId || null,
|
|
179
|
-
topicName: topicName || null,
|
|
180
|
-
triggerMessageId: triggerMessageId != null ? triggerMessageId : null,
|
|
200
|
+
topicName: topicName || null,
|
|
201
|
+
triggerMessageId: triggerMessageId != null ? triggerMessageId : null,
|
|
181
202
|
messageAppLink: messageAppLink || null,
|
|
182
|
-
}
|
|
203
|
+
}
|
|
204
|
+
ensureRoutesInner(sessionId).set(effectiveChannel, route)
|
|
183
205
|
}
|
|
184
206
|
|
|
185
207
|
function clearSessionRoute(sessionId, reason = 'unknown') {
|
|
@@ -190,12 +212,20 @@ export function createOpenClawBridge({
|
|
|
190
212
|
}
|
|
191
213
|
|
|
192
214
|
function hasExplicitRoute(sessionId) {
|
|
193
|
-
|
|
215
|
+
if (!sessionId) return false
|
|
216
|
+
const inner = sessionRoutes.get(sessionId)
|
|
217
|
+
return Boolean(inner && inner.size > 0)
|
|
194
218
|
}
|
|
195
219
|
|
|
196
|
-
function resolveRoute(sessionId) {
|
|
197
|
-
const
|
|
198
|
-
if (
|
|
220
|
+
function resolveRoute(sessionId, channel = null) {
|
|
221
|
+
const inner = getRoutesInner(sessionId)
|
|
222
|
+
if (inner && inner.size > 0) {
|
|
223
|
+
if (channel) return inner.get(channel) || null
|
|
224
|
+
// 无 channel hint:返回最新注册的(Map 保留插入顺序,最后一个 value 是 latest)
|
|
225
|
+
let last = null
|
|
226
|
+
for (const r of inner.values()) last = r
|
|
227
|
+
return last
|
|
228
|
+
}
|
|
199
229
|
const oc = getOpenClawConfig()
|
|
200
230
|
if (!oc.targetUserId) return null
|
|
201
231
|
return {
|
|
@@ -219,7 +249,7 @@ export function createOpenClawBridge({
|
|
|
219
249
|
if (!rateLimitOk()) return { ok: false, reason: 'rate_limited' }
|
|
220
250
|
|
|
221
251
|
const oc = getOpenClawConfig()
|
|
222
|
-
const route = sessionId ? resolveRoute(sessionId) : null
|
|
252
|
+
const route = sessionId ? resolveRoute(sessionId, channel || null) : null
|
|
223
253
|
const effectiveChannel = channel || route?.channel || oc.channel || 'openclaw-weixin'
|
|
224
254
|
const rawTarget = target || route?.targetUserId || oc.targetUserId
|
|
225
255
|
const effectiveTarget = normalizeTarget(rawTarget, effectiveChannel)
|
|
@@ -269,8 +299,8 @@ export function createOpenClawBridge({
|
|
|
269
299
|
const threadIdForSend = route?.threadId || null
|
|
270
300
|
// 防御兜底:sessionId-routed 但没拿到 thread → fallback 路径,不能静默落 General。
|
|
271
301
|
// 仅当 caller 传了 sessionId 时启用(无 sessionId 是显式 broadcast,允许直发默认 chat)。
|
|
272
|
-
if (!threadIdForSend && sessionId && !
|
|
273
|
-
logger.warn?.(`[openclaw-bridge] refuse send to telegram general: sid=${sessionId} has no registered route (routesSize=${sessionRoutes.size}); would have leaked to General. msgLen=${message.length}`)
|
|
302
|
+
if (!threadIdForSend && sessionId && !hasExplicitRoute(sessionId)) {
|
|
303
|
+
logger.warn?.(`[openclaw-bridge] refuse send to telegram general: sid=${sessionId} has no registered route (routesSize=${sessionRoutes.size}); would have leaked to General. msgLen=${message.length}`) // sessionRoutes.size = outer Map size (number of sessions)
|
|
274
304
|
return { ok: false, reason: 'no_thread_id_route_missing' }
|
|
275
305
|
}
|
|
276
306
|
logger.info?.(`[openclaw-bridge] telegram send sessionId=${sessionId} chatId=${effectiveTarget} threadId=${threadIdForSend} (route=${route ? JSON.stringify({tid: route.threadId, tn: route.topicName}) : 'null'}) attachment=${attachment ? 'yes' : 'no'} msgLen=${message.length}`)
|
|
@@ -445,12 +475,16 @@ export function createOpenClawBridge({
|
|
|
445
475
|
function findSessionsByTarget(peer) {
|
|
446
476
|
if (!peer) return []
|
|
447
477
|
const out = []
|
|
448
|
-
for (const [sid,
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
478
|
+
for (const [sid, inner] of sessionRoutes) {
|
|
479
|
+
let matched = false
|
|
480
|
+
for (const info of inner.values()) {
|
|
481
|
+
const tgt = info?.targetUserId || ''
|
|
482
|
+
if (tgt === peer || tgt.startsWith(peer + '@') || peer.startsWith(tgt + '@')) {
|
|
483
|
+
matched = true
|
|
484
|
+
break
|
|
485
|
+
}
|
|
453
486
|
}
|
|
487
|
+
if (matched) out.push(sid)
|
|
454
488
|
}
|
|
455
489
|
return out
|
|
456
490
|
}
|
|
@@ -479,15 +513,17 @@ export function createOpenClawBridge({
|
|
|
479
513
|
function findSessionByRoute({ channel = null, chatId, threadId = null, rootMessageId = null } = {}) {
|
|
480
514
|
if (!chatId) return null
|
|
481
515
|
const targetStr = String(chatId)
|
|
482
|
-
for (const [sid,
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
if (
|
|
487
|
-
|
|
516
|
+
for (const [sid, inner] of sessionRoutes) {
|
|
517
|
+
for (const info of inner.values()) {
|
|
518
|
+
if (channel && info?.channel !== channel) continue
|
|
519
|
+
if (String(info?.targetUserId || '') !== targetStr) continue
|
|
520
|
+
if (rootMessageId) {
|
|
521
|
+
if (info?.rootMessageId === rootMessageId) return sid
|
|
522
|
+
continue
|
|
523
|
+
}
|
|
524
|
+
if ((info?.threadId || null) !== (threadId || null)) continue
|
|
525
|
+
return sid
|
|
488
526
|
}
|
|
489
|
-
if ((info?.threadId || null) !== (threadId || null)) continue
|
|
490
|
-
return sid
|
|
491
527
|
}
|
|
492
528
|
return null
|
|
493
529
|
}
|
|
@@ -535,15 +571,127 @@ export function createOpenClawBridge({
|
|
|
535
571
|
}
|
|
536
572
|
}
|
|
537
573
|
|
|
574
|
+
/**
|
|
575
|
+
* 把 user prompt echo 到所有已绑定的 IM thread,排除 origin channel。
|
|
576
|
+
* 路由从注入的 getRoutesForSession 拿(读 db 双路由),不依赖 in-memory sessionRoutes
|
|
577
|
+
* (后者每 session 只有一条 route,无法跨 telegram + lark 同时发)。
|
|
578
|
+
*
|
|
579
|
+
* 失败一律静默 warn —— echo 是辅助路径,不能影响 agent 的 Stop hook 主流程。
|
|
580
|
+
*/
|
|
581
|
+
async function broadcastEcho({ sessionId, message, excludeChannel } = {}) {
|
|
582
|
+
if (!sessionId || !message) return { skipped: true, reason: 'missing_args' }
|
|
583
|
+
if (typeof getRoutesForSession !== 'function') return { skipped: true, reason: 'no_routes_fn' }
|
|
584
|
+
if (!rateLimitOk()) return { skipped: true, reason: 'rate_limited' }
|
|
585
|
+
|
|
586
|
+
let routes
|
|
587
|
+
try {
|
|
588
|
+
routes = getRoutesForSession(sessionId) || {}
|
|
589
|
+
} catch (e) {
|
|
590
|
+
logger?.warn?.(`[openclaw-bridge] broadcastEcho getRoutesForSession threw: ${e.message}`)
|
|
591
|
+
return { skipped: true, reason: 'routes_lookup_failed' }
|
|
592
|
+
}
|
|
593
|
+
const { telegram: tg, lark: lk } = routes
|
|
594
|
+
const results = { telegram: null, lark: null }
|
|
595
|
+
|
|
596
|
+
if (excludeChannel === 'telegram') {
|
|
597
|
+
results.telegram = { skipped: true, reason: 'excluded' }
|
|
598
|
+
} else if (!tg?.threadId || !tg?.targetUserId) {
|
|
599
|
+
results.telegram = { skipped: true, reason: 'no_route' }
|
|
600
|
+
} else {
|
|
601
|
+
const token = getTelegramTokenFromConfig(getConfig())
|
|
602
|
+
if (!token) {
|
|
603
|
+
results.telegram = { ok: false, reason: 'no_token' }
|
|
604
|
+
} else {
|
|
605
|
+
try {
|
|
606
|
+
results.telegram = await telegramSender({
|
|
607
|
+
token,
|
|
608
|
+
chatId: String(tg.targetUserId),
|
|
609
|
+
threadId: Number(tg.threadId),
|
|
610
|
+
text: message,
|
|
611
|
+
logger,
|
|
612
|
+
})
|
|
613
|
+
if (results.telegram?.ok) recordSend()
|
|
614
|
+
} catch (e) {
|
|
615
|
+
logger?.warn?.(`[openclaw-bridge] broadcastEcho telegram threw: ${e.message}`)
|
|
616
|
+
results.telegram = { ok: false, reason: 'threw', detail: e.message }
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (excludeChannel === 'lark') {
|
|
622
|
+
results.lark = { skipped: true, reason: 'excluded' }
|
|
623
|
+
} else if (!lk?.rootMessageId || !larkBot?.replyInThread) {
|
|
624
|
+
results.lark = { skipped: true, reason: 'no_route' }
|
|
625
|
+
} else {
|
|
626
|
+
try {
|
|
627
|
+
results.lark = await larkBot.replyInThread({
|
|
628
|
+
rootMessageId: String(lk.rootMessageId),
|
|
629
|
+
text: message,
|
|
630
|
+
})
|
|
631
|
+
if (results.lark?.ok) recordSend()
|
|
632
|
+
} catch (e) {
|
|
633
|
+
logger?.warn?.(`[openclaw-bridge] broadcastEcho lark threw: ${e.message}`)
|
|
634
|
+
results.lark = { ok: false, reason: 'threw', detail: e.message }
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return results
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Fan-out 文本到 sessionId 当前所有绑定的 channel。Stop hook / ask_user / dispatcher 警告类
|
|
643
|
+
* 消息走这里,确保 telegram + lark 都能看到(cross-channel mirror 对齐)。
|
|
644
|
+
*
|
|
645
|
+
* 实现:对每个绑定的 channel 调用一次 postText(显式带 channel),复用现有的限流、
|
|
646
|
+
* fast-path、CLI fallback。返回 byChannel 聚合结果。
|
|
647
|
+
*/
|
|
648
|
+
async function broadcastText({ sessionId, message, replyMarkup = null, attachment = null, excludeChannel = null } = {}) {
|
|
649
|
+
if (!sessionId || !message) return { skipped: true, reason: 'missing_args' }
|
|
650
|
+
const routes = allRoutesForSession(sessionId)
|
|
651
|
+
if (!routes.length) {
|
|
652
|
+
// session 没有任何 in-memory 路由 → 退回单 postText(用 config 默认 target)。
|
|
653
|
+
// 行为对齐改造前的 postText({sessionId, message}) 老语义。
|
|
654
|
+
const r = await postText({ sessionId, message, replyMarkup, attachment })
|
|
655
|
+
return { ok: !!r?.ok, byChannel: { default: r }, fanout: false }
|
|
656
|
+
}
|
|
657
|
+
const byChannel = {}
|
|
658
|
+
let anyOk = false
|
|
659
|
+
for (const route of routes) {
|
|
660
|
+
if (route.channel === excludeChannel) {
|
|
661
|
+
byChannel[route.channel] = { skipped: true, reason: 'excluded' }
|
|
662
|
+
continue
|
|
663
|
+
}
|
|
664
|
+
const r = await postText({
|
|
665
|
+
sessionId,
|
|
666
|
+
message,
|
|
667
|
+
channel: route.channel,
|
|
668
|
+
target: route.targetUserId,
|
|
669
|
+
replyMarkup,
|
|
670
|
+
attachment,
|
|
671
|
+
})
|
|
672
|
+
byChannel[route.channel] = r
|
|
673
|
+
if (r?.ok) anyOk = true
|
|
674
|
+
}
|
|
675
|
+
return { ok: anyOk, byChannel, fanout: true }
|
|
676
|
+
}
|
|
677
|
+
|
|
538
678
|
function setTelegramBot(bot) { telegramBot = bot }
|
|
539
679
|
function setLarkBot(bot) { larkBot = bot || null }
|
|
540
680
|
function setTopicGoneHandler(fn) { topicGoneHandler = typeof fn === 'function' ? fn : null }
|
|
541
681
|
function listSessionRoutes() {
|
|
542
|
-
|
|
682
|
+
const out = []
|
|
683
|
+
for (const [sid, inner] of sessionRoutes) {
|
|
684
|
+
for (const route of inner.values()) {
|
|
685
|
+
out.push({ sessionId: sid, ...route })
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return out
|
|
543
689
|
}
|
|
544
690
|
|
|
545
691
|
return {
|
|
546
692
|
postText,
|
|
693
|
+
broadcastText,
|
|
694
|
+
broadcastEcho,
|
|
547
695
|
healthCheck,
|
|
548
696
|
isEnabled,
|
|
549
697
|
registerSessionRoute,
|
|
@@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
23
23
|
import { DEFAULT_ROOT_DIR } from './config.js'
|
|
24
24
|
|
|
25
25
|
const QUADTODO_MANAGED_KEY = '_quadtodoManaged'
|
|
26
|
-
const HOOK_EVENTS = ['Stop', 'Notification', 'SessionEnd']
|
|
26
|
+
const HOOK_EVENTS = ['Stop', 'Notification', 'SessionEnd', 'UserPromptSubmit']
|
|
27
27
|
const HOOK_VERSION_RE = /quadtodo-hook-version:\s*(\d+)/
|
|
28
28
|
|
|
29
29
|
function defaultHookScriptPath() {
|
|
@@ -52,6 +52,7 @@ function buildHookEntry(event, hookScriptPath) {
|
|
|
52
52
|
// Claude Code hook 格式(参考其文档):matchers 数组里每项有 type+command
|
|
53
53
|
const eventLower = event === 'SessionEnd' ? 'session-end'
|
|
54
54
|
: event === 'Notification' ? 'notification'
|
|
55
|
+
: event === 'UserPromptSubmit' ? 'user-prompt-submit'
|
|
55
56
|
: 'stop'
|
|
56
57
|
return {
|
|
57
58
|
matcher: '*',
|