agent-loft 0.0.0-stage → 0.2.7
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 +110 -2
- package/bin/cli.js +52 -0
- package/package.json +64 -4
- package/src/agents/claude/account.ts +122 -0
- package/src/agents/claude/driver.ts +216 -0
- package/src/agents/claude/errors.ts +129 -0
- package/src/agents/claude/permissions.ts +260 -0
- package/src/agents/claude/probe.ts +54 -0
- package/src/agents/claude/session.ts +487 -0
- package/src/agents/codex/account.ts +122 -0
- package/src/agents/codex/app-server.ts +346 -0
- package/src/agents/codex/driver.ts +210 -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 +773 -0
- package/src/channels/feishu/bot.ts +669 -0
- package/src/channels/feishu/cards.ts +360 -0
- package/src/channels/feishu/client.ts +185 -0
- package/src/channels/feishu/envelope.ts +117 -0
- package/src/channels/feishu/index.ts +62 -0
- package/src/channels/feishu/message.ts +115 -0
- package/src/channels/feishu/register.ts +171 -0
- package/src/channels/feishu/slash-sync.ts +192 -0
- package/src/channels/feishu/turn-stream.ts +98 -0
- package/src/channels/web/api.ts +645 -0
- package/src/channels/web/auth.ts +204 -0
- package/src/channels/web/http.ts +118 -0
- package/src/channels/web/push.ts +218 -0
- package/src/channels/web/server.ts +446 -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 +137 -0
- package/src/core/agent-env.ts +11 -0
- package/src/core/command-catalog.ts +112 -0
- package/src/core/commands.ts +104 -0
- package/src/core/config.ts +285 -0
- package/src/core/dedup.ts +16 -0
- package/src/core/footers.ts +88 -0
- package/src/core/hub.ts +1152 -0
- package/src/core/interactions.ts +273 -0
- package/src/core/pairing.ts +156 -0
- package/src/core/store.ts +178 -0
- package/src/core/types.ts +213 -0
- package/src/core/uploads.ts +200 -0
- package/src/home.ts +43 -0
- package/src/main.ts +68 -0
- package/web/app.js +3731 -0
- package/web/apple-touch-icon.png +0 -0
- package/web/icon-maskable.svg +13 -0
- package/web/icon.svg +12 -0
- package/web/index.html +23 -0
- package/web/manifest.webmanifest +15 -0
- package/web/style.css +2604 -0
- package/web/sw.js +76 -0
|
@@ -0,0 +1,62 @@
|
|
|
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, prev) => {
|
|
39
|
+
reconcile(next.feishu.bots);
|
|
40
|
+
// 命令顺序 / 隐藏改了,或者机器人能用的 agent 变了:重新同步飞书指令面板
|
|
41
|
+
const commandsChanged = JSON.stringify(next.commands) !== JSON.stringify(prev.commands);
|
|
42
|
+
for (const bot of bots.values()) {
|
|
43
|
+
const before = prev.feishu.bots.find((b) => b.id === bot.id);
|
|
44
|
+
const agentsChanged =
|
|
45
|
+
!before || JSON.stringify([before.agents, before.defaultAgent]) !== JSON.stringify([bot.cfg.agents, bot.cfg.defaultAgent]);
|
|
46
|
+
if (commandsChanged || agentsChanged) bot.slash.request();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
// 配对被批准(Web 上点的,或终端里另一个进程批的):通知等着的人
|
|
50
|
+
watchPairing((channelId, _userId, chatId) => {
|
|
51
|
+
if (chatId) bots.get(channelId)?.notifyApproved(chatId);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function stopFeishu(): void {
|
|
56
|
+
for (const bot of bots.values()) bot.stop();
|
|
57
|
+
bots.clear();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function feishuBots(): FeishuBot[] {
|
|
61
|
+
return [...bots.values()];
|
|
62
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// 飞书消息解析:文字 + 附件引用(还没下载)。
|
|
2
|
+
import type { AttachmentKind } from "../../core/types.js";
|
|
3
|
+
|
|
4
|
+
export type AttachmentRef = { kind: AttachmentKind; key: string; name?: string; durationMs?: number };
|
|
5
|
+
export type ParsedMessage = { text: string; attachments: AttachmentRef[] };
|
|
6
|
+
|
|
7
|
+
const UNSUPPORTED_TYPES: Record<string, string> = {
|
|
8
|
+
sticker: "表情包",
|
|
9
|
+
merge_forward: "合并转发",
|
|
10
|
+
interactive: "卡片",
|
|
11
|
+
share_chat: "群名片",
|
|
12
|
+
share_user: "个人名片",
|
|
13
|
+
location: "位置",
|
|
14
|
+
todo: "任务",
|
|
15
|
+
vote: "投票",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function unsupportedTypeLabel(messageType: string | undefined): string {
|
|
19
|
+
return (messageType && UNSUPPORTED_TYPES[messageType]) ?? "这种";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 不认识的消息类型返回 null,调用方据此回一句「不支持」。 */
|
|
23
|
+
export function parseMessage(message: { message_type?: string; content?: string }): ParsedMessage | null {
|
|
24
|
+
let content: any;
|
|
25
|
+
try {
|
|
26
|
+
content = JSON.parse(message.content ?? "{}");
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
switch (message.message_type) {
|
|
31
|
+
case "text":
|
|
32
|
+
return { text: String(content.text ?? ""), attachments: [] };
|
|
33
|
+
case "image":
|
|
34
|
+
return content.image_key ? { text: "", attachments: [{ kind: "image", key: content.image_key }] } : null;
|
|
35
|
+
case "file":
|
|
36
|
+
return content.file_key
|
|
37
|
+
? { text: "", attachments: [{ kind: "file", key: content.file_key, name: content.file_name }] }
|
|
38
|
+
: null;
|
|
39
|
+
case "audio":
|
|
40
|
+
return content.file_key
|
|
41
|
+
? {
|
|
42
|
+
text: "",
|
|
43
|
+
attachments: [{ kind: "audio", key: content.file_key, durationMs: Number(content.duration) || undefined }],
|
|
44
|
+
}
|
|
45
|
+
: null;
|
|
46
|
+
case "media":
|
|
47
|
+
return content.file_key
|
|
48
|
+
? {
|
|
49
|
+
text: "",
|
|
50
|
+
attachments: [
|
|
51
|
+
{
|
|
52
|
+
kind: "video",
|
|
53
|
+
key: content.file_key,
|
|
54
|
+
name: content.file_name,
|
|
55
|
+
durationMs: Number(content.duration) || undefined,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
}
|
|
59
|
+
: null;
|
|
60
|
+
case "post":
|
|
61
|
+
return parsePost(content);
|
|
62
|
+
default:
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 富文本:文字按原顺序拼起来,图片和视频在原位置留个占位(「看第二张图」才对得上号)。 */
|
|
68
|
+
function parsePost(content: any): ParsedMessage | null {
|
|
69
|
+
const body = Array.isArray(content?.content)
|
|
70
|
+
? content
|
|
71
|
+
: Object.values(content ?? {}).find((v: any) => Array.isArray(v?.content));
|
|
72
|
+
if (!body) return null;
|
|
73
|
+
const attachments: AttachmentRef[] = [];
|
|
74
|
+
const lines: string[] = [];
|
|
75
|
+
if ((body as any).title) lines.push(String((body as any).title));
|
|
76
|
+
const count = (kind: AttachmentKind) => attachments.filter((a) => a.kind === kind).length;
|
|
77
|
+
for (const paragraph of (body as any).content as any[][]) {
|
|
78
|
+
if (!Array.isArray(paragraph)) continue;
|
|
79
|
+
let line = "";
|
|
80
|
+
for (const el of paragraph) {
|
|
81
|
+
switch (el?.tag) {
|
|
82
|
+
case "text":
|
|
83
|
+
case "md":
|
|
84
|
+
line += el.text ?? "";
|
|
85
|
+
break;
|
|
86
|
+
case "a":
|
|
87
|
+
line += el.text && el.href && el.text !== el.href ? `[${el.text}](${el.href})` : (el.href ?? el.text ?? "");
|
|
88
|
+
break;
|
|
89
|
+
case "at":
|
|
90
|
+
line += el.user_id ?? "";
|
|
91
|
+
break;
|
|
92
|
+
case "code_block":
|
|
93
|
+
line += `\n\`\`\`${el.language ?? ""}\n${el.text ?? ""}\n\`\`\`\n`;
|
|
94
|
+
break;
|
|
95
|
+
case "emotion":
|
|
96
|
+
line += el.emoji_type ? `[${el.emoji_type}]` : "";
|
|
97
|
+
break;
|
|
98
|
+
case "img":
|
|
99
|
+
if (el.image_key) {
|
|
100
|
+
attachments.push({ kind: "image", key: el.image_key });
|
|
101
|
+
line += `[图片${count("image")}]`;
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
case "media":
|
|
105
|
+
if (el.file_key) {
|
|
106
|
+
attachments.push({ kind: "video", key: el.file_key });
|
|
107
|
+
line += `[视频${count("video")}]`;
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
lines.push(line);
|
|
113
|
+
}
|
|
114
|
+
return { text: lines.join("\n"), attachments };
|
|
115
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// 飞书应用注册:OAuth device-code flow。
|
|
2
|
+
//
|
|
3
|
+
// 扫码后一次拿全 appId / appSecret / 扫码人的 open_id,用户不用去开发者后台
|
|
4
|
+
// 手抄任何东西。协议端点与字段取自飞书 accounts 域的 app registration 接口。
|
|
5
|
+
import QRCode from "qrcode";
|
|
6
|
+
|
|
7
|
+
export type FeishuDomain = "feishu" | "lark";
|
|
8
|
+
|
|
9
|
+
const ACCOUNTS_URL: Record<FeishuDomain, string> = {
|
|
10
|
+
feishu: "https://accounts.feishu.cn",
|
|
11
|
+
lark: "https://accounts.larksuite.com",
|
|
12
|
+
};
|
|
13
|
+
const REGISTRATION_PATH = "/oauth/v1/app/registration";
|
|
14
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
15
|
+
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
|
16
|
+
const DEFAULT_EXPIRE_SECONDS = 600;
|
|
17
|
+
|
|
18
|
+
export type RegistrationResult = {
|
|
19
|
+
appId: string;
|
|
20
|
+
appSecret: string;
|
|
21
|
+
domain: FeishuDomain;
|
|
22
|
+
openId?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type BeginResult = {
|
|
26
|
+
deviceCode: string;
|
|
27
|
+
qrUrl: string;
|
|
28
|
+
userCode: string;
|
|
29
|
+
interval: number;
|
|
30
|
+
expireIn: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type PollOutcome =
|
|
34
|
+
| { status: "success"; result: RegistrationResult }
|
|
35
|
+
| { status: "access_denied" }
|
|
36
|
+
| { status: "expired" }
|
|
37
|
+
| { status: "timeout" }
|
|
38
|
+
| { status: "error"; message: string };
|
|
39
|
+
|
|
40
|
+
type InitResponse = { nonce?: string; supported_auth_methods?: string[] };
|
|
41
|
+
type BeginResponse = {
|
|
42
|
+
device_code: string;
|
|
43
|
+
verification_uri: string;
|
|
44
|
+
verification_uri_complete: string;
|
|
45
|
+
user_code: string;
|
|
46
|
+
interval: number;
|
|
47
|
+
expire_in: number;
|
|
48
|
+
};
|
|
49
|
+
type PollResponse = {
|
|
50
|
+
client_id?: string;
|
|
51
|
+
client_secret?: string;
|
|
52
|
+
user_info?: { open_id?: string; tenant_brand?: FeishuDomain };
|
|
53
|
+
error?: string;
|
|
54
|
+
error_description?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
async function post<T>(domain: FeishuDomain, body: Record<string, string>): Promise<T> {
|
|
58
|
+
const res = await fetch(`${ACCOUNTS_URL[domain]}${REGISTRATION_PATH}`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
61
|
+
body: new URLSearchParams(body).toString(),
|
|
62
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
63
|
+
});
|
|
64
|
+
// 待授权/错误状态也会以 4xx + JSON body 返回,所以不按状态码抛错。
|
|
65
|
+
return (await res.json()) as T;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 第一步:确认当前环境支持 client_secret 认证。 */
|
|
69
|
+
export async function initRegistration(domain: FeishuDomain = "feishu"): Promise<void> {
|
|
70
|
+
const res = await post<InitResponse>(domain, { action: "init" });
|
|
71
|
+
if (!res.supported_auth_methods?.includes("client_secret")) {
|
|
72
|
+
throw new Error("当前环境不支持 client_secret 认证方式");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 第二步:开始设备码流程,拿到给用户扫的二维码地址。 */
|
|
77
|
+
export async function beginRegistration(domain: FeishuDomain = "feishu"): Promise<BeginResult> {
|
|
78
|
+
const res = await post<BeginResponse>(domain, {
|
|
79
|
+
action: "begin",
|
|
80
|
+
archetype: "PersonalAgent",
|
|
81
|
+
auth_method: "client_secret",
|
|
82
|
+
request_user_info: "open_id",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const url = new URL(res.verification_uri_complete);
|
|
86
|
+
// 只接受飞书/Lark 自家域名,避免响应被污染后把用户引到别处
|
|
87
|
+
if (!/(^|\.)(feishu\.cn|larksuite\.com)$/.test(url.hostname)) {
|
|
88
|
+
throw new Error(`拒绝非飞书域名的验证地址:${url.hostname}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
deviceCode: res.device_code,
|
|
93
|
+
qrUrl: url.toString(),
|
|
94
|
+
userCode: res.user_code,
|
|
95
|
+
interval: Number.isFinite(res.interval) ? res.interval : DEFAULT_POLL_INTERVAL_SECONDS,
|
|
96
|
+
expireIn: Number.isFinite(res.expire_in) ? res.expire_in : DEFAULT_EXPIRE_SECONDS,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const sleep = (seconds: number) =>
|
|
101
|
+
new Promise((resolve) => setTimeout(resolve, Math.max(1, seconds) * 1000));
|
|
102
|
+
|
|
103
|
+
/** 第三步:轮询直到用户在手机上确认。会根据 tenant_brand 自动切换 feishu/lark 域。 */
|
|
104
|
+
export async function pollRegistration(params: {
|
|
105
|
+
deviceCode: string;
|
|
106
|
+
interval: number;
|
|
107
|
+
expireIn: number;
|
|
108
|
+
domain?: FeishuDomain;
|
|
109
|
+
onTick?: (secondsLeft: number) => void;
|
|
110
|
+
}): Promise<PollOutcome> {
|
|
111
|
+
let domain: FeishuDomain = params.domain ?? "feishu";
|
|
112
|
+
let interval = params.interval;
|
|
113
|
+
let domainSwitched = false;
|
|
114
|
+
const deadline = Date.now() + params.expireIn * 1000;
|
|
115
|
+
|
|
116
|
+
while (Date.now() < deadline) {
|
|
117
|
+
let res: PollResponse;
|
|
118
|
+
try {
|
|
119
|
+
res = await post<PollResponse>(domain, {
|
|
120
|
+
action: "poll",
|
|
121
|
+
device_code: params.deviceCode,
|
|
122
|
+
tp: "ob_cli_app",
|
|
123
|
+
});
|
|
124
|
+
} catch {
|
|
125
|
+
await sleep(interval); // 网络抖动,继续轮询
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 租户在 Lark 侧时自动换域重试一次
|
|
130
|
+
if (!domainSwitched && res.user_info?.tenant_brand === "lark") {
|
|
131
|
+
domain = "lark";
|
|
132
|
+
domainSwitched = true;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (res.client_id && res.client_secret) {
|
|
137
|
+
return {
|
|
138
|
+
status: "success",
|
|
139
|
+
result: {
|
|
140
|
+
appId: res.client_id,
|
|
141
|
+
appSecret: res.client_secret,
|
|
142
|
+
domain,
|
|
143
|
+
...(res.user_info?.open_id ? { openId: res.user_info.open_id } : {}),
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (res.error === "slow_down") interval += 5;
|
|
149
|
+
else if (res.error === "access_denied") return { status: "access_denied" };
|
|
150
|
+
else if (res.error === "expired_token") return { status: "expired" };
|
|
151
|
+
else if (res.error && res.error !== "authorization_pending") {
|
|
152
|
+
return { status: "error", message: `${res.error}: ${res.error_description ?? "未知错误"}` };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
params.onTick?.(Math.max(0, Math.round((deadline - Date.now()) / 1000)));
|
|
156
|
+
await sleep(interval);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { status: "timeout" };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 终端里打印二维码。必须无边框输出,加了装饰框会破坏识别。 */
|
|
163
|
+
export async function printQrCode(url: string): Promise<void> {
|
|
164
|
+
const output = await QRCode.toString(url, { type: "terminal", small: true });
|
|
165
|
+
process.stdout.write(output.endsWith("\n") ? output : `${output}\n`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 给 Web 页面用的二维码(SVG 字符串)。 */
|
|
169
|
+
export async function qrSvg(url: string): Promise<string> {
|
|
170
|
+
return QRCode.toString(url, { type: "svg", margin: 1, width: 240 });
|
|
171
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// 把命令表同步成飞书的「斜杠指令」:用户在飞书输入框里打「/」就能看到并选择。
|
|
2
|
+
//
|
|
3
|
+
// 接口:/open-apis/application/v7/app_slash_commands(增删改查)。扫码创建的应用自带读写权限。
|
|
4
|
+
// 实测出来的规矩(文档没写全,别凭感觉改):
|
|
5
|
+
// - 指令名必须匹配 ^[a-z][a-z0-9_-]{0,31}$ —— 带冒号的 plugin skill、大写、超长的都注册不了,跳过;
|
|
6
|
+
// - 说明最长 100 字;每个应用最多 100 条;
|
|
7
|
+
// - 列表按创建时间倒序返回(后建的在前),没有排序字段。所以想要某个顺序,就得
|
|
8
|
+
// 按目标顺序倒着建,每条之间隔开 1 秒以上(create_time 精确到秒,同一秒内顺序不定);
|
|
9
|
+
// - 改动约 5 分钟生效,客户端还有约 3 分钟缓存。
|
|
10
|
+
//
|
|
11
|
+
// 这个应用是 agent-loft 专用的:同步时会删掉不在命令表里的指令,包括手动加的。
|
|
12
|
+
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
13
|
+
import type { CommandEntry } from "../../core/command-catalog.js";
|
|
14
|
+
|
|
15
|
+
const API = "/open-apis/application/v7/app_slash_commands";
|
|
16
|
+
const NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
17
|
+
const MAX_COMMANDS = 100;
|
|
18
|
+
const MAX_DESCRIPTION = 100;
|
|
19
|
+
/** create_time 精确到秒,间隔要超过 1 秒顺序才稳定 */
|
|
20
|
+
const CREATE_GAP_MS = 1100;
|
|
21
|
+
|
|
22
|
+
export type FeishuSlashCommand = { command: string; description: string };
|
|
23
|
+
export type RemoteSlashCommand = { command_id: string; command: string; description?: { default_value?: string } };
|
|
24
|
+
|
|
25
|
+
export type SlashSyncStatus = {
|
|
26
|
+
state: "idle" | "syncing" | "done" | "error";
|
|
27
|
+
/** 最近一次完成(或失败)的时间 */
|
|
28
|
+
at?: number;
|
|
29
|
+
/** 飞书上现在注册了多少条 */
|
|
30
|
+
registered?: number;
|
|
31
|
+
/** 名字不合飞书规矩、或者超出 100 条而没注册的命令 */
|
|
32
|
+
skipped?: string[];
|
|
33
|
+
/** 最近一次是否真的改了东西(没改就是已经一致) */
|
|
34
|
+
changed?: boolean;
|
|
35
|
+
error?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function clipChars(text: string, max: number): string {
|
|
39
|
+
const chars = Array.from(text);
|
|
40
|
+
return chars.length <= max ? text : `${chars.slice(0, max - 1).join("")}…`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 命令表 → 飞书指令。参数提示并进说明里,飞书那边没有单独的参数字段。 */
|
|
44
|
+
export function toFeishuCommands(entries: CommandEntry[]): { commands: FeishuSlashCommand[]; skipped: string[] } {
|
|
45
|
+
const commands: FeishuSlashCommand[] = [];
|
|
46
|
+
const skipped: string[] = [];
|
|
47
|
+
const seen = new Set<string>();
|
|
48
|
+
for (const c of entries) {
|
|
49
|
+
if (c.hidden || seen.has(c.name)) continue;
|
|
50
|
+
seen.add(c.name);
|
|
51
|
+
if (!NAME.test(c.name) || commands.length >= MAX_COMMANDS) {
|
|
52
|
+
skipped.push(c.name);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const text = `${c.args ? `${c.args} ` : ""}${c.description}`.replace(/\s+/g, " ").trim() || c.name;
|
|
56
|
+
commands.push({ command: c.name, description: clipChars(text, MAX_DESCRIPTION) });
|
|
57
|
+
}
|
|
58
|
+
return { commands, skipped };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type SyncPlan =
|
|
62
|
+
| { action: "none" }
|
|
63
|
+
| { action: "patch"; updates: { id: string; description: string }[] }
|
|
64
|
+
| { action: "rebuild" };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* remote 是飞书返回的顺序(后建的在前),desired 是想要的显示顺序(第一条在最前)。
|
|
68
|
+
* 名字和顺序都对得上,最多改改说明;否则整体重建 —— 飞书没有排序字段,只能靠重建来排。
|
|
69
|
+
*/
|
|
70
|
+
export function planSync(remote: RemoteSlashCommand[], desired: FeishuSlashCommand[]): SyncPlan {
|
|
71
|
+
const sameOrder =
|
|
72
|
+
remote.length === desired.length && remote.every((r, i) => r.command === desired[i]!.command);
|
|
73
|
+
if (!sameOrder) return { action: "rebuild" };
|
|
74
|
+
const updates = remote
|
|
75
|
+
.map((r, i) => ({ r, d: desired[i]! }))
|
|
76
|
+
.filter(({ r, d }) => (r.description?.default_value ?? "") !== d.description)
|
|
77
|
+
.map(({ r, d }) => ({ id: r.command_id, description: d.description }));
|
|
78
|
+
return updates.length > 0 ? { action: "patch", updates } : { action: "none" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type ApiResponse<T> = { code?: number; msg?: string; data?: T };
|
|
82
|
+
|
|
83
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 一个机器人一个。request() 可以随便多次调用:会合并成一次,正在同步时再要求就等这次完了再跑一遍。
|
|
87
|
+
*/
|
|
88
|
+
export class SlashSync {
|
|
89
|
+
status: SlashSyncStatus = { state: "idle" };
|
|
90
|
+
private running = false;
|
|
91
|
+
private again = false;
|
|
92
|
+
private stopped = false;
|
|
93
|
+
private debounce: NodeJS.Timeout | null = null;
|
|
94
|
+
|
|
95
|
+
constructor(
|
|
96
|
+
private readonly lark: Lark.Client,
|
|
97
|
+
/** 现在应该注册哪些命令(已排好序) */
|
|
98
|
+
private readonly desired: () => Promise<CommandEntry[]>,
|
|
99
|
+
private readonly tag: string,
|
|
100
|
+
) {}
|
|
101
|
+
|
|
102
|
+
/** delayMs:设置里连着点几次保存,只同步一次 */
|
|
103
|
+
request(delayMs = 3000): void {
|
|
104
|
+
if (this.stopped) return;
|
|
105
|
+
if (this.debounce) clearTimeout(this.debounce);
|
|
106
|
+
this.debounce = setTimeout(() => {
|
|
107
|
+
this.debounce = null;
|
|
108
|
+
void this.run();
|
|
109
|
+
}, delayMs);
|
|
110
|
+
this.debounce.unref?.();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
stop(): void {
|
|
114
|
+
this.stopped = true;
|
|
115
|
+
if (this.debounce) clearTimeout(this.debounce);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async run(): Promise<void> {
|
|
119
|
+
if (this.running) {
|
|
120
|
+
this.again = true;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this.running = true;
|
|
124
|
+
this.status = { ...this.status, state: "syncing" };
|
|
125
|
+
try {
|
|
126
|
+
const { commands, skipped } = toFeishuCommands(await this.desired());
|
|
127
|
+
const remote = await this.list();
|
|
128
|
+
const plan = planSync(remote, commands);
|
|
129
|
+
if (plan.action === "patch") {
|
|
130
|
+
for (const u of plan.updates) {
|
|
131
|
+
await this.call("PATCH", `${API}/${u.id}`, { description: { default_value: u.description } });
|
|
132
|
+
}
|
|
133
|
+
} else if (plan.action === "rebuild") {
|
|
134
|
+
for (const r of remote) {
|
|
135
|
+
if (this.stopped) return;
|
|
136
|
+
await this.call("DELETE", `${API}/${r.command_id}`);
|
|
137
|
+
}
|
|
138
|
+
// 倒着建:最后建的显示在最前
|
|
139
|
+
for (const [i, c] of [...commands].reverse().entries()) {
|
|
140
|
+
if (this.stopped) return;
|
|
141
|
+
if (i > 0) await sleep(CREATE_GAP_MS);
|
|
142
|
+
await this.call("POST", API, { command: c.command, description: { default_value: c.description } });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
this.status = {
|
|
146
|
+
state: "done",
|
|
147
|
+
at: Date.now(),
|
|
148
|
+
registered: commands.length,
|
|
149
|
+
skipped,
|
|
150
|
+
changed: plan.action !== "none",
|
|
151
|
+
};
|
|
152
|
+
if (plan.action !== "none") {
|
|
153
|
+
console.log(`${this.tag} 斜杠指令已同步:${commands.length} 条(${plan.action === "rebuild" ? "重建" : "更新说明"}),约 5~8 分钟后在飞书里生效`);
|
|
154
|
+
}
|
|
155
|
+
} catch (err) {
|
|
156
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
157
|
+
this.status = { ...this.status, state: "error", at: Date.now(), error: message };
|
|
158
|
+
console.error(`${this.tag} 斜杠指令同步失败:${message}`);
|
|
159
|
+
} finally {
|
|
160
|
+
this.running = false;
|
|
161
|
+
if (this.again && !this.stopped) {
|
|
162
|
+
this.again = false;
|
|
163
|
+
void this.run();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private async list(): Promise<RemoteSlashCommand[]> {
|
|
169
|
+
const data = await this.call<{ items?: RemoteSlashCommand[] }>("GET", API);
|
|
170
|
+
return data?.items ?? [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private async call<T>(method: "GET" | "POST" | "PATCH" | "DELETE", url: string, data?: unknown): Promise<T | undefined> {
|
|
174
|
+
let res: ApiResponse<T>;
|
|
175
|
+
try {
|
|
176
|
+
res = await this.lark.request<ApiResponse<T>>({ method, url, ...(data !== undefined ? { data } : {}) });
|
|
177
|
+
} catch (err) {
|
|
178
|
+
const body = (err as { response?: { data?: ApiResponse<T> } })?.response?.data;
|
|
179
|
+
if (body?.code) throw new Error(explain(body.code, body.msg));
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
if (res?.code) throw new Error(explain(res.code, res.msg));
|
|
183
|
+
return res?.data;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function explain(code: number, msg?: string): string {
|
|
188
|
+
if (code === 99991640 || code === 99991672) {
|
|
189
|
+
return `应用没有斜杠指令权限(${code})。到飞书开发者后台给这个应用开通 application:app_slash_command:write / read,并发布新版本`;
|
|
190
|
+
}
|
|
191
|
+
return `code=${code} ${msg ?? ""}`.trim();
|
|
192
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// 一轮对话的输出编排:
|
|
2
|
+
// 过程(思考 / 工具调用)→ 实时刷新的过程卡
|
|
3
|
+
// 最终回复 → 普通消息,可复制可引用,不套卡片
|
|
4
|
+
//
|
|
5
|
+
// 全程串行执行。曾经踩过的坑:过程卡的 send 还没 await 完成,收尾就到了,
|
|
6
|
+
// 于是多发一张卡,而先前那张永远停在中间态。
|
|
7
|
+
export type TurnStreamDeps = {
|
|
8
|
+
sendCard: (card: unknown) => Promise<string | undefined>;
|
|
9
|
+
updateCard: (messageId: string, card: unknown) => Promise<boolean>;
|
|
10
|
+
renderProgress: (steps: string[], done: boolean, footer?: string) => unknown;
|
|
11
|
+
/** 发送最终回复(普通文本消息) */
|
|
12
|
+
sendReply: (text: string) => Promise<unknown>;
|
|
13
|
+
onError?: (err: unknown) => void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type TurnStreamOptions = {
|
|
17
|
+
minIntervalMs?: number;
|
|
18
|
+
maxEdits?: number;
|
|
19
|
+
now?: () => number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export class TurnStream {
|
|
23
|
+
private steps: string[] = [];
|
|
24
|
+
private text = "";
|
|
25
|
+
private messageId: string | undefined;
|
|
26
|
+
private edits = 0;
|
|
27
|
+
private lastEdit = 0;
|
|
28
|
+
private chain: Promise<void> = Promise.resolve();
|
|
29
|
+
|
|
30
|
+
private readonly minIntervalMs: number;
|
|
31
|
+
private readonly maxEdits: number;
|
|
32
|
+
private readonly now: () => number;
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly deps: TurnStreamDeps,
|
|
36
|
+
options: TurnStreamOptions = {},
|
|
37
|
+
) {
|
|
38
|
+
this.minIntervalMs = options.minIntervalMs ?? 2000;
|
|
39
|
+
this.maxEdits = options.maxEdits ?? 15;
|
|
40
|
+
this.now = options.now ?? (() => Date.now());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private enqueue(fn: () => Promise<void>): void {
|
|
44
|
+
this.chain = this.chain.then(fn).catch((err) => this.deps.onError?.(err));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 一条过程信息(思考片段 / 工具调用)。 */
|
|
48
|
+
addStep(step: string): void {
|
|
49
|
+
this.steps.push(step);
|
|
50
|
+
this.enqueue(async () => {
|
|
51
|
+
const now = this.now();
|
|
52
|
+
if (now - this.lastEdit < this.minIntervalMs || this.edits >= this.maxEdits) return;
|
|
53
|
+
this.lastEdit = now;
|
|
54
|
+
const card = this.deps.renderProgress(this.steps, false);
|
|
55
|
+
if (!this.messageId) {
|
|
56
|
+
this.messageId = await this.deps.sendCard(card);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
this.edits += 1;
|
|
60
|
+
await this.deps.updateCard(this.messageId, card);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 回复正文片段,累积起来备用。 */
|
|
65
|
+
addText(text: string): void {
|
|
66
|
+
this.text += text;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 收尾:过程卡定格为完成态,然后把最终回复作为普通消息发出去。
|
|
71
|
+
* 整轮没有过程时不会留下任何卡片 —— 简单问答就该只有一条回复。
|
|
72
|
+
*/
|
|
73
|
+
finish(finalText: string, footer?: string): void {
|
|
74
|
+
this.enqueue(async () => {
|
|
75
|
+
if (this.messageId) {
|
|
76
|
+
await this.deps.updateCard(
|
|
77
|
+
this.messageId,
|
|
78
|
+
this.deps.renderProgress(this.steps, true, footer),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const reply = finalText.trim() || this.text.trim();
|
|
82
|
+
if (reply) await this.deps.sendReply(reply);
|
|
83
|
+
this.reset();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private reset(): void {
|
|
88
|
+
this.steps = [];
|
|
89
|
+
this.text = "";
|
|
90
|
+
this.messageId = undefined;
|
|
91
|
+
this.edits = 0;
|
|
92
|
+
this.lastEdit = 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async drain(): Promise<void> {
|
|
96
|
+
await this.chain;
|
|
97
|
+
}
|
|
98
|
+
}
|