Hello world
This commit is contained in:
commit
67d2bd55fd
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
config.js
|
||||||
|
node_modules/
|
21
config.js.sample
Normal file
21
config.js.sample
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
export default {
|
||||||
|
xmpp: {
|
||||||
|
service: "kosmos.org",
|
||||||
|
resource: "lightning-antispam",
|
||||||
|
username: "botka",
|
||||||
|
password: "123456789",
|
||||||
|
nick: "botka",
|
||||||
|
rooms: [
|
||||||
|
"kosmos-random@kosmos.chat",
|
||||||
|
"lightningnetwork@kosmos.chat"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
amounts: {
|
||||||
|
voice: 1000 // sats
|
||||||
|
},
|
||||||
|
lndhub: {
|
||||||
|
endpoint: "https://lndhub.kosmos.org",
|
||||||
|
username: "123456789",
|
||||||
|
password: "123456789"
|
||||||
|
}
|
||||||
|
}
|
159
index.js
Normal file
159
index.js
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { client, xml } from "@xmpp/client";
|
||||||
|
// import debug from "@xmpp/debug";
|
||||||
|
import config from "./config.js";
|
||||||
|
import Lndhub from "./lib/lndhub.js";
|
||||||
|
import {
|
||||||
|
randomHex,
|
||||||
|
pingXml,
|
||||||
|
isPingResponse,
|
||||||
|
roomDirectMessageXml,
|
||||||
|
grantVoiceMessageXml,
|
||||||
|
parseForm,
|
||||||
|
isVoiceRequest,
|
||||||
|
} from "./lib/utils.js";
|
||||||
|
|
||||||
|
const xmpp = client(config.xmpp);
|
||||||
|
|
||||||
|
// debug(xmpp, true);
|
||||||
|
|
||||||
|
const connectRetryInMilliseconds = 5000;
|
||||||
|
const pingIntervalInMilliseconds = 3000;
|
||||||
|
const pingTimeoutInMilliseconds = 10000;
|
||||||
|
let pingInterval = null;
|
||||||
|
let lastPingSuccess = null;
|
||||||
|
|
||||||
|
const lndhub = new Lndhub(config.lndhub.endpoint);
|
||||||
|
|
||||||
|
function checkAndSendPing () {
|
||||||
|
const now = new Date();
|
||||||
|
if ((now.getTime() - lastPingSuccess.getTime()) > pingTimeoutInMilliseconds) {
|
||||||
|
console.log(`No ping response received in ${pingTimeoutInMilliseconds/1000} seconds, reconnecting`);
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
xmpp.stop().catch(console.error);
|
||||||
|
} else {
|
||||||
|
xmpp.send(pingXml(config.xmpp.service))
|
||||||
|
.catch(err => console.warn(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPingInterval () {
|
||||||
|
if (pingInterval) { clearInterval(pingInterval); }
|
||||||
|
pingInterval = setInterval(checkAndSendPing, pingIntervalInMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForInvoicePaid (room, nick, invoice, retries=0) {
|
||||||
|
if (retries >= 300) {
|
||||||
|
console.log(`Invoice not paid after 15 minutes: ${invoice.payment_hash}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
lndhub.getInvoice(invoice.payment_hash).then(async data => {
|
||||||
|
if (data.is_paid) {
|
||||||
|
console.log(`Invoice paid: ${invoice.payment_hash}`);
|
||||||
|
await grantVoice(room, nick);
|
||||||
|
} else {
|
||||||
|
setTimeout(waitForInvoicePaid, 3000, room, nick, invoice, retries++);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function respondToVoiceRequest (room, nick, invoice) {
|
||||||
|
const welcomeMessage = `
|
||||||
|
Welcome, ${nick}! I see you're interested in joining the conversation.
|
||||||
|
|
||||||
|
You can wait for a human moderator to approve your request, but it may take a while. You may also prove that you're not a spambot by donating a small amount of sats for the maintenance of this chat server via the following Lightning invoice. I will immediately approve your request in that case.
|
||||||
|
|
||||||
|
${invoice.payment_request}
|
||||||
|
`;
|
||||||
|
|
||||||
|
await xmpp.send(roomDirectMessageXml(room, nick, welcomeMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function grantVoice (room, nick) {
|
||||||
|
console.log(`Granting voice to ${nick} in ${room}`);
|
||||||
|
await xmpp.send(grantVoiceMessageXml(room, nick));
|
||||||
|
|
||||||
|
const message = `Thanks ${nick}, and welcome aboard! You can send messages to the channel now.`;
|
||||||
|
await xmpp.send(roomDirectMessageXml(room, nick, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVoiceRequest (stanza) {
|
||||||
|
try {
|
||||||
|
const formFields = parseForm(stanza);
|
||||||
|
const room = stanza.attrs.from;
|
||||||
|
// const jid = formFields.find(f => f.name === "muc#jid").value;
|
||||||
|
const nick = formFields.find(f => f.name === "muc#roomnick").value;
|
||||||
|
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);
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectXmpp () {
|
||||||
|
await xmpp.start().catch(err => {
|
||||||
|
console.log(`Connect error: ${err}`);
|
||||||
|
console.log(`Retrying in ${connectRetryInMilliseconds/1000} seconds`);
|
||||||
|
setTimeout(async () => {
|
||||||
|
await connectXmpp();
|
||||||
|
}, connectRetryInMilliseconds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectLndhub () {
|
||||||
|
return lndhub.auth(config.lndhub.username, config.lndhub.password).then(() => {
|
||||||
|
console.log("Connected to Lndhub");
|
||||||
|
}).catch(err => {
|
||||||
|
console.warn(`Lndhub auth failed: ${err}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePingResponse (stanza) {
|
||||||
|
lastPingSuccess = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIq (stanza) {
|
||||||
|
if (isPingResponse(stanza)) handlePingResponse(stanza);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMessage (stanza) {
|
||||||
|
if (isVoiceRequest(stanza)) handleVoiceRequest(stanza);
|
||||||
|
}
|
||||||
|
|
||||||
|
xmpp.on("error", (err) => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
xmpp.on("offline", () => {
|
||||||
|
console.log("Offline");
|
||||||
|
connectXmpp();
|
||||||
|
});
|
||||||
|
|
||||||
|
xmpp.on("online", async (address) => {
|
||||||
|
console.log("Connected to XMPP server");
|
||||||
|
|
||||||
|
for (const jid of config.xmpp.rooms) {
|
||||||
|
const msg = xml("presence", { to: `${jid}/${config.nick}` });
|
||||||
|
await xmpp.send(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
connectLndhub();
|
||||||
|
|
||||||
|
lastPingSuccess = new Date();
|
||||||
|
startPingInterval();
|
||||||
|
});
|
||||||
|
|
||||||
|
xmpp.on("stanza", async (stanza) => {
|
||||||
|
if (stanza.is("iq")) readIq(stanza);
|
||||||
|
if (stanza.is("message")) readMessage(stanza);
|
||||||
|
});
|
||||||
|
|
||||||
|
xmpp.on("status", (status) => {
|
||||||
|
console.debug("XMPP status:", status);
|
||||||
|
});
|
||||||
|
|
||||||
|
connectXmpp();
|
65
lib/lndhub.js
Normal file
65
lib/lndhub.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
|
export default class Lndhub {
|
||||||
|
constructor (baseUrl) {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async auth (username, password) {
|
||||||
|
const payload = { "login": username, "password": password };
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reauth (refreshToken) {
|
||||||
|
const payload = { "refresh_token": this.refreshToken };
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createInvoice (amount, description) {
|
||||||
|
const payload = { amount, description };
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
59
lib/utils.js
Normal file
59
lib/utils.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { xml } from "@xmpp/client";
|
||||||
|
|
||||||
|
export function randomHex () {
|
||||||
|
return Math.random().toString(16).slice(2, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pingXml (to) {
|
||||||
|
return xml(
|
||||||
|
"iq", { to, id: `ping-${randomHex()}`, type: "get" },
|
||||||
|
xml("ping", { xmlns: "urn:xmpp:ping" })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPingResponse (stanza) {
|
||||||
|
return stanza.attrs.id.match(/^ping/) &&
|
||||||
|
stanza.attrs.type === "result";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roomDirectMessageXml (room, nick, messageBody) {
|
||||||
|
return xml(
|
||||||
|
"message", { type: "chat", to: `${room}/${nick}` },
|
||||||
|
xml("body", {}, messageBody)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function grantVoiceMessageXml (room, nick) {
|
||||||
|
return xml(
|
||||||
|
"iq", { id: `voice-${randomHex()}`, to: room, type: "set" },
|
||||||
|
xml("query", { xmlns: "http://jabber.org/protocol/muc#admin" },
|
||||||
|
xml("item", { nick: nick, role: "participant" }
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseForm (stanza) {
|
||||||
|
const form = stanza.getChild("x", "jabber:x:data");
|
||||||
|
if (!form) return false;
|
||||||
|
|
||||||
|
const fieldElements = form.getChildren("field");
|
||||||
|
const fields = [];
|
||||||
|
|
||||||
|
for (const e of fieldElements) {
|
||||||
|
fields.push({
|
||||||
|
name: e.attrs.var,
|
||||||
|
value: e.getChild("value").text()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVoiceRequest (stanza) {
|
||||||
|
const formFields = parseForm(stanza);
|
||||||
|
|
||||||
|
return !!formFields && formFields.find(f => {
|
||||||
|
return f.name === "FORM_TYPE" &&
|
||||||
|
f.value === "http://jabber.org/protocol/muc#request"
|
||||||
|
});
|
||||||
|
}
|
4744
package-lock.json
generated
Normal file
4744
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
Normal file
17
package.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "xmpp-lightning-antispam",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"author": "Râu Cao",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@xmpp/client": "^0.13.1",
|
||||||
|
"@xmpp/debug": "^0.13.0",
|
||||||
|
"node-fetch": "^3.3.2"
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user