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
3 changes: 2 additions & 1 deletion apps/bot/src/forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "discord.js";

import { config } from "./config.js";
import { postBranded } from "./posting.js";
import { dashboardUrl, formUrl } from "./urls.js";

const MSK_GREEN = 0x00e676;
Expand Down Expand Up @@ -149,7 +150,7 @@ export async function handleFormsCommand(
new ButtonBuilder().setStyle(ButtonStyle.Link).setLabel("Open form").setURL(url),
);

await channel.send({ embeds: [embed], components: [row] });
await postBranded(channel, guildId, { embeds: [embed], components: [row] });
await interaction.reply({
content: `✅ Posted **${form.title}** in <#${channel.id}>.`,
flags: MessageFlags.Ephemeral,
Expand Down
3 changes: 2 additions & 1 deletion apps/bot/src/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "discord.js";

import { config } from "./config.js";
import { postBranded } from "./posting.js";
import { dashboardSubmissionUrl, statusUrl } from "./urls.js";

const MSK_GREEN = 0x00e676;
Expand Down Expand Up @@ -111,7 +112,7 @@ async function deliverReview(client: Client, row: PendingRow): Promise<boolean>
new ButtonBuilder().setStyle(ButtonStyle.Link).setLabel("Open in dashboard").setURL(url),
);

await channel.send({ embeds: [embed], components: [buttons] });
await postBranded(channel, row.guildId, { embeds: [embed], components: [buttons] });
return true;
} catch (err) {
if (err instanceof DiscordAPIError && CHANNEL_GONE.includes(Number(err.code))) {
Expand Down
64 changes: 64 additions & 0 deletions apps/bot/src/posting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { prisma } from "@msk-forms/db";
import { parseBotConfig } from "@msk-forms/shared";
import type { ActionRowBuilder, ButtonBuilder, EmbedBuilder, SendableChannels } from "discord.js";

import { config } from "./config.js";

const WEBHOOK_NAME = "MSK Forms";

interface BrandedPayload {
embeds: EmbedBuilder[];
components?: ActionRowBuilder<ButtonBuilder>[];
}

/**
* Post a message to a channel using the guild's per-guild appearance, if set:
* when `botConfig.postName` is configured, send through a bot-owned webhook with
* that display name and the guild's branding logo as the avatar. Components
* still work because the webhook is owned by this application. Falls back to a
* normal bot message when no appearance is set, on threads, or if the bot lacks
* Manage-Webhooks permission.
*
* `guildId` is the MSK Forms guild UUID (not the Discord guild id).
*/
export async function postBranded(
channel: SendableChannels,
guildId: string,
payload: BrandedPayload,
): Promise<void> {
const guild = await prisma.guild.findUnique({
where: { id: guildId },
select: { botConfig: true, branding: true },
});
const postName = parseBotConfig(guild?.botConfig).postName;

// No per-guild appearance configured → post as the bot.
if (!postName) {
await channel.send(payload);
return;
}

const logoKey = (guild?.branding as { logoKey?: string } | null)?.logoKey;
const avatarURL = logoKey ? `${config.apiBaseUrl}/api/guilds/${guildId}/logo` : undefined;

try {
// Webhooks only exist on standard text channels, not threads.
if (!("fetchWebhooks" in channel) || !("createWebhook" in channel)) {
throw new Error("channel has no webhook support");
}
const selfId = channel.client.user?.id;
const hooks = await channel.fetchWebhooks();
const existing = hooks.find((w) => w.owner?.id === selfId && w.name === WEBHOOK_NAME);
const hook = existing ?? (await channel.createWebhook({ name: WEBHOOK_NAME }));

await hook.send({
username: postName.slice(0, 80),
avatarURL,
embeds: payload.embeds,
components: payload.components,
});
} catch (err) {
console.warn("[bot] branded webhook post failed, posting as bot:", (err as Error).message);
await channel.send(payload);
}
}
6 changes: 6 additions & 0 deletions apps/web/src/components/bot/bot-config-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function BotConfigForm({
const router = useRouter();
const [channel, setChannel] = useState(initial.reviewChannelId ?? "");
const [role, setRole] = useState(initial.acceptedRoleId ?? "");
const [postName, setPostName] = useState(initial.postName ?? "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
Expand All @@ -33,6 +34,7 @@ export function BotConfigForm({
const body: Record<string, string> = {};
if (channel.trim()) body.reviewChannelId = channel.trim();
if (role.trim()) body.acceptedRoleId = role.trim();
if (postName.trim()) body.postName = postName.trim();
const res = await fetch(`/api/guilds/${guildId}/bot-config`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
Expand Down Expand Up @@ -66,6 +68,10 @@ export function BotConfigForm({
</Field>
<p className="text-xs text-muted-foreground">{t.idHint}</p>

<Field label={t.postName} hint={t.postNameHint}>
<Input value={postName} placeholder="MSK Forms" onChange={onEdit(setPostName)} />
</Field>

<div className="flex items-center gap-3">
<Button type="button" onClick={save} disabled={saving}>
{saving ? t.saving : t.save}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/i18n/dictionaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ const en = {
acceptedRole: "Accepted role ID",
acceptedRoleHint: "Granted to the applicant when a submission is accepted.",
idHint: "Enable Developer Mode in Discord (Settings → Advanced), then right-click a channel/role → Copy ID.",
postName: "Posting name",
postNameHint: "When set, the bot posts forms & review embeds under this name with your logo (via a webhook). Needs the Manage Webhooks permission. Leave empty to post as the bot.",
save: "Save", saving: "Saving…", saved: "Saved.",
errSave: "Could not save bot settings.",
noPerm: "You don’t have permission to edit bot settings.",
Expand Down Expand Up @@ -330,6 +332,8 @@ const de: Dictionary = {
acceptedRole: "Annahme-Rollen-ID",
acceptedRoleHint: "Wird dem Bewerber bei Annahme der Einreichung vergeben.",
idHint: "Aktiviere den Entwicklermodus in Discord (Einstellungen → Erweitert), dann Rechtsklick auf Kanal/Rolle → ID kopieren.",
postName: "Anzeigename beim Posten",
postNameHint: "Wenn gesetzt, postet der Bot Formulare & Review-Embeds unter diesem Namen mit deinem Logo (per Webhook). Benötigt die Berechtigung zum Verwalten von Webhooks. Leer lassen, um als Bot zu posten.",
save: "Speichern", saving: "Wird gespeichert…", saved: "Gespeichert.",
errSave: "Bot-Einstellungen konnten nicht gespeichert werden.",
noPerm: "Du hast keine Berechtigung, die Bot-Einstellungen zu bearbeiten.",
Expand Down
6 changes: 6 additions & 0 deletions packages/shared/src/bot-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ export const botConfigSchema = z.object({
reviewChannelId: snowflake.optional(),
/** Role granted to the applicant when a submission is accepted. */
acceptedRoleId: snowflake.optional(),
/**
* When set, the bot posts forms/embeds through a webhook using this display
* name and the guild's branding logo as the avatar (per-guild appearance).
* Empty/unset → posts as the bot itself.
*/
postName: z.string().min(1).max(80).optional(),
});

export type BotConfig = z.infer<typeof botConfigSchema>;
Expand Down