@xmanrui/dsh-im 4.24.1 → 4.26.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.
Files changed (49) hide show
  1. package/PROACTIVE_DELIVERY.en.md +12 -4
  2. package/PROACTIVE_DELIVERY.md +12 -4
  3. package/README.en.md +11 -3
  4. package/README.md +11 -3
  5. package/THIRD_PARTY_NOTICES.md +2 -0
  6. package/lib/client.js +654 -311
  7. package/lib/index.js +298 -289
  8. package/package.json +7 -8
  9. package/plugin-src/client/channel-logos.js +11 -0
  10. package/plugin-src/client/channels/matrix/api.js +11 -0
  11. package/plugin-src/client/channels/matrix/index.js +151 -0
  12. package/plugin-src/client/channels/matrix/styles.js +36 -0
  13. package/plugin-src/client/global-settings.js +26 -14
  14. package/plugin-src/client/i18n.js +20 -0
  15. package/plugin-src/client/index.js +20 -0
  16. package/plugin-src/client/session-channel-logos.js +2 -1
  17. package/plugin-src/client/styles.js +19 -3
  18. package/plugin-src/client/update-panel.js +31 -11
  19. package/plugin-src/host/channels/matrix/index.mjs +31 -0
  20. package/plugin-src/host/channels/matrix/production.mjs +227 -0
  21. package/plugin-src/host/channels/matrix/rpc.mjs +228 -0
  22. package/plugin-src/host/channels/shared/access-policy-production.mjs +1 -1
  23. package/plugin-src/host/channels/shared/startup-error.mjs +2 -1
  24. package/plugin-src/host/delivery-adapter.mjs +18 -0
  25. package/plugin-src/host/delivery-rpc.mjs +7 -2
  26. package/plugin-src/host/delivery-service.mjs +8 -2
  27. package/plugin-src/host/delivery-suggestions.mjs +13 -0
  28. package/plugin-src/host/index.mjs +3 -0
  29. package/scripts/verify-injected-context.mjs +120 -0
  30. package/scripts/verify-lan-management.mjs +1 -1
  31. package/scripts/verify-package.mjs +6 -1
  32. package/src/channels/feishu/feishu-runtime.mjs +13 -3
  33. package/src/channels/matrix/matrix-api.mjs +696 -0
  34. package/src/channels/matrix/matrix-bridge.mjs +20 -0
  35. package/src/channels/matrix/matrix-config-store.mjs +356 -0
  36. package/src/channels/matrix/matrix-controller.mjs +404 -0
  37. package/src/channels/matrix/matrix-crypto-store.mjs +279 -0
  38. package/src/channels/matrix/matrix-crypto.mjs +1014 -0
  39. package/src/channels/matrix/matrix-harness-client.mjs +11 -0
  40. package/src/channels/matrix/matrix-normalize.mjs +357 -0
  41. package/src/channels/matrix/matrix-rich-text.mjs +313 -0
  42. package/src/channels/matrix/matrix-runtime.mjs +900 -0
  43. package/src/channels/shared/command-catalog.mjs +1 -1
  44. package/src/channels/shared/i18n-en/image-input.mjs +1 -0
  45. package/src/channels/shared/i18n-en/matrix.mjs +75 -0
  46. package/src/channels/shared/i18n-en.mjs +2 -0
  47. package/src/channels/shared/injected-context.mjs +3 -3
  48. package/src/channels/shared/session-channel-labels.mjs +1 -0
  49. package/src/channels/shared/text-harness-bridge.mjs +3 -0
