@llblab/pi-telegram 0.41.1 → 0.42.1

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.
@@ -153,63 +153,158 @@ export function parseTopLevelTelegramComment(
153
153
  };
154
154
  }
155
155
 
156
- function parseCanonicalTelegramActionAttributes(
156
+ function parseTolerantTelegramAttributes(
157
157
  source: string,
158
+ names: readonly string[],
158
159
  ): Record<string, string> | undefined {
159
160
  const attributes: Record<string, string> = {};
160
- const pattern = /\s*([A-Za-z_][A-Za-z0-9_-]*)="([^"]*)"/y;
161
- let offset = 0;
162
- while (offset < source.length) {
163
- pattern.lastIndex = offset;
164
- const match = pattern.exec(source);
165
- if (!match) return undefined;
166
- const value = match[2].trim();
167
- if (value) attributes[match[1]] = value;
168
- offset = pattern.lastIndex;
161
+ const namePattern = names.join("|");
162
+ const pattern = new RegExp(
163
+ `\\b(${namePattern})\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s]+))`,
164
+ "gu",
165
+ );
166
+ for (const match of source.matchAll(pattern)) {
167
+ const value = (match[2] ?? match[3] ?? match[4] ?? "").trim();
168
+ if (value) attributes[match[1]!] = value;
169
169
  }
170
170
  return Object.keys(attributes).length > 0 ? attributes : undefined;
171
171
  }
172
172
 
173
- function getTelegramActionPayloadSource(
174
- comment: TelegramTopLevelHtmlComment,
175
- command: string,
176
- ): { source: string; hasBody: boolean } | undefined {
177
- const parsed = parseTopLevelTelegramComment(comment, command);
178
- if (!parsed || parsed.head.trimStart().startsWith(":")) return undefined;
179
- const source = [parsed.head, parsed.body]
180
- .filter((part): part is string => part !== undefined)
181
- .join("\n")
182
- .trim();
183
- return source ? { source, hasBody: parsed.body !== undefined } : undefined;
184
- }
185
-
186
173
  function isTelegramActionPayload(value: unknown): value is Record<string, unknown> {
187
174
  return value !== null && typeof value === "object" && !Array.isArray(value);
188
175
  }
189
176
 
