forked from molecule-ai/molecule-core
Merge pull request #2055 from Molecule-AI/feat/lark-channel-first-class-v2
feat(channels): first-class Lark/Feishu support via schema-driven config
This commit is contained in:
commit
2dbd06d52e
@ -183,7 +183,31 @@ describe("ChannelsTab — htmlFor/id label associations (WCAG 1.3.1)", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockImplementation((url: string) => {
|
||||
if (url.includes("/channels/adapters")) {
|
||||
return Promise.resolve([{ type: "telegram", display_name: "Telegram" }]);
|
||||
// Mirror the real GET /channels/adapters shape — schema-driven form
|
||||
// relies on config_schema arriving from the adapter. A bare
|
||||
// {type, display_name} mock renders an empty form and every
|
||||
// getByLabelText below fails.
|
||||
return Promise.resolve([
|
||||
{
|
||||
type: "telegram",
|
||||
display_name: "Telegram",
|
||||
config_schema: [
|
||||
{
|
||||
key: "bot_token",
|
||||
label: "Bot Token",
|
||||
type: "password",
|
||||
required: true,
|
||||
sensitive: true,
|
||||
},
|
||||
{
|
||||
key: "chat_id",
|
||||
label: "Chat IDs",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user