diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..186fcfd
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,29 @@
+# Copy to .env and fill in real values. Never commit .env.
+
+DISCORD_BOT_TOKEN=""
+
+VERBOSE=true
+# SAFE_MODE blocks Discord side effects (role grants, DMs) while the DB still runs.
+SAFE_MODE=true
+# DEV_MODE exposes debug surfaces (Diagnostics panel, /campaigndatabase). MUST be false in prod.
+DEV_MODE=false
+
+# When set, slash commands register only to this guild (fast dev iteration).
+# Leave empty in production to register globally.
+DISCORD_GUILD_ID=""
+DEBUG_ADMIN_ID=""
+
+MEDIA_BASE_URL=""
+MEDIA_PORT=""
+
+ADMIN_ROLE_NAME=""
+MOD_ROLE_NAME=""
+
+# Host path mounted at /app/data (SQLite DB + uploaded media).
+MOONTRACER_DATA_DIR="./data"
+
+# Docker network the container joins. Leave empty to let compose create an
+# isolated network. To attach to an existing reverse-proxy network, set
+# MOONTRACER_PROXY_NETWORK to that network's name AND MOONTRACER_PROXY_EXTERNAL=true.
+MOONTRACER_PROXY_NETWORK=""
+MOONTRACER_PROXY_EXTERNAL=""
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..aaa9c1c
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,19 @@
+name: tests
+
+on:
+ push:
+ branches: [main, dev]
+ pull_request:
+ branches: [main, dev]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.25.6'
+ cache: true
+ - run: go build ./...
+ - run: go test ./... -race -count=1
diff --git a/.gitignore b/.gitignore
index 496e968..0dde2a1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,118 +1,49 @@
-# Dependencies
-node_modules/
-.pnp
-.pnp.js
-
-# Bun
-bun.lockb
-.bun/
-
-# Testing
-coverage/
-.nyc_output/
-*.lcov
-
-# Next.js
-.next/
-out/
-build/
-dist/
-
-# Production
-*.tsbuildinfo
-next-env.d.ts
-
-# Misc
-.DS_Store
-*.pem
-Thumbs.db
-
-# Debug
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-.pnpm-debug.log*
-lerna-debug.log*
-
-# Local env files
-.env
-.env.*
-!.env.example
-!.env.template
-
-# Secrets
-# (CRITICAL - never ever ever **ever** commit)
-secrets/
-*.key
-*.pem
-*.crt
-*.p12
-*.pfx
-
-# Vercel
-.vercel
-
-# TypeScript
-*.tsbuildinfo
-
-# Logs
-logs/
-*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-pnpm-debug.log*
-lerna-debug.log*
-
-# IDE
-.vscode/
-!.vscode/extensions.json
-!.vscode/settings.json
-.idea/
-*.swp
-*.swo
-*~
-.project
-.classpath
-.c9/
-*.launch
-.settings/
-*.sublime-workspace
-.fleet/
-
-# OS
-.DS_Store
-.DS_Store?
-._*
-.Spotlight-V100
-.Trashes
-ehthumbs.db
-Thumbs.db
-desktop.ini
-
-# Docker
-.dockerignore
-
-# Old/archived files
-.old/
-*.old
-*~
-.backup/
-*.backup
-*.bak
-
-# Temporary build directories
-/tmp/
-temp/
-.tmp/
-
-# Database
-*.db
-*.sqlite
-*.sqlite3
-
-# Misc
-.cache/
-.parcel-cache/
-.turbo/
-*.tar
\ No newline at end of file
+# Old TypeScript codebase
+.old/
+
+# Go
+/moontracer
+/cmd/moontracer/moontracer
+/bin/
+
+# Secrets (CRITICAL - never commit)
+secrets/
+*.key
+*.pem
+*.crt
+*.p12
+*.pfx
+
+# Env
+.env
+.env.*
+!.env.example
+
+# OS
+.DS_Store
+Thumbs.db
+
+# IDE
+.vscode/
+!.vscode/extensions.json
+!.vscode/settings.json
+.idea/
+*.swp
+*.swo
+*~
+
+# Docker
+.dockerignore
+
+# Temp
+/tmp/
+.cache/
+
+# Database
+*.db
+*.sqlite
+*.sqlite3
+
+# Logs
+*.log
+.claude/
diff --git a/Dockerfile b/Dockerfile
old mode 100644
new mode 100755
index 29b6b5e..dc50368
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,47 +1,19 @@
-FROM oven/bun:1-alpine AS base
-
-FROM base AS deps
-RUN apk add --no-cache libc6-compat
-WORKDIR /app
-COPY package.json bun.lock ./
-RUN bun install --frozen-lockfile
-
-FROM base AS builder
-WORKDIR /app
-COPY --from=deps /app/node_modules ./node_modules
-COPY . .
-RUN bun run build
-
-FROM base AS runner
-WORKDIR /app
-
-ENV NODE_ENV=production
-ENV NEXT_TELEMETRY_DISABLED=1
-ENV PORT=3000
-ENV HOSTNAME="0.0.0.0"
-
-# Install runtime dependencies
-RUN apk add --no-cache dumb-init su-exec
-
-COPY --from=builder --chown=bun:bun /app/public ./public
-COPY --from=builder --chown=bun:bun /app/.next/standalone ./
-COPY --from=builder --chown=bun:bun /app/.next/static ./.next/static
-
-# Create entrypoint
-COPY <<'EOF' /docker-entrypoint.sh
-#!/bin/sh
-set -e
-
-if [ -d /run/secrets ]; then
- chmod 644 /run/secrets/* 2>/dev/null || true
-fi
-
-exec su-exec bun bun server.js "$@"
-EOF
-
-RUN chmod +x /docker-entrypoint.sh
-
-EXPOSE 3000
-
-ENTRYPOINT ["/usr/bin/dumb-init", "--"]
-CMD ["/docker-entrypoint.sh"]
\ No newline at end of file
+FROM golang:1-alpine AS build
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+RUN BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) && \
+ CGO_ENABLED=0 go build \
+ -ldflags "-X moontracer/internal/commands.BuildTime=${BUILD_TIME}" \
+ -o /moontracer ./cmd/moontracer
+
+FROM alpine:3.21
+RUN apk add --no-cache ca-certificates
+
+RUN mkdir -p /app/data
+WORKDIR /app
+
+COPY --from=build /moontracer /app/moontracer
+
+ENTRYPOINT ["/app/moontracer"]
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..0ad25db
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/README.md b/README.md
index e215bc4..26a56d9 100644
--- a/README.md
+++ b/README.md
@@ -1,36 +1,220 @@
-This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
-
-## Getting Started
-
-First, run the development server:
-
-```bash
-npm run dev
-# or
-yarn dev
-# or
-pnpm dev
-# or
-bun dev
-```
-
-Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
-
-You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
-
-This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
-
-## Learn More
-
-To learn more about Next.js, take a look at the following resources:
-
-- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
-- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
-
-You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
-
-## Deploy on Vercel
-
-The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
-
-Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+```
+* * * * * π*
+ * * * * *
+ββ ββββββββ ββββββββββββββββββ
+ββββ ββ ββ β β βββββββ ββββββ
+ββββββββββ β β β ββ ββββββββ β
+* *awoo* * * *
+ πΊ * * *
+```
+
+# πΊ Moontracer
+
+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.
+
+## π Thanks!
+
+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!
+
+πΊ awoo! π
\ No newline at end of file
diff --git a/assets/moontracer-nightly.png b/assets/moontracer-nightly.png
new file mode 100644
index 0000000..73ad532
Binary files /dev/null and b/assets/moontracer-nightly.png differ
diff --git a/assets/moontracer-stable-v1.png b/assets/moontracer-stable-v1.png
new file mode 100644
index 0000000..0f31a51
Binary files /dev/null and b/assets/moontracer-stable-v1.png differ
diff --git a/assets/moontracer-stable-v2.png b/assets/moontracer-stable-v2.png
new file mode 100644
index 0000000..64b484b
Binary files /dev/null and b/assets/moontracer-stable-v2.png differ
diff --git a/bun.lock b/bun.lock
deleted file mode 100644
index 61ece4b..0000000
--- a/bun.lock
+++ /dev/null
@@ -1,934 +0,0 @@
-{
- "lockfileVersion": 1,
- "configVersion": 0,
- "workspaces": {
- "": {
- "name": "moontracer",
- "dependencies": {
- "bun": "^1.3.5",
- "discord-interactions": "^4.4.0",
- "next": "^16.1.1",
- "postgres": "^3.4.7",
- "react": "19.2.3",
- "react-dom": "19.2.3",
- "tweetnacl": "^1.0.3",
- },
- "devDependencies": {
- "@tailwindcss/postcss": "^4",
- "@types/bun": "latest",
- "@types/node": "^25.0.9",
- "@types/react": "^19",
- "@types/react-dom": "^19",
- "babel-plugin-react-compiler": "1.0.0",
- "eslint": "^9",
- "eslint-config-next": "16.1.1",
- "tailwindcss": "^4",
- "typescript": "^5",
- },
- },
- },
- "trustedDependencies": [
- "sharp",
- "bun",
- "unrs-resolver",
- ],
- "packages": {
- "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
-
- "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
-
- "@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
-
- "@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
-
- "@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
-
- "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
-
- "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
-
- "@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
-
- "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
-
- "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
-
- "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
-
- "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
-
- "@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
-
- "@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
-
- "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
-
- "@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
-
- "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
-
- "@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
-
- "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
-
- "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
-
- "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
-
- "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
-
- "@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
-
- "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
-
- "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
-
- "@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="],
-
- "@eslint/js": ["@eslint/js@9.39.2", "", {}, "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA=="],
-
- "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
-
- "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
-
- "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
-
- "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
-
- "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
-
- "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
-
- "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
-
- "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
-
- "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
-
- "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
-
- "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
-
- "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
-
- "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
-
- "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
-
- "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
-
- "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
-
- "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
-
- "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
-
- "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
-
- "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
-
- "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
-
- "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
-
- "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
-
- "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
-
- "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
-
- "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
-
- "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
-
- "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
-
- "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
-
- "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
-
- "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
-
- "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
-
- "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
-
- "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
-
- "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
-
- "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
-
- "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
-
- "@next/env": ["@next/env@16.1.1", "", {}, "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA=="],
-
- "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.1.1", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw=="],
-
- "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA=="],
-
- "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw=="],
-
- "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ=="],
-
- "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg=="],
-
- "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ=="],
-
- "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA=="],
-
- "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA=="],
-
- "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw=="],
-
- "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
-
- "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
-
- "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
-
- "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
-
- "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8GvNtMo0NINM7Emk9cNAviCG3teEgr3BUX9be0+GD029zIagx2Sf54jMui1Eu1IpFm7nWHODuLEefGOQNaJ0gQ=="],
-
- "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-r33eHQOHAwkuiBJIwmkXIyqONQOQMnd1GMTpDzaxx9vf9+svby80LZO9Hcm1ns6KT/TBRFyODC/0loA7FAaffg=="],
-
- "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-p5q3rJk48qhLuLBOFehVc+kqCE03YrswTc6NCxbwsxiwfySXwcAvpF2KWKF/ZZObvvR8hCCvqe1F81b2p5r2dg=="],
-
- "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-zkcHPI23QxJ1TdqafhgkXt1NOEN8o5C460sVeNnrhfJ43LwZgtfcvcQE39x/pBedu67fatY8CU0iY00nOh46ZQ=="],
-
- "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-HKBeUlJdNduRkzJKZ5DXM+pPqntfC50/Hu2X65jVX0Y7hu/6IC8RaUTqpr8FtCZqqmc9wDK0OTL+Mbi9UQIKYQ=="],
-
- "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-n7zhKTSDZS0yOYg5Rq8easZu5Y/o47sv0c7yGr2ciFdcie9uYV55fZ7QMqhWMGK33ezCSikh5EDkUMCIvfWpjA=="],
-
- "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FeCQyBU62DMuB0nn01vPnf3McXrKOsrK9p7sHaBFYycw0mmoU8kCq/WkBkGMnLuvQljJSyen8QBTx+fXdNupWg=="],
-
- "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-XkCCHkByYn8BIDvoxnny898znju4xnW2kvFE8FT5+0Y62cWdcBGMZ9RdsEUTeRz16k8hHtJpaSfLcEmNTFIwRQ=="],
-
- "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-TJiYC7KCr0XxFTsxgwQOeE7dncrEL/RSyL0EzSL3xRkrxJMWBCvCSjQn7LV1i6T7hFst0+3KoN3VWvD5BinqHA=="],
-
- "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-T3xkODItb/0ftQPFsZDc7EAX2D6A4TEazQ2YZyofZToO8Q7y8YT8ooWdhd0BQiTCd66uEvgE1DCZetynwg2IoA=="],
-
- "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-rtVQB9/1XK8FWJgFtsOthbPifRMYypgJwxu+pK3NHx8WvFKmq7HcPDqNr8xLzGULjQEO7eAo2aOZfONOwYz+5g=="],
-
- "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
-
- "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
-
- "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
-
- "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
-
- "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
-
- "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
-
- "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
-
- "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
-
- "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
-
- "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
-
- "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
-
- "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
-
- "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
-
- "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
-
- "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
-
- "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
-
- "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="],
-
- "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
-
- "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
-
- "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
-
- "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
-
- "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
-
- "@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
-
- "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
-
- "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
-
- "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.51.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/type-utils": "8.51.0", "@typescript-eslint/utils": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.2.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.51.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og=="],
-
- "@typescript-eslint/parser": ["@typescript-eslint/parser@8.51.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A=="],
-
- "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.51.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.51.0", "@typescript-eslint/types": "^8.51.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ=="],
-
- "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0" } }, "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA=="],
-
- "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.51.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw=="],
-
- "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/utils": "8.51.0", "debug": "^4.3.4", "ts-api-utils": "^2.2.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q=="],
-
- "@typescript-eslint/types": ["@typescript-eslint/types@8.51.0", "", {}, "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="],
-
- "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.51.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.51.0", "@typescript-eslint/tsconfig-utils": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.2.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng=="],
-
- "@typescript-eslint/utils": ["@typescript-eslint/utils@8.51.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA=="],
-
- "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg=="],
-
- "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="],
-
- "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="],
-
- "@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.11.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g=="],
-
- "@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.11.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ=="],
-
- "@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.11.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw=="],
-
- "@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw=="],
-
- "@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw=="],
-
- "@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ=="],
-
- "@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w=="],
-
- "@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.11.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA=="],
-
- "@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ=="],
-
- "@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew=="],
-
- "@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.11.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg=="],
-
- "@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w=="],
-
- "@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA=="],
-
- "@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.11.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" }, "cpu": "none" }, "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ=="],
-
- "@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.11.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw=="],
-
- "@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.11.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ=="],
-
- "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
-
- "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
-
- "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
-
- "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
-
- "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
-
- "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
-
- "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
-
- "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
-
- "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="],
-
- "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="],
-
- "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="],
-
- "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="],
-
- "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="],
-
- "array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="],
-
- "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
-
- "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
-
- "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
-
- "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
-
- "axe-core": ["axe-core@4.11.0", "", {}, "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ=="],
-
- "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
-
- "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
-
- "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
-
- "baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="],
-
- "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
-
- "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
-
- "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
-
- "bun": ["bun@1.3.5", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.5", "@oven/bun-darwin-x64": "1.3.5", "@oven/bun-darwin-x64-baseline": "1.3.5", "@oven/bun-linux-aarch64": "1.3.5", "@oven/bun-linux-aarch64-musl": "1.3.5", "@oven/bun-linux-x64": "1.3.5", "@oven/bun-linux-x64-baseline": "1.3.5", "@oven/bun-linux-x64-musl": "1.3.5", "@oven/bun-linux-x64-musl-baseline": "1.3.5", "@oven/bun-windows-x64": "1.3.5", "@oven/bun-windows-x64-baseline": "1.3.5" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-c1YHIGUfgvYPJmLug5QiLzNWlX2Dg7X/67JWu1Va+AmMXNXzC/KQn2lgQ7rD+n1u1UqDpJMowVGGxTNpbPydNw=="],
-
- "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
-
- "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
-
- "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
-
- "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
-
- "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
-
- "caniuse-lite": ["caniuse-lite@1.0.30001762", "", {}, "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw=="],
-
- "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
-
- "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
-
- "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
-
- "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
-
- "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
-
- "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
-
- "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
-
- "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
-
- "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
-
- "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
-
- "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
-
- "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
-
- "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
-
- "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
-
- "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
-
- "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
-
- "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
-
- "discord-interactions": ["discord-interactions@4.4.0", "", {}, "sha512-jjJx8iwAeJcj8oEauV43fue9lNqkf38fy60aSs2+G8D1nJmDxUIrk08o3h0F3wgwuBWWJUZO+X/VgfXsxpCiJA=="],
-
- "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
-
- "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
-
- "electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
-
- "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
-
- "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
-
- "es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
-
- "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
-
- "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
-
- "es-iterator-helpers": ["es-iterator-helpers@1.2.2", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.1", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "safe-array-concat": "^1.1.3" } }, "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w=="],
-
- "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
-
- "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
-
- "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="],
-
- "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
-
- "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
-
- "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
-
- "eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="],
-
- "eslint-config-next": ["eslint-config-next@16.1.1", "", { "dependencies": { "@next/eslint-plugin-next": "16.1.1", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^7.0.0", "globals": "16.4.0", "typescript-eslint": "^8.46.0" }, "peerDependencies": { "eslint": ">=9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-55nTpVWm3qeuxoQKLOjQVciKZJUphKrNM0fCcQHAIOGl6VFXgaqeMfv0aKJhs7QtcnlAPhNVqsqRfRjeKBPIUA=="],
-
- "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="],
-
- "eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import", "eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="],
-
- "eslint-module-utils": ["eslint-module-utils@2.12.1", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="],
-
- "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="],
-
- "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="],
-
- "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="],
-
- "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
-
- "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
-
- "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
-
- "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
-
- "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
-
- "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
-
- "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
-
- "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
-
- "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
-
- "fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
-
- "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
-
- "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
-
- "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
-
- "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
-
- "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
-
- "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
-
- "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
-
- "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
-
- "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
-
- "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
-
- "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
-
- "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="],
-
- "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
-
- "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
-
- "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
-
- "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
-
- "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
-
- "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
-
- "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
-
- "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
-
- "globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="],
-
- "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
-
- "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
-
- "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
-
- "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
-
- "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
-
- "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
-
- "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="],
-
- "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
-
- "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
-
- "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
-
- "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
-
- "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
-
- "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
-
- "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
-
- "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
-
- "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
-
- "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
-
- "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
-
- "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="],
-
- "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="],
-
- "is-bun-module": ["is-bun-module@2.0.0", "", { "dependencies": { "semver": "^7.7.1" } }, "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ=="],
-
- "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
-
- "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
-
- "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="],
-
- "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="],
-
- "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
-
- "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
-
- "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
-
- "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
-
- "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
-
- "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="],
-
- "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
-
- "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
-
- "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
-
- "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="],
-
- "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
-
- "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
-
- "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="],
-
- "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
-
- "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="],
-
- "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="],
-
- "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="],
-
- "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
-
- "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
-
- "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="],
-
- "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
-
- "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
-
- "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
-
- "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
-
- "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
-
- "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
-
- "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
-
- "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
-
- "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
-
- "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
-
- "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="],
-
- "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="],
-
- "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
-
- "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
-
- "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
-
- "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
-
- "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
-
- "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
-
- "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
-
- "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
-
- "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
-
- "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
-
- "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
-
- "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
-
- "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
-
- "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
-
- "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
-
- "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
-
- "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
-
- "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
-
- "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
-
- "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
-
- "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
-
- "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
-
- "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
-
- "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
-
- "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
-
- "napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="],
-
- "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
-
- "next": ["next@16.1.1", "", { "dependencies": { "@next/env": "16.1.1", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.1", "@next/swc-darwin-x64": "16.1.1", "@next/swc-linux-arm64-gnu": "16.1.1", "@next/swc-linux-arm64-musl": "16.1.1", "@next/swc-linux-x64-gnu": "16.1.1", "@next/swc-linux-x64-musl": "16.1.1", "@next/swc-win32-arm64-msvc": "16.1.1", "@next/swc-win32-x64-msvc": "16.1.1", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w=="],
-
- "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
-
- "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
-
- "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
-
- "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
-
- "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
-
- "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="],
-
- "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="],
-
- "object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="],
-
- "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
-
- "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
-
- "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
-
- "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
-
- "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
-
- "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
-
- "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
-
- "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
-
- "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
-
- "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
-
- "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
-
- "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
-
- "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
-
- "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
-
- "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
-
- "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
-
- "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
-
- "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
-
- "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
-
- "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
-
- "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
-
- "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
-
- "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
-
- "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
-
- "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
-
- "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
-
- "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
-
- "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
-
- "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
-
- "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
-
- "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
-
- "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
-
- "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
-
- "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
-
- "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
-
- "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
-
- "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
-
- "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
-
- "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
-
- "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
-
- "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
-
- "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
-
- "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
-
- "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
-
- "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
-
- "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
-
- "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
-
- "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
-
- "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="],
-
- "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="],
-
- "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="],
-
- "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
-
- "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
-
- "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
-
- "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
-
- "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
-
- "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
-
- "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
-
- "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
-
- "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
-
- "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
-
- "ts-api-utils": ["ts-api-utils@2.3.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg=="],
-
- "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
-
- "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
-
- "tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="],
-
- "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
-
- "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
-
- "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
-
- "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="],
-
- "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="],
-
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
-
- "typescript-eslint": ["typescript-eslint@8.51.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.51.0", "@typescript-eslint/parser": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/utils": "8.51.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA=="],
-
- "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
-
- "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
-
- "unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="],
-
- "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
-
- "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
-
- "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
-
- "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
-
- "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="],
-
- "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="],
-
- "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
-
- "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
-
- "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
-
- "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
-
- "zod": ["zod@4.3.4", "", {}, "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A=="],
-
- "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
-
- "@babel/core/json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
-
- "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
-
- "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
-
- "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
-
- "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
-
- "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
-
- "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
-
- "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
-
- "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
-
- "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
-
- "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
-
- "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
-
- "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
-
- "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
-
- "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
-
- "eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="],
-
- "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
-
- "is-bun-module/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
-
- "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
-
- "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
-
- "sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
-
- "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
- }
-}
diff --git a/cmd/moontracer/main.go b/cmd/moontracer/main.go
new file mode 100644
index 0000000..601b413
--- /dev/null
+++ b/cmd/moontracer/main.go
@@ -0,0 +1,80 @@
+package main
+
+import (
+ "log"
+ "os"
+
+ // NOTE: this embeds the IANA timezone database so LoadLocation works without system tzdata
+ _ "time/tzdata"
+
+ "github.com/framebuffers/moontracer/internal/db"
+ "github.com/framebuffers/moontracer/internal/discord"
+ "github.com/framebuffers/moontracer/internal/mediaserver"
+)
+
+/*
+Flow:
+ 1. Read required env vars: DISCORD_BOT_TOKEN, ADMIN_ROLE_NAME. Optional: MOD_ROLE_NAME.
+ 2. Read optional DISCORD_GUILD_ID (dev mode: register commands to single guild).
+ 3. Each guild gets its own SQLite DB in the "data" directory (bind-mounted via Docker).
+ 4. Create GuildDBManager (per-guild databases are created on demand).
+ 5. Create Discord bot with token, guild ID, admin role name, and GuildDBManager.
+ 6. Start the bot β open gateway, discover guilds, init DBs, register commands, listen for interactions.
+ 7. Block until SIGINT/SIGTERM (Ctrl+C), then clean up and exit.
+*/
+func main() {
+ if os.Getenv("VERBOSE") == "true" {
+ log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds | log.Lshortfile)
+ log.Println("verbose mode enabled")
+ }
+
+ token := os.Getenv("DISCORD_BOT_TOKEN")
+ if token == "" {
+ log.Fatal("DISCORD_BOT_TOKEN is required")
+ }
+
+ // optional: if set, commands register to that guild only (instant); if empty, commands register globally (up to 1h propagation)
+ guildID := os.Getenv("DISCORD_GUILD_ID")
+
+ adminRole := os.Getenv("ADMIN_ROLE_NAME")
+ if adminRole == "" {
+ log.Fatal("ADMIN_ROLE_NAME is required")
+ }
+
+ // optional: if empty, mods are admin-assigned only
+ modRole := os.Getenv("MOD_ROLE_NAME")
+
+ dbDir := "data"
+
+ if err := os.MkdirAll(dbDir, 0755); err != nil {
+ log.Fatalf("failed to create DB directory %s: %v", dbDir, err)
+ }
+
+ guildDBM := db.NewGuildDBManager(dbDir)
+
+ mediaPort := os.Getenv("MEDIA_PORT")
+ if mediaPort == "" {
+ mediaPort = "8090"
+ }
+ mediaBaseURL := os.Getenv("MEDIA_BASE_URL")
+ if mediaBaseURL == "" {
+ 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)
+
+ bot, err := discord.New(token, guildID, adminRole, modRole, dbDir, mediaBaseURL, guildDBM)
+ if err != nil {
+ log.Fatalf("failed to create bot: %v", err)
+ }
+
+ if err := bot.Run(); err != nil {
+ log.Fatalf("bot error: %v", err)
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
old mode 100644
new mode 100755
index 26ab567..a286b77
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,68 +1,29 @@
+# The built-in media server has no rate limiting. Front this with a reverse
+# proxy (Caddy/Traefik/nginx) for TLS termination and rate limiting in prod.
+
services:
- moontracer-api:
- build:
- context: .
- secrets:
- - discord_public_key
- - discord_bot_token
- image: moontracer-api:latest
- container_name: moontracer-api
+ moontracer:
+ build: .
+ image: moontracer:latest
+ container_name: moontracer
restart: unless-stopped
- ports:
- - "3000:3000"
- secrets:
- - discord_public_key
- - discord_bot_token
- - database_url
- - api_secret_key
environment:
- - NODE_ENV=production
+ DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN}
+ VERBOSE: ${VERBOSE}
+ SAFE_MODE: ${SAFE_MODE}
+ DEV_MODE: ${DEV_MODE}
+ DISCORD_GUILD_ID: ${DISCORD_GUILD_ID}
+ DEBUG_ADMIN_ID: ${DEBUG_ADMIN_ID}
+ ADMIN_ROLE_NAME: ${ADMIN_ROLE_NAME}
+ MOD_ROLE_NAME: ${MOD_ROLE_NAME}
+ MEDIA_BASE_URL: ${MEDIA_BASE_URL}
+ MEDIA_PORT: ${MEDIA_PORT}
volumes:
- - ./logs:/app/logs
+ - ${MOONTRACER_DATA_DIR:-./data}:/app/data
networks:
- web_proxy
- - moontracer_internal
- depends_on:
- - postgres
- healthcheck:
- test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/v1/test"]
- interval: 30s
- timeout: 5s
- retries: 3
-
- postgres:
- image: postgres:16-alpine
- container_name: moontracer-db
- restart: unless-stopped
- secrets:
- - postgres_password
- environment:
- POSTGRES_DB: moontracer
- POSTGRES_USER: moontracer
- POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
- volumes:
- - db_data:/var/lib/postgresql/data
- networks:
- - moontracer_internal
-
-secrets:
- discord_public_key:
- file: ./secrets/discord_public_key.txt
- discord_bot_token:
- file: ./secrets/discord_bot_token.txt
- database_url:
- file: ./secrets/database_url.txt
- api_secret_key:
- file: ./secrets/api_secret_key.txt
- postgres_password:
- file: ./secrets/postgres_password.txt
networks:
web_proxy:
- external: true
- name: web_proxy
- moontracer_internal:
- driver: bridge
-
-volumes:
- db_data:
\ No newline at end of file
+ name: ${MOONTRACER_PROXY_NETWORK:-moontracer_web_proxy}
+ external: ${MOONTRACER_PROXY_EXTERNAL:-false}
diff --git a/eslint.config.mjs b/eslint.config.mjs
deleted file mode 100644
index 05e726d..0000000
--- a/eslint.config.mjs
+++ /dev/null
@@ -1,18 +0,0 @@
-import { defineConfig, globalIgnores } from "eslint/config";
-import nextVitals from "eslint-config-next/core-web-vitals";
-import nextTs from "eslint-config-next/typescript";
-
-const eslintConfig = defineConfig([
- ...nextVitals,
- ...nextTs,
- // Override default ignores of eslint-config-next.
- globalIgnores([
- // Default ignores of eslint-config-next:
- ".next/**",
- "out/**",
- "build/**",
- "next-env.d.ts",
- ]),
-]);
-
-export default eslintConfig;
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..0f3b850
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,42 @@
+module github.com/framebuffers/moontracer
+
+go 1.25.6
+
+require (
+ github.com/bwmarrin/discordgo v0.29.0
+ github.com/disintegration/imaging v1.6.2
+ github.com/google/uuid v1.6.0
+ github.com/stretchr/testify v1.11.1
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-sqlite3 v1.14.32 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
+ github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
+ github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
+ golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
+ golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ modernc.org/libc v1.67.0 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+ modernc.org/sqlite v1.40.1 // indirect
+)
+
+require (
+ github.com/gorilla/websocket v1.4.2 // indirect
+ github.com/uptrace/bun v1.2.16
+ github.com/uptrace/bun/dialect/sqlitedialect v1.2.16
+ github.com/uptrace/bun/driver/sqliteshim v1.2.16
+ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect
+ golang.org/x/sys v0.38.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..f241df8
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,103 @@
+github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
+github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
+github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
+github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
+github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
+github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
+github.com/uptrace/bun v1.2.16 h1:QlObi6ZIK5Ao7kAALnh91HWYNZUBbVwye52fmlQM9kc=
+github.com/uptrace/bun v1.2.16/go.mod h1:jMoNg2n56ckaawi/O/J92BHaECmrz6IRjuMWqlMaMTM=
+github.com/uptrace/bun/dialect/sqlitedialect v1.2.16 h1:6wVAiYLj1pMibRthGwy4wDLa3D5AQo32Y8rvwPd8CQ0=
+github.com/uptrace/bun/dialect/sqlitedialect v1.2.16/go.mod h1:Z7+5qK8CGZkDQiPMu+LSdVuDuR1I5jcwtkB1Pi3F82E=
+github.com/uptrace/bun/driver/sqliteshim v1.2.16 h1:M6Dh5kkDWFbUWBrOsIE1g1zdZ5JbSytTD4piFRBOUAI=
+github.com/uptrace/bun/driver/sqliteshim v1.2.16/go.mod h1:iKdJ06P3XS+pwKcONjSIK07bbhksH3lWsw3mpfr0+bY=
+github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
+github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
+github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
+github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
+golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
+golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
+golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
+golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
+golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
+golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
+modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
+modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
+modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
+modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
+modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.67.0 h1:QzL4IrKab2OFmxA3/vRYl0tLXrIamwrhD6CKD4WBVjQ=
+modernc.org/libc v1.67.0/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
+modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/auth/approval_auth_test.go b/internal/auth/approval_auth_test.go
new file mode 100644
index 0000000..19abe2f
--- /dev/null
+++ b/internal/auth/approval_auth_test.go
@@ -0,0 +1,150 @@
+package auth
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/framebuffers/moontracer/internal/manager/models"
+ "github.com/framebuffers/moontracer/internal/testutil"
+)
+
+/*
+Unit Testing: approval authorization β who can approve/deny campaigns.
+*/
+
+/*
+Mod can approve.
+
+When:
+
+ Player has ServerRoleMod, not banned.
+
+Expected:
+
+ ScopeMod passes. Mods may approve/deny campaigns.
+*/
+func TestApprovalAuth_ModCanApprove(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("mod1", models.ServerRoleMod, false)).Exec(ctx)
+ require.NoError(t, err)
+
+ ok, err := Authorize(database, "mod1", ScopeMod, "")
+ require.NoError(t, err)
+ assert.True(t, ok, "mod should be able to approve/deny campaigns")
+}
+
+/*
+Admin can approve.
+
+When:
+
+ Player has ServerRoleAdmin, not banned.
+
+Expected:
+
+ ScopeMod passes. Admin implies mod.
+*/
+func TestApprovalAuth_AdminCanApprove(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("admin1", models.ServerRoleAdmin, false)).Exec(ctx)
+ require.NoError(t, err)
+
+ ok, err := Authorize(database, "admin1", ScopeMod, "")
+ require.NoError(t, err)
+ assert.True(t, ok, "admin should be able to approve/deny campaigns (admin implies mod)")
+}
+
+/*
+Player cannot approve.
+
+When:
+
+ Player has ServerRolePlayer (regular player), not banned.
+
+Expected:
+
+ ScopeMod denied. Regular players have no approval authority.
+*/
+func TestApprovalAuth_PlayerCannotApprove(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("user1", models.ServerRolePlayer, false)).Exec(ctx)
+ require.NoError(t, err)
+
+ ok, err := Authorize(database, "user1", ScopeMod, "")
+ require.NoError(t, err)
+ assert.False(t, ok, "regular player should not be able to approve/deny campaigns")
+}
+
+/*
+Banned mod cannot approve.
+
+When:
+
+ Player has ServerRoleMod but is globally banned.
+
+Expected:
+
+ ScopeMod denied. Global ban overrides role.
+*/
+func TestApprovalAuth_BannedModCannotApprove(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("banned-mod", models.ServerRoleMod, true)).Exec(ctx)
+ require.NoError(t, err)
+
+ ok, err := Authorize(database, "banned-mod", ScopeMod, "")
+ require.NoError(t, err)
+ assert.False(t, ok, "globally banned mod should not be able to approve/deny campaigns")
+}
+
+/*
+Banned admin cannot approve.
+
+When:
+
+ Player has ServerRoleAdmin but is globally banned.
+
+Expected:
+
+ ScopeMod denied. Global ban overrides role, even admin.
+*/
+func TestApprovalAuth_BannedAdminCannotApprove(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("banned-admin", models.ServerRoleAdmin, true)).Exec(ctx)
+ require.NoError(t, err)
+
+ ok, err := Authorize(database, "banned-admin", ScopeMod, "")
+ require.NoError(t, err)
+ assert.False(t, ok, "globally banned admin should not be able to approve/deny campaigns")
+}
+
+/*
+Unregistered user cannot approve.
+
+When:
+
+ User ID does not exist in the players table.
+
+Expected:
+
+ ScopeMod denied. No player row means no authorization.
+*/
+func TestApprovalAuth_UnregisteredUserCannotApprove(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ ok, err := Authorize(database, "ghost", ScopeMod, "")
+ require.NoError(t, err)
+ assert.False(t, ok, "unregistered user should not be able to approve/deny campaigns")
+}
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
new file mode 100644
index 0000000..5b73816
--- /dev/null
+++ b/internal/auth/auth.go
@@ -0,0 +1,232 @@
+package auth
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/db"
+ "github.com/framebuffers/moontracer/internal/manager/models"
+)
+
+/*
+ Flow:
+ 1. Loads Player.
+ 2. Checks if Player has a GlobalBan.
+ 3. Switches on scope: Player (if it exists and is not banned), Member, DM, Mod, Admin.
+ 4. Returns a bool if authorized or not.
+
+ Note:
+ In the case a composite query if two or more permissions are set, use `AuthorizeAny()`
+*/
+
+// Scope represents a permission level required to perform an action.
+type Scope string
+
+const (
+ // ScopePlayer β any registered player (exists in players table, not banned).
+ ScopePlayer Scope = "player"
+
+ // ScopeMember β an active participant in a specific campaign.
+ ScopeMember Scope = "member"
+
+ // ScopeDM β the dungeon master of a specific campaign.
+ ScopeDM Scope = "dm"
+
+ // ScopeMod β a user with the mod or admin server role.
+ ScopeMod Scope = "mod"
+
+ // ScopeAdmin β a user with the admin server role.
+ ScopeAdmin Scope = "admin"
+)
+
+/*
+Authorize checks whether a user has the required scope.
+
+- For ScopePlayer, ScopeMod, and ScopeAdmin, campaignID can be empty.
+
+- For ScopeMember and ScopeDM, campaignID is required.
+
+Authorize then loads the player in a single query, filtering CampaignPlayers to the
+relevant campaign when needed.
+
+Returns:
+
+ true if authorized, false otherwise.
+*/
+func Authorize(database *bun.DB, userID string, required Scope, campaignID string) (bool, error) {
+ ctx := context.Background()
+
+ var player models.Player
+ q := database.NewSelect().Model(&player).Where("id = ?", userID)
+
+ // Only eager-load campaign memberships when the scope needs them.
+ if required == ScopeMember || required == ScopeDM {
+ q = q.Relation("CampaignPlayers", func(sq *bun.SelectQuery) *bun.SelectQuery {
+ return sq.Where("campaign_id = ?", campaignID)
+ })
+ }
+
+ err := q.Scan(ctx)
+ if err != nil {
+ return false, nil // player not found = not authorized
+ }
+
+ // Global ban blocks everything.
+ if player.PlayerIsBanned {
+ return false, nil
+ }
+
+ switch required {
+ case ScopePlayer:
+ return true, nil // exists and not banned
+
+ case ScopeMember:
+ return player.IsMemberOf(campaignID), nil
+
+ case ScopeDM:
+ 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
+
+ case ScopeAdmin:
+ return player.IsAdmin(), nil
+
+ default:
+ return false, fmt.Errorf("unknown scope: %s", required)
+ }
+}
+
+// AuthorizeAny returns true if the user has ANY of the provided scopes.
+// Useful for compound checks like "DM or Mod can do this", because: everyone is a Player.
+func AuthorizeAny(database *bun.DB, userID string, campaignID string, scopes ...Scope) (bool, error) {
+ for _, scope := range scopes {
+ ok, err := Authorize(database, userID, scope, campaignID)
+ if err != nil {
+ return false, err
+ }
+ if ok {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+// Resolve returns all user IDs that match the given scope for a campaign.
+// Used by the notification system to determine who should receive a message.
+func Resolve(database *bun.DB, s *discordgo.Session, campaignID, guildID, adminRole string, scope Scope) ([]string, error) {
+ ctx := context.Background()
+
+ switch scope {
+ case ScopePlayer:
+ players, err := db.GetAll[models.Player](database)
+ if err != nil {
+ return nil, err
+ }
+ ids := make([]string, len(players))
+ for i, p := range players {
+ ids[i] = p.ID
+ }
+ return ids, nil
+
+ case ScopeMember:
+ players, err := models.GetCampaignPlayers(database, campaignID)
+ if err != nil {
+ return nil, err
+ }
+ var ids []string
+ for _, p := range players {
+ if p.Status == models.StatusActive {
+ ids = append(ids, p.PlayerID)
+ }
+ }
+ return ids, nil
+
+ case ScopeDM:
+ campaign, err := db.GetByID[models.Campaign](database, campaignID)
+ if err != nil {
+ return nil, err
+ }
+ return []string{campaign.DungeonMaster}, nil
+
+ case ScopeMod, ScopeAdmin:
+ // Query the DB for players with the matching server role.
+ var players []models.Player
+ q := database.NewSelect().Model(&players)
+ if scope == ScopeAdmin {
+ q = q.Where("role = ?", models.ServerRoleAdmin)
+ } else {
+ q = q.Where("role IN (?)", bun.In([]models.ServerRole{models.ServerRoleMod, models.ServerRoleAdmin}))
+ }
+ if err := q.Scan(ctx); err != nil {
+ return nil, err
+ }
+ ids := make([]string, len(players))
+ for i, p := range players {
+ ids[i] = p.ID
+ }
+ return ids, nil
+
+ default:
+ return nil, nil
+ }
+}
+
+// adminsWithRole returns user IDs of guild members who have the given role name.
+// Used by SyncServerRoles, not by Authorize (which uses the DB).
+func adminsWithRole(s *discordgo.Session, guildID, roleName string) ([]string, error) {
+ roles, err := s.GuildRoles(guildID)
+ if err != nil {
+ return nil, err
+ }
+
+ var roleID string
+ for _, r := range roles {
+ if r.Name == roleName {
+ roleID = r.ID
+ break
+ }
+ }
+ if roleID == "" {
+ return nil, nil
+ }
+
+ var ids []string
+ after := ""
+ for {
+ members, err := s.GuildMembers(guildID, after, 1000)
+ if err != nil {
+ return nil, err
+ }
+ if len(members) == 0 {
+ break
+ }
+ for _, m := range members {
+ for _, r := range m.Roles {
+ if r == roleID {
+ ids = append(ids, m.User.ID)
+ break
+ }
+ }
+ }
+ after = members[len(members)-1].User.ID
+ if len(members) < 1000 {
+ break
+ }
+ }
+ return ids, nil
+}
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
new file mode 100644
index 0000000..226b10d
--- /dev/null
+++ b/internal/auth/auth_test.go
@@ -0,0 +1,506 @@
+package auth
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/framebuffers/moontracer/internal/manager/models"
+ "github.com/framebuffers/moontracer/internal/testutil"
+)
+
+/*
+Helper functions
+*/
+
+func newPlayer(id string, role models.ServerRole, banned bool) *models.Player {
+ return &models.Player{
+ ID: id,
+ Role: role,
+ PlayerIsBanned: banned,
+ }
+}
+
+func newCampaignPlayer(playerID, campaignID string, role models.Role, status models.CampaignPlayerStatus) *models.CampaignPlayer {
+ return &models.CampaignPlayer{
+ PlayerID: playerID,
+ CampaignID: campaignID,
+ Role: role,
+ Status: status,
+ }
+}
+
+func newCampaign(id, name, tag, dmID string) *models.Campaign {
+ return &models.Campaign{
+ ID: id,
+ Name: name,
+ Tag: tag,
+ DungeonMaster: dmID,
+ }
+}
+
+/*
+Unit Testing: enforce authorization and sovereignty rules.
+*/
+
+/*
+Cross-campaign isolation truth table:
+
+ | campA | campB |
+ +---------------+---------------+
+ usr1| T | F |
+ ----+---------------+---------------+
+ usr2| F | T |
+ ----+---------------+---------------+
+
+When:
+
+ Two DMs each own one campaign.
+
+Expected:
+
+ Each DM is authorized only for their own campaign, never the other's.
+*/
+func TestCrossCampaignIsolation(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("dm1", models.ServerRolePlayer, false)).Exec(ctx)
+ require.NoError(t, err)
+
+ _, err = database.NewInsert().Model(newPlayer("dm2", models.ServerRolePlayer, false)).Exec(ctx)
+ require.NoError(t, err)
+
+ _, err = database.NewInsert().Model(newCampaign("campA", "Test Campaign 1", "test1", "dm1")).Exec(ctx)
+ require.NoError(t, err)
+
+ _, err = database.NewInsert().Model(newCampaign("campB", "Test Campaign 2", "test2", "dm2")).Exec(ctx)
+ require.NoError(t, err)
+
+ _, err = database.NewInsert().Model(newCampaignPlayer("dm1", "campA", models.RoleDM, models.StatusActive)).Exec(ctx)
+ require.NoError(t, err)
+
+ _, err = database.NewInsert().Model(newCampaignPlayer("dm2", "campB", models.RoleDM, models.StatusActive)).Exec(ctx)
+ require.NoError(t, err)
+
+ authDM1campaignA, err := Authorize(database, "dm1", ScopeDM, "campA")
+ require.NoError(t, err)
+ assert.True(t, authDM1campaignA, "DM1 should be the master of CampaignA. DM1 is not authorized as master of this campaign.")
+
+ authDM2campaignB, err := Authorize(database, "dm2", ScopeDM, "campB")
+ require.NoError(t, err)
+ assert.True(t, authDM2campaignB, "DM2 should be the master of CampaignB. DM2 is not authorized as master of this campaign.")
+
+ authDM1campaignB, err := Authorize(database, "dm1", ScopeDM, "campB")
+ require.NoError(t, err)
+ assert.False(t, authDM1campaignB, "DM1 cannot be the master of CampaignB. DM1 is not authorized as a master of this campaign.")
+
+ authDM2campaignA, err := Authorize(database, "dm2", ScopeDM, "campA")
+ require.NoError(t, err)
+ assert.False(t, authDM2campaignA, "DM2 cannot be the master of CampaignA. DM2 is not authorized as the master of this campaign.")
+}
+
+/*
+Unregistered user.
+
+When:
+
+ User ID does not exist in the players table.
+
+Expected:
+
+ Authorization denied for any scope.
+*/
+func TestAuthorize_UnregisteredUser(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ ok, err := Authorize(database, "unknown-user", ScopePlayer, "")
+ require.NoError(t, err)
+ assert.False(t, ok, "unregistered user should not be authorized")
+}
+
+/*
+Registered player.
+
+When:
+
+ User exists in the players table, is not banned, role = player.
+
+Expected:
+
+ ScopePlayer passes.
+*/
+func TestAuthorize_RegisteredPlayer(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("user1", models.ServerRolePlayer, false)).Exec(ctx)
+ require.NoError(t, err)
+
+ ok, err := Authorize(database, "user1", ScopePlayer, "")
+ require.NoError(t, err)
+ assert.True(t, ok, "registered player should be authorized for ScopePlayer")
+}
+
+/*
+Globally banned player.
+
+When:
+
+ User has admin role but is globally banned.
+
+Expected:
+
+ All scopes denied. Global ban overrides every role.
+*/
+func TestAuthorize_GloballyBannedPlayer(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("banned1", models.ServerRoleAdmin, true)).Exec(ctx)
+ require.NoError(t, err)
+
+ scopes := []Scope{ScopePlayer, ScopeMod, ScopeAdmin}
+ for _, scope := range scopes {
+ ok, err := Authorize(database, "banned1", scope, "")
+ require.NoError(t, err)
+ assert.False(t, ok, "banned player should fail %s", scope)
+ }
+}
+
+/*
+Active campaign member.
+
+When:
+
+ Player has an active CampaignPlayer row for the target campaign.
+
+Expected:
+
+ ScopeMember passes.
+*/
+func TestAuthorize_ActiveCampaignMember(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("user1", models.ServerRolePlayer, false)).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newCampaign("camp1", "Test Campaign", "test", "dm1")).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newCampaignPlayer("user1", "camp1", models.RolePlayer, models.StatusActive)).Exec(ctx)
+ require.NoError(t, err)
+
+ ok, err := Authorize(database, "user1", ScopeMember, "camp1")
+ require.NoError(t, err)
+ assert.True(t, ok, "active member should be authorized")
+}
+
+/*
+Inactive campaign member.
+
+When:
+
+ Player has a CampaignPlayer row but with StatusHiatus.
+
+Expected:
+
+ ScopeMember denied. Only active members pass.
+*/
+func TestAuthorize_InactiveCampaignMember(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("user1", models.ServerRolePlayer, false)).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newCampaign("camp1", "Test Campaign", "test", "dm1")).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newCampaignPlayer("user1", "camp1", models.RolePlayer, models.StatusHiatus)).Exec(ctx)
+ require.NoError(t, err)
+
+ ok, err := Authorize(database, "user1", ScopeMember, "camp1")
+ require.NoError(t, err)
+ assert.False(t, ok, "hiatus member should not be authorized as active member")
+}
+
+/*
+DM of campaign.
+
+When:
+
+ Player has a CampaignPlayer row with RoleDM for the target campaign.
+
+Expected:
+
+ ScopeDM passes.
+*/
+func TestAuthorize_DMOfCampaign(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("dm1", models.ServerRolePlayer, false)).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newCampaign("camp1", "DM Campaign", "dmcamp", "dm1")).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newCampaignPlayer("dm1", "camp1", models.RoleDM, models.StatusActive)).Exec(ctx)
+ require.NoError(t, err)
+
+ ok, err := Authorize(database, "dm1", ScopeDM, "camp1")
+ require.NoError(t, err)
+ assert.True(t, ok, "DM of campaign should be authorized")
+}
+
+/*
+Player is not DM.
+
+When:
+
+ Player has a CampaignPlayer row with RolePlayer (not RoleDM).
+
+Expected:
+
+ ScopeDM denied. Campaign membership does not imply DM ownership.
+*/
+func TestAuthorize_PlayerIsNotDM(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("user1", models.ServerRolePlayer, false)).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newCampaign("camp1", "Campaign", "camp", "dm1")).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newCampaignPlayer("user1", "camp1", models.RolePlayer, models.StatusActive)).Exec(ctx)
+ require.NoError(t, err)
+
+ ok, err := Authorize(database, "user1", ScopeDM, "camp1")
+ require.NoError(t, err)
+ assert.False(t, ok, "player role should not authorize as DM")
+}
+
+/*
+Mod role.
+
+When:
+
+ Player has ServerRoleMod.
+
+Expected:
+
+ ScopeMod passes.
+*/
+func TestAuthorize_ModRole(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("mod1", models.ServerRoleMod, false)).Exec(ctx)
+ require.NoError(t, err)
+
+ ok, err := Authorize(database, "mod1", ScopeMod, "")
+ require.NoError(t, err)
+ assert.True(t, ok, "mod should be authorized for ScopeMod")
+}
+
+/*
+Admin implies mod.
+
+When:
+
+ Player has ServerRoleAdmin.
+
+Expected:
+
+ ScopeMod passes. Admin is a superset of mod.
+*/
+func TestAuthorize_AdminImpliesMod(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("admin1", models.ServerRoleAdmin, false)).Exec(ctx)
+ require.NoError(t, err)
+
+ ok, err := Authorize(database, "admin1", ScopeMod, "")
+ require.NoError(t, err)
+ assert.True(t, ok, "admin should pass ScopeMod check (admin implies mod)")
+}
+
+/*
+Admin scope.
+
+When:
+
+ Player has ServerRoleAdmin.
+
+Expected:
+
+ ScopeAdmin passes.
+*/
+func TestAuthorize_AdminScope(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("admin1", models.ServerRoleAdmin, false)).Exec(ctx)
+ require.NoError(t, err)
+
+ ok, err := Authorize(database, "admin1", ScopeAdmin, "")
+ require.NoError(t, err)
+ assert.True(t, ok, "admin should be authorized for ScopeAdmin")
+}
+
+/*
+Mod is not admin.
+
+When:
+
+ Player has ServerRoleMod.
+
+Expected:
+
+ ScopeAdmin denied. Mod does not imply admin.
+*/
+func TestAuthorize_ModIsNotAdmin(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("mod1", models.ServerRoleMod, false)).Exec(ctx)
+ require.NoError(t, err)
+
+ ok, err := Authorize(database, "mod1", ScopeAdmin, "")
+ require.NoError(t, err)
+ assert.False(t, ok, "mod should not be authorized for ScopeAdmin")
+}
+
+/*
+AuthorizeAny: DM or Mod β user is DM.
+
+When:
+
+ Player is the DM of the target campaign, with ServerRolePlayer.
+
+Expected:
+
+ AuthorizeAny(DM, Mod) passes via the DM path.
+*/
+func TestAuthorizeAny_DMOrMod_IsDM(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("dm1", models.ServerRolePlayer, false)).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newCampaign("camp1", "Campaign", "camp", "dm1")).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newCampaignPlayer("dm1", "camp1", models.RoleDM, models.StatusActive)).Exec(ctx)
+ require.NoError(t, err)
+
+ ok, err := AuthorizeAny(database, "dm1", "camp1", ScopeDM, ScopeMod)
+ require.NoError(t, err)
+ assert.True(t, ok, "DM should pass AuthorizeAny(DM, Mod)")
+}
+
+/*
+Sovereignty: mod cannot claim ScopeDM.
+
+When:
+
+ A mod is a regular player in a campaign they do not DM.
+ A second mod is not in the campaign at all.
+
+Expected:
+
+ ScopeDM denied for both. Server role never overrides campaign ownership.
+*/
+func TestAuthorize_ModCannotScopeDM(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("dm", models.ServerRolePlayer, false)).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newPlayer("mod", models.ServerRoleMod, false)).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newCampaign("camp1", "Campaign", "camp", "dm")).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newCampaignPlayer("dm", "camp1", models.RoleDM, models.StatusActive)).Exec(ctx)
+ require.NoError(t, err)
+
+ // Mod as a regular player in the campaign β cannot claim DM.
+ _, err = database.NewInsert().Model(newCampaignPlayer("mod", "camp1", models.RolePlayer, models.StatusActive)).Exec(ctx)
+ require.NoError(t, err)
+
+ ok, err := Authorize(database, "mod", ScopeDM, "camp1")
+ require.NoError(t, err)
+ assert.False(t, ok, "Mod who is a campaign member (RolePlayer) must not pass ScopeDM")
+
+ // Mod not in the campaign at all β still cannot claim DM.
+ _, err = database.NewInsert().Model(newPlayer("mod2", models.ServerRoleMod, false)).Exec(ctx)
+ require.NoError(t, err)
+
+ ok, err = Authorize(database, "mod2", ScopeDM, "camp1")
+ require.NoError(t, err)
+ assert.False(t, ok, "Mod who is not in the campaign must not pass ScopeDM")
+}
+
+/*
+Sovereignty: admin cannot claim ScopeDM.
+
+When:
+
+ An admin is not in the campaign.
+ Then the same admin is added as a regular player.
+
+Expected:
+
+ ScopeDM denied in both cases. Admin privilege never implies DM ownership.
+*/
+func TestAuthorize_AdminCannotScopeDM(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("dm", models.ServerRolePlayer, false)).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newPlayer("admin", models.ServerRoleAdmin, false)).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newCampaign("camp1", "Campaign", "camp", "dm")).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newCampaignPlayer("dm", "camp1", models.RoleDM, models.StatusActive)).Exec(ctx)
+ require.NoError(t, err)
+
+ // Admin not in the campaign β cannot claim DM.
+ ok, err := Authorize(database, "admin", ScopeDM, "camp1")
+ require.NoError(t, err)
+ assert.False(t, ok, "Admin who is not in the campaign must not pass ScopeDM")
+
+ // Admin as a regular player in the campaign β still cannot claim DM.
+ _, err = database.NewInsert().Model(newCampaignPlayer("admin", "camp1", models.RolePlayer, models.StatusActive)).Exec(ctx)
+ require.NoError(t, err)
+
+ ok, err = Authorize(database, "admin", ScopeDM, "camp1")
+ require.NoError(t, err)
+ assert.False(t, ok, "Admin who is a campaign member (RolePlayer) must not pass ScopeDM")
+}
+
+/*
+AuthorizeAny: neither scope matches.
+
+When:
+
+ A plain player is a member of a campaign but is neither DM nor mod.
+
+Expected:
+
+ AuthorizeAny(DM, Mod) denied.
+*/
+func TestAuthorizeAny_Neither(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("user1", models.ServerRolePlayer, false)).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newCampaign("camp1", "Campaign", "camp", "dm1")).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newCampaignPlayer("user1", "camp1", models.RolePlayer, models.StatusActive)).Exec(ctx)
+ require.NoError(t, err)
+
+ ok, err := AuthorizeAny(database, "user1", "camp1", ScopeDM, ScopeMod)
+ require.NoError(t, err)
+ assert.False(t, ok, "plain player should fail AuthorizeAny(DM, Mod)")
+}
diff --git a/internal/auth/debug_admin_test.go b/internal/auth/debug_admin_test.go
new file mode 100644
index 0000000..4deb8c6
--- /dev/null
+++ b/internal/auth/debug_admin_test.go
@@ -0,0 +1,289 @@
+package auth
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/framebuffers/moontracer/internal/guard"
+ "github.com/framebuffers/moontracer/internal/manager/models"
+ "github.com/framebuffers/moontracer/internal/testutil"
+)
+
+/*
+ Unit Testing: enforce authorization and sovereignty rules while on Debug Mode
+*/
+
+/*
+Helper functions
+*/
+func withDebugAdmin(t *testing.T, id string, fn func()) {
+ t.Helper()
+ origSafe := guard.SafeMode
+ origID := guard.DebugAdminID
+ guard.SafeMode = true
+ guard.DebugAdminID = id
+ t.Cleanup(func() {
+ guard.SafeMode = origSafe
+ guard.DebugAdminID = origID
+ })
+ fn()
+}
+
+/*
+Test
+syncRole
+
+Flow:
+ 1. Register a plain player.
+ 2. Sync without Discord admins.
+ a) Debug Admin should still be elevated.
+*/
+func TestSyncRoles_DebugAdminElevated(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("debug1", models.ServerRolePlayer, false)).Exec(ctx)
+ require.NoError(t, err)
+
+ withDebugAdmin(t, "debug1", func() {
+ err := syncRoles(database, nil, nil)
+ require.NoError(t, err)
+
+ var p models.Player
+ err = database.NewSelect().Model(&p).Where("id = ?", "debug1").Scan(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, models.ServerRoleAdmin, p.Role, "debug admin should be elevated to admin after sync")
+ })
+}
+
+/*
+When:
+
+ SafeMode = OFF;
+ DebugAdminID = set;
+ User does NOT have the Discord admin role.
+
+Expected:
+
+ Elevation denied. In production, DEBUG_ADMIN_ID requires the Discord role as confirmation.
+*/
+func TestSyncRoles_DebugAdminDeniedWithoutDiscordRole(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("debug1", models.ServerRolePlayer, false)).Exec(ctx)
+ require.NoError(t, err)
+
+ origSafe := guard.SafeMode
+ origID := guard.DebugAdminID
+ guard.SafeMode = false
+ guard.DebugAdminID = "debug1"
+ t.Cleanup(func() {
+ guard.SafeMode = origSafe
+ guard.DebugAdminID = origID
+ })
+
+ // nil adminIDs = no Discord role confirmation
+ err = syncRoles(database, nil, nil)
+ require.NoError(t, err)
+
+ var p models.Player
+ err = database.NewSelect().Model(&p).Where("id = ?", "debug1").Scan(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, models.ServerRolePlayer, p.Role, "debug admin must NOT be elevated without the Discord admin role when safe mode is off")
+}
+
+/*
+When:
+
+ SafeMode = OFF;
+ DebugAdminID = set;
+ User HAS the Discord admin role.
+
+Expected:
+
+ Elevated. Two-factor confirmed (env var + Discord role).
+*/
+func TestSyncRoles_DebugAdminElevatedWithDiscordRole(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("debug1", models.ServerRolePlayer, false)).Exec(ctx)
+ require.NoError(t, err)
+
+ origSafe := guard.SafeMode
+ origID := guard.DebugAdminID
+ guard.SafeMode = false
+ guard.DebugAdminID = "debug1"
+ t.Cleanup(func() {
+ guard.SafeMode = origSafe
+ guard.DebugAdminID = origID
+ })
+
+ // "debug1" is in the Discord admin set β two-factor confirmed
+ err = syncRoles(database, []string{"debug1"}, nil)
+ require.NoError(t, err)
+
+ var p models.Player
+ err = database.NewSelect().Model(&p).Where("id = ?", "debug1").Scan(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, models.ServerRoleAdmin, p.Role, "debug admin should be elevated when confirmed by Discord role")
+}
+
+/*
+When:
+
+ No player row for "ghost"
+
+Expected:
+
+ Sync should not panic or create one.
+*/
+func TestSyncRoles_DebugAdminUnregisteredIsNoOp(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ withDebugAdmin(t, "ghost", func() {
+ err := syncRoles(database, nil, nil)
+ require.NoError(t, err)
+ })
+}
+
+/*
+Tests:
+
+ Authorization.
+ Prove security and sovereignty, even whilst elevated as debug admin.
+
+When:
+
+ Player is admin but globally banned.
+*/
+func TestDebugAdmin_BannedAdminDenied(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("debug1", models.ServerRoleAdmin, true)).Exec(ctx)
+ require.NoError(t, err)
+
+ withDebugAdmin(t, "debug1", func() {
+ for _, scope := range []Scope{ScopePlayer, ScopeMod, ScopeAdmin} {
+ ok, err := Authorize(database, "debug1", scope, "")
+ require.NoError(t, err)
+ assert.False(t, ok, "banned debug admin must be denied for %s", scope)
+ }
+ })
+}
+
+/*
+Deny Unregistered
+
+When:
+
+ DEBUG_ADMIN_ID is set, but the user has no player row in the database.
+
+Expected:
+
+ Authorization must be denied. Debug elevation does not create players.
+*/
+func TestDebugAdmin_UnregisteredDenied(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ // DEBUG_ADMIN_ID is set, but the user has no player row.
+ withDebugAdmin(t, "no-such-user", func() {
+ ok, err := Authorize(database, "no-such-user", ScopeAdmin, "")
+ require.NoError(t, err)
+ assert.False(t, ok, "unregistered debug admin must be denied")
+ })
+}
+
+/*
+Enforce sovereignty: cannot claim other DM's campaigns.
+
+When:
+
+ Debug admin is elevated but is not the DM of the target campaign.
+
+Expected:
+
+ ScopeDM must be denied. Admin role never implies DM ownership.
+*/
+func TestDebugAdmin_CannotClaimDMOfOthersCampaign(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("debug1", models.ServerRoleAdmin, false)).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newPlayer("real-dm", models.ServerRolePlayer, false)).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newCampaign("camp1", "Campaign", "camp", "real-dm")).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newCampaignPlayer("real-dm", "camp1", models.RoleDM, models.StatusActive)).Exec(ctx)
+ require.NoError(t, err)
+
+ withDebugAdmin(t, "debug1", func() {
+ ok, err := Authorize(database, "debug1", ScopeDM, "camp1")
+ require.NoError(t, err)
+ assert.False(t, ok, "debug admin must not pass ScopeDM for someone else's campaign")
+ })
+}
+
+/*
+Member not in campaign they did not join.
+
+When:
+
+ Debug admin is elevated but has no CampaignPlayer row for the target campaign.
+
+Expected:
+
+ ScopeMember must be denied. Admin role does not grant campaign membership.
+*/
+
+func TestDebugAdmin_NotMemberOfCampaignTheyDidNotJoin(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(newPlayer("debug1", models.ServerRoleAdmin, false)).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(newCampaign("camp1", "Campaign", "camp", "dm1")).Exec(ctx)
+ require.NoError(t, err)
+
+ withDebugAdmin(t, "debug1", func() {
+ ok, err := Authorize(database, "debug1", ScopeMember, "camp1")
+ require.NoError(t, err)
+ assert.False(t, ok, "debug admin must not pass ScopeMember for a campaign they didn't join")
+ })
+}
+
+/*
+Authorization: passes Admin and Mod scopes.
+
+When:
+
+ Debug admin is elevated, registered, and not banned.
+
+Expected:
+
+ ScopeAdmin and ScopeMod both pass (admin implies mod).
+*/
+func TestDebugAdmin_PassesAdminAndModScopes(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ // After sync elevation, the player's DB role is admin.
+ _, err := database.NewInsert().Model(newPlayer("debug1", models.ServerRoleAdmin, false)).Exec(ctx)
+ require.NoError(t, err)
+
+ withDebugAdmin(t, "debug1", func() {
+ okAdmin, err := Authorize(database, "debug1", ScopeAdmin, "")
+ require.NoError(t, err)
+ assert.True(t, okAdmin, "debug admin should pass ScopeAdmin")
+
+ okMod, err := Authorize(database, "debug1", ScopeMod, "")
+ require.NoError(t, err)
+ assert.True(t, okMod, "debug admin should pass ScopeMod (admin implies mod)")
+ })
+}
diff --git a/internal/auth/sync.go b/internal/auth/sync.go
new file mode 100644
index 0000000..1621295
--- /dev/null
+++ b/internal/auth/sync.go
@@ -0,0 +1,131 @@
+package auth
+
+import (
+ "context"
+ "log"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/guard"
+ "github.com/framebuffers/moontracer/internal/manager/models"
+)
+
+/*
+ Flow:
+ 1. Figure out which users have the Admin role.
+ 2. Fetch all registered members.
+ 3. Check if they have the roles they need for their permission level (admin, mod), else, gets "demoted" to Player.
+ 4. Update permissions.
+
+ Notes:
+ - What "DB-only" assignments mean, is a feature where an Admin can assign the role of a Moderator,
+ within the context of this bot, without having them to be a server-wide mod.
+ - This is useful when there are moderators that only have Campaign moderation access, enabling more granular permissions control.
+*/
+
+/*
+SyncServerRoles reads guild members from Discord and updates Player.Role
+in the database to match.
+
+Players with the admin Discord role get ServerRoleAdmin.
+Players with the mod Discord role get ServerRoleMod.
+All other registered players get ServerRolePlayer.
+
+modRoleName is optional: if empty, mod is not synced from Discord.
+Call this on bot startup and on GuildMemberUpdate events.
+*/
+func SyncServerRoles(database *bun.DB, s *discordgo.Session, guildID, adminRoleName, modRoleName string) error {
+ adminIDs, err := adminsWithRole(s, guildID, adminRoleName)
+ if err != nil {
+ return err
+ }
+
+ var modIDs []string
+ if modRoleName != "" {
+ modIDs, err = adminsWithRole(s, guildID, modRoleName)
+ if err != nil {
+ return err
+ }
+ }
+
+ return syncRoles(database, adminIDs, modIDs)
+}
+
+/*
+syncRoles is the core sync logic, separated from the Discord API call
+so it can be tested without a live session.
+
+Notes:
+
+ Admin > Mod > Player. A user in both sets gets Admin (higher wins).
+ When DEBUG_ADMIN_ID is set, elevate the debug admin through the normal role path.
+
+Design note (two-factor guard pattern):
+
+ The DEBUG_ADMIN_ID block below is the first instance of a two-factor
+ authorization: "env var claims X" + "Discord role confirms X". If a third
+ source appears, consider extracting a composable guard:
+
+ AuthorizeAll(db, userID, campaignID, ...func() (bool, error)) (bool, error)
+
+ where each check is a closure over a different source (DB scope, Discord role,
+ env var). Until then, inline is fine.
+*/
+func syncRoles(database *bun.DB, adminIDs, modIDs []string) error {
+ ctx := context.Background()
+
+ adminSet := make(map[string]bool, len(adminIDs))
+ for _, id := range adminIDs {
+ adminSet[id] = true
+ }
+
+ modSet := make(map[string]bool, len(modIDs))
+ for _, id := range modIDs {
+ modSet[id] = true
+ }
+
+ if guard.DebugAdminID != "" {
+ if guard.SafeMode {
+ // Safe mode: env var alone is sufficient for testing.
+ if !adminSet[guard.DebugAdminID] {
+ adminSet[guard.DebugAdminID] = true
+ log.Printf("sync: debug admin %s injected into admin set (safe mode)", guard.DebugAdminID)
+ }
+ } else if adminSet[guard.DebugAdminID] {
+ // Production: env var confirms the Discord role (two-factor).
+ log.Printf("sync: debug admin %s confirmed by Discord role", guard.DebugAdminID)
+ } else {
+ log.Printf("sync: WARNING β debug admin %s does not have the Discord admin role; elevation denied", guard.DebugAdminID)
+ }
+ }
+
+ var players []models.Player
+ if err := database.NewSelect().Model(&players).Scan(ctx); err != nil {
+ return err
+ }
+
+ for _, p := range players {
+ var desired models.ServerRole
+ if adminSet[p.ID] {
+ desired = models.ServerRoleAdmin
+ } else if modSet[p.ID] {
+ desired = models.ServerRoleMod
+ } else {
+ desired = models.ServerRolePlayer
+ }
+
+ if p.Role != desired {
+ _, err := database.NewUpdate().Model((*models.Player)(nil)).
+ Set("role = ?", desired).
+ Where("id = ?", p.ID).
+ Exec(ctx)
+ if err != nil {
+ return err
+ }
+ log.Printf("sync: synced role for %s: %s -> %s", p.ID, p.Role, desired)
+ }
+ }
+
+ return nil
+}
diff --git a/internal/commands/abandon_campaign.go b/internal/commands/abandon_campaign.go
new file mode 100644
index 0000000..a0f34d0
--- /dev/null
+++ b/internal/commands/abandon_campaign.go
@@ -0,0 +1,41 @@
+package commands
+
+import (
+ "fmt"
+ "log"
+ "time"
+
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/db"
+ "github.com/framebuffers/moontracer/internal/manager/models"
+)
+
+/*
+ArchiveCampaign sets a campaign as archived and marks all members as finished.
+Shared between the manage_archive handler and the GuildMemberRemove event handler.
+*/
+func ArchiveCampaign(database *bun.DB, campaign *models.Campaign, reason string) error {
+ campaign.IsArchived = true
+ campaign.ArchivedAt = time.Now().UTC()
+ campaign.ArchivedReason = reason
+
+ if err := db.Update(database, campaign); err != nil {
+ return fmt.Errorf("update campaign: %w", err)
+ }
+
+ players, err := models.GetCampaignPlayers(database, campaign.ID)
+ if err != nil {
+ return fmt.Errorf("get campaign players: %w", err)
+ }
+
+ for _, p := range players {
+ if p.Status == models.StatusActive || p.Status == models.StatusPending || p.Status == models.StatusHiatus {
+ if err := models.SetCampaignPlayerStatus(database, p.PlayerID, campaign.ID, models.StatusFinished); err != nil {
+ log.Printf("archive: failed to set player %s to finished in campaign %s: %v", p.PlayerID, campaign.ID, err)
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/internal/commands/about.go b/internal/commands/about.go
new file mode 100644
index 0000000..ae2386d
--- /dev/null
+++ b/internal/commands/about.go
@@ -0,0 +1,119 @@
+package commands
+
+import (
+ "fmt"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/interactions/router"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+ The bot's about page (/moontracer).
+
+ Renders attribution, copyright, license, and three extra buttons so new users
+ have an obvious next step, instead of a dead-end wall of text:
+ "My Hub" (ViewMe), "Help" (ViewHelp), and a link button to the GitHub
+ repo (external URL, so Style=Link with no CustomID).
+
+ Flow:
+ 1. User runs `/moontracer`.
+ 2. Assemble body (description, copyright/license, repo line, attributions, footer).
+ 3. Respond with source + three-button action row.
+*/
+
+// aboutCommand returns a string with attributions, copyright, licences, and a little awoo from the bot.
+type aboutCommand struct {
+ db *bun.DB
+}
+
+// Data is the command metadata that Discord shows to users.
+func (a *aboutCommand) Data() *discordgo.ApplicationCommand {
+ return &discordgo.ApplicationCommand{
+ Name: messages.AboutCommandName,
+ Description: messages.AboutCommandDesc,
+ }
+}
+
+// Execute is the logic that runs when the user invokes that command.
+func (a *aboutCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ // textCopyright := fmt.Sprintf("%s\nCopyright %s. Released under the %s license.\n", messages.AboutCommandBotDesc, messages.AboutCommandCopyright, messages.AboutCommandLicense)
+ // textAttributions := fmt.Sprintf("**%s**", messages.AboutCommandAttributions)
+ // textFooter := fmt.Sprintf("%s\n%s", messages.AboutCommandHelp, messages.AboutCommandAwoo)
+
+ // text := fmt.Sprintf("%s\n%s\n%s", textCopyright, textAttributions, textFooter)
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: &discordgo.InteractionResponseData{
+ Content: ascii,
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ router.NavButton(messages.MyProfileLabel, discordgo.PrimaryButton, router.ViewMe),
+ router.NavButton(messages.HelpLabel, discordgo.SecondaryButton, router.ViewHelp),
+ discordgo.Button{
+ Label: messages.AboutCommandGitHubLabel,
+ Style: discordgo.LinkButton,
+ URL: messages.AboutCommandGitHubRepoLink,
+ },
+ }},
+ },
+ },
+ })
+}
+
+// RenderAboutUpdate renders the about page as a message update (used by the router from /me).
+func RenderAboutUpdate(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: ascii,
+ Embeds: []*discordgo.MessageEmbed{},
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ router.NavButton(messages.MyProfileLabel, discordgo.PrimaryButton, router.ViewMe),
+ router.NavButton(messages.HelpLabel, discordgo.SecondaryButton, router.ViewHelp),
+ discordgo.Button{
+ Label: messages.AboutCommandGitHubLabel,
+ Style: discordgo.LinkButton,
+ URL: messages.AboutCommandGitHubRepoLink,
+ },
+ }},
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+var ascii2 = "```" + `
+* * * * * π*
+ * * * * *
+ββ ββββββββ ββββββββββββββββββ
+ββββ ββ ββ β β βββββββ ββββββ
+ββββββββββ β β β ββ ββββββββ β
+* *awoo* * * *
+ πΊ * * *
+` +
+ messages.AboutCommandBotDesc +
+ "```"
+
+var ascii = fmt.Sprintf(`
+%s
+%s
+-# Released under the %s license.
+-# %s
+
+%s
+
+-# %s
+-# πΊ %s π
+`, ascii2,
+ messages.AboutCommandCopyright,
+ messages.AboutCommandLicense,
+ messages.BotVersion,
+ messages.AboutCommandHelp,
+ messages.AboutCommandAttributions,
+ messages.AboutCommandAwoo,
+)
diff --git a/internal/commands/admin.go b/internal/commands/admin.go
new file mode 100644
index 0000000..3cb84bd
--- /dev/null
+++ b/internal/commands/admin.go
@@ -0,0 +1,322 @@
+package commands
+
+/*
+ Flow:
+ 1. User runs `/admin`.
+ 2. Authorize: check if the user is a mod or admin.
+ 3. Show admin hub with action buttons: Manage Campaigns, Active Campaigns (stub), Broadcast (stub), Database, Settings (stub).
+*/
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "runtime"
+ "runtime/debug"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "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"
+)
+
+/*
+startedAt captures an approximation of process start time.
+
+Package-level vars initialize before main(), so this is accurate to within a few ms.
+*/
+var startedAt = time.Now()
+
+type adminCommand struct {
+ db *bun.DB
+}
+
+func (a *adminCommand) Data() *discordgo.ApplicationCommand {
+ return &discordgo.ApplicationCommand{
+ Name: messages.AdminCommandName,
+ Description: messages.AdminCommandDesc,
+ }
+}
+
+func (a *adminCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ userID := i.Member.User.ID
+
+ ok, err := auth.Authorize(a.db, userID, auth.ScopeMod, "")
+ if err != nil {
+ log.Printf("admin: auth check failed: %v", err)
+ respond(s, i, messages.GenericErrorMessage)
+ return
+ }
+ if !ok {
+ respond(s, i, messages.AdminNotStaff)
+ return
+ }
+
+ RenderAdminHub(s, i)
+}
+
+// RenderAdminHub renders the admin panel, callable from the slash command and back buttons.
+func RenderAdminHub(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: adminHubData(),
+ })
+}
+
+// RenderAdminHubUpdate re-renders the admin panel as a message update (for back navigation).
+func RenderAdminHubUpdate(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: adminHubData(),
+ })
+}
+
+func adminHubData() *discordgo.InteractionResponseData {
+ row1 := []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.AdminCampaignsLabel,
+ Style: discordgo.PrimaryButton,
+ CustomID: messages.AdminCampaignsPrefix,
+ },
+ discordgo.Button{
+ Label: messages.AdminBroadcastLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: messages.AdminBroadcastPrefix,
+ },
+ discordgo.Button{
+ Label: messages.AdminSettingsLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: messages.AdminSettingsPrefix,
+ },
+ }
+
+ components := []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: row1},
+ }
+ if guard.DevMode {
+ components = append(components, discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.AdminDiagLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: messages.AdminDiagPrefix,
+ },
+ }})
+ }
+ components = append(components, helpers.BackRow(router.ViewMe))
+
+ return &discordgo.InteractionResponseData{
+ Content: messages.AdminHubMessage,
+ Embeds: []*discordgo.MessageEmbed{},
+ Components: components,
+ Flags: discordgo.MessageFlagsEphemeral,
+ }
+}
+
+// BuildTime is set at compile time via -ldflags; remains "unknown" in dev builds.
+var BuildTime = "unknown"
+
+// RenderAdminDiag renders the diagnostics sub-view as a message update.
+func RenderAdminDiag(s *discordgo.Session, i *discordgo.InteractionCreate, guildDB *bun.DB, guildID string) {
+ if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: adminDiagData(s, guildDB, guildID),
+ }); err != nil {
+ log.Printf("admin_diag: failed to respond: %v", err)
+ }
+}
+
+/*
+adminDiagData builds a single-code-block diagnostics view.
+
+Using a code fence (not Components V2) keeps the message in V1 so
+Back -> admin hub navigation works without a sticky-flags rejection.
+*/
+func adminDiagData(s *discordgo.Session, guildDB *bun.DB, guildID string) *discordgo.InteractionResponseData {
+ return &discordgo.InteractionResponseData{
+ Content: "```\n" + diagBlock(s, guildDB, guildID) + "```",
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ router.BackButton(messages.BackLabel, router.ViewAdmin),
+ router.NavButton(messages.HomeLabel, discordgo.SecondaryButton, router.ViewMe),
+ }},
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ }
+}
+
+const diagWidth = 44
+const diagLabelW = 12
+
+func drow(label, value string) string {
+ return fmt.Sprintf("%-*s %s\n", diagLabelW, label, value)
+}
+
+func ddiv() string {
+ return strings.Repeat("β", diagWidth) + "\n"
+}
+
+func yesNo(b bool) string {
+ if b {
+ return "yes"
+ }
+ return "no"
+}
+
+func fmtBytes(n int64) string {
+ switch {
+ case n >= 1<<20:
+ return fmt.Sprintf("%.1f MiB", float64(n)/float64(1<<20))
+ case n >= 1<<10:
+ return fmt.Sprintf("%d KiB", n>>10)
+ default:
+ return fmt.Sprintf("%d B", n)
+ }
+}
+
+func diagBlock(s *discordgo.Session, guildDB *bun.DB, guildID string) string {
+ var b strings.Builder
+
+ // header: version + mode flags
+ ver := messages.BotVersion
+ flags := ""
+ if guard.SafeMode {
+ flags += " [SAFE]"
+ }
+ if guard.DevMode {
+ flags += " [DEV]"
+ }
+ header := "moontracer " + ver
+ pad := diagWidth - len(header) - len(flags)
+ if pad < 1 {
+ pad = 1
+ }
+ b.WriteString(header + strings.Repeat(" ", pad) + flags + "\n")
+ b.WriteString(ddiv())
+
+ // runtime
+ commit := "unknown"
+ if bi, ok := debug.ReadBuildInfo(); ok {
+ for _, setting := range bi.Settings {
+ if setting.Key == "vcs.revision" {
+ if len(setting.Value) >= 7 {
+ commit = setting.Value[:7]
+ } else {
+ commit = setting.Value
+ }
+ }
+ }
+ }
+ hostname, _ := os.Hostname()
+ var mem runtime.MemStats
+ runtime.ReadMemStats(&mem)
+
+ b.WriteString(drow("version", ver))
+ b.WriteString(drow("commit", commit))
+ b.WriteString(drow("built", BuildTime))
+ b.WriteString(drow("go", runtime.Version()))
+ b.WriteString(drow("os/arch", runtime.GOOS+"/"+runtime.GOARCH))
+ b.WriteString(drow("host", hostname))
+ b.WriteString(drow("pid", strconv.Itoa(os.Getpid())))
+ b.WriteString(drow("uptime", time.Since(startedAt).Round(time.Second).String()))
+ b.WriteString(drow("goroutines", strconv.Itoa(runtime.NumGoroutine())))
+ b.WriteString(drow("heap", fmt.Sprintf("%d KiB", mem.HeapAlloc/1024)))
+ b.WriteString(ddiv())
+
+ // discord session
+ userName, userTail, sidTail := "unknown", "?????", "?????"
+ guildCount, gatewayVersion := 0, 0
+ if s.State != nil {
+ if s.State.User != nil {
+ userName = s.State.User.Username
+ if uid := s.State.User.ID; len(uid) >= 5 {
+ userTail = uid[len(uid)-5:]
+ }
+ }
+ if sid := s.State.SessionID; len(sid) >= 5 {
+ sidTail = sid[len(sid)-5:]
+ }
+ guildCount = len(s.State.Guilds)
+ gatewayVersion = s.State.Version
+ }
+ b.WriteString(drow("discordgo", "v"+discordgo.VERSION))
+ b.WriteString(drow("gateway", fmt.Sprintf("v%d", gatewayVersion)))
+ b.WriteString(drow("bot", fmt.Sprintf("%s (β¦%s)", userName, userTail)))
+ b.WriteString(drow("latency", s.HeartbeatLatency().Truncate(time.Millisecond).String()))
+ b.WriteString(drow("guilds", strconv.Itoa(guildCount)))
+ b.WriteString(drow("session", "β¦"+sidTail))
+ b.WriteString(ddiv())
+
+ // config
+ adminRole := os.Getenv("ADMIN_ROLE_NAME")
+ if adminRole == "" {
+ adminRole = "(not set)"
+ }
+ modRole := os.Getenv("MOD_ROLE_NAME")
+ if modRole == "" {
+ modRole = "(not set)"
+ }
+ debugAdmin := "(not set)"
+ if guard.DebugAdminID != "" {
+ debugAdmin = "(set)"
+ }
+ b.WriteString(drow("safe mode", yesNo(guard.SafeMode)))
+ b.WriteString(drow("dev mode", yesNo(guard.DevMode)))
+ b.WriteString(drow("verbose", yesNo(os.Getenv("VERBOSE") != "")))
+ b.WriteString(drow("admin role", adminRole))
+ b.WriteString(drow("mod role", modRole))
+ b.WriteString(drow("debug admin", debugAdmin))
+ b.WriteString(ddiv())
+
+ // this guild
+ if guildDB != nil {
+ ctx := context.Background()
+ var total, live int
+ guildDB.NewSelect().TableExpr("campaigns").ColumnExpr("count(*)").Scan(ctx, &total)
+ guildDB.NewSelect().TableExpr("campaigns").Where("is_archived = false").ColumnExpr("count(*)").Scan(ctx, &live)
+
+ campaignStr := strconv.Itoa(live) + " live"
+ if archived := total - live; archived > 0 {
+ campaignStr += " Β· " + strconv.Itoa(archived) + " archived"
+ }
+ b.WriteString(drow("campaigns", campaignStr))
+
+ guildDBSize := int64(0)
+ if info, err := os.Stat(filepath.Join("data", guildID+".db")); err == nil {
+ guildDBSize = info.Size()
+ }
+ b.WriteString(drow("db size", fmtBytes(guildDBSize)))
+ b.WriteString(ddiv())
+ }
+
+ // storage: total across all guilds
+ var totalSize int64
+ dbFileCount := 0
+ if entries, err := os.ReadDir("data"); err == nil {
+ for _, e := range entries {
+ if !e.IsDir() && strings.HasSuffix(e.Name(), ".db") {
+ if info, err := e.Info(); err == nil {
+ totalSize += info.Size()
+ dbFileCount++
+ }
+ }
+ }
+ }
+ totalStr := fmtBytes(totalSize)
+ if dbFileCount > 1 {
+ totalStr += fmt.Sprintf(" (%d guilds)", dbFileCount)
+ }
+ b.WriteString(drow("total size", totalStr))
+
+ 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
new file mode 100644
index 0000000..72d4c55
--- /dev/null
+++ b/internal/commands/admin_visibility_test.go
@@ -0,0 +1,77 @@
+package commands
+
+import (
+ "testing"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/stretchr/testify/assert"
+
+ "github.com/framebuffers/moontracer/internal/guard"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+Unit Testing
+/admin hub button visibility (DevMode gating)
+
+Covers adminHubData():
+ The second action row conditionally includes the
+ Database and Diagnostics buttons when guard.DevMode is true.
+
+ Settings is always visible.
+
+Note:
+ This is the live-deploy guard rail that hides debug surfaces
+ from real users when DEV_MODE is off.
+*/
+
+/*
+allHubCustomIDs returns CustomIDs of every button across all admin hub rows.
+*/
+func allHubCustomIDs(t *testing.T) []string {
+ t.Helper()
+ data := adminHubData()
+ var ids []string
+ for _, comp := range data.Components {
+ row, ok := comp.(discordgo.ActionsRow)
+ if !ok {
+ continue
+ }
+ for _, c := range row.Components {
+ btn, ok := c.(discordgo.Button)
+ if !ok {
+ continue
+ }
+ ids = append(ids, btn.CustomID)
+ }
+ }
+ return ids
+}
+
+/*
+DevMode on exposes the Diagnostics button.
+
+Layout (DevMode=true): row0=[Query Campaigns, Broadcast, Settings], row1=[Diag], row2=[Back].
+*/
+func TestAdminHub_DevModeShowsDebugButtons(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ ids := allHubCustomIDs(t)
+ assert.Contains(t, ids, messages.AdminDiagPrefix, "DevMode hub should expose Diagnostics")
+ assert.Contains(t, ids, messages.AdminSettingsPrefix, "Settings must always be present")
+}
+
+/*
+DevMode off hides Diagnostics.
+
+Layout (DevMode=false): row0=[Query Campaigns, Broadcast, Settings], row1=[Back].
+No debug surfaces exposed to real users.
+*/
+func TestAdminHub_NonDevModeHidesDebugButtons(t *testing.T) {
+ guard.SetModesForTest(t, true, false)
+
+ ids := allHubCustomIDs(t)
+ assert.Contains(t, ids, messages.AdminSettingsPrefix, "Settings must always be present")
+ assert.NotContains(t, ids, messages.AdminDatabasePrefix, "Database button must not leak in production")
+ assert.NotContains(t, ids, messages.AdminDiagPrefix, "Diagnostics button must not leak in production")
+}
diff --git a/internal/commands/awoo.go b/internal/commands/awoo.go
new file mode 100644
index 0000000..cd863c6
--- /dev/null
+++ b/internal/commands/awoo.go
@@ -0,0 +1,63 @@
+package commands
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/manager/models"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+ Test command:
+ - Checks for connectivity with the VPS/Docker container.
+ - Increments and displays an awoo counter (also tests DB read/write).
+ - Note: the bot uses WebHooks only.
+*/
+
+type awooCommand struct {
+ db *bun.DB
+}
+
+// Data is the command metadata that Discord shows to users.
+func (c *awooCommand) Data() *discordgo.ApplicationCommand {
+ return &discordgo.ApplicationCommand{
+ Name: messages.AwooCommandName,
+ Description: messages.AwooCommandDesc,
+ }
+}
+
+// Execute is the logic that runs when the user invokes that command.
+func (c *awooCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ ctx := context.Background()
+
+ var record models.CommandRecord
+
+ err := c.db.NewSelect().Model(&record).
+ Column("times_used").
+ Where("name = ?", "awoo").
+ Scan(ctx)
+
+ if err != nil {
+ log.Printf("awoo: smoke test failed: %v", err)
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf("there was no one to awoo to. (current counter: %d)", record.TimesUsed),
+ },
+ })
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf("awoo (x %d)", record.TimesUsed),
+ },
+ })
+}
diff --git a/internal/commands/ban.go b/internal/commands/ban.go
new file mode 100644
index 0000000..21c2564
--- /dev/null
+++ b/internal/commands/ban.go
@@ -0,0 +1,152 @@
+package commands
+
+import (
+ "fmt"
+ "log"
+
+ "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/manager/models"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+ Flow:
+ 1. Someone with permissions (a mod/admin) invokes `/ban @player [reason]`
+ 2. Check if you are trying to ban yourself, and that you are indeed authorized to ban someone.
+ a. Calls `Authorize()`
+ b. Checks that the invoker is, at least, a moderator.
+ 3. Loads both players, and checks their 'role weight':
+ a. From less to more: unregistered -> member -> player -> DM -> mod -> admin
+ b. Privilege is issued through a composition chain: access to things are 'added' like a hat on top of your current permissions.
+ c. Putting a 'mod' hat on a player means that it *inherits* all the permissions granted from below.
+ d. DM is special: it grants privileges *only* on Campaigns, and only in those you own. However, a mod can override this.
+ 4. Checks if the player is already banned. Bail out early if so.
+ 5. Set `Player.IsBanned = true` + the reason in the DB.
+ 6. Cascade through every single campaign this member is a player on, and set their state to `banned`.
+ 7. Respond with either success or failure.
+*/
+
+type banCommand struct {
+ db *bun.DB
+}
+
+// Data is the command metadata that Discord shows to users.
+func (r *banCommand) Data() *discordgo.ApplicationCommand {
+ return &discordgo.ApplicationCommand{
+ Name: messages.BanCommandName,
+ Description: messages.BanCommandDesc,
+ Options: []*discordgo.ApplicationCommandOption{
+ {
+ Type: discordgo.ApplicationCommandOptionUser,
+ Name: "player",
+ Description: "The player to ban.",
+ Required: true,
+ },
+ {
+ Type: discordgo.ApplicationCommandOptionString,
+ Name: "reason",
+ Description: "Reason for the ban (optional).",
+ Required: false,
+ },
+ },
+ }
+}
+
+/*
+ Flow:
+ 1. Guard check: cannot ban yourself.
+ 2. Load both players to compare roles.
+ 3. Guard check: to protect roles, invoker musy outrank target.
+ 4. Guard check: is the player already banned?
+ 5. Persist the global ban.
+*/
+
+// Execute is the logic that runs when the user invokes the ban command.
+func (r *banCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ invokerID := i.Member.User.ID
+ targetUser := i.ApplicationCommandData().Options[0].UserValue(s)
+
+ var reason string
+ if len(i.ApplicationCommandData().Options) > 1 {
+ reason = i.ApplicationCommandData().Options[1].StringValue()
+ }
+
+ if invokerID == targetUser.ID {
+ respond(s, i, messages.BanCannotBanSelf)
+ return
+ }
+
+ ok, err := auth.Authorize(r.db, invokerID, auth.ScopeMod, "")
+ if err != nil {
+ log.Printf("ban: invoker auth check failed: %v", err)
+ respond(s, i, messages.GenericErrorMessage)
+ return
+ }
+ if !ok {
+ respond(s, i, messages.AddPlayerNotDMOrModMessage)
+ return
+ }
+
+ invoker, err := db.GetByID[models.Player](r.db, invokerID)
+ if err != nil {
+ log.Printf("ban: failed to load invoker: %v", err)
+ respond(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ target, err := db.GetByID[models.Player](r.db, targetUser.ID)
+ if err != nil {
+ respond(s, i, messages.BanTargetNotFound)
+ return
+ }
+
+ if invoker.Role.Weight() <= target.Role.Weight() {
+ respond(s, i, messages.BanInsufficientRole)
+ return
+ }
+
+ if target.PlayerIsBanned {
+ respond(s, i, messages.BanTargetAlreadyBanned)
+ return
+ }
+
+ target.PlayerIsBanned = true
+ target.PlayerBanReason = reason
+
+ if err := db.Update(r.db, target); err != nil {
+ log.Printf("ban: failed to update player: %v", err)
+ respond(s, i, messages.BanFailureMessage)
+ return
+ }
+
+ /*
+
+ A fundamental concept for Moontracer is: DM Sovereignty.
+ The **only** true sovereign of a Campaign is the one that created, managed and played it.
+ Therefore, permissions work a bit differently than *just* a regular hierarchical structure.
+
+ The global ban flag blocks all auth checks (auth.go:70), so the player
+ cannot interact with the bot anymore.
+
+ Campaign memberships are left untouched, the DM retains sovereignty over their roster and can
+ campaign-ban the player separately if desired.
+
+ */
+
+ if err := models.InsertAuditEntry(r.db, target.ID, invokerID, models.AuditBan, reason); err != nil {
+ log.Printf("ban: failed to write audit entry: %v", err)
+ }
+
+ log.Printf("ban: %s banned %s (reason: %s)", invokerID, target.ID, reason)
+ if reason == "" {
+ respond(s, i, fmt.Sprintf(messages.BanSuccessMessage, targetUser.ID))
+ } else {
+ 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
new file mode 100644
index 0000000..20b0379
--- /dev/null
+++ b/internal/commands/ban_test.go
@@ -0,0 +1,256 @@
+package commands
+
+import (
+ "context"
+ "testing"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/uptrace/bun"
+
+ "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"
+)
+
+/*
+Unit Testing
+/ban command β hierarchy and cascade
+
+Covers banCommand.Execute:
+ global-ban flag flip + audit entry insert.
+ Campaign memberships are intentionally NOT touched (DM sovereignty model: the global
+ ban flag blocks auth.go, so cascades are cosmetic elsewhere).
+
+Auth is ScopeMod on the invoker, plus role-weight strict-greater-than on the
+target.
+
+Banned status and self-ban are rejected before persistence.
+*/
+
+const (
+ testBanInvokerID = "mod-1"
+ testBanTargetID = "player-1"
+)
+
+/*
+banFixture seeds invoker + target players with the given roles and ban state.
+*/
+func banFixture(t *testing.T, database *bun.DB, invokerRole, targetRole models.ServerRole, targetAlreadyBanned bool) {
+ t.Helper()
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(&models.Player{ID: testBanInvokerID, Role: invokerRole}).Exec(ctx)
+ require.NoError(t, err)
+
+ _, err = database.NewInsert().Model(&models.Player{
+ ID: testBanTargetID,
+ Role: targetRole,
+ PlayerIsBanned: targetAlreadyBanned,
+ }).Exec(ctx)
+ require.NoError(t, err)
+}
+
+/*
+buildBanInteraction constructs an ApplicationCommand InteractionCreate with
+the two /ban options (player + optional reason) and a Member-shaped invoker.
+*/
+func buildBanInteraction(invokerID, targetID, reason string) *discordgo.InteractionCreate {
+ options := []*discordgo.ApplicationCommandInteractionDataOption{
+ {
+ Name: "player",
+ Type: discordgo.ApplicationCommandOptionUser,
+ Value: targetID,
+ },
+ }
+ if reason != "" {
+ options = append(options, &discordgo.ApplicationCommandInteractionDataOption{
+ Name: "reason",
+ Type: discordgo.ApplicationCommandOptionString,
+ Value: reason,
+ })
+ }
+
+ return &discordgo.InteractionCreate{
+ Interaction: &discordgo.Interaction{
+ Type: discordgo.InteractionApplicationCommand,
+ Member: &discordgo.Member{User: &discordgo.User{ID: invokerID}},
+ Data: discordgo.ApplicationCommandInteractionData{
+ Name: messages.BanCommandName,
+ Options: options,
+ },
+ },
+ }
+}
+
+/*
+Mod bans a plain player with a reason.
+
+When:
+
+ A mod invokes /ban on a non-mod target with a reason string.
+
+Expected:
+
+ Target.PlayerIsBanned flips to true; Target.PlayerBanReason is stored;
+ an AuditBan row is written attributing the action to the mod.
+*/
+func TestBan_ModBansPlayer(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ banFixture(t, database, models.ServerRoleMod, models.ServerRolePlayer, false)
+ stub := testutil.NewStubSession(t)
+
+ handler := &banCommand{db: database}
+ handler.Execute(stub.Session, buildBanInteraction(testBanInvokerID, testBanTargetID, "spamming"))
+
+ target, err := db.GetByID[models.Player](database, testBanTargetID)
+ require.NoError(t, err)
+ assert.True(t, target.PlayerIsBanned, "target should be globally banned")
+ assert.Equal(t, "spamming", target.PlayerBanReason)
+
+ var audits []models.AuditEntry
+ err = database.NewSelect().Model(&audits).Where("player_id = ?", testBanTargetID).Scan(context.Background())
+ require.NoError(t, err)
+ require.Len(t, audits, 1, "exactly one audit entry should be written")
+ assert.Equal(t, models.AuditBan, audits[0].Action)
+ assert.Equal(t, testBanInvokerID, audits[0].AuthorID)
+}
+
+/*
+Cannot ban yourself.
+
+When:
+
+ A mod passes their own ID as the target.
+
+Expected:
+
+ Self-ban guard fires before auth; no ban flag flip, no audit entry.
+*/
+func TestBan_SelfBanRejected(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ banFixture(t, database, models.ServerRoleMod, models.ServerRolePlayer, false)
+ stub := testutil.NewStubSession(t)
+
+ handler := &banCommand{db: database}
+ handler.Execute(stub.Session, buildBanInteraction(testBanInvokerID, testBanInvokerID, ""))
+
+ invoker, err := db.GetByID[models.Player](database, testBanInvokerID)
+ require.NoError(t, err)
+ assert.False(t, invoker.PlayerIsBanned, "self-ban must not flip the flag")
+}
+
+/*
+Plain player cannot invoke /ban.
+
+When:
+
+ A player (non-mod) runs /ban.
+
+Expected:
+
+ ScopeMod auth rejects before role-weight check. Target is untouched.
+*/
+func TestBan_PlainPlayerRejected(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ banFixture(t, database, models.ServerRolePlayer, models.ServerRolePlayer, false)
+ stub := testutil.NewStubSession(t)
+
+ handler := &banCommand{db: database}
+ handler.Execute(stub.Session, buildBanInteraction(testBanInvokerID, testBanTargetID, ""))
+
+ target, err := db.GetByID[models.Player](database, testBanTargetID)
+ require.NoError(t, err)
+ assert.False(t, target.PlayerIsBanned, "plain player must not ban anyone")
+}
+
+/*
+Mod cannot ban equal-rank mod.
+
+When:
+
+ A mod invokes /ban on another mod (same weight).
+
+Expected:
+
+ Weight strict-greater-than guard rejects; target is untouched. This is
+ the rule that protects peers from each other.
+*/
+func TestBan_EqualRankRejected(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ banFixture(t, database, models.ServerRoleMod, models.ServerRoleMod, false)
+ stub := testutil.NewStubSession(t)
+
+ handler := &banCommand{db: database}
+ handler.Execute(stub.Session, buildBanInteraction(testBanInvokerID, testBanTargetID, ""))
+
+ target, err := db.GetByID[models.Player](database, testBanTargetID)
+ require.NoError(t, err)
+ assert.False(t, target.PlayerIsBanned, "mod must not ban an equal-rank mod")
+}
+
+/*
+Already-banned target is a no-op.
+
+When:
+
+ A mod tries to ban someone whose PlayerIsBanned is already true.
+
+Expected:
+
+ Bail-out before persistence; no duplicate audit entry is written.
+*/
+func TestBan_AlreadyBannedBailOut(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ banFixture(t, database, models.ServerRoleMod, models.ServerRolePlayer, true)
+ stub := testutil.NewStubSession(t)
+
+ handler := &banCommand{db: database}
+ handler.Execute(stub.Session, buildBanInteraction(testBanInvokerID, testBanTargetID, "second try"))
+
+ var audits []models.AuditEntry
+ err := database.NewSelect().Model(&audits).Where("player_id = ?", testBanTargetID).Scan(context.Background())
+ require.NoError(t, err)
+ assert.Empty(t, audits, "no audit entry should be written for an already-banned target")
+}
+
+/*
+Unregistered target rejected.
+
+When:
+
+ A mod runs /ban on a user ID with no Player row.
+
+Expected:
+
+ BanTargetNotFound guard fires after invoker auth; no row is created for
+ the unregistered user.
+*/
+func TestBan_UnregisteredTargetRejected(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ // Step: seed only the invoker; target has no Player row.
+ _, err := database.NewInsert().Model(&models.Player{ID: testBanInvokerID, Role: models.ServerRoleMod}).Exec(context.Background())
+ require.NoError(t, err)
+
+ stub := testutil.NewStubSession(t)
+ handler := &banCommand{db: database}
+ handler.Execute(stub.Session, buildBanInteraction(testBanInvokerID, testBanTargetID, ""))
+
+ _, err = db.GetByID[models.Player](database, testBanTargetID)
+ assert.Error(t, err, "no Player row should be created for an unregistered target")
+}
diff --git a/internal/commands/campaign_upload.go b/internal/commands/campaign_upload.go
new file mode 100644
index 0000000..159acf8
--- /dev/null
+++ b/internal/commands/campaign_upload.go
@@ -0,0 +1,204 @@
+package commands
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/google/uuid"
+ "github.com/uptrace/bun"
+
+ "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"
+)
+
+/*
+campaignUploadCommand implements /campaignupload.
+
+Flow:
+
+ 1. User runs `/campaignupload kind:Cover campaign: image:`.
+ 2. Autocomplete on `campaign` surfaces campaigns the user DMs.
+ 3. Execute authorizes the caller as DM, validates the attachment,
+ downloads the file to disk, creates a Media record, and responds
+ with the public CDN URL.
+*/
+type campaignUploadCommand struct {
+ db *bun.DB
+ dataDir string
+ mediaBaseURL string
+}
+
+const maxCoverBytes = 8 * 1024 * 1024
+
+func (c *campaignUploadCommand) Data() *discordgo.ApplicationCommand {
+ return &discordgo.ApplicationCommand{
+ Name: messages.CampaignUploadCommandName,
+ Description: messages.CampaignUploadCommandDesc,
+ Options: []*discordgo.ApplicationCommandOption{
+ {
+ Type: discordgo.ApplicationCommandOptionString,
+ Name: messages.CampaignUploadKindOptName,
+ Description: messages.CampaignUploadKindOptDesc,
+ Required: true,
+ Choices: []*discordgo.ApplicationCommandOptionChoice{
+ {Name: messages.CampaignUploadKindCoverChoice, Value: "cover"},
+ },
+ },
+ {
+ Type: discordgo.ApplicationCommandOptionString,
+ Name: messages.CampaignUploadCampaignOptName,
+ Description: messages.CampaignUploadCampaignOptDesc,
+ Required: true,
+ Autocomplete: true,
+ },
+ {
+ Type: discordgo.ApplicationCommandOptionAttachment,
+ Name: messages.CampaignUploadImageOptName,
+ Description: messages.CampaignUploadImageOptDesc,
+ Required: true,
+ },
+ },
+ }
+}
+
+func (c *campaignUploadCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ data := i.ApplicationCommandData()
+
+ var campaignID, attachmentID string
+ for _, opt := range data.Options {
+ switch opt.Name {
+ case messages.CampaignUploadCampaignOptName:
+ campaignID = opt.StringValue()
+ case messages.CampaignUploadImageOptName:
+ attachmentID = opt.Value.(string)
+ }
+ }
+
+ userID := ""
+ if i.Member != nil {
+ userID = i.Member.User.ID
+ } else if i.User != nil {
+ userID = i.User.ID
+ }
+
+ campaign, err := db.GetByID[models.Campaign](c.db, campaignID)
+ if err != nil {
+ respond(s, i, messages.CampaignNotFoundMessage)
+ return
+ }
+ if !campaign.CanMutate() {
+ respond(s, i, messages.CampaignArchivedMessage)
+ return
+ }
+
+ ok, err := auth.Authorize(c.db, userID, auth.ScopeDM, campaign.ID)
+ if err != nil || !ok {
+ respond(s, i, messages.CampaignUploadNotDM)
+ return
+ }
+
+ att, ok := data.Resolved.Attachments[attachmentID]
+ if !ok || att == nil {
+ respond(s, i, messages.CampaignUploadMissingAttach)
+ return
+ }
+
+ if !strings.HasPrefix(att.ContentType, "image/") {
+ respond(s, i, messages.CampaignUploadNotImage)
+ return
+ }
+ if att.Size > maxCoverBytes {
+ respond(s, i, messages.CampaignUploadTooLarge)
+ return
+ }
+
+ ext := filepath.Ext(att.Filename)
+ if ext == "" {
+ ext = ".png"
+ }
+ diskPath, publicURL := mediaserver.CoverPath(c.dataDir, c.mediaBaseURL, i.GuildID, campaignID, ext)
+
+ mimeType, err := mediaserver.Download(att.URL, diskPath)
+ if err != nil {
+ log.Printf("campaignupload: download failed for campaign %s: %v", campaignID, err)
+ respond(s, i, messages.CampaignUploadFailure)
+ return
+ }
+
+ media := &models.Media{
+ ID: uuid.NewString(),
+ OwnerID: userID,
+ CampaignID: campaignID,
+ Path: diskPath,
+ URL: publicURL,
+ Kind: models.KindCoverArt,
+ Name: att.Filename,
+ MimeType: mimeType,
+ CreatedAt: time.Now(),
+ }
+ if _, err := c.db.NewInsert().Model(media).Exec(context.Background()); err != nil {
+ log.Printf("campaignupload: failed to insert media record for campaign %s: %v", campaignID, err)
+ respond(s, i, messages.CampaignUploadFailure)
+ return
+ }
+
+ log.Printf("campaignupload: %s uploaded cover for campaign %s -> %s", userID, campaignID, diskPath)
+ respond(s, i, fmt.Sprintf(messages.CampaignUploadSuccess, campaign.Name, publicURL))
+}
+
+func (c *campaignUploadCommand) Autocomplete(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ data := i.ApplicationCommandData()
+ 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 data.Options {
+ if opt.Name == messages.CampaignUploadCampaignOptName && opt.Focused {
+ query = opt.StringValue()
+ }
+ }
+
+ campaigns, err := db.GetAll[models.Campaign](c.db)
+ if err != nil {
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionApplicationCommandAutocompleteResult,
+ Data: &discordgo.InteractionResponseData{},
+ })
+ return
+ }
+
+ queryLower := strings.ToLower(query)
+ var choices []*discordgo.ApplicationCommandOptionChoice
+ for _, camp := range campaigns {
+ if camp.IsArchived || camp.DungeonMaster != userID {
+ continue
+ }
+ if query != "" && !strings.Contains(strings.ToLower(camp.Name), queryLower) {
+ continue
+ }
+ choices = append(choices, &discordgo.ApplicationCommandOptionChoice{
+ Name: camp.Name,
+ Value: camp.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/campaigns.go b/internal/commands/campaigns.go
new file mode 100644
index 0000000..02b5372
--- /dev/null
+++ b/internal/commands/campaigns.go
@@ -0,0 +1,137 @@
+package commands
+
+import (
+ "fmt"
+ "log"
+
+ "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:
+ 1. User runs `/campaigns`.
+ 2. Load all approved, non-archived campaigns.
+ 3. Show format filter dropdown + campaign select dropdown.
+*/
+
+type campaignsCommand struct {
+ db *bun.DB
+}
+
+func (c *campaignsCommand) Data() *discordgo.ApplicationCommand {
+ return &discordgo.ApplicationCommand{
+ Name: messages.CampaignsCommandName,
+ Description: messages.CampaignsCommandDesc,
+ }
+}
+
+func (c *campaignsCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ campaigns, err := db.GetAll[models.Campaign](c.db)
+ if err != nil {
+ log.Printf("campaigns: failed to load campaigns: %v", err)
+ respond(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ var filtered []models.Campaign
+ for _, camp := range campaigns {
+ if camp.IsApproved && !camp.IsArchived {
+ filtered = append(filtered, camp)
+ }
+ }
+
+ filterMenu := discordgo.SelectMenu{
+ CustomID: messages.CampaignsFilterPrefix,
+ Placeholder: messages.CampaignsFilterPlaceholder,
+ Options: []discordgo.SelectMenuOption{
+ {Label: messages.CampaignsFilterAll, Value: "all", Default: true},
+ {Label: messages.CampaignsFilterCampaign, Value: "campaign"},
+ {Label: messages.CampaignsFilterOneshot, Value: "oneshot"},
+ {Label: messages.CampaignsFilterWestmarch, Value: "westmarch"},
+ },
+ }
+
+ components := []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{filterMenu}},
+ }
+
+ content := messages.CampaignsCommandDesc
+ if len(filtered) > 0 {
+ campaignSelect := buildCampaignSelectMenu(filtered, messages.CampaignSelectPrefix, messages.CampaignsSelectPlaceholder)
+ components = append(components, discordgo.ActionsRow{Components: []discordgo.MessageComponent{campaignSelect}})
+ } else {
+ content = messages.CampaignsNoneAvailable
+ }
+
+ components = append(components, discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ router.NavButton(messages.HomeLabel, discordgo.SecondaryButton, router.ViewMe),
+ }})
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: &discordgo.InteractionResponseData{
+ Content: content,
+ Components: components,
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+buildCampaignSelectMenu builds a select menu from campaigns.
+
+Note:
+
+ In order to avoid an import cycle, this method is local.
+*/
+func buildCampaignSelectMenu(campaigns []models.Campaign, customID, placeholder string) discordgo.SelectMenu {
+ var options []discordgo.SelectMenuOption
+ for _, c := range campaigns {
+ if len(options) >= 25 {
+ break
+ }
+
+ format := "Campaign"
+ if c.IsOneshot {
+ format = "One-shot"
+ }
+ if c.IsWestmarch {
+ format = "Westmarch"
+ }
+ status := "Open"
+ if !c.IsOpen {
+ status = "Closed"
+ }
+ slots := c.DisplaySlots()
+ if c.Slots > 0 && c.Slots <= 10 {
+ slots = fmt.Sprintf("%s slots", slots)
+ }
+ desc := fmt.Sprintf("%s β %s, %s", format, status, slots)
+
+ options = append(options, discordgo.SelectMenuOption{
+ Label: c.Name,
+ Value: c.ID,
+ Description: desc,
+ })
+ }
+
+ if len(options) == 0 {
+ options = append(options, discordgo.SelectMenuOption{
+ Label: "No campaigns available",
+ Value: "none",
+ Default: true,
+ })
+ }
+
+ return discordgo.SelectMenu{
+ CustomID: customID,
+ Placeholder: placeholder,
+ Options: options,
+ }
+}
diff --git a/internal/commands/command.go b/internal/commands/command.go
new file mode 100644
index 0000000..07de9aa
--- /dev/null
+++ b/internal/commands/command.go
@@ -0,0 +1,24 @@
+package commands
+
+import "github.com/bwmarrin/discordgo"
+
+// Command is the interface every slash command must implement.
+type Command interface {
+ Data() *discordgo.ApplicationCommand
+ Execute(s *discordgo.Session, i *discordgo.InteractionCreate)
+}
+
+// AutocompleteCommand is an optional extension for commands that support Discord autocomplete.
+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
new file mode 100644
index 0000000..3cb73e2
--- /dev/null
+++ b/internal/commands/embed.go
@@ -0,0 +1,240 @@
+package commands
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+
+ "github.com/framebuffers/moontracer/internal/manager/models"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+CampaignEmbed builds a rich embed for displaying campaign details.
+
+coverURL is shown as the main image. viewerTokenURL, if set, is shown as a
+thumbnail (top-right).
+
+This is used to display the viewing player's character token.
+*/
+func CampaignEmbed(c models.Campaign, players []models.CampaignPlayer, coverURL, viewerTokenURL, callerID string) *discordgo.MessageEmbed {
+ status := messages.ClosedStatusLabel
+ if c.IsArchived {
+ status = messages.ArchivedStatusLabel
+ } else if c.IsOpen {
+ status = messages.OpenStatusLabel
+ }
+
+ campaignType := messages.CampaignLabel
+ if c.IsOneshot {
+ campaignType = messages.CampaignTypeOneShotLabel
+ }
+ if c.IsWestmarch {
+ campaignType = messages.CampaignTypeWestmarchLabel
+ }
+
+ var playerLines []string
+ for _, p := range players {
+ if p.Status == models.StatusBanned {
+ continue
+ }
+ playerLines = append(playerLines, fmt.Sprintf("<@%s> β %s (%s, %d sessions)",
+ p.PlayerID, p.Role, p.Status, p.SessionsPlayed))
+ }
+ playersValue := messages.NoneLabel
+ if len(playerLines) > 0 {
+ playersValue = strings.Join(playerLines, "\n")
+ }
+
+ warnings := messages.NoneLabel
+ if len(c.Warnings) > 0 {
+ warnings = strings.Join(c.Warnings, ", ")
+ }
+
+ slotsValue := c.DisplaySlots()
+
+ fields := []*discordgo.MessageEmbedField{
+ {Name: "DM", Value: fmt.Sprintf("<@%s>", c.DungeonMaster), Inline: true},
+ {Name: "Status", Value: status, Inline: true},
+ {Name: "Slots", Value: slotsValue, Inline: true},
+ {Name: "Edition", Value: c.Game.Edition, Inline: true},
+ }
+
+ if c.Game.Rules != "" {
+ fields = append(fields, &discordgo.MessageEmbedField{Name: "Rules", Value: c.Game.Rules, Inline: true})
+ }
+ if c.Game.VTT != "" {
+ fields = append(fields, &discordgo.MessageEmbedField{Name: "VTT", Value: c.Game.VTT, Inline: true})
+ }
+ if c.ChannelID != "" && callerIsMember(callerID, c, players) {
+ fields = append(fields, &discordgo.MessageEmbedField{Name: "Channel", Value: fmt.Sprintf("<#%s>", c.ChannelID), Inline: true})
+ }
+
+ synopsis := c.Description
+ if synopsis == "" {
+ synopsis = messages.NoneLabel
+ }
+ fields = append(fields,
+ &discordgo.MessageEmbedField{Name: "Synopsis", Value: synopsis, Inline: false},
+ &discordgo.MessageEmbedField{Name: "Schedule", Value: FormatSchedule(c), Inline: false},
+ &discordgo.MessageEmbedField{Name: "Warnings", Value: warnings, Inline: false},
+ &discordgo.MessageEmbedField{Name: fmt.Sprintf("Players (%d)", len(playerLines)), Value: playersValue, Inline: false},
+ )
+
+ if links := formatEmbedLinks(c); links != "" {
+ fields = append(fields, &discordgo.MessageEmbedField{Name: messages.ManageLinksEmbedTitle, Value: links, Inline: false})
+ }
+
+ embed := &discordgo.MessageEmbed{
+ Title: fmt.Sprintf("%s β %s", campaignType, c.Name),
+ Color: messages.EmbedColor,
+ Fields: fields,
+ }
+ if coverURL != "" {
+ embed.Image = &discordgo.MessageEmbedImage{URL: coverURL}
+ }
+ if viewerTokenURL != "" {
+ embed.Thumbnail = &discordgo.MessageEmbedThumbnail{URL: viewerTokenURL}
+ }
+ return embed
+}
+
+/*
+CampaignButtons builds context-aware action rows for a campaign view.
+
+viewerSheetURL is the viewing player's own sheet URL; when set, an "Open Sheet"
+link button is prepended to their action row.
+
+Pass "" for non-members.
+
+Returns complete ActionsRows ready to append directly to a component list.
+*/
+func CampaignButtons(callerID string, c models.Campaign, players []models.CampaignPlayer, viewerSheetURL string) []discordgo.MessageComponent {
+ if c.IsArchived {
+ return nil
+ }
+
+ if c.DungeonMaster == callerID {
+ return []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.ManageCampaignButtonLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: fmt.Sprintf("manage_campaign:%s", c.ID),
+ },
+ }},
+ }
+ }
+
+ isCallerMember := false
+ for _, p := range players {
+ if p.PlayerID == callerID && p.Status == models.StatusActive {
+ isCallerMember = true
+ break
+ }
+ }
+
+ if isCallerMember {
+ row1 := []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.PlayerSetSheetLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.PlayerSetSheetPrefix, c.ID),
+ },
+ discordgo.Button{
+ Label: messages.PlayerSetTokenLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.PlayerSetTokenPrefix, c.ID),
+ },
+ discordgo.Button{
+ Label: messages.PlayerContactDMLabel,
+ Style: discordgo.SuccessButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.PlayerContactDMPrefix, c.ID),
+ },
+ }
+ if viewerSheetURL != "" {
+ row1 = append([]discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.PlayerOpenSheetLabel,
+ Style: discordgo.LinkButton,
+ URL: viewerSheetURL,
+ },
+ }, row1...)
+ }
+ return []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: row1},
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.LeaveCampaignLabel,
+ Style: discordgo.DangerButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.PlayerLeaveConfirmPrefix, c.ID),
+ },
+ }},
+ }
+ }
+
+ if c.IsOpen {
+ return []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.JoinCampaignLabel,
+ Style: discordgo.SuccessButton,
+ CustomID: fmt.Sprintf("campaign_join:%s", c.Tag),
+ },
+ }},
+ }
+ }
+
+ 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 != "" {
+ parts = append(parts, fmt.Sprintf("**VTT:** %s", c.VTTLink))
+ }
+ for _, r := range c.Links {
+ parts = append(parts, fmt.Sprintf("β’ %s", r))
+ }
+ return strings.Join(parts, "\n")
+}
+
+// FormatSchedule builds a human-readable schedule string for the campaign embed.
+func FormatSchedule(c models.Campaign) string {
+ sched := c.Schedule
+
+ if !sched.HasSchedule() {
+ if sched.Frequency == "" {
+ return "Schedule not set"
+ }
+ return fmt.Sprintf("%s β schedule not set", sched.Frequency)
+ }
+
+ line := fmt.Sprintf("%s β %s %s UTC (%.0fh)", sched.Frequency, sched.DayName(), sched.StartTime, sched.DurationHours)
+
+ if !sched.NextSession.IsZero() {
+ line += fmt.Sprintf("\nNext: %s", sched.NextSession.Format("2006-01-02"))
+ }
+ if !sched.LastSession.IsZero() {
+ line += fmt.Sprintf(" | Last: %s", sched.LastSession.Format("2006-01-02"))
+ }
+
+ return line
+}
diff --git a/internal/commands/help.go b/internal/commands/help.go
new file mode 100644
index 0000000..bef07c8
--- /dev/null
+++ b/internal/commands/help.go
@@ -0,0 +1,84 @@
+package commands
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/dispatch"
+ "github.com/framebuffers/moontracer/internal/interactions/router"
+ msg "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+ /help lists the bot's slash commands and offers a jump back to /me so a
+ lost user always has a single click back to their hub.
+
+ Flow (slash command):
+ 1. User runs `/help`.
+ 2. All(&db, d) returns the registered ApplicationCommand list.
+ 3. helpText is a bulleted list of "/ - description" entries.
+ 4. Respond with source + a "My Hub" button routing to ViewMe.
+
+ Flow (router ViewHelp):
+ 1. Another view's button navigates to ViewHelp.
+ 2. views.go adapter calls RenderHelp(s, i, db, d).
+ 3. RenderHelp rebuilds the same body and responds with
+ InteractionResponseUpdateMessage so it replaces the current view.
+*/
+
+// helpCommand returns a list with all available commands.
+type helpCommand struct {
+ db bun.DB
+ d *dispatch.Dispatcher
+}
+
+// Data is the command metadata that Discord shows to users.
+func (h *helpCommand) Data() *discordgo.ApplicationCommand {
+ return &discordgo.ApplicationCommand{
+ Name: msg.HelpCommandName,
+ Description: msg.HelpCommandDesc,
+ }
+}
+
+// Execute is the logic that runs when the user invokes that command.
+func (c *helpCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: helpResponseData(&c.db, c.d),
+ })
+}
+
+// RenderHelp re-renders /help as a message update (for router back/forward nav).
+func RenderHelp(s *discordgo.Session, i *discordgo.InteractionCreate, db *bun.DB, d *dispatch.Dispatcher) {
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: helpResponseData(db, d),
+ })
+}
+
+func helpResponseData(db *bun.DB, d *dispatch.Dispatcher) *discordgo.InteractionResponseData {
+ commands := All(db, d, "", "")
+
+ 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)
+ }
+
+ return &discordgo.InteractionResponseData{
+ Content: helpText.String(),
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ router.NavButton(msg.MyProfileLabel, discordgo.PrimaryButton, router.ViewMe),
+ }},
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ }
+}
diff --git a/internal/commands/import_campaign.go b/internal/commands/import_campaign.go
new file mode 100644
index 0000000..c8f1172
--- /dev/null
+++ b/internal/commands/import_campaign.go
@@ -0,0 +1,311 @@
+package commands
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/google/uuid"
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/auth"
+ "github.com/framebuffers/moontracer/internal/guard"
+ "github.com/framebuffers/moontracer/internal/manager/models"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+Flow:
+ 1. Staff invokes /importcampaign with an existing channel, role, and DM.
+ 2. Bot defers an ephemeral response immediately (work can take several seconds).
+ 3. In a goroutine:
+ a. Read channel metadata (name, category) from Discord.
+ b. Paginate GuildMembers to collect all members who hold the campaign role.
+ c. Bulk-upsert Player rows for every collected member (including the DM),
+ preserving any existing role/ban state via ON CONFLICT DO NOTHING.
+ d. Insert a Campaign record pointing at the existing channel/role, marked IsApproved.
+ e. Scan the channel for existing threads by name; bind to found ones,
+ create any of the five standard threads that are missing.
+ f. Save AnnouncementsThreadID back to the campaign row.
+ g. Bulk-insert CampaignPlayer rows for every member.
+ h. Edit the deferred response with a summary.
+*/
+
+// importCampaignStandardThreads are the five threads Moontracer expects in every campaign channel.
+var importCampaignStandardThreads = []string{"welcome", "announcements", "sessions", "dice-rolls", "general"}
+
+// importCampaignThreadInitMessages maps thread names to their pinned welcome messages.
+var importCampaignThreadInitMessages = map[string]string{
+ "announcements": messages.ThreadInitMsgAnnouncements,
+ "sessions": messages.ThreadInitMsgSessions,
+ "dice-rolls": messages.ThreadInitMsgDiceRolls,
+ "general": messages.ThreadInitMsgGeneral,
+}
+
+type importCampaignCommand struct {
+ db *bun.DB
+}
+
+func (c *importCampaignCommand) Data() *discordgo.ApplicationCommand {
+ textChannelType := discordgo.ChannelTypeGuildText
+ return &discordgo.ApplicationCommand{
+ Name: messages.ImportCampaignCommandName,
+ Description: messages.ImportCampaignCommandDesc,
+ Options: []*discordgo.ApplicationCommandOption{
+ {
+ Type: discordgo.ApplicationCommandOptionChannel,
+ Name: messages.ImportCampaignOptChannel,
+ Description: "The existing campaign text channel.",
+ Required: true,
+ ChannelTypes: []discordgo.ChannelType{textChannelType},
+ },
+ {
+ Type: discordgo.ApplicationCommandOptionRole,
+ Name: messages.ImportCampaignOptRole,
+ Description: "The Discord role tied to this campaign.",
+ Required: true,
+ },
+ {
+ Type: discordgo.ApplicationCommandOptionUser,
+ Name: messages.ImportCampaignOptDM,
+ Description: "The Dungeon Master of this campaign.",
+ Required: true,
+ },
+ },
+ }
+}
+
+func (c *importCampaignCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ invokerID := i.Member.User.ID
+ guildID := i.GuildID
+
+ ok, err := auth.Authorize(c.db, invokerID, auth.ScopeMod, "")
+ if err != nil {
+ log.Printf("importcampaign: auth check failed: %v", err)
+ respond(s, i, messages.GenericErrorMessage)
+ return
+ }
+ if !ok {
+ respond(s, i, messages.AddPlayerNotDMOrModMessage)
+ return
+ }
+
+ opts := i.ApplicationCommandData().Options
+ channelID := opts[0].ChannelValue(s).ID
+ roleID := opts[1].RoleValue(s, guildID).ID
+ dmUser := opts[2].UserValue(s)
+ dmID := dmUser.ID
+
+ /*
+ Discord gives 3 seconds for the bot to respond to a query, else it shows a failure message.
+ This immediately acknowledges the requests and defers its execution with an Importing... message.
+ */
+ if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: &discordgo.InteractionResponseData{
+ Content: messages.ImportCampaignProcessing,
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ }); err != nil {
+ log.Printf("importcampaign: deferred ack failed: %v", err)
+ return
+ }
+
+ go func() {
+ edit := func(content string) {
+ s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &content})
+ }
+
+ // 1. Read channel metadata.
+ ch, err := s.Channel(channelID)
+ if err != nil {
+ log.Printf("importcampaign: fetch channel %s: %v", channelID, err)
+ edit(messages.ImportCampaignErrChannel)
+ return
+ }
+ channelName := ch.Name
+ categoryID := ch.ParentID
+
+ // 2. Collect all guild members who hold the campaign role.
+ memberIDs := collectMembersWithRole(s, guildID, roleID)
+
+ // 3. Ensure the DM is always included, even if they don't have the role yet.
+ if !containsString(memberIDs, dmID) {
+ memberIDs = append(memberIDs, dmID)
+ }
+
+ // 4. Create Player rows for everyone who doesn't have one.
+ if err := models.BulkUpsertPlayers(c.db, memberIDs); err != nil {
+ log.Printf("importcampaign: bulk upsert players: %v", err)
+ }
+
+ // 5. Build and insert the campaign record.
+ campaign := &models.Campaign{
+ ID: uuid.NewString(),
+ Name: channelName,
+ DungeonMaster: dmID,
+ ChannelID: channelID,
+ CategoryID: categoryID,
+ RoleID: roleID,
+ IsApproved: true,
+ IsOpen: true,
+ }
+ if _, err := c.db.NewInsert().Model(campaign).Exec(context.Background()); err != nil {
+ log.Printf("importcampaign: insert campaign: %v", err)
+ edit(messages.ImportCampaignErrDB)
+ return
+ }
+
+ /*
+ 6. Grant the bot the permissions it needs to create threads and pin messages
+ in the existing channel. Normal campaign creation does this via channel overwrites
+ at creation time; for imported channels we add the overwrite explicitly.
+ */
+ if err := guard.ChannelPermissionSet(s, channelID, s.State.User.ID,
+ discordgo.PermissionOverwriteTypeMember,
+ discordgo.PermissionViewChannel|discordgo.PermissionManageThreads|discordgo.PermissionSendMessages|discordgo.PermissionManageMessages,
+ 0,
+ ); err != nil {
+ log.Printf("importcampaign: set bot permissions on %s: %v", channelID, err)
+ }
+
+ // 7. Wire up threads: bind existing, create missing.
+ created, bound := bindOrCreateImportThreads(s, guildID, campaign, channelID, channelName)
+
+ // 8. Persist AnnouncementsThreadID if it was resolved.
+ if campaign.AnnouncementsThreadID != "" {
+ if _, err := c.db.NewUpdate().Model(campaign).
+ Column("announcements_thread_id").
+ Where("id = ?", campaign.ID).
+ Exec(context.Background()); err != nil {
+ log.Printf("importcampaign: save announcements thread id: %v", err)
+ }
+ }
+
+ // 9. Create CampaignPlayer rows.
+ if err := models.BulkAddCampaignMembers(c.db, campaign.ID, memberIDs); err != nil {
+ log.Printf("importcampaign: bulk add members: %v", err)
+ }
+
+ log.Printf("importcampaign: imported %q (%s): %d members, %d threads bound, %d threads created",
+ channelName, campaign.ID, len(memberIDs), bound, created)
+
+ edit(fmt.Sprintf(messages.ImportCampaignSuccess, channelName, len(memberIDs), bound, created))
+ }()
+}
+
+func (c *importCampaignCommand) Hidden() bool { return true }
+
+// collectMembersWithRole paginates GuildMembers and returns IDs of members who hold roleID.
+func collectMembersWithRole(s *discordgo.Session, guildID, roleID string) []string {
+ var ids []string
+ after := ""
+ for {
+ members, err := s.GuildMembers(guildID, after, 1000)
+ if err != nil {
+ log.Printf("importcampaign: fetch members (after=%s): %v", after, err)
+ break
+ }
+ for _, m := range members {
+ for _, r := range m.Roles {
+ if r == roleID {
+ ids = append(ids, m.User.ID)
+ break
+ }
+ }
+ }
+ if len(members) < 1000 {
+ break
+ }
+ after = members[len(members)-1].User.ID
+ }
+ return ids
+}
+
+/*
+bindOrCreateImportThreads checks for existing threads matching Moontracer's standard names,
+binds to them if found, and creates any that are missing.
+*/
+func bindOrCreateImportThreads(s *discordgo.Session, guildID string, c *models.Campaign, channelID, channelName string) (created, bound int) {
+ // Build a lookup of existing thread names in this channel.
+ existing := map[string]string{} // threadName -> threadID
+
+ if active, err := s.GuildThreadsActive(guildID); err == nil {
+ for _, t := range active.Threads {
+ if t.ParentID == channelID {
+ existing[t.Name] = t.ID
+ }
+ }
+ } else {
+ log.Printf("importcampaign: fetch active threads: %v", err)
+ }
+
+ // Also check public archived threads for this channel.
+ if archived, err := s.ThreadsArchived(channelID, nil, 100); err == nil {
+ for _, t := range archived.Threads {
+ if _, already := existing[t.Name]; !already {
+ existing[t.Name] = t.ID
+ }
+ }
+ }
+
+ for _, name := range importCampaignStandardThreads {
+ threadName := fmt.Sprintf("%s-%s", channelName, name)
+
+ if id, found := existing[threadName]; found {
+ if name == "announcements" {
+ c.AnnouncementsThreadID = id
+ }
+ bound++
+ continue
+ }
+
+ // Thread doesn't exist: create it.
+ const archiveDuration = 10080 // 1 week in minutes
+ thread, err := guard.ThreadStart(s, channelID, threadName, archiveDuration)
+ if err != nil {
+ log.Printf("importcampaign: create thread %s: %v", threadName, err)
+ continue
+ }
+ created++
+
+ if name == "announcements" {
+ c.AnnouncementsThreadID = thread.ID
+ }
+
+ var initMsg string
+ if name == "welcome" {
+ initMsg = fmt.Sprintf(messages.ThreadInitMsgWelcomeFmt, c.Name)
+ } else if msg, ok := importCampaignThreadInitMessages[name]; ok {
+ initMsg = msg
+ }
+
+ if initMsg == "" {
+ continue
+ }
+ msg, err := guard.ChannelMessageSend(s, thread.ID, initMsg)
+ if err != nil {
+ log.Printf("importcampaign: init message for %s: %v", threadName, err)
+ continue
+ }
+ if err := guard.ChannelMessagePin(s, thread.ID, msg.ID); err != nil {
+ log.Printf("importcampaign: pin in %s: %v", threadName, err)
+ }
+ if name == "welcome" {
+ if err := guard.LockThread(s, thread.ID); err != nil {
+ log.Printf("importcampaign: lock %s: %v", threadName, err)
+ }
+ }
+ }
+ return
+}
+
+func containsString(ss []string, s string) bool {
+ for _, v := range ss {
+ if v == s {
+ return true
+ }
+ }
+ return false
+}
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
new file mode 100644
index 0000000..ad73025
--- /dev/null
+++ b/internal/commands/me.go
@@ -0,0 +1,117 @@
+package commands
+
+import (
+ "fmt"
+ "log"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/auth"
+ "github.com/framebuffers/moontracer/internal/interactions/router"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+ /me is the player hub. The landing page every other player-facing view
+ backs out to. Button layout is composed from the invoker's scope so users
+ only see affordances they can actually use.
+
+ Flow (slash command):
+ 1. User runs `/me`.
+ 2. Authorize ScopePlayer (is the user registered?). Unregistered ->
+ NotRegisteredMessage and abort.
+ 3. Call buildMeHubComponents(db, userID) to produce a scope-aware
+ []MessageComponent.
+ 4. Respond with InteractionResponseChannelMessageWithSource
+ (fresh ephemeral message).
+
+ Flow (back navigation β RenderMeHub):
+ 1. User clicks "Back" on any child view that pointed at ViewMe.
+ 2. navHandler -> router.Navigate -> the ViewMe adapter in views.go.
+ 3. Adapter calls RenderMeHub(s, i, db, userID).
+ 4. RenderMeHub re-runs buildMeHubComponents and responds with
+ InteractionResponseUpdateMessage (replaces the current view
+ in place, no new ephemeral).
+
+ Flow (buildMeHubComponents scope composition):
+ 1. Row 1 (Campaigns) is fixed: My Campaigns, New Campaign, Browse.
+ 2. Row 2 (Activity) is fixed: Next Sessions, Notifications.
+ 3. Row 3 (Options) starts with Timezone (always shown).
+ - Probe isDMOfAnyCampaign: if true, add Manage Campaigns.
+ - Probe auth.Authorize(ScopeMod): if true, add Admin Panel.
+ ScopeAdmin is a superset of ScopeMod so this covers both.
+ 4. On probe failure (DB error), the button is silently omitted
+ rather than blocking the whole hub β /me must always render
+ something for a registered user.
+*/
+
+type meCommand struct {
+ db *bun.DB
+}
+
+func (m *meCommand) Data() *discordgo.ApplicationCommand {
+ return &discordgo.ApplicationCommand{
+ Name: messages.MeCommandName,
+ Description: messages.MeCommandDesc,
+ }
+}
+
+func (m *meCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ userID := i.Member.User.ID
+
+ registered, err := auth.Authorize(m.db, userID, auth.ScopePlayer, "")
+ if err != nil {
+ log.Printf("me: auth check failed: %v", err)
+ respond(s, i, messages.GenericErrorMessage)
+ return
+ }
+ if !registered {
+ respondNotRegistered(s, i)
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf(messages.MeHubMessage, userID),
+ Components: buildMeHubComponents(),
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+// RenderMeHub re-renders the /me hub via a message update (used by router back buttons).
+func RenderMeHub(s *discordgo.Session, i *discordgo.InteractionCreate, db *bun.DB, userID string) {
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf(messages.MeHubMessage, userID),
+ Embeds: []*discordgo.MessageEmbed{},
+ Components: buildMeHubComponents(),
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+buildMeHubComponents returns the three-button /me hub row.
+
+All hub buttons are blurple (Primary). Sub-views handle scoped content.
+*/
+func buildMeHubComponents() []discordgo.MessageComponent {
+ return []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ router.NavButton(messages.MeCampaignsLabel, discordgo.PrimaryButton, router.ViewMeCampaigns),
+ router.NavButton(messages.TokensLabel, discordgo.PrimaryButton, router.ViewMeTokens),
+ discordgo.Button{
+ Label: messages.NextSessionsLabel,
+ Style: discordgo.PrimaryButton,
+ CustomID: messages.NextSessionsPrefix,
+ },
+ router.NavButton(messages.MeConfigLabel, discordgo.PrimaryButton, router.ViewMeConfig),
+ router.NavButton(messages.AboutLabel, discordgo.SecondaryButton, router.ViewAbout),
+ }},
+ }
+}
+
diff --git a/internal/commands/newcampaign.go b/internal/commands/newcampaign.go
new file mode 100644
index 0000000..bd4b0c7
--- /dev/null
+++ b/internal/commands/newcampaign.go
@@ -0,0 +1,83 @@
+package commands
+
+import (
+ "log"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/auth"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+// newCampaignCommand opens the campaign creation modal directly as a slash command.
+type newCampaignCommand struct {
+ db *bun.DB
+}
+
+func (c *newCampaignCommand) Data() *discordgo.ApplicationCommand {
+ return &discordgo.ApplicationCommand{
+ Name: messages.NewCampaignCommandName,
+ Description: messages.NewCampaignCommandDesc,
+ }
+}
+
+func (c *newCampaignCommand) 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("newcampaign: auth check failed: %v", err)
+ respond(s, i, messages.GenericErrorMessage)
+ return
+ }
+ if !ok {
+ respondNotRegistered(s, i)
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseModal,
+ Data: &discordgo.InteractionResponseData{
+ CustomID: messages.NewCampaignModalCustomID,
+ Title: messages.NewCampaignModalTitle,
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: messages.FieldNameID,
+ Label: messages.FieldNameLabel,
+ Style: discordgo.TextInputShort,
+ Placeholder: messages.FieldNamePlaceholder,
+ Required: true,
+ MaxLength: 100,
+ },
+ }},
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: messages.FieldSlotsID,
+ Label: messages.FieldSlotsLabel,
+ Style: discordgo.TextInputShort,
+ Placeholder: messages.FieldSlotsPlaceholder,
+ Required: false,
+ MaxLength: 3,
+ },
+ }},
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: messages.FieldDescriptionID,
+ Label: messages.FieldSynopsisLabel,
+ Style: discordgo.TextInputParagraph,
+ Placeholder: messages.FieldDescriptionPlaceholder,
+ Required: true,
+ MaxLength: 1000,
+ },
+ }},
+ },
+ },
+ })
+}
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..8d593fd
--- /dev/null
+++ b/internal/commands/nextsessions.go
@@ -0,0 +1,91 @@
+package commands
+
+import (
+ "fmt"
+ "log"
+ "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 upcoming sessions the player is a member of, across all approved
+ campaigns, sorted ascending by scheduled time.
+ 3. Responds ephemerally with a formatted list 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
+ }
+
+ sessions, err := models.GetAllUpcomingSessionsForPlayer(c.db, userID)
+ if err != nil {
+ log.Printf("nextsessions: load sessions for %s: %v", userID, err)
+ respond(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ if len(sessions) == 0 {
+ respond(s, i, messages.NextSessionsNone)
+ return
+ }
+
+ 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 _, sess := range sessions {
+ campaignName := ""
+ if sess.Campaign != nil {
+ campaignName = sess.Campaign.Name
+ }
+ formatted := helpers.FormatInLocation(sess.ScheduledAt, messages.SessionListFormat, loc) + " " + helpers.TZLabel(loc)
+ lines = append(lines, fmt.Sprintf("β’ **%s** β %s Β· %s", campaignName, formatted, helpers.TimeRemaining(sess.ScheduledAt)))
+ }
+ 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
new file mode 100644
index 0000000..276902f
--- /dev/null
+++ b/internal/commands/ping.go
@@ -0,0 +1,28 @@
+package commands
+
+import (
+ "github.com/bwmarrin/discordgo"
+
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+// pingCommand tests the connectivity between client and server. Responds with 'pong!' when successful.
+type pingCommand struct{}
+
+// Data is the command metadata that Discord shows to users.
+func (c *pingCommand) Data() *discordgo.ApplicationCommand {
+ return &discordgo.ApplicationCommand{
+ Name: messages.PingCommandName,
+ Description: messages.PingCommandDesc,
+ }
+}
+
+// Execute is the logic that runs when the user invokes that command.
+func (c *pingCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: &discordgo.InteractionResponseData{
+ Content: "pong!",
+ },
+ })
+}
diff --git a/internal/commands/register.go b/internal/commands/register.go
new file mode 100644
index 0000000..03aec1f
--- /dev/null
+++ b/internal/commands/register.go
@@ -0,0 +1,115 @@
+package commands
+
+import (
+ "fmt"
+ "log"
+
+ "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"
+)
+
+/*
+ Flow:
+ 1. User runs `/register`.
+ 2. Check if the user is already registered (ScopePlayer auth check).
+ 3. If already registered, respond: "You are already registered!"
+ 4. If not, insert a new Player record into the DB with the user's Discord ID.
+ 5. Respond: "Welcome, @user! You are now registered."
+*/
+
+type registerCommand struct {
+ db *bun.DB
+}
+
+// Data is the command metadata that Discord shows to users.
+func (r *registerCommand) Data() *discordgo.ApplicationCommand {
+ return &discordgo.ApplicationCommand{
+ Name: messages.RegisterCommandName,
+ Description: messages.RegisterCommandDesc,
+ }
+}
+
+// Execute is the logic that runs when the user invokes that command.
+func (r *registerCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ userID := i.Member.User.ID
+
+ registered, err := auth.Authorize(r.db, userID, auth.ScopePlayer, "")
+ if err != nil {
+ log.Printf("register: %s: %v", messages.RegistrationCheckError, err)
+ respond(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ content := messages.AlreadyRegisteredMessage
+ if !registered {
+ player := &models.Player{ID: userID}
+ if err := db.Insert(r.db, player); err != nil {
+ log.Printf("register: %s: %v", messages.RegistrationInsertError, err)
+ respond(s, i, messages.RegistrationFailureMessage)
+ return
+ }
+ content = fmt.Sprintf(messages.RegistrationSuccessMessage, userID)
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: &discordgo.InteractionResponseData{
+ Content: content + "\n\nWhat would you like to do?",
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.BrowseCampaignsLabel,
+ Style: discordgo.PrimaryButton,
+ CustomID: router.NavCustomID(router.ViewCampaignsBrowse, "all"),
+ },
+ discordgo.Button{
+ Label: messages.MyProfileLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: router.NavCustomID(router.ViewMe),
+ },
+ }},
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+// respond is a helper to send an ephemeral text response.
+func respond(s *discordgo.Session, i *discordgo.InteractionCreate, content string) {
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: &discordgo.InteractionResponseData{
+ Content: content,
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+respondNotRegistered sends a fresh ephemeral with the "not registered" message and a
+Register button that registers the user and opens the /me hub in one click.
+*/
+func respondNotRegistered(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: &discordgo.InteractionResponseData{
+ Content: messages.NotRegisteredMessage,
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.RegisterButtonLabel,
+ Style: discordgo.SuccessButton,
+ CustomID: messages.QuickRegisterPrefix,
+ },
+ }},
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
diff --git a/internal/commands/registry.go b/internal/commands/registry.go
new file mode 100644
index 0000000..3785f0a
--- /dev/null
+++ b/internal/commands/registry.go
@@ -0,0 +1,95 @@
+package commands
+
+import (
+ "context"
+
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/dispatch"
+ "github.com/framebuffers/moontracer/internal/manager/models"
+)
+
+/*
+All returns every registered command. Add new commands here.
+
+How to add a new slash command:
+
+ 1. Create internal/commands/.go with a struct type that holds
+ whatever dependencies it needs (usually `db *bun.DB`).
+
+ 2. Implement the Command interface (internal/commands/command.go):
+
+ - Data() *discordgo.ApplicationCommand. This is the Discord registration
+ metadata (name, description, options).
+
+ - Execute(s, i). This is the handler invoked when the user runs the command.
+
+ - Note: Add user-facing strings to internal/messages/messages.go. Never
+ inline literals. The messages package is the single source of truth for copy.
+
+ 3. If the command uses autocomplete options, also implement the
+ AutocompleteCommand interface (Autocomplete(s, i)).
+
+ 4. Add the command to the slice below. Place debug-only commands inside.
+
+ 5. If the command needs auth, call auth.Authorize(db, userID, scope, id)
+ at the top of Execute. Scopes: ScopePlayer, ScopeDM, ScopeMod.
+
+ 6. Run `go build ./...` once all edits are done.
+ - Note: Always make sure to also run tests (`go test ./...`) to make sure
+ important features, like sovereignty and authorization, work after creating new commands.
+
+Note:
+
+ At startup the bot calls All() once to collect command metadata, registers
+ them with Discord (scoped to DISCORD_GUILD_ID when set, global otherwise),
+ and routes incoming InteractionApplicationCommand events by command name
+ via internal/discord/handler.go.
+
+ Debug-only commands are gated on guard.DevMode so production deployments
+ don't expose them in Discord's command picker.
+*/
+func All(db *bun.DB, d *dispatch.Dispatcher, dataDir, mediaBaseURL string) []Command {
+ cmds := []Command{
+ &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},
+ &unbanCommand{db: db},
+ &adminCommand{db: db},
+ &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},
+ &importCampaignCommand{db: db},
+ }
+
+ return cmds
+}
+
+// RegisterCommands populates the commands table with metadata from all registered commands.
+func RegisterCommands(db *bun.DB, d *dispatch.Dispatcher) error {
+ ctx := context.Background()
+ commands := All(db, d, "", "")
+
+ for _, cmd := range commands {
+ data := cmd.Data()
+ record := &models.CommandRecord{
+ Name: data.Name,
+ Description: data.Description,
+ }
+ _, err := db.NewInsert().Model(record).On("CONFLICT (name) DO NOTHING").Exec(ctx)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/internal/commands/routes.md b/internal/commands/routes.md
new file mode 100644
index 0000000..6b1705d
--- /dev/null
+++ b/internal/commands/routes.md
@@ -0,0 +1,161 @@
+# Moontracer Menus
+
+Note: This route structure follows the same rule: Each interaction has buttons for the one down the list. e. g: using `/register` opens an interaction where it has buttons for Join, and Browse. Clicking on Brose opens an interaction with buttons for "By Format" (clicking it displays buttons for all three options) or "All".
+
+Interactions can also have commands associated with them, some being links to pre-existing interactions. This is done such that the user has a logical flow of where things are, without having to backtrack.
+
+Each interaction also has a back button to go up the directory.
+
+Reference for syntax:
+
+- Dropdowns: Select Menus
+- [thing]:[option] text argument
+
+
+## Onboarding
+
+- Register [/register]
+ - Join a Campaign [/join]
+ - Browse Campaigns [/campaigns]
+ - By Format:
+ - One-shots [/campaigns format:oneshot]
+ - Westmarches [/campaigns format:westmarch]
+ - Campaigns [/campaigns format:campaign]
+ - All
+
+## Player
+
+### Player Menu [/me]
+
+- My Campaigns [/mycampaigns]
+- Next Sessions [/nextsessions]
+- Notifications [/notifications] // if something is pending, got approved, denied, a mod sent a message
+
+## CampaignPlayer [/campaign dropdown:campaign]
+
+- Tokens
+ - Add or Create a new token [/token dropdown:campaign]
+- Next session [/nextsession dropdown:campaign]
+- Send a message to the DM [/messagedm dropdown:campaign]
+- Leave Campaign
+
+## Campaign [/campaign dropdown:campaign]
+
+- Ask to join [button]
+- Next Session [button]
+- Stats [button, /stats dropdown:campaign]
+ - Deaths
+ - Sessions Played
+ - Journal [/gamejournal dropdown:campaign]
+
+
+## DM [/mycampaigns (if is DM)]
+
+- Approve new players [/approve dropdown:campaign]
+- New Campaign [/newcampaign, opens modal]
+- Requests [/requests, opens requests to join session]
+- Edit my Campaigns [/configcampaign dropdown:campaign]
+- Config [/configcampaign dropdown:campaign]
+ - Edit [/editcampaign dropdown:campaign]
+ - Journal [/editjournal dropdown:campaign]
+ - Add entry [/newjournal dropdown:campaign modal:text]
+ - Edit entry [/editjournal dropdown:campaign entry_number]
+ - Entries [/journal dropdown:campaign]
+ - Config [/configjournal dropdown:campaign]
+ - Disable [/disablejournal dropdown:campaign]
+ - Hide from players [/hidejournal dropdown:campaign]
+ - Synopsis [/campaignsynopsis dropdown:campaign]
+ - Cover art [/editcoverart dropdown:campaign]
+ - Links [/campaignlinks dropdown:campaign]
+ - Add [/addlink dropdown:campaign link]
+ - [a list of all links]
+ - Edit [/editlink dropdown:campaign select:link_key value]
+ - Remove [/removelink dropdown:campaign select:link_key]
+ - [show value on interaction] // usuals are: [vtt, music, character_sheets (a link to dndbeyond, or wherever your character sheet exists)]
+ - Players [/myplayers dropdown:campaign]
+ - List
+ - Remove player from next session [/kickfromsession campaign:currently_selected player:player]
+ - Tokens [/campaigntokens dropdown:campaign]
+ - Download all tokens [/downloadtokens dropdown:campaign]
+ - Change Player's token [/changetoken campaign:currently_selected player:player file]
+ - Prioritize new players [/prioritizenewplayers dropdown:campaign yes/no]
+ /* on westmarches, the DM assigns a set number of players per session (because they cannot manage infinite number of players).
+
+ to manage who gets to play, a DM can prioritise by: queue (fill the quota and then close), join date/least plays (effectively, the ones that have played the least get priority over the ones that have played the most, in order to avoid having the same party members each time).
+ */
+ - Scheduling [/campainschedule dropdown:campaign]
+ - Set frequency [/setschedule campaign:currently_active frequency:{daily, weekly, biweekly, monthly, quarterly, yearly, westmarch, one-shot} day:modal]
+ - Re-schedule next session [/reschedule dropdown:campaign modal:next_date]
+- New Session [/newsession dropdown:campaign]
+ - Set date and time [if it is a westmarch, open modal to enter datetime, else, ask if it's a one-time edit or a complete reschedule (if it is, just open the /reschedule modal)]
+ - Config [links to /configcampaign]
+ - Description [modal text]
+ - Links [link to links interaction]
+ - Broadcast [button]
+- Stats [/campaignstats dropdown:campaign]
+ - Deaths [/addplayerdeath dropdown:campaign player]
+ - Players joined
+ - Players active [have played over the last month]
+ - Players left [counter]
+ - Campaign journal [/journal dropdown:campaign, can be set to off or hidden]
+
+## Mod/Admin menu [/admin]
+
+- Get active campaigns [/activecampaigns]
+ - [this is what appears when a /managecampaign is used by a mod/admin]
+ - DM [/getcampaigndm dropdown:campaign]
+ - Ban from server [button, staff-only, link to /ban player reason]
+ - Message Campaign DM [button, staff-only]
+ - Ban from server [button, staff-only]
+ - Stats [inherit from DM menu]
+ - Times played
+ - Players (status, playcount, deathcount)
+ - Deaths total
+ - Playtime (to be implemented, might use VC to track)
+ - Get by DM [/campaigns format:dm]
+ - Get All [/campaigns default]
+- DMs [button, staff-only]
+ - Ban from server [/ban player reason, staff-only]
+ - Ban from making campaigns [/softban player reason, staff-only]
+ - Unban member [/unban player reason, staff-only]
+ - Campaigns [link to /campaigns format:dm player:dm-selector]
+ - Stats [button, staff-only]
+ - Campaignes owned
+ - Campaigns playing/played
+ - Time played
+- Broadcast announcement [/announce message]
+
+## Bot config [/settings]
+
+- Database [button, staff-only]
+ - List all Members
+ - List all Campaigns
+ - List Campaign-Player relationships
+ - Stats
+ - Active players
+ - Active campaigns
+ - Archived campaigns
+ - Denied campaigns + DM
+ - Audit log
+- Add/remove permissions to user [button, staff-only]
+ - [/grantpermission permission-id user]
+- Debug
+ - [add things here to see stats to debug issues]
+ - [add copy of docker log]
+- Storage used [button, staff-only]
+- Enable/disable features [button, staff-only]
+ - [buttons/interactions to allow/deny]
+- About [/moontracer]
+
+## Campaign Create [/newcampaign]
+
+- Name
+ - Tag is now a normalised version of the name
+- Book
+ - Dropdown with options: {5e, 5.5e, pathfinder, other common options, homebrew/other}
+- Max players
+ - If empty, it's an open campaign
+- Synopsis and rules
+- Game format
+ - Dropdown: {campaign, westmarch, one-shot}
+
diff --git a/internal/commands/search.go b/internal/commands/search.go
new file mode 100644
index 0000000..83369c0
--- /dev/null
+++ b/internal/commands/search.go
@@ -0,0 +1,130 @@
+package commands
+
+import (
+ "log"
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/db"
+ "github.com/framebuffers/moontracer/internal/manager/models"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+ Flow:
+ 1. User types `/search name:`.
+ 2. Discord sends autocomplete interactions as the user types.
+ 3. Autocomplete handler returns matching campaign names.
+ 4. User selects a campaign from the autocomplete list.
+ 5. Execute renders the campaign detail embed.
+*/
+
+type searchCommand struct {
+ db *bun.DB
+}
+
+func (c *searchCommand) Data() *discordgo.ApplicationCommand {
+ return &discordgo.ApplicationCommand{
+ Name: messages.SearchCommandName,
+ Description: messages.SearchCommandDesc,
+ Options: []*discordgo.ApplicationCommandOption{
+ {
+ Type: discordgo.ApplicationCommandOptionString,
+ Name: messages.SearchOptionName,
+ Description: messages.SearchOptionDesc,
+ Required: true,
+ Autocomplete: true,
+ },
+ },
+ }
+}
+
+func (c *searchCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ var campaignID string
+ for _, opt := range i.ApplicationCommandData().Options {
+ if opt.Name == messages.SearchOptionName {
+ campaignID = opt.StringValue()
+ }
+ }
+
+ campaign, err := db.GetByID[models.Campaign](c.db, campaignID)
+ if err != nil || !campaign.IsApproved {
+ respond(s, i, messages.SearchNoResults)
+ return
+ }
+
+ players, err := models.GetCampaignPlayers(c.db, campaign.ID)
+ if err != nil {
+ log.Printf("search: failed to load players for %s: %v", campaignID, err)
+ respond(s, i, messages.CampaignPlayersLoadError)
+ return
+ }
+
+ userID := i.Member.User.ID
+ coverURL := models.CoverURLForCampaign(c.db, campaign.ID)
+ embed := CampaignEmbed(*campaign, players, coverURL, "", userID)
+ buttons := CampaignButtons(userID, *campaign, players, "")
+
+ resp := &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: &discordgo.InteractionResponseData{
+ Embeds: []*discordgo.MessageEmbed{embed},
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ }
+
+ if len(buttons) > 0 {
+ resp.Data.Components = []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: buttons},
+ }
+ }
+
+ s.InteractionRespond(i.Interaction, resp)
+}
+
+func (c *searchCommand) Autocomplete(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ var query string
+ for _, opt := range i.ApplicationCommandData().Options {
+ if opt.Name == messages.SearchOptionName {
+ query = opt.StringValue()
+ }
+ }
+
+ campaigns, err := db.GetAll[models.Campaign](c.db)
+ if err != nil {
+ log.Printf("search autocomplete: failed to load campaigns: %v", err)
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionApplicationCommandAutocompleteResult,
+ Data: &discordgo.InteractionResponseData{},
+ })
+ return
+ }
+
+ queryLower := strings.ToLower(query)
+ var choices []*discordgo.ApplicationCommandOptionChoice
+
+ for _, camp := range campaigns {
+ if !camp.IsApproved || camp.IsArchived {
+ continue
+ }
+ if query != "" && !strings.Contains(strings.ToLower(camp.Name), queryLower) {
+ continue
+ }
+ choices = append(choices, &discordgo.ApplicationCommandOptionChoice{
+ Name: camp.Name,
+ Value: camp.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/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
new file mode 100644
index 0000000..4ba1f80
--- /dev/null
+++ b/internal/commands/unban.go
@@ -0,0 +1,90 @@
+package commands
+
+import (
+ "fmt"
+ "log"
+
+ "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/manager/models"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+ Flow:
+ 1. A mod/admin invokes `/unban @player`
+ 2. Auth: invoker must be at least mod.
+ 3. Load target player, check they are actually banned.
+ 4. Clear IsBanned + BanReason.
+ 5. Respond with success or failure.
+*/
+
+type unbanCommand struct {
+ db *bun.DB
+}
+
+func (u *unbanCommand) Data() *discordgo.ApplicationCommand {
+ return &discordgo.ApplicationCommand{
+ Name: messages.UnbanCommandName,
+ Description: messages.UnbanCommandDesc,
+ Options: []*discordgo.ApplicationCommandOption{
+ {
+ Type: discordgo.ApplicationCommandOptionUser,
+ Name: "player",
+ Description: "The player to unban.",
+ Required: true,
+ },
+ },
+ }
+}
+
+func (u *unbanCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ invokerID := i.Member.User.ID
+ targetUser := i.ApplicationCommandData().Options[0].UserValue(s)
+
+ ok, err := auth.Authorize(u.db, invokerID, auth.ScopeMod, "")
+ if err != nil {
+ log.Printf("unban: invoker auth check failed: %v", err)
+ respond(s, i, messages.GenericErrorMessage)
+ return
+ }
+ if !ok {
+ respond(s, i, messages.AddPlayerNotDMOrModMessage)
+ return
+ }
+
+ target, err := db.GetByID[models.Player](u.db, targetUser.ID)
+ if err != nil {
+ respond(s, i, messages.UnbanTargetNotFound)
+ return
+ }
+
+ if !target.PlayerIsBanned {
+ respond(s, i, messages.UnbanTargetNotBanned)
+ return
+ }
+
+ target.PlayerIsBanned = false
+ target.PlayerBanReason = ""
+
+ if err := db.Update(u.db, target); err != nil {
+ log.Printf("unban: failed to update player: %v", err)
+ respond(s, i, messages.UnbanFailureMessage)
+ return
+ }
+
+ /*
+ Unbanning a member only clears the global ban flag. A Player's membership to a Campaign remains untouched.
+ */
+ if err := models.InsertAuditEntry(u.db, target.ID, invokerID, models.AuditUnban, "global ban lifted"); err != nil {
+ log.Printf("unban: failed to write audit entry: %v", err)
+ }
+
+ 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
new file mode 100644
index 0000000..eddd434
--- /dev/null
+++ b/internal/commands/upload_token.go
@@ -0,0 +1,225 @@
+package commands
+
+import (
+ "encoding/hex"
+ "fmt"
+ "image/color"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/google/uuid"
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/mediaserver"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+uploadTokenCommand implements /uploadtoken.
+
+ Flow:
+ 1. User runs /uploadtoken source: frame:.
+ 2. Bot defers the response.
+ 3. Downloads both attachments to temp paths.
+ 4. Calls mediaserver.ProcessToken.
+ 5. Edits the deferred response with a preview embed + Apply/Discard buttons.
+ 6. Apply: saves the Media record and removes temps.
+ Discard: removes all three temp files.
+*/
+type uploadTokenCommand struct {
+ db *bun.DB
+ dataDir string
+ mediaBaseURL string
+}
+
+func (c *uploadTokenCommand) Data() *discordgo.ApplicationCommand {
+ return &discordgo.ApplicationCommand{
+ Name: messages.TokenUploadCommandName,
+ Description: messages.TokenUploadCommandDesc,
+ Options: []*discordgo.ApplicationCommandOption{
+ {
+ Type: discordgo.ApplicationCommandOptionAttachment,
+ Name: messages.TokenUploadSourceOptName,
+ Description: messages.TokenUploadSourceOptDesc,
+ Required: true,
+ },
+ {
+ Type: discordgo.ApplicationCommandOptionAttachment,
+ Name: messages.TokenUploadFrameOptName,
+ Description: messages.TokenUploadFrameOptDesc,
+ Required: false,
+ },
+ {
+ Type: discordgo.ApplicationCommandOptionString,
+ Name: messages.TokenUploadColorOptName,
+ Description: messages.TokenUploadColorOptDesc,
+ Required: false,
+ },
+ },
+ }
+}
+
+func (c *uploadTokenCommand) 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
+ }
+
+ data := i.ApplicationCommandData()
+ var sourceID, frameID, colorHex string
+ for _, opt := range data.Options {
+ switch opt.Name {
+ case messages.TokenUploadSourceOptName:
+ sourceID = opt.Value.(string)
+ case messages.TokenUploadFrameOptName:
+ frameID = opt.Value.(string)
+ case messages.TokenUploadColorOptName:
+ colorHex = opt.Value.(string)
+ }
+ }
+
+ hasFrame := frameID != ""
+ hasColor := colorHex != ""
+ if hasFrame == hasColor {
+ respond(s, i, messages.TokenUploadNeedOneOf)
+ return
+ }
+
+ source := data.Resolved.Attachments[sourceID]
+ if source == nil {
+ respond(s, i, messages.GenericErrorMessage)
+ return
+ }
+ if !strings.HasPrefix(source.ContentType, "image/") {
+ respond(s, i, messages.TokenUploadNotImage)
+ return
+ }
+ if source.Size > maxCoverBytes {
+ respond(s, i, messages.TokenUploadTooLarge)
+ return
+ }
+
+ var frame *discordgo.MessageAttachment
+ if hasFrame {
+ frame = data.Resolved.Attachments[frameID]
+ if frame == nil {
+ respond(s, i, messages.GenericErrorMessage)
+ return
+ }
+ if !strings.HasPrefix(frame.ContentType, "image/") {
+ respond(s, i, messages.TokenUploadNotImage)
+ return
+ }
+ if frame.Size > maxCoverBytes {
+ respond(s, i, messages.TokenUploadTooLarge)
+ return
+ }
+ }
+
+ var frameColor color.RGBA
+ if hasColor {
+ parsed, err := parseHexColor(colorHex)
+ if err != nil {
+ respond(s, i, messages.TokenUploadNeedOneOf)
+ return
+ }
+ frameColor = parsed
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
+ Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral},
+ })
+
+ token := uuid.NewString()
+ sourceExt := extOrDefault(source.Filename, ".jpg")
+ sourceDsk, _ := mediaserver.TokenPath(c.dataDir, c.mediaBaseURL, i.GuildID, userID, "src_"+token, sourceExt)
+ outDisk, outURL := mediaserver.TokenPath(c.dataDir, c.mediaBaseURL, i.GuildID, userID, "out_"+token, ".png")
+
+ if _, err := mediaserver.Download(source.URL, sourceDsk); err != nil {
+ log.Printf("uploadtoken: download source failed for %s: %v", userID, err)
+ editDeferred(s, i, messages.TokenUploadProcessFailed)
+ return
+ }
+
+ if hasFrame {
+ frameExt := extOrDefault(frame.Filename, ".png")
+ frameDsk, _ := mediaserver.TokenPath(c.dataDir, c.mediaBaseURL, i.GuildID, userID, "frm_"+token, frameExt)
+ if _, err := mediaserver.Download(frame.URL, frameDsk); err != nil {
+ log.Printf("uploadtoken: download frame failed for %s: %v", userID, err)
+ cleanupFiles(sourceDsk)
+ editDeferred(s, i, messages.TokenUploadProcessFailed)
+ return
+ }
+ if err := mediaserver.ProcessToken(sourceDsk, frameDsk, outDisk); err != nil {
+ log.Printf("uploadtoken: processing failed for %s: %v", userID, err)
+ cleanupFiles(sourceDsk, frameDsk)
+ editDeferred(s, i, messages.TokenUploadProcessFailed)
+ return
+ }
+ cleanupFiles(frameDsk)
+ } else {
+ if err := mediaserver.ProcessBasicToken(sourceDsk, outDisk, frameColor); err != nil {
+ log.Printf("uploadtoken: basic processing failed for %s: %v", userID, err)
+ cleanupFiles(sourceDsk)
+ editDeferred(s, i, messages.TokenUploadProcessFailed)
+ return
+ }
+ }
+ cleanupFiles(sourceDsk)
+
+ // format: token_apply:{guildID}:{userID}:{token}
+ applyID := fmt.Sprintf("%s:%s:%s:%s", messages.TokenApplyPrefix, i.GuildID, userID, token)
+ discardID := fmt.Sprintf("%s:%s:%s:%s", messages.TokenDiscardPrefix, i.GuildID, userID, token)
+
+ embed := &discordgo.MessageEmbed{
+ Title: "Token Preview",
+ Color: messages.EmbedColor,
+ Image: &discordgo.MessageEmbedImage{URL: outURL},
+ }
+
+ s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
+ Content: strPtr(messages.TokenUploadPreviewContent),
+ Embeds: &[]*discordgo.MessageEmbed{embed},
+ Components: &[]discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{Label: messages.TokenApplyLabel, Style: discordgo.SuccessButton, CustomID: applyID},
+ discordgo.Button{Label: messages.TokenDiscardLabel, Style: discordgo.DangerButton, CustomID: discardID},
+ }},
+ },
+ })
+}
+
+func extOrDefault(filename, def string) string {
+ if ext := filepath.Ext(filename); ext != "" {
+ return ext
+ }
+ return def
+}
+
+func cleanupFiles(paths ...string) {
+ for _, p := range paths {
+ os.Remove(p)
+ }
+}
+
+func editDeferred(s *discordgo.Session, i *discordgo.InteractionCreate, content string) {
+ s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: strPtr(content)})
+}
+
+func strPtr(s string) *string { return &s }
+
+// parseHexColor accepts 6-char hex strings (with or without a leading #) and returns color.RGBA.
+func parseHexColor(s string) (color.RGBA, error) {
+ s = strings.TrimPrefix(s, "#")
+ b, err := hex.DecodeString(s)
+ if err != nil || len(b) != 3 {
+ return color.RGBA{}, fmt.Errorf("invalid hex color: %q", s)
+ }
+ return color.RGBA{R: b[0], G: b[1], B: b[2], A: 255}, nil
+}
diff --git a/internal/commands/upload_token_test.go b/internal/commands/upload_token_test.go
new file mode 100644
index 0000000..e6bbfd9
--- /dev/null
+++ b/internal/commands/upload_token_test.go
@@ -0,0 +1,87 @@
+package commands
+
+import (
+ "image/color"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+/*
+parseHexColor coverage.
+
+The function gates a color.RGBA value that is later passed straight into
+the gradient generator. It currently accepts:
+
+ - 6 hex chars, with or without leading "#"
+ - Any case
+
+It rejects everything else. These tests pin behavior so a future change
+(e.g. accepting #RGB shorthand or named colors) is intentional.
+*/
+func TestParseHexColor(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ want color.RGBA
+ wantErr bool
+ }{
+ {"with hash", "#ff0000", color.RGBA{R: 255, A: 255}, false},
+ {"without hash", "00ff00", color.RGBA{G: 255, A: 255}, false},
+ {"uppercase", "#0000FF", color.RGBA{B: 255, A: 255}, false},
+ {"empty", "", color.RGBA{}, true},
+ {"only hash", "#", color.RGBA{}, true},
+ {"too short", "#abc", color.RGBA{}, true},
+ {"too long", "#aabbccdd", color.RGBA{}, true},
+ {"non-hex chars", "#zz0000", color.RGBA{}, true},
+ {"whitespace", " ff0000 ", color.RGBA{}, true},
+ {"injection-ish", "#ff0000;rm -rf /", color.RGBA{}, true},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got, err := parseHexColor(c.in)
+ if c.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ assert.NoError(t, err)
+ assert.Equal(t, c.want, got)
+ })
+ }
+}
+
+/*
+extOrDefault must not let user-supplied filenames inject path separators
+or traversal into the on-disk filename.
+
+When:
+
+ A user uploads a file whose name contains slashes or "..".
+
+Expected:
+
+ The returned extension is bounded β at minimum, no separators. Currently
+ uses filepath.Ext which only returns text after the last dot, so most
+ traversal payloads collapse safely; but ".png/../etc" -> "" -> default,
+ and "foo.png\x00bad" returns ".png\x00bad" on Linux. Pin behavior.
+*/
+func TestExtOrDefault(t *testing.T) {
+ cases := []struct {
+ name, filename, def, want string
+ }{
+ {"plain", "cat.png", ".jpg", ".png"},
+ {"no ext uses default", "cat", ".jpg", ".jpg"},
+ {"trailing dot", "cat.", ".jpg", "."},
+ {"hidden file", ".gitignore", ".jpg", ".gitignore"},
+ {"traversal in name", "../etc/passwd", ".jpg", ".jpg"},
+ {"double ext keeps last", "cat.png.exe", ".jpg", ".exe"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got := extOrDefault(c.filename, c.def)
+ assert.Equal(t, c.want, got, "input %q", c.filename)
+ assert.NotContains(t, got, "/", "extension must not contain a path separator")
+ assert.NotContains(t, got, "\\", "extension must not contain a backslash")
+ })
+ }
+}
diff --git a/internal/commands/waos.go b/internal/commands/waos.go
new file mode 100644
index 0000000..fbc7e36
--- /dev/null
+++ b/internal/commands/waos.go
@@ -0,0 +1,55 @@
+package commands
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ "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
+}
+
+func (c *waosCommand) Data() *discordgo.ApplicationCommand {
+ return &discordgo.ApplicationCommand{
+ Name: "waos",
+ Description: "waos",
+ }
+}
+
+func (c *waosCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ ctx := context.Background()
+
+ var record models.CommandRecord
+
+ err := c.db.NewSelect().Model(&record).
+ Column("times_used").
+ Where("name = ?", "waos").
+ Scan(ctx)
+
+ if err != nil {
+ log.Printf("waos: smoke test failed: %v", err)
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf("waosn't. (current counter: %d)", record.TimesUsed),
+ },
+ })
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf("waos (x %d)", record.TimesUsed),
+ },
+ })
+}
diff --git a/internal/db/approval_gate_test.go b/internal/db/approval_gate_test.go
new file mode 100644
index 0000000..ae824db
--- /dev/null
+++ b/internal/db/approval_gate_test.go
@@ -0,0 +1,314 @@
+package db_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/db"
+ "github.com/framebuffers/moontracer/internal/manager/models"
+ "github.com/framebuffers/moontracer/internal/testutil"
+)
+
+/*
+Helper functions
+*/
+
+func seedPlayer(t *testing.T, database *bun.DB, id string, role models.ServerRole) {
+ t.Helper()
+ ctx := context.Background()
+ p := &models.Player{ID: id, Role: role}
+ _, err := database.NewInsert().Model(p).Exec(ctx)
+ require.NoError(t, err)
+}
+
+func seedCampaign(t *testing.T, database *bun.DB, id, name, tag, dmID string, approved bool) {
+ t.Helper()
+ ctx := context.Background()
+ c := &models.Campaign{
+ ID: id,
+ Name: name,
+ Tag: tag,
+ DungeonMaster: dmID,
+ IsApproved: approved,
+ }
+ _, err := database.NewInsert().Model(c).Exec(ctx)
+ require.NoError(t, err)
+}
+
+func seedCampaignPlayer(t *testing.T, database *bun.DB, playerID, campaignID string, role models.Role, status models.CampaignPlayerStatus) {
+ t.Helper()
+ ctx := context.Background()
+ cp := &models.CampaignPlayer{
+ PlayerID: playerID,
+ CampaignID: campaignID,
+ Role: role,
+ Status: status,
+ }
+ _, err := database.NewInsert().Model(cp).Exec(ctx)
+ require.NoError(t, err)
+}
+
+/*
+Unit Testing: staff queries and approval gate.
+*/
+
+/*
+GetStaff returns mods and admins.
+
+When:
+
+ Database has one player, one mod, one admin.
+
+Expected:
+
+ Only the mod and admin are returned. Regular players are excluded.
+*/
+func TestGetStaff_ReturnsModsAndAdmins(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ seedPlayer(t, database, "player1", models.ServerRolePlayer)
+ seedPlayer(t, database, "mod1", models.ServerRoleMod)
+ seedPlayer(t, database, "admin1", models.ServerRoleAdmin)
+
+ staff, err := db.GetStaff(database)
+ require.NoError(t, err)
+ assert.Len(t, staff, 2, "GetStaff should return mod + admin, not regular players")
+
+ ids := map[string]bool{}
+ for _, s := range staff {
+ ids[s.ID] = true
+ }
+ assert.True(t, ids["mod1"], "mod should be in staff")
+ assert.True(t, ids["admin1"], "admin should be in staff")
+ assert.False(t, ids["player1"], "regular player should not be in staff")
+}
+
+/*
+GetStaff returns empty when no staff exist.
+
+When:
+
+ Database has only regular players, no mods or admins.
+
+Expected:
+
+ Empty result.
+*/
+func TestGetStaff_EmptyWhenNoStaff(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ seedPlayer(t, database, "player1", models.ServerRolePlayer)
+ seedPlayer(t, database, "player2", models.ServerRolePlayer)
+
+ staff, err := db.GetStaff(database)
+ require.NoError(t, err)
+ assert.Empty(t, staff, "GetStaff should return empty when no mods or admins exist")
+}
+
+/*
+Approval gate: campaign lookup returns unapproved campaigns.
+
+ The gate is enforced in the handler (if !campaign.IsApproved), not at the DB level.
+ These tests verify the data layer returns the campaign regardless, so the handler can decide.
+*/
+
+/*
+GetByTag returns an unapproved campaign.
+
+When:
+
+ Campaign exists with IsApproved = false.
+
+Expected:
+
+ Campaign is returned. The data layer does not filter by approval status.
+*/
+func TestGetByTag_ReturnsUnapprovedCampaign(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ seedPlayer(t, database, "dm1", models.ServerRolePlayer)
+ seedCampaign(t, database, "camp1", "Pending Campaign", "pending", "dm1", false)
+
+ campaign, err := db.GetByTag[models.Campaign](database, "pending")
+ require.NoError(t, err)
+ assert.Equal(t, "camp1", campaign.ID)
+ assert.False(t, campaign.IsApproved, "campaign should still be unapproved")
+}
+
+/*
+GetByTag returns an approved campaign.
+
+When:
+
+ Campaign exists with IsApproved = true.
+
+Expected:
+
+ Campaign is returned with approval flag intact.
+*/
+func TestGetByTag_ReturnsApprovedCampaign(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ seedPlayer(t, database, "dm1", models.ServerRolePlayer)
+ seedCampaign(t, database, "camp1", "Approved Campaign", "approved", "dm1", true)
+
+ campaign, err := db.GetByTag[models.Campaign](database, "approved")
+ require.NoError(t, err)
+ assert.Equal(t, "camp1", campaign.ID)
+ assert.True(t, campaign.IsApproved, "campaign should be approved")
+}
+
+/*
+Approve campaign sets IsApproved to true.
+
+When:
+
+ Campaign starts unapproved, then IsApproved is set to true and updated.
+
+Expected:
+
+ Reloaded campaign has IsApproved = true.
+*/
+func TestApproveCampaign_SetsIsApprovedTrue(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ seedPlayer(t, database, "dm1", models.ServerRolePlayer)
+ seedCampaign(t, database, "camp1", "Test", "test", "dm1", false)
+
+ campaign, err := db.GetByID[models.Campaign](database, "camp1")
+ require.NoError(t, err)
+ assert.False(t, campaign.IsApproved)
+
+ campaign.IsApproved = true
+ err = db.Update(database, campaign)
+ require.NoError(t, err)
+
+ reloaded, err := db.GetByID[models.Campaign](database, "camp1")
+ require.NoError(t, err)
+ assert.True(t, reloaded.IsApproved, "campaign should be approved after update")
+}
+
+/*
+Deny campaign deletes from database.
+
+When:
+
+ Campaign exists, then is deleted (denial = deletion).
+
+Expected:
+
+ Lookup after deletion returns an error.
+*/
+func TestDenyCampaign_DeletesFromDB(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ seedPlayer(t, database, "dm1", models.ServerRolePlayer)
+ seedCampaign(t, database, "camp1", "Doomed", "doomed", "dm1", false)
+
+ _, err := db.GetByID[models.Campaign](database, "camp1")
+ require.NoError(t, err, "campaign should exist before denial")
+
+ err = db.Delete[models.Campaign](database, "camp1")
+ require.NoError(t, err)
+
+ _, err = db.GetByID[models.Campaign](database, "camp1")
+ assert.Error(t, err, "campaign should not exist after deletion")
+}
+
+/*
+Double approve is idempotent.
+
+When:
+
+ Campaign is approved twice in succession.
+
+Expected:
+
+ No error on second approve. Campaign remains approved.
+*/
+func TestDoubleApprove_IsIdempotent(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ seedPlayer(t, database, "dm1", models.ServerRolePlayer)
+ seedCampaign(t, database, "camp1", "Test", "test", "dm1", false)
+
+ campaign, err := db.GetByID[models.Campaign](database, "camp1")
+ require.NoError(t, err)
+
+ // First approve
+ campaign.IsApproved = true
+ err = db.Update(database, campaign)
+ require.NoError(t, err)
+
+ // Second approve β should not error
+ campaign.IsApproved = true
+ err = db.Update(database, campaign)
+ require.NoError(t, err)
+
+ reloaded, err := db.GetByID[models.Campaign](database, "camp1")
+ require.NoError(t, err)
+ assert.True(t, reloaded.IsApproved)
+}
+
+/*
+Deny already-deleted campaign returns not found.
+
+When:
+
+ Campaign is deleted, then looked up again.
+
+Expected:
+
+ Lookup returns an error. No phantom records.
+*/
+func TestDenyAlreadyDeleted_ReturnsNotFound(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ seedPlayer(t, database, "dm1", models.ServerRolePlayer)
+ seedCampaign(t, database, "camp1", "Gone", "gone", "dm1", false)
+
+ err := db.Delete[models.Campaign](database, "camp1")
+ require.NoError(t, err)
+
+ _, err = db.GetByID[models.Campaign](database, "camp1")
+ assert.Error(t, err, "looking up a deleted campaign should return an error")
+}
+
+/*
+GetPlayerCampaigns includes both approved and unapproved.
+
+When:
+
+ DM owns one approved and one unapproved campaign.
+
+Expected:
+
+ GetPlayerCampaigns returns both. Handler-level filtering narrows to approved only.
+*/
+func TestGetPlayerCampaigns_IncludesBothApprovedAndUnapproved(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ seedPlayer(t, database, "dm1", models.ServerRolePlayer)
+ seedCampaign(t, database, "approved-camp", "Approved", "approved", "dm1", true)
+ seedCampaign(t, database, "pending-camp", "Pending", "pending", "dm1", false)
+ seedCampaignPlayer(t, database, "dm1", "approved-camp", models.RoleDM, models.StatusActive)
+ seedCampaignPlayer(t, database, "dm1", "pending-camp", models.RoleDM, models.StatusActive)
+
+ entries, err := models.GetPlayerCampaigns(database, "dm1")
+ require.NoError(t, err)
+ assert.Len(t, entries, 2, "GetPlayerCampaigns returns all campaigns (handler filters)")
+
+ // Simulate the handler filter: only approved campaigns shown
+ var visible []models.CampaignPlayer
+ for _, e := range entries {
+ if e.Campaign != nil && e.Campaign.IsApproved {
+ visible = append(visible, e)
+ }
+ }
+ assert.Len(t, visible, 1, "only approved campaigns should be visible after filtering")
+ assert.Equal(t, "approved-camp", visible[0].CampaignID)
+}
diff --git a/internal/db/crud.go b/internal/db/crud.go
new file mode 100644
index 0000000..d427a34
--- /dev/null
+++ b/internal/db/crud.go
@@ -0,0 +1,100 @@
+package db
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/manager/models"
+)
+
+func GetByID[T any](db *bun.DB, id string) (*T, error) {
+ ctx := context.Background()
+ var model T
+ err := db.NewSelect().Model(&model).Where("id = ?", id).Scan(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return &model, nil
+}
+
+func GetByTag[T any](db *bun.DB, tag string) (*T, error) {
+ ctx := context.Background()
+ var model T
+ err := db.NewSelect().Model(&model).Where("tag = ?", tag).Scan(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return &model, nil
+}
+
+func GetAll[T any](db *bun.DB) ([]T, error) {
+ ctx := context.Background()
+ var models []T
+ err := db.NewSelect().Model(&models).Scan(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return models, nil
+}
+
+func GetStaff(db *bun.DB) ([]models.Player, error) {
+ ctx := context.Background()
+ var players []models.Player
+ err := db.NewSelect().
+ Model(&players).
+ Where("role IN (?)", bun.In([]models.ServerRole{models.ServerRoleMod, models.ServerRoleAdmin})).
+ Scan(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return players, nil
+}
+
+func Update[T any](db *bun.DB, model *T) error {
+ ctx := context.Background()
+
+ exists, err := db.NewSelect().Model(model).WherePK().Exists(ctx)
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return fmt.Errorf("record not found")
+ }
+
+ _, err = db.NewUpdate().Model(model).WherePK().Exec(ctx)
+ return err
+}
+
+func Delete[T any](db *bun.DB, id string) error {
+ ctx := context.Background()
+ _, err := db.NewDelete().Model((*T)(nil)).Where("id = ?", id).Exec(ctx)
+ return err
+}
+
+func Insert[T any](db *bun.DB, model *T) error {
+ ctx := context.Background()
+ _, err := db.NewInsert().Model(model).Exec(ctx)
+ return err
+}
+
+/*
+ScrubOrphanedCampaignPlayers removes CampaignPlayer rows whose campaign
+no longer exists. This cleans up records left by older code that deleted
+campaigns without cascade-deleting their players.
+*/
+func ScrubOrphanedCampaignPlayers(db *bun.DB) (int64, error) {
+ ctx := context.Background()
+ res, err := db.NewDelete().
+ Model((*models.CampaignPlayer)(nil)).
+ Where("campaign_id NOT IN (?)",
+ db.NewSelect().Model((*models.Campaign)(nil)).Column("id"),
+ ).
+ Exec(ctx)
+ if err != nil {
+ return 0, err
+ }
+ n, _ := res.RowsAffected()
+ return n, nil
+}
diff --git a/internal/db/database_manager.go b/internal/db/database_manager.go
new file mode 100644
index 0000000..d21edbb
--- /dev/null
+++ b/internal/db/database_manager.go
@@ -0,0 +1,39 @@
+package db
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/uptrace/bun"
+ "github.com/uptrace/bun/dialect/sqlitedialect"
+ "github.com/uptrace/bun/driver/sqliteshim"
+)
+
+type DatabaseManager struct {
+ Database *bun.DB
+}
+
+func (d *DatabaseManager) Get(connectionString string) (*bun.DB, error) {
+ /*
+ Note:
+ This assumes connectionString is a bare path (no existing query params).
+ If the DSN ever includes a "?" (e.g. "file:data/moontracer.db?cache=shared"),
+ this concatenation will break β use "&" instead of "?" in that case.
+
+ Right now, the bot passes a bare string, so this is not important *for now*.
+ */
+ sqldb, err := sql.Open(sqliteshim.ShimName, connectionString+"?_pragma=foreign_keys(1)")
+ if err != nil {
+ return nil, err
+ }
+
+ bunDB := bun.NewDB(sqldb, sqlitedialect.New())
+
+ err = bunDB.PingContext(context.Background())
+ if err != nil {
+ return nil, err
+ }
+
+ d.Database = bunDB
+ return bunDB, nil
+}
diff --git a/internal/db/guild_db_manager.go b/internal/db/guild_db_manager.go
new file mode 100644
index 0000000..8b3b9e0
--- /dev/null
+++ b/internal/db/guild_db_manager.go
@@ -0,0 +1,138 @@
+package db
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "log"
+ "path/filepath"
+ "runtime"
+ "sync"
+
+ "github.com/uptrace/bun"
+ "github.com/uptrace/bun/dialect/sqlitedialect"
+ "github.com/uptrace/bun/driver/sqliteshim"
+)
+
+/*
+GuildDBManager maintains a thread-safe registry of per-guild SQLite databases.
+
+Each guild gets its own database file (/.db), created and
+migrated on first access.
+*/
+type GuildDBManager struct {
+ mu sync.RWMutex
+ dbs map[string]*bun.DB
+ dataDir string
+}
+
+// NewGuildDBManager creates a manager that stores guild databases in dataDir.
+func NewGuildDBManager(dataDir string) *GuildDBManager {
+ return &GuildDBManager{
+ dbs: make(map[string]*bun.DB),
+ dataDir: dataDir,
+ }
+}
+
+/*
+GetOrCreate returns the *bun.DB for the given guild, creating and migrating the database file if it doesn't exist yet.
+
+Uses double-checked locking so the hot path (DB already cached) takes only a shared read lock.
+*/
+func (m *GuildDBManager) GetOrCreate(guildID string) (*bun.DB, error) {
+ /*
+ Hot path:
+ read lock only.
+ */
+ m.mu.RLock()
+ if db, ok := m.dbs[guildID]; ok {
+ m.mu.RUnlock()
+ return db, nil
+ }
+ m.mu.RUnlock()
+
+ /*
+ Cold path:
+ write lock, double-check.
+ */
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if db, ok := m.dbs[guildID]; ok {
+ return db, nil
+ }
+
+ db, err := m.openAndMigrate(guildID)
+ if err != nil {
+ return nil, err
+ }
+ m.dbs[guildID] = db
+ return db, nil
+}
+
+/*
+InitForGuilds pre-creates and migrates databases for all known guilds in parallel, bounded by NumCPU.
+
+Errors are logged but non-fatal. A guild will retry on its first interaction.
+*/
+func (m *GuildDBManager) InitForGuilds(guildIDs []string) {
+ var wg sync.WaitGroup
+ sem := make(chan struct{}, runtime.NumCPU())
+
+ for _, id := range guildIDs {
+ wg.Add(1)
+ sem <- struct{}{}
+ go func(guildID string) {
+ defer wg.Done()
+ defer func() { <-sem }()
+ if _, err := m.GetOrCreate(guildID); err != nil {
+ log.Printf("guild_db_manager: init warning for guild %s: %v", guildID, err)
+ }
+ }(id)
+ }
+
+ wg.Wait()
+}
+
+// Close closes all open guild database connections.
+func (m *GuildDBManager) Close() {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ for guildID, db := range m.dbs {
+ if err := db.Close(); err != nil {
+ log.Printf("guild_db_manager: failed to close DB for guild %s: %v", guildID, err)
+ }
+ }
+ m.dbs = make(map[string]*bun.DB)
+}
+
+func (m *GuildDBManager) openAndMigrate(guildID string) (*bun.DB, error) {
+ path := filepath.Join(m.dataDir, guildID+".db")
+ sqldb, err := sql.Open(sqliteshim.ShimName, path+"?_pragma=foreign_keys(1)")
+ if err != nil {
+ return nil, fmt.Errorf("open guild DB %s: %w", guildID, err)
+ }
+ // SQLite best practice: serialize writes
+ sqldb.SetMaxOpenConns(1)
+ if err := sqldb.PingContext(context.Background()); err != nil {
+ sqldb.Close()
+ return nil, fmt.Errorf("ping guild DB %s: %w", guildID, err)
+ }
+
+ bunDB := bun.NewDB(sqldb, sqlitedialect.New())
+
+ if err := Migrate(bunDB); err != nil {
+ bunDB.Close()
+ return nil, fmt.Errorf("migrate guild DB %s: %w", guildID, err)
+ }
+
+ if n, err := ScrubOrphanedCampaignPlayers(bunDB); err != nil {
+ log.Printf("guild_db_manager: scrub warning for guild %s: %v", guildID, err)
+ } else if n > 0 {
+ log.Printf("guild_db_manager: scrubbed %d orphaned campaign_player rows for guild %s", n, guildID)
+ }
+
+ log.Printf("guild_db_manager: initialized DB for guild %s at %s", guildID, path)
+ return bunDB, nil
+}
diff --git a/internal/db/migrate.go b/internal/db/migrate.go
new file mode 100644
index 0000000..0cd3408
--- /dev/null
+++ b/internal/db/migrate.go
@@ -0,0 +1,167 @@
+package db
+
+import (
+ "context"
+ "log"
+ "strings"
+
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/manager/models"
+)
+
+// Migrate creates all tables if they don't already exist.
+func Migrate(db *bun.DB) error {
+ ctx := context.Background()
+
+ tables := []interface{}{
+ (*models.CommandRecord)(nil),
+ (*models.Player)(nil),
+ (*models.Media)(nil),
+ (*models.Campaign)(nil),
+ (*models.CampaignPlayer)(nil),
+ (*models.AuditEntry)(nil),
+ (*models.PlayerSettings)(nil),
+ (*models.Session)(nil),
+ (*models.SessionRSVP)(nil),
+ }
+
+ for _, model := range tables {
+ _, err := db.NewCreateTable().Model(model).IfNotExists().Exec(ctx)
+ if err != nil {
+ return err
+ }
+ }
+
+ /*
+ Migration process (three passes, all idempotent):
+
+ 1. CreateTable loop above β `IfNotExists`, so fresh DBs get every column
+ from the model struct tags. Existing DBs skip this entirely.
+ 2. `alterStmts` below β `ADD COLUMN` retrofits for columns that were added
+ to a model after deploy. Each statement is wrapped by `isDuplicateColumn`:
+ SQLite errors "duplicate column name ..." on re-run, which we swallow.
+ Any other error aborts migration. Columns MUST be appended (never
+ reordered or removed) so old deploys advancing N versions at once
+ replay history in order.
+ 3. Post-migration cleanup β dedup passes, unique indexes, backfills.
+ Each runs on every boot; must be idempotent.
+
+ Adding a new model:
+ - Define it under `internal/manager/models/` with bun tags.
+ - Append `(*models.Foo)(nil)` to `tables` (pass 1 handles fresh DBs).
+ - If the model will ever gain columns after first deploy, every such
+ addition lands as a new `ALTER TABLE foos ADD COLUMN ...` line at
+ the end of `alterStmts` (pass 2 retrofits existing DBs).
+ - If columns need a unique index, backfill, or dedup, add to pass 3.
+
+ Why this shape: bun has no migration framework wired up here, so the
+ three passes stand in. Ordering matters within `alterStmts` only when a
+ later statement depends on an earlier column existing β otherwise append
+ is safe.
+ */
+
+ alterStmts := []string{
+ "ALTER TABLE campaigns ADD COLUMN name TEXT NOT NULL DEFAULT ''",
+ "ALTER TABLE campaigns ADD COLUMN tag TEXT NOT NULL DEFAULT ''",
+ "ALTER TABLE campaigns ADD COLUMN is_approved INTEGER NOT NULL DEFAULT 0",
+ "UPDATE campaigns SET is_approved = 1 WHERE is_approved = 0",
+ "ALTER TABLE players ADD COLUMN role TEXT NOT NULL DEFAULT 'player'",
+ "ALTER TABLE players ADD COLUMN is_banned INTEGER NOT NULL DEFAULT 0",
+ "ALTER TABLE players ADD COLUMN ban_reason TEXT",
+ "ALTER TABLE campaigns ADD COLUMN can_overflow INTEGER NOT NULL DEFAULT 0",
+ "ALTER TABLE campaigns ADD COLUMN role_id TEXT DEFAULT ''",
+ "ALTER TABLE commands ADD COLUMN times_used INTEGER NOT NULL DEFAULT 0",
+ "ALTER TABLE campaigns ADD COLUMN day_of_week INTEGER NOT NULL DEFAULT -1",
+ "ALTER TABLE campaigns ADD COLUMN start_time TEXT NOT NULL DEFAULT ''",
+ "ALTER TABLE campaigns ADD COLUMN duration_hours REAL NOT NULL DEFAULT 3",
+ "ALTER TABLE players ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC'",
+ "ALTER TABLE campaign_players ADD COLUMN ban_reason TEXT",
+ "ALTER TABLE campaign_players ADD COLUMN banned_from_campaign INTEGER NOT NULL DEFAULT 0",
+ "ALTER TABLE campaign_players ADD COLUMN ban_reason_from_campaign TEXT",
+ "ALTER TABLE campaign_players ADD COLUMN sessions_played INTEGER NOT NULL DEFAULT 0",
+ "ALTER TABLE campaign_players ADD COLUMN dice_throw_picture TEXT",
+ "ALTER TABLE campaigns ADD COLUMN is_archived INTEGER NOT NULL DEFAULT 0",
+ "ALTER TABLE campaigns ADD COLUMN archived_at TIMESTAMP",
+ "ALTER TABLE campaigns ADD COLUMN archived_reason TEXT",
+ "ALTER TABLE campaigns ADD COLUMN channel_id TEXT DEFAULT ''",
+ "ALTER TABLE campaigns ADD COLUMN category_id TEXT DEFAULT ''",
+ "ALTER TABLE campaigns ADD COLUMN announcements_thread_id TEXT DEFAULT ''",
+ "ALTER TABLE campaigns ADD COLUMN cover_channel_id TEXT DEFAULT ''",
+ "ALTER TABLE campaigns ADD COLUMN cover_message_id TEXT DEFAULT ''",
+ "ALTER TABLE campaigns ADD COLUMN cover_attachment_id TEXT DEFAULT ''",
+ "ALTER TABLE campaigns ADD COLUMN cover_cached_url TEXT DEFAULT ''",
+ "ALTER TABLE campaigns ADD COLUMN cover_cached_refreshed TIMESTAMP",
+ "ALTER TABLE campaigns ADD COLUMN session_capacity INTEGER NOT NULL DEFAULT 6",
+ "UPDATE campaigns SET slots = 2147483647 WHERE is_westmarch = 1 AND slots = -1",
+ "ALTER TABLE player_settings ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC'",
+ "ALTER TABLE campaign_players ADD COLUMN rsvp_status TEXT NOT NULL DEFAULT ''",
+ "ALTER TABLE campaign_players ADD COLUMN media_id TEXT",
+ "ALTER TABLE media ADD COLUMN url TEXT NOT NULL DEFAULT ''",
+ "ALTER TABLE campaigns ADD COLUMN player_sheet_url TEXT NOT NULL DEFAULT ''",
+ "ALTER TABLE campaign_players ADD COLUMN sheet_url TEXT",
+ }
+ for _, stmt := range alterStmts {
+ if _, err := db.ExecContext(ctx, stmt); err != nil {
+ if !isDuplicateColumn(err) {
+ return err
+ }
+ }
+ }
+
+ _, err := db.ExecContext(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS campaigns_tag_unique ON campaigns (tag)")
+ if err != nil {
+ return err
+ }
+
+ /*
+ Refactoring note: deduping commands table.
+ Earlier builds (<0.6.2) used ON CONFLICT DO NOTHING with no unique index on (name),
+ so every startup inserted fresh rows.
+ This coalesces each name's times_used into the min-id row, drops the rest, then adds the
+ unique index, so future inserts actually short-circuit.
+ */
+ dedupStmts := []string{
+ `UPDATE commands SET times_used = (
+ SELECT COALESCE(SUM(c2.times_used), 0) FROM commands c2 WHERE c2.name = commands.name
+ ) WHERE id IN (SELECT MIN(id) FROM commands GROUP BY name)`,
+ `DELETE FROM commands WHERE id NOT IN (SELECT MIN(id) FROM commands GROUP BY name)`,
+ `CREATE UNIQUE INDEX IF NOT EXISTS commands_name_unique ON commands (name)`,
+ }
+ for _, stmt := range dedupStmts {
+ if _, err := db.ExecContext(ctx, stmt); err != nil {
+ return err
+ }
+ }
+
+ // 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
+}
+
+func isDuplicateColumn(err error) bool {
+ return err != nil && strings.Contains(err.Error(), "duplicate column name")
+}
diff --git a/internal/db/orphan_scrub_test.go b/internal/db/orphan_scrub_test.go
new file mode 100644
index 0000000..7b382f8
--- /dev/null
+++ b/internal/db/orphan_scrub_test.go
@@ -0,0 +1,182 @@
+package db
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/uptrace/bun"
+ "github.com/uptrace/bun/dialect/sqlitedialect"
+ "github.com/uptrace/bun/driver/sqliteshim"
+
+ "github.com/framebuffers/moontracer/internal/manager/models"
+)
+
+/*
+newTestDB sets up an in-memory bun DB with the full schema migrated.
+
+Development Note:
+
+ Rather than reusing testutil.NewTestDB, this method is inlined here
+ because testutil imports this package, which would create a test-import cycle.
+*/
+func newTestDB(t *testing.T) *bun.DB {
+ t.Helper()
+
+ sqldb, err := sql.Open(sqliteshim.ShimName, ":memory:?_pragma=foreign_keys(1)")
+ if err != nil {
+ t.Fatalf("open in-memory db: %v", err)
+ }
+
+ bunDB := bun.NewDB(sqldb, sqlitedialect.New())
+ if err := Migrate(bunDB); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+
+ t.Cleanup(func() { bunDB.Close() })
+ return bunDB
+}
+
+/*
+Unit Testing
+ScrubOrphanedCampaignPlayers β guild-DB sanity sweep
+
+Covers the cleanup helper that runs on every guild-DB open.
+The function is idempotent and safe to call when no orphans exist.
+
+Development Note:
+
+ Older code paths (pre commit c32d817) deleted campaigns without cascading their
+ CampaignPlayer rows, leaving orphan memberships that auth.go then walked into.
+
+ The scrub deletes any CampaignPlayer whose campaign_id has no matching Campaign row.
+
+*/
+
+const (
+ scrubLiveCampID = "live-1"
+ scrubOrphanCampID = "orphan-1"
+ scrubOrphanCampID2 = "orphan-2"
+ scrubMember1ID = "member-1"
+ scrubMember2ID = "member-2"
+ scrubGhost1ID = "ghost-1"
+ scrubGhost2ID = "ghost-2"
+ scrubGhost3ID = "ghost-3"
+)
+
+/*
+Removes orphans, keeps live memberships.
+
+When:
+
+ A guild DB has 2 CampaignPlayer rows tied to a real Campaign and 3 rows
+ pointing at nonexistent campaign IDs.
+
+Expected:
+
+ The 3 orphans are deleted (RowsAffected reports 3); the 2 live
+ memberships survive untouched.
+*/
+func TestScrubOrphans_RemovesOnlyDanglingRows(t *testing.T) {
+ database := newTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(&models.Campaign{
+ ID: scrubLiveCampID,
+ Name: "Live",
+ Tag: "live",
+ DungeonMaster: scrubMember1ID,
+ IsApproved: true,
+ }).Exec(ctx)
+ require.NoError(t, err)
+
+ live := []models.CampaignPlayer{
+ {PlayerID: scrubMember1ID, CampaignID: scrubLiveCampID, Role: models.RoleDM, Status: models.StatusActive},
+ {PlayerID: scrubMember2ID, CampaignID: scrubLiveCampID, Role: models.RolePlayer, Status: models.StatusActive},
+ }
+ for i := range live {
+ _, err := database.NewInsert().Model(&live[i]).Exec(ctx)
+ require.NoError(t, err)
+ }
+
+ orphans := []models.CampaignPlayer{
+ {PlayerID: scrubGhost1ID, CampaignID: scrubOrphanCampID, Role: models.RolePlayer, Status: models.StatusActive},
+ {PlayerID: scrubGhost2ID, CampaignID: scrubOrphanCampID, Role: models.RolePlayer, Status: models.StatusFinished},
+ {PlayerID: scrubGhost3ID, CampaignID: scrubOrphanCampID2, Role: models.RoleDM, Status: models.StatusActive},
+ }
+ for i := range orphans {
+ _, err := database.NewInsert().Model(&orphans[i]).Exec(ctx)
+ require.NoError(t, err)
+ }
+
+ n, err := ScrubOrphanedCampaignPlayers(database)
+ require.NoError(t, err)
+ assert.Equal(t, int64(3), n, "all 3 orphan rows should be deleted")
+
+ survivors, err := models.GetCampaignPlayers(database, scrubLiveCampID)
+ require.NoError(t, err)
+ assert.Len(t, survivors, 2, "live memberships must survive the scrub")
+
+ var remaining []models.CampaignPlayer
+ require.NoError(t, database.NewSelect().Model(&remaining).
+ Where("campaign_id IN (?, ?)", scrubOrphanCampID, scrubOrphanCampID2).
+ Scan(ctx))
+ assert.Empty(t, remaining, "no orphans should be left behind")
+}
+
+/*
+No-op when nothing is orphaned.
+
+When:
+
+ Every CampaignPlayer row points at a real Campaign.
+
+Expected:
+
+ RowsAffected reports 0; no error. Confirms the scrub is safe to invoke
+ on every guild-DB open without touching healthy state.
+*/
+func TestScrubOrphans_NoOpWhenClean(t *testing.T) {
+ database := newTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(&models.Campaign{
+ ID: scrubLiveCampID,
+ Name: "Live",
+ Tag: "live",
+ DungeonMaster: scrubMember1ID,
+ IsApproved: true,
+ }).Exec(ctx)
+ require.NoError(t, err)
+
+ _, err = database.NewInsert().Model(&models.CampaignPlayer{
+ PlayerID: scrubMember1ID, CampaignID: scrubLiveCampID,
+ Role: models.RoleDM, Status: models.StatusActive,
+ }).Exec(ctx)
+ require.NoError(t, err)
+
+ n, err := ScrubOrphanedCampaignPlayers(database)
+ require.NoError(t, err)
+ assert.Equal(t, int64(0), n, "clean DB should report zero scrubbed rows")
+}
+
+/*
+Empty DB is safe.
+
+When:
+
+ The DB has no campaigns and no campaign_player rows.
+
+Expected:
+
+ RowsAffected = 0, no error. Catches the freshly-migrated guild path.
+*/
+func TestScrubOrphans_EmptyDB(t *testing.T) {
+ database := newTestDB(t)
+
+ n, err := ScrubOrphanedCampaignPlayers(database)
+ require.NoError(t, err)
+ assert.Equal(t, int64(0), n)
+}
diff --git a/internal/discord/bot.go b/internal/discord/bot.go
new file mode 100644
index 0000000..f250a8f
--- /dev/null
+++ b/internal/discord/bot.go
@@ -0,0 +1,269 @@
+package discord
+
+import (
+ "log"
+ "os"
+ "os/signal"
+ "sync"
+ "syscall"
+
+ "github.com/bwmarrin/discordgo"
+
+ "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"
+)
+
+/*
+Bot wraps a discordgo session and manages its lifecycle.
+
+Note: guildID is optional: if it is set, commands register to that guild only (dev mode)
+*/
+type Bot struct {
+ session *discordgo.Session
+ guildID string
+ guildDBM *db.GuildDBManager
+ adminRole string
+ modRole string
+ registered []*discordgo.ApplicationCommand
+ dispatcher *dispatch.Dispatcher
+ scheduler *scheduler.Scheduler
+ dataDir string
+ mediaBaseURL string
+}
+
+// New creates a Bot with the given token, guild ID, role names, and guild DB manager.
+func New(token, guildID, adminRole, modRole, dataDir, mediaBaseURL string, guildDBM *db.GuildDBManager) (*Bot, error) {
+ s, err := discordgo.New("Bot " + token)
+ if err != nil {
+ return nil, err
+ }
+ return &Bot{
+ session: s,
+ guildID: guildID,
+ guildDBM: guildDBM,
+ adminRole: adminRole,
+ modRole: modRole,
+ dataDir: dataDir,
+ mediaBaseURL: mediaBaseURL,
+ }, nil
+}
+
+/*
+ Flow (Run):
+ 1. Add the main event handler to the session (per-guild DB resolution + command dispatch).
+ 2. Open the Discord gateway connection.
+ 3. Discover all guilds the bot is in, initialize per-guild databases in parallel.
+ 4. Sync server roles for each guild in parallel.
+ 5. Register all commands globally with Discord.
+ 6. Block waiting for SIGINT/SIGTERM (Ctrl+C).
+ 7. On shutdown signal, in dev mode (guildID set) remove the guild-scoped
+ commands from Discord; in production (global registration) leave them
+ in place so restarts don't wipe commands from every guild.
+ 8. Close the gateway connection, close all guild databases, and return.
+*/
+
+/*
+Run opens the gateway, registers global commands, blocks until
+SIGINT/SIGTERM, then removes commands and closes the session.
+*/
+func (b *Bot) Run() error {
+ // workers
+ b.dispatcher = dispatch.NewDispatcher(b.session, 5)
+ b.scheduler = scheduler.New(b.guildDBM, b.dispatcher)
+
+ b.session.Identify.Intents = discordgo.IntentsGuilds |
+ discordgo.IntentsGuildMembers |
+ discordgo.IntentsGuildMessages |
+ discordgo.IntentsDirectMessages
+
+ b.session.AddHandler(NewHandler(b.guildDBM, b.dispatcher, b.adminRole, b.scheduler, b.dataDir, b.mediaBaseURL))
+
+ b.session.AddHandler(func(s *discordgo.Session, e *discordgo.GuildMemberUpdate) {
+ guildDB, err := b.guildDBM.GetOrCreate(e.GuildID)
+ if err != nil {
+ log.Printf("bot: warning: could not get DB for guild %s on member update: %v", e.GuildID, err)
+ return
+ }
+ if err := auth.SyncServerRoles(guildDB, s, e.GuildID, b.adminRole, b.modRole); err != nil {
+ log.Printf("bot: warning: role sync on member update failed for guild %s: %v", e.GuildID, err)
+ }
+ })
+
+ b.session.AddHandler(HandleGuildMemberRemove(b.guildDBM, b.scheduler))
+
+ // Initialize new guilds joined mid-runtime.
+ b.session.AddHandler(func(s *discordgo.Session, e *discordgo.GuildCreate) {
+ if guard.DebugGuildID != "" && e.Guild.ID != guard.DebugGuildID {
+ log.Printf("bot: skipping guild %s (%s) β scoped to %s", e.Guild.Name, e.Guild.ID, guard.DebugGuildID)
+ return
+ }
+ guildDB, err := b.guildDBM.GetOrCreate(e.Guild.ID)
+ if err != nil {
+ log.Printf("bot: failed to create DB for guild %s (%s): %v", e.Guild.Name, e.Guild.ID, err)
+ return
+ }
+ if err := commands.RegisterCommands(guildDB, nil); err != nil {
+ log.Printf("bot: failed to register commands in DB for guild %s: %v", e.Guild.ID, err)
+ }
+ if err := auth.SyncServerRoles(guildDB, s, e.Guild.ID, b.adminRole, b.modRole); err != nil {
+ log.Printf("bot: failed to sync roles for guild %s: %v", e.Guild.ID, err)
+ }
+ log.Printf("bot: initialized guild %s (%s)", e.Guild.Name, e.Guild.ID)
+ })
+
+ if err := b.session.Open(); err != nil {
+ return err
+ }
+ defer b.session.Close()
+
+ b.dispatcher.Start()
+
+ appID := b.session.State.User.ID
+ log.Printf("bot: logged in as %s (app %s)", b.session.State.User.Username, appID)
+
+ /*
+ Discover all guilds and initialize their databases in parallel.
+
+ NOTE:
+ In dev mode scoped to a single guild, ignore all other guilds.
+ */
+ var guildIDs []string
+ for _, g := range b.session.State.Guilds {
+ if guard.DebugGuildID != "" && g.ID != guard.DebugGuildID {
+ log.Printf("bot: skipping guild %s (%s) β scoped to %s", g.Name, g.ID, guard.DebugGuildID)
+ continue
+ }
+ guildIDs = append(guildIDs, g.ID)
+ }
+ log.Printf("bot: discovered %d guild(s), initializing databases...", len(guildIDs))
+ b.guildDBM.InitForGuilds(guildIDs)
+ b.scheduler.BootScan(guildIDs)
+
+ // Register command metadata in each guild's DB and sync roles in parallel.
+ var wg sync.WaitGroup
+ for _, g := range b.session.State.Guilds {
+ if guard.DebugGuildID != "" && g.ID != guard.DebugGuildID {
+ continue
+ }
+ wg.Add(1)
+ go func(guild *discordgo.Guild) {
+ defer wg.Done()
+ guildDB, err := b.guildDBM.GetOrCreate(guild.ID)
+ if err != nil {
+ log.Printf("bot: skipping guild %s: %v", guild.ID, err)
+ return
+ }
+ if err := commands.RegisterCommands(guildDB, nil); err != nil {
+ log.Printf("bot: failed to register commands in DB for guild %s: %v", guild.ID, err)
+ }
+ if err := auth.SyncServerRoles(guildDB, b.session, guild.ID, b.adminRole, b.modRole); err != nil {
+ log.Printf("bot: role sync failed for guild %s: %v", guild.ID, err)
+ }
+ }(g)
+ }
+ wg.Wait()
+ log.Println("bot: all guild databases initialized and roles synced")
+
+ if err := b.registerCommands(appID); err != nil {
+ return err
+ }
+
+ log.Println("bot is running β press Ctrl+C to exit")
+
+ stop := make(chan os.Signal, 1)
+ signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
+ <-stop
+
+ log.Println("shutting down...")
+ b.scheduler.Stop()
+ b.dispatcher.Stop()
+ if b.guildID != "" {
+ b.removeCommands(appID)
+ } else {
+ log.Println("bot: leaving global commands registered across restart")
+ }
+ b.guildDBM.Close()
+
+ return nil
+}
+
+func (b *Bot) registerCommands(appID string) error {
+ /*
+ Flow:
+ Use the first available guild's DB to build command metadata.
+ commands.All needs a *bun.DB for struct init, but registerCommands only calls cmd.Data() for the Discord API registration.
+ */
+ var guildIDs []string
+ for _, g := range b.session.State.Guilds {
+ guildIDs = append(guildIDs, g.ID)
+ }
+ if len(guildIDs) == 0 {
+ log.Println("bot: no guilds found, skipping command registration")
+ return nil
+ }
+
+ bunDB, err := b.guildDBM.GetOrCreate(guildIDs[0])
+ if err != nil {
+ return err
+ }
+
+ /*
+ 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, "")
+ if err != nil {
+ log.Printf("bot: warning: could not list global commands for cleanup: %v", err)
+ } else {
+ for _, g := range existing {
+ if err := b.session.ApplicationCommandDelete(appID, "", g.ID); err != nil {
+ log.Printf("bot: warning: failed to delete stale global /%s: %v", g.Name, err)
+ } else {
+ log.Printf("bot: deleted stale global /%s", g.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
+}
+
+func (b *Bot) removeCommands(appID string) {
+ for _, cmd := range b.registered {
+ if err := b.session.ApplicationCommandDelete(appID, b.guildID, cmd.ID); err != nil {
+ log.Printf("bot: failed to remove /%s: %v", cmd.Name, err)
+ } else {
+ log.Printf("bot: removed /%s", cmd.Name)
+ }
+ }
+}
diff --git a/internal/discord/events.go b/internal/discord/events.go
new file mode 100644
index 0000000..0ec5530
--- /dev/null
+++ b/internal/discord/events.go
@@ -0,0 +1,79 @@
+package discord
+
+import (
+ "log"
+
+ "github.com/bwmarrin/discordgo"
+
+ "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"
+)
+
+/*
+ Flow:
+ 1. A guild member leaves the server, triggering `GuildMemberRemove`.
+ 2. Resolve the guild's database from the event's guild ID.
+ 3. Check if the departing user is a registered player. If not, do nothing.
+ 4. Load all CampaignPlayer entries for this player.
+ 5. For each entry where the player is the DM and the campaign is still mutable:
+ a. Archive the campaign (set IsArchived, ArchivedReason).
+ b. Insert an audit entry recording the auto-archive.
+ 6. Log each archived campaign.
+*/
+
+/*
+ HandleGuildMemberRemove returns a handler that archives any campaigns owned by a departing DM.
+
+This enforces DM sovereignty: if the DM leaves, the campaign becomes an immutable record rather than being handed off.
+*/
+func HandleGuildMemberRemove(guildDBM *db.GuildDBManager, sched *scheduler.Scheduler) func(s *discordgo.Session, e *discordgo.GuildMemberRemove) {
+ return func(s *discordgo.Session, e *discordgo.GuildMemberRemove) {
+ database, err := guildDBM.GetOrCreate(e.GuildID)
+ if err != nil {
+ log.Printf("events: failed to get DB for guild %s: %v", e.GuildID, err)
+ return
+ }
+
+ userID := e.User.ID
+
+ // Check if this player is registered.
+ _, err = db.GetByID[models.Player](database, userID)
+ if err != nil {
+ return // not registered, nothing to do
+ }
+
+ // Load all campaigns this player participates in.
+ campaignPlayers, err := models.GetPlayerCampaigns(database, userID)
+ if err != nil {
+ log.Printf("events: failed to load campaigns for departing member %s: %v", userID, err)
+ return
+ }
+
+ for _, cp := range campaignPlayers {
+ if cp.Role != models.RoleDM {
+ continue
+ }
+ if cp.Campaign == nil {
+ continue
+ }
+ if !cp.Campaign.CanMutate() {
+ continue // already archived
+ }
+
+ if err := commands.ArchiveCampaign(database, cp.Campaign, messages.AbandonReasonLeftServer); err != nil {
+ log.Printf("events: failed to archive campaign %s for departing DM %s: %v", cp.CampaignID, userID, err)
+ continue
+ }
+ sched.Cancel(e.GuildID, cp.CampaignID)
+
+ if err := models.InsertAuditEntry(database, userID, "system", models.AuditCampaignArchive, "DM left server, campaign auto-archived"); err != nil {
+ log.Printf("events: failed to write audit entry for campaign %s: %v", cp.CampaignID, err)
+ }
+
+ log.Printf("events: auto-archived campaign %s (%s) β DM %s left server", cp.Campaign.Name, cp.CampaignID, userID)
+ }
+ }
+}
diff --git a/internal/discord/handler.go b/internal/discord/handler.go
new file mode 100644
index 0000000..be17f78
--- /dev/null
+++ b/internal/discord/handler.go
@@ -0,0 +1,202 @@
+package discord
+
+import (
+ "context"
+ "log"
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+ "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"
+ "github.com/framebuffers/moontracer/internal/scheduler"
+)
+
+/*
+ Flow:
+ 1. NewHandler receives the GuildDBManager, dispatcher, and admin role name.
+ 2. Returns a closure that acts as the main Discord event handler.
+ 3. For each interaction:
+ a. Resolve the guild ID: from the interaction itself, or from the
+ CustomID for DM interactions (approval buttons encode guild ID).
+ b. Look up or create the guild's database via GuildDBManager.
+ c. Build handler sets for that guild's DB.
+ d. Dispatch to the appropriate handler.
+*/
+
+/*
+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,
+ dispatcher *dispatch.Dispatcher,
+ adminRole string,
+ sched *scheduler.Scheduler,
+ dataDir, mediaBaseURL string,
+) 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
+
+ /*
+ Note:
+ DM interactions have no guild ID.
+
+ For approval buttons/modals sent via DM, the guild ID is encoded in
+ the CustomID as the second segment (prefix:guildID:campaignID).
+ */
+ if guildID == "" {
+ guildID = extractGuildFromCustomID(i)
+ }
+ if guildID == "" {
+ respondEphemeral(s, i, "This command must be used in a server.")
+ return
+ }
+
+ if guard.DebugGuildID != "" && guildID != guard.DebugGuildID {
+ log.Printf("handler: rejecting interaction from guild %s (scoped to %s)", guildID, guard.DebugGuildID)
+ respondEphemeral(s, i, "This bot is scoped to a single server and will not respond here.")
+ return
+ }
+
+ guildDB, err := guildDBM.GetOrCreate(guildID)
+ if err != nil {
+ log.Printf("handler: failed to get DB for guild %s: %v", guildID, err)
+ respondEphemeral(s, i, "Internal error β please try again later.")
+ return
+ }
+
+ cmds := commands.All(guildDB, dispatcher, dataDir, mediaBaseURL)
+ components := interactions.AllComponents(guildDB, dispatcher, sched, dataDir, mediaBaseURL)
+ modals := interactions.AllModals(guildDB, dispatcher, sched, dataDir, mediaBaseURL)
+
+ switch i.Type {
+ case discordgo.InteractionApplicationCommand:
+ name := i.ApplicationCommandData().Name
+ for _, cmd := range cmds {
+ if cmd.Data().Name == name {
+ userID := "unknown"
+ if i.Member != nil {
+ userID = i.Member.User.ID
+ } else if i.User != nil {
+ userID = i.User.ID
+ }
+ log.Printf("handler: /%s invoked by %s in guild %s", name, userID, guildID)
+ incrementCommandCounter(guildDB, name)
+ cmd.Execute(s, i)
+ return
+ }
+ }
+ log.Printf("handler: unknown command: /%s", name)
+
+ case discordgo.InteractionMessageComponent:
+ customID := i.MessageComponentData().CustomID
+ prefix := customID
+ if idx := strings.Index(customID, ":"); idx != -1 {
+ prefix = customID[:idx]
+ }
+ for _, c := range components {
+ if c.CustomIDPrefix() == prefix {
+ log.Printf("handler: component %s triggered in guild %s", customID, guildID)
+ c.HandleComponents(s, i)
+ return
+ }
+ }
+ log.Printf("handler: unknown component: %s", customID)
+
+ case discordgo.InteractionApplicationCommandAutocomplete:
+ name := i.ApplicationCommandData().Name
+ for _, cmd := range cmds {
+ if cmd.Data().Name == name {
+ if ac, ok := cmd.(commands.AutocompleteCommand); ok {
+ ac.Autocomplete(s, i)
+ return
+ }
+ break
+ }
+ }
+
+ case discordgo.InteractionModalSubmit:
+ customID := i.ModalSubmitData().CustomID
+ prefix := customID
+ if idx := strings.Index(customID, ":"); idx != -1 {
+ prefix = customID[:idx]
+ }
+ for _, m := range modals {
+ if m.CustomIDPrefix() == prefix {
+ log.Printf("handler: modal %s submitted in guild %s", customID, guildID)
+ m.HandleModal(s, i)
+ return
+ }
+ }
+ log.Printf("handler: unknown modal: %s", customID)
+ }
+ }
+}
+
+/*
+extractGuildFromCustomID attempts to extract a guild ID from a DM
+interaction's CustomID.
+
+Approval buttons use the format prefix::.
+*/
+func extractGuildFromCustomID(i *discordgo.InteractionCreate) string {
+ var customID string
+ switch i.Type {
+ case discordgo.InteractionMessageComponent:
+ customID = i.MessageComponentData().CustomID
+ case discordgo.InteractionModalSubmit:
+ customID = i.ModalSubmitData().CustomID
+ default:
+ return ""
+ }
+
+ parts := strings.SplitN(customID, ":", 3)
+ if len(parts) >= 3 {
+ return parts[1]
+ }
+ return ""
+}
+
+/*
+incrementCommandCounter bumps times_used for the given command name.
+
+Runs synchronously so command handlers observe the new value if they read it.
+Logs and swallows errors, since a counter bump must not block command execution.
+*/
+func incrementCommandCounter(guildDB *bun.DB, name string) {
+ _, err := guildDB.NewUpdate().
+ Table("commands").
+ Set("times_used = times_used + 1").
+ Where("name = ?", name).
+ Exec(context.Background())
+ if err != nil {
+ log.Printf("handler: failed to increment counter for /%s: %v", name, err)
+ }
+}
+
+func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, content string) {
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: &discordgo.InteractionResponseData{
+ Content: content,
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
diff --git a/internal/dispatch/dispatcher.go b/internal/dispatch/dispatcher.go
new file mode 100644
index 0000000..b78ffb5
--- /dev/null
+++ b/internal/dispatch/dispatcher.go
@@ -0,0 +1,179 @@
+package dispatch
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "sync"
+
+ "github.com/bwmarrin/discordgo"
+
+ "github.com/framebuffers/moontracer/internal/guard"
+)
+
+type DirectMessage struct {
+ ID string
+ Sender string
+ Target string
+ Content string
+ Components []discordgo.MessageComponent // optional
+ Embeds []*discordgo.MessageEmbed // optional
+}
+
+/*
+ Flow:
+ 1. NewDispatcher(session, numberOfWorkers): creates the dispatcher with a discordgo session, with an empty stack, mutex+cond pair, and a quit channel.
+ 2. d.Start(): creates a series of dispatchers (goroutines) equal to the number of workers set.
+ a) Each one of them calls d.work(id) -> calls d.pop().
+ b) The stack is empty, so each worker hit d.cond.Wait() and sleep on the mutex.
+ 3. d.Push(msg): locks the mutex, pushes the message to the stack, unlocks mutex, cals d.cond.Signal().
+ a) signal() wakes only one sleeping worker.
+ 4. d.Pop(): in the woken worker, reacquires the mutex released by Wait(), checks the stack is non-empty, pops last element, unlocks, returns the message.
+ 5. d.work(): a worker now has a DirectMessage. It calls d.send(msg).
+ 6. d.send(msg): two Discord API calls are made, in sequence:
+ a) UserChannelCreate(target.ID): opens/reuses a DM channel with the target player.
+ b) ChannelMessageSend(channel.ID, content): sends the actual text to the target player.
+ c) Note: If either fails, the eror wraps the context (the step and ID) and returns.
+ i. back in work(), this error is logged.
+ ii. the worker loops back to pop() and sleeps waiting a new job.
+ 7. d.Stop(): Closes the quit channel, broadcasts to all sleeping workers that it is closing.
+ a) each woken worker checks <-d.quit, sees if it's closed and pop() returns false.
+ b) work() logs shutdown and goroutine exits.
+
+ Notes:
+ A stack+mutex prevents workers stepping on each other *and* keeps the order the messages were sent in (with the LIFO structure of a stack).
+ The worker that finishes first, pops the next message. Because of this, no worker can pop the same message.
+*/
+
+// Dispatcher manages a stack of outbound DMs processed by a pool of workers.
+type Dispatcher struct {
+ session *discordgo.Session
+ DryRun bool // when true, log DMs instead of sending them
+
+ mu sync.Mutex
+ stack []DirectMessage
+ cond *sync.Cond
+
+ workers int
+ quit chan struct{}
+}
+
+func NewDispatcher(session *discordgo.Session, workers int) *Dispatcher {
+ if workers < 1 {
+ workers = 1
+ }
+ d := &Dispatcher{
+ session: session,
+ DryRun: os.Getenv("SAFE_MODE") == "true",
+ workers: workers,
+ quit: make(chan struct{}),
+ }
+ d.cond = sync.NewCond(&d.mu)
+ return d
+}
+
+// Start launches all workers. Each worker pops from the stack and sends.
+func (d *Dispatcher) Start() {
+ for i := 1; i <= d.workers; i++ {
+ go d.work(i)
+ }
+}
+
+// Stop signals all workers to drain and exit.
+func (d *Dispatcher) Stop() {
+ close(d.quit)
+ d.cond.Broadcast()
+}
+
+// Push adds a message to the top of the stack.
+func (d *Dispatcher) Push(msg DirectMessage) {
+ d.mu.Lock()
+ d.stack = append(d.stack, msg)
+ d.mu.Unlock()
+ d.cond.Signal()
+}
+
+/*
+Pending returns a snapshot of messages currently queued but not yet sent.
+
+Pending is useful for diagnostics and for tests that need to inspect dispatch state
+without starting worker goroutines.
+*/
+func (d *Dispatcher) Pending() []DirectMessage {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ out := make([]DirectMessage, len(d.stack))
+ copy(out, d.stack)
+ return out
+}
+
+func (d *Dispatcher) pop() (DirectMessage, bool) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+
+ for len(d.stack) == 0 {
+ select {
+ case <-d.quit:
+ return DirectMessage{}, false
+ default:
+ }
+ d.cond.Wait()
+ select {
+ case <-d.quit:
+ return DirectMessage{}, false
+ default:
+ }
+ }
+
+ n := len(d.stack) - 1
+ msg := d.stack[n]
+ d.stack = d.stack[:n]
+ return msg, true
+}
+
+func (d *Dispatcher) work(id int) {
+ for {
+ msg, ok := d.pop()
+ if !ok {
+ log.Printf("dispatcher: worker %d shutting down", id)
+ return
+ }
+ if err := d.send(msg); err != nil {
+ log.Printf("dispatcher: worker %d failed to send message %s: %s", id, msg.ID, err)
+ }
+ }
+}
+
+func (d *Dispatcher) send(msg DirectMessage) error {
+ if d.DryRun && msg.Target != guard.DebugAdminID {
+ log.Printf("dispatcher: [SAFE_MODE] would DM user %s (msg %s): %s", msg.Target, msg.ID, truncate(msg.Content, 80))
+ return nil
+ }
+ if d.DryRun {
+ log.Printf("dispatcher: [SAFE_MODE] sending DM to debug admin %s (whitelisted)", msg.Target)
+ }
+
+ channel, err := d.session.UserChannelCreate(msg.Target)
+ if err != nil {
+ return fmt.Errorf("dispatcher: create DM channel for %s: %w", msg.ID, err)
+ }
+
+ _, err = d.session.ChannelMessageSendComplex(channel.ID, &discordgo.MessageSend{
+ Content: msg.Content,
+ Components: msg.Components,
+ Embeds: msg.Embeds,
+ })
+ if err != nil {
+ return fmt.Errorf("dispatcher: send to channel %s: %w", channel.ID, err)
+ }
+
+ log.Printf("dispatcher: sent message %s to %s", msg.ID, msg.ID)
+ return nil
+}
+
+func truncate(s string, max int) string {
+ if len(s) <= max {
+ return s
+ }
+ return s[:max] + "..."
+}
diff --git a/internal/guard/guard.go b/internal/guard/guard.go
new file mode 100644
index 0000000..46e6d7a
--- /dev/null
+++ b/internal/guard/guard.go
@@ -0,0 +1,184 @@
+package guard
+
+import (
+ "log"
+ "os"
+
+ "github.com/bwmarrin/discordgo"
+)
+
+/*
+SafeMode is true when SAFE_MODE=true is set.
+
+In safe mode, all Discord-mutating operations (role create/add/remove, DMs)
+are logged but not executed.
+
+DB operations still run normally so the bot can be tested end-to-end without impacting the server.
+*/
+var SafeMode = os.Getenv("SAFE_MODE") == "true"
+
+/*
+DevMode is true when DEV_MODE=true is set, OR when DISCORD_GUILD_ID is set.
+
+Rationale:
+ Setting DISCORD_GUILD_ID scopes the bot to a single test server,
+ which is only done during development/staging. Treating that as dev mode
+ means debug UI lights up automatically on that guild with no second flag.
+ Production deployments leave DISCORD_GUILD_ID empty and DEV_MODE unset.
+
+In dev mode, debug-only UI surfaces are exposed: the Diagnostics button on
+/admin, the /campaigndatabase slash command, and any future internal-tooling
+buttons.
+
+When false (the default, for production), those surfaces are hidden
+and their handlers refuse to execute even if a stale CustomID is clicked.
+
+Unrelated to SafeMode:
+
+ You can run production with DevMode off AND SafeMode off
+ (live mutations, hidden debug UI) or dev with both on.
+
+ Staging may set SafeMode off + DevMode on to rehearse live mutations with debug visibility.
+*/
+var DevMode = os.Getenv("DEV_MODE") == "true" || os.Getenv("DISCORD_GUILD_ID") != ""
+
+/*
+DebugAdminID is the Discord user ID granted admin privileges for testing.
+
+The user must still be registered and not banned:
+this only elevates their server role.
+It does not bypass any other security checks.
+*/
+var DebugAdminID = os.Getenv("DEBUG_ADMIN_ID")
+
+/*
+DebugGuildID is the Discord guild ID the bot is scoped to in dev mode.
+
+When DevMode is on and this is set, the handler refuses interactions from
+any other guild. Sourced from DISCORD_GUILD_ID, the same env that scopes
+command registration to a single guild in dev.
+*/
+var DebugGuildID = os.Getenv("DISCORD_GUILD_ID")
+
+func init() {
+ if SafeMode {
+ log.Println("guard: SAFE_MODE is ON β Discord-mutating operations will be logged but not executed")
+ }
+ if DevMode {
+ log.Println("guard: DEV_MODE is ON β debug UI surfaces (Diagnostics, /campaigndatabase) are visible")
+ if DebugGuildID != "" {
+ log.Printf("guard: DEV_MODE scoped to guild %s β interactions from other guilds will be rejected", DebugGuildID)
+ }
+ }
+ if DebugAdminID != "" {
+ log.Printf("guard: DEBUG_ADMIN_ID is set β user %s will be treated as admin", DebugAdminID)
+ if !SafeMode {
+ log.Println("guard: WARNING β DEBUG_ADMIN_ID is set with SAFE_MODE OFF; elevation requires the Discord admin role as confirmation")
+ }
+ }
+}
+
+// GuildRoleCreate creates a guild role, or logs and returns a fake role in safe mode.
+func GuildRoleCreate(s *discordgo.Session, guildID string, params *discordgo.RoleParams) (*discordgo.Role, error) {
+ if SafeMode {
+ name := ""
+ if params != nil {
+ name = params.Name
+ }
+ log.Printf("guard: [SAFE_MODE] would create role %q in guild %s", name, guildID)
+ return &discordgo.Role{ID: "safe-mode-role", Name: name}, nil
+ }
+ return s.GuildRoleCreate(guildID, params)
+}
+
+// GuildMemberRoleAdd adds a role to a member, or logs in safe mode.
+func GuildMemberRoleAdd(s *discordgo.Session, guildID, userID, roleID string) error {
+ if SafeMode {
+ log.Printf("guard: [SAFE_MODE] would add role %s to user %s in guild %s", roleID, userID, guildID)
+ return nil
+ }
+ return s.GuildMemberRoleAdd(guildID, userID, roleID)
+}
+
+// GuildMemberRoleRemove removes a role from a member, or logs in safe mode.
+func GuildMemberRoleRemove(s *discordgo.Session, guildID, userID, roleID string) error {
+ if SafeMode {
+ log.Printf("guard: [SAFE_MODE] would remove role %s from user %s in guild %s", roleID, userID, guildID)
+ return nil
+ }
+ return s.GuildMemberRoleRemove(guildID, userID, roleID)
+}
+
+// GuildChannelCreateComplex creates a guild channel, or logs and returns a fake channel in safe mode.
+func GuildChannelCreateComplex(s *discordgo.Session, guildID string, data discordgo.GuildChannelCreateData) (*discordgo.Channel, error) {
+ if SafeMode {
+ log.Printf("guard: [SAFE_MODE] would create channel %q (type %d) in guild %s", data.Name, data.Type, guildID)
+ return &discordgo.Channel{ID: "safe-mode-channel", Name: data.Name, Type: data.Type, ParentID: data.ParentID}, nil
+ }
+ return s.GuildChannelCreateComplex(guildID, data)
+}
+
+// ChannelDelete deletes a channel, or logs in safe mode.
+func ChannelDelete(s *discordgo.Session, channelID string) (*discordgo.Channel, error) {
+ if SafeMode {
+ log.Printf("guard: [SAFE_MODE] would delete channel %s", channelID)
+ return &discordgo.Channel{ID: channelID}, nil
+ }
+ return s.ChannelDelete(channelID)
+}
+
+// ThreadStart starts a new thread on a channel (no parent message), or logs in safe mode.
+func ThreadStart(s *discordgo.Session, channelID, name string, archiveDuration int) (*discordgo.Channel, error) {
+ if SafeMode {
+ log.Printf("guard: [SAFE_MODE] would start thread %q in channel %s", name, channelID)
+ return &discordgo.Channel{ID: "safe-mode-thread-" + name, Name: name}, nil
+ }
+ return s.ThreadStart(channelID, name, discordgo.ChannelTypeGuildPublicThread, archiveDuration)
+}
+
+// ChannelMessageSend sends a message to a channel, or logs in safe mode.
+func ChannelMessageSend(s *discordgo.Session, channelID, content string) (*discordgo.Message, error) {
+ if SafeMode {
+ log.Printf("guard: [SAFE_MODE] would send message to channel %s: %q", channelID, content)
+ return &discordgo.Message{ID: "safe-mode-message"}, nil
+ }
+ 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
new file mode 100644
index 0000000..7c34c3a
--- /dev/null
+++ b/internal/guard/mode_split_test.go
@@ -0,0 +1,81 @@
+package guard_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/framebuffers/moontracer/internal/guard"
+)
+
+/*
+Unit Testing: SAFE_MODE / DEV_MODE split.
+
+ SafeMode gates Discord-mutating operations (roles, channels, threads, DMs).
+ DevMode gates debug-only UI surfaces (Diagnostics, /campaigndatabase).
+ They are independent; each of the four combinations represents a valid
+ deployment posture.
+*/
+
+/*
+Mode matrix covers every (SafeMode, DevMode) combination.
+
+When:
+
+ Each combo (t,t), (t,f), (f,t), (f,f) is applied via SetModesForTest.
+
+Expected:
+
+ Package vars reflect the applied values exactly. Each flag is orthogonal:
+ setting one does not change the other. Each combo maps to a posture:
+ (t,t) β dev (mutations faked, debug UI visible)
+ (t,f) β dev-no-debug (mutations faked, debug UI hidden)
+ (f,t) β staging (mutations live, debug UI visible)
+ (f,f) β production (mutations live, debug UI hidden)
+*/
+func TestModeSplit_MatrixCoversAllPostures(t *testing.T) {
+ cases := []struct {
+ name string
+ safe bool
+ dev bool
+ }{
+ {"dev (safe+dev)", true, true},
+ {"dev-no-debug (safe only)", true, false},
+ {"staging (dev only)", false, true},
+ {"production (neither)", false, false},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ guard.SetModesForTest(t, tc.safe, tc.dev)
+
+ assert.Equal(t, tc.safe, guard.SafeMode, "SafeMode should match applied value")
+ assert.Equal(t, tc.dev, guard.DevMode, "DevMode should match applied value")
+ })
+ }
+}
+
+/*
+SetModesForTest restores original values on cleanup.
+
+When:
+
+ A subtest overrides SafeMode and DevMode via SetModesForTest, then returns.
+
+Expected:
+
+ After the subtest finishes, both flags are restored to their original
+ values so subsequent tests are not polluted by ordering effects.
+*/
+func TestSetModesForTest_RestoresOriginalOnCleanup(t *testing.T) {
+ origSafe, origDev := guard.SafeMode, guard.DevMode
+
+ t.Run("override inside subtest", func(t *testing.T) {
+ guard.SetModesForTest(t, !origSafe, !origDev)
+ assert.NotEqual(t, origSafe, guard.SafeMode, "SafeMode should be overridden inside subtest")
+ assert.NotEqual(t, origDev, guard.DevMode, "DevMode should be overridden inside subtest")
+ })
+
+ assert.Equal(t, origSafe, guard.SafeMode, "SafeMode should be restored after subtest cleanup")
+ assert.Equal(t, origDev, guard.DevMode, "DevMode should be restored after subtest cleanup")
+}
diff --git a/internal/guard/testhelpers.go b/internal/guard/testhelpers.go
new file mode 100644
index 0000000..1fe7dca
--- /dev/null
+++ b/internal/guard/testhelpers.go
@@ -0,0 +1,29 @@
+package guard
+
+import "testing"
+
+/*
+SetModesForTest overrides SafeMode and DevMode for the duration of the test,
+restoring prior values via t.Cleanup.
+
+Intended for tests that exercise mode-sensitive behavior without touching the
+process environment.
+
+Since SafeMode/DevMode are package-level vars evaluated
+once at init time, tests need a way to flip them deterministically.
+
+Example:
+
+ func TestSomething(t *testing.T) {
+ guard.SetModesForTest(t, false, true) // prod-mutations, dev-UI
+ // ... assertions ...
+ }
+*/
+func SetModesForTest(t *testing.T, safe, dev bool) {
+ t.Helper()
+ origSafe, origDev := SafeMode, DevMode
+ SafeMode, DevMode = safe, dev
+ t.Cleanup(func() {
+ SafeMode, DevMode = origSafe, origDev
+ })
+}
diff --git a/internal/interactions/admin_broadcast.go b/internal/interactions/admin_broadcast.go
new file mode 100644
index 0000000..177cc97
--- /dev/null
+++ b/internal/interactions/admin_broadcast.go
@@ -0,0 +1,128 @@
+package interactions
+
+/*
+ Admin Broadcast handler for the /admin hub.
+
+ Flow:
+ 1. Staff clicks "Broadcast" on the /admin hub.
+ 2. Auth: ScopeMod.
+ 3. Open a modal with a paragraph text field for the broadcast message.
+ 4. On submit, load all registered Players and push a DM via the
+ dispatcher to every one (excluding the sender unless DEBUG_ADMIN_ID
+ matches, mirroring the per-campaign announce flow).
+ 5. Confirm with the count of dispatched messages.
+*/
+
+import (
+ "fmt"
+ "log"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/google/uuid"
+ "github.com/uptrace/bun"
+
+ "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 {
+ db *bun.DB
+ dispatcher *dispatch.Dispatcher
+}
+
+func (h *adminBroadcastHandler) CustomIDPrefix() string {
+ return messages.AdminBroadcastPrefix
+}
+
+func (h *adminBroadcastHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ userID := helpers.GetUserID(i)
+
+ ok, err := auth.Authorize(h.db, userID, auth.ScopeMod, "")
+ if err != nil || !ok {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignDBNotStaff)
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseModal,
+ Data: &discordgo.InteractionResponseData{
+ CustomID: messages.AdminBroadcastModalID,
+ Title: messages.AdminBroadcastModalTitle,
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: messages.AdminBroadcastFieldID,
+ Label: messages.AdminBroadcastFieldLabel,
+ Style: discordgo.TextInputParagraph,
+ Required: true,
+ },
+ }},
+ },
+ },
+ })
+}
+
+type adminBroadcastModal struct {
+ db *bun.DB
+ dispatcher *dispatch.Dispatcher
+}
+
+func (h *adminBroadcastModal) CustomIDPrefix() string {
+ return messages.AdminBroadcastModalID
+}
+
+func (h *adminBroadcastModal) HandleModal(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ userID := helpers.GetUserID(i)
+
+ ok, err := auth.Authorize(h.db, userID, auth.ScopeMod, "")
+ if err != nil || !ok {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignDBNotStaff)
+ return
+ }
+
+ var message string
+ for _, row := range i.ModalSubmitData().Components {
+ for _, comp := range row.(*discordgo.ActionsRow).Components {
+ input := comp.(*discordgo.TextInput)
+ if input.CustomID == messages.AdminBroadcastFieldID {
+ message = input.Value
+ }
+ }
+ }
+
+ if message == "" {
+ helpers.RespondUpdateTerminal(s, i, messages.AdminBroadcastFailed)
+ return
+ }
+
+ players, err := db.GetAll[models.Player](h.db)
+ if err != nil {
+ log.Printf("admin_broadcast: failed to load players: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.AdminBroadcastFailed)
+ return
+ }
+
+ skipSelf := guard.DebugAdminID == "" || guard.DebugAdminID != userID
+
+ msgID := uuid.NewString()
+ sent := 0
+ for _, p := range players {
+ if p.ID == userID && skipSelf {
+ continue
+ }
+ h.dispatcher.Push(dispatch.DirectMessage{
+ ID: msgID,
+ Sender: userID,
+ Target: p.ID,
+ Content: fmt.Sprintf(messages.AdminBroadcastDMContent, userID, message),
+ })
+ sent++
+ }
+
+ helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.AdminBroadcastSent, sent))
+}
diff --git a/internal/interactions/admin_campaigns.go b/internal/interactions/admin_campaigns.go
new file mode 100644
index 0000000..fd987df
--- /dev/null
+++ b/internal/interactions/admin_campaigns.go
@@ -0,0 +1,226 @@
+package interactions
+
+/*
+ Admin Campaigns handler for the /admin hub.
+
+ Flow:
+ 1. Staff clicks "Query Campaigns" on the /admin hub.
+ 2. Auth: ScopeMod.
+ 3. Load ALL campaigns (approved, pending, archived).
+ 4. Render a code block with name, status, open/closed, DM name, next session.
+ 5. Selecting a campaign shows admin-level detail + Contact DM button.
+ 6. Back button returns to /admin hub.
+*/
+
+import (
+ "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/interactions/router"
+ "github.com/framebuffers/moontracer/internal/manager/models"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+type adminCampaignsHandler struct {
+ db *bun.DB
+}
+
+func (h *adminCampaignsHandler) CustomIDPrefix() string {
+ return messages.AdminCampaignsPrefix
+}
+
+func (h *adminCampaignsHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ userID := helpers.GetUserID(i)
+
+ ok, err := auth.Authorize(h.db, userID, auth.ScopeMod, "")
+ if err != nil || !ok {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignDBNotStaff)
+ return
+ }
+
+ filtered, err := db.GetAll[models.Campaign](h.db)
+ if err != nil {
+ log.Printf("admin_campaigns: failed to load campaigns: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ if len(filtered) == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.AdminCampaignsNone)
+ return
+ }
+
+ resolveName := func(userID string) string {
+ if m, err := s.State.Member(i.GuildID, userID); err == nil && m.User != nil {
+ if m.Nick != "" {
+ return m.Nick
+ }
+ return m.User.Username
+ }
+ if len(userID) >= 5 {
+ return "..." + userID[len(userID)-5:]
+ }
+ return userID
+ }
+
+ const nameW, statusW, dmW = 20, 10, 18
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "%-*s %-*s %-4s %-*s %s\n",
+ nameW, "CAMPAIGN", statusW, "STATUS", "OPEN", dmW, "DM", "NEXT SESSION")
+ sb.WriteString(strings.Repeat("-", nameW+statusW+dmW+30) + "\n")
+ for _, c := range filtered {
+ name := c.Name
+ if len(name) > nameW {
+ name = name[:nameW-1] + "."
+ }
+ status := messages.BuildFlags(c)
+ if len(status) > statusW {
+ status = status[:statusW-1] + "."
+ }
+ open := "no"
+ if c.IsOpen {
+ open = "yes"
+ }
+ dmName := resolveName(c.DungeonMaster)
+ if len(dmName) > dmW {
+ dmName = dmName[:dmW-1] + "."
+ }
+ next := "(not set)"
+ if !c.Schedule.NextSession.IsZero() {
+ next = c.Schedule.NextSession.Format("2006-01-02")
+ }
+ fmt.Fprintf(&sb, "%-*s %-*s %-4s %-*s %s\n",
+ nameW, name, statusW, status, open, dmW, dmName, next)
+ }
+ block := "```\n" + sb.String() + "```"
+ if len(block) > 1900 {
+ block = block[:1896] + "```"
+ }
+
+ var options []discordgo.SelectMenuOption
+ for idx, c := range filtered {
+ if idx >= 25 {
+ break
+ }
+ dmName := resolveName(c.DungeonMaster)
+ options = append(options, discordgo.SelectMenuOption{
+ Label: c.Name,
+ Description: fmt.Sprintf("DM: %s - %s", dmName, messages.BuildFlags(c)),
+ Value: c.ID,
+ })
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: block,
+ Embeds: []*discordgo.MessageEmbed{},
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.SelectMenu{
+ MenuType: discordgo.StringSelectMenu,
+ CustomID: messages.AdminCampaignSelectPrefix,
+ Placeholder: messages.AdminCampaignSelectPlaceholder,
+ Options: options,
+ },
+ }},
+ helpers.BackRow(router.ViewAdmin),
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+adminCampaignSelectHandler renders an admin-level detail panel when the user
+picks a campaign from the dropdown rendered by adminCampaignsHandler.
+
+CustomID: admin_campaign_select (the value carries the campaign ID).
+*/
+type adminCampaignSelectHandler struct {
+ db *bun.DB
+}
+
+func (h *adminCampaignSelectHandler) CustomIDPrefix() string {
+ return messages.AdminCampaignSelectPrefix
+}
+
+func (h *adminCampaignSelectHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ userID := helpers.GetUserID(i)
+
+ ok, err := auth.Authorize(h.db, userID, auth.ScopeMod, "")
+ if err != nil || !ok {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignDBNotStaff)
+ return
+ }
+
+ values := i.MessageComponentData().Values
+ if len(values) == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.InvalidButtonDataMessage)
+ return
+ }
+ campaignID := values[0]
+
+ campaign, err := db.GetByID[models.Campaign](h.db, campaignID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageCampaignNotFound)
+ return
+ }
+
+ roleLine := "_no role linked_"
+ if campaign.RoleID != "" {
+ roleLine = fmt.Sprintf("<@&%s>", campaign.RoleID)
+ }
+
+ scheduleLine := fmt.Sprintf("%s %s UTC (%.1fh, %s)",
+ campaign.Schedule.DayName(),
+ campaign.Schedule.StartTime,
+ campaign.Schedule.DurationHours,
+ campaign.Schedule.Frequency)
+
+ body := fmt.Sprintf(
+ "**Tag:** `%s`\n**DM:** <@%s>\n**Status:** %s\n**Slots:** %d\n**Role:** %s\n**Schedule:** %s",
+ campaign.Tag,
+ campaign.DungeonMaster,
+ messages.BuildFlags(*campaign),
+ campaign.Slots,
+ roleLine,
+ scheduleLine,
+ )
+
+ if campaign.Description != "" {
+ body += "\n\n**Description:**\n" + campaign.Description
+ }
+
+ embed := &discordgo.MessageEmbed{
+ Title: campaign.Name,
+ Description: body,
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: "",
+ Embeds: []*discordgo.MessageEmbed{embed},
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ router.BackButton(messages.BackLabel, router.ViewAdmin),
+ router.NavButton(messages.HomeLabel, discordgo.DangerButton, router.ViewMe),
+ discordgo.Button{
+ Label: messages.AdminContactDMLabel,
+ Style: discordgo.LinkButton,
+ URL: fmt.Sprintf("discord://-/users/%s", campaign.DungeonMaster),
+ },
+ }},
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
diff --git a/internal/interactions/admin_database.go b/internal/interactions/admin_database.go
new file mode 100644
index 0000000..a06f200
--- /dev/null
+++ b/internal/interactions/admin_database.go
@@ -0,0 +1,88 @@
+package interactions
+
+/*
+ Admin Database handler for the /admin hub.
+
+ Flow:
+ 1. Staff clicks "Database" on the /admin hub.
+ 2. Auth: ScopeMod.
+ 3. Load all campaigns from DB (same logic as campaignDatabaseCommand).
+ 4. Build a formatted list with name, tag, DM, status flags.
+ 5. Respond ephemerally.
+ 6. Back button returns to /admin hub.
+
+ Note: this replicates the existing /campaigndatabase slash command as a button handler.
+ Once fully wired, the slash command can be removed.
+*/
+
+import (
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+ "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/guard"
+ "github.com/framebuffers/moontracer/internal/interactions/router"
+ "github.com/framebuffers/moontracer/internal/manager/models"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+type adminDatabaseHandler struct {
+ db *bun.DB
+}
+
+func (h *adminDatabaseHandler) CustomIDPrefix() string {
+ return messages.AdminDatabasePrefix
+}
+
+func (h *adminDatabaseHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ /*
+ Double-gate:
+ The button is hidden in adminHubData when DevMode is off,
+ but a stale ephemeral could still carry the CustomID.
+
+ Reject here so production never exposes the raw database view regardless.
+ */
+ if !guard.DevMode {
+ helpers.RespondUpdateTerminal(s, i, messages.DebugSurfaceDisabled)
+ return
+ }
+
+ userID := helpers.GetUserID(i)
+ ok, err := auth.Authorize(h.db, userID, auth.ScopeMod, "")
+ if err != nil || !ok {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignDBNotStaff)
+ return
+ }
+
+ campaigns, err := db.GetAll[models.Campaign](h.db)
+ if err != nil {
+ log.Printf("admin_database: failed to load campaigns: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ if len(campaigns) == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignDBEmpty)
+ return
+ }
+
+ var lines []string
+ for _, camp := range campaigns {
+ flags := messages.BuildFlags(camp)
+ lines = append(lines, fmt.Sprintf(messages.AdminDBCampaignLine,
+ camp.Name, camp.Tag, camp.DungeonMaster, flags))
+ }
+
+ content := "**All campaigns in database:**\n" + strings.Join(lines, "\n")
+ if len(content) > 1900 {
+ content = content[:1900] + "\n... (truncated)"
+ }
+
+ helpers.RespondWithBack(s, i, discordgo.InteractionResponseUpdateMessage, content, nil, router.ViewAdmin)
+}
diff --git a/internal/interactions/admin_diag.go b/internal/interactions/admin_diag.go
new file mode 100644
index 0000000..4df39ff
--- /dev/null
+++ b/internal/interactions/admin_diag.go
@@ -0,0 +1,61 @@
+package interactions
+
+/*
+ Admin Diagnostics handler for the /admin hub.
+
+ Flow:
+ 1. Staff clicks "Diagnostics" on the /admin hub.
+ 2. Auth: ScopeMod.
+ 3. Render runtime / session / config diagnostics as an ephemeral sub-view.
+ 4. Back button returns to /admin hub via BackAdminID.
+*/
+
+import (
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+ "log"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "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 {
+ db *bun.DB
+}
+
+func (h *adminDiagHandler) CustomIDPrefix() string {
+ return messages.AdminDiagPrefix
+}
+
+func (h *adminDiagHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ /*
+ Double-gate:
+ The button is hidden in adminHubData when DevMode is off,
+ but a stale ephemeral could still carry the CustomID.
+
+ Reject here so production never exposes the raw database view regardless.
+ */
+ if !guard.DevMode {
+ helpers.RespondUpdateTerminal(s, i, messages.DebugSurfaceDisabled)
+ return
+ }
+
+ userID := i.Member.User.ID
+
+ ok, err := auth.Authorize(h.db, userID, auth.ScopeMod, "")
+ if err != nil {
+ log.Printf("admin_diag: auth check failed: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ if !ok {
+ helpers.RespondUpdateTerminal(s, i, messages.AdminNotStaff)
+ return
+ }
+
+ commands.RenderAdminDiag(s, i, h.db, i.GuildID)
+}
diff --git a/internal/interactions/admin_settings.go b/internal/interactions/admin_settings.go
new file mode 100644
index 0000000..8d193f1
--- /dev/null
+++ b/internal/interactions/admin_settings.go
@@ -0,0 +1,52 @@
+package interactions
+
+/*
+ Admin Settings handler for the /admin hub.
+
+ Status: stubbed for 1.0. The full settings UI requires a new GuildSettings
+ model and migration; that work is deferred to v1.1. For 1.0, the button
+ renders a placeholder panel with a back button so the admin hub stays
+ navigable without dead-ending.
+
+ Future flow (v1.1):
+ 1. Staff clicks "Settings" on the /admin hub.
+ 2. Auth: ScopeAdmin (admin only, not mod).
+ 3. Show per-guild settings as toggle buttons or a form.
+ 4. Persist via GuildSettings table (TBD model).
+*/
+
+import (
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "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 {
+ db *bun.DB
+}
+
+func (h *adminSettingsHandler) CustomIDPrefix() string {
+ return messages.AdminSettingsPrefix
+}
+
+func (h *adminSettingsHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ userID := helpers.GetUserID(i)
+
+ ok, err := auth.Authorize(h.db, userID, auth.ScopeAdmin, "")
+ if err != nil || !ok {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignDBNotStaff)
+ return
+ }
+
+ helpers.RespondWithBack(
+ s, i,
+ discordgo.InteractionResponseUpdateMessage,
+ "**Settings**\n\n_The settings UI is coming in v1.1. Per-guild configuration (default reminder times, notification channels, auto-approval policy) will live here._",
+ nil,
+ router.ViewAdmin,
+ )
+}
diff --git a/internal/interactions/back_handlers.go b/internal/interactions/back_handlers.go
new file mode 100644
index 0000000..de56c5c
--- /dev/null
+++ b/internal/interactions/back_handlers.go
@@ -0,0 +1,163 @@
+package interactions
+
+/*
+ Render functions for the list-style player views.
+
+ RenderMyCampaignsList and RenderManageList are registered with the router
+ (see views.go) under ViewMyCampaigns and ViewManage respectively. They are
+ invoked both as forward links from /me and as "Back" targets from child
+ views that came from one of these lists.
+
+ Flow (RenderMyCampaignsList):
+ 1. Load CampaignPlayer rows for the invoking user (all roles).
+ 2. On load failure -> ephemeral error and abort.
+ 3. Empty result -> empty-state message with only a Back to /me button.
+ 4. Otherwise build a string select of the user's approved campaigns
+ plus a textual list header, and render via helpers.RespondUpdate
+ (replaces the current ephemeral message in place).
+
+ Flow (RenderManageList):
+ 1. Load CampaignPlayer rows for the user.
+ 2. Filter down to entries where Role == DM.
+ 3. Empty filtered set β empty-state message with Back only.
+ 4. Otherwise render a manage select plus a Back + "New Campaign"
+ button row.
+
+ Also exports helpers.GetUserID, a small helper used across the interactions
+ package to pull the invoking user's ID out of either the Member (guild
+ context) or User (DM) field on an InteractionCreate.
+
+ Historical note:
+ Previously this file held one ComponentHandler per
+ "back_*" CustomID. Those have been replaced by the view router; the
+ rendering bodies live here as package-level functions.
+*/
+
+import (
+ "fmt"
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+ "strings"
+
+ "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"
+)
+
+// RenderMyCampaignsList renders the player's own campaigns as a select menu.
+func RenderMyCampaignsList(s *discordgo.Session, i *discordgo.InteractionCreate, db *bun.DB, userID string) {
+ entries, err := models.GetPlayerCampaigns(db, userID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.MyCampaignsLoadError)
+ return
+ }
+
+ if len(entries) == 0 {
+ helpers.RespondUpdate(s, i, messages.NoCampaignsMessage, nil, []discordgo.MessageComponent{
+ helpers.BackRow(router.ViewMe),
+ })
+ return
+ }
+
+ selectMenu := BuildPlayerCampaignSelect(entries, messages.MyCampaignSelectPrefix, messages.MyCampaignsPlaceholder)
+
+ var lines []string
+ for _, e := range entries {
+ if e.Campaign != nil && e.Campaign.IsApproved {
+ lines = append(lines, fmt.Sprintf(messages.MyCampaignListLine, e.Campaign.Name, e.Role, e.Status))
+ }
+ }
+ content := messages.MyCampaignsListHeader + strings.Join(lines, "\n")
+
+ helpers.RespondUpdate(s, i, content, nil, []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{selectMenu}},
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.PlayerDownloadTokensLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: messages.PlayerDownloadTokensPrefix,
+ },
+ }},
+ helpers.BackRow(router.ViewMe),
+ })
+}
+
+// RenderManageList renders the campaigns the user DMs as a select menu.
+func RenderManageList(s *discordgo.Session, i *discordgo.InteractionCreate, db *bun.DB, userID string) {
+ entries, err := models.GetPlayerCampaigns(db, userID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ var dmEntries []models.CampaignPlayer
+ for _, e := range entries {
+ if e.Role == models.RoleDM {
+ dmEntries = append(dmEntries, e)
+ }
+ }
+
+ if len(dmEntries) == 0 {
+ helpers.RespondUpdate(s, i, messages.ManageNoDMCampaigns, nil, []discordgo.MessageComponent{
+ helpers.BackRow(router.ViewMe),
+ })
+ return
+ }
+
+ // 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 {
+ 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")
+
+ helpers.RespondUpdate(s, i, content, nil, []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{selectMenu}},
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.NewCampaignLabel,
+ Style: discordgo.SuccessButton,
+ CustomID: messages.ManageNewCampaignPrefix,
+ },
+ router.NavButton(messages.HomeLabel, discordgo.SecondaryButton, router.ViewMe),
+ }},
+ })
+}
diff --git a/internal/interactions/campaign_announce.go b/internal/interactions/campaign_announce.go
new file mode 100644
index 0000000..0e516ac
--- /dev/null
+++ b/internal/interactions/campaign_announce.go
@@ -0,0 +1,222 @@
+package interactions
+
+import (
+ "fmt"
+ "log"
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/google/uuid"
+ "github.com/uptrace/bun"
+
+ "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"
+)
+
+/*
+ Flow:
+ 1. DM opens `/managecampaigns`, selects a campaign, clicks "Announce".
+ 2. `manageCampaignAnnounce` catches `manage_announce:`, authorizes (ScopeDM).
+ 3. Opens a modal with a single text field for the announcement message.
+ 4. DM submits the modal, triggering `manage_announce_modal:`.
+ 5. `manageCampaignAnnounceModal` parses the message, loads all active CampaignPlayers.
+ 6. For each active member (excluding the DM), pushes a DirectMessage via the dispatcher.
+ 7. Responds to the DM ephemerally: "Announcement sent to X members of Y."
+*/
+
+/*
+manageCampaignAnnounce opens a modal for the DM to type an announcement.
+
+Custom ID format: manage_announce:
+*/
+type manageCampaignAnnounce struct {
+ db *bun.DB
+}
+
+func (h *manageCampaignAnnounce) CustomIDPrefix() string {
+ return messages.AnnounceComponentPrefix
+}
+
+func (h *manageCampaignAnnounce) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ campaignID := parts[1]
+
+ campaign, ok := helpers.LoadDMCampaign(s, i, h.db, campaignID)
+ if !ok {
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseModal,
+ Data: &discordgo.InteractionResponseData{
+ CustomID: messages.AnnounceModalPrefix + ":" + campaignID,
+ Title: messages.AnnounceModalTitle,
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: messages.AnnounceFieldID,
+ Label: messages.AnnounceFieldLabel,
+ Style: discordgo.TextInputParagraph,
+ Required: true,
+ Placeholder: messages.AnnounceFieldPlaceholder,
+ },
+ }},
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: "vtt_link",
+ Label: messages.ManageLinksVTTLabel,
+ Style: discordgo.TextInputShort,
+ Placeholder: messages.ManageLinksVTTPlaceholder,
+ Value: campaign.VTTLink,
+ Required: false,
+ MaxLength: 500,
+ },
+ }},
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: "resources",
+ Label: messages.ManageLinksResourcesLabel,
+ Style: discordgo.TextInputParagraph,
+ Placeholder: messages.ManageLinksResourcesPlaceholder,
+ Value: strings.Join(campaign.Links, "\n"),
+ Required: false,
+ MaxLength: 1000,
+ },
+ }},
+ },
+ },
+ })
+}
+
+/*
+manageCampaignAnnounceModal handles the modal submission and DMs all active members.
+
+ Custom ID format: manage_announce_modal:
+*/
+type manageCampaignAnnounceModal struct {
+ db *bun.DB
+ dispatcher *dispatch.Dispatcher
+}
+
+func (h *manageCampaignAnnounceModal) CustomIDPrefix() string {
+ return messages.AnnounceModalPrefix
+}
+
+/*
+HandleModal processes and handles information to be presented inside the modal screen.
+
+Note:
+
+ The modal follows a Thread-first path. If there's one, post announcements on that thread.
+*/
+func (h *manageCampaignAnnounceModal) 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)
+
+ campaign, ok := helpers.LoadDMCampaign(s, i, h.db, campaignID)
+ if !ok {
+ return
+ }
+
+ var message string
+ for _, row := range i.ModalSubmitData().Components {
+ for _, comp := range row.(*discordgo.ActionsRow).Components {
+ input := comp.(*discordgo.TextInput)
+ switch input.CustomID {
+ case messages.AnnounceFieldID:
+ message = input.Value
+ case "vtt_link":
+ campaign.VTTLink = strings.TrimSpace(input.Value)
+ case "resources":
+ campaign.Links = parseLinks(input.Value)
+ }
+ }
+ }
+
+ if err := db.Update(h.db, campaign); err != nil {
+ log.Printf("campaign_announce: failed to save links for campaign %s: %v", campaignID, err)
+ // Non-fatal: continue with the announcement even if link save fails.
+ }
+
+ if campaign.AnnouncementsThreadID != "" {
+ rolePing := ""
+ if campaign.RoleID != "" {
+ rolePing = fmt.Sprintf("<@&%s> ", campaign.RoleID)
+ }
+ content := fmt.Sprintf(messages.AnnounceThreadContent, rolePing, userID, message) + buildAnnounceLinkBlock(campaign, "")
+ if _, err := guard.ChannelMessageSend(s, campaign.AnnouncementsThreadID, content); err != nil {
+ log.Printf("campaign_announce: failed to post to thread %s: %v", campaign.AnnouncementsThreadID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.AnnounceError)
+ return
+ }
+ helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.AnnouncePostedToThread, campaign.Name))
+ return
+ }
+
+ players, err := models.GetCampaignPlayers(h.db, campaignID)
+ if err != nil {
+ log.Printf("campaign_announce: failed to load players: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.AnnounceError)
+ return
+ }
+
+ /*
+ When DEBUG_ADMIN_ID matches the sender, include them in the recipient list
+ so a solo tester can verify announcement DMs.
+ */
+ skipSelf := guard.DebugAdminID == "" || guard.DebugAdminID != userID
+
+ msgID := uuid.NewString()
+ sent := 0
+ for _, p := range players {
+ if p.Status != models.StatusActive {
+ continue
+ }
+ if p.PlayerID == userID && skipSelf {
+ continue
+ }
+ h.dispatcher.Push(dispatch.DirectMessage{
+ ID: msgID,
+ Sender: userID,
+ Target: p.PlayerID,
+ Content: fmt.Sprintf(messages.AnnounceDMContent, campaign.Name, userID, message) + buildAnnounceLinkBlock(campaign, p.SheetURL),
+ })
+ sent++
+ }
+
+ if sent == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.AnnounceNoMembers)
+ return
+ }
+
+ helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.AnnounceSentMessage, sent, campaign.Name))
+}
+
+func buildAnnounceLinkBlock(campaign *models.Campaign, sheetURL string) string {
+ if campaign.VTTLink == "" && sheetURL == "" && len(campaign.Links) == 0 {
+ return ""
+ }
+ var b strings.Builder
+ b.WriteString(messages.ManageReminderLinks)
+ if campaign.VTTLink != "" {
+ b.WriteString(fmt.Sprintf(messages.ManageReminderVTT, campaign.VTTLink))
+ }
+ if sheetURL != "" {
+ b.WriteString(fmt.Sprintf(messages.ManageReminderSheets, sheetURL))
+ }
+ for _, r := range campaign.Links {
+ b.WriteString(fmt.Sprintf(messages.ManageReminderResource, r))
+ }
+ return b.String()
+}
diff --git a/internal/interactions/campaign_approve.go b/internal/interactions/campaign_approve.go
new file mode 100644
index 0000000..09f9495
--- /dev/null
+++ b/internal/interactions/campaign_approve.go
@@ -0,0 +1,259 @@
+package interactions
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+
+ "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/dispatch"
+ "github.com/framebuffers/moontracer/internal/manager/models"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+ Flow (Approve):
+ 1. Mod receives a DM with Approve/Deny buttons for a pending campaign.
+ 2. Mod clicks "Approve", triggering `campaign_approve:`.
+ 3. `parseApprovalInteraction` extracts the campaign ID and the mod's user ID from the DM interaction.
+ 4. `checkModAuth` verifies the user has ScopeMod (mod or admin).
+ 5. Loads the campaign by ID, sets IsApproved = true, updates the DB.
+ 6. Sends a DM to the campaign's DM via the dispatcher: "Your campaign has been approved!"
+ 7. Responds to the mod ephemerally: "Campaign X has been approved."
+
+ Flow (Deny):
+ 1. Mod clicks "Deny", triggering `campaign_deny:`.
+ 2. Auth check (same as approve).
+ 3. Instead of denying immediately, opens a modal asking for a denial reason.
+ 4. Mod submits the modal, triggering `campaign_deny_modal:`.
+ 5. `campaignDenyModal` parses the reason, deletes the campaign from the DB.
+ 6. Sends a DM to the campaign's DM via the dispatcher with the denial reason.
+ 7. Responds to the mod ephemerally: "Campaign X has been denied and deleted."
+*/
+
+/*
+campaignApprove handles the "Approve" button click on a campaign approval DM.
+
+Custom ID format: campaign_approve:
+*/
+type campaignApprove struct {
+ db *bun.DB
+ dispatcher *dispatch.Dispatcher
+}
+
+func (c *campaignApprove) CustomIDPrefix() string {
+ return messages.CampaignApprovePrefix
+}
+
+/*
+HandleComponents processes and renders information that will be presented inside the modal window.
+
+Note:
+
+ createCampaignChannels: creates category, channel and standard Discord Threads.
+ errors are logged but non-fatal.
+*/
+func (c *campaignApprove) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ guildID, campaignID, _, ok := parseApprovalInteraction(s, i)
+ if !ok {
+ 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
+ }
+
+ campaign.IsApproved = true
+ if err := db.Update(c.db, campaign); err != nil {
+ log.Printf("campaign_approve: failed to approve campaign %s: %v", campaignID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignApproveError)
+ return
+ }
+
+ /*
+ 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)
+ }
+
+ 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: senderID,
+ Target: campaign.DungeonMaster,
+ Content: fmt.Sprintf(messages.CampaignApprovedDMMessage, campaign.Name),
+ })
+
+ 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: content,
+ Components: []discordgo.MessageComponent{},
+ },
+ })
+ _ = empty
+}
+
+/*
+campaignDeny handles the "Deny" button click on a campaign approval DM.
+
+Custom ID format: campaign_deny:
+*/
+type campaignDeny struct {
+ db *bun.DB
+}
+
+func (c *campaignDeny) CustomIDPrefix() string {
+ return messages.CampaignDenyPrefix
+}
+
+func (c *campaignDeny) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ guildID, campaignID, userID, ok := parseApprovalInteraction(s, i)
+ if !ok {
+ return
+ }
+
+ if !checkModAuth(c.db, s, i, userID) {
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseModal,
+ Data: &discordgo.InteractionResponseData{
+ CustomID: messages.CampaignDenyModalPrefix + ":" + guildID + ":" + campaignID,
+ Title: messages.CampaignDenyModalTitle,
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: messages.CampaignDenyReasonFieldID,
+ Label: messages.CampaignDenyReasonLabel,
+ Style: discordgo.TextInputParagraph,
+ Required: true,
+ Placeholder: messages.CampaignDenyReasonPlaceholder,
+ },
+ }},
+ },
+ },
+ })
+}
+
+/*
+parseApprovalInteraction extracts the guild ID, campaign ID, and user ID from an approval button click.
+
+Custom ID format: prefix::
+Note: In DMs, i.Member is nil. i.User is used instead.
+*/
+func parseApprovalInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) (guildID, campaignID, userID string, ok bool) {
+ parts, valid := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 3)
+ if !valid {
+ return "", "", "", false
+ }
+
+ if i.User != nil {
+ userID = i.User.ID
+ } else if i.Member != nil {
+ userID = i.Member.User.ID
+ }
+
+ return parts[1], parts[2], userID, true
+}
+
+// checkModAuth verifies the user is a mod or admin.
+func checkModAuth(database *bun.DB, s *discordgo.Session, i *discordgo.InteractionCreate, userID string) bool {
+ ok, err := auth.Authorize(database, userID, auth.ScopeMod, "")
+ if err != nil || !ok {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignApproveNotModError)
+ return false
+ }
+ return true
+}
+
+/*
+campaignDenyModal handles the modal submission after a mod clicks "Deny" and provides a reason.
+
+Custom ID format: campaign_deny_modal:
+*/
+type campaignDenyModal struct {
+ db *bun.DB
+ dispatcher *dispatch.Dispatcher
+}
+
+func (m *campaignDenyModal) CustomIDPrefix() string {
+ return messages.CampaignDenyModalPrefix
+}
+
+func (m *campaignDenyModal) HandleModal(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ // CustomID format: campaign_deny_modal::
+ parts, ok := helpers.SplitCustomID(s, i, i.ModalSubmitData().CustomID, 3)
+ if !ok {
+ return
+ }
+ campaignID := parts[2]
+
+ var userID string
+ if i.User != nil {
+ userID = i.User.ID
+ } else if i.Member != nil {
+ userID = i.Member.User.ID
+ }
+
+ campaign, ok := helpers.LoadModCampaign(s, i, m.db, campaignID)
+ if !ok {
+ return
+ }
+
+ var reason string
+ for _, row := range i.ModalSubmitData().Components {
+ for _, comp := range row.(*discordgo.ActionsRow).Components {
+ input := comp.(*discordgo.TextInput)
+ if input.CustomID == messages.CampaignDenyReasonFieldID {
+ reason = input.Value
+ }
+ }
+ }
+
+ ctx := context.Background()
+ if _, err := m.db.NewDelete().Model((*models.CampaignPlayer)(nil)).
+ Where("campaign_id = ?", campaignID).Exec(ctx); err != nil {
+ log.Printf("campaign_deny_modal: failed to delete campaign players for %s: %v", campaignID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignApproveError)
+ return
+ }
+
+ if err := db.Delete[models.Campaign](m.db, campaignID); err != nil {
+ log.Printf("campaign_deny_modal: failed to delete campaign %s: %v", campaignID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignApproveError)
+ return
+ }
+
+ m.dispatcher.Push(dispatch.DirectMessage{
+ ID: campaignID,
+ Sender: userID,
+ Target: campaign.DungeonMaster,
+ Content: fmt.Sprintf(messages.CampaignDeniedDMMessage, campaign.Name, reason),
+ })
+
+ 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
new file mode 100644
index 0000000..7909b59
--- /dev/null
+++ b/internal/interactions/campaign_approve_test.go
@@ -0,0 +1,268 @@
+package interactions
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/uptrace/bun"
+
+ "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"
+)
+
+/*
+Unit Testing
+Campaign approval/denial handlers
+
+These tests exercise the ComponentHandler path end-to-end, up to the Discord session boundary.
+The session is swapped for testutil.StubSession so REST calls are captured in memory
+rather than hitting Discord.
+
+SafeMode is enabled for every test so guard-wrapped mutations (channel/thread creation during
+approval) short-circuit.
+
+Dispatcher inspection
+
+ Construct the dispatcher without calling Start(), so Push()'d DMs accumulate in the
+ pending stack where Pending() can read them.
+
+ This keeps tests deterministic. No worker goroutines, therefore no session sends.
+*/
+
+const (
+ testGuildID = "guild-1"
+ testModID = "mod-1"
+ testDMID = "dm-1"
+ testCampID = "camp-1"
+)
+
+/*
+approveFixture seeds a minimal DB with a mod, a DM, and a pending campaign
+owned by that DM.
+
+Returns the dispatcher so tests can inspect queued DMs.
+*/
+func approveFixture(t *testing.T, database *bun.DB, modRole models.ServerRole, modBanned bool) *dispatch.Dispatcher {
+ t.Helper()
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(&models.Player{ID: testModID, Role: modRole, PlayerIsBanned: modBanned}).Exec(ctx)
+ require.NoError(t, err)
+
+ _, err = database.NewInsert().Model(&models.Player{ID: testDMID, Role: models.ServerRolePlayer}).Exec(ctx)
+ require.NoError(t, err)
+
+ _, err = database.NewInsert().Model(&models.Campaign{
+ ID: testCampID,
+ Name: "Test Campaign",
+ Tag: "test",
+ DungeonMaster: testDMID,
+ IsApproved: false,
+ }).Exec(ctx)
+ require.NoError(t, err)
+
+ _, err = database.NewInsert().Model(&models.CampaignPlayer{
+ PlayerID: testDMID,
+ CampaignID: testCampID,
+ Role: models.RoleDM,
+ Status: models.StatusActive,
+ }).Exec(ctx)
+ require.NoError(t, err)
+
+ return dispatch.NewDispatcher(nil, 1)
+}
+
+/*
+buildApproveInteraction constructs a button-click InteractionCreate targeting
+campaign_approve with the DM-interaction shape (User populated, Member nil).
+*/
+func buildApproveInteraction(userID, guildID, campaignID string) *discordgo.InteractionCreate {
+ return &discordgo.InteractionCreate{
+ Interaction: &discordgo.Interaction{
+ Type: discordgo.InteractionMessageComponent,
+ User: &discordgo.User{ID: userID},
+ Data: discordgo.MessageComponentInteractionData{
+ CustomID: messages.CampaignApprovePrefix + ":" + guildID + ":" + campaignID,
+ ComponentType: discordgo.ButtonComponent,
+ },
+ },
+ }
+}
+
+/*
+buildDenyModalInteraction constructs a modal-submit InteractionCreate with
+the deny-reason text field populated.
+*/
+func buildDenyModalInteraction(userID, guildID, campaignID, reason string) *discordgo.InteractionCreate {
+ return &discordgo.InteractionCreate{
+ Interaction: &discordgo.Interaction{
+ Type: discordgo.InteractionModalSubmit,
+ User: &discordgo.User{ID: userID},
+ Data: discordgo.ModalSubmitInteractionData{
+ CustomID: messages.CampaignDenyModalPrefix + ":" + guildID + ":" + campaignID,
+ Components: []discordgo.MessageComponent{
+ &discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ &discordgo.TextInput{
+ CustomID: messages.CampaignDenyReasonFieldID,
+ Value: reason,
+ },
+ }},
+ },
+ },
+ },
+ }
+}
+
+/*
+Mod approves a pending campaign.
+
+When:
+
+ A mod clicks the Approve button on a pending campaign's DM.
+
+Expected:
+
+ Campaign.IsApproved flips to true; a DM is queued to the campaign's DM
+ announcing approval.
+*/
+func TestCampaignApprove_ModAuthorized(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ dispatcher := approveFixture(t, database, models.ServerRoleMod, false)
+ stub := testutil.NewStubSession(t)
+
+ handler := &campaignApprove{db: database, dispatcher: dispatcher}
+ handler.HandleComponents(stub.Session, buildApproveInteraction(testModID, testGuildID, testCampID))
+
+ reloaded, err := db.GetByID[models.Campaign](database, testCampID)
+ require.NoError(t, err)
+ assert.True(t, reloaded.IsApproved, "campaign should be approved after mod click")
+
+ pending := dispatcher.Pending()
+ require.Len(t, pending, 1, "one approval DM should be queued")
+ assert.Equal(t, testDMID, pending[0].Target, "DM should be addressed to the campaign's DM")
+ assert.Contains(t, pending[0].Content, "Test Campaign")
+}
+
+/*
+Plain player cannot approve.
+
+When:
+
+ A regular player (ServerRolePlayer) clicks Approve β e.g. via a forged
+ CustomID or leaked DM button.
+
+Expected:
+
+ Campaign stays unapproved; no DM queued.
+*/
+func TestCampaignApprove_UnauthorizedRejected(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ dispatcher := approveFixture(t, database, models.ServerRolePlayer, false)
+ stub := testutil.NewStubSession(t)
+
+ handler := &campaignApprove{db: database, dispatcher: dispatcher}
+ handler.HandleComponents(stub.Session, buildApproveInteraction(testModID, testGuildID, testCampID))
+
+ reloaded, err := db.GetByID[models.Campaign](database, testCampID)
+ require.NoError(t, err)
+ assert.False(t, reloaded.IsApproved, "plain player must not approve")
+ assert.Empty(t, dispatcher.Pending(), "no DM should be queued on rejection")
+}
+
+/*
+Banned mod cannot approve.
+
+When:
+
+ A user with ServerRoleMod but PlayerIsBanned=true clicks Approve.
+
+Expected:
+
+ Global ban overrides role; campaign stays unapproved.
+*/
+func TestCampaignApprove_BannedModRejected(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ dispatcher := approveFixture(t, database, models.ServerRoleMod, true)
+ stub := testutil.NewStubSession(t)
+
+ handler := &campaignApprove{db: database, dispatcher: dispatcher}
+ handler.HandleComponents(stub.Session, buildApproveInteraction(testModID, testGuildID, testCampID))
+
+ reloaded, err := db.GetByID[models.Campaign](database, testCampID)
+ require.NoError(t, err)
+ assert.False(t, reloaded.IsApproved, "globally-banned mod must not approve")
+ assert.Empty(t, dispatcher.Pending())
+}
+
+/*
+Deny modal captures the reason and forwards it to the DM.
+
+When:
+
+ A mod submits the deny modal with a typed reason.
+
+Expected:
+
+ Campaign row (and its CampaignPlayers) is deleted; a DM is queued to the
+ campaign's DM containing the reason text.
+*/
+func TestCampaignDenyModal_ReasonCaptured(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ dispatcher := approveFixture(t, database, models.ServerRoleMod, false)
+ stub := testutil.NewStubSession(t)
+
+ reason := "duplicate of an existing campaign"
+ handler := &campaignDenyModal{db: database, dispatcher: dispatcher}
+ handler.HandleModal(stub.Session, buildDenyModalInteraction(testModID, testGuildID, testCampID, reason))
+
+ _, err := db.GetByID[models.Campaign](database, testCampID)
+ assert.Error(t, err, "campaign row should be deleted after denial")
+
+ pending := dispatcher.Pending()
+ require.Len(t, pending, 1, "one denial DM should be queued")
+ assert.Equal(t, testDMID, pending[0].Target)
+ assert.True(t, strings.Contains(pending[0].Content, reason), "denial DM should include the mod's reason, got %q", pending[0].Content)
+}
+
+/*
+Non-mod cannot deny.
+
+When:
+
+ A plain player submits the deny modal via a forged CustomID.
+
+Expected:
+
+ Campaign is still present; no DM queued.
+*/
+func TestCampaignDenyModal_UnauthorizedRejected(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ dispatcher := approveFixture(t, database, models.ServerRolePlayer, false)
+ stub := testutil.NewStubSession(t)
+
+ handler := &campaignDenyModal{db: database, dispatcher: dispatcher}
+ handler.HandleModal(stub.Session, buildDenyModalInteraction(testModID, testGuildID, testCampID, "no reason given"))
+
+ reloaded, err := db.GetByID[models.Campaign](database, testCampID)
+ require.NoError(t, err, "campaign must survive an unauthorized deny attempt")
+ assert.False(t, reloaded.IsApproved)
+ assert.Empty(t, dispatcher.Pending())
+}
diff --git a/internal/interactions/campaign_detail.go b/internal/interactions/campaign_detail.go
new file mode 100644
index 0000000..e995522
--- /dev/null
+++ b/internal/interactions/campaign_detail.go
@@ -0,0 +1,97 @@
+package interactions
+
+import (
+ "log"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "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"
+)
+
+/*
+ Flow:
+ Triggered when a viewer drills into a single campaign.
+ From the browse select menu (campaigns_browse.go), search results, or any nav target
+ pointing at ViewCampaignDetail.
+ 1. RenderCampaignDetail(database, campaignID):
+ a. Loads the campaign; replies CampaignNotFound if missing.
+ b. Gate: must be approved OR archived. Pending campaigns are hidden
+ from public detail view.
+ c. Loads players + cover URL.
+ d. If the viewer is a member, loads their CampaignPlayer to surface
+ their assigned token URL and personal sheet URL into the embed/buttons.
+ e. Builds embed via commands.CampaignEmbed; if archived, stamps the
+ ArchivedFooter.
+ f. Builds context-aware buttons via commands.CampaignButtons (Join/Leave/
+ Manage/Sheet depending on viewer + state).
+ g. [Back -> ViewCampaignsBrowse(all)]
+ h. Replies with UpdateMessage so it replaces the current ephemeral view.
+
+ Notes:
+ - The button set is intentionally computed in the commands package so it
+ stays consistent between the slash-command detail view and this
+ interaction-driven view.
+*/
+
+/*
+RenderCampaignDetail shows a campaign embed with context-aware buttons.
+
+Uses InteractionResponseUpdateMessage so it replaces the current message.
+*/
+func RenderCampaignDetail(s *discordgo.Session, i *discordgo.InteractionCreate, database *bun.DB, campaignID string) {
+ campaign, err := db.GetByID[models.Campaign](database, campaignID)
+ if err != nil {
+ log.Printf("campaign_detail: campaign %s not found: %v", campaignID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignNotFoundMessage)
+ return
+ }
+
+ if !campaign.IsApproved && !campaign.IsArchived {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignNotFoundMessage)
+ return
+ }
+
+ players, err := models.GetCampaignPlayers(database, campaign.ID)
+ if err != nil {
+ log.Printf("campaign_detail: failed to load players for %s: %v", campaignID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignPlayersLoadError)
+ return
+ }
+
+ userID := helpers.GetUserID(i)
+ coverURL := models.CoverURLForCampaign(database, campaign.ID)
+
+ var viewerTokenURL, viewerSheetURL string
+ if cp, err := models.GetCampaignPlayer(database, userID, campaign.ID); err == nil {
+ viewerSheetURL = cp.SheetURL
+ if cp.MediaID != "" {
+ if media, err := db.GetByID[models.Media](database, cp.MediaID); err == nil {
+ viewerTokenURL = media.URL
+ }
+ }
+ }
+
+ embed := commands.CampaignEmbed(*campaign, players, coverURL, viewerTokenURL, userID)
+ if campaign.IsArchived {
+ embed.Footer = &discordgo.MessageEmbedFooter{Text: messages.CampaignArchivedFooter}
+ }
+
+ components := commands.CampaignButtons(userID, *campaign, players, viewerSheetURL)
+ components = append(components, helpers.BackRow(router.ViewCampaignsBrowse, "all"))
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: "",
+ Embeds: []*discordgo.MessageEmbed{embed},
+ Components: components,
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
diff --git a/internal/interactions/campaign_invite.go b/internal/interactions/campaign_invite.go
new file mode 100644
index 0000000..d8c1e2a
--- /dev/null
+++ b/internal/interactions/campaign_invite.go
@@ -0,0 +1,352 @@
+package interactions
+
+/*
+ Player invitation flow.
+
+ Flow:
+ 1. DM clicks "Invite Player" on manage menu β user select dropdown.
+ 2. DM picks a user β validate (registered, not in campaign, campaign not full).
+ 3. Create CampaignPlayer with StatusPending, send invitation DM with Accept/Decline.
+ 4. Target clicks Accept β status becomes active, role assigned, DM message updated.
+ 5. Target clicks Decline β CampaignPlayer removed, DM message updated.
+
+ CustomID formats:
+ manage_invite:
+ manage_invite_select:
+ campaign_invite_accept::
+ campaign_invite_decline::
+*/
+
+import (
+ "fmt"
+ "log"
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/google/uuid"
+ "github.com/uptrace/bun"
+
+ "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.
+type manageCampaignInvite struct {
+ db *bun.DB
+ dispatcher *dispatch.Dispatcher
+}
+
+func (h *manageCampaignInvite) CustomIDPrefix() string {
+ return messages.ManageInvitePrefix
+}
+
+func (h *manageCampaignInvite) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ campaignID := parts[1]
+
+ campaign, ok := helpers.LoadDMCampaign(s, i, h.db, campaignID)
+ if !ok {
+ return
+ }
+
+ if !helpers.IsCampaignMutable(s, i, campaign) {
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf(messages.ManageInviteSelectPrompt, campaign.Name),
+ Embeds: []*discordgo.MessageEmbed{},
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.SelectMenu{
+ MenuType: discordgo.UserSelectMenu,
+ CustomID: fmt.Sprintf("%s:%s", messages.ManageInviteSelectPrefix, campaignID),
+ Placeholder: "Select a player...",
+ },
+ }},
+ helpers.BackRow(router.ViewManageCampaign, campaignID),
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+// manageCampaignInviteSelect handles the user selection from the invite dropdown.
+type manageCampaignInviteSelect struct {
+ db *bun.DB
+ dispatcher *dispatch.Dispatcher
+}
+
+func (h *manageCampaignInviteSelect) CustomIDPrefix() string {
+ return messages.ManageInviteSelectPrefix
+}
+
+func (h *manageCampaignInviteSelect) 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
+ }
+
+ values := i.MessageComponentData().Values
+ if len(values) == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.InvalidButtonDataMessage)
+ return
+ }
+ targetID := values[0]
+
+ if !helpers.IsCampaignMutable(s, i, campaign) {
+ return
+ }
+
+ /*
+ Guard clauses:
+ 1. Target must be registered.
+ */
+ if _, err := db.GetByID[models.Player](h.db, targetID); err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.AddPlayerTargetNotRegistered)
+ return
+ }
+
+ // 2. Target must not already be in the campaign.
+ players, err := models.GetCampaignPlayers(h.db, campaignID)
+ if err != nil {
+ log.Printf("campaign_invite_select: failed to load players: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ activeCount := 0
+ for _, p := range players {
+ if p.PlayerID == targetID {
+ helpers.RespondUpdateTerminal(s, i, messages.AddPlayerAlreadyInCampaign)
+ return
+ }
+ if p.Status == models.StatusActive {
+ activeCount++
+ }
+ }
+
+ // 3. Campaign must not be full.
+ if campaign.Slots > 0 && activeCount >= campaign.Slots && !campaign.CanOverflow {
+ helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.InviteCampaignFull, campaign.Name))
+ return
+ }
+
+ // 4. Create pending membership.
+ cp := &models.CampaignPlayer{
+ PlayerID: targetID,
+ CampaignID: campaignID,
+ Role: models.RolePlayer,
+ Status: models.StatusPending,
+ }
+ if err := db.Insert(h.db, cp); err != nil {
+ log.Printf("campaign_invite_select: failed to insert campaign player: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ /*
+ Send invitation DM with accept/decline buttons.
+ */
+ guildID := i.GuildID
+ h.dispatcher.Push(dispatch.DirectMessage{
+ ID: uuid.NewString(),
+ Sender: userID,
+ Target: targetID,
+ Content: fmt.Sprintf(messages.InviteDMMessage, campaign.Name, userID),
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: "Accept",
+ Style: discordgo.SuccessButton,
+ CustomID: fmt.Sprintf("%s:%s:%s", messages.InviteAcceptPrefix, guildID, campaignID),
+ },
+ discordgo.Button{
+ Label: "Decline",
+ Style: discordgo.DangerButton,
+ CustomID: fmt.Sprintf("%s:%s:%s", messages.InviteDeclinePrefix, guildID, campaignID),
+ },
+ }},
+ },
+ })
+
+ helpers.RespondUpdate(s, i, fmt.Sprintf(messages.InviteSentMessage, targetID, campaign.Name), nil, []discordgo.MessageComponent{
+ helpers.BackRow(router.ViewManageCampaign, campaignID),
+ })
+}
+
+// campaignInviteAccept handles the "Accept" button on an invitation DM.
+type campaignInviteAccept struct {
+ db *bun.DB
+}
+
+func (h *campaignInviteAccept) CustomIDPrefix() string {
+ return messages.InviteAcceptPrefix
+}
+
+func (h *campaignInviteAccept) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ // CustomID: campaign_invite_accept::
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 3)
+ if !ok {
+ return
+ }
+ guildID := parts[1]
+ campaignID := parts[2]
+
+ var userID string
+ if i.User != nil {
+ userID = i.User.ID
+ } else if i.Member != nil {
+ userID = i.Member.User.ID
+ }
+
+ /*
+ Guard clause:
+ 1. Verify pending invitation exists.
+ */
+ players, err := models.GetCampaignPlayers(h.db, campaignID)
+ if err != nil {
+ log.Printf("campaign_invite_accept: failed to load players: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ found := false
+ for _, p := range players {
+ if p.PlayerID == userID && p.Status == models.StatusPending {
+ found = true
+ break
+ }
+ }
+ if !found {
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: messages.InviteAlreadyProcessed,
+ Components: []discordgo.MessageComponent{},
+ },
+ })
+ return
+ }
+
+ // 2. Activate membership.
+ if err := models.SetCampaignPlayerStatus(h.db, userID, campaignID, models.StatusActive); err != nil {
+ log.Printf("campaign_invite_accept: failed to set status: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ // 3. Assign campaign role if one exists.
+ campaign, err := db.GetByID[models.Campaign](h.db, campaignID)
+ if err == nil && campaign.RoleID != "" {
+ if err := guard.GuildMemberRoleAdd(s, guildID, userID, campaign.RoleID); err != nil {
+ log.Printf("campaign_invite_accept: failed to add role %s to %s: %v", campaign.RoleID, userID, err)
+ }
+ }
+
+ campaignName := campaignID
+ if campaign != nil {
+ campaignName = campaign.Name
+ }
+
+ log.Printf("campaign_invite_accept: %s accepted invitation to %s", userID, campaignID)
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf(messages.InviteAcceptedDMUpdate, campaignName),
+ Components: []discordgo.MessageComponent{},
+ },
+ })
+}
+
+// campaignInviteDecline handles the "Decline" button on an invitation DM.
+type campaignInviteDecline struct {
+ db *bun.DB
+}
+
+func (h *campaignInviteDecline) CustomIDPrefix() string {
+ return messages.InviteDeclinePrefix
+}
+
+func (h *campaignInviteDecline) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ // CustomID: campaign_invite_decline::
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 3)
+ if !ok {
+ return
+ }
+ campaignID := parts[2]
+
+ var userID string
+ if i.User != nil {
+ userID = i.User.ID
+ } else if i.Member != nil {
+ userID = i.Member.User.ID
+ }
+
+ /*
+ Guard clause:
+ 1. Verify pending invitation exists.
+ */
+ players, err := models.GetCampaignPlayers(h.db, campaignID)
+ if err != nil {
+ log.Printf("campaign_invite_decline: failed to load players: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ found := false
+ for _, p := range players {
+ if p.PlayerID == userID && p.Status == models.StatusPending {
+ found = true
+ break
+ }
+ }
+ if !found {
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: messages.InviteAlreadyProcessed,
+ Components: []discordgo.MessageComponent{},
+ },
+ })
+ return
+ }
+
+ // 2. Remove the pending membership.
+ if err := models.RemoveCampaignPlayer(h.db, userID, campaignID); err != nil {
+ log.Printf("campaign_invite_decline: failed to remove player: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ campaign, err := db.GetByID[models.Campaign](h.db, campaignID)
+ campaignName := campaignID
+ if err == nil {
+ campaignName = campaign.Name
+ }
+
+ log.Printf("campaign_invite_decline: %s declined invitation to %s", userID, campaignID)
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf(messages.InviteDeclinedDMUpdate, campaignName),
+ Components: []discordgo.MessageComponent{},
+ },
+ })
+}
diff --git a/internal/interactions/campaign_invite_test.go b/internal/interactions/campaign_invite_test.go
new file mode 100644
index 0000000..9b4ccbf
--- /dev/null
+++ b/internal/interactions/campaign_invite_test.go
@@ -0,0 +1,340 @@
+package interactions
+
+import (
+ "context"
+ "testing"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/uptrace/bun"
+
+ "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"
+)
+
+/*
+Unit Testing
+Campaign invite flow (select / accept / decline)
+
+Covers the three handler types that implement invitations:
+
+ - manageCampaignInviteSelect: DM picks a user from the dropdown.
+ - campaignInviteAccept: target clicks Accept on the invitation DM.
+ - campaignInviteDecline: target clicks Decline on the invitation DM.
+
+Auth is ScopeDM for the select step, and pending-membership presence for
+accept/decline. Slot overflow is enforced at select time.
+*/
+
+const testTargetID = "target-1"
+
+/*
+inviteFixture seeds a DM + a pending campaign with one DM membership row.
+
+Parameters let each test tune what's missing:
+
+ - a registered target,
+ - a full-capacity campaign, or
+ - an existing pending invitation.
+
+slots=0: means "unlimited" (no overflow check fires).
+*/
+func inviteFixture(t *testing.T, database *bun.DB, slots int, registerTarget bool) *dispatch.Dispatcher {
+ t.Helper()
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(&models.Player{ID: testDMID, Role: models.ServerRolePlayer}).Exec(ctx)
+ require.NoError(t, err)
+
+ if registerTarget {
+ _, err = database.NewInsert().Model(&models.Player{ID: testTargetID, Role: models.ServerRolePlayer}).Exec(ctx)
+ require.NoError(t, err)
+ }
+
+ _, err = database.NewInsert().Model(&models.Campaign{
+ ID: testCampID,
+ Name: "Invite Test",
+ Tag: "invite-test",
+ DungeonMaster: testDMID,
+ IsApproved: true,
+ Slots: slots,
+ }).Exec(ctx)
+ require.NoError(t, err)
+
+ _, err = database.NewInsert().Model(&models.CampaignPlayer{
+ PlayerID: testDMID,
+ CampaignID: testCampID,
+ Role: models.RoleDM,
+ Status: models.StatusActive,
+ }).Exec(ctx)
+ require.NoError(t, err)
+
+ return dispatch.NewDispatcher(nil, 1)
+}
+
+/*
+buildInviteSelect constructs a user-select InteractionCreate for the invite dropdown.
+The select handler reads CustomID + Values.
+GuildID is needed for the accept/decline buttons it builds.
+*/
+func buildInviteSelect(userID, guildID, campaignID string, selected []string) *discordgo.InteractionCreate {
+ return &discordgo.InteractionCreate{
+ Interaction: &discordgo.Interaction{
+ Type: discordgo.InteractionMessageComponent,
+ GuildID: guildID,
+ User: &discordgo.User{ID: userID},
+ Data: discordgo.MessageComponentInteractionData{
+ CustomID: messages.ManageInviteSelectPrefix + ":" + campaignID,
+ Values: selected,
+ },
+ },
+ }
+}
+
+/*
+buildInviteButton constructs a button-click InteractionCreate
+with the accept/decline CustomID format (prefix:guildID:campaignID).
+*/
+func buildInviteButton(prefix, userID, guildID, campaignID string) *discordgo.InteractionCreate {
+ return &discordgo.InteractionCreate{
+ Interaction: &discordgo.Interaction{
+ Type: discordgo.InteractionMessageComponent,
+ User: &discordgo.User{ID: userID},
+ Data: discordgo.MessageComponentInteractionData{
+ CustomID: prefix + ":" + guildID + ":" + campaignID,
+ ComponentType: discordgo.ButtonComponent,
+ },
+ },
+ }
+}
+
+/*
+DM invites a registered user.
+
+When:
+
+ The DM selects a registered, non-member target via the user-select dropdown.
+
+Expected:
+
+ A CampaignPlayer row with StatusPending is inserted; an invitation DM
+ with Accept/Decline buttons is queued for the target.
+*/
+func TestInviteSelect_DMAuthorized(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ dispatcher := inviteFixture(t, database, 4, true)
+ stub := testutil.NewStubSession(t)
+
+ handler := &manageCampaignInviteSelect{db: database, dispatcher: dispatcher}
+ handler.HandleComponents(stub.Session, buildInviteSelect(testDMID, testGuildID, testCampID, []string{testTargetID}))
+
+ players, err := models.GetCampaignPlayers(database, testCampID)
+ require.NoError(t, err)
+
+ var target *models.CampaignPlayer
+ for i := range players {
+ if players[i].PlayerID == testTargetID {
+ target = &players[i]
+ }
+ }
+ require.NotNil(t, target, "pending membership row should be created for the target")
+ assert.Equal(t, models.StatusPending, target.Status)
+ assert.Equal(t, models.RolePlayer, target.Role)
+
+ pending := dispatcher.Pending()
+ require.Len(t, pending, 1, "one invitation DM should be queued")
+ assert.Equal(t, testTargetID, pending[0].Target)
+ require.NotEmpty(t, pending[0].Components, "invitation DM should carry Accept/Decline buttons")
+}
+
+/*
+Non-DM cannot invite.
+
+When:
+
+ A plain player (not the campaign's DM) triggers the select handler via a
+ forged CustomID.
+
+Expected:
+
+ No pending row is created; no DM is queued.
+*/
+func TestInviteSelect_NonDMRejected(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ dispatcher := inviteFixture(t, database, 4, true)
+
+ // The attacker is registered but is NOT the DM of testCampID.
+ _, err := database.NewInsert().Model(&models.Player{ID: "attacker", Role: models.ServerRolePlayer}).Exec(context.Background())
+ require.NoError(t, err)
+
+ stub := testutil.NewStubSession(t)
+ handler := &manageCampaignInviteSelect{db: database, dispatcher: dispatcher}
+ handler.HandleComponents(stub.Session, buildInviteSelect("attacker", testGuildID, testCampID, []string{testTargetID}))
+
+ players, err := models.GetCampaignPlayers(database, testCampID)
+ require.NoError(t, err)
+ for _, p := range players {
+ assert.NotEqual(t, testTargetID, p.PlayerID, "no row should be created for target")
+ }
+ assert.Empty(t, dispatcher.Pending(), "no DM should be queued")
+}
+
+/*
+Unregistered target is rejected pre-insert.
+
+When:
+
+ DM selects a user ID that has no row in the players table.
+
+Expected:
+
+ No CampaignPlayer row is created; no DM queued.
+ The guard clause at campaign_invite.go:143 catches this before mutation.
+*/
+func TestInviteSelect_UnregisteredTargetGuarded(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ dispatcher := inviteFixture(t, database, 4, false) // note: target NOT registered
+ stub := testutil.NewStubSession(t)
+
+ handler := &manageCampaignInviteSelect{db: database, dispatcher: dispatcher}
+ handler.HandleComponents(stub.Session, buildInviteSelect(testDMID, testGuildID, testCampID, []string{testTargetID}))
+
+ players, err := models.GetCampaignPlayers(database, testCampID)
+ require.NoError(t, err)
+ for _, p := range players {
+ assert.NotEqual(t, testTargetID, p.PlayerID, "no row should be created for an unregistered user")
+ }
+ assert.Empty(t, dispatcher.Pending())
+}
+
+/*
+Full campaign rejects new invite.
+
+When:
+
+ Campaign has Slots=1, the DM already occupies that slot (active), and the
+ DM tries to invite another player. CanOverflow is false.
+
+Expected:
+
+ No pending row created; no DM queued. Overflow guard fires before insert.
+*/
+func TestInviteSelect_SlotOverflow(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ dispatcher := inviteFixture(t, database, 1, true) // Slots=1, DM already active
+ stub := testutil.NewStubSession(t)
+
+ handler := &manageCampaignInviteSelect{db: database, dispatcher: dispatcher}
+ handler.HandleComponents(stub.Session, buildInviteSelect(testDMID, testGuildID, testCampID, []string{testTargetID}))
+
+ players, err := models.GetCampaignPlayers(database, testCampID)
+ require.NoError(t, err)
+ for _, p := range players {
+ assert.NotEqual(t, testTargetID, p.PlayerID, "overflow guard should block new membership row")
+ }
+ assert.Empty(t, dispatcher.Pending())
+}
+
+/*
+Accept promotes pending to active.
+
+When:
+
+ A CampaignPlayer with StatusPending exists for the target, and the target
+ clicks Accept on the invitation DM.
+
+Expected:
+
+ The row's Status flips to StatusActive. No role assignment is attempted
+ because the fixture leaves Campaign.RoleID empty.
+*/
+func TestInviteAccept_CreatesMembership(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ dispatcher := inviteFixture(t, database, 0, true)
+
+ // Step: Seed the pending invitation that Accept is responding to.
+ _, err := database.NewInsert().Model(&models.CampaignPlayer{
+ PlayerID: testTargetID,
+ CampaignID: testCampID,
+ Role: models.RolePlayer,
+ Status: models.StatusPending,
+ }).Exec(context.Background())
+ require.NoError(t, err)
+
+ stub := testutil.NewStubSession(t)
+ handler := &campaignInviteAccept{db: database}
+ handler.HandleComponents(stub.Session, buildInviteButton(messages.InviteAcceptPrefix, testTargetID, testGuildID, testCampID))
+
+ players, err := models.GetCampaignPlayers(database, testCampID)
+ require.NoError(t, err)
+ var target *models.CampaignPlayer
+ for i := range players {
+ if players[i].PlayerID == testTargetID {
+ target = &players[i]
+ }
+ }
+ require.NotNil(t, target, "target membership row should still exist after accept")
+ assert.Equal(t, models.StatusActive, target.Status, "pending should be promoted to active")
+ _ = dispatcher // Note: fixture returns it but accept flow doesn't dispatch
+}
+
+/*
+Decline removes the pending row.
+
+When:
+
+ A pending CampaignPlayer row exists for the target, and the target
+ clicks Decline on the invitation DM.
+
+Expected:
+
+ The pending row is removed. No other rows are touched (DM's active row survives).
+*/
+func TestInviteDecline_RemovesPending(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ dispatcher := inviteFixture(t, database, 0, true)
+
+ _, err := database.NewInsert().Model(&models.CampaignPlayer{
+ PlayerID: testTargetID,
+ CampaignID: testCampID,
+ Role: models.RolePlayer,
+ Status: models.StatusPending,
+ }).Exec(context.Background())
+ require.NoError(t, err)
+
+ stub := testutil.NewStubSession(t)
+ handler := &campaignInviteDecline{db: database}
+ handler.HandleComponents(stub.Session, buildInviteButton(messages.InviteDeclinePrefix, testTargetID, testGuildID, testCampID))
+
+ players, err := models.GetCampaignPlayers(database, testCampID)
+ require.NoError(t, err)
+ for _, p := range players {
+ assert.NotEqual(t, testTargetID, p.PlayerID, "pending row should be removed after decline")
+ }
+
+ // Note: DM's own active row must survive the decline.
+ var dmPresent bool
+ for _, p := range players {
+ if p.PlayerID == testDMID && p.Status == models.StatusActive {
+ dmPresent = true
+ }
+ }
+ assert.True(t, dmPresent, "DM's active membership must not be touched by a target's decline")
+ _ = dispatcher
+}
diff --git a/internal/interactions/campaign_join.go b/internal/interactions/campaign_join.go
new file mode 100644
index 0000000..54577d5
--- /dev/null
+++ b/internal/interactions/campaign_join.go
@@ -0,0 +1,184 @@
+package interactions
+
+import (
+ "fmt"
+ "log"
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/google/uuid"
+ "github.com/uptrace/bun"
+
+ "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"
+)
+
+/*
+ Flow:
+ 1. User clicks `/campaign tag:X` to view campaign details.
+ 2. User clicks the "Join Campaign" button, triggering `campaign_join:X`.
+ 3. `campaignJoin` validates: user is registered, campaign exists & is open, user not already in it.
+ 4. Inserts CampaignPlayer record with StatusPending (awaiting DM approval).
+ 5. Responds to user ephemerally: "Your join request has been sent to the DM for approval."
+*/
+
+// campaignJoin handles when a player clicks "Join Campaign" on an open campaign.
+type campaignJoin struct {
+ db *bun.DB
+ dispatcher *dispatch.Dispatcher
+}
+
+func (h *campaignJoin) CustomIDPrefix() string {
+ return "campaign_join"
+}
+
+func (h *campaignJoin) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+
+ // split the custom ID in two: id and params
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ tag := parts[1]
+ userID := i.Member.User.ID
+
+ // Is the player registered and not globally banned?
+ ok, err := auth.Authorize(h.db, userID, auth.ScopePlayer, "")
+ if err != nil {
+ log.Printf("campaign_join: auth check failed: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ if !ok {
+ helpers.RespondNotRegistered(s, i)
+ return
+ }
+
+ // does the campaign exist and is it active?
+ campaign, err := db.GetByTag[models.Campaign](h.db, tag)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignNotFoundMessage)
+ return
+ }
+
+ if !campaign.IsApproved {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignNotFoundMessage)
+ return
+ }
+
+ if !helpers.IsCampaignMutable(s, i, campaign) {
+ return
+ }
+
+ // Check if the player has the campaign's linked Discord role.
+ hasLinkedRole := false
+ if campaign.RoleID != "" {
+ for _, roleID := range i.Member.Roles {
+ if roleID == campaign.RoleID {
+ hasLinkedRole = true
+ break
+ }
+ }
+ }
+
+ // Campaign must be open OR the player must have the linked role.
+ if !campaign.IsOpen && !hasLinkedRole {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignClosedMessage)
+ return
+ }
+
+ // is the player already a member?
+ players, err := models.GetCampaignPlayers(h.db, campaign.ID)
+ if err != nil {
+ log.Printf("campaign_join: %s: %v", messages.PlayerFetchErrorMessage, err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ for _, p := range players {
+ if p.PlayerID == userID {
+ if p.Status == models.StatusBanned {
+ helpers.RespondUpdateTerminal(s, i, messages.PlayerBannedMessage)
+ return
+ }
+ helpers.RespondUpdateTerminal(s, i, messages.PlayerAlreadyOnCampaignMessage)
+ return
+ }
+ }
+
+ /*
+ Roster cap.
+
+ Westmarches store math.MaxInt32, so this never trips for them;
+ their tripwire is SessionCapacity, evaluated below after admission.
+ */
+ activePlayerCount := 0
+ for _, p := range players {
+ if p.Status == models.StatusActive {
+ activePlayerCount++
+ }
+ }
+ if !campaign.IsWestmarch && campaign.Slots > 0 && activePlayerCount >= campaign.Slots {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignFullMessage)
+ return
+ }
+
+ cp := &models.CampaignPlayer{
+ PlayerID: userID,
+ CampaignID: campaign.ID,
+ Role: models.RolePlayer,
+ Status: models.StatusActive,
+ }
+ if err := db.Insert(h.db, cp); err != nil {
+ log.Printf("campaign_join: %s: %v", messages.InsertPlayerErrorMessage, err)
+ helpers.RespondUpdateTerminal(s, i, messages.PlayerFailedToJoinMessage)
+ return
+ }
+ newActiveCount := activePlayerCount + 1
+
+ // if the campaign has a role set already, assign it to the player.
+ if campaign.RoleID != "" {
+ if err := guard.GuildMemberRoleAdd(s, i.GuildID, userID, campaign.RoleID); err != nil {
+ log.Printf("campaign_join: failed to assign role %s to %s: %v", campaign.RoleID, userID, err)
+ }
+ }
+
+ // Auto-close when the last slot fills.
+ if !campaign.IsWestmarch && campaign.Slots > 0 && newActiveCount >= campaign.Slots && campaign.IsOpen {
+ campaign.IsOpen = false
+ if err := db.Update(h.db, campaign); err != nil {
+ log.Printf("campaign_join: auto-close failed for campaign %s: %v", campaign.ID, err)
+ } else {
+ h.dispatcher.Push(dispatch.DirectMessage{
+ ID: uuid.NewString(),
+ Target: campaign.DungeonMaster,
+ Content: fmt.Sprintf(messages.CampaignAutoClosedDM, campaign.Name, campaign.Slots),
+ })
+ }
+ }
+
+ // Westmarch tripwire: admit, then alert if the limit is passed.
+ if campaign.IsWestmarch && campaign.SessionCapacity > 0 && newActiveCount > campaign.SessionCapacity {
+ h.dispatcher.Push(dispatch.DirectMessage{
+ ID: uuid.NewString(),
+ Sender: userID,
+ Target: campaign.DungeonMaster,
+ Content: fmt.Sprintf(messages.WestmarchOverCapacityDMAlert,
+ userID, campaign.Name, newActiveCount, campaign.SessionCapacity),
+ })
+ helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.WestmarchOverCapacityPlayerNotice,
+ campaign.Name, campaign.SessionCapacity))
+ return
+ }
+
+ 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
new file mode 100644
index 0000000..029c0b3
--- /dev/null
+++ b/internal/interactions/campaign_join_test.go
@@ -0,0 +1,260 @@
+package interactions
+
+import (
+ "context"
+ "math"
+ "strings"
+ "testing"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/uptrace/bun"
+
+ "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"
+)
+
+/*
+Unit Testing
+Westmarch FCFS join flow with session-capacity tripwire.
+
+The tripwire is a soft alert: when the (N+1)th player joins past
+SessionCapacity, the join still succeeds, but a DM is dispatched to the
+campaign DM and the joining player gets an over-capacity ephemeral notice.
+
+The tripwire is westmarch-only. Non-westmarch campaigns retain the existing
+hard-rejection behaviour at Slots cap.
+*/
+
+const (
+ testJoinerID = "joiner-1"
+)
+
+/*
+westmarchJoinFixture seeds:
+
+ - the DM (testDMID),
+ - the joiner (testJoinerID),
+ - a westmarch campaign (testCampID, IsApproved=true, IsOpen=true) with
+ Slots=MaxInt32 and the supplied SessionCapacity,
+ - the DM as an active CampaignPlayer,
+ - prefillPlayers extra dummy active members so we can position the joiner
+ relative to capacity.
+
+prefillPlayers does NOT include the DM (DM is always seeded). To test the
+3rd joiner against capacity=2 with the DM filling seat 1, pass prefill=1
+(which adds one extra active member, so DM + 1 prefill = 2 active, joiner
+is the 3rd β tripwire).
+*/
+func westmarchJoinFixture(t *testing.T, database *bun.DB, sessionCapacity, prefillPlayers int) *dispatch.Dispatcher {
+ t.Helper()
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(&models.Player{ID: testDMID, Role: models.ServerRolePlayer}).Exec(ctx)
+ require.NoError(t, err)
+
+ _, err = database.NewInsert().Model(&models.Player{ID: testJoinerID, Role: models.ServerRolePlayer}).Exec(ctx)
+ require.NoError(t, err)
+
+ _, err = database.NewInsert().Model(&models.Campaign{
+ ID: testCampID,
+ Name: "Westmarch Test",
+ Tag: "westmarch-test",
+ DungeonMaster: testDMID,
+ IsApproved: true,
+ IsOpen: true,
+ IsWestmarch: true,
+ Slots: math.MaxInt32,
+ SessionCapacity: sessionCapacity,
+ }).Exec(ctx)
+ require.NoError(t, err)
+
+ _, err = database.NewInsert().Model(&models.CampaignPlayer{
+ PlayerID: testDMID,
+ CampaignID: testCampID,
+ Role: models.RoleDM,
+ Status: models.StatusActive,
+ }).Exec(ctx)
+ require.NoError(t, err)
+
+ for n := 0; n < prefillPlayers; n++ {
+ prefillID := "prefill-" + string(rune('a'+n))
+ _, err = database.NewInsert().Model(&models.Player{ID: prefillID, Role: models.ServerRolePlayer}).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(&models.CampaignPlayer{
+ PlayerID: prefillID,
+ CampaignID: testCampID,
+ Role: models.RolePlayer,
+ Status: models.StatusActive,
+ }).Exec(ctx)
+ require.NoError(t, err)
+ }
+
+ return dispatch.NewDispatcher(nil, 1)
+}
+
+/*
+buildJoinInteraction constructs a button-click InteractionCreate for the
+"Join Campaign" button.
+
+The handler reads i.Member.User.ID (not i.User).
+*/
+func buildJoinInteraction(userID, guildID, tag string) *discordgo.InteractionCreate {
+ return &discordgo.InteractionCreate{
+ Interaction: &discordgo.Interaction{
+ Type: discordgo.InteractionMessageComponent,
+ GuildID: guildID,
+ Member: &discordgo.Member{
+ User: &discordgo.User{ID: userID},
+ Roles: []string{},
+ },
+ Data: discordgo.MessageComponentInteractionData{
+ CustomID: "campaign_join:" + tag,
+ ComponentType: discordgo.ButtonComponent,
+ },
+ },
+ }
+}
+
+/*
+Joining within session capacity is silent.
+
+When:
+
+ A westmarch with SessionCapacity=6 has 1 active member (the DM); a
+ registered, non-banned player clicks Join.
+
+Expected:
+
+ The joiner is admitted (active row created), and no DM is dispatched
+ to the campaign DM. The active count after admission (2) is still
+ at-or-below capacity, so the tripwire never fires.
+*/
+func TestJoin_WestmarchUnderCapacityIsSilent(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ dispatcher := westmarchJoinFixture(t, database, 6, 0)
+ stub := testutil.NewStubSession(t)
+
+ handler := &campaignJoin{db: database, dispatcher: dispatcher}
+ handler.HandleComponents(stub.Session, buildJoinInteraction(testJoinerID, testGuildID, "westmarch-test"))
+
+ players, err := models.GetCampaignPlayers(database, testCampID)
+ require.NoError(t, err)
+ var joiner *models.CampaignPlayer
+ for i := range players {
+ if players[i].PlayerID == testJoinerID {
+ joiner = &players[i]
+ }
+ }
+ require.NotNil(t, joiner, "joiner should be admitted to the westmarch")
+ assert.Equal(t, models.StatusActive, joiner.Status)
+
+ assert.Empty(t, dispatcher.Pending(), "tripwire must not fire while under session capacity")
+}
+
+/*
+Crossing session capacity admits the player and DMs the DM.
+
+When:
+
+ A westmarch with SessionCapacity=2 has 2 active members (DM + 1
+ prefilled player). A registered, non-banned 3rd player clicks Join.
+
+Expected:
+
+ The 3rd player is admitted (active row created, engine never blocks).
+ One DM is dispatched, targeted at the campaign DM, and its content
+ includes the joiner mention, the campaign name, the new active count
+ (3), and the configured capacity (2).
+*/
+func TestJoin_WestmarchOverCapacityFiresTripwire(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ dispatcher := westmarchJoinFixture(t, database, 2, 1) // DM + 1 prefill = 2 active
+ stub := testutil.NewStubSession(t)
+
+ handler := &campaignJoin{db: database, dispatcher: dispatcher}
+ handler.HandleComponents(stub.Session, buildJoinInteraction(testJoinerID, testGuildID, "westmarch-test"))
+
+ players, err := models.GetCampaignPlayers(database, testCampID)
+ require.NoError(t, err)
+ var joiner *models.CampaignPlayer
+ for i := range players {
+ if players[i].PlayerID == testJoinerID {
+ joiner = &players[i]
+ }
+ }
+ require.NotNil(t, joiner, "tripwire is admit-then-alert; the joiner must still be admitted")
+ assert.Equal(t, models.StatusActive, joiner.Status)
+
+ pending := dispatcher.Pending()
+ require.Len(t, pending, 1, "exactly one over-capacity DM should be queued")
+ assert.Equal(t, testDMID, pending[0].Target, "DM alert must be targeted at the campaign DM")
+ assert.Contains(t, pending[0].Content, testJoinerID, "DM alert should mention the joining player")
+ assert.Contains(t, pending[0].Content, "Westmarch Test", "DM alert should name the campaign")
+ assert.True(t,
+ strings.Contains(pending[0].Content, "3") && strings.Contains(pending[0].Content, "2"),
+ "DM alert should include both the new active count (3) and the configured capacity (2)")
+}
+
+/*
+Non-westmarch campaigns still hard-reject at Slots cap.
+
+When:
+
+ A non-westmarch campaign has Slots=1 and the DM occupies the only seat.
+ A registered player clicks Join.
+
+Expected:
+
+ The joiner is NOT admitted (existing rejection path preserved). No DM
+ is dispatched (tripwire is westmarch-only).
+*/
+func TestJoin_NonWestmarchHardRejectionPreserved(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(&models.Player{ID: testDMID, Role: models.ServerRolePlayer}).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(&models.Player{ID: testJoinerID, Role: models.ServerRolePlayer}).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(&models.Campaign{
+ ID: testCampID,
+ Name: "Regular Campaign",
+ Tag: "regular-test",
+ DungeonMaster: testDMID,
+ IsApproved: true,
+ IsOpen: true,
+ IsWestmarch: false,
+ Slots: 1,
+ }).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(&models.CampaignPlayer{
+ PlayerID: testDMID,
+ CampaignID: testCampID,
+ Role: models.RoleDM,
+ Status: models.StatusActive,
+ }).Exec(ctx)
+ require.NoError(t, err)
+
+ dispatcher := dispatch.NewDispatcher(nil, 1)
+ stub := testutil.NewStubSession(t)
+
+ handler := &campaignJoin{db: database, dispatcher: dispatcher}
+ handler.HandleComponents(stub.Session, buildJoinInteraction(testJoinerID, testGuildID, "regular-test"))
+
+ players, err := models.GetCampaignPlayers(database, testCampID)
+ require.NoError(t, err)
+ for _, p := range players {
+ assert.NotEqual(t, testJoinerID, p.PlayerID, "non-westmarch full campaign must reject the joiner")
+ }
+ assert.Empty(t, dispatcher.Pending(), "tripwire must not fire on non-westmarch campaigns")
+}
diff --git a/internal/interactions/campaign_leave.go b/internal/interactions/campaign_leave.go
new file mode 100644
index 0000000..7dd8b57
--- /dev/null
+++ b/internal/interactions/campaign_leave.go
@@ -0,0 +1,81 @@
+package interactions
+
+import (
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+ "fmt"
+ "log"
+
+ "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/guard"
+ "github.com/framebuffers/moontracer/internal/manager/models"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+ Flow:
+ 1. User clicks `/campaign tag:X` to view campaign details.
+ 2. User clicks the "Leave Campaign" button, triggering `campaign_leave:X`.
+ 3. `campaignLeave` validates: campaign exists, user is active member, user is not the DM.
+ 4. Deletes the CampaignPlayer record for that user.
+ 5. Responds to user ephemerally: "You have left campaign ".
+*/
+
+// campaignLeave handles when a player clicks "Leave Campaign" to remove themselves.
+type campaignLeave struct {
+ db *bun.DB
+}
+
+func (h *campaignLeave) CustomIDPrefix() string {
+ return "campaign_leave"
+}
+
+func (h *campaignLeave) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ tag := parts[1]
+ userID := i.Member.User.ID
+
+ campaign, err := db.GetByTag[models.Campaign](h.db, tag)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignNotFoundMessage)
+ return
+ }
+
+ if !campaign.IsApproved {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignNotFoundMessage)
+ return
+ }
+
+ // DMs cannot leave their own campaign.
+ isDM, err := auth.Authorize(h.db, userID, auth.ScopeDM, campaign.ID)
+ if err != nil {
+ log.Printf("campaign_leave: auth check failed: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ if isDM {
+ helpers.RespondUpdateTerminal(s, i, messages.MasterIsLeavingCampaignErrorMessage)
+ return
+ }
+
+ if err := models.RemoveCampaignPlayer(h.db, userID, campaign.ID); err != nil {
+ log.Printf("campaign_leave: %s: %v", messages.LeavingCampaignErrorMessage, err)
+ helpers.RespondUpdateTerminal(s, i, messages.FailedToLeaveCampaignErrorMessage)
+ return
+ }
+
+ // Remove the campaign's linked Discord role if one exists.
+ if campaign.RoleID != "" {
+ if err := guard.GuildMemberRoleRemove(s, i.GuildID, userID, campaign.RoleID); err != nil {
+ log.Printf("campaign_leave: failed to remove role %s from %s: %v", campaign.RoleID, userID, err)
+ }
+ }
+
+ helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.PlayerLeftCampaignMessage, campaign.Name))
+}
diff --git a/internal/interactions/campaign_reschedule.go b/internal/interactions/campaign_reschedule.go
new file mode 100644
index 0000000..b2dfeb6
--- /dev/null
+++ b/internal/interactions/campaign_reschedule.go
@@ -0,0 +1,222 @@
+package interactions
+
+import (
+ "fmt"
+ "log"
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+ "strconv"
+ "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/manager/models"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+ Flow:
+ 1. DM opens `/managecampaigns`, selects a campaign, clicks "Reschedule".
+ 2. `manageCampaignReschedule` catches `manage_reschedule:`, authorizes (ScopeDM).
+ 3. Opens a modal with four fields: day of week, start time, duration, frequency.
+ 4. DM submits the modal, triggering `manage_reschedule_modal:`.
+ 5. `manageCampaignRescheduleModal` parses and validates all four fields.
+ 6. Updates the campaign's schedule in the DB via `db.Update`.
+ 7. Responds to the DM ephemerally: "Schedule updated for X: Saturday 19:00 UTC (3h), weekly."
+*/
+
+/*
+manageCampaignReschedule opens a modal for the DM to update the campaign schedule.
+
+Custom ID format: manage_reschedule:
+*/
+type manageCampaignReschedule struct {
+ db *bun.DB
+}
+
+func (h *manageCampaignReschedule) CustomIDPrefix() string {
+ return messages.RescheduleComponentPrefix
+}
+
+func (h *manageCampaignReschedule) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ campaignID := parts[1]
+ userID := i.Member.User.ID
+
+ ok, err := auth.Authorize(h.db, userID, auth.ScopeDM, campaignID)
+ if err != nil || !ok {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageNotAuthorized)
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseModal,
+ Data: &discordgo.InteractionResponseData{
+ CustomID: messages.RescheduleModalPrefix + ":" + campaignID,
+ Title: messages.RescheduleModalTitle,
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: messages.RescheduleDayFieldID,
+ Label: messages.RescheduleDayLabel,
+ Style: discordgo.TextInputShort,
+ Required: true,
+ Placeholder: messages.RescheduleDayPlaceholder,
+ },
+ }},
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: messages.RescheduleTimeFieldID,
+ Label: messages.RescheduleTimeLabel,
+ Style: discordgo.TextInputShort,
+ Required: true,
+ Placeholder: messages.RescheduleTimePlaceholder,
+ },
+ }},
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: messages.RescheduleDurFieldID,
+ Label: messages.RescheduleDurLabel,
+ Style: discordgo.TextInputShort,
+ Required: true,
+ Placeholder: messages.RescheduleDurPlaceholder,
+ },
+ }},
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: messages.RescheduleFreqFieldID,
+ Label: messages.RescheduleFreqLabel,
+ Style: discordgo.TextInputShort,
+ Required: true,
+ Placeholder: messages.RescheduleFreqPlaceholder,
+ },
+ }},
+ },
+ },
+ })
+}
+
+/*
+manageCampaignRescheduleModal handles the modal submission and updates the schedule.
+
+Custom ID format: manage_reschedule_modal:
+*/
+type manageCampaignRescheduleModal struct {
+ db *bun.DB
+}
+
+func (h *manageCampaignRescheduleModal) CustomIDPrefix() string {
+ return messages.RescheduleModalPrefix
+}
+
+func (h *manageCampaignRescheduleModal) HandleModal(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.ModalSubmitData().CustomID, 2)
+ if !ok {
+ return
+ }
+ campaignID := parts[1]
+
+ campaign, ok := helpers.LoadDMCampaign(s, i, h.db, campaignID)
+ if !ok {
+ return
+ }
+ if !helpers.IsCampaignMutable(s, i, campaign) {
+ return
+ }
+
+ var dayStr, timeStr, durStr, freqStr string
+ for _, row := range i.ModalSubmitData().Components {
+ for _, comp := range row.(*discordgo.ActionsRow).Components {
+ input := comp.(*discordgo.TextInput)
+ switch input.CustomID {
+ case messages.RescheduleDayFieldID:
+ dayStr = strings.TrimSpace(input.Value)
+ case messages.RescheduleTimeFieldID:
+ timeStr = strings.TrimSpace(input.Value)
+ case messages.RescheduleDurFieldID:
+ durStr = strings.TrimSpace(input.Value)
+ case messages.RescheduleFreqFieldID:
+ freqStr = strings.TrimSpace(strings.ToLower(input.Value))
+ }
+ }
+ }
+
+ day, ok := parseDayOfWeek(dayStr)
+ if !ok {
+ helpers.RespondUpdateTerminal(s, i, messages.RescheduleInvalidDay)
+ return
+ }
+
+ if !isValidTime(timeStr) {
+ helpers.RespondUpdateTerminal(s, i, messages.RescheduleInvalidTime)
+ return
+ }
+
+ duration, err := strconv.ParseFloat(durStr, 64)
+ if err != nil || duration <= 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.RescheduleInvalidDuration)
+ return
+ }
+
+ freq := models.CampaignFrequency(freqStr)
+ if !isValidFrequency(freq) {
+ helpers.RespondUpdateTerminal(s, i, messages.RescheduleInvalidFrequency)
+ return
+ }
+
+ campaign.Schedule.DayOfWeek = day
+ campaign.Schedule.StartTime = timeStr
+ campaign.Schedule.DurationHours = duration
+ campaign.Schedule.Frequency = freq
+
+ if err := db.Update(h.db, campaign); err != nil {
+ log.Printf("campaign_reschedule: failed to update schedule: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.RescheduleError)
+ return
+ }
+
+ dayName := campaign.Schedule.DayName()
+ helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.RescheduleSuccess, campaign.Name, dayName, timeStr, durStr, freqStr))
+}
+
+func isValidTime(t string) bool {
+ parts := strings.SplitN(t, ":", 2)
+ if len(parts) != 2 {
+ return false
+ }
+ h, err := strconv.Atoi(parts[0])
+ if err != nil || h < 0 || h > 23 {
+ return false
+ }
+ m, err := strconv.Atoi(parts[1])
+ if err != nil || m < 0 || m > 59 {
+ return false
+ }
+ return true
+}
+
+func parseDayOfWeek(s string) (int, bool) {
+ s = strings.ToLower(strings.TrimSpace(s))
+ if n, ok := messages.DayOfWeekInput[s]; ok {
+ return n, true
+ }
+ // also accept numeric for backwards compat
+ n, err := strconv.Atoi(s)
+ if err == nil && n >= 0 && n <= 6 {
+ return n, true
+ }
+ return 0, false
+}
+
+func isValidFrequency(f models.CampaignFrequency) bool {
+ switch f {
+ case models.Weekly, models.Biweekly, models.Monthly, models.Quarterly, models.Yearly:
+ return true
+ }
+ return false
+}
diff --git a/internal/interactions/campaign_threads.go b/internal/interactions/campaign_threads.go
new file mode 100644
index 0000000..2ae6409
--- /dev/null
+++ b/internal/interactions/campaign_threads.go
@@ -0,0 +1,267 @@
+package interactions
+
+/*
+ Campaign channel + thread creation helpers.
+
+ Called during campaign approval (campaign_approve.go) to set up the
+ Discord category, text channel, and standard threads for a new campaign.
+*/
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+
+ "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{"welcome", "announcements", "sessions", "dice-rolls", "general"}
+
+// 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,
+}
+
+/*
+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.
+
+Exported so the approval handler can call it independently of channel setup.
+*/
+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})
+ if err != nil {
+ 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: 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 {
+ return fmt.Errorf("resolve category: %w", err)
+ }
+ c.CategoryID = categoryID
+
+ 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 {
+ 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, channelID, threadName, defaultArchiveDuration)
+ if err != nil {
+ 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)
+ }
+}
+
+/*
+findOrCreateCampaignsCategory returns the ID of the shared "Campaigns" Discord category,
+creating it if it doesn't already exist in the guild.
+*/
+func findOrCreateCampaignsCategory(s *discordgo.Session, guildID string) (string, error) {
+ channels, err := s.GuildChannels(guildID)
+ if err != nil {
+ return "", fmt.Errorf("fetch guild channels: %w", err)
+ }
+ for _, ch := range channels {
+ if ch.Type == discordgo.ChannelTypeGuildCategory &&
+ strings.EqualFold(ch.Name, messages.CampaignsCategoryName) {
+ return ch.ID, nil
+ }
+ }
+ cat, err := guard.GuildChannelCreateComplex(s, guildID, discordgo.GuildChannelCreateData{
+ Name: messages.CampaignsCategoryName,
+ Type: discordgo.ChannelTypeGuildCategory,
+ })
+ if err != nil {
+ return "", fmt.Errorf("create campaigns category: %w", err)
+ }
+ 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
new file mode 100644
index 0000000..4fd5405
--- /dev/null
+++ b/internal/interactions/campaign_toggle.go
@@ -0,0 +1,73 @@
+package interactions
+
+import (
+ "fmt"
+ "log"
+
+ "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"
+)
+
+/*
+ Flow:
+ 1. User clicks `/campaign tag:X` to view campaign details.
+ 2. DM clicks "Set as Open/Closed Campaign" button, triggering `campaign_toggle:X`.
+ 3. `campaignToggle` validates: campaign exists, user is the DM.
+ 4. Toggles campaign.IsOpen (open β closed, closed β open).
+ 5. Updates campaign in DB.
+ 6. Responds to DM ephemerally with the new status.
+*/
+
+// campaignToggle handles when a DM clicks to toggle a campaign between open/closed.
+type campaignToggle struct {
+ db *bun.DB
+}
+
+func (h *campaignToggle) CustomIDPrefix() string {
+ return "campaign_toggle"
+}
+
+func (h *campaignToggle) 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, err := db.GetByID[models.Campaign](h.db, campaignID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignNotFoundMessage)
+ return
+ }
+
+ ok, err = auth.Authorize(h.db, userID, auth.ScopeDM, campaign.ID)
+ if err != nil {
+ log.Printf("campaign_toggle: auth check failed: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ if !ok {
+ helpers.RespondUpdateTerminal(s, i, messages.MasterCanToggleStatusErrorMessage)
+ return
+ }
+
+ campaign.IsOpen = !campaign.IsOpen
+ if err := db.Update(h.db, campaign); err != nil {
+ log.Printf("campaign_toggle: %s: %v", messages.CampaignUpdateErrorMessage, err)
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignUpdateErrorMessage)
+ return
+ }
+
+ status := "closed"
+ if campaign.IsOpen {
+ status = "open"
+ }
+ helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.CampaignStatusMessage, campaign.Name, status))
+}
diff --git a/internal/interactions/campaign_view.go b/internal/interactions/campaign_view.go
new file mode 100644
index 0000000..2e0c935
--- /dev/null
+++ b/internal/interactions/campaign_view.go
@@ -0,0 +1,122 @@
+package interactions
+
+import (
+ "fmt"
+ "log"
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "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"
+)
+
+/*
+ Flow:
+ 1. User clicks `/mycampaigns`, getting a list of which ones the Player is a part of.
+ 2. User clicks a button, like `campaign_view:suvachi`
+ 3. `campaignView` catches it, parses the tag given by `CustomID`
+ 4. Fetches Player and Campaign data from the DB.
+ 5. Respons with an ephemeral view (only the player can see it).
+
+*/
+
+/*
+campaignView is a ComponentHandler that shows campaign details **only to the user** through an ephemeral embed.
+
+It's a quick preview of the campaigns a user is present on.
+
+The difference with `/campaign` is that it is a quick, private, simplified view of a Campaign data.
+*/
+type campaignView struct {
+ db *bun.DB
+}
+
+// CustomIDPrefix is the unique identifier used to route it through `handler.go`, identifying it as a ComponentHandler.
+func (h *campaignView) CustomIDPrefix() string {
+ return "campaign_view"
+}
+
+// HandleComponents handles the process of fetching data from the DB and composing the final interaction to be returned to Discord.
+func (h *campaignView) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ tag := parts[1]
+
+ campaign, err := db.GetByTag[models.Campaign](h.db, tag)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignNotFoundMessage)
+ return
+ }
+
+ if !campaign.IsApproved {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignNotFoundMessage)
+ return
+ }
+
+ players, err := models.GetCampaignPlayers(h.db, campaign.ID)
+ if err != nil {
+ log.Printf("campaign_view: %s: %v", messages.PlayerFetchErrorMessage, err)
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignLoadFailureErrorMessage)
+ return
+ }
+
+ coverURL := models.CoverURLForCampaign(h.db, campaign.ID)
+ embed := buildCampaignEmbed(*campaign, players, coverURL)
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: "",
+ Embeds: []*discordgo.MessageEmbed{embed},
+ Components: []discordgo.MessageComponent{helpers.BackRow(router.ViewMyCampaigns)},
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+// buildCampaignEmbed builds the ephemeral embed that the user will see, with all the Campaigns the Player is a player on.
+func buildCampaignEmbed(c models.Campaign, players []models.CampaignPlayer, coverURL string) *discordgo.MessageEmbed {
+ status := "Closed"
+ if c.IsOpen {
+ status = "Open"
+ }
+
+ campaignType := "Campaign"
+ if c.IsOneshot {
+ campaignType = "One-shot"
+ }
+
+ var playerLines []string
+ for _, p := range players {
+ playerLines = append(playerLines, fmt.Sprintf("<@%s> β %s (%s)", p.PlayerID, p.Role, p.Status))
+ }
+ playersValue := "None"
+ if len(playerLines) > 0 {
+ playersValue = strings.Join(playerLines, "\n")
+ }
+
+ embed := &discordgo.MessageEmbed{
+ Title: fmt.Sprintf("%s β %s", campaignType, c.Name),
+ Description: c.Description,
+ Color: 0x5865F2,
+ Fields: []*discordgo.MessageEmbedField{
+ {Name: "Tag", Value: c.Tag, Inline: true},
+ {Name: "DM", Value: fmt.Sprintf("<@%s>", c.DungeonMaster), Inline: true},
+ {Name: "Status", Value: status, Inline: true},
+ {Name: "Slots", Value: c.DisplaySlots(), Inline: true},
+ {Name: "Edition", Value: c.Game.Edition, Inline: true},
+ {Name: fmt.Sprintf("Players (%d)", len(players)), Value: playersValue, Inline: false},
+ },
+ }
+ if coverURL != "" {
+ embed.Image = &discordgo.MessageEmbedImage{URL: coverURL}
+ }
+ return embed
+}
diff --git a/internal/interactions/campaigns_browse.go b/internal/interactions/campaigns_browse.go
new file mode 100644
index 0000000..fbfff69
--- /dev/null
+++ b/internal/interactions/campaigns_browse.go
@@ -0,0 +1,140 @@
+package interactions
+
+import (
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+ "github.com/framebuffers/moontracer/internal/interactions/router"
+ "log"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/db"
+ "github.com/framebuffers/moontracer/internal/manager/models"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+ Flow:
+ Triggered from /me -> Campaigns -> Browse, or any nav target landing on
+ ViewCampaignsBrowse. The view is a two-control screen: a format filter
+ dropdown above a campaign select.
+ 1. RenderCampaignsBrowse(database, filter):
+ a. Loads every campaign, drops non-approved and archived ones.
+ b. Applies the format filter: "all" | "campaign" | "oneshot" | "westmarch".
+ "campaign" means !IsOneshot && !IsWestmarch (plain long-form).
+ c. Renders the filter SelectMenu with the current value marked Default
+ so the dropdown remembers the active filter across re-renders.
+ d. If any campaigns survived: appends a campaign-picker SelectMenu
+ built by BuildCampaignSelect. Otherwise swaps the header for
+ CampaignsNoneAvailable.
+ e. [Back -> ViewMeCampaigns]
+ 2. Filter change (campaigns_filter): campaignsFilterHandler re-invokes
+ RenderCampaignsBrowse with the new filter value (defaults to "all" if
+ the menu returned nothing).
+ 3. Campaign pick (campaign_select:...): campaignSelectHandler forwards
+ to RenderCampaignDetail (campaign_detail.go). "none" sentinel is
+ silently ignored (placeholder option from BuildCampaignSelect).
+*/
+
+// RenderCampaignsBrowse renders the campaign browse view with filter + select menu.
+func RenderCampaignsBrowse(s *discordgo.Session, i *discordgo.InteractionCreate, database *bun.DB, filter string) {
+ campaigns, err := db.GetAll[models.Campaign](database)
+ if err != nil {
+ log.Printf("campaigns_browse: failed to load campaigns: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ var filtered []models.Campaign
+ for _, c := range campaigns {
+ if !c.IsApproved || c.IsArchived {
+ continue
+ }
+ switch filter {
+ case "oneshot":
+ if !c.IsOneshot {
+ continue
+ }
+ case "westmarch":
+ if !c.IsWestmarch {
+ continue
+ }
+ case "campaign":
+ if c.IsOneshot || c.IsWestmarch {
+ continue
+ }
+ }
+ filtered = append(filtered, c)
+ }
+
+ filterMenu := discordgo.SelectMenu{
+ CustomID: messages.CampaignsFilterPrefix,
+ Placeholder: messages.CampaignsFilterPlaceholder,
+ Options: []discordgo.SelectMenuOption{
+ {Label: messages.CampaignsFilterAll, Value: "all", Default: filter == "all"},
+ {Label: messages.CampaignsFilterCampaign, Value: "campaign", Default: filter == "campaign"},
+ {Label: messages.CampaignsFilterOneshot, Value: "oneshot", Default: filter == "oneshot"},
+ {Label: messages.CampaignsFilterWestmarch, Value: "westmarch", Default: filter == "westmarch"},
+ },
+ }
+
+ components := []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{filterMenu}},
+ }
+
+ content := messages.CampaignsCommandDesc
+ if len(filtered) > 0 {
+ campaignSelect := BuildCampaignSelect(filtered, messages.CampaignSelectPrefix, messages.CampaignsSelectPlaceholder)
+ components = append(components, discordgo.ActionsRow{Components: []discordgo.MessageComponent{campaignSelect}})
+ } else {
+ content = messages.CampaignsNoneAvailable
+ }
+
+ components = append(components, helpers.BackRow(router.ViewMeCampaigns))
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: content,
+ Embeds: []*discordgo.MessageEmbed{},
+ Components: components,
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+// campaignsFilterHandler handles the format filter dropdown selection.
+type campaignsFilterHandler struct {
+ db *bun.DB
+}
+
+func (h *campaignsFilterHandler) CustomIDPrefix() string {
+ return messages.CampaignsFilterPrefix
+}
+
+func (h *campaignsFilterHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ values := i.MessageComponentData().Values
+ filter := "all"
+ if len(values) > 0 {
+ filter = values[0]
+ }
+ RenderCampaignsBrowse(s, i, h.db, filter)
+}
+
+// campaignSelectHandler handles when user picks a campaign from the browse dropdown.
+type campaignSelectHandler struct {
+ db *bun.DB
+}
+
+func (h *campaignSelectHandler) CustomIDPrefix() string {
+ return messages.CampaignSelectPrefix
+}
+
+func (h *campaignSelectHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ values := i.MessageComponentData().Values
+ if len(values) == 0 || values[0] == "none" {
+ return
+ }
+ campaignID := values[0]
+ RenderCampaignDetail(s, i, h.db, campaignID)
+}
diff --git a/internal/interactions/components.go b/internal/interactions/components.go
new file mode 100644
index 0000000..20fb543
--- /dev/null
+++ b/internal/interactions/components.go
@@ -0,0 +1,138 @@
+package interactions
+
+import (
+ "fmt"
+
+ "github.com/bwmarrin/discordgo"
+
+ "github.com/framebuffers/moontracer/internal/manager/models"
+)
+
+/*
+ Flow:
+ Triggered indirectly: this file holds the shared SelectMenu builders
+ used by every list-style view (browse, /mycampaigns, /managecampaigns,
+ manage-campaign-bans, etc.). No handlers here: only construction.
+
+ 1. BuildCampaignSelect(campaigns, customID, placeholder):
+ a. Iterates the input, capped at Discord's 25-option limit.
+ b. Each option: Label = truncated name (<=100), Value = campaign ID,
+ Description = campaignOptionDescription (format + open/closed + slots).
+ c. Empty input -> single sentinel option {Value:"none", Default:true}
+ so the menu still renders. Callers must ignore "none" selections.
+
+ 2. BuildPlayerCampaignSelect(entries, customID, placeholder):
+ a. Same 25-cap + truncation rules.
+ b. Skips entries whose Campaign is nil or unapproved.
+ c. Description is "Role -- Status" (e.g. "DM -- Active").
+ d. Same "none" sentinel fallback for empty results.
+
+ 3. campaignOptionDescription: format/status/slots line.
+ truncate: "..." tail clipping at max chars.
+
+ Notes:
+ - The customID determines downstream routing: pass the same prefix the
+ matching ComponentHandler expects (campaign_select, manage_select, ...).
+ - Slot wording: only suffixes "slots" when 1..10; everything else uses
+ DisplaySlots() as-is (handles unlimited/closed/etc).
+*/
+
+/*
+BuildCampaignSelect builds a StringSelectMenu from a list of campaigns.
+
+customID determines routing (e.g. "campaign_select", "manage_select").
+*/
+func BuildCampaignSelect(campaigns []models.Campaign, customID, placeholder string) discordgo.SelectMenu {
+ var options []discordgo.SelectMenuOption
+
+ for _, c := range campaigns {
+ if len(options) >= 25 {
+ break // Discord limit
+ }
+
+ desc := campaignOptionDescription(c)
+ options = append(options, discordgo.SelectMenuOption{
+ Label: truncate(c.Name, 100),
+ Value: c.ID,
+ Description: truncate(desc, 100),
+ })
+ }
+
+ if len(options) == 0 {
+ options = append(options, discordgo.SelectMenuOption{
+ Label: "No campaigns available",
+ Value: "none",
+ Default: true,
+ })
+ }
+
+ return discordgo.SelectMenu{
+ CustomID: customID,
+ Placeholder: placeholder,
+ Options: options,
+ }
+}
+
+// BuildPlayerCampaignSelect builds a select menu from CampaignPlayer entries.
+func BuildPlayerCampaignSelect(entries []models.CampaignPlayer, customID, placeholder string) discordgo.SelectMenu {
+ var options []discordgo.SelectMenuOption
+
+ for _, e := range entries {
+ if len(options) >= 25 {
+ break
+ }
+ if e.Campaign == nil || !e.Campaign.IsApproved {
+ continue
+ }
+ name := e.Campaign.Name
+ desc := fmt.Sprintf("%s β %s", e.Role, e.Status)
+ options = append(options, discordgo.SelectMenuOption{
+ Label: truncate(name, 100),
+ Value: e.CampaignID,
+ Description: truncate(desc, 100),
+ })
+ }
+
+ if len(options) == 0 {
+ options = append(options, discordgo.SelectMenuOption{
+ Label: "No campaigns",
+ Value: "none",
+ Default: true,
+ })
+ }
+
+ return discordgo.SelectMenu{
+ CustomID: customID,
+ Placeholder: placeholder,
+ Options: options,
+ }
+}
+
+func campaignOptionDescription(c models.Campaign) string {
+ format := "Campaign"
+ if c.IsOneshot {
+ format = "One-shot"
+ }
+ if c.IsWestmarch {
+ format = "Westmarch"
+ }
+
+ status := "Open"
+ if !c.IsOpen {
+ status = "Closed"
+ }
+
+ slots := c.DisplaySlots()
+ if c.Slots > 0 && c.Slots <= 10 {
+ slots = fmt.Sprintf("%s slots", slots)
+ }
+
+ return fmt.Sprintf("%s β %s, %s", format, status, slots)
+}
+
+func truncate(s string, max int) string {
+ if len(s) <= max {
+ return s
+ }
+ return s[:max-3] + "..."
+}
diff --git a/internal/interactions/helpers/customid.go b/internal/interactions/helpers/customid.go
new file mode 100644
index 0000000..18202e1
--- /dev/null
+++ b/internal/interactions/helpers/customid.go
@@ -0,0 +1,40 @@
+package helpers
+
+import (
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+SplitCustomID splits a colon-delimited Discord CustomID and ensures it has at
+least n parts. On failure it sends InvalidButtonDataMessage and returns
+(nil, false), so the caller can just early-return.
+
+Use it for both component CustomIDs (i.MessageComponentData().CustomID) and
+modal CustomIDs (i.ModalSubmitData().CustomID): the caller passes the string.
+*/
+func SplitCustomID(s *discordgo.Session, i *discordgo.InteractionCreate, customID string, n int) ([]string, bool) {
+ parts := strings.SplitN(customID, ":", n)
+ if len(parts) < n {
+ Respond(s, i, messages.InvalidButtonDataMessage)
+ return nil, false
+ }
+ return parts, true
+}
+
+/*
+GetUserID returns the invoking user's Discord ID, handling both guild (Member)
+and DM (User) contexts. Empty string if neither is populated.
+*/
+func GetUserID(i *discordgo.InteractionCreate) string {
+ if i.Member != nil {
+ return i.Member.User.ID
+ }
+ if i.User != nil {
+ return i.User.ID
+ }
+ return ""
+}
diff --git a/internal/interactions/helpers/loadcampaign.go b/internal/interactions/helpers/loadcampaign.go
new file mode 100644
index 0000000..926d21f
--- /dev/null
+++ b/internal/interactions/helpers/loadcampaign.go
@@ -0,0 +1,90 @@
+package helpers
+
+import (
+ "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/manager/models"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+LoadDMCampaign authorizes the invoking user as a DM of campaignID and loads
+the campaign by ID. On either failure it sends the standard response and
+returns (nil, false), so callers can early-return.
+
+The usual flow to get campaigns is:
+
+"authorize -> load -> respond on miss"
+
+This replaces the process with a single method to retrieve Campaigns.
+
+Note:
+Handlers that load before authorizing (campaign_leave, campaign_toggle, RenderManageCampaignMenu),
+that look up by tag (campaign_join), or that use a different scope
+(campaign_approve via checkModAuth) are intentionally omitted.
+*/
+func LoadDMCampaign(
+ s *discordgo.Session,
+ i *discordgo.InteractionCreate,
+ database *bun.DB,
+ campaignID string,
+) (*models.Campaign, bool) {
+ ok, err := auth.Authorize(database, GetUserID(i), auth.ScopeDM, campaignID)
+ if err != nil || !ok {
+ Respond(s, i, messages.ManageNotAuthorized)
+ return nil, false
+ }
+ campaign, err := db.GetByID[models.Campaign](database, campaignID)
+ if err != nil {
+ Respond(s, i, messages.ManageCampaignNotFound)
+ return nil, false
+ }
+ return campaign, true
+}
+
+/*
+IsCampaignMutable rejects archived (or otherwise non-mutable) campaigns by sending
+the standard CampaignArchivedMessage response. Returns true when the campaign
+is mutable; false when the caller should early-return.
+
+Pair with a load helper at any site that writes to the campaign or fires a
+campaign-bound side-effect.
+*/
+func IsCampaignMutable(s *discordgo.Session, i *discordgo.InteractionCreate, c *models.Campaign) bool {
+ if !c.CanMutate() {
+ Respond(s, i, messages.CampaignArchivedMessage)
+ return false
+ }
+ return true
+}
+
+/*
+LoadModCampaign authorizes the invoking user as a mod (ScopeMod) and loads the
+campaign by ID. On either failure it sends the approval-flow response and
+returns (nil, false), so callers can early-return.
+
+This is the mod-scoped counterpart to LoadDMCampaign, used by the campaign
+approval/denial handlers. Sites that only need the auth check (no load) should
+keep using checkModAuth.
+*/
+func LoadModCampaign(
+ s *discordgo.Session,
+ i *discordgo.InteractionCreate,
+ database *bun.DB,
+ campaignID string,
+) (*models.Campaign, bool) {
+ ok, err := auth.Authorize(database, GetUserID(i), auth.ScopeMod, "")
+ if err != nil || !ok {
+ Respond(s, i, messages.CampaignApproveNotModError)
+ return nil, false
+ }
+ campaign, err := db.GetByID[models.Campaign](database, campaignID)
+ if err != nil {
+ Respond(s, i, messages.CampaignApproveNotFound)
+ return nil, false
+ }
+ return campaign, true
+}
diff --git a/internal/interactions/helpers/responses.go b/internal/interactions/helpers/responses.go
new file mode 100644
index 0000000..a829204
--- /dev/null
+++ b/internal/interactions/helpers/responses.go
@@ -0,0 +1,164 @@
+package helpers
+
+import (
+ "github.com/bwmarrin/discordgo"
+
+ "github.com/framebuffers/moontracer/internal/interactions/router"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+Discord interaction response cheat sheet for Moontracer (specifics to bot)
+
+Every interaction must be acknowledged within 3 seconds (a respond* call counts
+as acknowledgement). Failing to acknowledge shows the user "this interaction
+failed". Calling InteractionRespond twice on the same interaction silently
+errors with "interaction has already been acknowledged".
+
+Helpers in this package:
+
+ Respond(s, i, content)
+ New ephemeral text. Use for plain success/error messages.
+ Wraps InteractionResponseChannelMessageWithSource + ephemeral flag.
+
+ RespondUpdate(s, i, content, embeds, components)
+ Replaces the current ephemeral message in place. Use after a button
+ click that should rewrite the same panel (back-nav, menu refresh).
+ Wraps InteractionResponseUpdateMessage + ephemeral flag.
+
+ RespondWithBack(s, i, typ, content, components, view)
+ Wraps Respond OR RespondUpdate (decided by typ) and appends a
+ back-button row that navigates to view.
+
+Components rules:
+
+ - Top-level Data.Components entries MUST be ActionsRow (or V2 containers).
+ A bare Button at the top level is rejected by Discord.
+ - 5 ActionsRows max per message. 5 components per row, OR 1 SelectMenu alone.
+ - Buttons need either a Label or Emoji, plus a CustomID (non-link buttons).
+
+Embeds:
+
+ - On InteractionResponseUpdateMessage, set Embeds: []*MessageEmbed{} to drop
+ the previous embeds. Leaving it nil keeps them in place.
+
+Modals:
+
+ - InteractionResponseModal uses Data.CustomID + Title + Components
+ (TextInputs wrapped in ActionsRows). Modals can NOT be ephemeral.
+ - The submit fires a separate interaction; read i.ModalSubmitData().
+
+Common mistakes:
+
+ - Returning from a handler without responding (interaction hangs).
+ - Calling Respond twice (second call silently dropped).
+ - Forgetting MessageFlagsEphemeral on admin/mod/DM views (leaks to channel).
+ - Putting a Button directly in Data.Components without ActionsRow wrapper.
+ - Forgetting to clear Embeds on Update (stale embed sticks around).
+ - Truncating per-line instead of final content (2000 char limit applies to
+ the assembled Content, not each line).
+*/
+
+/*
+Respond sends an ephemeral text response to an interaction.
+Used by every handler in the interactions package.
+*/
+func Respond(s *discordgo.Session, i *discordgo.InteractionCreate, content string) {
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: &discordgo.InteractionResponseData{
+ Content: content,
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+RespondUpdate replaces the current message instead of sending a new one.
+Used by back buttons and select menus to update in place.
+*/
+func RespondUpdate(s *discordgo.Session, i *discordgo.InteractionCreate, content string, embeds []*discordgo.MessageEmbed, components []discordgo.MessageComponent) {
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: content,
+ Embeds: embeds,
+ Components: components,
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+RespondNotRegistered updates the current message with the "not registered" prompt and a
+Register button that registers the user and opens the /me hub in one click.
+*/
+func RespondNotRegistered(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ RespondUpdate(s, i, messages.NotRegisteredMessage, []*discordgo.MessageEmbed{}, []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.RegisterButtonLabel,
+ Style: discordgo.SuccessButton,
+ CustomID: messages.QuickRegisterPrefix,
+ },
+ }},
+ })
+}
+
+/*
+RespondUpdateTerminal updates the current message with a terminal text response
+and a Home button. Use in component handlers for final success and error states
+that end the current flow.
+*/
+func RespondUpdateTerminal(s *discordgo.Session, i *discordgo.InteractionCreate, content string) {
+ RespondUpdate(s, i, content, []*discordgo.MessageEmbed{}, []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ router.NavButton(messages.HomeLabel, discordgo.DangerButton, router.ViewMe),
+ }},
+ })
+}
+
+/*
+BackRow builds an ActionsRow with a back button pointing at target.
+When target is not ViewMe, a secondary Home button (-> ViewMe) is appended so
+users can always return to the player hub from any depth.
+*/
+func BackRow(target router.ViewID, args ...string) discordgo.ActionsRow {
+ if target == router.ViewMe {
+ // Back and Home are the same destination here; one button avoids a duplicate CustomID.
+ return discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ router.NavButton(messages.HomeLabel, discordgo.DangerButton, router.ViewMe),
+ }}
+ }
+ return discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ router.BackButton(messages.BackLabel, target, args...),
+ router.NavButton(messages.HomeLabel, discordgo.DangerButton, router.ViewMe),
+ }}
+}
+
+/*
+RespondWithBack sends a response that ends with a back-button row navigating to
+view. typ controls whether this is a new ephemeral message
+(InteractionResponseChannelMessageWithSource) or an in-place update
+(InteractionResponseUpdateMessage).
+
+components is a slice of ActionsRows the caller wants above the back-button
+row; pass nil if there are none.
+*/
+func RespondWithBack(s *discordgo.Session,
+ i *discordgo.InteractionCreate,
+ typ discordgo.InteractionResponseType,
+ content string,
+ components []discordgo.MessageComponent,
+ view router.ViewID) {
+ components = append(components, BackRow(view))
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: typ,
+ Data: &discordgo.InteractionResponseData{
+ Content: content,
+ Components: components,
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
diff --git a/internal/interactions/helpers/time.go b/internal/interactions/helpers/time.go
new file mode 100644
index 0000000..0923fa0
--- /dev/null
+++ b/internal/interactions/helpers/time.go
@@ -0,0 +1,65 @@
+package helpers
+
+import (
+ "fmt"
+ "time"
+)
+
+/*
+ DateTime helpers
+*/
+
+// TimeRemaining returns a human-readable "in X days/hours/minutes" string relative to now.
+func TimeRemaining(t time.Time) string {
+ d := time.Until(t).Round(time.Minute)
+ if d <= 0 {
+ return "now"
+ }
+ h := int(d.Hours())
+ m := int(d.Minutes()) % 60
+ switch {
+ case h >= 24*14:
+ return fmt.Sprintf("in %d weeks", h/168)
+ case h >= 24:
+ days := h / 24
+ if h%24 == 0 {
+ return fmt.Sprintf("in %d days", days)
+ }
+ return fmt.Sprintf("in %d days %dh", days, h%24)
+ case h > 0:
+ if m == 0 {
+ return fmt.Sprintf("in %dh", h)
+ }
+ return fmt.Sprintf("in %dh %dm", h, m)
+ default:
+ return fmt.Sprintf("in %dm", m)
+ }
+}
+
+/*
+FormatInLocation formats t in the given location using the provided layout.
+
+Falls back to UTC if loc is nil.
+*/
+func FormatInLocation(t time.Time, layout string, loc *time.Location) string {
+ if loc == nil {
+ loc = time.UTC
+ }
+ return t.In(loc).Format(layout)
+}
+
+// TZLabel returns a short label like "CLST (UTC-4)" for display in modal fields.
+func TZLabel(loc *time.Location) string {
+ if loc == nil || loc == time.UTC {
+ return "UTC"
+ }
+ now := time.Now().In(loc)
+ abbr, offset := now.Zone()
+ h := offset / 3600
+ sign := "+"
+ if h < 0 {
+ sign = "-"
+ h = -h
+ }
+ return fmt.Sprintf("%s (UTC%s%d)", abbr, sign, h)
+}
diff --git a/internal/interactions/interaction.go b/internal/interactions/interaction.go
new file mode 100644
index 0000000..5853d2b
--- /dev/null
+++ b/internal/interactions/interaction.go
@@ -0,0 +1,45 @@
+package interactions
+
+import "github.com/bwmarrin/discordgo"
+
+/*
+ Note:
+ These handlers, when going to the dispatcher (handler.go), go through this switch statement that decides which handler to execute:
+ ```go
+ switch i.Type {
+ case discordgo.InteractionApplicationCommand:
+ cmd.Execute(s, i)
+ case discordgo.InteractionMessageComponent: // ComponentHandler
+ handler.HandleComponents(s, i)
+ case discordgo.InteractionModalSubmit: // ModalHandler
+ handler.HandleModals(s, i)
+ }
+ ```
+*/
+
+/*
+ComponenHandler defines a single routing string, plus its handler, that all Components must implement.
+It handles button clicks and interactions like selecting menus, etc.
+
+Flow: campaignJoin handles the "Join Campaign" button -> HandleComponents(s, i) -> gets registered handlers `[campaignJoin, campaignLeave, campaignToggel, campaignView]`
+*/
+type ComponentHandler interface {
+
+ /*
+ CustomIDPrefix tells Moontracer which handler to call, and the params it expects to perform its tasks.
+ convention = prefix:arg (e.g. `join_campaign:strahd`)
+ */
+ CustomIDPrefix() string
+ HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate)
+}
+
+/*
+ModalHandler defines a single identification string, plus its handler, that all Modals must implement, in specific, modal form submissions/
+This handles the user interface in charge of user text input.
+
+Flow: modalCampaignCreate handles the /newcampaign modal form submission -> HandleModal(s, i) -> InteractionModalSubmit routed to ModalHandler.
+*/
+type ModalHandler interface {
+ CustomIDPrefix() string
+ HandleModal(s *discordgo.Session, i *discordgo.InteractionCreate)
+}
diff --git a/internal/interactions/manage_archive_test.go b/internal/interactions/manage_archive_test.go
new file mode 100644
index 0000000..5523c70
--- /dev/null
+++ b/internal/interactions/manage_archive_test.go
@@ -0,0 +1,187 @@
+package interactions
+
+import (
+ "context"
+ "testing"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/uptrace/bun"
+
+ "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"
+)
+
+/*
+Unit Testing
+Manage -> Archive confirm flow
+
+Covers manageArchiveConfirm:
+ The destructive button at the end of the manage-menu archive sub-flow
+ that calls commands.ArchiveCampaign and flips IsArchived.
+
+Auth is ScopeDM. Already-archived campaigns short-circuit at CanMutate()
+so the second click is a no-op.
+*/
+
+/*
+archiveFixture seeds a registered DM and a campaign owned by them, with a DM
+membership row.
+
+The alreadyArchived flag pre-flips IsArchived so tests can exercise the no-op branch.
+*/
+func archiveFixture(t *testing.T, database *bun.DB, alreadyArchived bool) {
+ t.Helper()
+ ctx := context.Background()
+
+ _, err := database.NewInsert().Model(&models.Player{ID: testDMID, Role: models.ServerRolePlayer}).Exec(ctx)
+ require.NoError(t, err)
+
+ _, err = database.NewInsert().Model(&models.Campaign{
+ ID: testCampID,
+ Name: "Archive Test",
+ Tag: "archive-test",
+ DungeonMaster: testDMID,
+ IsApproved: true,
+ IsArchived: alreadyArchived,
+ }).Exec(ctx)
+ require.NoError(t, err)
+
+ _, err = database.NewInsert().Model(&models.CampaignPlayer{
+ PlayerID: testDMID,
+ CampaignID: testCampID,
+ Role: models.RoleDM,
+ Status: models.StatusActive,
+ }).Exec(ctx)
+ require.NoError(t, err)
+}
+
+/*
+buildArchiveConfirm constructs a button-click InteractionCreate matching
+the manage_archive_confirm CustomID format (prefix:campaignID).
+*/
+func buildArchiveConfirm(userID, campaignID string) *discordgo.InteractionCreate {
+ return &discordgo.InteractionCreate{
+ Interaction: &discordgo.Interaction{
+ Type: discordgo.InteractionMessageComponent,
+ GuildID: testGuildID,
+ User: &discordgo.User{ID: userID},
+ Data: discordgo.MessageComponentInteractionData{
+ CustomID: messages.ManageArchiveConfirmID + ":" + campaignID,
+ ComponentType: discordgo.ButtonComponent,
+ },
+ },
+ }
+}
+
+/*
+DM archives their own campaign.
+
+When:
+
+ The campaign's DM clicks the Archive Confirm button.
+
+Expected:
+
+ Campaign.IsArchived flips to true; the DM's active membership is moved to
+ StatusFinished by the ArchiveCampaign cascade.
+*/
+func TestManageArchiveConfirm_DMAuthorized(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ archiveFixture(t, database, false)
+ stub := testutil.NewStubSession(t)
+
+ handler := &manageArchiveConfirm{db: database}
+ handler.HandleComponents(stub.Session, buildArchiveConfirm(testDMID, testCampID))
+
+ reloaded, err := db.GetByID[models.Campaign](database, testCampID)
+ require.NoError(t, err)
+ assert.True(t, reloaded.IsArchived, "campaign should be archived after DM confirm")
+
+ players, err := models.GetCampaignPlayers(database, testCampID)
+ require.NoError(t, err)
+ for _, p := range players {
+ if p.PlayerID == testDMID {
+ assert.Equal(t, models.StatusFinished, p.Status, "DM membership should be finished after archive cascade")
+ }
+ }
+}
+
+/*
+Plain player cannot archive someone else's campaign.
+
+When:
+
+ A non-DM player triggers the confirm via a forged CustomID.
+
+Expected:
+
+ Campaign stays unarchived; DM's membership row is untouched.
+*/
+func TestManageArchiveConfirm_NonDMRejected(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ archiveFixture(t, database, false)
+
+ _, err := database.NewInsert().Model(&models.Player{ID: "attacker", Role: models.ServerRolePlayer}).Exec(context.Background())
+ require.NoError(t, err)
+
+ stub := testutil.NewStubSession(t)
+ handler := &manageArchiveConfirm{db: database}
+ handler.HandleComponents(stub.Session, buildArchiveConfirm("attacker", testCampID))
+
+ reloaded, err := db.GetByID[models.Campaign](database, testCampID)
+ require.NoError(t, err)
+ assert.False(t, reloaded.IsArchived, "non-DM must not archive the campaign")
+
+ players, err := models.GetCampaignPlayers(database, testCampID)
+ require.NoError(t, err)
+ for _, p := range players {
+ if p.PlayerID == testDMID {
+ assert.Equal(t, models.StatusActive, p.Status, "DM membership should remain active when archive is rejected")
+ }
+ }
+}
+
+/*
+Already-archived campaign is a no-op on second click.
+
+When:
+
+ The DM clicks Archive Confirm on a campaign that's already archived
+ (e.g. via a stale ephemeral menu).
+
+Expected:
+
+ No second-pass mutation. The CanMutate() guard short-circuits before
+ ArchiveCampaign runs; ArchivedReason from the prior run is preserved.
+*/
+func TestManageArchiveConfirm_AlreadyArchivedNoOp(t *testing.T) {
+ guard.SetModesForTest(t, true, true)
+
+ database := testutil.NewTestDB(t)
+ archiveFixture(t, database, true) // Note: pre-archived
+
+ // Step: pin a sentinel reason so we can detect any second-pass overwrite.
+ _, err := database.NewUpdate().Model((*models.Campaign)(nil)).
+ Set("archived_reason = ?", "first archive").
+ Where("id = ?", testCampID).
+ Exec(context.Background())
+ require.NoError(t, err)
+
+ stub := testutil.NewStubSession(t)
+ handler := &manageArchiveConfirm{db: database}
+ handler.HandleComponents(stub.Session, buildArchiveConfirm(testDMID, testCampID))
+
+ reloaded, err := db.GetByID[models.Campaign](database, testCampID)
+ require.NoError(t, err)
+ assert.True(t, reloaded.IsArchived, "still archived")
+ assert.Equal(t, "first archive", reloaded.ArchivedReason, "reason must not be overwritten by a second confirm")
+}
diff --git a/internal/interactions/manage_campaign.go b/internal/interactions/manage_campaign.go
new file mode 100644
index 0000000..c20ecd0
--- /dev/null
+++ b/internal/interactions/manage_campaign.go
@@ -0,0 +1,413 @@
+package interactions
+
+/*
+ Managing a campaign is a multi-stage flow. This is the flow for the `/managecampaigns` command.
+ Flow:
+ 1. Authorize: check if the player is registered.
+ 2. Load all the Player's Campaigns, filter the ones where Role == DM
+ 3. Renders an ephemeral list with a button per campaign (CustomID: "manage_campaign:")
+*/
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+ "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/guard"
+ "github.com/framebuffers/moontracer/internal/interactions/router"
+ "github.com/framebuffers/moontracer/internal/manager/models"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+manageCampaignMenu provides a model to select options in a menu providing options to manage a Campaign. interaction: `manage_campaign:` [interaction: menu]
+
+ 1. Authorize: check if invoker is DM or Mod for that Campaign.
+ 2. Show action buttons: [Edit, Delete, Ban, Announce, Reschedule]
+*/
+type manageCampaignMenu struct {
+ db *bun.DB
+}
+
+func (h *manageCampaignMenu) CustomIDPrefix() string {
+ return "manage_campaign"
+}
+
+func (h *manageCampaignMenu) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ RenderManageCampaignMenu(s, i, h.db, parts[1])
+}
+
+/*
+RenderManageCampaignMenu renders the management menu for a campaign.
+
+Used by the manage_campaign handler and back_manage_campaign handler.
+*/
+func RenderManageCampaignMenu(s *discordgo.Session, i *discordgo.InteractionCreate, database *bun.DB, campaignID string) {
+ userID := helpers.GetUserID(i)
+
+ /*
+ Check campaign exists before auth. Prevents misleading "not authorized"
+ when selecting a deleted campaign from a stale menu.
+ */
+ campaign, err := db.GetByID[models.Campaign](database, campaignID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageCampaignNotFound)
+ return
+ }
+
+ ok, err := auth.Authorize(database, userID, auth.ScopeDM, campaignID)
+ if err != nil {
+ log.Printf("manage_campaign: auth check failed: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ if !ok {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageNotAuthorized)
+ return
+ }
+
+ if !helpers.IsCampaignMutable(s, i, campaign) {
+ 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{
+ Content: fmt.Sprintf(messages.ManageCampaignHeader, campaign.Name),
+ Embeds: []*discordgo.MessageEmbed{},
+ 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),
+ }},
+ helpers.BackRow(router.ViewManage),
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+manageCampaignDelete is a model with information to ban a Player. Interaction: `manage_delete:` [delete]
+
+ 1. Authorize: invoker must be DM of that Campaign or Mod.
+ 2. Delete all CampaignMembers from that Campaign, then delete the Campaign itself.
+*/
+type manageCampaignDelete struct {
+ db *bun.DB
+}
+
+func (h *manageCampaignDelete) CustomIDPrefix() string {
+ return "manage_delete"
+}
+
+func (h *manageCampaignDelete) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ campaignID := parts[1]
+
+ campaign, ok := helpers.LoadDMCampaign(s, i, h.db, campaignID)
+ if !ok {
+ return
+ }
+
+ if !helpers.IsCampaignMutable(s, i, campaign) {
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf(messages.ManageDeleteConfirm, campaign.Name),
+ Embeds: []*discordgo.MessageEmbed{},
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.ManageDeleteConfirmLabel,
+ Style: discordgo.DangerButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.ManageDeleteConfirmID, campaignID),
+ },
+ discordgo.Button{
+ Label: messages.ManageDeleteCancelLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: router.NavCustomID(router.ViewManageDanger, campaignID),
+ },
+ }},
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+manageDeleteConfirm handles the "Yes, Delete" confirmation button.
+
+Custom ID format: manage_delete_confirm:
+*/
+type manageDeleteConfirm struct {
+ db *bun.DB
+}
+
+func (h *manageDeleteConfirm) CustomIDPrefix() string {
+ return messages.ManageDeleteConfirmID
+}
+
+func (h *manageDeleteConfirm) 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
+ }
+
+ ctx := context.Background()
+ _, err := h.db.NewDelete().Model((*models.CampaignPlayer)(nil)).
+ Where("campaign_id = ?", campaignID).Exec(ctx)
+ if err != nil {
+ log.Printf("manage_delete_confirm: failed to delete campaign players: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.ManageDeleteFailure)
+ return
+ }
+
+ if err := db.Delete[models.Campaign](h.db, campaignID); err != nil {
+ log.Printf("manage_delete_confirm: failed to delete campaign: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.ManageDeleteFailure)
+ 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))
+}
+
+/*
+
+truth table: can ban?
+ +-----------+-----------+-------+-----------+-----------+-------------------+
+ | admin | mod | dm | player | member | unregistered |
+ --------------------+-----------+-----------+-------+-----------+-----------+-------------------+
+ | admin | F | T | T | T | T | T |
+ --------------------+-----------+-----------+-------+-----------+-----------+-------------------+
+ | mod | F | F | T | T | T | T |
+ --------------------+-----------+-----------+-------+-----------+-----------+-------------------+
+ | dm | F | F | F | T* | F | F |
+ --------------------+-----------+-----------+-------+-----------+-----------+-------------------+
+ | player | F | F | F | F | F | F |
+ --------------------+-----------+-----------+-------+-----------+-----------+-------------------+
+ | member | F | F | F | F | F | F |
+ --------------------+-----------+-----------+-------+-----------+-----------+-------------------+
+ | unregistered | F | F | F | F | F | F |
+ --------------------+-----------+-----------+-------+-----------+-----------+-------------------+
+
+ basically:
+ - if you have banning permissions, you cannot ban yourself.
+ - as said before, permissions cascade.
+ - the *only* special case are DMs, where they have a **limited scope** for banning permissions (their Campaign).
+ - apart from DMs, anyone below cannot ban anyone else.
+
+*/
+
+/*
+manageCampaignBan is a model with information to ban a member from a Campaign. Interaction: `manage_ban:` [ban select]
+ 1. Authorize: invoker must be a DM of that Campaign or Mod.
+ 2. Load active members (excluding the DM or already banned users)
+ 3. Show a select menu dropdown.
+*/
+type manageCampaignBan struct {
+ db *bun.DB
+}
+
+func (h *manageCampaignBan) CustomIDPrefix() string {
+ return "manage_ban"
+}
+
+func (h *manageCampaignBan) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ campaignID := parts[1]
+ userID := i.Member.User.ID
+
+ ok, err := auth.Authorize(h.db, userID, auth.ScopeDM, campaignID)
+ if err != nil {
+ log.Printf("manage_ban: auth check failed: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ if !ok {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageNotAuthorized)
+ return
+ }
+
+ campaign, err := db.GetByID[models.Campaign](h.db, campaignID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageCampaignNotFound)
+ return
+ }
+
+ if !helpers.IsCampaignMutable(s, i, campaign) {
+ return
+ }
+
+ players, err := models.GetCampaignPlayers(h.db, campaignID)
+ if err != nil {
+ log.Printf("manage_ban: failed to load players: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ var options []discordgo.SelectMenuOption
+ for _, p := range players {
+ if p.Role == models.RoleDM || p.Status == models.StatusBanned || p.PlayerID == userID {
+ continue
+ }
+ label := p.PlayerID
+ if p.Player != nil {
+ label = p.Player.ID // Discord user ID β shown as fallback
+ }
+ options = append(options, discordgo.SelectMenuOption{
+ Label: label,
+ Value: fmt.Sprintf("%s:%s", campaignID, p.PlayerID),
+ })
+ }
+
+ if len(options) == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageBanNoMembers)
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf(messages.ManageSelectMember, campaign.Name),
+ Embeds: []*discordgo.MessageEmbed{},
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{
+ Components: []discordgo.MessageComponent{
+ discordgo.SelectMenu{
+ CustomID: "manage_ban_select",
+ Placeholder: messages.ManageBanSelectPlaceholder,
+ Options: options,
+ },
+ },
+ },
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+manageCampaignBanSelect is a model that returns information to execute a ban action. Interaction: `[manage_ban_select]`
+
+ 1. Get `campaignID:playerID` from manageCampaignBan.
+
+ 2. Split values into two separate values.
+
+ 3. Authorize
+
+ 4. Call `SetCampaignPlayerStatus` and set it to models.StatusBanned.
+
+ Notes:
+
+ - Applies only to the campaign being passed. Scope is limited to Campaign only.
+
+ - Invoker must be a DM of this Campaign or have a heavier role (Mod or Admin).
+*/
+type manageCampaignBanSelect struct {
+ db *bun.DB
+}
+
+func (h *manageCampaignBanSelect) CustomIDPrefix() string {
+ return "manage_ban_select"
+}
+
+func (h *manageCampaignBanSelect) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ invokerID := i.Member.User.ID
+
+ values := i.MessageComponentData().Values
+ if len(values) == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.InvalidButtonDataMessage)
+ return
+ }
+
+ parts := strings.SplitN(values[0], ":", 2)
+ if len(parts) < 2 {
+ helpers.RespondUpdateTerminal(s, i, messages.InvalidButtonDataMessage)
+ return
+ }
+ campaignID := parts[0]
+ targetID := parts[1]
+
+ ok, err := auth.Authorize(h.db, invokerID, auth.ScopeDM, campaignID)
+ if err != nil {
+ log.Printf("manage_ban_select: auth check failed: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ if !ok {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageNotAuthorized)
+ return
+ }
+
+ err = models.SetCampaignPlayerStatus(h.db, targetID, campaignID, models.StatusBanned)
+ if err != nil {
+ log.Printf("manage_ban_select: failed to set status to banned: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.ManageCampaignBanFailure)
+ return
+ }
+
+ // Remove the campaign's linked Discord role if one exists.
+ campaign, err := db.GetByID[models.Campaign](h.db, campaignID)
+ if err == nil && campaign.RoleID != "" {
+ if err := guard.GuildMemberRoleRemove(s, i.GuildID, targetID, campaign.RoleID); err != nil {
+ log.Printf("manage_ban_select: failed to remove role %s from %s: %v", campaign.RoleID, targetID, err)
+ }
+ }
+
+ log.Printf("manage_ban_select: banned successfully. target: %s, campaign: %s", targetID, campaignID)
+ helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.ManageCampaignBanSuccess, targetID, campaignID))
+}
diff --git a/internal/interactions/manage_links.go b/internal/interactions/manage_links.go
new file mode 100644
index 0000000..39298f5
--- /dev/null
+++ b/internal/interactions/manage_links.go
@@ -0,0 +1,154 @@
+package interactions
+
+import (
+ "fmt"
+ "log"
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/db"
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+ Flow:
+ Triggered from the manage-campaign Settings sub-menu via the "Edit Links" button.
+ 1. Button click (manage_links:): manageLinksHandler
+ a. Loads the campaign (DM-auth + mutable check).
+ b. Opens a modal pre-filled with current VTTLink and Links (newline-joined).
+ Two fields: vtt_link (short) and resources (paragraph).
+ 2. Modal submit (manage_links_modal:): manageLinksModal
+ a. Re-loads + re-checks DM auth + mutable.
+ b. Writes campaign.VTTLink (trimmed) and campaign.Links (parseLinks splits
+ newlines, trims, drops blanks).
+ c. db.Update + terminal success reply.
+
+ Notes:
+ - PlayerSheetURL is set elsewhere (campaign_announce.go modal); this file
+ only manages VTT + free-form resource links.
+ - parseLinks lives here because it's the only call site.
+*/
+
+type manageLinksHandler struct {
+ db *bun.DB
+}
+
+func (h *manageLinksHandler) CustomIDPrefix() string {
+ return messages.ManageLinksPrefix
+}
+
+func (h *manageLinksHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ campaignID := parts[1]
+
+ campaign, ok := helpers.LoadDMCampaign(s, i, h.db, campaignID)
+ if !ok {
+ return
+ }
+ if !helpers.IsCampaignMutable(s, i, campaign) {
+ return
+ }
+
+ resourcesValue := strings.Join(campaign.Links, "\n")
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseModal,
+ Data: &discordgo.InteractionResponseData{
+ CustomID: fmt.Sprintf("%s:%s", messages.ManageLinksModalID, campaignID),
+ Title: messages.ManageLinksModalTitle,
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: "vtt_link",
+ Label: messages.ManageLinksVTTLabel,
+ Style: discordgo.TextInputShort,
+ Placeholder: messages.ManageLinksVTTPlaceholder,
+ Value: campaign.VTTLink,
+ Required: false,
+ MaxLength: 500,
+ },
+ }},
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: "resources",
+ Label: messages.ManageLinksResourcesLabel,
+ Style: discordgo.TextInputParagraph,
+ Placeholder: messages.ManageLinksResourcesPlaceholder,
+ Value: resourcesValue,
+ Required: false,
+ MaxLength: 1000,
+ },
+ }},
+ },
+ },
+ })
+}
+
+type manageLinksModal struct {
+ db *bun.DB
+}
+
+func (h *manageLinksModal) CustomIDPrefix() string {
+ return messages.ManageLinksModalID
+}
+
+func (h *manageLinksModal) HandleModal(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.ModalSubmitData().CustomID, 2)
+ if !ok {
+ return
+ }
+ campaignID := parts[1]
+
+ campaign, ok := helpers.LoadDMCampaign(s, i, h.db, campaignID)
+ if !ok {
+ return
+ }
+ if !helpers.IsCampaignMutable(s, i, campaign) {
+ return
+ }
+
+ fields := i.ModalSubmitData().Components
+ for _, row := range fields {
+ ar, ok := row.(*discordgo.ActionsRow)
+ if !ok {
+ continue
+ }
+ for _, comp := range ar.Components {
+ ti, ok := comp.(*discordgo.TextInput)
+ if !ok {
+ continue
+ }
+ switch ti.CustomID {
+ case "vtt_link":
+ campaign.VTTLink = strings.TrimSpace(ti.Value)
+ case "resources":
+ campaign.Links = parseLinks(ti.Value)
+ }
+ }
+ }
+
+ if err := db.Update(h.db, campaign); err != nil {
+ log.Printf("manage_links: update failed for campaign %s: %v", campaignID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.ManageLinksSuccess, campaign.Name))
+}
+
+// parseLinks splits a newline-separated string into a slice of non-empty trimmed URLs.
+func parseLinks(raw string) []string {
+ var out []string
+ for _, line := range strings.Split(raw, "\n") {
+ if u := strings.TrimSpace(line); u != "" {
+ out = append(out, u)
+ }
+ }
+ return out
+}
diff --git a/internal/interactions/manage_newcampaign_button.go b/internal/interactions/manage_newcampaign_button.go
new file mode 100644
index 0000000..8e3cb77
--- /dev/null
+++ b/internal/interactions/manage_newcampaign_button.go
@@ -0,0 +1,85 @@
+package interactions
+
+/*
+ New Campaign from button handler for the /me hub and /managecampaigns menu.
+
+ Flow:
+ 1. Player clicks "New Campaign" button.
+ 2. Auth check: must be a registered player.
+ 3. Respond with InteractionResponseModal (same modal as /newcampaign slash command).
+ 4. Modal submission is handled by the existing modalCampaignCreate handler.
+*/
+
+import (
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+ "log"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/auth"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+type manageNewCampaignButton struct {
+ db *bun.DB
+}
+
+func (h *manageNewCampaignButton) CustomIDPrefix() string {
+ return messages.ManageNewCampaignPrefix
+}
+
+func (h *manageNewCampaignButton) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ userID := helpers.GetUserID(i)
+
+ ok, err := auth.Authorize(h.db, userID, auth.ScopePlayer, "")
+ if err != nil {
+ log.Printf("manage_newcampaign: auth check failed: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ if !ok {
+ helpers.RespondNotRegistered(s, i)
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseModal,
+ Data: &discordgo.InteractionResponseData{
+ CustomID: messages.NewCampaignModalCustomID,
+ Title: messages.NewCampaignModalTitle,
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: messages.FieldNameID,
+ Label: messages.FieldNameLabel,
+ Style: discordgo.TextInputShort,
+ Placeholder: messages.FieldNamePlaceholder,
+ Required: true,
+ MaxLength: 100,
+ },
+ }},
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: messages.FieldSlotsID,
+ Label: messages.FieldSlotsLabel,
+ Style: discordgo.TextInputShort,
+ Placeholder: messages.FieldSlotsPlaceholder,
+ Required: false,
+ MaxLength: 3,
+ },
+ }},
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: messages.FieldDescriptionID,
+ Label: messages.FieldSynopsisLabel,
+ Style: discordgo.TextInputParagraph,
+ Placeholder: messages.FieldDescriptionPlaceholder,
+ Required: true,
+ MaxLength: 1000,
+ },
+ }},
+ },
+ },
+ })
+}
diff --git a/internal/interactions/manage_role_archive.go b/internal/interactions/manage_role_archive.go
new file mode 100644
index 0000000..7b966c6
--- /dev/null
+++ b/internal/interactions/manage_role_archive.go
@@ -0,0 +1,250 @@
+package interactions
+
+/*
+ Handlers for the Set Role and Archive buttons on the manage campaign menu.
+
+ Set Role flow:
+ 1. Button click (manage_role:): open a modal asking for a role name.
+ 2. Modal submit (modal_manage_role:): find-or-create Discord role, link to campaign.
+
+ Archive flow:
+ 1. Button click (manage_archive:): show confirmation message.
+ 2. Confirm (manage_archive_confirm:): archive via ArchiveCampaign + audit.
+ 3. Cancel (manage_archive_cancel:): return to manage menu.
+*/
+
+import (
+ "fmt"
+ "log"
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "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"
+)
+
+/*
+ Set Role:
+ Button to Modal
+*/
+
+type manageSetRole struct {
+ db *bun.DB
+}
+
+func (h *manageSetRole) CustomIDPrefix() string {
+ return messages.ManageSetRolePrefix
+}
+
+func (h *manageSetRole) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ campaignID := parts[1]
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseModal,
+ Data: &discordgo.InteractionResponseData{
+ CustomID: fmt.Sprintf("%s:%s", messages.ManageSetRoleModalID, campaignID),
+ Title: messages.ManageSetRoleModalTitle,
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: "role_name",
+ Label: messages.ManageSetRoleFieldLabel,
+ Style: discordgo.TextInputShort,
+ Required: true,
+ MaxLength: 100,
+ Placeholder: "e.g. Curse of Strahd",
+ },
+ }},
+ },
+ },
+ })
+}
+
+/*
+ Set Role:
+ Modal Submit
+*/
+
+type manageSetRoleModal struct {
+ db *bun.DB
+}
+
+func (h *manageSetRoleModal) CustomIDPrefix() string {
+ return messages.ManageSetRoleModalID
+}
+
+func (h *manageSetRoleModal) 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)
+
+ campaign, ok := helpers.LoadDMCampaign(s, i, h.db, campaignID)
+ if !ok {
+ return
+ }
+
+ if !helpers.IsCampaignMutable(s, i, campaign) {
+ return
+ }
+
+ roleName := i.ModalSubmitData().Components[0].(*discordgo.ActionsRow).Components[0].(*discordgo.TextInput).Value
+ if roleName == "" {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageSetRoleFailed)
+ return
+ }
+
+ // discord role: find or create
+ var roleID string
+ roles, err := s.GuildRoles(i.GuildID)
+ if err != nil {
+ log.Printf("manage_role: failed to fetch guild roles: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.ManageSetRoleFailed)
+ return
+ }
+
+ for _, role := range roles {
+ if strings.EqualFold(role.Name, roleName) {
+ roleID = role.ID
+ break
+ }
+ }
+
+ if roleID == "" {
+ role, err := guard.GuildRoleCreate(s, i.GuildID, &discordgo.RoleParams{
+ Name: roleName,
+ })
+ if err != nil {
+ log.Printf("manage_role: failed to create role: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.ManageSetRoleFailed)
+ return
+ }
+ roleID = role.ID
+ }
+
+ campaign.RoleID = roleID
+ if err := db.Update(h.db, campaign); err != nil {
+ log.Printf("manage_role: failed to update campaign: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.ManageSetRoleFailed)
+ return
+ }
+
+ log.Printf("manage_role: %s linked role %s (%s) to campaign %s", userID, roleName, roleID, campaign.Name)
+ helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.ManageSetRoleSuccess, roleName, campaign.Name))
+}
+
+/*
+ Archive flow:
+ Button for confirmation.
+*/
+
+type manageArchive struct {
+ db *bun.DB
+}
+
+func (h *manageArchive) CustomIDPrefix() string {
+ return messages.ManageArchivePrefix
+}
+
+func (h *manageArchive) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ campaignID := parts[1]
+
+ campaign, ok := helpers.LoadDMCampaign(s, i, h.db, campaignID)
+ if !ok {
+ return
+ }
+
+ if !helpers.IsCampaignMutable(s, i, campaign) {
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf(messages.ManageArchiveConfirm, campaign.Name),
+ Embeds: []*discordgo.MessageEmbed{},
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.ManageArchiveConfirmLabel,
+ Style: discordgo.DangerButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.ManageArchiveConfirmID, campaignID),
+ },
+ discordgo.Button{
+ Label: messages.ManageArchiveCancelLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: router.NavCustomID(router.ViewManageDanger, campaignID),
+ },
+ }},
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+ Archive flow:
+ Confirm button
+*/
+
+type manageArchiveConfirm struct {
+ db *bun.DB
+ sched *scheduler.Scheduler
+}
+
+func (h *manageArchiveConfirm) CustomIDPrefix() string {
+ return messages.ManageArchiveConfirmID
+}
+
+func (h *manageArchiveConfirm) 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
+ }
+
+ if err := commands.ArchiveCampaign(h.db, campaign, messages.AbandonReasonDM); err != nil {
+ log.Printf("manage_archive: failed to archive campaign %s: %v", campaign.ID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.ManageArchiveFailed)
+ 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)
+ }
+
+ log.Printf("manage_archive: %s archived campaign %s (%s)", userID, campaign.Name, campaign.ID)
+ helpers.RespondUpdate(s, i, fmt.Sprintf(messages.ManageArchiveSuccess, campaign.Name), nil, []discordgo.MessageComponent{
+ helpers.BackRow(router.ViewManage),
+ })
+}
diff --git a/internal/interactions/manage_set_session.go b/internal/interactions/manage_set_session.go
new file mode 100644
index 0000000..4ddfb0f
--- /dev/null
+++ b/internal/interactions/manage_set_session.go
@@ -0,0 +1,228 @@
+package interactions
+
+/*
+ Set / Reschedule Session flow.
+
+ Step 1 β Button (manage_set_session:):
+ Opens a modal. Title and fields adapt to context:
+ - First set: "Set Session" title, date + time fields.
+ - Re-schedule: "Reschedule Session" title, date + time + optional reason.
+ Date/time fields are pre-filled and labelled in the DM's local timezone
+ (from PlayerSettings). Input is parsed in that timezone and stored as UTC.
+
+ Step 2 β Modal (modal_manage_set_session:):
+ Validates and writes campaign.Schedule.NextSession (UTC).
+ On re-schedule with a reason: posts to the campaign's announcements thread
+ (if one is set) and writes an audit entry.
+*/
+
+import (
+ "fmt"
+ "log"
+ "strings"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "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 {
+ db *bun.DB
+}
+
+func (h *manageSetSession) CustomIDPrefix() string {
+ return messages.ManageSetSessionPrefix
+}
+
+func (h *manageSetSession) 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, err := models.GetOrCreatePlayerSettings(h.db, userID)
+ if err != nil {
+ log.Printf("manage_set_session: load settings for %s: %v", userID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ loc := settings.Location()
+
+ isReschedule := !campaign.Schedule.NextSession.IsZero()
+
+ dateValue, timeValue := "", ""
+ if isReschedule {
+ next := campaign.Schedule.NextSession.In(loc)
+ dateValue = next.Format(messages.DateInputFormat)
+ timeValue = next.Format(messages.TimeInputFormat)
+ }
+
+ timeLabel := fmt.Sprintf("%s (%s)", messages.ManageSetSessionTimeLabel, helpers.TZLabel(loc))
+
+ modalTitle := messages.ManageSetSessionModalTitle
+ if isReschedule {
+ modalTitle = messages.ManageRescheduleModalTitle
+ }
+
+ rows := []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: messages.ManageSetSessionDateFieldID,
+ Label: messages.ManageSetSessionDateLabel,
+ Style: discordgo.TextInputShort,
+ Required: true,
+ Placeholder: messages.ManageSetSessionDatePlaceholder,
+ Value: dateValue,
+ },
+ }},
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: messages.ManageSetSessionTimeFieldID,
+ Label: timeLabel,
+ Style: discordgo.TextInputShort,
+ Required: true,
+ Placeholder: messages.ManageSetSessionTimePlaceholder,
+ Value: timeValue,
+ },
+ }},
+ }
+ if isReschedule {
+ rows = append(rows, discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: messages.ManageSetSessionReasonFieldID,
+ Label: messages.ManageSetSessionReasonLabel,
+ Style: discordgo.TextInputParagraph,
+ Required: false,
+ Placeholder: messages.ManageSetSessionReasonPlaceholder,
+ },
+ }})
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseModal,
+ Data: &discordgo.InteractionResponseData{
+ CustomID: fmt.Sprintf("%s:%s", messages.ManageSetSessionModalID, campaignID),
+ Title: modalTitle,
+ Components: rows,
+ },
+ })
+}
+
+type manageSetSessionModal struct {
+ db *bun.DB
+ sched *scheduler.Scheduler
+}
+
+func (h *manageSetSessionModal) CustomIDPrefix() string {
+ return messages.ManageSetSessionModalID
+}
+
+func (h *manageSetSessionModal) 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)
+
+ 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("manage_set_session: load settings for %s: %v", userID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ loc := settings.Location()
+
+ isReschedule := !campaign.Schedule.NextSession.IsZero()
+
+ var dateStr, timeStr, reason string
+ for _, row := range i.ModalSubmitData().Components {
+ for _, comp := range row.(*discordgo.ActionsRow).Components {
+ input := comp.(*discordgo.TextInput)
+ switch input.CustomID {
+ case messages.ManageSetSessionDateFieldID:
+ dateStr = strings.TrimSpace(input.Value)
+ case messages.ManageSetSessionTimeFieldID:
+ timeStr = strings.TrimSpace(input.Value)
+ case messages.ManageSetSessionReasonFieldID:
+ reason = strings.TrimSpace(input.Value)
+ }
+ }
+ }
+
+ if _, err := time.Parse(messages.DateInputFormat, dateStr); err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageSetSessionInvalidDate)
+ return
+ }
+ if !isValidTime(timeStr) {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageSetSessionInvalidTime)
+ return
+ }
+
+ when, err := time.ParseInLocation(messages.DateTimeInputFormat, dateStr+" "+timeStr, loc)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageSetSessionInvalidTime)
+ return
+ }
+
+ if !when.After(time.Now().UTC()) {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageSetSessionInPast)
+ return
+ }
+
+ campaign.Schedule.NextSession = when.UTC()
+ campaign.Schedule.AlertSent = false
+ if err := db.Update(h.db, campaign); err != nil {
+ log.Printf("manage_set_session: failed to update campaign %s: %v", campaign.ID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.ManageSetSessionUpdateFailed)
+ return
+ }
+ h.sched.Schedule(i.GuildID, campaign)
+ if err := models.ResetCampaignRSVPs(h.db, campaign.ID); err != nil {
+ log.Printf("manage_set_session: reset RSVPs for %s: %v", campaign.ID, err)
+ }
+
+ displayTime := helpers.FormatInLocation(when, messages.SessionTimeFormat, loc) + " " + helpers.TZLabel(loc)
+ remaining := helpers.TimeRemaining(when)
+
+ if isReschedule && reason != "" {
+ if campaign.AnnouncementsThreadID != "" {
+ threadMsg := fmt.Sprintf(messages.ManageSetSessionRescheduleThread, displayTime, reason)
+ if _, err := guard.ChannelMessageSend(s, campaign.AnnouncementsThreadID, threadMsg); err != nil {
+ log.Printf("manage_set_session: post to thread %s: %v", campaign.AnnouncementsThreadID, err)
+ }
+ }
+ if err := models.InsertAuditEntry(h.db, userID, userID, models.AuditSessionReschedule, reason); err != nil {
+ log.Printf("manage_set_session: audit entry for campaign %s: %v", campaign.ID, err)
+ }
+ helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.ManageSetSessionRescheduleSuccess, campaign.Name, displayTime, remaining))
+ return
+ }
+
+ helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.ManageSetSessionSuccess, campaign.Name, displayTime, remaining))
+}
diff --git a/internal/interactions/manage_setcover.go b/internal/interactions/manage_setcover.go
new file mode 100644
index 0000000..22201c6
--- /dev/null
+++ b/internal/interactions/manage_setcover.go
@@ -0,0 +1,55 @@
+package interactions
+
+import (
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+ "github.com/framebuffers/moontracer/internal/interactions/router"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+ Flow:
+ Triggered from the manage-campaign Settings sub-menu via the "Set Cover" button.
+ 1. Button click (manage_setcover:): manageSetCover
+ a. Replies with an ephemeral instructions message pointing the DM
+ at the /campaignupload slash command.
+ b. [Back -> ViewManageSettings]
+
+ Notes:
+ - The actual upload pipeline lives on /campaignupload (commands/campaign_upload.go);
+ this file is purely a signpost so DMs discover the command from inside the menu.
+*/
+
+/*
+manageSetCover is a cosmetic entry point for setting a campaign cover.
+
+Clicking it just surfaces ephemeral instructions pointing at /campaignupload.
+The actual upload pipeline lives on the slash command β no plumbing here.
+*/
+type manageSetCover struct {
+ db *bun.DB
+}
+
+func (h *manageSetCover) CustomIDPrefix() string {
+ return "manage_setcover"
+}
+
+func (h *manageSetCover) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ campaignID := parts[1]
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: messages.SetCoverInstructions,
+ Embeds: []*discordgo.MessageEmbed{},
+ Components: []discordgo.MessageComponent{helpers.BackRow(router.ViewManageSettings, campaignID)},
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
diff --git a/internal/interactions/manage_submenus.go b/internal/interactions/manage_submenus.go
new file mode 100644
index 0000000..5e9499c
--- /dev/null
+++ b/internal/interactions/manage_submenus.go
@@ -0,0 +1,279 @@
+package interactions
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+
+ "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/interactions/router"
+ "github.com/framebuffers/moontracer/internal/manager/models"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+ Flow:
+ Triggered from the top-level manage-campaign menu (a per-campaign hub
+ reached from /managecampaigns -> select).
+
+ This file owns the four sub-menus below that hub, plus one action button.
+
+ Each Render* function is invoked by router.ViewManage* nav targets:
+ 1. RenderManagePlayersMenu (ViewManagePlayers):
+ a. Buttons: Ban, Invite, Download Tokens.
+ b. [Back -> ViewManageCampaign]
+ 2. RenderManageSessionsMenu (ViewManageSessions):
+ a. Button label flips Set/Reschedule based on Schedule.NextSession.
+ b. Buttons: Set/Reschedule Session, Announce.
+ c. [Back -> ViewManageCampaign]
+ 3. RenderManageSettingsMenu (ViewManageSettings):
+ a. Buttons: Set Cover, Set Role, Edit Links, Open/Close toggle.
+ b. Second row: Spicy Zone (danger nav).
+ c. [Back -> ViewManageCampaign]
+ 4. RenderManageDangerMenu (ViewManageDanger):
+ a. Buttons: Archive, Delete.
+ b. [Back -> ViewManageSettings]
+ 5. manageDownloadTokensHandler (manage_download_tokens:):
+ a. DM-auth check (auth.ScopeDM).
+ b. Loads CampaignPlayers with media_id != '' and their Media relation.
+ c. Opens each file from disk and replies with all attached at once,
+ deferred-cleanup via handles slice + defer.
+ d. Empty state -> ManageDownloadTokensNone terminal reply.
+
+ Notes:
+ - renderManageSubAuth centralizes the load-campaign + DM-auth + mutable
+ triple-check used by every sub-menu render. Returns (campaign, false)
+ and writes the error reply on any failure.
+ - Mutable check rejects archived campaigns: archived campaigns can be
+ viewed but not managed.
+*/
+
+func renderManageSubAuth(s *discordgo.Session, i *discordgo.InteractionCreate, database *bun.DB, campaignID string) (*models.Campaign, bool) {
+ userID := helpers.GetUserID(i)
+ campaign, err := db.GetByID[models.Campaign](database, campaignID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageCampaignNotFound)
+ return nil, false
+ }
+ ok, err := auth.Authorize(database, userID, auth.ScopeDM, campaignID)
+ if err != nil {
+ log.Printf("manage_submenu: auth check failed: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return nil, false
+ }
+ if !ok {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageNotAuthorized)
+ return nil, false
+ }
+ if !helpers.IsCampaignMutable(s, i, campaign) {
+ return nil, false
+ }
+ return campaign, true
+}
+
+// RenderManagePlayersMenu renders the Players sub-menu (Ban, Invite).
+func RenderManagePlayersMenu(s *discordgo.Session, i *discordgo.InteractionCreate, database *bun.DB, campaignID string) {
+ campaign, ok := renderManageSubAuth(s, i, database, campaignID)
+ if !ok {
+ return
+ }
+ helpers.RespondUpdate(s, i, fmt.Sprintf(messages.ManageCampaignHeader, campaign.Name), []*discordgo.MessageEmbed{}, []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.ManageBanLabel,
+ Style: discordgo.DangerButton,
+ CustomID: fmt.Sprintf("manage_ban:%s", campaignID),
+ },
+ discordgo.Button{
+ Label: messages.ManageInviteLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.ManageInvitePrefix, campaignID),
+ },
+ discordgo.Button{
+ Label: messages.ManageDownloadTokensLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.ManageDownloadTokensPrefix, campaignID),
+ },
+ }},
+ helpers.BackRow(router.ViewManageCampaign, campaignID),
+ })
+}
+
+// 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
+ }
+ _ = 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: messages.ManageNewSessionLabel,
+ Style: discordgo.SuccessButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.ManageNewSessionPrefix, campaignID),
+ },
+ }},
+ helpers.BackRow(router.ViewManageCampaign, campaignID),
+ })
+}
+
+// RenderManageSettingsMenu renders the Settings sub-menu (Set Cover, Set Role, Spicy Zone).
+func RenderManageSettingsMenu(s *discordgo.Session, i *discordgo.InteractionCreate, database *bun.DB, campaignID string) {
+ campaign, ok := renderManageSubAuth(s, i, database, campaignID)
+ if !ok {
+ return
+ }
+ toggleLabel := messages.ManageCloseLabel
+ if !campaign.IsOpen {
+ toggleLabel = messages.ManageOpenLabel
+ }
+ helpers.RespondUpdate(s, i, fmt.Sprintf(messages.ManageCampaignHeader, campaign.Name), []*discordgo.MessageEmbed{}, []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.SetCoverButtonLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: fmt.Sprintf("manage_setcover:%s", campaignID),
+ },
+ discordgo.Button{
+ Label: messages.ManageSetRoleLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.ManageSetRolePrefix, campaignID),
+ },
+ discordgo.Button{
+ Label: messages.ManageLinksLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.ManageLinksPrefix, campaignID),
+ },
+ discordgo.Button{
+ Label: toggleLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: fmt.Sprintf("campaign_toggle:%s", campaignID),
+ },
+ }},
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ router.NavButton(messages.ManageDangerLabel, discordgo.DangerButton, router.ViewManageDanger, campaignID),
+ }},
+ helpers.BackRow(router.ViewManageCampaign, campaignID),
+ })
+}
+
+/*
+manageDownloadTokensHandler sends all player tokens for a campaign as file attachments.
+
+Only the DM can trigger this. Skips players with no token assigned.
+
+CustomID: manage_download_tokens:
+*/
+type manageDownloadTokensHandler struct {
+ db *bun.DB
+}
+
+func (h *manageDownloadTokensHandler) CustomIDPrefix() string {
+ return messages.ManageDownloadTokensPrefix
+}
+
+func (h *manageDownloadTokensHandler) 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, err := db.GetByID[models.Campaign](h.db, campaignID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageCampaignNotFound)
+ return
+ }
+ if authorized, _ := auth.Authorize(h.db, userID, auth.ScopeDM, campaignID); !authorized {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageNotAuthorized)
+ return
+ }
+
+ var players []models.CampaignPlayer
+ if err := h.db.NewSelect().Model(&players).
+ Relation("Media").
+ Where("campaign_player.campaign_id = ?", campaignID).
+ Where("campaign_player.media_id != ''").
+ Scan(context.Background()); err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ if len(players) == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageDownloadTokensNone)
+ return
+ }
+
+ var handles []*os.File
+ defer func() {
+ for _, f := range handles {
+ f.Close()
+ }
+ }()
+
+ var files []*discordgo.File
+ for _, cp := range players {
+ if cp.Media == nil {
+ continue
+ }
+ f, err := os.Open(cp.Media.Path)
+ if err != nil {
+ log.Printf("manage_download_tokens: open %s: %v", cp.Media.Path, err)
+ continue
+ }
+ handles = append(handles, f)
+ name := cp.Media.Name
+ if name == "" {
+ name = cp.PlayerID[:8]
+ }
+ files = append(files, &discordgo.File{
+ Name: name + ".png",
+ ContentType: "image/png",
+ Reader: f,
+ })
+ }
+
+ if len(files) == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageDownloadTokensNone)
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf(messages.ManageDownloadTokensContent, campaign.Name, len(files)),
+ Files: files,
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+// RenderManageDangerMenu renders the Spicy Zone sub-menu (Archive, Delete).
+func RenderManageDangerMenu(s *discordgo.Session, i *discordgo.InteractionCreate, database *bun.DB, campaignID string) {
+ campaign, ok := renderManageSubAuth(s, i, database, campaignID)
+ if !ok {
+ return
+ }
+ helpers.RespondUpdate(s, i, fmt.Sprintf(messages.ManageCampaignHeader, campaign.Name), []*discordgo.MessageEmbed{}, []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.ManageArchiveLabel,
+ Style: discordgo.DangerButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.ManageArchivePrefix, campaignID),
+ },
+ discordgo.Button{
+ Label: messages.ManageDeleteLabel,
+ Style: discordgo.DangerButton,
+ CustomID: fmt.Sprintf("manage_delete:%s", campaignID),
+ },
+ }},
+ helpers.BackRow(router.ViewManageSettings, campaignID),
+ })
+}
diff --git a/internal/interactions/me_subviews.go b/internal/interactions/me_subviews.go
new file mode 100644
index 0000000..69d9e9b
--- /dev/null
+++ b/internal/interactions/me_subviews.go
@@ -0,0 +1,121 @@
+package interactions
+
+import (
+ "log"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "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"
+)
+
+/*
+ Flow:
+ Triggered from the /me hub by clicking Campaigns or Configuration.
+ Both render targets are reached via router nav (ViewMeCampaigns /
+ ViewMeConfig). This file owns the two sub-view layouts only.
+
+ 1. RenderMeCampaigns (ViewMeCampaigns):
+ a. Fixed buttons: New Campaign (success), My Campaigns, Browse.
+ b. [Back -> ViewMe]
+ 2. RenderMeConfig (ViewMeConfig):
+ a. Fixed buttons: Timezone, Notifications.
+ b. Conditional: Control Panel button appended if user DMs any
+ campaign (via isDMOfAnyCampaign probe over GetPlayerCampaigns).
+ c. Conditional: Admin Panel (danger) appended if user has ScopeMod.
+ d. [Back -> ViewMe]
+
+ Notes:
+ - Conditional buttons mean the row width varies (2-4 buttons). Always
+ fits Discord's 5-per-row limit because at most this adds 2 conditionals.
+ - isDMOfAnyCampaign swallows errors as "false" to fail-closed, because a glitchy
+ probe shouldn't expose DM-only UI to non-DMs.
+*/
+
+/*
+RenderMeCampaigns renders the Campaigns sub-view of the /me hub.
+
+Buttons: My Campaigns (grey) | New Campaign (green) | Browse (grey) | Back -> ViewMe
+*/
+func RenderMeCampaigns(s *discordgo.Session, i *discordgo.InteractionCreate, db *bun.DB, userID string) {
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: messages.MeCampaignsHeader,
+ Embeds: []*discordgo.MessageEmbed{},
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.NewCampaignLabel,
+ Style: discordgo.SuccessButton,
+ CustomID: messages.ManageNewCampaignPrefix,
+ },
+ router.NavButton(messages.MyCampaignsLabel, discordgo.SecondaryButton, router.ViewMyCampaigns),
+ router.NavButton(messages.BrowseCampaignsLabel, discordgo.SecondaryButton, router.ViewCampaignsBrowse, "all"),
+ }},
+ helpers.BackRow(router.ViewMe),
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+RenderMeConfig renders the Configuration sub-view of the /me hub.
+
+Fixed buttons: Timezone (grey), Notifications (grey).
+Conditional: Manage Campaigns (grey) if DM; Admin Panel (red) if mod/admin.
+*/
+func RenderMeConfig(s *discordgo.Session, i *discordgo.InteractionCreate, db *bun.DB, userID string) {
+ row := []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.TimezoneLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: messages.TimezonePrefix,
+ },
+ discordgo.Button{
+ Label: messages.NotificationsLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: messages.NotificationsPrefix,
+ },
+ }
+
+ if isDMOfAnyCampaign(db, userID) {
+ row = append(row, router.NavButton(messages.ControlPanelLabel, discordgo.SecondaryButton, router.ViewManage))
+ }
+ if isMod, err := auth.Authorize(db, userID, auth.ScopeMod, ""); err == nil && isMod {
+ row = append(row, router.NavButton(messages.AdminPanelLabel, discordgo.DangerButton, router.ViewAdmin))
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: messages.MeConfigHeader,
+ Embeds: []*discordgo.MessageEmbed{},
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: row},
+ helpers.BackRow(router.ViewMe),
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+// isDMOfAnyCampaign returns true if the user has at least one campaign membership with RoleDM.
+func isDMOfAnyCampaign(db *bun.DB, userID string) bool {
+ entries, err := models.GetPlayerCampaigns(db, userID)
+ if err != nil {
+ log.Printf("me_subviews: failed to probe DM status for %s: %v", userID, err)
+ return false
+ }
+ for _, e := range entries {
+ if e.Role == models.RoleDM {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/interactions/modal_campaign_create.go b/internal/interactions/modal_campaign_create.go
new file mode 100644
index 0000000..f2a9fa3
--- /dev/null
+++ b/internal/interactions/modal_campaign_create.go
@@ -0,0 +1,137 @@
+package interactions
+
+import (
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+ "fmt"
+ "log"
+ "strconv"
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "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"
+)
+
+/*
+ Flow:
+ 1. User submits the /newcampaign modal (3 fields: Name, Max Players, Synopsis & Rules).
+ 2. modalCampaignCreate parses the form, normalizes the tag from the name
+ (deduping if necessary), and creates the Campaign with IsApproved=false.
+ 3. Responds with the configuration message: book + format dropdowns and
+ Submit/Cancel buttons. The book/format handlers update the campaign
+ in-place; submit sends the approval DMs to staff (see newcampaign_config.go).
+*/
+
+// modalCampaignCreate handles the modal submission from `/newcampaign`.
+type modalCampaignCreate struct {
+ db *bun.DB
+ dispatch *dispatch.Dispatcher
+}
+
+func (m *modalCampaignCreate) CustomIDPrefix() string {
+ return messages.NewCampaignModalCustomID
+}
+
+func (m *modalCampaignCreate) HandleModal(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ data := i.ModalSubmitData()
+ userID := i.Member.User.ID
+
+ var name, slotsStr, description string
+ for _, row := range data.Components {
+ for _, comp := range row.(*discordgo.ActionsRow).Components {
+ input := comp.(*discordgo.TextInput)
+ switch input.CustomID {
+ case messages.FieldNameID:
+ name = input.Value
+ case messages.FieldSlotsID:
+ slotsStr = input.Value
+ case messages.FieldDescriptionID:
+ description = input.Value
+ }
+ }
+ }
+
+ name = strings.TrimSpace(name)
+ if name == "" {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignCreationFailureErrorMessage)
+ return
+ }
+
+ // Slots: empty field -> unlimited (-1). Otherwise must be a positive int (>= 1).
+ slots := -1
+ slotsStr = strings.TrimSpace(slotsStr)
+ if slotsStr != "" {
+ parsed, err := strconv.Atoi(slotsStr)
+ if err != nil || parsed < 1 {
+ helpers.RespondUpdateTerminal(s, i, messages.SlotCountMismatchErrorMessage)
+ return
+ }
+ slots = parsed
+ }
+
+ tag, err := uniqueTag(m.db, models.NormalizeTag(name))
+ if err != nil {
+ log.Printf("modal_campaign_create: tag dedup failed: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignCreationFailureErrorMessage)
+ return
+ }
+
+ conf := &models.GameConfig{}
+ schedule := &models.CampaignSchedule{}
+
+ campaign := &models.Campaign{}
+ created, err := campaign.CreateCampaign(
+ m.db,
+ userID,
+ nil, // no initial players
+ name,
+ tag,
+ description,
+ conf,
+ slots,
+ true, // open by default
+ false, // isOneshot β chosen later via dropdown
+ nil,
+ "",
+ schedule,
+ nil,
+ "",
+ nil,
+ nil,
+ false,
+ )
+ if err != nil {
+ log.Printf("modal_campaign_create: %s: %v", messages.CampaignCreationFailureErrorMessage, err)
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignAndRegistrationFailureErrorMessage)
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf(messages.NewCampaignConfigMessage, created.Name),
+ Components: newCampaignConfigComponents(created.ID),
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+// uniqueTag returns base unchanged if no campaign owns it; otherwise appends -2, -3, ...
+func uniqueTag(database *bun.DB, base string) (string, error) {
+ if base == "" {
+ base = "campaign"
+ }
+ candidate := base
+ for n := 2; n < 1000; n++ {
+ _, err := db.GetByTag[models.Campaign](database, candidate)
+ if err != nil {
+ return candidate, nil
+ }
+ candidate = fmt.Sprintf("%s-%d", base, n)
+ }
+ return "", fmt.Errorf("could not find a unique tag for %q", base)
+}
diff --git a/internal/interactions/nav_handler.go b/internal/interactions/nav_handler.go
new file mode 100644
index 0000000..59fa518
--- /dev/null
+++ b/internal/interactions/nav_handler.go
@@ -0,0 +1,46 @@
+package interactions
+
+/*
+ navHandler is the single component handler behind every "nav:" CustomID.
+
+ Flow:
+ 1. User clicks any button whose CustomID starts with "nav:".
+ 2. discordgo's prefix dispatcher matches on CustomIDPrefix() == "nav"
+ and invokes HandleComponents on this handler.
+ 3. HandleComponents calls router.ParseCustomID to split the CustomID
+ into (ViewID, args). Malformed IDs produce an ephemeral error so
+ the user isn't left on a spinning interaction.
+ 4. router.Navigate looks up the ViewID in the registry (populated at
+ startup by RegisterAllViews in views.go) and invokes the
+ registered RenderFunc, which renders the target view in place.
+
+ This handler is the only piece of glue between Discord's component
+ dispatch and the router. Registration of views themselves lives in
+ views.go.
+*/
+
+import (
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/interactions/router"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+type navHandler struct {
+ db *bun.DB
+}
+
+func (h *navHandler) CustomIDPrefix() string {
+ return router.NavPrefix
+}
+
+func (h *navHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ viewID, args, ok := router.ParseCustomID(i.MessageComponentData().CustomID)
+ if !ok {
+ helpers.RespondUpdateTerminal(s, i, messages.InvalidButtonDataMessage)
+ return
+ }
+ router.Navigate(s, i, viewID, args)
+}
diff --git a/internal/interactions/newcampaign_config.go b/internal/interactions/newcampaign_config.go
new file mode 100644
index 0000000..9120edf
--- /dev/null
+++ b/internal/interactions/newcampaign_config.go
@@ -0,0 +1,501 @@
+package interactions
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "math"
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+ "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/interactions/router"
+ "github.com/framebuffers/moontracer/internal/manager/models"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+ Flow (post-modal new-campaign config):
+ 1. modalCampaignCreate creates the Campaign in DB (IsApproved=false) and
+ shows a follow-up message with two dropdowns (book + format) and two
+ buttons (Submit / Cancel).
+ 2. Each dropdown selection updates the campaign in place via Update.
+ 3. Submit sends approval DMs to staff and confirms to the creator.
+ 4. Cancel deletes the campaign and its CampaignPlayer entries.
+
+ CustomID format:
+ newcampaign_book:
+ newcampaign_format:
+ newcampaign_submit:
+ newcampaign_cancel:
+*/
+
+/*
+newCampaignConfigComponents builds the book/format/frequency dropdowns + submit/cancel buttons.
+
+Reused by the modal create handler and any re-render path.
+*/
+func newCampaignConfigComponents(campaignID string) []discordgo.MessageComponent {
+ bookSelect := discordgo.SelectMenu{
+ CustomID: fmt.Sprintf("%s:%s", messages.NewCampaignBookPrefix, campaignID),
+ Placeholder: messages.NewCampaignBookPlaceholder,
+ Options: []discordgo.SelectMenuOption{
+ // note: to add more options, load the label from messages.go, and set the value here.
+ {Label: messages.NewCampaignBookLabel5e, Value: "5e"},
+ {Label: messages.NewCampaignBookLabel55e, Value: "5.5e"},
+ {Label: messages.NewCampaignBookLabelPF2e, Value: "pf2e"},
+ {Label: messages.NewCampaignBookLabelOther, Value: "other"},
+ },
+ }
+
+ formatSelect := discordgo.SelectMenu{
+ CustomID: fmt.Sprintf("%s:%s", messages.NewCampaignFormatPrefix, campaignID),
+ Placeholder: messages.NewCampaignFormatPlaceholder,
+ Options: []discordgo.SelectMenuOption{
+ {Label: messages.CampaignLabel, Value: "campaign"},
+ {Label: messages.CampaignTypeOneShotLabel, Value: "oneshot"},
+ {Label: messages.CampaignTypeWestmarchLabel, Value: "westmarch"},
+ },
+ }
+
+ 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,
+ Style: discordgo.SuccessButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.NewCampaignSubmitPrefix, campaignID),
+ },
+ discordgo.Button{
+ Label: messages.NewCampaignCancelLabel,
+ Style: discordgo.DangerButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.NewCampaignCancelPrefix, campaignID),
+ },
+ }},
+ }
+}
+
+// parseConfigCustomID extracts the campaign ID from a ":" CustomID.
+func parseConfigCustomID(customID string) (string, bool) {
+ parts := strings.SplitN(customID, ":", 2)
+ if len(parts) < 2 || parts[1] == "" {
+ return "", false
+ }
+ return parts[1], true
+}
+
+// loadCampaignForConfig fetches the campaign and verifies the invoker is the DM (creator).
+func loadCampaignForConfig(database *bun.DB, campaignID, userID string) (*models.Campaign, error) {
+ c, err := db.GetByID[models.Campaign](database, campaignID)
+ if err != nil {
+ return nil, err
+ }
+ if c.DungeonMaster != userID {
+ return nil, fmt.Errorf("not authorized")
+ }
+ return c, nil
+}
+
+/*
+ Book Select
+*/
+
+type newCampaignBookHandler struct {
+ db *bun.DB
+}
+
+func (h *newCampaignBookHandler) CustomIDPrefix() string { return messages.NewCampaignBookPrefix }
+
+func (h *newCampaignBookHandler) 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.Game.Edition = values[0]
+ if err := db.Update(h.db, c); err != nil {
+ log.Printf("newcampaign_book: update failed: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ /*
+ Updating the message for any reason will reset the message.
+ Therefore, ack silently and keep going.
+ */
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseDeferredMessageUpdate,
+ })
+}
+
+/*
+ Format Select
+*/
+
+type newCampaignFormatHandler struct {
+ db *bun.DB
+}
+
+func (h *newCampaignFormatHandler) CustomIDPrefix() string { return messages.NewCampaignFormatPrefix }
+
+func (h *newCampaignFormatHandler) 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
+ }
+
+ switch values[0] {
+ case "oneshot":
+ c.IsOneshot = true
+ c.IsWestmarch = false
+ c.Schedule.Frequency = models.OneShot
+ case "westmarch":
+ c.IsOneshot = false
+ c.IsWestmarch = true
+ c.Schedule.Frequency = models.Westmarch
+ c.Slots = math.MaxInt32
+ default:
+ c.IsOneshot = false
+ c.IsWestmarch = false
+ c.Schedule.Frequency = models.Weekly
+ }
+
+ if err := db.Update(h.db, c); err != nil {
+ log.Printf("newcampaign_format: update failed: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ /*
+ Updating the message for any reason will reset the message.
+ Therefore, ack silently and keep going.
+ */
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseDeferredMessageUpdate,
+ })
+}
+
+/*
+ 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 {
+ db *bun.DB
+ dispatcher *dispatch.Dispatcher
+}
+
+func (h *newCampaignSubmitHandler) CustomIDPrefix() string { return messages.NewCampaignSubmitPrefix }
+
+func (h *newCampaignSubmitHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ campaignID, ok := parseConfigCustomID(i.MessageComponentData().CustomID)
+ if !ok {
+ helpers.RespondUpdateTerminal(s, i, messages.InvalidButtonDataMessage)
+ return
+ }
+
+ userID := helpers.GetUserID(i)
+ c, err := loadCampaignForConfig(h.db, campaignID, userID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageCampaignNotFound)
+ return
+ }
+
+ 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_schedule: failed to get staff: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignStaffNotifyFailureMessage)
+ return
+ }
+
+ guildID := i.GuildID
+ approvalButtons := []discordgo.MessageComponent{
+ discordgo.ActionsRow{
+ Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.ApproveButtonLabel,
+ Style: discordgo.SuccessButton,
+ CustomID: messages.CampaignApprovePrefix + ":" + guildID + ":" + c.ID,
+ },
+ discordgo.Button{
+ Label: messages.DenyButtonLabel,
+ Style: discordgo.DangerButton,
+ CustomID: messages.CampaignDenyPrefix + ":" + guildID + ":" + c.ID,
+ },
+ },
+ },
+ }
+
+ players, _ := models.GetCampaignPlayers(h.db, c.ID)
+ coverURL := models.CoverURLForCampaign(h.db, c.ID)
+ campaignEmbed := commands.CampaignEmbed(*c, players, coverURL, "", userID)
+
+ msgID := uuid.NewString()
+ for _, staff := range staffMembers {
+ h.dispatcher.Push(dispatch.DirectMessage{
+ ID: msgID,
+ Sender: userID,
+ Target: staff.ID,
+ Content: fmt.Sprintf(messages.CampaignApprovalRequestMessage, c.Name, userID),
+ Components: approvalButtons,
+ Embeds: []*discordgo.MessageEmbed{campaignEmbed},
+ })
+ }
+
+ 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,
+ },
+ })
+}
+
+/*
+ Cancellation
+*/
+
+type newCampaignCancelHandler struct {
+ db *bun.DB
+}
+
+func (h *newCampaignCancelHandler) CustomIDPrefix() string { return messages.NewCampaignCancelPrefix }
+
+func (h *newCampaignCancelHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ campaignID, ok := parseConfigCustomID(i.MessageComponentData().CustomID)
+ if !ok {
+ helpers.RespondUpdateTerminal(s, i, messages.InvalidButtonDataMessage)
+ return
+ }
+
+ c, err := loadCampaignForConfig(h.db, campaignID, helpers.GetUserID(i))
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.ManageCampaignNotFound)
+ return
+ }
+
+ ctx := context.Background()
+ if _, err := h.db.NewDelete().Model((*models.CampaignPlayer)(nil)).
+ Where("campaign_id = ?", c.ID).Exec(ctx); err != nil {
+ log.Printf("newcampaign_cancel: failed to delete campaign players: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ if err := db.Delete[models.Campaign](h.db, c.ID); err != nil {
+ log.Printf("newcampaign_cancel: failed to delete campaign: %v", err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ helpers.RespondUpdate(s, i, messages.NewCampaignCancelledMessage, nil, nil)
+}
diff --git a/internal/interactions/next_sessions.go b/internal/interactions/next_sessions.go
new file mode 100644
index 0000000..a744295
--- /dev/null
+++ b/internal/interactions/next_sessions.go
@@ -0,0 +1,77 @@
+package interactions
+
+/*
+ Next Sessions handler for the /me hub.
+
+ Flow:
+ 1. User clicks "Next Sessions" button on the /me hub.
+ 2. Load all upcoming sessions the player is a member of, across all approved campaigns.
+ 3. Sort by scheduled_at ascending (done by the query).
+ 4. Render a list with campaign name, date/time, and time remaining.
+ 5. Back button returns to /me hub.
+*/
+
+import (
+ "fmt"
+ "log"
+ "strings"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "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 {
+ db *bun.DB
+}
+
+func (h *nextSessionsHandler) CustomIDPrefix() string {
+ return messages.NextSessionsPrefix
+}
+
+func (h *nextSessionsHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ userID := helpers.GetUserID(i)
+
+ sessions, err := models.GetAllUpcomingSessionsForPlayer(h.db, userID)
+ if err != nil {
+ log.Printf("next_sessions: failed to load sessions for %s: %v", userID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ backRow := discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ router.BackButton(messages.BackLabel, router.ViewMe),
+ }}
+
+ if len(sessions) == 0 {
+ helpers.RespondUpdate(s, i, messages.NextSessionsNone, nil, []discordgo.MessageComponent{backRow})
+ return
+ }
+
+ settings, err := models.GetOrCreatePlayerSettings(h.db, userID)
+ if err != nil {
+ log.Printf("next_sessions: load settings for %s: %v", userID, err)
+ }
+ loc := time.UTC
+ if settings != nil {
+ loc = settings.Location()
+ }
+
+ var lines []string
+ for _, sess := range sessions {
+ campaignName := ""
+ if sess.Campaign != nil {
+ campaignName = sess.Campaign.Name
+ }
+ formatted := helpers.FormatInLocation(sess.ScheduledAt, messages.SessionListFormat, loc) + " " + helpers.TZLabel(loc)
+ lines = append(lines, fmt.Sprintf("β’ **%s** β %s Β· %s", campaignName, formatted, helpers.TimeRemaining(sess.ScheduledAt)))
+ }
+ content := messages.NextSessionsHeader + "\n" + strings.Join(lines, "\n")
+
+ helpers.RespondUpdate(s, i, content, nil, []discordgo.MessageComponent{backRow})
+}
diff --git a/internal/interactions/notifications.go b/internal/interactions/notifications.go
new file mode 100644
index 0000000..88e66fe
--- /dev/null
+++ b/internal/interactions/notifications.go
@@ -0,0 +1,126 @@
+package interactions
+
+/*
+ Notifications handler for the /me hub.
+
+ Flow:
+ 1. User clicks "Notifications" button on the /me hub.
+ 2. Load (or create) PlayerSettings for the user.
+ 3. Render current preferences as toggle buttons:
+ - Announcements (green = ON, red = OFF)
+ - Session Reminders
+ - Invitations
+ 4. Clicking a toggle flips the stored bool and re-renders.
+ 5. Back button returns to /me hub.
+
+ CustomID formats:
+ notifications -- open panel
+ notif_toggle: -- flip field (announcements|sessions|invitations)
+*/
+
+import (
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+ "context"
+ "fmt"
+ "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"
+)
+
+type notificationsHandler struct {
+ db *bun.DB
+}
+
+func (h *notificationsHandler) CustomIDPrefix() string {
+ return messages.NotificationsPrefix
+}
+
+func (h *notificationsHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ userID := helpers.GetUserID(i)
+ renderNotificationsPanel(s, i, h.db, userID)
+}
+
+// notifToggleHandler flips one notification preference and re-renders the panel.
+type notifToggleHandler struct {
+ db *bun.DB
+}
+
+func (h *notifToggleHandler) CustomIDPrefix() string {
+ return messages.NotifTogglePrefix
+}
+
+func (h *notifToggleHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ field := parts[1]
+ userID := helpers.GetUserID(i)
+
+ settings, err := models.GetOrCreatePlayerSettings(h.db, userID)
+ if err != nil {
+ log.Printf("notif_toggle: load failed for %s: %v", userID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.NotifLoadFailed)
+ return
+ }
+
+ switch field {
+ case messages.NotifFieldAnnouncements:
+ settings.NotifyAnnouncements = !settings.NotifyAnnouncements
+ case messages.NotifFieldSessions:
+ settings.NotifySessionRemind = !settings.NotifySessionRemind
+ case messages.NotifFieldInvitations:
+ settings.NotifyInvitations = !settings.NotifyInvitations
+ default:
+ helpers.RespondUpdateTerminal(s, i, messages.InvalidButtonDataMessage)
+ return
+ }
+
+ if _, err := h.db.NewUpdate().Model(settings).WherePK().Exec(context.Background()); err != nil {
+ log.Printf("notif_toggle: update failed for %s: %v", userID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.NotifUpdateFailed)
+ return
+ }
+
+ renderNotificationsPanel(s, i, h.db, userID)
+}
+
+// renderNotificationsPanel shows toggle buttons for the user's current settings.
+func renderNotificationsPanel(s *discordgo.Session, i *discordgo.InteractionCreate, db *bun.DB, userID string) {
+ settings, err := models.GetOrCreatePlayerSettings(db, userID)
+ if err != nil {
+ log.Printf("notifications: load failed for %s: %v", userID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.NotifLoadFailed)
+ return
+ }
+
+ row := discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ notifToggleButton(messages.NotifFieldAnnouncements, messages.NotifLabelAnnouncements, settings.NotifyAnnouncements),
+ notifToggleButton(messages.NotifFieldSessions, messages.NotifLabelSessions, settings.NotifySessionRemind),
+ notifToggleButton(messages.NotifFieldInvitations, messages.NotifLabelInvitations, settings.NotifyInvitations),
+ }}
+ backRow := discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ router.BackButton(messages.BackLabel, router.ViewMe),
+ }}
+
+ helpers.RespondUpdate(s, i, messages.NotificationsHeader, nil, []discordgo.MessageComponent{row, backRow})
+}
+
+func notifToggleButton(field, label string, enabled bool) discordgo.Button {
+ style := discordgo.DangerButton
+ text := fmt.Sprintf(messages.NotifDisabledSuffix, label)
+ if enabled {
+ style = discordgo.SuccessButton
+ text = fmt.Sprintf(messages.NotifEnabledSuffix, label)
+ }
+ return discordgo.Button{
+ Label: text,
+ Style: style,
+ CustomID: fmt.Sprintf("%s:%s", messages.NotifTogglePrefix, field),
+ }
+}
diff --git a/internal/interactions/player_settings.go b/internal/interactions/player_settings.go
new file mode 100644
index 0000000..2ec5b28
--- /dev/null
+++ b/internal/interactions/player_settings.go
@@ -0,0 +1,711 @@
+package interactions
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+
+ "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/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"
+)
+
+/*
+ Flow:
+ Triggered from the campaign detail view (campaign_detail.go) and from
+ "My Campaigns". This file owns every per-campaign player-side action
+ that isn't Join/Leave-flow itself. Six sub-flows live here:
+
+ 1. Set Sheet (player_set_sheet:): playerSetSheetHandler
+ a. Loads the viewer's CampaignPlayer.
+ b. Opens a modal pre-filled with current SheetURL.
+ c. Modal submit (player_set_sheet_modal:):
+ playerSetSheetModal writes CP.SheetURL and replies with success.
+
+ 2. Set Token (player_set_token:): playerSetTokenHandler
+ a. Loads the viewer's KindTokenPlayer media (newest first).
+ b. Empty -> PlayerNoTokens terminal.
+ c. Exactly one -> renders embed + single Assign button.
+ d. Many -> renders SelectMenu of tokens.
+ [Back -> ViewMyCampaigns]
+ Selection (player_token_select:) -> playerTokenSelectHandler:
+ Renders a preview embed for the chosen token + Assign button.
+ Assign click (player_token_assign::) ->
+ playerTokenAssignHandler updates CampaignPlayer.media_id.
+
+ 3. Leave Campaign (player_leave_confirm:):
+ playerLeaveConfirmPromptHandler
+ a. Rejects with MasterIsLeaving... if viewer is the DM (DM-of-self
+ cannot leave; archive/delete via manage menu instead).
+ b. Renders Confirm/Cancel pair.
+ Confirm (player_leave_do:) -> playerLeaveDoHandler:
+ a. Re-runs the DM guard (defence-in-depth against stale buttons).
+ b. RemoveCampaignPlayer + best-effort GuildMemberRoleRemove.
+
+ 4. Contact DM (player_contact_dm:):
+ playerContactDMHandler opens a paragraph-input modal.
+ Modal submit (player_contact_dm_modal:):
+ playerContactDMModal pushes the message to the DM via dispatch
+ (rejects if the viewer IS the campaign DM -- can't contact yourself).
+
+ 5. Download Tokens (player_download_tokens): playerDownloadTokensHandler
+ a. Lists all the viewer's campaigns that have a token assigned.
+ b. Renders SelectMenu with an "All" option + one per campaign.
+ c. [Back -> ViewMyCampaigns]
+ Selection (player_download_select) -> playerDownloadSelectHandler:
+ a. "All" -> top 10 KindTokenPlayer media for the user (newest first).
+ b. campaignID -> single token assigned to that CampaignPlayer.
+ c. Opens each file, attaches them ephemerally, defers close until
+ after response.
+
+ Notes:
+ - All flows are viewer-scoped (use helpers.GetUserID); none require
+ DM-side authorization.
+ - Set Token here is the in-campaign assignment counterpart; the gallery
+ in player_tokens.go is the cross-campaign manager.
+*/
+
+/*
+playerSetSheetHandler opens a modal for the player to set their character sheet URL.
+
+CustomID: player_set_sheet:
+*/
+type playerSetSheetHandler struct {
+ db *bun.DB
+}
+
+func (h *playerSetSheetHandler) CustomIDPrefix() string { return messages.PlayerSetSheetPrefix }
+
+func (h *playerSetSheetHandler) 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)
+
+ cp, err := models.GetCampaignPlayer(h.db, userID, campaignID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseModal,
+ Data: &discordgo.InteractionResponseData{
+ CustomID: fmt.Sprintf("%s:%s", messages.PlayerSetSheetModalID, campaignID),
+ Title: messages.PlayerSetSheetModalTitle,
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: messages.PlayerSetSheetFieldID,
+ Label: messages.PlayerSetSheetFieldLabel,
+ Style: discordgo.TextInputShort,
+ Placeholder: messages.PlayerSetSheetFieldPlaceholder,
+ Value: cp.SheetURL,
+ Required: false,
+ MaxLength: 500,
+ },
+ }},
+ },
+ },
+ })
+}
+
+/*
+playerSetSheetModal saves the character sheet URL to the player's campaign membership.
+
+CustomID: player_set_sheet_modal:
+*/
+type playerSetSheetModal struct {
+ db *bun.DB
+}
+
+func (h *playerSetSheetModal) CustomIDPrefix() string { return messages.PlayerSetSheetModalID }
+
+func (h *playerSetSheetModal) 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)
+
+ cp, err := models.GetCampaignPlayer(h.db, userID, campaignID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ for _, row := range i.ModalSubmitData().Components {
+ for _, comp := range row.(*discordgo.ActionsRow).Components {
+ if ti, ok := comp.(*discordgo.TextInput); ok && ti.CustomID == messages.PlayerSetSheetFieldID {
+ cp.SheetURL = ti.Value
+ }
+ }
+ }
+
+ if err := db.Update(h.db, cp); err != nil {
+ log.Printf("player_set_sheet: update failed for player %s campaign %s: %v", userID, campaignID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.PlayerSetSheetFailed)
+ return
+ }
+
+ campaign, err := db.GetByID[models.Campaign](h.db, campaignID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.PlayerSetSheetSuccess)
+ return
+ }
+ helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.PlayerSetSheetSuccess, campaign.Name))
+}
+
+/*
+playerSetTokenHandler shows the player their available tokens to assign to this campaign.
+
+CustomID: player_set_token:
+*/
+type playerSetTokenHandler struct {
+ db *bun.DB
+}
+
+func (h *playerSetTokenHandler) CustomIDPrefix() string { return messages.PlayerSetTokenPrefix }
+
+func (h *playerSetTokenHandler) 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)
+
+ var tokens []*models.Media
+ if err := h.db.NewSelect().Model(&tokens).
+ Where("owner_id = ? AND kind = ?", userID, models.KindTokenPlayer).
+ OrderExpr("created_at DESC").
+ Scan(context.Background()); err != nil {
+ log.Printf("player_set_token: load tokens for player %s: %v", userID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ if len(tokens) == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.PlayerNoTokens)
+ return
+ }
+
+ if len(tokens) == 1 {
+ t := tokens[0]
+ assignID := fmt.Sprintf("%s:%s:%s", messages.PlayerTokenAssignPrefix, campaignID, t.ID)
+ embed := &discordgo.MessageEmbed{
+ Title: t.Name,
+ Color: messages.EmbedColor,
+ Image: &discordgo.MessageEmbedImage{URL: t.URL},
+ }
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: messages.PlayerTokenNewHint,
+ Embeds: []*discordgo.MessageEmbed{embed},
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.PlayerTokenAssignLabel,
+ Style: discordgo.SuccessButton,
+ CustomID: assignID,
+ },
+ }},
+ helpers.BackRow(router.ViewMyCampaigns),
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+ return
+ }
+
+ var options []discordgo.SelectMenuOption
+ for _, t := range tokens {
+ label := t.Name
+ if label == "" {
+ label = t.ID[:8]
+ }
+ options = append(options, discordgo.SelectMenuOption{
+ Label: label,
+ Value: t.ID,
+ })
+ }
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: messages.PlayerTokenNewHint,
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.SelectMenu{
+ CustomID: fmt.Sprintf("%s:%s", messages.PlayerTokenSelectPrefix, campaignID),
+ Placeholder: messages.PlayerTokenSelectPlaceholder,
+ Options: options,
+ },
+ }},
+ helpers.BackRow(router.ViewMyCampaigns),
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+playerTokenSelectHandler handles a token selection from the select menu and shows a preview.
+
+CustomID: player_token_select:
+*/
+type playerTokenSelectHandler struct {
+ db *bun.DB
+}
+
+func (h *playerTokenSelectHandler) CustomIDPrefix() string { return messages.PlayerTokenSelectPrefix }
+
+func (h *playerTokenSelectHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ campaignID := parts[1]
+
+ values := i.MessageComponentData().Values
+ if len(values) == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ mediaID := values[0]
+
+ media, err := db.GetByID[models.Media](h.db, mediaID)
+ if err != nil {
+ log.Printf("player_token_select: load media %s: %v", mediaID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ assignID := fmt.Sprintf("%s:%s:%s", messages.PlayerTokenAssignPrefix, campaignID, mediaID)
+ embed := &discordgo.MessageEmbed{
+ Title: "Token Preview",
+ Color: messages.EmbedColor,
+ Image: &discordgo.MessageEmbedImage{URL: media.URL},
+ }
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Embeds: []*discordgo.MessageEmbed{embed},
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.PlayerTokenAssignLabel,
+ Style: discordgo.SuccessButton,
+ CustomID: assignID,
+ },
+ }},
+ helpers.BackRow(router.ViewMyCampaigns),
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+playerTokenAssignHandler assigns the selected token to the player's campaign slot.
+
+CustomID: player_token_assign::
+*/
+type playerTokenAssignHandler struct {
+ db *bun.DB
+}
+
+func (h *playerTokenAssignHandler) CustomIDPrefix() string { return messages.PlayerTokenAssignPrefix }
+
+func (h *playerTokenAssignHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 3)
+ if !ok {
+ return
+ }
+ campaignID, mediaID := parts[1], parts[2]
+ userID := helpers.GetUserID(i)
+
+ _, err := h.db.NewUpdate().Model((*models.CampaignPlayer)(nil)).
+ Set("media_id = ?", mediaID).
+ Where("player_id = ? AND campaign_id = ?", userID, campaignID).
+ Exec(context.Background())
+ if err != nil {
+ log.Printf("player_token_assign: update failed for player %s campaign %s: %v", userID, campaignID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ campaign, err := db.GetByID[models.Campaign](h.db, campaignID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.PlayerTokenAssignSuccess)
+ return
+ }
+ helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.PlayerTokenAssignSuccess, campaign.Name))
+}
+
+/*
+playerLeaveConfirmPromptHandler shows a confirmation prompt before leaving a campaign.
+
+CustomID: player_leave_confirm:
+*/
+type playerLeaveConfirmPromptHandler struct {
+ db *bun.DB
+}
+
+func (h *playerLeaveConfirmPromptHandler) CustomIDPrefix() string {
+ return messages.PlayerLeaveConfirmPrefix
+}
+
+func (h *playerLeaveConfirmPromptHandler) 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, err := db.GetByID[models.Campaign](h.db, campaignID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignNotFoundMessage)
+ return
+ }
+
+ if isDM, _ := auth.Authorize(h.db, userID, auth.ScopeDM, campaignID); isDM {
+ helpers.RespondUpdateTerminal(s, i, messages.MasterIsLeavingCampaignErrorMessage)
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf(messages.PlayerLeaveConfirmMsg, campaign.Name),
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.PlayerLeaveConfirmLabel,
+ Style: discordgo.DangerButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.PlayerLeaveDoPrefix, campaignID),
+ },
+ router.NavButton(messages.PlayerLeaveCancelLabel, discordgo.SecondaryButton, router.ViewMyCampaigns),
+ }},
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+playerLeaveDoHandler executes the campaign leave after confirmation.
+
+CustomID: player_leave_do:
+*/
+type playerLeaveDoHandler struct {
+ db *bun.DB
+}
+
+func (h *playerLeaveDoHandler) CustomIDPrefix() string { return messages.PlayerLeaveDoPrefix }
+
+func (h *playerLeaveDoHandler) 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, err := db.GetByID[models.Campaign](h.db, campaignID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignNotFoundMessage)
+ return
+ }
+
+ if isDM, _ := auth.Authorize(h.db, userID, auth.ScopeDM, campaignID); isDM {
+ helpers.RespondUpdateTerminal(s, i, messages.MasterIsLeavingCampaignErrorMessage)
+ return
+ }
+
+ if err := models.RemoveCampaignPlayer(h.db, userID, campaign.ID); err != nil {
+ log.Printf("player_leave_do: remove player %s from campaign %s: %v", userID, campaignID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.FailedToLeaveCampaignErrorMessage)
+ return
+ }
+
+ if campaign.RoleID != "" {
+ if err := guard.GuildMemberRoleRemove(s, i.GuildID, userID, campaign.RoleID); err != nil {
+ log.Printf("player_leave_do: remove role %s from player %s: %v", campaign.RoleID, userID, err)
+ }
+ }
+
+ helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.PlayerLeftCampaignMessage, campaign.Name))
+}
+
+/*
+playerContactDMHandler opens a modal for the player to send a message to the DM.
+
+CustomID: player_contact_dm:
+*/
+type playerContactDMHandler struct {
+ db *bun.DB
+}
+
+func (h *playerContactDMHandler) CustomIDPrefix() string { return messages.PlayerContactDMPrefix }
+
+func (h *playerContactDMHandler) 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, err := db.GetByID[models.Campaign](h.db, campaignID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignNotFoundMessage)
+ return
+ }
+
+ if campaign.DungeonMaster == userID {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseModal,
+ Data: &discordgo.InteractionResponseData{
+ CustomID: fmt.Sprintf("%s:%s", messages.PlayerContactDMModalID, campaignID),
+ Title: fmt.Sprintf(messages.PlayerContactDMModalTitle),
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: messages.PlayerContactDMFieldID,
+ Label: messages.PlayerContactDMFieldLabel,
+ Style: discordgo.TextInputParagraph,
+ Placeholder: messages.PlayerContactDMFieldPlaceholder,
+ Required: true,
+ MaxLength: 1000,
+ },
+ }},
+ },
+ },
+ })
+}
+
+/*
+playerContactDMModal delivers the player's message to the DM via DM dispatch.
+
+CustomID: player_contact_dm_modal:
+*/
+type playerContactDMModal struct {
+ db *bun.DB
+ dispatcher *dispatch.Dispatcher
+}
+
+func (h *playerContactDMModal) CustomIDPrefix() string { return messages.PlayerContactDMModalID }
+
+func (h *playerContactDMModal) 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)
+
+ campaign, err := db.GetByID[models.Campaign](h.db, campaignID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.CampaignNotFoundMessage)
+ return
+ }
+
+ var message string
+ for _, row := range i.ModalSubmitData().Components {
+ for _, comp := range row.(*discordgo.ActionsRow).Components {
+ if ti, ok := comp.(*discordgo.TextInput); ok && ti.CustomID == messages.PlayerContactDMFieldID {
+ message = ti.Value
+ }
+ }
+ }
+
+ h.dispatcher.Push(dispatch.DirectMessage{
+ ID: fmt.Sprintf("contact:%s:%s", campaignID, userID),
+ Target: campaign.DungeonMaster,
+ Content: fmt.Sprintf(messages.PlayerContactDMReceived, userID, campaign.Name, message),
+ })
+
+ helpers.RespondUpdateTerminal(s, i, messages.PlayerContactDMSuccess)
+}
+
+/*
+playerDownloadTokensHandler shows a campaign select for the player to pick
+which token(s) to download.
+
+CustomID: player_download_tokens
+*/
+type playerDownloadTokensHandler struct {
+ db *bun.DB
+}
+
+func (h *playerDownloadTokensHandler) CustomIDPrefix() string {
+ return messages.PlayerDownloadTokensPrefix
+}
+
+func (h *playerDownloadTokensHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ userID := helpers.GetUserID(i)
+
+ all, err := models.GetPlayerCampaigns(h.db, userID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ // NOTE: Consider only campaigns where the player has a token assigned.
+ var withToken []models.CampaignPlayer
+ for _, cp := range all {
+ if cp.MediaID != "" && cp.Campaign != nil {
+ withToken = append(withToken, cp)
+ }
+ }
+ if len(withToken) == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.PlayerDownloadNoTokens)
+ return
+ }
+
+ options := []discordgo.SelectMenuOption{
+ {Label: messages.PlayerDownloadAllLabel, Value: messages.PlayerDownloadAllValue},
+ }
+ for _, cp := range withToken {
+ options = append(options, discordgo.SelectMenuOption{
+ Label: cp.Campaign.Name,
+ Value: cp.CampaignID,
+ })
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: "Select a campaign to download tokens for:",
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.SelectMenu{
+ CustomID: messages.PlayerDownloadSelectPrefix,
+ Placeholder: messages.PlayerDownloadSelectPlaceholder,
+ Options: options,
+ },
+ }},
+ helpers.BackRow(router.ViewMyCampaigns),
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+playerDownloadSelectHandler sends the player's token file(s) as ephemeral attachments.
+
+Selected value is either messages.PlayerDownloadAllValue ("all") or a campaignID.
+
+CustomID: player_download_select
+*/
+type playerDownloadSelectHandler struct {
+ db *bun.DB
+}
+
+func (h *playerDownloadSelectHandler) CustomIDPrefix() string {
+ return messages.PlayerDownloadSelectPrefix
+}
+
+func (h *playerDownloadSelectHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ userID := helpers.GetUserID(i)
+
+ values := i.MessageComponentData().Values
+ if len(values) == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ selected := values[0]
+
+ var mediaRecords []*models.Media
+
+ if selected == messages.PlayerDownloadAllValue {
+ if err := h.db.NewSelect().Model(&mediaRecords).
+ Where("owner_id = ? AND kind = ?", userID, models.KindTokenPlayer).
+ OrderExpr("created_at DESC").
+ Limit(10).
+ Scan(context.Background()); err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ } else {
+ cp, err := models.GetCampaignPlayer(h.db, userID, selected)
+ if err != nil || cp.MediaID == "" {
+ helpers.RespondUpdateTerminal(s, i, messages.PlayerDownloadNoTokens)
+ return
+ }
+ media, err := db.GetByID[models.Media](h.db, cp.MediaID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ mediaRecords = []*models.Media{media}
+ }
+
+ if len(mediaRecords) == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.PlayerDownloadNoTokens)
+ return
+ }
+
+ /*
+ Open files and build attachment list.
+ Keep handles open until after respond.
+ */
+ var handles []*os.File
+ defer func() {
+ for _, f := range handles {
+ f.Close()
+ }
+ }()
+
+ var files []*discordgo.File
+ for _, m := range mediaRecords {
+ f, err := os.Open(m.Path)
+ if err != nil {
+ log.Printf("player_download_select: open %s: %v", m.Path, err)
+ continue
+ }
+ handles = append(handles, f)
+ name := m.Name
+ if name == "" {
+ name = m.ID[:8]
+ }
+ files = append(files, &discordgo.File{
+ Name: name + ".png",
+ ContentType: "image/png",
+ Reader: f,
+ })
+ }
+
+ if len(files) == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.PlayerDownloadNoTokens)
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf(messages.PlayerDownloadContent, len(files)),
+ Files: files,
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
diff --git a/internal/interactions/player_tokens.go b/internal/interactions/player_tokens.go
new file mode 100644
index 0000000..360c8c6
--- /dev/null
+++ b/internal/interactions/player_tokens.go
@@ -0,0 +1,460 @@
+package interactions
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "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"
+)
+
+/*
+ Flow
+ Triggered from player token gallery, entered from /me -> Tokens):
+ 1. ViewMeTokens nav target calls RenderMeTokens,
+ a. loads up to 10 of the player's KindTokenPlayer media rows (newest first) and,
+ b. renders one embed per token plus a select menu listing them.
+ c. Back button -> ViewMe.
+ d. Empty state shows TokenGalleryNone with only a Back row.
+ 2. Selecting a token: tokenGallerySelectHandler renders a detail
+ view (single embed) with three action buttons:
+ - Assign (success): tokenGalleryAssignHandler
+ - Download (secondary): tokenDownloadHandler
+ - Delete (danger): tokenDeletePromptHandler
+ [Back button -> ViewMeTokens]
+ 3. Assign click: tokenGalleryAssignHandler renders a campaign select
+ sourced from GetPlayerCampaigns.
+ - Selection -> tokenGalleryAssignSelectHandler updates CampaignPlayer.media_id
+ for (userID, campaignID).
+ 4. Download click: tokenDownloadHandler opens the out_ file on disk
+ and replies with it as an ephemeral attachment.
+ [Back -> ViewMeTokens]
+ 5. Delete click: tokenDeletePromptHandler shows a Confirm/Cancel pair.
+ [Confirm -> tokenDeleteConfirmHandler]
+ a. Clears media_id on every CampaignPlayer referencing this token
+ (avoids dangling refs across all campaigns).
+ b. Deletes the Media row.
+ c. Removes the file from disk (logs but ignores not-exist).
+
+ Notes:
+ - Discord caps embeds per ephemeral message at 10
+ - the gallery query honors that limit and shows newest first.
+ - This file is the post-creation counterpart to token_confirm.go:
+ token_confirm.go handles the one-shot assignment immediately after
+ /uploadtoken. This file handles all later management.
+*/
+
+/*
+RenderMeTokens renders the token gallery: up to 10 embeds (one per token) with a manage select below.
+
+Navigated to via ViewMeTokens. Back goes to ViewMe.
+
+Note:
+
+ Discord limits files/embeds being shown inside ephemeral messages to 10.
+*/
+func RenderMeTokens(s *discordgo.Session, i *discordgo.InteractionCreate, bdb *bun.DB, userID string) {
+ var tokens []*models.Media
+ if err := bdb.NewSelect().Model(&tokens).
+ Where("owner_id = ? AND kind = ?", userID, models.KindTokenPlayer).
+ OrderExpr("created_at DESC").
+ Limit(10).
+ Scan(context.Background()); err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ if len(tokens) == 0 {
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: messages.TokenGalleryNone,
+ Embeds: []*discordgo.MessageEmbed{},
+ Components: []discordgo.MessageComponent{helpers.BackRow(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.InteractionResponseUpdateMessage,
+ 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,
+ },
+ }},
+ helpers.BackRow(router.ViewMe),
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+tokenGallerySelectHandler shows a detail view for the selected token with Assign and Delete options.
+
+CustomID: token_gallery_select
+*/
+type tokenGallerySelectHandler struct {
+ db *bun.DB
+}
+
+func (h *tokenGallerySelectHandler) CustomIDPrefix() string { return messages.TokenGallerySelectPrefix }
+
+func (h *tokenGallerySelectHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ values := i.MessageComponentData().Values
+ if len(values) == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ mediaID := values[0]
+
+ media, err := db.GetByID[models.Media](h.db, mediaID)
+ if err != nil || media.OwnerID != helpers.GetUserID(i) {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ name := media.Name
+ if name == "" {
+ name = media.ID[:8]
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Embeds: []*discordgo.MessageEmbed{{
+ Title: name,
+ Color: messages.EmbedColor,
+ Image: &discordgo.MessageEmbedImage{URL: media.URL},
+ }},
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.PlayerTokenAssignLabel,
+ Style: discordgo.SuccessButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.TokenGalleryAssignPrefix, mediaID),
+ },
+ discordgo.Button{
+ Label: messages.TokenDownloadLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.TokenDownloadPrefix, mediaID),
+ },
+ discordgo.Button{
+ Label: messages.TokenDeleteLabel,
+ Style: discordgo.DangerButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.TokenDeletePromptPrefix, mediaID),
+ },
+ }},
+ helpers.BackRow(router.ViewMeTokens),
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+tokenGalleryAssignHandler shows a campaign picker to assign the chosen token to.
+
+CustomID: token_gallery_assign:
+*/
+type tokenGalleryAssignHandler struct {
+ db *bun.DB
+}
+
+func (h *tokenGalleryAssignHandler) CustomIDPrefix() string { return messages.TokenGalleryAssignPrefix }
+
+func (h *tokenGalleryAssignHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ mediaID := parts[1]
+ userID := helpers.GetUserID(i)
+
+ media, err := db.GetByID[models.Media](h.db, mediaID)
+ if err != nil || media.OwnerID != userID {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ campaigns, err := models.GetPlayerCampaigns(h.db, userID)
+ if err != nil || len(campaigns) == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.NoCampaignsMessage)
+ return
+ }
+
+ name := media.Name
+ if name == "" {
+ name = media.ID[:8]
+ }
+
+ var options []discordgo.SelectMenuOption
+ for _, cp := range campaigns {
+ if cp.Campaign == nil {
+ continue
+ }
+ options = append(options, discordgo.SelectMenuOption{
+ Label: cp.Campaign.Name,
+ Value: cp.CampaignID,
+ })
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf(messages.TokenGalleryAssignPrompt, name),
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.SelectMenu{
+ CustomID: fmt.Sprintf("%s:%s", messages.TokenGalleryAssignSelectPrefix, mediaID),
+ Placeholder: messages.TokenGalleryAssignSelectPlaceholder,
+ Options: options,
+ },
+ }},
+ helpers.BackRow(router.ViewMeTokens),
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+tokenGalleryAssignSelectHandler executes the token-to-campaign assignment from the gallery.
+
+CustomID: token_gallery_assign_select:
+*/
+type tokenGalleryAssignSelectHandler struct {
+ db *bun.DB
+}
+
+func (h *tokenGalleryAssignSelectHandler) CustomIDPrefix() string {
+ return messages.TokenGalleryAssignSelectPrefix
+}
+
+func (h *tokenGalleryAssignSelectHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ mediaID := parts[1]
+ userID := helpers.GetUserID(i)
+
+ values := i.MessageComponentData().Values
+ if len(values) == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ campaignID := values[0]
+
+ media, err := db.GetByID[models.Media](h.db, mediaID)
+ if err != nil || media.OwnerID != userID {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ if _, err := h.db.NewUpdate().Model((*models.CampaignPlayer)(nil)).
+ Set("media_id = ?", mediaID).
+ Where("player_id = ? AND campaign_id = ?", userID, campaignID).
+ Exec(context.Background()); err != nil {
+ log.Printf("token_gallery_assign_select: update player %s campaign %s: %v", userID, campaignID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ campaign, err := db.GetByID[models.Campaign](h.db, campaignID)
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.PlayerTokenAssignSuccess)
+ return
+ }
+ helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.PlayerTokenAssignSuccess, campaign.Name))
+}
+
+/*
+tokenDeletePromptHandler shows a delete confirmation for the selected token.
+
+CustomID: token_delete_prompt:
+*/
+type tokenDeletePromptHandler struct {
+ db *bun.DB
+}
+
+func (h *tokenDeletePromptHandler) CustomIDPrefix() string { return messages.TokenDeletePromptPrefix }
+
+func (h *tokenDeletePromptHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ mediaID := parts[1]
+
+ media, err := db.GetByID[models.Media](h.db, mediaID)
+ if err != nil || media.OwnerID != helpers.GetUserID(i) {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ name := media.Name
+ if name == "" {
+ name = media.ID[:8]
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf(messages.TokenDeleteConfirmMsg, name),
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.TokenDeleteConfirmLabel,
+ Style: discordgo.DangerButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.TokenDeleteConfirmPrefix, mediaID),
+ },
+ router.NavButton(messages.TokenDeleteCancelLabel, discordgo.SecondaryButton, router.ViewMeTokens),
+ }},
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+tokenDeleteConfirmHandler deletes the token record and its file, clearing any campaign assignments.
+It clear tokens from all campaign slots before deleting the record.
+
+CustomID: token_delete_confirm:
+*/
+type tokenDeleteConfirmHandler struct {
+ db *bun.DB
+ dataDir string
+}
+
+func (h *tokenDeleteConfirmHandler) CustomIDPrefix() string { return messages.TokenDeleteConfirmPrefix }
+
+func (h *tokenDeleteConfirmHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ mediaID := parts[1]
+
+ media, err := db.GetByID[models.Media](h.db, mediaID)
+ if err != nil || media.OwnerID != helpers.GetUserID(i) {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ name := media.Name
+ if name == "" {
+ name = media.ID[:8]
+ }
+
+ if _, err := h.db.NewUpdate().Model((*models.CampaignPlayer)(nil)).
+ Set("media_id = ''").
+ Where("media_id = ?", mediaID).
+ Exec(context.Background()); err != nil {
+ log.Printf("token_delete_confirm: clear campaign slots for media %s: %v", mediaID, err)
+ }
+
+ if _, err := h.db.NewDelete().Model((*models.Media)(nil)).
+ Where("id = ?", mediaID).
+ Exec(context.Background()); err != nil {
+ log.Printf("token_delete_confirm: delete media record %s: %v", mediaID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.TokenDeleteFailed)
+ return
+ }
+
+ if err := os.Remove(media.Path); err != nil && !os.IsNotExist(err) {
+ log.Printf("token_delete_confirm: remove file %s: %v", media.Path, err)
+ }
+
+ helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.TokenDeleteSuccess, name))
+}
+
+/*
+tokenDownloadHandler sends a single token file as an ephemeral attachment.
+
+CustomID: token_download:
+*/
+type tokenDownloadHandler struct {
+ db *bun.DB
+}
+
+func (h *tokenDownloadHandler) CustomIDPrefix() string { return messages.TokenDownloadPrefix }
+
+func (h *tokenDownloadHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ mediaID := parts[1]
+
+ media, err := db.GetByID[models.Media](h.db, mediaID)
+ if err != nil || media.OwnerID != helpers.GetUserID(i) {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ f, err := os.Open(media.Path)
+ if err != nil {
+ log.Printf("token_download: open %s: %v", media.Path, err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ defer f.Close()
+
+ name := media.Name
+ if name == "" {
+ name = media.ID[:8]
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf(messages.PlayerDownloadContent, 1),
+ Embeds: []*discordgo.MessageEmbed{},
+ Files: []*discordgo.File{{
+ Name: name + ".png",
+ ContentType: "image/png",
+ Reader: f,
+ }},
+ Components: []discordgo.MessageComponent{
+ helpers.BackRow(router.ViewMeTokens),
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
diff --git a/internal/interactions/player_tokens_test.go b/internal/interactions/player_tokens_test.go
new file mode 100644
index 0000000..ebd12a4
--- /dev/null
+++ b/internal/interactions/player_tokens_test.go
@@ -0,0 +1,353 @@
+package interactions
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/manager/models"
+ "github.com/framebuffers/moontracer/internal/messages"
+ "github.com/framebuffers/moontracer/internal/testutil"
+)
+
+/*
+Ownership-guard coverage for the player token gallery.
+
+Every handler in player_tokens.go loads the target Media row by ID alone:
+
+ media, err := db.GetByID[models.Media](h.db, mediaID)
+
+There is no `WHERE owner_id = ?` clause and no post-load comparison against
+helpers.GetUserID(i). A user who learns or guesses another user's mediaID
+(via leaked link, message scrape, or simply iterating UUIDs in dev) can:
+
+ - Assign someone else's token to their own campaign roster
+ - Download the raw PNG bytes
+ - Delete the record + file
+
+These tests assert the *correct* behavior: an interaction from a non-owner
+must not mutate state, must not return file bytes, and should respond with
+an error message. They will FAIL on current main β that's the point. They
+document the missing guard for the fix PR.
+*/
+
+const (
+ tokOwnerID = "owner-1"
+ tokAttackerID = "attacker-2"
+ tokVictimMID = "media-victim"
+)
+
+func seedVictimToken(t *testing.T, database *bun.DB, dataDir string) string {
+ t.Helper()
+
+ tokenDir := filepath.Join(dataDir, "guild-1", "tokens", tokOwnerID)
+ require.NoError(t, os.MkdirAll(tokenDir, 0o755))
+ tokenPath := filepath.Join(tokenDir, "out.png")
+ require.NoError(t, os.WriteFile(tokenPath, []byte("VICTIM_TOKEN_BYTES"), 0o644))
+
+ _, err := database.NewInsert().Model(&models.Media{
+ ID: tokVictimMID,
+ OwnerID: tokOwnerID,
+ Path: tokenPath,
+ URL: "http://cdn/guild-1/tokens/" + tokOwnerID + "/out.png",
+ Kind: models.KindTokenPlayer,
+ Name: "victim",
+ CreatedAt: time.Now(),
+ }).Exec(context.Background())
+ require.NoError(t, err)
+ return tokenPath
+}
+
+func buildComponentInteraction(userID, customID string) *discordgo.InteractionCreate {
+ return &discordgo.InteractionCreate{
+ Interaction: &discordgo.Interaction{
+ Type: discordgo.InteractionMessageComponent,
+ User: &discordgo.User{ID: userID},
+ Data: discordgo.MessageComponentInteractionData{
+ CustomID: customID,
+ ComponentType: discordgo.ButtonComponent,
+ },
+ },
+ }
+}
+
+func buildSelectInteraction(userID, customID string, values []string) *discordgo.InteractionCreate {
+ return &discordgo.InteractionCreate{
+ Interaction: &discordgo.Interaction{
+ Type: discordgo.InteractionMessageComponent,
+ User: &discordgo.User{ID: userID},
+ Data: discordgo.MessageComponentInteractionData{
+ CustomID: customID,
+ ComponentType: discordgo.SelectMenuComponent,
+ Values: values,
+ },
+ },
+ }
+}
+
+/*
+Attacker downloads someone else's token.
+
+When:
+
+ An attacker clicks a download button whose CustomID embeds the victim's
+ mediaID (forged or scraped).
+
+Expected:
+
+ No file attachment is sent. The handler must reject the click before
+ opening media.Path.
+
+Development Note (v0.12.6, 20260511):
+
+ Currently FAILS β handler reads the file and replies with the bytes.
+*/
+func TestTokenDownload_RejectsNonOwner(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ dataDir := t.TempDir()
+ seedVictimToken(t, database, dataDir)
+
+ stub := testutil.NewStubSession(t)
+ handler := &tokenDownloadHandler{db: database}
+
+ customID := fmt.Sprintf("%s:%s", messages.TokenDownloadPrefix, tokVictimMID)
+ handler.HandleComponents(stub.Session, buildComponentInteraction(tokAttackerID, customID))
+
+ /*
+ The interaction response is sent via REST POST. Inspect bodies for the
+ victim's bytes (base64 or raw won't appear here:
+ discordgo serializes files into multipart).
+
+ Easiest assertion: at least one captured request must NOT contain a
+ Files payload section.
+
+ Stronger: no request body should reference the victim filename.
+ */
+ for _, r := range stub.Requests() {
+ assert.NotContains(t, string(r.Body), "VICTIM_TOKEN_BYTES",
+ "attacker received victim's token bytes via %s %s", r.Method, r.URL)
+ assert.NotContains(t, string(r.Body), `"filename":"victim`,
+ "attacker received victim's token attachment via %s %s", r.Method, r.URL)
+ }
+}
+
+/*
+Owner can download their own token (regression guard for the fix).
+
+When:
+
+ The legitimate owner clicks the download button for their own media.
+
+Expected:
+
+ A response is sent (we don't pin the exact shape: the file plumbing goes
+ through discordgo's multipart writer). The handler must NOT reject this
+ case once ownership checks are added.
+*/
+func TestTokenDownload_AllowsOwner(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ dataDir := t.TempDir()
+ seedVictimToken(t, database, dataDir)
+
+ stub := testutil.NewStubSession(t)
+ handler := &tokenDownloadHandler{db: database}
+
+ customID := fmt.Sprintf("%s:%s", messages.TokenDownloadPrefix, tokVictimMID)
+ handler.HandleComponents(stub.Session, buildComponentInteraction(tokOwnerID, customID))
+
+ require.NotEmpty(t, stub.Requests(), "owner click must produce some Discord response")
+}
+
+/*
+Attacker cannot delete someone else's token.
+
+When:
+
+ An attacker submits the delete-confirm interaction with the victim's
+ mediaID.
+
+Expected:
+
+ The Media row remains in the DB and the file remains on disk.
+
+Development Note (v0.12.6, 20260511):
+
+ Currently FAILS: the handler issues DELETE and os.Remove with no ownership check.
+*/
+func TestTokenDeleteConfirm_RejectsNonOwner(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ dataDir := t.TempDir()
+ tokenPath := seedVictimToken(t, database, dataDir)
+
+ stub := testutil.NewStubSession(t)
+ handler := &tokenDeleteConfirmHandler{db: database, dataDir: dataDir}
+
+ customID := fmt.Sprintf("%s:%s", messages.TokenDeleteConfirmPrefix, tokVictimMID)
+ handler.HandleComponents(stub.Session, buildComponentInteraction(tokAttackerID, customID))
+
+ var remaining models.Media
+ err := database.NewSelect().Model(&remaining).Where("id = ?", tokVictimMID).Scan(context.Background())
+ assert.NoError(t, err, "victim's media row must survive an attacker's delete")
+
+ _, statErr := os.Stat(tokenPath)
+ assert.NoError(t, statErr, "victim's token file must survive an attacker's delete")
+}
+
+/*
+Attacker cannot assign someone else's token to their own campaign.
+
+When:
+
+ An attacker submits the assign-select interaction with the victim's
+ mediaID and a campaign the attacker is a member of.
+
+Expected:
+
+ No CampaignPlayer row anywhere has its media_id set to the victim's
+ mediaID.
+
+Development Note (v0.12.6, 20260511):
+
+ Currently FAILS: the handler runs the UPDATE without checking
+ ownership of the media.
+*/
+func TestTokenGalleryAssignSelect_RejectsNonOwner(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ dataDir := t.TempDir()
+ seedVictimToken(t, database, dataDir)
+
+ ctx := context.Background()
+ const attackerCampaign = "camp-attacker"
+ _, err := database.NewInsert().Model(&models.Campaign{
+ ID: attackerCampaign,
+ Name: "Attacker Campaign",
+ Tag: "atk",
+ DungeonMaster: tokAttackerID,
+ IsApproved: true,
+ }).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(&models.CampaignPlayer{
+ PlayerID: tokAttackerID,
+ CampaignID: attackerCampaign,
+ Role: models.RolePlayer,
+ Status: models.StatusActive,
+ }).Exec(ctx)
+ require.NoError(t, err)
+
+ stub := testutil.NewStubSession(t)
+ handler := &tokenGalleryAssignSelectHandler{db: database}
+
+ customID := fmt.Sprintf("%s:%s", messages.TokenGalleryAssignSelectPrefix, tokVictimMID)
+ handler.HandleComponents(stub.Session,
+ buildSelectInteraction(tokAttackerID, customID, []string{attackerCampaign}))
+
+ count, err := database.NewSelect().Model((*models.CampaignPlayer)(nil)).
+ Where("media_id = ?", tokVictimMID).Count(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, 0, count,
+ "attacker assigned victim's token to a campaign β ownership check missing")
+}
+
+/*
+Gallery detail view leaks the victim's token preview to a non-owner.
+
+When:
+
+ An attacker submits the gallery select with the victim's mediaID.
+
+Expected:
+
+ No response embed contains the victim's token URL.
+
+Development Note (v0.12.6, 20260511):
+
+ Currently FAILS: handler renders the embed (with image URL) for any caller.
+*/
+func TestTokenGallerySelect_RejectsNonOwner(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ dataDir := t.TempDir()
+ seedVictimToken(t, database, dataDir)
+
+ stub := testutil.NewStubSession(t)
+ handler := &tokenGallerySelectHandler{db: database}
+
+ handler.HandleComponents(stub.Session, buildSelectInteraction(
+ tokAttackerID, messages.TokenGallerySelectPrefix, []string{tokVictimMID}))
+
+ for _, r := range stub.Requests() {
+ assert.NotContains(t, string(r.Body), "tokens/"+tokOwnerID,
+ "attacker received victim's token URL in gallery detail response")
+ }
+}
+
+/*
+Delete-prompt screen leaks the victim's token name to a non-owner.
+
+When:
+
+ An attacker clicks a delete-prompt button with the victim's mediaID.
+
+Expected:
+
+ No response references the victim's media.
+
+Development Note (v0.12.6, 20260511):
+
+ Currently FAILS: handler renders the prompt for any caller.
+*/
+func TestTokenDeletePrompt_RejectsNonOwner(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ dataDir := t.TempDir()
+ seedVictimToken(t, database, dataDir)
+
+ stub := testutil.NewStubSession(t)
+ handler := &tokenDeletePromptHandler{db: database}
+
+ customID := fmt.Sprintf("%s:%s", messages.TokenDeletePromptPrefix, tokVictimMID)
+ handler.HandleComponents(stub.Session, buildComponentInteraction(tokAttackerID, customID))
+
+ for _, r := range stub.Requests() {
+ assert.NotContains(t, string(r.Body), "victim",
+ "attacker saw victim's token name in delete-prompt response")
+ }
+}
+
+/*
+Assign picker leaks the victim's token name to a non-owner.
+
+When:
+
+ An attacker clicks an assign button with the victim's mediaID.
+
+Expected:
+
+ No response references the victim's token.
+
+Development Note (v0.12.6, 20260511):
+
+ Currently FAILS.
+*/
+func TestTokenGalleryAssign_RejectsNonOwner(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ dataDir := t.TempDir()
+ seedVictimToken(t, database, dataDir)
+
+ stub := testutil.NewStubSession(t)
+ handler := &tokenGalleryAssignHandler{db: database}
+
+ customID := fmt.Sprintf("%s:%s", messages.TokenGalleryAssignPrefix, tokVictimMID)
+ handler.HandleComponents(stub.Session, buildComponentInteraction(tokAttackerID, customID))
+
+ for _, r := range stub.Requests() {
+ assert.NotContains(t, string(r.Body), "victim",
+ "attacker saw victim's token name in assign-picker response")
+ }
+}
diff --git a/internal/interactions/quick_register.go b/internal/interactions/quick_register.go
new file mode 100644
index 0000000..6090834
--- /dev/null
+++ b/internal/interactions/quick_register.go
@@ -0,0 +1,66 @@
+package interactions
+
+import (
+ "log"
+
+ "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/interactions/router"
+ "github.com/framebuffers/moontracer/internal/manager/models"
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+ Flow:
+ Triggered when an unregistered user clicks a "Register" CTA (call-to-action) button that
+ any gated surface can attach (campaign join, /me hub entry, etc.).
+ 1. Button click (quick_register): quickRegisterHandler
+ a. Probes ScopePlayer auth to decide if a Player row already exists.
+ b. If not registered: inserts a fresh Player{ID:userID}. Failure ->
+ RegistrationFailureMessage terminal reply.
+ c. Either way (already-registered users get the same UX), navigates
+ the viewer to ViewMe via router.Navigate.
+
+ Notes:
+ - Idempotent on purpose: a stale button still works for an already-
+ registered user. This just sends them to /me.
+ - No /register slash command call here. This is the inline shortcut.
+*/
+
+/*
+quickRegisterHandler registers the user on the spot and navigates them to the /me hub.
+
+Shown as a button whenever an unregistered user hits a gated surface.
+
+CustomID: quick_register
+*/
+type quickRegisterHandler struct {
+ db *bun.DB
+}
+
+func (h *quickRegisterHandler) CustomIDPrefix() string { return messages.QuickRegisterPrefix }
+
+func (h *quickRegisterHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ userID := helpers.GetUserID(i)
+
+ registered, err := auth.Authorize(h.db, userID, auth.ScopePlayer, "")
+ if err != nil {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ if !registered {
+ player := &models.Player{ID: userID}
+ if err := db.Insert(h.db, player); err != nil {
+ log.Printf("quick_register: insert failed for %s: %v", userID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.RegistrationFailureMessage)
+ return
+ }
+ }
+
+ router.Navigate(s, i, router.ViewMe, nil)
+}
diff --git a/internal/interactions/registry.go b/internal/interactions/registry.go
new file mode 100644
index 0000000..77627b5
--- /dev/null
+++ b/internal/interactions/registry.go
@@ -0,0 +1,190 @@
+package interactions
+
+import (
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/dispatch"
+ "github.com/framebuffers/moontracer/internal/scheduler"
+)
+
+/*
+AllComponents returns an array with all the `ComponentHandler`s available to the bot.
+
+Also registers all router views. It's called once at startup.
+
+How to add a new component (button / select menu) interaction:
+
+ 1. Create internal/interactions/.go with a struct that holds
+ its dependencies (usually `db *bun.DB`, sometimes the dispatcher).
+
+ 2. Implement the ComponentHandler interface (interaction.go):
+
+ - CustomIDPrefix() string. It's the "" part of CustomIDs of
+ the form "prefix:arg1:arg2". Must be unique across all handlers.
+
+ - HandleComponents(s, i). It's invoked when a button/select whose
+ CustomID starts with this prefix fires.
+
+ 3. Emit the CustomID from wherever the button is rendered, as
+ fmt.Sprintf("%s:%s", prefix, someID). Keep the prefix literal in
+ internal/messages/messages.go if it's shared, or as a constant on
+ the handler file if it's local.
+
+ 4. Add the handler to the slice below, grouped with its neighbors
+ (Campaign actions, Manage, Approval, Browse, Admin, etc).
+
+ 5. User-facing strings go in internal/messages/messages.go.
+
+ 6. If the handler needs auth, call auth.Authorize at the top. Same
+ scopes as commands.β
+
+How to add a new modal:
+
+ 1. Implement the ModalHandler interface (CustomIDPrefix + HandleModal).
+
+ 2. Register it in AllModals() below.
+
+ 3. Trigger it by responding to a prior interaction with
+ InteractionResponseModal and a matching CustomID.
+
+How to add a new router view (universal back-button support):
+
+ Views are addressable screens (ViewMe, ViewManage, ViewAdmin...) that
+ render via navHandler. Add new views in internal/interactions/views.go
+ by calling router.Register(router.ViewDragon, renderFn). The back button
+ then navigates by ViewID, so any new screen that wants Back support
+ needs to be a registered view.
+
+At startup the bot calls AllComponents/AllModals once per guild DB; the
+main handler (internal/discord/handler.go) dispatches incoming events to
+the first handler whose prefix matches.
+*/
+func AllComponents(db *bun.DB, d *dispatch.Dispatcher, sched *scheduler.Scheduler, dataDir, mediaBaseURL string) []ComponentHandler {
+ RegisterAllViews(db, d)
+
+ return []ComponentHandler{
+ // Campaign actions
+ &campaignJoin{db: db, dispatcher: d},
+ &campaignLeave{db: db},
+ &campaignToggle{db: db},
+ &campaignView{db: db},
+
+ // Campaign management
+ &manageCampaignMenu{db: db},
+ &manageCampaignDelete{db: db},
+ &manageDeleteConfirm{db: db},
+ &manageCampaignBan{db: db},
+ &manageCampaignBanSelect{db: db},
+ &manageCampaignAnnounce{db: db},
+ &manageCampaignReschedule{db: db},
+ &manageSetRole{db: db},
+ &manageArchive{db: db},
+ &manageArchiveConfirm{db: db, sched: sched},
+ &manageSetCover{db: db},
+
+ // Approval (DM flow)
+ &campaignApprove{db: db, dispatcher: d},
+ &campaignDeny{db: db},
+
+ // Browse & select
+ &campaignsFilterHandler{db: db},
+ &campaignSelectHandler{db: db},
+ &myCampaignSelectHandler{db: db},
+ &manageSelectHandler{db: db},
+
+ // Navigation (all "nav:*" CustomIDs go through the view router).
+ &navHandler{db: db},
+
+ // 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},
+
+ // Player hub
+ &nextSessionsHandler{db: db},
+ ¬ificationsHandler{db: db},
+ ¬ifToggleHandler{db: db},
+ &timezoneButtonHandler{db: db},
+ &timezoneSelectHandler{db: db},
+
+ // Admin hub
+ &adminCampaignsHandler{db: db},
+ &adminCampaignSelectHandler{db: db},
+ &adminBroadcastHandler{db: db, dispatcher: d},
+ &adminDatabaseHandler{db: db},
+ &adminSettingsHandler{db: db},
+ &adminDiagHandler{db: db},
+
+ // Manage: new campaign from button, links, player tokens
+ &manageNewCampaignButton{db: db},
+ &manageLinksHandler{db: db},
+ &manageDownloadTokensHandler{db: db},
+
+ // Token generator confirm/discard/assign
+ &tokenApplyHandler{db: db, dataDir: dataDir, mediaBaseURL: mediaBaseURL},
+ &tokenDiscardHandler{dataDir: dataDir, mediaBaseURL: mediaBaseURL},
+ &playerTokenPostcreateSelectHandler{db: db},
+ &playerTokenSkipHandler{db: db},
+ &manageSetSession{db: db},
+
+ // Token gallery (/me -> Tokens)
+ &tokenGallerySelectHandler{db: db},
+ &tokenGalleryAssignHandler{db: db},
+ &tokenGalleryAssignSelectHandler{db: db},
+ &tokenDeletePromptHandler{db: db},
+ &tokenDeleteConfirmHandler{db: db, dataDir: dataDir},
+ &tokenDownloadHandler{db: db},
+
+ // Player campaign card menus
+ &playerSetSheetHandler{db: db},
+ &playerSetTokenHandler{db: db},
+ &playerTokenSelectHandler{db: db},
+ &playerTokenAssignHandler{db: db},
+ &playerLeaveConfirmPromptHandler{db: db},
+ &playerLeaveDoHandler{db: db},
+ &playerContactDMHandler{db: db},
+ &playerDownloadTokensHandler{db: db},
+ &playerDownloadSelectHandler{db: db},
+
+ // Session RSVP (buttons on reminder DMs)
+ &rsvpAcceptHandler{db: db, dispatcher: d},
+ &rsvpDeclineHandler{db: db, dispatcher: d},
+
+ // Invitations
+ &manageCampaignInvite{db: db, dispatcher: d},
+ &manageCampaignInviteSelect{db: db, dispatcher: d},
+ &campaignInviteAccept{db: db},
+ &campaignInviteDecline{db: db},
+ }
+}
+
+// AllModals returns an array with all the ModalHandlers available to the bot.
+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},
+ &manageSetRoleModal{db: db},
+ &manageLinksModal{db: db},
+ &manageSetSessionModal{db: db, sched: sched},
+ &adminBroadcastModal{db: db, dispatcher: d},
+ &playerSetSheetModal{db: db},
+ &playerContactDMModal{db: db, dispatcher: d},
+ &tokenApplyModal{db: db, dataDir: dataDir, mediaBaseURL: mediaBaseURL},
+ }
+}
diff --git a/internal/interactions/router/nav_button.go b/internal/interactions/router/nav_button.go
new file mode 100644
index 0000000..1508166
--- /dev/null
+++ b/internal/interactions/router/nav_button.go
@@ -0,0 +1,64 @@
+package router
+
+/*
+ Button constructors for router-driven navigation.
+
+ These helpers are the only supported way to build "nav:" CustomIDs.
+ Call sites never hand-format the "nav:[:argβ¦]" string; that shape
+ is owned by this file + router.ParseCustomID so both ends agree.
+
+ Flow (rendering a navigable button):
+ 1. Caller decides where the button should go (target ViewID + any
+ positional args: e.g. a campaignID for ViewManageCampaign).
+ 2. Caller invokes NavButton / BackButton / NavCustomID.
+ 3. The helper composes "nav:[:argsβ¦]" via NavCustomID and
+ returns a discordgo.Button (or just the string for select-menu
+ option values, where a Button isn't applicable).
+ 4. When clicked, the CustomID flows back through navHandler ->
+ router.Navigate (see router.go for the click-to-render flow).
+
+ Use NavButton for forward links (most buttons that jump to another view)
+ and BackButton for the conventional "Back" button on any non-hub view.
+*/
+
+import (
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+)
+
+/*
+NavCustomID builds a "nav:[:arg1β¦]" CustomID string.
+
+Useful when you need the raw string (e.g. inside a select-menu option) instead of a full button.
+*/
+func NavCustomID(target ViewID, args ...string) string {
+ parts := make([]string, 0, 2+len(args))
+ parts = append(parts, NavPrefix, string(target))
+ parts = append(parts, args...)
+ return strings.Join(parts, ":")
+}
+
+// NavButton builds a clickable button that routes to the given view.
+func NavButton(label string, style discordgo.ButtonStyle, target ViewID, args ...string) discordgo.Button {
+ return discordgo.Button{
+ Label: label,
+ Style: style,
+ CustomID: NavCustomID(target, args...),
+ }
+}
+
+/*
+BackButton is a secondary-styled button with a back-arrow emoji, pointing to
+the given target view.
+
+Label is supplied so callers can localize.
+*/
+func BackButton(label string, target ViewID, args ...string) discordgo.Button {
+ return discordgo.Button{
+ Label: label,
+ Style: discordgo.PrimaryButton,
+ CustomID: NavCustomID(target, args...),
+ Emoji: &discordgo.ComponentEmoji{Name: "β"},
+ }
+}
diff --git a/internal/interactions/router/router.go b/internal/interactions/router/router.go
new file mode 100644
index 0000000..175e8fc
--- /dev/null
+++ b/internal/interactions/router/router.go
@@ -0,0 +1,130 @@
+package router
+
+/*
+ View router.
+
+ Every navigable screen in the bot is identified by a ViewID. Buttons that
+ navigate to a view use the CustomID format:
+
+ nav:[:arg1[:arg2β¦]]
+
+ Flow (click-to-render):
+ 1. User clicks a button whose CustomID starts with "nav:".
+ 2. discordgo's prefix dispatcher routes the interaction to navHandler
+ (registered once in registry.go, defined in nav_handler.go).
+ 3. navHandler calls router.ParseCustomID -> (ViewID, args, ok).
+ 4. router.Navigate looks up the ViewID in the registry map and invokes
+ the registered RenderFunc with (session, interaction, args).
+ 5. The RenderFunc (defined in interactions/views.go as a thin adapter)
+ calls the real render function: e.g. commands.RenderMeHub; which
+ issues an InteractionResponseUpdateMessage to replace the current
+ ephemeral message with the new view.
+
+ Flow (startup registration):
+ 1. main() builds ComponentHandlers via interactions.AllComponents.
+ 2. AllComponents calls RegisterAllViews(db) before returning.
+ 3. RegisterAllViews performs a router.Register(ViewID, RenderFunc)
+ call per view, populating the registry map used above.
+
+ Design:
+ Back-only. The "parent view" is encoded into each Back button's
+ CustomID at render time, so navigation is stateless and survives bot
+ restarts. There is no forward button and no in-memory stack: if a user
+ wants to go forward, they click the target button again.
+*/
+
+import (
+ "fmt"
+ "log"
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+)
+
+// ViewID identifies a renderable view.
+type ViewID string
+
+// NavPrefix is the CustomID prefix handled by the nav router.
+const NavPrefix = "nav"
+
+// View identifiers. Keep stable: they appear in persisted CustomIDs on live messages.
+const (
+ ViewMe ViewID = "me"
+ ViewMeCampaigns ViewID = "me_campaigns"
+ ViewMeConfig ViewID = "me_config"
+ ViewMeTokens ViewID = "me_tokens"
+ ViewMyCampaigns ViewID = "mycampaigns"
+ ViewManage ViewID = "manage"
+ ViewManageCampaign ViewID = "manage_campaign"
+ ViewManagePlayers ViewID = "manage_players"
+ ViewManageSessions ViewID = "manage_sessions"
+ ViewManageSettings ViewID = "manage_settings"
+ ViewManageDanger ViewID = "manage_danger"
+ ViewCampaignsBrowse ViewID = "campaigns_browse"
+ ViewCampaignDetail ViewID = "campaign_detail"
+ ViewAdmin ViewID = "admin"
+ ViewAdminDiag ViewID = "admin_diag"
+ ViewAdminDatabase ViewID = "admin_database"
+ ViewAdminCampaigns ViewID = "admin_campaigns"
+ ViewAdminSettings ViewID = "admin_settings"
+ ViewAdminBroadcast ViewID = "admin_broadcast"
+ ViewInbox ViewID = "inbox"
+ ViewConfig ViewID = "config"
+ ViewNextSessions ViewID = "next_sessions"
+ ViewHelp ViewID = "help"
+ ViewAbout ViewID = "about"
+)
+
+/*
+RenderFunc renders a view.
+
+args are positional extras parsed from the CustomID after the ViewID
+(e.g. a campaignID for ViewManageCampaign).
+*/
+type RenderFunc func(s *discordgo.Session, i *discordgo.InteractionCreate, args []string)
+
+var registry = map[ViewID]RenderFunc{}
+
+/*
+Register attaches a render function to a ViewID.
+
+Safe to call once at startup; later calls overwrite.
+*/
+func Register(id ViewID, fn RenderFunc) {
+ registry[id] = fn
+}
+
+/*
+Navigate dispatches to the render function for the given view.
+
+If no render is registered, it logs and responds with an ephemeral error,
+so the user isn't left hanging on an interaction timeout.
+*/
+func Navigate(s *discordgo.Session, i *discordgo.InteractionCreate, id ViewID, args []string) {
+ fn, ok := registry[id]
+ if !ok {
+ log.Printf("router: no renderer for view %q", id)
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf("Navigation error: unknown view %q.", id),
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+ return
+ }
+ fn(s, i, args)
+}
+
+/*
+ParseCustomID splits a "nav:[:argβ¦]" CustomID into view + args.
+
+Returns ok=false if the string doesn't have the nav prefix.
+*/
+func ParseCustomID(customID string) (ViewID, []string, bool) {
+ parts := strings.Split(customID, ":")
+ if len(parts) < 2 || parts[0] != NavPrefix {
+ return "", nil, false
+ }
+ return ViewID(parts[1]), parts[2:], true
+}
diff --git a/internal/interactions/rsvp.go b/internal/interactions/rsvp.go
new file mode 100644
index 0000000..64e185c
--- /dev/null
+++ b/internal/interactions/rsvp.go
@@ -0,0 +1,137 @@
+package interactions
+
+/*
+ Session RSVP flow.
+
+ Players receive a reminder DM ~1 hour before their session with buttons for
+ Going or Not Going to a session.
+
+ Clicking either button:
+ 1. Saves RSVPStatus on the CampaignPlayer row.
+ 2. Updates the reminder message (removes buttons, shows confirmation).
+ 3. DMs the campaign DM in their own timezone.
+
+ CustomID format: rsvp_accept::
+ rsvp_decline::
+
+ Three-part format lets extractGuildFromCustomID resolve the guild for DM interactions.
+*/
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "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"
+)
+
+/*
+ Accept
+*/
+
+type rsvpAcceptHandler struct {
+ db *bun.DB
+ dispatcher *dispatch.Dispatcher
+}
+
+func (h *rsvpAcceptHandler) CustomIDPrefix() string { return messages.RSVPAcceptPrefix }
+
+func (h *rsvpAcceptHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ handleRSVP(s, i, h.db, h.dispatcher, models.RSVPAccepted)
+}
+
+/*
+ Decline
+*/
+
+type rsvpDeclineHandler struct {
+ db *bun.DB
+ dispatcher *dispatch.Dispatcher
+}
+
+func (h *rsvpDeclineHandler) CustomIDPrefix() string { return messages.RSVPDeclinePrefix }
+
+func (h *rsvpDeclineHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ handleRSVP(s, i, h.db, h.dispatcher, models.RSVPDeclined)
+}
+
+/*
+ Handle RSVP
+*/
+
+func handleRSVP(s *discordgo.Session, i *discordgo.InteractionCreate, guildDB *bun.DB, dispatcher *dispatch.Dispatcher, status models.RSVPStatus) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 3)
+ if !ok {
+ return
+ }
+ campaignID := parts[2]
+ playerID := helpers.GetUserID(i)
+
+ campaign, err := db.GetByID[models.Campaign](guildDB, campaignID)
+ if err != nil || campaign.IsArchived {
+ helpers.RespondUpdateTerminal(s, i, messages.RSVPCampaignGone)
+ return
+ }
+
+ var cp models.CampaignPlayer
+ err = guildDB.NewSelect().Model(&cp).
+ Where("player_id = ? AND campaign_id = ?", playerID, campaignID).
+ Scan(context.Background())
+ if err != nil {
+ log.Printf("rsvp: load campaign player %s/%s: %v", playerID, campaignID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ if cp.RSVPStatus != models.RSVPPending {
+ helpers.RespondUpdateTerminal(s, i, messages.RSVPAlreadyResponded)
+ return
+ }
+
+ cp.RSVPStatus = status
+ if _, err := guildDB.NewUpdate().Model(&cp).Column("rsvp_status").WherePK().Exec(context.Background()); err != nil {
+ log.Printf("rsvp: save status for %s/%s: %v", playerID, campaignID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ confirmText := messages.RSVPAcceptedPlayer
+ if status == models.RSVPDeclined {
+ confirmText = messages.RSVPDeclinedPlayer
+ }
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: i.Message.Content + "\n\n" + confirmText,
+ Components: []discordgo.MessageComponent{},
+ },
+ })
+
+ if campaign.DungeonMaster == "" || campaign.Schedule.NextSession.IsZero() {
+ return
+ }
+ dmSettings, err := models.GetOrCreatePlayerSettings(guildDB, campaign.DungeonMaster)
+ if err != nil {
+ log.Printf("rsvp: load DM settings for %s: %v", campaign.DungeonMaster, err)
+ return
+ }
+ sessionTime := helpers.FormatInLocation(campaign.Schedule.NextSession, messages.SessionTimeFormat, dmSettings.Location()) +
+ " " + helpers.TZLabel(dmSettings.Location())
+
+ notifyFmt := messages.RSVPDMNotifyAccept
+ if status == models.RSVPDeclined {
+ notifyFmt = messages.RSVPDMNotifyDecline
+ }
+ dispatcher.Push(dispatch.DirectMessage{
+ ID: fmt.Sprintf("rsvp-notify:%s:%s:%s", campaignID, playerID, string(status)),
+ Target: campaign.DungeonMaster,
+ Content: fmt.Sprintf(notifyFmt, playerID, campaign.Name, sessionTime),
+ })
+}
diff --git a/internal/interactions/select_handlers.go b/internal/interactions/select_handlers.go
new file mode 100644
index 0000000..1867e19
--- /dev/null
+++ b/internal/interactions/select_handlers.go
@@ -0,0 +1,63 @@
+package interactions
+
+import (
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/messages"
+)
+
+/*
+ Flow:
+ Triggered when a player picks an entry from one of the two list-style
+ select menus rendered in back_handlers.go. Each handler is a thin
+ forwarder to the appropriate render function:
+
+ 1. mycampaign_select: myCampaignSelectHandler
+ a. Selection -> RenderCampaignDetail (campaign_detail.go).
+ b. Sentinel "none" (empty-list placeholder) is silently ignored.
+ 2. manage_select: manageSelectHandler
+ a. Selection -> RenderManageCampaignMenu (manage_campaign.go).
+ b. Same "none" sentinel handling.
+
+ Notes:
+ - "none" comes from BuildPlayerCampaignSelect's empty-state fallback in
+ components.go; both menus reuse that builder.
+ - Auth is not enforced here; the render functions both gate themselves
+ (RenderManageCampaignMenu via DM-scope auth, RenderCampaignDetail by
+ approval state).
+*/
+
+// myCampaignSelectHandler handles mycampaign_select: player picked a campaign from /mycampaigns.
+type myCampaignSelectHandler struct {
+ db *bun.DB
+}
+
+func (h *myCampaignSelectHandler) CustomIDPrefix() string {
+ return messages.MyCampaignSelectPrefix
+}
+
+func (h *myCampaignSelectHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ values := i.MessageComponentData().Values
+ if len(values) == 0 || values[0] == "none" {
+ return
+ }
+ RenderCampaignDetail(s, i, h.db, values[0])
+}
+
+// manageSelectHandler handles manage_select: DM picked a campaign from /managecampaigns.
+type manageSelectHandler struct {
+ db *bun.DB
+}
+
+func (h *manageSelectHandler) CustomIDPrefix() string {
+ return messages.ManageSelectPrefix
+}
+
+func (h *manageSelectHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ values := i.MessageComponentData().Values
+ if len(values) == 0 || values[0] == "none" {
+ return
+ }
+ RenderManageCampaignMenu(s, i, h.db, values[0])
+}
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
new file mode 100644
index 0000000..6cb1159
--- /dev/null
+++ b/internal/interactions/timezone_select.go
@@ -0,0 +1,133 @@
+package interactions
+
+/*
+ Timezone preference flow.
+
+ 1. User clicks "Timezone" on the /me hub.
+ 2. timezoneButtonHandler (set_timezone) shows the current timezone and a select menu.
+ 3. User picks from a curated list of IANA zones.
+ 4. timezoneSelectHandler (timezone_select) saves to PlayerSettings and confirms.
+*/
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "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"
+)
+
+/*
+tzOptions is the list shown in the select menu containing all the available timezones.
+*/
+var tzOptions = []discordgo.SelectMenuOption{
+ {Label: "UTC / GMT", Value: "UTC"},
+ {Label: "Hawaii (UTC-10)", Value: "Pacific/Honolulu"},
+ {Label: "Alaska (UTC-9)", Value: "America/Anchorage"},
+ {Label: "Pacific US/Canada (UTC-8/-7)", Value: "America/Los_Angeles"},
+ {Label: "Mountain US/Canada (UTC-7/-6)", Value: "America/Denver"},
+ {Label: "Central US/Canada (UTC-6/-5)", Value: "America/Chicago"},
+ {Label: "Eastern US/Canada (UTC-5/-4)", Value: "America/New_York"},
+ {Label: "Venezuela (UTC-4)", Value: "America/Caracas"},
+ {Label: "Colombia / Peru (UTC-5)", Value: "America/Bogota"},
+ {Label: "Santiago, Chile (UTC-4/-3)", Value: "America/Santiago"},
+ {Label: "Brazil / SΓ£o Paulo (UTC-3/-2)", Value: "America/Sao_Paulo"},
+ {Label: "Argentina / Buenos Aires (UTC-3)", Value: "America/Argentina/Buenos_Aires"},
+ {Label: "Azores (UTC-1)", Value: "Atlantic/Azores"},
+ {Label: "London / Dublin (UTC+0/+1)", Value: "Europe/London"},
+ {Label: "Paris / Berlin / Madrid (UTC+1/+2)", Value: "Europe/Paris"},
+ {Label: "Helsinki / Kyiv (UTC+2/+3)", Value: "Europe/Helsinki"},
+ {Label: "Moscow (UTC+3)", Value: "Europe/Moscow"},
+ {Label: "Dubai / UAE (UTC+4)", Value: "Asia/Dubai"},
+ {Label: "India (UTC+5:30)", Value: "Asia/Kolkata"},
+ {Label: "Thailand / Jakarta (UTC+7)", Value: "Asia/Bangkok"},
+ {Label: "China / Singapore (UTC+8)", Value: "Asia/Shanghai"},
+ {Label: "Japan / Korea (UTC+9)", Value: "Asia/Tokyo"},
+ {Label: "Sydney / Melbourne (UTC+10/+11)", Value: "Australia/Sydney"},
+ {Label: "Auckland (UTC+12/+13)", Value: "Pacific/Auckland"},
+ {Label: "South Africa (UTC+2)", Value: "Africa/Johannesburg"},
+}
+
+type timezoneButtonHandler struct {
+ db *bun.DB
+}
+
+func (h *timezoneButtonHandler) CustomIDPrefix() string {
+ return messages.TimezonePrefix
+}
+
+func (h *timezoneButtonHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ userID := helpers.GetUserID(i)
+ settings, err := models.GetOrCreatePlayerSettings(h.db, userID)
+ if err != nil {
+ log.Printf("timezone: load settings for %s: %v", userID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ current := settings.Timezone
+ if current == "" {
+ current = "UTC"
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf(messages.TimezoneHeader, current),
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.SelectMenu{
+ CustomID: messages.TimezoneSelectID,
+ MenuType: discordgo.StringSelectMenu,
+ Placeholder: messages.TimezoneSelectPlaceholder,
+ Options: tzOptions,
+ },
+ }},
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ router.BackButton(messages.BackLabel, router.ViewMe),
+ }},
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+type timezoneSelectHandler struct {
+ db *bun.DB
+}
+
+func (h *timezoneSelectHandler) CustomIDPrefix() string {
+ return messages.TimezoneSelectID
+}
+
+func (h *timezoneSelectHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ userID := helpers.GetUserID(i)
+ values := i.MessageComponentData().Values
+ if len(values) == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.TimezoneInvalid)
+ return
+ }
+ tz := values[0]
+
+ settings, err := models.GetOrCreatePlayerSettings(h.db, userID)
+ if err != nil {
+ log.Printf("timezone: load settings for %s: %v", userID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ settings.Timezone = tz
+ if _, err := h.db.NewUpdate().Model(settings).WherePK().Exec(context.Background()); err != nil {
+ log.Printf("timezone: save for %s: %v", userID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ helpers.RespondUpdateTerminal(s, i, fmt.Sprintf(messages.TimezoneSuccess, tz))
+}
diff --git a/internal/interactions/token_confirm.go b/internal/interactions/token_confirm.go
new file mode 100644
index 0000000..0b4ce12
--- /dev/null
+++ b/internal/interactions/token_confirm.go
@@ -0,0 +1,343 @@
+package interactions
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/uptrace/bun"
+
+ "github.com/bwmarrin/discordgo"
+
+ "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"
+)
+
+/*
+ Flow:
+ Triggered post-upload token confirmation, started by /uploadtoken.
+ 1. /uploadtoken processes the source+frame attachments
+ a. writes src_/frm_/out_ temp files under dataDir, and
+ b. posts a preview embed with Apply/Discard buttons (handled in commands/upload_token.go).
+ 2. Apply click: tokenApplyHandler opens a naming modal
+ (CustomID: token_apply:::).
+ 3. Modal submit: tokenApplyModal:
+ a. Inserts a Media row (KindTokenPlayer) pointing at the out_ file.
+ b. Removes the src_/frm_ temps (out_ becomes the canonical file).
+ c. If the player has active non-DM campaigns, renders a campaign
+ select + Skip button so the token can be assigned immediately.
+ Otherwise replies with a terminal "saved" message.
+ 4. Campaign select: playerTokenPostcreateSelectHandler updates
+ CampaignPlayer.media_id for (player, campaign).
+ 5. Skip click: playerTokenSkipHandler dismisses with "saved, not assigned".
+ 6. Discard click (any time before Apply): tokenDiscardHandler removes
+ all three temp files (src_/frm_/out_) and replies with a dismissal.
+
+ Notes:
+ - Re-assignment of an existing token is handled in player_tokens.go
+ (gallery flow), not here.
+ - File extensions are probed across {.jpg,.jpeg,.png,.webp} because the
+ source upload can be any of these; out_ is always .png.
+*/
+
+/*
+tokenApplyHandler opens a naming modal before saving the token.
+
+CustomID: token_apply:{guildID}:{playerID}:{token-uuid}
+*/
+type tokenApplyHandler struct {
+ db *bun.DB
+ dataDir string
+ mediaBaseURL string
+}
+
+func (h *tokenApplyHandler) CustomIDPrefix() string {
+ return messages.TokenApplyPrefix
+}
+
+func (h *tokenApplyHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 4)
+ if !ok {
+ return
+ }
+
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseModal,
+ Data: &discordgo.InteractionResponseData{
+ CustomID: fmt.Sprintf("%s:%s:%s:%s", messages.TokenApplyModalPrefix, parts[1], parts[2], parts[3]),
+ Title: messages.TokenNameModalTitle,
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.TextInput{
+ CustomID: messages.TokenNameFieldID,
+ Label: messages.TokenNameFieldLabel,
+ Style: discordgo.TextInputShort,
+ Placeholder: messages.TokenNameFieldPlaceholder,
+ Required: true,
+ MaxLength: 100,
+ },
+ }},
+ },
+ },
+ })
+}
+
+/*
+tokenApplyModal saves the processed token, then offers to assign it to a campaign.
+
+CustomID: token_apply_modal:{guildID}:{playerID}:{token-uuid}
+*/
+type tokenApplyModal struct {
+ db *bun.DB
+ dataDir string
+ mediaBaseURL string
+}
+
+func (h *tokenApplyModal) CustomIDPrefix() string {
+ return messages.TokenApplyModalPrefix
+}
+
+func (h *tokenApplyModal) HandleModal(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.ModalSubmitData().CustomID, 4)
+ if !ok {
+ return
+ }
+ guildID, playerID, token := parts[1], parts[2], parts[3]
+
+ if helpers.GetUserID(i) != playerID {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ var name string
+ for _, row := range i.ModalSubmitData().Components {
+ for _, comp := range row.(*discordgo.ActionsRow).Components {
+ if ti, ok := comp.(*discordgo.TextInput); ok && ti.CustomID == messages.TokenNameFieldID {
+ name = strings.TrimSpace(ti.Value)
+ }
+ }
+ }
+ if name == "" {
+ name = "token"
+ }
+
+ outDisk, outURL := mediaserver.TokenPath(h.dataDir, h.mediaBaseURL, guildID, playerID, "out_"+token, ".png")
+
+ media := &models.Media{
+ ID: uuid.NewString(),
+ OwnerID: playerID,
+ Path: outDisk,
+ URL: outURL,
+ Kind: models.KindTokenPlayer,
+ Name: name,
+ MimeType: "image/png",
+ CreatedAt: time.Now(),
+ }
+ if _, err := h.db.NewInsert().Model(media).Exec(context.Background()); err != nil {
+ log.Printf("token_apply_modal: insert failed for player %s: %v", playerID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.TokenApplyFailed)
+ return
+ }
+
+ for _, suffix := range []string{"src_", "frm_"} {
+ for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} {
+ disk, _ := mediaserver.TokenPath(h.dataDir, h.mediaBaseURL, guildID, playerID, suffix+token, ext)
+ os.Remove(disk)
+ }
+ }
+
+ 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.RespondUpdate(s, i, fmt.Sprintf(messages.TokenApplySuccess, name), []*discordgo.MessageEmbed{}, []discordgo.MessageComponent{downloadRow})
+ return
+ }
+ var activeCPs []models.CampaignPlayer
+ for _, cp := range allCPs {
+ if cp.Role == models.RolePlayer && cp.Status == models.StatusActive && cp.Campaign != nil {
+ activeCPs = append(activeCPs, cp)
+ }
+ }
+ if len(activeCPs) == 0 {
+ helpers.RespondUpdate(s, i, fmt.Sprintf(messages.TokenApplySuccess, name), []*discordgo.MessageEmbed{}, []discordgo.MessageComponent{downloadRow})
+ return
+ }
+
+ var options []discordgo.SelectMenuOption
+ for _, cp := range activeCPs {
+ options = append(options, discordgo.SelectMenuOption{
+ Label: cp.Campaign.Name,
+ Value: cp.CampaignID,
+ })
+ }
+
+ embed := &discordgo.MessageEmbed{
+ Title: name,
+ Color: messages.EmbedColor,
+ Image: &discordgo.MessageEmbedImage{URL: outURL},
+ }
+ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseUpdateMessage,
+ Data: &discordgo.InteractionResponseData{
+ Content: fmt.Sprintf(messages.TokenPostcreateHeader, name),
+ Embeds: []*discordgo.MessageEmbed{embed},
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.SelectMenu{
+ CustomID: fmt.Sprintf("%s:%s", messages.TokenPostcreateSelectPrefix, media.ID),
+ Placeholder: messages.TokenPostcreateSelectPlaceholder,
+ Options: options,
+ },
+ }},
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{Label: messages.TokenDownloadLabel, Style: discordgo.LinkButton, URL: downloadURL},
+ discordgo.Button{
+ Label: messages.TokenSkipLabel,
+ Style: discordgo.SecondaryButton,
+ CustomID: fmt.Sprintf("%s:%s", messages.TokenSkipPrefix, media.ID),
+ },
+ }},
+ },
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+/*
+playerTokenPostcreateSelectHandler assigns a freshly-created token to a campaign.
+
+CustomID: player_token_postcreate:{mediaID}
+Values[0]: campaignID
+*/
+type playerTokenPostcreateSelectHandler struct {
+ db *bun.DB
+}
+
+func (h *playerTokenPostcreateSelectHandler) CustomIDPrefix() string {
+ return messages.TokenPostcreateSelectPrefix
+}
+
+func (h *playerTokenPostcreateSelectHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 2)
+ if !ok {
+ return
+ }
+ mediaID := parts[1]
+ userID := helpers.GetUserID(i)
+
+ values := i.MessageComponentData().Values
+ if len(values) == 0 {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+ campaignID := values[0]
+
+ media, err := db.GetByID[models.Media](h.db, mediaID)
+ if err != nil || media.OwnerID != userID {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ _, err = h.db.NewUpdate().Model((*models.CampaignPlayer)(nil)).
+ Set("media_id = ?", mediaID).
+ Where("player_id = ? AND campaign_id = ?", userID, campaignID).
+ Exec(context.Background())
+ if err != nil {
+ log.Printf("token_postcreate: assign failed player %s campaign %s: %v", userID, campaignID, err)
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ 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.RespondUpdate(s, i, fmt.Sprintf(messages.TokenPostcreateAssigned, campaignID), []*discordgo.MessageEmbed{}, []discordgo.MessageComponent{downloadRow})
+ return
+ }
+ helpers.RespondUpdate(s, i, fmt.Sprintf(messages.TokenPostcreateAssigned, campaign.Name), []*discordgo.MessageEmbed{}, []discordgo.MessageComponent{downloadRow})
+}
+
+/*
+playerTokenSkipHandler dismisses the post-create assignment prompt.
+
+CustomID: player_token_skip:{mediaID}
+*/
+type playerTokenSkipHandler struct {
+ db *bun.DB
+}
+
+func (h *playerTokenSkipHandler) CustomIDPrefix() string { return messages.TokenSkipPrefix }
+
+func (h *playerTokenSkipHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ 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),
+ }},
+ })
+}
+
+/*
+tokenDiscardHandler removes all three temp files and dismisses the preview.
+
+CustomID: token_discard:{guildID}:{playerID}:{token-uuid}
+*/
+type tokenDiscardHandler struct {
+ dataDir string
+ mediaBaseURL string
+}
+
+func (h *tokenDiscardHandler) CustomIDPrefix() string {
+ return messages.TokenDiscardPrefix
+}
+
+func (h *tokenDiscardHandler) HandleComponents(s *discordgo.Session, i *discordgo.InteractionCreate) {
+ parts, ok := helpers.SplitCustomID(s, i, i.MessageComponentData().CustomID, 4)
+ if !ok {
+ return
+ }
+ guildID, playerID, token := parts[1], parts[2], parts[3]
+
+ if helpers.GetUserID(i) != playerID {
+ helpers.RespondUpdateTerminal(s, i, messages.GenericErrorMessage)
+ return
+ }
+
+ for _, suffix := range []string{"src_", "frm_", "out_"} {
+ for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} {
+ disk, _ := mediaserver.TokenPath(h.dataDir, h.mediaBaseURL, guildID, playerID, suffix+token, ext)
+ os.Remove(disk)
+ }
+ }
+
+ helpers.RespondUpdateTerminal(s, i, messages.TokenDiscardSuccess)
+}
diff --git a/internal/interactions/token_confirm_test.go b/internal/interactions/token_confirm_test.go
new file mode 100644
index 0000000..f91364c
--- /dev/null
+++ b/internal/interactions/token_confirm_test.go
@@ -0,0 +1,198 @@
+package interactions
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/framebuffers/moontracer/internal/manager/models"
+ "github.com/framebuffers/moontracer/internal/messages"
+ "github.com/framebuffers/moontracer/internal/testutil"
+)
+
+/*
+Coverage for the post-upload Apply / Discard / Postcreate-assign handlers.
+
+Critical issue: the Apply / Discard CustomIDs encode (guildID, playerID, token).
+Both handlers extract playerID from the CustomID, NOT from the click's
+helpers.GetUserID(i). That means anyone who clicks the button (or fabricates
+the CustomID via interaction replay) acts AS that playerID:
+
+ - tokenApplyModal inserts a Media row owned by the CustomID's playerID.
+ - tokenDiscardHandler deletes the temp files belonging to the CustomID's
+ playerID.
+
+The token UUID is unguessable, so practical exploitation requires the
+attacker to see the original Apply button (e.g. via a leaked ephemeral
+screenshot or a bug surfacing the CustomID). The fix is to compare
+helpers.GetUserID(i) against parts[2] in both handlers.
+
+The postcreate select handler has a different shape: the UPDATE WHERE filters
+by the clicker's userID, so an attacker can only stamp a victim's mediaID
+into THEIR OWN CampaignPlayer row: cosmetic, but still a cross-owner data
+reference that the gallery flow's fix should also close here.
+*/
+
+const (
+ tokConfirmGuildID = "guild-1"
+ tokConfirmVictimID = "victim-1"
+ tokConfirmAtkID = "attacker-2"
+ tokConfirmTokenID = "uuid-token-1"
+)
+
+func writePreviewFiles(t *testing.T, dataDir, guildID, playerID, token string) []string {
+ t.Helper()
+ dir := filepath.Join(dataDir, guildID, "tokens", playerID)
+ require.NoError(t, os.MkdirAll(dir, 0o755))
+ paths := []string{
+ filepath.Join(dir, "src_"+token+".png"),
+ filepath.Join(dir, "out_"+token+".png"),
+ }
+ for _, p := range paths {
+ require.NoError(t, os.WriteFile(p, []byte("preview"), 0o644))
+ }
+ return paths
+}
+
+/*
+Attacker forges a token_apply_modal submission targeting the victim's preview.
+
+When:
+
+ The victim ran /uploadtoken (preview written to disk). The attacker
+ submits a modal with CustomID embedding the victim's playerID + token.
+
+Expected:
+
+ No Media row is created with OwnerID=victim.
+
+Development Note (v0.12.6, 20260511):
+
+ Currently FAILS:
+ modal pulls playerID from the CustomID, so the attacker writes a Media row
+ on the victim's behalf pointing at the on-disk preview.
+*/
+func TestTokenApplyModal_RejectsCrossUser(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ dataDir := t.TempDir()
+ writePreviewFiles(t, dataDir, tokConfirmGuildID, tokConfirmVictimID, tokConfirmTokenID)
+
+ stub := testutil.NewStubSession(t)
+ handler := &tokenApplyModal{db: database, dataDir: dataDir, mediaBaseURL: "http://cdn"}
+
+ customID := fmt.Sprintf("%s:%s:%s:%s",
+ messages.TokenApplyModalPrefix, tokConfirmGuildID, tokConfirmVictimID, tokConfirmTokenID)
+ interaction := &discordgo.InteractionCreate{
+ Interaction: &discordgo.Interaction{
+ Type: discordgo.InteractionModalSubmit,
+ User: &discordgo.User{ID: tokConfirmAtkID},
+ Data: discordgo.ModalSubmitInteractionData{
+ CustomID: customID,
+ Components: []discordgo.MessageComponent{
+ &discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ &discordgo.TextInput{CustomID: messages.TokenNameFieldID, Value: "stolen"},
+ }},
+ },
+ },
+ },
+ }
+ handler.HandleModal(stub.Session, interaction)
+
+ count, err := database.NewSelect().Model((*models.Media)(nil)).
+ Where("owner_id = ?", tokConfirmVictimID).Count(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, 0, count,
+ "attacker created a Media row owned by the victim β TokenApplyModal must check helpers.GetUserID == playerID")
+}
+
+/*
+Attacker forges a token_discard targeting the victim's preview.
+
+When:
+
+ The victim has a preview on disk awaiting Apply. The attacker submits
+ a discard CustomID embedding the victim's playerID + token.
+
+Expected:
+
+ The victim's preview files remain on disk.
+
+Development Note (v0.12.6, 20260511):
+
+ Currently FAILS:
+ discard playerID from the CustomID and unlinks anything matching the
+ pattern.
+*/
+func TestTokenDiscard_RejectsCrossUser(t *testing.T) {
+ dataDir := t.TempDir()
+ paths := writePreviewFiles(t, dataDir, tokConfirmGuildID, tokConfirmVictimID, tokConfirmTokenID)
+
+ stub := testutil.NewStubSession(t)
+ handler := &tokenDiscardHandler{dataDir: dataDir, mediaBaseURL: "http://cdn"}
+
+ customID := fmt.Sprintf("%s:%s:%s:%s",
+ messages.TokenDiscardPrefix, tokConfirmGuildID, tokConfirmVictimID, tokConfirmTokenID)
+ handler.HandleComponents(stub.Session, buildComponentInteraction(tokConfirmAtkID, customID))
+
+ for _, p := range paths {
+ _, err := os.Stat(p)
+ assert.NoError(t, err, "victim's preview file %s deleted by attacker", p)
+ }
+}
+
+/*
+Attacker uses the postcreate select to stamp the victim's mediaID into their
+own roster row.
+
+When:
+
+ The attacker is a player in some campaign and submits a postcreate-select
+ whose mediaID points at the victim's token.
+
+Expected:
+
+ No CampaignPlayer row anywhere references the victim's mediaID.
+
+Development Note (v0.12.6, 20260511):
+
+ Currently FAILS:
+ handler updates by (clicker, campaign) without verifying the
+ media belongs to the clicker.
+*/
+func TestPlayerTokenPostcreate_RejectsCrossOwnerMedia(t *testing.T) {
+ database := testutil.NewTestDB(t)
+ dataDir := t.TempDir()
+ seedVictimToken(t, database, dataDir)
+
+ ctx := context.Background()
+ const campaignID = "camp-x"
+ _, err := database.NewInsert().Model(&models.Campaign{
+ ID: campaignID, Name: "X", Tag: "x",
+ DungeonMaster: tokConfirmAtkID, IsApproved: true,
+ }).Exec(ctx)
+ require.NoError(t, err)
+ _, err = database.NewInsert().Model(&models.CampaignPlayer{
+ PlayerID: tokConfirmAtkID, CampaignID: campaignID,
+ Role: models.RolePlayer, Status: models.StatusActive,
+ }).Exec(ctx)
+ require.NoError(t, err)
+
+ stub := testutil.NewStubSession(t)
+ handler := &playerTokenPostcreateSelectHandler{db: database}
+
+ customID := fmt.Sprintf("%s:%s", messages.TokenPostcreateSelectPrefix, tokVictimMID)
+ handler.HandleComponents(stub.Session,
+ buildSelectInteraction(tokConfirmAtkID, customID, []string{campaignID}))
+
+ count, err := database.NewSelect().Model((*models.CampaignPlayer)(nil)).
+ Where("media_id = ?", tokVictimMID).Count(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, 0, count,
+ "attacker stamped victim's mediaID into their own roster row β postcreate must verify media.OwnerID == userID")
+}
diff --git a/internal/interactions/views.go b/internal/interactions/views.go
new file mode 100644
index 0000000..a1029e5
--- /dev/null
+++ b/internal/interactions/views.go
@@ -0,0 +1,130 @@
+package interactions
+
+/*
+ View registration.
+
+ This file is the single place where ViewIDs are bound to render functions.
+ The actual rendering bodies live next to the feature they render
+ (commands.RenderMeHub, interactions.RenderManageCampaignMenu, etc.):
+ RegisterAllViews only glues them to the router.
+
+ Flow (startup):
+ 1. main() calls interactions.AllComponents(db, dispatcher).
+ 2. AllComponents calls RegisterAllViews(db) as its first statement,
+ before returning the ComponentHandler list.
+ 3. For each ViewID, RegisterAllViews calls router.Register with an
+ adapter closure of shape RenderFunc β func(s, i, args).
+ 4. The adapter unpacks `args` (e.g. args[0] as a campaignID) and
+ forwards to the underlying render function with its natural
+ signature. This keeps render functions free of the router's
+ positional-args convention.
+ 5. Once this completes, router.Navigate can resolve any registered
+ ViewID for the lifetime of the process.
+*/
+
+import (
+ "github.com/framebuffers/moontracer/internal/interactions/helpers"
+ "github.com/bwmarrin/discordgo"
+ "github.com/uptrace/bun"
+
+ "github.com/framebuffers/moontracer/internal/commands"
+ "github.com/framebuffers/moontracer/internal/dispatch"
+ "github.com/framebuffers/moontracer/internal/interactions/router"
+)
+
+/*
+RegisterAllViews wires every router ViewID to a package-local render function.
+
+Call once at startup before the bot starts handling interactions.
+*/
+func RegisterAllViews(db *bun.DB, d *dispatch.Dispatcher) {
+ router.Register(router.ViewMe, func(s *discordgo.Session, i *discordgo.InteractionCreate, args []string) {
+ commands.RenderMeHub(s, i, db, helpers.GetUserID(i))
+ })
+
+ router.Register(router.ViewMeCampaigns, func(s *discordgo.Session, i *discordgo.InteractionCreate, args []string) {
+ RenderMeCampaigns(s, i, db, helpers.GetUserID(i))
+ })
+
+ router.Register(router.ViewMeConfig, func(s *discordgo.Session, i *discordgo.InteractionCreate, args []string) {
+ RenderMeConfig(s, i, db, helpers.GetUserID(i))
+ })
+
+ router.Register(router.ViewMeTokens, func(s *discordgo.Session, i *discordgo.InteractionCreate, args []string) {
+ RenderMeTokens(s, i, db, helpers.GetUserID(i))
+ })
+
+ router.Register(router.ViewMyCampaigns, func(s *discordgo.Session, i *discordgo.InteractionCreate, args []string) {
+ RenderMyCampaignsList(s, i, db, helpers.GetUserID(i))
+ })
+
+ router.Register(router.ViewManage, func(s *discordgo.Session, i *discordgo.InteractionCreate, args []string) {
+ RenderManageList(s, i, db, helpers.GetUserID(i))
+ })
+
+ router.Register(router.ViewManageCampaign, func(s *discordgo.Session, i *discordgo.InteractionCreate, args []string) {
+ if len(args) < 1 {
+ return
+ }
+ RenderManageCampaignMenu(s, i, db, args[0])
+ })
+
+ router.Register(router.ViewManagePlayers, func(s *discordgo.Session, i *discordgo.InteractionCreate, args []string) {
+ if len(args) < 1 {
+ return
+ }
+ RenderManagePlayersMenu(s, i, db, args[0])
+ })
+
+ router.Register(router.ViewManageSessions, func(s *discordgo.Session, i *discordgo.InteractionCreate, args []string) {
+ if len(args) < 1 {
+ return
+ }
+ RenderManageSessionsMenu(s, i, db, args[0])
+ })
+
+ router.Register(router.ViewManageSettings, func(s *discordgo.Session, i *discordgo.InteractionCreate, args []string) {
+ if len(args) < 1 {
+ return
+ }
+ RenderManageSettingsMenu(s, i, db, args[0])
+ })
+
+ router.Register(router.ViewManageDanger, func(s *discordgo.Session, i *discordgo.InteractionCreate, args []string) {
+ if len(args) < 1 {
+ return
+ }
+ RenderManageDangerMenu(s, i, db, args[0])
+ })
+
+ router.Register(router.ViewCampaignsBrowse, func(s *discordgo.Session, i *discordgo.InteractionCreate, args []string) {
+ filter := "all"
+ if len(args) > 0 && args[0] != "" {
+ filter = args[0]
+ }
+ RenderCampaignsBrowse(s, i, db, filter)
+ })
+
+ router.Register(router.ViewCampaignDetail, func(s *discordgo.Session, i *discordgo.InteractionCreate, args []string) {
+ if len(args) < 1 {
+ return
+ }
+ RenderCampaignDetail(s, i, db, args[0])
+ })
+
+ router.Register(router.ViewAdmin, func(s *discordgo.Session, i *discordgo.InteractionCreate, args []string) {
+ commands.RenderAdminHubUpdate(s, i)
+ })
+
+ router.Register(router.ViewAdminDiag, func(s *discordgo.Session, i *discordgo.InteractionCreate, args []string) {
+ commands.RenderAdminDiag(s, i, db, i.GuildID)
+ })
+
+ router.Register(router.ViewHelp, func(s *discordgo.Session, i *discordgo.InteractionCreate, args []string) {
+ commands.RenderHelp(s, i, db, d)
+ })
+
+ router.Register(router.ViewAbout, func(s *discordgo.Session, i *discordgo.InteractionCreate, args []string) {
+ commands.RenderAboutUpdate(s, i)
+ })
+}
diff --git a/internal/manager/models/campaign.go b/internal/manager/models/campaign.go
new file mode 100644
index 0000000..afeea1f
--- /dev/null
+++ b/internal/manager/models/campaign.go
@@ -0,0 +1,324 @@
+package models
+
+import (
+ "context"
+ "fmt"
+ "math"
+ "strings"
+ "time"
+ "unicode"
+
+ "github.com/google/uuid"
+ "github.com/uptrace/bun"
+)
+
+// Campaign represents a DnD campaign.
+type Campaign struct {
+ bun.BaseModel `bun:"table:campaigns"`
+
+ ID string `bun:",pk,notnull" json:"id"` // Server-generated unique identifier. This is the ID that will be used throughout to identify this particular campaign.
+ Name string `bun:",notnull" json:"name"` // Display name for the campaign.
+ Tag string `bun:",unique,notnull" json:"tag"` // Short, user-facing identifier for lookups (e.g. "strahd", "avalon", "itzaal", "nuevosur", "suvachi").
+ DungeonMaster string `bun:",notnull" json:"dm"` // Player FK of the user (DM) who created the campaign.
+ DM *Player `bun:"rel:belongs-to,join:dungeon_master=id" json:"dm_player,omitempty"`
+ Description string `bun:",notnull,default:''" json:"description"` // Campaign's description, synopsis, details, lore, or whatever you want to add to describe your setting.
+ Game GameConfig `bun:"embed:"` // Embedded singular game configuration. Stored as flat columns.
+
+ // Details about your campaign, like open slots, the style, trigger warnings, extra info by the DM to be added to the Campaign's description.
+
+ Slots int `bun:",notnull,default:0" json:"slots"` // Total roster cap. Westmarches set this to math.MaxInt32 (effectively unlimited) and gate per-session admission via SessionCapacity instead.
+ SessionCapacity int `bun:",notnull,default:6" json:"session_capacity"` // Westmarch tripwire β seats per sitting. Joining past this admits anyway and DMs the campaign DM. Ignored for non-westmarch campaigns.
+ IsOpen bool `bun:",notnull,default:false" json:"is_open"`
+ IsOneshot bool `bun:",notnull,default:false" json:"is_oneshot"`
+ IsWestmarch bool `bun:",notnull,default:false" json:"is_westmarch"`
+ Warnings []string `bun:",array,type:jsonb" json:"warnings,omitempty"`
+ Extra string `bun:",default:''" json:"extra,omitempty"`
+
+ Schedule CampaignSchedule `bun:"embed:"` // Campaign schedule.
+
+ // Links and media
+ Links []string `bun:",array,type:jsonb" json:"links,omitempty"`
+ VTTLink string `bun:",default:''" json:"vtt_link,omitempty"`
+ 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"`
+ 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"`
+
+ // Discord role ID that grants access to this campaign's channel.
+ // Empty string means no role has been linked yet.
+ RoleID string `bun:",default:''" json:"role_id"`
+
+ // Discord channel/thread IDs created on approval.
+ ChannelID string `bun:",default:''" json:"channel_id"`
+ CategoryID string `bun:",default:''" json:"category_id"`
+ AnnouncementsThreadID string `bun:",default:''" json:"announcements_thread_id"`
+
+ // Has this campaign been approved to be published?
+ IsApproved bool `bun:",notnull,default:false"`
+
+ // Archival: an archived campaign is an immutable record. No mutations allowed.
+ // Archival happens when a DM explicitly abandons the campaign or leaves the server.
+ IsArchived bool `bun:",notnull,default:false"`
+ ArchivedAt time.Time `bun:",nullzero"`
+ ArchivedReason string `bun:",nullzero"`
+}
+
+// CanMutate returns false if the campaign is archived (immutable).
+func (c *Campaign) CanMutate() bool {
+ return !c.IsArchived
+}
+
+/*
+DisplaySlots renders the roster cap for player-facing UI.
+
+Westmarches store math.MaxInt32 to mean "no roster cap" and must never leak that number;
+legacy rows with Slots <= 0 mean unset/unlimited.
+
+The "10+" form is similar to the stock counter at a dept-store: hide the exact figure once it stops being meaningful.
+*/
+func (c *Campaign) DisplaySlots() string {
+ if c.Slots <= 0 {
+ return "Unlimited"
+ }
+ if c.Slots > 10 {
+ return "10+"
+ }
+ return fmt.Sprintf("%d", c.Slots)
+}
+
+/*
+PlayerMap returns a map of player ID to CampaignPlayer for quick lookups.
+
+Requires CampaignPlayers to be loaded (via Relation).
+*/
+func (c *Campaign) PlayerMap() map[string]*CampaignPlayer {
+ m := make(map[string]*CampaignPlayer, len(c.CampaignPlayers))
+ for i := range c.CampaignPlayers {
+ m[c.CampaignPlayers[i].PlayerID] = &c.CampaignPlayers[i]
+ }
+ return m
+}
+
+/*
+DMMap returns a map of player ID to CampaignPlayer for DMs only.
+
+Requires CampaignPlayers to be loaded (via Relation).
+*/
+func (c *Campaign) DMMap() map[string]*CampaignPlayer {
+ m := make(map[string]*CampaignPlayer)
+ for i := range c.CampaignPlayers {
+ if c.CampaignPlayers[i].Role == RoleDM {
+ m[c.CampaignPlayers[i].PlayerID] = &c.CampaignPlayers[i]
+ }
+ }
+ return m
+}
+
+// GameConfig holds the game system details for a campaign.
+type GameConfig struct {
+ Edition string `bun:",notnull,default:''" json:"edition"`
+ Rules string `bun:",notnull,default:''" json:"rules"`
+ VTT string `bun:",notnull,default:''" json:"vtt"`
+ BooksAllowed []string `bun:",array,type:jsonb" json:"books_allowed,omitempty"`
+ OtherGame []string `bun:",array,type:jsonb" json:"other_game,omitempty"`
+}
+
+// CampaignSchedule holds the timing details for a campaign.
+type CampaignSchedule struct {
+ Frequency CampaignFrequency `bun:",notnull,default:'weekly'" json:"frequency"`
+ DayOfWeek int `bun:",notnull,default:-1" json:"day_of_week"` // 0=Mon..6=Sun, -1=unset
+ StartTime string `bun:",notnull,default:''" json:"start_time"` // "HH:MM" in UTC, empty=unset
+ DurationHours float64 `bun:",notnull,default:3" json:"duration_hours"` // session length in hours, default 3h
+ CreatedAt time.Time `bun:",notnull,default:current_timestamp" json:"created_at"`
+ LastSession time.Time `bun:",nullzero" json:"last_session"`
+ NextSession time.Time `bun:",nullzero" json:"next_session"`
+ AlertSent bool `bun:",notnull,default:false" json:"alert_sent"`
+}
+
+// DayName returns the display name for the schedule's day of week.
+func (s CampaignSchedule) DayName() string {
+ days := [7]string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"}
+ if s.DayOfWeek < 0 || s.DayOfWeek > 6 {
+ return "Unset"
+ }
+ return days[s.DayOfWeek]
+}
+
+// HasSchedule returns true if the campaign has a day and time set.
+func (s CampaignSchedule) HasSchedule() bool {
+ return s.DayOfWeek >= 0 && s.StartTime != ""
+}
+
+/*
+ NormalizeTag converts a campaign name into a URL-safe tag.
+
+"Curse of Strahd" -> "curse-of-strahd"
+*/
+func NormalizeTag(name string) string {
+ if idx := strings.IndexByte(name, ':'); idx != -1 {
+ name = name[:idx]
+ }
+ tag := strings.ToLower(strings.TrimSpace(name))
+
+ // Replace non-alphanumeric characters with hyphens; allow unicode letters (e.g. Γ±, Γ©).
+ var b strings.Builder
+ prevHyphen := false
+ for _, r := range tag {
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
+ b.WriteRune(r)
+ prevHyphen = false
+ } else if !prevHyphen {
+ b.WriteByte('-')
+ prevHyphen = true
+ }
+ }
+
+ tag = strings.Trim(b.String(), "-")
+
+ if len(tag) > 30 {
+ tag = tag[:30]
+ tag = strings.TrimRight(tag, "-")
+ }
+
+ return tag
+}
+
+// CampaignFrequency defines how often will sessions in this Campaign will occur.
+type CampaignFrequency string
+
+const (
+ OneShot CampaignFrequency = "oneshot"
+ Weekly CampaignFrequency = "weekly"
+ Biweekly CampaignFrequency = "biweekly"
+ Monthly CampaignFrequency = "monthly"
+ Quarterly CampaignFrequency = "quarterly"
+ Yearly CampaignFrequency = "yearly"
+ Westmarch CampaignFrequency = "westmarch"
+ Irregular CampaignFrequency = "irregular"
+)
+
+func (c *Campaign) CreateCampaign(
+ db *bun.DB,
+ dmID string,
+ playerIDs []string,
+ name string,
+ tag string,
+ description string,
+ conf *GameConfig,
+ slots int,
+ isOpen bool,
+ isOneshot bool,
+ warnings []string,
+ extraInfo string,
+ schedule *CampaignSchedule,
+ links []string,
+ vtt string,
+ books []string,
+ otherGameInfo []string,
+ isApproved bool,
+) (*Campaign, error) {
+ ctx := context.Background()
+
+ // get DM from DB
+ var dm Player
+ err := db.NewSelect().Model(&dm).Where("id = ?", dmID).Scan(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // get players from DB
+ var players []Player
+ if len(playerIDs) > 0 {
+ err = db.NewSelect().Model(&players).Where("id IN (?)", bun.In(playerIDs)).Scan(ctx)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ // create campaign entry on DB
+ campaign := &Campaign{
+ ID: uuid.NewString(),
+ Name: name,
+ Tag: tag,
+ DungeonMaster: dmID,
+ DM: &dm,
+ Description: description,
+ Game: *conf,
+ Slots: slots,
+ IsOpen: isOpen,
+ IsOneshot: isOneshot,
+ Warnings: warnings,
+ Extra: extraInfo,
+ Schedule: *schedule,
+ Links: links,
+ VTTLink: vtt,
+ IsApproved: false,
+ }
+
+ _, err = db.NewInsert().Model(campaign).Exec(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // create CampaignPlayer entries for each player
+ for _, p := range players {
+ cp := &CampaignPlayer{
+ PlayerID: p.ID,
+ CampaignID: campaign.ID,
+ Role: RolePlayer,
+ Status: StatusActive,
+ }
+ _, err = db.NewInsert().Model(cp).Exec(ctx)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ // also add the DM as a CampaignPlayer
+ // dm's are players too :)
+ dmEntry := &CampaignPlayer{
+ PlayerID: dmID,
+ CampaignID: campaign.ID,
+ Role: RoleDM,
+ Status: StatusActive,
+ }
+ _, err = db.NewInsert().Model(dmEntry).Exec(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ if campaign.IsWestmarch {
+ campaign.Slots = math.MaxInt32
+ }
+
+ 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
new file mode 100644
index 0000000..296540f
--- /dev/null
+++ b/internal/manager/models/campaign_player.go
@@ -0,0 +1,224 @@
+package models
+
+import (
+ "context"
+
+ "github.com/uptrace/bun"
+)
+
+/*
+Role distinguishes players from DMs within a campaign.
+
+Players can be DMs.
+*/
+type Role string
+
+/*
+Permissions are hierarchical: admins can override mod permissions, mod can override DMs and so on.
+e.g. to authorise a campaign, a DM has to ask a mod for permission.
+
+But, if a mod account gets compromised, an admin can override any mod and act as the final decisionmaker of the whole permissions chain.
+Every new member starts as a player, with the server admin as the only admin account.
+
+On Moontracer's config, a Moderator role can be used to automatically give mod access to the bot.
+A player becomes a DM when:
+
+ 1. uploads a request for a new campaign (filling the new campaign form), and
+ 2. gets authorised by a mod.
+
+A player can be part of a Campaign if:
+ 1. the campaign is active and properly authorized by a mod/admin.
+ 2. the DM is active, authorised as such, owner of the campaign and is not banned.
+*/
+const (
+ RolePlayer Role = "player"
+ RoleDM Role = "dm"
+ RoleModerator Role = "mod"
+ RoleAdmin Role = "admin"
+)
+
+/*
+CampaignPlayerStatus tracks a participant's (DM or Player) standing in a campaign.
+
+Before a Campaign is approved to be listed as active, it remanins by default as "pending".
+After it gets approved, it becomes "active".
+
+A campaign then can change to be on "hiatus" (paused by the DM until further notice), "cancelled" or "finished".
+
+Since this type also defines the relationship between player and DM, a sixth status is added: "banned".
+This last status is used when a user is permanently banned from a campaign, satisfying the player <-> DM relationship.
+*/
+type CampaignPlayerStatus string
+
+const (
+ StatusActive CampaignPlayerStatus = "active"
+ StatusHiatus CampaignPlayerStatus = "hiatus"
+ StatusCancelled CampaignPlayerStatus = "cancelled"
+ StatusFinished CampaignPlayerStatus = "finished"
+ StatusBanned CampaignPlayerStatus = "banned"
+ StatusPending CampaignPlayerStatus = "pending"
+)
+
+/*
+RSVPStatus records a player's attendance response for the current session.
+
+Reset to RSVPPending whenever NextSession changes.
+*/
+type RSVPStatus string
+
+const (
+ RSVPPending RSVPStatus = ""
+ RSVPAccepted RSVPStatus = "accepted"
+ RSVPDeclined RSVPStatus = "declined"
+ RSVPWaitlisted RSVPStatus = "waitlisted"
+)
+
+/*
+CampaignPlayer is the join table between Player and Campaign.
+
+MediaID optionally links a Media record (player token) to this player's slot in a campaign.
+*/
+type CampaignPlayer struct {
+ bun.BaseModel `bun:"table:campaign_players"`
+
+ PlayerID string `bun:",pk,notnull" json:"player_id"`
+ Player *Player `bun:"rel:belongs-to,join:player_id=id" json:"player,omitempty"`
+ CampaignID string `bun:",pk,notnull" json:"campaign_id"`
+ Campaign *Campaign `bun:"rel:belongs-to,join:campaign_id=id" json:"campaign,omitempty"`
+ Role Role `bun:",notnull,default:'player'" json:"role"`
+ MediaID string `bun:",nullzero" json:"media_id,omitempty"`
+ Media *Media `bun:"rel:belongs-to,join:media_id=id" json:"media,omitempty"`
+ Status CampaignPlayerStatus `bun:",notnull,default:'active'" json:"status"`
+ SessionsPlayed int `bun:",notnull,default:0" json:"sessions_played"`
+ DiceThrowPicture string `bun:",nullzero" json:"dice_throw_picture,omitempty"`
+ BanReason string `bun:",nullzero" json:"ban_reason,omitempty"`
+ BannedFromCampaign bool `bun:",notnull,default:false" json:"banned_from_campaign"`
+ BanReasonFromCampaign string `bun:",nullzero" json:"ban_reason_from_campaign,omitempty"`
+ RSVPStatus RSVPStatus `bun:",notnull,default:''" json:"rsvp_status"`
+ SheetURL string `bun:",nullzero" json:"sheet_url,omitempty"`
+}
+
+// GetCampaignPlayer retrieves a single CampaignPlayer by its composite primary key.
+func GetCampaignPlayer(db *bun.DB, playerID, campaignID string) (*CampaignPlayer, error) {
+ ctx := context.Background()
+ cp := &CampaignPlayer{}
+ err := db.NewSelect().Model(cp).
+ Where("player_id = ? AND campaign_id = ?", playerID, campaignID).
+ Scan(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return cp, nil
+}
+
+// GetCampaignPlayers retrieves all CampaignPlayers belonging to a given Campaign ID
+func GetCampaignPlayers(db *bun.DB, campaignID string) ([]CampaignPlayer, error) {
+ ctx := context.Background()
+ var players []CampaignPlayer
+ err := db.NewSelect().Model(&players).
+ Relation("Player").
+ Where("campaign_id = ?", campaignID).
+ Scan(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return players, nil
+}
+
+// GetPlayerCampaigns retrieves all the Campaigns a Player belongs to.
+func GetPlayerCampaigns(db *bun.DB, playerID string) ([]CampaignPlayer, error) {
+ ctx := context.Background()
+ var campaigns []CampaignPlayer
+ err := db.NewSelect().Model(&campaigns).
+ Relation("Campaign").
+ Where("player_id = ?", playerID).
+ Scan(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return campaigns, nil
+}
+
+// RemoveCampaignPlayer removes a player from a given Campaign.
+func RemoveCampaignPlayer(db *bun.DB, playerID, campaignID string) error {
+ ctx := context.Background()
+ _, err := db.NewDelete().Model((*CampaignPlayer)(nil)).
+ Where("player_id = ? AND campaign_id = ?", playerID, campaignID).
+ Exec(ctx)
+ return err
+}
+
+// SetCampaignPlayerStatus sets the status of a Player to any CampainPlayerStatus entry.
+func SetCampaignPlayerStatus(db *bun.DB, playerID, campaignID string, status CampaignPlayerStatus) error {
+ ctx := context.Background()
+ _, err := db.NewUpdate().Model((*CampaignPlayer)(nil)).
+ Set("status = ?", status).
+ Where("player_id = ? AND campaign_id = ?", playerID, campaignID).
+ Exec(ctx)
+ return err
+}
+
+/*
+BulkSetCampaignPlayerStatus updates campaign memberships for a player in bulk.
+
+skipLogic decides which entries to leave untouched. nil means skip nothing.
+
+Returns updated/skipped counts and a map of campaignIDβerror for any failures.
+*/
+func BulkSetCampaignPlayerStatus(db *bun.DB, playerID string, campaigns []CampaignPlayer, to CampaignPlayerStatus, skipLogic func(CampaignPlayer) bool) (updated int, skipped int, errs map[string]error) {
+ errors := make(map[string]error)
+
+ for _, cp := range campaigns {
+ if skipLogic != nil && skipLogic(cp) {
+ skipped++
+ continue
+ }
+ if err := SetCampaignPlayerStatus(db, playerID, cp.CampaignID, to); err != nil {
+ errors[cp.CampaignID] = err
+ continue
+ }
+ updated++
+ }
+
+ if len(errors) == 0 {
+ return updated, skipped, nil
+ }
+ return updated, skipped, errors
+}
+
+/*
+BulkAddCampaignMembers creates CampaignPlayer rows for each player ID, skipping any that already exist.
+
+Preserves existing membership data (role, ban state, etc.) via ON CONFLICT DO NOTHING.
+*/
+func BulkAddCampaignMembers(db *bun.DB, campaignID string, playerIDs []string) error {
+ if len(playerIDs) == 0 {
+ return nil
+ }
+ ctx := context.Background()
+ rows := make([]CampaignPlayer, 0, len(playerIDs))
+ for _, id := range playerIDs {
+ rows = append(rows, CampaignPlayer{
+ PlayerID: id,
+ CampaignID: campaignID,
+ Status: StatusActive,
+ })
+ }
+ _, err := db.NewInsert().Model(&rows).
+ On("CONFLICT (player_id, campaign_id) DO NOTHING").
+ Exec(ctx)
+ return err
+}
+
+/*
+ResetCampaignRSVPs clears the RSVP status for all players in a campaign.
+
+Call whenever NextSession is updated so responses from the previous session don't persist.
+*/
+func ResetCampaignRSVPs(db *bun.DB, campaignID string) error {
+ _, err := db.NewUpdate().Model((*CampaignPlayer)(nil)).
+ Set("rsvp_status = ?", RSVPPending).
+ Where("campaign_id = ?", campaignID).
+ Exec(context.Background())
+ return err
+}
diff --git a/internal/manager/models/campaign_test.go b/internal/manager/models/campaign_test.go
new file mode 100644
index 0000000..6eeed1f
--- /dev/null
+++ b/internal/manager/models/campaign_test.go
@@ -0,0 +1,106 @@
+package models
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+/*
+Unit Testing: Campaign model methods.
+*/
+
+/*
+PlayerMap returns all campaign players keyed by ID.
+
+When:
+
+ Campaign has three players (two regular, one DM).
+
+Expected:
+
+ Map contains all three, keyed by PlayerID.
+*/
+func TestPlayerMap(t *testing.T) {
+ c := &Campaign{
+ CampaignPlayers: []CampaignPlayer{
+ {PlayerID: "p1", Role: RolePlayer},
+ {PlayerID: "p2", Role: RoleDM},
+ {PlayerID: "p3", Role: RolePlayer},
+ },
+ }
+
+ m := c.PlayerMap()
+ assert.Len(t, m, 3)
+ assert.Equal(t, "p1", m["p1"].PlayerID)
+ assert.Equal(t, "p2", m["p2"].PlayerID)
+ assert.Equal(t, "p3", m["p3"].PlayerID)
+}
+
+/*
+PlayerMap with no players.
+
+When:
+
+ Campaign has no CampaignPlayers loaded.
+
+Expected:
+
+ Returns an empty map.
+*/
+func TestPlayerMap_Empty(t *testing.T) {
+ c := &Campaign{}
+ m := c.PlayerMap()
+ assert.Empty(t, m)
+}
+
+/*
+DMMap returns only DM-role players keyed by ID.
+
+When:
+
+ Campaign has two DMs and two regular players.
+
+Expected:
+
+ Map contains only the two DMs. Regular players are excluded.
+*/
+func TestDMMap(t *testing.T) {
+ c := &Campaign{
+ CampaignPlayers: []CampaignPlayer{
+ {PlayerID: "p1", Role: RolePlayer},
+ {PlayerID: "p2", Role: RoleDM},
+ {PlayerID: "p3", Role: RoleDM},
+ {PlayerID: "p4", Role: RolePlayer},
+ },
+ }
+
+ m := c.DMMap()
+ assert.Len(t, m, 2)
+ assert.Contains(t, m, "p2")
+ assert.Contains(t, m, "p3")
+ assert.NotContains(t, m, "p1")
+ assert.NotContains(t, m, "p4")
+}
+
+/*
+DMMap with no DMs.
+
+When:
+
+ Campaign has only regular players, no DM-role entries.
+
+Expected:
+
+ Returns an empty map.
+*/
+func TestDMMap_NoDMs(t *testing.T) {
+ c := &Campaign{
+ CampaignPlayers: []CampaignPlayer{
+ {PlayerID: "p1", Role: RolePlayer},
+ },
+ }
+
+ m := c.DMMap()
+ assert.Empty(t, m)
+}
diff --git a/internal/manager/models/command.go b/internal/manager/models/command.go
new file mode 100644
index 0000000..6d1b091
--- /dev/null
+++ b/internal/manager/models/command.go
@@ -0,0 +1,13 @@
+package models
+
+import "github.com/uptrace/bun"
+
+// CommandRecord stores metadata about registered slash commands in the database.
+type CommandRecord struct {
+ bun.BaseModel `bun:"table:commands"`
+
+ ID int `bun:",pk,autoincrement"`
+ Name string `bun:",notnull"`
+ Description string
+ TimesUsed int `bun:",notnull,default:0"`
+}
diff --git a/internal/manager/models/media.go b/internal/manager/models/media.go
new file mode 100644
index 0000000..d7c6da4
--- /dev/null
+++ b/internal/manager/models/media.go
@@ -0,0 +1,97 @@
+package models
+
+import (
+ "context"
+ "time"
+
+ "github.com/uptrace/bun"
+)
+
+type MediaKind string
+
+const (
+ KindCoverArt MediaKind = "cover_art"
+ KindTokenPlayer MediaKind = "token_player"
+ KindTokenCampaign MediaKind = "token_campaign"
+ KindVTTLink MediaKind = "vtt_link"
+ KindInternal MediaKind = "internal"
+)
+
+/*
+Media is the single record for any externally-stored asset:
+cover art, player/NPC tokens, VTT links, etc.
+
+Path holds a local disk path (for files) or a raw URL (for links).
+URL holds the public CDN URL served to Discord (empty for non-file assets).
+*/
+type Media struct {
+ bun.BaseModel `bun:"table:media"`
+
+ ID string `bun:",pk,notnull" json:"id"`
+ OwnerID string `bun:",notnull" json:"owner_id"`
+ CampaignID string `bun:",nullzero" json:"campaign_id,omitempty"`
+ Path string `bun:",notnull" json:"path"`
+ URL string `bun:",nullzero" json:"url,omitempty"`
+ Kind MediaKind `bun:",notnull" json:"kind"`
+ Name string `bun:",notnull" json:"name"`
+ MimeType string `bun:",nullzero" json:"mime_type,omitempty"`
+ CreatedAt time.Time `bun:",notnull" json:"created_at"`
+}
+
+func MediaByOwner(db *bun.DB, ownerID string) ([]*Media, error) {
+ var out []*Media
+ err := db.NewSelect().Model(&out).Where("owner_id = ?", ownerID).OrderExpr("created_at DESC").Scan(context.Background())
+ return out, err
+}
+
+func MediaByCampaign(db *bun.DB, campaignID string, kind MediaKind) ([]*Media, error) {
+ var out []*Media
+ err := db.NewSelect().Model(&out).
+ Where("campaign_id = ?", campaignID).
+ Where("kind = ?", kind).
+ OrderExpr("created_at DESC").
+ Scan(context.Background())
+ return out, err
+}
+
+func MediaByKind(db *bun.DB, kind MediaKind) ([]*Media, error) {
+ var out []*Media
+ err := db.NewSelect().Model(&out).Where("kind = ?", kind).OrderExpr("created_at DESC").Scan(context.Background())
+ return out, err
+}
+
+func MediaByPath(db *bun.DB, path string) (*Media, error) {
+ var out Media
+ err := db.NewSelect().Model(&out).Where("path = ?", path).Limit(1).Scan(context.Background())
+ return &out, err
+}
+
+/*
+CoverURLForCampaign returns the public CDN URL for the most-recent cover art
+uploaded for the campaign, or an empty string if none exists.
+*/
+func CoverURLForCampaign(db *bun.DB, campaignID string) string {
+ media, err := MediaByCampaign(db, campaignID, KindCoverArt)
+ if err != nil || len(media) == 0 {
+ return ""
+ }
+ return media[0].URL
+}
+
+/*
+TokenURLForPlayer returns the public CDN URL for the most-recent player token
+stored for the given player, or an empty string if none exists.
+*/
+func TokenURLForPlayer(db *bun.DB, playerID string) string {
+ var out []*Media
+ err := db.NewSelect().Model(&out).
+ Where("owner_id = ?", playerID).
+ Where("kind = ?", KindTokenPlayer).
+ OrderExpr("created_at DESC").
+ Limit(1).
+ Scan(context.Background())
+ if err != nil || len(out) == 0 {
+ return ""
+ }
+ return out[0].URL
+}
diff --git a/internal/manager/models/media_test.go b/internal/manager/models/media_test.go
new file mode 100644
index 0000000..172927e
--- /dev/null
+++ b/internal/manager/models/media_test.go
@@ -0,0 +1,201 @@
+package models_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/uptrace/bun"
+
+ "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 {
+ t.Helper()
+ m := &models.Media{
+ ID: id,
+ OwnerID: ownerID,
+ CampaignID: campaignID,
+ Path: path,
+ Kind: kind,
+ Name: id,
+ CreatedAt: time.Now(),
+ }
+ _, err := db.NewInsert().Model(m).Exec(context.Background())
+ require.NoError(t, err)
+ return m
+}
+
+/*
+MediaByOwner returns all records for a given owner.
+
+When:
+
+ Two records share an owner; a third belongs to someone else.
+
+Expected:
+
+ Only the two matching records are returned.
+*/
+func TestMediaByOwner(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ seedMedia(t, database, "m1", "owner1", "", "/img/a.webp", models.KindCoverArt)
+ seedMedia(t, database, "m2", "owner1", "", "/img/b.webp", models.KindTokenPlayer)
+ seedMedia(t, database, "m3", "owner2", "", "/img/c.webp", models.KindCoverArt)
+
+ results, err := models.MediaByOwner(database, "owner1")
+ require.NoError(t, err)
+ assert.Len(t, results, 2)
+
+ ids := map[string]bool{}
+ for _, r := range results {
+ ids[r.ID] = true
+ }
+ assert.True(t, ids["m1"])
+ assert.True(t, ids["m2"])
+ assert.False(t, ids["m3"])
+}
+
+/*
+MediaByOwner returns empty when owner has no records.
+
+When:
+
+ Database has records belonging to a different owner.
+
+Expected:
+
+ Empty result, no error.
+*/
+func TestMediaByOwner_Empty(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ seedMedia(t, database, "m1", "owner1", "", "/img/a.webp", models.KindCoverArt)
+
+ results, err := models.MediaByOwner(database, "owner2")
+ require.NoError(t, err)
+ assert.Empty(t, results)
+}
+
+/*
+MediaByCampaign returns only records matching both campaign and kind.
+
+When:
+
+ Campaign has one cover art and one player token; another campaign has its own cover art.
+
+Expected:
+
+ Only the cover art for the requested campaign is returned.
+*/
+func TestMediaByCampaign(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ seedMedia(t, database, "m1", "owner1", "camp1", "/img/cover.webp", models.KindCoverArt)
+ seedMedia(t, database, "m2", "owner1", "camp1", "/img/token.webp", models.KindTokenPlayer)
+ seedMedia(t, database, "m3", "owner2", "camp2", "/img/other.webp", models.KindCoverArt)
+
+ results, err := models.MediaByCampaign(database, "camp1", models.KindCoverArt)
+ require.NoError(t, err)
+ assert.Len(t, results, 1)
+ assert.Equal(t, "m1", results[0].ID)
+}
+
+/*
+MediaByCampaign returns empty when no records match kind.
+
+When:
+
+ Campaign has records, but none of the requested kind.
+
+Expected:
+
+ Empty result, no error.
+*/
+func TestMediaByCampaign_WrongKind(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ seedMedia(t, database, "m1", "owner1", "camp1", "/img/token.webp", models.KindTokenPlayer)
+
+ results, err := models.MediaByCampaign(database, "camp1", models.KindCoverArt)
+ require.NoError(t, err)
+ assert.Empty(t, results)
+}
+
+/*
+MediaByKind returns all records of a given kind across owners.
+
+When:
+
+ Two cover art records exist alongside one player token.
+
+Expected:
+
+ Only the two cover art records are returned.
+*/
+func TestMediaByKind(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ seedMedia(t, database, "m1", "owner1", "camp1", "/img/a.webp", models.KindCoverArt)
+ seedMedia(t, database, "m2", "owner2", "camp2", "/img/b.webp", models.KindCoverArt)
+ seedMedia(t, database, "m3", "owner1", "", "/img/tok.webp", models.KindTokenPlayer)
+
+ results, err := models.MediaByKind(database, models.KindCoverArt)
+ require.NoError(t, err)
+ assert.Len(t, results, 2)
+
+ ids := map[string]bool{}
+ for _, r := range results {
+ ids[r.ID] = true
+ }
+ assert.True(t, ids["m1"])
+ assert.True(t, ids["m2"])
+ assert.False(t, ids["m3"])
+}
+
+/*
+MediaByPath returns the record at the given path.
+
+When:
+
+ Two records exist with different paths.
+
+Expected:
+
+ The record matching the path is returned.
+*/
+func TestMediaByPath(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ seedMedia(t, database, "m1", "owner1", "", "/img/cover.webp", models.KindCoverArt)
+ seedMedia(t, database, "m2", "owner1", "", "/img/token.webp", models.KindTokenPlayer)
+
+ result, err := models.MediaByPath(database, "/img/cover.webp")
+ require.NoError(t, err)
+ assert.Equal(t, "m1", result.ID)
+ assert.Equal(t, models.KindCoverArt, result.Kind)
+}
+
+/*
+MediaByPath returns an error for a path that does not exist.
+
+When:
+
+ Database has records, but none match the requested path.
+
+Expected:
+
+ Error is returned.
+*/
+func TestMediaByPath_NotFound(t *testing.T) {
+ database := testutil.NewTestDB(t)
+
+ seedMedia(t, database, "m1", "owner1", "", "/img/cover.webp", models.KindCoverArt)
+
+ _, err := models.MediaByPath(database, "/img/missing.webp")
+ assert.Error(t, err)
+}
diff --git a/internal/manager/models/player.go b/internal/manager/models/player.go
new file mode 100644
index 0000000..fe8cc98
--- /dev/null
+++ b/internal/manager/models/player.go
@@ -0,0 +1,214 @@
+package models
+
+import (
+ "context"
+ "time"
+
+ "github.com/uptrace/bun"
+)
+
+/*
+ServerRole represents a player's guild-wide role (not campaign-specific).
+
+Roles are hierarchical: Admin > Mod > Player.
+Admin implies Mod. Mod can also be a DM of campaigns (roles are additive).
+*/
+type ServerRole string
+
+const (
+ ServerRolePlayer ServerRole = "player"
+ ServerRoleMod ServerRole = "mod"
+ ServerRoleAdmin ServerRole = "admin"
+)
+
+/*
+Weight returns the numeric rank of a ServerRole for hierarchy comparisons.
+
+Higher weight = higher authority. Used by ban protection to ensure
+a user can only ban someone with a strictly lower role.
+*/
+func (r ServerRole) Weight() int {
+ switch r {
+ case ServerRoleAdmin:
+ return 2
+ case ServerRoleMod:
+ return 1
+ default:
+ return 0
+ }
+}
+
+// AuditAction describes what moderation operation was performed.
+type AuditAction string
+
+const (
+ AuditBan AuditAction = "ban"
+ AuditUnban AuditAction = "unban"
+ AuditCampaignBan AuditAction = "campaign_ban"
+ AuditCampaignArchive AuditAction = "campaign_archive"
+ AuditSessionReschedule AuditAction = "session_reschedule"
+)
+
+// AuditEntry is an immutable moderation log record.
+type AuditEntry struct {
+ bun.BaseModel `bun:"table:audit_entries"`
+
+ ID int64 `bun:",pk,autoincrement" json:"id"`
+ PlayerID string `bun:",notnull" json:"player_id"`
+ Player *Player `bun:"rel:belongs-to,join:player_id=id" json:"player,omitempty"`
+ AuthorID string `bun:",notnull" json:"author_id"`
+ Author *Player `bun:"rel:belongs-to,join:author_id=id" json:"author,omitempty"`
+ Action AuditAction `bun:",notnull" json:"action"`
+ Reason string `bun:",nullzero" json:"reason,omitempty"`
+ CreatedAt time.Time `bun:",notnull,default:current_timestamp" json:"created_at"`
+}
+
+// InsertAuditEntry writes an immutable moderation log record.
+func InsertAuditEntry(db *bun.DB, playerID, authorID string, action AuditAction, reason string) error {
+ ctx := context.Background()
+ entry := &AuditEntry{
+ PlayerID: playerID,
+ AuthorID: authorID,
+ Action: action,
+ Reason: reason,
+ CreatedAt: time.Now(),
+ }
+ _, err := db.NewInsert().Model(entry).Exec(ctx)
+ return err
+}
+
+/*
+GetPlayerWithCampaigns loads a Player by ID with its CampaignPlayers relation.
+
+This is required for Player methods that inspect campaign memberships
+(IsDMOf, IsMemberOf, IsBannedFromCampaign, etc.).
+*/
+func GetPlayerWithCampaigns(db *bun.DB, playerID string) (*Player, error) {
+ ctx := context.Background()
+ var player Player
+ err := db.NewSelect().
+ Model(&player).
+ Relation("CampaignPlayers").
+ Where("player.id = ?", playerID).
+ Scan(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return &player, nil
+}
+
+/*
+Player represents a Discord user participating in campaigns.
+Player is the single owner of all role and permission data.
+
+DM status is campaign-scoped via CampaignPlayer, while Mod/Admin are server-wide via Role.
+*/
+type Player struct {
+ bun.BaseModel `bun:"table:players"`
+
+ ID string `bun:",pk,notnull" json:"id"` // Discord user ID, used as primary key.
+ Role ServerRole `bun:",notnull,default:'player'" json:"role"` // Guild-wide role, synced from Discord. Determines mod/admin permissions.
+ PlayerIsBanned bool `bun:"column:is_banned,notnull,default:false" json:"is_banned"` // Global ban. A globally banned player cannot interact with the bot at all.
+ PlayerBanReason string `bun:"column:ban_reason,nullzero" json:"ban_reason,omitempty"`
+ Media []Media `bun:"rel:has-many,join:id=owner_id" json:"media,omitempty"`
+ CampaignPlayers []CampaignPlayer `bun:"rel:has-many,join:id=player_id" json:"campaign_players,omitempty"`
+ ModerationLog []AuditEntry `bun:"rel:has-many,join:id=player_id" json:"moderation_log,omitempty"`
+}
+
+// IsAdmin returns true if the player has the admin server role.
+func (p *Player) IsAdmin() bool {
+ return p.Role == ServerRoleAdmin
+}
+
+// IsMod returns true if the player is a mod or admin (admin is a superset of mod).
+func (p *Player) IsMod() bool {
+ return p.Role == ServerRoleMod || p.Role == ServerRoleAdmin
+}
+
+/*
+IsDMOf returns true if the player is the DM of the given campaign.
+Requires CampaignPlayers to be loaded (via Relation).
+*/
+func (p *Player) IsDMOf(campaignID string) bool {
+ for _, cp := range p.CampaignPlayers {
+ if cp.CampaignID == campaignID && cp.Role == RoleDM {
+ return true
+ }
+ }
+ return false
+}
+
+/*
+IsMemberOf returns true if the player is an active member of the given campaign.
+Requires CampaignPlayers to be loaded (via Relation).
+*/
+func (p *Player) IsMemberOf(campaignID string) bool {
+ for _, cp := range p.CampaignPlayers {
+ if cp.CampaignID == campaignID && cp.Status == StatusActive {
+ return true
+ }
+ }
+ return false
+}
+
+/*
+DMCampaignIDs returns the IDs of all campaigns this player DMs.
+Requires CampaignPlayers to be loaded (via Relation).
+*/
+func (p *Player) DMCampaignIDs() []string {
+ var ids []string
+ for _, cp := range p.CampaignPlayers {
+ if cp.Role == RoleDM {
+ ids = append(ids, cp.CampaignID)
+ }
+ }
+ return ids
+}
+
+/*
+IsBannedFrom returns the CampaignPlayer relationships on which the Player is banned from.
+Requires CampaignPlayers to be loaded (via Relation).
+*/
+func (p *Player) IsBannedFrom() []CampaignPlayer {
+ var bannedFromCampaigns []CampaignPlayer
+
+ for _, cp := range p.CampaignPlayers {
+ if cp.PlayerID == p.ID && cp.BannedFromCampaign {
+ bannedFromCampaigns = append(bannedFromCampaigns, cp)
+ }
+ }
+
+ return bannedFromCampaigns
+}
+
+/*
+BulkUpsertPlayers creates Player rows for each Discord user ID that doesn't already have one.
+Existing rows are left unchanged: ON CONFLICT DO NOTHING preserves role and ban state.
+*/
+func BulkUpsertPlayers(db *bun.DB, playerIDs []string) error {
+ if len(playerIDs) == 0 {
+ return nil
+ }
+ ctx := context.Background()
+ players := make([]Player, 0, len(playerIDs))
+ for _, id := range playerIDs {
+ players = append(players, Player{ID: id, Role: ServerRolePlayer})
+ }
+ _, err := db.NewInsert().Model(&players).
+ On("CONFLICT (id) DO NOTHING").
+ Exec(ctx)
+ return err
+}
+
+/*
+IsBannedFromCampaign returns true if the player has a campaign-scoped ban on the given campaign.
+Requires CampaignPlayers to be loaded (via Relation).
+*/
+func (p *Player) IsBannedFromCampaign(campaignID string) bool {
+ for _, cp := range p.CampaignPlayers {
+ if cp.CampaignID == campaignID && cp.BannedFromCampaign {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/manager/models/player_settings.go b/internal/manager/models/player_settings.go
new file mode 100644
index 0000000..12f4c0c
--- /dev/null
+++ b/internal/manager/models/player_settings.go
@@ -0,0 +1,59 @@
+package models
+
+import (
+ "context"
+ "time"
+
+ "github.com/uptrace/bun"
+)
+
+/*
+PlayerSettings holds per-player preferences, primarily notification toggles.
+
+One row per Player, keyed by PlayerID. Defaults are all-on so players get
+everything until they opt out.
+*/
+type PlayerSettings struct {
+ bun.BaseModel `bun:"table:player_settings"`
+
+ PlayerID string `bun:",pk,notnull" json:"player_id"`
+ Player *Player `bun:"rel:belongs-to,join:player_id=id" json:"player,omitempty"`
+ NotifyAnnouncements bool `bun:",notnull,default:true" json:"notify_announcements"`
+ NotifySessionRemind bool `bun:",notnull,default:true" json:"notify_session_remind"`
+ NotifyInvitations bool `bun:",notnull,default:true" json:"notify_invitations"`
+ Timezone string `bun:",notnull,default:'UTC'" json:"timezone"`
+}
+
+// Location returns the player's preferred *time.Location, falling back to UTC on any error.
+func (s *PlayerSettings) Location() *time.Location {
+ if s.Timezone == "" || s.Timezone == "UTC" {
+ return time.UTC
+ }
+ loc, err := time.LoadLocation(s.Timezone)
+ if err != nil {
+ return time.UTC
+ }
+ return loc
+}
+
+// GetOrCreatePlayerSettings loads settings for a player, creating defaults if none exist.
+func GetOrCreatePlayerSettings(db *bun.DB, playerID string) (*PlayerSettings, error) {
+ ctx := context.Background()
+ var s PlayerSettings
+ err := db.NewSelect().Model(&s).Where("player_id = ?", playerID).Scan(ctx)
+ if err == nil {
+ return &s, nil
+ }
+
+ s = PlayerSettings{
+ PlayerID: playerID,
+ NotifyAnnouncements: true,
+ NotifySessionRemind: true,
+ NotifyInvitations: true,
+ Timezone: "UTC",
+ }
+ if _, err := db.NewInsert().Model(&s).Exec(ctx); err != nil {
+ return nil, err
+ }
+ return &s, nil
+}
diff --git a/internal/manager/models/player_test.go b/internal/manager/models/player_test.go
new file mode 100644
index 0000000..84c1bf7
--- /dev/null
+++ b/internal/manager/models/player_test.go
@@ -0,0 +1,153 @@
+package models
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+/*
+Unit Testing: Player model methods.
+*/
+
+/*
+IsAdmin truth table.
+
+When:
+
+ Player role is Player, Mod, or Admin.
+
+Expected:
+
+ Only Admin returns true.
+*/
+func TestIsAdmin(t *testing.T) {
+ tests := []struct {
+ role ServerRole
+ want bool
+ }{
+ {ServerRolePlayer, false},
+ {ServerRoleMod, false},
+ {ServerRoleAdmin, true},
+ }
+ for _, tt := range tests {
+ p := &Player{Role: tt.role}
+ assert.Equal(t, tt.want, p.IsAdmin(), "IsAdmin() for role %q", tt.role)
+ }
+}
+
+/*
+IsMod truth table.
+
+When:
+
+ Player role is Player, Mod, or Admin.
+
+Expected:
+
+ Mod and Admin return true. Admin implies mod.
+*/
+func TestIsMod(t *testing.T) {
+ tests := []struct {
+ role ServerRole
+ want bool
+ }{
+ {ServerRolePlayer, false},
+ {ServerRoleMod, true},
+ {ServerRoleAdmin, true},
+ }
+ for _, tt := range tests {
+ p := &Player{Role: tt.role}
+ assert.Equal(t, tt.want, p.IsMod(), "IsMod() for role %q", tt.role)
+ }
+}
+
+/*
+IsDMOf checks campaign-scoped DM ownership.
+
+When:
+
+ Player is DM of camp-a, regular player in camp-b.
+
+Expected:
+
+ True for camp-a, false for camp-b, false for camp-c (not in campaign at all).
+*/
+func TestIsDMOf(t *testing.T) {
+ p := &Player{
+ CampaignPlayers: []CampaignPlayer{
+ {CampaignID: "camp-a", Role: RoleDM},
+ {CampaignID: "camp-b", Role: RolePlayer},
+ },
+ }
+
+ assert.True(t, p.IsDMOf("camp-a"), "should be DM of camp-a")
+ assert.False(t, p.IsDMOf("camp-b"), "player role in camp-b, not DM")
+ assert.False(t, p.IsDMOf("camp-c"), "not in camp-c at all")
+}
+
+/*
+IsMemberOf checks active campaign membership.
+
+When:
+
+ Player is active in camp-a, on hiatus in camp-b, banned in camp-c.
+
+Expected:
+
+ Only active status returns true. Hiatus, banned, and absent all return false.
+*/
+func TestIsMemberOf(t *testing.T) {
+ p := &Player{
+ CampaignPlayers: []CampaignPlayer{
+ {CampaignID: "camp-a", Status: StatusActive},
+ {CampaignID: "camp-b", Status: StatusHiatus},
+ {CampaignID: "camp-c", Status: StatusBanned},
+ },
+ }
+
+ assert.True(t, p.IsMemberOf("camp-a"), "active member")
+ assert.False(t, p.IsMemberOf("camp-b"), "hiatus is not active")
+ assert.False(t, p.IsMemberOf("camp-c"), "banned is not active")
+ assert.False(t, p.IsMemberOf("camp-d"), "not in campaign")
+}
+
+/*
+DMCampaignIDs returns IDs of all campaigns the player DMs.
+
+When:
+
+ Player is DM of camp-a and camp-c, regular player in camp-b.
+
+Expected:
+
+ Returns ["camp-a", "camp-c"]. Order does not matter.
+*/
+func TestDMCampaignIDs(t *testing.T) {
+ p := &Player{
+ CampaignPlayers: []CampaignPlayer{
+ {CampaignID: "camp-a", Role: RoleDM},
+ {CampaignID: "camp-b", Role: RolePlayer},
+ {CampaignID: "camp-c", Role: RoleDM},
+ },
+ }
+
+ ids := p.DMCampaignIDs()
+ assert.ElementsMatch(t, []string{"camp-a", "camp-c"}, ids)
+}
+
+/*
+DMCampaignIDs with no campaigns.
+
+When:
+
+ Player has no CampaignPlayers loaded.
+
+Expected:
+
+ Returns nil.
+*/
+func TestDMCampaignIDs_Empty(t *testing.T) {
+ p := &Player{}
+ assert.Nil(t, p.DMCampaignIDs(), "no campaigns β nil")
+}
diff --git a/internal/manager/models/session.go b/internal/manager/models/session.go
new file mode 100644
index 0000000..4b59dc0
--- /dev/null
+++ b/internal/manager/models/session.go
@@ -0,0 +1,158 @@
+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).
+ Relation("Campaign").
+ Join("JOIN campaign_players AS cp ON cp.campaign_id = session.campaign_id").
+ Join("JOIN campaigns AS cam ON cam.id = session.campaign_id").
+ Where("cp.player_id = ? AND cp.status = ?", playerID, StatusActive).
+ Where("cam.is_approved = ?", true).
+ 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
new file mode 100644
index 0000000..5d2f80c
--- /dev/null
+++ b/internal/mediaserver/server.go
@@ -0,0 +1,79 @@
+package mediaserver
+
+import (
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+/*
+Serve starts a read-only HTTP file server rooted at dataDir, listening on addr.
+Files are accessible under the /api/v1/cdn/ prefix:
+
+ GET /api/v1/cdn/[guild_id]/assets/campaigns/[campaign_id]/cover/[file]
+
+This server runs in a background goroutine; does not block the Dispatcher or other goroutines.
+*/
+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/ /dl/", addr)
+ if err := http.ListenAndServe(addr, mux); err != nil {
+ log.Fatalf("mediaserver: %v", err)
+ }
+ }()
+}
+
+/*
+filesOnlyHandler serves files under dataDir but 404s any directory request,
+so the FileServer never auto-indexes and discloses filenames.
+*/
+func filesOnlyHandler(dataDir string) http.Handler {
+ fs := http.FileServer(http.Dir(dataDir))
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "/") {
+ http.NotFound(w, r)
+ return
+ }
+ full := filepath.Join(dataDir, filepath.FromSlash(r.URL.Path))
+ info, err := os.Stat(full)
+ if err != nil || info.IsDir() {
+ http.NotFound(w, r)
+ return
+ }
+ fs.ServeHTTP(w, r)
+ })
+}
+
+/*
+Probe checks that the local CDN endpoint is reachable after Serve() is called.
+Retries up to 5 times with 100ms gaps to allow the goroutine to bind.
+Logs the result.
+*/
+func Probe(addr string) {
+ url := fmt.Sprintf("http://localhost%s/api/v1/cdn/", addr)
+ client := &http.Client{Timeout: 2 * time.Second}
+
+ var lastErr error
+ for range 5 {
+ time.Sleep(100 * time.Millisecond)
+ resp, err := client.Get(url)
+ if err != nil {
+ lastErr = err
+ continue
+ }
+ resp.Body.Close()
+ log.Printf("mediaserver: reachable (status %d)", resp.StatusCode)
+ return
+ }
+ log.Printf("mediaserver: probe failed β CDN may not be reachable: %v", lastErr)
+}
diff --git a/internal/mediaserver/server_test.go b/internal/mediaserver/server_test.go
new file mode 100644
index 0000000..6fba2a9
--- /dev/null
+++ b/internal/mediaserver/server_test.go
@@ -0,0 +1,196 @@
+package mediaserver
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+/*
+HTTP path-traversal coverage for the /api/v1/cdn/ file server.
+
+The production handler in Serve() is:
+
+ mux.Handle("/api/v1/cdn/", http.StripPrefix("/api/v1/cdn/", http.FileServer(http.Dir(dataDir))))
+
+These tests reconstruct the same mux against a temp dataDir laid out as:
+
+ /
+ secret.txt <-- MUST NEVER be served (sibling of dataDir)
+ data/
+ guild-1/
+ assets/campaigns/c1/cover/legit.png <-- legitimately served
+
+If any traversal payload returns the secret, the FileServer is leaking data
+above its root.
+*/
+
+const secretBody = "TOP_SECRET_DO_NOT_LEAK"
+
+func newCDNTestServer(t *testing.T) (*httptest.Server, string) {
+ t.Helper()
+
+ root := t.TempDir()
+ dataDir := filepath.Join(root, "data")
+
+ require.NoError(t, os.WriteFile(filepath.Join(root, "secret.txt"), []byte(secretBody), 0o600))
+
+ legit := filepath.Join(dataDir, "guild-1", "assets", "campaigns", "c1", "cover")
+ require.NoError(t, os.MkdirAll(legit, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(legit, "legit.png"), []byte("png"), 0o644))
+
+ mux := http.NewServeMux()
+ mux.Handle("/api/v1/cdn/", http.StripPrefix("/api/v1/cdn/", filesOnlyHandler(dataDir)))
+
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ return srv, dataDir
+}
+
+func get(t *testing.T, base, path string) (int, string) {
+ t.Helper()
+ // NOTE: Build raw request URL to bypass Go client URL cleaning where possible.
+ req, err := http.NewRequest(http.MethodGet, base+path, nil)
+ require.NoError(t, err)
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ return resp.StatusCode, string(body)
+}
+
+/*
+The legitimate happy path serves files from inside dataDir.
+
+When:
+
+ A well-formed CDN URL points to an existing file under dataDir.
+
+Expected:
+
+ 200 OK, body matches the file on disk.
+*/
+func TestCDN_LegitimatePath(t *testing.T) {
+ srv, _ := newCDNTestServer(t)
+
+ status, body := get(t, srv.URL, "/api/v1/cdn/guild-1/assets/campaigns/c1/cover/legit.png")
+ assert.Equal(t, http.StatusOK, status)
+ assert.Equal(t, "png", body)
+}
+
+/*
+Dot-dot traversal must never escape dataDir.
+
+When:
+
+ A request uses ../ segments to climb above dataDir and target the sibling
+ "secret.txt" file.
+
+Expected:
+
+ The secret body is never returned. ServeMux's CleanPath redirects /../
+ traversal back inside the root before FileServer ever sees it.
+*/
+func TestCDN_NoTraversalAboveRoot(t *testing.T) {
+ srv, _ := newCDNTestServer(t)
+
+ payloads := []string{
+ "/api/v1/cdn/../secret.txt",
+ "/api/v1/cdn/guild-1/../../secret.txt",
+ "/api/v1/cdn/./../secret.txt",
+ }
+ for _, p := range payloads {
+ t.Run(p, func(t *testing.T) {
+ _, body := get(t, srv.URL, p)
+ assert.NotContains(t, body, secretBody, "traversal %q leaked secret file", p)
+ })
+ }
+}
+
+/*
+URL-encoded traversal must also not escape.
+
+When:
+
+ The ../ segments are percent-encoded so they survive ServeMux cleaning.
+
+Expected:
+
+ No secret leakage. http.FileServer rejects encoded slashes via
+ http.ServeFile's containsDotDot check after URL decoding.
+*/
+func TestCDN_NoTraversalEncoded(t *testing.T) {
+ srv, _ := newCDNTestServer(t)
+
+ payloads := []string{
+ "/api/v1/cdn/%2e%2e/secret.txt",
+ "/api/v1/cdn/guild-1/%2e%2e/%2e%2e/secret.txt",
+ "/api/v1/cdn/..%2fsecret.txt",
+ }
+ for _, p := range payloads {
+ t.Run(p, func(t *testing.T) {
+ _, body := get(t, srv.URL, p)
+ assert.NotContains(t, body, secretBody, "encoded traversal %q leaked secret file", p)
+ })
+ }
+}
+
+/*
+Backslash separators are not a path-component separator on Linux and must not
+be interpreted as one by the FileServer.
+
+When:
+
+ A Windows-style path with backslashes is requested.
+
+Expected:
+
+ No secret leakage. On Linux the backslash is just a filename character and
+ the lookup misses; on Windows http.Dir rejects backslashes.
+*/
+func TestCDN_NoBackslashTraversal(t *testing.T) {
+ srv, _ := newCDNTestServer(t)
+
+ _, body := get(t, srv.URL, "/api/v1/cdn/..\\secret.txt")
+ assert.NotContains(t, body, secretBody)
+}
+
+/*
+Directory listings must not be served.
+
+When:
+
+ A request targets a directory rather than a file.
+
+Expected:
+
+ No HTML index of cover/ contents. http.FileServer auto-indexes by default
+ unless the directory contains an index.html β this test pins current
+ behavior so a future change that disables it (or adds index.html guarding)
+ is intentional and reviewed.
+
+Note:
+
+ This test currently passes because FileServer DOES auto-index directories,
+ exposing filenames. If you consider that a leak (it discloses cover-art
+ UUIDs and token paths), flip the assertion and add a NotFoundHandler wrapper
+ in Serve() to reject directory requests. Marked t.Log so reviewers see it.
+*/
+func TestCDN_DirectoryListingBehavior(t *testing.T) {
+ srv, _ := newCDNTestServer(t)
+
+ status, body := get(t, srv.URL, "/api/v1/cdn/guild-1/assets/campaigns/c1/cover/")
+ t.Logf("directory request returned status=%d body-contains-listing=%v",
+ status, strings.Contains(body, "legit.png"))
+
+ // NOTE: Directory listings disclose filenames. A hardened server should 404.
+ assert.NotContains(t, body, "legit.png",
+ "directory listing exposes filenames β wrap FileServer to reject dir requests")
+}
diff --git a/internal/mediaserver/storage.go b/internal/mediaserver/storage.go
new file mode 100644
index 0000000..0770b2e
--- /dev/null
+++ b/internal/mediaserver/storage.go
@@ -0,0 +1,97 @@
+package mediaserver
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+/*
+MaxDownloadBytes caps remote response bodies (16 MiB, double the upload limit
+to absorb encoding overhead).
+
+Anything larger is treated as a malicious or misconfigured upstream and rejected.
+*/
+const MaxDownloadBytes = 16 * 1024 * 1024
+
+/*
+CoverPath returns the disk path and URL path for a campaign cover asset.
+
+ disk: data/[guildID]/assets/campaigns/[campaignID]/cover/[uuid][ext]
+ url: [baseURL]/[guildID]/assets/campaigns/[campaignID]/cover/[uuid][ext]
+*/
+func CoverPath(dataDir, baseURL, guildID, campaignID, ext string) (diskPath, publicURL string) {
+ filename := uuid.NewString() + ext
+ rel := filepath.Join(guildID, "assets", "campaigns", campaignID, "cover", filename)
+ diskPath = filepath.Join(dataDir, rel)
+ publicURL = fmt.Sprintf("%s/%s/assets/campaigns/%s/cover/%s", baseURL, guildID, campaignID, filename)
+ return
+}
+
+/*
+Download fetches srcURL and writes it atomically to diskPath.
+Creates any missing parent directories. Returns the detected MIME type.
+*/
+func Download(srcURL, diskPath string) (mimeType string, err error) {
+ if err = os.MkdirAll(filepath.Dir(diskPath), 0755); err != nil {
+ return
+ }
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Get(srcURL)
+ if err != nil {
+ return
+ }
+ defer resp.Body.Close()
+
+ // NOTE: Cap reads at MaxDownloadBytes+1 so an oversize body is detectable.
+ limited := io.LimitReader(resp.Body, MaxDownloadBytes+1)
+
+ // NOTE: Read first 512 bytes to detect MIME, then prepend them back.
+ buf := make([]byte, 512)
+ n, err := io.ReadFull(limited, buf)
+ if err != nil && err != io.ErrUnexpectedEOF {
+ return
+ }
+ mimeType = http.DetectContentType(buf[:n])
+ if !strings.HasPrefix(mimeType, "image/") {
+ err = fmt.Errorf("download: rejected non-image content-type %q", mimeType)
+ return
+ }
+
+ tmp, err := os.CreateTemp(filepath.Dir(diskPath), ".tmp-*")
+ if err != nil {
+ return
+ }
+ tmpName := tmp.Name()
+ defer func() {
+ tmp.Close()
+ if err != nil {
+ os.Remove(tmpName)
+ }
+ }()
+
+ if _, err = tmp.Write(buf[:n]); err != nil {
+ return
+ }
+ var copied int64
+ if copied, err = io.Copy(tmp, limited); err != nil {
+ return
+ }
+ if int64(n)+copied > MaxDownloadBytes {
+ err = fmt.Errorf("download: response exceeded %d bytes", MaxDownloadBytes)
+ return
+ }
+ if err = tmp.Close(); err != nil {
+ return
+ }
+
+ err = os.Rename(tmpName, diskPath)
+ return
+}
diff --git a/internal/mediaserver/storage_test.go b/internal/mediaserver/storage_test.go
new file mode 100644
index 0000000..02aa923
--- /dev/null
+++ b/internal/mediaserver/storage_test.go
@@ -0,0 +1,169 @@
+package mediaserver
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+/*
+Coverage for the Download() helper.
+
+Download() is invoked from /uploadtoken and /uploadcover with a Discord CDN
+URL the user supplied via attachment. Discord vouches for those URLs, BUT:
+
+ - Discord could serve a redirect to anywhere (Download follows redirects).
+ - Discord CDN content is whatever the user uploaded β Discord does not
+ validate that an attachment with ContentType "image/png" is actually a
+ PNG. The bot must validate the magic bytes itself.
+ - A pathological response (huge body, slow drip) can exhaust disk and
+ starve the 30s timeout.
+
+These tests document the current behavior so a fix PR can flip them green.
+*/
+
+/*
+Download writes the response body to disk.
+
+When:
+
+ A trivial server returns a small image-like body.
+
+Expected:
+
+ The file lands at diskPath, the mimeType is detected, no error.
+*/
+func TestDownload_HappyPath(t *testing.T) {
+ body := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("x", 64))
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write(body)
+ }))
+ t.Cleanup(srv.Close)
+
+ dst := filepath.Join(t.TempDir(), "out.png")
+ mime, err := Download(srv.URL+"/x", dst)
+ require.NoError(t, err)
+ assert.Equal(t, "image/png", mime)
+
+ got, err := os.ReadFile(dst)
+ require.NoError(t, err)
+ assert.Equal(t, body, got)
+}
+
+/*
+Download must reject non-image content.
+
+When:
+
+ The remote server returns a text/html or application/octet-stream body
+ (e.g. a redirected phishing page or an arbitrary binary).
+
+Expected:
+
+ No file is written and an error is returned.
+
+Development Note (v0.12.6, 20260511):
+
+ Currently FAILS:
+ Download detects the MIME but never rejects on it, so the bot would persist HTML
+ or arbitrary binaries as if they were images and serve them back via
+ the CDN.
+*/
+func TestDownload_RejectsNonImageMIME(t *testing.T) {
+ htmlBody := []byte("" + strings.Repeat(" ", 600))
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ w.Write(htmlBody)
+ }))
+ t.Cleanup(srv.Close)
+
+ dst := filepath.Join(t.TempDir(), "evil.png")
+ _, err := Download(srv.URL+"/x", dst)
+
+ assert.Error(t, err, "Download must reject non-image MIME types")
+ _, statErr := os.Stat(dst)
+ assert.True(t, os.IsNotExist(statErr),
+ "non-image content was persisted to disk at %s β Download lacks MIME enforcement", dst)
+}
+
+/*
+Download must cap response size.
+
+When:
+
+ A malicious or misconfigured server streams a huge body.
+
+Expected:
+
+ Download stops at a sensible cap (e.g. 16 MiB matching the upload limit)
+ rather than filling the disk.
+ This test uses a 64 KiB cap target via a 100 KiB response to keep the
+ test fast; flip the cap when adding the real guard.
+
+Development Note (v0.12.6, 20260511):
+
+ Currently FAILS β io.Copy is unbounded.
+*/
+func TestDownload_BoundsResponseSize(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "image/png")
+ // PNG magic prefix so MIME detection passes.
+ w.Write([]byte("\x89PNG\r\n\x1a\n"))
+ w.Write(make([]byte, MaxDownloadBytes+1024))
+ }))
+ t.Cleanup(srv.Close)
+
+ dst := filepath.Join(t.TempDir(), "big.png")
+ _, err := Download(srv.URL+"/x", dst)
+
+ assert.Error(t, err, "Download must reject responses larger than MaxDownloadBytes")
+ _, statErr := os.Stat(dst)
+ assert.True(t, os.IsNotExist(statErr),
+ "oversize body was persisted to disk at %s", dst)
+}
+
+/*
+TokenPath must not allow callers to escape the player's token directory.
+
+When:
+
+ A caller (today: only trusted internal code, but future: any handler
+ pulling suffix from user input) passes a suffix containing a slash or
+ traversal sequence.
+
+Expected:
+
+ The returned disk path stays inside dataDir//tokens//.
+
+Development Note (v0.12.6, 20260511):
+
+ Currently FAILS:
+ TokenPath uses filepath.Join with no validation, so
+ "../../etc/passwd" as suffix climbs out of the player directory.
+*/
+func TestTokenPath_RejectsTraversalSuffix(t *testing.T) {
+ dataDir := "/var/data"
+ expectedPrefix := filepath.Join(dataDir, "guild-1", "tokens", "player-1") + string(filepath.Separator)
+
+ cases := []struct {
+ name, suffix, ext string
+ }{
+ {"dotdot suffix", "../../etc/passwd", ""},
+ {"slash suffix", "src/../../../escape", ".png"},
+ {"nul-ish ext", "out", "/../../escape"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ disk, _ := TokenPath(dataDir, "http://cdn", "guild-1", "player-1", c.suffix, c.ext)
+ assert.True(t, strings.HasPrefix(disk, expectedPrefix),
+ "suffix %q ext %q escaped the player dir: got %q (want prefix %q)",
+ c.suffix, c.ext, disk, expectedPrefix)
+ })
+ }
+}
diff --git a/internal/mediaserver/token.go b/internal/mediaserver/token.go
new file mode 100644
index 0000000..93d8f62
--- /dev/null
+++ b/internal/mediaserver/token.go
@@ -0,0 +1,44 @@
+package mediaserver
+
+import (
+ "image/color"
+ "os"
+ "path/filepath"
+
+ "github.com/framebuffers/moontracer/internal/tokengenerator"
+)
+
+/*
+TokenPath generates disk and public URL paths for a player token file.
+
+suffix distinguishes src/frm/out variants (e.g. "source", "frame", "out").
+*/
+func TokenPath(dataDir, baseURL, guildID, playerID, suffix, ext string) (disk, url string) {
+ /*
+ filepath.Base collapses any traversal in suffix/ext to a single filename
+ component, keeping the result inside the player's token directory.
+ */
+ filename := filepath.Base(suffix + ext)
+ rel := filepath.Join(guildID, "tokens", playerID, filename)
+ disk = filepath.Join(dataDir, rel)
+ url = baseURL + "/" + filepath.ToSlash(rel)
+ return
+}
+
+// ProcessToken composites a source photo with a frame image and writes the result to outPath.
+func ProcessToken(srcPath, frmPath, outPath string) error {
+ if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
+ return err
+ }
+ _, err := tokengenerator.New(srcPath, frmPath, outPath)
+ return err
+}
+
+// ProcessBasicToken composites a source photo with a solid-color gradient ring and writes the result to outPath.
+func ProcessBasicToken(srcPath, outPath string, frameColor color.RGBA) error {
+ if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
+ return err
+ }
+ _, err := tokengenerator.NewBasicToken(srcPath, frameColor, 32, outPath)
+ return err
+}
diff --git a/internal/messages/messages.go b/internal/messages/messages.go
new file mode 100644
index 0000000..94976af
--- /dev/null
+++ b/internal/messages/messages.go
@@ -0,0 +1,1110 @@
+package messages
+
+/*
+
+ Messages:
+ Every single string inside Moontracer is described here.
+ This is such that there is a single source of truth for all string values inside the Bot.
+ If the dev wants to change any string, they can change it here.
+
+ Organization:
+ Within each scope, constants are split into two groups:
+ - identifiers: command names, custom IDs, field IDs, component prefixes, internal log
+ prefixes. These are interface contracts or internal keys. Do not translate these.
+ - user-facing: messages, labels, descriptions, placeholders, and success/error copy shown
+ to users. These can be translated.
+
+*/
+
+// Generic
+const (
+ // identifiers
+ BotVersion = "v1.0.0"
+ // 2026-05-17: took a long time to get here, but here we are. v1.0. time to roll initiative for the first release, i guess
+)
+const (
+ // user-facing
+ GenericErrorMessage = "π« Something went wrong."
+ InvalidButtonDataMessage = "β οΈ Invalid button data."
+)
+
+/*
+Date/time formats.
+
+There are two different kinds of formats: input and display.
+
+Input formats are interface contracts: they describe what DMs type into
+modals and aren't locale-dependent.
+
+Display formats render human-readable strings and could be localized:
+to translate them, swap these per locale to get day/month names
+in the user's language.
+
+NOTE:
+
+ Go's time package uses the reference time `Mon Jan 2 15:04:05 MST 2006` to
+ define layouts; see https://pkg.go.dev/time#Layout.
+*/
+const (
+ // input format contracts. changing these breaks modal parsing
+ DateInputFormat = "02/01/2006" // DD/MM/YYYY
+ TimeInputFormat = "15:04" // HH:MM (24h)
+ DateTimeInputFormat = "02/01/2006 15:04" // combined for ParseInLocation
+)
+const (
+ // user-facing display formats. swap per locale for localized day/month names
+ SessionTimeFormat = "Mon 2 Jan 2006 15:04" // long form, includes year
+ SessionListFormat = "Mon 2 Jan 15:04" // compact, year implied
+)
+
+// Command names and descriptions
+const (
+ // identifiers
+ PingCommandName = "ping"
+ AwooCommandName = "awoo"
+ RegisterCommandName = "register"
+ CampaignCommandName = "campaign"
+ MyCampaignsCommandName = "mycampaigns"
+ AddPlayerCommandName = "add_player"
+ TagCommandName = "tag"
+)
+const (
+ // user-facing
+ PingCommandDesc = "Replies with pong!"
+ AwooCommandDesc = "do a heccin awoo."
+ RegisterCommandDesc = "Register as a player so you can join and create campaigns."
+ CampaignCommandDesc = "Show campaign details."
+ MyCampaignsCommandDesc = "List the campaigns you're part of."
+ AddPlayerCommandDesc = "Adds a new player to a Campaign."
+ TagCommandDesc = "Campaign tag to look up."
+)
+
+// Registration
+const (
+ // internal log prefixes
+ RegistrationCheckError = "register: error checking registration: "
+ RegistrationInsertError = "register: error inserting player: "
+ QuickRegisterPrefix = "quick_register"
+)
+const (
+ // user-facing
+ RegisterButtonLabel = "Register"
+ NotRegisteredMessage = "β οΈ You need to register first."
+ AlreadyRegisteredMessage = "βΉοΈ You are already registered!"
+ RegistrationFailureMessage = "π« Failed to register. Please try again later."
+ RegistrationSuccessMessage = "πΊ Welcome, <@%s>! You are now registered."
+)
+
+// Campaign lookup
+const (
+ // internal log prefixes
+ CampaignFetchError = "campaign: error fetching campaign %s: "
+ PlayerFetchErrorMessage = "models.GetCampaignPlayers(): Error fetching players: "
+)
+const (
+ // user-facing
+ CampaignNotFoundMessage = "βΉοΈ Campaign not found."
+ CampaignArchivedFooter = "βΉοΈ This campaign is archived. It is read-only now."
+ CampaignLoadFailureErrorMessage = "π« Failed to load campaign."
+ CampaignPlayersLoadError = "π« Failed to load campaign players."
+)
+
+// Campaign creation
+const (
+ // internal log prefix
+ CampaignCreationFailureErrorMessage = "campaign.CreateCampaign(): error creating campaign: "
+)
+const (
+ // user-facing
+ SlotCountMismatchErrorMessage = "π« Invalid slot count. Capacity must be a positive number. Leave the field empty for unlimited."
+ CampaignAndRegistrationFailureErrorMessage = "π« Failed to create campaign. Make sure you are registered."
+ CampaignCreationMessage = "β
You just created a new campaign: "
+ CampaignStaffNotifyFailureMessage = "β οΈ Could not notify staff members to ask for approval of this Campaign."
+ CampaignApprovalRequestMessage = "βΉοΈ New campaign **%s** by <@%s> needs approval."
+)
+
+// Campaign join
+const (
+ // internal log prefix
+ InsertPlayerErrorMessage = "db.Insert(): Error inserting Campaign Player: "
+)
+const (
+ // user-facing
+ CampaignClosedMessage = "β οΈ This campaign is not open for new players."
+ PlayerBannedMessage = "β You are banned from this campaign."
+ PlayerAlreadyOnCampaignMessage = "βΉοΈ You are already in this campaign."
+ CampaignFullMessage = "β οΈ This campaign is full."
+ PlayerFailedToJoinMessage = "π« Failed to join campaign."
+ PlayerJoinedCampaignMessage = "πΊ You have joined **%s**!"
+
+ // Westmarch session-capacity tripwire (FCFS soft alert).
+ WestmarchOverCapacityDMAlert = "β οΈ **INFO:** <@%s> just joined westmarch **%s**. Roster is now %d active player(s). Session capacity is %d. They've been admitted; bring an extra seat or trim attendance for the next session."
+ WestmarchOverCapacityPlayerNotice = "πΊ You're in **%s**! \nβ οΈ Warning!: this westmarch's session capacity (%d) is already met, so the DM has been notified. Talk to your DM for more help."
+)
+
+// Campaign leave
+const (
+ // internal log prefix
+ LeavingCampaignErrorMessage = "models.RemoveCampaignPlayer(): error removing player: "
+)
+const (
+ // user-facing
+ MasterIsLeavingCampaignErrorMessage = "β οΈ You are the DM. You cannot leave your own campaign."
+ FailedToLeaveCampaignErrorMessage = "π« Failed to leave campaign."
+ PlayerLeftCampaignMessage = "βΉοΈ You have left **%s**."
+)
+
+// Campaign toggle
+const (
+ // internal log prefix
+ CampaignUpdateErrorMessage = "db.Update(): error updating campaign: "
+)
+const (
+ // user-facing
+ MasterCanToggleStatusErrorMessage = "βΉοΈ Only the DM can toggle campaign status."
+ CampaignStatusMessage = "βΉοΈ **%s** is now **%s**."
+)
+
+// My campaigns (all user-facing)
+const (
+ NoCampaignsMessage = "βΉοΈ You are not in any campaigns yet!"
+ MyCampaignsLoadError = "β οΈ Failed to load your campaigns."
+)
+
+// Campaign embed UI labels
+const (
+ // identifiers
+ EmbedColor = 0x5865F2
+)
+const (
+ // user-facing
+ OpenCampaignLabel = "π΅ Set as Open Campaign"
+ ClosedCampaignLabel = "π΄ Set as Closed Campaign"
+ LeaveCampaignLabel = "πͺ Leave Campaign"
+ JoinCampaignLabel = "π’ Join Campaign"
+ ClosedStatusLabel = "π« Closed"
+ OpenStatusLabel = "π’ Open"
+ ArchivedStatusLabel = "π‘ Archived"
+ CampaignLabel = "π° Campaign"
+ CampaignTypeOneShotLabel = "π One-shot"
+ CampaignTypeWestmarchLabel = "π‘οΈ Westmarch"
+ NoneLabel = "None"
+ NoBooksSpecifiedLabel = "β οΈ No books specified"
+)
+
+// New campaign modal
+const (
+ // identifiers
+ NewCampaignModalCustomID = "modal_campaign_create"
+ NewCampaignCommandName = "newcampaign"
+)
+const (
+ // user-facing
+ NewCampaignModalError = "newcampaign: error opening modal: "
+ NewCampaignModalTitle = "π° Create a New Campaign"
+ NewCampaignCommandDesc = "Create a new campaign (you will be the DM!)"
+)
+
+// New campaign modal field IDs (all identifiers)
+const (
+ FieldNameID = "name"
+ FieldTagID = "tag"
+ FieldDescriptionID = "description"
+ FieldEditionID = "edition"
+ FieldSlotsID = "slots"
+)
+
+// New campaign modal labels (all user-facing)
+const (
+ FieldNameLabel = "Name"
+ FieldTagLabel = "Tag"
+ FieldDescriptionLabel = "Description"
+ FieldEditionLabel = "Edition"
+ FieldSlotsLabel = "Player Slots"
+)
+
+// New campaign modal placeholders (all user-facing)
+const (
+ FieldNamePlaceholder = "e.g. Curse of Strahd"
+ FieldTagPlaceholder = "e.g. curse-of-strahd (short, no spaces)"
+ FieldDescriptionPlaceholder = "Describe your campaign setting and premise..."
+ FieldEditionPlaceholder = "e.g. 5e, 3.5e, PF2e"
+)
+
+// Add player (all user-facing)
+const (
+ AddPlayerNotDMOrModMessage = "βΉοΈ You must be the DM of this campaign to add players."
+ AddPlayerTargetNotRegistered = "βΉοΈ That user is not registered. They need to `/register` first."
+ AddPlayerAlreadyInCampaign = "β οΈ That player is already in this campaign."
+ AddPlayerCampaignFullMessage = "π« This campaign is full."
+ AddPlayerFailureMessage = "β οΈ Failed to add player to campaign."
+ AddPlayerSuccessMessage = "πΊ Added <@%s> to **%s**!"
+)
+
+// Ban
+const (
+ // identifiers
+ BanCommandName = "ban"
+)
+const (
+ // user-facing
+ BanCommandDesc = "Globally ban a player from the server."
+ BanCannotBanSelf = "β οΈ You cannot ban yourself."
+ BanInsufficientRole = "β οΈ You cannot ban someone of equal or higher role."
+ BanTargetNotFound = "βΉοΈ That player is not registered."
+ BanPlayerNotInCampaign = "β οΈ The player ID is not valid for this campaign, or it does not belong to it."
+ BanTargetAlreadyBanned = "βΉοΈ That player is already banned."
+ BanFailureMessage = "π« Failed to ban player."
+ BanSuccessMessage = "βΉοΈ Banned <@%s>. \nThey can no longer interact with the bot."
+ BanSuccessReasonMessage = "βΉοΈ Banned <@%s>: %s"
+)
+
+// Unban
+const (
+ // identifiers
+ UnbanCommandName = "unban"
+)
+const (
+ // user-facing
+ UnbanCommandDesc = "Lift a global ban from a player."
+ UnbanTargetNotFound = "βΉοΈ That player is not registered."
+ UnbanTargetNotBanned = "βΉοΈ That player is not banned."
+ UnbanFailureMessage = "π« Failed to unban player."
+ UnbanSuccessMessage = "βΉοΈ Unbanned <@%s>. They can interact with the bot again."
+)
+
+// Campaign archival
+const (
+ // identifiers: command name and audit-log reasons stored in DB (do not translate!)
+ AbandonCommandName = "abandon"
+ AbandonReasonDM = "DM abandoned"
+ AbandonReasonLeftServer = "DM left server"
+)
+const (
+ // user-facing
+ CampaignArchivedMessage = "βΉοΈ This campaign has been archived and can no longer be modified."
+ AbandonCommandDesc = "Archive your campaign permanently. Only the DM can do this."
+ AbandonNotDMMessage = "βΉοΈ Only the DM of this campaign can abandon it."
+ AbandonFailureMessage = "π« Failed to archive campaign."
+ AbandonSuccessMessage = "βΉοΈ Campaign **%s** has been archived. It is now an immutable record, and cannot be recovered."
+)
+
+// Manage campaigns
+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."
+ ManageCampaignNotFound = "βΉοΈ Campaign not found."
+ ManageDeleteSuccess = "βΉοΈ Campaign **%s** has been deleted."
+ ManageDeleteFailure = "π« Failed to delete campaign."
+ ManageBanNoMembers = "βΉοΈ This campaign has no members to ban."
+ ManageCampaignBanSuccess = "βΉοΈ Banned <@%s> from **%s**."
+ ManageCampaignBanFailure = "π« Failed to ban player from campaign."
+ ManageSelectMember = "Select a member to ban from **%s**:"
+ ManageBanSelectPlaceholder = "Select a member..."
+ ManageCampaignHeader = "Managing **%s**:"
+ ManageInviteSelectPrompt = "Select a player to invite to **%s**:"
+ MyCampaignListLine = "**%s** β %s (%s)"
+ ManageCampaignListLine = "**%s** β %s"
+)
+
+// Token upload
+const (
+ // identifiers β token gallery
+ TokensCommandName = "tokens"
+ TokenGallerySelectPrefix = "token_gallery_select"
+ TokenGalleryAssignPrefix = "token_gallery_assign"
+ TokenGalleryAssignSelectPrefix = "token_gallery_assign_select"
+ TokenDeletePromptPrefix = "token_delete_prompt"
+ TokenDeleteConfirmPrefix = "token_delete_confirm"
+ TokenDownloadPrefix = "token_download"
+)
+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."
+ TokenNotAssigned = "βΉοΈ This token is not assigned to any campaign"
+ TokenGallerySelectPlaceholder = "Select a token to manage..."
+ TokenDownloadLabel = "β¬οΈ Download"
+ TokenDeleteLabel = "ποΈ Delete"
+ TokenDeleteConfirmMsg = "β οΈ Are you sure you want to delete **%s**? This cannot be undone."
+ TokenDeleteConfirmLabel = "ποΈ Yes, Delete"
+ TokenDeleteCancelLabel = "β Cancel"
+ TokenDeleteSuccess = "βΉοΈ Token **%s** deleted."
+ TokenDeleteFailed = "β οΈ Failed to delete token."
+ TokenGalleryAssignPrompt = "βΉοΈ Assign **%s** to a campaign:"
+ TokenGalleryAssignSelectPlaceholder = "Select a campaign..."
+)
+
+// Token upload
+const (
+ // identifiers
+ TokenUploadCommandName = "newtoken"
+ TokenUploadSourceOptName = "source"
+ TokenUploadFrameOptName = "frame"
+ TokenUploadColorOptName = "color"
+ TokenApplyPrefix = "token_apply"
+ TokenApplyModalPrefix = "token_apply_modal"
+ TokenDiscardPrefix = "token_discard"
+ TokenPostcreateSelectPrefix = "player_token_postcreate"
+ TokenSkipPrefix = "player_token_skip"
+ TokenNameFieldID = "token_name"
+)
+const (
+ // user-facing
+ TokenUploadCommandDesc = "Create a player token from a photo, with a frame image or a solid color border."
+ TokenUploadSourceOptDesc = "Your photo (JPG/PNG)."
+ TokenUploadFrameOptDesc = "Frame/border image (PNG with transparency). Cannot be used with color."
+ TokenUploadColorOptDesc = "Border color as hex (e.g. ff3a7c). Cannot be used with frame."
+ TokenUploadNeedOneOf = "π« Provide either a frame image or a color β not both, not neither."
+ TokenUploadNotImage = "β οΈ Both files must be images (JPEG or PNG)."
+ TokenUploadTooLarge = "β οΈ Each file must be under 8 MiB."
+ TokenUploadProcessFailed = "π« Failed to process your token. Please try again."
+ TokenUploadPreviewContent = "βΉοΈ Here's a preview of your token. Apply it to your profile or discard."
+ TokenApplyLabel = "β
Apply"
+ TokenDiscardLabel = "β Discard"
+ TokenPostcreateSelectPlaceholder = "Assign to a campaign..."
+ TokenSkipLabel = "β¬
οΈ Skip for now"
+ TokenPostcreateHeader = "β
**%s** saved! Assign it to one of your campaigns, or skip."
+ TokenPostcreateAssigned = "πΊ Token assigned to **%s**!"
+ TokenSavedNoAssign = "βΉοΈ Token saved. You can assign it later from your campaign card."
+ TokenNameModalTitle = "Name Your Token"
+ TokenNameFieldLabel = "Character Name"
+ TokenNameFieldPlaceholder = "e.g. Soft Doggo"
+ TokenApplySuccess = "β
Token saved as **%s**!"
+ TokenDiscardSuccess = "βΉοΈ Token discarded."
+ TokenApplyFailed = "β οΈ Failed to save token. Please try again."
+)
+
+// Campaign cover / upload
+const (
+ // identifiers
+ CampaignUploadCommandName = "uploadcover"
+ CampaignUploadKindOptName = "kind"
+ CampaignUploadKindCoverChoice = "Cover"
+ CampaignUploadCampaignOptName = "campaign"
+ CampaignUploadImageOptName = "image"
+)
+const (
+ // user-facing
+ 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)."
+ CampaignUploadNotDM = "βΉοΈ Only the DM of this campaign can change its cover."
+ CampaignUploadNotImage = "β οΈ That file doesn't look like an image. Try JPEG, PNG, or WebP."
+ CampaignUploadTooLarge = "β οΈ Image is too large. Max 8 MiB."
+ CampaignUploadMissingAttach = "βΉοΈ No image attached. Attach a file to the `image` option."
+ CampaignUploadFailure = "β οΈ Failed to save cover. Please try again."
+ CampaignUploadSuccess = "β
Cover set for **%s**. [View](%s)"
+ SetCoverButtonLabel = "Set Cover"
+ SetCoverInstructions = "Use `/uploadcover campaign: image:` to set a cover for this campaign."
+)
+
+// Manage campaigns: button labels (all user-facing)
+const (
+ ManageDeleteLabel = "ποΈ Delete"
+ ManageBanLabel = "π« Ban Member"
+ ManageAnnounceLabel = "π° New Session"
+ ManageRescheduleLabel = "π
Configure Schedule"
+ ManageCampaignButtonLabel = "βοΈ Manage"
+ ManageDownloadTokensLabel = "β¬οΈ Player Tokens"
+ ManageDownloadTokensNone = "βΉοΈ No players in this campaign have tokens assigned."
+ ManageDownloadTokensContent = "βΉοΈ Tokens for **%s** (%d player(s)):"
+)
+
+// Set campaign role
+const (
+ // identifiers
+ SetCampaignRoleCommandName = "setcampaignrole"
+ SetRoleFieldName = "role"
+)
+const (
+ // user-facing
+ SetCampaignRoleCommandDesc = "Link a Discord role to a campaign (creates one if it doesn't exist)."
+ SetRoleFieldDesc = "Name of the Discord role to link."
+ SetRoleNotDMOrMod = "β οΈ You must be the DM of this campaign to set its role."
+ SetRoleSuccess = "β
Linked role **%s** to campaign **%s**."
+ SetRoleCreateFailed = "β οΈ Failed to create Discord role."
+ SetRoleUpdateFailed = "β οΈ Failed to update campaign."
+)
+
+// Campaign approval
+const (
+ // identifiers
+ CampaignApprovePrefix = "campaign_approve"
+ CampaignDenyPrefix = "campaign_deny"
+ CampaignDenyModalPrefix = "campaign_deny_modal"
+ CampaignDenyReasonFieldID = "deny_reason"
+)
+const (
+ // user-facing
+ ApproveButtonLabel = "β
Approve"
+ DenyButtonLabel = "π« Deny"
+ CampaignApprovedMessage = "πΊ Campaign **%s** has been approved."
+ CampaignDeniedMessage = "π« Campaign **%s** has been denied and deleted."
+ CampaignApproveNotModError = "β οΈ Only mods or admins can approve campaigns."
+ CampaignApproveNotFound = "βΉοΈ Campaign not found or already processed."
+ CampaignApproveError = "β οΈ Failed to process campaign approval."
+ CampaignDenyModalTitle = "π« Deny Campaign"
+ CampaignDenyReasonLabel = "Reason"
+ CampaignDenyReasonPlaceholder = "Why is this campaign being denied?"
+ CampaignDeniedDMMessage = "π« Your campaign **%s** has been denied. Reason: %s"
+ CampaignApprovedDMMessage = "πΊ Your campaign **%s** has been approved!"
+ 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..."
+)
+
+// Announce
+const (
+ // identifiers
+ AnnounceModalPrefix = "manage_announce_modal"
+ AnnounceComponentPrefix = "manage_announce"
+ AnnounceFieldID = "announce_message"
+)
+const (
+ // user-facing
+ AnnounceModalTitle = "New Session Announcement"
+ AnnounceFieldLabel = "βοΈ Message"
+ AnnounceFieldPlaceholder = "Type your announcement to all campaign members..."
+ AnnounceSentMessage = "π£οΈ Announcement sent to %d members of **%s**."
+ AnnounceNoMembers = "βΉοΈ This campaign has no members to announce to."
+ AnnounceError = "β οΈ Failed to send announcement."
+ AnnouncePostedToThread = "π£οΈ Announcement posted to the **%s** announcements thread."
+ AnnounceThreadContent = "%s**π£οΈAnnouncement from <@%s>:**\n\n%s"
+ AnnounceDMContent = "**[%s]** π£οΈ Announcement from <@%s>:\n\n%s"
+)
+
+// 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,
+ "tue": 1, "tuesday": 1,
+ "wed": 2, "wednesday": 2,
+ "thu": 3, "thursday": 3,
+ "fri": 4, "friday": 4,
+ "sat": 5, "saturday": 5,
+ "sun": 6, "sunday": 6,
+}
+
+// Reschedule
+const (
+ // identifiers
+ RescheduleModalPrefix = "manage_reschedule_modal"
+ RescheduleComponentPrefix = "manage_reschedule"
+ RescheduleDayFieldID = "reschedule_day"
+ RescheduleTimeFieldID = "reschedule_time"
+ RescheduleDurFieldID = "reschedule_duration"
+ RescheduleFreqFieldID = "reschedule_freq"
+)
+const (
+ // user-facing
+ RescheduleModalTitle = "Configure Schedule"
+ RescheduleDayLabel = "Day of Week"
+ RescheduleDayPlaceholder = "e.g. Saturday or sat"
+ RescheduleTimeLabel = "Start Time (HH:MM UTC)"
+ RescheduleTimePlaceholder = "e.g. 19:00"
+ RescheduleDurLabel = "Duration (hours)"
+ RescheduleDurPlaceholder = "e.g. 3"
+ RescheduleFreqLabel = "Frequency (weekly, biweekly, monthly, quarterly, yearly)"
+ RescheduleFreqPlaceholder = "e.g. weekly"
+ RescheduleSuccess = "β
Schedule updated for **%s**: %s %s UTC (%sh), %s."
+ RescheduleInvalidDay = "β οΈ Invalid day. Use a name like Monday, Tuesday... or abbreviation like mon, tue..."
+ RescheduleInvalidTime = "β οΈ Invalid time format. Use HH:MM (e.g. 19:00)."
+ RescheduleInvalidDuration = "β οΈ Invalid duration. Enter a number (e.g. 3)."
+ RescheduleInvalidFrequency = "β οΈ Invalid frequency. Use: weekly, biweekly, monthly, quarterly, yearly."
+ RescheduleError = "π« Failed to update schedule."
+)
+
+// Campaign database (debug)
+const (
+ // identifiers
+ CampaignDBCommandName = "campaigndatabase"
+)
+const (
+ // user-facing
+ CampaignDBCommandDesc = "Show all campaigns in the database (staff only)."
+ CampaignDBEmpty = "βΉοΈ No campaigns in the database."
+ CampaignDBNotStaff = "βΉοΈ Only mods or admins can use this command."
+)
+
+// Help command
+const (
+ // identifiers
+ HelpCommandName = "help"
+)
+const (
+ // user-facing
+ HelpCommandDesc = "Get a list of all available commands."
+)
+
+// Player hub (/me)
+const (
+ // identifiers
+ MeCommandName = "me"
+)
+const (
+ // user-facing
+ MeCommandDesc = "Your player profile and quick actions."
+ MeHubMessage = "πΊ Hey, <@%s>! What would you like to do?"
+ MeCampaignsLabel = "π° Campaigns"
+ MeConfigLabel = "βοΈ Configuration"
+ MeCampaignsHeader = "π° Campaigns"
+ MeConfigHeader = "βοΈ Configuration"
+ ControlPanelLabel = "ποΈ Control Panel"
+)
+
+// Browse campaigns (/campaigns)
+const (
+ // identifiers
+ CampaignsCommandName = "campaigns"
+)
+const (
+ // user-facing
+ CampaignsCommandDesc = "Browse all available campaigns."
+ CampaignsFilterPlaceholder = "Filter by format..."
+ CampaignsSelectPlaceholder = "Select a campaign..."
+ CampaignsNoneAvailable = "βΉοΈ No campaigns match this filter."
+ CampaignsFilterAll = "All"
+ CampaignsFilterCampaign = "Campaigns"
+ CampaignsFilterOneshot = "One-shots"
+ CampaignsFilterWestmarch = "Westmarches"
+)
+
+// Search (/search)
+const (
+ // identifiers
+ SearchCommandName = "search"
+ SearchOptionName = "name"
+)
+const (
+ // user-facing
+ SearchCommandDesc = "Search for a campaign by name."
+ SearchOptionDesc = "Campaign name to search for."
+ SearchNoResults = "βΉοΈ No campaigns found matching that name."
+)
+
+// Admin hub (/admin)
+const (
+ // identifiers
+ AdminCommandName = "admin"
+)
+const (
+ // user-facing
+ AdminCommandDesc = "Mod/Admin panel."
+ AdminNotStaff = "βΉοΈ Only mods or admins can use this command."
+ DebugSurfaceDisabled = "βΉοΈ This surface is disabled in production. Set DEV_MODE=true to re-enable."
+ AdminHubMessage = "Admin Panel:"
+ AdminCampaignsLabel = "βΉοΈ Query Campaigns"
+ AdminDMsLabel = "βοΈ DMs"
+ AdminBroadcastLabel = "π£οΈ Broadcast"
+ AdminDatabaseLabel = "ποΈ Database"
+ AdminSettingsLabel = "βοΈ Settings"
+ AdminDiagLabel = "πΊ Diagnostics"
+)
+
+// About (/moontracer)
+const (
+ // identifiers
+ AboutCommandName = "about"
+ AboutCommandGitHubRepoLink = "https://github.com/framebuffers/moontracer"
+ AboutCommandWebsite = "https://framebuffer.cl/moontracer"
+ AboutCommandLicense = "AGPL-v3.0"
+)
+const (
+ // user-facing
+ AboutCommandDesc = "About this bot."
+ AboutCommandGitHubLabel = "π» GitHub"
+ AboutLabel = "πΊ About"
+ HelpLabel = "β Help"
+ AboutCommandBotDesc = "...a D&D campaign manager for players, DM and spectators!"
+ AboutCommandCopyright = "(C) 2026 **Framebuffer**"
+ AboutCommandHelp = "Type `/help`, or press the **Help** button below for a list of commands."
+ AboutCommandAwoo = "awoo!"
+ AboutCommandAttributions = "Thanks to the D&D r/Chile Discord server for giving me the idea, letting me test the bot on their server and give me feedback to improve this bot."
+)
+
+// Navigation buttons (all user-facing)
+const (
+ BackLabel = ""
+ HomeLabel = "π "
+)
+
+// Hub button labels (all user-facing)
+const (
+ MyCampaignsLabel = "π° My Campaigns"
+ NextSessionsLabel = "π
Next Sessions"
+ NotificationsLabel = "π Alerts"
+ BrowseCampaignsLabel = "π Browse Campaigns"
+ MyProfileLabel = HomeLabel
+ NewCampaignLabel = "π New Campaign"
+ AdminPanelLabel = "βοΈ Admin Panel"
+)
+
+// Manage campaign hub labels (all user-facing)
+const (
+ ManagePlayersLabel = "πΊ Players"
+ ManageSessionsLabel = "π Sessions"
+ ManageSettingsLabel = "βοΈ Settings"
+ ManageDangerLabel = "β οΈ Spicy Zone"
+ ManageOpenLabel = "π’ Open Campaign"
+ ManageCloseLabel = "π΄ Close Campaign"
+ CampaignAutoClosedDM = "βΉοΈ **%s** has been automatically closed β all %d slots are filled."
+)
+
+// Manage campaign: Links
+const (
+ // identifiers
+ ManageLinksPrefix = "manage_links"
+ ManageLinksModalID = "modal_manage_links"
+)
+const (
+ // user-facing
+ ManageLinksLabel = "π Links"
+ ManageLinksModalTitle = "π Campaign Links"
+ ManageLinksVTTLabel = "π VTT Link"
+ ManageLinksVTTPlaceholder = "https://owlbear.rodeo/... or https://app.roll20.net/..."
+ ManageLinksResourcesLabel = "Session Resources (one URL per line)"
+ ManageLinksResourcesPlaceholder = "https://drive.google.com/...\nhttps://example.com/map"
+ ManageLinksSuccess = "β
Links updated for **%s**."
+ ManageLinksEmbedTitle = "π Links"
+ ManageReminderLinks = "\n\nπ **Links for tonight:**"
+ ManageReminderVTT = "\nVTT: %s"
+ ManageReminderSheets = "\nSheets: %s"
+ ManageReminderResource = "\nβ’ %s"
+)
+
+// Manage campaign: additional buttons
+const (
+ // identifiers
+ ManageSetRolePrefix = "manage_role"
+ ManageArchivePrefix = "manage_archive"
+ ManageDownloadTokensPrefix = "manage_download_tokens"
+ ManageSetRoleModalID = "modal_manage_role"
+ ManageDeleteConfirmID = "manage_delete_confirm"
+ ManageArchiveConfirmID = "manage_archive_confirm"
+ ManageArchiveCancelID = "manage_archive_cancel"
+)
+const (
+ // user-facing
+ ManageSetRoleLabel = "π₯ Set Role"
+ ManageArchiveLabel = "π Archive"
+
+ // Set Role modal
+ ManageSetRoleModalTitle = "π Link Discord Role"
+ ManageSetRoleFieldLabel = "Role name (creates if it doesn't exist)"
+ ManageSetRoleSuccess = "β
Linked role **%s** to campaign **%s**."
+ ManageSetRoleFailed = "π« Failed to set role."
+
+ // Delete confirmation + handler
+ ManageDeleteConfirm = "β οΈ Are you sure you want to delete **%s**? This is permanent and cannot be undone. All members will be removed."
+ ManageDeleteConfirmLabel = "β
Yes, Delete"
+ ManageDeleteCancelLabel = "β Cancel"
+
+ // Archive confirmation + handler
+ ManageArchiveConfirm = "β οΈ Are you sure you want to archive **%s**? This is permanent and cannot be undone."
+ ManageArchiveConfirmLabel = "β
Yes, Archive"
+ ManageArchiveCancelLabel = "β Cancel"
+ ManageArchiveSuccess = "βΉοΈ Campaign **%s** has been archived. It is now an immutable record."
+ ManageArchiveFailed = "π« Failed to archive campaign."
+)
+
+// New campaign config (post-modal dropdowns)
+const (
+ // identifiers
+ 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..."
+ 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)
+const (
+ FieldSlotsPlaceholder = "e.g. 4 (leave empty for unlimited)"
+ FieldSynopsisLabel = "Synopsis & Rules"
+)
+
+// Select menu placeholders + content prefixes for /mycampaigns and /managecampaigns (all user-facing)
+const (
+ MyCampaignsPlaceholder = "Select a campaign..."
+ ManageCampaignsPlaceholder = "Select a campaign to manage..."
+ MyCampaignsListHeader = "π° Your campaigns:\n"
+ ManageCampaignsListHeader = "π° Your campaigns (DM):\n"
+)
+
+// Select menu CustomIDs (all identifiers)
+const (
+ CampaignSelectPrefix = "campaign_select"
+ MyCampaignSelectPrefix = "mycampaign_select"
+ ManageSelectPrefix = "manage_select"
+ CampaignsFilterPrefix = "campaigns_filter"
+ AdminCampaignSelectPrefix = "admin_campaign_select"
+)
+
+// Player hub: Next Sessions
+const (
+ // identifiers
+ NextSessionsPrefix = "next_sessions"
+ NextSessionsCommandName = "nextsessions"
+)
+const (
+ // user-facing
+ NextSessionsHeader = "Upcoming sessions:"
+ NextSessionsNone = "βΉοΈ You have no upcoming sessions."
+ NextSessionsCommandDesc = "Show your upcoming sessions."
+)
+
+// Player hub: Notifications
+const (
+ // identifiers
+ NotificationsPrefix = "notifications"
+ NotifTogglePrefix = "notif_toggle"
+ NotifFieldAnnouncements = "announcements"
+ NotifFieldSessions = "sessions"
+ NotifFieldInvitations = "invitations"
+)
+const (
+ // user-facing
+ NotificationsHeader = "π Alert settings:"
+ NotificationsNone = "βΉοΈ No notification preferences configured yet."
+ NotifLabelAnnouncements = "π£οΈ Announcements"
+ NotifLabelSessions = "π
Session Reminders"
+ NotifLabelInvitations = "βοΈ Invitations"
+ NotifLoadFailed = "π« Failed to load notification settings."
+ NotifUpdateFailed = "π« Failed to update notification settings."
+ NotifEnabledSuffix = "%s: ON"
+ NotifDisabledSuffix = "%s: OFF"
+)
+
+// Admin hub: Campaign browser (all campaigns)
+const (
+ // identifiers
+ AdminCampaignsPrefix = "admin_campaigns"
+)
+const (
+ // user-facing
+ AdminCampaignsHeader = "All campaigns:"
+ AdminCampaignsNone = "βΉοΈ No campaigns in the database."
+ AdminCampaignSelectPlaceholder = "Pick a campaign for details..."
+ AdminContactDMLabel = "π¬ Contact DM"
+)
+
+// Admin hub: Broadcast
+const (
+ // identifiers
+ AdminBroadcastPrefix = "admin_broadcast"
+ AdminBroadcastModalID = "modal_admin_broadcast"
+ AdminBroadcastFieldID = "broadcast_message"
+)
+const (
+ // user-facing
+ AdminBroadcastModalTitle = "π’ Broadcast Message"
+ AdminBroadcastFieldLabel = "βοΈ Message"
+ AdminBroadcastSuccess = "β
Broadcast sent."
+ AdminBroadcastSent = "β
Broadcast sent to %d players."
+ AdminBroadcastFailed = "π« Failed to send broadcast."
+ AdminBroadcastDMContent = "**π£οΈ Broadcast from <@%s>:**\n\n%s\n\n-# _You can disable these in `/me` β Configuration β π Alerts._"
+)
+
+// Admin hub: Database viewer
+const (
+ // identifiers
+ AdminDatabasePrefix = "admin_database"
+)
+const (
+ // user-facing
+ AdminDBCampaignLine = "**%s** (`%s`) β DM: <@%s> [%s]"
+)
+
+// Admin hub: Settings
+const (
+ // identifiers
+ AdminSettingsPrefix = "admin_settings"
+)
+const (
+ // user-facing
+ AdminSettingsHeader = "Bot settings:"
+)
+
+// Admin hub: Diagnostics
+const (
+ // identifiers
+ AdminDiagPrefix = "admin_diag"
+)
+
+// Manage campaign: New Campaign from button
+const (
+ // identifiers
+ ManageNewCampaignPrefix = "manage_newcampaign"
+)
+
+// Manage campaign: Invite Player
+const (
+ // identifiers
+ ManageInvitePrefix = "manage_invite"
+ ManageSetSessionPrefix = "manage_set_session"
+ ManageSetSessionModalID = "modal_manage_set_session"
+ ManageSetSessionDateFieldID = "session_date"
+ ManageSetSessionTimeFieldID = "session_time"
+ ManageSetSessionReasonFieldID = "session_reason"
+ ManageInviteSelectPrefix = "manage_invite_select"
+ InviteAcceptPrefix = "campaign_invite_accept"
+ InviteDeclinePrefix = "campaign_invite_decline"
+)
+const (
+ // user-facing
+ ManageInviteLabel = "π€ Invite Player"
+ ManageSetSessionLabel = "π
Set Session"
+ ManageRescheduleSessionLabel = "β Reschedule"
+ ManageSetSessionModalTitle = "π° Set Next Session"
+ ManageSetSessionDateLabel = "Date (DD/MM/YYYY)"
+ ManageSetSessionDatePlaceholder = "08/05/2026"
+ ManageSetSessionTimeLabel = "Time UTC (HH:MM, 24h)"
+ ManageSetSessionTimePlaceholder = "19:00"
+ 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."
+ ManageSetSessionUpdateFailed = "π« Failed to update next session."
+
+ // Reschedule-specific (existing session -> new date + reason).
+ ManageRescheduleModalTitle = "Reschedule Session"
+ ManageSetSessionReasonLabel = "Reason for change (optional)"
+ ManageSetSessionReasonPlaceholder = "e.g. DM unavailable this week"
+ ManageSetSessionRescheduleThread = "π
Session rescheduled to **%s** β _%s_"
+ ManageSetSessionRescheduleSuccess = "β
Session for **%s** rescheduled to **%s** β %s. Reason posted to thread."
+
+ // Session reminder DM (sent ~1 hour before NextSession).
+ ReminderContent = "βΉοΈ **Session Reminder: %s**\nYour next session starts in about 1 hour β **%s** (%s)"
+
+ InviteSentMessage = "β
Invitation sent to <@%s> for **%s**."
+ InviteDMMessage = "βΉοΈ You've been invited to join **%s** by <@%s>!"
+ InviteAcceptedDMUpdate = "β
You accepted the invitation to **%s**."
+ InviteDeclinedDMUpdate = "βΉοΈ You declined the invitation to **%s**."
+ InviteAlreadyProcessed = "βΉοΈ This invitation has already been processed."
+ InviteCampaignFull = "β οΈ Cannot invite β campaign **%s** is full."
+)
+
+// Session RSVP β legacy (campaign-level, reminder DMs only)
+const (
+ // identifiers
+ RSVPAcceptPrefix = "rsvp_accept"
+ RSVPDeclinePrefix = "rsvp_decline"
+)
+const (
+ // user-facing
+ RSVPAcceptLabel = "β
I'm Going!"
+ RSVPDeclineLabel = "β I'm Not Going"
+ RSVPAcceptedPlayer = "β
Confirmed! The DM has been notified. May the RNG be with you!"
+ RSVPDeclinedPlayer = "β Noted. The DM has been notified. Have a good day!"
+ RSVPDMNotifyAccept = "β
<@%s> confirmed assistance at **%s**\n%s."
+ RSVPDMNotifyDecline = "β <@%s> won't be coming for **%s**:\n%s."
+ RSVPAlreadyResponded = "βΉοΈ You've already responded for this session. If you changed your mind, talk to your DM."
+ 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
+ PlayerSetSheetPrefix = "player_set_sheet"
+ PlayerSetSheetModalID = "player_set_sheet_modal"
+ PlayerSetSheetFieldID = "sheet_url"
+ PlayerSetTokenPrefix = "player_set_token"
+ PlayerTokenSelectPrefix = "player_token_select"
+ PlayerTokenAssignPrefix = "player_token_assign"
+ PlayerLeaveConfirmPrefix = "player_leave_confirm"
+ PlayerLeaveDoPrefix = "player_leave_do"
+ PlayerContactDMPrefix = "player_contact_dm"
+ PlayerContactDMModalID = "player_contact_dm_modal"
+ PlayerContactDMFieldID = "dm_message"
+ PlayerDownloadTokensPrefix = "player_download_tokens"
+ PlayerDownloadSelectPrefix = "player_download_select"
+ PlayerDownloadAllValue = "all"
+)
+const (
+ // user-facing
+ PlayerSetSheetModalTitle = "Set Character Sheet"
+ PlayerSetSheetFieldLabel = "Character Sheet URL"
+ PlayerSetSheetFieldPlaceholder = "https://www.dndbeyond.com/characters/..."
+ PlayerSetSheetSuccess = "β
Character sheet updated for **%s**."
+ PlayerSetSheetFailed = "π« Failed to save character sheet."
+ PlayerSetSheetLabel = "π Set Sheet"
+ PlayerOpenSheetLabel = "π° Open Sheet"
+ PlayerSetTokenLabel = "πΊ Set Token"
+ PlayerTokenSelectPlaceholder = "Select a token..."
+ PlayerTokenNewHint = "Use `/newtoken` to create a new one."
+ PlayerTokenAssignLabel = "π° Assign to Campaign"
+ PlayerTokenAssignSuccess = "β
Token assigned to **%s**."
+ PlayerNoTokens = "βΉοΈ No tokens found. Create one first with `/newtoken`."
+ PlayerLeaveConfirmLabel = "β¬
οΈ Yes, Leave"
+ PlayerLeaveConfirmMsg = "β οΈ Are you sure you want to leave **%s**? This cannot be undone."
+ PlayerLeaveCancelLabel = "β Cancel"
+ PlayerContactDMModalTitle = "π¨ Send a Message to Your DM"
+ PlayerContactDMFieldLabel = "βοΈ Message"
+ PlayerContactDMFieldPlaceholder = "Ask about scheduling, character questions, lore..."
+ PlayerContactDMLabel = "π¨ Contact DM"
+ PlayerContactDMSuccess = "β
Your message has been sent to the DM."
+ PlayerContactDMReceived = "**π£οΈ Message from <@%s>** regarding **%s**:\n\n%s"
+
+ PlayerDownloadTokensLabel = "β¬οΈ Download Tokens"
+ PlayerDownloadSelectPlaceholder = "Select a campaign..."
+ PlayerDownloadAllLabel = "π° All campaigns"
+ PlayerDownloadNoTokens = "βΉοΈ You haven't assigned tokens to any campaigns yet. Open a campaign card and use **Set Token** to assign one."
+ PlayerDownloadContent = "βΉοΈ Here are your %d token(s):"
+)
+
+// Campaign import
+const (
+ ImportCampaignCommandName = "importcampaign"
+ ImportCampaignCommandDesc = "Import an existing campaign channel and role into Moontracer"
+
+ ImportCampaignOptChannel = "channel"
+ ImportCampaignOptRole = "role"
+ ImportCampaignOptDM = "dm"
+
+ ImportCampaignProcessing = "β³ Importing campaign, this may take a momentβ¦"
+ ImportCampaignSuccess = "β
Imported **%s**: %d member(s) registered, %d thread(s) bound, %d thread(s) created."
+ ImportCampaignErrDB = "β Failed to write campaign to the database."
+ ImportCampaignErrChannel = "β Could not read the channel. Make sure the bot has access to it."
+)
+
+// Timezone preference
+const (
+ // identifiers
+ TimezonePrefix = "set_timezone"
+ TimezoneSelectID = "timezone_select"
+)
+const (
+ // user-facing
+ TimezoneLabel = "π Timezone"
+ TimezoneHeader = "**Set your timezone**\nTimes will be shown in your local time.\nCurrent: **%s**"
+ TimezoneSelectPlaceholder = "Select your timezoneβ¦"
+ TimezoneSuccess = "β
Timezone set to **%s**."
+ TimezoneInvalid = "β οΈ Unknown timezone. Please select from the list."
+)
diff --git a/internal/messages/strutils.go b/internal/messages/strutils.go
new file mode 100644
index 0000000..7f08eb8
--- /dev/null
+++ b/internal/messages/strutils.go
@@ -0,0 +1,26 @@
+package messages
+
+import (
+ "strings"
+
+ "github.com/framebuffers/moontracer/internal/manager/models"
+)
+
+/*
+BuildFlags renders a Campaign's status flags (approved/unapproved, archived,
+open/closed) as a comma-separated string for display.
+*/
+func BuildFlags(c models.Campaign) string {
+ flags := []string{"unapproved"}
+ if c.IsApproved {
+ flags[0] = "approved"
+ }
+ if c.IsArchived {
+ flags = append(flags, "archived", "closed")
+ } else if c.IsOpen {
+ flags = append(flags, "open")
+ } else {
+ flags = append(flags, "closed")
+ }
+ return strings.Join(flags, ", ")
+}
diff --git a/internal/scheduler/reminder.go b/internal/scheduler/reminder.go
new file mode 100644
index 0000000..84ab191
--- /dev/null
+++ b/internal/scheduler/reminder.go
@@ -0,0 +1,199 @@
+package scheduler
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "strings"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+
+ "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) {
+ gdb, err := s.guildDBM.GetOrCreate(guildID)
+ if err != nil {
+ log.Printf("scheduler: fire: get db for guild %s: %v", guildID, err)
+ return
+ }
+
+ campaign, err := db.GetByID[models.Campaign](gdb, campaignID)
+ if err != nil {
+ log.Printf("scheduler: fire: load campaign %s: %v", campaignID, err)
+ return
+ }
+
+ // Re-validate reminder. State may have changed since the timer was set.
+ if campaign.IsArchived || campaign.Schedule.AlertSent || campaign.Schedule.NextSession.IsZero() {
+ return
+ }
+ if !campaign.Schedule.NextSession.After(time.Now().UTC()) {
+ return
+ }
+
+ /*
+ Persist the idempotency flag BEFORE fan-out, so a restart between
+ here and the dispatcher push never sends duplicate reminders.
+ */
+ campaign.Schedule.AlertSent = true
+ if err := db.Update(gdb, campaign); err != nil {
+ log.Printf("scheduler: fire: mark AlertSent for campaign %s: %v", campaignID, err)
+ return
+ }
+
+ players, err := models.GetCampaignPlayers(gdb, campaignID)
+ if err != nil {
+ log.Printf("scheduler: fire: load players for campaign %s: %v", 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 {
+ log.Printf("scheduler: fire: load settings for player %s: %v", p.PlayerID, err)
+ continue
+ }
+ if !settings.NotifySessionRemind {
+ continue
+ }
+ loc := settings.Location()
+ displayTime := helpers.FormatInLocation(campaign.Schedule.NextSession, messages.SessionTimeFormat, loc)
+ content := fmt.Sprintf(messages.ReminderContent,
+ campaign.Name,
+ displayTime,
+ helpers.TZLabel(loc),
+ )
+ content += formatReminderLinks(campaign, p.SheetURL)
+ s.dispatcher.Push(dispatch.DirectMessage{
+ ID: fmt.Sprintf("reminder:%s:%s", campaignID, p.PlayerID),
+ Target: p.PlayerID,
+ Content: content,
+ Components: []discordgo.MessageComponent{
+ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
+ discordgo.Button{
+ Label: messages.RSVPAcceptLabel,
+ Style: discordgo.SuccessButton,
+ CustomID: fmt.Sprintf("%s:%s:%s", messages.RSVPAcceptPrefix, guildID, campaignID),
+ },
+ discordgo.Button{
+ Label: messages.RSVPDeclineLabel,
+ Style: discordgo.DangerButton,
+ CustomID: fmt.Sprintf("%s:%s:%s", messages.RSVPDeclinePrefix, guildID, campaignID),
+ },
+ }},
+ },
+ })
+ sent++
+ }
+
+ log.Printf("scheduler: fired reminder for %s (%s, guild %s) β %d DM(s) queued",
+ 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 != ""
+ hasResources := len(c.Links) > 0
+ if !hasVTT && !hasSheets && !hasResources {
+ return ""
+ }
+ var b strings.Builder
+ b.WriteString(messages.ManageReminderLinks)
+ if hasVTT {
+ b.WriteString(fmt.Sprintf(messages.ManageReminderVTT, c.VTTLink))
+ }
+ if hasSheets {
+ b.WriteString(fmt.Sprintf(messages.ManageReminderSheets, sheetURL))
+ }
+ for _, r := range c.Links {
+ b.WriteString(fmt.Sprintf(messages.ManageReminderResource, r))
+ }
+ return b.String()
+}
diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go
new file mode 100644
index 0000000..f7a03b2
--- /dev/null
+++ b/internal/scheduler/scheduler.go
@@ -0,0 +1,206 @@
+package scheduler
+
+import (
+ "context"
+ "log"
+ "sync"
+ "time"
+
+ "github.com/framebuffers/moontracer/internal/db"
+ "github.com/framebuffers/moontracer/internal/dispatch"
+ "github.com/framebuffers/moontracer/internal/manager/models"
+)
+
+const DefaultLeadTime = time.Hour
+
+// Scheduler manages one-shot session reminder timers, keyed by guild/campaign.
+type Scheduler struct {
+ guildDBM *db.GuildDBManager
+ dispatcher *dispatch.Dispatcher
+
+ mu sync.Mutex
+ timers map[string]*time.Timer
+}
+
+func New(guildDBM *db.GuildDBManager, dispatcher *dispatch.Dispatcher) *Scheduler {
+ return &Scheduler{
+ guildDBM: guildDBM,
+ dispatcher: dispatcher,
+ timers: make(map[string]*time.Timer),
+ }
+}
+
+func timerKey(guildID, id string) string { return guildID + "/" + id }
+
+/*
+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.
+*/
+func (s *Scheduler) BootScan(guildIDs []string) {
+ now := time.Now().UTC()
+ total := 0
+ for _, guildID := range guildIDs {
+ gdb, err := s.guildDBM.GetOrCreate(guildID)
+ if err != nil {
+ 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 campaigns for guild %s: %v", guildID, err)
+ continue
+ }
+ for i := range campaigns {
+ c := &campaigns[i]
+ if c.IsArchived || c.Schedule.AlertSent || c.Schedule.NextSession.IsZero() {
+ continue
+ }
+ 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++
+ }
+ }
+ log.Printf("scheduler: boot scan complete β %d reminder(s) scheduled across %d guild(s)", total, len(guildIDs))
+}
+
+/*
+Schedule cancels any existing timer for the campaign and sets a new one.
+
+Safe to call after every NextSession update. It always replaces the prior timer.
+*/
+func (s *Scheduler) Schedule(guildID string, campaign *models.Campaign) {
+ if s == nil {
+ return
+ }
+ if campaign.IsArchived || campaign.Schedule.AlertSent || campaign.Schedule.NextSession.IsZero() {
+ return
+ }
+ now := time.Now().UTC()
+ if !campaign.Schedule.NextSession.After(now) {
+ return
+ }
+
+ delay := time.Until(campaign.Schedule.NextSession.Add(-DefaultLeadTime))
+ if delay < 0 {
+ // NOTE: it's past lead-time window but session hasn't started. fire alert immediately.
+ delay = 0
+ }
+
+ key := timerKey(guildID, campaign.ID)
+ campaignID := campaign.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()
+ fireReminder(s, guildID, campaignID)
+ })
+ s.mu.Unlock()
+
+ 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.
+
+Call whenever a campaign is archived or its session is cleared.
+*/
+func (s *Scheduler) Cancel(guildID, campaignID string) {
+ if s == nil {
+ return
+ }
+ key := timerKey(guildID, campaignID)
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if t, ok := s.timers[key]; ok {
+ t.Stop()
+ delete(s.timers, key)
+ log.Printf("scheduler: cancelled reminder for campaign %s (guild %s)", campaignID, guildID)
+ }
+}
+
+// Stop cancels all pending timers. Call during bot shutdown.
+func (s *Scheduler) Stop() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for key, t := range s.timers {
+ t.Stop()
+ delete(s.timers, key)
+ }
+ log.Printf("scheduler: stopped")
+}
diff --git a/internal/testutil/db.go b/internal/testutil/db.go
new file mode 100644
index 0000000..b13d930
--- /dev/null
+++ b/internal/testutil/db.go
@@ -0,0 +1,36 @@
+package testutil
+
+import (
+ "database/sql"
+ "testing"
+
+ "github.com/uptrace/bun"
+ "github.com/uptrace/bun/dialect/sqlitedialect"
+ "github.com/uptrace/bun/driver/sqliteshim"
+
+ "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.
+*/
+func NewTestDB(t *testing.T) *bun.DB {
+ t.Helper()
+
+ sqldb, err := sql.Open(sqliteshim.ShimName, ":memory:?_pragma=foreign_keys(1)")
+ if err != nil {
+ t.Fatalf("open in-memory db: %v", err)
+ }
+
+ bunDB := bun.NewDB(sqldb, sqlitedialect.New())
+
+ if err := db.Migrate(bunDB); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+
+ t.Cleanup(func() { bunDB.Close() })
+
+ return bunDB
+}
diff --git a/internal/testutil/discordsession.go b/internal/testutil/discordsession.go
new file mode 100644
index 0000000..5e7d182
--- /dev/null
+++ b/internal/testutil/discordsession.go
@@ -0,0 +1,113 @@
+package testutil
+
+import (
+ "io"
+ "net/http"
+ "regexp"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/bwmarrin/discordgo"
+)
+
+/*
+Handler tests need a *discordgo.Session that doesn't hit the network.
+
+A plain `discordgo.New(...)` session is fully initialized (ratelimiter, state, http
+client). Therefore, swapping its Client.Transport is enough to stop real REST calls
+while letting the handler code path run normally.
+
+Captured requests are available via Requests() for assertions that need to know
+what the handler told Discord.
+*/
+
+// RecordedRequest captures a single REST call intercepted by the stub transport.
+type RecordedRequest struct {
+ Method string
+ URL string
+ Body []byte
+}
+
+/*
+StubSession wraps a real *discordgo.Session whose HTTP client is redirected
+into an in-memory recorder.
+
+Note:
+
+ REST calls always succeed with 200 OK + `{}`.
+*/
+type StubSession struct {
+ *discordgo.Session
+ transport *stubTransport
+}
+
+// NewStubSession builds a Session that captures REST calls without hitting Discord.
+func NewStubSession(t *testing.T) *StubSession {
+ t.Helper()
+
+ s, err := discordgo.New("Bot test-token")
+ if err != nil {
+ t.Fatalf("stub session: %v", err)
+ }
+
+ tr := &stubTransport{}
+ s.Client = &http.Client{Transport: tr}
+ return &StubSession{Session: s, transport: tr}
+}
+
+// Requests returns a snapshot of every REST call the stub intercepted, in order.
+func (s *StubSession) Requests() []RecordedRequest {
+ return s.transport.snapshot()
+}
+
+type stubTransport struct {
+ mu sync.Mutex
+ captured []RecordedRequest
+}
+
+/*
+userLookupPath matches /users/ where is not "@me" and has no further
+path segments.
+
+discordgo's UserValue(s) calls s.User(id) which hits this path;
+returning 404 here makes UserValue fall back to &User{ID: id} from its option
+data, preserving the target ID in command tests.
+*/
+var userLookupPath = regexp.MustCompile(`/users/[^/@][^/]*$`)
+
+func (t *stubTransport) RoundTrip(r *http.Request) (*http.Response, error) {
+ var body []byte
+ if r.Body != nil {
+ body, _ = io.ReadAll(r.Body)
+ _ = r.Body.Close()
+ }
+
+ t.mu.Lock()
+ t.captured = append(t.captured, RecordedRequest{
+ Method: r.Method,
+ URL: r.URL.String(),
+ Body: body,
+ })
+ t.mu.Unlock()
+
+ status := http.StatusOK
+ if r.Method == http.MethodGet && userLookupPath.MatchString(r.URL.Path) {
+ status = http.StatusNotFound
+ }
+
+ return &http.Response{
+ StatusCode: status,
+ Body: io.NopCloser(strings.NewReader("{}")),
+ Header: make(http.Header),
+ Request: r,
+ }, nil
+}
+
+func (t *stubTransport) snapshot() []RecordedRequest {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ out := make([]RecordedRequest, len(t.captured))
+ copy(out, t.captured)
+ return out
+}
diff --git a/internal/tokengenerator/generator.go b/internal/tokengenerator/generator.go
new file mode 100644
index 0000000..241a76c
--- /dev/null
+++ b/internal/tokengenerator/generator.go
@@ -0,0 +1,192 @@
+package tokengenerator
+
+import (
+ "image"
+ "image/color"
+ "image/draw"
+ "image/png"
+ "log"
+ "math"
+ "os"
+
+ "github.com/disintegration/imaging"
+)
+
+const size = 512
+
+/*
+portraitRadius is the circle mask radius used when compositing against a frame image.
+
+Slightly smaller than size/2 so the portrait stays inside the frame's inner edge.
+Tune this if the portrait bleeds over a different frame's ring.
+
+Note:
+These magic numbers relate to the fact that tokens are ~512px square, and a little
+bit of a border just to avoid that bleeding outside the token.
+*/
+const portraitRadius = size/2 - 42
+
+func New(imgPath string, framePath string, destination string) (string, error) {
+ pic, err := imaging.Open(imgPath)
+ if err != nil {
+ log.Printf("tokengenerator_new: could not load thumbnail: %s", err)
+ return "", err
+ }
+
+ frame, err := imaging.Open(framePath)
+ if err != nil {
+ log.Printf("tokengenerator_new: could not load frame: %s", err)
+ return "", err
+ }
+
+ /*
+ Note:
+ Tokens are limited to 512px square (sounds like a pretty reasonable size to me).
+ Photos are center-cropped so they fill the square.
+
+ When overlaying the frame on top of position 0,0, the frame covers the full canvas
+ */
+ pic = resizeToSquare(pic)
+ frame = resizeToSquare(frame)
+
+ dst := applyCircleMask(pic, portraitRadius)
+
+ result := imaging.Overlay(dst, frame, image.Pt(0, 0), 1.0)
+
+ f, err := os.Create(destination)
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+
+ if err := png.Encode(f, result); err != nil {
+ return "", err
+ }
+
+ return destination, nil
+}
+
+/*
+DrawBasicFrame draws a circular gradient ring of the given color and thickness over the source
+image.
+
+The gradient fades from transparent at the inner edge to fully opaque at the outer edge.
+Thickness is clamped to at most half the image size.
+
+Returns the composited image.
+*/
+func DrawBasicFrame(source string, destination string, frameColor color.RGBA, thickness float32) (*image.RGBA, error) {
+ pic, err := imaging.Open(source)
+ if err != nil {
+ log.Printf("tokengenerator_drawbasicframe: could not load thumbnail: %s", err)
+ return nil, err
+ }
+
+ if pic.Bounds().Dx() != pic.Bounds().Dy() {
+ pic = resizeToSquare(pic)
+ }
+
+ bounds := pic.Bounds()
+ w, h := bounds.Dx(), bounds.Dy()
+
+ maxThickness := float32(min(w, h)) / 2
+ if thickness > maxThickness {
+ thickness = maxThickness
+ }
+
+ src := image.NewRGBA(bounds)
+ draw.Draw(src, bounds, pic, bounds.Min, draw.Src)
+ return applyGradientRing(src, frameColor, thickness), nil
+}
+
+/*
+NewBasicToken generates the frame from a solid color with a gradient ring.
+
+Follows same pipeline as New(), but instead of a picture, it's a gradient.
+*/
+func NewBasicToken(imgPath string, frameColor color.RGBA, thickness float32, destination string) (string, error) {
+ pic, err := imaging.Open(imgPath)
+ if err != nil {
+ log.Printf("tokengenerator_newbasictoken: could not load thumbnail: %s", err)
+ return "", err
+ }
+
+ pic = resizeToSquare(pic)
+ masked := applyCircleMask(pic, size/2)
+
+ maxThickness := float32(size) / 2
+ if thickness > maxThickness {
+ thickness = maxThickness
+ }
+
+ result := applyGradientRing(masked, frameColor, thickness)
+
+ f, err := os.Create(destination)
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+
+ if err := png.Encode(f, result); err != nil {
+ return "", err
+ }
+
+ return destination, nil
+}
+
+func applyCircleMask(img image.Image, radius int) *image.RGBA {
+ bounds := image.Rect(0, 0, size, size)
+ center := image.Point{X: size / 2, Y: size / 2}
+
+ mask := image.NewAlpha(bounds)
+ for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
+ for x := bounds.Min.X; x < bounds.Max.X; x++ {
+ dx := x - center.X
+ dy := y - center.Y
+ if dx*dx+dy*dy <= radius*radius {
+ mask.SetAlpha(x, y, color.Alpha{A: 255})
+ }
+ }
+ }
+
+ dst := image.NewRGBA(bounds)
+ draw.DrawMask(dst, bounds, img, image.Point{}, mask, image.Point{}, draw.Over)
+ return dst
+}
+
+func applyGradientRing(img *image.RGBA, frameColor color.RGBA, thickness float32) *image.RGBA {
+ bounds := img.Bounds()
+ w, h := bounds.Dx(), bounds.Dy()
+ cx := float64(w) / 2
+ cy := float64(h) / 2
+ outerR := math.Min(float64(w), float64(h)) / 2
+ innerR := outerR - float64(thickness)
+
+ ring := image.NewRGBA(bounds)
+ for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
+ for x := bounds.Min.X; x < bounds.Max.X; x++ {
+ dx := float64(x) - cx + 0.5
+ dy := float64(y) - cy + 0.5
+ dist := math.Sqrt(dx*dx + dy*dy)
+ if dist < innerR || dist > outerR {
+ continue
+ }
+ t := (dist - innerR) / float64(thickness)
+ ring.SetRGBA(x, y, color.RGBA{
+ R: frameColor.R,
+ G: frameColor.G,
+ B: frameColor.B,
+ A: uint8(t * float64(frameColor.A)),
+ })
+ }
+ }
+
+ dst := image.NewRGBA(bounds)
+ draw.Draw(dst, bounds, img, bounds.Min, draw.Src)
+ draw.Draw(dst, bounds, ring, bounds.Min, draw.Over)
+ return dst
+}
+
+func resizeToSquare(img image.Image) image.Image {
+ return imaging.Fill(img, size, size, imaging.Top, imaging.Lanczos)
+}
diff --git a/next.config.ts b/next.config.ts
deleted file mode 100644
index ec16752..0000000
--- a/next.config.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import type { NextConfig } from "next";
-
-const nextConfig: NextConfig = {
- output: "standalone",
- turbopack: {
- root: __dirname,
- },
-};
-
-export default nextConfig;
\ No newline at end of file
diff --git a/package.json b/package.json
deleted file mode 100644
index 173ed91..0000000
--- a/package.json
+++ /dev/null
@@ -1,42 +0,0 @@
-{
- "name": "moontracer",
- "version": "0.1.0",
- "private": true,
- "scripts": {
- "dev": "next dev",
- "build": "next build",
- "start": "next start",
- "lint": "next lint && bun run type-check",
- "type-check": "tsc --noEmit"
- },
- "dependencies": {
- "bun": "^1.3.5",
- "discord-interactions": "^4.4.0",
- "next": "^16.1.1",
- "postgres": "^3.4.7",
- "react": "19.2.3",
- "react-dom": "19.2.3",
- "tweetnacl": "^1.0.3"
- },
- "devDependencies": {
- "@tailwindcss/postcss": "^4",
- "@types/node": "^25.0.9",
- "@types/react": "^19",
- "@types/react-dom": "^19",
- "babel-plugin-react-compiler": "1.0.0",
- "eslint": "^9",
- "eslint-config-next": "16.1.1",
- "tailwindcss": "^4",
- "typescript": "^5",
- "@types/bun": "latest"
- },
- "ignoreScripts": [
- "sharp",
- "unrs-resolver"
- ],
- "trustedDependencies": [
- "bun",
- "sharp",
- "unrs-resolver"
- ]
-}
diff --git a/postcss.config.mjs b/postcss.config.mjs
deleted file mode 100644
index 61e3684..0000000
--- a/postcss.config.mjs
+++ /dev/null
@@ -1,7 +0,0 @@
-const config = {
- plugins: {
- "@tailwindcss/postcss": {},
- },
-};
-
-export default config;
diff --git a/public/file.svg b/public/file.svg
deleted file mode 100644
index 004145c..0000000
--- a/public/file.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/globe.svg b/public/globe.svg
deleted file mode 100644
index 567f17b..0000000
--- a/public/globe.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/next.svg b/public/next.svg
deleted file mode 100644
index 5174b28..0000000
--- a/public/next.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/vercel.svg b/public/vercel.svg
deleted file mode 100644
index 7705396..0000000
--- a/public/vercel.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/window.svg b/public/window.svg
deleted file mode 100644
index b2b2a44..0000000
--- a/public/window.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/scripts/register_commands.ts b/scripts/register_commands.ts
deleted file mode 100644
index f46d2b4..0000000
--- a/scripts/register_commands.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-const DISCORD_BOT_TOKEN = process.env.DISCORD_BOT_TOKEN!;
-const DISCORD_APPLICATION_ID = process.env.DISCORD_APPLICATION_ID;
-
-const commands = [
- {
- name: 'ping',
- description: 'pong!'
- },
- {
- name: 'awoo',
- description: 'oh no, don\'t awoo'
- }
-]
-
-async function registerCommands() {
- try {
- const url = `https://discord.com/api/v10/applications/${DISCORD_APPLICATION_ID}/commands`;
-
- const response = await fetch(url, {
- method: 'PUT',
- headers: {
- 'Authorization': `Bot ${DISCORD_BOT_TOKEN}`,
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify(commands)
- });
-
- if (response.ok) {
- console.log('Command registered successfully!');
- const data = await response.json();
- console.log('Registered: ', data.map((x: any) => x.name).join(', '));
- } else {
- console.error('Failed to register commands');
- console.error(await response.text());
- }
- } catch (err) {
- console.error('Could not register commands: ', err)
- }
-}
-
-registerCommands();
diff --git a/scripts/type-check.sh b/scripts/type-check.sh
deleted file mode 100755
index ad37869..0000000
--- a/scripts/type-check.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/bin/bash
-echo "Running TypeScript type check..."
-bunx tsc --noEmit
-
-if [ $? -eq 0 ]; then
- echo "β
Type check passed"
-else
- echo "β Type check failed"
- exit 1
-fi
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
deleted file mode 100644
index 95307be..0000000
--- a/tsconfig.json
+++ /dev/null
@@ -1,36 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2017",
- "lib": ["dom", "dom.iterable", "esnext"],
- "allowJs": true,
- "skipLibCheck": true,
- "strict": true,
- "noEmit": true,
- "esModuleInterop": true,
- "module": "esnext",
- "moduleResolution": "bundler",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "jsx": "react-jsx",
- "incremental": true,
- "noImplicitReturns": true,
- "strictFunctionTypes": true,
- "plugins": [
- {
- "name": "next"
- }
- ],
- "paths": {
- "@/*": ["./src/*"]
- }
- },
- "include": [
- "next-env.d.ts",
- "**/*.ts",
- "**/*.tsx",
- ".next/types/**/*.ts",
- ".next/dev/types/**/*.ts",
- "**/*.mts"
- ],
- "exclude": ["node_modules"]
-}