/** * `dz tg-post` — the pure half: validate an approved draft against what Telegram will accept and * what the channel's own accepted design demands. * * The design is NOT this module's to invent. features/genai-tweets-channel/ carries four ACCEPTED * ADRs (2026-08-04): HTML mode, never MarkdownV2 (18 escapes against 3, one miss is a 400); the * cadence rule "no posts 00:00-06:00 MSK"; link previews off by default because x.com previews in * Telegram have been broken since 2022; and ADR-004's standing order — publishing stays MANUAL, and * autonomous publishing means REVISING the ADR, not flipping a quiet flag. This module only makes * those decisions checkable. * * PURE: no filesystem, no network, no clock — the timestamp arrives as a parameter, or the MSK * night-window rule could not be tested at all. */ /** Tags Bot API accepts in HTML mode (verified against the live docs, Bot API 10.2). */ export const TG_ALLOWED_TAGS: readonly string[] = [ 'b', 'strong', 'i', 'em', 'u', 'ins', 's', 'strike', 'del', 'tg-spoiler', 'a', 'tg-emoji', 'code', 'pre', 'blockquote', ]; /** Hard ceiling for `sendMessage.text`, characters after entity parsing. */ export const TG_TEXT_LIMIT = 4096; export interface TgHtmlIssue { readonly kind: 'unknown-tag' | 'unclosed-tag' | 'stray-close' | 'bare-ampersand' | 'bare-angle' | 'over-limit' | 'empty'; readonly detail: string; } /** * The character count Telegram limits: text WITHOUT the markup. An approximation is stated as one — * entities like tg-emoji count differently — but a draft within this bound by a margin is safe, and * the render prints the number so the author sees the headroom, not a verdict. */ export function tgVisibleLength(html: string): number { return html.replace(/<[^>]*>/g, '').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').length; } /** * Everything wrong with a draft, or an empty list. One pass, every finding named — a validator that * stops at the first fault sends the author around the loop once per mistake. */ export function tgPostHtmlIssues(html: string): TgHtmlIssue[] { const issues: TgHtmlIssue[] = []; const text = html.trim(); if (text === '') return [{ kind: 'empty', detail: 'the draft is empty — nothing to send' }]; // Tag balance over the allowed set. Telegram closes nothing for you: an unclosed is a 400. const stack: string[] = []; const tagRe = /<(\/?)([a-zA-Z-]+)((?:\s+[a-zA-Z-]+(?:="[^"]*")?)*)\s*(\/?)>/g; let covered = 0; for (let m = tagRe.exec(text); m !== null; m = tagRe.exec(text)) { covered += 1; const closing = m[1] === '/'; const name = (m[2] as string).toLowerCase(); const expandable = name === 'blockquote'; // `
` is the ADR-003 body form if (!TG_ALLOWED_TAGS.includes(name) && !expandable) { issues.push({ kind: 'unknown-tag', detail: `<${name}> is not a Bot API HTML tag — Telegram answers 400 to tags it does not know` }); continue; } if (closing) { if (stack.length === 0 || stack[stack.length - 1] !== name) { issues.push({ kind: 'stray-close', detail: ` closes nothing that is open — tags must nest, not interleave` }); } else { stack.pop(); } } else if (m[4] !== '/') { stack.push(name); } } for (const open of stack) { issues.push({ kind: 'unclosed-tag', detail: `<${open}> is never closed — Telegram closes nothing for you, this is a 400` }); } // Bare & and < outside tags: HTML mode requires entity-escaping exactly these. const outside = text.replace(/<[^>]*>/g, ''); if (/&(?!(lt|gt|amp|quot|#\d+|#x[0-9a-fA-F]+);)/.test(outside)) { issues.push({ kind: 'bare-ampersand', detail: 'a bare & outside an entity — HTML mode needs &' }); } if (/ TG_TEXT_LIMIT) { issues.push({ kind: 'over-limit', detail: `${visible} visible characters against the ${TG_TEXT_LIMIT} hard limit — cut ${visible - TG_TEXT_LIMIT}` }); } void covered; return issues; } export interface TgSendDecision { readonly action: 'send' | 'refuse'; readonly reason: string; } /** * May this draft go out NOW? * * The night window is ADR-003's cadence rule, encoded as a refusal with an explicit override rather * than as advice: 00:00-06:00 MSK is when the channel's audience is asleep and its author is too — * a send landing then is far more often a timezone mistake than an intention. `--night` states the * intention; without it the refusal names the local MSK time it computed, so the operator can check * the arithmetic instead of trusting it. */ export function decideTgSend(input: { readonly issues: readonly TgHtmlIssue[]; readonly provenanceOutcome: 'allowed' | 'blocked' | 'not-established' | 'skipped'; readonly confirmed: boolean; readonly nowUtcIso: string; readonly nightOverride: boolean; }): TgSendDecision { if (input.issues.length > 0) { return { action: 'refuse', reason: `${input.issues.length} formatting issue(s) — Telegram would refuse or mangle this draft` }; } // The provenance gate is not optional and "skipped" is not a pass: ADR-002 of the provenance // feature — nothing leaves this machine citing a source that may not. if (input.provenanceOutcome !== 'allowed') { return { action: 'refuse', reason: input.provenanceOutcome === 'skipped' ? 'no provenance manifest was checked — an unchecked draft is not an approved draft' : `the provenance gate said ${input.provenanceOutcome} — nothing goes out citing a source that may not leave this machine`, }; } if (!input.confirmed) { return { action: 'refuse', reason: 'publishing is MANUAL by the channel\'s own ADR-004 — pass --send --yes to state the decision out loud' }; } const utc = Date.parse(input.nowUtcIso); if (Number.isFinite(utc)) { const mskHour = new Date(utc + 3 * 3600_000).getUTCHours(); if (mskHour < 6 && !input.nightOverride) { return { action: 'refuse', reason: `it is ${String(mskHour).padStart(2, '0')}:xx MSK — the channel posts nothing between 00:00 and 06:00 MSK (ADR-003). Pass --night if this is deliberate` }; } } return { action: 'send', reason: 'formatted, provenance-cleared, confirmed, and inside posting hours' }; }