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,107 @@
|
|
|
1
|
+
import * as Lark from '@larksuiteoapi/node-sdk'
|
|
2
|
+
|
|
3
|
+
function isBlank(value) {
|
|
4
|
+
return value == null || String(value) === ''
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function normalizeError(error) {
|
|
8
|
+
return error?.message || error?.description || String(error)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function defaultDispatcherFactory() {
|
|
12
|
+
return new Lark.EventDispatcher({})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function defaultWsClientFactory({ appId, appSecret }) {
|
|
16
|
+
return new Lark.WSClient({
|
|
17
|
+
appId,
|
|
18
|
+
appSecret,
|
|
19
|
+
appType: Lark.AppType.SelfBuild,
|
|
20
|
+
loggerLevel: Lark.LoggerLevel?.info,
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createLarkEventClient({
|
|
25
|
+
appId,
|
|
26
|
+
appSecret,
|
|
27
|
+
onEvent,
|
|
28
|
+
onCardAction = null,
|
|
29
|
+
dispatcherFactory = defaultDispatcherFactory,
|
|
30
|
+
wsClientFactory = defaultWsClientFactory,
|
|
31
|
+
logger = console,
|
|
32
|
+
} = {}) {
|
|
33
|
+
if (typeof onEvent !== 'function') throw new Error('onEvent_required')
|
|
34
|
+
|
|
35
|
+
let wsClient = null
|
|
36
|
+
let running = false
|
|
37
|
+
let lastReason = null
|
|
38
|
+
let lastDetail = null
|
|
39
|
+
|
|
40
|
+
function hasCredentials() {
|
|
41
|
+
return !isBlank(appId) && !isBlank(appSecret)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function start() {
|
|
45
|
+
if (!hasCredentials()) {
|
|
46
|
+
lastReason = 'lark_credentials_missing'
|
|
47
|
+
lastDetail = null
|
|
48
|
+
return { ok: false, reason: 'lark_credentials_missing' }
|
|
49
|
+
}
|
|
50
|
+
if (running) return { ok: true, action: 'already_running' }
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const handlers = {
|
|
54
|
+
'im.message.receive_v1': async (data) => {
|
|
55
|
+
try {
|
|
56
|
+
await onEvent(data)
|
|
57
|
+
} catch (e) {
|
|
58
|
+
const detail = normalizeError(e)
|
|
59
|
+
logger.warn?.(`[lark-event] lark_event_handler_failed: ${detail}`)
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
if (typeof onCardAction === 'function') {
|
|
64
|
+
handlers['card.action.trigger'] = async (data) => {
|
|
65
|
+
try {
|
|
66
|
+
return await onCardAction(data)
|
|
67
|
+
} catch (e) {
|
|
68
|
+
const detail = normalizeError(e)
|
|
69
|
+
logger.warn?.(`[lark-event] lark_card_action_handler_failed: ${detail}`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const eventDispatcher = dispatcherFactory().register(handlers)
|
|
74
|
+
wsClient = wsClientFactory({ appId, appSecret })
|
|
75
|
+
wsClient.start({ eventDispatcher })
|
|
76
|
+
running = true
|
|
77
|
+
lastReason = null
|
|
78
|
+
lastDetail = null
|
|
79
|
+
return { ok: true, action: 'started' }
|
|
80
|
+
} catch (e) {
|
|
81
|
+
running = false
|
|
82
|
+
wsClient = null
|
|
83
|
+
lastReason = 'lark_ws_start_failed'
|
|
84
|
+
lastDetail = normalizeError(e)
|
|
85
|
+
logger.warn?.(`[lark-event] websocket start failed: ${lastDetail}`)
|
|
86
|
+
return { ok: false, reason: lastReason, detail: lastDetail }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function stop() {
|
|
91
|
+
running = false
|
|
92
|
+
const current = wsClient
|
|
93
|
+
wsClient = null
|
|
94
|
+
if (current?.stop) await current.stop()
|
|
95
|
+
return { ok: true }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function describe() {
|
|
99
|
+
return {
|
|
100
|
+
running,
|
|
101
|
+
reason: lastReason,
|
|
102
|
+
detail: lastDetail,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { start, stop, describe }
|
|
107
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把飞书入站图片下载到本地,让 PTY stdin 写 `@<path>` 喂给 Claude Code attach。
|
|
3
|
+
*
|
|
4
|
+
* 流程:
|
|
5
|
+
* 1) lark-api-client.getMessageResource({messageId, fileKey, type:'image'}) → SDK
|
|
6
|
+
* 返回 { writeFile, getReadableStream, headers }
|
|
7
|
+
* 2) 根据 headers['content-type'] 推扩展名(image/png → png, image/jpeg → jpg, ...)
|
|
8
|
+
* 3) writeFile 落到 ~/.agentquad/lark-uploads/<ts>-<rand>.<ext>
|
|
9
|
+
* 4) 返回本地绝对路径
|
|
10
|
+
*
|
|
11
|
+
* 不主动清理:磁盘占用可忽略(图片量级一般几百 KB)。
|
|
12
|
+
*/
|
|
13
|
+
import { mkdirSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { DEFAULT_ROOT_DIR } from './config.js'
|
|
16
|
+
|
|
17
|
+
const DEFAULT_DIR = join(DEFAULT_ROOT_DIR, 'lark-uploads')
|
|
18
|
+
|
|
19
|
+
const CONTENT_TYPE_TO_EXT = {
|
|
20
|
+
'image/png': 'png',
|
|
21
|
+
'image/jpeg': 'jpg',
|
|
22
|
+
'image/jpg': 'jpg',
|
|
23
|
+
'image/gif': 'gif',
|
|
24
|
+
'image/webp': 'webp',
|
|
25
|
+
'image/svg+xml': 'svg',
|
|
26
|
+
'image/bmp': 'bmp',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function extFromContentType(headers) {
|
|
30
|
+
const ct = String(
|
|
31
|
+
headers?.['content-type']
|
|
32
|
+
|| headers?.['Content-Type']
|
|
33
|
+
|| ''
|
|
34
|
+
).toLowerCase()
|
|
35
|
+
for (const [type, ext] of Object.entries(CONTENT_TYPE_TO_EXT)) {
|
|
36
|
+
if (ct.includes(type)) return ext
|
|
37
|
+
}
|
|
38
|
+
return 'bin'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param opts.apiClient lark-api-client 实例(或类似 shape:必须有 getMessageResource)
|
|
43
|
+
* @param opts.messageId 飞书 message_id(图片所在的消息)
|
|
44
|
+
* @param opts.imageKey content.image_key(普通 image 消息)或 post 里 img 节点的 image_key
|
|
45
|
+
* @param opts.destDir 目标目录(默认 ~/.agentquad/lark-uploads)
|
|
46
|
+
* @returns {Promise<{ ok: true, localPath } | { ok: false, reason, detail? }>}
|
|
47
|
+
*/
|
|
48
|
+
export async function downloadLarkImage({ apiClient, messageId, imageKey, destDir = DEFAULT_DIR } = {}) {
|
|
49
|
+
if (!apiClient?.getMessageResource) return { ok: false, reason: 'apiClient_required' }
|
|
50
|
+
if (!messageId) return { ok: false, reason: 'messageId_required' }
|
|
51
|
+
if (!imageKey) return { ok: false, reason: 'imageKey_required' }
|
|
52
|
+
|
|
53
|
+
const r = await apiClient.getMessageResource({ messageId, fileKey: imageKey, type: 'image' })
|
|
54
|
+
if (!r?.ok) return { ok: false, reason: r?.reason || 'lark_resource_failed', detail: r?.detail }
|
|
55
|
+
if (typeof r.writeFile !== 'function') return { ok: false, reason: 'no_writefile' }
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
mkdirSync(destDir, { recursive: true })
|
|
59
|
+
} catch (e) {
|
|
60
|
+
return { ok: false, reason: 'mkdir_failed', detail: e.message }
|
|
61
|
+
}
|
|
62
|
+
const ext = extFromContentType(r.headers || {})
|
|
63
|
+
const localName = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`
|
|
64
|
+
const localPath = join(destDir, localName)
|
|
65
|
+
try {
|
|
66
|
+
await r.writeFile(localPath)
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return { ok: false, reason: 'write_failed', detail: e.message }
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, localPath }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 从飞书 message 提取所有 image_key。
|
|
75
|
+
* 普通 image 消息:content.image_key。
|
|
76
|
+
* post 富文本:content.content[][].tag === 'img' 节点的 image_key。
|
|
77
|
+
*/
|
|
78
|
+
export function extractImageKeys(message = {}) {
|
|
79
|
+
const keys = []
|
|
80
|
+
let content = message.content
|
|
81
|
+
if (typeof content === 'string') {
|
|
82
|
+
try { content = JSON.parse(content) } catch { content = null }
|
|
83
|
+
}
|
|
84
|
+
if (!content || typeof content !== 'object') return keys
|
|
85
|
+
if (typeof content.image_key === 'string' && content.image_key) {
|
|
86
|
+
keys.push(content.image_key)
|
|
87
|
+
}
|
|
88
|
+
if (Array.isArray(content.content)) {
|
|
89
|
+
for (const line of content.content) {
|
|
90
|
+
if (!Array.isArray(line)) continue
|
|
91
|
+
for (const node of line) {
|
|
92
|
+
if (node?.tag === 'img' && typeof node.image_key === 'string' && node.image_key) {
|
|
93
|
+
keys.push(node.image_key)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return keys
|
|
99
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 飞书 text 消息不渲染 markdown,把常见 markdown 语法降级为可读纯文本。
|
|
3
|
+
* 主线场景:把 LLM/AgentQuad 输出的 markdown 长文本干净地推到飞书 thread。
|
|
4
|
+
*
|
|
5
|
+
* 处理范围(按顺序):
|
|
6
|
+
* - 代码块 ```lang ... ``` → 去掉栅栏,保留内容
|
|
7
|
+
* - 图片  → 删掉
|
|
8
|
+
* - 链接 [text](url) → "text (url)",text==url 时只保留 url
|
|
9
|
+
* - 标题 #..###### → 去掉 # 前缀
|
|
10
|
+
* - 引用 > → 去掉 > 前缀
|
|
11
|
+
* - 粗体 **x** / __x__ → x
|
|
12
|
+
* - 斜体 *x* / _x_ → x(用前后界判断避免吃 list bullet)
|
|
13
|
+
* - 删除线 ~~x~~ → x
|
|
14
|
+
* - 水平线 --- / *** / ___ → ——————
|
|
15
|
+
* - 转义 \* \_ \` 等 → * _ `
|
|
16
|
+
*
|
|
17
|
+
* 不处理:
|
|
18
|
+
* - inline code `code` 保留 backticks(视觉提示),飞书原样显示
|
|
19
|
+
* - 表格 | a | b | 保留原样(飞书不渲染但能读)
|
|
20
|
+
* - 列表标记 - / * / 1. 保留
|
|
21
|
+
*/
|
|
22
|
+
export function toLarkText(text) {
|
|
23
|
+
if (text == null) return ''
|
|
24
|
+
if (typeof text !== 'string') return String(text)
|
|
25
|
+
let out = text
|
|
26
|
+
// 代码块(带或不带语言)
|
|
27
|
+
out = out.replace(/```[a-zA-Z0-9_+-]*\n?([\s\S]*?)```/g, '$1')
|
|
28
|
+
// 图片
|
|
29
|
+
out = out.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
|
|
30
|
+
// 链接
|
|
31
|
+
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, url) =>
|
|
32
|
+
label === url ? url : `${label} (${url})`
|
|
33
|
+
)
|
|
34
|
+
// 标题(行首 1-6 个 #)
|
|
35
|
+
out = out.replace(/^#{1,6}\s+/gm, '')
|
|
36
|
+
// 引用
|
|
37
|
+
out = out.replace(/^>\s?/gm, '')
|
|
38
|
+
// 粗体 **x** / __x__
|
|
39
|
+
out = out.replace(/\*\*([^*\n]+?)\*\*/g, '$1')
|
|
40
|
+
out = out.replace(/__([^_\n]+?)__/g, '$1')
|
|
41
|
+
// 斜体 *x* / _x_:用 lookbehind 避免吃 list bullet 和反斜杠转义的 *
|
|
42
|
+
out = out.replace(/(?<![\\*\w])\*([^*\n]+?)(?<!\\)\*(?!\w)/g, '$1')
|
|
43
|
+
out = out.replace(/(?<![\\_\w])_([^_\n]+?)(?<!\\)_(?!\w)/g, '$1')
|
|
44
|
+
// 删除线
|
|
45
|
+
out = out.replace(/~~([^~\n]+?)~~/g, '$1')
|
|
46
|
+
// 水平线(整行只有 --- / *** / ___ 至少 3 个)
|
|
47
|
+
out = out.replace(/^\s*([-*_])\1{2,}\s*$/gm, '——————————')
|
|
48
|
+
// backslash 转义
|
|
49
|
+
out = out.replace(/\\([\\*_`~\[\]()#+\-.!|>])/g, '$1')
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 飞书视频入站:跟 lark-image 类似,但用 getMessageResource(type:'file')。
|
|
3
|
+
*
|
|
4
|
+
* 实测飞书发视频时,事件 shape 是:
|
|
5
|
+
* msg_type: 'post'
|
|
6
|
+
* content: { title:'', content: [[{ tag:'media', file_key:'file_v3_xxx', image_key:'img_v3_xxx' }]] }
|
|
7
|
+
*
|
|
8
|
+
* 也可能(理论上 / 其他版本)出现:
|
|
9
|
+
* msg_type: 'media' / 'video', 顶层 content.file_key
|
|
10
|
+
* msg_type: 'file', content.file_key + file_name 后缀是视频
|
|
11
|
+
*
|
|
12
|
+
* extractVideoFileKey 把以上几种 shape 都覆盖。
|
|
13
|
+
*/
|
|
14
|
+
import { mkdirSync } from 'node:fs'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { DEFAULT_ROOT_DIR } from './config.js'
|
|
17
|
+
|
|
18
|
+
const DEFAULT_DIR = join(DEFAULT_ROOT_DIR, 'lark-uploads')
|
|
19
|
+
|
|
20
|
+
const CONTENT_TYPE_TO_EXT = {
|
|
21
|
+
'video/mp4': 'mp4',
|
|
22
|
+
'video/quicktime': 'mov',
|
|
23
|
+
'video/x-msvideo': 'avi',
|
|
24
|
+
'video/x-matroska': 'mkv',
|
|
25
|
+
'video/webm': 'webm',
|
|
26
|
+
'video/3gpp': '3gp',
|
|
27
|
+
'video/mpeg': 'mpeg',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function videoExtFromContentType(headers) {
|
|
31
|
+
const ct = String(
|
|
32
|
+
headers?.['content-type']
|
|
33
|
+
|| headers?.['Content-Type']
|
|
34
|
+
|| ''
|
|
35
|
+
).toLowerCase()
|
|
36
|
+
for (const [type, ext] of Object.entries(CONTENT_TYPE_TO_EXT)) {
|
|
37
|
+
if (ct.includes(type)) return ext
|
|
38
|
+
}
|
|
39
|
+
return 'mp4' // 飞书视频默认 mp4,未知 mime 兜底成 mp4 比 bin 更安全
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const VIDEO_MSG_TYPES = new Set(['media', 'video'])
|
|
43
|
+
const VIDEO_FILE_NAME_RE = /\.(mp4|mov|m4v|webm|mkv|avi|3gp|mpeg|mpg|wmv|flv)$/i
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 从飞书 message 提取视频 file_key。
|
|
47
|
+
*
|
|
48
|
+
* 识别优先级:
|
|
49
|
+
* ① post 富文本里的 media 节点 —— 实测飞书发视频走的就是这条
|
|
50
|
+
* ② msg_type ∈ {media, video} → content.file_key(顶层)
|
|
51
|
+
* ③ msg_type='file' 且 file_name 是视频后缀 → content.file_key
|
|
52
|
+
* ④ 兜底嵌套:content.video.file_key / content.media.file_key
|
|
53
|
+
* ⑤ 兜底:未知 msg_type 但 content 里有 file_key + 文件名是视频后缀
|
|
54
|
+
*
|
|
55
|
+
* @returns {{ fileKey: string, fileName: string|null, duration: number|null, msgType: string|null } | null}
|
|
56
|
+
*/
|
|
57
|
+
export function extractVideoFileKey(message = {}) {
|
|
58
|
+
if (!message || typeof message !== 'object') return null
|
|
59
|
+
const msgType = message.msg_type || message.message_type || null
|
|
60
|
+
|
|
61
|
+
let content = message.content
|
|
62
|
+
if (typeof content === 'string') {
|
|
63
|
+
try { content = JSON.parse(content) } catch { content = null }
|
|
64
|
+
}
|
|
65
|
+
if (!content || typeof content !== 'object') return null
|
|
66
|
+
|
|
67
|
+
// ① post 富文本里的 media 节点
|
|
68
|
+
if (Array.isArray(content.content)) {
|
|
69
|
+
for (const line of content.content) {
|
|
70
|
+
if (!Array.isArray(line)) continue
|
|
71
|
+
for (const node of line) {
|
|
72
|
+
if (node && node.tag === 'media' && typeof node.file_key === 'string' && node.file_key) {
|
|
73
|
+
return {
|
|
74
|
+
fileKey: node.file_key,
|
|
75
|
+
fileName: typeof node.file_name === 'string' ? node.file_name : null,
|
|
76
|
+
duration: typeof node.duration === 'number' ? node.duration : null,
|
|
77
|
+
msgType,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ②③④⑤ 顶层 / 嵌套 file_key
|
|
85
|
+
const fileKey = pickFirstString([
|
|
86
|
+
content.file_key,
|
|
87
|
+
content.video && content.video.file_key,
|
|
88
|
+
content.media && content.media.file_key,
|
|
89
|
+
])
|
|
90
|
+
if (!fileKey) return null
|
|
91
|
+
|
|
92
|
+
const fileName = pickFirstString([
|
|
93
|
+
content.file_name,
|
|
94
|
+
content.video && content.video.file_name,
|
|
95
|
+
content.media && content.media.file_name,
|
|
96
|
+
]) || null
|
|
97
|
+
|
|
98
|
+
let claim = false
|
|
99
|
+
if (msgType && VIDEO_MSG_TYPES.has(String(msgType).toLowerCase())) {
|
|
100
|
+
claim = true
|
|
101
|
+
} else if (fileName && VIDEO_FILE_NAME_RE.test(fileName)) {
|
|
102
|
+
claim = true
|
|
103
|
+
}
|
|
104
|
+
if (!claim) return null
|
|
105
|
+
|
|
106
|
+
const duration = typeof content.duration === 'number' ? content.duration : null
|
|
107
|
+
|
|
108
|
+
return { fileKey, fileName, duration, msgType }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function pickFirstString(candidates) {
|
|
112
|
+
for (const c of candidates) {
|
|
113
|
+
if (typeof c === 'string' && c) return c
|
|
114
|
+
}
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param opts.apiClient lark-api-client 实例(必须有 getMessageResource)
|
|
120
|
+
* @param opts.messageId 飞书 message_id
|
|
121
|
+
* @param opts.fileKey content.file_key 或 post media 节点的 file_key
|
|
122
|
+
* @param opts.fileName 用于推扩展名(可选;优先级低于 content-type)
|
|
123
|
+
* @param opts.destDir 目标目录
|
|
124
|
+
* @returns {Promise<{ ok: true, localPath } | { ok: false, reason, detail? }>}
|
|
125
|
+
*/
|
|
126
|
+
export async function downloadLarkVideo({
|
|
127
|
+
apiClient,
|
|
128
|
+
messageId,
|
|
129
|
+
fileKey,
|
|
130
|
+
fileName = null,
|
|
131
|
+
destDir = DEFAULT_DIR,
|
|
132
|
+
} = {}) {
|
|
133
|
+
if (!apiClient?.getMessageResource) return { ok: false, reason: 'apiClient_required' }
|
|
134
|
+
if (!messageId) return { ok: false, reason: 'messageId_required' }
|
|
135
|
+
if (!fileKey) return { ok: false, reason: 'fileKey_required' }
|
|
136
|
+
|
|
137
|
+
const r = await apiClient.getMessageResource({ messageId, fileKey, type: 'file' })
|
|
138
|
+
if (!r?.ok) return { ok: false, reason: r?.reason || 'lark_resource_failed', detail: r?.detail }
|
|
139
|
+
if (typeof r.writeFile !== 'function') return { ok: false, reason: 'no_writefile' }
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
mkdirSync(destDir, { recursive: true })
|
|
143
|
+
} catch (e) {
|
|
144
|
+
return { ok: false, reason: 'mkdir_failed', detail: e.message }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let ext = videoExtFromContentType(r.headers || {})
|
|
148
|
+
if (ext === 'mp4' && fileName) {
|
|
149
|
+
const dot = fileName.lastIndexOf('.')
|
|
150
|
+
if (dot > 0 && dot < fileName.length - 1) {
|
|
151
|
+
const guess = fileName.slice(dot + 1).toLowerCase()
|
|
152
|
+
if (/^[a-z0-9]{2,5}$/.test(guess)) ext = guess
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const localName = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`
|
|
156
|
+
const localPath = join(destDir, localName)
|
|
157
|
+
try {
|
|
158
|
+
await r.writeFile(localPath)
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return { ok: false, reason: 'write_failed', detail: e.message }
|
|
161
|
+
}
|
|
162
|
+
return { ok: true, localPath }
|
|
163
|
+
}
|
package/src/mcp/audit.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 简单的 NDJSON 审计日志:每次破坏性 MCP 工具真执行后追加一行。
|
|
6
|
+
*
|
|
7
|
+
* rootDir 通常是 ~/.agentquad;文件名固定为 mcp-audit.log。
|
|
8
|
+
* 调用失败(磁盘只读等)不影响主流程——静默降级。
|
|
9
|
+
*/
|
|
10
|
+
export function createAuditLog({ rootDir, filename = 'mcp-audit.log' } = {}) {
|
|
11
|
+
if (!rootDir) throw new Error('rootDir_required')
|
|
12
|
+
const path = join(rootDir, filename)
|
|
13
|
+
|
|
14
|
+
function ensureDir() {
|
|
15
|
+
const dir = dirname(path)
|
|
16
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function append(entry) {
|
|
20
|
+
try {
|
|
21
|
+
ensureDir()
|
|
22
|
+
const line = JSON.stringify({
|
|
23
|
+
ts: new Date().toISOString(),
|
|
24
|
+
...entry,
|
|
25
|
+
}) + '\n'
|
|
26
|
+
appendFileSync(path, line, 'utf8')
|
|
27
|
+
return { ok: true }
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return { ok: false, error: e?.message }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { append, path }
|
|
34
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import express from 'express'
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
3
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
4
|
+
import { registerReadTools } from './tools/read/index.js'
|
|
5
|
+
import { registerWriteTools } from './tools/write/index.js'
|
|
6
|
+
import { registerDestructiveTools } from './tools/destructive/index.js'
|
|
7
|
+
import { registerOpenClawTools } from './tools/openclaw/index.js'
|
|
8
|
+
import { createAuditLog } from './audit.js'
|
|
9
|
+
import { createTranscriptScanner } from '../search/transcripts.js'
|
|
10
|
+
|
|
11
|
+
const SERVER_NAME = 'agentquad'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 创建一个挂在 Express 下的 MCP Streamable HTTP 路由。
|
|
15
|
+
*
|
|
16
|
+
* 工作方式:一个全局 McpServer + 一个全局 StreamableHTTPServerTransport,stateless 模式。
|
|
17
|
+
* 每个 HTTP 请求都由 transport.handleRequest 完整处理。
|
|
18
|
+
*
|
|
19
|
+
* 依赖:
|
|
20
|
+
* - db:openDb(...) 返回的句柄
|
|
21
|
+
* - searchService:createSearchService 返回
|
|
22
|
+
* - wikiDir:wiki .md 文件所在目录(用于 read_wiki)
|
|
23
|
+
* - getVersion():可选,注入当前 AgentQuad 版本
|
|
24
|
+
* - aiTerminal:可选,{ spawnSession },用于 start_ai_session
|
|
25
|
+
* - openclaw:可选,OpenClaw bridge 句柄
|
|
26
|
+
* - pending:可选,pending-question coordinator 句柄
|
|
27
|
+
* - getConfig:可选,() => 当前配置快照
|
|
28
|
+
*/
|
|
29
|
+
export function createMcpRouter({
|
|
30
|
+
db, searchService, wikiDir, rootDir, logDir, getVersion,
|
|
31
|
+
aiTerminal = null, openclaw = null, pending = null, getConfig = null,
|
|
32
|
+
} = {}) {
|
|
33
|
+
if (!db) throw new Error('db_required')
|
|
34
|
+
if (!searchService) throw new Error('searchService_required')
|
|
35
|
+
|
|
36
|
+
const server = new McpServer({
|
|
37
|
+
name: SERVER_NAME,
|
|
38
|
+
version: (typeof getVersion === 'function' && getVersion()) || '0.1.0',
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const audit = rootDir ? createAuditLog({ rootDir }) : null
|
|
42
|
+
const transcriptScanner = logDir ? createTranscriptScanner({ db, logDir }) : null
|
|
43
|
+
|
|
44
|
+
registerReadTools(server, { db, searchService, wikiDir, transcriptScanner })
|
|
45
|
+
registerWriteTools(server, { db })
|
|
46
|
+
registerDestructiveTools(server, { db, audit })
|
|
47
|
+
if (pending) {
|
|
48
|
+
registerOpenClawTools(server, { db, aiTerminal, openclaw, pending, getConfig })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
|
|
52
|
+
// 异步 connect;路由处理器会等这个 promise resolve 之后再调 handleRequest。
|
|
53
|
+
const ready = server.connect(transport)
|
|
54
|
+
|
|
55
|
+
const router = express.Router()
|
|
56
|
+
// MCP Streamable HTTP 约定:客户端用 POST /mcp 下发 JSON-RPC;
|
|
57
|
+
// 对于 SSE 变体或重连,GET 会触发会话初始化。
|
|
58
|
+
// 我们是 stateless mode,所以两种方法都交给 transport.handleRequest。
|
|
59
|
+
const handle = async (req, res) => {
|
|
60
|
+
try {
|
|
61
|
+
await ready
|
|
62
|
+
await transport.handleRequest(req, res, req.body)
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (!res.headersSent) {
|
|
65
|
+
res.status(500).json({
|
|
66
|
+
jsonrpc: '2.0',
|
|
67
|
+
error: { code: -32603, message: e?.message || 'internal_error' },
|
|
68
|
+
id: null,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
router.post('/', handle)
|
|
74
|
+
router.get('/', handle)
|
|
75
|
+
router.delete('/', handle)
|
|
76
|
+
|
|
77
|
+
// 健康检查(MCP 客户端一般不走这个,但方便 `agentquad mcp status` 和运维)
|
|
78
|
+
router.get('/health', (_req, res) => {
|
|
79
|
+
res.json({ ok: true, server: SERVER_NAME, tools: server._registeredTools ? Object.keys(server._registeredTools).length : undefined })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return { router, server, transport }
|
|
83
|
+
}
|