177
+ function removeTelegramJsonTrailingCommas(source: string): string {
178
+ let normalized = "";
179
+ let inString = false;
180
+ let escaped = false;
181
+ for (let offset = 0; offset < source.length; offset += 1) {
182
+ const character = source[offset]!;
183
+ if (inString) {
184
+ normalized += character;
185
+ if (escaped) escaped = false;
186
+ else if (character === "\\") escaped = true;
187
+ else if (character === '"') inString = false;
188
+ continue;
189
+ }
190
+ if (character === '"') {
191
+ inString = true;
192
+ normalized += character;
193
+ continue;
194
+ }
195
+ if (character === ",") {
196
+ let next = offset + 1;
197
+ while (/\s/u.test(source[next] ?? "")) next += 1;
198
+ if (source[next] === "}" || source[next] === "]") continue;
199
+ }
200
+ normalized += character;
201
+ }
202
+ return normalized;
203
+ }
204
+
205
+ function parseTelegramJsonObjectCandidate(
206
+ source: string,
207
+ ): Record<string, unknown> | undefined {
208
+ const normalized = removeTelegramJsonTrailingCommas(source);
209
+ for (const candidate of normalized === source ? [source] : [source, normalized]) {
210
+ try {
211
+ const value: unknown = JSON.parse(candidate);
212
+ if (isTelegramActionPayload(value)) return value;
213
+ } catch {
214
+ // Try the bounded trailing-comma normalization before rejecting JSON.
215
+ }
216
+ }
217
+ return undefined;
218
+ }
219
+
220
+ function looksLikeTelegramNamedJsonObject(
221
+ source: string,
222
+ offset: number,
223
+ ): boolean {
224
+ return /^\{\s*"(?:[^"\\]|\\.)*"\s*:/u.test(source.slice(offset));
225
+ }
226
+
190
227
  export function parseTelegramActionPayload(
191
228
  comment: TelegramTopLevelHtmlComment,
192
229
  command: string,
193
230
  ): Record<string, unknown> | undefined {
194
- const payload = getTelegramActionPayloadSource(comment, command);
195
- if (!payload) return undefined;
196
- if (payload.source.startsWith("{")) {
197
- try {
198
- const value: unknown = JSON.parse(payload.source);
199
- return isTelegramActionPayload(value) ? value : undefined;
200
- } catch {
201
- return undefined;
231
+ let content = comment.content.replace(/^\s+/, "").replace(/^!/, "");
232
+ if (!content.startsWith(command)) return undefined;
233
+ content = content.slice(command.length);
234
+ let attributeEnvelope = content;
235
+ for (let offset = 0; offset < content.length; offset += 1) {
236
+ if (content[offset] !== "{" && content[offset] !== "[") continue;
237
+ if (
238
+ content[offset] === "[" &&
239
+ !isPlausibleTelegramMatrixStart(content, offset)
240
+ ) {
241
+ const noiseEnd = findTelegramStructuredPayloadEnd(content, offset);
242
+ if (noiseEnd !== undefined) {
243
+ attributeEnvelope = `${attributeEnvelope.slice(0, offset)}${" ".repeat(noiseEnd - offset)}${attributeEnvelope.slice(noiseEnd)}`;
244
+ offset = noiseEnd - 1;
245
+ }
246
+ continue;
247
+ }
248
+ if (content[offset] === "{") {
249
+ const parsed = parseTelegramAdaptiveActionPayloadRows(
250
+ content.slice(offset),
251
+ parseTelegramVoiceCompactActionPayload,
252
+ { allowTrailing: true },
253
+ );
254
+ if (parsed) return parsed.rows[0]![0];
202
255
  }
256
+ const end = findTelegramStructuredPayloadEnd(content, offset);
257
+ if (end === undefined) continue;
258
+ attributeEnvelope = `${attributeEnvelope.slice(0, offset)}${" ".repeat(end - offset)}${attributeEnvelope.slice(end)}`;
259
+ offset = end - 1;
203
260
  }
204
- if (payload.hasBody) return undefined;
205
- return parseCanonicalTelegramActionAttributes(payload.source);
261
+ return parseTolerantTelegramAttributes(attributeEnvelope, [
262
+ "text",
263
+ "value",
264
+ "lang",
265
+ "rate",
266
+ ]);
206
267
  }
207
268
 
208
269
  const TELEGRAM_COMPACT_ACTION_CONTROL_PATTERN = /[\u0000-\u001f\u007f-\u009f]/u;
209
270
 
271
+ type TelegramCompactActionPayloadParser = (
272
+ atoms: readonly string[],
273
+ ) => Record<string, unknown> | undefined;
274
+
275
+ function parseTelegramButtonCompactActionPayload(
276
+ atoms: readonly string[],
277
+ ): Record<string, unknown> | undefined {
278
+ const [label, prompt, selectedStyle] = atoms;
279
+ if (atoms.length === 1) return label ? { value: label } : undefined;
280
+ if (!prompt) return undefined;
281
+ const action = label ? { label, prompt } : { prompt };
282
+ if (atoms.length === 2) return action;
283
+ if (
284
+ selectedStyle !== "primary" &&
285
+ selectedStyle !== "success" &&
286
+ selectedStyle !== "danger"
287
+ ) return undefined;
288
+ return { ...action, selected_style: selectedStyle };
289
+ }
290
+
291
+ function parseTelegramVoiceCompactActionPayload(
292
+ atoms: readonly string[],
293
+ ): Record<string, unknown> | undefined {
294
+ const [text, lang, rate] = atoms;
295
+ if (!text) return undefined;
296
+ if (atoms.length === 1) return { text };
297
+ if (!lang) return undefined;
298
+ if (atoms.length === 2) return { text, lang };
299
+ if (!rate) return undefined;
300
+ return { text, lang, rate };
301
+ }
302
+
210
303
  function parseTelegramAdaptiveActionPayloadRows(
211
304
  source: string,
212
- ): Record<string, unknown>[][] | undefined {
305
+ parseCompactPayload: TelegramCompactActionPayloadParser,
306
+ options: { allowTrailing?: boolean } = {},
307
+ ): { rows: Record<string, unknown>[][]; end: number } | undefined {
213
308
  let offset = 0;
214
309
  const isStructuralWhitespace = (character: string | undefined): boolean =>
215
310
  character === " " ||
@@ -224,11 +319,11 @@ function parseTelegramAdaptiveActionPayloadRows(
224
319
  if (source[offset] !== ",") return true;
225
320
  offset += 1;
226
321
  skipWhitespace();
227
- return source[offset] !== "," && source[offset] !== "]";
322
+ return source[offset] !== ",";
228
323
  };
229
324
  const normalizeAtom = (value: string): string | undefined => {
230
325
  const normalized = value.trim();
231
- return normalized && !TELEGRAM_COMPACT_ACTION_CONTROL_PATTERN.test(normalized)
326
+ return !TELEGRAM_COMPACT_ACTION_CONTROL_PATTERN.test(normalized)
232
327
  ? normalized
233
328
  : undefined;
234
329
  };
@@ -259,15 +354,7 @@ function parseTelegramAdaptiveActionPayloadRows(
259
354
  normalizeAtom(atom.join("")),
260
355
  );
261
356
  if (atoms.some((atom) => atom === undefined)) return undefined;
262
- const [label, prompt, selectedStyle] = atoms as string[];
263
- if (atoms.length === 1) return { value: label };
264
- if (atoms.length === 2) return { label, prompt };
265
- if (
266
- selectedStyle !== "primary" &&
267
- selectedStyle !== "success" &&
268
- selectedStyle !== "danger"
269
- ) return undefined;
270
- return { label, prompt, selected_style: selectedStyle };
357
+ return parseCompactPayload(atoms as string[]);
271
358
  }
272
359
  atomSources.at(-1)!.push(character);
273
360
  offset += 1;
@@ -304,14 +391,10 @@ function parseTelegramAdaptiveActionPayloadRows(
304
391
  ) return undefined;
305
392
  if (stack.length > 0) continue;
306
393
  const candidate = source.slice(start, index + 1);
307
- try {
308
- const value: unknown = JSON.parse(candidate);
309
- if (!isTelegramActionPayload(value)) return undefined;
310
- offset = index + 1;
311
- return value;
312
- } catch {
313
- return undefined;
314
- }
394
+ const value = parseTelegramJsonObjectCandidate(candidate);
395
+ if (!value) return undefined;
396
+ offset = index + 1;
397
+ return value;
315
398
  }
316
399
  return undefined;
317
400
  };
