@keyou007/agent-bot 0.1.13 → 0.1.14-alpha.1

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +41 -44
  3. package/README.zh.md +41 -44
  4. package/config.example.yaml +4 -0
  5. package/dist/codex/CodexRuntime.js +8 -3
  6. package/dist/codex/CodexRuntime.js.map +1 -1
  7. package/dist/commands/CommandRouter.js +13 -0
  8. package/dist/commands/CommandRouter.js.map +1 -1
  9. package/dist/config/loadConfig.js +2 -0
  10. package/dist/config/loadConfig.js.map +1 -1
  11. package/dist/config/schema.js +2 -0
  12. package/dist/config/schema.js.map +1 -1
  13. package/dist/console/ConsoleTurnPresenter.js +2 -0
  14. package/dist/console/ConsoleTurnPresenter.js.map +1 -1
  15. package/dist/feishu/CardRenderer.js +428 -9
  16. package/dist/feishu/CardRenderer.js.map +1 -1
  17. package/dist/feishu/FeishuConnector.js +29 -8
  18. package/dist/feishu/FeishuConnector.js.map +1 -1
  19. package/dist/feishu/FeishuMessageClient.js +78 -8
  20. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  21. package/dist/feishu/FeishuTurnPresenter.js +31 -2
  22. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  23. package/dist/feishu/ownerAccess.js +7 -0
  24. package/dist/feishu/ownerAccess.js.map +1 -0
  25. package/dist/index.js +14 -17
  26. package/dist/index.js.map +1 -1
  27. package/dist/presentation/OutboundRouter.js +19 -1
  28. package/dist/presentation/OutboundRouter.js.map +1 -1
  29. package/dist/presentation/splitMarkdown.js +16 -4
  30. package/dist/presentation/splitMarkdown.js.map +1 -1
  31. package/dist/proxy/ProxySessionController.js +1012 -58
  32. package/dist/proxy/ProxySessionController.js.map +1 -1
  33. package/dist/startup/StartupNotifier.js +65 -16
  34. package/dist/startup/StartupNotifier.js.map +1 -1
  35. package/dist/startup/startFeishu.js +2 -2
  36. package/dist/startup/startFeishu.js.map +1 -1
  37. package/dist/state/StateStore.js +250 -67
  38. package/dist/state/StateStore.js.map +1 -1
  39. package/dist/state/migrations.js +26 -0
  40. package/dist/state/migrations.js.map +1 -1
  41. package/dist/supervision/SafeRestartNotifier.js +17 -16
  42. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  43. package/dist/supervision/SafeRestartScheduler.js +12 -7
  44. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  45. package/dist/supervision/replacementSupervisor.js +45 -4
  46. package/dist/supervision/replacementSupervisor.js.map +1 -1
  47. package/dist/supervisor.js +30 -18
  48. package/dist/supervisor.js.map +1 -1
  49. package/docs/technical-reference.md +18 -5
  50. package/docs/technical-reference.zh.md +18 -5
  51. package/npm-shrinkwrap.json +2 -2
  52. package/package.json +1 -1
  53. package/skills/agent-bot/SKILL.md +8 -2
  54. package/src/codex/CodexRuntime.ts +9 -3
  55. package/src/commands/CommandRouter.ts +13 -0
  56. package/src/commands/commandTypes.ts +2 -0
  57. package/src/config/loadConfig.ts +2 -0
  58. package/src/config/schema.ts +2 -0
  59. package/src/console/ConsoleTurnPresenter.ts +4 -1
  60. package/src/feishu/CardRenderer.ts +533 -9
  61. package/src/feishu/FeishuConnector.ts +44 -10
  62. package/src/feishu/FeishuMessageClient.ts +118 -7
  63. package/src/feishu/FeishuTurnPresenter.ts +42 -3
  64. package/src/feishu/ownerAccess.ts +7 -0
  65. package/src/feishu/types.ts +7 -0
  66. package/src/index.ts +20 -20
  67. package/src/presentation/OutboundRouter.ts +28 -3
  68. package/src/presentation/splitMarkdown.ts +26 -3
  69. package/src/proxy/ProxySessionController.ts +1177 -57
  70. package/src/startup/StartupNotifier.ts +67 -17
  71. package/src/startup/startFeishu.ts +5 -3
  72. package/src/state/StateStore.ts +394 -65
  73. package/src/state/migrations.ts +26 -0
  74. package/src/supervision/SafeRestartNotifier.ts +17 -17
  75. package/src/supervision/SafeRestartScheduler.ts +13 -6
  76. package/src/supervision/replacementSupervisor.ts +50 -4
  77. package/src/supervisor.ts +35 -16
@@ -2,6 +2,7 @@ import type { Logger } from "pino";
2
2
  import type { AppConfig } from "../config/schema.js";
