@botcord/daemon 0.2.59 → 0.2.61
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/dist/config.d.ts +4 -1
- package/dist/config.js +2 -2
- package/dist/cross-room.js +3 -1
- package/dist/daemon-config-map.js +6 -0
- package/dist/daemon.js +21 -1
- package/dist/gateway/channels/botcord.d.ts +7 -0
- package/dist/gateway/channels/botcord.js +3 -1
- package/dist/gateway/channels/feishu-registration.d.ts +35 -0
- package/dist/gateway/channels/feishu-registration.js +101 -0
- package/dist/gateway/channels/feishu.d.ts +16 -0
- package/dist/gateway/channels/feishu.js +459 -0
- package/dist/gateway/channels/index.d.ts +2 -0
- package/dist/gateway/channels/index.js +2 -0
- package/dist/gateway/channels/login-session.d.ts +9 -1
- package/dist/gateway/channels/login-session.js +1 -1
- package/dist/gateway/dispatcher.js +7 -3
- package/dist/gateway/policy-resolver.d.ts +10 -6
- package/dist/gateway/types.d.ts +2 -1
- package/dist/gateway-control.d.ts +8 -1
- package/dist/gateway-control.js +171 -18
- package/dist/provision.js +7 -1
- package/package.json +2 -1
- package/src/__tests__/cross-room.test.ts +2 -0
- package/src/__tests__/gateway-control.test.ts +84 -0
- package/src/__tests__/policy-updated-handler.test.ts +5 -7
- package/src/__tests__/third-party-gateway.test.ts +28 -0
- package/src/config.ts +6 -3
- package/src/cross-room.ts +3 -1
- package/src/daemon-config-map.ts +3 -0
- package/src/daemon.ts +24 -3
- package/src/gateway/__tests__/botcord-channel.test.ts +77 -0
- package/src/gateway/__tests__/dispatcher.test.ts +14 -4
- package/src/gateway/__tests__/feishu-channel.test.ts +306 -0
- package/src/gateway/channels/botcord.ts +10 -1
- package/src/gateway/channels/feishu-registration.ts +155 -0
- package/src/gateway/channels/feishu.ts +554 -0
- package/src/gateway/channels/index.ts +6 -0
- package/src/gateway/channels/login-session.ts +10 -2
- package/src/gateway/dispatcher.ts +7 -3
- package/src/gateway/policy-resolver.ts +19 -11
- package/src/gateway/types.ts +2 -1
- package/src/gateway-control.ts +188 -17
- package/src/provision.ts +13 -1
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { sanitizeUntrustedContent } from "./sanitize.js";
|
|
5
|
+
import { loadGatewaySecret } from "./secret-store.js";
|
|
6
|
+
import { GatewayStateStore } from "./state-store.js";
|
|
7
|
+
import { splitText } from "./text-split.js";
|
|
8
|
+
const FEISHU_PROVIDER = "feishu";
|
|
9
|
+
const DEFAULT_SPLIT_AT = 4000;
|
|
10
|
+
const MAX_SEEN_MESSAGES = 2048;
|
|
11
|
+
function sdkDomain(domain) {
|
|
12
|
+
const sdk = Lark;
|
|
13
|
+
return domain === "lark" ? sdk.Domain?.Lark : sdk.Domain?.Feishu;
|
|
14
|
+
}
|
|
15
|
+
function parseMessageContent(content) {
|
|
16
|
+
if (!content)
|
|
17
|
+
return null;
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(content);
|
|
20
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return { text: content };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function parseInboundText(message) {
|
|
27
|
+
const parsed = parseMessageContent(message.content);
|
|
28
|
+
if (!parsed)
|
|
29
|
+
return null;
|
|
30
|
+
if (typeof parsed.text === "string")
|
|
31
|
+
return parsed.text;
|
|
32
|
+
if (message.message_type === "image" && typeof parsed.image_key === "string") {
|
|
33
|
+
return `[image: ${parsed.image_key}]`;
|
|
34
|
+
}
|
|
35
|
+
if (message.message_type === "file" && typeof parsed.file_key === "string") {
|
|
36
|
+
const name = typeof parsed.file_name === "string" ? ` ${parsed.file_name}` : "";
|
|
37
|
+
return `[file${name}: ${parsed.file_key}]`;
|
|
38
|
+
}
|
|
39
|
+
if (message.message_type === "audio" && typeof parsed.file_key === "string") {
|
|
40
|
+
return `[audio: ${parsed.file_key}]`;
|
|
41
|
+
}
|
|
42
|
+
if (message.message_type === "media" && typeof parsed.file_key === "string") {
|
|
43
|
+
return `[video: ${parsed.file_key}]`;
|
|
44
|
+
}
|
|
45
|
+
if (message.message_type)
|
|
46
|
+
return `[${message.message_type} message]`;
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
function senderLabel(event) {
|
|
50
|
+
const mentions = event.message?.mentions ?? [];
|
|
51
|
+
const senderOpenId = event.sender?.sender_id?.open_id;
|
|
52
|
+
const hit = mentions.find((m) => m.id?.open_id && m.id.open_id === senderOpenId);
|
|
53
|
+
return typeof hit?.name === "string" && hit.name ? hit.name : undefined;
|
|
54
|
+
}
|
|
55
|
+
export function createFeishuChannel(opts) {
|
|
56
|
+
const splitAt = opts.splitAt && opts.splitAt > 0 ? opts.splitAt : DEFAULT_SPLIT_AT;
|
|
57
|
+
const allowedSenderIds = new Set((opts.allowedSenderIds ?? []).map(String));
|
|
58
|
+
const allowedChatIds = new Set((opts.allowedChatIds ?? []).map(String));
|
|
59
|
+
let appSecret = opts.appSecret;
|
|
60
|
+
let wsClient = null;
|
|
61
|
+
let client = null;
|
|
62
|
+
let stateStore = null;
|
|
63
|
+
let botOpenId;
|
|
64
|
+
let botName;
|
|
65
|
+
let liveSetStatus = null;
|
|
66
|
+
let statusSnapshot = {
|
|
67
|
+
channel: opts.id,
|
|
68
|
+
accountId: opts.accountId,
|
|
69
|
+
running: false,
|
|
70
|
+
connected: false,
|
|
71
|
+
reconnectAttempts: 0,
|
|
72
|
+
lastError: null,
|
|
73
|
+
provider: FEISHU_PROVIDER,
|
|
74
|
+
authorized: false,
|
|
75
|
+
};
|
|
76
|
+
function ensureState() {
|
|
77
|
+
if (!stateStore) {
|
|
78
|
+
stateStore = new GatewayStateStore(opts.id, {
|
|
79
|
+
override: opts.stateFile,
|
|
80
|
+
debounceMs: opts.stateDebounceMs,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return stateStore;
|
|
84
|
+
}
|
|
85
|
+
function readProviderState() {
|
|
86
|
+
return (ensureState().getProviderState() ?? {});
|
|
87
|
+
}
|
|
88
|
+
function hasSeenMessage(messageId) {
|
|
89
|
+
const seen = readProviderState().seenMessageIds ?? {};
|
|
90
|
+
return Object.prototype.hasOwnProperty.call(seen, messageId);
|
|
91
|
+
}
|
|
92
|
+
function rememberMessage(messageId) {
|
|
93
|
+
const providerState = readProviderState();
|
|
94
|
+
const seen = { ...(providerState.seenMessageIds ?? {}), [messageId]: Date.now() };
|
|
95
|
+
const entries = Object.entries(seen).sort((a, b) => a[1] - b[1]);
|
|
96
|
+
while (entries.length > MAX_SEEN_MESSAGES)
|
|
97
|
+
entries.shift();
|
|
98
|
+
ensureState().update({
|
|
99
|
+
providerState: {
|
|
100
|
+
...providerState,
|
|
101
|
+
seenMessageIds: Object.fromEntries(entries),
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function loadSecretIfNeeded() {
|
|
106
|
+
if (appSecret)
|
|
107
|
+
return appSecret;
|
|
108
|
+
const secret = loadGatewaySecret(opts.id, opts.secretFile);
|
|
109
|
+
if (typeof secret?.appSecret === "string" && secret.appSecret.length > 0) {
|
|
110
|
+
appSecret = secret.appSecret;
|
|
111
|
+
}
|
|
112
|
+
return appSecret;
|
|
113
|
+
}
|
|
114
|
+
function ensureClient() {
|
|
115
|
+
if (client)
|
|
116
|
+
return client;
|
|
117
|
+
if (!opts.appId || !loadSecretIfNeeded()) {
|
|
118
|
+
throw new Error("feishu appId/appSecret not loaded");
|
|
119
|
+
}
|
|
120
|
+
const sdk = Lark;
|
|
121
|
+
client = new sdk.Client({
|
|
122
|
+
appId: opts.appId,
|
|
123
|
+
appSecret,
|
|
124
|
+
appType: sdk.AppType?.SelfBuild,
|
|
125
|
+
domain: sdkDomain(opts.domain),
|
|
126
|
+
});
|
|
127
|
+
return client;
|
|
128
|
+
}
|
|
129
|
+
function markStatus(patch, setStatus) {
|
|
130
|
+
statusSnapshot = { ...statusSnapshot, ...patch };
|
|
131
|
+
(setStatus ?? liveSetStatus)?.(patch);
|
|
132
|
+
}
|
|
133
|
+
async function probe() {
|
|
134
|
+
const res = (await ensureClient().request({
|
|
135
|
+
method: "POST",
|
|
136
|
+
url: "/open-apis/bot/v1/openclaw_bot/ping",
|
|
137
|
+
data: { needBotInfo: true },
|
|
138
|
+
}));
|
|
139
|
+
if (res.code !== 0) {
|
|
140
|
+
throw new Error(res.msg || `feishu bot ping failed: code=${res.code}`);
|
|
141
|
+
}
|
|
142
|
+
botOpenId = res.data?.pingBotInfo?.botID;
|
|
143
|
+
botName = res.data?.pingBotInfo?.botName;
|
|
144
|
+
}
|
|
145
|
+
function normalizeMessage(event) {
|
|
146
|
+
const message = event.message;
|
|
147
|
+
const sender = event.sender;
|
|
148
|
+
if (!message || !sender)
|
|
149
|
+
return null;
|
|
150
|
+
const chatId = message.chat_id;
|
|
151
|
+
const messageId = message.message_id;
|
|
152
|
+
const senderOpenId = sender.sender_id?.open_id;
|
|
153
|
+
if (!chatId || !messageId || !senderOpenId)
|
|
154
|
+
return null;
|
|
155
|
+
if (botOpenId && senderOpenId === botOpenId)
|
|
156
|
+
return null;
|
|
157
|
+
if (hasSeenMessage(messageId))
|
|
158
|
+
return null;
|
|
159
|
+
if (allowedChatIds.size > 0 && !allowedChatIds.has(chatId))
|
|
160
|
+
return null;
|
|
161
|
+
if (!allowedSenderIds.has(senderOpenId))
|
|
162
|
+
return null;
|
|
163
|
+
const text = parseInboundText(message);
|
|
164
|
+
if (text === null)
|
|
165
|
+
return null;
|
|
166
|
+
rememberMessage(messageId);
|
|
167
|
+
const chatType = message.chat_type ?? "";
|
|
168
|
+
const conversationKind = chatType === "p2p" ? "direct" : "group";
|
|
169
|
+
const conversationId = conversationKind === "direct" ? `feishu:user:${chatId}` : `feishu:chat:${chatId}`;
|
|
170
|
+
const receivedAt = Number(message.create_time) || Date.now();
|
|
171
|
+
return {
|
|
172
|
+
id: `feishu:${messageId}`,
|
|
173
|
+
channel: opts.id,
|
|
174
|
+
accountId: opts.accountId,
|
|
175
|
+
conversation: {
|
|
176
|
+
id: conversationId,
|
|
177
|
+
kind: conversationKind,
|
|
178
|
+
threadId: message.root_id || message.parent_id || null,
|
|
179
|
+
},
|
|
180
|
+
sender: {
|
|
181
|
+
id: `feishu:user:${senderOpenId}`,
|
|
182
|
+
...(senderLabel(event) ? { name: senderLabel(event) } : {}),
|
|
183
|
+
kind: "user",
|
|
184
|
+
},
|
|
185
|
+
text: sanitizeUntrustedContent(text),
|
|
186
|
+
raw: event,
|
|
187
|
+
replyTo: messageId,
|
|
188
|
+
mentioned: true,
|
|
189
|
+
receivedAt,
|
|
190
|
+
trace: { id: `feishu:${messageId}`, streamable: true },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
async function start(ctx) {
|
|
194
|
+
liveSetStatus = ctx.setStatus;
|
|
195
|
+
try {
|
|
196
|
+
if (!opts.appId || !loadSecretIfNeeded()) {
|
|
197
|
+
markStatus({
|
|
198
|
+
running: false,
|
|
199
|
+
connected: false,
|
|
200
|
+
authorized: false,
|
|
201
|
+
lastError: "feishu appId/appSecret not loaded",
|
|
202
|
+
}, ctx.setStatus);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
await probe();
|
|
206
|
+
const sdk = Lark;
|
|
207
|
+
const dispatcher = new sdk.EventDispatcher({});
|
|
208
|
+
dispatcher.register({
|
|
209
|
+
"im.message.receive_v1": async (data) => {
|
|
210
|
+
const normalized = normalizeMessage(data);
|
|
211
|
+
if (!normalized)
|
|
212
|
+
return;
|
|
213
|
+
markStatus({ lastInboundAt: Date.now(), connected: true, authorized: true });
|
|
214
|
+
await ctx.emit({ message: normalized });
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
wsClient = new sdk.WSClient({
|
|
218
|
+
appId: opts.appId,
|
|
219
|
+
appSecret,
|
|
220
|
+
domain: sdkDomain(opts.domain),
|
|
221
|
+
loggerLevel: sdk.LoggerLevel?.info,
|
|
222
|
+
});
|
|
223
|
+
markStatus({
|
|
224
|
+
running: true,
|
|
225
|
+
connected: true,
|
|
226
|
+
authorized: true,
|
|
227
|
+
lastError: null,
|
|
228
|
+
}, ctx.setStatus);
|
|
229
|
+
Promise.resolve(wsClient.start({ eventDispatcher: dispatcher })).catch((err) => {
|
|
230
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
231
|
+
markStatus({
|
|
232
|
+
running: false,
|
|
233
|
+
connected: false,
|
|
234
|
+
authorized: false,
|
|
235
|
+
lastError: message,
|
|
236
|
+
reconnectAttempts: (statusSnapshot.reconnectAttempts ?? 0) + 1,
|
|
237
|
+
});
|
|
238
|
+
ctx.log.warn("feishu ws client failed", { err: message });
|
|
239
|
+
});
|
|
240
|
+
await new Promise((resolve) => {
|
|
241
|
+
if (ctx.abortSignal.aborted)
|
|
242
|
+
return resolve();
|
|
243
|
+
ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
248
|
+
markStatus({
|
|
249
|
+
running: false,
|
|
250
|
+
connected: false,
|
|
251
|
+
authorized: false,
|
|
252
|
+
lastError: message,
|
|
253
|
+
}, ctx.setStatus);
|
|
254
|
+
throw err;
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
try {
|
|
258
|
+
wsClient?.close({ force: true });
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// best effort
|
|
262
|
+
}
|
|
263
|
+
wsClient = null;
|
|
264
|
+
markStatus({ running: false, connected: false }, ctx.setStatus);
|
|
265
|
+
try {
|
|
266
|
+
stateStore?.flush();
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
ctx.log.warn("feishu state flush failed", {
|
|
270
|
+
err: err instanceof Error ? err.message : String(err),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function chatIdFromConversation(conversationId) {
|
|
276
|
+
if (conversationId.startsWith("feishu:user:")) {
|
|
277
|
+
return conversationId.slice("feishu:user:".length);
|
|
278
|
+
}
|
|
279
|
+
if (conversationId.startsWith("feishu:chat:")) {
|
|
280
|
+
return conversationId.slice("feishu:chat:".length);
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
async function callFeishu(args) {
|
|
285
|
+
const res = (await ensureClient().request(args));
|
|
286
|
+
if (res.code !== undefined && res.code !== 0) {
|
|
287
|
+
throw new Error(res.msg || `feishu api failed: code=${res.code}`);
|
|
288
|
+
}
|
|
289
|
+
return res;
|
|
290
|
+
}
|
|
291
|
+
function resultMessageId(res) {
|
|
292
|
+
return ((typeof res.data?.message_id === "string" ? res.data.message_id : undefined) ??
|
|
293
|
+
(typeof res.message_id === "string" ? res.message_id : undefined));
|
|
294
|
+
}
|
|
295
|
+
function resultResourceKey(res, key) {
|
|
296
|
+
const direct = res[key];
|
|
297
|
+
if (typeof direct === "string")
|
|
298
|
+
return direct;
|
|
299
|
+
const nested = res.data?.[key];
|
|
300
|
+
if (typeof nested === "string")
|
|
301
|
+
return nested;
|
|
302
|
+
throw new Error(`feishu upload failed: ${key} missing`);
|
|
303
|
+
}
|
|
304
|
+
function attachmentBytes(attachment) {
|
|
305
|
+
if (attachment.data)
|
|
306
|
+
return Buffer.from(attachment.data);
|
|
307
|
+
if (attachment.filePath)
|
|
308
|
+
return readFileSync(attachment.filePath);
|
|
309
|
+
throw new Error("feishu attachment requires filePath or data");
|
|
310
|
+
}
|
|
311
|
+
function fileNameForAttachment(attachment) {
|
|
312
|
+
if (attachment.filename)
|
|
313
|
+
return attachment.filename;
|
|
314
|
+
if (attachment.filePath)
|
|
315
|
+
return path.basename(attachment.filePath);
|
|
316
|
+
return "attachment";
|
|
317
|
+
}
|
|
318
|
+
function isImageAttachment(attachment) {
|
|
319
|
+
if (attachment.kind === "image")
|
|
320
|
+
return true;
|
|
321
|
+
if (attachment.contentType?.startsWith("image/"))
|
|
322
|
+
return true;
|
|
323
|
+
return /\.(png|jpe?g|gif|webp|bmp|tiff?|ico)$/i.test(fileNameForAttachment(attachment));
|
|
324
|
+
}
|
|
325
|
+
function feishuFileType(attachment) {
|
|
326
|
+
if (attachment.kind === "video")
|
|
327
|
+
return "mp4";
|
|
328
|
+
const name = fileNameForAttachment(attachment).toLowerCase();
|
|
329
|
+
const contentType = attachment.contentType?.toLowerCase() ?? "";
|
|
330
|
+
if (contentType.includes("pdf") || name.endsWith(".pdf"))
|
|
331
|
+
return "pdf";
|
|
332
|
+
if (contentType.includes("word") || /\.(doc|docx)$/i.test(name))
|
|
333
|
+
return "doc";
|
|
334
|
+
if (contentType.includes("spreadsheet") || /\.(xls|xlsx)$/i.test(name))
|
|
335
|
+
return "xls";
|
|
336
|
+
if (contentType.includes("presentation") || /\.(ppt|pptx)$/i.test(name))
|
|
337
|
+
return "ppt";
|
|
338
|
+
if (contentType.includes("audio/ogg") || name.endsWith(".opus"))
|
|
339
|
+
return "opus";
|
|
340
|
+
if (contentType.includes("video/mp4") || name.endsWith(".mp4"))
|
|
341
|
+
return "mp4";
|
|
342
|
+
return "stream";
|
|
343
|
+
}
|
|
344
|
+
async function uploadAttachment(attachment) {
|
|
345
|
+
const bytes = attachmentBytes(attachment);
|
|
346
|
+
if (isImageAttachment(attachment)) {
|
|
347
|
+
const res = await callFeishu({
|
|
348
|
+
method: "POST",
|
|
349
|
+
url: "/open-apis/im/v1/images",
|
|
350
|
+
data: { image_type: "message", image: bytes },
|
|
351
|
+
headers: { "Content-Type": "multipart/form-data" },
|
|
352
|
+
});
|
|
353
|
+
return { msgType: "image", content: { image_key: resultResourceKey(res, "image_key") } };
|
|
354
|
+
}
|
|
355
|
+
const fileType = feishuFileType(attachment);
|
|
356
|
+
const res = await callFeishu({
|
|
357
|
+
method: "POST",
|
|
358
|
+
url: "/open-apis/im/v1/files",
|
|
359
|
+
data: {
|
|
360
|
+
file_type: fileType,
|
|
361
|
+
file_name: fileNameForAttachment(attachment),
|
|
362
|
+
file: bytes,
|
|
363
|
+
},
|
|
364
|
+
headers: { "Content-Type": "multipart/form-data" },
|
|
365
|
+
});
|
|
366
|
+
const fileKey = resultResourceKey(res, "file_key");
|
|
367
|
+
if (fileType === "opus")
|
|
368
|
+
return { msgType: "audio", content: { file_key: fileKey } };
|
|
369
|
+
if (fileType === "mp4")
|
|
370
|
+
return { msgType: "media", content: { file_key: fileKey } };
|
|
371
|
+
return { msgType: "file", content: { file_key: fileKey } };
|
|
372
|
+
}
|
|
373
|
+
async function sendPayload(args) {
|
|
374
|
+
const data = {
|
|
375
|
+
msg_type: args.msgType,
|
|
376
|
+
content: JSON.stringify(args.content),
|
|
377
|
+
};
|
|
378
|
+
if (args.replyTo) {
|
|
379
|
+
data.reply_in_thread = args.replyInThread ?? false;
|
|
380
|
+
const res = await callFeishu({
|
|
381
|
+
method: "POST",
|
|
382
|
+
url: `/open-apis/im/v1/messages/${encodeURIComponent(args.replyTo)}/reply`,
|
|
383
|
+
data,
|
|
384
|
+
});
|
|
385
|
+
return resultMessageId(res);
|
|
386
|
+
}
|
|
387
|
+
const res = await callFeishu({
|
|
388
|
+
method: "POST",
|
|
389
|
+
url: "/open-apis/im/v1/messages",
|
|
390
|
+
params: { receive_id_type: "chat_id" },
|
|
391
|
+
data: {
|
|
392
|
+
...data,
|
|
393
|
+
receive_id: args.chatId,
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
return resultMessageId(res);
|
|
397
|
+
}
|
|
398
|
+
async function send(ctx) {
|
|
399
|
+
const chatId = chatIdFromConversation(ctx.message.conversationId);
|
|
400
|
+
if (!chatId) {
|
|
401
|
+
throw new Error("unsupported feishu conversation id");
|
|
402
|
+
}
|
|
403
|
+
let providerMessageId;
|
|
404
|
+
const replyTo = ctx.message.replyTo ?? ctx.message.threadId ?? null;
|
|
405
|
+
const textParts = ctx.message.text.length > 0 ? splitText(ctx.message.text, splitAt) : [];
|
|
406
|
+
for (const part of textParts) {
|
|
407
|
+
providerMessageId = await sendPayload({
|
|
408
|
+
chatId,
|
|
409
|
+
msgType: "text",
|
|
410
|
+
content: { text: part },
|
|
411
|
+
replyTo,
|
|
412
|
+
replyInThread: Boolean(ctx.message.threadId),
|
|
413
|
+
}) ?? providerMessageId;
|
|
414
|
+
}
|
|
415
|
+
for (const attachment of ctx.message.attachments ?? []) {
|
|
416
|
+
const uploaded = await uploadAttachment(attachment);
|
|
417
|
+
providerMessageId = await sendPayload({
|
|
418
|
+
chatId,
|
|
419
|
+
msgType: uploaded.msgType,
|
|
420
|
+
content: uploaded.content,
|
|
421
|
+
replyTo,
|
|
422
|
+
replyInThread: Boolean(ctx.message.threadId),
|
|
423
|
+
}) ?? providerMessageId;
|
|
424
|
+
}
|
|
425
|
+
markStatus({ lastSendAt: Date.now() });
|
|
426
|
+
return { providerMessageId };
|
|
427
|
+
}
|
|
428
|
+
async function typing(ctx) {
|
|
429
|
+
ctx.log.debug("feishu typing ignored: no native bot typing API", {
|
|
430
|
+
channel: opts.id,
|
|
431
|
+
conversationId: ctx.conversationId,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
async function stop(_ctx) {
|
|
435
|
+
try {
|
|
436
|
+
wsClient?.close({ force: true });
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
// best effort
|
|
440
|
+
}
|
|
441
|
+
wsClient = null;
|
|
442
|
+
try {
|
|
443
|
+
stateStore?.close();
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
// best effort
|
|
447
|
+
}
|
|
448
|
+
markStatus({ running: false, connected: false });
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
id: opts.id,
|
|
452
|
+
type: FEISHU_PROVIDER,
|
|
453
|
+
start,
|
|
454
|
+
stop,
|
|
455
|
+
send,
|
|
456
|
+
typing,
|
|
457
|
+
status: () => ({ ...statusSnapshot }),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
@@ -2,6 +2,8 @@ export { createBotCordChannel } from "./botcord.js";
|
|
|
2
2
|
export type { BotCordChannelClient, BotCordChannelOptions, BotCordClientFactory, } from "./botcord.js";
|
|
3
3
|
export { createTelegramChannel, type TelegramChannelOptions } from "./telegram.js";
|
|
4
4
|
export { createWechatChannel, type WechatChannelOptions } from "./wechat.js";
|
|
5
|
+
export { createFeishuChannel, type FeishuChannelOptions } from "./feishu.js";
|
|
6
|
+
export { startFeishuRegistration, pollFeishuRegistration, type FeishuDomain, } from "./feishu-registration.js";
|
|
5
7
|
export { getBotQrcode, getQrcodeStatus, DEFAULT_WECHAT_BASE_URL, type WechatQrcode, type WechatQrcodeStatus, type WechatLoginOptions, } from "./wechat-login.js";
|
|
6
8
|
export { defaultGatewaySecretPath, loadGatewaySecret, saveGatewaySecret, deleteGatewaySecret, } from "./secret-store.js";
|
|
7
9
|
export { GatewayStateStore, defaultGatewayStatePath, type GatewayStateStoreOptions, type ThirdPartyGatewayState, } from "./state-store.js";
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { createBotCordChannel } from "./botcord.js";
|
|
2
2
|
export { createTelegramChannel } from "./telegram.js";
|
|
3
3
|
export { createWechatChannel } from "./wechat.js";
|
|
4
|
+
export { createFeishuChannel } from "./feishu.js";
|
|
5
|
+
export { startFeishuRegistration, pollFeishuRegistration, } from "./feishu-registration.js";
|
|
4
6
|
export { getBotQrcode, getQrcodeStatus, DEFAULT_WECHAT_BASE_URL, } from "./wechat-login.js";
|
|
5
7
|
export { defaultGatewaySecretPath, loadGatewaySecret, saveGatewaySecret, deleteGatewaySecret, } from "./secret-store.js";
|
|
6
8
|
export { GatewayStateStore, defaultGatewayStatePath, } from "./state-store.js";
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* outside the daemon process or the per-gateway secret file. A daemon
|
|
9
9
|
* restart drops in-flight logins; the user just rescans.
|
|
10
10
|
*/
|
|
11
|
-
export type LoginProvider = "wechat" | "telegram";
|
|
11
|
+
export type LoginProvider = "wechat" | "telegram" | "feishu";
|
|
12
12
|
export interface LoginSession {
|
|
13
13
|
loginId: string;
|
|
14
14
|
accountId: string;
|
|
@@ -22,6 +22,14 @@ export interface LoginSession {
|
|
|
22
22
|
baseUrl?: string;
|
|
23
23
|
/** Stored only after the user confirms the qrcode. Never returned to Hub. */
|
|
24
24
|
botToken?: string;
|
|
25
|
+
/** Feishu/Lark PersonalAgent app id returned by app registration. */
|
|
26
|
+
appId?: string;
|
|
27
|
+
/** Feishu/Lark PersonalAgent app secret returned by app registration. */
|
|
28
|
+
appSecret?: string;
|
|
29
|
+
/** Feishu/Lark tenant domain selected during registration. */
|
|
30
|
+
domain?: "feishu" | "lark";
|
|
31
|
+
/** Feishu/Lark user open_id returned by app registration. */
|
|
32
|
+
userOpenId?: string;
|
|
25
33
|
/** Masked preview safe for Hub/dashboard display. */
|
|
26
34
|
tokenPreview?: string;
|
|
27
35
|
/** Unix millis. */
|
|
@@ -91,7 +91,7 @@ export function maskTokenPreview(token) {
|
|
|
91
91
|
* ids and racefully claim someone else's session.
|
|
92
92
|
*/
|
|
93
93
|
export function mintLoginId(provider) {
|
|
94
|
-
const prefix = provider === "wechat" ? "wxl" : "tgl";
|
|
94
|
+
const prefix = provider === "wechat" ? "wxl" : provider === "feishu" ? "fsl" : "tgl";
|
|
95
95
|
const ts = Date.now().toString(36);
|
|
96
96
|
// 32 hex chars = 128 bits of entropy — W5 regression fix from round 2.
|
|
97
97
|
const rand = randomBytes(16).toString("hex");
|
|
@@ -1003,6 +1003,7 @@ export class Dispatcher {
|
|
|
1003
1003
|
// own loop-risk accounting downstream.
|
|
1004
1004
|
const isOwnerChat = isOwnerChatRoom(msg);
|
|
1005
1005
|
const canDeliverRuntimeText = isOwnerChat || !isBotCordChannel(channel);
|
|
1006
|
+
const canDeliverRuntimeDiagnostics = canDeliverRuntimeText || isBotCordChannel(channel);
|
|
1006
1007
|
if (slot.timedOut) {
|
|
1007
1008
|
this.transcript.write({
|
|
1008
1009
|
ts: nowIso(),
|
|
@@ -1015,12 +1016,13 @@ export class Dispatcher {
|
|
|
1015
1016
|
error: `runtime timeout after ${this.turnTimeoutMs}ms`,
|
|
1016
1017
|
durationMs: Date.now() - slot.dispatchedAt,
|
|
1017
1018
|
});
|
|
1018
|
-
if (
|
|
1019
|
+
if (canDeliverRuntimeDiagnostics) {
|
|
1019
1020
|
await this.sendReply(channel, {
|
|
1020
1021
|
channel: msg.channel,
|
|
1021
1022
|
accountId: msg.accountId,
|
|
1022
1023
|
conversationId: msg.conversation.id,
|
|
1023
1024
|
threadId: msg.conversation.threadId ?? null,
|
|
1025
|
+
type: "error",
|
|
1024
1026
|
text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
|
|
1025
1027
|
replyTo: msg.id,
|
|
1026
1028
|
traceId: msg.trace?.id ?? null,
|
|
@@ -1060,12 +1062,13 @@ export class Dispatcher {
|
|
|
1060
1062
|
error: errMsg,
|
|
1061
1063
|
durationMs: Date.now() - slot.dispatchedAt,
|
|
1062
1064
|
});
|
|
1063
|
-
if (
|
|
1065
|
+
if (canDeliverRuntimeDiagnostics) {
|
|
1064
1066
|
await this.sendReply(channel, {
|
|
1065
1067
|
channel: msg.channel,
|
|
1066
1068
|
accountId: msg.accountId,
|
|
1067
1069
|
conversationId: msg.conversation.id,
|
|
1068
1070
|
threadId: msg.conversation.threadId ?? null,
|
|
1071
|
+
type: "error",
|
|
1069
1072
|
text: `⚠️ Runtime error: ${truncate(errMsg, 500)}`,
|
|
1070
1073
|
replyTo: msg.id,
|
|
1071
1074
|
traceId: msg.trace?.id ?? null,
|
|
@@ -1150,12 +1153,13 @@ export class Dispatcher {
|
|
|
1150
1153
|
runtime: route.runtime,
|
|
1151
1154
|
error: result.error,
|
|
1152
1155
|
});
|
|
1153
|
-
if (
|
|
1156
|
+
if (canDeliverRuntimeDiagnostics) {
|
|
1154
1157
|
const sendResult = await this.sendReply(channel, {
|
|
1155
1158
|
channel: msg.channel,
|
|
1156
1159
|
accountId: msg.accountId,
|
|
1157
1160
|
conversationId: msg.conversation.id,
|
|
1158
1161
|
threadId: msg.conversation.threadId ?? null,
|
|
1162
|
+
type: "error",
|
|
1159
1163
|
text: `⚠️ Runtime error: ${truncate(result.error, 500)}`,
|
|
1160
1164
|
replyTo: msg.id,
|
|
1161
1165
|
traceId: msg.trace?.id ?? null,
|
|
@@ -22,25 +22,29 @@
|
|
|
22
22
|
* the agent is revoked or the cache must rebuild from scratch.
|
|
23
23
|
*/
|
|
24
24
|
import type { AttentionPolicy } from "@botcord/protocol-core";
|
|
25
|
+
export type DaemonAttentionPolicy = Omit<AttentionPolicy, "mode"> & {
|
|
26
|
+
mode: AttentionPolicy["mode"] | "allowed_senders";
|
|
27
|
+
allowedSenderIds?: string[];
|
|
28
|
+
};
|
|
25
29
|
/** Public surface — kept narrow so the dispatcher can mock easily in tests. */
|
|
26
30
|
export interface PolicyResolverLike {
|
|
27
|
-
resolve(agentId: string, roomId: string | null): Promise<
|
|
31
|
+
resolve(agentId: string, roomId: string | null): Promise<DaemonAttentionPolicy>;
|
|
28
32
|
invalidate(agentId: string, roomId?: string): void;
|
|
29
33
|
/**
|
|
30
34
|
* Install (or replace) the cached policy entry for an agent / room. Used
|
|
31
35
|
* by the `policy_updated` control-frame handler to apply embedded policy
|
|
32
36
|
* payloads without forcing a refetch.
|
|
33
37
|
*/
|
|
34
|
-
put(agentId: string, roomId: string | null, policy:
|
|
38
|
+
put(agentId: string, roomId: string | null, policy: DaemonAttentionPolicy): void;
|
|
35
39
|
}
|
|
36
40
|
export interface PolicyResolverOptions {
|
|
37
41
|
/** Fetcher for the per-agent default. Returning `undefined` means "no policy known"; the resolver falls back to `mode=always`. */
|
|
38
|
-
fetchGlobal: (agentId: string) => Promise<
|
|
42
|
+
fetchGlobal: (agentId: string) => Promise<DaemonAttentionPolicy | undefined>;
|
|
39
43
|
/**
|
|
40
44
|
* Optional per-room fetcher. PR2 supplies this; PR3 leaves it
|
|
41
45
|
* unimplemented and the resolver collapses to the global policy.
|
|
42
46
|
*/
|
|
43
|
-
fetchEffective?: (agentId: string, roomId: string) => Promise<
|
|
47
|
+
fetchEffective?: (agentId: string, roomId: string) => Promise<DaemonAttentionPolicy | undefined>;
|
|
44
48
|
/** Cache TTL in milliseconds. Defaults to 5 minutes. */
|
|
45
49
|
ttlMs?: number;
|
|
46
50
|
}
|
|
@@ -50,8 +54,8 @@ export declare class PolicyResolver implements PolicyResolverLike {
|
|
|
50
54
|
private readonly ttlMs;
|
|
51
55
|
private readonly cache;
|
|
52
56
|
constructor(opts: PolicyResolverOptions);
|
|
53
|
-
resolve(agentId: string, roomId: string | null): Promise<
|
|
57
|
+
resolve(agentId: string, roomId: string | null): Promise<DaemonAttentionPolicy>;
|
|
54
58
|
private safeFetch;
|
|
55
59
|
invalidate(agentId: string, roomId?: string): void;
|
|
56
|
-
put(agentId: string, roomId: string | null, policy:
|
|
60
|
+
put(agentId: string, roomId: string | null, policy: DaemonAttentionPolicy): void;
|
|
57
61
|
}
|
package/dist/gateway/types.d.ts
CHANGED
|
@@ -154,6 +154,7 @@ export interface GatewayOutboundMessage {
|
|
|
154
154
|
accountId: string;
|
|
155
155
|
conversationId: string;
|
|
156
156
|
threadId?: string | null;
|
|
157
|
+
type?: "message" | "error";
|
|
157
158
|
text: string;
|
|
158
159
|
attachments?: GatewayOutboundAttachment[];
|
|
159
160
|
replyTo?: string | null;
|
|
@@ -171,7 +172,7 @@ export interface ChannelStatusSnapshot {
|
|
|
171
172
|
lastStopAt?: number;
|
|
172
173
|
lastError?: string | null;
|
|
173
174
|
/** Third-party provider id when this channel is not the built-in BotCord. */
|
|
174
|
-
provider?: "wechat" | "telegram";
|
|
175
|
+
provider?: "wechat" | "telegram" | "feishu";
|
|
175
176
|
/** Last time the adapter polled the upstream provider (ms epoch). */
|
|
176
177
|
lastPollAt?: number;
|
|
177
178
|
/** Last time the adapter accepted an inbound message (ms epoch). */
|
|
@@ -13,9 +13,10 @@ import type { Gateway } from "./gateway/index.js";
|
|
|
13
13
|
import { type DaemonConfig } from "./config.js";
|
|
14
14
|
import { LoginSessionStore } from "./gateway/channels/login-session.js";
|
|
15
15
|
import { getBotQrcode, getQrcodeStatus } from "./gateway/channels/wechat-login.js";
|
|
16
|
+
import { pollFeishuRegistration, startFeishuRegistration } from "./gateway/channels/feishu-registration.js";
|
|
16
17
|
import type { FetchLike } from "./gateway/channels/http-types.js";
|
|
17
18
|
type AckBody = Omit<ControlAck, "id">;
|
|
18
|
-
type GatewayProvider = "telegram" | "wechat";
|
|
19
|
+
type GatewayProvider = "telegram" | "wechat" | "feishu";
|
|
19
20
|
interface UpsertGatewayParams {
|
|
20
21
|
id: string;
|
|
21
22
|
type: GatewayProvider;
|
|
@@ -28,6 +29,7 @@ interface UpsertGatewayParams {
|
|
|
28
29
|
};
|
|
29
30
|
settings?: {
|
|
30
31
|
baseUrl?: string;
|
|
32
|
+
domain?: "feishu" | "lark";
|
|
31
33
|
allowedSenderIds?: string[];
|
|
32
34
|
allowedChatIds?: string[];
|
|
33
35
|
splitAt?: number;
|
|
@@ -45,6 +47,7 @@ interface GatewayLoginStartParams {
|
|
|
45
47
|
accountId: string;
|
|
46
48
|
gatewayId?: string;
|
|
47
49
|
baseUrl?: string;
|
|
50
|
+
domain?: "feishu" | "lark";
|
|
48
51
|
}
|
|
49
52
|
interface GatewayLoginStatusParams {
|
|
50
53
|
provider: GatewayProvider;
|
|
@@ -75,6 +78,10 @@ export interface GatewayControlContext {
|
|
|
75
78
|
getBotQrcode: typeof getBotQrcode;
|
|
76
79
|
getQrcodeStatus: typeof getQrcodeStatus;
|
|
77
80
|
};
|
|
81
|
+
feishuLoginClient?: {
|
|
82
|
+
startFeishuRegistration: typeof startFeishuRegistration;
|
|
83
|
+
pollFeishuRegistration: typeof pollFeishuRegistration;
|
|
84
|
+
};
|
|
78
85
|
/** Override the global fetch — used by `test_gateway` for Telegram getMe. */
|
|
79
86
|
fetchImpl?: FetchLike;
|
|
80
87
|
}
|