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.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +318 -0
  3. package/dist-web/assets/index-CMaXwixo.js +1234 -0
  4. package/dist-web/assets/index-DBHApzV1.css +32 -0
  5. package/dist-web/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
  6. package/dist-web/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
  7. package/dist-web/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
  8. package/dist-web/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
  9. package/dist-web/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
  10. package/dist-web/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
  11. package/dist-web/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
  12. package/dist-web/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
  13. package/dist-web/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
  14. package/dist-web/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
  15. package/dist-web/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
  16. package/dist-web/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
  17. package/dist-web/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
  18. package/dist-web/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
  19. package/dist-web/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
  20. package/dist-web/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
  21. package/dist-web/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
  22. package/dist-web/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
  23. package/dist-web/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
  24. package/dist-web/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
  25. package/dist-web/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
  26. package/dist-web/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
  27. package/dist-web/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
  28. package/dist-web/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
  29. package/dist-web/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
  30. package/dist-web/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
  31. package/dist-web/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
  32. package/dist-web/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
  33. package/dist-web/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
  34. package/dist-web/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
  35. package/dist-web/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
  36. package/dist-web/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
  37. package/dist-web/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  38. package/dist-web/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  39. package/dist-web/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  40. package/dist-web/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  41. package/dist-web/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  42. package/dist-web/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  43. package/dist-web/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
  44. package/dist-web/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  45. package/dist-web/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
  46. package/dist-web/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
  47. package/dist-web/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
  48. package/dist-web/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
  49. package/dist-web/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
  50. package/dist-web/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
  51. package/dist-web/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
  52. package/dist-web/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
  53. package/dist-web/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
  54. package/dist-web/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
  55. package/dist-web/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
  56. package/dist-web/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
  57. package/dist-web/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
  58. package/dist-web/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
  59. package/dist-web/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
  60. package/dist-web/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
  61. package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  62. package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  63. package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-BWTpRfYl.woff2 +0 -0
  64. package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-CEoEElIJ.woff +0 -0
  65. package/dist-web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  66. package/dist-web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  67. package/dist-web/assets/jetbrains-mono-greek-700-normal-C6CZE3T8.woff2 +0 -0
  68. package/dist-web/assets/jetbrains-mono-greek-700-normal-DEigVDxa.woff +0 -0
  69. package/dist-web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  70. package/dist-web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  71. package/dist-web/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
  72. package/dist-web/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
  73. package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  74. package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  75. package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CZipNAKV.woff2 +0 -0
  76. package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CxPITLHs.woff +0 -0
  77. package/dist-web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  78. package/dist-web/assets/jetbrains-mono-vietnamese-700-normal-BDLVIk2r.woff +0 -0
  79. package/dist-web/assets/logo-D4DDtU-r.png +0 -0
  80. package/dist-web/favicon.png +0 -0
  81. package/dist-web/index.html +14 -0
  82. package/package.json +88 -0
  83. package/src/ask-user-buttons.js +142 -0
  84. package/src/claude-transcript.js +203 -0
  85. package/src/cli.js +1040 -0
  86. package/src/codex-event-emitter.js +111 -0
  87. package/src/codex-prompt-detector.js +53 -0
  88. package/src/codex-sidecar.js +52 -0
  89. package/src/codex-transcript.js +74 -0
  90. package/src/config.js +692 -0
  91. package/src/data/claude-code-commands.json +52 -0
  92. package/src/db.js +1503 -0
  93. package/src/dispatch.js +13 -0
  94. package/src/export/todoMarkdown.js +246 -0
  95. package/src/first-run-wizard.js +82 -0
  96. package/src/git/gitStatus.js +139 -0
  97. package/src/lark-api-client.js +205 -0
  98. package/src/lark-bot.js +510 -0
  99. package/src/lark-card.js +88 -0
  100. package/src/lark-config-service.js +16 -0
  101. package/src/lark-event-client.js +107 -0
  102. package/src/lark-image.js +99 -0
  103. package/src/lark-markdown.js +51 -0
  104. package/src/lark-video.js +163 -0
  105. package/src/mcp/audit.js +34 -0
  106. package/src/mcp/server.js +83 -0
  107. package/src/mcp/tools/destructive/index.js +252 -0
  108. package/src/mcp/tools/openclaw/index.js +405 -0
  109. package/src/mcp/tools/read/index.js +269 -0
  110. package/src/mcp/tools/write/index.js +157 -0
  111. package/src/openclaw-bridge.js +566 -0
  112. package/src/openclaw-hook-installer.js +338 -0
  113. package/src/openclaw-hook.js +908 -0
  114. package/src/openclaw-wizard.js +2442 -0
  115. package/src/pending-questions.js +297 -0
  116. package/src/pricing.js +45 -0
  117. package/src/prompt-render.js +36 -0
  118. package/src/pty.js +992 -0
  119. package/src/routes/ai-terminal.js +1228 -0
  120. package/src/routes/git.js +89 -0
  121. package/src/routes/openclaw-hook.js +67 -0
  122. package/src/routes/openclaw-inbound.js +36 -0
  123. package/src/routes/recurringRules.js +80 -0
  124. package/src/routes/reports.js +50 -0
  125. package/src/routes/search.js +46 -0
  126. package/src/routes/stats.js +31 -0
  127. package/src/routes/telegram-config.js +152 -0
  128. package/src/routes/telegram-sync.js +221 -0
  129. package/src/routes/templates.js +63 -0
  130. package/src/routes/todos.js +649 -0
  131. package/src/routes/transcripts.js +75 -0
  132. package/src/routes/uploads.js +107 -0
  133. package/src/routes/wiki.js +142 -0
  134. package/src/search/fts.js +209 -0
  135. package/src/search/index.js +199 -0
  136. package/src/search/transcripts.js +148 -0
  137. package/src/server.js +1791 -0
  138. package/src/session-input-dispatcher.js +256 -0
  139. package/src/stats/markdown.js +42 -0
  140. package/src/stats/report.js +207 -0
  141. package/src/summarize.js +84 -0
  142. package/src/system-rules.js +52 -0
  143. package/src/telegram-bot.js +875 -0
  144. package/src/telegram-commands.js +149 -0
  145. package/src/telegram-config-service.js +84 -0
  146. package/src/telegram-image.js +95 -0
  147. package/src/telegram-loading-status.js +112 -0
  148. package/src/telegram-markdown.js +82 -0
  149. package/src/telegram-reaction-tracker.js +69 -0
  150. package/src/telegram-video.js +75 -0
  151. package/src/templates/claude-hooks/notify.js +103 -0
  152. package/src/transcript.js +305 -0
  153. package/src/transcripts/blocks.js +56 -0
  154. package/src/transcripts/index.js +222 -0
  155. package/src/transcripts/indexer.js +34 -0
  156. package/src/transcripts/matcher.js +70 -0
  157. package/src/transcripts/scanner.js +259 -0
  158. package/src/usage-footer.js +170 -0
  159. package/src/usage-parser.js +132 -0
  160. package/src/wiki/guide.js +44 -0
  161. package/src/wiki/index.js +232 -0
  162. package/src/wiki/redact.js +34 -0
  163. package/src/wiki/sources.js +122 -0
