ralph-cli-sandboxed 0.4.1 → 0.4.2

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 (69) hide show
  1. package/README.md +30 -0
  2. package/dist/commands/action.js +9 -9
  3. package/dist/commands/chat.js +13 -12
  4. package/dist/commands/config.js +2 -1
  5. package/dist/commands/daemon.js +4 -3
  6. package/dist/commands/docker.js +102 -66
  7. package/dist/commands/fix-config.js +2 -1
  8. package/dist/commands/fix-prd.js +2 -2
  9. package/dist/commands/init.js +78 -17
  10. package/dist/commands/listen.js +3 -1
  11. package/dist/commands/notify.js +1 -1
  12. package/dist/commands/once.js +17 -9
  13. package/dist/commands/prd.js +4 -1
  14. package/dist/commands/run.js +40 -25
  15. package/dist/commands/slack.js +2 -2
  16. package/dist/config/responder-presets.json +69 -0
  17. package/dist/index.js +1 -1
  18. package/dist/providers/discord.d.ts +28 -0
  19. package/dist/providers/discord.js +227 -14
  20. package/dist/providers/slack.d.ts +41 -1
  21. package/dist/providers/slack.js +389 -8
  22. package/dist/providers/telegram.d.ts +30 -0
  23. package/dist/providers/telegram.js +185 -5
  24. package/dist/responders/claude-code-responder.d.ts +48 -0
  25. package/dist/responders/claude-code-responder.js +203 -0
  26. package/dist/responders/cli-responder.d.ts +62 -0
  27. package/dist/responders/cli-responder.js +298 -0
  28. package/dist/responders/llm-responder.d.ts +135 -0
  29. package/dist/responders/llm-responder.js +582 -0
  30. package/dist/templates/macos-scripts.js +2 -4
  31. package/dist/templates/prompts.js +4 -2
  32. package/dist/tui/ConfigEditor.js +19 -5
  33. package/dist/tui/components/ArrayEditor.js +1 -1
  34. package/dist/tui/components/EditorPanel.js +10 -6
  35. package/dist/tui/components/HelpPanel.d.ts +1 -1
  36. package/dist/tui/components/HelpPanel.js +1 -1
  37. package/dist/tui/components/JsonSnippetEditor.js +8 -5
  38. package/dist/tui/components/KeyValueEditor.js +54 -9
  39. package/dist/tui/components/LLMProvidersEditor.d.ts +22 -0
  40. package/dist/tui/components/LLMProvidersEditor.js +357 -0
  41. package/dist/tui/components/ObjectEditor.js +1 -1
  42. package/dist/tui/components/Preview.js +1 -1
  43. package/dist/tui/components/RespondersEditor.d.ts +22 -0
  44. package/dist/tui/components/RespondersEditor.js +437 -0
  45. package/dist/tui/components/SectionNav.js +27 -3
  46. package/dist/utils/chat-client.d.ts +4 -0
  47. package/dist/utils/chat-client.js +12 -5
  48. package/dist/utils/config.d.ts +84 -0
  49. package/dist/utils/config.js +78 -1
  50. package/dist/utils/daemon-client.d.ts +21 -0
  51. package/dist/utils/daemon-client.js +28 -1
  52. package/dist/utils/llm-client.d.ts +82 -0
  53. package/dist/utils/llm-client.js +185 -0
  54. package/dist/utils/message-queue.js +6 -6
  55. package/dist/utils/notification.d.ts +6 -1
  56. package/dist/utils/notification.js +103 -2
  57. package/dist/utils/prd-validator.js +60 -19
  58. package/dist/utils/prompt.js +22 -12
  59. package/dist/utils/responder-logger.d.ts +47 -0
  60. package/dist/utils/responder-logger.js +129 -0
  61. package/dist/utils/responder-presets.d.ts +92 -0
  62. package/dist/utils/responder-presets.js +156 -0
  63. package/dist/utils/responder.d.ts +88 -0
  64. package/dist/utils/responder.js +207 -0
  65. package/dist/utils/stream-json.js +6 -6
  66. package/docs/CHAT-RESPONDERS.md +785 -0
  67. package/docs/DEVELOPMENT.md +25 -0
  68. package/docs/chat-architecture.md +251 -0
  69. package/package.json +11 -1
