@tritard/waterbrother 0.16.138 → 0.16.140

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tritard/waterbrother",
3
- "version": "0.16.138",
3
+ "version": "0.16.140",
4
4
  "description": "Waterbrother: bring-your-own-model coding CLI with local tools, sessions, operator modes, and approval controls",
5
5
  "type": "module",
6
6
  "bin": {
package/src/discord.js CHANGED
@@ -18,7 +18,8 @@ import {
18
18
  releaseSharedOperator,
19
19
  removeSharedMember,
20
20
  setSharedRoom,
21
- setSharedRoomMode
21
+ setSharedRoomMode,
22
+ upsertSharedAgent
22
23
  } from "./shared-project.js";
23
24
 
24
25
  const DISCORD_API_BASE = "https://discord.com/api/v10";
@@ -158,6 +159,12 @@ function buildDiscordHelp() {
158
159
  "/project show the current project and sharing state",
159
160
  "/project share enable Roundtable on the current project and bind this Discord channel",
160
161
  "/room show shared room status for the current project",
162
+ "/people list recently seen Discord users in this channel",
163
+ "/terminals list live Waterbrother terminals for this project",
164
+ "/executor show the selected executor",
165
+ "/reviewer show the assigned reviewer",
166
+ "/verifier show the assigned verifier",
167
+ "/assign-role <terminal|this terminal> <executor|reviewer|verifier|standby> assign a room role",
161
168
  "/members list shared room members",
162
169
  "/tasks list shared room tasks",
163
170
  "/events list recent shared room events",
@@ -282,6 +289,74 @@ function formatDiscordEvents(events = []) {
282
289
  ].join("\n");
283
290
  }
284
291
 
292
+ function formatDiscordPeople({ people = [], project = null } = {}) {
293
+ if (!people.length) {
294
+ return [
295
+ "Recent Discord people",
296
+ "- none",
297
+ "Talk in this channel first, then run /people again."
298
+ ].join("\n");
299
+ }
300
+ const members = Array.isArray(project?.members) ? project.members : [];
301
+ const pendingInvites = Array.isArray(project?.pendingInvites) ? project.pendingInvites : [];
302
+ return [
303
+ "Recent Discord people",
304
+ ...people.map((person) => {
305
+ const member = members.find((entry) => String(entry?.id || "").trim() === person.userId);
306
+ const pendingInvite = pendingInvites.find((entry) => String(entry?.memberId || "").trim() === person.userId);
307
+ const bits = [`[${person.userId}]`];
308
+ if (person.usernameHandle) bits.push(person.usernameHandle);
309
+ bits.push(person.displayName || person.userId);
310
+ if (member?.role) bits.push(`member:${member.role}`);
311
+ else if (pendingInvite?.id) bits.push(`pending:${pendingInvite.id}`);
312
+ else if (person.paired) bits.push("paired");
313
+ return `- ${bits.join(" ")}`;
314
+ }),
315
+ "Invite by id, @username, full name, or mention with /invite."
316
+ ].join("\n");
317
+ }
318
+
319
+ function getAgentOwnerDisplay(agent = {}, fallback = "") {
320
+ return String(agent?.ownerName || fallback || agent?.label || agent?.ownerId || agent?.id || "unknown").trim() || "unknown";
321
+ }
322
+
323
+ function getAgentTerminalDisplay(agent = {}, fallback = "") {
324
+ return String(agent?.label || fallback || agent?.ownerName || agent?.ownerId || agent?.id || "terminal").trim() || "terminal";
325
+ }
326
+
327
+ function formatBridgeHostLabel(host = {}) {
328
+ const owner = String(host?.ownerName || host?.ownerId || "").trim();
329
+ const label = String(host?.label || "").trim();
330
+ const sessionSuffix = String(host?.sessionId || "").trim().slice(-6);
331
+ const runtime = host?.provider && host?.model ? `${host.provider}/${host.model}` : "";
332
+ const primary = label || owner || (sessionSuffix ? `terminal ${sessionSuffix}` : "live terminal");
333
+ return [primary, owner && label && owner !== label ? `(${owner})` : "", runtime ? `[${runtime}]` : ""].filter(Boolean).join(" ").trim();
334
+ }
335
+
336
+ function chooseAgentByRole(project = {}, role = "") {
337
+ return (Array.isArray(project?.agents) ? project.agents : []).find((agent) => String(agent?.role || "").trim() === String(role || "").trim()) || null;
338
+ }
339
+
340
+ function formatDiscordAgentStatus(title, agent = null, options = {}) {
341
+ if (!agent) return `${title}\nNo ${String(title || "").toLowerCase()} is assigned yet.`;
342
+ return [
343
+ title,
344
+ `owner: ${getAgentOwnerDisplay(agent)}`,
345
+ `terminal: ${getAgentTerminalDisplay(agent)}`,
346
+ `role: ${agent.role || "standby"}`,
347
+ `runtime: ${agent.provider && agent.model ? `${agent.provider}/${agent.model}` : "unknown"}`,
348
+ `live: ${options.live ? "yes" : "no"}`
349
+ ].join("\n");
350
+ }
351
+
352
+ function formatDiscordLiveHosts(hosts = []) {
353
+ if (!hosts.length) return "Live terminals\n- none";
354
+ return [
355
+ "Live terminals",
356
+ ...hosts.map((host) => `- ${formatBridgeHostLabel(host)}${host?.surface ? ` (${host.surface})` : ""}`)
357
+ ].join("\n");
358
+ }
359
+
285
360
  function parseDiscordInviteCommand(message = {}, text = "") {
286
361
  const value = String(text || "").trim();
287
362
  const match = value.match(/^\/?(?:room\s+)?invite\s+(.+)$/i);
@@ -302,6 +377,197 @@ function parseDiscordInviteCommand(message = {}, text = "") {
302
377
  return { memberId, memberName, role };
303
378
  }
304
379
 
380
+ function knownPersonKey(chatId, userId) {
381
+ return `${String(chatId || "").trim()}:${String(userId || "").trim()}`;
382
+ }
383
+
384
+ function listKnownDiscordPeople(state, message = {}) {
385
+ const chatId = String(message?.channel_id || "").trim();
386
+ const byId = new Map();
387
+ const upsert = (person = {}) => {
388
+ const userId = String(person.userId || person.id || "").trim();
389
+ if (!userId) return;
390
+ const existing = byId.get(userId) || { userId, usernameHandle: "", displayName: userId, paired: false, lastSeenAt: "" };
391
+ byId.set(userId, {
392
+ ...existing,
393
+ ...person,
394
+ userId,
395
+ username: String(person.username || existing.username || "").trim(),
396
+ usernameHandle: String(person.usernameHandle || existing.usernameHandle || "").trim(),
397
+ displayName: String(person.displayName || existing.displayName || userId).trim() || userId,
398
+ paired: person.paired === true || existing.paired === true,
399
+ lastSeenAt: String(person.lastSeenAt || existing.lastSeenAt || "").trim()
400
+ });
401
+ };
402
+
403
+ for (const person of Object.values(state?.knownPeople || {})) {
404
+ if (String(person?.chatId || "").trim() !== chatId) continue;
405
+ upsert(person);
406
+ }
407
+ for (const [userId, peer] of Object.entries(state?.peers || {})) {
408
+ if (String(peer?.chatId || "").trim() !== chatId) continue;
409
+ upsert({
410
+ userId,
411
+ username: String(peer?.username || "").trim(),
412
+ usernameHandle: String(peer?.usernameHandle || "").trim(),
413
+ displayName: String(peer?.displayName || peer?.username || userId).trim(),
414
+ paired: true,
415
+ lastSeenAt: String(peer?.lastSeenAt || "").trim()
416
+ });
417
+ }
418
+
419
+ const current = describeDiscordUser(message);
420
+ if (current.userId) upsert({ ...current, paired: Boolean(state?.peers?.[current.userId]) });
421
+ for (const mention of Array.isArray(message?.mentions) ? message.mentions : []) {
422
+ const mentioned = {
423
+ userId: String(mention?.id || "").trim(),
424
+ username: String(mention?.username || "").trim(),
425
+ usernameHandle: mention?.username ? `@${String(mention.username).trim()}` : "",
426
+ displayName: String(mention?.global_name || mention?.username || mention?.id || "").trim()
427
+ };
428
+ if (mentioned.userId) upsert(mentioned);
429
+ }
430
+
431
+ return [...byId.values()].sort((a, b) => {
432
+ const aSeen = Date.parse(String(a.lastSeenAt || "").trim()) || 0;
433
+ const bSeen = Date.parse(String(b.lastSeenAt || "").trim()) || 0;
434
+ return bSeen - aSeen || String(a.displayName || "").localeCompare(String(b.displayName || ""));
435
+ });
436
+ }
437
+
438
+ async function rememberKnownDiscordPeople(state, message = {}) {
439
+ const chatId = String(message?.channel_id || "").trim();
440
+ if (!chatId) return false;
441
+ const entries = [];
442
+ const author = describeDiscordUser(message);
443
+ if (author.userId) {
444
+ entries.push({
445
+ chatId,
446
+ userId: author.userId,
447
+ username: author.username,
448
+ usernameHandle: author.usernameHandle,
449
+ displayName: author.displayName,
450
+ paired: Boolean(state?.peers?.[author.userId]),
451
+ lastSeenAt: new Date().toISOString()
452
+ });
453
+ }
454
+ for (const mention of Array.isArray(message?.mentions) ? message.mentions : []) {
455
+ const userId = String(mention?.id || "").trim();
456
+ if (!userId) continue;
457
+ entries.push({
458
+ chatId,
459
+ userId,
460
+ username: String(mention?.username || "").trim(),
461
+ usernameHandle: mention?.username ? `@${String(mention.username).trim()}` : "",
462
+ displayName: String(mention?.global_name || mention?.username || mention?.id || "").trim(),
463
+ paired: Boolean(state?.peers?.[userId]),
464
+ lastSeenAt: new Date().toISOString()
465
+ });
466
+ }
467
+ if (!entries.length) return false;
468
+ const knownPeople = state.knownPeople && typeof state.knownPeople === "object" ? { ...state.knownPeople } : {};
469
+ for (const person of entries) {
470
+ knownPeople[knownPersonKey(person.chatId, person.userId)] = {
471
+ ...(knownPeople[knownPersonKey(person.chatId, person.userId)] || {}),
472
+ ...person
473
+ };
474
+ }
475
+ const trimmed = Object.entries(knownPeople)
476
+ .sort((a, b) => (Date.parse(String(b[1]?.lastSeenAt || "").trim()) || 0) - (Date.parse(String(a[1]?.lastSeenAt || "").trim()) || 0))
477
+ .slice(0, 250);
478
+ state.knownPeople = Object.fromEntries(trimmed);
479
+ await persistDiscordGatewayState(state);
480
+ return true;
481
+ }
482
+
483
+ function resolveDiscordInviteTarget(state, message = {}, rawTarget = "") {
484
+ const direct = parseDiscordInviteCommand(message, `/invite ${String(rawTarget || "").trim()}`);
485
+ if (direct?.memberId && (direct.memberId === direct.memberName || direct.memberId.match(/^\d+$/) || String(rawTarget || "").includes("<@"))) {
486
+ return direct;
487
+ }
488
+ const value = String(rawTarget || "").trim();
489
+ if (!value) return direct;
490
+ const known = listKnownDiscordPeople(state, message);
491
+ if (/^\d+$/.test(value)) {
492
+ const match = known.find((person) => person.userId === value);
493
+ return { memberId: value, memberName: match?.displayName || value, role: direct?.role || "editor" };
494
+ }
495
+ const normalized = value.replace(/^@/, "").trim().toLowerCase();
496
+ const byUsername = known.filter((person) => String(person.usernameHandle || "").trim().toLowerCase() === `@${normalized}` || String(person.username || "").trim().toLowerCase() === normalized);
497
+ if (byUsername.length === 1) {
498
+ return { memberId: byUsername[0].userId, memberName: byUsername[0].displayName || byUsername[0].userId, role: direct?.role || "editor" };
499
+ }
500
+ if (byUsername.length > 1) {
501
+ throw new Error(`Multiple Discord users matched @${normalized}. Use /people and invite by id.`);
502
+ }
503
+ const exact = known.find((person) => String(person.displayName || "").trim().toLowerCase() === normalized);
504
+ if (exact) {
505
+ return { memberId: exact.userId, memberName: exact.displayName || exact.userId, role: direct?.role || "editor" };
506
+ }
507
+ const partial = known.filter((person) => String(person.displayName || "").trim().toLowerCase().includes(normalized));
508
+ if (partial.length === 1) {
509
+ return { memberId: partial[0].userId, memberName: partial[0].displayName || partial[0].userId, role: direct?.role || "editor" };
510
+ }
511
+ if (partial.length > 1) {
512
+ throw new Error(`Multiple Discord users matched ${value}. Use /people and invite by id or @username.`);
513
+ }
514
+ return direct;
515
+ }
516
+
517
+ function resolveDiscordAgentTarget(project = {}, liveHosts = [], rawTarget = "") {
518
+ const value = String(rawTarget || "").trim().toLowerCase();
519
+ if (!value) return null;
520
+ if (/^(?:this|my)(?:\s+terminal)?$/.test(value)) {
521
+ const host = liveHosts[0] || null;
522
+ if (!host) return null;
523
+ return {
524
+ id: `agent:discord-bridge:${String(host.sessionId || host.pid || "current").trim()}`,
525
+ ownerId: String(host.ownerId || "").trim(),
526
+ ownerName: String(host.ownerName || "").trim(),
527
+ label: String(host.label || "").trim(),
528
+ surface: String(host.surface || "live-tui").trim(),
529
+ provider: String(host.provider || "").trim(),
530
+ model: String(host.model || "").trim(),
531
+ runtimeProfile: String(host.runtimeProfile || "").trim(),
532
+ sessionId: String(host.sessionId || "").trim(),
533
+ cwd: String(host.cwd || "").trim(),
534
+ role: "standby"
535
+ };
536
+ }
537
+ if (["executor", "reviewer", "verifier"].includes(value)) {
538
+ return chooseAgentByRole(project, value);
539
+ }
540
+ const candidates = [
541
+ ...(Array.isArray(project?.agents) ? project.agents : []),
542
+ ...liveHosts.map((host) => ({
543
+ id: `agent:discord-bridge:${String(host.sessionId || host.pid || "current").trim()}`,
544
+ ownerId: String(host.ownerId || "").trim(),
545
+ ownerName: String(host.ownerName || "").trim(),
546
+ label: String(host.label || "").trim(),
547
+ surface: String(host.surface || "live-tui").trim(),
548
+ provider: String(host.provider || "").trim(),
549
+ model: String(host.model || "").trim(),
550
+ runtimeProfile: String(host.runtimeProfile || "").trim(),
551
+ sessionId: String(host.sessionId || "").trim(),
552
+ cwd: String(host.cwd || "").trim(),
553
+ role: "standby"
554
+ }))
555
+ ];
556
+ const exact = candidates.find((agent) => {
557
+ const label = String(agent?.label || "").trim().toLowerCase();
558
+ const owner = String(agent?.ownerName || "").trim().toLowerCase();
559
+ const sessionId = String(agent?.sessionId || "").trim().toLowerCase();
560
+ return value === label || value === owner || value === sessionId;
561
+ });
562
+ if (exact) return exact;
563
+ const partial = candidates.filter((agent) => {
564
+ const label = String(agent?.label || "").trim().toLowerCase();
565
+ const owner = String(agent?.ownerName || "").trim().toLowerCase();
566
+ return (label && label.includes(value)) || (owner && owner.includes(value));
567
+ });
568
+ return partial.length === 1 ? partial[0] : null;
569
+ }
570
+
305
571
  async function loadDiscordGatewayState() {
306
572
  return loadGatewayState("discord");
307
573
  }
@@ -710,6 +976,74 @@ async function handleDiscordControlCommand(runtime, state, message, rawText) {
710
976
  "Use /room to inspect room state."
711
977
  ].join("\n");
712
978
  }
979
+ if (normalized === "/terminals" || normalized === "terminals" || normalized === "/live" || normalized === "live" || normalizedRoomAlias === "terminals") {
980
+ const sessionId = await ensurePeerSession(runtime, state, message);
981
+ const session = await loadSession(sessionId).catch(() => null);
982
+ const liveHosts = await getLiveBridgeHosts({ cwd: session?.cwd || "" });
983
+ return formatDiscordLiveHosts(liveHosts);
984
+ }
985
+ if (normalized === "/executor" || normalized === "executor" || normalizedRoomAlias === "executor") {
986
+ const sessionId = await ensurePeerSession(runtime, state, message);
987
+ const { project, session } = await bindSharedRoomForMessage(message, sessionId);
988
+ if (!project?.enabled) return "This project is not shared.";
989
+ const agent = chooseAgentByRole(project, "executor");
990
+ const liveHosts = await getLiveBridgeHosts({ cwd: session?.cwd || "" });
991
+ const live = agent ? liveHosts.some((host) => String(host?.ownerId || "") === String(agent?.ownerId || "") || String(host?.sessionId || "") === String(agent?.sessionId || "")) : false;
992
+ return formatDiscordAgentStatus("Executor", agent, { live });
993
+ }
994
+ if (normalized === "/reviewer" || normalized === "reviewer" || normalizedRoomAlias === "reviewer") {
995
+ const sessionId = await ensurePeerSession(runtime, state, message);
996
+ const { project, session } = await bindSharedRoomForMessage(message, sessionId);
997
+ if (!project?.enabled) return "This project is not shared.";
998
+ const agent = chooseAgentByRole(project, "reviewer");
999
+ const liveHosts = await getLiveBridgeHosts({ cwd: session?.cwd || "" });
1000
+ const live = agent ? liveHosts.some((host) => String(host?.ownerId || "") === String(agent?.ownerId || "") || String(host?.sessionId || "") === String(agent?.sessionId || "")) : false;
1001
+ return formatDiscordAgentStatus("Reviewer", agent, { live });
1002
+ }
1003
+ if (normalized === "/verifier" || normalized === "verifier" || normalizedRoomAlias === "verifier") {
1004
+ const sessionId = await ensurePeerSession(runtime, state, message);
1005
+ const { project, session } = await bindSharedRoomForMessage(message, sessionId);
1006
+ if (!project?.enabled) return "This project is not shared.";
1007
+ const agent = chooseAgentByRole(project, "verifier");
1008
+ const liveHosts = await getLiveBridgeHosts({ cwd: session?.cwd || "" });
1009
+ const live = agent ? liveHosts.some((host) => String(host?.ownerId || "") === String(agent?.ownerId || "") || String(host?.sessionId || "") === String(agent?.sessionId || "")) : false;
1010
+ return formatDiscordAgentStatus("Verifier", agent, { live });
1011
+ }
1012
+ if (normalized.startsWith("/assign-role ") || normalizedRoomAlias.startsWith("assign-role ")) {
1013
+ const body = normalized.startsWith("/assign-role ") ? value.slice("/assign-role ".length).trim() : roomAlias.slice("assign-role ".length).trim();
1014
+ const match = body.match(/^(.+?)\s+(executor|reviewer|verifier|standby)\s*$/i);
1015
+ if (!match) return "Usage: /assign-role <terminal|this terminal> <executor|reviewer|verifier|standby>";
1016
+ const targetText = String(match[1] || "").trim();
1017
+ const nextRole = String(match[2] || "").trim().toLowerCase();
1018
+ const sessionId = await ensurePeerSession(runtime, state, message);
1019
+ const { project, session } = await bindSharedRoomForMessage(message, sessionId);
1020
+ if (!project?.enabled) return "This project is not shared.";
1021
+ const liveHosts = await getLiveBridgeHosts({ cwd: session?.cwd || "" });
1022
+ const targetAgent = resolveDiscordAgentTarget(project, liveHosts, targetText);
1023
+ if (!targetAgent) {
1024
+ return `No terminal found for ${targetText}. Use /terminals first.`;
1025
+ }
1026
+ const actor = describeDiscordUser(message);
1027
+ const nextProject = await upsertSharedAgent(session.cwd || process.cwd(), {
1028
+ ...targetAgent,
1029
+ role: nextRole
1030
+ }, {
1031
+ actorId: actor.userId,
1032
+ actorName: actor.displayName
1033
+ });
1034
+ return [
1035
+ "Room role updated",
1036
+ `owner: ${getAgentOwnerDisplay(targetAgent, actor.displayName || actor.userId)}`,
1037
+ `terminal: ${getAgentTerminalDisplay(targetAgent, "terminal")}`,
1038
+ `role: ${nextRole}`,
1039
+ `project: ${nextProject.projectName || "project"}`
1040
+ ].join("\n");
1041
+ }
1042
+ if (normalized === "/people" || normalized === "people" || normalizedRoomAlias === "people") {
1043
+ const sessionId = await ensurePeerSession(runtime, state, message);
1044
+ const { project } = await bindSharedRoomForMessage(message, sessionId);
1045
+ return formatDiscordPeople({ people: listKnownDiscordPeople(state, message), project });
1046
+ }
713
1047
  if (normalized === "/events" || normalized === "events" || normalizedRoomAlias === "events") {
714
1048
  const sessionId = await ensurePeerSession(runtime, state, message);
715
1049
  const { session, project } = await bindSharedRoomForMessage(message, sessionId);
@@ -730,10 +1064,10 @@ async function handleDiscordControlCommand(runtime, state, message, rawText) {
730
1064
  || normalizedRoomAlias.startsWith("invite ")
731
1065
  ) {
732
1066
  const sessionId = await ensurePeerSession(runtime, state, message);
733
- const sourceText = normalizedRoomAlias.startsWith("invite ") ? `/invite ${roomAlias.slice("invite ".length).trim()}` : value;
734
- const inviteRequest = parseDiscordInviteCommand(message, sourceText);
1067
+ const sourceText = normalizedRoomAlias.startsWith("invite ") ? roomAlias.slice("invite ".length).trim() : value.replace(/^\/?invite\s+/i, "").trim();
1068
+ const inviteRequest = resolveDiscordInviteTarget(state, message, sourceText);
735
1069
  if (!inviteRequest?.memberId) {
736
- return "Usage: /invite <@user|user-id> [owner|editor|observer]";
1070
+ return "Usage: /invite <@user|user-id|name> [owner|editor|observer]";
737
1071
  }
738
1072
  const { session, project } = await bindSharedRoomForMessage(message, sessionId);
739
1073
  if (!project?.enabled) return "This project is not shared.";
@@ -926,6 +1260,34 @@ async function getLiveBridgeHost({ cwd = "" } = {}) {
926
1260
  return matchingHosts[0] || null;
927
1261
  }
928
1262
 
1263
+ async function getLiveBridgeHosts({ cwd = "" } = {}) {
1264
+ const bridge = await loadGatewayBridge("discord");
1265
+ const hosts = Array.isArray(bridge.hosts) ? bridge.hosts : [];
1266
+ const nextHosts = [];
1267
+ let changed = false;
1268
+ for (const host of hosts) {
1269
+ const pid = Number(host?.pid || 0);
1270
+ if (!Number.isFinite(pid) || pid <= 0) {
1271
+ changed = true;
1272
+ continue;
1273
+ }
1274
+ try {
1275
+ process.kill(pid, 0);
1276
+ } catch {
1277
+ changed = true;
1278
+ continue;
1279
+ }
1280
+ nextHosts.push(host);
1281
+ }
1282
+ if (changed || nextHosts.length !== hosts.length) {
1283
+ bridge.hosts = nextHosts;
1284
+ await saveGatewayBridge("discord", bridge);
1285
+ }
1286
+ return cwd
1287
+ ? nextHosts.filter((host) => !host?.cwd || String(host.cwd) === String(cwd || ""))
1288
+ : nextHosts;
1289
+ }
1290
+
929
1291
  async function runPromptViaBridge(runtime, message, promptText, options = {}) {
930
1292
  const host = await getLiveBridgeHost();
931
1293
  if (!host) {
@@ -1124,10 +1486,12 @@ export async function runDiscordGateway(runtime = {}, { log = console.log, signa
1124
1486
  if (!msg || msg.author?.bot) return;
1125
1487
  const scope = msg.guild_id ? `guild:${msg.guild_id}` : "dm";
1126
1488
  log(`discord: ${scope} #${msg.channel_id} ${msg.author?.username || "unknown"} -> ${String(msg.content || "").trim()}`);
1127
- const shouldReply = shouldReplyToMessage(msg, botUser?.id);
1128
- const rawText = extractMentionContent(String(msg.content || "").trim(), botUser?.id);
1129
- const reply = shouldReply ? buildReply(msg, botUser?.id) : null;
1130
1489
  try {
1490
+ const trackingState = await loadDiscordGatewayState();
1491
+ await rememberKnownDiscordPeople(trackingState, msg);
1492
+ const shouldReply = shouldReplyToMessage(msg, botUser?.id);
1493
+ const rawText = extractMentionContent(String(msg.content || "").trim(), botUser?.id);
1494
+ const reply = shouldReply ? buildReply(msg, botUser?.id) : null;
1131
1495
  if (reply) {
1132
1496
  await sendChannelMessage(discord, msg.channel_id, reply);
1133
1497
  log(`discord: replied in ${msg.channel_id}`);
@@ -19,6 +19,7 @@ function gatewayBridgePath(serviceId) {
19
19
 
20
20
  function normalizeGatewayState(parsed = {}) {
21
21
  const continuations = parsed?.continuations && typeof parsed.continuations === "object" ? parsed.continuations : {};
22
+ const knownPeople = parsed?.knownPeople && typeof parsed.knownPeople === "object" ? parsed.knownPeople : {};
22
23
  return {
23
24
  offset: Number.isFinite(Number(parsed?.offset)) ? Math.max(0, Math.floor(Number(parsed.offset))) : 0,
24
25
  peers: parsed?.peers && typeof parsed.peers === "object" ? parsed.peers : {},
@@ -26,6 +27,20 @@ function normalizeGatewayState(parsed = {}) {
26
27
  announcedEvents: Array.isArray(parsed?.announcedEvents)
27
28
  ? parsed.announcedEvents.map((value) => String(value || "").trim()).filter(Boolean).slice(-100)
28
29
  : [],
30
+ knownPeople: Object.fromEntries(
31
+ Object.entries(knownPeople).map(([key, item]) => [
32
+ key,
33
+ {
34
+ chatId: String(item?.chatId || "").trim(),
35
+ userId: String(item?.userId || "").trim(),
36
+ username: String(item?.username || "").trim(),
37
+ usernameHandle: String(item?.usernameHandle || "").trim(),
38
+ displayName: String(item?.displayName || item?.username || item?.userId || "").trim(),
39
+ paired: item?.paired === true,
40
+ lastSeenAt: String(item?.lastSeenAt || "").trim()
41
+ }
42
+ ]).filter(([, item]) => item.chatId && item.userId)
43
+ ),
29
44
  continuations: Object.fromEntries(
30
45
  Object.entries(continuations).map(([key, item]) => [
31
46
  key,
@@ -154,7 +169,7 @@ export async function loadGatewayState(serviceId) {
154
169
  return normalizeGatewayState(JSON.parse(raw));
155
170
  } catch (error) {
156
171
  if (error?.code === "ENOENT") {
157
- return { offset: 0, peers: {}, pendingPairings: {}, announcedEvents: [], continuations: {} };
172
+ return { offset: 0, peers: {}, pendingPairings: {}, announcedEvents: [], knownPeople: {}, continuations: {} };
158
173
  }
159
174
  throw error;
160
175
  }