@0xmaxma/claude-gateway 1.3.4 → 1.3.6
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/README.md +1 -1
- package/config.template.json +9 -7
- package/dist/agent/runner.d.ts +20 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +94 -1
- package/dist/agent/runner.js.map +1 -1
- package/dist/api/gateway-router.d.ts +14 -0
- package/dist/api/gateway-router.d.ts.map +1 -1
- package/dist/api/gateway-router.js +279 -7
- package/dist/api/gateway-router.js.map +1 -1
- package/dist/discord/receiver.d.ts +1 -0
- package/dist/discord/receiver.d.ts.map +1 -1
- package/dist/discord/receiver.js +7 -1
- package/dist/discord/receiver.js.map +1 -1
- package/dist/session/process.d.ts +8 -0
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +34 -0
- package/dist/session/process.js.map +1 -1
- package/dist/shell/claude-pty-shell.js +211 -4
- package/dist/shell/claude-pty-shell.js.map +1 -1
- package/dist/shell/emitter.d.ts +13 -0
- package/dist/shell/emitter.d.ts.map +1 -1
- package/dist/shell/emitter.js +16 -0
- package/dist/shell/emitter.js.map +1 -1
- package/dist/shell/menu-cancel.d.ts +50 -0
- package/dist/shell/menu-cancel.d.ts.map +1 -0
- package/dist/shell/menu-cancel.js +62 -0
- package/dist/shell/menu-cancel.js.map +1 -0
- package/dist/shell/pty-serialize.d.ts +23 -0
- package/dist/shell/pty-serialize.d.ts.map +1 -0
- package/dist/shell/pty-serialize.js +131 -0
- package/dist/shell/pty-serialize.js.map +1 -0
- package/dist/shell/pty-stream-registry.d.ts +59 -0
- package/dist/shell/pty-stream-registry.d.ts.map +1 -0
- package/dist/shell/pty-stream-registry.js +279 -0
- package/dist/shell/pty-stream-registry.js.map +1 -0
- package/dist/shell/screen.d.ts +43 -0
- package/dist/shell/screen.d.ts.map +1 -1
- package/dist/shell/screen.js +91 -1
- package/dist/shell/screen.js.map +1 -1
- package/dist/telegram/receiver.d.ts +1 -0
- package/dist/telegram/receiver.d.ts.map +1 -1
- package/dist/telegram/receiver.js +7 -1
- package/dist/telegram/receiver.js.map +1 -1
- package/dist/ui/web-ui.d.ts +2 -2
- package/dist/ui/web-ui.d.ts.map +1 -1
- package/dist/ui/web-ui.js +682 -114
- package/dist/ui/web-ui.js.map +1 -1
- package/mcp/tools/discord/module.ts +88 -1
- package/mcp/tools/discord/outbound.ts +36 -0
- package/mcp/tools/discord/types.ts +2 -0
- package/mcp/tools/telegram/menu-parser.ts +37 -0
- package/mcp/tools/telegram/receiver-server.ts +83 -0
- package/mcp/tools/telegram/typing.ts +7 -1
- package/package.json +4 -2
package/dist/ui/web-ui.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web-ui.js","sourceRoot":"","sources":["../../src/ui/web-ui.ts"],"names":[],"mappings":";;AAIA,
|
|
1
|
+
{"version":3,"file":"web-ui.js","sourceRoot":"","sources":["../../src/ui/web-ui.ts"],"names":[],"mappings":";;AAIA,sDAkxBC;AAtxBD;;;GAGG;AACH,SAAgB,qBAAqB,CAAC,SAAS,GAAG,EAAE;IAClD,MAAM,SAAS,GAAG,SAAS,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;IAC3E,OAAO;;;;;qCAK4B,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QA0wBtC,CAAC;AACT,CAAC"}
|
|
@@ -16,9 +16,10 @@ import type {
|
|
|
16
16
|
McpToolResult,
|
|
17
17
|
ToolVisibility,
|
|
18
18
|
InboundMessageHandler,
|
|
19
|
+
InboundMessage,
|
|
19
20
|
ChannelId,
|
|
20
21
|
} from '../../types';
|
|
21
|
-
import { sendMessage } from './outbound';
|
|
22
|
+
import { sendMessage, buildChoiceComponents } from './outbound';
|
|
22
23
|
import { loadAccess, saveAccess, gate } from './access';
|
|
23
24
|
import { maybeCreateThread } from './threading';
|
|
24
25
|
import { createMessageHandler } from './inbound';
|
|
@@ -260,6 +261,74 @@ export class DiscordModule implements ChannelModule {
|
|
|
260
261
|
}
|
|
261
262
|
});
|
|
262
263
|
|
|
264
|
+
// Interactive-menu button clicks: custom_id `choice:N`. A click is routed
|
|
265
|
+
// exactly like the user typing "N" (same InboundMessage → handler), so the
|
|
266
|
+
// session's pending-menu handler injects the selection into the PTY.
|
|
267
|
+
// Security mirrors the message path: the access gate must return 'deliver'.
|
|
268
|
+
// discord.js client is typed as `any` (dynamic import) so we narrow locally.
|
|
269
|
+
interface ButtonInteraction {
|
|
270
|
+
isButton?: () => boolean;
|
|
271
|
+
customId?: string;
|
|
272
|
+
guildId?: string | null;
|
|
273
|
+
channelId: string;
|
|
274
|
+
channel?: { isThread?: () => boolean };
|
|
275
|
+
user: { id: string; username: string };
|
|
276
|
+
message?: { id: string };
|
|
277
|
+
client: { user?: { id: string } };
|
|
278
|
+
reply(opts: { content: string; ephemeral: boolean }): Promise<unknown>;
|
|
279
|
+
update(opts: { components: unknown[] }): Promise<unknown>;
|
|
280
|
+
}
|
|
281
|
+
this.client.on('interactionCreate', async (interaction: ButtonInteraction) => {
|
|
282
|
+
try {
|
|
283
|
+
if (!interaction.isButton?.()) return;
|
|
284
|
+
const m = /^choice:(\d+)$/.exec(interaction.customId ?? '');
|
|
285
|
+
if (!m) return;
|
|
286
|
+
|
|
287
|
+
const isDM = !interaction.guildId;
|
|
288
|
+
const isThread = interaction.channel?.isThread?.() ?? false;
|
|
289
|
+
const context: DiscordMessageContext = {
|
|
290
|
+
guildId: interaction.guildId ?? null,
|
|
291
|
+
channelId: interaction.channelId,
|
|
292
|
+
threadId: isThread ? interaction.channelId : null,
|
|
293
|
+
userId: interaction.user.id,
|
|
294
|
+
username: interaction.user.username,
|
|
295
|
+
messageId: interaction.message?.id ?? '',
|
|
296
|
+
isDM,
|
|
297
|
+
isThread,
|
|
298
|
+
};
|
|
299
|
+
const access = loadAccessFn();
|
|
300
|
+
const result = gate(access, context, saveAccessFn, () => randomBytes(3).toString('hex'));
|
|
301
|
+
if (result.action !== 'deliver') {
|
|
302
|
+
await interaction.reply({ content: 'Not authorized.', ephemeral: true }).catch(() => {});
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Disable the buttons (acknowledges the click + prevents double-selection).
|
|
307
|
+
await interaction.update({ components: [] }).catch(() => {});
|
|
308
|
+
|
|
309
|
+
const channelId = interaction.channelId;
|
|
310
|
+
const typingFileDir = path.join(this.stateDir, 'typing');
|
|
311
|
+
try {
|
|
312
|
+
fs.mkdirSync(typingFileDir, { recursive: true });
|
|
313
|
+
fs.writeFileSync(path.join(typingFileDir, channelId), '');
|
|
314
|
+
} catch {}
|
|
315
|
+
|
|
316
|
+
const inbound: InboundMessage = {
|
|
317
|
+
channel: 'discord',
|
|
318
|
+
accountId: interaction.client.user?.id ?? 'discord',
|
|
319
|
+
senderId: interaction.user.id,
|
|
320
|
+
chatId: channelId,
|
|
321
|
+
chatType: isDM ? 'direct' : 'group',
|
|
322
|
+
text: m[1],
|
|
323
|
+
messageId: interaction.message?.id ?? '',
|
|
324
|
+
ts: Date.now(),
|
|
325
|
+
};
|
|
326
|
+
await handler(inbound);
|
|
327
|
+
} catch (err) {
|
|
328
|
+
process.stderr.write(`discord: interaction handler error: ${err}\n`);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
263
332
|
const approvedDir = path.join(this.stateDir, 'approved');
|
|
264
333
|
const approvedInterval = setInterval(() => { void this.checkApprovals(approvedDir); }, 5000);
|
|
265
334
|
approvedInterval.unref();
|
|
@@ -288,6 +357,24 @@ export class DiscordModule implements ChannelModule {
|
|
|
288
357
|
try { files = fs.readdirSync(typingDir); } catch { return; }
|
|
289
358
|
|
|
290
359
|
for (const file of files) {
|
|
360
|
+
// Interactive menu: render the question with Secondary buttons. Each click
|
|
361
|
+
// routes back as a normal "N" message (see the interactionCreate handler).
|
|
362
|
+
if (file.endsWith('.menu')) {
|
|
363
|
+
const filePath = path.join(typingDir, file);
|
|
364
|
+
const channelId = file.slice(0, -'.menu'.length);
|
|
365
|
+
try {
|
|
366
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8').trim()) as {
|
|
367
|
+
text: string;
|
|
368
|
+
options: Array<{ label: string }>;
|
|
369
|
+
};
|
|
370
|
+
fs.rmSync(filePath, { force: true });
|
|
371
|
+
if (parsed.text && Array.isArray(parsed.options) && parsed.options.length) {
|
|
372
|
+
const channel = await this.client.channels.fetch(channelId);
|
|
373
|
+
await sendMessage(channel, parsed.text, { components: buildChoiceComponents(parsed.options) });
|
|
374
|
+
}
|
|
375
|
+
} catch { /* non-fatal — result text carries the numbered list as fallback */ }
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
291
378
|
if (!file.endsWith('.forward')) continue;
|
|
292
379
|
const filePath = path.join(typingDir, file);
|
|
293
380
|
const channelId = file.slice(0, -'.forward'.length);
|
|
@@ -17,10 +17,23 @@ export async function sendMessage(
|
|
|
17
17
|
replyTo?: string;
|
|
18
18
|
files?: string[];
|
|
19
19
|
useEmbed?: boolean;
|
|
20
|
+
components?: unknown[];
|
|
20
21
|
} = {},
|
|
21
22
|
): Promise<SentMessage[]> {
|
|
22
23
|
const sent: SentMessage[] = [];
|
|
23
24
|
|
|
25
|
+
// Interactive menu: send the text with button rows attached to the first message.
|
|
26
|
+
if (options.components && options.components.length) {
|
|
27
|
+
const chunks = chunkText(text, MAX_MESSAGE_LENGTH);
|
|
28
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
29
|
+
const last = i === chunks.length - 1;
|
|
30
|
+
const sendOpts: Parameters<SendableChannel['send']>[0] = { content: chunks[i] };
|
|
31
|
+
if (last) sendOpts.components = options.components;
|
|
32
|
+
sent.push(await channel.send(sendOpts));
|
|
33
|
+
}
|
|
34
|
+
return sent;
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
if (text.length > MAX_MESSAGE_LENGTH && options.useEmbed) {
|
|
25
38
|
const embedText = text.slice(0, MAX_EMBED_DESCRIPTION);
|
|
26
39
|
const embed: EmbedData = { description: embedText };
|
|
@@ -55,6 +68,29 @@ export async function sendMessage(
|
|
|
55
68
|
return sent;
|
|
56
69
|
}
|
|
57
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Build Discord action-row components for an interactive menu. Each option
|
|
73
|
+
* becomes a Secondary button labelled "N. label" whose custom_id is `choice:N`
|
|
74
|
+
* (≤5 buttons per row, ≤5 rows). Uses raw component JSON to keep this module
|
|
75
|
+
* free of a discord.js import (matching the rest of the file).
|
|
76
|
+
*/
|
|
77
|
+
export function buildChoiceComponents(options: Array<{ label: string }>): unknown[] {
|
|
78
|
+
const rows: unknown[] = [];
|
|
79
|
+
for (let i = 0; i < options.length && rows.length < 5; i += 5) {
|
|
80
|
+
const buttons = options.slice(i, i + 5).map((o, j) => {
|
|
81
|
+
const n = i + j + 1;
|
|
82
|
+
return {
|
|
83
|
+
type: 2, // Button
|
|
84
|
+
style: 2, // Secondary
|
|
85
|
+
label: `${n}. ${o.label}`.slice(0, 80),
|
|
86
|
+
custom_id: `choice:${n}`,
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
rows.push({ type: 1, components: buttons }); // ActionRow
|
|
90
|
+
}
|
|
91
|
+
return rows;
|
|
92
|
+
}
|
|
93
|
+
|
|
58
94
|
export function chunkText(text: string, limit: number): string[] {
|
|
59
95
|
if (text.length <= limit) return [text];
|
|
60
96
|
const chunks: string[] = [];
|
|
@@ -65,6 +65,8 @@ export type SendOptions = {
|
|
|
65
65
|
embeds?: EmbedData[];
|
|
66
66
|
files?: FileAttachment[];
|
|
67
67
|
reply?: { messageReference: string };
|
|
68
|
+
/** Raw Discord message components (action rows of buttons), passed through to channel.send. */
|
|
69
|
+
components?: unknown[];
|
|
68
70
|
};
|
|
69
71
|
|
|
70
72
|
export type SentMessage = { id: string };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure parsing logic for .menu files written by AgentRunner.writeMenuForward.
|
|
3
|
+
* Kept in a separate module so tests can import it without pulling in the full
|
|
4
|
+
* bot runtime (receiver-server.ts imports grammy + all its dependencies).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type InlineKeyboardButton = { text: string; callback_data: string };
|
|
8
|
+
|
|
9
|
+
export type MenuMessage = {
|
|
10
|
+
text: string;
|
|
11
|
+
inline_keyboard: Array<Array<InlineKeyboardButton>>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse raw .menu file content into a Telegram sendMessage payload.
|
|
16
|
+
* Returns null if the content is malformed or missing required fields.
|
|
17
|
+
*/
|
|
18
|
+
export function parseMenuFileContent(raw: string): MenuMessage | null {
|
|
19
|
+
let parsed: { text?: unknown; options?: unknown };
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(raw) as { text?: unknown; options?: unknown };
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const text = typeof parsed.text === 'string' ? parsed.text : '';
|
|
26
|
+
const options = Array.isArray(parsed.options)
|
|
27
|
+
? (parsed.options as Array<{ label?: unknown }>)
|
|
28
|
+
.map(o => (typeof o?.label === 'string' ? o.label : ''))
|
|
29
|
+
.filter((l): l is string => l.length > 0)
|
|
30
|
+
: [];
|
|
31
|
+
if (!text || options.length === 0) return null;
|
|
32
|
+
const inline_keyboard = options.map((label, i) => [{
|
|
33
|
+
text: `${i + 1}. ${label}`.slice(0, 60),
|
|
34
|
+
callback_data: `choice:${i + 1}`,
|
|
35
|
+
}]);
|
|
36
|
+
return { text, inline_keyboard };
|
|
37
|
+
}
|
|
@@ -690,6 +690,8 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
|
|
|
690
690
|
const SEND_ONLY = process.env.TELEGRAM_SEND_ONLY === 'true'
|
|
691
691
|
const RECEIVER_MODE = process.env.TELEGRAM_RECEIVER_MODE === 'true'
|
|
692
692
|
|
|
693
|
+
import { parseMenuFileContent } from './menu-parser'
|
|
694
|
+
|
|
693
695
|
const BOT_COMMANDS = [
|
|
694
696
|
{ command: 'session', description: 'Show current session info' },
|
|
695
697
|
{ command: 'sessions', description: 'Manage conversation sessions' },
|
|
@@ -1157,6 +1159,45 @@ bot.command('clear', async ctx => {
|
|
|
1157
1159
|
bot.on('callback_query:data', async ctx => {
|
|
1158
1160
|
const data = ctx.callbackQuery.data
|
|
1159
1161
|
|
|
1162
|
+
// Handle interactive-menu choice: choice:<N>. A tap is routed back exactly like
|
|
1163
|
+
// the user typing "N" — same content + meta as the text path — so the session's
|
|
1164
|
+
// pending-menu handler injects the selection into the PTY. Security mirrors the
|
|
1165
|
+
// text path: the tapper must be in allowFrom.
|
|
1166
|
+
const choiceMatch = /^choice:(\d+)$/.exec(data)
|
|
1167
|
+
if (choiceMatch) {
|
|
1168
|
+
const access = loadAccess()
|
|
1169
|
+
if (!access.allowFrom.includes(String(ctx.from.id))) {
|
|
1170
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
1171
|
+
return
|
|
1172
|
+
}
|
|
1173
|
+
const chat_id = String(ctx.callbackQuery.message?.chat.id ?? ctx.from.id)
|
|
1174
|
+
const choice = choiceMatch[1]
|
|
1175
|
+
// Remove the keyboard immediately to prevent double-selection.
|
|
1176
|
+
await ctx.editMessageReplyMarkup({ reply_markup: undefined }).catch(() => {})
|
|
1177
|
+
await ctx.answerCallbackQuery({ text: `✓ ${choice}` }).catch(() => {})
|
|
1178
|
+
const callbackUrl = process.env.CLAUDE_CHANNEL_CALLBACK
|
|
1179
|
+
if (callbackUrl) {
|
|
1180
|
+
const channelParams = {
|
|
1181
|
+
content: choice,
|
|
1182
|
+
meta: {
|
|
1183
|
+
chat_id,
|
|
1184
|
+
user: ctx.from.username ?? String(ctx.from.id),
|
|
1185
|
+
user_id: String(ctx.from.id),
|
|
1186
|
+
ts: new Date().toISOString(),
|
|
1187
|
+
},
|
|
1188
|
+
}
|
|
1189
|
+
fetch(callbackUrl, {
|
|
1190
|
+
method: 'POST',
|
|
1191
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1192
|
+
body: JSON.stringify(channelParams),
|
|
1193
|
+
}).catch(err => {
|
|
1194
|
+
process.stderr.write(`telegram channel: choice callback POST failed: ${err}\n`)
|
|
1195
|
+
})
|
|
1196
|
+
if (RECEIVER_MODE) typingManager.start(chat_id)
|
|
1197
|
+
}
|
|
1198
|
+
return
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1160
1201
|
// Handle model selection callback: model:<model_id>
|
|
1161
1202
|
const modelMatch = /^model:(.+)$/.exec(data)
|
|
1162
1203
|
if (modelMatch) {
|
|
@@ -1633,6 +1674,48 @@ if (RECEIVER_MODE) {
|
|
|
1633
1674
|
process.on('SIGTERM', shutdown)
|
|
1634
1675
|
process.on('SIGINT', shutdown)
|
|
1635
1676
|
|
|
1677
|
+
// Interactive-menu delivery. The AgentRunner drops a `<chatId>.menu` file in
|
|
1678
|
+
// TYPING_DIR whenever the PTY session blocks on an interactive select menu
|
|
1679
|
+
// (AskUserQuestion / plan approval). We render it as inline buttons here.
|
|
1680
|
+
//
|
|
1681
|
+
// This MUST be independent of the typing-indicator lifecycle: a menu can be
|
|
1682
|
+
// emitted after typing has already been torn down (e.g. the agent called the
|
|
1683
|
+
// reply tool earlier in the same turn, or the turn ended), and the old
|
|
1684
|
+
// stop()-coupled drain would early-return on a missing typing state, leaving
|
|
1685
|
+
// the .menu file orphaned and the user staring at a silent chat. A standalone
|
|
1686
|
+
// poller — mirroring the Discord side — guarantees every menu reaches the chat.
|
|
1687
|
+
function drainMenuFiles(): void {
|
|
1688
|
+
let files: string[]
|
|
1689
|
+
try {
|
|
1690
|
+
files = readdirSync(TYPING_DIR)
|
|
1691
|
+
} catch {
|
|
1692
|
+
return
|
|
1693
|
+
}
|
|
1694
|
+
for (const name of files) {
|
|
1695
|
+
if (!name.endsWith('.menu')) continue
|
|
1696
|
+
const chatId = name.slice(0, -'.menu'.length)
|
|
1697
|
+
const menuPath = join(TYPING_DIR, name)
|
|
1698
|
+
let raw: string
|
|
1699
|
+
try {
|
|
1700
|
+
raw = readFileSync(menuPath, 'utf8').trim()
|
|
1701
|
+
} catch {
|
|
1702
|
+
// Malformed or mid-write (the runner writes atomically, so this is rare) —
|
|
1703
|
+
// drop it so the poller doesn't spin on the same bad file forever.
|
|
1704
|
+
rmSync(menuPath, { force: true })
|
|
1705
|
+
continue
|
|
1706
|
+
}
|
|
1707
|
+
// Remove BEFORE sending: a slow or failing send must not let the next tick
|
|
1708
|
+
// re-deliver the same menu (which would stack duplicate button messages).
|
|
1709
|
+
rmSync(menuPath, { force: true })
|
|
1710
|
+
const msg = parseMenuFileContent(raw)
|
|
1711
|
+
if (!msg) continue
|
|
1712
|
+
void bot.api.sendMessage(chatId, msg.text, { reply_markup: { inline_keyboard: msg.inline_keyboard } }).catch(err => {
|
|
1713
|
+
process.stderr.write(`telegram channel (receiver): failed to send menu to ${chatId}: ${err}\n`)
|
|
1714
|
+
})
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
setInterval(drainMenuFiles, 1000).unref()
|
|
1718
|
+
|
|
1636
1719
|
void (async () => {
|
|
1637
1720
|
for (let attempt = 1; ; attempt++) {
|
|
1638
1721
|
try {
|
|
@@ -96,7 +96,7 @@ export interface WorkingState {
|
|
|
96
96
|
|
|
97
97
|
export interface BotApi {
|
|
98
98
|
sendChatAction(chatId: string, action: 'typing'): Promise<unknown>
|
|
99
|
-
sendMessage(chatId: string, text: string, opts?: { parse_mode?: 'MarkdownV2' | 'HTML' | 'Markdown' }): Promise<{ message_id: number }>
|
|
99
|
+
sendMessage(chatId: string, text: string, opts?: { parse_mode?: 'MarkdownV2' | 'HTML' | 'Markdown'; reply_markup?: unknown }): Promise<{ message_id: number }>
|
|
100
100
|
editMessageText(chatId: string, msgId: number, text: string): Promise<unknown>
|
|
101
101
|
deleteMessage(chatId: string, msgId: number): Promise<unknown>
|
|
102
102
|
setMessageReaction(chatId: string, msgId: number, emoji: string): Promise<unknown>
|
|
@@ -212,6 +212,12 @@ export function createWorkingStateManager(
|
|
|
212
212
|
} catch {}
|
|
213
213
|
fsApi.rmSync(forwardPath, { force: true })
|
|
214
214
|
}
|
|
215
|
+
// NOTE: interactive-menu (.menu) delivery is handled by an independent poller
|
|
216
|
+
// in receiver-server.ts (drainMenuFiles), NOT here. A menu can be emitted
|
|
217
|
+
// after the typing state has already been torn down (reply tool called earlier
|
|
218
|
+
// in the turn, or the turn ended), in which case this stop() never runs for it
|
|
219
|
+
// and the .menu file would be orphaned. The standalone poller has no such
|
|
220
|
+
// coupling, so it reliably delivers every menu. Do not re-add a drain here.
|
|
215
221
|
fsApi.rmSync(repliedPath, { force: true })
|
|
216
222
|
// Read signal file timestamp before deleting — process.ts overwrites it with a newer
|
|
217
223
|
// timestamp when a queued turn is injected; if newer than this turn's startedAt it means
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@0xmaxma/claude-gateway",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.6",
|
|
4
4
|
"description": "Multi-agent gateway for Claude",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -47,10 +47,12 @@
|
|
|
47
47
|
"lru-cache": "^10.0.0",
|
|
48
48
|
"node-cron": "^3.0.0",
|
|
49
49
|
"node-pty": "^1.1.0",
|
|
50
|
-
"p-queue": "^6.6.2"
|
|
50
|
+
"p-queue": "^6.6.2",
|
|
51
|
+
"ws": "^8.21.0"
|
|
51
52
|
},
|
|
52
53
|
"devDependencies": {
|
|
53
54
|
"@types/express": "^4.17.0",
|
|
55
|
+
"@types/ws": "^8.18.1",
|
|
54
56
|
"@types/jest": "^29.0.0",
|
|
55
57
|
"@types/js-yaml": "^4.0.0",
|
|
56
58
|
"@types/node": "^22.19.18",
|