merge: upstream

This commit is contained in:
Mar0xy 2023-10-03 15:20:49 +02:00
commit 38e35e1472
No known key found for this signature in database
GPG key ID: 56569BBE47D2C828
185 changed files with 4442 additions and 2501 deletions

View file

@ -216,4 +216,6 @@ module.exports = {
maxWorkers: 1, // Make it use worker (that can be killed and restarted)
logHeapUsage: true, // To debug when out-of-memory happens on CI
workerIdleMemoryLimit: '1GiB', // Limit the worker to 1GB (GitHub Workflows dies at 2GB)
maxConcurrency: 32,
};

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class NotificationRecieveConfig1695944637565 {
name = 'NotificationRecieveConfig1695944637565'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "mutingNotificationTypes"`);
await queryRunner.query(`ALTER TABLE "user_profile" ADD "notificationRecieveConfig" jsonb NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "notificationRecieveConfig"`);
await queryRunner.query(`ALTER TABLE "user_profile" ADD "mutingNotificationTypes" "public"."user_profile_notificationrecieveconfig_enum" array NOT NULL DEFAULT '{}'`);
}
}

View file

@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class WithReplies1696222183852 {
name = 'WithReplies1696222183852'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "following" ADD "withReplies" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "user_list_joining" ADD "withReplies" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`CREATE INDEX "IDX_d74d8ab5efa7e3bb82825c0fa2" ON "following" ("followeeId", "followerHost") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_d74d8ab5efa7e3bb82825c0fa2"`);
await queryRunner.query(`ALTER TABLE "user_list_joining" DROP COLUMN "withReplies"`);
await queryRunner.query(`ALTER TABLE "following" DROP COLUMN "withReplies"`);
}
}

View file

@ -0,0 +1,11 @@
export class UserListMembership1696323464251 {
name = 'UserListMembership1696323464251'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list_joining" RENAME TO "user_list_membership"`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list_membership" RENAME TO "user_list_joining"`);
}
}

View file

@ -0,0 +1,17 @@
export class Hibernation1696331570827 {
name = 'Hibernation1696331570827'
async up(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_d74d8ab5efa7e3bb82825c0fa2"`);
await queryRunner.query(`ALTER TABLE "user" ADD "isHibernated" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "following" ADD "isFollowerHibernated" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`CREATE INDEX "IDX_ce62b50d882d4e9dee10ad0d2f" ON "following" ("followeeId", "followerHost", "isFollowerHibernated") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_ce62b50d882d4e9dee10ad0d2f"`);
await queryRunner.query(`ALTER TABLE "following" DROP COLUMN "isFollowerHibernated"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isHibernated"`);
await queryRunner.query(`CREATE INDEX "IDX_d74d8ab5efa7e3bb82825c0fa2" ON "following" ("followeeId", "followerHost") `);
}
}

View file

