@worca/ui 0.9.0 → 0.11.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.
- package/app/main.bundle.js +895 -813
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +216 -9
- package/app/utils/state-actions.js +55 -0
- package/package.json +6 -4
- package/server/app.js +291 -6
- package/server/beads-reader.js +1 -1
- package/server/dispatch-external.js +106 -0
- package/server/ensure-webhook.js +66 -0
- package/server/index.js +22 -0
- package/server/integrations/adapter.js +91 -0
- package/server/integrations/adapters/discord.js +109 -0
- package/server/integrations/adapters/slack.js +106 -0
- package/server/integrations/adapters/telegram.js +231 -0
- package/server/integrations/adapters/webhook_out.js +253 -0
- package/server/integrations/allowlist.js +19 -0
- package/server/integrations/chat_context.js +68 -0
- package/server/integrations/commands/control.js +120 -0
- package/server/integrations/commands/global.js +239 -0
- package/server/integrations/commands/parser.js +29 -0
- package/server/integrations/commands/project.js +394 -0
- package/server/integrations/config-loader.js +40 -0
- package/server/integrations/index.js +390 -0
- package/server/integrations/markdown.js +220 -0
- package/server/integrations/rate_limiter.js +131 -0
- package/server/integrations/renderers.js +191 -0
- package/server/integrations/rest_client.js +17 -0
- package/server/integrations/verify.js +23 -0
- package/server/process-manager.js +217 -14
- package/server/project-routes.js +210 -44
- package/server/settings-validator.js +250 -0
- package/server/ws-beads-watcher.js +22 -6
- package/server/ws-message-router.js +1 -1
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createIntegrations factory — boots enabled adapters, wires event fan-out
|
|
3
|
+
* and inbound command dispatch.
|
|
4
|
+
* @module integrations/index
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { createDiscordAdapter } from './adapters/discord.js';
|
|
9
|
+
import { createSlackAdapter } from './adapters/slack.js';
|
|
10
|
+
import { createTelegramAdapter } from './adapters/telegram.js';
|
|
11
|
+
import { createWebhookOutAdapter } from './adapters/webhook_out.js';
|
|
12
|
+
import { createAllowlistGuard } from './allowlist.js';
|
|
13
|
+
import { createChatContext } from './chat_context.js';
|
|
14
|
+
import { createControlHandlers } from './commands/control.js';
|
|
15
|
+
import { createGlobalHandlers } from './commands/global.js';
|
|
16
|
+
import { parseCommand } from './commands/parser.js';
|
|
17
|
+
import { createProjectHandlers } from './commands/project.js';
|
|
18
|
+
import { loadIntegrationsConfig } from './config-loader.js';
|
|
19
|
+
import { createRateLimiter } from './rate_limiter.js';
|
|
20
|
+
import { renderEvent } from './renderers.js';
|
|
21
|
+
import { createRestClient } from './rest_client.js';
|
|
22
|
+
import { verify } from './verify.js';
|
|
23
|
+
|
|
24
|
+
/** Symbol used to pass raw request body through the stored event for HMAC verification. */
|
|
25
|
+
export const RAW_BODY = Symbol('raw_body');
|
|
26
|
+
|
|
27
|
+
const NO_OP_STUB = {
|
|
28
|
+
onEvent() {},
|
|
29
|
+
status() {
|
|
30
|
+
return { enabled: false };
|
|
31
|
+
},
|
|
32
|
+
strictInboxVerification: false,
|
|
33
|
+
secrets: [],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {{
|
|
38
|
+
* port: number,
|
|
39
|
+
* host?: string,
|
|
40
|
+
* prefsDir: string,
|
|
41
|
+
* configPath: string,
|
|
42
|
+
* }} opts
|
|
43
|
+
* @returns {{ onEvent, status, strictInboxVerification: boolean, secrets: string[] }}
|
|
44
|
+
*/
|
|
45
|
+
export function createIntegrations({
|
|
46
|
+
port,
|
|
47
|
+
host = '127.0.0.1',
|
|
48
|
+
prefsDir,
|
|
49
|
+
configPath,
|
|
50
|
+
}) {
|
|
51
|
+
let cfg = loadIntegrationsConfig(configPath);
|
|
52
|
+
if (!cfg || !cfg.enabled) return NO_OP_STUB;
|
|
53
|
+
|
|
54
|
+
let secrets = _loadSecrets(cfg);
|
|
55
|
+
const integrationsDir = join(prefsDir, 'integrations');
|
|
56
|
+
const chatContext = createChatContext(
|
|
57
|
+
join(integrationsDir, 'chat_context.json'),
|
|
58
|
+
);
|
|
59
|
+
const restClient = createRestClient({ host, port });
|
|
60
|
+
|
|
61
|
+
const globalHandlers = createGlobalHandlers({
|
|
62
|
+
chatContext,
|
|
63
|
+
prefsDir,
|
|
64
|
+
restClient,
|
|
65
|
+
});
|
|
66
|
+
const projectHandlers = createProjectHandlers({ chatContext, restClient });
|
|
67
|
+
const controlHandlers = createControlHandlers({ chatContext, restClient });
|
|
68
|
+
const allHandlers = {
|
|
69
|
+
...globalHandlers,
|
|
70
|
+
...projectHandlers,
|
|
71
|
+
...controlHandlers,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Mutable adapter registry — keyed by adapter name
|
|
75
|
+
const adapterMap = new Map(); // name → { adapter, adapterCfg }
|
|
76
|
+
const rateLimiters = new Map();
|
|
77
|
+
let allowlist = createAllowlistGuard(_collectAllowedIds(cfg));
|
|
78
|
+
|
|
79
|
+
let invalidSigEvents = 0;
|
|
80
|
+
let lastEventAt = null;
|
|
81
|
+
|
|
82
|
+
// Boot initial adapters from config
|
|
83
|
+
for (const entry of _bootAdapters(cfg, integrationsDir)) {
|
|
84
|
+
_startEntry(entry);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function _startEntry({ adapter, adapterCfg }) {
|
|
88
|
+
adapterMap.set(adapter.name, { adapter, adapterCfg });
|
|
89
|
+
rateLimiters.set(
|
|
90
|
+
adapter.name,
|
|
91
|
+
createRateLimiter({ ratePerMin: adapterCfg.rate_limit_per_min ?? 20 }),
|
|
92
|
+
);
|
|
93
|
+
if (adapter.supportsInbound) {
|
|
94
|
+
adapter.onInbound((msg) => _handleInbound(msg));
|
|
95
|
+
}
|
|
96
|
+
adapter
|
|
97
|
+
.start()
|
|
98
|
+
.catch((err) =>
|
|
99
|
+
console.error(
|
|
100
|
+
`[integrations] ${adapter.name} start error:`,
|
|
101
|
+
err.message,
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function _stopAdapter(name) {
|
|
107
|
+
const entry = adapterMap.get(name);
|
|
108
|
+
if (!entry) return;
|
|
109
|
+
try {
|
|
110
|
+
await entry.adapter.stop();
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.error(`[integrations] ${name} stop error:`, err.message);
|
|
113
|
+
}
|
|
114
|
+
adapterMap.delete(name);
|
|
115
|
+
rateLimiters.delete(name);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Hot-reload a single adapter: re-reads config, stops the old instance,
|
|
120
|
+
* boots a new one. No-op if the adapter's config section is missing/disabled.
|
|
121
|
+
*/
|
|
122
|
+
async function reloadAdapter(name) {
|
|
123
|
+
cfg = loadIntegrationsConfig(configPath) || cfg;
|
|
124
|
+
if (!cfg.enabled) return;
|
|
125
|
+
secrets = _loadSecrets(cfg);
|
|
126
|
+
allowlist = createAllowlistGuard(_collectAllowedIds(cfg));
|
|
127
|
+
|
|
128
|
+
// Stop existing adapter — must complete before booting new one
|
|
129
|
+
await _stopAdapter(name);
|
|
130
|
+
|
|
131
|
+
// Boot new adapter from fresh config
|
|
132
|
+
const entries = _bootAdapters({ [name]: cfg[name] }, integrationsDir);
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
_startEntry(entry);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Remove a single adapter: stops and unregisters it, refreshes config.
|
|
140
|
+
*/
|
|
141
|
+
async function removeAdapter(name) {
|
|
142
|
+
await _stopAdapter(name);
|
|
143
|
+
cfg = loadIntegrationsConfig(configPath) || cfg;
|
|
144
|
+
secrets = _loadSecrets(cfg);
|
|
145
|
+
allowlist = createAllowlistGuard(_collectAllowedIds(cfg));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function _handleInbound(msg) {
|
|
149
|
+
if (!allowlist.isAllowed({ platform: msg.platform, chatId: msg.chatId }))
|
|
150
|
+
return;
|
|
151
|
+
|
|
152
|
+
const parsed = parseCommand(msg.text);
|
|
153
|
+
if (!parsed) return;
|
|
154
|
+
|
|
155
|
+
const chatKey = `${msg.platform}:${msg.chatId}`;
|
|
156
|
+
const handler = allHandlers[parsed.command];
|
|
157
|
+
let reply;
|
|
158
|
+
|
|
159
|
+
if (!handler) {
|
|
160
|
+
reply = `Unknown command /${parsed.command}. Try /help.`;
|
|
161
|
+
} else {
|
|
162
|
+
try {
|
|
163
|
+
reply = await handler(chatKey, parsed.args);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.error('[integrations] command error:', err.message);
|
|
166
|
+
reply = 'Internal error — try again.';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (reply) await _sendReply(msg.platform, msg.chatId, reply);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function _sendReply(platform, chatId, text) {
|
|
174
|
+
const entry = adapterMap.get(platform);
|
|
175
|
+
if (!entry) return;
|
|
176
|
+
const msg = {
|
|
177
|
+
title: null,
|
|
178
|
+
body: [{ kind: 'markdown', value: text }],
|
|
179
|
+
severity: 'info',
|
|
180
|
+
};
|
|
181
|
+
const rl = rateLimiters.get(platform);
|
|
182
|
+
if (rl) {
|
|
183
|
+
await rl.send(msg, (m) => entry.adapter.send(chatId, m));
|
|
184
|
+
} else {
|
|
185
|
+
await entry.adapter.send(chatId, msg);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function onEvent(stored) {
|
|
190
|
+
const rawBody = stored[RAW_BODY];
|
|
191
|
+
const sigHeader = stored.headers?.['x-worca-signature'];
|
|
192
|
+
|
|
193
|
+
if (secrets.length > 0) {
|
|
194
|
+
if (!rawBody || !verify(rawBody, sigHeader, secrets)) {
|
|
195
|
+
invalidSigEvents++;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
lastEventAt = new Date().toISOString();
|
|
201
|
+
const envelope = stored.envelope;
|
|
202
|
+
|
|
203
|
+
for (const [, { adapter, adapterCfg }] of adapterMap) {
|
|
204
|
+
const events = adapterCfg.events ?? [];
|
|
205
|
+
if (!events.includes(envelope?.event_type)) continue;
|
|
206
|
+
|
|
207
|
+
const chatId = String(adapterCfg.chat_id ?? adapterCfg.channel_id ?? '');
|
|
208
|
+
const chatKey = `${adapter.name}:${chatId}`;
|
|
209
|
+
|
|
210
|
+
if (chatContext.isMuted(chatKey)) {
|
|
211
|
+
chatContext.incrementMuted(chatKey);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const msg = renderEvent(envelope);
|
|
216
|
+
if (!msg) continue;
|
|
217
|
+
|
|
218
|
+
const rl = rateLimiters.get(adapter.name);
|
|
219
|
+
const sendFn = (m) => adapter.send(chatId, m);
|
|
220
|
+
|
|
221
|
+
if (rl) {
|
|
222
|
+
rl.send(msg, sendFn).catch((err) =>
|
|
223
|
+
console.error(
|
|
224
|
+
`[integrations] ${adapter.name} send error:`,
|
|
225
|
+
err.message,
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
} else {
|
|
229
|
+
sendFn(msg).catch((err) =>
|
|
230
|
+
console.error(
|
|
231
|
+
`[integrations] ${adapter.name} send error:`,
|
|
232
|
+
err.message,
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function status() {
|
|
240
|
+
return {
|
|
241
|
+
enabled: true,
|
|
242
|
+
strict_inbox_verification: cfg.strict_inbox_verification ?? false,
|
|
243
|
+
secrets_configured: secrets.length,
|
|
244
|
+
adapters: [...adapterMap.values()].map(({ adapter }) => {
|
|
245
|
+
const conn = adapter.connectionState?.() ?? {
|
|
246
|
+
state: 'n/a',
|
|
247
|
+
error: null,
|
|
248
|
+
};
|
|
249
|
+
return {
|
|
250
|
+
name: adapter.name,
|
|
251
|
+
enabled: true,
|
|
252
|
+
persistent: adapter.persistent ?? false,
|
|
253
|
+
connection: conn.state,
|
|
254
|
+
connection_error: conn.error,
|
|
255
|
+
dropped_messages:
|
|
256
|
+
rateLimiters.get(adapter.name)?.getStats().dropped_messages ?? 0,
|
|
257
|
+
invalid_signature_events: invalidSigEvents,
|
|
258
|
+
last_event_at: lastEventAt,
|
|
259
|
+
};
|
|
260
|
+
}),
|
|
261
|
+
chats: _collectChatStatus(cfg, chatContext),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
onEvent,
|
|
267
|
+
status,
|
|
268
|
+
reloadAdapter,
|
|
269
|
+
removeAdapter,
|
|
270
|
+
/** @internal — used by detect endpoint to pause/resume adapter */
|
|
271
|
+
_getAdapter: (name) => adapterMap.get(name) ?? null,
|
|
272
|
+
strictInboxVerification: cfg.strict_inbox_verification ?? false,
|
|
273
|
+
secrets,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// Helpers
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
function _loadSecrets(cfg) {
|
|
282
|
+
const secrets = [];
|
|
283
|
+
if (cfg.webhook_secret_env) {
|
|
284
|
+
const val = process.env[cfg.webhook_secret_env];
|
|
285
|
+
if (val) secrets.push(val);
|
|
286
|
+
}
|
|
287
|
+
if (cfg.webhook_secrets_env) {
|
|
288
|
+
const val = process.env[cfg.webhook_secrets_env];
|
|
289
|
+
if (val) {
|
|
290
|
+
for (const s of val.split(',')) {
|
|
291
|
+
const trimmed = s.trim();
|
|
292
|
+
if (trimmed) secrets.push(trimmed);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return secrets;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function _bootAdapters(cfg, integrationsDir) {
|
|
300
|
+
const adapters = [];
|
|
301
|
+
|
|
302
|
+
if (cfg.telegram?.enabled) {
|
|
303
|
+
const token =
|
|
304
|
+
cfg.telegram.bot_token ||
|
|
305
|
+
process.env[cfg.telegram.bot_token_env || 'TELEGRAM_BOT_TOKEN'];
|
|
306
|
+
if (!token) {
|
|
307
|
+
console.warn('[integrations] telegram token not configured — skipping');
|
|
308
|
+
} else {
|
|
309
|
+
adapters.push({
|
|
310
|
+
adapter: createTelegramAdapter({
|
|
311
|
+
token,
|
|
312
|
+
cursorPath: join(integrationsDir, 'telegram.cursor'),
|
|
313
|
+
}),
|
|
314
|
+
adapterCfg: cfg.telegram,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (cfg.discord?.enabled) {
|
|
320
|
+
const botToken =
|
|
321
|
+
cfg.discord.bot_token ||
|
|
322
|
+
process.env[cfg.discord.bot_token_env || 'DISCORD_BOT_TOKEN'];
|
|
323
|
+
if (!botToken) {
|
|
324
|
+
console.warn('[integrations] discord token not configured — skipping');
|
|
325
|
+
} else {
|
|
326
|
+
adapters.push({
|
|
327
|
+
adapter: createDiscordAdapter({
|
|
328
|
+
botToken,
|
|
329
|
+
channelId: cfg.discord.channel_id,
|
|
330
|
+
}),
|
|
331
|
+
adapterCfg: cfg.discord,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (cfg.slack?.enabled) {
|
|
337
|
+
const webhookUrl =
|
|
338
|
+
cfg.slack.webhook_url ||
|
|
339
|
+
process.env[cfg.slack.webhook_url_env || 'SLACK_WEBHOOK_URL'];
|
|
340
|
+
if (!webhookUrl) {
|
|
341
|
+
console.warn(
|
|
342
|
+
'[integrations] slack webhook URL not configured — skipping',
|
|
343
|
+
);
|
|
344
|
+
} else {
|
|
345
|
+
adapters.push({
|
|
346
|
+
adapter: createSlackAdapter({ webhookUrl }),
|
|
347
|
+
adapterCfg: cfg.slack,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (cfg.webhook_out?.enabled) {
|
|
353
|
+
const endpoints = cfg.webhook_out.endpoints ?? [];
|
|
354
|
+
if (endpoints.length > 0) {
|
|
355
|
+
const events = [...new Set(endpoints.flatMap((ep) => ep.events ?? []))];
|
|
356
|
+
adapters.push({
|
|
357
|
+
adapter: createWebhookOutAdapter({ endpoints }),
|
|
358
|
+
adapterCfg: { ...cfg.webhook_out, events },
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return adapters;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function _collectAllowedIds(cfg) {
|
|
367
|
+
const ids = [];
|
|
368
|
+
if (cfg.telegram?.chat_id) ids.push(String(cfg.telegram.chat_id));
|
|
369
|
+
if (cfg.discord?.channel_id) ids.push(String(cfg.discord.channel_id));
|
|
370
|
+
if (cfg.slack?.chat_id) ids.push(String(cfg.slack.chat_id));
|
|
371
|
+
return ids;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function _collectChatStatus(cfg, chatContext) {
|
|
375
|
+
const chats = [];
|
|
376
|
+
if (cfg.telegram?.chat_id) {
|
|
377
|
+
const chatKey = `telegram:${cfg.telegram.chat_id}`;
|
|
378
|
+
const id = cfg.telegram.chat_id;
|
|
379
|
+
const state = chatContext.get(chatKey);
|
|
380
|
+
const masked = id.length > 6 ? `${id.slice(0, 3)}***${id.slice(-3)}` : id;
|
|
381
|
+
chats.push({
|
|
382
|
+
platform: 'telegram',
|
|
383
|
+
chat_id: masked,
|
|
384
|
+
active_project: state.active_project,
|
|
385
|
+
muted_until: state.mute_until,
|
|
386
|
+
muted_messages: state.muted_messages,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return chats;
|
|
390
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown-to-native format converters for chat adapter responses.
|
|
3
|
+
*
|
|
4
|
+
* Command handlers write responses in standard markdown. Each adapter
|
|
5
|
+
* converts to its native format before sending.
|
|
6
|
+
*
|
|
7
|
+
* @module integrations/markdown
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Helpers
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Escape HTML special characters in text (for Telegram HTML).
|
|
16
|
+
*/
|
|
17
|
+
function escHtml(text) {
|
|
18
|
+
return text
|
|
19
|
+
.replace(/&/g, '&')
|
|
20
|
+
.replace(/</g, '<')
|
|
21
|
+
.replace(/>/g, '>');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Process a markdown string by extracting protected regions (code blocks and
|
|
26
|
+
* inline code) first, applying transformations to unprotected text, then
|
|
27
|
+
* restoring the protected regions.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} md - Markdown input
|
|
30
|
+
* @param {(text: string) => string} transformText - Transform non-code text
|
|
31
|
+
* @param {(code: string) => string} transformCodeBlock - Transform ```block```
|
|
32
|
+
* @param {(code: string) => string} transformInlineCode - Transform `code`
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
function processWithCodeProtection(
|
|
36
|
+
md,
|
|
37
|
+
transformText,
|
|
38
|
+
transformCodeBlock,
|
|
39
|
+
transformInlineCode,
|
|
40
|
+
) {
|
|
41
|
+
const placeholders = [];
|
|
42
|
+
let idx = 0;
|
|
43
|
+
|
|
44
|
+
function placeholder(value) {
|
|
45
|
+
const key = `\x00PH${idx++}\x00`;
|
|
46
|
+
placeholders.push({ key, value });
|
|
47
|
+
return key;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 1. Protect fenced code blocks (``` ... ```)
|
|
51
|
+
let result = md.replace(/```([\s\S]*?)```/g, (_match, code) => {
|
|
52
|
+
return placeholder(transformCodeBlock(code));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// 2. Protect inline code (` ... `)
|
|
56
|
+
result = result.replace(/`([^`]+)`/g, (_match, code) => {
|
|
57
|
+
return placeholder(transformInlineCode(code));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// 3. Transform the remaining (unprotected) text
|
|
61
|
+
result = transformText(result);
|
|
62
|
+
|
|
63
|
+
// 4. Restore placeholders
|
|
64
|
+
for (const { key, value } of placeholders) {
|
|
65
|
+
result = result.replace(key, value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Telegram HTML
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Convert standard markdown to Telegram HTML.
|
|
77
|
+
*
|
|
78
|
+
* Supports: bold, italic, code, code blocks, links, strikethrough.
|
|
79
|
+
* Escapes <, >, & in regular text before adding HTML tags.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} md
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
export function toTelegramHtml(md) {
|
|
85
|
+
if (!md) return '';
|
|
86
|
+
|
|
87
|
+
return processWithCodeProtection(
|
|
88
|
+
md,
|
|
89
|
+
// Transform regular text
|
|
90
|
+
(text) => {
|
|
91
|
+
// Escape HTML entities in regular text first
|
|
92
|
+
text = text
|
|
93
|
+
.replace(/&/g, '&')
|
|
94
|
+
.replace(/</g, '<')
|
|
95
|
+
.replace(/>/g, '>');
|
|
96
|
+
// Links: [text](url) → <a href="url">text</a>
|
|
97
|
+
text = text.replace(
|
|
98
|
+
/\[([^\]]+)\]\(([^)]+)\)/g,
|
|
99
|
+
(_m, label, url) => `<a href="${url}">${label}</a>`,
|
|
100
|
+
);
|
|
101
|
+
// Bold: **text** → <b>text</b>
|
|
102
|
+
text = text.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
|
|
103
|
+
// Italic: *text* → <i>text</i>
|
|
104
|
+
text = text.replace(/\*(.+?)\*/g, '<i>$1</i>');
|
|
105
|
+
// Strikethrough: ~~text~~ → <s>text</s>
|
|
106
|
+
text = text.replace(/~~(.+?)~~/g, '<s>$1</s>');
|
|
107
|
+
return text;
|
|
108
|
+
},
|
|
109
|
+
// Code block: ```block``` → <pre>block</pre>
|
|
110
|
+
(code) => `<pre>${escHtml(code)}</pre>`,
|
|
111
|
+
// Inline code: `code` → <code>code</code>
|
|
112
|
+
(code) => `<code>${escHtml(code)}</code>`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Discord markdown (pass-through)
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Convert standard markdown to Discord markdown.
|
|
122
|
+
* Discord supports standard markdown natively, so this is a pass-through.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} md
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
export function toDiscordMarkdown(md) {
|
|
128
|
+
return md ?? '';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Slack mrkdwn
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Convert standard markdown to Slack mrkdwn format.
|
|
137
|
+
*
|
|
138
|
+
* Supports: bold, italic, code, code blocks, links, strikethrough.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} md
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
export function toSlackMrkdwn(md) {
|
|
144
|
+
if (!md) return '';
|
|
145
|
+
|
|
146
|
+
return processWithCodeProtection(
|
|
147
|
+
md,
|
|
148
|
+
// Transform regular text
|
|
149
|
+
(text) => {
|
|
150
|
+
// Links: [text](url) → <url|text>
|
|
151
|
+
// Match links carefully — url part uses a greedy match up to the closing )
|
|
152
|
+
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, url) => {
|
|
153
|
+
// Escape pipe characters in url and label
|
|
154
|
+
const safeUrl = url.replace(/\|/g, '%7C');
|
|
155
|
+
const safeLabel = label.replace(/\|/g, '\u2758');
|
|
156
|
+
return `<${safeUrl}|${safeLabel}>`;
|
|
157
|
+
});
|
|
158
|
+
// Italic (single *) BEFORE bold — we need to match single * that are NOT
|
|
159
|
+
// part of ** pairs. Convert italic first using a temp marker, then bold.
|
|
160
|
+
// Step 1: Convert bold **text** → placeholder
|
|
161
|
+
const boldParts = [];
|
|
162
|
+
let bIdx = 0;
|
|
163
|
+
text = text.replace(/\*\*(.+?)\*\*/g, (_m, content) => {
|
|
164
|
+
const key = `\x01B${bIdx++}\x01`;
|
|
165
|
+
boldParts.push({ key, content });
|
|
166
|
+
return key;
|
|
167
|
+
});
|
|
168
|
+
// Step 2: Convert remaining italic *text* → _text_
|
|
169
|
+
text = text.replace(/\*(.+?)\*/g, '_$1_');
|
|
170
|
+
// Step 3: Restore bold as Slack bold *text*
|
|
171
|
+
for (const { key, content } of boldParts) {
|
|
172
|
+
text = text.replace(key, `*${content}*`);
|
|
173
|
+
}
|
|
174
|
+
// Strikethrough: ~~text~~ → ~text~
|
|
175
|
+
text = text.replace(/~~(.+?)~~/g, '~$1~');
|
|
176
|
+
return text;
|
|
177
|
+
},
|
|
178
|
+
// Code blocks pass through as-is (``` is native in Slack)
|
|
179
|
+
(code) => `\`\`\`${code}\`\`\``,
|
|
180
|
+
// Inline code passes through as-is
|
|
181
|
+
(code) => `\`${code}\``,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Plain text
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Strip all markdown formatting and return plain text.
|
|
191
|
+
*
|
|
192
|
+
* @param {string} md
|
|
193
|
+
* @returns {string}
|
|
194
|
+
*/
|
|
195
|
+
export function toPlainText(md) {
|
|
196
|
+
if (!md) return '';
|
|
197
|
+
|
|
198
|
+
return processWithCodeProtection(
|
|
199
|
+
md,
|
|
200
|
+
// Transform regular text
|
|
201
|
+
(text) => {
|
|
202
|
+
// Links: [text](url) → text (url)
|
|
203
|
+
text = text.replace(
|
|
204
|
+
/\[([^\]]+)\]\(([^)]+)\)/g,
|
|
205
|
+
(_m, label, url) => `${label} (${url})`,
|
|
206
|
+
);
|
|
207
|
+
// Bold: **text** → text
|
|
208
|
+
text = text.replace(/\*\*(.+?)\*\*/g, '$1');
|
|
209
|
+
// Italic: *text* → text
|
|
210
|
+
text = text.replace(/\*(.+?)\*/g, '$1');
|
|
211
|
+
// Strikethrough: ~~text~~ → text
|
|
212
|
+
text = text.replace(/~~(.+?)~~/g, '$1');
|
|
213
|
+
return text;
|
|
214
|
+
},
|
|
215
|
+
// Code blocks: just the content
|
|
216
|
+
(code) => code,
|
|
217
|
+
// Inline code: just the content
|
|
218
|
+
(code) => code,
|
|
219
|
+
);
|
|
220
|
+
}
|