@tritard/waterbrother 0.16.143 → 0.16.144

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.143",
3
+ "version": "0.16.144",
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/channels.js CHANGED
@@ -232,11 +232,12 @@ export function buildChannelOnboardingPayload(serviceId) {
232
232
  "waterbrother config set-json channels '{\"discord\":{\"enabled\":true,\"botToken\":\"YOUR_BOT_TOKEN\",\"applicationId\":\"YOUR_APP_ID\",\"pairingMode\":\"manual\",\"allowedUserIds\":[]}}'",
233
233
  "waterbrother channels status",
234
234
  "waterbrother discord status",
235
+ "waterbrother discord sync-commands",
235
236
  "waterbrother discord run"
236
237
  ],
237
238
  notes: [
238
239
  "Start with direct messages before any guild or channel workflow.",
239
- "This first runtime slice handles DMs and mentions, not full Roundtable rooms yet.",
240
+ "Waterbrother now syncs native Discord slash commands on startup, and you can force a sync with waterbrother discord sync-commands.",
240
241
  "Prefer explicit user allowlists.",
241
242
  "The interactive TUI onboarding wizard can store the Discord token and application id directly, just like Telegram."
242
243
  ]
package/src/cli.js CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { promisify } from "node:util";
9
9
  import { Agent } from "./agent.js";
10
10
  import { getConfigPath, loadConfigLayers, resolveRuntimeConfig, saveConfig } from "./config.js";
11
- import { getDiscordStatus, runDiscordGateway } from "./discord.js";
11
+ import { getDiscordStatus, runDiscordGateway, syncDiscordCommands } from "./discord.js";
12
12
  import { createChatCompletion, createJsonCompletion, listModels } from "./model-client.js";
13
13
  import { buildChannelOnboardingPayload, getChannelStatuses, getGatewayStatus } from "./channels.js";
14
14
  import { runGatewayService } from "./gateway.js";
