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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 1 addition & 13 deletions src/cli/message-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { formatOutboundSlackText } from "../slack/format-outbound.ts";
import type { SlackApiClient } from "../slack/client.ts";
import { uploadLocalFileToSlack } from "../slack/upload.ts";
import { buildSlackMessageUrl } from "../slack/url.ts";
import { normalizeAttachPaths } from "./options.ts";
import { resolveSchedulePostAt } from "../slack/scheduled-messages.ts";

function loadBlocksFromPath(path: string): unknown[] {
Expand Down Expand Up @@ -209,19 +210,6 @@ export async function sendMessage(input: {
});
}

function normalizeAttachPaths(raw: string[] | undefined): string[] {
if (!Array.isArray(raw) || raw.length === 0) {
return [];
}
const out: string[] = [];
for (const p of raw.map((v) => String(v).trim()).filter(Boolean)) {
if (!out.includes(p)) {
out.push(p);
}
}
return out;
}

async function sendMessageToChannel(input: {
client: SlackApiClient;
workspaceUrl?: string;
Expand Down
5 changes: 1 addition & 4 deletions src/cli/message-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,7 @@ import { composeMessage } from "./compose-actions.ts";
import { registerScheduledMessageCommand } from "./message-scheduled-command.ts";
import { registerMessageDraftCommand } from "./message-draft-command.ts";
import { isSafeModeEnabled, redirectSendToDraft, safeModeBlockedError } from "./safe-mode.ts";

function collectOptionValue(value: string, previous: string[] = []): string[] {
return [...previous, value];
}
import { collectOptionValue } from "./options.ts";

export function registerMessageCommand(input: { program: Command; ctx: CliContext }): void {
const safeModeActive = (): boolean =>
Expand Down
274 changes: 180 additions & 94 deletions src/cli/message-draft-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
import { fetchMessage } from "../slack/messages.ts";
import { getString, isRecord } from "../lib/object-type-guards.ts";
import { normalizeScheduleLimit } from "../slack/scheduled-messages.ts";
import { cleanupUploadedDraftFiles, uploadFilesForDraft } from "../slack/upload.ts";
import { normalizeAttachPaths } from "./options.ts";

export async function listDraftsAction(input: {
ctx: CliContext;
Expand All @@ -38,7 +40,7 @@ export async function createDraftAction(input: {
ctx: CliContext;
targetInput: string;
text: string;
options: { workspace?: string; threadTs?: string; broadcast?: boolean };
options: { workspace?: string; threadTs?: string; broadcast?: boolean; attach?: string[] };
}): Promise<Record<string, unknown>> {
const target = parseMsgTarget(String(input.targetInput));
const workspaceUrl =
Expand All @@ -57,30 +59,45 @@ export async function createDraftAction(input: {
assertBroadcastAllowedStatically(target, input.options.threadTs);
}

return await input.ctx.withAutoRefresh({
workspaceUrl,
work: async () => {
const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
const { channelId, threadTs } = await resolveDraftDestination(client, {
target,
threadTs: input.options.threadTs,
});
// Backstop for URL targets, whose channel/thread are only known here.
if (input.options.broadcast && isDmChannelId(channelId)) {
throw new Error("--broadcast is not supported for DM targets.");
}
if (input.options.broadcast && !threadTs) {
throw new Error("--broadcast requires a thread (use --thread-ts or a message URL target).");
}
const draft = await createDraft(client, {
channelId,
text: input.text,
threadTs,
broadcast: input.options.broadcast,
});
return { ok: true, draft };
},
});
// Memoize uploaded file ids outside work() so that, if withAutoRefresh
// re-runs work() after an auth refresh, the files are reused instead of
// re-uploaded — a re-upload would orphan the first (unbound, private) copies.
let uploadedFileIds: string[] | undefined;
try {
return await input.ctx.withAutoRefresh({
workspaceUrl,
work: async () => {
const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
const { channelId, threadTs } = await resolveDraftDestination(client, {
target,
threadTs: input.options.threadTs,
});
// Backstop for URL targets, whose channel/thread are only known here.
if (input.options.broadcast && isDmChannelId(channelId)) {
throw new Error("--broadcast is not supported for DM targets.");
}
if (input.options.broadcast && !threadTs) {
throw new Error("--broadcast requires a thread (use --thread-ts or a message URL target).");
}
if (input.options.attach && !uploadedFileIds) {
uploadedFileIds = await uploadDraftAttachments(client, input.options.attach);
}
const draft = await createDraft(client, {
channelId,
text: input.text,
threadTs,
broadcast: input.options.broadcast,
fileIds: uploadedFileIds,
});
return { ok: true, draft };
},
});
} catch (err) {
// The draft call failed after a successful upload, so the uploaded files
// never got bound to a draft. Clean them up so they don't sit orphaned.
await cleanupOrphanedDraftFiles(input.ctx, workspaceUrl, uploadedFileIds);
throw err;
}
}

export async function updateDraftAction(input: {
Expand All @@ -93,6 +110,7 @@ export async function updateDraftAction(input: {
threadTs?: string;
broadcast?: boolean;
lastUpdatedTs?: string;
attach?: string[];
};
}): Promise<Record<string, unknown>> {
const channelTarget = input.options.channel
Expand All @@ -115,75 +133,89 @@ export async function updateDraftAction(input: {
assertBroadcastAllowedStatically(channelTarget, input.options.threadTs);
}

return await input.ctx.withAutoRefresh({
workspaceUrl,
work: async () => {
const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
// drafts.update replaces the whole draft, so start from the existing
// one and override only what the caller passed.
const existing = await findDraft(client, input.draftId);
// The CLI rebuilds the draft from a single destination and no schedule, so
// refuse drafts it can't faithfully round-trip rather than silently drop a
// scheduled-send time or extra recipients (both are creatable in the Slack
// client). Deleting + recreating, or editing in Slack, is the safe path.
if (existing.date_scheduled) {
throw new Error(
`Draft ${input.draftId} has a scheduled send time; updating it here could clear the schedule. Edit it in the Slack client, or delete and recreate it.`,
);
}
if (!channelTarget && existing.destinations.length > 1) {
throw new Error(
`Draft ${input.draftId} targets multiple destinations; updating its text here would drop all but the first. Edit it in the Slack client, or re-address it with --channel.`,
);
}
const lastUpdatedTs = input.options.lastUpdatedTs ?? existing.last_updated_ts;
if (!lastUpdatedTs) {
throw new Error(`Draft ${input.draftId} has no last_updated_ts; pass --last-updated-ts.`);
}
const [destination] = existing.destinations;
const resolved = channelTarget
? await resolveDraftDestination(client, {
target: channelTarget,
threadTs: input.options.threadTs,
})
: {
channelId: destination?.channel_id,
threadTs: input.options.threadTs ?? destination?.thread_ts,
};
if (!resolved.channelId) {
throw new Error(`Draft ${input.draftId} has no destination; pass --channel.`);
}
// Inherit the existing broadcast flag only when the destination is truly
// unchanged: same channel (no --channel) AND same thread. Changing the
// thread (via --thread-ts) or re-addressing resets broadcast to what was
// explicitly requested, so an inherited flag can never ratchet a reply
// into a different thread's channel. `??` preserves an explicit
// --no-broadcast.
const broadcast =
input.options.broadcast ??
(!channelTarget && resolved.threadTs === destination?.thread_ts
? destination?.broadcast
: undefined);
// A DM (`D...`) destination — targeted directly, via URL, or an existing
// DM draft — has no channel to broadcast to.
if (broadcast && isDmChannelId(resolved.channelId)) {
throw new Error("--broadcast is not supported for DM targets.");
}
if (broadcast && !resolved.threadTs) {
throw new Error("--broadcast requires a thread (use --thread-ts).");
}
const draft = await updateDraft(client, {
draftId: input.draftId,
clientLastUpdatedTs: lastUpdatedTs,
channelId: resolved.channelId,
text: input.text,
threadTs: resolved.threadTs,
broadcast,
fileIds: existing.file_ids,
});
return { ok: true, draft };
},
});
// Memoize newly uploaded ids outside work(): on an auth-retry re-run of
// work(), reuse them instead of re-uploading (which would orphan the first
// upload). On a final failure these new ids are cleaned up below.
let uploadedFileIds: string[] | undefined;
try {
return await input.ctx.withAutoRefresh({
workspaceUrl,
work: async () => {
const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
// drafts.update replaces the whole draft, so start from the existing
// one and override only what the caller passed.
const existing = await findDraft(client, input.draftId);
// The CLI rebuilds the draft from a single destination and no schedule, so
// refuse drafts it can't faithfully round-trip rather than silently drop a
// scheduled-send time or extra recipients (both are creatable in the Slack
// client). Deleting + recreating, or editing in Slack, is the safe path.
if (existing.date_scheduled) {
throw new Error(
`Draft ${input.draftId} has a scheduled send time; updating it here could clear the schedule. Edit it in the Slack client, or delete and recreate it.`,
);
}
if (!channelTarget && existing.destinations.length > 1) {
throw new Error(
`Draft ${input.draftId} targets multiple destinations; updating its text here would drop all but the first. Edit it in the Slack client, or re-address it with --channel.`,
);
}
const lastUpdatedTs = input.options.lastUpdatedTs ?? existing.last_updated_ts;
if (!lastUpdatedTs) {
throw new Error(`Draft ${input.draftId} has no last_updated_ts; pass --last-updated-ts.`);
}
const [destination] = existing.destinations;
const resolved = channelTarget
? await resolveDraftDestination(client, {
target: channelTarget,
threadTs: input.options.threadTs,
})
: {
channelId: destination?.channel_id,
threadTs: input.options.threadTs ?? destination?.thread_ts,
};
if (!resolved.channelId) {
throw new Error(`Draft ${input.draftId} has no destination; pass --channel.`);
}
// Inherit the existing broadcast flag only when the destination is truly
// unchanged: same channel (no --channel) AND same thread. Changing the
// thread (via --thread-ts) or re-addressing resets broadcast to what was
// explicitly requested, so an inherited flag can never ratchet a reply
// into a different thread's channel. `??` preserves an explicit
// --no-broadcast.
const broadcast =
input.options.broadcast ??
(!channelTarget && resolved.threadTs === destination?.thread_ts
? destination?.broadcast
: undefined);
// A DM (`D...`) destination — targeted directly, via URL, or an existing
// DM draft — has no channel to broadcast to.
if (broadcast && isDmChannelId(resolved.channelId)) {
throw new Error("--broadcast is not supported for DM targets.");
}
if (broadcast && !resolved.threadTs) {
throw new Error("--broadcast requires a thread (use --thread-ts).");
}
if (input.options.attach && !uploadedFileIds) {
uploadedFileIds = await uploadDraftAttachments(client, input.options.attach);
}
const draft = await updateDraft(client, {
draftId: input.draftId,
clientLastUpdatedTs: lastUpdatedTs,
channelId: resolved.channelId,
text: input.text,
threadTs: resolved.threadTs,
broadcast,
fileIds: mergeFileIds(existing.file_ids, uploadedFileIds),
});
return { ok: true, draft };
},
});
} catch (err) {
// Only the newly uploaded files are at risk; existing.file_ids still
// belong to the draft (whose update failed) and are left untouched.
await cleanupOrphanedDraftFiles(input.ctx, workspaceUrl, uploadedFileIds);
throw err;
}
}

export async function deleteDraftAction(input: {
Expand Down Expand Up @@ -331,3 +363,57 @@ async function resolveChannelDisplayName(
}
return undefined;
}

/**
* Upload local --attach files for a draft. Files are staged first and only
* completed together once every file staged successfully, so a failed upload
* leaves no orphaned private files. Returns undefined when nothing is
* attached, so callers can distinguish "no attachments" from "attachments
* present".
*/
async function uploadDraftAttachments(
client: SlackApiClient,
attachPaths: string[] | undefined,
): Promise<string[] | undefined> {
const paths = normalizeAttachPaths(attachPaths);
if (paths.length === 0) {
return undefined;
}
return await uploadFilesForDraft({ client, filePaths: paths });
}

/** Merge preserved draft file ids with newly uploaded ones, de-duplicated, order preserved. */
function mergeFileIds(
existing: string[] | undefined,
next: string[] | undefined,
): string[] | undefined {
if (!next || next.length === 0) {
return existing;
}
if (!existing || existing.length === 0) {
return next;
}
return [...new Set([...existing, ...next])];
}

/**
* Best-effort cleanup of files uploaded for a draft whose create/update then
* failed. The draft never got bound to these files, so deleting them prevents
* orphaned private files. Swallows all errors so it never masks the original
* failure that triggered the cleanup.
*/
async function cleanupOrphanedDraftFiles(
ctx: CliContext,
workspaceUrl: string | undefined,
fileIds: string[] | undefined,
): Promise<void> {
if (!fileIds || fileIds.length === 0) {
return;
}
try {
const { client } = await ctx.getClientForWorkspace(workspaceUrl);
await cleanupUploadedDraftFiles(client, fileIds);
} catch {
// best-effort: never mask the original failure.
}
}
6 changes: 5 additions & 1 deletion src/cli/message-draft-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
listDraftsAction,
updateDraftAction,
} from "./message-draft-actions.ts";
import { collectOptionValue } from "./options.ts";

export function registerMessageDraftCommand(input: { messageCmd: Command; ctx: CliContext }): void {
const draftCmd = input.messageCmd
Expand Down Expand Up @@ -51,11 +52,12 @@ export function registerMessageDraftCommand(input: { messageCmd: Command; ctx: C
"--broadcast",
"Also send the thread reply to the channel when posted (requires thread context)",
)
.option("--attach <path>", "Attach a local file to the draft (repeatable)", collectOptionValue, [])
.action(async (...args) => {
const [targetInput, text, options] = args as [
string,
string,
{ workspace?: string; threadTs?: string; broadcast?: boolean },
{ workspace?: string; threadTs?: string; broadcast?: boolean; attach?: string[] },
];
try {
const payload = await createDraftAction({ ctx: input.ctx, targetInput, text, options });
Expand All @@ -82,6 +84,7 @@ export function registerMessageDraftCommand(input: { messageCmd: Command; ctx: C
"Also send the thread reply to the channel when posted (requires thread context)",
)
.option("--no-broadcast", "Clear an inherited broadcast flag (keeps the thread)")
.option("--attach <path>", "Attach a local file to the draft (repeatable)", collectOptionValue, [])
.option(
"--last-updated-ts <ts>",
"Draft last_updated_ts for conflict detection (auto-fetched when omitted)",
Expand All @@ -96,6 +99,7 @@ export function registerMessageDraftCommand(input: { messageCmd: Command; ctx: C
threadTs?: string;
broadcast?: boolean;
lastUpdatedTs?: string;
attach?: string[];
},
];
try {
Expand Down
Loading