opencode-telegram-bot 1.0.10 → 1.1.0
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 +29 -0
- package/dist/app.d.ts +7 -1
- package/dist/index.js +198 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -112,6 +112,8 @@ These are handled directly by the Telegram bot:
|
|
|
112
112
|
| `/model` | Show current model and usage hints |
|
|
113
113
|
| `/model <keyword>` | Search models by keyword |
|
|
114
114
|
| `/model default` | Reset to the default model |
|
|
115
|
+
| `/agent` | Show current agent and switch (plan, build, ...) |
|
|
116
|
+
| `/agent <name>` | Switch directly to a named agent |
|
|
115
117
|
| `/usage` | Show token and cost usage for this session |
|
|
116
118
|
| `/help` | Show available commands |
|
|
117
119
|
|
|
@@ -177,6 +179,33 @@ Other commands:
|
|
|
177
179
|
- `/model` shows the current model and usage hints
|
|
178
180
|
- `/model default` resets to the server default
|
|
179
181
|
|
|
182
|
+
### Agent Switching
|
|
183
|
+
|
|
184
|
+
OpenCode supports multiple agents: **build** (default, full edit access), **plan** (read-only analysis), and others. Use `/agent` to see and switch between them:
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
You: /agent
|
|
188
|
+
Bot: Current agent: build
|
|
189
|
+
|
|
190
|
+
Available agents:
|
|
191
|
+
- build (active) -- The default agent
|
|
192
|
+
- plan -- Plan mode. Disallows all edit tools.
|
|
193
|
+
- general -- General-purpose agent
|
|
194
|
+
- explore -- Codebase explorer
|
|
195
|
+
|
|
196
|
+
[build] [plan] [general] [explore]
|
|
197
|
+
|
|
198
|
+
You: [tap "plan"]
|
|
199
|
+
(button message is deleted)
|
|
200
|
+
Bot: Agent: plan | Model: claude-opus-4 | Verbose: off [pinned]
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Pinned Status Message
|
|
204
|
+
|
|
205
|
+
A pinned message at the top of the chat shows the current agent, model, and verbose mode. It updates automatically when any of these change — via `/agent`, `/model`, or `/verbose`. The pinned message is the only confirmation; no separate reply is sent.
|
|
206
|
+
|
|
207
|
+
Agent, model, and verbose selections are per-chat and persist across bot restarts.
|
|
208
|
+
|
|
180
209
|
## Usage
|
|
181
210
|
|
|
182
211
|
Use `/usage` to see the current session's token counts and estimated cost:
|
package/dist/app.d.ts
CHANGED
|
@@ -26,6 +26,9 @@ interface OpencodeClientLike {
|
|
|
26
26
|
reply: (params: any, options?: any) => Promise<any>;
|
|
27
27
|
reject: (params: any, options?: any) => Promise<any>;
|
|
28
28
|
};
|
|
29
|
+
app: {
|
|
30
|
+
agents: (params?: any, options?: any) => Promise<any>;
|
|
31
|
+
};
|
|
29
32
|
}
|
|
30
33
|
interface StartOptions {
|
|
31
34
|
url: string;
|
|
@@ -48,7 +51,7 @@ interface TelegramBot {
|
|
|
48
51
|
}) => Promise<void>;
|
|
49
52
|
stop: (reason?: string) => void;
|
|
50
53
|
telegram: {
|
|
51
|
-
sendMessage: (chatId: number, text: string, options?: Record<string, unknown>) => Promise<
|
|
54
|
+
sendMessage: (chatId: number, text: string, options?: Record<string, unknown>) => Promise<any>;
|
|
52
55
|
deleteMessage: (chatId: number, messageId: number) => Promise<void>;
|
|
53
56
|
sendDocument: (chatId: number, file: {
|
|
54
57
|
source: Buffer;
|
|
@@ -61,6 +64,9 @@ interface TelegramBot {
|
|
|
61
64
|
command: string;
|
|
62
65
|
description: string;
|
|
63
66
|
}>) => Promise<unknown>;
|
|
67
|
+
pinChatMessage: (chatId: number, messageId: number, extra?: Record<string, unknown>) => Promise<void>;
|
|
68
|
+
unpinChatMessage: (chatId: number, messageId?: number) => Promise<void>;
|
|
69
|
+
editMessageText: (chatId: number | undefined, messageId: number | undefined, inlineMessageId: string | undefined, text: string, extra?: Record<string, unknown>) => Promise<void>;
|
|
64
70
|
};
|
|
65
71
|
}
|
|
66
72
|
export declare function startTelegram(options: StartOptions): Promise<TelegramBot>;
|
package/dist/index.js
CHANGED
|
@@ -19967,6 +19967,8 @@ async function startTelegram(options) {
|
|
|
19967
19967
|
const sessionsFile = options.sessionsFilePath || SESSIONS_FILE;
|
|
19968
19968
|
// Initialize OpenCode client
|
|
19969
19969
|
const client = options.client || client_createOpencodeClient({ baseUrl: url });
|
|
19970
|
+
// Available agents fetched from OpenCode on startup
|
|
19971
|
+
let availableAgents = [];
|
|
19970
19972
|
// Verify connection to the OpenCode server and fetch available commands
|
|
19971
19973
|
const opencodeCommands = new Set();
|
|
19972
19974
|
const opencodeCommandMenu = [];
|
|
@@ -20004,13 +20006,25 @@ async function startTelegram(options) {
|
|
|
20004
20006
|
}
|
|
20005
20007
|
console.log(`[Telegram] Available OpenCode commands: ${[...opencodeCommands].join(", ")}`);
|
|
20006
20008
|
}
|
|
20009
|
+
// Fetch available agents
|
|
20010
|
+
const agentsResult = await client.app.agents({});
|
|
20011
|
+
if (agentsResult.data) {
|
|
20012
|
+
availableAgents = agentsResult.data
|
|
20013
|
+
.filter((a) => !a.hidden)
|
|
20014
|
+
.map((a) => ({
|
|
20015
|
+
name: a.name,
|
|
20016
|
+
description: a.description || "",
|
|
20017
|
+
mode: a.mode || "primary",
|
|
20018
|
+
}));
|
|
20019
|
+
console.log(`[Telegram] Available agents: ${availableAgents.map((a) => `${a.name} (${a.mode})`).join(", ")}`);
|
|
20020
|
+
}
|
|
20007
20021
|
}
|
|
20008
20022
|
catch (err) {
|
|
20009
20023
|
throw new Error(`[Telegram] Failed to connect to OpenCode server at ${url}. Make sure it's running (npm run serve). Error: ${err}`);
|
|
20010
20024
|
}
|
|
20011
20025
|
// Telegram-only commands that should not be forwarded to OpenCode
|
|
20012
20026
|
const telegramCommands = new Set([
|
|
20013
|
-
"start", "help", "new", "sessions", "switch", "title", "delete", "export", "verbose", "model", "usage",
|
|
20027
|
+
"start", "help", "new", "sessions", "switch", "title", "delete", "export", "verbose", "model", "usage", "agent",
|
|
20014
20028
|
]);
|
|
20015
20029
|
const telegramCommandMenu = [
|
|
20016
20030
|
{ command: "new", description: "Start a new conversation" },
|
|
@@ -20019,6 +20033,7 @@ async function startTelegram(options) {
|
|
|
20019
20033
|
{ command: "export", description: "Export session (/export full for details)" },
|
|
20020
20034
|
{ command: "verbose", description: "Toggle verbose mode" },
|
|
20021
20035
|
{ command: "model", description: "Search models (/model <keyword>)" },
|
|
20036
|
+
{ command: "agent", description: "Switch agent (plan, build, ...)" },
|
|
20022
20037
|
{ command: "usage", description: "Show token and cost usage" },
|
|
20023
20038
|
{ command: "help", description: "Show available commands" },
|
|
20024
20039
|
];
|
|
@@ -20031,6 +20046,10 @@ async function startTelegram(options) {
|
|
|
20031
20046
|
const chatVerboseMode = new Set();
|
|
20032
20047
|
// Map of chatId -> model override (provider/model)
|
|
20033
20048
|
const chatModelOverride = new Map();
|
|
20049
|
+
// Map of chatId -> agent override (e.g. "plan", "build")
|
|
20050
|
+
const chatAgentOverride = new Map();
|
|
20051
|
+
// Map of chatId -> pinned status message ID
|
|
20052
|
+
const chatPinnedStatusMsg = new Map();
|
|
20034
20053
|
// Map of chatId -> last search results (in-memory only)
|
|
20035
20054
|
const chatModelSearchResults = new Map();
|
|
20036
20055
|
// Map of questionId -> pending question context (for forwarding OpenCode questions to Telegram)
|
|
@@ -20045,6 +20064,8 @@ async function startTelegram(options) {
|
|
|
20045
20064
|
known: [...knownSessionIds],
|
|
20046
20065
|
verbose: [...chatVerboseMode],
|
|
20047
20066
|
models: Object.fromEntries(chatModelOverride),
|
|
20067
|
+
agents: Object.fromEntries(chatAgentOverride),
|
|
20068
|
+
pinnedStatus: Object.fromEntries(chatPinnedStatusMsg),
|
|
20048
20069
|
};
|
|
20049
20070
|
(0,external_node_fs_.writeFileSync)(sessionsFile, JSON.stringify(data, null, 2));
|
|
20050
20071
|
}
|
|
@@ -20062,6 +20083,8 @@ async function startTelegram(options) {
|
|
|
20062
20083
|
let storedKnown = [];
|
|
20063
20084
|
let storedVerbose = [];
|
|
20064
20085
|
let storedModels = {};
|
|
20086
|
+
let storedAgents = {};
|
|
20087
|
+
let storedPinnedStatus = {};
|
|
20065
20088
|
if ((0,external_node_fs_.existsSync)(sessionsFile)) {
|
|
20066
20089
|
try {
|
|
20067
20090
|
const raw = (0,external_node_fs_.readFileSync)(sessionsFile, "utf-8");
|
|
@@ -20072,6 +20095,8 @@ async function startTelegram(options) {
|
|
|
20072
20095
|
storedKnown = parsed.known || [];
|
|
20073
20096
|
storedVerbose = parsed.verbose || [];
|
|
20074
20097
|
storedModels = parsed.models || {};
|
|
20098
|
+
storedAgents = parsed.agents || {};
|
|
20099
|
+
storedPinnedStatus = parsed.pinnedStatus || {};
|
|
20075
20100
|
}
|
|
20076
20101
|
else {
|
|
20077
20102
|
// Old format: flat { chatId: sessionId }
|
|
@@ -20096,6 +20121,18 @@ async function startTelegram(options) {
|
|
|
20096
20121
|
chatModelOverride.set(chatId, modelId);
|
|
20097
20122
|
}
|
|
20098
20123
|
}
|
|
20124
|
+
// Restore agent overrides
|
|
20125
|
+
for (const [chatId, agentName] of Object.entries(storedAgents)) {
|
|
20126
|
+
if (agentName) {
|
|
20127
|
+
chatAgentOverride.set(chatId, agentName);
|
|
20128
|
+
}
|
|
20129
|
+
}
|
|
20130
|
+
// Restore pinned status message IDs
|
|
20131
|
+
for (const [chatId, msgId] of Object.entries(storedPinnedStatus)) {
|
|
20132
|
+
if (msgId) {
|
|
20133
|
+
chatPinnedStatusMsg.set(chatId, msgId);
|
|
20134
|
+
}
|
|
20135
|
+
}
|
|
20099
20136
|
// Fetch all server sessions once for validation and fallback matching
|
|
20100
20137
|
let serverSessions = [];
|
|
20101
20138
|
try {
|
|
@@ -20214,6 +20251,10 @@ async function startTelegram(options) {
|
|
|
20214
20251
|
const modelID = modelParts.join("/");
|
|
20215
20252
|
promptBody.model = { providerID, modelID };
|
|
20216
20253
|
}
|
|
20254
|
+
const agentOverride = chatAgentOverride.get(chatId);
|
|
20255
|
+
if (agentOverride) {
|
|
20256
|
+
promptBody.agent = agentOverride;
|
|
20257
|
+
}
|
|
20217
20258
|
return promptBody;
|
|
20218
20259
|
}
|
|
20219
20260
|
function getChatIdFromContext(ctx) {
|
|
@@ -20787,7 +20828,12 @@ async function startTelegram(options) {
|
|
|
20787
20828
|
if (!authorizedUserId) {
|
|
20788
20829
|
return next();
|
|
20789
20830
|
}
|
|
20790
|
-
|
|
20831
|
+
// Skip auth for service messages without a sender or from the bot itself
|
|
20832
|
+
// (e.g. pin notifications where ctx.from is the bot)
|
|
20833
|
+
if (!ctx.from || ctx.from.is_bot) {
|
|
20834
|
+
return next();
|
|
20835
|
+
}
|
|
20836
|
+
const userId = ctx.from.id.toString();
|
|
20791
20837
|
if (userId === authorizedUserId) {
|
|
20792
20838
|
return next();
|
|
20793
20839
|
}
|
|
@@ -20810,6 +20856,7 @@ async function startTelegram(options) {
|
|
|
20810
20856
|
"/verbose - Toggle verbose mode (show thinking and tool calls)\n" +
|
|
20811
20857
|
"/verbose on|off - Set verbose mode explicitly\n" +
|
|
20812
20858
|
"/model <keyword> - Search available models\n" +
|
|
20859
|
+
"/agent - Switch agent (plan, build, ...)\n" +
|
|
20813
20860
|
"/usage - Show token and cost usage for this session\n" +
|
|
20814
20861
|
"/help - Show this help message\n";
|
|
20815
20862
|
const visibleOpenCodeCommands = getVisibleOpenCodeCommands();
|
|
@@ -20832,6 +20879,7 @@ async function startTelegram(options) {
|
|
|
20832
20879
|
"/verbose - Toggle verbose mode (show thinking and tool calls)\n" +
|
|
20833
20880
|
"/verbose on|off - Set verbose mode explicitly\n" +
|
|
20834
20881
|
"/model <keyword> - Search available models\n" +
|
|
20882
|
+
"/agent - Switch agent (plan, build, ...)\n" +
|
|
20835
20883
|
"/usage - Show token and cost usage for this session\n" +
|
|
20836
20884
|
"/help - Show this help message\n";
|
|
20837
20885
|
const visibleOpenCodeCommands = getVisibleOpenCodeCommands();
|
|
@@ -21084,9 +21132,7 @@ async function startTelegram(options) {
|
|
|
21084
21132
|
saveSessions();
|
|
21085
21133
|
const enabled = chatVerboseMode.has(chatId);
|
|
21086
21134
|
console.log(`[Telegram] Verbose mode ${enabled ? "enabled" : "disabled"} for chat ${chatId}`);
|
|
21087
|
-
ctx.
|
|
21088
|
-
? "Verbose mode enabled. Responses will include thinking and tool calls."
|
|
21089
|
-
: "Verbose mode disabled. Responses will only show the assistant's text.");
|
|
21135
|
+
updatePinnedStatus(chatId, ctx.chat.id);
|
|
21090
21136
|
});
|
|
21091
21137
|
/**
|
|
21092
21138
|
* Fetch and search available models from connected providers.
|
|
@@ -21186,7 +21232,15 @@ async function startTelegram(options) {
|
|
|
21186
21232
|
const value = `${selection.providerID}/${selection.modelID}`;
|
|
21187
21233
|
chatModelOverride.set(chatId, value);
|
|
21188
21234
|
saveSessions();
|
|
21189
|
-
|
|
21235
|
+
try {
|
|
21236
|
+
await ctx.answerCbQuery();
|
|
21237
|
+
}
|
|
21238
|
+
catch { /* ignore */ }
|
|
21239
|
+
try {
|
|
21240
|
+
await ctx.deleteMessage();
|
|
21241
|
+
}
|
|
21242
|
+
catch { /* ignore */ }
|
|
21243
|
+
updatePinnedStatus(chatId, Number(chatId));
|
|
21190
21244
|
});
|
|
21191
21245
|
bot.action("model_default", async (ctx) => {
|
|
21192
21246
|
const chatId = getChatIdFromContext(ctx);
|
|
@@ -21194,7 +21248,144 @@ async function startTelegram(options) {
|
|
|
21194
21248
|
return;
|
|
21195
21249
|
chatModelOverride.delete(chatId);
|
|
21196
21250
|
saveSessions();
|
|
21197
|
-
|
|
21251
|
+
try {
|
|
21252
|
+
await ctx.answerCbQuery();
|
|
21253
|
+
}
|
|
21254
|
+
catch { /* ignore */ }
|
|
21255
|
+
try {
|
|
21256
|
+
await ctx.deleteMessage();
|
|
21257
|
+
}
|
|
21258
|
+
catch { /* ignore */ }
|
|
21259
|
+
updatePinnedStatus(chatId, Number(chatId));
|
|
21260
|
+
});
|
|
21261
|
+
// --- Agent switching and pinned status message ---
|
|
21262
|
+
function getActiveAgent(chatId) {
|
|
21263
|
+
return chatAgentOverride.get(chatId) || "build";
|
|
21264
|
+
}
|
|
21265
|
+
function getActiveModelDisplay(chatId) {
|
|
21266
|
+
const m = chatModelOverride.get(chatId) || model;
|
|
21267
|
+
if (!m)
|
|
21268
|
+
return "default";
|
|
21269
|
+
const parts = m.split("/");
|
|
21270
|
+
return parts.length > 1 ? parts.slice(1).join("/") : m;
|
|
21271
|
+
}
|
|
21272
|
+
function buildStatusText(chatId) {
|
|
21273
|
+
const agent = getActiveAgent(chatId);
|
|
21274
|
+
const modelDisplay = getActiveModelDisplay(chatId);
|
|
21275
|
+
const verbose = chatVerboseMode.has(chatId) ? "on" : "off";
|
|
21276
|
+
return `Agent: *${agent}* | Model: ${modelDisplay} | Verbose: ${verbose}`;
|
|
21277
|
+
}
|
|
21278
|
+
function buildStatusKeyboard(chatId) {
|
|
21279
|
+
const active = getActiveAgent(chatId);
|
|
21280
|
+
// Show non-hidden agents as buttons, highlight the active one
|
|
21281
|
+
const buttons = availableAgents
|
|
21282
|
+
.filter((a) => a.mode === "primary" || a.mode === "subagent")
|
|
21283
|
+
.map((a) => ({
|
|
21284
|
+
text: a.name === active ? `[${a.name}]` : a.name,
|
|
21285
|
+
callback_data: `agent:${a.name}`,
|
|
21286
|
+
}));
|
|
21287
|
+
// Arrange in rows of 3
|
|
21288
|
+
const keyboard = [];
|
|
21289
|
+
for (let i = 0; i < buttons.length; i += 3) {
|
|
21290
|
+
keyboard.push(buttons.slice(i, i + 3));
|
|
21291
|
+
}
|
|
21292
|
+
return keyboard;
|
|
21293
|
+
}
|
|
21294
|
+
async function updatePinnedStatus(chatId, numericChatId) {
|
|
21295
|
+
const text = buildStatusText(chatId);
|
|
21296
|
+
// Delete the old status message to keep the chat clean
|
|
21297
|
+
const existingMsgId = chatPinnedStatusMsg.get(chatId);
|
|
21298
|
+
if (existingMsgId) {
|
|
21299
|
+
try {
|
|
21300
|
+
await bot.telegram.deleteMessage(numericChatId, existingMsgId);
|
|
21301
|
+
}
|
|
21302
|
+
catch {
|
|
21303
|
+
// Already deleted
|
|
21304
|
+
}
|
|
21305
|
+
chatPinnedStatusMsg.delete(chatId);
|
|
21306
|
+
}
|
|
21307
|
+
// Always send a fresh message and pin it. Editing in place doesn't
|
|
21308
|
+
// refresh the pinned bar on Android, so delete+send+pin is the only
|
|
21309
|
+
// reliable approach. The pinned message itself serves as the
|
|
21310
|
+
// confirmation — no separate reply is needed.
|
|
21311
|
+
try {
|
|
21312
|
+
const msg = await bot.telegram.sendMessage(numericChatId, text, {
|
|
21313
|
+
parse_mode: "Markdown",
|
|
21314
|
+
});
|
|
21315
|
+
const messageId = msg.message_id;
|
|
21316
|
+
if (messageId) {
|
|
21317
|
+
chatPinnedStatusMsg.set(chatId, messageId);
|
|
21318
|
+
saveSessions();
|
|
21319
|
+
try {
|
|
21320
|
+
await bot.telegram.pinChatMessage(numericChatId, messageId, {
|
|
21321
|
+
disable_notification: true,
|
|
21322
|
+
});
|
|
21323
|
+
}
|
|
21324
|
+
catch (pinErr) {
|
|
21325
|
+
console.warn("[Telegram] Failed to pin status message:", pinErr);
|
|
21326
|
+
}
|
|
21327
|
+
}
|
|
21328
|
+
}
|
|
21329
|
+
catch (err) {
|
|
21330
|
+
console.warn("[Telegram] Failed to send status message:", err);
|
|
21331
|
+
}
|
|
21332
|
+
}
|
|
21333
|
+
// Handle /agent command
|
|
21334
|
+
bot.command("agent", async (ctx) => {
|
|
21335
|
+
const chatId = ctx.chat.id.toString();
|
|
21336
|
+
const args = (ctx.message?.text || "").replace(/^\/agent\s*/i, "").trim();
|
|
21337
|
+
if (!args) {
|
|
21338
|
+
// Show current agent and list available ones
|
|
21339
|
+
const current = getActiveAgent(chatId);
|
|
21340
|
+
let msg = `Current agent: *${current}*\n\nAvailable agents:\n`;
|
|
21341
|
+
for (const a of availableAgents) {
|
|
21342
|
+
const marker = a.name === current ? " (active)" : "";
|
|
21343
|
+
const desc = a.description ? ` -- ${truncate(a.description, 80)}` : "";
|
|
21344
|
+
msg += `- *${a.name}*${marker}${desc}\n`;
|
|
21345
|
+
}
|
|
21346
|
+
msg += "\nTap a button or use `/agent <name>` to switch.";
|
|
21347
|
+
const keyboard = buildStatusKeyboard(chatId);
|
|
21348
|
+
await ctx.reply(msg, {
|
|
21349
|
+
parse_mode: "Markdown",
|
|
21350
|
+
reply_markup: { inline_keyboard: keyboard },
|
|
21351
|
+
});
|
|
21352
|
+
return;
|
|
21353
|
+
}
|
|
21354
|
+
// Direct switch by name
|
|
21355
|
+
const target = args.toLowerCase();
|
|
21356
|
+
const match = availableAgents.find((a) => a.name === target);
|
|
21357
|
+
if (!match) {
|
|
21358
|
+
await ctx.reply(`Unknown agent "${args}". Available: ${availableAgents.map((a) => a.name).join(", ")}`);
|
|
21359
|
+
return;
|
|
21360
|
+
}
|
|
21361
|
+
chatAgentOverride.set(chatId, match.name);
|
|
21362
|
+
saveSessions();
|
|
21363
|
+
await updatePinnedStatus(chatId, ctx.chat.id);
|
|
21364
|
+
});
|
|
21365
|
+
// Handle agent switch via inline button
|
|
21366
|
+
bot.action(/^agent:(.+)$/, async (ctx) => {
|
|
21367
|
+
const chatId = getChatIdFromContext(ctx);
|
|
21368
|
+
if (!chatId)
|
|
21369
|
+
return;
|
|
21370
|
+
const agentName = ctx.match?.[1];
|
|
21371
|
+
if (!agentName)
|
|
21372
|
+
return;
|
|
21373
|
+
const match = availableAgents.find((a) => a.name === agentName);
|
|
21374
|
+
if (!match) {
|
|
21375
|
+
await answerAndEdit(ctx, `Unknown agent: ${agentName}`);
|
|
21376
|
+
return;
|
|
21377
|
+
}
|
|
21378
|
+
chatAgentOverride.set(chatId, match.name);
|
|
21379
|
+
saveSessions();
|
|
21380
|
+
try {
|
|
21381
|
+
await ctx.answerCbQuery();
|
|
21382
|
+
}
|
|
21383
|
+
catch { /* ignore */ }
|
|
21384
|
+
try {
|
|
21385
|
+
await ctx.deleteMessage();
|
|
21386
|
+
}
|
|
21387
|
+
catch { /* ignore */ }
|
|
21388
|
+
await updatePinnedStatus(chatId, Number(chatId));
|
|
21198
21389
|
});
|
|
21199
21390
|
// Handle question answer callback (from OpenCode question.asked events)
|
|
21200
21391
|
bot.action(/^qa:([^:]+):(\d+):(\d+)$/, async (ctx) => {
|