瀏覽代碼

feat: initial Zoho Cliq plugin v1.0.0

- OAuth2 authentication with auto-refresh (EU data center)
- Tools: setup_auth, auth_status, list_chats, list_channels,
  send_message, get_messages, post_to_channel
- Required scopes: ZohoCliq.Chats.READ, ZohoCliq.Messages.READ,
  ZohoCliq.Webhooks.CREATE, ZohoCliq.Channels.READ, ZohoCliq.Users.READ
- Companion skill: zoho-cliq (setup guide, scope reference, troubleshooting)
ShadowMan Service User 23 小時之前
當前提交
d0d19ba4df
共有 6 個文件被更改,包括 513 次插入0 次删除
  1. 4 0
      .gitignore
  2. 6 0
      .spkgignore
  3. 52 0
      index.js
  4. 179 0
      lib/auth.js
  5. 183 0
      lib/cliq.js
  6. 89 0
      manifest.json

+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+logs/
+.env
+*.spkg
+node_modules/

+ 6 - 0
.spkgignore

@@ -0,0 +1,6 @@
+logs/
+.env
+*.spkg
+node_modules/
+.git/
+.spkgignore

+ 52 - 0
index.js

@@ -0,0 +1,52 @@
+// Zoho Cliq Plugin v1.0.0
+// Zoho Cliq chat/messaging integration (EU data center)
+var auth = require('./lib/auth');
+var cliq = require('./lib/cliq');
+
+shadowman.log.info('Zoho Cliq plugin v1.0.0 loaded');
+
+// Auth tools
+shadowman.tools.register('setup_auth', function(args, context) {
+    shadowman.storage.set('auth_conversation_id', context.conversationId);
+    return auth.setupAuth(args.grant_token);
+});
+
+shadowman.tools.register('auth_status', function(args, context) {
+    return auth.getStatus();
+});
+
+// Chat tools
+shadowman.tools.register('list_chats', function(args, context) {
+    var token = auth.getValidToken();
+    if (!token) return { error: 'Not authenticated. Run setup_auth with a grant token first.' };
+    return cliq.listChats(token);
+});
+
+shadowman.tools.register('list_channels', function(args, context) {
+    var token = auth.getValidToken();
+    if (!token) return { error: 'Not authenticated. Run setup_auth with a grant token first.' };
+    return cliq.listChannels(token);
+});
+
+shadowman.tools.register('send_message', function(args, context) {
+    var token = auth.getValidToken();
+    if (!token) return { error: 'Not authenticated. Run setup_auth with a grant token first.' };
+    if (!args.chat_id) return { error: 'Missing chat_id parameter' };
+    if (!args.message) return { error: 'Missing message parameter' };
+    return cliq.sendMessage(token, args.chat_id, args.message);
+});
+
+shadowman.tools.register('get_messages', function(args, context) {
+    var token = auth.getValidToken();
+    if (!token) return { error: 'Not authenticated. Run setup_auth with a grant token first.' };
+    if (!args.chat_id) return { error: 'Missing chat_id parameter' };
+    return cliq.getMessages(token, args.chat_id, args.index || 1);
+});
+
+shadowman.tools.register('post_to_channel', function(args, context) {
+    var token = auth.getValidToken();
+    if (!token) return { error: 'Not authenticated. Run setup_auth with a grant token first.' };
+    if (!args.channel_name) return { error: 'Missing channel_name parameter' };
+    if (!args.message) return { error: 'Missing message parameter' };
+    return cliq.postToChannel(token, args.channel_name, args.message);
+});

+ 179 - 0
lib/auth.js

