kimaki 0.4.78 → 0.4.80

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 (90) hide show
  1. package/dist/anthropic-auth-plugin.js +628 -0
  2. package/dist/channel-management.js +2 -2
  3. package/dist/cli.js +316 -129
  4. package/dist/commands/action-buttons.js +1 -1
  5. package/dist/commands/login.js +634 -277
  6. package/dist/commands/model.js +91 -6
  7. package/dist/commands/paginated-select.js +57 -0
  8. package/dist/commands/resume.js +2 -2
  9. package/dist/commands/tasks.js +205 -0
  10. package/dist/commands/undo-redo.js +80 -18
  11. package/dist/context-awareness-plugin.js +347 -0
  12. package/dist/database.js +103 -7
  13. package/dist/db.js +39 -1
  14. package/dist/discord-bot.js +42 -19
  15. package/dist/discord-urls.js +11 -0
  16. package/dist/discord-ws-proxy.js +350 -0
  17. package/dist/discord-ws-proxy.test.js +500 -0
  18. package/dist/errors.js +1 -1
  19. package/dist/gateway-session.js +163 -0
  20. package/dist/hrana-server.js +114 -4
  21. package/dist/interaction-handler.js +30 -7
  22. package/dist/ipc-tools-plugin.js +186 -0
  23. package/dist/message-preprocessing.js +56 -11
  24. package/dist/onboarding-welcome.js +1 -1
  25. package/dist/opencode-interrupt-plugin.js +133 -75
  26. package/dist/opencode-plugin.js +12 -389
  27. package/dist/opencode.js +59 -5
  28. package/dist/parse-permission-rules.test.js +117 -0
  29. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  30. package/dist/session-handler/thread-session-runtime.js +68 -29
  31. package/dist/startup-time.e2e.test.js +295 -0
  32. package/dist/store.js +1 -0
  33. package/dist/system-message.js +3 -1
  34. package/dist/task-runner.js +7 -3
  35. package/dist/task-schedule.js +12 -0
  36. package/dist/thread-message-queue.e2e.test.js +13 -1
  37. package/dist/undo-redo.e2e.test.js +166 -0
  38. package/dist/utils.js +4 -1
  39. package/dist/voice-attachment.js +34 -0
  40. package/dist/voice-handler.js +11 -9
  41. package/dist/voice-message.e2e.test.js +78 -0
  42. package/dist/voice.test.js +31 -0
  43. package/package.json +12 -7
  44. package/skills/egaki/SKILL.md +80 -15
  45. package/skills/errore/SKILL.md +13 -0
  46. package/skills/lintcn/SKILL.md +749 -0
  47. package/skills/npm-package/SKILL.md +17 -3
  48. package/skills/spiceflow/SKILL.md +14 -0
  49. package/skills/zele/SKILL.md +9 -0
  50. package/src/anthropic-auth-plugin.ts +732 -0
  51. package/src/channel-management.ts +2 -2
  52. package/src/cli.ts +354 -132
  53. package/src/commands/action-buttons.ts +1 -0
  54. package/src/commands/login.ts +836 -337
  55. package/src/commands/model.ts +102 -7
  56. package/src/commands/paginated-select.ts +81 -0
  57. package/src/commands/resume.ts +6 -1
  58. package/src/commands/tasks.ts +293 -0
  59. package/src/commands/undo-redo.ts +87 -20
  60. package/src/context-awareness-plugin.ts +469 -0
  61. package/src/database.ts +138 -7
  62. package/src/db.ts +40 -1
  63. package/src/discord-bot.ts +46 -19
  64. package/src/discord-urls.ts +12 -0
  65. package/src/errors.ts +1 -1
  66. package/src/hrana-server.ts +124 -3
  67. package/src/interaction-handler.ts +41 -9
  68. package/src/ipc-tools-plugin.ts +228 -0
  69. package/src/message-preprocessing.ts +82 -11
  70. package/src/onboarding-welcome.ts +1 -1
  71. package/src/opencode-interrupt-plugin.ts +164 -91
  72. package/src/opencode-plugin.ts +13 -483
  73. package/src/opencode.ts +60 -5
  74. package/src/parse-permission-rules.test.ts +127 -0
  75. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  76. package/src/session-handler/thread-runtime-state.ts +4 -1
  77. package/src/session-handler/thread-session-runtime.ts +82 -20
  78. package/src/startup-time.e2e.test.ts +372 -0
  79. package/src/store.ts +8 -0
  80. package/src/system-message.ts +10 -1
  81. package/src/task-runner.ts +9 -22
  82. package/src/task-schedule.ts +15 -0
  83. package/src/thread-message-queue.e2e.test.ts +14 -1
  84. package/src/undo-redo.e2e.test.ts +207 -0
  85. package/src/utils.ts +7 -0
  86. package/src/voice-attachment.ts +51 -0
  87. package/src/voice-handler.ts +15 -7
  88. package/src/voice-message.e2e.test.ts +95 -0
  89. package/src/voice.test.ts +36 -0
  90. package/src/onboarding-tutorial-plugin.ts +0 -93
