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 +77 -7
- package/attachments.mjs +112 -0
- package/dingtalk-bridge.mjs +88 -26
- package/inbound.mjs +22 -12
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/session-log.mjs +110 -0
package/README.md
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
# herdr-dingtalk-omo-bridge
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/herdr-dingtalk-omo-bridge)
|
|
4
|
+
[](https://www.npmjs.com/package/herdr-dingtalk-omo-bridge)
|
|
5
|
+
[](https://github.com/CurrenWong/herdr-dingtalk-omo-bridge/actions/workflows/ci.yml)
|
|
6
|
+
[](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" |
|
|
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
|
-
- **
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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 读写文件、跑命令。正式用请务必设置。
|
package/attachments.mjs
ADDED
|
@@ -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
|
+
}
|
package/dingtalk-bridge.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
360
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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
|
|
5
|
-
//
|
|
4
|
+
// Stream connection: dingtalk-bridge.mjs connects at module level and cannot be
|
|
5
|
+
// imported by a test.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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",
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
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
|
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "herdr-dingtalk-omo-bridge",
|
|
3
|
-
"version": "0.1
|
|
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
|
|
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
|
|
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 .",
|
package/session-log.mjs
ADDED
|
@@ -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
|
+
}
|