@0xmaxma/claude-gateway 1.2.0 → 1.2.1

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 (2) hide show
  1. package/lib/pairing.ts +326 -0
  2. package/package.json +2 -1
package/lib/pairing.ts ADDED
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Shared pairing primitives for both scripts/create-agent.ts and mcp/tools/agent/handlers.ts.
3
+ * Uses fetch (available in Node 18+ and Bun) — no https module dependency.
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import { randomBytes } from 'crypto';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export interface TelegramFirstDM {
15
+ senderId: string;
16
+ chatId: string;
17
+ /** getUpdates offset to use for the next poll call */
18
+ nextOffset: number;
19
+ }
20
+
21
+ export interface DiscordFirstDM {
22
+ senderId: string;
23
+ channelId: string;
24
+ }
25
+
26
+ interface TgUpdate {
27
+ update_id: number;
28
+ message?: {
29
+ from: { id: number };
30
+ chat: { id: number; type: string };
31
+ text?: string;
32
+ };
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Telegram helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Long-poll Telegram getUpdates until the first private DM arrives.
41
+ * Returns sender/chat IDs plus the next polling offset.
42
+ */
43
+ export async function pollForFirstTelegramDM(
44
+ token: string,
45
+ timeoutMs = 10 * 60 * 1000,
46
+ ): Promise<TelegramFirstDM> {
47
+ const deadline = Date.now() + timeoutMs;
48
+ let offset = 0;
49
+
50
+ while (Date.now() < deadline) {
51
+ const remaining = Math.max(0, deadline - Date.now());
52
+ const pollSecs = Math.min(30, Math.ceil(remaining / 1000));
53
+ if (pollSecs === 0) break;
54
+
55
+ try {
56
+ const url = `https://api.telegram.org/bot${token}/getUpdates?offset=${offset}&timeout=${pollSecs}&allowed_updates=%5B%22message%22%5D`;
57
+ const res = await fetch(url);
58
+ const data = (await res.json()) as { ok: boolean; result: TgUpdate[] };
59
+
60
+ if (data.ok && data.result.length > 0) {
61
+ for (const update of data.result) {
62
+ offset = update.update_id + 1;
63
+ if (update.message?.chat.type === 'private') {
64
+ return {
65
+ senderId: String(update.message.from.id),
66
+ chatId: String(update.message.chat.id),
67
+ nextOffset: offset,
68
+ };
69
+ }
70
+ }
71
+ }
72
+ } catch {
73
+ await new Promise((r) => setTimeout(r, 2000));
74
+ }
75
+ }
76
+
77
+ throw new Error('Timed out waiting for first Telegram DM');
78
+ }
79
+
80
+ /**
81
+ * Poll getUpdates until the user in chatId replies with the expected code.
82
+ * Returns true if confirmed, false on timeout.
83
+ */
84
+ export async function pollForTelegramCode(
85
+ token: string,
86
+ chatId: string,
87
+ expectedCode: string,
88
+ offset: number,
89
+ timeoutMs = 3 * 60 * 1000,
90
+ ): Promise<boolean> {
91
+ const deadline = Date.now() + timeoutMs;
92
+ let currentOffset = offset;
93
+
94
+ while (Date.now() < deadline) {
95
+ const remaining = Math.max(0, deadline - Date.now());
96
+ const pollSecs = Math.min(30, Math.ceil(remaining / 1000));
97
+ if (pollSecs === 0) break;
98
+
99
+ try {
100
+ const url = `https://api.telegram.org/bot${token}/getUpdates?offset=${currentOffset}&timeout=${pollSecs}&allowed_updates=%5B%22message%22%5D`;
101
+ const res = await fetch(url);
102
+ const data = (await res.json()) as { ok: boolean; result: TgUpdate[] };
103
+
104
+ if (data.ok && data.result.length > 0) {
105
+ for (const update of data.result) {
106
+ currentOffset = update.update_id + 1;
107
+ if (
108
+ update.message?.chat.type === 'private' &&
109
+ String(update.message.chat.id) === chatId &&
110
+ update.message.text?.trim().toLowerCase() === expectedCode
111
+ ) {
112
+ return true;
113
+ }
114
+ }
115
+ }
116
+ } catch {
117
+ await new Promise((r) => setTimeout(r, 2000));
118
+ }
119
+ }
120
+
121
+ return false;
122
+ }
123
+
124
+ /** Send a message to a Telegram chat. Errors are swallowed (non-fatal). */
125
+ export async function sendTelegramMessage(
126
+ token: string,
127
+ chatId: string,
128
+ text: string,
129
+ ): Promise<void> {
130
+ try {
131
+ await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
132
+ method: 'POST',
133
+ headers: { 'Content-Type': 'application/json' },
134
+ body: JSON.stringify({ chat_id: chatId, text }),
135
+ });
136
+ } catch {
137
+ // Not fatal
138
+ }
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Discord helpers
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /**
146
+ * Connect to the Discord gateway via WebSocket and wait for the first DM.
147
+ * Returns sender and channel IDs.
148
+ */
149
+ export async function pollForFirstDiscordDM(
150
+ token: string,
151
+ timeoutMs = 10 * 60 * 1000,
152
+ ): Promise<DiscordFirstDM> {
153
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
154
+ const WS = (globalThis as any).WebSocket as typeof WebSocket;
155
+ return new Promise((resolve, reject) => {
156
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
157
+ let resolved = false;
158
+
159
+ const ws = new WS('wss://gateway.discord.gg/?v=10&encoding=json');
160
+
161
+ const deadline = setTimeout(() => {
162
+ ws.close();
163
+ reject(new Error('Pairing timeout — no Discord DM received within the allowed time'));
164
+ }, timeoutMs);
165
+
166
+ ws.onmessage = (event: MessageEvent) => {
167
+ const payload = JSON.parse(event.data as string) as {
168
+ op: number;
169
+ d: Record<string, unknown>;
170
+ t?: string;
171
+ };
172
+ const { op, d, t } = payload;
173
+
174
+ if (op === 10) {
175
+ const interval = (d.heartbeat_interval as number) ?? 41250;
176
+ heartbeatTimer = setInterval(() => ws.send(JSON.stringify({ op: 1, d: null })), interval);
177
+ ws.send(
178
+ JSON.stringify({
179
+ op: 2,
180
+ d: {
181
+ token,
182
+ intents: 4096 + 32768, // DIRECT_MESSAGES + MESSAGE_CONTENT
183
+ properties: { os: 'linux', browser: 'claude-gateway', device: 'claude-gateway' },
184
+ },
185
+ }),
186
+ );
187
+ } else if (op === 0 && t === 'MESSAGE_CREATE') {
188
+ const msg = d as Record<string, unknown>;
189
+ const author = msg['author'] as Record<string, unknown> | undefined;
190
+ if (!msg['guild_id'] && !author?.['bot']) {
191
+ if (!resolved) {
192
+ resolved = true;
193
+ clearTimeout(deadline);
194
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
195
+ ws.close();
196
+ resolve({
197
+ senderId: String(author?.['id'] ?? ''),
198
+ channelId: String(msg['channel_id']),
199
+ });
200
+ }
201
+ }
202
+ }
203
+ };
204
+
205
+ ws.onerror = () => {
206
+ clearTimeout(deadline);
207
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
208
+ reject(new Error('Discord WebSocket error during pairing'));
209
+ };
210
+ });
211
+ }
212
+
213
+ /** Send a message to a Discord channel. Errors are swallowed (non-fatal). */
214
+ export async function sendDiscordMessage(
215
+ token: string,
216
+ channelId: string,
217
+ text: string,
218
+ ): Promise<void> {
219
+ try {
220
+ await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
221
+ method: 'POST',
222
+ headers: {
223
+ 'Content-Type': 'application/json',
224
+ Authorization: `Bot ${token}`,
225
+ },
226
+ body: JSON.stringify({ content: text }),
227
+ });
228
+ } catch {
229
+ // Not fatal
230
+ }
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Access.json writers
235
+ // ---------------------------------------------------------------------------
236
+
237
+ export function writeTelegramAccess(stateDir: string, senderId: string): void {
238
+ fs.writeFileSync(
239
+ path.join(stateDir, 'access.json'),
240
+ JSON.stringify(
241
+ { dmPolicy: 'allowlist', allowFrom: [senderId], groups: {}, pending: {} },
242
+ null,
243
+ 2,
244
+ ),
245
+ { mode: 0o600 },
246
+ );
247
+ }
248
+
249
+ export function writeDiscordAccess(stateDir: string, senderId: string): void {
250
+ fs.writeFileSync(
251
+ path.join(stateDir, 'access.json'),
252
+ JSON.stringify(
253
+ {
254
+ dmPolicy: 'allowlist',
255
+ allowFrom: [senderId],
256
+ guildAllowlist: [],
257
+ channelAllowlist: [],
258
+ roleAllowlist: [],
259
+ pending: {},
260
+ },
261
+ null,
262
+ 2,
263
+ ) + '\n',
264
+ { mode: 0o600 },
265
+ );
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // High-level non-interactive pairing (for MCP — no terminal readline)
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /**
273
+ * Full non-interactive Telegram pairing:
274
+ * 1. Wait for first DM → 2. Send code → 3. Wait for user to reply with code → 4. Write access.json
275
+ */
276
+ export async function pairTelegramUser(
277
+ token: string,
278
+ stateDir: string,
279
+ timeoutMs = 8 * 60 * 1000,
280
+ ): Promise<{ chatId: string }> {
281
+ const { senderId, chatId, nextOffset } = await pollForFirstTelegramDM(token, timeoutMs);
282
+
283
+ const code = randomBytes(3).toString('hex');
284
+ await sendTelegramMessage(
285
+ token,
286
+ chatId,
287
+ `Pairing code: ${code}\n\nReply with this code to complete pairing.`,
288
+ );
289
+
290
+ const confirmed = await pollForTelegramCode(token, chatId, code, nextOffset, 3 * 60 * 1000);
291
+ if (!confirmed) {
292
+ throw new Error('Pairing timed out — user did not reply with the pairing code within 3 minutes');
293
+ }
294
+
295
+ writeTelegramAccess(stateDir, senderId);
296
+ await sendTelegramMessage(token, chatId, "You're connected! Send me a message to get started.");
297
+
298
+ return { chatId };
299
+ }
300
+
301
+ /**
302
+ * Full non-interactive Discord pairing:
303
+ * 1. Wait for first DM → 2. Send code → 3. Auto-approve (no HTTP polling for Discord code reply) → 4. Write access.json
304
+ *
305
+ * Discord does not support HTTP-based message polling, so code confirmation is skipped;
306
+ * the first DM sender is trusted as the owner.
307
+ */
308
+ export async function pairDiscordUser(
309
+ token: string,
310
+ stateDir: string,
311
+ timeoutMs = 8 * 60 * 1000,
312
+ ): Promise<{ channelId: string }> {
313
+ const { senderId, channelId } = await pollForFirstDiscordDM(token, timeoutMs);
314
+
315
+ const code = randomBytes(3).toString('hex');
316
+ await sendDiscordMessage(
317
+ token,
318
+ channelId,
319
+ `Pairing code: ${code}\n\nYou have been automatically paired as the bot owner.`,
320
+ );
321
+
322
+ writeDiscordAccess(stateDir, senderId);
323
+ await sendDiscordMessage(token, channelId, "You're connected! Send me a message to get started.");
324
+
325
+ return { channelId };
326
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xmaxma/claude-gateway",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Multi-agent gateway for Claude",
5
5
  "repository": {
6
6
  "type": "git",
@@ -15,6 +15,7 @@
15
15
  },
16
16
  "files": [
17
17
  "dist/",
18
+ "lib/",
18
19
  "resource/",
19
20
  "mcp/",
20
21
  "README.md",