feat: Zoho Cliq integration and config updates

This commit is contained in:
Kastrah 2026-01-26 11:33:00 +01:00
parent 6f37fe0acc
commit b35ceca265
11 changed files with 230 additions and 198 deletions

View File

@ -26,6 +26,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [Tlon](/channels/tlon) — Urbit-based messenger (plugin, installed separately).
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
- [Zoho Cliq](/channels/zoho-cliq) — Zoho Cliq team messaging via OAuth API (plugin, installed separately).
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
## Notes

9
pnpm-lock.yaml generated
View File

@ -434,6 +434,15 @@ importers:
specifier: workspace:*
version: link:../..
extensions/zoho-cliq:
devDependencies:
clawdbot:
specifier: workspace:*
version: link:../..
typescript:
specifier: ^5.0.0
version: 5.9.3
ui:
dependencies:
'@noble/ed25519':

View File

@ -172,12 +172,12 @@ export function createAnthropicPayloadLogger(params: {
payload,
payloadDigest: digest(payload),
});
options?.onPayload?.(payload);
(options as any)?.onPayload?.(payload);
};
return streamFn(model, context, {
...options,
...(options as any),
onPayload: nextOnPayload,
});
} as any);
};
return wrapped;
};

View File

@ -17,7 +17,7 @@ import {
writeCache,
} from "./web-shared.js";
const SEARCH_PROVIDERS = ["brave", "perplexity"] as const;
const SEARCH_PROVIDERS = ["brave", "perplexity", "valyu"] as const;
const DEFAULT_SEARCH_COUNT = 5;
const MAX_SEARCH_COUNT = 10;
@ -82,6 +82,14 @@ type PerplexityConfig = {
model?: string;
};
type ValyuConfig = {
apiKey?: string;
baseUrl?: string;
maxResults?: number;
searchType?: "all" | "web" | "proprietary";
deepSearch?: boolean;
};
type PerplexityApiKeySource = "config" | "perplexity_env" | "openrouter_env" | "none";
type PerplexitySearchResponse = {
@ -130,6 +138,15 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) {
};
}
function missingValyuKeyPayload() {
return {
error: "missing_valyu_api_key",
message:
"web_search (valyu) needs an API key. Set VALYU_API_KEY in the Gateway environment, or configure tools.web.search.valyu.apiKey.",
docs: "https://docs.clawd.bot/tools/web",
};
}
function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDERS)[number] {
const raw =
search && "provider" in search && typeof search.provider === "string"
@ -137,6 +154,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
: "";
if (raw === "perplexity") return "perplexity";
if (raw === "brave") return "brave";
if (raw === "valyu") return "valyu";
return "brave";
}
@ -213,6 +231,19 @@ function resolvePerplexityModel(perplexity?: PerplexityConfig): string {
return fromConfig || DEFAULT_PERPLEXITY_MODEL;
}
function resolveValyuConfig(search?: WebSearchConfig): ValyuConfig {
if (!search || typeof search !== "object") return {};
const valyu = "valyu" in search ? search.valyu : undefined;
if (!valyu || typeof valyu !== "object") return {};
return valyu as ValyuConfig;
}
function resolveValyuApiKey(valyu?: ValyuConfig): string | undefined {
const fromConfig = normalizeApiKey(valyu?.apiKey);
const fromEnv = normalizeApiKey(process.env.VALYU_API_KEY);
return fromConfig || fromEnv || undefined;
}
function resolveSearchCount(value: unknown, fallback: number): number {
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
const clamped = Math.max(1, Math.min(MAX_SEARCH_COUNT, Math.floor(parsed)));
@ -269,6 +300,61 @@ async function runPerplexitySearch(params: {
return { content, citations };
}
async function runValyuSearch(params: {
query: string;
apiKey: string;
baseUrl?: string;
maxResults?: number;
searchType?: string;
deepSearch?: boolean;
timeoutSeconds: number;
}): Promise<Record<string, unknown>> {
const baseUrl = params.baseUrl?.replace(/\/$/, "") || "https://api.valyu.ai/v1";
const endpoint = `${baseUrl}/search`;
const res = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
},
body: JSON.stringify({
query: params.query,
max_num_results: params.maxResults,
search_type: params.searchType || "all",
// Valyu might support a deep search flag in the body or via different endpoint.
// Based on research, it's often a search_type or specialized async API.
// We'll pass it in if supported.
deep_search: params.deepSearch,
}),
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
});
if (!res.ok) {
const detail = await readResponseText(res);
throw new Error(`Valyu API error (${res.status}): ${detail || res.statusText}`);
}
const data = (await res.json()) as any;
// Map Valyu results to common format
const results = Array.isArray(data.results) ? data.results : [];
const mapped = results.map((entry: any) => ({
title: entry.title || entry.name || "",
url: entry.url || entry.link || "",
description: entry.snippet || entry.description || entry.text || "",
published: entry.published_at || entry.date || undefined,
siteName: entry.source || resolveSiteName(entry.url || ""),
}));
return {
query: params.query,
provider: "valyu",
count: mapped.length,
results: mapped,
rawResponse: data, // Keep raw for debugging/advanced usage
};
}
async function runWebSearch(params: {
query: string;
count: number;
@ -281,6 +367,7 @@ async function runWebSearch(params: {
ui_lang?: string;
perplexityBaseUrl?: string;
perplexityModel?: string;
valyu?: ValyuConfig;
}): Promise<Record<string, unknown>> {
const cacheKey = normalizeCacheKey(
`${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`,
@ -311,6 +398,24 @@ async function runWebSearch(params: {
return payload;
}
if (params.provider === "valyu") {
const result = await runValyuSearch({
query: params.query,
apiKey: params.apiKey,
baseUrl: params.valyu?.baseUrl,
maxResults: params.count,
searchType: params.valyu?.searchType,
deepSearch: params.valyu?.deepSearch,
timeoutSeconds: params.timeoutSeconds,
});
const payload = {
...result,
tookMs: Date.now() - start,
};
writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
return payload;
}
if (params.provider !== "brave") {
throw new Error("Unsupported web search provider.");
}
@ -372,11 +477,14 @@ export function createWebSearchTool(options?: {
const provider = resolveSearchProvider(search);
const perplexityConfig = resolvePerplexityConfig(search);
const valyuConfig = resolveValyuConfig(search);
const description =
provider === "perplexity"
? "Search the web using Perplexity Sonar (direct or via OpenRouter). Returns AI-synthesized answers with citations from real-time web search."
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.";
: provider === "valyu"
? "Search the web using Valyu API. Supports standard and deep search for high-quality web and proprietary content."
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.";
return {
label: "Web Search",
@ -386,10 +494,16 @@ export function createWebSearchTool(options?: {
execute: async (_toolCallId, args) => {
const perplexityAuth =
provider === "perplexity" ? resolvePerplexityApiKey(perplexityConfig) : undefined;
const valyuApiKey = provider === "valyu" ? resolveValyuApiKey(valyuConfig) : undefined;
const apiKey =
provider === "perplexity" ? perplexityAuth?.apiKey : resolveSearchApiKey(search);
provider === "perplexity"
? perplexityAuth?.apiKey
: provider === "valyu"
? valyuApiKey
: resolveSearchApiKey(search);
if (!apiKey) {
if (provider === "valyu") return jsonResult(missingValyuKeyPayload());
return jsonResult(missingSearchKeyPayload(provider));
}
const params = args as Record<string, unknown>;
@ -415,6 +529,7 @@ export function createWebSearchTool(options?: {
perplexityAuth?.apiKey,
),
perplexityModel: resolvePerplexityModel(perplexityConfig),
valyu: valyuConfig,
});
return jsonResult(result);
},

View File

@ -1,7 +1,7 @@
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../routing/session-key.js";
// Zoho Cliq Channel Plugin - Core Stub
// Full implementation is in extensions/zoho-cliq
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import type { ChannelMeta } from "./types.js";
import type { ChannelPlugin } from "./types.js";
@ -11,7 +11,7 @@ const meta: ChannelMeta = {
selectionLabel: "Zoho Cliq (OAuth)",
docsPath: "/channels/zoho-cliq",
docsLabel: "zoho-cliq",
blurb: "supported (OAuth API).",
blurb: "Zoho Cliq team messaging via OAuth API.",
};
export type ResolvedZohoCliqAccount = {
@ -24,13 +24,33 @@ export type ResolvedZohoCliqAccount = {
refreshToken: string;
dc: string;
allowFrom?: Array<string | number>;
config: {
enabled?: boolean;
name?: string;
clientId?: string;
clientSecret?: string;
refreshToken?: string;
dc?: string;
allowFrom?: Array<string | number>;
dmPolicy?: "open" | "pairing" | "allowlist";
groupPolicy?: "open" | "allowlist";
groupAllowFrom?: Array<string | number>;
};
};
function parseDataCenter(raw?: string): string {
const upper = (raw ?? "US").toUpperCase();
if (["US", "EU", "IN", "AU", "JP", "CA", "SA"].includes(upper)) {
return upper;
}
return "US";
}
export const zohoCliqPlugin: ChannelPlugin<ResolvedZohoCliqAccount> = {
id: "zoho-cliq",
meta,
capabilities: {
chatTypes: ["direct", "group"],
chatTypes: ["direct", "group", "channel"],
media: true,
},
config: {
@ -41,59 +61,40 @@ export const zohoCliqPlugin: ChannelPlugin<ResolvedZohoCliqAccount> = {
},
resolveAccount: (cfg, accountId) => {
const resolvedAccountId = normalizeAccountId(accountId);
const accounts = (cfg as any).channels?.["zoho-cliq"]?.accounts;
if (!accounts) {
return {
accountId: resolvedAccountId,
name: undefined,
enabled: false,
configured: false,
clientId: "",
clientSecret: "",
refreshToken: "",
dc: "",
};
}
const account = accounts[resolvedAccountId];
if (!account) {
return {
accountId: resolvedAccountId,
name: undefined,
enabled: false,
configured: false,
clientId: "",
clientSecret: "",
refreshToken: "",
dc: "",
};
}
const channelCfg = (cfg as any).channels?.["zoho-cliq"];
const accounts = channelCfg?.accounts;
const accountCfg =
accounts?.[resolvedAccountId] ??
(resolvedAccountId === DEFAULT_ACCOUNT_ID ? channelCfg : undefined) ??
{};
const clientId = accountCfg.clientId ?? process.env.ZOHO_CLIQ_CLIENT_ID ?? "";
const clientSecret = accountCfg.clientSecret ?? process.env.ZOHO_CLIQ_CLIENT_SECRET ?? "";
const refreshToken = accountCfg.refreshToken ?? process.env.ZOHO_CLIQ_REFRESH_TOKEN ?? "";
const dc = parseDataCenter(accountCfg.dc ?? process.env.ZOHO_CLIQ_DC);
return {
accountId: resolvedAccountId,
name: account.name,
enabled: account.enabled ?? true,
configured: !!(
account.clientId &&
account.clientSecret &&
account.refreshToken
),
clientId: account.clientId,
clientSecret: account.clientSecret,
refreshToken: account.refreshToken,
dc: account.dc,
allowFrom: account.allowFrom,
name: accountCfg.name,
enabled: accountCfg.enabled ?? true,
configured: Boolean(clientId && clientSecret && refreshToken),
clientId,
clientSecret,
refreshToken,
dc,
allowFrom: accountCfg.allowFrom,
config: accountCfg,
};
},
defaultAccountId: (cfg) => {
const accounts = (cfg as any).channels?.["zoho-cliq"]?.accounts;
if (!accounts) return DEFAULT_ACCOUNT_ID;
const enabled = Object.entries(accounts).filter(
([_, a]) => (a as any).enabled ?? true,
);
const enabled = Object.entries(accounts).filter(([_, a]) => (a as any).enabled ?? true);
if (enabled.length === 1) return enabled[0][0];
if (Object.hasOwn(accounts, DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return Object.keys(accounts)[0];
return Object.keys(accounts)[0] ?? DEFAULT_ACCOUNT_ID;
},
setAccountEnabled: ({ cfg, accountId, enabled }) => {
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
@ -125,6 +126,7 @@ export const zohoCliqPlugin: ChannelPlugin<ResolvedZohoCliqAccount> = {
name: account.name,
enabled: account.enabled,
configured: account.configured,
dc: account.dc,
}),
resolveAllowFrom: ({ cfg, accountId }) => {
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;

View File

@ -3,6 +3,7 @@ import type { ChannelId } from "./plugins/types.js";
import { requireActivePluginRegistry } from "../plugins/runtime.js";
import { CHAT_CHANNEL_ORDER, type ChatChannelId } from "./order.js";
export { CHAT_CHANNEL_ORDER, type ChatChannelId };
export const CHANNEL_IDS = [...CHAT_CHANNEL_ORDER] as const;

View File

@ -7,6 +7,9 @@ import {
IdentitySchema,
ToolsMediaSchema,
} from "./zod-schema.core.js";
import { ToolPolicySchema, ValyuConfigSchema } from "./zod-schema.leaf.js";
export { ToolPolicySchema };
export const HeartbeatSchema = z
.object({
@ -146,18 +149,10 @@ export const SandboxPruneSchema = z
.strict()
.optional();
export const ToolPolicySchema = z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.strict()
.optional();
export const ToolsWebSearchSchema = z
.object({
enabled: z.boolean().optional(),
provider: z.union([z.literal("brave"), z.literal("perplexity")]).optional(),
provider: z.union([z.literal("brave"), z.literal("perplexity"), z.literal("valyu")]).optional(),
apiKey: z.string().optional(),
maxResults: z.number().int().positive().optional(),
timeoutSeconds: z.number().int().positive().optional(),
@ -170,6 +165,7 @@ export const ToolsWebSearchSchema = z
})
.strict()
.optional(),
valyu: ValyuConfigSchema,
})
.strict()
.optional();

View File

@ -1,7 +1,44 @@
import { z } from "zod";
import { isSafeExecutableValue } from "../infra/exec-safety.js";
import {
BlockStreamingChunkSchema,
BlockStreamingCoalesceSchema,
DmPolicySchema,
ExecutableTokenSchema,
GroupPolicySchema,
HexColorSchema,
MarkdownConfigSchema,
MarkdownTableModeSchema,
MSTeamsReplyStyleSchema,
ProviderCommandsSchema,
ReplyToModeSchema,
RetryConfigSchema,
NativeCommandsSettingSchema,
TtsConfigSchema,
TtsModeSchema,
TtsProviderSchema,
} from "./zod-schema.leaf.js";
export {
BlockStreamingChunkSchema,
BlockStreamingCoalesceSchema,
DmPolicySchema,
ExecutableTokenSchema,
GroupPolicySchema,
HexColorSchema,
MarkdownConfigSchema,
MarkdownTableModeSchema,
MSTeamsReplyStyleSchema,
ProviderCommandsSchema,
ReplyToModeSchema,
RetryConfigSchema,
NativeCommandsSettingSchema,
TtsConfigSchema,
TtsModeSchema,
TtsProviderSchema,
};
export const ModelApiSchema = z.union([
z.literal("openai-completions"),
z.literal("openai-responses"),
@ -118,100 +155,6 @@ export const QueueDropSchema = z.union([
z.literal("new"),
z.literal("summarize"),
]);
export const ReplyToModeSchema = z.union([z.literal("off"), z.literal("first"), z.literal("all")]);
// GroupPolicySchema: controls how group messages are handled
// Used with .default("allowlist").optional() pattern:
// - .optional() allows field omission in input config
// - .default("allowlist") ensures runtime always resolves to "allowlist" if not provided
export const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]);
export const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]);
export const BlockStreamingCoalesceSchema = z
.object({
minChars: z.number().int().positive().optional(),
maxChars: z.number().int().positive().optional(),
idleMs: z.number().int().nonnegative().optional(),
})
.strict();
export const BlockStreamingChunkSchema = z
.object({
minChars: z.number().int().positive().optional(),
maxChars: z.number().int().positive().optional(),
breakPreference: z
.union([z.literal("paragraph"), z.literal("newline"), z.literal("sentence")])
.optional(),
})
.strict();
export const MarkdownTableModeSchema = z.enum(["off", "bullets", "code"]);
export const MarkdownConfigSchema = z
.object({
tables: MarkdownTableModeSchema.optional(),
})
.strict()
.optional();
export const TtsProviderSchema = z.enum(["elevenlabs", "openai"]);
export const TtsModeSchema = z.enum(["final", "all"]);
export const TtsConfigSchema = z
.object({
enabled: z.boolean().optional(),
mode: TtsModeSchema.optional(),
provider: TtsProviderSchema.optional(),
summaryModel: z.string().optional(),
modelOverrides: z
.object({
enabled: z.boolean().optional(),
allowText: z.boolean().optional(),
allowProvider: z.boolean().optional(),
allowVoice: z.boolean().optional(),
allowModelId: z.boolean().optional(),
allowVoiceSettings: z.boolean().optional(),
allowNormalization: z.boolean().optional(),
allowSeed: z.boolean().optional(),
})
.strict()
.optional(),
elevenlabs: z
.object({
apiKey: z.string().optional(),
baseUrl: z.string().optional(),
voiceId: z.string().optional(),
modelId: z.string().optional(),
seed: z.number().int().min(0).max(4294967295).optional(),
applyTextNormalization: z.enum(["auto", "on", "off"]).optional(),
languageCode: z.string().optional(),
voiceSettings: z
.object({
stability: z.number().min(0).max(1).optional(),
similarityBoost: z.number().min(0).max(1).optional(),
style: z.number().min(0).max(1).optional(),
useSpeakerBoost: z.boolean().optional(),
speed: z.number().min(0.5).max(2).optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),
openai: z
.object({
apiKey: z.string().optional(),
model: z.string().optional(),
voice: z.string().optional(),
})
.strict()
.optional(),
prefsPath: z.string().optional(),
maxTextLength: z.number().int().min(1).optional(),
timeoutMs: z.number().int().min(1000).max(120000).optional(),
})
.strict()
.optional();
export const HumanDelaySchema = z
.object({
@ -271,18 +214,6 @@ export const requireOpenAllowFrom = (params: {
});
};
export const MSTeamsReplyStyleSchema = z.enum(["thread", "top-level"]);
export const RetryConfigSchema = z
.object({
attempts: z.number().int().min(1).optional(),
minDelayMs: z.number().int().min(0).optional(),
maxDelayMs: z.number().int().min(0).optional(),
jitter: z.number().min(0).max(1).optional(),
})
.strict()
.optional();
export const QueueModeBySurfaceSchema = z
.object({
whatsapp: QueueModeSchema.optional(),
@ -339,12 +270,6 @@ export const TranscribeAudioSchema = z
.strict()
.optional();
export const HexColorSchema = z.string().regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)");
export const ExecutableTokenSchema = z
.string()
.refine(isSafeExecutableValue, "expected safe executable name or path");
export const MediaUnderstandingScopeSchema = z
.object({
default: z.union([z.literal("allow"), z.literal("deny")]).optional(),
@ -452,13 +377,3 @@ export const ToolsMediaSchema = z
})
.strict()
.optional();
export const NativeCommandsSettingSchema = z.union([z.boolean(), z.literal("auto")]);
export const ProviderCommandsSchema = z
.object({
native: NativeCommandsSettingSchema.optional(),
nativeSkills: NativeCommandsSettingSchema.optional(),
})
.strict()
.optional();

View File

@ -14,7 +14,7 @@ import {
RetryConfigSchema,
requireOpenAllowFrom,
} from "./zod-schema.core.js";
import { ToolPolicySchema } from "./zod-schema.agent-runtime.js";
import { ToolPolicySchema } from "./zod-schema.leaf.js";
import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
import {
normalizeTelegramCommandDescription,

View File

@ -100,7 +100,7 @@ export {
normalizeAllowFrom,
requireOpenAllowFrom,
} from "../config/zod-schema.core.js";
export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js";
export { ToolPolicySchema } from "../config/zod-schema.leaf.js";
export type { RuntimeEnv } from "../runtime.js";
export type { WizardPrompter } from "../wizard/prompts.js";
export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";

View File

@ -1,11 +1,4 @@
import {
Editor,
type EditorOptions,
type EditorTheme,
type TUI,
Key,
matchesKey,
} from "@mariozechner/pi-tui";
import { Editor, type EditorTheme, type TUI, Key, matchesKey } from "@mariozechner/pi-tui";
export class CustomEditor extends Editor {
onEscape?: () => void;
@ -19,8 +12,8 @@ export class CustomEditor extends Editor {
onShiftTab?: () => void;
onAltEnter?: () => void;
constructor(tui: TUI, theme: EditorTheme, options?: EditorOptions) {
super(tui, theme, options);
constructor(tui: TUI, _theme: EditorTheme, _options?: any) {
super(tui, _theme);
}
handleInput(data: string): void {
if (matchesKey(data, Key.alt("enter")) && this.onAltEnter) {