From f156e91bcd0395a8a73c8b0902696a055e9fc487 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Thu, 13 Nov 2025 10:59:49 -0800 Subject: [PATCH] this is good enough --- packages/spike/src/config.ts | 6 +- packages/spike/src/db.ts | 63 +-- packages/spike/src/discord/events.ts | 17 +- packages/spike/src/gitea/api.ts | 4 +- packages/spike/src/gitea/helpers.ts | 150 +++++++ packages/spike/src/gitea/webhook-handler.ts | 441 ++++++++------------ 6 files changed, 349 insertions(+), 332 deletions(-) create mode 100644 packages/spike/src/gitea/helpers.ts diff --git a/packages/spike/src/config.ts b/packages/spike/src/config.ts index 0615511..e515116 100644 --- a/packages/spike/src/config.ts +++ b/packages/spike/src/config.ts @@ -21,13 +21,13 @@ const config = { dev: "/Users/corey/code/tmp/data", prod: "/var/data", }, - userMappings: { + giteaToDiscordUserMappings: { dev: { probablycorey: "corey", } as Record, prod: { - probablycorey: "probablycorey", - defunkt: "defunkt.gg", + probablycorey: "corey", + defunkt: "defunkt", } as Record, }, } diff --git a/packages/spike/src/db.ts b/packages/spike/src/db.ts index 403bbe5..500bd13 100644 --- a/packages/spike/src/db.ts +++ b/packages/spike/src/db.ts @@ -18,21 +18,19 @@ db.run(` CREATE TABLE IF NOT EXISTS comments ( gitea_comment_id INTEGER PRIMARY KEY, discord_message_id TEXT NOT NULL, - gitea_pr_id INTEGER NOT NULL, - parent_comment_id INTEGER, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (gitea_pr_id) REFERENCES prs(gitea_pr_id) - ); - - CREATE TABLE IF NOT EXISTS participants ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - gitea_pr_id INTEGER NOT NULL, - discord_user_id TEXT NOT NULL, - UNIQUE(gitea_pr_id, discord_user_id), - FOREIGN KEY (gitea_pr_id) REFERENCES prs(gitea_pr_id) + discord_thread_id TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); `) +// Migration: Add discord_thread_id to existing comments tables +try { + db.run(`ALTER TABLE comments ADD COLUMN discord_thread_id TEXT`) + console.log(`📊 Migration: Added discord_thread_id column to comments table`) +} catch (e) { + // Column already exists, ignore error +} + console.log(`📊 Database initialized at ${dbPath}`) // PR operations @@ -63,9 +61,10 @@ export const getPRByGiteaId = (giteaPrId: number) => { export const getPRByDiscordThreadId = (discordThreadId: string) => { const row = db - .query<{ gitea_pr_id: number; repo: string; pr_number: number }, string>( - `SELECT gitea_pr_id, repo, pr_number FROM prs WHERE discord_thread_id = ?` - ) + .query< + { gitea_pr_id: number; repo: string; pr_number: number; discord_thread_id: string }, + string + >(`SELECT gitea_pr_id, repo, pr_number, discord_thread_id FROM prs WHERE discord_thread_id = ?`) .get(discordThreadId) // Don't throw - we use this to check if it's a PR thread @@ -76,43 +75,21 @@ export const getPRByDiscordThreadId = (discordThreadId: string) => { export const insertComment = ( giteaCommentId: number, discordMessageId: string, - giteaPrId: number, - parentCommentId?: number + discordThreadId: string ) => { db.run( - `INSERT INTO comments (gitea_comment_id, discord_message_id, gitea_pr_id, parent_comment_id) - VALUES (?, ?, ?, ?)`, - [giteaCommentId, discordMessageId, giteaPrId, parentCommentId || null] + `INSERT INTO comments (gitea_comment_id, discord_message_id, discord_thread_id) + VALUES (?, ?, ?)`, + [giteaCommentId, discordMessageId, discordThreadId] ) } export const getCommentByGiteaId = (giteaCommentId: number) => { const row = db - .query<{ discord_message_id: string }, number>( - `SELECT discord_message_id FROM comments WHERE gitea_comment_id = ?` + .query<{ discord_message_id: string; discord_thread_id: string }, number>( + `SELECT discord_message_id, discord_thread_id FROM comments WHERE gitea_comment_id = ?` ) .get(giteaCommentId) - if (!row) { - throw new Error(`No database entry found for Gitea comment ID ${giteaCommentId}`) - } - return row } - -// Participant operations -export const addParticipant = (giteaPrId: number, discordUserId: string) => { - db.run( - `INSERT OR IGNORE INTO participants (gitea_pr_id, discord_user_id) - VALUES (?, ?)`, - [giteaPrId, discordUserId] - ) -} - -export const getParticipants = (giteaPrId: number) => { - return db - .query<{ discord_user_id: string }, number>( - `SELECT discord_user_id FROM participants WHERE gitea_pr_id = ?` - ) - .all(giteaPrId) -} diff --git a/packages/spike/src/discord/events.ts b/packages/spike/src/discord/events.ts index 5dbf119..0d955be 100644 --- a/packages/spike/src/discord/events.ts +++ b/packages/spike/src/discord/events.ts @@ -2,7 +2,7 @@ import { ActivityType, type Client } from "discord.js" import { runCommand } from "./commands" import { getPRByDiscordThreadId } from "../db" import { createPRComment } from "../gitea/api" -import { getConfig } from "../config" +import { convertUsername, convertDiscordMentionsToGitea } from "../gitea/helpers" export const listenForEvents = (client: Client) => { client.on("interactionCreate", async (interaction) => { @@ -16,7 +16,9 @@ export const listenForEvents = (client: Client) => { // Check if this is a PR thread const prData = getPRByDiscordThreadId(msg.channel.id) if (prData) { - const message = `**${mapDiscordUserToGitea(msg.author.username)}**: ${msg.content}` + const username = convertUsername({ discordUsername: msg.author.displayName }) + const messageContent = await convertDiscordMentionsToGitea(msg.content) + const message = `**${username}**: ${messageContent}` await createPRComment(prData, message, msg.id) } @@ -51,14 +53,3 @@ export const listenForEvents = (client: Client) => { console.warn("Discord client warning:", info) }) } - -const mapDiscordUserToGitea = (username: string): string => { - const userMappings = getConfig("userMappings") - for (const [giteaUser, discordUser] of Object.entries(userMappings)) { - if (discordUser === username) { - return giteaUser - } - } - - return username -} diff --git a/packages/spike/src/gitea/api.ts b/packages/spike/src/gitea/api.ts index e010961..ee99863 100644 --- a/packages/spike/src/gitea/api.ts +++ b/packages/spike/src/gitea/api.ts @@ -14,7 +14,7 @@ type CreateCommentResponse = { } export const createPRComment = async ( - prData: { gitea_pr_id: number; repo: string; pr_number: number }, + prData: { gitea_pr_id: number; repo: string; pr_number: number; discord_thread_id: string }, body: string, discordMessageId: string ): Promise => { @@ -44,7 +44,7 @@ export const createPRComment = async ( const comment = (await response.json()) as CreateCommentResponse // Store the mapping immediately so webhook handler knows this came from Discord - insertComment(comment.id, discordMessageId, prData.gitea_pr_id) + insertComment(comment.id, discordMessageId, prData.discord_thread_id) return comment } diff --git a/packages/spike/src/gitea/helpers.ts b/packages/spike/src/gitea/helpers.ts new file mode 100644 index 0000000..779d22d --- /dev/null +++ b/packages/spike/src/gitea/helpers.ts @@ -0,0 +1,150 @@ +import { client } from "../discord/index" +import { getPRByGiteaId } from "../db" +import { getConfig } from "../config" +import { ChannelType, TextChannel } from "discord.js" +import { assertNever } from "@workshop/shared/utils" +import { fetchPR } from "./api" +import type { Gitea } from "./types" +import { PRHandler } from "./webhook-handler" + +export const ensurePRCreatedOnDiscord = async ( + pullRequest: Gitea.PullRequest | Gitea.Issue, + repository: Gitea.Repository +) => { + const prNumber = pullRequest.number + + const exists = getPRByGiteaId(pullRequest.id) + if (exists) return true + + const pr = await fetchPR(repository.full_name, prNumber) + await PRHandler.handleOpened(pr, repository) + + return true +} + +export const getThread = async (pullRequest: Gitea.PullRequest | Gitea.Issue) => { + const row = getPRByGiteaId(pullRequest.id) + if (!row) { + return + } + + const thread = await client.channels.fetch(row.discord_thread_id) + if (!thread) { + return + } + + if (!thread.isThread()) { + throw new Error(`Discord channel ${row.discord_thread_id} is not a thread for PR #${pullRequest.number}`) + } + + return thread +} + +type PullRequestState = "closed" | "merged" | "opened" +const prState = (pullRequest: Gitea.PullRequest): { icon: string; label: string } => { + let state: PullRequestState = "opened" + if (pullRequest.merged) { + state = "merged" + } else if (pullRequest.state === "closed") { + state = "closed" + } + + let stateInfo: { icon: string; label: string } + if (state === "merged") { + stateInfo = { icon: "🟣", label: "merged" } + } else if (state === "closed") { + stateInfo = { icon: "🔴", label: "closed" } + } else if (state === "opened") { + stateInfo = { icon: "🟢", label: "opened" } + } else { + assertNever(state, `Unhandled pull request state: ${state}`) + } + return stateInfo +} + +export const threadName = (pullRequest: Gitea.PullRequest, repository: Gitea.Repository): string => { + const stateInfo = prState(pullRequest) + const name = `${stateInfo.icon} [${repository.name}] ${pullRequest.title}` + return name.slice(0, 100) +} + +export const getWebhook = async () => { + const channel = await getChannel() + 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 +} + +export const getDiscordAvatarUrl = async (discordUsername: string) => { + const discordUser = await getDiscordUser(discordUsername) + return discordUser?.avatarURL() || undefined +} + +export const convertUsername = (opts: { discordUsername: string } | { giteaUsername: string }): string => { + const giteaToDiscordUserMappings = getConfig("giteaToDiscordUserMappings") + let convertedUsername: string | undefined + let username: string + if ("giteaUsername" in opts) { + username = opts.giteaUsername + convertedUsername = giteaToDiscordUserMappings[username] + } else { + username = opts.discordUsername + const entries = Object.entries(giteaToDiscordUserMappings) + const result = entries.find(([_, discordUsername]) => discordUsername === username) + if (result) convertedUsername = result[0] + } + + return convertedUsername || `💥${username}💥` +} + +export const getDiscordUser = async (discordUsername: string) => { + const cachedUser = client.users.cache.find((user) => user.username === discordUsername) + if (cachedUser) return cachedUser + + const channel = await getChannel() + const members = await channel.guild.members.fetch({ query: discordUsername, limit: 1 }) + const member = members.first() + return member?.user ?? undefined +} + +export const convertDiscordMentionsToGitea = async (content: string): Promise => { + const mentionRegex = /<@(\d+)>/g + const mentions = [...content.matchAll(mentionRegex)] + + let result = content + for (const match of mentions) { + const userId = match[1]! + const user = await client.users.fetch(userId).catch(() => null) + if (!user) continue + + const giteaUsername = convertUsername({ discordUsername: user.displayName }) + console.log(`🌭`, { userId, user, giteaUsername }) + result = result.replace(match[0], `@${giteaUsername}`) + } + + return result +} + +let channel: TextChannel | null = null +const getChannel = async (): Promise => { + if (channel) return channel + + const channelId = getConfig("channelId") + const foundChannel = await client.channels.fetch(channelId) + if (foundChannel?.type !== ChannelType.GuildText) { + throw new Error( + `Discord channel ${channelId} (from config.ts) is type ${foundChannel?.type}, expected GuildText (0)` + ) + } + + channel = foundChannel as TextChannel + return channel +} diff --git a/packages/spike/src/gitea/webhook-handler.ts b/packages/spike/src/gitea/webhook-handler.ts index de5eb5f..9310b9a 100644 --- a/packages/spike/src/gitea/webhook-handler.ts +++ b/packages/spike/src/gitea/webhook-handler.ts @@ -1,304 +1,203 @@ -import { client } from "../discord/index" -import { insertPR, getPRByGiteaId, insertComment, getCommentByGiteaId } from "../db" -import { getConfig } from "../config" -import { ChannelType, ThreadChannel, type Channel } from "discord.js" -import { assertNever } from "@workshop/shared/utils" -import { fetchPR } from "./api" +import { insertPR, insertComment, getCommentByGiteaId } from "../db" +import { + ensurePRCreatedOnDiscord as ensureThreadExists, + getDiscordUser, + getWebhook, + getThread, + getDiscordAvatarUrl, + convertUsername, + threadName, +} from "./helpers" import type { Gitea } from "./types" +import { getConfig } from "../config" export const handleGiteaWebhook = async (payload: Gitea.Webhook) => { const isDev = process.env.NODE_ENV !== "production" if (!isDev && payload.repository.name === "ignore-me") return - if ("pull_request" in payload) { - const { pull_request: pullRequest, repository } = payload + if (PRHandler.canHandle(payload)) { + await PRHandler.handle(payload) + } else if (CommentHandler.canHandle(payload)) { + await CommentHandler.handle(payload) + } +} - if (payload.action === "opened") { - await handlePullRequestOpened(pullRequest, repository) - } else if (payload.action === "edited") { - await ensurePRCreatedOnDiscord(pullRequest, repository) - await handlePullRequestEdited(pullRequest, repository) - } else if (payload.action === "closed" || payload.action === "reopened") { - await ensurePRCreatedOnDiscord(pullRequest, repository) - await handlePullRequestStateChange(pullRequest, repository) +export class PRHandler { + static canHandle(payload: Gitea.Webhook): payload is Gitea.PullRequestWebhook { + return "pull_request" in payload + } + + static async handle(payload: Gitea.PullRequestWebhook) { + const { pull_request: pullRequest, repository, action } = payload + + if (action === "opened") { + await this.handleOpened(pullRequest, repository) + } else if (action === "edited") { + await this.handleEdited(pullRequest, repository) + } else if (action === "closed" || action === "reopened") { + await this.handleStateChange(pullRequest, repository) } - console.log(`✅ pull request webhook action: ${payload.action} #${payload.number}`) - } else if ("comment" in payload && "issue" in payload) { - const { issue, comment, repository } = payload + console.log(`✅ pull request webhook action: ${action} #${payload.number}`) + } + + static async handleOpened(pullRequest: Gitea.PullRequest, repository: Gitea.Repository) { + // Post the PR details as first message in thread + const webhook = await getWebhook() + const prMessage = this.formatPrBody(pullRequest, repository.full_name) + const discordUsername = convertUsername({ giteaUsername: pullRequest.user.login }) + + const message = await webhook.send({ + content: prMessage, + username: discordUsername, + avatarURL: await getDiscordAvatarUrl(discordUsername), + }) + + const thread = await message.startThread({ + name: threadName(pullRequest, repository), + reason: `PR #${pullRequest.number} opened`, + }) + + insertPR(pullRequest.id, repository.full_name, pullRequest.number, thread.id, message.id) + } + + static async handleEdited(pullRequest: Gitea.PullRequest, repository: Gitea.Repository) { + const thread = await getThread(pullRequest) + if (!thread) { + this.handleOpened(pullRequest, repository) + return + } + + const prBody = await thread.fetchStarterMessage() + if (!prBody) { + throw new Error(`Could not fetch PR body for #${pullRequest.number}`) + } + + const updatedMessage = this.formatPrBody(pullRequest, repository.full_name) + thread.setName(threadName(pullRequest, repository)) + const webhook = await getWebhook() + await webhook.editMessage(prBody, { content: updatedMessage }) + } + + static async handleStateChange(pullRequest: Gitea.PullRequest, repository: Gitea.Repository) { + const thread = await getThread(pullRequest) + if (!thread) { + this.handleOpened(pullRequest, repository) + return + } + + await thread.setName(threadName(pullRequest, repository)) + } + + static formatPrBody(pullRequest: Gitea.PullRequest, repositoryFullName: string): string { + const body = pullRequest.body || "_empty_" + return ` + > ### [${pullRequest.title}](<${pullRequest.html_url}>) + > **${repositoryFullName}** + > + ${body + .split("\n") + .map((line) => `> ${line}`) + .join("\n")} + ` + } +} + +class CommentHandler { + static canHandle(payload: Gitea.Webhook): payload is Gitea.IssueCommentWebhook { + return "comment" in payload && "issue" in payload + } + + static async handle(payload: Gitea.IssueCommentWebhook) { + const { issue, comment, repository, action } = payload if (!issue.pull_request) return - if (payload.action === "created") { - await ensurePRCreatedOnDiscord(issue, repository) - await handleIssueCommentCreated(issue, comment) - } else if (payload.action === "edited") { - await ensurePRCreatedOnDiscord(issue, repository) - await handleIssueCommentEdited(issue, comment) - } else if (payload.action === "deleted") { - await ensurePRCreatedOnDiscord(issue, repository) - await handleIssueDeleted(issue, comment) + if (action === "created") { + await ensureThreadExists(issue, repository) + await this.handleCreated(issue, comment) + } else if (action === "edited") { + await ensureThreadExists(issue, repository) + await this.handleEdited(issue, comment) + } else if (action === "deleted") { + await ensureThreadExists(issue, repository) + await this.handleDeleted(comment) } - console.log(`✅ comment webhook action: ${payload.action} on #${payload.issue.number}`) - } else { - // console.log(`🙈 Unhandled Gitea webhook payload`, { payload: JSON.stringify(payload, null, 2) }) - } -} - -const channelId = getConfig("channelId") -const channel = await client.channels.fetch(channelId) -if (channel?.type !== ChannelType.GuildText) { - throw new Error( - `Discord channel ${channelId} (from config.ts) is type ${channel?.type}, expected GuildText (0)` - ) -} - -const handlePullRequestOpened = async (pullRequest: Gitea.PullRequest, repository: Gitea.Repository) => { - // Post the PR details as first message in thread - const webhook = await getOrCreateWebhook(channel) - const prMessage = formatPrBody(pullRequest, repository.full_name) - - const message = await webhook.send({ - content: prMessage, - username: mapGiteaUserToDiscord(pullRequest.user.login), - avatarURL: await mapGiteaUserToAvatarURL(pullRequest.user.login), - }) - - const thread = await message.startThread({ - name: threadName(pullRequest, repository), - reason: `PR #${pullRequest.number} opened`, - }) - - insertPR(pullRequest.id, repository.full_name, pullRequest.number, thread.id, message.id) -} - -const handlePullRequestEdited = async (pullRequest: Gitea.PullRequest, repository: Gitea.Repository) => { - const thread = await getThread(pullRequest) - const prBody = await thread.fetchStarterMessage() - if (!prBody) { - throw new Error(`Could not fetch PR body for #${pullRequest.number}`) + console.log(`✅ comment webhook action: ${action} on #${issue.number}`) } - const updatedMessage = formatPrBody(pullRequest, repository.full_name) - console.log(`🌭`, { title: pullRequest.title }) - thread.setName(threadName(pullRequest, repository)) - const webhook = await getOrCreateWebhook(thread) - await webhook.editMessage(prBody, { content: updatedMessage }) -} + static async handleCreated(issue: Gitea.Issue, comment: Gitea.Comment) { + const commentRow = getCommentByGiteaId(comment.id) + if (commentRow) { + return // Comment already exists, skip + } -const handleIssueCommentCreated = async (issue: Gitea.Issue, comment: Gitea.Comment) => { - // Check if this comment already exists (came from Discord originally) - try { - getCommentByGiteaId(comment.id) - return - } catch { - // Comment doesn't exist - this is a new Gitea comment, post to Discord + const discordUsername = convertUsername({ giteaUsername: comment.user.login }) + const thread = await getThread(issue) + if (!thread) { + throw new Error(`No thread found for PR #${issue.number} when handling new comment`) + } + + const webhook = await getWebhook() + const message = await webhook.send({ + content: await this.formatComment(comment.body), + username: discordUsername, + avatarURL: await getDiscordAvatarUrl(discordUsername), + threadId: thread.id, + }) + + insertComment(comment.id, message.id, thread.id) } - const thread = await getThread(issue) - 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, - }) + static async handleEdited(issue: Gitea.Issue, comment: Gitea.Comment) { + const commentRow = getCommentByGiteaId(comment.id) + if (!commentRow) { + this.handleCreated(issue, comment) + return + } - insertComment(comment.id, message.id, issue.id) -} - -const handleIssueCommentEdited = async (issue: Gitea.Issue, comment: Gitea.Comment) => { - const commentRow = getCommentByGiteaId(comment.id) - const thread = await getThread(issue) - - const discordMessage = await thread.messages.fetch(commentRow.discord_message_id) - if (!discordMessage) { - throw new Error(`Discord message ${commentRow.discord_message_id} not found`) - } - - const webhook = await getOrCreateWebhook(thread) - const content = await formatComment(comment) - await webhook.editMessage(commentRow.discord_message_id, { content, threadId: thread.id }) -} - -const handleIssueDeleted = async (issue: Gitea.Issue, comment: Gitea.Comment) => { - const commentRow = getCommentByGiteaId(comment.id) - const thread = await getThread(issue) - - const discordMessage = await thread.messages.fetch(commentRow.discord_message_id) - if (!discordMessage) { - throw new Error(`Discord message ${commentRow.discord_message_id} not found`) - } - - const webhook = await getOrCreateWebhook(thread) - await webhook.editMessage(commentRow.discord_message_id, { content: `[DELETED]`, threadId: thread.id }) -} - -const ensurePRCreatedOnDiscord = async ( - pullRequest: Gitea.PullRequest | Gitea.Issue, - repository: Gitea.Repository -) => { - const prNumber = pullRequest.number - - const exists = getPRByGiteaId(pullRequest.id) - if (exists) return true - - const pr = await fetchPR(repository.full_name, prNumber) - await handlePullRequestOpened(pr, repository) - - return true -} - -const getThread = async (pullRequest: Gitea.PullRequest | Gitea.Issue) => { - const row = getPRByGiteaId(pullRequest.id) - if (!row) { - throw new Error(`No PR found in database for Gitea PR ID ${pullRequest.id}`) - } - - const thread = await client.channels.fetch(row.discord_thread_id) - if (!thread?.isThread()) { - throw new Error(`Discord channel ${row.discord_thread_id} is not a thread for PR #${pullRequest.number}`) - } - - return thread -} - -const prState = (pullRequest: Gitea.PullRequest): { icon: string; label: string } => { - let state: PullRequestState = "opened" - if (pullRequest.merged) { - state = "merged" - } else if (pullRequest.state === "closed") { - state = "closed" - } - - console.log(`🌭 `, { state, merged: pullRequest.merged }) - - let stateInfo: { icon: string; label: string } - if (state === "merged") { - stateInfo = { icon: "🟣", label: "merged" } - } else if (state === "closed") { - stateInfo = { icon: "🔴", label: "closed" } - } else if (state === "opened") { - stateInfo = { icon: "🟢", label: "opened" } - } else { - assertNever(state, `Unhandled pull request state: ${state}`) - } - return stateInfo -} - -const threadName = (pullRequest: Gitea.PullRequest, repository: Gitea.Repository): string => { - const stateInfo = prState(pullRequest) - const name = `${stateInfo.icon} [${repository.name}] ${pullRequest.title}` - return name.slice(0, 100) -} - -type PullRequestState = "closed" | "merged" | "opened" -const handlePullRequestStateChange = async (pullRequest: Gitea.PullRequest, repository: Gitea.Repository) => { - const thread = await getThread(pullRequest) - - await thread.setName(threadName(pullRequest, repository)) -} - -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", + const webhook = await getWebhook() + const content = await this.formatComment(comment.body) + await webhook.editMessage(commentRow.discord_message_id, { + content, + threadId: commentRow.discord_thread_id, }) } - return webhook -} + static async handleDeleted(comment: Gitea.Comment) { + const commentRow = getCommentByGiteaId(comment.id) + if (!commentRow) { + return // Comment doesn't exist, skip + } -const formatPrBody = (pullRequest: Gitea.PullRequest, repositoryFullName: string): string => { - const body = pullRequest.body || "_empty_" - return ` -> ### [${pullRequest.title}](<${pullRequest.html_url}>) -> **${repositoryFullName}** -> -${body - .split("\n") - .map((line) => `> ${line}`) - .join("\n")} -` -} - -const formatComment = async (comment: { body: string; user: Gitea.User }) => { - return convertMentionsToDiscord(comment.body) -} - -const mapGiteaUserToAvatarURL = async (username: string) => { - const discordUserName = mapGiteaUserToDiscord(username) - const discordUser = await getDiscordUser(discordUserName) - - return discordUser?.avatarURL() || undefined -} - -const mapGiteaUserToDiscord = (username: string): string => { - const userMappings = getConfig("userMappings") - const discordUserId = userMappings[username] - - return discordUserId || `💥${username}💥` -} - -const getDiscordUser = async (discordUsername: string) => { - const cachedUser = client.users.cache.find((user) => user.username === discordUsername) - if (cachedUser) return cachedUser - - 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() - -const getDiscordUserId = async (discordUsername: string): Promise => { - // Check cache first - if (discordUserIdCache.has(discordUsername)) { - return discordUserIdCache.get(discordUsername)! + const webhook = await getWebhook() + await webhook.editMessage(commentRow.discord_message_id, { + content: `[DELETED]`, + threadId: commentRow.discord_thread_id, + }) } - // Fetch from Discord - const members = await channel.guild.members.fetch({ query: discordUsername, limit: 1 }) - const member = members.first() + static async formatComment(comment: string) { + const giteaToDiscordUserMappings = getConfig("giteaToDiscordUserMappings") - if (member) { - discordUserIdCache.set(discordUsername, member.user.id) - return member.user.id - } + // Find all @username mentions + const mentionRegex = /@(\w+)/g + const mentions = [...comment.matchAll(mentionRegex)] - return null -} + let result = comment + for (const match of mentions) { + const giteaUsername = match[1]! + const discordUsername = giteaToDiscordUserMappings[giteaUsername] -const convertMentionsToDiscord = async (text: string): Promise => { - const userMappings = getConfig("userMappings") - - // Find all @username mentions - const mentionRegex = /@(\w+)/g - const mentions = [...text.matchAll(mentionRegex)] - - let result = text - for (const match of mentions) { - const giteaUsername = match[1]! - const discordUsername = userMappings[giteaUsername] - - if (discordUsername) { - const discordId = await getDiscordUserId(discordUsername) - if (discordId) { - result = result.replace(match[0], `<@${discordId}>`) + if (discordUsername) { + const discordUser = await getDiscordUser(discordUsername) + if (discordUser) { + result = result.replace(match[0], `<@${discordUser.id}>`) + } } } - } - return result + return result + } }