@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.
- package/CHANGELOG.md +17 -0
- package/README.md +41 -44
- package/README.zh.md +41 -44
- package/config.example.yaml +4 -0
- package/dist/codex/CodexRuntime.js +8 -3
- package/dist/codex/CodexRuntime.js.map +1 -1
- package/dist/commands/CommandRouter.js +13 -0
- package/dist/commands/CommandRouter.js.map +1 -1
- package/dist/config/loadConfig.js +2 -0
- package/dist/config/loadConfig.js.map +1 -1
- package/dist/config/schema.js +2 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/console/ConsoleTurnPresenter.js +2 -0
- package/dist/console/ConsoleTurnPresenter.js.map +1 -1
- package/dist/feishu/CardRenderer.js +428 -9
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/feishu/FeishuConnector.js +29 -8
- package/dist/feishu/FeishuConnector.js.map +1 -1
- package/dist/feishu/FeishuMessageClient.js +78 -8
- package/dist/feishu/FeishuMessageClient.js.map +1 -1
- package/dist/feishu/FeishuTurnPresenter.js +31 -2
- package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
- package/dist/feishu/ownerAccess.js +7 -0
- package/dist/feishu/ownerAccess.js.map +1 -0
- package/dist/index.js +14 -17
- package/dist/index.js.map +1 -1
- package/dist/presentation/OutboundRouter.js +19 -1
- package/dist/presentation/OutboundRouter.js.map +1 -1
- package/dist/presentation/splitMarkdown.js +16 -4
- package/dist/presentation/splitMarkdown.js.map +1 -1
- package/dist/proxy/ProxySessionController.js +1012 -58
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/startup/StartupNotifier.js +65 -16
- package/dist/startup/StartupNotifier.js.map +1 -1
- package/dist/startup/startFeishu.js +2 -2
- package/dist/startup/startFeishu.js.map +1 -1
- package/dist/state/StateStore.js +250 -67
- package/dist/state/StateStore.js.map +1 -1
- package/dist/state/migrations.js +26 -0
- package/dist/state/migrations.js.map +1 -1
- package/dist/supervision/SafeRestartNotifier.js +17 -16
- package/dist/supervision/SafeRestartNotifier.js.map +1 -1
- package/dist/supervision/SafeRestartScheduler.js +12 -7
- package/dist/supervision/SafeRestartScheduler.js.map +1 -1
- package/dist/supervision/replacementSupervisor.js +45 -4
- package/dist/supervision/replacementSupervisor.js.map +1 -1
- package/dist/supervisor.js +30 -18
- package/dist/supervisor.js.map +1 -1
- package/docs/technical-reference.md +18 -5
- package/docs/technical-reference.zh.md +18 -5
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/agent-bot/SKILL.md +8 -2
- package/src/codex/CodexRuntime.ts +9 -3
- package/src/commands/CommandRouter.ts +13 -0
- package/src/commands/commandTypes.ts +2 -0
- package/src/config/loadConfig.ts +2 -0
- package/src/config/schema.ts +2 -0
- package/src/console/ConsoleTurnPresenter.ts +4 -1
- package/src/feishu/CardRenderer.ts +533 -9
- package/src/feishu/FeishuConnector.ts +44 -10
- package/src/feishu/FeishuMessageClient.ts +118 -7
- package/src/feishu/FeishuTurnPresenter.ts +42 -3
- package/src/feishu/ownerAccess.ts +7 -0
- package/src/feishu/types.ts +7 -0
- package/src/index.ts +20 -20
- package/src/presentation/OutboundRouter.ts +28 -3
- package/src/presentation/splitMarkdown.ts +26 -3
- package/src/proxy/ProxySessionController.ts +1177 -57
- package/src/startup/StartupNotifier.ts +67 -17
- package/src/startup/startFeishu.ts +5 -3
- package/src/state/StateStore.ts +394 -65
- package/src/state/migrations.ts +26 -0
- package/src/supervision/SafeRestartNotifier.ts +17 -17
- package/src/supervision/SafeRestartScheduler.ts +13 -6
- package/src/supervision/replacementSupervisor.ts +50 -4
- 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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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, () =>
|
|
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
|
-
|
|
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<
|
|
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(
|
|
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
|
+
}
|
package/src/feishu/types.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
56
|
-
process.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
342
|
+
if (restartReason) startReplacementSupervisor(restartReason, restartNotificationTargets);
|
|
337
343
|
process.exit(exitCode);
|
|
338
344
|
}
|
|
339
345
|
|
|
340
|
-
function startReplacementSupervisor(
|
|
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
|
-
|
|
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<
|
|
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<
|
|
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
|
-
|
|
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);
|