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 CHANGED
@@ -6,9 +6,11 @@
6
6
 
7
7
  ```bash
8
8
  npm install
9
- cp .env.example .env
10
- # 编辑 .env
11
- npm run dev
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
- stderr += chunk.toString();
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
- completed = true;
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 "dotenv/config";
2
- import { existsSync } from "node:fs";
3
- import { resolve } from "node:path";
4
- function requireEnv(name) {
5
- const value = process.env[name]?.trim();
6
- if (!value)
7
- throw new Error(`Missing required env: ${name}`);
8
- return value;
9
- }
10
- function splitCsv(value) {
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 configured = process.env.CODEX_WORK_DIR?.trim();
20
- const dir = configured ? resolve(configured) : process.cwd();
13
+ const dir = resolve(process.cwd());
21
14
  if (!existsSync(dir))
22
- throw new Error(`CODEX_WORK_DIR does not exist: ${dir}`);
15
+ throw new Error(`Codex work directory does not exist: ${dir}`);
23
16
  return dir;
24
17
  }
25
18
  export function loadConfig() {
26
- const permissionMode = process.env.CODEX_PERMISSION_MODE?.trim().toLowerCase();
27
- const timeoutRaw = process.env.OPEN_IM_CLI_TIMEOUT_MS?.trim();
28
- const timeout = timeoutRaw ? Number.parseInt(timeoutRaw, 10) : 30 * 60 * 1000;
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: requireEnv("DINGTALK_CLIENT_ID"),
31
- dingtalkClientSecret: requireEnv("DINGTALK_CLIENT_SECRET"),
32
- codexCliPath: process.env.CODEX_CLI_PATH?.trim() || "codex",
25
+ dingtalkClientId: clientId,
26
+ dingtalkClientSecret: clientSecret,
27
+ codexCliPath: "codex",
33
28
  codexWorkDir: resolveWorkDir(),
34
- codexProxy: process.env.CODEX_PROXY?.trim() || undefined,
35
- codexPermissionMode: permissionMode === "bypass" ? "bypass" : undefined,
36
- allowedUserIds: splitCsv(process.env.ALLOWED_USER_IDS),
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(`Sent DingTalk text to conversation=${conversationId}`);
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
- let payload;
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
- payload = JSON.parse(data.data);
341
+ parsed = JSON.parse(text);
69
342
  }
70
343
  catch {
71
- log.warn("Failed to parse DingTalk payload");
72
- return null;
344
+ return text;
73
345
  }
74
- const conversationId = payload.conversationId;
75
- const sessionWebhook = payload.sessionWebhook;
76
- const senderStaffId = payload.senderStaffId;
77
- const senderId = payload.senderId;
78
- const msgtype = payload.msgtype;
79
- const textPayload = payload.text;
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
- this.webhooks.set(conversationId, sessionWebhook);
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)