From 46545ec66ab905f0980197f6c8152191d7ef2da5 Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Fri, 9 Oct 2026 12:49:33 +0000 Subject: [PATCH] Add Discord ticket provider and tests --- api/discord-provider.test.ts | 178 +++++++++++++++++++++++++++++++++++ api/discord-provider.ts | 177 ++++++++++++++++++++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 api/discord-provider.test.ts create mode 100644 api/discord-provider.ts diff --git a/api/discord-provider.test.ts b/api/discord-provider.test.ts new file mode 100644 index 0000000..966e7b1 --- /dev/null +++ b/api/discord-provider.test.ts @@ -0,0 +1,178 @@ +import { assertEquals } from '@std/assert' +import { describe, it } from '@std/testing/bdd' +import { createDiscordProvider } from './discord-provider.ts' +import { extractKey } from './tickets.ts' + +const snowflake = (iso: string): string => + String((BigInt(Date.parse(iso)) - 1420070400000n) << 22n) + +const guildId = '800000000000000001' +const channelId = '900000000000000001' +const threadId = snowflake('2026-01-01T09:00:00.000Z') +const lastMessageId = snowflake('2026-01-03T12:00:00.000Z') + +const thread = { + id: threadId, + guild_id: guildId, + name: 'TNT393 Hide settings tab for non-admin tournament users', + parent_id: channelId, + last_message_id: lastMessageId, + thread_metadata: { archived: false }, +} + +const message = ( + iso: string, + overrides: Record = {}, +) => ({ + id: snowflake(iso), + type: 0, + timestamp: iso, + author: { id: 'discord-ada' }, + attachments: [], + embeds: [], + ...overrides, +}) + +describe('discordProvider', () => { + it('lists keyed threads of the project channel', async () => { + const received: { path: string; q?: string }[] = [] + const provider = createDiscordProvider((path, params) => { + received.push({ path, q: params?.q }) + return Promise.resolve([ + thread, + { ...thread, id: snowflake('2026-01-02T00:00:00.000Z'), name: 'chat' }, + { ...thread, id: undefined }, + ]) + }) + + const items = await provider.list({ discordChannelId: channelId }) + + assertEquals(received.map(({ path }) => path), ['discord/channel']) + assertEquals( + received[0].q?.includes(`.parent_id == "${channelId}"`), + true, + ) + assertEquals( + received[0].q?.includes('.type == 10 or .type == 11 or .type == 12'), + true, + ) + assertEquals(items.length, 1) + assertEquals(items[0].externalId, threadId) + assertEquals( + items[0].externalUrl, + `https://discord.com/channels/${guildId}/${threadId}`, + ) + assertEquals(items[0].title, thread.name) + assertEquals(items[0].status, 'open') + assertEquals(items[0].updatedAt, Date.parse('2026-01-03T12:00:00.000Z')) + assertEquals(extractKey(items[0]), 'TNT393') + }) + + it('marks archived threads', async () => { + const provider = createDiscordProvider(() => + Promise.resolve([{ ...thread, thread_metadata: { archived: true } }]) + ) + + const [item] = await provider.list({ discordChannelId: channelId }) + + assertEquals(item.status, 'archived') + }) + + it('does not list when channel scope is absent', async () => { + let called = false + const provider = createDiscordProvider(() => { + called = true + return Promise.resolve([]) + }) + + assertEquals(await provider.list({}), []) + assertEquals(called, false) + }) + + it('returns every thread message chronologically, scoped by thread', async () => { + const received: { path: string; q?: string }[] = [] + const botReply = message('2026-01-02T11:00:00.000Z', { + author: { id: 'ci-bot' }, + embeds: [{ title: 'Deploy preview', description: 'Ready' }], + }) + const provider = createDiscordProvider((path, params) => { + received.push({ path, q: params?.q }) + if (path === 'discord/channel') return Promise.resolve([thread]) + if (path.startsWith('discord/message/')) { + return Promise.resolve([ + message('2026-01-03T12:00:00.000Z', { + content: 'Merged, closing.', + author: { id: 'discord-grace' }, + }), + message('2026-01-01T09:00:00.000Z', { + type: 21, + content: '', + referenced_message: message('2026-01-01T08:59:00.000Z', { + content: 'Settings tab should be admin only.', + }), + }), + message('2026-01-02T10:00:00.000Z', { + content: 'Screenshot:', + attachments: [{ url: 'https://cdn.discordapp.com/a.png' }], + }), + message('2026-01-02T10:30:00.000Z', { type: 6, content: '' }), + ]) + } + // both bot ingests carry the same message + return Promise.resolve([botReply]) + }) + + const [item] = await provider.list({ discordChannelId: channelId }) + const comments = await provider.comments(item) + + assertEquals(received.slice(1).map(({ path }) => path), [ + `discord/message/${threadId}`, + `discord/message-bot/${threadId}`, + `discord/bot-message/${threadId}`, + ]) + assertEquals( + received.slice(1).every(({ q }) => + q?.includes(`.channel_id == "${threadId}"`) + ), + true, + ) + assertEquals(comments.map((comment) => comment.body), [ + 'Settings tab should be admin only.', + 'Screenshot:\nhttps://cdn.discordapp.com/a.png', + 'Deploy preview\nReady', + 'Merged, closing.', + ]) + assertEquals(comments.map((comment) => comment.author), [ + 'discord-ada', + 'discord-ada', + 'ci-bot', + 'discord-grace', + ]) + assertEquals( + comments[0].createdAt, + Date.parse('2026-01-01T08:59:00.000Z'), + ) + assertEquals( + comments[2].url, + `https://discord.com/channels/${guildId}/${threadId}/${botReply.id}`, + ) + }) + + it('does not query messages for a non-snowflake thread id', async () => { + let called = false + const provider = createDiscordProvider(() => { + called = true + return Promise.resolve([]) + }) + + const comments = await provider.comments({ + source: 'discord', + externalId: '../../google/user', + externalUrl: '', + title: 'TNT393 Hide settings', + }) + + assertEquals(comments, []) + assertEquals(called, false) + }) +}) diff --git a/api/discord-provider.ts b/api/discord-provider.ts new file mode 100644 index 0000000..7fa5f8e --- /dev/null +++ b/api/discord-provider.ts @@ -0,0 +1,177 @@ +import { get } from './lmdb-store.ts' +import { + type Comment, + extractKey, + type ProjectScope, + type Provider, + type WorkItem, +} from './tickets.ts' + +type DiscordThread = { + id?: string + guild_id?: string + name?: string + parent_id?: string + last_message_id?: string | null + thread_metadata?: { + archived?: boolean | null + } | null +} + +type DiscordMessageContent = { + id?: string | null + content?: string | null + timestamp?: string | null + author?: { id?: string | null } | null + attachments?: { url?: string | null }[] + embeds?: { title?: string | null; description?: string | null }[] +} + +type DiscordMessage = DiscordMessageContent & { + type?: number + referenced_message?: DiscordMessageContent | null +} + +type StoreGet = ( + path: string, + params?: { q?: string }, +) => Promise + +// ANNOUNCEMENT_THREAD, PUBLIC_THREAD, PRIVATE_THREAD +const THREAD_TYPES = [10, 11, 12] as const + +// DEFAULT, REPLY, CHAT_INPUT_COMMAND, THREAD_STARTER_MESSAGE, +// CONTEXT_MENU_COMMAND — everything else is a system notice (pin, join, +// rename...) rather than something someone said +const THREAD_STARTER_MESSAGE = 21 as const +const CONVERSATION_TYPES = new Set([0, 19, 20, THREAD_STARTER_MESSAGE, 23]) + +// every key is `{collection}/{channelId}/{messageId}`, so scoping the path to +// the thread id makes the store prefix-scan that thread only +const MESSAGE_COLLECTIONS = [ + 'discord/message', + 'discord/message-bot', + 'discord/bot-message', +] as const + +const MESSAGE_FIELDS = `id, + content, + timestamp, + author: { id: .author.id }, + attachments: ((.attachments // []) | map({ url })), + embeds: ((.embeds // []) | map({ title, description }))` as const + +const DISCORD_EPOCH = 1420070400000n as const +const SNOWFLAKE = /^[0-9]+$/ + +const snowflakeTime = (id: string): number => + Number((BigInt(id) >> 22n) + DISCORD_EPOCH) + +const toWorkItem = (thread: DiscordThread): WorkItem | undefined => { + if (!thread.id || !thread.name || !thread.guild_id) return undefined + return { + source: 'discord', + externalId: thread.id, + externalUrl: `https://discord.com/channels/${thread.guild_id}/${thread.id}`, + title: thread.name, + status: thread.thread_metadata?.archived ? 'archived' : 'open', + updatedAt: snowflakeTime(thread.last_message_id ?? thread.id), + raw: { thread }, + } +} + +const messageBody = (message: DiscordMessageContent): string => + [ + message.content, + ...(message.embeds ?? []).flatMap((embed) => [ + embed.title, + embed.description, + ]), + ...(message.attachments ?? []).map((attachment) => attachment.url), + ].filter(Boolean).join('\n') + +const toComment = ( + item: WorkItem, + message: DiscordMessage, +): Comment | undefined => { + if (!message.id || !CONVERSATION_TYPES.has(message.type ?? 0)) { + return undefined + } + // the starter of a thread opened from a channel message lives in the parent + // channel, it only shows up here as the referenced message + const entry = message.type === THREAD_STARTER_MESSAGE + ? message.referenced_message + : message + const body = entry ? messageBody(entry) : '' + if (!body) return undefined + return { + source: 'discord', + author: entry?.author?.id ?? undefined, + body, + url: `${item.externalUrl}/${message.id}`, + createdAt: entry?.timestamp + ? Date.parse(entry.timestamp) + : snowflakeTime(entry?.id ?? message.id), + } +} + +export const createDiscordProvider = ( + storeGet: StoreGet = (path, params) => + get(path, params), +): Provider => ({ + id: 'discord', + + async list(scope: ProjectScope): Promise { + if (!scope.discordChannelId) return [] + const threads = await storeGet('discord/channel', { + q: `select(.parent_id == ${JSON.stringify(scope.discordChannelId)} and (${ + THREAD_TYPES.map((type) => `.type == ${type}`).join(' or ') + })) | { + id, + guild_id, + name, + parent_id, + last_message_id, + thread_metadata: { archived: .thread_metadata.archived } + }`, + }) + + const items: WorkItem[] = [] + for (const thread of threads as DiscordThread[]) { + const item = toWorkItem(thread) + if (item && extractKey(item)) items.push(item) + } + return items + }, + + async comments(item: WorkItem): Promise { + if (!SNOWFLAKE.test(item.externalId)) return [] + const q = `select(.channel_id == ${JSON.stringify(item.externalId)}) | { + type, + ${MESSAGE_FIELDS}, + referenced_message: (if .type == ${THREAD_STARTER_MESSAGE} + then (.referenced_message | { ${MESSAGE_FIELDS} }) + else null end) + }` + const results = await Promise.all( + MESSAGE_COLLECTIONS.map((collection) => + storeGet(`${collection}/${item.externalId}`, { q }) + ), + ) + + // the two bot ingests overlap, keep one copy of each message + const messages = new Map() + for (const message of results.flat() as DiscordMessage[]) { + if (message.id) messages.set(message.id, message) + } + + const comments: Comment[] = [] + for (const message of messages.values()) { + const comment = toComment(item, message) + if (comment) comments.push(comment) + } + return comments.sort((a, b) => a.createdAt! - b.createdAt!) + }, +}) + +export const discordProvider = createDiscordProvider()