@vaultgradient/pipequery-cli 0.3.3 → 0.6.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.
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Watch manager — polls each configured watch on its interval, evaluates
3
+ * the pipequery expression against the live source data, and dispatches
4
+ * notifications when the watch's fire condition is met.
5
+ *
6
+ * Lives alongside the existing endpoint registry in createServer(). Same
7
+ * lifecycle: start when the server boots, stop when it disposes.
8
+ *
9
+ * Idempotency model:
10
+ * - For `when_non_empty`: track per-watch `lastWasEmpty` boolean. Fire
11
+ * only when the previous tick was empty (or first run) and the current
12
+ * tick is non-empty. Re-fires after the result goes empty and back.
13
+ * - For `when_empty`: the inverse.
14
+ * - For `on_change`: track a hash of the last result; fire on hash change.
15
+ *
16
+ * All firing is best-effort. If a notifier throws, the error is logged on
17
+ * stderr and the watch state advances anyway — we don't want one stuck
18
+ * notifier to wedge an alert pipeline.
19
+ */
20
+ import { createHash } from 'node:crypto';
21
+ import { query } from '../../engine.js';
22
+ import { parseDuration } from '../../utils/parseDuration.js';
23
+ import * as activity from '../activity-log.js';
24
+ import type { WatchConfig, WatchNotify } from '../../config/schema.js';
25
+ import type { SourceManager } from '../sources/manager.js';
26
+ import type { Notifier } from '../../telegram/notifier.js';
27
+ import { TelegramNotifier } from '../../telegram/notifier.js';
28
+
29
+ const DEFAULT_INTERVAL_MS = 60_000;
30
+
31
+ interface WatchState {
32
+ config: WatchConfig;
33
+ notifiers: Notifier[];
34
+ intervalMs: number;
35
+ timer: ReturnType<typeof setInterval> | null;
36
+ lastWasEmpty: boolean | null;
37
+ lastHash: string | null;
38
+ }
39
+
40
+ export class WatchManager {
41
+ private watches = new Map<string, WatchState>();
42
+ private disposed = false;
43
+
44
+ constructor(private sourceManager: SourceManager) {}
45
+
46
+ start(configs: Record<string, WatchConfig>): void {
47
+ for (const [name, cfg] of Object.entries(configs)) {
48
+ try {
49
+ this.startOne(name, cfg);
50
+ } catch (err) {
51
+ const msg = err instanceof Error ? err.message : String(err);
52
+ activity.error(`Watch "${name}" failed to start: ${msg}`);
53
+ process.stderr.write(`[pipequery-watch] "${name}" failed to start: ${msg}\n`);
54
+ }
55
+ }
56
+ }
57
+
58
+ private startOne(name: string, cfg: WatchConfig): void {
59
+ const notifiers = buildNotifiers(cfg.notify);
60
+ if (notifiers.length === 0) {
61
+ throw new Error(`watch has no notification channels configured`);
62
+ }
63
+ const intervalMs = cfg.interval ? parseDuration(cfg.interval) : DEFAULT_INTERVAL_MS;
64
+ const state: WatchState = {
65
+ config: cfg,
66
+ notifiers,
67
+ intervalMs,
68
+ timer: null,
69
+ lastWasEmpty: null,
70
+ lastHash: null,
71
+ };
72
+ this.watches.set(name, state);
73
+ activity.info(`Watch "${name}" registered (every ${cfg.interval ?? '60s'})`);
74
+
75
+ // Run once immediately to seed `lastWasEmpty`/`lastHash` — we don't fire
76
+ // on the very first tick, since "transitioned" requires a previous state.
77
+ void this.tick(name);
78
+ state.timer = setInterval(() => void this.tick(name), intervalMs);
79
+ }
80
+
81
+ private async tick(name: string): Promise<void> {
82
+ if (this.disposed) return;
83
+ const state = this.watches.get(name);
84
+ if (!state) return;
85
+
86
+ let result: unknown;
87
+ try {
88
+ const ctx = this.sourceManager.getContext();
89
+ result = query(ctx, state.config.query);
90
+ } catch (err) {
91
+ const msg = err instanceof Error ? err.message : String(err);
92
+ activity.error(`Watch "${name}" query failed: ${msg}`);
93
+ // Don't advance state on query failure — wait for the next tick.
94
+ return;
95
+ }
96
+
97
+ const fireWhen = state.config.fireWhen ?? 'when_non_empty';
98
+ const isEmpty = isResultEmpty(result);
99
+ const hash = hashResult(result);
100
+
101
+ let shouldFire = false;
102
+ let reason = '';
103
+ if (fireWhen === 'when_non_empty') {
104
+ if (state.lastWasEmpty !== false && !isEmpty) {
105
+ shouldFire = true;
106
+ reason = 'result transitioned to non-empty';
107
+ }
108
+ } else if (fireWhen === 'when_empty') {
109
+ if (state.lastWasEmpty !== true && isEmpty) {
110
+ shouldFire = true;
111
+ reason = 'result transitioned to empty';
112
+ }
113
+ } else if (fireWhen === 'on_change') {
114
+ if (state.lastHash !== null && state.lastHash !== hash) {
115
+ shouldFire = true;
116
+ reason = 'result content changed';
117
+ }
118
+ }
119
+
120
+ state.lastWasEmpty = isEmpty;
121
+ state.lastHash = hash;
122
+
123
+ if (!shouldFire) return;
124
+
125
+ activity.info(`Watch "${name}" fired (${reason})`);
126
+ await Promise.all(
127
+ state.notifiers.map((n) =>
128
+ n.notify({ watchName: name, result, reason }).catch((err) => {
129
+ const msg = err instanceof Error ? err.message : String(err);
130
+ activity.error(`Watch "${name}" notify failed: ${msg}`);
131
+ process.stderr.write(`[pipequery-watch] "${name}" notify failed: ${msg}\n`);
132
+ }),
133
+ ),
134
+ );
135
+ }
136
+
137
+ stop(name: string): boolean {
138
+ const state = this.watches.get(name);
139
+ if (!state) return false;
140
+ if (state.timer) clearInterval(state.timer);
141
+ this.watches.delete(name);
142
+ return true;
143
+ }
144
+
145
+ list(): string[] {
146
+ return [...this.watches.keys()];
147
+ }
148
+
149
+ dispose(): void {
150
+ this.disposed = true;
151
+ for (const state of this.watches.values()) {
152
+ if (state.timer) clearInterval(state.timer);
153
+ }
154
+ this.watches.clear();
155
+ }
156
+ }
157
+
158
+ // ─── Helpers ────────────────────────────────────────────────────────────────
159
+
160
+ function buildNotifiers(notify: WatchNotify): Notifier[] {
161
+ const out: Notifier[] = [];
162
+ if (notify.telegram) out.push(new TelegramNotifier(notify.telegram));
163
+ return out;
164
+ }
165
+
166
+ function isResultEmpty(result: unknown): boolean {
167
+ if (result === null || result === undefined) return true;
168
+ if (Array.isArray(result)) return result.length === 0;
169
+ if (typeof result === 'object') return Object.keys(result as object).length === 0;
170
+ return false;
171
+ }
172
+
173
+ function hashResult(result: unknown): string {
174
+ // Stable JSON for hashing — skip undefined / sort keys.
175
+ const json = JSON.stringify(result, (_, v) => (v === undefined ? null : v));
176
+ return createHash('sha256').update(json).digest('hex');
177
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Telegram bot transport for pipequery.
3
+ *
4
+ * Same shape as the MCP server: takes a Provider (Local or Attached) and
5
+ * exposes the same five verbs as bot commands. No state in the bot itself —
6
+ * everything routes through the provider, so behaviour matches the MCP path
7
+ * exactly (including governance, when that lands).
8
+ */
9
+ import { Bot, type Context, GrammyError, HttpError } from 'grammy';
10
+ import type { Provider } from '../mcp/provider.js';
11
+ import { formatError, formatResult, escapeHtml } from './format.js';
12
+ import type { NLTranslator } from './nl.js';
13
+
14
+ export interface BotOptions {
15
+ /** Whitelist of Telegram user IDs (numeric) or @usernames allowed to query.
16
+ * Empty array = anyone with the bot token can query (NOT recommended). */
17
+ allowUsers?: string[];
18
+ /** Optional natural-language translator. If present, plain-text messages
19
+ * (no leading slash) are routed through it. */
20
+ nl?: NLTranslator;
21
+ }
22
+
23
+ export function buildBot(token: string, provider: Provider, opts: BotOptions = {}): Bot {
24
+ const bot = new Bot(token);
25
+ const allowList = normalizeAllowList(opts.allowUsers ?? []);
26
+ const nl = opts.nl;
27
+
28
+ // Auth middleware — runs before any handler.
29
+ bot.use(async (ctx, next) => {
30
+ if (allowList.size === 0) {
31
+ // No allowlist configured. Print a warning once per unique user so
32
+ // operators notice they're running an unauthenticated bot.
33
+ const id = ctx.from?.id;
34
+ if (id) trackUnauthSeen(id, ctx.from?.username);
35
+ await next();
36
+ return;
37
+ }
38
+ if (!isAllowed(ctx, allowList)) {
39
+ await ctx.reply('🔒 You are not authorized to use this bot.');
40
+ return;
41
+ }
42
+ await next();
43
+ });
44
+
45
+ bot.command('start', async (ctx) => {
46
+ await ctx.reply(
47
+ [
48
+ '👋 <b>pipequery bot</b>',
49
+ '',
50
+ 'Run pipequery expressions against your configured data sources from chat.',
51
+ '',
52
+ '<b>Commands</b>',
53
+ '/sources — list configured sources and their health',
54
+ '/describe &lt;name&gt; — sample rows + field names',
55
+ '/endpoints — list pre-configured endpoints',
56
+ '/call &lt;path&gt; — execute a pre-configured endpoint',
57
+ '/query &lt;expression&gt; — run an arbitrary pipequery expression',
58
+ '/help — this message',
59
+ ].join('\n'),
60
+ { parse_mode: 'HTML' },
61
+ );
62
+ });
63
+
64
+ bot.command('help', async (ctx) => {
65
+ const lines = [
66
+ '<b>Examples</b>',
67
+ '<code>/sources</code>',
68
+ '<code>/describe products</code>',
69
+ '<code>/query products | sort(price desc) | first(5)</code>',
70
+ '<code>/query crypto | where(price &gt; 100) | rollup(avg(price) as avg)</code>',
71
+ '<code>/call /api/top-coins</code>',
72
+ ];
73
+ if (nl) {
74
+ lines.push(
75
+ '',
76
+ '<b>Natural language</b>',
77
+ 'Send any plain-text message (no leading <code>/</code>) and I\'ll translate it into a pipequery expression and run it.',
78
+ 'Example: <i>"top 5 most expensive paid orders"</i>',
79
+ );
80
+ }
81
+ lines.push(
82
+ '',
83
+ '<b>Tips</b>',
84
+ '• Wrap quoted strings carefully — Telegram\'s autocorrect can mangle them. Send long expressions as a separate message.',
85
+ '• Use <code>/describe &lt;source&gt;</code> to discover field names before constructing a where clause.',
86
+ '• Results &gt; 30 rows are truncated with a footer; tighten your query to see all of them.',
87
+ );
88
+ await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
89
+ });
90
+
91
+ bot.command('sources', async (ctx) => {
92
+ try {
93
+ const sources = await provider.listSources();
94
+ if (sources.length === 0) {
95
+ await ctx.reply('<i>No sources configured.</i>', { parse_mode: 'HTML' });
96
+ return;
97
+ }
98
+ const lines = sources.map((s) => {
99
+ const dot = s.status.healthy ? '🟢' : '🔴';
100
+ const rowCount = s.status.rowCount.toLocaleString();
101
+ const err = s.status.error ? ` <i>(${escapeHtml(s.status.error)})</i>` : '';
102
+ return `${dot} <b>${escapeHtml(s.name)}</b> — ${rowCount} rows${err}`;
103
+ });
104
+ await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
105
+ } catch (err) {
106
+ await ctx.reply(formatError(err), { parse_mode: 'HTML' });
107
+ }
108
+ });
109
+
110
+ bot.command('describe', async (ctx) => {
111
+ const name = ctx.match.trim();
112
+ if (!name) {
113
+ await ctx.reply('Usage: <code>/describe &lt;source-name&gt;</code>', { parse_mode: 'HTML' });
114
+ return;
115
+ }
116
+ try {
117
+ const desc = await provider.describeSource(name, 5);
118
+ if (!desc) {
119
+ await ctx.reply(`Source <code>${escapeHtml(name)}</code> not found.`, { parse_mode: 'HTML' });
120
+ return;
121
+ }
122
+ const fields = desc.fields.length ? desc.fields.map((f) => `<code>${escapeHtml(f)}</code>`).join(', ') : '<i>(none inferred)</i>';
123
+ const sample = formatResult(desc.sample);
124
+ await ctx.reply(
125
+ `<b>${escapeHtml(name)}</b> — ${desc.status.rowCount.toLocaleString()} rows\n<b>Fields:</b> ${fields}\n\n<b>Sample:</b>\n${sample}`,
126
+ { parse_mode: 'HTML' },
127
+ );
128
+ } catch (err) {
129
+ await ctx.reply(formatError(err), { parse_mode: 'HTML' });
130
+ }
131
+ });
132
+
133
+ bot.command('endpoints', async (ctx) => {
134
+ try {
135
+ const endpoints = await provider.listEndpoints();
136
+ if (endpoints.length === 0) {
137
+ await ctx.reply('<i>No endpoints configured.</i>', { parse_mode: 'HTML' });
138
+ return;
139
+ }
140
+ const lines = endpoints.map(
141
+ (e) => `<b>${escapeHtml(e.path)}</b>\n<code>${escapeHtml(e.config.query)}</code>`,
142
+ );
143
+ await ctx.reply(lines.join('\n\n'), { parse_mode: 'HTML' });
144
+ } catch (err) {
145
+ await ctx.reply(formatError(err), { parse_mode: 'HTML' });
146
+ }
147
+ });
148
+
149
+ bot.command('call', async (ctx) => {
150
+ const path = ctx.match.trim();
151
+ if (!path) {
152
+ await ctx.reply('Usage: <code>/call &lt;endpoint-path&gt;</code>', { parse_mode: 'HTML' });
153
+ return;
154
+ }
155
+ try {
156
+ const result = await provider.callEndpoint(path);
157
+ await ctx.reply(formatResult(result), { parse_mode: 'HTML' });
158
+ } catch (err) {
159
+ await ctx.reply(formatError(err), { parse_mode: 'HTML' });
160
+ }
161
+ });
162
+
163
+ bot.command('query', async (ctx) => {
164
+ const expr = ctx.match.trim();
165
+ if (!expr) {
166
+ await ctx.reply('Usage: <code>/query &lt;pipequery expression&gt;</code>', { parse_mode: 'HTML' });
167
+ return;
168
+ }
169
+ try {
170
+ const result = await provider.runQuery(expr);
171
+ await ctx.reply(formatResult(result), { parse_mode: 'HTML' });
172
+ } catch (err) {
173
+ await ctx.reply(formatError(err), { parse_mode: 'HTML' });
174
+ }
175
+ });
176
+
177
+ // Plain-text fallback — only fires if NL translation is enabled. Anything
178
+ // starting with `/` is already routed by grammy's command matchers above;
179
+ // bot.on('message:text') still receives them, so we filter explicitly.
180
+ bot.on('message:text', async (ctx) => {
181
+ const text = ctx.message.text.trim();
182
+ if (!text || text.startsWith('/')) return;
183
+ if (!nl) {
184
+ await ctx.reply(
185
+ 'I don\'t recognise that. Use <code>/help</code> to see the available commands. ' +
186
+ 'Natural-language queries are disabled — start the bot with <code>--anthropic-key</code> to enable them.',
187
+ { parse_mode: 'HTML' },
188
+ );
189
+ return;
190
+ }
191
+ try {
192
+ await ctx.replyWithChatAction('typing');
193
+ const { expression, explanation } = await nl.translate(text);
194
+ if (!expression) {
195
+ const reason = explanation || 'I couldn\'t translate that into a pipequery expression.';
196
+ await ctx.reply(`🤔 ${escapeHtml(reason)}`, { parse_mode: 'HTML' });
197
+ return;
198
+ }
199
+ const result = await provider.runQuery(expression);
200
+ const header = `<i>${escapeHtml(explanation || 'Translated query')}</i>\n<code>${escapeHtml(expression)}</code>\n\n`;
201
+ await ctx.reply(header + formatResult(result), { parse_mode: 'HTML' });
202
+ } catch (err) {
203
+ await ctx.reply(formatError(err), { parse_mode: 'HTML' });
204
+ }
205
+ });
206
+
207
+ bot.catch((err) => {
208
+ if (err.error instanceof GrammyError) {
209
+ process.stderr.write(`[pipequery-telegram] grammy error: ${err.error.description}\n`);
210
+ } else if (err.error instanceof HttpError) {
211
+ process.stderr.write(`[pipequery-telegram] http error: ${err.error.message}\n`);
212
+ } else {
213
+ process.stderr.write(`[pipequery-telegram] unhandled: ${err.error}\n`);
214
+ }
215
+ });
216
+
217
+ return bot;
218
+ }
219
+
220
+ // ─── Auth helpers ───────────────────────────────────────────────────────────
221
+
222
+ function normalizeAllowList(entries: string[]): Set<string> {
223
+ const set = new Set<string>();
224
+ for (const raw of entries) {
225
+ const trimmed = raw.trim().replace(/^@/, '').toLowerCase();
226
+ if (trimmed) set.add(trimmed);
227
+ }
228
+ return set;
229
+ }
230
+
231
+ function isAllowed(ctx: Context, allowList: Set<string>): boolean {
232
+ const from = ctx.from;
233
+ if (!from) return false;
234
+ if (allowList.has(String(from.id))) return true;
235
+ if (from.username && allowList.has(from.username.toLowerCase())) return true;
236
+ return false;
237
+ }
238
+
239
+ const seenUnauth = new Set<number>();
240
+ function trackUnauthSeen(id: number, username?: string): void {
241
+ if (seenUnauth.has(id)) return;
242
+ seenUnauth.add(id);
243
+ process.stderr.write(
244
+ `[pipequery-telegram] WARNING: bot has no allowlist; user "${username ?? id}" can query freely. ` +
245
+ `Restart with --allow-user @yourname to lock it down.\n`,
246
+ );
247
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Telegram message formatting for pipequery results.
3
+ *
4
+ * Telegram has hard limits we have to respect:
5
+ * - Plain message text: 4096 characters
6
+ * - MarkdownV2 / HTML escaping rules (we use HTML mode — friendlier to
7
+ * pipe expressions and SQL-ish content with underscores)
8
+ * - Single message must be self-contained (no streaming/long-polling tricks)
9
+ *
10
+ * For result sets that don't fit, we return a truncated rendering with a
11
+ * row-count footer ("showing 20 of 1247 rows — narrow your query") rather
12
+ * than dumping the full payload as a file. Files are heavier UX than most
13
+ * Telegram users want for a quick chat query.
14
+ */
15
+
16
+ const TELEGRAM_TEXT_LIMIT = 4096;
17
+ const FENCE_OVERHEAD = 32; // <pre>...</pre>\n\nfooter
18
+ const TRUNCATE_BUDGET = TELEGRAM_TEXT_LIMIT - FENCE_OVERHEAD;
19
+ const MAX_ROWS_INLINE = 30;
20
+ const MAX_COL_WIDTH = 24;
21
+
22
+ export interface FormatOptions {
23
+ /** Max rows to render before truncating with a footer. Default 30. */
24
+ maxRows?: number;
25
+ /** Max width per column character before ellipsis. Default 24. */
26
+ maxColWidth?: number;
27
+ }
28
+
29
+ /**
30
+ * Render a query result as a Telegram-safe HTML message.
31
+ *
32
+ * - Object arrays → fixed-width table inside <pre>
33
+ * - Scalar arrays → bullet list
34
+ * - Single objects / scalars → JSON inside <pre>
35
+ * - Empty results → "(no rows)"
36
+ *
37
+ * Always returns a string ≤ 4096 chars.
38
+ */
39
+ export function formatResult(result: unknown, opts: FormatOptions = {}): string {
40
+ const maxRows = opts.maxRows ?? MAX_ROWS_INLINE;
41
+
42
+ if (Array.isArray(result)) {
43
+ if (result.length === 0) return '<i>(no rows)</i>';
44
+
45
+ const allObjects = result.every(
46
+ (r) => r !== null && typeof r === 'object' && !Array.isArray(r),
47
+ );
48
+
49
+ if (allObjects) {
50
+ return renderTable(result as Record<string, unknown>[], result.length, maxRows, opts);
51
+ }
52
+ // Mixed / scalar array — render as JSON list.
53
+ return renderJson(result.slice(0, maxRows), result.length, maxRows);
54
+ }
55
+
56
+ return renderJson(result, 1, 1);
57
+ }
58
+
59
+ export function formatError(err: unknown): string {
60
+ const msg = err instanceof Error ? err.message : String(err);
61
+ return `❌ <b>Error</b>\n<pre>${escapeHtml(msg)}</pre>`;
62
+ }
63
+
64
+ // ─── Table rendering ────────────────────────────────────────────────────────
65
+
66
+ function renderTable(
67
+ rows: Record<string, unknown>[],
68
+ totalCount: number,
69
+ maxRows: number,
70
+ opts: FormatOptions,
71
+ ): string {
72
+ const maxColWidth = opts.maxColWidth ?? MAX_COL_WIDTH;
73
+ const sample = rows.slice(0, maxRows);
74
+
75
+ // Column set = union of all keys in the rendered sample, in first-seen
76
+ // order. Mirrors the engine's row-shape semantics where rows aren't
77
+ // required to be schema-uniform.
78
+ const columns: string[] = [];
79
+ const seen = new Set<string>();
80
+ for (const r of sample) {
81
+ for (const k of Object.keys(r)) {
82
+ if (!seen.has(k)) {
83
+ seen.add(k);
84
+ columns.push(k);
85
+ }
86
+ }
87
+ }
88
+
89
+ const widths = columns.map((c) =>
90
+ Math.min(
91
+ maxColWidth,
92
+ Math.max(c.length, ...sample.map((r) => stringifyCell(r[c]).length)),
93
+ ),
94
+ );
95
+
96
+ const headerRow = columns.map((c, i) => padCell(c, widths[i])).join(' ');
97
+ const sep = widths.map((w) => '─'.repeat(w)).join(' ');
98
+ const dataRows = sample.map((r) =>
99
+ columns.map((c, i) => padCell(stringifyCell(r[c]), widths[i])).join(' '),
100
+ );
101
+
102
+ const lines = [headerRow, sep, ...dataRows];
103
+ let body = lines.join('\n');
104
+
105
+ // Hard cap on body to leave room for footer + fence
106
+ if (body.length > TRUNCATE_BUDGET) {
107
+ body = body.slice(0, TRUNCATE_BUDGET - 30) + '\n... (truncated)';
108
+ }
109
+
110
+ let out = `<pre>${escapeHtml(body)}</pre>`;
111
+ if (totalCount > maxRows) {
112
+ out += `\n<i>showing ${maxRows} of ${totalCount} rows — narrow your query</i>`;
113
+ } else {
114
+ out += `\n<i>${totalCount} row${totalCount === 1 ? '' : 's'}</i>`;
115
+ }
116
+ return out;
117
+ }
118
+
119
+ function stringifyCell(value: unknown): string {
120
+ if (value === null || value === undefined) return '';
121
+ if (typeof value === 'string') return value;
122
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
123
+ // Compact JSON for nested objects / arrays.
124
+ return JSON.stringify(value);
125
+ }
126
+
127
+ function padCell(s: string, width: number): string {
128
+ // Truncate with ellipsis if too wide
129
+ if (s.length > width) return s.slice(0, width - 1) + '…';
130
+ return s.padEnd(width, ' ');
131
+ }
132
+
133
+ // ─── JSON fallback ──────────────────────────────────────────────────────────
134
+
135
+ function renderJson(data: unknown, totalCount: number, maxRows: number): string {
136
+ const text = JSON.stringify(data, null, 2);
137
+ let body = text;
138
+ if (body.length > TRUNCATE_BUDGET) {
139
+ body = body.slice(0, TRUNCATE_BUDGET - 30) + '\n... (truncated)';
140
+ }
141
+ let out = `<pre>${escapeHtml(body)}</pre>`;
142
+ if (Array.isArray(data) && totalCount > maxRows) {
143
+ out += `\n<i>showing ${maxRows} of ${totalCount} rows — narrow your query</i>`;
144
+ }
145
+ return out;
146
+ }
147
+
148
+ // ─── HTML escaping (Telegram's HTML mode) ───────────────────────────────────
149
+
150
+ const HTML_ENTITIES: Record<string, string> = {
151
+ '&': '&amp;',
152
+ '<': '&lt;',
153
+ '>': '&gt;',
154
+ };
155
+
156
+ export function escapeHtml(s: string): string {
157
+ return s.replace(/[&<>]/g, (c) => HTML_ENTITIES[c] ?? c);
158
+ }