Skip to content

Commit 6abb1bc

Browse files
KrisBraunclaude
andauthored
Slack: sync read state (#356)
* feat(slack): expose conversation and thread read cursors * feat(slack): add read-state projection helpers * feat(slack): mirror a thread's own read state into Plot * feat(slack): track which links still need read reconciliation * fix(slack): don't leave a read anchor for an already-read DM sync * feat(slack): reconcile Slack read state into Plot daily * fix(slack): keep the read-state chain alive on unexpected errors - Stamp readStateSyncedAt on completion, not entry, matching membersSyncedAt/customEmojiSyncedAt, so a pass that did no real work doesn't suppress queueWorkspaceDailyTasks' 24h backstop. - Stop the generic catch from killing the daily reschedule chain on an unexpected error, mirroring syncMembers. - Cover the auth-shaped SlackPermanentError branch: markNeedsReauth is called and every anchor is left in place. * feat(slack): mark direct conversations read in Slack * fix(slack): flag reauth on genuinely dead tokens in onThreadRead The blanket SlackPermanentError handler in onThreadRead treated every permanent error the same as a declined optional dms scope, including invalid_auth/token_revoked/account_inactive/no_permission — so a truly revoked token never prompted the user to reconnect. Check missing_scope first (it's itself a member of SLACK_AUTH_ERRORS), then gate markNeedsReauth on SLACK_AUTH_ERRORS membership, matching every other write-back path in this connector. * docs(slack): document read-state sync * fix(slack): clear the read anchor once Plot's own read state changes onThreadRead never cleared reconcileReadState's pending-anchor bookkeeping. For a direct conversation, marking it read called conversations.mark, which moved Slack's own cursor to the anchor's newest timestamp — but left the anchor itself in place. Marking the same thread unread again in Plot right after left that stale anchor sitting there, so the next daily sweep saw a cursor it had itself advanced, decided the thread was "read", and reverted the user's manual unread. Clear the anchor at the top of onThreadRead, before either the unread or meta.direct guard, so it covers both a channel thread and a direct conversation, and both directions (mark read or mark unread) — whichever way Plot's read state changed is the user's final word, and no leftover anchor should be able to override it later. A DM's meta.threadTs is the conversation's latest message, not its anchor key, so the anchor id is chosen accordingly (the conversation id for a DM, threadTs otherwise). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xy4JTgqSLoGCd6MYpCjjXH * fix(slack): gate the channel-thread read anchor write like the DM anchor buildConversationLink's two branches each inlined their own copy of the read-anchor write logic, and they had drifted apart: the DM branch only wrote an anchor when advanceConversationHead && link.unread !== false, but the channel-thread branch wrote (or rewrote) an anchor whenever the read verdict wasn't "read" — regardless of advanceConversationHead. refreshSlackThread calls buildConversationLink with advanceConversationHead: false for a reaction refresh. A root-only channel message has no thread cursor of its own, so its read verdict is always "unknown" — which the channel-thread branch treated as "not read" and wrote the same anchor right back, even though no new content had actually arrived. The next daily sweep then saw that anchor and re-asserted "read" over a user's manual unread. Extract the shared applyReadAnchor helper and route both branches through it, so one gating rule governs both: advanceConversationHead && link.unread !== false for the write, with a solid "read" verdict from a cursor always allowed to clear the anchor regardless of advanceConversationHead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xy4JTgqSLoGCd6MYpCjjXH * fix(slack): harden the daily read-state sweep Three related problems in reconcileReadState, all fixed together since they sit in the same loop: - No once-per-day guard. queueWorkspaceDailyTasks dispatches this once per ENABLED CHANNEL, but the sweep itself is workspace-scoped (it reads every read_anchor: entry regardless of channel). Slack auto-observes channels as the user composes into them, so each newly-enabled channel armed its own daily recurring chain — keyed per channel, so scheduleRecurring couldn't dedupe them — meaning N chains each doing a full workspace sweep once a day: N times the conversations.info calls and N duplicate writes per anchor that resolves. Add the same top-of-function 24h guard syncMembers uses, gated on `readStateSyncedAt`. The vestigial `scheduleDaily` flag (declared true, never reassigned) is removed along with it — the finally block now always re-arms the one chain that gets past the guard. - missing_scope and no_permission were treated as evidence the whole connection is dead. onThreadRead already special-cases missing_scope (a declined optional scope group is a user decision, not a broken connection); reconcileReadState predates that fix. A user who declined the optional dms group would hit missing_scope on the first DM anchor, get a spurious "reconnect Slack" prompt on an otherwise healthy connection, and never sweep the channels behind it. Mirror onThreadRead: missing_scope and no_permission both skip just that conversation and continue the pass; only the remaining SLACK_AUTH_ERRORS codes flag reauth and stop. - The rate-limit log reported byConversation.size (the total conversation count) as "left for a later pass" instead of the actual remainder. Iterate with an index so the count reflects what's really left. Also pass `author: null` on the reconcile upsert — it's a genuinely authorless write (only flips `unread`), and this silences saveLink's development-time unattributed-link warning that would otherwise fire on every reconciled link. Adds coverage for a DM-shaped anchor in the sweep: `read_anchor:D1:D1` parses to a threadTs of "D1", a non-timestamp fed straight into conversationSource(). Nothing previously exercised this path; a regression in isKnownDMChannel here would synthesise a bogus app_redirect URL and create a new, empty thread instead of updating the real DM link. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xy4JTgqSLoGCd6MYpCjjXH * fix(slack): correct onChannelDisabled's read-anchor cleanup comment The comment claimed this sweep clears "read anchors" for a disabled channel without qualification, but read_anchor:${channel.id}: only ever matches a channel thread's anchor. A direct conversation's anchor is keyed on the conversation id in both positions (read_anchor:<dmChannelId>:<dmChannelId>), and a DM conversation id is never an enabled channel, so this sweep can't reach it — no behavior change, just documenting the actual scope. DM anchors are left to age out via reconcileReadState's 30-day retention. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xy4JTgqSLoGCd6MYpCjjXH * fix(slack): abstain on a malformed Slack timestamp instead of asserting read compareSlackTs returned 1 (the "greater than" direction) whenever either half of a timestamp failed to parse as a number, because every JS comparison against NaN (< 0, >= 0, === 0) evaluates to false, and the old `secDiff < 0 ? -1 : 1` shape defaulted to 1 in that case. channelReadVerdict reads a compareSlackTs result of >= 0 as "read" — so a malformed cursor failed toward the UNSAFE direction, asserting a link read on a timestamp comparison that was actually meaningless. compareSlackTs now returns NaN when either half doesn't parse, and both channelReadVerdict and threadReadVerdict's last_read/latest_reply fallback check Number.isNaN and abstain ("unknown") instead of falling through to a directional default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xy4JTgqSLoGCd6MYpCjjXH * fix(slack): preserve thread titles on the reconcile upsert reconcileReadState's upsert deliberately sent no title, on the assumption that omitting a field always preserves the stored value. That holds for `preview` (its platform default derives from the notes, so with none it's null and COALESCE falls through) but not for `title`: `upsert_thread` takes an "archived" code path whenever the user's `thread_priority` row points at an archived priority (they archived the focus a thread was filed under) or is missing, and on that path the platform's title default is the literal string "Untitled" — never null — so an omitted title there always loses to it. A perfectly healthy connection reconciling a thread filed under an archived focus would silently destroy that thread's real title. Carry the title on the read anchor instead, so the upsert has something to re-send: - `SlackReadAnchor` gains `title?: string | null`; `deriveReadAnchor` takes it via its options rather than trying to infer it from the messages. - `applyReadAnchor` records the link's current title when it (re)writes an anchor. `assembleSlackDmLink` deliberately omits `title` when `users.info` was unavailable, to avoid permanently renaming a DM thread to a raw Slack user id — so when the link carries no title, the previously-stored anchor's title is carried forward instead of being dropped in that window. - `reconcileReadState`'s `saveLink` call includes `title` when the anchor has one, and omits it (as before) when it doesn't — never worse than today. Sending `title` also re-writes it on the normal, non-archived upsert path, which is harmless: a channel thread's title is derived deterministically from its root message and a DM's is the counterparty name, so both are re-sent unchanged by every ordinary sync anyway — this isn't a new source of truth, just re-asserting the same value on a path that already agreed with itself. `created`, `schedules`, `preview`, and `notes` remain omitted from the reconcile upsert, unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xy4JTgqSLoGCd6MYpCjjXH --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 6b4e294 commit 6abb1bc

7 files changed

Lines changed: 1850 additions & 3 deletions

File tree

‎connectors/slack/README.md‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Slack Connector for Plot
2+
3+
Follow Slack channels and DMs, reply in threads, and start new conversations.
4+
5+
## What it does
6+
7+
- OAuth 2.0 authentication with Slack — a user-token connection only, no bot
8+
user installed in the workspace
9+
- Direct messages and group DMs, each as one ongoing Plot thread
10+
- Channel threads that mention you (directly, via a user group, or through
11+
`@here`/`@channel`) or that you've starred — never whole channels
12+
- Starred (saved) Slack items sync as Plot to-dos
13+
- Real-time sync via the Slack Events API
14+
- Reactions round-trip in both directions
15+
- Replying in Plot posts back to Slack, including file attachments
16+
17+
## OAuth scopes
18+
19+
Required: `channels:history`, `channels:read`, `groups:history`,
20+
`groups:read`, `users:read`, `users:read.email`, `chat:write`, `files:write`,
21+
`stars:read`, `stars:write`, `reactions:read`, `reactions:write`.
22+
23+
Optional (connect-time toggles): custom emoji in reactions (`emoji:read`),
24+
@-mentions of a Slack user group you belong to (`usergroups:read`), and
25+
direct/group DMs (`im:history`, `im:write`, `im:read`, `mpim:history`,
26+
`mpim:write`, `mpim:read`).
27+
28+
## Read state
29+
30+
Read state is kept in step with Slack in both directions, within the limits of
31+
what Slack's API exposes.
32+
33+
**Slack → Plot.** Slack tracks two separate read cursors: one for a channel's
34+
timeline, and one for each thread. A conversation or channel message you have
35+
already read in Slack is marked read in Plot, and a thread you have opened in
36+
Slack is marked read once its own cursor moves. A channel message is settled by
37+
a once-daily reconciliation pass; a thread settles as soon as it sees another
38+
reply, because the thread's cursor arrives with the messages.
39+
40+
Because the two cursors are independent, catching up on a channel does **not**
41+
mark its threads read — that matches Slack, where a thread you never opened
42+
stays unread in your Threads view. A thread you read in Slack and that then
43+
goes permanently quiet keeps its Plot unread until you open it in Plot.
44+
45+
Marking something unread in Slack is not propagated to Plot.
46+
47+
**Plot → Slack.** Reading a direct message in Plot marks that conversation read
48+
in Slack. Channel threads do not write back: Slack's API offers no per-thread
49+
mark, and the only available call moves the entire channel's cursor, which
50+
would clear unread on every other message in it.
51+
52+
## License
53+
54+
MIT

‎connectors/slack/src/slack-api.test.ts‎

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { describe, expect, it } from "vitest";
1+
import { describe, expect, it, vi } from "vitest";
22
import {
3+
SlackApi,
34
transformSlackThread,
5+
syncSlackChannel,
46
type SlackMessage,
57
type SlackUserInfoMap,
68
} from "./slack-api";
@@ -67,3 +69,95 @@ describe("transformSlackThread", () => {
6769
).toBeUndefined();
6870
});
6971
});
72+
73+
describe("SlackApi.getConversationInfo", () => {
74+
it("returns the caller's last_read cursor", async () => {
75+
const api = new SlackApi("xoxp-test");
76+
const call = vi
77+
.spyOn(api, "call")
78+
.mockResolvedValue({ channel: { id: "C1", last_read: "1700000000.000001" } });
79+
80+
await expect(api.getConversationInfo("C1")).resolves.toEqual({
81+
lastRead: "1700000000.000001",
82+
});
83+
expect(call).toHaveBeenCalledWith("conversations.info", { channel: "C1" });
84+
});
85+
86+
it("returns null when Slack omits last_read rather than inventing one", async () => {
87+
const api = new SlackApi("xoxp-test");
88+
vi.spyOn(api, "call").mockResolvedValue({ channel: { id: "C1" } });
89+
90+
await expect(api.getConversationInfo("C1")).resolves.toEqual({ lastRead: null });
91+
});
92+
});
93+
94+
describe("SlackApi.markConversationRead", () => {
95+
it("marks the conversation read at the given ts", async () => {
96+
const api = new SlackApi("xoxp-test");
97+
const call = vi.spyOn(api, "call").mockResolvedValue({ ok: true });
98+
99+
await api.markConversationRead("D1", "1700000000.000001");
100+
101+
expect(call).toHaveBeenCalledWith("conversations.mark", {
102+
channel: "D1",
103+
ts: "1700000000.000001",
104+
});
105+
});
106+
});
107+
108+
describe("syncSlackChannel thread parent", () => {
109+
it("keeps the conversations.replies parent so its thread cursor survives", async () => {
110+
const historyParent = {
111+
type: "message",
112+
ts: "1700000000.000001",
113+
thread_ts: "1700000000.000001",
114+
user: "U1",
115+
text: "parent",
116+
reply_count: 1,
117+
};
118+
const repliesParent = { ...historyParent, unread_count: 0, subscribed: true };
119+
const reply = {
120+
type: "message",
121+
ts: "1700000002.000000",
122+
thread_ts: "1700000000.000001",
123+
user: "U2",
124+
text: "reply",
125+
};
126+
127+
const api = {
128+
getConversationHistory: vi
129+
.fn()
130+
.mockResolvedValue({ messages: [historyParent], hasMore: false }),
131+
getThread: vi.fn().mockResolvedValue([repliesParent, reply]),
132+
getThreadReplies: vi.fn(),
133+
};
134+
135+
const { threads } = await syncSlackChannel(api as never, { channelId: "C1" });
136+
137+
expect(threads).toHaveLength(1);
138+
expect(threads[0]![0]!.unread_count).toBe(0);
139+
expect(threads[0]![1]!.ts).toBe("1700000002.000000");
140+
expect(api.getThreadReplies).not.toHaveBeenCalled();
141+
});
142+
143+
it("falls back to the history parent when conversations.replies returns nothing", async () => {
144+
const historyParent = {
145+
type: "message",
146+
ts: "1700000000.000001",
147+
thread_ts: "1700000000.000001",
148+
user: "U1",
149+
text: "parent",
150+
reply_count: 1,
151+
};
152+
const api = {
153+
getConversationHistory: vi
154+
.fn()
155+
.mockResolvedValue({ messages: [historyParent], hasMore: false }),
156+
getThread: vi.fn().mockResolvedValue([]),
157+
};
158+
159+
const { threads } = await syncSlackChannel(api as never, { channelId: "C1" });
160+
161+
expect(threads).toEqual([[historyParent]]);
162+
});
163+
});

