From fd939af0b7aad7585e8ce2e35cc527f7aa9391f4 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Wed, 12 Nov 2025 11:49:10 -0800 Subject: [PATCH] This is much better --- .gitignore | 4 +- packages/spike/README.md | 12 +-- packages/spike/local-spike.db | Bin 24576 -> 0 bytes packages/spike/package.json | 3 +- packages/spike/src/gitea/webhook-handler.ts | 106 ++++++++++++++------ packages/spike/src/server.tsx | 14 +-- 6 files changed, 93 insertions(+), 46 deletions(-) delete mode 100644 packages/spike/local-spike.db diff --git a/.gitignore b/.gitignore index 853cc52..bbfed7c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store -.nano-remix \ No newline at end of file +.nano-remix + +local-spike.db \ No newline at end of file diff --git a/packages/spike/README.md b/packages/spike/README.md index ef597df..b7e6759 100644 --- a/packages/spike/README.md +++ b/packages/spike/README.md @@ -7,10 +7,8 @@ 1. Install deps with `bun install`. 1. Setup a dummy Discord bot and get your DISCORD*TOKEN and CLIENT_ID at https://discord.com/developers/applications *(Corey doesn't remember how to do this all this, so if pair with him if you run into problems. Then document it in here). 1. `cp .env.example .env` and fill in the values for everything. -1. Find a Discord channel id to use for testing and set it in `.env` as `CHANNEL_ID`. -1. There is a very simple auth server you can use to invite the bot to your server. Run it with `bun authServer`. -1. Run the bot with `bun bot:discord` - -# Before deploying - -- Add the env var for the gitea token to render +1. Find a Discord channel id to use for testing and set it in `.env` as `DISCORD_CLIENT_ID`. +1. Run `bun run subdomain:dev` to start the server. +1. Visit `localhost:3000/discord/auth` to authorize the bot. +1. Use `bun run grok` to start an ngrok tunnel to your local server for webhooks. +1. Setup a Gitea webhook to point to your ngrok url at `https://git.nose.space/user/settings/hooks`. **remember the url ends with /gitea/webhook!** diff --git a/packages/spike/local-spike.db b/packages/spike/local-spike.db deleted file mode 100644 index a9e59a6b13be2bece5de2d63b32af46ea888a126..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24576 zcmeI2+inv_7=YI~I*uJjqEfDqRstka*k;Z%yEjb=3$jQ|FpgTJN{)#au!>{Hb_Dbo zo8DI95qJb{deJ-j26==$0+&^<`mclO?mARj^`?#fk!Zr)p27m5)(Z{}gZ;ha z?%a)vzftw=>c$tFzMWvN?$=wvb*Q;IT$!F znqRqiG4u21Bei?&Rx1Fl2rQ3z?U#)12(~V0@-xy5K;}R6Ly~Bz1#M`z$R`ZU3;F%)wpBbXzAko( z>&4}zlchIHM@!pFcHwQ|UA~iloj)$TDv-i%e!XDjmlsbK-z*+2+PQa&+qt*7SGnCB zSvbyF3$GW}7di{e*^}&>>``_bf?`1fXaEhM0W^RH{+9-vVPVeV8S8XrVkBpj8A-L4 zQeBSDCz!`!KH)mUe2ixbcvrdv-X?i2o#A4f=UNL5w4i2^XAnOZA{zzMGQorQ{D%@=sIp zt(1H-$}?>^Rooc3f61lj^Ph(6NeScoB*rUBxCtLwt`L(vHO}C1jEDRT^9|O-{EYYn z;y;S=GkTo { @@ -11,19 +11,20 @@ export const handleGiteaWebhook = async (payload: GiteaWebhook) => { } if ("pull_request" in payload) { + // TODO: states to handle still "closed" "reopened" if (payload.action === "opened") { await handlePullRequestOpened(payload) } else if (payload.action === "edited") { await handlePullRequestEdited(payload) } - console.log(`✅ pull request webhook action: ${payload.action}`) + console.log(`✅ pull request webhook action: ${payload.action} #${payload.number}`) } else if ("comment" in payload && "issue" in payload) { if (payload.action === "created") { await handleIssueCommentCreated(payload) } else if (payload.action === "edited") { await handleIssueCommentEdited(payload) } - console.log(`✅ comment webhook action: ${payload.action}`) + console.log(`✅ comment webhook action: ${payload.action} on #${payload.issue.number}`) } else { assertNever(payload, "Unhandled Gitea webhook payload") } @@ -40,20 +41,21 @@ const handlePullRequestOpened = async (payload: PullRequestWebhook) => { ) } - // Create thread name - const authorMention = mapGiteaUserToDiscord(pullRequest.user.login) - const threadName = `[${repository.name}] ${pullRequest.title} - ${authorMention}` + // Post the PR details as first message in thread + const webhook = await getOrCreateWebhook(channel) + const prMessage = formatPrMessage(pullRequest, repository.full_name) - // Create the thread - const thread = await channel.threads.create({ - name: threadName.slice(0, 100), // Discord has 100 char limit on thread names - reason: `PR #${pullRequest.number} opened`, + const message = await webhook.send({ + content: prMessage, + username: mapGiteaUserToDiscord(pullRequest.user.login), + avatarURL: await mapGiteaUserToAvatarURL(pullRequest.user.login), }) - // Post the PR details as first message in thread - const prMessage = formatPrMessage(pullRequest) - - const message = await thread.send(prMessage) + const threadName = `[${repository.name}] ${pullRequest.title}` + const thread = await message.startThread({ + name: threadName.slice(0, 100), + reason: `PR #${pullRequest.number} opened`, + }) insertPR(pullRequest.id, repository.full_name, pullRequest.number, thread.id, message.id) } @@ -77,7 +79,7 @@ const handlePullRequestEdited = async (payload: PullRequestWebhook) => { ) } - const updatedMessage = formatPrMessage(pullRequest) + const updatedMessage = formatPrMessage(pullRequest, payload.repository.full_name) await prMessage.edit(updatedMessage) } @@ -90,8 +92,6 @@ const handleIssueCommentCreated = async (payload: IssueCommentWebhook) => { // Check if this comment already exists (came from Discord originally) try { getCommentByGiteaId(comment.id) - // Comment exists - this came from Discord, skip posting back - console.log(`⏭️ Skipping comment ${comment.id} - already exists (came from Discord)`) return } catch { // Comment doesn't exist - this is a new Gitea comment, post to Discord @@ -106,7 +106,13 @@ const handleIssueCommentCreated = async (payload: IssueCommentWebhook) => { ) } - const message = await thread.send(await formatComment(comment)) + const webhook = await getOrCreateWebhook(thread) + const message = await webhook.send({ + content: await formatComment(comment), + username: mapGiteaUserToDiscord(comment.user.login), + avatarURL: await mapGiteaUserToAvatarURL(comment.user.login), + threadId: thread.id, + }) insertComment(comment.id, message.id, issue.id) } @@ -130,27 +136,52 @@ const handleIssueCommentEdited = async (payload: IssueCommentWebhook) => { throw new Error(`Discord message ${commentRow.discord_message_id} not found`) } - await discordMessage.edit(await formatComment(comment)) + const webhook = await getOrCreateWebhook(thread) + const content = await formatComment(comment) + await webhook.editMessage(commentRow.discord_message_id, { content }) } -const formatPrMessage = (pullRequest: GiteaPullRequest): string => { - const authorMention = mapGiteaUserToDiscord(pullRequest.user.login) +const getOrCreateWebhook = async (threadOrChannel: ThreadChannel | Channel) => { + const channel = threadOrChannel.isThread() ? threadOrChannel.parent : threadOrChannel + if (!channel || channel.type !== ChannelType.GuildText) { + throw new Error(`Channel ${threadOrChannel.id} is not a text channel`) + } + const webhooks = await channel.fetchWebhooks() + let webhook = webhooks.find((wh) => wh.owner?.id === client.user?.id) + + if (!webhook) { + webhook = await channel.createWebhook({ + name: "Gitea Bridge", + reason: "Proxy Gitea comments to Discord", + }) + } + + return webhook +} + +const formatPrMessage = (pullRequest: GiteaPullRequest, repositoryFullName: string): string => { + const body = pullRequest.body || "_empty_" return ` - -${pullRequest.html_url} -**${pullRequest.title}** by __${authorMention}__ - -> ${pullRequest.body || "_empty_"} - +> ### [${pullRequest.title}](<${pullRequest.html_url}>) +> **${repositoryFullName}** +> +${body + .split("\n") + .map((line) => `> ${line}`) + .join("\n")} ` } -const formatComment = async (comment: { body: string; user: GiteaUser }): Promise => { - const authorName = mapGiteaUserToDiscord(comment.user.login) - const messageBody = await convertMentionsToDiscord(comment.body) +const formatComment = async (comment: { body: string; user: GiteaUser }) => { + return convertMentionsToDiscord(comment.body) +} - return `**${authorName}**: ${messageBody}` +const mapGiteaUserToAvatarURL = async (username: string) => { + const discordUserName = mapGiteaUserToDiscord(username) + const discordUser = await getDiscordUser(discordUserName) + + return discordUser?.avatarURL() || undefined } const mapGiteaUserToDiscord = (username: string): string => { @@ -160,6 +191,19 @@ const mapGiteaUserToDiscord = (username: string): string => { return discordUserId || `💥${username}💥` } +const getDiscordUser = async (discordUsername: string) => { + const cachedUser = client.users.cache.find((user) => user.username === discordUsername) + if (cachedUser) return cachedUser + + const channelId = getConfig("channelId") + const channel = await client.channels.fetch(channelId) + if (!channel || !("guild" in channel)) return undefined + + const members = await channel.guild.members.fetch({ query: discordUsername, limit: 1 }) + const member = members.first() + return member?.user ?? undefined +} + // Cache Discord username -> user ID const discordUserIdCache = new Map() diff --git a/packages/spike/src/server.tsx b/packages/spike/src/server.tsx index 88be691..475d4fd 100644 --- a/packages/spike/src/server.tsx +++ b/packages/spike/src/server.tsx @@ -1,6 +1,6 @@ import { serve } from "bun" import { handleGiteaWebhook } from "./gitea/webhook-handler" -import "./discord/index" // Initialize Discord bot +import "./discord/index" // Make suer the discord client is initialized interface ErrorLog { timestamp: string @@ -14,8 +14,9 @@ const errors: ErrorLog[] = [] const server = serve({ port: parseInt(process.env.PORT || "3000"), routes: { - "/": async () => { - return new Response("🌵") + "/": { + GET: () => new Response("🌵"), + POST: () => new Response("Use /gitea/webhook for POST requests", { status: 400 }), }, "/gitea/webhook": { POST: async (req) => { @@ -46,13 +47,14 @@ const server = serve({ }, }, "/discord/auth": async () => { + const permissions = 536870912 // from https://discord.com/developers/applications + const authorizeUrl = `https://discord.com/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT_ID}&scope=bot&permissions=${permissions}` + const html = (

Authenticate spike

- - Authorize - + Authorize )