@@ -320,6 +403,7 @@ function parseTelegramAdaptiveActionPayloadRows(
320
403
  const jsonCell = parseJsonObjectCell();
321
404
  if (jsonCell) return jsonCell;
322
405
  offset = start;
406
+ if (looksLikeTelegramNamedJsonObject(source, start)) return undefined;
323
407
  return parseCompactCell();
324
408
  };
325
409
  const parseRow = (): Record<string, unknown>[] | undefined => {
@@ -377,42 +461,92 @@ function parseTelegramAdaptiveActionPayloadRows(
377
461
  }
378
462
  if (!rows) return undefined;
379
463
  skipWhitespace();
380
- return offset === source.length ? rows : undefined;
464
+ if (!options.allowTrailing && offset !== source.length) return undefined;
465
+ return { rows, end: offset };
466
+ }
467
+
468
+ function findTelegramStructuredPayloadEnd(
469
+ source: string,
470
+ start: number,
471
+ ): number | undefined {
472
+ const stack: string[] = [source[start]!];
473
+ let inString = false;
474
+ let escaped = false;
475
+ for (let offset = start + 1; offset < source.length; offset += 1) {
476
+ const character = source[offset]!;
477
+ if (inString) {
478
+ if (escaped) escaped = false;
479
+ else if (character === "\\") escaped = true;
480
+ else if (character === '"') inString = false;
481
+ continue;
482
+ }
483
+ if (character === '"') {
484
+ inString = true;
485
+ continue;
486
+ }
487
+ if (character === "[" || character === "{") {
488
+ stack.push(character);
489
+ continue;
490
+ }
491
+ if (character !== "]" && character !== "}") continue;
492
+ const expected = character === "]" ? "[" : "{";
493
+ if (stack.at(-1) === expected) stack.pop();
494
+ if (stack.length === 0) return offset + 1;
495
+ }
496
+ return undefined;
497
+ }
498
+
499
+ function isPlausibleTelegramMatrixStart(
500
+ source: string,
501
+ start: number,
502
+ ): boolean {
503
+ let offset = start + 1;
504
+ while (/\s/u.test(source[offset] ?? "")) offset += 1;
505
+ return (
506
+ source[offset] === "{" ||
507
+ source[offset] === "[" ||
508
+ source[offset] === "]"
509
+ );
381
510
  }