@@ -1,15 +1,16 @@
1
1
  // Core Discord bot module that handles message events and bot lifecycle.
2
2
  // Bridges Discord messages to OpenCode sessions, manages voice connections,
3
3
  // and orchestrates the main event loop for the Kimaki bot.
4
- import { initDatabase, closeDatabase, getThreadWorktree, createPendingWorktree, setWorktreeReady, setWorktreeError, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, getPrisma, cancelAllPendingIpcRequests, } from './database.js';
4
+ import { initDatabase, closeDatabase, getThreadWorktree, getThreadSession, createPendingWorktree, setWorktreeReady, setWorktreeError, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, getPrisma, cancelAllPendingIpcRequests, } from './database.js';
5
5
  import { stopOpencodeServer, } from './opencode.js';
6
6
  import { formatWorktreeName } from './commands/new-worktree.js';
7
7
  import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
8
8
  import { createWorktreeWithSubmodules } from './worktrees.js';
9
- import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, } from './discord-utils.js';
9
+ import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, } from './discord-utils.js';
10
10
  import { getOpencodeSystemMessage, } from './system-message.js';
11
11
  import yaml from 'js-yaml';
12
12
  import { getTextAttachments, resolveMentions, } from './message-formatting.js';
13
+ import { isVoiceAttachment } from './voice-attachment.js';
13
14
  import { preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
14
15
  import { cancelPendingActionButtons } from './commands/action-buttons.js';
15
16
  import { cancelPendingQuestion } from './commands/ask-question.js';
@@ -23,7 +24,7 @@ import { getOrCreateRuntime, disposeRuntime, } from './session-handler/thread-se
23
24
  import { runShellCommand } from './commands/run-command.js';
24
25
  import { registerInteractionHandler } from './interaction-handler.js';
25
26
  import { getDiscordRestApiUrl } from './discord-urls.js';
26
- import { stopHranaServer } from './hrana-server.js';
27
+ import { markDiscordGatewayReady, stopHranaServer } from './hrana-server.js';
27
28
  import { notifyError } from './sentry.js';
28
29
  import { flushDebouncedProcessCallbacks } from './debounced-process-flush.js';
29
30
  import { startRuntimeIdleSweeper } from './runtime-idle-sweeper.js';
@@ -158,6 +159,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
158
159
  discordLogger.log(`Bot Application ID (provided): ${currentAppId}`);
159
160
  }
160
161
  voiceLogger.log('[READY] Bot is ready');
162
+ markDiscordGatewayReady();
161
163
  registerInteractionHandler({ discordClient: c, appId: currentAppId });
162
164
  registerVoiceStateHandler({ discordClient: c, appId: currentAppId });
163
165
  // Channel logging is informational only; do it in background so startup stays responsive.
@@ -173,7 +175,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
173
175
  discordLogger.log(' No channels for this bot');
174
176
  }
175
177
  })().catch((error) => {
176
- discordLogger.warn(`Background guild channel scan failed: ${error instanceof Error ? error.message : String(error)}`);
178
+ discordLogger.warn(`Background guild channel scan failed: ${error instanceof Error ? error.stack : String(error)}`);
177
179
  });
178
180
  };
179
181
  // If client is already ready (was logged in before being passed to us),
@@ -258,6 +260,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
258
260
  const cliInjectedModel = isCliInjectedPrompt
259
261
  ? promptMarker?.model
260
262
  : undefined;
263
+ const cliInjectedPermissions = isCliInjectedPrompt
264
+ ? promptMarker?.permissions
265
+ : undefined;
261
266
  // Always ignore our own messages (unless CLI-injected prompt above).