3
3
  import { threadContextKey } from "./contextKey.js";
4
4
  import { resolveFeishuBotOpenId } from "./FeishuBotIdentity.js";
5
+ import { allowsFeishuUser } from "./ownerAccess.js";
5
6
  import type { ChatUpdatedEvent, FeishuEventHandler, IncomingMessage } from "./types.js";
6
7
 
7
8
  type BotOpenIdResolver = (appId: string, appSecret: string) => Promise<string>;
@@ -33,18 +34,31 @@ export class FeishuConnector {
33
34
  this.logger.warn({ error }, "Failed to resolve the Lark bot Open ID for the Agent environment.");
34
35
  }
35
36
  }
36
- await this.startFeishuWs(appId, appSecret, respondToAllGroupMessages ? undefined : botOpenId);
37
+ await this.startFeishuWs(appId, appSecret, botOpenId, !respondToAllGroupMessages);
37
38
  }
38
39
 
39
40
  stop(): void {
40
41
  // SDK connector currently relies on process lifetime.
41
42
  }
42
43
 
43
- private async startFeishuWs(appId: string, appSecret: string, requiredMentionOpenId?: string): Promise<void> {
44
+ private async startFeishuWs(
45
+ appId: string,
46
+ appSecret: string,
47
+ botOpenId?: string,
48
+ requireMention = false,
49
+ ): Promise<void> {
44
50
  const lark = (await import("@larksuiteoapi/node-sdk")) as Record<string, any>;
45
51
  const eventDispatcher = new lark.EventDispatcher({}).register({
46
52
  "im.message.receive_v1": async (data: unknown) => {
47
- const message = toIncomingMessage(data, requiredMentionOpenId);
53
+ const senderOpenId = getFeishuEvent(data)?.sender?.sender_id?.open_id;
54
+ if (!allowsFeishuUser(this.config, senderOpenId)) {
55
+ this.logger.debug(
56
+ { messageId: getFeishuEvent(data)?.message?.message_id },
57
+ "Ignored a Feishu message from a non-owner.",
58
+ );
59
+ return;
60
+ }
61
+ const message = toIncomingMessage(data, botOpenId, requireMention);
48
62
  if (!message) {
49
63
  this.logger.debug({ data }, "Ignored unsupported Feishu message event.");
50
64
  return;
@@ -74,6 +88,14 @@ export class FeishuConnector {
74
88
  });
75
89
  },
76
90
  "card.action.trigger": async (data: unknown) => {
91
+ const operatorOpenId = getFeishuEvent(data)?.operator?.open_id;
92
+ if (!allowsFeishuUser(this.config, operatorOpenId)) {
93
+ this.logger.debug(
94
+ { actionId: getEventId(data) },
95
+ "Ignored a Feishu card action from a non-owner.",
96
+ );
97
+ return {};
98
+ }
77
99
  const action = toCardAction(data);
78
100
  if (!action) {
79
101
  this.logger.debug({ data }, "Ignored unsupported Feishu card action.");
@@ -115,7 +137,11 @@ function toChatUpdatedEvent(data: unknown): ChatUpdatedEvent | undefined {
115
137
  };
116
138
  }
117
139
 
118
- function toIncomingMessage(data: unknown, requiredMentionOpenId?: string): IncomingMessage | undefined {
140
+ function toIncomingMessage(
141
+ data: unknown,
142
+ botOpenId?: string,
143
+ requireMention = false,
144
+ ): IncomingMessage | undefined {
119
145
  const event = getFeishuEvent(data);
120
146
  const message = event?.message;
121
147
  if (!message) {
@@ -128,11 +154,10 @@ function toIncomingMessage(data: unknown, requiredMentionOpenId?: string): Incom
128
154
  if (!parsed) return undefined;
129
155
  const chatId = message.chat_id;
130
156
  const chatType = message.chat_type === "group" ? "group" : "p2p";
131
- if (
132
- chatType === "group" &&
133
- requiredMentionOpenId &&
134
- !mentionsOpenId(message.mentions, requiredMentionOpenId)
135
- ) {
157
+ const mentionedBot = chatType === "group"
158
+ && Boolean(botOpenId)
159
+ && mentionsOpenId(message.mentions, botOpenId!);
160
+ if (chatType === "group" && requireMention && !mentionedBot) {
136
161
  return undefined;
137
162
  }
138
163
  const threadId = typeof message.thread_id === "string" && message.thread_id ? message.thread_id : undefined;
@@ -161,6 +186,7 @@ function toIncomingMessage(data: unknown, requiredMentionOpenId?: string): Incom
161
186
  ...(threadId ? { threadId } : {}),
162
187
  ...(rootMessageId ? { rootMessageId } : {}),
163
188
  ...(parentMessageId ? { parentMessageId } : {}),
189
+ ...(mentionedBot ? { mentionedBot: true as const } : {}),
164
190
  text: parsed.text,
165
191
  ...(parsed.images.length > 0 ? { images: parsed.images.map((imageKey) => ({ imageKey })) } : {}),
166
192
  };
@@ -250,10 +276,18 @@ function toCardAction(data: unknown) {
250
276
  contextKey: chatId ? `chat_id:${chatId}` : `open_id:${openId}`,
251
277
  userId: openId,
252
278
  ...(typeof event.context?.open_message_id === "string" ? { messageId: event.context.open_message_id } : {}),
253
- value: isRecord(event.action.value) ? event.action.value : {},
279
+ value: getCardActionValue(event.action),
254
280
  };
255
281
  }
256
282
 
283
+ function getCardActionValue(action: Record<string, unknown>): Record<string, unknown> {
284
+ if (action.tag === "overflow" && typeof action.option === "string") {
285
+ const option = parseJsonObject(action.option);
286
+ if (Object.keys(option).length > 0) return option;
287
+ }
288
+ return isRecord(action.value) ? action.value : {};
289
+ }
290
+
257
291
  function getEventId(data: unknown): string | undefined {
258
292
  const value = getNested<unknown>(data, ["header", "event_id"]) ?? getNested(data, ["data", "header", "event_id"]);
259
293
  return typeof value === "string" ? value : undefined;
@@ -48,6 +48,15 @@ interface UploadImageResponse {
48
48
  error?: unknown;
49
49
  }
50
50
 
51
+ interface UploadFileResponse {
52
+ code: number;
53
+ msg: string;
54
+ data?: {
55
+ file_key?: string;
56
+ };
57
+ error?: unknown;
58
+ }
59
+
51
60
  interface ReactionResponse {
52
61
  code: number;
53
62
  msg: string;
@@ -178,13 +187,25 @@ export class FeishuMessageClient implements FeishuOutbound {
178
187
  return this.sendMessage(contextKey, "text", { text });
179
188
  }
180
189
 
190
+ async sendFile(contextKey: string, filePath: string): Promise<string | undefined> {
191
+ const fileKey = await this.uploadFile(filePath);
192
+ return this.sendMessage(contextKey, "file", { file_key: fileKey });
193
+ }
194
+
181
195
  async sendMarkdown(contextKey: string, markdown: string, idempotencyKey?: string): Promise<string | undefined> {
196
+ const normalizedMarkdown = normalizeFeishuMarkdown(markdown);
182
197
  const elements = await renderMarkdownWithLocalImages(
183
- normalizeFeishuMarkdown(markdown),
198
+ normalizedMarkdown,
184
199
  (filePath) => this.uploadImageCached(filePath),
185
200
  (error, filePath) => this.logger.warn({ error, filePath }, "Failed to upload local image to Feishu."),
186
201
  );
187
- return this.sendMessage(contextKey, "interactive", finalAnswerCard(elements), idempotencyKey);
202
+ return this.sendMessage(
203
+ contextKey,
204
+ "interactive",
205
+ finalAnswerCard(elements),
206
+ idempotencyKey,
207
+ normalizedMarkdown,
208
+ );
188
209
  }
189
210
 
190
211
  async sendInteractiveCard(
@@ -203,18 +224,35 @@ export class FeishuMessageClient implements FeishuOutbound {
203
224
  return this.replyMessage(contextKey, target, "text", { text }, idempotencyKey);
204
225
  }
205
226
 
227
+ async replyFile(
228
+ contextKey: string,
229
+ target: MessageReplyTarget,
230
+ filePath: string,
231
+ ): Promise<string | undefined> {
232
+ const fileKey = await this.uploadFile(filePath);
233
+ return this.replyMessage(contextKey, target, "file", { file_key: fileKey });
234
+ }
235
+
206
236
  async replyMarkdown(
207
237
  contextKey: string,
208
238
  target: MessageReplyTarget,
209
239
  markdown: string,
210
240
  idempotencyKey?: string,
211
241
  ): Promise<string | undefined> {
242
+ const normalizedMarkdown = normalizeFeishuMarkdown(markdown);
212
243
  const elements = await renderMarkdownWithLocalImages(
213
- normalizeFeishuMarkdown(markdown),
244
+ normalizedMarkdown,
214
245
  (filePath) => this.uploadImageCached(filePath),
215
246
  (error, filePath) => this.logger.warn({ error, filePath }, "Failed to upload local image to Feishu."),
216
247
  );
217
- return this.replyMessage(contextKey, target, "interactive", finalAnswerCard(elements), idempotencyKey);
248
+ return this.replyMessage(
249
+ contextKey,
250
+ target,
251
+ "interactive",
252
+ finalAnswerCard(elements),
253
+ idempotencyKey,
254
+ normalizedMarkdown,
255
+ );
218
256
  }
219
257
 
220
258
  async replyInteractiveCard(
@@ -278,8 +316,26 @@ export class FeishuMessageClient implements FeishuOutbound {
278
316
  msgType: string,
279
317
  content: Record<string, unknown>,
280
318
  idempotencyKey?: string,
319
+ cardTableLimitFallbackText?: string,
281
320
  ): Promise<string | undefined> {
282
- return this.enqueueSend(contextKey, () => this.sendMessageNow(contextKey, msgType, content, idempotencyKey));
321
+ return this.enqueueSend(contextKey, async () => {
322
+ try {
323
+ return await this.sendMessageNow(contextKey, msgType, content, idempotencyKey);
324
+ } catch (error) {
325
+ const normalized = normalizeTransportError(error, "send");
326
+ if (!cardTableLimitFallbackText || !normalized.isCardTableLimitFailure) throw error;
327
+ this.logger.warn(
328
+ { code: normalized.code, contextKey },
329
+ "Feishu rejected a card with too many tables; retrying as text.",
330
+ );
331
+ return this.sendMessageNow(
332
+ contextKey,
333
+ "text",
334
+ { text: cardTableLimitFallbackText },
335
+ idempotencyKey,
336
+ );
337
+ }
338
+ });
283
339
  }
284
340
 
285
341
  private async replyMessage(
@@ -288,9 +344,26 @@ export class FeishuMessageClient implements FeishuOutbound {
288
344
  msgType: string,
289
345
  content: Record<string, unknown>,
290
346
  idempotencyKey?: string,
347
+ cardTableLimitFallbackText?: string,
291
348
  ): Promise<string | undefined> {
292
- return this.enqueueSend(contextKey, () =>
293
- this.replyMessageNow(target, msgType, content, idempotencyKey));
349
+ return this.enqueueSend(contextKey, async () => {
350
+ try {
351
+ return await this.replyMessageNow(target, msgType, content, idempotencyKey);
352
+ } catch (error) {
353
+ const normalized = normalizeTransportError(error, "reply");
354
+ if (!cardTableLimitFallbackText || !normalized.isCardTableLimitFailure) throw error;
355
+ this.logger.warn(
356
+ { code: normalized.code, messageId: target.messageId },
357
+ "Feishu rejected a reply card with too many tables; retrying as text.",
358
+ );
359
+ return this.replyMessageNow(
360
+ target,
361
+ "text",
362
+ { text: cardTableLimitFallbackText },
363
+ idempotencyKey,
364
+ );
365
+ }
366
+ });
294
367
  }
295
368
 
296
369
  private async replyMessageNow(
@@ -504,6 +577,40 @@ export class FeishuMessageClient implements FeishuOutbound {
504
577
  }
505
578
  }
506
579
 
580
+ private async uploadFile(filePath: string): Promise<string> {
581
+ const fileName = path.basename(filePath);
582
+ try {
583
+ const fileStats = await stat(filePath);
584
+ if (!fileStats.isFile()) throw new Error(`Not a regular file: ${fileName}`);
585
+ if (fileStats.size === 0) throw new Error(`Feishu does not accept empty files: ${fileName}`);
586
+ if (fileStats.size > 30 * 1024 * 1024) {
587
+ throw new Error(`File exceeds Feishu's 30 MiB limit: ${fileName}`);
588
+ }
589
+
590
+ const contents = await readFile(filePath);
591
+ const token = await this.getTenantAccessToken();
592
+ const form = new FormData();
593
+ form.append("file_type", "stream");
594
+ form.append("file_name", fileName);
595
+ const bytes = new ArrayBuffer(contents.byteLength);
596
+ new Uint8Array(bytes).set(contents);
597
+ form.append("file", new Blob([bytes]), fileName);
598
+ const response = await fetch("https://open.feishu.cn/open-apis/im/v1/files", {
599
+ method: "POST",
600
+ headers: { Authorization: `Bearer ${token}` },
601
+ body: form,
602
+ });
603
+ const payload = (await response.json()) as UploadFileResponse;
604
+ const fileKey = payload.data?.file_key;
605
+ if (!response.ok || payload.code !== 0 || !fileKey) {
606
+ throw new FeishuApiError(payload.msg || response.statusText, payload.code, payload, "upload file", response.status);
607
+ }
608
+ return fileKey;
609
+ } catch (error) {
610
+ throw normalizeTransportError(error, "upload file");
611
+ }
612
+ }
613
+
507
614
  private async uploadImageBytes(
508
615
  contents: Uint8Array,
509
616
  imageType: "message" | "avatar",
@@ -616,6 +723,7 @@ export class FeishuApiError extends Error {
616
723
  readonly isRateLimit: boolean;
617
724
  readonly isRetryable: boolean;
618
725
  readonly isEmailAddressAuditFailure: boolean;
726
+ readonly isCardTableLimitFailure: boolean;
619
727
 
620
728
  constructor(
621
729
  message: string,
@@ -624,6 +732,7 @@ export class FeishuApiError extends Error {
624
732
  | SendMessageResponse
625
733
  | CreateGroupResponse
626
734
  | UploadImageResponse
735
+ | UploadFileResponse
627
736
  | ReactionResponse
628
737
  | DownloadImageErrorResponse,
629
738
  operation = "send",
@@ -635,6 +744,8 @@ export class FeishuApiError extends Error {
635
744
  this.isRateLimit = code === 230020 || message.toLowerCase().includes("frequency limit");
636
745
  this.isRetryable = forceRetryable || this.isRateLimit || (httpStatus !== undefined && httpStatus >= 500);
637
746
  this.isEmailAddressAuditFailure = code === 230028 && message.includes("EMAIL_ADDRESS");
747
+ this.isCardTableLimitFailure = code === 230099
748
+ && /table number over limit|ErrorValue:\s*table/iu.test(message);
638
749
  }
639
750
  }
640
751
 
@@ -47,6 +47,8 @@ export interface FeishuTurnPresenterOptions {
47
47
  finalRetryBackoffMs?: number[];
48
48
  }
49
49
 
50
+ const MAX_FINAL_TABLES_PER_CARD = 5;
51
+
50
52
  interface TurnEntry {
51
53
  contextKey: string;
52
54
  state: TurnViewState;
@@ -114,7 +116,7 @@ export class FeishuTurnPresenter {
114
116
  taskTitle?: string,
115
117
  replyTarget?: MessageReplyTarget,
116
118
  prompt?: string,
117
- ): Promise<void> {
119
+ ): Promise<string | undefined> {
118
120
  const existing = this.pendingEntries.get(sessionId);
119
121
  if (existing) {
120
122
  await existing.initializing;
@@ -123,7 +125,7 @@ export class FeishuTurnPresenter {
123
125
  this.store.saveTurnSnapshot(existing.state.turnId, sessionId, existing.state, existing.contextKey);
124
126
  if (!existing.historySnapshot) existing.scheduler?.update(existing.state, "critical");
125
127
  }
126
- return;
128
+ return existing.state.turnId;
127
129
  }
128
130
  this.sessionContexts.set(sessionId, contextKey);
129
131
  if (taskTitle) this.sessionTitles.set(sessionId, taskTitle);
@@ -144,6 +146,7 @@ export class FeishuTurnPresenter {
144
146
  entry.initializing = this.initializeEntry(entry);
145
147
  try {
146
148
  await entry.initializing;
149
+ return state.turnId;
147
150
  } catch (error) {
148
151
  if (this.pendingEntries.get(sessionId) === entry) this.pendingEntries.delete(sessionId);
149
152
  this.entries.delete(state.turnId);
@@ -167,6 +170,34 @@ export class FeishuTurnPresenter {
167
170
  await this.syncThinkingCardReaction(entry);
168
171
  }
169
172
 
173
+ async interruptTurnForRecovery(
174
+ sessionId: string,
175
+ contextKey: string,
176
+ turnId: string,
177
+ message: string,
178
+ ): Promise<void> {
179
+ const snapshot = this.store.getTurnSnapshot(turnId);
180
+ if (!isTurnViewState(snapshot) || snapshot.sessionId !== sessionId || isTerminalViewStatus(snapshot.status)) return;
181
+ const state: TurnViewState = {
182
+ ...snapshot,
183
+ status: "cancelled",
184
+ progressText: message,
185
+ activeTool: undefined,
186
+ approval: undefined,
187
+ completedAt: Date.now(),
188
+ };
189
+ this.store.saveTurnSnapshot(turnId, sessionId, state, contextKey);
190
+ const delivery = this.store.getTurnDelivery(turnId);
191
+ if (delivery?.progressMessageId) {
192
+ await this.outbound.updateInteractiveCard(delivery.progressMessageId, this.renderer.renderTurn(state));
193
+ }
194
+ const entry = this.entries.get(turnId);
195
+ if (entry) {
196
+ this.entries.delete(turnId);
197
+ if (this.pendingEntries.get(sessionId) === entry) this.pendingEntries.delete(sessionId);
198
+ }
199
+ }
200
+
170
201
  async appendSteerMessage(
171
202
  sessionId: string,
172
203
  turnId: string,
@@ -464,7 +495,11 @@ export class FeishuTurnPresenter {
464
495
  private async deliverFinal(contextKey: string, state: TurnViewState): Promise<void> {
465
496
  const delivery = this.store.getTurnDelivery(state.turnId);
466
497
  if (delivery?.finalDelivered || !state.finalResponse) return;
467
- const chunks = splitMarkdown(state.finalResponse, this.options.finalChunkLength ?? 4_000);
498
+ const chunks = splitMarkdown(
499
+ state.finalResponse,
500
+ this.options.finalChunkLength ?? 4_000,
501
+ MAX_FINAL_TABLES_PER_CARD,
502
+ );
468
503
  const messageIds = [...(delivery?.finalMessageIds ?? [])];
469
504
  for (let index = messageIds.length; index < chunks.length; index += 1) {
470
505
  const chunk = chunks[index];
@@ -513,6 +548,10 @@ function isTerminalEvent(event: AgentEvent): boolean {
513
548
  return event.type === "turn_completed" || event.type === "turn_cancelled" || event.type === "turn_failed";
514
549
  }
515
550
 
551
+ function isTerminalViewStatus(status: TurnViewState["status"]): boolean {
552
+ return status === "completed" || status === "cancelled" || status === "failed";
553
+ }
554
+
516
555
  function thinkingCardReactionForStatus(status: TurnViewState["status"]): string | undefined {
517
556
  if (status === "completed") return undefined;
518
557
  if (status === "failed") return THINKING_CARD_FAILED_REACTION;
@@ -0,0 +1,7 @@
1
+ import type { AppConfig } from "../config/schema.js";
2
+
3
+ export function allowsFeishuUser(config: AppConfig, userOpenId: string | undefined): boolean {
4
+ if (config.feishu?.respondToOwnerOnly !== true) return true;
5
+ const ownerOpenId = config.feishu.userOpenId?.trim();
6
+ return Boolean(ownerOpenId && userOpenId === ownerOpenId);
7
+ }
@@ -9,6 +9,7 @@ export interface IncomingMessage {
9
9
  threadId?: string;
10
10
  rootMessageId?: string;
11
11
  parentMessageId?: string;
12
+ mentionedBot?: true;
12
13
  text: string;
13
14
  images?: IncomingImageReference[];
14
15
  }
@@ -53,6 +54,7 @@ export interface FeishuOutbound {
53
54
  deleteReaction?(messageId: string, reactionId: string): Promise<void>;
54
55
  downloadImage?(messageId: string, imageKey: string): Promise<string>;
55
56
  sendText(contextKey: string, text: string): Promise<string | undefined>;
57
+ sendFile?(contextKey: string, filePath: string): Promise<string | undefined>;
56
58
  sendMarkdown(contextKey: string, markdown: string, idempotencyKey?: string): Promise<string | undefined>;
57
59
  sendInteractiveCard(contextKey: string, card: Record<string, unknown>): Promise<string | undefined>;
58
60
  replyText?(
@@ -61,6 +63,11 @@ export interface FeishuOutbound {
61
63
  text: string,
62
64
  idempotencyKey?: string,
63
65
  ): Promise<string | undefined>;
66
+ replyFile?(
67
+ contextKey: string,
68
+ target: MessageReplyTarget,
69
+ filePath: string,
70
+ ): Promise<string | undefined>;
64
71
  replyMarkdown?(
65
72
  contextKey: string,
66
73
  target: MessageReplyTarget,
package/src/index.ts CHANGED
@@ -19,7 +19,7 @@ import { ConsoleFeishuClient } from "./feishu/ConsoleFeishuClient.js";
19
19
  import { FeishuConnector } from "./feishu/FeishuConnector.js";
20
20
  import { FeishuMessageClient } from "./feishu/FeishuMessageClient.js";
21
21
  import { FeishuTurnPresenter } from "./feishu/FeishuTurnPresenter.js";
22
- import { baseChatContextKey, isThreadContextKey } from "./feishu/contextKey.js";
22
+ import { isThreadContextKey } from "./feishu/contextKey.js";
23
23
  import type { MessageReplyTarget } from "./feishu/types.js";
24
24
  import { requireServerFeishuTransport } from "./feishu/transport.js";
25
25
  import { createLogger } from "./logging/logger.js";
@@ -34,8 +34,10 @@ import { startFeishu } from "./startup/startFeishu.js";
34
34
  import { STOP_EXIT_CODE } from "./supervision/restartPolicy.js";
35
35
  import {
36
36
  RESTART_GROUP_CONTEXTS_ENV,
37
+ RESTART_NOTIFICATION_TARGETS_ENV,
37
38
  replacementSupervisorEnvironment,
38
39
  restartGroupContextKeysFromEnvironment,
40
+ restartNotificationTargetsFromEnvironment,
39
41
  } from "./supervision/replacementSupervisor.js";
40
42
  import { SafeRestartScheduler } from "./supervision/SafeRestartScheduler.js";
41
43
  import type { RestartNotificationTarget } from "./supervision/SafeRestartScheduler.js";
@@ -52,8 +54,10 @@ const agentBotVersion = readPackageVersion();
52
54
  const supervised = process.env.AGENT_BOT_SUPERVISED === "1";
53
55
  const startupReason = process.env.AGENT_BOT_RESTART_REASON?.trim()
54
56
  || (supervised ? "Supervisor 启动" : "直接启动");
55
- const restartGroupContextKeys = restartGroupContextKeysFromEnvironment(
56
- process.env[RESTART_GROUP_CONTEXTS_ENV],
57
+ const restartNotificationTargets = mergeRestartNotificationTargets(
58
+ restartNotificationTargetsFromEnvironment(process.env[RESTART_NOTIFICATION_TARGETS_ENV]),
59
+ restartGroupContextKeysFromEnvironment(process.env[RESTART_GROUP_CONTEXTS_ENV])
60
+ .map((contextKey) => ({ contextKey })),
57
61
  );
58
62
  const consoleOnly = process.env.AGENT_BOT_CONSOLE_ONLY === "1";
59
63
  const config = loadConfig();
@@ -80,7 +84,7 @@ let serverReady = false;
80
84
 
81
85
  const feishuOutbound = transport === "sdk" ? new FeishuMessageClient(config, logger) : undefined;
82
86
  if (feishuOutbound) {
83
- const renderer = new CardRenderer();
87
+ const renderer = new CardRenderer({ thinkingCardLayout: config.feishu.thinkingCardLayout });
84
88
  const presenter = new FeishuTurnPresenter(feishuOutbound, store, renderer, {
85
89
  normalIntervalMs: 2_000,
86
90
  criticalGapMs: 500,
@@ -170,13 +174,16 @@ if (feishuConnector && startupNotifier) {
170
174
  startupReason,
171
175
  startControlServer,
172
176
  () => { serverReady = true; },
173
- restartGroupContextKeys,
177
+ restartNotificationTargets,
174
178
  );
175
179
  } else {
176
180
  await startControlServer();
177
181
  await feishuConnector?.start();
178
182
  serverReady = true;
179
183
  }
184
+ await controller.recoverInterruptedTasks().catch((error: unknown) => {
185
+ logger.error({ error }, "Startup task recovery failed; the server will remain available.");
186
+ });
180
187
  consoleConnector?.start();
181
188
 
182
189
  async function requestRestart(
@@ -222,7 +229,6 @@ async function initiateRestart(
222
229
  notificationTargets,
223
230
  safeRestartNotifier?.getNotificationTargets() ?? [],
224
231
  );
225
- const restartGroupContextKeys = groupContextKeysForRestartTargets(allNotificationTargets);
226
232
  await sendRestartTexts(
227
233
  allNotificationTargets,
228
234
  "Agent Bot 正在重启,恢复在线后会发送启动状态通知。",
@@ -230,7 +236,7 @@ async function initiateRestart(
230
236
  setTimeout(() => void shutdown(
231
237
  supervised ? STOP_EXIT_CODE : 0,
232
238
  reason,
233
- restartGroupContextKeys,
239
+ allNotificationTargets,
234
240
  ), 100);
235
241
  }
236
242
 
@@ -317,7 +323,7 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
317
323
  async function shutdown(
318
324
  exitCode: number,
319
325
  restartReason?: string,
320
- restartGroupContextKeys: string[] = [],
326
+ restartNotificationTargets: RestartNotificationTarget[] = [],
321
327
  ): Promise<void> {
322
328
  if (shuttingDown) return;
323
329
  shuttingDown = true;
@@ -333,11 +339,14 @@ async function shutdown(
333
339
  runtimes.close();
334
340
  await controlServer?.close().catch((error: unknown) => logger.warn({ error }, "Failed to close local control endpoint."));
335
341
  store.close();
336
- if (restartReason) startReplacementSupervisor(restartReason, restartGroupContextKeys);
342
+ if (restartReason) startReplacementSupervisor(restartReason, restartNotificationTargets);
337
343
  process.exit(exitCode);
338
344
  }
339
345
 
340
- function startReplacementSupervisor(restartReason: string, restartGroupContextKeys: string[]): void {
346
+ function startReplacementSupervisor(
347
+ restartReason: string,
348
+ restartNotificationTargets: RestartNotificationTarget[],
349
+ ): void {
341
350
  const supervisorEntry = fileURLToPath(new URL("./supervisor.js", import.meta.url));
342
351
  const reportDirectory = resolveSupervisorDiagnosticsPaths(config).crashReportDirectory;
343
352
  prepareCrashReportDirectory(reportDirectory);
@@ -361,7 +370,7 @@ function startReplacementSupervisor(restartReason: string, restartGroupContextKe
361
370
  env: replacementSupervisorEnvironment(
362
371
  restartReason,
363
372
  environmentRefresh.environment,
364
- restartGroupContextKeys,
373
+ restartNotificationTargets,
365
374
  ),
366
375
  });
367
376
  supervisor.unref();
@@ -430,15 +439,6 @@ async function sendRestartTexts(targets: RestartNotificationTarget[], text: stri
430
439
  }));
431
440
  }
432
441
 
433
- function groupContextKeysForRestartTargets(targets: RestartNotificationTarget[]): string[] {
434
- const knownGroupContextKeys = new Set(
435
- store.listChatContexts("group").map((chat) => chat.contextKey),
436
- );
437
- return [...new Set(targets
438
- .map((target) => baseChatContextKey(target.contextKey))
439
- .filter((contextKey) => knownGroupContextKeys.has(contextKey)))];
440
- }
441
-
442
442
  function mergeRestartNotificationTargets(
443
443
  ...targetGroups: RestartNotificationTarget[][]
444
444
  ): RestartNotificationTarget[] {
@@ -23,8 +23,9 @@ export interface TurnPresenter {
23
23
  taskTitle?: string,
24
24
  replyTarget?: MessageReplyTarget,
25
25
  prompt?: string,
26
- ): Promise<void>;
26
+ ): Promise<string | undefined>;
27
27
  failPendingTurn(sessionId: string, message: string): Promise<void>;
28
+ interruptTurnForRecovery(sessionId: string, contextKey: string, turnId: string, message: string): Promise<void>;
28
29
  appendSteerMessage(sessionId: string, turnId: string, text: string, messageId?: string): Promise<void>;
29
30
  onEvent(event: AgentEvent): Promise<void>;
30
31
  showDetails(contextKey: string, turnId: string): Promise<void>;
@@ -75,6 +76,10 @@ export class OutboundRouter {
75
76
  return this.sessionReplyTargets.get(sessionId);
76
77
  }
77
78
 
79
+ canRoute(contextKey: string): boolean {
80
+ return this.routes.some((route) => route.matches(contextKey));
81
+ }
82
+
78
83
  updateSessionTitle(sessionId: string, taskTitle: string): void {
79
84
  this.sessionRoutes.get(sessionId)?.presenter.updateSessionTitle(sessionId, taskTitle);
80
85
  }
@@ -93,19 +98,28 @@ export class OutboundRouter {
93
98
  taskTitle?: string,
94
99
  replyTarget?: MessageReplyTarget,
95
100
  prompt?: string,
96
- ): Promise<void> {
101
+ ): Promise<string | undefined> {
97
102
  const route = this.route(contextKey);
98
103
  this.sessionRoutes.set(sessionId, route);
99
104
  this.sessionContextKeys.set(sessionId, contextKey);
100
105
  if (replyTarget) this.sessionReplyTargets.set(sessionId, replyTarget);
101
106
  else this.sessionReplyTargets.delete(sessionId);
102
- await route.presenter.startPendingTurn(sessionId, contextKey, taskTitle, replyTarget, prompt);
107
+ return route.presenter.startPendingTurn(sessionId, contextKey, taskTitle, replyTarget, prompt);
103
108
  }
104
109
 
105
110
  async failPendingTurn(sessionId: string, message: string): Promise<void> {
106
111
  await this.sessionRoutes.get(sessionId)?.presenter.failPendingTurn(sessionId, message);
107
112
  }
108
113
 
114
+ async interruptTurnForRecovery(
115
+ sessionId: string,
116
+ contextKey: string,
117
+ turnId: string,
118
+ message: string,
119
+ ): Promise<void> {
120
+ await this.route(contextKey).presenter.interruptTurnForRecovery(sessionId, contextKey, turnId, message);
121
+ }
122
+
109
123
  async appendSteerMessage(
110
124
  sessionId: string,
111
125
  turnId: string,
@@ -175,6 +189,17 @@ export class OutboundRouter {
175
189
  : outbound.sendText(contextKey, text);
176
190
  }
177
191
 
192
+ sendFile(contextKey: string, filePath: string): Promise<string | undefined> {
193
+ const outbound = this.route(contextKey).outbound;
194
+ const target = this.currentReplyTarget(contextKey);
195
+ if (target) {
196
+ if (!outbound.replyFile) throw new Error("当前消息通道不支持在话题中发送文件。");
197
+ return outbound.replyFile(contextKey, target, filePath);
198
+ }
199
+ if (!outbound.sendFile) throw new Error("当前消息通道不支持发送文件。");
200
+ return outbound.sendFile(contextKey, filePath);
201
+ }
202
+
178
203
  sendMarkdown(contextKey: string, markdown: string): Promise<string | undefined> {
179
204
  const outbound = this.route(contextKey).outbound;
180
205
  const target = this.currentReplyTarget(contextKey);