mirror of
https://github.com/cline/cline.git
synced 2026-08-30 17:20:20 +08:00
feat(cli): add Slack socket mode support (#11245)
* feat(cli): add Slack socket mode support Add socket mode as an alternative to webhook mode for Slack connector, allowing connections without a public URL. - Introduce `--connection` flag to select webhook or socket mode - Add `--app-token` option for socket mode authentication - Make signing secret and base URL conditional on webhook mode - Add `parseSlackConnectionMode` with validation and tests - Update CLI platform definition to support hybrid connection type - Update README docs with socket mode usage examples * use base-url and remove connection flag * isSocketMode
This commit is contained in:
@@ -212,8 +212,12 @@ cline schedule create "PR summary" \
|
||||
Chat with your agent from any messaging platform: Telegram, Slack, Discord, Google Chat, WhatsApp, and Linear. Each conversation thread maps to an agent session with full context. Set up access control to restrict who can interact with your agent.
|
||||
|
||||
```bash
|
||||
# Connect to Telegram
|
||||
cline connect telegram -k $BOT_TOKEN
|
||||
cline connect slack --token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
|
||||
# Connect to Slack through webhook
|
||||
cline connect slack --bot-token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
|
||||
# Connect to Slack using socket mode
|
||||
cline connect slack --bot-token $SLACK_TOKEN --app-token $SLACK_APP_TOKEN
|
||||
```
|
||||
|
||||
## Headless CLI for CI/CD
|
||||
|
||||
@@ -174,6 +174,9 @@ cline connect telegram -k 123456:ABCDEF...
|
||||
# Slack (webhook mode)
|
||||
cline connect slack --bot-token $SLACK_BOT_TOKEN --signing-secret $SLACK_SIGNING_SECRET --base-url https://your-domain.com
|
||||
|
||||
# Slack (socket mode)
|
||||
cline connect slack --bot-token $SLACK_BOT_TOKEN --app-token $SLACK_APP_TOKEN
|
||||
|
||||
# Google Chat (webhook mode)
|
||||
cline connect gchat --base-url https://your-domain.com
|
||||
|
||||
|
||||
@@ -1,9 +1,67 @@
|
||||
import type { ConnectSlackOptions } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __test__ } from "./slack";
|
||||
import { __test__, slackConnector } from "./slack";
|
||||
|
||||
const parseSlackArgs = (rawArgs: string[]): ConnectSlackOptions =>
|
||||
(
|
||||
slackConnector as unknown as {
|
||||
parseArgs(rawArgs: string[]): ConnectSlackOptions;
|
||||
}
|
||||
).parseArgs(rawArgs);
|
||||
|
||||
describe("slack binding lookup", () => {
|
||||
const participantKey = __test__.buildSlackParticipantKey("T123", "U123");
|
||||
|
||||
it("infers Slack webhook mode from a base URL", () => {
|
||||
expect(__test__.inferSlackConnectionMode("https://example.test")).toBe(
|
||||
"webhook",
|
||||
);
|
||||
expect(__test__.inferSlackConnectionMode(" ")).toBe("socket");
|
||||
expect(__test__.inferSlackConnectionMode(undefined)).toBe("socket");
|
||||
});
|
||||
|
||||
it("uses webhook mode when Slack args include a base URL", () => {
|
||||
const options = parseSlackArgs([
|
||||
"--bot-token",
|
||||
"xoxb-token",
|
||||
"--signing-secret",
|
||||
"secret",
|
||||
"--app-token",
|
||||
"xapp-ignored",
|
||||
"--base-url",
|
||||
"https://example.test",
|
||||
]);
|
||||
|
||||
expect(options.connectionMode).toBe("webhook");
|
||||
expect(options.baseUrl).toBe("https://example.test");
|
||||
expect(options.signingSecret).toBe("secret");
|
||||
expect(options.appToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses socket mode when Slack args omit a base URL", () => {
|
||||
const previousBaseUrl = process.env.BASE_URL;
|
||||
delete process.env.BASE_URL;
|
||||
let options: ConnectSlackOptions;
|
||||
try {
|
||||
options = parseSlackArgs([
|
||||
"--bot-token",
|
||||
"xoxb-token",
|
||||
"--app-token",
|
||||
"xapp-token",
|
||||
]);
|
||||
} finally {
|
||||
if (previousBaseUrl === undefined) {
|
||||
delete process.env.BASE_URL;
|
||||
} else {
|
||||
process.env.BASE_URL = previousBaseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
expect(options.connectionMode).toBe("socket");
|
||||
expect(options.baseUrl).toBeUndefined();
|
||||
expect(options.appToken).toBe("xapp-token");
|
||||
});
|
||||
|
||||
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
|
||||
@@ -79,6 +79,14 @@ type SlackThreadState = ConnectorThreadState & {
|
||||
teamId?: string;
|
||||
};
|
||||
|
||||
type SlackConnectionMode = ConnectSlackOptions["connectionMode"];
|
||||
|
||||
function inferSlackConnectionMode(
|
||||
baseUrl: string | undefined,
|
||||
): SlackConnectionMode {
|
||||
return baseUrl?.trim() ? "webhook" : "socket";
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxLength = 160): string {
|
||||
return truncateConnectorText(value, maxLength);
|
||||
}
|
||||
@@ -380,7 +388,10 @@ class SlackConnector extends ConnectorBase<
|
||||
SlackConnectorState
|
||||
> {
|
||||
constructor() {
|
||||
super("slack", "Slack webhook bridge backed by RPC runtime sessions");
|
||||
super(
|
||||
"slack",
|
||||
"Slack webhook/socket bridge backed by RPC runtime sessions",
|
||||
);
|
||||
}
|
||||
|
||||
protected override createCommand(): Command {
|
||||
@@ -393,6 +404,7 @@ class SlackConnector extends ConnectorBase<
|
||||
"Slack bot token for single-workspace mode",
|
||||
)
|
||||
.option("--signing-secret <secret>", "Slack signing secret")
|
||||
.option("--app-token <token>", "Slack app-level token for socket mode")
|
||||
.option("--client-id <id>", "Slack OAuth client id")
|
||||
.option("--client-secret <secret>", "Slack OAuth client secret")
|
||||
.option(
|
||||
@@ -433,6 +445,7 @@ class SlackConnector extends ConnectorBase<
|
||||
"Environment:",
|
||||
" SLACK_BOT_TOKEN Single-workspace bot token",
|
||||
" SLACK_SIGNING_SECRET Slack signing secret",
|
||||
" SLACK_APP_TOKEN App-level token for socket mode",
|
||||
" SLACK_CLIENT_ID OAuth client id",
|
||||
" SLACK_CLIENT_SECRET OAuth client secret",
|
||||
" SLACK_ENCRYPTION_KEY Optional installation encryption key",
|
||||
@@ -445,6 +458,7 @@ class SlackConnector extends ConnectorBase<
|
||||
userName?: string;
|
||||
botToken?: string;
|
||||
signingSecret?: string;
|
||||
appToken?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
encryptionKey?: string;
|
||||
@@ -467,17 +481,50 @@ class SlackConnector extends ConnectorBase<
|
||||
this.parseOptionalInteger(opts.port, "port") ??
|
||||
Number.parseInt(process.env.PORT ?? "8787", 10);
|
||||
const port = Number.isFinite(parsedPort) ? parsedPort : 8787;
|
||||
const baseUrl = opts.baseUrl?.trim() || process.env.BASE_URL?.trim();
|
||||
const connectionMode = inferSlackConnectionMode(baseUrl);
|
||||
const isSocketMode = connectionMode === "socket";
|
||||
if (isSocketMode && (opts.clientId?.trim() || opts.clientSecret?.trim())) {
|
||||
throw new Error(
|
||||
"Slack socket mode does not support --client-id or --client-secret",
|
||||
);
|
||||
}
|
||||
const botToken =
|
||||
opts.botToken?.trim() || process.env.SLACK_BOT_TOKEN?.trim();
|
||||
const appToken = isSocketMode
|
||||
? opts.appToken?.trim() || process.env.SLACK_APP_TOKEN?.trim()
|
||||
: undefined;
|
||||
if (isSocketMode && !appToken) {
|
||||
throw new Error(
|
||||
"Slack socket mode requires --app-token or SLACK_APP_TOKEN",
|
||||
);
|
||||
}
|
||||
if (isSocketMode && !botToken) {
|
||||
throw new Error(
|
||||
"Slack socket mode requires --bot-token or SLACK_BOT_TOKEN",
|
||||
);
|
||||
}
|
||||
return {
|
||||
userName:
|
||||
opts.userName?.trim() ||
|
||||
process.env.SLACK_BOT_USERNAME?.trim() ||
|
||||
"cline-slack",
|
||||
botToken: opts.botToken?.trim() || process.env.SLACK_BOT_TOKEN?.trim(),
|
||||
connectionMode,
|
||||
botToken,
|
||||
signingSecret:
|
||||
opts.signingSecret?.trim() || process.env.SLACK_SIGNING_SECRET?.trim(),
|
||||
clientId: opts.clientId?.trim() || process.env.SLACK_CLIENT_ID?.trim(),
|
||||
connectionMode === "webhook"
|
||||
? opts.signingSecret?.trim() ||
|
||||
process.env.SLACK_SIGNING_SECRET?.trim()
|
||||
: opts.signingSecret?.trim(),
|
||||
appToken,
|
||||
clientId:
|
||||
connectionMode === "webhook"
|
||||
? opts.clientId?.trim() || process.env.SLACK_CLIENT_ID?.trim()
|
||||
: undefined,
|
||||
clientSecret:
|
||||
opts.clientSecret?.trim() || process.env.SLACK_CLIENT_SECRET?.trim(),
|
||||
connectionMode === "webhook"
|
||||
? opts.clientSecret?.trim() || process.env.SLACK_CLIENT_SECRET?.trim()
|
||||
: undefined,
|
||||
encryptionKey:
|
||||
opts.encryptionKey?.trim() || process.env.SLACK_ENCRYPTION_KEY?.trim(),
|
||||
installationKeyPrefix:
|
||||
@@ -500,10 +547,7 @@ class SlackConnector extends ConnectorBase<
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND?.trim(),
|
||||
port,
|
||||
host: opts.host?.trim() || process.env.HOST?.trim() || "0.0.0.0",
|
||||
baseUrl:
|
||||
opts.baseUrl?.trim() ||
|
||||
process.env.BASE_URL?.trim() ||
|
||||
`http://127.0.0.1:${port}`,
|
||||
baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -599,9 +643,11 @@ class SlackConnector extends ConnectorBase<
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
state.connectionMode === "socket"
|
||||
? `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} mode=socket`
|
||||
: `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[slack] starting background connector pid=${pid} user=${options.userName}`,
|
||||
`[slack] starting background connector pid=${pid} user=${options.userName} mode=${options.connectionMode}`,
|
||||
foregroundHint:
|
||||
"[slack] use `cline connect slack -i ...` to run in the foreground",
|
||||
launchFailureMessage: "failed to launch Slack connector in background",
|
||||
@@ -618,6 +664,7 @@ class SlackConnector extends ConnectorBase<
|
||||
const consoleLogger = new ConsoleLogger("info", "slack-connect");
|
||||
const slackConfig: Record<string, unknown> = {
|
||||
logger: consoleLogger,
|
||||
mode: options.connectionMode,
|
||||
userName: options.userName,
|
||||
};
|
||||
if (options.botToken?.trim()) {
|
||||
@@ -626,6 +673,9 @@ class SlackConnector extends ConnectorBase<
|
||||
if (options.signingSecret?.trim()) {
|
||||
slackConfig.signingSecret = options.signingSecret.trim();
|
||||
}
|
||||
if (options.appToken?.trim()) {
|
||||
slackConfig.appToken = options.appToken.trim();
|
||||
}
|
||||
if (options.clientId?.trim()) {
|
||||
slackConfig.clientId = options.clientId.trim();
|
||||
}
|
||||
@@ -694,10 +744,12 @@ class SlackConnector extends ConnectorBase<
|
||||
await client.connect();
|
||||
this.writeConnectorState(statePath, {
|
||||
userName: options.userName,
|
||||
connectionMode: options.connectionMode,
|
||||
pid: process.pid,
|
||||
rpcAddress,
|
||||
port: options.port,
|
||||
baseUrl: options.baseUrl,
|
||||
...(options.connectionMode === "webhook"
|
||||
? { port: options.port, baseUrl: options.baseUrl }
|
||||
: {}),
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -948,48 +1000,64 @@ class SlackConnector extends ConnectorBase<
|
||||
},
|
||||
});
|
||||
|
||||
const webhookUrl = `${options.baseUrl.replace(/\/$/, "")}/api/webhooks/slack`;
|
||||
const oauthCallbackUrl = `${options.baseUrl.replace(/\/$/, "")}/api/oauth/slack/callback`;
|
||||
const server = await startConnectorWebhookServer({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
routes: {
|
||||
"/api/webhooks/slack": async (request) => bot.webhooks.slack(request),
|
||||
"/api/oauth/slack/callback": async (request) => {
|
||||
try {
|
||||
const result = await slack.handleOAuthCallback(request);
|
||||
return new Response(
|
||||
`Slack installation stored for team ${result.teamId}. You can return to Slack.`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
loggerAdapter.core.log("Slack OAuth callback failed", {
|
||||
severity: "warn",
|
||||
transport: "slack",
|
||||
error: message,
|
||||
let webhookUrl: string | undefined;
|
||||
let oauthCallbackUrl: string | undefined;
|
||||
const server =
|
||||
options.connectionMode === "webhook"
|
||||
? await (async () => {
|
||||
const baseUrl = options.baseUrl?.trim();
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
"Slack webhook mode requires --base-url or BASE_URL",
|
||||
);
|
||||
}
|
||||
webhookUrl = `${baseUrl.replace(/\/$/, "")}/api/webhooks/slack`;
|
||||
oauthCallbackUrl = `${baseUrl.replace(/\/$/, "")}/api/oauth/slack/callback`;
|
||||
return startConnectorWebhookServer({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
routes: {
|
||||
"/api/webhooks/slack": async (request) =>
|
||||
bot.webhooks.slack(request),
|
||||
"/api/oauth/slack/callback": async (request) => {
|
||||
try {
|
||||
const result = await slack.handleOAuthCallback(request);
|
||||
return new Response(
|
||||
`Slack installation stored for team ${result.teamId}. You can return to Slack.`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
loggerAdapter.core.log("Slack OAuth callback failed", {
|
||||
severity: "warn",
|
||||
transport: "slack",
|
||||
error: message,
|
||||
});
|
||||
return new Response(`Slack OAuth error: ${message}`, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
},
|
||||
"/health": () => new Response("ok"),
|
||||
"/": () =>
|
||||
new Response(
|
||||
[
|
||||
"Slack connector is running.",
|
||||
"Connection mode: webhook",
|
||||
`Webhook URL: ${webhookUrl}`,
|
||||
`OAuth callback URL: ${oauthCallbackUrl}`,
|
||||
options.botToken?.trim()
|
||||
? "Auth mode: single workspace"
|
||||
: options.clientId?.trim() &&
|
||||
options.clientSecret?.trim()
|
||||
? "Auth mode: multi-workspace OAuth"
|
||||
: "Auth mode: incomplete (set bot token or OAuth credentials)",
|
||||
].join("\n"),
|
||||
),
|
||||
},
|
||||
});
|
||||
return new Response(`Slack OAuth error: ${message}`, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
},
|
||||
"/health": () => new Response("ok"),
|
||||
"/": () =>
|
||||
new Response(
|
||||
[
|
||||
"Slack connector is running.",
|
||||
`Webhook URL: ${webhookUrl}`,
|
||||
`OAuth callback URL: ${oauthCallbackUrl}`,
|
||||
options.botToken?.trim()
|
||||
? "Auth mode: single workspace"
|
||||
: options.clientId?.trim() && options.clientSecret?.trim()
|
||||
? "Auth mode: multi-workspace OAuth"
|
||||
: "Auth mode: incomplete (set bot token or OAuth credentials)",
|
||||
].join("\n"),
|
||||
),
|
||||
},
|
||||
});
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
const stopEventStream = client.streamEvents(
|
||||
{ clientId: `${clientId}-server-events` },
|
||||
@@ -1052,17 +1120,22 @@ class SlackConnector extends ConnectorBase<
|
||||
process.once("SIGINT", () => requestStop("sigint"));
|
||||
process.once("SIGTERM", () => requestStop("sigterm"));
|
||||
|
||||
io.writeln(`[slack] listening on ${options.host}:${options.port}`);
|
||||
io.writeln(`[slack] configure Slack webhook URL: ${webhookUrl}`);
|
||||
io.writeln(
|
||||
`[slack] configure Slack OAuth callback URL: ${oauthCallbackUrl}`,
|
||||
);
|
||||
if (options.connectionMode === "webhook") {
|
||||
io.writeln(`[slack] listening on ${options.host}:${options.port}`);
|
||||
io.writeln(`[slack] configure Slack webhook URL: ${webhookUrl}`);
|
||||
io.writeln(
|
||||
`[slack] configure Slack OAuth callback URL: ${oauthCallbackUrl}`,
|
||||
);
|
||||
} else {
|
||||
io.writeln("[slack] socket mode connected");
|
||||
}
|
||||
|
||||
await stopPromise;
|
||||
clearBindingSessionIds<SlackThreadState>(bindingsPath);
|
||||
stopTaskUpdateStream();
|
||||
stopEventStream();
|
||||
await server.close();
|
||||
await server?.close();
|
||||
await bot.shutdown();
|
||||
userInstructionService.stop();
|
||||
client.close();
|
||||
this.removeStateFile(statePath);
|
||||
@@ -1073,6 +1146,7 @@ class SlackConnector extends ConnectorBase<
|
||||
export const slackConnector: ConnectCommandDefinition = new SlackConnector();
|
||||
|
||||
export const __test__ = {
|
||||
inferSlackConnectionMode,
|
||||
buildSlackParticipantKey,
|
||||
resolveSlackParticipant,
|
||||
normalizeSlackMessageEventChannelType,
|
||||
|
||||
@@ -19,7 +19,7 @@ export const CONNECTOR_CATALOG: ConnectorCatalogEntry[] = [
|
||||
},
|
||||
{
|
||||
name: "slack",
|
||||
description: "Slack webhook bridge backed by RPC runtime sessions",
|
||||
description: "Slack webhook/socket bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "telegram",
|
||||
|
||||
@@ -26,6 +26,7 @@ export type ActiveConnectorRecord = {
|
||||
phoneNumberId?: string;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
connectionMode?: string;
|
||||
};
|
||||
|
||||
function listConnectorStatePaths(
|
||||
@@ -68,6 +69,8 @@ const connectorFieldExtractors: Record<
|
||||
startedAt: (p) => (typeof p.startedAt === "string" ? p.startedAt : undefined),
|
||||
port: (p) => (typeof p.port === "number" ? p.port : undefined),
|
||||
baseUrl: (p) => (typeof p.baseUrl === "string" ? p.baseUrl : undefined),
|
||||
connectionMode: (p) =>
|
||||
typeof p.connectionMode === "string" ? p.connectionMode : undefined,
|
||||
userName: (p) => (typeof p.userName === "string" ? p.userName : undefined),
|
||||
botUsername: (p) =>
|
||||
typeof p.botUsername === "string" ? p.botUsername : undefined,
|
||||
@@ -91,7 +94,10 @@ const connectorConfigs: Record<
|
||||
required: ["userName"],
|
||||
optional: ["startedAt", "port", "baseUrl"],
|
||||
},
|
||||
slack: { required: ["userName"], optional: ["startedAt", "port", "baseUrl"] },
|
||||
slack: {
|
||||
required: ["userName"],
|
||||
optional: ["startedAt", "connectionMode", "port", "baseUrl"],
|
||||
},
|
||||
whatsapp: {
|
||||
required: ["userName"],
|
||||
optional: ["startedAt", "phoneNumberId", "port", "baseUrl"],
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import { runConnectAdapter } from "../../commands/connect";
|
||||
import { PLATFORMS, type PlatformDef, type SecurityDef } from "./platforms";
|
||||
import {
|
||||
PLATFORMS,
|
||||
type PlatformDef,
|
||||
type SecurityDef,
|
||||
shouldIncludeField,
|
||||
} from "./platforms";
|
||||
|
||||
function isCancel(value: unknown): value is symbol {
|
||||
return p.isCancel(value);
|
||||
@@ -10,6 +15,7 @@ const SENSITIVE_FLAGS = new Set([
|
||||
"-k",
|
||||
"--access-token",
|
||||
"--api-key",
|
||||
"--app-token",
|
||||
"--app-secret",
|
||||
"--bot-token",
|
||||
"--credentials-json",
|
||||
@@ -33,28 +39,40 @@ function redactCommandArgs(args: string[]): string {
|
||||
|
||||
async function collectFields(platform: PlatformDef): Promise<string[] | null> {
|
||||
const args: string[] = [];
|
||||
const values: Record<string, string> = {};
|
||||
|
||||
for (const field of platform.fields) {
|
||||
if (!shouldIncludeField(field, values)) {
|
||||
continue;
|
||||
}
|
||||
if (field.help) {
|
||||
for (const line of field.help) {
|
||||
p.log.info(line);
|
||||
}
|
||||
}
|
||||
|
||||
const value = await p.text({
|
||||
message: field.label,
|
||||
placeholder: field.placeholder,
|
||||
validate: field.required
|
||||
? (v) => {
|
||||
if (!v?.trim()) return `${field.label} is required`;
|
||||
return undefined;
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const value = field.options
|
||||
? await p.select({
|
||||
message: field.label,
|
||||
options: field.options,
|
||||
initialValue: field.initialValue,
|
||||
})
|
||||
: await p.text({
|
||||
message: field.label,
|
||||
placeholder: field.placeholder,
|
||||
defaultValue: field.initialValue,
|
||||
validate: field.required
|
||||
? (v) => {
|
||||
if (!v?.trim()) return `${field.label} is required`;
|
||||
return undefined;
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (isCancel(value)) return null;
|
||||
|
||||
const trimmed = (value as string).trim();
|
||||
values[field.flag] = trimmed;
|
||||
if (trimmed) {
|
||||
args.push(field.flag, trimmed);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PLATFORMS } from "./platforms";
|
||||
import { PLATFORMS, shouldIncludeField } from "./platforms";
|
||||
|
||||
describe("connect wizard platform security fields", () => {
|
||||
it("does not ask Telegram users to re-enter the bot username", () => {
|
||||
@@ -30,4 +30,28 @@ describe("connect wizard platform security fields", () => {
|
||||
expect(slackUser?.validate?.("U01ABC123")).toBeUndefined();
|
||||
expect(slackUser?.validate?.("U01$(bad)")).toContain("Slack member");
|
||||
});
|
||||
|
||||
it("asks Slack users for mode-specific setup fields", () => {
|
||||
const slack = PLATFORMS.find((platform) => platform.id === "slack");
|
||||
const fields = slack?.fields ?? [];
|
||||
const webhookValues = { "--base-url": "https://example.test" };
|
||||
const socketValues = { "--base-url": "" };
|
||||
|
||||
expect(fields.map((field) => field.flag)).toEqual([
|
||||
"--bot-token",
|
||||
"--base-url",
|
||||
"--signing-secret",
|
||||
"--app-token",
|
||||
]);
|
||||
expect(
|
||||
fields
|
||||
.filter((field) => shouldIncludeField(field, webhookValues))
|
||||
.map((field) => field.flag),
|
||||
).toEqual(["--bot-token", "--base-url", "--signing-secret"]);
|
||||
expect(
|
||||
fields
|
||||
.filter((field) => shouldIncludeField(field, socketValues))
|
||||
.map((field) => field.flag),
|
||||
).toEqual(["--bot-token", "--base-url", "--app-token"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export interface PlatformDef {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook";
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
hint: string;
|
||||
fields: FieldDef[];
|
||||
security?: SecurityDef;
|
||||
@@ -13,8 +13,17 @@ export interface FieldDef {
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: FieldCondition;
|
||||
}
|
||||
|
||||
export type FieldCondition = {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
|
||||
export interface SecurityFieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -30,6 +39,24 @@ export interface SecurityDef {
|
||||
buildHookCommand: (values: Record<string, string>) => string;
|
||||
}
|
||||
|
||||
export function shouldIncludeField(
|
||||
field: FieldDef,
|
||||
values: Record<string, string>,
|
||||
): boolean {
|
||||
const condition = field.includeWhen;
|
||||
if (!condition) {
|
||||
return true;
|
||||
}
|
||||
const value = values[condition.flag] ?? "";
|
||||
if (condition.equals !== undefined && value !== condition.equals) {
|
||||
return false;
|
||||
}
|
||||
if (condition.notEquals !== undefined && value === condition.notEquals) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateTelegramUserId(value: string): string | undefined {
|
||||
return /^\d+$/.test(value)
|
||||
? undefined
|
||||
@@ -91,8 +118,8 @@ export const PLATFORMS: PlatformDef[] = [
|
||||
{
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
type: "webhook",
|
||||
hint: "Requires a Slack app and public URL.",
|
||||
type: "hybrid",
|
||||
hint: "Public URL for webhook mode; leave blank for socket mode.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--bot-token",
|
||||
@@ -105,21 +132,32 @@ export const PLATFORMS: PlatformDef[] = [
|
||||
"Install to workspace and copy the Bot Token",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "leave blank for socket mode",
|
||||
help: [
|
||||
"Enter a publicly accessible URL for webhook mode",
|
||||
"Leave blank to use Slack socket mode instead",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--signing-secret",
|
||||
label: "Signing secret",
|
||||
required: true,
|
||||
help: ["Found in your app's Basic Information page"],
|
||||
includeWhen: { flag: "--base-url", notEquals: "" },
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "https://example.com",
|
||||
flag: "--app-token",
|
||||
label: "App-level token",
|
||||
placeholder: "xapp-...",
|
||||
required: true,
|
||||
help: [
|
||||
"Your publicly accessible URL for webhook callbacks",
|
||||
"Use ngrok or similar for local development",
|
||||
"Enable Socket Mode in the Slack app",
|
||||
"Generate an app-level token with the connections:write scope",
|
||||
],
|
||||
includeWhen: { flag: "--base-url", equals: "" },
|
||||
},
|
||||
],
|
||||
security: {
|
||||
|
||||
@@ -2,7 +2,10 @@ import { spawn } from "node:child_process";
|
||||
import process from "node:process";
|
||||
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
|
||||
import { listActiveConnectors } from "../../../cli/src/connectors/status";
|
||||
import { PLATFORMS } from "../../../cli/src/wizards/connect/platforms";
|
||||
import {
|
||||
PLATFORMS,
|
||||
shouldIncludeField,
|
||||
} from "../../../cli/src/wizards/connect/platforms";
|
||||
import type {
|
||||
WebviewConnectorChannel,
|
||||
WebviewConnectorChannelsResponse,
|
||||
@@ -27,6 +30,9 @@ export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
|
||||
placeholder: field.placeholder,
|
||||
required: field.required,
|
||||
help: field.help,
|
||||
initialValue: field.initialValue,
|
||||
options: field.options,
|
||||
includeWhen: field.includeWhen,
|
||||
})),
|
||||
security: platform.security
|
||||
? {
|
||||
@@ -104,9 +110,21 @@ function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
|
||||
throw new Error(`connector channel is not available: ${channel}`);
|
||||
}
|
||||
const values = asRecord(args?.values) ?? {};
|
||||
const fieldValues: Record<string, string> = {};
|
||||
for (const field of platform.fields) {
|
||||
const rawValue = values[field.flag];
|
||||
if (typeof rawValue === "string") {
|
||||
fieldValues[field.flag] = rawValue.trim();
|
||||
} else if (field.initialValue) {
|
||||
fieldValues[field.flag] = field.initialValue;
|
||||
}
|
||||
}
|
||||
const cliArgs = [channel];
|
||||
for (const field of platform.fields) {
|
||||
const value = asString(values[field.flag]);
|
||||
if (!shouldIncludeField(field, fieldValues)) {
|
||||
continue;
|
||||
}
|
||||
const value = fieldValues[field.flag];
|
||||
if (!value) {
|
||||
if (field.required) throw new Error(`${field.label} is required`);
|
||||
continue;
|
||||
|
||||
@@ -134,6 +134,13 @@ export type WebviewConnectorField = {
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type WebviewConnectorSecurityField = {
|
||||
@@ -147,7 +154,7 @@ export type WebviewConnectorSecurityField = {
|
||||
export type WebviewConnectorChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook";
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
hint: string;
|
||||
fields: WebviewConnectorField[];
|
||||
security?: {
|
||||
@@ -168,6 +175,7 @@ export type WebviewActiveConnector = {
|
||||
phoneNumberId?: string;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
connectionMode?: string;
|
||||
};
|
||||
|
||||
export type WebviewConnectorChannelsResponse = {
|
||||
|
||||
@@ -42,6 +42,13 @@ type ConnectorField = {
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ConnectorSecurityField = {
|
||||
@@ -55,7 +62,7 @@ type ConnectorSecurityField = {
|
||||
type ConnectorChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook";
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
hint: string;
|
||||
fields: ConnectorField[];
|
||||
security?: {
|
||||
@@ -76,6 +83,7 @@ type ActiveConnector = {
|
||||
phoneNumberId?: string;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
connectionMode?: string;
|
||||
};
|
||||
|
||||
type ConnectorChannelsResponse = {
|
||||
@@ -142,10 +150,41 @@ function isMultilineField(field: ConnectorField): boolean {
|
||||
return label.includes("json") || field.flag.includes("credentials");
|
||||
}
|
||||
|
||||
function shouldIncludeField(
|
||||
field: ConnectorField,
|
||||
values: Record<string, string>,
|
||||
): boolean {
|
||||
const condition = field.includeWhen;
|
||||
if (!condition) {
|
||||
return true;
|
||||
}
|
||||
const value = values[condition.flag] ?? "";
|
||||
if (condition.equals !== undefined && value !== condition.equals) {
|
||||
return false;
|
||||
}
|
||||
if (condition.notEquals !== undefined && value === condition.notEquals) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function initialValuesForChannel(
|
||||
channel?: ConnectorChannel,
|
||||
): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
for (const field of channel?.fields ?? []) {
|
||||
if (field.initialValue) {
|
||||
values[field.flag] = field.initialValue;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function createFormState(channels: ConnectorChannel[]): ConnectorFormState {
|
||||
const channel = channels[0];
|
||||
return {
|
||||
channelId: channels[0]?.id ?? "",
|
||||
values: {},
|
||||
channelId: channel?.id ?? "",
|
||||
values: initialValuesForChannel(channel),
|
||||
securityEnabled: false,
|
||||
securityValues: {},
|
||||
};
|
||||
@@ -175,6 +214,15 @@ export function ChannelsContent() {
|
||||
() => channels.find((channel) => channel.id === formState.channelId),
|
||||
[channels, formState.channelId],
|
||||
);
|
||||
const visibleFields = useMemo(() => {
|
||||
const values = {
|
||||
...initialValuesForChannel(selectedChannel),
|
||||
...formState.values,
|
||||
};
|
||||
return (selectedChannel?.fields ?? []).filter((field) =>
|
||||
shouldIncludeField(field, values),
|
||||
);
|
||||
}, [selectedChannel, formState.values]);
|
||||
|
||||
const applyResponse = useCallback((response: ConnectorChannelsResponse) => {
|
||||
setChannels(response.available);
|
||||
@@ -233,6 +281,9 @@ export function ChannelsContent() {
|
||||
return;
|
||||
}
|
||||
for (const field of selectedChannel.fields) {
|
||||
if (!visibleFields.includes(field)) {
|
||||
continue;
|
||||
}
|
||||
if (field.required && !formState.values[field.flag]?.trim()) {
|
||||
setFormError(`${field.label} is required`);
|
||||
return;
|
||||
@@ -376,6 +427,11 @@ export function ChannelsContent() {
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
{formatDateTime(connector.startedAt)}
|
||||
</span>
|
||||
{connector.connectionMode ? (
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
{connector.connectionMode}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -413,7 +469,9 @@ export function ChannelsContent() {
|
||||
}
|
||||
setFormState({
|
||||
channelId: value,
|
||||
values: {},
|
||||
values: initialValuesForChannel(
|
||||
channels.find((channel) => channel.id === value),
|
||||
),
|
||||
securityEnabled: false,
|
||||
securityValues: {},
|
||||
});
|
||||
@@ -433,7 +491,7 @@ export function ChannelsContent() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedChannel?.fields.map((field) => (
|
||||
{visibleFields.map((field) => (
|
||||
<div className="grid gap-2" key={field.flag}>
|
||||
<Label>
|
||||
{field.label}
|
||||
@@ -441,7 +499,29 @@ export function ChannelsContent() {
|
||||
<span className="text-destructive"> *</span>
|
||||
) : null}
|
||||
</Label>
|
||||
{isMultilineField(field) ? (
|
||||
{field.options ? (
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
updateFieldValue(field.flag, value);
|
||||
}
|
||||
}}
|
||||
value={
|
||||
formState.values[field.flag] ?? field.initialValue ?? ""
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={field.placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : isMultilineField(field) ? (
|
||||
<Textarea
|
||||
onChange={(event) =>
|
||||
updateFieldValue(field.flag, event.target.value)
|
||||
|
||||
+20
-5
@@ -22,7 +22,7 @@ cline connect
|
||||
| Platform | Direct Command | Required Credentials |
|
||||
|----------|---------------|---------------------|
|
||||
| Telegram | `cline connect telegram` | Bot token |
|
||||
| Slack | `cline connect slack` | Bot token, signing secret, base URL |
|
||||
| Slack | `cline connect slack` | Bot token plus webhook signing secret/base URL or socket app token |
|
||||
| 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 |
|
||||
@@ -75,13 +75,28 @@ The `--hook-command` receives each incoming message with sender info via stdin.
|
||||
|
||||
## Slack
|
||||
|
||||
Requires a bot token, signing secret, and public base URL.
|
||||
Slack supports webhook mode and socket mode. Each Slack thread maps to an agent session, so the agent maintains conversation context within a thread.
|
||||
|
||||
Webhook mode requires a bot token, signing secret, and public base URL:
|
||||
|
||||
```bash
|
||||
cline connect slack --token <BOT-TOKEN> --signing-secret <SECRET> --base-url <URL>
|
||||
cline connect slack \
|
||||
--bot-token <BOT-TOKEN> \
|
||||
--signing-secret <SECRET> \
|
||||
--base-url <URL>
|
||||
```
|
||||
|
||||
Each Slack thread maps to an agent session, so the agent maintains conversation context within a thread.
|
||||
Configure the Slack app's event subscription and interactivity request URLs to `<URL>/api/webhooks/slack`.
|
||||
|
||||
Socket mode requires a bot token and an app-level token with the `connections:write` scope:
|
||||
|
||||
```bash
|
||||
cline connect slack \
|
||||
--bot-token <BOT-TOKEN> \
|
||||
--app-token <APP-LEVEL-TOKEN>
|
||||
```
|
||||
|
||||
Enable Socket Mode in the Slack app. Socket mode does not need a public request URL and is single-workspace only.
|
||||
|
||||
## Discord
|
||||
|
||||
@@ -257,7 +272,7 @@ Multiple connectors can run simultaneously. They all share the same hub:
|
||||
cline connect telegram -k $TELEGRAM_TOKEN
|
||||
|
||||
# Terminal 2
|
||||
cline connect slack --token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
|
||||
cline connect slack --bot-token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
|
||||
```
|
||||
|
||||
Connectors require the hub. Start it with `cline hub start` if it doesn't auto-start.
|
||||
|
||||
@@ -58,8 +58,10 @@ export type TelegramConnectorState = {
|
||||
|
||||
export type ConnectSlackOptions = {
|
||||
userName: string;
|
||||
connectionMode: "webhook" | "socket";
|
||||
botToken?: string;
|
||||
signingSecret?: string;
|
||||
appToken?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
encryptionKey?: string;
|
||||
@@ -77,15 +79,16 @@ export type ConnectSlackOptions = {
|
||||
hookCommand?: string;
|
||||
port: number;
|
||||
host: string;
|
||||
baseUrl: string;
|
||||
baseUrl?: string;
|
||||
};
|
||||
|
||||
export type SlackConnectorState = {
|
||||
userName: string;
|
||||
connectionMode?: "webhook" | "socket";
|
||||
pid: number;
|
||||
rpcAddress: string;
|
||||
port: number;
|
||||
baseUrl: string;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
startedAt: string;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user