feat: add Zoho Cliq support

- Implement Zoho Cliq chat channels and configuration schemas
- Add user authentication flow for Zoho Cliq
- Add bot, send, server, and types for Zoho Cliq integration
- Add zoho-cliq plugin for channel implementation
- Add types.zoho-cliq.ts with ZohoCliqConfig types
This commit is contained in:
Kastrah 2026-01-17 21:45:16 +01:00
parent e0158c5d5d
commit ea66123a0b
10 changed files with 566 additions and 0 deletions

View File

@ -0,0 +1,39 @@
import { chunkText } from "../../../auto-reply/chunk.js";
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
import { sendMessageZohoCliq } from "../../../zoho-cliq/send.js";
import type { ChannelOutboundAdapter } from "../types.js";
export const zohoCliqOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkText,
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to Zoho Cliq requires --to <channelOrChatId>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps }) => {
const send = (deps as any)?.sendZohoCliq ?? sendMessageZohoCliq;
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
const result = await send(to, text, {
accountId: resolvedAccountId,
});
return { channel: "zoho-cliq", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps }) => {
const send = (deps as any)?.sendZohoCliq ?? sendMessageZohoCliq;
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
const result = await send(to, text, {
accountId: resolvedAccountId,
mediaUrl,
});
return { channel: "zoho-cliq", ...result };
},
};

View File

@ -0,0 +1,143 @@
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../routing/session-key.js";
import type { ChannelMeta } from "./types.js";
import type { ChannelPlugin } from "./types.js";
const meta: ChannelMeta = {
id: "zoho-cliq",
label: "Zoho Cliq",
selectionLabel: "Zoho Cliq (OAuth)",
docsPath: "/channels/zoho-cliq",
docsLabel: "zoho-cliq",
blurb: "supported (OAuth API).",
};
export type ResolvedZohoCliqAccount = {
accountId: string;
name?: string;
enabled: boolean;
configured: boolean;
clientId: string;
clientSecret: string;
refreshToken: string;
dc: string;
allowFrom?: Array<string | number>;
};
export const zohoCliqPlugin: ChannelPlugin<ResolvedZohoCliqAccount> = {
id: "zoho-cliq",
meta,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
},
config: {
listAccountIds: (cfg) => {
const accounts = (cfg as any).channels?.["zoho-cliq"]?.accounts;
if (!accounts) return [];
return Object.keys(accounts);
},
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: "",
};
}
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,
};
},
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,
);
if (enabled.length === 1) return enabled[0][0];
if (Object.hasOwn(accounts, DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return Object.keys(accounts)[0];
},
setAccountEnabled: ({ cfg, accountId, enabled }) => {
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
const channels = (cfg as any).channels;
if (!channels) (cfg as any).channels = {};
const zoho = channels["zoho-cliq"];
if (!zoho) return cfg;
if (!zoho.accounts) zoho.accounts = {};
const account = zoho.accounts[resolvedAccountId];
if (account) account.enabled = enabled;
return cfg;
},
deleteAccount: ({ cfg, accountId }) => {
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
const channels = (cfg as any).channels;
if (!channels) return cfg;
const zoho = channels["zoho-cliq"];
if (!zoho) return cfg;
if (!zoho.accounts) return cfg;
delete zoho.accounts[resolvedAccountId];
if (Object.keys(zoho.accounts).length === 0) {
delete channels["zoho-cliq"];
}
return cfg;
},
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
}),
resolveAllowFrom: ({ cfg, accountId }) => {
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
const accounts = (cfg as any).channels?.["zoho-cliq"]?.accounts;
if (!accounts) return [];
const account = accounts[resolvedAccountId];
return (account?.allowFrom ?? []).map((entry: any) => String(entry));
},
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry: any) => String(entry).trim())
.filter(Boolean)
.map((entry: any) => entry.toLowerCase()),
},
reload: { configPrefixes: ["channels.zoho-cliq"] },
};

View File

@ -24,3 +24,4 @@ export * from "./types.slack.js";
export * from "./types.telegram.js";
export * from "./types.tools.js";
export * from "./types.whatsapp.js";
export * from "./types.zoho-cliq.js";

