jeo-code 0.12.7 → 0.12.8
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 +4 -0
- package/README.ja.md +2 -0
- package/README.ko.md +2 -0
- package/README.md +4 -2
- package/README.zh.md +2 -0
- package/package.json +1 -1
- package/src/agent/compaction.ts +6 -7
- package/src/agent/notify/discord-api.ts +125 -28
- package/src/agent/notify/discord-artifacts.ts +126 -0
- package/src/agent/notify/discord-attachments.ts +116 -0
- package/src/agent/notify/discord-daemon.ts +213 -39
- package/src/agent/notify/session-endpoint.ts +127 -5
- package/src/agent/session.ts +7 -5
- package/src/cli/runner.ts +9 -0
- package/src/cli.ts +4 -0
- package/src/commands/launch/agy-turn.ts +20 -7
- package/src/commands/launch/confirm.ts +78 -0
- package/src/commands/launch/flags.ts +7 -0
- package/src/commands/launch/input.ts +18 -1
- package/src/commands/launch.ts +320 -40
- package/src/commands/messenger.ts +146 -0
- package/src/commands/notify.ts +3 -0
- package/src/messenger/activity.ts +30 -0
- package/src/messenger/artifacts.ts +167 -0
- package/src/messenger/config.ts +145 -0
- package/src/messenger/discord-setup.ts +568 -0
- package/src/messenger/discord.ts +707 -0
- package/src/messenger/gateway.ts +444 -0
- package/src/messenger/http.ts +88 -0
- package/src/messenger/service.ts +122 -0
- package/src/messenger/session-pool.ts +109 -0
- package/src/messenger/slack.ts +333 -0
- package/src/messenger/telegram.ts +241 -0
- package/src/messenger/types.ts +82 -0
- package/src/tui/components/slash.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,10 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
### Added
|
|
13
|
+
- **Discord session files and control** — local opt-in enables authorized Discord prompts to use host session tools and exchange staged attachments; see the [Discord guide](docs/integrations/discord.md) for limits, public visibility, and revocation boundaries.
|
|
14
|
+
- **Discord messenger gateway** — guided `jeo messenger setup`, local configuration checking, and a separate gateway with per-conversation persisted sessions, opt-in host tools, threaded replies, and optional bounded attachments. See the [messenger guide](docs/integrations/messenger.md).
|
|
15
|
+
|
|
12
16
|
## [0.12.7] - 2026-10-18
|
|
13
17
|
_Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization._
|
|
14
18
|
|
package/README.ja.md
CHANGED
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
|
|
31
31
|
## ドキュメント
|
|
32
32
|
|
|
33
|
+
- [双方向メッセンジャーゲートウェイ (Discord, Telegram, Slack)](docs/integrations/messenger.md)
|
|
34
|
+
|
|
33
35
|
📖 **[使い方ガイド](docs/usage-guide.md)** — インストール、TUI操作(↑履歴、Ctrl+O、`!`シェル)、スラッシュコマンド、`/resume`、スペックファーストワークフローをデモ動画付きで解説。
|
|
34
36
|
|
|
35
37
|
<video src="https://raw.githubusercontent.com/akillness/jeo-code/main/docs/jeo-code-promo.mp4" controls muted playsinline width="100%"></video>
|
package/README.ko.md
CHANGED
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
|
|
31
31
|
## 문서
|
|
32
32
|
|
|
33
|
+
- [양방향 메신저 게이트웨이 (Discord, Telegram, Slack)](docs/integrations/messenger.md)
|
|
34
|
+
|
|
33
35
|
📖 **[사용 가이드](docs/usage-guide.md)** — 설치, TUI 조작(↑ 이전 쿼리, Ctrl+O, `!` 셸), 슬래시 명령, `/resume`, 스펙 우선 워크플로를 데모 영상과 함께 설명합니다.
|
|
34
36
|
|
|
35
37
|
<video src="https://raw.githubusercontent.com/akillness/jeo-code/main/docs/jeo-code-promo.mp4" controls muted playsinline width="100%"></video>
|
package/README.md
CHANGED
|
@@ -32,6 +32,8 @@ Run `jeo` inside a repository and it reads files, edits them, runs commands, and
|
|
|
32
32
|
|
|
33
33
|
## Documentation
|
|
34
34
|
|
|
35
|
+
- [Bidirectional messenger gateway (Discord, Telegram, Slack)](docs/integrations/messenger.md)
|
|
36
|
+
|
|
35
37
|
📖 **[Usage guide](docs/usage-guide.md)** — install, TUI controls (↑ recall, Ctrl+O, `!` shell), slash commands, `/resume`, and the spec-first workflow, with a demo video.
|
|
36
38
|
|
|
37
39
|
<video src="https://raw.githubusercontent.com/akillness/jeo-code/main/docs/jeo-code-promo.mp4" controls muted playsinline width="100%"></video>
|
|
@@ -258,7 +260,7 @@ jeo daemon start|stop|status|reload # the shared daemon has no provider select
|
|
|
258
260
|
| Destination | private chat via challenge pairing (send the displayed `/start jeo_<code>` to the bot within 120 s) or explicit `--chat-id`; groups need `--allowed-user-ids` | explicit `--channel-id` (text channel, DM, announcement, or an existing thread) | explicit `--channel-id` (`C…`) the bot has joined; workspace is pinned to the token's team |
|
|
259
261
|
| Allowlist | optional for a private chat, required for groups | required (human user IDs; bots are ignored) | required (human user IDs) |
|
|
260
262
|
| Transport | direct Bot API `getUpdates` long-poll, one poll owner per bot token | REST for sends, Gateway WebSocket for inbound | Web API for sends, Socket Mode WebSocket for inbound |
|
|
261
|
-
| Extras | forum topics, inline cancel buttons, image attachments, optional per-session topics (`notifications.telegram.perSessionTopics`, private chat with Threaded Mode only) |
|
|
263
|
+
| Extras | forum topics, inline cancel buttons, image attachments, optional per-session topics (`notifications.telegram.perSessionTopics`, private chat with Threaded Mode only) | session replies, public channel reply threads, and staged attachments; see the [Discord guide](docs/integrations/discord.md) for limits and local control | reply in a session notification's thread to send that session text (24 h); registered Slack slash commands or `@bot /command` both work |
|
|
262
264
|
| Commands | `/subagents` `/steer <session> <subagent> <msg>` `/cancel <session> <subagent>` `/help` | `/sessions` `/subagents` `/send <session> <text>` `/steer …` `/cancel …` `/help` | same as Discord |
|
|
263
265
|
|
|
264
266
|
What the words mean, because they are not interchangeable:
|
|
@@ -266,7 +268,7 @@ What the words mean, because they are not interchangeable:
|
|
|
266
268
|
- **Setup** validates bot identity and destination access with the official API (Telegram `getMe`, Discord bot + channel lookup, Slack `auth.test`/`bots.info`/`conversations.info` and a Socket Mode URL) and stores the result in `~/.jeo/config.json` under `notifications.<provider>` — **plaintext**; keep that file private. It does not send a message and does not open a socket.
|
|
267
269
|
- **`initialized`** (`jeo daemon status`) means the daemon process owns its lock and has started its transports; it is not proof of a successful Telegram poll, Discord Gateway handshake, or Slack Socket Mode connection. Use `notify health` / `notify test` for that.
|
|
268
270
|
- **Changing a token or destination** resets the allowlist for Discord and Slack (you must pass `--allowed-user-ids` again) and the per-session topic map for Telegram; re-running setup with the exact same credentials and destination keeps them.
|
|
269
|
-
- **Remote commands are literal text** (`/subagents`, …) authorized to the configured chat/channel and allowlist; Slack additionally accepts the same names as registered slash commands.
|
|
271
|
+
- **Remote commands are literal text** (`/subagents`, …) authorized to the configured chat/channel and allowlist; Slack additionally accepts the same names as registered slash commands. Discord session control defaults off and requires the host operator to submit `/discord control on --allow-full-control` locally; `/discord control off` revokes it. Authorized users can exercise the host session's tool permissions; this is not a sandbox. See the [Discord guide](docs/integrations/discord.md) for file handling and revocation limits. Never put credentials in chat.
|
|
270
272
|
- **Retries are bounded per provider**: Telegram honours a 429 `retry_after` cooldown (clamped 1 s–1 h) with separate poll backoff; Discord and Slack retry at most 3 times on 429 using the server's `Retry-After`, and never re-send a POST whose outcome is unknown (the platform may already have it).
|
|
271
273
|
- **Orphan recovery**: if a daemon crashes, its lock becomes `stale`; the next `jeo daemon start` reclaims it. `jeo daemon stop` refuses to signal a recycled PID when the host can report process start times, and says so when it cannot.
|
|
272
274
|
- **Verified so far**: Telegram end to end (real send, polling, clean shutdown). Discord and Slack are implemented behind the same daemon with offline transport/wiring tests and code, security, and type reviews; live connectivity has not been exercised against real bot/app credentials.
|
package/README.zh.md
CHANGED
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
|
|
31
31
|
## 文档
|
|
32
32
|
|
|
33
|
+
- [双向消息网关(Discord、Telegram、Slack)](docs/integrations/messenger.md)
|
|
34
|
+
|
|
33
35
|
📖 **[使用指南](docs/usage-guide.md)** — 安装、TUI 操作(↑ 历史、Ctrl+O、`!` shell)、斜杠命令、`/resume`、规格优先工作流,附演示视频。
|
|
34
36
|
|
|
35
37
|
<video src="https://raw.githubusercontent.com/akillness/jeo-code/main/docs/jeo-code-promo.mp4" controls muted playsinline width="100%"></video>
|
package/package.json
CHANGED
package/src/agent/compaction.ts
CHANGED
|
@@ -315,7 +315,8 @@ export function extractTouchedFiles(messages: Message[], max = 20): string[] {
|
|
|
315
315
|
|
|
316
316
|
export async function maybeCompact(
|
|
317
317
|
history: Message[],
|
|
318
|
-
opts: CompactionOptions = {}
|
|
318
|
+
opts: CompactionOptions = {},
|
|
319
|
+
summarize: typeof callLlm = callLlm,
|
|
319
320
|
): Promise<CompactionResult> {
|
|
320
321
|
const maxMessages = opts.maxMessages ?? (opts.force ? 1 : 40);
|
|
321
322
|
|
|
@@ -396,7 +397,7 @@ export async function maybeCompact(
|
|
|
396
397
|
break;
|
|
397
398
|
}
|
|
398
399
|
try {
|
|
399
|
-
summary = await
|
|
400
|
+
summary = await summarize(
|
|
400
401
|
[
|
|
401
402
|
{ role: "user", content: olderFormatted }
|
|
402
403
|
],
|
|
@@ -417,6 +418,9 @@ export async function maybeCompact(
|
|
|
417
418
|
}
|
|
418
419
|
}
|
|
419
420
|
|
|
421
|
+
// A provider can resolve after cancellation; never mutate the cancelled turn's history.
|
|
422
|
+
if (opts.signal?.aborted) return { compacted: false, removed: 0 };
|
|
423
|
+
|
|
420
424
|
if (summary !== undefined) {
|
|
421
425
|
// Rung 1 success: behave exactly as before — summary message + bounded recent.
|
|
422
426
|
const systemMessages = hasSystem ? [history[0]] : [];
|
|
@@ -452,11 +456,6 @@ export async function maybeCompact(
|
|
|
452
456
|
};
|
|
453
457
|
}
|
|
454
458
|
|
|
455
|
-
// Aborted (user cancelled the turn): do NOT mutate history as a side effect of a
|
|
456
|
-
// cancelled compaction — leave it untouched and report no compaction.
|
|
457
|
-
if (opts.signal?.aborted) {
|
|
458
|
-
return { compacted: false, removed: 0 };
|
|
459
|
-
}
|
|
460
459
|
|
|
461
460
|
// Rung 2: KEEP-RECENT fallback. The summarizer persistently failed, so rather than inject
|
|
462
461
|
// a placeholder the model could mistake for real conversation, keep the N most-recent
|
|
@@ -30,48 +30,87 @@ function delay(ms: number, signal: AbortSignal): Promise<void> {
|
|
|
30
30
|
return promise;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
const DISCORD_WHITESPACE = /^[\s\u0085\u001c-\u001f]$/u;
|
|
34
|
+
const DISCORD_BOUNDARY_GUARD = "\u200b";
|
|
35
|
+
|
|
36
|
+
function splitDiscordText(text: string): { chunks: string[]; needsAttachment: boolean } {
|
|
37
|
+
const chunks: string[] = [];
|
|
38
|
+
let needsAttachment = false;
|
|
39
|
+
for (let start = 0; start < text.length;) {
|
|
40
|
+
const leading = DISCORD_WHITESPACE.test(text[start]!) ? DISCORD_BOUNDARY_GUARD : "";
|
|
41
|
+
let fallback = 0;
|
|
42
|
+
let end = Math.min(start + 2000 - leading.length, text.length);
|
|
43
|
+
for (; end > start; end--) {
|
|
44
|
+
const last = text.charCodeAt(end - 1);
|
|
45
|
+
if (end < text.length && last >= 0xd800 && last <= 0xdbff && text.charCodeAt(end) >= 0xdc00 && text.charCodeAt(end) <= 0xdfff) continue;
|
|
46
|
+
const trailing = DISCORD_WHITESPACE.test(text[end - 1]!);
|
|
47
|
+
if (end - start + leading.length + Number(trailing) > 2000) continue;
|
|
48
|
+
if (!fallback) fallback = end;
|
|
49
|
+
if (end === text.length || (!trailing && !DISCORD_WHITESPACE.test(text[end]!))) break;
|
|
50
|
+
}
|
|
51
|
+
if (end === start) end = fallback;
|
|
52
|
+
const chunk = text.slice(start, end);
|
|
53
|
+
const trailing = DISCORD_WHITESPACE.test(chunk[chunk.length - 1]!) ? DISCORD_BOUNDARY_GUARD : "";
|
|
54
|
+
if (leading || trailing) needsAttachment = true;
|
|
55
|
+
chunks.push(`${leading}${chunk}${trailing}`);
|
|
56
|
+
start = end;
|
|
57
|
+
}
|
|
58
|
+
return { chunks, needsAttachment };
|
|
59
|
+
}
|
|
60
|
+
|
|
33
61
|
export class DiscordApi {
|
|
34
62
|
private readonly controller = new AbortController();
|
|
35
63
|
private cooldownUntil = 0;
|
|
64
|
+
private reactionCooldownUntil = 0;
|
|
65
|
+
private globalCooldownUntil = 0;
|
|
36
66
|
private fatalStatus: number | undefined;
|
|
37
67
|
private queue = Promise.resolve();
|
|
38
68
|
private queued = 0;
|
|
69
|
+
private reactionQueue = Promise.resolve();
|
|
70
|
+
private reactionsQueued = 0;
|
|
71
|
+
private sendQueue = Promise.resolve();
|
|
39
72
|
|
|
40
73
|
constructor(private readonly token: string, private readonly options: DiscordApiOptions = {}) {}
|
|
41
74
|
|
|
42
75
|
stop(): void { this.controller.abort(); }
|
|
43
76
|
|
|
44
|
-
private request<T>(route: string, body?: unknown): Promise<T> {
|
|
77
|
+
private request<T>(route: string, body?: unknown, method = body === undefined ? "GET" : "POST", eligible?: () => boolean): Promise<T> {
|
|
45
78
|
if (this.queued >= 128) return Promise.reject(new DiscordApiError("Discord request queue full"));
|
|
46
79
|
this.queued++;
|
|
47
|
-
const result = this.queue.then(() => this.perform<T>(route, body));
|
|
80
|
+
const result = this.queue.then(() => this.perform<T>(route, body, method, eligible));
|
|
48
81
|
this.queue = result.then(() => {}, () => {}).finally(() => { this.queued--; });
|
|
49
82
|
return result;
|
|
50
83
|
}
|
|
51
84
|
|
|
52
|
-
private async perform<T>(route: string, body?:
|
|
85
|
+
private async perform<T>(route: string, body: unknown, method: string, eligible?: () => boolean, reaction = false): Promise<T> {
|
|
53
86
|
const now = this.options.now ?? Date.now;
|
|
54
87
|
const retries = Math.max(0, Math.min(this.options.maxRetries ?? 2, 3));
|
|
55
88
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
56
89
|
if (this.controller.signal.aborted) throw new DiscordApiError("Discord request stopped");
|
|
57
90
|
if (this.fatalStatus) throw new DiscordApiError("Discord authentication or permission denied", this.fatalStatus);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
91
|
+
for (;;) {
|
|
92
|
+
const wait = Math.max(this.globalCooldownUntil, reaction ? this.reactionCooldownUntil : this.cooldownUntil) - now();
|
|
93
|
+
if (wait > 30_000) throw new DiscordApiError("Discord rate limit cooldown active", 429, wait);
|
|
94
|
+
if (wait <= 0) break;
|
|
95
|
+
await (this.options.sleep ?? delay)(wait, this.controller.signal);
|
|
96
|
+
if (this.controller.signal.aborted) throw new DiscordApiError("Discord request stopped");
|
|
97
|
+
}
|
|
61
98
|
if (this.controller.signal.aborted) throw new DiscordApiError("Discord request stopped");
|
|
62
|
-
|
|
99
|
+
if (this.fatalStatus) throw new DiscordApiError("Discord authentication or permission denied", this.fatalStatus);
|
|
100
|
+
if (eligible && !eligible()) throw new DiscordApiError("Discord delivery no longer eligible");
|
|
101
|
+
const timeout = AbortSignal.timeout(Math.max(1, Math.min(this.options.requestTimeoutMs ?? (reaction ? 2000 : 10_000), reaction ? 2000 : 30_000)));
|
|
63
102
|
let response: Response;
|
|
64
103
|
let data: unknown;
|
|
65
104
|
try {
|
|
66
105
|
response = await (this.options.fetchImpl ?? fetch)(`https://discord.com/api/v10${route}`, {
|
|
67
|
-
method
|
|
68
|
-
headers: { Authorization: `Bot ${this.token}`, "Content-Type": "application/json", "User-Agent": `DiscordBot (https://github.com/akillness/jeo-code, ${version})` },
|
|
69
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
106
|
+
method,
|
|
107
|
+
headers: { Authorization: `Bot ${this.token}`, ...(body === undefined || body instanceof FormData ? {} : { "Content-Type": "application/json" }), "User-Agent": `DiscordBot (https://github.com/akillness/jeo-code, ${version})` },
|
|
108
|
+
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
|
|
70
109
|
signal: AbortSignal.any([this.controller.signal, timeout]),
|
|
71
110
|
redirect: "error",
|
|
72
111
|
});
|
|
73
|
-
if (response.status === 401
|
|
74
|
-
data = await response.json().catch(error => {
|
|
112
|
+
if (response.status === 401) this.fatalStatus = response.status;
|
|
113
|
+
data = response.status === 204 ? null : await response.json().catch(error => {
|
|
75
114
|
if (response.ok) throw error;
|
|
76
115
|
return null; // HTTP denial/cooldown headers remain authoritative without a JSON body.
|
|
77
116
|
});
|
|
@@ -86,11 +125,18 @@ export class DiscordApi {
|
|
|
86
125
|
? record.retry_after ?? response.headers.get("Retry-After")
|
|
87
126
|
: response.headers.get("X-RateLimit-Reset-After"));
|
|
88
127
|
const ms = Number.isFinite(raw) && raw > 0 ? raw * 1000 : 30_000;
|
|
89
|
-
|
|
90
|
-
|
|
128
|
+
const until = now() + ms;
|
|
129
|
+
if (response.status === 429 && (record.global === true || response.headers.get("X-RateLimit-Global") === "true" || response.headers.get("X-RateLimit-Scope") === "global")) {
|
|
130
|
+
this.globalCooldownUntil = Math.max(this.globalCooldownUntil, until);
|
|
131
|
+
} else if (reaction) {
|
|
132
|
+
this.reactionCooldownUntil = Math.max(this.reactionCooldownUntil, until);
|
|
133
|
+
} else {
|
|
134
|
+
this.cooldownUntil = Math.max(this.cooldownUntil, until);
|
|
135
|
+
}
|
|
91
136
|
}
|
|
92
137
|
if (response.status === 429 && attempt < retries) continue;
|
|
93
|
-
if (!response.ok) throw new DiscordApiError(`Discord HTTP ${response.status}`, response.status, Math.max(0, this.cooldownUntil - now()));
|
|
138
|
+
if (!response.ok) throw new DiscordApiError(`Discord HTTP ${response.status}`, response.status, Math.max(0, Math.max(this.globalCooldownUntil, reaction ? this.reactionCooldownUntil : this.cooldownUntil) - now()));
|
|
139
|
+
if (response.status === 204) return undefined as T;
|
|
94
140
|
if (!data || typeof data !== "object" || Array.isArray(data)) throw new DiscordApiError("Invalid Discord response");
|
|
95
141
|
return data as T;
|
|
96
142
|
}
|
|
@@ -105,9 +151,9 @@ export class DiscordApi {
|
|
|
105
151
|
return me;
|
|
106
152
|
}
|
|
107
153
|
|
|
108
|
-
async getChannel(channelId: string): Promise<{ id: string; type: number }> {
|
|
154
|
+
async getChannel(channelId: string): Promise<{ id: string; type: number; parent_id?: string | null; owner_id?: string | null }> {
|
|
109
155
|
this.checkId(channelId);
|
|
110
|
-
const channel = await this.request<{ id: string; type: number }>(`/channels/${channelId}`);
|
|
156
|
+
const channel = await this.request<{ id: string; type: number; parent_id?: string | null; owner_id?: string | null }>(`/channels/${channelId}`);
|
|
111
157
|
if (channel.id !== channelId || !Number.isInteger(channel.type)) throw new DiscordApiError("Invalid Discord channel");
|
|
112
158
|
return channel;
|
|
113
159
|
}
|
|
@@ -121,27 +167,78 @@ export class DiscordApi {
|
|
|
121
167
|
return gateway;
|
|
122
168
|
}
|
|
123
169
|
|
|
124
|
-
|
|
170
|
+
sendMessage(channelId: string, content: string, eligible?: () => boolean): Promise<{ id: string }[]> {
|
|
125
171
|
this.checkId(channelId);
|
|
126
|
-
|
|
127
|
-
|
|
172
|
+
const result = this.sendQueue.then(() => this.sendMessageOrdered(channelId, content, eligible));
|
|
173
|
+
this.sendQueue = result.then(() => {}, () => {});
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
sendFile(channelId: string, name: string, data: Uint8Array, eligible?: () => boolean): Promise<{ id: string }[]> {
|
|
178
|
+
this.checkId(channelId);
|
|
179
|
+
if (!name || name.startsWith(".") || /[\/\\\x00-\x1f\x7f]/.test(name) || data.byteLength > 8 * 1024 * 1024) throw new DiscordApiError("Invalid Discord attachment");
|
|
180
|
+
const result = this.sendQueue.then(async () => {
|
|
181
|
+
const form = new FormData();
|
|
182
|
+
form.append("payload_json", JSON.stringify({ allowed_mentions: { parse: [], replied_user: false } }));
|
|
183
|
+
form.append("files[0]", new Blob([new Uint8Array(data)], { type: "application/octet-stream" }), name);
|
|
184
|
+
const message = await this.request<{ id: string }>(`/channels/${channelId}/messages`, form, "POST", eligible);
|
|
185
|
+
if (!message || typeof message.id !== "string" || !/^\d{1,20}$/.test(message.id)) throw new DiscordApiError("Invalid Discord message ID");
|
|
186
|
+
return [message];
|
|
187
|
+
});
|
|
188
|
+
this.sendQueue = result.then(() => {}, () => {});
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private async sendMessageOrdered(channelId: string, content: string, eligible?: () => boolean): Promise<{ id: string }[]> {
|
|
193
|
+
const { chunks, needsAttachment } = splitDiscordText(content);
|
|
128
194
|
const messages: { id: string }[] = [];
|
|
129
|
-
for (let
|
|
130
|
-
let
|
|
131
|
-
if (
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
195
|
+
for (let index = 0; index < chunks.length + Number(needsAttachment); index++) {
|
|
196
|
+
let body: unknown = { content: chunks[index], allowed_mentions: { parse: [], replied_user: false } };
|
|
197
|
+
if (index === chunks.length) {
|
|
198
|
+
const form = new FormData();
|
|
199
|
+
form.append("payload_json", JSON.stringify({ allowed_mentions: { parse: [], replied_user: false } }));
|
|
200
|
+
form.append("files[0]", new Blob([content], { type: "text/plain; charset=utf-8" }), "response.txt");
|
|
201
|
+
body = form;
|
|
202
|
+
}
|
|
203
|
+
const message = await this.request<{ id: string }>(`/channels/${channelId}/messages`, body, "POST", eligible).catch(error => {
|
|
135
204
|
if (error instanceof DiscordApiError) throw new DiscordApiError(error.message, error.status, error.retryAfterMs, messages);
|
|
136
205
|
throw new DiscordApiError("Discord delivery failed", undefined, undefined, messages);
|
|
137
206
|
});
|
|
138
|
-
if (typeof message.id !== "string" || !/^\d
|
|
207
|
+
if (!message || typeof message.id !== "string" || !/^\d{1,20}$/.test(message.id)) throw new DiscordApiError("Invalid Discord message ID", undefined, undefined, messages);
|
|
139
208
|
messages.push(message);
|
|
140
|
-
offset = end;
|
|
141
209
|
}
|
|
142
210
|
return messages;
|
|
143
211
|
}
|
|
144
212
|
|
|
213
|
+
async createThread(channelId: string, messageId: string, name: string): Promise<{ id: string }> {
|
|
214
|
+
this.checkId(channelId);
|
|
215
|
+
this.checkId(messageId);
|
|
216
|
+
if (typeof name !== "string" || name.length < 1 || name.length > 100) throw new DiscordApiError("Invalid Discord thread name");
|
|
217
|
+
const thread = await this.request<{ id: string }>(`/channels/${channelId}/messages/${messageId}/threads`, { name });
|
|
218
|
+
if (!thread) throw new DiscordApiError("Invalid Discord thread response");
|
|
219
|
+
this.checkId(thread.id);
|
|
220
|
+
return thread;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async addReaction(channelId: string, messageId: string, emoji: string): Promise<void> {
|
|
224
|
+
await this.reaction(channelId, messageId, emoji, "PUT");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async removeReaction(channelId: string, messageId: string, emoji: string): Promise<void> {
|
|
228
|
+
await this.reaction(channelId, messageId, emoji, "DELETE");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private async reaction(channelId: string, messageId: string, emoji: string, method: "PUT" | "DELETE"): Promise<void> {
|
|
232
|
+
this.checkId(channelId);
|
|
233
|
+
this.checkId(messageId);
|
|
234
|
+
if (typeof emoji !== "string" || !emoji || emoji.length > 128 || !emoji.isWellFormed()) throw new DiscordApiError("Invalid Discord emoji");
|
|
235
|
+
if (this.reactionsQueued >= 128) throw new DiscordApiError("Discord reaction queue full");
|
|
236
|
+
this.reactionsQueued++;
|
|
237
|
+
const result = this.reactionQueue.then(() => this.perform<void>(`/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`, undefined, method, undefined, true));
|
|
238
|
+
this.reactionQueue = result.then(() => {}, () => {}).finally(() => { this.reactionsQueued--; });
|
|
239
|
+
await result;
|
|
240
|
+
}
|
|
241
|
+
|
|
145
242
|
private checkId(id: string): void {
|
|
146
243
|
if (typeof id !== "string" || !/^\d{1,20}$/.test(id)) throw new DiscordApiError("Invalid Discord ID");
|
|
147
244
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { notifySessionsDir } from "./paths";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
|
|
6
|
+
const MAX_FILES = 4;
|
|
7
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
10
|
+
const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
11
|
+
|
|
12
|
+
async function privateDirectory(directory: string): Promise<void> {
|
|
13
|
+
const info = await fs.lstat(directory);
|
|
14
|
+
if (!info.isDirectory() || (info.mode & 0o077) !== 0 || (typeof process.getuid === "function" && info.uid !== process.getuid())) throw new Error("Invalid private artifact directory");
|
|
15
|
+
if (await fs.realpath(directory) !== directory) throw new Error("Artifact directory contains a link");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function prepareDiscordArtifactRoot(sessionId: string): Promise<string> {
|
|
19
|
+
if (!SESSION_ID.test(sessionId)) throw new Error("Invalid session ID");
|
|
20
|
+
await fs.mkdir(notifySessionsDir(), { recursive: true, mode: 0o700 });
|
|
21
|
+
const sessions = await fs.realpath(notifySessionsDir());
|
|
22
|
+
const base = path.join(sessions, "discord-artifacts");
|
|
23
|
+
await fs.mkdir(base, { mode: 0o700 }).catch((error: NodeJS.ErrnoException) => { if (error.code !== "EEXIST") throw error; });
|
|
24
|
+
await privateDirectory(base);
|
|
25
|
+
const root = path.join(base, sessionId);
|
|
26
|
+
await fs.mkdir(root, { mode: 0o700 }).catch((error: NodeJS.ErrnoException) => { if (error.code !== "EEXIST") throw error; });
|
|
27
|
+
await privateDirectory(root);
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DiscordArtifactPaths { output: string; input?: string }
|
|
32
|
+
|
|
33
|
+
export async function resolveDiscordArtifactStage(sessionId: string, requestId: string): Promise<DiscordArtifactPaths> {
|
|
34
|
+
if (!SESSION_ID.test(sessionId) || !REQUEST_ID.test(requestId)) throw new Error("Invalid artifact request ID");
|
|
35
|
+
const sessions = await fs.realpath(notifySessionsDir());
|
|
36
|
+
const base = path.join(sessions, "discord-artifacts");
|
|
37
|
+
const root = path.join(base, sessionId);
|
|
38
|
+
const request = path.join(root, requestId);
|
|
39
|
+
const output = path.join(request, "output");
|
|
40
|
+
for (const directory of [base, root, request, output]) await privateDirectory(directory);
|
|
41
|
+
const input = path.join(request, "input");
|
|
42
|
+
try { await privateDirectory(input); }
|
|
43
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { output }; throw error; }
|
|
44
|
+
const names = await fs.readdir(input);
|
|
45
|
+
if (names.length > 10) throw new Error("Too many input files");
|
|
46
|
+
let total = 0;
|
|
47
|
+
for (const name of names) {
|
|
48
|
+
if (name.startsWith(".") || /[\x00-\x1f\x7f]/.test(name)) throw new Error("Invalid input filename");
|
|
49
|
+
const info = await fs.lstat(path.join(input, name));
|
|
50
|
+
if (!info.isFile() || info.nlink !== 1 || info.size > 25 * 1024 * 1024) throw new Error("Invalid input file");
|
|
51
|
+
total += info.size;
|
|
52
|
+
if (total > 50 * 1024 * 1024) throw new Error("Input files exceed size limit");
|
|
53
|
+
}
|
|
54
|
+
return { output, input };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DiscordArtifactStage {
|
|
58
|
+
directory: string;
|
|
59
|
+
requestDirectory: string;
|
|
60
|
+
collect(): Promise<Array<{ name: string; data: Uint8Array }>>;
|
|
61
|
+
cleanup(): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A private, per-request output directory; never upload arbitrary session paths. */
|
|
65
|
+
export async function createDiscordArtifactStage(sessionId: string, requestId: string): Promise<DiscordArtifactStage> {
|
|
66
|
+
if (!REQUEST_ID.test(requestId)) throw new Error("Invalid artifact request ID");
|
|
67
|
+
const root = await prepareDiscordArtifactRoot(sessionId);
|
|
68
|
+
const requestDirectory = path.join(root, requestId);
|
|
69
|
+
await fs.mkdir(requestDirectory, { mode: 0o700 });
|
|
70
|
+
const requestInitial = await fs.lstat(requestDirectory);
|
|
71
|
+
const directory = path.join(requestDirectory, "output");
|
|
72
|
+
try { await fs.mkdir(directory, { mode: 0o700 }); }
|
|
73
|
+
catch (error) { await fs.rm(requestDirectory, { recursive: true, force: true }); throw error; }
|
|
74
|
+
const initial = await fs.lstat(directory);
|
|
75
|
+
async function checkDirectory(): Promise<void> {
|
|
76
|
+
if ((await resolveDiscordArtifactStage(sessionId, requestId)).output !== directory) throw new Error("Artifact staging path changed");
|
|
77
|
+
const current = await fs.lstat(directory);
|
|
78
|
+
if (!current.isDirectory() || current.dev !== initial.dev || current.ino !== initial.ino) throw new Error("Artifact staging directory changed");
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
directory,
|
|
82
|
+
requestDirectory,
|
|
83
|
+
async collect() {
|
|
84
|
+
await checkDirectory();
|
|
85
|
+
const names = await fs.readdir(directory);
|
|
86
|
+
if (names.length > MAX_FILES) throw new Error("Too many output files (maximum 4)");
|
|
87
|
+
const result: Array<{ name: string; data: Uint8Array }> = [];
|
|
88
|
+
let total = 0;
|
|
89
|
+
for (const name of names) {
|
|
90
|
+
if (name.startsWith(".") || /[\x00-\x1f\x7f]/.test(name)) throw new Error("Invalid output filename");
|
|
91
|
+
await checkDirectory();
|
|
92
|
+
const file = path.join(directory, name);
|
|
93
|
+
const before = await fs.lstat(file);
|
|
94
|
+
if (!before.isFile() || before.nlink !== 1) throw new Error("Output must be a regular file, not a link or directory");
|
|
95
|
+
if (before.size > MAX_BYTES - total) throw new Error("Output files exceed 8 MiB total");
|
|
96
|
+
const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
97
|
+
try {
|
|
98
|
+
const opened = await handle.stat();
|
|
99
|
+
if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size) throw new Error("Output file changed during collection");
|
|
100
|
+
const data = new Uint8Array(opened.size);
|
|
101
|
+
let offset = 0;
|
|
102
|
+
while (offset < data.length) {
|
|
103
|
+
const { bytesRead } = await handle.read(data, offset, data.length - offset, offset);
|
|
104
|
+
if (!bytesRead) throw new Error("Output file changed during collection");
|
|
105
|
+
offset += bytesRead;
|
|
106
|
+
}
|
|
107
|
+
const after = await handle.stat();
|
|
108
|
+
if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.nlink !== 1) throw new Error("Output file changed during collection");
|
|
109
|
+
await checkDirectory();
|
|
110
|
+
total += data.length;
|
|
111
|
+
result.push({ name, data });
|
|
112
|
+
} finally {
|
|
113
|
+
await handle.close();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
},
|
|
118
|
+
async cleanup() {
|
|
119
|
+
await privateDirectory(root);
|
|
120
|
+
await privateDirectory(requestDirectory);
|
|
121
|
+
const current = await fs.lstat(requestDirectory);
|
|
122
|
+
if (current.dev !== requestInitial.dev || current.ino !== requestInitial.ino) throw new Error("Artifact request directory changed");
|
|
123
|
+
await fs.rm(requestDirectory, { recursive: true, force: true });
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { lstat, mkdir, open, realpath, rm } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const MAX_FILES = 10;
|
|
5
|
+
const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|
6
|
+
const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
|
|
7
|
+
const CDN_HOSTS = ["cdn.discordapp.com", "media.discordapp.net"];
|
|
8
|
+
|
|
9
|
+
interface Attachment {
|
|
10
|
+
filename: string;
|
|
11
|
+
url: string;
|
|
12
|
+
size: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DiscordAttachmentOptions {
|
|
16
|
+
directory: string;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
fetchImpl?: typeof fetch;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DiscordAttachmentStage {
|
|
22
|
+
files: string[];
|
|
23
|
+
cleanup: () => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Staged files are private and must be removed with cleanup after the request finishes. */
|
|
27
|
+
export async function stageDiscordAttachments(
|
|
28
|
+
attachments: unknown[],
|
|
29
|
+
options: DiscordAttachmentOptions,
|
|
30
|
+
): Promise<DiscordAttachmentStage> {
|
|
31
|
+
if (!Array.isArray(attachments) || attachments.length > MAX_FILES) throw new Error("Invalid Discord attachment count");
|
|
32
|
+
const validated: Attachment[] = [];
|
|
33
|
+
let declaredTotal = 0;
|
|
34
|
+
for (const value of attachments) {
|
|
35
|
+
if (!value || typeof value !== "object") throw new Error("Invalid Discord attachment");
|
|
36
|
+
const item = value as Record<string, unknown>;
|
|
37
|
+
if (typeof item.filename !== "string" || !item.filename || typeof item.url !== "string" ||
|
|
38
|
+
!Number.isSafeInteger(item.size) || (item.size as number) < 0 || (item.size as number) > MAX_FILE_BYTES) {
|
|
39
|
+
throw new Error("Invalid Discord attachment metadata");
|
|
40
|
+
}
|
|
41
|
+
let url: URL;
|
|
42
|
+
try { url = new URL(item.url); } catch { throw new Error("Invalid Discord attachment URL"); }
|
|
43
|
+
if (url.protocol !== "https:" || !CDN_HOSTS.includes(url.hostname) || url.username || url.password || url.port) {
|
|
44
|
+
throw new Error("Untrusted Discord attachment URL");
|
|
45
|
+
}
|
|
46
|
+
declaredTotal += item.size as number;
|
|
47
|
+
if (declaredTotal > MAX_TOTAL_BYTES) throw new Error("Discord attachments exceed total size limit");
|
|
48
|
+
validated.push({ filename: item.filename, url: url.href, size: item.size as number });
|
|
49
|
+
}
|
|
50
|
+
if (options.signal?.aborted) throw new Error("Discord attachment download aborted");
|
|
51
|
+
const directory = options.directory;
|
|
52
|
+
if (typeof directory !== "string" || !isAbsolute(directory) || resolve(directory) !== directory || basename(directory) !== "input") {
|
|
53
|
+
throw new Error("Invalid Discord attachment staging directory");
|
|
54
|
+
}
|
|
55
|
+
const parent = dirname(directory);
|
|
56
|
+
const info = await lstat(parent);
|
|
57
|
+
if (!info.isDirectory() || (info.mode & 0o077) !== 0 ||
|
|
58
|
+
(typeof process.getuid === "function" && info.uid !== process.getuid()) || await realpath(parent) !== parent) {
|
|
59
|
+
throw new Error("Untrusted Discord attachment staging directory");
|
|
60
|
+
}
|
|
61
|
+
await mkdir(directory, { mode: 0o700 });
|
|
62
|
+
const cleanup = () => rm(directory, { recursive: true, force: true });
|
|
63
|
+
try {
|
|
64
|
+
const files: string[] = [];
|
|
65
|
+
let total = 0;
|
|
66
|
+
for (const [index, item] of validated.entries()) {
|
|
67
|
+
const name = item.filename.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^\.+/, "_").slice(0, 180) || "attachment";
|
|
68
|
+
const path = join(directory, `${index + 1}-${name}`);
|
|
69
|
+
const signal = AbortSignal.any([...(options.signal ? [options.signal] : []), AbortSignal.timeout(30_000)]);
|
|
70
|
+
let response: Response;
|
|
71
|
+
try {
|
|
72
|
+
response = await (options.fetchImpl ?? fetch)(item.url, { redirect: "error", signal });
|
|
73
|
+
} catch {
|
|
74
|
+
throw new Error("Discord attachment download failed or aborted");
|
|
75
|
+
}
|
|
76
|
+
if (!response.ok || !response.body) {
|
|
77
|
+
await response.body?.cancel().catch(() => {});
|
|
78
|
+
throw new Error("Discord attachment download failed");
|
|
79
|
+
}
|
|
80
|
+
const file = await open(path, "wx", 0o600).catch(async () => {
|
|
81
|
+
await response.body?.cancel().catch(() => {});
|
|
82
|
+
throw new Error("Discord attachment staging failed");
|
|
83
|
+
});
|
|
84
|
+
const reader = response.body.getReader();
|
|
85
|
+
let size = 0;
|
|
86
|
+
try {
|
|
87
|
+
for (;;) {
|
|
88
|
+
if (signal.aborted) throw new Error("Discord attachment download aborted");
|
|
89
|
+
const { done, value } = await reader.read().catch(() => { throw new Error("Discord attachment download failed or aborted"); });
|
|
90
|
+
if (signal.aborted) throw new Error("Discord attachment download aborted");
|
|
91
|
+
if (done) break;
|
|
92
|
+
size += value.byteLength;
|
|
93
|
+
total += value.byteLength;
|
|
94
|
+
if (size > MAX_FILE_BYTES || size > item.size || total > MAX_TOTAL_BYTES) throw new Error("Discord attachment exceeds size limit");
|
|
95
|
+
let offset = 0;
|
|
96
|
+
while (offset < value.byteLength) {
|
|
97
|
+
if (signal.aborted) throw new Error("Discord attachment download aborted");
|
|
98
|
+
const { bytesWritten } = await file.write(value, offset, value.byteLength - offset);
|
|
99
|
+
if (!bytesWritten) throw new Error("Discord attachment write failed");
|
|
100
|
+
offset += bytesWritten;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (size !== item.size) throw new Error("Discord attachment size mismatch");
|
|
104
|
+
} finally {
|
|
105
|
+
await reader.cancel().catch(() => {});
|
|
106
|
+
await file.close();
|
|
107
|
+
}
|
|
108
|
+
files.push(path);
|
|
109
|
+
}
|
|
110
|
+
if (options.signal?.aborted) throw new Error("Discord attachment download aborted");
|
|
111
|
+
return { files, cleanup };
|
|
112
|
+
} catch (error) {
|
|
113
|
+
await cleanup();
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|