Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <NEXTAUTH_URL>/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=
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ node_modules
.docs
.docker
apps/status
apps/discord-bot

# Testing
coverage
Expand Down
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions apps/web/src/app/(marketing)/discord/linked-roles/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="min-h-[70vh] bg-background text-foreground">
<div className="mx-auto max-w-xl px-6 py-24">
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-border/60 bg-muted/40 px-4 py-1.5 text-xs text-muted-foreground">
<span className={`h-1.5 w-1.5 rounded-full ${status === "linked" ? "bg-[#00c3d6]" : "bg-muted-foreground"}`} />
Discord linked roles
</div>
<h1 className="text-3xl font-bold tracking-tight">{copy.title}</h1>
<p className="mt-3 text-muted-foreground">{copy.body}</p>

{linked && (
<dl className="mt-8 divide-y divide-border/40 rounded-2xl border border-border/50 bg-muted/20">
{[
["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]) => (
<div key={label} className="flex items-center justify-between gap-4 px-5 py-3 text-sm">
<dt className="text-muted-foreground">{label}</dt>
<dd className="font-medium tabular-nums">{value}</dd>
</div>
))}
</dl>
)}

<p className="mt-8 text-sm text-muted-foreground">
Roles reflect your account at the moment you connect. Upgraded or sent more since?{" "}
<Link href="/api/discord/linked-roles" className="text-primary hover:underline">
Connect again
</Link>{" "}
to refresh them.
</p>
</div>
</main>
);
}
51 changes: 51 additions & 0 deletions apps/web/src/app/api/discord/linked-roles/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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");
}
}
38 changes: 38 additions & 0 deletions apps/web/src/app/api/discord/linked-roles/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
15 changes: 15 additions & 0 deletions apps/web/src/app/api/stats/route.ts
Original file line number Diff line number Diff line change
@@ -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" },
});
}
4 changes: 4 additions & 0 deletions apps/web/src/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down
94 changes: 94 additions & 0 deletions apps/web/src/server/service/discord-linked-roles-service.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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})`);
}
}
2 changes: 2 additions & 0 deletions docker/cloud/docker-compose.web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions docker/prod/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}

Expand Down
Loading