@botcord/daemon 0.2.70 → 0.2.71
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/gateway/channels/feishu.js +94 -4
- package/dist/gateway/dispatcher.d.ts +1 -0
- package/dist/gateway/dispatcher.js +7 -4
- package/package.json +2 -2
- package/src/gateway/__tests__/dispatcher.test.ts +27 -2
- package/src/gateway/__tests__/feishu-channel.test.ts +61 -8
- package/src/gateway/channels/feishu.ts +96 -4
- package/src/gateway/dispatcher.ts +8 -4
|
@@ -8,6 +8,8 @@ import { splitText } from "./text-split.js";
|
|
|
8
8
|
const FEISHU_PROVIDER = "feishu";
|
|
9
9
|
const DEFAULT_SPLIT_AT = 4000;
|
|
10
10
|
const MAX_SEEN_MESSAGES = 2048;
|
|
11
|
+
const TYPING_EMOJI = "Typing";
|
|
12
|
+
const TYPING_REACTION_TTL_MS = 20_000;
|
|
11
13
|
function sdkDomain(domain) {
|
|
12
14
|
const sdk = Lark;
|
|
13
15
|
return domain === "lark" ? sdk.Domain?.Lark : sdk.Domain?.Feishu;
|
|
@@ -63,6 +65,7 @@ export function createFeishuChannel(opts) {
|
|
|
63
65
|
let botOpenId;
|
|
64
66
|
let botName;
|
|
65
67
|
let liveSetStatus = null;
|
|
68
|
+
const activeTypingReactions = new Map();
|
|
66
69
|
let statusSnapshot = {
|
|
67
70
|
channel: opts.id,
|
|
68
71
|
accountId: opts.accountId,
|
|
@@ -292,6 +295,45 @@ export function createFeishuChannel(opts) {
|
|
|
292
295
|
return ((typeof res.data?.message_id === "string" ? res.data.message_id : undefined) ??
|
|
293
296
|
(typeof res.message_id === "string" ? res.message_id : undefined));
|
|
294
297
|
}
|
|
298
|
+
function resultReactionId(res) {
|
|
299
|
+
return ((typeof res.data?.reaction_id === "string" ? res.data.reaction_id : undefined) ??
|
|
300
|
+
(typeof res.reaction_id === "string" ? res.reaction_id : undefined) ??
|
|
301
|
+
null);
|
|
302
|
+
}
|
|
303
|
+
function messageIdFromTrace(traceId) {
|
|
304
|
+
if (!traceId.startsWith("feishu:"))
|
|
305
|
+
return null;
|
|
306
|
+
const messageId = traceId.slice("feishu:".length).trim();
|
|
307
|
+
return messageId.length > 0 ? messageId : null;
|
|
308
|
+
}
|
|
309
|
+
async function removeTypingReaction(messageId) {
|
|
310
|
+
const state = activeTypingReactions.get(messageId);
|
|
311
|
+
if (!state)
|
|
312
|
+
return;
|
|
313
|
+
activeTypingReactions.delete(messageId);
|
|
314
|
+
if (state.timer)
|
|
315
|
+
clearTimeout(state.timer);
|
|
316
|
+
if (!state.reactionId)
|
|
317
|
+
return;
|
|
318
|
+
try {
|
|
319
|
+
await callFeishu({
|
|
320
|
+
method: "DELETE",
|
|
321
|
+
url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(state.reactionId)}`,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
statusSnapshot.lastError = err instanceof Error ? err.message : String(err);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function scheduleTypingCleanup(messageId, state) {
|
|
329
|
+
if (state.timer)
|
|
330
|
+
clearTimeout(state.timer);
|
|
331
|
+
state.timer = setTimeout(() => {
|
|
332
|
+
void removeTypingReaction(messageId);
|
|
333
|
+
}, TYPING_REACTION_TTL_MS);
|
|
334
|
+
if (typeof state.timer.unref === "function")
|
|
335
|
+
state.timer.unref();
|
|
336
|
+
}
|
|
295
337
|
function resultResourceKey(res, key) {
|
|
296
338
|
const direct = res[key];
|
|
297
339
|
if (typeof direct === "string")
|
|
@@ -422,14 +464,61 @@ export function createFeishuChannel(opts) {
|
|
|
422
464
|
replyInThread: Boolean(ctx.message.threadId),
|
|
423
465
|
}) ?? providerMessageId;
|
|
424
466
|
}
|
|
467
|
+
if (ctx.message.replyTo) {
|
|
468
|
+
void removeTypingReaction(ctx.message.replyTo);
|
|
469
|
+
}
|
|
470
|
+
if (ctx.message.threadId && ctx.message.threadId !== ctx.message.replyTo) {
|
|
471
|
+
void removeTypingReaction(ctx.message.threadId);
|
|
472
|
+
}
|
|
425
473
|
markStatus({ lastSendAt: Date.now() });
|
|
426
474
|
return { providerMessageId };
|
|
427
475
|
}
|
|
428
476
|
async function typing(ctx) {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
477
|
+
const messageId = messageIdFromTrace(ctx.traceId);
|
|
478
|
+
if (!messageId) {
|
|
479
|
+
ctx.log.debug("feishu typing skipped: trace id has no message id", {
|
|
480
|
+
channel: opts.id,
|
|
481
|
+
conversationId: ctx.conversationId,
|
|
482
|
+
traceId: ctx.traceId,
|
|
483
|
+
});
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const existing = activeTypingReactions.get(messageId);
|
|
487
|
+
if (existing) {
|
|
488
|
+
scheduleTypingCleanup(messageId, existing);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const state = { reactionId: null, timer: null };
|
|
492
|
+
activeTypingReactions.set(messageId, state);
|
|
493
|
+
scheduleTypingCleanup(messageId, state);
|
|
494
|
+
try {
|
|
495
|
+
const res = await callFeishu({
|
|
496
|
+
method: "POST",
|
|
497
|
+
url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions`,
|
|
498
|
+
data: { reaction_type: { emoji_type: TYPING_EMOJI } },
|
|
499
|
+
});
|
|
500
|
+
const reactionId = resultReactionId(res);
|
|
501
|
+
if (activeTypingReactions.get(messageId) !== state) {
|
|
502
|
+
if (reactionId) {
|
|
503
|
+
await callFeishu({
|
|
504
|
+
method: "DELETE",
|
|
505
|
+
url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(reactionId)}`,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
state.reactionId = reactionId;
|
|
511
|
+
}
|
|
512
|
+
catch (err) {
|
|
513
|
+
activeTypingReactions.delete(messageId);
|
|
514
|
+
if (state.timer)
|
|
515
|
+
clearTimeout(state.timer);
|
|
516
|
+
ctx.log.warn("feishu typing reaction failed", {
|
|
517
|
+
channel: opts.id,
|
|
518
|
+
conversationId: ctx.conversationId,
|
|
519
|
+
err: err instanceof Error ? err.message : String(err),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
433
522
|
}
|
|
434
523
|
async function stop(_ctx) {
|
|
435
524
|
try {
|
|
@@ -439,6 +528,7 @@ export function createFeishuChannel(opts) {
|
|
|
439
528
|
// best effort
|
|
440
529
|
}
|
|
441
530
|
wsClient = null;
|
|
531
|
+
await Promise.allSettled(Array.from(activeTypingReactions.keys()).map(removeTypingReaction));
|
|
442
532
|
try {
|
|
443
533
|
stateStore?.close();
|
|
444
534
|
}
|
|
@@ -1098,7 +1098,7 @@ export class Dispatcher {
|
|
|
1098
1098
|
threadId: msg.conversation.threadId ?? null,
|
|
1099
1099
|
type: "error",
|
|
1100
1100
|
text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
|
|
1101
|
-
replyTo: msg
|
|
1101
|
+
replyTo: this.providerReplyTo(msg),
|
|
1102
1102
|
traceId: msg.trace?.id ?? null,
|
|
1103
1103
|
}, turnId);
|
|
1104
1104
|
}
|
|
@@ -1144,7 +1144,7 @@ export class Dispatcher {
|
|
|
1144
1144
|
threadId: msg.conversation.threadId ?? null,
|
|
1145
1145
|
type: "error",
|
|
1146
1146
|
text: `⚠️ Runtime error: ${truncate(errMsg, 500)}`,
|
|
1147
|
-
replyTo: msg
|
|
1147
|
+
replyTo: this.providerReplyTo(msg),
|
|
1148
1148
|
traceId: msg.trace?.id ?? null,
|
|
1149
1149
|
}, turnId);
|
|
1150
1150
|
}
|
|
@@ -1252,7 +1252,7 @@ export class Dispatcher {
|
|
|
1252
1252
|
threadId: msg.conversation.threadId ?? null,
|
|
1253
1253
|
type: "error",
|
|
1254
1254
|
text: `⚠️ Runtime error: ${truncate(result.error, 500)}`,
|
|
1255
|
-
replyTo: msg
|
|
1255
|
+
replyTo: this.providerReplyTo(msg),
|
|
1256
1256
|
traceId: msg.trace?.id ?? null,
|
|
1257
1257
|
}, turnId);
|
|
1258
1258
|
this.emitOutbound({
|
|
@@ -1323,7 +1323,7 @@ export class Dispatcher {
|
|
|
1323
1323
|
conversationId: msg.conversation.id,
|
|
1324
1324
|
threadId: msg.conversation.threadId ?? null,
|
|
1325
1325
|
text: replyText,
|
|
1326
|
-
replyTo: msg
|
|
1326
|
+
replyTo: this.providerReplyTo(msg),
|
|
1327
1327
|
traceId: msg.trace?.id ?? null,
|
|
1328
1328
|
}, turnId);
|
|
1329
1329
|
this.emitOutbound({
|
|
@@ -1388,6 +1388,9 @@ export class Dispatcher {
|
|
|
1388
1388
|
}
|
|
1389
1389
|
return { ok: true };
|
|
1390
1390
|
}
|
|
1391
|
+
providerReplyTo(msg) {
|
|
1392
|
+
return msg.replyTo ?? msg.id;
|
|
1393
|
+
}
|
|
1391
1394
|
emitInbound(turnId, msg) {
|
|
1392
1395
|
if (!this.transcript.enabled)
|
|
1393
1396
|
return;
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botcord/daemon",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.71",
|
|
4
4
|
"description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"botcord-daemon": "
|
|
7
|
+
"botcord-daemon": "dist/index.js"
|
|
8
8
|
},
|
|
9
9
|
"main": "./dist/index.js",
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
@@ -293,6 +293,30 @@ describe("Dispatcher", () => {
|
|
|
293
293
|
expect(store.all()[0].threadId).toBe("t_1");
|
|
294
294
|
});
|
|
295
295
|
|
|
296
|
+
it("sends replies to the provider reply id when it differs from the internal message id", async () => {
|
|
297
|
+
const runtime = new FakeRuntime({ reply: "ok" });
|
|
298
|
+
const feishuChannel = new FakeChannel({ id: "gw_feishu_1", type: "feishu" });
|
|
299
|
+
const { dispatcher, channel } = await scaffold({
|
|
300
|
+
runtimeFactory: () => runtime,
|
|
301
|
+
channel: feishuChannel,
|
|
302
|
+
config: baseConfig({
|
|
303
|
+
channels: [{ id: "gw_feishu_1", type: "feishu", accountId: "ag_me" }],
|
|
304
|
+
}),
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
await dispatcher.handle(
|
|
308
|
+
makeEnvelope({
|
|
309
|
+
id: "feishu:om_internal_wrapped",
|
|
310
|
+
replyTo: "om_provider_raw",
|
|
311
|
+
channel: "gw_feishu_1",
|
|
312
|
+
conversation: { id: "feishu:user:oc_chat", kind: "direct" },
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
expect(channel.sends.length).toBe(1);
|
|
317
|
+
expect(channel.sends[0].message.replyTo).toBe("om_provider_raw");
|
|
318
|
+
});
|
|
319
|
+
|
|
296
320
|
it("reuses session id on second message with same queue key", async () => {
|
|
297
321
|
const seen: Array<string | null> = [];
|
|
298
322
|
const runtime = new FakeRuntime({
|
|
@@ -2009,7 +2033,8 @@ describe("Dispatcher", () => {
|
|
|
2009
2033
|
});
|
|
2010
2034
|
await dispatcher.handle(
|
|
2011
2035
|
makeEnvelope({
|
|
2012
|
-
id: "
|
|
2036
|
+
id: "feishu:om_internal_err",
|
|
2037
|
+
replyTo: "om_provider_err",
|
|
2013
2038
|
conversation: { id: "rm_g_other", kind: "group" },
|
|
2014
2039
|
}),
|
|
2015
2040
|
);
|
|
@@ -2017,7 +2042,7 @@ describe("Dispatcher", () => {
|
|
|
2017
2042
|
expect(channel.sends[0].message.type).toBe("error");
|
|
2018
2043
|
expect(channel.sends[0].message.text).toContain("Runtime error: boom");
|
|
2019
2044
|
expect(channel.sends[0].message.conversationId).toBe("rm_g_other");
|
|
2020
|
-
expect(channel.sends[0].message.replyTo).toBe("
|
|
2045
|
+
expect(channel.sends[0].message.replyTo).toBe("om_provider_err");
|
|
2021
2046
|
});
|
|
2022
2047
|
|
|
2023
2048
|
// ─────────────────────────────────────────────────────────────────────
|
|
@@ -255,27 +255,80 @@ describe("createFeishuChannel", () => {
|
|
|
255
255
|
expect(JSON.parse(send.data.content as string)).toEqual({ file_key: "file_v2_uploaded" });
|
|
256
256
|
});
|
|
257
257
|
|
|
258
|
-
it("
|
|
259
|
-
const debug = vi.fn();
|
|
258
|
+
it("adds a Typing reaction for typing and removes it when replying", async () => {
|
|
260
259
|
const adapter = createFeishuChannel({
|
|
261
260
|
id: "gw_fs",
|
|
262
261
|
accountId: "ag_self",
|
|
263
262
|
appId: "cli_a",
|
|
264
263
|
appSecret: "sec",
|
|
265
264
|
});
|
|
265
|
+
larkMock.responses.push(
|
|
266
|
+
{ code: 0, data: { reaction_id: "react_typing_1" } },
|
|
267
|
+
{ code: 0, data: { message_id: "om_reply" } },
|
|
268
|
+
{ code: 0, data: {} },
|
|
269
|
+
);
|
|
266
270
|
|
|
267
271
|
await adapter.typing?.({
|
|
268
272
|
traceId: "feishu:om_1",
|
|
269
273
|
accountId: "ag_self",
|
|
270
274
|
conversationId: "feishu:chat:oc_chat",
|
|
271
|
-
log:
|
|
275
|
+
log: SILENT_LOG,
|
|
272
276
|
});
|
|
273
277
|
|
|
274
|
-
expect(larkMock.requests).toHaveLength(
|
|
275
|
-
expect(
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
278
|
+
expect(larkMock.requests).toHaveLength(1);
|
|
279
|
+
expect(larkMock.requests[0]).toEqual({
|
|
280
|
+
method: "POST",
|
|
281
|
+
url: "/open-apis/im/v1/messages/om_1/reactions",
|
|
282
|
+
data: { reaction_type: { emoji_type: "Typing" } },
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
await adapter.send({
|
|
286
|
+
message: {
|
|
287
|
+
channel: "gw_fs",
|
|
288
|
+
accountId: "ag_self",
|
|
289
|
+
conversationId: "feishu:chat:oc_chat",
|
|
290
|
+
text: "reply",
|
|
291
|
+
replyTo: "om_1",
|
|
292
|
+
},
|
|
293
|
+
log: SILENT_LOG,
|
|
294
|
+
});
|
|
295
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
296
|
+
|
|
297
|
+
expect(larkMock.requests).toHaveLength(3);
|
|
298
|
+
expect(larkMock.requests[2]).toEqual({
|
|
299
|
+
method: "DELETE",
|
|
300
|
+
url: "/open-apis/im/v1/messages/om_1/reactions/react_typing_1",
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("refreshes an existing Feishu typing reaction without creating duplicates", async () => {
|
|
305
|
+
const adapter = createFeishuChannel({
|
|
306
|
+
id: "gw_fs",
|
|
307
|
+
accountId: "ag_self",
|
|
308
|
+
appId: "cli_a",
|
|
309
|
+
appSecret: "sec",
|
|
310
|
+
});
|
|
311
|
+
larkMock.responses.push({ code: 0, data: { reaction_id: "react_typing_1" } });
|
|
312
|
+
|
|
313
|
+
await adapter.typing?.({
|
|
314
|
+
traceId: "feishu:om_1",
|
|
315
|
+
accountId: "ag_self",
|
|
316
|
+
conversationId: "feishu:chat:oc_chat",
|
|
317
|
+
log: SILENT_LOG,
|
|
318
|
+
});
|
|
319
|
+
await adapter.typing?.({
|
|
320
|
+
traceId: "feishu:om_1",
|
|
321
|
+
accountId: "ag_self",
|
|
322
|
+
conversationId: "feishu:chat:oc_chat",
|
|
323
|
+
log: SILENT_LOG,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
expect(larkMock.requests).toHaveLength(1);
|
|
327
|
+
expect(larkMock.requests[0]).toEqual({
|
|
328
|
+
method: "POST",
|
|
329
|
+
url: "/open-apis/im/v1/messages/om_1/reactions",
|
|
330
|
+
data: { reaction_type: { emoji_type: "Typing" } },
|
|
331
|
+
});
|
|
279
332
|
});
|
|
280
333
|
|
|
281
334
|
it("surfaces websocket start failures in channel status", async () => {
|
|
@@ -21,6 +21,8 @@ import type { FeishuDomain } from "./feishu-registration.js";
|
|
|
21
21
|
const FEISHU_PROVIDER = "feishu" as const;
|
|
22
22
|
const DEFAULT_SPLIT_AT = 4000;
|
|
23
23
|
const MAX_SEEN_MESSAGES = 2048;
|
|
24
|
+
const TYPING_EMOJI = "Typing";
|
|
25
|
+
const TYPING_REACTION_TTL_MS = 20_000;
|
|
24
26
|
|
|
25
27
|
export interface FeishuChannelOptions {
|
|
26
28
|
id: string;
|
|
@@ -80,6 +82,10 @@ interface FeishuApiResponse {
|
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
type FeishuClient = { request(args: unknown): Promise<unknown> };
|
|
85
|
+
type TypingReactionState = {
|
|
86
|
+
reactionId: string | null;
|
|
87
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
88
|
+
};
|
|
83
89
|
|
|
84
90
|
function sdkDomain(domain: FeishuDomain | undefined): unknown {
|
|
85
91
|
const sdk = Lark as unknown as {
|
|
@@ -137,6 +143,7 @@ export function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter
|
|
|
137
143
|
let botOpenId: string | undefined;
|
|
138
144
|
let botName: string | undefined;
|
|
139
145
|
let liveSetStatus: ((patch: Partial<ChannelStatusSnapshot>) => void) | null = null;
|
|
146
|
+
const activeTypingReactions = new Map<string, TypingReactionState>();
|
|
140
147
|
|
|
141
148
|
let statusSnapshot: ChannelStatusSnapshot = {
|
|
142
149
|
channel: opts.id,
|
|
@@ -387,6 +394,44 @@ export function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter
|
|
|
387
394
|
);
|
|
388
395
|
}
|
|
389
396
|
|
|
397
|
+
function resultReactionId(res: FeishuApiResponse): string | null {
|
|
398
|
+
return (
|
|
399
|
+
(typeof res.data?.reaction_id === "string" ? res.data.reaction_id : undefined) ??
|
|
400
|
+
(typeof res.reaction_id === "string" ? res.reaction_id : undefined) ??
|
|
401
|
+
null
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function messageIdFromTrace(traceId: string): string | null {
|
|
406
|
+
if (!traceId.startsWith("feishu:")) return null;
|
|
407
|
+
const messageId = traceId.slice("feishu:".length).trim();
|
|
408
|
+
return messageId.length > 0 ? messageId : null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function removeTypingReaction(messageId: string): Promise<void> {
|
|
412
|
+
const state = activeTypingReactions.get(messageId);
|
|
413
|
+
if (!state) return;
|
|
414
|
+
activeTypingReactions.delete(messageId);
|
|
415
|
+
if (state.timer) clearTimeout(state.timer);
|
|
416
|
+
if (!state.reactionId) return;
|
|
417
|
+
try {
|
|
418
|
+
await callFeishu({
|
|
419
|
+
method: "DELETE",
|
|
420
|
+
url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(state.reactionId)}`,
|
|
421
|
+
});
|
|
422
|
+
} catch (err) {
|
|
423
|
+
statusSnapshot.lastError = err instanceof Error ? err.message : String(err);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function scheduleTypingCleanup(messageId: string, state: TypingReactionState): void {
|
|
428
|
+
if (state.timer) clearTimeout(state.timer);
|
|
429
|
+
state.timer = setTimeout(() => {
|
|
430
|
+
void removeTypingReaction(messageId);
|
|
431
|
+
}, TYPING_REACTION_TTL_MS);
|
|
432
|
+
if (typeof state.timer.unref === "function") state.timer.unref();
|
|
433
|
+
}
|
|
434
|
+
|
|
390
435
|
function resultResourceKey(res: FeishuApiResponse, key: "image_key" | "file_key"): string {
|
|
391
436
|
const direct = res[key];
|
|
392
437
|
if (typeof direct === "string") return direct;
|
|
@@ -516,15 +561,61 @@ export function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter
|
|
|
516
561
|
replyInThread: Boolean(ctx.message.threadId),
|
|
517
562
|
}) ?? providerMessageId;
|
|
518
563
|
}
|
|
564
|
+
if (ctx.message.replyTo) {
|
|
565
|
+
void removeTypingReaction(ctx.message.replyTo);
|
|
566
|
+
}
|
|
567
|
+
if (ctx.message.threadId && ctx.message.threadId !== ctx.message.replyTo) {
|
|
568
|
+
void removeTypingReaction(ctx.message.threadId);
|
|
569
|
+
}
|
|
519
570
|
markStatus({ lastSendAt: Date.now() });
|
|
520
571
|
return { providerMessageId };
|
|
521
572
|
}
|
|
522
573
|
|
|
523
574
|
async function typing(ctx: ChannelTypingContext): Promise<void> {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
575
|
+
const messageId = messageIdFromTrace(ctx.traceId);
|
|
576
|
+
if (!messageId) {
|
|
577
|
+
ctx.log.debug("feishu typing skipped: trace id has no message id", {
|
|
578
|
+
channel: opts.id,
|
|
579
|
+
conversationId: ctx.conversationId,
|
|
580
|
+
traceId: ctx.traceId,
|
|
581
|
+
});
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const existing = activeTypingReactions.get(messageId);
|
|
585
|
+
if (existing) {
|
|
586
|
+
scheduleTypingCleanup(messageId, existing);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const state: TypingReactionState = { reactionId: null, timer: null };
|
|
591
|
+
activeTypingReactions.set(messageId, state);
|
|
592
|
+
scheduleTypingCleanup(messageId, state);
|
|
593
|
+
try {
|
|
594
|
+
const res = await callFeishu({
|
|
595
|
+
method: "POST",
|
|
596
|
+
url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions`,
|
|
597
|
+
data: { reaction_type: { emoji_type: TYPING_EMOJI } },
|
|
598
|
+
});
|
|
599
|
+
const reactionId = resultReactionId(res);
|
|
600
|
+
if (activeTypingReactions.get(messageId) !== state) {
|
|
601
|
+
if (reactionId) {
|
|
602
|
+
await callFeishu({
|
|
603
|
+
method: "DELETE",
|
|
604
|
+
url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(reactionId)}`,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
state.reactionId = reactionId;
|
|
610
|
+
} catch (err) {
|
|
611
|
+
activeTypingReactions.delete(messageId);
|
|
612
|
+
if (state.timer) clearTimeout(state.timer);
|
|
613
|
+
ctx.log.warn("feishu typing reaction failed", {
|
|
614
|
+
channel: opts.id,
|
|
615
|
+
conversationId: ctx.conversationId,
|
|
616
|
+
err: err instanceof Error ? err.message : String(err),
|
|
617
|
+
});
|
|
618
|
+
}
|
|
528
619
|
}
|
|
529
620
|
|
|
530
621
|
async function stop(_ctx: ChannelStopContext): Promise<void> {
|
|
@@ -534,6 +625,7 @@ export function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter
|
|
|
534
625
|
// best effort
|
|
535
626
|
}
|
|
536
627
|
wsClient = null;
|
|
628
|
+
await Promise.allSettled(Array.from(activeTypingReactions.keys()).map(removeTypingReaction));
|
|
537
629
|
try {
|
|
538
630
|
stateStore?.close();
|
|
539
631
|
} catch {
|
|
@@ -1343,7 +1343,7 @@ export class Dispatcher {
|
|
|
1343
1343
|
threadId: msg.conversation.threadId ?? null,
|
|
1344
1344
|
type: "error",
|
|
1345
1345
|
text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
|
|
1346
|
-
replyTo: msg
|
|
1346
|
+
replyTo: this.providerReplyTo(msg),
|
|
1347
1347
|
traceId: msg.trace?.id ?? null,
|
|
1348
1348
|
}, turnId);
|
|
1349
1349
|
} else {
|
|
@@ -1389,7 +1389,7 @@ export class Dispatcher {
|
|
|
1389
1389
|
threadId: msg.conversation.threadId ?? null,
|
|
1390
1390
|
type: "error",
|
|
1391
1391
|
text: `⚠️ Runtime error: ${truncate(errMsg, 500)}`,
|
|
1392
|
-
replyTo: msg
|
|
1392
|
+
replyTo: this.providerReplyTo(msg),
|
|
1393
1393
|
traceId: msg.trace?.id ?? null,
|
|
1394
1394
|
}, turnId);
|
|
1395
1395
|
} else {
|
|
@@ -1494,7 +1494,7 @@ export class Dispatcher {
|
|
|
1494
1494
|
threadId: msg.conversation.threadId ?? null,
|
|
1495
1495
|
type: "error",
|
|
1496
1496
|
text: `⚠️ Runtime error: ${truncate(result.error, 500)}`,
|
|
1497
|
-
replyTo: msg
|
|
1497
|
+
replyTo: this.providerReplyTo(msg),
|
|
1498
1498
|
traceId: msg.trace?.id ?? null,
|
|
1499
1499
|
}, turnId);
|
|
1500
1500
|
this.emitOutbound({
|
|
@@ -1571,7 +1571,7 @@ export class Dispatcher {
|
|
|
1571
1571
|
conversationId: msg.conversation.id,
|
|
1572
1572
|
threadId: msg.conversation.threadId ?? null,
|
|
1573
1573
|
text: replyText,
|
|
1574
|
-
replyTo: msg
|
|
1574
|
+
replyTo: this.providerReplyTo(msg),
|
|
1575
1575
|
traceId: msg.trace?.id ?? null,
|
|
1576
1576
|
}, turnId);
|
|
1577
1577
|
this.emitOutbound({
|
|
@@ -1638,6 +1638,10 @@ export class Dispatcher {
|
|
|
1638
1638
|
return { ok: true };
|
|
1639
1639
|
}
|
|
1640
1640
|
|
|
1641
|
+
private providerReplyTo(msg: GatewayInboundMessage): string {
|
|
1642
|
+
return msg.replyTo ?? msg.id;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1641
1645
|
private emitInbound(turnId: string, msg: GatewayInboundEnvelope["message"]): void {
|
|
1642
1646
|
if (!this.transcript.enabled) return;
|
|
1643
1647
|
const rawText = typeof msg.text === "string" ? msg.text : "";
|