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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
- **Runtime**: Bun
- **Deployment**: Docker containers
- **Branches**: `main` (production)
- **Version**: 3.2.0
- **Version**: 3.2.1

## Critical Rules

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
43 changes: 32 additions & 11 deletions src/events/application/close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 },
);
});
}
};
Expand Down
42 changes: 30 additions & 12 deletions src/events/ticket/close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
);
});
}
};
Expand Down
39 changes: 35 additions & 4 deletions src/utils/api/handlers/applicationHandlers.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,7 +11,18 @@ import { writeAuditLog } from './auditHelper';
const applicationRepo = lazyRepo(Application);
const archivedAppConfigRepo = lazyRepo(ArchivedApplicationConfig);

export function registerApplicationHandlers(client: Client, routes: Map<string, RouteHandler>): 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<string, RouteHandler>,
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');
Expand Down Expand Up @@ -71,7 +82,20 @@ export function registerApplicationHandlers(client: Client, routes: Map<string,
// Mark closed
await applicationRepo.update({ id: app.id, guildId }, { status: 'closed' });

const channel = app.channelId ? await client.channels.fetch(app.channelId).catch(() => 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 };
}
Expand All @@ -84,10 +108,17 @@ export function registerApplicationHandlers(client: Client, routes: Map<string,
archivedConfig.channelId,
);

if (!result.archived) {
// Archive failed — the workflow preserved the channel; revert the status
// so the archive can be retried instead of stranding it 'closed'.
await applicationRepo.update({ id: app.id, guildId }, { status: app.status });
return { success: false, archived: false };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const triggeredBy = optionalString(body, 'triggeredBy');
await writeAuditLog(guildId, 'application.archive', triggeredBy, {
applicationId: app.id,
});
return { success: result.success, archived: result.archived };
return { success: true, archived: true };
});
}
31 changes: 28 additions & 3 deletions src/utils/api/handlers/ticketHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,21 @@ export function registerTicketHandlers(
// Mark closed immediately
await ticketRepo.update({ id: ticket.id, guildId }, { status: 'closed' });

// Get channel
const channel = ticket.channelId ? await client.channels.fetch(ticket.channelId).catch(() => 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 };
}
Expand All @@ -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 };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
Expand All @@ -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,
Expand Down
Loading