lazy-gravity 0.2.0 → 0.3.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.
Files changed (56) hide show
  1. package/README.md +76 -15
  2. package/dist/bin/commands/doctor.js +19 -2
  3. package/dist/bin/commands/setup.js +286 -70
  4. package/dist/bot/eventRouter.js +70 -0
  5. package/dist/bot/index.js +353 -147
  6. package/dist/bot/telegramCommands.js +428 -0
  7. package/dist/bot/telegramMessageHandler.js +304 -0
  8. package/dist/bot/telegramProjectCommand.js +137 -0
  9. package/dist/bot/workspaceQueue.js +61 -0
  10. package/dist/commands/joinCommandHandler.js +4 -1
  11. package/dist/database/telegramBindingRepository.js +97 -0
  12. package/dist/database/userPreferenceRepository.js +46 -1
  13. package/dist/events/interactionCreateHandler.js +36 -0
  14. package/dist/events/messageCreateHandler.js +11 -7
  15. package/dist/handlers/approvalButtonAction.js +99 -0
  16. package/dist/handlers/autoAcceptButtonAction.js +43 -0
  17. package/dist/handlers/buttonHandler.js +55 -0
  18. package/dist/handlers/commandHandler.js +44 -0
  19. package/dist/handlers/errorPopupButtonAction.js +137 -0
  20. package/dist/handlers/messageHandler.js +70 -0
  21. package/dist/handlers/modeSelectAction.js +63 -0
  22. package/dist/handlers/modelButtonAction.js +102 -0
  23. package/dist/handlers/planningButtonAction.js +118 -0
  24. package/dist/handlers/selectHandler.js +41 -0
  25. package/dist/handlers/templateButtonAction.js +54 -0
  26. package/dist/platform/adapter.js +8 -0
  27. package/dist/platform/discord/discordAdapter.js +99 -0
  28. package/dist/platform/discord/index.js +15 -0
  29. package/dist/platform/discord/wrappers.js +331 -0
  30. package/dist/platform/index.js +18 -0
  31. package/dist/platform/richContentBuilder.js +76 -0
  32. package/dist/platform/telegram/index.js +16 -0
  33. package/dist/platform/telegram/telegramAdapter.js +195 -0
  34. package/dist/platform/telegram/telegramFormatter.js +134 -0
  35. package/dist/platform/telegram/wrappers.js +329 -0
  36. package/dist/platform/types.js +28 -0
  37. package/dist/services/approvalDetector.js +15 -2
  38. package/dist/services/cdpBridgeManager.js +91 -146
  39. package/dist/services/defaultModelApplicator.js +54 -0
  40. package/dist/services/modeService.js +16 -1
  41. package/dist/services/modelService.js +57 -16
  42. package/dist/services/notificationSender.js +149 -0
  43. package/dist/services/responseMonitor.js +1 -2
  44. package/dist/ui/autoAcceptUi.js +37 -0
  45. package/dist/ui/modeUi.js +38 -1
  46. package/dist/ui/modelsUi.js +96 -0
  47. package/dist/ui/outputUi.js +32 -0
  48. package/dist/ui/projectListUi.js +55 -0
  49. package/dist/ui/screenshotUi.js +26 -0
  50. package/dist/ui/sessionPickerUi.js +35 -1
  51. package/dist/ui/templateUi.js +41 -0
  52. package/dist/utils/configLoader.js +63 -12
  53. package/dist/utils/lockfile.js +5 -5
  54. package/dist/utils/logger.js +7 -0
  55. package/dist/utils/telegramImageHandler.js +127 -0
  56. package/package.json +4 -2
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ /**
3
+ * Telegram HTML formatter.
4
+ *
5
+ * Converts markdown-like text and RichContent to Telegram-compatible HTML.
6
+ * Telegram supports a subset of HTML tags: <b>, <i>, <code>, <pre>, <s>,
7
+ * <a href="...">, and a few others.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.escapeHtml = escapeHtml;
11
+ exports.markdownToTelegramHtml = markdownToTelegramHtml;
12
+ exports.richContentToHtml = richContentToHtml;
13
+ // ---------------------------------------------------------------------------
14
+ // HTML escaping
15
+ // ---------------------------------------------------------------------------
16
+ /** Escape characters that are special in HTML. */
17
+ function escapeHtml(text) {
18
+ return text
19
+ .replace(/&/g, '&amp;')
20
+ .replace(/</g, '&lt;')
21
+ .replace(/>/g, '&gt;')
22
+ .replace(/"/g, '&quot;')
23
+ .replace(/'/g, '&#x27;');
24
+ }
25
+ // ---------------------------------------------------------------------------
26
+ // Markdown -> Telegram HTML
27
+ // ---------------------------------------------------------------------------
28
+ /**
29
+ * Convert a limited subset of Markdown to Telegram HTML.
30
+ *
31
+ * Supported conversions:
32
+ * - `**bold**` -> `<b>bold</b>`
33
+ * - `*italic*` -> `<i>italic</i>` (only outside ** pairs)
34
+ * - `` `code` `` -> `<code>code</code>`
35
+ * - ` ```block``` ` -> `<pre>block</pre>`
36
+ * - `~~strike~~` -> `<s>strike</s>`
37
+ * - `[text](url)` -> `<a href="url">text</a>`
38
+ *
39
+ * Text outside these patterns is HTML-escaped.
40
+ */
41
+ function markdownToTelegramHtml(text) {
42
+ // Process code blocks first (``` ... ```) to avoid inner transformations
43
+ let result = text.replace(/```(?:\w*\n)?([\s\S]*?)```/g, (_match, code) => `<pre>${escapeHtml(code.trim())}</pre>`);
44
+ // Markdown headings (# ... ######) -> bold text
45
+ // Telegram has no heading tags, so we convert to <b>bold</b>
46
+ result = result.replace(/^(#{1,6})\s+(.+)$/gm, (_match, _hashes, content) => `\n<b>${content}</b>`);
47
+ // Inline code (`...`)
48
+ result = result.replace(/`([^`]+)`/g, (_match, code) => `<code>${escapeHtml(code)}</code>`);
49
+ // Links [text](url) - must be processed before other inline markup
50
+ result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, linkText, url) => `<a href="${escapeHtml(url)}">${escapeHtml(linkText)}</a>`);
51
+ // Bold **text** (must come before italic)
52
+ //
53
+ // HTML escaping note: Content inside bold/italic/strikethrough is NOT
54
+ // escaped here. Earlier regex passes (code blocks, inline code, links)
55
+ // have already replaced their matched text with HTML tags (e.g.
56
+ // <code>...</code>). Since the bold regex `.+?` can span text that
57
+ // includes those prior HTML outputs, calling escapeHtml() would
58
+ // double-escape them (e.g. &lt;code&gt;).
59
+ //
60
+ // This is safe because Telegram's Bot API HTML parser rejects unknown
61
+ // tags with a parse error rather than executing them, so raw `<` / `>`
62
+ // in user text will cause a Telegram API error, not an XSS vector.
63
+ result = result.replace(/\*\*(.+?)\*\*/g, (_match, content) => `<b>${content}</b>`);
64
+ // Italic *text* (single asterisk, not inside bold)
65
+ // Same HTML escaping rationale as bold above.
66
+ result = result.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, (_match, content) => `<i>${content}</i>`);
67
+ // Strikethrough ~~text~~
68
+ // Same HTML escaping rationale as bold above.
69
+ result = result.replace(/~~(.+?)~~/g, (_match, content) => `<s>${content}</s>`);
70
+ return result;
71
+ }
72
+ // ---------------------------------------------------------------------------
73
+ // RichContent -> Telegram HTML
74
+ // ---------------------------------------------------------------------------
75
+ /**
76
+ * Format a single field for Telegram display.
77
+ */
78
+ function formatField(field) {
79
+ const escapedName = escapeHtml(field.name);
80
+ const convertedValue = markdownToTelegramHtml(field.value);
81
+ return `<b>${escapedName}:</b> ${convertedValue}`;
82
+ }
83
+ /**
84
+ * Group fields into inline groups and standalone fields.
85
+ * Consecutive inline fields are joined with " | ".
86
+ */
87
+ function formatFields(fields) {
88
+ const parts = [];
89
+ let inlineGroup = [];
90
+ for (const field of fields) {
91
+ if (field.inline) {
92
+ inlineGroup = [...inlineGroup, formatField(field)];
93
+ }
94
+ else {
95
+ if (inlineGroup.length > 0) {
96
+ parts.push(inlineGroup.join(' | '));
97
+ inlineGroup = [];
98
+ }
99
+ parts.push(formatField(field));
100
+ }
101
+ }
102
+ // Flush any remaining inline group
103
+ if (inlineGroup.length > 0) {
104
+ parts.push(inlineGroup.join(' | '));
105
+ }
106
+ return parts.join('\n');
107
+ }
108
+ /**
109
+ * Convert a RichContent object to a single Telegram HTML string.
110
+ *
111
+ * Layout:
112
+ * <b>title</b>\n\n
113
+ * description (markdown-converted)
114
+ * \n<b>fieldName:</b> fieldValue (inline fields separated by " | ")
115
+ * \n\n<i>footer</i>
116
+ */
117
+ function richContentToHtml(rc) {
118
+ const sections = [];
119
+ if (rc.title) {
120
+ sections.push(`<b>${escapeHtml(rc.title)}</b>`);
121
+ }
122
+ if (rc.description) {
123
+ sections.push(markdownToTelegramHtml(rc.description));
124
+ }
125
+ if (rc.fields && rc.fields.length > 0) {
126
+ sections.push(formatFields(rc.fields));
127
+ }
128
+ // Join title/description/fields with double newline, then append footer
129
+ let html = sections.join('\n\n');
130
+ if (rc.footer) {
131
+ html += `\n\n<i>${escapeHtml(rc.footer)}</i>`;
132
+ }
133
+ return html;
134
+ }
@@ -0,0 +1,329 @@
1
+ "use strict";
2
+ /**
3
+ * Telegram wrapper functions.
4
+ *
5
+ * Convert Telegram-specific objects to the platform-agnostic types defined
6
+ * in ../types.ts. Uses `TelegramBotLike` interface instead of importing
7
+ * grammy directly, so the code compiles without grammy installed.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.SELECT_CALLBACK_SEP = void 0;
11
+ exports.toTelegramPayload = toTelegramPayload;
12
+ exports.wrapTelegramUser = wrapTelegramUser;
13
+ exports.wrapTelegramChannel = wrapTelegramChannel;
14
+ exports.wrapTelegramMessage = wrapTelegramMessage;
15
+ exports.wrapTelegramCallbackQuery = wrapTelegramCallbackQuery;
16
+ exports.wrapTelegramSentMessage = wrapTelegramSentMessage;
17
+ const telegramFormatter_1 = require("./telegramFormatter");
18
+ function buttonDefToInline(btn) {
19
+ return { text: btn.label, callback_data: btn.customId };
20
+ }
21
+ /**
22
+ * Separator for select menu callback_data: customId + SEP + value.
23
+ * Uses ASCII Unit Separator (0x1F) to avoid collisions with button
24
+ * customIds that legitimately contain colons (e.g. "approve_action:proj:ch").
25
+ */
26
+ exports.SELECT_CALLBACK_SEP = '\x1f';
27
+ function selectMenuToInlineRows(menu) {
28
+ return menu.options.map((opt) => [
29
+ { text: opt.label, callback_data: `${menu.customId}${exports.SELECT_CALLBACK_SEP}${opt.value}` },
30
+ ]);
31
+ }
32
+ function componentRowsToInlineKeyboard(rows) {
33
+ const keyboard = [];
34
+ for (const row of rows) {
35
+ let buttons = [];
36
+ for (const comp of row.components) {
37
+ if (comp.type === 'button') {
38
+ buttons = [...buttons, buttonDefToInline(comp)];
39
+ }
40
+ else if (comp.type === 'selectMenu') {
41
+ // A select menu becomes multiple rows (one per option)
42
+ const menuRows = selectMenuToInlineRows(comp);
43
+ // Flush any accumulated buttons first
44
+ if (buttons.length > 0) {
45
+ keyboard.push([...buttons]);
46
+ buttons = [];
47
+ }
48
+ for (const menuRow of menuRows) {
49
+ keyboard.push(menuRow);
50
+ }
51
+ }
52
+ }
53
+ if (buttons.length > 0) {
54
+ keyboard.push(buttons);
55
+ }
56
+ }
57
+ return keyboard;
58
+ }
59
+ // ---------------------------------------------------------------------------
60
+ // toTelegramPayload
61
+ // ---------------------------------------------------------------------------
62
+ /**
63
+ * Convert a platform-agnostic MessagePayload to Telegram send options.
64
+ *
65
+ * - RichContent is rendered to HTML via richContentToHtml
66
+ * - ComponentRow[] become inline_keyboard
67
+ * - text + richContent are combined into one HTML message
68
+ */
69
+ function toTelegramPayload(payload) {
70
+ const parts = [];
71
+ if (payload.text) {
72
+ parts.push((0, telegramFormatter_1.markdownToTelegramHtml)(payload.text));
73
+ }
74
+ if (payload.richContent) {
75
+ parts.push((0, telegramFormatter_1.richContentToHtml)(payload.richContent));
76
+ }
77
+ const text = parts.join('\n\n') || ' ';
78
+ const options = {
79
+ text,
80
+ parse_mode: 'HTML',
81
+ };
82
+ if (payload.components !== undefined) {
83
+ if (payload.components.length > 0) {
84
+ const keyboard = componentRowsToInlineKeyboard(payload.components);
85
+ if (keyboard.length > 0) {
86
+ return {
87
+ ...options,
88
+ reply_markup: { inline_keyboard: keyboard },
89
+ };
90
+ }
91
+ }
92
+ // Explicitly empty components array => remove existing keyboard
93
+ return {
94
+ ...options,
95
+ reply_markup: { inline_keyboard: [] },
96
+ };
97
+ }
98
+ return options;
99
+ }
100
+ // ---------------------------------------------------------------------------
101
+ // Entity wrappers
102
+ // ---------------------------------------------------------------------------
103
+ /** Wrap a Telegram user object to a PlatformUser. */
104
+ function wrapTelegramUser(from) {
105
+ const displayParts = [from.first_name];
106
+ if (from.last_name) {
107
+ displayParts.push(from.last_name);
108
+ }
109
+ return {
110
+ id: String(from.id),
111
+ platform: 'telegram',
112
+ username: from.username ?? String(from.id),
113
+ displayName: displayParts.join(' '),
114
+ isBot: from.is_bot,
115
+ };
116
+ }
117
+ /**
118
+ * Try to send a file attachment via Telegram photo/document API.
119
+ * Returns the sent message, or null if file sending is not available.
120
+ *
121
+ * @param toInputFile - Optional converter that wraps Buffer for the Telegram API.
122
+ * grammY requires Buffer wrapped in InputFile; pass `bot.toInputFile` here.
123
+ */
124
+ async function trySendFile(api, chatId, file, caption, extraOptions, toInputFile) {
125
+ const isImage = file.contentType?.startsWith('image/') || file.name.match(/\.(png|jpe?g|gif|webp)$/i);
126
+ // grammY requires Buffer wrapped in InputFile; use toInputFile if available.
127
+ const inputFile = toInputFile ? toInputFile(file.data, file.name) : file.data;
128
+ if (isImage && api.sendPhoto) {
129
+ return api.sendPhoto(chatId, inputFile, {
130
+ caption,
131
+ parse_mode: caption ? 'HTML' : undefined,
132
+ ...extraOptions,
133
+ });
134
+ }
135
+ if (api.sendDocument) {
136
+ return api.sendDocument(chatId, inputFile, {
137
+ caption,
138
+ parse_mode: caption ? 'HTML' : undefined,
139
+ ...extraOptions,
140
+ });
141
+ }
142
+ return null;
143
+ }
144
+ /** Wrap a Telegram chat as a PlatformChannel. */
145
+ function wrapTelegramChannel(api, chatId, toInputFile) {
146
+ const chatIdStr = String(chatId);
147
+ return {
148
+ id: chatIdStr,
149
+ platform: 'telegram',
150
+ name: undefined,
151
+ async send(payload) {
152
+ // Handle file attachments (e.g., screenshots)
153
+ if (payload.files && payload.files.length > 0) {
154
+ const file = payload.files[0];
155
+ const opts = payload.text || payload.richContent
156
+ ? toTelegramPayload({ text: payload.text, richContent: payload.richContent })
157
+ : null;
158
+ const caption = opts?.text;
159
+ const sent = await trySendFile(api, chatId, file, caption, undefined, toInputFile);
160
+ if (sent) {
161
+ return wrapTelegramSentMessage(sent, api, chatId);
162
+ }
163
+ // Fallback to text-only if file sending not supported
164
+ }
165
+ const opts = toTelegramPayload(payload);
166
+ const { text, ...rest } = opts;
167
+ const sent = await api.sendMessage(chatId, text, rest);
168
+ return wrapTelegramSentMessage(sent, api, chatId);
169
+ },
170
+ };
171
+ }
172
+ /**
173
+ * Build PlatformAttachment[] from a Telegram photo message.
174
+ * Uses the largest photo size (last in the array) and constructs
175
+ * the download URL from the bot token and file_id.
176
+ */
177
+ function buildPhotoAttachments(photo, botToken) {
178
+ if (photo.length === 0)
179
+ return [];
180
+ // Telegram sends multiple sizes; last is the largest
181
+ const largest = photo[photo.length - 1];
182
+ // URL is constructed later during download via getFile API.
183
+ // Store file_id as the URL so the download utility can resolve it.
184
+ const url = botToken
185
+ ? `telegram-file://${largest.file_id}`
186
+ : `telegram-file://${largest.file_id}`;
187
+ return [{
188
+ name: `photo-${largest.file_unique_id}.jpg`,
189
+ contentType: 'image/jpeg',
190
+ url,
191
+ size: largest.file_size ?? 0,
192
+ }];
193
+ }
194
+ /** Wrap a Telegram message as a PlatformMessage. */
195
+ function wrapTelegramMessage(msg, api, toInputFile, botToken) {
196
+ const author = msg.from
197
+ ? wrapTelegramUser(msg.from)
198
+ : {
199
+ id: '0',
200
+ platform: 'telegram',
201
+ username: 'unknown',
202
+ displayName: 'Unknown',
203
+ isBot: false,
204
+ };
205
+ const channel = wrapTelegramChannel(api, msg.chat.id, toInputFile);
206
+ // Photo messages: use caption as content, build attachments from photo array
207
+ const content = msg.text ?? msg.caption ?? '';
208
+ const attachments = msg.photo
209
+ ? buildPhotoAttachments(msg.photo, botToken)
210
+ : [];
211
+ return {
212
+ id: String(msg.message_id),
213
+ platform: 'telegram',
214
+ content,
215
+ author,
216
+ channel,
217
+ attachments,
218
+ createdAt: new Date(msg.date * 1000),
219
+ async react(emoji) {
220
+ // Telegram Bot API 7.0+ setMessageReaction — limited to 79 emoji.
221
+ // Silently ignore failures (unsupported emoji, old API, etc.).
222
+ if (api.setMessageReaction) {
223
+ await api.setMessageReaction(msg.chat.id, msg.message_id, [{ type: 'emoji', emoji }]).catch(() => { });
224
+ }
225
+ },
226
+ async reply(payload) {
227
+ // Handle file attachments (e.g., screenshots)
228
+ if (payload.files && payload.files.length > 0) {
229
+ const file = payload.files[0];
230
+ const opts = payload.text || payload.richContent
231
+ ? toTelegramPayload({ text: payload.text, richContent: payload.richContent })
232
+ : null;
233
+ const caption = opts?.text;
234
+ const sent = await trySendFile(api, msg.chat.id, file, caption, { reply_to_message_id: msg.message_id }, toInputFile);
235
+ if (sent) {
236
+ return wrapTelegramSentMessage(sent, api, msg.chat.id);
237
+ }
238
+ // Fallback to text-only if file sending not supported
239
+ }
240
+ const opts = toTelegramPayload(payload);
241
+ const { text, ...rest } = opts;
242
+ const sent = await api.sendMessage(msg.chat.id, text, {
243
+ ...rest,
244
+ reply_to_message_id: msg.message_id,
245
+ });
246
+ return wrapTelegramSentMessage(sent, api, msg.chat.id);
247
+ },
248
+ };
249
+ }
250
+ /**
251
+ * Validate that a chatId is usable for sending messages.
252
+ * Throws a descriptive error if the chatId is synthetic (0).
253
+ */
254
+ function assertValidChatId(chatId) {
255
+ if (chatId === 0 || chatId === '0') {
256
+ throw new Error('Cannot send message: callback query has no associated chat (chatId is 0). ' +
257
+ 'Use answerCallbackQuery instead.');
258
+ }
259
+ }
260
+ /** Wrap a Telegram callback query as a PlatformButtonInteraction. */
261
+ function wrapTelegramCallbackQuery(query, api) {
262
+ const user = wrapTelegramUser(query.from);
263
+ const chatId = query.message?.chat.id ?? 0;
264
+ const channel = wrapTelegramChannel(api, chatId);
265
+ const messageId = query.message ? String(query.message.message_id) : '0';
266
+ const callbackQueryId = query.id;
267
+ return {
268
+ id: query.id,
269
+ platform: 'telegram',
270
+ customId: query.data ?? '',
271
+ user,
272
+ channel,
273
+ messageId,
274
+ async deferUpdate() {
275
+ // Acknowledge the callback query to dismiss the loading indicator
276
+ await api.answerCallbackQuery(callbackQueryId);
277
+ },
278
+ async reply(payload) {
279
+ assertValidChatId(chatId);
280
+ const opts = toTelegramPayload(payload);
281
+ const { text, ...rest } = opts;
282
+ await api.sendMessage(chatId, text, rest);
283
+ },
284
+ async update(payload) {
285
+ if (!query.message)
286
+ return;
287
+ assertValidChatId(chatId);
288
+ const opts = toTelegramPayload(payload);
289
+ const { text, ...rest } = opts;
290
+ await api.editMessageText(chatId, query.message.message_id, text, rest);
291
+ },
292
+ async editReply(payload) {
293
+ if (!query.message)
294
+ return;
295
+ assertValidChatId(chatId);
296
+ const opts = toTelegramPayload(payload);
297
+ const { text, ...rest } = opts;
298
+ await api.editMessageText(chatId, query.message.message_id, text, rest);
299
+ },
300
+ async followUp(payload) {
301
+ assertValidChatId(chatId);
302
+ const opts = toTelegramPayload(payload);
303
+ const { text, ...rest } = opts;
304
+ const sent = await api.sendMessage(chatId, text, rest);
305
+ return wrapTelegramSentMessage(sent, api, chatId);
306
+ },
307
+ };
308
+ }
309
+ // ---------------------------------------------------------------------------
310
+ // Sent message wrapper
311
+ // ---------------------------------------------------------------------------
312
+ /** Wrap a Telegram API send result as a PlatformSentMessage. */
313
+ function wrapTelegramSentMessage(msg, api, chatId) {
314
+ const msgId = String(msg.message_id ?? msg.id ?? '0');
315
+ return {
316
+ id: msgId,
317
+ platform: 'telegram',
318
+ channelId: String(chatId),
319
+ async edit(payload) {
320
+ const opts = toTelegramPayload(payload);
321
+ const { text, ...rest } = opts;
322
+ const edited = await api.editMessageText(chatId, Number(msgId), text, rest);
323
+ return wrapTelegramSentMessage(edited, api, chatId);
324
+ },
325
+ async delete() {
326
+ await api.deleteMessage(chatId, Number(msgId));
327
+ },
328
+ };
329
+ }
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ /**
3
+ * Platform abstraction types for multi-platform support.
4
+ *
5
+ * These types provide a common interface layer between Discord, Telegram,
6
+ * and future messaging platforms. All platform-specific code should convert
7
+ * to/from these types at the adapter boundary.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.toPlatformKey = toPlatformKey;
11
+ exports.fromPlatformKey = fromPlatformKey;
12
+ /** Encode a PlatformId to a "platform:id" string key. */
13
+ function toPlatformKey(pid) {
14
+ return `${pid.platform}:${pid.id}`;
15
+ }
16
+ /** Decode a "platform:id" string key to a PlatformId. Returns null on invalid input. */
17
+ function fromPlatformKey(key) {
18
+ const idx = key.indexOf(':');
19
+ if (idx <= 0)
20
+ return null;
21
+ const platform = key.slice(0, idx);
22
+ if (platform !== 'discord' && platform !== 'telegram')
23
+ return null;
24
+ const id = key.slice(idx + 1);
25
+ if (!id)
26
+ return null;
27
+ return { platform: platform, id };
28
+ }
@@ -192,7 +192,13 @@ function buildClickScript(buttonText) {
192
192
  ariaLabel.includes(wanted);
193
193
  });
194
194
  if (!target) return { ok: false, error: 'Button not found: ' + text };
195
- target.click();
195
+ const rect = target.getBoundingClientRect();
196
+ const x = rect.left + rect.width / 2;
197
+ const y = rect.top + rect.height / 2;
198
+ const eventInit = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y };
199
+ for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
200
+ target.dispatchEvent(new PointerEvent(type, { ...eventInit, pointerId: 1 }));
201
+ }
196
202
  return { ok: true };
197
203
  })()`;
198
204
  }
@@ -359,7 +365,14 @@ class ApprovalDetector {
359
365
  */
360
366
  async clickButton(buttonText) {
361
367
  try {
362
- const result = await this.runEvaluateScript(buildClickScript(buttonText));
368
+ const script = buildClickScript(buttonText);
369
+ const result = await this.runEvaluateScript(script);
370
+ if (result?.ok !== true) {
371
+ logger_1.logger.warn(`[ApprovalDetector] Click failed for "${buttonText}":`, result?.error ?? 'unknown');
372
+ }
373
+ else {
374
+ logger_1.logger.debug(`[ApprovalDetector] Click OK for "${buttonText}"`);
375
+ }
363
376
  return result?.ok === true;
364
377
  }
365
378
  catch (error) {