262
267
  // Without this, assigning the Kimaki role to the bot itself would loop.
263
268
  if (isSelfBotMessage && !isCliInjectedPrompt) {
@@ -331,6 +336,15 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
331
336
  if (isThread) {
332
337
  const thread = channel;
333
338
  discordLogger.log(`Message in thread ${thread.name} (${thread.id})`);
339
+ // Only respond in threads kimaki knows about (has a session row in DB)
340
+ // or where the bot is explicitly @mentioned. This prevents the bot from
341
+ // hijacking user-created threads in project channels. (GitHub #84)
342
+ const hasExistingSession = await getThreadSession(thread.id);
343
+ const botMentioned = discordClient.user && message.mentions.has(discordClient.user.id);
344
+ if (!hasExistingSession && !botMentioned && !isCliInjectedPrompt) {
345
+ discordLogger.log(`Ignoring thread ${thread.id}: no existing session and bot not mentioned`);
346
+ return;
347
+ }
334
348
  const parent = thread.parent;
335
349
  let projectDirectory;
336
350
  if (parent) {
@@ -352,7 +366,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
352
366
  if (worktreeInfo.status === 'error') {
353
367
  await message.reply({
354
368
  content: `❌ Worktree creation failed: ${(worktreeInfo.error_message || '').slice(0, 1900)}`,
355
- flags: SILENT_MESSAGE_FLAGS,
369
+ flags: NOTIFY_MESSAGE_FLAGS,
356
370
  });
357
371
  return;
358
372
  }
@@ -367,7 +381,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
367
381
  discordLogger.error(`Directory does not exist: ${projectDirectory}`);
368
382
  await message.reply({
369
383
  content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
370
- flags: SILENT_MESSAGE_FLAGS,
384
+ flags: NOTIFY_MESSAGE_FLAGS,
371
385
  });
372
386
  return;
373
387
  }
@@ -391,8 +405,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
391
405
  return;
392
406
  }
393
407
  }
394
- const hasVoiceAttachment = message.attachments.some((a) => {
395
- return a.contentType?.startsWith('audio/');
408
+ const hasVoiceAttachment = message.attachments.some((attachment) => {
409
+ return isVoiceAttachment(attachment);
396
410
  });
397
411
  if (!projectDirectory) {
398
412
  discordLogger.log(`Cannot process message: no project directory for thread ${thread.id}`);
@@ -442,6 +456,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
442
456
  appId: currentAppId,
443
457
  agent: cliInjectedAgent,
444
458
  model: cliInjectedModel,
459
+ permissions: cliInjectedPermissions,
445
460
  sessionStartSource: sessionStartSource
446
461
  ? {
447
462
  scheduleKind: sessionStartSource.scheduleKind,
@@ -492,7 +507,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
492
507
  discordLogger.error(`Directory does not exist: ${projectDirectory}`);
493
508
  await message.reply({
494
509
  content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
495
- flags: SILENT_MESSAGE_FLAGS,
510
+ flags: NOTIFY_MESSAGE_FLAGS,
496
511
  });
497
512
  return;
498
513
  }
@@ -511,7 +526,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
511
526
  return;
512
527
  }
513
528
  }
514
- const hasVoice = message.attachments.some((a) => a.contentType?.startsWith('audio/'));
529
+ const hasVoice = message.attachments.some((attachment) => {
530
+ return isVoiceAttachment(attachment);
531
+ });
515
532
  const baseThreadName = hasVoice
516
533
  ? 'Voice Message'
517
534
  : stripMentions(message.content || '')
@@ -555,7 +572,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
555
572
  });
556
573
  await thread.send({
557
574
  content: `⚠️ Failed to create worktree: ${errMsg}\nUsing main project directory instead.`,
558
- flags: SILENT_MESSAGE_FLAGS,
575
+ flags: NOTIFY_MESSAGE_FLAGS,
559
576
  });
560
577
  }
561
578
  else {
@@ -609,7 +626,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
609
626
  const errMsg = (error instanceof Error ? error.message : String(error)).slice(0, 1900);
610
627
  await message.reply({
611
628
  content: `Error: ${errMsg}`,
612
- flags: SILENT_MESSAGE_FLAGS,
629
+ flags: NOTIFY_MESSAGE_FLAGS,
613
630
  });
614
631
  }