382
511
 
383
512
  export function parseTelegramActionPayloadRows(
384
513
  comment: TelegramTopLevelHtmlComment,
385
514
  command: string,
386
515
  ): Record<string, unknown>[][] | undefined {
387
- const payload = getTelegramActionPayloadSource(comment, command);
388
- if (!payload) return undefined;
389
- if (payload.source.startsWith("[") || payload.source.startsWith("{")) {
390
- try {
391
- const value: unknown = JSON.parse(payload.source);
392
- if (isTelegramActionPayload(value)) return [[value]];
393
- if (!Array.isArray(value)) return undefined;
394
- const rows: Record<string, unknown>[][] = [];
395
- for (const entry of value) {
396
- if (isTelegramActionPayload(entry)) {
397
- rows.push([entry]);
398
- continue;
399
- }
400
- if (
401
- !Array.isArray(entry) ||
402
- entry.length === 0 ||
403
- !entry.every(isTelegramActionPayload)
404
- ) {
405
- return undefined;
406
- }
407
- rows.push(entry);
516
+ let content = comment.content.replace(/^\s+/, "").replace(/^!/, "");
517
+ if (!content.startsWith(command)) return undefined;
518
+ content = content.slice(command.length);
519
+ let attributeEnvelope = content;
520
+ for (let offset = 0; offset < content.length; offset += 1) {
521
+ if (content[offset] !== "[" && content[offset] !== "{") continue;
522
+ if (
523
+ content[offset] === "[" &&
524
+ !isPlausibleTelegramMatrixStart(content, offset)
525
+ ) {
526
+ const noiseEnd = findTelegramStructuredPayloadEnd(content, offset);
527
+ if (noiseEnd !== undefined) {
528
+ attributeEnvelope = `${attributeEnvelope.slice(0, offset)}${" ".repeat(noiseEnd - offset)}${attributeEnvelope.slice(noiseEnd)}`;
529
+ offset = noiseEnd - 1;
408
530
  }
409
- return rows;
410
- } catch {
411
- return parseTelegramAdaptiveActionPayloadRows(payload.source);
531
+ continue;
412
532
  }
533
+ const parsed = parseTelegramAdaptiveActionPayloadRows(
534
+ content.slice(offset),
535
+ parseTelegramButtonCompactActionPayload,
536
+ { allowTrailing: true },
537
+ );
538
+ if (parsed) return parsed.rows;
539
+ const end = findTelegramStructuredPayloadEnd(content, offset);
540
+ if (end === undefined) continue;
541
+ attributeEnvelope = `${attributeEnvelope.slice(0, offset)}${" ".repeat(end - offset)}${attributeEnvelope.slice(end)}`;
542
+ offset = end - 1;
413
543
  }
414
- if (payload.hasBody) return undefined;
415
- const attributes = parseCanonicalTelegramActionAttributes(payload.source);
544
+ const attributes = parseTolerantTelegramAttributes(attributeEnvelope, [
545
+ "label",
546
+ "prompt",
547
+ "value",
548
+ "selected_style",
549
+ ]);
416
550
  return attributes ? [[attributes]] : undefined;
417
551
  }
418
552
 
@@ -481,9 +615,10 @@ export function planTelegramVoiceReply(
481
615
  let lang: string | undefined;
482
616
  let rate: string | undefined;
483
617
  const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
484
- const command = parseTopLevelTelegramComment(comment, "telegram_voice");
485
- if (!command) return comment.raw;
486
- const payload = parseTelegramActionPayload(comment, "telegram_voice");
618
+ const command = "telegram_voice";
619
+ const normalizedContent = comment.content.replace(/^\s+/, "").replace(/^!/, "");
620
+ if (!normalizedContent.startsWith(command)) return comment.raw;
621
+ const payload = parseTelegramActionPayload(comment, command);
487
622
  if (!payload) return "";
488
623
  const text =
489
624
  getTelegramActionString(payload, "text") ??
package/lib/routing.ts CHANGED
@@ -759,6 +759,42 @@ export function createTelegramInboundRouteRuntime<
759
759
  );
760
760
  }
761
761
  };
762
+ const resolveTelegramThreadLabel = (message: {
763
+ chat: { id: number };
764
+ message_thread_id?: number;
765
+ }): string | undefined => {
766
+ const chatId = message.chat.id;
767
+ const threadId = message.message_thread_id;
768
+ if (!threadId) return undefined;
769
+ const localLabel = deps.getLocalThreadLabelForTarget?.({ chatId, threadId });
770
+ if (localLabel) return localLabel;
771
+ if (!deps.threadStore) return undefined;
772
+ const records = deps.threadStore.list();
773
+ const currentInstanceId = deps.getCurrentInstanceId?.();
774
+ for (const record of records) {
775
+ if (
776
+ record.target.chatId !== chatId ||
777
+ record.target.threadId !== threadId
778
+ ) {
779
+ continue;
780
+ }
781
+ if (
782
+ currentInstanceId &&
783
+ record.instanceId &&
784
+ record.instanceId !== currentInstanceId
785
+ ) {
786
+ continue;
787
+ }
788
+ return record.threadName &&
789
+ Threads.isTelegramTopicThreadNameValidForSlot(
790
+ record.threadName,
791
+ record.slot,
792
+ )
793
+ ? record.threadName
794
+ : getRestoredThreadName(record, record.slot ?? "");
795
+ }
796
+ return undefined;
797
+ };
762
798
  const createAdmissionReceipts = (
763
799
  queueKind: Queue.TelegramQueueItemKind,
764
800
  sources: readonly unknown[],
@@ -1628,6 +1664,13 @@ export function createTelegramInboundRouteRuntime<
1628
1664
  replyToMessageId: messageId,
1629
1665
  queueOrder,
1630
1666
  action,
1667
+ telegramPrefix: Turns.createTelegramTurnPrefix({
1668
+ thread: resolveTelegramThreadLabel({
1669
+ chat: { id: chatId },
1670
+ message_thread_id:
1671
+ buttonQuery.message?.message_thread_id,
1672
+ }),
1673
+ }),
1631
1674
  }),
