agentquad 0.3.0
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/LICENSE +21 -0
- package/README.md +318 -0
- package/dist-web/assets/index-CMaXwixo.js +1234 -0
- package/dist-web/assets/index-DBHApzV1.css +32 -0
- package/dist-web/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
- package/dist-web/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
- package/dist-web/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
- package/dist-web/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
- package/dist-web/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
- package/dist-web/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
- package/dist-web/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
- package/dist-web/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
- package/dist-web/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
- package/dist-web/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
- package/dist-web/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
- package/dist-web/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
- package/dist-web/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
- package/dist-web/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
- package/dist-web/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
- package/dist-web/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
- package/dist-web/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/dist-web/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/dist-web/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/dist-web/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/dist-web/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/dist-web/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/dist-web/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
- package/dist-web/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
- package/dist-web/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
- package/dist-web/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
- package/dist-web/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
- package/dist-web/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
- package/dist-web/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
- package/dist-web/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
- package/dist-web/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
- package/dist-web/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-BWTpRfYl.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-CEoEElIJ.woff +0 -0
- package/dist-web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- package/dist-web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-greek-700-normal-C6CZE3T8.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-greek-700-normal-DEigVDxa.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CZipNAKV.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CxPITLHs.woff +0 -0
- package/dist-web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- package/dist-web/assets/jetbrains-mono-vietnamese-700-normal-BDLVIk2r.woff +0 -0
- package/dist-web/assets/logo-D4DDtU-r.png +0 -0
- package/dist-web/favicon.png +0 -0
- package/dist-web/index.html +14 -0
- package/package.json +88 -0
- package/src/ask-user-buttons.js +142 -0
- package/src/claude-transcript.js +203 -0
- package/src/cli.js +1040 -0
- package/src/codex-event-emitter.js +111 -0
- package/src/codex-prompt-detector.js +53 -0
- package/src/codex-sidecar.js +52 -0
- package/src/codex-transcript.js +74 -0
- package/src/config.js +692 -0
- package/src/data/claude-code-commands.json +52 -0
- package/src/db.js +1503 -0
- package/src/dispatch.js +13 -0
- package/src/export/todoMarkdown.js +246 -0
- package/src/first-run-wizard.js +82 -0
- package/src/git/gitStatus.js +139 -0
- package/src/lark-api-client.js +205 -0
- package/src/lark-bot.js +510 -0
- package/src/lark-card.js +88 -0
- package/src/lark-config-service.js +16 -0
- package/src/lark-event-client.js +107 -0
- package/src/lark-image.js +99 -0
- package/src/lark-markdown.js +51 -0
- package/src/lark-video.js +163 -0
- package/src/mcp/audit.js +34 -0
- package/src/mcp/server.js +83 -0
- package/src/mcp/tools/destructive/index.js +252 -0
- package/src/mcp/tools/openclaw/index.js +405 -0
- package/src/mcp/tools/read/index.js +269 -0
- package/src/mcp/tools/write/index.js +157 -0
- package/src/openclaw-bridge.js +566 -0
- package/src/openclaw-hook-installer.js +338 -0
- package/src/openclaw-hook.js +908 -0
- package/src/openclaw-wizard.js +2442 -0
- package/src/pending-questions.js +297 -0
- package/src/pricing.js +45 -0
- package/src/prompt-render.js +36 -0
- package/src/pty.js +992 -0
- package/src/routes/ai-terminal.js +1228 -0
- package/src/routes/git.js +89 -0
- package/src/routes/openclaw-hook.js +67 -0
- package/src/routes/openclaw-inbound.js +36 -0
- package/src/routes/recurringRules.js +80 -0
- package/src/routes/reports.js +50 -0
- package/src/routes/search.js +46 -0
- package/src/routes/stats.js +31 -0
- package/src/routes/telegram-config.js +152 -0
- package/src/routes/telegram-sync.js +221 -0
- package/src/routes/templates.js +63 -0
- package/src/routes/todos.js +649 -0
- package/src/routes/transcripts.js +75 -0
- package/src/routes/uploads.js +107 -0
- package/src/routes/wiki.js +142 -0
- package/src/search/fts.js +209 -0
- package/src/search/index.js +199 -0
- package/src/search/transcripts.js +148 -0
- package/src/server.js +1791 -0
- package/src/session-input-dispatcher.js +256 -0
- package/src/stats/markdown.js +42 -0
- package/src/stats/report.js +207 -0
- package/src/summarize.js +84 -0
- package/src/system-rules.js +52 -0
- package/src/telegram-bot.js +875 -0
- package/src/telegram-commands.js +149 -0
- package/src/telegram-config-service.js +84 -0
- package/src/telegram-image.js +95 -0
- package/src/telegram-loading-status.js +112 -0
- package/src/telegram-markdown.js +82 -0
- package/src/telegram-reaction-tracker.js +69 -0
- package/src/telegram-video.js +75 -0
- package/src/templates/claude-hooks/notify.js +103 -0
- package/src/transcript.js +305 -0
- package/src/transcripts/blocks.js +56 -0
- package/src/transcripts/index.js +222 -0
- package/src/transcripts/indexer.js +34 -0
- package/src/transcripts/matcher.js +70 -0
- package/src/transcripts/scanner.js +259 -0
- package/src/usage-footer.js +170 -0
- package/src/usage-parser.js +132 -0
- package/src/wiki/guide.js +44 -0
- package/src/wiki/index.js +232 -0
- package/src/wiki/redact.js +34 -0
- package/src/wiki/sources.js +122 -0
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code Hook 主动推送处理器。
|
|
3
|
+
*
|
|
4
|
+
* 接收 hook 脚本(~/.agentquad/claude-hooks/notify.js)发来的事件,
|
|
5
|
+
* 应用节流规则,调 openclaw-bridge 推送微信/Telegram。
|
|
6
|
+
*
|
|
7
|
+
* 节流规则(按设计稿 §4):
|
|
8
|
+
* - ask_user pending 时 Stop 静默:DB 查 pending_questions 匹配 sessionId → 跳过 Stop
|
|
9
|
+
* - 同 (sessionId × event) 30s cooldown
|
|
10
|
+
* - Notification 优先级最高,无视 cooldown
|
|
11
|
+
* - SessionEnd 不节流,必送达
|
|
12
|
+
* - 整体出站沿用 openclaw-bridge 的 6/min 限流
|
|
13
|
+
*
|
|
14
|
+
* 内容来源(v2 重构):
|
|
15
|
+
* - 优先:Claude Code 的 jsonl 日志(~/.claude/projects/.../uuid.jsonl)
|
|
16
|
+
* 干净的结构化消息,无 spinner/ANSI 噪声
|
|
17
|
+
* - 兜底:PTY recentOutput(旧路径,过滤 spinner)
|
|
18
|
+
*
|
|
19
|
+
* 长内容处理:
|
|
20
|
+
* - ≤ 4000 字 → inline 直发
|
|
21
|
+
* - > 4000 字 → inline 顶部 800 字 + 完整 .md 附件(Telegram sendDocument)
|
|
22
|
+
* - SessionEnd → 额外附整段 transcript .md
|
|
23
|
+
*/
|
|
24
|
+
import { writeFileSync, mkdirSync } from 'node:fs'
|
|
25
|
+
import { join, dirname } from 'node:path'
|
|
26
|
+
import { readLatestAssistantTurn, readLatestAssistantTurnFresh, buildFullTranscript, readJsonlLines as defaultReadJsonlLines } from './claude-transcript.js'
|
|
27
|
+
import { extractTurnUsage, extractSessionUsageFromLines as defaultExtractSessionUsageFromLines, formatUsageFooter } from './usage-footer.js'
|
|
28
|
+
import { DEFAULT_PRICING } from './pricing.js'
|
|
29
|
+
import {
|
|
30
|
+
readLatestCodexTurnFresh as defaultReadLatestCodexTurnFresh,
|
|
31
|
+
buildFullCodexTranscript as defaultBuildFullCodexTranscript,
|
|
32
|
+
extractCodexTurnUsageFromLines as defaultExtractCodexTurnUsageFromLines,
|
|
33
|
+
} from './codex-transcript.js'
|
|
34
|
+
import { buildPermissionCard } from './lark-card.js'
|
|
35
|
+
import { DEFAULT_ROOT_DIR } from './config.js'
|
|
36
|
+
|
|
37
|
+
const DEFAULT_COOLDOWN_MS = 30_000
|
|
38
|
+
const TRANSCRIPT_TMP_DIR = join(DEFAULT_ROOT_DIR, 'tmp')
|
|
39
|
+
const INLINE_MAX_CHARS = 4000
|
|
40
|
+
const ATTACHMENT_HEAD_CHARS = 800
|
|
41
|
+
|
|
42
|
+
function ensureTranscriptDir() {
|
|
43
|
+
try { mkdirSync(TRANSCRIPT_TMP_DIR, { recursive: true }) } catch {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function writeTranscriptTmp(content, sessionId, kind) {
|
|
47
|
+
ensureTranscriptDir()
|
|
48
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
|
49
|
+
const path = join(TRANSCRIPT_TMP_DIR, `transcript-${sessionId}-${ts}-${kind}.md`)
|
|
50
|
+
try {
|
|
51
|
+
writeFileSync(path, content, 'utf8')
|
|
52
|
+
return path
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hookTranscriptMatchesSession(transcriptPath, nativeId) {
|
|
59
|
+
if (!transcriptPath || !nativeId) return false
|
|
60
|
+
const fileName = String(transcriptPath).split(/[\\/]/).pop()
|
|
61
|
+
return fileName === `${nativeId}.jsonl`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 把 todoId 字符串收成 3 字符短码(去除连字符后取末 3 位,转小写)
|
|
65
|
+
function shortTodoId(todoId) {
|
|
66
|
+
if (!todoId) return null
|
|
67
|
+
const cleaned = String(todoId).replace(/[^a-z0-9]/gi, '')
|
|
68
|
+
if (cleaned.length === 0) return null
|
|
69
|
+
return cleaned.slice(-3).toLowerCase()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function stripAnsi(s) {
|
|
73
|
+
return String(s || '')
|
|
74
|
+
.replace(/\x1b\[[0-9;?]*[A-Za-z~]/g, '')
|
|
75
|
+
.replace(/\x1b\][^\x07]*\x07/g, '')
|
|
76
|
+
.replace(/\x1b[()#][A-Za-z0-9]/g, '')
|
|
77
|
+
.replace(/\x1b[>=<cDEHMNOPZ78]/g, '')
|
|
78
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Unicode box-drawing chars: 把 ╭ ╮ ╰ ╯ ├ ┤ │ ─ 等替成简洁字符
|
|
82
|
+
const BOX_HORIZONTAL = /[─━┄┅┈┉═]/g
|
|
83
|
+
const BOX_VERTICAL = /[│┃┆┇┊┋║]/g
|
|
84
|
+
const BOX_CORNERS = /[┌┍┎┏┐┑┒┓└┕┖┗┘┙┚┛┌┐└┘╭╮╯╰╓╒╕╖╙╘╛╜╔╗╚╝]/g
|
|
85
|
+
const BOX_TEES = /[├┝┞┟┠┡┢┣┤┥┦┧┨┩┪┫┬┭┮┯┰┱┲┳┴┵┶┷┸┹┺┻┼┽┾┿╀╁╂╃╄╅╆╇╈╉╊╋╠╣╦╩╬]/g
|
|
86
|
+
|
|
87
|
+
function cleanBoxDrawing(s) {
|
|
88
|
+
return String(s || '')
|
|
89
|
+
.replace(BOX_HORIZONTAL, '-') // 横线 → -
|
|
90
|
+
.replace(BOX_VERTICAL, '|') // 竖线 → |
|
|
91
|
+
.replace(BOX_CORNERS, '+') // 角 → +
|
|
92
|
+
.replace(BOX_TEES, '+') // 三叉 → +
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function compactBlankLines(s) {
|
|
96
|
+
// 多个空行收成一个
|
|
97
|
+
return String(s || '').replace(/\n[ \t]*\n+/g, '\n\n')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function trimTrailingSpaces(s) {
|
|
101
|
+
return String(s || '').split('\n').map((l) => l.replace(/[ \t]+$/, '')).join('\n')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Claude Code 的 spinner / 进度 / 状态行 —— 这些是无信息量的 UI chrome
|
|
105
|
+
// 出现在每次 AI thinking 时大量刷屏,会把真正的问题内容挤出 buffer
|
|
106
|
+
const SPINNER_CHARS = '✶✳✻✽★⚙∗⠁⠂⠄⡀⢀⠠⠐⠈'
|
|
107
|
+
// "<Verb>ing…" / "<Verb>ed for X" 是 Claude Code 的 spinner 状态 —— 不写死词典
|
|
108
|
+
// 因为 Claude Code 几乎每周都在加新动词(Skedaddling / Drizzling / Mulling / etc)
|
|
109
|
+
// 通用规则:任何 3-20 字母单词后接 ing/ed + 省略号 / for + 时间,视为状态
|
|
110
|
+
const STATUS_KEYWORDS = /\b[A-Z][a-z]{2,19}(?:ing|ed)\s+for\s+/ // "Cooked for 3m" / "Brewing for"
|
|
111
|
+
// 行首允许任意 spinner 字符 + 空格,再跟 verb + ellipsis
|
|
112
|
+
const STATUS_VERB_LINE = /^\s*[*✶✳✻✽★⚙∗⠁⠂⠄⡀⢀⠠⠐⠈]*\s*[A-Z][a-z]{2,19}(?:ing|ed)?\s*(…|\.\.\.|\.\.|\.)\s*$/
|
|
113
|
+
const PROMPT_LINE = /^\s*(❯|⏵|►|→)/
|
|
114
|
+
const AUTO_MODE_LINE = /(auto mode (on|off)|shift\+tab to cycle|ctrl\+[a-z]\b)/i
|
|
115
|
+
const BORDER_LINE = /^[\s\-=_|+~]+$/
|
|
116
|
+
|
|
117
|
+
function isSpinnerOnly(line) {
|
|
118
|
+
// 全部是 spinner 字符(含空格)
|
|
119
|
+
const trimmed = line.replace(/\s+/g, '')
|
|
120
|
+
if (!trimmed) return true
|
|
121
|
+
for (const ch of trimmed) {
|
|
122
|
+
if (!SPINNER_CHARS.includes(ch) && !/\d/.test(ch)) return false
|
|
123
|
+
}
|
|
124
|
+
return true
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isStatusLine(line) {
|
|
128
|
+
if (STATUS_KEYWORDS.test(line)) return true
|
|
129
|
+
if (STATUS_VERB_LINE.test(line)) return true
|
|
130
|
+
if (PROMPT_LINE.test(line)) return true
|
|
131
|
+
if (AUTO_MODE_LINE.test(line)) return true
|
|
132
|
+
if (BORDER_LINE.test(line)) return true
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isThinLine(line) {
|
|
137
|
+
// 空行保留(compactBlankLines 阶段再合并),只过滤"很短但非空"的噪声行
|
|
138
|
+
const real = line.replace(/[\s✶✳✻✽★⚙∗⠁⠂⠄⡀⢀⠠⠐⠈]/g, '')
|
|
139
|
+
if (real.length === 0) return false // 空行 → 不算 thin,保留作分隔
|
|
140
|
+
return real.length < 3
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function filterMeaningfulLines(s) {
|
|
144
|
+
return s.split('\n').filter((line) => {
|
|
145
|
+
// 空行保留
|
|
146
|
+
if (!line.trim()) return true
|
|
147
|
+
if (isSpinnerOnly(line)) return false
|
|
148
|
+
if (isStatusLine(line)) return false
|
|
149
|
+
if (isThinLine(line)) return false
|
|
150
|
+
return true
|
|
151
|
+
}).join('\n')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 取 PTY recentOutput 的"有意义"末尾。
|
|
156
|
+
* 多步清洗:strip ANSI → strip box-drawing → 过滤 spinner/状态/边框 → 折叠空行 → 截尾。
|
|
157
|
+
*
|
|
158
|
+
* AI thinking 时 spinner 会快速覆盖 recentOutput buffer(4KB),导致原始
|
|
159
|
+
* 问题内容被冲走。所以传入 fallback `historicalRaw`(更大的 outputHistory),
|
|
160
|
+
* 当 recentOutput 过滤后过瘦时回退过去找。
|
|
161
|
+
*/
|
|
162
|
+
function extractTailSnippet(recentOutput, maxChars = 800, historicalRaw = null) {
|
|
163
|
+
function clean(raw) {
|
|
164
|
+
let s = stripAnsi(raw || '')
|
|
165
|
+
s = cleanBoxDrawing(s)
|
|
166
|
+
s = trimTrailingSpaces(s)
|
|
167
|
+
s = filterMeaningfulLines(s)
|
|
168
|
+
s = compactBlankLines(s)
|
|
169
|
+
return s.trim()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let s = clean(recentOutput)
|
|
173
|
+
// recentOutput 太瘦 → 回退到 outputHistory
|
|
174
|
+
if (s.length < 50 && historicalRaw) {
|
|
175
|
+
const fallback = clean(historicalRaw)
|
|
176
|
+
if (fallback.length > s.length) s = fallback
|
|
177
|
+
}
|
|
178
|
+
if (!s) return ''
|
|
179
|
+
if (s.length <= maxChars) return s
|
|
180
|
+
// 从尾部截,但尽量从最近的换行开始(避免半截行)
|
|
181
|
+
const cut = s.slice(-maxChars)
|
|
182
|
+
const nl = cut.indexOf('\n')
|
|
183
|
+
return '…' + (nl > 0 && nl < 200 ? cut.slice(nl + 1) : cut)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 构造给 IM 推送的最终消息文本。
|
|
188
|
+
*
|
|
189
|
+
* 两种内容源:
|
|
190
|
+
* - cleanContent: 来自 Claude jsonl 的"已经干净"的 turn 文本 → 原样使用,不再过滤
|
|
191
|
+
* - snippet/historicalRaw: 来自 PTY 的"脏" 输出 → 走 extractTailSnippet 过滤 spinner / box-drawing
|
|
192
|
+
*
|
|
193
|
+
* 优先 cleanContent;只在它缺失时走脏路径。
|
|
194
|
+
*/
|
|
195
|
+
function buildMessage({ event, todoId, todoTitle, cleanContent, snippet, historicalRaw }) {
|
|
196
|
+
// 每任务一个 topic — 无需 tag / title / 引导语,正文直给
|
|
197
|
+
let body = ''
|
|
198
|
+
if (cleanContent && typeof cleanContent === 'string' && cleanContent.trim()) {
|
|
199
|
+
body = cleanContent.trim()
|
|
200
|
+
} else if (snippet) {
|
|
201
|
+
body = extractTailSnippet(snippet, 800, historicalRaw)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const fallback = event === 'notification'
|
|
205
|
+
? '⚠️ AI 还在思考 / spinner 中,最近没新内容'
|
|
206
|
+
: '🤖 AI 一轮结束(无新内容)'
|
|
207
|
+
switch (event) {
|
|
208
|
+
case 'stop':
|
|
209
|
+
return body || fallback
|
|
210
|
+
case 'notification':
|
|
211
|
+
return body ? `⚠️ ${body}` : fallback
|
|
212
|
+
case 'session-end':
|
|
213
|
+
return body ? `✅ AI session 已结束\n\n${body}` : `✅ AI session 已结束`
|
|
214
|
+
default:
|
|
215
|
+
return `🦞 ${tag} ${title} hook event: ${event}${snippetBlock || ''}`
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 创建 hook 处理器。
|
|
221
|
+
*
|
|
222
|
+
* 依赖:
|
|
223
|
+
* - db: 用于查 pending_questions(ask_user pending 静默用)
|
|
224
|
+
* - openclaw: openclaw-bridge 实例
|
|
225
|
+
* - cooldownMs: 同 (sessionId × event) 内的最小间隔
|
|
226
|
+
*
|
|
227
|
+
* 并发安全:所有状态都在单实例内部 Map 里;同进程多 hook 调用顺序处理。
|
|
228
|
+
*/
|
|
229
|
+
export function createOpenClawHookHandler(deps = {}) {
|
|
230
|
+
const {
|
|
231
|
+
db,
|
|
232
|
+
aiTerminal = null,
|
|
233
|
+
sidecar = null,
|
|
234
|
+
pty = null,
|
|
235
|
+
telegramBot = null,
|
|
236
|
+
larkBot = null,
|
|
237
|
+
loadingTracker = null,
|
|
238
|
+
reactionTracker = null,
|
|
239
|
+
sessionInputDispatcher = null, // Stop / session-end → 触发 dispatcher flush / cleanup
|
|
240
|
+
cooldownMs = DEFAULT_COOLDOWN_MS,
|
|
241
|
+
getConfig = null, // () => app config(用于读 telegram.notificationCooldownMs)
|
|
242
|
+
logger = console,
|
|
243
|
+
// injectable transcript / usage helpers (codex branch testability)
|
|
244
|
+
readLatestCodexTurnFresh = defaultReadLatestCodexTurnFresh,
|
|
245
|
+
buildFullCodexTranscript = defaultBuildFullCodexTranscript,
|
|
246
|
+
extractCodexTurnUsageFromLines = defaultExtractCodexTurnUsageFromLines,
|
|
247
|
+
extractSessionUsageFromLines = defaultExtractSessionUsageFromLines,
|
|
248
|
+
readJsonlLines = defaultReadJsonlLines,
|
|
249
|
+
} = deps
|
|
250
|
+
// `openclaw` is the legacy bridge handle (used by the claude branch);
|
|
251
|
+
// `bridge` is the codex-branch alias accepted for clarity. Either one is fine.
|
|
252
|
+
const openclaw = deps.openclaw || deps.bridge
|
|
253
|
+
const codexBridge = deps.bridge || deps.openclaw
|
|
254
|
+
|
|
255
|
+
if (!db) throw new Error('db_required')
|
|
256
|
+
if (!openclaw) throw new Error('openclaw_required')
|
|
257
|
+
|
|
258
|
+
// dedupKey → lastSentAt
|
|
259
|
+
const lastSentAt = new Map()
|
|
260
|
+
|
|
261
|
+
function dedupKey(sessionId, event) {
|
|
262
|
+
return `${sessionId || 'global'}:${event}`
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function isOnCooldown(sessionId, event, customCooldownMs) {
|
|
266
|
+
const key = dedupKey(sessionId, event)
|
|
267
|
+
const last = lastSentAt.get(key) || 0
|
|
268
|
+
return (Date.now() - last) < (customCooldownMs ?? cooldownMs)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Notification 自己的 cooldown(默认 10 分钟,可改 telegram.notificationCooldownMs)
|
|
272
|
+
// 设 0 = 关闭(每条都推),设很大 = 等于禁用 idle 提醒
|
|
273
|
+
function notificationCooldownMs() {
|
|
274
|
+
try {
|
|
275
|
+
const cfg = getConfig?.() || {}
|
|
276
|
+
const raw = cfg.telegram?.notificationCooldownMs
|
|
277
|
+
if (raw === 0) return 0
|
|
278
|
+
const n = Number(raw)
|
|
279
|
+
return Number.isFinite(n) && n >= 0 ? n : 600_000 // default 10min
|
|
280
|
+
} catch { return 600_000 }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 默认丢弃 Claude Code 的 idle Notification —— AgentQuad bypass 模式下纯噪声。
|
|
284
|
+
// 用户可在 config 里 telegram.suppressNotificationEvents = false 恢复旧 cooldown 行为。
|
|
285
|
+
function notificationSuppressed() {
|
|
286
|
+
try {
|
|
287
|
+
const cfg = getConfig?.() || {}
|
|
288
|
+
const raw = cfg.telegram?.suppressNotificationEvents
|
|
289
|
+
if (raw === false) return false // 显式 false → 不抑制
|
|
290
|
+
return true // 默认 true / undefined → 抑制
|
|
291
|
+
} catch { return true }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function getSessionPermissionMode(sessionId) {
|
|
295
|
+
const sess = sessionId && aiTerminal?.sessions?.get(sessionId)
|
|
296
|
+
return sess?.permissionMode || sess?.autoMode || 'default'
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function resolveExplicitInteractiveRoute(sessionId) {
|
|
300
|
+
if (!sessionId) return null
|
|
301
|
+
if (!openclaw.hasExplicitRoute?.(sessionId)) return null
|
|
302
|
+
const route = openclaw.resolveRoute?.(sessionId)
|
|
303
|
+
if (!route) return null
|
|
304
|
+
if (route.channel === 'telegram' || !!route.threadId) return route
|
|
305
|
+
if (route.channel === 'lark' && route.rootMessageId) return route
|
|
306
|
+
return null
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function suppressPermissionNotifications() {
|
|
310
|
+
try {
|
|
311
|
+
const cfg = getConfig?.() || {}
|
|
312
|
+
return cfg.telegram?.suppressPermissionNotifications === true
|
|
313
|
+
} catch { return false }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isPermissionReminderEligible(sessionId) {
|
|
317
|
+
if (!sessionId) return false
|
|
318
|
+
if (!resolveExplicitInteractiveRoute(sessionId)) return false
|
|
319
|
+
if (suppressPermissionNotifications()) return false
|
|
320
|
+
return getSessionPermissionMode(sessionId) !== 'bypass'
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function permissionShortId(sessionId) {
|
|
324
|
+
return String(sessionId || '').slice(-4)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function buildPermissionReplyMarkup(sessionId) {
|
|
328
|
+
const shortId = permissionShortId(sessionId)
|
|
329
|
+
return {
|
|
330
|
+
inline_keyboard: [[
|
|
331
|
+
{ text: '允许(Enter)', callback_data: `qt:perm:${shortId}:allow` },
|
|
332
|
+
{ text: '拒绝/退出(Esc)', callback_data: `qt:perm:${shortId}:deny` },
|
|
333
|
+
]],
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function buildPermissionNotificationMessage(message) {
|
|
338
|
+
return `⚠️ Claude Code 正在等待你的响应。\n按钮会向终端发送 Enter/Esc。\n\n${message}`
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── token usage footer 配置 ──
|
|
342
|
+
// pricing.showInPush : 是否在每条 Telegram/飞书推送末尾追加 token / 费用 footer(默认 false,需在 UI 打开)
|
|
343
|
+
// pricing.showCnyInPush : footer 显示时是否同时带 ¥(默认 true,仅 showInPush=true 时生效)
|
|
344
|
+
// pricing : 单价表,缺省走 pricing.js 的 DEFAULT_PRICING(含 cnyRate=7.2)
|
|
345
|
+
// 若 config.pricing 存在,原样透传给 estimateCost
|
|
346
|
+
function shouldShowUsage() {
|
|
347
|
+
try {
|
|
348
|
+
return getConfig?.()?.pricing?.showInPush === true
|
|
349
|
+
} catch { return false }
|
|
350
|
+
}
|
|
351
|
+
function shouldShowUsageCny() {
|
|
352
|
+
try {
|
|
353
|
+
const v = getConfig?.()?.pricing?.showCnyInPush
|
|
354
|
+
return v !== false // undefined / true → on
|
|
355
|
+
} catch { return true }
|
|
356
|
+
}
|
|
357
|
+
function getPricingConfig() {
|
|
358
|
+
try {
|
|
359
|
+
const cfg = getConfig?.() || {}
|
|
360
|
+
// 用户可整块覆盖;不覆盖时用 DEFAULT_PRICING
|
|
361
|
+
return cfg.pricing || DEFAULT_PRICING
|
|
362
|
+
} catch { return DEFAULT_PRICING }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function recordSent(sessionId, event) {
|
|
366
|
+
lastSentAt.set(dedupKey(sessionId, event), Date.now())
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function hasPendingAskUser(sessionId) {
|
|
370
|
+
if (!sessionId) return false
|
|
371
|
+
try {
|
|
372
|
+
// 通过 listPendingQuestions 拿全部,过滤 sessionId 匹配
|
|
373
|
+
const list = db.listPendingQuestions()
|
|
374
|
+
return list.some((p) => p.sessionId === sessionId && p.status === 'pending')
|
|
375
|
+
} catch {
|
|
376
|
+
return false
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function notifyWebTurnDone(sessionId, todoTitle) {
|
|
381
|
+
if (!sessionId || !aiTerminal?.notifyTurnDone) return
|
|
382
|
+
try {
|
|
383
|
+
aiTerminal.notifyTurnDone(sessionId, {
|
|
384
|
+
event: 'stop',
|
|
385
|
+
status: 'idle',
|
|
386
|
+
todoTitle: todoTitle || undefined,
|
|
387
|
+
})
|
|
388
|
+
} catch (e) {
|
|
389
|
+
logger.warn?.(`[openclaw-hook] notifyTurnDone failed: ${e.message}`)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function normalizePersistedTelegramRoute(route) {
|
|
394
|
+
const targetUserId = String(route?.targetUserId ?? '').trim()
|
|
395
|
+
if (!targetUserId) return null
|
|
396
|
+
if (route.channel && route.channel !== 'telegram') return null
|
|
397
|
+
|
|
398
|
+
const threadId = Number(route.threadId)
|
|
399
|
+
if (!Number.isInteger(threadId) || threadId <= 0) return null
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
...route,
|
|
403
|
+
targetUserId,
|
|
404
|
+
threadId,
|
|
405
|
+
channel: 'telegram',
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function normalizePersistedLarkRoute(route) {
|
|
410
|
+
const targetUserId = String(route?.targetUserId ?? '').trim()
|
|
411
|
+
if (!targetUserId) return null
|
|
412
|
+
if (route.channel && route.channel !== 'lark') return null
|
|
413
|
+
const rootMessageId = String(route?.rootMessageId ?? '').trim()
|
|
414
|
+
if (!rootMessageId) return null
|
|
415
|
+
return { ...route, targetUserId, rootMessageId, channel: 'lark' }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function restorePersistedRoute(sessionId, todoId) {
|
|
419
|
+
if (!sessionId || !todoId || !openclaw?.registerSessionRoute || !db?.getTodo) return false
|
|
420
|
+
if (openclaw.hasExplicitRoute?.(sessionId)) return false
|
|
421
|
+
try {
|
|
422
|
+
const todo = db.getTodo(todoId)
|
|
423
|
+
const aiSession = (todo?.aiSessions || []).find((item) => item?.sessionId === sessionId)
|
|
424
|
+
// 优先 lark:纯飞书用户 telegramRoute 永远是空,用旧逻辑会无声跳过 → 重启后 hook
|
|
425
|
+
// 拿不到 route → push 失败到飞书。lark 校验通过就走它,否则再尝试 telegram。
|
|
426
|
+
const larkRoute = normalizePersistedLarkRoute(aiSession?.larkRoute)
|
|
427
|
+
if (larkRoute) {
|
|
428
|
+
openclaw.registerSessionRoute(sessionId, larkRoute)
|
|
429
|
+
logger.info?.(`[openclaw-hook] restored lark route for sid=${sessionId} root=${larkRoute.rootMessageId}`)
|
|
430
|
+
return true
|
|
431
|
+
}
|
|
432
|
+
const tgRoute = normalizePersistedTelegramRoute(aiSession?.telegramRoute)
|
|
433
|
+
if (!tgRoute) return false
|
|
434
|
+
openclaw.registerSessionRoute(sessionId, tgRoute)
|
|
435
|
+
logger.info?.(`[openclaw-hook] restored telegram route for sid=${sessionId} threadId=${tgRoute.threadId}`)
|
|
436
|
+
return true
|
|
437
|
+
} catch (e) {
|
|
438
|
+
logger.warn?.(`[openclaw-hook] restore route failed: ${e.message}`)
|
|
439
|
+
return false
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* 处理一条 hook 事件 —— 统一入口,按 source/path 分发。
|
|
445
|
+
* - source=codex,path=jsonl → handleCodexJsonl(Phase C)
|
|
446
|
+
* - source=codex,path=detector → handleCodexDetector(Phase E 占位,目前直接拒绝)
|
|
447
|
+
* - 其它(默认 claude) → handleClaude(保留原逻辑,签名不变)
|
|
448
|
+
* 返回 { ok, action: 'sent'|'skipped'|'failed', reason? }
|
|
449
|
+
*/
|
|
450
|
+
async function handle(req = {}) {
|
|
451
|
+
const source = req?.source || 'claude'
|
|
452
|
+
if (source === 'codex' && req?.path === 'jsonl') return handleCodexJsonl(req)
|
|
453
|
+
if (source === 'codex' && req?.path === 'detector') return handleCodexDetector(req)
|
|
454
|
+
return handleClaude(req)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ─── Codex 分支(Phase C)─────────────────────────────────────────────────────
|
|
458
|
+
async function handleCodexJsonl({ event, nativeId, transcript_path, raw_event_payload }) {
|
|
459
|
+
// 1) 解析 AgentQuad sessionId
|
|
460
|
+
let quadtodoSessionId = null
|
|
461
|
+
let todoId = null
|
|
462
|
+
let cwd = null
|
|
463
|
+
const fromSidecar = sidecar?.lookup?.(nativeId)
|
|
464
|
+
if (fromSidecar) {
|
|
465
|
+
quadtodoSessionId = fromSidecar.quadtodoSessionId
|
|
466
|
+
todoId = fromSidecar.todoId
|
|
467
|
+
cwd = fromSidecar.cwd
|
|
468
|
+
} else if (aiTerminal?.sessions) {
|
|
469
|
+
// pty.js 里 session 上挂的字段是 `nativeId`(见 pty.js:297),不是
|
|
470
|
+
// `nativeSessionId`——只有 PtyManager 内部 API 参数名是 nativeSessionId。
|
|
471
|
+
// 早期实现写错了字段名,导致 fallback 扫描永远 miss。
|
|
472
|
+
for (const [sid, sess] of aiTerminal.sessions) {
|
|
473
|
+
if (sess?.nativeId === nativeId) {
|
|
474
|
+
quadtodoSessionId = sid
|
|
475
|
+
todoId = sess.todoId || null
|
|
476
|
+
cwd = sess.cwd || null
|
|
477
|
+
break
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (!quadtodoSessionId) {
|
|
482
|
+
logger.warn?.(`[codex-hook] no AgentQuad session for nativeId=${nativeId}`)
|
|
483
|
+
return { ok: false, reason: 'no_quadtodo_session' }
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// 2) 定位 jsonl
|
|
487
|
+
const filePath = transcript_path || pty?.findCodexSession?.(nativeId)?.filePath || null
|
|
488
|
+
if (!filePath) return { ok: false, reason: 'no_transcript' }
|
|
489
|
+
|
|
490
|
+
// 3) 读最新一轮
|
|
491
|
+
let text = ''
|
|
492
|
+
if (event === 'Stop' || event === 'TurnAborted') {
|
|
493
|
+
try {
|
|
494
|
+
const turn = await readLatestCodexTurnFresh(filePath, null, { retries: 3, retryMs: 200 })
|
|
495
|
+
text = turn?.text || ''
|
|
496
|
+
} catch (e) {
|
|
497
|
+
logger.warn?.(`[codex-hook] read latest turn failed: ${e.message}`)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// 4) 拼 footer
|
|
502
|
+
let lines = []
|
|
503
|
+
try { lines = readJsonlLines(filePath) || [] } catch { lines = [] }
|
|
504
|
+
let turnUsage = null
|
|
505
|
+
let sessionUsage = null
|
|
506
|
+
try { turnUsage = extractCodexTurnUsageFromLines(lines) } catch {}
|
|
507
|
+
try { sessionUsage = extractSessionUsageFromLines(lines, 'codex') } catch {}
|
|
508
|
+
let footer = ''
|
|
509
|
+
try {
|
|
510
|
+
footer = formatUsageFooter({
|
|
511
|
+
turn: turnUsage ? { ...turnUsage, model: sessionUsage?.primaryModel } : null,
|
|
512
|
+
session: sessionUsage,
|
|
513
|
+
})
|
|
514
|
+
} catch (e) {
|
|
515
|
+
logger.warn?.(`[codex-hook] format usage footer failed: ${e.message}`)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// 5) 拼正文
|
|
519
|
+
let todoTitle = null
|
|
520
|
+
try { todoTitle = (await db.getTodo?.(todoId))?.title || null } catch { todoTitle = null }
|
|
521
|
+
todoTitle = todoTitle || todoId || ''
|
|
522
|
+
const idTail = todoId ? String(todoId).slice(-3) : '???'
|
|
523
|
+
const headLine = event === 'Stop'
|
|
524
|
+
? `🤖 [#t${idTail}] 任务「${todoTitle}」AI 一轮结束`
|
|
525
|
+
: event === 'TurnAborted'
|
|
526
|
+
? `🛑 [#t${idTail}] 任务「${todoTitle}」AI 一轮被中断`
|
|
527
|
+
: event === 'Error'
|
|
528
|
+
? `❌ [#t${idTail}] 任务「${todoTitle}」Codex 报错:${raw_event_payload?.message || ''}`
|
|
529
|
+
: event === 'SessionEnd'
|
|
530
|
+
? `✅ [#t${idTail}] 任务「${todoTitle}」AI 跑完了`
|
|
531
|
+
: `[codex] 未知事件 ${event}`
|
|
532
|
+
const fullText = text
|
|
533
|
+
? `${headLine}\n\n${text}${footer ? `\n\n${footer}` : ''}`
|
|
534
|
+
: `${headLine}${footer ? `\n\n${footer}` : ''}`
|
|
535
|
+
|
|
536
|
+
// 6) 推送
|
|
537
|
+
// bridge.postText 的形参是 `message`,不是 `text`——早期实现写错字段名,
|
|
538
|
+
// 导致 bridge 收到 message=undefined 直接走 message_required 短路返回。
|
|
539
|
+
try {
|
|
540
|
+
const r = await codexBridge.postText({ sessionId: quadtodoSessionId, message: fullText })
|
|
541
|
+
if (!r?.ok) {
|
|
542
|
+
logger.warn?.(`[codex-hook] postText returned not-ok: reason=${r?.reason} detail=${r?.detail || ''}`)
|
|
543
|
+
return { ok: false, reason: 'post_failed', detail: r?.reason }
|
|
544
|
+
}
|
|
545
|
+
logger.info?.(`[codex-hook] postText OK sessionId=${quadtodoSessionId} event=${event} len=${fullText.length}`)
|
|
546
|
+
} catch (e) {
|
|
547
|
+
logger.warn?.(`[codex-hook] postText threw: ${e.message}`)
|
|
548
|
+
return { ok: false, reason: 'post_failed', detail: e?.message }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// 7) SessionEnd → 附完整 transcript
|
|
552
|
+
if (event === 'SessionEnd') {
|
|
553
|
+
try {
|
|
554
|
+
const full = buildFullCodexTranscript(filePath)
|
|
555
|
+
if (full?.markdown) {
|
|
556
|
+
const tmpPath = writeTranscriptTmp(full.markdown, quadtodoSessionId, 'codex-full')
|
|
557
|
+
if (tmpPath && codexBridge?.sendDocument) {
|
|
558
|
+
await codexBridge.sendDocument({ sessionId: quadtodoSessionId, path: tmpPath })
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
} catch (e) {
|
|
562
|
+
logger.warn?.(`[codex-hook] attach full transcript failed: ${e.message}`)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return { ok: true, action: 'sent', source: 'codex', event }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ─── Codex stdout detector 分支(Phase E)────────────────────────────────────
|
|
570
|
+
// PtyManager 的 prompt-detector 命中([Y/n] / apply patch? 等)→ POST /api/openclaw/hook
|
|
571
|
+
// 走到这里推一张飞书 / Telegram 权限卡片。actionId 里带 'codex:' 前缀,让卡片回调
|
|
572
|
+
// 走的还是 wizard.handlePermissionCallback 的 \r/\x1b 路径(tool-agnostic)。
|
|
573
|
+
async function handleCodexDetector({ event, sessionId, nativeId, promptText, matchedPattern } = {}) {
|
|
574
|
+
if (!sessionId) return { ok: false, reason: 'no_sessionId' }
|
|
575
|
+
const sess = aiTerminal?.sessions?.get(sessionId)
|
|
576
|
+
if (!sess) return { ok: false, reason: 'session_gone' }
|
|
577
|
+
// 把 session.status 翻成 pending_confirm —— 前端 deriveAiState 据此显示"待确认"。
|
|
578
|
+
// 信号源是 codex-prompt-detector(已经过 AI self-quoted 过滤),比旧的 PTY 正则路径准。
|
|
579
|
+
try { aiTerminal?.markPendingConfirm?.(sessionId, { source: 'codex-detector' }) } catch { /* ignore */ }
|
|
580
|
+
const todoId = sess.todoId
|
|
581
|
+
let todoTitle = todoId
|
|
582
|
+
try {
|
|
583
|
+
const todo = await db.getTodo?.(todoId)
|
|
584
|
+
todoTitle = todo?.title || todoId
|
|
585
|
+
} catch { /* ignore */ }
|
|
586
|
+
const idTail = todoId ? String(todoId).slice(-3) : '???'
|
|
587
|
+
const text = `⚠️ [#t${idTail}] 任务「${todoTitle}」AI 卡住等输入:\n\n\`\`\`\n${promptText}\n\`\`\``
|
|
588
|
+
const card = buildPermissionCard({
|
|
589
|
+
message: text,
|
|
590
|
+
actionId: `codex:${sessionId}`,
|
|
591
|
+
headerTitle: '⚠️ Codex 等待授权',
|
|
592
|
+
})
|
|
593
|
+
try {
|
|
594
|
+
await codexBridge.postCard?.({ sessionId, card })
|
|
595
|
+
} catch (e) {
|
|
596
|
+
logger.warn?.(`[codex-detector] postCard failed: ${e.message}`)
|
|
597
|
+
return { ok: false, reason: 'post_failed', detail: e?.message }
|
|
598
|
+
}
|
|
599
|
+
return { ok: true, action: 'sent', source: 'codex', event, nativeId, matchedPattern }
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ─── Claude 分支(既有实现,原 handle() 主体不变)─────────────────────────────
|
|
603
|
+
async function handleClaude({ event, sessionId, todoId, todoTitle, hookPayload } = {}) {
|
|
604
|
+
if (!event) return { ok: false, action: 'failed', reason: 'event_required' }
|
|
605
|
+
const evt = String(event).toLowerCase()
|
|
606
|
+
|
|
607
|
+
// 诊断:sessionId 给了但 bridge 没注册过 route → 先尝试从 DB 持久化 route 恢复。
|
|
608
|
+
// 恢复失败才 warn;postText 仍会拒绝 route-less Telegram session,避免泄漏到 General。
|
|
609
|
+
if (sessionId && openclaw?.hasExplicitRoute && !openclaw.hasExplicitRoute(sessionId)) {
|
|
610
|
+
restorePersistedRoute(sessionId, todoId)
|
|
611
|
+
}
|
|
612
|
+
if (sessionId && openclaw?.hasExplicitRoute && !openclaw.hasExplicitRoute(sessionId)) {
|
|
613
|
+
logger.warn?.(`[openclaw-hook] hook fired with no registered route: event=${evt} sid=${sessionId} todoId=${todoId || 'null'}`)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// 1) ask_user pending 时 Stop 静默
|
|
617
|
+
if (evt === 'stop' && hasPendingAskUser(sessionId)) {
|
|
618
|
+
return { ok: true, action: 'skipped', reason: 'ask_user_pending' }
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const permissionReminderEligible = evt === 'notification' && isPermissionReminderEligible(sessionId)
|
|
622
|
+
|
|
623
|
+
// 注:原来这里会立即 notifyWebTurnDone。已经挪到 ③ 读完 JSONL 之后,
|
|
624
|
+
// 由 turnEndedNormally 把校验门串起来——只有 stop_reason === 'end_turn' 才翻 idle。
|
|
625
|
+
|
|
626
|
+
// 2) cooldown:默认不再对 Stop 启用 cooldown
|
|
627
|
+
//
|
|
628
|
+
// 原因:每个 Stop 事件都对应一次 AI 真实回话(用户提问 → AI 回应 → Stop fire)。
|
|
629
|
+
// 之前用 30s cooldown 想去重 micro-turn,但实际把多轮对话也吞了 ——
|
|
630
|
+
// 用户问完话 AI 立刻回 → Stop 在 30s 内 fire → 被静默 → 用户只能等 1min
|
|
631
|
+
// 后的 idle Notification(⚠️ 图标),误以为"超时响应"。
|
|
632
|
+
//
|
|
633
|
+
// 现在所有事件都无 cooldown;rate limit 由 openclaw-bridge 整体的 6/min 出站
|
|
634
|
+
// 限流兜底(防风控)。
|
|
635
|
+
// 仍然保留 isOnCooldown 函数(debug / 未来某些事件类型可能需要)。
|
|
636
|
+
|
|
637
|
+
// 3) 拼消息文本 —— 优先从 Claude Code jsonl 拿干净的 assistant turn
|
|
638
|
+
let cleanContent = null // jsonl 干净内容(不过滤、不截尾)
|
|
639
|
+
let snippet = null // PTY 兜底(脏,需要过滤)
|
|
640
|
+
let historicalRaw = null
|
|
641
|
+
let attachmentPath = null
|
|
642
|
+
|
|
643
|
+
// 3a. 拿 sessionId 的 nativeId(Claude Code session UUID)
|
|
644
|
+
let nativeId = null
|
|
645
|
+
if (sessionId && aiTerminal?.sessions) {
|
|
646
|
+
const sess = aiTerminal.sessions.get(sessionId)
|
|
647
|
+
nativeId = sess?.nativeSessionId || null
|
|
648
|
+
if (sess) {
|
|
649
|
+
snippet = sess.recentOutput || ''
|
|
650
|
+
if (Array.isArray(sess.outputHistory)) historicalRaw = sess.outputHistory.join('')
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (!nativeId && hookPayload && typeof hookPayload === 'object') {
|
|
654
|
+
nativeId = hookPayload.session_id || hookPayload.sessionId || null
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// 3b. 从 jsonl 取 latest assistant turn(这是首选源)
|
|
658
|
+
let turnText = null
|
|
659
|
+
let turnRaw = null // 给 footer 算本轮 usage 用
|
|
660
|
+
let jsonlPath = null // 给 footer 算 session 累计用
|
|
661
|
+
const rawHookTranscriptPath = hookPayload && typeof hookPayload === 'object' && typeof hookPayload.transcript_path === 'string'
|
|
662
|
+
? hookPayload.transcript_path
|
|
663
|
+
: null
|
|
664
|
+
const hookTranscriptPath = hookTranscriptMatchesSession(rawHookTranscriptPath, nativeId)
|
|
665
|
+
? rawHookTranscriptPath
|
|
666
|
+
: null
|
|
667
|
+
if (rawHookTranscriptPath && !hookTranscriptPath) {
|
|
668
|
+
logger.warn?.(`[openclaw-hook] ignoring transcript path that does not match native session id: nativeId=${nativeId || 'null'} path=${rawHookTranscriptPath}`)
|
|
669
|
+
}
|
|
670
|
+
if (hookTranscriptPath || (nativeId && pty?.findClaudeSession)) {
|
|
671
|
+
try {
|
|
672
|
+
const loc = hookTranscriptPath ? { filePath: hookTranscriptPath } : pty.findClaudeSession(nativeId)
|
|
673
|
+
if (loc?.filePath) {
|
|
674
|
+
jsonlPath = loc.filePath
|
|
675
|
+
// **关键**:用 Fresh 版,等 jsonl 写完最新 turn 再读
|
|
676
|
+
// 避免 Stop hook 触发但 jsonl 还没 flush,导致读到上一轮("每条回复都是上一次的")
|
|
677
|
+
const turn = evt === 'session-end'
|
|
678
|
+
? readLatestAssistantTurn(loc.filePath) // session 结束没必要等
|
|
679
|
+
: await readLatestAssistantTurnFresh(loc.filePath)
|
|
680
|
+
if (turn?.text) {
|
|
681
|
+
turnText = turn.text
|
|
682
|
+
turnRaw = turn.raw
|
|
683
|
+
if (turn.fresh === false) {
|
|
684
|
+
logger.warn?.(`[openclaw-hook] jsonl still stale after retries for ${nativeId || 'hook-payload'} (event=${evt}); using stale content as fallback`)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// SessionEnd 时额外做 full transcript 附件
|
|
688
|
+
if (evt === 'session-end') {
|
|
689
|
+
const full = buildFullTranscript(loc.filePath)
|
|
690
|
+
if (full.markdown) {
|
|
691
|
+
attachmentPath = writeTranscriptTmp(full.markdown, sessionId, 'full')
|
|
692
|
+
}
|
|
693
|
+
} else if (turnText && turnText.length > INLINE_MAX_CHARS) {
|
|
694
|
+
// Stop / Notification:长 turn 拆成 inline + 附件
|
|
695
|
+
attachmentPath = writeTranscriptTmp(turnText, sessionId, 'turn')
|
|
696
|
+
}
|
|
697
|
+
} else {
|
|
698
|
+
logger.warn?.(`[openclaw-hook] no jsonl found for nativeId=${nativeId}; falling back to PTY snippet`)
|
|
699
|
+
}
|
|
700
|
+
} catch (e) {
|
|
701
|
+
logger.warn?.(`[openclaw-hook] read transcript failed: ${e.message}`)
|
|
702
|
+
}
|
|
703
|
+
} else if (sessionId) {
|
|
704
|
+
logger.warn?.(`[openclaw-hook] cannot resolve transcript for sessionId=${sessionId} (nativeId=${nativeId} transcript_path=${hookTranscriptPath || 'null'} pty.findClaudeSession=${!!pty?.findClaudeSession}); falling back to PTY snippet`)
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// 3b'. Stop hook 校验门:只有当 JSONL 末行 assistant.stop_reason === 'end_turn'
|
|
708
|
+
// 才视为"本轮真的结束"。Claude 自家 Stop hook 在 sub-agent 完成 / 中间停顿 /
|
|
709
|
+
// 内部 transition 等场景下也会 fire,stop_reason 会是 'tool_use' / 'max_tokens' / null。
|
|
710
|
+
// 读不到 jsonl(nativeId 缺失 / 文件没找到)则兜底 true,保持旧行为,不阻塞 dispatcher。
|
|
711
|
+
let turnEndedNormally = true
|
|
712
|
+
if (evt === 'stop' && nativeId && jsonlPath) {
|
|
713
|
+
const stopReason = turnRaw?.message?.stop_reason ?? null
|
|
714
|
+
if (stopReason !== 'end_turn') {
|
|
715
|
+
turnEndedNormally = false
|
|
716
|
+
logger.warn?.(`[openclaw-hook] Stop hook deferred: stopReason=${stopReason || 'null'} sid=${sessionId} nativeId=${nativeId} — waiting for jsonl watcher to fire on real end_turn`)
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// 3b''. notifyWebTurnDone:向浏览器广播 turn_done。仅在确认 end_turn 时触发,避免
|
|
721
|
+
// 前端的 markSessionTurnDone(store/aiSessionStore.ts)把状态错误地翻成 idle。
|
|
722
|
+
if (evt === 'stop' && turnEndedNormally) {
|
|
723
|
+
notifyWebTurnDone(sessionId, todoTitle)
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// 3c. 决定 cleanContent(jsonl 命中时优先;长内容截短)
|
|
727
|
+
if (turnText) {
|
|
728
|
+
cleanContent = turnText.length > INLINE_MAX_CHARS
|
|
729
|
+
? turnText.slice(0, INLINE_MAX_CHARS - 200) + '\n\n…(完整内容见附件)'
|
|
730
|
+
: turnText
|
|
731
|
+
// jsonl 命中时不传 PTY 内容,避免 buildMessage 再次回退到脏数据
|
|
732
|
+
snippet = null
|
|
733
|
+
historicalRaw = null
|
|
734
|
+
} else if (!snippet && hookPayload && typeof hookPayload === 'object') {
|
|
735
|
+
const hint = hookPayload.message || hookPayload.summary || null
|
|
736
|
+
if (hint && typeof hint === 'string') snippet = hint.trim()
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// 3d. token usage footer ——
|
|
740
|
+
// 仅 Stop / SessionEnd 推送时附加(notification 是 idle 心跳,没新轮次,无意义)
|
|
741
|
+
// 配置开关:pricing.showInPush(默认 false,需 UI 打开)/ pricing.showCnyInPush(默认 true)
|
|
742
|
+
let usageFooter = ''
|
|
743
|
+
if ((evt === 'stop' || evt === 'session-end') && jsonlPath && shouldShowUsage()) {
|
|
744
|
+
try {
|
|
745
|
+
const turnUsage = extractTurnUsage(turnRaw)
|
|
746
|
+
let sessionUsage = null
|
|
747
|
+
try {
|
|
748
|
+
const lines = readJsonlLines(jsonlPath)
|
|
749
|
+
if (lines.length > 0) sessionUsage = extractSessionUsageFromLines(lines)
|
|
750
|
+
} catch (e) {
|
|
751
|
+
logger.warn?.(`[openclaw-hook] read session usage failed: ${e.message}`)
|
|
752
|
+
}
|
|
753
|
+
usageFooter = formatUsageFooter({
|
|
754
|
+
turn: turnUsage,
|
|
755
|
+
session: sessionUsage,
|
|
756
|
+
showCny: shouldShowUsageCny(),
|
|
757
|
+
pricing: getPricingConfig(),
|
|
758
|
+
})
|
|
759
|
+
} catch (e) {
|
|
760
|
+
logger.warn?.(`[openclaw-hook] format usage footer failed: ${e.message}`)
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
let message = buildMessage({
|
|
765
|
+
event: evt, todoId, todoTitle,
|
|
766
|
+
cleanContent,
|
|
767
|
+
snippet,
|
|
768
|
+
historicalRaw,
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
// Claude Code Notification hook fire 本身就是"需要用户介入"的可信信号——
|
|
772
|
+
// 不再用正则/关键词反推 message 内容是不是"权限相关"。任何 Notification 都
|
|
773
|
+
// 翻 session.status 成 pending_confirm,让前端 deriveAiState 渲染"待确认"。
|
|
774
|
+
// markPendingConfirm 幂等 + 仅对 LIVE session 生效。
|
|
775
|
+
if (evt === 'notification' && sessionId) {
|
|
776
|
+
try { aiTerminal?.markPendingConfirm?.(sessionId, { source: 'claude-notification' }) } catch { /* ignore */ }
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// 1b-pre) bypass 模式下 session 不会真的卡在等用户,Notification 是 idle 心跳噪音,
|
|
780
|
+
// 默认抑制。非 bypass session 的 Notification 直接放过(不再做文本侧筛选)。
|
|
781
|
+
if (evt === 'notification' && notificationSuppressed() && !permissionReminderEligible) {
|
|
782
|
+
return { ok: true, action: 'skipped', reason: 'notification_suppressed' }
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// 1b) Notification cooldown(同一 session 单位时间内只推一次,默认 10 分钟)
|
|
786
|
+
if (evt === 'notification') {
|
|
787
|
+
const cd = notificationCooldownMs()
|
|
788
|
+
if (cd > 0 && isOnCooldown(sessionId, evt, cd)) {
|
|
789
|
+
return { ok: true, action: 'skipped', reason: 'notification_cooldown', cooldownMs: cd }
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
let replyMarkup = null
|
|
794
|
+
if (evt === 'notification' && permissionReminderEligible) {
|
|
795
|
+
// 非 bypass session 收到 Notification:附 Enter/Esc 快速响应按钮。
|
|
796
|
+
// 即便不是授权请求(例如 idle 提醒),点 Enter 给 Claude 一个空 line 也是无害的。
|
|
797
|
+
message = buildPermissionNotificationMessage(message)
|
|
798
|
+
replyMarkup = buildPermissionReplyMarkup(sessionId)
|
|
799
|
+
}
|
|
800
|
+
// footer 永远附在最末尾(即使消息被截短到附件也要保留,让用户能看到费用)
|
|
801
|
+
if (usageFooter) message = `${message}\n\n${usageFooter}`
|
|
802
|
+
|
|
803
|
+
// 4) 推送(postText 接受可选 attachment)
|
|
804
|
+
const result = await openclaw.postText({
|
|
805
|
+
sessionId,
|
|
806
|
+
message,
|
|
807
|
+
attachment: attachmentPath, // bridge 转给 telegramBot.sendDocument
|
|
808
|
+
replyMarkup,
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
// 5) SessionEnd 后处理:close topic + 改名 ✅ + 清状态
|
|
812
|
+
if (evt === 'session-end') {
|
|
813
|
+
const route = openclaw.resolveRoute?.(sessionId)
|
|
814
|
+
if (route?.threadId && telegramBot) {
|
|
815
|
+
try {
|
|
816
|
+
await telegramBot.closeForumTopic({ chatId: route.targetUserId, threadId: route.threadId })
|
|
817
|
+
if (route.topicName) {
|
|
818
|
+
await telegramBot.editForumTopic({
|
|
819
|
+
chatId: route.targetUserId,
|
|
820
|
+
threadId: route.threadId,
|
|
821
|
+
name: `✅ ${route.topicName}`.slice(0, 128),
|
|
822
|
+
})
|
|
823
|
+
}
|
|
824
|
+
} catch (e) {
|
|
825
|
+
logger.warn?.(`[openclaw-hook] close/edit topic failed: ${e.message}`)
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
openclaw.clearLastPushForSession?.(sessionId)
|
|
829
|
+
openclaw.clearSessionRoute?.(sessionId, 'session-end')
|
|
830
|
+
if (sessionInputDispatcher?.onSessionEnd) {
|
|
831
|
+
Promise.resolve(sessionInputDispatcher.onSessionEnd(sessionId))
|
|
832
|
+
.catch((e) => logger.warn?.(`[openclaw-hook] dispatcher.onSessionEnd failed: ${e.message}`))
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Stop 事件 = Claude 完成一轮回复 → 更新 idle 状态 + flush dispatcher 队列。
|
|
837
|
+
// 这里不受 result.ok 左右,原因仍然成立:
|
|
838
|
+
// - awaitingReply 描述的是 Claude 自身状态(finished a turn),跟"我们有没有把回复
|
|
839
|
+
// 成功推到 telegram/lark"无关;
|
|
840
|
+
// - 推送失败(route 缺失、TG 限流、网络错)一旦把这两步吞掉,dispatcher 就永远以为
|
|
841
|
+
// busy,后续用户消息全部回 "🔄 已排队",队列也不 flush,直到进程重启都不能恢复。
|
|
842
|
+
// 但额外加了 turnEndedNormally 门:JSONL 末行 stop_reason !== 'end_turn' 时 defer
|
|
843
|
+
// 这两个 mutation —— Claude 自家 Stop hook 在中间停顿 / sub-agent 完成 / 内部
|
|
844
|
+
// transition 等场景会假阳性 fire,把状态翻成 idle 后用户瞄一眼把 unread 清掉,徽标
|
|
845
|
+
// 消失,等真正 end_turn 时再 fire 一次又变"待确认",体验劣化。jsonl watcher 会在
|
|
846
|
+
// 真 end_turn 时兜底走相同的状态翻转,dispatcher 不会卡住。
|
|
847
|
+
if (evt === 'stop' && turnEndedNormally && sessionId && aiTerminal?.markSessionAwaitingReply) {
|
|
848
|
+
try {
|
|
849
|
+
const ok = aiTerminal.markSessionAwaitingReply(sessionId, true)
|
|
850
|
+
// mark 返回 false = no-op:session 不在 ait.sessions / status 不是 running|pending_confirm
|
|
851
|
+
// / 已经是目标值。出现这种情况说明之后 dispatcher 会把后续用户消息一直 queue 不投递
|
|
852
|
+
// → 显式 warn 让 ops 知道 root cause(session lifecycle 跟 hook 不一致)。
|
|
853
|
+
if (!ok) {
|
|
854
|
+
const sess = aiTerminal?.sessions?.get?.(sessionId)
|
|
855
|
+
logger.warn?.(`[openclaw-hook] markSessionAwaitingReply(true) NO-OP sid=${sessionId} sessionExists=${!!sess} status=${sess?.status || 'null'} awaitingReply=${sess?.awaitingReply}`)
|
|
856
|
+
}
|
|
857
|
+
} catch (e) { logger.warn?.(`[openclaw-hook] markSessionAwaitingReply failed: ${e.message}`) }
|
|
858
|
+
}
|
|
859
|
+
// 顺序:上面已 markSessionAwaitingReply(true) 让 dispatcher 看到 idle,再 flush
|
|
860
|
+
if (evt === 'stop' && turnEndedNormally && sessionId && sessionInputDispatcher?.onSessionIdle) {
|
|
861
|
+
Promise.resolve(sessionInputDispatcher.onSessionIdle(sessionId))
|
|
862
|
+
.catch((e) => logger.warn?.(`[openclaw-hook] dispatcher.onSessionIdle failed: ${e.message}`))
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if (result.ok) {
|
|
866
|
+
recordSent(sessionId, evt)
|
|
867
|
+
// Stop 事件 = Claude 完成一轮回复 → 标题切到 💤(在 push 成功后才切,
|
|
868
|
+
// 避免推送失败时标题先变 💤 但消息没到)
|
|
869
|
+
if (evt === 'stop' && sessionId && loadingTracker?.markIdle) {
|
|
870
|
+
loadingTracker.markIdle(sessionId).catch((e) => logger.warn?.(`[openclaw-hook] markIdle failed: ${e.message}`))
|
|
871
|
+
}
|
|
872
|
+
// Stop / session-end → 清掉 lark "在思考" reaction(如果是 lark route)
|
|
873
|
+
if ((evt === 'stop' || evt === 'session-end') && sessionId && larkBot?.clearReactionsForSession) {
|
|
874
|
+
const route = openclaw.resolveRoute?.(sessionId)
|
|
875
|
+
if (route?.channel === 'lark') {
|
|
876
|
+
larkBot.clearReactionsForSession(sessionId).catch((e) => logger.warn?.(`[openclaw-hook] clearReactionsForSession failed: ${e.message}`))
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
// Stop / session-end → 清掉 telegram "✍" reaction(如果是 telegram route)
|
|
880
|
+
if ((evt === 'stop' || evt === 'session-end') && sessionId && reactionTracker?.clearReactionsForSession) {
|
|
881
|
+
const route = openclaw.resolveRoute?.(sessionId)
|
|
882
|
+
if (route?.channel === 'telegram') {
|
|
883
|
+
reactionTracker.clearReactionsForSession(sessionId).catch((e) => logger.warn?.(`[openclaw-hook] tg clearReactionsForSession failed: ${e.message}`))
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return { ok: true, action: 'sent', message, attachment: attachmentPath }
|
|
887
|
+
}
|
|
888
|
+
return { ok: false, action: 'failed', reason: result.reason || 'unknown', detail: result }
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function describe() {
|
|
892
|
+
return {
|
|
893
|
+
cooldownMs,
|
|
894
|
+
activeDedups: lastSentAt.size,
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// 测试 / 调试钩子
|
|
899
|
+
function _reset() { lastSentAt.clear() }
|
|
900
|
+
|
|
901
|
+
return {
|
|
902
|
+
handle,
|
|
903
|
+
describe,
|
|
904
|
+
_reset,
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
export const __test__ = { buildMessage, shortTodoId }
|