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:
Bee
2026-06-03 12:00:57 -07:00
committed by GitHub
parent 76af785fa6
commit 423fde4828
14 changed files with 450 additions and 101 deletions
+5 -1
View File
@@ -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
+3
View File
@@ -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
+59 -1
View File
@@ -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(
{
+134 -60
View File
@@ -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,
+1 -1
View File
@@ -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",
+7 -1
View File
@@ -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"],
+29 -11
View File
@@ -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);
}
+25 -1
View File
@@ -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"]);
});
});
+46 -8
View File
@@ -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: {
+20 -2
View File
@@ -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;
+9 -1
View File
@@ -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
View File
@@ -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;
};