agentquad 0.4.5 → 0.4.7
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/dist-web/assets/{index-By--XlP3.js → index-BEiPvgk7.js} +237 -235
- package/dist-web/assets/index-qY2UiOW2.css +32 -0
- package/dist-web/index.html +2 -2
- package/package.json +7 -1
- package/src/claude-prompt-detector.js +72 -0
- 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 +53 -31
- package/src/lark-bot.js +44 -5
- package/src/mcp/tools/openclaw/index.js +1 -1
- 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 +119 -24
- package/src/permission-prompt.js +113 -31
- package/src/pty.js +183 -49
- package/src/routes/ai-terminal.js +75 -26
- package/src/routes/telegram-sync.js +7 -5
- package/src/server.js +90 -12
- package/src/session-input-dispatcher.js +48 -4
- 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/dist-web/assets/index-8A0oLLcX.css +0 -32
package/src/openclaw-hook.js
CHANGED
|
@@ -83,6 +83,9 @@ const BOX_HORIZONTAL = /[─━┄┅┈┉═]/g
|
|
|
83
83
|
const BOX_VERTICAL = /[│┃┆┇┊┋║]/g
|
|
84
84
|
const BOX_CORNERS = /[┌┍┎┏┐┑┒┓└┕┖┗┘┙┚┛┌┐└┘╭╮╯╰╓╒╕╖╙╘╛╜╔╗╚╝]/g
|
|
85
85
|
const BOX_TEES = /[├┝┞┟┠┡┢┣┤┥┦┧┨┩┪┫┬┭┮┯┰┱┲┳┴┵┶┷┸┹┺┻┼┽┾┿╀╁╂╃╄╅╆╇╈╉╊╋╠╣╦╩╬]/g
|
|
86
|
+
// Unicode Block Elements (U+2580-259F):▀▁▂▃▄▅▆▇█ ▉▊▋▌▍▎▏ ▐░▒▓▔▕▖▗▘▙▚▛▜▝▞▟
|
|
87
|
+
// Cursor TUI 用这些画状态栏 / 进度条 / 边框,发到 IM 一串看就是黑条。
|
|
88
|
+
const BOX_BLOCK = /[▀-▟]/g
|
|
86
89
|
|
|
87
90
|
function cleanBoxDrawing(s) {
|
|
88
91
|
return String(s || '')
|
|
@@ -90,6 +93,7 @@ function cleanBoxDrawing(s) {
|
|
|
90
93
|
.replace(BOX_VERTICAL, '|') // 竖线 → |
|
|
91
94
|
.replace(BOX_CORNERS, '+') // 角 → +
|
|
92
95
|
.replace(BOX_TEES, '+') // 三叉 → +
|
|
96
|
+
.replace(BOX_BLOCK, '') // 块元素直接删(连成一片就是黑条,没语义)
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
function compactBlankLines(s) {
|
|
@@ -114,6 +118,15 @@ const PROMPT_LINE = /^\s*(❯|⏵|►|→)/
|
|
|
114
118
|
const AUTO_MODE_LINE = /(auto mode (on|off)|shift\+tab to cycle|ctrl\+[a-z]\b)/i
|
|
115
119
|
const BORDER_LINE = /^[\s\-=_|+~]+$/
|
|
116
120
|
|
|
121
|
+
// Cursor TUI 底部状态栏噪声 —— 每次 stop hook 触发时这些行都会出现在 PTY tail,
|
|
122
|
+
// 完全没信息量,发到 IM 是纯刷屏。
|
|
123
|
+
// "Opus 4.6 (Thinking) 200K High · 23.6%" → 模型选择器 + context %
|
|
124
|
+
// "Auto-run" / "Manual" → 模式指示
|
|
125
|
+
// "~/Desktop/code/crazyCombo/quadtodo · main" → cwd · git branch
|
|
126
|
+
const CURSOR_MODEL_LINE = /^\s*(Opus|Sonnet|Haiku|GPT|Claude|Codex|Composer)\s+[\w.-]+.*·\s*[\d.]+%\s*$/i
|
|
127
|
+
const CURSOR_AUTORUN_LINE = /^\s*(Auto-run|Manual|Auto)\s*$/i
|
|
128
|
+
const CURSOR_CWD_BRANCH_LINE = /^\s*~?\/[^\s·]+(?:\/[^\s·]+)*\s+·\s+[\w./-]+\s*$/
|
|
129
|
+
|
|
117
130
|
function isSpinnerOnly(line) {
|
|
118
131
|
// 全部是 spinner 字符(含空格)
|
|
119
132
|
const trimmed = line.replace(/\s+/g, '')
|
|
@@ -130,6 +143,9 @@ function isStatusLine(line) {
|
|
|
130
143
|
if (PROMPT_LINE.test(line)) return true
|
|
131
144
|
if (AUTO_MODE_LINE.test(line)) return true
|
|
132
145
|
if (BORDER_LINE.test(line)) return true
|
|
146
|
+
if (CURSOR_MODEL_LINE.test(line)) return true
|
|
147
|
+
if (CURSOR_AUTORUN_LINE.test(line)) return true
|
|
148
|
+
if (CURSOR_CWD_BRANCH_LINE.test(line)) return true
|
|
133
149
|
return false
|
|
134
150
|
}
|
|
135
151
|
|
|
@@ -444,6 +460,7 @@ export function createOpenClawHookHandler(deps = {}) {
|
|
|
444
460
|
* 处理一条 hook 事件 —— 统一入口,按 source/path 分发。
|
|
445
461
|
* - source=codex,path=jsonl → handleCodexJsonl(Phase C)
|
|
446
462
|
* - source=codex,path=detector → handleCodexDetector(Phase E 占位,目前直接拒绝)
|
|
463
|
+
* - source=claude,path=detector→ handleClaudeDetector(PTY 兜底 Notification hook 不 fire)
|
|
447
464
|
* - 其它(默认 claude) → handleClaude(保留原逻辑,签名不变)
|
|
448
465
|
* 返回 { ok, action: 'sent'|'skipped'|'failed', reason? }
|
|
449
466
|
*/
|
|
@@ -451,6 +468,7 @@ export function createOpenClawHookHandler(deps = {}) {
|
|
|
451
468
|
const source = req?.source || 'claude'
|
|
452
469
|
if (source === 'codex' && req?.path === 'jsonl') return handleCodexJsonl(req)
|
|
453
470
|
if (source === 'codex' && req?.path === 'detector') return handleCodexDetector(req)
|
|
471
|
+
if (source === 'claude' && req?.path === 'detector') return handleClaudeDetector(req)
|
|
454
472
|
return handleClaude(req)
|
|
455
473
|
}
|
|
456
474
|
|
|
@@ -566,12 +584,12 @@ export function createOpenClawHookHandler(deps = {}) {
|
|
|
566
584
|
// bridge.postText 的形参是 `message`,不是 `text`——早期实现写错字段名,
|
|
567
585
|
// 导致 bridge 收到 message=undefined 直接走 message_required 短路返回。
|
|
568
586
|
try {
|
|
569
|
-
const r = await codexBridge.
|
|
587
|
+
const r = await codexBridge.broadcastText({ sessionId: quadtodoSessionId, message: fullText })
|
|
570
588
|
if (!r?.ok) {
|
|
571
|
-
logger.warn?.(`[codex-hook]
|
|
589
|
+
logger.warn?.(`[codex-hook] broadcastText returned not-ok: reason=${r?.reason} detail=${r?.detail || ''}`)
|
|
572
590
|
return { ok: false, reason: 'post_failed', detail: r?.reason }
|
|
573
591
|
}
|
|
574
|
-
logger.info?.(`[codex-hook]
|
|
592
|
+
logger.info?.(`[codex-hook] broadcastText OK sessionId=${quadtodoSessionId} event=${event} len=${fullText.length}`)
|
|
575
593
|
} catch (e) {
|
|
576
594
|
logger.warn?.(`[codex-hook] postText threw: ${e.message}`)
|
|
577
595
|
return { ok: false, reason: 'post_failed', detail: e?.message }
|
|
@@ -628,6 +646,61 @@ export function createOpenClawHookHandler(deps = {}) {
|
|
|
628
646
|
return { ok: true, action: 'sent', source: 'codex', event, nativeId, matchedPattern }
|
|
629
647
|
}
|
|
630
648
|
|
|
649
|
+
// ─── Claude PTY-detector 分支 ────────────────────────────────────────────────
|
|
650
|
+
// Notification hook 不 fire 的兜底(实测 permissions.defaultMode='auto' 时 model
|
|
651
|
+
// classifier 触发的权限框走不到 hook)。两件事:
|
|
652
|
+
// 1) 翻 session.status → pending_confirm(web 端 deriveAiState 据此显示"待确认")
|
|
653
|
+
// 2) 推 IM 权限卡 / 按钮(跟真 Notification 走同一份 cooldown,避免双推)
|
|
654
|
+
// 与 handleCodexDetector 同构,但走 broadcastText(保持跟 Claude 真 Notification 一致),
|
|
655
|
+
// header 标题区别成 "Claude Code 等待你的响应"。
|
|
656
|
+
async function handleClaudeDetector({ event, sessionId, promptText } = {}) {
|
|
657
|
+
if (!sessionId) return { ok: false, reason: 'no_sessionId' }
|
|
658
|
+
const sess = aiTerminal?.sessions?.get(sessionId)
|
|
659
|
+
if (!sess) return { ok: false, reason: 'session_gone' }
|
|
660
|
+
|
|
661
|
+
// 1) 翻状态。markPendingConfirm 默认只接受 running → pending_confirm,但 PTY-detector
|
|
662
|
+
// 要求 anchor + ≥2 数字选项才 emit,假阳性概率极低;显式 allowIdleFlip=true 让它
|
|
663
|
+
// 在 status=idle 时也能翻(覆盖 auto 模式权限框在 Stop hook 后才浮出的场景)。
|
|
664
|
+
try {
|
|
665
|
+
aiTerminal?.markPendingConfirm?.(sessionId, {
|
|
666
|
+
source: 'claude-pty-detector',
|
|
667
|
+
promptText,
|
|
668
|
+
allowIdleFlip: true,
|
|
669
|
+
})
|
|
670
|
+
} catch { /* ignore */ }
|
|
671
|
+
|
|
672
|
+
// 2) IM 推送资格 & cooldown 跟真 Notification 共享 → 不会双推
|
|
673
|
+
if (!isPermissionReminderEligible(sessionId)) {
|
|
674
|
+
return { ok: true, action: 'skipped', reason: 'im_push_not_eligible' }
|
|
675
|
+
}
|
|
676
|
+
const cd = notificationCooldownMs()
|
|
677
|
+
if (cd > 0 && isOnCooldown(sessionId, 'notification', cd)) {
|
|
678
|
+
return { ok: true, action: 'skipped', reason: 'notification_cooldown', cooldownMs: cd }
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// 3) 拼消息:直接用 PTY detector 抽出来的 promptText(已经过 anchor + cleanPtyTail 清洗)
|
|
682
|
+
const todoId = sess.todoId
|
|
683
|
+
let todoTitle = todoId
|
|
684
|
+
try {
|
|
685
|
+
const todo = await db.getTodo?.(todoId)
|
|
686
|
+
todoTitle = todo?.title || todoId
|
|
687
|
+
} catch { /* ignore */ }
|
|
688
|
+
const idTail = todoId ? String(todoId).slice(-3) : '???'
|
|
689
|
+
let message = `[#t${idTail}] 任务「${todoTitle}」\n\n${promptText || '(no prompt text)'}`
|
|
690
|
+
message = buildPermissionNotificationMessage(message)
|
|
691
|
+
const replyMarkup = buildPermissionReplyMarkup(sessionId)
|
|
692
|
+
|
|
693
|
+
let result
|
|
694
|
+
try {
|
|
695
|
+
result = await openclaw.broadcastText({ sessionId, message, replyMarkup })
|
|
696
|
+
} catch (e) {
|
|
697
|
+
logger.warn?.(`[claude-detector] broadcastText failed: ${e.message}`)
|
|
698
|
+
return { ok: false, reason: 'post_failed', detail: e?.message }
|
|
699
|
+
}
|
|
700
|
+
if (result?.ok !== false) recordSent(sessionId, 'notification')
|
|
701
|
+
return { ok: true, action: 'sent', source: 'claude', path: 'detector', event }
|
|
702
|
+
}
|
|
703
|
+
|
|
631
704
|
// ─── Claude 分支(既有实现,原 handle() 主体不变)─────────────────────────────
|
|
632
705
|
async function handleClaude({ event, sessionId, todoId, todoTitle, hookPayload } = {}) {
|
|
633
706
|
if (!event) return { ok: false, action: 'failed', reason: 'event_required' }
|
|
@@ -642,11 +715,56 @@ export function createOpenClawHookHandler(deps = {}) {
|
|
|
642
715
|
logger.warn?.(`[openclaw-hook] hook fired with no registered route: event=${evt} sid=${sessionId} todoId=${todoId || 'null'}`)
|
|
643
716
|
}
|
|
644
717
|
|
|
718
|
+
// 0) user-prompt-submit → echo user's prompt to all IM channels (minus origin)
|
|
719
|
+
if (evt === 'user-prompt-submit') {
|
|
720
|
+
if (!sessionId) return { ok: true, action: 'skipped', reason: 'no_session' }
|
|
721
|
+
const promptRaw =
|
|
722
|
+
(hookPayload && typeof hookPayload === 'object' && (
|
|
723
|
+
hookPayload.user_prompt ||
|
|
724
|
+
hookPayload.prompt ||
|
|
725
|
+
hookPayload.user_message ||
|
|
726
|
+
hookPayload.message
|
|
727
|
+
)) || ''
|
|
728
|
+
const prompt = String(promptRaw).trim()
|
|
729
|
+
if (!prompt) return { ok: true, action: 'skipped', reason: 'empty_prompt' }
|
|
730
|
+
|
|
731
|
+
// 截断:>2000 字符 → 取前 2000 + 末尾标注总字数
|
|
732
|
+
const MAX = 2000
|
|
733
|
+
const truncated = prompt.length > MAX
|
|
734
|
+
? `${prompt.slice(0, MAX)}\n… [共 ${prompt.length} 字]`
|
|
735
|
+
: prompt
|
|
736
|
+
const message = `👤 ${truncated}`
|
|
737
|
+
|
|
738
|
+
let originChannel = null
|
|
739
|
+
try {
|
|
740
|
+
originChannel = sessionInputDispatcher?.consumeOrigin?.(sessionId, prompt) || null
|
|
741
|
+
} catch (e) {
|
|
742
|
+
logger.warn?.(`[openclaw-hook] consumeOrigin threw: ${e.message}`)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
try {
|
|
746
|
+
await openclaw?.broadcastEcho?.({ sessionId, message, excludeChannel: originChannel })
|
|
747
|
+
} catch (e) {
|
|
748
|
+
logger.warn?.(`[openclaw-hook] broadcastEcho threw: ${e.message}`)
|
|
749
|
+
}
|
|
750
|
+
return { ok: true, action: 'echoed', origin: originChannel, length: prompt.length }
|
|
751
|
+
}
|
|
752
|
+
|
|
645
753
|
// 1) ask_user pending 时 Stop 静默
|
|
646
754
|
if (evt === 'stop' && hasPendingAskUser(sessionId)) {
|
|
647
755
|
return { ok: true, action: 'skipped', reason: 'ask_user_pending' }
|
|
648
756
|
}
|
|
649
757
|
|
|
758
|
+
// 1a) Cursor 的 stop hook 每轮 fire 3 次(实测 13ms 内连发,cursor 内部架构使然)。
|
|
759
|
+
// 给 cursor session 加 5 秒短 cooldown 去重 —— 5s 足够吞掉同一轮内的连发,
|
|
760
|
+
// 又远小于用户两轮对话的最小间隔。Claude/Codex 不受影响(它们 stop 一轮一发)。
|
|
761
|
+
if (evt === 'stop' && sessionId) {
|
|
762
|
+
const sess = aiTerminal?.sessions?.get?.(sessionId)
|
|
763
|
+
if (sess?.tool === 'cursor' && isOnCooldown(sessionId, 'stop', 5_000)) {
|
|
764
|
+
return { ok: true, action: 'skipped', reason: 'cursor_stop_dedup' }
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
650
768
|
const permissionReminderEligible = evt === 'notification' && isPermissionReminderEligible(sessionId)
|
|
651
769
|
|
|
652
770
|
// 注:原来这里会立即 notifyWebTurnDone。已经挪到 ③ 读完 JSONL 之后,
|
|
@@ -833,8 +951,8 @@ export function createOpenClawHookHandler(deps = {}) {
|
|
|
833
951
|
// footer 永远附在最末尾(即使消息被截短到附件也要保留,让用户能看到费用)
|
|
834
952
|
if (usageFooter) message = `${message}\n\n${usageFooter}`
|
|
835
953
|
|
|
836
|
-
// 4) 推送(
|
|
837
|
-
const result = await openclaw.
|
|
954
|
+
// 4) 推送(broadcastText 扇出到 session 所有绑定 channel;接受可选 attachment)
|
|
955
|
+
const result = await openclaw.broadcastText({
|
|
838
956
|
sessionId,
|
|
839
957
|
message,
|
|
840
958
|
attachment: attachmentPath, // bridge 转给 telegramBot.sendDocument
|
|
@@ -843,7 +961,7 @@ export function createOpenClawHookHandler(deps = {}) {
|
|
|
843
961
|
|
|
844
962
|
// 5) SessionEnd 后处理:close topic + 改名 ✅ + 清状态
|
|
845
963
|
if (evt === 'session-end') {
|
|
846
|
-
const route = openclaw.resolveRoute?.(sessionId)
|
|
964
|
+
const route = openclaw.resolveRoute?.(sessionId, 'telegram')
|
|
847
965
|
if (route?.threadId && telegramBot) {
|
|
848
966
|
try {
|
|
849
967
|
await telegramBot.closeForumTopic({ chatId: route.targetUserId, threadId: route.threadId })
|
|
@@ -904,14 +1022,14 @@ export function createOpenClawHookHandler(deps = {}) {
|
|
|
904
1022
|
}
|
|
905
1023
|
// Stop / session-end → 清掉 lark "在思考" reaction(如果是 lark route)
|
|
906
1024
|
if ((evt === 'stop' || evt === 'session-end') && sessionId && larkBot?.clearReactionsForSession) {
|
|
907
|
-
const route = openclaw.resolveRoute?.(sessionId)
|
|
1025
|
+
const route = openclaw.resolveRoute?.(sessionId, 'lark')
|
|
908
1026
|
if (route?.channel === 'lark') {
|
|
909
1027
|
larkBot.clearReactionsForSession(sessionId).catch((e) => logger.warn?.(`[openclaw-hook] clearReactionsForSession failed: ${e.message}`))
|
|
910
1028
|
}
|
|
911
1029
|
}
|
|
912
1030
|
// Stop / session-end → 清掉 telegram "✍" reaction(如果是 telegram route)
|
|
913
1031
|
if ((evt === 'stop' || evt === 'session-end') && sessionId && reactionTracker?.clearReactionsForSession) {
|
|
914
|
-
const route = openclaw.resolveRoute?.(sessionId)
|
|
1032
|
+
const route = openclaw.resolveRoute?.(sessionId, 'telegram')
|
|
915
1033
|
if (route?.channel === 'telegram') {
|
|
916
1034
|
reactionTracker.clearReactionsForSession(sessionId).catch((e) => logger.warn?.(`[openclaw-hook] tg clearReactionsForSession failed: ${e.message}`))
|
|
917
1035
|
}
|
|
@@ -938,4 +1056,4 @@ export function createOpenClawHookHandler(deps = {}) {
|
|
|
938
1056
|
}
|
|
939
1057
|
}
|
|
940
1058
|
|
|
941
|
-
export const __test__ = { buildMessage, shortTodoId }
|
|
1059
|
+
export const __test__ = { buildMessage, shortTodoId, extractTailSnippet }
|
package/src/openclaw-wizard.js
CHANGED
|
@@ -821,9 +821,9 @@ export function createOpenClawWizard({
|
|
|
821
821
|
if (!sessionId || !todoId) return { ok: false, reason: 'missing_args' }
|
|
822
822
|
if (!telegramBot?.createForumTopic) return { ok: false, reason: 'no_telegram_bot' }
|
|
823
823
|
|
|
824
|
-
//
|
|
825
|
-
const existing = openclaw?.resolveRoute?.(sessionId)
|
|
826
|
-
if (existing
|
|
824
|
+
// 已有 telegram 路由 → 跳过(per-channel resolveRoute;避免被 lark 路由误判)
|
|
825
|
+
const existing = openclaw?.resolveRoute?.(sessionId, 'telegram')
|
|
826
|
+
if (existing?.threadId) return { ok: true, action: 'already_bound' }
|
|
827
827
|
|
|
828
828
|
// DB 里已持久化(rehydrate 时常见)→ 重注路由就行
|
|
829
829
|
const todo = db.getTodo(todoId)
|
|
@@ -896,8 +896,9 @@ export function createOpenClawWizard({
|
|
|
896
896
|
if (!sessionId || !todoId) return { ok: false, reason: 'missing_args' }
|
|
897
897
|
if (!larkBot?.sendMessage) return { ok: false, reason: 'no_lark_bot' }
|
|
898
898
|
|
|
899
|
-
|
|
900
|
-
|
|
899
|
+
// 显式取 lark 路由,避免被 telegram 路由误判
|
|
900
|
+
const existing = openclaw?.resolveRoute?.(sessionId, 'lark')
|
|
901
|
+
if (existing?.rootMessageId) {
|
|
901
902
|
return { ok: true, action: 'already_bound' }
|
|
902
903
|
}
|
|
903
904
|
|
|
@@ -916,7 +917,7 @@ export function createOpenClawWizard({
|
|
|
916
917
|
|
|
917
918
|
const shortCode = String(todoId).replace(/[^a-zA-Z0-9]/g, '').slice(-4).toLowerCase() || 'auto'
|
|
918
919
|
const title = (todo.title || `todo-${shortCode}`).slice(0, 96)
|
|
919
|
-
const topicName =
|
|
920
|
+
const topicName = title.slice(0, 128)
|
|
920
921
|
const intro = [
|
|
921
922
|
`${topicName}`,
|
|
922
923
|
`AI 已启动(自动镜像 from web/CLI),后续输出会回复在这条消息的 thread 里。`,
|
|
@@ -1015,7 +1016,7 @@ export function createOpenClawWizard({
|
|
|
1015
1016
|
const todo = db.getTodo(sess.todoId)
|
|
1016
1017
|
if (todo) {
|
|
1017
1018
|
// 构造一个假 aiSession(DB 里没持久化,能跑就行)
|
|
1018
|
-
const route = openclaw.resolveRoute?.(sid) || {}
|
|
1019
|
+
const route = openclaw.resolveRoute?.(sid, 'telegram') || {}
|
|
1019
1020
|
const fakeAi = {
|
|
1020
1021
|
sessionId: sid,
|
|
1021
1022
|
tool: sess.tool,
|
|
@@ -1168,7 +1169,7 @@ export function createOpenClawWizard({
|
|
|
1168
1169
|
if (sess?.todoId) {
|
|
1169
1170
|
const todo = db.getTodo(sess.todoId)
|
|
1170
1171
|
if (todo) {
|
|
1171
|
-
const route = openclaw.resolveRoute?.(sid) || {}
|
|
1172
|
+
const route = openclaw.resolveRoute?.(sid, 'lark') || {}
|
|
1172
1173
|
const fakeAi = {
|
|
1173
1174
|
sessionId: sid,
|
|
1174
1175
|
tool: sess.tool,
|
|
@@ -1272,6 +1273,16 @@ export function createOpenClawWizard({
|
|
|
1272
1273
|
const routeKey = makeRouteKey(channel, chatId, threadId)
|
|
1273
1274
|
const isLarkThreadReply = channel === 'lark' && (threadId || rootMessageId)
|
|
1274
1275
|
|
|
1276
|
+
// 飞书无前缀建任务守门:channel + 配置 + 文本 + slash 守门
|
|
1277
|
+
// 调用方需自行加 newTaskGateOpen + targetSid 缺失等额外条件。
|
|
1278
|
+
function shouldLarkAutoCreate() {
|
|
1279
|
+
if (channel !== 'lark') return false
|
|
1280
|
+
if (getConfig?.()?.lark?.autoCreateTodo === false) return false
|
|
1281
|
+
if (!trimmed) return false
|
|
1282
|
+
if (/^\/[a-z][a-z0-9_]*\b/i.test(trimmed)) return false
|
|
1283
|
+
return true
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1275
1286
|
// Lark 任务话题/root 回复必须严格隔离到原始路由:不允许被全局 ask_user、新任务触发词、
|
|
1276
1287
|
// lastPush 或单活跃 session 等模糊 fallback 消费,避免把群内任务线程回复送到不相关会话。
|
|
1277
1288
|
//
|
|
@@ -1565,6 +1576,36 @@ export function createOpenClawWizard({
|
|
|
1565
1576
|
}
|
|
1566
1577
|
}
|
|
1567
1578
|
if (targetSid && typeof targetSid === 'object' && targetSid.notFound) {
|
|
1579
|
+
// 未绑定 lark thread 的首条消息:默认起新建任务向导(受 autoCreateTodo 控制)
|
|
1580
|
+
if (newTaskGateOpen && shouldLarkAutoCreate()) {
|
|
1581
|
+
logger.info?.(`[wizard] lark auto-create from non-prefix text (unbound thread): chatId=${chatId} thread=${threadId || '-'} title="${trimmed.slice(0, 80)}"`)
|
|
1582
|
+
const w = startWizard({ channel, chatId, threadId, text: trimmed, messageId, rootMessageId, imagePaths, userId: fromUserId })
|
|
1583
|
+
if (w.step === STEP_DONE) return await finalizeWizard(w)
|
|
1584
|
+
if (w.step === STEP_QUADRANT) {
|
|
1585
|
+
const p = buildQuadrantPrompt()
|
|
1586
|
+
return {
|
|
1587
|
+
reply: `任务: ${w.title}\n(目录已识别为 ${w.chosenWorkdir})\n\n${p.text}`,
|
|
1588
|
+
replyMarkup: p.replyMarkup,
|
|
1589
|
+
action: 'wizard_started',
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
if (w.step === STEP_TEMPLATE) {
|
|
1593
|
+
const tpls = db.listTemplates()
|
|
1594
|
+
w.cachedTemplates = tpls
|
|
1595
|
+
const p = buildTemplatePrompt(tpls)
|
|
1596
|
+
return {
|
|
1597
|
+
reply: `任务: ${w.title}\n(目录+象限已识别)\n\n${p.text}`,
|
|
1598
|
+
replyMarkup: p.replyMarkup,
|
|
1599
|
+
action: 'wizard_started',
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
const p = buildWorkdirPrompt(w.workdirOptions)
|
|
1603
|
+
return {
|
|
1604
|
+
reply: `任务: ${w.title}\n\n${p.text}`,
|
|
1605
|
+
replyMarkup: p.replyMarkup,
|
|
1606
|
+
action: 'wizard_started',
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1568
1609
|
return {
|
|
1569
1610
|
reply: '没有找到对应运行中的任务',
|
|
1570
1611
|
action: 'session_not_found',
|
|
@@ -1718,6 +1759,38 @@ export function createOpenClawWizard({
|
|
|
1718
1759
|
}
|
|
1719
1760
|
}
|
|
1720
1761
|
|
|
1762
|
+
// 5.5 飞书无前缀建任务兜底:lark + autoCreateTodo + step 5 没匹配任何 PTY target →
|
|
1763
|
+
// 把消息原文当 title 起 wizard。Telegram/微信/openclaw 不受影响(channel 守门)。
|
|
1764
|
+
if (newTaskGateOpen && shouldLarkAutoCreate()) {
|
|
1765
|
+
logger.info?.(`[wizard] lark auto-create from non-prefix text: chatId=${chatId} thread=${threadId || '-'} title="${trimmed.slice(0, 80)}"`)
|
|
1766
|
+
const w = startWizard({ channel, chatId, threadId, text: trimmed, messageId, rootMessageId, imagePaths, userId: fromUserId })
|
|
1767
|
+
if (w.step === STEP_DONE) return await finalizeWizard(w)
|
|
1768
|
+
if (w.step === STEP_QUADRANT) {
|
|
1769
|
+
const p = buildQuadrantPrompt()
|
|
1770
|
+
return {
|
|
1771
|
+
reply: `任务: ${w.title}\n(目录已识别为 ${w.chosenWorkdir})\n\n${p.text}`,
|
|
1772
|
+
replyMarkup: p.replyMarkup,
|
|
1773
|
+
action: 'wizard_started',
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
if (w.step === STEP_TEMPLATE) {
|
|
1777
|
+
const tpls = db.listTemplates()
|
|
1778
|
+
w.cachedTemplates = tpls
|
|
1779
|
+
const p = buildTemplatePrompt(tpls)
|
|
1780
|
+
return {
|
|
1781
|
+
reply: `任务: ${w.title}\n(目录+象限已识别)\n\n${p.text}`,
|
|
1782
|
+
replyMarkup: p.replyMarkup,
|
|
1783
|
+
action: 'wizard_started',
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
const p = buildWorkdirPrompt(w.workdirOptions)
|
|
1787
|
+
return {
|
|
1788
|
+
reply: `任务: ${w.title}\n\n${p.text}`,
|
|
1789
|
+
replyMarkup: p.replyMarkup,
|
|
1790
|
+
action: 'wizard_started',
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1721
1794
|
// 6. fallback
|
|
1722
1795
|
// General 频道里专门提示:保护 PTY 上下文不被污染
|
|
1723
1796
|
if (isInGeneralOfSupergroup) {
|
|
@@ -1782,7 +1855,7 @@ export function createOpenClawWizard({
|
|
|
1782
1855
|
// ── 权限按钮路径(qt:perm:<short>:allow|deny)──────────────────
|
|
1783
1856
|
const permCb = parsePermissionCallback(callbackData)
|
|
1784
1857
|
if (permCb) {
|
|
1785
|
-
return handlePermissionCallback(permCb, { chatId, threadId })
|
|
1858
|
+
return handlePermissionCallback(permCb, { chatId, threadId, channel: args.channel || null })
|
|
1786
1859
|
}
|
|
1787
1860
|
if (callbackData.startsWith(`${CALLBACK_PREFIX}:${PERMISSION_CALLBACK_KIND}:`)) {
|
|
1788
1861
|
return { toast: '无效的权限按钮', action: 'invalid', editOriginal: true }
|
|
@@ -1899,30 +1972,51 @@ export function createOpenClawWizard({
|
|
|
1899
1972
|
}
|
|
1900
1973
|
|
|
1901
1974
|
// ─── 权限按钮回调 ───────────────────────────────────────────────
|
|
1902
|
-
async function handlePermissionCallback({ short, action } = {}, { chatId, threadId } = {}) {
|
|
1903
|
-
const stale = () =>
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1975
|
+
async function handlePermissionCallback({ short, action } = {}, { chatId, threadId, channel = null } = {}) {
|
|
1976
|
+
const stale = (why) => {
|
|
1977
|
+
logger.warn?.(`[wizard/perm] stale short=${short} action=${action} channel=${channel || 'null'} chatId=${chatId || 'null'} threadId=${threadId || 'null'} reason=${why}`)
|
|
1978
|
+
return {
|
|
1979
|
+
toast: '会话已结束',
|
|
1980
|
+
reply: `⚠️ 会话已结束(#${short}),无法发送权限选择。`,
|
|
1981
|
+
action: 'permission_session_stale',
|
|
1982
|
+
editOriginal: true,
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1909
1985
|
|
|
1910
1986
|
const sid = openclaw?.findSessionByShortId?.(short) || null
|
|
1911
|
-
if (!sid
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1987
|
+
if (!sid) return stale('no_sid_for_short')
|
|
1988
|
+
if (!pty?.has?.(sid)) return stale(`pty_no_session sid=${sid}`)
|
|
1989
|
+
// channel hint 关键:同一 session 经常同时绑 telegram + lark,resolveRoute(sid)
|
|
1990
|
+
// 不带 channel 时返回最后注册的 route(通常是 telegram),导致 lark 点击的 sameChat
|
|
1991
|
+
// 校验失败被误判为 stale。caller 已经知道点击来自哪个渠道,必须传过来。
|
|
1992
|
+
const route = openclaw?.resolveRoute?.(sid, channel) || null
|
|
1993
|
+
if (!route) return stale(`no_route sid=${sid} channel=${channel || 'null'}`)
|
|
1994
|
+
const sameChat = String(route.targetUserId) === String(chatId)
|
|
1995
|
+
// 飞书 card.action.trigger 事件实测经常不带 open_thread_id(即使卡片是在 thread
|
|
1996
|
+
// 里发的);用 sameThread 硬校验会把所有 lark click 卡成 stale。lark 渠道下
|
|
1997
|
+
// 信任 sameChat + shortid(已经锁到具体 session)就够了,threadId 只在 telegram
|
|
1998
|
+
// 渠道生效(topic 隔离才有意义)。
|
|
1999
|
+
const sameThread = route.channel === 'lark'
|
|
2000
|
+
? true
|
|
2001
|
+
: (route.threadId || null) === (threadId || null)
|
|
1915
2002
|
// 允许 telegram / lark 渠道的权限回调;老的"threadId 非空就算"留作 legacy 兜底。
|
|
1916
|
-
const isRoutedChannel = route
|
|
1917
|
-
if (!sameChat || !sameThread || !isRoutedChannel)
|
|
2003
|
+
const isRoutedChannel = route.channel === 'telegram' || route.channel === 'lark' || route.threadId != null
|
|
2004
|
+
if (!sameChat || !sameThread || !isRoutedChannel) {
|
|
2005
|
+
return stale(`route_mismatch sid=${sid} route=${JSON.stringify({channel: route.channel, targetUserId: route.targetUserId, threadId: route.threadId})} sameChat=${sameChat} sameThread=${sameThread} isRoutedChannel=${isRoutedChannel}`)
|
|
2006
|
+
}
|
|
1918
2007
|
|
|
1919
2008
|
if (action === PERMISSION_ACTION_ALLOW) {
|
|
1920
2009
|
try {
|
|
1921
2010
|
pty.write(sid, '\r')
|
|
1922
2011
|
} catch (e) {
|
|
1923
2012
|
logger.warn?.(`[wizard] permission allow write failed: ${e.message}`)
|
|
1924
|
-
return stale()
|
|
2013
|
+
return stale('write_failed_allow')
|
|
1925
2014
|
}
|
|
2015
|
+
// Lark click 直写 PTY 绕过了 /api/ai-terminal/input 路径,web 端的
|
|
2016
|
+
// permissionPrompt + 卡片不会自动消失。手工调一下 awaitingReply(false) 让
|
|
2017
|
+
// ai-terminal 走 markSessionRunningAfterInput → 翻 running、清 permissionPrompt、
|
|
2018
|
+
// 广播 pending_cleared,前端 webview 那张"AI 等待授权"卡随之消失。
|
|
2019
|
+
try { aiTerminal?.markSessionAwaitingReply?.(sid, false) } catch { /* ignore */ }
|
|
1926
2020
|
return {
|
|
1927
2021
|
toast: '已发送 Enter',
|
|
1928
2022
|
chosenLabel: '允许(Enter)',
|
|
@@ -1936,8 +2030,9 @@ export function createOpenClawWizard({
|
|
|
1936
2030
|
pty.write(sid, '\x1b')
|
|
1937
2031
|
} catch (e) {
|
|
1938
2032
|
logger.warn?.(`[wizard] permission deny write failed: ${e.message}`)
|
|
1939
|
-
return stale()
|
|
2033
|
+
return stale('write_failed_deny')
|
|
1940
2034
|
}
|
|
2035
|
+
try { aiTerminal?.markSessionAwaitingReply?.(sid, false) } catch { /* ignore */ }
|
|
1941
2036
|
return {
|
|
1942
2037
|
toast: '已发送 Esc',
|
|
1943
2038
|
chosenLabel: '拒绝/退出(Esc)',
|