@tritard/waterbrother 0.16.121 → 0.16.123

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.121",
3
+ "version": "0.16.123",
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
@@ -55,7 +55,13 @@ function normalizeTelegramConfig(value = {}) {
55
55
  const common = normalizeCommonChannelConfig(source);
56
56
  return {
57
57
  ...common,
58
- botToken: String(source.botToken || "").trim(),
58
+ botToken: String(
59
+ source.botToken ||
60
+ source.token ||
61
+ process.env.TELEGRAM_BOT_TOKEN ||
62
+ process.env.WATERBROTHER_TELEGRAM_BOT_TOKEN ||
63
+ ""
64
+ ).trim(),
59
65
  pairingExpiryMinutes: Number.isFinite(Number(source.pairingExpiryMinutes)) ? Math.max(1, Math.floor(Number(source.pairingExpiryMinutes))) : 720
60
66
  };
61
67
  }
@@ -65,8 +71,19 @@ function normalizeDiscordConfig(value = {}) {
65
71
  const common = normalizeCommonChannelConfig(source);
66
72
  return {
67
73
  ...common,
68
- botToken: String(source.botToken || "").trim(),
69
- applicationId: String(source.applicationId || "").trim(),
74
+ botToken: String(
75
+ source.botToken ||
76
+ source.token ||
77
+ process.env.DISCORD_BOT_TOKEN ||
78
+ process.env.WATERBROTHER_DISCORD_BOT_TOKEN ||
79
+ ""
80
+ ).trim(),
81
+ applicationId: String(
82
+ source.applicationId ||
83
+ process.env.DISCORD_APPLICATION_ID ||
84
+ process.env.WATERBROTHER_DISCORD_APPLICATION_ID ||
85
+ ""
86
+ ).trim(),
70
87
  allowedGuildIds: asStringArray(source.allowedGuildIds)
71
88
  };
72
89
  }
@@ -205,19 +222,21 @@ export function buildChannelOnboardingPayload(serviceId) {
205
222
  id: spec.id,
206
223
  label: spec.label,
207
224
  docsPath: spec.docsPath,
208
- summary: "Single-user Discord control through a bot token, application id, and DM-first policy.",
225
+ summary: "DM-first Discord control through a bot token, application id, and a local Gateway runner.",
209
226
  prerequisites: [
210
227
  "Create a Discord application and bot",
211
228
  "Capture the bot token and application id",
212
- "Enable only the intents you actually need"
229
+ "Enable Message Content Intent before testing DMs or mentions"
213
230
  ],
214
231
  commands: [
215
- "waterbrother config set-json channels '{\"discord\":{\"enabled\":true,\"botToken\":\"YOUR_BOT_TOKEN\",\"applicationId\":\"YOUR_APP_ID\",\"pairingMode\":\"manual\",\"allowedUserIds\":[]}}'",
232
+ "waterbrother config set-json channels '{\"discord\":{\"enabled\":true,\"botToken\":\"{env:DISCORD_BOT_TOKEN}\",\"applicationId\":\"{env:DISCORD_APPLICATION_ID}\",\"pairingMode\":\"manual\",\"allowedUserIds\":[]}}'",
216
233
  "waterbrother channels status",
217
- "waterbrother gateway status"
234
+ "waterbrother discord status",
235
+ "waterbrother discord run"
218
236
  ],
219
237
  notes: [
220
238
  "Start with direct messages before any guild or channel workflow.",
239
+ "This first runtime slice handles DMs and mentions, not full Roundtable rooms yet.",
221
240
  "Prefer explicit user allowlists."
222
241
  ]
223
242
  };
package/src/cli.js CHANGED
@@ -8,6 +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
12
  import { createChatCompletion, createJsonCompletion, listModels } from "./model-client.js";
12
13
  import { buildChannelOnboardingPayload, getChannelStatuses, getGatewayStatus } from "./channels.js";
13
14
  import { runGatewayService } from "./gateway.js";
@@ -334,6 +335,8 @@ Usage:
334
335
  waterbrother channels list
335
336
  waterbrother channels status
336
337
  waterbrother channels show <service>
338
+ waterbrother discord status
339
+ waterbrother discord run
337
340
  waterbrother gateway status
338
341
  waterbrother gateway run telegram
339
342
  waterbrother gateway stop [telegram]
