diff --git a/apps/bot/src/forms.ts b/apps/bot/src/forms.ts index d8a4599..fa8c7d7 100644 --- a/apps/bot/src/forms.ts +++ b/apps/bot/src/forms.ts @@ -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; @@ -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, diff --git a/apps/bot/src/notifications.ts b/apps/bot/src/notifications.ts index b30b4e9..e7b3e60 100644 --- a/apps/bot/src/notifications.ts +++ b/apps/bot/src/notifications.ts @@ -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; @@ -111,7 +112,7 @@ async function deliverReview(client: Client, row: PendingRow): Promise 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))) { diff --git a/apps/bot/src/posting.ts b/apps/bot/src/posting.ts new file mode 100644 index 0000000..d85358f --- /dev/null +++ b/apps/bot/src/posting.ts @@ -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[]; +} + +/** + * 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 { + 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); + } +} diff --git a/apps/web/src/components/bot/bot-config-form.tsx b/apps/web/src/components/bot/bot-config-form.tsx index 2f25ec2..81e43eb 100644 --- a/apps/web/src/components/bot/bot-config-form.tsx +++ b/apps/web/src/components/bot/bot-config-form.tsx @@ -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(null); const [saved, setSaved] = useState(false); @@ -33,6 +34,7 @@ export function BotConfigForm({ const body: Record = {}; 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" }, @@ -66,6 +68,10 @@ export function BotConfigForm({

{t.idHint}

+ + + +