mirror of
https://github.com/cline/cline.git
synced 2026-09-17 17:45:33 +08:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8660e6a2ce | ||
|
|
091cbb9a58 | ||
|
|
65f683d5e6 | ||
|
|
5bc4455862 | ||
|
|
ecd51562c2 | ||
|
|
df4cb1fac9 | ||
|
|
a5532236e3 | ||
|
|
c3844fa076 | ||
|
|
16c3011d96 | ||
|
|
9e3c50412c | ||
|
|
87d287b127 |
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user