@keyou007/agent-bot 0.1.7 → 0.1.8

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 (42) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +4 -0
  3. package/README.zh.md +4 -0
  4. package/config.example.yaml +2 -0
  5. package/dist/cli/FeishuAppConfiguration.js +2 -5
  6. package/dist/cli/FeishuAppConfiguration.js.map +1 -1
  7. package/dist/codex/CodexEventMapper.js +13 -0
  8. package/dist/codex/CodexEventMapper.js.map +1 -1
  9. package/dist/codex/CodexRuntime.js +33 -4
  10. package/dist/codex/CodexRuntime.js.map +1 -1
  11. package/dist/config/loadConfig.js +1 -0
  12. package/dist/config/loadConfig.js.map +1 -1
  13. package/dist/config/schema.js +1 -0
  14. package/dist/config/schema.js.map +1 -1
  15. package/dist/feishu/CardRenderer.js +2 -2
  16. package/dist/feishu/CardRenderer.js.map +1 -1
  17. package/dist/feishu/FeishuBotIdentity.js +35 -0
  18. package/dist/feishu/FeishuBotIdentity.js.map +1 -0
  19. package/dist/feishu/FeishuConnector.js +20 -5
  20. package/dist/feishu/FeishuConnector.js.map +1 -1
  21. package/dist/presentation/TurnStateReducer.js +2 -1
  22. package/dist/presentation/TurnStateReducer.js.map +1 -1
  23. package/dist/proxy/ProxySessionController.js +5 -1
  24. package/dist/proxy/ProxySessionController.js.map +1 -1
  25. package/dist/utils/generatedImageMarkdown.js +11 -0
  26. package/dist/utils/generatedImageMarkdown.js.map +1 -0
  27. package/docs/technical-reference.md +5 -1
  28. package/docs/technical-reference.zh.md +5 -1
  29. package/npm-shrinkwrap.json +2 -2
  30. package/package.json +1 -1
  31. package/skills/agent-bot/SKILL.md +5 -1
  32. package/src/cli/FeishuAppConfiguration.ts +2 -5
  33. package/src/codex/CodexEventMapper.ts +13 -0
  34. package/src/codex/CodexRuntime.ts +48 -5
  35. package/src/config/loadConfig.ts +1 -0
  36. package/src/config/schema.ts +1 -0
  37. package/src/feishu/CardRenderer.ts +2 -2
  38. package/src/feishu/FeishuBotIdentity.ts +55 -0
  39. package/src/feishu/FeishuConnector.ts +24 -4
  40. package/src/presentation/TurnStateReducer.ts +6 -1
  41. package/src/proxy/ProxySessionController.ts +4 -1
  42. package/src/utils/generatedImageMarkdown.ts +10 -0
@@ -93,7 +93,8 @@ Core configuration required for basic messaging:
93
93
  - Bot capability
94
94
  - Persistent-connection event delivery
95
95
  - `im.message.receive_v1`
96
- - Tenant permissions for group @ messages and private messages
96
+ - `im:message.group_msg:readonly` for all user messages in groups containing the bot
97
+ - Tenant permission for private messages
97
98
  - `im:message:send_as_bot` or a broader equivalent
98
99
  - `application:application:self_manage` so initialization can inspect the published version
99
100
 
@@ -120,6 +121,7 @@ feishu:
120
121
  appId: "${FEISHU_APP_ID}"
121
122
  appSecret: "${FEISHU_APP_SECRET}"
122
123
  userOpenId: "${FEISHU_USER_OPEN_ID}"
124
+ respondToAllGroupMessages: true
123
125
 
124
126
  console:
125
127
  enabled: true
@@ -144,6 +146,8 @@ logging:
144
146
  path: "./logs/agent-bot.log"
