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
103 changes: 13 additions & 90 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
formatQueueFailureMessage,
formatQueuedUrlMessage,
} from "./presenters/queue.js";
import { ProgressPresenter } from "./presenters/progress.js";
import {
runAddCommand,
runSummaryCommand,
Expand Down Expand Up @@ -470,18 +471,11 @@ async function processUrlWithProgress(
authorId: message.author.id,
});

let lastPhase: string | null = null;
// Once we see a terminal phase ('completed' or 'failed') we should
// suppress any subsequent progress updates emitted by the CLI to avoid
// confusing the user with post-completion informational objects.
let terminalPhaseSeen = false;
let eventCount = 0;
let finalResult: AddResult | undefined;
// Keep a reference to the last posted Discord message so we can
// append the created item link/ID to it when the CLI returns the ID.
// In real runtime this will be a discord.js Message; in tests the
// mocked send/reply helpers may return undefined which we handle.
let lastPostedMessage: any = null;

// ProgressPresenter manages status message state and lifecycle
const presenter = new ProgressPresenter(thread, message, logger);

// Process progress events using manual iteration to capture return value
logger.info("Waiting for CLI progress events...", { messageId: message.id, url });
Expand Down Expand Up @@ -511,94 +505,23 @@ async function processUrlWithProgress(
eventCount,
});

// If we've already observed a terminal phase, ignore any further
// progress events to avoid confusing follow-up messages (some CLI
// implementations emit informational objects after completion).
if (terminalPhaseSeen) {
logger.debug("Ignoring CLI progress event after terminal phase", {
messageId: message.id,
url,
eventCount,
});
continue;
}

// Only send update if phase changed (avoid spam)
if (event.phase !== lastPhase) {
lastPhase = event.phase;

const progressMsg = formatProgressMessage(event);

// Always ensure URLs shown to users are wrapped in backticks to avoid embeds.
// If event contains a url or title, prefer showing the title wrapped in ticks.
const safeProgressMsg = ((): string => {
try {
if (event.title) return progressMsg.replace(event.title, `\`${event.title}\``);
if (event.url) return progressMsg.replace(event.url, `\`${event.url}\``);
return progressMsg;
} catch {
return progressMsg;
}
})();
await presenter.handleProgressEvent(event);
}

if (thread) {
try {
// Capture the returned message when possible so we can edit it
// later to append the created item link/ID.
const posted = await thread.send(safeProgressMsg);
lastPostedMessage = posted ?? lastPostedMessage;
} catch (error) {
logger.warn("Failed to send progress update to thread; falling back to channel reply", {
threadId: thread.id,
phase: event.phase,
error: error instanceof Error ? error.message : String(error),
});
// Fallback: reply in channel so user still receives updates
try {
const posted = await message.reply(safeProgressMsg);
lastPostedMessage = posted ?? lastPostedMessage;
} catch (err) {
logger.warn("Failed to send fallback progress reply to channel", {
messageId: message.id,
error: err instanceof Error ? err.message : String(err),
});
}
}
} else {
// No thread available -> send progress updates to channel (safe)
try {
const posted = await message.reply(safeProgressMsg);
lastPostedMessage = posted ?? lastPostedMessage;
} catch (err) {
logger.warn("Failed to send progress update to channel", {
messageId: message.id,
phase: event.phase,
error: err instanceof Error ? err.message : String(err),
});
}
}
// Retrieve presenter state for final result handling
const lastPhase = presenter.getLastPhase();
let lastPostedMessage: any = presenter.getLastPostedMessage();