‎connectors/slack/src/slack-api.ts‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,20 @@ export type SlackMessage = {
5555
}>;
5656
reply_count?: number;
5757
reply_users_count?: number;
58+
latest_reply?: string;
59+
/**
60+
* Per-THREAD read state for the calling user, present on the parent message
61+
* of a `conversations.replies` response. Slack tracks a thread's read state
62+
* separately from its channel's: reading the channel does not advance these,
63+
* and opening the thread does not advance the channel's `last_read`.
64+
*
65+
* Absent on messages from `conversations.history`, which is why the thread
66+
* fetch in `syncSlackChannel` keeps the replies parent rather than the
67+
* history one.
68+
*/
69+
subscribed?: boolean;
70+
last_read?: string;
71+
unread_count?: number;
5872
};
5973

6074
export type SlackUser = {
@@ -372,6 +386,37 @@ export class SlackApi {
372386
return messages.slice(1);
373387
}
374388

389+
/**
390+
* The calling user's read cursor for one conversation.
391+
*
392+
* Tier 3, and NOT subject to the 1 rpm non-Marketplace limit that applies to
393+
* `conversations.history`/`conversations.replies` — so this is safe to call
394+
* per conversation in a sweep. Returns `null` when Slack omits `last_read`
395+
* (the caller must then abstain rather than assume a state).
396+
*/
397+
public async getConversationInfo(
398+
channelId: string
399+
): Promise<{ lastRead: string | null }> {
400+
const data = await this.call("conversations.info", { channel: channelId });
401+
const lastRead = data.channel?.last_read;
402+
return { lastRead: typeof lastRead === "string" ? lastRead : null };
403+
}
404+
405+
/**
406+
* Move the calling user's read cursor for one conversation to `ts`.
407+
*
408+
* CONVERSATION-scoped: there is no per-thread equivalent in the public Web
409+
* API, so this must only ever be called for a direct conversation, where the
410+
* Plot link IS the whole conversation. Calling it for a channel would move
411+
* that channel's cursor for every other message in it.
412+
*/
413+
public async markConversationRead(
414+
channelId: string,
415+
ts: string
416+
): Promise<void> {
417+
await this.call("conversations.mark", { channel: channelId, ts });
418+
}
419+
375420
public async postMessage(
376421
channelId: string,
377422
text: string,
@@ -858,8 +903,21 @@ export async function syncSlackChannel(
858903
// that cursor indefinitely. Degrade to the parent-only path so the
859904
// rest of the channel still advances.
860905
try {
861-
const replies = await api.getThreadReplies(state.channelId, threadTs);
862-
threads.push([parentMessage, ...replies]);
906+
// `getThread` (conversations.replies), not `getThreadReplies`: the
907+
// parent it returns carries this thread's own read cursor
908+
// (`unread_count`/`last_read`/`latest_reply`), which the
909+
// `conversations.history` parent does not have. Same single API call
910+
// either way — `getThreadReplies` just threw the parent away.
911+
//
912+
// Spread the history parent underneath so any field only that response
913+
// carried survives, then let the replies parent's fields win.
914+
const full = await api.getThread(state.channelId, threadTs);
915+
const [repliesParent, ...replies] = full;
916+
threads.push(
917+
repliesParent
918+
? [{ ...parentMessage, ...repliesParent }, ...replies]
919+
: [parentMessage]
920+
);
863921
} catch (error) {
864922
console.warn(
865923
`conversations.replies failed for ${state.channelId}/${threadTs}; falling back to parent-only`,

0 commit comments

Comments
 (0)