Files
FrozenFriendsYume/packages/backend/built/server/api/stream/index.js
T
2026-07-26 18:25:37 +09:00

503 lines
17 KiB
JavaScript

import readNote from "../../../services/note/read.js";
import { Users, Followings, Mutings, RenoteMutings, UserProfiles, ChannelFollowings, Blockings, CallBlockings, UserListJoinings, UserGroupJoinings, UserGroups } from "../../../models/index.js";
import { publishMainStream, publishChannelStream, publishGroupMessagingStream, publishMessagingStream } from "../../../services/stream.js";
import { readNotification } from "../common/read-notification.js";
import channels from "./channels/index.js";
/**
* Main stream connection
*/ export default class Connection {
user;
userProfile;
following = new Set();
muting = new Set();
renoteMuting = new Set();
blocking = new Set();
hidden = new Set();
followingChannels = new Set();
token;
wsConnection;
subscriber;
channels = [];
subscribingNotes = new Map();
cachedNotes = [];
host;
accessToken;
currentSubscribe = [];
constructor(wsConnection, subscriber, user, token, host, accessToken, prepareStream){
this.wsConnection = wsConnection;
this.subscriber = subscriber;
if (user) this.user = user;
if (token) this.token = token;
if (host) this.host = host;
if (accessToken) this.accessToken = accessToken;
this.onWsConnectionMessage = this.onWsConnectionMessage.bind(this);
this.onUserEvent = this.onUserEvent.bind(this);
this.onNoteStreamMessage = this.onNoteStreamMessage.bind(this);
this.onBroadcastMessage = this.onBroadcastMessage.bind(this);
this.wsConnection.on("message", this.onWsConnectionMessage);
this.subscriber.on("broadcast", (data)=>{
this.onBroadcastMessage(data);
});
if (this.user) {
this.updateFollowing();
this.updateMuting();
this.updateRenoteMuting();
this.updateBlocking();
this.updateHidden();
this.updateFollowingChannels();
this.updateUserProfile();
this.subscriber.on(`user:${this.user.id}`, this.onUserEvent);
}
if (prepareStream) {
this.onWsConnectionMessage({
type: "utf8",
utf8Data: JSON.stringify({
stream: prepareStream,
type: "subscribe"
})
});
}
}
onUserEvent(data) {
// { type, body }と展開するとそれぞれ型が分離してしまう
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;
// TODO: renote mute events
// TODO: block events
case "followChannel":
this.followingChannels.add(data.body.id);
break;
case "unfollowChannel":
this.followingChannels.delete(data.body.id);
break;
case "userHidden":
this.hidden.add(data.body);
break;
case "userUnhidden":
this.hidden.delete(data.body);
break;
case "updateUserProfile":
this.userProfile = data.body;
break;
case "terminate":
this.wsConnection.close();
this.dispose();
break;
default:
break;
}
}
/**
* クライアントからメッセージ受信時
*/ async onWsConnectionMessage(data) {
if (data.type !== "utf8") return;
if (data.utf8Data == null) return;
let objs;
try {
objs = [
JSON.parse(data.utf8Data)
];
} catch (e) {
return;
}
for (const obj of objs){
const { type, body } = obj;
// console.log(type, body);
switch(type){
case "readNotification":
this.onReadNotification(body);
break;
case "subNote":
this.onSubscribeNote(body);
break;
case "s":
this.onSubscribeNote(body);
break; // alias
case "sr":
this.onSubscribeNote(body);
this.readNote(body);
break;
case "unsubNote":
this.onUnsubscribeNote(body);
break;
case "un":
this.onUnsubscribeNote(body);
break; // alias
case "connect":
this.onChannelConnectRequested(body);
break;
case "disconnect":
this.onChannelDisconnectRequested(body);
break;
case "channel":
this.onChannelMessageRequested(body);
break;
case "ch":
this.onChannelMessageRequested(body);
break; // alias
// 個々のチャンネルではなくルートレベルでこれらのメッセージを受け取る理由は、
// クライアントの事情を考慮したとき、入力フォームはノートチャンネルやメッセージのメインコンポーネントとは別
// なこともあるため、それらのコンポーネントがそれぞれ各チャンネルに接続するようにするのは面倒なため。
case "typingOnChannel":
this.typingOnChannel(body.channel);
break;
case "typingOnMessaging":
this.typingOnMessaging(body);
break;
case "callSignal":
this.callSignal(body);
break;
}
}
}
onBroadcastMessage(data) {
this.sendMessageToWs(data.type, data.body);
}
cacheNote(note) {
const add = (note)=>{
const existIndex = this.cachedNotes.findIndex((n)=>n.id === note.id);
if (existIndex > -1) {
this.cachedNotes[existIndex] = note;
return;
}
this.cachedNotes.unshift(note);
if (this.cachedNotes.length > 32) {
this.cachedNotes.splice(32);
}
};
add(note);
if (note.reply) add(note.reply);
if (note.renote) add(note.renote);
}
readNote(body) {
const id = body.id;
const note = this.cachedNotes.find((n)=>n.id === id);
if (note == null) return;
if (this.user && note.userId !== this.user.id) {
readNote(this.user.id, [
note
], {
following: this.following,
followingChannels: this.followingChannels
});
}
}
onReadNotification(payload) {
if (!payload.id) return;
readNotification(this.user.id, [
payload.id
]);
}
/**
* 投稿購読要求時
*/ onSubscribeNote(payload) {
if (!payload.id) return;
const current = this.subscribingNotes.get(payload.id) || 0;
this.subscribingNotes.set(payload.id, current + 1);
if (!current) {
this.subscriber.on(`noteStream:${payload.id}`, this.onNoteStreamMessage);
}
}
/**
* 投稿購読解除要求時
*/ onUnsubscribeNote(payload) {
if (!payload.id) return;
const current = this.subscribingNotes.get(payload.id) || 0;
if (current <= 1) {
this.subscribingNotes.delete(payload.id);
this.subscriber.off(`noteStream:${payload.id}`, this.onNoteStreamMessage);
return;
}
this.subscribingNotes.set(payload.id, current - 1);
}
async onNoteStreamMessage(data) {
this.sendMessageToWs("noteUpdated", {
id: data.body.id,
type: data.type,
body: data.body.body
});
}
/**
* チャンネル接続要求時
*/ onChannelConnectRequested(payload) {
const { channel, id, params, pong } = payload;
this.connectChannel(id, params, channel, pong);
}
/**
* チャンネル切断要求時
*/ onChannelDisconnectRequested(payload) {
const { id } = payload;
this.disconnectChannel(id);
}
/**
* クライアントにメッセージ送信
*/ sendMessageToWs(type, payload) {
this.wsConnection.send(JSON.stringify({
type: type,
body: payload
}));
}
/**
* チャンネルに接続
*/ connectChannel(id, params, channel, pong = false) {
if (channels[channel].requireCredential && this.user == null) {
return;
}
// 共有可能チャンネルに接続しようとしていて、かつそのチャンネルに既に接続していたら無意味なので無視
if (channels[channel].shouldShare && this.channels.some((c)=>c.chName === channel)) {
return;
}
const ch = new channels[channel](id, this);
this.channels.push(ch);
ch.init(params);
if (pong) {
this.sendMessageToWs("connected", {
id: id
});
}
}
/**
* チャンネルから切断
* @param id チャンネルコネクションID
*/ disconnectChannel(id) {
const channel = this.channels.find((c)=>c.id === id);
if (channel) {
if (channel.dispose) channel.dispose();
this.channels = this.channels.filter((c)=>c.id !== id);
}
}
/**
* チャンネルへメッセージ送信要求時
* @param data メッセージ
*/ onChannelMessageRequested(data) {
const channel = this.channels.find((c)=>c.id === data.id);
if (channel?.onMessage != null) {
channel.onMessage(data.type, data.body);
}
}
typingOnChannel(channel) {
if (this.user) {
publishChannelStream(channel, "typing", this.user.id);
}
}
typingOnMessaging(param) {
if (this.user) {
if (param.partner) {
publishMessagingStream(param.partner, this.user.id, "typing", this.user.id);
} else if (param.group) {
publishGroupMessagingStream(param.group, "typing", this.user.id);
}
}
}
async callSignal(body) {
if (!this.user || !body.sessionId) return;
const recipients = new Set();
let recipientGroupId = null;
const target = typeof body.to === "string" ? body.to.trim() : "";
if (body.toUserId) {
const user = await Users.findOneBy({
id: body.toUserId,
host: null
});
if (user && user.id !== this.user.id) recipients.add(user.id);
} else if (target === "") {
if (body.signal?.type !== "offer") return;
const users = await Users.createQueryBuilder("user").select([
"user.id"
]).where("user.host IS NULL").andWhere("user.id != :meId", {
meId: this.user.id
}).andWhere("user.isSuspended = FALSE").andWhere("user.isDeleted = FALSE").getMany();
for (const user of users)recipients.add(user.id);
} else if (target.startsWith("@@")) {
const group = await UserGroups.findOneBy({
username: target.slice(2).toLowerCase(),
allowCalls: true
});
if (!group) return;
recipientGroupId = group.id;
const joinings = await UserGroupJoinings.findBy({
userGroupId: group.id
});
if (group.userId !== this.user.id) recipients.add(group.userId);
for (const joining of joinings){
if (joining.userId !== this.user.id) recipients.add(joining.userId);
}
} else if (target.startsWith("@")) {
const username = target.slice(1).split("@")[0].toLowerCase();
const user = await Users.findOneBy({
usernameLower: username,
host: null
});
const profile = user ? await UserProfiles.findOneBy({
userId: user.id,
allowCalls: true
}) : null;
if (user && profile && user.id !== this.user.id) recipients.add(user.id);
} else {
const group = await UserGroups.findOneBy({
username: target.toLowerCase(),
allowCalls: true
});
if (!group) return;
recipientGroupId = group.id;
const isMember = group.userId === this.user.id || await UserGroupJoinings.exist({
where: {
userGroupId: group.id,
userId: this.user.id
}
});
if (!isMember) return;
const joinings = await UserGroupJoinings.findBy({
userGroupId: group.id
});
if (group.userId !== this.user.id) recipients.add(group.userId);
for (const joining of joinings){
if (joining.userId !== this.user.id) recipients.add(joining.userId);
}
}
if (recipientGroupId != null) {
const groupCallBlocked = await CallBlockings.exist({
where: {
groupId: recipientGroupId,
blockeeId: this.user.id
}
});
if (groupCallBlocked) return;
}
for (const recipient of recipients){
if (await this.isCallRecipientBlocked(recipient)) continue;
publishMainStream(recipient, "callSignal", {
fromUserId: this.user.id,
to: target,
sessionId: body.sessionId,
kind: body.kind,
signal: body.signal
});
}
}
async isCallRecipientBlocked(recipient) {
const normalBlocked = await Blockings.exist({
where: [
{
blockerId: recipient,
blockeeId: this.user.id,
groupId: null
},
{
blockerId: this.user.id,
blockeeId: recipient,
groupId: null
}
]
});
if (normalBlocked) return true;
return await CallBlockings.exist({
where: [
{
blockerId: recipient,
blockeeId: this.user.id,
groupId: null
},
{
blockerId: this.user.id,
blockeeId: recipient,
groupId: null
}
]
});
}
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() {
// ここでいうBlockingは被Blockingの意
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 updateFollowingChannels() {
const followings = await ChannelFollowings.find({
where: {
followerId: this.user.id
},
select: [
"followeeId"
]
});
this.followingChannels = new Set(followings.map((x)=>x.followeeId));
}
async updateUserProfile() {
this.userProfile = await UserProfiles.findOneBy({
userId: this.user.id
});
}
/**
* ストリームが切れたとき
*/ dispose() {
for (const c of this.channels.filter((c)=>c.dispose)){
if (c.dispose) c.dispose();
}
}
}