oh-my-im 0.1.0 → 0.1.2
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 +5 -3
- package/dist/cli.js +0 -0
- package/dist/codex.js +25 -8
- package/dist/config.js +23 -29
- package/dist/dingtalk-card.js +116 -0
- package/dist/dingtalk.js +285 -29
- package/dist/dws-dashboard.js +126 -0
- package/dist/dws-listener.js +858 -0
- package/dist/index.js +131 -13
- package/dist/monitor-command.js +37 -0
- package/dist/omi-bot.js +74 -0
- package/dist/omi.js +181 -0
- package/package.json +8 -5
package/README.md
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npm install
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
npm run build
|
|
10
|
+
npm link
|
|
11
|
+
omi
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
+
首次运行会在 `~/.oh-my-im` 创建本地配置;在管理页填写钉钉应用凭证、钉钉规则和机器人单聊授权人员即可。`omi` 的状态、日志和全部运行配置都固定保存在此目录,因此可在任意目录执行 `omi status`、`omi stop` 或 `omi update`。
|
|
15
|
+
|
|
14
16
|
详见 [docs/development.md](docs/development.md)。
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/codex.js
CHANGED
|
@@ -40,14 +40,18 @@ function buildArgs(prompt, workDir, sessionId, config) {
|
|
|
40
40
|
if (config.codexPermissionMode === "bypass") {
|
|
41
41
|
common.push("--dangerously-bypass-approvals-and-sandbox");
|
|
42
42
|
}
|
|
43
|
+
else if (config.codexPermissionMode === "read-only") {
|
|
44
|
+
common.push("--sandbox", "read-only");
|
|
45
|
+
}
|
|
43
46
|
else {
|
|
44
47
|
common.push("--full-auto");
|
|
45
48
|
}
|
|
49
|
+
const modelOptions = config.codexModel ? ["--model", config.codexModel] : [];
|
|
46
50
|
return sessionId
|
|
47
|
-
? ["exec", "resume", ...common, sessionId, "-"]
|
|
48
|
-
: ["exec", ...common, "--cd", workDir, "-"];
|
|
51
|
+
? ["exec", "resume", ...common, ...modelOptions, sessionId, "-"]
|
|
52
|
+
: ["exec", ...common, ...modelOptions, "--cd", workDir, "-"];
|
|
49
53
|
}
|
|
50
|
-
export function runCodex(prompt, sessionId, config) {
|
|
54
|
+
export function runCodex(prompt, sessionId, config, callbacks = {}) {
|
|
51
55
|
return new Promise((resolve, reject) => {
|
|
52
56
|
const start = Date.now();
|
|
53
57
|
const args = buildArgs(prompt, config.codexWorkDir, sessionId, config);
|
|
@@ -83,8 +87,20 @@ export function runCodex(prompt, sessionId, config) {
|
|
|
83
87
|
child.stdin.write(prompt);
|
|
84
88
|
child.stdin.end();
|
|
85
89
|
child.stderr.on("data", (chunk) => {
|
|
86
|
-
|
|
90
|
+
const text = chunk.toString();
|
|
91
|
+
stderr += text;
|
|
92
|
+
for (const line of text.split(/\r?\n/).map((item) => item.trim()).filter(Boolean)) {
|
|
93
|
+
log.debug(`stderr: ${line.slice(0, 1_000)}`);
|
|
94
|
+
}
|
|
87
95
|
});
|
|
96
|
+
const fail = (message) => {
|
|
97
|
+
if (completed)
|
|
98
|
+
return;
|
|
99
|
+
completed = true;
|
|
100
|
+
clearTimeout(timeout);
|
|
101
|
+
child.kill("SIGTERM");
|
|
102
|
+
reject(new Error(message));
|
|
103
|
+
};
|
|
88
104
|
const rl = createInterface({ input: child.stdout });
|
|
89
105
|
rl.on("line", (line) => {
|
|
90
106
|
const event = parseJsonLine(line);
|
|
@@ -104,9 +120,7 @@ export function runCodex(prompt, sessionId, config) {
|
|
|
104
120
|
const err = payload.error;
|
|
105
121
|
const message = err?.message ?? (typeof payload.message === "string" ? payload.message : "Codex failed");
|
|
106
122
|
log.error(message);
|
|
107
|
-
|
|
108
|
-
clearTimeout(timeout);
|
|
109
|
-
reject(new Error(message));
|
|
123
|
+
fail(message);
|
|
110
124
|
return;
|
|
111
125
|
}
|
|
112
126
|
if (event.type === "response_item" || type === "item.started" || type === "item.updated" || type === "item.completed") {
|
|
@@ -116,11 +130,14 @@ export function runCodex(prompt, sessionId, config) {
|
|
|
116
130
|
const name = typeof item.name === "string" ? item.name : "tool";
|
|
117
131
|
toolStats[name] = (toolStats[name] ?? 0) + 1;
|
|
118
132
|
log.debug(`tool=${name}`);
|
|
133
|
+
callbacks.onToolUse?.(name, { ...toolStats });
|
|
119
134
|
return;
|
|
120
135
|
}
|
|
121
136
|
const text = extractText(item);
|
|
122
|
-
if (text)
|
|
137
|
+
if (text) {
|
|
123
138
|
accumulated += (accumulated ? "\n\n" : "") + text;
|
|
139
|
+
callbacks.onText?.(accumulated);
|
|
140
|
+
}
|
|
124
141
|
return;
|
|
125
142
|
}
|
|
126
143
|
if (type === "task_complete" || type === "turn.completed") {
|
package/dist/config.js
CHANGED
|
@@ -1,39 +1,33 @@
|
|
|
1
|
-
import "
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if (!value)
|
|
12
|
-
return [];
|
|
13
|
-
return value
|
|
14
|
-
.split(",")
|
|
15
|
-
.map((item) => item.trim())
|
|
16
|
-
.filter(Boolean);
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
function loadLocalBotConfig() {
|
|
4
|
+
const path = join(process.cwd(), ".oh-my-im", "dws-dashboard.json");
|
|
5
|
+
try {
|
|
6
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
throw new Error("未找到 .oh-my-im/dws-dashboard.json,请先启动 omi 并在管理页配置钉钉应用凭证");
|
|
10
|
+
}
|
|
17
11
|
}
|
|
18
12
|
function resolveWorkDir() {
|
|
19
|
-
const
|
|
20
|
-
const dir = configured ? resolve(configured) : process.cwd();
|
|
13
|
+
const dir = resolve(process.cwd());
|
|
21
14
|
if (!existsSync(dir))
|
|
22
|
-
throw new Error(`
|
|
15
|
+
throw new Error(`Codex work directory does not exist: ${dir}`);
|
|
23
16
|
return dir;
|
|
24
17
|
}
|
|
25
18
|
export function loadConfig() {
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
19
|
+
const local = loadLocalBotConfig();
|
|
20
|
+
const clientId = local.clientId?.trim();
|
|
21
|
+
const clientSecret = local.clientSecret?.trim();
|
|
22
|
+
if (!clientId || !clientSecret)
|
|
23
|
+
throw new Error("请先在管理页填写钉钉应用 Client ID 和 Client Secret");
|
|
29
24
|
return {
|
|
30
|
-
dingtalkClientId:
|
|
31
|
-
dingtalkClientSecret:
|
|
32
|
-
codexCliPath:
|
|
25
|
+
dingtalkClientId: clientId,
|
|
26
|
+
dingtalkClientSecret: clientSecret,
|
|
27
|
+
codexCliPath: "codex",
|
|
33
28
|
codexWorkDir: resolveWorkDir(),
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
cliTimeoutMs: Number.isFinite(timeout) && timeout > 0 ? timeout : 30 * 60 * 1000,
|
|
29
|
+
codexPermissionMode: "bypass",
|
|
30
|
+
allowedUserIds: [...new Set((local.botAllowedUserIds ?? []).map((id) => id.trim()).filter(Boolean))],
|
|
31
|
+
cliTimeoutMs: 30 * 60 * 1000,
|
|
38
32
|
};
|
|
39
33
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createLogger } from "./logger.js";
|
|
2
|
+
const log = createLogger("DingTalkCard");
|
|
3
|
+
const apiBase = "https://api.dingtalk.com";
|
|
4
|
+
function buildCardData(title, content) {
|
|
5
|
+
return JSON.stringify({
|
|
6
|
+
config: { autoLayout: true, enableForward: true },
|
|
7
|
+
header: {
|
|
8
|
+
title: { type: "text", text: title.trim() || "映客活动AI" },
|
|
9
|
+
logo: "@lALPDfJ6V_FPDmvNAfTNAfQ",
|
|
10
|
+
},
|
|
11
|
+
contents: [{ type: "markdown", text: content.trim() || "处理中...", id: "content" }],
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export class DingTalkCardClient {
|
|
15
|
+
accessToken;
|
|
16
|
+
accessTokenExpiresAt = 0;
|
|
17
|
+
robotCode;
|
|
18
|
+
clientId;
|
|
19
|
+
clientSecret;
|
|
20
|
+
constructor(clientId = "", clientSecret = "", robotCode = "dingn9wrup8mqq1ptabn") {
|
|
21
|
+
this.clientId = clientId;
|
|
22
|
+
this.clientSecret = clientSecret;
|
|
23
|
+
this.robotCode = robotCode;
|
|
24
|
+
}
|
|
25
|
+
setCredentials(clientId, clientSecret) {
|
|
26
|
+
const nextClientId = clientId.trim();
|
|
27
|
+
const nextClientSecret = clientSecret.trim();
|
|
28
|
+
if (this.clientId === nextClientId && this.clientSecret === nextClientSecret)
|
|
29
|
+
return;
|
|
30
|
+
this.clientId = nextClientId;
|
|
31
|
+
this.clientSecret = nextClientSecret;
|
|
32
|
+
this.accessToken = undefined;
|
|
33
|
+
this.accessTokenExpiresAt = 0;
|
|
34
|
+
}
|
|
35
|
+
setRobotCode(robotCode) {
|
|
36
|
+
this.robotCode = robotCode.trim();
|
|
37
|
+
}
|
|
38
|
+
get enabled() {
|
|
39
|
+
return Boolean(this.clientId && this.clientSecret && this.robotCode);
|
|
40
|
+
}
|
|
41
|
+
async create(groupId, cardBizId, title, content) {
|
|
42
|
+
if (!this.enabled) {
|
|
43
|
+
throw new Error("映客活动AI 卡片未启用:DINGTALK_CLIENT_ID 或 DINGTALK_CLIENT_SECRET 缺失");
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
await this.call("POST", "/v1.0/im/v1.0/robot/interactiveCards/send", {
|
|
47
|
+
cardTemplateId: "StandardCard",
|
|
48
|
+
cardBizId,
|
|
49
|
+
outTrackId: cardBizId,
|
|
50
|
+
robotCode: this.robotCode,
|
|
51
|
+
openConversationId: groupId,
|
|
52
|
+
cardData: buildCardData(title, content),
|
|
53
|
+
});
|
|
54
|
+
log.info(`card started group=${groupId} card=${cardBizId}`);
|
|
55
|
+
return { groupId, cardBizId };
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
59
|
+
log.error(`card start failed: ${message}`);
|
|
60
|
+
throw new Error(`映客活动AI 卡片发送失败:${message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async update(handle, title, content) {
|
|
64
|
+
await this.call("PUT", "/v1.0/im/robots/interactiveCards", {
|
|
65
|
+
cardBizId: handle.cardBizId,
|
|
66
|
+
cardData: buildCardData(title, content),
|
|
67
|
+
});
|
|
68
|
+
log.debug(`card updated group=${handle.groupId} card=${handle.cardBizId}`);
|
|
69
|
+
}
|
|
70
|
+
async call(method, path, body) {
|
|
71
|
+
const accessToken = await this.getAccessToken();
|
|
72
|
+
const response = await fetch(`${apiBase}${path}`, {
|
|
73
|
+
method,
|
|
74
|
+
headers: {
|
|
75
|
+
"content-type": "application/json",
|
|
76
|
+
"x-acs-dingtalk-access-token": accessToken,
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify(body),
|
|
79
|
+
signal: AbortSignal.timeout(30_000),
|
|
80
|
+
});
|
|
81
|
+
const text = await response.text();
|
|
82
|
+
if (!response.ok)
|
|
83
|
+
throw new Error(`DingTalk card API failed: ${response.status} ${text}`);
|
|
84
|
+
if (!text)
|
|
85
|
+
return {};
|
|
86
|
+
const result = JSON.parse(text);
|
|
87
|
+
const code = result.errcode ?? result.errorCode ?? result.code;
|
|
88
|
+
if ((typeof code === "number" && code !== 0) ||
|
|
89
|
+
(typeof code === "string" && code && code !== "0" && code.toLowerCase() !== "ok") ||
|
|
90
|
+
result.success === false) {
|
|
91
|
+
throw new Error(`DingTalk card business error: ${text.slice(0, 1_000)}`);
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
async getAccessToken() {
|
|
96
|
+
if (this.accessToken && Date.now() < this.accessTokenExpiresAt)
|
|
97
|
+
return this.accessToken;
|
|
98
|
+
if (!this.clientId || !this.clientSecret)
|
|
99
|
+
throw new Error("DingTalk app credentials are missing");
|
|
100
|
+
const response = await fetch(`${apiBase}/v1.0/oauth2/accessToken`, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: { "content-type": "application/json" },
|
|
103
|
+
body: JSON.stringify({ appKey: this.clientId, appSecret: this.clientSecret }),
|
|
104
|
+
signal: AbortSignal.timeout(30_000),
|
|
105
|
+
});
|
|
106
|
+
const text = await response.text();
|
|
107
|
+
if (!response.ok)
|
|
108
|
+
throw new Error(`DingTalk token API failed: ${response.status}`);
|
|
109
|
+
const result = JSON.parse(text);
|
|
110
|
+
if (!result.accessToken)
|
|
111
|
+
throw new Error("DingTalk token API returned no accessToken");
|
|
112
|
+
this.accessToken = result.accessToken;
|
|
113
|
+
this.accessTokenExpiresAt = Date.now() + Math.max((result.expireIn ?? 7200) - 120, 60) * 1000;
|
|
114
|
+
return result.accessToken;
|
|
115
|
+
}
|
|
116
|
+
}
|
package/dist/dingtalk.js
CHANGED
|
@@ -1,6 +1,165 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
1
3
|
import { DWClient, TOPIC_ROBOT } from "dingtalk-stream";
|
|
2
4
|
import { createLogger } from "./logger.js";
|
|
3
5
|
const log = createLogger("DingTalk");
|
|
6
|
+
function safePreview(value, limit = 160) {
|
|
7
|
+
const text = typeof value === "string" ? value : JSON.stringify(value) ?? String(value);
|
|
8
|
+
return text.length > limit ? `${text.slice(0, limit)}...` : text;
|
|
9
|
+
}
|
|
10
|
+
export function isSingleConversation(type) {
|
|
11
|
+
const normalized = type?.trim().toLowerCase();
|
|
12
|
+
return normalized === "0" || normalized === "single" || normalized === "singlechat" || normalized === "oto";
|
|
13
|
+
}
|
|
14
|
+
function buildStandardCardData(title, content) {
|
|
15
|
+
const safeTitle = title.trim() || "Codex";
|
|
16
|
+
const safeContent = content.trim() || "...";
|
|
17
|
+
return JSON.stringify({
|
|
18
|
+
config: { autoLayout: true, enableForward: true },
|
|
19
|
+
header: {
|
|
20
|
+
title: { type: "text", text: safeTitle },
|
|
21
|
+
logo: "@lALPDfJ6V_FPDmvNAfTNAfQ",
|
|
22
|
+
},
|
|
23
|
+
contents: [
|
|
24
|
+
{ type: "markdown", text: safeContent, id: "content" },
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export function parseDingTalkMessage(data) {
|
|
29
|
+
let payload;
|
|
30
|
+
try {
|
|
31
|
+
payload = JSON.parse(data.data);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
log.warn(`Failed to parse DingTalk payload: ${safePreview(data.data, 240)}`);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const conversationId = payload.conversationId;
|
|
38
|
+
const sessionWebhook = payload.sessionWebhook;
|
|
39
|
+
const senderStaffId = payload.senderStaffId;
|
|
40
|
+
const senderId = payload.senderId;
|
|
41
|
+
const conversationType = payload.conversationType;
|
|
42
|
+
const robotCode = payload.robotCode;
|
|
43
|
+
const msgtype = payload.msgtype;
|
|
44
|
+
const textPayload = asRecord(payload.text);
|
|
45
|
+
const contentPayload = asRecord(payload.content);
|
|
46
|
+
if (typeof conversationId !== "string" ||
|
|
47
|
+
typeof sessionWebhook !== "string" ||
|
|
48
|
+
typeof senderId !== "string" ||
|
|
49
|
+
typeof msgtype !== "string") {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const text = extractText(msgtype, textPayload, contentPayload);
|
|
53
|
+
const attachments = extractAttachments(msgtype, contentPayload, payload);
|
|
54
|
+
return {
|
|
55
|
+
callbackId: data.headers.messageId,
|
|
56
|
+
conversationId,
|
|
57
|
+
conversationType: typeof conversationType === "string" ? conversationType : undefined,
|
|
58
|
+
sessionWebhook,
|
|
59
|
+
senderId,
|
|
60
|
+
senderStaffId: typeof senderStaffId === "string" ? senderStaffId : undefined,
|
|
61
|
+
senderNick: typeof payload.senderNick === "string" ? payload.senderNick : undefined,
|
|
62
|
+
robotCode: typeof robotCode === "string" ? robotCode : undefined,
|
|
63
|
+
text,
|
|
64
|
+
msgtype,
|
|
65
|
+
attachments,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function extractText(msgtype, textPayload, contentPayload) {
|
|
69
|
+
const directText = asString(textPayload.content) ?? asString(contentPayload.text);
|
|
70
|
+
if (directText)
|
|
71
|
+
return directText;
|
|
72
|
+
if (msgtype === "audio" || msgtype === "voice")
|
|
73
|
+
return asString(contentPayload.recognition) ?? "";
|
|
74
|
+
if (msgtype === "richText") {
|
|
75
|
+
const richText = contentPayload.richText;
|
|
76
|
+
if (!Array.isArray(richText))
|
|
77
|
+
return "";
|
|
78
|
+
return richText
|
|
79
|
+
.map((item) => asString(asRecord(item).text) ?? "")
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.join("\n");
|
|
82
|
+
}
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
85
|
+
function extractAttachments(msgtype, contentPayload, payload) {
|
|
86
|
+
const attachments = [];
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
const append = (typeValue, item) => {
|
|
89
|
+
const type = normalizeAttachmentType(typeValue);
|
|
90
|
+
const downloadCode = asString(item.downloadCode) ?? asString(item.pictureDownloadCode);
|
|
91
|
+
if (!type || !downloadCode)
|
|
92
|
+
return;
|
|
93
|
+
if (seen.has(downloadCode))
|
|
94
|
+
return;
|
|
95
|
+
seen.add(downloadCode);
|
|
96
|
+
attachments.push({
|
|
97
|
+
type,
|
|
98
|
+
downloadCode,
|
|
99
|
+
fileName: asString(item.fileName) ?? asString(item.name),
|
|
100
|
+
duration: asNumber(item.duration),
|
|
101
|
+
recognition: asString(item.recognition),
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
append(msgtype, contentPayload);
|
|
105
|
+
append(msgtype, asRecord(payload));
|
|
106
|
+
if (msgtype === "richText" && Array.isArray(contentPayload.richText)) {
|
|
107
|
+
for (const item of contentPayload.richText) {
|
|
108
|
+
const record = asRecord(item);
|
|
109
|
+
append(asString(record.type) ?? "picture", record);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return attachments;
|
|
113
|
+
}
|
|
114
|
+
function asRecord(value) {
|
|
115
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
116
|
+
}
|
|
117
|
+
function asString(value) {
|
|
118
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
119
|
+
}
|
|
120
|
+
function asNumber(value) {
|
|
121
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
122
|
+
return value;
|
|
123
|
+
if (typeof value === "string") {
|
|
124
|
+
const parsed = Number.parseInt(value, 10);
|
|
125
|
+
if (Number.isFinite(parsed))
|
|
126
|
+
return parsed;
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
function normalizeAttachmentType(value) {
|
|
131
|
+
const normalized = value.trim().toLowerCase();
|
|
132
|
+
if (normalized === "picture" || normalized === "image")
|
|
133
|
+
return "picture";
|
|
134
|
+
if (normalized === "audio" || normalized === "voice")
|
|
135
|
+
return "audio";
|
|
136
|
+
if (normalized === "video")
|
|
137
|
+
return "video";
|
|
138
|
+
if (normalized === "file")
|
|
139
|
+
return "file";
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
function fileExtension(attachment, contentType) {
|
|
143
|
+
const fromName = attachment.fileName ? extname(attachment.fileName) : "";
|
|
144
|
+
if (fromName)
|
|
145
|
+
return fromName;
|
|
146
|
+
if (attachment.type === "picture") {
|
|
147
|
+
if (contentType?.includes("png"))
|
|
148
|
+
return ".png";
|
|
149
|
+
if (contentType?.includes("webp"))
|
|
150
|
+
return ".webp";
|
|
151
|
+
return ".jpg";
|
|
152
|
+
}
|
|
153
|
+
if (attachment.type === "audio")
|
|
154
|
+
return ".amr";
|
|
155
|
+
if (attachment.type === "video")
|
|
156
|
+
return ".mp4";
|
|
157
|
+
return ".bin";
|
|
158
|
+
}
|
|
159
|
+
function sanitizeFileName(name) {
|
|
160
|
+
const clean = basename(name).replace(/[^\w.-]+/g, "_");
|
|
161
|
+
return clean || "attachment";
|
|
162
|
+
}
|
|
4
163
|
export class DingTalkBot {
|
|
5
164
|
config;
|
|
6
165
|
client = null;
|
|
@@ -19,9 +178,11 @@ export class DingTalkBot {
|
|
|
19
178
|
const message = this.parseMessage(data);
|
|
20
179
|
if (!message) {
|
|
21
180
|
log.warn("Ignored invalid DingTalk payload");
|
|
181
|
+
log.debug(`downstream topic=${data.headers.topic} messageId=${data.headers.messageId} dataLen=${data.data.length}`);
|
|
22
182
|
this.ack(data.headers.messageId, { ignored: true });
|
|
23
183
|
return;
|
|
24
184
|
}
|
|
185
|
+
log.info(`incoming conversation=${message.conversationId} msgtype=${message.msgtype} sender=${message.senderStaffId ?? message.senderId} textLen=${message.text.length} attachments=${message.attachments.length} hasWebhook=${Boolean(message.sessionWebhook)} hasRobotCode=${Boolean(message.robotCode)}`);
|
|
25
186
|
try {
|
|
26
187
|
await onMessage(message);
|
|
27
188
|
this.ack(message.callbackId, { handled: true });
|
|
@@ -41,9 +202,12 @@ export class DingTalkBot {
|
|
|
41
202
|
}
|
|
42
203
|
async sendText(conversationId, content) {
|
|
43
204
|
const webhook = this.webhooks.get(conversationId);
|
|
44
|
-
if (!webhook)
|
|
205
|
+
if (!webhook) {
|
|
206
|
+
log.warn(`No sessionWebhook for conversation=${conversationId}`);
|
|
45
207
|
throw new Error(`No sessionWebhook for conversation: ${conversationId}`);
|
|
208
|
+
}
|
|
46
209
|
const accessToken = await this.getAccessToken();
|
|
210
|
+
log.debug(`replying conversation=${conversationId} contentLen=${content.length}`);
|
|
47
211
|
const res = await fetch(webhook, {
|
|
48
212
|
method: "POST",
|
|
49
213
|
headers: {
|
|
@@ -58,43 +222,135 @@ export class DingTalkBot {
|
|
|
58
222
|
});
|
|
59
223
|
if (!res.ok) {
|
|
60
224
|
const text = await res.text();
|
|
225
|
+
log.warn(`reply failed conversation=${conversationId} status=${res.status} body=${safePreview(text)}`);
|
|
61
226
|
throw new Error(`DingTalk reply failed: ${res.status} ${text}`);
|
|
62
227
|
}
|
|
63
|
-
log.debug(`
|
|
228
|
+
log.debug(`reply ok conversation=${conversationId} status=${res.status}`);
|
|
229
|
+
}
|
|
230
|
+
async sendThinkingCard(message, content) {
|
|
231
|
+
if (!message.robotCode) {
|
|
232
|
+
await this.sendText(message.conversationId, content);
|
|
233
|
+
return { conversationId: message.conversationId, mode: "text" };
|
|
234
|
+
}
|
|
235
|
+
const cardBizId = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
236
|
+
const body = {
|
|
237
|
+
cardTemplateId: "StandardCard",
|
|
238
|
+
cardBizId,
|
|
239
|
+
outTrackId: cardBizId,
|
|
240
|
+
robotCode: message.robotCode,
|
|
241
|
+
cardData: buildStandardCardData("Codex - 执行中", content),
|
|
242
|
+
};
|
|
243
|
+
if (isSingleConversation(message.conversationType) && message.senderStaffId) {
|
|
244
|
+
body.singleChatReceiver = JSON.stringify({ userid: message.senderStaffId });
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
body.openConversationId = message.conversationId;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
await this.callOpenApi("POST", "/v1.0/im/v1.0/robot/interactiveCards/send", body);
|
|
251
|
+
log.debug(`card sent conversation=${message.conversationId} cardBizId=${cardBizId}`);
|
|
252
|
+
return { conversationId: message.conversationId, mode: "card", cardBizId };
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
log.warn(`card send failed, fallback to text: ${err instanceof Error ? err.message : String(err)}`);
|
|
256
|
+
await this.sendText(message.conversationId, content);
|
|
257
|
+
return { conversationId: message.conversationId, mode: "text" };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
async updateReply(handle, title, content, options = { fallbackToText: true }) {
|
|
261
|
+
if (handle.mode !== "card" || !handle.cardBizId) {
|
|
262
|
+
await this.sendText(handle.conversationId, content);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
await this.callOpenApi("PUT", "/v1.0/im/robots/interactiveCards", {
|
|
267
|
+
cardBizId: handle.cardBizId,
|
|
268
|
+
cardData: buildStandardCardData(title, content),
|
|
269
|
+
});
|
|
270
|
+
log.debug(`card updated conversation=${handle.conversationId} cardBizId=${handle.cardBizId}`);
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
log.warn(`card update failed${options.fallbackToText === false ? "" : ", fallback to text"}: ${err instanceof Error ? err.message : String(err)}`);
|
|
274
|
+
if (options.fallbackToText !== false) {
|
|
275
|
+
await this.sendText(handle.conversationId, content);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
async downloadAttachments(message) {
|
|
280
|
+
if (message.attachments.length === 0)
|
|
281
|
+
return [];
|
|
282
|
+
if (!message.robotCode) {
|
|
283
|
+
throw new Error("DingTalk media download requires robotCode, but this message did not include it");
|
|
284
|
+
}
|
|
285
|
+
const dir = resolve(this.config.codexWorkDir, ".oh-my-im", "media");
|
|
286
|
+
await mkdir(dir, { recursive: true });
|
|
287
|
+
const downloaded = [];
|
|
288
|
+
for (let index = 0; index < message.attachments.length; index += 1) {
|
|
289
|
+
const attachment = message.attachments[index];
|
|
290
|
+
const result = await this.callOpenApi("POST", "/v1.0/robot/messageFiles/download", {
|
|
291
|
+
downloadCode: attachment.downloadCode,
|
|
292
|
+
robotCode: message.robotCode,
|
|
293
|
+
});
|
|
294
|
+
const resultRecord = asRecord(result);
|
|
295
|
+
const downloadUrl = asString(resultRecord.downloadUrl) ?? asString(resultRecord.url);
|
|
296
|
+
if (!downloadUrl) {
|
|
297
|
+
throw new Error(`DingTalk media download response did not include downloadUrl: ${safePreview(result)}`);
|
|
298
|
+
}
|
|
299
|
+
const res = await fetch(downloadUrl, { signal: AbortSignal.timeout(60_000) });
|
|
300
|
+
if (!res.ok) {
|
|
301
|
+
throw new Error(`DingTalk media file download failed: ${res.status}`);
|
|
302
|
+
}
|
|
303
|
+
const contentType = res.headers.get("content-type") ?? undefined;
|
|
304
|
+
const nameBase = attachment.fileName ? sanitizeFileName(attachment.fileName) : attachment.type;
|
|
305
|
+
const withExt = extname(nameBase) ? nameBase : `${nameBase}${fileExtension(attachment, contentType)}`;
|
|
306
|
+
const path = join(dir, `${Date.now()}-${index}-${withExt}`);
|
|
307
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
308
|
+
await writeFile(path, buffer);
|
|
309
|
+
const size = buffer.byteLength;
|
|
310
|
+
downloaded.push({ ...attachment, path, size, contentType });
|
|
311
|
+
log.info(`downloaded attachment type=${attachment.type} path=${path} size=${size || "unknown"}`);
|
|
312
|
+
}
|
|
313
|
+
return downloaded;
|
|
64
314
|
}
|
|
65
315
|
parseMessage(data) {
|
|
66
|
-
|
|
316
|
+
const message = parseDingTalkMessage(data);
|
|
317
|
+
if (!message)
|
|
318
|
+
return null;
|
|
319
|
+
this.webhooks.set(message.conversationId, message.sessionWebhook);
|
|
320
|
+
log.debug(`parsed conversation=${message.conversationId} sender=${message.senderStaffId ?? message.senderId} msgtype=${message.msgtype} textLen=${message.text.length} attachments=${message.attachments.length} webhookLen=${message.sessionWebhook.length}`);
|
|
321
|
+
return message;
|
|
322
|
+
}
|
|
323
|
+
async callOpenApi(method, path, body) {
|
|
324
|
+
const accessToken = await this.getAccessToken();
|
|
325
|
+
const res = await fetch(`https://api.dingtalk.com${path}`, {
|
|
326
|
+
method,
|
|
327
|
+
headers: {
|
|
328
|
+
"content-type": "application/json",
|
|
329
|
+
"x-acs-dingtalk-access-token": accessToken,
|
|
330
|
+
},
|
|
331
|
+
body: JSON.stringify(body),
|
|
332
|
+
signal: AbortSignal.timeout(30_000),
|
|
333
|
+
});
|
|
334
|
+
const text = await res.text();
|
|
335
|
+
if (!res.ok)
|
|
336
|
+
throw new Error(`DingTalk OpenAPI failed: ${res.status} ${text}`);
|
|
337
|
+
if (!text)
|
|
338
|
+
return {};
|
|
339
|
+
let parsed;
|
|
67
340
|
try {
|
|
68
|
-
|
|
341
|
+
parsed = JSON.parse(text);
|
|
69
342
|
}
|
|
70
343
|
catch {
|
|
71
|
-
|
|
72
|
-
return null;
|
|
344
|
+
return text;
|
|
73
345
|
}
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const text = msgtype === "text" ? textPayload?.content?.trim() ?? "" : "";
|
|
81
|
-
if (typeof conversationId !== "string" ||
|
|
82
|
-
typeof sessionWebhook !== "string" ||
|
|
83
|
-
typeof senderId !== "string" ||
|
|
84
|
-
typeof msgtype !== "string") {
|
|
85
|
-
return null;
|
|
346
|
+
const code = parsed.errcode ?? parsed.errorCode ?? parsed.code;
|
|
347
|
+
const success = parsed.success;
|
|
348
|
+
if ((typeof code === "number" && code !== 0) ||
|
|
349
|
+
(typeof code === "string" && code && code !== "0" && code.toLowerCase() !== "ok") ||
|
|
350
|
+
success === false) {
|
|
351
|
+
throw new Error(`DingTalk OpenAPI business error: ${safePreview(parsed)}`);
|
|
86
352
|
}
|
|
87
|
-
|
|
88
|
-
return {
|
|
89
|
-
callbackId: data.headers.messageId,
|
|
90
|
-
conversationId,
|
|
91
|
-
sessionWebhook,
|
|
92
|
-
senderId,
|
|
93
|
-
senderStaffId: typeof senderStaffId === "string" ? senderStaffId : undefined,
|
|
94
|
-
senderNick: typeof payload.senderNick === "string" ? payload.senderNick : undefined,
|
|
95
|
-
text,
|
|
96
|
-
msgtype,
|
|
97
|
-
};
|
|
353
|
+
return parsed;
|
|
98
354
|
}
|
|
99
355
|
async getAccessToken() {
|
|
100
356
|
if (!this.client)
|