@@ -0,0 +1,179 @@
+// Zoho OAuth2 Authentication Module
+// EU data center: accounts.zoho.eu
+
+var ACCOUNTS_BASE = 'https://accounts.zoho.eu';
+var SCOPE = 'ZohoCliq.Chats.READ,ZohoCliq.Messages.READ,ZohoCliq.Webhooks.CREATE,ZohoCliq.Channels.READ,ZohoCliq.Users.READ';
+
+function setupAuth(grantToken) {
+    var clientId = shadowman.config.value('client_id');
+    var clientSecret = shadowman.config.value('client_secret');
+
+    if (!clientId || !clientSecret) {
+        return { error: 'Missing OAuth credentials. Set client_id and client_secret in plugin config.' };
+    }
+
+    if (!grantToken) {
+        return { error: 'Missing grant_token parameter. Generate one from Zoho Developer Console (Self Client).' };
+    }
+
+    shadowman.log.info('Exchanging Zoho grant token for access + refresh tokens...');
+
+    var body = 'grant_type=authorization_code' +
+               '&client_id=' + encodeURIComponent(clientId) +
+               '&client_secret=' + encodeURIComponent(clientSecret) +
+               '&redirect_uri=oob' +
+               '&code=' + encodeURIComponent(grantToken);
+
+    var resp = shadowman.http.post(ACCOUNTS_BASE + '/oauth/v2/token', body, {
+        headers: {
+            'Content-Type': 'application/x-www-form-urlencoded'
+        }
+    });
+
+    if (resp.status !== 200) {
+        shadowman.log.error('Token exchange failed: ' + resp.status + ' ' + resp.body);
+        var errBody;
+        try {
+            errBody = JSON.parse(resp.body);
+        } catch (e) {
+            errBody = resp.body;
+        }
+        return { error: 'Token exchange failed: ' + (errBody.error || errBody.error_description || resp.body) };
+    }
+
+    var tokens;
+    try {
+        tokens = JSON.parse(resp.body);
+    } catch (e) {
+        return { error: 'Invalid response from Zoho: ' + resp.body };
+    }
+
+    shadowman.storage.set('tokens', {
+        access_token: tokens.access_token,
+        refresh_token: tokens.refresh_token,
+        expires_at: Date.now() + (tokens.expires_in * 1000),
+        api_domain: tokens.api_domain || 'https://cliq.zoho.eu',
+        token_type: tokens.token_type || 'Bearer'
+    });
+
+    shadowman.log.info('Zoho OAuth tokens stored successfully. Refresh token does not expire.');
+
+    var result = {
+        success: true,
+        message: 'Authentication successful. Refresh token stored permanently.',
+        expires_in: tokens.expires_in
+    };
+
+    var convId = shadowman.storage.get('auth_conversation_id');
+    if (convId) {
+        shadowman.events.emit('message', {
+            text: 'Zoho Cliq OAuth successful! You can now use chat tools.',
+            conversationId: convId
+        });
+        shadowman.storage.del('auth_conversation_id');
+    }
+
+    return result;
+}
+
+function getStatus() {
+    var tokens = shadowman.storage.get('tokens');
+    if (!tokens) {
+        return { authenticated: false, message: 'Not authenticated. Use setup_auth with a grant token.' };
+    }
+
+    if (Date.now() >= tokens.expires_at) {
+        var newTokens = refreshTokenInternal();
+        if (newTokens.error) {
+            return { authenticated: false, message: 'Token expired and refresh failed. Re-run setup_auth.' };
+        }
+        return { authenticated: true, message: 'Authenticated (token refreshed)', domain: newTokens.api_domain };
+    }
+
+    var remaining = Math.round((tokens.expires_at - Date.now()) / 1000);
+    return {
+        authenticated: true,
+        message: 'Authenticated',
+        expires_in_seconds: remaining,
+        domain: tokens.api_domain
+    };
+}
+
+function getValidToken() {
+    var tokens = shadowman.storage.get('tokens');
+    if (!tokens) {
+        return null;
+    }
+
+    if (Date.now() >= (tokens.expires_at - 300000)) {
+        var newTokens = refreshTokenInternal();
+        if (newTokens.error) {
+            shadowman.log.error('Token refresh failed: ' + newTokens.error);
+            if (Date.now() < tokens.expires_at) {
+                return tokens.access_token;
+            }
+            return null;
+        }
+        return newTokens.access_token;
+    }
+
+    return tokens.access_token;
+}
+
+function refreshTokenInternal() {
+    var tokens = shadowman.storage.get('tokens');
+    if (!tokens || !tokens.refresh_token) {
+        return { error: 'No refresh token available' };
+    }
+
+    var clientId = shadowman.config.value('client_id');
+    var clientSecret = shadowman.config.value('client_secret');
+
+    if (!clientId || !clientSecret) {
+        return { error: 'OAuth client credentials missing' };
+    }
+
+    shadowman.log.info('Refreshing Zoho access token...');
+
+    var body = 'grant_type=refresh_token' +
+               '&client_id=' + encodeURIComponent(clientId) +
+               '&client_secret=' + encodeURIComponent(clientSecret) +
+               '&refresh_token=' + encodeURIComponent(tokens.refresh_token);
+
+    var resp = shadowman.http.post(ACCOUNTS_BASE + '/oauth/v2/token', body, {
+        headers: {
+            'Content-Type': 'application/x-www-form-urlencoded'
+        }
+    });
+
+    if (resp.status !== 200) {
+        shadowman.log.error('Token refresh failed: ' + resp.status + ' ' + resp.body);
+        return { error: 'Refresh failed: ' + resp.body };
+    }
+
+    var newTokens;
+    try {
+        newTokens = JSON.parse(resp.body);
+    } catch (e) {
+        return { error: 'Invalid refresh response: ' + resp.body };
+    }
+
+    var updated = {
+        access_token: newTokens.access_token,
+        refresh_token: tokens.refresh_token,
+        expires_at: Date.now() + (newTokens.expires_in * 1000),
+        api_domain: newTokens.api_domain || tokens.api_domain,
+        token_type: newTokens.token_type || 'Bearer'
+    };
+
+    shadowman.storage.set('tokens', updated);
+    shadowman.log.info('Zoho token refreshed successfully');
+
+    return updated;
+}
+
+module.exports = {
+    setupAuth: setupAuth,
+    getStatus: getStatus,
+    getValidToken: getValidToken
+};

