oh-my-im 0.1.21 → 0.2.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/README.md +89 -32
- package/dist/agents/codex-agent.js +16 -4
- package/dist/agents/opencode-agent.js +1 -1
- package/dist/agents/pi-agent.js +10 -2
- package/dist/bot-app.js +230 -67
- package/dist/bot-worker.js +29 -7
- package/dist/{config.js → core/config.js} +3 -3
- package/dist/{version.js → core/version.js} +1 -1
- package/dist/dashboard-worker.js +81 -21
- package/dist/dingtalk/ai-card.js +99 -0
- package/dist/dingtalk/dingtalk-ai-card.js +210 -0
- package/dist/{dingtalk-card.js → dingtalk/dingtalk-card.js} +1 -1
- package/dist/{dingtalk.js → dingtalk/dingtalk.js} +1 -1
- package/dist/dingtalk/markdown.js +112 -0
- package/dist/{dws-client.js → dws/dws-client.js} +14 -0
- package/dist/{dws-history.js → dws/dws-history.js} +1 -1
- package/dist/dws-dashboard.js +128 -36
- package/dist/group-worker.js +283 -79
- package/dist/omi.js +1 -1
- package/outputs/favicon/apple-touch-icon.png +0 -0
- package/outputs/favicon/icon-192.png +0 -0
- package/outputs/favicon/icon-512.png +0 -0
- package/package.json +1 -1
- /package/dist/{conversation-log.js → core/conversation-log.js} +0 -0
- /package/dist/{logger.js → core/logger.js} +0 -0
- /package/dist/{monitor-command.js → core/monitor-command.js} +0 -0
- /package/dist/{dingtalk-robot.js → dingtalk/dingtalk-robot.js} +0 -0
package/dist/dashboard-worker.js
CHANGED
|
@@ -6,9 +6,9 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { dirname, join } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { startDashboard,
|
|
10
|
-
import { getDwsAuthStatus, getCurrentDwsUser, startDwsDeviceLogin, getDwsDeviceLoginOutput, logoutDws, searchGroups, listGroupMembers, searchUsers, searchBots, } from "./dws-client.js";
|
|
11
|
-
import { readVersion } from "./version.js";
|
|
9
|
+
import { startDashboard, SESSION_MAX_AGE_SECONDS, } from "./dws-dashboard.js";
|
|
10
|
+
import { getDwsAuthStatus, getCurrentDwsUser, startDwsDeviceLogin, getDwsDeviceLoginOutput, logoutDws, searchGroups, listGroupMembers, searchUsers, searchBots, } from "./dws/dws-client.js";
|
|
11
|
+
import { readVersion } from "./core/version.js";
|
|
12
12
|
const dataDir = join(homedir(), ".oh-my-im");
|
|
13
13
|
const configFile = join(dataDir, "dws-dashboard.json");
|
|
14
14
|
const serverFile = join(dataDir, "dws-dashboard-server.json");
|
|
@@ -20,7 +20,9 @@ const omiPath = join(workerDir, "omi.js");
|
|
|
20
20
|
const processPaths = { "group-worker": join(workerDir, "group-worker.js"), bot: join(workerDir, "bot-worker.js") };
|
|
21
21
|
const repliesDir = join(dataDir, "replies");
|
|
22
22
|
const passwordFile = join(dataDir, "dashboard-password.json");
|
|
23
|
+
const sessionsFile = join(dataDir, "dashboard-sessions.json");
|
|
23
24
|
const scrypt = promisify(scryptCallback);
|
|
25
|
+
const SESSION_TTL_MS = SESSION_MAX_AGE_SECONDS * 1000;
|
|
24
26
|
const sessions = new Map();
|
|
25
27
|
const DEFAULT_PASSWORD = "5552123";
|
|
26
28
|
async function passwordHash(password, salt = randomBytes(16).toString("hex")) {
|
|
@@ -47,6 +49,29 @@ async function savePassword(record) {
|
|
|
47
49
|
function cookieValue(request) {
|
|
48
50
|
return request.headers.cookie?.split(";").map((part) => part.trim()).find((part) => part.startsWith("omi_session="))?.slice("omi_session=".length);
|
|
49
51
|
}
|
|
52
|
+
// 登录会话持久化到磁盘,重启看板不会把已登录的设备踢下线(有效期 60 天)。
|
|
53
|
+
async function loadSessions() {
|
|
54
|
+
try {
|
|
55
|
+
const stored = JSON.parse(await readFile(sessionsFile, "utf8"));
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
Object.entries(stored).forEach(([token, expires]) => {
|
|
58
|
+
if (typeof expires === "number" && expires > now)
|
|
59
|
+
sessions.set(token, expires);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch { /* first run */ }
|
|
63
|
+
}
|
|
64
|
+
async function saveSessions() {
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
for (const [token, expires] of sessions)
|
|
67
|
+
if (expires <= now)
|
|
68
|
+
sessions.delete(token);
|
|
69
|
+
await mkdir(dataDir, { recursive: true });
|
|
70
|
+
const temporary = `${sessionsFile}.${process.pid}.tmp`;
|
|
71
|
+
await writeFile(temporary, `${JSON.stringify(Object.fromEntries(sessions))}\n`, { encoding: "utf8", mode: 0o600 });
|
|
72
|
+
await chmod(temporary, 0o600);
|
|
73
|
+
await rename(temporary, sessionsFile);
|
|
74
|
+
}
|
|
50
75
|
const defaultConfig = () => ({
|
|
51
76
|
privateChatEnabled: false,
|
|
52
77
|
responseMode: "card",
|
|
@@ -60,7 +85,7 @@ const defaultConfig = () => ({
|
|
|
60
85
|
targets: [], botAllowedUserIds: [], botAllowedUserNames: {},
|
|
61
86
|
botSuperAdminUserIds: [], botSuperAdminUserNames: {}, robotSenderOpenDingTalkId: "",
|
|
62
87
|
commandKeywords: { pause: [], monitorOpen: [], monitorStop: [], switchPi: [], switchCodex: [], switchOpencode: [] },
|
|
63
|
-
groupPromptSuffix: "",
|
|
88
|
+
groupPromptSuffix: "", aiCardTemplateId: "", aiCardContentKey: "content", aiCardStreamIntervalMs: 500, robotName: "AI Agent",
|
|
64
89
|
clientId: "", clientSecret: "", agentModels: { codex: "", pi: "", opencode: "" }, agent: "pi",
|
|
65
90
|
});
|
|
66
91
|
async function loadConfig() {
|
|
@@ -73,7 +98,7 @@ async function loadConfig() {
|
|
|
73
98
|
return {
|
|
74
99
|
...defaultConfig(),
|
|
75
100
|
...migrated,
|
|
76
|
-
agentModels: { ...defaultConfig().agentModels, ...(migrated.agentModels ?? {})
|
|
101
|
+
agentModels: { ...defaultConfig().agentModels, ...(migrated.agentModels ?? {}) },
|
|
77
102
|
commandKeywords: { ...defaultConfig().commandKeywords, ...(migrated.commandKeywords ?? {}) },
|
|
78
103
|
};
|
|
79
104
|
}
|
|
@@ -309,13 +334,37 @@ function externalCliEnv() {
|
|
|
309
334
|
}
|
|
310
335
|
return env;
|
|
311
336
|
}
|
|
337
|
+
async function listCodexModels() {
|
|
338
|
+
// Codex CLI 没有列模型的命令,但会在 config.toml 的 model_catalog_json 里
|
|
339
|
+
// 指定模型目录(含内置目录);读出其中的 slug 作为可选模型。
|
|
340
|
+
const home = process.env.CODEX_HOME?.trim() || join(homedir(), ".codex");
|
|
341
|
+
let catalogPath = join(home, "models.json");
|
|
342
|
+
try {
|
|
343
|
+
const toml = await readFile(join(home, "config.toml"), "utf8");
|
|
344
|
+
const match = toml.match(/^\s*model_catalog_json\s*=\s*["']([^"']+)["']/m);
|
|
345
|
+
if (match?.[1])
|
|
346
|
+
catalogPath = match[1];
|
|
347
|
+
}
|
|
348
|
+
catch { /* fall back to the default catalog path */ }
|
|
349
|
+
try {
|
|
350
|
+
const parsed = JSON.parse(await readFile(catalogPath, "utf8"));
|
|
351
|
+
const slugs = (parsed.models ?? [])
|
|
352
|
+
.filter((model) => model.visibility !== "hide")
|
|
353
|
+
.map((model) => (typeof model.slug === "string" ? model.slug.trim() : ""))
|
|
354
|
+
.filter(Boolean);
|
|
355
|
+
return [...new Set(slugs)];
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return [];
|
|
359
|
+
}
|
|
360
|
+
}
|
|
312
361
|
async function listAgentModels(agent) {
|
|
313
|
-
|
|
362
|
+
if (agent === "codex")
|
|
363
|
+
return { models: await listCodexModels() };
|
|
364
|
+
const command = agent === "pi" ? "pi" : "opencode";
|
|
314
365
|
const args = agent === "opencode" ? ["models"] : ["--list-models"];
|
|
315
366
|
const result = spawnSync(command, args, { encoding: "utf8", timeout: 20_000, env: agent === "opencode" ? externalCliEnv() : process.env });
|
|
316
367
|
if (result.error || result.status !== 0) {
|
|
317
|
-
if (agent === "codex")
|
|
318
|
-
return { models: ["默认模型(不指定)"] };
|
|
319
368
|
throw new Error(result.stderr?.trim() || result.error?.message || `${command} 模型列表查询失败`);
|
|
320
369
|
}
|
|
321
370
|
const lines = String(result.stdout || "").split(/\r?\n/).map((line) => line.trim());
|
|
@@ -326,7 +375,7 @@ async function listAgentModels(agent) {
|
|
|
326
375
|
})
|
|
327
376
|
: lines.filter((line) => line && !/^[-= ]+$/.test(line) && !/^available models/i.test(line));
|
|
328
377
|
if (agent === "pi")
|
|
329
|
-
return { models: [...new Set(models
|
|
378
|
+
return { models: [...new Set(models)] };
|
|
330
379
|
if (agent !== "opencode")
|
|
331
380
|
return { models: [...new Set(models)] };
|
|
332
381
|
let defaultModel;
|
|
@@ -343,16 +392,25 @@ async function listAgentModels(agent) {
|
|
|
343
392
|
}
|
|
344
393
|
catch { /* model list remains usable if debug output changes */ }
|
|
345
394
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
defaultModel = undefined;
|
|
349
|
-
return { models: [...new Set(defaultModel ? [defaultModel, ...filteredModels] : filteredModels)], defaultModel };
|
|
395
|
+
// 不做白名单过滤,OpenCode CLI 能列出什么就展示什么。
|
|
396
|
+
return { models: [...new Set(defaultModel ? [defaultModel, ...models] : models)], defaultModel };
|
|
350
397
|
}
|
|
351
398
|
async function botStatus() {
|
|
352
399
|
const config = await loadConfig();
|
|
353
400
|
try {
|
|
354
401
|
const value = JSON.parse(await readFile(botStatusFile, "utf8"));
|
|
355
|
-
|
|
402
|
+
// 状态文件可能是上次进程遗留的:进程已经不在时不能报“已连接”。
|
|
403
|
+
const alive = typeof value.pid === "number" && value.pid > 0 && (() => {
|
|
404
|
+
try {
|
|
405
|
+
process.kill(value.pid, 0);
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
})();
|
|
412
|
+
const enabled = config.privateChatEnabled === true;
|
|
413
|
+
return { enabled, connected: enabled && alive && value.connected !== false, updatedAt: value.updatedAt };
|
|
356
414
|
}
|
|
357
415
|
catch {
|
|
358
416
|
return { enabled: config.privateChatEnabled !== false, connected: false };
|
|
@@ -385,6 +443,7 @@ async function main() {
|
|
|
385
443
|
}, 1_000);
|
|
386
444
|
replyReloadTimer.unref();
|
|
387
445
|
const password = await loadPassword();
|
|
446
|
+
await loadSessions();
|
|
388
447
|
// Password-free access only for genuinely local use. A reverse proxy or
|
|
389
448
|
// tunnel (nginx/frp) on this machine also connects from 127.0.0.1, so the
|
|
390
449
|
// socket alone cannot separate local use from proxied external traffic.
|
|
@@ -406,20 +465,21 @@ async function main() {
|
|
|
406
465
|
isAuthenticated: (request) => { if (isLoopbackRequest(request))
|
|
407
466
|
return true; const token = cookieValue(request); const expires = token ? sessions.get(token) : undefined; return Boolean(expires && expires > Date.now()); },
|
|
408
467
|
login: async (candidate) => { const derived = await passwordHash(candidate, password.salt); const matches = derived.hash.length === password.hash.length && timingSafeEqual(Buffer.from(derived.hash, "hex"), Buffer.from(password.hash, "hex")); if (!matches)
|
|
409
|
-
return null; const token = randomBytes(32).toString("base64url"); sessions.set(token, Date.now() +
|
|
410
|
-
logout: (request) => { const token = cookieValue(request); if (token)
|
|
411
|
-
sessions.delete(token);
|
|
468
|
+
return null; const token = randomBytes(32).toString("base64url"); sessions.set(token, Date.now() + SESSION_TTL_MS); void saveSessions(); return token; },
|
|
469
|
+
logout: (request) => { const token = cookieValue(request); if (token) {
|
|
470
|
+
sessions.delete(token);
|
|
471
|
+
void saveSessions();
|
|
472
|
+
} },
|
|
412
473
|
changePassword: async (request, current, next) => { if (!auth.isAuthenticated(request))
|
|
413
474
|
return "未登录"; if (next.length < 8 || next.length > 200)
|
|
414
475
|
return "新密码长度需为 8-200 位"; const currentHash = await passwordHash(current, password.salt); if (currentHash.hash.length !== password.hash.length || !timingSafeEqual(Buffer.from(currentHash.hash, "hex"), Buffer.from(password.hash, "hex")))
|
|
415
|
-
return "当前密码错误"; const record = await passwordHash(next); await savePassword(record); password.salt = record.salt; password.hash = record.hash; sessions.clear(); return null; },
|
|
476
|
+
return "当前密码错误"; const record = await passwordHash(next); await savePassword(record); password.salt = record.salt; password.hash = record.hash; sessions.clear(); void saveSessions(); return null; },
|
|
416
477
|
};
|
|
417
478
|
startDashboard(serverConfig.port, {
|
|
418
479
|
getConfig: () => config,
|
|
419
480
|
updateConfig: async (next) => {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
config = normalized;
|
|
481
|
+
await saveConfig(next);
|
|
482
|
+
config = next;
|
|
423
483
|
},
|
|
424
484
|
getStatus: () => ({ ...runtime }),
|
|
425
485
|
// Replies are persisted by group-worker/bot. The dashboard remains usable
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* 一张 AI 卡片的完整生命周期封装:投放 → 流式推送(含打字机补帧)→ 收尾
|
|
4
|
+
* (finalize + $end_text + $title),并内置接口失败时的降级。
|
|
5
|
+
*
|
|
6
|
+
* 与底层 {@link DingTalkAiCardClient} 分开:客户端只管钉钉 API 协议,本类
|
|
7
|
+
* 只管「一张卡片」的业务流程,让私聊(bot-app)和群聊(group-worker)
|
|
8
|
+
* 共用同一套逻辑,后续要改 AI 卡片行为只需要动这一个文件。
|
|
9
|
+
*/
|
|
10
|
+
export class AiCardSession {
|
|
11
|
+
outTrackId;
|
|
12
|
+
client;
|
|
13
|
+
templateId;
|
|
14
|
+
contentKey;
|
|
15
|
+
log;
|
|
16
|
+
constructor(options) {
|
|
17
|
+
this.client = options.client;
|
|
18
|
+
this.templateId = options.templateId;
|
|
19
|
+
this.contentKey = options.contentKey;
|
|
20
|
+
this.log = options.log;
|
|
21
|
+
this.outTrackId = options.outTrackId ?? randomUUID();
|
|
22
|
+
}
|
|
23
|
+
/** 投放一张群聊卡片。 */
|
|
24
|
+
async openForGroup(params) {
|
|
25
|
+
await this.client.createForGroup({
|
|
26
|
+
outTrackId: this.outTrackId,
|
|
27
|
+
templateId: this.templateId,
|
|
28
|
+
contentKey: this.contentKey,
|
|
29
|
+
openConversationId: params.openConversationId,
|
|
30
|
+
title: params.title,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** 投放一张机器人单聊卡片。 */
|
|
34
|
+
async openForSingle(params) {
|
|
35
|
+
await this.client.createForSingle({
|
|
36
|
+
outTrackId: this.outTrackId,
|
|
37
|
+
templateId: this.templateId,
|
|
38
|
+
contentKey: this.contentKey,
|
|
39
|
+
userId: params.userId,
|
|
40
|
+
title: params.title,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** 中间帧:逐帧补推增量,尽量呈现打字机效果;失败只记日志,不影响主流程。 */
|
|
44
|
+
async push(content) {
|
|
45
|
+
await this.client
|
|
46
|
+
.typeOutRemaining({ outTrackId: this.outTrackId, contentKey: this.contentKey, content, maxFrames: 5, maxDurationMs: 400 })
|
|
47
|
+
.catch((err) => this.log.warn(`AI card stream skipped: ${String(err)}`));
|
|
48
|
+
}
|
|
49
|
+
/** 用 isError 关闭一张残留的卡片(例如进程重启后卡片停在「输入中」)。 */
|
|
50
|
+
async closeStale() {
|
|
51
|
+
await this.client
|
|
52
|
+
.stream({ outTrackId: this.outTrackId, contentKey: this.contentKey, content: "", isError: true })
|
|
53
|
+
.catch((err) => this.log.warn(`stale AI card finalize skipped: ${String(err)}`));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 收尾:finalize 正文 + 写 $end_text + 改 $title。
|
|
57
|
+
* @returns 正文是否成功写入卡片;false 表示调用方应改用文本兜底。
|
|
58
|
+
*/
|
|
59
|
+
async finish(options) {
|
|
60
|
+
const isError = options.error === true;
|
|
61
|
+
let contentDelivered = true;
|
|
62
|
+
try {
|
|
63
|
+
if (!isError) {
|
|
64
|
+
await this.client
|
|
65
|
+
.typeOutRemaining({ outTrackId: this.outTrackId, contentKey: this.contentKey, content: options.content })
|
|
66
|
+
.catch((err) => this.log.warn(`AI card type-out skipped: ${String(err)}`));
|
|
67
|
+
}
|
|
68
|
+
// 必须在 finalize 之前写入 $end_text:卡片进入「完成」状态那一刻就会渲染,
|
|
69
|
+
// 之后再改变量部分模板不会重新渲染。
|
|
70
|
+
if (!isError && options.endText) {
|
|
71
|
+
await this.client
|
|
72
|
+
.setEndText({ outTrackId: this.outTrackId, text: options.endText })
|
|
73
|
+
.catch((err) => this.log.warn(`AI card end_text update skipped: ${String(err)}`));
|
|
74
|
+
}
|
|
75
|
+
await this.client.stream({
|
|
76
|
+
outTrackId: this.outTrackId,
|
|
77
|
+
contentKey: this.contentKey,
|
|
78
|
+
content: options.content,
|
|
79
|
+
isFinalize: !isError,
|
|
80
|
+
isError,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
// 流式接口失败时用实例更新接口兜底写正文,避免卡片停留在空白/输入中。
|
|
85
|
+
this.log.warn(`AI card finalize failed: ${String(err)}`);
|
|
86
|
+
contentDelivered = await this.client
|
|
87
|
+
.updateCardData({ outTrackId: this.outTrackId, data: { [this.contentKey]: options.content } })
|
|
88
|
+
.then(() => true)
|
|
89
|
+
.catch((updateErr) => {
|
|
90
|
+
this.log.warn(`AI card content fallback skipped: ${String(updateErr)}`);
|
|
91
|
+
return false;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
await this.client
|
|
95
|
+
.updateTitle({ outTrackId: this.outTrackId, title: options.title })
|
|
96
|
+
.catch((err) => this.log.warn(`AI card title update skipped: ${String(err)}`));
|
|
97
|
+
return contentDelivered;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createLogger } from "../core/logger.js";
|
|
3
|
+
const log = createLogger("DingTalkAiCard");
|
|
4
|
+
const apiBase = "https://api.dingtalk.com";
|
|
5
|
+
// 模板里用于展示结束语的变量名。
|
|
6
|
+
const END_TEXT_KEY = "end_text";
|
|
7
|
+
/** 5xx 与网络错误可重试;4xx 业务错误不重试。 */
|
|
8
|
+
function isRetryableCardError(err) {
|
|
9
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
10
|
+
const status = /API failed:\s*(\d{3})/.exec(message)?.[1];
|
|
11
|
+
if (status)
|
|
12
|
+
return Number(status) >= 500;
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 钉钉 AI 卡片(流式卡片)客户端。
|
|
17
|
+
*
|
|
18
|
+
* 与内置的 StandardCard 不同,AI 卡片需要在卡片平台手工创建模板(消息卡片 +
|
|
19
|
+
* 场景「AI 卡片」,并在 Markdown 组件上开启流式开关、绑定一个变量名)。流程:
|
|
20
|
+
* 1. POST /v1.0/card/instances/createAndDeliver 投放卡片,进入「处理中」状态
|
|
21
|
+
* 2. PUT /v1.0/card/streaming 反复推送全量 markdown,进入「输入中」
|
|
22
|
+
* 3. 最后一帧 isFinalize=true(完成)或 isError=true(失败)
|
|
23
|
+
*
|
|
24
|
+
* 依赖权限:Card.Instance.Write(投放)+ Card.Streaming.Write(流式更新)。
|
|
25
|
+
*/
|
|
26
|
+
export class DingTalkAiCardClient {
|
|
27
|
+
accessToken;
|
|
28
|
+
accessTokenExpiresAt = 0;
|
|
29
|
+
clientId = "";
|
|
30
|
+
clientSecret = "";
|
|
31
|
+
robotCode = "";
|
|
32
|
+
// Last streamed content length per card, used to type out content that the
|
|
33
|
+
// agent produced in one burst instead of as incremental deltas.
|
|
34
|
+
streamedLengths = new Map();
|
|
35
|
+
setCredentials(clientId, clientSecret, robotCode) {
|
|
36
|
+
const nextClientId = clientId.trim();
|
|
37
|
+
const nextClientSecret = clientSecret.trim();
|
|
38
|
+
const nextRobotCode = (robotCode ?? clientId).trim();
|
|
39
|
+
if (this.clientId === nextClientId && this.clientSecret === nextClientSecret && this.robotCode === nextRobotCode)
|
|
40
|
+
return;
|
|
41
|
+
this.clientId = nextClientId;
|
|
42
|
+
this.clientSecret = nextClientSecret;
|
|
43
|
+
this.robotCode = nextRobotCode;
|
|
44
|
+
this.accessToken = undefined;
|
|
45
|
+
this.accessTokenExpiresAt = 0;
|
|
46
|
+
}
|
|
47
|
+
get configured() {
|
|
48
|
+
return Boolean(this.clientId && this.clientSecret && this.robotCode);
|
|
49
|
+
}
|
|
50
|
+
get robotCodeValue() {
|
|
51
|
+
return this.robotCode;
|
|
52
|
+
}
|
|
53
|
+
/** 投放一张群聊 AI 卡片。outTrackId 由调用方生成,后续流式更新必须复用。 */
|
|
54
|
+
async createForGroup(params) {
|
|
55
|
+
await this.createAndDeliver({
|
|
56
|
+
cardTemplateId: params.templateId,
|
|
57
|
+
outTrackId: params.outTrackId,
|
|
58
|
+
cardData: { cardParamMap: { [params.contentKey]: "", [END_TEXT_KEY]: "", ...(params.title ? { title: params.title } : {}) } },
|
|
59
|
+
openSpaceId: `dtv1.card//IM_GROUP.${params.openConversationId}`,
|
|
60
|
+
imGroupOpenSpaceModel: { supportForward: true },
|
|
61
|
+
imGroupOpenDeliverModel: { robotCode: this.robotCode },
|
|
62
|
+
userIdType: 1,
|
|
63
|
+
});
|
|
64
|
+
log.info(`ai card delivered group=${params.openConversationId} outTrackId=${params.outTrackId}`);
|
|
65
|
+
}
|
|
66
|
+
/** 投放一张机器人单聊 AI 卡片。 */
|
|
67
|
+
async createForSingle(params) {
|
|
68
|
+
await this.createAndDeliver({
|
|
69
|
+
cardTemplateId: params.templateId,
|
|
70
|
+
outTrackId: params.outTrackId,
|
|
71
|
+
cardData: { cardParamMap: { [params.contentKey]: "", [END_TEXT_KEY]: "", ...(params.title ? { title: params.title } : {}) } },
|
|
72
|
+
openSpaceId: `dtv1.card//IM_ROBOT.${params.userId}`,
|
|
73
|
+
imRobotOpenSpaceModel: { supportForward: false },
|
|
74
|
+
imRobotOpenDeliverModel: { spaceType: "IM_ROBOT", robotCode: this.robotCode },
|
|
75
|
+
userIdType: 1,
|
|
76
|
+
});
|
|
77
|
+
log.info(`ai card delivered single user=${params.userId} outTrackId=${params.outTrackId}`);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 流式更新。markdown 场景必须使用全量内容且 isFull=true;最后一帧传
|
|
81
|
+
* isFinalize=true,异常传 isError=true。
|
|
82
|
+
*/
|
|
83
|
+
async stream(params) {
|
|
84
|
+
log.info(`ai card stream outTrackId=${params.outTrackId} len=${params.content.length}${params.isFinalize ? " FINALIZE" : ""}${params.isError ? " ERROR" : ""}`);
|
|
85
|
+
// guid 在重试时保持不变,保证服务端幂等;钉钉流式接口偶发返回 500,
|
|
86
|
+
// 尤其首帧与 finalize 帧,重试可以显著降低卡片空白/卡在输入中的概率。
|
|
87
|
+
await this.callWithRetry("PUT", "/v1.0/card/streaming", {
|
|
88
|
+
outTrackId: params.outTrackId,
|
|
89
|
+
guid: randomUUID(),
|
|
90
|
+
key: params.contentKey,
|
|
91
|
+
content: params.content,
|
|
92
|
+
isFull: true,
|
|
93
|
+
isFinalize: params.isFinalize === true,
|
|
94
|
+
isError: params.isError === true,
|
|
95
|
+
});
|
|
96
|
+
this.streamedLengths.set(params.outTrackId, params.content.length);
|
|
97
|
+
if (params.isFinalize || params.isError)
|
|
98
|
+
this.streamedLengths.delete(params.outTrackId);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 若最终内容比已经流式推送过的内容更长(模型一次性吐出整段),按几帧逐步
|
|
102
|
+
* 补推剩余内容,保证卡片仍然呈现“打字机”效果;已经逐字流过的则几乎不做额外请求。
|
|
103
|
+
*/
|
|
104
|
+
async typeOutRemaining(params) {
|
|
105
|
+
const already = this.streamedLengths.get(params.outTrackId) ?? 0;
|
|
106
|
+
if (params.content.length <= already + 1)
|
|
107
|
+
return;
|
|
108
|
+
const remaining = params.content.length - already;
|
|
109
|
+
const frames = Math.max(1, Math.min(params.maxFrames ?? 8, remaining));
|
|
110
|
+
const step = Math.max(1, Math.ceil(remaining / frames));
|
|
111
|
+
const delayMs = Math.max(0, Math.floor((params.maxDurationMs ?? 1_200) / frames));
|
|
112
|
+
for (let end = already + step; end < params.content.length; end += step) {
|
|
113
|
+
await this.stream({ outTrackId: params.outTrackId, contentKey: params.contentKey, content: params.content.slice(0, end) });
|
|
114
|
+
if (delayMs > 0)
|
|
115
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* 按 key 更新卡片变量(不覆盖其它变量)。用于完成后把标题改成
|
|
120
|
+
* 「【Pi】完成 总耗时 15s」这类状态文案。
|
|
121
|
+
*/
|
|
122
|
+
async updateTitle(params) {
|
|
123
|
+
await this.updateCardData({ outTrackId: params.outTrackId, data: { title: params.title } });
|
|
124
|
+
log.info(`ai card title updated outTrackId=${params.outTrackId} title=${params.title}`);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* 完成后把「模型 x条消息 y次工具」这类结束语写到模板的 $end_text,
|
|
128
|
+
* 不再拼接到正文里。
|
|
129
|
+
*/
|
|
130
|
+
async setEndText(params) {
|
|
131
|
+
await this.updateCardData({ outTrackId: params.outTrackId, data: { [END_TEXT_KEY]: params.text } });
|
|
132
|
+
log.info(`ai card end_text updated outTrackId=${params.outTrackId} len=${params.text.length}`);
|
|
133
|
+
}
|
|
134
|
+
/** 按 key 更新一个或多个卡片变量,其它变量保持不变。 */
|
|
135
|
+
async updateCardData(params) {
|
|
136
|
+
await this.callWithRetry("PUT", "/v1.0/card/instances", {
|
|
137
|
+
outTrackId: params.outTrackId,
|
|
138
|
+
cardData: { cardParamMap: params.data },
|
|
139
|
+
cardUpdateOptions: { updateCardDataByKey: true },
|
|
140
|
+
userIdType: 1,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
async createAndDeliver(body) {
|
|
144
|
+
await this.call("POST", "/v1.0/card/instances/createAndDeliver", body);
|
|
145
|
+
}
|
|
146
|
+
/** 对 5xx / 网络错误做指数退避重试;4xx 业务错误不重试。 */
|
|
147
|
+
async callWithRetry(method, path, body, attempts = 3) {
|
|
148
|
+
let lastError;
|
|
149
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
150
|
+
try {
|
|
151
|
+
return await this.call(method, path, body);
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
lastError = err;
|
|
155
|
+
if (attempt === attempts - 1 || !isRetryableCardError(err))
|
|
156
|
+
throw err;
|
|
157
|
+
const delayMs = 250 * 2 ** attempt;
|
|
158
|
+
log.warn(`ai card API retry ${attempt + 1}/${attempts - 1} in ${delayMs}ms: ${err instanceof Error ? err.message : String(err)}`);
|
|
159
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
throw lastError;
|
|
163
|
+
}
|
|
164
|
+
async call(method, path, body) {
|
|
165
|
+
const accessToken = await this.getAccessToken();
|
|
166
|
+
const response = await fetch(`${apiBase}${path}`, {
|
|
167
|
+
method,
|
|
168
|
+
headers: {
|
|
169
|
+
"content-type": "application/json",
|
|
170
|
+
"x-acs-dingtalk-access-token": accessToken,
|
|
171
|
+
},
|
|
172
|
+
body: JSON.stringify(body),
|
|
173
|
+
signal: AbortSignal.timeout(30_000),
|
|
174
|
+
});
|
|
175
|
+
const text = await response.text();
|
|
176
|
+
if (!response.ok)
|
|
177
|
+
throw new Error(`DingTalk AI card API failed: ${response.status} ${text.slice(0, 1_000)}`);
|
|
178
|
+
if (!text)
|
|
179
|
+
return {};
|
|
180
|
+
const result = JSON.parse(text);
|
|
181
|
+
const code = result.errcode ?? result.errorCode ?? result.code;
|
|
182
|
+
if ((typeof code === "number" && code !== 0) ||
|
|
183
|
+
(typeof code === "string" && code && code !== "0" && code.toLowerCase() !== "ok") ||
|
|
184
|
+
result.success === false) {
|
|
185
|
+
throw new Error(`DingTalk AI card business error: ${text.slice(0, 1_000)}`);
|
|
186
|
+
}
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
async getAccessToken() {
|
|
190
|
+
if (this.accessToken && Date.now() < this.accessTokenExpiresAt)
|
|
191
|
+
return this.accessToken;
|
|
192
|
+
if (!this.clientId || !this.clientSecret)
|
|
193
|
+
throw new Error("DingTalk app credentials are missing");
|
|
194
|
+
const response = await fetch(`${apiBase}/v1.0/oauth2/accessToken`, {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: { "content-type": "application/json" },
|
|
197
|
+
body: JSON.stringify({ appKey: this.clientId, appSecret: this.clientSecret }),
|
|
198
|
+
signal: AbortSignal.timeout(30_000),
|
|
199
|
+
});
|
|
200
|
+
const text = await response.text();
|
|
201
|
+
if (!response.ok)
|
|
202
|
+
throw new Error(`DingTalk token API failed: ${response.status}`);
|
|
203
|
+
const result = JSON.parse(text);
|
|
204
|
+
if (!result.accessToken)
|
|
205
|
+
throw new Error("DingTalk token API returned no accessToken");
|
|
206
|
+
this.accessToken = result.accessToken;
|
|
207
|
+
this.accessTokenExpiresAt = Date.now() + Math.max((result.expireIn ?? 7200) - 120, 60) * 1_000;
|
|
208
|
+
return result.accessToken;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import { basename, extname, join, resolve } from "node:path";
|
|
3
3
|
import { DWClient, TOPIC_ROBOT } from "dingtalk-stream";
|
|
4
|
-
import { createLogger } from "
|
|
4
|
+
import { createLogger } from "../core/logger.js";
|
|
5
5
|
const log = createLogger("DingTalk");
|
|
6
6
|
function safePreview(value, limit = 160) {
|
|
7
7
|
const text = typeof value === "string" ? value : JSON.stringify(value) ?? String(value);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DingTalk 的互动卡片与机器人消息使用钉钉自有的 Markdown 渲染器,它只支持
|
|
3
|
+
* 标题、加粗、列表、链接、引用和分割线等有限语法,**不支持 GitHub 风格的表格**。
|
|
4
|
+
* 因此 Agent 输出的 `| a | b |` 表格会被原样显示成一堆竖线,在手机端尤其难读。
|
|
5
|
+
*
|
|
6
|
+
* 这里把表格块转换成移动端/桌面端都好读的紧凑结构:行内用第一列序号 +
|
|
7
|
+
* 名称类列做强标题,其余字段用 ` | ` 拼成一行;字段过多或过长时才退化成列表。
|
|
8
|
+
*
|
|
9
|
+
* | 排名 | 次数 | UID | 昵称 | 收礼主播 |
|
|
10
|
+
* |---|---|---|---|---|
|
|
11
|
+
* | 1 | 3 | 778339247 | 不语 | Dh·幽月 |
|
|
12
|
+
*
|
|
13
|
+
* 变成:
|
|
14
|
+
*
|
|
15
|
+
* **1. 不语**
|
|
16
|
+
* 次数:3 | UID:778339247 | 收礼主播:Dh·幽月
|
|
17
|
+
*/
|
|
18
|
+
const INDEX_HEADER = /^(#|序号|编号|排名|名次|排位|no\.?|index|id|rank)$/i;
|
|
19
|
+
const NAME_HEADER = /^(昵称|名称|名字|用户|用户名|用户昵称|主播|收礼主播|送礼用户|送礼人|项目|项目名|活动|活动名|name|user|nick|nickname|title)$/i;
|
|
20
|
+
// 表格分隔行:支持 - / – / — 三种横线,以及可选的居中对齐冒号。
|
|
21
|
+
const SEPARATOR_CELL = /^:?[-\u2013\u2014]{1,}:?$/;
|
|
22
|
+
function splitRow(line) {
|
|
23
|
+
let text = line.trim();
|
|
24
|
+
if (text.startsWith("|"))
|
|
25
|
+
text = text.slice(1);
|
|
26
|
+
if (text.endsWith("|"))
|
|
27
|
+
text = text.slice(0, -1);
|
|
28
|
+
return text.split("|").map((cell) => cell.trim());
|
|
29
|
+
}
|
|
30
|
+
function isSeparatorRow(line) {
|
|
31
|
+
const cells = splitRow(line);
|
|
32
|
+
return cells.length > 0 && cells.every((cell) => SEPARATOR_CELL.test(cell));
|
|
33
|
+
}
|
|
34
|
+
function looksLikeTableRow(line) {
|
|
35
|
+
return line.includes("|") && line.trim().length > 0;
|
|
36
|
+
}
|
|
37
|
+
function renderTable(header, rows) {
|
|
38
|
+
// 序号列:优先识别常见的排序列,否则回退到第一列。
|
|
39
|
+
const indexColumn = header.findIndex((cell, column) => Boolean(cell) && INDEX_HEADER.test(cell));
|
|
40
|
+
// 标题列:优先用名称类列(昵称/项目等),否则用第一个非序号列。
|
|
41
|
+
let titleColumn = header.findIndex((cell, column) => column !== indexColumn && Boolean(cell) && NAME_HEADER.test(cell));
|
|
42
|
+
if (titleColumn < 0)
|
|
43
|
+
titleColumn = header.findIndex((cell, column) => column !== indexColumn && Boolean(cell));
|
|
44
|
+
if (titleColumn < 0)
|
|
45
|
+
titleColumn = indexColumn >= 0 ? indexColumn : 0;
|
|
46
|
+
const blocks = rows.map((row) => {
|
|
47
|
+
const title = (row[titleColumn] ?? "").trim() || "(空)";
|
|
48
|
+
const indexValue = indexColumn >= 0 ? (row[indexColumn] ?? "").trim() : "";
|
|
49
|
+
const prefix = indexColumn >= 0 && indexColumn !== titleColumn && indexValue ? `${indexValue}. ` : "";
|
|
50
|
+
const lines = [`**${prefix}${title}**`];
|
|
51
|
+
const fields = [];
|
|
52
|
+
header.forEach((name, column) => {
|
|
53
|
+
if (column === indexColumn || column === titleColumn)
|
|
54
|
+
return;
|
|
55
|
+
const value = (row[column] ?? "").trim();
|
|
56
|
+
if (!value)
|
|
57
|
+
return;
|
|
58
|
+
fields.push(`${name || `列${column + 1}`}:${value}`);
|
|
59
|
+
});
|
|
60
|
+
if (fields.length > 0) {
|
|
61
|
+
const joined = fields.join(" | ");
|
|
62
|
+
// 字段少且不长时并成一行,卡片更紧凑;否则退回列表便于逐条阅读。
|
|
63
|
+
if (fields.length <= 4 && joined.length <= 60)
|
|
64
|
+
lines.push(joined);
|
|
65
|
+
else
|
|
66
|
+
lines.push(...fields.map((field) => `- ${field}`));
|
|
67
|
+
}
|
|
68
|
+
return lines.join("\n");
|
|
69
|
+
});
|
|
70
|
+
return blocks.join("\n\n");
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 把内容中的 Markdown 表格转成钉钉可读的列表,其它内容保持不变。
|
|
74
|
+
* 代码块(``` 或 ~~~)内的竖线不会被处理。
|
|
75
|
+
*/
|
|
76
|
+
export function normalizeDingTalkMarkdown(content) {
|
|
77
|
+
const lines = content.replace(/\r\n?/g, "\n").split("\n");
|
|
78
|
+
const output = [];
|
|
79
|
+
let inFence = false;
|
|
80
|
+
let cursor = 0;
|
|
81
|
+
while (cursor < lines.length) {
|
|
82
|
+
const line = lines[cursor];
|
|
83
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
84
|
+
inFence = !inFence;
|
|
85
|
+
output.push(line);
|
|
86
|
+
cursor += 1;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// 表头行 + 分隔行才算一个表格,且列数要一致,避免误伤正文中的单个竖线
|
|
90
|
+
// 或紧跟在横线下方的普通文本。
|
|
91
|
+
if (!inFence && looksLikeTableRow(line) && cursor + 1 < lines.length && isSeparatorRow(lines[cursor + 1])) {
|
|
92
|
+
const header = splitRow(line);
|
|
93
|
+
const separator = splitRow(lines[cursor + 1]);
|
|
94
|
+
if (separator.length !== header.length) {
|
|
95
|
+
output.push(line);
|
|
96
|
+
cursor += 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const rows = [];
|
|
100
|
+
cursor += 2;
|
|
101
|
+
while (cursor < lines.length && looksLikeTableRow(lines[cursor]) && !isSeparatorRow(lines[cursor])) {
|
|
102
|
+
rows.push(splitRow(lines[cursor]));
|
|
103
|
+
cursor += 1;
|
|
104
|
+
}
|
|
105
|
+
output.push(renderTable(header, rows));
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
output.push(line);
|
|
109
|
+
cursor += 1;
|
|
110
|
+
}
|
|
111
|
+
return output.join("\n");
|
|
112
|
+
}
|
|
@@ -177,6 +177,20 @@ export async function listGroupMembers(groupId) {
|
|
|
177
177
|
return senderId && senderName ? [{ senderId, senderName, role: user.role }] : [];
|
|
178
178
|
});
|
|
179
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* 机器人成员与真人成员使用同一套 openDingtalkId,可用于判断群消息是否由
|
|
182
|
+
* 群内机器人(AI)发出。返回的 senderId 与事件里的 sender_open_dingtalk_id 可直接比较。
|
|
183
|
+
*/
|
|
184
|
+
export async function listGroupBotMembers(groupId) {
|
|
185
|
+
const result = await runDwsJson([
|
|
186
|
+
"chat", "+chat-members-list", "--conversation-id", groupId, "--member-types", "bot",
|
|
187
|
+
]);
|
|
188
|
+
return (result.bots ?? []).flatMap((bot) => {
|
|
189
|
+
const senderId = (bot.openDingtalkId || bot.openDingTalkId)?.trim();
|
|
190
|
+
const senderName = bot.name?.trim();
|
|
191
|
+
return senderId && senderName ? [{ senderId, senderName }] : [];
|
|
192
|
+
});
|
|
193
|
+
}
|
|
180
194
|
export async function listConversations() {
|
|
181
195
|
const result = await runDwsJson([
|
|
182
196
|
"chat", "+chat-list", "--types", "group", "--page-size", "20",
|
|
@@ -2,7 +2,7 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { getCurrentDwsUser, searchRecentGroupMessages } from "./dws-client.js";
|
|
5
|
-
import { createLogger } from "
|
|
5
|
+
import { createLogger } from "../core/logger.js";
|
|
6
6
|
const log = createLogger("personal_history");
|
|
7
7
|
const DEFAULT_INTERVAL_SECONDS = 15;
|
|
8
8
|
const DEFAULT_LOOKBACK_MINUTES = 10;
|