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
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
// Tests for the Discord WS + REST reverse proxy.
|
|
2
|
+
// Spins up a mock Discord server (HTTP + WS) and verifies the proxy
|
|
3
|
+
// correctly forwards REST requests, rewrites /gateway/bot URLs,
|
|
4
|
+
// pipes WebSocket frames bidirectionally, and propagates close events.
|
|
5
|
+
import { describe, test, expect, beforeAll, afterAll } from 'vitest';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import WebSocket, { WebSocketServer } from 'ws';
|
|
8
|
+
import { createDiscordRestProxyHandler, createGatewayUpgradeHandler, deriveGatewayBaseFromRest, isLoopbackAddress, } from './discord-ws-proxy.js';
|
|
9
|
+
// ── Test infrastructure ──────────────────────────────────────────────────
|
|
10
|
+
const TEST_GATEWAY_TOKEN = 'test-client-id:test-secret';
|
|
11
|
+
let mockDiscordServer;
|
|
12
|
+
let mockDiscordPort;
|
|
13
|
+
let mockDiscordWss;
|
|
14
|
+
// Track active WS connections for cleanup
|
|
15
|
+
const mockWsConnections = [];
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
// Start mock Discord server (simulates the gateway-proxy REST API)
|
|
18
|
+
mockDiscordWss = new WebSocketServer({ noServer: true });
|
|
19
|
+
mockDiscordServer = http.createServer((req, res) => {
|
|
20
|
+
const url = new URL(req.url || '/', 'http://localhost');
|
|
21
|
+
// Mock GET /api/v10/gateway/bot
|
|
22
|
+
if (url.pathname === '/api/v10/gateway/bot') {
|
|
23
|
+
const body = JSON.stringify({
|
|
24
|
+
url: `ws://127.0.0.1:${mockDiscordPort}/gateway-ws`,
|
|
25
|
+
shards: 1,
|
|
26
|
+
session_start_limit: {
|
|
27
|
+
total: 1000,
|
|
28
|
+
remaining: 999,
|
|
29
|
+
reset_after: 14400000,
|
|
30
|
+
max_concurrency: 1,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
res.writeHead(200, {
|
|
34
|
+
'content-type': 'application/json',
|
|
35
|
+
'x-ratelimit-remaining': '39',
|
|
36
|
+
});
|
|
37
|
+
res.end(body);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
// Mock generic REST endpoint
|
|
41
|
+
if (url.pathname.startsWith('/api/')) {
|
|
42
|
+
const chunks = [];
|
|
43
|
+
req.on('data', (c) => {
|
|
44
|
+
chunks.push(c);
|
|
45
|
+
});
|
|
46
|
+
req.on('end', () => {
|
|
47
|
+
const requestBody = chunks.length > 0 ? Buffer.concat(chunks).toString() : undefined;
|
|
48
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
49
|
+
res.end(JSON.stringify({
|
|
50
|
+
echo: true,
|
|
51
|
+
method: req.method,
|
|
52
|
+
path: url.pathname,
|
|
53
|
+
body: requestBody,
|
|
54
|
+
authorization: req.headers['authorization'],
|
|
55
|
+
}));
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
res.writeHead(404);
|
|
60
|
+
res.end();
|
|
61
|
+
});
|
|
62
|
+
// Handle WebSocket upgrades on mock Discord server
|
|
63
|
+
mockDiscordServer.on('upgrade', (req, socket, head) => {
|
|
64
|
+
mockDiscordWss.handleUpgrade(req, socket, head, (ws) => {
|
|
65
|
+
mockWsConnections.push(ws);
|
|
66
|
+
mockDiscordWss.emit('connection', ws, req);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
await new Promise((resolve) => {
|
|
70
|
+
mockDiscordServer.listen(0, '127.0.0.1', () => {
|
|
71
|
+
const addr = mockDiscordServer.address();
|
|
72
|
+
mockDiscordPort = typeof addr === 'object' && addr ? addr.port : 0;
|
|
73
|
+
resolve();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
afterAll(async () => {
|
|
78
|
+
for (const ws of mockWsConnections) {
|
|
79
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
80
|
+
ws.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
mockWsConnections.length = 0;
|
|
84
|
+
await new Promise((r) => {
|
|
85
|
+
mockDiscordWss.close(() => {
|
|
86
|
+
mockDiscordServer.close(() => {
|
|
87
|
+
r();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
// ── REST proxy tests ─────────────────────────────────────────────────────
|
|
93
|
+
describe('REST proxy', () => {
|
|
94
|
+
test('forwards GET request to upstream and returns response', async () => {
|
|
95
|
+
const { server, port } = await createTestProxy();
|
|
96
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/v10/channels/123/messages`, { headers: { authorization: 'Bot test-token' } });
|
|
97
|
+
const body = await res.json();
|
|
98
|
+
expect(res.status).toBe(200);
|
|
99
|
+
expect(body.echo).toBe(true);
|
|
100
|
+
expect(body.method).toBe('GET');
|
|
101
|
+
expect(body.path).toBe('/api/v10/channels/123/messages');
|
|
102
|
+
expect(body.authorization).toBe('Bot test-token');
|
|
103
|
+
await closeServer(server);
|
|
104
|
+
});
|
|
105
|
+
test('forwards POST request with body', async () => {
|
|
106
|
+
const { server, port } = await createTestProxy();
|
|
107
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/v10/channels/123/messages`, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: {
|
|
110
|
+
'content-type': 'application/json',
|
|
111
|
+
authorization: 'Bot test-token',
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify({ content: 'hello' }),
|
|
114
|
+
});
|
|
115
|
+
const body = await res.json();
|
|
116
|
+
expect(body.method).toBe('POST');
|
|
117
|
+
expect(body.body).toBe('{"content":"hello"}');
|
|
118
|
+
await closeServer(server);
|
|
119
|
+
});
|
|
120
|
+
test('returns 404 for non-/api/ paths', async () => {
|
|
121
|
+
const { server, port } = await createTestProxy();
|
|
122
|
+
const res = await fetch(`http://127.0.0.1:${port}/something-else`);
|
|
123
|
+
expect(res.status).toBe(404);
|
|
124
|
+
await closeServer(server);
|
|
125
|
+
});
|
|
126
|
+
test('rewrites /gateway/bot URL to local WS proxy', async () => {
|
|
127
|
+
const { server, port } = await createTestProxy();
|
|
128
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/v10/gateway/bot`, { headers: { authorization: 'Bot test-token' } });
|
|
129
|
+
const body = await res.json();
|
|
130
|
+
// The url should be rewritten to point to local proxy
|
|
131
|
+
expect(body.url).toBe(`ws://127.0.0.1:${port}/gateway`);
|
|
132
|
+
// Other fields preserved
|
|
133
|
+
expect(body.shards).toBe(1);
|
|
134
|
+
// Rate limit headers forwarded
|
|
135
|
+
expect(res.headers.get('x-ratelimit-remaining')).toBe('39');
|
|
136
|
+
await closeServer(server);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
// ── WebSocket bridge tests ───────────────────────────────────────────────
|
|
140
|
+
describe('WebSocket bridge', () => {
|
|
141
|
+
test('bidirectional frame forwarding between gateway and local', async () => {
|
|
142
|
+
const { server, port } = await createTestProxyWithWs();
|
|
143
|
+
// Gateway connects first
|
|
144
|
+
const gateway = new WebSocket(`ws://127.0.0.1:${port}/gateway`, {
|
|
145
|
+
headers: { 'x-kimaki-role': 'gateway', authorization: TEST_GATEWAY_TOKEN },
|
|
146
|
+
});
|
|
147
|
+
await waitForOpen(gateway);
|
|
148
|
+
// Local (discord.js) connects
|
|
149
|
+
const local = new WebSocket(`ws://127.0.0.1:${port}/gateway?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`);
|
|
150
|
+
await waitForOpen(local);
|
|
151
|
+
const localMessages = [];
|
|
152
|
+
const gatewayMessages = [];
|
|
153
|
+
local.on('message', (data) => {
|
|
154
|
+
localMessages.push(data.toString());
|
|
155
|
+
});
|
|
156
|
+
gateway.on('message', (data) => {
|
|
157
|
+
gatewayMessages.push(data.toString());
|
|
158
|
+
});
|
|
159
|
+
// Gateway → Local
|
|
160
|
+
gateway.send('event-from-gateway');
|
|
161
|
+
await waitFor(() => localMessages.length >= 1, 2000);
|
|
162
|
+
expect(localMessages).toEqual(['event-from-gateway']);
|
|
163
|
+
// Local → Gateway
|
|
164
|
+
local.send('identify-from-local');
|
|
165
|
+
await waitFor(() => gatewayMessages.length >= 1, 2000);
|
|
166
|
+
expect(gatewayMessages).toEqual(['identify-from-local']);
|
|
167
|
+
gateway.close();
|
|
168
|
+
local.close();
|
|
169
|
+
await closeServer(server);
|
|
170
|
+
});
|
|
171
|
+
test('local connects before gateway', async () => {
|
|
172
|
+
const { server, port } = await createTestProxyWithWs();
|
|
173
|
+
// Local connects first
|
|
174
|
+
const local = new WebSocket(`ws://127.0.0.1:${port}/gateway?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`);
|
|
175
|
+
await waitForOpen(local);
|
|
176
|
+
const localMessages = [];
|
|
177
|
+
local.on('message', (data) => {
|
|
178
|
+
localMessages.push(data.toString());
|
|
179
|
+
});
|
|
180
|
+
// Gateway connects after
|
|
181
|
+
const gateway = new WebSocket(`ws://127.0.0.1:${port}/gateway`, {
|
|
182
|
+
headers: { 'x-kimaki-role': 'gateway', authorization: TEST_GATEWAY_TOKEN },
|
|
183
|
+
});
|
|
184
|
+
await waitForOpen(gateway);
|
|
185
|
+
gateway.send('hello-from-gateway');
|
|
186
|
+
await waitFor(() => localMessages.length >= 1, 2000);
|
|
187
|
+
expect(localMessages).toEqual(['hello-from-gateway']);
|
|
188
|
+
gateway.close();
|
|
189
|
+
local.close();
|
|
190
|
+
await closeServer(server);
|
|
191
|
+
});
|
|
192
|
+
test('gateway close propagates to local', async () => {
|
|
193
|
+
const { server, port } = await createTestProxyWithWs();
|
|
194
|
+
const gateway = new WebSocket(`ws://127.0.0.1:${port}/gateway`, {
|
|
195
|
+
headers: { 'x-kimaki-role': 'gateway', authorization: TEST_GATEWAY_TOKEN },
|
|
196
|
+
});
|
|
197
|
+
const local = new WebSocket(`ws://127.0.0.1:${port}/gateway?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`);
|
|
198
|
+
await waitForOpen(gateway);
|
|
199
|
+
await waitForOpen(local);
|
|
200
|
+
const localClosed = new Promise((resolve) => {
|
|
201
|
+
local.on('close', (code, reason) => {
|
|
202
|
+
resolve({ code, reason: reason.toString() });
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
gateway.close(4001, 'gateway shutdown');
|
|
206
|
+
const result = await localClosed;
|
|
207
|
+
expect(result.code).toBe(4001);
|
|
208
|
+
expect(result.reason).toBe('gateway shutdown');
|
|
209
|
+
await closeServer(server);
|
|
210
|
+
});
|
|
211
|
+
test('local close propagates to gateway', async () => {
|
|
212
|
+
const { server, port } = await createTestProxyWithWs();
|
|
213
|
+
const gateway = new WebSocket(`ws://127.0.0.1:${port}/gateway`, {
|
|
214
|
+
headers: { 'x-kimaki-role': 'gateway', authorization: TEST_GATEWAY_TOKEN },
|
|
215
|
+
});
|
|
216
|
+
const local = new WebSocket(`ws://127.0.0.1:${port}/gateway?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`);
|
|
217
|
+
await waitForOpen(gateway);
|
|
218
|
+
await waitForOpen(local);
|
|
219
|
+
const gatewayClosed = new Promise((resolve) => {
|
|
220
|
+
gateway.on('close', (code, reason) => {
|
|
221
|
+
resolve({ code, reason: reason.toString() });
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
local.close(4000, 'client done');
|
|
225
|
+
const result = await gatewayClosed;
|
|
226
|
+
expect(result.code).toBe(4000);
|
|
227
|
+
expect(result.reason).toBe('client done');
|
|
228
|
+
await closeServer(server);
|
|
229
|
+
});
|
|
230
|
+
test('rejects upgrade on non-/gateway path', async () => {
|
|
231
|
+
const { server, port } = await createTestProxyWithWs();
|
|
232
|
+
const client = new WebSocket(`ws://127.0.0.1:${port}/other-path`);
|
|
233
|
+
const error = await new Promise((resolve) => {
|
|
234
|
+
client.on('error', (err) => {
|
|
235
|
+
resolve(err);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
expect(error).toBeDefined();
|
|
239
|
+
await closeServer(server);
|
|
240
|
+
});
|
|
241
|
+
test('rejects gateway connection with wrong authorization', async () => {
|
|
242
|
+
const { server, port } = await createTestProxyWithWs();
|
|
243
|
+
const client = new WebSocket(`ws://127.0.0.1:${port}/gateway`, {
|
|
244
|
+
headers: { 'x-kimaki-role': 'gateway', authorization: 'wrong-token' },
|
|
245
|
+
});
|
|
246
|
+
const error = await new Promise((resolve) => {
|
|
247
|
+
client.on('error', (err) => {
|
|
248
|
+
resolve(err);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
expect(error).toBeDefined();
|
|
252
|
+
expect(error.message).toContain('401');
|
|
253
|
+
await closeServer(server);
|
|
254
|
+
});
|
|
255
|
+
test('new gateway connection replaces waiting one', async () => {
|
|
256
|
+
const { server, port } = await createTestProxyWithWs();
|
|
257
|
+
// First gateway connects and waits
|
|
258
|
+
const gateway1 = new WebSocket(`ws://127.0.0.1:${port}/gateway`, {
|
|
259
|
+
headers: { 'x-kimaki-role': 'gateway', authorization: TEST_GATEWAY_TOKEN },
|
|
260
|
+
});
|
|
261
|
+
await waitForOpen(gateway1);
|
|
262
|
+
const gateway1Closed = new Promise((resolve) => {
|
|
263
|
+
gateway1.on('close', (code) => {
|
|
264
|
+
resolve(code);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
// Second gateway replaces it
|
|
268
|
+
const gateway2 = new WebSocket(`ws://127.0.0.1:${port}/gateway`, {
|
|
269
|
+
headers: { 'x-kimaki-role': 'gateway', authorization: TEST_GATEWAY_TOKEN },
|
|
270
|
+
});
|
|
271
|
+
await waitForOpen(gateway2);
|
|
272
|
+
// First should be closed
|
|
273
|
+
const code = await gateway1Closed;
|
|
274
|
+
expect(code).toBe(1000);
|
|
275
|
+
// Now local connects and pairs with gateway2
|
|
276
|
+
const local = new WebSocket(`ws://127.0.0.1:${port}/gateway?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`);
|
|
277
|
+
await waitForOpen(local);
|
|
278
|
+
const localMessages = [];
|
|
279
|
+
local.on('message', (data) => {
|
|
280
|
+
localMessages.push(data.toString());
|
|
281
|
+
});
|
|
282
|
+
gateway2.send('from-gateway2');
|
|
283
|
+
await waitFor(() => localMessages.length >= 1, 2000);
|
|
284
|
+
expect(localMessages).toEqual(['from-gateway2']);
|
|
285
|
+
gateway2.close();
|
|
286
|
+
local.close();
|
|
287
|
+
await closeServer(server);
|
|
288
|
+
});
|
|
289
|
+
test('multiple sequential bridge sessions', async () => {
|
|
290
|
+
const { server, port } = await createTestProxyWithWs();
|
|
291
|
+
// First session
|
|
292
|
+
const g1 = new WebSocket(`ws://127.0.0.1:${port}/gateway`, {
|
|
293
|
+
headers: { 'x-kimaki-role': 'gateway', authorization: TEST_GATEWAY_TOKEN },
|
|
294
|
+
});
|
|
295
|
+
const l1 = new WebSocket(`ws://127.0.0.1:${port}/gateway?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`);
|
|
296
|
+
await waitForOpen(g1);
|
|
297
|
+
await waitForOpen(l1);
|
|
298
|
+
const l1Messages = [];
|
|
299
|
+
l1.on('message', (data) => {
|
|
300
|
+
l1Messages.push(data.toString());
|
|
301
|
+
});
|
|
302
|
+
g1.send('session1');
|
|
303
|
+
await waitFor(() => l1Messages.length >= 1, 2000);
|
|
304
|
+
expect(l1Messages).toEqual(['session1']);
|
|
305
|
+
// Close first session
|
|
306
|
+
g1.close();
|
|
307
|
+
l1.close();
|
|
308
|
+
await waitFor(() => g1.readyState === WebSocket.CLOSED, 2000);
|
|
309
|
+
await waitFor(() => l1.readyState === WebSocket.CLOSED, 2000);
|
|
310
|
+
// Second session should work fine
|
|
311
|
+
const g2 = new WebSocket(`ws://127.0.0.1:${port}/gateway`, {
|
|
312
|
+
headers: { 'x-kimaki-role': 'gateway', authorization: TEST_GATEWAY_TOKEN },
|
|
313
|
+
});
|
|
314
|
+
const l2 = new WebSocket(`ws://127.0.0.1:${port}/gateway?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`);
|
|
315
|
+
await waitForOpen(g2);
|
|
316
|
+
await waitForOpen(l2);
|
|
317
|
+
const l2Messages = [];
|
|
318
|
+
l2.on('message', (data) => {
|
|
319
|
+
l2Messages.push(data.toString());
|
|
320
|
+
});
|
|
321
|
+
g2.send('session2');
|
|
322
|
+
await waitFor(() => l2Messages.length >= 1, 2000);
|
|
323
|
+
expect(l2Messages).toEqual(['session2']);
|
|
324
|
+
g2.close();
|
|
325
|
+
l2.close();
|
|
326
|
+
await closeServer(server);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
// ── Gateway URL derivation tests ─────────────────────────────────────────
|
|
330
|
+
describe('deriveGatewayBaseFromRest', () => {
|
|
331
|
+
test('discord.com returns gateway.discord.gg', () => {
|
|
332
|
+
expect(deriveGatewayBaseFromRest('https://discord.com')).toBe('wss://gateway.discord.gg');
|
|
333
|
+
});
|
|
334
|
+
test('gateway proxy swaps https to wss', () => {
|
|
335
|
+
expect(deriveGatewayBaseFromRest('https://discord-gateway.kimaki.xyz')).toBe('wss://discord-gateway.kimaki.xyz');
|
|
336
|
+
});
|
|
337
|
+
test('http proxy swaps to ws', () => {
|
|
338
|
+
expect(deriveGatewayBaseFromRest('http://127.0.0.1:8080')).toBe('ws://127.0.0.1:8080');
|
|
339
|
+
});
|
|
340
|
+
test('strips trailing slash', () => {
|
|
341
|
+
expect(deriveGatewayBaseFromRest('https://proxy.example.com/')).toBe('wss://proxy.example.com');
|
|
342
|
+
});
|
|
343
|
+
test('invalid URL falls back to default', () => {
|
|
344
|
+
expect(deriveGatewayBaseFromRest('not-a-url')).toBe('wss://gateway.discord.gg');
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
describe('isLoopbackAddress', () => {
|
|
348
|
+
test('returns true for loopback variants', () => {
|
|
349
|
+
expect(isLoopbackAddress('::1')).toBe(true);
|
|
350
|
+
expect(isLoopbackAddress('127.0.0.1')).toBe(true);
|
|
351
|
+
expect(isLoopbackAddress('127.12.34.56')).toBe(true);
|
|
352
|
+
expect(isLoopbackAddress('::ffff:127.0.0.1')).toBe(true);
|
|
353
|
+
});
|
|
354
|
+
test('returns false for non-loopback addresses', () => {
|
|
355
|
+
expect(isLoopbackAddress('192.168.1.5')).toBe(false);
|
|
356
|
+
expect(isLoopbackAddress('10.0.0.1')).toBe(false);
|
|
357
|
+
expect(isLoopbackAddress('::ffff:192.168.1.5')).toBe(false);
|
|
358
|
+
expect(isLoopbackAddress('')).toBe(false);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
362
|
+
/**
|
|
363
|
+
* Create a test proxy server with REST handler only (pointing to mock Discord).
|
|
364
|
+
*/
|
|
365
|
+
async function createTestProxy() {
|
|
366
|
+
const handler = createMockRestProxyHandler();
|
|
367
|
+
const server = http.createServer(handler);
|
|
368
|
+
const port = await new Promise((resolve) => {
|
|
369
|
+
server.listen(0, '127.0.0.1', () => {
|
|
370
|
+
const addr = server.address();
|
|
371
|
+
resolve(typeof addr === 'object' && addr ? addr.port : 0);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
return { server, port };
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Create a test proxy server with REST and WS bridge handling.
|
|
378
|
+
*/
|
|
379
|
+
async function createTestProxyWithWs() {
|
|
380
|
+
const handler = createMockRestProxyHandler();
|
|
381
|
+
const server = http.createServer(handler);
|
|
382
|
+
// Mount the real bridge handler
|
|
383
|
+
const upgradeHandler = createGatewayUpgradeHandler({
|
|
384
|
+
getGatewayToken: () => TEST_GATEWAY_TOKEN,
|
|
385
|
+
});
|
|
386
|
+
server.on('upgrade', (req, socket, head) => {
|
|
387
|
+
upgradeHandler(req, socket, head);
|
|
388
|
+
});
|
|
389
|
+
const port = await new Promise((resolve) => {
|
|
390
|
+
server.listen(0, '127.0.0.1', () => {
|
|
391
|
+
const addr = server.address();
|
|
392
|
+
resolve(typeof addr === 'object' && addr ? addr.port : 0);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
return { server, port };
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* REST proxy handler for tests — forwards to mock Discord server.
|
|
399
|
+
*/
|
|
400
|
+
function createMockRestProxyHandler() {
|
|
401
|
+
return async (req, res) => {
|
|
402
|
+
const url = new URL(req.url || '/', 'http://localhost');
|
|
403
|
+
if (!url.pathname.startsWith('/api/')) {
|
|
404
|
+
res.writeHead(404);
|
|
405
|
+
res.end();
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const upstreamUrl = `http://127.0.0.1:${mockDiscordPort}${url.pathname}${url.search}`;
|
|
409
|
+
const headers = {};
|
|
410
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
411
|
+
if (key === 'host' || key === 'connection') {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (typeof value === 'string') {
|
|
415
|
+
headers[key] = value;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const chunks = [];
|
|
419
|
+
for await (const chunk of req) {
|
|
420
|
+
chunks.push(chunk);
|
|
421
|
+
}
|
|
422
|
+
const body = chunks.length > 0 ? Buffer.concat(chunks) : undefined;
|
|
423
|
+
const upstreamRes = await fetch(upstreamUrl, {
|
|
424
|
+
method: req.method || 'GET',
|
|
425
|
+
headers,
|
|
426
|
+
body,
|
|
427
|
+
}).catch((err) => {
|
|
428
|
+
res.writeHead(502);
|
|
429
|
+
res.end(err.message);
|
|
430
|
+
return null;
|
|
431
|
+
});
|
|
432
|
+
if (!upstreamRes) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
// Intercept /gateway/bot for URL rewrite
|
|
436
|
+
const isGatewayBot = url.pathname.endsWith('/gateway/bot');
|
|
437
|
+
if (isGatewayBot && upstreamRes.ok) {
|
|
438
|
+
const responseBody = await upstreamRes.json();
|
|
439
|
+
const addr = res.socket?.localPort;
|
|
440
|
+
responseBody.url = `ws://127.0.0.1:${addr}/gateway`;
|
|
441
|
+
const rewritten = JSON.stringify(responseBody);
|
|
442
|
+
const responseHeaders = {
|
|
443
|
+
'content-type': 'application/json',
|
|
444
|
+
};
|
|
445
|
+
upstreamRes.headers.forEach((value, key) => {
|
|
446
|
+
if (key.startsWith('x-ratelimit') || key === 'retry-after') {
|
|
447
|
+
responseHeaders[key] = value;
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
res.writeHead(upstreamRes.status, responseHeaders);
|
|
451
|
+
res.end(rewritten);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const responseBody = Buffer.from(await upstreamRes.arrayBuffer());
|
|
455
|
+
const responseHeaders = {};
|
|
456
|
+
upstreamRes.headers.forEach((value, key) => {
|
|
457
|
+
if (key !== 'transfer-encoding') {
|
|
458
|
+
responseHeaders[key] = value;
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
responseHeaders['content-length'] = responseBody.length.toString();
|
|
462
|
+
res.writeHead(upstreamRes.status, responseHeaders);
|
|
463
|
+
res.end(responseBody);
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
function closeServer(server) {
|
|
467
|
+
return new Promise((resolve) => {
|
|
468
|
+
server.close(() => {
|
|
469
|
+
resolve();
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
function sleep(ms) {
|
|
474
|
+
return new Promise((resolve) => {
|
|
475
|
+
setTimeout(resolve, ms);
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
function waitForOpen(ws) {
|
|
479
|
+
return new Promise((resolve, reject) => {
|
|
480
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
481
|
+
resolve();
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
ws.on('open', () => {
|
|
485
|
+
resolve();
|
|
486
|
+
});
|
|
487
|
+
ws.on('error', (err) => {
|
|
488
|
+
reject(err);
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
async function waitFor(condition, timeoutMs) {
|
|
493
|
+
const start = Date.now();
|
|
494
|
+
while (!condition()) {
|
|
495
|
+
if (Date.now() - start > timeoutMs) {
|
|
496
|
+
throw new Error(`waitFor timed out after ${timeoutMs}ms`);
|
|
497
|
+
}
|
|
498
|
+
await sleep(20);
|
|
499
|
+
}
|
|
500
|
+
}
|
package/dist/errors.js
CHANGED
|
@@ -147,7 +147,7 @@ export class NotFastForwardError extends createTaggedError({
|
|
|
147
147
|
}
|
|
148
148
|
export class ConflictingFilesError extends createTaggedError({
|
|
149
149
|
name: 'ConflictingFilesError',
|
|
150
|
-
message: 'Cannot merge: $target worktree has uncommitted changes in overlapping files',
|
|
150
|
+
message: 'Cannot merge: $target worktree has uncommitted changes in overlapping files. Commit changes in main worktree first, then run `/merge-worktree` again.',
|
|
151
151
|
}) {
|
|
152
152
|
}
|
|
153
153
|
export class PushError extends createTaggedError({
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Persists Discord Gateway session data (session_id, resume_url, sequence)
|
|
2
|
+
// to disk so discord.js can RESUME instead of fresh IDENTIFY on restarts.
|
|
3
|
+
//
|
|
4
|
+
// RESUME skips the full Gateway handshake and guild hydration, saving
|
|
5
|
+
// 500-2000ms on cold starts. Critical for scale-to-zero where the bot
|
|
6
|
+
// process is killed and restarted frequently.
|
|
7
|
+
//
|
|
8
|
+
// Session data is stored as a JSON file in the kimaki data directory.
|
|
9
|
+
// It's ephemeral — if the file is missing or stale, discord.js falls
|
|
10
|
+
// back to IDENTIFY automatically.
|
|
11
|
+
//
|
|
12
|
+
// Implementation: uses the public `buildStrategy` hook in discord.js
|
|
13
|
+
// ClientOptions.ws. Inside buildStrategy, the @discordjs/ws manager's
|
|
14
|
+
// options.retrieveSessionInfo and options.updateSessionInfo callbacks
|
|
15
|
+
// are replaced with disk-backed versions BEFORE any shard connects.
|
|
16
|
+
// This is the approved extension point — no monkey-patching of private
|
|
17
|
+
// internals or method overrides needed.
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { SimpleShardingStrategy, } from '@discordjs/ws';
|
|
21
|
+
import { getDataDir } from './config.js';
|
|
22
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
23
|
+
const logger = createLogger(LogPrefix.DISCORD);
|
|
24
|
+
// Gateway sessions expire after 60 seconds of disconnection per Discord docs.
|
|
25
|
+
// Use a conservative 30s window — if the file is older than this, RESUME
|
|
26
|
+
// will likely fail anyway and discord.js falls back to IDENTIFY.
|
|
27
|
+
const MAX_SESSION_AGE_MS = 30_000;
|
|
28
|
+
function getSessionFilePath() {
|
|
29
|
+
return path.join(getDataDir(), 'gateway-session.json');
|
|
30
|
+
}
|
|
31
|
+
function loadPersistedSessions() {
|
|
32
|
+
const filePath = getSessionFilePath();
|
|
33
|
+
try {
|
|
34
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
35
|
+
const parsed = JSON.parse(raw);
|
|
36
|
+
if (!parsed.shards || typeof parsed.savedAt !== 'number') {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (Date.now() - parsed.savedAt > MAX_SESSION_AGE_MS) {
|
|
40
|
+
logger.log('Gateway session file too old, will IDENTIFY fresh');
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function savePersistedSessions(sessions) {
|
|
50
|
+
const filePath = getSessionFilePath();
|
|
51
|
+
try {
|
|
52
|
+
fs.writeFileSync(filePath, JSON.stringify(sessions), 'utf-8');
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
logger.warn(`Failed to persist gateway session: ${error instanceof Error ? error.message : String(error)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Creates a `buildStrategy` function for discord.js ClientOptions.ws
|
|
60
|
+
* that enables Gateway session persistence for RESUME on restarts.
|
|
61
|
+
*
|
|
62
|
+
* Usage:
|
|
63
|
+
* ```ts
|
|
64
|
+
* const client = new Client({
|
|
65
|
+
* ws: { buildStrategy: buildSessionResumeStrategy() },
|
|
66
|
+
* })
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* How it works:
|
|
70
|
+
* - discord.js calls buildStrategy(manager) during WebSocketManager construction
|
|
71
|
+
* - We replace manager.options.retrieveSessionInfo/updateSessionInfo with
|
|
72
|
+
* disk-backed versions BEFORE any shard connects
|
|
73
|
+
* - On startup, persisted session data enables RESUME instead of IDENTIFY
|
|
74
|
+
* - On shutdown, flushPersist() writes the latest session data to disk
|
|
75
|
+
*
|
|
76
|
+
* Returns the buildStrategy function AND a flushPersist handle so the
|
|
77
|
+
* caller can ensure data is written on graceful shutdown.
|
|
78
|
+
*/
|
|
79
|
+
export function buildSessionResumeStrategy() {
|
|
80
|
+
// In-memory cache of current session info per shard
|
|
81
|
+
const currentSessions = {};
|
|
82
|
+
// Load any persisted sessions from last run
|
|
83
|
+
const persisted = loadPersistedSessions();
|
|
84
|
+
if (persisted) {
|
|
85
|
+
Object.assign(currentSessions, persisted.shards);
|
|
86
|
+
const shardCount = Object.keys(persisted.shards).length;
|
|
87
|
+
logger.log(`Loaded ${shardCount} persisted gateway session(s) for RESUME (age: ${Math.round((Date.now() - persisted.savedAt) / 1000)}s)`);
|
|
88
|
+
}
|
|
89
|
+
// Debounced disk writer. updateSessionInfo fires on every Gateway dispatch
|
|
90
|
+
// (to update the sequence number), so writing on every call would thrash
|
|
91
|
+
// disk. Debounce to at most one write per second.
|
|
92
|
+
let pendingWrite = null;
|
|
93
|
+
const schedulePersist = () => {
|
|
94
|
+
if (frozen || pendingWrite) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
pendingWrite = setTimeout(() => {
|
|
98
|
+
pendingWrite = null;
|
|
99
|
+
if (frozen) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
savePersistedSessions({
|
|
103
|
+
shards: currentSessions,
|
|
104
|
+
savedAt: Date.now(),
|
|
105
|
+
});
|
|
106
|
+
}, 1_000);
|
|
107
|
+
};
|
|
108
|
+
// After flush, freeze persistence so destroy()'s updateSessionInfo(null)
|
|
109
|
+
// doesn't overwrite the saved session data via schedulePersist.
|
|
110
|
+
let frozen = false;
|
|
111
|
+
const flushPersist = () => {
|
|
112
|
+
if (pendingWrite) {
|
|
113
|
+
clearTimeout(pendingWrite);
|
|
114
|
+
pendingWrite = null;
|
|
115
|
+
}
|
|
116
|
+
const shardCount = Object.keys(currentSessions).length;
|
|
117
|
+
if (shardCount > 0) {
|
|
118
|
+
savePersistedSessions({
|
|
119
|
+
shards: currentSessions,
|
|
120
|
+
savedAt: Date.now(),
|
|
121
|
+
});
|
|
122
|
+
logger.log(`Persisted ${shardCount} gateway session(s) for next RESUME`);
|
|
123
|
+
}
|
|
124
|
+
frozen = true;
|
|
125
|
+
};
|
|
126
|
+
const buildStrategy = (manager) => {
|
|
127
|
+
// Wrap the original callbacks from discord.js (which store in-memory
|
|
128
|
+
// on the discord.js WebSocketShard.sessionInfo property).
|
|
129
|
+
const originalRetrieve = manager.options.retrieveSessionInfo;
|
|
130
|
+
const originalUpdate = manager.options.updateSessionInfo;
|
|
131
|
+
// Replace retrieveSessionInfo: try persisted data first, then in-memory.
|
|
132
|
+
// This runs BEFORE shard.connect() sends IDENTIFY or RESUME, so the
|
|
133
|
+
// persisted session is available for the first connection attempt.
|
|
134
|
+
manager.options.retrieveSessionInfo = (shardId) => {
|
|
135
|
+
// Check in-memory cache (populated from disk on startup, or from
|
|
136
|
+
// previous updateSessionInfo calls during this process lifetime)
|
|
137
|
+
const cached = currentSessions[String(shardId)];
|
|
138
|
+
if (cached) {
|
|
139
|
+
logger.log(`[shard ${shardId}] Attempting RESUME (session: ${cached.sessionId.slice(0, 8)}..., seq: ${cached.sequence})`);
|
|
140
|
+
return cached;
|
|
141
|
+
}
|
|
142
|
+
// Fall back to discord.js default (reads shard.sessionInfo)
|
|
143
|
+
return originalRetrieve(shardId);
|
|
144
|
+
};
|
|
145
|
+
// Replace updateSessionInfo: persist to disk alongside in-memory update.
|
|
146
|
+
manager.options.updateSessionInfo = (shardId, info) => {
|
|
147
|
+
// Call original so discord.js shard.sessionInfo stays in sync
|
|
148
|
+
originalUpdate(shardId, info);
|
|
149
|
+
if (info) {
|
|
150
|
+
currentSessions[String(shardId)] = info;
|
|
151
|
+
schedulePersist();
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
// null = session invalidated (logout or invalid session).
|
|
155
|
+
// Only delete this shard's data, not other shards'.
|
|
156
|
+
delete currentSessions[String(shardId)];
|
|
157
|
+
schedulePersist();
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
return new SimpleShardingStrategy(manager);
|
|
161
|
+
};
|
|
162
|
+
return { buildStrategy, flushPersist };
|
|
163
|
+
}
|