Files
oh-my-pi-telegram/lib/outbound-markup.ts
Evandro Fonseca Junior d1d04001d0
Release / release (push) Failing after 5s
Validate / validate (push) Failing after 5m21s
chore: fork @llblab/[email protected] as @evandrodevbr/oh-my-pi-telegram
Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a
drop-in extension for can1357/oh-my-pi (omp >= 16.5.2).

Changes vs upstream:
- Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram
- Bump version: 0.22.0 -> 0.22.0-evandro.1
- Update repository/homepage/bugs URLs to evandrodevbr
- Add omp field in package.json (displayName + compatibleWith)
- README: prepend 'Oh My Pi (omp) Compatibility' section with install
  commands, verified-components table, upstream lineage, and rationale
- CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased
- docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram
- tests/public-api.test.ts: rename package import paths

Gates re-run on the fork:
- npm run typecheck  -> tsc --noEmit (success)
- npm test           -> 1229 pass / 0 fail / 1 platform-only skip
- npm run audit      -> found 0 vulnerabilities
- npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz

Verified:
- omp install --link ./pi-telegram -> Linked OK
- omp plugin list -> @evandrodevbr/[email protected].0-evandro.1

Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
2026-07-15 14:18:16 -03:00

393 lines
12 KiB
TypeScript

