1
0
Fork 0

feat(bot): add Discord bot scaffold with sign-up, attendance embeds, and notification bridge

This commit is contained in:
Jason Fraley 2026-08-12 12:39:00 -04:00
parent ad774121b0
commit 00919ceeb3
20 changed files with 1243 additions and 1 deletions

View file

@ -6,3 +6,19 @@ APP_URL=http://localhost:3000
# Secret that guards the /api/game-tick/notify endpoint. The game tick script # Secret that guards the /api/game-tick/notify endpoint. The game tick script
# sends this header so connected clients refresh after each tick. # sends this header so connected clients refresh after each tick.
GAME_TICK_NOTIFY_SECRET=YOUR_GAME_TICK_NOTIFY_SECRET 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=

View file

@ -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). - **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`. - 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 ## Auth
Username-based login (no email login). Users log in via Payload admin with `username` only. Username-based login (no email login). Users log in via Payload admin with `username` only.

View file

@ -29,6 +29,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"discord.js": "^14.27.0",
"dotenv": "17.2.3", "dotenv": "17.2.3",
"graphql": "^16.12.0", "graphql": "^16.12.0",
"lucide-react": "^0.561.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=="], "@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/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=="], "@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=="], "@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/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=="], "@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=="], "@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/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=="], "@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=="], "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=="], "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "2.0.3" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], "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.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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "@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/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=="], "@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/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=="], "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=="], "pino-pretty/strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],

58
docs/bot/context.md Normal file
View file

@ -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.

154
docs/bot/design.md Normal file
View file

@ -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:<missionId>:<response>` (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, `<t:epoch:F>` absolute + `<t:epoch:R>` 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.

View file

@ -5,6 +5,7 @@
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"scripts": { "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", "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", "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", "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", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"discord.js": "^14.27.0",
"dotenv": "17.2.3", "dotenv": "17.2.3",
"graphql": "^16.12.0", "graphql": "^16.12.0",
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",

View file

@ -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,
});
},
};

13
src/bot/commands/index.ts Normal file
View file

@ -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> | void;
}
export const commands: BotCommand[] = [pingCommand, signupCommand, linkCommand, announceCommand];

37
src/bot/commands/link.ts Normal file
View file

@ -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;
}
},
};

9
src/bot/commands/ping.ts Normal file
View file

@ -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!");
},
};

View file

@ -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,
});
},
};

49
src/bot/config.ts Normal file
View file

@ -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),
};

View file

@ -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<AttendanceResponse, string> = {
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<void> => {
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,
});
}
}
};

67
src/bot/index.ts Normal file
View file

@ -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);
});

50
src/bot/lib/resolve.ts Normal file
View file

@ -0,0 +1,50 @@
import type { Payload } from "payload";
import type { User } from "@/payload-types";
export const findUserByDiscordId = async (
payload: Payload,
discordId: string,
): Promise<User | null> => {
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<User | null> => {
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<User | null> => {
const result = await payload.find({
collection: "users",
where: {
username: { equals: username },
},
limit: 1,
depth: 0,
overrideAccess: true,
});
return result.docs[0] ?? null;
};

13
src/bot/lib/roles.ts Normal file
View file

@ -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<boolean> => {
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");
};

View file

@ -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> | void;
export const services: ServiceStarter[] = [startMissionEmbeds, startNotificationBridge];

View file

@ -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<MissionAttendance[]> => {
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<Mission[]> => {
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<Mission[]> => {
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:** <t:${startUnix}:F> (<t:${startUnix}:R>)`,
`**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<ButtonBuilder> =>
new ActionRowBuilder<ButtonBuilder>().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<TextBasedChannelFields> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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);
};

View file

@ -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<void> => new Promise((resolve) => setTimeout(resolve, ms));
const initCursor = async (payload: Payload): Promise<void> => {
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<void> => {
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);
};

View file

@ -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<void> => {
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 };
};