@nanhara/hara 0.121.0 → 0.122.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/README.md +40 -6
- package/dist/agent/failover.js +1 -1
- package/dist/agent/loop.js +158 -21
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +20 -6
- package/dist/config.js +62 -26
- package/dist/cron/deliver.js +37 -3
- package/dist/feedback.js +5 -9
- package/dist/fs-read.js +106 -12
- package/dist/fs-write.js +242 -16
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -18
- package/dist/gateway/feishu.js +158 -57
- package/dist/gateway/flows-pending.js +720 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +80 -15
- package/dist/gateway/mattermost.js +44 -32
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/serve.js +657 -162
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +27 -22
- package/dist/gateway/slack.js +26 -17
- package/dist/gateway/telegram.js +32 -18
- package/dist/gateway/wecom.js +32 -24
- package/dist/gateway/weixin.js +127 -49
- package/dist/hooks.js +32 -20
- package/dist/index.js +640 -219
- package/dist/org/projects.js +347 -0
- package/dist/org/roles.js +38 -11
- package/dist/security/secrets.js +150 -0
- package/dist/serve/server.js +772 -317
- package/dist/serve/sessions.js +112 -28
- package/dist/session/store.js +337 -44
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +61 -23
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/edit.js +11 -8
- package/dist/tools/patch.js +230 -31
- package/dist/tools/search.js +482 -72
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +364 -64
- package/dist/tui/run.js +26 -23
- package/dist/undo.js +83 -7
- package/package.json +1 -1
package/dist/fs-read.js
CHANGED
|
@@ -1,20 +1,92 @@
|
|
|
1
1
|
// Bounded streaming line reader for files too large to load as one string. It stores only the requested
|
|
2
2
|
// window and a capped prefix of each line, so huge logs/JSONL and minified one-line files stay safe.
|
|
3
|
-
import { closeSync,
|
|
3
|
+
import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
|
|
4
|
+
import { open } from "node:fs/promises";
|
|
4
5
|
export class BinaryFileError extends Error {
|
|
5
6
|
constructor(path) {
|
|
6
7
|
super(`${path} appears to be binary (NUL byte detected)`);
|
|
7
8
|
this.name = "BinaryFileError";
|
|
8
9
|
}
|
|
9
10
|
}
|
|
11
|
+
export class NonRegularFileError extends Error {
|
|
12
|
+
code = "HARA_NOT_REGULAR_FILE";
|
|
13
|
+
constructor(path) {
|
|
14
|
+
super(`${path} is not a regular file`);
|
|
15
|
+
this.name = "NonRegularFileError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class FileReadLimitError extends Error {
|
|
19
|
+
limit;
|
|
20
|
+
code = "HARA_FILE_TOO_LARGE";
|
|
21
|
+
constructor(path, limit) {
|
|
22
|
+
super(`${path} exceeds the ${limit}-byte safe edit/read limit`);
|
|
23
|
+
this.limit = limit;
|
|
24
|
+
this.name = "FileReadLimitError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
10
27
|
const DEFAULT_LINE_CAP = 2000;
|
|
11
28
|
const DEFAULT_MAX_SCAN = 64 * 1024 * 1024;
|
|
29
|
+
const MAX_SLICE_LINES = 2_000;
|
|
30
|
+
/** Editing tools materialize the old text for diff/CAS. Keep that allocation explicitly bounded. */
|
|
31
|
+
export const MAX_EDIT_READ_BYTES = 64 * 1024 * 1024;
|
|
32
|
+
const READ_CHUNK_BYTES = 64 * 1024;
|
|
33
|
+
const MAX_PREFIX_CHARS = 1_000_000;
|
|
34
|
+
/** Open without blocking on a FIFO, validate the SAME descriptor, then read at most maxBytes from it.
|
|
35
|
+
* Path-level stat→readFile is unsafe because the path can be exchanged for a pipe/device between calls. */
|
|
36
|
+
async function readRegularFileSnapshotWithFlags(path, maxBytes, flags) {
|
|
37
|
+
const requested = Number.isFinite(maxBytes) ? Math.floor(maxBytes) : MAX_EDIT_READ_BYTES;
|
|
38
|
+
const limit = Math.min(MAX_EDIT_READ_BYTES, Math.max(1, requested));
|
|
39
|
+
const handle = await open(path, flags);
|
|
40
|
+
try {
|
|
41
|
+
const info = await handle.stat();
|
|
42
|
+
if (!info.isFile())
|
|
43
|
+
throw new NonRegularFileError(path);
|
|
44
|
+
if (info.size > limit)
|
|
45
|
+
throw new FileReadLimitError(path, limit);
|
|
46
|
+
const chunks = [];
|
|
47
|
+
let total = 0;
|
|
48
|
+
let position = 0;
|
|
49
|
+
// Read one byte past the limit so concurrent growth cannot bypass the pre-read size check.
|
|
50
|
+
while (total <= limit) {
|
|
51
|
+
const want = Math.min(READ_CHUNK_BYTES, limit + 1 - total);
|
|
52
|
+
const buffer = Buffer.allocUnsafe(want);
|
|
53
|
+
const { bytesRead } = await handle.read(buffer, 0, want, position);
|
|
54
|
+
if (bytesRead === 0)
|
|
55
|
+
break;
|
|
56
|
+
chunks.push(buffer.subarray(0, bytesRead));
|
|
57
|
+
total += bytesRead;
|
|
58
|
+
position += bytesRead;
|
|
59
|
+
}
|
|
60
|
+
if (total > limit)
|
|
61
|
+
throw new FileReadLimitError(path, limit);
|
|
62
|
+
return { text: Buffer.concat(chunks, total).toString("utf8"), dev: info.dev, ino: info.ino, mode: info.mode & 0o777, nlink: info.nlink };
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
await handle.close().catch(() => { });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export async function readRegularFileSnapshot(path, maxBytes = MAX_EDIT_READ_BYTES) {
|
|
69
|
+
return readRegularFileSnapshotWithFlags(path, maxBytes, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
70
|
+
}
|
|
71
|
+
/** Quarantine/transaction reader: reject a symlink at open(2), then validate/read that same fd. */
|
|
72
|
+
export async function readRegularFileSnapshotNoFollow(path, maxBytes = MAX_EDIT_READ_BYTES) {
|
|
73
|
+
const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
74
|
+
return readRegularFileSnapshotWithFlags(path, maxBytes, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
|
|
75
|
+
}
|
|
76
|
+
export async function readRegularFileText(path, maxBytes = MAX_EDIT_READ_BYTES) {
|
|
77
|
+
return (await readRegularFileSnapshot(path, maxBytes)).text;
|
|
78
|
+
}
|
|
12
79
|
/** Read only enough bytes to produce a bounded UTF-8 prefix (used by synchronous @file expansion). */
|
|
13
80
|
export function readTextPrefixSync(path, maxChars) {
|
|
14
|
-
const
|
|
15
|
-
const
|
|
81
|
+
const requested = Number.isFinite(maxChars) ? Math.floor(maxChars) : MAX_PREFIX_CHARS;
|
|
82
|
+
const chars = Math.min(MAX_PREFIX_CHARS, Math.max(0, requested));
|
|
83
|
+
// O_NONBLOCK makes opening a FIFO return immediately; fstat on this exact fd then rejects it before read.
|
|
84
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
16
85
|
try {
|
|
17
|
-
const
|
|
86
|
+
const info = fstatSync(fd);
|
|
87
|
+
if (!info.isFile())
|
|
88
|
+
throw new NonRegularFileError(path);
|
|
89
|
+
const size = info.size;
|
|
18
90
|
// Four bytes per Unicode scalar plus a small boundary cushion guarantees enough decoded input for
|
|
19
91
|
// `chars` without allocating the entire file. readSync can short-read, so fill in a loop.
|
|
20
92
|
const byteLimit = Math.min(size, chars * 4 + 4);
|
|
@@ -41,11 +113,19 @@ export function readTextPrefixSync(path, maxChars) {
|
|
|
41
113
|
}
|
|
42
114
|
/** Render a line slice without retaining the entire file. Total line count is shown only when EOF is reached. */
|
|
43
115
|
export async function streamFileSlice(path, offset = 1, limit = 300, options = {}) {
|
|
44
|
-
const
|
|
45
|
-
const
|
|
116
|
+
const requestedStart = Number.isFinite(offset) ? Math.floor(offset) : 1;
|
|
117
|
+
const requestedLines = Number.isFinite(limit) ? Math.floor(limit) : 300;
|
|
118
|
+
const start = Math.min(Number.MAX_SAFE_INTEGER, Math.max(1, requestedStart));
|
|
119
|
+
const want = Math.min(MAX_SLICE_LINES, Math.max(1, requestedLines));
|
|
46
120
|
const requestedEnd = start + want - 1;
|
|
47
|
-
const
|
|
48
|
-
|
|
121
|
+
const requestedLineCap = Number.isFinite(options.lineCap)
|
|
122
|
+
? Math.floor(options.lineCap)
|
|
123
|
+
: DEFAULT_LINE_CAP;
|
|
124
|
+
const lineCap = Math.min(DEFAULT_LINE_CAP, Math.max(1, requestedLineCap));
|
|
125
|
+
const requestedScan = Number.isFinite(options.maxScanChars)
|
|
126
|
+
? Math.floor(options.maxScanChars)
|
|
127
|
+
: DEFAULT_MAX_SCAN;
|
|
128
|
+
const maxScan = Math.min(DEFAULT_MAX_SCAN, Math.max(lineCap, requestedScan));
|
|
49
129
|
const rendered = [];
|
|
50
130
|
let lineNo = 1;
|
|
51
131
|
let linePrefix = "";
|
|
@@ -98,8 +178,17 @@ export async function streamFileSlice(path, offset = 1, limit = 300, options = {
|
|
|
98
178
|
if (current > requestedEnd)
|
|
99
179
|
hasMore = true;
|
|
100
180
|
};
|
|
101
|
-
|
|
181
|
+
// Keep validation and streaming on the same non-blocking descriptor. This closes the stat→open race
|
|
182
|
+
// where an attacker/local generator exchanges a regular path for a FIFO after validation.
|
|
183
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
184
|
+
let stream;
|
|
102
185
|
try {
|
|
186
|
+
const info = await handle.stat();
|
|
187
|
+
if (!info.isFile())
|
|
188
|
+
throw new NonRegularFileError(path);
|
|
189
|
+
// `end` is an inclusive byte offset. It makes the scan ceiling a true fd-level byte bound (not just
|
|
190
|
+
// a post-read character counter, which could overshoot badly on multi-byte text or a giant chunk).
|
|
191
|
+
stream = handle.createReadStream({ encoding: "utf8", highWaterMark: 64 * 1024, autoClose: false, end: maxScan - 1 });
|
|
103
192
|
for await (const raw of stream) {
|
|
104
193
|
const chunk = String(raw);
|
|
105
194
|
if (chunk.length)
|
|
@@ -125,17 +214,22 @@ export async function streamFileSlice(path, offset = 1, limit = 300, options = {
|
|
|
125
214
|
stoppedEarly = true;
|
|
126
215
|
break;
|
|
127
216
|
}
|
|
128
|
-
|
|
217
|
+
}
|
|
218
|
+
if (!stoppedEarly && stream.bytesRead >= maxScan) {
|
|
219
|
+
// Re-fstat the same open file description so concurrent growth is also detected. An exact-size file
|
|
220
|
+
// is genuine EOF and should still receive the normal total-line rendering.
|
|
221
|
+
const latest = await handle.stat();
|
|
222
|
+
if (latest.size > stream.bytesRead) {
|
|
129
223
|
scanLimited = true;
|
|
130
224
|
stoppedEarly = true;
|
|
131
225
|
if (lineNo >= start && lineNo <= requestedEnd)
|
|
132
226
|
finishLine(true);
|
|
133
|
-
break;
|
|
134
227
|
}
|
|
135
228
|
}
|
|
136
229
|
}
|
|
137
230
|
finally {
|
|
138
|
-
stream
|
|
231
|
+
stream?.destroy();
|
|
232
|
+
await handle.close().catch(() => { });
|
|
139
233
|
}
|
|
140
234
|
if (!stoppedEarly) {
|
|
141
235
|
// A trailing newline creates no phantom line, matching String#split + trailing-empty removal.
|
package/dist/fs-write.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// Crash-safe UTF-8 writes for coding tools. Content is staged beside the destination, fsynced, then
|
|
2
2
|
// renamed into place so a killed process never leaves a half-written source file.
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { constants, linkSync, lstatSync, readlinkSync, renameSync, symlinkSync, unlinkSync } from "node:fs";
|
|
6
|
+
import { link, lstat, mkdir, open, realpath, rename, rmdir, stat, unlink } from "node:fs/promises";
|
|
7
|
+
import { NonRegularFileError, readRegularFileSnapshotNoFollow } from "./fs-read.js";
|
|
5
8
|
export class FileChangedError extends Error {
|
|
6
9
|
code = "HARA_FILE_CHANGED";
|
|
7
10
|
constructor(path) {
|
|
@@ -24,12 +27,139 @@ async function writeTarget(path) {
|
|
|
24
27
|
}
|
|
25
28
|
return path;
|
|
26
29
|
}
|
|
30
|
+
/** mkdir -p with ownership accounting: only a mkdir call that actually succeeded is recorded. */
|
|
31
|
+
async function ensureDirectory(path, created) {
|
|
32
|
+
try {
|
|
33
|
+
const info = await stat(path);
|
|
34
|
+
if (!info.isDirectory())
|
|
35
|
+
throw new Error(`${path} is not a directory`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error?.code !== "ENOENT")
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
const parent = dirname(path);
|
|
43
|
+
if (parent === path)
|
|
44
|
+
throw new Error(`cannot create directory ${path}`);
|
|
45
|
+
await ensureDirectory(parent, created);
|
|
46
|
+
try {
|
|
47
|
+
await mkdir(path);
|
|
48
|
+
const info = await lstat(path);
|
|
49
|
+
if (!info.isDirectory())
|
|
50
|
+
throw new Error(`${path} changed while it was being created`);
|
|
51
|
+
// Store the canonical name now. Keeping a lexical path beneath a symlink would make undo look in a
|
|
52
|
+
// different tree after that parent link is retargeted, silently leaving directories we created behind.
|
|
53
|
+
const canonical = await realpath(path);
|
|
54
|
+
const canonicalInfo = await lstat(canonical);
|
|
55
|
+
if (!canonicalInfo.isDirectory() || canonicalInfo.dev !== info.dev || canonicalInfo.ino !== info.ino) {
|
|
56
|
+
throw new FileChangedError(path);
|
|
57
|
+
}
|
|
58
|
+
created.push({ path: canonical, dev: info.dev, ino: info.ino, mode: info.mode & 0o777 });
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error?.code !== "EEXIST")
|
|
62
|
+
throw error;
|
|
63
|
+
const info = await stat(path);
|
|
64
|
+
if (!info.isDirectory())
|
|
65
|
+
throw new Error(`${path} is not a directory`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Remove only directories this process actually created and that still name the same empty inode. */
|
|
69
|
+
export async function removeCreatedDirectories(created) {
|
|
70
|
+
for (let i = created.length - 1; i >= 0; i--) {
|
|
71
|
+
const expected = created[i];
|
|
72
|
+
let info;
|
|
73
|
+
try {
|
|
74
|
+
info = await lstat(expected.path);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error?.code === "ENOENT")
|
|
78
|
+
continue;
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
if (!info.isDirectory() || info.dev !== expected.dev || info.ino !== expected.ino || (info.mode & 0o777) !== expected.mode) {
|
|
82
|
+
throw new FileChangedError(expected.path);
|
|
83
|
+
}
|
|
84
|
+
await rmdir(expected.path); // ENOTEMPTY is a safe refusal: concurrent content is never removed.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Delete a previously verified quarantine entry without reopening the long verify→unlink race.
|
|
89
|
+
*
|
|
90
|
+
* The caller has already verified content through a no-follow descriptor. We atomically claim the current
|
|
91
|
+
* directory entry under one more unpredictable name, then perform the final no-follow identity check and
|
|
92
|
+
* unlink synchronously in the same JS turn. A same-process watcher therefore cannot replace the entry in
|
|
93
|
+
* the long asynchronous content-read window and have an unrelated inode unlinked. On any mismatch/failure,
|
|
94
|
+
* the claimed entry is deliberately retained and its exact recovery path is included in the error.
|
|
95
|
+
*/
|
|
96
|
+
export function discardClaimedPath(path, expected) {
|
|
97
|
+
const disposal = join(dirname(path), `.hara-discard-${process.pid}-${randomUUID()}.tmp`);
|
|
98
|
+
renameSync(path, disposal);
|
|
99
|
+
try {
|
|
100
|
+
const info = lstatSync(disposal);
|
|
101
|
+
const isLink = expected.linkTarget !== undefined;
|
|
102
|
+
const same = info.dev === expected.dev &&
|
|
103
|
+
info.ino === expected.ino &&
|
|
104
|
+
(info.mode & 0o777) === expected.mode &&
|
|
105
|
+
info.nlink === expected.nlink &&
|
|
106
|
+
info.isSymbolicLink() === isLink &&
|
|
107
|
+
(!isLink || readlinkSync(disposal) === expected.linkTarget);
|
|
108
|
+
if (!same)
|
|
109
|
+
throw new FileChangedError(path);
|
|
110
|
+
unlinkSync(disposal);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}; claimed entry is preserved at ${disposal}`, { cause: error });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function sameClaimedPath(path, expected) {
|
|
117
|
+
const info = lstatSync(path);
|
|
118
|
+
const isLink = expected.linkTarget !== undefined;
|
|
119
|
+
return (info.dev === expected.dev &&
|
|
120
|
+
info.ino === expected.ino &&
|
|
121
|
+
(info.mode & 0o777) === expected.mode &&
|
|
122
|
+
info.nlink === expected.nlink &&
|
|
123
|
+
info.isSymbolicLink() === isLink &&
|
|
124
|
+
(!isLink || readlinkSync(path) === expected.linkTarget));
|
|
125
|
+
}
|
|
126
|
+
/** Restore a move-claimed entry without overwriting a path that appeared concurrently. */
|
|
127
|
+
function restoreClaimedPath(claimed, target, expected) {
|
|
128
|
+
if (!sameClaimedPath(claimed, expected)) {
|
|
129
|
+
throw new Error(`${new FileChangedError(target).message}; claimed entry is preserved at ${claimed}`);
|
|
130
|
+
}
|
|
131
|
+
const info = lstatSync(claimed);
|
|
132
|
+
if (info.isDirectory()) {
|
|
133
|
+
// POSIX has no portable rename-no-replace for directories. Retaining is safer than overwriting a new target.
|
|
134
|
+
throw new Error(`cannot safely restore a concurrently claimed directory; it is preserved at ${claimed}`);
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
if (expected.linkTarget !== undefined)
|
|
138
|
+
symlinkSync(expected.linkTarget, target);
|
|
139
|
+
else
|
|
140
|
+
linkSync(claimed, target);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (error?.code === "EEXIST") {
|
|
144
|
+
throw new Error(`another entry appeared at ${target}; the claimed entry is preserved at ${claimed}`);
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
discardClaimedPath(claimed, {
|
|
149
|
+
...expected,
|
|
150
|
+
nlink: expected.nlink + (expected.linkTarget === undefined ? 1 : 0),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
27
153
|
async function syncDirectory(path) {
|
|
28
154
|
// Directory fsync makes the rename durable across a power loss on POSIX. Some filesystems/platforms
|
|
29
155
|
// reject opening directories, so durability degrades gracefully after the file itself was synced.
|
|
30
156
|
try {
|
|
31
|
-
|
|
157
|
+
// The directory name can be exchanged after rename. O_NONBLOCK plus fstat on this exact descriptor
|
|
158
|
+
// keeps best-effort durability from hanging forever on a replacement FIFO/device.
|
|
159
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
32
160
|
try {
|
|
161
|
+
if (!(await handle.stat()).isDirectory())
|
|
162
|
+
return;
|
|
33
163
|
await handle.sync();
|
|
34
164
|
}
|
|
35
165
|
finally {
|
|
@@ -42,12 +172,27 @@ async function syncDirectory(path) {
|
|
|
42
172
|
}
|
|
43
173
|
/** Atomically replace/create a UTF-8 file, optionally refusing to overwrite a newer disk version. */
|
|
44
174
|
export async function atomicWriteText(path, content, options = {}) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
175
|
+
let target = await writeTarget(path);
|
|
176
|
+
let dir = dirname(target);
|
|
177
|
+
const createdDirs = [];
|
|
178
|
+
await ensureDirectory(dir, createdDirs);
|
|
179
|
+
// Canonicalize the parent too, not just a final-component symlink. A workspace may sit beneath a linked
|
|
180
|
+
// directory; returning/rolling back the canonical target prevents a concurrent parent retarget from sending
|
|
181
|
+
// later transaction steps to a different tree.
|
|
182
|
+
dir = await realpath(dir);
|
|
183
|
+
target = join(dir, basename(target));
|
|
184
|
+
// A caller-provided preflight identity is authoritative. Reading mode from a path before the move-claim
|
|
185
|
+
// would let a temporary same-path replacement influence the mode even when the expected inode is restored
|
|
186
|
+
// before claim verification.
|
|
187
|
+
let mode = options.mode === undefined ? (options.expectedIdentity?.mode ?? 0o666) : options.mode & 0o777;
|
|
188
|
+
let preserveExactMode = options.mode !== undefined || options.expectedIdentity !== undefined;
|
|
49
189
|
try {
|
|
50
|
-
|
|
190
|
+
const info = await stat(target);
|
|
191
|
+
if (!info.isFile())
|
|
192
|
+
throw new NonRegularFileError(path);
|
|
193
|
+
if (options.mode === undefined && !options.expectedIdentity)
|
|
194
|
+
mode = info.mode & 0o777;
|
|
195
|
+
preserveExactMode = true;
|
|
51
196
|
}
|
|
52
197
|
catch (error) {
|
|
53
198
|
if (error?.code !== "ENOENT")
|
|
@@ -57,12 +202,21 @@ export async function atomicWriteText(path, content, options = {}) {
|
|
|
57
202
|
// perfectly valid near-NAME_MAX file impossible to edit because the temporary name becomes longer.
|
|
58
203
|
const temp = join(dir, `.hara-${process.pid}-${Date.now().toString(36)}-${tempSequence++}.tmp`);
|
|
59
204
|
let staged = false;
|
|
205
|
+
let writtenIdentity;
|
|
206
|
+
const warnings = [];
|
|
207
|
+
let succeeded = false;
|
|
60
208
|
try {
|
|
61
209
|
const handle = await open(temp, "wx", mode);
|
|
62
210
|
staged = true;
|
|
63
211
|
try {
|
|
64
212
|
await handle.writeFile(content, "utf8");
|
|
213
|
+
// open(2) applies the process umask even when replacing an existing file. Restore that existing (or
|
|
214
|
+
// explicit rollback) mode on the staged fd; brand-new ordinary files still honor the user's umask.
|
|
215
|
+
if (preserveExactMode)
|
|
216
|
+
await handle.chmod(mode);
|
|
65
217
|
await handle.sync();
|
|
218
|
+
const info = await handle.stat();
|
|
219
|
+
writtenIdentity = { dev: info.dev, ino: info.ino, mode: info.mode & 0o777, nlink: info.nlink };
|
|
66
220
|
}
|
|
67
221
|
finally {
|
|
68
222
|
await handle.close();
|
|
@@ -81,25 +235,97 @@ export async function atomicWriteText(path, content, options = {}) {
|
|
|
81
235
|
await unlink(temp);
|
|
82
236
|
staged = false;
|
|
83
237
|
}
|
|
84
|
-
else {
|
|
85
|
-
|
|
86
|
-
|
|
238
|
+
else if (typeof options.expected === "string") {
|
|
239
|
+
// Claim the directory entry BEFORE verification. Reading the old fd and then rename-overwriting the
|
|
240
|
+
// path leaves a verify→commit race; a concurrent replacement can otherwise be silently destroyed.
|
|
241
|
+
const claimed = join(dir, `.hara-claim-${process.pid}-${randomUUID()}.tmp`);
|
|
242
|
+
try {
|
|
243
|
+
await rename(target, claimed);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (error?.code === "ENOENT")
|
|
247
|
+
throw new FileChangedError(path);
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
let claimedIdentity;
|
|
251
|
+
try {
|
|
252
|
+
const pathInfo = await lstat(claimed);
|
|
253
|
+
claimedIdentity = {
|
|
254
|
+
dev: pathInfo.dev,
|
|
255
|
+
ino: pathInfo.ino,
|
|
256
|
+
mode: pathInfo.mode & 0o777,
|
|
257
|
+
nlink: pathInfo.nlink,
|
|
258
|
+
linkTarget: pathInfo.isSymbolicLink() ? readlinkSync(claimed) : undefined,
|
|
259
|
+
};
|
|
260
|
+
const current = await readRegularFileSnapshotNoFollow(claimed);
|
|
261
|
+
const expectedIdentity = options.expectedIdentity;
|
|
262
|
+
const identityMatches = !expectedIdentity ||
|
|
263
|
+
(current.dev === expectedIdentity.dev &&
|
|
264
|
+
current.ino === expectedIdentity.ino &&
|
|
265
|
+
current.mode === expectedIdentity.mode &&
|
|
266
|
+
current.nlink === expectedIdentity.nlink);
|
|
267
|
+
if (!identityMatches || current.text !== options.expected)
|
|
268
|
+
throw new FileChangedError(path);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
87
271
|
try {
|
|
88
|
-
|
|
272
|
+
if (!claimedIdentity) {
|
|
273
|
+
const info = lstatSync(claimed);
|
|
274
|
+
claimedIdentity = {
|
|
275
|
+
dev: info.dev,
|
|
276
|
+
ino: info.ino,
|
|
277
|
+
mode: info.mode & 0o777,
|
|
278
|
+
nlink: info.nlink,
|
|
279
|
+
linkTarget: info.isSymbolicLink() ? readlinkSync(claimed) : undefined,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
restoreClaimedPath(claimed, target, claimedIdentity);
|
|
89
283
|
}
|
|
90
|
-
catch {
|
|
91
|
-
throw new
|
|
284
|
+
catch (restoreError) {
|
|
285
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}; safe restore was incomplete: ${restoreError?.message ?? String(restoreError)}`, { cause: error });
|
|
92
286
|
}
|
|
93
|
-
|
|
94
|
-
throw new FileChangedError(path);
|
|
287
|
+
throw error;
|
|
95
288
|
}
|
|
289
|
+
if (!claimedIdentity)
|
|
290
|
+
throw new Error(`Failed to identify claimed file for ${path}`);
|
|
291
|
+
try {
|
|
292
|
+
await link(temp, target); // atomic create-if-absent: never overwrites an entry created after claim.
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
let recovery = "";
|
|
296
|
+
try {
|
|
297
|
+
restoreClaimedPath(claimed, target, claimedIdentity);
|
|
298
|
+
}
|
|
299
|
+
catch (restoreError) {
|
|
300
|
+
recovery = `; claimed old entry is retained: ${restoreError?.message ?? String(restoreError)}`;
|
|
301
|
+
}
|
|
302
|
+
if (error?.code === "EEXIST")
|
|
303
|
+
throw new Error(`${new FileChangedError(path).message}${recovery}`);
|
|
304
|
+
throw new Error(`${error?.message ?? String(error)}${recovery}`, { cause: error });
|
|
305
|
+
}
|
|
306
|
+
await unlink(temp);
|
|
307
|
+
staged = false;
|
|
308
|
+
try {
|
|
309
|
+
discardClaimedPath(claimed, claimedIdentity);
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
warnings.push(`old entry cleanup was refused: ${error?.message ?? String(error)}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
96
316
|
await rename(temp, target);
|
|
97
317
|
staged = false;
|
|
98
318
|
}
|
|
99
319
|
await syncDirectory(dir);
|
|
320
|
+
succeeded = true;
|
|
100
321
|
}
|
|
101
322
|
finally {
|
|
102
323
|
if (staged)
|
|
103
324
|
await unlink(temp).catch(() => { });
|
|
325
|
+
if (!succeeded)
|
|
326
|
+
await removeCreatedDirectories(createdDirs).catch(() => { });
|
|
104
327
|
}
|
|
328
|
+
if (!writtenIdentity)
|
|
329
|
+
throw new Error(`Failed to identify staged file for ${path}`);
|
|
330
|
+
return { ...writtenIdentity, target, createdDirs, ...(warnings.length ? { warnings } : {}) };
|
|
105
331
|
}
|
package/dist/gateway/dingtalk.js
CHANGED
|
@@ -38,7 +38,10 @@ export function parseDingtalkMessage(msg) {
|
|
|
38
38
|
text = flattenRichText(msg.content?.richText).trim();
|
|
39
39
|
if (!text)
|
|
40
40
|
return null; // unsupported type (audio/file/etc.) or empty
|
|
41
|
-
|
|
41
|
+
// DingTalk documents conversationType=1 for a one-to-one bot chat and 2 for groups. Missing/novel values
|
|
42
|
+
// stay group-classified so protocol drift cannot expose the full-auto DM driver in a channel.
|
|
43
|
+
const chatType = String(msg.conversationType ?? "").trim() === "1" ? "p2p" : "group";
|
|
44
|
+
return { msg: { chatId, userId, userName, text, chatType }, sessionWebhook };
|
|
42
45
|
}
|
|
43
46
|
/** Flatten a DingTalk richText message (an array of {text}/{type} runs) into plain text (pure). */
|
|
44
47
|
function flattenRichText(runs) {
|
package/dist/gateway/discord.js
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
// the cross-platform gateway plumbing (send_file, in-chat system context, stuck-guard, image attach/describe)
|
|
5
5
|
// works unchanged. NOTE: receiving message text needs the privileged "Message Content Intent" enabled for the
|
|
6
6
|
// bot in the Discord developer portal.
|
|
7
|
-
import { readFileSync
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { basename } from "node:path";
|
|
9
|
+
import { InboundMediaBudget, savePrivateResponse } from "./media.js";
|
|
10
10
|
import { chunkText } from "./telegram.js";
|
|
11
11
|
const REST = "https://discord.com/api/v10";
|
|
12
12
|
const GATEWAY = "wss://gateway.discord.gg/?v=10&encoding=json";
|
|
@@ -20,16 +20,12 @@ const sleep = (ms, signal) => new Promise((r) => {
|
|
|
20
20
|
signal?.addEventListener?.("abort", () => { clearTimeout(t); r(); }, { once: true });
|
|
21
21
|
});
|
|
22
22
|
const isImage = (name, mime) => (mime?.startsWith("image/") ?? false) || /\.(png|jpe?g|gif|webp)$/i.test(name);
|
|
23
|
-
async function downloadDiscordAttachment(url, filename) {
|
|
23
|
+
async function downloadDiscordAttachment(url, filename, options) {
|
|
24
24
|
try {
|
|
25
|
-
const r = await fetch(url);
|
|
25
|
+
const r = await fetch(url, { signal: options.signal });
|
|
26
26
|
if (!r.ok)
|
|
27
27
|
return null;
|
|
28
|
-
|
|
29
|
-
mkdirSync(dir, { recursive: true });
|
|
30
|
-
const path = join(dir, `dc_${Date.now()}_${basename(filename) || "file.bin"}`);
|
|
31
|
-
writeFileSync(path, Buffer.from(await r.arrayBuffer()));
|
|
32
|
-
return path;
|
|
28
|
+
return await savePrivateResponse(r, { platform: "discord", filenameHint: filename, ...options });
|
|
33
29
|
}
|
|
34
30
|
catch {
|
|
35
31
|
return null;
|
|
@@ -37,7 +33,13 @@ async function downloadDiscordAttachment(url, filename) {
|
|
|
37
33
|
}
|
|
38
34
|
/** Parse a Discord MESSAGE_CREATE payload → InboundMsg + its image attachment URLs (pure; download happens in
|
|
39
35
|
* start()). null = ignore (own message / another bot / empty). */
|
|
40
|
-
export function
|
|
36
|
+
export function discordChatType(message, resolvedChannelType) {
|
|
37
|
+
if (message?.guild_id)
|
|
38
|
+
return "group";
|
|
39
|
+
// Discord channel type 1 is a one-to-one DM; type 3 is a group DM. Missing/failed REST metadata stays group.
|
|
40
|
+
return Number(resolvedChannelType) === 1 ? "p2p" : "group";
|
|
41
|
+
}
|
|
42
|
+
export function parseDiscordMessage(d, selfId, resolvedChannelType) {
|
|
41
43
|
if (!d?.channel_id || !d?.author?.id)
|
|
42
44
|
return null;
|
|
43
45
|
if (d.author.id === selfId || d.author.bot)
|
|
@@ -55,6 +57,7 @@ export function parseDiscordMessage(d, selfId) {
|
|
|
55
57
|
userId: String(d.author.id),
|
|
56
58
|
userName: d.author.global_name || d.author.username || String(d.author.id),
|
|
57
59
|
text: text || "[图片]",
|
|
60
|
+
chatType: discordChatType(d, resolvedChannelType),
|
|
58
61
|
},
|
|
59
62
|
imageUrls,
|
|
60
63
|
};
|
|
@@ -78,13 +81,13 @@ export function discordAdapter(token) {
|
|
|
78
81
|
form.append("files[0]", new Blob([readFileSync(filePath)]), basename(filePath));
|
|
79
82
|
await fetch(`${REST}/channels/${chatId}/messages`, { method: "POST", headers: auth, body: form }).catch(() => { });
|
|
80
83
|
},
|
|
81
|
-
async start(onMessage, signal) {
|
|
84
|
+
async start(onMessage, signal, shouldDownload) {
|
|
82
85
|
if (!WSImpl) {
|
|
83
86
|
console.error("hara gateway: Discord needs Node ≥ 22 (global WebSocket). Upgrade Node.");
|
|
84
87
|
return;
|
|
85
88
|
}
|
|
86
89
|
while (!signal.aborted) {
|
|
87
|
-
await connectOnce(token, onMessage, signal);
|
|
90
|
+
await connectOnce(token, onMessage, signal, shouldDownload);
|
|
88
91
|
if (!signal.aborted)
|
|
89
92
|
await sleep(3000, signal); // reconnect backoff
|
|
90
93
|
}
|
|
@@ -93,9 +96,10 @@ export function discordAdapter(token) {
|
|
|
93
96
|
}
|
|
94
97
|
/** One gateway connection: HELLO→heartbeat, IDENTIFY, then dispatch MESSAGE_CREATE. Resolves on close/abort;
|
|
95
98
|
* the caller reconnects. v1 keeps it simple — fresh IDENTIFY each time, no RESUME. */
|
|
96
|
-
function connectOnce(token, onMessage, signal) {
|
|
99
|
+
function connectOnce(token, onMessage, signal, shouldDownload) {
|
|
97
100
|
return new Promise((resolve) => {
|
|
98
101
|
const ws = new WSImpl(GATEWAY);
|
|
102
|
+
const channelTypes = new Map();
|
|
99
103
|
let hb = null;
|
|
100
104
|
let seq = null;
|
|
101
105
|
let selfId = "";
|
|
@@ -145,12 +149,43 @@ function connectOnce(token, onMessage, signal) {
|
|
|
145
149
|
if (p.t === "READY")
|
|
146
150
|
selfId = p.d?.user?.id ?? "";
|
|
147
151
|
else if (p.t === "MESSAGE_CREATE") {
|
|
152
|
+
// Parse/filter first with the safe group default, then resolve non-guild channel metadata only for a
|
|
153
|
+
// real user message. Channel type is immutable, so known results are safe to cache for this connection.
|
|
148
154
|
const parsed = parseDiscordMessage(p.d, selfId);
|
|
149
155
|
if (parsed) {
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
156
|
+
if (!p.d?.guild_id) {
|
|
157
|
+
const channelId = String(p.d?.channel_id ?? "");
|
|
158
|
+
let channelType = channelTypes.get(channelId);
|
|
159
|
+
if (channelType === undefined && channelId) {
|
|
160
|
+
try {
|
|
161
|
+
const response = await fetch(`${REST}/channels/${encodeURIComponent(channelId)}`, {
|
|
162
|
+
headers: { Authorization: `Bot ${token}` },
|
|
163
|
+
signal,
|
|
164
|
+
});
|
|
165
|
+
const body = response.ok ? (await response.json()) : null;
|
|
166
|
+
const candidate = Number(body?.type);
|
|
167
|
+
if (Number.isSafeInteger(candidate)) {
|
|
168
|
+
channelType = candidate;
|
|
169
|
+
channelTypes.set(channelId, candidate);
|
|
170
|
+
if (channelTypes.size > 1000)
|
|
171
|
+
channelTypes.delete(channelTypes.keys().next().value);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
/* cannot prove a DM → retain the safe group classification and retry on the next message */
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
parsed.msg.chatType = discordChatType(p.d, channelType);
|
|
179
|
+
}
|
|
180
|
+
if (shouldDownload?.(parsed.msg) === true) {
|
|
181
|
+
const budget = new InboundMediaBudget("discord", signal);
|
|
182
|
+
for (const im of parsed.imageUrls) {
|
|
183
|
+
const path = await budget.download((options) => downloadDiscordAttachment(im.url, im.name, options));
|
|
184
|
+
if (path) {
|
|
185
|
+
(parsed.msg.images ??= []).push(path);
|
|
186
|
+
(parsed.msg.transientFiles ??= []).push(path);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
154
189
|
}
|
|
155
190
|
await onMessage(parsed.msg).catch(() => { });
|
|
156
191
|
}
|