@@ -0,0 +1,11 @@
1
+ import { HarnessClient } from '../shared/harness-client.mjs';
2
+
3
+ export class MatrixHarnessClient extends HarnessClient {
4
+ constructor(options) {
5
+ super({
6
+ ...options,
7
+ rpcIdPrefix: 'matrix',
8
+ logPrefix: 'dsh-matrix',
9
+ });
10
+ }
11
+ }
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Pure Matrix inbound funnel helpers: identity guards, dedup ring, startup
3
+ * grace and clock-skew detection, mention detection with its fallback chain,
4
+ * mention stripping, the `!command` normalization and the normalized-message
5
+ * projection the TextHarnessBridge consumes. Every function is deterministic
6
+ * and side-effect free so the funnel is unit-testable without a homeserver.
7
+ */
8
+
9
+ import { isMatrixEventId, isMatrixRoomId, isMatrixUserId } from './matrix-api.mjs';
10
+
11
+ const GRACE_MS = 5_000;
12
+ const SKEW_OBSERVE_AFTER_MS = 30_000;
13
+ const SKEW_THRESHOLD_MS = 300_000;
14
+ const SKEW_STREAK_TO_WARN = 3;
15
+
16
+ function lowerId(value) {
17
+ return typeof value === 'string' ? value.trim().toLowerCase() : '';
18
+ }
19
+
20
+ export function localpartOf(mxid) {
21
+ const raw = lowerId(mxid);
22
+ if (!raw.startsWith('@')) return '';
23
+ const separator = raw.indexOf(':');
24
+ return separator > 1 ? raw.slice(1, separator) : '';
25
+ }
26
+
27
+ export function serverNameOf(mxid) {
28
+ const raw = lowerId(mxid);
29
+ if (!raw.startsWith('@')) return '';
30
+ const separator = raw.indexOf(':');
31
+ return separator > 1 ? raw.slice(separator + 1) : '';
32
+ }
33
+
34
+ export class EventDedupeRing {
35
+ #capacity;
36
+ #seen = new Set();
37
+ #order = [];
38
+
39
+ constructor(capacity = 1_000) {
40
+ this.#capacity = capacity;
41
+ }
42
+
43
+ seed(ids) {
44
+ for (const id of Array.isArray(ids) ? ids : []) this.#push(id);
45
+ }
46
+
47
+ has(eventId) {
48
+ return this.#seen.has(eventId);
49
+ }
50
+
51
+ /** Returns true only for the first observation of an event id. */
52
+ mark(eventId) {
53
+ if (typeof eventId !== 'string' || !eventId || this.#seen.has(eventId)) return false;
54
+ this.#push(eventId);
55
+ return true;
56
+ }
57
+
58
+ snapshot() {
59
+ return [...this.#order];
60
+ }
61
+
62
+ #push(eventId) {
63
+ if (this.#seen.has(eventId)) return;
64
+ this.#seen.add(eventId);
65
+ this.#order.push(eventId);
66
+ while (this.#order.length > this.#capacity) {
67
+ const oldest = this.#order.shift();
68
+ this.#seen.delete(oldest);
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Drops timeline events stamped before the connection became live and detects a
75
+ * host clock running ahead of the homeserver: after the observation window,
76
+ * three consecutive far-past timestamps with a consistent offset mark the
77
+ * local clock skewed, so the guard warns exactly once and keeps dropping the
78
+ * stale backlog instead of silencing the bot forever.
79
+ */
80
+ export class ClockSkewGuard {
81
+ #startupTsMs;
82
+ #firstObservedAt = null;
83
+ #skewStreak = 0;
84
+ #warned = false;
85
+
86
+ constructor({ startupTsMs = Date.now() } = {}) {
87
+ this.#startupTsMs = startupTsMs;
88
+ }
89
+
90
+ evaluate(eventOriginServerTsMs, nowMs = Date.now()) {
91
+ if (!Number.isSafeInteger(eventOriginServerTsMs) || eventOriginServerTsMs <= 0) {
92
+ return { drop: false, warnSkew: false };
93
+ }
94
+ if (eventOriginServerTsMs < this.#startupTsMs - GRACE_MS) {
95
+ return { drop: true, warnSkew: false };
96
+ }
97
+ const skewMs = nowMs - eventOriginServerTsMs;
98
+ if (skewMs <= SKEW_THRESHOLD_MS || nowMs - this.#startupTsMs < SKEW_OBSERVE_AFTER_MS) {
99
+ this.#skewStreak = 0;
100
+ return { drop: false, warnSkew: false };
101
+ }
102
+ this.#skewStreak += 1;
103
+ if (!this.#warned && this.#skewStreak >= SKEW_STREAK_TO_WARN) {
104
+ this.#warned = true;
105
+ return { drop: true, warnSkew: true };
106
+ }
107
+ return { drop: this.#warned, warnSkew: false };
108
+ }
109
+
110
+ get warned() {
111
+ return this.#warned;
112
+ }
113
+ }
114
+
115
+ export function isSelfSender(sender, botUserId) {
116
+ // An unresolved own identity is treated as self: dropping a first message is
117
+ // survivable, answering oneself in a loop is not.
118
+ const own = lowerId(botUserId);
119
+ if (!own) return true;
120
+ return lowerId(sender) === own;
121
+ }
122
+
123
+ export function isBridgeOrSystemSender(sender) {
124
+ const raw = typeof sender === 'string' ? sender.trim() : '';
125
+ if (!raw.startsWith('@')) return true;
126
+ const localpart = localpartOf(raw);
127
+ if (!localpart) return true;
128
+ // Appservice bridges and puppeting conventions prefix virtual users with `_`.
129
+ return localpart.startsWith('_');
130
+ }
131
+
132
+ export function compileIgnorePatterns(values) {
133
+ const patterns = [];
134
+ for (const value of Array.isArray(values) ? values : []) {
135
+ if (typeof value !== 'string' || !value.trim()) continue;
136
+ try {
137
+ patterns.push(new RegExp(value, 'iu'));
138
+ } catch {
139
+ // A broken operator pattern must not break the funnel; it is skipped.
140
+ }
141
+ }
142
+ return patterns;
143
+ }
144
+
145
+ export function matchesIgnoredSender(sender, patterns) {
146
+ const raw = typeof sender === 'string' ? sender.trim() : '';
147
+ if (!raw) return false;
148
+ return patterns.some((pattern) => {
149
+ pattern.lastIndex = 0;
150
+ return pattern.test(raw);
151
+ });
152
+ }
153
+
154
+ /**
155
+ * Mention detection with a fallback chain: the MSC3952 `m.mentions.user_ids`
156
+ * signal is authoritative, then a raw full MXID in the body, then a pill link
157
+ * in formatted_body, then a word-bounded localpart. One weak signal missing
158
+ * never silences the bot because the chain keeps the stronger ones.
159
+ */
160
+ export function detectMatrixMention(content, botUserId) {
161
+ const own = lowerId(botUserId);
162
+ if (!own) return false;
163
+ const mentions = Array.isArray(content?.['m.mentions']?.user_ids) ? content['m.mentions'].user_ids : [];
164
+ if (mentions.some((candidate) => lowerId(candidate) === own)) return true;
165
+ const body = typeof content?.body === 'string' ? content.body : '';
166
+ if (body.toLowerCase().includes(`<${own}>`)) return true;
167
+ if (body.toLowerCase().includes(own)) {
168
+ const escaped = own.replace(/[.*+?^${}()|\\\[\]]/g, '\\$&');
169
+ if (new RegExp(`(^|[^A-Za-z0-9._=\\-\\/+])${escaped}($|[^A-Za-z0-9._=\\-\\/+])`, 'iu').test(body)) return true;
170
+ }
171
+ const formatted = typeof content?.formatted_body === 'string' ? content.formatted_body : '';
172
+ if (formatted.toLowerCase().includes(`matrix.to/#/${own}`)) return true;
173
+ const localpart = localpartOf(own);
174
+ if (localpart.length >= 2 && localpart !== 'room' && localpart !== 'all') {
175
+ const escaped = localpart.replace(/[.*+?^${}()|\\\[\]]/g, '\\$&');
176
+ const bodyHit = new RegExp(`(^|[^A-Za-z0-9._=\\-\\/+])@${escaped}($|[^A-Za-z0-9._=\\-\\/+])`, 'iu').test(body);
177
+ if (bodyHit) return true;
178
+ }
179
+ return false;
180
+ }
181
+
182
+ /**
183
+ * Remove bot-directed mention forms from prompt text. Only full `@local:server`
184
+ * forms and Matrix pill forms are stripped; a bare localpart word is never
185
+ * removed so phrases like "Hermes Agent" survive intact.
186
+ */
187
+ export function stripMatrixMentions(body, botUserId) {
188
+ let text = String(body ?? '');
189
+ const own = lowerId(botUserId);
190
+ if (own) {
191
+ const escaped = own.replace(/[.*+?^${}()|\\\[\]]/g, '\\$&');
192
+ const pill = new RegExp(`\\[<${escaped}>\\]\\(https?://matrix\\.to/[^)]*\\)`, 'gi');
193
+ text = text.replaceAll(pill, '');
194
+ text = text.replaceAll(new RegExp(`<${escaped}>`, 'gi'), '');
195
+ text = text.replaceAll(new RegExp(escaped, 'gi'), '');
196
+ }
197
+ return text.trim();
198
+ }
199
+
200
+ export function isMatrixCommandLike(text) {
201
+ return /^[/!][A-Za-z][A-Za-z0-9_-]{0,63}(\s|$)/.test(String(text ?? '').trim());
202
+ }
203
+
204
+ export function resolveBangMatrixCommand(text, isKnownSlashCommand) {
205
+ const trimmed = String(text ?? '');
206
+ if (!trimmed.startsWith('!') || trimmed.startsWith('!!')) return trimmed;
207
+ const candidate = trimmed.replace(/^!/, '');
208
+ const token = `/${candidate.split(/\s/u, 1)[0] ?? ''}`.replace(/[。?!,.!?;;:]+$/u, '');
209
+ const name = token.slice(1);
210
+ if (!name || typeof isKnownSlashCommand !== 'function') return trimmed;
211
+ if (!isKnownSlashCommand(name) && !isKnownSlashCommand(token)) return trimmed;
212
+ return `/${candidate}`;
213
+ }
214
+
215
+ const MEDIA_MSGTYPES = new Set(['m.image', 'm.audio', 'm.video', 'm.file']);
216
+
217
+ function eventOriginTs(event) {
218
+ const ts = Number(event?.origin_server_ts ?? event?.['org.matrix.server_ts']);
219
+ return Number.isSafeInteger(ts) && ts > 0 ? (ts < 1e12 ? ts * 1_000 : ts) : null;
220
+ }
221
+
222
+ function threadIdOf(content) {
223
+ const relates = content?.['m.relates_to'];
224
+ if (!relates || typeof relates !== 'object') return null;
225
+ const chain = Array.isArray(relates.chain) ? relates.chain : [];
226
+ const threaded = chain.find((entry) => entry?.rel_type === 'm.thread') ?? (relates.rel_type === 'm.thread' ? relates : null);
227
+ const eventId = typeof threaded?.event_id === 'string' ? threaded.event_id : null;
228
+ return eventId && isMatrixEventId(eventId) ? eventId : null;
229
+ }
230
+
231
+ function replyEventIdOf(content) {
232
+ const inReplyTo = content?.['m.relates_to']?.['m.in_reply_to'];
233
+ const eventId = typeof inReplyTo?.event_id === 'string' ? inReplyTo.event_id : null;
234
+ return eventId && isMatrixEventId(eventId) ? eventId : null;
235
+ }
236
+
237
+ /**
238
+ * Project one timeline event into the normalized bridge message, or into a
239
+ * typed drop reason. The order of gates is fixed: self, bridge/system,
240
+ * ignore patterns, dedup, clock, shape, notice/edit, room policy, mention
241
+ * policy. Media sources are created through `deps.createMediaSource` so the
242
+ * funnel stays free of transport code.
243
+ */
244
+ export function normalizeMatrixTimelineEvent({
245
+ event,
246
+ roomId,
247
+ botUserId,
248
+ isDirect,
249
+ config = {},
250
+ patterns = [],
251
+ ring,
252
+ clock,
253
+ deps = {},
254
+ } = {}) {
255
+ const senderId = typeof event?.sender === 'string' ? event.sender.trim() : '';
256
+ const eventId = typeof event?.event_id === 'string' ? event.event_id.trim() : '';
257
+ if (!isMatrixRoomId(roomId) || !eventId || !senderId) return { drop: 'malformed' };
258
+ if (isSelfSender(senderId, botUserId)) return { drop: 'self' };
259
+ if (isBridgeOrSystemSender(senderId)) return { drop: 'bridge' };
260
+ if (matchesIgnoredSender(senderId, patterns)) return { drop: 'ignored' };
261
+ if (ring && !ring.mark(eventId)) return { drop: 'duplicate' };
262
+ if (clock) {
263
+ const verdict = clock.evaluate(eventOriginTs(event));
264
+ if (verdict.drop) return { drop: 'clock', warnSkew: verdict.warnSkew };
265
+ }
266
+ if (event?.type !== 'm.room.message') return { drop: 'type' };
267
+ const content = event.content && typeof event.content === 'object' ? event.content : {};
268
+ const msgtype = typeof content.msgtype === 'string' ? content.msgtype : 'm.text';
269
+ if (msgtype === 'm.notice' && config.processNotices !== true) return { drop: 'notice' };
270
+ const isEdit = content['m.relates_to']?.rel_type === 'm.replace';
271
+ if (isEdit) return { drop: 'edit' };
272
+
273
+ const kind = isDirect ? 'direct' : 'group';
274
+ const allowedRooms = config.allowedRooms instanceof Set ? config.allowedRooms : null;
275
+ if (kind === 'group' && allowedRooms && allowedRooms.size > 0 && !allowedRooms.has(roomId)) {
276
+ return { drop: 'room-not-allowed' };
277
+ }
278
+
279
+ const threadId = threadIdOf(content);
280
+ const replyToEventId = replyEventIdOf(content);
281
+ const rawText = typeof content.body === 'string' ? content.body : '';
282
+ const mentioned = detectMatrixMention(content, botUserId);
283
+ const commandLike = isMatrixCommandLike(rawText);
284
+ const requiresMention = kind === 'group'
285
+ && config.requireMention !== false
286
+ && !(config.freeResponseRooms instanceof Set && config.freeResponseRooms.has(roomId));
287
+ if (requiresMention && !mentioned && !commandLike && replyToEventId === null) return { drop: 'mention-required' };
288
+
289
+ const text = stripMatrixMentions(rawText, botUserId);
290
+ const media = MEDIA_MSGTYPES.has(msgtype)
291
+ ? (deps.createMediaSource ? deps.createMediaSource(content, msgtype) : null)
292
+ : null;
293
+ if (MEDIA_MSGTYPES.has(msgtype) && !media) return { drop: 'media-unusable' };
294
+ if (msgtype !== 'm.text' && msgtype !== 'm.notice' && !MEDIA_MSGTYPES.has(msgtype)) return { drop: 'type' };
295
+
296
+ const conversationId = kind === 'direct'
297
+ ? `dm:${senderId.toLowerCase()}`
298
+ : `room:${roomId}${threadId ? `$${threadId}` : ''}`;
299
+
300
+ return {
301
+ message: {
302
+ kind,
303
+ roomId,
304
+ messageId: eventId,
305
+ senderId,
306
+ senderIsBot: false,
307
+ conversationId,
308
+ contextSource: () => ({ chatId: roomId, ...(threadId ? { threadId } : {}) }),
309
+ content: text,
310
+ plainText: msgtype === 'm.text' && content.format !== 'org.matrix.custom.html',
311
+ images: media?.images ?? [],
312
+ files: media?.files ?? [],
313
+ addressed: kind === 'direct' || mentioned || commandLike || replyToEventId !== null,
314
+ mentioned,
315
+ threadId,
316
+ replyToEventId,
317
+ reactionTarget: { roomId, eventId },
318
+ replyTarget: {
319
+ roomId,
320
+ ...(threadId ? { threadId } : {}),
321
+ ...(!threadId && replyToEventId ? { replyToEventId } : {}),
322
+ recipientUserId: senderId,
323
+ },
324
+ ...(kind === 'direct' ? { connectionTestTarget: { roomId } } : {}),
325
+ },
326
+ };
327
+ }
328
+
329
+ export function normalizeMatrixDeliveryTarget({ kind, route } = {}) {
330
+ if (kind === 'room') {
331
+ if (Object.keys(route ?? {}).join(',') !== 'roomId') return { error: 'route' };
332
+ const roomId = String(route.roomId ?? '').trim();
333
+ if (!isMatrixRoomId(roomId)) return { error: 'roomId' };
334
+ return { value: { kind, roomId } };
335
+ }
336
+ if (kind === 'thread') {
337
+ if (Object.keys(route ?? {}).sort().join(',') !== 'roomId,threadId') return { error: 'route' };
338
+ const roomId = String(route.roomId ?? '').trim();
339
+ const threadId = String(route.threadId ?? '').trim();
340
+ if (!isMatrixRoomId(roomId)) return { error: 'roomId' };
341
+ if (!isMatrixEventId(threadId)) return { error: 'threadId' };
342
+ return { value: { kind, roomId, threadId } };
343
+ }
344
+ if (kind === 'dm') {
345
+ if (Object.keys(route ?? {}).join(',') !== 'userId') return { error: 'route' };
346
+ const userId = String(route.userId ?? '').trim();
347
+ if (!isMatrixUserId(userId)) return { error: 'userId' };
348
+ return { value: { kind, userId } };
349
+ }
350
+ return { error: 'kind' };
351
+ }
352
+
353
+ export function matrixConversationKeyFromTarget(target) {
354
+ if (!target) return null;
355
+ if (target.kind === 'dm') return `dm:${target.userId.toLowerCase()}`;
356
+ return `room:${target.roomId}${'threadId' in target && target.threadId ? `$${target.threadId}` : ''}`;
357
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Matrix-side rich-text construction and pure event-content builders.
3
+ *
4
+ * The harness replies with Markdown or plain text. Matrix clients render
5
+ * `formatted_body` when the format is recognized, so this module produces the
6
+ * sanitized HTML projection plus the always-present plain `body` fallback,
7
+ * injects MXID pills outside code regions, and builds the relation events for
8
+ * replies, threads, edits and reactions exactly once per message pipeline.
9
+ */
10
+
11
+ const SAFE_URL_SCHEMES = new Set(['http:', 'https:', 'matrix:', 'mailto:']);
12
+
13
+ const ALLOWED_TAGS = Object.freeze({
14
+ br: [], b: [], strong: [], i: [], em: [], u: [], del: [], s: [],
15
+ code: ['class'], pre: [], blockquote: [], ul: [], ol: [], li: [],
16
+ a: ['href'], h1: [], h2: [], h3: [], h4: [], h5: [], h6: [],
17
+ table: [], thead: [], tbody: [], tr: [], th: [], td: [],
18
+ p: [], hr: [], details: [], summary: [], span: ['data-mx-id', 'data-mx-pill'],
19
+ });
20
+
21
+ const TAG_PATTERN = /<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9-]*)((?:[^<>"']|"[^"]*"|'[^']*')*)\s*(\/?)\s*>/g;
22
+ const ATTRIBUTE_PATTERN = /([a-zA-Z_:][-a-zA-Z0-9_:]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
23
+
24
+ export function escapeMatrixHtml(value) {
25
+ return String(value ?? '')
26
+ .replaceAll('&', '&amp;')
27
+ .replaceAll('<', '&lt;')
28
+ .replaceAll('>', '&gt;')
29
+ .replaceAll('"', '&quot;');
30
+ }
31
+
32
+ function safeMatrixUrl(value) {
33
+ const raw = String(value ?? '').trim();
34
+ if (!raw || /[\u0000-\u0020\u007f]/u.test(raw)) return null;
35
+ if (raw.startsWith('#') || raw.startsWith('/')) return null;
36
+ let url;
37
+ try {
38
+ url = new URL(raw);
39
+ } catch {
40
+ // Relative and scheme-less links stay visible as plain text instead of
41
+ // becoming dead anchors; Matrix clients cannot resolve them anyway.
42
+ if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/u.test(raw)) return raw;
43
+ return null;
44
+ }
45
+ return SAFE_URL_SCHEMES.has(url.protocol) ? url.href : null;
46
+ }
47
+
48
+ function decodeAttributeEntities(value) {
49
+ return String(value ?? '')
50
+ .replaceAll('&lt;', '<')
51
+ .replaceAll('&gt;', '>')
52
+ .replaceAll('&quot;', '"')
53
+ .replaceAll('&#39;', "'")
54
+ .replaceAll(/&#(\d{1,7});/gu, (match) => String.fromCodePoint(Number(match.slice(2, -1))))
55
+ .replaceAll(/&#x([0-9A-Fa-f]{1,6});/gu, (match) => String.fromCodePoint(Number(`0x${match.slice(3, -1)}`)))
56
+ .replaceAll('&amp;', '&');
57
+ }
58
+
59
+ function sanitizeAttributes(tag, attributeText) {
60
+ const allowed = ALLOWED_TAGS[tag] ?? [];
61
+ if (allowed.length === 0 || !attributeText.trim()) return '';
62
+ const kept = [];
63
+ for (const match of attributeText.matchAll(ATTRIBUTE_PATTERN)) {
64
+ const name = String(match[1] ?? '').toLowerCase();
65
+ const value = match[2] ?? match[3] ?? match[4] ?? '';
66
+ if (!allowed.includes(name)) continue;
67
+ if (name === 'href') {
68
+ const safe = safeMatrixUrl(decodeAttributeEntities(value));
69
+ if (safe) kept.push(`href="${escapeMatrixHtml(safe)}"`);
70
+ continue;
71
+ }
72
+ if (name === 'class') {
73
+ if (/^language-[A-Za-z0-9+-]{1,64}$/.test(value)) kept.push(`class="${escapeMatrixHtml(value)}"`);
74
+ continue;
75
+ }
76
+ kept.push(`${name}="${escapeMatrixHtml(value)}"`);
77
+ }
78
+ return kept.length ? ` ${kept.join(' ')}` : '';
79
+ }
80
+
81
+ /**
82
+ * Whitelist-only HTML sanitizer for outbound `formatted_body`. Unknown tags are
83
+ * dropped while their text content survives; `on*` attributes, inline event
84
+ * handlers and `javascript:`/`data:` schemes never survive the attribute pass.
85
+ */
86
+ export function sanitizeMatrixHtml(html) {
87
+ if (typeof html !== 'string' || !html) return '';
88
+ const stripped = html
89
+ .replaceAll(/<\s*(script|style|iframe|form|object|embed)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
90
+ .replaceAll(/<\s*(script|style|iframe|form|object|embed)\b[^>]*\/?\s*>/gi, '');
91
+ return stripped.replaceAll(TAG_PATTERN, (whole, closing, rawTag, attributes, selfClose) => {
92
+ const tag = rawTag.toLowerCase();
93
+ if (!Object.hasOwn(ALLOWED_TAGS, tag)) return '';
94
+ if (closing) return selfClose ? '' : `</${tag}>`;
95
+ const attribute = sanitizeAttributes(tag, attributes ?? '');
96
+ const selfClosing = selfClose && (tag === 'br' || tag === 'hr' || tag === 'img') ? ' /' : '';
97
+ return `<${tag}${attribute}${selfClosing}>`;
98
+ });
99
+ }
100
+
101
+ function inlineMatrixPass(escaped) {
102
+ const codeStash = [];
103
+ const stash = (html) => {
104
+ codeStash.push(html);
105
+ return `\u0000${codeStash.length - 1}\u0000`;
106
+ };
107
+ let text = escaped.replace(/`([^`\n]+)`/g, (_whole, inner) => stash(`<code>${inner}</code>`));
108
+ text = text.replace(/\*\*([^*\n]+)\*\*|__([^_\n]+)__/g, (_whole, a, b) => `<strong>${a ?? b}</strong>`);
109
+ text = text.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, (_whole, lead, inner) => `${lead}<em>${inner}</em>`);
110
+ text = text.replace(/~~([^~\n]+)~~/g, (_whole, inner) => `<del>${inner}</del>`);
111
+ text = text.replace(/\[([^\]\n]*)\]\(([^)\s]+)\)/g, (_whole, label, target) => {
112
+ const safe = safeMatrixUrl(target.replaceAll(/&amp;/g, '&'));
113
+ if (!safe) return `${label} (${target})`;
114
+ return stash(`<a href="${escapeMatrixHtml(safe)}">${label}</a>`);
115
+ });
116
+ text = text.replace(/\u0000(\d+)\u0000/g, (_whole, index) => codeStash[Number(index)] ?? '');
117
+ return text;
118
+ }
119
+
120
+ /**
121
+ * Deterministic Markdown subset for Matrix rendering: fenced and inline code,
122
+ * bold/italic/strikethrough, links, ATX headings, bullet and ordered lists,
123
+ * blockquotes, thematic breaks and hard line breaks. Everything else stays
124
+ * literal text, so tables keep readable prose instead of collapsing.
125
+ */
126
+ export function markdownToMatrixHtml(text) {
127
+ const source = String(text ?? '');
128
+ if (!source) return '';
129
+ const lines = source.replaceAll('\r\n', '\n').split('\n');
130
+ const blocks = [];
131
+ let paragraph = [];
132
+ let list = null;
133
+ let quote = [];
134
+ let fence = null;
135
+
136
+ const flushParagraph = () => {
137
+ if (!paragraph.length) return;
138
+ blocks.push(`<p>${paragraph.map((line) => inlineMatrixPass(escapeMatrixHtml(line))).join('<br/>')}</p>`);
139
+ paragraph = [];
140
+ };
141
+ const flushList = () => {
142
+ if (!list) return;
143
+ const items = list.items.map((item) => `<li>${inlineMatrixPass(escapeMatrixHtml(item))}</li>`).join('');
144
+ blocks.push(list.ordered ? `<ol>${items}</ol>` : `<ul>${items}</ul>`);
145
+ list = null;
146
+ };
147
+ const flushQuote = () => {
148
+ if (!quote.length) return;
149
+ blocks.push(`<blockquote>${quote.map((line) => inlineMatrixPass(escapeMatrixHtml(line))).join('<br/>')}</blockquote>`);
150
+ quote = [];
151
+ };
152
+ const flushAll = () => {
153
+ flushParagraph();
154
+ flushList();
155
+ flushQuote();
156
+ };
157
+
158
+ for (const line of lines) {
159
+ const fenceMatch = /^\s{0,3}(`{3,}|~{3,})\s*([A-Za-z0-9+._-]*)\s*$/.exec(line);
160
+ if (fence && line.trim().endsWith(fence.marker) && line.trim().length >= fence.marker.length) {
161
+ blocks.push(`<pre><code class="language-${escapeMatrixHtml(fence.lang || 'plain')}">${
162
+ escapeMatrixHtml(fence.body.join('\n'))
163
+ }</code></pre>`);
164
+ fence = null;
165
+ continue;
166
+ }
167
+ if (fence) {
168
+ fence.body.push(line);
169
+ continue;
170
+ }
171
+ if (fenceMatch) {
172
+ flushAll();
173
+ fence = { marker: fenceMatch[1], lang: fenceMatch[2] ?? '', body: [] };
174
+ continue;
175
+ }
176
+ const heading = /^\s{0,3}(#{1,6})\s+(.*)$/.exec(line);
177
+ if (heading) {
178
+ flushAll();
179
+ blocks.push(`<p><strong>${inlineMatrixPass(escapeMatrixHtml(heading[2].trim()))}</strong></p>`);
180
+ continue;
181
+ }
182
+ if (/^\s{0,3}([-*_])\s*(?:\1\s*){2,}$/.test(line)) {
183
+ flushAll();
184
+ blocks.push('<hr/>');
185
+ continue;
186
+ }
187
+ const quoteMatch = /^\s{0,3}>\s?(.*)$/.exec(line);
188
+ if (quoteMatch) {
189
+ flushParagraph();
190
+ flushList();
191
+ quote.push(quoteMatch[1]);
192
+ continue;
193
+ }
194
+ flushQuote();
195
+ const bullet = /^\s*[-+*]\s+(.*)$/.exec(line);
196
+ const ordered = /^\s*(\d{1,9})[.)]\s+(.*)$/.exec(line);
197
+ if (bullet || ordered) {
198
+ flushParagraph();
199
+ const orderedItem = ordered ? ordered[2] : bullet[1];
200
+ if (!list || list.ordered !== Boolean(ordered)) {
201
+ flushList();
202
+ list = { ordered: Boolean(ordered), items: [] };
203
+ }
204
+ list.items.push(orderedItem);
205
+ continue;
206
+ }
207
+ if (!line.trim()) {
208
+ flushAll();
209
+ continue;
210
+ }
211
+ flushList();
212
+ flushQuote();
213
+ paragraph.push(line);
214
+ }
215
+ if (fence) {
216
+ blocks.push(`<pre><code class="language-${escapeMatrixHtml(fence.lang || 'plain')}">${
217
+ escapeMatrixHtml(fence.body.join('\n'))
218
+ }</code></pre>`);
219
+ }
220
+ flushAll();
221
+ return blocks.join('\n');
222
+ }
223
+
224
+ function protectCodeRegions(text) {
225
+ const stash = [];
226
+ const protectedText = text
227
+ .replaceAll(/```[\s\S]*?```/g, (whole) => {
228
+ stash.push(whole);
229
+ return `\u0001${stash.length - 1}\u0001`;
230
+ })
231
+ .replaceAll(/`[^`\n]+`/g, (whole) => {
232
+ stash.push(whole);
233
+ return `\u0001${stash.length - 1}\u0001`;
234
+ });
235
+ return { protectedText, stash };
236
+ }
237
+
238
+ function restoreCodeRegions(text, stash) {
239
+ return text.replaceAll(/\u0001(\d+)\u0001/g, (_whole, index) => stash[Number(index)] ?? '');
240
+ }
241
+
242
+ const OUTBOUND_MENTION_PATTERN = /(^|[\s(])@([A-Za-z0-9._=\-\/+]+):([A-Za-z0-9.-]+(?::\d{1,5})?)/g;
243
+
244
+ /** Collect the full MXIDs an outbound message mentions for `m.mentions`. */
245
+ export function extractOutboundMentions(text) {
246
+ const mentions = [];
247
+ const { protectedText } = protectCodeRegions(String(text ?? ''));
248
+ for (const match of protectedText.matchAll(OUTBOUND_MENTION_PATTERN)) {
249
+ mentions.push(`@${match[2]}:${match[3]}`);
250
+ }
251
+ return [...new Set(mentions)];
252
+ }
253
+
254
+ export function hasRoomMention(text) {
255
+ const { protectedText } = protectCodeRegions(String(text ?? ''));
256
+ return /(^|[\s(])@(room|all)\b/u.test(protectedText);
257
+ }
258
+
259
+ /**
260
+ * Wrap full MXIDs outside code regions into Matrix pill links so Element-style
261
+ * clients render them as chips; `m.mentions` still carries the notification set.
262
+ */
263
+ export function injectOutboundMentionPills(text) {
264
+ const { protectedText, stash } = protectCodeRegions(String(text ?? ''));
265
+ const pillText = protectedText.replaceAll(
266
+ OUTBOUND_MENTION_PATTERN,
267
+ (_whole, lead, localpart, server) => `${lead}[<@${localpart}:${server}>](https://matrix.to/#/@${localpart}:${server})`,
268
+ );
269
+ return restoreCodeRegions(pillText, stash);
270
+ }
271
+
272
+ export function buildMatrixTextContent({ text, mentionUserIds = [], roomMention = false } = {}) {
273
+ const body = String(text ?? '');
274
+ const html = sanitizeMatrixHtml(markdownToMatrixHtml(injectOutboundMentionPills(body)));
275
+ const hasHtml = Boolean(html) && html !== escapeMatrixHtml(body).replaceAll(/\n/g, '<br/>');
276
+ const content = { msgtype: 'm.text', body };
277
+ if (hasHtml) {
278
+ content.format = 'org.matrix.custom.html';
279
+ content.formatted_body = html;
280
+ }
281
+ const user_ids = [...new Set(mentionUserIds)];
282
+ if (user_ids.length || roomMention) content['m.mentions'] = { user_ids, 'm.room': roomMention };
283
+ return content;
284
+ }
285
+
286
+ export function buildMatrixEditContent({ originalContent, newText, eventId } = {}) {
287
+ const edited = buildMatrixTextContent({ text: newText, mentionUserIds: originalContent?.['m.mentions']?.user_ids ?? [] });
288
+ const content = { ...originalContent, ...edited, body: `* ${edited.body}` };
289
+ if (edited.formatted_body) content.formatted_body = `* ${edited.formatted_body}`;
290
+ content['m.new_content'] = edited;
291
+ content['m.relates_to'] = { event_id: eventId, rel_type: 'm.replace' };
292
+ return content;
293
+ }
294
+
295
+ export function buildMatrixReactionContent(eventId, key) {
296
+ return { 'm.relates_to': { event_id: eventId, rel_type: 'm.annotation' }, 'm.reaction': key };
297
+ }
298
+
299
+ export function applyMatrixRelations(content, { threadId, replyToEventId } = {}) {
300
+ if (replyToEventId && !threadId) {
301
+ content['m.relates_to'] = { 'm.in_reply_to': { event_id: replyToEventId }, is_falling_back: false };
302
+ return content;
303
+ }
304
+ if (threadId) {
305
+ content['m.relates_to'] = {
306
+ event_id: threadId,
307
+ rel_type: 'm.thread',
308
+ is_falling_back: true,
309
+ 'm.in_reply_to': { event_id: replyToEventId ?? threadId },
310
+ };
311
+ }
312
+ return content;
313
+ }