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. diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index ebc419d4..b661a348 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -16,6 +16,7 @@ import type { AdapterPostableMessage, Attachment, ChannelInfo, + ChannelVisibility, ChatInstance, EmojiValue, EphemeralMessage, @@ -269,6 +270,8 @@ interface SlackWebhookPayload { | SlackUserChangeEvent; event_id?: string; event_time?: number; + /** Whether this event occurred in an externally shared channel (Slack Connect) */ + is_ext_shared_channel?: boolean; team_id?: string; type: string; } @@ -375,6 +378,12 @@ export class SlackAdapter implements Adapter { private static CHANNEL_CACHE_TTL_MS = 8 * 24 * 60 * 60 * 1000; // 8 days private static REVERSE_INDEX_TTL_MS = 8 * 24 * 60 * 60 * 1000; // 8 days + /** + * Cache of channel IDs known to be external/shared (Slack Connect). + * Populated from `is_ext_shared_channel` in incoming webhook payloads. + */ + private readonly _externalChannels = new Set(); + // Multi-workspace support private readonly clientId: string | undefined; private readonly clientSecret: string | undefined; @@ -383,6 +392,7 @@ export class SlackAdapter implements Adapter { private readonly requestContext = new AsyncLocalStorage<{ token: string; botUserId?: string; + isExtSharedChannel?: boolean; }>(); /** Bot user ID (e.g., U_BOT_123) used for mention detection */ @@ -898,6 +908,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) { + 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); + } + } + if (event.type === "message" || event.type === "app_mention") { const slackEvent = event as SlackEvent; if (!(slackEvent.team || slackEvent.team_id) && payload.team_id) { @@ -3256,17 +3279,39 @@ 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; + is_private?: 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, 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, + channelVisibility, metadata: { threadTs, channel: result.channel, @@ -3322,6 +3367,35 @@ export class SlackAdapter implements Adapter { return channel.startsWith("D"); } + /** + * 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) + */ + getChannelVisibility(threadId: string): ChannelVisibility { + const { channel } = this.decodeThreadId(threadId); + + // 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 { const parts = threadId.split(":"); if (parts.length < 2 || parts.length > 3 || parts[0] !== "slack") { @@ -3634,15 +3708,38 @@ 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 }; topic?: { value?: string }; }; + // Update external channel cache from API response + if (info?.is_ext_shared) { + 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), + 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 d6c8ea2e..2c299f09 100644 --- a/packages/chat/src/channel.test.ts +++ b/packages/chat/src/channel.test.ts @@ -527,6 +527,7 @@ describe("ChannelImpl", () => { _type: "chat:Channel", id: "slack:C123", adapterName: "slack", + channelVisibility: "unknown", isDM: false, }); }); @@ -621,6 +622,50 @@ describe("thread.channel", () => { expect(thread.channel.isDM).toBe(true); }); + + it("should inherit channelVisibility from thread", () => { + const mockAdapter = createMockAdapter(); + const mockState = createMockState(); + + const thread = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + channelVisibility: "external", + }); + + expect(thread.channel.channelVisibility).toBe("external"); + }); + + it("should default channelVisibility to unknown", () => { + const mockAdapter = createMockAdapter(); + const mockState = createMockState(); + + const thread = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + }); + + 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"); + }); }); describe("ChannelImpl.postEphemeral", () => { diff --git a/packages/chat/src/channel.ts b/packages/chat/src/channel.ts index 51a41e98..907cf903 100644 --- a/packages/chat/src/channel.ts +++ b/packages/chat/src/channel.ts @@ -18,6 +18,7 @@ import type { Author, Channel, ChannelInfo, + ChannelVisibility, EphemeralMessage, PostableMessage, PostEphemeralOptions, @@ -37,6 +38,7 @@ const CHANNEL_STATE_KEY_PREFIX = "channel-state:"; export interface SerializedChannel { _type: "chat:Channel"; adapterName: string; + channelVisibility?: ChannelVisibility; id: string; isDM: boolean; } @@ -46,6 +48,7 @@ export interface SerializedChannel { */ interface ChannelImplConfigWithAdapter { adapter: Adapter; + channelVisibility?: ChannelVisibility; id: string; isDM?: boolean; messageHistory?: MessageHistoryCache; @@ -57,6 +60,7 @@ interface ChannelImplConfigWithAdapter { */ interface ChannelImplConfigLazy { adapterName: string; + channelVisibility?: ChannelVisibility; id: string; isDM?: boolean; } @@ -83,6 +87,7 @@ export class ChannelImpl> { readonly id: string; readonly isDM: boolean; + readonly channelVisibility: ChannelVisibility; private _adapter?: Adapter; private readonly _adapterName?: string; @@ -93,6 +98,7 @@ export class ChannelImpl> constructor(config: ChannelImplConfig) { this.id = config.id; this.isDM = config.isDM ?? false; + this.channelVisibility = config.channelVisibility ?? "unknown"; if (isLazyConfig(config)) { this._adapterName = config.adapterName; @@ -387,6 +393,7 @@ export class ChannelImpl> _type: "chat:Channel", id: this.id, adapterName: this.adapter.name, + channelVisibility: this.channelVisibility, isDM: this.isDM, }; } @@ -398,6 +405,7 @@ export class ChannelImpl> const channel = new ChannelImpl({ id: json.id, adapterName: json.adapterName, + channelVisibility: json.channelVisibility, isDM: json.isDM, }); if (adapter) { diff --git a/packages/chat/src/chat.ts b/packages/chat/src/chat.ts index 9da245f6..814d1a2e 100644 --- a/packages/chat/src/chat.ts +++ b/packages/chat/src/chat.ts @@ -2085,6 +2085,10 @@ export class Chat< // Check if this is a DM const isDM = adapter.isDM?.(threadId) ?? false; + // Get channel visibility + const channelVisibility = + adapter.getChannelVisibility?.(threadId) ?? "unknown"; + return new ThreadImpl({ id: threadId, adapter, @@ -2093,6 +2097,7 @@ export class Chat< initialMessage, isSubscribedContext, isDM, + channelVisibility, currentMessage: initialMessage, logger: this.logger, streamingUpdateIntervalMs: this._streamingUpdateIntervalMs, diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index 03d6d167..bebb348b 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -271,6 +271,7 @@ export type { Author, Channel, ChannelInfo, + ChannelVisibility, ChatConfig, ChatInstance, ConcurrencyConfig, diff --git a/packages/chat/src/mock-adapter.ts b/packages/chat/src/mock-adapter.ts index fc9a1845..e50b5afe 100644 --- a/packages/chat/src/mock-adapter.ts +++ b/packages/chat/src/mock-adapter.ts @@ -70,6 +70,7 @@ export function createMockAdapter(name = "slack"): Adapter { isDM: vi .fn() .mockImplementation((threadId: string) => threadId.includes(":D")), + 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 81962cde..7a05c0c7 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", @@ -54,6 +55,59 @@ 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, + channelVisibility: "external", + }); + + const json = thread.toJSON(); + + expect(json._type).toBe("chat:Thread"); + expect(json.channelVisibility).toBe("external"); + }); + + it("should serialize private 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: "private", + }); + + const json = thread.toJSON(); + + 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", () => { const mockAdapter = createMockAdapter("teams"); const mockState = createMockState(); @@ -163,6 +217,37 @@ describe("Serialization", () => { expect(restored.adapter.name).toBe(original.adapter.name); }); + it("should round-trip channelVisibility correctly", () => { + const mockAdapter = createMockAdapter("slack"); + + const original = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + channelVisibility: "external", + }); + + const json = original.toJSON(); + const restored = ThreadImpl.fromJSON(json); + + expect(restored.channelVisibility).toBe("external"); + }); + + it("should default channelVisibility to unknown 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.channelVisibility).toBe("unknown"); + }); + it("should serialize currentMessage", () => { const mockAdapter = createMockAdapter("slack"); const currentMessage = createTestMessage("msg-1", "Hello", { @@ -730,6 +815,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.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/thread.ts b/packages/chat/src/thread.ts index b58e359a..eb6fa285 100644 --- a/packages/chat/src/thread.ts +++ b/packages/chat/src/thread.ts @@ -22,6 +22,7 @@ import type { Attachment, Author, Channel, + ChannelVisibility, EphemeralMessage, PostableMessage, PostEphemeralOptions, @@ -42,6 +43,7 @@ export interface SerializedThread { _type: "chat:Thread"; adapterName: string; channelId: string; + channelVisibility?: ChannelVisibility; currentMessage?: SerializedMessage; id: string; isDM: boolean; @@ -53,6 +55,7 @@ export interface SerializedThread { interface ThreadImplConfigWithAdapter { adapter: Adapter; channelId: string; + channelVisibility?: ChannelVisibility; currentMessage?: Message; fallbackStreamingPlaceholderText?: string | null; id: string; @@ -72,6 +75,7 @@ interface ThreadImplConfigWithAdapter { interface ThreadImplConfigLazy { adapterName: string; channelId: string; + channelVisibility?: ChannelVisibility; currentMessage?: Message; fallbackStreamingPlaceholderText?: string | null; id: string; @@ -110,6 +114,7 @@ export class ThreadImpl> readonly id: string; readonly channelId: string; readonly isDM: boolean; + readonly channelVisibility: ChannelVisibility; /** Direct adapter instance (if provided) */ private _adapter?: Adapter; @@ -135,6 +140,7 @@ export class ThreadImpl> this.id = config.id; this.channelId = config.channelId; this.isDM = config.isDM ?? false; + this.channelVisibility = config.channelVisibility ?? "unknown"; this._isSubscribedContext = config.isSubscribedContext ?? false; this._currentMessage = config.currentMessage; this._logger = config.logger; @@ -252,6 +258,7 @@ export class ThreadImpl> adapter: this.adapter, stateAdapter: this._stateAdapter, isDM: this.isDM, + channelVisibility: this.channelVisibility, messageHistory: this._messageHistory, }); } @@ -727,6 +734,7 @@ export class ThreadImpl> _type: "chat:Thread", id: this.id, channelId: this.channelId, + channelVisibility: this.channelVisibility, currentMessage: this._currentMessage?.toJSON(), isDM: this.isDM, adapterName: this.adapter.name, @@ -755,6 +763,7 @@ export class ThreadImpl> id: json.id, adapterName: json.adapterName, channelId: json.channelId, + channelVisibility: json.channelVisibility, currentMessage: json.currentMessage ? Message.fromJSON(json.currentMessage) : undefined, diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index 0cc20cba..0076d3c7 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -11,6 +11,24 @@ import type { Message } from "./message"; import type { ModalElement } from "./modals"; import type { SerializedThread } from "./thread"; +// ============================================================================= +// 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 // ============================================================================= @@ -257,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; @@ -751,6 +780,8 @@ 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 */ @@ -878,6 +909,8 @@ export interface ThreadSummary { * Channel metadata returned by fetchInfo(). */ export interface ChannelInfo { + /** The visibility scope of this channel */ + channelVisibility?: ChannelVisibility; id: string; isDM?: boolean; memberCount?: number; @@ -1080,6 +1113,8 @@ 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;