From 00265d7028eebadbd1dbd1d610431e79200b475c Mon Sep 17 00:00:00 2001 From: rabbitblood Date: Fri, 24 Apr 2026 11:51:15 -0700 Subject: [PATCH] feat(channels): first-class Lark/Feishu support via schema-driven config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lark adapter was already implemented in Go (lark.go — outbound Custom Bot webhook + inbound Event Subscriptions with constant-time token verify), but the Canvas connect-form hardcoded a Telegram-shaped pair of inputs (bot_token + chat_id). Selecting "Lark / Feishu" from the dropdown silently sent the wrong field names — there was no way to enter a webhook URL. Fix: move form shape to the server. - Add `ConfigField` struct + `ConfigSchema()` method to the `ChannelAdapter` interface. Each adapter declares its own fields with label/type/required/sensitive/placeholder/help. - Implement per-adapter schemas: - Lark: webhook_url (required+sensitive) + verify_token (optional+sensitive) - Slack: bot_token/channel_id/webhook_url/username/icon_emoji - Discord: webhook_url + optional public_key - Telegram: bot_token + chat_id (unchanged UX, keeps Detect Chats) - Change `ListAdapters()` to return `[]AdapterInfo` with config_schema inline. Sorted deterministically by display name so UI ordering is stable across Go's random map iteration. - Update the 3 existing `ListAdapters` test sites to struct access. Canvas (`ChannelsTab.tsx`): - Replace the two hardcoded bot_token/chat_id inputs with a single schema-driven `SchemaField` component. Renders one input per field in the order the adapter returns them. - Form state becomes `formValues: Record` keyed by `ConfigField.key`. Values reset on platform-switch so stale Telegram credentials can't leak into a new Lark channel. - "Detect Chats" stays but only renders for platforms in `SUPPORTS_DETECT_CHATS` (Telegram only — the only provider with getUpdates). - Only schema-known keys are posted in `config`, scrubbing any stale values from previous platform selections. Regression tests: - `TestLark_ConfigSchema` locks in the 2-field Lark contract with the required/sensitive flags correctly set. - `TestListAdapters_IncludesLark` confirms registry wiring + schema survives round-trip through ListAdapters. Known pre-existing `TestStripPluginMarkers_AwkScript` failure in internal/handlers is unrelated to this change (verified via stash+test on clean staging). Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/tabs/ChannelsTab.tsx | 273 ++++++++++++------ workspace-server/internal/channels/adapter.go | 35 +++ .../internal/channels/channels_test.go | 15 +- workspace-server/internal/channels/discord.go | 26 ++ .../internal/channels/discord_test.go | 6 +- workspace-server/internal/channels/lark.go | 27 ++ .../internal/channels/lark_test.go | 57 ++++ .../internal/channels/registry.go | 29 +- workspace-server/internal/channels/slack.go | 51 ++++ .../internal/channels/telegram.go | 25 ++ 10 files changed, 435 insertions(+), 109 deletions(-) diff --git a/canvas/src/components/tabs/ChannelsTab.tsx b/canvas/src/components/tabs/ChannelsTab.tsx index b7e93ea4..fc5c09de 100644 --- a/canvas/src/components/tabs/ChannelsTab.tsx +++ b/canvas/src/components/tabs/ChannelsTab.tsx @@ -4,9 +4,23 @@ import { useState, useEffect, useCallback, useId } from "react"; import { api } from "@/lib/api"; import { ConfirmDialog } from "@/components/ConfirmDialog"; +// ConfigField mirrors the Go struct returned by GET /channels/adapters — +// the UI renders one input per field in the order the adapter returns +// them, so per-platform form shape stays server-owned. +interface ConfigField { + key: string; + label: string; + type: "text" | "password" | "textarea"; + required: boolean; + sensitive?: boolean; + placeholder?: string; + help?: string; +} + interface ChannelAdapter { type: string; display_name: string; + config_schema?: ConfigField[]; } interface Channel { @@ -25,6 +39,11 @@ interface Props { workspaceId: string; } +// Telegram is the only platform that supports "Detect Chats" via +// getUpdates. Every other platform uses a webhook URL that already +// encodes the chat, so the button is only offered when useful. +const SUPPORTS_DETECT_CHATS = new Set(["telegram"]); + function relativeTime(iso: string | null | undefined): string { if (!iso) return "never"; const diff = Date.now() - new Date(iso).getTime(); @@ -41,11 +60,12 @@ export function ChannelsTab({ workspaceId }: Props) { const [showForm, setShowForm] = useState(false); const [testing, setTesting] = useState(null); const [pendingDelete, setPendingDelete] = useState(null); + const [error, setError] = useState(""); - // Form state + // Form state — schema-driven: formValues holds the typed-in config for + // whichever adapter is currently selected, keyed by ConfigField.key. const [formType, setFormType] = useState("telegram"); - const [formBotToken, setFormBotToken] = useState(""); - const [formChatId, setFormChatId] = useState(""); + const [formValues, setFormValues] = useState>({}); const [formAllowedUsers, setFormAllowedUsers] = useState(""); const [formError, setFormError] = useState(""); const [discovering, setDiscovering] = useState(false); @@ -53,18 +73,13 @@ export function ChannelsTab({ workspaceId }: Props) { const [selectedChats, setSelectedChats] = useState>(new Set()); const [showManualInput, setShowManualInput] = useState(false); - // Stable IDs for label↔input associations (WCAG 1.3.1) const platformId = useId(); - const botTokenId = useId(); - const chatIdId = useId(); const allowedUsersId = useId(); + const currentAdapter = adapters.find((a) => a.type === formType); + const currentSchema: ConfigField[] = currentAdapter?.config_schema || []; + const load = useCallback(async () => { - // Fetch channels and adapters independently so a failure in one - // doesn't blank the other. Previously a single Promise.all + silent - // catch meant ANY request failing left both `channels` and - // `adapters` empty — the user saw a "+ Connect" button with no - // platform options, with no clue why. const [chResult, adResult] = await Promise.allSettled([ api.get(`/workspaces/${workspaceId}/channels`), api.get(`/channels/adapters`), @@ -82,8 +97,6 @@ export function ChannelsTab({ workspaceId }: Props) { console.warn("ChannelsTab: adapters load failed", adResult.reason); errors.push("platforms"); } - // Surface BOTH failure modes so the user can distinguish - // "no channels configured" from "API unreachable". if (errors.length > 0) { setError(`Failed to load ${errors.join(" and ")} — try refreshing`); } else { @@ -100,8 +113,24 @@ export function ChannelsTab({ workspaceId }: Props) { return () => clearInterval(interval); }, [load]); + // Reset form values when the selected platform changes — each platform + // has a different field set, so reusing old values would leak stale + // data across platforms. + useEffect(() => { + setFormValues({}); + setDiscoveredChats([]); + setSelectedChats(new Set()); + setShowManualInput(false); + setFormError(""); + }, [formType]); + + const setFieldValue = (key: string, value: string) => { + setFormValues((prev) => ({ ...prev, [key]: value })); + }; + const handleDiscover = async () => { - if (!formBotToken) { + const botToken = formValues["bot_token"] || ""; + if (!botToken) { setFormError("Enter a bot token first"); return; } @@ -111,16 +140,15 @@ export function ChannelsTab({ workspaceId }: Props) { try { const res = await api.post<{ chats: { chat_id: string; name: string; type: string }[]; hint: string }>( `/channels/discover`, - { channel_type: formType, bot_token: formBotToken, workspace_id: workspaceId } + { channel_type: formType, bot_token: botToken, workspace_id: workspaceId } ); const chats = res.chats || []; setDiscoveredChats(chats); if (chats.length === 0) { setFormError("No chats found. For groups: add the bot and send a message. For DMs: send /start to the bot first. Then retry."); } else { - // Auto-select all discovered chats setSelectedChats(new Set(chats.map((c) => c.chat_id))); - setFormChatId(chats.map((c) => c.chat_id).join(", ")); + setFieldValue("chat_id", chats.map((c) => c.chat_id).join(", ")); } } catch (e) { setFormError(String(e)); @@ -134,15 +162,22 @@ export function ChannelsTab({ workspaceId }: Props) { const next = new Set(prev); if (next.has(chatId)) next.delete(chatId); else next.add(chatId); - setFormChatId(Array.from(next).join(", ")); + setFieldValue("chat_id", Array.from(next).join(", ")); return next; }); }; const handleCreate = async () => { setFormError(""); - if (!formBotToken || !formChatId) { - setFormError("Bot token and chat ID are required"); + // Client-side required-field check so the user sees the gap before + // we round-trip to the server. ValidateConfig on the backend remains + // authoritative — adapter-specific rules like "bot_token OR webhook_url" + // for Slack aren't expressible in required-flag alone. + const missing = currentSchema + .filter((f) => f.required && !(formValues[f.key] || "").trim()) + .map((f) => f.label); + if (missing.length > 0) { + setFormError(`Required: ${missing.join(", ")}`); return; } try { @@ -150,14 +185,20 @@ export function ChannelsTab({ workspaceId }: Props) { .split(",") .map((s) => s.trim()) .filter(Boolean); + // Only send keys the schema knows about — avoids accidentally + // persisting stale values when the user switched platforms mid-edit. + const config: Record = {}; + for (const f of currentSchema) { + const v = (formValues[f.key] || "").trim(); + if (v) config[f.key] = v; + } await api.post(`/workspaces/${workspaceId}/channels`, { channel_type: formType, - config: { bot_token: formBotToken, chat_id: formChatId }, + config, allowed_users: allowed, }); setShowForm(false); - setFormBotToken(""); - setFormChatId(""); + setFormValues({}); setFormAllowedUsers(""); load(); } catch (e) { @@ -165,8 +206,6 @@ export function ChannelsTab({ workspaceId }: Props) { } }; - const [error, setError] = useState(""); - const handleToggle = async (ch: Channel) => { try { await api.patch(`/workspaces/${workspaceId}/channels/${ch.id}`, { @@ -228,7 +267,7 @@ export function ChannelsTab({ workspaceId }: Props) { )} - {/* Create form */} + {/* Create form — schema-driven */} {showForm && (
@@ -244,73 +283,69 @@ export function ChannelsTab({ workspaceId }: Props) { ))}
-
- - setFormBotToken(e.target.value)} - placeholder="123456:ABC-DEF..." - className="w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300 placeholder-zinc-600" - /> -
-
-
- - + + {/* Render one input per schema field. Fallback path: if the + backend didn't return a schema (older platform version) show + a single bot_token + chat_id pair to preserve the old UX. */} + {currentSchema.length === 0 ? ( +
+ Platform exposes no config schema — upgrade the platform to pick up first-class support.
- {discoveredChats.length > 0 && ( -
- {discoveredChats.map((chat) => ( - - ))} -
- )} - {(discoveredChats.length === 0 || showManualInput) && ( - setFormChatId(e.target.value)} - placeholder="-100123456789, -100987654321" - className="w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300 placeholder-zinc-600" + ) : ( + currentSchema.map((field) => ( + setFieldValue(field.key, v)} + // Detect Chats button lives next to the chat_id input on + // Telegram only (the only platform with getUpdates). + renderExtras={ + field.key === "chat_id" && SUPPORTS_DETECT_CHATS.has(formType) + ? () => ( + <> +
+ +
+ {discoveredChats.length > 0 && ( +
+ {discoveredChats.map((chat) => ( + + ))} + +
+ )} + + ) + : undefined + } /> - )} -