// If this event indicates a terminal state, mark it so we ignore
// any subsequent non-actionable events.
try {
if (event.phase === "completed" || event.phase === "failed") {
terminalPhaseSeen = true;
}
} catch {
// ignore
}
}
}
if (finalResult) {
logger.info("CLI processing complete", {
messageId: message.id,
url,
success: finalResult?.success,
title: finalResult?.title,
error: finalResult?.error,
success: finalResult.success,
title: finalResult.title,
error: finalResult.error,
});

if (finalResult?.success) {
if (finalResult.success) {
await removeReaction(message, PROCESSING_REACTION);
await addReaction(message, SUCCESS_REACTION);

Expand Down
149 changes: 149 additions & 0 deletions src/presenters/progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import type { Message, ThreadChannel } from "discord.js";
import type { AddProgressEvent } from "../bot/cli-runner.js";
import { formatProgressMessage } from "../formatters/progress.js";
import type { Logger } from "../logger.js";

/**
* Manages Discord status message lifecycle for CLI progress events.
*
* Responsible for:
* - Formatting progress messages using phase emojis and labels
* - Tracking posted status messages via a keyed Map
* - Creating status messages in the appropriate Discord target (thread or channel)
* - Deduplicating progress updates (only posting when the phase changes)
* - Suppressing further updates once a terminal phase is observed
*/
export class ProgressPresenter {
/**
* Tracks the most recently posted Discord message for each phase key.
* Provides infrastructure for future update/delete operations on status messages.
*/
private readonly statusMessages: Map<string, any> = new Map();

private lastPhase: string | null = null;
private terminalPhaseSeen = false;
private lastPostedMessage: any = null;

constructor(
private readonly thread: ThreadChannel | null,
private readonly message: Message,
private readonly logger: Logger
) {}

/**
* Handle a CLI progress event, posting a Discord status update if the phase changed.
*
* @param event - The progress event from the CLI runner.
* @returns `true` if a status message was posted, `false` if the event was skipped
* (e.g. duplicate phase or post-terminal event).
*/
async handleProgressEvent(event: AddProgressEvent): Promise<boolean> {
if (this.terminalPhaseSeen) {
this.logger.debug("Ignoring CLI progress event after terminal phase", {
messageId: this.message.id,
phase: event.phase,
});
return false;
}

if (event.phase === this.lastPhase) {
return false;
}

this.lastPhase = event.phase ?? null;

const progressMsg = formatProgressMessage(event);
const safeProgressMsg = this.makeSafeProgressMsg(event, progressMsg);

await this.postStatusMessage(event.phase, safeProgressMsg);

if (event.phase === "completed" || event.phase === "failed") {
this.terminalPhaseSeen = true;
}

return true;
}

/**
* Wrap URLs and titles in backticks so Discord does not create embeds for them.
*/
private makeSafeProgressMsg(event: AddProgressEvent, progressMsg: string): string {
try {
if (event.title) return progressMsg.replace(event.title, `\`${event.title}\``);
if (event.url) return progressMsg.replace(event.url, `\`${event.url}\``);
return progressMsg;
} catch {
return progressMsg;
}
}

/**
* Post a status message to the thread (preferred) or channel (fallback).
* Updates `statusMessages` and `lastPostedMessage` on success.
*/
private async postStatusMessage(phase: string | undefined, content: string): Promise<void> {
const key = phase ?? "__unknown__";

if (this.thread) {
try {
const posted = await this.thread.send(content);
this.lastPostedMessage = posted ?? this.lastPostedMessage;
if (posted) this.statusMessages.set(key, posted);
} catch (error) {
this.logger.warn(
"Failed to send progress update to thread; falling back to channel reply",
{
threadId: this.thread.id,
phase,
error: error instanceof Error ? error.message : String(error),
}
);
try {
const posted = await this.message.reply(content);
this.lastPostedMessage = posted ?? this.lastPostedMessage;
if (posted) this.statusMessages.set(key, posted);
} catch (err) {
this.logger.warn("Failed to send fallback progress reply to channel", {
messageId: this.message.id,
error: err instanceof Error ? err.message : String(err),
});
}
}
} else {
try {
const posted = await this.message.reply(content);
this.lastPostedMessage = posted ?? this.lastPostedMessage;
if (posted) this.statusMessages.set(key, posted);
} catch (err) {
this.logger.warn("Failed to send progress update to channel", {
messageId: this.message.id,
phase,
error: err instanceof Error ? err.message : String(err),
});
}
}
}

/** Returns the last Discord message object posted by this presenter. */
getLastPostedMessage(): any {
return this.lastPostedMessage;
}

/** Returns the phase string of the most recently handled event, or `null`. */
getLastPhase(): string | null {
return this.lastPhase;
}

/** Returns `true` once a terminal phase (`completed` or `failed`) has been observed. */
isTerminalPhaseSeen(): boolean {
return this.terminalPhaseSeen;
}

/**
* Returns a read-only view of the status messages Map.
* Keys are phase strings; values are Discord message objects.
*/
getStatusMessages(): ReadonlyMap<string, any> {
return this.statusMessages;
}
}
Loading
Loading