Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
54 changes: 54 additions & 0 deletions connectors/slack/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
# Slack Connector for Plot

Follow Slack channels and DMs, reply in threads, and start new conversations.

## What it does

- OAuth 2.0 authentication with Slack — a user-token connection only, no bot
user installed in the workspace
- Direct messages and group DMs, each as one ongoing Plot thread
- Channel threads that mention you (directly, via a user group, or through
`@here`/`@channel`) or that you've starred — never whole channels
- Starred (saved) Slack items sync as Plot to-dos
- Real-time sync via the Slack Events API
- Reactions round-trip in both directions
- Replying in Plot posts back to Slack, including file attachments

## OAuth scopes

Required: `channels:history`, `channels:read`, `groups:history`,
`groups:read`, `users:read`, `users:read.email`, `chat:write`, `files:write`,
`stars:read`, `stars:write`, `reactions:read`, `reactions:write`.

Optional (connect-time toggles): custom emoji in reactions (`emoji:read`),
@-mentions of a Slack user group you belong to (`usergroups:read`), and
direct/group DMs (`im:history`, `im:write`, `im:read`, `mpim:history`,
`mpim:write`, `mpim:read`).

## Read state

Read state is kept in step with Slack in both directions, within the limits of
what Slack's API exposes.

**Slack → Plot.** Slack tracks two separate read cursors: one for a channel's
timeline, and one for each thread. A conversation or channel message you have
already read in Slack is marked read in Plot, and a thread you have opened in
Slack is marked read once its own cursor moves. A channel message is settled by
a once-daily reconciliation pass; a thread settles as soon as it sees another
reply, because the thread's cursor arrives with the messages.

Because the two cursors are independent, catching up on a channel does **not**
mark its threads read — that matches Slack, where a thread you never opened
stays unread in your Threads view. A thread you read in Slack and that then
goes permanently quiet keeps its Plot unread until you open it in Plot.

Marking something unread in Slack is not propagated to Plot.

**Plot → Slack.** Reading a direct message in Plot marks that conversation read
in Slack. Channel threads do not write back: Slack's API offers no per-thread
mark, and the only available call moves the entire channel's cursor, which
would clear unread on every other message in it.

## License

MIT
96 changes: 95 additions & 1 deletion connectors/slack/src/slack-api.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
SlackApi,
transformSlackThread,
syncSlackChannel,
type SlackMessage,
type SlackUserInfoMap,
} from "./slack-api";
Expand DownExpand Up@@ -67,3 +69,95 @@ describe("transformSlackThread", () => {
).toBeUndefined();
});
});

describe("SlackApi.getConversationInfo", () => {
it("returns the caller's last_read cursor", async () => {
const api = new SlackApi("xoxp-test");
const call = vi
.spyOn(api, "call")
.mockResolvedValue({ channel: { id: "C1", last_read: "1700000000.000001" } });

await expect(api.getConversationInfo("C1")).resolves.toEqual({
lastRead: "1700000000.000001",
});
expect(call).toHaveBeenCalledWith("conversations.info", { channel: "C1" });
});

it("returns null when Slack omits last_read rather than inventing one", async () => {
const api = new SlackApi("xoxp-test");
vi.spyOn(api, "call").mockResolvedValue({ channel: { id: "C1" } });

await expect(api.getConversationInfo("C1")).resolves.toEqual({ lastRead: null });
});
});

describe("SlackApi.markConversationRead", () => {
it("marks the conversation read at the given ts", async () => {
const api = new SlackApi("xoxp-test");
const call = vi.spyOn(api, "call").mockResolvedValue({ ok: true });

await api.markConversationRead("D1", "1700000000.000001");

expect(call).toHaveBeenCalledWith("conversations.mark", {
channel: "D1",
ts: "1700000000.000001",
});
});
});

