herdr-dingtalk-omo-bridge 0.1.0 → 0.2.1

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
@@ -1,8 +1,19 @@
1
1
  # herdr-dingtalk-omo-bridge
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/herdr-dingtalk-omo-bridge)](https://www.npmjs.com/package/herdr-dingtalk-omo-bridge)
4
+ [![npm downloads](https://img.shields.io/npm/dw/herdr-dingtalk-omo-bridge)](https://www.npmjs.com/package/herdr-dingtalk-omo-bridge)
5
+ [![CI](https://github.com/CurrenWong/herdr-dingtalk-omo-bridge/actions/workflows/ci.yml/badge.svg)](https://github.com/CurrenWong/herdr-dingtalk-omo-bridge/actions/workflows/ci.yml)
6
+ [![license](https://img.shields.io/github/license/CurrenWong/herdr-dingtalk-omo-bridge)](LICENSE)
7
+
3
8
  A [Herdr](https://herdr.dev) plugin that makes an **OmO** coding agent reachable
4
9
  from DingTalk — and tells you, on the message itself, what state it is in.
5
10
 
11
+ ```bash
12
+ npx herdr-dingtalk-omo-bridge@latest install
13
+ # or
14
+ herdr plugin install CurrenWong/herdr-dingtalk-omo-bridge
15
+ ```
16
+
6
17
  Send a message in DingTalk:
7
18
 
8
19
  1. the message gets tagged 👌**Received** within a few hundred milliseconds;
@@ -28,10 +39,30 @@ when Herdr restarts, and leaves you a TUI you can watch while it works.
28
39
  - [Troubleshooting](#troubleshooting)
29
40
  - [Security notes](#security-notes)
30
41
  - [Known limitations](#known-limitations)
42
+ - [Publishing](#publishing)
31
43
  - [中文说明](#中文说明)
32
44
 
33
45
  ---
34
46
 
47
+ ## Publishing
48
+
49
+ Two channels, both driven by manual GitHub Actions workflows:
50
+
51
+ | Workflow | Registry | Package name |
52
+ |---|---|---|
53
+ | `publish.yml` | npmjs.org | `herdr-dingtalk-omo-bridge` |
54
+ | `publish-github-packages.yml` | npm.pkg.github.com | `@currenwong/herdr-dingtalk-omo-bridge` |
55
+
56
+ The npmjs one stages the tarball and then waits for a 2FA approval, because this
57
+ account has 2FA and npm no longer accepts tokens that bypass it. The GitHub
58
+ Packages one authenticates with the job's own `GITHUB_TOKEN`, so it needs no
59
+ secret — the workflow rewrites the package name in the runner, since GitHub
60
+ namespaces everything under the account and the npmjs package is unscoped.
61
+
62
+ Bump `version` in `package.json` and tag the commit before releasing. A release,
63
+ an npm version and a git tag that disagree about what `vX.Y.Z` means is the
64
+ kind of thing that is only noticed months later.
65
+
35
66
  ## Contributing
36
67
 
37
68
  ```bash
@@ -190,6 +221,12 @@ Either way, confirm it landed:
190
221
  herdr plugin list # expect: dingtalk-omo-bridge (DingTalk ↔ OmO Bridge) enabled
191
222
  ```
192
223
 
224
+ > **Which registry?** Install from npmjs (`npx herdr-dingtalk-omo-bridge@latest`)
225
+ > or straight from this repo. A copy is also mirrored to GitHub Packages as
226
+ > `@currenwong/herdr-dingtalk-omo-bridge` so it shows under the repository's
227
+ > Packages panel, but that one needs a GitHub token to install, so it is not the
228
+ > recommended path. See [Publishing](#publishing) if you fork this.
229
+
193
230
  ### 3. Register the bot
194
231
 
195
232
  ```bash
@@ -314,6 +351,25 @@ herdr pane send-text <pane> <text>
314
351
  herdr pane send-keys <pane> enter
315
352
  ```
316
353
 
354
+ ### Attachments
355
+
356
+ A picture message never carries the image. DingTalk sends a `downloadCode`, and
357
+ the file has to be redeemed in two steps:
358
+
359
+ ```
360
+ POST https://api.dingtalk.com/v1.0/robot/messageFiles/download { downloadCode, robotCode }
361
+ -> { downloadUrl } (short-lived)
362
+ GET <downloadUrl> -> bytes
363
+ ```
364
+
365
+ The bridge saves the result into `<agent cwd>/.dingtalk-inbox/` (override with
366
+ `LAB_ATTACHMENT_DIR`) and sends the agent a prompt naming that path, so it can
367
+ open the file with its usual tools. Names are sanitised but keep their script:
368
+ `账单 2026.pdf` survives as `账单_2026.pdf` rather than being reduced to
369
+ `_2026.pdf`.
370
+
371
+ Files over 20 MB are refused rather than pulled into the workspace.
372
+
317
373
  ### The message tags
318
374
 
319
375
  These are plain REST calls against the DingTalk emotion API, which needs an app
@@ -406,7 +462,8 @@ node startup.mjs # with the HERDR_* plugin env vars set
406
462
  | Bridge never connects | Another Stream client is already using this app. DingTalk permits exactly one connection per app — check for a second `dingtalk-bridge.mjs`. |
407
463
  | Tags never appear | Run `node probe-emotion.mjs <conv-id>`, and confirm the app has the robot capability that allows emotions. |
408
464
  | `msg.dec.err` on every tag | The message id was not accepted. Usually a stale or non-robot message. |
409
- | Reply says "只支持文本消息" / "Text messages only" | Working as intended — the bridge handles text, and OmO cannot receive images or files through the pane. |
465
+ | Reply says "只支持文本消息" / "Text messages only" | The message carried neither text nor a `downloadCode`. Voice notes and rich-text in group chats are the usual cases. |
466
+ | Reply says "附件下载失败" / "Could not download the attachment" | The `downloadCode` was rejected or the download URL expired. `downloadCode`s are single-use and short-lived; check the log line `!! attachment failed`. |
410
467
  | `check-reply.mjs` prints "no OmO sessions directory" | OmO is not installed, or has never run on this machine. The bridge reads answers from OmO's session logs, so there is nothing to read yet. |
411
468
 
412
469
  ## Security notes
@@ -463,9 +520,12 @@ If you get it running elsewhere, widening the manifest to
463
520
  conversation; concurrent senders queue rather than run in parallel.
464
521
  - **Tied to OmO's session-log format.** Another agent would need its own
465
522
  reply-extraction adapter (`sessionFileForCwd` / `lastAssistantText`).
466
- - **Text messages only.** Images and files cannot be handed to OmO through the
467
- pane, so the bridge replies saying so rather than going quiet. Support would
468
- mean downloading the media and writing it into the agent's working directory.
523
+ - **Attachments are fetched, not inlined.** Images and files arrive as a
524
+ `downloadCode`; the bridge redeems it and writes the bytes into the agent's
525
+ working directory (`.dingtalk-inbox/`), then asks the agent to open that path.
526
+ Whether the agent can *interpret* the contents depends on its model — a
527
+ non-multimodal model will say it cannot read an image, which is the agent's
528
+ answer to give, not the bridge's.
469
529
  - **One turn at a time.** A message that arrives mid-turn gets "still working on
470
530
  the previous one" instead of queueing.
471
531
  - **No allowlist by default**, which on a shared bot is a real exposure. See
@@ -501,9 +561,19 @@ If you get it running elsewhere, widening the manifest to
501
561
  **标签是独立的 REST 调用**,需要 app accessToken(Stream 长连接本身拿不到),
502
562
  所以任何失败都只记日志、绝不吞掉正文回复。`LAB_EMOTIONS=0` 可整体关闭。
503
563
 
504
- **只支持文本消息**。图片和文件没法通过 pane 交给 OmO,所以桥会明确回一句
505
- 「只支持文本消息」,而不是静默不理——静默跟机器人挂了是分不清的。同一时间
506
- 只处理一轮,中途再发会收到「上一条还在处理中」。
564
+ **图片和文件会被取回来。** 图片消息本身不含图片,只有一个 `downloadCode`;桥拿它
565
+ 去换临时下载地址、把字节存进 OmO 自己的工作目录,然后把**绝对路径**告诉 OmO(字节
566
+ 不进 prompt,避免把上下文和模型都不支持的二进制塞进去)。超过 20 MB 会直接拒绝。
567
+ 语音、群里的富文本这类既没有文本也没有 `downloadCode` 的消息,才会明确回一句
568
+ 「只支持文本消息」——静默跟机器人挂了是分不清的。
569
+
570
+ **过程也会推给你。** 一轮任务可能要装包、试错、绕坑,过去这段时间是完全静默的。
571
+ 现在 OmO 边做边说的内容会以 `⏳` 前缀实时推送,最后再把真正的答案发出来。判别靠
572
+ **结构**:带 `toolCall` 的消息是行动预告,纯文本才是结论——所以预告不会被当成
573
+ 结果。进度按字节偏移去重,每轮最多 10 条(`LAB_PROGRESS_LIMIT` 可调),因为钉钉
574
+ 机器人约 20 条/分钟限流。
575
+
576
+ 同一时间只处理一轮,中途再发会收到「上一条还在处理中」。
507
577
 
508
578
  **⚠️ 默认没有白名单。** `DINGTALK_ALLOW_STAFF` 不设的话,任何能给这个机器人
509
579
  发消息的人都能驱动你本机的 OmO 读写文件、跑命令。正式用请务必设置。
@@ -0,0 +1,112 @@
1
+ // Fetch an attachment the user sent to the bot, and decide where it lands.
2
+ //
3
+ // DingTalk never puts the bytes in the callback. A picture message arrives as
4
+ // { msgtype: "picture", content: { downloadCode, pictureDownloadCode } }
5
+ // and the file itself has to be redeemed in two steps:
6
+ //
7
+ // POST /v1.0/robot/messageFiles/download { downloadCode, robotCode }
8
+ // -> { downloadUrl }
9
+ // GET <downloadUrl> -> bytes
10
+ //
11
+ // The URL is short-lived and unauthenticated, which is why this runs immediately
12
+ // rather than queueing the download for later.
13
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
14
+ import { basename, extname, join } from "node:path";
15
+
16
+ // Cap what we are willing to pull into the agent's working directory.
17
+ export const MAX_BYTES = 20 * 1024 * 1024;
18
+
19
+ const EXT_BY_TYPE = new Map([
20
+ ["image/jpeg", ".jpg"],
21
+ ["image/jpg", ".jpg"],
22
+ ["image/png", ".png"],
23
+ ["image/gif", ".gif"],
24
+ ["image/webp", ".webp"],
25
+ ["image/bmp", ".bmp"],
26
+ ["audio/amr", ".amr"],
27
+ ["audio/mpeg", ".mp3"],
28
+ ["audio/wav", ".wav"],
29
+ ["video/mp4", ".mp4"],
30
+ ["application/pdf", ".pdf"],
31
+ ]);
32
+
33
+ /** The download code inside a non-text message, or "" when there is none. */
34
+ export function downloadCodeOf(msg) {
35
+ const c = msg?.content ?? {};
36
+ return String(c.downloadCode ?? c.pictureDownloadCode ?? "").trim();
37
+ }
38
+
39
+ /**
40
+ * A safe, stable filename for a saved attachment.
41
+ *
42
+ * Prefers the name the sender used (file messages carry `fileName`), because a
43
+ * human will recognise it; falls back to the message id. Whatever we end up with
44
+ * is stripped of path separators — this string becomes a filesystem path.
45
+ */
46
+ export function attachmentName(msg, contentType = "") {
47
+ const hint = String(msg?.content?.fileName ?? "").trim();
48
+ // Keep letters from any script: \w alone would reduce "账单 2026.pdf" to
49
+ // "_2026.pdf", which is useless to the person who sent it.
50
+ const safeHint = basename(hint)
51
+ .replace(/[^\p{L}\p{N}._-]+/gu, "_")
52
+ .replace(/^_+|_+$/g, "")
53
+ .slice(0, 80);
54
+ const id = String(msg?.msgId ?? "attachment").replace(/[^\w-]+/g, "_").slice(0, 40);
55
+
56
+ const fromType = EXT_BY_TYPE.get(String(contentType).split(";")[0].trim().toLowerCase()) ?? "";
57
+ const fromHint = extname(safeHint).toLowerCase();
58
+ const ext = fromType || fromHint || ".bin";
59
+
60
+ if (safeHint) {
61
+ // Keep the sender's stem when we know it, unless it already ends in the
62
+ // extension we resolved from the content type.
63
+ const stem = fromHint ? safeHint.slice(0, -fromHint.length) : safeHint;
64
+ return `${stem || id}${ext}`;
65
+ }
66
+ return `${id}${ext}`;
67
+ }
68
+
69
+ /**
70
+ * Turn the access token into bytes on disk.
71
+ * Returns { path, bytes, contentType } or throws with a usable message.
72
+ */
73
+ export async function saveAttachment({ msg, robotCode, accessToken, dir }) {
74
+ const downloadCode = downloadCodeOf(msg);
75
+ if (!downloadCode) throw new Error("message carries no downloadCode");
76
+
77
+ const token = await accessToken();
78
+
79
+ const res = await fetch("https://api.dingtalk.com/v1.0/robot/messageFiles/download", {
80
+ method: "POST",
81
+ headers: {
82
+ "Content-Type": "application/json",
83
+ "x-acs-dingtalk-access-token": token,
84
+ },
85
+ body: JSON.stringify({ downloadCode, robotCode }),
86
+ });
87
+ const body = await res.text();
88
+ if (!res.ok) {
89
+ throw new Error(`downloadCode rejected: HTTP ${res.status} ${body.slice(0, 200)}`);
90
+ }
91
+ const url = JSON.parse(body)?.downloadUrl;
92
+ if (!url) throw new Error(`no downloadUrl in response: ${body.slice(0, 200)}`);
93
+
94
+ const file = await fetch(url);
95
+ if (!file.ok) throw new Error(`downloadUrl fetch failed: HTTP ${file.status}`);
96
+
97
+ const declared = Number(file.headers.get("content-length") ?? 0);
98
+ if (declared > MAX_BYTES) {
99
+ throw new Error(`attachment is ${Math.round(declared / 1048576)}MB, limit is ${MAX_BYTES / 1048576}MB`);
100
+ }
101
+ const buf = Buffer.from(await file.arrayBuffer());
102
+ if (buf.length > MAX_BYTES) {
103
+ throw new Error(`attachment is ${Math.round(buf.length / 1048576)}MB, limit is ${MAX_BYTES / 1048576}MB`);
104
+ }
105
+
106
+ const contentType = file.headers.get("content-type") ?? "";
107
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
108
+ const path = join(dir, attachmentName(msg, contentType));
109
+ writeFileSync(path, buf);
110
+
111
+ return { path, bytes: buf.length, contentType };
112
+ }
@@ -20,7 +20,9 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statS
20
20
  import { homedir } from "node:os";
21
21
  import { join, resolve as resolvePath } from "node:path";
22
22
  import { DWClient, TOPIC_ROBOT } from "dingtalk-stream";
23
+ import { saveAttachment } from "./attachments.mjs";
23
24
  import { ACTIONS, classifyInbound } from "./inbound.mjs";
25
+ import { assistantMessagesSince, lastAssistantText } from "./session-log.mjs";
24
26
  import { DEFAULT_PLUGIN_ID, defaultConfigDir, defaultStateDir } from "./herdr-paths.mjs";
25
27
 
26
28
  const HERDR = process.env.HERDR_BIN_PATH ?? "herdr";
@@ -36,6 +38,16 @@ const TARGET = process.env.LAB_TARGET_PANE ?? "";
36
38
  const ANSWER_TIMEOUT_MS = Number(process.env.LAB_AGENT_TIMEOUT_MS ?? 300000);
37
39
  const READ_LINES = 60;
38
40
  const OMO_SESSIONS = join(homedir(), ".omo", "agent", "sessions");
41
+ // Attachments are written into the agent's working directory, not the plugin's
42
+ // state dir: the agent opens them by path, and a relative path in its own
43
+ // workspace is the least surprising thing to hand it.
44
+ const ATTACHMENT_DIR = process.env.LAB_ATTACHMENT_DIR ?? ".dingtalk-inbox";
45
+ // One DingTalk markdown message comfortably carries a few thousand characters;
46
+ // beyond this the tail is cut and the reply says so.
47
+ const PUSH_LIMIT = Number(process.env.LAB_PUSH_LIMIT ?? 4000);
48
+ // DingTalk rate-limits a robot to roughly 20 messages a minute, so cap how much
49
+ // of a long-running agent's narration gets forwarded.
50
+ const PROGRESS_LIMIT = Number(process.env.LAB_PROGRESS_LIMIT ?? 10);
39
51
 
40
52
  // Every string the bot says to the user lives here so the plugin is not welded
41
53
  // to one language. Set LAB_LANG=en for English; anything else falls back to the
@@ -51,6 +63,11 @@ const TEXT = {
51
63
  busy: "上一条还在处理中,稍等。",
52
64
  handleFailed: (msg) => `桥接出错:${msg}`,
53
65
  unsupported: (type) => `只支持文本消息,收到的是 ${type}。图片和文件没法交给 OmO。`,
66
+ attachmentPrompt: ({ type, path, size }) =>
67
+ `用户通过钉钉发来一个 ${type} 附件,已保存到本地:\n${path}\n(${size})\n\n请查看这个文件并回应。如果无法直接读取其中的内容,请说明原因,不要猜测。`,
68
+ attachmentFailed: (err) => `附件下载失败:${err}`,
69
+ truncated: (n) => `…(内容过长,已截断剩余 ${n} 字符)`,
70
+ progress: (text) => `⏳ ${text}`,
54
71
  },
55
72
  en: {
56
73
  fallbackTitle: "Reply",
@@ -61,6 +78,11 @@ const TEXT = {
61
78
  busy: "Still working on the previous message.",
62
79
  handleFailed: (msg) => `Bridge error: ${msg}`,
63
80
  unsupported: (type) => `Text messages only — this one was "${type}". Images and files cannot be handed to OmO.`,
81
+ attachmentPrompt: ({ type, path, size }) =>
82
+ `The user sent a ${type} attachment through DingTalk. It is saved locally at:\n${path}\n(${size})\n\nPlease look at it and respond. If you cannot actually read its contents, say so rather than guessing.`,
83
+ attachmentFailed: (err) => `Could not download the attachment: ${err}`,
84
+ truncated: (n) => `…(${n} more characters truncated)`,
85
+ progress: (text) => `⏳ ${text}`,
64
86
  },
65
87
  };
66
88
  const t = (key, ...args) => TEXT[LANG][key](...args);
@@ -156,27 +178,6 @@ function sessionFileForCwd(cwd) {
156
178
  return best;
157
179
  }
158
180
 
159
- function lastAssistantText(file, fromByte) {
160
- if (!file || !existsSync(file.path)) return null;
161
- const fd = readFileSync(file.path, "utf8");
162
- const slice = fromByte > 0 && fromByte <= fd.length ? fd.slice(fromByte) : fd;
163
- let answer = null;
164
- for (const line of slice.split("\n")) {
165
- if (!line.trim()) continue;
166
- let row;
167
- try {
168
- row = JSON.parse(line);
169
- } catch {
170
- continue; // a partially written tail line; the next poll will see it whole
171
- }
172
- if (row.type !== "message" || row.message?.role !== "assistant") continue;
173
- const parts = (row.message.content ?? [])
174
- .filter((c) => c.type === "text" && c.text)
175
- .map((c) => c.text.trim());
176
- if (parts.length) answer = parts.join("\n\n");
177
- }
178
- return answer;
179
- }
180
181
 
181
182
  function stripAnsi(text) {
182
183
  return text
@@ -336,7 +337,11 @@ const state = { started_at: new Date().toISOString(), seen: 0, last_push: null }
336
337
 
337
338
  // Returns {text, ok} so the caller knows whether to tag the turn Done or
338
339
  // Error — a timeout or a blocked prompt is not a successful answer.
339
- async function askAgent(paneId, text, cwd) {
340
+ //
341
+ // `onProgress` receives the agent's narration as it happens. Those messages are
342
+ // useless as an answer but genuinely useful while waiting, especially for turns
343
+ // that spend a minute installing a package first.
344
+ async function askAgent(paneId, text, cwd, onProgress) {
340
345
  const snap = sessionFileForCwd(cwd);
341
346
  log("session snapshot", JSON.stringify({ file: snap?.path ?? null, size: snap?.size ?? 0 }));
342
347
  const paneBefore = TARGET ? readPane(paneId) : "";
@@ -349,6 +354,11 @@ async function askAgent(paneId, text, cwd) {
349
354
 
350
355
  const deadline = Date.now() + ANSWER_TIMEOUT_MS;
351
356
  let sawWorking = false;
357
+ // Narration is deduped by byte offset so a row seen on two polls is not sent
358
+ // twice. Capped because DingTalk rate-limits a robot to ~20 messages/minute.
359
+ const narrated = new Set();
360
+ let progressSent = 0;
361
+
352
362
  while (Date.now() < deadline) {
353
363
  await sleep(900);
354
364
  const info = agentInfo();
@@ -356,8 +366,25 @@ async function askAgent(paneId, text, cwd) {
356
366
  if (snap) {
357
367
  const fresh = sessionFileForCwd(cwd);
358
368
  const file = fresh && fresh.path !== snap.path ? fresh : snap;
359
- const answer = lastAssistantText(file, snap.path === file.path ? snap.size : 0);
360
- if (answer) {
369
+ const fromByte = snap.path === file.path ? snap.size : 0;
370
+
371
+ if (onProgress) {
372
+ for (const m of assistantMessagesSince(file, fromByte)) {
373
+ if (m.final || narrated.has(m.offset)) continue;
374
+ if (progressSent >= PROGRESS_LIMIT) break;
375
+ narrated.add(m.offset);
376
+ progressSent += 1;
377
+ log("progress", JSON.stringify(m.text.slice(0, 200)));
378
+ await onProgress(m.text).catch((err) => log("!! progress push failed", err.message));
379
+ }
380
+ }
381
+
382
+ const answer = lastAssistantText(file, fromByte);
383
+ // While the agent is still "working", its latest text is commentary on
384
+ // what it is about to do, not a conclusion. The structural check inside
385
+ // lastAssistantText catches the common case (narration bundled with a
386
+ // toolCall); this also catches narration sent as its own text-only row.
387
+ if (answer && info?.agent_status !== "working") {
361
388
  log("answer from session log", JSON.stringify(answer.slice(0, 300)));
362
389
  return { text: answer, ok: true };
363
390
  }
@@ -429,8 +456,43 @@ async function handle(msg) {
429
456
  }
430
457
  log("-> agent", JSON.stringify({ agent: AGENT, pane: paneId, cwd: info?.cwd, status: info?.agent_status }));
431
458
 
432
- const answer = await askAgent(paneId, verdict.text, info?.cwd);
433
- await push(msg.sessionWebhook, answer.text.slice(0, 4000));
459
+ // Attachments are redeemed into the agent's working directory so the agent
460
+ // can open them with its normal tools. DingTalk never sends the bytes in the
461
+ // callback, and the download URL it hands back is short-lived, so this has to
462
+ // happen now rather than being queued.
463
+ let promptText = verdict.text;
464
+ if (verdict.action === ACTIONS.ATTACHMENT) {
465
+ const dir = join(info?.cwd ?? process.cwd(), ATTACHMENT_DIR);
466
+ try {
467
+ const saved = await saveAttachment({
468
+ msg,
469
+ robotCode: msg.robotCode ?? msg.chatbotCorpId ?? CLIENT_ID,
470
+ accessToken,
471
+ dir,
472
+ });
473
+ log("attachment saved", JSON.stringify({ path: saved.path, bytes: saved.bytes, type: saved.contentType }));
474
+ promptText = t("attachmentPrompt", {
475
+ type: verdict.msgtype,
476
+ path: saved.path,
477
+ size: `${Math.max(1, Math.round(saved.bytes / 1024))} KB`,
478
+ });
479
+ } catch (err) {
480
+ log("!! attachment failed", err.message);
481
+ await push(msg.sessionWebhook, t("attachmentFailed", err.message));
482
+ await finish(false);
483
+ return;
484
+ }
485
+ }
486
+
487
+ const answer = await askAgent(paneId, promptText, info?.cwd, async (progress) => {
488
+ // Marked so a running commentary is never mistaken for the result.
489
+ await push(msg.sessionWebhook, t("progress", progress.slice(0, 800)));
490
+ });
491
+ // Truncating silently would look like the agent stopped mid-sentence.
492
+ const body = answer.text.length > PUSH_LIMIT
493
+ ? `${answer.text.slice(0, PUSH_LIMIT)}\n\n${t("truncated", answer.text.length - PUSH_LIMIT)}`
494
+ : answer.text;
495
+ await push(msg.sessionWebhook, body);
434
496
  await finish(answer.ok);
435
497
  }
436
498
 
package/inbound.mjs CHANGED
@@ -1,18 +1,23 @@
1
1
  // Decide what to do with an inbound DingTalk message.
2
2
  //
3
3
  // Pulled out of the bridge's handle() so the rules can be tested without a
4
- // Stream connection: dingtalk-bridge.mjs connects at module top level, so it
5
- // cannot be imported by a test.
4
+ // Stream connection: dingtalk-bridge.mjs connects at module level and cannot be
5
+ // imported by a test.
6
6
  //
7
- // The order matters and used to be wrong. The bridge checked for empty text
8
- // before anything else, so a non-text message (an image, a file) got no tag,
9
- // no reply and nothing but a log line — indistinguishable from the bot being
10
- // dead. Unsupported *types* are now answered explicitly.
7
+ // Order matters and used to be wrong. The bridge checked for empty text before
8
+ // anything else, so a non-text message got no tag, no reply and nothing but a
9
+ // log line — indistinguishable from the bot being dead. Now:
10
+ //
11
+ // REJECT not allowed to talk to this bot
12
+ // ATTACHMENT allowed, and carries a downloadCode we can redeem
13
+ // UNSUPPORTED allowed, but there is nothing we can act on
14
+ // PROCESS hand the text to the agent
11
15
 
12
16
  export const ACTIONS = {
13
- REJECT: "reject", // not allowed to talk to this bot
14
- UNSUPPORTED: "unsupported", // allowed, but this kind of message is not handled
15
- PROCESS: "process", // hand it to the agent
17
+ REJECT: "reject",
18
+ ATTACHMENT: "attachment",
19
+ UNSUPPORTED: "unsupported",
20
+ PROCESS: "process",
16
21
  };
17
22
 
18
23
  /**
@@ -24,14 +29,19 @@ export function classifyInbound(msg, { allow = [] } = {}) {
24
29
  const msgtype = msg?.msgtype ?? "text";
25
30
  const text = String(msg?.text?.content ?? "").trim();
26
31
  const staffId = msg?.senderStaffId;
32
+ const content = msg?.content ?? {};
33
+ const downloadCode = String(content.downloadCode ?? content.pictureDownloadCode ?? "").trim();
27
34
 
28
35
  if (allow.length && !allow.includes(staffId)) {
29
36
  return { action: ACTIONS.REJECT, msgtype, text, staffId };
30
37
  }
31
38
 
32
- // `text` messages that are empty (whitespace, or a payload we cannot read)
33
- // fall in here too: from the user's point of view it is still not something
34
- // we can act on, and silence is the worst answer.
39
+ // A non-text message with a downloadCode is something we can actually fetch:
40
+ // images, files, voice notes. Text messages never carry one.
41
+ if (msgtype !== "text" && downloadCode) {
42
+ return { action: ACTIONS.ATTACHMENT, msgtype, text, staffId, downloadCode };
43
+ }
44
+
35
45
  if (msgtype !== "text" || !text) {
36
46
  return { action: ACTIONS.UNSUPPORTED, msgtype, text, staffId };
37
47
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "herdr-dingtalk-omo-bridge",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "herdr-dingtalk-omo-bridge",
9
- "version": "0.1.0",
9
+ "version": "0.2.1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "dingtalk-stream": "^2.1.6-beta.1"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-dingtalk-omo-bridge",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Herdr plugin that bridges DingTalk messages to an OmO coding-agent session, posts the answer back, and tags each message as received/done.",
5
5
  "keywords": [
6
6
  "herdr",
@@ -18,6 +18,7 @@
18
18
  "bin/",
19
19
  "installer.mjs",
20
20
  "herdr-plugin.toml",
21
+ "attachments.mjs",
21
22
  "auth.mjs",
22
23
  "check-reply.mjs",
23
24
  "dingtalk-bridge.mjs",
@@ -25,6 +26,7 @@
25
26
  "inbound.mjs",
26
27
  "probe.mjs",
27
28
  "probe-emotion.mjs",
29
+ "session-log.mjs",
28
30
  "startup.mjs",
29
31
  "supervise.mjs",
30
32
  "npm-shrinkwrap.json",
@@ -46,7 +48,7 @@
46
48
  "node": ">=20"
47
49
  },
48
50
  "scripts": {
49
- "check": "node --check auth.mjs && node --check check-reply.mjs && node --check dingtalk-bridge.mjs && node --check herdr-paths.mjs && node --check inbound.mjs && node --check installer.mjs && node --check bin/herdr-dingtalk-omo-bridge.mjs && node --check probe.mjs && node --check probe-emotion.mjs && node --check startup.mjs && node --check supervise.mjs",
51
+ "check": "node --check auth.mjs && node --check attachments.mjs && node --check check-reply.mjs && node --check dingtalk-bridge.mjs && node --check herdr-paths.mjs && node --check inbound.mjs && node --check installer.mjs && node --check bin/herdr-dingtalk-omo-bridge.mjs && node --check probe.mjs && node --check probe-emotion.mjs && node --check startup.mjs && node --check session-log.mjs && node --check supervise.mjs",
50
52
  "check-paths": "node .github/scripts/check-paths.mjs",
51
53
  "check-manifest": "node .github/scripts/check-manifest.mjs",
52
54
  "scan-secrets": "node .github/scripts/scan-secrets.mjs .",
@@ -0,0 +1,110 @@
1
+ // Reading OmO's session log by byte offset.
2
+ //
3
+ // Lives in its own module because getting this wrong is invisible: the bridge
4
+ // polls right after typing a prompt, so a reader that mistakes byte offsets for
5
+ // string indices returns the *previous* turn's answer, reports success, and
6
+ // looks fast while doing it. That happened once already.
7
+ import { existsSync, readFileSync } from "node:fs";
8
+
9
+ /**
10
+ * Rows appended after `fromByte`.
11
+ *
12
+ * `fromByte` is a byte count (statSync().size). The file is read as a Buffer and
13
+ * sliced as bytes — reading it as a string and slicing by index compares bytes
14
+ * against UTF-16 code units, which diverge as soon as the content is not ASCII.
15
+ *
16
+ * Returns null when nothing new has been written.
17
+ */
18
+ export function rowsAppendedSince(file, fromByte) {
19
+ if (!file || !existsSync(file.path)) return null;
20
+ const buf = readFileSync(file.path);
21
+ if (buf.length <= fromByte) return null;
22
+
23
+ const rows = [];
24
+ for (const line of buf.subarray(fromByte).toString("utf8").split("\n")) {
25
+ if (!line.trim()) continue;
26
+ try {
27
+ rows.push(JSON.parse(line));
28
+ } catch {
29
+ // A partially written tail line; the next poll will see it whole.
30
+ }
31
+ }
32
+ return rows;
33
+ }
34
+
35
+ /**
36
+ * Every assistant message appended since `fromByte`, in order.
37
+ *
38
+ * Each entry carries `offset` (a stable identity for dedup across polls) and
39
+ * `final`, which is true only for a message made purely of text.
40
+ *
41
+ * `final === false` means the agent is narrating what it is about to do — those
42
+ * messages arrive bundled with the toolCall they announce. They are useful as
43
+ * progress reports and misleading as answers, so the caller can do both:
44
+ * forward them while waiting, and never end the turn on one.
45
+ */
46
+ export function assistantMessagesSince(file, fromByte) {
47
+ const rows = rowsAppendedSince(file, fromByte);
48
+ if (!rows?.length) return [];
49
+
50
+ const out = [];
51
+ let offset = fromByte;
52
+ const buf = readFileSync(file.path);
53
+ const tail = buf.subarray(fromByte).toString("utf8");
54
+
55
+ for (const line of tail.split("\n")) {
56
+ const start = offset;
57
+ offset += Buffer.byteLength(line, "utf8") + 1;
58
+ if (!line.trim()) continue;
59
+
60
+ let row;
61
+ try {
62
+ row = JSON.parse(line);
63
+ } catch {
64
+ continue;
65
+ }
66
+ if (row.type !== "message" || row.message?.role !== "assistant") continue;
67
+
68
+ const content = row.message.content;
69
+ if (typeof content === "string") {
70
+ const text = content.trim();
71
+ if (text) out.push({ offset: start, text, final: true });
72
+ continue;
73
+ }
74
+ if (!Array.isArray(content)) continue;
75
+
76
+ const stillActing = content.some((c) => typeof c !== "string" && (c?.type ?? "") !== "text");
77
+ const text = content
78
+ .map((c) => (typeof c === "string" ? c : c.text))
79
+ .filter((t) => typeof t === "string" && t.trim())
80
+ .map((t) => t.trim())
81
+ .join("\n\n");
82
+ if (text) out.push({ offset: start, text, final: !stillActing });
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * The assistant's answer among the rows appended since `fromByte`, or null if
89
+ * the turn has not produced one yet.
90
+ *
91
+ * Two guards, each for a failure that actually happened:
92
+ *
93
+ * * the user row must be present, or a poll that outraces the agent can match a
94
+ * leftover assistant row and call the turn finished;
95
+ * * an assistant row that carries anything other than text is *not* an answer.
96
+ * OmO narrates before it acts — "I need to extract text from the PDF, I'll
97
+ * use Python" arrives as an assistant message bundled with the toolCall that
98
+ * follows it. Accepting that reported the plan as the result, 70 seconds
99
+ * before the real answer existed.
100
+ */
101
+ export function lastAssistantText(file, fromByte) {
102
+ const rows = rowsAppendedSince(file, fromByte);
103
+ if (!rows?.length) return null;
104
+
105
+ const sawUser = rows.some((r) => r.type === "message" && r.message?.role === "user");
106
+ if (!sawUser) return null;
107
+
108
+ const candidates = assistantMessagesSince(file, fromByte).filter((m) => m.final);
109
+ return candidates.length ? candidates[candidates.length - 1].text : null;
110
+ }