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; }