fix(slack): scope download-file to channel and thread context

This commit is contained in:
Peter Steinberger
2026-03-02 02:23:12 +00:00
parent 17bae93680
commit 40fda40aa7
6 changed files with 298 additions and 12 deletions

View File

@@ -280,6 +280,129 @@ export async function listSlackPins(
return (result.items ?? []) as SlackPin[];
}
type SlackFileInfoSummary = {
id?: string;
name?: string;
mimetype?: string;
url_private?: string;
url_private_download?: string;
channels?: unknown;
groups?: unknown;
ims?: unknown;
shares?: unknown;
};
type SlackFileThreadShare = {
channelId: string;
ts?: string;
threadTs?: string;
};
function normalizeSlackScopeValue(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function collectSlackDirectShareChannelIds(file: SlackFileInfoSummary): Set<string> {
const ids = new Set<string>();
for (const group of [file.channels, file.groups, file.ims]) {
if (!Array.isArray(group)) {
continue;
}
for (const entry of group) {
if (typeof entry !== "string") {
continue;
}
const normalized = normalizeSlackScopeValue(entry);
if (normalized) {
ids.add(normalized);
}
}
}
return ids;
}
function collectSlackShareMaps(file: SlackFileInfoSummary): Array<Record<string, unknown>> {
if (!file.shares || typeof file.shares !== "object" || Array.isArray(file.shares)) {
return [];
}
const shares = file.shares as Record<string, unknown>;
return [shares.public, shares.private].filter(
(value): value is Record<string, unknown> =>
Boolean(value) && typeof value === "object" && !Array.isArray(value),
);
}
function collectSlackSharedChannelIds(file: SlackFileInfoSummary): Set<string> {
const ids = new Set<string>();
for (const shareMap of collectSlackShareMaps(file)) {
for (const channelId of Object.keys(shareMap)) {
const normalized = normalizeSlackScopeValue(channelId);
if (normalized) {
ids.add(normalized);
}
}
}
return ids;
}
function collectSlackThreadShares(
file: SlackFileInfoSummary,
channelId: string,
): SlackFileThreadShare[] {
const matches: SlackFileThreadShare[] = [];
for (const shareMap of collectSlackShareMaps(file)) {
const rawEntries = shareMap[channelId];
if (!Array.isArray(rawEntries)) {
continue;
}
for (const rawEntry of rawEntries) {
if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
continue;
}
const entry = rawEntry as Record<string, unknown>;
const ts = typeof entry.ts === "string" ? normalizeSlackScopeValue(entry.ts) : undefined;
const threadTs =
typeof entry.thread_ts === "string" ? normalizeSlackScopeValue(entry.thread_ts) : undefined;
matches.push({ channelId, ts, threadTs });
}
}
return matches;
}
function hasSlackScopeMismatch(params: {
file: SlackFileInfoSummary;
channelId?: string;
threadId?: string;
}): boolean {
const channelId = normalizeSlackScopeValue(params.channelId);
if (!channelId) {
return false;
}
const threadId = normalizeSlackScopeValue(params.threadId);
const directIds = collectSlackDirectShareChannelIds(params.file);
const sharedIds = collectSlackSharedChannelIds(params.file);
const hasChannelEvidence = directIds.size > 0 || sharedIds.size > 0;
const inChannel = directIds.has(channelId) || sharedIds.has(channelId);
if (hasChannelEvidence && !inChannel) {
return true;
}
if (!threadId) {
return false;
}
const threadShares = collectSlackThreadShares(params.file, channelId);
if (threadShares.length === 0) {
return false;
}
const threadEvidence = threadShares.filter((entry) => entry.threadTs || entry.ts);
if (threadEvidence.length === 0) {
return false;
}
return !threadEvidence.some((entry) => entry.threadTs === threadId || entry.ts === threadId);
}
/**
* Downloads a Slack file by ID and saves it to the local media store.
* Fetches a fresh download URL via files.info to avoid using stale private URLs.
@@ -287,26 +410,21 @@ export async function listSlackPins(
*/
export async function downloadSlackFile(
fileId: string,
opts: SlackActionClientOpts & { maxBytes: number },
opts: SlackActionClientOpts & { maxBytes: number; channelId?: string; threadId?: string },
): Promise<SlackMediaResult | null> {
const token = resolveToken(opts.token, opts.accountId);
const client = await getClient(opts);
// Fetch fresh file metadata (includes a current url_private_download).
const info = await client.files.info({ file: fileId });
const file = info.file as
| {
id?: string;
name?: string;
mimetype?: string;
url_private?: string;
url_private_download?: string;
}
| undefined;
const file = info.file as SlackFileInfoSummary | undefined;
if (!file?.url_private_download && !file?.url_private) {
return null;
}
if (hasSlackScopeMismatch({ file, channelId: opts.channelId, threadId: opts.threadId })) {
return null;
}
const results = await resolveSlackMedia({
files: [