Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/add-channel-visibility.md
Original file line number Diff line number Diff line change
@@ -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.
99 changes: 98 additions & 1 deletion packages/adapter-slack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
AdapterPostableMessage,
Attachment,
ChannelInfo,
ChannelVisibility,
ChatInstance,
EmojiValue,
EphemeralMessage,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -375,6 +378,12 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
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<string>();

// Multi-workspace support
private readonly clientId: string | undefined;
private readonly clientSecret: string | undefined;
Expand All @@ -383,6 +392,7 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
private readonly requestContext = new AsyncLocalStorage<{
token: string;
botUserId?: string;
isExtSharedChannel?: boolean;
}>();

/** Bot user ID (e.g., U_BOT_123) used for mention detection */
Expand Down Expand Up @@ -898,6 +908,19 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
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) {
Expand Down Expand Up @@ -3256,17 +3279,39 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
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,
Expand Down Expand Up @@ -3322,6 +3367,35 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
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") {
Expand Down Expand Up @@ -3634,15 +3708,38 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
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,
Expand Down
45 changes: 45 additions & 0 deletions packages/chat/src/channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ describe("ChannelImpl", () => {
_type: "chat:Channel",
id: "slack:C123",
adapterName: "slack",
channelVisibility: "unknown",
isDM: false,
});
});
Expand Down Expand Up @@ -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", () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/chat/src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
Author,
Channel,
ChannelInfo,
ChannelVisibility,
EphemeralMessage,
PostableMessage,
PostEphemeralOptions,
Expand All @@ -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;
}
Expand All @@ -46,6 +48,7 @@ export interface SerializedChannel {
*/
interface ChannelImplConfigWithAdapter {
adapter: Adapter;
channelVisibility?: ChannelVisibility;
id: string;
isDM?: boolean;
messageHistory?: MessageHistoryCache;
Expand All @@ -57,6 +60,7 @@ interface ChannelImplConfigWithAdapter {
*/
interface ChannelImplConfigLazy {
adapterName: string;
channelVisibility?: ChannelVisibility;
id: string;
isDM?: boolean;
}
Expand All @@ -83,6 +87,7 @@ export class ChannelImpl<TState = Record<string, unknown>>
{
readonly id: string;
readonly isDM: boolean;
readonly channelVisibility: ChannelVisibility;

private _adapter?: Adapter;
private readonly _adapterName?: string;
Expand All @@ -93,6 +98,7 @@ export class ChannelImpl<TState = Record<string, unknown>>
constructor(config: ChannelImplConfig) {
this.id = config.id;
this.isDM = config.isDM ?? false;
this.channelVisibility = config.channelVisibility ?? "unknown";

if (isLazyConfig(config)) {
this._adapterName = config.adapterName;
Expand Down Expand Up @@ -387,6 +393,7 @@ export class ChannelImpl<TState = Record<string, unknown>>
_type: "chat:Channel",
id: this.id,
adapterName: this.adapter.name,
channelVisibility: this.channelVisibility,
isDM: this.isDM,
};
}
Expand All @@ -398,6 +405,7 @@ export class ChannelImpl<TState = Record<string, unknown>>
const channel = new ChannelImpl<TState>({
id: json.id,
adapterName: json.adapterName,
channelVisibility: json.channelVisibility,
isDM: json.isDM,
});
if (adapter) {
Expand Down
5 changes: 5 additions & 0 deletions packages/chat/src/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TState>({
id: threadId,
adapter,
Expand All @@ -2093,6 +2097,7 @@ export class Chat<
initialMessage,
isSubscribedContext,
isDM,
channelVisibility,
currentMessage: initialMessage,
logger: this.logger,
streamingUpdateIntervalMs: this._streamingUpdateIntervalMs,
Expand Down
1 change: 1 addition & 0 deletions packages/chat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ export type {
Author,
Channel,
ChannelInfo,
ChannelVisibility,
ChatConfig,
ChatInstance,
ConcurrencyConfig,
Expand Down
1 change: 1 addition & 0 deletions packages/chat/src/mock-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading