pikiloop 0.4.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 +353 -0
- package/README.v2.md +287 -0
- package/README.zh-CN.md +352 -0
- package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
- package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
- package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
- package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
- package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
- package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
- package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
- package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
- package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
- package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
- package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
- package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
- package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
- package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
- package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
- package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
- package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
- package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
- package/dashboard/dist/assets/index-reSbuley.css +1 -0
- package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
- package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
- package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
- package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
- package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
- package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
- package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
- package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
- package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
- package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
- package/dashboard/dist/favicon.svg +28 -0
- package/dashboard/dist/index.html +17 -0
- package/dist/agent/acp-client.js +261 -0
- package/dist/agent/auto-update.js +432 -0
- package/dist/agent/await-resume.js +50 -0
- package/dist/agent/cli/auth.js +325 -0
- package/dist/agent/cli/catalog.js +40 -0
- package/dist/agent/cli/detector.js +136 -0
- package/dist/agent/cli/index.js +7 -0
- package/dist/agent/cli/registry.js +33 -0
- package/dist/agent/driver.js +39 -0
- package/dist/agent/drivers/claude-tui.js +2297 -0
- package/dist/agent/drivers/claude.js +2689 -0
- package/dist/agent/drivers/codex.js +2210 -0
- package/dist/agent/drivers/gemini.js +1059 -0
- package/dist/agent/drivers/hermes.js +795 -0
- package/dist/agent/goal.js +274 -0
- package/dist/agent/handover.js +130 -0
- package/dist/agent/images.js +355 -0
- package/dist/agent/index.js +50 -0
- package/dist/agent/mcp/bridge.js +791 -0
- package/dist/agent/mcp/extensions.js +637 -0
- package/dist/agent/mcp/oauth.js +353 -0
- package/dist/agent/mcp/registry.js +119 -0
- package/dist/agent/mcp/session-server.js +229 -0
- package/dist/agent/mcp/tools/ask-user.js +113 -0
- package/dist/agent/mcp/tools/await-resume.js +77 -0
- package/dist/agent/mcp/tools/goal.js +144 -0
- package/dist/agent/mcp/tools/types.js +12 -0
- package/dist/agent/mcp/tools/workspace.js +212 -0
- package/dist/agent/npm.js +31 -0
- package/dist/agent/session.js +1206 -0
- package/dist/agent/skill-installer.js +160 -0
- package/dist/agent/skills.js +257 -0
- package/dist/agent/stream.js +743 -0
- package/dist/agent/types.js +13 -0
- package/dist/agent/utils.js +687 -0
- package/dist/bot/bot.js +2499 -0
- package/dist/bot/command-ui.js +633 -0
- package/dist/bot/commands.js +513 -0
- package/dist/bot/headless-bot.js +36 -0
- package/dist/bot/host.js +192 -0
- package/dist/bot/human-loop.js +168 -0
- package/dist/bot/menu.js +48 -0
- package/dist/bot/orchestration.js +79 -0
- package/dist/bot/render-shared.js +309 -0
- package/dist/bot/session-hub.js +361 -0
- package/dist/bot/session-status.js +55 -0
- package/dist/bot/streaming.js +309 -0
- package/dist/browser-profile.js +579 -0
- package/dist/browser-supervisor.js +249 -0
- package/dist/catalog/cli-tools.js +421 -0
- package/dist/catalog/index.js +21 -0
- package/dist/catalog/local-models.js +94 -0
- package/dist/catalog/mcp-servers.js +315 -0
- package/dist/catalog/skill-repos.js +173 -0
- package/dist/channels/base.js +55 -0
- package/dist/channels/dingtalk/bot.js +549 -0
- package/dist/channels/dingtalk/channel.js +268 -0
- package/dist/channels/discord/bot.js +552 -0
- package/dist/channels/discord/channel.js +245 -0
- package/dist/channels/feishu/bot.js +1275 -0
- package/dist/channels/feishu/channel.js +911 -0
- package/dist/channels/feishu/markdown.js +91 -0
- package/dist/channels/feishu/render.js +619 -0
- package/dist/channels/health.js +109 -0
- package/dist/channels/slack/bot.js +554 -0
- package/dist/channels/slack/channel.js +283 -0
- package/dist/channels/states.js +6 -0
- package/dist/channels/telegram/bot.js +1310 -0
- package/dist/channels/telegram/channel.js +820 -0
- package/dist/channels/telegram/directory.js +111 -0
- package/dist/channels/telegram/live-preview.js +220 -0
- package/dist/channels/telegram/render.js +384 -0
- package/dist/channels/wecom/bot.js +558 -0
- package/dist/channels/wecom/channel.js +479 -0
- package/dist/channels/weixin/api.js +520 -0
- package/dist/channels/weixin/bot.js +1000 -0
- package/dist/channels/weixin/channel.js +222 -0
- package/dist/cli/autostart.js +262 -0
- package/dist/cli/channel-supervisor.js +313 -0
- package/dist/cli/channels.js +54 -0
- package/dist/cli/main.js +726 -0
- package/dist/cli/onboarding.js +227 -0
- package/dist/cli/run.js +308 -0
- package/dist/cli/setup-wizard.js +235 -0
- package/dist/core/config/runtime-config.js +201 -0
- package/dist/core/config/user-config.js +510 -0
- package/dist/core/config/validation.js +521 -0
- package/dist/core/constants.js +400 -0
- package/dist/core/git.js +145 -0
- package/dist/core/legacy-compat.js +60 -0
- package/dist/core/logging.js +101 -0
- package/dist/core/platform.js +59 -0
- package/dist/core/process-control.js +315 -0
- package/dist/core/secrets/index.js +42 -0
- package/dist/core/secrets/inline-seal.js +60 -0
- package/dist/core/secrets/ref.js +33 -0
- package/dist/core/secrets/resolver.js +65 -0
- package/dist/core/secrets/store.js +63 -0
- package/dist/core/utils.js +233 -0
- package/dist/core/version.js +15 -0
- package/dist/dashboard/platform.js +219 -0
- package/dist/dashboard/routes/agents.js +450 -0
- package/dist/dashboard/routes/cli.js +174 -0
- package/dist/dashboard/routes/config.js +523 -0
- package/dist/dashboard/routes/extensions.js +745 -0
- package/dist/dashboard/routes/local-models.js +290 -0
- package/dist/dashboard/routes/models.js +324 -0
- package/dist/dashboard/routes/sessions.js +838 -0
- package/dist/dashboard/runtime.js +410 -0
- package/dist/dashboard/server.js +237 -0
- package/dist/dashboard/session-control.js +347 -0
- package/dist/model/catalog.js +104 -0
- package/dist/model/index.js +20 -0
- package/dist/model/injector.js +272 -0
- package/dist/model/provider-models.js +112 -0
- package/dist/model/store.js +212 -0
- package/dist/model/types.js +13 -0
- package/dist/model/validation.js +203 -0
- package/package.json +82 -0
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu channel — Feishu/Lark Open Platform transport using official SDK.
|
|
3
|
+
*
|
|
4
|
+
* Uses @larksuiteoapi/node-sdk for:
|
|
5
|
+
* - WSClient + EventDispatcher: WebSocket event receiving with auto-reconnect
|
|
6
|
+
* - Client.im: message send/edit/delete, image/file upload, resource download
|
|
7
|
+
* - Automatic tenant_access_token management
|
|
8
|
+
*
|
|
9
|
+
* All messages are sent as regular interactive cards (no CardKit streaming).
|
|
10
|
+
*/
|
|
11
|
+
import * as lark from '@larksuiteoapi/node-sdk';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { Channel, DEFAULT_CHANNEL_CAPABILITIES, sleep, } from '../base.js';
|
|
15
|
+
import { FEISHU_LIMITS } from '../../core/constants.js';
|
|
16
|
+
import { ChannelHealth } from '../health.js';
|
|
17
|
+
import { adaptMarkdownForFeishu } from './markdown.js';
|
|
18
|
+
import { writeScopedLog, shouldLog } from '../../core/logging.js';
|
|
19
|
+
import { recordKnownChatId } from '../../core/config/user-config.js';
|
|
20
|
+
export { FeishuChannel };
|
|
21
|
+
const FEISHU_CARD_MAX = FEISHU_LIMITS.cardMax;
|
|
22
|
+
const FILE_MAX_BYTES = FEISHU_LIMITS.fileMaxBytes;
|
|
23
|
+
const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
|
|
24
|
+
const FEISHU_WS_START_RETRY_MAX_DELAY_MS = FEISHU_LIMITS.wsStartRetryMaxDelay;
|
|
25
|
+
function describeError(err) {
|
|
26
|
+
if (!(err instanceof Error))
|
|
27
|
+
return String(err ?? 'unknown error');
|
|
28
|
+
const parts = [`${err.name}: ${err.message}`];
|
|
29
|
+
for (const key of ['code', 'errno', 'syscall', 'address', 'port', 'host', 'hostname']) {
|
|
30
|
+
const value = err?.[key];
|
|
31
|
+
if (value != null && value !== '')
|
|
32
|
+
parts.push(`${key}=${value}`);
|
|
33
|
+
}
|
|
34
|
+
return parts.join(' | ');
|
|
35
|
+
}
|
|
36
|
+
function isRetryableWsStartError(err) {
|
|
37
|
+
const text = describeError(err).toLowerCase();
|
|
38
|
+
return [
|
|
39
|
+
'socket hang up',
|
|
40
|
+
'econnreset',
|
|
41
|
+
'etimedout',
|
|
42
|
+
'econnrefused',
|
|
43
|
+
'enotfound',
|
|
44
|
+
'eai_again',
|
|
45
|
+
'fetch failed',
|
|
46
|
+
'timeout',
|
|
47
|
+
'bad gateway',
|
|
48
|
+
'service unavailable',
|
|
49
|
+
'gateway timeout',
|
|
50
|
+
].some(token => text.includes(token));
|
|
51
|
+
}
|
|
52
|
+
function isRetryableUploadError(err) {
|
|
53
|
+
const text = describeError(err).toLowerCase();
|
|
54
|
+
return [
|
|
55
|
+
'socket hang up',
|
|
56
|
+
'econnreset',
|
|
57
|
+
'etimedout',
|
|
58
|
+
'econnrefused',
|
|
59
|
+
'enotfound',
|
|
60
|
+
'eai_again',
|
|
61
|
+
'fetch failed',
|
|
62
|
+
'timeout',
|
|
63
|
+
'temporarily unavailable',
|
|
64
|
+
'internal server error',
|
|
65
|
+
'bad gateway',
|
|
66
|
+
'service unavailable',
|
|
67
|
+
'gateway timeout',
|
|
68
|
+
].some(token => text.includes(token));
|
|
69
|
+
}
|
|
70
|
+
function requireMessageId(resp, action) {
|
|
71
|
+
const messageId = resp?.data?.message_id;
|
|
72
|
+
if (messageId)
|
|
73
|
+
return String(messageId);
|
|
74
|
+
const code = resp?.code;
|
|
75
|
+
const msg = resp?.msg || resp?.message || 'no message_id returned';
|
|
76
|
+
throw new Error(`${action} failed: code=${code ?? '?'} msg=${msg}`);
|
|
77
|
+
}
|
|
78
|
+
/** Treat "card content didn't change" responses as a successful no-op edit. */
|
|
79
|
+
function isFeishuNotModifiedMessage(msg) {
|
|
80
|
+
if (!msg)
|
|
81
|
+
return false;
|
|
82
|
+
const lower = msg.toLowerCase();
|
|
83
|
+
return lower.includes('not modified')
|
|
84
|
+
|| lower.includes('not modify')
|
|
85
|
+
|| lower.includes('content not change')
|
|
86
|
+
|| lower.includes('same content')
|
|
87
|
+
|| lower.includes('same as before')
|
|
88
|
+
|| lower.includes('no change');
|
|
89
|
+
}
|
|
90
|
+
function buildPostContent(paragraphs, title = '') {
|
|
91
|
+
return JSON.stringify({
|
|
92
|
+
zh_cn: {
|
|
93
|
+
title,
|
|
94
|
+
content: paragraphs,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Card builder helper
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
function inferActionLayout(actions) {
|
|
102
|
+
if (actions.length >= 3)
|
|
103
|
+
return 'trisection';
|
|
104
|
+
if (actions.length === 2)
|
|
105
|
+
return 'bisected';
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
function chunkActionRows(actions, size = 3) {
|
|
109
|
+
const rows = [];
|
|
110
|
+
for (let i = 0; i < actions.length; i += size) {
|
|
111
|
+
const rowActions = actions.slice(i, i + size).filter(Boolean);
|
|
112
|
+
if (!rowActions.length)
|
|
113
|
+
continue;
|
|
114
|
+
rows.push({ actions: rowActions, layout: inferActionLayout(rowActions) });
|
|
115
|
+
}
|
|
116
|
+
return rows;
|
|
117
|
+
}
|
|
118
|
+
function keyboardToRows(keyboard) {
|
|
119
|
+
const explicitRows = Array.isArray(keyboard?.rows)
|
|
120
|
+
? keyboard.rows
|
|
121
|
+
.filter((row) => Array.isArray(row?.actions) && row.actions.length)
|
|
122
|
+
.map((row) => ({
|
|
123
|
+
actions: row.actions.filter(Boolean),
|
|
124
|
+
layout: row.layout || inferActionLayout(row.actions),
|
|
125
|
+
}))
|
|
126
|
+
: [];
|
|
127
|
+
if (explicitRows.length)
|
|
128
|
+
return explicitRows;
|
|
129
|
+
const actions = Array.isArray(keyboard?.actions)
|
|
130
|
+
? keyboard.actions.filter(Boolean)
|
|
131
|
+
: [];
|
|
132
|
+
return chunkActionRows(actions);
|
|
133
|
+
}
|
|
134
|
+
function buildCardFromView(view) {
|
|
135
|
+
const adapted = adaptMarkdownForFeishu(view.markdown);
|
|
136
|
+
const content = adapted.length > FEISHU_CARD_MAX
|
|
137
|
+
? adapted.slice(0, FEISHU_CARD_MAX) + '\n\n...(truncated)'
|
|
138
|
+
: adapted;
|
|
139
|
+
const actionElements = [];
|
|
140
|
+
for (const row of view.rows || []) {
|
|
141
|
+
const actions = row.actions.filter(Boolean);
|
|
142
|
+
if (!actions.length)
|
|
143
|
+
continue;
|
|
144
|
+
const element = { tag: 'action', actions };
|
|
145
|
+
const layout = row.layout || inferActionLayout(actions);
|
|
146
|
+
if (layout)
|
|
147
|
+
element.layout = layout;
|
|
148
|
+
actionElements.push(element);
|
|
149
|
+
}
|
|
150
|
+
// Card JSON 2.0 supports tables in markdown but dropped `tag: action`.
|
|
151
|
+
// Use v2 for content-only cards; fall back to v1 when buttons are needed.
|
|
152
|
+
if (actionElements.length) {
|
|
153
|
+
const card = {
|
|
154
|
+
config: { wide_screen_mode: true, update_multi: true },
|
|
155
|
+
elements: [{ tag: 'markdown', content }, ...actionElements],
|
|
156
|
+
};
|
|
157
|
+
if (view.title) {
|
|
158
|
+
card.header = {
|
|
159
|
+
template: view.template || 'blue',
|
|
160
|
+
title: { content: view.title, tag: 'plain_text' },
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return card;
|
|
164
|
+
}
|
|
165
|
+
const card = {
|
|
166
|
+
schema: '2.0',
|
|
167
|
+
config: { update_multi: true },
|
|
168
|
+
body: { elements: [{ tag: 'markdown', content }] },
|
|
169
|
+
};
|
|
170
|
+
if (view.title) {
|
|
171
|
+
card.header = {
|
|
172
|
+
template: view.template || 'blue',
|
|
173
|
+
title: { content: view.title, tag: 'plain_text' },
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return card;
|
|
177
|
+
}
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// FeishuChannel
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
class FeishuChannel extends Channel {
|
|
182
|
+
capabilities = {
|
|
183
|
+
...DEFAULT_CHANNEL_CAPABILITIES,
|
|
184
|
+
editMessages: true,
|
|
185
|
+
commandMenu: true,
|
|
186
|
+
messageReactions: true,
|
|
187
|
+
sendImage: true,
|
|
188
|
+
};
|
|
189
|
+
/** Implementation of Channel.sendImage — uploads the buffer as an image
|
|
190
|
+
* message. When a caption is supplied, falls back to a post with both an
|
|
191
|
+
* img tag and a text line (Feishu's `msg_type:'image'` doesn't have a
|
|
192
|
+
* native caption field). */
|
|
193
|
+
async sendImage(chatId, bytes, opts) {
|
|
194
|
+
const caption = opts.caption?.trim() || '';
|
|
195
|
+
const replyTo = opts.replyTo ? String(opts.replyTo) : undefined;
|
|
196
|
+
const imageKey = await this.uploadImage(bytes);
|
|
197
|
+
if (caption) {
|
|
198
|
+
return await this.sendPost(String(chatId), buildPostContent([
|
|
199
|
+
[{ tag: 'img', image_key: imageKey }],
|
|
200
|
+
[{ tag: 'text', text: caption }],
|
|
201
|
+
]), { replyTo });
|
|
202
|
+
}
|
|
203
|
+
const msgContent = JSON.stringify({ image_key: imageKey });
|
|
204
|
+
this._logOutgoing('sendImage', `${replyTo ? `reply_to=${replyTo}` : `chat=${chatId}`} bytes=${bytes.byteLength}`);
|
|
205
|
+
const resp = replyTo
|
|
206
|
+
? await this.client.im.message.reply({ path: { message_id: replyTo }, data: { msg_type: 'image', content: msgContent } })
|
|
207
|
+
: await this.client.im.message.create({ params: { receive_id_type: 'chat_id' }, data: { receive_id: String(chatId), msg_type: 'image', content: msgContent } });
|
|
208
|
+
return requireMessageId(resp, 'send image');
|
|
209
|
+
}
|
|
210
|
+
appId;
|
|
211
|
+
appSecret;
|
|
212
|
+
domain;
|
|
213
|
+
workdir;
|
|
214
|
+
allowedChatIds;
|
|
215
|
+
client;
|
|
216
|
+
wsClient = null;
|
|
217
|
+
eventDispatcher;
|
|
218
|
+
running = false;
|
|
219
|
+
messageChains = new Map();
|
|
220
|
+
/** Recently processed message IDs — guards against Feishu server retries. */
|
|
221
|
+
_seenMessageIds = new Set();
|
|
222
|
+
_seenMessageIdQueue = [];
|
|
223
|
+
static SEEN_MESSAGE_CAP = 256;
|
|
224
|
+
/** Maps open_id → chat_id for resolving menu event context. */
|
|
225
|
+
_openIdToChat = new Map();
|
|
226
|
+
_hCommand = null;
|
|
227
|
+
_hMessage = null;
|
|
228
|
+
_hCardAction = null;
|
|
229
|
+
_hRecall = null;
|
|
230
|
+
_hError = null;
|
|
231
|
+
knownChats = new Set();
|
|
232
|
+
/** Resolves when wsClient.start() settles (used by listen() to block). */
|
|
233
|
+
_listenResolve = null;
|
|
234
|
+
constructor(opts) {
|
|
235
|
+
super();
|
|
236
|
+
this.appId = opts.appId;
|
|
237
|
+
this.appSecret = opts.appSecret;
|
|
238
|
+
this.domain = (opts.domain ?? 'https://open.feishu.cn').replace(/\/+$/, '');
|
|
239
|
+
this.workdir = opts.workdir ?? process.cwd();
|
|
240
|
+
this.allowedChatIds = opts.allowedChatIds ?? new Set();
|
|
241
|
+
// Resolve SDK domain enum or custom string
|
|
242
|
+
const sdkDomain = this.domain.includes('larksuite.com')
|
|
243
|
+
? lark.Domain.Lark
|
|
244
|
+
: this.domain === 'https://open.feishu.cn'
|
|
245
|
+
? lark.Domain.Feishu
|
|
246
|
+
: this.domain;
|
|
247
|
+
this.client = new lark.Client({
|
|
248
|
+
appId: this.appId,
|
|
249
|
+
appSecret: this.appSecret,
|
|
250
|
+
domain: sdkDomain,
|
|
251
|
+
loggerLevel: lark.LoggerLevel.warn,
|
|
252
|
+
});
|
|
253
|
+
this.eventDispatcher = new lark.EventDispatcher({});
|
|
254
|
+
this._registerEvents();
|
|
255
|
+
}
|
|
256
|
+
// ---- Hook registration ---------------------------------------------------
|
|
257
|
+
onCommand(h) { this._hCommand = h; }
|
|
258
|
+
onMessage(h) { this._hMessage = h; }
|
|
259
|
+
onCallback(h) { this._hCardAction = h; }
|
|
260
|
+
onMessageRecalled(h) { this._hRecall = h; }
|
|
261
|
+
onError(h) { this._hError = h; }
|
|
262
|
+
// ========================================================================
|
|
263
|
+
// Lifecycle
|
|
264
|
+
// ========================================================================
|
|
265
|
+
async connect() {
|
|
266
|
+
// Get bot info via raw request (SDK doesn't have a dedicated bot info method)
|
|
267
|
+
try {
|
|
268
|
+
const resp = await this.client.request({
|
|
269
|
+
method: 'GET',
|
|
270
|
+
url: '/open-apis/bot/v3/info',
|
|
271
|
+
data: {},
|
|
272
|
+
});
|
|
273
|
+
this._debug(`[connect] bot info resp: ${JSON.stringify(resp)}`);
|
|
274
|
+
const info = resp?.bot;
|
|
275
|
+
this.bot = {
|
|
276
|
+
id: info?.open_id || this.appId,
|
|
277
|
+
username: info?.app_name || 'pikiloop',
|
|
278
|
+
displayName: info?.app_name || 'pikiloop',
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
this._log(`[connect] bot info failed: ${e?.message || e}`, 'warn');
|
|
283
|
+
this.bot = { id: this.appId, username: 'pikiloop', displayName: 'pikiloop' };
|
|
284
|
+
}
|
|
285
|
+
return this.bot;
|
|
286
|
+
}
|
|
287
|
+
async listen() {
|
|
288
|
+
this.running = true;
|
|
289
|
+
const health = new ChannelHealth({
|
|
290
|
+
label: 'Feishu',
|
|
291
|
+
opAction: 'WS start',
|
|
292
|
+
initialDelayMs: FEISHU_LIMITS.wsStartRetryInitialDelay,
|
|
293
|
+
maxDelayMs: FEISHU_WS_START_RETRY_MAX_DELAY_MS,
|
|
294
|
+
sustainedFailureHint: 'verify feishuAppId / feishuAppSecret in setting.json',
|
|
295
|
+
log: (msg, level) => this._log(msg, level),
|
|
296
|
+
});
|
|
297
|
+
while (this.running) {
|
|
298
|
+
const sdkDomain = this.domain.includes('larksuite.com')
|
|
299
|
+
? lark.Domain.Lark
|
|
300
|
+
: this.domain === 'https://open.feishu.cn'
|
|
301
|
+
? lark.Domain.Feishu
|
|
302
|
+
: this.domain;
|
|
303
|
+
this.wsClient = new lark.WSClient({
|
|
304
|
+
appId: this.appId,
|
|
305
|
+
appSecret: this.appSecret,
|
|
306
|
+
domain: sdkDomain,
|
|
307
|
+
loggerLevel: lark.LoggerLevel.warn,
|
|
308
|
+
autoReconnect: true,
|
|
309
|
+
});
|
|
310
|
+
this._debug('[ws] starting SDK WSClient...');
|
|
311
|
+
try {
|
|
312
|
+
await this.wsClient.start({ eventDispatcher: this.eventDispatcher });
|
|
313
|
+
this._debug('[ws] WSClient started, listening for events');
|
|
314
|
+
health.recordSuccess();
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
try {
|
|
319
|
+
this.wsClient.close({ force: true });
|
|
320
|
+
}
|
|
321
|
+
catch { }
|
|
322
|
+
this.wsClient = null;
|
|
323
|
+
if (!this.running)
|
|
324
|
+
return;
|
|
325
|
+
if (!isRetryableWsStartError(err))
|
|
326
|
+
throw err;
|
|
327
|
+
await sleep(health.recordFailure(err));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (!this.running || !this.wsClient)
|
|
331
|
+
return;
|
|
332
|
+
// Block until disconnect() is called
|
|
333
|
+
await new Promise(resolve => {
|
|
334
|
+
this._listenResolve = resolve;
|
|
335
|
+
if (!this.running)
|
|
336
|
+
resolve();
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
disconnect() {
|
|
340
|
+
this.running = false;
|
|
341
|
+
if (this.wsClient) {
|
|
342
|
+
try {
|
|
343
|
+
this.wsClient.close({ force: true });
|
|
344
|
+
}
|
|
345
|
+
catch { }
|
|
346
|
+
this.wsClient = null;
|
|
347
|
+
}
|
|
348
|
+
this._listenResolve?.();
|
|
349
|
+
this._listenResolve = null;
|
|
350
|
+
}
|
|
351
|
+
// ========================================================================
|
|
352
|
+
// Event handling (via SDK EventDispatcher)
|
|
353
|
+
// ========================================================================
|
|
354
|
+
_registerEvents() {
|
|
355
|
+
this.eventDispatcher.register({
|
|
356
|
+
'im.message.receive_v1': (data) => {
|
|
357
|
+
void this._handleMessageEvent(data).catch(e => {
|
|
358
|
+
this._log(`[dispatch] error: ${e}`, 'warn');
|
|
359
|
+
this._hError?.(e instanceof Error ? e : new Error(String(e)));
|
|
360
|
+
});
|
|
361
|
+
},
|
|
362
|
+
'card.action.trigger': (data) => {
|
|
363
|
+
void this._dispatchCardAction(data).catch(e => {
|
|
364
|
+
this._log(`[card-action] error: ${e}`, 'warn');
|
|
365
|
+
this._hError?.(e instanceof Error ? e : new Error(String(e)));
|
|
366
|
+
});
|
|
367
|
+
return {};
|
|
368
|
+
},
|
|
369
|
+
'application.bot.menu_v6': (data) => {
|
|
370
|
+
void this._dispatchMenuEvent(data).catch(e => {
|
|
371
|
+
this._log(`[menu] error: ${e}`, 'warn');
|
|
372
|
+
this._hError?.(e instanceof Error ? e : new Error(String(e)));
|
|
373
|
+
});
|
|
374
|
+
},
|
|
375
|
+
'im.message.recalled_v1': (data) => {
|
|
376
|
+
void this._dispatchMessageRecalled(data).catch(e => {
|
|
377
|
+
this._log(`[message-recalled] error: ${e}`, 'warn');
|
|
378
|
+
this._hError?.(e instanceof Error ? e : new Error(String(e)));
|
|
379
|
+
});
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
async _handleMessageEvent(event) {
|
|
384
|
+
if (shouldLog('debug'))
|
|
385
|
+
this._debug(`[recv] raw event received: ${JSON.stringify(event)}`);
|
|
386
|
+
const msg = event?.message;
|
|
387
|
+
if (!msg) {
|
|
388
|
+
this._debug(`[recv] event has no message object`);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const chatId = msg.chat_id;
|
|
392
|
+
const messageId = msg.message_id;
|
|
393
|
+
const chatType = msg.chat_type === 'p2p' ? 'p2p' : 'group';
|
|
394
|
+
const msgType = msg.message_type;
|
|
395
|
+
if (!chatId || !messageId)
|
|
396
|
+
return;
|
|
397
|
+
// Dedup: Feishu server may retry events when the ack is slow
|
|
398
|
+
if (this._seenMessageIds.has(messageId)) {
|
|
399
|
+
this._debug(`[recv] dedup: message=${messageId} already processed, skipping`);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
this._seenMessageIds.add(messageId);
|
|
403
|
+
this._seenMessageIdQueue.push(messageId);
|
|
404
|
+
while (this._seenMessageIdQueue.length > FeishuChannel.SEEN_MESSAGE_CAP) {
|
|
405
|
+
this._seenMessageIds.delete(this._seenMessageIdQueue.shift());
|
|
406
|
+
}
|
|
407
|
+
if (!this._isAllowed(chatId)) {
|
|
408
|
+
this._log(`[recv] blocked: chat=${chatId} not allowed`, 'warn');
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
this._trackChat(chatId);
|
|
412
|
+
const sender = event.sender;
|
|
413
|
+
// Skip messages from the bot itself
|
|
414
|
+
if (sender?.sender_type === 'app')
|
|
415
|
+
return;
|
|
416
|
+
const from = {
|
|
417
|
+
openId: sender?.sender_id?.open_id || '',
|
|
418
|
+
userId: sender?.sender_id?.user_id,
|
|
419
|
+
name: '',
|
|
420
|
+
};
|
|
421
|
+
// Track open_id → chat_id for menu event resolution
|
|
422
|
+
if (from.openId)
|
|
423
|
+
this._openIdToChat.set(from.openId, chatId);
|
|
424
|
+
// Group: require @mention
|
|
425
|
+
if (chatType === 'group') {
|
|
426
|
+
if (shouldLog('debug'))
|
|
427
|
+
this._debug(`[recv] group check mention: bot=${JSON.stringify(this.bot)}, mentions=${JSON.stringify(msg.mentions)}`);
|
|
428
|
+
if (!this._isBotMentioned(msg)) {
|
|
429
|
+
this._debug(`[recv] skipped: not mentioned in group ${chatId}`);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const parentId = typeof msg.parent_id === 'string' && msg.parent_id ? msg.parent_id : null;
|
|
434
|
+
const ctx = this._makeCtx(chatId, messageId, from, chatType, event, parentId);
|
|
435
|
+
// Parse message content
|
|
436
|
+
let text = '';
|
|
437
|
+
const files = [];
|
|
438
|
+
try {
|
|
439
|
+
const content = JSON.parse(msg.content || '{}');
|
|
440
|
+
if (msgType === 'text') {
|
|
441
|
+
text = this._cleanMention(content.text || '');
|
|
442
|
+
}
|
|
443
|
+
else if (msgType === 'image') {
|
|
444
|
+
if (content.image_key) {
|
|
445
|
+
try {
|
|
446
|
+
const localPath = await this._downloadResource(messageId, content.image_key, 'image');
|
|
447
|
+
files.push(localPath);
|
|
448
|
+
}
|
|
449
|
+
catch (e) {
|
|
450
|
+
this._log(`[recv] image download failed: ${e}`, 'warn');
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
else if (msgType === 'file') {
|
|
455
|
+
if (content.file_key) {
|
|
456
|
+
try {
|
|
457
|
+
const localPath = await this._downloadResource(messageId, content.file_key, 'file', content.file_name);
|
|
458
|
+
files.push(localPath);
|
|
459
|
+
}
|
|
460
|
+
catch (e) {
|
|
461
|
+
this._log(`[recv] file download failed: ${e}`, 'warn');
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
else if (msgType === 'post') {
|
|
466
|
+
text = this._cleanMention(this._extractPostText(content));
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
text = this._cleanMention(content.text || '');
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
catch (e) {
|
|
473
|
+
this._log(`[recv] content parse error: ${e.message || e}`, 'warn');
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const trimmedText = text.trim();
|
|
477
|
+
// Queue dispatch per chat to preserve ordering
|
|
478
|
+
const key = chatId;
|
|
479
|
+
const prev = this.messageChains.get(key) || Promise.resolve();
|
|
480
|
+
const current = prev.catch(() => { }).then(async () => {
|
|
481
|
+
// Command dispatch
|
|
482
|
+
if (trimmedText.startsWith('/') && this._hCommand) {
|
|
483
|
+
const spaceIdx = trimmedText.indexOf(' ');
|
|
484
|
+
const cmd = (spaceIdx > 0 ? trimmedText.slice(1, spaceIdx) : trimmedText.slice(1)).toLowerCase();
|
|
485
|
+
const args = spaceIdx > 0 ? trimmedText.slice(spaceIdx + 1).trim() : '';
|
|
486
|
+
await this._hCommand(cmd, args, ctx);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
// Message dispatch
|
|
490
|
+
if (!this._hMessage)
|
|
491
|
+
return;
|
|
492
|
+
if (!trimmedText && !files.length)
|
|
493
|
+
return;
|
|
494
|
+
await this._hMessage({ text: trimmedText, files }, ctx);
|
|
495
|
+
});
|
|
496
|
+
const settled = current.catch(e => {
|
|
497
|
+
this._log(`[dispatch] handler error: ${e}`, 'warn');
|
|
498
|
+
this._hError?.(e instanceof Error ? e : new Error(String(e)));
|
|
499
|
+
}).finally(() => {
|
|
500
|
+
if (this.messageChains.get(key) === settled)
|
|
501
|
+
this.messageChains.delete(key);
|
|
502
|
+
});
|
|
503
|
+
this.messageChains.set(key, settled);
|
|
504
|
+
await settled;
|
|
505
|
+
}
|
|
506
|
+
async _dispatchCardAction(event) {
|
|
507
|
+
const chatId = event.context?.open_chat_id;
|
|
508
|
+
const messageId = event.context?.open_message_id;
|
|
509
|
+
const actionStr = event.action?.value?.action;
|
|
510
|
+
if (!chatId || !actionStr || !this._hCardAction)
|
|
511
|
+
return;
|
|
512
|
+
if (!this._isAllowed(chatId)) {
|
|
513
|
+
this._debug(`[card-action] blocked: chat=${chatId}`);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const from = {
|
|
517
|
+
openId: event.operator?.open_id || '',
|
|
518
|
+
userId: event.operator?.user_id,
|
|
519
|
+
};
|
|
520
|
+
this._debug(`[recv] card_action chat=${chatId} msg=${messageId} action="${actionStr}"`);
|
|
521
|
+
await this._hCardAction(actionStr, {
|
|
522
|
+
chatId,
|
|
523
|
+
messageId,
|
|
524
|
+
from,
|
|
525
|
+
editReply: (msgId, text, opts) => this.editMessage(chatId, msgId, text, opts),
|
|
526
|
+
channel: this,
|
|
527
|
+
raw: event,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
async _dispatchMenuEvent(event) {
|
|
531
|
+
const eventKey = event.event_key;
|
|
532
|
+
const openId = event.operator?.operator_id?.open_id;
|
|
533
|
+
if (!eventKey || !openId || !this._hCommand)
|
|
534
|
+
return;
|
|
535
|
+
// Try: event payload → cache → API resolve
|
|
536
|
+
const chatId = this._openIdToChat.get(openId)
|
|
537
|
+
?? await this._resolveP2pChatId(openId);
|
|
538
|
+
if (!chatId) {
|
|
539
|
+
this._log(`[menu] cannot resolve chat_id for open_id=${openId}, event_key=${eventKey}`, 'warn');
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (!this._isAllowed(chatId))
|
|
543
|
+
return;
|
|
544
|
+
this._debug(`[recv] menu event_key=${eventKey} open_id=${openId} chat=${chatId}`);
|
|
545
|
+
const from = { openId, userId: event.operator?.operator_id?.user_id };
|
|
546
|
+
const ctx = this._makeCtx(chatId, '', from, 'p2p', event);
|
|
547
|
+
await this._hCommand(eventKey, '', ctx);
|
|
548
|
+
}
|
|
549
|
+
async _dispatchMessageRecalled(event) {
|
|
550
|
+
const chatId = String(event?.chat_id || '').trim();
|
|
551
|
+
const messageId = String(event?.message_id || '').trim();
|
|
552
|
+
if (!chatId || !messageId || !this._hRecall)
|
|
553
|
+
return;
|
|
554
|
+
if (!this._isAllowed(chatId)) {
|
|
555
|
+
this._debug(`[message-recalled] blocked: chat=${chatId}`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
this._trackChat(chatId);
|
|
559
|
+
this._debug(`[recv] message_recalled chat=${chatId} msg=${messageId}`);
|
|
560
|
+
await this._hRecall(messageId, chatId, event);
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Resolve a p2p chat_id for a given open_id by sending a minimal message
|
|
564
|
+
* via open_id and extracting the chat_id from the API response.
|
|
565
|
+
*/
|
|
566
|
+
async _resolveP2pChatId(openId) {
|
|
567
|
+
try {
|
|
568
|
+
const resp = await this.client.im.message.create({
|
|
569
|
+
params: { receive_id_type: 'open_id' },
|
|
570
|
+
data: {
|
|
571
|
+
receive_id: openId,
|
|
572
|
+
msg_type: 'text',
|
|
573
|
+
content: JSON.stringify({ text: '...' }),
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
const chatId = resp?.data?.chat_id ?? null;
|
|
577
|
+
const msgId = resp?.data?.message_id;
|
|
578
|
+
// Clean up the placeholder message
|
|
579
|
+
if (msgId) {
|
|
580
|
+
try {
|
|
581
|
+
await this.client.im.message.delete({ path: { message_id: msgId } });
|
|
582
|
+
}
|
|
583
|
+
catch { }
|
|
584
|
+
}
|
|
585
|
+
if (chatId) {
|
|
586
|
+
this._openIdToChat.set(openId, chatId);
|
|
587
|
+
this._trackChat(chatId);
|
|
588
|
+
this._debug(`[menu] resolved chat_id=${chatId} for open_id=${openId}`);
|
|
589
|
+
}
|
|
590
|
+
return chatId;
|
|
591
|
+
}
|
|
592
|
+
catch (e) {
|
|
593
|
+
this._log(`[menu] resolve chat_id failed for open_id=${openId}: ${e?.message || e}`, 'warn');
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
// ========================================================================
|
|
598
|
+
// Outgoing primitives (Channel interface)
|
|
599
|
+
// ========================================================================
|
|
600
|
+
async setMenu(commands) {
|
|
601
|
+
this._debug(`[menu] ${commands.length} commands. Configure in Feishu Developer Console → Bot → Custom Menu:`);
|
|
602
|
+
for (const c of commands) {
|
|
603
|
+
this._debug(`[menu] event_key="${c.command}" name="${c.description}"`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
async clearMenu() {
|
|
607
|
+
this._debug('[menu] cleared (remove items in Feishu Developer Console)');
|
|
608
|
+
}
|
|
609
|
+
async sendCard(chatId, view) {
|
|
610
|
+
const card = buildCardFromView(view);
|
|
611
|
+
this._logOutgoing('send', `chat=${chatId} chars=${view.markdown.length} rows=${view.rows?.length || 0}`);
|
|
612
|
+
const resp = await this.client.im.message.create({
|
|
613
|
+
params: { receive_id_type: 'chat_id' },
|
|
614
|
+
data: {
|
|
615
|
+
receive_id: String(chatId),
|
|
616
|
+
msg_type: 'interactive',
|
|
617
|
+
content: JSON.stringify(card),
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
return requireMessageId(resp, 'send interactive card');
|
|
621
|
+
}
|
|
622
|
+
async send(chatId, text, opts = {}) {
|
|
623
|
+
const rows = keyboardToRows(opts.keyboard);
|
|
624
|
+
const view = { markdown: text.trim() || '(empty)', rows };
|
|
625
|
+
// Reply to a specific message if replyTo is set
|
|
626
|
+
if (opts.replyTo) {
|
|
627
|
+
return await this.replyCard(String(opts.replyTo), view);
|
|
628
|
+
}
|
|
629
|
+
return await this.sendCard(chatId, view);
|
|
630
|
+
}
|
|
631
|
+
async replyCard(replyToMsgId, view) {
|
|
632
|
+
const card = buildCardFromView(view);
|
|
633
|
+
this._logOutgoing('reply', `reply_to=${replyToMsgId} chars=${view.markdown.length} rows=${view.rows?.length || 0}`);
|
|
634
|
+
const resp = await this.client.im.message.reply({
|
|
635
|
+
path: { message_id: replyToMsgId },
|
|
636
|
+
data: {
|
|
637
|
+
msg_type: 'interactive',
|
|
638
|
+
content: JSON.stringify(card),
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
return requireMessageId(resp, 'reply interactive card');
|
|
642
|
+
}
|
|
643
|
+
async editCard(chatId, msgId, view) {
|
|
644
|
+
if (!view.markdown.trim())
|
|
645
|
+
return;
|
|
646
|
+
const card = buildCardFromView(view);
|
|
647
|
+
this._logOutgoing('edit', `chat=${chatId} msg_id=${msgId} chars=${view.markdown.length} rows=${view.rows?.length || 0}`);
|
|
648
|
+
// The Lark SDK's response interceptor returns the JSON body for any HTTP 2xx, so
|
|
649
|
+
// Feishu application errors (`code != 0`) never throw — they look like success here.
|
|
650
|
+
// Inspect the response code ourselves so callers can fall back to a fresh send.
|
|
651
|
+
let resp;
|
|
652
|
+
try {
|
|
653
|
+
resp = await this.client.im.message.patch({
|
|
654
|
+
path: { message_id: String(msgId) },
|
|
655
|
+
data: { content: JSON.stringify(card) },
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
catch (e) {
|
|
659
|
+
const msg = String(e?.message || e).toLowerCase();
|
|
660
|
+
if (isFeishuNotModifiedMessage(msg))
|
|
661
|
+
return;
|
|
662
|
+
const err = e instanceof Error ? e : new Error(String(e ?? 'edit card failed'));
|
|
663
|
+
err.feishuEditFailed = true;
|
|
664
|
+
throw err;
|
|
665
|
+
}
|
|
666
|
+
const code = resp?.code;
|
|
667
|
+
if (code != null && code !== 0) {
|
|
668
|
+
const msg = String(resp?.msg ?? resp?.message ?? '').trim();
|
|
669
|
+
if (isFeishuNotModifiedMessage(msg))
|
|
670
|
+
return;
|
|
671
|
+
const err = new Error(`edit card failed: code=${code} msg=${msg || '(no message)'}`);
|
|
672
|
+
err.feishuCode = code;
|
|
673
|
+
err.feishuEditFailed = true;
|
|
674
|
+
throw err;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
async editMessage(chatId, msgId, text, opts = {}) {
|
|
678
|
+
if (!text.trim())
|
|
679
|
+
return;
|
|
680
|
+
const rows = keyboardToRows(opts.keyboard);
|
|
681
|
+
await this.editCard(chatId, msgId, {
|
|
682
|
+
markdown: text,
|
|
683
|
+
rows,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
async deleteMessage(_chatId, msgId) {
|
|
687
|
+
try {
|
|
688
|
+
await this.client.im.message.delete({
|
|
689
|
+
path: { message_id: String(msgId) },
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
catch { }
|
|
693
|
+
}
|
|
694
|
+
async sendTyping(_chatId) {
|
|
695
|
+
// Feishu has no typing indicator API — no-op
|
|
696
|
+
}
|
|
697
|
+
async setMessageReaction(_chatId, msgId, reactions) {
|
|
698
|
+
const messageId = String(msgId || '').trim();
|
|
699
|
+
const emojiTypes = [...new Set(reactions.map(reaction => String(reaction || '').trim()).filter(Boolean))];
|
|
700
|
+
if (!messageId || !emojiTypes.length)
|
|
701
|
+
return;
|
|
702
|
+
this._logOutgoing('setReaction', `msg_id=${messageId} reactions=${emojiTypes.join(',')}`);
|
|
703
|
+
for (const emojiType of emojiTypes) {
|
|
704
|
+
await this.client.im.messageReaction.create({
|
|
705
|
+
path: { message_id: messageId },
|
|
706
|
+
data: { reaction_type: { emoji_type: emojiType } },
|
|
707
|
+
}).catch(() => { });
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
// ========================================================================
|
|
711
|
+
// Feishu-specific outgoing
|
|
712
|
+
// ========================================================================
|
|
713
|
+
/** Send a text message (not card). For simple notifications. */
|
|
714
|
+
async sendText(chatId, text) {
|
|
715
|
+
const resp = await this.client.im.message.create({
|
|
716
|
+
params: { receive_id_type: 'chat_id' },
|
|
717
|
+
data: {
|
|
718
|
+
receive_id: chatId,
|
|
719
|
+
msg_type: 'text',
|
|
720
|
+
content: JSON.stringify({ text }),
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
return requireMessageId(resp, 'send text');
|
|
724
|
+
}
|
|
725
|
+
async sendPost(chatId, content, opts = {}) {
|
|
726
|
+
const replyTo = opts.replyTo ? String(opts.replyTo) : undefined;
|
|
727
|
+
this._logOutgoing('sendPost', `${replyTo ? `reply_to=${replyTo}` : `chat=${chatId}`} chars=${content.length}`);
|
|
728
|
+
const resp = replyTo
|
|
729
|
+
? await this.client.im.message.reply({
|
|
730
|
+
path: { message_id: replyTo },
|
|
731
|
+
data: { msg_type: 'post', content },
|
|
732
|
+
})
|
|
733
|
+
: await this.client.im.message.create({
|
|
734
|
+
params: { receive_id_type: 'chat_id' },
|
|
735
|
+
data: { receive_id: chatId, msg_type: 'post', content },
|
|
736
|
+
});
|
|
737
|
+
return requireMessageId(resp, 'send post');
|
|
738
|
+
}
|
|
739
|
+
/** Upload an image and return the image_key. */
|
|
740
|
+
async uploadImage(imageBuffer) {
|
|
741
|
+
this._logOutgoing('uploadImage', `bytes=${imageBuffer.byteLength}`);
|
|
742
|
+
const resp = await this.client.im.image.create({
|
|
743
|
+
data: {
|
|
744
|
+
image_type: 'message',
|
|
745
|
+
image: imageBuffer,
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
const imageKey = resp?.image_key ?? resp?.data?.image_key;
|
|
749
|
+
if (!imageKey)
|
|
750
|
+
throw new Error('Image upload failed: no image_key returned');
|
|
751
|
+
return imageKey;
|
|
752
|
+
}
|
|
753
|
+
/** Upload a file and return the file_key. */
|
|
754
|
+
async uploadFile(fileBuffer, fileName) {
|
|
755
|
+
const ext = path.extname(fileName).toLowerCase().slice(1);
|
|
756
|
+
const fileType = (['pdf', 'doc', 'xls', 'ppt'].includes(ext) ? ext : 'stream');
|
|
757
|
+
this._logOutgoing('uploadFile', `file=${fileName} bytes=${fileBuffer.byteLength}`);
|
|
758
|
+
const resp = await this.client.im.file.create({
|
|
759
|
+
data: {
|
|
760
|
+
file_type: fileType,
|
|
761
|
+
file_name: fileName,
|
|
762
|
+
file: fileBuffer,
|
|
763
|
+
},
|
|
764
|
+
});
|
|
765
|
+
const fileKey = resp?.file_key ?? resp?.data?.file_key;
|
|
766
|
+
if (!fileKey)
|
|
767
|
+
throw new Error('File upload failed: no file_key returned');
|
|
768
|
+
return fileKey;
|
|
769
|
+
}
|
|
770
|
+
/** Upload and send a local file. */
|
|
771
|
+
async sendFile(chatId, filePath, opts = {}) {
|
|
772
|
+
const stat = fs.statSync(filePath);
|
|
773
|
+
if (stat.size > FILE_MAX_BYTES) {
|
|
774
|
+
throw new Error(`file too large (${(stat.size / 1024 / 1024).toFixed(1)}MB, max ${FILE_MAX_BYTES / 1024 / 1024}MB)`);
|
|
775
|
+
}
|
|
776
|
+
const content = fs.readFileSync(filePath);
|
|
777
|
+
const filename = path.basename(filePath);
|
|
778
|
+
const isPhoto = opts.asPhoto ?? PHOTO_EXTS.has(path.extname(filename).toLowerCase());
|
|
779
|
+
const caption = typeof opts.caption === 'string' ? opts.caption.trim() : '';
|
|
780
|
+
const replyTo = opts.replyTo ? String(opts.replyTo) : undefined;
|
|
781
|
+
if (isPhoto) {
|
|
782
|
+
try {
|
|
783
|
+
const imageKey = await this.uploadImage(content);
|
|
784
|
+
if (caption) {
|
|
785
|
+
return await this.sendPost(String(chatId), buildPostContent([
|
|
786
|
+
[{ tag: 'img', image_key: imageKey }],
|
|
787
|
+
[{ tag: 'text', text: caption }],
|
|
788
|
+
]), { replyTo });
|
|
789
|
+
}
|
|
790
|
+
const msgContent = JSON.stringify({ image_key: imageKey });
|
|
791
|
+
this._logOutgoing('sendImage', `${replyTo ? `reply_to=${replyTo}` : `chat=${chatId}`} file=${filename}`);
|
|
792
|
+
const resp = replyTo
|
|
793
|
+
? await this.client.im.message.reply({ path: { message_id: replyTo }, data: { msg_type: 'image', content: msgContent } })
|
|
794
|
+
: await this.client.im.message.create({ params: { receive_id_type: 'chat_id' }, data: { receive_id: String(chatId), msg_type: 'image', content: msgContent } });
|
|
795
|
+
return requireMessageId(resp, 'send image');
|
|
796
|
+
}
|
|
797
|
+
catch (err) {
|
|
798
|
+
if (isRetryableUploadError(err))
|
|
799
|
+
throw err;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
const fileKey = await this.uploadFile(content, filename);
|
|
803
|
+
const msgContent = JSON.stringify({ file_key: fileKey });
|
|
804
|
+
this._logOutgoing('sendFile', `${replyTo ? `reply_to=${replyTo}` : `chat=${chatId}`} file=${filename}`);
|
|
805
|
+
const resp = replyTo
|
|
806
|
+
? await this.client.im.message.reply({ path: { message_id: replyTo }, data: { msg_type: 'file', content: msgContent } })
|
|
807
|
+
: await this.client.im.message.create({ params: { receive_id_type: 'chat_id' }, data: { receive_id: String(chatId), msg_type: 'file', content: msgContent } });
|
|
808
|
+
return requireMessageId(resp, 'send file');
|
|
809
|
+
}
|
|
810
|
+
// ========================================================================
|
|
811
|
+
// Download resources from received messages
|
|
812
|
+
// ========================================================================
|
|
813
|
+
async _downloadResource(messageId, fileKey, type, filename) {
|
|
814
|
+
const resp = await this.client.im.messageResource.get({
|
|
815
|
+
path: { message_id: messageId, file_key: fileKey },
|
|
816
|
+
params: { type },
|
|
817
|
+
});
|
|
818
|
+
const ext = type === 'image' ? '.jpg' : (filename ? path.extname(filename) : '.bin');
|
|
819
|
+
const name = filename || `feishu_${fileKey.slice(-8)}${ext}`;
|
|
820
|
+
const localPath = path.join(this.workdir, `_feishu_${name}`);
|
|
821
|
+
fs.mkdirSync(this.workdir, { recursive: true });
|
|
822
|
+
await resp.writeFile(localPath);
|
|
823
|
+
// Check downloaded file size
|
|
824
|
+
const stat = fs.statSync(localPath);
|
|
825
|
+
if (stat.size > FILE_MAX_BYTES) {
|
|
826
|
+
fs.rmSync(localPath, { force: true });
|
|
827
|
+
throw new Error(`file too large (${(stat.size / 1024 / 1024).toFixed(1)}MB, max ${FILE_MAX_BYTES / 1024 / 1024}MB)`);
|
|
828
|
+
}
|
|
829
|
+
return localPath;
|
|
830
|
+
}
|
|
831
|
+
// ========================================================================
|
|
832
|
+
// Internal helpers
|
|
833
|
+
// ========================================================================
|
|
834
|
+
_makeCtx(chatId, messageId, from, chatType, raw, replyToMessageId) {
|
|
835
|
+
return {
|
|
836
|
+
chatId,
|
|
837
|
+
messageId,
|
|
838
|
+
from,
|
|
839
|
+
chatType,
|
|
840
|
+
replyToMessageId: replyToMessageId || null,
|
|
841
|
+
reply: (text, opts) => this.send(chatId, text, { ...opts, replyTo: messageId || opts?.replyTo }),
|
|
842
|
+
editReply: (msgId, text, opts) => this.editMessage(chatId, msgId, text, opts),
|
|
843
|
+
channel: this,
|
|
844
|
+
raw,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
_isAllowed(chatId) {
|
|
848
|
+
return this.allowedChatIds.size === 0 || this.allowedChatIds.has(chatId);
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Add a chat to the in-memory `knownChats` set and persist it to setting.json
|
|
852
|
+
* so a future cold start (e.g. crash-respawn) can still address it. The
|
|
853
|
+
* persistence call is fire-and-forget — disk errors must not break receive.
|
|
854
|
+
*/
|
|
855
|
+
_trackChat(chatId) {
|
|
856
|
+
if (this.knownChats.has(chatId))
|
|
857
|
+
return;
|
|
858
|
+
this.knownChats.add(chatId);
|
|
859
|
+
try {
|
|
860
|
+
recordKnownChatId('feishu', chatId);
|
|
861
|
+
}
|
|
862
|
+
catch { }
|
|
863
|
+
}
|
|
864
|
+
_isBotMentioned(msg) {
|
|
865
|
+
const mentions = msg.mentions || [];
|
|
866
|
+
if (!this.bot)
|
|
867
|
+
return mentions.length > 0;
|
|
868
|
+
return mentions.some((m) => {
|
|
869
|
+
const mentionId = m.id?.open_id || m.id?.app_id || '';
|
|
870
|
+
return mentionId === this.bot.id || m.name === this.bot.displayName;
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
_cleanMention(text) {
|
|
874
|
+
return text.replace(/@_user_\d+/g, '').trim();
|
|
875
|
+
}
|
|
876
|
+
/** Extract plain text from a rich text (post) message content. */
|
|
877
|
+
_extractPostText(content) {
|
|
878
|
+
const post = content.zh_cn || content.en_us || content;
|
|
879
|
+
const parts = [];
|
|
880
|
+
if (post.title)
|
|
881
|
+
parts.push(post.title);
|
|
882
|
+
const paragraphs = post.content || [];
|
|
883
|
+
for (const paragraph of paragraphs) {
|
|
884
|
+
if (!Array.isArray(paragraph))
|
|
885
|
+
continue;
|
|
886
|
+
const line = paragraph
|
|
887
|
+
.map((elem) => {
|
|
888
|
+
if (elem.tag === 'text')
|
|
889
|
+
return elem.text || '';
|
|
890
|
+
if (elem.tag === 'a')
|
|
891
|
+
return elem.text || elem.href || '';
|
|
892
|
+
if (elem.tag === 'at')
|
|
893
|
+
return '';
|
|
894
|
+
return '';
|
|
895
|
+
})
|
|
896
|
+
.join('');
|
|
897
|
+
if (line.trim())
|
|
898
|
+
parts.push(line);
|
|
899
|
+
}
|
|
900
|
+
return parts.join('\n');
|
|
901
|
+
}
|
|
902
|
+
_debug(msg) {
|
|
903
|
+
this._log(msg, 'debug');
|
|
904
|
+
}
|
|
905
|
+
_log(msg, level = 'info') {
|
|
906
|
+
writeScopedLog('feishu', msg, { level });
|
|
907
|
+
}
|
|
908
|
+
_logOutgoing(action, meta) {
|
|
909
|
+
this._debug(`[send] ${action} ${meta}`);
|
|
910
|
+
}
|
|
911
|
+
}
|