@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 +1 -1
- package/src/channels.js +26 -7
- package/src/cli.js +58 -0
- package/src/config.js +14 -1
- package/src/discord.js +290 -0
package/package.json
CHANGED
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(
|
|
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(
|
|
69
|
-
|
|
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: "
|
|
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
|
|
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\":\"
|
|
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
|
|
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
|
+
}
|