@@ -6,6 +6,11 @@
6
6
  * npm install @slack/bolt @slack/web-api
7
7
  */
8
8
  import { parseCommand, } from "../utils/chat-client.js";
9
+ import { ResponderMatcher } from "../utils/responder.js";
10
+ import { loadConfig } from "../utils/config.js";
11
+ import { executeLLMResponder } from "../responders/llm-responder.js";
12
+ import { executeClaudeCodeResponder } from "../responders/claude-code-responder.js";
13
+ import { executeCLIResponder } from "../responders/cli-responder.js";
9
14
  // Dynamic imports for @slack/bolt and @slack/web-api
10
15
  // These packages may not be installed in all environments
11
16
  let AppConstructor = null;
@@ -34,9 +39,238 @@ export class SlackChatClient {
34
39
  webClient = null;
35
40
  onCommand = null;
36
41
  onMessage = null;
42
+ responderMatcher = null;
43
+ respondersConfig = null;
44
+ botUserId = null;
45
+ // Track active thread conversations for multi-turn chat
46
+ threadConversations = new Map();
37
47
  constructor(settings, debug = false) {
38
48
  this.settings = settings;
39
49
  this.debug = debug;
50
+ // Initialize responders from config if available
51
+ this.initializeResponders();
52
+ }
53
+ /**
54
+ * Initialize responder matching from config.
55
+ */
56
+ initializeResponders() {
57
+ try {
58
+ const config = loadConfig();
59
+ if (config.chat?.responders) {
60
+ this.respondersConfig = config.chat.responders;
61
+ this.responderMatcher = new ResponderMatcher(config.chat.responders);
62
+ if (this.debug) {
63
+ console.log(`[slack] Initialized ${Object.keys(config.chat.responders).length} responders`);
64
+ }
65
+ }
66
+ }
67
+ catch {
68
+ // Config not available or responders not configured
69
+ if (this.debug) {
70
+ console.log("[slack] No responders configured");
71
+ }
72
+ }
73
+ }
74
+ /**
75
+ * Fetch thread context (previous messages in the thread).
76
+ * Returns formatted context string or empty string if not in a thread.
77
+ */
78
+ async fetchThreadContext(channelId, threadTs, currentMessageTs) {
79
+ if (!this.webClient)
80
+ return "";
81
+ try {
82
+ // Fetch thread replies
83
+ const result = await this.webClient.conversations.replies({
84
+ channel: channelId,
85
+ ts: threadTs,
86
+ limit: 10, // Get last 10 messages in thread
87
+ });
88
+ if (!result.messages || result.messages.length <= 1) {
89
+ return ""; // No thread context (only the parent message or current message)
90
+ }
91
+ // Format thread messages, excluding the current message and bot messages
92
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
93
+ const contextMessages = result.messages
94
+ .filter((msg) => {
95
+ // Exclude current message and bot messages
96
+ if (msg.ts === currentMessageTs)
97
+ return false;
98
+ if (msg.bot_id || msg.subtype === "bot_message")
99
+ return false;
100
+ return true;
101
+ })
102
+ .map((msg) => {
103
+ const text = msg.text || "[no text]";
104
+ return `---\n${text}`;
105
+ });
106
+ if (contextMessages.length === 0) {
107
+ return "";
108
+ }
109
+ return `Previous messages in thread:\n${contextMessages.join("\n")}\n---\n\nCurrent request:\n`;
110
+ }
111
+ catch (err) {
112
+ if (this.debug) {
113
+ console.error(`[slack] Failed to fetch thread context: ${err}`);
114
+ }
115
+ return "";
116
+ }
117
+ }
118
+ /**
119
+ * Execute a responder and return the result.
120
+ */
121
+ async executeResponder(match, message, threadContext) {
122
+ const { responder } = match;
123
+ // Prepend thread context if available
124
+ const fullMessage = threadContext ? threadContext + message : message;
125
+ switch (responder.type) {
126
+ case "llm":
127
+ return executeLLMResponder(fullMessage, responder, undefined, {
128
+ responderName: match.name,
129
+ trigger: responder.trigger,
130
+ threadContextLength: threadContext?.length,
131
+ debug: this.debug,
132
+ });
133
+ case "claude-code":
134
+ return executeClaudeCodeResponder(fullMessage, responder);
135
+ case "cli":
136
+ return executeCLIResponder(message, responder); // CLI doesn't use thread context
137
+ default:
138
+ return {
139
+ success: false,
140
+ response: "",
141
+ error: `Unknown responder type: ${responder.type}`,
142
+ };
143
+ }
144
+ }
145
+ /**
146
+ * Handle a message that might match a responder or continue an existing thread conversation.
147
+ * Returns true if a responder was matched and executed.
148
+ */
149
+ async handleResponderMessage(message, say, threadTs, messageTs) {
150
+ // Check if this is a continuation of an existing thread conversation
151
+ if (threadTs) {
152
+ const existingConversation = this.threadConversations.get(threadTs);
153
+ if (existingConversation) {
154
+ if (this.debug) {
155
+ console.log(`[slack] Continuing thread conversation with ${existingConversation.responderName}`);
156
+ }
157
+ return this.continueThreadConversation(existingConversation, threadTs, message.text, say);
158
+ }
159
+ }
160
+ if (!this.responderMatcher) {
161
+ return false;
162
+ }
163
+ if (this.debug) {
164
+ console.log(`[slack] Checking responders for message: "${message.text.slice(0, 50)}..."`);
165
+ }
166
+ const match = this.responderMatcher.matchResponder(message.text);
167
+ if (!match) {
168
+ if (this.debug) {
169
+ console.log(`[slack] No responder matched for message`);
170
+ }
171
+ return false;
172
+ }
173
+ if (this.debug) {
174
+ console.log(`[slack] Matched responder: ${match.name} (type: ${match.responder.type})`);
175
+ }
176
+ // For LLM responders, start a new thread conversation
177
+ const userMessage = match.args || message.text;
178
+ const result = await this.executeResponder(match, userMessage, undefined);
179
+ // Send the response (use thread_ts for context continuity)
180
+ const responseThreadTs = threadTs || messageTs;
181
+ if (result.success) {
182
+ await say({
183
+ text: result.response,
184
+ thread_ts: responseThreadTs,
185
+ });
186
+ // Start tracking this thread conversation for LLM responders
187
+ if (match.responder.type === "llm" && responseThreadTs) {
188
+ this.threadConversations.set(responseThreadTs, {
189
+ responderName: match.name,
190
+ responder: match.responder,
191
+ messages: [
192
+ { role: "user", content: userMessage, timestamp: messageTs || "" },
193
+ { role: "assistant", content: result.response, timestamp: new Date().toISOString() },
194
+ ],
195
+ createdAt: new Date(),
196
+ });
197
+ if (this.debug) {
198
+ console.log(`[slack] Started thread conversation for ${responseThreadTs}`);
199
+ }
200
+ }
201
+ }
202
+ else {
203
+ const errorMsg = result.error
204
+ ? `Error: ${result.error}`
205
+ : "An error occurred while processing your message.";
206
+ await say({
207
+ text: errorMsg,
208
+ thread_ts: responseThreadTs,
209
+ });
210
+ }
211
+ return true;
212
+ }
213
+ /**
214
+ * Continue an existing thread conversation with the LLM.
215
+ */
216
+ async continueThreadConversation(conversation, threadTs, userMessage, say) {
217
+ // Build conversation history for the LLM
218
+ const conversationHistory = conversation.messages.map((msg) => ({
219
+ role: msg.role,
220
+ content: msg.content,
221
+ }));
222
+ if (this.debug) {
223
+ console.log(`[slack] Conversation history: ${conversationHistory.length} messages`);
224
+ }
225
+ // Execute responder with conversation history
226
+ const result = await executeLLMResponder(userMessage, conversation.responder, undefined, {
227
+ responderName: conversation.responderName,
228
+ trigger: conversation.responder.trigger,
229
+ conversationHistory,
230
+ debug: this.debug,
231
+ });
232
+ if (result.success) {
233
+ await say({
234
+ text: result.response,
235
+ thread_ts: threadTs,
236
+ });
237
+ // Add messages to conversation history
238
+ conversation.messages.push({ role: "user", content: userMessage, timestamp: new Date().toISOString() }, { role: "assistant", content: result.response, timestamp: new Date().toISOString() });
239
+ // Limit conversation history to prevent token overflow (keep last 20 messages)
240
+ if (conversation.messages.length > 20) {
241
+ conversation.messages = conversation.messages.slice(-20);
242
+ }
243
+ }
244
+ else {
245
+ const errorMsg = result.error
246
+ ? `Error: ${result.error}`
247
+ : "An error occurred while processing your message.";
248
+ await say({
249
+ text: errorMsg,
250
+ thread_ts: threadTs,
251
+ });
252
+ }
253
+ return true;
254
+ }
255
+ /**
256
+ * Check if a message is mentioning the bot.
257
+ */
258
+ isBotMentioned(text) {
259
+ if (!this.botUserId) {
260
+ return false;
261
+ }
262
+ // Check for <@USERID> format used by Slack
263
+ return text.includes(`<@${this.botUserId}>`);
264
+ }
265
+ /**
266
+ * Remove bot mention from message text.
267
+ */
268
+ removeBotMention(text) {
269
+ if (!this.botUserId) {
270
+ return text;
271
+ }
272
+ // Remove <@USERID> and any surrounding whitespace
273
+ return text.replace(new RegExp(`<@${this.botUserId}>\\s*`, "g"), "").trim();
40
274
  }
41
275
  /**
42
276
  * Check if a channel ID is allowed.
@@ -66,14 +300,91 @@ export class SlackChatClient {
66
300
  setupEventHandlers() {
67
301
  if (!this.app)
68
302
  return;
303
+ // Add global error handler
304
+ this.app.error(async (error) => {
305
+ console.error("[slack] App error:", error);
306
+ });
307
+ // Log all incoming events when debug is enabled
308
+ if (this.debug) {
309
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
310
+ this.app.use(async ({ payload, next }) => {
311
+ const eventType = payload?.type || payload?.event?.type || "unknown";
312
+ console.log(`[slack] Event received: ${eventType}`);
313
+ await next();
314
+ });
315
+ }
316
+ // Handle app_mention events (when someone @mentions the bot)
317
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
318
+ this.app.event("app_mention", async ({ event, say, }) => {
319
+ if (this.debug) {
320
+ console.log(`[slack] Received app_mention in channel ${event.channel}`);
321
+ }
322
+ const channelId = event.channel;
323
+ // Check if channel is allowed
324
+ if (!this.isChannelAllowed(channelId)) {
325
+ if (this.debug) {
326
+ console.log(`[slack] Ignoring mention from unauthorized channel: ${channelId}`);
327
+ }
328
+ return;
329
+ }
330
+ // Remove the bot mention from the message text
331
+ const cleanedText = this.removeBotMention(event.text || "");
332
+ const chatMessage = this.toMessage({
333
+ text: cleanedText,
334
+ channel: channelId,
335
+ user: event.user,
336
+ ts: event.ts || String(Date.now() / 1000),
337
+ });
338
+ // Get thread_ts for reply context (use parent thread or message ts)
339
+ const threadTs = event.thread_ts || event.ts;
340
+ // Try responder matching first
341
+ try {
342
+ const handled = await this.handleResponderMessage(chatMessage, async (opts) => {
343
+ if (typeof opts === "string") {
344
+ await say({ text: opts, thread_ts: threadTs });
345
+ }
346
+ else {
347
+ await say({ ...opts, thread_ts: threadTs });
348
+ }
349
+ }, threadTs, event.ts);
350
+ if (handled) {
351
+ return;
352
+ }
353
+ }
354
+ catch (err) {
355
+ if (this.debug) {
356
+ console.error(`[slack] Responder error: ${err}`);
357
+ }
358
+ await say({
359
+ text: `Error processing message: ${err instanceof Error ? err.message : "Unknown error"}`,
360
+ thread_ts: threadTs,
361
+ });
362
+ return;
363
+ }
364
+ // If no responder matched and no default responder, reply with help
365
+ if (!this.responderMatcher?.hasDefaultResponder()) {
366
+ await say({
367
+ text: "I received your message, but no responders are configured. Use /ralph for commands.",
368
+ thread_ts: threadTs,
369
+ });
370
+ }
371
+ });
69
372
  // Handle all messages (including DMs and channel messages)
70
373
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
- this.app.message(async ({ message, say }) => {
374
+ this.app.message(async ({ message, say, }) => {
375
+ // Debug: log raw message event before any filtering
376
+ if (this.debug) {
377
+ console.log(`[slack] Raw message event: channel=${message.channel}, text="${(message.text || "").slice(0, 50)}", subtype=${message.subtype || "none"}, bot_id=${message.bot_id || "none"}`);
378
+ }
72
379
  // Type guard for message with text
73
380
  if (!message.text)
74
381
  return;
75
382
  if (!message.channel)
76
383
  return;
384
+ // Ignore bot messages to prevent loops
385
+ if (message.bot_id || message.subtype === "bot_message") {
386
+ return;
387
+ }
77
388
  const channelId = message.channel;
78
389
  // Check if channel is allowed
79
390
  if (!this.isChannelAllowed(channelId)) {
@@ -82,8 +393,17 @@ export class SlackChatClient {
82
393
  }
83
394
  return;
84
395
  }
396
+ // Get thread_ts for reply context (use parent thread or message ts)
397
+ const threadTs = message.thread_ts || message.ts;
398
+ // Check if this is a bot mention - if so, process it
399
+ const isMention = this.isBotMentioned(message.text);
400
+ // Create chat message
401
+ let messageText = message.text;
402
+ if (isMention) {
403
+ messageText = this.removeBotMention(message.text);
404
+ }
85
405
  const chatMessage = this.toMessage({
86
- text: message.text,
406
+ text: messageText,
87
407
  channel: channelId,
88
408
  user: message.user,
89
409
  ts: message.ts || String(Date.now() / 1000),
@@ -99,18 +419,56 @@ export class SlackChatClient {
99
419
  }
100
420
  }
101
421
  }
102
- // Try to parse as a command
422
+ // Try to parse as a command first
103
423
  const command = parseCommand(chatMessage.text, chatMessage);
104
424
  if (command && this.onCommand) {
105
425
  try {
106
426
  await this.onCommand(command);
427
+ return; // Command handled, don't process as responder message
107
428
  }
108
429
  catch (err) {
109
430
  if (this.debug) {
110
431
  console.error(`[slack] Command handler error: ${err}`);
111
432
  }
112
433
  // Send error message to channel
113
- await say(`Error executing command: ${err instanceof Error ? err.message : "Unknown error"}`);
434
+ await say({
435
+ text: `Error executing command: ${err instanceof Error ? err.message : "Unknown error"}`,
436
+ thread_ts: threadTs,
437
+ });
438
+ return;
439
+ }
440
+ }
441
+ // Check if this is a continuation of an active thread conversation
442
+ const isActiveThread = threadTs && this.threadConversations.has(threadTs);
443
+ // If message contains a bot mention, responders are configured, or we're in an active thread,
444
+ // try to route through responder matching
445
+ // Note: We try matching whenever responders exist, not just on bot mention,
446
+ // so that @trigger patterns (like @qa) work without requiring @bot first
447
+ if (isMention || this.responderMatcher || isActiveThread) {
448
+ try {
449
+ const handled = await this.handleResponderMessage(chatMessage, async (opts) => {
450
+ if (typeof opts === "string") {
451
+ await say({ text: opts, thread_ts: threadTs });
452
+ }
453
+ else {
454
+ await say({ ...opts, thread_ts: threadTs });
455
+ }
456
+ }, threadTs, message.ts);
457
+ if (handled) {
458
+ return;
459
+ }
460
+ }
461
+ catch (err) {
462
+ if (this.debug) {
463
+ console.error(`[slack] Responder error: ${err}`);
464
+ }
465
+ if (isMention || isActiveThread) {
466
+ // Reply with error if this was a direct mention or active thread
467
+ await say({
468
+ text: `Error processing message: ${err instanceof Error ? err.message : "Unknown error"}`,
469
+ thread_ts: threadTs,
470
+ });
471
+ }
114
472
  }
115
473
  }
116
474
  });
@@ -123,7 +481,7 @@ export class SlackChatClient {
123
481
  }
124
482
  this.app.command("/ralph",
125
483
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
126
- async ({ command, ack, respond }) => {
484
+ async ({ command, ack, respond, }) => {
127
485
  if (this.debug) {
128
486
  console.log(`[slack] Received: /ralph ${command.text} from channel ${command.channel_id}`);
129
487
  }
@@ -187,7 +545,7 @@ export class SlackChatClient {
187
545
  // Handle button actions (Block Kit interactive components)
188
546
  this.app.action(/^ralph_action_.*/,
189
547
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
190
- async ({ action, ack, body, respond }) => {
548
+ async ({ action, ack, body, respond, }) => {
191
549
  await ack();
192
550
  if (!action.value)
193
551
  return;
@@ -254,9 +612,28 @@ export class SlackChatClient {
254
612
  await this.app.start();
255
613
  // Verify connection by checking auth
256
614
  const authResult = await this.webClient.auth.test();
257
- if (this.debug) {
258
- console.log(`[slack] Connected as @${authResult.user} (bot ID: ${authResult.bot_id})`);
615
+ this.botUserId = authResult.user_id || authResult.bot_id;
616
+ // Always show connection info
617
+ console.log(`[slack] Workspace: ${authResult.team || "unknown"} (${authResult.url || ""})`);
618
+ console.log(`[slack] Bot user: @${authResult.user || "unknown"} (ID: ${this.botUserId})`);
619
+ if (authResult.app_id) {
620
+ console.log(`[slack] App ID: ${authResult.app_id}`);
621
+ console.log(`[slack] Configure at: https://api.slack.com/apps/${authResult.app_id}`);
622
+ }
623
+ // Show responder info
624
+ if (this.respondersConfig) {
625
+ const responderNames = Object.keys(this.respondersConfig);
626
+ console.log(`[slack] Responders: ${responderNames.join(", ") || "(none)"}`);
259
627
  }
628
+ else {
629
+ console.log(`[slack] Responders: (none configured)`);
630
+ }
631
+ // Remind about slash command setup
632
+ console.log(`[slack] `);
633
+ console.log(`[slack] For /ralph slash command to work:`);
634
+ console.log(`[slack] 1. Go to Slack App settings → Slash Commands`);
635
+ console.log(`[slack] 2. Create command: /ralph`);
636
+ console.log(`[slack] 3. Enable "Escape channels, users, and links" option`);
260
637
  this.connected = true;
261
638
  }
262
639
  catch (err) {
@@ -272,6 +649,10 @@ export class SlackChatClient {
272
649
  channel: chatId,
273
650
  text, // Fallback text for notifications
274
651
  };
652
+ // Add thread_ts for context continuity if provided
653
+ if (options?.threadTs) {
654
+ payload.thread_ts = options.threadTs;
655
+ }
275
656
  // Convert inline keyboard to Slack Block Kit buttons
276
657
  if (options?.inlineKeyboard && options.inlineKeyboard.length > 0) {
277
658
  const blocks = [];
@@ -11,7 +11,37 @@ export declare class TelegramChatClient implements ChatClient {
11
11
  private lastUpdateId;
12
12
  private pollingTimeout;
13
13
  private debug;
14
+ private responderMatcher;
15
+ private respondersConfig;
16
+ private botUserId;
17
+ private botUsername;
14
18
  constructor(settings: TelegramSettings, debug?: boolean);
19
+ /**
20
+ * Initialize responder matching from config.
21
+ */
22
+ private initializeResponders;
23
+ /**
24
+ * Execute a responder and return the result.
25
+ */
26
+ private executeResponder;
27
+ /**
28
+ * Handle a message that might match a responder.
29
+ * Returns true if a responder was matched and executed.
30
+ */
31
+ private handleResponderMessage;
32
+ /**
33
+ * Check if the bot is mentioned in a message.
34
+ * Handles both @username mentions and direct replies to the bot.
35
+ */
36
+ private isBotMentioned;
37
+ /**
38
+ * Remove bot mention from message text.
39
+ */
40
+ private removeBotMention;
41
+ /**
42
+ * Check if a chat is a group chat (group or supergroup).
43
+ */
44
+ private isGroupChat;
15
45
  /**
16
46
  * Make a request to the Telegram Bot API.
17
47
  */