A webring for Wafrn users. https://wafring.jbc.lol (mirror of https://git.jbc.lol/jbcrn/wafring)
at master 422 lines 16 kB view raw
1import WebSocket from 'ws'; 2import { stripHtml } from "string-strip-html"; 3import { readFileSync, writeFileSync } from "node:fs"; 4import config from './config.json'; 5import express from 'express'; 6import Handlebars from "handlebars"; 7import { CronJob } from 'cron'; 8 9const app = express(); 10const port = 3023; 11 12const HELP_TEMPLATE = 13 "WafringBot is a Wafrn bot that handles registration on the Wafring (The Wafrn Webring).\n" + 14 "Member list is on https://wafring.jbc.lol/\n" + 15 "\n" + 16 "Commands available in the bot:\n" + 17 "\n" + 18 "```\n" + 19 " PARAMS: [required], (optional)\n" + 20 "\n" + 21 " (nothing) see this screen\n" + 22 " join [website] [...desc] join the wafring\n" + 23 " edit [website] [...desc] edit your member info\n" + 24 " leave leave the wafring\n" + 25 " is-wafrn (handle) util cmd, checks if the fedi instance\n" + 26 " is a Wafrn instance or not\n" + 27 "\n" + 28 " EXAMPLE: @wafring@wf.jbc.lol join https://jbc.lol/ A developer.\n" + 29 "```\n" + 30 "\n" + 31 "The prefix is the bot's mention (@wafring@wf.jbc.lol). Note that it only works on DMs (private mentions)."; 32 33let WAFRING_JSON = []; 34 35try { 36 WAFRING_JSON = JSON.parse(readFileSync('./members.json', 'utf-8')); 37} catch { 38 writeFileSync('./members.json', '[]'); 39} 40 41Array.prototype.sample = function () { 42 return this[Math.floor(Math.random() * this.length)]; 43} 44 45export class WafrnApi { 46 _token = ''; 47 _instance = ''; 48 _recentPosts = []; 49 50 constructor() { } 51 52 async init(server, email, password) { 53 this._instance = server; 54 const res = await fetch(`https://${server}/api/login`, { 55 method: "POST", 56 headers: { 57 "Content-Type": "application/json" 58 }, 59 body: JSON.stringify({ 60 email: email, 61 password: password 62 }) 63 }) 64 if (!res.ok) { 65 throw new Error('Could not sign in, ' + await res.text()); 66 } 67 const json = await res.json(); 68 if (!json.success || !res.ok) { 69 throw new Error('Could not sign in, ' + await res.text()); 70 } 71 72 this._token = json.token; 73 this.loadWs(); 74 75 new CronJob( 76 '*/1 * * * *', 77 () => { 78 this.checkIfMatches() 79 }, 80 null, 81 true, 82 'America/Los_Angeles' 83 ); 84 85 new CronJob( 86 '0 */1 * * *', 87 () => { 88 this.fetchUpdates() 89 }, 90 null, 91 true, 92 'America/Los_Angeles' 93 ); 94 95 await this.fetchUpdates() 96 } 97 98 loadWs() { 99 const ws = new WebSocket(`wss://${this._instance}/api/notifications/socket`); 100 ws.on('open', () => { 101 ws.send(JSON.stringify({ 102 type: "auth", 103 object: this._token 104 })) 105 console.log('connected'); 106 }) 107 108 ws.on('message', async (msg) => { 109 const message = msg.toString(); 110 const msgJson = JSON.parse(message); 111 console.log(msgJson); 112 113 if (msgJson.type === "MENTION") { 114 setTimeout(() => this.checkIfMatches(), 15000); 115 } 116 }) 117 118 ws.on('close', () => { 119 console.log('reconnecting in 10s...'); 120 setTimeout(() => { this.loadWs(); }, 10000); 121 }) 122 } 123 124 async fetchUpdates() { 125 const list = WAFRING_JSON; 126 console.log('updating members'); 127 128 for (let i = 0; i < list.length; i++) { 129 const member = list[i]; 130 let memberName = member.handle; 131 if (memberName.endsWith('wf.jbc.lol')) { 132 memberName = memberName.replace('@', '').split('@')[0]; 133 } 134 135 const res = await fetch(`https://wf.jbc.lol/api/user?id=${memberName}`, { 136 method: 'GET', 137 headers: { 138 Authorization: `Bearer ${this._token}` 139 }, 140 }); 141 142 if (!res.ok) { 143 console.log('could not get user', memberName) 144 } 145 146 const json = await res.json(); 147 148 WAFRING_JSON[i] = { 149 ...member, 150 avatar: json.avatar.startsWith('https') ? json.avatar : `https://wfmdi.jbc.lol${json.avatar}`, 151 headerImage: json.headerImage.startsWith('https') ? json.headerImage : `https://wfmdi.jbc.lol${json.headerImage}`, 152 } 153 154 console.log('updated member', memberName); 155 } 156 157 this.saveMembers(); 158 console.log('updated all members') 159 } 160 161 async checkIfMatches() { 162 const notifs = await this.getNotifs(); 163 const firstNotif = notifs.notifications[0]; 164 if (this._recentPosts.length === 0) this._recentPosts.push(`${firstNotif.userId}${firstNotif.postId}`); 165 if (this._recentPosts.includes(`${firstNotif.userId}${firstNotif.postId}`)) return; 166 167 for (let i = 0; i < notifs.notifications.length; i++) { 168 console.log(this._recentPosts); 169 const thisNotif = notifs.notifications[i]; 170 if (this._recentPosts.includes(`${thisNotif.userId}${thisNotif.postId}`)) return; 171 else this._recentPosts.push(`${thisNotif.userId}${thisNotif.postId}`); 172 const postNotif = await this.getPost(thisNotif.postId); 173 174 const mdContent = postNotif.posts[0].markdownContent ?? stripHtml(postNotif.posts[0].content).result; 175 const contSpl = mdContent.split(' '); 176 const mention = contSpl[0]; 177 const rest = '' + contSpl.slice(1).join(' ').toLowerCase(); 178 const restSpl = rest.split(' '); 179 const command = '' + restSpl[0]; 180 const args = '' + restSpl.slice(1).join(' ').toLowerCase(); 181 182 let user = thisNotif.user.url; 183 let u2 = user; 184 if (!user.startsWith('@')) user = '@' + user; 185 if (!user.replace(/^\@/, '').includes('@')) user = user + '@wf.jbc.lol' 186 187 console.log(contSpl, mention, rest); 188 console.log(postNotif); 189 console.log(thisNotif); 190 if (postNotif.posts[0].privacy !== 10) { 191 return; 192 } 193 194 console.log(mention, command, args); 195 196 if (command === 'is-wafrn') { 197 const ifWafrn = await this.checkIfWafrn(!!args ? args : user); 198 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); 199 200 console.log(`sent post in woot ${postNotif.posts[0].id}`); 201 return; 202 } else if (command === 'join') { 203 const isWafrn = await this.checkIfWafrn(user); 204 const website = args.split(' ')[0]; 205 const restArgs = args.split(' ').slice(1).join(' '); 206 const member = WAFRING_JSON.find(x => x.handle === user); 207 208 if (!isWafrn.isWafrn) { 209 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); 210 return; 211 } else if (!website) { 212 await this.send("", 'You need to specify your website URL.', postNotif.posts[0].privacy, "", postNotif.users.map(t => t.id), postNotif.posts[0].id); 213 return; 214 } else if (!!member) { 215 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); 216 return; 217 } 218 219 WAFRING_JSON.push({ 220 handle: user, 221 url: website, 222 desc: !!restArgs ? restArgs : 'No description', 223 avatar: thisNotif.user.avatar, 224 headerImage: thisNotif.user.headerImage, 225 id: thisNotif.user.id 226 }) 227 this.saveMembers(); 228 229 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); 230 return; 231 } else if (command === 'edit') { 232 const website = args.split(' ')[0]; 233 const restArgs = args.split(' ').slice(1).join(' '); 234 const member = WAFRING_JSON.find(x => x.handle === user); 235 236 if (!member) { 237 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); 238 return; 239 } 240 241 const idx = WAFRING_JSON.indexOf(member); 242 WAFRING_JSON[idx] = { 243 handle: user, 244 url: website, 245 desc: !!restArgs ? restArgs : 'No description', 246 avatar: thisNotif.user.avatar, 247 headerImage: thisNotif.user.headerImage, 248 id: thisNotif.user.id 249 } 250 this.saveMembers(); 251 252 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); 253 return; 254 } else if (command === 'leave') { 255 const member = WAFRING_JSON.find(x => x.handle === user); 256 257 if (!member) { 258 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); 259 return; 260 } 261 262 WAFRING_JSON = WAFRING_JSON.filter(x => x !== member); 263 this.saveMembers(); 264 265 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); 266 return; 267 } 268 269 await this.send("", HELP_TEMPLATE, postNotif.posts[0].privacy, "", postNotif.users.map(t => t.id), postNotif.posts[0].id); 270 } 271 } 272 273 async saveMembers() { 274 writeFileSync('./members.json', JSON.stringify(WAFRING_JSON)); 275 } 276 277 async checkIfWafrn(uname) { 278 const nameSpl = uname.split('@'); 279 const prov = nameSpl[2] ?? 'wf.jbc.lol'; 280 console.log(prov) 281 282 const nfRes = await fetch(`https://${prov}/.well-known/nodeinfo`); 283 if (!nfRes.ok) return { 284 isWafrn: false, 285 httpError: true 286 }; 287 288 const link = await nfRes.json(); 289 console.log(link); 290 const nfInst = await fetch(link.links[0].href); 291 if (!nfInst.ok) return { 292 isWafrn: false, 293 httpError: true 294 }; 295 296 const nfJson = await nfInst.json(); 297 if (nfJson.software.name.toLowerCase() !== 'wafrn') return { 298 isWafrn: false, 299 software: nfJson.software, 300 metadata: nfJson.metadata 301 }; 302 303 return { 304 isWafrn: true, 305 software: nfJson.software, 306 metadata: nfJson.metadata 307 }; 308 } 309 310 async send(username, html, type = 10, warning = '', mentions = [], parent = '') { 311 if (!this._token) { 312 throw new Error('Please log in first'); 313 } 314 315 const res = await fetch(`https://${this._instance}/api/v3/createPost`, { 316 method: "POST", 317 headers: { 318 "Content-Type": "application/json", 319 Authorization: `Bearer ${this._token}` 320 }, 321 body: JSON.stringify({ 322 content: `${!!username ? `@${username}\n\n` : ''}${html}`, 323 "content_warning": warning, 324 medias: [], 325 mentionedUserIds: mentions, 326 parent: parent, 327 privacy: type, 328 tags: "" 329 }) 330 }) 331 if (!res.ok) { 332 throw new Error('Could not create dm'); 333 } 334 } 335 336 async getNotifs() { 337 const res = await fetch(`https://${this._instance}/api/v3/notificationsScroll?date=${new Date().getTime()}&page=0`, { 338 method: 'GET', 339 headers: { 340 Authorization: `Bearer ${this._token}` 341 }, 342 }); 343 344 if (!res.ok) { 345 throw new Error('Could not get notifs'); 346 } 347 348 const resJson = await res.json(); 349 return resJson; 350 } 351 352 async getPost(postId) { 353 const res = await fetch(`https://${this._instance}/api/v2/post/${postId}`, { 354 method: 'GET', 355 headers: { 356 Authorization: `Bearer ${this._token}` 357 }, 358 }); 359 360 const text = await res.text(); 361 362 if (!res.ok || !text) { 363 throw new Error('Could not get post, ' + text); 364 } 365 366 let resJson = JSON.parse(text); 367 return resJson; 368 } 369} 370 371const w = new WafrnApi(); 372w.init(config.instance, config.email, config.password); 373 374console.log('e'); 375 376app.get('/.json', (req, res) => { 377 res.setHeader('x-robots-tag', 'noindex, nofollow'); 378 res.setHeader('x-powered-by', 'wafring'); 379 res.setHeader('content-type', 'application/json; charset=utf-8'); 380 res.send(JSON.stringify(WAFRING_JSON)); 381}) 382 383app.get('/', (req, res) => { 384 const templateHtml = readFileSync('template.handlebars', 'utf-8'); 385 const template = Handlebars.compile(templateHtml); 386 res.setHeader('x-robots-tag', 'noindex, nofollow'); 387 res.setHeader('x-powered-by', 'wafring'); 388 res.setHeader('content-type', 'text/html; charset=utf-8'); 389 res.send(template({ members: WAFRING_JSON })); 390}) 391 392app.get('/:way/:user', (req, res) => { 393 res.setHeader('x-robots-tag', 'noindex, nofollow'); 394 res.setHeader('x-powered-by', 'wafring'); 395 res.setHeader('content-type', 'text/html; charset=utf-8'); 396 const way = req.params.way; 397 const user = req.params.user; 398 const jUser = WAFRING_JSON.find(x => x.handle.replace(/^\@/, '') === user.replace(/^\@/, '')); 399 400 if (!jUser && (way === 'prev' || way === 'next')) { 401 res.status(404); 402 res.send('not found'); 403 return; 404 } else if (way === 'rand') { 405 const s = WAFRING_JSON.sample(); 406 res.redirect(302, s.url); 407 return; 408 } 409 410 const idx = WAFRING_JSON.indexOf(jUser); 411 const sel = (way === 'prev') ? WAFRING_JSON[(idx === 0) ? WAFRING_JSON.length - 1 : idx - 1] : WAFRING_JSON[(idx === WAFRING_JSON.length - 1) ? 0 : idx + 1] 412 res.redirect(302, sel.url); 413}) 414 415app.get('/rand', (req, res) => { 416 const s = WAFRING_JSON.sample(); 417 res.redirect(302, s.url); 418}) 419 420app.listen(port, () => { 421 console.log(`Wafring listening at port ${port}`); 422})