615
632
  catch (sendError) {
@@ -633,7 +650,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
633
650
  const starterMessage = await thread
634
651
  .fetchStarterMessage()
635
652
  .catch((error) => {
636
- discordLogger.warn(`[THREAD_CREATE] Failed to fetch starter message for thread ${thread.id}:`, error instanceof Error ? error.message : String(error));
653
+ discordLogger.warn(`[THREAD_CREATE] Failed to fetch starter message for thread ${thread.id}:`, error instanceof Error ? error.stack : String(error));
637
654
  return null;
638
655
  });
639
656
  if (!starterMessage) {
@@ -675,7 +692,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
675
692
  discordLogger.error(`[BOT_SESSION] Directory does not exist: ${projectDirectory}`);
676
693
  await thread.send({
677
694
  content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
678
- flags: SILENT_MESSAGE_FLAGS,
695
+ flags: NOTIFY_MESSAGE_FLAGS,
679
696
  });
680
697
  return;
681
698
  }
@@ -710,11 +727,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
710
727
  });
711
728
  await (worktreeStatusMessage?.edit({
712
729
  content: `⚠️ Failed to create worktree: ${worktreeResult.message}\nUsing main project directory instead.`,
713
- flags: SILENT_MESSAGE_FLAGS,
730
+ flags: NOTIFY_MESSAGE_FLAGS,
714
731
  }) ||
715
732
  thread.send({
716
733
  content: `⚠️ Failed to create worktree: ${worktreeResult.message}\nUsing main project directory instead.`,
717
- flags: SILENT_MESSAGE_FLAGS,
734
+ flags: NOTIFY_MESSAGE_FLAGS,
718
735
  }));
719
736
  return projectDirectory;
720
737
  }
@@ -757,6 +774,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
757
774
  appId: currentAppId,
758
775
  agent: marker.agent,
759
776
  model: marker.model,
777
+ permissions: marker.permissions,
760
778
  mode: 'opencode',
761
779
  sessionStartSource: botThreadStartSource
762
780
  ? {
@@ -773,7 +791,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
773
791
  const errMsg = (error instanceof Error ? error.message : String(error)).slice(0, 1900);
774
792
  await thread.send({
775
793
  content: `Error: ${errMsg}`,
776
- flags: SILENT_MESSAGE_FLAGS,
794
+ flags: NOTIFY_MESSAGE_FLAGS,
777
795
  });
778
796
  }