+ 183 - 0
lib/cliq.js

@@ -0,0 +1,183 @@
+// Zoho Cliq API Module
+// EU data center: https://cliq.zoho.eu
+// API: https://www.zoho.com/cliq/help/restapi/v2/
+
+var API_BASE = 'https://cliq.zoho.eu';
+
+function makeRequest(token, method, path, jsonBody, queryParams) {
+    var url = API_BASE + path;
+
+    if (queryParams) {
+        var qs = [];
+        for (var key in queryParams) {
+            if (queryParams.hasOwnProperty(key) && queryParams[key] !== undefined) {
+                qs.push(encodeURIComponent(key) + '=' + encodeURIComponent(queryParams[key]));
+            }
+        }
+        if (qs.length > 0) {
+            url += '?' + qs.join('&');
+        }
+    }
+
+    var headers = {
+        'Authorization': 'Zoho-oauthtoken ' + token
+    };
+    var body = null;
+
+    if (jsonBody) {
+        body = JSON.stringify(jsonBody);
+        headers['Content-Type'] = 'application/json;charset=UTF-8';
+    }
+
+    var resp = shadowman.http.request(url, {
+        method: method,
+        body: body,
+        headers: headers
+    });
+
+    if (resp.status === 401) {
+        return { _tokenExpired: true };
+    }
+
+    if (resp.status >= 400) {
+        var raw = resp.body;
+        var msg;
+        if (typeof raw === 'string') {
+            try {
+                var p = JSON.parse(raw);
+                msg = p.error && p.error.message ? p.error.message : raw;
+            } catch (e) {
+                msg = raw;
+            }
+        } else if (raw && typeof raw === 'object') {
+            msg = (raw.error && raw.error.message) || raw.message || raw.description || JSON.stringify(raw);
+        } else {
+            msg = String(raw);
+        }
+        return { error: 'Zoho Cliq API error (' + resp.status + '): ' + msg };
+    }
+
+    if (resp.body && typeof resp.body === 'object') {
+        return resp.body;
+    }
+
+    if (!resp.body || (typeof resp.body === 'string' && resp.body.trim() === '')) {
+        return { success: true };
+    }
+
+    try {
+        return JSON.parse(resp.body);
+    } catch (e) {
+        return { error: 'Invalid JSON response: ' + resp.body };
+    }
+}
+
+function apiCall(token, method, path, jsonBody, queryParams) {
+    var result = makeRequest(token, method, path, jsonBody, queryParams);
+    if (result && result._tokenExpired) {
+        var auth = require('./auth');
+        var newToken = auth.getValidToken();
+        if (!newToken) {
+            return { error: 'Authentication expired and refresh failed. Re-run setup_auth.' };
+        }
+        return makeRequest(newToken, method, path, jsonBody, queryParams);
+    }
+    return result;
+}
+
+// --- List chats (channels + direct chats) ---
+// GET /api/v2/chats
+
+function listChats(token) {
+    return apiCall(token, 'GET', '/api/v2/chats');
+}
+
+// --- List channels ---
+// GET /api/v2/channels
+
+function listChannels(token) {
+    return apiCall(token, 'GET', '/api/v2/channels');
+}
+
+// --- Get messages from a chat ---
+// GET /api/v2/chats/{chat_id}/messages
+
+function getMessages(token, chatId) {
+    return apiCall(token, 'GET',
+        '/api/v2/chats/{CHATID}/messages'
+        .replace('{CHATID}', chatId));
+}
+
+// --- Post message to a chat ---
+// POST /api/v2/chats/{chat_id}/message  (singular!)
+
+function sendMessage(token, chatId, message) {
+    var url = API_BASE + '/api/v2/chats/' + chatId + '/message';
+    var body = JSON.stringify({ text: message });
+    var headers = {
+        'Authorization': 'Zoho-oauthtoken ' + token,
+        'Content-Type': 'application/json'
+    };
+    return makeRawRequest(token, 'POST', url, body, headers);
+}
+
+// --- Post message to a channel ---
+// POST /api/v2/channelsbyname/{channel_unique_name}/message
+
+function postToChannel(token, channelName, message) {
+    var url = API_BASE + '/api/v2/channelsbyname/' + channelName + '/message';
+    var body = JSON.stringify({ text: message });
+    var headers = {
+        'Authorization': 'Zoho-oauthtoken ' + token,
+        'Content-Type': 'application/json'
+    };
+    return makeRawRequest(token, 'POST', url, body, headers);
+}
+
+function makeRawRequest(token, method, url, body, headers) {
+    var resp = shadowman.http.request(url, {
+        method: method,
+        body: body,
+        headers: headers
+    });
+
+    if (resp.status >= 400) {
+        var raw = resp.body;
+        var msg;
+        if (typeof raw === 'string') {
+            try {
+                var p = JSON.parse(raw);
+                msg = p.error && p.error.message ? p.error.message : raw;
+            } catch (e) {
+                msg = raw;
+            }
+        } else if (raw && typeof raw === 'object') {
+            msg = (raw.error && raw.error.message) || raw.message || raw.description || JSON.stringify(raw);
+        } else {
+            msg = String(raw);
+        }
+        return { error: 'Zoho Cliq API error (' + resp.status + '): ' + msg };
+    }
+
+    if (resp.body && typeof resp.body === 'object') {
+        return resp.body;
+    }
+
+    if (!resp.body || (typeof resp.body === 'string' && resp.body.trim() === '')) {
+        return { success: true };
+    }
+
+    try {
+        return JSON.parse(resp.body);
+    } catch (e) {
+        return { error: 'Invalid JSON response: ' + resp.body };
+    }
+}
+
+module.exports = {
+    listChats: listChats,
+    listChannels: listChannels,
+    getMessages: getMessages,
+    sendMessage: sendMessage,
+    postToChannel: postToChannel
+};

