import { Blockings, Followings, Mutings, RenoteMutings, UserListJoinings, UserProfiles } from "../../../../models/index.js"; import { apiLogger } from "../../logger.js"; import { MastodonStreamUser } from "./channels/user.js"; import { MastodonStreamDirect } from "./channels/direct.js"; import { MastodonStreamPublic } from "./channels/public.js"; import { MastodonStreamList } from "./channels/list.js"; import { toSingleLast } from "../../../../prelude/array.js"; import { MastodonStreamTag } from "./channels/tag.js"; const logger = apiLogger.createSubLogger("streaming").createSubLogger("mastodon"); const channels = { "user": MastodonStreamUser, "user:notification": MastodonStreamUser, "direct": MastodonStreamDirect, "list": MastodonStreamList, "public": MastodonStreamPublic, "public:media": MastodonStreamPublic, "public:local": MastodonStreamPublic, "public:local:media": MastodonStreamPublic, "public:remote": MastodonStreamPublic, "public:remote:media": MastodonStreamPublic, "public:allow_local_only": MastodonStreamPublic, "public:allow_local_only:media": MastodonStreamPublic, "hashtag": MastodonStreamTag, "hashtag:local": MastodonStreamTag }; export class MastodonStreamingConnection { user; userProfile; following = new Set(); muting = new Set(); renoteMuting = new Set(); blocking = new Set(); hidden = new Set(); token; wsConnection; channels = []; subscriber; constructor(wsConnection, subscriber, user, token, query){ const channel = toSingleLast(query.stream); logger.debug(`New connection on channel: ${channel}`); this.wsConnection = wsConnection; this.subscriber = subscriber; if (user) this.user = user; if (token) this.token = token; this.onMessage = this.onMessage.bind(this); this.onUserEvent = this.onUserEvent.bind(this); this.wsConnection.on("message", this.onMessage); if (this.user) { this.updateFollowing(); this.updateMuting(); this.updateRenoteMuting(); this.updateBlocking(); this.updateHidden(); this.updateUserProfile(); this.subscriber.on(`user:${this.user.id}`, this.onUserEvent); } if (channel) { const list = toSingleLast(query.list); const tag = toSingleLast(query.tag); this.onMessage({ type: "utf8", utf8Data: JSON.stringify({ stream: channel, type: "subscribe", list, tag }) }); } } onUserEvent(data) { switch(data.type){ case "follow": this.following.add(data.body.id); break; case "unfollow": this.following.delete(data.body.id); break; case "mute": this.muting.add(data.body.id); break; case "unmute": this.muting.delete(data.body.id); break; case "userHidden": this.hidden.add(data.body); break; case "userUnhidden": this.hidden.delete(data.body); break; // TODO: renote mute events // TODO: block events case "updateUserProfile": this.userProfile = data.body; break; case "terminate": this.closeConnection(); break; default: break; } } async onMessage(data) { if (data.type !== "utf8") return; if (data.utf8Data == null) return; let message; try { message = JSON.parse(data.utf8Data); } catch (e) { logger.error("Failed to parse json data, ignoring"); return; } const { stream, type, list, tag } = message; if (!message.stream || !message.type) { logger.error("Invalid message received, ignoring"); return; } if (list ?? tag) logger.info(`${type}: ${stream} ${list ?? tag}`); else logger.info(`${type}: ${stream}`); switch(type){ case "subscribe": this.connectChannel(stream, list, tag); break; case "unsubscribe": this.disconnectChannel(stream); break; } } send(stream, event, payload) { const json = JSON.stringify({ stream: [ stream ], event: event, payload: typeof payload === "string" ? payload : JSON.stringify(payload) }); this.wsConnection.send(json); } connectChannel(channel, list, tag) { if (!channels[channel]) { logger.info(`Ignoring connection to unknown channel ${channel}`); return; } if (channels[channel].requireCredential) { if (this.user == null) { logger.info(`Refusing connection to channel ${channel} without authentication, terminating connection`); this.closeConnection(); return; } else if (!channels[channel].requiredScopes.every((p)=>this.token?.scopes?.includes(p))) { logger.info(`Refusing connection to channel ${channel} without required OAuth scopes, terminating connection`); this.closeConnection(); return; } } if (channels[channel].shouldShare && this.channels.some((c)=>c.chName === channel)) { return; } let ch; if (channel === "list") { ch = new channels[channel](this, channel, list); } else if (channel.startsWith("hashtag")) ch = new channels[channel](this, channel, tag); else ch = new channels[channel](this, channel); this.channels.push(ch); ch.init(null); } disconnectChannel(channelName) { const channel = this.channels.find((c)=>c.chName === channelName); if (channel) { if (channel.dispose) channel.dispose(); this.channels = this.channels.filter((c)=>c.chName !== channelName); } } async updateFollowing() { const followings = await Followings.find({ where: { followerId: this.user.id }, select: [ "followeeId" ] }); this.following = new Set(followings.map((x)=>x.followeeId)); } async updateMuting() { const mutings = await Mutings.find({ where: { muterId: this.user.id }, select: [ "muteeId" ] }); this.muting = new Set(mutings.map((x)=>x.muteeId)); } async updateRenoteMuting() { const renoteMutings = await RenoteMutings.find({ where: { muterId: this.user.id }, select: [ "muteeId" ] }); this.renoteMuting = new Set(renoteMutings.map((x)=>x.muteeId)); } async updateBlocking() { const blockings = await Blockings.find({ where: { blockeeId: this.user.id }, select: [ "blockerId" ] }); this.blocking = new Set(blockings.map((x)=>x.blockerId)); } async updateHidden() { const hidden = await UserListJoinings.find({ where: { userList: { userId: this.user.id, hideFromHomeTl: true } }, select: [ "userId" ] }); this.hidden = new Set(hidden.map((x)=>x.userId)); } async updateUserProfile() { this.userProfile = await UserProfiles.findOneBy({ userId: this.user.id }); } closeConnection() { this.wsConnection.close(); this.dispose(); } dispose() { for (const c of this.channels.filter((c)=>c.dispose)){ if (c.dispose) c.dispose(); } } }