View File

@ -0,0 +1,40 @@
import type { OutboundRetryConfig } from "./types.base.js";
export type ZohoCliqWebhookConfig = {
/** Port for the webhook server. Default: same as main or specific. */
port?: number;
/** Path for the webhook endpoint. Default: /zoho/webhook. */
path?: string;
/** Secret key for validating incoming webhook signatures (if applicable). */
secret?: string;
};
export type ZohoCliqAccountConfig = {
/** Optional display name for this account. */
name?: string;
/** If false, do not start this Zoho Cliq account. Default: true. */
enabled?: boolean;
/** OAuth Client ID. */
clientId?: string;
/** OAuth Client Secret. */
clientSecret?: string;
/** OAuth Refresh Token. */
refreshToken?: string;
/** Data Center (US, EU, IN, AU, JP, CN). Default: US. */
dc?: "US" | "EU" | "IN" | "AU" | "JP" | "CN" | "SA" | "UK" | "CA";
/** Webhook configuration. */
webhook?: ZohoCliqWebhookConfig;
/** Direct message access policy (default: pairing). */
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
/** Allowlist for DM senders (ZUIDs or emails). */
allowFrom?: Array<string>;
/** Outbound text chunk size (chars). Default: 1000. */
textChunkLimit?: number;
/** Retry policy for outbound API calls. */
retry?: OutboundRetryConfig;
};
export type ZohoCliqConfig = {
/** Optional per-account Configuration. */
accounts?: Record<string, ZohoCliqAccountConfig>;
} & ZohoCliqAccountConfig;

40
src/zoho-cliq/auth.ts Normal file
View File

