import WebSocket from 'ws';
import { stripHtml } from "string-strip-html";
import { readFileSync, writeFileSync } from "node:fs";
import config from './config.json';
import express from 'express';
import Handlebars from "handlebars";
import { CronJob } from 'cron';
const app = express();
const port = 3023;
const HELP_TEMPLATE =
"WafringBot is a Wafrn bot that handles registration on the Wafring (The Wafrn Webring).\n" +
"Member list is on https://wafring.jbc.lol/\n" +
"\n" +
"Commands available in the bot:\n" +
"\n" +
"```\n" +
" PARAMS: [required], (optional)\n" +
"\n" +
" (nothing) see this screen\n" +
" join [website] [...desc] join the wafring\n" +
" edit [website] [...desc] edit your member info\n" +
" leave leave the wafring\n" +
" is-wafrn (handle) util cmd, checks if the fedi instance\n" +
" is a Wafrn instance or not\n" +
"\n" +
" EXAMPLE: @wafring@wf.jbc.lol join https://jbc.lol/ A developer.\n" +
"```\n" +
"\n" +
"The prefix is the bot's mention (@wafring@wf.jbc.lol). Note that it only works on DMs (private mentions).";
let WAFRING_JSON = [];
try {
WAFRING_JSON = JSON.parse(readFileSync('./members.json', 'utf-8'));
} catch {
writeFileSync('./members.json', '[]');
}
Array.prototype.sample = function () {
return this[Math.floor(Math.random() * this.length)];
}
export class WafrnApi {
_token = '';
_instance = '';
_recentPosts = [];
constructor() { }
async init(server, email, password) {
this._instance = server;
const res = await fetch(`https://${server}/api/login`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
email: email,
password: password
})
})
if (!res.ok) {
throw new Error('Could not sign in, ' + await res.text());
}
const json = await res.json();
if (!json.success || !res.ok) {
throw new Error('Could not sign in, ' + await res.text());
}
this._token = json.token;
this.loadWs();
new CronJob(
'*/1 * * * *',
() => {
this.checkIfMatches()
},
null,
true,
'America/Los_Angeles'
);
new CronJob(
'0 */1 * * *',
() => {
this.fetchUpdates()
},
null,
true,
'America/Los_Angeles'
);
await this.fetchUpdates()
}
loadWs() {
const ws = new WebSocket(`wss://${this._instance}/api/notifications/socket`);
ws.on('open', () => {
ws.send(JSON.stringify({
type: "auth",
object: this._token
}))
console.log('connected');
})
ws.on('message', async (msg) => {
const message = msg.toString();
const msgJson = JSON.parse(message);
console.log(msgJson);
if (msgJson.type === "MENTION") {
setTimeout(() => this.checkIfMatches(), 15000);
}
})
ws.on('close', () => {
console.log('reconnecting in 10s...');
setTimeout(() => { this.loadWs(); }, 10000);
})
}
async fetchUpdates() {
const list = WAFRING_JSON;
console.log('updating members');
for (let i = 0; i < list.length; i++) {
const member = list[i];
let memberName = member.handle;
if (memberName.endsWith('wf.jbc.lol')) {
memberName = memberName.replace('@', '').split('@')[0];
}
const res = await fetch(`https://wf.jbc.lol/api/user?id=${memberName}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${this._token}`
},
});
if (!res.ok) {
console.log('could not get user', memberName)
}
const json = await res.json();
WAFRING_JSON[i] = {
...member,
avatar: json.avatar.startsWith('https') ? json.avatar : `https://wfmdi.jbc.lol${json.avatar}`,
headerImage: json.headerImage.startsWith('https') ? json.headerImage : `https://wfmdi.jbc.lol${json.headerImage}`,
}
console.log('updated member', memberName);
}
this.saveMembers();
console.log('updated all members')
}
async checkIfMatches() {
const notifs = await this.getNotifs();
const firstNotif = notifs.notifications[0];
if (this._recentPosts.length === 0) this._recentPosts.push(`${firstNotif.userId}${firstNotif.postId}`);
if (this._recentPosts.includes(`${firstNotif.userId}${firstNotif.postId}`)) return;
for (let i = 0; i < notifs.notifications.length; i++) {
console.log(this._recentPosts);
const thisNotif = notifs.notifications[i];
if (this._recentPosts.includes(`${thisNotif.userId}${thisNotif.postId}`)) return;
else this._recentPosts.push(`${thisNotif.userId}${thisNotif.postId}`);
const postNotif = await this.getPost(thisNotif.postId);
const mdContent = postNotif.posts[0].markdownContent ?? stripHtml(postNotif.posts[0].content).result;
const contSpl = mdContent.split(' ');
const mention = contSpl[0];
const rest = '' + contSpl.slice(1).join(' ').toLowerCase();
const restSpl = rest.split(' ');
const command = '' + restSpl[0];
const args = '' + restSpl.slice(1).join(' ').toLowerCase();
let user = thisNotif.user.url;
let u2 = user;
if (!user.startsWith('@')) user = '@' + user;
if (!user.replace(/^\@/, '').includes('@')) user = user + '@wf.jbc.lol'
console.log(contSpl, mention, rest);
console.log(postNotif);
console.log(thisNotif);
if (postNotif.posts[0].privacy !== 10) {
return;
}
console.log(mention, command, args);
if (command === 'is-wafrn') {
const ifWafrn = await this.checkIfWafrn(!!args ? args : user);
await this.send("", `${!!args ? args + ' is' : 'You are'} ${ifWafrn.isWafrn ? 'in' : 'not in'} a Wafrn instance (${ifWafrn.httpError ? 'either not a Fediverse instance or HTTP/fetch error getting nodeinfo, try again later' : `${ifWafrn.software.name} ${ifWafrn.software.version}`})`, postNotif.posts[0].privacy, "", postNotif.users.map(t => t.id), postNotif.posts[0].id);
console.log(`sent post in woot ${postNotif.posts[0].id}`);
return;
} else if (command === 'join') {
const isWafrn = await this.checkIfWafrn(user);
const website = args.split(' ')[0];
const restArgs = args.split(' ').slice(1).join(' ');
const member = WAFRING_JSON.find(x => x.handle === user);
if (!isWafrn.isWafrn) {
await this.send("", 'You are not in a Wafrn instance. Run `is-wafrn` to learn more.\n\nIf you want to join a Fediverse webring, you might wanna check out [Fediring](https://fediring.net/)!', postNotif.posts[0].privacy, "", postNotif.users.map(t => t.id), postNotif.posts[0].id);
return;
} else if (!website) {
await this.send("", 'You need to specify your website URL.', postNotif.posts[0].privacy, "", postNotif.users.map(t => t.id), postNotif.posts[0].id);
return;
} else if (!!member) {
await this.send("", `You already joined the Wafring. Use \`edit\` to edit your info.\n\nWebsite: ${member.url}\nDescription: ${member.desc}`, postNotif.posts[0].privacy, "", postNotif.users.map(t => t.id), postNotif.posts[0].id);
return;
}
WAFRING_JSON.push({
handle: user,
url: website,
desc: !!restArgs ? restArgs : 'No description',
avatar: thisNotif.user.avatar,
headerImage: thisNotif.user.headerImage,
id: thisNotif.user.id
})
this.saveMembers();
await this.send("", `You are now on Wafring!\n\nWebsite: ${website}\nDescription: ${!!restArgs ? restArgs : 'No description'}\n\n[Previous URL](https://wafring.jbc.lol/prev/${user.replace(/^\@/, '')}/), [Next URL](https://wafring.jbc.lol/next/${user.replace(/^\@/, '')}/), [Random URL](https://wafring.jbc.lol/rand/)`, postNotif.posts[0].privacy, "", postNotif.users.map(t => t.id), postNotif.posts[0].id);
return;
} else if (command === 'edit') {
const website = args.split(' ')[0];
const restArgs = args.split(' ').slice(1).join(' ');
const member = WAFRING_JSON.find(x => x.handle === user);
if (!member) {
await this.send("", `You haven't joined the Wafring. Use \`join\` to edit your info.\n\nWebsite: ${member.url}\nDescription: ${member.desc}`, postNotif.posts[0].privacy, "", postNotif.users.map(t => t.id), postNotif.posts[0].id);
return;
}
const idx = WAFRING_JSON.indexOf(member);
WAFRING_JSON[idx] = {
handle: user,
url: website,
desc: !!restArgs ? restArgs : 'No description',
avatar: thisNotif.user.avatar,
headerImage: thisNotif.user.headerImage,
id: thisNotif.user.id
}
this.saveMembers();
await this.send("", `Your info is now edited!\n\nWebsite: ${website}\nDescription: ${!!restArgs ? restArgs : 'No description'}\n\n[Previous URL](https://wafring.jbc.lol/prev/${user.replace(/^\@/, '')}/), [Next URL](https://wafring.jbc.lol/next/${user.replace(/^\@/, '')}/), [Random URL](https://wafring.jbc.lol/rand/)`, postNotif.posts[0].privacy, "", postNotif.users.map(t => t.id), postNotif.posts[0].id);
return;
} else if (command === 'leave') {
const member = WAFRING_JSON.find(x => x.handle === user);
if (!member) {
await this.send("", `You haven't joined the Wafring. Use \`join\` to edit your info.\n\nWebsite: ${member.url}\nDescription: ${member.desc}`, postNotif.posts[0].privacy, "", postNotif.users.map(t => t.id), postNotif.posts[0].id);
return;
}
WAFRING_JSON = WAFRING_JSON.filter(x => x !== member);
this.saveMembers();
await this.send("", `You left the Wafring. You can join back later using \`join\`!`, postNotif.posts[0].privacy, "", postNotif.users.map(t => t.id), postNotif.posts[0].id);
return;
}
await this.send("", HELP_TEMPLATE, postNotif.posts[0].privacy, "", postNotif.users.map(t => t.id), postNotif.posts[0].id);
}
}
async saveMembers() {
writeFileSync('./members.json', JSON.stringify(WAFRING_JSON));
}
async checkIfWafrn(uname) {
const nameSpl = uname.split('@');
const prov = nameSpl[2] ?? 'wf.jbc.lol';
console.log(prov)
const nfRes = await fetch(`https://${prov}/.well-known/nodeinfo`);
if (!nfRes.ok) return {
isWafrn: false,
httpError: true
};
const link = await nfRes.json();
console.log(link);
const nfInst = await fetch(link.links[0].href);
if (!nfInst.ok) return {
isWafrn: false,
httpError: true
};
const nfJson = await nfInst.json();
if (nfJson.software.name.toLowerCase() !== 'wafrn') return {
isWafrn: false,
software: nfJson.software,
metadata: nfJson.metadata
};
return {
isWafrn: true,
software: nfJson.software,
metadata: nfJson.metadata
};
}
async send(username, html, type = 10, warning = '', mentions = [], parent = '') {
if (!this._token) {
throw new Error('Please log in first');
}
const res = await fetch(`https://${this._instance}/api/v3/createPost`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this._token}`
},
body: JSON.stringify({
content: `${!!username ? `@${username}\n\n` : ''}${html}`,
"content_warning": warning,
medias: [],
mentionedUserIds: mentions,
parent: parent,
privacy: type,
tags: ""
})
})
if (!res.ok) {
throw new Error('Could not create dm');
}
}
async getNotifs() {
const res = await fetch(`https://${this._instance}/api/v3/notificationsScroll?date=${new Date().getTime()}&page=0`, {
method: 'GET',
headers: {
Authorization: `Bearer ${this._token}`
},
});
if (!res.ok) {
throw new Error('Could not get notifs');
}
const resJson = await res.json();
return resJson;
}
async getPost(postId) {
const res = await fetch(`https://${this._instance}/api/v2/post/${postId}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${this._token}`
},
});
const text = await res.text();
if (!res.ok || !text) {
throw new Error('Could not get post, ' + text);
}
let resJson = JSON.parse(text);
return resJson;
}
}
const w = new WafrnApi();
w.init(config.instance, config.email, config.password);
console.log('e');
app.get('/.json', (req, res) => {
res.setHeader('x-robots-tag', 'noindex, nofollow');
res.setHeader('x-powered-by', 'wafring');
res.setHeader('content-type', 'application/json; charset=utf-8');
res.send(JSON.stringify(WAFRING_JSON));
})
app.get('/', (req, res) => {
const templateHtml = readFileSync('template.handlebars', 'utf-8');
const template = Handlebars.compile(templateHtml);
res.setHeader('x-robots-tag', 'noindex, nofollow');
res.setHeader('x-powered-by', 'wafring');
res.setHeader('content-type', 'text/html; charset=utf-8');
res.send(template({ members: WAFRING_JSON }));
})
app.get('/:way/:user', (req, res) => {
res.setHeader('x-robots-tag', 'noindex, nofollow');
res.setHeader('x-powered-by', 'wafring');
res.setHeader('content-type', 'text/html; charset=utf-8');
const way = req.params.way;
const user = req.params.user;
const jUser = WAFRING_JSON.find(x => x.handle.replace(/^\@/, '') === user.replace(/^\@/, ''));
if (!jUser && (way === 'prev' || way === 'next')) {
res.status(404);
res.send('not found');
return;
} else if (way === 'rand') {
const s = WAFRING_JSON.sample();
res.redirect(302, s.url);
return;
}
const idx = WAFRING_JSON.indexOf(jUser);
const sel = (way === 'prev') ? WAFRING_JSON[(idx === 0) ? WAFRING_JSON.length - 1 : idx - 1] : WAFRING_JSON[(idx === WAFRING_JSON.length - 1) ? 0 : idx + 1]
res.redirect(302, sel.url);
})
app.get('/rand', (req, res) => {
const s = WAFRING_JSON.sample();
res.redirect(302, s.url);
})
app.listen(port, () => {
console.log(`Wafring listening at port ${port}`);
})