/** * @node nextcloud-talk * @name Nextcloud Talk * @category integration * @version 1.0.0 * @description Post a message to a Nextcloud Talk conversation, or read the recent messages in one * @icon message-square */ const configSchema = { type: 'object', properties: { baseUrl: { type: 'string', title: 'Nextcloud URL', description: 'Address of the Nextcloud instance, e.g. https://cloud.example.com' }, credentialId: { type: 'string', title: 'Credential', description: 'A basic credential holding the username and an app password. Not needed for a bot webhook.', dynamicOptions: { source: 'credentials', filter: { type: 'basic' } } }, action: { type: 'string', title: 'Action', enum: ['send', 'read'], default: 'send', description: 'Post a message, or read the recent ones' }, token: { type: 'string', title: 'Conversation Token', description: 'The token in the conversation URL, e.g. the a1b2c3d4 in /call/a1b2c3d4' }, message: { type: 'string', title: 'Message', description: 'What to post. Supports {{variable}} interpolation.' }, replyTo: { type: 'string', title: 'Reply To', description: 'Post as a reply to this message id' }, silent: { type: 'boolean', title: 'Send Silently', description: 'Post without notifying participants', default: false }, limit: { type: 'integer', title: 'Limit', description: 'How many messages to read', default: 20, minimum: 1, maximum: 200 }, timeoutMs: { type: 'number', title: 'Timeout (ms)', default: 30000 } }, required: ['baseUrl', 'credentialId', 'token'] }; const inputSchema = { type: 'object', properties: { token: { type: 'string', description: 'Overrides the configured conversation' }, message: { type: 'string', description: 'Overrides the configured message' } } }; const outputSchema = { type: 'object', properties: { success: { type: 'boolean' }, messageId: { type: 'integer', description: 'The id Talk gave the posted message' }, token: { type: 'string', description: 'The conversation it was posted to' }, messages: { type: 'array', description: 'Recent messages, when reading', items: { type: 'object', properties: { id: { type: 'integer' }, actorId: { type: 'string' }, actorDisplayName: { type: 'string' }, message: { type: 'string' }, timestamp: { type: 'integer' } } } }, count: { type: 'integer', description: 'How many messages were read' } } }; const outputs = [ { name: 'main', displayName: 'Result', type: 'object', color: '#3b82f6' } ]; // A connection hands the previous node's whole output over as input.data, and // some nodes wrap their own return in a result object on top of that. Rather // than guess which shape arrived, the layers are flattened with the innermost // winning, so a field set anywhere in the chain is found. function incoming(input) { const isPlain = (v) => v && typeof v === 'object' && !Array.isArray(v); const merged = {}; for (const layer of [input, input && input.data, input && input.data && input.data.result]) { if (!isPlain(layer)) continue; for (const key of Object.keys(layer)) { merged[key] = layer[key]; } } return merged; } // Talk is reached through the OCS API, which refuses any request that does not // declare itself as an API call with this header, and returns XML unless asked // for JSON. function ocsHeaders(credential) { const headers = { 'OCS-APIRequest': 'true', 'Accept': 'application/json', 'Content-Type': 'application/json' }; if (credential && credential.headerName) { headers[credential.headerName] = credential.headerValue; } return headers; } function readCredential(credentialId) { const credential = smartbotic.credentials.get(credentialId); if (!credential || !credential.success) { throw new Error('Could not read the Nextcloud credential: ' + ((credential && credential.error) || 'unknown error')); } return credential; } // A Nextcloud that is unhappy answers with XML, or with the HTML of a login // page, and parsing that as JSON throws a bare syntax error that says nothing // about which request failed. The body is reported instead. function parseJsonResponse(response, what) { if (typeof response.data !== 'string') { return response.data; } try { return JSON.parse(response.data); } catch (e) { const snippet = response.data.substring(0, 200).replace(/\s+/g, ' '); throw new Error('Nextcloud Talk ' + what + ' failed: HTTP ' + response.status + ' returned ' + (response.headers && response.headers['content-type'] || 'an unreadable body') + ' instead of JSON: ' + snippet); } } // OCS wraps everything in ocs.meta plus ocs.data, and reports failure in the // meta rather than always in the status code. function unwrapOcs(response, what) { const body = parseJsonResponse(response, what); const ocs = body && body.ocs; if (!ocs) { throw new Error('Nextcloud Talk ' + what + ' failed: HTTP ' + response.status + ' returned no OCS envelope: ' + JSON.stringify(body).substring(0, 200)); } const status = ocs.meta && ocs.meta.statuscode; if (status && (status < 200 || status >= 300)) { throw new Error('Nextcloud Talk ' + what + ' failed: ' + ((ocs.meta && ocs.meta.message) || ('OCS ' + status))); } return ocs.data; } module.exports = { configSchema, inputSchema, outputSchema, outputs, async execute(config, input, context) { const from = incoming(input); const base = String(config.baseUrl || '').replace(/\/+$/, ''); if (!base) { throw new Error('The Nextcloud URL is required'); } const token = String(from.token || config.token || '').trim(); if (!token) { throw new Error('A conversation token is required'); } const credential = readCredential(config.credentialId); const headers = ocsHeaders(credential); const timeout = Number(config.timeoutMs) || 30000; const chatUrl = base + '/ocs/v2.php/apps/spreed/api/v1/chat/' + token; if ((config.action || 'send') === 'read') { const limit = Number(config.limit) || 20; // lookIntoFuture=0 asks for the messages already there rather than // holding the connection open waiting for the next one. const response = smartbotic.http.request({ method: 'GET', url: chatUrl + '?format=json&lookIntoFuture=0&limit=' + limit, headers: headers, timeout: timeout }); const data = unwrapOcs(response, 'read') || []; const messages = data.map(function (entry) { return { id: entry.id, actorId: entry.actorId, actorDisplayName: entry.actorDisplayName, message: entry.message, timestamp: entry.timestamp }; }); return { success: true, token: token, messages: messages, count: messages.length }; } const message = from.message || config.message || ''; if (!message) { throw new Error('A message is required'); } const payload = { message: message }; if (config.replyTo) { payload.replyTo = Number(config.replyTo); } if (config.silent === true) { payload.silent = true; } const response = smartbotic.http.request({ method: 'POST', url: chatUrl + '?format=json', headers: headers, body: JSON.stringify(payload), timeout: timeout }); const data = unwrapOcs(response, 'send') || {}; smartbotic.log.info('Nextcloud Talk: posted to conversation ' + token); return { success: true, messageId: data.id, token: token }; } };