@ -0,0 +1,33 @@
export class Clean1696332072038 {
name = 'Clean1696332072038'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list_membership" DROP CONSTRAINT "FK_d844bfc6f3f523a05189076efaa"`);
await queryRunner.query(`ALTER TABLE "user_list_membership" DROP CONSTRAINT "FK_605472305f26818cc93d1baaa74"`);
await queryRunner.query(`DROP INDEX "public"."IDX_d844bfc6f3f523a05189076efa"`);
await queryRunner.query(`DROP INDEX "public"."IDX_605472305f26818cc93d1baaa7"`);
await queryRunner.query(`DROP INDEX "public"."IDX_90f7da835e4c10aca6853621e1"`);
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "preservedUsernames" SET DEFAULT '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }'`);
await queryRunner.query(`COMMENT ON COLUMN "user_list_membership"."createdAt" IS 'The created date of the UserListMembership.'`);
await queryRunner.query(`CREATE INDEX "IDX_021015e6683570ae9f6b0c62be" ON "user_list_membership" ("userId") `);
await queryRunner.query(`CREATE INDEX "IDX_cddcaf418dc4d392ecfcca842a" ON "user_list_membership" ("userListId") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_e4f3094c43f2d665e6030b0337" ON "user_list_membership" ("userId", "userListId") `);
await queryRunner.query(`ALTER TABLE "user_list_membership" ADD CONSTRAINT "FK_021015e6683570ae9f6b0c62bee" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "user_list_membership" ADD CONSTRAINT "FK_cddcaf418dc4d392ecfcca842a7" FOREIGN KEY ("userListId") REFERENCES "user_list"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list_membership" DROP CONSTRAINT "FK_cddcaf418dc4d392ecfcca842a7"`);
await queryRunner.query(`ALTER TABLE "user_list_membership" DROP CONSTRAINT "FK_021015e6683570ae9f6b0c62bee"`);
await queryRunner.query(`DROP INDEX "public"."IDX_e4f3094c43f2d665e6030b0337"`);
await queryRunner.query(`DROP INDEX "public"."IDX_cddcaf418dc4d392ecfcca842a"`);
await queryRunner.query(`DROP INDEX "public"."IDX_021015e6683570ae9f6b0c62be"`);
await queryRunner.query(`COMMENT ON COLUMN "user_list_membership"."createdAt" IS 'The created date of the UserListJoining.'`);
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "preservedUsernames" SET DEFAULT '{admin,administrator,root,system,maintainer,host,mod,moderator,owner,superuser,staff,auth,i,me,everyone,all,mention,mentions,example,user,users,account,accounts,official,help,helps,support,supports,info,information,informations,announce,announces,announcement,announcements,notice,notification,notifications,dev,developer,developers,tech,misskey}'`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_90f7da835e4c10aca6853621e1" ON "user_list_membership" ("userId", "userListId") `);
await queryRunner.query(`CREATE INDEX "IDX_605472305f26818cc93d1baaa7" ON "user_list_membership" ("userListId") `);
await queryRunner.query(`CREATE INDEX "IDX_d844bfc6f3f523a05189076efa" ON "user_list_membership" ("userId") `);
await queryRunner.query(`ALTER TABLE "user_list_membership" ADD CONSTRAINT "FK_605472305f26818cc93d1baaa74" FOREIGN KEY ("userListId") REFERENCES "user_list"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "user_list_membership" ADD CONSTRAINT "FK_d844bfc6f3f523a05189076efaa" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
}

View file

@ -39,7 +39,7 @@
"@swc/core-win32-x64-msvc": "1.3.56",
"@tensorflow/tfjs": "4.4.0",
"@tensorflow/tfjs-node": "4.4.0",
"bufferutil": "^4.0.7",
"bufferutil": "4.0.7",
"slacc-android-arm-eabi": "0.0.10",
"slacc-android-arm64": "0.0.10",
"slacc-darwin-arm64": "0.0.10",
@ -53,7 +53,7 @@
"slacc-linux-x64-musl": "0.0.10",
"slacc-win32-arm64-msvc": "0.0.10",
"slacc-win32-x64-msvc": "0.0.10",
"utf-8-validate": "^6.0.3"
"utf-8-validate": "6.0.3"
},
"dependencies": {
"@aws-sdk/client-s3": "3.412.0",
@ -67,18 +67,18 @@
"@fastify/cors": "8.4.0",
"@fastify/express": "2.3.0",
"@fastify/http-proxy": "9.2.1",
"@fastify/multipart": "7.7.3",
"@fastify/multipart": "8.0.0",
"@fastify/static": "6.11.2",
"@fastify/view": "8.2.0",
"@nestjs/common": "10.2.6",
"@nestjs/core": "10.2.6",
"@nestjs/testing": "10.2.6",
"@peertube/http-signature": "1.7.0",
"@simplewebauthn/server": "8.1.1",
"@simplewebauthn/server": "8.2.0",
"@sinonjs/fake-timers": "11.1.0",
"@smithy/node-http-handler": "2.1.5",
"@swc/cli": "0.1.62",
"@swc/core": "1.3.87",
"@swc/core": "1.3.90",
"accepts": "1.3.8",
"ajv": "8.12.0",
"archiver": "6.0.1",
@ -118,7 +118,7 @@
"jsonld": "8.3.1",
"jsrsasign": "10.8.6",
"megalodon": "workspace:*",
"meilisearch": "0.34.2",
"meilisearch": "0.35.0",
"mfm-js": "0.23.3",
"microformats-parser": "1.5.2",
"mime-types": "2.1.35",
@ -158,7 +158,7 @@
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly",
"systeminformation": "5.21.8",
"systeminformation": "5.21.9",
"tinycolor2": "1.6.0",
"tmp": "0.2.1",
"tsc-alias": "1.8.8",
@ -191,34 +191,34 @@
"@types/jsdom": "21.1.3",
"@types/jsonld": "1.5.10",
"@types/jsrsasign": "10.5.9",
"@types/mime-types": "2.1.1",
"@types/ms": "0.7.31",
"@types/node": "20.6.4",
"@types/mime-types": "2.1.2",
"@types/ms": "0.7.32",
"@types/node": "20.7.1",
"@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.11",
"@types/oauth": "0.9.2",
"@types/oauth2orize": "1.11.1",
"@types/oauth2orize-pkce": "0.1.0",
"@types/pg": "8.10.2",
"@types/pug": "2.0.6",
"@types/pg": "8.10.3",
"@types/pug": "2.0.7",
"@types/punycode": "2.1.0",
"@types/qrcode": "1.5.2",
"@types/random-seed": "0.3.3",
"@types/ratelimiter": "3.4.4",
"@types/rename": "1.0.4",
"@types/sanitize-html": "2.9.0",
"@types/semver": "7.5.2",
"@types/rename": "1.0.5",
"@types/sanitize-html": "2.9.1",
"@types/semver": "7.5.3",
"@types/sharp": "0.32.0",
"@types/simple-oauth2": "5.0.4",
"@types/sinonjs__fake-timers": "8.1.2",
"@types/simple-oauth2": "5.0.5",
"@types/sinonjs__fake-timers": "8.1.3",
"@types/tinycolor2": "1.4.4",
"@types/tmp": "0.2.4",
"@types/uuid": "^9.0.4",
"@types/vary": "1.1.0",
"@types/web-push": "3.6.0",
"@types/ws": "8.5.5",
"@typescript-eslint/eslint-plugin": "6.7.2",
"@typescript-eslint/parser": "6.7.2",
"@types/vary": "1.1.1",
"@types/web-push": "3.6.1",
"@types/ws": "8.5.6",
"@typescript-eslint/eslint-plugin": "6.7.3",
"@typescript-eslint/parser": "6.7.3",
"aws-sdk-client-mock": "3.0.0",
"cross-env": "7.0.3",
"eslint": "8.50.0",

View file

@ -70,11 +70,19 @@ const $redisForSub: Provider = {
inject: [DI.config],
};
const $redisForTimelines: Provider = {
provide: DI.redisForTimelines,
useFactory: (config: Config) => {
return new Redis.Redis(config.redisForTimelines);
},
inject: [DI.config],
};
@Global()
@Module({
imports: [RepositoryModule],
providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub],
exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, RepositoryModule],
providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines],
exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, RepositoryModule],
})
export class GlobalModule implements OnApplicationShutdown {
constructor(
@ -82,6 +90,7 @@ export class GlobalModule implements OnApplicationShutdown {
@Inject(DI.redis) private redisClient: Redis.Redis,
@Inject(DI.redisForPub) private redisForPub: Redis.Redis,
@Inject(DI.redisForSub) private redisForSub: Redis.Redis,
@Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis,
) {}
public async dispose(): Promise<void> {
@ -98,6 +107,7 @@ export class GlobalModule implements OnApplicationShutdown {
this.redisClient.disconnect(),
this.redisForPub.disconnect(),
this.redisForSub.disconnect(),
this.redisForTimelines.disconnect(),
]);
}

View file

@ -66,6 +66,7 @@ export async function masterMain() {
showNodejsVersion();
config = loadConfigBoot();
//await connectDb();
if (config.pidFile) fs.writeFileSync(config.pidFile, process.pid.toString());
} catch (e) {
bootLogger.error('Fatal error occurred during initialization', null, true);
process.exit(1);

View file

@ -47,6 +47,7 @@ type Source = {
redis: RedisOptionsSource;
redisForPubsub?: RedisOptionsSource;
redisForJobQueue?: RedisOptionsSource;
redisForTimelines?: RedisOptionsSource;
meilisearch?: {
host: string;
port: string;
@ -89,6 +90,7 @@ type Source = {
perChannelMaxNoteCacheCount?: number;
perUserNotificationsMaxCount?: number;
deactivateAntennaThreshold?: number;
pidFile: string;
};
export type Config = {
@ -160,9 +162,11 @@ export type Config = {
redis: RedisOptions & RedisOptionsSource;
redisForPubsub: RedisOptions & RedisOptionsSource;
redisForJobQueue: RedisOptions & RedisOptionsSource;
redisForTimelines: RedisOptions & RedisOptionsSource;
perChannelMaxNoteCacheCount: number;
perUserNotificationsMaxCount: number;
deactivateAntennaThreshold: number;
pidFile: string;
};
const _filename = fileURLToPath(import.meta.url);
@ -225,6 +229,7 @@ export function loadConfig(): Config {
redis,
redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis,
redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis,
redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis,
id: config.id,
proxy: config.proxy,
proxySmtp: config.proxySmtp,
@ -255,6 +260,7 @@ export function loadConfig(): Config {
perChannelMaxNoteCacheCount: config.perChannelMaxNoteCacheCount ?? 1000,
perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 300,
deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7),
pidFile: config.pidFile,
};
}

View file

@ -9,7 +9,7 @@ import { IsNull, In, MoreThan, Not } from 'typeorm';
import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListJoiningsRepository, UsersRepository } from '@/models/_.js';
import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListMembershipsRepository, UsersRepository } from '@/models/_.js';
import type { RelationshipJobData, ThinUser } from '@/queue/types.js';
import { IdService } from '@/core/IdService.js';
@ -42,8 +42,8 @@ export class AccountMoveService {
@Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
@Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository,
@ -215,40 +215,40 @@ export class AccountMoveService {
@bindThis
public async updateLists(src: ThinUser, dst: MiUser): Promise<void> {
// Return if there is no list to be updated.
const oldJoinings = await this.userListJoiningsRepository.find({
const oldMemberships = await this.userListMembershipsRepository.find({
where: {
userId: src.id,
},
});
if (oldJoinings.length === 0) return;
if (oldMemberships.length === 0) return;
const existingUserListIds = await this.userListJoiningsRepository.find({
const existingUserListIds = await this.userListMembershipsRepository.find({
where: {
userId: dst.id,
},
}).then(joinings => joinings.map(joining => joining.userListId));
}).then(memberships => memberships.map(membership => membership.userListId));
const newJoinings: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map();
const newMemberships: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map();
// 重複しないようにIDを生成
const genId = (): string => {
let id: string;
do {
id = this.idService.genId();
} while (newJoinings.has(id));
} while (newMemberships.has(id));
return id;
};
for (const joining of oldJoinings) {
if (existingUserListIds.includes(joining.userListId)) continue; // skip if dst exists in this user's list
newJoinings.set(genId(), {
for (const membership of oldMemberships) {
if (existingUserListIds.includes(membership.userListId)) continue; // skip if dst exists in this user's list
newMemberships.set(genId(), {
createdAt: new Date(),
userId: dst.id,
userListId: joining.userListId,
userListId: membership.userListId,
});
}
const arrayToInsert = Array.from(newJoinings.entries()).map(entry => ({ ...entry[1], id: entry[0] }));
await this.userListJoiningsRepository.insert(arrayToInsert);
const arrayToInsert = Array.from(newMemberships.entries()).map(entry => ({ ...entry[1], id: entry[0] }));
await this.userListMembershipsRepository.insert(arrayToInsert);
// Have the proxy account follow the new account in the same way as UserListService.push
if (this.userEntityService.isRemoteUser(dst)) {

View file

@ -12,10 +12,10 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
import * as Acct from '@/misc/acct.js';
import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js';
import type { AntennasRepository, UserListJoiningsRepository } from '@/models/_.js';
import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { StreamMessages } from '@/server/api/stream/types.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
@ -24,8 +24,8 @@ export class AntennaService implements OnApplicationShutdown {
private antennas: MiAntenna[];
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@ -33,8 +33,8 @@ export class AntennaService implements OnApplicationShutdown {
@Inject(DI.antennasRepository)
private antennasRepository: AntennasRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
private utilityService: UtilityService,
private globalEventService: GlobalEventService,
@ -50,7 +50,7 @@ export class AntennaService implements OnApplicationShutdown {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message as StreamMessages['internal']['payload'];
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'antennaCreated':
this.antennas.push({
@ -81,7 +81,7 @@ export class AntennaService implements OnApplicationShutdown {
const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const)));
const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna);
const redisPipeline = this.redisClient.pipeline();
const redisPipeline = this.redisForTimelines.pipeline();
for (const antenna of matchedAntennas) {
redisPipeline.xadd(
@ -108,7 +108,7 @@ export class AntennaService implements OnApplicationShutdown {
if (antenna.src === 'home') {
// TODO
} else if (antenna.src === 'list') {
const listUsers = (await this.userListJoiningsRepository.findBy({
const listUsers = (await this.userListMembershipsRepository.findBy({
userListId: antenna.userListId!,
})).map(x => x.userId);

View file

@ -5,13 +5,13 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js';
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { StreamMessages } from '@/server/api/stream/types.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
@ -25,7 +25,7 @@ export class CacheService implements OnApplicationShutdown {
public userBlockingCache: RedisKVCache<Set<string>>;
public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
public renoteMutingsCache: RedisKVCache<Set<string>>;
public userFollowingsCache: RedisKVCache<Set<string>>;
public userFollowingsCache: RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>;
public userFollowingChannelsCache: RedisKVCache<Set<string>>;
constructor(
@ -136,12 +136,18 @@ export class CacheService implements OnApplicationShutdown {
fromRedisConverter: (value) => new Set(JSON.parse(value)),
});
this.userFollowingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowings', {
this.userFollowingsCache = new RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>(this.redisClient, 'userFollowings', {
lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60, // 1m
fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))),
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
fromRedisConverter: (value) => new Set(JSON.parse(value)),
fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId', 'withReplies'] }).then(xs => {
const obj: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
for (const x of xs) {
obj[x.followeeId] = { withReplies: x.withReplies };
}
return obj;
}),
toRedisConverter: (value) => JSON.stringify(value),
fromRedisConverter: (value) => JSON.parse(value),
});
this.userFollowingChannelsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowingChannels', {
@ -160,7 +166,7 @@ export class CacheService implements OnApplicationShutdown {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message as StreamMessages['internal']['payload'];
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'userChangeSuspendedState':
case 'remoteUserUpdated': {
@ -188,6 +194,7 @@ export class CacheService implements OnApplicationShutdown {
if (follower) follower.followingCount++;
const followee = this.userByIdCache.get(body.followeeId);
if (followee) followee.followersCount++;
this.userFollowingsCache.delete(body.followerId);
break;
}
default:

View file

@ -47,6 +47,7 @@ import { SignupService } from './SignupService.js';
import { WebAuthnService } from './WebAuthnService.js';
import { UserBlockingService } from './UserBlockingService.js';
import { CacheService } from './CacheService.js';
import { UserService } from './UserService.js';
import { UserFollowingService } from './UserFollowingService.js';
import { UserKeypairService } from './UserKeypairService.js';
import { UserListService } from './UserListService.js';
@ -175,6 +176,7 @@ const $SignupService: Provider = { provide: 'SignupService', useExisting: Signup
const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService };
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService };
const $UserService: Provider = { provide: 'UserService', useExisting: UserService };
const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService };
const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService };
const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService };
@ -306,6 +308,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebAuthnService,
UserBlockingService,
CacheService,
UserService,
UserFollowingService,
UserKeypairService,
UserListService,
@ -430,6 +433,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$WebAuthnService,
$UserBlockingService,
$CacheService,
$UserService,
$UserFollowingService,
$UserKeypairService,
$UserListService,
@ -555,6 +559,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebAuthnService,
UserBlockingService,
CacheService,
UserService,
UserFollowingService,
UserKeypairService,
UserListService,
@ -678,6 +683,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$WebAuthnService,
$UserBlockingService,
$CacheService,
$UserService,
$UserFollowingService,
$UserKeypairService,
$UserListService,

View file

@ -17,7 +17,7 @@ import { bindThis } from '@/decorators.js';
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js';
import { query } from '@/misc/prelude/url.js';
import type { Serialized } from '@/server/api/stream/types.js';
import type { Serialized } from '@/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import type { Config } from '@/config.js';

View file

@ -5,27 +5,254 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import type { MiChannel } from '@/models/Channel.js';
import type { MiUser } from '@/models/User.js';
import type { MiUserProfile } from '@/models/UserProfile.js';
import type { MiNote } from '@/models/Note.js';
import type { MiUserList } from '@/models/UserList.js';
import type { MiAntenna } from '@/models/Antenna.js';
import type {
StreamChannels,
AdminStreamTypes,
AntennaStreamTypes,
BroadcastTypes,
DriveStreamTypes,
InternalStreamTypes,
MainStreamTypes,
NoteStreamTypes,
UserListStreamTypes,
RoleTimelineStreamTypes,
} from '@/server/api/stream/types.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiDriveFolder } from '@/models/DriveFolder.js';
import type { MiUserList } from '@/models/UserList.js';
import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
import type { MiSignin } from '@/models/Signin.js';
import type { MiPage } from '@/models/Page.js';
import type { MiWebhook } from '@/models/Webhook.js';
import type { MiMeta } from '@/models/Meta.js';
import { MiRole, MiRoleAssignment } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';
import { MiRole } from '@/models/_.js';
import { Serialized } from '@/types.js';
import type Emitter from 'strict-event-emitter-types';
import type { EventEmitter } from 'events';
//#region Stream type-body definitions
export interface BroadcastTypes {
emojiAdded: {
emoji: Packed<'EmojiDetailed'>;
};
emojiUpdated: {
emojis: Packed<'EmojiDetailed'>[];
};
emojiDeleted: {
emojis: {
id?: string;
name: string;
[other: string]: any;
}[];
};
announcementCreated: {
announcement: Packed<'Announcement'>;
};
}
export interface MainEventTypes {
notification: Packed<'Notification'>;
mention: Packed<'Note'>;
reply: Packed<'Note'>;
renote: Packed<'Note'>;
follow: Packed<'UserDetailedNotMe'>;
followed: Packed<'User'>;
unfollow: Packed<'User'>;
meUpdated: Packed<'User'>;
pageEvent: {
pageId: MiPage['id'];
event: string;
var: any;
userId: MiUser['id'];
user: Packed<'User'>;
};
urlUploadFinished: {
marker?: string | null;
file: Packed<'DriveFile'>;
};
readAllNotifications: undefined;
unreadNotification: Packed<'Notification'>;
unreadMention: MiNote['id'];
readAllUnreadMentions: undefined;
unreadSpecifiedNote: MiNote['id'];
readAllUnreadSpecifiedNotes: undefined;
readAllAntennas: undefined;
unreadAntenna: MiAntenna;
readAllAnnouncements: undefined;
myTokenRegenerated: undefined;
signin: MiSignin;
registryUpdated: {
scope?: string[];
key: string;
value: any | null;
};
driveFileCreated: Packed<'DriveFile'>;
readAntenna: MiAntenna;
receiveFollowRequest: Packed<'User'>;
announcementCreated: {
announcement: Packed<'Announcement'>;
};
}
export interface DriveEventTypes {
fileCreated: Packed<'DriveFile'>;
fileDeleted: MiDriveFile['id'];
fileUpdated: Packed<'DriveFile'>;
folderCreated: Packed<'DriveFolder'>;
folderDeleted: MiDriveFolder['id'];
folderUpdated: Packed<'DriveFolder'>;
}
export interface NoteEventTypes {
pollVoted: {
choice: number;
userId: MiUser['id'];
};
deleted: {
deletedAt: Date;
};
updated: {
cw: string | null;
text: string;
};
reacted: {
reaction: string;
emoji?: {
name: string;
url: string;
} | null;
userId: MiUser['id'];
};
unreacted: {
reaction: string;
userId: MiUser['id'];
};
}
type NoteStreamEventTypes = {
[key in keyof NoteEventTypes]: {
id: MiNote['id'];
body: NoteEventTypes[key];
};
};
export interface UserListEventTypes {
userAdded: Packed<'User'>;
userRemoved: Packed<'User'>;
}
export interface AntennaEventTypes {
note: MiNote;
}
export interface RoleTimelineEventTypes {
note: Packed<'Note'>;
}
export interface AdminEventTypes {
newAbuseUserReport: {
id: MiAbuseUserReport['id'];
targetUserId: MiUser['id'],
reporterId: MiUser['id'],
comment: string;
};
}
//#endregion
// 辞書(interface or type)から{ type, body }ユニオンを定義
// https://stackoverflow.com/questions/49311989/can-i-infer-the-type-of-a-value-using-extends-keyof-type
// VS Codeの展開を防止するためにEvents型を定義
type Events<T extends object> = { [K in keyof T]: { type: K; body: T[K]; } };
type EventUnionFromDictionary<
T extends object,
U = Events<T>
> = U[keyof U];
type SerializedAll<T> = {
[K in keyof T]: Serialized<T[K]>;
};
export interface InternalEventTypes {
userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; };
userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; };
remoteUserUpdated: { id: MiUser['id']; };
follow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
unfollow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
blockingCreated: { blockerId: MiUser['id']; blockeeId: MiUser['id']; };
blockingDeleted: { blockerId: MiUser['id']; blockeeId: MiUser['id']; };
policiesUpdated: MiRole['policies'];
roleCreated: MiRole;
roleDeleted: MiRole;
roleUpdated: MiRole;
userRoleAssigned: MiRoleAssignment;
userRoleUnassigned: MiRoleAssignment;
webhookCreated: MiWebhook;
webhookDeleted: MiWebhook;
webhookUpdated: MiWebhook;
antennaCreated: MiAntenna;
antennaDeleted: MiAntenna;
antennaUpdated: MiAntenna;
metaUpdated: MiMeta;
followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
updateUserProfile: MiUserProfile;
mute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; };
userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; };
}
// name/messages(spec) pairs dictionary
export type GlobalEvents = {
internal: {
name: 'internal';
payload: EventUnionFromDictionary<SerializedAll<InternalEventTypes>>;
};
broadcast: {
name: 'broadcast';
payload: EventUnionFromDictionary<SerializedAll<BroadcastTypes>>;
};
main: {
name: `mainStream:${MiUser['id']}`;
payload: EventUnionFromDictionary<SerializedAll<MainEventTypes>>;
};
drive: {
name: `driveStream:${MiUser['id']}`;
payload: EventUnionFromDictionary<SerializedAll<DriveEventTypes>>;
};
note: {
name: `noteStream:${MiNote['id']}`;
payload: EventUnionFromDictionary<SerializedAll<NoteStreamEventTypes>>;
};
userList: {
name: `userListStream:${MiUserList['id']}`;
payload: EventUnionFromDictionary<SerializedAll<UserListEventTypes>>;
};
roleTimeline: {
name: `roleTimelineStream:${MiRole['id']}`;
payload: EventUnionFromDictionary<SerializedAll<RoleTimelineEventTypes>>;
};
antenna: {
name: `antennaStream:${MiAntenna['id']}`;
payload: EventUnionFromDictionary<SerializedAll<AntennaEventTypes>>;
};
admin: {
name: `adminStream:${MiUser['id']}`;
payload: EventUnionFromDictionary<SerializedAll<AdminEventTypes>>;
};
notes: {
name: 'notesStream';
payload: Serialized<Packed<'Note'>>;
};
};
// API event definitions
// ストリームごとのEmitterの辞書を用意
type EventEmitterDictionary = { [x in keyof GlobalEvents]: Emitter.default<EventEmitter, { [y in GlobalEvents[x]['name']]: (e: GlobalEvents[x]['payload']) => void }> };
// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする
export type StreamEventEmitter = UnionToIntersection<EventEmitterDictionary[keyof GlobalEvents]>;
// { [y in name]: (e: spec) => void }をまとめてその交差型をEmitterにかけるとts(2590)にひっかかる
// provide stream channels union
export type StreamChannels = GlobalEvents[keyof GlobalEvents]['name'];
@Injectable()
export class GlobalEventService {
@ -51,7 +278,7 @@ export class GlobalEventService {
}
@bindThis
public publishInternalEvent<K extends keyof InternalStreamTypes>(type: K, value?: InternalStreamTypes[K]): void {
public publishInternalEvent<K extends keyof InternalEventTypes>(type: K, value?: InternalEventTypes[K]): void {
this.publish('internal', type, typeof value === 'undefined' ? null : value);
}
@ -61,17 +288,17 @@ export class GlobalEventService {
}
@bindThis
public publishMainStream<K extends keyof MainStreamTypes>(userId: MiUser['id'], type: K, value?: MainStreamTypes[K]): void {
public publishMainStream<K extends keyof MainEventTypes>(userId: MiUser['id'], type: K, value?: MainEventTypes[K]): void {
this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
@bindThis
public publishDriveStream<K extends keyof DriveStreamTypes>(userId: MiUser['id'], type: K, value?: DriveStreamTypes[K]): void {
public publishDriveStream<K extends keyof DriveEventTypes>(userId: MiUser['id'], type: K, value?: DriveEventTypes[K]): void {
this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
@bindThis
public publishNoteStream<K extends keyof NoteStreamTypes>(noteId: MiNote['id'], type: K, value?: NoteStreamTypes[K]): void {
public publishNoteStream<K extends keyof NoteEventTypes>(noteId: MiNote['id'], type: K, value?: NoteEventTypes[K]): void {
this.publish(`noteStream:${noteId}`, type, {
id: noteId,
body: value,
@ -79,17 +306,17 @@ export class GlobalEventService {
}
@bindThis
public publishUserListStream<K extends keyof UserListStreamTypes>(listId: MiUserList['id'], type: K, value?: UserListStreamTypes[K]): void {
public publishUserListStream<K extends keyof UserListEventTypes>(listId: MiUserList['id'], type: K, value?: UserListEventTypes[K]): void {
this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value);
}
@bindThis
public publishAntennaStream<K extends keyof AntennaStreamTypes>(antennaId: MiAntenna['id'], type: K, value?: AntennaStreamTypes[K]): void {
public publishAntennaStream<K extends keyof AntennaEventTypes>(antennaId: MiAntenna['id'], type: K, value?: AntennaEventTypes[K]): void {
this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value);
}
@bindThis
public publishRoleTimelineStream<K extends keyof RoleTimelineStreamTypes>(roleId: MiRole['id'], type: K, value?: RoleTimelineStreamTypes[K]): void {
public publishRoleTimelineStream<K extends keyof RoleTimelineEventTypes>(roleId: MiRole['id'], type: K, value?: RoleTimelineEventTypes[K]): void {
this.publish(`roleTimelineStream:${roleId}`, type, typeof value === 'undefined' ? null : value);
}
@ -99,7 +326,7 @@ export class GlobalEventService {
}
@bindThis
public publishAdminStream<K extends keyof AdminStreamTypes>(userId: MiUser['id'], type: K, value?: AdminStreamTypes[K]): void {
public publishAdminStream<K extends keyof AdminEventTypes>(userId: MiUser['id'], type: K, value?: AdminEventTypes[K]): void {
this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
}

View file

@ -10,7 +10,7 @@ import { DI } from '@/di-symbols.js';
import { MiMeta } from '@/models/Meta.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { bindThis } from '@/decorators.js';
import { StreamMessages } from '@/server/api/stream/types.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
@ -46,7 +46,7 @@ export class MetaService implements OnApplicationShutdown {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message as StreamMessages['internal']['payload'];
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'metaUpdated': {
this.cache = body;

View file

@ -5,7 +5,7 @@
import { setImmediate } from 'node:timers/promises';
import * as mfm from 'mfm-js';
import { In, DataSource } from 'typeorm';
import { In, DataSource, IsNull, LessThan } from 'typeorm';
import * as Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import RE2 from 're2';
@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js';
import type { ChannelsRepository, FollowingsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiApp } from '@/models/App.js';
import { concat } from '@/misc/prelude/array.js';
@ -54,8 +54,6 @@ import { RoleService } from '@/core/RoleService.js';
import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js';
const mutedWordsCache = new MemorySingleCache<{ userId: MiUserProfile['userId']; mutedWords: MiUserProfile['mutedWords']; }[]>(1000 * 60 * 5);
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
class NotificationManager {
@ -110,9 +108,8 @@ class NotificationManager {
// 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する
if (!mentioneesMutedUserIds.includes(this.notifier.id)) {
this.notificationService.createNotification(x.target, x.reason, {
notifierId: this.notifier.id,
noteId: this.note.id,
});
}, this.notifier.id);
}
}
}
@ -158,8 +155,8 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.db)
private db: DataSource,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@ -176,8 +173,8 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.mutedNotesRepository)
private mutedNotesRepository: MutedNotesRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
@ -188,6 +185,9 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService,
private idService: IdService,
@ -335,7 +335,7 @@ export class NoteCreateService implements OnApplicationShutdown {
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
if (data.channel) {
this.redisClient.xadd(
this.redisForTimelines.xadd(
`channelTimeline:${data.channel.id}`,
'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(),
'*',
@ -481,26 +481,13 @@ export class NoteCreateService implements OnApplicationShutdown {
// Increment notes count (user)
this.incNotesCountOfUser(user);
// Word mute
mutedWordsCache.fetch(() => this.userProfilesRepository.find({
where: {
enableWordMute: true,
},
select: ['userId', 'mutedWords'],
})).then(us => {
for (const u of us) {
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
if (shouldMute) {
this.mutedNotesRepository.insert({
id: this.idService.genId(),
userId: u.userId,
noteId: note.id,
reason: 'word',
});
}
});
}
});
if (data.visibility === 'public' || data.visibility === 'home') {
this.pushToTl(note, user);
} else if (data.visibility === 'followers') {
this.pushToTl(note, user);
} else if (data.visibility === 'specified') {
// TODO
}
this.antennaService.addNoteToAntennas(note, user);
@ -509,15 +496,16 @@ export class NoteCreateService implements OnApplicationShutdown {
}
if (data.reply == null) {
// TODO: キャッシュ
this.followingsRepository.findBy({
followeeId: user.id,
notify: 'normal',
}).then(followings => {
for (const following of followings) {
// TODO: ワードミュート考慮
this.notificationService.createNotification(following.followerId, 'note', {
notifierId: user.id,
noteId: note.id,
});
}, user.id);
}
});
}
@ -813,6 +801,205 @@ export class NoteCreateService implements OnApplicationShutdown {
return mentionedUsers;
}
@bindThis
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
const redisPipeline = this.redisForTimelines.pipeline();
if (note.channelId) {
const channelFollowings = await this.channelFollowingsRepository.find({
where: {
followeeId: note.channelId,
},
select: ['followerId'],
});
for (const channelFollowing of channelFollowings) {
redisPipeline.xadd(
`homeTimeline:${channelFollowing.followerId}`,
'MAXLEN', '~', '200',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`homeTimelineWithFiles:${channelFollowing.followerId}`,
'MAXLEN', '~', '100',
'*',
'note', note.id);
}
}
} else {
// TODO: キャッシュ?
const followings = await this.followingsRepository.find({
where: {
followeeId: user.id,
followerHost: IsNull(),
isFollowerHibernated: false,
},
select: ['followerId', 'withReplies'],
});
const userListMemberships = await this.userListMembershipsRepository.find({
where: {
userId: user.id,
},
select: ['userListId', 'withReplies'],
});
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
for (const following of followings) {
// 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) {
if (!following.withReplies) continue;
}
redisPipeline.xadd(
`homeTimeline:${following.followerId}`,
'MAXLEN', '~', '200',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`homeTimelineWithFiles:${following.followerId}`,
'MAXLEN', '~', '100',
'*',
'note', note.id);
}
}
// TODO
//if (note.visibility === 'followers') {
// // TODO: 重そうだから何とかしたい Set 使う?
// userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListUserId));
//}
for (const userListMembership of userListMemberships) {
// 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) {
if (!userListMembership.withReplies) continue;
}
redisPipeline.xadd(
`userListTimeline:${userListMembership.userListId}`,
'MAXLEN', '~', '200',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`userListTimelineWithFiles:${userListMembership.userListId}`,
'MAXLEN', '~', '100',
'*',
'note', note.id);
}
}
{ // 自分自身のHTL
redisPipeline.xadd(
`homeTimeline:${user.id}`,
'MAXLEN', '~', '200',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`homeTimelineWithFiles:${user.id}`,
'MAXLEN', '~', '100',
'*',
'note', note.id);
}
}
if (note.visibility === 'public' || note.visibility === 'home') {
// 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) {
redisPipeline.xadd(
`userTimelineWithReplies:${user.id}`,
'MAXLEN', '~', '1000',
'*',
'note', note.id);
} else {
redisPipeline.xadd(
`userTimeline:${user.id}`,
'MAXLEN', '~', '1000',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`userTimelineWithFiles:${user.id}`,
'MAXLEN', '~', '500',
'*',
'note', note.id);
}
if (note.visibility === 'public' && note.userHost == null) {
redisPipeline.xadd(
'localTimeline',
'MAXLEN', '~', '1000',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
'localTimelineWithFiles',
'MAXLEN', '~', '500',
'*',
'note', note.id);
}
}
}
}
if (Math.random() < 0.1) {
process.nextTick(() => {
this.checkHibernation(followings);
});
}
}
redisPipeline.exec();
}
@bindThis
public async checkHibernation(followings: MiFollowing[]) {
if (followings.length === 0) return;
const shuffle = (array: MiFollowing[]) => {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
};
// ランダムに最大1000件サンプリング
const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000));
const hibernatedUsers = await this.usersRepository.find({
where: {
id: In(samples.map(x => x.followerId)),
lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))),
},
select: ['id'],
});
if (hibernatedUsers.length > 0) {
this.usersRepository.update({
id: In(hibernatedUsers.map(x => x.id)),
}, {
isHibernated: true,
});
this.followingsRepository.update({
followerId: In(hibernatedUsers.map(x => x.id)),
}, {
isFollowerHibernated: true,
});
}
}
@bindThis
public dispose(): void {
this.#shutdownController.abort();

View file

@ -5,7 +5,7 @@
import { setImmediate } from 'node:timers/promises';
import * as mfm from 'mfm-js';
import { In } from 'typeorm';
import { In, LessThan } from 'typeorm';
import * as Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import RE2 from 're2';
@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js';
import type { NoteEditRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { NoteEditRepository, ChannelsRepository, InstancesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository, UserListMembershipsRepository, ChannelFollowingsRepository, MiFollowing } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiApp } from '@/models/App.js';
import { concat } from '@/misc/prelude/array.js';
@ -49,8 +49,6 @@ import { RoleService } from '@/core/RoleService.js';
import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js';
const mutedWordsCache = new MemorySingleCache<{ userId: MiUserProfile['userId']; mutedWords: MiUserProfile['mutedWords']; }[]>(1000 * 60 * 5);
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
class NotificationManager {
@ -105,9 +103,8 @@ class NotificationManager {
// 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する
if (!mentioneesMutedUserIds.includes(this.notifier.id)) {
this.notificationService.createNotification(x.target, x.reason, {
notifierId: this.notifier.id,
noteId: this.note.id,
});
}, this.notifier.id);
}
}
}
@ -152,8 +149,8 @@ export class NoteEditService implements OnApplicationShutdown {
@Inject(DI.config)
private config: Config,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@ -170,12 +167,15 @@ export class NoteEditService implements OnApplicationShutdown {
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.mutedNotesRepository)
private mutedNotesRepository: MutedNotesRepository,
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
@Inject(DI.noteThreadMutingsRepository)
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
@ -423,7 +423,7 @@ export class NoteEditService implements OnApplicationShutdown {
await this.notesRepository.update(oldnote.id, note);
if (data.channel) {
this.redisClient.xadd(
this.redisForTimelines.xadd(
`channelTimeline:${data.channel.id}`,
'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(),
'*',
@ -461,27 +461,6 @@ export class NoteEditService implements OnApplicationShutdown {
this.hashtagService.updateHashtags(user, tags);
}
// Word mute
mutedWordsCache.fetch(() => this.userProfilesRepository.find({
where: {
enableWordMute: true,
},
select: ['userId', 'mutedWords'],
})).then(us => {
for (const u of us) {
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
if (shouldMute) {
this.mutedNotesRepository.insert({
id: this.idService.genId(),
userId: u.userId,
noteId: note.id,
reason: 'word',
});
}
});
}
});
if (data.poll && data.poll.expiresAt) {
const delay = data.poll.expiresAt.getTime() - Date.now();
this.queueService.endedPollNotificationQueue.add(note.id, {
@ -492,6 +471,14 @@ export class NoteEditService implements OnApplicationShutdown {
});
}
if (data.visibility === 'public' || data.visibility === 'home') {
this.pushToTl(note, user);
} else if (data.visibility === 'followers') {
this.pushToTl(note, user);
} else if (data.visibility === 'specified') {
// TODO
}
if (!silent) {
if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user);
@ -524,7 +511,8 @@ export class NoteEditService implements OnApplicationShutdown {
const noteObj = await this.noteEntityService.pack(note);
this.globalEventService.publishNoteStream(note.id, 'updated', {
updatedAt: note.updatedAt!,
cw: note.cw,
text: note.text!,
});
this.roleService.addNoteToRoleTimeline(noteObj);
@ -651,6 +639,163 @@ export class NoteEditService implements OnApplicationShutdown {
this.index(note);
}
@bindThis
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
const redisPipeline = this.redisForTimelines.pipeline();
if (note.channelId) {
const channelFollowings = await this.channelFollowingsRepository.find({
where: {
followeeId: note.channelId,
},
select: ['followerId'],
});
for (const channelFollowing of channelFollowings) {
redisPipeline.xadd(
`homeTimeline:${channelFollowing.followerId}`,
'MAXLEN', '~', '200',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`homeTimelineWithFiles:${channelFollowing.followerId}`,
'MAXLEN', '~', '100',
'*',
'note', note.id);
}
}
} else {
// TODO: キャッシュ?
const userListMemberships = await this.userListMembershipsRepository.find({
where: {
userId: user.id,
},
select: ['userListId', 'withReplies'],
});
// TODO
//if (note.visibility === 'followers') {
// // TODO: 重そうだから何とかしたい Set 使う?
// userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListUserId));
//}
for (const userListMembership of userListMemberships) {
// 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) {
if (!userListMembership.withReplies) continue;
}
redisPipeline.xadd(
`userListTimeline:${userListMembership.userListId}`,
'MAXLEN', '~', '200',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`userListTimelineWithFiles:${userListMembership.userListId}`,
'MAXLEN', '~', '100',
'*',
'note', note.id);
}
}
{ // 自分自身のHTL
redisPipeline.xadd(
`homeTimeline:${user.id}`,
'MAXLEN', '~', '200',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`homeTimelineWithFiles:${user.id}`,
'MAXLEN', '~', '100',
'*',
'note', note.id);
}
}
if (note.visibility === 'public' || note.visibility === 'home') {
// 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) {
redisPipeline.xadd(
`userTimelineWithReplies:${user.id}`,
'MAXLEN', '~', '1000',
'*',
'note', note.id);
} else {
redisPipeline.xadd(
`userTimeline:${user.id}`,
'MAXLEN', '~', '1000',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`userTimelineWithFiles:${user.id}`,
'MAXLEN', '~', '500',
'*',
'note', note.id);
}
if (note.visibility === 'public' && note.userHost == null) {
redisPipeline.xadd(
'localTimeline',
'MAXLEN', '~', '1000',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
'localTimelineWithFiles',
'MAXLEN', '~', '500',
'*',
'note', note.id);
}
}
}
}
}
redisPipeline.exec();
}
@bindThis
public async checkHibernation(followings: MiFollowing[]) {
if (followings.length === 0) return;
const shuffle = (array: MiFollowing[]) => {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
};
// ランダムに最大1000件サンプリング
const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000));
const hibernatedUsers = await this.usersRepository.find({
where: {
id: In(samples.map(x => x.followerId)),
lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))),
},
select: ['id'],
});
if (hibernatedUsers.length > 0) {
this.usersRepository.update({
id: In(hibernatedUsers.map(x => x.id)),
}, {
isHibernated: true,
});
}
}
@bindThis
private isSensitive(note: Option, sensitiveWord: string[]): boolean {
if (sensitiveWord.length > 0) {

View file

@ -18,6 +18,7 @@ import { NotificationEntityService } from '@/core/entities/NotificationEntitySer
import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js';
import type { Config } from '@/config.js';
import { UserListService } from '@/core/UserListService.js';
@Injectable()
export class NotificationService implements OnApplicationShutdown {
@ -38,6 +39,7 @@ export class NotificationService implements OnApplicationShutdown {
private globalEventService: GlobalEventService,
private pushNotificationService: PushNotificationService,
private cacheService: CacheService,
private userListService: UserListService,
) {
}
@ -74,27 +76,59 @@ export class NotificationService implements OnApplicationShutdown {
public async createNotification(
notifieeId: MiUser['id'],
type: MiNotification['type'],
data: Partial<MiNotification>,
data: Omit<Partial<MiNotification>, 'notifierId'>,
notifierId?: MiUser['id'] | null,
): Promise<MiNotification | null> {
const profile = await this.cacheService.userProfileCache.fetch(notifieeId);
const isMuted = profile.mutingNotificationTypes.includes(type);
if (isMuted) return null;
if (data.notifierId) {
if (notifieeId === data.notifierId) {
// 古いMisskeyバージョンのキャッシュが残っている可能性がある
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const recieveConfig = (profile.notificationRecieveConfig ?? {})[type];
if (recieveConfig?.type === 'never') {
return null;
}
if (notifierId) {
if (notifieeId === notifierId) {
return null;
}
const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId);
if (mutings.has(data.notifierId)) {
if (mutings.has(notifierId)) {
return null;
}
if (recieveConfig?.type === 'following') {
const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId));
if (!isFollowing) {
return null;
}
} else if (recieveConfig?.type === 'follower') {
const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId));
if (!isFollower) {
return null;
}
} else if (recieveConfig?.type === 'mutualFollow') {
const [isFollowing, isFollower] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
]);
if (!isFollowing && !isFollower) {
return null;
}
} else if (recieveConfig?.type === 'list') {
const isMember = await this.userListService.membersCache.fetch(recieveConfig.userListId).then(members => members.has(notifierId));
if (!isMember) {
return null;
}
}
}
const notification = {
id: this.idService.genId(),
createdAt: new Date(),
type: type,
notifierId: notifierId,
...data,
} as MiNotification;
@ -117,8 +151,8 @@ export class NotificationService implements OnApplicationShutdown {
this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed);
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);
if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! }));
if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! }));
if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: notifierId! }));
if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: notifierId! }));
}, () => { /* aborted, ignore it */ });
return notification;

View file

@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Brackets, ObjectLiteral } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MiUser } from '@/models/User.js';
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js';
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import type { SelectQueryBuilder } from 'typeorm';
@ -23,9 +23,6 @@ export class QueryService {
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
@Inject(DI.mutedNotesRepository)
private mutedNotesRepository: MutedNotesRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@ -108,39 +105,6 @@ export class QueryService {
q.setParameters(blockedQuery.getParameters());
}
@bindThis
public generateChannelQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void {
if (me == null) {
q.andWhere('note.channelId IS NULL');
} else {
q.leftJoinAndSelect('note.channel', 'channel');
const channelFollowingQuery = this.channelFollowingsRepository.createQueryBuilder('channelFollowing')
.select('channelFollowing.followeeId')
.where('channelFollowing.followerId = :followerId', { followerId: me.id });
q.andWhere(new Brackets(qb => { qb
// チャンネルのノートではない
.where('note.channelId IS NULL')
// または自分がフォローしているチャンネルのノート
.orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`);
}));
q.setParameters(channelFollowingQuery.getParameters());
}
}
@bindThis
public generateMutedNoteQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
const mutedQuery = this.mutedNotesRepository.createQueryBuilder('muted')
.select('muted.noteId')
.where('muted.userId = :userId', { userId: me.id });
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
q.setParameters(mutedQuery.getParameters());
}
@bindThis
public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
@ -212,32 +176,6 @@ export class QueryService {
q.setParameters(mutingQuery.getParameters());
}
@bindThis
public generateRepliesQuery(q: SelectQueryBuilder<any>, withReplies: boolean, me?: Pick<MiUser, 'id'> | null): void {
if (me == null) {
q.andWhere(new Brackets(qb => { qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}));
}));
} else if (!withReplies) {
q.andWhere(new Brackets(qb => { qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信
.orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信
.where('note.replyId IS NOT NULL')
.andWhere('note.userId = :meId', { meId: me.id });
}))
.orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}));
}));
}
}
@bindThis
public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void {
// This code must always be synchronized with the checks in Notes.isVisibleForMe.

View file

@ -219,10 +219,9 @@ export class ReactionService {
// リアクションされたユーザーがローカルユーザーなら通知を作成
if (note.userHost === null) {
this.notificationService.createNotification(note.userId, 'reaction', {
notifierId: user.id,
noteId: note.id,
reaction: reaction,
});
}, user.id);
}
//#region 配信

