im-agent 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +100 -2
- package/bin/cli.js +52 -0
- package/package.json +64 -4
- package/src/agents/claude/driver.ts +149 -0
- package/src/agents/claude/errors.ts +129 -0
- package/src/agents/claude/permissions.ts +227 -0
- package/src/agents/claude/session.ts +430 -0
- package/src/agents/codex/account.ts +92 -0
- package/src/agents/codex/app-server.ts +346 -0
- package/src/agents/codex/driver.ts +183 -0
- package/src/agents/codex/errors.ts +158 -0
- package/src/agents/codex/protocol.ts +284 -0
- package/src/agents/codex/refusals.ts +62 -0
- package/src/agents/codex/session.ts +738 -0
- package/src/channels/feishu/bot.ts +609 -0
- package/src/channels/feishu/cards.ts +309 -0
- package/src/channels/feishu/client.ts +185 -0
- package/src/channels/feishu/envelope.ts +112 -0
- package/src/channels/feishu/index.ts +52 -0
- package/src/channels/feishu/message.ts +115 -0
- package/src/channels/feishu/register.ts +171 -0
- package/src/channels/feishu/turn-stream.ts +98 -0
- package/src/channels/web/api.ts +505 -0
- package/src/channels/web/auth.ts +204 -0
- package/src/channels/web/http.ts +118 -0
- package/src/channels/web/server.ts +363 -0
- package/src/cli/doctor.ts +39 -0
- package/src/cli/import.ts +155 -0
- package/src/cli/pair.ts +35 -0
- package/src/cli/passwd.ts +15 -0
- package/src/cli/service.ts +117 -0
- package/src/core/agent-env.ts +11 -0
- package/src/core/commands.ts +99 -0
- package/src/core/config.ts +227 -0
- package/src/core/dedup.ts +16 -0
- package/src/core/hub.ts +933 -0
- package/src/core/interactions.ts +261 -0
- package/src/core/pairing.ts +156 -0
- package/src/core/store.ts +174 -0
- package/src/core/types.ts +159 -0
- package/src/core/uploads.ts +200 -0
- package/src/home.ts +36 -0
- package/src/main.ts +68 -0
- package/web/app.js +1939 -0
- package/web/icon.svg +6 -0
- package/web/index.html +23 -0
- package/web/manifest.webmanifest +13 -0
- package/web/style.css +1267 -0
- package/web/sw.js +5 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// 卡片构造,全部使用 JSON 2.0。
|
|
2
|
+
//
|
|
3
|
+
// 2.0 与 1.0 的三处硬差异(踩过坑,别改回去):
|
|
4
|
+
// 1. 没有 `tag: "action"` 容器,按钮直接作为 element;横排要用 column_set 包 column
|
|
5
|
+
// 2. 回调数据不在按钮的 value 上,而是 behaviors: [{ type: "callback", value }]
|
|
6
|
+
// 3. 没有 `tag: "note"`,小字提示只能并进 markdown
|
|
7
|
+
import { createEnvelope, type CardCtx, type EnvelopeAction } from "./envelope.js";
|
|
8
|
+
import { answerTextOf, SETTLEMENT_LABEL, type Interaction } from "../../core/interactions.js";
|
|
9
|
+
import type { ModelInfo, SessionSummary } from "../../core/types.js";
|
|
10
|
+
|
|
11
|
+
function button(
|
|
12
|
+
label: string,
|
|
13
|
+
action: EnvelopeAction,
|
|
14
|
+
ctx: CardCtx,
|
|
15
|
+
type: "default" | "primary" | "danger" = "default",
|
|
16
|
+
) {
|
|
17
|
+
return {
|
|
18
|
+
tag: "button",
|
|
19
|
+
text: { tag: "plain_text", content: label },
|
|
20
|
+
type,
|
|
21
|
+
behaviors: [{ type: "callback", value: createEnvelope(action, ctx) }],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buttonRow(buttons: ReturnType<typeof button>[]) {
|
|
26
|
+
return {
|
|
27
|
+
tag: "column_set",
|
|
28
|
+
flex_mode: "flow",
|
|
29
|
+
horizontal_spacing: "8px",
|
|
30
|
+
columns: buttons.map((b) => ({ tag: "column", width: "auto", elements: [b] })),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 截断过长的文本,避免卡片超限;保留头尾更利于判断。 */
|
|
35
|
+
export function clip(text: string, max = 1500): string {
|
|
36
|
+
if (text.length <= max) return text;
|
|
37
|
+
const head = text.slice(0, Math.floor(max * 0.7));
|
|
38
|
+
const tail = text.slice(-Math.floor(max * 0.2));
|
|
39
|
+
return `${head}\n\n… (省略 ${text.length - head.length - tail.length} 字) …\n\n${tail}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// update_multi 必须显式为 true:更新卡片接口要求前后 config 都声明它
|
|
43
|
+
const baseConfig = { width_mode: "fill", update_multi: true };
|
|
44
|
+
|
|
45
|
+
function card(elements: unknown[], header?: { title: string; template: string }) {
|
|
46
|
+
return {
|
|
47
|
+
schema: "2.0",
|
|
48
|
+
config: baseConfig,
|
|
49
|
+
...(header ? { header: { title: { tag: "plain_text", content: header.title }, template: header.template } } : {}),
|
|
50
|
+
body: { elements },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------- 交互 ----------
|
|
55
|
+
|
|
56
|
+
/** 审批卡:三档决策。请求 id 印在正文里,卡片失效时还能手打 /approve 兜底。 */
|
|
57
|
+
export function approvalCard(interaction: Interaction, ctx: CardCtx) {
|
|
58
|
+
const spec = interaction.spec;
|
|
59
|
+
if (spec.kind !== "approval") throw new Error("不是审批");
|
|
60
|
+
const id = interaction.id;
|
|
61
|
+
const buttons = [
|
|
62
|
+
button("允许一次", { a: "approval", r: id, d: "allow-once" }, ctx, "primary"),
|
|
63
|
+
...(spec.alwaysLabel ? [button(spec.alwaysLabel, { a: "approval", r: id, d: "allow-always" }, ctx)] : []),
|
|
64
|
+
button("拒绝", { a: "approval", r: id, d: "deny" }, ctx, "danger"),
|
|
65
|
+
];
|
|
66
|
+
return card(
|
|
67
|
+
[
|
|
68
|
+
{ tag: "markdown", content: clip(spec.detail) },
|
|
69
|
+
{ tag: "hr" },
|
|
70
|
+
buttonRow(buttons),
|
|
71
|
+
{ tag: "markdown", content: `请求 \`${id}\` 按钮失效时可发送:\`/approve ${id} allow-once\`` },
|
|
72
|
+
],
|
|
73
|
+
{ title: spec.title, template: "orange" },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 提问卡:有状态,每点一下就用最新的勾选重渲。全单选且都选完自动收束,多选要点「提交」。 */
|
|
78
|
+
export function questionCard(interaction: Interaction, ctx: CardCtx, agentLabel: string) {
|
|
79
|
+
const spec = interaction.spec;
|
|
80
|
+
if (spec.kind !== "question") throw new Error("不是提问");
|
|
81
|
+
const id = interaction.id;
|
|
82
|
+
const needsSubmit = spec.questions.some((q) => q.multiSelect);
|
|
83
|
+
const elements: unknown[] = [];
|
|
84
|
+
spec.questions.forEach((q, qi) => {
|
|
85
|
+
if (qi > 0) elements.push({ tag: "hr" });
|
|
86
|
+
const picked = interaction.selections[qi] ?? [];
|
|
87
|
+
elements.push({ tag: "markdown", content: `**${q.header}** ${q.question}${q.multiSelect ? "(可多选)" : ""}` });
|
|
88
|
+
if (q.options.length > 0) {
|
|
89
|
+
elements.push(
|
|
90
|
+
buttonRow(
|
|
91
|
+
q.options.map((opt, oi) => {
|
|
92
|
+
const on = picked.includes(oi);
|
|
93
|
+
const next: 0 | 1 = q.multiSelect && on ? 0 : 1;
|
|
94
|
+
return button(
|
|
95
|
+
`${on ? "✅ " : ""}${clip(opt.label, 30)}`,
|
|
96
|
+
{ a: "ask", r: id, q: qi, o: oi, v: next },
|
|
97
|
+
ctx,
|
|
98
|
+
on ? "primary" : "default",
|
|
99
|
+
);
|
|
100
|
+
}),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
const notes = q.options
|
|
104
|
+
.filter((opt) => opt.description)
|
|
105
|
+
.map((opt) => `**${opt.label}** — ${opt.description}`)
|
|
106
|
+
.join("\n");
|
|
107
|
+
if (notes) elements.push({ tag: "markdown", content: clip(notes, 800) });
|
|
108
|
+
} else {
|
|
109
|
+
elements.push({ tag: "markdown", content: "_这一问需要你直接回一句话。_" });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
elements.push({ tag: "hr" });
|
|
113
|
+
elements.push(
|
|
114
|
+
buttonRow([
|
|
115
|
+
...(needsSubmit ? [button("提交", { a: "ask-submit", r: id }, ctx, "primary")] : []),
|
|
116
|
+
button("跳过", { a: "ask-skip", r: id }, ctx),
|
|
117
|
+
]),
|
|
118
|
+
);
|
|
119
|
+
elements.push({ tag: "markdown", content: "选项都不合适?**直接发一条消息**就当作你的回答。" });
|
|
120
|
+
return card(elements, { title: spec.title || `${agentLabel} 想确认一下`, template: "violet" });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 收束后的卡:原地替换,避免重复点击和状态歧义。 */
|
|
124
|
+
export function settledCard(interaction: Interaction) {
|
|
125
|
+
const settled = interaction.settled;
|
|
126
|
+
const settlement = settled?.settlement ?? "cancelled";
|
|
127
|
+
const by = settled?.by ?? "";
|
|
128
|
+
if (interaction.spec.kind === "approval") {
|
|
129
|
+
const ok = settlement === "allow-once" || settlement === "allow-always";
|
|
130
|
+
const label =
|
|
131
|
+
settlement === "allow-always"
|
|
132
|
+
? `已允许(${interaction.spec.alwaysLabel ?? "后续同类自动放行"})`
|
|
133
|
+
: SETTLEMENT_LABEL[settlement];
|
|
134
|
+
return card([{ tag: "markdown", content: `请求 \`${interaction.id}\` ${by}` }], {
|
|
135
|
+
title: `${label}:${interaction.spec.title.replace(/^需要授权:/, "")}`,
|
|
136
|
+
template: ok ? "green" : "grey",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const answers = answerTextOf(interaction);
|
|
140
|
+
const answered = interaction.spec.questions.filter((q) => answers[q.question]);
|
|
141
|
+
const empty = answered.length === 0;
|
|
142
|
+
const lines = answered.map((q) => `**${q.header}** ${q.question}\n→ ${answers[q.question]}`);
|
|
143
|
+
return card(
|
|
144
|
+
[
|
|
145
|
+
{ tag: "markdown", content: clip(empty ? `${SETTLEMENT_LABEL[settlement]}。${by ? `(${by})` : ""}` : lines.join("\n\n"), 2000) },
|
|
146
|
+
...(!empty && by ? [{ tag: "markdown", content: by }] : []),
|
|
147
|
+
],
|
|
148
|
+
{ title: empty ? "未作答" : "已回答", template: empty ? "grey" : "green" },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------- 过程与回复 ----------
|
|
153
|
+
|
|
154
|
+
/** 过程卡:进行中展开,完成后折叠成一行摘要。 */
|
|
155
|
+
export function progressCard(params: { steps: string[]; done?: boolean; footer?: string }) {
|
|
156
|
+
const body = clip(params.steps.length > 0 ? params.steps.join("\n") : "…", 2000);
|
|
157
|
+
if (!params.done) return card([{ tag: "markdown", content: body }]);
|
|
158
|
+
const summary = `已完成 ${params.steps.length} 步${params.footer ? ` ${params.footer}` : ""}`;
|
|
159
|
+
return card([
|
|
160
|
+
{
|
|
161
|
+
tag: "collapsible_panel",
|
|
162
|
+
expanded: false,
|
|
163
|
+
header: {
|
|
164
|
+
title: { tag: "plain_text", content: summary },
|
|
165
|
+
vertical_align: "center",
|
|
166
|
+
icon: { tag: "standard_icon", token: "down-small-ccm_outlined", size: "16px 16px" },
|
|
167
|
+
},
|
|
168
|
+
padding: "8px 8px 8px 8px",
|
|
169
|
+
elements: [{ tag: "markdown", content: body }],
|
|
170
|
+
},
|
|
171
|
+
]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 回复卡:飞书纯文本不渲染 markdown,所以回复必须走卡片。刻意做到最简。 */
|
|
175
|
+
export function replyCard(text: string) {
|
|
176
|
+
return card([{ tag: "markdown", content: clip(text, 4000) }]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------- 命令结果 ----------
|
|
180
|
+
|
|
181
|
+
export function modelPickerCard(params: {
|
|
182
|
+
agentLabel: string;
|
|
183
|
+
models: ModelInfo[];
|
|
184
|
+
current?: string;
|
|
185
|
+
currentEffort?: string;
|
|
186
|
+
effortSupported: boolean;
|
|
187
|
+
sessionKey: string;
|
|
188
|
+
ctx: CardCtx;
|
|
189
|
+
}) {
|
|
190
|
+
const elements: unknown[] = [
|
|
191
|
+
{
|
|
192
|
+
tag: "markdown",
|
|
193
|
+
content: `${params.agentLabel} 当前:**${params.current ?? "默认模型"}**${
|
|
194
|
+
params.currentEffort ? ` 思考 **${params.currentEffort}**` : ""
|
|
195
|
+
}`,
|
|
196
|
+
},
|
|
197
|
+
{ tag: "hr" },
|
|
198
|
+
buttonRow(
|
|
199
|
+
params.models
|
|
200
|
+
.slice(0, 12)
|
|
201
|
+
.map((m) =>
|
|
202
|
+
button(
|
|
203
|
+
`${m.label}${m.id === params.current ? " ✅" : ""}`,
|
|
204
|
+
{ a: "model", m: m.id, k: params.sessionKey },
|
|
205
|
+
params.ctx,
|
|
206
|
+
),
|
|
207
|
+
),
|
|
208
|
+
),
|
|
209
|
+
];
|
|
210
|
+
const efforts =
|
|
211
|
+
params.models.find((m) => m.id === params.current)?.efforts ??
|
|
212
|
+
params.models.find((m) => m.efforts?.length)?.efforts ??
|
|
213
|
+
[];
|
|
214
|
+
if (params.effortSupported && efforts.length > 0) {
|
|
215
|
+
elements.push({ tag: "hr" }, { tag: "markdown", content: "**思考程度**" });
|
|
216
|
+
elements.push(
|
|
217
|
+
buttonRow(
|
|
218
|
+
efforts
|
|
219
|
+
.slice(0, 8)
|
|
220
|
+
.map((e) =>
|
|
221
|
+
button(`${e}${e === params.currentEffort ? " ✅" : ""}`, { a: "effort", f: e, k: params.sessionKey }, params.ctx),
|
|
222
|
+
),
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return card(elements, { title: "切换模型", template: "blue" });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function agentPickerCard(params: {
|
|
230
|
+
agents: { id: string; label: string }[];
|
|
231
|
+
current: string;
|
|
232
|
+
sessionKey: string;
|
|
233
|
+
ctx: CardCtx;
|
|
234
|
+
}) {
|
|
235
|
+
return card(
|
|
236
|
+
[
|
|
237
|
+
{ tag: "markdown", content: "每个 agent 的会话各自保留,切回来能接着聊。" },
|
|
238
|
+
buttonRow(
|
|
239
|
+
params.agents.map((a) =>
|
|
240
|
+
button(
|
|
241
|
+
`${a.label}${a.id === params.current ? " ✅" : ""}`,
|
|
242
|
+
{ a: "agent", g: a.id, k: params.sessionKey },
|
|
243
|
+
params.ctx,
|
|
244
|
+
a.id === params.current ? "primary" : "default",
|
|
245
|
+
),
|
|
246
|
+
),
|
|
247
|
+
),
|
|
248
|
+
],
|
|
249
|
+
{ title: "切换 Agent", template: "blue" },
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function sessionListCard(params: {
|
|
254
|
+
agentLabel: string;
|
|
255
|
+
sessions: SessionSummary[];
|
|
256
|
+
current?: string;
|
|
257
|
+
cwd: string;
|
|
258
|
+
allDirs: boolean;
|
|
259
|
+
fellBack: boolean;
|
|
260
|
+
sessionKey: string;
|
|
261
|
+
ctx: CardCtx;
|
|
262
|
+
}) {
|
|
263
|
+
const elements: unknown[] = [
|
|
264
|
+
{
|
|
265
|
+
tag: "markdown",
|
|
266
|
+
content: `${params.agentLabel} ${
|
|
267
|
+
params.allDirs
|
|
268
|
+
? params.fellBack
|
|
269
|
+
? `\`${params.cwd}\` 下没有历史会话,已列出**所有目录**的最近会话`
|
|
270
|
+
: "所有目录的最近会话"
|
|
271
|
+
: `目录 \`${params.cwd}\``
|
|
272
|
+
}`,
|
|
273
|
+
},
|
|
274
|
+
];
|
|
275
|
+
params.sessions.forEach((entry, index) => {
|
|
276
|
+
const isCurrent = entry.id === params.current;
|
|
277
|
+
elements.push({ tag: "hr" });
|
|
278
|
+
elements.push({
|
|
279
|
+
tag: "markdown",
|
|
280
|
+
content:
|
|
281
|
+
`**${index + 1}. ${entry.title}**${isCurrent ? " ✅ 当前" : ""}\n` +
|
|
282
|
+
(params.allDirs && entry.cwd ? `\`${entry.cwd}\`\n` : "") +
|
|
283
|
+
`\`${entry.id}\` ${relativeTime(entry.updatedAt)}`,
|
|
284
|
+
});
|
|
285
|
+
if (!isCurrent) {
|
|
286
|
+
elements.push(
|
|
287
|
+
buttonRow([
|
|
288
|
+
button(
|
|
289
|
+
"恢复这条",
|
|
290
|
+
{ a: "resume", s: entry.id, k: params.sessionKey, ...(entry.cwd ? { c: entry.cwd } : {}) },
|
|
291
|
+
params.ctx,
|
|
292
|
+
),
|
|
293
|
+
]),
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
return card(elements, { title: "历史会话", template: "blue" });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function relativeTime(ts: number, now = Date.now()): string {
|
|
301
|
+
const minutes = Math.floor((now - ts) / 60_000);
|
|
302
|
+
if (minutes < 1) return "刚刚";
|
|
303
|
+
if (minutes < 60) return `${minutes} 分钟前`;
|
|
304
|
+
const hours = Math.floor(minutes / 60);
|
|
305
|
+
if (hours < 24) return `${hours} 小时前`;
|
|
306
|
+
const days = Math.floor(hours / 24);
|
|
307
|
+
if (days < 30) return `${days} 天前`;
|
|
308
|
+
return new Date(ts).toLocaleDateString("zh-CN");
|
|
309
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// 飞书客户端封装:一个机器人一个实例(以前是全局单例,现在一个进程可以跑多个机器人)。
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
4
|
+
|
|
5
|
+
function errData(err: unknown): { code?: number; msg?: string } | undefined {
|
|
6
|
+
return (err as { response?: { data?: { code?: number; msg?: string } } })?.response?.data;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function rememberBounded<K, V>(map: Map<K, V>, key: K, value: V, max = 500): void {
|
|
10
|
+
map.set(key, value);
|
|
11
|
+
if (map.size > max) {
|
|
12
|
+
const oldest = map.keys().next().value;
|
|
13
|
+
if (oldest !== undefined) map.delete(oldest);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class FeishuClient {
|
|
18
|
+
readonly lark: Lark.Client;
|
|
19
|
+
/** message_id → card_id;cardkit 的全量更新要求 sequence 严格递增,每张卡自己记着 */
|
|
20
|
+
private cardIdByMessage = new Map<string, string>();
|
|
21
|
+
private sequenceByCard = new Map<string, number>();
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
private readonly appId: string,
|
|
25
|
+
private readonly appSecret: string,
|
|
26
|
+
domain: "feishu" | "lark",
|
|
27
|
+
private readonly tag: string,
|
|
28
|
+
) {
|
|
29
|
+
this.lark = new Lark.Client({
|
|
30
|
+
appId,
|
|
31
|
+
appSecret,
|
|
32
|
+
domain: domain === "lark" ? Lark.Domain.Lark : Lark.Domain.Feishu,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async sendText(chatId: string, text: string): Promise<string | undefined> {
|
|
37
|
+
try {
|
|
38
|
+
const res = await this.lark.im.v1.message.create({
|
|
39
|
+
params: { receive_id_type: "chat_id" },
|
|
40
|
+
data: { receive_id: chatId, msg_type: "text", content: JSON.stringify({ text }) },
|
|
41
|
+
});
|
|
42
|
+
return res.data?.message_id;
|
|
43
|
+
} catch (err) {
|
|
44
|
+
const data = errData(err);
|
|
45
|
+
console.error(`[${this.tag}] 发消息失败:`, data ? `code=${data.code} msg=${data.msg}` : err);
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async sendCard(chatId: string, card: unknown): Promise<string | undefined> {
|
|
51
|
+
try {
|
|
52
|
+
const res = await this.lark.im.v1.message.create({
|
|
53
|
+
params: { receive_id_type: "chat_id" },
|
|
54
|
+
data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) },
|
|
55
|
+
});
|
|
56
|
+
const messageId = res.data?.message_id;
|
|
57
|
+
if (!messageId) console.warn(`[${this.tag}] 发卡成功但没拿到 message_id,该卡片将无法更新`);
|
|
58
|
+
return messageId;
|
|
59
|
+
} catch (err) {
|
|
60
|
+
const data = errData(err);
|
|
61
|
+
console.error(`[${this.tag}] 发卡片失败:`, data ? `code=${data.code} msg=${data.msg}` : err);
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async resolveCardId(messageId: string): Promise<string | undefined> {
|
|
67
|
+
const cached = this.cardIdByMessage.get(messageId);
|
|
68
|
+
if (cached) return cached;
|
|
69
|
+
try {
|
|
70
|
+
const res = await this.lark.cardkit.v1.card.idConvert({ data: { message_id: messageId } });
|
|
71
|
+
const cardId = res.data?.card_id;
|
|
72
|
+
if (cardId) rememberBounded(this.cardIdByMessage, messageId, cardId);
|
|
73
|
+
return cardId;
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const data = errData(err);
|
|
76
|
+
console.warn(`[${this.tag}] message_id ${messageId} 转 card_id 失败:`, data ? `code=${data.code}` : String(err));
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 原地更新卡片。走 cardkit 的全量更新:它是 JSON 2.0 卡片的正规通道。
|
|
83
|
+
* 老的 im.v1.message.patch 对 2.0 卡片不可靠(带交互组件的更新不动),只作回退。
|
|
84
|
+
*/
|
|
85
|
+
async updateCard(messageId: string, card: unknown): Promise<boolean> {
|
|
86
|
+
const cardId = await this.resolveCardId(messageId);
|
|
87
|
+
if (cardId) {
|
|
88
|
+
const sequence = (this.sequenceByCard.get(cardId) ?? Math.floor(Date.now() / 1000)) + 1;
|
|
89
|
+
rememberBounded(this.sequenceByCard, cardId, sequence);
|
|
90
|
+
try {
|
|
91
|
+
const res = await this.lark.cardkit.v1.card.update({
|
|
92
|
+
path: { card_id: cardId },
|
|
93
|
+
data: { card: { type: "card_json", data: JSON.stringify(card) }, sequence },
|
|
94
|
+
});
|
|
95
|
+
if (res.code === 0) return true;
|
|
96
|
+
console.warn(`[${this.tag}] cardkit 更新 ${cardId} 被拒绝: code=${res.code} msg=${res.msg}`);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
const data = errData(err);
|
|
99
|
+
console.warn(`[${this.tag}] cardkit 更新 ${cardId} 失败:`, data ? `code=${data.code} msg=${data.msg}` : String(err));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const res = await this.lark.im.v1.message.patch({
|
|
104
|
+
path: { message_id: messageId },
|
|
105
|
+
data: { content: JSON.stringify(card) },
|
|
106
|
+
});
|
|
107
|
+
return res.code === 0;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
const data = errData(err);
|
|
110
|
+
console.warn(`[${this.tag}] 更新卡片 ${messageId} 失败:`, data ? `code=${data.code} msg=${data.msg}` : String(err));
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 飞书没有给机器人开放 typing 指示器,表情回复是最接近的替代。 */
|
|
116
|
+
async addTypingReaction(messageId: string): Promise<string | undefined> {
|
|
117
|
+
try {
|
|
118
|
+
const res = await this.lark.im.v1.messageReaction.create({
|
|
119
|
+
path: { message_id: messageId },
|
|
120
|
+
data: { reaction_type: { emoji_type: "Typing" } },
|
|
121
|
+
});
|
|
122
|
+
return res.data?.reaction_id;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.warn(`[${this.tag}] 加表情失败:`, err instanceof Error ? err.message : err);
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async removeReaction(messageId: string, reactionId: string): Promise<void> {
|
|
130
|
+
try {
|
|
131
|
+
await this.lark.im.v1.messageReaction.delete({
|
|
132
|
+
path: { message_id: messageId, reaction_id: reactionId },
|
|
133
|
+
});
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.warn(`[${this.tag}] 删表情失败:`, err instanceof Error ? err.message : err);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 下载消息里的资源到 target。失败时把飞书的错误码翻成人话抛出来。 */
|
|
140
|
+
async downloadResource(messageId: string, fileKey: string, type: "image" | "file", target: string): Promise<void> {
|
|
141
|
+
try {
|
|
142
|
+
const res = await this.lark.im.v1.messageResource.get({
|
|
143
|
+
path: { message_id: messageId, file_key: fileKey },
|
|
144
|
+
params: { type },
|
|
145
|
+
});
|
|
146
|
+
await res.writeFile(target);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
fs.rmSync(target, { force: true });
|
|
149
|
+
const data = await readErrorBody(err);
|
|
150
|
+
if (data?.code === 99991672 || data?.code === 99991679) {
|
|
151
|
+
throw new Error("飞书应用缺少「获取与上传图片或文件资源」(im:resource) 权限");
|
|
152
|
+
}
|
|
153
|
+
if (data) throw new Error(`飞书返回 code=${data.code} ${(data.msg ?? "").slice(0, 120)}`.trim());
|
|
154
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 测试连通性:拿一次 tenant token 就知道 appId / secret 对不对。 */
|
|
159
|
+
async probe(): Promise<{ ok: boolean; message: string }> {
|
|
160
|
+
try {
|
|
161
|
+
const res = await this.lark.auth.v3.tenantAccessToken.internal({
|
|
162
|
+
data: { app_id: this.appId, app_secret: this.appSecret },
|
|
163
|
+
});
|
|
164
|
+
return res.code === 0 ? { ok: true, message: "凭据有效" } : { ok: false, message: `code=${res.code} ${res.msg}` };
|
|
165
|
+
} catch (err) {
|
|
166
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 下载接口按二进制流请求,出错时响应体也是一个流,得先读出来再解析。 */
|
|
172
|
+
async function readErrorBody(err: unknown): Promise<{ code?: number; msg?: string } | undefined> {
|
|
173
|
+
const raw = (err as { response?: { data?: unknown } })?.response?.data;
|
|
174
|
+
if (!raw) return undefined;
|
|
175
|
+
if (typeof (raw as AsyncIterable<unknown>)[Symbol.asyncIterator] !== "function") {
|
|
176
|
+
return raw as { code?: number; msg?: string };
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
let body = "";
|
|
180
|
+
for await (const chunk of raw as AsyncIterable<Buffer | string>) body += chunk.toString();
|
|
181
|
+
return JSON.parse(body);
|
|
182
|
+
} catch {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// 卡片按钮里携带的交互信封。
|
|
2
|
+
//
|
|
3
|
+
// 信任模型:信封本身是明文、不签名,它不是凭证,只是「这张卡片期望由谁、在哪、
|
|
4
|
+
// 在什么期限前点」的声明。真正的信任锚在服务端:
|
|
5
|
+
// 1. 飞书回调里的 operator.open_id 由平台签发,客户端伪造不了;
|
|
6
|
+
// 2. 请求 id 必须命中服务端的待处理表,且点击者要通过授权判断。
|
|
7
|
+
// 所以信封挡的是「同群别人替你点」「过期后补点」「卡片被转发到别的会话点」。
|
|
8
|
+
//
|
|
9
|
+
// ask 的 v 是「点完之后该是选中(1)还是未选中(0)」,而不是「切换」:飞书会重投回调,
|
|
10
|
+
// 切换语义在重投下不幂等。
|
|
11
|
+
import type { ApprovalDecision } from "../../core/types.js";
|
|
12
|
+
|
|
13
|
+
export const ENVELOPE_VERSION = "aim2";
|
|
14
|
+
|
|
15
|
+
/** k 是会话键:卡片回调里拿不到 thread_id,凡是作用于某条会话的动作都得带上它。 */
|
|
16
|
+
export type EnvelopeAction =
|
|
17
|
+
| { a: "approval"; r: string; d: ApprovalDecision }
|
|
18
|
+
| { a: "ask"; r: string; q: number; o: number; v: 0 | 1 }
|
|
19
|
+
| { a: "ask-submit"; r: string }
|
|
20
|
+
| { a: "ask-skip"; r: string }
|
|
21
|
+
| { a: "model"; m: string; k: string }
|
|
22
|
+
| { a: "effort"; f: string; k: string }
|
|
23
|
+
| { a: "agent"; g: string; k: string }
|
|
24
|
+
| { a: "resume"; s: string; k: string; c?: string };
|
|
25
|
+
|
|
26
|
+
export type CardEnvelope = EnvelopeAction & {
|
|
27
|
+
oc: typeof ENVELOPE_VERSION;
|
|
28
|
+
/** 期望的点击人 open_id;不带表示任何授权用户都能点(比如 Web 上发起的那一轮) */
|
|
29
|
+
u?: string;
|
|
30
|
+
/** 期望的会话 chat_id */
|
|
31
|
+
h?: string;
|
|
32
|
+
/** 过期时间戳(ms) */
|
|
33
|
+
e: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type CardCtx = { operatorOpenId?: string; chatId?: string; expiresAt: number };
|
|
37
|
+
|
|
38
|
+
export type DecodeFailure = "malformed" | "stale" | "wrong_user" | "wrong_conversation" | "not_allowed";
|
|
39
|
+
|
|
40
|
+
export function createEnvelope(action: EnvelopeAction, ctx: CardCtx): CardEnvelope {
|
|
41
|
+
return {
|
|
42
|
+
oc: ENVELOPE_VERSION,
|
|
43
|
+
...action,
|
|
44
|
+
...(ctx.operatorOpenId ? { u: ctx.operatorOpenId } : {}),
|
|
45
|
+
...(ctx.chatId ? { h: ctx.chatId } : {}),
|
|
46
|
+
e: ctx.expiresAt,
|
|
47
|
+
} as CardEnvelope;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
51
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function decodeEnvelope(params: {
|
|
55
|
+
value: unknown;
|
|
56
|
+
operatorOpenId: string | undefined;
|
|
57
|
+
chatId: string | undefined;
|
|
58
|
+
isAllowed: (openId: string | undefined) => boolean;
|
|
59
|
+
now?: number;
|
|
60
|
+
}): { ok: true; envelope: CardEnvelope } | { ok: false; reason: DecodeFailure } {
|
|
61
|
+
const { value, operatorOpenId, chatId, isAllowed, now = Date.now() } = params;
|
|
62
|
+
if (!isRecord(value) || value.oc !== ENVELOPE_VERSION || typeof value.a !== "string") {
|
|
63
|
+
return { ok: false, reason: "malformed" };
|
|
64
|
+
}
|
|
65
|
+
if (typeof value.e !== "number" || !Number.isFinite(value.e)) return { ok: false, reason: "malformed" };
|
|
66
|
+
if (value.e < now) return { ok: false, reason: "stale" };
|
|
67
|
+
const operator = operatorOpenId?.trim().toLowerCase();
|
|
68
|
+
if (typeof value.u === "string" && value.u.trim().toLowerCase() !== operator) {
|
|
69
|
+
return { ok: false, reason: "wrong_user" };
|
|
70
|
+
}
|
|
71
|
+
if (typeof value.h === "string" && value.h.trim() !== (chatId ?? "").trim()) {
|
|
72
|
+
return { ok: false, reason: "wrong_conversation" };
|
|
73
|
+
}
|
|
74
|
+
if (!validAction(value)) return { ok: false, reason: "malformed" };
|
|
75
|
+
// 白名单是最后也是最硬的一道
|
|
76
|
+
if (!isAllowed(operatorOpenId)) return { ok: false, reason: "not_allowed" };
|
|
77
|
+
return { ok: true, envelope: value as CardEnvelope };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const str = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length < 512;
|
|
81
|
+
const idx = (v: unknown): v is number => typeof v === "number" && Number.isInteger(v) && v >= 0 && v < 100;
|
|
82
|
+
|
|
83
|
+
/** 每种动作的字段逐个验类型:信封是明文,别让一个畸形的值流进 agent。 */
|
|
84
|
+
function validAction(v: Record<string, unknown>): boolean {
|
|
85
|
+
switch (v.a) {
|
|
86
|
+
case "approval":
|
|
87
|
+
return str(v.r) && (v.d === "allow-once" || v.d === "allow-always" || v.d === "deny");
|
|
88
|
+
case "ask":
|
|
89
|
+
return str(v.r) && idx(v.q) && idx(v.o) && (v.v === 0 || v.v === 1);
|
|
90
|
+
case "ask-submit":
|
|
91
|
+
case "ask-skip":
|
|
92
|
+
return str(v.r);
|
|
93
|
+
case "model":
|
|
94
|
+
return str(v.m) && str(v.k);
|
|
95
|
+
case "effort":
|
|
96
|
+
return str(v.f) && str(v.k);
|
|
97
|
+
case "agent":
|
|
98
|
+
return str(v.g) && str(v.k);
|
|
99
|
+
case "resume":
|
|
100
|
+
return str(v.s) && str(v.k) && (v.c === undefined || str(v.c));
|
|
101
|
+
default:
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const DECODE_FAILURE_TEXT: Record<DecodeFailure, string> = {
|
|
107
|
+
malformed: "这个按钮的数据已损坏(或来自旧版本的卡片),请重新发起操作。",
|
|
108
|
+
stale: "这张卡片已过期,请重新发起操作。",
|
|
109
|
+
wrong_user: "这张卡片不是发给你的,只有发起人本人可以操作。",
|
|
110
|
+
wrong_conversation: "这张卡片属于另一个会话,不能在这里操作。",
|
|
111
|
+
not_allowed: "你不在这台机器的授权名单里。",
|
|
112
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// 飞书渠道:按配置拉起 / 停掉机器人。Web 上改了机器人配置,这里就地对账,不用重启进程。
|
|
2
|
+
import { config, onConfigChange, type FeishuBotConfig } from "../../core/config.js";
|
|
3
|
+
import { watchPairing } from "../../core/pairing.js";
|
|
4
|
+
import { FeishuBot } from "./bot.js";
|
|
5
|
+
|
|
6
|
+
const bots = new Map<string, FeishuBot>();
|
|
7
|
+
|
|
8
|
+
function fingerprint(cfg: FeishuBotConfig): string {
|
|
9
|
+
// allowFrom / agents 变了不必重连,直接换配置对象即可;凭据变了才要重建连接
|
|
10
|
+
return JSON.stringify([cfg.appId, cfg.appSecret, cfg.domain, cfg.enabled]);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function reconcile(list: FeishuBotConfig[]): void {
|
|
14
|
+
const wanted = new Map(list.filter((b) => b.enabled && b.appId && b.appSecret).map((b) => [b.id, b]));
|
|
15
|
+
for (const [id, bot] of bots) {
|
|
16
|
+
const next = wanted.get(id);
|
|
17
|
+
if (!next || fingerprint(next) !== fingerprint(bot.cfg)) {
|
|
18
|
+
bot.stop();
|
|
19
|
+
bots.delete(id);
|
|
20
|
+
console.log(`[飞书] 已停止机器人 ${bot.cfg.name}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
for (const [id, cfg] of wanted) {
|
|
24
|
+
const existing = bots.get(id);
|
|
25
|
+
if (existing) {
|
|
26
|
+
Object.assign(existing.cfg, cfg);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const bot = new FeishuBot({ ...cfg });
|
|
30
|
+
bots.set(id, bot);
|
|
31
|
+
bot.start();
|
|
32
|
+
console.log(`[飞书] 已启动机器人 ${cfg.name}(agent:${cfg.agents.join("、")})`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function startFeishu(): void {
|
|
37
|
+
reconcile(config().feishu.bots);
|
|
38
|
+
onConfigChange((next) => reconcile(next.feishu.bots));
|
|
39
|
+
// 配对被批准(Web 上点的,或终端里另一个进程批的):通知等着的人
|
|
40
|
+
watchPairing((channelId, _userId, chatId) => {
|
|
41
|
+
if (chatId) bots.get(channelId)?.notifyApproved(chatId);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function stopFeishu(): void {
|
|
46
|
+
for (const bot of bots.values()) bot.stop();
|
|
47
|
+
bots.clear();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function feishuBots(): FeishuBot[] {
|
|
51
|
+
return [...bots.values()];
|
|
52
|
+
}
|