145
147
  ```
146
148
 
149
+ `feishu.respondToAllGroupMessages` defaults to `true`. Set it to `false` to ignore group messages unless they mention the current bot. The worker resolves the bot's Open ID at startup so mentioning another member does not trigger it. Private messages are always accepted. Initialization requests all-user group-message delivery regardless of this runtime option, allowing it to be changed later without another authorization.
150
+
147
151
  `agent-bot server start` requires both Feishu credentials. The worker starts the SDK's persistent connection without inspecting SDK logs or private connection state, then sends startup status cards as an outbound readiness check. Each startup card includes the Agent Bot version read from the installed package metadata. It normally targets known private chats and recently active groups. If the database has no known chat yet, it sends the card to `feishu.userOpenId` using an `open_id` private message. When notification targets exist, at least one card must be delivered before the server reports ready; individual target failures remain isolated. If neither a known chat nor `feishu.userOpenId` is available, startup continues without the outbound check. Missing credentials still fail startup with an initialization hint. `agent-bot console` is the explicit local-only path and does not require Feishu credentials.
148
152
 
149
153
  At least one agent must be configured. `defaults.agent` must name a configured agent.
@@ -93,7 +93,8 @@ CLI 界面文本不受系统语言影响,统一使用英文。系统生成的
93
93
  - 机器人能力
94
94
  - 长连接事件接收
95
95
  - `im.message.receive_v1`
96
- - 接收群聊 @ 消息和私聊消息所需的租户权限
96
+ - `im:message.group_msg:readonly`,用于接收机器人所在群内的全部用户消息
97
+ - 接收私聊消息所需的租户权限
97
98
  - `im:message:send_as_bot` 或覆盖它的更大权限
98
99
  - `application:application:self_manage`,供初始化检查已发布版本
99
100
 
@@ -120,6 +121,7 @@ feishu:
120
121
  appId: "${FEISHU_APP_ID}"
121
122
  appSecret: "${FEISHU_APP_SECRET}"
122
123
  userOpenId: "${FEISHU_USER_OPEN_ID}"
124
+ respondToAllGroupMessages: true
123
125
 
124
126
  console:
125
127
  enabled: true
@@ -144,6 +146,8 @@ logging:
144
146
  path: "./logs/agent-bot.log"
145
147
  ```
146
148
 
149
+ `feishu.respondToAllGroupMessages` 默认为 `true`。设为 `false` 后,未 @ 当前机器人的群消息会被忽略;Worker 启动时会解析机器人的 Open ID,因此 @ 其他成员不会误触发。私聊始终正常处理。无论该运行时配置如何,初始化都会申请接收全部群消息的权限,之后切换配置无需再次授权。
150
+
147
151
  `agent-bot server start` 要求同时配置飞书 `appId` 和 `appSecret`。Worker 启动 SDK 长连接时不读取 SDK 日志或私有连接状态,随后通过发送启动状态卡片检查出站能力。每张启动卡片都会显示从已安装包元数据读取的 Agent Bot 版本。通知通常发往已知私聊和最近活跃的群聊;数据库里尚无已知会话时,改用 `feishu.userOpenId`,按 `open_id` 私聊发送。存在通知目标时,至少一张卡片发送成功后 Server 才报告就绪,单个目标发送失败仍相互隔离。如果既没有已知会话,也没有 `feishu.userOpenId`,启动会跳过出站检查并继续。缺少凭据时仍会启动失败并提示先初始化。`agent-bot console` 是明确的纯本地入口,不需要飞书凭据。
148
152
 
149
153
  必须至少配置一个 Agent,`defaults.agent` 必须指向已配置的 Agent。
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@keyou007/agent-bot",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@keyou007/agent-bot",
9
- "version": "0.1.7",
9
+ "version": "0.1.8",
10
10
  "license": "GPL-3.0-only",
