@holon-run/agentinbox 0.1.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.
@@ -0,0 +1,567 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FeishuDeliveryAdapter = exports.FeishuSourceRuntime = exports.FeishuUxcClient = void 0;
4
+ exports.normalizeFeishuBotEvent = normalizeFeishuBotEvent;
5
+ const uxc_daemon_client_1 = require("@holon-run/uxc-daemon-client");
6
+ const FEISHU_OPENAPI_ENDPOINT = "https://open.feishu.cn/open-apis";
7
+ const FEISHU_IM_SCHEMA_URL = "https://raw.githubusercontent.com/holon-run/uxc/main/skills/feishu-openapi-skill/references/feishu-im.openapi.json";
8
+ const DEFAULT_EVENT_TYPES = ["im.message.receive_v1"];
9
+ const DEFAULT_SYNC_INTERVAL_SECS = 2;
10
+ const MAX_ERROR_BACKOFF_MULTIPLIER = 8;
11
+ class FeishuUxcClient {
12
+ client;
13
+ constructor(client = new uxc_daemon_client_1.UxcDaemonClient({ env: process.env })) {
14
+ this.client = client;
15
+ }
16
+ async ensureLongConnectionSubscription(config, checkpoint) {
17
+ if (checkpoint.uxcJobId) {
18
+ try {
19
+ const status = await this.client.subscribeStatus(checkpoint.uxcJobId);
20
+ if (status.status === "running" || status.status === "reconnecting") {
21
+ return {
22
+ job_id: checkpoint.uxcJobId,
23
+ mode: "stream",
24
+ protocol: "feishu_long_connection",
25
+ endpoint: config.endpoint ?? FEISHU_OPENAPI_ENDPOINT,
26
+ sink: "memory:",
27
+ status: status.status,
28
+ };
29
+ }
30
+ }
31
+ catch {
32
+ // Recreate the job below.
33
+ }
34
+ }
35
+ return this.client.subscribeStart({
36
+ endpoint: config.endpoint ?? FEISHU_OPENAPI_ENDPOINT,
37
+ mode: "stream",
38
+ options: { auth: config.uxcAuth },
39
+ sink: "memory:",
40
+ ephemeral: false,
41
+ transportHint: "feishu_long_connection",
42
+ });
43
+ }
44
+ async readSubscriptionEvents(jobId, afterSeq) {
45
+ const response = await this.client.subscriptionEvents({
46
+ jobId,
47
+ afterSeq,
48
+ limit: 100,
49
+ waitMs: 10,
50
+ });
51
+ return {
52
+ events: response.events,
53
+ nextAfterSeq: response.next_after_seq,
54
+ status: response.status,
55
+ };
56
+ }
57
+ async sendChatMessage(input) {
58
+ await this.client.call({
59
+ endpoint: input.endpoint ?? FEISHU_OPENAPI_ENDPOINT,
60
+ operation: "post:/im/v1/messages",
61
+ payload: {
62
+ receive_id_type: "chat_id",
63
+ receive_id: input.chatId,
64
+ msg_type: input.msgType,
65
+ content: input.content,
66
+ uuid: input.uuid ?? null,
67
+ },
68
+ options: {
69
+ auth: input.auth,
70
+ schema_url: input.schemaUrl ?? FEISHU_IM_SCHEMA_URL,
71
+ },
72
+ });
73
+ }
74
+ async replyToMessage(input) {
75
+ await this.client.call({
76
+ endpoint: input.endpoint ?? FEISHU_OPENAPI_ENDPOINT,
77
+ operation: "post:/im/v1/messages/{message_id}/reply",
78
+ payload: {
79
+ message_id: input.messageId,
80
+ msg_type: input.msgType,
81
+ content: input.content,
82
+ reply_in_thread: input.replyInThread ?? null,
83
+ uuid: input.uuid ?? null,
84
+ },
85
+ options: {
86
+ auth: input.auth,
87
+ schema_url: input.schemaUrl ?? FEISHU_IM_SCHEMA_URL,
88
+ },
89
+ });
90
+ }
91
+ }
92
+ exports.FeishuUxcClient = FeishuUxcClient;
93
+ class FeishuSourceRuntime {
94
+ store;
95
+ appendSourceEvent;
96
+ client;
97
+ interval = null;
98
+ inFlight = new Set();
99
+ errorCounts = new Map();
100
+ nextRetryAt = new Map();
101
+ constructor(store, appendSourceEvent, client) {
102
+ this.store = store;
103
+ this.appendSourceEvent = appendSourceEvent;
104
+ this.client = client ?? new FeishuUxcClient();
105
+ }
106
+ async ensureSource(source) {
107
+ if (source.sourceType !== "feishu_bot") {
108
+ return;
109
+ }
110
+ const config = parseFeishuSourceConfig(source);
111
+ const checkpoint = parseFeishuCheckpoint(source.checkpoint);
112
+ const started = await this.client.ensureLongConnectionSubscription(config, checkpoint);
113
+ this.store.updateSourceRuntime(source.sourceId, {
114
+ status: "active",
115
+ checkpoint: JSON.stringify({
116
+ ...checkpoint,
117
+ uxcJobId: started.job_id,
118
+ }),
119
+ });
120
+ }
121
+ async start() {
122
+ if (this.interval) {
123
+ return;
124
+ }
125
+ this.interval = setInterval(() => {
126
+ void this.syncAll();
127
+ }, 2_000);
128
+ try {
129
+ await this.syncAll();
130
+ }
131
+ catch (error) {
132
+ console.warn("feishu_bot initial sync failed:", error);
133
+ }
134
+ }
135
+ async stop() {
136
+ if (this.interval) {
137
+ clearInterval(this.interval);
138
+ this.interval = null;
139
+ }
140
+ }
141
+ async pollSource(sourceId) {
142
+ return this.syncSource(sourceId);
143
+ }
144
+ status() {
145
+ return {
146
+ activeSourceIds: Array.from(this.inFlight.values()).sort(),
147
+ erroredSourceIds: Array.from(this.errorCounts.keys()).sort(),
148
+ };
149
+ }
150
+ async syncAll() {
151
+ const sources = this.store
152
+ .listSources()
153
+ .filter((source) => source.sourceType === "feishu_bot" && source.status !== "paused");
154
+ for (const source of sources) {
155
+ try {
156
+ await this.syncSource(source.sourceId);
157
+ }
158
+ catch (error) {
159
+ console.warn(`feishu_bot sync failed for ${source.sourceId}:`, error);
160
+ }
161
+ }
162
+ }
163
+ async syncSource(sourceId) {
164
+ if (this.inFlight.has(sourceId)) {
165
+ return {
166
+ sourceId,
167
+ sourceType: "feishu_bot",
168
+ appended: 0,
169
+ deduped: 0,
170
+ eventsRead: 0,
171
+ note: "source sync already in flight",
172
+ };
173
+ }
174
+ this.inFlight.add(sourceId);
175
+ try {
176
+ const source = this.store.getSource(sourceId);
177
+ if (!source) {
178
+ throw new Error(`unknown source: ${sourceId}`);
179
+ }
180
+ const config = parseFeishuSourceConfig(source);
181
+ if (source.status === "error") {
182
+ const retryAt = this.nextRetryAt.get(sourceId) ?? 0;
183
+ if (Date.now() < retryAt) {
184
+ return {
185
+ sourceId,
186
+ sourceType: "feishu_bot",
187
+ appended: 0,
188
+ deduped: 0,
189
+ eventsRead: 0,
190
+ note: "error backoff not elapsed",
191
+ };
192
+ }
193
+ }
194
+ let checkpoint = parseFeishuCheckpoint(source.checkpoint);
195
+ const subscription = await this.client.ensureLongConnectionSubscription(config, checkpoint);
196
+ if (subscription.job_id !== checkpoint.uxcJobId) {
197
+ checkpoint = { ...checkpoint, uxcJobId: subscription.job_id };
198
+ }
199
+ const batch = await this.client.readSubscriptionEvents(checkpoint.uxcJobId, checkpoint.afterSeq ?? 0);
200
+ let appended = 0;
201
+ let deduped = 0;
202
+ for (const event of batch.events) {
203
+ if (event.event_kind !== "data") {
204
+ continue;
205
+ }
206
+ const normalized = normalizeFeishuBotEvent(source, config, event.data);
207
+ if (!normalized) {
208
+ continue;
209
+ }
210
+ const result = await this.appendSourceEvent(normalized);
211
+ appended += result.appended;
212
+ deduped += result.deduped;
213
+ }
214
+ this.store.updateSourceRuntime(sourceId, {
215
+ status: "active",
216
+ checkpoint: JSON.stringify({
217
+ ...checkpoint,
218
+ uxcJobId: checkpoint.uxcJobId,
219
+ afterSeq: batch.nextAfterSeq,
220
+ lastEventAt: new Date().toISOString(),
221
+ lastError: null,
222
+ }),
223
+ });
224
+ this.errorCounts.delete(sourceId);
225
+ this.nextRetryAt.delete(sourceId);
226
+ return {
227
+ sourceId,
228
+ sourceType: "feishu_bot",
229
+ appended,
230
+ deduped,
231
+ eventsRead: batch.events.length,
232
+ note: `subscription status=${batch.status}`,
233
+ };
234
+ }
235
+ catch (error) {
236
+ const source = this.store.getSource(sourceId);
237
+ if (source) {
238
+ const checkpoint = parseFeishuCheckpoint(source.checkpoint);
239
+ const nextErrorCount = (this.errorCounts.get(sourceId) ?? 0) + 1;
240
+ this.errorCounts.set(sourceId, nextErrorCount);
241
+ this.nextRetryAt.set(sourceId, Date.now() + computeErrorBackoffMs(DEFAULT_SYNC_INTERVAL_SECS, nextErrorCount));
242
+ this.store.updateSourceRuntime(sourceId, {
243
+ status: "error",
244
+ checkpoint: JSON.stringify({
245
+ ...checkpoint,
246
+ lastError: error instanceof Error ? error.message : String(error),
247
+ }),
248
+ });
249
+ }
250
+ throw error;
251
+ }
252
+ finally {
253
+ this.inFlight.delete(sourceId);
254
+ }
255
+ }
256
+ }
257
+ exports.FeishuSourceRuntime = FeishuSourceRuntime;
258
+ function computeErrorBackoffMs(syncIntervalSecs, errorCount) {
259
+ const baseMs = Math.max(1, syncIntervalSecs) * 1000;
260
+ const multiplier = Math.min(2 ** Math.max(0, errorCount - 1), MAX_ERROR_BACKOFF_MULTIPLIER);
261
+ return baseMs * multiplier;
262
+ }
263
+ class FeishuDeliveryAdapter {
264
+ client;
265
+ constructor(client) {
266
+ this.client = client ?? new FeishuUxcClient();
267
+ }
268
+ async send(request, attempt) {
269
+ const config = parseDeliveryConfig(request.payload);
270
+ const message = normalizeDeliveryMessage(request.payload);
271
+ if (attempt.surface === "message_reply") {
272
+ await this.client.replyToMessage({
273
+ endpoint: config.endpoint,
274
+ schemaUrl: config.schemaUrl,
275
+ auth: config.auth,
276
+ messageId: attempt.targetRef,
277
+ msgType: message.msgType,
278
+ content: message.content,
279
+ replyInThread: config.replyInThread,
280
+ uuid: config.uuid,
281
+ });
282
+ return { status: "sent", note: "sent Feishu message reply" };
283
+ }
284
+ if (attempt.surface === "chat_message") {
285
+ await this.client.sendChatMessage({
286
+ endpoint: config.endpoint,
287
+ schemaUrl: config.schemaUrl,
288
+ auth: config.auth,
289
+ chatId: attempt.targetRef,
290
+ msgType: message.msgType,
291
+ content: message.content,
292
+ uuid: config.uuid,
293
+ });
294
+ return { status: "sent", note: "sent Feishu chat message" };
295
+ }
296
+ throw new Error(`unsupported Feishu surface: ${attempt.surface}`);
297
+ }
298
+ }
299
+ exports.FeishuDeliveryAdapter = FeishuDeliveryAdapter;
300
+ function normalizeFeishuBotEvent(source, config, raw) {
301
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
302
+ return null;
303
+ }
304
+ const payload = raw;
305
+ const header = asRecord(payload.header);
306
+ const eventType = asString(payload.event_type) ?? asString(header.event_type);
307
+ if (!eventType || !(config.eventTypes ?? DEFAULT_EVENT_TYPES).includes(eventType)) {
308
+ return null;
309
+ }
310
+ const event = asRecord(payload.event);
311
+ const message = nonEmptyRecord(payload.message) ?? nonEmptyRecord(event.message) ?? {};
312
+ const sender = nonEmptyRecord(payload.sender) ?? nonEmptyRecord(event.sender) ?? {};
313
+ const messageId = asString(message.message_id);
314
+ const eventId = asString(header.event_id) ?? messageId;
315
+ const chatId = asString(message.chat_id);
316
+ if (!eventId || !messageId || !chatId) {
317
+ return null;
318
+ }
319
+ if (config.chatIds && config.chatIds.length > 0 && !config.chatIds.includes(chatId)) {
320
+ return null;
321
+ }
322
+ const mentions = extractMentionNames(message.mentions);
323
+ const mentionOpenIds = extractMentionOpenIds(message.mentions);
324
+ const messageType = asString(message.message_type) ?? "unknown";
325
+ const senderId = asString(asRecord(sender.sender_id).open_id);
326
+ const senderType = asString(sender.sender_type);
327
+ const content = stringifyFeishuMessageContent(messageType, asString(message.content), message.mentions);
328
+ const threadId = asString(message.thread_id) ?? asString(message.root_id);
329
+ const parentId = asString(message.parent_id);
330
+ return {
331
+ sourceId: source.sourceId,
332
+ sourceNativeId: `feishu_event:${eventId}`,
333
+ eventVariant: `${eventType}.${messageType}`,
334
+ occurredAt: fromUnixMillisString(asString(message.create_time))
335
+ ?? fromUnixMillisString(asString(header.create_time))
336
+ ?? new Date().toISOString(),
337
+ metadata: {
338
+ provider: "feishu",
339
+ eventType,
340
+ chatId,
341
+ chatType: asString(message.chat_type),
342
+ messageId,
343
+ messageType,
344
+ senderOpenId: senderId,
345
+ senderType,
346
+ mentions,
347
+ mentionOpenIds,
348
+ content,
349
+ threadId,
350
+ parentId,
351
+ },
352
+ rawPayload: {
353
+ header,
354
+ event_type: eventType,
355
+ event,
356
+ message,
357
+ sender,
358
+ },
359
+ deliveryHandle: {
360
+ provider: "feishu",
361
+ surface: "message_reply",
362
+ targetRef: messageId,
363
+ threadRef: threadId ?? parentId ?? null,
364
+ replyMode: "reply",
365
+ },
366
+ };
367
+ }
368
+ function parseFeishuSourceConfig(source) {
369
+ const config = source.config ?? {};
370
+ return {
371
+ endpoint: asString(config.endpoint) ?? FEISHU_OPENAPI_ENDPOINT,
372
+ schemaUrl: asString(config.schemaUrl) ?? FEISHU_IM_SCHEMA_URL,
373
+ uxcAuth: asString(config.uxcAuth) ?? source.configRef ?? asString(config.credentialRef) ?? undefined,
374
+ eventTypes: asStringArray(config.eventTypes) ?? DEFAULT_EVENT_TYPES,
375
+ chatIds: asStringArray(config.chatIds) ?? undefined,
376
+ };
377
+ }
378
+ function parseFeishuCheckpoint(checkpoint) {
379
+ if (!checkpoint) {
380
+ return {};
381
+ }
382
+ try {
383
+ return JSON.parse(checkpoint);
384
+ }
385
+ catch {
386
+ return {};
387
+ }
388
+ }
389
+ function parseDeliveryConfig(payload) {
390
+ return {
391
+ endpoint: asString(payload.endpoint) ?? undefined,
392
+ schemaUrl: asString(payload.schemaUrl) ?? asString(payload.schema_url) ?? FEISHU_IM_SCHEMA_URL,
393
+ auth: asString(payload.uxcAuth) ?? asString(payload.auth) ?? undefined,
394
+ replyInThread: typeof payload.replyInThread === "boolean"
395
+ ? payload.replyInThread
396
+ : typeof payload.reply_in_thread === "boolean"
397
+ ? payload.reply_in_thread
398
+ : undefined,
399
+ uuid: asString(payload.uuid) ?? undefined,
400
+ };
401
+ }
402
+ function normalizeDeliveryMessage(payload) {
403
+ const explicitContent = asString(payload.content);
404
+ const explicitMsgType = asString(payload.msgType) ?? asString(payload.msg_type);
405
+ if (explicitContent && explicitMsgType) {
406
+ return { msgType: explicitMsgType, content: explicitContent };
407
+ }
408
+ if (typeof payload.text === "string") {
409
+ return {
410
+ msgType: "text",
411
+ content: JSON.stringify({ text: payload.text }),
412
+ };
413
+ }
414
+ return {
415
+ msgType: "text",
416
+ content: JSON.stringify({ text: JSON.stringify(payload) }),
417
+ };
418
+ }
419
+ function extractMentionNames(raw) {
420
+ if (!Array.isArray(raw)) {
421
+ return [];
422
+ }
423
+ const names = raw
424
+ .map((item) => asRecord(item))
425
+ .map((item) => asString(item.name))
426
+ .filter((value) => Boolean(value));
427
+ return Array.from(new Set(names)).sort();
428
+ }
429
+ function extractMentionOpenIds(raw) {
430
+ if (!Array.isArray(raw)) {
431
+ return [];
432
+ }
433
+ const ids = raw
434
+ .map((item) => asRecord(item))
435
+ .map((item) => {
436
+ const id = item.id;
437
+ if (typeof id === "string") {
438
+ return id;
439
+ }
440
+ return asString(asRecord(id).open_id);
441
+ })
442
+ .filter((value) => Boolean(value));
443
+ return Array.from(new Set(ids)).sort();
444
+ }
445
+ function stringifyFeishuMessageContent(messageType, rawContent, mentions) {
446
+ if (!rawContent) {
447
+ return null;
448
+ }
449
+ let parsed = null;
450
+ try {
451
+ parsed = JSON.parse(rawContent);
452
+ }
453
+ catch {
454
+ return rawContent;
455
+ }
456
+ if (messageType === "text") {
457
+ return asString(asRecord(parsed).text) ?? rawContent;
458
+ }
459
+ if (messageType === "post") {
460
+ const mentionMap = buildMentionMap(mentions);
461
+ const lines = flattenPostContent(parsed, mentionMap);
462
+ return lines.length > 0 ? lines.join("\n") : rawContent;
463
+ }
464
+ if (messageType === "image") {
465
+ return imagePlaceholder(parsed);
466
+ }
467
+ if (messageType === "file" || messageType === "audio" || messageType === "video" || messageType === "media") {
468
+ return filePlaceholder(parsed, messageType);
469
+ }
470
+ if (messageType === "interactive") {
471
+ return "[interactive card]";
472
+ }
473
+ const text = asString(asRecord(parsed).text);
474
+ return text ?? JSON.stringify(parsed);
475
+ }
476
+ function buildMentionMap(raw) {
477
+ const map = new Map();
478
+ if (!Array.isArray(raw)) {
479
+ return map;
480
+ }
481
+ for (const item of raw) {
482
+ const value = asRecord(item);
483
+ const key = asString(value.key);
484
+ const name = asString(value.name);
485
+ if (key && name) {
486
+ map.set(key, `@${name}`);
487
+ }
488
+ }
489
+ return map;
490
+ }
491
+ function flattenPostContent(raw, mentionMap) {
492
+ const parsed = asRecord(raw);
493
+ const localeEntries = Object.entries(parsed);
494
+ const content = asRecord(localeEntries[0]?.[1]).content;
495
+ if (!Array.isArray(content)) {
496
+ return [];
497
+ }
498
+ const lines = [];
499
+ for (const row of content) {
500
+ if (!Array.isArray(row)) {
501
+ continue;
502
+ }
503
+ const parts = row
504
+ .map((cell) => {
505
+ const record = asRecord(cell);
506
+ const tag = asString(record.tag);
507
+ if (tag === "text") {
508
+ return asString(record.text) ?? "";
509
+ }
510
+ if (tag === "a") {
511
+ return asString(record.text) ?? asString(record.href) ?? "";
512
+ }
513
+ if (tag === "at") {
514
+ const key = asString(record.user_id);
515
+ return (key && mentionMap.get(key)) ?? asString(record.user_name) ?? "@mention";
516
+ }
517
+ if (tag === "img") {
518
+ return "[image]";
519
+ }
520
+ return "";
521
+ })
522
+ .filter((value) => value.length > 0);
523
+ if (parts.length > 0) {
524
+ lines.push(parts.join(""));
525
+ }
526
+ }
527
+ return lines;
528
+ }
529
+ function imagePlaceholder(raw) {
530
+ const imageKey = asString(asRecord(raw).image_key);
531
+ return imageKey ? `[image:${imageKey}]` : "[image]";
532
+ }
533
+ function filePlaceholder(raw, kind) {
534
+ const fileKey = asString(asRecord(raw).file_key);
535
+ return fileKey ? `[${kind}:${fileKey}]` : `[${kind}]`;
536
+ }
537
+ function fromUnixMillisString(value) {
538
+ if (!value) {
539
+ return null;
540
+ }
541
+ const millis = Number(value);
542
+ if (!Number.isFinite(millis)) {
543
+ return null;
544
+ }
545
+ return new Date(millis).toISOString();
546
+ }
547
+ function nonEmptyRecord(value) {
548
+ const record = asRecord(value);
549
+ return Object.keys(record).length > 0 ? record : null;
550
+ }
551
+ function asRecord(value) {
552
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
553
+ return {};
554
+ }
555
+ return value;
556
+ }
557
+ function asString(value) {
558
+ return typeof value === "string" ? value : null;
559
+ }
560
+ function asStringArray(value) {
561
+ if (!Array.isArray(value)) {
562
+ return null;
563
+ }
564
+ return value
565
+ .map((item) => asString(item))
566
+ .filter((item) => Boolean(item));
567
+ }