@@ -3854,6 +3857,7 @@ async function runDoctorCommand({ runtime, cwd, asJson = false }) {
3854
3857
  const git = await checkBinary("git");
3855
3858
  const mcpServerNames = Object.keys(runtime.mcpServers || {});
3856
3859
  const gateway = getGatewayStatus(runtime);
3860
+ const discord = await getDiscordStatus(runtime);
3857
3861
 
3858
3862
  const report = {
3859
3863
  timestamp: new Date().toISOString(),
@@ -3878,6 +3882,15 @@ async function runDoctorCommand({ runtime, cwd, asJson = false }) {
3878
3882
  gateway,
3879
3883
  channels: gateway.channels
3880
3884
  },
3885
+ discord: {
3886
+ configured: discord.configured,
3887
+ enabled: discord.enabled,
3888
+ tokenConfigured: discord.tokenConfigured,
3889
+ applicationId: discord.applicationId,
3890
+ intents: discord.intents,
3891
+ state: discord.lastStatus?.state || null,
3892
+ botUser: discord.lastStatus?.botUser || null
3893
+ },
3881
3894
  binaries: {
3882
3895
  rg,
3883
3896
  git
@@ -3902,10 +3915,50 @@ async function runDoctorCommand({ runtime, cwd, asJson = false }) {
3902
3915
  for (const channel of gateway.channels) {
3903
3916
  console.log(` - ${formatChannelStatusLine(channel)}`);
3904
3917
  }
3918
+ console.log(`Discord runtime: ${report.discord.configured ? `configured (${report.discord.state || "idle"})` : "not configured"}`);
3905
3919
  console.log(`rg: ${rg.ok ? `ok (${rg.path})` : "missing"}`);
3906
3920
  console.log(`git: ${git.ok ? `ok (${git.path})` : "missing"}`);
3907
3921
  }
3908
3922
 
3923
+ async function runDiscordCommand(positional, runtime, { asJson = false } = {}) {
3924
+ const sub = String(positional[1] || "status").trim().toLowerCase();
3925
+ if (!["status", "run"].includes(sub)) {
3926
+ throw new Error("Usage: waterbrother discord <status|run>");
3927
+ }
3928
+
3929
+ if (sub === "status") {
3930
+ const status = await getDiscordStatus(runtime);
3931
+ if (asJson) {
3932
+ printData(status, true);
3933
+ return;
3934
+ }
3935
+ console.log("Discord");
3936
+ console.log("");
3937
+ console.log(`configured: ${status.configured ? "yes" : "no"}`);
3938
+ console.log(`enabled: ${status.enabled ? "yes" : "no"}`);
3939
+ console.log(`token: ${status.tokenConfigured ? "configured" : "missing"}`);
3940
+ console.log(`application id: ${status.applicationId || "missing"}`);
3941
+ console.log(`intents: ${status.intents.join(", ") || "none"}`);
3942
+ if (status.lastStatus) {
3943
+ console.log(`last state: ${status.lastStatus.state || "unknown"}`);
3944
+ console.log(`updated: ${status.lastStatus.updatedAt || "unknown"}`);
3945
+ if (status.lastStatus.botUser?.username) {
3946
+ console.log(`bot user: ${status.lastStatus.botUser.username}`);
3947
+ }
3948
+ }
3949
+ return;
3950
+ }
3951
+
3952
+ if (asJson) {
3953
+ throw new Error("waterbrother discord run does not support --json");
3954
+ }
3955
+
3956
+ console.log("Discord gateway");
3957
+ console.log("");
3958
+ console.log("Connecting to Discord Gateway. Press Ctrl+C to stop.");
3959
+ await runDiscordGateway(runtime, { log: (line) => console.log(line) });
3960
+ }
3961
+
3909
3962
  async function runChannelsCommand(positional, runtime, { asJson = false } = {}) {
3910
3963
  const sub = positional[1] || "status";
3911
3964
  const statuses = getChannelStatuses(runtime);
@@ -11228,6 +11281,11 @@ export async function runCli(argv) {
11228
11281
  return;
11229
11282
  }
11230
11283
 
11284
+ if (command === "discord") {
11285
+ await runDiscordCommand(positional, runtime, { asJson });
11286
+ return;
11287
+ }
11288
+
11231
11289
  if (command === "update") {
11232
11290
  await runUpdateCommand({ cwd: startupCwd, asJson });
11233
11291
  return;
package/src/config.js CHANGED
@@ -91,7 +91,7 @@ function readFirstEnv(names = []) {
91
91
  async function readJsonFile(filePath) {
92
92
  try {
93
93
  const raw = await fs.readFile(filePath, 'utf8');
94
- return JSON.parse(raw);
94
+ return substituteEnvPlaceholders(JSON.parse(raw));
95
95
  } catch (error) {
96
96
  if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
97
97
  return {};
@@ -100,6 +100,19 @@ async function readJsonFile(filePath) {
100
100
  }
101
101
  }
102
102
 
103
+ function substituteEnvPlaceholders(value) {
104
+ if (Array.isArray(value)) {
105
+ return value.map((item) => substituteEnvPlaceholders(item));
106
+ }
107
+ if (value && typeof value === 'object') {
108
+ return Object.fromEntries(
109
+ Object.entries(value).map(([key, entry]) => [key, substituteEnvPlaceholders(entry)])
110
+ );
111
+ }
112
+ if (typeof value !== 'string') return value;
113
+ return value.replace(/\{env:([^}]+)\}/g, (_, key) => process.env[String(key).trim()] || '');
114
+ }
115
+
103
116
  function mergeConfigLayers(userConfig = {}, projectConfig = {}) {
104
117
  const mergedMcpServers = {
105
118
  ...(userConfig.mcpServers && typeof userConfig.mcpServers === 'object' ? userConfig.mcpServers : {}),
package/src/discord.js ADDED
@@ -0,0 +1,290 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ const DISCORD_API_BASE = "https://discord.com/api/v10";
6
+ const STATUS_PATH = path.join(os.homedir(), ".waterbrother", "discord-status.json");
7
+
8
+ const INTENT_BITS = {
9
+ GUILDS: 1 << 0,
10
+ GUILD_MEMBERS: 1 << 1,
11
+ GUILD_MESSAGES: 1 << 9,
12
+ DIRECT_MESSAGES: 1 << 12,
13
+ MESSAGE_CONTENT: 1 << 15
14
+ };
15
+
16
+ function maskToken(token) {
17
+ const value = String(token || "").trim();
18
+ if (!value) return "";
19
+ if (value.length <= 10) return `${value.slice(0, 2)}...${value.slice(-2)}`;
20
+ return `${value.slice(0, 6)}...${value.slice(-4)}`;
21
+ }
22
+
23
+ function resolveIntentMask(intents = []) {
24
+ const names = Array.isArray(intents) ? intents : [];
25
+ return names.reduce((mask, name) => mask | (INTENT_BITS[String(name || "").trim()] || 0), 0);
26
+ }
27
+
28
+ async function ensureStatusDir() {
29
+ await fs.mkdir(path.dirname(STATUS_PATH), { recursive: true });
30
+ }
31
+
32
+ async function saveDiscordStatus(status) {
33
+ await ensureStatusDir();
34
+ await fs.writeFile(STATUS_PATH, `${JSON.stringify(status, null, 2)}\n`, "utf8");
35
+ }
36
+
37
+ async function loadDiscordStatus() {
38
+ try {
39
+ const raw = await fs.readFile(STATUS_PATH, "utf8");
40
+ return JSON.parse(raw);
41
+ } catch (error) {
42
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
43
+ return null;
44
+ }
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ function normalizeDiscordRuntime(runtime = {}) {
50
+ const discord = runtime?.channels?.discord || {};
51
+ const intents = Array.isArray(discord.intents) && discord.intents.length > 0
52
+ ? discord.intents
53
+ : ["GUILDS", "GUILD_MESSAGES", "DIRECT_MESSAGES", "MESSAGE_CONTENT"];
54
+ return {
55
+ enabled: discord.enabled === true,
56
+ token: String(discord.botToken || discord.token || "").trim(),
57
+ applicationId: String(discord.applicationId || "").trim(),
58
+ publicKey: String(discord.publicKey || "").trim(),
59
+ guildId: String(discord.guildId || "").trim(),
60
+ intents,
61
+ intentMask: resolveIntentMask(intents)
62
+ };
63
+ }
64
+
65
+ async function discordFetch(runtime, route, init = {}) {
66
+ const token = String(runtime?.token || "").trim();
67
+ if (!token) {
68
+ throw new Error("Discord bot token is missing");
69
+ }
70
+ const headers = {
71
+ "Content-Type": "application/json",
72
+ Authorization: `Bot ${token}`,
73
+ ...(init.headers || {})
74
+ };
75
+ const response = await fetch(`${DISCORD_API_BASE}${route}`, { ...init, headers });
76
+ if (!response.ok) {
77
+ const text = await response.text().catch(() => "");
78
+ throw new Error(`Discord API ${response.status}: ${text || response.statusText}`);
79
+ }
80
+ if (response.status === 204) return null;
81
+ return response.json();
82
+ }
83
+
84
+ async function fetchGatewayInfo(runtime) {
85
+ return discordFetch(runtime, "/gateway/bot", { method: "GET" });
86
+ }
87
+
88
+ async function sendChannelMessage(runtime, channelId, content) {
89
+ const text = String(content || "").trim();
90
+ if (!channelId || !text) return null;
91
+ return discordFetch(runtime, `/channels/${channelId}/messages`, {
92
+ method: "POST",
93
+ body: JSON.stringify({ content: text })
94
+ });
95
+ }
96
+
97
+ function extractMentionContent(content, botUserId) {
98
+ const raw = String(content || "");
99
+ if (!botUserId) return raw.trim();
100
+ return raw
101
+ .replace(new RegExp(`<@!?${botUserId}>`, "g"), "")
102
+ .trim();
103
+ }
104
+
105
+ function shouldReplyToMessage(message, botUserId) {
106
+ if (!message || message.author?.bot) return false;
107
+ const content = String(message.content || "").trim();
108
+ if (!content) return false;
109
+ const dm = !message.guild_id;
110
+ const mentioned = botUserId ? content.includes(`<@${botUserId}>`) || content.includes(`<@!${botUserId}>`) : false;
111
+ return dm || mentioned;
112
+ }
113
+
114
+ function buildReply(message, botUserId) {
115
+ const content = extractMentionContent(message.content, botUserId).toLowerCase();
116
+ if (!content || content === "ping") {
117
+ return "pong";
118
+ }
119
+ if (content.includes("status") || content.includes("online")) {
120
+ return "Discord gateway is online. I can see messages and basic mentions. Full conversational Discord runtime is the next slice.";
121
+ }
122
+ if (content.includes("hello") || content.includes("hi") || content.includes("hey")) {
123
+ return "Waterbrother is here. Discord setup is live at the gateway level; deeper room workflows come next.";
124
+ }
125
+ return null;
126
+ }
127
+
128
+ export async function getDiscordStatus(runtime = {}) {
129
+ const discord = normalizeDiscordRuntime(runtime);
130
+ const saved = await loadDiscordStatus();
131
+ return {
132
+ configured: Boolean(discord.enabled && discord.token && discord.applicationId),
133
+ enabled: discord.enabled,
134
+ tokenConfigured: Boolean(discord.token),
135
+ tokenHint: discord.token ? maskToken(discord.token) : "",
136
+ applicationId: discord.applicationId || null,
137
+ publicKeyConfigured: Boolean(discord.publicKey),
138
+ guildId: discord.guildId || null,
139
+ intents: discord.intents,
140
+ intentMask: discord.intentMask,
141
+ lastStatus: saved
142
+ };
143
+ }
144
+
145
+ export async function runDiscordGateway(runtime = {}, { log = console.log, signal } = {}) {
146
+ const discord = normalizeDiscordRuntime(runtime);
147
+ if (!discord.enabled) {
148
+ throw new Error("Discord is not enabled in channels.discord.enabled");
149
+ }
150
+ if (!discord.token) {
151
+ throw new Error("Discord bot token is missing");
152
+ }
153
+ if (!discord.applicationId) {
154
+ throw new Error("Discord application id is missing");
155
+ }
156
+
157
+ const gatewayInfo = await fetchGatewayInfo(discord);
158
+ const gatewayUrl = `${gatewayInfo.url}?v=10&encoding=json`;
159
+ const ws = new WebSocket(gatewayUrl);
160
+
161
+ let heartbeatTimer = null;
162
+ let heartbeatAck = true;
163
+ let seq = null;
164
+ let botUser = null;
165
+ let closed = false;
166
+
167
+ const stopHeartbeat = () => {
168
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
169
+ heartbeatTimer = null;
170
+ };
171
+
172
+ const close = async (reason = "shutdown") => {
173
+ if (closed) return;
174
+ closed = true;
175
+ stopHeartbeat();
176
+ try { ws.close(1000, reason); } catch {}
177
+ await saveDiscordStatus({
178
+ state: reason,
179
+ updatedAt: new Date().toISOString(),
180
+ applicationId: discord.applicationId,
181
+ tokenHint: maskToken(discord.token),
182
+ intents: discord.intents,
183
+ botUser: botUser ? { id: botUser.id, username: botUser.username } : null
184
+ });
185
+ };
186
+
187
+ if (signal) {
188
+ if (signal.aborted) {
189
+ await close("aborted");
190
+ return;
191
+ }
192
+ signal.addEventListener("abort", () => {
193
+ void close("aborted");
194
+ }, { once: true });
195
+ }
196
+
197
+ return new Promise((resolve, reject) => {
198
+ ws.addEventListener("message", async (event) => {
199
+ let packet;
200
+ try {
201
+ packet = JSON.parse(String(event.data || ""));
202
+ } catch {
203
+ return;
204
+ }
205
+ if (packet.s !== undefined && packet.s !== null) {
206
+ seq = packet.s;
207
+ }
208
+
209
+ if (packet.op === 10) {
210
+ const intervalMs = Number(packet.d?.heartbeat_interval || 45000);
211
+ heartbeatAck = true;
212
+ stopHeartbeat();
213
+ heartbeatTimer = setInterval(() => {
214
+ if (!heartbeatAck) {
215
+ log("discord: missed heartbeat ack, closing");
216
+ void close("heartbeat-timeout").then(resolve);
217
+ return;
218
+ }
219
+ heartbeatAck = false;
220
+ ws.send(JSON.stringify({ op: 1, d: seq }));
221
+ }, intervalMs);
222
+ ws.send(JSON.stringify({
223
+ op: 2,
224
+ d: {
225
+ token: discord.token,
226
+ intents: discord.intentMask,
227
+ properties: {
228
+ os: process.platform,
229
+ browser: "waterbrother",
230
+ device: "waterbrother"
231
+ }
232
+ }
233
+ }));
234
+ return;
235
+ }
236
+
237
+ if (packet.op === 11) {
238
+ heartbeatAck = true;
239
+ return;
240
+ }
241
+
242
+ if (packet.t === "READY") {
243
+ botUser = packet.d?.user || null;
244
+ log(`discord: ready as ${botUser?.username || "unknown"} (${botUser?.id || "n/a"})`);
245
+ void saveDiscordStatus({
246
+ state: "ready",
247
+ updatedAt: new Date().toISOString(),
248
+ applicationId: discord.applicationId,
249
+ tokenHint: maskToken(discord.token),
250
+ intents: discord.intents,
251
+ botUser: botUser ? { id: botUser.id, username: botUser.username } : null,
252
+ sessionId: packet.d?.session_id || null
253
+ });
254
+ return;
255
+ }
256
+
257
+ if (packet.t === "MESSAGE_CREATE") {
258
+ const msg = packet.d;
259
+ if (!msg || msg.author?.bot) return;
260
+ const scope = msg.guild_id ? `guild:${msg.guild_id}` : "dm";
261
+ log(`discord: ${scope} #${msg.channel_id} ${msg.author?.username || "unknown"} -> ${String(msg.content || "").trim()}`);
262
+ if (shouldReplyToMessage(msg, botUser?.id)) {
263
+ const reply = buildReply(msg, botUser?.id);
264
+ if (reply) {
265
+ try {
266
+ await sendChannelMessage(discord, msg.channel_id, reply);
267
+ log(`discord: replied in ${msg.channel_id}`);
268
+ } catch (error) {
269
+ log(`discord: reply failed: ${error instanceof Error ? error.message : String(error)}`);
270
+ }
271
+ }
272
+ }
273
+ }
274
+ });
275
+
276
+ ws.addEventListener("open", () => {
277
+ log("discord: gateway connected");
278
+ });
279
+
280
+ ws.addEventListener("close", async (event) => {
281
+ await close(`closed:${event.code}`);
282
+ resolve();
283
+ });
284
+
285
+ ws.addEventListener("error", async (event) => {
286
+ await close("error");
287
+ reject(new Error(`Discord gateway error: ${event?.message || "connection failed"}`));
288
+ });
289
+ });
290
+ }