@vaultgradient/pipequery-cli 0.4.0 → 0.6.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.
- package/README.md +64 -0
- package/__tests__/loader-watches.test.ts +125 -0
- package/__tests__/nl-translator.test.ts +228 -0
- package/__tests__/telegram-format.test.ts +62 -0
- package/__tests__/watch-manager.test.ts +202 -0
- package/dist/index.js +932 -58
- package/package.json +3 -1
- package/src/commands/telegram.ts +65 -0
- package/src/commands/watch.ts +84 -0
- package/src/config/loader.ts +1 -0
- package/src/config/schema.ts +45 -0
- package/src/index.ts +43 -0
- package/src/server/app.ts +10 -1
- package/src/server/watches/manager.ts +177 -0
- package/src/telegram/bot.ts +247 -0
- package/src/telegram/format.ts +158 -0
- package/src/telegram/nl.ts +318 -0
- package/src/telegram/notifier.ts +110 -0
|
@@ -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 <name> — sample rows + field names',
|
|
55
|
+
'/endpoints — list pre-configured endpoints',
|
|
56
|
+
'/call <path> — execute a pre-configured endpoint',
|
|
57
|
+
'/query <expression> — 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 > 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 <source></code> to discover field names before constructing a where clause.',
|
|
86
|
+
'• Results > 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 <source-name></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 <endpoint-path></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 <pipequery expression></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
|
+
'&': '&',
|
|
152
|
+
'<': '<',
|
|
153
|
+
'>': '>',
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export function escapeHtml(s: string): string {
|
|
157
|
+
return s.replace(/[&<>]/g, (c) => HTML_ENTITIES[c] ?? c);
|
|
158
|
+
}
|