├── .gitignore ├── .travis.yml ├── docs ├── ansible │ └── xmpp-bot │ │ ├── meta │ │ └── main.yml │ │ ├── tasks │ │ ├── nodejs.yml │ │ └── main.yml │ │ ├── README.md │ │ ├── defaults │ │ └── main.yml │ │ └── templates │ │ └── config.json.j2 ├── xmpp-bot.service └── xmpp-bot.conf ├── .eslintrc.json ├── lib ├── server.js ├── error │ └── index.js ├── config │ ├── index.js │ └── config.json.dist ├── logger │ └── index.js ├── outgoing │ └── index.js ├── xmpp │ └── index.js └── webhook │ └── index.js ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── test ├── server.js ├── config.json ├── outgoing.js ├── webhook.js └── xmpp.js ├── package.json ├── README.md └── LICENSE.md /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /lib/config/config.json 3 | /*.log 4 | /.nyc_output/ 5 | /coverage/ 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: jammy 2 | 3 | language: node_js 4 | 5 | node_js: 6 | - node 7 | - lts/* 8 | 9 | before_script: 10 | - cp test/config.json lib/config/config.json 11 | 12 | script: 13 | - npm run lint 14 | - npm run coveralls 15 | -------------------------------------------------------------------------------- /docs/ansible/xmpp-bot/meta/main.yml: -------------------------------------------------------------------------------- 1 | galaxy_info: 2 | author: Nioc 3 | description: Install XMPP bot 4 | issue_tracker_url: https://github.com/nioc/xmpp-bot/issues 5 | license: license (AGPL-3.0-or-later) 6 | min_ansible_version: 2.9 7 | galaxy_tags: [] 8 | 9 | dependencies: [] 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es6": true, 5 | "node": true, 6 | "mocha": true 7 | }, 8 | "extends": [ 9 | "standard" 10 | ], 11 | "globals": { 12 | "Atomics": "readonly", 13 | "SharedArrayBuffer": "readonly" 14 | }, 15 | "parserOptions": { 16 | "ecmaVersion": 2018 17 | }, 18 | "rules": { 19 | } 20 | } -------------------------------------------------------------------------------- /docs/xmpp-bot.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=XMPP Bot 3 | Documentation=https://github.com/nioc/xmpp-bot 4 | After=network.target 5 | 6 | [Service] 7 | User=xmpp-bot 8 | WorkingDirectory=/usr/local/bin/xmpp-bot/ 9 | ExecStart=/usr/bin/node /usr/local/bin/xmpp-bot/lib/server.js 10 | Restart=on-failure 11 | RestartSec=1000ms 12 | Environment=NODE_ENV=production 13 | SyslogIdentifier=xmpp-bot 14 | 15 | [Install] 16 | WantedBy=multi-user.target 17 | -------------------------------------------------------------------------------- /docs/xmpp-bot.conf: -------------------------------------------------------------------------------- 1 | # Fail2Ban configuration file for webhook log 2 | 3 | # Option: failregex 4 | # Notes.: regex to match the Unauthorized log entrys in webhook log (defined in config.json: webhooksListener.accessLog). 5 | # Values: TEXT 6 | # 7 | [Definition] 8 | failregex=^ - .* ".*" 401 \d* ".*" ".*"$ 9 | 10 | # Option: ignoreregex 11 | # Notes.: regex to ignore. If this regex matches, the line is ignored. 12 | # Values: TEXT 13 | # 14 | ignoreregex = 15 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // create default logger 4 | const logger = require('./logger')() 5 | 6 | // get configuration 7 | const config = require('./config')(logger) 8 | 9 | // update logger with configuration 10 | logger.updateConfig(config.logger) 11 | 12 | // output application version 13 | const { name, version } = require('./../package.json') 14 | logger.info(`Start ${name} service - version ${version}`) 15 | 16 | // load xmpp module 17 | const xmpp = require('./xmpp')(logger, config) 18 | 19 | // load webhook module 20 | require('./webhook')(logger, config, xmpp) 21 | 22 | // handle error and process ending 23 | require('./error')(logger, xmpp) 24 | -------------------------------------------------------------------------------- /docs/ansible/xmpp-bot/tasks/nodejs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Add NodeSource signing key 3 | become: yes 4 | apt_key: 5 | url: https://deb.nodesource.com/gpgkey/nodesource.gpg.key 6 | state: present 7 | 8 | - name: Add NodeSource APT repository 9 | become: yes 10 | apt_repository: 11 | repo: deb https://deb.nodesource.com/{{nodejs_repo}} {{distro}} main 12 | filename: nodesource 13 | update_cache: yes 14 | state: present 15 | vars: 16 | - distro: "{{ ansible_distribution_release | default('buster') }}" 17 | 18 | - name: Install Node.js packages 19 | become: yes 20 | apt: 21 | name: nodejs 22 | state: present 23 | cache_valid_time: 3600 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /lib/error/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Close handler 3 | * 4 | * Disconnect bot from XMPP server before app closes 5 | * 6 | * @file This files defines the closing handler 7 | * @author nioc 8 | * @since 1.0.0 9 | * @license AGPL-3.0+ 10 | */ 11 | 12 | module.exports = (logger, xmpp) => { 13 | const nodeCleanup = require('node-cleanup') 14 | nodeCleanup(function (exitCode, signal) { 15 | logger.warn(`Received ${exitCode}/${signal} (application is closing), disconnect from XMPP server`) 16 | try { 17 | xmpp.close() 18 | .then(() => { 19 | logger.debug('Connection successfully closed') 20 | }) 21 | .catch((error) => { 22 | logger.error('Error during XMPP disconnection', error) 23 | }) 24 | } catch (error) { 25 | logger.error('Error during XMPP disconnection: ' + error.message) 26 | } 27 | logger.debug('Synchronize logs file') 28 | logger.shutdown() 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[Bug]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Set configuration '...' 16 | 2. Start script '....' 17 | 3. Send message to bot '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Logs** 24 | Add logs output (trace or debug level) to help explain your problem. Please do not post your Jid or domain. 25 | 26 | **Environment (please complete the following information):** 27 | - Operating system [e.g. Debian Buster] 28 | - npm version (`npm -v`) [e.g. 6.12.1] 29 | - Node.js version (`node -v`) [e.g. v10.15.2] 30 | - Code version / commit reference [e.g. 2.0.0] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | process.env.NODE_ENV = 'production' 3 | require('chai').should() 4 | const mock = require('mock-require') 5 | const sinon = require('sinon') 6 | 7 | describe('Server', () => { 8 | let xmppStub, webhookStub 9 | 10 | before('Setup', (done) => { 11 | // mock XMPP component 12 | xmppStub = sinon.stub() 13 | webhookStub = sinon.stub() 14 | mock('./../lib/xmpp', () => { 15 | this.send = () => {} 16 | this.close = () => {} 17 | xmppStub() 18 | return this 19 | }) 20 | 21 | // mock webhook component 22 | mock('./../lib/webhook', webhookStub) 23 | 24 | done() 25 | }) 26 | 27 | after('Remove mock', () => { 28 | mock.stopAll() 29 | }) 30 | 31 | beforeEach('Reset stub', (done) => { 32 | xmppStub.resetHistory() 33 | webhookStub.resetHistory() 34 | done() 35 | }) 36 | 37 | describe('Start server', () => { 38 | it('Should call XMPP and webhook components', (done) => { 39 | require('../lib/server') 40 | sinon.assert.calledOnce(xmppStub) 41 | sinon.assert.calledOnce(webhookStub) 42 | done() 43 | }) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xmpp-bot", 3 | "version": "2.2.0", 4 | "description": "XMPP bot", 5 | "main": "./lib/server.js", 6 | "scripts": { 7 | "dev": "nodemon lib/server.js", 8 | "start": "NODE_ENV=production node lib/server.js", 9 | "lint": "eslint .", 10 | "test": "mocha", 11 | "cover": "nyc --reporter=html --reporter=text mocha", 12 | "coveralls": "nyc npm test && nyc report --reporter=text-lcov | coveralls" 13 | }, 14 | "engines": { 15 | "node": ">= 12.4.0" 16 | }, 17 | "author": "nioc ", 18 | "license": "AGPL-3.0-or-later", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/nioc/xmpp-bot.git" 22 | }, 23 | "private": true, 24 | "dependencies": { 25 | "@xmpp/client": "^0.13.1", 26 | "body-parser": "^1.20.0", 27 | "express": "^4.18.1", 28 | "express-basic-auth": "^1.2.1", 29 | "jmespath": "^0.16.0", 30 | "log4js": "^6.6.1", 31 | "morgan": "^1.10.0", 32 | "node-cleanup": "^2.1.2", 33 | "request": "^2.88.2" 34 | }, 35 | "devDependencies": { 36 | "chai": "^4.3.6", 37 | "coveralls": "^3.0.9", 38 | "eslint": "^8.24.0", 39 | "eslint-config-standard": "^17.0.0", 40 | "eslint-plugin-import": "^2.26.0", 41 | "eslint-plugin-node": "^11.1.0", 42 | "eslint-plugin-promise": "^6.0.1", 43 | "mocha": "^10.0.0", 44 | "mock-require": "^3.0.3", 45 | "nock": "^13.2.9", 46 | "nodemon": "^2.0.20", 47 | "nyc": "^15.1.0", 48 | "sinon": "^14.0.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /docs/ansible/xmpp-bot/README.md: -------------------------------------------------------------------------------- 1 | Ansible Role: XMPP Bot 2 | ====================== 3 | 4 | Install XMPP Bot: 5 | 6 | - install [Node.js](https://nodejs.org/), 7 | - install npm, 8 | - download archive, 9 | - install dependencies, 10 | - create service user, 11 | - set [configuration](https://github.com/nioc/xmpp-bot#configuration), 12 | - add as a systemd service. 13 | 14 | Requirements 15 | ------------ 16 | 17 | - Ansible >= 2.9, 18 | - a working XMPP server. 19 | 20 | Role Variables 21 | -------------- 22 | 23 | These variables are installation related and should be checked/updated before use: 24 | 25 | - `xmppbot_install_nodejs`: Does NodeJS should be installed, set `false` if already present, default: `true`, 26 | - `nodejs_repo`: NodeJS version to install, default: `node_12.x`. 27 | - `domain`: your domain name (not a role variable but **must be set** in your playbook/host), no default, 28 | 29 | For variables in `webhooks config`, `XMPP server config`, `outgoing webhooks config` sections, please see [configuration](https://github.com/nioc/xmpp-bot#configuration). 30 | 31 | 32 | Dependencies 33 | ------------ 34 | 35 | None. 36 | 37 | Example Playbook 38 | ---------------- 39 | 40 | - hosts: servers 41 | vars: 42 | domain: mydomain.ltd 43 | roles: 44 | - name: xmpp-bot 45 | xmppbot_incoming_webhooks: 46 | - path: /webhooks/alerting 47 | action: send_xmpp_message 48 | 49 | License 50 | ------- 51 | 52 | AGPL-3.0-or-later 53 | 54 | Author Information 55 | ------------------ 56 | 57 | This role was created in 2020 by [Nioc](https://github.com/nioc). 58 | -------------------------------------------------------------------------------- /lib/config/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration 3 | * 4 | * Handle configuration file 5 | * 6 | * @file This files defines the configuration 7 | * @author nioc 8 | * @since 1.0.0 9 | * @license AGPL-3.0+ 10 | */ 11 | 12 | module.exports = function Configuration (logger, configPath = null) { 13 | let config 14 | if (configPath === null) { 15 | configPath = './lib/config/config.json' 16 | } 17 | try { 18 | const data = require('fs').readFileSync(configPath) 19 | config = JSON.parse(data) 20 | } catch (error) { 21 | logger.fatal(`Invalid configuration file: ${error.message}, current directory is: ${process.cwd()}`) 22 | process.exit(99) 23 | } 24 | return { 25 | listener: { 26 | path: config.webhooksListener.path, 27 | port: config.webhooksListener.port, 28 | ssl: config.webhooksListener.ssl, 29 | log: config.webhooksListener.accessLog, 30 | users: config.webhooksListener.users.reduce((acc, user) => { 31 | acc[user.login] = user.password 32 | return acc 33 | }, {}) 34 | }, 35 | xmpp: config.xmppServer, 36 | logger: config.logger, 37 | getWebhookAction: (path) => { 38 | return config.incomingWebhooks.find((webhook) => { 39 | return (webhook.path === path) 40 | }) 41 | }, 42 | getOutgoingWebhook: (code) => { 43 | return config.outgoingWebhooks.find((webhook) => { 44 | return (webhook.code === code) 45 | }) 46 | }, 47 | getXmppHookAction: (room) => { 48 | return config.xmppHooks.find((xmppHook) => { 49 | return (xmppHook.room === room) 50 | }) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /docs/ansible/xmpp-bot/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # installation: 3 | xmppbot_install_nodejs: true 4 | nodejs_repo: node_12.x 5 | xmppbot_version: HEAD 6 | xmppbot_git_url: https://github.com/nioc/xmpp-bot.git 7 | # global config: 8 | xmppbot_dir: /usr/local/bin/xmpp-bot 9 | xmppbot_user: xmpp-bot 10 | xmppbot_log_dir: /var/log/xmpp-bot 11 | # webhooks config: 12 | xmppbot_webhook_path: '/webhooks' 13 | xmppbot_webhook_port: '8000' 14 | xmppbot_webhook_port_ssl: '8001' 15 | xmppbot_webhook_certpath: /etc/ssl/certs/ssl-cert-snakeoil.pem 16 | xmppbot_webhook_keypath: /etc/ssl/private/ssl-cert-snakeoil.key 17 | xmppbot_webhook_users: 18 | login1: 1pass 19 | login2: 2pass 20 | xmppbot_incoming_webhooks: 21 | - path: /webhooks/w1 22 | action: send_xmpp_message 23 | - path: /webhooks/grafana 24 | action: send_xmpp_template 25 | args: 26 | destination: "grafana@conference.domain-xmpp.ltd" 27 | type: "groupchat" 28 | template: "${title}\r\n${message}\r\n${evalMatches[].metric}: ${evalMatches[].value}\r\n${imageUrl}" 29 | # XMPP server config: 30 | xmppbot_xmpp_server: 31 | service: xmpps://domain-xmpp.ltd:5223 32 | domain: domain-xmpp.ltd 33 | username: bot@domain-xmpp.ltd 34 | password: botPass 35 | rooms: 36 | - id: roomname@conference.domain-xmpp.ltd 37 | password: 'null' 38 | xmppbot_xmpp_hooks: 39 | - room: bot@domain-xmpp.ltd 40 | action: outgoing_webhook 41 | args: '["w1"]' 42 | - room: roomname@conference.domain-xmpp.ltd 43 | action: outgoing_webhook 44 | args: '["w1"]' 45 | xmppbot_xmpp_error_reply: Oops, something went wrong :( 46 | xmppbot_xmpp_resource: botservice 47 | # outgoing webhooks config: 48 | xmppbot_outgoing_webhooks: 49 | - code: 'w1' 50 | url: 'https://domain.ltd:port/path/resource?parameter1=value1' 51 | timeout: '500' 52 | strictSSL: 'true' 53 | contentType: 'application/json' 54 | authMethod: 'basic' 55 | user: 'user3' 56 | password: '3pass' 57 | bearer: 'null' -------------------------------------------------------------------------------- /docs/ansible/xmpp-bot/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install and configure NodeJS LTS 3 | include_tasks: nodejs.yml 4 | when: xmppbot_install_nodejs 5 | 6 | - name: Install/update npm package globally 7 | become: yes 8 | npm: 9 | name: npm 10 | state: latest 11 | global: yes 12 | 13 | - name: Get XMPP bot code from Git repo 14 | become: yes 15 | git: 16 | repo: '{{xmppbot_git_url}}' 17 | version: '{{xmppbot_version}}' 18 | dest: '{{xmppbot_dir}}/' 19 | force: yes 20 | 21 | - name: Install XMPP bot based on package.json 22 | become: yes 23 | npm: 24 | path: '{{xmppbot_dir}}' 25 | production: yes 26 | 27 | - name: Add XMPP bot user "{{xmppbot_user}}" 28 | become: yes 29 | user: 30 | name: '{{xmppbot_user}}' 31 | system: yes 32 | shell: /bin/false 33 | 34 | - name: Set configuration file 35 | become: yes 36 | template: 37 | src: config.json.j2 38 | dest: '{{xmppbot_dir}}/lib/config/config.json' 39 | 40 | - name: Set permissions 41 | become: yes 42 | file: 43 | path: '{{xmppbot_dir}}/' 44 | state: directory 45 | owner: '{{xmppbot_user}}' 46 | group: '{{xmppbot_user}}' 47 | recurse: yes 48 | 49 | - name: Creates XMPP bot logs folder 50 | become: yes 51 | file: 52 | path: '{{xmppbot_log_dir}}' 53 | state: directory 54 | owner: '{{xmppbot_user}}' 55 | group: '{{xmppbot_user}}' 56 | 57 | - name: Create XMPP bot service 58 | become: yes 59 | copy: 60 | src: '{{xmppbot_dir}}/docs/xmpp-bot.service' 61 | dest: /usr/lib/systemd/system/xmpp-bot.service 62 | remote_src: yes 63 | 64 | - name: Tune service (dir and running user) 65 | become: yes 66 | lineinfile: 67 | path: /usr/lib/systemd/system/xmpp-bot.service 68 | regexp: '{{item.regexp}}' 69 | line: '{{item.line}}' 70 | state: present 71 | with_items: 72 | - regexp: '^User=' 73 | line: 'User={{xmppbot_user}}' 74 | - regexp: '^WorkingDirectory=' 75 | line: 'WorkingDirectory={{xmppbot_dir}}' 76 | - regexp: '^ExecStart=' 77 | line: 'ExecStart=/usr/bin/node {{xmppbot_dir}}/lib/server.js' 78 | 79 | - name: Enable XMPP bot service 80 | become: yes 81 | systemd: 82 | name: xmpp-bot 83 | enabled: yes 84 | state: started 85 | daemon_reload: yes -------------------------------------------------------------------------------- /lib/logger/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Logger 3 | * 4 | * Create logger 5 | * 6 | * @file This files defines the logger 7 | * @author nioc 8 | * @since 1.0.0 9 | * @license AGPL-3.0+ 10 | */ 11 | 12 | module.exports = () => { 13 | // default configuration (info to stdout) 14 | const log4js = require('log4js') 15 | let logger = log4js.getLogger() 16 | logger.level = 'info' 17 | 18 | // function updating logger with configuration 19 | logger.updateConfig = (config) => { 20 | // coloured console in dev 21 | if (process.env.NODE_ENV !== 'production') { 22 | config.console.active = true 23 | config.stdout.active = false 24 | config.level = 'trace' 25 | } 26 | 27 | // add appenders 28 | const appenders = [] 29 | if (config.file.active) { 30 | const fs = require('fs') 31 | if (!fs.existsSync(config.file.path)) { 32 | try { 33 | fs.mkdirSync(config.file.path) 34 | } catch (error) { 35 | logger.fatal(`Can not write logs: ${error.message}`) 36 | process.exit(99) 37 | } 38 | } 39 | appenders.push('file') 40 | } 41 | let layout = 'basic' 42 | if (config.console.active) { 43 | if (config.console.coloured) { 44 | layout = 'coloured' 45 | } 46 | appenders.push('console') 47 | } 48 | if (config.stdout.active) { 49 | appenders.push('stdout') 50 | } 51 | if (appenders.length === 0) { 52 | logger.fatal('App require at least one log appender') 53 | process.exit(99) 54 | } 55 | 56 | // configure logger 57 | try { 58 | log4js.configure({ 59 | appenders: { 60 | console: { 61 | type: 'console', 62 | layout: { type: layout } 63 | }, 64 | stdout: { 65 | type: 'stdout', 66 | layout: { type: 'pattern', pattern: config.stdout.pattern } 67 | }, 68 | file: { 69 | type: 'file', 70 | layout: { type: 'pattern', pattern: config.file.pattern }, 71 | filename: config.file.path + config.file.filename, 72 | maxLogSize: 1048576 73 | } 74 | }, 75 | categories: { 76 | default: { appenders, level: 'info' } 77 | } 78 | }) 79 | logger = log4js.getLogger() 80 | } catch (error) { 81 | logger.error(`Invalid logs config: ${error.message}`) 82 | process.exit(99) 83 | } 84 | 85 | logger.level = config.level 86 | } 87 | 88 | // synchronize logs before closing app 89 | logger.shutdown = (cb) => { 90 | log4js.shutdown(cb) 91 | } 92 | 93 | return logger 94 | } 95 | -------------------------------------------------------------------------------- /lib/outgoing/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Slack webhooks component 3 | * 4 | * Create webhooks publisher 5 | * 6 | * @file This files defines the webhooks publisher 7 | * @author nioc 8 | * @since 1.0.0 9 | * @license AGPL-3.0+ 10 | */ 11 | 12 | module.exports = async (logger, config, xmpp, user, destination, message, type, code) => { 13 | const webhook = config.getOutgoingWebhook(code) 14 | if (!webhook) { 15 | logger.warn(`There is no webhook with code "${code}"`) 16 | throw new Error(`There is no webhook with code "${code}"`) 17 | } 18 | const { promisify } = require('util') 19 | const request = promisify(require('request')) 20 | // request.debug = true 21 | const options = { 22 | method: 'POST', 23 | url: webhook.url, 24 | strictSSL: webhook.strictSSL 25 | } 26 | logger.trace('Outgoing webhook url:', webhook.url) 27 | switch (webhook.authMethod) { 28 | case 'basic': 29 | logger.trace(`Basic auth method user: ${webhook.user} pass: ${webhook.password}`) 30 | options.auth = { 31 | user: webhook.user, 32 | pass: webhook.password 33 | } 34 | break 35 | case 'bearer': 36 | logger.trace(`Bearer token auth method bearer: ${webhook.bearer}`) 37 | options.auth = { 38 | user: null, 39 | pass: null, 40 | sendImmediately: true, 41 | bearer: webhook.bearer 42 | } 43 | break 44 | 45 | default: 46 | break 47 | } 48 | switch (webhook.contentType) { 49 | case 'application/x-www-form-urlencoded': 50 | logger.trace('Content-type: application/x-www-form-urlencoded') 51 | options.form = { 52 | from: user, 53 | message, 54 | channel: destination 55 | } 56 | logger.trace('Outgoing webhook request:', options.form) 57 | break 58 | case 'application/json': 59 | logger.trace('Content-type: application/json') 60 | options.json = { 61 | from: user, 62 | message, 63 | channel: destination 64 | } 65 | logger.trace('Outgoing webhook request:', options.json) 66 | break 67 | default: 68 | break 69 | } 70 | options.timeout = webhook.timeout || 5000 71 | logger.trace('Outgoing webhook options:', options) 72 | try { 73 | const { statusCode, body } = await request(options) 74 | if (statusCode === 200) { 75 | logger.trace('Response:', body) 76 | if (body && typeof (body) === 'object' && 'reply' in body === true) { 77 | logger.debug(`There is a reply to send back in chat ${destination}: ${body.reply.replace(/\n|\r/g, ' ')}`) 78 | xmpp.send(destination, body.reply, type) 79 | return `Message sent. There is a reply to send back in chat ${destination}: ${body.reply}` 80 | } 81 | return 'Message sent' 82 | } 83 | throw new Error(`HTTP error: ${statusCode}`) 84 | } catch (error) { 85 | logger.error(`Error during outgoing webhook request: ${error.message}`) 86 | throw error 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /lib/config/config.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "logger": { 3 | "level": "debug", 4 | "file": { 5 | "active": false, 6 | "pattern": "%d %p %m", 7 | "path": "/var/log/xmpp-bot/", 8 | "filename": "xmpp-bot.log" 9 | }, 10 | "console": { 11 | "active": false, 12 | "coloured": true 13 | }, 14 | "stdout": { 15 | "active": true, 16 | "pattern": "%p %m" 17 | } 18 | }, 19 | "webhooksListener": { 20 | "path": "/webhooks", 21 | "port": 8000, 22 | "ssl": { 23 | "port": 8001, 24 | "certPath": "/etc/ssl/certs/ssl-cert-snakeoil.pem", 25 | "keyPath": "/etc/ssl/private/ssl-cert-snakeoil.key" 26 | }, 27 | "users": [ 28 | { 29 | "login": "login1", 30 | "password": "1pass" 31 | }, 32 | { 33 | "login": "login2", 34 | "password": "2pass" 35 | } 36 | ], 37 | "accessLog": { 38 | "active": true, 39 | "path": "/var/log/xmpp-bot/", 40 | "filename": "webhook.log" 41 | } 42 | }, 43 | "xmppServer": { 44 | "service": "xmpps://domain-xmpp.ltd:5223", 45 | "domain": "domain-xmpp.ltd", 46 | "username": "bot@domain-xmpp.ltd", 47 | "password": "botPass", 48 | "resource": "botservice", 49 | "errorReply": "Oops, something went wrong :(", 50 | "rooms": [ 51 | { 52 | "id": "roomname@conference.domain-xmpp.ltd", 53 | "password": null 54 | } 55 | ] 56 | }, 57 | "incomingWebhooks": [ 58 | { 59 | "path": "/webhooks/w1", 60 | "action": "send_xmpp_message" 61 | }, 62 | { 63 | "path": "/webhooks/grafana", 64 | "action": "send_xmpp_template", 65 | "args": { 66 | "destination": "grafana@conference.domain-xmpp.ltd", 67 | "type": "groupchat" 68 | }, 69 | "template": "${title}\r\n${message}\r\n${evalMatches[].metric}: ${evalMatches[].value}\r\n${imageUrl}" 70 | } 71 | ], 72 | "xmppHooks": [ 73 | { 74 | "room": "bot@domain-xmpp.ltd", 75 | "action": "outgoing_webhook", 76 | "args": ["w1"] 77 | }, 78 | { 79 | "room": "roomname@conference.domain-xmpp.ltd", 80 | "action": "outgoing_webhook", 81 | "args": ["w1"] 82 | } 83 | ], 84 | "outgoingWebhooks": [ 85 | { 86 | "code": "w1", 87 | "url": "https://domain.ltd:port/path/resource?parameter1=value1", 88 | "timeout": 500, 89 | "strictSSL": true, 90 | "contentType": "application/json", 91 | "authMethod": "basic", 92 | "user": "user3", 93 | "password": "3pass", 94 | "bearer": null 95 | } 96 | ] 97 | } 98 | -------------------------------------------------------------------------------- /docs/ansible/xmpp-bot/templates/config.json.j2: -------------------------------------------------------------------------------- 1 | { 2 | "logger": { 3 | "level": "debug", 4 | "file": { 5 | "active": false, 6 | "pattern": "%d %p %m%n", 7 | "path": "{{xmppbot_log_dir}}/", 8 | "filename": "xmpp-bot.log" 9 | }, 10 | "console": { 11 | "active": false, 12 | "coloured": true 13 | }, 14 | "stdout": { 15 | "active": true, 16 | "pattern": "%p %m" 17 | } 18 | }, 19 | "webhooksListener": { 20 | "path": "{{xmppbot_webhook_path}}", 21 | "port": {{xmppbot_webhook_port}}, 22 | "ssl": { 23 | "port": {{xmppbot_webhook_port_ssl}}, 24 | "certPath": "{{xmppbot_webhook_certpath}}", 25 | "keyPath": "{{xmppbot_webhook_keypath}}" 26 | }, 27 | "users": [{% for login, passwd in xmppbot_webhook_users.iteritems() %} 28 | 29 | { 30 | "login": "{{login}}", 31 | "password": "{{passwd}}" 32 | }{% if not loop.last %},{% endif %}{% endfor %} 33 | 34 | ], 35 | "accessLog": { 36 | "active": true, 37 | "path": "{{xmppbot_log_dir}}/", 38 | "filename": "webhook.log" 39 | } 40 | }, 41 | "xmppServer": { 42 | "service": "{{xmppbot_xmpp_server.service}}", 43 | "domain": "{{xmppbot_xmpp_server.domain}}", 44 | "username": "{{xmppbot_xmpp_server.username}}", 45 | "password": "{{xmppbot_xmpp_server.password}}", 46 | "resource": "{{xmppbot_xmpp_resource}}", 47 | "errorReply": "{{xmppbot_xmpp_error_reply}}", 48 | "rooms": [{% for room in xmppbot_xmpp_server.rooms %} 49 | 50 | { 51 | "id": "{{room.id}}", 52 | "password": {{room.password}} 53 | }{% if not loop.last %},{% endif %}{% endfor %} 54 | 55 | ] 56 | }, 57 | "incomingWebhooks": [{% for webhook in xmppbot_incoming_webhooks %} 58 | 59 | { 60 | "path": "{{webhook.path}}", 61 | {% if webhook.args is defined -%} 62 | "args": { {% for key, value in webhook.args.iteritems() %} 63 | 64 | "{{key}}": "{{value}}"{% if not loop.last %},{% endif %}{% endfor %} 65 | 66 | }, 67 | {% endif -%} 68 | {% if webhook.template is defined -%} 69 | "template": {{webhook.template|tojson}}, 70 | {% endif -%} 71 | "action": "{{webhook.action}}" 72 | }{% if not loop.last %},{% endif %}{% endfor %} 73 | 74 | ], 75 | "xmppHooks": [{% for xmpp_hook in xmppbot_xmpp_hooks %} 76 | 77 | { 78 | "room": "{{xmpp_hook.room}}", 79 | "action": "{{xmpp_hook.action}}", 80 | "args": {{xmpp_hook.args}} 81 | }{% if not loop.last %},{% endif %}{% endfor %} 82 | 83 | ], 84 | "outgoingWebhooks": [{% for outgoing_webhook in xmppbot_outgoing_webhooks %} 85 | 86 | { 87 | "code": "{{outgoing_webhook.code}}", 88 | "url": "{{outgoing_webhook.url}}", 89 | "timeout": {{outgoing_webhook.timeout}}, 90 | "strictSSL": {{outgoing_webhook.strictSSL}}, 91 | "contentType": "{{outgoing_webhook.contentType}}", 92 | "authMethod": "{{outgoing_webhook.authMethod}}", 93 | "user": "{{outgoing_webhook.user}}", 94 | "password": "{{outgoing_webhook.password}}", 95 | "bearer": {{outgoing_webhook.bearer}} 96 | }{% if not loop.last %},{% endif %}{% endfor %} 97 | 98 | ] 99 | } 100 | -------------------------------------------------------------------------------- /test/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "logger": { 3 | "level": "error", 4 | "file": { 5 | "active": true, 6 | "pattern": "%d %p %m", 7 | "path": "./", 8 | "filename": "xmpp-bot.log" 9 | }, 10 | "console": { 11 | "active": false, 12 | "coloured": true 13 | }, 14 | "stdout": { 15 | "active": false, 16 | "pattern": "%p %m" 17 | } 18 | }, 19 | "webhooksListener": { 20 | "path": "/webhooks", 21 | "port": 8000, 22 | "ssl": { 23 | "port": null, 24 | "certPath": "/etc/ssl/certs/ssl-cert-snakeoil.pem", 25 | "keyPath": "/etc/ssl/private/ssl-cert-snakeoil.key" 26 | }, 27 | "users": [ 28 | { 29 | "login": "login1", 30 | "password": "1pass" 31 | }, 32 | { 33 | "login": "login2", 34 | "password": "2pass" 35 | } 36 | ], 37 | "accessLog": { 38 | "active": true, 39 | "path": "./", 40 | "filename": "webhook.log" 41 | } 42 | }, 43 | "xmppServer": { 44 | "service": "xmpps://domain-xmpp.ltd:5223", 45 | "domain": "domain-xmpp.ltd", 46 | "username": "bot@domain-xmpp.ltd", 47 | "password": "botPass", 48 | "resource": "botservice", 49 | "errorReply": "Oops, something went wrong :(", 50 | "rooms": [ 51 | { 52 | "id": "roomname@conference.domain-xmpp.ltd", 53 | "password": null 54 | } 55 | ] 56 | }, 57 | "incomingWebhooks": [ 58 | { 59 | "path": "/webhooks/w1", 60 | "action": "send_xmpp_message" 61 | }, 62 | { 63 | "path": "/webhooks/grafana", 64 | "action": "send_xmpp_template", 65 | "args": { 66 | "destination": "grafana@conference.domain-xmpp.ltd", 67 | "type": "groupchat" 68 | }, 69 | "template": "${title}\r\n${message}\r\n${evalMatches[].metric}: ${evalMatches[].value}\r\n${imageUrl}" 70 | }, 71 | { 72 | "path": "/webhooks/dummy", 73 | "action": "dummy" 74 | } 75 | ], 76 | "xmppHooks": [ 77 | { 78 | "room": "bot@domain-xmpp.ltd", 79 | "action": "outgoing_webhook", 80 | "args": ["w1"] 81 | }, 82 | { 83 | "room": "roomname@conference.domain-xmpp.ltd", 84 | "action": "outgoing_webhook", 85 | "args": ["w1"] 86 | } 87 | ], 88 | "outgoingWebhooks": [ 89 | { 90 | "code": "basic-json-reply", 91 | "url": "https://domain.ltd:port/path/resource", 92 | "strictSSL": true, 93 | "contentType": "application/json", 94 | "authMethod": "basic", 95 | "user": "user3", 96 | "password": "3pass", 97 | "bearer": null 98 | }, 99 | { 100 | "code": "bearer-form", 101 | "url": "https://domain.ltd:port/path/resource", 102 | "strictSSL": true, 103 | "contentType": "application/x-www-form-urlencoded", 104 | "authMethod": "bearer", 105 | "user": null, 106 | "password": null, 107 | "bearer": "abcdefgh" 108 | }, 109 | { 110 | "code": "protected", 111 | "url": "https://domain.ltd:port/path/protectedresource", 112 | "strictSSL": true, 113 | "contentType": "application/json", 114 | "authMethod": null, 115 | "user": null, 116 | "password": null, 117 | "bearer": null 118 | }, 119 | { 120 | "code": "request-error", 121 | "url": "https://domain.ltd:port/path/request-error", 122 | "strictSSL": true, 123 | "contentType": "application/json", 124 | "authMethod": null, 125 | "user": null, 126 | "password": null, 127 | "bearer": null 128 | }, 129 | { 130 | "code": "timeout-error", 131 | "url": "https://domain.ltd:port/path/timeout-error", 132 | "timeout": 500, 133 | "strictSSL": true, 134 | "contentType": "application/json", 135 | "authMethod": null, 136 | "user": null, 137 | "password": null, 138 | "bearer": null 139 | } 140 | ] 141 | } 142 | -------------------------------------------------------------------------------- /lib/xmpp/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * XMPP bot 3 | * 4 | * Create XMPP client, connect bot to server and handle interactions 5 | * 6 | * @exports xmpp 7 | * @file This files defines the XMPP bot 8 | * @author nioc 9 | * @since 2.0.0 10 | * @license AGPL-3.0+ 11 | */ 12 | 13 | module.exports = (logger, config) => { 14 | const { client, xml, jid } = require('@xmpp/client') 15 | const outgoing = require('../outgoing') 16 | this.jid = null 17 | 18 | // declare send chat/groupchat function 19 | this.send = async (to, message, type) => { 20 | logger.info(`Send ${type} message to ${to}`) 21 | logger.debug(`Send ${type} message to ${to}: '${message.replace(/\n|\r/g, ' ')}'`) 22 | const stanza = xml( 23 | 'message', { 24 | to, 25 | type 26 | }, 27 | xml( 28 | 'body', { 29 | }, 30 | message) 31 | ) 32 | await xmppClient.send(stanza) 33 | logger.debug(`${type} message successfully sent to ${to}`) 34 | } 35 | 36 | // declare close function 37 | this.close = async () => { 38 | await xmppClient.stop() 39 | } 40 | 41 | // create XMPP client 42 | const xmppClient = client(config.xmpp) 43 | 44 | // handle connection 45 | xmppClient.on('online', (address) => { 46 | logger.info(`XMPP connected on ${config.xmpp.service} with JID: ${address.toString()}`) 47 | this.jid = address 48 | // send presence 49 | xmppClient.send(xml('presence')) 50 | .then(() => { 51 | logger.debug('presence sent') 52 | }) 53 | .catch((error) => { 54 | logger.warn('presence returned following error:', error) 55 | }) 56 | // join rooms 57 | config.xmpp.rooms.forEach(function (room) { 58 | const occupantJid = room.id + '/' + address.local 59 | logger.debug(`Join room: ${room.id} ('${occupantJid}')`) 60 | const stanza = xml( 61 | 'presence', { 62 | to: occupantJid 63 | }, 64 | xml( 65 | 'x', { 66 | xmlns: 'http://jabber.org/protocol/muc' 67 | } 68 | ) 69 | ) 70 | xmppClient.send(stanza) 71 | logger.info(`Joined room: ${room.id}`) 72 | }) 73 | }) 74 | 75 | // handle stanzas 76 | xmppClient.on('stanza', stanza => { 77 | if (!stanza.is('message')) { 78 | // not a message, do nothing 79 | return 80 | } 81 | const type = stanza.attrs.type 82 | switch (type) { 83 | case 'chat': 84 | case 'groupchat': { 85 | const body = stanza.getChild('body') 86 | if (!body) { 87 | // empty body, do nothing 88 | return 89 | } 90 | const fromJid = jid(stanza.attrs.from) 91 | // for chat, "to" and "replyTo" must be something like "user@domain.ltd", "from" is local part "user" 92 | let to = this.jid.bare() 93 | let from = fromJid.local 94 | let replyTo = fromJid.bare() 95 | if (type === 'groupchat') { 96 | // for groupchat, "to" and "replyTo" is conference name, "from" is nickname 97 | to = fromJid.bare() 98 | from = fromJid.getResource() 99 | replyTo = to 100 | if (from === this.jid.local || stanza.getChild('delay')) { 101 | // message from bot or old message, do nothing 102 | return 103 | } 104 | } 105 | const message = body.text() 106 | // handle message delivery receipts for chat 107 | if (type === 'chat') { 108 | const request = stanza.getChild('request') 109 | if (request && 110 | request.attrs.xmlns && 111 | request.attrs.xmlns === 'urn:xmpp:receipts' && 112 | stanza.attrs.id) { 113 | logger.debug('Message delivery receipt is requested and will be processed') 114 | const receiptStanza = xml( 115 | 'message', { 116 | to: fromJid 117 | }, 118 | xml( 119 | 'received', { 120 | xmlns: 'urn:xmpp:receipts', 121 | id: stanza.attrs.id 122 | } 123 | ) 124 | ) 125 | xmppClient.send(receiptStanza) 126 | } 127 | } 128 | logger.info(`Incoming ${type} message from ${from} (${fromJid.toString()}) to ${to}`) 129 | logger.debug(`Message: "${message.replace(/\n|\r/g, ' ')}"`) 130 | const xmppHook = config.getXmppHookAction(to.toString()) 131 | if (!xmppHook) { 132 | logger.error(`There is no action for incoming ${type} message to: "${to}"`) 133 | return 134 | } 135 | switch (xmppHook.action) { 136 | case 'outgoing_webhook': 137 | logger.info(`Call outgoing webhook: ${xmppHook.args[0]}`) 138 | outgoing(logger, config, this, from.toString(), replyTo.toString(), message, type, xmppHook.args[0]) 139 | .catch(() => { 140 | this.send(replyTo.toString(), config.xmpp.errorReply, type) 141 | }) 142 | break 143 | default: 144 | break 145 | } 146 | break 147 | } 148 | } 149 | }) 150 | 151 | // handle status 152 | xmppClient.on('status', (status) => { 153 | logger.trace(`Status changed to ${status}`) 154 | }) 155 | 156 | // trace input/output 157 | // xmppClient.on('input', (input) => { 158 | // logger.trace('<<<<', input) 159 | // }) 160 | // xmppClient.on('output', (output) => { 161 | // logger.trace('>>>', output) 162 | // }) 163 | 164 | // handle error 165 | xmppClient.on('error', (err) => { 166 | logger.error('XMPP client encountered following error:', err.message) 167 | process.exit(99) 168 | }) 169 | 170 | // connect 171 | xmppClient.start() 172 | .catch((error) => { 173 | logger.error('XMPP client encountered following error at connection:', error.message) 174 | }) 175 | 176 | return this 177 | } 178 | -------------------------------------------------------------------------------- /test/outgoing.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* eslint-disable handle-callback-err */ 3 | process.env.NODE_ENV = 'production' 4 | 5 | const sinon = require('sinon') 6 | const should = require('chai').should() 7 | const nock = require('nock') 8 | const Outgoing = require('./../lib/outgoing') 9 | 10 | describe('Outgoing webhook component', () => { 11 | let logger, config, xmppSendStub, xmpp, scope, scopeUnauthorized, scopeWithError, scopeWithTimeout, reqSpy 12 | 13 | before('Setup', () => { 14 | // create default logger 15 | logger = require('./../lib/logger')() 16 | 17 | // get configuration 18 | config = require('./../lib/config')(logger, './test/config.json') 19 | 20 | // update logger with configuration 21 | logger.updateConfig(config.logger) 22 | 23 | // mock xmpp component 24 | xmppSendStub = sinon.stub() 25 | xmpp = { 26 | send: xmppSendStub 27 | } 28 | 29 | // spy nock requests 30 | reqSpy = sinon.spy() 31 | }) 32 | 33 | beforeEach('Reset XMPP stub history', function (done) { 34 | xmppSendStub.resetHistory() 35 | reqSpy.resetHistory() 36 | 37 | // mock remote server 38 | scope = nock('https://domain.ltd:port') 39 | .post('/path/resource') 40 | .reply(200, { reply: 'This is a reply' }) 41 | scope.on('request', reqSpy) 42 | 43 | scopeUnauthorized = nock('https://domain.ltd:port') 44 | .post('/path/protectedresource') 45 | .reply(401, {}) 46 | scopeUnauthorized.on('request', reqSpy) 47 | 48 | scopeWithError = nock('https://domain.ltd:port') 49 | .post('/path/request-error') 50 | .replyWithError('error in request') 51 | scopeWithError.on('request', reqSpy) 52 | 53 | scopeWithTimeout = nock('https://domain.ltd:port') 54 | .post('/path/timeout-error') 55 | .delay(1000) 56 | .reply(200, { reply: 'This is a reply' }) 57 | scopeWithTimeout.on('request', reqSpy) 58 | 59 | done() 60 | }) 61 | 62 | describe('Unkwnow outgoing webhook', () => { 63 | it('Should throw an exception and not execute request', async () => { 64 | try { 65 | await Outgoing(logger, config, xmpp, 'user', 'destination', 'message', 'type', 'unknownCode') 66 | should.fail(0, 1, 'Exception not thrown') 67 | } catch (error) { 68 | error.message.should.equal('There is no webhook with code "unknownCode"') 69 | } 70 | sinon.assert.notCalled(reqSpy) 71 | }) 72 | }) 73 | 74 | describe('POST with basic authorization and JSON content-type and reply message to XMPP', () => { 75 | it('Should send basic authentication and JSON content-type in header and send an XMPP message', async () => { 76 | let result 77 | try { 78 | result = await Outgoing(logger, config, xmpp, 'user', 'destination', 'This a first message', 'type', 'basic-json-reply') 79 | } catch (error) { 80 | should.fail(0, 1, 'Exception is thrown') 81 | } 82 | result.should.equal('Message sent. There is a reply to send back in chat destination: This is a reply') 83 | sinon.assert.calledOnce(reqSpy) 84 | const req = reqSpy.args[0][0] 85 | const bodyReq = JSON.parse(reqSpy.args[0][2]) 86 | req.headers.authorization.should.equal('Basic dXNlcjM6M3Bhc3M=') 87 | req.headers['content-type'].should.equal('application/json') 88 | bodyReq.from.should.equal('user') 89 | bodyReq.channel.should.equal('destination') 90 | bodyReq.message.should.equal('This a first message') 91 | sinon.assert.calledOnce(xmppSendStub) 92 | const xmppSendArgs = xmppSendStub.args[0] 93 | xmppSendArgs[0].should.equal('destination') 94 | xmppSendArgs[1].should.equal('This is a reply') 95 | xmppSendArgs[2].should.equal('type') 96 | }) 97 | }) 98 | 99 | describe('POST with bearer authorization and form-urlencoded content-type', () => { 100 | it('Should send bearer authentication and form-urlencoded content-type in header', async () => { 101 | let result 102 | try { 103 | result = await Outgoing(logger, config, xmpp, 'user', 'destination', 'This a second message', 'type', 'bearer-form') 104 | } catch (error) { 105 | should.fail(0, 1, 'Exception is thrown') 106 | } 107 | result.should.equal('Message sent') 108 | sinon.assert.calledOnce(reqSpy) 109 | const req = reqSpy.args[0][0] 110 | const bodyReq = decodeURIComponent(reqSpy.args[0][2]) 111 | req.headers.authorization.should.equal('Bearer abcdefgh') 112 | req.headers['content-type'].should.equal('application/x-www-form-urlencoded') 113 | bodyReq.should.equal('from=user&message=This a second message&channel=destination') 114 | }) 115 | }) 116 | 117 | describe('POST without authorization', () => { 118 | it('Should not send authorization in header, handle 401 and throw an exception', async () => { 119 | try { 120 | await Outgoing(logger, config, xmpp, 'user', 'destination', 'message', 'type', 'protected') 121 | should.fail(0, 1, 'Exception not thrown') 122 | } catch (error) { 123 | error.message.should.equal('HTTP error: 401') 124 | } 125 | sinon.assert.calledOnce(reqSpy) 126 | }) 127 | }) 128 | 129 | describe('POST with HTTP error', () => { 130 | it('Should handle error and throw an exception', async () => { 131 | try { 132 | await Outgoing(logger, config, xmpp, 'user', 'destination', 'This a second message', 'type', 'request-error') 133 | should.fail(0, 1, 'Exception not thrown') 134 | } catch (error) { 135 | error.message.should.equal('error in request') 136 | } 137 | sinon.assert.calledOnce(reqSpy) 138 | }) 139 | }) 140 | 141 | describe('POST with timeout', () => { 142 | it('Should handle error and throw an exception', async () => { 143 | try { 144 | await Outgoing(logger, config, xmpp, 'user', 'destination', 'This a second message', 'type', 'timeout-error') 145 | should.fail(0, 1, 'Exception not thrown') 146 | } catch (error) { 147 | error.message.should.equal('ESOCKETTIMEDOUT') 148 | } 149 | sinon.assert.calledOnce(reqSpy) 150 | }) 151 | }) 152 | }) 153 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # XMPP Bot 2 | 3 | [![license: AGPLv3](https://img.shields.io/badge/license-AGPLv3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) 4 | [![GitHub release](https://img.shields.io/github/release/nioc/xmpp-bot.svg)](https://github.com/nioc/xmpp-bot/releases/latest) 5 | [![Build Status](https://api.travis-ci.com/nioc/xmpp-bot.svg?branch=master)](https://app.travis-ci.com/github/nioc/xmpp-bot) 6 | [![Coverage Status](https://coveralls.io/repos/github/nioc/xmpp-bot/badge.svg?branch=master)](https://coveralls.io/github/nioc/xmpp-bot?branch=master) 7 | 8 | XMPP Bot is a tiny little bot making the link between XMPP conversations and webhooks. 9 | 10 | User ⇄ XMPP client ⇄ XMPP Server ⇄ **XMPP Bot** ⇄ REST API 11 | 12 | ## Key features 13 | 14 | - Call outgoing webhook on XMPP incoming messages from user chat or group chat (Multi-user chat "MUC"), 15 | - Send message templates (with values to apply to variables in that template) to user or room (MUC) on incoming authorized (basic or bearer) webhook. 16 | 17 | ## Installation 18 | 19 | An [Ansible role](/docs/ansible/xmpp-bot/README.md) is provided, but you can also use following commands: 20 | 21 | - Install [Node.js](https://nodejs.org/): 22 | ```shell 23 | curl -sL https://deb.nodesource.com/setup_10.x | bash - 24 | apt-get install -y nodejs 25 | ``` 26 | 27 | - Install npm: 28 | ```shell 29 | npm install npm@latest -g 30 | ``` 31 | 32 | - Clone repository: 33 | ```shell 34 | git clone https://github.com/nioc/xmpp-bot.git /usr/local/bin/xmpp-bot/ 35 | ``` 36 | 37 | - Install dependency: 38 | ```shell 39 | cd /usr/local/bin/xmpp-bot/ && npm install --production 40 | ``` 41 | 42 | - Create run user (optionnal): 43 | ``` 44 | useradd -r -s /bin/false xmpp-bot 45 | chown xmpp-bot:xmpp-bot /usr/local/bin/xmpp-bot -R 46 | ``` 47 | 48 | - Set [configuration](#configuration) in `/lib/config/config.json` (you can copy `config.json.dist`) 49 | 50 | - Add systemd service from [model](/docs/xmpp-bot.service): 51 | ```shell 52 | cp docs/xmpp-bot.service /etc/systemd/system/xmpp-bot.service 53 | ``` 54 | 55 | - Update systemd: 56 | ```shell 57 | systemctl daemon-reload 58 | ``` 59 | 60 | - Start service: 61 | ```shell 62 | systemctl start xmpp-bot 63 | ``` 64 | 65 | - Start service at boot: 66 | ```shell 67 | systemctl enable xmpp-bot 68 | ``` 69 | 70 | - Add fail2ban filter from [model](/docs/xmpp-bot.conf) (optionnal): 71 | ```shell 72 | cp docs/xmpp-bot.conf /etc/fail2ban/filter.d/xmpp-bot.conf 73 | ``` 74 | Add the jail (`/etc/fail2ban/jail.local`): 75 | ```properties 76 | [xmpp-bot] 77 | enabled = true 78 | port = http,https 79 | filter = xmpp-bot 80 | logpath = /var/log/xmpp-bot/webhook.log 81 | maxretry = 3 82 | bantime = 21600 ; 6 hours 83 | ``` 84 | 85 | ## Configuration 86 | 87 | ### Logger 88 | 89 | - `level` log4js level (all < trace < debug < info < warn < error < fatal < mark < off) 90 | - `file`, `console` and `stdout` define log appenders (see [log4js doc](https://log4js-node.github.io/log4js-node/appenders.html)) 91 | 92 | ### Webhooks listener 93 | 94 | - `path` and `port` define the listening endpoint 95 | - `ssl` define key and certificat location and port used for exposing in https, make sure that user of the process is allowed to read cert 96 | - `users` is an array of user/password for basic authentication 97 | - `accessLog` define the listener logger 98 | 99 | ### XMPP Server 100 | 101 | - `service` and `domain` define XMPP server 102 | - `username` and `password` define XMPP "bot" user credentials 103 | - `rooms` list rooms (and optionnal password) where bot will listen 104 | 105 | ### Incoming webhooks (list) 106 | 107 | - `path` is the webhook key:a POST request on this path will trigger corresponding `action` 108 | - `action` among enumeration: 109 | - `send_xmpp_message` will send message (`message` in request body) to `destination` (from request body) ; if `destination` is found in `config.xmppServer.rooms` array, message will send as a groupchat). Request exemple: 110 | 111 | ```http 112 | POST /webhooks/w1 HTTP/1.1 113 | Host: domain.ltd:8000 114 | Content-Type: application/json 115 | Authorization: Basic dXNlcjE6cGFzczE= 116 | Content-Length: 70 117 | 118 | { 119 | "destination":"me@domain.ltd", 120 | "message":"Hi, there something wrong." 121 | } 122 | ``` 123 | 124 | - `send_xmpp_template` will send template with merged variables (using JMESPath) to `destination` (user or room if `type` set to `chat` or `groupchat`) 125 | 126 | ### XMPP hooks (list) 127 | 128 | - `room` is the XMPP hook key: an incoming groupchat (or chat) from this room (or this user) will trigger corresponding `action` 129 | - `action` among enumeration: 130 | - `outgoing_webhook` will execute a request to corresponding webhook with `args` as webhook code 131 | 132 | ## FAQ 133 | 134 | - *XMPP server is using a self signed certificate, how can i run service?* 135 | You can allow insecure TLS connections and HTTPS requests by adding `Environment=NODE_TLS_REJECT_UNAUTHORIZED=0` in /usr/lib/systemd/system/xmpp-bot.service. 136 | 137 | ## Credits 138 | 139 | - **[Nioc](https://github.com/nioc/)** - _Initial work_ 140 | 141 | See also the list of [contributors](https://github.com/nioc/xmpp-bot/contributors) to this project. 142 | 143 | This project is powered by the following components: 144 | 145 | - [xmpp.js](https://github.com/simple-xmpp/node-simple-xmpp) (ISC) 146 | - [express](https://github.com/expressjs/express) (MIT) 147 | - [body-parser](https://github.com/expressjs/body-parser) (MIT) 148 | - [express-basic-auth](https://github.com/LionC/express-basic-auth) (MIT) 149 | - [morgan](https://github.com/expressjs/morgan) (MIT) 150 | - [jmespath.js](https://github.com/jmespath/jmespath.js) (Apache-2.0) 151 | - [request](https://github.com/request/request) (Apache-2.0) 152 | - [node-cleanup](https://github.com/jtlapp/node-cleanup) (MIT) 153 | - [log4js-node](https://github.com/log4js-node/log4js-node) (Apache-2.0) 154 | 155 | ## License 156 | 157 | This project is licensed under the GNU Affero General Public License v3.0 - see the [LICENSE](LICENSE.md) file for details 158 | -------------------------------------------------------------------------------- /lib/webhook/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Webhooks listener 3 | * 4 | * Create webhooks listener 5 | * 6 | * @file This files defines the webhooks listener 7 | * @author nioc 8 | * @since 1.0.0 9 | * @license AGPL-3.0+ 10 | */ 11 | 12 | module.exports = (logger, config, xmpp) => { 13 | const http = require('http') 14 | const express = require('express') 15 | const bodyParser = require('body-parser') 16 | const basicAuth = require('express-basic-auth') 17 | const jmespath = require('jmespath') 18 | const port = config.listener.port || 8000 19 | const portSsl = config.listener.ssl.port || 8001 20 | 21 | const webhook = express() 22 | 23 | // handle connection from proxy (get IP in 'X-Forwarded-For' header) 24 | webhook.set('trust proxy', true) 25 | 26 | // logs request 27 | if (config.listener.log.active) { 28 | const morgan = require('morgan') 29 | const fs = require('fs') 30 | // create path if not exists 31 | if (!fs.existsSync(config.listener.log.path)) { 32 | try { 33 | fs.mkdirSync(config.listener.log.path) 34 | } catch (error) { 35 | logger.fatal(`Can not create webhooks log folder: ${error.message}`) 36 | process.exit(99) 37 | } 38 | } 39 | // create log 40 | const accessLogStream = fs.createWriteStream(config.listener.log.path + config.listener.log.filename, { flags: 'a' }) 41 | accessLogStream.on('error', (err) => { 42 | logger.fatal('Can not create webhooks log file: ' + err.message) 43 | process.exit(99) 44 | }) 45 | webhook.use(morgan('combined', { stream: accessLogStream })) 46 | } 47 | 48 | // parse request 49 | webhook.use(bodyParser.json()) 50 | 51 | // add basic authentification 52 | webhook.use(basicAuth({ 53 | users: config.listener.users, 54 | unauthorizedResponse: 'Invalid authorization' 55 | })) 56 | 57 | // handle post request 58 | webhook.post(config.listener.path + '/*', (req, res) => { 59 | logger.info(`Incoming webhook from ${req.auth.user}`) 60 | const webhook = config.getWebhookAction(req.path) 61 | if (!webhook) { 62 | logger.error(`Webhook received: ${req.path}, not found`) 63 | return res.status(404).send('Webhook not found') 64 | } 65 | logger.debug(`Webhook received: ${webhook.path}, start action: ${webhook.action}`) 66 | logger.trace(req.body) 67 | switch (webhook.action) { 68 | case 'send_xmpp_message': { 69 | // get destination 70 | if ('destination' in req.body === false) { 71 | logger.error('Destination not found') 72 | return res.status(400).send('Destination not found') 73 | } 74 | const destination = req.body.destination 75 | 76 | // get message 77 | if ('message' in req.body === false) { 78 | logger.error('Message not found') 79 | return res.status(400).send('Message not found') 80 | } 81 | const message = req.body.message 82 | 83 | // check if destination is a group chat 84 | const type = config.xmpp.rooms.some((room) => room.id === destination) ? 'groupchat' : 'chat' 85 | 86 | // send message 87 | logger.trace(`Send to ${destination} (group:${type}) following message :\r\n${message}`) 88 | xmpp.send(destination, message, type) 89 | .then(() => { 90 | return res.status(200).send({ status: 'ok', destination }) 91 | }) 92 | .catch(() => { 93 | return res.status(500).send('Could not send message') 94 | }) 95 | break 96 | } 97 | 98 | case 'send_xmpp_template': { 99 | // bind data in template 100 | const msg = webhook.template.replace(/\$\{(.+?)\}/g, (match, $1) => { 101 | return jmespath.search(req.body, $1) || '' 102 | }) 103 | logger.trace(`Message:\r\n${msg}`) 104 | logger.trace(`Arguments: ${webhook.args}`) 105 | 106 | // send message 107 | logger.trace(`Send to ${webhook.args.destination} (group:${webhook.args.type}) following message :\r\n${msg}`) 108 | xmpp.send(webhook.args.destination, msg, webhook.args.type) 109 | .then(() => { 110 | return res.status(200).send('ok') 111 | }) 112 | .catch(() => { 113 | return res.status(500).send('Could not send message') 114 | }) 115 | break 116 | } 117 | 118 | default: 119 | return res.status(204).send() 120 | } 121 | }) 122 | 123 | // handle non post requests 124 | webhook.all('*', (req, res) => { 125 | return res.status(405).send('Method not allowed') 126 | }) 127 | 128 | // handle server error 129 | webhook.on('error', (error) => { 130 | logger.error('Error', error) 131 | }) 132 | 133 | // get IP v4 addresses and prepare endpoints for output 134 | let addresses = [] 135 | const networkInterfaces = require('os').networkInterfaces() 136 | for (const ifaceName in networkInterfaces) { 137 | addresses = addresses.concat(networkInterfaces[ifaceName].reduce((add, iface) => { 138 | if (iface.family === 'IPv4') { 139 | add.push(iface.address) 140 | } 141 | return add 142 | }, [])) 143 | } 144 | 145 | // start HTTP listener 146 | const httpServer = http.createServer(webhook).listen(port, () => { 147 | let endpoints = `http://localhost:${port}${config.listener.path}` 148 | addresses.forEach(address => { 149 | endpoints += ` http://${address}:${port}${config.listener.path}` 150 | }) 151 | logger.info(`Listening webhooks on ${endpoints}`) 152 | }) 153 | 154 | // start HTTPS listener 155 | if (config.listener.ssl.port !== null) { 156 | if (process.getuid) { 157 | logger.debug(`App is started with uid: ${process.getuid()}`) 158 | } 159 | logger.debug(`Start HTTPS on port ${portSsl}, private key: ${config.listener.ssl.keyPath}, cert: ${config.listener.ssl.certPath}`) 160 | const https = require('https') 161 | const fs = require('fs') 162 | // check if cert is readable 163 | try { 164 | fs.accessSync(config.listener.ssl.keyPath, fs.constants.R_OK) 165 | logger.debug('Can read private key') 166 | try { 167 | fs.accessSync(config.listener.ssl.certPath, fs.constants.R_OK) 168 | logger.debug('Can read certificate') 169 | const credentials = { 170 | key: fs.readFileSync(config.listener.ssl.keyPath), 171 | cert: fs.readFileSync(config.listener.ssl.certPath) 172 | } 173 | https.createServer(credentials, webhook).listen(portSsl, () => { 174 | let endpoints = `https://localhost:${portSsl}${config.listener.path}` 175 | addresses.forEach(address => { 176 | endpoints += ` https://${address}:${portSsl}${config.listener.path}` 177 | }) 178 | logger.info(`Listening webhooks on ${endpoints}`) 179 | }) 180 | } catch (err) { 181 | logger.error(`Can not read certificate: ${err.message}`) 182 | } 183 | } catch (err) { 184 | logger.error(`Can not read private key: ${err.message}`) 185 | } 186 | } 187 | 188 | // Closing HTTP listener (for test) 189 | webhook.close = () => { 190 | httpServer.close() 191 | } 192 | 193 | return webhook 194 | } 195 | -------------------------------------------------------------------------------- /test/webhook.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* eslint-disable n/handle-callback-err */ 3 | /* eslint-disable prefer-regex-literals */ 4 | process.env.NODE_ENV = 'production' 5 | 6 | require('chai').should() 7 | const sinon = require('sinon') 8 | const fs = require('fs') 9 | const request = require('request') 10 | 11 | describe('Webhook component', () => { 12 | let logger, config, xmppSendStub, xmpp, baseUrl, webhook, logFile, options 13 | 14 | before('Setup', () => { 15 | // create default logger 16 | logger = require('./../lib/logger')() 17 | 18 | // get configuration 19 | config = require('./../lib/config')(logger, './test/config.json') 20 | 21 | // update logger with configuration 22 | logger.updateConfig(config.logger) 23 | 24 | // mock xmpp component 25 | xmpp = { 26 | send: null 27 | } 28 | // configure webhook 29 | baseUrl = 'http://localhost:' + config.listener.port 30 | webhook = require('./../lib/webhook')(logger, config, xmpp) 31 | logFile = config.listener.log.path + config.listener.log.filename 32 | }) 33 | 34 | beforeEach('Reset XMPP stub history and request option', function () { 35 | // mock xmpp component 36 | xmppSendStub = sinon.stub().resolves() 37 | xmpp.send = xmppSendStub 38 | options = { 39 | method: 'POST', 40 | url: baseUrl + config.listener.path + '/', 41 | auth: { 42 | user: 'login1', 43 | pass: '1pass' 44 | } 45 | } 46 | }) 47 | 48 | after('Close listener', (done) => { 49 | webhook.close() 50 | done() 51 | }) 52 | 53 | describe('POST without authorization', () => { 54 | it('Should return 401 and be logged', (done) => { 55 | request.post(baseUrl, (error, response, body) => { 56 | response.statusCode.should.equal(401) 57 | fs.readFile(logFile, 'utf8', (err, data) => { 58 | if (err) { 59 | throw err 60 | } 61 | data.should.match(new RegExp('"POST / HTTP/1.1" 401 ')) 62 | done() 63 | }) 64 | }) 65 | }) 66 | }) 67 | 68 | describe('Wrong method (GET)', () => { 69 | it('Should return 405', (done) => { 70 | request.get(baseUrl, { auth: options.auth }, (error, response, body) => { 71 | response.statusCode.should.equal(405) 72 | done() 73 | }) 74 | }) 75 | }) 76 | 77 | describe('POST unknown webhook', () => { 78 | it('Should return 404 and be logged', (done) => { 79 | options.url += 'unknown' 80 | request(options, (error, response, body) => { 81 | response.statusCode.should.equal(404) 82 | fs.readFile(logFile, 'utf8', (err, data) => { 83 | if (err) { 84 | throw err 85 | } 86 | data.should.match(new RegExp('"POST ' + config.listener.path + '/unknown HTTP/1.1" 404 ')) 87 | done() 88 | }) 89 | }) 90 | }) 91 | }) 92 | 93 | describe('POST dummy webhook', () => { 94 | it('Should return 204', (done) => { 95 | options.url += 'dummy' 96 | request(options, (error, response, body) => { 97 | response.statusCode.should.equal(204) 98 | done() 99 | }) 100 | }) 101 | }) 102 | 103 | describe('POST missing destination webhook', () => { 104 | it('Should return 400 and error detail', (done) => { 105 | options.url += 'w1' 106 | request(options, (error, response, body) => { 107 | response.statusCode.should.equal(400) 108 | response.body.should.equal('Destination not found') 109 | done() 110 | }) 111 | }) 112 | }) 113 | 114 | describe('POST missing message webhook', () => { 115 | it('Should return 400 and error detail', (done) => { 116 | options.json = { 117 | destination: 'destination' 118 | } 119 | options.url += 'w1' 120 | request(options, (error, response, body) => { 121 | response.statusCode.should.equal(400) 122 | response.body.should.equal('Message not found') 123 | done() 124 | }) 125 | }) 126 | }) 127 | 128 | describe('POST valid webhook (send message)', () => { 129 | it('Should return 200 and send XMPP message', (done) => { 130 | options.json = { 131 | destination: 'destination', 132 | message: 'This is a message' 133 | } 134 | options.url += 'w1' 135 | request(options, (error, response, body) => { 136 | response.statusCode.should.equal(200) 137 | sinon.assert.calledOnce(xmppSendStub) 138 | const args = xmppSendStub.args[0] 139 | args.should.have.length(3) 140 | args[0].should.equal(options.json.destination) 141 | args[1].should.equal(options.json.message) 142 | args[2].should.equal('chat') 143 | done() 144 | }) 145 | }) 146 | }) 147 | 148 | describe('POST valid webhook (send message) with XMPP error', () => { 149 | it('Should return 200 and send XMPP message', (done) => { 150 | xmppSendStub = sinon.stub().rejects() 151 | xmpp.send = xmppSendStub 152 | options.json = { 153 | destination: 'destination', 154 | message: 'This is a message' 155 | } 156 | options.url += 'w1' 157 | request(options, (error, response, body) => { 158 | response.statusCode.should.equal(500) 159 | sinon.assert.calledOnce(xmppSendStub) 160 | const args = xmppSendStub.args[0] 161 | args.should.have.length(3) 162 | args[0].should.equal(options.json.destination) 163 | args[1].should.equal(options.json.message) 164 | args[2].should.equal('chat') 165 | done() 166 | }) 167 | }) 168 | }) 169 | 170 | describe('POST valid webhook (send template)', () => { 171 | it('Should return 200 and send XMPP message', (done) => { 172 | options.json = { 173 | title: 'This is a title', 174 | message: 'This is a message', 175 | evalMatches: [ 176 | { 177 | metric: 'metric', 178 | value: 'value' 179 | } 180 | ], 181 | imageUrl: 'https://domain.ltd:port/path/image' 182 | } 183 | options.url += 'grafana' 184 | request(options, (error, response, body) => { 185 | response.statusCode.should.equal(200) 186 | sinon.assert.calledOnce(xmppSendStub) 187 | const args = xmppSendStub.args[0] 188 | args.should.have.length(3) 189 | args[0].should.equal('grafana@conference.domain-xmpp.ltd') 190 | args[1].should.equal('This is a title\r\nThis is a message\r\nmetric: value\r\nhttps://domain.ltd:port/path/image') 191 | args[2].should.equal('groupchat') 192 | done() 193 | }) 194 | }) 195 | }) 196 | 197 | describe('POST valid webhook (send template) with XMPP error', () => { 198 | it('Should return 200 and send XMPP message', (done) => { 199 | xmppSendStub = sinon.stub().rejects() 200 | xmpp.send = xmppSendStub 201 | options.json = { 202 | title: 'This is a title', 203 | message: 'This is a message', 204 | evalMatches: [ 205 | { 206 | metric: 'metric', 207 | value: 'value' 208 | } 209 | ], 210 | imageUrl: 'https://domain.ltd:port/path/image' 211 | } 212 | options.url += 'grafana' 213 | request(options, (error, response, body) => { 214 | response.statusCode.should.equal(500) 215 | sinon.assert.calledOnce(xmppSendStub) 216 | const args = xmppSendStub.args[0] 217 | args.should.have.length(3) 218 | args[0].should.equal('grafana@conference.domain-xmpp.ltd') 219 | args[1].should.equal('This is a title\r\nThis is a message\r\nmetric: value\r\nhttps://domain.ltd:port/path/image') 220 | args[2].should.equal('groupchat') 221 | done() 222 | }) 223 | }) 224 | }) 225 | }) 226 | -------------------------------------------------------------------------------- /test/xmpp.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | process.env.NODE_ENV = 'production' 3 | 4 | const sinon = require('sinon') 5 | require('chai').should() 6 | const EventEmitter = require('events').EventEmitter 7 | const mock = require('mock-require') 8 | const xml = require('@xmpp/xml') 9 | const jid = require('@xmpp/jid') 10 | 11 | describe('XMPP component', () => { 12 | let logger, config, outgoingStub, outgoingStubRejected, xmppSendStub, xmppCloseStub, simpleXmppEvents, xmpp 13 | 14 | before('Setup logger and config', (done) => { 15 | // create default logger 16 | logger = require('./../lib/logger')() 17 | 18 | // get configuration 19 | config = require('./../lib/config')(logger, './test/config.json') 20 | 21 | // update logger with configuration 22 | logger.updateConfig(config.logger) 23 | 24 | // mock logger trace 25 | sinon.stub(logger, 'trace') 26 | 27 | done() 28 | }) 29 | 30 | after('Remove mocks', (done) => { 31 | mock.stopAll() 32 | logger.trace.restore() 33 | done() 34 | }) 35 | 36 | beforeEach('Setup XMPP client with stub', function () { 37 | simpleXmppEvents = new EventEmitter() 38 | 39 | // mock outgoing module 40 | outgoingStub = sinon.stub().resolves() 41 | mock('./../lib/outgoing', outgoingStub) 42 | 43 | // mock @xmpp/client module 44 | xmppSendStub = sinon.stub().resolves() 45 | xmppCloseStub = sinon.stub().resolves() 46 | mock('@xmpp/client', { 47 | client: () => { 48 | this.start = async () => {} 49 | this.stop = xmppCloseStub 50 | this.send = xmppSendStub 51 | this.on = (eventName, callback) => { 52 | simpleXmppEvents.on(eventName, callback) 53 | } 54 | return this 55 | }, 56 | xml: require('@xmpp/xml'), 57 | jid: require('@xmpp/jid') 58 | }) 59 | 60 | // reset stubs history 61 | outgoingStub.resetHistory() 62 | xmppSendStub.resetHistory() 63 | xmppCloseStub.resetHistory() 64 | 65 | // call module and emit online event 66 | xmpp = require('./../lib/xmpp')(logger, config) 67 | simpleXmppEvents.emit('online', jid('bot@domain-xmpp.ltd/resource')) 68 | }) 69 | 70 | describe('Connect to XMPP server', () => { 71 | beforeEach(async () => { 72 | await simpleXmppEvents.emit('status', 'connecting') 73 | }) 74 | it('Should connect to XMPP server and join rooms when application start', (done) => { 75 | sinon.assert.called(xmppSendStub) 76 | // 1 "send" call for presence and n "send" calls for joining rooms 77 | const roomsLength = config.xmpp.rooms.length 78 | sinon.assert.callCount(xmppSendStub, roomsLength + 1) 79 | for (let index = 1; index < roomsLength + 1; index++) { 80 | const args = xmppSendStub.args[index] 81 | args.should.have.length(1) 82 | const occupantJid = config.xmpp.rooms[index - 1].id + '/' + 'bot' 83 | const stanza = xml( 84 | 'presence', { 85 | to: occupantJid 86 | }, 87 | xml( 88 | 'x', { 89 | xmlns: 'http://jabber.org/protocol/muc' 90 | } 91 | ) 92 | ) 93 | args[0].should.deep.equal(stanza) 94 | } 95 | done() 96 | }) 97 | it('Should trace connection status', (done) => { 98 | sinon.assert.calledWith(logger.trace, 'Status changed to connecting') 99 | done() 100 | }) 101 | }) 102 | 103 | describe('Connect to XMPP server but fail to start', () => { 104 | let xmppStartStub 105 | beforeEach(async () => { 106 | xmppStartStub = sinon.stub().rejects(new Error('Stubed error message')) 107 | mock('@xmpp/client', { 108 | client: () => { 109 | this.start = xmppStartStub 110 | this.stop = xmppCloseStub 111 | this.send = xmppSendStub 112 | this.on = (eventName, callback) => { 113 | simpleXmppEvents.on(eventName, callback) 114 | } 115 | return this 116 | }, 117 | xml: require('@xmpp/xml'), 118 | jid: require('@xmpp/jid') 119 | }) 120 | xmpp = require('./../lib/xmpp')(logger, config) 121 | }) 122 | it('Should log error', (done) => { 123 | require('fs').readFile(config.logger.file.path + config.logger.file.filename, 'utf8', (err, data) => { 124 | if (err) { 125 | throw err 126 | } 127 | data.should.match(new RegExp('XMPP client encountered following error at connection: Stubed error message' + '\n$')) 128 | done() 129 | }) 130 | }) 131 | }) 132 | 133 | describe('Bot receive a presence stanza from someone', () => { 134 | beforeEach(async () => { 135 | await simpleXmppEvents.emit('stanza', xml( 136 | 'presence', { 137 | from: 'someone@domain-xmpp.ltd', 138 | to: 'bot@domain-xmpp.ltd' 139 | } 140 | )) 141 | }) 142 | it('Should not trigger outgoing webhook', (done) => { 143 | sinon.assert.notCalled(outgoingStub) 144 | done() 145 | }) 146 | }) 147 | 148 | describe('Bot receive an empty message from someone', () => { 149 | beforeEach(async () => { 150 | await simpleXmppEvents.emit('stanza', xml( 151 | 'message', { 152 | from: 'someone@domain-xmpp.ltd', 153 | to: 'bot@domain-xmpp.ltd', 154 | type: 'chat' 155 | } 156 | )) 157 | }) 158 | it('Should not trigger outgoing webhook', (done) => { 159 | sinon.assert.notCalled(outgoingStub) 160 | done() 161 | }) 162 | }) 163 | 164 | describe('Bot receive a message from someone', () => { 165 | beforeEach(async () => { 166 | xmppSendStub.resetHistory() 167 | await simpleXmppEvents.emit('stanza', xml( 168 | 'message', { 169 | from: 'someone@domain-xmpp.ltd', 170 | to: 'bot@domain-xmpp.ltd', 171 | type: 'chat', 172 | id: 'fcdd3d8c' 173 | }, 174 | xml( 175 | 'body', { 176 | }, 177 | 'This is the message text'), 178 | xml( 179 | 'request', { 180 | xmlns: 'urn:xmpp:receipts' 181 | }) 182 | )) 183 | }) 184 | it('Should trigger outgoing webhook with valid arguments and send a message delivery receipt', (done) => { 185 | sinon.assert.calledOnce(outgoingStub) 186 | const args = outgoingStub.args[0] 187 | args.should.have.length(8) 188 | args[3].should.equal('someone') 189 | args[4].should.equal('someone@domain-xmpp.ltd') 190 | args[5].should.equal('This is the message text') 191 | args[6].should.equal('chat') 192 | args[7].should.equal('w1') 193 | sinon.assert.calledOnce(xmppSendStub) 194 | const receiptStanza = xml( 195 | 'message', { 196 | to: 'someone@domain-xmpp.ltd' 197 | }, 198 | xml( 199 | 'received', { 200 | xmlns: 'urn:xmpp:receipts', 201 | id: 'fcdd3d8c' 202 | } 203 | ) 204 | ) 205 | xmppSendStub.args[0][0].should.deep.equal(receiptStanza) 206 | done() 207 | }) 208 | }) 209 | 210 | describe('Bot receive a message from someone but webhook call failed', () => { 211 | beforeEach(async () => { 212 | simpleXmppEvents = new EventEmitter() 213 | outgoingStubRejected = sinon.stub().rejects() 214 | mock('./../lib/outgoing', outgoingStubRejected) 215 | require('./../lib/xmpp')(logger, config) 216 | simpleXmppEvents.emit('online', jid('bot@domain-xmpp.ltd/resource')) 217 | xmppSendStub.resetHistory() 218 | await simpleXmppEvents.emit('stanza', xml( 219 | 'message', { 220 | from: 'someone@domain-xmpp.ltd', 221 | to: 'bot@domain-xmpp.ltd', 222 | type: 'chat', 223 | id: 'fcdd3d8c' 224 | }, 225 | xml( 226 | 'body', { 227 | }, 228 | 'Is it working?') 229 | )) 230 | }) 231 | it('Should send a XMPP reply if webhook failed', () => { 232 | sinon.assert.calledOnce(outgoingStubRejected) 233 | const args = outgoingStubRejected.args[0] 234 | args.should.have.length(8) 235 | args[3].should.equal('someone') 236 | args[4].should.equal('someone@domain-xmpp.ltd') 237 | args[5].should.equal('Is it working?') 238 | args[6].should.equal('chat') 239 | args[7].should.equal('w1') 240 | sinon.assert.calledOnce(xmppSendStub) 241 | const receiptStanza = xml( 242 | 'message', { 243 | to: 'someone@domain-xmpp.ltd', 244 | type: 'chat' 245 | }, 246 | xml( 247 | 'body', { 248 | }, 249 | 'Oops, something went wrong :(' 250 | ) 251 | ) 252 | xmppSendStub.args[0][0].should.deep.equal(receiptStanza) 253 | }) 254 | }) 255 | 256 | describe('Bot receive a message from himself in a room', () => { 257 | beforeEach(async () => { 258 | await simpleXmppEvents.emit('stanza', xml( 259 | 'message', { 260 | from: 'roomname@conference.domain-xmpp.ltd/bot', 261 | to: 'roomname@conference.domain-xmpp.ltd', 262 | type: 'groupchat' 263 | }, 264 | xml( 265 | 'body', { 266 | }, 267 | 'This is the message text') 268 | )) 269 | }) 270 | it('Should not trigger outgoing webhook', (done) => { 271 | sinon.assert.notCalled(outgoingStub) 272 | done() 273 | }) 274 | }) 275 | 276 | describe('Bot receive a message in an unknown room', () => { 277 | beforeEach(async () => { 278 | await simpleXmppEvents.emit('stanza', xml( 279 | 'message', { 280 | from: 'unknownroomname@conference.domain-xmpp.ltd/someone', 281 | to: 'unknownroomname@conference.domain-xmpp.ltd', 282 | type: 'groupchat' 283 | }, 284 | xml( 285 | 'body', { 286 | }, 287 | 'This is the message text') 288 | )) 289 | }) 290 | it('Should not trigger outgoing webhook', (done) => { 291 | sinon.assert.notCalled(outgoingStub) 292 | done() 293 | }) 294 | }) 295 | 296 | describe('Bot receive an old message in a room', () => { 297 | beforeEach(async () => { 298 | await simpleXmppEvents.emit('stanza', xml( 299 | 'message', { 300 | from: 'roomname@conference.domain-xmpp.ltd/someone', 301 | to: 'roomname@conference.domain-xmpp.ltd', 302 | type: 'groupchat' 303 | }, 304 | xml( 305 | 'body', { 306 | }, 307 | 'This is the message text'), 308 | xml( 309 | 'delay', { 310 | xmlns: 'urn:xmpp:delay', 311 | from: 'roomname@conference.domain-xmpp.ltd' 312 | }, 313 | 'This is the message text') 314 | )) 315 | }) 316 | it('Should not trigger outgoing webhook', (done) => { 317 | sinon.assert.notCalled(outgoingStub) 318 | done() 319 | }) 320 | }) 321 | 322 | describe('Bot receive a message in a room', () => { 323 | beforeEach(async () => { 324 | await simpleXmppEvents.emit('stanza', xml( 325 | 'message', { 326 | from: 'roomname@conference.domain-xmpp.ltd/someone', 327 | to: 'roomname@conference.domain-xmpp.ltd', 328 | type: 'groupchat' 329 | }, 330 | xml( 331 | 'body', { 332 | }, 333 | 'This is the message text') 334 | )) 335 | }) 336 | it('Should trigger outgoing webhook with valid arguments', (done) => { 337 | sinon.assert.calledOnce(outgoingStub) 338 | const args = outgoingStub.args[0] 339 | args.should.have.length(8) 340 | args[3].should.equal('someone') 341 | args[4].should.equal('roomname@conference.domain-xmpp.ltd') 342 | args[5].should.equal('This is the message text') 343 | args[6].should.equal('groupchat') 344 | args[7].should.equal('w1') 345 | done() 346 | }) 347 | }) 348 | 349 | describe('Send a message', () => { 350 | beforeEach(async () => { 351 | xmppSendStub.resetHistory() 352 | await xmpp.send('someone@domain-xmpp.ltd', 'This is the message text sent by bot', 'chat') 353 | }) 354 | it('Should call xmpp/client.send() with valid stanza', (done) => { 355 | sinon.assert.calledOnce(xmppSendStub) 356 | const stanza = xml( 357 | 'message', { 358 | to: 'someone@domain-xmpp.ltd', 359 | type: 'chat' 360 | }, 361 | xml( 362 | 'body', { 363 | }, 364 | 'This is the message text sent by bot') 365 | ) 366 | const args = xmppSendStub.args[0] 367 | args.should.have.length(1) 368 | args[0].should.deep.equal(stanza) 369 | done() 370 | }) 371 | }) 372 | 373 | describe('Close connection', () => { 374 | beforeEach(async () => { 375 | await xmpp.close() 376 | }) 377 | it('Should call xmpp/client.stop()', (done) => { 378 | sinon.assert.calledOnce(xmppCloseStub) 379 | done() 380 | }) 381 | }) 382 | 383 | describe('XMPP server send an error', () => { 384 | let errorText 385 | beforeEach(async () => { 386 | sinon.stub(process, 'exit') 387 | errorText = 'This the error text' 388 | await simpleXmppEvents.emit('error', new Error(errorText)) 389 | }) 390 | afterEach(() => { 391 | process.exit.restore() 392 | }) 393 | it('Should log error and exit with 99 code', (done) => { 394 | require('fs').readFile(config.logger.file.path + config.logger.file.filename, 'utf8', (err, data) => { 395 | if (err) { 396 | throw err 397 | } 398 | data.should.match(new RegExp('XMPP client encountered following error: ' + errorText + '\n$')) 399 | sinon.assert.calledWith(process.exit, 99) 400 | done() 401 | }) 402 | }) 403 | }) 404 | }) 405 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ### GNU AFFERO GENERAL PUBLIC LICENSE 2 | 3 | Version 3, 19 November 2007 4 | 5 | Copyright (C) 2007 Free Software Foundation, Inc. 6 | 7 | 8 | Everyone is permitted to copy and distribute verbatim copies of this 9 | license document, but changing it is not allowed. 10 | 11 | ### Preamble 12 | 13 | The GNU Affero General Public License is a free, copyleft license for 14 | software and other kinds of works, specifically designed to ensure 15 | cooperation with the community in the case of network server software. 16 | 17 | The licenses for most software and other practical works are designed 18 | to take away your freedom to share and change the works. By contrast, 19 | our General Public Licenses are intended to guarantee your freedom to 20 | share and change all versions of a program--to make sure it remains 21 | free software for all its users. 22 | 23 | When we speak of free software, we are referring to freedom, not 24 | price. Our General Public Licenses are designed to make sure that you 25 | have the freedom to distribute copies of free software (and charge for 26 | them if you wish), that you receive source code or can get it if you 27 | want it, that you can change the software or use pieces of it in new 28 | free programs, and that you know you can do these things. 29 | 30 | Developers that use our General Public Licenses protect your rights 31 | with two steps: (1) assert copyright on the software, and (2) offer 32 | you this License which gives you legal permission to copy, distribute 33 | and/or modify the software. 34 | 35 | A secondary benefit of defending all users' freedom is that 36 | improvements made in alternate versions of the program, if they 37 | receive widespread use, become available for other developers to 38 | incorporate. Many developers of free software are heartened and 39 | encouraged by the resulting cooperation. However, in the case of 40 | software used on network servers, this result may fail to come about. 41 | The GNU General Public License permits making a modified version and 42 | letting the public access it on a server without ever releasing its 43 | source code to the public. 44 | 45 | The GNU Affero General Public License is designed specifically to 46 | ensure that, in such cases, the modified source code becomes available 47 | to the community. It requires the operator of a network server to 48 | provide the source code of the modified version running there to the 49 | users of that server. Therefore, public use of a modified version, on 50 | a publicly accessible server, gives the public access to the source 51 | code of the modified version. 52 | 53 | An older license, called the Affero General Public License and 54 | published by Affero, was designed to accomplish similar goals. This is 55 | a different license, not a version of the Affero GPL, but Affero has 56 | released a new version of the Affero GPL which permits relicensing 57 | under this license. 58 | 59 | The precise terms and conditions for copying, distribution and 60 | modification follow. 61 | 62 | ### TERMS AND CONDITIONS 63 | 64 | #### 0. Definitions. 65 | 66 | "This License" refers to version 3 of the GNU Affero General Public 67 | License. 68 | 69 | "Copyright" also means copyright-like laws that apply to other kinds 70 | of works, such as semiconductor masks. 71 | 72 | "The Program" refers to any copyrightable work licensed under this 73 | License. Each licensee is addressed as "you". "Licensees" and 74 | "recipients" may be individuals or organizations. 75 | 76 | To "modify" a work means to copy from or adapt all or part of the work 77 | in a fashion requiring copyright permission, other than the making of 78 | an exact copy. The resulting work is called a "modified version" of 79 | the earlier work or a work "based on" the earlier work. 80 | 81 | A "covered work" means either the unmodified Program or a work based 82 | on the Program. 83 | 84 | To "propagate" a work means to do anything with it that, without 85 | permission, would make you directly or secondarily liable for 86 | infringement under applicable copyright law, except executing it on a 87 | computer or modifying a private copy. Propagation includes copying, 88 | distribution (with or without modification), making available to the 89 | public, and in some countries other activities as well. 90 | 91 | To "convey" a work means any kind of propagation that enables other 92 | parties to make or receive copies. Mere interaction with a user 93 | through a computer network, with no transfer of a copy, is not 94 | conveying. 95 | 96 | An interactive user interface displays "Appropriate Legal Notices" to 97 | the extent that it includes a convenient and prominently visible 98 | feature that (1) displays an appropriate copyright notice, and (2) 99 | tells the user that there is no warranty for the work (except to the 100 | extent that warranties are provided), that licensees may convey the 101 | work under this License, and how to view a copy of this License. If 102 | the interface presents a list of user commands or options, such as a 103 | menu, a prominent item in the list meets this criterion. 104 | 105 | #### 1. Source Code. 106 | 107 | The "source code" for a work means the preferred form of the work for 108 | making modifications to it. "Object code" means any non-source form of 109 | a work. 110 | 111 | A "Standard Interface" means an interface that either is an official 112 | standard defined by a recognized standards body, or, in the case of 113 | interfaces specified for a particular programming language, one that 114 | is widely used among developers working in that language. 115 | 116 | The "System Libraries" of an executable work include anything, other 117 | than the work as a whole, that (a) is included in the normal form of 118 | packaging a Major Component, but which is not part of that Major 119 | Component, and (b) serves only to enable use of the work with that 120 | Major Component, or to implement a Standard Interface for which an 121 | implementation is available to the public in source code form. A 122 | "Major Component", in this context, means a major essential component 123 | (kernel, window system, and so on) of the specific operating system 124 | (if any) on which the executable work runs, or a compiler used to 125 | produce the work, or an object code interpreter used to run it. 126 | 127 | The "Corresponding Source" for a work in object code form means all 128 | the source code needed to generate, install, and (for an executable 129 | work) run the object code and to modify the work, including scripts to 130 | control those activities. However, it does not include the work's 131 | System Libraries, or general-purpose tools or generally available free 132 | programs which are used unmodified in performing those activities but 133 | which are not part of the work. For example, Corresponding Source 134 | includes interface definition files associated with source files for 135 | the work, and the source code for shared libraries and dynamically 136 | linked subprograms that the work is specifically designed to require, 137 | such as by intimate data communication or control flow between those 138 | subprograms and other parts of the work. 139 | 140 | The Corresponding Source need not include anything that users can 141 | regenerate automatically from other parts of the Corresponding Source. 142 | 143 | The Corresponding Source for a work in source code form is that same 144 | work. 145 | 146 | #### 2. Basic Permissions. 147 | 148 | All rights granted under this License are granted for the term of 149 | copyright on the Program, and are irrevocable provided the stated 150 | conditions are met. This License explicitly affirms your unlimited 151 | permission to run the unmodified Program. The output from running a 152 | covered work is covered by this License only if the output, given its 153 | content, constitutes a covered work. This License acknowledges your 154 | rights of fair use or other equivalent, as provided by copyright law. 155 | 156 | You may make, run and propagate covered works that you do not convey, 157 | without conditions so long as your license otherwise remains in force. 158 | You may convey covered works to others for the sole purpose of having 159 | them make modifications exclusively for you, or provide you with 160 | facilities for running those works, provided that you comply with the 161 | terms of this License in conveying all material for which you do not 162 | control copyright. Those thus making or running the covered works for 163 | you must do so exclusively on your behalf, under your direction and 164 | control, on terms that prohibit them from making any copies of your 165 | copyrighted material outside their relationship with you. 166 | 167 | Conveying under any other circumstances is permitted solely under the 168 | conditions stated below. Sublicensing is not allowed; section 10 makes 169 | it unnecessary. 170 | 171 | #### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 172 | 173 | No covered work shall be deemed part of an effective technological 174 | measure under any applicable law fulfilling obligations under article 175 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 176 | similar laws prohibiting or restricting circumvention of such 177 | measures. 178 | 179 | When you convey a covered work, you waive any legal power to forbid 180 | circumvention of technological measures to the extent such 181 | circumvention is effected by exercising rights under this License with 182 | respect to the covered work, and you disclaim any intention to limit 183 | operation or modification of the work as a means of enforcing, against 184 | the work's users, your or third parties' legal rights to forbid 185 | circumvention of technological measures. 186 | 187 | #### 4. Conveying Verbatim Copies. 188 | 189 | You may convey verbatim copies of the Program's source code as you 190 | receive it, in any medium, provided that you conspicuously and 191 | appropriately publish on each copy an appropriate copyright notice; 192 | keep intact all notices stating that this License and any 193 | non-permissive terms added in accord with section 7 apply to the code; 194 | keep intact all notices of the absence of any warranty; and give all 195 | recipients a copy of this License along with the Program. 196 | 197 | You may charge any price or no price for each copy that you convey, 198 | and you may offer support or warranty protection for a fee. 199 | 200 | #### 5. Conveying Modified Source Versions. 201 | 202 | You may convey a work based on the Program, or the modifications to 203 | produce it from the Program, in the form of source code under the 204 | terms of section 4, provided that you also meet all of these 205 | conditions: 206 | 207 | - a) The work must carry prominent notices stating that you modified 208 | it, and giving a relevant date. 209 | - b) The work must carry prominent notices stating that it is 210 | released under this License and any conditions added under 211 | section 7. This requirement modifies the requirement in section 4 212 | to "keep intact all notices". 213 | - c) You must license the entire work, as a whole, under this 214 | License to anyone who comes into possession of a copy. This 215 | License will therefore apply, along with any applicable section 7 216 | additional terms, to the whole of the work, and all its parts, 217 | regardless of how they are packaged. This License gives no 218 | permission to license the work in any other way, but it does not 219 | invalidate such permission if you have separately received it. 220 | - d) If the work has interactive user interfaces, each must display 221 | Appropriate Legal Notices; however, if the Program has interactive 222 | interfaces that do not display Appropriate Legal Notices, your 223 | work need not make them do so. 224 | 225 | A compilation of a covered work with other separate and independent 226 | works, which are not by their nature extensions of the covered work, 227 | and which are not combined with it such as to form a larger program, 228 | in or on a volume of a storage or distribution medium, is called an 229 | "aggregate" if the compilation and its resulting copyright are not 230 | used to limit the access or legal rights of the compilation's users 231 | beyond what the individual works permit. Inclusion of a covered work 232 | in an aggregate does not cause this License to apply to the other 233 | parts of the aggregate. 234 | 235 | #### 6. Conveying Non-Source Forms. 236 | 237 | You may convey a covered work in object code form under the terms of 238 | sections 4 and 5, provided that you also convey the machine-readable 239 | Corresponding Source under the terms of this License, in one of these 240 | ways: 241 | 242 | - a) Convey the object code in, or embodied in, a physical product 243 | (including a physical distribution medium), accompanied by the 244 | Corresponding Source fixed on a durable physical medium 245 | customarily used for software interchange. 246 | - b) Convey the object code in, or embodied in, a physical product 247 | (including a physical distribution medium), accompanied by a 248 | written offer, valid for at least three years and valid for as 249 | long as you offer spare parts or customer support for that product 250 | model, to give anyone who possesses the object code either (1) a 251 | copy of the Corresponding Source for all the software in the 252 | product that is covered by this License, on a durable physical 253 | medium customarily used for software interchange, for a price no 254 | more than your reasonable cost of physically performing this 255 | conveying of source, or (2) access to copy the Corresponding 256 | Source from a network server at no charge. 257 | - c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | - d) Convey the object code by offering access from a designated 263 | place (gratis or for a charge), and offer equivalent access to the 264 | Corresponding Source in the same way through the same place at no 265 | further charge. You need not require recipients to copy the 266 | Corresponding Source along with the object code. If the place to 267 | copy the object code is a network server, the Corresponding Source 268 | may be on a different server (operated by you or a third party) 269 | that supports equivalent copying facilities, provided you maintain 270 | clear directions next to the object code saying where to find the 271 | Corresponding Source. Regardless of what server hosts the 272 | Corresponding Source, you remain obligated to ensure that it is 273 | available for as long as needed to satisfy these requirements. 274 | - e) Convey the object code using peer-to-peer transmission, 275 | provided you inform other peers where the object code and 276 | Corresponding Source of the work are being offered to the general 277 | public at no charge under subsection 6d. 278 | 279 | A separable portion of the object code, whose source code is excluded 280 | from the Corresponding Source as a System Library, need not be 281 | included in conveying the object code work. 282 | 283 | A "User Product" is either (1) a "consumer product", which means any 284 | tangible personal property which is normally used for personal, 285 | family, or household purposes, or (2) anything designed or sold for 286 | incorporation into a dwelling. In determining whether a product is a 287 | consumer product, doubtful cases shall be resolved in favor of 288 | coverage. For a particular product received by a particular user, 289 | "normally used" refers to a typical or common use of that class of 290 | product, regardless of the status of the particular user or of the way 291 | in which the particular user actually uses, or expects or is expected 292 | to use, the product. A product is a consumer product regardless of 293 | whether the product has substantial commercial, industrial or 294 | non-consumer uses, unless such uses represent the only significant 295 | mode of use of the product. 296 | 297 | "Installation Information" for a User Product means any methods, 298 | procedures, authorization keys, or other information required to 299 | install and execute modified versions of a covered work in that User 300 | Product from a modified version of its Corresponding Source. The 301 | information must suffice to ensure that the continued functioning of 302 | the modified object code is in no case prevented or interfered with 303 | solely because modification has been made. 304 | 305 | If you convey an object code work under this section in, or with, or 306 | specifically for use in, a User Product, and the conveying occurs as 307 | part of a transaction in which the right of possession and use of the 308 | User Product is transferred to the recipient in perpetuity or for a 309 | fixed term (regardless of how the transaction is characterized), the 310 | Corresponding Source conveyed under this section must be accompanied 311 | by the Installation Information. But this requirement does not apply 312 | if neither you nor any third party retains the ability to install 313 | modified object code on the User Product (for example, the work has 314 | been installed in ROM). 315 | 316 | The requirement to provide Installation Information does not include a 317 | requirement to continue to provide support service, warranty, or 318 | updates for a work that has been modified or installed by the 319 | recipient, or for the User Product in which it has been modified or 320 | installed. Access to a network may be denied when the modification 321 | itself materially and adversely affects the operation of the network 322 | or violates the rules and protocols for communication across the 323 | network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | #### 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders 351 | of that material) supplement the terms of this License with terms: 352 | 353 | - a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | - b) Requiring preservation of specified reasonable legal notices or 356 | author attributions in that material or in the Appropriate Legal 357 | Notices displayed by works containing it; or 358 | - c) Prohibiting misrepresentation of the origin of that material, 359 | or requiring that modified versions of such material be marked in 360 | reasonable ways as different from the original version; or 361 | - d) Limiting the use for publicity purposes of names of licensors 362 | or authors of the material; or 363 | - e) Declining to grant rights under trademark law for use of some 364 | trade names, trademarks, or service marks; or 365 | - f) Requiring indemnification of licensors and authors of that 366 | material by anyone who conveys the material (or modified versions 367 | of it) with contractual assumptions of liability to the recipient, 368 | for any liability that these contractual assumptions directly 369 | impose on those licensors and authors. 370 | 371 | All other non-permissive additional terms are considered "further 372 | restrictions" within the meaning of section 10. If the Program as you 373 | received it, or any part of it, contains a notice stating that it is 374 | governed by this License along with a term that is a further 375 | restriction, you may remove that term. If a license document contains 376 | a further restriction but permits relicensing or conveying under this 377 | License, you may add to a covered work material governed by the terms 378 | of that license document, provided that the further restriction does 379 | not survive such relicensing or conveying. 380 | 381 | If you add terms to a covered work in accord with this section, you 382 | must place, in the relevant source files, a statement of the 383 | additional terms that apply to those files, or a notice indicating 384 | where to find the applicable terms. 385 | 386 | Additional terms, permissive or non-permissive, may be stated in the 387 | form of a separately written license, or stated as exceptions; the 388 | above requirements apply either way. 389 | 390 | #### 8. Termination. 391 | 392 | You may not propagate or modify a covered work except as expressly 393 | provided under this License. Any attempt otherwise to propagate or 394 | modify it is void, and will automatically terminate your rights under 395 | this License (including any patent licenses granted under the third 396 | paragraph of section 11). 397 | 398 | However, if you cease all violation of this License, then your license 399 | from a particular copyright holder is reinstated (a) provisionally, 400 | unless and until the copyright holder explicitly and finally 401 | terminates your license, and (b) permanently, if the copyright holder 402 | fails to notify you of the violation by some reasonable means prior to 403 | 60 days after the cessation. 404 | 405 | Moreover, your license from a particular copyright holder is 406 | reinstated permanently if the copyright holder notifies you of the 407 | violation by some reasonable means, this is the first time you have 408 | received notice of violation of this License (for any work) from that 409 | copyright holder, and you cure the violation prior to 30 days after 410 | your receipt of the notice. 411 | 412 | Termination of your rights under this section does not terminate the 413 | licenses of parties who have received copies or rights from you under 414 | this License. If your rights have been terminated and not permanently 415 | reinstated, you do not qualify to receive new licenses for the same 416 | material under section 10. 417 | 418 | #### 9. Acceptance Not Required for Having Copies. 419 | 420 | You are not required to accept this License in order to receive or run 421 | a copy of the Program. Ancillary propagation of a covered work 422 | occurring solely as a consequence of using peer-to-peer transmission 423 | to receive a copy likewise does not require acceptance. However, 424 | nothing other than this License grants you permission to propagate or 425 | modify any covered work. These actions infringe copyright if you do 426 | not accept this License. Therefore, by modifying or propagating a 427 | covered work, you indicate your acceptance of this License to do so. 428 | 429 | #### 10. Automatic Licensing of Downstream Recipients. 430 | 431 | Each time you convey a covered work, the recipient automatically 432 | receives a license from the original licensors, to run, modify and 433 | propagate that work, subject to this License. You are not responsible 434 | for enforcing compliance by third parties with this License. 435 | 436 | An "entity transaction" is a transaction transferring control of an 437 | organization, or substantially all assets of one, or subdividing an 438 | organization, or merging organizations. If propagation of a covered 439 | work results from an entity transaction, each party to that 440 | transaction who receives a copy of the work also receives whatever 441 | licenses to the work the party's predecessor in interest had or could 442 | give under the previous paragraph, plus a right to possession of the 443 | Corresponding Source of the work from the predecessor in interest, if 444 | the predecessor has it or can get it with reasonable efforts. 445 | 446 | You may not impose any further restrictions on the exercise of the 447 | rights granted or affirmed under this License. For example, you may 448 | not impose a license fee, royalty, or other charge for exercise of 449 | rights granted under this License, and you may not initiate litigation 450 | (including a cross-claim or counterclaim in a lawsuit) alleging that 451 | any patent claim is infringed by making, using, selling, offering for 452 | sale, or importing the Program or any portion of it. 453 | 454 | #### 11. Patents. 455 | 456 | A "contributor" is a copyright holder who authorizes use under this 457 | License of the Program or a work on which the Program is based. The 458 | work thus licensed is called the contributor's "contributor version". 459 | 460 | A contributor's "essential patent claims" are all patent claims owned 461 | or controlled by the contributor, whether already acquired or 462 | hereafter acquired, that would be infringed by some manner, permitted 463 | by this License, of making, using, or selling its contributor version, 464 | but do not include claims that would be infringed only as a 465 | consequence of further modification of the contributor version. For 466 | purposes of this definition, "control" includes the right to grant 467 | patent sublicenses in a manner consistent with the requirements of 468 | this License. 469 | 470 | Each contributor grants you a non-exclusive, worldwide, royalty-free 471 | patent license under the contributor's essential patent claims, to 472 | make, use, sell, offer for sale, import and otherwise run, modify and 473 | propagate the contents of its contributor version. 474 | 475 | In the following three paragraphs, a "patent license" is any express 476 | agreement or commitment, however denominated, not to enforce a patent 477 | (such as an express permission to practice a patent or covenant not to 478 | sue for patent infringement). To "grant" such a patent license to a 479 | party means to make such an agreement or commitment not to enforce a 480 | patent against the party. 481 | 482 | If you convey a covered work, knowingly relying on a patent license, 483 | and the Corresponding Source of the work is not available for anyone 484 | to copy, free of charge and under the terms of this License, through a 485 | publicly available network server or other readily accessible means, 486 | then you must either (1) cause the Corresponding Source to be so 487 | available, or (2) arrange to deprive yourself of the benefit of the 488 | patent license for this particular work, or (3) arrange, in a manner 489 | consistent with the requirements of this License, to extend the patent 490 | license to downstream recipients. "Knowingly relying" means you have 491 | actual knowledge that, but for the patent license, your conveying the 492 | covered work in a country, or your recipient's use of the covered work 493 | in a country, would infringe one or more identifiable patents in that 494 | country that you have reason to believe are valid. 495 | 496 | If, pursuant to or in connection with a single transaction or 497 | arrangement, you convey, or propagate by procuring conveyance of, a 498 | covered work, and grant a patent license to some of the parties 499 | receiving the covered work authorizing them to use, propagate, modify 500 | or convey a specific copy of the covered work, then the patent license 501 | you grant is automatically extended to all recipients of the covered 502 | work and works based on it. 503 | 504 | A patent license is "discriminatory" if it does not include within the 505 | scope of its coverage, prohibits the exercise of, or is conditioned on 506 | the non-exercise of one or more of the rights that are specifically 507 | granted under this License. You may not convey a covered work if you 508 | are a party to an arrangement with a third party that is in the 509 | business of distributing software, under which you make payment to the 510 | third party based on the extent of your activity of conveying the 511 | work, and under which the third party grants, to any of the parties 512 | who would receive the covered work from you, a discriminatory patent 513 | license (a) in connection with copies of the covered work conveyed by 514 | you (or copies made from those copies), or (b) primarily for and in 515 | connection with specific products or compilations that contain the 516 | covered work, unless you entered into that arrangement, or that patent 517 | license was granted, prior to 28 March 2007. 518 | 519 | Nothing in this License shall be construed as excluding or limiting 520 | any implied license or other defenses to infringement that may 521 | otherwise be available to you under applicable patent law. 522 | 523 | #### 12. No Surrender of Others' Freedom. 524 | 525 | If conditions are imposed on you (whether by court order, agreement or 526 | otherwise) that contradict the conditions of this License, they do not 527 | excuse you from the conditions of this License. If you cannot convey a 528 | covered work so as to satisfy simultaneously your obligations under 529 | this License and any other pertinent obligations, then as a 530 | consequence you may not convey it at all. For example, if you agree to 531 | terms that obligate you to collect a royalty for further conveying 532 | from those to whom you convey the Program, the only way you could 533 | satisfy both those terms and this License would be to refrain entirely 534 | from conveying the Program. 535 | 536 | #### 13. Remote Network Interaction; Use with the GNU General Public License. 537 | 538 | Notwithstanding any other provision of this License, if you modify the 539 | Program, your modified version must prominently offer all users 540 | interacting with it remotely through a computer network (if your 541 | version supports such interaction) an opportunity to receive the 542 | Corresponding Source of your version by providing access to the 543 | Corresponding Source from a network server at no charge, through some 544 | standard or customary means of facilitating copying of software. This 545 | Corresponding Source shall include the Corresponding Source for any 546 | work covered by version 3 of the GNU General Public License that is 547 | incorporated pursuant to the following paragraph. 548 | 549 | Notwithstanding any other provision of this License, you have 550 | permission to link or combine any covered work with a work licensed 551 | under version 3 of the GNU General Public License into a single 552 | combined work, and to convey the resulting work. The terms of this 553 | License will continue to apply to the part which is the covered work, 554 | but the work with which it is combined will remain governed by version 555 | 3 of the GNU General Public License. 556 | 557 | #### 14. Revised Versions of this License. 558 | 559 | The Free Software Foundation may publish revised and/or new versions 560 | of the GNU Affero General Public License from time to time. Such new 561 | versions will be similar in spirit to the present version, but may 562 | differ in detail to address new problems or concerns. 563 | 564 | Each version is given a distinguishing version number. If the Program 565 | specifies that a certain numbered version of the GNU Affero General 566 | Public License "or any later version" applies to it, you have the 567 | option of following the terms and conditions either of that numbered 568 | version or of any later version published by the Free Software 569 | Foundation. If the Program does not specify a version number of the 570 | GNU Affero General Public License, you may choose any version ever 571 | published by the Free Software Foundation. 572 | 573 | If the Program specifies that a proxy can decide which future versions 574 | of the GNU Affero General Public License can be used, that proxy's 575 | public statement of acceptance of a version permanently authorizes you 576 | to choose that version for the Program. 577 | 578 | Later license versions may give you additional or different 579 | permissions. However, no additional obligations are imposed on any 580 | author or copyright holder as a result of your choosing to follow a 581 | later version. 582 | 583 | #### 15. Disclaimer of Warranty. 584 | 585 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 586 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 587 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT 588 | WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT 589 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 590 | A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND 591 | PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE 592 | DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR 593 | CORRECTION. 594 | 595 | #### 16. Limitation of Liability. 596 | 597 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 598 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR 599 | CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 600 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES 601 | ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT 602 | NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR 603 | LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM 604 | TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER 605 | PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 606 | 607 | #### 17. Interpretation of Sections 15 and 16. 608 | 609 | If the disclaimer of warranty and limitation of liability provided 610 | above cannot be given local legal effect according to their terms, 611 | reviewing courts shall apply local law that most closely approximates 612 | an absolute waiver of all civil liability in connection with the 613 | Program, unless a warranty or assumption of liability accompanies a 614 | copy of the Program in return for a fee. 615 | 616 | END OF TERMS AND CONDITIONS 617 | 618 | ### How to Apply These Terms to Your New Programs 619 | 620 | If you develop a new program, and you want it to be of the greatest 621 | possible use to the public, the best way to achieve this is to make it 622 | free software which everyone can redistribute and change under these 623 | terms. 624 | 625 | To do so, attach the following notices to the program. It is safest to 626 | attach them to the start of each source file to most effectively state 627 | the exclusion of warranty; and each file should have at least the 628 | "copyright" line and a pointer to where the full notice is found. 629 | 630 | 631 | Copyright (C) 632 | 633 | This program is free software: you can redistribute it and/or modify 634 | it under the terms of the GNU Affero General Public License as 635 | published by the Free Software Foundation, either version 3 of the 636 | License, or (at your option) any later version. 637 | 638 | This program is distributed in the hope that it will be useful, 639 | but WITHOUT ANY WARRANTY; without even the implied warranty of 640 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 641 | GNU Affero General Public License for more details. 642 | 643 | You should have received a copy of the GNU Affero General Public License 644 | along with this program. If not, see . 645 | 646 | Also add information on how to contact you by electronic and paper 647 | mail. 648 | 649 | If your software can interact with users remotely through a computer 650 | network, you should also make sure that it provides a way for users to 651 | get its source. For example, if your program is a web application, its 652 | interface could display a "Source" link that leads users to an archive 653 | of the code. There are many ways you could offer source, and different 654 | solutions will be better for different programs; see section 13 for 655 | the specific requirements. 656 | 657 | You should also get your employer (if you work as a programmer) or 658 | school, if any, to sign a "copyright disclaimer" for the program, if 659 | necessary. For more information on this, and how to apply and follow 660 | the GNU AGPL, see . 661 | --------------------------------------------------------------------------------