/**
* Telegram outbound markup parsing helpers
* Zones: telegram outbound, assistant markup
* Owns top-level assistant action comment extraction, attribute parsing, and markup stripping shared by voice and outbound delivery
*/
export interface TelegramTopLevelHtmlComment {
raw: string;
content: string;
start: number;
end: number;
}
interface TelegramTopLevelFenceState {
marker: "`" | "~";
length: number;
}
function isTelegramActionCommentContent(content: string): boolean {
const normalizedContent = content.replace(/^\s+/, "");
const [head = ""] = normalizedContent.split(/\r?\n/, 1);
return ["telegram_voice", "telegram_button"].some((command) => {
if (!head.startsWith(command)) return false;
const nextChar = head[command.length];
return nextChar === undefined || /\s|:/.test(nextChar);
});
}
function getMarkdownLineEnd(markdown: string, offset: number): number {
const newlineIndex = markdown.indexOf("\n", offset);
return newlineIndex === -1 ? markdown.length : newlineIndex + 1;
}
function getMarkdownLineText(
markdown: string,
offset: number,
end: number,
): string {
return markdown.slice(offset, end).replace(/\r?\n$/, "");
}
function getTopLevelOpeningFence(
line: string,
): TelegramTopLevelFenceState | undefined {
const match = line.match(/^(?: {0,3})(`{3,}|~{3,})/);
const sequence = match?.[1];
if (!sequence) return undefined;
return {
marker: sequence[0] as "`" | "~",
length: sequence.length,
};
}
function isTopLevelClosingFence(
line: string,
fence: TelegramTopLevelFenceState,
): boolean {
const match = line.match(/^(?: {0,3})(`{3,}|~{3,})([ \t]*)$/);
const sequence = match?.[1];
return (
!!sequence &&
sequence[0] === fence.marker &&
sequence.length >= fence.length
);
}
function collectPairedTelegramVoiceActionBody(
markdown: string,
bodyStart: number,
commentContent: string,
): { content: string; end: number } | undefined {
const normalizedContent = commentContent.trim();
if (
!normalizedContent.startsWith("telegram_voice") ||
!isTelegramActionCommentContent(commentContent)
) {
return undefined;
}
let offset = bodyStart;
while (offset < markdown.length) {
const lineEnd = getMarkdownLineEnd(markdown, offset);
const line = getMarkdownLineText(markdown, offset, lineEnd);
if (line === "<!-- /telegram_voice -->") {
const body = markdown.slice(bodyStart, offset).trim();
if (!body) return undefined;
return {
content: `${commentContent.trimEnd()}\n${body}`,
end: lineEnd,
};
}
if (line.startsWith("<!--")) return undefined;
offset = lineEnd;
}
return undefined;
}
function collectInlineClosedTelegramActionBody(
markdown: string,
bodyStart: number,
commentContent: string,
): { content: string; end: number } | undefined {
const bodyLineEnd = getMarkdownLineEnd(markdown, bodyStart);
const bodyLine = getMarkdownLineText(markdown, bodyStart, bodyLineEnd);
const closeLineEnd = getMarkdownLineEnd(markdown, bodyLineEnd);
const closeLine = getMarkdownLineText(markdown, bodyLineEnd, closeLineEnd);
const hasRecoverableBody =
isTelegramActionCommentContent(commentContent) &&
bodyLine.trim() !== "" &&
!bodyLine.startsWith("<!--") &&
!bodyLine.startsWith("-->") &&
closeLine === "-->";
if (!hasRecoverableBody) return undefined;
return {
content: `${commentContent.trimEnd()}\n${bodyLine}`,
end: bodyLineEnd + 3,
};
}
export function collectTopLevelHtmlComments(markdown: string): {
comments: TelegramTopLevelHtmlComment[];
openCommentStart?: number;
} {
const comments: TelegramTopLevelHtmlComment[] = [];
let offset = 0;
let fence: TelegramTopLevelFenceState | undefined;
while (offset < markdown.length) {
const lineEnd = getMarkdownLineEnd(markdown, offset);
const line = getMarkdownLineText(markdown, offset, lineEnd);
if (fence) {
if (isTopLevelClosingFence(line, fence)) fence = undefined;
offset = lineEnd;
continue;
}
const nextFence = getTopLevelOpeningFence(line);
if (nextFence) {
fence = nextFence;
offset = lineEnd;
continue;
}
if (line.startsWith("<!--")) {
const closeIndex = markdown.indexOf("-->", offset + 4);
if (closeIndex === -1) return { comments, openCommentStart: offset };
let end = closeIndex + 3;
let raw = markdown.slice(offset, end);
let content = raw.slice(4, -3);
const closeColumn = closeIndex - offset;
const closesOnOpeningLine = closeIndex < lineEnd;
const hasOnlyWhitespaceAfterClose =
line.slice(closeColumn + 3).trim() === "";
const pairedVoiceBody =
closesOnOpeningLine && hasOnlyWhitespaceAfterClose
? collectPairedTelegramVoiceActionBody(markdown, lineEnd, content)
: undefined;
const inlineBody =
!pairedVoiceBody && closesOnOpeningLine && hasOnlyWhitespaceAfterClose
? collectInlineClosedTelegramActionBody(markdown, lineEnd, content)
: undefined;
const recoveredBody = pairedVoiceBody ?? inlineBody;
if (recoveredBody) {
end = recoveredBody.end;
raw = markdown.slice(offset, end);
content = recoveredBody.content;
}
comments.push({ raw, content, start: offset, end });
offset = getMarkdownLineEnd(markdown, end);
continue;
}
offset = lineEnd;
}
return { comments };
}
export function replaceTopLevelHtmlComments(
markdown: string,
replacer: (comment: TelegramTopLevelHtmlComment) => string,
): string {
const { comments } = collectTopLevelHtmlComments(markdown);
if (comments.length === 0) return markdown;
let result = "";
let offset = 0;
for (const comment of comments) {
result += markdown.slice(offset, comment.start);
result += replacer(comment);
offset = comment.end;
}
return result + markdown.slice(offset);
}
export function findTopLevelOpenOrPartialHtmlCommentIndex(
markdown: string,
): number {
const { openCommentStart } = collectTopLevelHtmlComments(markdown);
if (openCommentStart !== undefined) return openCommentStart;
let offset = 0;
let fence: TelegramTopLevelFenceState | undefined;
while (offset < markdown.length) {
const lineEnd = getMarkdownLineEnd(markdown, offset);
const line = getMarkdownLineText(markdown, offset, lineEnd);
const isLastLine = lineEnd >= markdown.length;
if (fence) {
if (isTopLevelClosingFence(line, fence)) fence = undefined;
offset = lineEnd;
continue;
}
const nextFence = getTopLevelOpeningFence(line);
if (nextFence) {
fence = nextFence;
offset = lineEnd;
continue;
}
if (isLastLine && (line === "<" || line === "<!" || line === "<!-")) {
return offset;
}
offset = lineEnd;
}
return -1;
}
export function parseTopLevelTelegramComment(
comment: TelegramTopLevelHtmlComment,
command: string,
): { head: string; body?: string } | undefined {
let normalizedContent = comment.content.replace(/^\s+/, "");
normalizedContent = normalizedContent.replace(/^!/, "");
const [rawHead = "", ...bodyLines] = normalizedContent.split(/\r?\n/);
let head = rawHead.trimStart();
if (!head.startsWith(command)) return undefined;
const nextChar = head[command.length];
if (nextChar !== undefined && !/\s|:/.test(nextChar)) return undefined;
return {
head: head.slice(command.length),
...(bodyLines.length > 0 ? { body: bodyLines.join("\n") } : {}),
};
}
export function parseTelegramCommentAttributes(
input: string,
): Record<string, string> {
const attributes: Record<string, string> = {};
for (const match of input.matchAll(
/([A-Za-z_][A-Za-z0-9_-]*)=(?:"([^"]*)"|'([^']*)'|(\S+))/g,
)) {
const key = match[1];
const value = (match[2] ?? match[3] ?? match[4] ?? "").trim();
if (value) attributes[key] = value;
}
return attributes;
}
export function normalizeMarkdownAfterVoiceExtraction(
markdown: string,
): string {
return markdown.replace(/\n{3,}/g, "\n\n").trim();
}
export function stripTelegramCommentMarkupForPreview(markdown: string): string {
const withoutClosedBlocks = replaceTopLevelHtmlComments(markdown, () => "");
const openBlockIndex =
findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
const previewMarkdown =
openBlockIndex >= 0
? withoutClosedBlocks.slice(0, openBlockIndex)
: withoutClosedBlocks;
return normalizeMarkdownAfterVoiceExtraction(previewMarkdown);
}
export function stripTelegramCommentMarkupForDelivery(
markdown: string,
): string {
const withoutClosedBlocks = replaceTopLevelHtmlComments(markdown, () => "");
const openBlockIndex =
findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
const deliveryMarkdown =
openBlockIndex >= 0
? withoutClosedBlocks.slice(0, openBlockIndex)
: withoutClosedBlocks;
return normalizeMarkdownAfterVoiceExtraction(deliveryMarkdown);
}
export function stripTelegramVoiceMarkupForPreview(markdown: string): string {
return stripTelegramCommentMarkupForPreview(markdown);
}
export interface TelegramVoiceReplyItem {
text: string;
lang?: string;
rate?: string;
}
export interface TelegramVoiceReplyPlan {
markdown: string;
voiceText?: string;
voiceReplies?: TelegramVoiceReplyItem[];
lang?: string;
rate?: string;
}
function parseVoiceReplyAttributes(input: string): {
lang?: string;
rate?: string;
text?: string;
} {
const attributes = parseTelegramCommentAttributes(input);
return {
...(attributes.lang ? { lang: attributes.lang } : {}),
...(attributes.rate ? { rate: attributes.rate } : {}),
...(attributes.text ? { text: attributes.text } : {}),
};
}
function parseVoiceCommentBody(
head: string,
body: string | undefined,
): {
attrs: string;
text: string;
} {
const trimmedHead = head.trim();
if (body !== undefined) {
return { attrs: trimmedHead.replace(/^:/, "").trim(), text: body.trim() };
}
let colonIndex = -1;
let inQuote = false;
let quoteChar = "";
for (let i = 0; i < trimmedHead.length; i++) {
const char = trimmedHead[i];
if (inQuote) {
if (char === quoteChar) inQuote = false;
} else {
if (char === '"' || char === "'") {
inQuote = true;
quoteChar = char;
} else if (char === ":") {
colonIndex = i;
break;
}
}
}
if (colonIndex > 0) {
const attrsPart = trimmedHead.slice(0, colonIndex).trim();
const textPart = trimmedHead.slice(colonIndex + 1).trim();
const attrs = parseVoiceReplyAttributes(attrsPart);
return { attrs: attrsPart, text: textPart || attrs.text || "", ...attrs };
}
if (trimmedHead.startsWith(":")) {
return { attrs: "", text: trimmedHead.slice(1).trim() };
}
const attrs = parseVoiceReplyAttributes(trimmedHead);
return { attrs: trimmedHead, text: attrs.text ?? "" };
}
export function planTelegramVoiceReply(
markdown: string,
): TelegramVoiceReplyPlan {
const voiceReplies: TelegramVoiceReplyItem[] = [];
let lang: string | undefined;
let rate: string | undefined;
const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
let command = parseTopLevelTelegramComment(comment, "telegram_voice");
if (!command) {
let content = comment.content.replace(/^\s+/, "").replace(/^!/, "");
if (content.startsWith("telegram_voice")) {
const headPart = content.slice("telegram_voice".length).trim();
command = { head: headPart, body: undefined };
}
}
if (!command) return "";
const parsed = parseVoiceCommentBody(command.head, command.body);
const attrs = parseVoiceReplyAttributes(parsed.attrs);
if (parsed.text) {
voiceReplies.push({
text: parsed.text,
...(attrs.lang ? { lang: attrs.lang } : {}),
...(attrs.rate ? { rate: attrs.rate } : {}),
});
}
if (attrs.lang) lang = attrs.lang;
if (attrs.rate) rate = attrs.rate;
return "";
});
const voiceText = voiceReplies
.map((reply) => reply.text)
.join("\n\n")
.trim();
return {
markdown: stripTelegramCommentMarkupForDelivery(stripped),
...(voiceText ? { voiceText } : {}),
...(voiceReplies.length > 0 ? { voiceReplies } : {}),
...(lang ? { lang } : {}),
...(rate ? { rate } : {}),
};
}