- {discoveredChats.length > 0 ? ( - <> - Chats: {formChatId || "(none selected)"} - {" · "} - - - ) : ( - "Click Detect Chats after adding the bot to groups or sending /start in DMs." - )} -

-
+ )) + )} +
{formError && ( @@ -343,7 +378,7 @@ export function ChannelsTab({ workspaceId }: Props) {

No channels connected

- Connect Telegram, Slack, or Discord to chat with this agent from social platforms. + Connect Telegram, Slack, Discord, or Lark / Feishu to chat with this agent from social platforms.

)} @@ -364,7 +399,7 @@ export function ChannelsTab({ workspaceId }: Props) { {ch.channel_type.charAt(0).toUpperCase() + ch.channel_type.slice(1)} - {ch.config.chat_id} + {ch.config.chat_id || ch.config.channel_id || ""}
@@ -415,3 +450,53 @@ export function ChannelsTab({ workspaceId }: Props) {
); } + +// SchemaField renders one ConfigField as a label + input. Kept inline in +// this file so the ChannelsTab stays self-contained; promote to its own +// module if another tab ever needs it. +function SchemaField({ + field, + value, + onChange, + renderExtras, +}: { + field: ConfigField; + value: string; + onChange: (v: string) => void; + renderExtras?: () => React.ReactNode; +}) { + const inputId = useId(); + const common = + "w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300 placeholder-zinc-600"; + return ( +
+ + {field.type === "textarea" ? ( +