+ 89 - 0
manifest.json

@@ -0,0 +1,89 @@
+{
+  "id": "zoho-cliq",
+  "name": "Zoho Cliq",
+  "description": "Zoho Cliq chat/messaging integration — send messages, list chats and history",
+  "version": "1.0.0",
+  "author": "Ferenc Szontagh & Zoë",
+  "sdkVersion": 1,
+  "entry": "index.js",
+  "runtime": "quickjs",
+  "permissions": {
+    "allowNetwork": true,
+    "allowStorage": true
+  },
+  "config": [
+    { "key": "client_id", "label": "Zoho OAuth Client ID", "required": true, "secret": true },
+    { "key": "client_secret", "label": "Zoho OAuth Client Secret", "required": true, "secret": true }
+  ],
+  "tools": [
+    {
+      "name": "setup_auth",
+      "description": "Exchange a Zoho grant token for access + refresh tokens. Run this ONCE after getting a grant token from the Zoho Developer Console.",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "grant_token": { "type": "string", "description": "Grant token from Zoho Developer Console (Self Client → Generate)" }
+        },
+        "required": ["grant_token"]
+      }
+    },
+    {
+      "name": "auth_status",
+      "description": "Check authentication status and token validity",
+      "parameters": { "type": "object", "properties": {} }
+    },
+    {
+      "name": "list_chats",
+      "description": "List recent chats in Zoho Cliq",
+      "parameters": {
+        "type": "object",
+        "properties": {}
+      }
+    },
+    {
+      "name": "list_channels",
+      "description": "List all channels in Zoho Cliq",
+      "parameters": {
+        "type": "object",
+        "properties": {}
+      }
+    },
+    {
+      "name": "send_message",
+      "description": "Send a message to a Zoho Cliq chat",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "chat_id": { "type": "string", "description": "Chat ID (e.g. CT_1375868514056111025_20106394584)" },
+          "message": { "type": "string", "description": "Message text" }
+        },
+        "required": ["chat_id", "message"]
+      }
+    },
+    {
+      "name": "get_messages",
+      "description": "Get messages from a specific Zoho Cliq chat",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "chat_id": { "type": "string", "description": "Chat ID" },
+          "index": { "type": "number", "description": "Page index (default 1)" }
+        },
+        "required": ["chat_id"]
+      }
+    },
+    {
+      "name": "post_to_channel",
+      "description": "Post a message to a Zoho Cliq channel by name",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "channel_name": { "type": "string", "description": "Channel unique name (e.g. announcements)" },
+          "message": { "type": "string", "description": "Message text" }
+        },
+        "required": ["channel_name", "message"]
+      }
+    }
+  ],
+  "skills": ["zoho-cliq"]
+}