Compare commits

...

11 Commits

Author SHA1 Message Date
0caaf3c88a 1.2.0 2024-06-27 12:31:29 +02:00
bbd41a697d Fix function context for scheduled reauth 2024-06-12 17:08:36 +02:00
04ec236c58 Update README 2024-06-12 17:08:02 +02:00
310ee0d6eb Update dependencies 2024-06-12 17:07:58 +02:00
2c4724638a Add documentation 2024-02-12 14:04:58 +01:00
Râu Cao
867bdcf978 Add license 2023-11-30 16:58:41 +01:00
Râu Cao
d4ab769453 1.1.0 2023-11-23 00:22:31 +01:00
Râu Cao
ae44874671 Remove debug log 2023-11-22 21:14:44 +01:00
Râu Cao
833ab50616 Automatically refresh lndhub token once a day 2023-11-22 21:12:24 +01:00
Râu Cao
35844b577c Handle failed API requests, reauth when token expired 2023-11-22 21:08:01 +01:00
Râu Cao
b51bbd446c Wait longer, increase check interval for payment checks 2023-11-22 21:07:01 +01:00
9 changed files with 196 additions and 68 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,2 @@
config.js
/config.js
node_modules/

19
LICENSE Normal file
View File

@@ -0,0 +1,19 @@
Copyright (c) 2023 Râu Cao
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

56
README.md Normal file
View File

