diff --git a/README.md b/README.md index 855efaf..26a56d9 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,220 @@ +``` +* * * * * ๐ŸŒ•* + * * * * * +โ”โ” โ”โ”โ”ƒโ”โ”โ”ƒโ”โ” โ”โ”โ”›โ”โ”โ”ƒโ”โ”โ”ƒโ”โ”โ”›โ”โ”โ”›โ”โ”โ”ƒ +โ”ƒโ”ƒโ”ƒโ”ƒ โ”ƒโ”ƒ โ”ƒโ”ƒ โ”ƒ โ”ƒ โ”โ”โ”›โ”โ”โ”ƒโ”ƒ โ”โ”โ”›โ”โ”โ”› +โ”›โ”›โ”›โ”โ”โ”›โ”โ”โ”›โ”› โ”› โ”› โ”› โ”›โ”› โ”›โ”โ”โ”›โ”โ”โ”›โ”› โ”› +* *awoo* * * * + ๐Ÿบ * * * +``` + # ๐Ÿบ Moontracer -A Discord bot for DnD players, game masters and spectators! +A **D&D Campaign Manager Discord bot** for players, DMs and spectators! + +Moontracer can manage everything you need to organise your TTRPG experience online: **manage campaigns, players, alerts, and more!** + +## ๐Ÿš€ Quick Start + +### 1. Create a Discord application + +1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) and create a new application. +2. Under **Bot**, enable these **Privileged Gateway Intents**: `Server Members Intent` and `Message Content Intent`. +3. Copy the **Bot Token**: you'll need it for `DISCORD_BOT_TOKEN`. +4. Under **OAuth2 -> URL Generator**, select scopes `bot` and `applications.commands`, then the permissions your server requires. Use the generated URL to invite the bot. + + +### 2. Configure + +```sh +cp .env.example .env +``` + +Edit `.env` and fill in at minimum: + +``` +DISCORD_BOT_TOKEN=your_token_here +ADMIN_ROLE_NAME=Admin # exact name of the admin role on your server +``` + +Set `MEDIA_BASE_URL` to the public URL your reverse proxy will serve media from (for example: `https://yourhost.example.com/api/v1/cdn`). + +### 3. Run + +**With Docker Compose (recommended):** + +```sh +docker compose up -d +``` + +**Locally (requires Go 1.25+):** + +```sh +go build ./cmd/moontracer && ./moontracer +``` + +The bot registers slash commands on startup. With `DISCORD_GUILD_ID` set, commands appear instantly. Without it, global propagation takes up to an hour. + +### 4. First use + +Once the bot is online in your server, run `/register` to create your player profile, then `/help` to see available commands. + +--- + +## โ„น๏ธ What is it? + +- Self-hosted Discord bot to manage TTRPG (mainly Dungeons and Dragons) campaigns in a Discord server. +- Can handle the whole campaign lifecycle: creation, approval, playing and archiving/deleting them. +- Supports Westmarch-style campaigns: multiple concurrent campaign support, players register themselves. +- Staff (admins/mods) approve campaigns, DMs manage their own, players browse and join. + +## โš”๏ธ Main features! + +### Campaign Management + +- DMs can create new campaigns using `/newcampaign`. + - It follows a multi-step modal flow: name your campaign, add a description, system, capacity and schedule. +- Campaigns require staff approval before becoming publicly available. +- When a campaign is approved, a Channel and Threads for general talk, announcements, dice throws and upcoming sessions are created, as well as a (locked and pinned) welcome one, for easier traversal. +- DMs can manage their campaigns using `/manage` with autocomplete, with options for players, sessions, and setting submenus. +- Archive or delete campaigns using `/manage`: it hides/restricts the channel for staff-only. +- Upload cover art using `/uploadcover`: self-hosted, independent from Discord's CDN. +- Browse campaigns using `/campaigns` (menu-based), or `/search` (text autocomplete). + +### Session scheduling: + +- DMs schedule their sessions via `/newsession` (modal: date DD/MM/YYYY, time, and optional notes). +- Sessions announce to the campaign's `#announcements` thread with a role mention. +- Session embed includes: title, date/time, DM, capacity, RSVP counts (who's going/not going/on a waitlist). +- Players can say if they're going or not via a button on their DMs or on the `#announcements` message. +- Conflict detection: warns when a player already has a session scheduled at the same time. +- Session reminders: if the player opts-in, they can receive notifications 1 hour before a session starts. +- `/nextsessions` show a player's upcoming sessions across all their campaigns, sorted by date, in their local timezone. + +### Player management + +- Players register using `/register` +- `/me` hub: a centre for your campaigns, sessions, tokens and settings. +- Players can join campaigns from either the search box command, or via the UI. +- Capacity enforcement: sessions reject new players when they're full. +- Per-player timezone settings (IANA tz names): all times and dates are formatted in their local timezones. + +### Tokens + +- Upload and create new player tokens using `/newtoken`. + - Upload your image, add a frame **or** write a colour of your choosing. + - The bot composites a new circular token based on the image you give it. + - Returns a PNG ready to use! +- Flow: preview your token, set a name, save it, assign it to a campaign or skip. +- See your tokens using `/tokens`: a gallery of all your uploaded tokens. Preview them as embeds, select them with a menu. +- Download tokens: get your tokens using a one-time download URL (UUID claim, 10min TTL). It never exposes internal paths for extra security! +- You can reassign tokens to different campaigns after creation. + +### Staff/Admin + +- `/admin` panel (hidden from `/help`, staff-only): They hold server-wide settings, with per-guild role name overrides. +- `/ban` and `/unban` (hidden from `/help`, staff-only): ban players server-wide. +- `/campaigndatabase`: see all the campaigns to have ever been created inside your server. +- `DEBUG_ADMIN_ID` env var enables a two-factor developer override for sovereignty operations: if and only if the Discord account has permissions inside a server, it can perform operations on it. Even if it remains set up, nobody can enter your server without your consent, by design. +- `SAFE_MODE` env var disables all Discord-mutating operations (like assigning roles, creating channels, etc): perfect for testing! + +## โŒจ๏ธ Command List +| Command | Who | Description | +|---|---|---| +| `/register` | Anyone | Register as a player | +| `/me` | Player | Profile hub | +| `/campaigns` | Anyone | Browse all campaigns | +| `/search` | Anyone | Search campaigns by name | +| `/campaign` | Anyone | Show campaign details | +| `/newcampaign` | Player | Create a campaign (you become DM) | +| `/manage` | DM | Manage one of your campaigns (autocomplete) | +| `/newsession` | DM | Schedule and announce a session | +| `/nextsessions` | Player | View upcoming sessions | +| `/tokens` | Player | Browse and manage your tokens | +| `/newtoken` | Player | Create a player token | +| `/uploadcover` | DM | Upload campaign cover art | +| `/abandon` | DM | Archive your campaign permanently | +| `/help` | Anyone | List all commands | +| `/about` | Anyone | About the bot | +| `/admin` | Staff | Mod/admin panel (hidden from help) | +| `/ban` / `/unban` | Staff | Global player ban (hidden from help) | + +## ๐Ÿฐ Architecture + +- Written in **Go** (module: `github.com/framebuffers/moontracer`) +- Discord library: [discordgo](https://github.com/bwmarrin/discordgo) (bwmarrin). +- ORM: [uptrace/bun](https://github.com/uptrace/bun) with SQLite per-guild (modernc.org/sqlite). +- One SQLite database file per guild, stored in a bind-mounted /app/data directory. +- Embedded IANA timezone database (time/tzdata): no system tzdata dependency. +- Image processing: [disintegration/imaging](https://github.com/disintegration/imaging), [fogleman/gg](https://github.com/fogleman/gg). +- Built-in HTTP media server (Go stdlib net/http) for serving cover art and tokens. +- One-time opaque download endpoint (claim store, UUID, 10-min TTL). +- All Discord-mutating operations wrapped in a guard package that respects SAFE_MODE. +- Command registration uses ApplicationCommandBulkOverwrite โ€” stale commands auto-removed on startup. +- Global/guild command mode: + - guild ID set -> instant registration (dev); + - unset -> global (up to 1h propagation). + +## โš™๏ธ Configuration/Environment Variables + +| Variable | Required | Default | Description | +|---|---|---|---| +| `DISCORD_BOT_TOKEN` | Yes | โ€” | Bot token from Discord Developer Portal | +| `ADMIN_ROLE_NAME` | Yes | โ€” | Name of the admin/owner role on the server | +| `MOD_ROLE_NAME` | No | โ€” | Name of the moderator role (optional) | +| `DISCORD_GUILD_ID` | No | โ€” | Limit command registration to one guild (dev mode) | +| `MEDIA_PORT` | No | `8090` | Port for the built-in media server | +| `MEDIA_BASE_URL` | No | `http://localhost:8090/api/v1/cdn` | Public CDN root URL | +| `MEDIA_DOWNLOAD_URL` | No | `http://localhost:8090` | Public server root for `/dl/` download links | +| `SAFE_MODE` | No | โ€” | Disable Discord mutations (roles, DMs) | +| `DEV_MODE` | No | โ€” | Development mode flag | +| `DEBUG_ADMIN_ID` | No | โ€” | Developer override Discord user ID | +| `VERBOSE` | No | โ€” | Enable verbose logging with timestamps | + +## ๐Ÿ“ฆ Deployment + +- Docker: Dockerfile + docker-compose.yml provided. +- Designed to sit behind a reverse proxy (Caddy / Traefik / nginx) for TLS and rate limiting. +- Data volume: bind-mount a host directory to /app/data (contains all per-guild SQLite DBs and media files). +- No external services required: fully self-contained (SQLite, no Redis, no external DB). +- Docker Compose network: joins a named web_proxy network (configurable via `MOONTRACER_PROXY_NETWORK`) + +## ๐Ÿ›ก๏ธ Security Notes + +- Download links use opaque UUID tokens (10-minute TTL, one-time use): internal paths never exposed. +- Path traversal rejected on /dl/ endpoint. +- Staff-only commands hidden from /help listing. +- `SAFE_MODE` prevents accidental Discord mutations in staging environments. +- `ADMIN_ROLE_NAME` required at startup: bot refuses to start without it. +- Recommend fronting the media server with a reverse proxy (rate limiting, TLS). + +## ๐Ÿ—๏ธ Development Notes + +- Run with `DISCORD_GUILD_ID` set for instant command registration during development. +- `SAFE_MODE=true` lets you run against a real DB without sending Discord roles/DMs. +- `VERBOSE=true` enables microsecond-precision structured logs with file/line info. +- Tests: `go test ./...` (ban, token upload, admin visibility covered). +- Build: `go build ./...`: .silent output means success. + +## ๐Ÿ” Operation Security: how can you make sure this bot is not backdoored + +`DEBUG_ADMIN_ID` is an operator-controlled env var for development. + +In production (SAFE_MODE off), it requires the invoking user to also hold the configured admin Discord role: the env var alone grants **nothing**. +It is never hardcoded. Operators who don't need it can leave it unset. + +## ๐Ÿ“„ License + +[GNU Affero General Public License v3.0](LICENSE) + +You're **free** to self-host, modify, and redistribute; but any publicly-accessible network service built on this code **must also be open source under the same license.** + +## ๐Ÿค Contributing + +Issues and pull requests are welcome! Please open an issue first for significant changes, so we can discuss the approach before you invest time in it. -Moontracer can manage everything you need to organise your TTRPG experience online: manage campaigns, players, alerts, and more! +## ๐Ÿ’— Thanks! -## ๐Ÿ›ˆ What does it do? +Thanks to the folks at the r/Chile D&D Discord server for letting me test live on prod on their server, and giving me the idea and support to keep on going. Here onto more nat-d20's! -- \ No newline at end of file +๐Ÿบ awoo! ๐ŸŒ• \ No newline at end of file diff --git a/cmd/moontracer/main.go b/cmd/moontracer/main.go index cd2b7fe..601b413 100644 --- a/cmd/moontracer/main.go +++ b/cmd/moontracer/main.go @@ -7,9 +7,9 @@ import ( // NOTE: this embeds the IANA timezone database so LoadLocation works without system tzdata _ "time/tzdata" - "moontracer/internal/db" - "moontracer/internal/discord" - "moontracer/internal/mediaserver" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/discord" + "github.com/framebuffers/moontracer/internal/mediaserver" ) /* @@ -61,6 +61,11 @@ func main() { mediaBaseURL = "http://localhost:" + mediaPort + "/api/v1/cdn" } + downloadURL := os.Getenv("MEDIA_DOWNLOAD_URL") + if downloadURL == "" { + downloadURL = "http://localhost:" + mediaPort + } + mediaserver.SetDownloadBase(downloadURL) mediaserver.Serve(dbDir, ":"+mediaPort) mediaserver.Probe(":" + mediaPort) diff --git a/go.mod b/go.mod index 3bc12a1..66fa88f 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module moontracer +module github.com/framebuffers/moontracer go 1.25.6 diff --git a/internal/auth/approval_auth_test.go b/internal/auth/approval_auth_test.go index c447b8e..19abe2f 100644 --- a/internal/auth/approval_auth_test.go +++ b/internal/auth/approval_auth_test.go @@ -7,8 +7,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "moontracer/internal/manager/models" - "moontracer/internal/testutil" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/testutil" ) /* diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 0c47a60..5b73816 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -7,8 +7,8 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/db" - "moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/manager/models" ) /* @@ -87,7 +87,18 @@ func Authorize(database *bun.DB, userID string, required Scope, campaignID strin return player.IsMemberOf(campaignID), nil case ScopeDM: - return player.IsDMOf(campaignID), nil + if player.IsDMOf(campaignID) { + return true, nil + } + /* + Fallback: check Campaign.DungeonMaster directly. + Covers campaigns where the campaign_players row is missing (e.g. legacy data). + */ + campaign, err := db.GetByID[models.Campaign](database, campaignID) + if err != nil { + return false, nil + } + return campaign.DungeonMaster == userID, nil case ScopeMod: return player.IsMod(), nil diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 41283b5..226b10d 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -7,8 +7,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "moontracer/internal/manager/models" - "moontracer/internal/testutil" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/testutil" ) /* diff --git a/internal/auth/debug_admin_test.go b/internal/auth/debug_admin_test.go index 0ae765b..4deb8c6 100644 --- a/internal/auth/debug_admin_test.go +++ b/internal/auth/debug_admin_test.go @@ -7,9 +7,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "moontracer/internal/guard" - "moontracer/internal/manager/models" - "moontracer/internal/testutil" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/testutil" ) /* diff --git a/internal/auth/sync.go b/internal/auth/sync.go index 63147b8..1621295 100644 --- a/internal/auth/sync.go +++ b/internal/auth/sync.go @@ -7,8 +7,8 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/guard" - "moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/manager/models" ) /* diff --git a/internal/commands/abandon_campaign.go b/internal/commands/abandon_campaign.go index 6dd168d..a0f34d0 100644 --- a/internal/commands/abandon_campaign.go +++ b/internal/commands/abandon_campaign.go @@ -7,8 +7,8 @@ import ( "github.com/uptrace/bun" - "moontracer/internal/db" - "moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/manager/models" ) /* diff --git a/internal/commands/about.go b/internal/commands/about.go index 09970ac..ae2386d 100644 --- a/internal/commands/about.go +++ b/internal/commands/about.go @@ -6,8 +6,8 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/interactions/router" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/commands/admin.go b/internal/commands/admin.go index 94b130f..3cb84bd 100644 --- a/internal/commands/admin.go +++ b/internal/commands/admin.go @@ -22,11 +22,11 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/guard" - "moontracer/internal/interactions/helpers" - "moontracer/internal/interactions/router" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/messages" ) /* @@ -318,3 +318,5 @@ func diagBlock(s *discordgo.Session, guildDB *bun.DB, guildID string) string { return b.String() } + +func (a *adminCommand) Hidden() bool { return true } diff --git a/internal/commands/admin_visibility_test.go b/internal/commands/admin_visibility_test.go index 3ea2aa7..72d4c55 100644 --- a/internal/commands/admin_visibility_test.go +++ b/internal/commands/admin_visibility_test.go @@ -6,8 +6,8 @@ import ( "github.com/bwmarrin/discordgo" "github.com/stretchr/testify/assert" - "moontracer/internal/guard" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/commands/awoo.go b/internal/commands/awoo.go index 1897a10..cd863c6 100644 --- a/internal/commands/awoo.go +++ b/internal/commands/awoo.go @@ -8,8 +8,8 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/commands/ban.go b/internal/commands/ban.go index f065f68..21c2564 100644 --- a/internal/commands/ban.go +++ b/internal/commands/ban.go @@ -7,10 +7,10 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/db" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* @@ -148,3 +148,5 @@ func (r *banCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreat respond(s, i, fmt.Sprintf(messages.BanSuccessReasonMessage, targetUser.ID, reason)) } } + +func (r *banCommand) Hidden() bool { return true } diff --git a/internal/commands/ban_test.go b/internal/commands/ban_test.go index 56c605a..20b0379 100644 --- a/internal/commands/ban_test.go +++ b/internal/commands/ban_test.go @@ -9,11 +9,11 @@ import ( "github.com/stretchr/testify/require" "github.com/uptrace/bun" - "moontracer/internal/db" - "moontracer/internal/guard" - "moontracer/internal/manager/models" - "moontracer/internal/messages" - "moontracer/internal/testutil" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/testutil" ) /* diff --git a/internal/commands/campaign_upload.go b/internal/commands/campaign_upload.go index d5b365d..159acf8 100644 --- a/internal/commands/campaign_upload.go +++ b/internal/commands/campaign_upload.go @@ -12,11 +12,11 @@ import ( "github.com/google/uuid" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/db" - "moontracer/internal/manager/models" - "moontracer/internal/mediaserver" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/mediaserver" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/commands/campaigns.go b/internal/commands/campaigns.go index c722f08..02b5372 100644 --- a/internal/commands/campaigns.go +++ b/internal/commands/campaigns.go @@ -7,10 +7,10 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/db" - "moontracer/internal/interactions/router" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/commands/command.go b/internal/commands/command.go index c09b6c2..07de9aa 100644 --- a/internal/commands/command.go +++ b/internal/commands/command.go @@ -13,3 +13,12 @@ type AutocompleteCommand interface { Command Autocomplete(s *discordgo.Session, i *discordgo.InteractionCreate) } + +/* +HiddenCommand is an optional marker for commands that should be omitted from /help output. +Use for staff-only or low-signal commands that clutter the help list for regular users. +*/ +type HiddenCommand interface { + Command + Hidden() bool +} diff --git a/internal/commands/embed.go b/internal/commands/embed.go index d0c753c..3cb73e2 100644 --- a/internal/commands/embed.go +++ b/internal/commands/embed.go @@ -6,8 +6,8 @@ import ( "github.com/bwmarrin/discordgo" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* @@ -18,7 +18,7 @@ thumbnail (top-right). This is used to display the viewing player's character token. */ -func CampaignEmbed(c models.Campaign, players []models.CampaignPlayer, coverURL, viewerTokenURL string) *discordgo.MessageEmbed { +func CampaignEmbed(c models.Campaign, players []models.CampaignPlayer, coverURL, viewerTokenURL, callerID string) *discordgo.MessageEmbed { status := messages.ClosedStatusLabel if c.IsArchived { status = messages.ArchivedStatusLabel @@ -67,7 +67,7 @@ func CampaignEmbed(c models.Campaign, players []models.CampaignPlayer, coverURL, if c.Game.VTT != "" { fields = append(fields, &discordgo.MessageEmbedField{Name: "VTT", Value: c.Game.VTT, Inline: true}) } - if c.ChannelID != "" { + if c.ChannelID != "" && callerIsMember(callerID, c, players) { fields = append(fields, &discordgo.MessageEmbedField{Name: "Channel", Value: fmt.Sprintf("<#%s>", c.ChannelID), Inline: true}) } @@ -189,6 +189,22 @@ func CampaignButtons(callerID string, c models.Campaign, players []models.Campai return nil } +// callerIsMember returns true if callerID is the DM or an active player of the campaign. +func callerIsMember(callerID string, c models.Campaign, players []models.CampaignPlayer) bool { + if callerID == "" { + return false + } + if c.DungeonMaster == callerID { + return true + } + for _, p := range players { + if p.PlayerID == callerID && p.Status == models.StatusActive { + return true + } + } + return false +} + func formatEmbedLinks(c models.Campaign) string { var parts []string if c.VTTLink != "" { diff --git a/internal/commands/help.go b/internal/commands/help.go index abe0733..bef07c8 100644 --- a/internal/commands/help.go +++ b/internal/commands/help.go @@ -7,9 +7,9 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/dispatch" - "moontracer/internal/interactions/router" - msg "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/interactions/router" + msg "github.com/framebuffers/moontracer/internal/messages" ) /* @@ -65,6 +65,9 @@ func helpResponseData(db *bun.DB, d *dispatch.Dispatcher) *discordgo.Interaction var helpText strings.Builder helpText.WriteString("**Available Commands: **\n") for _, cmd := range commands { + if h, ok := cmd.(HiddenCommand); ok && h.Hidden() { + continue + } data := cmd.Data() fmt.Fprintf(&helpText, "**/%s** - %s\n", data.Name, data.Description) } diff --git a/internal/commands/manage.go b/internal/commands/manage.go new file mode 100644 index 0000000..5e15826 --- /dev/null +++ b/internal/commands/manage.go @@ -0,0 +1,135 @@ +package commands + +import ( + "context" + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/uptrace/bun" + + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" +) + +/* +Flow (slash command): + 1. DM types /manage and starts typing a campaign name. + 2. Autocomplete (below) filters to their active, approved, non-archived DM campaigns. + 3. DM selects a campaign; bot responds with the management hub (Players / Sessions / Settings rows). + 4. All subsequent button interactions are handled by the existing manage_* handlers in the interactions package + +Flow (autocomplete): + 1. Queries CampaignPlayer rows where role = DM for the invoking user. + 2. Filters to approved, non-archived campaigns matching the current search string. + 3. Returns up to 25 choices (name -> campaign ID). +*/ + +/* +manageCommand opens the campaign management hub for one of the DM's campaigns. +*/ + +type manageCommand struct { + db *bun.DB +} + +func (c *manageCommand) Data() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Name: messages.ManageCommandName, + Description: messages.ManageCommandDesc, + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: messages.ManageCommandOptionName, + Description: messages.ManageCommandOptionDesc, + Required: true, + Autocomplete: true, + }, + }, + } +} + +func (c *manageCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreate) { + userID := "" + if i.Member != nil { + userID = i.Member.User.ID + } else if i.User != nil { + userID = i.User.ID + } + + var campaignID string + for _, opt := range i.ApplicationCommandData().Options { + if opt.Name == messages.ManageCommandOptionName { + campaignID = opt.StringValue() + } + } + + campaign, err := db.GetByID[models.Campaign](c.db, campaignID) + if err != nil || campaign.DungeonMaster != userID || campaign.IsArchived { + respond(s, i, messages.ManageCampaignNotFound) + return + } + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: fmt.Sprintf(messages.ManageCampaignHeader, campaign.Name), + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + router.NavButton(messages.ManagePlayersLabel, discordgo.PrimaryButton, router.ViewManagePlayers, campaignID), + router.NavButton(messages.ManageSessionsLabel, discordgo.PrimaryButton, router.ViewManageSessions, campaignID), + router.NavButton(messages.ManageSettingsLabel, discordgo.PrimaryButton, router.ViewManageSettings, campaignID), + }}, + }, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} + +func (c *manageCommand) Autocomplete(s *discordgo.Session, i *discordgo.InteractionCreate) { + userID := "" + if i.Member != nil { + userID = i.Member.User.ID + } else if i.User != nil { + userID = i.User.ID + } + + var query string + for _, opt := range i.ApplicationCommandData().Options { + if opt.Name == messages.ManageCommandOptionName { + query = opt.StringValue() + } + } + + ctx := context.Background() + var players []models.CampaignPlayer + _ = c.db.NewSelect().Model(&players). + Relation("Campaign"). + Where("campaign_player.player_id = ? AND campaign_player.role = ?", userID, models.RoleDM). + Scan(ctx) + + queryLower := strings.ToLower(query) + var choices []*discordgo.ApplicationCommandOptionChoice + for _, p := range players { + if p.Campaign == nil || !p.Campaign.IsApproved || p.Campaign.IsArchived { + continue + } + if query != "" && !strings.Contains(strings.ToLower(p.Campaign.Name), queryLower) { + continue + } + choices = append(choices, &discordgo.ApplicationCommandOptionChoice{ + Name: p.Campaign.Name, + Value: p.Campaign.ID, + }) + if len(choices) >= 25 { + break + } + } + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionApplicationCommandAutocompleteResult, + Data: &discordgo.InteractionResponseData{Choices: choices}, + }) +} diff --git a/internal/commands/me.go b/internal/commands/me.go index 40036f2..ad73025 100644 --- a/internal/commands/me.go +++ b/internal/commands/me.go @@ -7,9 +7,9 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/interactions/router" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/commands/newcampaign.go b/internal/commands/newcampaign.go index 6d4da64..bd4b0c7 100644 --- a/internal/commands/newcampaign.go +++ b/internal/commands/newcampaign.go @@ -6,8 +6,8 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/messages" ) // newCampaignCommand opens the campaign creation modal directly as a slash command. diff --git a/internal/commands/newsession.go b/internal/commands/newsession.go new file mode 100644 index 0000000..96a1711 --- /dev/null +++ b/internal/commands/newsession.go @@ -0,0 +1,176 @@ +package commands + +import ( + "context" + "fmt" + "log" + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/uptrace/bun" + + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" +) + +/* +newSessionCommand opens the new-session modal for a DM's campaign. + +Flow: + 1. DM types /newsession and selects a campaign via autocomplete. + 2. Bot opens the shared new-session modal. + 3. DM fills in date, time, and optional notes, then submits. + 4. The newSessionModal handler (interactions package) creates the session record, + posts the channel announcement with RSVP buttons, and DMs all active members. +*/ +type newSessionCommand struct { + db *bun.DB +} + +func (c *newSessionCommand) Data() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Name: messages.NewSessionCommandName, + Description: messages.NewSessionCommandDesc, + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: messages.NewSessionOptionName, + Description: messages.NewSessionOptionDesc, + Required: true, + Autocomplete: true, + }, + }, + } +} + +func (c *newSessionCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreate) { + userID := "" + if i.Member != nil { + userID = i.Member.User.ID + } else if i.User != nil { + userID = i.User.ID + } + + ok, err := auth.Authorize(c.db, userID, auth.ScopePlayer, "") + if err != nil { + log.Printf("newsession: auth check failed: %v", err) + respond(s, i, messages.GenericErrorMessage) + return + } + if !ok { + respondNotRegistered(s, i) + return + } + + var campaignID string + for _, opt := range i.ApplicationCommandData().Options { + if opt.Name == messages.NewSessionOptionName { + campaignID = opt.StringValue() + } + } + + campaign, err := db.GetByID[models.Campaign](c.db, campaignID) + if err != nil || campaign.DungeonMaster != userID || campaign.IsArchived { + respond(s, i, messages.ManageCampaignNotFound) + return + } + + settings, _ := models.GetOrCreatePlayerSettings(c.db, userID) + loc := settings.Location() + timeLabel := fmt.Sprintf(messages.NewCampaignScheduleTimeLabelFmt, helpers.TZLabel(loc)) + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseModal, + Data: &discordgo.InteractionResponseData{ + CustomID: fmt.Sprintf("%s:%s", messages.NewSessionModalID, campaignID), + Title: messages.NewSessionModalTitle, + Components: NewSessionModalRows(timeLabel), + }, + }) +} + +func (c *newSessionCommand) Autocomplete(s *discordgo.Session, i *discordgo.InteractionCreate) { + userID := "" + if i.Member != nil { + userID = i.Member.User.ID + } else if i.User != nil { + userID = i.User.ID + } + + var query string + for _, opt := range i.ApplicationCommandData().Options { + if opt.Name == messages.NewSessionOptionName { + query = opt.StringValue() + } + } + + ctx := context.Background() + var players []models.CampaignPlayer + _ = c.db.NewSelect().Model(&players). + Relation("Campaign"). + Where("campaign_player.player_id = ? AND campaign_player.role = ?", userID, models.RoleDM). + Scan(ctx) + + queryLower := strings.ToLower(query) + var choices []*discordgo.ApplicationCommandOptionChoice + for _, p := range players { + if p.Campaign == nil || !p.Campaign.IsApproved || p.Campaign.IsArchived { + continue + } + if query != "" && !strings.Contains(strings.ToLower(p.Campaign.Name), queryLower) { + continue + } + choices = append(choices, &discordgo.ApplicationCommandOptionChoice{ + Name: p.Campaign.Name, + Value: p.Campaign.ID, + }) + if len(choices) >= 25 { + break + } + } + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionApplicationCommandAutocompleteResult, + Data: &discordgo.InteractionResponseData{Choices: choices}, + }) +} + +/* +NewSessionModalRows returns the standard modal component rows for session scheduling. +Exported so the manage-menu button handler can reuse it without duplication. +*/ +func NewSessionModalRows(timeLabel string) []discordgo.MessageComponent { + return []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: messages.NewSessionDateFieldID, + Label: messages.NewCampaignScheduleDateLabel, + Style: discordgo.TextInputShort, + Required: true, + Placeholder: messages.NewCampaignScheduleDatePlaceholder, + }, + }}, + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: messages.NewSessionTimeFieldID, + Label: timeLabel, + Style: discordgo.TextInputShort, + Required: true, + Placeholder: messages.NewCampaignScheduleTimePlaceholder, + }, + }}, + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: messages.NewSessionNotesFieldID, + Label: messages.NewSessionNotesLabel, + Style: discordgo.TextInputParagraph, + Required: false, + Placeholder: messages.NewSessionNotesPlaceholder, + MaxLength: 500, + }, + }}, + } +} diff --git a/internal/commands/nextsessions.go b/internal/commands/nextsessions.go new file mode 100644 index 0000000..7c65044 --- /dev/null +++ b/internal/commands/nextsessions.go @@ -0,0 +1,113 @@ +package commands + +import ( + "fmt" + "log" + "sort" + "strings" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/uptrace/bun" + + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" +) + +/* + +Flow: + 1. Player types /nextsessions. + 2. Bot loads all CampaignPlayer rows for the user, filters to active memberships in approved + campaigns with a future NextSession set, and sorts ascending by scheduled time. + 3. Responds ephemerally with a formatted list of upcoming sessions in the player's timezone. + If none are found, shows NextSessionsNone. +*/ + +/* +nextSessionsCommand shows the invoking player's upcoming sessions directly via slash command. + +The same data is also reachable via /me -> Next Sessions button (interactions/next_sessions.go). +This command provides a direct entry point without navigating the hub first. +*/ +type nextSessionsCommand struct { + db *bun.DB +} + +func (c *nextSessionsCommand) Data() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Name: messages.NextSessionsCommandName, + Description: messages.NextSessionsCommandDesc, + } +} + +func (c *nextSessionsCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreate) { + userID := "" + if i.Member != nil { + userID = i.Member.User.ID + } else if i.User != nil { + userID = i.User.ID + } + + entries, err := models.GetPlayerCampaigns(c.db, userID) + if err != nil { + log.Printf("nextsessions: load campaigns for %s: %v", userID, err) + respond(s, i, messages.GenericErrorMessage) + return + } + + now := time.Now().UTC() + + type upcoming struct { + Name string + When time.Time + } + var list []upcoming + for _, e := range entries { + if e.Status != models.StatusActive { + continue + } + if e.Campaign == nil || !e.Campaign.IsApproved { + continue + } + if e.Campaign.Schedule.NextSession.IsZero() || !e.Campaign.Schedule.NextSession.After(now) { + continue + } + list = append(list, upcoming{ + Name: e.Campaign.Name, + When: e.Campaign.Schedule.NextSession.UTC(), + }) + } + + if len(list) == 0 { + respond(s, i, messages.NextSessionsNone) + return + } + + sort.Slice(list, func(a, b int) bool { return list[a].When.Before(list[b].When) }) + + settings, err := models.GetOrCreatePlayerSettings(c.db, userID) + if err != nil { + log.Printf("nextsessions: load settings for %s: %v", userID, err) + } + loc := time.UTC + if settings != nil { + loc = settings.Location() + } + + var lines []string + for _, e := range list { + formatted := helpers.FormatInLocation(e.When, messages.SessionListFormat, loc) + " " + helpers.TZLabel(loc) + lines = append(lines, fmt.Sprintf("โ€ข **%s** โ€” %s ยท %s", e.Name, formatted, helpers.TimeRemaining(e.When))) + } + content := messages.NextSessionsHeader + "\n" + strings.Join(lines, "\n") + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: content, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} diff --git a/internal/commands/ping.go b/internal/commands/ping.go index 7364103..276902f 100644 --- a/internal/commands/ping.go +++ b/internal/commands/ping.go @@ -3,7 +3,7 @@ package commands import ( "github.com/bwmarrin/discordgo" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/messages" ) // pingCommand tests the connectivity between client and server. Responds with 'pong!' when successful. diff --git a/internal/commands/register.go b/internal/commands/register.go index 9afc814..03aec1f 100644 --- a/internal/commands/register.go +++ b/internal/commands/register.go @@ -4,11 +4,11 @@ import ( "fmt" "log" - "moontracer/internal/auth" - "moontracer/internal/db" - "moontracer/internal/interactions/router" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" diff --git a/internal/commands/registry.go b/internal/commands/registry.go index fa36fc9..772dfee 100644 --- a/internal/commands/registry.go +++ b/internal/commands/registry.go @@ -5,8 +5,8 @@ import ( "github.com/uptrace/bun" - "moontracer/internal/dispatch" - "moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/manager/models" ) /* @@ -51,11 +51,13 @@ Note: */ func All(db *bun.DB, d *dispatch.Dispatcher, dataDir, mediaBaseURL string) []Command { cmds := []Command{ - &pingCommand{}, &awooCommand{db: db}, &helpCommand{db: *db, d: d}, ®isterCommand{db: db}, &meCommand{db: db}, + &tokensCommand{db: db}, + &manageCommand{db: db}, + &nextSessionsCommand{db: db}, &campaignsCommand{db: db}, &searchCommand{db: db}, &banCommand{db: db}, @@ -64,6 +66,7 @@ func All(db *bun.DB, d *dispatch.Dispatcher, dataDir, mediaBaseURL string) []Com &aboutCommand{db: db}, &waosCommand{db: db}, &newCampaignCommand{db: db}, + &newSessionCommand{db: db}, &campaignUploadCommand{db: db, dataDir: dataDir, mediaBaseURL: mediaBaseURL}, &uploadTokenCommand{db: db, dataDir: dataDir, mediaBaseURL: mediaBaseURL}, } diff --git a/internal/commands/search.go b/internal/commands/search.go index 759db7c..83369c0 100644 --- a/internal/commands/search.go +++ b/internal/commands/search.go @@ -7,9 +7,9 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/db" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* @@ -64,7 +64,7 @@ func (c *searchCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCr userID := i.Member.User.ID coverURL := models.CoverURLForCampaign(c.db, campaign.ID) - embed := CampaignEmbed(*campaign, players, coverURL, "") + embed := CampaignEmbed(*campaign, players, coverURL, "", userID) buttons := CampaignButtons(userID, *campaign, players, "") resp := &discordgo.InteractionResponse{ diff --git a/internal/commands/tokens.go b/internal/commands/tokens.go new file mode 100644 index 0000000..9412319 --- /dev/null +++ b/internal/commands/tokens.go @@ -0,0 +1,117 @@ +package commands + +import ( + "context" + "log" + + "github.com/bwmarrin/discordgo" + "github.com/uptrace/bun" + + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" +) + +/* +Flow: + 1. Player types /tokens. + 2. Bot queries their Media rows (KindTokenPlayer), most-recent-first, up to 10. + 3. If none exist: ephemeral empty-state message with a Home button. + 4. Otherwise: ephemeral response with one embed per token (title + image) and a + select menu (token_gallery_select) below for managing individual tokens. + 5. All subsequent select / button interactions are handled by the existing + token gallery handlers in interactions/player_tokens.go. +*/ + +/* +tokensCommand opens the player's token gallery directly via slash command. + +Mirrors the gallery rendered by the /me hub -> Tokens button (interactions/player_tokens.go +RenderMeTokens), but uses InteractionResponseChannelMessageWithSource instead of +InteractionResponseUpdateMessage so it works as a standalone entry point. +*/ +type tokensCommand struct { + db *bun.DB +} + +func (c *tokensCommand) Data() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Name: messages.TokensCommandName, + Description: messages.TokensCommandDesc, + } +} + +func (c *tokensCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreate) { + userID := "" + if i.Member != nil { + userID = i.Member.User.ID + } else if i.User != nil { + userID = i.User.ID + } + + var tokens []*models.Media + if err := c.db.NewSelect().Model(&tokens). + Where("owner_id = ? AND kind = ?", userID, models.KindTokenPlayer). + OrderExpr("created_at DESC"). + Limit(10). + Scan(context.Background()); err != nil { + log.Printf("tokens: load for %s: %v", userID, err) + respond(s, i, messages.GenericErrorMessage) + return + } + + if len(tokens) == 0 { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: messages.TokenGalleryNone, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + router.NavButton(messages.HomeLabel, discordgo.DangerButton, router.ViewMe), + }}, + }, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + var embeds []*discordgo.MessageEmbed + var options []discordgo.SelectMenuOption + for _, t := range tokens { + name := t.Name + if name == "" { + name = t.ID[:8] + } + embeds = append(embeds, &discordgo.MessageEmbed{ + Title: name, + Color: messages.EmbedColor, + Image: &discordgo.MessageEmbedImage{URL: t.URL}, + }) + options = append(options, discordgo.SelectMenuOption{ + Label: name, + Value: t.ID, + }) + } + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: messages.TokenGalleryHeader, + Embeds: embeds, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.SelectMenu{ + CustomID: messages.TokenGallerySelectPrefix, + Placeholder: messages.TokenGallerySelectPlaceholder, + Options: options, + }, + }}, + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + router.NavButton(messages.HomeLabel, discordgo.DangerButton, router.ViewMe), + }}, + }, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} diff --git a/internal/commands/unban.go b/internal/commands/unban.go index 6074ffb..4ba1f80 100644 --- a/internal/commands/unban.go +++ b/internal/commands/unban.go @@ -7,10 +7,10 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/db" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* @@ -86,3 +86,5 @@ func (u *unbanCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCre log.Printf("unban: %s unbanned %s", invokerID, target.ID) respond(s, i, fmt.Sprintf(messages.UnbanSuccessMessage, targetUser.ID)) } + +func (u *unbanCommand) Hidden() bool { return true } diff --git a/internal/commands/upload_token.go b/internal/commands/upload_token.go index 374d505..eddd434 100644 --- a/internal/commands/upload_token.go +++ b/internal/commands/upload_token.go @@ -13,8 +13,8 @@ import ( "github.com/google/uuid" "github.com/uptrace/bun" - "moontracer/internal/mediaserver" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/mediaserver" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/commands/waos.go b/internal/commands/waos.go index 9d650f6..fbc7e36 100644 --- a/internal/commands/waos.go +++ b/internal/commands/waos.go @@ -5,12 +5,14 @@ import ( "fmt" "log" - "moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/manager/models" "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" ) +// blame ankhef for this one + type waosCommand struct { db *bun.DB } diff --git a/internal/db/approval_gate_test.go b/internal/db/approval_gate_test.go index c8325c3..ae824db 100644 --- a/internal/db/approval_gate_test.go +++ b/internal/db/approval_gate_test.go @@ -8,9 +8,9 @@ import ( "github.com/stretchr/testify/require" "github.com/uptrace/bun" - "moontracer/internal/db" - "moontracer/internal/manager/models" - "moontracer/internal/testutil" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/testutil" ) /* diff --git a/internal/db/crud.go b/internal/db/crud.go index fa7a42b..d427a34 100644 --- a/internal/db/crud.go +++ b/internal/db/crud.go @@ -6,7 +6,7 @@ import ( "github.com/uptrace/bun" - "moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/manager/models" ) func GetByID[T any](db *bun.DB, id string) (*T, error) { diff --git a/internal/db/migrate.go b/internal/db/migrate.go index 4d17784..0cd3408 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -7,7 +7,7 @@ import ( "github.com/uptrace/bun" - "moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/manager/models" ) // Migrate creates all tables if they don't already exist. @@ -22,6 +22,8 @@ func Migrate(db *bun.DB) error { (*models.CampaignPlayer)(nil), (*models.AuditEntry)(nil), (*models.PlayerSettings)(nil), + (*models.Session)(nil), + (*models.SessionRSVP)(nil), } for _, model := range tables { @@ -132,6 +134,30 @@ func Migrate(db *bun.DB) error { } } + // Unique index so the conflict-based seed below stays idempotent. + if _, err := db.ExecContext(ctx, + `CREATE UNIQUE INDEX IF NOT EXISTS sessions_campaign_scheduled_unique ON sessions (campaign_id, scheduled_at)`, + ); err != nil { + return err + } + + /* + Backfill one session row per approved, non-archived campaign that already has a + NextSession set and has no sessions in the new table yet. + */ + if _, err := db.ExecContext(ctx, ` + INSERT OR IGNORE INTO sessions (id, campaign_id, scheduled_at, title, channel_msg_id, capacity, alert_sent, status, created_at) + SELECT lower(hex(randomblob(16))), c.id, c.next_session, '', '', 0, c.alert_sent, 'upcoming', datetime('now') + FROM campaigns c + WHERE c.next_session IS NOT NULL + AND c.next_session > datetime('now') + AND c.is_approved = 1 + AND c.is_archived = 0 + AND NOT EXISTS (SELECT 1 FROM sessions s WHERE s.campaign_id = c.id) + `); err != nil { + return err + } + log.Println("database migration complete") return nil } diff --git a/internal/db/orphan_scrub_test.go b/internal/db/orphan_scrub_test.go index 1dad52a..7b382f8 100644 --- a/internal/db/orphan_scrub_test.go +++ b/internal/db/orphan_scrub_test.go @@ -11,7 +11,7 @@ import ( "github.com/uptrace/bun/dialect/sqlitedialect" "github.com/uptrace/bun/driver/sqliteshim" - "moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/manager/models" ) /* diff --git a/internal/discord/bot.go b/internal/discord/bot.go index 3cfc042..f250a8f 100644 --- a/internal/discord/bot.go +++ b/internal/discord/bot.go @@ -9,12 +9,12 @@ import ( "github.com/bwmarrin/discordgo" - "moontracer/internal/auth" - "moontracer/internal/commands" - "moontracer/internal/db" - "moontracer/internal/dispatch" - "moontracer/internal/guard" - "moontracer/internal/scheduler" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/commands" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/scheduler" ) /* @@ -212,10 +212,8 @@ func (b *Bot) registerCommands(appID string) error { } /* - When scoped to a single guild, clear any stale global registrations - left over from earlier runs. Otherwise Discord keeps serving the old - global commands in every server the bot is in, even though we are - only registering to the debug guild this run. + When scoped to a single guild (dev mode), clear any stale global registrations + left over from earlier runs so Discord doesn't serve them in other servers. */ if b.guildID != "" { existing, err := b.session.ApplicationCommands(appID, "") @@ -232,17 +230,30 @@ func (b *Bot) registerCommands(appID string) error { } } - for _, cmd := range commands.All(bunDB, b.dispatcher, b.dataDir, b.mediaBaseURL) { - created, err := b.session.ApplicationCommandCreate(appID, b.guildID, cmd.Data()) - if err != nil { - return err - } - b.registered = append(b.registered, created) - if b.guildID != "" { - log.Printf("bot: registered /%s (guild %s)", created.Name, b.guildID) - } else { - log.Printf("bot: registered /%s (global)", created.Name) - } + /* + BulkOverwrite atomically replaces all registered commands with the current list. + Any command previously registered but absent from All() is automatically removed. + + No manual cleanup needed when a command is retired. + */ + all := commands.All(bunDB, b.dispatcher, b.dataDir, b.mediaBaseURL) + cmdData := make([]*discordgo.ApplicationCommand, len(all)) + for i, cmd := range all { + cmdData[i] = cmd.Data() + } + + registered, err := b.session.ApplicationCommandBulkOverwrite(appID, b.guildID, cmdData) + if err != nil { + return err + } + b.registered = registered + + scope := "global" + if b.guildID != "" { + scope = "guild " + b.guildID + } + for _, cmd := range registered { + log.Printf("bot: registered /%s (%s)", cmd.Name, scope) } return nil } diff --git a/internal/discord/events.go b/internal/discord/events.go index f28373e..0ec5530 100644 --- a/internal/discord/events.go +++ b/internal/discord/events.go @@ -5,11 +5,11 @@ import ( "github.com/bwmarrin/discordgo" - "moontracer/internal/commands" - "moontracer/internal/db" - "moontracer/internal/manager/models" - "moontracer/internal/messages" - "moontracer/internal/scheduler" + "github.com/framebuffers/moontracer/internal/commands" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/scheduler" ) /* diff --git a/internal/discord/handler.go b/internal/discord/handler.go index 86a4daa..be17f78 100644 --- a/internal/discord/handler.go +++ b/internal/discord/handler.go @@ -8,12 +8,12 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/commands" - "moontracer/internal/db" - "moontracer/internal/dispatch" - "moontracer/internal/guard" - "moontracer/internal/interactions" - "moontracer/internal/scheduler" + "github.com/framebuffers/moontracer/internal/commands" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/interactions" + "github.com/framebuffers/moontracer/internal/scheduler" ) /* @@ -31,6 +31,10 @@ import ( /* NewHandler returns a discordgo event handler that resolves the guild's database per interaction, then dispatches slash commands, component interactions (buttons), and modal submissions. + +NOTE: + + discordgo (at leasy ) */ func NewHandler( guildDBM *db.GuildDBManager, @@ -41,6 +45,13 @@ func NewHandler( ) func(s *discordgo.Session, i *discordgo.InteractionCreate) { return func(s *discordgo.Session, i *discordgo.InteractionCreate) { + defer func() { + if r := recover(); r != nil { + log.Printf("handler: panic recovered: %v", r) + respondEphemeral(s, i, "An unexpected error occurred. Please try again.") + } + }() + guildID := i.GuildID /* diff --git a/internal/dispatch/dispatcher.go b/internal/dispatch/dispatcher.go index 1f38b0b..b78ffb5 100644 --- a/internal/dispatch/dispatcher.go +++ b/internal/dispatch/dispatcher.go @@ -8,7 +8,7 @@ import ( "github.com/bwmarrin/discordgo" - "moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/guard" ) type DirectMessage struct { diff --git a/internal/guard/guard.go b/internal/guard/guard.go index f9adcdd..46e6d7a 100644 --- a/internal/guard/guard.go +++ b/internal/guard/guard.go @@ -144,3 +144,41 @@ func ChannelMessageSend(s *discordgo.Session, channelID, content string) (*disco } return s.ChannelMessageSend(channelID, content) } + +// ChannelMessageSendComplex sends a rich message (embeds, components) to a channel, or logs in safe mode. +func ChannelMessageSendComplex(s *discordgo.Session, channelID string, data *discordgo.MessageSend) (*discordgo.Message, error) { + if SafeMode { + log.Printf("guard: [SAFE_MODE] would send complex message to channel %s", channelID) + return &discordgo.Message{ID: "safe-mode-message"}, nil + } + return s.ChannelMessageSendComplex(channelID, data) +} + +// ChannelMessagePin pins a message in a channel, or logs in safe mode. +func ChannelMessagePin(s *discordgo.Session, channelID, messageID string) error { + if SafeMode { + log.Printf("guard: [SAFE_MODE] would pin message %s in channel %s", messageID, channelID) + return nil + } + return s.ChannelMessagePin(channelID, messageID) +} + +// LockThread locks a thread so only members with Manage Threads can send messages in it, or logs in safe mode. +func LockThread(s *discordgo.Session, threadID string) error { + if SafeMode { + log.Printf("guard: [SAFE_MODE] would lock thread %s", threadID) + return nil + } + locked := true + _, err := s.ChannelEdit(threadID, &discordgo.ChannelEdit{Locked: &locked}) + return err +} + +// ChannelPermissionSet sets a permission overwrite on a channel, or logs in safe mode. +func ChannelPermissionSet(s *discordgo.Session, channelID, targetID string, targetType discordgo.PermissionOverwriteType, allow, deny int64) error { + if SafeMode { + log.Printf("guard: [SAFE_MODE] would set permission on channel %s target %s allow=%d deny=%d", channelID, targetID, allow, deny) + return nil + } + return s.ChannelPermissionSet(channelID, targetID, targetType, allow, deny) +} diff --git a/internal/guard/mode_split_test.go b/internal/guard/mode_split_test.go index cbaf52e..7c34c3a 100644 --- a/internal/guard/mode_split_test.go +++ b/internal/guard/mode_split_test.go @@ -5,7 +5,7 @@ import ( "github.com/stretchr/testify/assert" - "moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/guard" ) /* diff --git a/internal/interactions/admin_broadcast.go b/internal/interactions/admin_broadcast.go index d68c5f7..177cc97 100644 --- a/internal/interactions/admin_broadcast.go +++ b/internal/interactions/admin_broadcast.go @@ -21,13 +21,13 @@ import ( "github.com/google/uuid" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/db" - "moontracer/internal/dispatch" - "moontracer/internal/guard" - "moontracer/internal/interactions/helpers" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) type adminBroadcastHandler struct { diff --git a/internal/interactions/admin_campaigns.go b/internal/interactions/admin_campaigns.go index de97a3c..fd987df 100644 --- a/internal/interactions/admin_campaigns.go +++ b/internal/interactions/admin_campaigns.go @@ -20,12 +20,12 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/db" - "moontracer/internal/interactions/helpers" - "moontracer/internal/interactions/router" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) type adminCampaignsHandler struct { diff --git a/internal/interactions/admin_database.go b/internal/interactions/admin_database.go index 26b3688..a06f200 100644 --- a/internal/interactions/admin_database.go +++ b/internal/interactions/admin_database.go @@ -16,7 +16,7 @@ package interactions */ import ( - "moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/helpers" "fmt" "log" "strings" @@ -24,12 +24,12 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/db" - "moontracer/internal/guard" - "moontracer/internal/interactions/router" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) type adminDatabaseHandler struct { diff --git a/internal/interactions/admin_diag.go b/internal/interactions/admin_diag.go index 63dd2f1..4df39ff 100644 --- a/internal/interactions/admin_diag.go +++ b/internal/interactions/admin_diag.go @@ -11,16 +11,16 @@ package interactions */ import ( - "moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/helpers" "log" "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/commands" - "moontracer/internal/guard" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/commands" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/messages" ) type adminDiagHandler struct { diff --git a/internal/interactions/admin_settings.go b/internal/interactions/admin_settings.go index 9cc2333..8d193f1 100644 --- a/internal/interactions/admin_settings.go +++ b/internal/interactions/admin_settings.go @@ -19,10 +19,10 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/interactions/helpers" - "moontracer/internal/interactions/router" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/messages" ) type adminSettingsHandler struct { diff --git a/internal/interactions/back_handlers.go b/internal/interactions/back_handlers.go index 72ccea8..de56c5c 100644 --- a/internal/interactions/back_handlers.go +++ b/internal/interactions/back_handlers.go @@ -34,16 +34,16 @@ package interactions */ import ( - "moontracer/internal/interactions/helpers" "fmt" + "github.com/framebuffers/moontracer/internal/interactions/helpers" "strings" "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/interactions/router" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) // RenderMyCampaignsList renders the player's own campaigns as a select menu. @@ -106,13 +106,46 @@ func RenderManageList(s *discordgo.Session, i *discordgo.InteractionCreate, db * return } - selectMenu := BuildPlayerCampaignSelect(dmEntries, messages.ManageSelectPrefix, messages.ManageCampaignsPlaceholder) + // Build select including pending campaigns so DMs can enter and delete them. + var options []discordgo.SelectMenuOption + for _, e := range dmEntries { + if e.Campaign == nil || len(options) >= 25 { + continue + } + label := e.Campaign.Name + desc := "DM" + if !e.Campaign.IsApproved { + desc = "Pending approval โ€” enter to cancel" + } else if e.Campaign.IsArchived { + desc = "Archived" + } + options = append(options, discordgo.SelectMenuOption{ + Label: truncate(label, 100), + Value: e.CampaignID, + Description: truncate(desc, 100), + }) + } + if len(options) == 0 { + options = []discordgo.SelectMenuOption{{Label: "No campaigns", Value: "none", Default: true}} + } + selectMenu := discordgo.SelectMenu{ + CustomID: messages.ManageSelectPrefix, + Placeholder: messages.ManageCampaignsPlaceholder, + Options: options, + } var lines []string for _, e := range dmEntries { - if e.Campaign != nil { - lines = append(lines, fmt.Sprintf(messages.ManageCampaignListLine, e.Campaign.Name, e.Status)) + if e.Campaign == nil { + continue } + status := "Active" + if !e.Campaign.IsApproved { + status = "Pending" + } else if e.Campaign.IsArchived { + status = "Archived" + } + lines = append(lines, fmt.Sprintf(messages.ManageCampaignListLine, e.Campaign.Name, status)) } content := messages.ManageCampaignsListHeader + strings.Join(lines, "\n") @@ -128,4 +161,3 @@ func RenderManageList(s *discordgo.Session, i *discordgo.InteractionCreate, db * }}, }) } - diff --git a/internal/interactions/campaign_announce.go b/internal/interactions/campaign_announce.go index 9f196da..0e516ac 100644 --- a/internal/interactions/campaign_announce.go +++ b/internal/interactions/campaign_announce.go @@ -3,18 +3,18 @@ package interactions import ( "fmt" "log" - "moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/helpers" "strings" "github.com/bwmarrin/discordgo" "github.com/google/uuid" "github.com/uptrace/bun" - "moontracer/internal/db" - "moontracer/internal/dispatch" - "moontracer/internal/guard" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/interactions/campaign_approve.go b/internal/interactions/campaign_approve.go index 9123082..09f9495 100644 --- a/internal/interactions/campaign_approve.go +++ b/internal/interactions/campaign_approve.go @@ -1,19 +1,19 @@ package interactions import ( - "moontracer/internal/interactions/helpers" "context" "fmt" "log" + "github.com/framebuffers/moontracer/internal/interactions/helpers" "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/db" - "moontracer/internal/dispatch" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* @@ -64,6 +64,10 @@ func (c *campaignApprove) HandleComponents(s *discordgo.Session, i *discordgo.In return } + /* + Auth + DB load are fast; handle errors here before the defer so we can still + call InteractionRespond without conflicting with a deferred response. + */ campaign, ok := helpers.LoadModCampaign(s, i, c.db, campaignID) if !ok { return @@ -76,27 +80,40 @@ func (c *campaignApprove) HandleComponents(s *discordgo.Session, i *discordgo.In return } - createCampaignChannels(s, guildID, campaign) - if err := db.Update(c.db, campaign); err != nil { - log.Printf("campaign_approve: failed to save channel IDs for %s: %v", campaignID, err) + /* + Create the campaign role now (fast, single API call) so players can be assigned it on join. + Channel setup is deferred to the DM: they pick new or existing from campaign settings. + */ + if err := EnsureCampaignRole(s, guildID, campaign); err != nil { + log.Printf("campaign_approve: role for %s: %v", campaignID, err) + } else if err := db.Update(c.db, campaign); err != nil { + log.Printf("campaign_approve: save role for %s: %v", campaignID, err) } - msgApproved := &dispatch.DirectMessage{ + var senderID string + if i.User != nil { + senderID = i.User.ID + } else if i.Member != nil { + senderID = i.Member.User.ID + } + c.dispatcher.Push(dispatch.DirectMessage{ ID: campaignID, - Sender: i.User.ID, + Sender: senderID, Target: campaign.DungeonMaster, Content: fmt.Sprintf(messages.CampaignApprovedDMMessage, campaign.Name), - } - - c.dispatcher.Push(*msgApproved) + }) + content := fmt.Sprintf(messages.CampaignApprovedStatusMessage, campaign.Name) + empty := []discordgo.MessageComponent{} + // Use InteractionResponseUpdateMessage (sync) since we no longer make slow Discord API calls. s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseUpdateMessage, Data: &discordgo.InteractionResponseData{ - Content: fmt.Sprintf(messages.CampaignApprovedStatusMessage, campaign.Name), + Content: content, Components: []discordgo.MessageComponent{}, }, }) + _ = empty } /* @@ -238,5 +255,5 @@ func (m *campaignDenyModal) HandleModal(s *discordgo.Session, i *discordgo.Inter Content: fmt.Sprintf(messages.CampaignDeniedDMMessage, campaign.Name, reason), }) - helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.CampaignDeniedMessage, campaign.Name)) + helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.CampaignDeniedStatusMessage, campaign.Name, reason)) } diff --git a/internal/interactions/campaign_approve_test.go b/internal/interactions/campaign_approve_test.go index 030c54e..7909b59 100644 --- a/internal/interactions/campaign_approve_test.go +++ b/internal/interactions/campaign_approve_test.go @@ -10,12 +10,12 @@ import ( "github.com/stretchr/testify/require" "github.com/uptrace/bun" - "moontracer/internal/db" - "moontracer/internal/dispatch" - "moontracer/internal/guard" - "moontracer/internal/manager/models" - "moontracer/internal/messages" - "moontracer/internal/testutil" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/testutil" ) /* diff --git a/internal/interactions/campaign_detail.go b/internal/interactions/campaign_detail.go index cb5aa10..e995522 100644 --- a/internal/interactions/campaign_detail.go +++ b/internal/interactions/campaign_detail.go @@ -6,12 +6,12 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/commands" - "moontracer/internal/db" - "moontracer/internal/interactions/helpers" - "moontracer/internal/interactions/router" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/commands" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* @@ -77,7 +77,7 @@ func RenderCampaignDetail(s *discordgo.Session, i *discordgo.InteractionCreate, } } - embed := commands.CampaignEmbed(*campaign, players, coverURL, viewerTokenURL) + embed := commands.CampaignEmbed(*campaign, players, coverURL, viewerTokenURL, userID) if campaign.IsArchived { embed.Footer = &discordgo.MessageEmbedFooter{Text: messages.CampaignArchivedFooter} } diff --git a/internal/interactions/campaign_invite.go b/internal/interactions/campaign_invite.go index bbe912b..d8c1e2a 100644 --- a/internal/interactions/campaign_invite.go +++ b/internal/interactions/campaign_invite.go @@ -20,18 +20,18 @@ package interactions import ( "fmt" "log" - "moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/helpers" "github.com/bwmarrin/discordgo" "github.com/google/uuid" "github.com/uptrace/bun" - "moontracer/internal/db" - "moontracer/internal/dispatch" - "moontracer/internal/guard" - "moontracer/internal/interactions/router" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) // manageCampaignInvite shows a user select menu for the DM to pick a player to invite. diff --git a/internal/interactions/campaign_invite_test.go b/internal/interactions/campaign_invite_test.go index 37a9ba9..9b4ccbf 100644 --- a/internal/interactions/campaign_invite_test.go +++ b/internal/interactions/campaign_invite_test.go @@ -9,11 +9,11 @@ import ( "github.com/stretchr/testify/require" "github.com/uptrace/bun" - "moontracer/internal/dispatch" - "moontracer/internal/guard" - "moontracer/internal/manager/models" - "moontracer/internal/messages" - "moontracer/internal/testutil" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/testutil" ) /* diff --git a/internal/interactions/campaign_join.go b/internal/interactions/campaign_join.go index 12410f4..54577d5 100644 --- a/internal/interactions/campaign_join.go +++ b/internal/interactions/campaign_join.go @@ -3,18 +3,19 @@ package interactions import ( "fmt" "log" - "moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/helpers" "github.com/bwmarrin/discordgo" "github.com/google/uuid" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/db" - "moontracer/internal/dispatch" - "moontracer/internal/guard" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* @@ -174,5 +175,10 @@ func (h *campaignJoin) HandleComponents(s *discordgo.Session, i *discordgo.Inter return } - helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.PlayerJoinedCampaignMessage, campaign.Name)) + helpers.RespondUpdate(s, i, fmt.Sprintf(messages.PlayerJoinedCampaignMessage, campaign.Name), []*discordgo.MessageEmbed{}, []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + router.NavButton(messages.BrowseMoreLabel, discordgo.SecondaryButton, router.ViewCampaignsBrowse), + router.NavButton(messages.HomeLabel, discordgo.DangerButton, router.ViewMe), + }}, + }) } diff --git a/internal/interactions/campaign_join_test.go b/internal/interactions/campaign_join_test.go index a0a9b1f..029c0b3 100644 --- a/internal/interactions/campaign_join_test.go +++ b/internal/interactions/campaign_join_test.go @@ -11,10 +11,10 @@ import ( "github.com/stretchr/testify/require" "github.com/uptrace/bun" - "moontracer/internal/dispatch" - "moontracer/internal/guard" - "moontracer/internal/manager/models" - "moontracer/internal/testutil" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/testutil" ) /* diff --git a/internal/interactions/campaign_leave.go b/internal/interactions/campaign_leave.go index 52000bd..7dd8b57 100644 --- a/internal/interactions/campaign_leave.go +++ b/internal/interactions/campaign_leave.go @@ -1,18 +1,18 @@ package interactions import ( - "moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/helpers" "fmt" "log" "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/db" - "moontracer/internal/guard" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/interactions/campaign_reschedule.go b/internal/interactions/campaign_reschedule.go index 4bc2d72..b2dfeb6 100644 --- a/internal/interactions/campaign_reschedule.go +++ b/internal/interactions/campaign_reschedule.go @@ -3,17 +3,17 @@ package interactions import ( "fmt" "log" - "moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/helpers" "strconv" "strings" "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/db" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/interactions/campaign_threads.go b/internal/interactions/campaign_threads.go index 4d3c29e..2ae6409 100644 --- a/internal/interactions/campaign_threads.go +++ b/internal/interactions/campaign_threads.go @@ -10,97 +10,175 @@ package interactions import ( "fmt" "log" + "os" "strings" "github.com/bwmarrin/discordgo" - "moontracer/internal/guard" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) // defaultArchiveDuration is the auto-archive duration for new threads, in minutes (1 week). const defaultArchiveDuration = 10080 // standardThreads are the threads auto-created in every approved campaign's channel. -var standardThreads = []string{"announcements", "sessions", "dice-rolls", "general"} +var standardThreads = []string{"welcome", "announcements", "sessions", "dice-rolls", "general"} -/* -createCampaignChannels creates a role, a private text channel, and standard threads for a campaign, -grouped under a shared "Campaigns" category (found or created). +// threadInitMessages is the pinned welcome message sent to each standard thread on creation. +var threadInitMessages = map[string]string{ + "announcements": messages.ThreadInitMsgAnnouncements, + "sessions": messages.ThreadInitMsgSessions, + "dice-rolls": messages.ThreadInitMsgDiceRolls, + "general": messages.ThreadInitMsgGeneral, +} -The channel is visible only to members of the campaign role (@everyone is denied ViewChannel). -The DM is immediately assigned the role. +/* +EnsureCampaignRole creates the campaign's Discord role (named after the tag) and assigns it +to the DM. Idempotent: if RoleID is already set it skips creation. -On success, mutates the campaign in-place with RoleID, CategoryID, ChannelID, and AnnouncementsThreadID. -Errors are logged but non-fatal โ€” partial setup is better than none. +Exported so the approval handler can call it independently of channel setup. */ -func createCampaignChannels(s *discordgo.Session, guildID string, c *models.Campaign) { +func EnsureCampaignRole(s *discordgo.Session, guildID string, c *models.Campaign) error { + if c.RoleID != "" { + return nil + } channelName := c.Tag if channelName == "" { channelName = models.NormalizeTag(c.Name) } - - role, err := guard.GuildRoleCreate(s, guildID, &discordgo.RoleParams{ - Name: channelName, - }) + role, err := guard.GuildRoleCreate(s, guildID, &discordgo.RoleParams{Name: channelName}) if err != nil { - log.Printf("campaign_threads: failed to create role for %s: %v", c.ID, err) - return + return fmt.Errorf("create role: %w", err) } c.RoleID = role.ID - if err := guard.GuildMemberRoleAdd(s, guildID, c.DungeonMaster, role.ID); err != nil { - log.Printf("campaign_threads: failed to assign role to DM for %s: %v", c.ID, err) + log.Printf("campaign_threads: assign role to DM %s: %v", c.DungeonMaster, err) + } + return nil +} + +/* +SetupNewChannel creates a private text channel for the campaign under the shared Campaigns +category, then creates the standard threads inside it. + +Mutates campaign in-place with CategoryID, ChannelID, AnnouncementsThreadID. +*/ +func SetupNewChannel(s *discordgo.Session, guildID string, c *models.Campaign) error { + channelName := c.Tag + if channelName == "" { + channelName = models.NormalizeTag(c.Name) + } + + // Ensure role exists before setting channel permissions. + if err := EnsureCampaignRole(s, guildID, c); err != nil { + log.Printf("campaign_threads: role for new channel: %v", err) } categoryID, err := findOrCreateCampaignsCategory(s, guildID) if err != nil { - log.Printf("campaign_threads: failed to resolve campaigns category: %v", err) - return + return fmt.Errorf("resolve category: %w", err) } c.CategoryID = categoryID - ch, err := guard.GuildChannelCreateComplex(s, guildID, discordgo.GuildChannelCreateData{ - Name: channelName, - Type: discordgo.ChannelTypeGuildText, - ParentID: categoryID, - PermissionOverwrites: []*discordgo.PermissionOverwrite{ - { - // Deny @everyone view access (everyone role ID == guild ID in Discord). - ID: guildID, - Type: discordgo.PermissionOverwriteTypeRole, - Deny: discordgo.PermissionViewChannel, - }, - { - ID: role.ID, - Type: discordgo.PermissionOverwriteTypeRole, - Allow: discordgo.PermissionViewChannel, - }, - { - // Bot must explicitly retain access to the channel it just made so it can create threads. - ID: s.State.User.ID, - Type: discordgo.PermissionOverwriteTypeMember, - Allow: discordgo.PermissionViewChannel | discordgo.PermissionManageThreads | discordgo.PermissionSendMessages, - }, + overwrites := []*discordgo.PermissionOverwrite{ + { + ID: guildID, + Type: discordgo.PermissionOverwriteTypeRole, + Deny: discordgo.PermissionViewChannel, + }, + { + ID: s.State.User.ID, + Type: discordgo.PermissionOverwriteTypeMember, + Allow: discordgo.PermissionViewChannel | discordgo.PermissionManageThreads | discordgo.PermissionSendMessages, }, + } + if c.RoleID != "" { + overwrites = append(overwrites, &discordgo.PermissionOverwrite{ + ID: c.RoleID, + Type: discordgo.PermissionOverwriteTypeRole, + Allow: discordgo.PermissionViewChannel, + }) + } + + ch, err := guard.GuildChannelCreateComplex(s, guildID, discordgo.GuildChannelCreateData{ + Name: channelName, + Type: discordgo.ChannelTypeGuildText, + ParentID: categoryID, + PermissionOverwrites: overwrites, }) if err != nil { - log.Printf("campaign_threads: failed to create channel for %s: %v", c.ID, err) - return + return fmt.Errorf("create channel: %w", err) } c.ChannelID = ch.ID + createStandardThreads(s, c, ch.ID, channelName) + return nil +} + +/* +SetupExistingChannel links the campaign to an already-existing Discord channel and creates +the standard threads inside it. +Mutates campaign in-place with ChannelID and AnnouncementsThreadID. +*/ +func SetupExistingChannel(s *discordgo.Session, c *models.Campaign, channelID string) { + channelName := c.Tag + if channelName == "" { + channelName = models.NormalizeTag(c.Name) + } + c.ChannelID = channelID + createStandardThreads(s, c, channelID, channelName) +} + +// createStandardThreads creates the standard threads in channelID and sets AnnouncementsThreadID. +func createStandardThreads(s *discordgo.Session, c *models.Campaign, channelID, channelName string) { for _, name := range standardThreads { threadName := fmt.Sprintf("%s-%s", channelName, name) - thread, err := guard.ThreadStart(s, ch.ID, threadName, defaultArchiveDuration) + thread, err := guard.ThreadStart(s, channelID, threadName, defaultArchiveDuration) if err != nil { - log.Printf("campaign_threads: failed to create thread %s: %v", threadName, err) + log.Printf("campaign_threads: create thread %s: %v", threadName, err) continue } if name == "announcements" { c.AnnouncementsThreadID = thread.ID } + + // Resolve init message: welcome uses the campaign name; others use the static map. + var initMsg string + if name == "welcome" { + initMsg = fmt.Sprintf(messages.ThreadInitMsgWelcomeFmt, c.Name) + } else if msg, ok := threadInitMessages[name]; ok { + initMsg = msg + } + + if initMsg != "" { + msg, err := guard.ChannelMessageSend(s, thread.ID, initMsg) + if err != nil { + log.Printf("campaign_threads: send init message to %s: %v", threadName, err) + continue + } + if err := guard.ChannelMessagePin(s, thread.ID, msg.ID); err != nil { + log.Printf("campaign_threads: pin init message in %s: %v", threadName, err) + } + if name == "welcome" { + if err := guard.LockThread(s, thread.ID); err != nil { + log.Printf("campaign_threads: lock welcome thread %s: %v", threadName, err) + } + } + } + } +} + +// createCampaignChannels is kept for backward compatibility with any remaining call sites. +// New code should call EnsureCampaignRole + SetupNewChannel separately. +func createCampaignChannels(s *discordgo.Session, guildID string, c *models.Campaign) { + if err := EnsureCampaignRole(s, guildID, c); err != nil { + log.Printf("campaign_threads: %v", err) + return + } + if err := SetupNewChannel(s, guildID, c); err != nil { + log.Printf("campaign_threads: %v", err) } } @@ -128,3 +206,62 @@ func findOrCreateCampaignsCategory(s *discordgo.Session, guildID string) (string } return cat.ID, nil } + +/* +RetireChannel locks down a campaign's Discord channel when the campaign is archived or deleted. + +Permission changes applied to the channel: + - @everyone: deny ViewChannel + - Campaign role (if set): deny ViewChannel + - Mod/admin Discord roles (from ADMIN_ROLE_NAME / MOD_ROLE_NAME env): allow ViewChannel + +This makes the channel invisible to regular members while keeping it accessible to staff. +Errors are logged but non-fatal; the DB operation that triggered retirement already succeeded. +*/ +func RetireChannel(s *discordgo.Session, guildID string, campaign *models.Campaign) { + channelID := campaign.ChannelID + if channelID == "" { + return + } + + // Resolve mod/admin Discord role IDs from env-configured names. + adminRoleName := os.Getenv("ADMIN_ROLE_NAME") + modRoleName := os.Getenv("MOD_ROLE_NAME") + + var staffRoleIDs []string + if adminRoleName != "" || modRoleName != "" { + roles, err := s.GuildRoles(guildID) + if err != nil { + log.Printf("campaign_threads: retire channel %s: fetch roles: %v", channelID, err) + } else { + for _, r := range roles { + if (adminRoleName != "" && strings.EqualFold(r.Name, adminRoleName)) || + (modRoleName != "" && strings.EqualFold(r.Name, modRoleName)) { + staffRoleIDs = append(staffRoleIDs, r.ID) + } + } + } + } + + // Deny @everyone. + if err := guard.ChannelPermissionSet(s, channelID, guildID, + discordgo.PermissionOverwriteTypeRole, 0, discordgo.PermissionViewChannel); err != nil { + log.Printf("campaign_threads: retire channel %s: deny everyone: %v", channelID, err) + } + + // Deny campaign role. + if campaign.RoleID != "" { + if err := guard.ChannelPermissionSet(s, channelID, campaign.RoleID, + discordgo.PermissionOverwriteTypeRole, 0, discordgo.PermissionViewChannel); err != nil { + log.Printf("campaign_threads: retire channel %s: deny campaign role: %v", channelID, err) + } + } + + // Allow staff roles. + for _, roleID := range staffRoleIDs { + if err := guard.ChannelPermissionSet(s, channelID, roleID, + discordgo.PermissionOverwriteTypeRole, discordgo.PermissionViewChannel, 0); err != nil { + log.Printf("campaign_threads: retire channel %s: allow staff role %s: %v", channelID, roleID, err) + } + } +} diff --git a/internal/interactions/campaign_toggle.go b/internal/interactions/campaign_toggle.go index 6bf3512..4fd5405 100644 --- a/internal/interactions/campaign_toggle.go +++ b/internal/interactions/campaign_toggle.go @@ -7,11 +7,11 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/db" - "moontracer/internal/interactions/helpers" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/interactions/campaign_view.go b/internal/interactions/campaign_view.go index 1dd9119..2e0c935 100644 --- a/internal/interactions/campaign_view.go +++ b/internal/interactions/campaign_view.go @@ -8,11 +8,11 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/db" - "moontracer/internal/interactions/helpers" - "moontracer/internal/interactions/router" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/interactions/campaigns_browse.go b/internal/interactions/campaigns_browse.go index 7a83c25..fbfff69 100644 --- a/internal/interactions/campaigns_browse.go +++ b/internal/interactions/campaigns_browse.go @@ -1,16 +1,16 @@ package interactions import ( - "moontracer/internal/interactions/helpers" - "moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/router" "log" "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/db" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/interactions/components.go b/internal/interactions/components.go index 0502501..20fb543 100644 --- a/internal/interactions/components.go +++ b/internal/interactions/components.go @@ -5,7 +5,7 @@ import ( "github.com/bwmarrin/discordgo" - "moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/manager/models" ) /* diff --git a/internal/interactions/helpers/customid.go b/internal/interactions/helpers/customid.go index b3af956..18202e1 100644 --- a/internal/interactions/helpers/customid.go +++ b/internal/interactions/helpers/customid.go @@ -5,7 +5,7 @@ import ( "github.com/bwmarrin/discordgo" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/interactions/helpers/loadcampaign.go b/internal/interactions/helpers/loadcampaign.go index caf1be6..926d21f 100644 --- a/internal/interactions/helpers/loadcampaign.go +++ b/internal/interactions/helpers/loadcampaign.go @@ -4,10 +4,10 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/db" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/interactions/helpers/responses.go b/internal/interactions/helpers/responses.go index fcece5f..a829204 100644 --- a/internal/interactions/helpers/responses.go +++ b/internal/interactions/helpers/responses.go @@ -3,8 +3,8 @@ package helpers import ( "github.com/bwmarrin/discordgo" - "moontracer/internal/interactions/router" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/interactions/manage_archive_test.go b/internal/interactions/manage_archive_test.go index 4ac2ba1..5523c70 100644 --- a/internal/interactions/manage_archive_test.go +++ b/internal/interactions/manage_archive_test.go @@ -9,11 +9,11 @@ import ( "github.com/stretchr/testify/require" "github.com/uptrace/bun" - "moontracer/internal/db" - "moontracer/internal/guard" - "moontracer/internal/manager/models" - "moontracer/internal/messages" - "moontracer/internal/testutil" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/testutil" ) /* diff --git a/internal/interactions/manage_campaign.go b/internal/interactions/manage_campaign.go index 9d49123..c20ecd0 100644 --- a/internal/interactions/manage_campaign.go +++ b/internal/interactions/manage_campaign.go @@ -12,18 +12,18 @@ import ( "context" "fmt" "log" - "moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/helpers" "strings" "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/db" - "moontracer/internal/guard" - "moontracer/internal/interactions/router" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* @@ -81,6 +81,25 @@ func RenderManageCampaignMenu(s *discordgo.Session, i *discordgo.InteractionCrea return } + // Pending campaigns are awaiting staff approval โ€” only show the danger zone. + if !campaign.IsApproved { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseUpdateMessage, + Data: &discordgo.InteractionResponseData{ + Content: fmt.Sprintf("โณ **%s** is awaiting staff approval. You can cancel it below.", campaign.Name), + Embeds: []*discordgo.MessageEmbed{}, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + router.NavButton(messages.ManageSettingsLabel, discordgo.PrimaryButton, router.ViewManageSettings, campaignID), + }}, + helpers.BackRow(router.ViewManage), + }, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseUpdateMessage, Data: &discordgo.InteractionResponseData{ @@ -198,6 +217,7 @@ func (h *manageDeleteConfirm) HandleComponents(s *discordgo.Session, i *discordg return } + RetireChannel(s, i.GuildID, campaign) log.Printf("manage_delete_confirm: %s deleted campaign %s (%s)", userID, campaign.Name, campaignID) helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.ManageDeleteSuccess, campaign.Name)) } diff --git a/internal/interactions/manage_links.go b/internal/interactions/manage_links.go index 7472dd3..39298f5 100644 --- a/internal/interactions/manage_links.go +++ b/internal/interactions/manage_links.go @@ -8,9 +8,9 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/db" - "moontracer/internal/interactions/helpers" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/interactions/manage_newcampaign_button.go b/internal/interactions/manage_newcampaign_button.go index 2524620..8e3cb77 100644 --- a/internal/interactions/manage_newcampaign_button.go +++ b/internal/interactions/manage_newcampaign_button.go @@ -11,14 +11,14 @@ package interactions */ import ( - "moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/helpers" "log" "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/messages" ) type manageNewCampaignButton struct { diff --git a/internal/interactions/manage_role_archive.go b/internal/interactions/manage_role_archive.go index 6265c20..7b966c6 100644 --- a/internal/interactions/manage_role_archive.go +++ b/internal/interactions/manage_role_archive.go @@ -16,19 +16,19 @@ package interactions import ( "fmt" "log" - "moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/helpers" "strings" "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/commands" - "moontracer/internal/db" - "moontracer/internal/guard" - "moontracer/internal/interactions/router" - "moontracer/internal/manager/models" - "moontracer/internal/messages" - "moontracer/internal/scheduler" + "github.com/framebuffers/moontracer/internal/commands" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/scheduler" ) /* @@ -237,6 +237,7 @@ func (h *manageArchiveConfirm) HandleComponents(s *discordgo.Session, i *discord return } h.sched.Cancel(i.GuildID, campaign.ID) + RetireChannel(s, i.GuildID, campaign) if err := models.InsertAuditEntry(h.db, userID, userID, models.AuditCampaignArchive, fmt.Sprintf("archived campaign %s (%s) via manage menu", campaign.Name, campaign.Tag)); err != nil { log.Printf("manage_archive: failed to write audit entry: %v", err) diff --git a/internal/interactions/manage_set_session.go b/internal/interactions/manage_set_session.go index 9397ab5..4ddfb0f 100644 --- a/internal/interactions/manage_set_session.go +++ b/internal/interactions/manage_set_session.go @@ -25,12 +25,12 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/db" - "moontracer/internal/guard" - "moontracer/internal/interactions/helpers" - "moontracer/internal/manager/models" - "moontracer/internal/messages" - "moontracer/internal/scheduler" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/scheduler" ) type manageSetSession struct { diff --git a/internal/interactions/manage_setcover.go b/internal/interactions/manage_setcover.go index dc1c162..22201c6 100644 --- a/internal/interactions/manage_setcover.go +++ b/internal/interactions/manage_setcover.go @@ -4,9 +4,9 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/interactions/helpers" - "moontracer/internal/interactions/router" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/interactions/manage_submenus.go b/internal/interactions/manage_submenus.go index 9d41a91..5e9499c 100644 --- a/internal/interactions/manage_submenus.go +++ b/internal/interactions/manage_submenus.go @@ -9,12 +9,12 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/db" - "moontracer/internal/interactions/helpers" - "moontracer/internal/interactions/router" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* @@ -105,27 +105,19 @@ func RenderManagePlayersMenu(s *discordgo.Session, i *discordgo.InteractionCreat }) } -// RenderManageSessionsMenu renders the Sessions sub-menu (Set/Reschedule, Announce). +// RenderManageSessionsMenu renders the Sessions sub-menu (New Session). func RenderManageSessionsMenu(s *discordgo.Session, i *discordgo.InteractionCreate, database *bun.DB, campaignID string) { campaign, ok := renderManageSubAuth(s, i, database, campaignID) if !ok { return } - sessionLabel := messages.ManageSetSessionLabel - if !campaign.Schedule.NextSession.IsZero() { - sessionLabel = messages.ManageRescheduleSessionLabel - } - helpers.RespondUpdate(s, i, fmt.Sprintf(messages.ManageCampaignHeader, campaign.Name), []*discordgo.MessageEmbed{}, []discordgo.MessageComponent{ + _ = campaign // loaded for auth; not needed for the static menu + helpers.RespondUpdate(s, i, fmt.Sprintf(messages.ManageCampaignHeader, "Sessions"), []*discordgo.MessageEmbed{}, []discordgo.MessageComponent{ discordgo.ActionsRow{Components: []discordgo.MessageComponent{ discordgo.Button{ - Label: sessionLabel, - Style: discordgo.SecondaryButton, - CustomID: fmt.Sprintf("%s:%s", messages.ManageSetSessionPrefix, campaignID), - }, - discordgo.Button{ - Label: messages.ManageAnnounceLabel, + Label: messages.ManageNewSessionLabel, Style: discordgo.SuccessButton, - CustomID: fmt.Sprintf("manage_announce:%s", campaignID), + CustomID: fmt.Sprintf("%s:%s", messages.ManageNewSessionPrefix, campaignID), }, }}, helpers.BackRow(router.ViewManageCampaign, campaignID), diff --git a/internal/interactions/me_subviews.go b/internal/interactions/me_subviews.go index 2d76eaf..69d9e9b 100644 --- a/internal/interactions/me_subviews.go +++ b/internal/interactions/me_subviews.go @@ -6,11 +6,11 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/interactions/helpers" - "moontracer/internal/interactions/router" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/interactions/modal_campaign_create.go b/internal/interactions/modal_campaign_create.go index ee279f2..f2a9fa3 100644 --- a/internal/interactions/modal_campaign_create.go +++ b/internal/interactions/modal_campaign_create.go @@ -1,7 +1,7 @@ package interactions import ( - "moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/helpers" "fmt" "log" "strconv" @@ -10,10 +10,10 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/db" - "moontracer/internal/dispatch" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* @@ -81,7 +81,7 @@ func (m *modalCampaignCreate) HandleModal(s *discordgo.Session, i *discordgo.Int } conf := &models.GameConfig{} - schedule := &models.CampaignSchedule{Frequency: models.Weekly} + schedule := &models.CampaignSchedule{} campaign := &models.Campaign{} created, err := campaign.CreateCampaign( diff --git a/internal/interactions/nav_handler.go b/internal/interactions/nav_handler.go index bfa5f1d..59fa518 100644 --- a/internal/interactions/nav_handler.go +++ b/internal/interactions/nav_handler.go @@ -20,12 +20,12 @@ package interactions */ import ( - "moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/helpers" "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/interactions/router" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/messages" ) type navHandler struct { diff --git a/internal/interactions/newcampaign_config.go b/internal/interactions/newcampaign_config.go index 23dcc91..9120edf 100644 --- a/internal/interactions/newcampaign_config.go +++ b/internal/interactions/newcampaign_config.go @@ -5,19 +5,20 @@ import ( "fmt" "log" "math" - "moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/helpers" "strings" + "time" "github.com/bwmarrin/discordgo" "github.com/google/uuid" "github.com/uptrace/bun" - "moontracer/internal/commands" - "moontracer/internal/db" - "moontracer/internal/dispatch" - "moontracer/internal/interactions/router" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/commands" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* @@ -37,7 +38,7 @@ import ( */ /* -newCampaignConfigComponents builds the book/format dropdowns + submit/cancel buttons. +newCampaignConfigComponents builds the book/format/frequency dropdowns + submit/cancel buttons. Reused by the modal create handler and any re-render path. */ @@ -64,9 +65,21 @@ func newCampaignConfigComponents(campaignID string) []discordgo.MessageComponent }, } + freqSelect := discordgo.SelectMenu{ + CustomID: fmt.Sprintf("%s:%s", messages.NewCampaignFreqPrefix, campaignID), + Placeholder: messages.NewCampaignFreqPlaceholder, + Options: []discordgo.SelectMenuOption{ + {Label: messages.NewCampaignFreqLabelWeekly, Value: string(models.Weekly)}, + {Label: messages.NewCampaignFreqLabelBiweekly, Value: string(models.Biweekly)}, + {Label: messages.NewCampaignFreqLabelMonthly, Value: string(models.Monthly)}, + {Label: messages.NewCampaignFreqLabelIrregular, Value: string(models.Irregular)}, + }, + } + return []discordgo.MessageComponent{ discordgo.ActionsRow{Components: []discordgo.MessageComponent{bookSelect}}, discordgo.ActionsRow{Components: []discordgo.MessageComponent{formatSelect}}, + discordgo.ActionsRow{Components: []discordgo.MessageComponent{freqSelect}}, discordgo.ActionsRow{Components: []discordgo.MessageComponent{ discordgo.Button{ Label: messages.NewCampaignSubmitLabel, @@ -205,7 +218,46 @@ func (h *newCampaignFormatHandler) HandleComponents(s *discordgo.Session, i *dis } /* - Submit. + Frequency Select +*/ + +type newCampaignFrequencyHandler struct { + db *bun.DB +} + +func (h *newCampaignFrequencyHandler) CustomIDPrefix() string { return messages.NewCampaignFreqPrefix } + +func (h *newCampaignFrequencyHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) { + campaignID, ok := parseConfigCustomID(i.MessageComponentData().CustomID) + if !ok { + helpers.RespondUpdateTerminal(s, i, messages.InvalidButtonDataMessage) + return + } + values := i.MessageComponentData().Values + if len(values) == 0 { + return + } + + c, err := loadCampaignForConfig(h.db, campaignID, helpers.GetUserID(i)) + if err != nil { + helpers.RespondUpdateTerminal(s, i, messages.ManageCampaignNotFound) + return + } + + c.Schedule.Frequency = models.CampaignFrequency(values[0]) + if err := db.Update(h.db, c); err != nil { + log.Printf("newcampaign_freq: update failed: %v", err) + helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage) + return + } + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredMessageUpdate, + }) +} + +/* + Submit: opens the schedule modal (date + time) before sending approval DMs. */ type newCampaignSubmitHandler struct { @@ -229,14 +281,136 @@ func (h *newCampaignSubmitHandler) HandleComponents(s *discordgo.Session, i *dis return } - if c.Game.Edition == "" { - helpers.RespondUpdateTerminal(s, i, messages.NewCampaignMissingSystemMessage) + if c.Game.Edition == "" || c.Schedule.Frequency == "" { + helpers.RespondUpdate(s, i, messages.NewCampaignMissingConfigMessage, []*discordgo.MessageEmbed{}, newCampaignConfigComponents(campaignID)) + return + } + + settings, err := models.GetOrCreatePlayerSettings(h.db, userID) + if err != nil { + log.Printf("newcampaign_submit: load settings for %s: %v", userID, err) + helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage) + return + } + loc := settings.Location() + timeLabel := fmt.Sprintf(messages.NewCampaignScheduleTimeLabelFmt, helpers.TZLabel(loc)) + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseModal, + Data: &discordgo.InteractionResponseData{ + CustomID: fmt.Sprintf("%s:%s", messages.NewCampaignScheduleModalID, campaignID), + Title: messages.NewCampaignScheduleModalTitle, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: messages.NewCampaignScheduleDateFieldID, + Label: messages.NewCampaignScheduleDateLabel, + Style: discordgo.TextInputShort, + Required: false, + Placeholder: messages.NewCampaignScheduleDatePlaceholder, + }, + }}, + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: messages.NewCampaignScheduleTimeFieldID, + Label: timeLabel, + Style: discordgo.TextInputShort, + Required: false, + Placeholder: messages.NewCampaignScheduleTimePlaceholder, + }, + }}, + }, + }, + }) +} + +/* + Schedule Modal: parses optional date/time, saves to Campaign.Schedule.NextSession, + then sends approval DMs to staff. +*/ + +type newCampaignScheduleModal struct { + db *bun.DB + dispatcher *dispatch.Dispatcher +} + +func (h *newCampaignScheduleModal) CustomIDPrefix() string { + return messages.NewCampaignScheduleModalID +} + +func (h *newCampaignScheduleModal) HandleModal(s *discordgo.Session, i *discordgo.InteractionCreate) { + parts := strings.SplitN(i.ModalSubmitData().CustomID, ":", 2) + if len(parts) < 2 || parts[1] == "" { + helpers.RespondUpdateTerminal(s, i, messages.InvalidButtonDataMessage) + return + } + campaignID := parts[1] + userID := helpers.GetUserID(i) + + c, err := loadCampaignForConfig(h.db, campaignID, userID) + if err != nil { + helpers.RespondUpdateTerminal(s, i, messages.ManageCampaignNotFound) + return + } + + settings, err := models.GetOrCreatePlayerSettings(h.db, userID) + if err != nil { + log.Printf("newcampaign_schedule: load settings for %s: %v", userID, err) + helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage) return } + loc := settings.Location() + + var dateStr, timeStr string + for _, row := range i.ModalSubmitData().Components { + for _, comp := range row.(*discordgo.ActionsRow).Components { + input := comp.(*discordgo.TextInput) + switch input.CustomID { + case messages.NewCampaignScheduleDateFieldID: + dateStr = strings.TrimSpace(input.Value) + case messages.NewCampaignScheduleTimeFieldID: + timeStr = strings.TrimSpace(input.Value) + } + } + } + + if dateStr != "" || timeStr != "" { + if dateStr == "" || timeStr == "" { + msg := messages.NewCampaignScheduleInvalidDate + if dateStr != "" { + msg = messages.NewCampaignScheduleInvalidTime + } + helpers.RespondUpdateTerminal(s, i, msg) + return + } + if _, err := time.Parse(messages.DateInputFormat, dateStr); err != nil { + helpers.RespondUpdateTerminal(s, i, messages.NewCampaignScheduleInvalidDate) + return + } + if !isValidTime(timeStr) { + helpers.RespondUpdateTerminal(s, i, messages.NewCampaignScheduleInvalidTime) + return + } + when, err := time.ParseInLocation(messages.DateTimeInputFormat, dateStr+" "+timeStr, loc) + if err != nil { + helpers.RespondUpdateTerminal(s, i, messages.NewCampaignScheduleInvalidTime) + return + } + if !when.After(time.Now().UTC()) { + helpers.RespondUpdateTerminal(s, i, messages.NewCampaignScheduleInPast) + return + } + c.Schedule.NextSession = when.UTC() + if err := db.Update(h.db, c); err != nil { + log.Printf("newcampaign_schedule: update failed: %v", err) + helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage) + return + } + } staffMembers, err := db.GetStaff(h.db) if err != nil { - log.Printf("newcampaign_submit: failed to get staff: %v", err) + log.Printf("newcampaign_schedule: failed to get staff: %v", err) helpers.RespondUpdateTerminal(s, i, messages.CampaignStaffNotifyFailureMessage) return } @@ -261,7 +435,7 @@ func (h *newCampaignSubmitHandler) HandleComponents(s *discordgo.Session, i *dis players, _ := models.GetCampaignPlayers(h.db, c.ID) coverURL := models.CoverURLForCampaign(h.db, c.ID) - campaignEmbed := commands.CampaignEmbed(*c, players, coverURL, "") + campaignEmbed := commands.CampaignEmbed(*c, players, coverURL, "", userID) msgID := uuid.NewString() for _, staff := range staffMembers { @@ -275,8 +449,15 @@ func (h *newCampaignSubmitHandler) HandleComponents(s *discordgo.Session, i *dis }) } - helpers.RespondUpdate(s, i, fmt.Sprintf(messages.NewCampaignSubmittedMessage, c.Name), nil, []discordgo.MessageComponent{ - helpers.BackRow(router.ViewManage), + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: fmt.Sprintf(messages.NewCampaignSubmittedMessage, c.Name), + Components: []discordgo.MessageComponent{ + helpers.BackRow(router.ViewManage), + }, + Flags: discordgo.MessageFlagsEphemeral, + }, }) } diff --git a/internal/interactions/next_sessions.go b/internal/interactions/next_sessions.go index 4396344..00ac84b 100644 --- a/internal/interactions/next_sessions.go +++ b/internal/interactions/next_sessions.go @@ -23,10 +23,10 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/interactions/helpers" - "moontracer/internal/interactions/router" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) type nextSessionsHandler struct { diff --git a/internal/interactions/notifications.go b/internal/interactions/notifications.go index 7a6955e..88e66fe 100644 --- a/internal/interactions/notifications.go +++ b/internal/interactions/notifications.go @@ -19,7 +19,7 @@ package interactions */ import ( - "moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/helpers" "context" "fmt" "log" @@ -27,9 +27,9 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/interactions/router" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) type notificationsHandler struct { diff --git a/internal/interactions/player_settings.go b/internal/interactions/player_settings.go index 0f9306b..2ec5b28 100644 --- a/internal/interactions/player_settings.go +++ b/internal/interactions/player_settings.go @@ -9,14 +9,14 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/db" - "moontracer/internal/dispatch" - "moontracer/internal/guard" - "moontracer/internal/interactions/helpers" - "moontracer/internal/interactions/router" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/interactions/player_tokens.go b/internal/interactions/player_tokens.go index 00789c8..360c8c6 100644 --- a/internal/interactions/player_tokens.go +++ b/internal/interactions/player_tokens.go @@ -9,11 +9,11 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/db" - "moontracer/internal/interactions/helpers" - "moontracer/internal/interactions/router" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/interactions/player_tokens_test.go b/internal/interactions/player_tokens_test.go index a2f102d..ebd12a4 100644 --- a/internal/interactions/player_tokens_test.go +++ b/internal/interactions/player_tokens_test.go @@ -13,9 +13,9 @@ import ( "github.com/stretchr/testify/require" "github.com/uptrace/bun" - "moontracer/internal/manager/models" - "moontracer/internal/messages" - "moontracer/internal/testutil" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/testutil" ) /* diff --git a/internal/interactions/quick_register.go b/internal/interactions/quick_register.go index 0453030..6090834 100644 --- a/internal/interactions/quick_register.go +++ b/internal/interactions/quick_register.go @@ -6,12 +6,12 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/auth" - "moontracer/internal/db" - "moontracer/internal/interactions/helpers" - "moontracer/internal/interactions/router" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/auth" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/interactions/registry.go b/internal/interactions/registry.go index 5b2f30f..77627b5 100644 --- a/internal/interactions/registry.go +++ b/internal/interactions/registry.go @@ -3,8 +3,8 @@ package interactions import ( "github.com/uptrace/bun" - "moontracer/internal/dispatch" - "moontracer/internal/scheduler" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/scheduler" ) /* @@ -98,9 +98,17 @@ func AllComponents(db *bun.DB, d *dispatch.Dispatcher, sched *scheduler.Schedule // Quick registration button (shown on all "not registered" surfaces). &quickRegisterHandler{db: db}, + // Session announce + RSVP + &manageNewSessionButton{db: db}, + &sessionRSVPAcceptHandler{db: db, dispatcher: d}, + &sessionRSVPDeclineHandler{db: db, dispatcher: d}, + &sessionRSVPConfirmHandler{db: db, dispatcher: d}, + &sessionRSVPCancelHandler{}, + // New campaign config (post-modal) &newCampaignBookHandler{db: db}, &newCampaignFormatHandler{db: db}, + &newCampaignFrequencyHandler{db: db}, &newCampaignSubmitHandler{db: db, dispatcher: d}, &newCampaignCancelHandler{db: db}, @@ -128,7 +136,7 @@ func AllComponents(db *bun.DB, d *dispatch.Dispatcher, sched *scheduler.Schedule &tokenApplyHandler{db: db, dataDir: dataDir, mediaBaseURL: mediaBaseURL}, &tokenDiscardHandler{dataDir: dataDir, mediaBaseURL: mediaBaseURL}, &playerTokenPostcreateSelectHandler{db: db}, - &playerTokenSkipHandler{}, + &playerTokenSkipHandler{db: db}, &manageSetSession{db: db}, // Token gallery (/me -> Tokens) @@ -166,6 +174,8 @@ func AllComponents(db *bun.DB, d *dispatch.Dispatcher, sched *scheduler.Schedule func AllModals(db *bun.DB, d *dispatch.Dispatcher, sched *scheduler.Scheduler, dataDir, mediaBaseURL string) []ModalHandler { return []ModalHandler{ &modalCampaignCreate{db: db, dispatch: d}, + &newCampaignScheduleModal{db: db, dispatcher: d}, + &newSessionModal{db: db, dispatcher: d, sched: sched}, &campaignDenyModal{db: db, dispatcher: d}, &manageCampaignAnnounceModal{db: db, dispatcher: d}, &manageCampaignRescheduleModal{db: db}, diff --git a/internal/interactions/rsvp.go b/internal/interactions/rsvp.go index 7ff1373..64e185c 100644 --- a/internal/interactions/rsvp.go +++ b/internal/interactions/rsvp.go @@ -25,11 +25,11 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/db" - "moontracer/internal/dispatch" - "moontracer/internal/interactions/helpers" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/interactions/select_handlers.go b/internal/interactions/select_handlers.go index e12df87..1867e19 100644 --- a/internal/interactions/select_handlers.go +++ b/internal/interactions/select_handlers.go @@ -4,7 +4,7 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/interactions/session_announce.go b/internal/interactions/session_announce.go new file mode 100644 index 0000000..4c1a01d --- /dev/null +++ b/internal/interactions/session_announce.go @@ -0,0 +1,591 @@ +package interactions + +/* +New-session announcement flow. + +Two entry points lead to the same modal: + - /newsession slash command (newSessionCommand in commands package) + - "New Session" button in the manage sessions menu (manageNewSessionButton below) + +Modal submission (newSessionModal): + 1. Parses date + time in the DM's timezone; notes are optional. + 2. Creates a Session record in the DB. + 3. Posts a public announcement embed in the campaign's channel with going/not going RSVP buttons. + 4. DMs all active members immediately with the same info + buttons. + 5. Calls campaign.RefreshNextSession to keep the legacy NextSession field in sync. + 6. Schedules the 1-hour reminder via the Scheduler. + +RSVP handlers (sessionRSVPAcceptHandler / sessionRSVPDeclineHandler): + - CustomID format: session_rsvp_accept:: + - Works from both the channel embed and reminder DMs (guildID encoded for DM routing). + - Checks campaign membership, schedule conflicts, and session capacity. + - Upserts to session_rsvps, edits the channel embed's RSVP counts, DMs the campaign DM. + +Conflict confirmation (sessionRSVPConfirmHandler): + - CustomID: session_rsvp_confirm:: + - Skips the conflict check and writes the RSVP directly. +*/ + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/google/uuid" + "github.com/uptrace/bun" + + "github.com/framebuffers/moontracer/internal/commands" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/guard" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/scheduler" +) + +/* + Manage menu button +*/ + +type manageNewSessionButton struct { + db *bun.DB +} + +func (h *manageNewSessionButton) CustomIDPrefix() string { return messages.ManageNewSessionPrefix } + +func (h *manageNewSessionButton) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) { + parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2) + if !ok { + return + } + campaignID := parts[1] + userID := helpers.GetUserID(i) + + campaign, ok := helpers.LoadDMCampaign(s, i, h.db, campaignID) + if !ok { + return + } + if !helpers.IsCampaignMutable(s, i, campaign) { + return + } + + settings, _ := models.GetOrCreatePlayerSettings(h.db, userID) + loc := settings.Location() + timeLabel := fmt.Sprintf(messages.NewCampaignScheduleTimeLabelFmt, helpers.TZLabel(loc)) + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseModal, + Data: &discordgo.InteractionResponseData{ + CustomID: fmt.Sprintf("%s:%s", messages.NewSessionModalID, campaignID), + Title: messages.NewSessionModalTitle, + Components: commands.NewSessionModalRows(timeLabel), + }, + }) +} + +/* + Modal Handler +*/ + +type newSessionModal struct { + db *bun.DB + dispatcher *dispatch.Dispatcher + sched *scheduler.Scheduler +} + +func (h *newSessionModal) CustomIDPrefix() string { return messages.NewSessionModalID } + +func (h *newSessionModal) HandleModal(s *discordgo.Session, i *discordgo.InteractionCreate) { + parts, ok := helpers.SplitCustomID(s, i, i.ModalSubmitData().CustomID, 2) + if !ok { + return + } + campaignID := parts[1] + userID := helpers.GetUserID(i) + guildID := i.GuildID + + campaign, ok := helpers.LoadDMCampaign(s, i, h.db, campaignID) + if !ok { + return + } + if !helpers.IsCampaignMutable(s, i, campaign) { + return + } + + settings, err := models.GetOrCreatePlayerSettings(h.db, userID) + if err != nil { + log.Printf("new_session_modal: load settings %s: %v", userID, err) + helpers.Respond(s, i, messages.GenericErrorMessage) + return + } + loc := settings.Location() + + var dateStr, timeStr, notes string + for _, row := range i.ModalSubmitData().Components { + for _, comp := range row.(*discordgo.ActionsRow).Components { + input := comp.(*discordgo.TextInput) + switch input.CustomID { + case messages.NewSessionDateFieldID: + dateStr = strings.TrimSpace(input.Value) + case messages.NewSessionTimeFieldID: + timeStr = strings.TrimSpace(input.Value) + case messages.NewSessionNotesFieldID: + notes = strings.TrimSpace(input.Value) + } + } + } + + if _, err := time.Parse(messages.DateInputFormat, dateStr); err != nil { + helpers.Respond(s, i, messages.NewCampaignScheduleInvalidDate) + return + } + if !isValidTime(timeStr) { + helpers.Respond(s, i, messages.NewCampaignScheduleInvalidTime) + return + } + when, err := time.ParseInLocation(messages.DateTimeInputFormat, dateStr+" "+timeStr, loc) + if err != nil || !when.After(time.Now().UTC()) { + helpers.Respond(s, i, messages.NewCampaignScheduleInPast) + return + } + when = when.UTC() + + session := models.NewSession(campaignID, when, notes, 0) + if _, err := h.db.NewInsert().Model(session).Exec(context.Background()); err != nil { + log.Printf("new_session_modal: insert session for %s: %v", campaignID, err) + helpers.Respond(s, i, messages.GenericErrorMessage) + return + } + + // 1. Build the channel announcement embed and RSVP buttons. + embed := buildSessionEmbed(session, campaign, nil) + rsvpButtons := sessionRSVPButtons(guildID, session.ID) + + // 2. Post to the campaign channel (announcements thread preferred). + channelID := campaign.AnnouncementsThreadID + if channelID == "" { + channelID = campaign.ChannelID + } + if channelID == "" { + helpers.Respond(s, i, messages.NewSessionNoChannel) + return + } + + var roleMention string + if campaign.RoleID != "" { + roleMention = fmt.Sprintf("<@&%s>", campaign.RoleID) + } + msg, err := guard.ChannelMessageSendComplex(s, channelID, &discordgo.MessageSend{ + Content: roleMention, + Embeds: []*discordgo.MessageEmbed{embed}, + Components: []discordgo.MessageComponent{rsvpButtons}, + }) + if err != nil { + log.Printf("new_session_modal: post to channel %s: %v", channelID, err) + helpers.Respond(s, i, messages.GenericErrorMessage) + return + } + + // 3. Save the announcement message ID so RSVP handlers can edit it later. + session.ChannelMsgID = msg.ID + if _, err := h.db.NewUpdate().Model(session).Column("channel_msg_id").WherePK().Exec(context.Background()); err != nil { + log.Printf("new_session_modal: save msg_id for session %s: %v", session.ID, err) + } + + // 4. DM all active members immediately. + players, _ := models.GetCampaignPlayers(h.db, campaignID) + notesBlock := "" + if notes != "" { + notesBlock = "\n" + notes + } + linkBlock := buildAnnounceLinkBlock(campaign, "") + msgID := uuid.NewString() + sent := 0 + for _, p := range players { + if p.Status != models.StatusActive { + continue + } + if p.PlayerID == userID { + continue // NOTE: don't DM the DM who just set this up + } + pSettings, _ := models.GetOrCreatePlayerSettings(h.db, p.PlayerID) + if !pSettings.NotifySessionRemind { + continue + } + content := fmt.Sprintf(messages.NewSessionDMContentFmt, + campaign.Name, + when.Unix(), when.Unix(), + notesBlock, + ) + linkBlock + h.dispatcher.Push(dispatch.DirectMessage{ + ID: msgID, + Target: p.PlayerID, + Content: content, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.Button{ + Label: messages.SessionRSVPAcceptLabel, + Style: discordgo.SuccessButton, + CustomID: fmt.Sprintf("%s:%s:%s", messages.SessionRSVPAcceptPrefix, guildID, session.ID), + }, + discordgo.Button{ + Label: messages.SessionRSVPDeclineLabel, + Style: discordgo.DangerButton, + CustomID: fmt.Sprintf("%s:%s:%s", messages.SessionRSVPDeclinePrefix, guildID, session.ID), + }, + }}, + }, + }) + sent++ + } + + // Refresh campaign.NextSession cache and schedule the 1-hour reminder. + if err := campaign.RefreshNextSession(h.db); err != nil { + log.Printf("new_session_modal: refresh next session for %s: %v", campaignID, err) + } + h.sched.ScheduleSession(guildID, session) + + helpers.Respond(s, i, fmt.Sprintf(messages.NewSessionAnnouncedFmt, campaign.Name, sent)) +} + +/* + RSVP handlers +*/ + +type sessionRSVPAcceptHandler struct { + db *bun.DB + dispatcher *dispatch.Dispatcher +} + +func (h *sessionRSVPAcceptHandler) CustomIDPrefix() string { return messages.SessionRSVPAcceptPrefix } +func (h *sessionRSVPAcceptHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) { + handleSessionRSVP(s, i, h.db, h.dispatcher, models.RSVPAccepted, false) +} + +type sessionRSVPDeclineHandler struct { + db *bun.DB + dispatcher *dispatch.Dispatcher +} + +func (h *sessionRSVPDeclineHandler) CustomIDPrefix() string { return messages.SessionRSVPDeclinePrefix } +func (h *sessionRSVPDeclineHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) { + handleSessionRSVP(s, i, h.db, h.dispatcher, models.RSVPDeclined, false) +} + +type sessionRSVPConfirmHandler struct { + db *bun.DB + dispatcher *dispatch.Dispatcher +} + +func (h *sessionRSVPConfirmHandler) CustomIDPrefix() string { return messages.SessionRSVPConfirmPrefix } +func (h *sessionRSVPConfirmHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) { + // NOTE: Confirm despite conflict: skip the conflict check. + handleSessionRSVP(s, i, h.db, h.dispatcher, models.RSVPAccepted, true) +} + +type sessionRSVPCancelHandler struct{} + +func (h *sessionRSVPCancelHandler) CustomIDPrefix() string { return messages.SessionRSVPCancelPrefix } +func (h *sessionRSVPCancelHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) { + helpers.RespondUpdate(s, i, messages.SessionRSVPCancelledMsg, []*discordgo.MessageEmbed{}, nil) +} + +/* + Core logic +*/ + +func handleSessionRSVP( + s *discordgo.Session, + i *discordgo.InteractionCreate, + guildDB *bun.DB, + dispatcher *dispatch.Dispatcher, + intent models.RSVPStatus, // RSVPAccepted or RSVPDeclined + skipConflictCheck bool, +) { + // CustomID: prefix:: + parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 3) + if !ok { + return + } + sessionID := parts[2] + playerID := helpers.GetUserID(i) + fromDM := i.GuildID == "" + + /* + For channel interactions, errors must be ephemeral so the embed stays intact. + For DM interactions, update the DM message in place. + */ + rsvpError := func(msg string) { + if fromDM { + helpers.RespondUpdate(s, i, msg, []*discordgo.MessageEmbed{}, nil) + } else { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + } + } + + ctx := context.Background() + + // 1. Load session. + session := &models.Session{} + if err := guildDB.NewSelect().Model(session).Where("id = ?", sessionID).Scan(ctx); err != nil { + rsvpError(messages.SessionRSVPGone) + return + } + if session.Status != models.SessionUpcoming { + rsvpError(messages.SessionRSVPGone) + return + } + + // 2. Load campaign. + campaign, err := db.GetByID[models.Campaign](guildDB, session.CampaignID) + if err != nil || campaign.IsArchived { + rsvpError(messages.RSVPCampaignGone) + return + } + + // 3. Must be an active campaign member. + cp := &models.CampaignPlayer{} + if err := guildDB.NewSelect().Model(cp). + Where("player_id = ? AND campaign_id = ?", playerID, session.CampaignID). + Scan(ctx); err != nil || cp.Status != models.StatusActive { + rsvpError(messages.SessionRSVPNotMember) + return + } + + // 4. Idempotency: already responded? + existing, _ := models.GetPlayerSessionRSVP(guildDB, sessionID, playerID) + if existing != nil && existing.Status != models.RSVPPending { + rsvpError(messages.SessionRSVPAlreadySet) + return + } + + finalStatus := intent + + if intent == models.RSVPAccepted { + // a. conflict check. + if !skipConflictCheck { + conflicts, _ := models.GetPlayerConflictingSessions(guildDB, playerID, session.ScheduledAt) + if len(conflicts) > 0 { + conflict := conflicts[0] + // b. load campaign name for the conflicting session. + conflictCampaign, _ := db.GetByID[models.Campaign](guildDB, conflict.CampaignID) + conflictName := conflict.CampaignID + if conflictCampaign != nil { + conflictName = conflictCampaign.Name + } + pSettings, _ := models.GetOrCreatePlayerSettings(guildDB, playerID) + conflictTime := helpers.FormatInLocation(conflict.ScheduledAt, messages.SessionListFormat, pSettings.Location()) + + guildID := parts[1] + confirmID := fmt.Sprintf("%s:%s:%s", messages.SessionRSVPConfirmPrefix, guildID, sessionID) + cancelID := fmt.Sprintf("%s:%s:%s", messages.SessionRSVPCancelPrefix, guildID, sessionID) + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: fmt.Sprintf(messages.SessionRSVPConflictFmt, conflictName, conflictTime), + Flags: discordgo.MessageFlagsEphemeral, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.Button{ + Label: messages.SessionRSVPConfirmLabel, + Style: discordgo.DangerButton, + CustomID: confirmID, + }, + discordgo.Button{ + Label: messages.SessionRSVPCancelLabel, + Style: discordgo.SecondaryButton, + CustomID: cancelID, + }, + }}, + }, + }, + }) + return + } + } + + // 5. Capacity check: if session has a cap (or falls back to campaign's SessionCapacity). + cap := session.Capacity + if cap == 0 { + cap = campaign.SessionCapacity + } + if cap > 0 { + accepted, _ := models.CountAcceptedRSVPs(guildDB, sessionID) + if accepted >= cap { + finalStatus = models.RSVPWaitlisted + } + } + } + + // 6. Write RSVP. + if err := models.UpsertSessionRSVP(guildDB, sessionID, playerID, finalStatus); err != nil { + log.Printf("session_rsvp: upsert for %s/%s: %v", playerID, sessionID, err) + rsvpError(messages.GenericErrorMessage) + return + } + + // 7. Rebuild embed with updated player list. + rsvps, _ := models.GetSessionRSVPs(guildDB, sessionID) + newEmbed := buildSessionEmbed(session, campaign, rsvps) + guildID := parts[1] + rsvpRow := sessionRSVPButtons(guildID, sessionID) + + channelID := campaign.AnnouncementsThreadID + if channelID == "" { + channelID = campaign.ChannelID + } + + // 8. Respond based on context. + if fromDM { + // Update the DM message (remove buttons, show confirmation). + var confirmMsg string + switch finalStatus { + case models.RSVPAccepted: + confirmMsg = messages.SessionRSVPAcceptedMsg + case models.RSVPDeclined: + confirmMsg = messages.SessionRSVPDeclinedMsg + default: + confirmMsg = messages.SessionRSVPWaitlistedMsg + } + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseUpdateMessage, + Data: &discordgo.InteractionResponseData{ + Content: i.Message.Content + "\n\n" + confirmMsg, + Components: []discordgo.MessageComponent{}, + }, + }) + // Also update the channel embed so everyone sees the new list. + if channelID != "" && session.ChannelMsgID != "" { + if _, err := s.ChannelMessageEditComplex(&discordgo.MessageEdit{ + ID: session.ChannelMsgID, + Channel: channelID, + Embeds: &[]*discordgo.MessageEmbed{newEmbed}, + Components: &[]discordgo.MessageComponent{rsvpRow}, + }); err != nil { + log.Printf("session_rsvp: edit channel embed %s: %v", session.ChannelMsgID, err) + } + } + } else { + // Update the channel embed in-place โ€” the updated player list is the confirmation. + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseUpdateMessage, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{newEmbed}, + Components: []discordgo.MessageComponent{rsvpRow}, + }, + }) + } + + // Notify campaign DM. + notifyDM(dispatcher, guildDB, campaign, playerID, session, finalStatus) +} + +/* + Helper methods +*/ + +func buildSessionEmbed(session *models.Session, campaign *models.Campaign, rsvps []models.SessionRSVP) *discordgo.MessageEmbed { + const maxNames = 10 + + var going, notGoing, waitlisted []string + for _, r := range rsvps { + mention := "<@" + r.PlayerID + ">" + switch r.Status { + case models.RSVPAccepted: + going = append(going, mention) + case models.RSVPDeclined: + notGoing = append(notGoing, mention) + case models.RSVPWaitlisted: + waitlisted = append(waitlisted, mention) + } + } + + desc := fmt.Sprintf(" ยท ", session.ScheduledAt.Unix(), session.ScheduledAt.Unix()) + if session.Title != "" { + desc += "\n" + session.Title + } + + desc += "\n\n" + rsvpLine(messages.SessionEmbedGoingLabel, going, maxNames) + desc += "\n" + rsvpLine(messages.SessionEmbedNotGoingLabel, notGoing, maxNames) + if len(waitlisted) > 0 { + desc += "\n" + rsvpLine(messages.SessionEmbedWaitlistedLabel, waitlisted, maxNames) + } + + return &discordgo.MessageEmbed{ + Title: fmt.Sprintf(messages.SessionEmbedTitleFmt, campaign.Name), + Description: desc, + Color: messages.EmbedColor, + } +} + +// rsvpLine formats "Label (N): @A, @B, +X more" or "Label (0): โ€”". +func rsvpLine(label string, names []string, max int) string { + if len(names) == 0 { + return fmt.Sprintf(messages.SessionRSVPLineEmptyFmt, label) + } + shown := names + overflow := 0 + if len(names) > max { + shown = names[:max] + overflow = len(names) - max + } + line := fmt.Sprintf(messages.SessionRSVPLineFmt, label, len(names), strings.Join(shown, ", ")) + if overflow > 0 { + line += fmt.Sprintf(messages.SessionRSVPLineOverflowFmt, overflow) + } + return line +} + +func sessionRSVPButtons(guildID, sessionID string) discordgo.ActionsRow { + return discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.Button{ + Label: messages.SessionRSVPAcceptLabel, + Style: discordgo.SuccessButton, + CustomID: fmt.Sprintf("%s:%s:%s", messages.SessionRSVPAcceptPrefix, guildID, sessionID), + }, + discordgo.Button{ + Label: messages.SessionRSVPDeclineLabel, + Style: discordgo.DangerButton, + CustomID: fmt.Sprintf("%s:%s:%s", messages.SessionRSVPDeclinePrefix, guildID, sessionID), + }, + }} +} + +func notifyDM(dispatcher *dispatch.Dispatcher, guildDB *bun.DB, campaign *models.Campaign, playerID string, session *models.Session, status models.RSVPStatus) { + if campaign.DungeonMaster == "" { + return + } + dmSettings, err := models.GetOrCreatePlayerSettings(guildDB, campaign.DungeonMaster) + if err != nil { + return + } + sessionTime := helpers.FormatInLocation(session.ScheduledAt, messages.SessionTimeFormat, dmSettings.Location()) + + " " + helpers.TZLabel(dmSettings.Location()) + + var notifyFmt string + switch status { + case models.RSVPAccepted: + notifyFmt = messages.SessionRSVPDMNotifyAccept + case models.RSVPDeclined: + notifyFmt = messages.SessionRSVPDMNotifyDecline + default: + notifyFmt = messages.SessionRSVPDMNotifyWaitlist + } + + dispatcher.Push(dispatch.DirectMessage{ + ID: fmt.Sprintf("session-rsvp-notify:%s:%s:%s", session.ID, playerID, string(status)), + Target: campaign.DungeonMaster, + Content: fmt.Sprintf(notifyFmt, playerID, campaign.Name, sessionTime), + }) +} diff --git a/internal/interactions/timezone_select.go b/internal/interactions/timezone_select.go index 967c33a..6cb1159 100644 --- a/internal/interactions/timezone_select.go +++ b/internal/interactions/timezone_select.go @@ -17,10 +17,10 @@ import ( "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/interactions/helpers" - "moontracer/internal/interactions/router" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) /* diff --git a/internal/interactions/token_confirm.go b/internal/interactions/token_confirm.go index 6c87534..0b4ce12 100644 --- a/internal/interactions/token_confirm.go +++ b/internal/interactions/token_confirm.go @@ -13,11 +13,12 @@ import ( "github.com/bwmarrin/discordgo" - "moontracer/internal/db" - "moontracer/internal/interactions/helpers" - "moontracer/internal/manager/models" - "moontracer/internal/mediaserver" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/mediaserver" + "github.com/framebuffers/moontracer/internal/messages" ) /* @@ -156,9 +157,15 @@ func (h *tokenApplyModal) HandleModal(s *discordgo.Session, i *discordgo.Interac log.Printf("token_apply_modal: token %q saved for player %s, media %s", name, playerID, media.ID) // Load campaigns where the player is an active non-DM member. + downloadURL := mediaserver.Register(outDisk, name) + downloadRow := discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.Button{Label: messages.TokenDownloadLabel, Style: discordgo.LinkButton, URL: downloadURL}, + router.NavButton(messages.HomeLabel, discordgo.DangerButton, router.ViewMe), + }} + allCPs, err := models.GetPlayerCampaigns(h.db, playerID) if err != nil { - helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.TokenApplySuccess, name)) + helpers.RespondUpdate(s, i, fmt.Sprintf(messages.TokenApplySuccess, name), []*discordgo.MessageEmbed{}, []discordgo.MessageComponent{downloadRow}) return } var activeCPs []models.CampaignPlayer @@ -168,7 +175,7 @@ func (h *tokenApplyModal) HandleModal(s *discordgo.Session, i *discordgo.Interac } } if len(activeCPs) == 0 { - helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.TokenApplySuccess, name)) + helpers.RespondUpdate(s, i, fmt.Sprintf(messages.TokenApplySuccess, name), []*discordgo.MessageEmbed{}, []discordgo.MessageComponent{downloadRow}) return } @@ -199,6 +206,7 @@ func (h *tokenApplyModal) HandleModal(s *discordgo.Session, i *discordgo.Interac }, }}, discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.Button{Label: messages.TokenDownloadLabel, Style: discordgo.LinkButton, URL: downloadURL}, discordgo.Button{ Label: messages.TokenSkipLabel, Style: discordgo.SecondaryButton, @@ -256,12 +264,17 @@ func (h *playerTokenPostcreateSelectHandler) HandleComponents(s *discordgo.Sessi return } + downloadRow := discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.Button{Label: messages.TokenDownloadLabel, Style: discordgo.LinkButton, URL: mediaserver.Register(media.Path, media.Name)}, + router.NavButton(messages.HomeLabel, discordgo.DangerButton, router.ViewMe), + }} + campaign, err := db.GetByID[models.Campaign](h.db, campaignID) if err != nil { - helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.TokenPostcreateAssigned, campaignID)) + helpers.RespondUpdate(s, i, fmt.Sprintf(messages.TokenPostcreateAssigned, campaignID), []*discordgo.MessageEmbed{}, []discordgo.MessageComponent{downloadRow}) return } - helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.TokenPostcreateAssigned, campaign.Name)) + helpers.RespondUpdate(s, i, fmt.Sprintf(messages.TokenPostcreateAssigned, campaign.Name), []*discordgo.MessageEmbed{}, []discordgo.MessageComponent{downloadRow}) } /* @@ -269,12 +282,28 @@ playerTokenSkipHandler dismisses the post-create assignment prompt. CustomID: player_token_skip:{mediaID} */ -type playerTokenSkipHandler struct{} +type playerTokenSkipHandler struct { + db *bun.DB +} func (h *playerTokenSkipHandler) CustomIDPrefix() string { return messages.TokenSkipPrefix } func (h *playerTokenSkipHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) { - helpers.RespondUpdateTerminal(s, i, messages.TokenSavedNoAssign) + parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2) + if !ok { + return + } + media, err := db.GetByID[models.Media](h.db, parts[1]) + if err != nil { + helpers.RespondUpdateTerminal(s, i, messages.TokenSavedNoAssign) + return + } + helpers.RespondUpdate(s, i, messages.TokenSavedNoAssign, []*discordgo.MessageEmbed{}, []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.Button{Label: messages.TokenDownloadLabel, Style: discordgo.LinkButton, URL: mediaserver.Register(media.Path, media.Name)}, + router.NavButton(messages.HomeLabel, discordgo.DangerButton, router.ViewMe), + }}, + }) } /* diff --git a/internal/interactions/token_confirm_test.go b/internal/interactions/token_confirm_test.go index 86c2ea3..f91364c 100644 --- a/internal/interactions/token_confirm_test.go +++ b/internal/interactions/token_confirm_test.go @@ -11,9 +11,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "moontracer/internal/manager/models" - "moontracer/internal/messages" - "moontracer/internal/testutil" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/testutil" ) /* diff --git a/internal/interactions/views.go b/internal/interactions/views.go index 873d193..a1029e5 100644 --- a/internal/interactions/views.go +++ b/internal/interactions/views.go @@ -23,13 +23,13 @@ package interactions */ import ( - "moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/interactions/helpers" "github.com/bwmarrin/discordgo" "github.com/uptrace/bun" - "moontracer/internal/commands" - "moontracer/internal/dispatch" - "moontracer/internal/interactions/router" + "github.com/framebuffers/moontracer/internal/commands" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/interactions/router" ) /* diff --git a/internal/manager/models/campaign.go b/internal/manager/models/campaign.go index bb1d480..afeea1f 100644 --- a/internal/manager/models/campaign.go +++ b/internal/manager/models/campaign.go @@ -6,6 +6,7 @@ import ( "math" "strings" "time" + "unicode" "github.com/google/uuid" "github.com/uptrace/bun" @@ -41,7 +42,8 @@ type Campaign struct { PlayerSheetURL string `bun:",default:''" json:"player_sheet_url,omitempty"` CampaignMedia []string `bun:",array,type:jsonb" json:"campaign_media,omitempty"` - CampaignPlayers []CampaignPlayer `bun:"rel:has-many,join:id=campaign_id" json:"campaign_players,omitempty"` // Has-many relation. + CampaignPlayers []CampaignPlayer `bun:"rel:has-many,join:id=campaign_id" json:"campaign_players,omitempty"` + Sessions []Session `bun:"rel:has-many,join:id=campaign_id" json:"sessions,omitempty"` // Can you add a new player *even if* the Campaign is full? CanOverflow bool `bun:",notnull,default:false" json:"can_overflow"` @@ -162,11 +164,11 @@ func NormalizeTag(name string) string { } tag := strings.ToLower(strings.TrimSpace(name)) - // Replace non-alphanumeric characters with hyphens. + // Replace non-alphanumeric characters with hyphens; allow unicode letters (e.g. รฑ, รฉ). var b strings.Builder prevHyphen := false for _, r := range tag { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + if unicode.IsLetter(r) || unicode.IsDigit(r) { b.WriteRune(r) prevHyphen = false } else if !prevHyphen { @@ -196,6 +198,7 @@ const ( Quarterly CampaignFrequency = "quarterly" Yearly CampaignFrequency = "yearly" Westmarch CampaignFrequency = "westmarch" + Irregular CampaignFrequency = "irregular" ) func (c *Campaign) CreateCampaign( @@ -294,3 +297,28 @@ func (c *Campaign) CreateCampaign( return campaign, nil } + +// RefreshNextSession updates Campaign.Schedule.NextSession to the earliest upcoming session +// in the sessions table. Should be called after inserting or deleting a Session. +func (c *Campaign) RefreshNextSession(db *bun.DB) error { + ctx := context.Background() + var earliest Session + err := db.NewSelect().Model(&earliest). + Where("campaign_id = ? AND status = ? AND scheduled_at > ?", c.ID, SessionUpcoming, time.Now().UTC()). + OrderExpr("scheduled_at ASC"). + Limit(1). + Scan(ctx) + if err != nil { + // No upcoming sessions โ€” clear NextSession. + c.Schedule.NextSession = time.Time{} + c.Schedule.AlertSent = false + } else { + c.Schedule.NextSession = earliest.ScheduledAt + c.Schedule.AlertSent = earliest.AlertSent + } + _, err2 := db.NewUpdate().Model(c). + Column("next_session", "alert_sent"). + Where("id = ?", c.ID). + Exec(ctx) + return err2 +} diff --git a/internal/manager/models/campaign_player.go b/internal/manager/models/campaign_player.go index d8fb332..468805d 100644 --- a/internal/manager/models/campaign_player.go +++ b/internal/manager/models/campaign_player.go @@ -67,9 +67,10 @@ Reset to RSVPPending whenever NextSession changes. type RSVPStatus string const ( - RSVPPending RSVPStatus = "" - RSVPAccepted RSVPStatus = "accepted" - RSVPDeclined RSVPStatus = "declined" + RSVPPending RSVPStatus = "" + RSVPAccepted RSVPStatus = "accepted" + RSVPDeclined RSVPStatus = "declined" + RSVPWaitlisted RSVPStatus = "waitlisted" ) /* diff --git a/internal/manager/models/media_test.go b/internal/manager/models/media_test.go index e2f07b3..172927e 100644 --- a/internal/manager/models/media_test.go +++ b/internal/manager/models/media_test.go @@ -9,8 +9,8 @@ import ( "github.com/stretchr/testify/require" "github.com/uptrace/bun" - "moontracer/internal/manager/models" - "moontracer/internal/testutil" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/testutil" ) func seedMedia(t *testing.T, db *bun.DB, id, ownerID, campaignID, path string, kind models.MediaKind) *models.Media { diff --git a/internal/manager/models/session.go b/internal/manager/models/session.go new file mode 100644 index 0000000..137752b --- /dev/null +++ b/internal/manager/models/session.go @@ -0,0 +1,155 @@ +package models + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/uptrace/bun" +) + +// SessionStatus tracks the lifecycle of a scheduled session slot. +type SessionStatus string + +const ( + SessionUpcoming SessionStatus = "upcoming" + SessionComplete SessionStatus = "complete" + SessionCancelled SessionStatus = "cancelled" +) + +// Session represents a single scheduled session slot for a campaign. +type Session struct { + bun.BaseModel `bun:"table:sessions"` + + ID string `bun:",pk,notnull"` + CampaignID string `bun:",notnull"` + ScheduledAt time.Time `bun:",notnull"` + Title string `bun:",notnull,default:''"` + ChannelMsgID string `bun:",notnull,default:''"` // message ID of the announcement embed + Capacity int `bun:",notnull,default:0"` // 0 = use campaign capacity + AlertSent bool `bun:",notnull,default:false"` + Status SessionStatus `bun:",notnull,default:'upcoming'"` + CreatedAt time.Time `bun:",nullzero"` + + Campaign *Campaign `bun:"rel:belongs-to,join:campaign_id=id"` + RSVPs []SessionRSVP `bun:"rel:has-many,join:id=session_id"` +} + +// SessionRSVP records a player's RSVP for a specific session slot. +type SessionRSVP struct { + bun.BaseModel `bun:"table:session_rsvps"` + + SessionID string `bun:",pk,notnull"` + PlayerID string `bun:",pk,notnull"` + Status RSVPStatus `bun:",notnull,default:''"` + UpdatedAt time.Time `bun:",nullzero"` + + Session *Session `bun:"rel:belongs-to,join:session_id=id"` +} + +// NewSession creates a Session with a generated ID and sets CreatedAt. +func NewSession(campaignID string, scheduledAt time.Time, title string, capacity int) *Session { + return &Session{ + ID: uuid.NewString(), + CampaignID: campaignID, + ScheduledAt: scheduledAt, + Title: title, + Capacity: capacity, + Status: SessionUpcoming, + CreatedAt: time.Now().UTC(), + } +} + +// GetUpcomingSessions returns all upcoming sessions for a campaign, sorted soonest first. +func GetUpcomingSessions(db *bun.DB, campaignID string) ([]Session, error) { + ctx := context.Background() + var sessions []Session + err := db.NewSelect().Model(&sessions). + Where("campaign_id = ? AND status = ? AND scheduled_at > ?", campaignID, SessionUpcoming, time.Now().UTC()). + OrderExpr("scheduled_at ASC"). + Scan(ctx) + return sessions, err +} + +/* +GetAllUpcomingSessionsForPlayer returns all upcoming sessions across all campaigns +where the player is an active member. +*/ +func GetAllUpcomingSessionsForPlayer(db *bun.DB, playerID string) ([]Session, error) { + ctx := context.Background() + var sessions []Session + err := db.NewSelect().Model(&sessions). + Join("JOIN campaign_players cp ON cp.campaign_id = session.campaign_id"). + Where("cp.player_id = ? AND cp.status = ?", playerID, StatusActive). + Where("session.status = ? AND session.scheduled_at > ?", SessionUpcoming, time.Now().UTC()). + OrderExpr("session.scheduled_at ASC"). + Scan(ctx) + return sessions, err +} + +// GetSessionRSVPs returns all RSVPs for a session slot. +func GetSessionRSVPs(db *bun.DB, sessionID string) ([]SessionRSVP, error) { + ctx := context.Background() + var rsvps []SessionRSVP + err := db.NewSelect().Model(&rsvps). + Where("session_id = ?", sessionID). + Scan(ctx) + return rsvps, err +} + +// GetPlayerSessionRSVP returns the RSVP for a specific player and session, or nil if not found. +func GetPlayerSessionRSVP(db *bun.DB, sessionID, playerID string) (*SessionRSVP, error) { + ctx := context.Background() + r := &SessionRSVP{} + err := db.NewSelect().Model(r). + Where("session_id = ? AND player_id = ?", sessionID, playerID). + Scan(ctx) + if err != nil { + return nil, err + } + return r, nil +} + +// UpsertSessionRSVP creates or updates a player's RSVP for a session. +func UpsertSessionRSVP(db *bun.DB, sessionID, playerID string, status RSVPStatus) error { + ctx := context.Background() + r := &SessionRSVP{ + SessionID: sessionID, + PlayerID: playerID, + Status: status, + UpdatedAt: time.Now().UTC(), + } + _, err := db.NewInsert().Model(r). + On("CONFLICT (session_id, player_id) DO UPDATE SET status = EXCLUDED.status, updated_at = EXCLUDED.updated_at"). + Exec(ctx) + return err +} + +// CountAcceptedRSVPs returns the number of players who accepted a session (not waitlisted). +func CountAcceptedRSVPs(db *bun.DB, sessionID string) (int, error) { + ctx := context.Background() + count, err := db.NewSelect().Model((*SessionRSVP)(nil)). + Where("session_id = ? AND status = ?", sessionID, RSVPAccepted). + Count(ctx) + return count, err +} + +/* +GetPlayerConflictingSessions returns upcoming sessions the player has accepted +that overlap the given time window (~2 hours). +*/ +func GetPlayerConflictingSessions(db *bun.DB, playerID string, at time.Time) ([]Session, error) { + ctx := context.Background() + windowStart := at.Add(-2 * time.Hour) + windowEnd := at.Add(2 * time.Hour) + + var sessions []Session + err := db.NewSelect().Model(&sessions). + Join("JOIN session_rsvps sr ON sr.session_id = session.id"). + Where("sr.player_id = ? AND sr.status = ?", playerID, RSVPAccepted). + Where("session.scheduled_at >= ? AND session.scheduled_at <= ?", windowStart, windowEnd). + Where("session.status = ?", SessionUpcoming). + OrderExpr("session.scheduled_at ASC"). + Scan(ctx) + return sessions, err +} diff --git a/internal/mediaserver/download.go b/internal/mediaserver/download.go new file mode 100644 index 0000000..3f0ead9 --- /dev/null +++ b/internal/mediaserver/download.go @@ -0,0 +1,179 @@ +package mediaserver + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +/* +One-time download claim store. + +Provides opaque, short-lived download URLs for user-generated files (e.g. player tokens) +without exposing the server's internal directory structure. + +Flow (registration): + 1. Caller invokes Register(diskPath, name) after saving a file. + 2. A UUID claim token is generated and stored in globalClaims with a 10-minute TTL. + 3. Register returns a public URL of the form {downloadBase}/dl/{token}. + +Flow (serving): + 1. User clicks the download link; browser GETs /dl/{token}. + 2. dlHandler (registered in server.go) calls globalClaims.pop(token). + - If the token is unknown or expired: 404. + - If valid: removes the entry (one-time use) and streams the file. + 3. Response includes Content-Disposition: attachment; filename="{sanitized name}.png" + so the browser prompts a save dialog with the character's name, not a UUID. + +Sweep: + A background goroutine (started by Serve via startSweep) evicts expired entries + every minute so the map does not grow unbounded even if claims are never redeemed. +*/ + +const claimTTL = 10 * time.Minute + +/* +downloadBase is the public root URL used to build /dl/ links (e.g. "https://media.example.com"). + +Set via SetDownloadBase before Register is called. +*/ +var downloadBase string + +// SetDownloadBase sets the public server root URL for building download links. +func SetDownloadBase(url string) { + downloadBase = strings.TrimRight(url, "/") +} + +/* +Register creates a one-time opaque download claim for diskPath and returns its URL. + +The claim expires after 10 minutes or on first use, whichever comes first. +*/ +func Register(diskPath, name string) string { + token := uuid.NewString() + globalClaims.store(token, diskPath, name) + return fmt.Sprintf("%s/dl/%s", downloadBase, token) +} + +/* + Claim Store +*/ + +type claimEntry struct { + diskPath string + name string + expiresAt time.Time +} + +type claimStore struct { + mu sync.Mutex + m map[string]claimEntry +} + +var globalClaims = &claimStore{m: make(map[string]claimEntry)} + +func (s *claimStore) store(token, diskPath, name string) { + s.mu.Lock() + s.m[token] = claimEntry{diskPath: diskPath, name: name, expiresAt: time.Now().Add(claimTTL)} + s.mu.Unlock() +} + +func (s *claimStore) pop(token string) (claimEntry, bool) { + s.mu.Lock() + defer s.mu.Unlock() + e, ok := s.m[token] + if !ok || time.Now().After(e.expiresAt) { + delete(s.m, token) + return claimEntry{}, false + } + delete(s.m, token) + return e, true +} + +func (s *claimStore) sweep() { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + for token, e := range s.m { + if now.After(e.expiresAt) { + delete(s.m, token) + } + } +} + +func startSweep() { + go func() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for range ticker.C { + globalClaims.sweep() + } + }() +} + +/* +dlHandler serves a claimed file exactly once, then invalidates the token. + +URL: /dl/{token} +*/ +func dlHandler(w http.ResponseWriter, r *http.Request) { + token := strings.TrimPrefix(r.URL.Path, "/") + + // INFO: Reject anything that looks like a path traversal. + if token == "" || strings.ContainsAny(token, "/\\") { + http.NotFound(w, r) + return + } + + entry, ok := globalClaims.pop(token) + if !ok { + http.NotFound(w, r) + return + } + + f, err := os.Open(entry.diskPath) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + safe := sanitizeFilename(entry.name) + ".png" + w.Header().Set("Content-Disposition", `attachment; filename="`+safe+`"`) + w.Header().Set("Content-Type", "image/png") + http.ServeContent(w, r, safe, fi.ModTime(), f) +} + +/* +sanitizeFilename strips path separators and characters illegal in HTTP +Content-Disposition filenames, then truncates to 80 characters. +*/ +func sanitizeFilename(name string) string { + name = filepath.Base(name) + name = strings.Map(func(r rune) rune { + switch r { + case '"', '\\', '/', '\n', '\r', '\t': + return '_' + } + return r + }, name) + if len(name) > 80 { + name = name[:80] + } + if name == "" { + name = "token" + } + return name +} diff --git a/internal/mediaserver/server.go b/internal/mediaserver/server.go index 14ce916..5d2f80c 100644 --- a/internal/mediaserver/server.go +++ b/internal/mediaserver/server.go @@ -21,9 +21,12 @@ This server runs in a background goroutine; does not block the Dispatcher or oth func Serve(dataDir, addr string) { mux := http.NewServeMux() mux.Handle("/api/v1/cdn/", http.StripPrefix("/api/v1/cdn/", filesOnlyHandler(dataDir))) + mux.Handle("/dl/", http.StripPrefix("/dl/", http.HandlerFunc(dlHandler))) + + startSweep() go func() { - log.Printf("mediaserver: listening on %s: /api/v1/cdn/", addr) + log.Printf("mediaserver: listening on %s: /api/v1/cdn/ /dl/", addr) if err := http.ListenAndServe(addr, mux); err != nil { log.Fatalf("mediaserver: %v", err) } diff --git a/internal/mediaserver/token.go b/internal/mediaserver/token.go index e80c912..93d8f62 100644 --- a/internal/mediaserver/token.go +++ b/internal/mediaserver/token.go @@ -5,7 +5,7 @@ import ( "os" "path/filepath" - "moontracer/internal/tokengenerator" + "github.com/framebuffers/moontracer/internal/tokengenerator" ) /* diff --git a/internal/messages/messages.go b/internal/messages/messages.go index b3d102f..8402020 100644 --- a/internal/messages/messages.go +++ b/internal/messages/messages.go @@ -19,7 +19,7 @@ package messages // Generic const ( // identifiers - BotVersion = "v0.13.0-RC2" + BotVersion = "v0.14.0-RC3" ) const ( // user-facing @@ -46,9 +46,9 @@ NOTE: */ const ( // input format contracts. changing these breaks modal parsing - DateInputFormat = "2006-01-02" // YYYY-MM-DD (ISO 8601 date) + DateInputFormat = "02/01/2006" // DD/MM/YYYY TimeInputFormat = "15:04" // HH:MM (24h) - DateTimeInputFormat = "2006-01-02 15:04" // combined for ParseInLocation + DateTimeInputFormat = "02/01/2006 15:04" // combined for ParseInLocation ) const ( // user-facing display formats. swap per locale for localized day/month names @@ -292,10 +292,14 @@ const ( const ( // identifiers ManageCampaignsCommandName = "managecampaigns" + ManageCommandName = "manage" + ManageCommandOptionName = "campaign" ) const ( // user-facing ManageCampaignsCommandDesc = "Manage campaigns you run as DM." + ManageCommandDesc = "Manage one of your campaigns." + ManageCommandOptionDesc = "Campaign to manage." ManageCampaignsLabel = "Manage Campaigns" ManageNoDMCampaigns = "โš ๏ธ You are not the DM of any campaigns." ManageNotAuthorized = "โ„น๏ธ You must be the DM of this campaign to manage it." @@ -316,6 +320,7 @@ const ( // Token upload const ( // identifiers โ€” token gallery + TokensCommandName = "tokens" TokenGallerySelectPrefix = "token_gallery_select" TokenGalleryAssignPrefix = "token_gallery_assign" TokenGalleryAssignSelectPrefix = "token_gallery_assign_select" @@ -325,6 +330,7 @@ const ( ) const ( // user-facing โ€” token gallery + TokensCommandDesc = "Browse and manage your tokens." TokensLabel = "๐Ÿบ Tokens" TokenGalleryHeader = "Your tokens:" TokenGalleryNone = "โ„น๏ธ You have no tokens yet! Use `/newtoken` to create one." @@ -384,7 +390,7 @@ const ( // Campaign cover / upload const ( // identifiers - CampaignUploadCommandName = "campaignupload" + CampaignUploadCommandName = "uploadcover" CampaignUploadKindOptName = "kind" CampaignUploadKindCoverChoice = "Cover" CampaignUploadCampaignOptName = "campaign" @@ -392,7 +398,7 @@ const ( ) const ( // user-facing - CampaignUploadCommandDesc = "Upload an image for one of your campaigns." + CampaignUploadCommandDesc = "Upload a cover image for one of your campaigns." CampaignUploadKindOptDesc = "What kind of image to upload." CampaignUploadCampaignOptDesc = "The campaign to upload an image for." CampaignUploadImageOptDesc = "Image file (JPEG/PNG/WebP, up to 8 MiB)." @@ -403,7 +409,7 @@ const ( CampaignUploadFailure = "โš ๏ธ Failed to save cover. Please try again." CampaignUploadSuccess = "โœ… Cover set for **%s**. [View](%s)" SetCoverButtonLabel = "Set Cover" - SetCoverInstructions = "Use `/campaignupload kind:Cover campaign: image:` to set a cover for this campaign." + SetCoverInstructions = "Use `/uploadcover campaign: image:` to set a cover for this campaign." ) // Manage campaigns: button labels (all user-facing) @@ -456,8 +462,8 @@ const ( CampaignDenyReasonPlaceholder = "Why is this campaign being denied?" CampaignDeniedDMMessage = "๐Ÿšซ Your campaign **%s** has been denied. Reason: %s" CampaignApprovedDMMessage = "๐Ÿบ Your campaign **%s** has been approved!" - CampaignApprovedStatusMessage = "โœ… Approved campaign **%s**." - CampaignDeniedStatusMessage = "๐Ÿšซ Denied campaign **%s**. Reason: %s" + CampaignApprovedStatusMessage = "โœ… **%s** approved. Channels set up and DM notified." + CampaignDeniedStatusMessage = "๐Ÿšซ **%s** denied and removed. DM notified with reason: _%s_" CampaignDenyPendingMessage = "โ„น๏ธ Campaign **%s**: denial in progress..." ) @@ -484,6 +490,16 @@ const ( // CampaignsCategoryName is the shared Discord category all campaign channels are grouped under. const CampaignsCategoryName = "Campaigns" +// Thread initial pinned messages, keyed by standard thread name. +const ( + ThreadInitMsgAnnouncements = "๐Ÿ“ฃ Campaign announcements and session news will be posted here." + ThreadInitMsgSessions = "๐Ÿ“… Session schedules and notes will be posted here." + ThreadInitMsgDiceRolls = "๐ŸŽฒ Roll your dice and share your results here!" + ThreadInitMsgGeneral = "๐Ÿ’ฌ General campaign discussion." + // ThreadInitMsgWelcomeFmt takes the campaign name as its argument. + ThreadInitMsgWelcomeFmt = "๐Ÿบ Welcome to **%s**! This is your campaign channel. \nCheck the other threads for announcements, sessions, dice rolls, and general discussion." +) + // DayOfWeekInput maps accepted day-name inputs (lower-cased) to 0-based weekday index (Mon=0). var DayOfWeekInput = map[string]int{ "mon": 0, "monday": 0, @@ -720,27 +736,52 @@ const ( // New campaign config (post-modal dropdowns) const ( // identifiers - NewCampaignBookPrefix = "newcampaign_book" - NewCampaignFormatPrefix = "newcampaign_format" - NewCampaignSubmitPrefix = "newcampaign_submit" - NewCampaignCancelPrefix = "newcampaign_cancel" + NewCampaignBookPrefix = "newcampaign_book" + NewCampaignFormatPrefix = "newcampaign_format" + NewCampaignFreqPrefix = "newcampaign_freq" + NewCampaignSubmitPrefix = "newcampaign_submit" + NewCampaignCancelPrefix = "newcampaign_cancel" + NewCampaignScheduleModalID = "modal_newcampaign_schedule" + + NewCampaignScheduleDateFieldID = "newcampaign_sched_date" + NewCampaignScheduleTimeFieldID = "newcampaign_sched_time" ) const ( // user-facing - NewCampaignBookPlaceholder = "Select a game system..." - NewCampaignFormatPlaceholder = "Select a format..." - NewCampaignConfigMessage = "โ„น๏ธ Campaign **%s** created (pending setup).\n\nSelect a game system and format, then submit for approval. You can set a cover image and links from the campaign settings after approval." - NewCampaignSubmitLabel = "โœ… Submit for Approval" - NewCampaignCancelLabel = "โŒ Cancel" - NewCampaignSubmittedMessage = "โœ… Campaign **%s** has been submitted for approval!" - NewCampaignCancelledMessage = "โ„น๏ธ Campaign creation cancelled." - NewCampaignMissingSystemMessage = "โš ๏ธ Please select a game system before submitting." + NewCampaignBookPlaceholder = "Select a game system..." + NewCampaignFormatPlaceholder = "Select a format..." + NewCampaignFreqPlaceholder = "Select a schedule frequency..." + NewCampaignConfigMessage = "โ„น๏ธ Campaign **%s** created (pending setup).\n\nSelect a game system, format, and frequency, then submit for approval. You'll set the first session date in the next step." + NewCampaignSubmitLabel = "โœ… Submit for Approval" + NewCampaignCancelLabel = "โŒ Cancel" + NewCampaignSubmittedMessage = "โœ… Campaign **%s** has been submitted for approval!" + NewCampaignCancelledMessage = "โ„น๏ธ Campaign creation cancelled." + NewCampaignMissingConfigMessage = "โš ๏ธ Please select a game system and format before submitting." + + NewCampaignScheduleModalTitle = "๐Ÿ“… When's your first session?" + NewCampaignScheduleDateLabel = "Date (DD/MM/YYYY)" + NewCampaignScheduleTimeLabelFmt = "Time (%s)" + NewCampaignScheduleDatePlaceholder = "e.g. 14/06/2026" + NewCampaignScheduleTimePlaceholder = "e.g. 19:00" + NewCampaignScheduleSkipHint = "Leave blank to set a session later from campaign settings." + NewCampaignScheduleInvalidDate = "โš ๏ธ Invalid date format. Use DD/MM/YYYY (e.g. 14/06/2026)." + NewCampaignScheduleInvalidTime = "โš ๏ธ Invalid time format. Use HH:MM (e.g. 19:00)." + NewCampaignScheduleInPast = "โš ๏ธ That date/time is in the past. Please pick a future date." // Book dropdown option labels NewCampaignBookLabel5e = "D&D 5e" NewCampaignBookLabel55e = "D&D 5.5e (2024)" NewCampaignBookLabelPF2e = "Pathfinder 2e" NewCampaignBookLabelOther = "Other / Homebrew" + + // Frequency dropdown option labels + NewCampaignFreqLabelWeekly = "Weekly" + NewCampaignFreqLabelBiweekly = "Bi-weekly" + NewCampaignFreqLabelMonthly = "Monthly" + NewCampaignFreqLabelIrregular = "Irregular / As-needed" + + // Browse more after joining + BrowseMoreLabel = "๐Ÿ” Browse more campaigns" ) // Campaign Modal (all user-facing) @@ -769,12 +810,14 @@ const ( // Player hub: Next Sessions const ( // identifiers - NextSessionsPrefix = "next_sessions" + NextSessionsPrefix = "next_sessions" + NextSessionsCommandName = "nextsessions" ) const ( // user-facing - NextSessionsHeader = "Upcoming sessions:" - NextSessionsNone = "โ„น๏ธ You have no upcoming sessions." + NextSessionsHeader = "Upcoming sessions:" + NextSessionsNone = "โ„น๏ธ You have no upcoming sessions." + NextSessionsCommandDesc = "Show your upcoming sessions." ) // Player hub: Notifications @@ -880,11 +923,11 @@ const ( ManageSetSessionLabel = "๐Ÿ“… Set Session" ManageRescheduleSessionLabel = "โŒš Reschedule" ManageSetSessionModalTitle = "๐Ÿฐ Set Next Session" - ManageSetSessionDateLabel = "Date (YYYY-MM-DD)" - ManageSetSessionDatePlaceholder = "2026-05-08" + ManageSetSessionDateLabel = "Date (DD/MM/YYYY)" + ManageSetSessionDatePlaceholder = "08/05/2026" ManageSetSessionTimeLabel = "Time UTC (HH:MM, 24h)" ManageSetSessionTimePlaceholder = "19:00" - ManageSetSessionInvalidDate = "โš ๏ธ Invalid date format. Use YYYY-MM-DD." + ManageSetSessionInvalidDate = "โš ๏ธ Invalid date format. Use DD/MM/YYYY." ManageSetSessionInvalidTime = "โš ๏ธ Invalid time format. Use HH:MM (24h)." ManageSetSessionInPast = "โš ๏ธ Cannot set a session in the past." ManageSetSessionSuccess = "โœ… Next session for **%s** set to **%s** โ€” %s." @@ -908,7 +951,7 @@ const ( InviteCampaignFull = "โš ๏ธ Cannot invite โ€” campaign **%s** is full." ) -// Session RSVP (buttons on reminder DMs) +// Session RSVP โ€” legacy (campaign-level, reminder DMs only) const ( // identifiers RSVPAcceptPrefix = "rsvp_accept" @@ -926,6 +969,65 @@ const ( RSVPCampaignGone = "โ„น๏ธ This campaign is no longer active." ) +// New Session command + per-session RSVP (sessions table) +const ( + // identifiers + NewSessionCommandName = "newsession" + NewSessionOptionName = "campaign" + NewSessionModalID = "modal_new_session" + NewSessionDateFieldID = "new_session_date" + NewSessionTimeFieldID = "new_session_time" + NewSessionNotesFieldID = "new_session_notes" + ManageNewSessionPrefix = "manage_new_session" + + SessionRSVPAcceptPrefix = "session_rsvp_accept" + SessionRSVPDeclinePrefix = "session_rsvp_decline" + SessionRSVPConfirmPrefix = "session_rsvp_confirm" + SessionRSVPCancelPrefix = "session_rsvp_cancel" +) +const ( + // user-facing + NewSessionCommandDesc = "Schedule and announce a new session for your campaign." + NewSessionOptionDesc = "Campaign to schedule a session for" + NewSessionModalTitle = "๐Ÿ“… Schedule a New Session" + NewSessionNotesLabel = "What to expect (optional)" + NewSessionNotesPlaceholder = "e.g. Picking up from last time โ€” don't forget your character sheet!" + ManageNewSessionLabel = "๐Ÿ“… New Session" + + SessionEmbedGoingFmt = "โœ… Going: %d ยท โŒ Not Going: %d" + + SessionRSVPAcceptLabel = "โœ… Going" + SessionRSVPDeclineLabel = "โŒ Not Going" + + SessionRSVPAcceptedMsg = "โœ… You're in! The DM has been notified." + SessionRSVPDeclinedMsg = "โŒ Can't make it โ€” the DM has been notified." + SessionRSVPWaitlistedMsg = "โณ Session is full โ€” you're on the waitlist. The DM will confirm the final roster." + SessionRSVPConflictFmt = "โš ๏ธ You already have a session at this time: **%s** on %s.\nConfirm anyway?" + SessionRSVPConfirmLabel = "Confirm anyway" + SessionRSVPCancelLabel = "Cancel" + SessionRSVPAlreadySet = "โ„น๏ธ You've already responded to this session." + SessionRSVPNotMember = "โš ๏ธ Join this campaign before RSVPing to its sessions." + SessionRSVPGone = "โ„น๏ธ This session is no longer active." + + SessionRSVPDMNotifyAccept = "โœ… <@%s> is going to **%s** ยท %s" + SessionRSVPDMNotifyDecline = "โŒ <@%s> can't make it for **%s** ยท %s" + SessionRSVPDMNotifyWaitlist = "โณ <@%s> is on the waitlist for **%s** ยท %s (session full)" + + NewSessionAnnouncedFmt = "โœ… Session for **%s** announced โ€” %d member(s) notified." + NewSessionNoChannel = "โš ๏ธ This campaign has no channel. Set one up in campaign settings first." + NewSessionDMContentFmt = "๐Ÿ“… **%s** โ€” New Session!\n ยท %s" + SessionReminderContentFmt = "โฐ Reminder: **%s** starts in ~1 hour!\n%s" + + SessionEmbedTitleFmt = "๐Ÿ“… New Session โ€” %s" + SessionEmbedGoingLabel = "โœ… Going" + SessionEmbedNotGoingLabel = "โŒ Not Going" + SessionEmbedWaitlistedLabel = "โณ Waitlisted" + SessionRSVPCancelledMsg = "โ„น๏ธ RSVP cancelled." + SessionRSVPLineEmptyFmt = "%s (0): โ€”" + SessionRSVPLineFmt = "%s (%d): %s" + SessionRSVPLineOverflowFmt = " +%d more" +) + // Player campaign card (player self-service) const ( // identifiers diff --git a/internal/messages/strutils.go b/internal/messages/strutils.go index 010dc0a..7f08eb8 100644 --- a/internal/messages/strutils.go +++ b/internal/messages/strutils.go @@ -3,7 +3,7 @@ package messages import ( "strings" - "moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/manager/models" ) /* diff --git a/internal/scheduler/reminder.go b/internal/scheduler/reminder.go index f20e590..84ab191 100644 --- a/internal/scheduler/reminder.go +++ b/internal/scheduler/reminder.go @@ -1,6 +1,7 @@ package scheduler import ( + "context" "fmt" "log" "strings" @@ -8,11 +9,11 @@ import ( "github.com/bwmarrin/discordgo" - "moontracer/internal/db" - "moontracer/internal/dispatch" - "moontracer/internal/interactions/helpers" - "moontracer/internal/manager/models" - "moontracer/internal/messages" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/interactions/helpers" + "github.com/framebuffers/moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/messages" ) func fireReminder(s *Scheduler, guildID, campaignID string) { @@ -101,6 +102,81 @@ func fireReminder(s *Scheduler, guildID, campaignID string) { campaign.Name, campaignID, guildID, sent) } +// fireSessionReminder sends RSVP reminder DMs for a session-table session. +func fireSessionReminder(s *Scheduler, guildID, sessionID string) { + gdb, err := s.guildDBM.GetOrCreate(guildID) + if err != nil { + log.Printf("scheduler: session fire: get db for guild %s: %v", guildID, err) + return + } + + session := &models.Session{} + if err := gdb.NewSelect().Model(session).Where("id = ?", sessionID).Scan(context.Background()); err != nil { + log.Printf("scheduler: session fire: load session %s: %v", sessionID, err) + return + } + if session.AlertSent || session.Status != models.SessionUpcoming || !session.ScheduledAt.After(time.Now().UTC()) { + return + } + + session.AlertSent = true + if _, err := gdb.NewUpdate().Model(session).Column("alert_sent").WherePK().Exec(context.Background()); err != nil { + log.Printf("scheduler: session fire: mark alert_sent for %s: %v", sessionID, err) + return + } + + campaign, err := db.GetByID[models.Campaign](gdb, session.CampaignID) + if err != nil { + log.Printf("scheduler: session fire: load campaign %s: %v", session.CampaignID, err) + return + } + + players, err := models.GetCampaignPlayers(gdb, session.CampaignID) + if err != nil { + log.Printf("scheduler: session fire: load players for campaign %s: %v", session.CampaignID, err) + return + } + + sent := 0 + for _, p := range players { + if p.Status != models.StatusActive && p.Status != models.StatusPending && p.Status != models.StatusHiatus { + continue + } + settings, err := models.GetOrCreatePlayerSettings(gdb, p.PlayerID) + if err != nil || !settings.NotifySessionRemind { + continue + } + displayTime := helpers.FormatInLocation(session.ScheduledAt, messages.SessionTimeFormat, settings.Location()) + content := fmt.Sprintf(messages.SessionReminderContentFmt, + campaign.Name, + session.ScheduledAt.Unix(), + formatReminderLinks(campaign, p.SheetURL), + ) + _ = displayTime // shown via Discord timestamp in content + s.dispatcher.Push(dispatch.DirectMessage{ + ID: fmt.Sprintf("session-reminder:%s:%s", sessionID, p.PlayerID), + Target: p.PlayerID, + Content: content, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.Button{ + Label: messages.SessionRSVPAcceptLabel, + Style: discordgo.SuccessButton, + CustomID: fmt.Sprintf("%s:%s:%s", messages.SessionRSVPAcceptPrefix, guildID, sessionID), + }, + discordgo.Button{ + Label: messages.SessionRSVPDeclineLabel, + Style: discordgo.DangerButton, + CustomID: fmt.Sprintf("%s:%s:%s", messages.SessionRSVPDeclinePrefix, guildID, sessionID), + }, + }}, + }, + }) + sent++ + } + log.Printf("scheduler: fired session reminder for %s (guild %s) โ€” %d DM(s) queued", sessionID, guildID, sent) +} + func formatReminderLinks(c *models.Campaign, sheetURL string) string { hasVTT := c.VTTLink != "" hasSheets := sheetURL != "" diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 7c1ab97..f7a03b2 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -1,13 +1,14 @@ package scheduler import ( + "context" "log" "sync" "time" - "moontracer/internal/db" - "moontracer/internal/dispatch" - "moontracer/internal/manager/models" + "github.com/framebuffers/moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/dispatch" + "github.com/framebuffers/moontracer/internal/manager/models" ) const DefaultLeadTime = time.Hour @@ -29,13 +30,11 @@ func New(guildDBM *db.GuildDBManager, dispatcher *dispatch.Dispatcher) *Schedule } } -func timerKey(guildID, campaignID string) string { - return guildID + "/" + campaignID -} +func timerKey(guildID, id string) string { return guildID + "/" + id } /* -BootScan re-schedules reminders for any campaign where NextSession is in the -future and AlertSent is false. +BootScan re-schedules reminders for upcoming sessions (sessions table) and any +legacy campaigns that still have NextSession set outside the sessions table. Call once after all guild DBs are initialised. */ @@ -48,9 +47,27 @@ func (s *Scheduler) BootScan(guildIDs []string) { log.Printf("scheduler: boot scan: get db for guild %s: %v", guildID, err) continue } + + // Session-table reminders (new path). + var sessions []models.Session + if err := gdb.NewSelect().Model(&sessions). + Where("status = ? AND scheduled_at > ? AND alert_sent = 0", models.SessionUpcoming, now). + Scan(context.Background()); err != nil { + log.Printf("scheduler: boot scan sessions for guild %s: %v", guildID, err) + } + for i := range sessions { + s.ScheduleSession(guildID, &sessions[i]) + total++ + } + + /* + LEGACY CODE: + Legacy campaign-level reminders (campaigns that have NextSession set but + no corresponding session row yet: they were not seeded by migration). + */ campaigns, err := db.GetAll[models.Campaign](gdb) if err != nil { - log.Printf("scheduler: boot scan: query guild %s: %v", guildID, err) + log.Printf("scheduler: boot scan campaigns for guild %s: %v", guildID, err) continue } for i := range campaigns { @@ -61,6 +78,15 @@ func (s *Scheduler) BootScan(guildIDs []string) { if !c.Schedule.NextSession.After(now) { continue } + // Skip if a session row covers this (to avoid duplicate reminders). + var count int + count, _ = gdb.NewSelect().Model((*models.Session)(nil)). + Where("campaign_id = ? AND scheduled_at = ? AND status = ?", + c.ID, c.Schedule.NextSession, models.SessionUpcoming). + Count(context.Background()) + if count > 0 { + continue + } s.Schedule(guildID, c) total++ } @@ -109,6 +135,46 @@ func (s *Scheduler) Schedule(guildID string, campaign *models.Campaign) { log.Printf("scheduler: reminder for campaign %s (guild %s) in %v", campaignID, guildID, delay.Truncate(time.Second)) } +/* +ScheduleSession sets a session-level reminder timer. + +Uses timerKey(guildID, session.ID) so it never collides with campaign-level timers. +*/ +func (s *Scheduler) ScheduleSession(guildID string, session *models.Session) { + if s == nil { + return + } + if session.AlertSent || session.Status != models.SessionUpcoming { + return + } + now := time.Now().UTC() + if !session.ScheduledAt.After(now) { + return + } + + delay := time.Until(session.ScheduledAt.Add(-DefaultLeadTime)) + if delay < 0 { + delay = 0 + } + + key := timerKey(guildID, session.ID) + sessionID := session.ID + + s.mu.Lock() + if existing, ok := s.timers[key]; ok { + existing.Stop() + } + s.timers[key] = time.AfterFunc(delay, func() { + s.mu.Lock() + delete(s.timers, key) + s.mu.Unlock() + fireSessionReminder(s, guildID, sessionID) + }) + s.mu.Unlock() + + log.Printf("scheduler: session reminder for %s (guild %s) in %v", sessionID, guildID, delay.Truncate(time.Second)) +} + /* Cancel stops the pending timer for a campaign without firing it. diff --git a/internal/testutil/db.go b/internal/testutil/db.go index 5b26f54..b13d930 100644 --- a/internal/testutil/db.go +++ b/internal/testutil/db.go @@ -8,11 +8,14 @@ import ( "github.com/uptrace/bun/dialect/sqlitedialect" "github.com/uptrace/bun/driver/sqliteshim" - "moontracer/internal/db" + "github.com/framebuffers/moontracer/internal/db" ) -// NewTestDB creates a fresh in-memory SQLite database with all migrations applied. -// The database is automatically closed when the test completes. +/* +NewTestDB creates a fresh in-memory SQLite database with all migrations applied. + +The database is automatically closed when the test completes. +*/ func NewTestDB(t *testing.T) *bun.DB { t.Helper()