From 00919ceeb3c2c1aae4231a56ccd816161d56774f Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Wed, 12 Aug 2026 12:39:00 -0400 Subject: [PATCH] feat(bot): add Discord bot scaffold with sign-up, attendance embeds, and notification bridge --- .env.example | 16 ++ AGENTS.md | 4 + bun.lock | 41 +++- docs/bot/context.md | 58 +++++ docs/bot/design.md | 154 +++++++++++++ package.json | 2 + src/bot/commands/announce.ts | 66 ++++++ src/bot/commands/index.ts | 13 ++ src/bot/commands/link.ts | 37 +++ src/bot/commands/ping.ts | 9 + src/bot/commands/signup.ts | 64 +++++ src/bot/config.ts | 49 ++++ src/bot/events/interactionCreate.ts | 94 ++++++++ src/bot/index.ts | 67 ++++++ src/bot/lib/resolve.ts | 50 ++++ src/bot/lib/roles.ts | 13 ++ src/bot/services/index.ts | 8 + src/bot/services/missionEmbeds.ts | 308 +++++++++++++++++++++++++ src/bot/services/notificationBridge.ts | 100 ++++++++ src/bot/services/signup.ts | 91 ++++++++ 20 files changed, 1243 insertions(+), 1 deletion(-) create mode 100644 docs/bot/context.md create mode 100644 docs/bot/design.md create mode 100644 src/bot/commands/announce.ts create mode 100644 src/bot/commands/index.ts create mode 100644 src/bot/commands/link.ts create mode 100644 src/bot/commands/ping.ts create mode 100644 src/bot/commands/signup.ts create mode 100644 src/bot/config.ts create mode 100644 src/bot/events/interactionCreate.ts create mode 100644 src/bot/index.ts create mode 100644 src/bot/lib/resolve.ts create mode 100644 src/bot/lib/roles.ts create mode 100644 src/bot/services/index.ts create mode 100644 src/bot/services/missionEmbeds.ts create mode 100644 src/bot/services/notificationBridge.ts create mode 100644 src/bot/services/signup.ts diff --git a/.env.example b/.env.example index 51744c2..8ca50b4 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,19 @@ APP_URL=http://localhost:3000 # Secret that guards the /api/game-tick/notify endpoint. The game tick script # sends this header so connected clients refresh after each tick. GAME_TICK_NOTIFY_SECRET=YOUR_GAME_TICK_NOTIFY_SECRET + +# --- Discord bot --- +# Bot token from the Discord Developer Portal. Required to run the bot. +DISCORD_TOKEN= +# Server (guild) id that slash commands are registered to. Required. +DISCORD_GUILD_ID= +# Channel id where attendance embeds are posted. +DISCORD_OPS_CHANNEL_ID= +# Channel id used as the default /announce target. +DISCORD_ANNOUNCE_CHANNEL_ID= +# Comma-separated Discord role ids that grant staff permissions. +DISCORD_STAFF_ROLE_IDS= +# How often the attendance reconcile loop runs, in ms. Defaults to 60000. +DISCORD_ATTENDANCE_POLL_MS= +# How often the notification bridge polls for new notifications, in ms. Defaults to 20000. +DISCORD_NOTIFICATION_POLL_MS= diff --git a/AGENTS.md b/AGENTS.md index 2eb1404..cf43da3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,6 +212,10 @@ gameTick → POST `/api/game-tick/notify` (guarded by `x-game-tick-secret` heade - **Dependencies**: `@xyflow/react` (v12), `radix-ui` (context-menu primitives), `lucide-react` (icons). - To add a new node type, create the component + register it in the `nodeTypes` object in `NarrativeFlowEditor.tsx`. +## Discord Bot + +A Discord bot (planned, not started) living in `src/bot/`, run as a standalone long-running process that imports `@payload-config` directly (same pattern as `src/scripts/` and `src/tools/seed/`). **Full product requirements live in `docs/bot/context.md`; the approved implementation design is in `docs/bot/design.md` — read both before touching bot code.** In short: (1) Discord sign-up/linking using the Discord username, no OAuth; (2) recurring operations calendar with Yes/No/Tentative attendance synced two-way between Discord embeds and the website; (3) announcements + optional per-user Discord notifications for app events (market offers, shipments, banking). Known gaps: no mission attendance model exists yet (missions do have `startDateTime`/`estimatedDuration`), no Discord snowflake ID on users, no Discord-specific preferences. + ## Auth Username-based login (no email login). Users log in via Payload admin with `username` only. diff --git a/bun.lock b/bun.lock index 377f8d9..a8aae22 100644 --- a/bun.lock +++ b/bun.lock @@ -29,6 +29,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "cross-env": "^10.1.0", + "discord.js": "^14.27.0", "dotenv": "17.2.3", "graphql": "^16.12.0", "lucide-react": "^0.561.0", @@ -221,6 +222,18 @@ "@date-fns/tz": ["@date-fns/tz@1.2.0", "", {}, "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg=="], + "@discordjs/builders": ["@discordjs/builders@1.14.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ=="], + + "@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], + + "@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="], + + "@discordjs/rest": ["@discordjs/rest@2.6.3", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.50", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "^6.27.0" } }, "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg=="], + + "@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="], + + "@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "react": "19.2.3" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], @@ -695,6 +708,12 @@ "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], + "@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="], + + "@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="], + + "@sapphire/snowflake": ["@sapphire/snowflake@3.5.5", "", {}, "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ=="], + "@smithy/abort-controller": ["@smithy/abort-controller@4.2.2", "", { "dependencies": { "@smithy/types": "4.7.1", "tslib": "2.8.1" } }, "sha512-fPbcmEI+A6QiGOuumTpKSo7z+9VYr5DLN8d5/8jDJOwmt4HAKy/UGuRstCMpKbtr+FMaHH4pvFinSAbIAYCHZQ=="], "@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA=="], @@ -983,6 +1002,8 @@ "@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="], + "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="], + "@xyflow/react": ["@xyflow/react@12.11.2", "", { "dependencies": { "@xyflow/system": "0.0.79", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "@types/react": ">=17", "@types/react-dom": ">=17", "react": ">=17", "react-dom": ">=17" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA=="], "@xyflow/system": ["@xyflow/system@0.0.79", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA=="], @@ -1197,6 +1218,10 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "2.0.3" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "discord-api-types": ["discord-api-types@0.38.53", "", {}, "sha512-HL1zz/UuZ+bbJjA/X8Kbxx9gk8v9rJAbTeWRNYKmIdjwJ7EovjlHgoJTxcLpATfNJ+AonOtMdy3Y5MVIJAAd/A=="], + + "discord.js": ["discord.js@14.27.0", "", { "dependencies": { "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.2", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.5", "discord-api-types": "^0.38.49", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "^6.27.0" } }, "sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A=="], + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "2.0.3" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], @@ -1585,6 +1610,8 @@ "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], @@ -1595,6 +1622,8 @@ "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-bytes.js": ["magic-bytes.js@1.13.1", "", {}, "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], @@ -2029,6 +2058,8 @@ "ts-essentials": ["ts-essentials@10.0.3", "", { "optionalDependencies": { "typescript": "5.9.3" } }, "sha512-/FrVAZ76JLTWxJOERk04fm8hYENDo0PWSP3YLQKxevLwWtxemGcl5JJEzN4iqfDlRve0ckyfFaOBu4xbNH/wZw=="], + "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="], + "tsconfck": ["tsconfck@3.1.6", "", { "optionalDependencies": { "typescript": "5.9.3" }, "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "0.0.29", "json5": "1.0.2", "minimist": "1.2.8", "strip-bom": "3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], @@ -2057,7 +2088,7 @@ "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "1.0.4", "has-bigints": "1.1.0", "has-symbols": "1.1.0", "which-boxed-primitive": "1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], - "undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="], + "undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], @@ -2171,6 +2202,12 @@ "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + + "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + + "@discordjs/ws/ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], + "@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], "@emotion/babel-plugin/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], @@ -2693,6 +2730,8 @@ "payload/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-uri": "3.1.0", "json-schema-traverse": "1.0.0", "require-from-string": "2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + "payload/undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="], + "pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "2.0.0", "postgres-bytea": "1.0.0", "postgres-date": "1.0.7", "postgres-interval": "1.2.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], "pino-pretty/strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], diff --git a/docs/bot/context.md b/docs/bot/context.md new file mode 100644 index 0000000..4a4fdae --- /dev/null +++ b/docs/bot/context.md @@ -0,0 +1,58 @@ +# Discord Bot — Product Context + +Status: **design phase, not started**. Requirements captured from Jason's explanation (2026-08-11). Keep this file updated as decisions are made — it is the canonical reference for what the bot must do. + +Planned location: `src/bot/` (same repo as the web app — the bot runs as a standalone long-running process importing `@payload-config`, following the existing `src/scripts/` / `src/tools/seed/` pattern). + +## 1. Discord sign-up and account linking + +Allow people in the Discord server to sign up for the site and link their Discord account — **without OAuth / without logging into Discord**. + +- The bot checks the Discord username of the person trying to sign up. +- If the username is acceptable, the bot uses it when creating the user (i.e., the new Payload `users` doc gets `discordUsername` = their Discord username). +- This is the "linking" step: the Discord account and the site account are tied together by the matching username. + +## 2. Recurring operations calendar + attendance (two-way sync) + +Post recurring calendar events for operations, with a **Yes / No / Tentative** sign-up system for tracking attendance. + +- When players mark their attendance in Discord, the corresponding mission attendance entry on the website is updated to show the same data. +- When a user changes their attendance on the website, the Discord calendar embed updates to reflect the change — names move between / are removed from the Yes / No / Tentative lists. + +In short: Discord embed ↔ website attendance data stay in sync in both directions. + +## 3. Announcements and app-event notifications + +Post announcements and selected app events to Discord. + +- Event examples: someone offers to buy a player's market listing; a shipment completes; a deposit to a bank account; etc. +- Notification should be **optional, per user preference** — "The options for these could live in the user's preferences." + +## Integration points (verified against the current codebase, 2026-08-11) + +- **Users collection** (`src/collections/users/Users.ts`): has `discordUsername` (text, required, unique) + `preferences` group (`notifications.mutedAll` / `mutedTypes`, `display.showCallsign`). No Discord snowflake/ID field yet. +- **Existing standalone-process pattern**: `src/scripts/` (game-tick, market-tick bins) and `src/tools/seed/` (`backfillProfiles.ts`) all do `import config from "@payload-config"` → `getPayload({ config })`. The bot reuses this + the `@/` aliases and `src/payload-types.ts`. +- **Notification types** (`src/lib/notifications/notificationTypes.ts`): `market:offer`, `market:counter`, `market:accept`, `market:reject`, `market:withdrawn`, `market:closed`, `market:sold`, `market:expired`, `account:discord-request`. `notifyUser()` (fire-and-forget in-app notifications) is the existing hook point the bot could bridge into. +- **Missions collection** (`src/collections/intelligence/Missions.ts`): the operations model. Has `classification.startDateTime` (date+time picker, required, defaults to next Saturday 20:00) and `classification.estimatedDuration` (minutes, default 240). Also `operationType` (main/side/external), `ownershipAndStatus.status` (Concept/Planning/Ready/Scheduled/Active/Completed/Cancelled) + `visibility`, `missionRoles`, campaign link, briefing/objectives, server details. **No attendance and no recurrence fields exist.** +- **Event log**: `emitGameEvent()` — the bot can log its own actions. +- **Reusable libs** the bot can call directly: banking (`applyTransaction`, `ensurePersonalAccount`), market (`buyListing`, negotiations), notifications, storage rules. + +## Locked decisions (2026-08-11) + +1. **Sign-up includes `steamId`** — the player provides it as part of `/signup` (it is required on the Users collection). +2. **The operations model is the Missions collection** — no new operations collection. Any scheduling/attendance work builds on `missions`. +3. **Attendance is per-occurrence.** Each mission has one `startDateTime`, so an occurrence == a mission; attendance records are per (mission, user). +4. **App → Discord bridge: the bot polls `user-notifications`** with direct DB access and mirrors new notifications to Discord DMs for users who opted in. No webhook plumbing; existing `notifyUser()` call sites stay untouched. + +## Gaps the bot work will need to fill + +- **No attendance model exists** (verified: zero matches for attendance/occurrence/recurring in `src/`). The website-side attendance model must be built as part of this feature. Lean: a new collection (e.g., `mission-attendances`: `mission` → missions, `user` → users, `response` yes/no/tentative), consistent with the app's LedgerEntries precedent. +- **No Discord snowflake ID on users.** `discordUsername` is a username string ("Z8MB1E"), not a Discord user ID. DMs / role-gating / reliable linking need the snowflake (add a `discordId` field + `/link` command or guild-join mapping). +- **No Discord notification preferences.** Current prefs mute *in-app* notifications only; Discord-notification toggles would extend the `preferences` group. + +## Open design questions + +1. Sign-up flow: what does the player type in Discord (username + password + display name + steam ID?), and how is the initial password handled? Lean: `/signup` in a DM; bot creates the user with a generated temp password, DMs it, player changes it on first login. +2. Recurrence type: same weekly slot? How far ahead are events posted? (Missions are one-shot docs — is a new mission created per operation night?) +3. Attendance model details: unique (mission, user) enforced in code; how web changes reach the bot (the chosen bridge is the `user-notifications` poll — attendance changes may need their own notification type so the bot reconciles the embed). +4. Announcements: which channels, who can trigger (Discord roles → app roles mapping), and how app → Discord pushes work. diff --git a/docs/bot/design.md b/docs/bot/design.md new file mode 100644 index 0000000..46aa5e0 --- /dev/null +++ b/docs/bot/design.md @@ -0,0 +1,154 @@ +# Discord Bot — Design + +Companion to `context.md` (requirements). Status: **approved design, not yet implemented**. Decisions here are locked unless noted; revisit via discussion, not silently. + +## Architecture at a glance + +- Bot lives in `src/bot/` — a standalone long-running process (`bun run src/bot/index.ts`) importing `@payload-config` directly, same pattern as `src/scripts/` and `src/tools/seed/`. +- The web app and the bot are **two independent Payload processes sharing one Postgres DB**. Both write via Payload's API with `overrideAccess` where needed; **the bot applies its own authz** (Discord roles + app roles) on top. +- App → Discord is one-way: the bot **polls** (notifications bridge + attendance hash reconcile). No webhooks, no changes to existing notify call sites beyond adding new ones. + +## Web-side changes + +### 1. `mission-attendances` collection (`src/collections/intelligence/MissionAttendances.ts`) + +One row per (mission, user). Slug: `mission-attendances`, admin group: Intelligence. + +- `mission` → missions (relationship, required, indexed) +- `user` → users (relationship, required) +- `response` → select: `yes` | `no` | `tentative` (required) +- Access: read any logged-in user; create/update = owner-of-self or admin/developer; delete = admin/developer +- **Uniqueness (mission, user) enforced in the service lib** (single write path), not a DB constraint + +### 2. Attendance service lib (`src/lib/attendance/index.ts`) + +Single source of truth, modeled on `src/lib/banking/index.ts`: + +- `setMissionAttendance(payload, { missionId, userId, response })` — find-or-update; emits game event `mission:attendance-change` (`EventTypes` addition); used by **both** the web action and the bot +- `getMissionAttendance(payload, missionId)` — `{ yes: User[], no: User[], tentative: User[], counts }` for embed + web rendering +- `getUserAttendance(payload, userId, missionIds)` — for showing the user's current response on the mission page + +### 3. Attendance UI + +Add an attendance section to the existing mission detail page `src/app/(frontend)/intelligence/missions/[id]/page.tsx`: three buttons (Yes / Tentative / No) showing the current user's selection, plus lists with counts. Server action `setAttendance` in `src/app/(frontend)/intelligence/missions/actions.ts` → calls the service lib. + +### 4. `discordId` on users (`src/collections/users/Users.ts`) + +Text field, optional, unique-when-set, indexed. The bot writes it via `overrideAccess` during `/signup` (snowflake known) and `/link`. Needed for: DMs (feature 3), RSVP identity mapping (feature 2), role checks. + +### 5. Discord notification preferences + +Extend the existing `preferences` group on users with a `discord` sub-group: +- `enabled` (checkbox, default false — **opt-in**: DMs are more intrusive than in-app) +- `mutedTypes` (select, hasMany, reuses `MUTEABLE_NOTIFICATION_TYPES`) + +Update `updatePreferences` action + `PreferencesForm` to include it. + +### 6. New notification types + notify sites + +`MUTEABLE_NOTIFICATION_TYPES` gains (for feature 3 examples): +- `finance:deposit`, `finance:withdraw`, `finance:transfer` (banking) +- `shipment:completed` (shipments) + +Add `notifyUser(...)` calls at the corresponding success points in `banking/actions.ts` and `shipments/actions.ts` (market already notifies — verified 8 sites in `market/actions.ts`). + +### 7. Template generation bin (`src/scripts/generateNextMission.ts`) + +Registered in `payload.config.ts` `bin` array as `{ key: "generate-mission", scriptPath: .../scripts/generateNextMission.ts }` (same shape as `game-tick`/`market-tick`, line 147–156 of `payload.config.ts`). Run by external cron weekly. + +- Clone the latest `operationType: "main"` mission (fallback: latest mission of any type; abort with a clear log if no mission exists yet — the GM creates the first one manually) +- Copy config/narrative/server/advanced fields; exclude `id`, timestamps +- `startDateTime` = next Saturday 20:00 (same logic as the collection default), `status` → `Concept` (so the bot won't post it until Ready/Scheduled), `codeName` → `${original}-${YYYYMMDD}` (guarantees uniqueness against the source) +- Idempotent: skip if a mission with that codeName already exists + +## Bot structure (`src/bot/`) + +``` +src/bot/ + index.ts # entry: getPayload → client login → register commands → start loops + config.ts # env parsing (see Env below); fails fast on missing required vars + commands/ # slash command definitions (builders) + execute handlers + signup.ts # /signup (DM) + link.ts # /link (DM) + announce.ts # /announce (staff only) + events/ + interactionCreate.ts # routes buttons + commands; the RSVP button handler lives here + ready.ts # startup: sync guild commands, kick off poll loops + services/ + missionEmbeds.ts # embed render + post/edit lifecycle + attendance-hash reconcile loop + notificationBridge.ts # user-notifications poll → Discord DMs + signup.ts # user creation w/ temp password, validation, /link logic + lib/ + roles.ts # isStaff(member) — Discord role IDs or app admin/developer + resolve.ts # discordId ↔ Payload userId lookups +``` + +Dependency: `discord.js` ^14 (add to root `package.json`). Script: `"bot": "bun run src/bot/index.ts"`. + +## Feature flows + +### A. Sign-up and linking + +- **`/signup`** (DM only; never accept credentials in a public channel). Options: `displayName`, `steamId`. `username` = the caller's Discord username (shown, not editable — that's the link). + - Validate: username free (not taken in `users.username` **or** `users.discordUsername` — both are set to it), snowflake not already linked, steamId is a 17-digit numeric string. + - Create user: `username`, `discordUsername` (same value), `discordId` (snowflake), `displayName`, `steamId`, `roles: ["user"]`, `password` = cryptographically random temp (16 chars, `crypto.randomBytes`). + - DM the temp password + `APP_URL` login instructions + "change it in Account settings". +- **`/link`** (DM): for people with an existing site account. Look up user by `discordUsername` == caller's Discord username; set `discordId`. No match → "no account with that Discord username — use /signup". Already linked to a different snowflake → error, contact staff. + +### B. Attendance embeds (feature 2) + +**Which missions get embeds**: `startDateTime` in the future **and** `ownershipAndStatus.status` in `[Ready, Scheduled]`. Posting rule: embed only missions with `visibility: "unit"` (the ops channel is unit-wide; leadership-only missions stay off Discord). + +**Lifecycle**: +1. Periodic reconcile loop (every `DISCORD_ATTENDANCE_POLL_MS`, default 60s): query eligible missions. + - No `discordMessageId` yet → post embed + button row, store `discordMessageId` on the mission. + - Has `discordMessageId` → compute attendance hash (sorted member lists + counts, JSON) → if ≠ stored `discordAttendanceHash`, edit the message in place, store the new hash. + - Status `Cancelled` → edit embed to a cancelled state, stop polling it. + - Past end time → leave final attendance as-is, stop polling. +2. **RSVP buttons**: action row on each embed: ✅ Yes / 🤔 Tentative / ❌ No, customId `ptf-att::` (well under Discord's 100-char limit). + - Click → resolve user via `discordId`; unlinked → ephemeral "Run /link or /signup first." + - `setMissionAttendance(...)` → immediately re-render + edit the embed (no waiting for the poll), update hash → ephemeral confirmation. +3. **Web → Discord**: web action → `setMissionAttendance` → next reconcile tick sees a hash difference → edits the embed. **Loop safety**: editing the embed never mutates attendance data, so the hash stabilizes — no feedback loop. Hash stored on the mission doc makes reconcile stateless across bot restarts (two new optional text fields on missions: `discordMessageId`, `discordAttendanceHash`). + +**Embed contents**: mission name + codeName, `` absolute + `` relative start time, operationType, maxPlayers, and three sections — ✅ Yes (n), 🤔 Tentative (n), ❌ No (n) — with member display names. + +### C. Notifications bridge + announcements (feature 3) + +- **Poll loop** (every `DISCORD_NOTIFICATION_POLL_MS`, default 20s): query `user-notifications` with `id > lastSeenId`, sorted ascending, limit 50. In-memory cursor; on startup, cursor = current max id (never DM pre-startup history). +- Per notification: load user → `preferences.discord.enabled` true **and** type not in `mutedTypes` → resolve `discordId` → DM an embed (title, message, link = `APP_URL + link`). +- **Rate limits**: DM sends are ~5/5s per channel. Sequential sends with a small queue; cap ~5 DMs per tick. Backpressure by taking a longer poll interval if the queue stays full. +- **`/announce`** (staff only, `isStaff(member)`): options `message` (+ optional `channel`, default `DISCORD_ANNOUNCE_CHANNEL_ID`). Posts a formatted announcement embed. App → Discord announcements are a later iteration, not v1. + +### Authz (`src/bot/lib/roles.ts`) + +`isStaff(member)`: caller has any `DISCORD_STAFF_ROLE_IDS`, **or** their linked Payload user has roles `admin`/`developer`. RSVP/signup/link are open to all members. The bot performs Payload writes with `overrideAccess` and relies on these checks + its own validations. + +## Env / secrets (add to `.env` + `.env.example`) + +| Var | Required | Purpose | +|---|---|---| +| `DISCORD_TOKEN` | yes | bot token | +| `DISCORD_GUILD_ID` | yes | guild for slash commands | +| `DISCORD_OPS_CHANNEL_ID` | yes | attendance embeds | +| `DISCORD_ANNOUNCE_CHANNEL_ID` | no | default `/announce` target | +| `DISCORD_STAFF_ROLE_IDS` | no | comma-separated role IDs granting staff | +| `DISCORD_ATTENDANCE_POLL_MS` | no | default 60000 | +| `DISCORD_NOTIFICATION_POLL_MS` | no | default 20000 | + +`APP_URL` already exists — used for login links in DMs. + +## Build sequencing (each step independently verifiable) + +1. **Foundation**: `discord.js` dep, `src/bot/{index,config}.ts`, env vars, `bot` script → bot comes online and registers `/ping` in the test server. +2. **Web model**: `mission-attendances` collection + attendance lib + `mission:attendance-change` event type + attendance UI on the mission page + `discordId` field on users → attendance works fully in the browser. +3. **Template bin**: `generate-mission` script → run twice, second run is a no-op. +4. **Feature 1**: `/signup` + `/link` + temp password flow → fresh Discord user signs up, logs into the site, changes password. +5. **Feature 2**: embed lifecycle + RSVP buttons + reconcile → RSVP in Discord updates the site; changing attendance on the site updates the embed within one poll. +6. **Feature 3**: `preferences.discord` group + bridge loop + DMs + `/announce` + new notify sites (banking/shipments) → market offer DMs an opted-in user; `/announce` posts. +7. **Polish**: preferences UI for Discord toggles, restart behavior checks, error paths (message deleted, DM closed, unlinked user). + +## Open questions (non-blocking, can decide during build) + +- Steam ID validation: strict 17-digit numeric, or lenient? +- Keep past-mission embeds in the channel (history) vs auto-delete — default: keep. +- `preferences.discord` labels for banking/shipment types once added. diff --git a/package.json b/package.json index 7c680bd..2a1d01a 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "license": "MIT", "type": "module", "scripts": { + "bot": "bun run src/bot/index.ts", "build": "bun run generate:version-info && cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build --webpack", "dev": "bun run generate:version-info && cross-env NODE_OPTIONS=--no-deprecation next dev --webpack", "devsafe": "rm -rf .next && bun run generate:version-info && cross-env NODE_OPTIONS=--no-deprecation next dev --webpack", @@ -49,6 +50,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "cross-env": "^10.1.0", + "discord.js": "^14.27.0", "dotenv": "17.2.3", "graphql": "^16.12.0", "lucide-react": "^0.561.0", diff --git a/src/bot/commands/announce.ts b/src/bot/commands/announce.ts new file mode 100644 index 0000000..8c5ea6e --- /dev/null +++ b/src/bot/commands/announce.ts @@ -0,0 +1,66 @@ +import { EmbedBuilder, SlashCommandBuilder, type TextBasedChannelFields } from "discord.js"; +import type { BotCommand } from "./index"; +import { botConfig } from "@/bot/config"; +import { isStaff } from "@/bot/lib/roles"; + +const ANNOUNCE_COLOR = 0xf59e0b; + +export const announceCommand: BotCommand = { + builder: new SlashCommandBuilder() + .setName("announce") + .setDescription("Post an announcement to a channel") + .addStringOption((option) => + option.setName("message").setDescription("The announcement text").setRequired(true), + ) + .addChannelOption((option) => + option.setName("channel").setDescription("Channel to post to"), + ) as SlashCommandBuilder, + execute: async (interaction, payload) => { + if (!interaction.inGuild() || !interaction.guild) { + await interaction.reply({ content: "This command only works in a server.", ephemeral: true }); + return; + } + + const member = await interaction.guild.members.fetch(interaction.user.id); + if (!(await isStaff(member, payload))) { + await interaction.reply({ + content: "You don't have permission to announce.", + ephemeral: true, + }); + return; + } + + const message = interaction.options.getString("message", true); + const option = interaction.options.getChannel("channel"); + const channelId = option?.id ?? botConfig.announceChannelId ?? botConfig.opsChannelId; + if (!channelId) { + await interaction.reply({ + content: "No announce channel is configured and no channel was given.", + ephemeral: true, + }); + return; + } + + const optionChannel = option && "send" in option ? (option as TextBasedChannelFields) : null; + const channel = optionChannel ?? (await interaction.guild.channels.fetch(channelId)); + if (!channel || !("send" in channel)) { + await interaction.reply({ content: "That channel isn't a text channel.", ephemeral: true }); + return; + } + + await (channel as TextBasedChannelFields).send({ + embeds: [ + new EmbedBuilder() + .setTitle("Announcement") + .setDescription(message) + .setColor(ANNOUNCE_COLOR), + ], + }); + + const name = (channel as unknown as { name?: string | null }).name ?? channelId; + await interaction.reply({ + content: `Announcement posted to #${name}.`, + ephemeral: true, + }); + }, +}; diff --git a/src/bot/commands/index.ts b/src/bot/commands/index.ts new file mode 100644 index 0000000..77e5c1b --- /dev/null +++ b/src/bot/commands/index.ts @@ -0,0 +1,13 @@ +import type { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; +import type { Payload } from "payload"; +import { pingCommand } from "./ping"; +import { signupCommand } from "./signup"; +import { linkCommand } from "./link"; +import { announceCommand } from "./announce"; + +export interface BotCommand { + builder: SlashCommandBuilder; + execute: (interaction: ChatInputCommandInteraction, payload: Payload) => Promise | void; +} + +export const commands: BotCommand[] = [pingCommand, signupCommand, linkCommand, announceCommand]; diff --git a/src/bot/commands/link.ts b/src/bot/commands/link.ts new file mode 100644 index 0000000..53a366c --- /dev/null +++ b/src/bot/commands/link.ts @@ -0,0 +1,37 @@ +import { SlashCommandBuilder } from "discord.js"; +import type { BotCommand } from "./index"; +import { linkDiscordAccount, SignupError } from "@/bot/services/signup"; + +const DM_ONLY_MESSAGE = "This command only works in direct messages."; + +export const linkCommand: BotCommand = { + builder: new SlashCommandBuilder() + .setName("link") + .setDescription("Link your existing Polaris account to your Discord account"), + execute: async (interaction, payload) => { + if (interaction.inGuild()) { + await interaction.reply({ content: DM_ONLY_MESSAGE, ephemeral: true }); + return; + } + + try { + const { user, wasLinked } = await linkDiscordAccount( + payload, + interaction.user.username, + interaction.user.id, + ); + await interaction.reply({ + content: wasLinked + ? `Your Discord account is now linked to ${user.username}.` + : `Your Discord account is already linked to ${user.username}.`, + ephemeral: true, + }); + } catch (error) { + if (error instanceof SignupError) { + await interaction.reply({ content: error.message, ephemeral: true }); + return; + } + throw error; + } + }, +}; diff --git a/src/bot/commands/ping.ts b/src/bot/commands/ping.ts new file mode 100644 index 0000000..4ecbb73 --- /dev/null +++ b/src/bot/commands/ping.ts @@ -0,0 +1,9 @@ +import { SlashCommandBuilder } from "discord.js"; +import type { BotCommand } from "./index"; + +export const pingCommand: BotCommand = { + builder: new SlashCommandBuilder().setName("ping").setDescription("Replies with Pong!"), + execute: async (interaction) => { + await interaction.reply("Pong!"); + }, +}; diff --git a/src/bot/commands/signup.ts b/src/bot/commands/signup.ts new file mode 100644 index 0000000..9495636 --- /dev/null +++ b/src/bot/commands/signup.ts @@ -0,0 +1,64 @@ +import { SlashCommandBuilder } from "discord.js"; +import type { BotCommand } from "./index"; +import { botConfig } from "@/bot/config"; +import { createDiscordUser, SignupError, validateSignup } from "@/bot/services/signup"; + +const DM_ONLY_MESSAGE = "This command only works in direct messages."; + +export const signupCommand: BotCommand = { + builder: new SlashCommandBuilder() + .setName("signup") + .setDescription("Create a Polaris Task Force account linked to your Discord account") + .addStringOption((option) => + option.setName("displayname").setDescription("Your in-game name").setRequired(true), + ) + .addStringOption((option) => + option + .setName("steamid") + .setDescription("Your Steam64 ID (17-digit numeric)") + .setRequired(true), + ) as SlashCommandBuilder, + execute: async (interaction, payload) => { + if (interaction.inGuild()) { + await interaction.reply({ content: DM_ONLY_MESSAGE, ephemeral: true }); + return; + } + + const username = interaction.user.username; + const discordId = interaction.user.id; + const displayName = interaction.options.getString("displayname", true); + const steamId = interaction.options.getString("steamid", true); + + try { + await validateSignup(payload, { username, discordId, steamId, displayName }); + } catch (error) { + if (error instanceof SignupError) { + await interaction.reply({ content: error.message, ephemeral: true }); + return; + } + throw error; + } + + const { user, tempPassword } = await createDiscordUser(payload, { + username, + discordId, + displayName, + steamId, + }); + + const passwordMessage = [ + "Your Polaris Task Force account has been created.", + "", + `Username: ${user.username}`, + `Temporary password: ${tempPassword}`, + "", + `Log in at ${botConfig.appUrl} and change your password in Account settings.`, + ].join("\n"); + await interaction.user.send(passwordMessage); + + await interaction.reply({ + content: `Account created for ${user.username}. Your temporary password was sent to this DM.`, + ephemeral: true, + }); + }, +}; diff --git a/src/bot/config.ts b/src/bot/config.ts new file mode 100644 index 0000000..d032784 --- /dev/null +++ b/src/bot/config.ts @@ -0,0 +1,49 @@ +export interface BotConfig { + token: string; + guildId: string; + appUrl: string; + opsChannelId: string | null; + announceChannelId: string | null; + staffRoleIds: string[]; + attendancePollMs: number; + notificationPollMs: number; +} + +const required = (name: string): string => { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`[Bot] Missing required environment variable: ${name}`); + } + return value; +}; + +const optional = (name: string): string | null => { + const value = process.env[name]?.trim(); + return value ? value : null; +}; + +const pollMs = (name: string, fallback: number): number => { + const raw = process.env[name]?.trim(); + if (!raw) { + return fallback; + } + const value = Number.parseInt(raw, 10); + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`[Bot] Invalid ${name}: expected a positive integer, got "${raw}"`); + } + return value; +}; + +export const botConfig: BotConfig = { + token: required("DISCORD_TOKEN"), + guildId: required("DISCORD_GUILD_ID"), + appUrl: required("APP_URL"), + opsChannelId: optional("DISCORD_OPS_CHANNEL_ID"), + announceChannelId: optional("DISCORD_ANNOUNCE_CHANNEL_ID"), + staffRoleIds: (optional("DISCORD_STAFF_ROLE_IDS") ?? "") + .split(",") + .map((id) => id.trim()) + .filter((id) => id.length > 0), + attendancePollMs: pollMs("DISCORD_ATTENDANCE_POLL_MS", 60_000), + notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000), +}; diff --git a/src/bot/events/interactionCreate.ts b/src/bot/events/interactionCreate.ts new file mode 100644 index 0000000..8eaf7c4 --- /dev/null +++ b/src/bot/events/interactionCreate.ts @@ -0,0 +1,94 @@ +import type { ButtonInteraction } from "discord.js"; +import type { Payload } from "payload"; +import { findUserByDiscordId } from "@/bot/lib/resolve"; +import { + attendanceHash, + buildAttendanceRow, + fetchAttendanceDocs, + renderAttendanceEmbed, +} from "@/bot/services/missionEmbeds"; +import { + attendanceClosedMessage, + getMissionAttendance, + isMissionPast, + setMissionAttendance, + type AttendanceResponse, +} from "@/lib/attendance"; + +const ATTENDANCE_LABELS: Record = { + yes: "Yes", + no: "No", + tentative: "Tentative", +}; + +const isAttendanceResponse = (value: string): value is AttendanceResponse => + value === "yes" || value === "no" || value === "tentative"; + +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : "Unknown error"; + +export const handleButtonInteraction = async ( + interaction: ButtonInteraction, + payload: Payload, +): Promise => { + const parts = interaction.customId.split(":"); + if (parts.length !== 3 || parts[0] !== "ptf-att") { + return; + } + const missionId = Number(parts[1]); + const response = parts[2]; + if (!Number.isInteger(missionId) || !isAttendanceResponse(response)) { + return; + } + + try { + const user = await findUserByDiscordId(payload, interaction.user.id); + if (!user) { + await interaction.reply({ content: "Run /signup or /link first.", ephemeral: true }); + return; + } + + const mission = await payload.findByID({ + collection: "missions", + id: missionId, + depth: 1, + overrideAccess: true, + }); + + if (isMissionPast(mission.classification.startDateTime)) { + await interaction.reply({ content: attendanceClosedMessage, ephemeral: true }); + return; + } + + await setMissionAttendance(payload, { missionId, userId: user.id, response }); + + const summary = await getMissionAttendance(payload, missionId); + const hash = attendanceHash(await fetchAttendanceDocs(payload, missionId)); + + await interaction.message.edit({ + embeds: [renderAttendanceEmbed(mission, summary)], + components: [buildAttendanceRow(missionId)], + }); + await payload.update({ + collection: "missions", + id: missionId, + data: { discordAttendanceHash: hash }, + overrideAccess: true, + }); + + await interaction.reply({ + content: `You are marked as ${ATTENDANCE_LABELS[response]} for ${mission.name}.`, + ephemeral: true, + }); + } catch (error) { + payload.logger.error( + `[Bot] RSVP interaction failed for mission #${missionId}: ${errorMessage(error)}`, + ); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + content: "Something went wrong while updating your attendance.", + ephemeral: true, + }); + } + } +}; diff --git a/src/bot/index.ts b/src/bot/index.ts new file mode 100644 index 0000000..02aea06 --- /dev/null +++ b/src/bot/index.ts @@ -0,0 +1,67 @@ +import { Client, Events, GatewayIntentBits } from "discord.js"; +import { getPayload } from "payload"; +import config from "@payload-config"; +import { botConfig } from "./config"; +import { commands } from "./commands"; +import { services } from "./services"; +import { handleButtonInteraction } from "./events/interactionCreate"; + +// Usage: `bun run bot`. Standalone long-running process; connects to Discord +// and shares the Postgres database with the web app via the Payload config. +const main = async () => { + const payload = await getPayload({ config }); + + const client = new Client({ + intents: [GatewayIntentBits.Guilds], + }); + + const commandExecutors = new Map( + commands.map((command) => [command.builder.name, command.execute]), + ); + + client.once(Events.ClientReady, async (readyClient) => { + payload.logger.info(`[Bot] Logged in as ${readyClient.user.tag}`); + await readyClient.application.commands.set( + commands.map((command) => command.builder.toJSON()), + botConfig.guildId, + ); + payload.logger.info(`[Bot] Registered ${commands.length} guild command(s)`); + for (const start of services) { + await start(readyClient, payload); + } + }); + + client.on(Events.InteractionCreate, async (interaction) => { + if (interaction.isButton() && interaction.customId.startsWith("ptf-att:")) { + await handleButtonInteraction(interaction, payload); + return; + } + if (!interaction.isChatInputCommand()) { + return; + } + const execute = commandExecutors.get(interaction.commandName); + if (!execute) { + return; + } + try { + await execute(interaction, payload); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + payload.logger.error(`[Bot] Error executing /${interaction.commandName}: ${message}`); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + content: "Something went wrong while running this command.", + ephemeral: true, + }); + } + } + }); + + await client.login(botConfig.token); +}; + +main().catch((error) => { + const message = error instanceof Error ? error.message : "Unknown error"; + console.error(`[Bot] Failed to start: ${message}`); + process.exit(1); +}); diff --git a/src/bot/lib/resolve.ts b/src/bot/lib/resolve.ts new file mode 100644 index 0000000..2da22f9 --- /dev/null +++ b/src/bot/lib/resolve.ts @@ -0,0 +1,50 @@ +import type { Payload } from "payload"; +import type { User } from "@/payload-types"; + +export const findUserByDiscordId = async ( + payload: Payload, + discordId: string, +): Promise => { + const result = await payload.find({ + collection: "users", + where: { + discordId: { equals: discordId }, + }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + return result.docs[0] ?? null; +}; + +export const findUserByDiscordUsername = async ( + payload: Payload, + discordUsername: string, +): Promise => { + const result = await payload.find({ + collection: "users", + where: { + discordUsername: { equals: discordUsername }, + }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + return result.docs[0] ?? null; +}; + +export const findUserByUsername = async ( + payload: Payload, + username: string, +): Promise => { + const result = await payload.find({ + collection: "users", + where: { + username: { equals: username }, + }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + return result.docs[0] ?? null; +}; diff --git a/src/bot/lib/roles.ts b/src/bot/lib/roles.ts new file mode 100644 index 0000000..e5abde1 --- /dev/null +++ b/src/bot/lib/roles.ts @@ -0,0 +1,13 @@ +import type { GuildMember } from "discord.js"; +import type { Payload } from "payload"; +import { botConfig } from "@/bot/config"; +import { findUserByDiscordId } from "@/bot/lib/resolve"; + +export const isStaff = async (member: GuildMember, payload: Payload): Promise => { + if (member.roles.cache.some((role) => botConfig.staffRoleIds.includes(role.id))) { + return true; + } + const user = await findUserByDiscordId(payload, member.id); + if (!user) return false; + return (user.roles ?? []).some((role) => role === "admin" || role === "developer"); +}; diff --git a/src/bot/services/index.ts b/src/bot/services/index.ts new file mode 100644 index 0000000..3c006c8 --- /dev/null +++ b/src/bot/services/index.ts @@ -0,0 +1,8 @@ +import type { Client } from "discord.js"; +import type { Payload } from "payload"; +import { start as startMissionEmbeds } from "./missionEmbeds"; +import { start as startNotificationBridge } from "./notificationBridge"; + +export type ServiceStarter = (client: Client, payload: Payload) => Promise | void; + +export const services: ServiceStarter[] = [startMissionEmbeds, startNotificationBridge]; diff --git a/src/bot/services/missionEmbeds.ts b/src/bot/services/missionEmbeds.ts new file mode 100644 index 0000000..b45626c --- /dev/null +++ b/src/bot/services/missionEmbeds.ts @@ -0,0 +1,308 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + type Client, + type TextBasedChannelFields, +} from "discord.js"; +import type { Payload } from "payload"; +import type { Mission, MissionAttendance, User } from "@/payload-types"; +import { getMissionAttendance, type MissionAttendanceSummary } from "@/lib/attendance"; +import { botConfig } from "@/bot/config"; + +const EMBED_COLOR = 0x3b82f6; +const CANCELLED_COLOR = 0x6b7280; +const ELIGIBLE_STATUSES = ["Ready", "Scheduled"]; +const MAX_ELIGIBLE_MISSIONS = 10; + +let opsChannelWarned = false; + +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : "Unknown error"; + +const missionTitle = (mission: Mission): string => + mission.codeName ? `${mission.name} (${mission.codeName})` : mission.name; + +const memberName = (user: User): string => user.displayName ?? user.username; + +const isStillFuture = (mission: Mission): boolean => { + const start = new Date(mission.classification.startDateTime).getTime(); + const end = start + mission.classification.estimatedDuration * 60_000; + return end > Date.now(); +}; + +const operationTypeLabel = (type: Mission["operationType"]): string => { + switch (type) { + case "main": + return "Main Operation"; + case "side": + return "Side Operation"; + case "external": + return "External Operation"; + } +}; + +/** + * Deterministic fingerprint of the attendance state so reconcile can skip + * embeds that haven't changed (anti-loop). + */ +export const attendanceHash = (docs: MissionAttendance[]): string => { + const rows = docs + .map((doc) => { + const userId = typeof doc.user === "object" ? doc.user.id : doc.user; + return [userId, doc.response] as const; + }) + .sort((a, b) => a[0] - b[0] || a[1].localeCompare(b[1])); + return JSON.stringify(rows); +}; + +export const fetchAttendanceDocs = async ( + payload: Payload, + missionId: number, +): Promise => { + const res = await payload.find({ + collection: "mission-attendances", + where: { mission: { equals: missionId } }, + depth: 0, + limit: 1000, + sort: "createdAt", + overrideAccess: true, + }); + return res.docs as unknown as MissionAttendance[]; +}; + +export const getEligibleMissions = async (payload: Payload): Promise => { + const res = await payload.find({ + collection: "missions", + overrideAccess: true, + depth: 1, + sort: "-classification.startDateTime", + limit: MAX_ELIGIBLE_MISSIONS, + where: { + and: [ + { "classification.startDateTime": { greater_than: new Date().toISOString() } }, + { "ownershipAndStatus.status": { in: ELIGIBLE_STATUSES } }, + { "ownershipAndStatus.visibility": { equals: "unit" } }, + ], + }, + }); + return res.docs as unknown as Mission[]; +}; + +const getCancelledMissions = async (payload: Payload): Promise => { + const res = await payload.find({ + collection: "missions", + overrideAccess: true, + depth: 1, + sort: "-classification.startDateTime", + limit: MAX_ELIGIBLE_MISSIONS, + where: { + and: [ + { "ownershipAndStatus.status": { equals: "Cancelled" } }, + { discordMessageId: { exists: true } }, + ], + }, + }); + return res.docs as unknown as Mission[]; +}; + +export const renderAttendanceEmbed = ( + mission: Mission, + summary: MissionAttendanceSummary, +): EmbedBuilder => { + const startUnix = Math.floor(new Date(mission.classification.startDateTime).getTime() / 1000); + const names = (users: User[]): string => users.map(memberName).join("\n").slice(0, 1024) || "—"; + return new EmbedBuilder() + .setTitle(missionTitle(mission)) + .setDescription( + [ + `**Operation type:** ${operationTypeLabel(mission.operationType)}`, + `**Starts:** ()`, + `**Max players:** ${mission.missionRoles.maxPlayers}`, + ].join("\n"), + ) + .setColor(EMBED_COLOR) + .addFields( + { name: `✅ Yes (${summary.counts.yes})`, value: names(summary.yes), inline: true }, + { + name: `🤔 Tentative (${summary.counts.tentative})`, + value: names(summary.tentative), + inline: true, + }, + { name: `❌ No (${summary.counts.no})`, value: names(summary.no), inline: true }, + ); +}; + +export const renderCancelledEmbed = (mission: Mission): EmbedBuilder => + new EmbedBuilder() + .setTitle(missionTitle(mission)) + .setDescription("This operation has been cancelled.") + .setColor(CANCELLED_COLOR); + +export const buildAttendanceRow = (missionId: number): ActionRowBuilder => + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`ptf-att:${missionId}:yes`) + .setStyle(ButtonStyle.Success) + .setLabel("✅ Yes"), + new ButtonBuilder() + .setCustomId(`ptf-att:${missionId}:tentative`) + .setStyle(ButtonStyle.Secondary) + .setLabel("🤔 Tentative"), + new ButtonBuilder() + .setCustomId(`ptf-att:${missionId}:no`) + .setStyle(ButtonStyle.Danger) + .setLabel("❌ No"), + ); + +const getOpsChannel = async ( + client: Client, + opsChannelId: string, +): Promise => { + const channel = await client.channels.fetch(opsChannelId); + if (!channel || !("send" in channel)) { + throw new Error(`[Bot] Ops channel ${opsChannelId} is not a text channel.`); + } + return channel as unknown as TextBasedChannelFields; +}; + +const postEmbed = async ( + client: Client, + payload: Payload, + opsChannelId: string, + mission: Mission, +): Promise => { + const summary = await getMissionAttendance(payload, mission.id); + const hash = attendanceHash(await fetchAttendanceDocs(payload, mission.id)); + const channel = await getOpsChannel(client, opsChannelId); + const message = await channel.send({ + embeds: [renderAttendanceEmbed(mission, summary)], + components: [buildAttendanceRow(mission.id)], + }); + await payload.update({ + collection: "missions", + id: mission.id, + data: { discordMessageId: message.id, discordAttendanceHash: hash }, + overrideAccess: true, + }); + payload.logger.info( + `[Bot] Posted attendance embed for mission #${mission.id} (${mission.name}).`, + ); +}; + +const manageMission = async ( + client: Client, + payload: Payload, + opsChannelId: string, + mission: Mission, +): Promise => { + if (!mission.discordMessageId) { + await postEmbed(client, payload, opsChannelId, mission); + return; + } + + const channel = await getOpsChannel(client, opsChannelId); + let message; + try { + message = await channel.messages.fetch(mission.discordMessageId); + } catch { + await postEmbed(client, payload, opsChannelId, mission); + payload.logger.warn(`[Bot] Attendance embed for mission #${mission.id} was deleted; reposted.`); + return; + } + + const hash = attendanceHash(await fetchAttendanceDocs(payload, mission.id)); + if (hash === mission.discordAttendanceHash) { + return; + } + const summary = await getMissionAttendance(payload, mission.id); + await message.edit({ + embeds: [renderAttendanceEmbed(mission, summary)], + components: [buildAttendanceRow(mission.id)], + }); + await payload.update({ + collection: "missions", + id: mission.id, + data: { discordAttendanceHash: hash }, + overrideAccess: true, + }); +}; + +const cancelMissionEmbed = async ( + client: Client, + payload: Payload, + opsChannelId: string, + mission: Mission, +): Promise => { + const channel = await getOpsChannel(client, opsChannelId); + let message; + try { + message = await channel.messages.fetch(mission.discordMessageId as string); + } catch { + await payload.update({ + collection: "missions", + id: mission.id, + data: { discordMessageId: null }, + overrideAccess: true, + }); + return; + } + await message.edit({ embeds: [renderCancelledEmbed(mission)], components: [] }); + await payload.update({ + collection: "missions", + id: mission.id, + data: { discordMessageId: null }, + overrideAccess: true, + }); + payload.logger.info( + `[Bot] Marked attendance embed for cancelled mission #${mission.id} (${mission.name}).`, + ); +}; + +export const reconcile = async (client: Client, payload: Payload): Promise => { + try { + const opsChannelId = botConfig.opsChannelId; + if (!opsChannelId) { + if (!opsChannelWarned) { + payload.logger.warn( + "[Bot] Attendance embed service: DISCORD_OPS_CHANNEL_ID is not set; skipping embed work.", + ); + opsChannelWarned = true; + } + return; + } + + const eligible = (await getEligibleMissions(payload)).filter(isStillFuture); + for (const mission of eligible) { + try { + await manageMission(client, payload, opsChannelId, mission); + } catch (error) { + payload.logger.error( + `[Bot] Attendance embed reconcile failed for mission #${mission.id}: ${errorMessage(error)}`, + ); + } + } + + const cancelled = await getCancelledMissions(payload); + for (const mission of cancelled) { + try { + await cancelMissionEmbed(client, payload, opsChannelId, mission); + } catch (error) { + payload.logger.error( + `[Bot] Cancelled-embed reconcile failed for mission #${mission.id}: ${errorMessage(error)}`, + ); + } + } + } catch (error) { + payload.logger.error(`[Bot] Attendance embed reconcile failed: ${errorMessage(error)}`); + } +}; + +export const start = (client: Client, payload: Payload): void => { + payload.logger.info("[Bot] Attendance embed service started"); + void reconcile(client, payload); + setInterval(() => { + void reconcile(client, payload); + }, botConfig.attendancePollMs); +}; diff --git a/src/bot/services/notificationBridge.ts b/src/bot/services/notificationBridge.ts new file mode 100644 index 0000000..50e7105 --- /dev/null +++ b/src/bot/services/notificationBridge.ts @@ -0,0 +1,100 @@ +import { EmbedBuilder, type Client } from "discord.js"; +import type { Payload } from "payload"; +import type { User, UserNotification } from "@/payload-types"; +import { botConfig } from "@/bot/config"; + +const MAX_SENDS_PER_TICK = 5; +const SEND_DELAY_MS = 300; +const EMBED_COLOR = 0x3b82f6; + +let cursor: number | null = null; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +const initCursor = async (payload: Payload): Promise => { + const res = await payload.find({ + collection: "user-notifications", + sort: "-id", + limit: 1, + depth: 0, + overrideAccess: true, + }); + cursor = res.docs[0]?.id ?? 0; +}; + +const notificationOwnerId = (notification: UserNotification): number => + typeof notification.user === "object" ? notification.user.id : notification.user; + +const eligibleForDm = (owner: User, type?: string | null): boolean => { + if (!owner.discordId) return false; + const discord = owner.preferences?.discord; + if (!discord || discord.enabled !== true) return false; + if (type && (discord.mutedTypes as string[] | null | undefined)?.includes(type)) return false; + return true; +}; + +const poll = async (client: Client, payload: Payload): Promise => { + try { + if (cursor === null) { + await initCursor(payload); + } + const res = await payload.find({ + collection: "user-notifications", + where: { id: { greater_than: cursor as number } }, + sort: "id", + limit: 50, + depth: 0, + overrideAccess: true, + }); + + let processed = 0; + let sent = 0; + + for (const notification of res.docs as unknown as UserNotification[]) { + processed++; + cursor = notification.id; + if (sent >= MAX_SENDS_PER_TICK) break; + + try { + const owner = (await payload.findByID({ + collection: "users", + id: notificationOwnerId(notification), + depth: 0, + overrideAccess: true, + })) as unknown as User; + if (!eligibleForDm(owner, notification.type)) continue; + + const target = await client.users.fetch(owner.discordId as string); + await target.send({ + embeds: [ + new EmbedBuilder() + .setTitle(notification.title) + .setDescription(notification.message ?? null) + .setColor(EMBED_COLOR) + .addFields({ + name: "Link", + value: `${botConfig.appUrl}${notification.link ?? ""}`, + }), + ], + }); + sent++; + if (sent < MAX_SENDS_PER_TICK) await sleep(SEND_DELAY_MS); + } catch { + // DM closed, user not found, or fetch failed — skip silently. + } + } + + payload.logger.info(`[Bot] Notification bridge: processed ${processed}, sent ${sent}`); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + payload.logger.error(`[Bot] Notification bridge poll failed: ${message}`); + } +}; + +export const start = (client: Client, payload: Payload): void => { + payload.logger.info("[Bot] Notification bridge service started"); + void poll(client, payload); + setInterval(() => { + void poll(client, payload); + }, botConfig.notificationPollMs); +}; diff --git a/src/bot/services/signup.ts b/src/bot/services/signup.ts new file mode 100644 index 0000000..3505885 --- /dev/null +++ b/src/bot/services/signup.ts @@ -0,0 +1,91 @@ +import { randomBytes } from "node:crypto"; +import type { Payload } from "payload"; +import type { User } from "@/payload-types"; +import { + findUserByDiscordId, + findUserByDiscordUsername, + findUserByUsername, +} from "@/bot/lib/resolve"; + +export class SignupError extends Error {} + +export interface SignupInput { + username: string; + discordId: string; + displayName: string; + steamId: string; +} + +export const STEAM_ID_REGEX = /^\d{17}$/; + +export const isValidSteamId = (steamId: string): boolean => STEAM_ID_REGEX.test(steamId); + +export const generateTempPassword = (): string => randomBytes(16).toString("base64url"); + +export const validateSignup = async ( + payload: Payload, + { username, discordId, steamId }: SignupInput, +): Promise => { + const byUsername = await findUserByUsername(payload, username); + if (byUsername) { + throw new SignupError(`The username "${username}" is already taken.`); + } + const byDiscordUsername = await findUserByDiscordUsername(payload, username); + if (byDiscordUsername) { + throw new SignupError(`The Discord username "${username}" is already linked to an account.`); + } + const byDiscordId = await findUserByDiscordId(payload, discordId); + if (byDiscordId) { + throw new SignupError("This Discord account is already linked to a Polaris account."); + } + if (!isValidSteamId(steamId)) { + throw new SignupError("Steam ID must be a 17-digit numeric Steam64 ID."); + } +}; + +export const createDiscordUser = async ( + payload: Payload, + input: SignupInput, +): Promise<{ user: User; tempPassword: string }> => { + const tempPassword = generateTempPassword(); + const user = await payload.create({ + collection: "users", + data: { + username: input.username, + discordUsername: input.username, + discordId: input.discordId, + displayName: input.displayName, + steamId: input.steamId, + roles: ["user"], + password: tempPassword, + }, + overrideAccess: true, + }); + return { user, tempPassword }; +}; + +export const linkDiscordAccount = async ( + payload: Payload, + discordUsername: string, + discordId: string, +): Promise<{ user: User; wasLinked: boolean }> => { + const user = await findUserByDiscordUsername(payload, discordUsername); + if (!user) { + throw new SignupError("No account with that Discord username — use /signup to create one."); + } + if (user.discordId && user.discordId !== discordId) { + throw new SignupError( + "That account is already linked to a different Discord account. Contact staff to resolve.", + ); + } + if (user.discordId === discordId) { + return { user, wasLinked: false }; + } + const updated = await payload.update({ + collection: "users", + id: user.id, + data: { discordId }, + overrideAccess: true, + }); + return { user: updated, wasLinked: true }; +};