@@ -4226,8 +4226,8 @@ async function runDoctorCommand({ runtime, cwd, asJson = false }) {
4226
4226
 
4227
4227
  async function runDiscordCommand(positional, runtime, { asJson = false } = {}) {
4228
4228
  const sub = String(positional[1] || "status").trim().toLowerCase();
4229
- if (!["status", "run"].includes(sub)) {
4230
- throw new Error("Usage: waterbrother discord <status|run>");
4229
+ if (!["status", "run", "sync-commands"].includes(sub)) {
4230
+ throw new Error("Usage: waterbrother discord <status|run|sync-commands>");
4231
4231
  }
4232
4232
 
4233
4233
  if (sub === "status") {
@@ -4243,6 +4243,12 @@ async function runDiscordCommand(positional, runtime, { asJson = false } = {}) {
4243
4243
  console.log(`token: ${status.tokenConfigured ? "configured" : "missing"}`);
4244
4244
  console.log(`application id: ${status.applicationId || "missing"}`);
4245
4245
  console.log(`intents: ${status.intents.join(", ") || "none"}`);
4246
+ if (status.commandScope) {
4247
+ console.log(`slash commands: ${status.commandCount || 0} (${status.commandScope})`);
4248
+ if (status.commandUpdatedAt) {
4249
+ console.log(`commands updated: ${status.commandUpdatedAt}`);
4250
+ }
4251
+ }
4246
4252
  if (status.lastStatus) {
4247
4253
  console.log(`last state: ${status.lastStatus.state || "unknown"}`);
4248
4254
  console.log(`updated: ${status.lastStatus.updatedAt || "unknown"}`);
@@ -4253,6 +4259,20 @@ async function runDiscordCommand(positional, runtime, { asJson = false } = {}) {
4253
4259
  return;
4254
4260
  }
4255
4261
 
4262
+ if (sub === "sync-commands") {
4263
+ if (asJson) {
4264
+ const result = await syncDiscordCommands(runtime);
4265
+ printData(result, true);
4266
+ return;
4267
+ }
4268
+ const result = await syncDiscordCommands(runtime, { log: (line) => console.log(line) });
4269
+ console.log("Discord slash commands");
4270
+ console.log("");
4271
+ console.log(`scope: ${result.scope}`);
4272
+ console.log(`count: ${result.count}`);
4273
+ return;
4274
+ }
4275
+
4256
4276
  if (asJson) {
4257
4277
  throw new Error("waterbrother discord run does not support --json");
4258
4278
  }
package/src/discord.js CHANGED
@@ -35,6 +35,7 @@ const STATUS_PATH = path.join(os.homedir(), ".waterbrother", "discord-status.jso
35
35
  const DISCORD_BRIDGE_TIMEOUT_MS = 5 * 60 * 1000;
36
36
  const DISCORD_BRIDGE_POLL_MS = 250;
37
37
  const DISCORD_CONTINUATION_TTL_MS = 15 * 60 * 1000;
38
+ const DISCORD_COMMAND_VERSION = 1;
38
39
 
39
40
  const INTENT_BITS = {
40
41
  GUILDS: 1 << 0,
@@ -77,6 +78,14 @@ async function loadDiscordStatus() {
77
78
  }
78
79
  }
79
80
 
81
+ async function patchDiscordStatus(patch = {}) {
82
+ const current = await loadDiscordStatus().catch(() => null);
83
+ await saveDiscordStatus({
84
+ ...(current || {}),
85
+ ...patch
86
+ });
87
+ }
88
+
80
89
  function normalizeDiscordRuntime(runtime = {}) {
81
90
  const discord = runtime?.channels?.discord || {};
82
91
  const intents = Array.isArray(discord.intents) && discord.intents.length > 0
@@ -93,6 +102,169 @@ function normalizeDiscordRuntime(runtime = {}) {
93
102
  };
94
103
  }
95
104
 
105
+ function buildDiscordSlashCommands() {
106
+ const roleChoices = [
107
+ { name: "executor", value: "executor" },
108
+ { name: "reviewer", value: "reviewer" },
109
+ { name: "verifier", value: "verifier" },
110
+ { name: "standby", value: "standby" }
111
+ ];
112
+ const roomModeChoices = [
113
+ { name: "chat", value: "chat" },
114
+ { name: "plan", value: "plan" },
115
+ { name: "execute", value: "execute" }
116
+ ];
117
+ const taskStateChoices = [
118
+ { name: "open", value: "open" },
119
+ { name: "active", value: "active" },
120
+ { name: "blocked", value: "blocked" },
121
+ { name: "done", value: "done" }
122
+ ];
123
+ const inviteRoleChoices = [
124
+ { name: "owner", value: "owner" },
125
+ { name: "editor", value: "editor" },
126
+ { name: "observer", value: "observer" }
127
+ ];
128
+ const commands = [
129
+ { name: "help", description: "Show Waterbrother Discord help" },
130
+ { name: "about", description: "Show Waterbrother identity and capabilities" },
131
+ { name: "state", description: "Show Waterbrother self-awareness state" },
132
+ { name: "status", description: "Show Discord gateway and remote session status" },
133
+ { name: "project", description: "Show the current project and sharing state" },
134
+ { name: "project-share", description: "Share the current project with this Discord room" },
135
+ { name: "room", description: "Show shared room status for this project" },
136
+ { name: "members", description: "List shared room members" },
137
+ { name: "tasks", description: "List shared tasks" },
138
+ { name: "people", description: "List recently seen Discord people" },
139
+ { name: "terminals", description: "List live Waterbrother terminals" },
140
+ { name: "executor", description: "Show the assigned executor" },
141
+ { name: "reviewer", description: "Show the assigned reviewer" },
142
+ { name: "verifier", description: "Show the assigned verifier" },
143
+ { name: "verify", description: "Run verification through the assigned verifier" },
144
+ { name: "verification", description: "Show the latest verification result" },
145
+ { name: "rerun-verification", description: "Rerun verification" },
146
+ { name: "override-verification", description: "Override blocking verification" },
147
+ { name: "review-status", description: "Show reviewer blocking status and latest outcome" },
148
+ { name: "accept-reviewer-concerns", description: "Accept reviewer concerns and clear blocking review" },
149
+ { name: "override-reviewer", description: "Override the current reviewer outcome" },
150
+ { name: "switch-executor-to-reviewer", description: "Make the reviewer the executor" },
151
+ { name: "new", description: "Start a fresh remote session" },
152
+ { name: "sessions", description: "List recent remote sessions" },
153
+ { name: "clear", description: "Clear the current remote conversation" },
154
+ {
155
+ name: "resume",
156
+ description: "Resume a remote session",
157
+ options: [
158
+ { type: 3, name: "session-id", description: "Remote session id", required: true }
159
+ ]
160
+ },
161
+ {
162
+ name: "mode",
163
+ description: "Show or set the shared room mode",
164
+ options: [
165
+ { type: 3, name: "mode", description: "Room mode", required: false, choices: roomModeChoices }
166
+ ]
167
+ },
168
+ { name: "events", description: "List recent shared room events" },
169
+ { name: "invites", description: "List pending shared room invites" },
170
+ {
171
+ name: "invite",
172
+ description: "Create a shared room invite",
173
+ options: [
174
+ { type: 3, name: "target", description: "Discord user id, @username, or recent display name", required: true },
175
+ { type: 3, name: "role", description: "Invite role", required: false, choices: inviteRoleChoices }
176
+ ]
177
+ },
178
+ {
179
+ name: "approve-invite",
180
+ description: "Approve a pending invite",
181
+ options: [
182
+ { type: 3, name: "invite-id", description: "Invite id", required: true }
183
+ ]
184
+ },
185
+ {
186
+ name: "accept-invite",
187
+ description: "Accept a pending invite",
188
+ options: [
189
+ { type: 3, name: "invite-id", description: "Invite id", required: true }
190
+ ]
191
+ },
192
+ {
193
+ name: "reject-invite",
194
+ description: "Reject a pending invite",
195
+ options: [
196
+ { type: 3, name: "invite-id", description: "Invite id", required: true }
197
+ ]
198
+ },
199
+ {
200
+ name: "remove-member",
201
+ description: "Remove a shared room member",
202
+ options: [
203
+ { type: 3, name: "member-id", description: "Member id", required: true }
204
+ ]
205
+ },
206
+ { name: "claim", description: "Claim shared operator control" },
207
+ { name: "release", description: "Release shared operator control" },
208
+ {
209
+ name: "assign-role",
210
+ description: "Assign a room role to a terminal or agent",
211
+ options: [
212
+ { type: 3, name: "target", description: "Terminal label, this terminal, or agent label", required: true },
213
+ { type: 3, name: "role", description: "Role to assign", required: true, choices: roleChoices }
214
+ ]
215
+ },
216
+ {
217
+ name: "task-add",
218
+ description: "Add a shared task",
219
+ options: [
220
+ { type: 3, name: "text", description: "Task text", required: true }
221
+ ]
222
+ },
223
+ {
224
+ name: "task-move",
225
+ description: "Move a shared task",
226
+ options: [
227
+ { type: 3, name: "task-id", description: "Task id", required: true },
228
+ { type: 3, name: "state", description: "Next task state", required: true, choices: taskStateChoices }
229
+ ]
230
+ },
231
+ {
232
+ name: "task-assign",
233
+ description: "Assign a shared task",
234
+ options: [
235
+ { type: 3, name: "task-id", description: "Task id", required: true },
236
+ { type: 3, name: "member-id", description: "Member id", required: true }
237
+ ]
238
+ },
239
+ {
240
+ name: "task-claim",
241
+ description: "Claim a shared task",
242
+ options: [
243
+ { type: 3, name: "task-id", description: "Task id", required: true }
244
+ ]
245
+ },
246
+ {
247
+ name: "task-comment",
248
+ description: "Comment on a shared task",
249
+ options: [
250
+ { type: 3, name: "task-id", description: "Task id", required: true },
251
+ { type: 3, name: "text", description: "Comment text", required: true }
252
+ ]
253
+ },
254
+ {
255
+ name: "task-history",
256
+ description: "Show shared task history",
257
+ options: [
258
+ { type: 3, name: "task-id", description: "Task id", required: true }
259
+ ]
260
+ }
261
+ ];
262
+ return commands.map((command) => ({
263
+ ...command,
264
+ dm_permission: true
265
+ }));
266
+ }
267
+
96
268
  async function discordFetch(runtime, route, init = {}) {
97
269
  const token = String(runtime?.token || "").trim();
98
270
  if (!token) {
@@ -112,6 +284,55 @@ async function discordFetch(runtime, route, init = {}) {
112
284
  return response.json();
113
285
  }
114
286
 
287
+ function getDiscordCommandScope(discord = {}, status = null) {
288
+ const guildId = String(discord.guildId || status?.lastGuildId || "").trim();
289
+ if (guildId) {
290
+ return {
291
+ guildId,
292
+ scope: `guild:${guildId}`,
293
+ route: `/applications/${discord.applicationId}/guilds/${guildId}/commands`
294
+ };
295
+ }
296
+ return {
297
+ guildId: "",
298
+ scope: "global",
299
+ route: `/applications/${discord.applicationId}/commands`
300
+ };
301
+ }
302
+
303
+ export async function syncDiscordCommands(runtime = {}, { log = () => {} } = {}) {
304
+ const discord = normalizeDiscordRuntime(runtime);
305
+ if (!discord.enabled) {
306
+ throw new Error("Discord is not enabled in channels.discord.enabled");
307
+ }
308
+ if (!discord.token) {
309
+ throw new Error("Discord bot token is missing");
310
+ }
311
+ if (!discord.applicationId) {
312
+ throw new Error("Discord application id is missing");
313
+ }
314
+ const savedStatus = await loadDiscordStatus().catch(() => null);
315
+ const scope = getDiscordCommandScope(discord, savedStatus);
316
+ const commands = buildDiscordSlashCommands();
317
+ const synced = await discordFetch(discord, scope.route, {
318
+ method: "PUT",
319
+ body: JSON.stringify(commands)
320
+ });
321
+ const count = Array.isArray(synced) ? synced.length : commands.length;
322
+ await patchDiscordStatus({
323
+ commandVersion: DISCORD_COMMAND_VERSION,
324
+ commandScope: scope.scope,
325
+ commandCount: count,
326
+ commandUpdatedAt: new Date().toISOString()
327
+ });
328
+ log(`discord: slash commands synced (${scope.scope}, ${count})`);
329
+ return {
330
+ scope: scope.scope,
331
+ guildId: scope.guildId,
332
+ count
333
+ };
334
+ }
335
+
115
336
  async function fetchGatewayInfo(runtime) {
116
337
  return discordFetch(runtime, "/gateway/bot", { method: "GET" });
117
338
  }
@@ -125,6 +346,27 @@ async function sendChannelMessage(runtime, channelId, content) {
125
346
  });
126
347
  }
127
348
 
349
+ async function createInteractionResponse(runtime, interaction, body) {
350
+ return discordFetch(runtime, `/interactions/${interaction.id}/${interaction.token}/callback`, {
351
+ method: "POST",
352
+ body: JSON.stringify(body)
353
+ });
354
+ }
355
+
356
+ async function deferInteractionResponse(runtime, interaction) {
357
+ return createInteractionResponse(runtime, interaction, {
358
+ type: 5
359
+ });
360
+ }
361
+
362
+ async function editInteractionResponse(runtime, interaction, content) {
363
+ const text = String(content || "").trim() || "(no content)";
364
+ return discordFetch(runtime, `/webhooks/${runtime.applicationId}/${interaction.token}/messages/@original`, {
365
+ method: "PATCH",
366
+ body: JSON.stringify({ content: text })
367
+ });
368
+ }
369
+
128
370
  function extractMentionContent(content, botUserId) {
129
371
  const raw = String(content || "");
130
372
  if (!botUserId) return raw.trim();
@@ -348,6 +590,105 @@ function describeDiscordUser(message = {}) {
348
590
  };
349
591
  }
350
592
 
593
+ function getInteractionOptionValue(options = [], name) {
594
+ const match = Array.isArray(options) ? options.find((option) => String(option?.name || "").trim() === String(name || "").trim()) : null;
595
+ return match?.value;
596
+ }
597
+
598
+ function buildInteractionMessage(interaction = {}) {
599
+ const user = interaction?.member?.user || interaction?.user || {};
600
+ return {
601
+ id: String(interaction?.id || "").trim(),
602
+ channel_id: String(interaction?.channel_id || "").trim(),
603
+ guild_id: String(interaction?.guild_id || "").trim(),
604
+ content: "",
605
+ author: {
606
+ id: String(user?.id || "").trim(),
607
+ username: String(user?.username || "").trim(),
608
+ global_name: String(user?.global_name || "").trim(),
609
+ bot: Boolean(user?.bot)
610
+ },
611
+ mentions: []
612
+ };
613
+ }
614
+
615
+ function buildDiscordCommandTextFromInteraction(interaction = {}) {
616
+ const data = interaction?.data && typeof interaction.data === "object" ? interaction.data : {};
617
+ const name = String(data.name || "").trim().toLowerCase();
618
+ const options = Array.isArray(data.options) ? data.options : [];
619
+ switch (name) {
620
+ case "help":
621
+ case "about":
622
+ case "state":
623
+ case "status":
624
+ case "project":
625
+ case "room":
626
+ case "members":
627
+ case "tasks":
628
+ case "people":
629
+ case "terminals":
630
+ case "executor":
631
+ case "reviewer":
632
+ case "verifier":
633
+ case "verify":
634
+ case "verification":
635
+ case "rerun-verification":
636
+ case "override-verification":
637
+ case "review-status":
638
+ case "accept-reviewer-concerns":
639
+ case "override-reviewer":
640
+ case "switch-executor-to-reviewer":
641
+ case "new":
642
+ case "sessions":
643
+ case "clear":
644
+ case "events":
645
+ case "invites":
646
+ case "claim":
647
+ case "release":
648
+ return `/${name}`;
649
+ case "project-share":
650
+ return "/project share";
651
+ case "resume":
652
+ return `/resume ${String(getInteractionOptionValue(options, "session-id") || "").trim()}`.trim();
653
+ case "mode": {
654
+ const mode = String(getInteractionOptionValue(options, "mode") || "").trim();
655
+ return mode ? `/mode ${mode}` : "/mode";
656
+ }
657
+ case "invite": {
658
+ const target = String(getInteractionOptionValue(options, "target") || "").trim();
659
+ const role = String(getInteractionOptionValue(options, "role") || "").trim();
660
+ return [`/invite`, target, role].filter(Boolean).join(" ");
661
+ }
662
+ case "approve-invite":
663
+ return `/approve-invite ${String(getInteractionOptionValue(options, "invite-id") || "").trim()}`.trim();
664
+ case "accept-invite":
665
+ return `/accept-invite ${String(getInteractionOptionValue(options, "invite-id") || "").trim()}`.trim();
666
+ case "reject-invite":
667
+ return `/reject-invite ${String(getInteractionOptionValue(options, "invite-id") || "").trim()}`.trim();
668
+ case "remove-member":
669
+ return `/remove-member ${String(getInteractionOptionValue(options, "member-id") || "").trim()}`.trim();
670
+ case "assign-role": {
671
+ const target = String(getInteractionOptionValue(options, "target") || "").trim();
672
+ const role = String(getInteractionOptionValue(options, "role") || "").trim();
673
+ return [`/assign-role`, target, role].filter(Boolean).join(" ");
674
+ }
675
+ case "task-add":
676
+ return `/task add ${String(getInteractionOptionValue(options, "text") || "").trim()}`.trim();
677
+ case "task-move":
678
+ return `/task move ${String(getInteractionOptionValue(options, "task-id") || "").trim()} ${String(getInteractionOptionValue(options, "state") || "").trim()}`.trim();
679
+ case "task-assign":
680
+ return `/task assign ${String(getInteractionOptionValue(options, "task-id") || "").trim()} ${String(getInteractionOptionValue(options, "member-id") || "").trim()}`.trim();
681
+ case "task-claim":
682
+ return `/task claim ${String(getInteractionOptionValue(options, "task-id") || "").trim()}`.trim();
683
+ case "task-comment":
684
+ return `/task comment ${String(getInteractionOptionValue(options, "task-id") || "").trim()} ${String(getInteractionOptionValue(options, "text") || "").trim()}`.trim();
685
+ case "task-history":
686
+ return `/task history ${String(getInteractionOptionValue(options, "task-id") || "").trim()}`.trim();
687
+ default:
688
+ return "";
689
+ }
690
+ }
691
+
351
692
  function formatDiscordInvites(invites = []) {
352
693
  if (!invites.length) return "Pending invites\n- none";
353
694
  return [
@@ -976,6 +1317,37 @@ async function handleDiscordSessionCommand(runtime, state, message, command) {
976
1317
  return null;
977
1318
  }
978
1319
 
1320
+ async function handleDiscordSlashInteraction(runtime, interaction, { log = () => {} } = {}) {
1321
+ const discord = normalizeDiscordRuntime(runtime);
1322
+ const message = buildInteractionMessage(interaction);
1323
+ const rawText = buildDiscordCommandTextFromInteraction(interaction);
1324
+ if (!rawText) {
1325
+ await createInteractionResponse(discord, interaction, {
1326
+ type: 4,
1327
+ data: { content: "Unsupported slash command payload." }
1328
+ });
1329
+ return;
1330
+ }
1331
+
1332
+ await deferInteractionResponse(discord, interaction);
1333
+ try {
1334
+ const state = await loadDiscordGatewayState();
1335
+ let reply = await handleDiscordControlCommand(runtime, state, message, rawText);
1336
+ if (!reply) {
1337
+ const command = parseDiscordSessionCommand(rawText);
1338
+ if (command) {
1339
+ reply = await handleDiscordSessionCommand(runtime, state, message, command);
1340
+ }
1341
+ }
1342
+ await editInteractionResponse(discord, interaction, reply || "Command completed.");
1343
+ log(`discord: interaction reply in ${message.channel_id}`);
1344
+ } catch (error) {
1345
+ const text = error instanceof Error ? error.message : String(error);
1346
+ await editInteractionResponse(discord, interaction, `Slash command failed: ${text}`);
1347
+ throw error;
1348
+ }
1349
+ }
1350
+
979
1351
  async function buildDiscordAbout(runtime, state, message) {
980
1352
  const actor = describeDiscordUser(message);
981
1353
  const sessionId = getPeerState(state, actor.userId)?.sessionId || "";
@@ -1807,6 +2179,9 @@ export async function getDiscordStatus(runtime = {}) {
1807
2179
  guildId: discord.guildId || null,
1808
2180
  intents: discord.intents,
1809
2181
  intentMask: discord.intentMask,
2182
+ commandScope: saved?.commandScope || null,
2183
+ commandCount: Number(saved?.commandCount || 0),
2184
+ commandUpdatedAt: saved?.commandUpdatedAt || null,
1810
2185
  lastStatus: saved
1811
2186
  };
1812
2187
  }
@@ -1823,6 +2198,12 @@ export async function runDiscordGateway(runtime = {}, { log = console.log, signa
1823
2198
  throw new Error("Discord application id is missing");
1824
2199
  }
1825
2200
 
2201
+ try {
2202
+ await syncDiscordCommands(runtime, { log });
2203
+ } catch (error) {
2204
+ log(`discord: slash command sync failed: ${error instanceof Error ? error.message : String(error)}`);
2205
+ }
2206
+
1826
2207
  const gatewayInfo = await fetchGatewayInfo(discord);
1827
2208
  const gatewayUrl = `${gatewayInfo.url}?v=10&encoding=json`;
1828
2209
  const ws = new WebSocket(gatewayUrl);
@@ -1843,7 +2224,7 @@ export async function runDiscordGateway(runtime = {}, { log = console.log, signa
1843
2224
  closed = true;
1844
2225
  stopHeartbeat();
1845
2226
  try { ws.close(1000, reason); } catch {}
1846
- await saveDiscordStatus({
2227
+ await patchDiscordStatus({
1847
2228
  state: reason,
1848
2229
  updatedAt: new Date().toISOString(),
1849
2230
  applicationId: discord.applicationId,
@@ -1911,7 +2292,7 @@ export async function runDiscordGateway(runtime = {}, { log = console.log, signa
1911
2292
  if (packet.t === "READY") {
1912
2293
  botUser = packet.d?.user || null;
1913
2294
  log(`discord: ready as ${botUser?.username || "unknown"} (${botUser?.id || "n/a"})`);
1914
- void saveDiscordStatus({
2295
+ void patchDiscordStatus({
1915
2296
  state: "ready",
1916
2297
  updatedAt: new Date().toISOString(),
1917
2298
  applicationId: discord.applicationId,
@@ -1923,12 +2304,33 @@ export async function runDiscordGateway(runtime = {}, { log = console.log, signa
1923
2304
  return;
1924
2305
  }
1925
2306
 
2307
+ if (packet.t === "INTERACTION_CREATE") {
2308
+ try {
2309
+ if (packet.d?.guild_id) {
2310
+ await patchDiscordStatus({
2311
+ lastGuildId: String(packet.d.guild_id || "").trim(),
2312
+ updatedAt: new Date().toISOString()
2313
+ });
2314
+ }
2315
+ await handleDiscordSlashInteraction(runtime, packet.d, { log });
2316
+ } catch (error) {
2317
+ log(`discord: interaction failed: ${error instanceof Error ? error.message : String(error)}`);
2318
+ }
2319
+ return;
2320
+ }
2321
+
1926
2322
  if (packet.t === "MESSAGE_CREATE") {
1927
2323
  const msg = packet.d;
1928
2324
  if (!msg || msg.author?.bot) return;
1929
2325
  const scope = msg.guild_id ? `guild:${msg.guild_id}` : "dm";
1930
2326
  log(`discord: ${scope} #${msg.channel_id} ${msg.author?.username || "unknown"} -> ${String(msg.content || "").trim()}`);
1931
2327
  try {
2328
+ if (msg.guild_id) {
2329
+ await patchDiscordStatus({
2330
+ lastGuildId: String(msg.guild_id || "").trim(),
2331
+ updatedAt: new Date().toISOString()
2332
+ });
2333
+ }
1932
2334
  const trackingState = await loadDiscordGatewayState();
1933
2335
  await rememberKnownDiscordPeople(trackingState, msg);
1934
2336
  const shouldReply = shouldReplyToMessage(msg, botUser?.id);