diff --git a/CHANGELOG.md b/CHANGELOG.md index f59247f..8dfae91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.2.1] - 2026-06-03 + +Archive carbon-copy fidelity + close-workflow robustness. Archived ticket and +application transcripts are now an exact copy of the conversation — the prior +builder silently truncated any message over 500 characters. + +### Fixed + +- **Transcripts no longer truncate (data loss).** `transcriptBuilder` removed + the 500-char per-message truncation; a message larger than a single Discord + post is now SPLIT across chunks on line boundaries (with code-fence balancing), + and every emitted chunk is guaranteed `<= 2000` chars. Fixes both ticket and + application archives (shared builder). +- **Archive failure no longer deletes the conversation.** When a forum post + fails, the source channel is now PRESERVED instead of deleted, and the close + is reverted (ticket/application status restored) so it can be retried — the + previous behavior deleted the only remaining copy of the conversation. +- **Re-close into a deleted archive thread recovers.** Re-closing when the + archive thread was deleted (Discord `10003`) now recreates the thread and + repoints the archive record instead of silently failing; non-`10003` errors + still surface (and preserve the channel). +- **Ticket assignment is now persisted.** `POST /tickets/:id/assign` writes + `assignedTo` + `assignedAt` — previously it only set a channel permission + overwrite, so the dashboard and close transcript showed the ticket unassigned. + +### Added + +- Transcripts now capture **stickers, polls** (question + per-answer vote + counts), and **embed media** (image/thumbnail/footer/author/linked title) — + sticker-only and poll-only messages are no longer dropped. +- Per-chunk failure logging on transcript posts (a partial-archive failure is + now attributable to its chunk index). + ## [3.2.0] - 2026-05-17 Major refinement of the bait channel (honeypot) subsystem. Closes ~22 diff --git a/CLAUDE.md b/CLAUDE.md index e8b588a..9cf3151 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ - **Runtime**: Bun - **Deployment**: Docker containers - **Branches**: `main` (production) -- **Version**: 3.2.0 +- **Version**: 3.2.1 ## Critical Rules diff --git a/package.json b/package.json index 3aaf4c6..0915c67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cogworks-bot", - "version": "3.2.0", + "version": "3.2.1", "description": "A modular Discord server management bot built with TypeScript and Discord.js", "main": "index.js", "scripts": { diff --git a/src/events/application/close.ts b/src/events/application/close.ts index aa5c8aa..c3eef71 100644 --- a/src/events/application/close.ts +++ b/src/events/application/close.ts @@ -22,7 +22,9 @@ export const applicationCloseEvent = async (client: Client, interaction: ButtonI const guildId = interaction.guildId; const channel = interaction.channel as GuildTextBasedChannel; const channelId = interaction.channelId || ''; - const archivedConfig = await archivedApplicationConfigRepo.findOneBy({ guildId }); + const archivedConfig = await archivedApplicationConfigRepo.findOneBy({ + guildId, + }); const application = await applicationRepo.findOneBy({ guildId, channelId }); if (!archivedConfig) { @@ -48,16 +50,35 @@ export const applicationCloseEvent = async (client: Client, interaction: ButtonI const result = await archiveAndCloseApplication(client, application, guildId, channel, archivedConfig.channelId); - if (result.transcriptFailed && !interaction.replied && !interaction.deferred) { - await interaction.reply({ - content: tl.transcriptCreate.error, - flags: [MessageFlags.Ephemeral], - }); - } else if (result.success && !result.archived) { - enhancedLogger.warn('Application closed but archive post failed', LogCategory.SYSTEM, { - guildId, - channelId, - applicationId: application.id, + if (!result.archived) { + // Close did not complete (transcript fetch or forum post failed). The + // workflow preserved the channel; revert the status so the close can be + // retried instead of stranding a 'closed' application with a live channel. + await applicationRepo.update({ id: application.id, guildId }, { status: application.status }); + enhancedLogger.warn( + 'Application close reverted — archive failed, channel + application preserved for retry', + LogCategory.SYSTEM, + { + guildId, + channelId, + applicationId: application.id, + transcriptFailed: result.transcriptFailed ?? false, + }, + ); + const notify = + interaction.replied || interaction.deferred + ? interaction.editReply({ content: tl.transcriptCreate.error }) + : interaction.reply({ + content: tl.transcriptCreate.error, + flags: [MessageFlags.Ephemeral], + }); + await notify.catch((err: unknown) => { + enhancedLogger.error( + 'Failed to deliver application-close failure notice to the user', + err instanceof Error ? err : undefined, + LogCategory.SYSTEM, + { guildId, channelId, applicationId: application.id }, + ); }); } }; diff --git a/src/events/ticket/close.ts b/src/events/ticket/close.ts index d00ed5a..643aacb 100644 --- a/src/events/ticket/close.ts +++ b/src/events/ticket/close.ts @@ -49,18 +49,36 @@ export const ticketCloseEvent = async (client: Client, interaction: ButtonIntera const result = await archiveAndCloseTicket(client, ticket, guildId, channel, archivedConfig.channelId); - if (result.transcriptFailed && !interaction.replied && !interaction.deferred) { - await interaction.reply({ - content: tl.transcriptCreate.error, - flags: [MessageFlags.Ephemeral], - }); - } else if (result.success && !result.archived) { - // Ticket closed cleanly but forum archive post failed — ticket is gone, - // channel is deleted. Log for operators; the user-facing close already happened. - enhancedLogger.warn('Ticket closed but archive post failed', LogCategory.SYSTEM, { - guildId, - channelId, - ticketId: ticket.id, + if (!result.archived) { + // Close did not complete (transcript fetch or forum post failed). The + // workflow deliberately preserved the channel, so revert the status — + // otherwise the ticket is stranded 'closed' with a live channel and the + // dup-close guard blocks any retry. + await ticketRepo.update({ id: ticket.id, guildId }, { status: ticket.status }); + enhancedLogger.warn( + 'Ticket close reverted — archive failed, channel + ticket preserved for retry', + LogCategory.SYSTEM, + { + guildId, + channelId, + ticketId: ticket.id, + transcriptFailed: result.transcriptFailed ?? false, + }, + ); + const notify = + interaction.replied || interaction.deferred + ? interaction.editReply({ content: tl.transcriptCreate.error }) + : interaction.reply({ + content: tl.transcriptCreate.error, + flags: [MessageFlags.Ephemeral], + }); + await notify.catch((err: unknown) => { + enhancedLogger.error( + 'Failed to deliver ticket-close failure notice to the user', + err instanceof Error ? err : undefined, + LogCategory.SYSTEM, + { guildId, channelId, ticketId: ticket.id }, + ); }); } }; diff --git a/src/utils/api/handlers/applicationHandlers.ts b/src/utils/api/handlers/applicationHandlers.ts index 153a2f7..40aa789 100644 --- a/src/utils/api/handlers/applicationHandlers.ts +++ b/src/utils/api/handlers/applicationHandlers.ts @@ -1,7 +1,7 @@ import type { Client, GuildTextBasedChannel } from 'discord.js'; import { Application } from '../../../typeorm/entities/application/Application'; import { ArchivedApplicationConfig } from '../../../typeorm/entities/application/ArchivedApplicationConfig'; -import { archiveAndCloseApplication } from '../../application/closeWorkflow'; +import { archiveAndCloseApplication as defaultArchiveAndCloseApplication } from '../../application/closeWorkflow'; import { lazyRepo } from '../../database/lazyRepo'; import { ApiError } from '../apiError'; import { optionalString, requireId, requireString } from '../helpers'; @@ -11,7 +11,18 @@ import { writeAuditLog } from './auditHelper'; const applicationRepo = lazyRepo(Application); const archivedAppConfigRepo = lazyRepo(ArchivedApplicationConfig); -export function registerApplicationHandlers(client: Client, routes: Map): void { +/** + * @param archiveAndCloseApplication Injectable for tests — defaults to the real + * close workflow. Passing a fake here lets the handler test avoid + * `mock.module()` on the shared closeWorkflow module, which would otherwise leak + * process-globally and poison closeWorkflow's own test suite (bun's mock.module + * is process-shared and not undone by mock.restore). + */ +export function registerApplicationHandlers( + client: Client, + routes: Map, + archiveAndCloseApplication: typeof defaultArchiveAndCloseApplication = defaultArchiveAndCloseApplication, +): void { // POST /internal/guilds/:guildId/applications/:id/approve routes.set('POST /applications/:id/approve', async (guildId, body, url) => { const appId = requireId(url, 'applications'); @@ -71,7 +82,20 @@ export function registerApplicationHandlers(client: Client, routes: Map null) : null; + // Distinguish a genuinely-gone channel (10003 → nothing to archive, terminal + // close) from a transient/permission fetch failure (→ revert so a retry can + // still archive, rather than stranding a live application as 'closed'). + let channelFetchFailed = false; + const channel = app.channelId + ? await client.channels.fetch(app.channelId).catch((err: unknown) => { + if ((err as { code?: number })?.code !== 10003) channelFetchFailed = true; + return null; + }) + : null; + if (channelFetchFailed) { + await applicationRepo.update({ id: app.id, guildId }, { status: app.status }); + return { success: false, archived: false }; + } if (!channel || !channel.isTextBased()) { return { success: true, archived: false }; } @@ -84,10 +108,17 @@ export function registerApplicationHandlers(client: Client, routes: Map null) : null; + // Get channel. Distinguish a genuinely-gone channel (Discord 10003 → + // nothing to archive, terminal close) from a transient/permission fetch + // failure (→ revert so a retry can still archive, rather than stranding a + // live ticket as 'closed'). + let channelFetchFailed = false; + const channel = ticket.channelId + ? await client.channels.fetch(ticket.channelId).catch((err: unknown) => { + if ((err as { code?: number })?.code !== 10003) channelFetchFailed = true; + return null; + }) + : null; + if (channelFetchFailed) { + await ticketRepo.update({ id: ticket.id, guildId }, { status: ticket.status }); + return { success: false, ticketId: ticket.id, archived: false }; + } if (!channel || !channel.isTextBased()) { return { success: true, ticketId: ticket.id, archived: false }; } @@ -52,11 +65,18 @@ export function registerTicketHandlers( archivedConfig.channelId, ); + if (!result.archived) { + // Archive failed — the workflow preserved the channel; revert the status + // so the close can be retried instead of stranding it 'closed'. + await ticketRepo.update({ id: ticket.id, guildId }, { status: ticket.status }); + return { success: false, ticketId: ticket.id, archived: false }; + } + const triggeredBy = optionalString(body, 'triggeredBy'); await writeAuditLog(guildId, 'ticket.close', triggeredBy, { ticketId: ticket.id, }); - return { success: true, ticketId: ticket.id, archived: result.archived }; + return { success: true, ticketId: ticket.id, archived: true }; }); // POST /internal/guilds/:guildId/tickets/:id/assign @@ -83,6 +103,11 @@ export function registerTicketHandlers( }); } + // Persist the assignment. The permission overwrite alone left the DB + // unaware of who owns the ticket — assignedTo/assignedAt were never + // written, so the dashboard and close transcript showed it unassigned. + await ticketRepo.update({ id: ticket.id, guildId }, { assignedTo: userId, assignedAt: new Date() }); + const triggeredBy = optionalString(body, 'triggeredBy'); await writeAuditLog(guildId, 'ticket.assign', triggeredBy, { ticketId, diff --git a/src/utils/application/closeWorkflow.ts b/src/utils/application/closeWorkflow.ts index d42b71e..c8220a3 100644 --- a/src/utils/application/closeWorkflow.ts +++ b/src/utils/application/closeWorkflow.ts @@ -27,6 +27,51 @@ export interface ArchiveApplicationResult { transcriptFailed?: boolean; } +/** + * Injectable seam dependencies for {@link archiveAndCloseApplication}. + * + * Mirrors the ticket close workflow's pattern. Production callers omit this; + * tests pass fakes directly instead of `mock.module()` (which bun applies + * inconsistently across a full-suite run on Linux — see the ticket workflow's + * flaky-CI note, 2026-05-30). Direct injection is deterministic on every platform. + */ +export interface CloseApplicationWorkflowDeps { + fetchMessagesAsTranscript: typeof fetchMessagesAsTranscript; + verifiedChannelDelete: typeof verifiedChannelDelete; + archivedAppRepo: typeof archivedAppRepo; +} + +const defaultDeps: CloseApplicationWorkflowDeps = { + fetchMessagesAsTranscript, + verifiedChannelDelete, + archivedAppRepo, +}; + +/** + * Post each transcript chunk to the thread. Never pings (historical content), + * and attributes a failed send to its chunk index. Rethrows so the caller marks + * the archive failed. + */ +async function sendTranscriptChunks( + thread: ForumThreadChannel, + chunks: string[], + ctx: { guildId: string; channelId: string }, +): Promise { + for (let i = 0; i < chunks.length; i++) { + try { + await thread.send({ content: chunks[i], allowedMentions: { parse: [] } }); + } catch (error) { + enhancedLogger.error( + `Application transcript chunk ${i + 1}/${chunks.length} failed to post`, + error as Error, + LogCategory.SYSTEM, + ctx, + ); + throw error; + } + } +} + /** * Archive an application to the forum channel and clean up. * @@ -40,12 +85,13 @@ export async function archiveAndCloseApplication( guildId: string, channel: GuildTextBasedChannel, archiveForumChannelId: string, + deps: CloseApplicationWorkflowDeps = defaultDeps, ): Promise { const channelId = application.channelId || channel.id; let transcriptMessages: TranscriptMessage[]; try { - transcriptMessages = await fetchMessagesAsTranscript(channel, client.user?.id ?? ''); + transcriptMessages = await deps.fetchMessagesAsTranscript(channel, client.user?.id ?? ''); } catch (error) { enhancedLogger.error('Failed to fetch application messages for transcript', error as Error, LogCategory.SYSTEM, { guildId, @@ -55,10 +101,11 @@ export async function archiveAndCloseApplication( } const creatorUser = await client.users.fetch(application.createdBy).catch(() => null); + const threadName = creatorUser?.username || 'Unknown'; const metadata: TicketMetadata = { - title: `Application: ${creatorUser?.username || 'Unknown'}`, + title: `Application: ${threadName}`, type: 'Application', - createdByUsername: creatorUser?.username || 'Unknown', + createdByUsername: threadName, openedAt: 'createdAt' in channel && channel.createdAt instanceof Date ? channel.createdAt : new Date(), closedAt: new Date(), assignedToUsername: null, @@ -69,38 +116,58 @@ export async function archiveAndCloseApplication( let archived = true; try { const forumChannel = (await client.channels.fetch(archiveForumChannelId)) as ForumChannel; - const existingArchive = await archivedAppRepo.findOneBy({ + const existingArchive = await deps.archivedAppRepo.findOneBy({ createdBy: application.createdBy, guildId, }); if (!existingArchive) { const newPost = await forumChannel.threads.create({ - name: creatorUser?.username || 'Unknown', + name: threadName, // allowedMentions parse:[] — historical transcript, never ping anyone. message: { content: transcript.header, allowedMentions: { parse: [] } }, }); - for (const chunk of transcript.chunks) { - await newPost.send({ content: chunk, allowedMentions: { parse: [] } }); - } - - await archivedAppRepo.save( - archivedAppRepo.create({ + // Persist the archive row BEFORE posting chunks: a chunk failure then + // leaves the row pointing at this thread, so the retry appends instead of + // orphaning it and creating a duplicate. + await deps.archivedAppRepo.save( + deps.archivedAppRepo.create({ guildId, createdBy: application.createdBy, messageId: newPost.id, }), ); + + await sendTranscriptChunks(newPost, transcript.chunks, { guildId, channelId }); } else if (existingArchive.messageId) { - const post = (await forumChannel.threads.fetch(existingArchive.messageId)) as ForumThreadChannel; - const separator = '\n━━━━━━━━━━━━━━━━━━━━━━━━\n'; - await post.send({ - content: separator + transcript.header, - allowedMentions: { parse: [] }, - }); - for (const chunk of transcript.chunks) { - await post.send({ content: chunk, allowedMentions: { parse: [] } }); + // Re-close: reuse the archive thread, but it may have been deleted out + // from under us. force:true bypasses the cache; catch ONLY 10003 (Unknown + // Channel) and recreate so the transcript is never lost — let other errors + // bubble to the outer catch (marks archived:false, preserves the channel). + const post = (await forumChannel.threads + .fetch(existingArchive.messageId, { force: true }) + .catch((err: unknown) => { + if ((err as { code?: number })?.code === 10003) return null; + throw err; + })) as ForumThreadChannel | null; + + if (!post) { + const newPost = await forumChannel.threads.create({ + name: threadName, + message: { content: transcript.header, allowedMentions: { parse: [] } }, + }); + // Repoint + persist BEFORE chunks so a chunk failure can't orphan it. + existingArchive.messageId = newPost.id; + await deps.archivedAppRepo.save(existingArchive); + await sendTranscriptChunks(newPost, transcript.chunks, { guildId, channelId }); + } else { + const separator = '\n━━━━━━━━━━━━━━━━━━━━━━━━\n'; + await post.send({ + content: separator + transcript.header, + allowedMentions: { parse: [] }, + }); + await sendTranscriptChunks(post, transcript.chunks, { guildId, channelId }); } } } catch (error) { @@ -111,21 +178,25 @@ export async function archiveAndCloseApplication( archived = false; } - if (archived) { - enhancedLogger.info('Application transcript archived successfully', LogCategory.SYSTEM, { - guildId, - channelId, - messageCount: transcript.messageCount, - attachmentCount: transcript.attachmentCount, - }); - } else { - enhancedLogger.warn('Application closing despite archive failure', LogCategory.SYSTEM, { + if (!archived) { + // Archive failed. DO NOT delete the source channel — preserve it so the + // conversation isn't lost and the close can be retried. The caller reverts + // the application status so the retry isn't blocked by the dup-close guard. + enhancedLogger.warn('Application archive failed — preserving channel for retry', LogCategory.SYSTEM, { guildId, channelId, }); + return { success: false, archived: false }; } - const deleteResult = await verifiedChannelDelete(channel, { + enhancedLogger.info('Application transcript archived successfully', LogCategory.SYSTEM, { + guildId, + channelId, + messageCount: transcript.messageCount, + attachmentCount: transcript.attachmentCount, + }); + + const deleteResult = await deps.verifiedChannelDelete(channel, { guildId, label: 'application channel', }); @@ -142,5 +213,5 @@ export async function archiveAndCloseApplication( ); } - return { success: true, archived }; + return { success: true, archived: true }; } diff --git a/src/utils/constants.ts b/src/utils/constants.ts index b51cf60..9db4388 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -239,4 +239,8 @@ export const TEXT_LIMITS = { ONBOARDING_WELCOME: 2000, /** Event template description max length */ EVENT_DESCRIPTION: 1000, + /** Transcript chunk packing target (headroom under Discord's hard limit) */ + TRANSCRIPT_CHUNK_SOFT: 1900, + /** Discord's hard per-message limit — no transcript chunk may exceed this */ + TRANSCRIPT_CHUNK_HARD: 2000, } as const; diff --git a/src/utils/fetchAllMessages.ts b/src/utils/fetchAllMessages.ts index 9bbe332..dfc0041 100644 --- a/src/utils/fetchAllMessages.ts +++ b/src/utils/fetchAllMessages.ts @@ -64,8 +64,27 @@ function toTranscriptMessage(m: Message, byId: Map, botClientId embeds: m.embeds.map(e => ({ title: e.title ?? undefined, description: e.description ?? undefined, + url: e.url ?? undefined, + author: e.author?.name ?? undefined, + footer: e.footer?.text ?? undefined, + imageUrl: e.image?.url ?? undefined, + thumbnailUrl: e.thumbnail?.url ?? undefined, + color: e.color ?? undefined, fields: e.fields?.map(f => ({ name: f.name, value: f.value })), })), + stickers: Array.from(m.stickers.values()).map(s => ({ + name: s.name, + url: s.url, + })), + poll: m.poll + ? { + question: m.poll.question.text ?? '', + answers: Array.from(m.poll.answers.values()).map(a => ({ + text: a.text ?? '', + voteCount: a.voteCount, + })), + } + : null, replyTo: replyTarget ? { author: replyTarget.author.username, diff --git a/src/utils/ticket/closeWorkflow.ts b/src/utils/ticket/closeWorkflow.ts index 28871a0..1f43a3b 100644 --- a/src/utils/ticket/closeWorkflow.ts +++ b/src/utils/ticket/closeWorkflow.ts @@ -134,6 +134,40 @@ async function resolveArchiveThreadName(client: Client, ticket: Ticket): Promise return user?.username || 'Unknown'; } +/** + * Post each transcript chunk to the thread. Never pings (historical content), + * and attributes a failed send to its chunk index so a partial-archive failure + * is diagnosable. Rethrows so the caller marks the archive failed. + */ +async function sendTranscriptChunks( + thread: ForumThreadChannel, + chunks: string[], + ctx: { guildId: string; channelId: string }, +): Promise { + for (let i = 0; i < chunks.length; i++) { + try { + await thread.send({ content: chunks[i], allowedMentions: { parse: [] } }); + } catch (error) { + enhancedLogger.error( + `Transcript chunk ${i + 1}/${chunks.length} failed to post`, + error as Error, + LogCategory.SYSTEM, + ctx, + ); + throw error; + } + } +} + +/** Union of existing + incoming forum tag ids, order-preserving, deduped. */ +function mergeForumTags(existing: string[] | null | undefined, incoming: string[]): string[] { + const merged = [...(existing ?? [])]; + for (const tag of incoming) { + if (!merged.includes(tag)) merged.push(tag); + } + return merged; +} + /** * Archive a ticket to the forum channel and clean up. * @@ -216,10 +250,10 @@ export async function archiveAndCloseTicket( await deps.applyForumTags(forumChannel, newPost.id, forumTagIds); } - for (const chunk of transcript.chunks) { - await newPost.send({ content: chunk, allowedMentions: { parse: [] } }); - } - + // Persist the archive row BEFORE posting chunks. If a chunk send fails + // mid-archive, the row already points at this thread, so the B2 retry + // appends to it (via the re-close branch) instead of orphaning it and + // creating a duplicate thread. await deps.archivedTicketRepo.save( deps.archivedTicketRepo.create({ guildId, @@ -234,29 +268,74 @@ export async function archiveAndCloseTicket( emailSubject: ticket.emailSubject, }), ); - } else if (existingArchive.messageId) { - // Re-close for the same user: append a separator + header + chunks - // to the existing thread. Tags still accumulate — "Forum Tag System" - // per CLAUDE.md. - const post = (await forumChannel.threads.fetch(existingArchive.messageId)) as ForumThreadChannel; - const separator = '\n━━━━━━━━━━━━━━━━━━━━━━━━\n'; - await post.send({ - content: separator + transcript.header, - allowedMentions: { parse: [] }, + await sendTranscriptChunks(newPost, transcript.chunks, { + guildId, + channelId, }); - for (const chunk of transcript.chunks) { - await post.send({ content: chunk, allowedMentions: { parse: [] } }); - } + } else if (existingArchive.messageId) { + // Re-close for the same user. The archive thread is reused — but it may + // have been deleted out from under us (manual delete, /archive cleanup). + // Catch ONLY 10003 (Unknown Channel) and recreate so the transcript is + // never lost; let permission/transient errors bubble to the outer catch + // (which marks archived:false and preserves the source channel for retry). + // force:true bypasses the channel cache so a thread deleted while the bot + // was offline (missed THREAD_DELETE) still surfaces as gone (10003 or a + // null fetch) and engages the recreate path, instead of returning a stale + // cached object we'd post into the void. + const post = (await forumChannel.threads + .fetch(existingArchive.messageId, { force: true }) + .catch((err: unknown) => { + if ((err as { code?: number })?.code === 10003) return null; + throw err; + })) as ForumThreadChannel | null; - if (forumTagIds.length > 0) { - const existingTags = existingArchive.forumTagIds || []; - const newTagId = forumTagIds[0]; - if (!existingTags.includes(newTagId)) { - const mergedTags = [...existingTags, newTagId]; - await deps.applyForumTags(forumChannel, existingArchive.messageId, mergedTags); - existingArchive.forumTagIds = mergedTags; - await deps.archivedTicketRepo.save(existingArchive); + if (!post) { + // Thread gone: recreate it (header as the initial message), re-apply + // the accumulated tags, repoint the archive row, and post the chunks. + const threadName = await resolveArchiveThreadName(client, ticket); + const newPost = await forumChannel.threads.create({ + name: threadName, + message: { + content: transcript.header, + allowedMentions: { parse: [] }, + }, + }); + const mergedTags = mergeForumTags(existingArchive.forumTagIds, forumTagIds); + if (mergedTags.length > 0) { + await deps.applyForumTags(forumChannel, newPost.id, mergedTags); + } + // Repoint + persist BEFORE chunks so a chunk failure can't orphan the + // new thread — a retry finds this row and appends to it. + existingArchive.messageId = newPost.id; + existingArchive.forumTagIds = mergedTags; + await deps.archivedTicketRepo.save(existingArchive); + await sendTranscriptChunks(newPost, transcript.chunks, { + guildId, + channelId, + }); + } else { + // Normal append: separator + header + chunks into the existing thread. + // Tags still accumulate — "Forum Tag System" per CLAUDE.md. + const separator = '\n━━━━━━━━━━━━━━━━━━━━━━━━\n'; + await post.send({ + content: separator + transcript.header, + allowedMentions: { parse: [] }, + }); + await sendTranscriptChunks(post, transcript.chunks, { + guildId, + channelId, + }); + + if (forumTagIds.length > 0) { + const existingTags = existingArchive.forumTagIds || []; + const newTagId = forumTagIds[0]; + if (!existingTags.includes(newTagId)) { + const mergedTags = [...existingTags, newTagId]; + await deps.applyForumTags(forumChannel, existingArchive.messageId, mergedTags); + existingArchive.forumTagIds = mergedTags; + await deps.archivedTicketRepo.save(existingArchive); + } } } } @@ -265,24 +344,28 @@ export async function archiveAndCloseTicket( guildId, channelId, }); - // Archive failed but ticket is still closed — proceed to channel delete. archived = false; } - if (archived) { - enhancedLogger.info('Ticket transcript archived successfully', LogCategory.SYSTEM, { - guildId, - channelId, - messageCount: transcript.messageCount, - attachmentCount: transcript.attachmentCount, - }); - } else { - enhancedLogger.warn('Ticket closing despite archive failure', LogCategory.SYSTEM, { + if (!archived) { + // Archive failed. DO NOT delete the source channel — that would destroy the + // only remaining copy of the conversation. Preserve it so the close can be + // retried; the caller reverts the ticket status so the retry isn't blocked + // by the duplicate-close guard. + enhancedLogger.warn('Ticket archive failed — preserving channel + ticket for retry', LogCategory.SYSTEM, { guildId, channelId, }); + return { success: false, archived: false }; } + enhancedLogger.info('Ticket transcript archived successfully', LogCategory.SYSTEM, { + guildId, + channelId, + messageCount: transcript.messageCount, + attachmentCount: transcript.attachmentCount, + }); + // Delete ticket channel (verified — Discord first, then DB) const deleteResult = await deps.verifiedChannelDelete(channel, { guildId, @@ -301,5 +384,5 @@ export async function archiveAndCloseTicket( ); } - return { success: true, archived }; + return { success: true, archived: true }; } diff --git a/src/utils/ticket/transcriptBuilder.ts b/src/utils/ticket/transcriptBuilder.ts index 0561c5e..17b9af5 100644 --- a/src/utils/ticket/transcriptBuilder.ts +++ b/src/utils/ticket/transcriptBuilder.ts @@ -6,8 +6,16 @@ * No Discord client or I/O — all Discord-touching concerns stay in the * fetcher layer. That separation is what makes this testable without a * gateway connection. + * + * Fidelity contract (v3.2.1): the transcript is an exact carbon copy of the + * conversation. Message content is NEVER truncated — a message longer than a + * single Discord post is split across multiple chunks on line boundaries (the + * pre-v3.2.1 builder hard-truncated at 500 chars and silently dropped the + * tail). Stickers, polls, and embed media are captured too. */ +import { TEXT_LIMITS } from '../constants'; + /** Per-message shape the fetcher hands to the builder. */ export interface TranscriptMessage { author: { username: string; id: string; bot: boolean }; @@ -15,6 +23,8 @@ export interface TranscriptMessage { timestamp: Date; attachments: TranscriptAttachment[]; embeds: TranscriptEmbed[]; + stickers: TranscriptSticker[]; + poll: TranscriptPoll | null; replyTo?: { author: string; content: string }; isSystem: boolean; hasOnlyComponents: boolean; @@ -29,9 +39,25 @@ export interface TranscriptAttachment { export interface TranscriptEmbed { title?: string; description?: string; + url?: string; + author?: string; + footer?: string; + imageUrl?: string; + thumbnailUrl?: string; + color?: number; fields?: { name: string; value: string }[]; } +export interface TranscriptSticker { + name: string; + url: string; +} + +export interface TranscriptPoll { + question: string; + answers: { text: string; voteCount: number }[]; +} + /** Ticket-level metadata rendered into the header. */ export interface TicketMetadata { title: string; @@ -49,10 +75,10 @@ export interface TranscriptResult { attachmentCount: number; } -/** Soft cap — Discord's hard limit is 2000 per message. */ -const CHUNK_SOFT_LIMIT = 1900; -/** When a single formatted message exceeds this, truncate inline. */ -const LONG_MESSAGE_LIMIT = 500; +/** Soft cap — the packing target, with headroom under the hard limit. */ +const CHUNK_SOFT_LIMIT = TEXT_LIMITS.TRANSCRIPT_CHUNK_SOFT; +/** Discord's hard per-message limit. No emitted chunk may exceed this. */ +const CHUNK_HARD_LIMIT = TEXT_LIMITS.TRANSCRIPT_CHUNK_HARD; /** `` — Discord renders this in the viewer's local timezone. */ function formatDiscordTimestamp(date: Date): string { @@ -71,11 +97,6 @@ export function formatDurationShort(ms: number): string { return `${minutes}m`; } -export function truncateLongMessage(content: string, limit: number = LONG_MESSAGE_LIMIT): string { - if (content.length <= limit) return content; - return `${content.slice(0, limit)}… (truncated)`; -} - /** Prefix every line with `> ` so the whole block becomes a Discord blockquote. */ function blockquote(body: string): string { return body @@ -107,15 +128,33 @@ function formatAttachment(a: TranscriptAttachment): string { return `> 📎 [${a.name}](${a.url})`; } +function formatSticker(s: TranscriptSticker): string { + if (!s.url) return `> 🏷️ Sticker: ${s.name}`; + return `> 🏷️ Sticker: [${s.name}](${s.url})`; +} + +function formatPoll(poll: TranscriptPoll): string[] { + const lines = [`> 📊 **Poll:** ${poll.question}`]; + for (const answer of poll.answers) { + const votes = `${answer.voteCount} vote${answer.voteCount === 1 ? '' : 's'}`; + lines.push(`> • ${answer.text} — ${votes}`); + } + return lines; +} + function formatEmbedBody(embed: TranscriptEmbed): string[] { const parts: string[] = []; - if (embed.title) parts.push(`**${embed.title}**`); + if (embed.author) parts.push(`*${embed.author}*`); + if (embed.title) parts.push(embed.url ? `**[${embed.title}](${embed.url})**` : `**${embed.title}**`); if (embed.description) parts.push(embed.description); if (embed.fields && embed.fields.length > 0) { for (const field of embed.fields) { parts.push(`*${field.name}:* ${field.value}`); } } + if (embed.imageUrl) parts.push(`🖼️ [image](${embed.imageUrl})`); + if (embed.thumbnailUrl) parts.push(`🖼️ [thumbnail](${embed.thumbnailUrl})`); + if (embed.footer) parts.push(`— ${embed.footer}`); if (parts.length === 0) return []; // Indent the whole embed one level deeper than the message blockquote. return blockquote(parts.join('\n')) @@ -130,15 +169,24 @@ export function formatMessage(msg: TranscriptMessage): string { const bodyLines: string[] = []; + // Full content — never truncated. Oversized messages are split across + // chunks by chunkByMessageBoundary so nothing is ever lost. if (msg.content) { - const content = truncateLongMessage(msg.content); - bodyLines.push(blockquote(content)); + bodyLines.push(blockquote(msg.content)); } for (const embed of msg.embeds) { bodyLines.push(...formatEmbedBody(embed)); } + for (const sticker of msg.stickers) { + bodyLines.push(formatSticker(sticker)); + } + + if (msg.poll) { + bodyLines.push(...formatPoll(msg.poll)); + } + for (const attachment of msg.attachments) { bodyLines.push(formatAttachment(attachment)); } @@ -152,24 +200,119 @@ export function formatMessage(msg: TranscriptMessage): string { return [header, ...bodyLines].join('\n'); } +/** Hard-slice a string into ≤`limit` segments. Last resort — never drops content. */ +function hardSlice(text: string, limit: number): string[] { + const out: string[] = []; + for (let i = 0; i < text.length; i += limit) { + out.push(text.slice(i, i + limit)); + } + return out; +} + +/** Leading run of `> ` blockquote markers (handles nesting, e.g. `> > `). */ +const QUOTE_PREFIX_RE = /^((?:> )+)/; + /** - * Split a list of already-formatted messages into chunks each ≤ `limit` - * characters, never splitting mid-message. A single message that on its - * own exceeds `limit` is kept intact — the caller should have already run - * `truncateLongMessage` on it via `formatMessage`. + * Split one formatted message that exceeds `limit` into ≤`limit` pieces on line + * boundaries. When a split lands inside a fenced code block the open fence is + * closed on the current piece and reopened on the next — at the SAME blockquote + * depth the fence was opened — so each resulting Discord message renders as + * valid markdown. A single line longer than `limit` is hard-sliced on its + * content while preserving its `> ` prefix on every segment, so continuation + * pieces stay blockquoted. Content is never dropped. */ -export function chunkByMessageBoundary(formattedMessages: string[], limit: number = CHUNK_SOFT_LIMIT): string[] { +function splitFormattedMessage(message: string, limit: number): string[] { + const pieces: string[] = []; + let buf: string[] = []; + let bufLen = 0; + let inFence = false; + // The blockquote depth the active fence was opened at — close/reopen must + // match it (a fence inside an embed body is double-quoted: `> > ```). + let fencePrefix = '> '; + const fenceLine = () => `${fencePrefix}\`\`\``; + + const flush = (willContinue: boolean) => { + if (buf.length > 0) { + let body = buf.join('\n'); + if (inFence) body += `\n${fenceLine()}`; // close the open fence cleanly + pieces.push(body); + buf = []; + bufLen = 0; + } + if (willContinue && inFence) { + const fl = fenceLine(); // reopen the fence on the next piece + buf = [fl]; + bufLen = fl.length; + } + }; + + const addLine = (line: string) => { + const addLen = (bufLen ? 1 : 0) + line.length; + if (bufLen && bufLen + addLen > limit) flush(true); + buf.push(line); + bufLen += bufLen ? 1 + line.length : line.length; + }; + + for (const line of message.split('\n')) { + const prefix = line.match(QUOTE_PREFIX_RE)?.[1] ?? ''; + const content = line.slice(prefix.length); + + if (line.length > limit) { + // Pathological single line (a long URL / base64 blob with no newline). + // Hard-slice the CONTENT and re-apply the quote prefix to every segment so + // continuation pieces stay blockquoted; reserve headroom for a fence + // close/reopen wrapper so every emitted piece stays ≤ limit. A fence + // marker is short and never lands here, so fence state is unaffected. + const reserve = fenceLine().length + 1; + const segLimit = Math.max(1, limit - prefix.length - reserve); + for (const seg of hardSlice(content, segLimit)) { + addLine(prefix + seg); + } + continue; + } + + addLine(line); + if (content.trimStart().startsWith('```')) { + inFence = !inFence; + if (inFence) fencePrefix = prefix; // remember the depth for close/reopen + } + } + flush(false); + return pieces; +} + +/** + * Pack already-formatted messages into chunks, each guaranteed ≤ `hardLimit` + * characters, never dropping content. Whole messages stay together when they + * fit; a single message larger than `softLimit` is split on line boundaries + * (see {@link splitFormattedMessage}). A final defensive pass hard-slices any + * chunk still over `hardLimit` — silent loss here is the exact failure mode the + * carbon-copy fix exists to prevent. + */ +export function chunkByMessageBoundary( + formattedMessages: string[], + softLimit: number = CHUNK_SOFT_LIMIT, + hardLimit: number = CHUNK_HARD_LIMIT, +): string[] { const chunks: string[] = []; let buffer = ''; - for (const message of formattedMessages) { - if (buffer && buffer.length + 2 + message.length > limit) { + const flush = () => { + if (buffer) { chunks.push(buffer); buffer = ''; } - buffer = buffer ? `${buffer}\n\n${message}` : message; + }; + + for (const message of formattedMessages) { + const pieces = message.length <= softLimit ? [message] : splitFormattedMessage(message, softLimit); + for (const piece of pieces) { + if (buffer && buffer.length + 2 + piece.length > softLimit) flush(); + buffer = buffer ? `${buffer}\n\n${piece}` : piece; + } } - if (buffer) chunks.push(buffer); - return chunks; + flush(); + + return chunks.flatMap(chunk => (chunk.length <= hardLimit ? [chunk] : hardSlice(chunk, hardLimit))); } function shouldIncludeMessage(msg: TranscriptMessage): boolean { @@ -177,8 +320,22 @@ function shouldIncludeMessage(msg: TranscriptMessage): boolean { if (msg.hasOnlyComponents) return false; const hasText = msg.content.trim().length > 0; const hasAttachments = msg.attachments.length > 0; - const hasMeaningfulEmbeds = msg.embeds.some(e => e.title || e.description || (e.fields && e.fields.length > 0)); - return hasText || hasAttachments || hasMeaningfulEmbeds; + const hasStickers = msg.stickers.length > 0; + const hasPoll = msg.poll !== null; + // Keep any embed the renderer (formatEmbedBody) would produce visible output + // for — including author/footer-only embeds — so nothing the carbon copy can + // render is silently filtered out. + const hasMeaningfulEmbeds = msg.embeds.some( + e => + e.title || + e.description || + e.author || + e.footer || + e.imageUrl || + e.thumbnailUrl || + (e.fields && e.fields.length > 0), + ); + return hasText || hasAttachments || hasStickers || hasPoll || hasMeaningfulEmbeds; } /** diff --git a/tests/unit/utils/api/applicationHandlers.test.ts b/tests/unit/utils/api/applicationHandlers.test.ts index 6d4939d..d755950 100644 --- a/tests/unit/utils/api/applicationHandlers.test.ts +++ b/tests/unit/utils/api/applicationHandlers.test.ts @@ -25,13 +25,16 @@ import { } from "bun:test"; import type { Client } from "discord.js"; +// Injected into registerApplicationHandlers (3rd arg) — NOT mock.module'd. +// Mocking the shared application/closeWorkflow module here would leak +// process-globally and make closeWorkflow's own test suite import this fake +// instead of the real SUT (bun's mock.module is process-shared and not undone +// by mock.restore). The handler accepts the archive fn as a parameter for +// exactly this reason — mirrors registerTicketHandlers. const fakeArchiveAndCloseApp = jest.fn(async () => ({ success: true, archived: true, })); -mock.module("../../../../src/utils/application/closeWorkflow", () => ({ - archiveAndCloseApplication: fakeArchiveAndCloseApp, -})); const fakeWriteAuditLog = jest.fn(async () => undefined); mock.module("../../../../src/utils/api/handlers/auditHelper", () => ({ @@ -137,7 +140,7 @@ beforeEach(() => { } as any; routes = new Map(); - registerApplicationHandlers(fakeClient, routes); + registerApplicationHandlers(fakeClient, routes, fakeArchiveAndCloseApp); }); afterEach(() => { @@ -401,7 +404,64 @@ describe("POST /applications/:id/archive", () => { expect(fakeArchiveAndCloseApp).not.toHaveBeenCalled(); }); - test("archiveAndCloseApplication returns archived: false — handler propagates honest flag", async () => { + test("transient channel-fetch failure (non-10003): reverts status, returns failure (retryable)", async () => { + applicationRepoState.findOneByResult = { + id: 7, + guildId: "guild-1", + status: "pending", + channelId: "c-1", + }; + archivedAppConfigRepoState.findOneByResult = { + guildId: "guild-1", + channelId: "archive-forum-1", + }; + (fakeClient.channels.fetch as any).mockRejectedValue( + Object.assign(new Error("Service Unavailable"), { code: 0 }), + ); + + const result = await getRoute("POST /applications/:id/archive")( + "guild-1", + {}, + "/applications/7/archive", + ); + + expect(result).toEqual({ success: false, archived: false }); + expect(applicationRepoState.updateCalls[0].partial).toEqual({ + status: "closed", + }); + expect(applicationRepoState.updateCalls[1].partial).toEqual({ + status: "pending", + }); + expect(fakeArchiveAndCloseApp).not.toHaveBeenCalled(); + }); + + test("genuinely-gone channel (10003): terminal close, archived:false, no revert", async () => { + applicationRepoState.findOneByResult = { + id: 7, + guildId: "guild-1", + status: "pending", + channelId: "c-1", + }; + archivedAppConfigRepoState.findOneByResult = { + guildId: "guild-1", + channelId: "archive-forum-1", + }; + (fakeClient.channels.fetch as any).mockRejectedValue( + Object.assign(new Error("Unknown Channel"), { code: 10003 }), + ); + + const result = await getRoute("POST /applications/:id/archive")( + "guild-1", + {}, + "/applications/7/archive", + ); + + expect(result).toEqual({ success: true, archived: false }); + expect(applicationRepoState.updateCalls).toHaveLength(1); // close flip only, no revert + expect(fakeArchiveAndCloseApp).not.toHaveBeenCalled(); + }); + + test("archiveAndCloseApplication returns archived: false — reverts status for retry, no audit log", async () => { applicationRepoState.findOneByResult = { id: 7, guildId: "guild-1", @@ -413,7 +473,7 @@ describe("POST /applications/:id/archive", () => { channelId: "archive-forum-1", }; fakeArchiveAndCloseApp.mockResolvedValue({ - success: true, + success: false, archived: false, }); @@ -423,7 +483,15 @@ describe("POST /applications/:id/archive", () => { "/applications/7/archive", ); - expect(result).toEqual({ success: true, archived: false }); - expect(fakeWriteAuditLog).toHaveBeenCalledTimes(1); + // Honest failure; channel preserved by the workflow. + expect(result).toEqual({ success: false, archived: false }); + // Status flipped to closed, then reverted to its prior value for retry. + expect(applicationRepoState.updateCalls[0].partial).toEqual({ + status: "closed", + }); + expect(applicationRepoState.updateCalls[1].partial).toEqual({ + status: "pending", + }); + expect(fakeWriteAuditLog).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/utils/api/ticketHandlers.test.ts b/tests/unit/utils/api/ticketHandlers.test.ts index 5422090..f59fd5a 100644 --- a/tests/unit/utils/api/ticketHandlers.test.ts +++ b/tests/unit/utils/api/ticketHandlers.test.ts @@ -78,6 +78,7 @@ let registerTicketHandlers: typeof import("../../../../src/utils/api/handlers/ti let routes: Map; let fakeClient: Client; let fakeChannel: any; +let fakeAssignChannel: any; let originalGetRepository: ((entity: unknown) => unknown) | undefined; beforeAll(async () => { @@ -134,10 +135,20 @@ beforeEach(() => { fakeWriteAuditLog.mockClear(); fakeChannel = { id: "ticket-channel-1", isTextBased: () => true }; + fakeAssignChannel = { + id: "ticket-channel-1", + permissionOverwrites: { create: jest.fn(async () => undefined) }, + }; + const fakeGuild = { + channels: { fetch: jest.fn(async () => fakeAssignChannel) }, + }; fakeClient = { channels: { fetch: jest.fn(async () => fakeChannel), }, + guilds: { + cache: { get: jest.fn(() => fakeGuild) }, + }, } as any; routes = new Map(); @@ -276,7 +287,33 @@ describe("POST /tickets/:id/close", () => { expect(fakeWriteAuditLog).not.toHaveBeenCalled(); }); - test("archiveAndCloseTicket returns archived: false — handler propagates the honest flag", async () => { + test("transient channel-fetch failure (non-10003): reverts status, returns failure (retryable)", async () => { + ticketRepoState.findOneByResult = { + id: 42, + guildId: "guild-1", + status: "open", + channelId: "ticket-channel-1", + }; + archivedTicketConfigRepoState.findOneByResult = { + guildId: "guild-1", + channelId: "archive-forum-1", + }; + (fakeClient.channels.fetch as any).mockRejectedValue( + Object.assign(new Error("Service Unavailable"), { code: 0 }), + ); + + const result = await getCloseHandler()("guild-1", {}, "/tickets/42/close"); + + // A transient fetch failure must NOT strand the ticket closed. + expect(result).toEqual({ success: false, ticketId: 42, archived: false }); + expect(ticketRepoState.updateCalls[0].partial).toEqual({ + status: "closed", + }); + expect(ticketRepoState.updateCalls[1].partial).toEqual({ status: "open" }); + expect(fakeArchiveAndClose).not.toHaveBeenCalled(); + }); + + test("genuinely-gone channel (10003): terminal close, archived:false, no revert", async () => { ticketRepoState.findOneByResult = { id: 42, guildId: "guild-1", @@ -287,11 +324,115 @@ describe("POST /tickets/:id/close", () => { guildId: "guild-1", channelId: "archive-forum-1", }; - fakeArchiveAndClose.mockResolvedValue({ success: true, archived: false }); + (fakeClient.channels.fetch as any).mockRejectedValue( + Object.assign(new Error("Unknown Channel"), { code: 10003 }), + ); const result = await getCloseHandler()("guild-1", {}, "/tickets/42/close"); + // Channel is genuinely gone — nothing to archive, so the close is terminal. expect(result).toEqual({ success: true, ticketId: 42, archived: false }); - expect(fakeWriteAuditLog).toHaveBeenCalledTimes(1); + expect(ticketRepoState.updateCalls).toHaveLength(1); // close flip only, no revert + expect(fakeArchiveAndClose).not.toHaveBeenCalled(); + }); + + test("archiveAndCloseTicket returns archived: false — reverts status for retry, writes NO audit log", async () => { + ticketRepoState.findOneByResult = { + id: 42, + guildId: "guild-1", + status: "open", + channelId: "ticket-channel-1", + }; + archivedTicketConfigRepoState.findOneByResult = { + guildId: "guild-1", + channelId: "archive-forum-1", + }; + fakeArchiveAndClose.mockResolvedValue({ success: false, archived: false }); + + const result = await getCloseHandler()("guild-1", {}, "/tickets/42/close"); + + // Honest failure surfaced to the caller. + expect(result).toEqual({ success: false, ticketId: 42, archived: false }); + // Status flipped to closed, then reverted to its prior value so the close + // can be retried (the workflow preserved the channel). + expect(ticketRepoState.updateCalls[0].partial).toEqual({ + status: "closed", + }); + expect(ticketRepoState.updateCalls[1].partial).toEqual({ status: "open" }); + // A failed close is not an audit-worthy "ticket.close". + expect(fakeWriteAuditLog).not.toHaveBeenCalled(); + }); +}); + +describe("POST /tickets/:id/assign", () => { + const ASSIGNEE = "123456789012345678"; + + function getAssignHandler() { + const handler = routes.get("POST /tickets/:id/assign"); + if (!handler) throw new Error("POST /tickets/:id/assign not registered"); + return handler; + } + + test("persists assignedTo + assignedAt (the v3.2.1 bug fix), grants channel access, audits", async () => { + ticketRepoState.findOneByResult = { + id: 42, + guildId: "guild-1", + status: "open", + channelId: "ticket-channel-1", + }; + + const result = await getAssignHandler()( + "guild-1", + { userId: ASSIGNEE, triggeredBy: "admin-1" }, + "/tickets/42/assign", + ); + + expect(result).toEqual({ success: true }); + // THE FIX: the assignment is now written to the DB (was only a perm overwrite before). + expect(ticketRepoState.updateCalls).toHaveLength(1); + const { criteria, partial } = ticketRepoState.updateCalls[0]; + expect(criteria).toEqual({ id: 42, guildId: "guild-1" }); + expect(partial.assignedTo).toBe(ASSIGNEE); + expect(partial.assignedAt).toBeInstanceOf(Date); + // Channel access still granted. + expect(fakeAssignChannel.permissionOverwrites.create).toHaveBeenCalledWith( + ASSIGNEE, + expect.objectContaining({ ViewChannel: true, SendMessages: true }), + ); + expect(fakeWriteAuditLog).toHaveBeenCalledWith( + "guild-1", + "ticket.assign", + "admin-1", + { + ticketId: 42, + userId: ASSIGNEE, + }, + ); + }); + + test("rejects a non-snowflake userId before any write", async () => { + ticketRepoState.findOneByResult = { + id: 42, + guildId: "guild-1", + status: "open", + channelId: "ticket-channel-1", + }; + + await expect( + getAssignHandler()( + "guild-1", + { userId: "not-a-snowflake" }, + "/tickets/42/assign", + ), + ).rejects.toMatchObject({ statusCode: 400 }); + expect(ticketRepoState.updateCalls).toHaveLength(0); + }); + + test("404 when the ticket does not exist", async () => { + ticketRepoState.findOneByResult = null; + await expect( + getAssignHandler()("guild-1", { userId: ASSIGNEE }, "/tickets/42/assign"), + ).rejects.toMatchObject({ statusCode: 404 }); + expect(ticketRepoState.updateCalls).toHaveLength(0); }); }); diff --git a/tests/unit/utils/application/closeWorkflow.test.ts b/tests/unit/utils/application/closeWorkflow.test.ts new file mode 100644 index 0000000..29b8ab3 --- /dev/null +++ b/tests/unit/utils/application/closeWorkflow.test.ts @@ -0,0 +1,260 @@ +/** + * Application Close Workflow Behavioral Tests + * + * Mirrors the ticket close workflow suite. archiveAndCloseApplication contains + * its OWN copy of the two data-loss-prevention branches (it is not a delegation + * to the ticket code), so it needs its own coverage: + * - B1: re-close into a DELETED archive thread (Discord 10003) recreates + + * repoints the thread; a NON-10003 fetch error bubbles (archive fails). + * - B2: on archive failure the source channel is PRESERVED (not deleted) and + * the result is {success:false, archived:false}. + * + * All seams (fetchMessagesAsTranscript, verifiedChannelDelete, archivedAppRepo) + * are INJECTED via the function's `deps` argument — no mock.module() — for the + * same deterministic-on-Linux reasons documented in the ticket suite. + */ + +import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"; +import { + archiveAndCloseApplication, + type ArchiveApplicationResult, + type CloseApplicationWorkflowDeps, +} from "../../../../src/utils/application/closeWorkflow"; + +interface FakeRepoState { + findOneByResult: any; + saveCalls: any[]; + createCalls: any[]; +} + +const fakeRepoState: FakeRepoState = { + findOneByResult: null, + saveCalls: [], + createCalls: [], +}; + +const fakeRepo = { + findOneBy: jest.fn(async () => fakeRepoState.findOneByResult), + create: jest.fn((data: any) => { + fakeRepoState.createCalls.push(data); + return data; + }), + save: jest.fn(async (entity: any) => { + fakeRepoState.saveCalls.push(entity); + return entity; + }), +}; + +const fakeVerifiedChannelDelete = jest.fn(async () => ({ + success: true, + alreadyGone: false, +})); +const fakeFetchMessages = jest.fn(async () => [] as any[]); + +const deps = { + fetchMessagesAsTranscript: fakeFetchMessages, + verifiedChannelDelete: fakeVerifiedChannelDelete, + archivedAppRepo: fakeRepo, +} as unknown as CloseApplicationWorkflowDeps; + +interface FakeForumState { + threadsCreated: any[]; + threadsFetched: Map; + createShouldThrow?: Error; + fetchShouldThrow?: Error; +} + +function makeFakeThread(id = "new-thread-1") { + const state = { id, sentMessages: [] as string[] }; + return { + ...state, + send: jest.fn(async ({ content }: { content: string }) => { + state.sentMessages.push(content); + return { id: `${id}-msg-${state.sentMessages.length}` }; + }), + }; +} + +function makeFakeForumChannel(state: FakeForumState) { + return { + threads: { + create: jest.fn(async ({ name }: { name: string }) => { + if (state.createShouldThrow) throw state.createShouldThrow; + const thread = makeFakeThread(`thread-for-${name}`); + state.threadsCreated.push(thread); + return thread; + }), + fetch: jest.fn(async (id: string, _opts?: unknown) => { + if (state.fetchShouldThrow) throw state.fetchShouldThrow; + const existing = state.threadsFetched.get(id); + if (existing) return existing; + const thread = makeFakeThread(id); + state.threadsFetched.set(id, thread); + return thread; + }), + }, + }; +} + +function makeFakeClient(forumChannel: any, userResolver: (id: string) => any = () => ({ username: "applicant" })) { + return { + user: { id: "bot-client-id" }, + channels: { fetch: jest.fn(async () => forumChannel) }, + users: { fetch: jest.fn(async (id: string) => userResolver(id)) }, + } as any; +} + +function makeChannel(id = "app-channel-1") { + return { id, createdAt: new Date("2026-04-20T10:00:00Z") } as any; +} + +function makeApplication(overrides: Partial = {}): any { + return { + id: 1, + guildId: "guild-1", + channelId: "app-channel-1", + createdBy: "user-100", + status: "pending", + ...overrides, + }; +} + +describe("archiveAndCloseApplication", () => { + let forumState: FakeForumState; + let forumChannel: any; + + beforeEach(() => { + fakeRepoState.findOneByResult = null; + fakeRepoState.saveCalls = []; + fakeRepoState.createCalls = []; + fakeRepo.findOneBy.mockClear(); + fakeRepo.create.mockClear(); + fakeRepo.save.mockClear(); + fakeVerifiedChannelDelete.mockClear(); + fakeVerifiedChannelDelete.mockImplementation(async () => ({ success: true, alreadyGone: false })); + fakeFetchMessages.mockClear(); + fakeFetchMessages.mockImplementation(async () => []); + forumState = { threadsCreated: [], threadsFetched: new Map() }; + forumChannel = makeFakeForumChannel(forumState); + }); + + afterEach(() => jest.clearAllMocks()); + + test("happy path — first close: creates thread, saves archive row, deletes channel", async () => { + const client = makeFakeClient(forumChannel); + const result: ArchiveApplicationResult = await archiveAndCloseApplication( + client, + makeApplication(), + "guild-1", + makeChannel(), + "forum-archive-1", + deps, + ); + + expect(result).toEqual({ success: true, archived: true }); + expect(forumChannel.threads.create).toHaveBeenCalledTimes(1); + expect(fakeRepoState.createCalls).toHaveLength(1); + expect(fakeRepoState.createCalls[0]).toMatchObject({ + guildId: "guild-1", + createdBy: "user-100", + messageId: forumState.threadsCreated[0].id, + }); + expect(fakeVerifiedChannelDelete).toHaveBeenCalledTimes(1); + }); + + test("re-close append: posts separator into the existing thread, no new thread", async () => { + fakeRepoState.findOneByResult = { messageId: "existing-app-thread" }; + const client = makeFakeClient(forumChannel); + const result = await archiveAndCloseApplication( + client, + makeApplication(), + "guild-1", + makeChannel(), + "forum-archive-1", + deps, + ); + + expect(result).toEqual({ success: true, archived: true }); + expect(forumChannel.threads.create).not.toHaveBeenCalled(); + expect(forumChannel.threads.fetch).toHaveBeenCalledWith("existing-app-thread", { force: true }); + const thread = forumState.threadsFetched.get("existing-app-thread"); + expect(thread.sentMessages[0]).toContain("━━━"); + expect(fakeVerifiedChannelDelete).toHaveBeenCalledTimes(1); + }); + + test("B1: re-close into a DELETED thread (10003) recreates it, repoints messageId, deletes channel", async () => { + fakeRepoState.findOneByResult = { messageId: "deleted-app-thread" }; + forumState.fetchShouldThrow = Object.assign(new Error("Unknown Channel"), { code: 10003 }); + const client = makeFakeClient(forumChannel); + + const result = await archiveAndCloseApplication( + client, + makeApplication(), + "guild-1", + makeChannel(), + "forum-archive-1", + deps, + ); + + expect(result).toEqual({ success: true, archived: true }); + expect(forumChannel.threads.create).toHaveBeenCalledTimes(1); + expect(fakeRepoState.saveCalls).toHaveLength(1); + expect(fakeRepoState.saveCalls[0].messageId).toBe(forumState.threadsCreated[0].id); + expect(fakeVerifiedChannelDelete).toHaveBeenCalledTimes(1); + }); + + test("B1: NON-10003 fetch error bubbles — archive fails, no recreate, channel preserved", async () => { + fakeRepoState.findOneByResult = { messageId: "existing-app-thread" }; + forumState.fetchShouldThrow = Object.assign(new Error("Missing Access"), { code: 50001 }); + const client = makeFakeClient(forumChannel); + + const result = await archiveAndCloseApplication( + client, + makeApplication(), + "guild-1", + makeChannel(), + "forum-archive-1", + deps, + ); + + expect(result).toEqual({ success: false, archived: false }); + expect(forumChannel.threads.create).not.toHaveBeenCalled(); + expect(fakeVerifiedChannelDelete).not.toHaveBeenCalled(); + }); + + test("B2: forum post failure preserves the channel (no data loss)", async () => { + forumState.createShouldThrow = new Error("Discord 50013 — missing permissions"); + const client = makeFakeClient(forumChannel); + + const result = await archiveAndCloseApplication( + client, + makeApplication(), + "guild-1", + makeChannel(), + "forum-archive-1", + deps, + ); + + expect(result).toEqual({ success: false, archived: false }); + expect(fakeVerifiedChannelDelete).not.toHaveBeenCalled(); + expect(fakeRepoState.saveCalls).toHaveLength(0); + }); + + test("transcript fetch failure short-circuits before any forum write or channel delete", async () => { + fakeFetchMessages.mockRejectedValue(new Error("Discord API timeout")); + const client = makeFakeClient(forumChannel); + + const result = await archiveAndCloseApplication( + client, + makeApplication(), + "guild-1", + makeChannel(), + "forum-archive-1", + deps, + ); + + expect(result).toEqual({ success: false, archived: false, transcriptFailed: true }); + expect(forumChannel.threads.create).not.toHaveBeenCalled(); + expect(fakeVerifiedChannelDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/utils/fetchAllMessages.test.ts b/tests/unit/utils/fetchAllMessages.test.ts new file mode 100644 index 0000000..c44246e --- /dev/null +++ b/tests/unit/utils/fetchAllMessages.test.ts @@ -0,0 +1,175 @@ +/** + * fetchMessagesAsTranscript mapping tests. + * + * The pure builder (transcriptBuilder.test.ts) is driven with synthetic + * TranscriptMessage[] and never validates that the FETCHER actually populates + * those fields from a real discord.js Message. A wrong field path + * (e.g. m.poll.question vs m.poll.question.text, or a missing Collection→array + * conversion) would compile fine but silently drop content at runtime. These + * tests pin the mapping, plus one fetch→build integration check that a genuinely + * >2000-char message survives end-to-end. + */ + +import { describe, expect, test } from 'bun:test'; +import { fetchMessagesAsTranscript } from '../../../src/utils/fetchAllMessages'; +import { buildTranscript, type TicketMetadata } from '../../../src/utils/ticket/transcriptBuilder'; + +// --- fake discord.js Message construction ------------------------------------- + +function makeMsg(overrides: Record = {}): any { + return { + id: 'm1', + author: { username: 'alice', id: '111', bot: false }, + content: 'hello', + cleanContent: 'hello', + createdAt: new Date('2026-04-01T12:00:00Z'), + attachments: new Map(), + embeds: [], + stickers: new Map(), + poll: null, + reference: undefined, + system: false, + components: [], + ...overrides, + }; +} + +// Mimics discord.js Collection.fetch result: .size, .values(), .last(). +function makeBatch(messages: any[]) { + return { + size: messages.length, + values: () => messages.values(), + last: () => messages[messages.length - 1], + }; +} + +// One page of messages then an empty page (terminates the pagination loop). +function makeChannel(messages: any[]): any { + let served = false; + return { + messages: { + fetch: async () => { + if (served) return makeBatch([]); + served = true; + return makeBatch(messages); + }, + }, + }; +} + +const META: TicketMetadata = { + title: 'T', + type: 'T', + createdByUsername: 'alice', + openedAt: new Date('2026-04-01T12:00:00Z'), + closedAt: new Date('2026-04-01T12:30:00Z'), + assignedToUsername: null, +}; + +describe('fetchMessagesAsTranscript mapping', () => { + test('maps embed url/author/footer/image/thumbnail/color + fields', async () => { + const msg = makeMsg({ + embeds: [ + { + title: 'T', + description: 'D', + url: 'https://e/x', + author: { name: 'ci-bot' }, + footer: { text: 'foot' }, + image: { url: 'https://cdn/i.png' }, + thumbnail: { url: 'https://cdn/t.png' }, + color: 0x5865f2, + fields: [{ name: 'k', value: 'v' }], + }, + ], + }); + const [out] = await fetchMessagesAsTranscript(makeChannel([msg]), 'bot-id'); + expect(out.embeds[0]).toEqual({ + title: 'T', + description: 'D', + url: 'https://e/x', + author: 'ci-bot', + footer: 'foot', + imageUrl: 'https://cdn/i.png', + thumbnailUrl: 'https://cdn/t.png', + color: 0x5865f2, + fields: [{ name: 'k', value: 'v' }], + }); + }); + + test('absent embed sub-objects map to undefined (no crash)', async () => { + const msg = makeMsg({ + embeds: [{ title: null, description: 'only desc', url: null, author: null, footer: null, image: null, thumbnail: null, color: null, fields: [] }], + }); + const [out] = await fetchMessagesAsTranscript(makeChannel([msg]), 'bot-id'); + expect(out.embeds[0].description).toBe('only desc'); + expect(out.embeds[0].author).toBeUndefined(); + expect(out.embeds[0].imageUrl).toBeUndefined(); + expect(out.embeds[0].color).toBeUndefined(); + }); + + test('maps stickers (name + url)', async () => { + const stickers = new Map([['s1', { name: 'party', url: 'https://cdn/s.png' }]]); + const [out] = await fetchMessagesAsTranscript(makeChannel([makeMsg({ stickers })]), 'bot-id'); + expect(out.stickers).toEqual([{ name: 'party', url: 'https://cdn/s.png' }]); + }); + + test('maps a poll (question.text + per-answer text/voteCount)', async () => { + const poll = { + question: { text: 'Best color?' }, + answers: new Map([ + [1, { text: 'Red', voteCount: 3 }], + [2, { text: 'Blue', voteCount: 1 }], + ]), + }; + const [out] = await fetchMessagesAsTranscript(makeChannel([makeMsg({ poll })]), 'bot-id'); + expect(out.poll).toEqual({ + question: 'Best color?', + answers: [ + { text: 'Red', voteCount: 3 }, + { text: 'Blue', voteCount: 1 }, + ], + }); + }); + + test('no poll → poll:null; no stickers → []', async () => { + const [out] = await fetchMessagesAsTranscript(makeChannel([makeMsg()]), 'bot-id'); + expect(out.poll).toBeNull(); + expect(out.stickers).toEqual([]); + }); + + test('null poll question text maps to empty string (no crash)', async () => { + const poll = { question: { text: null }, answers: new Map([[1, { text: null, voteCount: 0 }]]) }; + const [out] = await fetchMessagesAsTranscript(makeChannel([makeMsg({ poll })]), 'bot-id'); + expect(out.poll).toEqual({ question: '', answers: [{ text: '', voteCount: 0 }] }); + }); + + test('classifies a bot component-only message as hasOnlyComponents', async () => { + const msg = makeMsg({ + author: { username: 'cog', id: 'bot-id', bot: true }, + content: '', + cleanContent: '', + components: [{ type: 1 }], + }); + const [out] = await fetchMessagesAsTranscript(makeChannel([msg]), 'bot-id'); + expect(out.hasOnlyComponents).toBe(true); + }); +}); + +describe('fetch → build integration', () => { + test('a single >2000-char message survives end-to-end with every chunk <= 2000', async () => { + const body = `START-${'q'.repeat(5000)}-END`; + const msg = makeMsg({ content: body, cleanContent: body }); + const transcriptMessages = await fetchMessagesAsTranscript(makeChannel([msg]), 'bot-id'); + expect(transcriptMessages[0].content).toBe(body); // fetcher preserved it whole + + const result = buildTranscript(transcriptMessages, META); + for (const chunk of result.chunks) { + expect(chunk.length).toBeLessThanOrEqual(2000); + } + const flat = result.chunks.join('\n').replace(/\n> /g, '').replace(/^> /gm, ''); + expect(flat).toContain('START-'); + expect(flat).toContain('-END'); + expect(flat.split('q').length - 1).toBe(5000); + }); +}); diff --git a/tests/unit/utils/ticket/closeWorkflow.test.ts b/tests/unit/utils/ticket/closeWorkflow.test.ts index ecfdec9..45ff6f0 100644 --- a/tests/unit/utils/ticket/closeWorkflow.test.ts +++ b/tests/unit/utils/ticket/closeWorkflow.test.ts @@ -149,11 +149,15 @@ interface FakeThreadState { sentMessages: string[]; } -function makeFakeThread(id = "new-thread-1"): FakeThreadState & { send: any } { +function makeFakeThread( + id = "new-thread-1", + sendError?: Error, +): FakeThreadState & { send: any } { const state: FakeThreadState = { id, sentMessages: [] }; return { ...state, send: jest.fn(async ({ content }: { content: string }) => { + if (sendError) throw sendError; state.sentMessages.push(content); return { id: `${id}-msg-${state.sentMessages.length}` }; }), @@ -165,6 +169,7 @@ interface FakeForumState { threadsFetched: Map; createShouldThrow?: Error; fetchShouldThrow?: Error; + threadSendShouldThrow?: Error; } function makeFakeForumChannel(state: FakeForumState) { @@ -172,7 +177,10 @@ function makeFakeForumChannel(state: FakeForumState) { threads: { create: jest.fn(async ({ name }: { name: string }) => { if (state.createShouldThrow) throw state.createShouldThrow; - const thread = makeFakeThread(`thread-for-${name}`); + const thread = makeFakeThread( + `thread-for-${name}`, + state.threadSendShouldThrow, + ); state.threadsCreated.push(thread); return thread; }), @@ -455,10 +463,11 @@ describe("archiveAndCloseTicket", () => { ); expect(result).toEqual({ success: true, archived: true }); - // No NEW thread created — existing one fetched + // No NEW thread created — existing one fetched (force:true bypasses cache) expect(forumChannel.threads.create).not.toHaveBeenCalled(); expect(forumChannel.threads.fetch).toHaveBeenCalledWith( "existing-thread-9", + { force: true }, ); const existingThread = forumState.threadsFetched.get("existing-thread-9"); expect(existingThread.sentMessages.length).toBeGreaterThan(0); @@ -538,7 +547,7 @@ describe("archiveAndCloseTicket", () => { expect(fakeVerifiedChannelDelete).not.toHaveBeenCalled(); }); - test("forum post failure: ticket still closes (archived: false but success: true)", async () => { + test("forum post failure: archive fails, channel PRESERVED for retry (v3.2.1 — no data loss)", async () => { fakeBuiltinTypeInfo.mockReturnValue({ typeId: "general", displayName: "General", @@ -562,14 +571,142 @@ describe("archiveAndCloseTicket", () => { deps, ); - // Honest archived flag (the v3.1.9 contract-fidelity fix) - expect(result).toEqual({ success: true, archived: false }); - // Channel still deleted despite archive failure - expect(fakeVerifiedChannelDelete).toHaveBeenCalledTimes(1); - // No archive row saved (creation never reached the save call) + // Archive failed → honest failure; the caller reverts the ticket status. + expect(result).toEqual({ success: false, archived: false }); + // CRITICAL: the source channel is NOT deleted — it's the only remaining + // copy of the conversation. Deleting it on archive failure was the data-loss + // amplifier this fix removes. + expect(fakeVerifiedChannelDelete).not.toHaveBeenCalled(); + // No archive row saved (creation never reached the save call). expect(fakeRepoState.saveCalls).toHaveLength(0); }); + test("first-close chunk-send failure: archive row was already SAVED (retry appends, no orphan/duplicate)", async () => { + fakeBuiltinTypeInfo.mockReturnValue({ + typeId: "general", + displayName: "General", + emoji: null, + }); + // The thread is created, but a follow-up chunk send throws. + fakeFetchMessages.mockResolvedValue([ + { + author: { username: "u", id: "1", bot: false }, + content: "x", + timestamp: new Date(), + attachments: [], + embeds: [], + stickers: [], + poll: null, + isSystem: false, + hasOnlyComponents: false, + }, + ]); + forumState.threadSendShouldThrow = new Error( + "Discord 500 — chunk send failed", + ); + const client = makeFakeClient(forumChannel, () => ({ + username: "creator", + })); + const channel = makeFakeChannel(); + const ticket = makeTicket({ type: "general" }); + + const result = await archiveAndCloseTicket( + client, + ticket, + "guild-1", + channel, + "forum-archive-1", + deps, + ); + + expect(result).toEqual({ success: false, archived: false }); + // The thread was created AND the archive row was persisted BEFORE the chunk + // send failed — so a retry finds the row and appends instead of orphaning + // this thread and creating a duplicate. + expect(forumState.threadsCreated).toHaveLength(1); + expect(fakeRepoState.saveCalls).toHaveLength(1); + expect(fakeRepoState.saveCalls[0].messageId).toBe( + forumState.threadsCreated[0].id, + ); + // Channel preserved (archive failed). + expect(fakeVerifiedChannelDelete).not.toHaveBeenCalled(); + }); + + test("re-close into a DELETED thread (10003): recreates it, repoints messageId, channel deleted", async () => { + fakeBuiltinTypeInfo.mockReturnValue({ + typeId: "general", + displayName: "General", + emoji: null, + }); + fakeRepoState.findOneByResult = { + messageId: "deleted-thread-1", + forumTagIds: ["tag-old"], + }; + forumState.fetchShouldThrow = Object.assign(new Error("Unknown Channel"), { + code: 10003, + }); + const client = makeFakeClient(forumChannel, () => ({ + username: "creator", + })); + const channel = makeFakeChannel(); + const ticket = makeTicket({ type: "general" }); + + const result = await archiveAndCloseTicket( + client, + ticket, + "guild-1", + channel, + "forum-archive-1", + deps, + ); + + expect(result).toEqual({ success: true, archived: true }); + // A replacement thread was created (the deleted one is unrecoverable). + expect(forumChannel.threads.create).toHaveBeenCalledTimes(1); + // Archive row repointed to the new thread + accumulated tags, then saved. + expect(fakeRepoState.saveCalls).toHaveLength(1); + const saved = fakeRepoState.saveCalls[0]; + expect(saved.messageId).toBe(forumState.threadsCreated[0].id); + expect(saved.forumTagIds).toEqual(["tag-old", "tag-123"]); + // Recovery succeeded → channel deleted as normal. + expect(fakeVerifiedChannelDelete).toHaveBeenCalledTimes(1); + }); + + test("re-close where thread fetch fails NON-10003: bubbles, archive fails, channel preserved", async () => { + fakeBuiltinTypeInfo.mockReturnValue({ + typeId: "general", + displayName: "General", + emoji: null, + }); + fakeRepoState.findOneByResult = { + messageId: "existing-thread-9", + forumTagIds: [], + }; + forumState.fetchShouldThrow = Object.assign(new Error("Missing Access"), { + code: 50001, + }); + const client = makeFakeClient(forumChannel, () => ({ + username: "creator", + })); + const channel = makeFakeChannel(); + const ticket = makeTicket({ type: "general" }); + + const result = await archiveAndCloseTicket( + client, + ticket, + "guild-1", + channel, + "forum-archive-1", + deps, + ); + + expect(result).toEqual({ success: false, archived: false }); + // We must NOT recreate — a non-10003 error doesn't prove the thread is gone, + // and recreating would orphan the real thread + duplicate the archive. + expect(forumChannel.threads.create).not.toHaveBeenCalled(); + expect(fakeVerifiedChannelDelete).not.toHaveBeenCalled(); + }); + test("channel delete: already-gone counts as success (Discord 10003)", async () => { fakeBuiltinTypeInfo.mockReturnValue({ typeId: "general", diff --git a/tests/unit/utils/ticket/transcriptBuilder.test.ts b/tests/unit/utils/ticket/transcriptBuilder.test.ts index 084378e..0ec4c75 100644 --- a/tests/unit/utils/ticket/transcriptBuilder.test.ts +++ b/tests/unit/utils/ticket/transcriptBuilder.test.ts @@ -6,7 +6,7 @@ * markdown output. */ -import { describe, expect, test } from 'bun:test'; +import { describe, expect, test } from "bun:test"; import { buildTranscript, chunkByMessageBoundary, @@ -15,16 +15,19 @@ import { formatMessage, type TicketMetadata, type TranscriptMessage, - truncateLongMessage, -} from '../../../../src/utils/ticket/transcriptBuilder'; +} from "../../../../src/utils/ticket/transcriptBuilder"; -function makeMessage(overrides: Partial = {}): TranscriptMessage { +function makeMessage( + overrides: Partial = {}, +): TranscriptMessage { return { - author: { username: 'alice', id: '111', bot: false }, - content: 'Hello world', - timestamp: new Date('2026-04-01T12:00:00Z'), + author: { username: "alice", id: "111", bot: false }, + content: "Hello world", + timestamp: new Date("2026-04-01T12:00:00Z"), attachments: [], embeds: [], + stickers: [], + poll: null, isSystem: false, hasOnlyComponents: false, ...overrides, @@ -32,157 +35,210 @@ function makeMessage(overrides: Partial = {}): TranscriptMess } const META: TicketMetadata = { - title: 'Ban Appeal', - type: 'Ban Appeal', - createdByUsername: 'alice', - openedAt: new Date('2026-04-01T12:00:00Z'), - closedAt: new Date('2026-04-01T14:14:00Z'), - assignedToUsername: 'staff_bob', + title: "Ban Appeal", + type: "Ban Appeal", + createdByUsername: "alice", + openedAt: new Date("2026-04-01T12:00:00Z"), + closedAt: new Date("2026-04-01T14:14:00Z"), + assignedToUsername: "staff_bob", }; -describe('formatDurationShort()', () => { - test('under a minute renders as <1m', () => { - expect(formatDurationShort(5_000)).toBe('<1m'); +describe("formatDurationShort()", () => { + test("under a minute renders as <1m", () => { + expect(formatDurationShort(5_000)).toBe("<1m"); }); - test('minutes-only', () => { - expect(formatDurationShort(47 * 60_000)).toBe('47m'); + test("minutes-only", () => { + expect(formatDurationShort(47 * 60_000)).toBe("47m"); }); - test('hours-and-minutes', () => { - expect(formatDurationShort(2 * 60 * 60_000 + 14 * 60_000)).toBe('2h 14m'); + test("hours-and-minutes", () => { + expect(formatDurationShort(2 * 60 * 60_000 + 14 * 60_000)).toBe("2h 14m"); }); - test('hours-only when minutes are zero', () => { - expect(formatDurationShort(3 * 60 * 60_000)).toBe('3h'); + test("hours-only when minutes are zero", () => { + expect(formatDurationShort(3 * 60 * 60_000)).toBe("3h"); }); - test('multi-day duration', () => { - expect(formatDurationShort(3 * 24 * 60 * 60_000 + 5 * 60 * 60_000)).toBe('3d 5h'); + test("multi-day duration", () => { + expect(formatDurationShort(3 * 24 * 60 * 60_000 + 5 * 60 * 60_000)).toBe( + "3d 5h", + ); }); - test('multi-day duration with no hours', () => { - expect(formatDurationShort(2 * 24 * 60 * 60_000)).toBe('2d'); + test("multi-day duration with no hours", () => { + expect(formatDurationShort(2 * 24 * 60 * 60_000)).toBe("2d"); }); }); -describe('truncateLongMessage()', () => { - test('short content passes through unchanged', () => { - expect(truncateLongMessage('short', 500)).toBe('short'); - }); - - test('exceeds limit — appends truncation marker', () => { - const body = 'x'.repeat(600); - const result = truncateLongMessage(body, 500); - expect(result).toHaveLength(500 + '… (truncated)'.length); - expect(result.endsWith('… (truncated)')).toBe(true); - }); -}); +// truncateLongMessage was removed in v3.2.1 — content is split across chunks, +// never truncated. The carbon-copy guarantees are covered by the formatMessage +// and chunkByMessageBoundary suites below. -describe('formatHeader()', () => { - test('contains all required metadata lines', () => { +describe("formatHeader()", () => { + test("contains all required metadata lines", () => { const header = formatHeader(META, 5, 2); - expect(header).toContain('# 🎫 Ticket: Ban Appeal'); - expect(header).toContain('**Created by:** alice'); - expect(header).toContain('**Type:** Ban Appeal'); - expect(header).toContain('**Assigned to:** staff_bob'); - expect(header).toContain('**Messages:** 5'); - expect(header).toContain('**Attachments:** 2'); - expect(header).toContain('**Duration:** 2h 14m'); + expect(header).toContain("# 🎫 Ticket: Ban Appeal"); + expect(header).toContain("**Created by:** alice"); + expect(header).toContain("**Type:** Ban Appeal"); + expect(header).toContain("**Assigned to:** staff_bob"); + expect(header).toContain("**Messages:** 5"); + expect(header).toContain("**Attachments:** 2"); + expect(header).toContain("**Duration:** 2h 14m"); }); - test('omits the attachments line when count is zero', () => { + test("omits the attachments line when count is zero", () => { const header = formatHeader(META, 3, 0); - expect(header).not.toContain('**Attachments:**'); + expect(header).not.toContain("**Attachments:**"); }); - test('renders Unassigned when assigneeUsername is null', () => { + test("renders Unassigned when assigneeUsername is null", () => { const header = formatHeader({ ...META, assignedToUsername: null }, 1, 0); - expect(header).toContain('**Assigned to:** Unassigned'); + expect(header).toContain("**Assigned to:** Unassigned"); }); }); -describe('formatMessage()', () => { - test('plain text becomes a blockquote', () => { - const out = formatMessage(makeMessage({ content: 'Hello\nWorld' })); - expect(out).toContain('**alice**'); - expect(out).toContain('> Hello'); - expect(out).toContain('> World'); +describe("formatMessage()", () => { + test("plain text becomes a blockquote", () => { + const out = formatMessage(makeMessage({ content: "Hello\nWorld" })); + expect(out).toContain("**alice**"); + expect(out).toContain("> Hello"); + expect(out).toContain("> World"); }); - test('reply adds ↩️ suffix with original author', () => { + test("reply adds ↩️ suffix with original author", () => { const out = formatMessage( makeMessage({ - content: 'got it', - replyTo: { author: 'staff_bob', content: 'please confirm' }, + content: "got it", + replyTo: { author: "staff_bob", content: "please confirm" }, }), ); - expect(out).toContain('↩️ *replying to staff_bob*'); + expect(out).toContain("↩️ *replying to staff_bob*"); }); - test('multiple attachments render each on its own line', () => { + test("multiple attachments render each on its own line", () => { const out = formatMessage( makeMessage({ - content: '', + content: "", attachments: [ - { name: 'img.png', url: 'https://cdn/img.png', contentType: 'image/png' }, - { name: 'log.txt', url: 'https://cdn/log.txt' }, + { + name: "img.png", + url: "https://cdn/img.png", + contentType: "image/png", + }, + { name: "log.txt", url: "https://cdn/log.txt" }, ], }), ); - expect(out).toContain('> 📎 [img.png](https://cdn/img.png)'); - expect(out).toContain('> 📎 [log.txt](https://cdn/log.txt)'); + expect(out).toContain("> 📎 [img.png](https://cdn/img.png)"); + expect(out).toContain("> 📎 [log.txt](https://cdn/log.txt)"); }); - test('attachment with empty URL renders as unavailable', () => { + test("attachment with empty URL renders as unavailable", () => { const out = formatMessage( makeMessage({ - content: '', - attachments: [{ name: 'gone.pdf', url: '' }], + content: "", + attachments: [{ name: "gone.pdf", url: "" }], }), ); - expect(out).toContain('> 📎 ~~gone.pdf~~ (unavailable)'); + expect(out).toContain("> 📎 ~~gone.pdf~~ (unavailable)"); }); - test('code block content is preserved inside the blockquote', () => { - const content = '```js\nconst x = 1;\n```'; + test("code block content is preserved inside the blockquote", () => { + const content = "```js\nconst x = 1;\n```"; const out = formatMessage(makeMessage({ content })); - expect(out).toContain('> ```js'); - expect(out).toContain('> const x = 1;'); + expect(out).toContain("> ```js"); + expect(out).toContain("> const x = 1;"); + }); + + test("embed with title + description renders indented under the message", () => { + const out = formatMessage( + makeMessage({ + content: "", + embeds: [ + { title: "Heads up", description: "This is important", fields: [] }, + ], + }), + ); + expect(out).toContain("**Heads up**"); + expect(out).toContain("This is important"); + }); + + test("empty message falls back to placeholder so the header still lines up", () => { + const out = formatMessage(makeMessage({ content: "" })); + expect(out).toContain("*(no content)*"); + }); + + test("long single message keeps ALL content — never truncated (carbon-copy)", () => { + const body = "x".repeat(2000); + const out = formatMessage(makeMessage({ content: body })); + expect(out).not.toContain("… (truncated)"); + // Every original character survives (blockquote prefixes add `> ` but the + // body text itself is intact). + expect(out).toContain(body); }); - test('embed with title + description renders indented under the message', () => { + test("renders a sticker as a labelled link", () => { const out = formatMessage( makeMessage({ - content: '', - embeds: [{ title: 'Heads up', description: 'This is important', fields: [] }], + content: "", + stickers: [{ name: "party", url: "https://cdn/sticker.png" }], }), ); - expect(out).toContain('**Heads up**'); - expect(out).toContain('This is important'); + expect(out).toContain("> 🏷️ Sticker: [party](https://cdn/sticker.png)"); }); - test('empty message falls back to placeholder so the header still lines up', () => { - const out = formatMessage(makeMessage({ content: '' })); - expect(out).toContain('*(no content)*'); + test("renders a poll with question + per-answer vote counts", () => { + const out = formatMessage( + makeMessage({ + content: "", + poll: { + question: "Best color?", + answers: [ + { text: "Red", voteCount: 3 }, + { text: "Blue", voteCount: 1 }, + ], + }, + }), + ); + expect(out).toContain("> 📊 **Poll:** Best color?"); + expect(out).toContain("> • Red — 3 votes"); + expect(out).toContain("> • Blue — 1 vote"); }); - test('long single message is truncated inline', () => { - const out = formatMessage(makeMessage({ content: 'x'.repeat(2000) })); - expect(out).toContain('… (truncated)'); + test("renders embed image + footer + author + linked title", () => { + const out = formatMessage( + makeMessage({ + content: "", + embeds: [ + { + title: "Release", + url: "https://example.com/r", + author: "ci-bot", + footer: "built at 12:00", + imageUrl: "https://cdn/img.png", + fields: [], + }, + ], + }), + ); + expect(out).toContain("**[Release](https://example.com/r)**"); + expect(out).toContain("*ci-bot*"); + expect(out).toContain("🖼️ [image](https://cdn/img.png)"); + expect(out).toContain("— built at 12:00"); }); }); -describe('chunkByMessageBoundary()', () => { - test('small messages fit in one chunk', () => { - const chunks = chunkByMessageBoundary(['aaa', 'bbb', 'ccc'], 1900); +describe("chunkByMessageBoundary()", () => { + test("small messages fit in one chunk", () => { + const chunks = chunkByMessageBoundary(["aaa", "bbb", "ccc"], 1900); expect(chunks).toHaveLength(1); - expect(chunks[0]).toContain('aaa'); - expect(chunks[0]).toContain('ccc'); + expect(chunks[0]).toContain("aaa"); + expect(chunks[0]).toContain("ccc"); }); - test('splits on message boundary when buffer would exceed limit', () => { - const big = 'x'.repeat(1000); + test("splits on message boundary when buffer would exceed limit", () => { + const big = "x".repeat(1000); const chunks = chunkByMessageBoundary([big, big, big], 1900); // 1000 + 2 + 1000 = 2002 > 1900, so each chunk holds one message. expect(chunks).toHaveLength(3); @@ -191,86 +247,239 @@ describe('chunkByMessageBoundary()', () => { } }); - test('individual oversized message is kept intact (caller truncated it already)', () => { - const oversize = 'y'.repeat(3000); - const chunks = chunkByMessageBoundary([oversize], 1900); - expect(chunks).toHaveLength(1); - expect(chunks[0]).toBe(oversize); + test("individual oversized message is SPLIT, not dropped — every chunk fits the hard limit", () => { + // A single message of 30 lines × ~200 chars = ~6000 chars: well over a + // single Discord post. Pre-v3.2.1 this was truncated at 500 chars; now it + // must split across chunks with zero content loss. + const lines = Array.from( + { length: 30 }, + (_, i) => `> line ${i} ${"y".repeat(190)}`, + ); + const oversize = lines.join("\n"); + const chunks = chunkByMessageBoundary([oversize], 1900, 2000); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(2000); + } + // Every original line survives somewhere in the output. + const joined = chunks.join("\n"); + for (let i = 0; i < 30; i++) { + expect(joined).toContain(`line ${i} `); + } + }); + + test("a single line longer than the limit is hard-sliced, never dropped", () => { + const giant = `> ${"z".repeat(5000)}`; + const chunks = chunkByMessageBoundary([giant], 1900, 2000); + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(2000); + } + // All 5000 z's are preserved across the chunks. + const zCount = chunks.join("").split("z").length - 1; + expect(zCount).toBe(5000); + }); + + test("splitting inside a code fence balances the fence on both pieces", () => { + // Force a split mid-fence: a long fenced block as one formatted message. + const codeLines = Array.from( + { length: 40 }, + (_, i) => `> const v${i} = ${"a".repeat(60)};`, + ); + const message = ["**alice** ts", "> ```js", ...codeLines, "> ```"].join( + "\n", + ); + const chunks = chunkByMessageBoundary([message], 1900, 2000); + expect(chunks.length).toBeGreaterThan(1); + // Each chunk has a balanced number of ``` fences (so each renders cleanly). + for (const chunk of chunks) { + const fences = chunk.split("```").length - 1; + expect(fences % 2).toBe(0); + } }); - test('never splits mid-message even when two back-to-back fit but a third does not', () => { - const mid = 'z'.repeat(900); + test("balances a fence nested inside a double blockquote (embed code block)", () => { + // Embed bodies are double-quoted (`> > `). A fence there must still toggle + // and rebalance at the right depth (Copilot review finding). + const codeLines = Array.from( + { length: 40 }, + (_, i) => `> > const v${i} = ${"b".repeat(60)};`, + ); + const message = [ + "**author** ts", + "> > ```js", + ...codeLines, + "> > ```", + ].join("\n"); + const chunks = chunkByMessageBoundary([message], 1900, 2000); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect((chunk.split("```").length - 1) % 2).toBe(0); // balanced fences + expect(chunk.length).toBeLessThanOrEqual(2000); + } + }); + + test("hard-sliced long blockquoted line keeps its `> ` prefix on every continuation", () => { + // A 5000-char single blockquoted line (e.g. a long URL/base64). Each emitted + // segment must retain the `> ` prefix so continuations stay blockquoted, and + // nothing is dropped (Copilot review finding). + const longLine = `> ${"a".repeat(5000)}`; + const chunks = chunkByMessageBoundary([longLine], 1900, 2000); + const lines = chunks + .flatMap((c) => c.split("\n")) + .filter((l) => l.length > 0); + expect(lines.length).toBeGreaterThan(1); + for (const line of lines) expect(line.startsWith("> ")).toBe(true); + expect(chunks.join("").split("a").length - 1).toBe(5000); + for (const chunk of chunks) expect(chunk.length).toBeLessThanOrEqual(2000); + }); + + test("never splits mid-message even when two back-to-back fit but a third does not", () => { + const mid = "z".repeat(900); const chunks = chunkByMessageBoundary([mid, mid, mid], 1900); // First chunk: mid + '\n\n' + mid = 1802 ≤ 1900 → fits. Third goes alone. expect(chunks).toHaveLength(2); }); }); -describe('buildTranscript()', () => { - test('filters system + component-only messages', () => { +describe("buildTranscript()", () => { + test("filters system + component-only messages", () => { const messages: TranscriptMessage[] = [ - makeMessage({ content: 'real message from alice' }), - makeMessage({ content: '', isSystem: true }), - makeMessage({ content: '', hasOnlyComponents: true }), + makeMessage({ content: "real message from alice" }), + makeMessage({ content: "", isSystem: true }), + makeMessage({ content: "", hasOnlyComponents: true }), makeMessage({ - author: { username: 'bob', id: '222', bot: false }, - content: 'another real message', + author: { username: "bob", id: "222", bot: false }, + content: "another real message", }), ]; const result = buildTranscript(messages, META); expect(result.messageCount).toBe(2); - expect(result.chunks.join('\n')).toContain('real message from alice'); - expect(result.chunks.join('\n')).toContain('another real message'); + expect(result.chunks.join("\n")).toContain("real message from alice"); + expect(result.chunks.join("\n")).toContain("another real message"); }); - test('counts attachments across surviving messages', () => { + test("counts attachments across surviving messages", () => { const messages: TranscriptMessage[] = [ makeMessage({ - content: 'pics or it didnt happen', + content: "pics or it didnt happen", attachments: [ - { name: 'a.png', url: 'https://cdn/a.png' }, - { name: 'b.png', url: 'https://cdn/b.png' }, + { name: "a.png", url: "https://cdn/a.png" }, + { name: "b.png", url: "https://cdn/b.png" }, ], }), makeMessage({ - content: '', + content: "", hasOnlyComponents: true, - attachments: [{ name: 'filtered-out.png', url: 'https://cdn/f.png' }], + attachments: [{ name: "filtered-out.png", url: "https://cdn/f.png" }], }), ]; const result = buildTranscript(messages, META); expect(result.attachmentCount).toBe(2); }); - test('empty ticket produces a placeholder chunk', () => { + test("empty ticket produces a placeholder chunk", () => { const result = buildTranscript([], META); expect(result.messageCount).toBe(0); - expect(result.chunks).toEqual(['*(No messages)*']); + expect(result.chunks).toEqual(["*(No messages)*"]); }); - test('bot-only ticket (only system/component noise) returns the human-empty placeholder', () => { + test("bot-only ticket (only system/component noise) returns the human-empty placeholder", () => { const messages: TranscriptMessage[] = [ - makeMessage({ content: '', isSystem: true }), - makeMessage({ content: '', hasOnlyComponents: true }), + makeMessage({ content: "", isSystem: true }), + makeMessage({ content: "", hasOnlyComponents: true }), ]; const result = buildTranscript(messages, META); expect(result.messageCount).toBe(0); - expect(result.chunks).toEqual(['*(No human messages)*']); + expect(result.chunks).toEqual(["*(No human messages)*"]); + }); + + test("preserves chronological ordering", () => { + const messages: TranscriptMessage[] = [ + makeMessage({ + content: "first", + timestamp: new Date("2026-04-01T12:00:00Z"), + }), + makeMessage({ + author: { username: "bob", id: "222", bot: false }, + content: "second", + timestamp: new Date("2026-04-01T12:05:00Z"), + }), + makeMessage({ + content: "third", + timestamp: new Date("2026-04-01T12:10:00Z"), + }), + ]; + const result = buildTranscript(messages, META); + const joined = result.chunks.join("\n"); + expect(joined.indexOf("first")).toBeLessThan(joined.indexOf("second")); + expect(joined.indexOf("second")).toBeLessThan(joined.indexOf("third")); }); - test('preserves chronological ordering', () => { + test("keeps a sticker-only message (no longer filtered)", () => { const messages: TranscriptMessage[] = [ - makeMessage({ content: 'first', timestamp: new Date('2026-04-01T12:00:00Z') }), makeMessage({ - author: { username: 'bob', id: '222', bot: false }, - content: 'second', - timestamp: new Date('2026-04-01T12:05:00Z'), + content: "", + stickers: [{ name: "wave", url: "https://cdn/wave.png" }], }), - makeMessage({ content: 'third', timestamp: new Date('2026-04-01T12:10:00Z') }), ]; const result = buildTranscript(messages, META); - const joined = result.chunks.join('\n'); - expect(joined.indexOf('first')).toBeLessThan(joined.indexOf('second')); - expect(joined.indexOf('second')).toBeLessThan(joined.indexOf('third')); + expect(result.messageCount).toBe(1); + expect(result.chunks.join("\n")).toContain("Sticker: [wave]"); + }); + + test("keeps a poll-only message (no longer filtered)", () => { + const messages: TranscriptMessage[] = [ + makeMessage({ + content: "", + poll: { question: "Q?", answers: [{ text: "A", voteCount: 0 }] }, + }), + ]; + const result = buildTranscript(messages, META); + expect(result.messageCount).toBe(1); + expect(result.chunks.join("\n")).toContain("📊 **Poll:** Q?"); + }); + + test("keeps an author/footer-only embed message (filter aligned with renderer)", () => { + const messages: TranscriptMessage[] = [ + makeMessage({ + content: "", + embeds: [{ author: "ci-bot", footer: "v1.2.3" }], + }), + ]; + const result = buildTranscript(messages, META); + expect(result.messageCount).toBe(1); + const joined = result.chunks.join("\n"); + expect(joined).toContain("ci-bot"); + expect(joined).toContain("v1.2.3"); + }); + + test("carbon-copy: a multi-thousand-char conversation loses nothing across chunks", () => { + // 8 messages, each ~3000 chars — far past a single Discord post. The full + // text of every message must be reconstructable from the chunks. + const bodies = Array.from( + { length: 8 }, + (_, i) => `MSG${i}-${"w".repeat(3000)}-END${i}`, + ); + const messages = bodies.map((content, i) => + makeMessage({ content, timestamp: new Date(`2026-04-01T12:0${i}:00Z`) }), + ); + const result = buildTranscript(messages, META); + for (const chunk of result.chunks) { + expect(chunk.length).toBeLessThanOrEqual(2000); + } + // Strip the `> ` blockquote prefixes and chunk joins, then assert every + // message's start and end markers survived. + const flat = result.chunks + .join("\n") + .replace(/\n> /g, "") + .replace(/^> /gm, ""); + for (let i = 0; i < 8; i++) { + expect(flat).toContain(`MSG${i}-`); + expect(flat).toContain(`-END${i}`); + } + // Total-interior-content assertion: every one of the 8×3000 filler chars + // must survive. Boundary-marker checks alone would still pass if an entire + // interior chunk were silently dropped — this count would not. + expect(flat.split("w").length - 1).toBe(8 * 3000); }); });