Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions api/discord-provider.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {},
) => ({
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)
})
})
177 changes: 177 additions & 0 deletions api/discord-provider.ts
Original file line number Diff line number Diff line change
@@ -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<DiscordThread[] | DiscordMessage[]>

// 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<DiscordThread[] | DiscordMessage[]>(path, params),
): Provider => ({
id: 'discord',

async list(scope: ProjectScope): Promise<WorkItem[]> {
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<Comment[]> {
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<string, DiscordMessage>()
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()
Loading