@ -0,0 +1,40 @@
import { ZohoCliqTokenResponse } from "./types.js";
const DC_URLS: Record<string, string> = {
US: "https://accounts.zoho.com",
EU: "https://accounts.zoho.eu",
IN: "https://accounts.zoho.in",
AU: "https://accounts.zoho.com.au",
JP: "https://accounts.zoho.jp",
CN: "https://accounts.zoho.com.cn",
SA: "https://accounts.zoho.sa",
UK: "https://accounts.zoho.uk",
CA: "https://accounts.zohocloud.ca",
};
export async function refreshAccessToken(
clientId: string,
clientSecret: string,
refreshToken: string,
dc: string = "US",
scope: string = "ZohoCliq.Messages.CREATE,ZohoCliq.Channels.READ"
): Promise<string> {
const baseUrl = DC_URLS[dc] || DC_URLS["US"];
const url = `${baseUrl}/oauth/v2/token?refresh_token=${refreshToken}&client_id=${clientId}&client_secret=${clientSecret}&grant_type=refresh_token&scope=${encodeURIComponent(scope)}`;
const response = await fetch(url, {
method: "POST",
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to refresh Zoho Cliq token: ${response.status} ${response.statusText} - ${text}`);
}
const data = (await response.json()) as ZohoCliqTokenResponse;
if (data.error) {
throw new Error(`Zoho Cliq token refresh error: ${data.error}`);
}
return data.access_token;
}

104
src/zoho-cliq/bot.ts Normal file
View File

@ -0,0 +1,104 @@
import { refreshAccessToken } from "./auth.js";
import { ZohoCliqAccountConfig } from "../config/types.js";
import { getChildLogger } from "../logging.js";
const logger = getChildLogger({ module: "zoho-cliq-bot" });
export class ZohoCliqBot {
private accessToken: string | null = null;
private tokenExpiresAt: number = 0;
constructor(private config: ZohoCliqAccountConfig) { }
private async getAccessToken(): Promise<string> {
if (this.accessToken && Date.now() < this.tokenExpiresAt - 60000) {
return this.accessToken;
}
if (!this.config.clientId || !this.config.clientSecret || !this.config.refreshToken) {
throw new Error("Zoho Cliq OAuth credentials missing");
}
try {
// TODO: Allow configuring scope via config if needed.
// For now, default to Messages.CREATE and Channels.READ as a safe baseline.
const token = await refreshAccessToken(
this.config.clientId,
this.config.clientSecret,
this.config.refreshToken,
this.config.dc
);
this.accessToken = token;
// Zoho Access tokens typically expire in 1 hour (3600 seconds).
// Standardize on 55 minutes to be safe, or parse expires_in if functionality expands.
this.tokenExpiresAt = Date.now() + (55 * 60 * 1000);
return token;
} catch (error) {
logger.error({ err: error }, "Failed to refresh access token");
throw error;
}
}
async sendMessage(channelIdOrChatId: string, text: string) {
const token = await this.getAccessToken();
const dc = this.config.dc || "US";
// Logic to determine API domain based on DC
let apiDomain = "https://cliq.zoho.com";
switch (dc) {
case "EU": apiDomain = "https://cliq.zoho.eu"; break;
case "IN": apiDomain = "https://cliq.zoho.in"; break;
case "AU": apiDomain = "https://cliq.zoho.com.au"; break;
case "JP": apiDomain = "https://cliq.zoho.jp"; break;
case "CN": apiDomain = "https://cliq.zoho.com.cn"; break;
case "SA": apiDomain = "https://cliq.zoho.sa"; break;
case "UK": apiDomain = "https://cliq.zoho.uk"; break;
case "CA": apiDomain = "https://cliq.zohocloud.ca"; break;
default: apiDomain = "https://cliq.zoho.com"; break;
}
// Determine if target is a Chat ID or Channel Unique Name.
// Heuristic: Chat IDs are typically numeric or specific UUID-like strings.
// Channel unique names are user-defined strings.
// For simplicity: unique names usually don't look like IDs.
// However, the safer bet for V1 is sticking to the `chats` endpoint
// if we assume interactions start from a webhook (which gives a chat_id/channel_id).
const url = `${apiDomain}/api/v2/chats/${channelIdOrChatId}/message`;
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Zoho-oauthtoken ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ text }),
});
if (!response.ok) {
// Fallback for channels by unique name if 404/400?
// If the chat_id approach fails, try the named channel endpoint.
if (response.status === 404 || response.status === 400) {
const channelUrl = `${apiDomain}/api/v2/channelsbyname/${channelIdOrChatId}/message`;
const channelResponse = await fetch(channelUrl, {
method: "POST",
headers: {
Authorization: `Zoho-oauthtoken ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ text }),
});
if (channelResponse.ok) {
return channelResponse.json();
}
}
const errText = await response.text();
logger.error(`Failed to send message to ${channelIdOrChatId}: ${response.status} ${errText}`);
throw new Error(`Failed to send message: ${errText}`);
}
return response.json();
}
}

4
src/zoho-cliq/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from "./types.js";
export * from "./auth.js";
export * from "./bot.js";
export * from "./server.js";

21
src/zoho-cliq/send.ts Normal file
View File

@ -0,0 +1,21 @@
import { loadConfig } from "../config/config.js";
import { ZohoCliqBot } from "./bot.js";
import type { ZohoCliqConfig } from "../config/types.js";
export async function sendMessageZohoCliq(
channelIdOrChatId: string,
text: string,
opts?: { accountId?: string; mediaUrl?: string },
) {
const cfg = loadConfig();
const accountId = opts?.accountId ?? "main";
const zohoConfig = cfg.channels?.["zoho-cliq"] as ZohoCliqConfig | undefined;
const accountCfg = zohoConfig?.accounts?.[accountId];
if (!accountCfg) {
throw new Error(`Zoho Cliq account ${accountId} not found in config`);
}
const bot = new ZohoCliqBot(accountCfg);
return await bot.sendMessage(channelIdOrChatId, text);
}

115
src/zoho-cliq/server.ts Normal file
View File

