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
41 changes: 14 additions & 27 deletions src/slack/search-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,19 +174,22 @@ export async function searchMessagesInChannelsFallback(

const results: SearchCompactMessage[] = [];

for (const channelId of channelIds) {
let cursorLatest: string | undefined;
channelLoop: for (const channelId of channelIds) {
let cursor: string | undefined;
const seenCursors = new Set<string>();
for (;;) {
const resp = await client.api("conversations.history", {
channel: channelId,
limit: 200,
latest: cursorLatest,
cursor,
});
const messages = isRecord(resp) ? asArray(resp.messages).filter(isRecord) : [];
if (messages.length === 0) {
break;
}
const responseMetadata = isRecord(resp.response_metadata) ? resp.response_metadata : null;
const nextCursor = responseMetadata
? getString(responseMetadata.next_cursor)?.trim() || undefined
: undefined;

let reachedAfterBoundary = false;
for (const m of messages) {
const summary = messageSummaryFromApiMessage(channelId, m);

Expand All @@ -196,7 +199,7 @@ export async function searchMessagesInChannelsFallback(
continue;
}
if (afterSec !== null && tsNum < afterSec) {
cursorLatest = undefined;
reachedAfterBoundary = true;
break;
}
}
Expand Down Expand Up @@ -230,31 +233,15 @@ export async function searchMessagesInChannelsFallback(
matchedSummaries.push(summary);
results.push(toSearchCompactMessage(compact));
if (results.length >= input.limit) {
const referencedUserIds = collectReferencedUserIds(matchedSummaries, {
includeReactions: false,
});
const usersById = await resolveUsersById({
client,
workspaceUrl: input.workspace_url ?? "",
userIds: referencedUserIds,
forceRefresh: Boolean(input.refreshUsers),
});
return {
messages: results,
referenced_users: toReferencedUsers(referencedUserIds, usersById),
};
break channelLoop;
}
}

if (!cursorLatest) {
break;
}

const last = messages.at(-1);
cursorLatest = last ? getString(last.ts) : undefined;
if (!cursorLatest) {
if (reachedAfterBoundary || !nextCursor || seenCursors.has(nextCursor)) {
break;
}
seenCursors.add(nextCursor);
cursor = nextCursor;
}
}

Expand Down
168 changes: 168 additions & 0 deletions test/search-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ function createClient(calls: ApiCall[]) {

if (method === "conversations.history") {
return {
response_metadata: { next_cursor: "" },
messages: [
{
ts: "1.000001",
Expand Down Expand Up @@ -154,6 +155,173 @@ describe("search referenced users", () => {
expect(result.referenced_users).not.toHaveProperty("U44444444");
});

test("searchSlack scans older channel history pages using Slack cursors", async () => {
const calls: ApiCall[] = [];
const client = {
api: async (method: string, params: Record<string, unknown>) => {
calls.push({ method, params });
if (method !== "conversations.history") {
throw new Error(`Unexpected API method: ${method}`);
}
if (params.cursor === undefined) {
return {
response_metadata: { next_cursor: "page-2" },
messages: [{ ts: "2.000002", text: "not a match", user: "U11111111" }],
};
}
if (params.cursor === "page-2") {
return {
response_metadata: { next_cursor: "" },
messages: [{ ts: "1.000001", text: "needle", user: "U22222222" }],
};
}
throw new Error(`Unexpected cursor: ${String(params.cursor)}`);
},
};

const result = await searchSlack({
client: client as never,
auth: { auth_type: "standard", token: "x" },
options: {
workspace_url: "https://workspace.slack.com",
query: "needle",
kind: "messages",
channels: ["C12345678"],
limit: 20,
max_content_chars: 4000,
content_type: "any",
download: false,
},
});

expect(result.messages).toHaveLength(1);
expect(result.messages?.[0]?.content).toBe("needle");
expect(
calls
.filter((call) => call.method === "conversations.history")
.map((call) => call.params.cursor),
).toEqual([undefined, "page-2"]);
});

test("searchSlack follows a valid cursor from an empty history page", async () => {
const calls: ApiCall[] = [];
const client = {
api: async (method: string, params: Record<string, unknown>) => {
calls.push({ method, params });
if (method !== "conversations.history") {
throw new Error(`Unexpected API method: ${method}`);
}
if (params.cursor === undefined) {
return {
response_metadata: { next_cursor: "page-2" },
messages: [],
};
}
if (params.cursor === "page-2") {
return {
response_metadata: { next_cursor: "" },
messages: [{ ts: "1.000001", text: "needle", user: "U22222222" }],
};
}
throw new Error(`Unexpected cursor: ${String(params.cursor)}`);
},
};

const result = await searchSlack({
client: client as never,
auth: { auth_type: "standard", token: "x" },
options: {
workspace_url: "https://workspace.slack.com",
query: "needle",
kind: "messages",
channels: ["C12345678"],
limit: 20,
max_content_chars: 4000,
content_type: "any",
download: false,
},
});

expect(result.messages).toHaveLength(1);
expect(result.messages?.[0]?.content).toBe("needle");
expect(
calls
.filter((call) => call.method === "conversations.history")
.map((call) => call.params.cursor),
).toEqual([undefined, "page-2"]);
});

test("searchSlack stops when a pagination cursor cycles", async () => {
const calls: ApiCall[] = [];
const client = {
api: async (method: string, params: Record<string, unknown>) => {
calls.push({ method, params });
if (method !== "conversations.history") {
throw new Error(`Unexpected API method: ${method}`);
}
if (calls.filter((call) => call.method === "conversations.history").length > 3) {
throw new Error("Repeated pagination cursor was requested");
}
if (params.cursor === undefined) {
return { response_metadata: { next_cursor: "page-2" }, messages: [] };
}
if (params.cursor === "page-2") {
return { response_metadata: { next_cursor: "page-3" }, messages: [] };
}
if (params.cursor === "page-3") {
return { response_metadata: { next_cursor: "page-2" }, messages: [] };
}
throw new Error(`Unexpected cursor: ${String(params.cursor)}`);
},
};

const result = await searchSlack({
client: client as never,
auth: { auth_type: "standard", token: "x" },
options: {
workspace_url: "https://workspace.slack.com",
query: "needle",
kind: "messages",
channels: ["C12345678"],
limit: 20,
max_content_chars: 4000,
content_type: "any",
download: false,
},
});

expect(result.messages).toHaveLength(0);
expect(
calls
.filter((call) => call.method === "conversations.history")
.map((call) => call.params.cursor),
).toEqual([undefined, "page-2", "page-3"]);
});

test("searchSlack does not resolve users at the result limit without opt-in", async () => {
const calls: ApiCall[] = [];
const client = createClient(calls) as never;

const result = await searchSlack({
client,
auth: { auth_type: "standard", token: "x" },
options: {
workspace_url: "https://workspace.slack.com",
query: "hello",
kind: "messages",
channels: ["C12345678"],
limit: 1,
max_content_chars: 4000,
content_type: "any",
download: false,
},
});

expect(result.messages).toHaveLength(1);
expect(result.referenced_users).toBeUndefined();
expect(calls.filter((call) => call.method === "users.info")).toHaveLength(0);
});

test("search command forwards --refresh-users and bypasses cached user lookups", async () => {
const calls: ApiCall[] = [];
const ctx = createContext(calls);
Expand Down
Loading