View file

@ -15,7 +15,7 @@ import { MetaService } from '@/core/MetaService.js';
import { CacheService } from '@/core/CacheService.js';
import type { RoleCondFormulaValue } from '@/models/Role.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { StreamMessages } from '@/server/api/stream/types.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
@ -32,6 +32,7 @@ export type RolePolicies = {
inviteExpirationTime: number;
canManageCustomEmojis: boolean;
canSearchNotes: boolean;
canUseTranslator: boolean;
canHideAds: boolean;
driveCapacityMb: number;
alwaysMarkNsfw: boolean;
@ -56,6 +57,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
inviteExpirationTime: 0,
canManageCustomEmojis: false,
canSearchNotes: false,
canUseTranslator: true,
canHideAds: false,
driveCapacityMb: 100,
alwaysMarkNsfw: false,
@ -114,7 +116,7 @@ export class RoleService implements OnApplicationShutdown {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message as StreamMessages['internal']['payload'];
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'roleCreated': {
const cached = this.rolesCache.get();
@ -300,6 +302,7 @@ export class RoleService implements OnApplicationShutdown {
inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)),
canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)),
canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)),
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)),
alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)),

View file

@ -11,7 +11,7 @@ import type { MiBlocking } from '@/models/Blocking.js';
import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/_.js';
import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListMembershipsRepository } from '@/models/_.js';
import Logger from '@/logger.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
@ -38,8 +38,8 @@ export class UserBlockingService implements OnModuleInit {
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
private cacheService: CacheService,
private userEntityService: UserEntityService,
@ -149,7 +149,7 @@ export class UserBlockingService implements OnModuleInit {
});
for (const userList of userLists) {
await this.userListJoiningsRepository.delete({
await this.userListMembershipsRepository.delete({
userListId: userList.id,
userId: user.id,
});

View file

@ -123,7 +123,11 @@ export class UserFollowingService implements OnModuleInit {
// フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or
// フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである
// 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく
if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee))) {
if (
followee.isLocked ||
(followeeProfile.carefulBot && follower.isBot) ||
(this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee) && process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING !== 'true')
) {
let autoAccept = false;
// 鍵アカウントであっても、既にフォローされていた場合はスルー
@ -230,8 +234,7 @@ export class UserFollowingService implements OnModuleInit {
// 通知を作成
this.notificationService.createNotification(follower.id, 'followRequestAccepted', {
notifierId: followee.id,
});
}, followee.id);
}
if (alreadyFollowed) return;
@ -304,8 +307,7 @@ export class UserFollowingService implements OnModuleInit {
// 通知を作成
this.notificationService.createNotification(followee.id, 'follow', {
notifierId: follower.id,
});
}, follower.id);
}
}
@ -488,9 +490,8 @@ export class UserFollowingService implements OnModuleInit {
// 通知を作成
this.notificationService.createNotification(followee.id, 'receiveFollowRequest', {
notifierId: follower.id,
followRequestId: followRequest.id,
});
}, follower.id);
}
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {

View file

@ -3,11 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { UserListJoiningsRepository } from '@/models/_.js';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import * as Redis from 'ioredis';
import type { UserListMembershipsRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import type { MiUserList } from '@/models/UserList.js';
import type { MiUserListJoining } from '@/models/UserListJoining.js';
import type { MiUserListMembership } from '@/models/UserListMembership.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
@ -16,14 +17,24 @@ import { ProxyAccountService } from '@/core/ProxyAccountService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { QueueService } from '@/core/QueueService.js';
import { RedisKVCache } from '@/misc/cache.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
@Injectable()
export class UserListService {
export class UserListService implements OnApplicationShutdown {
public static TooManyUsersError = class extends Error {};
public membersCache: RedisKVCache<Set<string>>;
constructor(
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
private userEntityService: UserEntityService,
private idService: IdService,
@ -32,24 +43,63 @@ export class UserListService {
private proxyAccountService: ProxyAccountService,
private queueService: QueueService,
) {
this.membersCache = new RedisKVCache<Set<string>>(this.redisClient, 'userListMembers', {
lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60, // 1m
fetcher: (key) => this.userListMembershipsRepository.find({ where: { userListId: key }, select: ['userId'] }).then(xs => new Set(xs.map(x => x.userId))),
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
fromRedisConverter: (value) => new Set(JSON.parse(value)),
});
this.redisForSub.on('message', this.onMessage);
}
@bindThis
public async push(target: MiUser, list: MiUserList, me: MiUser) {
const currentCount = await this.userListJoiningsRepository.countBy({
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'userListMemberAdded': {
const { userListId, memberId } = body;
const members = await this.membersCache.get(userListId);
if (members) {
members.add(memberId);
}
break;
}
case 'userListMemberRemoved': {
const { userListId, memberId } = body;
const members = await this.membersCache.get(userListId);
if (members) {
members.delete(memberId);
}
break;
}
default:
break;
}
}
}
@bindThis
public async addMember(target: MiUser, list: MiUserList, me: MiUser) {
const currentCount = await this.userListMembershipsRepository.countBy({
userListId: list.id,
});
if (currentCount > (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) {
throw new UserListService.TooManyUsersError();
}
await this.userListJoiningsRepository.insert({
await this.userListMembershipsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
userId: target.id,
userListId: list.id,
} as MiUserListJoining);
} as MiUserListMembership);
this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id });
this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target));
// このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする
@ -60,4 +110,44 @@ export class UserListService {
}
}
}
@bindThis
public async removeMember(target: MiUser, list: MiUserList) {
await this.userListMembershipsRepository.delete({
userId: target.id,
userListId: list.id,
});
this.globalEventService.publishInternalEvent('userListMemberRemoved', { userListId: list.id, memberId: target.id });
this.globalEventService.publishUserListStream(list.id, 'userRemoved', await this.userEntityService.pack(target));
}
@bindThis
public async updateMembership(target: MiUser, list: MiUserList, options: { withReplies?: boolean }) {
const membership = await this.userListMembershipsRepository.findOneBy({
userId: target.id,
userListId: list.id,
});
if (membership == null) {
throw new Error('User is not a member of the list');
}
await this.userListMembershipsRepository.update({
id: membership.id,
}, {
withReplies: options.withReplies,
});
}
@bindThis
public dispose(): void {
this.redisForSub.off('message', this.onMessage);
this.membersCache.dispose();
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
}