11
11
  "dependencies": {
12
12
  "@agentclientprotocol/sdk": "1.2.1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keyou007/agent-bot",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "description": "Using Codex in Lark/Feishu feels just like using native Codex.",
6
6
  "license": "GPL-3.0-only",
@@ -48,6 +48,10 @@ agent-bot init
48
48
 
49
49
  Initialization creates the Agent Bot home, `config.yaml`, `.env`, `data/`, and `logs/` from the bundled examples. If Feishu credentials are absent, it starts Feishu one-click app registration, prints a QR code followed by its authorization URL, waits for the user to approve the request, and stores the returned app ID and secret in `.env`. It then audits the app's currently published tenant permissions, event subscriptions, and callbacks. Core and optional missing items produce separate incremental-configuration URLs. Core configuration is always polled until ready. For optional configuration, the CLI prints the QR code followed by the authorization URL and immediately waits up to five minutes for activation. The only terminal choice is `Y`, which skips optional authorization and continues initialization.
50
50
 
51
+ Core messaging configuration includes `im:message.group_msg:readonly`, not merely the narrower group-mention scope. Agent Bot groups accept ordinary messages without requiring users to @ the bot, so initialization must keep waiting until all-user group-message delivery is active.
52
+
53
+ Initialization always requests all-user group-message delivery. The runtime option `feishu.respondToAllGroupMessages` controls whether Agent Bot uses it: the default `true` responds to ordinary group messages, while `false` requires a mention of the current bot. Private messages are unaffected.
54
+
51
55
  After Feishu initialization succeeds, `init` releases its initialization lock and automatically starts the selected profile's server, waiting until it is ready. It does not create another supervisor when that profile is already running. `init --skip-feishu` is the exception: it prepares Console-only files and does not start the server. With `--json`, inspect `server.status` for `started`, `already-running`, or `skipped`.
52
56
 
53
57
  When running `init` on the user's behalf, relay every exact authorization or incremental-configuration URL and let the user complete it in their own browser. Optional polling starts automatically after the link is shown. Do not enter `Y` unless the user explicitly asks to skip optional authorization; there is no separate accept action. Optional capabilities such as group creation, group-title synchronization, reactions, images, and card actions never block initialization when skipped, timed out, or still unavailable; relay the final affected-feature warnings. In a non-interactive terminal, optional polling continues until activation or timeout because skip input is unavailable. Neither URL contains the app secret, and the secret itself is never printed.
@@ -151,7 +155,7 @@ Keep action labels placed to the right of card content or at the bottom of a car
151
155
 
152
156
  Messages whose trimmed text starts with `/` are always parsed as Agent Bot commands. Unknown slash commands are reported to the user with a `/help` hint and must never fall through to the model, including slash-prefixed messages that also contain images.
153
157
 
154
- When a group body has a current task, renaming the Feishu group to `[agent-name] new title` also renames that current task if the prefix matches the task's configured agent. Malformed names, agent mismatches, empty groups, and thread-specific tasks are ignored.
158
+ When a group body has a current task, renaming the Feishu group to `[agent-name] [project directory] new title` also renames that current task if the Agent prefix matches the task's configured agent. Only `new title` is written to the task; the Agent and project-directory prefixes remain group metadata. The older `[agent-name] new title` shape remains supported. Malformed names, agent mismatches, empty groups, and thread-specific tasks are ignored.
155
159
 
156
160
  Use `--context <key>` or `--status <status>` to narrow `task list`. Use `--profile <directory>` before the command when controlling an isolated Agent Bot instance, or `--config <path>` for only a non-default configuration.
157
161
 
@@ -28,11 +28,8 @@ const REQUIRED_SCOPE_REQUIREMENTS: ScopeRequirement[] = [
28
28
  accepted: ["im:chat:read", "im:chat:readonly", "im:chat"],
29
29
  },
30
30
  {
31
- requested: "im:message.group_at_msg:readonly",
31
+ requested: "im:message.group_msg:readonly",
32
32
  accepted: [
33
- "im:message.group_at_msg:readonly",
34
- "im:message.group_at_msg.include_bot:readonly",
35
- "im:message.group_at_msg",
36
33
  "im:message.group_msg:readonly",
37
34
  "im:message.group_msg",
38
35
  ],
@@ -69,7 +66,7 @@ export const REQUIRED_FEISHU_CALLBACKS = ["card.action.trigger"] as const;
69
66
 
70
67
  const CORE_FEISHU_SCOPES = new Set([
71
68
  "application:application:self_manage",
72
- "im:message.group_at_msg:readonly",
69
+ "im:message.group_msg:readonly",
73
70
  "im:message.p2p_msg:readonly",
74
71
  "im:message:send_as_bot",
75
72
  ]);
@@ -201,6 +201,19 @@ function mapTool(
201
201
  completedAt,
202
202
  };
203
203
  }
204
+ if (type === "imageGeneration") {
205
+ const imagePath = nonEmptyString(item.savedPath);
206
+ return {
207
+ id,
208
+ title: "生成图片",
209
+ kind: "image_generation",
210
+ status,
211
+ command: nonEmptyString(item.revisedPrompt),
212
+ imagePath,
213
+ startedAt,
214
+ completedAt,
215
+ };
216
+ }
204
217
  return undefined;
205
218
  }
