jeo-code 0.12.6 → 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 +15 -0
- package/README.ja.md +3 -1
- package/README.ko.md +3 -1
- package/README.md +5 -3
- package/README.zh.md +3 -1
- package/package.json +1 -1
- package/src/agent/compaction.ts +6 -7
- package/src/agent/engine.ts +6 -2
- 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/agent/tools.ts +1 -0
- package/src/ai/providers/anthropic.ts +1 -0
- package/src/ai/providers/openai-responses.ts +1 -0
- package/src/cli/runner.ts +9 -0
- package/src/cli.ts +4 -0
- package/src/commands/computer.ts +46 -15
- 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,21 @@ 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
|
+
|
|
16
|
+
## [0.12.7] - 2026-10-18
|
|
17
|
+
_Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization._
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **Screenshot vision feedback** — carry PNG attachments through tool execution, batches, and native Anthropic/OpenAI Responses replay instead of spilling base64 as text. Preserve CLI screenshot base64 stdout.
|
|
21
|
+
- **Desktop input** — use Quartz scroll events on macOS (cliclick `w:` waits rather than scrolling), release the mouse after dragging on macOS/Linux, normalize macOS key aliases/modifier combinations, and terminate Linux text options before user text.
|
|
22
|
+
- **Execution reliability** — drain subprocess pipes concurrently with exit, use collision-resistant screenshot paths, reject non-finite numeric inputs and out-of-range waits/scrolls, and explicitly reject unsupported platforms. Existing enablement/supervisor gates remain unchanged.
|
|
23
|
+
|
|
24
|
+
### Documentation
|
|
25
|
+
- Added [desktop setup and troubleshooting](docs/computer-use.md), including platform requirements, OS permissions, coordinate caveats, and supported action semantics.
|
|
26
|
+
|
|
12
27
|
## [0.12.6] - 2026-10-17
|
|
13
28
|
_Preserve OpenAI subscription authentication behind configured proxies, isolate account catalogs, and give provider-specific recovery instructions while retaining Antigravity OAuth calls._
|
|
14
29
|
|
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>
|
|
@@ -416,10 +418,10 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
416
418
|
|
|
417
419
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
418
420
|
- **[Unreleased]**
|
|
421
|
+
- **[0.12.7]** (2026-10-18) — Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization.
|
|
419
422
|
- **[0.12.6]** (2026-10-17) — Preserve OpenAI subscription authentication behind configured proxies, isolate account catalogs, and give provider-specific recovery instructions while retaining Antigravity OAuth calls.
|
|
420
423
|
- **[0.12.5]** (2026-10-17) — Antigravity and Kimi provider support in interactive setup, Kimi live model cache rehydration, immediate discovery caching on provider probing.
|
|
421
424
|
- **[0.12.4]** (2026-10-17) — Refresh Antigravity provider (`antigravity/*`) account catalogs with `antigravity/hub/2.23.1` so entitled Gemini 4 Argon and Gemini 3.8 Flash tiers appear directly in `/model provider antigravity`.
|
|
422
|
-
- **[0.12.3]** (2026-10-17) — Anthropic static model catalog trimmed to claude-5.x with live API discovery, and `/route off` strictly fixes the active model with no failover.
|
|
423
425
|
|
|
424
426
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
425
427
|
<!-- CHANGELOG:END -->
|
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>
|
|
@@ -417,10 +419,10 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
417
419
|
|
|
418
420
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
419
421
|
- **[Unreleased]**
|
|
422
|
+
- **[0.12.7]** (2026-10-18) — Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization.
|
|
420
423
|
- **[0.12.6]** (2026-10-17) — Preserve OpenAI subscription authentication behind configured proxies, isolate account catalogs, and give provider-specific recovery instructions while retaining Antigravity OAuth calls.
|
|
421
424
|
- **[0.12.5]** (2026-10-17) — Antigravity and Kimi provider support in interactive setup, Kimi live model cache rehydration, immediate discovery caching on provider probing.
|
|
422
425
|
- **[0.12.4]** (2026-10-17) — Refresh Antigravity provider (`antigravity/*`) account catalogs with `antigravity/hub/2.23.1` so entitled Gemini 4 Argon and Gemini 3.8 Flash tiers appear directly in `/model provider antigravity`.
|
|
423
|
-
- **[0.12.3]** (2026-10-17) — Anthropic static model catalog trimmed to claude-5.x with live API discovery, and `/route off` strictly fixes the active model with no failover.
|
|
424
426
|
|
|
425
427
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
426
428
|
<!-- CHANGELOG:END -->
|
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.
|
|
@@ -409,10 +411,10 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
409
411
|
|
|
410
412
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
411
413
|
- **[Unreleased]**
|
|
414
|
+
- **[0.12.7]** (2026-10-18) — Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization.
|
|
412
415
|
- **[0.12.6]** (2026-10-17) — Preserve OpenAI subscription authentication behind configured proxies, isolate account catalogs, and give provider-specific recovery instructions while retaining Antigravity OAuth calls.
|
|
413
416
|
- **[0.12.5]** (2026-10-17) — Antigravity and Kimi provider support in interactive setup, Kimi live model cache rehydration, immediate discovery caching on provider probing.
|
|
414
417
|
- **[0.12.4]** (2026-10-17) — Refresh Antigravity provider (`antigravity/*`) account catalogs with `antigravity/hub/2.23.1` so entitled Gemini 4 Argon and Gemini 3.8 Flash tiers appear directly in `/model provider antigravity`.
|
|
415
|
-
- **[0.12.3]** (2026-10-17) — Anthropic static model catalog trimmed to claude-5.x with live API discovery, and `/route off` strictly fixes the active model with no failover.
|
|
416
418
|
|
|
417
419
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
418
420
|
<!-- CHANGELOG:END -->
|
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>
|
|
@@ -414,10 +416,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
414
416
|
|
|
415
417
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
416
418
|
- **[Unreleased]**
|
|
419
|
+
- **[0.12.7]** (2026-10-18) — Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization.
|
|
417
420
|
- **[0.12.6]** (2026-10-17) — Preserve OpenAI subscription authentication behind configured proxies, isolate account catalogs, and give provider-specific recovery instructions while retaining Antigravity OAuth calls.
|
|
418
421
|
- **[0.12.5]** (2026-10-17) — Antigravity and Kimi provider support in interactive setup, Kimi live model cache rehydration, immediate discovery caching on provider probing.
|
|
419
422
|
- **[0.12.4]** (2026-10-17) — Refresh Antigravity provider (`antigravity/*`) account catalogs with `antigravity/hub/2.23.1` so entitled Gemini 4 Argon and Gemini 3.8 Flash tiers appear directly in `/model provider antigravity`.
|
|
420
|
-
- **[0.12.3]** (2026-10-17) — Anthropic static model catalog trimmed to claude-5.x with live API discovery, and `/route off` strictly fixes the active model with no failover.
|
|
421
423
|
|
|
422
424
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
423
425
|
<!-- CHANGELOG:END -->
|
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
|
package/src/agent/engine.ts
CHANGED
|
@@ -1215,6 +1215,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1215
1215
|
const { tool, arguments: args } = call;
|
|
1216
1216
|
let success: boolean;
|
|
1217
1217
|
let output: string;
|
|
1218
|
+
let images: import("../ai/types").ImageAttachment[] | undefined;
|
|
1218
1219
|
|
|
1219
1220
|
if (tool === "done") {
|
|
1220
1221
|
success = false;
|
|
@@ -1242,6 +1243,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1242
1243
|
const onProgress = ev.onToolProgress ? (partial: string) => ev.onToolProgress!(tool, partial) : undefined;
|
|
1243
1244
|
const res = await handler(args ?? {}, cwd, onProgress);
|
|
1244
1245
|
success = res.success;
|
|
1246
|
+
images = res.images;
|
|
1245
1247
|
output = res.success ? res.output : (res.error ? (res.output ? `${res.error}\n${res.output}` : res.error) : res.output);
|
|
1246
1248
|
} catch (err: any) {
|
|
1247
1249
|
success = false;
|
|
@@ -1250,7 +1252,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1250
1252
|
}
|
|
1251
1253
|
}
|
|
1252
1254
|
}
|
|
1253
|
-
return { success, output };
|
|
1255
|
+
return { success, output, images };
|
|
1254
1256
|
};
|
|
1255
1257
|
|
|
1256
1258
|
const WRITE_TOOLS = new Set(["write", "edit"]);
|
|
@@ -1293,7 +1295,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1293
1295
|
}
|
|
1294
1296
|
}
|
|
1295
1297
|
|
|
1296
|
-
const results: { success: boolean; output: string; executed: boolean }[] = Array.from(
|
|
1298
|
+
const results: { success: boolean; output: string; executed: boolean; images?: import("../ai/types").ImageAttachment[] }[] = Array.from(
|
|
1297
1299
|
{ length: toolCalls.length },
|
|
1298
1300
|
() => ({ success: false, output: "", executed: false })
|
|
1299
1301
|
);
|
|
@@ -1385,6 +1387,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1385
1387
|
}));
|
|
1386
1388
|
pushAssistantTurn(history, responseText, reasonBuf, artifactBuf, toolUse);
|
|
1387
1389
|
const resultMsg: Message = { role: "user", content: resultBlocks.join("\n\n"), toolResults };
|
|
1390
|
+
const images = indices.flatMap(idx => results[idx].images ?? []);
|
|
1391
|
+
if (images.length) resultMsg.images = images;
|
|
1388
1392
|
if (hookExtra) resultMsg.toolResultExtra = hookExtra;
|
|
1389
1393
|
history.push(resultMsg);
|
|
1390
1394
|
};
|
|
@@ -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
|
+
}
|