@@ -0,0 +1,149 @@
1
+ /**
2
+ * 构造给 Telegram 的 slash 菜单 —— Claude Code 命令 + 用户/项目/插件自定义命令。
3
+ *
4
+ * 来源(顺序优先):
5
+ * 1. src/data/claude-code-commands.json —— 内置静态清单(curated)
6
+ * 2. ~/.claude/commands/<name>.md —— 用户全局自定义命令
7
+ * 3. <projectRoot>/.claude/commands/<name>.md —— 项目级自定义命令
8
+ * 4. ~/.claude/plugins/cache/<plugin>/<version>/commands/<name>.md —— 插件命令
9
+ *
10
+ * 后面的源会覆盖前面同名命令的 description。
11
+ *
12
+ * Telegram 限制(hard-fail 注册整批,否则只丢这条):
13
+ * - 命令名:^[a-z][a-z0-9_]{0,31}$(**不允许连字符**)
14
+ * - description: 1-256 字符
15
+ * - 最多 100 条
16
+ *
17
+ * 不符合的命令会被静默过滤(warn 一行)。Claude Code 里 `install-github-app`
18
+ * 这种带 `-` 的命令注册不进 Telegram,但用户在 PTY 直接打字仍可用。
19
+ */
20
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
21
+ import { join, basename, extname } from 'node:path'
22
+ import { homedir } from 'node:os'
23
+ import { fileURLToPath } from 'node:url'
24
+ import { dirname } from 'node:path'
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url))
27
+ const STATIC_JSON = join(__dirname, 'data', 'claude-code-commands.json')
28
+
29
+ const TELEGRAM_NAME_RE = /^[a-z][a-z0-9_]{0,31}$/
30
+ const MAX_DESCRIPTION = 256
31
+ const MAX_COMMANDS = 100
32
+
33
+ /** 解析 .md 文件 frontmatter 中的 description(YAML 风格 `description: ...`)。 */
34
+ function parseDescription(filePath) {
35
+ try {
36
+ const text = readFileSync(filePath, 'utf8')
37
+ const m = text.match(/^---\s*\n([\s\S]*?)\n---/)
38
+ if (!m) return null
39
+ const fm = m[1]
40
+ // 简单 YAML 解析:找 description: <value>,支持引号
41
+ const dm = fm.match(/^description:\s*["']?(.+?)["']?\s*$/m)
42
+ return dm ? dm[1].trim() : null
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ /** 扫一个 commands 目录,返回 [{command, description, source}, ...]。 */
49
+ function scanCommandDir(dirPath, source) {
50
+ if (!existsSync(dirPath) || !statSync(dirPath).isDirectory()) return []
51
+ const out = []
52
+ let entries
53
+ try { entries = readdirSync(dirPath) } catch { return [] }
54
+ for (const name of entries) {
55
+ if (!name.endsWith('.md')) continue
56
+ const cmd = basename(name, extname(name))
57
+ const description = parseDescription(join(dirPath, name)) || `Custom command (${source})`
58
+ out.push({ command: cmd, description, source })
59
+ }
60
+ return out
61
+ }
62
+
63
+ /** 找 ~/.claude/plugins/cache 里所有 plugin 的 commands/ 目录。 */
64
+ function findPluginCommandDirs() {
65
+ const root = join(homedir(), '.claude', 'plugins', 'cache')
66
+ if (!existsSync(root)) return []
67
+ const dirs = []
68
+ try {
69
+ // 结构 cache/<owner>/<plugin>/<version>/commands
70
+ for (const owner of readdirSync(root)) {
71
+ const ownerDir = join(root, owner)
72
+ if (!statSync(ownerDir).isDirectory()) continue
73
+ for (const plugin of readdirSync(ownerDir)) {
74
+ const pluginDir = join(ownerDir, plugin)
75
+ if (!statSync(pluginDir).isDirectory()) continue
76
+ for (const ver of readdirSync(pluginDir)) {
77
+ const cmdsDir = join(pluginDir, ver, 'commands')
78
+ if (existsSync(cmdsDir)) dirs.push({ path: cmdsDir, source: `plugin:${plugin}` })
79
+ }
80
+ }
81
+ }
82
+ } catch {}
83
+ return dirs
84
+ }
85
+
86
+ /**
87
+ * 装载静态 JSON 清单。
88
+ * 容错:JSON 损坏 / 缺字段 → 返回空,让上游决定是否报错。
89
+ */
90
+ function loadStatic() {
91
+ try {
92
+ const raw = readFileSync(STATIC_JSON, 'utf8')
93
+ const j = JSON.parse(raw)
94
+ if (Array.isArray(j?.commands)) {
95
+ return j.commands.map((c) => ({ ...c, source: 'builtin' }))
96
+ }
97
+ } catch {}
98
+ return []
99
+ }
100
+
101
+ /**
102
+ * 构造最终 Telegram-ready 命令列表。
103
+ * @param {object} opts
104
+ * @param {string} opts.projectRoot - 项目根目录(用于 .claude/commands)
105
+ * @param {object} [opts.logger]
106
+ * @returns {{commands: Array<{command:string,description:string}>, skipped: Array<{command:string,reason:string}>}}
107
+ */
108
+ export function buildTelegramCommands({ projectRoot = process.cwd(), logger = console } = {}) {
109
+ const all = []
110
+ all.push(...loadStatic())
111
+ all.push(...scanCommandDir(join(homedir(), '.claude', 'commands'), 'user'))
112
+ all.push(...scanCommandDir(join(projectRoot, '.claude', 'commands'), 'project'))
113
+ for (const { path, source } of findPluginCommandDirs()) {
114
+ all.push(...scanCommandDir(path, source))
115
+ }
116
+
117
+ // dedupe by name; later entries (project / plugin) win over builtin
118
+ const map = new Map()
119
+ for (const c of all) {
120
+ if (!c?.command) continue
121
+ map.set(c.command, c)
122
+ }
123
+
124
+ const accepted = []
125
+ const skipped = []
126
+ for (const c of map.values()) {
127
+ if (!TELEGRAM_NAME_RE.test(c.command)) {
128
+ skipped.push({ command: c.command, reason: 'invalid_name (telegram requires [a-z0-9_])' })
129
+ continue
130
+ }
131
+ let desc = String(c.description || '').trim()
132
+ if (!desc) {
133
+ skipped.push({ command: c.command, reason: 'empty_description' })
134
+ continue
135
+ }
136
+ if (desc.length > MAX_DESCRIPTION) desc = desc.slice(0, MAX_DESCRIPTION - 1) + '…'
137
+ accepted.push({ command: c.command, description: desc })
138
+ }
139
+
140
+ if (accepted.length > MAX_COMMANDS) {
141
+ skipped.push(...accepted.slice(MAX_COMMANDS).map((c) => ({ command: c.command, reason: 'over_100_limit' })))
142
+ accepted.length = MAX_COMMANDS
143
+ }
144
+
145
+ if (skipped.length && logger?.warn) {
146
+ logger.warn(`[telegram-commands] skipped ${skipped.length} command(s): ${skipped.slice(0, 6).map((s) => `${s.command}(${s.reason})`).join(', ')}${skipped.length > 6 ? '…' : ''}`)
147
+ }
148
+ return { commands: accepted, skipped }
149
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Telegram 配置辅助:
3
+ * - maskBotToken / isMaskedToken:UI 上 token 的遮罩与回显检测
4
+ *
5
+ * 跟 telegram-bot.js 解耦,所有 IO 由 caller 注入。
6
+ */
7
+
8
+ const MASK_PREFIX = 'tg_***'
9
+
10
+ /**
11
+ * 把真实 token 转成展示串:tg_***末四位。null/空返回 null。
12
+ */
13
+ export function maskBotToken(token) {
14
+ if (!token || typeof token !== 'string') return null
15
+ const tail = token.length >= 4 ? token.slice(-4) : token
16
+ return MASK_PREFIX + tail
17
+ }
18
+
19
+ /**
20
+ * 判断字符串是不是 mask 格式(用户在 UI 没改 token 时回传的就是 mask)。
21
+ */
22
+ export function isMaskedToken(value) {
23
+ if (!value || typeof value !== 'string') return false
24
+ return value.startsWith(MASK_PREFIX)
25
+ }
26
+
27
+ /**
28
+ * Probe 状态机:startProbe(durationSec) 后,record(hit) 会写到 buffer 并通知订阅者。
29
+ * 同一时刻只能有一个活跃 probe(second startProbe 会失败)。
30
+ *
31
+ * 时间通过 now() 注入,便于测试。
32
+ *
33
+ * 返回:{ startProbe, stopProbe, record, subscribe, isActive, snapshot }
34
+ */
35
+ export function createProbeRegistry({ now = () => Date.now() } = {}) {
36
+ let expiresAt = 0
37
+ let hits = []
38
+ const subscribers = new Set()
39
+
40
+ function isActive() {
41
+ return now() < expiresAt
42
+ }
43
+
44
+ function startProbe(durationSec) {
45
+ if (isActive()) return { ok: false, reason: 'already_active' }
46
+ const clamped = Math.min(120, Math.max(10, Number(durationSec) || 60))
47
+ expiresAt = now() + clamped * 1000
48
+ hits = []
49
+ return { ok: true, durationSec: clamped, expiresAt }
50
+ }
51
+
52
+ function stopProbe() {
53
+ expiresAt = 0
54
+ hits = []
55
+ for (const fn of subscribers) {
56
+ try { fn(null) } catch {}
57
+ }
58
+ }
59
+
60
+ function record(hit) {
61
+ if (!isActive()) return false
62
+ const entry = { ...hit, at: now() }
63
+ hits.push(entry)
64
+ for (const fn of subscribers) {
65
+ try { fn(entry) } catch {}
66
+ }
67
+ return true
68
+ }
69
+
70
+ function subscribe(fn) {
71
+ subscribers.add(fn)
72
+ return () => subscribers.delete(fn)
73
+ }
74
+
75
+ function snapshot() {
76
+ return {
77
+ active: isActive(),
78
+ expiresAt,
79
+ hits: [...hits],
80
+ }
81
+ }
82
+
83
+ return { startProbe, stopProbe, record, subscribe, isActive, snapshot }
84
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * 把 Telegram 入站图片下载到本地,让 PTY 写 `@<path>` 喂给 Claude Code 做 attach。
3
+ *
4
+ * 流程:
5
+ * 1) callApi('getFile', { file_id }) → 拿 file_path(telegram 服务器上的相对路径)
6
+ * 2) GET https://api.telegram.org/file/bot<TOKEN>/<file_path> → 下载二进制
7
+ * 3) 写到 destDir,返回 { localPath, fileSize }
8
+ *
9
+ * 默认存到 ~/.agentquad/tg-uploads/<ts>-<rand>.<ext>,不主动清理(量级小,磁盘占用可忽略)
10
+ */
11
+ import { mkdirSync, writeFileSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+ import { Buffer } from 'node:buffer'
14
+ import { DEFAULT_ROOT_DIR } from './config.js'
15
+
16
+ const DEFAULT_DIR = join(DEFAULT_ROOT_DIR, 'tg-uploads')
17
+ const DOWNLOAD_TIMEOUT_MS = 30_000
18
+ const MAX_PHOTO_SIZE_MB = 20 // Telegram 限制 ~20MB
19
+
20
+ /**
21
+ * Telegram message.photo 是 array of PhotoSize,按分辨率从小到大排
22
+ * 选最大那张(通常 width >= 1280 的原图)
23
+ */
24
+ export function pickLargestPhoto(photos) {
25
+ if (!Array.isArray(photos) || photos.length === 0) return null
26
+ return photos.reduce((acc, p) =>
27
+ (p?.file_size || 0) > (acc?.file_size || 0) ? p : acc, photos[0])
28
+ }
29
+
30
+ /**
31
+ * @param {object} opts
32
+ * @param opts.token Telegram bot token
33
+ * @param opts.fetchFn (url, opts) => Response(默认全局 fetch;测试可注入)
34
+ * @param opts.fileId Telegram file_id
35
+ * @param opts.destDir 下载目标目录(默认 ~/.agentquad/tg-uploads)
36
+ * @param opts.fileSize 可选,预先校验 ≤ 20MB
37
+ * @returns {{ localPath: string, fileSize: number, ext: string }}
38
+ */
39
+ export async function downloadTelegramFile({
40
+ token,
41
+ fetchFn,
42
+ fileId,
43
+ destDir = DEFAULT_DIR,
44
+ fileSize = null,
45
+ } = {}) {
46
+ if (!token) throw new Error('token_required')
47
+ if (!fileId) throw new Error('fileId_required')
48
+ if (fileSize && fileSize > MAX_PHOTO_SIZE_MB * 1024 * 1024) {
49
+ throw new Error(`file_too_large: ${(fileSize / 1024 / 1024).toFixed(1)}MB > ${MAX_PHOTO_SIZE_MB}MB`)
50
+ }
51
+ const fetcher = fetchFn || fetch
52
+
53
+ // 1. getFile → file_path
54
+ const ctrl1 = new AbortController()
55
+ const t1 = setTimeout(() => ctrl1.abort(), DOWNLOAD_TIMEOUT_MS)
56
+ let filePath
57
+ try {
58
+ const resp = await fetcher(`https://api.telegram.org/bot${token}/getFile`, {
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/json' },
61
+ body: JSON.stringify({ file_id: fileId }),
62
+ signal: ctrl1.signal,
63
+ })
64
+ const data = await resp.json().catch(() => null)
65
+ if (!data?.ok || !data.result?.file_path) {
66
+ throw new Error(`getFile_failed: ${data?.description || resp.status}`)
67
+ }
68
+ filePath = data.result.file_path
69
+ } finally {
70
+ clearTimeout(t1)
71
+ }
72
+
73
+ // 2. download binary
74
+ const ctrl2 = new AbortController()
75
+ const t2 = setTimeout(() => ctrl2.abort(), DOWNLOAD_TIMEOUT_MS)
76
+ let buf
77
+ try {
78
+ const resp = await fetcher(`https://api.telegram.org/file/bot${token}/${filePath}`, {
79
+ signal: ctrl2.signal,
80
+ })
81
+ if (!resp.ok) throw new Error(`download_failed: HTTP ${resp.status}`)
82
+ const ab = await resp.arrayBuffer()
83
+ buf = Buffer.from(ab)
84
+ } finally {
85
+ clearTimeout(t2)
86
+ }
87
+
88
+ // 3. write to disk
89
+ mkdirSync(destDir, { recursive: true })
90
+ const ext = (filePath.split('.').pop() || 'bin').toLowerCase()
91
+ const localName = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`
92
+ const localPath = join(destDir, localName)
93
+ writeFileSync(localPath, buf)
94
+ return { localPath, fileSize: buf.length, ext }
95
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * 只做一件事:在 PTY session **终态** 时改 telegram topic 标题前缀。
3
+ *
4
+ * done → ✅ <name> (PTY exit 0)
5
+ * failed → ❌ <name> (PTY exit ≠ 0)
6
+ * stopped → ⏹ <name> (用户主动 stop)
7
+ *
8
+ * running / idle 状态由 src/telegram-reaction-tracker.js 通过给用户消息加/删
9
+ * ✍ reaction 表达 —— 那条路径粒度更细、节流压力更小,留这里只管终态。
10
+ *
11
+ * 限速防御:
12
+ * - 全局 backoff(429)保留,但终态硬上,不受 backoff 影响 —— ✅/❌/⏹
13
+ * 是用户最在意的状态,必须显示。
14
+ *
15
+ * 为了向后兼容,markIdle / markRunning / start 接口保留但 running/idle 改为 no-op。
16
+ */
17
+
18
+ const TITLE_PREFIX_BY_PHASE = {
19
+ done: '✅ ',
20
+ failed: '❌ ',
21
+ stopped: '⏹ ',
22
+ }
23
+
24
+ const TERMINAL_PHASES = new Set(['done', 'failed', 'stopped'])
25
+
26
+ /**
27
+ * @param {object} opts
28
+ * @param opts.telegramBot { editForumTopic({chatId,threadId,name}) }
29
+ * @param opts.openclaw { resolveRoute(sessionId) → {targetUserId, threadId, topicName} | null }
30
+ * @param opts.logger
31
+ * @param opts.now 可注入时钟(测试用)
32
+ */
33
+ export function createLoadingTracker({
34
+ telegramBot,
35
+ openclaw,
36
+ logger = console,
37
+ now = () => Date.now(),
38
+ getConfig = null,
39
+ } = {}) {
40
+ if (!telegramBot) throw new Error('telegramBot_required')
41
+ void now; void getConfig
42
+
43
+ // sessionId → { chatId, threadId, originalTopicName }
44
+ const sessions = new Map()
45
+
46
+ function parseRetryAfter(desc) {
47
+ const m = String(desc || '').match(/retry after (\d+)/i)
48
+ return m ? Number(m[1]) : 0
49
+ }
50
+
51
+ async function renameTerminal(state, phase) {
52
+ if (!telegramBot.editForumTopic || !state.originalTopicName) return
53
+ const prefix = TITLE_PREFIX_BY_PHASE[phase]
54
+ if (!prefix) return
55
+ const newName = (prefix + state.originalTopicName).slice(0, 128)
56
+ try {
57
+ await telegramBot.editForumTopic({
58
+ chatId: state.chatId,
59
+ threadId: state.threadId,
60
+ name: newName,
61
+ })
62
+ } catch (e) {
63
+ const desc = e?.description || e?.message || ''
64
+ const retryAfter = parseRetryAfter(desc) || (e?.parameters?.retry_after) || 0
65
+ if (/too many requests|429/i.test(desc) || retryAfter > 0) {
66
+ // 终态硬上:不再阻塞下一次终态 rename,仅 log
67
+ logger.warn?.(`[loading-status] terminal rename hit 429 sid=${state.sessionId} retry_after=${retryAfter || '?'}s`)
68
+ return
69
+ }
70
+ if (!/not[ _]modified/i.test(desc)) {
71
+ logger.warn?.(`[loading-status] editForumTopic phase=${phase} failed sid=${state.sessionId}: ${desc}`)
72
+ }
73
+ }
74
+ }
75
+
76
+ /**
77
+ * 注册 session(PTY native-session 时 server.js 调)。不再发任何 rename,
78
+ * 只把 originalTopicName 记下来给后续 stop 用。
79
+ * skipTitleRename 现在不影响行为(保留参数避免破坏 caller)。
80
+ */
81
+ async function start({ sessionId, skipTitleRename = false } = {}) {
82
+ if (!sessionId || sessions.has(sessionId)) return
83
+ void skipTitleRename
84
+ const route = openclaw?.resolveRoute?.(sessionId)
85
+ if (!route?.threadId) return
86
+ if (!route.topicName) return
87
+ sessions.set(sessionId, {
88
+ sessionId,
89
+ chatId: String(route.targetUserId),
90
+ threadId: route.threadId,
91
+ originalTopicName: route.topicName,
92
+ })
93
+ }
94
+
95
+ // running / idle 由 reaction-tracker 处理;这两个接口保留向后兼容,但改为 no-op
96
+ async function markIdle(_sessionId) { /* no-op */ }
97
+ async function markRunning(_sessionId) { /* no-op */ }
98
+
99
+ async function stop({ sessionId, finalStatus = 'done' } = {}) {
100
+ const state = sessions.get(sessionId)
101
+ if (!state) return
102
+ sessions.delete(sessionId)
103
+ if (TERMINAL_PHASES.has(finalStatus)) {
104
+ await renameTerminal(state, finalStatus)
105
+ }
106
+ }
107
+
108
+ function has(sessionId) { return sessions.has(sessionId) }
109
+ function size() { return sessions.size }
110
+
111
+ return { start, stop, markIdle, markRunning, has, size, __test__: { sessions } }
112
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 把 LLM 风格的 Markdown(标题、粗体、列表、code block)转成 Telegram MarkdownV2。
3
+ *
4
+ * 为什么用 V2 而不是 legacy:
5
+ * - legacy Markdown:标题 / 列表无渲染;`**bold**` 字面显示
6
+ * - MarkdownV2:标题→粗体、`**`→`*`、`-`→`•`、code block 内不转义
7
+ * 但 V2 的副作用是正文里的 `_*[]()~`>#+-=|{}.!` 都要转义,错一个就 parse fail。
8
+ * 所以走 `telegramify-markdown` 这个专门给 LLM 输出做的转换器。
9
+ *
10
+ * 用法:在所有 sendMessage / sendDocument(caption) 入口的 text 上跑一遍。
11
+ * parseMode 同步切到 'MarkdownV2'。
12
+ */
13
+ import telegramifyMarkdown from 'telegramify-markdown'
14
+
15
+ /**
16
+ * 把 markdown 表格行(连续 ≥2 行 `|...|`)包进 ``` 代码块。
17
+ * 原因:Telegram MarkdownV2 没有 table 渲染,但 fenced code block 能用等宽字体保留对齐
18
+ * —— 所以我们把表格"伪装"成 code block 让 telegramify 后续按 pre 出。
19
+ */
20
+ function wrapTablesAsCodeBlock(text) {
21
+ const lines = String(text).split('\n')
22
+ const out = []
23
+ let buf = []
24
+ const isTableLine = (l) => /^\s*\|.*\|\s*$/.test(l)
25
+ const flush = () => {
26
+ if (buf.length >= 2) {
27
+ out.push('```')
28
+ for (const l of buf) out.push(l)
29
+ out.push('```')
30
+ } else {
31
+ for (const l of buf) out.push(l)
32
+ }
33
+ buf = []
34
+ }
35
+ for (const line of lines) {
36
+ if (isTableLine(line)) {
37
+ buf.push(line)
38
+ } else {
39
+ if (buf.length) flush()
40
+ out.push(line)
41
+ }
42
+ }
43
+ if (buf.length) flush()
44
+ return out.join('\n')
45
+ }
46
+
47
+ /**
48
+ * 转成 V2-safe 文本。空/非字符串原样返回。库异常时也回退原文,
49
+ * 让上游 sendMessage 的 plain-text fallback 兜底。
50
+ *
51
+ * 表格预处理:先把 markdown 表格包进 ``` code block,让 Telegram 用等宽渲染保留对齐。
52
+ */
53
+ export function toTelegramV2(text) {
54
+ if (!text || typeof text !== 'string') return text
55
+ try {
56
+ const tablesAsPre = wrapTablesAsCodeBlock(text)
57
+ // telegramify 会在末尾加一个 '\n' —— 去掉,免得每条消息多一个空行
58
+ return telegramifyMarkdown(tablesAsPre, 'escape').replace(/\n$/, '')
59
+ } catch {
60
+ return text
61
+ }
62
+ }
63
+
64
+ /**
65
+ * V2 解析失败的兜底用:把 markdown 标记**删掉**,正文保留。
66
+ * 比直接发 raw text(满屏 #### / ** / >)干净得多。
67
+ *
68
+ * telegramify 'remove' 只剥 HTML 这种 unsupported tag,markdown 标记会被转成 V2
69
+ * syntax(*..*、>..),在 plain-text 模式下还是字面字符 — 所以这里直接做正则清洗。
70
+ *
71
+ * 注意:inline code(`code`)的 backticks **保留**——plain text 模式下没法显示
72
+ * 高亮,但留着 backticks 用户至少能识别"这是代码"。比把 `code` 剥成 code 强。
73
+ */
74
+ export function toPlainText(text) {
75
+ if (!text || typeof text !== 'string') return text
76
+ return String(text)
77
+ .replace(/^#{1,6}\s+/gm, '') // # / ## / #### 等标题前缀
78
+ .replace(/\*\*(.+?)\*\*/g, '$1') // **bold**
79
+ .replace(/(?<!\w)_([^_\n]+)_(?!\w)/g, '$1') // _italic_
80
+ // inline code `code` 不剥 backticks(保留视觉提示)
81
+ .replace(/^>\s?/gm, '') // > blockquote 前缀
82
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Telegram message reaction 跟踪器:
3
+ * - 用户每发一条触发 PTY 的消息,加 ✍ reaction
4
+ * - PTY Stop hook(一轮回复完成)→ 清掉这个 session 期间所有 ✍
5
+ *
6
+ * 跟 lark-bot.pendingReactions 对称;Telegram 这边 setMessageReaction 是覆盖式
7
+ * (传空数组 = 清除),不需要存 reaction_id,只记 (chatId, messageId)。
8
+ */
9
+
10
+ const DEFAULT_RUNNING_EMOJI = '✍'
11
+
12
+ export function createReactionTracker({
13
+ telegramBot,
14
+ getConfig = () => ({}),
15
+ logger = console,
16
+ } = {}) {
17
+ if (!telegramBot) throw new Error('telegramBot_required')
18
+
19
+ // sessionId → [{ chatId, messageId }]
20
+ const sessions = new Map()
21
+
22
+ function getCfg() {
23
+ return getConfig()?.telegram || {}
24
+ }
25
+
26
+ function isEnabled() {
27
+ const v = getCfg().reactionEnabled
28
+ return v !== false
29
+ }
30
+
31
+ function runningEmoji() {
32
+ return getCfg().reactionRunningEmoji || DEFAULT_RUNNING_EMOJI
33
+ }
34
+
35
+ async function noteUserMessage({ sessionId, chatId, messageId } = {}) {
36
+ if (!sessionId || !chatId || !messageId) return
37
+ if (!isEnabled()) return
38
+ const list = sessions.get(sessionId) || []
39
+ list.push({ chatId: String(chatId), messageId })
40
+ sessions.set(sessionId, list)
41
+ try {
42
+ await telegramBot.setMessageReaction({ chatId, messageId, emoji: runningEmoji() })
43
+ } catch (e) {
44
+ logger.warn?.(`[reaction-tracker] note failed sid=${sessionId} msg=${messageId}: ${e.message}`)
45
+ }
46
+ }
47
+
48
+ async function clearReactionsForSession(sessionId) {
49
+ if (!sessionId) return { ok: true, removed: 0 }
50
+ const list = sessions.get(sessionId)
51
+ sessions.delete(sessionId)
52
+ if (!list || list.length === 0) return { ok: true, removed: 0 }
53
+ let removed = 0
54
+ for (const { chatId, messageId } of list) {
55
+ try {
56
+ await telegramBot.setMessageReaction({ chatId, messageId, emoji: null })
57
+ removed++
58
+ } catch (e) {
59
+ logger.warn?.(`[reaction-tracker] clear failed sid=${sessionId} msg=${messageId}: ${e.message}`)
60
+ }
61
+ }
62
+ return { ok: true, removed, total: list.length }
63
+ }
64
+
65
+ function has(sessionId) { return sessions.has(sessionId) }
66
+ function size() { return sessions.size }
67
+
68
+ return { noteUserMessage, clearReactionsForSession, has, size, __test__: { sessions } }
69
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Telegram 视频入站:跟 telegram-image 走同一条 getFile + 下载流水线,
3
+ * 但兼容多种载体:
4
+ * - msg.video 普通视频(mp4/mov…)
5
+ * - msg.video_note 圆形短视频(≤1min)
6
+ * - msg.animation GIF / 静音 mp4
7
+ * - msg.document mime_type 以 video/ 开头的文件
8
+ *
9
+ * 所有文件统一过 Bot API 的 20MB 上限校验(复用 telegram-image 的下载函数)。
10
+ *
11
+ * 不处理:audio / voice / sticker / 非视频 document。
12
+ */
13
+ import { downloadTelegramFile } from './telegram-image.js'
14
+
15
+ /**
16
+ * 从 Telegram message 里挑出"视频载体"。返回 null 表示该消息没有视频。
17
+ * @returns {{ fileId: string, fileSize: number|null, fileName: string|null, kind: string } | null}
18
+ */
19
+ export function extractTelegramVideo(msg = {}) {
20
+ if (!msg || typeof msg !== 'object') return null
21
+
22
+ // video 是单个对象(不是 photo 那样的数组)
23
+ if (msg.video?.file_id) {
24
+ return {
25
+ fileId: msg.video.file_id,
26
+ fileSize: msg.video.file_size || null,
27
+ fileName: msg.video.file_name || null,
28
+ kind: 'video',
29
+ }
30
+ }
31
+
32
+ if (msg.video_note?.file_id) {
33
+ return {
34
+ fileId: msg.video_note.file_id,
35
+ fileSize: msg.video_note.file_size || null,
36
+ fileName: null, // video_note 没有 file_name
37
+ kind: 'video_note',
38
+ }
39
+ }
40
+
41
+ if (msg.animation?.file_id) {
42
+ return {
43
+ fileId: msg.animation.file_id,
44
+ fileSize: msg.animation.file_size || null,
45
+ fileName: msg.animation.file_name || null,
46
+ kind: 'animation',
47
+ }
48
+ }
49
+
50
+ // document 包了一层 mime_type;只在 video/* 时认领
51
+ if (msg.document?.file_id) {
52
+ const mime = String(msg.document.mime_type || '').toLowerCase()
53
+ if (mime.startsWith('video/')) {
54
+ return {
55
+ fileId: msg.document.file_id,
56
+ fileSize: msg.document.file_size || null,
57
+ fileName: msg.document.file_name || null,
58
+ kind: 'document_video',
59
+ }
60
+ }
61
+ }
62
+
63
+ return null
64
+ }
65
+
66
+ /**
67
+ * 复用 telegram-image 的下载函数。这里单独出一个 thin wrapper,
68
+ * 便于将来给视频独立调参(比如更大的超时、不同目录),现在保持一致。
69
+ *
70
+ * @param opts.token / opts.fetchFn / opts.fileId / opts.fileSize / opts.destDir 同 downloadTelegramFile
71
+ * @returns {Promise<{ localPath: string, fileSize: number, ext: string }>}
72
+ */
73
+ export async function downloadTelegramVideo(opts = {}) {
74
+ return downloadTelegramFile(opts)
75
+ }