206
219
 
@@ -17,6 +17,7 @@ import type {
17
17
  RuntimeSession,
18
18
  RuntimeSessionMetadata,
19
19
  } from "../runtime/types.js";
20
+ import { appendGeneratedImageMarkdown } from "../utils/generatedImageMarkdown.js";
20
21
  import { normalizeTaskTitle } from "../utils/taskTitle.js";
21
22
  import { mapCodexNotification } from "./CodexEventMapper.js";
22
23
  import { CodexLocalActivityDetector } from "./CodexLocalActivityDetector.js";
@@ -59,6 +60,7 @@ interface CodexSession extends RuntimeSession {
59
60
  activeTurnStartedAt?: number;
60
61
  terminalTurnIds: Set<string>;
61
62
  finalText: string;
63
+ generatedImagePaths: string[];
62
64
  messagePhases: Map<string, "commentary" | "final_answer">;
63
65
  needsResume: boolean;
64
66
  }
@@ -548,6 +550,14 @@ export class CodexRuntime implements AgentRuntime {
548
550
  } else if (mapped.kind === "plan") {
549
551
  this.emit({ type: "plan_updated", sessionId, turnId: mapped.turnId, steps: mapped.steps });
550
552
  } else if (mapped.kind === "tool") {
553
+ if (
554
+ mapped.phase === "updated"
555
+ && mapped.tool.kind === "image_generation"
556
+ && mapped.tool.status === "completed"
557
+ && mapped.tool.imagePath
558
+ ) {
559
+ session.generatedImagePaths = uniqueStrings([...session.generatedImagePaths, mapped.tool.imagePath]);
560
+ }
551
561
  this.emit({
552
562
  type: mapped.phase === "started" ? "tool_started" : "tool_updated",
553
563
  sessionId,
@@ -572,14 +582,16 @@ export class CodexRuntime implements AgentRuntime {
572
582
  } else if (mapped.status === "failed") {
573
583
  this.emit({ type: "turn_failed", sessionId, turnId: mapped.turnId, message: mapped.error ?? "Codex turn failed." });
574
584
  } else {
585
+ const finalResponse = appendGeneratedImageMarkdown(session.finalText, session.generatedImagePaths);
575
586
  this.emit({
576
587
  type: "turn_completed",
577
588
  sessionId,
578
589
  turnId: mapped.turnId,
579
- finalResponse: session.finalText,
590
+ finalResponse,
580
591
  durationMs: mapped.durationMs,
581
592
  });
582
593
  }
594
+ session.generatedImagePaths = [];
583
595
  }
584
596
  }
585
597
 
@@ -693,6 +705,7 @@ export class CodexRuntime implements AgentRuntime {
693
705
  session.activeTurnId = turnId;
694
706
  session.activeTurnStartedAt = startedAt;
695
707
  session.finalText = "";
708
+ session.generatedImagePaths = [];
696
709
  session.messagePhases.clear();
697
710
  this.emit({ type: "turn_started", sessionId: session.localSessionId, turnId, startedAt });
698
711
  }
@@ -726,8 +739,16 @@ export class CodexRuntime implements AgentRuntime {
726
739
  });
727
740
  return;
728
741
  }
729
- const finalResponse = extractFinalResponse(turn) || session.finalText;
742
+ const generatedImagePaths = uniqueStrings([
743
+ ...session.generatedImagePaths,
744
+ ...extractGeneratedImagePaths(turn),
745
+ ]);
746
+ const finalResponse = appendGeneratedImageMarkdown(
747
+ extractFinalResponse(turn) || session.finalText,
748
+ generatedImagePaths,
749
+ );
730
750
  session.finalText = finalResponse;
751
+ session.generatedImagePaths = [];
731
752
  this.emit({
732
753
  type: "turn_completed",
733
754
  sessionId: session.localSessionId,
@@ -765,6 +786,7 @@ export class CodexRuntime implements AgentRuntime {
765
786
  : [],
766
787
  ),
767
788
  finalText: "",
789
+ generatedImagePaths: [],
768
790
  messagePhases: new Map(),
769
791
  needsResume: false,
770
792
  };
@@ -796,6 +818,7 @@ export class CodexRuntime implements AgentRuntime {
796
818
  if (!turnId) continue;
797
819
  session.activeTurnId = undefined;
798
820
  session.activeTurnStartedAt = undefined;
821
+ session.generatedImagePaths = [];
799
822
  this.emit({
800
823
  type: "turn_failed",
801
824
  sessionId: session.localSessionId,
@@ -841,7 +864,13 @@ interface CodexThreadSnapshot {
841
864
  interface CodexTurnSnapshot {
842
865
  id: string;
843
866
  status: "completed" | "interrupted" | "failed" | "inProgress";
844
- items?: Array<{ type?: string; text?: string; phase?: string | null; status?: string }>;
867
+ items?: Array<{
868
+ type?: string;
869
+ text?: string;
870
+ phase?: string | null;
871
+ status?: string;
872
+ savedPath?: string;
873
+ }>;
845
874
  error?: { message?: string } | null;
846
875
  startedAt?: number | null;
847
876
  durationMs?: number | null;
@@ -893,6 +922,17 @@ function extractFinalResponse(turn: CodexTurnSnapshot): string {
893
922
  .join("\n\n");
894
923
  }
895
924
 
925
+ function extractGeneratedImagePaths(turn: CodexTurnSnapshot): string[] {
926
+ return uniqueStrings((turn.items ?? []).flatMap((item) => {
927
+ const savedPath = stringValue(item.savedPath)?.trim();
928
+ return item.type === "imageGeneration" && item.status !== "failed" && savedPath ? [savedPath] : [];
929
+ }));
930
+ }
931
+
932
+ function uniqueStrings(values: string[]): string[] {
933
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
934
+ }
935
+
896
936
  function remoteSessionSummary(thread: CodexThreadSnapshot): RemoteSessionSummary {
897
937
  const lastTurn = thread.turns?.at(-1);
898
938
  const lastCompletedTurn = [...(thread.turns ?? [])]
@@ -923,7 +963,9 @@ function remoteSessionSummary(thread: CodexThreadSnapshot): RemoteSessionSummary
923
963
  lastCompletedTurnId: lastCompletedTurn?.id,
924
964
  lastTurnStatus,
925
965
  lastActivity: lastText,
926
- finalResponse: lastTurn && lastTurn.status !== "inProgress" ? extractFinalResponse(lastTurn) || undefined : undefined,
966
+ finalResponse: lastTurn && lastTurn.status !== "inProgress"
967
+ ? appendGeneratedImageMarkdown(extractFinalResponse(lastTurn), extractGeneratedImagePaths(lastTurn)) || undefined
968
+ : undefined,
927
969
  lastError: lastTurn?.error?.message,
928
970
  lastTurnToolCount: toolCounts?.total,
929
971
  lastTurnCompletedToolCount: toolCounts?.completed,
@@ -956,7 +998,8 @@ function isToolItemType(type: string | undefined): boolean {
956
998
  || type === "mcpToolCall"
957
999
  || type === "dynamicToolCall"
958
1000
  || type === "webSearch"
959
- || type === "imageView";
1001
+ || type === "imageView"
1002
+ || type === "imageGeneration";
960
1003
  }
961
1004
 
962
1005
  function mergeRemoteSessionSummary(
@@ -15,6 +15,7 @@ const DEFAULT_CONFIG = `feishu:
15
15
  appId: "\${FEISHU_APP_ID}"
16
16
  appSecret: "\${FEISHU_APP_SECRET}"
17
17
  userOpenId: "\${FEISHU_USER_OPEN_ID}"
18
+ respondToAllGroupMessages: true
18
19
 
19
20
  console:
20
21
  enabled: true
@@ -22,6 +22,7 @@ export const appConfigSchema = z.object({
22
22
  appId: z.string().optional(),
23
23
  appSecret: z.string().optional(),
24
24
  userOpenId: z.string().optional(),
25
+ respondToAllGroupMessages: z.boolean().default(true),
25
26
  useConsoleWhenMissingCredentials: z.boolean().default(false),
26
27
  }),
27
28
  agents: z.record(z.string().min(1), agentConfigSchema).refine(
@@ -790,8 +790,8 @@ function turnActivities(state: TurnViewState): TurnActivity[] {
790
790
 
791
791
  function toolPanel(tool: ToolState, projectCwd?: string): Record<string, unknown> {
792
792
  const elements = [markdown(renderToolDetails(tool, projectCwd))];
793
- if (tool.kind === "image_view" && tool.imagePath) {
794
- elements.push(localCardImage(tool.imagePath, "view_image 图片"));
793
+ if ((tool.kind === "image_view" || tool.kind === "image_generation") && tool.imagePath) {
794
+ elements.push(localCardImage(tool.imagePath, tool.kind === "image_generation" ? "生成图片" : "view_image 图片"));
795
795
  }
796
796
  return collapsiblePanel(toolPanelTitle(tool), elements, { elementId: toolPanelElementId(tool.id) });
797
797
  }
@@ -0,0 +1,55 @@
1
+ const TENANT_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal";
2
+ const BOT_INFO_URL = "https://open.feishu.cn/open-apis/bot/v3/info";
3
+
4
+ interface TenantTokenResponse {
5
+ code?: number;
6
+ msg?: string;
7
+ tenant_access_token?: string;
8
+ }
9
+
10
+ interface BotInfoResponse {
11
+ code?: number;
12
+ msg?: string;
13
+ bot?: {
14
+ open_id?: string;
15
+ };
16
+ }
17
+
18
+ export async function resolveFeishuBotOpenId(
19
+ appId: string,
20
+ appSecret: string,
21
+ fetchImpl: typeof globalThis.fetch = globalThis.fetch,
22
+ ): Promise<string> {
23
+ const tokenResponse = await fetchImpl(TENANT_TOKEN_URL, {
24
+ method: "POST",
25
+ headers: { "Content-Type": "application/json; charset=utf-8" },
26
+ body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
27
+ });
28
+ const tokenPayload = await readJson<TenantTokenResponse>(tokenResponse, "get the Lark tenant access token");
29
+ const token = tokenPayload.tenant_access_token;
30
+ if (!tokenResponse.ok || tokenPayload.code !== 0 || !token) {
31
+ throw new Error(`Failed to get the Lark tenant access token: ${responseMessage(tokenPayload, tokenResponse)}`);
32
+ }
33
+
34
+ const infoResponse = await fetchImpl(BOT_INFO_URL, {
35
+ headers: { Authorization: `Bearer ${token}` },
36
+ });
37
+ const infoPayload = await readJson<BotInfoResponse>(infoResponse, "get Lark bot information");
38
+ const openId = infoPayload.bot?.open_id;
39
+ if (!infoResponse.ok || infoPayload.code !== 0 || !openId) {
40
+ throw new Error(`Failed to get Lark bot information: ${responseMessage(infoPayload, infoResponse)}`);
41
+ }
42
+ return openId;
43
+ }
44
+
45
+ async function readJson<T>(response: Response, operation: string): Promise<T> {
46
+ try {
47
+ return await response.json() as T;
48
+ } catch {
49
+ throw new Error(`Failed to ${operation}: Lark returned an invalid response (HTTP ${response.status}).`);
50
+ }
51
+ }
52
+
53
+ function responseMessage(payload: { msg?: string }, response: Response): string {
54
+ return payload.msg || response.statusText || `HTTP ${response.status}`;
55
+ }
@@ -1,13 +1,17 @@
1
1
  import type { Logger } from "pino";
2
2
  import type { AppConfig } from "../config/schema.js";
3
3
  import { threadContextKey } from "./contextKey.js";
4
+ import { resolveFeishuBotOpenId } from "./FeishuBotIdentity.js";
4
5
  import type { ChatUpdatedEvent, FeishuEventHandler, IncomingMessage } from "./types.js";
5
6
 
7
+ type BotOpenIdResolver = (appId: string, appSecret: string) => Promise<string>;
8
+
6
9
  export class FeishuConnector {
7
10
  constructor(
8
11
  private readonly config: AppConfig,
9
12
  private readonly handler: FeishuEventHandler,
10
13
  private readonly logger: Logger,
14
+ private readonly botOpenIdResolver: BotOpenIdResolver = resolveFeishuBotOpenId,
11
15
  ) {}
12
16
 
13
17
  async start(): Promise<void> {
@@ -16,18 +20,22 @@ export class FeishuConnector {
16
20
  throw new Error("Feishu appId/appSecret are required.");
17
21
  }
18
22
 
19
- await this.startFeishuWs(appId, appSecret);
23
+ const respondToAllGroupMessages = this.config.feishu.respondToAllGroupMessages !== false;
24
+ const botOpenId = respondToAllGroupMessages
25
+ ? undefined
26
+ : await this.botOpenIdResolver(appId, appSecret);
27
+ await this.startFeishuWs(appId, appSecret, botOpenId);
20
28
  }
21
29
 
22
30
  stop(): void {
23
31
  // SDK connector currently relies on process lifetime.
24
32
  }
25
33
 
26
- private async startFeishuWs(appId: string, appSecret: string): Promise<void> {
34
+ private async startFeishuWs(appId: string, appSecret: string, requiredMentionOpenId?: string): Promise<void> {
27
35
  const lark = (await import("@larksuiteoapi/node-sdk")) as Record<string, any>;
28
36
  const eventDispatcher = new lark.EventDispatcher({}).register({
29
37
  "im.message.receive_v1": async (data: unknown) => {
30
- const message = toIncomingMessage(data);
38
+ const message = toIncomingMessage(data, requiredMentionOpenId);
31
39
  if (!message) {
32
40
  this.logger.debug({ data }, "Ignored unsupported Feishu message event.");
33
41
  return;
@@ -98,7 +106,7 @@ function toChatUpdatedEvent(data: unknown): ChatUpdatedEvent | undefined {
98
106
  };
99
107
  }
100
108
 
101
- function toIncomingMessage(data: unknown): IncomingMessage | undefined {
109
+ function toIncomingMessage(data: unknown, requiredMentionOpenId?: string): IncomingMessage | undefined {
102
110
  const event = getFeishuEvent(data);
103
111
  const message = event?.message;
104
112
  if (!message) {
@@ -111,6 +119,13 @@ function toIncomingMessage(data: unknown): IncomingMessage | undefined {
111
119
  if (!parsed) return undefined;
112
120
  const chatId = message.chat_id;
113
121
  const chatType = message.chat_type === "group" ? "group" : "p2p";
122
+ if (
123
+ chatType === "group" &&
124
+ requiredMentionOpenId &&
125
+ !mentionsOpenId(message.mentions, requiredMentionOpenId)
126
+ ) {
127
+ return undefined;
128
+ }
114
129
  const threadId = typeof message.thread_id === "string" && message.thread_id ? message.thread_id : undefined;
115
130
  const rootMessageId = typeof message.root_id === "string" && message.root_id ? message.root_id : undefined;
116
131
  const parentMessageId = typeof message.parent_id === "string" && message.parent_id ? message.parent_id : undefined;
@@ -142,6 +157,11 @@ function toIncomingMessage(data: unknown): IncomingMessage | undefined {
142
157
  };
143
158
  }
144
159
 
160
+ function mentionsOpenId(value: unknown, openId: string): boolean {
161
+ return Array.isArray(value) && value.some((mention) =>
162
+ isRecord(mention) && mention.id === openId);
163
+ }
164
+
145
165
  function parseMessageContent(
146
166
  messageType: unknown,
147
167
  content: Record<string, unknown>,
@@ -1,4 +1,5 @@
1
1
  import type { AgentEvent, ToolState } from "../runtime/types.js";
2
+ import { appendGeneratedImageMarkdown } from "../utils/generatedImageMarkdown.js";
2
3
  import type { MessageReplyTarget } from "../feishu/types.js";
3
4
  import type { FileSummary, TurnActivity, TurnViewState } from "./turnViewTypes.js";
4
5
 
@@ -107,7 +108,11 @@ export function reduceTurnEvent(state: TurnViewState, event: AgentEvent): TurnVi
107
108
  approval: undefined,
108
109
  completedAt: state.startedAt + (event.durationMs ?? Date.now() - state.startedAt),
109
110
  durationMs: event.durationMs ?? Date.now() - state.startedAt,
110
- finalResponse: event.finalResponse,
111
+ finalResponse: appendGeneratedImageMarkdown(
112
+ event.finalResponse,
113
+ state.completedTools.flatMap((tool) =>
114
+ tool.kind === "image_generation" && tool.imagePath ? [tool.imagePath] : []),
115
+ ),
111
116
  };
112
117
  case "turn_cancelled":
113
118
  return { ...state, status: "cancelled", activeTool: undefined, approval: undefined, completedAt: Date.now() };
@@ -3264,7 +3264,10 @@ function truncateTail(value: string, maxLength: number): string {
3264
3264
  function parseAgentGroupName(value: string): { agentName: string; title: string } | undefined {
3265
3265
  const match = /^\[([^[\]]+)\]\s+(.+)$/.exec(value.trim());
3266
3266
  const agentName = match?.[1]?.trim();
3267
- const title = match?.[2]?.trim();
3267
+ const remainder = match?.[2]?.trim();
3268
+ if (!agentName || !remainder) return undefined;
3269
+ const projectPrefix = /^\[([^[\]]+)\](?:\s+(.+))?$/.exec(remainder);
3270
+ const title = projectPrefix ? projectPrefix[2]?.trim() : remainder;
3268
3271
  return agentName && title ? { agentName, title } : undefined;
3269
3272
  }
3270
3273
 
@@ -0,0 +1,10 @@
1
+ export function appendGeneratedImageMarkdown(finalResponse: string, imagePaths: string[]): string {
2
+ const text = finalResponse.trim();
3
+ const normalizedText = text.replaceAll("\\", "/");
4
+ const images = [...new Set(imagePaths.map((imagePath) => imagePath.trim().replaceAll("\\", "/")).filter(Boolean))]
5
+ .filter((imagePath) => !normalizedText.includes(imagePath));
6
+ if (images.length === 0) return text;
7
+ const imageMarkdown = images.map((imagePath, index) =>
8
+ `![生成图片 ${index + 1}](<${imagePath}>)`).join("\n\n");
9
+ return text ? `${text}\n\n${imageMarkdown}` : imageMarkdown;
10
+ }