1632
1675
  ...(admissionReceipts.length > 0 ? { admissionReceipts } : {}),
1633
1676
  };
@@ -1770,34 +1813,7 @@ export function createTelegramInboundRouteRuntime<
1770
1813
 
1771
1814
  // Voice policy resolves missing, invalid, and legacy manual config to hidden.
1772
1815
  getVoiceReplyMode: () => getTelegramVoiceReplyMode(deps.configStore.get()),
1773
- getTelegramThreadLabel(message) {
1774
- if (!deps.threadStore) return undefined;
1775
- const chatId = message.chat.id;
1776
- const threadId = message.message_thread_id;
1777
- if (!threadId) return undefined;
1778
- const localLabel = deps.getLocalThreadLabelForTarget?.({
1779
- chatId,
1780
- threadId,
1781
- });
1782
- if (localLabel) return localLabel;
1783
- const records = deps.threadStore.list();
1784
- const currentInstanceId = deps.getCurrentInstanceId?.();
1785
- for (const r of records) {
1786
- if (r.target.chatId !== chatId || r.target.threadId !== threadId)
1787
- continue;
1788
- if (
1789
- currentInstanceId &&
1790
- r.instanceId &&
1791
- r.instanceId !== currentInstanceId
1792
- )
1793
- continue;
1794
- return r.threadName &&
1795
- Threads.isTelegramTopicThreadNameValidForSlot(r.threadName, r.slot)
1796
- ? r.threadName
1797
- : getRestoredThreadName(r, r.slot ?? "");
1798
- }
1799
- return undefined;
1800
- },
1816
+ getTelegramThreadLabel: resolveTelegramThreadLabel,
1801
1817
  });
1802
1818
  const enqueueContinueTurn = async (
1803
1819
  message: TMessage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.41.1",
3
+ "version": "0.42.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"