@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.
- package/CHANGELOG.md +6 -0
- package/README.md +4 -0
- package/README.zh.md +4 -0
- package/config.example.yaml +2 -0
- package/dist/cli/FeishuAppConfiguration.js +2 -5
- package/dist/cli/FeishuAppConfiguration.js.map +1 -1
- package/dist/codex/CodexEventMapper.js +13 -0
- package/dist/codex/CodexEventMapper.js.map +1 -1
- package/dist/codex/CodexRuntime.js +33 -4
- package/dist/codex/CodexRuntime.js.map +1 -1
- package/dist/config/loadConfig.js +1 -0
- package/dist/config/loadConfig.js.map +1 -1
- package/dist/config/schema.js +1 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/feishu/CardRenderer.js +2 -2
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/feishu/FeishuBotIdentity.js +35 -0
- package/dist/feishu/FeishuBotIdentity.js.map +1 -0
- package/dist/feishu/FeishuConnector.js +20 -5
- package/dist/feishu/FeishuConnector.js.map +1 -1
- package/dist/presentation/TurnStateReducer.js +2 -1
- package/dist/presentation/TurnStateReducer.js.map +1 -1
- package/dist/proxy/ProxySessionController.js +5 -1
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/utils/generatedImageMarkdown.js +11 -0
- package/dist/utils/generatedImageMarkdown.js.map +1 -0
- package/docs/technical-reference.md +5 -1
- package/docs/technical-reference.zh.md +5 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/agent-bot/SKILL.md +5 -1
- package/src/cli/FeishuAppConfiguration.ts +2 -5
- package/src/codex/CodexEventMapper.ts +13 -0
- package/src/codex/CodexRuntime.ts +48 -5
- package/src/config/loadConfig.ts +1 -0
- package/src/config/schema.ts +1 -0
- package/src/feishu/CardRenderer.ts +2 -2
- package/src/feishu/FeishuBotIdentity.ts +55 -0
- package/src/feishu/FeishuConnector.ts +24 -4
- package/src/presentation/TurnStateReducer.ts +6 -1
- package/src/proxy/ProxySessionController.ts +4 -1
- 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
|
-
-
|
|
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。
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keyou007/agent-bot",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
|
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<{
|
|
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"
|
|
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(
|
package/src/config/loadConfig.ts
CHANGED
package/src/config/schema.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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
|
+
``).join("\n\n");
|
|
9
|
+
return text ? `${text}\n\n${imageMarkdown}` : imageMarkdown;
|
|
10
|
+
}
|