Compare commits

...
10 changed files with 1590 additions and 59 deletions
+332 -3
View File
@@ -1,9 +1,26 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { __test__ } from "./slack";
describe("slack binding lookup", () => {
const participantKey = __test__.buildSlackParticipantKey("T123", "U123");
beforeEach(() => {
__test__.clearSlackApiCaches();
});
it("accepts the documented --token alias for the bot token", () => {
expect(
__test__.parseSlackOptionsForTest([
"--token",
"xoxb-documented-token",
"--signing-secret",
"secret",
"--base-url",
"http://127.0.0.1:8787",
]).botToken,
).toBe("xoxb-documented-token");
});
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
const result = __test__.findBindingForThread(
{
@@ -67,10 +84,10 @@ describe("slack binding lookup", () => {
expect(result?.binding.sessionId).toBe("sess-2");
});
it("reuses a binding by participant key across different threads", () => {
it("does not reuse a thread-scoped binding by participant key across Slack channel threads", () => {
const result = __test__.findBindingForThread(
{
[participantKey]: {
"slack:C123:111.222": {
channelId: "slack:C123",
isDM: false,
participantKey,
@@ -80,6 +97,7 @@ describe("slack binding lookup", () => {
state: {
sessionId: "sess-1",
teamId: "T123",
bindingScope: "thread",
participantKey,
participantLabel: "alice",
},
@@ -91,6 +109,39 @@ describe("slack binding lookup", () => {
channelId: "slack:C999",
isDM: false,
participantKey,
bindingScope: "thread",
},
);
expect(result).toBeUndefined();
});
it("can still reuse participant-scoped bindings for Slack DMs", () => {
const result = __test__.findBindingForThread(
{
[participantKey]: {
channelId: "slack:D123",
isDM: true,
participantKey,
participantLabel: "alice",
serializedThread: "{}",
sessionId: "sess-1",
state: {
sessionId: "sess-1",
teamId: "T123",
bindingScope: "participant",
participantKey,
participantLabel: "alice",
},
updatedAt: "2026-03-17T00:00:00.000Z",
},
},
{
id: "slack:D123",
channelId: "slack:D123",
isDM: true,
participantKey,
bindingScope: "participant",
},
);
@@ -157,6 +208,284 @@ describe("slack binding lookup", () => {
);
});
it("strips a leading Slack mention before command handling", () => {
expect(__test__.stripLeadingSlackMention("<@U123> /help")).toBe(
"<@U123> /help",
);
expect(__test__.stripLeadingSlackMention("<@U123> /help", "U999")).toBe(
"<@U123> /help",
);
expect(__test__.stripLeadingSlackMention("<@U123> /help", "U123")).toBe(
"/help",
);
expect(__test__.stripLeadingSlackMention(" <@U123|Cline> /whereami")).toBe(
"<@U123|Cline> /whereami",
);
expect(
__test__.stripLeadingSlackMention(" <@U123|Cline> /whereami", "U123"),
).toBe("/whereami");
expect(__test__.stripLeadingSlackMention("hello <@U123> /help")).toBe(
"hello <@U123> /help",
);
});
it("uses raw Slack mention markup for command handling when display text is normalized", () => {
expect(
__test__.resolveSlackTurnText({
text: "@Cline CLI Test /help",
raw: { text: "<@U123> /help" },
botUserId: "U123",
}),
).toBe("/help");
expect(
__test__.resolveSlackTurnText({
text: "@Other User /help",
raw: { text: "<@U999> /help" },
botUserId: "U123",
}),
).toBe("@Other User /help");
});
it("adds Slack author context to runtime text", () => {
expect(
__test__.formatSlackRuntimeText({
text: "What's Ara's first question?",
thread: {
id: "slack:C123:111.222",
channelId: "slack:C123",
isDM: false,
} as never,
state: {
teamId: "T123",
bindingScope: "thread",
participantKey: "slack:team:T123:user:U08LK8A7YTC",
participantLabel: "Ara",
},
addressedToBot: false,
}),
).toBe(
[
"<slack_message_context>",
"teamId: T123",
"threadId: slack:C123:111.222",
"channelId: slack:C123",
"isDM: false",
"authorId: U08LK8A7YTC",
"authorMention: <@U08LK8A7YTC>",
"authorLabel: Ara",
"participantKey: slack:team:T123:user:U08LK8A7YTC",
"isDirectMention: false",
"</slack_message_context>",
"",
"What's Ara's first question?",
].join("\n"),
);
});
it("reuses cached Slack participant labels instead of calling users.info on every message", async () => {
const usersInfo = vi.fn(async () => ({
ok: true,
user: {
id: "U08LK8A7YTC",
profile: { display_name: "Ara" },
},
}));
const participant = {
key: __test__.buildSlackParticipantKey("T123", "U08LK8A7YTC"),
label: "U08LK8A7YTC",
};
const slack = {
webClient: { users: { info: usersInfo } },
getInstallation: vi.fn(async () => undefined),
withBotToken: vi.fn(async (_token: string, work: () => unknown) =>
work(),
),
} as never;
await expect(
__test__.resolveSlackParticipantLabel({
slack,
teamId: "T123",
participant,
currentState: {},
}),
).resolves.toEqual({ ...participant, label: "Ara" });
expect(usersInfo).toHaveBeenCalledTimes(1);
await expect(
__test__.resolveSlackParticipantLabel({
slack,
teamId: "T123",
participant: { ...participant, label: "ara" },
currentState: {
participantKey: participant.key,
participantLabel: "Ara",
},
}),
).resolves.toEqual({ ...participant, label: "Ara" });
expect(usersInfo).toHaveBeenCalledTimes(1);
await expect(
__test__.resolveSlackParticipantLabel({
slack,
teamId: "T123",
participant,
currentState: {
participantKey: __test__.buildSlackParticipantKey("T123", "UOTHER"),
participantLabel: "Other",
},
}),
).resolves.toEqual({ ...participant, label: "Ara" });
expect(usersInfo).toHaveBeenCalledTimes(1);
});
it("resolves outbound Slack display names to user mention ids", () => {
expect(
__test__.resolveSlackOutboundMentionText({
text: "@Ara can you check this?",
users: [
{
id: "U08LK8A7YTC",
name: "ara",
profile: { display_name: "Ara" },
},
],
}),
).toBe("<@U08LK8A7YTC> can you check this?");
});
it("resolves outbound multi-word Slack display names", () => {
expect(
__test__.resolveSlackOutboundMentionText({
text: "@Cline CLI Test can you take a look?",
users: [
{
id: "U0B4S0ZUVM2",
name: "cline-cli-test",
profile: { display_name: "Cline CLI Test" },
},
],
}),
).toBe("<@U0B4S0ZUVM2> can you take a look?");
});
it("leaves ambiguous outbound Slack names unresolved unless a preferred user matches", () => {
const users = [
{
id: "U111",
name: "alice-one",
profile: { display_name: "Alice" },
},
{
id: "U222",
name: "alice-two",
profile: { display_name: "Alice" },
},
];
expect(
__test__.resolveSlackOutboundMentionText({
text: "@Alice please check this.",
users,
}),
).toBe("@Alice please check this.");
expect(
__test__.resolveSlackOutboundMentionText({
text: "@Alice please check this.",
users,
preferredUserIds: ["U222"],
}),
).toBe("<@U222> please check this.");
});
it("skips Slack user lookup when text has no resolvable outbound mention", async () => {
const usersList = vi.fn(async () => ({ ok: true, members: [] }));
await expect(
__test__.resolveSlackOutboundMentions({
slack: { webClient: { users: { list: usersList } } } as never,
text: "Ping <@U123> or email test@example.com.",
teamId: "T123",
}),
).resolves.toBe("Ping <@U123> or email test@example.com.");
expect(usersList).not.toHaveBeenCalled();
});
it("caches Slack member lists for repeated outbound mention resolution", async () => {
const usersList = vi.fn(async () => ({
ok: true,
members: [
{
id: "U08LK8A7YTC",
name: "ara",
profile: { display_name: "Ara" },
},
],
}));
const slack = {
webClient: {
users: { list: usersList },
},
} as never;
await expect(
__test__.resolveSlackOutboundMentions({
slack,
teamId: "T123",
text: "@Ara shipped the fix.",
}),
).resolves.toBe("<@U08LK8A7YTC> shipped the fix.");
await expect(
__test__.resolveSlackOutboundMentions({
slack,
teamId: "T123",
text: "@Ara can review this too.",
}),
).resolves.toBe("<@U08LK8A7YTC> can review this too.");
expect(usersList).toHaveBeenCalledTimes(1);
await expect(
__test__.resolveSlackOutboundMentions({
slack,
teamId: "T456",
text: "@Ara should still resolve in another team.",
}),
).resolves.toBe("<@U08LK8A7YTC> should still resolve in another team.");
expect(usersList).toHaveBeenCalledTimes(2);
});
it("posts final Slack replies through the thread after resolving mentions", async () => {
const usersList = vi.fn(async () => ({
ok: true,
members: [
{
id: "U08LK8A7YTC",
name: "ara",
profile: { display_name: "Ara" },
},
],
}));
const fallbackPost = vi.fn(async () => undefined);
await __test__.postSlackResolvedText({
slack: {
webClient: {
users: { list: usersList },
},
} as never,
thread: {
id: "slack:C123:111.222",
post: fallbackPost,
} as never,
text: "@Ara shipped the fix.",
teamId: "T123",
});
expect(fallbackPost).toHaveBeenCalledWith(
"<@U08LK8A7YTC> shipped the fix.",
);
});
it("routes Slack posts through the installation bot token for a team", async () => {
const calls: string[] = [];
const result = await __test__.withSlackTeamBotToken({
+576 -9
View File
@@ -46,6 +46,7 @@ import {
import { FileStateAdapter } from "../stores/file-state";
import { startConnectorTaskUpdateRelay } from "../task-updates";
import {
type ConnectorBindingScope,
type ConnectorBindingStore,
type ConnectorThreadBinding,
type ConnectorThreadState,
@@ -70,7 +71,10 @@ import {
const SLACK_SYSTEM_RULES = getConnectorSystemRules(
"Slack",
"You can respond to user messages in threads and DMs, and you can use tools according to user's requests and your capabilities.",
[
"You can respond to user messages in threads and DMs, and you can use tools according to user's requests and your capabilities.",
"When asked to mention a Slack user or bot by name, write the mention as @display-name or @username. The connector resolves unique Slack names to Slack mention IDs before sending. Do not ask the user for a Slack ID unless the name cannot be resolved.",
].join("\n"),
);
const SLACK_FIRST_CONTACT_MESSAGE = getConnectorFirstContactMessage();
@@ -78,6 +82,47 @@ const SLACK_FIRST_CONTACT_MESSAGE = getConnectorFirstContactMessage();
type SlackThreadState = ConnectorThreadState & {
teamId?: string;
};
type SlackUserProfile = {
display_name?: string;
display_name_normalized?: string;
real_name?: string;
real_name_normalized?: string;
};
type SlackUser = {
id?: string;
name?: string;
real_name?: string;
deleted?: boolean;
profile?: SlackUserProfile;
};
type SlackUsersListResponse = {
ok?: boolean;
error?: string;
members?: SlackUser[];
response_metadata?: {
next_cursor?: string;
};
};
type SlackUsersInfoResponse = {
ok?: boolean;
error?: string;
user?: SlackUser;
};
const SLACK_API_CACHE_TTL_MS = 10 * 60 * 1000;
type SlackCacheEntry<T> = {
value: T;
expiresAt: number;
};
const slackUserLabelCache = new Map<string, SlackCacheEntry<string>>();
const pendingSlackUserLabelFetches = new Map<
string,
Promise<string | undefined>
>();
const slackUsersCache = new Map<string, SlackCacheEntry<SlackUser[]>>();
const pendingSlackUsersFetches = new Map<string, Promise<SlackUser[]>>();
function truncateText(value: string, maxLength = 160): string {
return truncateConnectorText(value, maxLength);
@@ -128,6 +173,58 @@ function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function normalizeSlackLookupName(value: string | undefined): string {
return (value ?? "")
.trim()
.replace(/^@+/, "")
.replace(/\s+/g, " ")
.toLowerCase();
}
function slackTeamCacheKey(teamId: string | undefined): string {
return teamId?.trim() || "default";
}
function slackUserLabelCacheKey(teamId: string, userId: string): string {
return `${slackTeamCacheKey(teamId)}:${userId.trim()}`;
}
function readSlackCache<T>(
cache: Map<string, SlackCacheEntry<T>>,
key: string,
now = Date.now(),
): T | undefined {
const entry = cache.get(key);
if (!entry) {
return undefined;
}
if (entry.expiresAt <= now) {
cache.delete(key);
return undefined;
}
return entry.value;
}
function writeSlackCache<T>(
cache: Map<string, SlackCacheEntry<T>>,
key: string,
value: T,
now = Date.now(),
): void {
cache.set(key, { value, expiresAt: now + SLACK_API_CACHE_TTL_MS });
}
function clearSlackApiCaches(): void {
slackUserLabelCache.clear();
pendingSlackUserLabelFetches.clear();
slackUsersCache.clear();
pendingSlackUsersFetches.clear();
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function normalizeSlackMessageEventChannelType<T>(event: T): T {
const record = asRecord(event);
const channel = readString(record?.channel);
@@ -140,10 +237,51 @@ function normalizeSlackMessageEventChannelType<T>(event: T): T {
} as T;
}
function readRawSlackMessageText(rawMessage: unknown): string | undefined {
const raw = asRecord(rawMessage);
return (
readString(raw?.text) ||
readString(asRecord(raw?.event)?.text) ||
readString(asRecord(raw?.message)?.text)
);
}
function stripLeadingSlackMention(text: string, botUserId?: string): string {
const trimmed = text.trim();
const botId = botUserId?.trim();
if (!botId) {
return trimmed;
}
return trimmed
.replace(new RegExp(`^<@${escapeRegExp(botId)}(?:\\|[^>]+)?>\\s*`, "i"), "")
.trim();
}
function resolveSlackTurnText(input: {
text: string;
raw: unknown;
botUserId?: string;
}): string {
const rawText = readRawSlackMessageText(input.raw);
if (rawText) {
const strippedRaw = stripLeadingSlackMention(rawText, input.botUserId);
if (strippedRaw !== rawText.trim()) {
return strippedRaw;
}
}
return stripLeadingSlackMention(input.text, input.botUserId);
}
function buildSlackParticipantKey(teamId: string, userId: string): string {
return `slack:team:${teamId}:user:${userId}`;
}
function resolveSlackParticipantUserId(
participantKey: string | undefined,
): string | undefined {
return participantKey?.match(/^slack:team:[^:]+:user:([^:]+)$/)?.[1];
}
function resolveSlackParticipant(
rawMessage: unknown,
teamId?: string,
@@ -170,6 +308,122 @@ function resolveSlackParticipant(
};
}
function slackUserDisplayLabel(
user: SlackUser | undefined,
): string | undefined {
if (!user) {
return undefined;
}
const profile = user.profile ?? {};
return (
profile.display_name_normalized?.trim() ||
profile.display_name?.trim() ||
profile.real_name_normalized?.trim() ||
profile.real_name?.trim() ||
user.real_name?.trim() ||
user.name?.trim() ||
user.id?.trim()
);
}
async function fetchSlackUserLabel(input: {
slack: Pick<SlackAdapter, "webClient">;
userId: string;
}): Promise<string | undefined> {
const result = (await input.slack.webClient.users.info({
user: input.userId,
})) as SlackUsersInfoResponse;
if (result.ok === false) {
throw new Error(result.error ?? "Slack users.info returned ok=false");
}
return slackUserDisplayLabel(result.user);
}
async function fetchCachedSlackUserLabel(input: {
slack: Pick<SlackAdapter, "webClient" | "getInstallation" | "withBotToken">;
teamId: string;
userId: string;
}): Promise<string | undefined> {
const key = slackUserLabelCacheKey(input.teamId, input.userId);
const cached = readSlackCache(slackUserLabelCache, key);
if (cached) {
return cached;
}
const pending = pendingSlackUserLabelFetches.get(key);
if (pending) {
return pending;
}
const fetch = withSlackTeamBotToken({
slack: input.slack,
teamId: input.teamId,
work: () =>
fetchSlackUserLabel({
slack: input.slack,
userId: input.userId,
}),
}).then((label) => {
if (label) {
writeSlackCache(slackUserLabelCache, key, label);
}
return label;
});
pendingSlackUserLabelFetches.set(key, fetch);
try {
return await fetch;
} finally {
pendingSlackUserLabelFetches.delete(key);
}
}
async function resolveSlackParticipantLabel(input: {
slack: Pick<SlackAdapter, "webClient" | "getInstallation" | "withBotToken">;
teamId: string;
participant: { key: string; label?: string } | undefined;
currentState: SlackThreadState;
logger?: CliLoggerAdapter;
}): Promise<{ key: string; label?: string } | undefined> {
if (!input.participant) {
return undefined;
}
const userId = resolveSlackParticipantUserId(input.participant.key);
if (!userId) {
return input.participant;
}
const currentLabel =
input.currentState.participantKey === input.participant.key
? input.currentState.participantLabel?.trim()
: undefined;
const rawLabel = input.participant.label?.trim();
const rawLabelMatchesCurrent =
rawLabel &&
currentLabel &&
normalizeSlackLookupName(rawLabel) ===
normalizeSlackLookupName(currentLabel);
if (
currentLabel &&
(!rawLabel || rawLabel === userId || rawLabelMatchesCurrent)
) {
return { ...input.participant, label: currentLabel };
}
try {
const profileLabel = await fetchCachedSlackUserLabel({
slack: input.slack,
teamId: input.teamId,
userId,
});
if (profileLabel) {
return { ...input.participant, label: profileLabel };
}
} catch (error) {
input.logger?.core.log("Slack participant label lookup skipped", {
severity: "warn",
transport: "slack",
error: error instanceof Error ? error.message : String(error),
});
}
return input.participant;
}
function extractSlackTeamId(raw: unknown): string | undefined {
if (!raw || typeof raw !== "object") {
return undefined;
@@ -184,6 +438,237 @@ function extractSlackTeamId(raw: unknown): string | undefined {
return value?.trim() || undefined;
}
function resolveSlackBindingScope(
thread: Pick<Thread<SlackThreadState>, "isDM">,
): ConnectorBindingScope {
return thread.isDM ? "participant" : "thread";
}
function formatSlackRuntimeText(input: {
text: string;
thread: Thread<SlackThreadState>;
state: SlackThreadState;
addressedToBot: boolean;
}): string {
const authorId = resolveSlackParticipantUserId(input.state.participantKey);
return [
"<slack_message_context>",
...(input.state.teamId ? [`teamId: ${input.state.teamId}`] : []),
`threadId: ${input.thread.id}`,
`channelId: ${input.thread.channelId}`,
`isDM: ${input.thread.isDM ? "true" : "false"}`,
...(authorId
? [`authorId: ${authorId}`, `authorMention: <@${authorId}>`]
: []),
...(input.state.participantLabel
? [`authorLabel: ${input.state.participantLabel}`]
: []),
...(input.state.participantKey
? [`participantKey: ${input.state.participantKey}`]
: []),
`isDirectMention: ${input.addressedToBot ? "true" : "false"}`,
"</slack_message_context>",
"",
input.text,
].join("\n");
}
function slackUserNames(user: SlackUser): string[] {
const profile = user.profile ?? {};
const names = [
user.name,
user.real_name,
profile.display_name,
profile.display_name_normalized,
profile.real_name,
profile.real_name_normalized,
];
return [
...new Set(
names
.map((name) => name?.trim())
.filter((name): name is string => Boolean(name)),
),
];
}
function buildSlackMentionNameIndex(
users: SlackUser[],
): Map<string, { ids: Set<string>; names: Set<string> }> {
const index = new Map<string, { ids: Set<string>; names: Set<string> }>();
for (const user of users) {
const id = user.id?.trim();
if (!id || user.deleted) {
continue;
}
for (const name of slackUserNames(user)) {
const normalizedName = normalizeSlackLookupName(name);
if (!normalizedName || /^[UW][A-Z0-9]+$/i.test(normalizedName)) {
continue;
}
const entry = index.get(normalizedName) ?? {
ids: new Set<string>(),
names: new Set<string>(),
};
entry.ids.add(id);
entry.names.add(name);
index.set(normalizedName, entry);
}
}
return index;
}
function pickSlackMentionForName(input: {
index: Map<string, { ids: Set<string>; names: Set<string> }>;
name: string;
preferredUserIds?: string[];
}): string | undefined {
const entry = input.index.get(normalizeSlackLookupName(input.name));
if (!entry) {
return undefined;
}
const ids = [...entry.ids];
if (ids.length === 1) {
return `<@${ids[0]}>`;
}
const preferredIds = new Set(input.preferredUserIds ?? []);
const preferred = ids.filter((id) => preferredIds.has(id));
return preferred.length === 1 ? `<@${preferred[0]}>` : undefined;
}
function hasPotentialSlackOutboundMention(text: string): boolean {
return /(^|[\s([{])@[A-Za-z0-9_.-]/.test(text);
}
function resolveSlackOutboundMentionText(input: {
text: string;
users: SlackUser[];
preferredUserIds?: string[];
}): string {
if (!hasPotentialSlackOutboundMention(input.text)) {
return input.text;
}
const index = buildSlackMentionNameIndex(input.users);
const candidates = [...index.entries()]
.map(([normalizedName, entry]) => ({
normalizedName,
names: [...entry.names].sort((a, b) => b.length - a.length),
}))
.sort((a, b) => b.normalizedName.length - a.normalizedName.length);
let resolved = input.text;
for (const candidate of candidates) {
const mention = pickSlackMentionForName({
index,
name: candidate.normalizedName,
preferredUserIds: input.preferredUserIds,
});
if (!mention) {
continue;
}
const pattern = new RegExp(
`(^|[\\s([{])@(?:${candidate.names.map(escapeRegExp).join("|")})(?=$|[\\s,.;:!?}\\])])`,
"gi",
);
resolved = resolved.replace(pattern, (_full, prefix: string) => {
return `${prefix}${mention}`;
});
}
return resolved;
}
async function fetchSlackUsers(input: {
slack: Pick<SlackAdapter, "webClient">;
}): Promise<SlackUser[]> {
const users: SlackUser[] = [];
let cursor: string | undefined;
do {
const result = (await input.slack.webClient.users.list({
limit: 200,
...(cursor ? { cursor } : {}),
})) as SlackUsersListResponse;
if (result.ok === false) {
throw new Error(result.error ?? "Slack users.list returned ok=false");
}
users.push(...(Array.isArray(result.members) ? result.members : []));
cursor = result.response_metadata?.next_cursor?.trim() || undefined;
} while (cursor);
return users;
}
async function fetchCachedSlackUsers(input: {
slack: Pick<SlackAdapter, "webClient">;
teamId?: string;
}): Promise<SlackUser[]> {
const key = slackTeamCacheKey(input.teamId);
const cached = readSlackCache(slackUsersCache, key);
if (cached) {
return cached;
}
const pending = pendingSlackUsersFetches.get(key);
if (pending) {
return pending;
}
const fetch = fetchSlackUsers({ slack: input.slack }).then((users) => {
writeSlackCache(slackUsersCache, key, users);
return users;
});
pendingSlackUsersFetches.set(key, fetch);
try {
return await fetch;
} finally {
pendingSlackUsersFetches.delete(key);
}
}
async function resolveSlackOutboundMentions(input: {
slack: Pick<SlackAdapter, "webClient">;
text: string;
teamId?: string;
preferredUserIds?: string[];
logger?: CliLoggerAdapter;
}): Promise<string> {
if (!hasPotentialSlackOutboundMention(input.text)) {
return input.text;
}
let users: SlackUser[];
try {
users = await fetchCachedSlackUsers({
slack: input.slack,
teamId: input.teamId,
});
} catch (error) {
input.logger?.core.log("Slack mention resolution skipped", {
severity: "warn",
transport: "slack",
error: error instanceof Error ? error.message : String(error),
});
return input.text;
}
return resolveSlackOutboundMentionText({
text: input.text,
users,
preferredUserIds: input.preferredUserIds,
});
}
async function postSlackResolvedText(input: {
slack: Pick<SlackAdapter, "webClient">;
thread: Thread<SlackThreadState>;
text: string;
teamId?: string;
preferredUserIds?: string[];
logger?: CliLoggerAdapter;
}): Promise<void> {
const resolvedText = await resolveSlackOutboundMentions({
slack: input.slack,
text: input.text,
teamId: input.teamId,
preferredUserIds: input.preferredUserIds,
logger: input.logger,
});
await input.thread.post(resolvedText);
}
async function withSlackBindingBotToken<T>(input: {
slack: Pick<SlackAdapter, "getInstallation" | "withBotToken">;
binding: ConnectorThreadBinding<SlackThreadState>;
@@ -252,24 +737,36 @@ function clearSlackBinding(
}
async function persistSlackThreadContext(input: {
slack: SlackAdapter;
thread: Thread<SlackThreadState>;
bindingsPath: string;
baseStartRequest: ChatStartSessionRequest;
rawMessage: unknown;
errorLabel: string;
logger?: CliLoggerAdapter;
}): Promise<void> {
const teamId = extractSlackTeamId(input.rawMessage);
const participant = resolveSlackParticipant(input.rawMessage, teamId);
let participant = resolveSlackParticipant(input.rawMessage, teamId);
if (!teamId) {
return;
}
const bindingScope = resolveSlackBindingScope(input.thread);
const currentState = await loadThreadState(
input.thread,
input.bindingsPath,
input.baseStartRequest,
bindingScope,
);
participant = await resolveSlackParticipantLabel({
slack: input.slack,
teamId,
participant,
currentState,
logger: input.logger,
});
if (
currentState.teamId === teamId &&
currentState.bindingScope === bindingScope &&
currentState.participantKey === participant?.key &&
currentState.participantLabel === participant?.label
) {
@@ -281,6 +778,7 @@ async function persistSlackThreadContext(input: {
{
...currentState,
teamId: teamId ?? currentState.teamId,
bindingScope,
participantKey: participant?.key ?? currentState.participantKey,
participantLabel: participant?.label ?? currentState.participantLabel,
},
@@ -392,6 +890,7 @@ class SlackConnector extends ConnectorBase<
"--bot-token <token>",
"Slack bot token for single-workspace mode",
)
.option("--token <token>", "Alias for --bot-token")
.option("--signing-secret <secret>", "Slack signing secret")
.option("--client-id <id>", "Slack OAuth client id")
.option("--client-secret <secret>", "Slack OAuth client secret")
@@ -444,6 +943,7 @@ class SlackConnector extends ConnectorBase<
const opts = command.opts<{
userName?: string;
botToken?: string;
token?: string;
signingSecret?: string;
clientId?: string;
clientSecret?: string;
@@ -472,7 +972,10 @@ class SlackConnector extends ConnectorBase<
opts.userName?.trim() ||
process.env.SLACK_BOT_USERNAME?.trim() ||
"cline-slack",
botToken: opts.botToken?.trim() || process.env.SLACK_BOT_TOKEN?.trim(),
botToken:
opts.botToken?.trim() ||
opts.token?.trim() ||
process.env.SLACK_BOT_TOKEN?.trim(),
signingSecret:
opts.signingSecret?.trim() || process.env.SLACK_SIGNING_SECRET?.trim(),
clientId: opts.clientId?.trim() || process.env.SLACK_CLIENT_ID?.trim(),
@@ -717,13 +1220,18 @@ class SlackConnector extends ConnectorBase<
const handleTurn = async (
thread: Thread<SlackThreadState>,
text: string,
addressedToBot: boolean,
) => {
const currentState = await loadThreadState(
thread,
bindingsPath,
startRequest,
resolveSlackBindingScope(thread),
);
const queueKey = currentState.participantKey || thread.id;
const queueKey =
currentState.bindingScope === "thread"
? thread.id
: currentState.participantKey || thread.id;
const runTurn = async () => {
try {
await withSlackTeamBotToken({
@@ -733,6 +1241,12 @@ class SlackConnector extends ConnectorBase<
handleConnectorUserTurn({
thread,
text,
runtimeText: formatSlackRuntimeText({
text,
thread,
state: currentState,
addressedToBot,
}),
client,
pendingApprovals,
baseStartRequest: startRequest,
@@ -743,6 +1257,7 @@ class SlackConnector extends ConnectorBase<
logger: loggerAdapter,
transport: "slack",
botUserName: options.userName,
addressedToBot,
requestStop,
bindingsPath,
hookCommand: options.hookCommand,
@@ -770,6 +1285,23 @@ class SlackConnector extends ConnectorBase<
}),
reusedLogMessage: "Slack thread reusing RPC session",
startedLogMessage: "Slack thread started RPC session",
postFinalReply: async ({
thread: replyThread,
text: replyText,
}) => {
await postSlackResolvedText({
slack,
thread: replyThread,
text: replyText,
teamId: currentState.teamId,
preferredUserIds: [
resolveSlackParticipantUserId(
currentState.participantKey,
),
].filter((id): id is string => Boolean(id)),
logger: loggerAdapter,
});
},
onMessageReceived: async (details) => {
await dispatchConnectorHook(
options.hookCommand,
@@ -842,18 +1374,25 @@ class SlackConnector extends ConnectorBase<
};
bot.onNewMention(async (thread, message) => {
const text = resolveSlackTurnText({
text: message.text,
raw: message.raw,
botUserId: slack.botUserId,
});
await thread.subscribe();
await persistSlackThreadContext({
slack,
thread,
bindingsPath,
baseStartRequest: startRequest,
rawMessage: message.raw,
errorLabel: "Slack",
logger: loggerAdapter,
});
if (
await maybeHandleConnectorApprovalReply({
thread,
text: message.text,
text,
client,
clientId,
pendingApprovals,
@@ -862,21 +1401,28 @@ class SlackConnector extends ConnectorBase<
) {
return;
}
await handleTurn(thread, message.text);
await handleTurn(thread, text, true);
});
bot.onSubscribedMessage(async (thread, message) => {
const text = resolveSlackTurnText({
text: message.text,
raw: message.raw,
botUserId: slack.botUserId,
});
await persistSlackThreadContext({
slack,
thread,
bindingsPath,
baseStartRequest: startRequest,
rawMessage: message.raw,
errorLabel: "Slack",
logger: loggerAdapter,
});
if (
await maybeHandleConnectorApprovalReply({
thread,
text: message.text,
text,
client,
clientId,
pendingApprovals,
@@ -885,7 +1431,7 @@ class SlackConnector extends ConnectorBase<
) {
return;
}
await handleTurn(thread, message.text);
await handleTurn(thread, text, message.isMention === true);
});
bot.onSlashCommand(async (event) => {
@@ -904,13 +1450,15 @@ class SlackConnector extends ConnectorBase<
});
await thread.subscribe();
await persistSlackThreadContext({
slack,
thread,
bindingsPath,
baseStartRequest: startRequest,
rawMessage: event.raw,
errorLabel: "Slack",
logger: loggerAdapter,
});
await handleTurn(thread, commandText);
await handleTurn(thread, commandText, true);
});
await bot.initialize();
@@ -1072,16 +1620,35 @@ class SlackConnector extends ConnectorBase<
export const slackConnector: ConnectCommandDefinition = new SlackConnector();
function parseSlackOptionsForTest(rawArgs: string[]): ConnectSlackOptions {
return (
new SlackConnector() as unknown as {
parseArgs(rawArgs: string[]): ConnectSlackOptions;
}
).parseArgs(rawArgs);
}
export const __test__ = {
parseSlackOptionsForTest,
buildSlackParticipantKey,
resolveSlackParticipantUserId,
resolveSlackParticipant,
resolveSlackParticipantLabel,
formatSlackRuntimeText,
normalizeSlackMessageEventChannelType,
stripLeadingSlackMention,
resolveSlackTurnText,
resolveSlackOutboundMentionText,
resolveSlackOutboundMentions,
postSlackResolvedText,
clearSlackApiCaches,
withSlackTeamBotToken,
isSlackInvalidThreadTsError,
findBindingForThread: (
bindings: ConnectorBindingStore<SlackThreadState>,
thread: Pick<Thread<SlackThreadState>, "id" | "channelId" | "isDM"> & {
participantKey?: string;
bindingScope?: ConnectorBindingScope;
},
) => findBindingForThread(bindings, thread),
};
+332 -1
View File
@@ -1,6 +1,7 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { RUNTIME_SESSION_NOT_FOUND_ERROR_CODE } from "@cline/core";
import type { SentMessage } from "chat";
import { afterEach, describe, expect, it, vi } from "vitest";
import { handleConnectorUserTurn } from "./connector-host";
@@ -43,7 +44,21 @@ function createThread(initialState: TestState = {}, isDM = true) {
state = { ...nextState };
},
async post(message: unknown) {
posts.push(message);
const asyncIterator =
message && typeof message === "object"
? (message as { [Symbol.asyncIterator]?: unknown })[
Symbol.asyncIterator
]
: undefined;
if (typeof asyncIterator === "function") {
let text = "";
for await (const chunk of message as AsyncIterable<string>) {
text += chunk;
}
posts.push(text);
} else {
posts.push(message);
}
const sentMessage = {
edit: async (nextMessage: unknown) => {
posts.push(nextMessage);
@@ -354,6 +369,49 @@ describe("handleConnectorUserTurn", () => {
);
});
it("allows transport-addressed connector slash commands after mention stripping", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts } = createThread(
{
enableTools: true,
autoApproveTools: false,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
participantKey: "slack:team:T123:user:U123",
participantLabel: "U123",
},
false,
);
const runtime = createRuntimeClient("unused");
await handleConnectorUserTurn({
thread: thread as never,
text: "/whereami",
addressedToBot: true,
client: runtime.client as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest({ enableTools: true }) as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "slack",
botUserName: "cline-slack-retest",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Slack",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
});
expect(posts).toEqual([expect.stringContaining("threadId=thread-1")]);
expect(runtime.sendRuntimeSession).not.toHaveBeenCalled();
});
it("denies non-owner connector slash commands", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
@@ -1381,6 +1439,279 @@ describe("handleConnectorUserTurn", () => {
expect(posts.at(-1)).toEqual({ raw: " " });
});
it.each([
{
label: "structured runtime error",
createError: () =>
Object.assign(new Error("session not found: stale-session"), {
code: RUNTIME_SESSION_NOT_FOUND_ERROR_CODE,
}),
},
{
label: "wrapped streaming error",
createError: () => new Error("session not found: stale-session"),
},
])("replaces stale persisted sessions when the runtime no longer has them ($label)", async ({
createError,
}) => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts, getState } = createThread({
sessionId: "stale-session",
enableTools: false,
autoApproveTools: false,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
participantKey: "slack:team:T123:user:U123",
participantLabel: "alice",
});
const startRuntimeSession = vi.fn(async () => ({
sessionId: "fresh-session",
}));
const sendRuntimeSession = vi.fn(async (sessionId: string) => {
if (sessionId === "stale-session") {
throw createError();
}
return {
result: {
text: "fresh reply",
finishReason: "stop",
iterations: 1,
},
};
});
const stopRuntimeSession = vi.fn(async () => ({ applied: true }));
const deleteSession = vi.fn(async () => ({ deleted: true }));
const onReplyFailed = vi.fn(async () => undefined);
await handleConnectorUserTurn({
thread: thread as never,
text: "hello",
client: {
startRuntimeSession,
updateSession: vi.fn(async () => undefined),
sendRuntimeSession,
stopRuntimeSession,
deleteSession,
streamEvents: vi.fn(() => () => undefined),
} as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest() as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "slack",
botUserName: "ClineAdapterBot",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Slack",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
startedLogMessage: "started",
onReplyFailed,
});
expect(sendRuntimeSession).toHaveBeenNthCalledWith(
1,
"stale-session",
expect.anything(),
{ timeoutMs: null },
);
expect(sendRuntimeSession).toHaveBeenNthCalledWith(
2,
"fresh-session",
expect.anything(),
{ timeoutMs: null },
);
expect(startRuntimeSession).toHaveBeenCalledTimes(1);
expect(stopRuntimeSession).toHaveBeenCalledWith("stale-session");
expect(deleteSession).toHaveBeenCalledWith("stale-session", true);
expect(onReplyFailed).not.toHaveBeenCalled();
expect(posts.at(-1)).toBe("fresh reply");
expect(getState().sessionId).toBe("fresh-session");
});
it("reports reply failure only after stale-session recovery retry fails", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread } = createThread({
sessionId: "stale-session",
enableTools: false,
autoApproveTools: false,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
participantKey: "slack:team:T123:user:U123",
participantLabel: "alice",
});
const startRuntimeSession = vi.fn(async () => ({
sessionId: "fresh-session",
}));
const sendRuntimeSession = vi.fn(async (sessionId: string) => {
if (sessionId === "stale-session") {
throw Object.assign(new Error("session not found: stale-session"), {
code: RUNTIME_SESSION_NOT_FOUND_ERROR_CODE,
});
}
throw new Error("retry failed");
});
const stopRuntimeSession = vi.fn(async () => ({ applied: true }));
const deleteSession = vi.fn(async () => ({ deleted: true }));
const onReplyFailed = vi.fn(async () => undefined);
await expect(
handleConnectorUserTurn({
thread: thread as never,
text: "hello",
client: {
startRuntimeSession,
updateSession: vi.fn(async () => undefined),
sendRuntimeSession,
stopRuntimeSession,
deleteSession,
streamEvents: vi.fn(() => () => undefined),
} as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest() as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "slack",
botUserName: "ClineAdapterBot",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Slack",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
startedLogMessage: "started",
onReplyFailed,
}),
).rejects.toThrow("retry failed");
expect(sendRuntimeSession).toHaveBeenNthCalledWith(
1,
"stale-session",
expect.anything(),
{ timeoutMs: null },
);
expect(sendRuntimeSession).toHaveBeenNthCalledWith(
2,
"fresh-session",
expect.anything(),
{ timeoutMs: null },
);
expect(onReplyFailed).toHaveBeenCalledTimes(1);
expect(onReplyFailed).toHaveBeenCalledWith({
sessionId: "fresh-session",
threadId: "thread-1",
error: expect.objectContaining({ message: "retry failed" }),
});
});
it("uses the replacement session for empty-runtime fallback after stale-session recovery", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts, getState } = createThread({
sessionId: "stale-session",
enableTools: true,
autoApproveTools: true,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
participantKey: "discord:user:U123",
participantLabel: "alice",
});
const startRuntimeSession = vi.fn(async () => ({
sessionId: "fresh-session",
}));
const sendRuntimeSession = vi.fn(async (sessionId: string) => {
if (sessionId === "stale-session") {
throw Object.assign(new Error("session not found: stale-session"), {
code: RUNTIME_SESSION_NOT_FOUND_ERROR_CODE,
});
}
return {
result: {
text: "",
finishReason: "stop",
iterations: 1,
},
};
});
const stopRuntimeSession = vi.fn(async () => ({ applied: true }));
const deleteSession = vi.fn(async () => ({ deleted: true }));
const createEmptyRuntimeReplyResolver = vi.fn(
async ({ sessionId }: { sessionId: string }) =>
async () =>
sessionId === "fresh-session" ? "fresh fallback reply" : undefined,
);
await handleConnectorUserTurn({
thread: thread as never,
text: "hello",
client: {
startRuntimeSession,
updateSession: vi.fn(async () => undefined),
sendRuntimeSession,
stopRuntimeSession,
deleteSession,
streamEvents: vi.fn(() => () => undefined),
} as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest({
enableTools: true,
autoApproveTools: true,
}) as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "discord",
botUserName: "ClineAdapterBot",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Discord",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
startedLogMessage: "started",
createEmptyRuntimeReplyResolver: createEmptyRuntimeReplyResolver as never,
});
expect(createEmptyRuntimeReplyResolver).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ sessionId: "stale-session" }),
);
expect(createEmptyRuntimeReplyResolver).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ sessionId: "fresh-session" }),
);
expect(sendRuntimeSession).toHaveBeenNthCalledWith(
1,
"stale-session",
expect.anything(),
{ timeoutMs: null },
);
expect(sendRuntimeSession).toHaveBeenNthCalledWith(
2,
"fresh-session",
expect.anything(),
{ timeoutMs: null },
);
expect(stopRuntimeSession).toHaveBeenCalledWith("stale-session");
expect(deleteSession).toHaveBeenCalledWith("stale-session", true);
expect(posts.at(-1)).toBe("fresh fallback reply");
expect(getState().sessionId).toBe("fresh-session");
});
it("steers active Telegram turns without the hub command timeout", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
+123 -28
View File
@@ -1,10 +1,11 @@
import { readFileSync } from "node:fs";
import { basename } from "node:path";
import type {
ChatRunTurnRequest,
ChatStartSessionRequest,
HubSessionClient,
UserInstructionConfigService,
import {
type ChatRunTurnRequest,
type ChatStartSessionRequest,
type HubSessionClient,
isRuntimeSessionNotFoundError,
type UserInstructionConfigService,
} from "@cline/core";
import type { SentMessage, Thread } from "chat";
import type { CliLoggerAdapter } from "../logging/adapter";
@@ -85,6 +86,21 @@ async function editConnectorText(
return await message.edit(connectorTextPayload(transport, text));
}
function isConnectorRuntimeSessionMissingError(
error: unknown,
sessionId: string,
): boolean {
if (isRuntimeSessionNotFoundError(error, sessionId)) {
return true;
}
// Some chat delivery adapters consume async iterables internally and rethrow
// plain Errors, which drops the structured HubCommandError code.
return (
error instanceof Error &&
error.message === `session not found: ${sessionId}`
);
}
function buildAttachments(input: {
userImages: string[];
userFiles: string[];
@@ -214,6 +230,7 @@ export async function handleConnectorUserTurn<
logger: CliLoggerAdapter;
transport: string;
botUserName?: string;
addressedToBot?: boolean;
ownerParticipantKeys?: string[];
requestStop: (reason: string) => void;
bindingsPath: string;
@@ -363,11 +380,10 @@ export async function handleConnectorUserTurn<
input.botUserName,
);
const isConnectorCommand = commandName !== undefined;
if (
isConnectorCommand &&
!input.thread.isDM &&
!isConnectorCommandAddressedToThisBot(resolvedInput, input.botUserName)
) {
const commandAddressedToBot =
input.addressedToBot ||
isConnectorCommandAddressedToThisBot(resolvedInput, input.botUserName);
if (isConnectorCommand && !input.thread.isDM && !commandAddressedToBot) {
input.logger.core.log("Unaddressed connector chat command ignored", {
transport: input.transport,
threadId: input.thread.id,
@@ -494,7 +510,7 @@ export async function handleConnectorUserTurn<
await maybeHandleChatCommand(resolvedInput, {
enabled: true,
botUserName: input.botUserName,
requireBotMention: !input.thread.isDM,
requireBotMention: !input.thread.isDM && !commandAddressedToBot,
host: input.chatCommandHost,
getState: async () => {
const current = await loadThreadState(
@@ -966,13 +982,10 @@ export async function handleConnectorUserTurn<
prompt,
attachments: buildAttachments({ userImages, userFiles }),
};
const resolveFallbackText = await input.createEmptyRuntimeReplyResolver?.({
client: input.client,
sessionId,
});
let activeSessionId = sessionId;
input.activeTurns?.set(turnKey, {
sessionId,
sessionId: activeSessionId,
threadId: input.thread.id,
participantKey: currentState.participantKey,
});
@@ -983,14 +996,31 @@ export async function handleConnectorUserTurn<
await input.postFinalReply?.({ thread: input.thread, text });
}
: undefined;
try {
const notifyReplyFailed = async (
sessionId: string,
error: unknown,
): Promise<void> => {
await input.onReplyFailed?.({
sessionId,
threadId: input.thread.id,
error: error instanceof Error ? error : new Error(String(error)),
});
};
const postRuntimeReply = async (
targetSessionId: string,
targetRequest: ChatRunTurnRequest,
) => {
const resolveFallbackText = await input.createEmptyRuntimeReplyResolver?.({
client: input.client,
sessionId: targetSessionId,
});
await postConnectorRuntimeReply(
input.thread,
input.transport,
createConnectorRuntimeTurnStream({
client: input.client,
sessionId,
request,
sessionId: targetSessionId,
request: targetRequest,
clientId: input.clientId,
logger: input.logger,
transport: input.transport,
@@ -1020,24 +1050,89 @@ export async function handleConnectorUserTurn<
},
onCompleted: async (result) => {
await input.onReplyCompleted?.({
sessionId,
sessionId: targetSessionId,
threadId: input.thread.id,
text: result.text,
finishReason: result.finishReason,
iterations: result.iterations,
});
},
onFailed: async (error) => {
await input.onReplyFailed?.({
sessionId,
threadId: input.thread.id,
error,
});
},
}),
postFinalReply,
resolveFallbackText,
);
};
try {
try {
await postRuntimeReply(activeSessionId, request);
} catch (error) {
const staleSessionId = currentState.sessionId?.trim();
if (
activeSessionId !== staleSessionId ||
!isConnectorRuntimeSessionMissingError(error, activeSessionId)
) {
await notifyReplyFailed(activeSessionId, error);
throw error;
}
input.logger.core.log(
"Connector runtime session missing; starting replacement session",
{
severity: "warn",
transport: input.transport,
threadId: input.thread.id,
sessionId: activeSessionId,
},
);
try {
await clearSession({
thread: input.thread,
client: input.client,
bindingsPath: input.bindingsPath,
baseStartRequest: input.baseStartRequest,
errorLabel: input.errorLabel,
});
const retryState = await loadThreadState(
input.thread,
input.bindingsPath,
input.baseStartRequest,
);
const retryStartRequest = buildThreadStartRequest(
input.baseStartRequest,
applyForcedToolDisable(retryState, input.forceDisableTools),
);
activeSessionId = await getOrCreateSessionId({
thread: input.thread,
client: input.client,
startRequest: retryStartRequest,
logger: input.logger,
clientId: input.clientId,
transport: input.transport,
bindingsPath: input.bindingsPath,
errorLabel: input.errorLabel,
hookCommand: input.hookCommand,
hookBotUserName: input.botUserName,
sessionMetadata: input.getSessionMetadata(
input.thread,
input.clientId,
retryState,
),
reusedLogMessage: input.reusedLogMessage,
startedLogMessage: input.startedLogMessage,
});
input.activeTurns?.set(turnKey, {
sessionId: activeSessionId,
threadId: input.thread.id,
participantKey: retryState.participantKey,
});
await postRuntimeReply(activeSessionId, {
...request,
config: retryStartRequest,
});
} catch (retryError) {
await notifyReplyFailed(activeSessionId, retryError);
throw retryError;
}
}
} finally {
input.pendingApprovals.delete(input.thread.id);
input.activeTurns?.delete(turnKey);
@@ -1051,7 +1146,7 @@ export async function handleConnectorUserTurn<
input.bindingsPath,
{
...currentState,
sessionId,
sessionId: activeSessionId,
},
input.errorLabel,
);
+64 -6
View File
@@ -1,6 +1,8 @@
import type { SerializedThread, Thread } from "chat";
import { readJsonFile, writeJsonFile } from "./common";
export type ConnectorBindingScope = "thread" | "participant";
export type ConnectorThreadState = {
sessionId?: string;
enableTools?: boolean;
@@ -8,6 +10,7 @@ export type ConnectorThreadState = {
cwd?: string;
workspaceRoot?: string;
systemPrompt?: string;
bindingScope?: ConnectorBindingScope;
participantKey?: string;
participantLabel?: string;
welcomeSentAt?: string;
@@ -44,6 +47,7 @@ export type ConnectorBindingThreadIdentity = Pick<
"id" | "channelId" | "isDM"
> & {
participantKey?: string;
bindingScope?: ConnectorBindingScope;
};
export type ConnectorMuteTarget = {
@@ -58,6 +62,12 @@ function normalizeParticipantKey(
return trimmed ? trimmed : undefined;
}
function normalizeBindingScope(
value: ConnectorBindingScope | undefined,
): ConnectorBindingScope | undefined {
return value === "thread" || value === "participant" ? value : undefined;
}
function readSerializedThreadIdentity(
serializedThread: string | undefined,
): Partial<ConnectorBindingThreadIdentity> | undefined {
@@ -136,6 +146,12 @@ export function resolveThreadBindingKey(
thread: ConnectorBindingThreadIdentity,
state?: ConnectorThreadState | null,
): string {
if (
normalizeBindingScope(state?.bindingScope ?? thread.bindingScope) ===
"thread"
) {
return thread.id;
}
return (
normalizeParticipantKey(state?.participantKey ?? thread.participantKey) ??
thread.id
@@ -161,6 +177,12 @@ export function findBindingForThread<TState extends ConnectorThreadState>(
thread: ConnectorBindingThreadIdentity,
): { binding: ConnectorThreadBinding<TState>; key: string } | undefined {
const participantKey = normalizeParticipantKey(thread.participantKey);
if (normalizeBindingScope(thread.bindingScope) === "thread") {
const exact = bindings[thread.id];
return exact && !isControlBinding(exact)
? { key: thread.id, binding: exact }
: undefined;
}
if (participantKey) {
const exactThread = bindings[thread.id];
const exactThreadParticipantKey = normalizeParticipantKey(
@@ -213,6 +235,7 @@ export function readBindingForThread<TState extends ConnectorThreadState>(
thread: Thread<TState>,
errorLabel: string,
participantKey?: string,
bindingScope?: ConnectorBindingScope,
): ConnectorThreadBinding<TState> | undefined {
const bindings = readBindings<TState>(path);
const threadIdentity: ConnectorBindingThreadIdentity = {
@@ -220,34 +243,45 @@ export function readBindingForThread<TState extends ConnectorThreadState>(
channelId: thread.channelId,
isDM: thread.isDM,
participantKey,
bindingScope,
};
const match = findBindingForThread(bindings, threadIdentity);
if (!match) {
return undefined;
}
const targetKey = resolveThreadBindingKey(
threadIdentity,
match.binding.state,
);
const targetKey = resolveThreadBindingKey(threadIdentity, {
...(match.binding.state ?? {}),
...(bindingScope ? { bindingScope } : {}),
});
const normalizedParticipantKey = normalizeParticipantKey(participantKey);
const normalizedBindingScope = normalizeBindingScope(bindingScope);
const storedThread = readSerializedThreadIdentity(
match.binding.serializedThread,
);
const storedParticipantKey = normalizeParticipantKey(
match.binding.participantKey ?? match.binding.state?.participantKey,
);
const storedBindingScope = normalizeBindingScope(
match.binding.state?.bindingScope,
);
const needsRefresh =
match.key !== targetKey ||
storedThread?.id !== thread.id ||
storedThread?.channelId !== thread.channelId ||
storedThread?.isDM !== thread.isDM ||
storedParticipantKey !== normalizedParticipantKey;
storedParticipantKey !== normalizedParticipantKey ||
(normalizedBindingScope !== undefined &&
storedBindingScope !== normalizedBindingScope);
if (needsRefresh) {
bindings[targetKey] = {
...match.binding,
channelId: thread.channelId,
isDM: thread.isDM,
participantKey: normalizedParticipantKey ?? match.binding.participantKey,
state: {
...(match.binding.state ?? ({} as TState)),
...(bindingScope ? { bindingScope } : {}),
},
serializedThread: serializeThread(thread, errorLabel),
updatedAt: new Date().toISOString(),
};
@@ -278,6 +312,7 @@ export function persistThreadBinding<TState extends ConnectorThreadState>(
): void {
const bindings = readBindings<TState>(path);
const participantKey = normalizeParticipantKey(state.participantKey);
const bindingScope = normalizeBindingScope(state.bindingScope);
const bindingKey = resolveThreadBindingKey(
thread as ConnectorBindingThreadIdentity,
state,
@@ -289,6 +324,13 @@ export function persistThreadBinding<TState extends ConnectorThreadState>(
const bindingParticipantKey = normalizeParticipantKey(
binding.participantKey ?? binding.state?.participantKey,
);
const storedThread = readSerializedThreadIdentity(binding.serializedThread);
if (bindingScope === "thread") {
if (key !== bindingKey && storedThread?.id === thread.id) {
delete bindings[key];
}
continue;
}
const matchesParticipant =
participantKey && bindingParticipantKey === participantKey;
const matchesLegacyKey = participantKey && key === thread.id;
@@ -476,6 +518,10 @@ export function mergeThreadState<TState extends ConnectorThreadState>(
threadState?.systemPrompt ||
bindingState?.systemPrompt ||
base.systemPrompt,
bindingScope:
threadState?.bindingScope ||
bindingState?.bindingScope ||
base.bindingScope,
participantKey:
threadState?.participantKey ||
bindingState?.participantKey ||
@@ -495,15 +541,27 @@ export async function loadThreadState<TState extends ConnectorThreadState>(
thread: Thread<TState>,
bindingsPath: string,
base: ConnectorThreadState,
bindingScope?: ConnectorBindingScope,
): Promise<TState> {
const threadState = await thread.state;
const resolvedBindingScope = threadState?.bindingScope ?? bindingScope;
const binding = readBindingForThread<TState>(
bindingsPath,
thread,
"Connector",
threadState?.participantKey,
resolvedBindingScope,
);
return mergeThreadState(
resolvedBindingScope
? ({
...(threadState ?? {}),
bindingScope: resolvedBindingScope,
} as TState)
: threadState,
binding?.state,
base,
);
return mergeThreadState(threadState, binding?.state, base);
}
export function findBindingForParticipantKey<
+117 -11
View File
@@ -3,6 +3,7 @@ title: "Connectors"
sidebarTitle: "Connectors"
description: "Connect the CLI to Telegram, Slack, Discord, Google Chat, WhatsApp, etc."
---
<Warning>
This feature currently only applies to Cline CLI.
</Warning>
@@ -19,14 +20,14 @@ cline connect
## Supported Platforms
| Platform | Direct Command | Required Credentials |
|----------|---------------|---------------------|
| Telegram | `cline connect telegram` | Bot token |
| Slack | `cline connect slack` | Bot token, signing secret, base URL |
| Discord | `cline connect discord` | Application ID, bot token, public key, base URL |
| Google Chat | `cline connect gchat` | Service account credentials JSON, base URL |
| WhatsApp | `cline connect whatsapp` | Phone number ID, access token, app secret, verify token, base URL |
| Linear | `cline connect linear` | API key, webhook signing secret, base URL |
| Platform | Direct Command | Required Credentials |
| ----------- | ------------------------- | ---------------------------------------------------------------------- |
| Telegram | `cline connect telegram` | Bot token |
| Slack | `cline connect slack` | Bot token, signing secret, base URL |
| Discord | `cline connect discord` | Application ID, bot token, public key, base URL |
| Google Chat | `cline connect gchat` | Service account credentials JSON, base URL |
| WhatsApp | `cline connect whatsapp` | Phone number ID, access token, app secret, verify token, base URL |
| Linear | `cline connect linear` | API key, webhook signing secret, base URL |
## Telegram
@@ -75,13 +76,118 @@ The `--hook-command` receives each incoming message with sender info via stdin.
## Slack
Requires a bot token, signing secret, and public base URL.
Slack runs in webhook mode. You need a Slack app, a bot token, a signing secret, and a public HTTPS URL that can forward requests to your local connector.
<Steps>
<Step title="Create or configure a Slack app">
Go to [api.slack.com/apps](https://api.slack.com/apps), create a Slack app, then open **OAuth & Permissions** and add these bot token scopes:
- `chat:write`
- `app_mentions:read`
- `channels:history`
- `channels:read`
- `im:history`
- `im:read`
- `im:write`
- `users:read`
Install the app to your workspace and copy the **Bot User OAuth Token** (`xoxb-...`). Copy the **Signing Secret** from **Basic Information**.
</Step>
<Step title="Expose your local connector">
For local development, use a tunnel such as ngrok:
```bash
ngrok http 8787
```
Use the HTTPS forwarding URL as your public base URL. For example, if ngrok prints `https://abc123.ngrok-free.app`, your base URL is:
```text
https://abc123.ngrok-free.app
```
</Step>
<Step title="Start the Slack connector">
Pass the public base URL without the `/api/webhooks/slack` path:
```bash
cline connect slack \
--token <BOT-TOKEN> \
--signing-secret <SECRET> \
--base-url https://abc123.ngrok-free.app \
--cwd /path/to/repo \
--enable-tools
```
`--enable-tools` lets the Slack agent inspect files, edit code, and run commands. Omit it for a chat-only connector.
</Step>
<Step title="Configure Event Subscriptions">
In your Slack app, open **Event Subscriptions** and turn events on. Set **Request URL** to the connector webhook path:
```text
https://abc123.ngrok-free.app/api/webhooks/slack
```
Slack should show the request URL as verified. Under **Subscribe to bot events**, add:
- `app_mention` with `app_mentions:read`
- `message.im` with `im:history`
- `message.channels` with `channels:history`
Save changes. If Slack asks you to reinstall the app after changing scopes, reinstall it and copy the new bot token if it changes.
</Step>
<Step title="Invite and test the bot">
Invite the app to the Slack channel where you want to use it. Start a conversation by mentioning the bot:
```text
@Cline /whereami
```
The bot should reply in a thread with the Slack thread ID, working directory, and tool state. Continue replying in that same thread to keep the same agent session and conversation context.
</Step>
</Steps>
### Slack Usage
Each Slack thread maps to an agent session. In channels, start with an app mention such as `@Cline run the tests`. After the bot replies in a thread, continue in that thread; you do not need to mention the bot on every reply. In DMs, send a normal message to the app.
When you ask the agent to mention someone, use the person's Slack display name or username, such as `@Jane Doe` or `@jane`. The connector resolves unique names to Slack user mentions before sending the reply.
Connector commands are sent as chat messages, for example `@Cline /help` in a channel thread or `/help` in a DM. Common commands:
- `/help` or `/start` - show connector help
- `/new` or `/clear` - start a fresh session
- `/whereami` - show thread, cwd, tools, and yolo state
- `/tools on|off|toggle` - allow or block repo/file/shell tools
- `/yolo on|off|toggle` - auto-approve tool use
- `/cwd <path>` - change working directory
- `/schedule create|list|trigger|delete` - manage scheduled workflows for this thread
- `/abort` - stop the current task
- `/exit` - stop the connector
### Slack Troubleshooting
- If Slack cannot verify the request URL, make sure the connector is running and the public URL is reachable. `curl https://abc123.ngrok-free.app/health` should return `ok`.
- If the bot does not reply in a channel, make sure the app is invited to the channel and the `app_mention`, `message.channels`, and `message.im` bot events are saved.
- If you use ngrok or another temporary tunnel, update Slack Event Subscriptions whenever the public URL changes.
- `--base-url` is only the public origin, such as `https://abc123.ngrok-free.app`; the Slack app Request URL is that origin plus `/api/webhooks/slack`.
### Slack Security
By default, anyone who can message the app in Slack can ask it to run tasks on your machine. Use `--hook-command` to restrict access. This example allows only one Slack workspace/user pair:
```bash
cline connect slack --token <BOT-TOKEN> --signing-secret <SECRET> --base-url <URL>
cline connect slack \
--token <BOT-TOKEN> \
--signing-secret <SECRET> \
--base-url <URL> \
--hook-command 'jq -r ".payload.actor.participantKey" | grep -q "slack:team:T01ABC123:user:U01ABC123" && echo "{\"action\":\"allow\"}" || echo "{\"action\":\"deny\",\"message\":\"unauthorized\"}"'
```
Each Slack thread maps to an agent session, so the agent maintains conversation context within a thread.
Send `/whereami` from Slack to see the `participantKey` for the current user.
## Discord
@@ -6,6 +6,10 @@ import type {
} from "@cline/shared";
import { parseHookEventPayload } from "../../../hooks";
import type { SendSessionInput } from "../../../runtime/host/runtime-host";
import {
isRuntimeSessionNotFoundError,
RUNTIME_SESSION_NOT_FOUND_ERROR_CODE,
} from "../../../runtime/session-errors";
import { logHubMessage } from "../hub-server-logging";
import { cancelPendingApprovals } from "./approval-handlers";
import { cancelPendingCapabilityRequests } from "./capability-handlers";
@@ -232,6 +236,13 @@ export async function handleSessionInput(
sessionId,
),
);
if (isRuntimeSessionNotFoundError(error, sessionId)) {
return errorReply(
envelope,
RUNTIME_SESSION_NOT_FOUND_ERROR_CODE,
error instanceof Error ? error.message : String(error),
);
}
throw error;
}
if (result) {
+5
View File
@@ -401,6 +401,11 @@ export {
isRuleEnabled,
mergeRulesForSystemPrompt,
} from "./runtime/safety/rules";
export {
isRuntimeSessionNotFoundError,
RUNTIME_SESSION_NOT_FOUND_ERROR_CODE,
RuntimeSessionNotFoundError,
} from "./runtime/session-errors";
export {
type SandboxCallOptions,
SubprocessSandbox,
@@ -81,6 +81,7 @@ import {
} from "../orchestration/runtime-oauth-token-manager";
import type { RuntimeBuilder } from "../orchestration/session-runtime";
import { SessionRuntime } from "../orchestration/session-runtime-orchestrator";
import { RuntimeSessionNotFoundError } from "../session-errors";
import { PendingPromptsController } from "../turn-queue/pending-prompt-service";
import { manifestToSessionRecord } from "./history";
import { AgentEventBridge } from "./local/agent-event-bridge";
@@ -1573,7 +1574,7 @@ export class LocalRuntimeHost implements RuntimeHost {
private getSessionOrThrow(sessionId: string): ActiveSession {
const session = this.sessions.get(sessionId);
if (!session) {
const error = new Error(`session not found: ${sessionId}`);
const error = new RuntimeSessionNotFoundError(sessionId);
captureSdkError(this.defaultTelemetry, {
component: "core",
operation: "session.active_lookup",
@@ -0,0 +1,28 @@
export const RUNTIME_SESSION_NOT_FOUND_ERROR_CODE = "session_not_found";
export class RuntimeSessionNotFoundError extends Error {
readonly code = RUNTIME_SESSION_NOT_FOUND_ERROR_CODE;
constructor(readonly sessionId: string) {
super(`session not found: ${sessionId}`);
this.name = "RuntimeSessionNotFoundError";
}
}
export function isRuntimeSessionNotFoundError(
error: unknown,
sessionId?: string,
): boolean {
if (!error || typeof error !== "object") {
return false;
}
const record = error as { code?: unknown; sessionId?: unknown };
if (record.code !== RUNTIME_SESSION_NOT_FOUND_ERROR_CODE) {
return false;
}
return (
!sessionId ||
typeof record.sessionId !== "string" ||
record.sessionId === sessionId
);
}