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
package/src/lark-bot.js
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import { createLarkApiClient } from './lark-api-client.js'
|
|
2
|
+
import { createLarkEventClient } from './lark-event-client.js'
|
|
3
|
+
import { downloadLarkImage, extractImageKeys } from './lark-image.js'
|
|
4
|
+
import { downloadLarkVideo, extractVideoFileKey } from './lark-video.js'
|
|
5
|
+
|
|
6
|
+
// 飞书内置 emoji_type 枚举里挑出一组"在思考 / 在干活"语义的值。
|
|
7
|
+
// 飞书的 emoji_type 是固定枚举(不是任意 unicode),不少看着合理的值(EYES / CLOCK /
|
|
8
|
+
// WOWFACE 等)都被服务端拒为 'reaction type is invalid'(code 231001)。
|
|
9
|
+
// 这里只保留实际验证过、飞书会接受的 emoji_type。
|
|
10
|
+
const BUSY_REACTION_EMOJIS = [
|
|
11
|
+
'THINKING', // 🤔 思考中
|
|
12
|
+
'OK', // 👌 已收到,正在做
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
function pickBusyReactionEmoji(rng = Math.random) {
|
|
16
|
+
const i = Math.floor(rng() * BUSY_REACTION_EMOJIS.length)
|
|
17
|
+
return BUSY_REACTION_EMOJIS[Math.min(i, BUSY_REACTION_EMOJIS.length - 1)]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isBlank(value) {
|
|
21
|
+
return value == null || String(value) === ''
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function stripMentionKeys(text, mentions) {
|
|
25
|
+
if (!text || typeof text !== 'string') return text || ''
|
|
26
|
+
if (!Array.isArray(mentions) || mentions.length === 0) return text
|
|
27
|
+
let out = text
|
|
28
|
+
for (const m of mentions) {
|
|
29
|
+
const key = m?.key
|
|
30
|
+
if (!key || typeof key !== 'string') continue
|
|
31
|
+
// 例如 "@_user_1"。占位符通常前后有空格,这里把 "<key> " / " <key>" / "<key>" 都替成空。
|
|
32
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
33
|
+
out = out.replace(new RegExp(escaped + '\\s*', 'g'), '')
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 飞书 post 富文本:content.content 是 [[node, node, ...], [node, ...], ...],
|
|
39
|
+
// node.tag 可能是 'text' / 'at' / 'a'(超链接) / 'img' / 'emotion' 等。
|
|
40
|
+
// 提取所有可见文字节点(text / a 的 text / md 的 text),跳过 at(就是要剥的 @ 占位)。
|
|
41
|
+
function extractPostText(post) {
|
|
42
|
+
if (!post || typeof post !== 'object') return ''
|
|
43
|
+
const lines = Array.isArray(post.content) ? post.content : []
|
|
44
|
+
const out = []
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
if (!Array.isArray(line)) continue
|
|
47
|
+
let buf = ''
|
|
48
|
+
for (const node of line) {
|
|
49
|
+
if (!node || typeof node !== 'object') continue
|
|
50
|
+
const tag = node.tag
|
|
51
|
+
if (tag === 'text' || tag === 'a' || tag === 'md') {
|
|
52
|
+
if (typeof node.text === 'string') buf += node.text
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (buf) out.push(buf)
|
|
56
|
+
}
|
|
57
|
+
const body = out.join('\n').trim()
|
|
58
|
+
if (body) return body
|
|
59
|
+
if (typeof post.title === 'string' && post.title.trim()) return post.title.trim()
|
|
60
|
+
return ''
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function extractText(message = {}) {
|
|
64
|
+
let content = message.content
|
|
65
|
+
if (typeof content === 'string') {
|
|
66
|
+
try { content = JSON.parse(content) } catch { content = {} }
|
|
67
|
+
}
|
|
68
|
+
if (!content || typeof content !== 'object') return ''
|
|
69
|
+
// 1. 普通 text 消息
|
|
70
|
+
if (typeof content.text === 'string' && content.text) {
|
|
71
|
+
return stripMentionKeys(content.text, message.mentions).replace(/^\s+/, '').trim()
|
|
72
|
+
}
|
|
73
|
+
// 2. post 富文本(@bot 的消息也是这种格式)
|
|
74
|
+
if (Array.isArray(content.content)) {
|
|
75
|
+
return extractPostText(content).trim()
|
|
76
|
+
}
|
|
77
|
+
// 3. 老的 title-only 兜底
|
|
78
|
+
if (typeof content.title === 'string') {
|
|
79
|
+
return stripMentionKeys(content.title, message.mentions).replace(/^\s+/, '').trim()
|
|
80
|
+
}
|
|
81
|
+
return ''
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function rememberSeen(seen, key, max = 500) {
|
|
85
|
+
if (!key || seen.has(key)) return false
|
|
86
|
+
seen.set(key, Date.now())
|
|
87
|
+
while (seen.size > max) {
|
|
88
|
+
let oldestKey
|
|
89
|
+
let oldestTime = Infinity
|
|
90
|
+
for (const [seenKey, timestamp] of seen.entries()) {
|
|
91
|
+
if (timestamp < oldestTime) {
|
|
92
|
+
oldestKey = seenKey
|
|
93
|
+
oldestTime = timestamp
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (oldestKey == null) break
|
|
97
|
+
seen.delete(oldestKey)
|
|
98
|
+
}
|
|
99
|
+
return true
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function stringOrNull(value) {
|
|
103
|
+
return value == null ? null : String(value)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function normalizeEvent(raw = {}) {
|
|
107
|
+
const event = raw.event || raw
|
|
108
|
+
const message = event.message || {}
|
|
109
|
+
const sender = event.sender || {}
|
|
110
|
+
const messageId = stringOrNull(message.message_id || message.messageId)
|
|
111
|
+
return {
|
|
112
|
+
eventId: stringOrNull(raw.event_id || raw.eventId || messageId),
|
|
113
|
+
chatId: stringOrNull(message.chat_id || message.chatId),
|
|
114
|
+
messageId,
|
|
115
|
+
threadId: stringOrNull(message.thread_id || message.threadId),
|
|
116
|
+
rootMessageId: stringOrNull(message.root_id || message.rootId || message.parent_id || message.parentId),
|
|
117
|
+
text: extractText(message),
|
|
118
|
+
fromUserId: stringOrNull(sender.sender_id?.open_id || sender.sender_id?.user_id || sender.open_id),
|
|
119
|
+
senderType: sender.sender_type || sender.type || null,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 把飞书 card.action.trigger event payload 拍平到 wizard.handleCallback 的参数:
|
|
125
|
+
* { channel: 'lark', chatId, threadId, callbackData, fromUserId }
|
|
126
|
+
* action.value 是按钮的 value(构卡片时塞的 JSON),约定字段:
|
|
127
|
+
* { callback_data: 'qt:perm:abcd:allow' } // 跟 telegram 的 callback_data 同字符串格式
|
|
128
|
+
*/
|
|
129
|
+
export function normalizeCardAction(raw = {}) {
|
|
130
|
+
const event = raw.event || raw
|
|
131
|
+
const action = event.action || {}
|
|
132
|
+
const context = event.context || {}
|
|
133
|
+
const operator = event.operator || {}
|
|
134
|
+
const valueObj = action.value && typeof action.value === 'object' ? action.value : {}
|
|
135
|
+
return {
|
|
136
|
+
chatId: context.open_chat_id != null ? String(context.open_chat_id) : null,
|
|
137
|
+
threadId: context.open_thread_id != null ? String(context.open_thread_id) : null,
|
|
138
|
+
rootMessageId: context.open_message_id != null ? String(context.open_message_id) : null,
|
|
139
|
+
callbackData: typeof valueObj.callback_data === 'string' ? valueObj.callback_data : '',
|
|
140
|
+
fromUserId: operator.open_id != null ? String(operator.open_id) : (operator.user_id != null ? String(operator.user_id) : null),
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function createLarkBot({
|
|
145
|
+
getConfig,
|
|
146
|
+
wizard,
|
|
147
|
+
apiClientFactory = createLarkApiClient,
|
|
148
|
+
eventClientFactory = createLarkEventClient,
|
|
149
|
+
logger = console,
|
|
150
|
+
} = {}) {
|
|
151
|
+
if (typeof getConfig !== 'function') throw new Error('getConfig_required')
|
|
152
|
+
if (!wizard || typeof wizard.handleInbound !== 'function') throw new Error('wizard_required')
|
|
153
|
+
|
|
154
|
+
const seenEvents = new Map()
|
|
155
|
+
const pendingReplyRetries = new Map()
|
|
156
|
+
// sessionId → [{messageId, reactionId}]:跟踪每个 PTY session 期间 bot 加在用户消息上
|
|
157
|
+
// 的 "在干活" reaction,等到 hook 报告 stop(Claude Code 完成一轮回复)时批量删掉。
|
|
158
|
+
const pendingReactions = new Map()
|
|
159
|
+
let running = false
|
|
160
|
+
let apiClient = null
|
|
161
|
+
let eventClient = null
|
|
162
|
+
|
|
163
|
+
function credentialsFromConfig() {
|
|
164
|
+
const lark = getConfig()?.lark || {}
|
|
165
|
+
return {
|
|
166
|
+
appId: lark.appId || '',
|
|
167
|
+
appSecret: lark.appSecret || '',
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function hasCredentials() {
|
|
172
|
+
const { appId, appSecret } = credentialsFromConfig()
|
|
173
|
+
return !isBlank(appId) && !isBlank(appSecret)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function getApiClient() {
|
|
177
|
+
if (!apiClient) {
|
|
178
|
+
apiClient = apiClientFactory({
|
|
179
|
+
...credentialsFromConfig(),
|
|
180
|
+
logger,
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
return apiClient
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function sendMessage({ chatId, text } = {}) {
|
|
187
|
+
if (isBlank(chatId)) return { ok: false, reason: 'chatId_required' }
|
|
188
|
+
if (isBlank(text)) return { ok: false, reason: 'text_required' }
|
|
189
|
+
if (!hasCredentials()) return { ok: false, reason: 'lark_credentials_missing' }
|
|
190
|
+
return getApiClient().sendMessage({ chatId, text })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function replyInThread({ rootMessageId, text } = {}) {
|
|
194
|
+
if (isBlank(rootMessageId)) return { ok: false, reason: 'rootMessageId_required' }
|
|
195
|
+
if (isBlank(text)) return { ok: false, reason: 'text_required' }
|
|
196
|
+
if (!hasCredentials()) return { ok: false, reason: 'lark_credentials_missing' }
|
|
197
|
+
return getApiClient().replyInThread({ rootMessageId, text })
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function sendCard({ chatId, card } = {}) {
|
|
201
|
+
if (isBlank(chatId)) return { ok: false, reason: 'chatId_required' }
|
|
202
|
+
if (!card || typeof card !== 'object') return { ok: false, reason: 'card_required' }
|
|
203
|
+
if (!hasCredentials()) return { ok: false, reason: 'lark_credentials_missing' }
|
|
204
|
+
return getApiClient().sendCard({ chatId, card })
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function replyWithCard({ rootMessageId, card } = {}) {
|
|
208
|
+
if (isBlank(rootMessageId)) return { ok: false, reason: 'rootMessageId_required' }
|
|
209
|
+
if (!card || typeof card !== 'object') return { ok: false, reason: 'card_required' }
|
|
210
|
+
if (!hasCredentials()) return { ok: false, reason: 'lark_credentials_missing' }
|
|
211
|
+
return getApiClient().replyWithCard({ rootMessageId, card })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function addReaction({ messageId, emojiType = 'THUMBSUP' } = {}) {
|
|
215
|
+
if (isBlank(messageId)) return { ok: false, reason: 'messageId_required' }
|
|
216
|
+
if (!hasCredentials()) return { ok: false, reason: 'lark_credentials_missing' }
|
|
217
|
+
return getApiClient().addReaction({ messageId, emojiType })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// thread root 失效时(用户撤回 / 飞书 5xx)静默 drop。"撤回 root" = 用户明示
|
|
221
|
+
// "不想看这个对话了",把消息泼到群主消息流是污染。reply 失败就让它失败。
|
|
222
|
+
async function deliverReply({ chatId, rootMessageId, text } = {}) {
|
|
223
|
+
if (!rootMessageId) return sendMessage({ chatId, text })
|
|
224
|
+
return replyInThread({ rootMessageId, text })
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function clearPendingReplyRetry(replyContext, ev) {
|
|
228
|
+
const keys = new Set([
|
|
229
|
+
...(replyContext?.retryKeys || []),
|
|
230
|
+
ev.eventId,
|
|
231
|
+
ev.messageId,
|
|
232
|
+
].filter(Boolean))
|
|
233
|
+
for (const key of keys) pendingReplyRetries.delete(key)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function replyFailureResult(replyResult, reason = null) {
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
reason: reason || replyResult?.reason || 'reply_failed',
|
|
240
|
+
detail: replyResult?.detail,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function handleEvent(raw) {
|
|
245
|
+
const ev = normalizeEvent(raw)
|
|
246
|
+
if (!ev.eventId) {
|
|
247
|
+
return { ok: true, action: 'duplicate' }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const pendingReplyRetry = pendingReplyRetries.get(ev.eventId) || (ev.messageId ? pendingReplyRetries.get(ev.messageId) : null)
|
|
251
|
+
if (pendingReplyRetry) {
|
|
252
|
+
const retryResult = await deliverReply(pendingReplyRetry)
|
|
253
|
+
if (!retryResult?.ok) {
|
|
254
|
+
return replyFailureResult(retryResult, 'reply_retry_failed')
|
|
255
|
+
}
|
|
256
|
+
clearPendingReplyRetry(pendingReplyRetry, ev)
|
|
257
|
+
return { ok: true, action: pendingReplyRetry.action || 'handled' }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (seenEvents.has(ev.eventId) || (ev.messageId && seenEvents.has(ev.messageId))) {
|
|
261
|
+
return { ok: true, action: 'duplicate' }
|
|
262
|
+
}
|
|
263
|
+
rememberSeen(seenEvents, ev.eventId)
|
|
264
|
+
if (ev.messageId && ev.messageId !== ev.eventId) rememberSeen(seenEvents, ev.messageId)
|
|
265
|
+
const forgetEvent = () => {
|
|
266
|
+
seenEvents.delete(ev.eventId)
|
|
267
|
+
if (ev.messageId && ev.messageId !== ev.eventId) seenEvents.delete(ev.messageId)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const configuredChatId = getConfig()?.lark?.chatId
|
|
271
|
+
if (configuredChatId && ev.chatId !== String(configuredChatId)) {
|
|
272
|
+
logger.warn?.(`[lark-bot] ignored_chat: event chatId=${ev.chatId} != configured ${configuredChatId} (eventId=${ev.eventId})`)
|
|
273
|
+
return { ok: true, action: 'ignored_chat' }
|
|
274
|
+
}
|
|
275
|
+
if (ev.senderType === 'app' || ev.senderType === 'bot') {
|
|
276
|
+
logger.info?.(`[lark-bot] ignored_self: senderType=${ev.senderType} (eventId=${ev.eventId})`)
|
|
277
|
+
return { ok: true, action: 'ignored_self' }
|
|
278
|
+
}
|
|
279
|
+
// 提取消息里的 image_key(普通 image 消息 + post 富文本里的 img 节点都能识别)
|
|
280
|
+
const rawMsg = raw?.event?.message || raw?.message || {}
|
|
281
|
+
const imageKeys = extractImageKeys(rawMsg)
|
|
282
|
+
// msg_type === 'media' 时还会有视频;这里跟图片正交(不会误吃 image 消息的封面)
|
|
283
|
+
const videoMeta = extractVideoFileKey(rawMsg)
|
|
284
|
+
|
|
285
|
+
if (isBlank(ev.text) && imageKeys.length === 0 && !videoMeta) {
|
|
286
|
+
const msgType = rawMsg.msg_type || rawMsg.message_type || '(unknown)'
|
|
287
|
+
const contentRaw = typeof rawMsg.content === 'string' ? rawMsg.content : JSON.stringify(rawMsg.content || {})
|
|
288
|
+
const mentions = JSON.stringify(rawMsg.mentions || [])
|
|
289
|
+
// 附件类(media/file/video/audio)一旦走到 ignored_empty 一定是 extract 漏了,
|
|
290
|
+
// 把完整 content dump 出来便于扩展 extractVideoFileKey 的识别规则
|
|
291
|
+
const isAttachmentLike = /^(media|file|video|audio)$/i.test(String(msgType))
|
|
292
|
+
if (isAttachmentLike) {
|
|
293
|
+
logger.warn?.(`[lark-bot] ignored_empty (ATTACHMENT NOT RECOGNIZED): eventId=${ev.eventId} msg_type=${msgType} FULL_content=${contentRaw} mentions=${mentions}`)
|
|
294
|
+
} else {
|
|
295
|
+
logger.warn?.(`[lark-bot] ignored_empty: no text (eventId=${ev.eventId} msg_type=${msgType} content=${contentRaw.slice(0, 240)} mentions=${mentions.slice(0, 240)})`)
|
|
296
|
+
}
|
|
297
|
+
return { ok: true, action: 'ignored_empty' }
|
|
298
|
+
}
|
|
299
|
+
logger.info?.(`[lark-bot] dispatching to wizard: chatId=${ev.chatId} thread=${ev.threadId || '-'} root=${ev.rootMessageId || '-'} images=${imageKeys.length} video=${videoMeta ? '1' : '0'} text="${(ev.text || '').slice(0, 80)}"`)
|
|
300
|
+
|
|
301
|
+
// 下载图片(顺序,简单点;并发收益不大)。失败的跳过,不阻塞 wizard。
|
|
302
|
+
const imagePaths = []
|
|
303
|
+
if (imageKeys.length > 0 && ev.messageId && hasCredentials()) {
|
|
304
|
+
for (const key of imageKeys) {
|
|
305
|
+
try {
|
|
306
|
+
const dl = await downloadLarkImage({
|
|
307
|
+
apiClient: getApiClient(),
|
|
308
|
+
messageId: ev.messageId,
|
|
309
|
+
imageKey: key,
|
|
310
|
+
})
|
|
311
|
+
if (dl?.ok && dl.localPath) {
|
|
312
|
+
imagePaths.push(dl.localPath)
|
|
313
|
+
} else {
|
|
314
|
+
logger.warn?.(`[lark-bot] image download failed key=${key}: ${dl?.reason || 'unknown'} ${dl?.detail || ''}`)
|
|
315
|
+
}
|
|
316
|
+
} catch (e) {
|
|
317
|
+
logger.warn?.(`[lark-bot] image download threw key=${key}: ${e.message}`)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (imagePaths.length > 0) {
|
|
321
|
+
logger.info?.(`[lark-bot] downloaded ${imagePaths.length}/${imageKeys.length} image(s) for eventId=${ev.eventId}`)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 下载视频。跟图片走同款失败兜底:失败仅 warn,不阻塞 wizard;
|
|
326
|
+
// 成功后路径塞进 imagePaths(CC 自己消化),并准备 caption 前缀给 wizard。
|
|
327
|
+
let videoCaptionTag = null
|
|
328
|
+
if (videoMeta && ev.messageId && hasCredentials()) {
|
|
329
|
+
try {
|
|
330
|
+
const dl = await downloadLarkVideo({
|
|
331
|
+
apiClient: getApiClient(),
|
|
332
|
+
messageId: ev.messageId,
|
|
333
|
+
fileKey: videoMeta.fileKey,
|
|
334
|
+
fileName: videoMeta.fileName,
|
|
335
|
+
})
|
|
336
|
+
if (dl?.ok && dl.localPath) {
|
|
337
|
+
imagePaths.push(dl.localPath)
|
|
338
|
+
const labelName = videoMeta.fileName || 'video.mp4'
|
|
339
|
+
videoCaptionTag = `[用户发了视频:${labelName}]`
|
|
340
|
+
logger.info?.(`[lark-bot] downloaded video → ${dl.localPath}`)
|
|
341
|
+
} else {
|
|
342
|
+
logger.warn?.(`[lark-bot] video download failed: ${dl?.reason || 'unknown'} ${dl?.detail || ''}`)
|
|
343
|
+
}
|
|
344
|
+
} catch (e) {
|
|
345
|
+
logger.warn?.(`[lark-bot] video download threw: ${e.message}`)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const wizardText = videoCaptionTag
|
|
349
|
+
? (ev.text ? `${videoCaptionTag}\n${ev.text}` : videoCaptionTag)
|
|
350
|
+
: ev.text
|
|
351
|
+
|
|
352
|
+
// 立即加 "在思考/在干活" reaction 让用户知道 bot 收到了;不 await,避免拖慢 wizard。
|
|
353
|
+
// 拿到 reaction_id 后跟 wizard 返回的 sessionId 配对,等到 PTY 完成一轮回复时清掉。
|
|
354
|
+
let reactionPromise = null
|
|
355
|
+
if (ev.messageId && hasCredentials()) {
|
|
356
|
+
reactionPromise = getApiClient()
|
|
357
|
+
.addReaction({ messageId: ev.messageId, emojiType: pickBusyReactionEmoji() })
|
|
358
|
+
.catch((e) => {
|
|
359
|
+
logger.warn?.(`[lark-bot] reaction failed: ${e.message}`)
|
|
360
|
+
return null
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
let result
|
|
365
|
+
try {
|
|
366
|
+
result = await wizard.handleInbound({
|
|
367
|
+
channel: 'lark',
|
|
368
|
+
chatId: ev.chatId,
|
|
369
|
+
threadId: ev.threadId,
|
|
370
|
+
rootMessageId: ev.rootMessageId,
|
|
371
|
+
messageId: ev.messageId,
|
|
372
|
+
text: wizardText,
|
|
373
|
+
fromUserId: ev.fromUserId,
|
|
374
|
+
imagePaths: imagePaths.length > 0 ? imagePaths : undefined,
|
|
375
|
+
})
|
|
376
|
+
} catch (e) {
|
|
377
|
+
forgetEvent()
|
|
378
|
+
return { ok: false, reason: 'wizard_failed', detail: e.message }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// 关联 reaction 到 sessionId(如果 wizard 返回的是 stdin proxy 或 wizard_done 形态)。
|
|
382
|
+
// 拿到 reaction_id 后存到 pendingReactions[sid],等 PTY 完成时一次性删掉。
|
|
383
|
+
const linkSid = result?.sessionId || null
|
|
384
|
+
if (linkSid && reactionPromise && ev.messageId) {
|
|
385
|
+
reactionPromise.then((r) => {
|
|
386
|
+
const reactionId = r?.payload?.reaction_id || null
|
|
387
|
+
if (!reactionId) return
|
|
388
|
+
const list = pendingReactions.get(linkSid) || []
|
|
389
|
+
list.push({ messageId: ev.messageId, reactionId })
|
|
390
|
+
pendingReactions.set(linkSid, list)
|
|
391
|
+
}).catch(() => {})
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const action = result?.action || 'handled'
|
|
395
|
+
if (result?.reply) {
|
|
396
|
+
// 优先 reply 进用户当前所在的 thread:
|
|
397
|
+
// - rootMessageId(用户在已有 thread 里发的回复)
|
|
398
|
+
// - 退而求其次用 messageId(用户在新建话题里发第一条消息,没 root_id;
|
|
399
|
+
// 用 reply API 直接回那条消息可让飞书把 reply 显示在同一个话题里)
|
|
400
|
+
const replyTarget = ev.rootMessageId || ev.messageId || null
|
|
401
|
+
const replyContext = {
|
|
402
|
+
chatId: ev.chatId,
|
|
403
|
+
rootMessageId: replyTarget,
|
|
404
|
+
text: result.reply,
|
|
405
|
+
action,
|
|
406
|
+
retryKeys: [ev.eventId, ev.messageId].filter(Boolean),
|
|
407
|
+
}
|
|
408
|
+
const replyResult = await deliverReply(replyContext)
|
|
409
|
+
if (!replyResult?.ok) {
|
|
410
|
+
pendingReplyRetries.set(ev.eventId, replyContext)
|
|
411
|
+
if (ev.messageId && ev.messageId !== ev.eventId) pendingReplyRetries.set(ev.messageId, replyContext)
|
|
412
|
+
return replyFailureResult(replyResult)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return { ok: true, action }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function handleCardAction(raw) {
|
|
420
|
+
const ev = normalizeCardAction(raw)
|
|
421
|
+
if (!ev.chatId || !ev.callbackData) {
|
|
422
|
+
return { ok: false, reason: 'invalid_card_action' }
|
|
423
|
+
}
|
|
424
|
+
const configuredChatId = getConfig()?.lark?.chatId
|
|
425
|
+
if (configuredChatId && ev.chatId !== String(configuredChatId)) {
|
|
426
|
+
logger.warn?.(`[lark-bot] ignored card_action from other chat: ${ev.chatId}`)
|
|
427
|
+
return { ok: true, action: 'ignored_chat' }
|
|
428
|
+
}
|
|
429
|
+
if (typeof wizard.handleCallback !== 'function') {
|
|
430
|
+
logger.warn?.(`[lark-bot] wizard.handleCallback unavailable; dropping lark card action`)
|
|
431
|
+
return { ok: false, reason: 'no_handler' }
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
return await wizard.handleCallback({
|
|
435
|
+
channel: 'lark',
|
|
436
|
+
chatId: ev.chatId,
|
|
437
|
+
threadId: ev.threadId,
|
|
438
|
+
rootMessageId: ev.rootMessageId,
|
|
439
|
+
callbackData: ev.callbackData,
|
|
440
|
+
fromUserId: ev.fromUserId,
|
|
441
|
+
})
|
|
442
|
+
} catch (e) {
|
|
443
|
+
logger.warn?.(`[lark-bot] card action handler failed: ${e.message}`)
|
|
444
|
+
return { ok: false, reason: 'handler_failed', detail: e.message }
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function start() {
|
|
449
|
+
const cfg = getConfig()?.lark || {}
|
|
450
|
+
if (!cfg.enabled || cfg.eventSubscribeEnabled === false) return { ok: false, reason: 'disabled' }
|
|
451
|
+
if (isBlank(cfg.chatId)) return { ok: false, reason: 'chatId_missing' }
|
|
452
|
+
if (!hasCredentials()) return { ok: false, reason: 'lark_credentials_missing' }
|
|
453
|
+
if (running) return { ok: true, action: 'already_running' }
|
|
454
|
+
|
|
455
|
+
eventClient = eventClientFactory({
|
|
456
|
+
...credentialsFromConfig(),
|
|
457
|
+
onEvent: handleEvent,
|
|
458
|
+
onCardAction: handleCardAction,
|
|
459
|
+
logger,
|
|
460
|
+
})
|
|
461
|
+
const result = await eventClient.start()
|
|
462
|
+
if (!result?.ok) return result
|
|
463
|
+
running = true
|
|
464
|
+
return { ok: true, action: 'started' }
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function stop() {
|
|
468
|
+
running = false
|
|
469
|
+
const current = eventClient
|
|
470
|
+
eventClient = null
|
|
471
|
+
if (current?.stop) await current.stop()
|
|
472
|
+
return { ok: true }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Claude Code 完成一轮回复后调用:把这个 session 期间加的 "在思考" reaction 全部删掉。
|
|
477
|
+
* 调用方一般是 openclaw-hook 处理 stop / done event 时。失败 swallow,主流程不阻塞。
|
|
478
|
+
*/
|
|
479
|
+
async function clearReactionsForSession(sessionId) {
|
|
480
|
+
if (!sessionId) return { ok: true, removed: 0 }
|
|
481
|
+
const list = pendingReactions.get(sessionId)
|
|
482
|
+
pendingReactions.delete(sessionId)
|
|
483
|
+
if (!list || list.length === 0) return { ok: true, removed: 0 }
|
|
484
|
+
if (!hasCredentials()) return { ok: false, reason: 'lark_credentials_missing' }
|
|
485
|
+
let removed = 0
|
|
486
|
+
for (const { messageId, reactionId } of list) {
|
|
487
|
+
const r = await getApiClient().deleteReaction({ messageId, reactionId }).catch((e) => ({ ok: false, detail: e.message }))
|
|
488
|
+
if (r?.ok) removed++
|
|
489
|
+
else logger.warn?.(`[lark-bot] reaction delete failed for sid=${sessionId} msg=${messageId} reaction=${reactionId}: ${r?.detail || r?.reason || 'unknown'}`)
|
|
490
|
+
}
|
|
491
|
+
return { ok: true, removed, total: list.length }
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function describe() {
|
|
495
|
+
const cfg = getConfig()?.lark || {}
|
|
496
|
+
const eventStatus = eventClient?.describe?.() || null
|
|
497
|
+
return {
|
|
498
|
+
enabled: !!cfg.enabled,
|
|
499
|
+
chatId: cfg.chatId || '',
|
|
500
|
+
eventSubscribeEnabled: cfg.eventSubscribeEnabled !== false,
|
|
501
|
+
running,
|
|
502
|
+
eventStatus,
|
|
503
|
+
pendingReactionSessions: pendingReactions.size,
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return { start, stop, sendMessage, replyInThread, sendCard, replyWithCard, handleEvent, handleCardAction, clearReactionsForSession, describe, __test__: { normalizeEvent, normalizeCardAction, _peekPendingReactions: () => new Map(pendingReactions) } }
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export { BUSY_REACTION_EMOJIS, pickBusyReactionEmoji }
|
package/src/lark-card.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把 telegram 风格的 replyMarkup(inline_keyboard)转成飞书 interactive card。
|
|
3
|
+
* 主要用途:openclaw-hook 给 Claude Code 权限提示发的「允许/拒绝」按钮,
|
|
4
|
+
* 在 lark 渠道改用飞书原生卡片,回调 value 仍带 'qt:perm:<short>:allow|deny' callback_data
|
|
5
|
+
* 让 wizard.handlePermissionCallback 复用现成路径。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const PERM_CALLBACK_PREFIX = 'qt:perm:'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @returns {boolean} replyMarkup 是否带权限按钮(callback_data 以 qt:perm: 开头)
|
|
12
|
+
*/
|
|
13
|
+
export function hasPermissionButtons(replyMarkup) {
|
|
14
|
+
const rows = replyMarkup?.inline_keyboard
|
|
15
|
+
if (!Array.isArray(rows)) return false
|
|
16
|
+
for (const row of rows) {
|
|
17
|
+
if (!Array.isArray(row)) continue
|
|
18
|
+
for (const btn of row) {
|
|
19
|
+
const cd = btn?.callback_data
|
|
20
|
+
if (typeof cd === 'string' && cd.startsWith(PERM_CALLBACK_PREFIX)) return true
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function pickButtonTone(callbackData) {
|
|
27
|
+
if (callbackData.endsWith(':allow')) return 'primary'
|
|
28
|
+
if (callbackData.endsWith(':deny')) return 'danger'
|
|
29
|
+
return 'default'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 把 actionId(例如 'codex:<sessionId>' 或 claude shortId)映射成 wizard 期望的
|
|
34
|
+
* qt:perm:<short>:allow|deny callback_data。
|
|
35
|
+
* `short` 取 actionId 末尾 4 个字母数字字符(与 wizard 短码一致)。
|
|
36
|
+
*/
|
|
37
|
+
function buildReplyMarkupFromActionId(actionId) {
|
|
38
|
+
if (typeof actionId !== 'string' || !actionId) return null
|
|
39
|
+
const cleaned = actionId.replace(/[^a-z0-9]/gi, '')
|
|
40
|
+
const short = cleaned.slice(-4).toLowerCase()
|
|
41
|
+
if (!/^[a-z0-9]{4}$/.test(short)) return null
|
|
42
|
+
return {
|
|
43
|
+
inline_keyboard: [[
|
|
44
|
+
{ text: '允许(Enter)', callback_data: `qt:perm:${short}:allow` },
|
|
45
|
+
{ text: '拒绝/退出(Esc)', callback_data: `qt:perm:${short}:deny` },
|
|
46
|
+
]],
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 构造飞书 interactive card:黄色 header + 文本 div + 按钮 action。
|
|
52
|
+
* 输入参数 message / replyMarkup 来自 openclaw-bridge.postText 的现有形参,
|
|
53
|
+
* 不需要 hook 改动。codex detector 路径用 actionId 直接生成按钮(短码取 actionId 末 4 字符)。
|
|
54
|
+
*/
|
|
55
|
+
export function buildPermissionCard({ message, replyMarkup, actionId, headerTitle = '⚠️ Claude Code 等待授权' } = {}) {
|
|
56
|
+
const buttons = []
|
|
57
|
+
const effectiveMarkup = replyMarkup || buildReplyMarkupFromActionId(actionId)
|
|
58
|
+
const rows = effectiveMarkup?.inline_keyboard || []
|
|
59
|
+
for (const row of rows) {
|
|
60
|
+
if (!Array.isArray(row)) continue
|
|
61
|
+
for (const btn of row) {
|
|
62
|
+
const cd = btn?.callback_data
|
|
63
|
+
if (typeof cd !== 'string') continue
|
|
64
|
+
buttons.push({
|
|
65
|
+
tag: 'button',
|
|
66
|
+
text: {
|
|
67
|
+
tag: 'plain_text',
|
|
68
|
+
content: String(btn.text || cd).slice(0, 64),
|
|
69
|
+
},
|
|
70
|
+
type: pickButtonTone(cd),
|
|
71
|
+
value: { callback_data: cd },
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// 飞书消息卡片 body 上限较大,但先给 4000 字裁剪兜底
|
|
76
|
+
const bodyContent = String(message || '').slice(0, 4000) || '(无内容)'
|
|
77
|
+
return {
|
|
78
|
+
config: { wide_screen_mode: true },
|
|
79
|
+
header: {
|
|
80
|
+
title: { tag: 'plain_text', content: headerTitle },
|
|
81
|
+
template: 'yellow',
|
|
82
|
+
},
|
|
83
|
+
elements: [
|
|
84
|
+
{ tag: 'div', text: { tag: 'lark_md', content: bodyContent } },
|
|
85
|
+
{ tag: 'action', actions: buttons },
|
|
86
|
+
],
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const MASK_PREFIX = 'lark_***'
|
|
2
|
+
|
|
3
|
+
export function maskLarkAppSecret(secret) {
|
|
4
|
+
if (!secret || typeof secret !== 'string') return null
|
|
5
|
+
const tail = secret.length >= 4 ? secret.slice(-4) : secret
|
|
6
|
+
return MASK_PREFIX + tail
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isMaskedLarkAppSecret(value) {
|
|
10
|
+
return typeof value === 'string' && value.startsWith(MASK_PREFIX)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function larkAppSecretSource(config) {
|
|
14
|
+
const secret = config?.lark?.appSecret
|
|
15
|
+
return secret && typeof secret === 'string' ? 'agentquad' : 'missing'
|
|
16
|
+
}
|