@acp-router/adapter-telegram 0.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/lib/index.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './telegram-adapter.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAA"}
package/lib/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from './telegram-adapter.js';
@@ -0,0 +1,2 @@
1
+ export declare function markdownToTelegramHtml(markdown: string): string;
2
+ //# sourceMappingURL=markdown.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../src/markdown.ts"],"names":[],"mappings":"AAwKA,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAE/D"}
@@ -0,0 +1,146 @@
1
+ import MarkdownIt from 'markdown-it';
2
+ function escapeHtml(str) {
3
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
4
+ }
5
+ function createTelegramRenderer() {
6
+ const md = new MarkdownIt({ linkify: true });
7
+ const { rules } = md.renderer;
8
+ let listStack = [];
9
+ let blockquoteDepth = 0;
10
+ rules.text = (tokens, idx) => escapeHtml(tokens[idx].content);
11
+ rules.strong_open = () => '<b>';
12
+ rules.strong_close = () => '</b>';
13
+ rules.em_open = () => '<i>';
14
+ rules.em_close = () => '</i>';
15
+ rules.s_open = () => '<s>';
16
+ rules.s_close = () => '</s>';
17
+ rules.code_inline = (tokens, idx) => `<code>${escapeHtml(tokens[idx].content)}</code>`;
18
+ rules.fence = (tokens, idx) => {
19
+ const token = tokens[idx];
20
+ const lang = token.info.trim();
21
+ const code = escapeHtml(token.content);
22
+ if (lang) {
23
+ return `<pre><code class="language-${escapeHtml(lang)}">${code}</code></pre>\n`;
24
+ }
25
+ return `<pre><code>${code}</code></pre>\n`;
26
+ };
27
+ rules.code_block = (tokens, idx) => {
28
+ return `<pre><code>${escapeHtml(tokens[idx].content)}</code></pre>\n`;
29
+ };
30
+ rules.heading_open = (tokens, idx) => {
31
+ const level = Number(tokens[idx].tag.slice(1));
32
+ if (level <= 2)
33
+ return '<b>';
34
+ return '<b>';
35
+ };
36
+ rules.heading_close = () => '</b>\n';
37
+ rules.paragraph_open = () => '';
38
+ rules.paragraph_close = (_tokens, _idx, _options, env) => {
39
+ if (env._inListItem)
40
+ return '';
41
+ if (blockquoteDepth > 0)
42
+ return '\n';
43
+ return '\n\n';
44
+ };
45
+ rules.blockquote_open = () => {
46
+ blockquoteDepth++;
47
+ return '<blockquote>';
48
+ };
49
+ rules.blockquote_close = () => {
50
+ blockquoteDepth--;
51
+ return '</blockquote>\n';
52
+ };
53
+ rules.bullet_list_open = () => {
54
+ const nested = listStack.length > 0;
55
+ listStack.push({ ordered: false, index: 0 });
56
+ return nested ? '\n' : '';
57
+ };
58
+ rules.bullet_list_close = () => {
59
+ listStack.pop();
60
+ return listStack.length > 0 ? '' : '\n';
61
+ };
62
+ rules.ordered_list_open = (tokens, idx) => {
63
+ const nested = listStack.length > 0;
64
+ const start = Number(tokens[idx].attrGet('start') ?? 1);
65
+ listStack.push({ ordered: true, index: start });
66
+ return nested ? '\n' : '';
67
+ };
68
+ rules.ordered_list_close = () => {
69
+ listStack.pop();
70
+ return listStack.length > 0 ? '' : '\n';
71
+ };
72
+ rules.list_item_open = (_tokens, _idx, _options, env) => {
73
+ env._inListItem = (env._inListItem ?? 0) + 1;
74
+ const ctx = listStack[listStack.length - 1];
75
+ if (!ctx)
76
+ return '• ';
77
+ const indent = ' '.repeat(listStack.length - 1);
78
+ if (ctx.ordered) {
79
+ const num = ctx.index;
80
+ ctx.index++;
81
+ return `${indent}${num}. `;
82
+ }
83
+ return `${indent}• `;
84
+ };
85
+ rules.list_item_close = (_tokens, _idx, _options, env) => {
86
+ env._inListItem = Math.max(0, (env._inListItem ?? 1) - 1);
87
+ return '\n';
88
+ };
89
+ rules.link_open = (tokens, idx) => {
90
+ const href = tokens[idx].attrGet('href') ?? '';
91
+ return `<a href="${escapeHtml(href)}">`;
92
+ };
93
+ rules.link_close = () => '</a>';
94
+ rules.image = (tokens, idx) => {
95
+ const token = tokens[idx];
96
+ const src = token.attrGet('src') ?? '';
97
+ const alt = token.content || token.attrGet('alt') || 'image';
98
+ return `<a href="${escapeHtml(src)}">${escapeHtml(alt)}</a>`;
99
+ };
100
+ rules.table_open = () => '<pre>';
101
+ rules.table_close = () => '</pre>\n';
102
+ rules.thead_open = () => '';
103
+ rules.thead_close = () => '';
104
+ rules.tbody_open = () => '';
105
+ rules.tbody_close = () => '';
106
+ rules.tr_open = () => '';
107
+ rules.tr_close = () => '\n';
108
+ rules.th_open = () => '';
109
+ rules.th_close = () => ' | ';
110
+ rules.td_open = () => '';
111
+ rules.td_close = () => ' | ';
112
+ rules.hr = () => '———\n';
113
+ rules.softbreak = () => '\n';
114
+ rules.hardbreak = () => '\n';
115
+ rules.html_block = (tokens, idx) => escapeHtml(tokens[idx].content);
116
+ rules.html_inline = (tokens, idx) => escapeHtml(tokens[idx].content);
117
+ // Store original renderToken for fallback on unknown open/close tokens
118
+ const originalRenderToken = md.renderer.renderToken.bind(md.renderer);
119
+ md.renderer.renderToken = function (tokens, idx, options) {
120
+ const token = tokens[idx];
121
+ // Suppress tags we don't explicitly handle (e.g. <p>, <ul>, <ol>, <li>, <table>)
122
+ // Open/close tokens for known container types are handled by rules above
123
+ const suppressed = new Set([
124
+ 'p', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
125
+ 'blockquote', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr',
126
+ 'dl', 'dt', 'dd'
127
+ ]);
128
+ if (suppressed.has(token.tag))
129
+ return '';
130
+ return originalRenderToken(tokens, idx, options);
131
+ };
132
+ // Reset state before each render
133
+ const originalRender = md.render.bind(md);
134
+ md.render = (src, env) => {
135
+ listStack = [];
136
+ blockquoteDepth = 0;
137
+ const result = originalRender(src, env ?? {});
138
+ // Clean up excessive blank lines
139
+ return result.replace(/\n{3,}/g, '\n\n').trim();
140
+ };
141
+ return md;
142
+ }
143
+ const renderer = createTelegramRenderer();
144
+ export function markdownToTelegramHtml(markdown) {
145
+ return renderer.render(markdown);
146
+ }
@@ -0,0 +1,31 @@
1
+ import { IMAdapter, type AdapterInteraction, type InlineMessage, type InlineUpdate, type InteractiveMessageKind, type TextMessageKind } from '@acp-router/core';
2
+ export declare class TelegramAdapter extends IMAdapter {
3
+ private token;
4
+ private allowList;
5
+ readonly platform = "telegram";
6
+ private bot;
7
+ private actionHandlers;
8
+ private fallbackActions;
9
+ private sendQueues;
10
+ private typingTimers;
11
+ constructor(token: string, allowList: number[]);
12
+ private enqueue;
13
+ init(): Promise<void>;
14
+ sendTextMessage(chatId: string, text: string, kind?: TextMessageKind): Promise<void>;
15
+ sendMedia(chatId: string, payload: {
16
+ kind: string;
17
+ mimeType: string;
18
+ data: Uint8Array;
19
+ filename?: string;
20
+ }): Promise<void>;
21
+ setCommands(commands: {
22
+ name: string;
23
+ description: string;
24
+ }[]): Promise<void>;
25
+ sendInteractiveMessage(chatId: string, message: InlineMessage, kind?: InteractiveMessageKind): Promise<AdapterInteraction>;
26
+ editInteractiveMessage(chatId: string, messageId: string, update: InlineUpdate): Promise<void>;
27
+ setActive(chatId: string, active: boolean): Promise<void>;
28
+ setReaction(chatId: string, messageId: string, kind: 'queued' | 'aborted' | 'ignored' | undefined): Promise<void>;
29
+ private refreshActions;
30
+ }
31
+ //# sourceMappingURL=telegram-adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"telegram-adapter.d.ts","sourceRoot":"","sources":["../src/telegram-adapter.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAU,KAAK,kBAAkB,EAAsB,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,KAAK,sBAAsB,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAA;AAG3L,qBAAa,eAAgB,SAAQ,SAAS;IAQhC,OAAO,CAAC,KAAK;IAAU,OAAO,CAAC,SAAS;IAPpD,QAAQ,CAAC,QAAQ,cAAa;IAC9B,OAAO,CAAC,GAAG,CAAK;IAChB,OAAO,CAAC,cAAc,CAAyD;IAC/E,OAAO,CAAC,eAAe,CAAyD;IAChF,OAAO,CAAC,UAAU,CAAmC;IACrD,OAAO,CAAC,YAAY,CAAoD;gBAEpD,KAAK,EAAE,MAAM,EAAU,SAAS,EAAE,MAAM,EAAE;IAK9D,OAAO,CAAC,OAAO;IAOT,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAgDrB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,eAA2B,GAAG,OAAO,CAAC,IAAI,CAAC;IAiC/F,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,UAAU,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAoB1H,WAAW,CAAC,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAK7E,sBAAsB,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,EAAE,IAAI,GAAE,sBAAkC,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAkCrI,sBAAsB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAqC9F,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBzD,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAgBvH,OAAO,CAAC,cAAc;CAuBvB"}
@@ -0,0 +1,277 @@
1
+ import { Bot, InlineKeyboard, InputFile } from 'grammy';
2
+ import { IMAdapter, logger } from '@acp-router/core';
3
+ import { markdownToTelegramHtml } from './markdown.js';
4
+ export class TelegramAdapter extends IMAdapter {
5
+ token;
6
+ allowList;
7
+ platform = 'telegram';
8
+ bot;
9
+ actionHandlers = new Map();
10
+ fallbackActions = new Map();
11
+ sendQueues = new Map();
12
+ typingTimers = new Map();
13
+ constructor(token, allowList) {
14
+ super();
15
+ this.token = token;
16
+ this.allowList = allowList;
17
+ this.bot = new Bot(token);
18
+ }
19
+ enqueue(chatId, fn) {
20
+ const prev = this.sendQueues.get(chatId) ?? Promise.resolve();
21
+ const next = prev.then(fn, fn);
22
+ this.sendQueues.set(chatId, next.then(() => { }, () => { }));
23
+ return next;
24
+ }
25
+ async init() {
26
+ logger.debug('Initializing Telegram adapter');
27
+ this.bot.use(async (ctx, next) => {
28
+ const chatId = ctx.chat?.id;
29
+ const chatType = ctx.chat?.type;
30
+ logger.debug({ chatId, chatType, hasMessage: !!ctx.message, hasCallback: !!ctx.callbackQuery }, 'Incoming update');
31
+ if (!chatId || !this.allowList.includes(chatId)) {
32
+ logger.debug({ chatId }, 'Chat not in allow list, ignoring');
33
+ return;
34
+ }
35
+ if (chatType !== 'private') {
36
+ logger.debug({ chatId, chatType }, 'Non-private chat, ignoring');
37
+ return;
38
+ }
39
+ await next();
40
+ });
41
+ this.bot.on('message:text', async (ctx) => {
42
+ const text = ctx.message.text;
43
+ const chatId = String(ctx.chat.id);
44
+ if (text.startsWith('/')) {
45
+ logger.debug({ chatId, text }, 'Received command message');
46
+ const parts = text.slice(1).trim().split(' ');
47
+ const command = parts[0];
48
+ const args = parts.slice(1).filter(Boolean);
49
+ logger.debug({ chatId, command, args }, 'Dispatching command');
50
+ await this.emit('command', chatId, String(ctx.message.message_id), command, args);
51
+ }
52
+ else {
53
+ logger.debug({ chatId, textLength: text.length }, 'Received text message');
54
+ await this.emit('text', chatId, String(ctx.message.message_id), text);
55
+ }
56
+ });
57
+ this.bot.on('callback_query:data', async (ctx) => {
58
+ const data = ctx.callbackQuery?.data;
59
+ if (!data)
60
+ return;
61
+ logger.debug({ data }, 'Received callback query');
62
+ const handler = this.actionHandlers.get(data) ?? this.fallbackActions.get(data);
63
+ if (!handler) {
64
+ logger.debug({ data }, 'No handler found for callback query');
65
+ return;
66
+ }
67
+ await handler(data);
68
+ await ctx.answerCallbackQuery().catch(() => { });
69
+ });
70
+ this.bot.catch((err) => logger.error({ err }, 'Telegram adapter error'));
71
+ this.bot.start();
72
+ logger.debug('Telegram bot polling started');
73
+ }
74
+ async sendTextMessage(chatId, text, kind = 'message') {
75
+ return this.enqueue(chatId, async () => {
76
+ logger.debug({ chatId, textLength: text.length, kind }, 'Sending message');
77
+ if (kind === 'thought') {
78
+ try {
79
+ const innerHtml = markdownToTelegramHtml(text);
80
+ const html = `<blockquote expandable>\u{1F4AD} <b>Thinking</b>\n\n${innerHtml}</blockquote>`;
81
+ await this.bot.api.sendMessage(Number(chatId), html, { parse_mode: 'HTML' });
82
+ }
83
+ catch (err) {
84
+ logger.warn({ chatId, err }, 'Failed to send thought as expandable blockquote, falling back');
85
+ try {
86
+ const html = markdownToTelegramHtml(`> **Thinking**\n>\n> ${text.replace(/\n/g, '\n> ')}`);
87
+ await this.bot.api.sendMessage(Number(chatId), html, { parse_mode: 'HTML' });
88
+ }
89
+ catch {
90
+ await this.bot.api.sendMessage(Number(chatId), `\u{1F4AD} Thinking\n\n${text}`).catch((err2) => {
91
+ logger.warn({ chatId, err: err2 }, 'Failed to send thought fallback');
92
+ });
93
+ }
94
+ }
95
+ }
96
+ else {
97
+ try {
98
+ const html = markdownToTelegramHtml(text);
99
+ await this.bot.api.sendMessage(Number(chatId), html, { parse_mode: 'HTML' });
100
+ }
101
+ catch (err) {
102
+ logger.warn({ chatId, err }, 'Failed to send as HTML, falling back to plain text');
103
+ await this.bot.api.sendMessage(Number(chatId), text).catch((err2) => {
104
+ logger.warn({ chatId, err: err2 }, 'Failed to send plain text fallback');
105
+ });
106
+ }
107
+ }
108
+ });
109
+ }
110
+ async sendMedia(chatId, payload) {
111
+ return this.enqueue(chatId, async () => {
112
+ logger.debug({ chatId, kind: payload.kind, mimeType: payload.mimeType, size: payload.data.length }, 'Sending media');
113
+ const file = new InputFile(Buffer.from(payload.data), payload.filename ?? `file.${extFromMime(payload.mimeType)}`);
114
+ if (payload.kind === 'image') {
115
+ await this.bot.api.sendPhoto(Number(chatId), file).catch((err) => logger.warn({ chatId, err }, 'Failed to send photo'));
116
+ return;
117
+ }
118
+ if (payload.kind === 'audio') {
119
+ await this.bot.api.sendAudio(Number(chatId), file).catch((err) => logger.warn({ chatId, err }, 'Failed to send audio'));
120
+ return;
121
+ }
122
+ if (payload.kind === 'video') {
123
+ await this.bot.api.sendVideo(Number(chatId), file).catch((err) => logger.warn({ chatId, err }, 'Failed to send video'));
124
+ return;
125
+ }
126
+ await this.bot.api.sendDocument(Number(chatId), file).catch((err) => logger.warn({ chatId, err }, 'Failed to send document'));
127
+ });
128
+ }
129
+ async setCommands(commands) {
130
+ await this.bot.api.setMyCommands(commands.map((cmd) => ({ command: cmd.name, description: cmd.description }))).catch(() => { });
131
+ logger.debug({ count: commands.length }, 'Telegram commands updated');
132
+ }
133
+ async sendInteractiveMessage(chatId, message, kind = 'generic') {
134
+ return this.enqueue(chatId, async () => {
135
+ logger.debug({ chatId, actionsCount: message.actions?.items.length ?? 0 }, 'Sending interactive message');
136
+ const kb = message.actions ? toKeyboard(message.actions) : undefined;
137
+ let html;
138
+ try {
139
+ html = markdownToTelegramHtml(message.markdown);
140
+ }
141
+ catch {
142
+ html = message.markdown;
143
+ }
144
+ const sent = await this.bot.api
145
+ .sendMessage(Number(chatId), html, { parse_mode: 'HTML', reply_markup: kb })
146
+ .catch((err) => {
147
+ logger.warn({ chatId, err }, 'Failed to send interactive message');
148
+ return null;
149
+ });
150
+ if (message.actions) {
151
+ for (const action of message.actions.items) {
152
+ this.actionHandlers.set(action.id, async (actionId) => {
153
+ await message.actions?.callback(actionId);
154
+ });
155
+ }
156
+ if (!sent) {
157
+ for (const action of message.actions.items) {
158
+ this.fallbackActions.set(action.id, async (actionId) => {
159
+ await message.actions?.callback(actionId);
160
+ });
161
+ }
162
+ }
163
+ }
164
+ return { id: sent ? String(sent.message_id) : `temp-${Date.now()}`, message };
165
+ });
166
+ }
167
+ async editInteractiveMessage(chatId, messageId, update) {
168
+ return this.enqueue(chatId, async () => {
169
+ logger.debug({ chatId, messageId, hasMarkdown: update.markdown != null, hasActions: 'actions' in update }, 'Updating interactive message');
170
+ const nextActions = 'actions' in update ? update.actions : undefined;
171
+ const removeKb = 'actions' in update && !update.actions;
172
+ const kb = nextActions ? toKeyboard(nextActions) : removeKb ? new InlineKeyboard() : undefined;
173
+ if (update.markdown != null) {
174
+ let html;
175
+ try {
176
+ html = markdownToTelegramHtml(update.markdown);
177
+ }
178
+ catch {
179
+ html = update.markdown;
180
+ }
181
+ await this.bot.api
182
+ .editMessageText(Number(chatId), Number(messageId), html, {
183
+ parse_mode: 'HTML',
184
+ reply_markup: kb
185
+ })
186
+ .catch((err) => {
187
+ logger.warn({ chatId, messageId, err }, 'Failed to edit message text');
188
+ });
189
+ this.refreshActions(nextActions);
190
+ return;
191
+ }
192
+ if ('actions' in update) {
193
+ await this.bot.api
194
+ .editMessageReplyMarkup(Number(chatId), Number(messageId), {
195
+ reply_markup: kb
196
+ })
197
+ .catch((err) => {
198
+ logger.warn({ chatId, messageId, err }, 'Failed to edit message reply markup');
199
+ });
200
+ this.refreshActions(nextActions);
201
+ }
202
+ });
203
+ }
204
+ async setActive(chatId, active) {
205
+ logger.debug({ chatId, active }, 'Setting chat active state');
206
+ const existing = this.typingTimers.get(chatId);
207
+ if (existing) {
208
+ clearInterval(existing);
209
+ this.typingTimers.delete(chatId);
210
+ }
211
+ if (!active)
212
+ return;
213
+ const sendTyping = () => {
214
+ this.bot.api.sendChatAction(Number(chatId), 'typing').catch((err) => {
215
+ logger.warn({ chatId, err }, 'Failed to send typing action');
216
+ });
217
+ };
218
+ sendTyping();
219
+ this.typingTimers.set(chatId, setInterval(sendTyping, 5000));
220
+ }
221
+ async setReaction(chatId, messageId, kind) {
222
+ return this.enqueue(chatId, async () => {
223
+ logger.debug({ chatId, messageId, kind }, 'Setting reaction');
224
+ const reaction = kind === 'queued'
225
+ ? [{ type: 'emoji', emoji: '✍' }]
226
+ : kind === 'aborted'
227
+ ? [{ type: 'emoji', emoji: '🕊' }]
228
+ : kind === 'ignored'
229
+ ? [{ type: 'emoji', emoji: '🤡' }]
230
+ : [];
231
+ await this.bot.api.setMessageReaction(Number(chatId), Number(messageId), reaction).catch((err) => {
232
+ logger.warn({ chatId, messageId, kind, err }, 'Failed to set reaction');
233
+ });
234
+ });
235
+ }
236
+ refreshActions(actions) {
237
+ if (!actions) {
238
+ this.actionHandlers.clear();
239
+ this.fallbackActions.clear();
240
+ return;
241
+ }
242
+ if (actions.items) {
243
+ this.actionHandlers.clear();
244
+ for (const action of actions.items) {
245
+ this.actionHandlers.set(action.id, async (actionId) => {
246
+ await actions.callback?.(actionId);
247
+ });
248
+ }
249
+ }
250
+ if (actions.callback && !actions.items) {
251
+ for (const [id, handler] of this.actionHandlers) {
252
+ this.actionHandlers.set(id, async (actionId) => {
253
+ await actions.callback?.(actionId);
254
+ await handler(actionId);
255
+ });
256
+ }
257
+ }
258
+ }
259
+ }
260
+ function extFromMime(mime) {
261
+ const idx = mime.indexOf('/');
262
+ return idx === -1 ? 'bin' : mime.slice(idx + 1);
263
+ }
264
+ function toKeyboard(actions) {
265
+ const kb = new InlineKeyboard();
266
+ const columns = actions.columns ?? 2;
267
+ let col = 0;
268
+ for (const action of actions.items) {
269
+ kb.text(action.label, action.id);
270
+ col += 1;
271
+ if (col >= columns) {
272
+ kb.row();
273
+ col = 0;
274
+ }
275
+ }
276
+ return kb;
277
+ }
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@acp-router/adapter-telegram",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./lib/index.js",
6
+ "types": "./lib/index.d.ts",
7
+ "files": [
8
+ "lib"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc -p tsconfig.json"
12
+ },
13
+ "dependencies": {
14
+ "@acp-router/core": "0.1.0",
15
+ "grammy": "^1.35.0",
16
+ "markdown-it": "^14.1.1"
17
+ },
18
+ "devDependencies": {
19
+ "@types/markdown-it": "^14.1.2",
20
+ "typescript": "^5.9.3"
21
+ }
22
+ }