779
797
  catch (sendError) {
@@ -786,7 +804,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
786
804
  discordClient.on(Events.ThreadDelete, (thread) => {
787
805
  disposeRuntime(thread.id);
788
806
  });
789
- await discordClient.login(token);
807
+ // Skip login if the caller already connected the client (e.g. cli.ts logs in
808
+ // before calling startDiscordBot). Calling login() again destroys the existing
809
+ // WebSocket (close code 1000) and triggers a spurious ShardReconnecting event.
810
+ if (!discordClient.isReady()) {
811
+ await discordClient.login(token);
812
+ }
790
813
  startHeapMonitor();
791
814
  const stopTaskRunner = startTaskRunner({ token });
792
815
  const stopRuntimeIdleSweeper = startRuntimeIdleSweeper();
@@ -802,7 +825,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
802
825
  await stopRuntimeIdleSweeper();
803
826
  await stopTaskRunner();
804
827
  await flushDebouncedProcessCallbacks().catch((error) => {
805
- discordLogger.warn('Failed to flush debounced process callbacks:', error instanceof Error ? error.message : String(error));
828
+ discordLogger.warn('Failed to flush debounced process callbacks:', error instanceof Error ? error.stack : String(error));
806
829
  });
807
830
  // Cancel pending IPC requests so plugin tools don't hang
808
831
  await cancelAllPendingIpcRequests().catch((e) => {
@@ -48,6 +48,17 @@ export function discordApiUrl(path) {
48
48
  export function createDiscordRest(token) {
49
49
  return new REST({ api: getDiscordRestApiUrl() }).setToken(token);
50
50
  }
51
+ /**
52
+ * Returns the internet-reachable base URL for this kimaki instance.
53
+ * When KIMAKI_INTERNET_REACHABLE_URL is set (e.g. "https://my-kimaki.fly.dev"),
54
+ * kimaki binds the hrana server to 0.0.0.0 and exposes a /kimaki/wake endpoint
55
+ * so the gateway-proxy can wake this instance. Discord traffic still flows
56
+ * through the normal path (gateway-proxy in gateway mode, direct in self-hosted).
57
+ * Returns null when not set (kimaki only reachable on localhost).
58
+ */
59
+ export function getInternetReachableBaseUrl() {
60
+ return process.env['KIMAKI_INTERNET_REACHABLE_URL'] || null;
61
+ }
51
62
  /**
52
63
  * Derive an HTTPS REST base URL from a WebSocket gateway URL.
53
64
  * Swaps wss→https and ws→http. Used for gateway mode where the
@@ -0,0 +1,350 @@
1
+ // Discord WS + REST reverse proxy.
2
+ // Mounted on the hrana http.Server to proxy Discord Gateway (WebSocket)
3
+ // and REST API traffic through localhost. This enables cloud deployment
4
+ // where kimaki runs behind a reverse proxy / tunnel.
5
+ //
6
+ // The proxy is always registered on the hrana server for simplicity,
7
+ // but only matters when KIMAKI_INTERNET_REACHABLE_URL is set (which
8
+ // makes discordBaseUrl point to localhost instead of Discord directly).
9
+ //
10
+ // Upstream URL is configurable: in self-hosted mode it's https://discord.com,
11
+ // in gateway mode it's the Rust gateway-proxy (e.g. https://discord-gateway.kimaki.xyz).
12
+ // The upstream is captured at proxy creation time from the current discordBaseUrl.
13
+ //
14
+ // REST proxy: forwards /api/* to upstream /api/*
15
+ // WS proxy: upgrades /gateway to upstream gateway WS
16
+ //
17
+ // Gateway URL rewrite: intercepts GET /api/v10/gateway/bot responses and
18
+ // rewrites the "url" field to ws://127.0.0.1:{port}/gateway so discord.js
19
+ // connects through the local proxy for WebSocket too.
20
+ //
21
+ // No retry logic — discord.js has its own reconnect/resume. The proxy is
22
+ // transparent: it pipes frames and propagates close events cleanly.
23
+ import http from 'node:http';
24
+ import stream from 'node:stream';
25
+ import WebSocket, { WebSocketServer } from 'ws';
26
+ import { createLogger } from './logger.js';
27
+ const logger = createLogger('PROXY');
28
+ const DEFAULT_REST_BASE = 'https://discord.com';
29
+ const DEFAULT_GATEWAY_BASE = 'wss://gateway.discord.gg';
30
+ /**
31
+ * Derive an upstream WS gateway URL from a REST base URL.
32
+ * For discord.com: returns wss://gateway.discord.gg (different host).
33
+ * For gateway proxies: swaps https→wss / http→ws (same host).
34
+ */
35
+ export function deriveGatewayBaseFromRest(restBase) {
36
+ try {
37
+ const parsed = new URL(restBase);
38
+ // discord.com uses a different host for the gateway
39
+ if (parsed.hostname === 'discord.com') {
40
+ return DEFAULT_GATEWAY_BASE;
41
+ }
42
+ // Gateway proxies serve both REST and WS on the same host
43
+ if (parsed.protocol === 'https:') {
44
+ parsed.protocol = 'wss:';
45
+ }
46
+ else if (parsed.protocol === 'http:') {
47
+ parsed.protocol = 'ws:';
48
+ }
49
+ return parsed.toString().replace(/\/$/, '');
50
+ }
51
+ catch {
52
+ return DEFAULT_GATEWAY_BASE;
53
+ }
54
+ }
55
+ /**
56
+ * Create an HTTP request handler that proxies /api/* to an upstream REST API.
57
+ *
58
+ * getUpstreamBase resolves the upstream REST base URL at request time.
59
+ * Called per-request so it picks up the correct upstream even when
60
+ * credential resolution (gateway vs self-hosted) happens after server start.
61
+ *
62
+ * Intercepts GET /api/v10/gateway/bot to rewrite the gateway URL so discord.js
63
+ * connects through the local WS proxy instead of directly to upstream.
64
+ */
65
+ export function createDiscordRestProxyHandler({ localPort, getUpstreamBase, getGatewayToken, }) {
66
+ return async (req, res) => {
67
+ const url = new URL(req.url || '/', `http://127.0.0.1:${localPort}`);
68
+ if (!url.pathname.startsWith('/api/')) {
69
+ // Not a Discord REST path — let other handlers deal with it
70
+ res.writeHead(404);
71
+ res.end();
72
+ return;
73
+ }
74
+ // Authenticate non-loopback REST requests with the gateway token.
75
+ // When bound to 0.0.0.0, remote clients must prove they hold the secret
76
+ // to prevent the proxy being used as an open relay to Discord's API.
77
+ const remoteAddr = req.socket.remoteAddress || '';
78
+ const isLoopback = isLoopbackAddress(remoteAddr);
79
+ if (!isLoopback) {
80
+ const expectedToken = getGatewayToken();
81
+ const providedToken = req.headers['x-kimaki-auth'];
82
+ if (!expectedToken || providedToken !== expectedToken) {
83
+ res.writeHead(401, { 'content-type': 'application/json' });
84
+ res.end(JSON.stringify({ error: 'unauthorized' }));
85
+ return;
86
+ }
87
+ }
88
+ const upstreamUrl = new URL(url.pathname + url.search, getUpstreamBase());
89
+ // Forward headers, replacing Host
90
+ const headers = {};
91
+ for (const [key, value] of Object.entries(req.headers)) {
92
+ if (key === 'host' || key === 'connection') {
93
+ continue;
94
+ }
95
+ if (typeof value === 'string') {
96
+ headers[key] = value;
97
+ }
98
+ else if (Array.isArray(value)) {
99
+ headers[key] = value.join(', ');
100
+ }
101
+ }
102
+ // Collect request body
103
+ const chunks = [];
104
+ for await (const chunk of req) {
105
+ chunks.push(chunk);
106
+ }
107
+ const body = chunks.length > 0 ? Buffer.concat(chunks) : undefined;
108
+ const upstreamRes = await fetch(upstreamUrl.toString(), {
109
+ method: req.method || 'GET',
110
+ headers,
111
+ body,
112
+ }).catch((err) => {
113
+ logger.error(`REST proxy error: ${err.message}`);
114
+ res.writeHead(502, { 'content-type': 'application/json' });
115
+ res.end(JSON.stringify({ error: 'upstream_error', message: err.message }));
116
+ return null;
117
+ });
118
+ if (!upstreamRes) {
119
+ return;
120
+ }
121
+ // Intercept /gateway/bot to rewrite the gateway URL
122
+ const isGatewayBot = url.pathname.endsWith('/gateway/bot');
123
+ if (isGatewayBot && upstreamRes.ok) {
124
+ const responseBody = await upstreamRes.json();
125
+ // Rewrite gateway URL to point to local WS proxy.
126
+ // Embed the gateway token as a query param so discord.js authenticates
127
+ // with the WS bridge (discord.js can't set custom headers on WS upgrade).
128
+ const token = getGatewayToken();
129
+ const tokenQuery = token ? `?token=${encodeURIComponent(token)}` : '';
130
+ responseBody.url = `ws://127.0.0.1:${localPort}/gateway${tokenQuery}`;
131
+ const rewritten = JSON.stringify(responseBody);
132
+ // Forward relevant response headers
133
+ const responseHeaders = {
134
+ 'content-type': 'application/json',
135
+ 'content-length': Buffer.byteLength(rewritten).toString(),
136
+ };
137
+ copyRateLimitHeaders(upstreamRes.headers, responseHeaders);
138
+ res.writeHead(upstreamRes.status, responseHeaders);
139
+ res.end(rewritten);
140
+ return;
141
+ }
142
+ // Forward response as-is
143
+ const responseHeaders = {};
144
+ upstreamRes.headers.forEach((value, key) => {
145
+ // Skip transfer-encoding since we buffer the full response
146
+ if (key === 'transfer-encoding') {
147
+ return;
148
+ }
149
+ responseHeaders[key] = value;
150
+ });
151
+ const responseBody = Buffer.from(await upstreamRes.arrayBuffer());
152
+ responseHeaders['content-length'] = responseBody.length.toString();
153
+ res.writeHead(upstreamRes.status, responseHeaders);
154
+ res.end(responseBody);
155
+ };
156
+ }
157
+ /**
158
+ * Check if a socket remote address is loopback.
159
+ * Accepts IPv6 loopback, IPv4 loopback range, and IPv4-mapped IPv6 loopback.
160
+ */
161
+ export function isLoopbackAddress(remoteAddr) {
162
+ return remoteAddr === '::1'
163
+ || remoteAddr.startsWith('127.')
164
+ || remoteAddr.startsWith('::ffff:127.');
165
+ }
166
+ /**
167
+ * Copy Discord rate-limit headers from upstream response to proxy response.
168
+ */
169
+ function copyRateLimitHeaders(source, target) {
170
+ const rateLimitHeaders = [
171
+ 'x-ratelimit-limit',
172
+ 'x-ratelimit-remaining',
173
+ 'x-ratelimit-reset',
174
+ 'x-ratelimit-reset-after',
175
+ 'x-ratelimit-bucket',
176
+ 'x-ratelimit-global',
177
+ 'retry-after',
178
+ ];
179
+ for (const header of rateLimitHeaders) {
180
+ const value = source.get(header);
181
+ if (value) {
182
+ target[header] = value;
183
+ }
184
+ }
185
+ }
186
+ // ── WebSocket bridge/relay ───────────────────────────────────────────────
187
+ //
188
+ // The WS bridge is a rendezvous point for two connections:
189
+ // 1. The gateway-proxy connects inbound (from the internet) on /gateway
190
+ // 2. discord.js connects locally on /gateway
191
+ //
192
+ // The bridge pairs the two and pipes frames bidirectionally.
193
+ // When either side closes, the other side is closed with the same code/reason.
194
+ //
195
+ // Connection ordering: the gateway-proxy may connect before or after discord.js.
196
+ // Unpaired connections wait up to 30s for their counterpart. If no match
197
+ // arrives, the connection is closed with 4000 "no counterpart connected".
198
+ //
199
+ // No retry logic — discord.js has its own reconnect/resume, and the
200
+ // gateway-proxy manages its own reconnection. The bridge is transparent.
201
+ const PAIR_TIMEOUT_MS = 30_000;
202
+ /**
203
+ * Create a WebSocket upgrade handler that implements the bridge/relay pattern.
204
+ * The gateway-proxy connects as "upstream" and discord.js connects as "local".
205
+ * Both connect to /gateway on the hrana server; the bridge pairs them.
206
+ *
207
+ * Identification: the gateway-proxy sends a custom header
208
+ * `X-Kimaki-Role: gateway` on its WS upgrade request. Connections without
209
+ * this header are treated as local (discord.js) connections.
210
+ *
211
+ * Authentication: gateway connections must include an `Authorization` header
212
+ * with the value `client_id:client_secret` matching the stored gateway token.
213
+ * Local connections from 127.0.0.1 are always allowed (discord.js).
214
+ */
215
+ export function createGatewayUpgradeHandler({ getGatewayToken, }) {
216
+ const wss = new WebSocketServer({ noServer: true });
217
+ // Waiting connections. At most one of each role waiting at a time.
218
+ let waitingGateway = null;
219
+ let waitingLocal = null;
220
+ function tryPair() {
221
+ if (!waitingGateway || !waitingLocal) {
222
+ return;
223
+ }
224
+ const gateway = waitingGateway;
225
+ const local = waitingLocal;
226
+ waitingGateway = null;
227
+ waitingLocal = null;
228
+ clearTimeout(gateway.timer);
229
+ clearTimeout(local.timer);
230
+ bridgeConnections(gateway.ws, local.ws);
231
+ }
232
+ return (req, socket, head) => {
233
+ const url = new URL(req.url || '/', 'http://localhost');
234
+ if (url.pathname !== '/gateway') {
235
+ socket.destroy();
236
+ return;
237
+ }
238
+ const role = req.headers['x-kimaki-role'] === 'gateway' ? 'gateway' : 'local';
239
+ // Authenticate ALL connections via Authorization header or query param.
240
+ // Gateway-proxy sends token as Authorization header.
241
+ // discord.js sends token as ?token= query param (can't set custom WS headers).
242
+ // Reject if token is not yet configured (startup race) or doesn't match.
243
+ const expectedToken = getGatewayToken();
244
+ const providedToken = req.headers['authorization'] || url.searchParams.get('token');
245
+ if (!expectedToken || providedToken !== expectedToken) {
246
+ logger.error(`WS bridge: ${role} connection rejected — ${!expectedToken ? 'token not configured' : 'invalid auth'}`);
247
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
248
+ socket.destroy();
249
+ return;
250
+ }
251
+ wss.handleUpgrade(req, socket, head, (ws) => {
252
+ logger.log(`WS bridge: ${role} connection established`);
253
+ if (role === 'gateway') {
254
+ // Close any existing waiting gateway connection
255
+ if (waitingGateway) {
256
+ clearTimeout(waitingGateway.timer);
257
+ waitingGateway.ws.close(1000, 'replaced by new gateway connection');
258
+ }
259
+ const timer = setTimeout(() => {
260
+ logger.log('WS bridge: gateway timed out waiting for local');
261
+ waitingGateway = null;
262
+ ws.close(4000, 'no counterpart connected');
263
+ }, PAIR_TIMEOUT_MS);
264
+ waitingGateway = { ws, timer };
265
+ }
266
+ else {
267
+ // Close any existing waiting local connection
268
+ if (waitingLocal) {
269
+ clearTimeout(waitingLocal.timer);
270
+ waitingLocal.ws.close(1000, 'replaced by new local connection');
271
+ }
272
+ const timer = setTimeout(() => {
273
+ logger.log('WS bridge: local timed out waiting for gateway');
274
+ waitingLocal = null;
275
+ ws.close(4000, 'no counterpart connected');
276
+ }, PAIR_TIMEOUT_MS);
277
+ waitingLocal = { ws, timer };
278
+ }
279
+ // If a connection closes before being paired, clean up
280
+ ws.on('close', () => {
281
+ if (waitingGateway?.ws === ws) {
282
+ clearTimeout(waitingGateway.timer);
283
+ waitingGateway = null;
284
+ }
285
+ if (waitingLocal?.ws === ws) {
286
+ clearTimeout(waitingLocal.timer);
287
+ waitingLocal = null;
288
+ }
289
+ });
290
+ tryPair();
291
+ });
292
+ };
293
+ }
294
+ /**
295
+ * Bridge two WebSocket connections: pipe frames bidirectionally.
296
+ * When either side closes, close the other with the same code/reason.
297
+ */
298
+ function bridgeConnections(gateway, local) {
299
+ logger.log('WS bridge: paired gateway ↔ local');
300
+ let gatewayClosed = false;
301
+ let localClosed = false;
302
+ // Gateway → Local
303
+ gateway.on('message', (data, isBinary) => {
304
+ if (local.readyState === WebSocket.OPEN) {
305
+ local.send(data, { binary: isBinary });
306
+ }
307
+ });
308
+ // Local → Gateway
309
+ local.on('message', (data, isBinary) => {
310
+ if (gateway.readyState === WebSocket.OPEN) {
311
+ gateway.send(data, { binary: isBinary });
312
+ }
313
+ });
314
+ // Gateway closes → close local
315
+ gateway.on('close', (code, reason) => {
316
+ gatewayClosed = true;
317
+ if (!localClosed) {
318
+ const closeCode = isValidCloseCode(code) ? code : 1000;
319
+ local.close(closeCode, reason);
320
+ }
321
+ });
322
+ // Local closes → close gateway
323
+ local.on('close', (code, reason) => {
324
+ localClosed = true;
325
+ if (!gatewayClosed) {
326
+ const closeCode = isValidCloseCode(code) ? code : 1000;
327
+ gateway.close(closeCode, reason);
328
+ }
329
+ });
330
+ // Error handling
331
+ gateway.on('error', (err) => {
332
+ logger.error(`WS bridge gateway error: ${err.message}`);
333
+ if (!localClosed) {
334
+ local.close(1001, 'gateway error');
335
+ }
336
+ });
337
+ local.on('error', (err) => {
338
+ logger.error(`WS bridge local error: ${err.message}`);
339
+ if (!gatewayClosed) {
340
+ gateway.close(1001, 'local error');
341
+ }
342
+ });
343
+ }
344
+ /**
345
+ * WebSocket close codes must be 1000 or in range 3000-4999 for application use.
346
+ * discord.js uses codes like 4000-4014 for gateway-specific closes.
347
+ */
348
+ function isValidCloseCode(code) {
349
+ return code === 1000 || code === 1001 || (code >= 3000 && code <= 4999);
350
+ }