@ -0,0 +1,115 @@
import { createServer } from "node:http";
import { loadConfig } from "../config/config.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { RuntimeEnv } from "../runtime.js";
import { readJsonBody } from "../gateway/hooks.js";
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
import { ZohoCliqBot } from "./bot.js";
import { ZohoCliqAccountConfig } from "../config/types.js";
import { danger } from "../globals.js";
type ZohoCliqWebhookOptions = {
port?: number;
runtime: RuntimeEnv;
bot: ZohoCliqBot;
accountId: string;
config: ZohoCliqAccountConfig;
};
export async function startZohoCliqWebhook(opts: ZohoCliqWebhookOptions) {
const { port = 8080, runtime, bot, accountId } = opts;
const server = createServer(async (req, res) => {
if (req.method !== "POST") {
res.writeHead(405);
res.end("Method Not Allowed");
return;
}
try {
const bodyResult = await readJsonBody(req, 1024 * 1024); // 1MB limit
if (!bodyResult.ok) {
res.writeHead(400);
res.end(bodyResult.error);
return;
}
const payload = bodyResult.value as any;
// Basic Zoho Cliq Message handling
// Note: Structure varies by event type (message, bot_mention, etc.)
const text = payload.text || payload.message?.text || payload.content;
const fromUser = payload.user || payload.from;
const fromId = fromUser?.id || payload.user_id;
const fromName = fromUser?.first_name || fromUser?.name || "Unknown";
const channelId = payload.channel_id || payload.chat_id || fromId;
if (!text || !fromId) {
// Verify ping/handshake
res.writeHead(200);
res.end();
return;
}
const cfg = loadConfig();
const { dispatcher, replyOptions, markDispatchIdle } =
createReplyDispatcherWithTyping({
responsePrefix: cfg.messages?.responsePrefix,
deliver: async (responsePayload, _info) => {
// TODO: Handle info.kind === "typing" if supported
if (responsePayload.text) {
await bot.sendMessage(channelId, responsePayload.text);
}
},
onError: (err, info) => {
runtime.error?.(danger(`zoho ${info.kind} reply failed: ${String(err)}`));
},
});
await dispatchReplyFromConfig({
ctx: {
Provider: "zoho-cliq" as any, // Cast if type union is strict
Body: text,
From: `zoho:${fromId}`,
To: `zoho:bot`,
SessionKey: `zoho:${channelId}`,
AccountId: accountId,
SenderId: String(fromId),
SenderName: fromName,
// Timestamp: Date.now(), // Approximate - Timestamp might strictly not exist in MsgContext
// Fill required fields with defaults
ChangeType: "message",
MessageSid: String(Date.now()),
Surface: "zoho-cliq" as any,
} as any,
cfg,
dispatcher,
replyOptions,
});
markDispatchIdle();
res.writeHead(200);
res.end();
} catch (err) {
runtime.error?.(`Zoho webhook failed: ${formatErrorMessage(err)}`);
res.writeHead(500);
res.end();
}
});
return new Promise<{ stop: () => Promise<void> }>((resolve, reject) => {
server.listen(port, () => {
runtime.log?.(`Zoho Cliq webhook listening on port ${port}`);
resolve({
stop: async () => {
return new Promise((r) => server.close(() => r()));
}
});
});
server.on('error', reject);
});
}

59
src/zoho-cliq/types.ts Normal file
View File

@ -0,0 +1,59 @@
export interface ZohoCliqConfig {
clientId: string;
clientSecret: string;
refreshToken: string;
dc: string;
}
export interface ZohoCliqTokenResponse {
access_token: string;
expires_in: number;
api_domain: string;
token_type: string;
error?: string;
}
export interface ZohoCliqMessage {
text: string;
bot?: {
name: string;
image: string;
};
card?: {
title: string;
theme: string;
};
slides?: Array<{
type: string;
title: string;
data: Array<{
[key: string]: string;
}>;
}>;
}
// Webhook payload from Zoho Cliq (partial)
export interface ZohoCliqWebhookPayload {
user: {
id: string; // ZUID
first_name: string;
last_name: string;
email: string;
display_name: string;
};
channel?: {
id: string;
name: string;
};
message?: {
text: string;
id: string; // message id
time: string; // timestamp
};
// DMs might have different structure or just 'user' and 'message'
chat?: {
id: string; // chat id for DM
};
text?: string; // Sometimes text is top level in slash commands
}