View file

@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { FollowingsRepository, UsersRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
@Injectable()
export class UserService {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
) {
}
@bindThis
public async updateLastActiveDate(user: MiUser): Promise<void> {
if (user.isHibernated) {
const result = await this.usersRepository.createQueryBuilder().update()
.set({
lastActiveDate: new Date(),
})
.where('id = :id', { id: user.id })
.returning('*')
.execute()
.then((response) => {
return response.raw[0];
});
const wokeUp = result.isHibernated;
if (wokeUp) {
this.usersRepository.update(user.id, {
isHibernated: false,
});
this.followingsRepository.update({
followerId: user.id,
}, {
isFollowerHibernated: false,
});
}
} else {
this.usersRepository.update(user.id, {
lastActiveDate: new Date(),
});
}
}
}

View file

@ -9,7 +9,7 @@ import type { WebhooksRepository } from '@/models/_.js';
import type { MiWebhook } from '@/models/Webhook.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { StreamMessages } from '@/server/api/stream/types.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
@ -45,7 +45,7 @@ export class WebhookService implements OnApplicationShutdown {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message as StreamMessages['internal']['payload'];
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'webhookCreated':
if (body.active) {

View file

@ -102,13 +102,13 @@ export class NoteEntityService implements OnModuleInit {
} else if (meId === packedNote.userId) {
hide = false;
} else if (packedNote.reply && (meId === packedNote.reply.userId)) {
// 自分の投稿に対するリプライ
// 自分の投稿に対するリプライ
hide = false;
} else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) {
// 自分へのメンション
// 自分へのメンション
hide = false;
} else {
// フォロワーかどうか
// フォロワーかどうか
const isFollowing = await this.followingsRepository.exist({
where: {
followeeId: packedNote.userId,
@ -312,6 +312,7 @@ export class NoteEntityService implements OnModuleInit {
const packed: Packed<'Note'> = await awaitAll({
id: note.id,
createdAt: note.createdAt.toISOString(),
updatedAt: note.updatedAt ? note.updatedAt.toISOString() : undefined,
userId: note.userId,
user: this.userEntityService.pack(note.user ?? note.userId, me, {
detail: false,
@ -342,7 +343,6 @@ export class NoteEntityService implements OnModuleInit {
mentions: note.mentions && note.mentions.length > 0 ? note.mentions : undefined,
uri: note.uri ?? undefined,
url: note.url ?? undefined,
updatedAt: note.updatedAt != null ? note.updatedAt.toISOString() : undefined,
...(meId ? {
myReaction: this.populateMyReaction(note, meId, options?._hint_),
} : {}),

View file

@ -458,7 +458,8 @@ export class UserEntityService implements OnModuleInit {
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
mutedWords: profile!.mutedWords,
mutedInstances: profile!.mutedInstances,
mutingNotificationTypes: profile!.mutingNotificationTypes,
mutingNotificationTypes: [], // 後方互換性のため
notificationRecieveConfig: profile!.notificationRecieveConfig,
emailNotificationTypes: profile!.emailNotificationTypes,
achievements: profile!.achievements,
loggedInDays: profile!.loggedInDates.length,
@ -492,6 +493,7 @@ export class UserEntityService implements OnModuleInit {
isMuted: relation.isMuted,
isRenoteMuted: relation.isRenoteMuted,
notify: relation.following?.notify ?? 'none',
withReplies: relation.following?.withReplies ?? false,
} : {}),
} as Promiseable<Packed<'User'>> as Promiseable<IsMeAndIsUserDetailed<ExpectsMe, D>>;

View file

@ -5,11 +5,12 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { UserListJoiningsRepository, UserListsRepository } from '@/models/_.js';
import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js';
import type { MiUserList } from '@/models/UserList.js';
import { bindThis } from '@/decorators.js';
import { UserEntityService } from './UserEntityService.js';
@Injectable()
export class UserListEntityService {
@ -17,8 +18,10 @@ export class UserListEntityService {
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
private userEntityService: UserEntityService,
) {
}
@ -28,7 +31,7 @@ export class UserListEntityService {
): Promise<Packed<'UserList'>> {
const userList = typeof src === 'object' ? src : await this.userListsRepository.findOneByOrFail({ id: src });
const users = await this.userListJoiningsRepository.findBy({
const users = await this.userListMembershipsRepository.findBy({
userListId: userList.id,
});
@ -40,5 +43,18 @@ export class UserListEntityService {
isPublic: userList.isPublic,
};
}
@bindThis
public async packMembershipsMany(
memberships: MiUserListMembership[],
) {
return Promise.all(memberships.map(async x => ({
id: x.id,
createdAt: x.createdAt.toISOString(),
userId: x.userId,
user: await this.userEntityService.pack(x.userId),
withReplies: x.withReplies,
})));
}
}

View file

@ -10,6 +10,7 @@ export const DI = {
redis: Symbol('redis'),
redisForPub: Symbol('redisForPub'),
redisForSub: Symbol('redisForSub'),
redisForTimelines: Symbol('redisForTimelines'),
//#region Repositories
usersRepository: Symbol('usersRepository'),
@ -30,7 +31,7 @@ export const DI = {
userPublickeysRepository: Symbol('userPublickeysRepository'),
userListsRepository: Symbol('userListsRepository'),
userListFavoritesRepository: Symbol('userListFavoritesRepository'),
userListJoiningsRepository: Symbol('userListJoiningsRepository'),
userListMembershipsRepository: Symbol('userListMembershipsRepository'),
userNotePiningsRepository: Symbol('userNotePiningsRepository'),
userIpsRepository: Symbol('userIpsRepository'),
usedUsernamesRepository: Symbol('usedUsernamesRepository'),
@ -63,7 +64,6 @@ export const DI = {
promoNotesRepository: Symbol('promoNotesRepository'),
promoReadsRepository: Symbol('promoReadsRepository'),
relaysRepository: Symbol('relaysRepository'),
mutedNotesRepository: Symbol('mutedNotesRepository'),
channelsRepository: Symbol('channelsRepository'),
channelFollowingsRepository: Symbol('channelFollowingsRepository'),
channelFavoritesRepository: Symbol('channelFavoritesRepository'),

View file

@ -9,6 +9,7 @@ import { MiUser } from './User.js';
@Entity('following')
@Index(['followerId', 'followeeId'], { unique: true })
@Index(['followeeId', 'followerHost', 'isFollowerHibernated'])
export class MiFollowing {
@PrimaryColumn(id())
public id: string;
@ -45,6 +46,17 @@ export class MiFollowing {
@JoinColumn()
public follower: MiUser | null;
@Column('boolean', {
default: false,
})
public isFollowerHibernated: boolean;
// タイムラインにその人のリプライまで含めるかどうか
@Column('boolean', {
default: false,
})
public withReplies: boolean;
@Index()
@Column('varchar', {
length: 32,

View file

@ -1,53 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm';
import { mutedNoteReasons } from '@/types.js';
import { id } from './util/id.js';
import { MiNote } from './Note.js';
import { MiUser } from './User.js';
@Entity('muted_note')
@Index(['noteId', 'userId'], { unique: true })
export class MiMutedNote {
@PrimaryColumn(id())
public id: string;
@Index()
@Column({
...id(),
comment: 'The note ID.',
})
public noteId: MiNote['id'];
@ManyToOne(type => MiNote, {
onDelete: 'CASCADE',
})
@JoinColumn()
public note: MiNote | null;
@Index()
@Column({
...id(),
comment: 'The user ID.',
})
public userId: MiUser['id'];
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: MiUser | null;
/**
*
*/
@Index()
@Column('enum', {
enum: mutedNoteReasons,
comment: 'The reason of the MutedNote.',
})
public reason: typeof mutedNoteReasons[number];
}

View file

@ -24,6 +24,12 @@ export class MiNote {
})
public createdAt: Date;
@Column('timestamp with time zone', {
comment: 'The update time of the Note.',
default: null,
})
public updatedAt: Date | null;
@Index()
@Column({
...id(),
@ -239,12 +245,6 @@ export class MiNote {
comment: '[Denormalized]',
})
public renoteUserHost: string | null;
@Index()
@Column('timestamp with time zone', {
comment: 'The update time of the Note.',
})
public updatedAt: Date | null;
//#endregion
constructor(data: Partial<MiNote>) {

View file

@ -5,7 +5,7 @@
import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMutedNote, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListJoining, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, NoteEdit } from './_.js';
import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, NoteEdit } from './_.js';
import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common';
@ -117,9 +117,9 @@ const $userListFavoritesRepository: Provider = {
inject: [DI.db],
};
const $userListJoiningsRepository: Provider = {
provide: DI.userListJoiningsRepository,
useFactory: (db: DataSource) => db.getRepository(MiUserListJoining),
const $userListMembershipsRepository: Provider = {
provide: DI.userListMembershipsRepository,
useFactory: (db: DataSource) => db.getRepository(MiUserListMembership),
inject: [DI.db],
};
@ -315,12 +315,6 @@ const $relaysRepository: Provider = {
inject: [DI.db],
};
const $mutedNotesRepository: Provider = {
provide: DI.mutedNotesRepository,
useFactory: (db: DataSource) => db.getRepository(MiMutedNote),
inject: [DI.db],
};
const $channelsRepository: Provider = {
provide: DI.channelsRepository,
useFactory: (db: DataSource) => db.getRepository(MiChannel),
@ -427,7 +421,7 @@ const $noteEditRepository: Provider = {
$userPublickeysRepository,
$userListsRepository,
$userListFavoritesRepository,
$userListJoiningsRepository,
$userListMembershipsRepository,
$userNotePiningsRepository,
$userIpsRepository,
$usedUsernamesRepository,
@ -460,7 +454,6 @@ const $noteEditRepository: Provider = {
$promoNotesRepository,
$promoReadsRepository,
$relaysRepository,
$mutedNotesRepository,
$channelsRepository,
$channelFollowingsRepository,
$channelFavoritesRepository,
@ -495,7 +488,7 @@ const $noteEditRepository: Provider = {
$userPublickeysRepository,
$userListsRepository,
$userListFavoritesRepository,
$userListJoiningsRepository,
$userListMembershipsRepository,
$userNotePiningsRepository,
$userIpsRepository,
$usedUsernamesRepository,
@ -528,7 +521,6 @@ const $noteEditRepository: Provider = {
$promoNotesRepository,
$promoReadsRepository,
$relaysRepository,
$mutedNotesRepository,
$channelsRepository,
$channelFollowingsRepository,
$channelFavoritesRepository,

View file

@ -187,6 +187,11 @@ export class MiUser {
})
public isExplorable: boolean;
@Column('boolean', {
default: false,
})
public isHibernated: boolean;
// アカウントが削除されたかどうかのフラグだが、完全に削除される際は物理削除なので実質削除されるまでの「削除が進行しているかどうか」のフラグ
@Column('boolean', {
default: false,

View file

@ -8,14 +8,14 @@ import { id } from './util/id.js';
import { MiUser } from './User.js';
import { MiUserList } from './UserList.js';
@Entity('user_list_joining')
@Entity('user_list_membership')
@Index(['userId', 'userListId'], { unique: true })
export class MiUserListJoining {
export class MiUserListMembership {
@PrimaryColumn(id())
public id: string;
@Column('timestamp with time zone', {
comment: 'The created date of the UserListJoining.',
comment: 'The created date of the UserListMembership.',
})
public createdAt: Date;
@ -44,4 +44,10 @@ export class MiUserListJoining {
})
@JoinColumn()
public userList: MiUserList | null;
// タイムラインにその人のリプライまで含めるかどうか
@Column('boolean', {
default: false,
})
public withReplies: boolean;
}

View file

@ -8,6 +8,7 @@ import { obsoleteNotificationTypes, ffVisibility, notificationTypes } from '@/ty
import { id } from './util/id.js';
import { MiUser } from './User.js';
import { MiPage } from './Page.js';
import { MiUserList } from './UserList.js';
// TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも
// ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン
@ -229,16 +230,25 @@ export class MiUserProfile {
})
public mutedInstances: string[];
@Column('enum', {
enum: [
...notificationTypes,
// マイグレーションで削除が困難なので古いenumは残しておく
...obsoleteNotificationTypes,
],
array: true,
default: [],
@Column('jsonb', {
default: {},
})
public mutingNotificationTypes: typeof notificationTypes[number][];
public notificationRecieveConfig: {
[notificationType in typeof notificationTypes[number]]?: {
type: 'all';
} | {
type: 'never';
} | {
type: 'following';
} | {
type: 'follower';
} | {
type: 'mutualFollow';
} | {
type: 'list';
userListId: MiUserList['id'];
};
};
@Column('varchar', {
length: 32, array: true, default: '{}',

View file

@ -28,7 +28,6 @@ import { MiHashtag } from '@/models/Hashtag.js';
import { MiInstance } from '@/models/Instance.js';
import { MiMeta } from '@/models/Meta.js';
import { MiModerationLog } from '@/models/ModerationLog.js';
import { MiMutedNote } from '@/models/MutedNote.js';
import { MiMuting } from '@/models/Muting.js';
import { MiRenoteMuting } from '@/models/RenoteMuting.js';
import { MiNote } from '@/models/Note.js';
@ -53,7 +52,7 @@ import { MiUser } from '@/models/User.js';
import { MiUserIp } from '@/models/UserIp.js';
import { MiUserKeypair } from '@/models/UserKeypair.js';
import { MiUserList } from '@/models/UserList.js';
import { MiUserListJoining } from '@/models/UserListJoining.js';
import { MiUserListMembership } from '@/models/UserListMembership.js';
import { MiUserNotePining } from '@/models/UserNotePining.js';
import { MiUserPending } from '@/models/UserPending.js';
import { MiUserProfile } from '@/models/UserProfile.js';
@ -97,7 +96,6 @@ export {
MiInstance,
MiMeta,
MiModerationLog,
MiMutedNote,
MiMuting,
MiRenoteMuting,
MiNote,
@ -123,7 +121,7 @@ export {
MiUserKeypair,
MiUserList,
MiUserListFavorite,
MiUserListJoining,
MiUserListMembership,
MiUserNotePining,
MiUserPending,
MiUserProfile,
@ -165,7 +163,6 @@ export type HashtagsRepository = Repository<MiHashtag>;
export type InstancesRepository = Repository<MiInstance>;
export type MetasRepository = Repository<MiMeta>;
export type ModerationLogsRepository = Repository<MiModerationLog>;
export type MutedNotesRepository = Repository<MiMutedNote>;
export type MutingsRepository = Repository<MiMuting>;
export type RenoteMutingsRepository = Repository<MiRenoteMuting>;
export type NotesRepository = Repository<MiNote>;
@ -191,7 +188,7 @@ export type UserIpsRepository = Repository<MiUserIp>;
export type UserKeypairsRepository = Repository<MiUserKeypair>;
export type UserListsRepository = Repository<MiUserList>;
export type UserListFavoritesRepository = Repository<MiUserListFavorite>;
export type UserListJoiningsRepository = Repository<MiUserListJoining>;
export type UserListMembershipsRepository = Repository<MiUserListMembership>;
export type UserNotePiningsRepository = Repository<MiUserNotePining>;
export type UserPendingsRepository = Repository<MiUserPending>;
export type UserProfilesRepository = Repository<MiUserProfile>;

View file

@ -17,12 +17,12 @@ export const packedNoteSchema = {
optional: false, nullable: false,
format: 'date-time',
},
deletedAt: {
updatedAt: {
type: 'string',
optional: true, nullable: true,
format: 'date-time',
},
updatedAt: {
deletedAt: {
type: 'string',
optional: true, nullable: true,
format: 'date-time',
@ -147,7 +147,7 @@ export const packedNoteSchema = {
isSensitive: {
type: 'boolean',
optional: true, nullable: false,
}
},
},
},
},

View file

@ -283,6 +283,10 @@ export const packedUserDetailedNotMeOnlySchema = {
type: 'string',
nullable: false, optional: true,
},
withReplies: {
type: 'boolean',
nullable: false, optional: true,
},
//#endregion
},
} as const;
@ -393,13 +397,9 @@ export const packedMeDetailedOnlySchema = {
nullable: false, optional: false,
},
},
mutingNotificationTypes: {
type: 'array',
nullable: true, optional: false,
items: {
type: 'string',
nullable: false, optional: false,
},
notificationRecieveConfig: {
type: 'object',
nullable: false, optional: false,
},
emailNotificationTypes: {
type: 'array',

View file

@ -36,7 +36,6 @@ import { MiHashtag } from '@/models/Hashtag.js';
import { MiInstance } from '@/models/Instance.js';
import { MiMeta } from '@/models/Meta.js';
import { MiModerationLog } from '@/models/ModerationLog.js';
import { MiMutedNote } from '@/models/MutedNote.js';
import { MiMuting } from '@/models/Muting.js';
import { MiRenoteMuting } from '@/models/RenoteMuting.js';
import { MiNote } from '@/models/Note.js';
@ -62,7 +61,7 @@ import { MiUserIp } from '@/models/UserIp.js';
import { MiUserKeypair } from '@/models/UserKeypair.js';
import { MiUserList } from '@/models/UserList.js';
import { MiUserListFavorite } from '@/models/UserListFavorite.js';
import { MiUserListJoining } from '@/models/UserListJoining.js';
import { MiUserListMembership } from '@/models/UserListMembership.js';
import { MiUserNotePining } from '@/models/UserNotePining.js';
import { MiUserPending } from '@/models/UserPending.js';
import { MiUserProfile } from '@/models/UserProfile.js';
@ -139,7 +138,7 @@ export const entities = [
MiUserPublickey,
MiUserList,
MiUserListFavorite,
MiUserListJoining,
MiUserListMembership,
MiUserNotePining,
MiUserSecurityKey,
MiUsedUsername,
@ -175,7 +174,6 @@ export const entities = [
MiPromoNote,
MiPromoRead,
MiRelay,
MiMutedNote,
MiChannel,
MiChannelFollowing,
MiChannelFavorite,

View file

@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { In, LessThan } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { AntennasRepository, MutedNotesRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/_.js';
import type { AntennasRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
@ -25,9 +25,6 @@ export class CleanProcessorService {
@Inject(DI.userIpsRepository)
private userIpsRepository: UserIpsRepository,
@Inject(DI.mutedNotesRepository)
private mutedNotesRepository: MutedNotesRepository,
@Inject(DI.antennasRepository)
private antennasRepository: AntennasRepository,
@ -48,16 +45,6 @@ export class CleanProcessorService {
createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))),
});
this.mutedNotesRepository.delete({
id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))),
reason: 'word',
});
this.mutedNotesRepository.delete({
id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))),
reason: 'word',
});
// 使われてないアンテナを停止
if (this.config.deactivateAntennaThreshold > 0) {
this.antennasRepository.update({

View file

@ -8,7 +8,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { format as DateFormat } from 'date-fns';
import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { AntennasRepository, UsersRepository, UserListJoiningsRepository, MiUser } from '@/models/_.js';
import type { AntennasRepository, UsersRepository, UserListMembershipsRepository, MiUser } from '@/models/_.js';
import Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js';
import { bindThis } from '@/decorators.js';
@ -29,8 +29,8 @@ export class ExportAntennasProcessorService {
@Inject(DI.antennasRepository)
private antennsRepository: AntennasRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
private driveService: DriveService,
private utilityService: UtilityService,
@ -65,9 +65,9 @@ export class ExportAntennasProcessorService {
for (const [index, antenna] of antennas.entries()) {
let users: MiUser[] | undefined;
if (antenna.userListId !== null) {
const joinings = await this.userListJoiningsRepository.findBy({ userListId: antenna.userListId });
const memberships = await this.userListMembershipsRepository.findBy({ userListId: antenna.userListId });
users = await this.usersRepository.findBy({
id: In(joinings.map(j => j.userId)),
id: In(memberships.map(j => j.userId)),
});
}
write(JSON.stringify({

View file

@ -8,7 +8,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { format as dateFormat } from 'date-fns';
import { DI } from '@/di-symbols.js';
import type { UserListJoiningsRepository, UserListsRepository, UsersRepository } from '@/models/_.js';
import type { UserListMembershipsRepository, UserListsRepository, UsersRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js';
import { createTemp } from '@/misc/create-temp.js';
@ -29,8 +29,8 @@ export class ExportUserListsProcessorService {
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
private utilityService: UtilityService,
private driveService: DriveService,
@ -61,9 +61,9 @@ export class ExportUserListsProcessorService {
const stream = fs.createWriteStream(path, { flags: 'a' });
for (const list of lists) {
const joinings = await this.userListJoiningsRepository.findBy({ userListId: list.id });
const memberships = await this.userListMembershipsRepository.findBy({ userListId: list.id });
const users = await this.usersRepository.findBy({
id: In(joinings.map(j => j.userId)),
id: In(memberships.map(j => j.userId)),
});
for (const u of users) {

View file

@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { UsersRepository, DriveFilesRepository, UserListJoiningsRepository, UserListsRepository } from '@/models/_.js';
import type { UsersRepository, DriveFilesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
import * as Acct from '@/misc/acct.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
@ -33,8 +33,8 @@ export class ImportUserListsProcessorService {
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
private utilityService: UtilityService,
private idService: IdService,
@ -99,9 +99,9 @@ export class ImportUserListsProcessorService {
target = await this.remoteUserResolveService.resolveUser(username, host);
}
if (await this.userListJoiningsRepository.findOneBy({ userListId: list!.id, userId: target.id }) != null) continue;
if (await this.userListMembershipsRepository.findOneBy({ userListId: list!.id, userId: target.id }) != null) continue;
this.userListService.push(target, list!, user);
this.userListService.addMember(target, list!, user);
} catch (e) {
this.logger.warn(`Error in line:${linenum} ${e}`);
}

View file

@ -205,7 +205,6 @@ import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js';
import * as ep___i_favorites from './endpoints/i/favorites.js';
import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js';
import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js';
import * as ep___i_getWordMutedNotesCount from './endpoints/i/get-word-muted-notes-count.js';
import * as ep___i_importBlocking from './endpoints/i/import-blocking.js';
import * as ep___i_importFollowing from './endpoints/i/import-following.js';
import * as ep___i_importMuting from './endpoints/i/import-muting.js';
@ -337,7 +336,9 @@ import * as ep___users_lists_show from './endpoints/users/lists/show.js';
import * as ep___users_lists_update from './endpoints/users/lists/update.js';
import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js';
import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js';
import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js';
import * as ep___users_lists_createFromPublic from './endpoints/users/lists/create-from-public.js';
import * as ep___users_lists_updateMembership from './endpoints/users/lists/update-membership.js';
import * as ep___users_lists_getMemberships from './endpoints/users/lists/get-memberships.js';
import * as ep___users_notes from './endpoints/users/notes.js';
import * as ep___users_pages from './endpoints/users/pages.js';
import * as ep___users_flashs from './endpoints/users/flashs.js';
@ -555,7 +556,6 @@ const $i_exportAntennas: Provider = { provide: 'ep:i/export-antennas', useClass:
const $i_favorites: Provider = { provide: 'ep:i/favorites', useClass: ep___i_favorites.default };
const $i_gallery_likes: Provider = { provide: 'ep:i/gallery/likes', useClass: ep___i_gallery_likes.default };
const $i_gallery_posts: Provider = { provide: 'ep:i/gallery/posts', useClass: ep___i_gallery_posts.default };
const $i_getWordMutedNotesCount: Provider = { provide: 'ep:i/get-word-muted-notes-count', useClass: ep___i_getWordMutedNotesCount.default };
const $i_importBlocking: Provider = { provide: 'ep:i/import-blocking', useClass: ep___i_importBlocking.default };
const $i_importFollowing: Provider = { provide: 'ep:i/import-following', useClass: ep___i_importFollowing.default };
const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep___i_importMuting.default };
@ -687,7 +687,9 @@ const $users_lists_show: Provider = { provide: 'ep:users/lists/show', useClass:
const $users_lists_update: Provider = { provide: 'ep:users/lists/update', useClass: ep___users_lists_update.default };
const $users_lists_favorite: Provider = { provide: 'ep:users/lists/favorite', useClass: ep___users_lists_favorite.default };
const $users_lists_unfavorite: Provider = { provide: 'ep:users/lists/unfavorite', useClass: ep___users_lists_unfavorite.default };
const $users_lists_create_from_public: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_create_from_public.default };
const $users_lists_createFromPublic: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_createFromPublic.default };
const $users_lists_updateMembership: Provider = { provide: 'ep:users/lists/update-membership', useClass: ep___users_lists_updateMembership.default };
const $users_lists_getMemberships: Provider = { provide: 'ep:users/lists/get-memberships', useClass: ep___users_lists_getMemberships.default };
const $users_notes: Provider = { provide: 'ep:users/notes', useClass: ep___users_notes.default };
const $users_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default };
const $users_flashs: Provider = { provide: 'ep:users/flashs', useClass: ep___users_flashs.default };
@ -909,7 +911,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_favorites,
$i_gallery_likes,
$i_gallery_posts,
$i_getWordMutedNotesCount,
$i_importBlocking,
$i_importFollowing,
$i_importMuting,
@ -1041,7 +1042,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_lists_update,
$users_lists_favorite,
$users_lists_unfavorite,
$users_lists_create_from_public,
$users_lists_createFromPublic,
$users_lists_updateMembership,
$users_lists_getMemberships,
$users_notes,
$users_pages,
$users_flashs,
@ -1257,7 +1260,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_favorites,
$i_gallery_likes,
$i_gallery_posts,
$i_getWordMutedNotesCount,
$i_importBlocking,
$i_importFollowing,
$i_importMuting,
@ -1386,7 +1388,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_lists_update,
$users_lists_favorite,
$users_lists_unfavorite,
$users_lists_create_from_public,
$users_lists_createFromPublic,
$users_lists_updateMembership,
$users_lists_getMemberships,
$users_notes,
$users_pages,
$users_flashs,

View file

@ -14,6 +14,7 @@ import { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
import { MiLocalUser } from '@/models/User.js';
import { UserService } from '@/core/UserService.js';
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
import MainStreamConnection from './stream/Connection.js';
import { ChannelsService } from './stream/ChannelsService.js';
@ -37,6 +38,7 @@ export class StreamingApiServerService {
private authenticateService: AuthenticateService,
private channelsService: ChannelsService,
private notificationService: NotificationService,
private usersService: UserService,
) {
}
@ -130,14 +132,10 @@ export class StreamingApiServerService {
this.#connections.set(connection, Date.now());
const userUpdateIntervalId = user ? setInterval(() => {
this.usersRepository.update(user.id, {
lastActiveDate: new Date(),
});
this.usersService.updateLastActiveDate(user);
}, 1000 * 60 * 5) : null;
if (user) {
this.usersRepository.update(user.id, {
lastActiveDate: new Date(),
});
this.usersService.updateLastActiveDate(user);
}
connection.once('close', () => {

View file

@ -205,7 +205,6 @@ import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js';
import * as ep___i_favorites from './endpoints/i/favorites.js';
import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js';
import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js';
import * as ep___i_getWordMutedNotesCount from './endpoints/i/get-word-muted-notes-count.js';
import * as ep___i_importBlocking from './endpoints/i/import-blocking.js';
import * as ep___i_importFollowing from './endpoints/i/import-following.js';
import * as ep___i_importMuting from './endpoints/i/import-muting.js';
@ -336,8 +335,10 @@ import * as ep___users_lists_push from './endpoints/users/lists/push.js';
import * as ep___users_lists_show from './endpoints/users/lists/show.js';
import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js';
import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js';
import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js';
import * as ep___users_lists_createFromPublic from './endpoints/users/lists/create-from-public.js';
import * as ep___users_lists_update from './endpoints/users/lists/update.js';
import * as ep___users_lists_updateMembership from './endpoints/users/lists/update-membership.js';
import * as ep___users_lists_getMemberships from './endpoints/users/lists/get-memberships.js';
import * as ep___users_notes from './endpoints/users/notes.js';
import * as ep___users_pages from './endpoints/users/pages.js';
import * as ep___users_flashs from './endpoints/users/flashs.js';
@ -553,7 +554,6 @@ const eps = [
['i/favorites', ep___i_favorites],
['i/gallery/likes', ep___i_gallery_likes],
['i/gallery/posts', ep___i_gallery_posts],
['i/get-word-muted-notes-count', ep___i_getWordMutedNotesCount],
['i/import-blocking', ep___i_importBlocking],
['i/import-following', ep___i_importFollowing],
['i/import-muting', ep___i_importMuting],
@ -685,7 +685,9 @@ const eps = [
['users/lists/favorite', ep___users_lists_favorite],
['users/lists/unfavorite', ep___users_lists_unfavorite],
['users/lists/update', ep___users_lists_update],
['users/lists/create-from-public', ep___users_lists_create_from_public],
['users/lists/create-from-public', ep___users_lists_createFromPublic],
['users/lists/update-membership', ep___users_lists_updateMembership],
['users/lists/get-memberships', ep___users_lists_getMemberships],
['users/notes', ep___users_notes],
['users/pages', ep___users_pages],
['users/flashs', ep___users_flashs],

View file

@ -8,6 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import type { AdsRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
export const meta = {
tags: ['admin'],
@ -39,9 +40,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private adsRepository: AdsRepository,
private idService: IdService,
private moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
await this.adsRepository.insert({
const ad = await this.adsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
expiresAt: new Date(ps.expiresAt),
@ -53,7 +55,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
ratio: ps.ratio,
place: ps.place,
memo: ps.memo,
}).then(r => this.adsRepository.findOneByOrFail({ id: r.identifiers[0].id }));
this.moderationLogService.log(me, 'createAd', {
adId: ad.id,
ad: ad,
});
return ad;
});
}
}

View file

@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { AdsRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { ApiError } from '../../../error.js';
export const meta = {
@ -37,6 +38,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor(
@Inject(DI.adsRepository)
private adsRepository: AdsRepository,
private moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
const ad = await this.adsRepository.findOneBy({ id: ps.id });
@ -44,6 +47,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ad == null) throw new ApiError(meta.errors.noSuchAd);
await this.adsRepository.delete(ad.id);
this.moderationLogService.log(me, 'deleteAd', {
adId: ad.id,
ad: ad,
});
});
}
}

View file

@ -22,6 +22,7 @@ export const paramDef = {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
publishing: { type: 'boolean', default: false },
},
required: [],
} as const;
@ -36,6 +37,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.adsRepository.createQueryBuilder('ad'), ps.sinceId, ps.untilId);
if (ps.publishing) {
query.andWhere('ad.expiresAt > :now', { now: new Date() }).andWhere('ad.startsAt <= :now', { now: new Date() });
}
const ads = await query.limit(ps.limit).getMany();
return ads;

View file

@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { AdsRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { ApiError } from '../../../error.js';
export const meta = {
@ -46,6 +47,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor(
@Inject(DI.adsRepository)
private adsRepository: AdsRepository,
private moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
const ad = await this.adsRepository.findOneBy({ id: ps.id });
@ -63,6 +66,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
startsAt: new Date(ps.startsAt),
dayOfWeek: ps.dayOfWeek,
});
const updatedAd = await this.adsRepository.findOneByOrFail({ id: ad.id });
this.moderationLogService.log(me, 'updateAd', {
adId: ad.id,
before: ad,
after: updatedAd,
});
});
}
}

View file

@ -81,7 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
receiveAnnouncementEmail: profile.receiveAnnouncementEmail,
mutedWords: profile.mutedWords,
mutedInstances: profile.mutedInstances,
mutingNotificationTypes: profile.mutingNotificationTypes,
notificationRecieveConfig: profile.notificationRecieveConfig,
isModerator: isModerator,
isSilenced: isSilenced,
isSuspended: user.isSuspended,

View file

@ -56,8 +56,8 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@ -86,7 +86,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
});
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
const noteIdsRes = await this.redisClient.xrevrange(
const noteIdsRes = await this.redisForTimelines.xrevrange(
`antennaTimeline:${antenna.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',

View file

@ -54,8 +54,8 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@ -83,7 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
let noteIdsRes: [string, string[]][] = [];
if (!ps.sinceId && !ps.sinceDate) {
noteIdsRes = await this.redisClient.xrevrange(
noteIdsRes = await this.redisForTimelines.xrevrange(
`channelTimeline:${channel.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-',
@ -104,7 +104,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me) {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
}
//#endregion
@ -129,7 +128,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me) {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
}
//#endregion

View file

@ -57,8 +57,9 @@ export const paramDef = {
properties: {
userId: { type: 'string', format: 'misskey:id' },
notify: { type: 'string', enum: ['normal', 'none'] },
withReplies: { type: 'boolean' },
},
required: ['userId', 'notify'],
required: ['userId'],
} as const;
@Injectable()
@ -98,7 +99,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
await this.followingsRepository.update({
id: exist.id,
}, {
notify: ps.notify === 'none' ? null : ps.notify,
notify: ps.notify != null ? (ps.notify === 'none' ? null : ps.notify) : undefined,
withReplies: ps.withReplies != null ? ps.withReplies : undefined,
});
return await this.userEntityService.pack(follower.id, me);

View file

@ -1,51 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { MutedNotesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
export const meta = {
tags: ['account'],
requireCredential: true,
kind: 'read:account',
res: {
type: 'object',
optional: false, nullable: false,
properties: {
count: {
type: 'number',
optional: false, nullable: false,
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.mutedNotesRepository)
private mutedNotesRepository: MutedNotesRepository,
) {
super(meta, paramDef, async (ps, me) => {
return {
count: await this.mutedNotesRepository.countBy({
userId: me.id,
reason: 'word',
}),
};
});
}
}

View file

@ -166,9 +166,7 @@ export const paramDef = {
mutedInstances: { type: 'array', items: {
type: 'string',
} },
mutingNotificationTypes: { type: 'array', items: {
type: 'string', enum: notificationTypes,
} },
notificationRecieveConfig: { type: 'object' },
emailNotificationTypes: { type: 'array', items: {
type: 'string',
} },
@ -250,7 +248,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
profileUpdates.enableWordMute = ps.mutedWords.length > 0;
}
if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances;
if (ps.mutingNotificationTypes !== undefined) profileUpdates.mutingNotificationTypes = ps.mutingNotificationTypes as typeof notificationTypes[number][];
if (ps.notificationRecieveConfig !== undefined) profileUpdates.notificationRecieveConfig = ps.notificationRecieveConfig;
if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked;
if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable;
if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus;

View file

@ -34,10 +34,11 @@ describe('api:notes/create', () => {
.toBe(VALID);
});
test('null post', () => {
expect(v({ text: null }))
.toBe(INVALID);
});
// TODO
//test('null post', () => {
// expect(v({ text: null }))
// .toBe(INVALID);
//});
test('0 characters post', () => {
expect(v({ text: '' }))

View file

@ -118,7 +118,7 @@ export const paramDef = {
type: 'string',
minLength: 1,
maxLength: MAX_NOTE_TEXT_LENGTH,
nullable: false,
nullable: true,
},
fileIds: {
type: 'array',

View file

@ -40,7 +40,6 @@ export const paramDef = {
type: 'object',
properties: {
withFiles: { type: 'boolean', default: false },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
@ -68,49 +67,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.gtlDisabled);
}
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.visibility = \'public\'')
.andWhere('note.channelId IS NULL')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateRepliesQuery(query, ps.withReplies, me);
if (me) {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();
process.nextTick(() => {
if (me) {
this.activeUsersChart.read(me);
}
});
return await this.noteEntityService.packMany(timeline, me);
// TODO?
return [];
});
}
}

View file

@ -5,14 +5,16 @@
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository, FollowingsRepository } from '@/models/_.js';
import * as Redis from 'ioredis';
import type { NotesRepository, FollowingsRepository, MiNote } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { CacheService } from '@/core/CacheService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -51,7 +53,6 @@ export const paramDef = {
includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true },
},
required: [],
@ -60,17 +61,17 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private roleService: RoleService,
private activeUsersChart: ActiveUsersChart,
private idService: IdService,
private cacheService: CacheService,
) {
super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me.id);
@ -78,79 +79,75 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.stlDisabled);
}
//#region Construct query
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :followerId', { followerId: me.id });
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]);
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
.andWhere(new Brackets(qb => {
qb.where(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id })
.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
}))
let timeline: MiNote[] = [];
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
let htlNoteIdsRes: [string, string[]][] = [];
let ltlNoteIdsRes: [string, string[]][] = [];
if (!ps.sinceId && !ps.sinceDate) {
htlNoteIdsRes = await this.redisForTimelines.xrevrange(
ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-',
'COUNT', limit);
ltlNoteIdsRes = await this.redisForTimelines.xrevrange(
ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline',
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-',
'COUNT', limit);
}
const htlNoteIds = htlNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
const ltlNoteIds = ltlNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
let noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
noteIds.sort((a, b) => a > b ? -1 : 1);
noteIds = noteIds.slice(0, ps.limit);
if (noteIds.length === 0) {
return [];
}
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.setParameters(followingQuery.getParameters());
.leftJoinAndSelect('note.channel', 'channel');
this.queryService.generateChannelQuery(query, me);
this.queryService.generateRepliesQuery(query, ps.withReplies, me);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
timeline = await query.getMany();
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
timeline = timeline.filter(note => {
if (note.userId === me.id) {
return true;
}
if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
if (ps.includeRenotedMyNotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
return true;
});
if (ps.includeLocalRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NOT NULL');
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
// TODO: フィルタした結果件数が足りなかった場合の対応
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
process.nextTick(() => {
this.activeUsersChart.read(me);

View file

@ -5,14 +5,16 @@
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/_.js';
import * as Redis from 'ioredis';
import type { MiNote, NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -41,11 +43,7 @@ export const paramDef = {
type: 'object',
properties: {
withFiles: { type: 'boolean', default: false },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true },
fileType: { type: 'array', items: {
type: 'string',
} },
excludeNsfw: { type: 'boolean', default: false },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
@ -59,14 +57,17 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private roleService: RoleService,
private activeUsersChart: ActiveUsersChart,
private idService: IdService,
private cacheService: CacheService,
) {
super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me ? me.id : null);
@ -74,56 +75,63 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.ltlDisabled);
}
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)')
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>(), new Set<string>()];
let timeline: MiNote[] = [];
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
let noteIdsRes: [string, string[]][] = [];
if (!ps.sinceId && !ps.sinceDate) {
noteIdsRes = await this.redisForTimelines.xrevrange(
ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline',
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-',
'COUNT', limit);
}
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
if (noteIds.length === 0) {
return [];
}
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
this.queryService.generateChannelQuery(query, me);
this.queryService.generateRepliesQuery(query, ps.withReplies, me);
this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQuery(query, me);
if (me) this.queryService.generateMutedNoteQuery(query, me);
if (me) this.queryService.generateBlockedUserQuery(query, me);
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
timeline = await query.getMany();
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
if (ps.fileType != null) {
query.andWhere('note.fileIds != \'{}\'');
query.andWhere(new Brackets(qb => {
for (const type of ps.fileType!) {
const i = ps.fileType!.indexOf(type);
qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type });
}
}));
if (ps.excludeNsfw) {
query.andWhere('note.cw IS NULL');
query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)');
timeline = timeline.filter(note => {
if (me && (note.userId === me.id)) {
return true;
}
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
//#endregion
return true;
});
const timeline = await query.limit(ps.limit).getMany();
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
process.nextTick(() => {
if (me) {

View file

@ -5,13 +5,16 @@
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository, FollowingsRepository } from '@/models/_.js';
import * as Redis from 'ioredis';
import type { NotesRepository, FollowingsRepository, MiNote } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
export const meta = {
tags: ['notes'],
@ -41,7 +44,6 @@ export const paramDef = {
includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true },
},
required: [],
@ -50,96 +52,82 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private activeUsersChart: ActiveUsersChart,
private idService: IdService,
private cacheService: CacheService,
) {
super(meta, paramDef, async (ps, me) => {
const followees = await this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :followerId', { followerId: me.id })
.getMany();
const [
followings,
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(me.id),
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]);
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
// パフォーマンス上の利点が無さそう?
//.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
let timeline: MiNote[] = [];
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
let noteIdsRes: [string, string[]][] = [];
if (!ps.sinceId && !ps.sinceDate) {
noteIdsRes = await this.redisForTimelines.xrevrange(
ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-',
'COUNT', limit);
}
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
if (noteIds.length === 0) {
return [];
}
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
if (followees.length > 0) {
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
timeline = await query.getMany();
query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
} else {
query.andWhere('note.userId = :meId', { meId: me.id });
}
timeline = timeline.filter(note => {
if (note.userId === me.id) {
return true;
}
if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
if (note.reply && note.reply.visibility === 'followers') {
if (!Object.hasOwn(followings, note.reply.userId)) return false;
}
this.queryService.generateChannelQuery(query, me);
this.queryService.generateRepliesQuery(query, ps.withReplies, me);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
return true;
});
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
// TODO: フィルタした結果件数が足りなかった場合の対応
if (ps.includeRenotedMyNotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeLocalRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NOT NULL');
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
process.nextTick(() => {
this.activeUsersChart.read(me);

View file

@ -10,12 +10,13 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { MetaService } from '@/core/MetaService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { GetterService } from '@/server/api/GetterService.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['notes'],
requireCredential: false,
requireCredential: true,
res: {
type: 'object',
@ -23,6 +24,11 @@ export const meta = {
},
errors: {
unavailable: {
message: 'Translate of notes unavailable.',
code: 'UNAVAILABLE',
id: '50a70314-2d8a-431b-b433-efa5cc56444c',
},
noSuchNote: {
message: 'No such note.',
code: 'NO_SUCH_NOTE',
@ -47,14 +53,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private getterService: GetterService,
private metaService: MetaService,
private httpRequestService: HttpRequestService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me.id);
if (!policies.canUseTranslator) {
throw new ApiError(meta.errors.unavailable);
}
const note = await this.getterService.getNote(ps.noteId).catch(err => {
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
throw err;
});
if (!(await this.noteEntityService.isVisibleForMe(note, me ? me.id : null))) {
if (!(await this.noteEntityService.isVisibleForMe(note, me.id))) {
return 204; // TODO: 良い感じのエラー返す
}

View file

@ -5,12 +5,16 @@
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/_.js';
import * as Redis from 'ioredis';
import type { NotesRepository, UserListsRepository, UserListMembershipsRepository, MiNote } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -63,18 +67,19 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private activeUsersChart: ActiveUsersChart,
private cacheService: CacheService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const list = await this.userListsRepository.findOneBy({
@ -86,72 +91,65 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchList);
}
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.innerJoin(this.userListJoiningsRepository.metadata.targetName, 'userListJoining', 'userListJoining.userId = note.userId')
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]);
let timeline: MiNote[] = [];
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
let noteIdsRes: [string, string[]][] = [];
if (!ps.sinceId && !ps.sinceDate) {
noteIdsRes = await this.redisForTimelines.xrevrange(
ps.withFiles ? `userListTimelineWithFiles:${list.id}` : `userListTimeline:${list.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-',
'COUNT', limit);
}
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
if (noteIds.length === 0) {
return [];
}
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.andWhere('userListJoining.userListId = :userListId', { userListId: list.id });
.leftJoinAndSelect('note.channel', 'channel');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
timeline = await query.getMany();
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
timeline = timeline.filter(note => {
if (note.userId === me.id) {
return true;
}
if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
if (ps.includeRenotedMyNotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
return true;
});
if (ps.includeLocalRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NOT NULL');
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
// TODO: フィルタした結果件数が足りなかった場合の対応
if (!ps.withReplies) {
query.andWhere('note.replyId IS NULL');
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
this.activeUsersChart.read(me);

View file

@ -53,8 +53,8 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@ -79,7 +79,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return [];
}
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
const noteIdsRes = await this.redisClient.xrevrange(
const noteIdsRes = await this.redisForTimelines.xrevrange(
`roleTimeline:${role.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',

View file

@ -4,7 +4,7 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import type { UserListsRepository, UserListJoiningsRepository, BlockingsRepository } from '@/models/_.js';
import type { UserListsRepository, UserListMembershipsRepository, BlockingsRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import type { MiUserList } from '@/models/UserList.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
@ -76,8 +76,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@ -110,7 +110,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
name: ps.name,
} as MiUserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0]));
const users = (await this.userListJoiningsRepository.findBy({
const users = (await this.userListMembershipsRepository.findBy({
userListId: ps.listId,
})).map(x => x.userId);
@ -132,7 +132,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
}
const exist = await this.userListJoiningsRepository.exist({
const exist = await this.userListMembershipsRepository.exist({
where: {
userListId: userList.id,
userId: currentUser.id,
@ -144,7 +144,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
try {
await this.userListService.push(currentUser, userList, me);
await this.userListService.addMember(currentUser, userList, me);
} catch (err) {
if (err instanceof UserListService.TooManyUsersError) {
throw new ApiError(meta.errors.tooManyUsers);

View file

@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { UserListsRepository, UserListFavoritesRepository, UserListMembershipsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
import { DI } from '@/di-symbols.js';
import { QueryService } from '@/core/QueryService.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['lists', 'account'],
requireCredential: false,
kind: 'read:account',
errors: {
noSuchList: {
message: 'No such list.',
code: 'NO_SUCH_LIST',
id: '7bc05c21-1d7a-41ae-88f1-66820f4dc686',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
listId: { type: 'string', format: 'misskey:id' },
forPublic: { type: 'boolean', default: false },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
},
required: ['listId'],
} as const;
@Injectable() // eslint-disable-next-line import/no-default-export
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
private userListEntityService: UserListEntityService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
// Fetch the list
const userList = await this.userListsRepository.findOneBy(!ps.forPublic && me !== null ? {
id: ps.listId,
userId: me.id,
} : {
id: ps.listId,
isPublic: true,
});
if (userList == null) {
throw new ApiError(meta.errors.noSuchList);
}
const query = this.queryService.makePaginationQuery(this.userListMembershipsRepository.createQueryBuilder('membership'), ps.sinceId, ps.untilId)
.andWhere('membership.userListId = :userListId', { userListId: userList.id })
.innerJoinAndSelect('membership.user', 'user');
const memberships = await query
.limit(ps.limit)
.getMany();
return this.userListEntityService.packMembershipsMany(memberships);
});
}
}

View file

@ -4,12 +4,11 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import type { UserListsRepository, UserListJoiningsRepository } from '@/models/_.js';
import type { UserListsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { GetterService } from '@/server/api/GetterService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { UserListService } from '@/core/UserListService.js';
import { ApiError } from '../../../error.js';
export const meta = {
@ -53,12 +52,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
private userEntityService: UserEntityService,
private userListService: UserListService,
private getterService: GetterService,
private globalEventService: GlobalEventService,
) {
super(meta, paramDef, async (ps, me) => {
// Fetch the list
@ -77,10 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw err;
});
// Pull the user
await this.userListJoiningsRepository.delete({ userListId: userList.id, userId: user.id });
this.globalEventService.publishUserListStream(userList.id, 'userRemoved', await this.userEntityService.pack(user));
await this.userListService.removeMember(user, userList);
});
}
}

View file

@ -5,7 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import type { UserListsRepository, UserListJoiningsRepository, BlockingsRepository } from '@/models/_.js';
import type { UserListsRepository, UserListMembershipsRepository, BlockingsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { GetterService } from '@/server/api/GetterService.js';
import { UserListService } from '@/core/UserListService.js';
@ -76,8 +76,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@ -115,7 +115,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
}
const exist = await this.userListJoiningsRepository.exist({
const exist = await this.userListMembershipsRepository.exist({
where: {
userListId: userList.id,
userId: user.id,
@ -127,7 +127,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
try {
await this.userListService.push(user, userList, me);
await this.userListService.addMember(user, userList, me);
} catch (err) {
if (err instanceof UserListService.TooManyUsersError) {
throw new ApiError(meta.errors.tooManyUsers);

View file

@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { UserListsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { GetterService } from '@/server/api/GetterService.js';
import { DI } from '@/di-symbols.js';
import { UserListService } from '@/core/UserListService.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['lists', 'users'],
requireCredential: true,
prohibitMoved: true,
kind: 'write:account',
errors: {
noSuchList: {
message: 'No such list.',
code: 'NO_SUCH_LIST',
id: '7f44670e-ab16-43b8-b4c1-ccd2ee89cc02',
},
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: '588e7f72-c744-4a61-b180-d354e912bda2',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
listId: { type: 'string', format: 'misskey:id' },
userId: { type: 'string', format: 'misskey:id' },
withReplies: { type: 'boolean' },
},
required: ['listId', 'userId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
private userListService: UserListService,
private getterService: GetterService,
) {
super(meta, paramDef, async (ps, me) => {
// Fetch the list
const userList = await this.userListsRepository.findOneBy({
id: ps.listId,
userId: me.id,
});
if (userList == null) {
throw new ApiError(meta.errors.noSuchList);
}
// Fetch the user
const user = await this.getterService.getUser(ps.userId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
});
await this.userListService.updateMembership(user, userList, {
withReplies: ps.withReplies,
});
});
}
}

View file

@ -5,12 +5,14 @@
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/_.js';
import * as Redis from 'ioredis';
import type { MiNote, NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js';
import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -50,9 +52,6 @@ export const paramDef = {
untilDate: { type: 'integer' },
includeMyRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false },
fileType: { type: 'array', items: {
type: 'string',
} },
excludeNsfw: { type: 'boolean', default: false },
},
required: ['userId'],
@ -61,64 +60,52 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private getterService: GetterService,
private cacheService: CacheService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
// Lookup user
const user = await this.getterService.getUser(ps.userId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
});
let timeline: MiNote[] = [];
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.userId = :userId', { userId: user.id })
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
let noteIdsRes: [string, string[]][] = [];
if (!ps.sinceId && !ps.sinceDate) {
noteIdsRes = await this.redisForTimelines.xrevrange(
ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : ps.withReplies ? `userTimelineWithReplies:${ps.userId}` : `userTimeline:${ps.userId}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-',
'COUNT', limit);
}
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
if (noteIds.length === 0) {
return [];
}
const isFollowing = me ? Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId) : false;
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('note.channel', 'channel')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
query.andWhere(new Brackets(qb => {
qb.orWhere('note.channelId IS NULL');
qb.orWhere('channel.isSensitive = false');
}));
this.queryService.generateVisibilityQuery(query, me);
if (me) {
this.queryService.generateMutedUserQuery(query, me, user);
this.queryService.generateBlockedUserQuery(query, me);
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
if (ps.fileType != null) {
query.andWhere('note.fileIds != \'{}\'');
query.andWhere(new Brackets(qb => {
for (const type of ps.fileType!) {
const i = ps.fileType!.indexOf(type);
qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type });
}
}));
if (ps.excludeNsfw) {
query.andWhere('note.cw IS NULL');
query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)');
}
}
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
if (!ps.withReplies) {
query.andWhere('note.replyId IS NULL');
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
@ -129,19 +116,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}));
}
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :userId', { userId: user.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
timeline = await query.getMany();
//#endregion
timeline = timeline.filter(note => {
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (ps.withRenotes === false) return false;
}
}
const timeline = await query.limit(ps.limit).getMany();
if (note.visibility === 'followers' && !isFollowing) return false;
return true;
});
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
return await this.noteEntityService.packMany(timeline, me);
});

View file

@ -11,11 +11,11 @@ import type { NoteReadService } from '@/core/NoteReadService.js';
import type { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
import { MiUserProfile } from '@/models/_.js';
import { MiFollowing, MiUserProfile } from '@/models/_.js';
import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js';
import type { ChannelsService } from './ChannelsService.js';
import type { EventEmitter } from 'events';
import type Channel from './channel.js';
import type { StreamEventEmitter, StreamMessages } from './types.js';
/**
* Main stream connection
@ -30,7 +30,7 @@ export default class Connection {
private subscribingNotes: any = {};
private cachedNotes: Packed<'Note'>[] = [];
public userProfile: MiUserProfile | null = null;
public following: Set<string> = new Set();
public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
public followingChannels: Set<string> = new Set();
public userIdsWhoMeMuting: Set<string> = new Set();
public userIdsWhoBlockingMe: Set<string> = new Set();
@ -122,7 +122,7 @@ export default class Connection {
}
@bindThis
private onBroadcastMessage(data: StreamMessages['broadcast']['payload']) {
private onBroadcastMessage(data: GlobalEvents['broadcast']['payload']) {
this.sendMessageToWs(data.type, data.body);
}
@ -196,7 +196,7 @@ export default class Connection {
}
@bindThis
private async onNoteStreamMessage(data: StreamMessages['note']['payload']) {
private async onNoteStreamMessage(data: GlobalEvents['note']['payload']) {
this.sendMessageToWs('noteUpdated', {
id: data.body.id,
type: data.type,

View file

@ -7,8 +7,8 @@ import { Injectable } from '@nestjs/common';
import { isUserRelated } from '@/misc/is-user-related.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import Channel from '../channel.js';
import type { StreamMessages } from '../types.js';
class AntennaChannel extends Channel {
public readonly chName = 'antenna';
@ -35,7 +35,7 @@ class AntennaChannel extends Channel {
}
@bindThis
private async onEvent(data: StreamMessages['antenna']['payload']) {
private async onEvent(data: GlobalEvents['antenna']['payload']) {
if (data.type === 'note') {
const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true });

View file

@ -18,7 +18,6 @@ class GlobalTimelineChannel extends Channel {
public readonly chName = 'globalTimeline';
public static shouldShare = true;
public static requireCredential = false;
private withReplies: boolean;
private withRenotes: boolean;
constructor(
@ -38,7 +37,6 @@ class GlobalTimelineChannel extends Channel {
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
if (!policies.gtlAvailable) return;
this.withReplies = params.withReplies ?? false;
this.withRenotes = params.withRenotes ?? true;
// Subscribe events
@ -64,7 +62,7 @@ class GlobalTimelineChannel extends Channel {
}
// 関係ない返信は除外
if (note.reply && !this.withReplies) {
if (note.reply && !this.following[note.userId]?.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
@ -82,13 +80,6 @@ class GlobalTimelineChannel extends Channel {
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる
if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return;
this.connection.cacheNote(note);
this.send('note', note);

View file

@ -16,7 +16,6 @@ class HomeTimelineChannel extends Channel {
public readonly chName = 'homeTimeline';
public static shouldShare = true;
public static requireCredential = true;
private withReplies: boolean;
private withRenotes: boolean;
constructor(
@ -31,7 +30,6 @@ class HomeTimelineChannel extends Channel {
@bindThis
public async init(params: any) {
this.withReplies = params.withReplies ?? false;
this.withRenotes = params.withRenotes ?? true;
this.subscriber.on('notesStream', this.onNote);
@ -43,7 +41,7 @@ class HomeTimelineChannel extends Channel {
if (!this.followingChannels.has(note.channelId)) return;
} else {
// その投稿のユーザーをフォローしていなかったら弾く
if ((this.user!.id !== note.userId) && !this.following.has(note.userId)) return;
if ((this.user!.id !== note.userId) && !Object.hasOwn(this.following, note.userId)) return;
}
// Ignore notes from instances the user has muted
@ -73,7 +71,7 @@ class HomeTimelineChannel extends Channel {
}
// 関係ない返信は除外
if (note.reply && !this.withReplies) {
if (note.reply && !this.following[note.userId]?.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
@ -88,13 +86,6 @@ class HomeTimelineChannel extends Channel {
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる
if (await checkWordMute(note, this.user, this.userProfile!.mutedWords)) return;
this.connection.cacheNote(note);
this.send('note', note);

View file

@ -18,7 +18,6 @@ class HybridTimelineChannel extends Channel {
public readonly chName = 'hybridTimeline';
public static shouldShare = true;
public static requireCredential = true;
private withReplies: boolean;
private withRenotes: boolean;
constructor(
@ -38,7 +37,6 @@ class HybridTimelineChannel extends Channel {
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
if (!policies.ltlAvailable) return;
this.withReplies = params.withReplies ?? false;
this.withRenotes = params.withRenotes ?? true;
// Subscribe events
@ -53,7 +51,7 @@ class HybridTimelineChannel extends Channel {
// フォローしているチャンネルの投稿 の場合だけ
if (!(
(note.channelId == null && this.user!.id === note.userId) ||
(note.channelId == null && this.following.has(note.userId)) ||
(note.channelId == null && Object.hasOwn(this.following, note.userId)) ||
(note.channelId == null && (note.user.host == null && note.visibility === 'public')) ||
(note.channelId != null && this.followingChannels.has(note.channelId))
)) return;
@ -85,7 +83,7 @@ class HybridTimelineChannel extends Channel {
if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances ?? []))) return;
// 関係ない返信は除外
if (note.reply && !this.withReplies) {
if (note.reply && !this.following[note.userId]?.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
@ -100,13 +98,6 @@ class HybridTimelineChannel extends Channel {
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる
if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return;
this.connection.cacheNote(note);
this.send('note', note);

View file

@ -17,7 +17,6 @@ class LocalTimelineChannel extends Channel {
public readonly chName = 'localTimeline';
public static shouldShare = true;
public static requireCredential = false;
private withReplies: boolean;
private withRenotes: boolean;
constructor(
@ -37,7 +36,6 @@ class LocalTimelineChannel extends Channel {
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
if (!policies.ltlAvailable) return;
this.withReplies = params.withReplies ?? false;
this.withRenotes = params.withRenotes ?? true;
// Subscribe events
@ -64,7 +62,7 @@ class LocalTimelineChannel extends Channel {
}
// 関係ない返信は除外
if (note.reply && this.user && !this.withReplies) {
if (note.reply && this.user && !this.following[note.userId]?.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;
@ -79,13 +77,6 @@ class LocalTimelineChannel extends Channel {
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる
if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return;
this.connection.cacheNote(note);
this.send('note', note);

View file

@ -9,8 +9,8 @@ import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import Channel from '../channel.js';
import { StreamMessages } from '../types.js';
class RoleTimelineChannel extends Channel {
public readonly chName = 'roleTimeline';
@ -37,7 +37,7 @@ class RoleTimelineChannel extends Channel {
}
@bindThis
private async onEvent(data: StreamMessages['roleTimeline']['payload']) {
private async onEvent(data: GlobalEvents['roleTimeline']['payload']) {
if (data.type === 'note') {
const note = data.body;

View file

@ -4,7 +4,7 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import type { UserListJoiningsRepository, UserListsRepository } from '@/models/_.js';
import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import type { Packed } from '@/misc/json-schema.js';
@ -18,12 +18,12 @@ class UserListChannel extends Channel {
public static shouldShare = false;
public static requireCredential = false;
private listId: string;
public listUsers: MiUser['id'][] = [];
public membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {};
private listUsersClock: NodeJS.Timeout;
constructor(
private userListsRepository: UserListsRepository,
private userListJoiningsRepository: UserListJoiningsRepository,
private userListMembershipsRepository: UserListMembershipsRepository,
private noteEntityService: NoteEntityService,
id: string,
@ -58,19 +58,25 @@ class UserListChannel extends Channel {
@bindThis
private async updateListUsers() {
const users = await this.userListJoiningsRepository.find({
const memberships = await this.userListMembershipsRepository.find({
where: {
userListId: this.listId,
},
select: ['userId'],
});
this.listUsers = users.map(x => x.userId);
const membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {};
for (const membership of memberships) {
membershipsMap[membership.userId] = {
withReplies: membership.withReplies,
};
}
this.membershipsMap = membershipsMap;
}
@bindThis
private async onNote(note: Packed<'Note'>) {
if (!this.listUsers.includes(note.userId)) return;
if (!Object.hasOwn(this.membershipsMap, note.userId)) return;
if (['followers', 'specified'].includes(note.visibility)) {
note = await this.noteEntityService.pack(note.id, this.user, {
@ -95,6 +101,13 @@ class UserListChannel extends Channel {
}
}
// 関係ない返信は除外
if (note.reply && !this.membershipsMap[note.userId]?.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.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
@ -124,8 +137,8 @@ export class UserListChannelService {
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
private noteEntityService: NoteEntityService,
) {
@ -135,7 +148,7 @@ export class UserListChannelService {
public create(id: string, connection: Channel['connection']): UserListChannel {
return new UserListChannel(
this.userListsRepository,
this.userListJoiningsRepository,
this.userListMembershipsRepository,
this.noteEntityService,
id,
connection,

View file

@ -1,258 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { MiChannel } from '@/models/Channel.js';
import type { MiUser } from '@/models/User.js';
import type { MiUserProfile } from '@/models/UserProfile.js';
import type { MiNote } from '@/models/Note.js';
import type { MiAntenna } from '@/models/Antenna.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiDriveFolder } from '@/models/DriveFolder.js';
import type { MiUserList } from '@/models/UserList.js';
import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
import type { MiSignin } from '@/models/Signin.js';
import type { MiPage } from '@/models/Page.js';
import type { Packed } from '@/misc/json-schema.js';
import type { MiWebhook } from '@/models/Webhook.js';
import type { MiMeta } from '@/models/Meta.js';
import { MiRole, MiRoleAssignment } from '@/models/_.js';
import type Emitter from 'strict-event-emitter-types';
import type { EventEmitter } from 'events';
//#region Stream type-body definitions
export interface InternalStreamTypes {
userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; };
userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; };
remoteUserUpdated: { id: MiUser['id']; };
follow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
unfollow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
blockingCreated: { blockerId: MiUser['id']; blockeeId: MiUser['id']; };
blockingDeleted: { blockerId: MiUser['id']; blockeeId: MiUser['id']; };
policiesUpdated: MiRole['policies'];
roleCreated: MiRole;
roleDeleted: MiRole;
roleUpdated: MiRole;
userRoleAssigned: MiRoleAssignment;
userRoleUnassigned: MiRoleAssignment;
webhookCreated: MiWebhook;
webhookDeleted: MiWebhook;
webhookUpdated: MiWebhook;
antennaCreated: MiAntenna;
antennaDeleted: MiAntenna;
antennaUpdated: MiAntenna;
metaUpdated: MiMeta;
followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
updateUserProfile: MiUserProfile;
mute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
}
export interface BroadcastTypes {
emojiAdded: {
emoji: Packed<'EmojiDetailed'>;
};
emojiUpdated: {
emojis: Packed<'EmojiDetailed'>[];
};
emojiDeleted: {
emojis: {
id?: string;
name: string;
[other: string]: any;
}[];
};
announcementCreated: {
announcement: Packed<'Announcement'>;
};
}
export interface MainStreamTypes {
notification: Packed<'Notification'>;
mention: Packed<'Note'>;
reply: Packed<'Note'>;
renote: Packed<'Note'>;
follow: Packed<'UserDetailedNotMe'>;
followed: Packed<'User'>;
unfollow: Packed<'User'>;
meUpdated: Packed<'User'>;
pageEvent: {
pageId: MiPage['id'];
event: string;
var: any;
userId: MiUser['id'];
user: Packed<'User'>;
};
urlUploadFinished: {
marker?: string | null;
file: Packed<'DriveFile'>;
};
readAllNotifications: undefined;
unreadNotification: Packed<'Notification'>;
unreadMention: MiNote['id'];
readAllUnreadMentions: undefined;
unreadSpecifiedNote: MiNote['id'];
readAllUnreadSpecifiedNotes: undefined;
readAllAntennas: undefined;
unreadAntenna: MiAntenna;
readAllAnnouncements: undefined;
myTokenRegenerated: undefined;
signin: MiSignin;
registryUpdated: {
scope?: string[];
key: string;
value: any | null;
};
driveFileCreated: Packed<'DriveFile'>;
readAntenna: MiAntenna;
receiveFollowRequest: Packed<'User'>;
announcementCreated: {
announcement: Packed<'Announcement'>;
};
}
export interface DriveStreamTypes {
fileCreated: Packed<'DriveFile'>;
fileDeleted: MiDriveFile['id'];
fileUpdated: Packed<'DriveFile'>;
folderCreated: Packed<'DriveFolder'>;
folderDeleted: MiDriveFolder['id'];
folderUpdated: Packed<'DriveFolder'>;
}
export interface NoteStreamTypes {
pollVoted: {
choice: number;
userId: MiUser['id'];
};
deleted: {
deletedAt: Date;
};
reacted: {
reaction: string;
emoji?: {
name: string;
url: string;
} | null;
userId: MiUser['id'];
};
unreacted: {
reaction: string;
userId: MiUser['id'];
};
updated: {
updatedAt: Date;
};
}
type NoteStreamEventTypes = {
[key in keyof NoteStreamTypes]: {
id: MiNote['id'];
body: NoteStreamTypes[key];
};
};
export interface UserListStreamTypes {
userAdded: Packed<'User'>;
userRemoved: Packed<'User'>;
}
export interface AntennaStreamTypes {
note: MiNote;
}
export interface RoleTimelineStreamTypes {
note: Packed<'Note'>;
}
export interface AdminStreamTypes {
newAbuseUserReport: {
id: MiAbuseUserReport['id'];
targetUserId: MiUser['id'],
reporterId: MiUser['id'],
comment: string;
};
}
//#endregion
// 辞書(interface or type)から{ type, body }ユニオンを定義
// https://stackoverflow.com/questions/49311989/can-i-infer-the-type-of-a-value-using-extends-keyof-type
// VS Codeの展開を防止するためにEvents型を定義
type Events<T extends object> = { [K in keyof T]: { type: K; body: T[K]; } };
type EventUnionFromDictionary<
T extends object,
U = Events<T>
> = U[keyof U];
// redis通すとDateのインスタンスはstringに変換されるので
export type Serialized<T> = {
[K in keyof T]:
T[K] extends Date
? string
: T[K] extends (Date | null)
? (string | null)
: T[K] extends Record<string, any>
? Serialized<T[K]>
: T[K];
};
type SerializedAll<T> = {
[K in keyof T]: Serialized<T[K]>;
};
// name/messages(spec) pairs dictionary
export type StreamMessages = {
internal: {
name: 'internal';
payload: EventUnionFromDictionary<SerializedAll<InternalStreamTypes>>;
};
broadcast: {
name: 'broadcast';
payload: EventUnionFromDictionary<SerializedAll<BroadcastTypes>>;
};
main: {
name: `mainStream:${MiUser['id']}`;
payload: EventUnionFromDictionary<SerializedAll<MainStreamTypes>>;
};
drive: {
name: `driveStream:${MiUser['id']}`;
payload: EventUnionFromDictionary<SerializedAll<DriveStreamTypes>>;
};
note: {
name: `noteStream:${MiNote['id']}`;
payload: EventUnionFromDictionary<SerializedAll<NoteStreamEventTypes>>;
};
userList: {
name: `userListStream:${MiUserList['id']}`;
payload: EventUnionFromDictionary<SerializedAll<UserListStreamTypes>>;
};
roleTimeline: {
name: `roleTimelineStream:${MiRole['id']}`;
payload: EventUnionFromDictionary<SerializedAll<RoleTimelineStreamTypes>>;
};
antenna: {
name: `antennaStream:${MiAntenna['id']}`;
payload: EventUnionFromDictionary<SerializedAll<AntennaStreamTypes>>;
};
admin: {
name: `adminStream:${MiUser['id']}`;
payload: EventUnionFromDictionary<SerializedAll<AdminStreamTypes>>;
};
notes: {
name: 'notesStream';
payload: Serialized<Packed<'Note'>>;
};
};
// API event definitions
// ストリームごとのEmitterの辞書を用意
type EventEmitterDictionary = { [x in keyof StreamMessages]: Emitter.default<EventEmitter, { [y in StreamMessages[x]['name']]: (e: StreamMessages[x]['payload']) => void }> };
// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする
export type StreamEventEmitter = UnionToIntersection<EventEmitterDictionary[keyof StreamMessages]>;
// { [y in name]: (e: spec) => void }をまとめてその交差型をEmitterにかけるとts(2590)にひっかかる
// provide stream channels union
export type StreamChannels = StreamMessages[keyof StreamMessages]['name'];

View file

@ -188,7 +188,7 @@ export class ClientServerService {
// Authenticate
fastify.addHook('onRequest', async (request, reply) => {
// %71ueueとかでリクエストされたら困るため
const url = decodeURI(request.url);
const url = decodeURI(request.routeOptions.url);
if (url === bullBoardPath || url.startsWith(bullBoardPath + '/')) {
const token = request.cookies.token;
if (token == null) {
@ -728,8 +728,8 @@ export class ClientServerService {
fastify.setErrorHandler(async (error, request, reply) => {
const errId = randomUUID();
this.clientLoggerService.logger.error(`Internal error occurred in ${request.routerPath}: ${error.message}`, {
path: request.routerPath,
this.clientLoggerService.logger.error(`Internal error occurred in ${request.routeOptions.url}: ${error.message}`, {
path: request.routeOptions.url,
params: request.params,
query: request.query,
code: error.name,

View file

@ -57,6 +57,9 @@ export const moderationLogTypes = [
'unmarkSensitiveDriveFile',
'resolveAbuseReport',
'createInvitation',
'createAd',
'updateAd',
'deleteAd',
] as const;
export type ModerationLogPayloads = {
@ -202,4 +205,28 @@ export type ModerationLogPayloads = {
createInvitation: {
invitations: any[];
};
createAd: {
adId: string;
ad: any;
};
updateAd: {
adId: string;
before: any;
after: any;
};
deleteAd: {
adId: string;
ad: any;
};
};
export type Serialized<T> = {
[K in keyof T]:
T[K] extends Date
? string
: T[K] extends (Date | null)
? (string | null)
: T[K] extends Record<string, any>
? Serialized<T[K]>
: T[K];
};

View file

@ -6,7 +6,7 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { signup, api, post, react, startServer, waitFire } from '../utils.js';
import { signup, api, post, react, startServer, waitFire, sleep } from '../utils.js';
import type { INestApplicationContext } from '@nestjs/common';
import type * as misskey from 'misskey-js';
@ -42,6 +42,9 @@ describe('Renote Mute', () => {
const carolRenote = await post(carol, { renoteId: bobNote.id });
const carolNote = await post(carol, { text: 'hi' });
// redisに追加されるのを待つ
await sleep(100);
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200);
@ -56,6 +59,9 @@ describe('Renote Mute', () => {
const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' });
const carolNote = await post(carol, { text: 'hi' });
// redisに追加されるのを待つ
await sleep(100);
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200);

View file

@ -0,0 +1,701 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING = 'true';
import * as assert from 'assert';
import { signup, api, post, react, startServer, waitFire, sleep, uploadUrl } from '../utils.js';
import type { INestApplicationContext } from '@nestjs/common';
import type * as misskey from 'misskey-js';
let app: INestApplicationContext;
beforeAll(async () => {
app = await startServer();
}, 1000 * 60 * 2);
afterAll(async () => {
await app.close();
});
describe('Timelines', () => {
describe('Home TL', () => {
test.concurrent('自分の visibility: followers なノートが含まれる', async () => {
const [alice] = await Promise.all([signup()]);
const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi');
});
test.concurrent('フォローしているユーザーのノートが含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi' });
const carolNote = await post(carol, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'followers' });
const carolNote = await post(carol, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi');
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: false でフォローしているユーザーの他人への返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: true でフォローしているユーザーの他人への返信が含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: true でフォローしているユーザーの他人へのDM返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
const carolNote = await post(carol, { text: 'hi', visibility: 'followers' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/create', { userId: carol.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
const carolNote = await post(carol, { text: 'hi', visibility: 'followers' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === carolNote.id).text, 'hi');
});
test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの投稿への visibility: specified な返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/create', { userId: carol.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true);
});
test.concurrent('withReplies: false でフォローしているユーザーのそのユーザー自身への返信が含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote1 = await post(bob, { text: 'hi' });
const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
});
test.concurrent('自分の他人への返信が含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const bobNote = await post(bob, { text: 'hi' });
const aliceNote = await post(alice, { text: 'hi', replyId: bobNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true);
});
test.concurrent('フォローしているユーザーの他人の投稿のリノートが含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { renoteId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿のリノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { renoteId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {
withRenotes: false,
}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿の引用が含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {
withRenotes: false,
}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('フォローしているユーザーの他人への visibility: specified なノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/mute/create', { userId: carol.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
await api('/mute/create', { userId: carol.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'home' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('[withFiles: true] フォローしているユーザーのファイル付きノートのみ含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const [bobFile, carolFile] = await Promise.all([
uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png'),
uploadUrl(carol, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png'),
]);
const bobNote1 = await post(bob, { text: 'hi' });
const bobNote2 = await post(bob, { fileIds: [bobFile.id] });
const carolNote1 = await post(carol, { text: 'hi' });
const carolNote2 = await post(carol, { fileIds: [carolFile.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', { withFiles: true }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote2.id), false);
}, 1000 * 10);
});
describe('Local TL', () => {
test.concurrent('visibility: home なノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
const carolNote = await post(carol, { text: 'hi', visibility: 'home' });
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('リモートユーザーのノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]);
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
// 含まれても良いと思うけど実装が面倒なので含まれない
test.concurrent('フォローしているユーザーの visibility: home なノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', {
userId: carol.id,
}, alice);
const carolNote = await post(carol, { text: 'hi', visibility: 'home' });
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('ミュートしているユーザーのノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/mute/create', { userId: carol.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/mute/create', { userId: carol.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
await api('/mute/create', { userId: carol.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png');
const bobNote1 = await post(bob, { text: 'hi' });
const bobNote2 = await post(bob, { fileIds: [file.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', { withFiles: true }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
}, 1000 * 10);
});
describe('Social TL', () => {
test.concurrent('ローカルユーザーのノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/hybrid-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('ローカルユーザーの visibility: home なノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const bobNote = await post(bob, { text: 'hi', visibility: 'home' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/hybrid-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
test.concurrent('フォローしているローカルユーザーの visibility: home なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'home' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/hybrid-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('リモートユーザーのノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]);
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/hybrid-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'home' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/hybrid-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png');
const bobNote1 = await post(bob, { text: 'hi' });
const bobNote2 = await post(bob, { fileIds: [file.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/hybrid-timeline', { withFiles: true }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
}, 1000 * 10);
});
describe('User List TL', () => {
test.concurrent('リスインしているフォローしていないユーザーのノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('リスインしているフォローしていないユーザーの visibility: home なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'home' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
/*
test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'followers' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
*/
test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれるが隠される', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'followers' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, null);
});
test.concurrent('リスインしているフォローしていないユーザーの他人への返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
test.concurrent('リスインしているフォローしていないユーザーのユーザー自身への返信が含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote1 = await post(bob, { text: 'hi' });
const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
});
test.concurrent('withReplies: true でリスインしているフォローしていないユーザーの他人への返信が含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
await api('/users/lists/update-membership', { listId: list.id, userId: bob.id, withReplies: true }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('リスインしているフォローしているユーザーの visibility: home なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'home' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('リスインしているフォローしているユーザーの visibility: followers なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'followers' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi');
});
test.concurrent('[withFiles: true] リスインしているユーザーのファイル付きノートのみ含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png');
const bobNote1 = await post(bob, { text: 'hi' });
const bobNote2 = await post(bob, { fileIds: [file.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id, withFiles: true }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
}, 1000 * 10);
});
// TODO: リノートミュート済みユーザーのテスト
// TODO: ページネーションのテスト
});

View file

@ -38,23 +38,10 @@ describe('users/notes', () => {
await app.close();
});
test('ファイルタイプ指定 (jpg)', async () => {
test('withFiles', async () => {
const res = await api('/users/notes', {
userId: alice.id,
fileType: ['image/jpeg'],
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 2);
assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true);
});
test('ファイルタイプ指定 (jpg or png)', async () => {
const res = await api('/users/notes', {
userId: alice.id,
fileType: ['image/jpeg', 'image/png'],
withFiles: true,
}, alice);
assert.strictEqual(res.status, 200);

View file

@ -133,6 +133,7 @@ describe('ユーザー', () => {
isMuted: user.isMuted ?? false,
isRenoteMuted: user.isRenoteMuted ?? false,
notify: user.notify ?? 'none',
withReplies: user.withReplies ?? false,
});
};
@ -167,6 +168,7 @@ describe('ユーザー', () => {
mutedWords: user.mutedWords,
mutedInstances: user.mutedInstances,
mutingNotificationTypes: user.mutingNotificationTypes,
notificationRecieveConfig: user.notificationRecieveConfig,
emailNotificationTypes: user.emailNotificationTypes,
achievements: user.achievements,
loggedInDays: user.loggedInDays,
@ -415,6 +417,7 @@ describe('ユーザー', () => {
assert.deepStrictEqual(response.mutedWords, []);
assert.deepStrictEqual(response.mutedInstances, []);
assert.deepStrictEqual(response.mutingNotificationTypes, []);
assert.deepStrictEqual(response.notificationRecieveConfig, {});
assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']);
assert.deepStrictEqual(response.achievements, []);
assert.deepStrictEqual(response.loggedInDays, 0);
@ -495,8 +498,8 @@ describe('ユーザー', () => {
{ parameters: (): object => ({ mutedWords: [] }) },
{ parameters: (): object => ({ mutedInstances: ['xxxx.xxxxx'] }) },
{ parameters: (): object => ({ mutedInstances: [] }) },
{ parameters: (): object => ({ mutingNotificationTypes: ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] }) },
{ parameters: (): object => ({ mutingNotificationTypes: [] }) },
{ parameters: (): object => ({ notificationRecieveConfig: { mention: { type: 'following' } } }) },
{ parameters: (): object => ({ notificationRecieveConfig: {} }) },
{ parameters: (): object => ({ emailNotificationTypes: ['mention', 'reply', 'quote', 'follow', 'receiveFollowRequest'] }) },
{ parameters: (): object => ({ emailNotificationTypes: [] }) },
] as const)('を書き換えることができる($#)', async ({ parameters }) => {

View file

@ -99,9 +99,17 @@ export const relativeFetch = async (path: string, init?: RequestInit | undefined
return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init);
};
function randomString(chars = 'abcdefghijklmnopqrstuvwxyz0123456789', length = 16) {
let randomString = '';
for (let i = 0; i < length; i++) {
randomString += chars[Math.floor(Math.random() * chars.length)];
}
return randomString;
}
export const signup = async (params?: Partial<misskey.Endpoints['signup']['req']>): Promise<NonNullable<misskey.Endpoints['signup']['res']>> => {
const q = Object.assign({
username: 'test',
username: randomString(),
password: 'test',
}, params);