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.
- package/dist/anthropic-auth-plugin.js +628 -0
- package/dist/channel-management.js +2 -2
- package/dist/cli.js +316 -129
- package/dist/commands/action-buttons.js +1 -1
- package/dist/commands/login.js +634 -277
- package/dist/commands/model.js +91 -6
- package/dist/commands/paginated-select.js +57 -0
- package/dist/commands/resume.js +2 -2
- package/dist/commands/tasks.js +205 -0
- package/dist/commands/undo-redo.js +80 -18
- package/dist/context-awareness-plugin.js +347 -0
- package/dist/database.js +103 -7
- package/dist/db.js +39 -1
- package/dist/discord-bot.js +42 -19
- package/dist/discord-urls.js +11 -0
- package/dist/discord-ws-proxy.js +350 -0
- package/dist/discord-ws-proxy.test.js +500 -0
- package/dist/errors.js +1 -1
- package/dist/gateway-session.js +163 -0
- package/dist/hrana-server.js +114 -4
- package/dist/interaction-handler.js +30 -7
- package/dist/ipc-tools-plugin.js +186 -0
- package/dist/message-preprocessing.js +56 -11
- package/dist/onboarding-welcome.js +1 -1
- package/dist/opencode-interrupt-plugin.js +133 -75
- package/dist/opencode-plugin.js +12 -389
- package/dist/opencode.js +59 -5
- package/dist/parse-permission-rules.test.js +117 -0
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
- package/dist/session-handler/thread-session-runtime.js +68 -29
- package/dist/startup-time.e2e.test.js +295 -0
- package/dist/store.js +1 -0
- package/dist/system-message.js +3 -1
- package/dist/task-runner.js +7 -3
- package/dist/task-schedule.js +12 -0
- package/dist/thread-message-queue.e2e.test.js +13 -1
- package/dist/undo-redo.e2e.test.js +166 -0
- package/dist/utils.js +4 -1
- package/dist/voice-attachment.js +34 -0
- package/dist/voice-handler.js +11 -9
- package/dist/voice-message.e2e.test.js +78 -0
- package/dist/voice.test.js +31 -0
- package/package.json +12 -7
- package/skills/egaki/SKILL.md +80 -15
- package/skills/errore/SKILL.md +13 -0
- package/skills/lintcn/SKILL.md +749 -0
- package/skills/npm-package/SKILL.md +17 -3
- package/skills/spiceflow/SKILL.md +14 -0
- package/skills/zele/SKILL.md +9 -0
- package/src/anthropic-auth-plugin.ts +732 -0
- package/src/channel-management.ts +2 -2
- package/src/cli.ts +354 -132
- package/src/commands/action-buttons.ts +1 -0
- package/src/commands/login.ts +836 -337
- package/src/commands/model.ts +102 -7
- package/src/commands/paginated-select.ts +81 -0
- package/src/commands/resume.ts +6 -1
- package/src/commands/tasks.ts +293 -0
- package/src/commands/undo-redo.ts +87 -20
- package/src/context-awareness-plugin.ts +469 -0
- package/src/database.ts +138 -7
- package/src/db.ts +40 -1
- package/src/discord-bot.ts +46 -19
- package/src/discord-urls.ts +12 -0
- package/src/errors.ts +1 -1
- package/src/hrana-server.ts +124 -3
- package/src/interaction-handler.ts +41 -9
- package/src/ipc-tools-plugin.ts +228 -0
- package/src/message-preprocessing.ts +82 -11
- package/src/onboarding-welcome.ts +1 -1
- package/src/opencode-interrupt-plugin.ts +164 -91
- package/src/opencode-plugin.ts +13 -483
- package/src/opencode.ts +60 -5
- package/src/parse-permission-rules.test.ts +127 -0
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
- package/src/session-handler/thread-runtime-state.ts +4 -1
- package/src/session-handler/thread-session-runtime.ts +82 -20
- package/src/startup-time.e2e.test.ts +372 -0
- package/src/store.ts +8 -0
- package/src/system-message.ts +10 -1
- package/src/task-runner.ts +9 -22
- package/src/task-schedule.ts +15 -0
- package/src/thread-message-queue.e2e.test.ts +14 -1
- package/src/undo-redo.e2e.test.ts +207 -0
- package/src/utils.ts +7 -0
- package/src/voice-attachment.ts +51 -0
- package/src/voice-handler.ts +15 -7
- package/src/voice-message.e2e.test.ts +95 -0
- package/src/voice.test.ts +36 -0
- package/src/onboarding-tutorial-plugin.ts +0 -93
package/dist/discord-bot.js
CHANGED
|
@@ -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.
|
|
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:
|
|
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:
|
|
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((
|
|
395
|
-
return
|
|
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:
|
|
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((
|
|
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:
|
|
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:
|
|
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.
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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) => {
|
package/dist/discord-urls.js
CHANGED
|
@@ -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
|
+
}
|