From 1578e6fe9c0aa3bf91302908622549e820498f18 Mon Sep 17 00:00:00 2001 From: v0 Date: Tue, 17 Feb 2026 19:42:53 +0000 Subject: [PATCH 1/6] feat: enhance Slack adapter to handle external shared channels Co-authored-by: Fernando Rojo <13172299+nandorojo@users.noreply.github.com> --- packages/adapter-slack/src/index.ts | 50 ++++++++++++++++++- packages/chat/src/channel.test.ts | 29 +++++++++++ packages/chat/src/channel.ts | 7 +++ packages/chat/src/chat.ts | 5 ++ packages/chat/src/mock-adapter.ts | 1 + packages/chat/src/serialization.test.ts | 66 +++++++++++++++++++++++++ packages/chat/src/thread.ts | 8 +++ packages/chat/src/types.ts | 17 +++++++ 8 files changed, 182 insertions(+), 1 deletion(-) diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index a88bda1f..0b53bb65 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -147,6 +147,8 @@ interface SlackWebhookPayload { event_id?: string; event_time?: number; team_id?: string; + /** Whether this event occurred in an externally shared channel (Slack Connect) */ + is_ext_shared_channel?: boolean; } /** Slack interactive payload (block_actions) for button clicks */ @@ -244,6 +246,12 @@ export class SlackAdapter implements Adapter { private formatConverter = new SlackFormatConverter(); private static USER_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour + /** + * Cache of channel IDs known to be external/shared (Slack Connect). + * Populated from `is_ext_shared_channel` in incoming webhook payloads. + */ + private _externalChannels = new Set(); + // Multi-workspace support private clientId: string | undefined; private clientSecret: string | undefined; @@ -251,6 +259,7 @@ export class SlackAdapter implements Adapter { private requestContext = new AsyncLocalStorage<{ token: string; botUserId?: string; + isExtSharedChannel?: boolean; }>(); /** Bot user ID (e.g., U_BOT_123) used for mention detection */ @@ -661,6 +670,19 @@ export class SlackAdapter implements Adapter { if (payload.type === "event_callback" && payload.event) { const event = payload.event; + // Track external/shared channel status from payload-level flag + if (payload.is_ext_shared_channel) { + const channelId = + "channel" in event + ? (event as SlackEvent).channel + : "item" in event + ? (event as SlackReactionEvent).item.channel + : undefined; + if (channelId) { + this._externalChannels.add(channelId); + } + } + if (event.type === "message" || event.type === "app_mention") { const slackEvent = event as SlackEvent; if (!slackEvent.team && !slackEvent.team_id && payload.team_id) { @@ -1993,7 +2015,15 @@ export class SlackAdapter implements Adapter { const result = await this.client.conversations.info( this.withToken({ channel }), ); - const channelInfo = result.channel as { name?: string } | undefined; + const channelInfo = result.channel as { + name?: string; + is_ext_shared?: boolean; + } | undefined; + + // Update external channel cache from API response + if (channelInfo?.is_ext_shared) { + this._externalChannels.add(channel); + } this.logger.debug("Slack API: conversations.info response", { channelName: channelInfo?.name, @@ -2004,6 +2034,7 @@ export class SlackAdapter implements Adapter { id: threadId, channelId: channel, channelName: channelInfo?.name, + isExternalChannel: channelInfo?.is_ext_shared ?? false, metadata: { threadTs, channel: result.channel, @@ -2057,6 +2088,16 @@ export class SlackAdapter implements Adapter { return channel.startsWith("D"); } + /** + * Check if a thread is in an external/shared channel (Slack Connect). + * Uses the `is_ext_shared_channel` flag from incoming webhook payloads, + * cached per channel ID. + */ + isExternalChannel(threadId: string): boolean { + const { channel } = this.decodeThreadId(threadId); + return this._externalChannels.has(channel); + } + decodeThreadId(threadId: string): SlackThreadId { const parts = threadId.split(":"); if (parts.length !== 3 || parts[0] !== "slack") { @@ -2368,15 +2409,22 @@ export class SlackAdapter implements Adapter { name?: string; is_im?: boolean; is_mpim?: boolean; + is_ext_shared?: boolean; num_members?: number; purpose?: { value?: string }; topic?: { value?: string }; }; + // Update external channel cache from API response + if (info?.is_ext_shared) { + this._externalChannels.add(channel); + } + return { id: channelId, name: info?.name ? `#${info.name}` : undefined, isDM: info?.is_im || info?.is_mpim || false, + isExternalChannel: info?.is_ext_shared ?? false, memberCount: info?.num_members, metadata: { purpose: info?.purpose?.value, diff --git a/packages/chat/src/channel.test.ts b/packages/chat/src/channel.test.ts index f6f0e4bf..39badc71 100644 --- a/packages/chat/src/channel.test.ts +++ b/packages/chat/src/channel.test.ts @@ -563,6 +563,35 @@ describe("thread.channel", () => { expect(thread.channel.isDM).toBe(true); }); + + it("should inherit isExternalChannel from thread", () => { + const mockAdapter = createMockAdapter(); + const mockState = createMockState(); + + const thread = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + isExternalChannel: true, + }); + + expect(thread.channel.isExternalChannel).toBe(true); + }); + + it("should default isExternalChannel to false", () => { + const mockAdapter = createMockAdapter(); + const mockState = createMockState(); + + const thread = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + }); + + expect(thread.channel.isExternalChannel).toBe(false); + }); }); describe("thread.messages (newest first)", () => { diff --git a/packages/chat/src/channel.ts b/packages/chat/src/channel.ts index 6ce2c387..4e094368 100644 --- a/packages/chat/src/channel.ts +++ b/packages/chat/src/channel.ts @@ -36,6 +36,7 @@ export interface SerializedChannel { id: string; adapterName: string; isDM: boolean; + isExternalChannel?: boolean; } /** @@ -46,6 +47,7 @@ interface ChannelImplConfigWithAdapter { adapter: Adapter; stateAdapter: StateAdapter; isDM?: boolean; + isExternalChannel?: boolean; } /** @@ -55,6 +57,7 @@ interface ChannelImplConfigLazy { id: string; adapterName: string; isDM?: boolean; + isExternalChannel?: boolean; } type ChannelImplConfig = ChannelImplConfigWithAdapter | ChannelImplConfigLazy; @@ -79,6 +82,7 @@ export class ChannelImpl> { readonly id: string; readonly isDM: boolean; + readonly isExternalChannel: boolean; private _adapter?: Adapter; private _adapterName?: string; @@ -88,6 +92,7 @@ export class ChannelImpl> constructor(config: ChannelImplConfig) { this.id = config.id; this.isDM = config.isDM ?? false; + this.isExternalChannel = config.isExternalChannel ?? false; if (isLazyConfig(config)) { this._adapterName = config.adapterName; @@ -333,6 +338,7 @@ export class ChannelImpl> id: this.id, adapterName: this.adapter.name, isDM: this.isDM, + ...(this.isExternalChannel ? { isExternalChannel: true } : {}), }; } @@ -344,6 +350,7 @@ export class ChannelImpl> id: json.id, adapterName: json.adapterName, isDM: json.isDM, + isExternalChannel: json.isExternalChannel, }); if (adapter) { channel._adapter = adapter; diff --git a/packages/chat/src/chat.ts b/packages/chat/src/chat.ts index d379bccd..a1d1daa1 100644 --- a/packages/chat/src/chat.ts +++ b/packages/chat/src/chat.ts @@ -1259,6 +1259,10 @@ export class Chat< // Check if this is a DM const isDM = adapter.isDM?.(threadId) ?? false; + // Check if this is an external/shared channel (e.g., Slack Connect) + const isExternalChannel = + adapter.isExternalChannel?.(threadId) ?? false; + return new ThreadImpl({ id: threadId, adapter, @@ -1267,6 +1271,7 @@ export class Chat< initialMessage, isSubscribedContext, isDM, + isExternalChannel, currentMessage: initialMessage, streamingUpdateIntervalMs: this._streamingUpdateIntervalMs, }); diff --git a/packages/chat/src/mock-adapter.ts b/packages/chat/src/mock-adapter.ts index 28d9ed1f..d13de4c1 100644 --- a/packages/chat/src/mock-adapter.ts +++ b/packages/chat/src/mock-adapter.ts @@ -68,6 +68,7 @@ export function createMockAdapter(name = "slack"): Adapter { isDM: vi .fn() .mockImplementation((threadId: string) => threadId.includes(":D")), + isExternalChannel: vi.fn().mockReturnValue(false), openModal: vi.fn().mockResolvedValue({ viewId: "V123" }), channelIdFromThreadId: vi .fn() diff --git a/packages/chat/src/serialization.test.ts b/packages/chat/src/serialization.test.ts index 9709b399..53bd8b96 100644 --- a/packages/chat/src/serialization.test.ts +++ b/packages/chat/src/serialization.test.ts @@ -53,6 +53,41 @@ describe("Serialization", () => { expect(json.isDM).toBe(true); }); + it("should serialize external channel thread correctly", () => { + const mockAdapter = createMockAdapter("slack"); + const mockState = createMockState(); + + const thread = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + isExternalChannel: true, + }); + + const json = thread.toJSON(); + + expect(json._type).toBe("chat:Thread"); + expect(json.isExternalChannel).toBe(true); + }); + + it("should omit isExternalChannel when false", () => { + const mockAdapter = createMockAdapter("slack"); + const mockState = createMockState(); + + const thread = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + isExternalChannel: false, + }); + + const json = thread.toJSON(); + + expect(json.isExternalChannel).toBeUndefined(); + }); + it("should produce JSON-serializable output", () => { const mockAdapter = createMockAdapter("teams"); const mockState = createMockState(); @@ -161,6 +196,37 @@ describe("Serialization", () => { expect(restored.isDM).toBe(original.isDM); expect(restored.adapter.name).toBe(original.adapter.name); }); + + it("should round-trip isExternalChannel correctly", () => { + const mockAdapter = createMockAdapter("slack"); + + const original = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + isExternalChannel: true, + }); + + const json = original.toJSON(); + const restored = ThreadImpl.fromJSON(json); + + expect(restored.isExternalChannel).toBe(true); + }); + + it("should default isExternalChannel to false when missing from JSON", () => { + const json: SerializedThread = { + _type: "chat:Thread", + id: "slack:C123:1234.5678", + channelId: "C123", + isDM: false, + adapterName: "slack", + }; + + const thread = ThreadImpl.fromJSON(json); + + expect(thread.isExternalChannel).toBe(false); + }); }); describe("Message.toJSON()", () => { diff --git a/packages/chat/src/thread.ts b/packages/chat/src/thread.ts index 6712fe14..30db91ed 100644 --- a/packages/chat/src/thread.ts +++ b/packages/chat/src/thread.ts @@ -36,6 +36,7 @@ export interface SerializedThread { id: string; channelId: string; isDM: boolean; + isExternalChannel?: boolean; adapterName: string; } @@ -50,6 +51,7 @@ interface ThreadImplConfigWithAdapter { initialMessage?: Message; isSubscribedContext?: boolean; isDM?: boolean; + isExternalChannel?: boolean; currentMessage?: Message; streamingUpdateIntervalMs?: number; } @@ -65,6 +67,7 @@ interface ThreadImplConfigLazy { initialMessage?: Message; isSubscribedContext?: boolean; isDM?: boolean; + isExternalChannel?: boolean; currentMessage?: Message; streamingUpdateIntervalMs?: number; } @@ -95,6 +98,7 @@ export class ThreadImpl> readonly id: string; readonly channelId: string; readonly isDM: boolean; + readonly isExternalChannel: boolean; /** Direct adapter instance (if provided) */ private _adapter?: Adapter; @@ -115,6 +119,7 @@ export class ThreadImpl> this.id = config.id; this.channelId = config.channelId; this.isDM = config.isDM ?? false; + this.isExternalChannel = config.isExternalChannel ?? false; this._isSubscribedContext = config.isSubscribedContext ?? false; this._currentMessage = config.currentMessage; this._streamingUpdateIntervalMs = config.streamingUpdateIntervalMs ?? 500; @@ -226,6 +231,7 @@ export class ThreadImpl> adapter: this.adapter, stateAdapter: this._stateAdapter, isDM: this.isDM, + isExternalChannel: this.isExternalChannel, }); } return this._channel; @@ -548,6 +554,7 @@ export class ThreadImpl> id: this.id, channelId: this.channelId, isDM: this.isDM, + ...(this.isExternalChannel ? { isExternalChannel: true } : {}), adapterName: this.adapter.name, }; } @@ -575,6 +582,7 @@ export class ThreadImpl> adapterName: json.adapterName, channelId: json.channelId, isDM: json.isDM, + isExternalChannel: json.isExternalChannel, }); if (adapter) { thread._adapter = adapter; diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index 44a9b4ff..c3ff09b4 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -239,6 +239,17 @@ export interface Adapter { */ isDM?(threadId: string): boolean; + /** + * Check if a thread is in an external/shared channel (e.g., Slack Connect). + * + * External channels are shared between different organizations. Bots should + * be careful about what information they expose in external channels. + * + * @param threadId - The thread ID to check + * @returns True if the thread is in an external channel, false otherwise + */ + isExternalChannel?(threadId: string): boolean; + /** * Open a modal/dialog form. * @@ -473,6 +484,8 @@ export interface Postable< readonly adapter: Adapter; /** Whether this is a direct message conversation */ readonly isDM: boolean; + /** Whether this is an external/shared channel (e.g., Slack Connect) */ + readonly isExternalChannel: boolean; /** * Get the current state. @@ -569,6 +582,8 @@ export interface ChannelInfo { id: string; name?: string; isDM?: boolean; + /** Whether this is an external/shared channel (e.g., Slack Connect) */ + isExternalChannel?: boolean; memberCount?: number; metadata: Record; } @@ -765,6 +780,8 @@ export interface ThreadInfo { channelName?: string; /** Whether this is a direct message conversation */ isDM?: boolean; + /** Whether this is an external/shared channel (e.g., Slack Connect) */ + isExternalChannel?: boolean; /** Platform-specific metadata */ metadata: Record; } From cea19a38b1df67798dc861e9e0a3d6d47becbdc0 Mon Sep 17 00:00:00 2001 From: v0 Date: Tue, 17 Feb 2026 19:52:08 +0000 Subject: [PATCH 2/6] feat: add 'isExternalChannel' support to chat SDK Co-authored-by: Fernando Rojo <13172299+nandorojo@users.noreply.github.com> --- .v0/plans/add-is-external-channel.md | 76 ++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .v0/plans/add-is-external-channel.md diff --git a/.v0/plans/add-is-external-channel.md b/.v0/plans/add-is-external-channel.md new file mode 100644 index 00000000..f1fd95dd --- /dev/null +++ b/.v0/plans/add-is-external-channel.md @@ -0,0 +1,76 @@ +# Add `isExternalChannel` support to chat SDK + +## Background + +Slack sends `is_ext_shared_channel: boolean` on event callback payloads. When the bot is in an external/shared channel (Slack Connect), it may be leaking internal context (repo summaries, etc.) to outsiders. We need to surface this at the `Thread` level so consumers can gate behavior. + +## API Design Decision: `isExternalChannel` (boolean) vs `visibility` (enum) + +**Recommendation: `isExternalChannel: boolean`** + +Reasons: +- **Matches the source data**: Slack (the only platform with this concept today) sends a simple `is_ext_shared_channel` boolean. No need to over-abstract. +- **Follows the `isDM` pattern**: The codebase already uses `isDM: boolean` on Thread/Postable with the exact same architecture (adapter method -> Chat.createThread -> ThreadImpl property). Adding `isExternalChannel` is perfectly consistent. +- **Discord has no equivalent**: Discord doesn't support cross-server channel sharing at all. +- **Teams**: Teams has "shared channels" (`membershipType: "shared"`) but that's a different Teams-specific concept. It could map to `isExternalChannel = true` in the future. +- **Google Chat**: GChat has external spaces but it's not exposed in webhook payloads -- would need a separate API call. Can be added later. +- **GitHub/Linear**: No concept of external channels. +- A `visibility` enum (`"private" | "external" | "public"`) conflates two orthogonal concerns -- DM/private vs external. A channel can be both public within a workspace AND externally shared. Keeping them separate (`isDM` + `isExternalChannel`) is cleaner. + +## Implementation Plan + +### 1. Core types (`packages/chat/src/types.ts`) + +- Add `isExternalChannel?(threadId: string): boolean` to the `Adapter` interface (optional, like `isDM?`) +- Add `readonly isExternalChannel: boolean` to the `Postable` interface (alongside `isDM`) +- Add `isExternalChannel?: boolean` to `ThreadInfo` interface +- Add `isExternalChannel?: boolean` to `ChannelInfo` interface + +### 2. Thread implementation (`packages/chat/src/thread.ts`) + +- Add `isExternalChannel?: boolean` to `SerializedThread` +- Add `isExternalChannel?: boolean` to both `ThreadImplConfigWithAdapter` and `ThreadImplConfigLazy` +- Add `readonly isExternalChannel: boolean` property to `ThreadImpl` class +- Set it in constructor: `this.isExternalChannel = config.isExternalChannel ?? false` +- Include it in `toJSON()` and `fromJSON()` serialization + +### 3. Channel implementation (`packages/chat/src/channel.ts`) + +- Add `isExternalChannel` to `ChannelImpl` (same pattern as `isDM`) + +### 4. Chat class (`packages/chat/src/chat.ts`) + +- In `createThread()`, call `adapter.isExternalChannel?.(threadId) ?? false` and pass to ThreadImpl constructor (same pattern as `isDM`) + +### 5. Slack adapter (`packages/adapter-slack/src/index.ts`) + +- Add `is_ext_shared_channel?: boolean` to the `SlackWebhookPayload` interface +- Cache `is_ext_shared_channel` per channel ID in a `Set` from incoming payloads +- Also read `is_ext_shared` from `conversations.info` API responses in `fetchThread` and `fetchChannelInfo` +- Implement `isExternalChannel(threadId)` that checks the cache + +### 6. Other adapters (Discord, Teams, GChat, GitHub, Linear) + +- `isExternalChannel` is optional on the `Adapter` interface, so these don't need explicit stubs +- The `Chat.createThread()` fallback (`adapter.isExternalChannel?.(threadId) ?? false`) handles the default + +### 7. Mock adapter (`packages/chat/src/mock-adapter.ts`) + +- Add `isExternalChannel` mock returning `false` (same pattern as `isDM`) + +### 8. Tests + +- Serialization round-trip tests for `isExternalChannel` on Thread +- Channel inheritance tests (`thread.channel.isExternalChannel`) +- Backward compatibility test (missing `isExternalChannel` in JSON defaults to `false`) + +## Key Files Modified + +1. `packages/chat/src/types.ts` - Core interfaces +2. `packages/chat/src/thread.ts` - ThreadImpl +3. `packages/chat/src/channel.ts` - ChannelImpl +4. `packages/chat/src/chat.ts` - Thread creation +5. `packages/chat/src/mock-adapter.ts` - Test mock +6. `packages/adapter-slack/src/index.ts` - Slack implementation (main one) +7. `packages/chat/src/serialization.test.ts` - Serialization tests +8. `packages/chat/src/channel.test.ts` - Channel tests From c19d782d7c69e71d7934a89c8eae874c14bbaf8a Mon Sep 17 00:00:00 2001 From: Hayden Bleasel Date: Tue, 24 Feb 2026 16:09:34 -0800 Subject: [PATCH 3/6] fix: resolve lint issues from merge Co-Authored-By: Claude Opus 4.6 --- packages/adapter-slack/src/index.ts | 26 +++++++++++---------- packages/chat/src/chat.ts | 3 +-- packages/chat/src/types.ts | 36 +++++++++-------------------- 3 files changed, 26 insertions(+), 39 deletions(-) diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index a1f69d2c..78331df7 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -201,9 +201,9 @@ interface SlackWebhookPayload { | SlackAppHomeOpenedEvent; event_id?: string; event_time?: number; - team_id?: string; /** Whether this event occurred in an externally shared channel (Slack Connect) */ is_ext_shared_channel?: boolean; + team_id?: string; type: string; } @@ -306,7 +306,7 @@ export class SlackAdapter implements Adapter { * Cache of channel IDs known to be external/shared (Slack Connect). * Populated from `is_ext_shared_channel` in incoming webhook payloads. */ - private _externalChannels = new Set(); + private readonly _externalChannels = new Set(); // Multi-workspace support private readonly clientId: string | undefined; @@ -753,12 +753,12 @@ export class SlackAdapter implements Adapter { // Track external/shared channel status from payload-level flag if (payload.is_ext_shared_channel) { - const channelId = - "channel" in event - ? (event as SlackEvent).channel - : "item" in event - ? (event as SlackReactionEvent).item.channel - : undefined; + let channelId: string | undefined; + if ("channel" in event) { + channelId = (event as SlackEvent).channel; + } else if ("item" in event) { + channelId = (event as SlackReactionEvent).item.channel; + } if (channelId) { this._externalChannels.add(channelId); } @@ -2395,10 +2395,12 @@ export class SlackAdapter implements Adapter { const result = await this.client.conversations.info( this.withToken({ channel }) ); - const channelInfo = result.channel as { - name?: string; - is_ext_shared?: boolean; - } | undefined; + const channelInfo = result.channel as + | { + name?: string; + is_ext_shared?: boolean; + } + | undefined; // Update external channel cache from API response if (channelInfo?.is_ext_shared) { diff --git a/packages/chat/src/chat.ts b/packages/chat/src/chat.ts index f183bbcc..356ebb2f 100644 --- a/packages/chat/src/chat.ts +++ b/packages/chat/src/chat.ts @@ -1577,8 +1577,7 @@ export class Chat< const isDM = adapter.isDM?.(threadId) ?? false; // Check if this is an external/shared channel (e.g., Slack Connect) - const isExternalChannel = - adapter.isExternalChannel?.(threadId) ?? false; + const isExternalChannel = adapter.isExternalChannel?.(threadId) ?? false; return new ThreadImpl({ id: threadId, diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index 7e971f9d..0370362a 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -195,6 +195,17 @@ export interface Adapter { */ isDM?(threadId: string): boolean; + /** + * Check if a thread is in an external/shared channel (e.g., Slack Connect). + * + * External channels are shared between different organizations. Bots should + * be careful about what information they expose in external channels. + * + * @param threadId - The thread ID to check + * @returns True if the thread is in an external channel, false otherwise + */ + isExternalChannel?(threadId: string): boolean; + /** * List threads in a channel. */ @@ -274,31 +285,6 @@ export interface Adapter { message: AdapterPostableMessage ): Promise>; - /** - * Check if a thread is in an external/shared channel (e.g., Slack Connect). - * - * External channels are shared between different organizations. Bots should - * be careful about what information they expose in external channels. - * - * @param threadId - The thread ID to check - * @returns True if the thread is in an external channel, false otherwise - */ - isExternalChannel?(threadId: string): boolean; - - /** - * Open a modal/dialog form. - * - * @param triggerId - Platform-specific trigger ID from the action event - * @param modal - The modal element to display - * @param contextId - Optional context ID for server-side stored thread/message context - * @returns The view/dialog ID - */ - openModal?( - triggerId: string, - modal: ModalElement, - contextId?: string, - ): Promise<{ viewId: string }>; - /** Remove a reaction from a message */ removeReaction( threadId: string, From cb5022152eb0ac3d1acdcd2566b049d13103a633 Mon Sep 17 00:00:00 2001 From: Kavin Valli Date: Tue, 24 Mar 2026 17:17:33 -0700 Subject: [PATCH 4/6] Replace isExternalChannel with channelVisibility enum (#298) --- packages/adapter-slack/src/index.ts | 61 ++++++++++++++++++++++--- packages/chat/src/channel.test.ts | 26 +++++++++-- packages/chat/src/channel.ts | 15 +++--- packages/chat/src/chat.ts | 7 +-- packages/chat/src/index.ts | 1 + packages/chat/src/mock-adapter.ts | 2 +- packages/chat/src/serialization.test.ts | 40 ++++++++++++---- packages/chat/src/thread.ts | 17 +++---- packages/chat/src/types.ts | 36 ++++++++++----- 9 files changed, 153 insertions(+), 52 deletions(-) diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 78331df7..c3f86aa5 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -15,6 +15,7 @@ import type { AdapterPostableMessage, Attachment, ChannelInfo, + ChannelVisibility, ChatInstance, EmojiValue, EphemeralMessage, @@ -2399,6 +2400,7 @@ export class SlackAdapter implements Adapter { | { name?: string; is_ext_shared?: boolean; + is_private?: boolean; } | undefined; @@ -2412,11 +2414,21 @@ export class SlackAdapter implements Adapter { ok: result.ok, }); + // Determine channel visibility + let channelVisibility: ChannelVisibility = "unknown"; + if (channelInfo?.is_ext_shared) { + channelVisibility = "external"; + } else if (channelInfo?.is_private || channel.startsWith("D")) { + channelVisibility = "private"; + } else if (channel.startsWith("C")) { + channelVisibility = "workspace"; + } + return { id: threadId, channelId: channel, channelName: channelInfo?.name, - isExternalChannel: channelInfo?.is_ext_shared ?? false, + channelVisibility, metadata: { threadTs, channel: result.channel, @@ -2473,13 +2485,32 @@ export class SlackAdapter implements Adapter { } /** - * Check if a thread is in an external/shared channel (Slack Connect). - * Uses the `is_ext_shared_channel` flag from incoming webhook payloads, - * cached per channel ID. + * Get the visibility scope of a channel containing the thread. + * + * - `external`: Slack Connect channel shared with external organizations + * - `private`: Private channel (starts with G) or DM (starts with D) + * - `workspace`: Public channel visible to all workspace members + * - `unknown`: Visibility cannot be determined (not yet cached) */ - isExternalChannel(threadId: string): boolean { + getChannelVisibility(threadId: string): ChannelVisibility { const { channel } = this.decodeThreadId(threadId); - return this._externalChannels.has(channel); + + // Check for external channel first (Slack Connect) + if (this._externalChannels.has(channel)) { + return "external"; + } + + // Private channels start with G, DMs start with D + if (channel.startsWith("G") || channel.startsWith("D")) { + return "private"; + } + + // Public channels start with C + if (channel.startsWith("C")) { + return "workspace"; + } + + return "unknown"; } decodeThreadId(threadId: string): SlackThreadId { @@ -2793,6 +2824,7 @@ export class SlackAdapter implements Adapter { name?: string; is_im?: boolean; is_mpim?: boolean; + is_private?: boolean; is_ext_shared?: boolean; num_members?: number; purpose?: { value?: string }; @@ -2804,11 +2836,26 @@ export class SlackAdapter implements Adapter { this._externalChannels.add(channel); } + // Determine channel visibility + let channelVisibility: ChannelVisibility = "unknown"; + if (info?.is_ext_shared) { + channelVisibility = "external"; + } else if ( + info?.is_im || + info?.is_mpim || + info?.is_private || + channel.startsWith("D") + ) { + channelVisibility = "private"; + } else if (channel.startsWith("C")) { + channelVisibility = "workspace"; + } + return { id: channelId, name: info?.name ? `#${info.name}` : undefined, isDM: Boolean(info?.is_im || info?.is_mpim), - isExternalChannel: info?.is_ext_shared ?? false, + channelVisibility, memberCount: info?.num_members, metadata: { purpose: info?.purpose?.value, diff --git a/packages/chat/src/channel.test.ts b/packages/chat/src/channel.test.ts index e13c44c5..ea7e965f 100644 --- a/packages/chat/src/channel.test.ts +++ b/packages/chat/src/channel.test.ts @@ -465,6 +465,7 @@ describe("ChannelImpl", () => { _type: "chat:Channel", id: "slack:C123", adapterName: "slack", + channelVisibility: "unknown", isDM: false, }); }); @@ -566,7 +567,7 @@ describe("thread.channel", () => { expect(thread.channel.isDM).toBe(true); }); - it("should inherit isExternalChannel from thread", () => { + it("should inherit channelVisibility from thread", () => { const mockAdapter = createMockAdapter(); const mockState = createMockState(); @@ -575,13 +576,13 @@ describe("thread.channel", () => { adapter: mockAdapter, channelId: "C123", stateAdapter: mockState, - isExternalChannel: true, + channelVisibility: "external", }); - expect(thread.channel.isExternalChannel).toBe(true); + expect(thread.channel.channelVisibility).toBe("external"); }); - it("should default isExternalChannel to false", () => { + it("should default channelVisibility to unknown", () => { const mockAdapter = createMockAdapter(); const mockState = createMockState(); @@ -592,7 +593,22 @@ describe("thread.channel", () => { stateAdapter: mockState, }); - expect(thread.channel.isExternalChannel).toBe(false); + expect(thread.channel.channelVisibility).toBe("unknown"); + }); + + it("should support private channel visibility", () => { + const mockAdapter = createMockAdapter(); + const mockState = createMockState(); + + const thread = new ThreadImpl({ + id: "slack:G123:1234.5678", + adapter: mockAdapter, + channelId: "G123", + stateAdapter: mockState, + channelVisibility: "private", + }); + + expect(thread.channel.channelVisibility).toBe("private"); }); }); diff --git a/packages/chat/src/channel.ts b/packages/chat/src/channel.ts index 16ac7562..f306abd6 100644 --- a/packages/chat/src/channel.ts +++ b/packages/chat/src/channel.ts @@ -16,6 +16,7 @@ import type { Author, Channel, ChannelInfo, + ChannelVisibility, EphemeralMessage, PostableMessage, PostEphemeralOptions, @@ -34,9 +35,9 @@ const CHANNEL_STATE_KEY_PREFIX = "channel-state:"; export interface SerializedChannel { _type: "chat:Channel"; adapterName: string; + channelVisibility?: ChannelVisibility; id: string; isDM: boolean; - isExternalChannel?: boolean; } /** @@ -44,9 +45,9 @@ export interface SerializedChannel { */ interface ChannelImplConfigWithAdapter { adapter: Adapter; + channelVisibility?: ChannelVisibility; id: string; isDM?: boolean; - isExternalChannel?: boolean; stateAdapter: StateAdapter; } @@ -55,9 +56,9 @@ interface ChannelImplConfigWithAdapter { */ interface ChannelImplConfigLazy { adapterName: string; + channelVisibility?: ChannelVisibility; id: string; isDM?: boolean; - isExternalChannel?: boolean; } type ChannelImplConfig = ChannelImplConfigWithAdapter | ChannelImplConfigLazy; @@ -82,7 +83,7 @@ export class ChannelImpl> { readonly id: string; readonly isDM: boolean; - readonly isExternalChannel: boolean; + readonly channelVisibility: ChannelVisibility; private _adapter?: Adapter; private readonly _adapterName?: string; @@ -92,7 +93,7 @@ export class ChannelImpl> constructor(config: ChannelImplConfig) { this.id = config.id; this.isDM = config.isDM ?? false; - this.isExternalChannel = config.isExternalChannel ?? false; + this.channelVisibility = config.channelVisibility ?? "unknown"; if (isLazyConfig(config)) { this._adapterName = config.adapterName; @@ -337,8 +338,8 @@ export class ChannelImpl> _type: "chat:Channel", id: this.id, adapterName: this.adapter.name, + channelVisibility: this.channelVisibility, isDM: this.isDM, - ...(this.isExternalChannel ? { isExternalChannel: true } : {}), }; } @@ -349,8 +350,8 @@ export class ChannelImpl> const channel = new ChannelImpl({ id: json.id, adapterName: json.adapterName, + channelVisibility: json.channelVisibility, isDM: json.isDM, - isExternalChannel: json.isExternalChannel, }); if (adapter) { channel._adapter = adapter; diff --git a/packages/chat/src/chat.ts b/packages/chat/src/chat.ts index 356ebb2f..c0b0afd1 100644 --- a/packages/chat/src/chat.ts +++ b/packages/chat/src/chat.ts @@ -1576,8 +1576,9 @@ export class Chat< // Check if this is a DM const isDM = adapter.isDM?.(threadId) ?? false; - // Check if this is an external/shared channel (e.g., Slack Connect) - const isExternalChannel = adapter.isExternalChannel?.(threadId) ?? false; + // Get channel visibility + const channelVisibility = + adapter.getChannelVisibility?.(threadId) ?? "unknown"; return new ThreadImpl({ id: threadId, @@ -1587,7 +1588,7 @@ export class Chat< initialMessage, isSubscribedContext, isDM, - isExternalChannel, + channelVisibility, currentMessage: initialMessage, streamingUpdateIntervalMs: this._streamingUpdateIntervalMs, }); diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index 40a6f919..82c5e12b 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -194,6 +194,7 @@ export type { Author, Channel, ChannelInfo, + ChannelVisibility, ChatConfig, ChatInstance, CustomEmojiMap, diff --git a/packages/chat/src/mock-adapter.ts b/packages/chat/src/mock-adapter.ts index a32e601a..d1edfc2a 100644 --- a/packages/chat/src/mock-adapter.ts +++ b/packages/chat/src/mock-adapter.ts @@ -68,7 +68,7 @@ export function createMockAdapter(name = "slack"): Adapter { isDM: vi .fn() .mockImplementation((threadId: string) => threadId.includes(":D")), - isExternalChannel: vi.fn().mockReturnValue(false), + getChannelVisibility: vi.fn().mockReturnValue("unknown"), openModal: vi.fn().mockResolvedValue({ viewId: "V123" }), channelIdFromThreadId: vi .fn() diff --git a/packages/chat/src/serialization.test.ts b/packages/chat/src/serialization.test.ts index 2020fcec..0cba853c 100644 --- a/packages/chat/src/serialization.test.ts +++ b/packages/chat/src/serialization.test.ts @@ -30,6 +30,7 @@ describe("Serialization", () => { _type: "chat:Thread", id: "slack:C123:1234.5678", channelId: "C123", + channelVisibility: "unknown", currentMessage: undefined, isDM: false, adapterName: "slack", @@ -63,16 +64,16 @@ describe("Serialization", () => { adapter: mockAdapter, channelId: "C123", stateAdapter: mockState, - isExternalChannel: true, + channelVisibility: "external", }); const json = thread.toJSON(); expect(json._type).toBe("chat:Thread"); - expect(json.isExternalChannel).toBe(true); + expect(json.channelVisibility).toBe("external"); }); - it("should omit isExternalChannel when false", () => { + it("should serialize private channel thread correctly", () => { const mockAdapter = createMockAdapter("slack"); const mockState = createMockState(); @@ -81,12 +82,30 @@ describe("Serialization", () => { adapter: mockAdapter, channelId: "C123", stateAdapter: mockState, - isExternalChannel: false, + channelVisibility: "private", }); const json = thread.toJSON(); - expect(json.isExternalChannel).toBeUndefined(); + expect(json._type).toBe("chat:Thread"); + expect(json.channelVisibility).toBe("private"); + }); + + it("should serialize workspace channel thread correctly", () => { + const mockAdapter = createMockAdapter("slack"); + const mockState = createMockState(); + + const thread = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + channelVisibility: "workspace", + }); + + const json = thread.toJSON(); + + expect(json.channelVisibility).toBe("workspace"); }); it("should produce JSON-serializable output", () => { @@ -198,7 +217,7 @@ describe("Serialization", () => { expect(restored.adapter.name).toBe(original.adapter.name); }); - it("should round-trip isExternalChannel correctly", () => { + it("should round-trip channelVisibility correctly", () => { const mockAdapter = createMockAdapter("slack"); const original = new ThreadImpl({ @@ -206,16 +225,16 @@ describe("Serialization", () => { adapter: mockAdapter, channelId: "C123", stateAdapter: mockState, - isExternalChannel: true, + channelVisibility: "external", }); const json = original.toJSON(); const restored = ThreadImpl.fromJSON(json); - expect(restored.isExternalChannel).toBe(true); + expect(restored.channelVisibility).toBe("external"); }); - it("should default isExternalChannel to false when missing from JSON", () => { + it("should default channelVisibility to unknown when missing from JSON", () => { const json: SerializedThread = { _type: "chat:Thread", id: "slack:C123:1234.5678", @@ -226,7 +245,7 @@ describe("Serialization", () => { const thread = ThreadImpl.fromJSON(json); - expect(thread.isExternalChannel).toBe(false); + expect(thread.channelVisibility).toBe("unknown"); }); it("should serialize currentMessage", () => { @@ -735,6 +754,7 @@ describe("Serialization", () => { _type: "chat:Thread", id: "slack:C123:1234.5678", channelId: "C123", + channelVisibility: "unknown", currentMessage: undefined, isDM: false, adapterName: "slack", diff --git a/packages/chat/src/thread.ts b/packages/chat/src/thread.ts index 54064f9a..7d2b83be 100644 --- a/packages/chat/src/thread.ts +++ b/packages/chat/src/thread.ts @@ -18,6 +18,7 @@ import type { Attachment, Author, Channel, + ChannelVisibility, EphemeralMessage, PostableMessage, PostEphemeralOptions, @@ -35,10 +36,10 @@ export interface SerializedThread { _type: "chat:Thread"; adapterName: string; channelId: string; + channelVisibility?: ChannelVisibility; currentMessage?: SerializedMessage; id: string; isDM: boolean; - isExternalChannel?: boolean; } /** @@ -47,11 +48,11 @@ export interface SerializedThread { interface ThreadImplConfigWithAdapter { adapter: Adapter; channelId: string; + channelVisibility?: ChannelVisibility; currentMessage?: Message; id: string; initialMessage?: Message; isDM?: boolean; - isExternalChannel?: boolean; isSubscribedContext?: boolean; stateAdapter: StateAdapter; streamingUpdateIntervalMs?: number; @@ -64,11 +65,11 @@ interface ThreadImplConfigWithAdapter { interface ThreadImplConfigLazy { adapterName: string; channelId: string; + channelVisibility?: ChannelVisibility; currentMessage?: Message; id: string; initialMessage?: Message; isDM?: boolean; - isExternalChannel?: boolean; isSubscribedContext?: boolean; streamingUpdateIntervalMs?: number; } @@ -99,7 +100,7 @@ export class ThreadImpl> readonly id: string; readonly channelId: string; readonly isDM: boolean; - readonly isExternalChannel: boolean; + readonly channelVisibility: ChannelVisibility; /** Direct adapter instance (if provided) */ private _adapter?: Adapter; @@ -120,7 +121,7 @@ export class ThreadImpl> this.id = config.id; this.channelId = config.channelId; this.isDM = config.isDM ?? false; - this.isExternalChannel = config.isExternalChannel ?? false; + this.channelVisibility = config.channelVisibility ?? "unknown"; this._isSubscribedContext = config.isSubscribedContext ?? false; this._currentMessage = config.currentMessage; this._streamingUpdateIntervalMs = config.streamingUpdateIntervalMs ?? 500; @@ -232,7 +233,7 @@ export class ThreadImpl> adapter: this.adapter, stateAdapter: this._stateAdapter, isDM: this.isDM, - isExternalChannel: this.isExternalChannel, + channelVisibility: this.channelVisibility, }); } return this._channel; @@ -556,9 +557,9 @@ export class ThreadImpl> _type: "chat:Thread", id: this.id, channelId: this.channelId, + channelVisibility: this.channelVisibility, currentMessage: this._currentMessage?.toJSON(), isDM: this.isDM, - ...(this.isExternalChannel ? { isExternalChannel: true } : {}), adapterName: this.adapter.name, }; } @@ -585,11 +586,11 @@ export class ThreadImpl> id: json.id, adapterName: json.adapterName, channelId: json.channelId, + channelVisibility: json.channelVisibility, currentMessage: json.currentMessage ? Message.fromJSON(json.currentMessage) : undefined, isDM: json.isDM, - isExternalChannel: json.isExternalChannel, }); if (adapter) { thread._adapter = adapter; diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index 0370362a..cae6492e 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -9,6 +9,20 @@ import type { Logger, LogLevel } from "./logger"; import type { Message } from "./message"; import type { ModalElement } from "./modals"; +// ============================================================================= +// Channel Visibility +// ============================================================================= + +/** + * Represents the visibility scope of a channel. + * + * - `private`: Channel is only visible to invited members (e.g., private Slack channels) + * - `workspace`: Channel is visible to all workspace members (e.g., public Slack channels) + * - `external`: Channel is shared with external organizations (e.g., Slack Connect) + * - `unknown`: Visibility cannot be determined + */ +export type ChannelVisibility = "private" | "workspace" | "external" | "unknown"; + // ============================================================================= // Re-exports from extracted modules // ============================================================================= @@ -196,15 +210,15 @@ export interface Adapter { isDM?(threadId: string): boolean; /** - * Check if a thread is in an external/shared channel (e.g., Slack Connect). + * Get the visibility scope of a channel containing the thread. * - * External channels are shared between different organizations. Bots should - * be careful about what information they expose in external channels. + * This distinguishes between private channels, workspace-visible channels, + * and externally shared channels (e.g., Slack Connect). * * @param threadId - The thread ID to check - * @returns True if the thread is in an external channel, false otherwise + * @returns The channel visibility scope */ - isExternalChannel?(threadId: string): boolean; + getChannelVisibility?(threadId: string): ChannelVisibility; /** * List threads in a channel. @@ -517,8 +531,8 @@ export interface Postable< readonly id: string; /** Whether this is a direct message conversation */ readonly isDM: boolean; - /** Whether this is an external/shared channel (e.g., Slack Connect) */ - readonly isExternalChannel: boolean; + /** The visibility scope of this channel */ + readonly channelVisibility: ChannelVisibility; /** * Get a platform-specific mention string for a user. @@ -613,8 +627,8 @@ export interface ThreadSummary { export interface ChannelInfo { id: string; isDM?: boolean; - /** Whether this is an external/shared channel (e.g., Slack Connect) */ - isExternalChannel?: boolean; + /** The visibility scope of this channel */ + channelVisibility?: ChannelVisibility; memberCount?: number; metadata: Record; name?: string; @@ -812,8 +826,8 @@ export interface ThreadInfo { id: string; /** Whether this is a direct message conversation */ isDM?: boolean; - /** Whether this is an external/shared channel (e.g., Slack Connect) */ - isExternalChannel?: boolean; + /** The visibility scope of this channel */ + channelVisibility?: ChannelVisibility; /** Platform-specific metadata */ metadata: Record; } From 0e5e1de1f7b1c870f0377b421a2da2153ea48005 Mon Sep 17 00:00:00 2001 From: Kavin Valli Date: Wed, 25 Mar 2026 11:33:02 -0700 Subject: [PATCH 5/6] fix lint and test issues --- .v0/plans/add-is-external-channel.md | 76 ---------------------------- packages/chat/src/thread.test.ts | 1 + packages/chat/src/types.ts | 40 ++++++++------- 3 files changed, 23 insertions(+), 94 deletions(-) delete mode 100644 .v0/plans/add-is-external-channel.md diff --git a/.v0/plans/add-is-external-channel.md b/.v0/plans/add-is-external-channel.md deleted file mode 100644 index f1fd95dd..00000000 --- a/.v0/plans/add-is-external-channel.md +++ /dev/null @@ -1,76 +0,0 @@ -# Add `isExternalChannel` support to chat SDK - -## Background - -Slack sends `is_ext_shared_channel: boolean` on event callback payloads. When the bot is in an external/shared channel (Slack Connect), it may be leaking internal context (repo summaries, etc.) to outsiders. We need to surface this at the `Thread` level so consumers can gate behavior. - -## API Design Decision: `isExternalChannel` (boolean) vs `visibility` (enum) - -**Recommendation: `isExternalChannel: boolean`** - -Reasons: -- **Matches the source data**: Slack (the only platform with this concept today) sends a simple `is_ext_shared_channel` boolean. No need to over-abstract. -- **Follows the `isDM` pattern**: The codebase already uses `isDM: boolean` on Thread/Postable with the exact same architecture (adapter method -> Chat.createThread -> ThreadImpl property). Adding `isExternalChannel` is perfectly consistent. -- **Discord has no equivalent**: Discord doesn't support cross-server channel sharing at all. -- **Teams**: Teams has "shared channels" (`membershipType: "shared"`) but that's a different Teams-specific concept. It could map to `isExternalChannel = true` in the future. -- **Google Chat**: GChat has external spaces but it's not exposed in webhook payloads -- would need a separate API call. Can be added later. -- **GitHub/Linear**: No concept of external channels. -- A `visibility` enum (`"private" | "external" | "public"`) conflates two orthogonal concerns -- DM/private vs external. A channel can be both public within a workspace AND externally shared. Keeping them separate (`isDM` + `isExternalChannel`) is cleaner. - -## Implementation Plan - -### 1. Core types (`packages/chat/src/types.ts`) - -- Add `isExternalChannel?(threadId: string): boolean` to the `Adapter` interface (optional, like `isDM?`) -- Add `readonly isExternalChannel: boolean` to the `Postable` interface (alongside `isDM`) -- Add `isExternalChannel?: boolean` to `ThreadInfo` interface -- Add `isExternalChannel?: boolean` to `ChannelInfo` interface - -### 2. Thread implementation (`packages/chat/src/thread.ts`) - -- Add `isExternalChannel?: boolean` to `SerializedThread` -- Add `isExternalChannel?: boolean` to both `ThreadImplConfigWithAdapter` and `ThreadImplConfigLazy` -- Add `readonly isExternalChannel: boolean` property to `ThreadImpl` class -- Set it in constructor: `this.isExternalChannel = config.isExternalChannel ?? false` -- Include it in `toJSON()` and `fromJSON()` serialization - -### 3. Channel implementation (`packages/chat/src/channel.ts`) - -- Add `isExternalChannel` to `ChannelImpl` (same pattern as `isDM`) - -### 4. Chat class (`packages/chat/src/chat.ts`) - -- In `createThread()`, call `adapter.isExternalChannel?.(threadId) ?? false` and pass to ThreadImpl constructor (same pattern as `isDM`) - -### 5. Slack adapter (`packages/adapter-slack/src/index.ts`) - -- Add `is_ext_shared_channel?: boolean` to the `SlackWebhookPayload` interface -- Cache `is_ext_shared_channel` per channel ID in a `Set` from incoming payloads -- Also read `is_ext_shared` from `conversations.info` API responses in `fetchThread` and `fetchChannelInfo` -- Implement `isExternalChannel(threadId)` that checks the cache - -### 6. Other adapters (Discord, Teams, GChat, GitHub, Linear) - -- `isExternalChannel` is optional on the `Adapter` interface, so these don't need explicit stubs -- The `Chat.createThread()` fallback (`adapter.isExternalChannel?.(threadId) ?? false`) handles the default - -### 7. Mock adapter (`packages/chat/src/mock-adapter.ts`) - -- Add `isExternalChannel` mock returning `false` (same pattern as `isDM`) - -### 8. Tests - -- Serialization round-trip tests for `isExternalChannel` on Thread -- Channel inheritance tests (`thread.channel.isExternalChannel`) -- Backward compatibility test (missing `isExternalChannel` in JSON defaults to `false`) - -## Key Files Modified - -1. `packages/chat/src/types.ts` - Core interfaces -2. `packages/chat/src/thread.ts` - ThreadImpl -3. `packages/chat/src/channel.ts` - ChannelImpl -4. `packages/chat/src/chat.ts` - Thread creation -5. `packages/chat/src/mock-adapter.ts` - Test mock -6. `packages/adapter-slack/src/index.ts` - Slack implementation (main one) -7. `packages/chat/src/serialization.test.ts` - Serialization tests -8. `packages/chat/src/channel.test.ts` - Channel tests diff --git a/packages/chat/src/thread.test.ts b/packages/chat/src/thread.test.ts index 742c7bb9..4031adad 100644 --- a/packages/chat/src/thread.test.ts +++ b/packages/chat/src/thread.test.ts @@ -1779,6 +1779,7 @@ describe("ThreadImpl", () => { _type: "chat:Thread", id: "slack:C123:1234.5678", channelId: "C123", + channelVisibility: "unknown", currentMessage: undefined, isDM: true, adapterName: "slack", diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index 6233360e..0076d3c7 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -23,7 +23,11 @@ import type { SerializedThread } from "./thread"; * - `external`: Channel is shared with external organizations (e.g., Slack Connect) * - `unknown`: Visibility cannot be determined */ -export type ChannelVisibility = "private" | "workspace" | "external" | "unknown"; +export type ChannelVisibility = + | "private" + | "workspace" + | "external" + | "unknown"; // ============================================================================= // Re-exports from extracted modules @@ -271,6 +275,17 @@ export interface Adapter { /** Fetch thread metadata */ fetchThread(threadId: string): Promise; + /** + * Get the visibility scope of a channel containing the thread. + * + * This distinguishes between private channels, workspace-visible channels, + * and externally shared channels (e.g., Slack Connect). + * + * @param threadId - The thread ID to check + * @returns The channel visibility scope + */ + getChannelVisibility?(threadId: string): ChannelVisibility; + /** Handle incoming webhook request */ handleWebhook(request: Request, options?: WebhookOptions): Promise; @@ -285,17 +300,6 @@ export interface Adapter { */ isDM?(threadId: string): boolean; - /** - * Get the visibility scope of a channel containing the thread. - * - * This distinguishes between private channels, workspace-visible channels, - * and externally shared channels (e.g., Slack Connect). - * - * @param threadId - The thread ID to check - * @returns The channel visibility scope - */ - getChannelVisibility?(threadId: string): ChannelVisibility; - /** * List threads in a channel. */ @@ -776,12 +780,12 @@ export interface Postable< > { /** The adapter this entity belongs to */ readonly adapter: Adapter; + /** The visibility scope of this channel */ + readonly channelVisibility: ChannelVisibility; /** Unique ID */ readonly id: string; /** Whether this is a direct message conversation */ readonly isDM: boolean; - /** The visibility scope of this channel */ - readonly channelVisibility: ChannelVisibility; /** * Get a platform-specific mention string for a user. @@ -905,10 +909,10 @@ export interface ThreadSummary { * Channel metadata returned by fetchInfo(). */ export interface ChannelInfo { - id: string; - isDM?: boolean; /** The visibility scope of this channel */ channelVisibility?: ChannelVisibility; + id: string; + isDM?: boolean; memberCount?: number; metadata: Record; name?: string; @@ -1109,11 +1113,11 @@ export interface Thread, TRawMessage = unknown> export interface ThreadInfo { channelId: string; channelName?: string; + /** The visibility scope of this channel */ + channelVisibility?: ChannelVisibility; id: string; /** Whether this is a direct message conversation */ isDM?: boolean; - /** The visibility scope of this channel */ - channelVisibility?: ChannelVisibility; /** Platform-specific metadata */ metadata: Record; } From d4ee1ac9f6394dbcc4d399e3b597d0a16c0576a8 Mon Sep 17 00:00:00 2001 From: Kavin Valli Date: Wed, 25 Mar 2026 11:39:52 -0700 Subject: [PATCH 6/6] changeset for add channel visibility --- .changeset/add-channel-visibility.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/add-channel-visibility.md diff --git a/.changeset/add-channel-visibility.md b/.changeset/add-channel-visibility.md new file mode 100644 index 00000000..53175c5e --- /dev/null +++ b/.changeset/add-channel-visibility.md @@ -0,0 +1,6 @@ +--- +"chat": minor +"@chat-adapter/slack": minor +--- + +Add `channelVisibility` enum to distinguish private, workspace, external, and unknown channel scopes. Implements `getChannelVisibility()` on the Adapter interface and Slack adapter, replacing the previous `isExternalChannel` boolean.