feat(channels): first-class Lark/Feishu support via schema-driven config

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<string,string>` 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) <noreply@anthropic.com>
This commit is contained in:
rabbitblood 2026-04-24 11:51:15 -07:00
parent f5d44eba8c
commit 00265d7028
10 changed files with 435 additions and 109 deletions

View File

@ -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<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<Channel | null>(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<Record<string, string>>({});
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<Set<string>>(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<Channel[]>(`/workspaces/${workspaceId}/channels`),
api.get<ChannelAdapter[]>(`/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<string, string> = {};
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) {
</div>
)}
{/* Create form */}
{/* Create form — schema-driven */}
{showForm && (
<div className="space-y-2 p-3 bg-zinc-800/40 rounded border border-zinc-700/50">
<div>
@ -244,73 +283,69 @@ export function ChannelsTab({ workspaceId }: Props) {
))}
</select>
</div>
<div>
<label htmlFor={botTokenId} className="text-[10px] text-zinc-500 block mb-1">Bot Token</label>
<input
id={botTokenId}
type="password"
value={formBotToken}
onChange={(e) => 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"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label htmlFor={chatIdId} className="text-[10px] text-zinc-500">Chat IDs</label>
<button
onClick={handleDiscover}
disabled={discovering || !formBotToken}
className="text-[10px] px-2 py-0.5 rounded bg-blue-600/20 text-blue-400 hover:bg-blue-600/30 transition disabled:opacity-40"
>
{discovering ? "Detecting..." : "Detect Chats"}
</button>
{/* 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 ? (
<div className="text-[10px] text-yellow-500">
Platform exposes no config schema upgrade the platform to pick up first-class support.
</div>
{discoveredChats.length > 0 && (
<div className="space-y-1 mb-2">
{discoveredChats.map((chat) => (
<label
key={chat.chat_id}
className="flex items-center gap-2 px-2 py-1.5 bg-zinc-900/50 rounded border border-zinc-700/50 cursor-pointer hover:bg-zinc-800/50"
>
<input
type="checkbox"
checked={selectedChats.has(chat.chat_id)}
onChange={() => toggleChat(chat.chat_id)}
className="rounded border-zinc-600"
/>
<span className="text-xs text-zinc-300">{chat.name || "Unknown"}</span>
<span className="text-[10px] text-zinc-500 ml-auto">{chat.type} {chat.chat_id}</span>
</label>
))}
</div>
)}
{(discoveredChats.length === 0 || showManualInput) && (
<input
id={chatIdId}
value={formChatId}
onChange={(e) => 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) => (
<SchemaField
key={field.key}
field={field}
value={formValues[field.key] || ""}
onChange={(v) => 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)
? () => (
<>
<div className="flex items-center justify-end mb-1 -mt-1">
<button
onClick={handleDiscover}
disabled={discovering || !formValues["bot_token"]}
className="text-[10px] px-2 py-0.5 rounded bg-blue-600/20 text-blue-400 hover:bg-blue-600/30 transition disabled:opacity-40"
>
{discovering ? "Detecting..." : "Detect Chats"}
</button>
</div>
{discoveredChats.length > 0 && (
<div className="space-y-1 mb-2">
{discoveredChats.map((chat) => (
<label
key={chat.chat_id}
className="flex items-center gap-2 px-2 py-1.5 bg-zinc-900/50 rounded border border-zinc-700/50 cursor-pointer hover:bg-zinc-800/50"
>
<input
type="checkbox"
checked={selectedChats.has(chat.chat_id)}
onChange={() => toggleChat(chat.chat_id)}
className="rounded border-zinc-600"
/>
<span className="text-xs text-zinc-300">{chat.name || "Unknown"}</span>
<span className="text-[10px] text-zinc-500 ml-auto">{chat.type} {chat.chat_id}</span>
</label>
))}
<button
onClick={() => setShowManualInput(!showManualInput)}
className="text-[10px] text-blue-400 hover:underline"
>
{showManualInput ? "hide manual input" : "edit manually"}
</button>
</div>
)}
</>
)
: undefined
}
/>
)}
<p className="text-[11px] text-zinc-500 mt-0.5">
{discoveredChats.length > 0 ? (
<>
Chats: <span className="text-zinc-400">{formChatId || "(none selected)"}</span>
{" · "}
<button
onClick={() => setShowManualInput(!showManualInput)}
className="text-blue-400 hover:underline"
>
{showManualInput ? "hide manual input" : "edit manually"}
</button>
</>
) : (
"Click Detect Chats after adding the bot to groups or sending /start in DMs."
)}
</p>
</div>
))
)}
<div>
<label htmlFor={allowedUsersId} className="text-[10px] text-zinc-500 block mb-1">
Allowed Users <span className="text-zinc-600">(optional, comma-separated)</span>
@ -323,7 +358,7 @@ export function ChannelsTab({ workspaceId }: Props) {
className="w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300 placeholder-zinc-600"
/>
<p className="text-[11px] text-zinc-500 mt-0.5">
Telegram user IDs. Leave empty to allow everyone.
Platform-specific user IDs. Leave empty to allow everyone.
</p>
</div>
{formError && (
@ -343,7 +378,7 @@ export function ChannelsTab({ workspaceId }: Props) {
<div className="text-center py-8">
<p className="text-zinc-500 text-xs">No channels connected</p>
<p className="text-zinc-600 text-[10px] mt-1">
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.
</p>
</div>
)}
@ -364,7 +399,7 @@ export function ChannelsTab({ workspaceId }: Props) {
{ch.channel_type.charAt(0).toUpperCase() + ch.channel_type.slice(1)}
</span>
<span className="text-[10px] text-zinc-500">
{ch.config.chat_id}
{ch.config.chat_id || ch.config.channel_id || ""}
</span>
</div>
<div className="flex items-center gap-1.5">
@ -415,3 +450,53 @@ export function ChannelsTab({ workspaceId }: Props) {
</div>
);
}
// 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 (
<div>
<label htmlFor={inputId} className="text-[10px] text-zinc-500 block mb-1">
{field.label}
{!field.required && <span className="text-zinc-600"> (optional)</span>}
</label>
{field.type === "textarea" ? (
<textarea
id={inputId}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
rows={3}
className={common}
/>
) : (
<input
id={inputId}
type={field.type === "password" ? "password" : "text"}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className={common}
/>
)}
{renderExtras?.()}
{field.help && (
<p className="text-[11px] text-zinc-500 mt-0.5">{field.help}</p>
)}
</div>
);
}

View File

@ -17,6 +17,14 @@ type ChannelAdapter interface {
// DisplayName returns the human-readable name (e.g. "Telegram").
DisplayName() string
// ConfigSchema describes the config fields each adapter needs. The UI
// renders the connect-channel form from this list, so each platform's
// field set (Telegram bot_token+chat_id, Lark webhook_url+verify_token,
// Slack bot_token+channel_id, Discord webhook_url) can be captured
// correctly without per-platform UI branching. Adapters must return the
// same schema on every call — the order is the rendering order.
ConfigSchema() []ConfigField
// ValidateConfig checks that channel_config JSONB has required fields.
ValidateConfig(config map[string]interface{}) error
@ -31,6 +39,33 @@ type ChannelAdapter interface {
StartPolling(ctx context.Context, config map[string]interface{}, onMessage MessageHandler) error
}
// ConfigField describes a single config field for the channels connect-form UI.
// Canvas renders one input per field in order. Values are strings in
// channel_config JSONB — this struct carries only presentation + validation
// hints; ValidateConfig on the adapter is still the source of truth for
// acceptance.
type ConfigField struct {
// Key is the channel_config map key (e.g. "webhook_url").
Key string `json:"key"`
// Label is the human-readable field name (e.g. "Webhook URL").
Label string `json:"label"`
// Type controls the HTML input type: "text" | "password" | "textarea".
Type string `json:"type"`
// Required marks the field as non-optional in the UI. Still enforced
// server-side via ValidateConfig regardless of this flag.
Required bool `json:"required"`
// Sensitive means the value must not be logged or shown unmasked in
// read APIs after creation. Canvas uses this to redact the value in
// list responses; server-side encryption is governed by sensitiveFields
// in secret.go (today: bot_token + webhook_secret only — this flag is
// forward-looking until that list is widened).
Sensitive bool `json:"sensitive"`
// Placeholder is rendered as the input's placeholder attribute.
Placeholder string `json:"placeholder,omitempty"`
// Help is a short one-liner shown below the input.
Help string `json:"help,omitempty"`
}
// InboundMessage is the standardized message from any social platform.
type InboundMessage struct {
ChatID string // Platform-specific chat/channel ID

View File

@ -127,10 +127,13 @@ func TestListAdapters(t *testing.T) {
}
found := false
for _, a := range list {
if a["type"] == "telegram" {
if a.Type == "telegram" {
found = true
if a["display_name"] != "Telegram" {
t.Errorf("expected display_name 'Telegram', got %q", a["display_name"])
if a.DisplayName != "Telegram" {
t.Errorf("expected display_name 'Telegram', got %q", a.DisplayName)
}
if len(a.ConfigSchema) == 0 {
t.Error("Telegram adapter must expose a non-empty ConfigSchema")
}
}
}
@ -740,10 +743,10 @@ func TestListAdapters_IncludesSlack(t *testing.T) {
list := ListAdapters()
found := false
for _, a := range list {
if a["type"] == "slack" {
if a.Type == "slack" {
found = true
if a["display_name"] != "Slack" {
t.Errorf("expected display_name 'Slack', got %q", a["display_name"])
if a.DisplayName != "Slack" {
t.Errorf("expected display_name 'Slack', got %q", a.DisplayName)
}
}
}

View File

@ -38,6 +38,32 @@ type DiscordAdapter struct{}
func (d *DiscordAdapter) Type() string { return "discord" }
func (d *DiscordAdapter) DisplayName() string { return "Discord" }
// ConfigSchema — Discord only needs a webhook URL for outbound.
// public_key is the Ed25519 pubkey used to verify inbound Interactions
// signatures (stored hex-encoded); not required if you only do outbound.
func (d *DiscordAdapter) ConfigSchema() []ConfigField {
return []ConfigField{
{
Key: "webhook_url",
Label: "Webhook URL",
Type: "password",
Required: true,
Sensitive: true,
Placeholder: "https://discord.com/api/webhooks/{id}/{token}",
Help: "From Server Settings → Integrations → Webhooks → Copy URL.",
},
{
Key: "public_key",
Label: "Interactions Public Key (hex)",
Type: "password",
Required: false,
Sensitive: true,
Placeholder: "optional — for inbound slash commands",
Help: "Ed25519 public key from the Discord Developer Portal → General Information. Only needed to receive slash commands.",
},
}
}
// ValidateConfig checks that the channel config contains a valid Discord
// Incoming Webhook URL. Returns a human-readable error for the Canvas UI.
func (d *DiscordAdapter) ValidateConfig(config map[string]interface{}) error {

View File

@ -241,10 +241,10 @@ func TestListAdapters_IncludesDiscord(t *testing.T) {
list := ListAdapters()
found := false
for _, a := range list {
if a["type"] == "discord" {
if a.Type == "discord" {
found = true
if a["display_name"] != "Discord" {
t.Errorf("expected display_name 'Discord', got %q", a["display_name"])
if a.DisplayName != "Discord" {
t.Errorf("expected display_name 'Discord', got %q", a.DisplayName)
}
}
}

View File

@ -37,6 +37,33 @@ const (
func (l *LarkAdapter) Type() string { return "lark" }
func (l *LarkAdapter) DisplayName() string { return "Lark / Feishu" }
// ConfigSchema — Lark Custom Bot webhook URL + optional Event Subscription
// verify token. The webhook URL already encodes the chat, so no separate
// chat_id field is needed (and StartPolling is a no-op for Lark — inbound
// is delivered by ParseWebhook from the Event Subscription callback).
func (l *LarkAdapter) ConfigSchema() []ConfigField {
return []ConfigField{
{
Key: "webhook_url",
Label: "Custom Bot Webhook URL",
Type: "password", // last path component is a secret
Required: true,
Sensitive: true,
Placeholder: "https://open.feishu.cn/open-apis/bot/v2/hook/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
Help: "From the Lark/Feishu bot page → Webhook settings. open.feishu.cn (China) and open.larksuite.com (international) both accepted.",
},
{
Key: "verify_token",
Label: "Event Subscription Verify Token",
Type: "password",
Required: false,
Sensitive: true,
Placeholder: "optional — from Event Subscriptions page",
Help: "Only needed if you want to receive messages from Lark. Paste the \"Verification Token\" from your app's Event Subscriptions configuration.",
},
}
}
// ValidateConfig requires webhook_url to point at a Lark or Feishu Custom
// Bot endpoint. verify_token is optional — when set, inbound events with a
// mismatching token are rejected (use Lark's "Verification Token" from the

View File

@ -401,3 +401,60 @@ func TestRegistry_HasLark(t *testing.T) {
t.Errorf("got %q want lark", a.Type())
}
}
// TestLark_ConfigSchema locks in the contract: Lark exposes a required +
// sensitive webhook_url and an optional + sensitive verify_token, in that
// order. Canvas renders the connect-form from this list so the order and
// required/sensitive flags are observable surface.
func TestLark_ConfigSchema(t *testing.T) {
schema := (&LarkAdapter{}).ConfigSchema()
if len(schema) != 2 {
t.Fatalf("expected 2 fields, got %d", len(schema))
}
want := []struct {
key string
required bool
sensitive bool
}{
{"webhook_url", true, true},
{"verify_token", false, true},
}
for i, w := range want {
got := schema[i]
if got.Key != w.key {
t.Errorf("field %d: key = %q, want %q", i, got.Key, w.key)
}
if got.Required != w.required {
t.Errorf("field %d (%s): required = %v, want %v", i, w.key, got.Required, w.required)
}
if got.Sensitive != w.sensitive {
t.Errorf("field %d (%s): sensitive = %v, want %v", i, w.key, got.Sensitive, w.sensitive)
}
if got.Label == "" {
t.Errorf("field %d (%s): label must not be empty", i, w.key)
}
}
}
// TestListAdapters_IncludesLark confirms the adapter is wired into the
// registry and its schema reaches the API layer intact. Regression guard
// against future registry.go refactors silently dropping Lark.
func TestListAdapters_IncludesLark(t *testing.T) {
list := ListAdapters()
var found *AdapterInfo
for i := range list {
if list[i].Type == "lark" {
found = &list[i]
break
}
}
if found == nil {
t.Fatal("lark adapter not in ListAdapters() output")
}
if found.DisplayName != "Lark / Feishu" {
t.Errorf("DisplayName = %q, want 'Lark / Feishu'", found.DisplayName)
}
if len(found.ConfigSchema) == 0 {
t.Error("ConfigSchema must not be empty in registry output")
}
}

View File

@ -15,14 +15,31 @@ func GetAdapter(channelType string) (ChannelAdapter, bool) {
return a, ok
}
// ListAdapters returns metadata about all available adapters.
func ListAdapters() []map[string]string {
result := make([]map[string]string, 0, len(adapters))
// AdapterInfo is the metadata payload returned by ListAdapters — the Canvas
// connect-channel form renders its field list dynamically from config_schema.
type AdapterInfo struct {
Type string `json:"type"`
DisplayName string `json:"display_name"`
ConfigSchema []ConfigField `json:"config_schema"`
}
// ListAdapters returns metadata about all available adapters, in a stable
// order (sorted by display name) so UI rendering + test assertions don't
// depend on Go's random map iteration.
func ListAdapters() []AdapterInfo {
result := make([]AdapterInfo, 0, len(adapters))
for _, a := range adapters {
result = append(result, map[string]string{
"type": a.Type(),
"display_name": a.DisplayName(),
result = append(result, AdapterInfo{
Type: a.Type(),
DisplayName: a.DisplayName(),
ConfigSchema: a.ConfigSchema(),
})
}
// Sort by display name for deterministic ordering.
for i := 1; i < len(result); i++ {
for j := i; j > 0 && result[j-1].DisplayName > result[j].DisplayName; j-- {
result[j-1], result[j] = result[j], result[j-1]
}
}
return result
}

View File

@ -31,6 +31,57 @@ type SlackAdapter struct{}
func (s *SlackAdapter) Type() string { return "slack" }
func (s *SlackAdapter) DisplayName() string { return "Slack" }
// ConfigSchema — Slack supports two mutually-exclusive outbound modes:
// Bot API (bot_token + channel_id, supports per-message identity override)
// and Incoming Webhook (webhook_url, legacy, no identity override). The
// form exposes both; ValidateConfig enforces "one or the other".
func (s *SlackAdapter) ConfigSchema() []ConfigField {
return []ConfigField{
{
Key: "bot_token",
Label: "Bot Token (xoxb-…)",
Type: "password",
Required: false,
Sensitive: true,
Placeholder: "xoxb-1234-5678-abc...",
Help: "Bot API mode — supports per-agent identity override. Required scopes: chat:write, chat:write.customize. Leave empty to use Incoming Webhook mode instead.",
},
{
Key: "channel_id",
Label: "Channel ID",
Type: "text",
Required: false,
Placeholder: "C01234ABCDE",
Help: "Required when using Bot Token mode. From the channel's \"View channel details\" dialog.",
},
{
Key: "webhook_url",
Label: "Incoming Webhook URL (legacy)",
Type: "password",
Required: false,
Sensitive: true,
Placeholder: "https://hooks.slack.com/services/T.../B.../...",
Help: "Simpler mode — no per-agent identity. Either Bot Token OR Webhook URL is required.",
},
{
Key: "username",
Label: "Override Username",
Type: "text",
Required: false,
Placeholder: "optional, Bot Token mode only",
Help: "Display name to use on outbound messages. Ignored in Webhook mode.",
},
{
Key: "icon_emoji",
Label: "Override Icon Emoji",
Type: "text",
Required: false,
Placeholder: ":robot_face:",
Help: "Emoji shortcode for per-message avatar. Ignored in Webhook mode.",
},
}
}
// ValidateConfig checks that the channel config contains a valid Slack
// Incoming Webhook URL (must start with https://hooks.slack.com/).
// Returns an error whose message becomes part of the 400 response body so

View File

@ -39,6 +39,31 @@ type TelegramAdapter struct{}
func (t *TelegramAdapter) Type() string { return "telegram" }
func (t *TelegramAdapter) DisplayName() string { return "Telegram" }
// ConfigSchema — Telegram uses Bot API long-polling. The bot token comes
// from @BotFather; chat_id is a comma-separated list discovered via the
// "Detect Chats" UI flow (calls Bot.getUpdates).
func (t *TelegramAdapter) ConfigSchema() []ConfigField {
return []ConfigField{
{
Key: "bot_token",
Label: "Bot Token",
Type: "password",
Required: true,
Sensitive: true,
Placeholder: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
Help: "From @BotFather → /newbot (or /token on an existing bot).",
},
{
Key: "chat_id",
Label: "Chat IDs",
Type: "text",
Required: true,
Placeholder: "-100123456789, -100987654321",
Help: "Comma-separated chat IDs. Use \"Detect Chats\" after adding the bot to groups or sending /start in DMs.",
},
}
}
func (t *TelegramAdapter) ValidateConfig(config map[string]interface{}) error {
token, _ := config["bot_token"].(string)
if token == "" {