Fixed 267U.pre2

This commit is contained in:
2026-07-26 18:25:37 +09:00
parent 50bfaeafdf
commit 317d00a284
1286 changed files with 80222 additions and 1 deletions
@@ -0,0 +1,75 @@
import { Notes } from "../../../models/index.js";
import { IdentifiableError } from "../../../misc/identifiable-error.js";
/**
* Stream channel
*/ export default class Channel {
connection;
id;
static shouldShare;
static requireCredential;
get user() {
return this.connection.user;
}
get userProfile() {
return this.connection.userProfile;
}
get following() {
return this.connection.following;
}
get muting() {
return this.connection.muting;
}
get renoteMuting() {
return this.connection.renoteMuting;
}
get blocking() {
return this.connection.blocking;
}
get hidden() {
return this.connection.hidden;
}
get followingChannels() {
return this.connection.followingChannels;
}
get subscriber() {
return this.connection.subscriber;
}
constructor(id, connection){
this.id = id;
this.connection = connection;
}
send(typeOrPayload, payload) {
const type = payload === undefined ? typeOrPayload.type : typeOrPayload;
const body = payload === undefined ? typeOrPayload.body : payload;
this.connection.sendMessageToWs("channel", {
id: this.id,
type: type,
body: body
});
}
withPackedNote(callback) {
return async (note)=>{
try {
// because `note` was previously JSON.stringify'ed, the fields that
// were objects before are now strings and have to be restored or
// removed from the object
note.createdAt = new Date(note.createdAt);
note.reply = undefined;
note.renote = undefined;
note.user = undefined;
note.channel = undefined;
const packed = await Notes.pack(note, this.user, {
detail: true
});
callback(packed);
} catch (err) {
if (err instanceof IdentifiableError && err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") {
// skip: note not visible to user
return;
} else {
throw err;
}
}
};
}
}
@@ -0,0 +1,12 @@
import Channel from "../channel.js";
export default class extends Channel {
chName = "admin";
static shouldShare = true;
static requireCredential = true;
async init(params) {
// Subscribe admin stream
this.subscriber.on(`adminStream:${this.user.id}`, (data)=>{
this.send(data);
});
}
}
@@ -0,0 +1,48 @@
import Channel from "../channel.js";
import { Notes } from "../../../../models/index.js";
import { isUserRelated } from "../../../../misc/is-user-related.js";
import { IdentifiableError } from "../../../../misc/identifiable-error.js";
export default class extends Channel {
chName = "antenna";
static shouldShare = false;
static requireCredential = false;
antennaId;
constructor(id, connection){
super(id, connection);
this.onEvent = this.onEvent.bind(this);
}
async init(params) {
this.antennaId = params.antennaId;
// Subscribe stream
this.subscriber.on(`antennaStream:${this.antennaId}`, this.onEvent);
}
async onEvent(data) {
if (data.type === "note") {
try {
const note = await Notes.pack(data.body.id, this.user, {
detail: true
});
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return;
this.connection.cacheNote(note);
this.send("note", note);
} catch (e) {
if (e instanceof IdentifiableError && e.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") {
// skip: note not visible to user
return;
} else {
throw e;
}
}
} else {
this.send(data.type, data.body);
}
}
dispose() {
// Unsubscribe events
this.subscriber.off(`antennaStream:${this.antennaId}`, this.onEvent);
}
}
@@ -0,0 +1,65 @@
import Channel from "../channel.js";
import { Users } from "../../../../models/index.js";
import { isUserRelated } from "../../../../misc/is-user-related.js";
export default class extends Channel {
chName = "channel";
static shouldShare = false;
static requireCredential = false;
channelId;
typers = new Map();
emitTypersIntervalId;
constructor(id, connection){
super(id, connection);
this.onNote = this.withPackedNote(this.onNote.bind(this));
this.emitTypers = this.emitTypers.bind(this);
}
async init(params) {
this.channelId = params.channelId;
// Subscribe stream
this.subscriber.on("notesStream", this.onNote);
this.subscriber.on(`channelStream:${this.channelId}`, this.onEvent);
this.emitTypersIntervalId = setInterval(this.emitTypers, 5000);
}
async onNote(note) {
if (note.visibility === "hidden") return;
if (note.channelId !== this.channelId) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return;
this.connection.cacheNote(note);
this.send("note", note);
}
onEvent(data) {
if (data.type === "typing") {
const id = data.body;
const begin = !this.typers.has(id);
this.typers.set(id, new Date());
if (begin) {
this.emitTypers();
}
}
}
async emitTypers() {
const now = new Date();
// Remove not typing users
for (const [userId, date] of Object.entries(this.typers)){
if (now.getTime() - date.getTime() > 5000) this.typers.delete(userId);
}
const userIds = Array.from(this.typers.keys());
const users = await Users.packMany(userIds, null, {
detail: false
});
this.send({
type: "typers",
body: users
});
}
dispose() {
// Unsubscribe events
this.subscriber.off("notesStream", this.onNote);
this.subscriber.off(`channelStream:${this.channelId}`, this.onEvent);
clearInterval(this.emitTypersIntervalId);
}
}
@@ -0,0 +1,12 @@
import Channel from "../channel.js";
export default class extends Channel {
chName = "drive";
static shouldShare = true;
static requireCredential = true;
async init(params) {
// Subscribe drive stream
this.subscriber.on(`driveStream:${this.user.id}`, (data)=>{
this.send(data);
});
}
}
@@ -0,0 +1,53 @@
import Channel from "../channel.js";
import { fetchMeta } from "../../../../misc/fetch-meta.js";
import { isInstanceMuted } from "../../../../misc/is-instance-muted.js";
import { isUserRelated } from "../../../../misc/is-user-related.js";
import { isFiltered } from "../../../../misc/is-filtered.js";
export default class extends Channel {
chName = "globalTimeline";
static shouldShare = true;
static requireCredential = false;
withReplies;
constructor(id, connection){
super(id, connection);
this.onNote = this.withPackedNote(this.onNote.bind(this));
}
async init(params) {
const meta = await fetchMeta();
if (meta.disableGlobalTimeline) {
if (this.user == null || !(this.user.isAdmin || this.user.isModerator)) return;
}
this.withReplies = params.withReplies;
// Subscribe events
this.subscriber.on("notesStream", this.onNote);
}
async onNote(note) {
if (note.visibility !== "public") return;
if (note.channelId != null) return;
// 関係ない返信は除外
if (note.reply && !this.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;
}
// Ignore notes from instances the user has muted
if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる
if (this.userProfile && await isFiltered(note, this.user, this.userProfile)) return;
this.connection.cacheNote(note);
this.send("note", note);
}
dispose() {
// Unsubscribe events
this.subscriber.off("notesStream", this.onNote);
}
}
@@ -0,0 +1,55 @@
import Channel from "../channel.js";
import { isUserRelated } from "../../../../misc/is-user-related.js";
import { isInstanceMuted } from "../../../../misc/is-instance-muted.js";
import { isFiltered } from "../../../../misc/is-filtered.js";
export default class extends Channel {
chName = "homeTimeline";
static shouldShare = true;
static requireCredential = true;
withReplies;
constructor(id, connection){
super(id, connection);
this.onNote = this.withPackedNote(this.onNote.bind(this));
}
async init(params) {
this.withReplies = params.withReplies;
// Subscribe events
this.subscriber.on("notesStream", this.onNote);
}
async onNote(note) {
if (note.visibility === "hidden") return;
if (note.channelId) {
if (!this.followingChannels.has(note.channelId)) return;
} else {
// その投稿のユーザーをフォローしていなかったら弾く
if (this.user.id !== note.userId && !this.following.has(note.userId)) return;
}
// Ignore notes from instances the user has muted
if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return;
// 関係ない返信は除外
if (note.reply && !this.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;
}
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
// Members of lists with hideFromHome set
if (note.userId !== this.user.id && isUserRelated(note, this.hidden)) return;
if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる
if (this.userProfile && await isFiltered(note, this.user, this.userProfile)) return;
this.connection.cacheNote(note);
this.send("note", note);
}
dispose() {
// Unsubscribe events
this.subscriber.off("notesStream", this.onNote);
}
}
@@ -0,0 +1,57 @@
import Channel from "../channel.js";
import { fetchMeta } from "../../../../misc/fetch-meta.js";
import { isUserRelated } from "../../../../misc/is-user-related.js";
import { isInstanceMuted } from "../../../../misc/is-instance-muted.js";
import { isFiltered } from "../../../../misc/is-filtered.js";
export default class extends Channel {
chName = "hybridTimeline";
static shouldShare = true;
static requireCredential = true;
withReplies;
constructor(id, connection){
super(id, connection);
this.onNote = this.withPackedNote(this.onNote.bind(this));
}
async init(params) {
const meta = await fetchMeta();
if (meta.disableLocalTimeline && !this.user.isAdmin && !this.user.isModerator) return;
this.withReplies = params.withReplies;
// Subscribe events
this.subscriber.on("notesStream", this.onNote);
}
async onNote(note) {
if (note.visibility === "hidden") return;
// チャンネルの投稿ではなく、自分自身の投稿 または
// チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または
// チャンネルの投稿ではなく、全体公開のローカルの投稿 または
// フォローしているチャンネルの投稿 の場合だけ
if (!(note.channelId == null && this.user.id === note.userId || note.channelId == null && this.following.has(note.userId) || note.channelId == null && note.user.host == null && note.visibility === "public" || note.channelId != null && this.followingChannels.has(note.channelId))) return;
// Ignore notes from instances the user has muted
if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return;
// 関係ない返信は除外
if (note.reply && !this.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;
}
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
// Members of lists with hideFromHome set
if (note.userId !== this.user.id && isUserRelated(note, this.hidden)) return;
if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる
if (this.userProfile && await isFiltered(note, this.user, this.userProfile)) return;
this.connection.cacheNote(note);
this.send("note", note);
}
dispose() {
// Unsubscribe events
this.subscriber.off("notesStream", this.onNote);
}
}
@@ -0,0 +1,40 @@
import main from "./main.js";
import homeTimeline from "./home-timeline.js";
import localTimeline from "./local-timeline.js";
import hybridTimeline from "./hybrid-timeline.js";
import recommendedTimeline from "./recommended-timeline.js";
import globalTimeline from "./global-timeline.js";
import serverStats from "./server-stats.js";
import queueStats from "./queue-stats.js";
import userList from "./user-list.js";
import antenna from "./antenna.js";
import messaging from "./messaging.js";
import messagingIndex from "./messaging-index.js";
import drive from "./drive.js";
import channel from "./channel.js";
import admin from "./admin.js";
import reversi from "./reversi.js";
import reversiGame from "./reversi-game.js";
import shogi from "./shogi.js";
import shogiGame from "./shogi-game.js";
export default {
main,
homeTimeline,
localTimeline,
recommendedTimeline,
hybridTimeline,
globalTimeline,
serverStats,
queueStats,
userList,
antenna,
messaging,
messagingIndex,
drive,
channel,
admin,
reversi,
reversiGame,
shogi,
shogiGame
};
@@ -0,0 +1,51 @@
import Channel from "../channel.js";
import { fetchMeta } from "../../../../misc/fetch-meta.js";
import { isUserRelated } from "../../../../misc/is-user-related.js";
import { isFiltered } from "../../../../misc/is-filtered.js";
export default class extends Channel {
chName = "localTimeline";
static shouldShare = true;
static requireCredential = false;
withReplies;
constructor(id, connection){
super(id, connection);
this.onNote = this.withPackedNote(this.onNote.bind(this));
}
async init(params) {
const meta = await fetchMeta();
if (meta.disableLocalTimeline) {
if (this.user == null || !(this.user.isAdmin || this.user.isModerator)) return;
}
this.withReplies = params.withReplies;
// Subscribe events
this.subscriber.on("notesStream", this.onNote);
}
async onNote(note) {
if (note.user.host !== null) return;
if (note.visibility !== "public") return;
if (note.channelId != null && !this.followingChannels.has(note.channelId)) return;
// 関係ない返信は除外
if (note.reply && !this.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;
}
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる
if (this.userProfile && await isFiltered(note, this.user, this.userProfile)) return;
this.connection.cacheNote(note);
this.send("note", note);
}
dispose() {
// Unsubscribe events
this.subscriber.off("notesStream", this.onNote);
}
}
@@ -0,0 +1,28 @@
import Channel from "../channel.js";
import { isInstanceMuted, isUserFromMutedInstance } from "../../../../misc/is-instance-muted.js";
export default class extends Channel {
chName = "main";
static shouldShare = true;
static requireCredential = true;
async init(params) {
// Subscribe main stream channel
this.subscriber.on(`mainStream:${this.user.id}`, async (data)=>{
switch(data.type){
case "notification":
{
// Ignore notifications from instances the user has muted
if (isUserFromMutedInstance(data.body, new Set(this.userProfile?.mutedInstances ?? []))) return;
if (data.body.userId && this.muting.has(data.body.userId)) return;
break;
}
case "mention":
{
if (isInstanceMuted(data.body, new Set(this.userProfile?.mutedInstances ?? []))) return;
if (this.muting.has(data.body.userId)) return;
break;
}
}
this.send(data.type, data.body);
});
}
}
@@ -0,0 +1,12 @@
import Channel from "../channel.js";
export default class extends Channel {
chName = "messagingIndex";
static shouldShare = true;
static requireCredential = true;
async init(params) {
// Subscribe messaging index stream
this.subscriber.on(`messagingIndexStream:${this.user.id}`, (data)=>{
this.send(data);
});
}
}
@@ -0,0 +1,95 @@
import { readUserMessagingMessage, readGroupMessagingMessage, deliverReadActivity } from "../../common/read-messaging-message.js";
import Channel from "../channel.js";
import { UserGroupJoinings, Users, MessagingMessages } from "../../../../models/index.js";
export default class extends Channel {
chName = "messaging";
static shouldShare = false;
static requireCredential = true;
otherpartyId;
otherparty;
groupId;
subCh;
typers = new Map();
emitTypersIntervalId;
constructor(id, connection){
super(id, connection);
this.onEvent = this.onEvent.bind(this);
this.onMessage = this.onMessage.bind(this);
this.emitTypers = this.emitTypers.bind(this);
}
async init(params) {
this.otherpartyId = params.otherparty;
this.otherparty = this.otherpartyId ? await Users.findOneByOrFail({
id: this.otherpartyId
}) : null;
this.groupId = params.group;
// Check joining
if (this.groupId) {
const joining = await UserGroupJoinings.findOneBy({
userId: this.user.id,
userGroupId: this.groupId
});
if (joining == null) {
return;
}
}
this.emitTypersIntervalId = setInterval(this.emitTypers, 5000);
this.subCh = this.otherpartyId ? `messagingStream:${this.user.id}-${this.otherpartyId}` : `messagingStream:${this.groupId}`;
// Subscribe messaging stream
this.subscriber.on(this.subCh, this.onEvent);
}
onEvent(data) {
if (data.type === "typing") {
const id = data.body;
const begin = !this.typers.has(id);
this.typers.set(id, new Date());
if (begin) {
this.emitTypers();
}
} else {
this.send(data);
}
}
onMessage(type, body) {
switch(type){
case "read":
if (this.otherpartyId) {
readUserMessagingMessage(this.user.id, this.otherpartyId, [
body.id
]);
// リモートユーザーからのメッセージだったら既読配信
if (Users.isLocalUser(this.user) && Users.isRemoteUser(this.otherparty)) {
MessagingMessages.findOneBy({
id: body.id
}).then((message)=>{
if (message) deliverReadActivity(this.user, this.otherparty, message);
});
}
} else if (this.groupId) {
readGroupMessagingMessage(this.user.id, this.groupId, [
body.id
]);
}
break;
}
}
async emitTypers() {
const now = new Date();
// Remove not typing users
for (const [userId, date] of this.typers.entries()){
if (now.getTime() - date.getTime() > 5000) this.typers.delete(userId);
}
const userIds = Array.from(this.typers.keys());
const users = await Users.packMany(userIds, null, {
detail: false
});
this.send({
type: "typers",
body: users
});
}
dispose() {
this.subscriber.off(this.subCh, this.onEvent);
clearInterval(this.emitTypersIntervalId);
}
}
@@ -0,0 +1,35 @@
import Xev from "xev";
import Channel from "../channel.js";
const ev = new Xev();
export default class extends Channel {
chName = "queueStats";
static shouldShare = true;
static requireCredential = false;
constructor(id, connection){
super(id, connection);
this.onStats = this.onStats.bind(this);
this.onMessage = this.onMessage.bind(this);
}
async init(params) {
ev.addListener("queueStats", this.onStats);
}
onStats(stats) {
this.send("stats", stats);
}
onMessage(type, body) {
switch(type){
case "requestLog":
ev.once(`queueStatsLog:${body.id}`, (statsLog)=>{
this.send("statsLog", statsLog);
});
ev.emit("requestQueueStatsLog", {
id: body.id,
length: body.length
});
break;
}
}
dispose() {
ev.removeListener("queueStats", this.onStats);
}
}
@@ -0,0 +1,56 @@
import Channel from "../channel.js";
import { fetchMeta } from "../../../../misc/fetch-meta.js";
import { isUserRelated } from "../../../../misc/is-user-related.js";
import { isInstanceMuted } from "../../../../misc/is-instance-muted.js";
import { isFiltered } from "../../../../misc/is-filtered.js";
export default class extends Channel {
chName = "recommendedTimeline";
static shouldShare = true;
static requireCredential = true;
withReplies;
constructor(id, connection){
super(id, connection);
this.onNote = this.withPackedNote(this.onNote.bind(this));
}
async init(params) {
const meta = await fetchMeta();
if (meta.disableRecommendedTimeline && !this.user.isAdmin && !this.user.isModerator) return;
this.withReplies = params.withReplies;
// Subscribe events
this.subscriber.on("notesStream", this.onNote);
}
async onNote(note) {
if (note.visibility === "hidden") return;
// チャンネルの投稿ではなく、自分自身の投稿 または
// チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または
// チャンネルの投稿ではなく、全体公開のローカルの投稿 または
// フォローしているチャンネルの投稿 の場合だけ
const meta = await fetchMeta();
if (!(note.user.host != null && meta.recommendedInstances.includes(note.user.host) && note.visibility === "public")) return;
// Ignore notes from instances the user has muted
if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return;
// 関係ない返信は除外
if (note.reply && !this.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;
}
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる
if (this.userProfile && await isFiltered(note, this.user, this.userProfile)) return;
this.connection.cacheNote(note);
this.send("note", note);
}
dispose() {
// Unsubscribe events
this.subscriber.off("notesStream", this.onNote);
}
}
@@ -0,0 +1,49 @@
import Channel from "../channel.js";
import { cancelGame, checkTimeout, gameReady, isValidUpdateKey, isValidUpdateValue, putStone, updateSettings } from "../../../../services/reversi/index.js";
export default class extends Channel {
chName = "reversiGame";
static shouldShare = false;
static requireCredential = false;
gameId = null;
constructor(id, connection){
super(id, connection);
this.onEvent = this.onEvent.bind(this);
}
async init(params) {
if (typeof params.gameId !== "string") return;
this.gameId = params.gameId;
this.subscriber.on(`reversiGameStream:${this.gameId}`, this.onEvent);
}
onEvent(data) {
this.send(data);
}
onMessage(type, body) {
if (!this.gameId) return;
switch(type){
case "ready":
if (this.user && typeof body === "boolean") gameReady(this.gameId, this.user, body);
break;
case "updateSettings":
if (this.user && body && isValidUpdateKey(body.key) && isValidUpdateValue(body.key, body.value)) {
updateSettings(this.gameId, this.user, body.key, body.value);
}
break;
case "cancel":
if (this.user) cancelGame(this.gameId, this.user);
break;
case "putStone":
if (this.user && body && typeof body.pos === "number") {
putStone(this.gameId, this.user, body.pos, typeof body.id === "string" ? body.id : null);
}
break;
case "claimTimeIsUp":
checkTimeout(this.gameId);
break;
}
}
dispose() {
if (this.gameId) {
this.subscriber.off(`reversiGameStream:${this.gameId}`, this.onEvent);
}
}
}
@@ -0,0 +1,19 @@
import Channel from "../channel.js";
export default class extends Channel {
chName = "reversi";
static shouldShare = true;
static requireCredential = true;
constructor(id, connection){
super(id, connection);
this.onEvent = this.onEvent.bind(this);
}
async init(params) {
this.subscriber.on(`reversiStream:${this.user.id}`, this.onEvent);
}
onEvent(data) {
this.send(data);
}
dispose() {
this.subscriber.off(`reversiStream:${this.user.id}`, this.onEvent);
}
}
@@ -0,0 +1,35 @@
import Xev from "xev";
import Channel from "../channel.js";
const ev = new Xev();
export default class extends Channel {
chName = "serverStats";
static shouldShare = true;
static requireCredential = false;
constructor(id, connection){
super(id, connection);
this.onStats = this.onStats.bind(this);
this.onMessage = this.onMessage.bind(this);
}
async init(params) {
ev.addListener("serverStats", this.onStats);
}
onStats(stats) {
this.send("stats", stats);
}
onMessage(type, body) {
switch(type){
case "requestLog":
ev.once(`serverStatsLog:${body.id}`, (statsLog)=>{
this.send("statsLog", statsLog);
});
ev.emit("requestServerStatsLog", {
id: body.id,
length: body.length
});
break;
}
}
dispose() {
ev.removeListener("serverStats", this.onStats);
}
}
@@ -0,0 +1,41 @@
import Channel from "../channel.js";
import { cancelGame, gameReady, putMove } from "../../../../services/shogi/index.js";
export default class extends Channel {
chName = "shogiGame";
static shouldShare = false;
static requireCredential = false;
gameId = null;
constructor(id, connection){
super(id, connection);
this.onEvent = this.onEvent.bind(this);
}
async init(params) {
if (typeof params.gameId !== "string") return;
this.gameId = params.gameId;
this.subscriber.on(`shogiGameStream:${this.gameId}`, this.onEvent);
}
onEvent(data) {
this.send(data);
}
onMessage(type, body) {
if (!this.gameId) return;
switch(type){
case "ready":
if (this.user && typeof body === "boolean") gameReady(this.gameId, this.user, body);
break;
case "cancel":
if (this.user) cancelGame(this.gameId, this.user);
break;
case "move":
if (this.user && body && typeof body.usi === "string") {
putMove(this.gameId, this.user, body.usi, typeof body.id === "string" ? body.id : null);
}
break;
}
}
dispose() {
if (this.gameId) {
this.subscriber.off(`shogiGameStream:${this.gameId}`, this.onEvent);
}
}
}
@@ -0,0 +1,19 @@
import Channel from "../channel.js";
export default class extends Channel {
chName = "shogi";
static shouldShare = true;
static requireCredential = true;
constructor(id, connection){
super(id, connection);
this.onEvent = this.onEvent.bind(this);
}
async init(params) {
this.subscriber.on(`shogiStream:${this.user.id}`, this.onEvent);
}
onEvent(data) {
this.send(data);
}
dispose() {
this.subscriber.off(`shogiStream:${this.user.id}`, this.onEvent);
}
}
@@ -0,0 +1,59 @@
import Channel from "../channel.js";
import { UserListJoinings, UserLists } from "../../../../models/index.js";
import { isUserRelated } from "../../../../misc/is-user-related.js";
export default class extends Channel {
chName = "userList";
static shouldShare = false;
static requireCredential = false;
listId;
listUsers = [];
listUsersClock;
constructor(id, connection){
super(id, connection);
this.updateListUsers = this.updateListUsers.bind(this);
this.onNote = this.withPackedNote(this.onNote.bind(this));
}
async init(params) {
this.listId = params.listId;
// Check existence and owner
const exist = await UserLists.exist({
where: {
id: this.listId,
userId: this.user.id
}
});
if (!exist) return;
// Subscribe stream
this.subscriber.on(`userListStream:${this.listId}`, this.send);
this.subscriber.on("notesStream", this.onNote);
this.updateListUsers();
this.listUsersClock = setInterval(this.updateListUsers, 5000);
}
async updateListUsers() {
const users = await UserListJoinings.find({
where: {
userListId: this.listId
},
select: [
"userId"
]
});
this.listUsers = users.map((x)=>x.userId);
}
async onNote(note) {
if (note.visibility === "hidden") return;
if (!this.listUsers.includes(note.userId)) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return;
this.send("note", note);
}
dispose() {
// Unsubscribe events
this.subscriber.off(`userListStream:${this.listId}`, this.send);
this.subscriber.off("notesStream", this.onNote);
clearInterval(this.listUsersClock);
}
}
@@ -0,0 +1,502 @@
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();
}
}
}
@@ -0,0 +1 @@
export { };