@@ -0,0 +1,56 @@
# XMPP Lightning Anti-Spam
In order to combat spam in public chat rooms, they can be configured so that
new, unknown participants have to request voice before being able to write
messages (known as a "moderated channel").
This bot listens to those voice requests, and when it sees one, it sends a
direct message to the user, which offers to give them voice immediately, in
exchange for a small amount of sats[^1], payable via the Lightning Network[^2].
## Requirements
* [Node.js](https://nodejs.org) and [NPM](https://www.npmjs.com)
* An XMPP account, which has admin permissions for the channels you want it to
manage voice requests in
* A hosted Lightning Network account compatible with LndHub clients (e.g. a
[Kosmos](https://kosmos.org) or [Alby](https://getalby.com/)
account, or self-hosted with
[lndhub.go](https://github.com/getAlby/lndhub.go)).
## Setup
Clone this repository:
git clone https://gitea.kosmos.org/raucao/xmpp-lightning-antispam.git
Install required dependencies:
npm install
Create a config file from the included sample:
cp samples/config.js config.js
Edit the config file using your favorite editor:
vim config.js
Start the program:
npm start
## Deployment
Set up as described above, or use a cloud platform instead.
An example for a systemd service can be found in
`samples/xmpp-lightning-antispam.service`.
## Contact
E-Mail, Chat, Social, Lightning: raucao@kosmos.org
[^1]: Sats are the smallest unit of bitcoin (1 sat = 0.00000001 BTC)
[^2]: The Lightning Network is a peer-to-peer network for instantly and very
cheaply sending and receiving bitcoin directly between nodes

View File

@@ -42,16 +42,24 @@ function startPingInterval () {
}
function waitForInvoicePaid (room, nick, invoice, retries=0) {
let timeout = 3000;
if (retries >= 300) {
console.log(`Invoice not paid after 15 minutes: ${invoice.payment_hash}`);
return false;
let timeout = 5000;
if (retries >= 500) {
// TODO ask user to request voice again to create a new invoice
return false;
}
}
lndhub.getInvoice(invoice.payment_hash).then(async data => {
if (data.is_paid) {
if (data && data.is_paid) {
console.log(`Invoice paid: ${invoice.payment_hash}`);
await grantVoice(room, nick);
} else {
setTimeout(waitForInvoicePaid, 3000, room, nick, invoice, retries++);
if (!data) console.warn('Fetching invoice status failed');
setTimeout(waitForInvoicePaid, timeout, room, nick, invoice, retries++);
}
});
}
@@ -85,10 +93,14 @@ async function handleVoiceRequest (stanza) {
console.log(`${nick} requested voice in ${room}`);
const invoice = await lndhub.createInvoice(config.amounts.voice, `Donation for ${room}`);
console.log(`Created lightning invoice for ${nick}: ${invoice.payment_hash}`)
await respondToVoiceRequest(room, nick, invoice);
waitForInvoicePaid(room, nick, invoice);
if (invoice) {
console.log(`Created lightning invoice for ${nick}: ${invoice.payment_hash}`)
await respondToVoiceRequest(room, nick, invoice);
waitForInvoicePaid(room, nick, invoice);
} else {
console.warn(`Invoice creation failed!`);
// TODO notify ops contact
}
} catch(err) {
console.error(err);
}
@@ -105,10 +117,16 @@ async function connectXmpp () {
}
function connectLndhub () {
return lndhub.auth(config.lndhub.username, config.lndhub.password).then(() => {
console.log("Connected to Lndhub");
return lndhub.auth(config.lndhub.username, config.lndhub.password).then(connected => {
if (connected) {
console.log("Connected to Lndhub");
// automatically refresh auth token once a day
setInterval(() => lndhub.reauth(), 86400000);
} else {
process.exit(1);
}
}).catch(err => {
console.warn(`Lndhub auth failed: ${err}`);
console.error(`Lndhub connection failed: ${err}`);
});
}

View File

@@ -5,61 +5,82 @@ export default class Lndhub {
this.baseUrl = baseUrl;
}
async callEndpoint (method, path, payload) {
const options = { method, headers: { 'Content-Type': 'application/json' } };
if (path !== '/auth') {
options.headers['Authorization'] = `Bearer ${this.accessToken}`;
}
if (typeof payload !== 'undefined') {
options.body = JSON.stringify(payload);
}
const res = await fetch(`${this.baseUrl}${path}`, options);
return res.json();
}
async handleErroredRequest (result, retryFunction, args=[]) {
console.warn('API request failed:', result.message);
if (result.code === 1) {
return this.reauth().then(connected => {
if (connected) {
console.warn('Lndhub reconnected, trying again...');
return this[retryFunction](...args);
}
});
} else {
return false;
}
}
async auth (username, password) {
const payload = { "login": username, "password": password };
const data = await this.callEndpoint('post', '/auth', payload);
const res = await fetch(`${this.baseUrl}/auth`, {
method: 'post',
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json();
this.refreshToken = data.refresh_token;
this.accessToken = data.access_token;
if (data.error) {
console.warn('Lndhub connection failed:', data.message);
return false;
} else {
this.refreshToken = data.refresh_token;
this.accessToken = data.access_token;
return true;
}
}
async reauth (refreshToken) {
const payload = { "refresh_token": this.refreshToken };
const data = await this.callEndpoint('post', '/auth', payload);
const res = await fetch(`${this.baseUrl}/auth`, {
method: 'post',
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json();
this.accessToken = data.access_token;
if (data.error) {
console.warn('Lndhub re-auth failed:', data.message);
return false;
} else {
if (this.accessToken !== data.access_token) {
console.log('Lndhub access token refreshed');
}
this.accessToken = data.access_token;
return true;
}
}
async createInvoice (amount, description) {
const payload = { amount, description };
let data = await this.callEndpoint('post', '/v2/invoices', payload);
const res = await fetch(`${this.baseUrl}/v2/invoices`, {
method: 'post',
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.accessToken}`
}
});
const data = await res.json();
// TODO re-auth if token has expired
return data;
if (data.error) {
return this.handleErroredRequest(data, 'createInvoice', Array.from(arguments));
} else {
return data;
}
}
async getInvoice (paymentHash) {
const res = await fetch(`${this.baseUrl}/v2/invoices/${paymentHash}`, {
method: 'get',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.accessToken}`
}
});
const data = await res.json();
// TODO re-auth if token has expired
const data = await this.callEndpoint('get', `/v2/invoices/${paymentHash}`);
return data;
if (data.error) {
return this.handleErroredRequest(data, 'getInvoice', Array.from(arguments));
} else {
return data;
}
}
}

32
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "xmpp-lightning-antispam",
"version": "1.0.0",
"version": "1.2.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "xmpp-lightning-antispam",
"version": "1.0.0",
"version": "1.2.0",
"license": "MIT",
"dependencies": {
"@xmpp/client": "^0.13.1",
@@ -941,12 +941,12 @@
}
},
"node_modules/braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"optional": true,
"dependencies": {
"fill-range": "^7.0.1"
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
@@ -1344,9 +1344,9 @@
}
},
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"optional": true,
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -3467,12 +3467,12 @@
}
},
"braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"optional": true,
"requires": {
"fill-range": "^7.0.1"
"fill-range": "^7.1.1"
}
},
"browserslist": {
@@ -3748,9 +3748,9 @@
}
},
"fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"optional": true,
"requires": {
"to-regex-range": "^5.0.1"

View File

@@ -1,10 +1,11 @@
{
"name": "xmpp-lightning-antispam",
"version": "1.0.0",
"description": "",
"version": "1.2.0",
"description": "A bot for moderated XMPP channels, offering voice for Lightning donations",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Râu Cao",

View File

@@ -0,0 +1,13 @@
[Unit]
Description=XMPP Lightning Antispam bot
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/opt/xmpp-lightning-antispam
ExecStart=/usr/bin/node index.js
Restart=on-failure
[Install]
WantedBy=multi-user.target