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
61 changes: 54 additions & 7 deletions packages/adapter-slack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
AdapterPostableMessage,
Attachment,
ChannelInfo,
ChannelVisibility,
ChatInstance,
EmojiValue,
EphemeralMessage,
Expand Down Expand Up @@ -2399,6 +2400,7 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
| {
name?: string;
is_ext_shared?: boolean;
is_private?: boolean;
}
| undefined;

Expand All @@ -2412,11 +2414,21 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
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,
Expand Down Expand Up @@ -2473,13 +2485,32 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
}

/**
* 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 {
Expand Down Expand Up @@ -2793,6 +2824,7 @@ 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 };
Expand All @@ -2804,11 +2836,26 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
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,
Expand Down
26 changes: 21 additions & 5 deletions packages/chat/src/channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ describe("ChannelImpl", () => {
_type: "chat:Channel",
id: "slack:C123",
adapterName: "slack",
channelVisibility: "unknown",
isDM: false,
});
});
Expand Down Expand Up @@ -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();

Expand All @@ -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();

Expand All @@ -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");
});
});

Expand Down
15 changes: 8 additions & 7 deletions packages/chat/src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
Author,
Channel,
ChannelInfo,
ChannelVisibility,
EphemeralMessage,
PostableMessage,
PostEphemeralOptions,
Expand All @@ -34,19 +35,19 @@ const CHANNEL_STATE_KEY_PREFIX = "channel-state:";
export interface SerializedChannel {
_type: "chat:Channel";
adapterName: string;
channelVisibility?: ChannelVisibility;
id: string;
isDM: boolean;
isExternalChannel?: boolean;
}

/**
* Config for creating a ChannelImpl with explicit adapter/state instances.
*/
interface ChannelImplConfigWithAdapter {
adapter: Adapter;
channelVisibility?: ChannelVisibility;
id: string;
isDM?: boolean;
isExternalChannel?: boolean;
stateAdapter: StateAdapter;
}

Expand All @@ -55,9 +56,9 @@ interface ChannelImplConfigWithAdapter {
*/
interface ChannelImplConfigLazy {
adapterName: string;
channelVisibility?: ChannelVisibility;
id: string;
isDM?: boolean;
isExternalChannel?: boolean;
}

type ChannelImplConfig = ChannelImplConfigWithAdapter | ChannelImplConfigLazy;
Expand All @@ -82,7 +83,7 @@ export class ChannelImpl<TState = Record<string, unknown>>
{
readonly id: string;
readonly isDM: boolean;
readonly isExternalChannel: boolean;
readonly channelVisibility: ChannelVisibility;

private _adapter?: Adapter;
private readonly _adapterName?: string;
Expand All @@ -92,7 +93,7 @@ export class ChannelImpl<TState = Record<string, unknown>>
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;
Expand Down Expand Up @@ -337,8 +338,8 @@ export class ChannelImpl<TState = Record<string, unknown>>
_type: "chat:Channel",
id: this.id,
adapterName: this.adapter.name,
channelVisibility: this.channelVisibility,
isDM: this.isDM,
...(this.isExternalChannel ? { isExternalChannel: true } : {}),
};
}

Expand All @@ -349,8 +350,8 @@ export class ChannelImpl<TState = Record<string, unknown>>
const channel = new ChannelImpl<TState>({
id: json.id,
adapterName: json.adapterName,
channelVisibility: json.channelVisibility,
isDM: json.isDM,
isExternalChannel: json.isExternalChannel,
});
if (adapter) {
channel._adapter = adapter;
Expand Down
7 changes: 4 additions & 3 deletions packages/chat/src/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TState>({
id: threadId,
Expand All @@ -1587,7 +1588,7 @@ export class Chat<
initialMessage,
isSubscribedContext,
isDM,
isExternalChannel,
channelVisibility,
currentMessage: initialMessage,
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 @@ -194,6 +194,7 @@ export type {
Author,
Channel,
ChannelInfo,
ChannelVisibility,
ChatConfig,
ChatInstance,
CustomEmojiMap,
Expand Down
2 changes: 1 addition & 1 deletion packages/chat/src/mock-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
40 changes: 30 additions & 10 deletions packages/chat/src/serialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe("Serialization", () => {
_type: "chat:Thread",
id: "slack:C123:1234.5678",
channelId: "C123",
channelVisibility: "unknown",
currentMessage: undefined,
isDM: false,
adapterName: "slack",
Expand Down Expand Up @@ -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();

Expand All @@ -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", () => {
Expand Down Expand Up @@ -198,24 +217,24 @@ 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({
id: "slack:C123:1234.5678",
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",
Expand All @@ -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", () => {
Expand Down Expand Up @@ -735,6 +754,7 @@ describe("Serialization", () => {
_type: "chat:Thread",
id: "slack:C123:1234.5678",
channelId: "C123",
channelVisibility: "unknown",
currentMessage: undefined,
isDM: false,
adapterName: "slack",
Expand Down
Loading