diff --git a/.env.example b/.env.example index a633369..05bee1d 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,10 @@ GITHUB_SECRET= # Discord — https://discord.com/developers/applications DISCORD_CLIENT_ID= DISCORD_CLIENT_SECRET= +# Discord Linked Roles: the support bot's application (it registers the role requirements). +# Add /api/discord/linked-roles/callback as an OAuth2 redirect on that application. +DISCORD_LINKED_ROLES_CLIENT_ID= +DISCORD_LINKED_ROLES_CLIENT_SECRET= # Google — https://console.cloud.google.com/apis/credentials GOOGLE_CLIENT_ID= diff --git a/.gitignore b/.gitignore index a5b1c06..f85ea42 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ node_modules .docs .docker apps/status +apps/discord-bot # Testing coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ef627a..7569e39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.5.0] - 2026-10-19 + +### Added +- **ByteSend Discord bot** (`apps/discord-bot`, Go) for the support server: + - **Presence** mirrors status.bytesend.cloud + - **Commands:** `/docs`, `/faq`, `/status`, `/stats`, `/links` and `/roles` + - **Moderation:** `/purge`, `/warn`, `/timeout`, `/untimeout`, `/kick` and `/ban`, with a mod log; they refuse to act on anyone whose top role is at or above the moderator's + - **AutoMod** for invite links, mass mentions and repeated messages + - **Automatic FAQ answers** in support channels + - **Custom emojis** from the application +- `GET /api/stats` (Cloud only): the open stats as JSON, cached for an hour +- **Discord linked roles**: members connect their ByteSend account at `/api/discord/linked-roles` to claim roles based on having an account, being on a paid plan, emails sent, or account age. Enable with `DISCORD_LINKED_ROLES_CLIENT_ID` and `DISCORD_LINKED_ROLES_CLIENT_SECRET` + +--- + ## [0.4.4] - 2026-10-19 ### Added @@ -916,7 +931,8 @@ Initial public beta release of ByteSend: an all-in-one email infrastructure plat --- -[Unreleased]: https://github.com/ByteSendApp/ByteSend/compare/v0.4.4...HEAD +[Unreleased]: https://github.com/ByteSendApp/ByteSend/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/ByteSendApp/ByteSend/compare/v0.4.4...v0.5.0 [0.4.4]: https://github.com/ByteSendApp/ByteSend/compare/v0.4.3...v0.4.4 [0.4.3]: https://github.com/ByteSendApp/ByteSend/compare/v0.4.2...v0.4.3 [0.4.2]: https://github.com/ByteSendApp/ByteSend/compare/v0.4.1...v0.4.2 diff --git a/apps/web/src/app/(marketing)/discord/linked-roles/page.tsx b/apps/web/src/app/(marketing)/discord/linked-roles/page.tsx new file mode 100644 index 0000000..68ca463 --- /dev/null +++ b/apps/web/src/app/(marketing)/discord/linked-roles/page.tsx @@ -0,0 +1,82 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { getServerAuthSession } from "~/server/auth"; +import { computeLinkedRoleMetadata } from "~/server/service/discord-linked-roles-service"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Discord roles – ByteSend", + robots: { index: false }, +}; + +const COPY = { + linked: { + title: "Your Discord roles are connected", + body: "Head back to Discord, open the server menu → Linked Roles, and claim the roles you qualify for.", + }, + cancelled: { + title: "Connection cancelled", + body: "Nothing was shared with Discord. You can connect again whenever you like.", + }, + error: { + title: "We couldn't connect your roles", + body: "The link may have expired. Start again from Discord, or use /roles in the ByteSend server.", + }, +} as const; + +type Status = keyof typeof COPY; + +export default async function LinkedRolesPage({ searchParams }: { searchParams: Promise<{ status?: string }> }) { + const { status: rawStatus } = await searchParams; + const status: Status = rawStatus === "linked" || rawStatus === "cancelled" ? rawStatus : "error"; + const copy = COPY[status]; + + const session = status === "linked" ? await getServerAuthSession() : null; + const linked = session?.user ? await computeLinkedRoleMetadata(session.user.id) : null; + + return ( +
+
+
+ + Discord linked roles +
+

{copy.title}

+

{copy.body}

+ + {linked && ( +
+ {[ + ["Shown on Discord as", linked.platformUsername], + ["ByteSend user", "Yes"], + ["Paid plan", linked.metadata.paid === "1" ? "Yes" : "No"], + ["Emails sent", Number(linked.metadata.emails_sent).toLocaleString("en-GB")], + [ + "Member since", + new Date(linked.metadata.member_since).toLocaleDateString("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + }), + ], + ].map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ )} + +

+ Roles reflect your account at the moment you connect. Upgraded or sent more since?{" "} + + Connect again + {" "} + to refresh them. +

+
+
+ ); +} diff --git a/apps/web/src/app/api/discord/linked-roles/callback/route.ts b/apps/web/src/app/api/discord/linked-roles/callback/route.ts new file mode 100644 index 0000000..779bc24 --- /dev/null +++ b/apps/web/src/app/api/discord/linked-roles/callback/route.ts @@ -0,0 +1,51 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { env } from "~/env"; +import { getServerAuthSession } from "~/server/auth"; +import { logger } from "~/server/logger/log"; +import { + computeLinkedRoleMetadata, + exchangeCode, + LINKED_ROLES_STATE_COOKIE, + linkedRolesConfigured, + pushRoleConnection, +} from "~/server/service/discord-linked-roles-service"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + if (!linkedRolesConfigured()) { + return new NextResponse("Linked roles are not configured", { status: 404 }); + } + + const resultUrl = (status: "linked" | "cancelled" | "error") => + `${env.NEXTAUTH_URL.replace(/\/$/, "")}/discord/linked-roles?status=${status}`; + const finish = (status: "linked" | "cancelled" | "error") => { + const response = NextResponse.redirect(resultUrl(status)); + response.cookies.delete({ name: LINKED_ROLES_STATE_COOKIE, path: "/api/discord/linked-roles" }); + return response; + }; + + const params = request.nextUrl.searchParams; + if (params.get("error")) return finish("cancelled"); + + const code = params.get("code"); + const state = params.get("state"); + const expectedState = request.cookies.get(LINKED_ROLES_STATE_COOKIE)?.value; + if (!code || !state || !expectedState || state !== expectedState) { + return finish("error"); + } + + const session = await getServerAuthSession(); + if (!session?.user || session.user.isBanned) return finish("error"); + + try { + const accessToken = await exchangeCode(code); + const { metadata, platformUsername } = await computeLinkedRoleMetadata(session.user.id); + await pushRoleConnection(accessToken, platformUsername, metadata); + logger.info({ userId: session.user.id, paid: metadata.paid }, "[LinkedRoles]: Discord role connection updated"); + return finish("linked"); + } catch (error) { + logger.error({ err: error, userId: session.user.id }, "[LinkedRoles]: Could not link Discord roles"); + return finish("error"); + } +} diff --git a/apps/web/src/app/api/discord/linked-roles/route.ts b/apps/web/src/app/api/discord/linked-roles/route.ts new file mode 100644 index 0000000..f81ea23 --- /dev/null +++ b/apps/web/src/app/api/discord/linked-roles/route.ts @@ -0,0 +1,38 @@ +import { randomBytes } from "node:crypto"; +import { NextResponse } from "next/server"; +import { env } from "~/env"; +import { getServerAuthSession } from "~/server/auth"; +import { + buildAuthorizeUrl, + LINKED_ROLES_STATE_COOKIE, + linkedRolesConfigured, +} from "~/server/service/discord-linked-roles-service"; + +export const dynamic = "force-dynamic"; + +// Discord sends members here (the app's "Linked Roles Verification URL") when they claim a role. +export async function GET() { + if (!linkedRolesConfigured()) { + return new NextResponse("Linked roles are not configured", { status: 404 }); + } + + const base = env.NEXTAUTH_URL.replace(/\/$/, ""); + const session = await getServerAuthSession(); + if (!session?.user) { + return NextResponse.redirect(`${base}/login?callbackUrl=${encodeURIComponent("/api/discord/linked-roles")}`); + } + if (session.user.isBanned) { + return NextResponse.redirect(`${base}/discord/linked-roles?status=error`); + } + + const state = randomBytes(24).toString("hex"); + const response = NextResponse.redirect(buildAuthorizeUrl(state)); + response.cookies.set(LINKED_ROLES_STATE_COOKIE, state, { + httpOnly: true, + secure: base.startsWith("https://"), + sameSite: "lax", + path: "/api/discord/linked-roles", + maxAge: 10 * 60, + }); + return response; +} diff --git a/apps/web/src/app/api/stats/route.ts b/apps/web/src/app/api/stats/route.ts new file mode 100644 index 0000000..a78a7e0 --- /dev/null +++ b/apps/web/src/app/api/stats/route.ts @@ -0,0 +1,15 @@ +import { PublicStatsService } from "~/server/service/public-stats-service"; +import { isCloud } from "~/utils/common"; + +export const dynamic = "force-dynamic"; + +// The same aggregate, platform-wide numbers as /stats, for the Discord bot and anyone else. +export async function GET() { + if (!isCloud()) { + return Response.json({ error: "Not found" }, { status: 404 }); + } + const stats = await PublicStatsService.get(); + return Response.json(stats, { + headers: { "Cache-Control": "public, max-age=300, s-maxage=300" }, + }); +} diff --git a/apps/web/src/env.js b/apps/web/src/env.js index 4f920a8..0dcdf97 100644 --- a/apps/web/src/env.js +++ b/apps/web/src/env.js @@ -25,6 +25,8 @@ export const env = createEnv({ GITHUB_SECRET: z.string().optional(), DISCORD_CLIENT_ID: z.string().optional(), DISCORD_CLIENT_SECRET: z.string().optional(), + DISCORD_LINKED_ROLES_CLIENT_ID: z.string().optional(), + DISCORD_LINKED_ROLES_CLIENT_SECRET: z.string().optional(), AWS_ACCESS_KEY: z.string(), AWS_SECRET_KEY: z.string(), BYTESEND_API_KEY: z.string().optional(), @@ -88,6 +90,8 @@ export const env = createEnv({ GITHUB_SECRET: process.env.GITHUB_SECRET, DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET, + DISCORD_LINKED_ROLES_CLIENT_ID: process.env.DISCORD_LINKED_ROLES_CLIENT_ID, + DISCORD_LINKED_ROLES_CLIENT_SECRET: process.env.DISCORD_LINKED_ROLES_CLIENT_SECRET, AWS_ACCESS_KEY: process.env.AWS_ACCESS_KEY, AWS_SECRET_KEY: process.env.AWS_SECRET_KEY, BYTESEND_API_KEY: process.env.BYTESEND_API_KEY, diff --git a/apps/web/src/server/service/discord-linked-roles-service.ts b/apps/web/src/server/service/discord-linked-roles-service.ts new file mode 100644 index 0000000..351f7f7 --- /dev/null +++ b/apps/web/src/server/service/discord-linked-roles-service.ts @@ -0,0 +1,94 @@ +import { env } from "~/env"; +import { db } from "../db"; + +const DISCORD_API = "https://discord.com/api/v10"; + +export const LINKED_ROLES_STATE_COOKIE = "bs_linked_roles_state"; + +export function linkedRolesConfigured() { + return Boolean(env.DISCORD_LINKED_ROLES_CLIENT_ID && env.DISCORD_LINKED_ROLES_CLIENT_SECRET); +} + +function redirectUri() { + return `${env.NEXTAUTH_URL.replace(/\/$/, "")}/api/discord/linked-roles/callback`; +} + +export function buildAuthorizeUrl(state: string) { + const url = new URL("https://discord.com/oauth2/authorize"); + url.searchParams.set("client_id", env.DISCORD_LINKED_ROLES_CLIENT_ID!); + url.searchParams.set("redirect_uri", redirectUri()); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", "role_connections.write identify"); + url.searchParams.set("state", state); + url.searchParams.set("prompt", "consent"); + return url.toString(); +} + +export async function exchangeCode(code: string): Promise { + const response = await fetch(`${DISCORD_API}/oauth2/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: env.DISCORD_LINKED_ROLES_CLIENT_ID!, + client_secret: env.DISCORD_LINKED_ROLES_CLIENT_SECRET!, + grant_type: "authorization_code", + code, + redirect_uri: redirectUri(), + }), + }); + if (!response.ok) { + throw new Error(`Discord token exchange failed (${response.status})`); + } + const body = (await response.json()) as { access_token?: string }; + if (!body.access_token) throw new Error("Discord returned no access token"); + return body.access_token; +} + +// Keys and types must match the metadata the support bot registers (apps/discord-bot/linked_roles.go). +// Discord expects every value as a string: booleans as "1"/"0", datetimes as ISO 8601. +export type LinkedRoleMetadata = { + customer: "1"; + paid: "0" | "1"; + emails_sent: string; + member_since: string; +}; + +export async function computeLinkedRoleMetadata(userId: number) { + const [user, memberships] = await Promise.all([ + db.user.findUnique({ where: { id: userId }, select: { name: true, createdAt: true } }), + db.teamUser.findMany({ where: { userId }, select: { teamId: true, team: { select: { plan: true } } } }), + ]); + if (!user) throw new Error("User not found"); + + const teamIds = memberships.map((membership) => membership.teamId); + const usage = teamIds.length + ? await db.dailyEmailUsage.aggregate({ where: { teamId: { in: teamIds } }, _sum: { sent: true } }) + : null; + + const metadata: LinkedRoleMetadata = { + customer: "1", + paid: memberships.some((membership) => membership.team.plan !== "FREE") ? "1" : "0", + emails_sent: String(usage?._sum.sent ?? 0), + member_since: user.createdAt.toISOString(), + }; + // Shown publicly on the member's Discord profile, so never use the email address here. + return { metadata, platformUsername: user.name?.trim() || "ByteSend user" }; +} + +export async function pushRoleConnection( + accessToken: string, + platformUsername: string, + metadata: LinkedRoleMetadata, +) { + const response = await fetch( + `${DISCORD_API}/users/@me/applications/${env.DISCORD_LINKED_ROLES_CLIENT_ID}/role-connection`, + { + method: "PUT", + headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ platform_name: "ByteSend", platform_username: platformUsername.slice(0, 100), metadata }), + }, + ); + if (!response.ok) { + throw new Error(`Discord role connection update failed (${response.status})`); + } +} diff --git a/docker/cloud/docker-compose.web.yml b/docker/cloud/docker-compose.web.yml index 79ca05b..aa57b33 100644 --- a/docker/cloud/docker-compose.web.yml +++ b/docker/cloud/docker-compose.web.yml @@ -27,6 +27,8 @@ services: # Discord OAuth (optional) - DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID} - DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET} + - DISCORD_LINKED_ROLES_CLIENT_ID=${DISCORD_LINKED_ROLES_CLIENT_ID:-} + - DISCORD_LINKED_ROLES_CLIENT_SECRET=${DISCORD_LINKED_ROLES_CLIENT_SECRET:-} # Google OAuth (optional) - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} diff --git a/docker/prod/compose.yml b/docker/prod/compose.yml index 399b916..b602f9c 100644 --- a/docker/prod/compose.yml +++ b/docker/prod/compose.yml @@ -67,6 +67,8 @@ services: - GITHUB_SECRET=${GITHUB_SECRET:-} - DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-} - DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-} + - DISCORD_LINKED_ROLES_CLIENT_ID=${DISCORD_LINKED_ROLES_CLIENT_ID:-} + - DISCORD_LINKED_ROLES_CLIENT_SECRET=${DISCORD_LINKED_ROLES_CLIENT_SECRET:-} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}