From 13d7235887378cf3e61eb684cac29cb6d211b9e3 Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Sun, 21 Jun 2026 21:17:04 +0200 Subject: [PATCH] feat(bot): per-guild posting appearance via webhook (name + logo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shared multi-tenant bot can't have a per-guild profile, so let each guild brand the messages the bot *posts* instead. When a guild sets a "posting name" (Bot settings), the bot posts forms and the review embed through a bot-owned webhook using that name + the guild's branding logo as the avatar. Components still work (the webhook is application-owned); falls back to a normal bot message on threads or without the Manage Webhooks permission. - shared: `botConfig.postName` (optional, 1–80 chars). - bot: `postBranded(channel, guildId, payload)` helper; used by `/forms post` and the new-submission review embed (`deliverReview`). - web: posting-name field in the Bot settings form; bot-config PATCH already passes it through. i18n DE/EN. Avatar reuses the guild's existing branding logo (served public WebP), so no new upload path. --- apps/bot/src/forms.ts | 3 +- apps/bot/src/notifications.ts | 3 +- apps/bot/src/posting.ts | 64 +++++++++++++++++++ .../src/components/bot/bot-config-form.tsx | 6 ++ apps/web/src/i18n/dictionaries.ts | 4 ++ packages/shared/src/bot-config.ts | 6 ++ 6 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 apps/bot/src/posting.ts 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}

+ + + +