Repository navigation
Expand file tree
/
Copy pathmain.js
More file actions
3727 lines (3266 loc) · 137 KB
/
Copy pathmain.js
File metadata and controls
3727 lines (3266 loc) · 137 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { NodeJSSerialConnection, Constants } from "@liamcottle/meshcore.js";
import {
REST,
Routes,
Client,
GatewayIntentBits,
SlashCommandBuilder,
MessageFlags,
Partials,
PermissionFlagsBits
} from "discord.js";
import fs from "fs";
import log from "./lib/logger.js";
import { loadConfig, saveConfigAtomic, validateConfig } from "./lib/config.js";
import { escapeRegex, normalizeForMesh, sleep, splitByMaxLen, generateMeshHash, utf8Len, truncateUtf8 } from "./lib/utils.js";
import {
isPocketMeshReact, parseMeshReaction, trackMessage, findHashByDiscordMessageId,
getMessageHistory, isDuplicate, flushHistory, clearHistorySaveTimer, HISTORY_MAX_AGE_MS,
} from "./lib/reactions.js";
import { createWebServer } from "./lib/web/server.js";
import { UNSCOPED, scopeKeyForName, resolveChannelScopes, normalizeScopeName, validateScopeValue, matchScopeName } from "./lib/scopes.js";
process.on("unhandledRejection", (reason, promise) => {
log.error("Unhandled promise rejection:", reason);
});
let config = loadConfig();
log.setDebug(!!config.DEBUG);
const connection = new NodeJSSerialConnection(config.SERIAL_PORT || "/dev/ttyUSB0");
// meshcore.js misparses TraceData pushes that use multi-byte path hashes and throws,
// which floods the error log with "Failed to process frame". The bridge doesn't use
// trace data, so drop malformed ones quietly.
const origOnTraceDataPush = connection.onTraceDataPush.bind(connection);
connection.onTraceDataPush = (bufferReader) => {
try {
origOnTraceDataPush(bufferReader);
} catch (e) {
log.debug(`Dropped malformed TraceData push: ${e.message}`);
}
};
// Pace all outgoing mesh transmissions, whichever code path sends them. Packets sent
// back to back (e.g. a message followed by its attachment link) can reach a repeater
// while it is still busy with the previous one and get dropped.
// The library waits forever for the radio's Ok/Err reply, so a reply lost to a USB
// glitch or radio reset would stall every later send; give up after a timeout instead.
function getMeshMinSendGapMs() { return Math.max(0, Number(config.MESH_MIN_SEND_GAP_MS ?? 5000)); }
const MESH_SEND_TIMEOUT_MS = 20_000;
let meshPaceChain = Promise.resolve();
let lastMeshSendAt = 0;
// Epoch seconds the library stamped on the most recent channel/DM send. Read it right
// after awaiting the send; reaction hashes must use this exact second.
let lastMeshSendTs = 0;
// Per-channel flood scopes (MESH_CHANNEL_SCOPES). The radio keeps one scope override
// that applies to every later channel message and DM, and it survives a bridge restart,
// so set it before each send whenever it may differ. Channels without an entry and DMs
// use the radio's default scope. Adverts always use the radio's default.
const SCOPE_CMD_TIMEOUT_MS = 5_000;
const CMD_SET_FLOOD_SCOPE_KEY = 54; // companion firmware v8+; "unscoped" subcommand v12+
let appliedScope = null; // null = unknown (fresh start or reconnect)
let meshFirmwareVer = null;
let scopeUnsupportedWarned = false;
let scopeCache = { config: null, byIdx: new Map() };
const meshChannelNames = new Map(); // mesh channel index -> name on the radio, read at connect
function getChannelScopes() {
if (scopeCache.config !== config) {
const { byIdx, warnings } = resolveChannelScopes(config.MESH_CHANNEL_SCOPES, config.DISCORD_TO_MESH_ROUTES);
for (const w of warnings) log.warn(`Config: ${w}`);
if (byIdx.size) log.info(`Flood scopes: ${[...byIdx].map(([i, s]) => `ch${i}${meshChannelNames.has(i) ? ` (${meshChannelNames.get(i)})` : ""}=${s === UNSCOPED ? "unscoped" : s}`).join(", ")}`);
scopeCache = { config, byIdx };
}
return scopeCache.byIdx;
}
// Region names to recognise on received packets: every scope we send with, plus
// MESH_KNOWN_SCOPES for regions other nodes use.
function getKnownScopeNames() {
const names = new Set();
for (const s of getChannelScopes().values()) if (s !== UNSCOPED) names.add(s);
for (const s of Array.isArray(config.MESH_KNOWN_SCOPES) ? config.MESH_KNOWN_SCOPES : []) {
if (s !== UNSCOPED && !validateScopeValue(s)) names.add(normalizeScopeName(s));
}
return [...names];
}
// Scopes are shown without a "#" so they aren't mistaken for hashtag channels.
// "alb" for a recognised region, "unknown scope" for a scoped packet we can't name,
// null for an unscoped one.
function describeFrameScope(frame) {
if (!frame || frame.transportCode === null) return null;
const name = matchScopeName(frame.transportCode, frame.payloadType, frame.payload, getKnownScopeNames());
return name || "unknown scope";
}
function setRadioScope(payload) {
return new Promise((resolve, reject) => {
const cleanup = () => {
clearTimeout(timer);
connection.off(Constants.ResponseCodes.Ok, onOk);
connection.off(Constants.ResponseCodes.Err, onErr);
};
const onOk = () => { cleanup(); resolve(); };
const onErr = () => { cleanup(); reject(new Error("radio rejected the flood scope command")); };
const timer = setTimeout(() => { cleanup(); reject(new Error("flood scope command timed out")); }, SCOPE_CMD_TIMEOUT_MS);
connection.once(Constants.ResponseCodes.Ok, onOk);
connection.once(Constants.ResponseCodes.Err, onErr);
connection.sendToRadioFrame(payload).catch((e) => { cleanup(); reject(e); });
});
}
async function applyScopeForSend(method, args) {
if (method === "sendFloodAdvert") return;
const scopes = getChannelScopes();
let wanted = method === "sendChannelTextMessage" ? (scopes.get(Number(args[0])) ?? "") : ""; // "" = radio default
if (meshFirmwareVer !== null && meshFirmwareVer < 8) {
if (scopes.size && !scopeUnsupportedWarned) {
log.warn(`Radio firmware v${meshFirmwareVer} has no flood scopes (needs v8+); MESH_CHANNEL_SCOPES is ignored`);
scopeUnsupportedWarned = true;
}
return;
}
if (wanted === UNSCOPED && meshFirmwareVer !== null && meshFirmwareVer < 12) {
if (!scopeUnsupportedWarned) {
log.warn(`Radio firmware v${meshFirmwareVer} can't force unscoped sends (needs v12+); "*" channels use the radio's default scope`);
scopeUnsupportedWarned = true;
}
wanted = "";
}
if (wanted === appliedScope) return;
let payload;
if (wanted === UNSCOPED) payload = Uint8Array.from([CMD_SET_FLOOD_SCOPE_KEY, 1]);
else if (wanted) payload = Uint8Array.from([CMD_SET_FLOOD_SCOPE_KEY, 0, ...scopeKeyForName(wanted)]);
else payload = Uint8Array.from([CMD_SET_FLOOD_SCOPE_KEY, 0]); // clear override
appliedScope = null; // unknown until the radio confirms
try {
try {
await setRadioScope(payload);
} catch {
// One retry: another command can briefly steal the shared Ok/Err reply
await setRadioScope(payload);
}
} catch (e) {
// With no scopes configured this is only clearing a possible leftover override;
// don't let firmware without scope support block every send.
if (scopes.size) throw e;
log.debug(`Could not clear flood scope override: ${e.message}`);
appliedScope = "";
return;
}
appliedScope = wanted;
log.debug(`Flood scope set to ${wanted === UNSCOPED ? "unscoped" : wanted || "radio default"}`);
}
for (const method of ["sendChannelTextMessage", "sendTextMessage", "sendFloodAdvert"]) {
const original = connection[method].bind(connection);
connection[method] = (...args) => {
const run = meshPaceChain.then(async () => {
const wait = lastMeshSendAt + getMeshMinSendGapMs() - Date.now();
if (wait > 0) {
log.debug(`Pacing mesh ${method}: waiting ${wait}ms`);
await sleep(wait);
}
// Sending in the wrong scope could leak a message into another region, so a
// scope that can't be set fails the send rather than going out mis-scoped.
await applyScopeForSend(method, args);
let timer;
try {
lastMeshSendTs = Math.floor(Date.now() / 1000); // same tick the library stamps the packet
return await Promise.race([
original(...args),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`mesh ${method} timed out`)), MESH_SEND_TIMEOUT_MS);
}),
]);
} finally {
clearTimeout(timer);
lastMeshSendAt = Date.now();
}
});
meshPaceChain = run.catch(() => {});
return run;
};
}
// Concurrent getContacts() calls share the library's Contact/EndOfContacts listeners,
// so a call that starts mid-dump gets only the tail of the list, and none of them time
// out. Share one in-flight dump between all callers.
const CONTACTS_TIMEOUT_MS = 30_000;
let contactsInFlight = null;
{
const original = connection.getContacts.bind(connection);
connection.getContacts = () => {
if (!contactsInFlight) {
let timer;
const dump = Promise.race([
original(),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("getContacts timed out")), CONTACTS_TIMEOUT_MS);
}),
]).finally(() => {
clearTimeout(timer);
if (contactsInFlight === dump) contactsInFlight = null; // a reconnect may have reset it
});
contactsInFlight = dump;
}
return contactsInFlight;
};
}
function withTimeout(promise, ms, what) {
let timer;
return Promise.race([
promise,
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${what} timed out`)), ms); }),
]).finally(() => clearTimeout(timer));
}
// ---- Discord webhook cache for mesh->discord messages ----
// Caches the lookup promise, so concurrent messages for a new channel share one
// fetch/create instead of each creating a webhook.
const webhookCache = new Map(); // channelId -> Promise<Webhook|null>
function getOrCreateWebhook(channel) {
const cached = webhookCache.get(channel.id);
if (cached) return cached;
const lookup = (async () => {
try {
// Look for an existing webhook created by us
const hooks = await channel.fetchWebhooks();
let hook = hooks.find(h => h.owner?.id === bot.user.id && h.name === "MeshCore Bridge");
if (!hook) {
hook = await channel.createWebhook({ name: "MeshCore Bridge", reason: "MeshCore bridge message forwarding" });
}
return hook;
} catch (e) {
log.error(`Failed to get/create webhook for channel ${channel.id}:`, e);
webhookCache.delete(channel.id); // retry on the next message
return null;
}
})();
webhookCache.set(channel.id, lookup);
return lookup;
}
// Discord rejects webhook usernames that contain "discord" or "clyde", are empty, or are
// exactly "everyone"/"here", which fails the whole post.
function sanitizeWebhookUsername(name) {
let n = String(name || "").replace(/discord/gi, "Disc0rd").replace(/clyde/gi, "Clyd3").trim().slice(0, 80);
if (!n || /^(everyone|here)$/i.test(n)) n = "Mesh";
return n;
}
// Post a mesh message as its sender via webhook, falling back to a plain bot message
// if the webhook is unavailable or the send fails, so the message is never lost.
async function postMeshToDiscord(dest, senderName, body, { prefix = "", suffix = "" } = {}) {
const webhook = await getOrCreateWebhook(dest);
if (webhook) {
try {
const avatarURL = `https://api.dicebear.com/9.x/identicon/png?seed=${encodeURIComponent(senderName)}&size=128`;
return await webhook.send({ content: `${prefix}${body}${suffix}`, username: sanitizeWebhookUsername(senderName), avatarURL });
} catch (e) {
if (e.code === 10015) webhookCache.delete(dest.id); // webhook was deleted; recreate next time
log.error(`Webhook send failed for channel ${dest.id}, falling back to bot message:`, e);
}
}
return dest.send(`${prefix}**${senderName}:** ${body}${suffix}`);
}
// ---- Path discovery (command 0x34, response 0x8D) ----
const PATH_DISCOVERY_CMD = 0x34;
const PATH_DISCOVERY_RESPONSE = 0x8D;
async function discoverPath(publicKey, timeoutMs = 30000) {
return new Promise((resolve, reject) => {
const pubKeyPrefix = publicKey.subarray(0, 6);
let timer = null;
let sentReceived = false;
const onRx = (frame) => {
const code = frame[0];
// The device sends a Sent response (0x06) first, then the discovery response (0x8D) later
if (code === 0x06 && !sentReceived) {
sentReceived = true;
log.debug(`Path discovery: got Sent acknowledgment`);
return;
}
// Log any unusual response codes during discovery for debugging
if (config.DEBUG && sentReceived && code !== PATH_DISCOVERY_RESPONSE && code !== 0x83) {
log.debug(`Path discovery: saw frame code=0x${code.toString(16)} len=${frame.length}`);
}
if (code !== PATH_DISCOVERY_RESPONSE) return;
log.debug(`Received path discovery response, frame length=${frame.length}`);
// Response format: [0x8D, reserved:1, pubkey_prefix:6, out_path_len:1, ...]
if (frame.length < 9) return;
const responsePubKey = frame.subarray(2, 8);
let match = true;
for (let i = 0; i < 6; i++) {
if (responsePubKey[i] !== pubKeyPrefix[i]) { match = false; break; }
}
if (!match) {
log.debug(`Path discovery response pubkey mismatch, ignoring`);
return;
}
clearTimeout(timer);
connection.off("rx", onRx);
const pathLenByte = frame[8];
const hopCount = pathLenByte & 0x3F;
log.debug(`Path discovered: ${hopCount} hops`);
resolve({ hopCount, pathLenByte });
};
connection.on("rx", onRx);
// Send path discovery command: [0x34, 0x00, <32-byte pubkey>]
const cmdBuf = new Uint8Array(2 + publicKey.length);
cmdBuf[0] = PATH_DISCOVERY_CMD;
cmdBuf[1] = 0x00;
cmdBuf.set(publicKey, 2);
connection.sendToRadioFrame(cmdBuf).catch((e) => {
connection.off("rx", onRx);
clearTimeout(timer);
reject(e);
});
timer = setTimeout(() => {
connection.off("rx", onRx);
log.debug(`Path discovery timed out (sentReceived=${sentReceived})`);
resolve(null);
}, timeoutMs);
});
}
// ---- Bridge prefix stripping ----
function getBridgePrefixRegexes() {
const prefixes = config.BRIDGE_PREFIXES || [];
return prefixes.map(p => new RegExp(`^${escapeRegex(p)}:\\s*`));
}
function stripBridgePrefixes(text) {
let result = text;
for (const re of getBridgePrefixRegexes()) {
result = result.replace(re, "");
}
return result;
}
// Firmware caps text at 160 UTF-8 bytes. For channel messages that includes the
// "<node name>: " prefix it adds, so the usable budget depends on our node's name.
const MESH_TEXT_MAX_BYTES = 160;
let meshSelfName = config.MESH_NODE_NAME || "";
function getConfiguredMaxLen() { return Number(config.MESH_MAXLEN) || MESH_TEXT_MAX_BYTES; }
function getMeshMaxLen() {
const budget = MESH_TEXT_MAX_BYTES - utf8Len(meshSelfName) - 2;
return Math.max(20, Math.min(getConfiguredMaxLen(), budget));
}
function getMeshDmMaxLen() { return Math.min(getConfiguredMaxLen(), MESH_TEXT_MAX_BYTES); }
function getMeshChunkDelayMs() { return Number(config.MESH_CHUNK_DELAY_MS ?? 2500); }
function getAlwaysForwardChannelIds() {
// New key: DISCORD_ALWAYS_FORWARD_CHANNEL_IDS (array or string)
const v = config.DISCORD_ALWAYS_FORWARD_CHANNEL_IDS ?? config.DISCORD_ALWAYS_FORWARD_CHANNEL_ID;
if (!v) return new Set();
if (Array.isArray(v)) return new Set(v.map(x => String(x)));
return new Set([String(v)]);
}
// ---- ImgBB image upload ----
function getImgbbApiKey() { return config.IMGBB_API_KEY || ""; }
const IMAGE_CONTENT_TYPES = new Set([
"image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp",
]);
function isImageAttachment(att) {
if (att.contentType && IMAGE_CONTENT_TYPES.has(att.contentType)) return true;
return /\.(png|jpe?g|gif|webp|bmp)$/i.test(att.name || "");
}
async function uploadToImgBB(imageUrl) {
if (!getImgbbApiKey()) {
log.error("getImgbbApiKey() not configured; cannot upload image.");
return null;
}
try {
const form = new URLSearchParams();
form.set("image", imageUrl);
const res = await fetch(`https://api.imgbb.com/1/upload?key=${getImgbbApiKey()}`, {
method: "POST",
body: form,
});
if (!res.ok) {
log.error(`ImgBB upload failed: ${res.status} ${res.statusText}`);
return null;
}
const data = await res.json();
return data?.data?.url || null;
} catch (e) {
log.error("ImgBB upload error:", e);
return null;
}
}
const MAX_ATTACHMENT_LINKS = 2;
// ---- Self-hosted file upload (e.g. unym.pics), used for images and other files ----
// The service takes raw bytes with a Bearer token and returns {"url"}. Discord CDN links
// are signed and expire, so the bridge downloads the attachment and re-uploads it.
function getFileHostUrl() { return String(config.FILE_HOST_URL || "").replace(/\/+$/, ""); }
function getFileHostToken() { return config.FILE_HOST_TOKEN || ""; }
const FILE_HOST_MAX_BYTES = 100 * 1024 * 1024;
const FILE_HOST_TIMEOUT_MS = 5 * 60 * 1000;
async function uploadToFileHost(att) {
if (!getFileHostUrl() || !getFileHostToken()) return null;
if (att.size > FILE_HOST_MAX_BYTES) {
log.warn(`Attachment ${att.name} is ${att.size} bytes, over the file host limit; not uploading.`);
return null;
}
try {
const signal = AbortSignal.timeout(FILE_HOST_TIMEOUT_MS);
const src = await fetch(att.url, { signal });
if (!src.ok || !src.body) {
log.error(`Failed to download Discord attachment ${att.name}: ${src.status} ${src.statusText}`);
return null;
}
const res = await fetch(`${getFileHostUrl()}/upload`, {
method: "POST",
headers: {
Authorization: `Bearer ${getFileHostToken()}`,
"Content-Type": att.contentType || "application/octet-stream",
"X-Filename": String(att.name || "file").replace(/[^\x20-\x7e]/g, "_"),
},
body: src.body,
duplex: "half",
signal,
});
if (!res.ok) {
log.error(`File host upload failed for ${att.name}: ${res.status} ${await res.text().catch(() => "")}`);
return null;
}
const data = await res.json();
return data?.url || null;
} catch (e) {
log.error(`File host upload error for ${att.name}:`, e);
return null;
}
}
// ---- URL shortening via TinyURL (free, no API key) ----
async function shortenUrl(url) {
try {
const res = await fetch(`https://tinyurl.com/api-create.php?url=${encodeURIComponent(url)}`);
if (!res.ok) {
log.error(`TinyURL shortening failed: ${res.status} ${res.statusText}`);
return url;
}
const short = (await res.text()).trim();
return short || url;
} catch (e) {
log.error("TinyURL shortening error:", e);
return url;
}
}
async function resolveMentions(text, guild) {
let result = text;
// User mentions: <@123456> or <@!123456>
const userMentions = result.match(/<@!?(\d+)>/g) || [];
for (const mention of userMentions) {
const id = mention.match(/\d+/)[0];
try {
const member = await guild.members.fetch(id);
const displayName = member.nickname || member.user.username;
result = result.replaceAll(mention, `@${displayName}`);
} catch {
result = result.replaceAll(mention, "@unknown");
}
}
// Role mentions: <@&123456>
const roleMentions = result.match(/<@&(\d+)>/g) || [];
for (const mention of roleMentions) {
const id = mention.match(/\d+/)[0];
const role = guild.roles.cache.get(id);
result = result.replaceAll(mention, role ? `@${role.name}` : "@unknown-role");
}
// Channel mentions: <#123456>
const channelMentions = result.match(/<#(\d+)>/g) || [];
for (const mention of channelMentions) {
const id = mention.match(/\d+/)[0];
const channel = guild.channels.cache.get(id);
result = result.replaceAll(mention, channel ? `#${channel.name}` : "#unknown-channel");
}
return result;
}
// ---- Mesh send serialization queue ----
let meshSendChain = Promise.resolve();
// Serialized mesh send queue. Resolves to { ok, value } and never rejects, so callers
// can report failures without a throw escaping into message handlers. The queue is
// capped (pacing drains at most one send per gap, so a backlog only makes messages
// arrive minutes late), and pausing the bridge drops whatever is still queued.
const MESH_QUEUE_MAX = 20;
let meshQueueDepth = 0;
let meshQueueEpoch = 0;
function enqueueMeshSend(taskFn, { force = false } = {}) {
if (!force && meshQueueDepth >= MESH_QUEUE_MAX) {
log.warn(`Mesh send queue full (${meshQueueDepth}); dropping send`);
return Promise.resolve({ ok: false, error: new Error("mesh send queue full") });
}
meshQueueDepth++;
const epoch = meshQueueEpoch;
const run = meshSendChain
.then(() => {
if (epoch !== meshQueueEpoch && !force) throw new Error("dropped: bridge was paused");
return taskFn();
})
.then(
(value) => ({ ok: true, value }),
(error) => {
if (epoch !== meshQueueEpoch && !force) log.info("Dropped queued mesh send (bridge paused)");
else log.error("Mesh send task error:", error);
return { ok: false, error };
},
)
.finally(() => { meshQueueDepth--; });
meshSendChain = run;
return run;
}
/**
* Chunk + send a message to meshcore with "n/N" suffix and delay between chunks.
* - If message fits in one chunk, sends once with NO suffix.
* - All mesh sends are serialized via enqueueMeshSend() to prevent interleaving.
*/
async function sendMeshChunked(channelIdx, fullText, onSent = null) {
const base = normalizeForMesh(fullText);
if (!base) return;
return enqueueMeshSend(async () => {
// If it fits as-is, send once with no suffix.
if (utf8Len(base) <= getMeshMaxLen()) {
log.debug(`mesh send (single) ch=${channelIdx} bytes=${utf8Len(base)}: "${base}"`);
await connection.sendChannelTextMessage(channelIdx, base);
const ts = lastMeshSendTs; // the second the library stamped on this packet
if (onSent) onSent(base, ts);
return;
}
// Otherwise chunk and add "n/N" suffixes.
const suffixReserve = 6; // safe for up to " 99/99"
const maxPayload = Math.max(1, getMeshMaxLen() - suffixReserve);
let chunks = splitByMaxLen(base, maxPayload);
// Recompute reserve based on actual total (handles " 100/100" etc.)
const total = chunks.length;
const suffixLen = (` ${total}/${total}`).length;
const maxPayload2 = Math.max(1, getMeshMaxLen() - suffixLen);
if (maxPayload2 !== maxPayload) {
chunks = splitByMaxLen(base, maxPayload2);
}
const total2 = chunks.length;
log.debug(`mesh send (chunked) ch=${channelIdx} parts=${total2} maxBytes=${getMeshMaxLen()} delayMs=${getMeshChunkDelayMs()}`);
let failed = 0;
for (let idx = 0; idx < total2; idx++) {
const partNum = idx + 1;
const suffix = ` ${partNum}/${total2}`;
// Final guard: ensure payload+suffix fits
const payload = truncateUtf8(chunks[idx], getMeshMaxLen() - suffix.length);
const out = payload + suffix;
try {
log.debug(`mesh chunk ${partNum}/${total2} ch=${channelIdx} bytes=${utf8Len(out)}: "${out}"`);
await connection.sendChannelTextMessage(channelIdx, out);
} catch (e) {
failed++;
log.error(`Mesh chunk send failed ${partNum}/${total2} ch=${channelIdx}:`, e);
}
if (idx !== total2 - 1) {
await sleep(getMeshChunkDelayMs());
}
}
if (failed) throw new Error(`${failed}/${total2} mesh chunks failed`);
});
}
/**
* Chunk + send a DM to a mesh contact with "n/N" suffix and delay between chunks.
*/
async function sendDMChunked(publicKey, fullText) {
const base = normalizeForMesh(fullText);
if (!base) return;
return enqueueMeshSend(async () => {
if (utf8Len(base) <= getMeshDmMaxLen()) {
log.debug(`DM send (single) bytes=${utf8Len(base)}: "${base}"`);
await connection.sendTextMessage(publicKey, base);
return;
}
const suffixReserve = 6;
const maxPayload = Math.max(1, getMeshDmMaxLen() - suffixReserve);
let chunks = splitByMaxLen(base, maxPayload);
const total = chunks.length;
const suffixLen = (` ${total}/${total}`).length;
const maxPayload2 = Math.max(1, getMeshDmMaxLen() - suffixLen);
if (maxPayload2 !== maxPayload) {
chunks = splitByMaxLen(base, maxPayload2);
}
const total2 = chunks.length;
log.debug(`DM send (chunked) parts=${total2}`);
let failed = 0;
for (let idx = 0; idx < total2; idx++) {
const partNum = idx + 1;
const suffix = ` ${partNum}/${total2}`;
const payload = truncateUtf8(chunks[idx], getMeshDmMaxLen() - suffix.length);
try {
await connection.sendTextMessage(publicKey, payload + suffix);
} catch (e) {
failed++;
log.error(`DM chunk send failed ${partNum}/${total2}:`, e);
}
if (idx !== total2 - 1) await sleep(getMeshChunkDelayMs());
}
if (failed) throw new Error(`${failed}/${total2} DM chunks failed`);
});
}
/** =========================
* Bridge mode state + auth
* ========================= */
const bridgeState = {
paused: false,
pausedBy: null,
pausedAt: null,
};
// Pause is saved to disk so a crash or reboot during an incident doesn't quietly
// resume forwarding.
const BRIDGE_STATE_FILE = "./bridge_state.json";
try {
Object.assign(bridgeState, JSON.parse(fs.readFileSync(BRIDGE_STATE_FILE, "utf8")));
if (bridgeState.paused) log.warn(`Bridge is PAUSED (by ${bridgeState.pausedBy ?? "unknown"} at ${bridgeState.pausedAt}); use /bridge resume`);
} catch {
// No saved state
}
const metrics = {
startedAt: Date.now(),
meshToDiscord: 0,
discordToMesh: 0,
reactionsApplied: 0,
errors: 0,
lastMeshMessage: null,
lastDiscordForward: null,
};
function isBridgePaused() {
return bridgeState.paused === true;
}
function setBridgePaused(paused, actorTag = null) {
if (paused && !bridgeState.paused) meshQueueEpoch++; // drop sends already queued
bridgeState.paused = !!paused;
bridgeState.pausedBy = paused ? actorTag : null;
bridgeState.pausedAt = paused ? new Date().toISOString() : null;
try {
fs.writeFileSync(BRIDGE_STATE_FILE, JSON.stringify(bridgeState));
} catch (e) {
log.error("Failed to save bridge pause state:", e);
}
}
function isBridgeAdminMember(member) {
if (!member) return false;
// Permission fallback: allow Manage Guild or Administrator
const perms = member.permissions;
if (perms?.has?.("Administrator") || perms?.has?.("ManageGuild")) return true;
// Role allowlist
const allowed = config.BRIDGE_ADMIN_ROLE_IDS || [];
if (!Array.isArray(allowed) || allowed.length === 0) return false;
return member.roles?.cache?.some?.(r => allowed.includes(r.id)) === true;
}
/** =========================
* Flood protection (Discord -> Mesh)
* ========================= */
function getFloodConfig() {
const f = config.FLOOD_PROTECT || {};
return {
windowMs: Math.max(1, Number(f.WINDOW_SECONDS ?? 15)) * 1000,
max: Math.max(1, Number(f.MAX_MESSAGES_PER_WINDOW ?? 6)),
cooldownMs: Math.max(1, Number(f.COOLDOWN_SECONDS ?? 60)) * 1000,
warn: f.WARN_IN_CHANNEL !== false,
};
}
// Per-Discord-channel tracking
const floodState = new Map(); // channelId -> { times: number[], cooldownUntil: number, warnedUntil: number }
function _getFloodRecord(channelId) {
const key = String(channelId);
let rec = floodState.get(key);
if (!rec) {
rec = { times: [], cooldownUntil: 0, warnedUntil: 0 };
floodState.set(key, rec);
}
return rec;
}
/**
* Returns true if we should allow forwarding this Discord channel's messages to Meshcore.
* If false, we're currently rate-limited (cooldown).
*/
async function floodAllowDiscordToMesh(messageLike, cost = 1) {
const channelId = String(messageLike?.channel?.id ?? "");
if (!channelId) return true;
const now = Date.now();
const rec = _getFloodRecord(channelId);
// In cooldown?
if (rec.cooldownUntil > now) {
log.debug(`Flood protect: dropping discord->mesh from ${channelId} (cooldown ${Math.ceil((rec.cooldownUntil - now) / 1000)}s)`);
return false;
}
const flood = getFloodConfig();
// Sliding window prune
const cutoff = now - flood.windowMs;
rec.times = rec.times.filter(t => t >= cutoff);
// Record this message, one entry per mesh packet it will produce
for (let i = 0; i < Math.max(1, cost); i++) rec.times.push(now);
// If exceeded, start cooldown
if (rec.times.length > flood.max) {
rec.cooldownUntil = now + flood.cooldownMs;
rec.times = []; // reset window for after cooldown
log.debug(`Flood protect: ENTER cooldown for ${channelId} (${flood.cooldownMs / 1000}s)`);
// Optional: warn once per cooldown period
if (flood.warn && rec.warnedUntil <= now) {
rec.warnedUntil = rec.cooldownUntil;
try {
if (messageLike?.channel?.send) {
await messageLike.channel.send(
`⚠️ Bridge flood protection: pausing forwarding to Meshcore for ${Math.ceil(flood.cooldownMs / 1000)}s (too many messages).`
);
}
} catch (e) {
log.error("Flood protect: failed to post warning in channel:", e);
}
}
return false;
}
return true;
}
/** =========================
* Emergency channel handling
* ========================= */
const emergencyState = {
active: false,
lastAlertAt: 0,
reminderTimer: null,
};
function getEmergencyCooldownMs() { return Math.max(1, Number(config.EMERGENCY_COOLDOWN_MINUTES ?? 30)) * 60 * 1000; }
function getEmergencyReminderMs() { return Math.max(1, Number(config.EMERGENCY_REMINDER_MINUTES ?? 5)) * 60 * 1000; }
function isEmergencyMeshChannel(channelIdx) {
const idx = config.EMERGENCY_MESH_CHANNEL_IDX;
if (idx === undefined || idx === null) return false;
return Number(channelIdx) === Number(idx);
}
function getEmergencyDiscordChannelId() {
return config.EMERGENCY_DISCORD_CHANNEL_ID || null;
}
function cancelEmergencyReminder() {
if (emergencyState.reminderTimer) {
clearTimeout(emergencyState.reminderTimer);
emergencyState.reminderTimer = null;
}
}
function scheduleEmergencyReminder(discordChannelId) {
cancelEmergencyReminder();
emergencyState.reminderTimer = setTimeout(async () => {
emergencyState.reminderTimer = null;
try {
const dest = await bot.channels.fetch(discordChannelId);
if (dest?.isTextBased()) {
await dest.send({ content: "🚨 No response yet - emergency message still awaiting reply @everyone", allowedMentions: { parse: ["everyone"] } });
}
} catch (e) {
log.error("Emergency reminder error:", e);
}
}, getEmergencyReminderMs());
}
/** =========================
* Mesh user block system
* ========================= */
// blockState tracks per-user daily warning and appeal status
// key = lowercase sender name, value = { lastWarned: timestamp, lastAppeal: timestamp }
const blockState = new Map();
function getBlockList() {
return config.BLOCKED_MESH_USERS || [];
}
function isUserBlocked(senderName) {
const list = getBlockList();
const nameLower = senderName.toLowerCase();
return list.some(entry => {
const entryName = typeof entry === "string" ? entry : entry.name;
if (entryName?.toLowerCase() !== nameLower) return false;
// Check if vote-block has expired
if (entry.expiresAt && Date.now() > entry.expiresAt) return false;
return true;
});
}
function addBlockedUser(name, pubKeyHex = null, opts = {}) {
if (!config.BLOCKED_MESH_USERS) config.BLOCKED_MESH_USERS = [];
// Don't add duplicates
if (isUserBlocked(name)) return false;
const entry = { name };
if (pubKeyHex) entry.pubKey = pubKeyHex;
if (opts.type) entry.type = opts.type; // "admin" or "vote"
if (opts.expiresAt) entry.expiresAt = opts.expiresAt;
if (opts.voteCount !== undefined) entry.voteCount = opts.voteCount;
config.BLOCKED_MESH_USERS.push(entry);
saveConfig();
return true;
}
function removeBlockedUser(name) {
if (!config.BLOCKED_MESH_USERS) return false;
const nameLower = name.toLowerCase();
const before = config.BLOCKED_MESH_USERS.length;
config.BLOCKED_MESH_USERS = config.BLOCKED_MESH_USERS.filter(entry => {
const entryName = typeof entry === "string" ? entry : entry.name;
return entryName?.toLowerCase() !== nameLower;
});
if (config.BLOCKED_MESH_USERS.length < before) {
saveConfig();
return true;
}
return false;
}
function saveConfig() {
try {
saveConfigAtomic(config);
} catch (e) {
log.error("Failed to save config:", e);
}
}
function getBlockState(senderName) {
const key = senderName.toLowerCase();
let state = blockState.get(key);
if (!state) {
state = { lastWarned: 0, lastAppeal: 0 };
blockState.set(key, state);
}
return state;
}
/** =========================
* Vote-block system
* ========================= */
const VOTE_DURATION_MS = 15 * 60 * 1000; // 15 minutes
const VOTE_COOLDOWN_MS = 4 * 60 * 60 * 1000; // 4 hours
const VOTE_MIN_YES = 3;
const VOTE_PERCENT = 0.10; // 10%
const VOTE_BLOCK_DAYS = [4, 8, 0]; // escalation: 4 days, 8 days, permanent (0 = permanent)
const VOTE_VETO_EMOJI = "❌";
// Active votes: Map<messageId, { username, reason, channelId, guildId, timer, initiator }>
const activeVotes = new Map();
// Cooldowns: Map<lowercase username, timestamp of last vote attempt>
const voteCooldowns = new Map();
function getVoteBlockCount(username) {
// Count how many times this user has been vote-blocked before (from config)
const history = config.VOTE_BLOCK_HISTORY || {};
return Number(history[username.toLowerCase()]) || 0; // tolerate counts saved as strings
}
function recordVoteBlock(username) {
if (!config.VOTE_BLOCK_HISTORY) config.VOTE_BLOCK_HISTORY = {};
const key = username.toLowerCase();
config.VOTE_BLOCK_HISTORY[key] = getVoteBlockCount(username) + 1;
saveConfig();
}
function getVoteBlockDuration(username) {
const count = getVoteBlockCount(username);
const idx = Math.min(count, VOTE_BLOCK_DAYS.length - 1);
return VOTE_BLOCK_DAYS[idx]; // days, 0 = permanent
}
// Check for expired vote-blocks periodically and notify
function startBlockExpiryChecker() {
setInterval(async () => {
if (!config.BLOCKED_MESH_USERS) return;
const now = Date.now();
const expired = [];
config.BLOCKED_MESH_USERS = config.BLOCKED_MESH_USERS.filter(entry => {
if (entry.expiresAt && now > entry.expiresAt) {
expired.push(entry);
return false;
}
return true;
});
if (expired.length > 0) {
saveConfig();
for (const entry of expired) {
// Notify on mesh
try {
await enqueueMeshSend(() =>
connection.sendChannelTextMessage(0,
`${entry.name}: Your block has expired. Please follow community guidelines.`)
);
} catch (e) {
log.error("Block expiry mesh notify error:", e);
}
// Notify on Discord
const announceId = config.NODE_ANNOUNCE_CHANNEL_ID || config.DISCORD_CHANNEL_ID;
if (announceId) {
try {
const dest = await bot.channels.fetch(announceId);
if (dest?.isTextBased()) {
await dest.send(`Vote-block expired for **${entry.name}**. They can now send messages again.`);
}
} catch (e) {
log.error("Block expiry Discord notify error:", e);
}
}
}
}
}, 60_000); // Check every minute
}
function isSameDay(ts1, ts2) {
const d1 = new Date(ts1);
const d2 = new Date(ts2);
return d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate();