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,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
|
+
}
|