describe("syncSlackChannel thread parent", () => {
it("keeps the conversations.replies parent so its thread cursor survives", async () => {
const historyParent = {
type: "message",
ts: "1700000000.000001",
thread_ts: "1700000000.000001",
user: "U1",
text: "parent",
reply_count: 1,
};
const repliesParent = { ...historyParent, unread_count: 0, subscribed: true };
const reply = {
type: "message",
ts: "1700000002.000000",
thread_ts: "1700000000.000001",
user: "U2",
text: "reply",
};

const api = {
getConversationHistory: vi
.fn()
.mockResolvedValue({ messages: [historyParent], hasMore: false }),
getThread: vi.fn().mockResolvedValue([repliesParent, reply]),
getThreadReplies: vi.fn(),
};

const { threads } = await syncSlackChannel(api as never, { channelId: "C1" });

expect(threads).toHaveLength(1);
expect(threads[0]![0]!.unread_count).toBe(0);
expect(threads[0]![1]!.ts).toBe("1700000002.000000");
expect(api.getThreadReplies).not.toHaveBeenCalled();
});

it("falls back to the history parent when conversations.replies returns nothing", async () => {
const historyParent = {
type: "message",
ts: "1700000000.000001",
thread_ts: "1700000000.000001",
user: "U1",
text: "parent",
reply_count: 1,
};
const api = {
getConversationHistory: vi
.fn()
.mockResolvedValue({ messages: [historyParent], hasMore: false }),
getThread: vi.fn().mockResolvedValue([]),
};

const { threads } = await syncSlackChannel(api as never, { channelId: "C1" });

expect(threads).toEqual([[historyParent]]);
});
});
62 changes: 60 additions & 2 deletions connectors/slack/src/slack-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,20 @@ export type SlackMessage = {
}>;
reply_count?: number;
reply_users_count?: number;
latest_reply?: string;
/**
* Per-THREAD read state for the calling user, present on the parent message
* of a `conversations.replies` response. Slack tracks a thread's read state
* separately from its channel's: reading the channel does not advance these,
* and opening the thread does not advance the channel's `last_read`.
*
* Absent on messages from `conversations.history`, which is why the thread
* fetch in `syncSlackChannel` keeps the replies parent rather than the
* history one.
*/
subscribed?: boolean;
last_read?: string;
unread_count?: number;
};

export type SlackUser = {
Expand DownExpand Up@@ -372,6 +386,37 @@ export class SlackApi {
return messages.slice(1);
}

/**
* The calling user's read cursor for one conversation.
*
* Tier 3, and NOT subject to the 1 rpm non-Marketplace limit that applies to
* `conversations.history`/`conversations.replies` — so this is safe to call
* per conversation in a sweep. Returns `null` when Slack omits `last_read`
* (the caller must then abstain rather than assume a state).
*/
public async getConversationInfo(
channelId: string
): Promise<{ lastRead: string | null }> {
const data = await this.call("conversations.info", { channel: channelId });
const lastRead = data.channel?.last_read;
return { lastRead: typeof lastRead === "string" ? lastRead : null };
}

/**
* Move the calling user's read cursor for one conversation to `ts`.
*
* CONVERSATION-scoped: there is no per-thread equivalent in the public Web
* API, so this must only ever be called for a direct conversation, where the
* Plot link IS the whole conversation. Calling it for a channel would move
* that channel's cursor for every other message in it.
*/
public async markConversationRead(
channelId: string,
ts: string
): Promise<void> {
await this.call("conversations.mark", { channel: channelId, ts });
}

public async postMessage(
channelId: string,
text: string,
Expand DownExpand Up@@ -858,8 +903,21 @@ export async function syncSlackChannel(
// that cursor indefinitely. Degrade to the parent-only path so the
// rest of the channel still advances.
try {
const replies = await api.getThreadReplies(state.channelId, threadTs);
threads.push([parentMessage, ...replies]);
// `getThread` (conversations.replies), not `getThreadReplies`: the
// parent it returns carries this thread's own read cursor
// (`unread_count`/`last_read`/`latest_reply`), which the
// `conversations.history` parent does not have. Same single API call
// either way — `getThreadReplies` just threw the parent away.
//
// Spread the history parent underneath so any field only that response
// carried survives, then let the replies parent's fields win.
const full = await api.getThread(state.channelId, threadTs);
const [repliesParent, ...replies] = full;
threads.push(
repliesParent
? [{ ...parentMessage, ...repliesParent }, ...replies]
: [parentMessage]
);
} catch (error) {
console.warn(
`conversations.replies failed for ${state.channelId}/${threadTs}; falling back to parent-only`,
Expand Down
Loading
Loading