diff --git a/src/channels/plugins/outbound/zoho-cliq.ts b/src/channels/plugins/outbound/zoho-cliq.ts new file mode 100644 index 000000000..1591dfc7c --- /dev/null +++ b/src/channels/plugins/outbound/zoho-cliq.ts @@ -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 ", + ), + }; + } + 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 }; + }, +}; diff --git a/src/channels/plugins/zoho-cliq.ts b/src/channels/plugins/zoho-cliq.ts new file mode 100644 index 000000000..06a437901 --- /dev/null +++ b/src/channels/plugins/zoho-cliq.ts @@ -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; +}; + +export const zohoCliqPlugin: ChannelPlugin = { + 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"] }, +}; diff --git a/src/config/types.ts b/src/config/types.ts index 368618262..f336c7855 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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"; diff --git a/src/config/types.zoho-cliq.ts b/src/config/types.zoho-cliq.ts new file mode 100644 index 000000000..04d518c24 --- /dev/null +++ b/src/config/types.zoho-cliq.ts @@ -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; + /** 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; +} & ZohoCliqAccountConfig; diff --git a/src/zoho-cliq/auth.ts b/src/zoho-cliq/auth.ts new file mode 100644 index 000000000..ff35b0d48 --- /dev/null +++ b/src/zoho-cliq/auth.ts @@ -0,0 +1,40 @@ +import { ZohoCliqTokenResponse } from "./types.js"; + +const DC_URLS: Record = { + 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 { + 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; +} diff --git a/src/zoho-cliq/bot.ts b/src/zoho-cliq/bot.ts new file mode 100644 index 000000000..3fd2aae40 --- /dev/null +++ b/src/zoho-cliq/bot.ts @@ -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 { + 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(); + } +} diff --git a/src/zoho-cliq/index.ts b/src/zoho-cliq/index.ts new file mode 100644 index 000000000..34b619073 --- /dev/null +++ b/src/zoho-cliq/index.ts @@ -0,0 +1,4 @@ +export * from "./types.js"; +export * from "./auth.js"; +export * from "./bot.js"; +export * from "./server.js"; diff --git a/src/zoho-cliq/send.ts b/src/zoho-cliq/send.ts new file mode 100644 index 000000000..152b30e97 --- /dev/null +++ b/src/zoho-cliq/send.ts @@ -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); +} diff --git a/src/zoho-cliq/server.ts b/src/zoho-cliq/server.ts new file mode 100644 index 000000000..d820a6ffa --- /dev/null +++ b/src/zoho-cliq/server.ts @@ -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 }>((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); + }); +} diff --git a/src/zoho-cliq/types.ts b/src/zoho-cliq/types.ts new file mode 100644 index 000000000..7646f6dfe --- /dev/null +++ b/src/zoho-cliq/types.ts @@ -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 +}