@rackbops/plugin-warbandeer 0.0.0
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/dist/plugin.js +522 -0
- package/package.json +30 -0
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// plugins/warbandeer/src/links.ts
|
|
3
|
+
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
var LINK_CODE_TTL_MS = 10 * 60 * 1000;
|
|
6
|
+
var MAX_LINKED_ACCOUNTS_PER_USER = 20;
|
|
7
|
+
function generateLinkCode() {
|
|
8
|
+
return randomBytes(4).toString("hex").toUpperCase();
|
|
9
|
+
}
|
|
10
|
+
function generateDeviceToken() {
|
|
11
|
+
return randomBytes(32).toString("base64url");
|
|
12
|
+
}
|
|
13
|
+
function hashToken(token) {
|
|
14
|
+
return createHash("sha256").update(token).digest("hex");
|
|
15
|
+
}
|
|
16
|
+
function hashesMatch(a, b) {
|
|
17
|
+
const bufA = Buffer.from(a);
|
|
18
|
+
const bufB = Buffer.from(b);
|
|
19
|
+
return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
|
|
20
|
+
}
|
|
21
|
+
function mintLinkCode(state, discordUserId, now, generateCode = generateLinkCode) {
|
|
22
|
+
const others = state.pending.filter((p) => p.discordUserId !== discordUserId);
|
|
23
|
+
let code = generateCode();
|
|
24
|
+
while (others.some((p) => p.code === code)) {
|
|
25
|
+
code = generateCode();
|
|
26
|
+
}
|
|
27
|
+
const pending = others.concat({ code, discordUserId, expiresAt: now + LINK_CODE_TTL_MS });
|
|
28
|
+
return { code, state: { ...state, pending } };
|
|
29
|
+
}
|
|
30
|
+
function redeemLinkCode(state, code, now) {
|
|
31
|
+
const match = state.pending.find((p) => p.code === code);
|
|
32
|
+
const nextState = { ...state, pending: state.pending.filter((p) => p.code !== code) };
|
|
33
|
+
if (!match)
|
|
34
|
+
return { ok: false, reason: "not-found", state: nextState };
|
|
35
|
+
if (match.expiresAt < now)
|
|
36
|
+
return { ok: false, reason: "expired", state: nextState };
|
|
37
|
+
return { ok: true, discordUserId: match.discordUserId, state: nextState };
|
|
38
|
+
}
|
|
39
|
+
function upsertLinkedAccount(state, discordUserId, accountLabel, tokenHash, now) {
|
|
40
|
+
const existing = state.accounts[discordUserId] ?? [];
|
|
41
|
+
const current = existing.find((a) => a.accountLabel === accountLabel);
|
|
42
|
+
const entry = current ? { ...current, tokenHash, updatedAt: now } : { accountLabel, tokenHash, linkedAt: now, updatedAt: now };
|
|
43
|
+
const accounts = current ? existing.map((a) => a.accountLabel === accountLabel ? entry : a) : [...existing, entry];
|
|
44
|
+
return { ...state, accounts: { ...state.accounts, [discordUserId]: accounts } };
|
|
45
|
+
}
|
|
46
|
+
function removeLinkedAccount(state, discordUserId, accountLabel) {
|
|
47
|
+
const existing = state.accounts[discordUserId] ?? [];
|
|
48
|
+
const removed = existing.find((a) => a.accountLabel === accountLabel);
|
|
49
|
+
if (!removed)
|
|
50
|
+
return;
|
|
51
|
+
const remaining = existing.filter((a) => a.accountLabel !== accountLabel);
|
|
52
|
+
const accounts = { ...state.accounts };
|
|
53
|
+
if (remaining.length > 0)
|
|
54
|
+
accounts[discordUserId] = remaining;
|
|
55
|
+
else
|
|
56
|
+
delete accounts[discordUserId];
|
|
57
|
+
return { removed, state: { ...state, accounts } };
|
|
58
|
+
}
|
|
59
|
+
function touchLinkedAccount(state, discordUserId, accountLabel, now) {
|
|
60
|
+
const existing = state.accounts[discordUserId];
|
|
61
|
+
if (!existing)
|
|
62
|
+
return state;
|
|
63
|
+
const accounts = existing.map((a) => a.accountLabel === accountLabel ? { ...a, updatedAt: now } : a);
|
|
64
|
+
return { ...state, accounts: { ...state.accounts, [discordUserId]: accounts } };
|
|
65
|
+
}
|
|
66
|
+
function findAccountByToken(state, token) {
|
|
67
|
+
const hash = hashToken(token);
|
|
68
|
+
for (const [discordUserId, accounts] of Object.entries(state.accounts)) {
|
|
69
|
+
const account = accounts.find((a) => hashesMatch(hash, a.tokenHash));
|
|
70
|
+
if (account)
|
|
71
|
+
return { discordUserId, account };
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
function normalizeLinksState(raw) {
|
|
76
|
+
const pending = Array.isArray(raw?.pending) ? raw.pending : [];
|
|
77
|
+
const accountsRaw = raw?.accounts;
|
|
78
|
+
const accounts = accountsRaw && typeof accountsRaw === "object" && !Array.isArray(accountsRaw) ? Object.fromEntries(Object.entries(accountsRaw).filter(([, v]) => Array.isArray(v))) : {};
|
|
79
|
+
return { pending, accounts };
|
|
80
|
+
}
|
|
81
|
+
async function loadLinksFrom(path, storage) {
|
|
82
|
+
const raw = await storage.readJsonOrFresh(path, () => ({ pending: [], accounts: {} }), "links");
|
|
83
|
+
return normalizeLinksState(raw);
|
|
84
|
+
}
|
|
85
|
+
var links = { pending: [], accounts: {} };
|
|
86
|
+
var linksWriter;
|
|
87
|
+
async function loadLinks(dataDir, storage) {
|
|
88
|
+
const path = join(dataDir, "links.json");
|
|
89
|
+
linksWriter = storage.createJsonWriter(path);
|
|
90
|
+
const loaded = await loadLinksFrom(path, storage);
|
|
91
|
+
links.pending = loaded.pending;
|
|
92
|
+
links.accounts = loaded.accounts;
|
|
93
|
+
}
|
|
94
|
+
function saveLinks() {
|
|
95
|
+
if (!linksWriter)
|
|
96
|
+
throw new Error("warbandeer: saveLinks() called before loadLinks()");
|
|
97
|
+
return linksWriter.save(links);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// plugins/warbandeer/src/characters.ts
|
|
101
|
+
import { join as join2 } from "path";
|
|
102
|
+
var MAX_ACCOUNT_LABEL_LENGTH = 64;
|
|
103
|
+
var MAX_CHARACTERS_PER_SNAPSHOT = 60;
|
|
104
|
+
var MAX_STRING_FIELD_LENGTH = 256;
|
|
105
|
+
function validateAccountLabel(accountLabel) {
|
|
106
|
+
const trimmed = accountLabel.trim();
|
|
107
|
+
if (trimmed.length === 0)
|
|
108
|
+
return { ok: false, error: "accountLabel must not be empty" };
|
|
109
|
+
if (trimmed.length > MAX_ACCOUNT_LABEL_LENGTH) {
|
|
110
|
+
return { ok: false, error: `accountLabel exceeds ${MAX_ACCOUNT_LABEL_LENGTH} characters` };
|
|
111
|
+
}
|
|
112
|
+
if (/[\x00-\x1f\x7f]/.test(trimmed)) {
|
|
113
|
+
return { ok: false, error: "accountLabel must not contain control characters" };
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, accountLabel: trimmed };
|
|
116
|
+
}
|
|
117
|
+
var MAX_SHAPE_DEPTH = 6;
|
|
118
|
+
var MAX_ARRAY_LENGTH = 200;
|
|
119
|
+
var MAX_OBJECT_KEYS = 100;
|
|
120
|
+
function checkBoundedShape(value, path, depth) {
|
|
121
|
+
if (depth > MAX_SHAPE_DEPTH)
|
|
122
|
+
return `${path}: nested too deeply`;
|
|
123
|
+
if (typeof value === "string") {
|
|
124
|
+
return value.length > MAX_STRING_FIELD_LENGTH ? `${path}: exceeds ${MAX_STRING_FIELD_LENGTH} characters` : undefined;
|
|
125
|
+
}
|
|
126
|
+
if (typeof value === "number" || typeof value === "boolean" || value === null)
|
|
127
|
+
return;
|
|
128
|
+
if (Array.isArray(value)) {
|
|
129
|
+
if (value.length > MAX_ARRAY_LENGTH)
|
|
130
|
+
return `${path}: array exceeds ${MAX_ARRAY_LENGTH} entries`;
|
|
131
|
+
for (const [i, item] of value.entries()) {
|
|
132
|
+
const err = checkBoundedShape(item, `${path}[${i}]`, depth + 1);
|
|
133
|
+
if (err)
|
|
134
|
+
return err;
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (typeof value === "object") {
|
|
139
|
+
const obj = value;
|
|
140
|
+
const keys = Object.keys(obj);
|
|
141
|
+
if (keys.length > MAX_OBJECT_KEYS)
|
|
142
|
+
return `${path}: object exceeds ${MAX_OBJECT_KEYS} keys`;
|
|
143
|
+
for (const key of keys) {
|
|
144
|
+
if (key.length > MAX_STRING_FIELD_LENGTH)
|
|
145
|
+
return `${path}: an object key exceeds ${MAX_STRING_FIELD_LENGTH} characters`;
|
|
146
|
+
const err = checkBoundedShape(obj[key], `${path}.${key}`, depth + 1);
|
|
147
|
+
if (err)
|
|
148
|
+
return err;
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
return `${path}: unsupported value type`;
|
|
153
|
+
}
|
|
154
|
+
function validateCharacterPayload(raw, accountLabel) {
|
|
155
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
156
|
+
return { ok: false, error: "payload must be a JSON object" };
|
|
157
|
+
}
|
|
158
|
+
const obj = raw;
|
|
159
|
+
const characters = obj.characters;
|
|
160
|
+
if (!Array.isArray(characters)) {
|
|
161
|
+
return { ok: false, error: "characters must be an array" };
|
|
162
|
+
}
|
|
163
|
+
if (characters.length > MAX_CHARACTERS_PER_SNAPSHOT) {
|
|
164
|
+
return { ok: false, error: `characters exceeds ${MAX_CHARACTERS_PER_SNAPSHOT} entries` };
|
|
165
|
+
}
|
|
166
|
+
for (const [i, c] of characters.entries()) {
|
|
167
|
+
if (typeof c !== "object" || c === null || Array.isArray(c)) {
|
|
168
|
+
return { ok: false, error: `characters[${i}] must be an object` };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const shapeError = checkBoundedShape(obj, "payload", 0);
|
|
172
|
+
if (shapeError)
|
|
173
|
+
return { ok: false, error: shapeError };
|
|
174
|
+
const warband = obj.warband;
|
|
175
|
+
const bankGold = warband && typeof warband === "object" && typeof warband.bankGold === "number" ? warband.bankGold : 0;
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
snapshot: { accountLabel, warband: { bankGold }, characters }
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
var charactersDirValue = "";
|
|
182
|
+
var storageRef;
|
|
183
|
+
var charactersMutator;
|
|
184
|
+
function initCharacters(dataDir, storage) {
|
|
185
|
+
charactersDirValue = join2(dataDir, "characters");
|
|
186
|
+
storageRef = storage;
|
|
187
|
+
charactersMutator = storage.createKeyedJsonMutator();
|
|
188
|
+
}
|
|
189
|
+
function charactersDir() {
|
|
190
|
+
return charactersDirValue;
|
|
191
|
+
}
|
|
192
|
+
function requireMutator() {
|
|
193
|
+
if (!charactersMutator)
|
|
194
|
+
throw new Error("warbandeer: characters storage used before initCharacters()");
|
|
195
|
+
return charactersMutator;
|
|
196
|
+
}
|
|
197
|
+
function charactersFilePathIn(baseDir, discordUserId) {
|
|
198
|
+
if (!/^\d{1,25}$/.test(discordUserId)) {
|
|
199
|
+
throw new Error(`refusing to build a characters path from a non-snowflake id: "${discordUserId}"`);
|
|
200
|
+
}
|
|
201
|
+
return join2(baseDir, `${discordUserId}.json`);
|
|
202
|
+
}
|
|
203
|
+
async function saveCharacterSnapshotTo(baseDir, discordUserId, snapshot, now = Date.now()) {
|
|
204
|
+
const path = charactersFilePathIn(baseDir, discordUserId);
|
|
205
|
+
await requireMutator().update(path, () => ({ snapshots: [] }), (current) => {
|
|
206
|
+
const withoutLabel = current.snapshots.filter((s) => s.accountLabel !== snapshot.accountLabel);
|
|
207
|
+
return { snapshots: [...withoutLabel, { ...snapshot, receivedAt: now }] };
|
|
208
|
+
}, "characters");
|
|
209
|
+
}
|
|
210
|
+
async function deleteCharacterSnapshotFrom(baseDir, discordUserId, accountLabel) {
|
|
211
|
+
const path = charactersFilePathIn(baseDir, discordUserId);
|
|
212
|
+
await requireMutator().update(path, () => ({ snapshots: [] }), (current) => ({ snapshots: current.snapshots.filter((s) => s.accountLabel !== accountLabel) }), "characters");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// plugins/warbandeer/src/link-command.ts
|
|
216
|
+
import { MessageFlags } from "discord.js";
|
|
217
|
+
|
|
218
|
+
// plugins/warbandeer/src/server.ts
|
|
219
|
+
var configured = false;
|
|
220
|
+
function setConnectorConfigured(value) {
|
|
221
|
+
configured = value;
|
|
222
|
+
}
|
|
223
|
+
function warbandeerConnectorConfigured() {
|
|
224
|
+
return configured;
|
|
225
|
+
}
|
|
226
|
+
var serverRunning = false;
|
|
227
|
+
function warbandeerServerRunning() {
|
|
228
|
+
return serverRunning;
|
|
229
|
+
}
|
|
230
|
+
var PRUNE_THRESHOLD = 1e4;
|
|
231
|
+
function createRateLimiter(opts) {
|
|
232
|
+
const now = opts.now ?? Date.now;
|
|
233
|
+
const windows = new Map;
|
|
234
|
+
return {
|
|
235
|
+
allow(key) {
|
|
236
|
+
const t = now();
|
|
237
|
+
if (windows.size > PRUNE_THRESHOLD) {
|
|
238
|
+
for (const [k, w2] of windows) {
|
|
239
|
+
if (t >= w2.resetAt)
|
|
240
|
+
windows.delete(k);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const w = windows.get(key);
|
|
244
|
+
if (!w || t >= w.resetAt) {
|
|
245
|
+
windows.set(key, { count: 1, resetAt: t + opts.windowMs });
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
if (w.count >= opts.max)
|
|
249
|
+
return false;
|
|
250
|
+
w.count += 1;
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
var DEFAULT_MAX_BODY_BYTES = 512 * 1024;
|
|
256
|
+
function extractBearerToken(authHeader) {
|
|
257
|
+
const m = authHeader?.match(/^Bearer (.+)$/);
|
|
258
|
+
return m?.[1];
|
|
259
|
+
}
|
|
260
|
+
function jsonResponse(body, status = 200) {
|
|
261
|
+
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
|
262
|
+
}
|
|
263
|
+
async function readBodyWithCap(req, maxBytes) {
|
|
264
|
+
const declared = req.headers.get("Content-Length");
|
|
265
|
+
if (declared && Number(declared) > maxBytes)
|
|
266
|
+
return;
|
|
267
|
+
if (!req.body)
|
|
268
|
+
return "";
|
|
269
|
+
const reader = req.body.getReader();
|
|
270
|
+
const chunks = [];
|
|
271
|
+
let total = 0;
|
|
272
|
+
for (;; ) {
|
|
273
|
+
const { done, value } = await reader.read();
|
|
274
|
+
if (done)
|
|
275
|
+
break;
|
|
276
|
+
total += value.byteLength;
|
|
277
|
+
if (total > maxBytes) {
|
|
278
|
+
await reader.cancel().catch(() => {});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
chunks.push(value);
|
|
282
|
+
}
|
|
283
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
284
|
+
}
|
|
285
|
+
function parseJsonObjectBody(text) {
|
|
286
|
+
try {
|
|
287
|
+
const parsed = JSON.parse(text);
|
|
288
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
|
|
289
|
+
} catch {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
async function handleRequest(req, clientIp, deps) {
|
|
294
|
+
const url = new URL(req.url);
|
|
295
|
+
if (req.method === "POST" && url.pathname === "/link") {
|
|
296
|
+
if (!deps.rateLimiter.allow(`link:${clientIp}`)) {
|
|
297
|
+
return new Response("too many requests", { status: 429 });
|
|
298
|
+
}
|
|
299
|
+
const body = await readBodyWithCap(req, deps.maxBodyBytes);
|
|
300
|
+
if (body === undefined)
|
|
301
|
+
return new Response("payload too large", { status: 413 });
|
|
302
|
+
const parsed = parseJsonObjectBody(body);
|
|
303
|
+
const code = typeof parsed?.code === "string" ? parsed.code.trim().toUpperCase() : undefined;
|
|
304
|
+
const rawAccountLabel = typeof parsed?.accountLabel === "string" ? parsed.accountLabel : undefined;
|
|
305
|
+
if (!code || !rawAccountLabel) {
|
|
306
|
+
return new Response("code and accountLabel are required", { status: 400 });
|
|
307
|
+
}
|
|
308
|
+
const labelResult = validateAccountLabel(rawAccountLabel);
|
|
309
|
+
if (!labelResult.ok)
|
|
310
|
+
return new Response(labelResult.error, { status: 400 });
|
|
311
|
+
const result = await deps.redeemCode(code, labelResult.accountLabel);
|
|
312
|
+
if (!result.ok)
|
|
313
|
+
return new Response(result.error, { status: 400 });
|
|
314
|
+
return jsonResponse({ token: result.token });
|
|
315
|
+
}
|
|
316
|
+
if (req.method === "POST" && url.pathname === "/characters") {
|
|
317
|
+
if (!deps.authFailureLimiter.allow(`characters-ip:${clientIp}`)) {
|
|
318
|
+
return new Response("too many requests", { status: 429 });
|
|
319
|
+
}
|
|
320
|
+
const token = extractBearerToken(req.headers.get("Authorization"));
|
|
321
|
+
if (!token)
|
|
322
|
+
return new Response("unauthorized", { status: 401 });
|
|
323
|
+
const owner = deps.authenticate(token);
|
|
324
|
+
if (!owner)
|
|
325
|
+
return new Response("unauthorized", { status: 401 });
|
|
326
|
+
if (!deps.rateLimiter.allow(`push:${token}`)) {
|
|
327
|
+
return new Response("too many requests", { status: 429 });
|
|
328
|
+
}
|
|
329
|
+
const body = await readBodyWithCap(req, deps.maxBodyBytes);
|
|
330
|
+
if (body === undefined)
|
|
331
|
+
return new Response("payload too large", { status: 413 });
|
|
332
|
+
const parsed = parseJsonObjectBody(body);
|
|
333
|
+
if (parsed === undefined)
|
|
334
|
+
return new Response("invalid JSON", { status: 400 });
|
|
335
|
+
const result = await deps.storeCharacters(owner.discordUserId, owner.accountLabel, parsed);
|
|
336
|
+
if (!result.ok)
|
|
337
|
+
return new Response(result.error, { status: 400 });
|
|
338
|
+
return new Response(null, { status: 204 });
|
|
339
|
+
}
|
|
340
|
+
return new Response("not found", { status: 404 });
|
|
341
|
+
}
|
|
342
|
+
function createProductionDeps(overrides) {
|
|
343
|
+
const state = overrides?.linksState ?? links;
|
|
344
|
+
const persistLinks = overrides?.persistLinks ?? saveLinks;
|
|
345
|
+
const charactersBaseDir = overrides?.charactersBaseDir ?? charactersDir();
|
|
346
|
+
return {
|
|
347
|
+
maxBodyBytes: DEFAULT_MAX_BODY_BYTES,
|
|
348
|
+
rateLimiter: createRateLimiter({ windowMs: 60000, max: 30 }),
|
|
349
|
+
authFailureLimiter: createRateLimiter({ windowMs: 60000, max: 300 }),
|
|
350
|
+
redeemCode: async (code, accountLabel) => {
|
|
351
|
+
const now = Date.now();
|
|
352
|
+
const redeemed = redeemLinkCode(state, code, now);
|
|
353
|
+
state.pending = redeemed.state.pending;
|
|
354
|
+
state.accounts = redeemed.state.accounts;
|
|
355
|
+
if (!redeemed.ok) {
|
|
356
|
+
await persistLinks();
|
|
357
|
+
console.error(`[warbandeer] /link redeem failed (${redeemed.reason})`);
|
|
358
|
+
return { ok: false, error: redeemed.reason === "expired" ? "code expired" : "unknown code" };
|
|
359
|
+
}
|
|
360
|
+
const existingCount = state.accounts[redeemed.discordUserId]?.length ?? 0;
|
|
361
|
+
const alreadyLinked = state.accounts[redeemed.discordUserId]?.some((a) => a.accountLabel === accountLabel) ?? false;
|
|
362
|
+
if (!alreadyLinked && existingCount >= MAX_LINKED_ACCOUNTS_PER_USER) {
|
|
363
|
+
await persistLinks();
|
|
364
|
+
return {
|
|
365
|
+
ok: false,
|
|
366
|
+
error: `you already have ${MAX_LINKED_ACCOUNTS_PER_USER} linked accounts \u2014 unlink one before adding another`
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const token = generateDeviceToken();
|
|
370
|
+
const next = upsertLinkedAccount(state, redeemed.discordUserId, accountLabel, hashToken(token), now);
|
|
371
|
+
state.accounts = next.accounts;
|
|
372
|
+
await persistLinks();
|
|
373
|
+
console.log(`[warbandeer] linked accountLabel="${accountLabel}" for discord user ${redeemed.discordUserId}`);
|
|
374
|
+
return { ok: true, token };
|
|
375
|
+
},
|
|
376
|
+
authenticate: (token) => {
|
|
377
|
+
const match = findAccountByToken(state, token);
|
|
378
|
+
return match ? { discordUserId: match.discordUserId, accountLabel: match.account.accountLabel } : undefined;
|
|
379
|
+
},
|
|
380
|
+
storeCharacters: async (discordUserId, accountLabel, raw) => {
|
|
381
|
+
const validated = validateCharacterPayload(raw, accountLabel);
|
|
382
|
+
if (!validated.ok) {
|
|
383
|
+
console.error(`[warbandeer] rejected a push for discord user ${discordUserId}: ${validated.error}`);
|
|
384
|
+
return validated;
|
|
385
|
+
}
|
|
386
|
+
await saveCharacterSnapshotTo(charactersBaseDir, discordUserId, validated.snapshot);
|
|
387
|
+
const next = touchLinkedAccount(state, discordUserId, accountLabel, Date.now());
|
|
388
|
+
state.accounts = next.accounts;
|
|
389
|
+
await persistLinks();
|
|
390
|
+
return { ok: true };
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
function startWarbandeerServer(port, deps = createProductionDeps()) {
|
|
395
|
+
const server = Bun.serve({
|
|
396
|
+
port,
|
|
397
|
+
maxRequestBodySize: deps.maxBodyBytes * 4,
|
|
398
|
+
idleTimeout: 30,
|
|
399
|
+
fetch: (req, srv) => {
|
|
400
|
+
const clientIp = req.headers.get("CF-Connecting-IP") ?? srv.requestIP(req)?.address ?? "unknown";
|
|
401
|
+
return handleRequest(req, clientIp, deps);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
serverRunning = true;
|
|
405
|
+
const boundPort = server.port ?? port;
|
|
406
|
+
console.log(`[warbandeer] ingest server listening on :${boundPort}`);
|
|
407
|
+
return {
|
|
408
|
+
port: boundPort,
|
|
409
|
+
stop: () => {
|
|
410
|
+
serverRunning = false;
|
|
411
|
+
server.stop();
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// plugins/warbandeer/src/link-command.ts
|
|
417
|
+
var LINK_CODE_TTL_MINUTES = 10;
|
|
418
|
+
function linkReply(code) {
|
|
419
|
+
return `\uD83D\uDD17 Your link code is **${code}**. Enter it in the Warbandeer desktop app within ` + `${LINK_CODE_TTL_MINUTES} minutes to connect your character data to this Discord account.`;
|
|
420
|
+
}
|
|
421
|
+
function linkAvailability(configured2, running) {
|
|
422
|
+
if (!configured2) {
|
|
423
|
+
return {
|
|
424
|
+
available: false,
|
|
425
|
+
message: "Character linking isn't configured on this bot \u2014 set `WARBANDEER_INGEST_PORT` to enable it."
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
if (!running) {
|
|
429
|
+
return {
|
|
430
|
+
available: false,
|
|
431
|
+
message: "Character linking is configured but the connector failed to start \u2014 check the bot's logs (see `WARBANDEER_INGEST_PORT`)."
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
return { available: true };
|
|
435
|
+
}
|
|
436
|
+
async function handleLinkCommand(interaction) {
|
|
437
|
+
const availability = linkAvailability(warbandeerConnectorConfigured(), warbandeerServerRunning());
|
|
438
|
+
if (!availability.available) {
|
|
439
|
+
await interaction.reply({ content: availability.message, flags: MessageFlags.Ephemeral });
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
|
443
|
+
const { code, state } = mintLinkCode(links, interaction.user.id, Date.now());
|
|
444
|
+
links.pending = state.pending;
|
|
445
|
+
links.accounts = state.accounts;
|
|
446
|
+
await saveLinks();
|
|
447
|
+
await interaction.editReply({ content: linkReply(code) });
|
|
448
|
+
}
|
|
449
|
+
function unlinkReply(accounts, accountLabel) {
|
|
450
|
+
if (accounts.length === 0) {
|
|
451
|
+
return { message: "You don't have any linked accounts." };
|
|
452
|
+
}
|
|
453
|
+
if (!accountLabel) {
|
|
454
|
+
const only = accounts[0];
|
|
455
|
+
if (accounts.length === 1 && only) {
|
|
456
|
+
return { message: `\uD83D\uDD13 Unlinked \`${only.accountLabel}\`.`, remove: only.accountLabel };
|
|
457
|
+
}
|
|
458
|
+
const labels = accounts.map((a) => `\`${a.accountLabel}\``).join(", ");
|
|
459
|
+
return { message: `You have multiple linked accounts (${labels}) \u2014 specify which one with \`account_label\`.` };
|
|
460
|
+
}
|
|
461
|
+
if (!accounts.some((a) => a.accountLabel === accountLabel)) {
|
|
462
|
+
return { message: `You don't have an account named \`${accountLabel}\`.` };
|
|
463
|
+
}
|
|
464
|
+
return { message: `\uD83D\uDD13 Unlinked \`${accountLabel}\`.`, remove: accountLabel };
|
|
465
|
+
}
|
|
466
|
+
async function handleUnlinkCommand(interaction) {
|
|
467
|
+
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
|
468
|
+
const accountLabel = interaction.options.getString("account_label") ?? undefined;
|
|
469
|
+
const accounts = links.accounts[interaction.user.id] ?? [];
|
|
470
|
+
const decision = unlinkReply(accounts, accountLabel);
|
|
471
|
+
if (decision.remove) {
|
|
472
|
+
const result = removeLinkedAccount(links, interaction.user.id, decision.remove);
|
|
473
|
+
if (result) {
|
|
474
|
+
links.accounts = result.state.accounts;
|
|
475
|
+
await saveLinks();
|
|
476
|
+
await deleteCharacterSnapshotFrom(charactersDir(), interaction.user.id, decision.remove);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
await interaction.editReply({ content: decision.message });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// plugins/warbandeer/src/index.ts
|
|
483
|
+
function createPlugin(host) {
|
|
484
|
+
const portRaw = host.env.WARBANDEER_INGEST_PORT;
|
|
485
|
+
let port;
|
|
486
|
+
if (portRaw !== undefined) {
|
|
487
|
+
const n = Number(portRaw);
|
|
488
|
+
if (!Number.isInteger(n) || n <= 0 || n > 65535) {
|
|
489
|
+
throw new Error(`WARBANDEER_INGEST_PORT must be a valid port number, got "${portRaw}"`);
|
|
490
|
+
}
|
|
491
|
+
port = n;
|
|
492
|
+
}
|
|
493
|
+
setConnectorConfigured(port !== undefined);
|
|
494
|
+
return {
|
|
495
|
+
commands: [
|
|
496
|
+
{
|
|
497
|
+
name: "link",
|
|
498
|
+
build: (builder) => builder.setDescription("Link the Warbandeer desktop app to your Discord account"),
|
|
499
|
+
handle: handleLinkCommand
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
name: "unlink",
|
|
503
|
+
build: (builder) => builder.setDescription("Unlink a Warbandeer desktop account from your Discord account").addStringOption((o) => o.setName("account_label").setDescription("Which linked account (only needed if you have more than one)").setRequired(false).setMaxLength(MAX_ACCOUNT_LABEL_LENGTH)),
|
|
504
|
+
handle: handleUnlinkCommand
|
|
505
|
+
}
|
|
506
|
+
],
|
|
507
|
+
async activate() {
|
|
508
|
+
await loadLinks(host.dataDir, host.storage);
|
|
509
|
+
initCharacters(host.dataDir, host.storage);
|
|
510
|
+
if (port !== undefined) {
|
|
511
|
+
try {
|
|
512
|
+
startWarbandeerServer(port);
|
|
513
|
+
} catch (err) {
|
|
514
|
+
host.log.error(`connector failed to start on :${port} \u2014 the bot keeps running without it; /link will report the feature disabled. ` + "Check the port isn't already in use and isn't a privileged one the container's non-root user can't bind.", err);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
export {
|
|
521
|
+
createPlugin
|
|
522
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rackbops/plugin-warbandeer",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Warbandeer desktop-app character linking (/link, /unlink, ingest endpoint)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/plugin.js",
|
|
7
|
+
"files": ["dist"],
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Rackbops/rackbops-bot-plugins.git",
|
|
11
|
+
"directory": "plugins/warbandeer"
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"discord.js": "^14.27.0"
|
|
15
|
+
},
|
|
16
|
+
"botPlugin": {
|
|
17
|
+
"hostApiVersion": 1,
|
|
18
|
+
"intents": [],
|
|
19
|
+
"commands": ["link", "unlink"],
|
|
20
|
+
"env": [
|
|
21
|
+
{
|
|
22
|
+
"key": "WARBANDEER_INGEST_PORT",
|
|
23
|
+
"format": "^([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$",
|
|
24
|
+
"required": false,
|
|
25
|
+
"secret": false,
|
|
26
|
+
"description": "Port the ingest server binds inside the container; unset = connector off (never published to the host; reachable only through the Cloudflare Tunnel)"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
}
|