A webring for Wafrn users. https://wafring.jbc.lol (mirror of https://git.jbc.lol/jbcrn/wafring)
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})