@keyou007/agent-bot 0.1.0
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/.env.example +8 -0
- package/CHANGELOG.md +11 -0
- package/LICENSE +675 -0
- package/README.md +187 -0
- package/README.zh.md +187 -0
- package/SECURITY.md +13 -0
- package/config.example.yaml +56 -0
- package/dist/acp/AcpJsonRpcConnection.js +155 -0
- package/dist/acp/AcpJsonRpcConnection.js.map +1 -0
- package/dist/acp/AcpProcessManager.js +65 -0
- package/dist/acp/AcpProcessManager.js.map +1 -0
- package/dist/acp/AcpSessionManager.js +164 -0
- package/dist/acp/AcpSessionManager.js.map +1 -0
- package/dist/acp/acpTypes.js +10 -0
- package/dist/acp/acpTypes.js.map +1 -0
- package/dist/cli/FeishuAppConfiguration.js +349 -0
- package/dist/cli/FeishuAppConfiguration.js.map +1 -0
- package/dist/cli/FeishuAppRegistration.js +160 -0
- package/dist/cli/FeishuAppRegistration.js.map +1 -0
- package/dist/cli/Initializer.js +112 -0
- package/dist/cli/Initializer.js.map +1 -0
- package/dist/cli/LocalControlClient.js +47 -0
- package/dist/cli/LocalControlClient.js.map +1 -0
- package/dist/cli/LocalControlServer.js +66 -0
- package/dist/cli/LocalControlServer.js.map +1 -0
- package/dist/cli/SkillRegistry.js +126 -0
- package/dist/cli/SkillRegistry.js.map +1 -0
- package/dist/cli/controlProtocol.js +10 -0
- package/dist/cli/controlProtocol.js.map +1 -0
- package/dist/cli/taskChatRoute.js +23 -0
- package/dist/cli/taskChatRoute.js.map +1 -0
- package/dist/cli.js +616 -0
- package/dist/cli.js.map +1 -0
- package/dist/codex/AppServerConnection.js +131 -0
- package/dist/codex/AppServerConnection.js.map +1 -0
- package/dist/codex/CodexEventMapper.js +296 -0
- package/dist/codex/CodexEventMapper.js.map +1 -0
- package/dist/codex/CodexLocalActivityDetector.js +124 -0
- package/dist/codex/CodexLocalActivityDetector.js.map +1 -0
- package/dist/codex/CodexProcessManager.js +64 -0
- package/dist/codex/CodexProcessManager.js.map +1 -0
- package/dist/codex/CodexRuntime.js +825 -0
- package/dist/codex/CodexRuntime.js.map +1 -0
- package/dist/codex/ProjectlessWorkspace.js +74 -0
- package/dist/codex/ProjectlessWorkspace.js.map +1 -0
- package/dist/codex/protocol.js +10 -0
- package/dist/codex/protocol.js.map +1 -0
- package/dist/commands/CommandRouter.js +176 -0
- package/dist/commands/CommandRouter.js.map +1 -0
- package/dist/commands/commandTypes.js +2 -0
- package/dist/commands/commandTypes.js.map +1 -0
- package/dist/config/loadConfig.js +107 -0
- package/dist/config/loadConfig.js.map +1 -0
- package/dist/config/paths.js +32 -0
- package/dist/config/paths.js.map +1 -0
- package/dist/config/schema.js +38 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/console/ConsoleConnector.js +39 -0
- package/dist/console/ConsoleConnector.js.map +1 -0
- package/dist/console/ConsoleTurnPresenter.js +60 -0
- package/dist/console/ConsoleTurnPresenter.js.map +1 -0
- package/dist/feishu/CardRenderer.js +972 -0
- package/dist/feishu/CardRenderer.js.map +1 -0
- package/dist/feishu/CardUpdateScheduler.js +148 -0
- package/dist/feishu/CardUpdateScheduler.js.map +1 -0
- package/dist/feishu/ConsoleFeishuClient.js +18 -0
- package/dist/feishu/ConsoleFeishuClient.js.map +1 -0
- package/dist/feishu/FeishuConnector.js +257 -0
- package/dist/feishu/FeishuConnector.js.map +1 -0
- package/dist/feishu/FeishuMarkdown.js +37 -0
- package/dist/feishu/FeishuMarkdown.js.map +1 -0
- package/dist/feishu/FeishuMessageClient.js +459 -0
- package/dist/feishu/FeishuMessageClient.js.map +1 -0
- package/dist/feishu/FeishuTurnPresenter.js +425 -0
- package/dist/feishu/FeishuTurnPresenter.js.map +1 -0
- package/dist/feishu/GroupAvatarGenerator.js +86 -0
- package/dist/feishu/GroupAvatarGenerator.js.map +1 -0
- package/dist/feishu/LocalCardImage.js +13 -0
- package/dist/feishu/LocalCardImage.js.map +1 -0
- package/dist/feishu/LocalImageMarkdown.js +89 -0
- package/dist/feishu/LocalImageMarkdown.js.map +1 -0
- package/dist/feishu/contextKey.js +12 -0
- package/dist/feishu/contextKey.js.map +1 -0
- package/dist/feishu/transport.js +14 -0
- package/dist/feishu/transport.js.map +1 -0
- package/dist/feishu/types.js +2 -0
- package/dist/feishu/types.js.map +1 -0
- package/dist/index.js +213 -0
- package/dist/index.js.map +1 -0
- package/dist/logging/logger.js +22 -0
- package/dist/logging/logger.js.map +1 -0
- package/dist/presentation/OutboundRouter.js +127 -0
- package/dist/presentation/OutboundRouter.js.map +1 -0
- package/dist/presentation/TurnStateReducer.js +307 -0
- package/dist/presentation/TurnStateReducer.js.map +1 -0
- package/dist/presentation/splitMarkdown.js +341 -0
- package/dist/presentation/splitMarkdown.js.map +1 -0
- package/dist/presentation/turnViewTypes.js +2 -0
- package/dist/presentation/turnViewTypes.js.map +1 -0
- package/dist/proxy/ProxySessionController.js +2819 -0
- package/dist/proxy/ProxySessionController.js.map +1 -0
- package/dist/runtime/AcpRuntimeAdapter.js +271 -0
- package/dist/runtime/AcpRuntimeAdapter.js.map +1 -0
- package/dist/runtime/AgentRuntimeRegistry.js +18 -0
- package/dist/runtime/AgentRuntimeRegistry.js.map +1 -0
- package/dist/runtime/agentEnvironment.js +11 -0
- package/dist/runtime/agentEnvironment.js.map +1 -0
- package/dist/runtime/types.js +2 -0
- package/dist/runtime/types.js.map +1 -0
- package/dist/startup/SessionMetadataHydrator.js +46 -0
- package/dist/startup/SessionMetadataHydrator.js.map +1 -0
- package/dist/startup/StartupNotifier.js +78 -0
- package/dist/startup/StartupNotifier.js.map +1 -0
- package/dist/startup/startFeishu.js +6 -0
- package/dist/startup/startFeishu.js.map +1 -0
- package/dist/state/StateStore.js +893 -0
- package/dist/state/StateStore.js.map +1 -0
- package/dist/state/migrations.js +129 -0
- package/dist/state/migrations.js.map +1 -0
- package/dist/supervision/SafeRestartNotifier.js +116 -0
- package/dist/supervision/SafeRestartNotifier.js.map +1 -0
- package/dist/supervision/SafeRestartScheduler.js +123 -0
- package/dist/supervision/SafeRestartScheduler.js.map +1 -0
- package/dist/supervision/replacementSupervisor.js +8 -0
- package/dist/supervision/replacementSupervisor.js.map +1 -0
- package/dist/supervision/restartPolicy.js +16 -0
- package/dist/supervision/restartPolicy.js.map +1 -0
- package/dist/supervisor.js +94 -0
- package/dist/supervisor.js.map +1 -0
- package/dist/utils/executeShellCommand.js +76 -0
- package/dist/utils/executeShellCommand.js.map +1 -0
- package/dist/utils/id.js +5 -0
- package/dist/utils/id.js.map +1 -0
- package/dist/utils/markdown.js +29 -0
- package/dist/utils/markdown.js.map +1 -0
- package/dist/utils/spawnCommand.js +21 -0
- package/dist/utils/spawnCommand.js.map +1 -0
- package/dist/utils/taskTitle.js +10 -0
- package/dist/utils/taskTitle.js.map +1 -0
- package/docs/technical-reference.md +294 -0
- package/docs/technical-reference.zh.md +294 -0
- package/npm-shrinkwrap.json +4525 -0
- package/package.json +87 -0
- package/skills/agent-bot/SKILL.md +141 -0
- package/skills/agent-bot/agents/openai.yaml +4 -0
- package/src/acp/AcpJsonRpcConnection.ts +212 -0
- package/src/acp/AcpProcessManager.ts +87 -0
- package/src/acp/AcpSessionManager.ts +227 -0
- package/src/acp/acpTypes.ts +99 -0
- package/src/cli/FeishuAppConfiguration.ts +473 -0
- package/src/cli/FeishuAppRegistration.ts +207 -0
- package/src/cli/Initializer.ts +159 -0
- package/src/cli/LocalControlClient.ts +48 -0
- package/src/cli/LocalControlServer.ts +67 -0
- package/src/cli/SkillRegistry.ts +151 -0
- package/src/cli/controlProtocol.ts +34 -0
- package/src/cli/taskChatRoute.ts +31 -0
- package/src/cli.ts +630 -0
- package/src/codex/AppServerConnection.ts +159 -0
- package/src/codex/CodexEventMapper.ts +351 -0
- package/src/codex/CodexLocalActivityDetector.ts +148 -0
- package/src/codex/CodexProcessManager.ts +63 -0
- package/src/codex/CodexRuntime.ts +1025 -0
- package/src/codex/ProjectlessWorkspace.ts +90 -0
- package/src/codex/protocol.ts +37 -0
- package/src/commands/CommandRouter.ts +179 -0
- package/src/commands/commandTypes.ts +28 -0
- package/src/config/loadConfig.ts +115 -0
- package/src/config/paths.ts +37 -0
- package/src/config/schema.ts +46 -0
- package/src/console/ConsoleConnector.ts +40 -0
- package/src/console/ConsoleTurnPresenter.ts +64 -0
- package/src/feishu/CardRenderer.ts +1157 -0
- package/src/feishu/CardUpdateScheduler.ts +165 -0
- package/src/feishu/ConsoleFeishuClient.ts +22 -0
- package/src/feishu/FeishuConnector.ts +277 -0
- package/src/feishu/FeishuMarkdown.ts +47 -0
- package/src/feishu/FeishuMessageClient.ts +649 -0
- package/src/feishu/FeishuTurnPresenter.ts +539 -0
- package/src/feishu/GroupAvatarGenerator.ts +102 -0
- package/src/feishu/LocalCardImage.ts +13 -0
- package/src/feishu/LocalImageMarkdown.ts +92 -0
- package/src/feishu/contextKey.ts +14 -0
- package/src/feishu/transport.ts +24 -0
- package/src/feishu/types.ts +83 -0
- package/src/index.ts +228 -0
- package/src/logging/logger.ts +28 -0
- package/src/presentation/OutboundRouter.ts +205 -0
- package/src/presentation/TurnStateReducer.ts +341 -0
- package/src/presentation/splitMarkdown.ts +358 -0
- package/src/presentation/turnViewTypes.ts +54 -0
- package/src/proxy/ProxySessionController.ts +3311 -0
- package/src/runtime/AcpRuntimeAdapter.ts +324 -0
- package/src/runtime/AgentRuntimeRegistry.ts +20 -0
- package/src/runtime/agentEnvironment.ts +14 -0
- package/src/runtime/types.ts +215 -0
- package/src/startup/SessionMetadataHydrator.ts +46 -0
- package/src/startup/StartupNotifier.ts +92 -0
- package/src/startup/startFeishu.ts +19 -0
- package/src/state/StateStore.ts +1249 -0
- package/src/state/migrations.ts +128 -0
- package/src/supervision/SafeRestartNotifier.ts +122 -0
- package/src/supervision/SafeRestartScheduler.ts +149 -0
- package/src/supervision/replacementSupervisor.ts +10 -0
- package/src/supervision/restartPolicy.ts +19 -0
- package/src/supervisor.ts +106 -0
- package/src/utils/executeShellCommand.ts +97 -0
- package/src/utils/id.ts +5 -0
- package/src/utils/markdown.ts +25 -0
- package/src/utils/spawnCommand.ts +35 -0
- package/src/utils/taskTitle.ts +9 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { Logger } from "pino";
|
|
5
|
+
import { defaultSqlitePath } from "../config/paths.js";
|
|
6
|
+
import type { AppConfig } from "../config/schema.js";
|
|
7
|
+
import { normalizeFeishuMarkdown } from "./FeishuMarkdown.js";
|
|
8
|
+
import { LOCAL_CARD_IMAGE_PATH } from "./LocalCardImage.js";
|
|
9
|
+
import { renderMarkdownWithLocalImages } from "./LocalImageMarkdown.js";
|
|
10
|
+
import type {
|
|
11
|
+
CreatedGroup,
|
|
12
|
+
CreateGroupInput,
|
|
13
|
+
FeishuOutbound,
|
|
14
|
+
MessageReplyTarget,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
|
|
17
|
+
interface TenantTokenResponse {
|
|
18
|
+
code: number;
|
|
19
|
+
msg: string;
|
|
20
|
+
tenant_access_token?: string;
|
|
21
|
+
expire?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface SendMessageResponse {
|
|
25
|
+
code: number;
|
|
26
|
+
msg: string;
|
|
27
|
+
data?: {
|
|
28
|
+
message_id?: string;
|
|
29
|
+
};
|
|
30
|
+
error?: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface CreateGroupResponse {
|
|
34
|
+
code: number;
|
|
35
|
+
msg: string;
|
|
36
|
+
data?: {
|
|
37
|
+
chat_id?: string;
|
|
38
|
+
};
|
|
39
|
+
error?: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface UploadImageResponse {
|
|
43
|
+
code: number;
|
|
44
|
+
msg: string;
|
|
45
|
+
data?: {
|
|
46
|
+
image_key?: string;
|
|
47
|
+
};
|
|
48
|
+
error?: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ReactionResponse {
|
|
52
|
+
code: number;
|
|
53
|
+
msg: string;
|
|
54
|
+
data?: {
|
|
55
|
+
reaction_id?: string;
|
|
56
|
+
};
|
|
57
|
+
error?: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface DownloadImageErrorResponse {
|
|
61
|
+
code: number;
|
|
62
|
+
msg: string;
|
|
63
|
+
error?: unknown;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class FeishuMessageClient implements FeishuOutbound {
|
|
67
|
+
private token?: {
|
|
68
|
+
value: string;
|
|
69
|
+
expiresAt: number;
|
|
70
|
+
};
|
|
71
|
+
private readonly sendQueues = new Map<string, Promise<void>>();
|
|
72
|
+
private readonly lastSendAt = new Map<string, number>();
|
|
73
|
+
private readonly imageUploads = new Map<string, { mtimeMs: number; upload: Promise<string> }>();
|
|
74
|
+
private readonly imageDownloads = new Map<string, Promise<string>>();
|
|
75
|
+
private readonly minSendIntervalMs = 1200;
|
|
76
|
+
|
|
77
|
+
constructor(
|
|
78
|
+
private readonly config: AppConfig,
|
|
79
|
+
private readonly logger: Logger,
|
|
80
|
+
) {}
|
|
81
|
+
|
|
82
|
+
async createGroup(input: CreateGroupInput): Promise<CreatedGroup> {
|
|
83
|
+
try {
|
|
84
|
+
const token = await this.getTenantAccessToken();
|
|
85
|
+
const avatar = input.avatarPng
|
|
86
|
+
? await this.uploadImageBytes(input.avatarPng, "avatar", "group-avatar.png")
|
|
87
|
+
: undefined;
|
|
88
|
+
const response = await fetch(
|
|
89
|
+
"https://open.feishu.cn/open-apis/im/v1/chats?user_id_type=open_id",
|
|
90
|
+
{
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: {
|
|
93
|
+
Authorization: `Bearer ${token}`,
|
|
94
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
95
|
+
},
|
|
96
|
+
body: JSON.stringify({
|
|
97
|
+
chat_mode: "group",
|
|
98
|
+
chat_type: "private",
|
|
99
|
+
name: input.name,
|
|
100
|
+
user_id_list: [input.userOpenId],
|
|
101
|
+
...(avatar ? { avatar } : {}),
|
|
102
|
+
}),
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
const payload = (await response.json()) as CreateGroupResponse;
|
|
106
|
+
const chatId = payload.data?.chat_id;
|
|
107
|
+
if (!response.ok || payload.code !== 0 || !chatId) {
|
|
108
|
+
throw new FeishuApiError(
|
|
109
|
+
payload.msg || response.statusText,
|
|
110
|
+
payload.code,
|
|
111
|
+
payload,
|
|
112
|
+
"create group",
|
|
113
|
+
response.status,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return { chatId, name: input.name };
|
|
117
|
+
} catch (error) {
|
|
118
|
+
throw normalizeTransportError(error, "create group");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async addReaction(messageId: string, emojiType: string): Promise<string | undefined> {
|
|
123
|
+
try {
|
|
124
|
+
const token = await this.getTenantAccessToken();
|
|
125
|
+
const response = await fetch(
|
|
126
|
+
`https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions`,
|
|
127
|
+
{
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: {
|
|
130
|
+
Authorization: `Bearer ${token}`,
|
|
131
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
132
|
+
},
|
|
133
|
+
body: JSON.stringify({ reaction_type: { emoji_type: emojiType } }),
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
const payload = (await response.json()) as ReactionResponse;
|
|
137
|
+
if (!response.ok || payload.code !== 0) {
|
|
138
|
+
throw new FeishuApiError(payload.msg || response.statusText, payload.code, payload, "add reaction", response.status);
|
|
139
|
+
}
|
|
140
|
+
return payload.data?.reaction_id;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
throw normalizeTransportError(error, "add reaction");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async deleteReaction(messageId: string, reactionId: string): Promise<void> {
|
|
147
|
+
try {
|
|
148
|
+
const token = await this.getTenantAccessToken();
|
|
149
|
+
const response = await fetch(
|
|
150
|
+
`https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(reactionId)}`,
|
|
151
|
+
{
|
|
152
|
+
method: "DELETE",
|
|
153
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
const payload = (await response.json()) as ReactionResponse;
|
|
157
|
+
if (!response.ok || payload.code !== 0) {
|
|
158
|
+
throw new FeishuApiError(payload.msg || response.statusText, payload.code, payload, "delete reaction", response.status);
|
|
159
|
+
}
|
|
160
|
+
} catch (error) {
|
|
161
|
+
throw normalizeTransportError(error, "delete reaction");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async downloadImage(messageId: string, imageKey: string): Promise<string> {
|
|
166
|
+
const cacheKey = `${messageId}:${imageKey}`;
|
|
167
|
+
const cached = this.imageDownloads.get(cacheKey);
|
|
168
|
+
if (cached) return cached;
|
|
169
|
+
const download = this.downloadImageNow(messageId, imageKey).catch((error: unknown) => {
|
|
170
|
+
if (this.imageDownloads.get(cacheKey) === download) this.imageDownloads.delete(cacheKey);
|
|
171
|
+
throw error;
|
|
172
|
+
});
|
|
173
|
+
this.imageDownloads.set(cacheKey, download);
|
|
174
|
+
return download;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async sendText(contextKey: string, text: string): Promise<string | undefined> {
|
|
178
|
+
return this.sendMessage(contextKey, "text", { text });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async sendMarkdown(contextKey: string, markdown: string, idempotencyKey?: string): Promise<string | undefined> {
|
|
182
|
+
const elements = await renderMarkdownWithLocalImages(
|
|
183
|
+
normalizeFeishuMarkdown(markdown),
|
|
184
|
+
(filePath) => this.uploadImageCached(filePath),
|
|
185
|
+
(error, filePath) => this.logger.warn({ error, filePath }, "Failed to upload local image to Feishu."),
|
|
186
|
+
);
|
|
187
|
+
return this.sendMessage(contextKey, "interactive", finalAnswerCard(elements), idempotencyKey);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async sendInteractiveCard(
|
|
191
|
+
contextKey: string,
|
|
192
|
+
card: Record<string, unknown>,
|
|
193
|
+
): Promise<string | undefined> {
|
|
194
|
+
return this.sendMessage(contextKey, "interactive", await this.prepareInteractiveCard(card));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async replyText(
|
|
198
|
+
contextKey: string,
|
|
199
|
+
target: MessageReplyTarget,
|
|
200
|
+
text: string,
|
|
201
|
+
idempotencyKey?: string,
|
|
202
|
+
): Promise<string | undefined> {
|
|
203
|
+
return this.replyMessage(contextKey, target, "text", { text }, idempotencyKey);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async replyMarkdown(
|
|
207
|
+
contextKey: string,
|
|
208
|
+
target: MessageReplyTarget,
|
|
209
|
+
markdown: string,
|
|
210
|
+
idempotencyKey?: string,
|
|
211
|
+
): Promise<string | undefined> {
|
|
212
|
+
const elements = await renderMarkdownWithLocalImages(
|
|
213
|
+
normalizeFeishuMarkdown(markdown),
|
|
214
|
+
(filePath) => this.uploadImageCached(filePath),
|
|
215
|
+
(error, filePath) => this.logger.warn({ error, filePath }, "Failed to upload local image to Feishu."),
|
|
216
|
+
);
|
|
217
|
+
return this.replyMessage(contextKey, target, "interactive", finalAnswerCard(elements), idempotencyKey);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async replyInteractiveCard(
|
|
221
|
+
contextKey: string,
|
|
222
|
+
target: MessageReplyTarget,
|
|
223
|
+
card: Record<string, unknown>,
|
|
224
|
+
idempotencyKey?: string,
|
|
225
|
+
): Promise<string | undefined> {
|
|
226
|
+
return this.replyMessage(
|
|
227
|
+
contextKey,
|
|
228
|
+
target,
|
|
229
|
+
"interactive",
|
|
230
|
+
await this.prepareInteractiveCard(card),
|
|
231
|
+
idempotencyKey,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async updateInteractiveCard(messageId: string, card: Record<string, unknown>): Promise<void> {
|
|
236
|
+
try {
|
|
237
|
+
const preparedCard = await this.prepareInteractiveCard(card);
|
|
238
|
+
const token = await this.getTenantAccessToken();
|
|
239
|
+
const response = await fetch(`https://open.feishu.cn/open-apis/im/v1/messages/${messageId}`, {
|
|
240
|
+
method: "PATCH",
|
|
241
|
+
headers: {
|
|
242
|
+
Authorization: `Bearer ${token}`,
|
|
243
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
244
|
+
},
|
|
245
|
+
body: JSON.stringify({ content: JSON.stringify(preparedCard) }),
|
|
246
|
+
});
|
|
247
|
+
const payload = (await response.json()) as SendMessageResponse;
|
|
248
|
+
if (!response.ok || payload.code !== 0) {
|
|
249
|
+
throw new FeishuApiError(payload.msg || response.statusText, payload.code, payload, "update", response.status);
|
|
250
|
+
}
|
|
251
|
+
} catch (error) {
|
|
252
|
+
throw normalizeTransportError(error, "update");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private async sendMessage(
|
|
257
|
+
contextKey: string,
|
|
258
|
+
msgType: string,
|
|
259
|
+
content: Record<string, unknown>,
|
|
260
|
+
idempotencyKey?: string,
|
|
261
|
+
): Promise<string | undefined> {
|
|
262
|
+
return this.enqueueSend(contextKey, () => this.sendMessageNow(contextKey, msgType, content, idempotencyKey));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private async replyMessage(
|
|
266
|
+
contextKey: string,
|
|
267
|
+
target: MessageReplyTarget,
|
|
268
|
+
msgType: string,
|
|
269
|
+
content: Record<string, unknown>,
|
|
270
|
+
idempotencyKey?: string,
|
|
271
|
+
): Promise<string | undefined> {
|
|
272
|
+
return this.enqueueSend(contextKey, () =>
|
|
273
|
+
this.replyMessageNow(target, msgType, content, idempotencyKey));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private async replyMessageNow(
|
|
277
|
+
target: MessageReplyTarget,
|
|
278
|
+
msgType: string,
|
|
279
|
+
content: Record<string, unknown>,
|
|
280
|
+
idempotencyKey?: string,
|
|
281
|
+
): Promise<string | undefined> {
|
|
282
|
+
let token: string;
|
|
283
|
+
try {
|
|
284
|
+
token = await this.getTenantAccessToken();
|
|
285
|
+
} catch (error) {
|
|
286
|
+
throw normalizeTransportError(error, "reply");
|
|
287
|
+
}
|
|
288
|
+
let lastError: Error | undefined;
|
|
289
|
+
|
|
290
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
291
|
+
if (attempt > 0) await delay(5000 * attempt);
|
|
292
|
+
|
|
293
|
+
let error: FeishuApiError;
|
|
294
|
+
try {
|
|
295
|
+
const response = await fetch(
|
|
296
|
+
`https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(target.messageId)}/reply`,
|
|
297
|
+
{
|
|
298
|
+
method: "POST",
|
|
299
|
+
headers: {
|
|
300
|
+
Authorization: `Bearer ${token}`,
|
|
301
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
302
|
+
},
|
|
303
|
+
body: JSON.stringify({
|
|
304
|
+
msg_type: msgType,
|
|
305
|
+
content: JSON.stringify(content),
|
|
306
|
+
reply_in_thread: target.replyInThread,
|
|
307
|
+
...(idempotencyKey ? { uuid: idempotencyKey } : {}),
|
|
308
|
+
}),
|
|
309
|
+
},
|
|
310
|
+
);
|
|
311
|
+
const payload = (await response.json()) as SendMessageResponse;
|
|
312
|
+
if (response.ok && payload.code === 0) return payload.data?.message_id;
|
|
313
|
+
error = new FeishuApiError(payload.msg || response.statusText, payload.code, payload, "reply", response.status);
|
|
314
|
+
} catch (caught) {
|
|
315
|
+
error = normalizeTransportError(caught, "reply");
|
|
316
|
+
}
|
|
317
|
+
lastError = error;
|
|
318
|
+
|
|
319
|
+
if (!error.isRetryable || attempt === 2) {
|
|
320
|
+
this.logger.error({ error, messageId: target.messageId, msgType }, "Failed to reply to Feishu message.");
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
this.logger.warn(
|
|
324
|
+
{ code: error.code, messageId: target.messageId, msgType, attempt: attempt + 1 },
|
|
325
|
+
"Retryable Feishu reply failure; retrying after backoff.",
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
throw lastError ?? new Error("Failed to reply to Feishu message.");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private async sendMessageNow(
|
|
333
|
+
contextKey: string,
|
|
334
|
+
msgType: string,
|
|
335
|
+
content: Record<string, unknown>,
|
|
336
|
+
idempotencyKey?: string,
|
|
337
|
+
): Promise<string | undefined> {
|
|
338
|
+
let token: string;
|
|
339
|
+
try {
|
|
340
|
+
token = await this.getTenantAccessToken();
|
|
341
|
+
} catch (error) {
|
|
342
|
+
throw normalizeTransportError(error, "send");
|
|
343
|
+
}
|
|
344
|
+
const { receiveId, receiveIdType } = parseContextKey(contextKey);
|
|
345
|
+
let lastError: Error | undefined;
|
|
346
|
+
|
|
347
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
348
|
+
if (attempt > 0) {
|
|
349
|
+
await delay(5000 * attempt);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
let error: FeishuApiError;
|
|
353
|
+
try {
|
|
354
|
+
const response = await fetch(
|
|
355
|
+
`https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`,
|
|
356
|
+
{
|
|
357
|
+
method: "POST",
|
|
358
|
+
headers: {
|
|
359
|
+
Authorization: `Bearer ${token}`,
|
|
360
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
361
|
+
},
|
|
362
|
+
body: JSON.stringify({
|
|
363
|
+
receive_id: receiveId,
|
|
364
|
+
msg_type: msgType,
|
|
365
|
+
content: JSON.stringify(content),
|
|
366
|
+
...(idempotencyKey ? { uuid: idempotencyKey } : {}),
|
|
367
|
+
}),
|
|
368
|
+
},
|
|
369
|
+
);
|
|
370
|
+
const payload = (await response.json()) as SendMessageResponse;
|
|
371
|
+
if (response.ok && payload.code === 0) return payload.data?.message_id;
|
|
372
|
+
error = new FeishuApiError(payload.msg || response.statusText, payload.code, payload, "send", response.status);
|
|
373
|
+
} catch (caught) {
|
|
374
|
+
error = normalizeTransportError(caught, "send");
|
|
375
|
+
}
|
|
376
|
+
lastError = error;
|
|
377
|
+
|
|
378
|
+
if (!error.isRetryable || attempt === 2) {
|
|
379
|
+
this.logger.error({ error, contextKey, msgType }, "Failed to send Feishu message.");
|
|
380
|
+
throw error;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
this.logger.warn(
|
|
384
|
+
{ code: error.code, contextKey, msgType, attempt: attempt + 1 },
|
|
385
|
+
"Retryable Feishu send failure; retrying after backoff.",
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
throw lastError ?? new Error("Failed to send Feishu message.");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private enqueueSend<T>(contextKey: string, task: () => Promise<T>): Promise<T> {
|
|
393
|
+
const previous = this.sendQueues.get(contextKey) ?? Promise.resolve();
|
|
394
|
+
const queued = previous.catch(() => undefined).then(async () => {
|
|
395
|
+
const lastSendAt = this.lastSendAt.get(contextKey) ?? 0;
|
|
396
|
+
const elapsed = Date.now() - lastSendAt;
|
|
397
|
+
if (elapsed < this.minSendIntervalMs) {
|
|
398
|
+
await delay(this.minSendIntervalMs - elapsed);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const result = await task();
|
|
402
|
+
this.lastSendAt.set(contextKey, Date.now());
|
|
403
|
+
return result;
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const stored = queued.then(
|
|
407
|
+
() => undefined,
|
|
408
|
+
() => undefined,
|
|
409
|
+
);
|
|
410
|
+
this.sendQueues.set(contextKey, stored);
|
|
411
|
+
stored.finally(() => {
|
|
412
|
+
if (this.sendQueues.get(contextKey) === stored) {
|
|
413
|
+
this.sendQueues.delete(contextKey);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
return queued;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private async getTenantAccessToken(): Promise<string> {
|
|
421
|
+
const now = Date.now();
|
|
422
|
+
if (this.token && this.token.expiresAt > now + 60_000) {
|
|
423
|
+
return this.token.value;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const appId = this.config.feishu.appId;
|
|
427
|
+
const appSecret = this.config.feishu.appSecret;
|
|
428
|
+
if (!appId || !appSecret) {
|
|
429
|
+
throw new Error("Feishu appId/appSecret are required for real Feishu messaging.");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const response = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
|
|
433
|
+
method: "POST",
|
|
434
|
+
headers: {
|
|
435
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
436
|
+
},
|
|
437
|
+
body: JSON.stringify({
|
|
438
|
+
app_id: appId,
|
|
439
|
+
app_secret: appSecret,
|
|
440
|
+
}),
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const payload = (await response.json()) as TenantTokenResponse;
|
|
444
|
+
if (!response.ok || payload.code !== 0 || !payload.tenant_access_token) {
|
|
445
|
+
throw new Error(`Failed to get Feishu tenant_access_token: ${payload.msg || response.statusText}`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
this.token = {
|
|
449
|
+
value: payload.tenant_access_token,
|
|
450
|
+
expiresAt: now + (payload.expire ?? 7200) * 1000,
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
return this.token.value;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private async uploadImage(filePath: string): Promise<string> {
|
|
457
|
+
try {
|
|
458
|
+
const contents = await readFile(filePath);
|
|
459
|
+
return await this.uploadImageBytes(contents, "message", path.basename(filePath));
|
|
460
|
+
} catch (error) {
|
|
461
|
+
throw normalizeTransportError(error, "upload image");
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private async uploadImageBytes(
|
|
466
|
+
contents: Uint8Array,
|
|
467
|
+
imageType: "message" | "avatar",
|
|
468
|
+
fileName: string,
|
|
469
|
+
): Promise<string> {
|
|
470
|
+
if (contents.byteLength > 10 * 1024 * 1024) {
|
|
471
|
+
throw new Error(`Image exceeds Feishu's 10 MiB limit: ${fileName}`);
|
|
472
|
+
}
|
|
473
|
+
const token = await this.getTenantAccessToken();
|
|
474
|
+
const form = new FormData();
|
|
475
|
+
form.append("image_type", imageType);
|
|
476
|
+
const bytes = new ArrayBuffer(contents.byteLength);
|
|
477
|
+
new Uint8Array(bytes).set(contents);
|
|
478
|
+
form.append("image", new Blob([bytes]), fileName);
|
|
479
|
+
const response = await fetch("https://open.feishu.cn/open-apis/im/v1/images", {
|
|
480
|
+
method: "POST",
|
|
481
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
482
|
+
body: form,
|
|
483
|
+
});
|
|
484
|
+
const payload = (await response.json()) as UploadImageResponse;
|
|
485
|
+
const imageKey = payload.data?.image_key;
|
|
486
|
+
if (!response.ok || payload.code !== 0 || !imageKey) {
|
|
487
|
+
throw new FeishuApiError(payload.msg || response.statusText, payload.code, payload, "upload image", response.status);
|
|
488
|
+
}
|
|
489
|
+
return imageKey;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private async downloadImageNow(messageId: string, imageKey: string): Promise<string> {
|
|
493
|
+
try {
|
|
494
|
+
const token = await this.getTenantAccessToken();
|
|
495
|
+
const response = await fetch(
|
|
496
|
+
`https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/resources/${encodeURIComponent(imageKey)}?type=image`,
|
|
497
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
498
|
+
);
|
|
499
|
+
if (!response.ok) {
|
|
500
|
+
const payload = await response.json().catch(() => ({
|
|
501
|
+
code: response.status,
|
|
502
|
+
msg: response.statusText,
|
|
503
|
+
})) as DownloadImageErrorResponse;
|
|
504
|
+
throw new FeishuApiError(
|
|
505
|
+
payload.msg || response.statusText,
|
|
506
|
+
payload.code,
|
|
507
|
+
payload,
|
|
508
|
+
"download image",
|
|
509
|
+
response.status,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
514
|
+
if (!contentType.startsWith("image/")) {
|
|
515
|
+
throw new Error(`Feishu returned an unexpected image content type: ${contentType || "unknown"}`);
|
|
516
|
+
}
|
|
517
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
518
|
+
if (bytes.byteLength === 0) throw new Error("Feishu returned an empty image.");
|
|
519
|
+
if (bytes.byteLength > 20 * 1024 * 1024) throw new Error("Feishu image exceeds the 20 MiB input limit.");
|
|
520
|
+
|
|
521
|
+
const sqlitePath = this.config.storage?.sqlitePath ?? defaultSqlitePath();
|
|
522
|
+
const directory = path.join(path.dirname(sqlitePath), "inbound-images");
|
|
523
|
+
await mkdir(directory, { recursive: true });
|
|
524
|
+
const digest = createHash("sha256").update(`${messageId}:${imageKey}`).digest("hex");
|
|
525
|
+
const filePath = path.join(directory, `${digest}${imageExtension(contentType)}`);
|
|
526
|
+
await writeFile(filePath, bytes);
|
|
527
|
+
return filePath;
|
|
528
|
+
} catch (error) {
|
|
529
|
+
throw normalizeTransportError(error, "download image");
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private async uploadImageCached(filePath: string): Promise<string> {
|
|
534
|
+
const cacheKey = path.resolve(filePath);
|
|
535
|
+
const mtimeMs = (await stat(cacheKey)).mtimeMs;
|
|
536
|
+
const cached = this.imageUploads.get(cacheKey);
|
|
537
|
+
if (cached?.mtimeMs === mtimeMs) return cached.upload;
|
|
538
|
+
const upload = this.uploadImage(cacheKey).catch((error: unknown) => {
|
|
539
|
+
if (this.imageUploads.get(cacheKey)?.upload === upload) this.imageUploads.delete(cacheKey);
|
|
540
|
+
throw error;
|
|
541
|
+
});
|
|
542
|
+
this.imageUploads.set(cacheKey, { mtimeMs, upload });
|
|
543
|
+
return upload;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private async prepareInteractiveCard(card: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
547
|
+
return await this.prepareCardValue(card) as Record<string, unknown>;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private async prepareCardValue(value: unknown): Promise<unknown> {
|
|
551
|
+
if (Array.isArray(value)) return Promise.all(value.map((item) => this.prepareCardValue(item)));
|
|
552
|
+
if (!isRecord(value)) return value;
|
|
553
|
+
const localImagePath = value[LOCAL_CARD_IMAGE_PATH];
|
|
554
|
+
if (value.tag === "img" && typeof localImagePath === "string") {
|
|
555
|
+
const image = { ...value };
|
|
556
|
+
delete image[LOCAL_CARD_IMAGE_PATH];
|
|
557
|
+
try {
|
|
558
|
+
image.img_key = await this.uploadImageCached(localImagePath);
|
|
559
|
+
return image;
|
|
560
|
+
} catch (error) {
|
|
561
|
+
this.logger.warn({ error, filePath: localImagePath }, "Failed to upload local card image to Feishu.");
|
|
562
|
+
return { tag: "markdown", content: `图片加载失败:${cardImageLabel(value)}` };
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const prepared: Record<string, unknown> = {};
|
|
566
|
+
await Promise.all(Object.entries(value).map(async ([key, child]) => {
|
|
567
|
+
prepared[key] = await this.prepareCardValue(child);
|
|
568
|
+
}));
|
|
569
|
+
return prepared;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export class FeishuApiError extends Error {
|
|
574
|
+
readonly isRateLimit: boolean;
|
|
575
|
+
readonly isRetryable: boolean;
|
|
576
|
+
|
|
577
|
+
constructor(
|
|
578
|
+
message: string,
|
|
579
|
+
readonly code: number,
|
|
580
|
+
readonly payload:
|
|
581
|
+
| SendMessageResponse
|
|
582
|
+
| CreateGroupResponse
|
|
583
|
+
| UploadImageResponse
|
|
584
|
+
| ReactionResponse
|
|
585
|
+
| DownloadImageErrorResponse,
|
|
586
|
+
operation = "send",
|
|
587
|
+
readonly httpStatus?: number,
|
|
588
|
+
forceRetryable = false,
|
|
589
|
+
) {
|
|
590
|
+
super(`Failed to ${operation} Feishu message: ${message}`);
|
|
591
|
+
this.name = "FeishuApiError";
|
|
592
|
+
this.isRateLimit = code === 230020 || message.toLowerCase().includes("frequency limit");
|
|
593
|
+
this.isRetryable = forceRetryable || this.isRateLimit || (httpStatus !== undefined && httpStatus >= 500);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function imageExtension(contentType: string): string {
|
|
598
|
+
switch (contentType) {
|
|
599
|
+
case "image/jpeg": return ".jpg";
|
|
600
|
+
case "image/png": return ".png";
|
|
601
|
+
case "image/gif": return ".gif";
|
|
602
|
+
case "image/webp": return ".webp";
|
|
603
|
+
case "image/bmp": return ".bmp";
|
|
604
|
+
case "image/tiff": return ".tiff";
|
|
605
|
+
default: return ".img";
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function normalizeTransportError(error: unknown, operation: string): FeishuApiError {
|
|
610
|
+
if (error instanceof FeishuApiError) return error;
|
|
611
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
612
|
+
return new FeishuApiError(message, 0, { code: 0, msg: message, error }, operation, undefined, true);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function delay(ms: number): Promise<void> {
|
|
616
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function parseContextKey(contextKey: string): { receiveIdType: string; receiveId: string } {
|
|
620
|
+
const [receiveIdType, receiveId] = contextKey.split(":", 2);
|
|
621
|
+
if (!receiveIdType || !receiveId) {
|
|
622
|
+
throw new Error(`Invalid contextKey for Feishu receive_id: ${contextKey}`);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return { receiveIdType, receiveId };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function finalAnswerCard(elements: Array<Record<string, unknown>>): Record<string, unknown> {
|
|
629
|
+
return {
|
|
630
|
+
schema: "2.0",
|
|
631
|
+
config: {
|
|
632
|
+
update_multi: true,
|
|
633
|
+
width_mode: "fill",
|
|
634
|
+
},
|
|
635
|
+
body: {
|
|
636
|
+
elements,
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
642
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function cardImageLabel(image: Record<string, unknown>): string {
|
|
646
|
+
const alt = image.alt;
|
|
647
|
+
if (isRecord(alt) && typeof alt.content === "string" && alt.content.trim()) return alt.content;
|
|
648
|
+
return "view_image 图片";
|
|
649
|
+
}
|