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}`); })