merge: Implement new Background Task queue (!1241)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1241 Approved-by: dakkar <dakkar@thenautilus.net> Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
commit
8c196d5cb2
229 changed files with 3209 additions and 1384 deletions
|
|
@ -253,21 +253,42 @@ id: 'aidx'
|
||||||
# Number of worker processes
|
# Number of worker processes
|
||||||
#clusterLimit: 1
|
#clusterLimit: 1
|
||||||
|
|
||||||
|
# +-------------------------+
|
||||||
|
# | Job concurrency options |
|
||||||
|
# +-------------------------+
|
||||||
|
#
|
||||||
|
### Available options:
|
||||||
|
# [type]JobConcurrency - limits the number jobs that can run at the same time.
|
||||||
|
# Sharkey will allow this many jobs of this type *per worker process*.
|
||||||
|
# [type]JobPerSec - limits the total number of jobs that may complete within a single second.
|
||||||
|
# If this limit is exceeded, then Sharkey will pause this type of job until the next second.
|
||||||
|
# [type]JobMaxAttempts - limits the number of times that a job is allowed to fail and re-try before it's permanently stopped.
|
||||||
|
# If this limit is exceeded, then the job is considered "failed" and recorded for debugging.
|
||||||
|
#
|
||||||
|
### Job types:
|
||||||
|
# inbox - processes ActivityPub messages (AKA "Activities") received from remote instances.
|
||||||
|
# All inbound activities are queued and processed in chronological order by this job.
|
||||||
|
# deliver - processes ActivityPub messages (AKA "Activities") being set to remote instances.
|
||||||
|
# All outbound activities are queued and processed in chronological order by this job.
|
||||||
|
# relationship - processes user-to-user tasks including follow/unfollow, block/unblock, account migrations, and all follow import jobs.
|
||||||
|
# background - processes background synchronization tasks that need to happen soon (but not immediately), such as remote user updates and instance metadata updates.
|
||||||
|
|
||||||
# Job concurrency per worker
|
# Job concurrency per worker
|
||||||
# deliverJobConcurrency: 128
|
#deliverJobConcurrency: 128
|
||||||
# inboxJobConcurrency: 16
|
#inboxJobConcurrency: 16
|
||||||
# relashionshipJobConcurrency: 16
|
#relationshipJobConcurrency: 16
|
||||||
# What's relashionshipJob?:
|
#backgroundJobConcurrency: 32
|
||||||
# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations.
|
|
||||||
|
|
||||||
# Job rate limiter
|
# Job rate limiter
|
||||||
# deliverJobPerSec: 128
|
#deliverJobPerSec: 128
|
||||||
# inboxJobPerSec: 32
|
#inboxJobPerSec: 32
|
||||||
# relashionshipJobPerSec: 64
|
#relationshipJobPerSec: 64
|
||||||
|
#backgroundJobPerSec: 256
|
||||||
|
|
||||||
# Job attempts
|
# Job attempts
|
||||||
# deliverJobMaxAttempts: 12
|
#deliverJobMaxAttempts: 12
|
||||||
# inboxJobMaxAttempts: 8
|
#inboxJobMaxAttempts: 8
|
||||||
|
#backgroundJobMaxAttempts: 8
|
||||||
|
|
||||||
# Local address used for outgoing requests
|
# Local address used for outgoing requests
|
||||||
#outgoingAddress: 127.0.0.1
|
#outgoingAddress: 127.0.0.1
|
||||||
|
|
|
||||||
|
|
@ -223,17 +223,42 @@ id: 'aidx'
|
||||||
# Number of worker processes
|
# Number of worker processes
|
||||||
#clusterLimit: 1
|
#clusterLimit: 1
|
||||||
|
|
||||||
|
# +-------------------------+
|
||||||
|
# | Job concurrency options |
|
||||||
|
# +-------------------------+
|
||||||
|
#
|
||||||
|
### Available options:
|
||||||
|
# [type]JobConcurrency - limits the number jobs that can run at the same time.
|
||||||
|
# Sharkey will allow this many jobs of this type *per worker process*.
|
||||||
|
# [type]JobPerSec - limits the total number of jobs that may complete within a single second.
|
||||||
|
# If this limit is exceeded, then Sharkey will pause this type of job until the next second.
|
||||||
|
# [type]JobMaxAttempts - limits the number of times that a job is allowed to fail and re-try before it's permanently stopped.
|
||||||
|
# If this limit is exceeded, then the job is considered "failed" and recorded for debugging.
|
||||||
|
#
|
||||||
|
### Job types:
|
||||||
|
# inbox - processes ActivityPub messages (AKA "Activities") received from remote instances.
|
||||||
|
# All inbound activities are queued and processed in chronological order by this job.
|
||||||
|
# deliver - processes ActivityPub messages (AKA "Activities") being set to remote instances.
|
||||||
|
# All outbound activities are queued and processed in chronological order by this job.
|
||||||
|
# relationship - processes user-to-user tasks including follow/unfollow, block/unblock, account migrations, and all follow import jobs.
|
||||||
|
# background - processes background synchronization tasks that need to happen soon (but not immediately), such as remote user updates and instance metadata updates.
|
||||||
|
|
||||||
# Job concurrency per worker
|
# Job concurrency per worker
|
||||||
# deliverJobConcurrency: 128
|
#deliverJobConcurrency: 128
|
||||||
# inboxJobConcurrency: 16
|
#inboxJobConcurrency: 16
|
||||||
|
#relationshipJobConcurrency: 16
|
||||||
|
#backgroundJobConcurrency: 32
|
||||||
|
|
||||||
# Job rate limiter
|
# Job rate limiter
|
||||||
# deliverJobPerSec: 128
|
#deliverJobPerSec: 128
|
||||||
# inboxJobPerSec: 32
|
#inboxJobPerSec: 32
|
||||||
|
#relationshipJobPerSec: 64
|
||||||
|
#backgroundJobPerSec: 256
|
||||||
|
|
||||||
# Job attempts
|
# Job attempts
|
||||||
# deliverJobMaxAttempts: 12
|
#deliverJobMaxAttempts: 12
|
||||||
# inboxJobMaxAttempts: 8
|
#inboxJobMaxAttempts: 8
|
||||||
|
#backgroundJobMaxAttempts: 8
|
||||||
|
|
||||||
# IP address family used for outgoing request (ipv4, ipv6 or dual)
|
# IP address family used for outgoing request (ipv4, ipv6 or dual)
|
||||||
#outgoingAddressFamily: ipv4
|
#outgoingAddressFamily: ipv4
|
||||||
|
|
|
||||||
|
|
@ -307,21 +307,42 @@ id: 'aidx'
|
||||||
# Number of worker processes
|
# Number of worker processes
|
||||||
#clusterLimit: 1
|
#clusterLimit: 1
|
||||||
|
|
||||||
|
# +-------------------------+
|
||||||
|
# | Job concurrency options |
|
||||||
|
# +-------------------------+
|
||||||
|
#
|
||||||
|
### Available options:
|
||||||
|
# [type]JobConcurrency - limits the number jobs that can run at the same time.
|
||||||
|
# Sharkey will allow this many jobs of this type *per worker process*.
|
||||||
|
# [type]JobPerSec - limits the total number of jobs that may complete within a single second.
|
||||||
|
# If this limit is exceeded, then Sharkey will pause this type of job until the next second.
|
||||||
|
# [type]JobMaxAttempts - limits the number of times that a job is allowed to fail and re-try before it's permanently stopped.
|
||||||
|
# If this limit is exceeded, then the job is considered "failed" and recorded for debugging.
|
||||||
|
#
|
||||||
|
### Job types:
|
||||||
|
# inbox - processes ActivityPub messages (AKA "Activities") received from remote instances.
|
||||||
|
# All inbound activities are queued and processed in chronological order by this job.
|
||||||
|
# deliver - processes ActivityPub messages (AKA "Activities") being set to remote instances.
|
||||||
|
# All outbound activities are queued and processed in chronological order by this job.
|
||||||
|
# relationship - processes user-to-user tasks including follow/unfollow, block/unblock, account migrations, and all follow import jobs.
|
||||||
|
# background - processes background synchronization tasks that need to happen soon (but not immediately), such as remote user updates and instance metadata updates.
|
||||||
|
|
||||||
# Job concurrency per worker
|
# Job concurrency per worker
|
||||||
#deliverJobConcurrency: 128
|
#deliverJobConcurrency: 128
|
||||||
#inboxJobConcurrency: 16
|
#inboxJobConcurrency: 16
|
||||||
#relationshipJobConcurrency: 16
|
#relationshipJobConcurrency: 16
|
||||||
# What's relationshipJob?:
|
#backgroundJobConcurrency: 32
|
||||||
# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations.
|
|
||||||
|
|
||||||
# Job rate limiter
|
# Job rate limiter
|
||||||
#deliverJobPerSec: 128
|
#deliverJobPerSec: 128
|
||||||
#inboxJobPerSec: 32
|
#inboxJobPerSec: 32
|
||||||
#relationshipJobPerSec: 64
|
#relationshipJobPerSec: 64
|
||||||
|
#backgroundJobPerSec: 256
|
||||||
|
|
||||||
# Job attempts
|
# Job attempts
|
||||||
#deliverJobMaxAttempts: 12
|
#deliverJobMaxAttempts: 12
|
||||||
#inboxJobMaxAttempts: 8
|
#inboxJobMaxAttempts: 8
|
||||||
|
#backgroundJobMaxAttempts: 8
|
||||||
|
|
||||||
# Local address used for outgoing requests
|
# Local address used for outgoing requests
|
||||||
#outgoingAddress: 127.0.0.1
|
#outgoingAddress: 127.0.0.1
|
||||||
|
|
|
||||||
|
|
@ -310,21 +310,42 @@ id: 'aidx'
|
||||||
# Number of worker processes
|
# Number of worker processes
|
||||||
#clusterLimit: 1
|
#clusterLimit: 1
|
||||||
|
|
||||||
|
# +-------------------------+
|
||||||
|
# | Job concurrency options |
|
||||||
|
# +-------------------------+
|
||||||
|
#
|
||||||
|
### Available options:
|
||||||
|
# [type]JobConcurrency - limits the number jobs that can run at the same time.
|
||||||
|
# Sharkey will allow this many jobs of this type *per worker process*.
|
||||||
|
# [type]JobPerSec - limits the total number of jobs that may complete within a single second.
|
||||||
|
# If this limit is exceeded, then Sharkey will pause this type of job until the next second.
|
||||||
|
# [type]JobMaxAttempts - limits the number of times that a job is allowed to fail and re-try before it's permanently stopped.
|
||||||
|
# If this limit is exceeded, then the job is considered "failed" and recorded for debugging.
|
||||||
|
#
|
||||||
|
### Job types:
|
||||||
|
# inbox - processes ActivityPub messages (AKA "Activities") received from remote instances.
|
||||||
|
# All inbound activities are queued and processed in chronological order by this job.
|
||||||
|
# deliver - processes ActivityPub messages (AKA "Activities") being set to remote instances.
|
||||||
|
# All outbound activities are queued and processed in chronological order by this job.
|
||||||
|
# relationship - processes user-to-user tasks including follow/unfollow, block/unblock, account migrations, and all follow import jobs.
|
||||||
|
# background - processes background synchronization tasks that need to happen soon (but not immediately), such as remote user updates and instance metadata updates.
|
||||||
|
|
||||||
# Job concurrency per worker
|
# Job concurrency per worker
|
||||||
#deliverJobConcurrency: 128
|
#deliverJobConcurrency: 128
|
||||||
#inboxJobConcurrency: 16
|
#inboxJobConcurrency: 16
|
||||||
#relationshipJobConcurrency: 16
|
#relationshipJobConcurrency: 16
|
||||||
# What's relationshipJob?:
|
#backgroundJobConcurrency: 32
|
||||||
# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations.
|
|
||||||
|
|
||||||
# Job rate limiter
|
# Job rate limiter
|
||||||
#deliverJobPerSec: 128
|
#deliverJobPerSec: 128
|
||||||
#inboxJobPerSec: 32
|
#inboxJobPerSec: 32
|
||||||
#relationshipJobPerSec: 64
|
#relationshipJobPerSec: 64
|
||||||
|
#backgroundJobPerSec: 256
|
||||||
|
|
||||||
# Job attempts
|
# Job attempts
|
||||||
#deliverJobMaxAttempts: 12
|
#deliverJobMaxAttempts: 12
|
||||||
#inboxJobMaxAttempts: 8
|
#inboxJobMaxAttempts: 8
|
||||||
|
#backgroundJobMaxAttempts: 8
|
||||||
|
|
||||||
# Local address used for outgoing requests
|
# Local address used for outgoing requests
|
||||||
#outgoingAddress: 127.0.0.1
|
#outgoingAddress: 127.0.0.1
|
||||||
|
|
|
||||||
4
locales/index.d.ts
vendored
4
locales/index.d.ts
vendored
|
|
@ -13669,6 +13669,10 @@ export interface Locale extends ILocale {
|
||||||
* Are you sure you want to restart this account migration?
|
* Are you sure you want to restart this account migration?
|
||||||
*/
|
*/
|
||||||
"restartMigrationConfirm": string;
|
"restartMigrationConfirm": string;
|
||||||
|
/**
|
||||||
|
* Background queue
|
||||||
|
*/
|
||||||
|
"backgroundQueue": string;
|
||||||
}
|
}
|
||||||
declare const locales: {
|
declare const locales: {
|
||||||
[lang: string]: Locale;
|
[lang: string]: Locale;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class FixIDXInstanceHostKey1748990662839 {
|
export class FixIDXInstanceHostKey1748990662839 {
|
||||||
|
name = 'FixIDXInstanceHostKey1748990662839';
|
||||||
|
|
||||||
async up(queryRunner) {
|
async up(queryRunner) {
|
||||||
// must include host for index-only scans: https://www.postgresql.org/docs/current/indexes-index-only-scans.html
|
// must include host for index-only scans: https://www.postgresql.org/docs/current/indexes-index-only-scans.html
|
||||||
await queryRunner.query(`DROP INDEX "public"."IDX_instance_host_key"`);
|
await queryRunner.query(`DROP INDEX "public"."IDX_instance_host_key"`);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class CreateIDXNoteForTimelines1748991828473 {
|
export class CreateIDXNoteForTimelines1748991828473 {
|
||||||
|
name = 'CreateIDXNoteForTimelines1748991828473';
|
||||||
|
|
||||||
async up(queryRunner) {
|
async up(queryRunner) {
|
||||||
await queryRunner.query(`
|
await queryRunner.query(`
|
||||||
create index "IDX_note_for_timelines"
|
create index "IDX_note_for_timelines"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class CreateIDXInstanceHostFilters1748992017688 {
|
export class CreateIDXInstanceHostFilters1748992017688 {
|
||||||
|
name = 'CreateIDXInstanceHostFilters1748992017688';
|
||||||
|
|
||||||
async up(queryRunner) {
|
async up(queryRunner) {
|
||||||
await queryRunner.query(`
|
await queryRunner.query(`
|
||||||
create index "IDX_instance_host_filters"
|
create index "IDX_instance_host_filters"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class CreateStatistics1748992128683 {
|
export class CreateStatistics1748992128683 {
|
||||||
|
name = 'CreateStatistics1748992128683';
|
||||||
|
|
||||||
async up(queryRunner) {
|
async up(queryRunner) {
|
||||||
await queryRunner.query(`CREATE STATISTICS "STTS_instance_isBlocked_isBubbled" (mcv) ON "isBlocked", "isBubbled" FROM "instance"`);
|
await queryRunner.query(`CREATE STATISTICS "STTS_instance_isBlocked_isBubbled" (mcv) ON "isBlocked", "isBubbled" FROM "instance"`);
|
||||||
await queryRunner.query(`CREATE STATISTICS "STTS_instance_isBlocked_isSilenced" (mcv) ON "isBlocked", "isSilenced" FROM "instance"`);
|
await queryRunner.query(`CREATE STATISTICS "STTS_instance_isBlocked_isSilenced" (mcv) ON "isBlocked", "isSilenced" FROM "instance"`);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class FixIDXNoteForTimeline1749097536193 {
|
export class FixIDXNoteForTimeline1749097536193 {
|
||||||
|
name = 'FixIDXNoteForTimeline1749097536193';
|
||||||
|
|
||||||
async up(queryRunner) {
|
async up(queryRunner) {
|
||||||
await queryRunner.query('drop index "IDX_note_for_timelines"');
|
await queryRunner.query('drop index "IDX_note_for_timelines"');
|
||||||
await queryRunner.query(`
|
await queryRunner.query(`
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class RemoveIDXInstanceHostFilters1749267016885 {
|
export class RemoveIDXInstanceHostFilters1749267016885 {
|
||||||
|
name = 'RemoveIDXInstanceHostFilters1749267016885';
|
||||||
|
|
||||||
async up(queryRunner) {
|
async up(queryRunner) {
|
||||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_instance_host_filters"`);
|
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_instance_host_filters"`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
// https://www.cybertec-postgresql.com/en/hot-updates-in-postgresql-for-better-performance/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class
|
||||||
|
* @implements {MigrationInterface}
|
||||||
|
*/
|
||||||
|
export class EnableInstanceHOTUpdates1750217001651 {
|
||||||
|
name = 'EnableInstanceHOTUpdates1750217001651';
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" SET (fillfactor = 50)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" SET (fillfactor = 100)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class MoreNoteEditColumns1750353421706 {
|
||||||
|
name = 'MoreNoteEditColumns1750353421706'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
// Update column types
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" ALTER COLUMN "cw" TYPE text USING "cw"::text`);
|
||||||
|
|
||||||
|
// Rename columns
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" RENAME COLUMN "oldText" TO "text"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" RENAME COLUMN "cw" TO "newCw"`);
|
||||||
|
|
||||||
|
// Add new fields
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" ADD "userId" character varying(32)`);
|
||||||
|
await queryRunner.query(`COMMENT ON COLUMN "note_edit"."userId" IS 'The ID of author.'`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" ADD CONSTRAINT "FK_7f1ded0f6e8a5bef701b7e698ab" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" ADD "renoteId" character varying(32)`);
|
||||||
|
await queryRunner.query(`COMMENT ON COLUMN "note_edit"."renoteId" IS 'The ID of renote target. Will always be null for older edits'`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" ADD CONSTRAINT "FK_d3003e5256bcbfad6c3588835c0" FOREIGN KEY ("renoteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" ADD "replyId" character varying(32)`);
|
||||||
|
await queryRunner.query(`COMMENT ON COLUMN "note_edit"."replyId" IS 'The ID of reply target. Will always be null for older edits'`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" ADD CONSTRAINT "FK_f34b53ab9b39774ca014972ad84" FOREIGN KEY ("replyId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" ADD "visibility" "public"."note_visibility_enum"`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" ADD "cw" text`);
|
||||||
|
await queryRunner.query(`COMMENT ON COLUMN "note_edit"."cw" IS 'Will always be null for older edits'`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" ADD "hasPoll" boolean NOT NULL DEFAULT false`);
|
||||||
|
await queryRunner.query(`COMMENT ON COLUMN "note_edit"."hasPoll" IS 'Whether this revision had a poll. Will always be false for older edits'`);
|
||||||
|
|
||||||
|
// Populate non-nullable fields
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE "note_edit" "e"
|
||||||
|
SET
|
||||||
|
"visibility" = "n"."visibility",
|
||||||
|
"userId" = "n"."userId"
|
||||||
|
FROM "note" "n"
|
||||||
|
WHERE "n"."id" = "e"."noteId"
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" ALTER COLUMN "visibility" SET NOT NULL`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" ALTER COLUMN "userId" SET NOT NULL`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
// Drop new columns
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" DROP COLUMN "visibility"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" DROP COLUMN "hasPoll"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" DROP COLUMN "cw"`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" DROP COLUMN "userId"`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" DROP CONSTRAINT "FK_f34b53ab9b39774ca014972ad84"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" DROP COLUMN "replyId"`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" DROP CONSTRAINT "FK_d3003e5256bcbfad6c3588835c0"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" DROP COLUMN "renoteId"`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" DROP CONSTRAINT "FK_7f1ded0f6e8a5bef701b7e698ab"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" DROP COLUMN "userId"`);
|
||||||
|
|
||||||
|
// Rename new columns
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" RENAME COLUMN "text" TO "oldText"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" RENAME COLUMN "newCw" TO "cw"`);
|
||||||
|
|
||||||
|
// Revert column types
|
||||||
|
await queryRunner.query(`ALTER TABLE "note_edit" ALTER COLUMN "cw" TYPE varchar(512) USING "cw"::varchar(512)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AddUserLastFetchedFeaturedAt1758129782800 {
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" ADD "lastFetchedFeaturedAt" DATE`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "lastFetchedFeaturedAt"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class FixUserLastFetchedFeaturedAtType1758136690898 {
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" ALTER COLUMN "lastFetchedFeaturedAt" TYPE TIMESTAMP WITH TIME ZONE`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" ALTER COLUMN "lastFetchedFeaturedAt" TYPE DATE`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -111,11 +111,14 @@ type Source = {
|
||||||
deliverJobConcurrency?: number;
|
deliverJobConcurrency?: number;
|
||||||
inboxJobConcurrency?: number;
|
inboxJobConcurrency?: number;
|
||||||
relationshipJobConcurrency?: number;
|
relationshipJobConcurrency?: number;
|
||||||
|
backgroundJobConcurrency?: number;
|
||||||
deliverJobPerSec?: number;
|
deliverJobPerSec?: number;
|
||||||
inboxJobPerSec?: number;
|
inboxJobPerSec?: number;
|
||||||
relationshipJobPerSec?: number;
|
relationshipJobPerSec?: number;
|
||||||
|
backgroundJobPerSec?: number;
|
||||||
deliverJobMaxAttempts?: number;
|
deliverJobMaxAttempts?: number;
|
||||||
inboxJobMaxAttempts?: number;
|
inboxJobMaxAttempts?: number;
|
||||||
|
backgroundJobMaxAttempts?: number;
|
||||||
|
|
||||||
mediaDirectory?: string;
|
mediaDirectory?: string;
|
||||||
mediaProxy?: string;
|
mediaProxy?: string;
|
||||||
|
|
@ -272,11 +275,14 @@ export type Config = {
|
||||||
deliverJobConcurrency: number | undefined;
|
deliverJobConcurrency: number | undefined;
|
||||||
inboxJobConcurrency: number | undefined;
|
inboxJobConcurrency: number | undefined;
|
||||||
relationshipJobConcurrency: number | undefined;
|
relationshipJobConcurrency: number | undefined;
|
||||||
|
backgroundJobConcurrency: number | undefined;
|
||||||
deliverJobPerSec: number | undefined;
|
deliverJobPerSec: number | undefined;
|
||||||
inboxJobPerSec: number | undefined;
|
inboxJobPerSec: number | undefined;
|
||||||
relationshipJobPerSec: number | undefined;
|
relationshipJobPerSec: number | undefined;
|
||||||
|
backgroundJobPerSec: number | undefined;
|
||||||
deliverJobMaxAttempts: number | undefined;
|
deliverJobMaxAttempts: number | undefined;
|
||||||
inboxJobMaxAttempts: number | undefined;
|
inboxJobMaxAttempts: number | undefined;
|
||||||
|
backgroundJobMaxAttempts: number | undefined;
|
||||||
proxyRemoteFiles: boolean | undefined;
|
proxyRemoteFiles: boolean | undefined;
|
||||||
customMOTD: string[] | undefined;
|
customMOTD: string[] | undefined;
|
||||||
signToActivityPubGet: boolean;
|
signToActivityPubGet: boolean;
|
||||||
|
|
@ -475,11 +481,14 @@ export function loadConfig(loggerService: LoggerService): Config {
|
||||||
deliverJobConcurrency: config.deliverJobConcurrency,
|
deliverJobConcurrency: config.deliverJobConcurrency,
|
||||||
inboxJobConcurrency: config.inboxJobConcurrency,
|
inboxJobConcurrency: config.inboxJobConcurrency,
|
||||||
relationshipJobConcurrency: config.relationshipJobConcurrency,
|
relationshipJobConcurrency: config.relationshipJobConcurrency,
|
||||||
|
backgroundJobConcurrency: config.backgroundJobConcurrency,
|
||||||
deliverJobPerSec: config.deliverJobPerSec,
|
deliverJobPerSec: config.deliverJobPerSec,
|
||||||
inboxJobPerSec: config.inboxJobPerSec,
|
inboxJobPerSec: config.inboxJobPerSec,
|
||||||
relationshipJobPerSec: config.relationshipJobPerSec,
|
relationshipJobPerSec: config.relationshipJobPerSec,
|
||||||
|
backgroundJobPerSec: config.backgroundJobPerSec,
|
||||||
deliverJobMaxAttempts: config.deliverJobMaxAttempts,
|
deliverJobMaxAttempts: config.deliverJobMaxAttempts,
|
||||||
inboxJobMaxAttempts: config.inboxJobMaxAttempts,
|
inboxJobMaxAttempts: config.inboxJobMaxAttempts,
|
||||||
|
backgroundJobMaxAttempts: config.backgroundJobMaxAttempts,
|
||||||
proxyRemoteFiles: config.proxyRemoteFiles,
|
proxyRemoteFiles: config.proxyRemoteFiles,
|
||||||
customMOTD: config.customMOTD,
|
customMOTD: config.customMOTD,
|
||||||
signToActivityPubGet: config.signToActivityPubGet ?? true,
|
signToActivityPubGet: config.signToActivityPubGet ?? true,
|
||||||
|
|
|
||||||
|
|
@ -218,9 +218,9 @@ export class AnnouncementService {
|
||||||
announcementId: announcement.id,
|
announcementId: announcement.id,
|
||||||
userId: me.id,
|
userId: me.id,
|
||||||
});
|
});
|
||||||
return this.announcementEntityService.pack({ ...announcement, isRead: read !== null }, me);
|
return await this.announcementEntityService.pack({ ...announcement, isRead: read !== null }, me);
|
||||||
} else {
|
} else {
|
||||||
return this.announcementEntityService.pack(announcement, null);
|
return await this.announcementEntityService.pack(announcement, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,16 @@ import type { AntennasRepository, UserListMembershipsRepository } from '@/models
|
||||||
import type { MiAntenna } from '@/models/Antenna.js';
|
import type { MiAntenna } from '@/models/Antenna.js';
|
||||||
import type { MiNote } from '@/models/Note.js';
|
import type { MiNote } from '@/models/Note.js';
|
||||||
import type { MiUser } from '@/models/User.js';
|
import type { MiUser } from '@/models/User.js';
|
||||||
|
import { InternalEventService } from '@/global/InternalEventService.js';
|
||||||
|
import { promiseMap } from '@/misc/promise-map.js';
|
||||||
import { CacheService } from './CacheService.js';
|
import { CacheService } from './CacheService.js';
|
||||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AntennaService implements OnApplicationShutdown {
|
export class AntennaService implements OnApplicationShutdown {
|
||||||
|
// TODO implement QuantumSingleCache then replace this
|
||||||
private antennasFetched: boolean;
|
private antennasFetched: boolean;
|
||||||
private antennas: MiAntenna[];
|
private antennas: Map<string, MiAntenna>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.redisForTimelines)
|
@Inject(DI.redisForTimelines)
|
||||||
|
|
@ -43,9 +46,10 @@ export class AntennaService implements OnApplicationShutdown {
|
||||||
private utilityService: UtilityService,
|
private utilityService: UtilityService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private fanoutTimelineService: FanoutTimelineService,
|
private fanoutTimelineService: FanoutTimelineService,
|
||||||
|
private readonly internalEventService: InternalEventService,
|
||||||
) {
|
) {
|
||||||
this.antennasFetched = false;
|
this.antennasFetched = false;
|
||||||
this.antennas = [];
|
this.antennas = new Map();
|
||||||
|
|
||||||
this.redisForSub.on('message', this.onRedisMessage);
|
this.redisForSub.on('message', this.onRedisMessage);
|
||||||
}
|
}
|
||||||
|
|
@ -58,35 +62,16 @@ export class AntennaService implements OnApplicationShutdown {
|
||||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'antennaCreated':
|
case 'antennaCreated':
|
||||||
this.antennas.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
|
case 'antennaUpdated':
|
||||||
|
this.antennas.set(body.id, { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
|
||||||
...body,
|
...body,
|
||||||
lastUsedAt: new Date(body.lastUsedAt),
|
lastUsedAt: new Date(body.lastUsedAt),
|
||||||
user: null, // joinなカラムは通常取ってこないので
|
user: null, // joinなカラムは通常取ってこないので
|
||||||
userList: null, // joinなカラムは通常取ってこないので
|
userList: null, // joinなカラムは通常取ってこないので
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'antennaUpdated': {
|
|
||||||
const idx = this.antennas.findIndex(a => a.id === body.id);
|
|
||||||
if (idx >= 0) {
|
|
||||||
this.antennas[idx] = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
|
|
||||||
...body,
|
|
||||||
lastUsedAt: new Date(body.lastUsedAt),
|
|
||||||
user: null, // joinなカラムは通常取ってこないので
|
|
||||||
userList: null, // joinなカラムは通常取ってこないので
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// サーバ起動時にactiveじゃなかった場合、リストに持っていないので追加する必要あり
|
|
||||||
this.antennas.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
|
|
||||||
...body,
|
|
||||||
lastUsedAt: new Date(body.lastUsedAt),
|
|
||||||
user: null, // joinなカラムは通常取ってこないので
|
|
||||||
userList: null, // joinなカラムは通常取ってこないので
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'antennaDeleted':
|
case 'antennaDeleted':
|
||||||
this.antennas = this.antennas.filter(a => a.id !== body.id);
|
this.antennas.delete(body.id);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|
@ -94,10 +79,27 @@ export class AntennaService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async updateAntenna(id: string, data: Partial<MiAntenna>) {
|
||||||
|
await this.antennasRepository.update({ id }, data);
|
||||||
|
|
||||||
|
const antenna = this.antennas.get(id) ?? await this.antennasRepository.findOneBy({ id });
|
||||||
|
if (antenna) {
|
||||||
|
// This will be handled above to save result
|
||||||
|
await this.internalEventService.emit('antennaUpdated', {
|
||||||
|
...antenna,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<void> {
|
public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<void> {
|
||||||
const antennas = await this.getAntennas();
|
const antennas = await this.getAntennas();
|
||||||
const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const)));
|
const antennasWithMatchResult = await promiseMap(antennas, async antenna => {
|
||||||
|
const hit = await this.checkHitAntenna(antenna, note, noteUser);
|
||||||
|
return [antenna, hit] as const;
|
||||||
|
});
|
||||||
const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna);
|
const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna);
|
||||||
|
|
||||||
const redisPipeline = this.redisForTimelines.pipeline();
|
const redisPipeline = this.redisForTimelines.pipeline();
|
||||||
|
|
@ -107,7 +109,7 @@ export class AntennaService implements OnApplicationShutdown {
|
||||||
this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
|
this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
|
||||||
}
|
}
|
||||||
|
|
||||||
redisPipeline.exec();
|
await redisPipeline.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
|
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
|
||||||
|
|
@ -212,13 +214,14 @@ export class AntennaService implements OnApplicationShutdown {
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getAntennas() {
|
public async getAntennas() {
|
||||||
if (!this.antennasFetched) {
|
if (!this.antennasFetched) {
|
||||||
this.antennas = await this.antennasRepository.findBy({
|
const allAntennas = await this.antennasRepository.findBy({
|
||||||
isActive: true,
|
isActive: true,
|
||||||
});
|
});
|
||||||
|
this.antennas = new Map(allAntennas.map(a => [a.id, a]));
|
||||||
this.antennasFetched = true;
|
this.antennasFetched = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.antennas;
|
return Array.from(this.antennas.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ import { JsonValue } from '@/misc/json-value.js';
|
||||||
import { UtilityService } from '@/core/UtilityService.js';
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import { TimeService } from '@/global/TimeService.js';
|
import { TimeService } from '@/global/TimeService.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { IActivity, IObject } from './activitypub/type.js';
|
import { IActivity, IObject } from '@/core/activitypub/type.js';
|
||||||
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { QueueService } from '@/core/QueueService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApLogService {
|
export class ApLogService {
|
||||||
|
|
@ -23,7 +25,7 @@ export class ApLogService {
|
||||||
private readonly config: Config,
|
private readonly config: Config,
|
||||||
|
|
||||||
@Inject(DI.apContextsRepository)
|
@Inject(DI.apContextsRepository)
|
||||||
private apContextsRepository: ApContextsRepository,
|
private readonly apContextsRepository: ApContextsRepository,
|
||||||
|
|
||||||
@Inject(DI.apInboxLogsRepository)
|
@Inject(DI.apInboxLogsRepository)
|
||||||
private readonly apInboxLogsRepository: ApInboxLogsRepository,
|
private readonly apInboxLogsRepository: ApInboxLogsRepository,
|
||||||
|
|
@ -34,6 +36,7 @@ export class ApLogService {
|
||||||
private readonly utilityService: UtilityService,
|
private readonly utilityService: UtilityService,
|
||||||
private readonly idService: IdService,
|
private readonly idService: IdService,
|
||||||
private readonly timeService: TimeService,
|
private readonly timeService: TimeService,
|
||||||
|
private readonly queueService: QueueService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -123,6 +126,16 @@ export class ApLogService {
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async deleteObjectLogsDeferred(objectUris: string | string[]): Promise<void> {
|
||||||
|
await this.queueService.createDeleteApLogsJob('object', objectUris);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async deleteInboxLogsDeferred(userIds: string | string[]): Promise<void> {
|
||||||
|
await this.queueService.createDeleteApLogsJob('inbox', userIds);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes all logged copies of an object or objects
|
* Deletes all logged copies of an object or objects
|
||||||
* @param objectUris URIs / AP IDs of the objects to delete
|
* @param objectUris URIs / AP IDs of the objects to delete
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ export class AvatarDecorationService implements OnApplicationShutdown {
|
||||||
if (noCache) {
|
if (noCache) {
|
||||||
this.cache.delete();
|
this.cache.delete();
|
||||||
}
|
}
|
||||||
return this.cache.fetch(() => this.avatarDecorationsRepository.find());
|
return await this.cache.fetch(() => this.avatarDecorationsRepository.find());
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
||||||
|
|
@ -605,12 +605,12 @@ export class ChatService {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async findMyRoomById(ownerId: MiUser['id'], roomId: MiChatRoom['id']) {
|
public async findMyRoomById(ownerId: MiUser['id'], roomId: MiChatRoom['id']) {
|
||||||
return this.chatRoomsRepository.findOneBy({ id: roomId, ownerId: ownerId });
|
return await this.chatRoomsRepository.findOneBy({ id: roomId, ownerId: ownerId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async findRoomById(roomId: MiChatRoom['id']) {
|
public async findRoomById(roomId: MiChatRoom['id']) {
|
||||||
return this.chatRoomsRepository.findOne({ where: { id: roomId }, relations: ['owner'] });
|
return await this.chatRoomsRepository.findOne({ where: { id: roomId }, relations: ['owner'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { bindThis } from '@/decorators.js';
|
||||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
|
||||||
import type { MiLocalUser } from '@/models/User.js';
|
import type { MiLocalUser } from '@/models/User.js';
|
||||||
import { TimeService } from '@/global/TimeService.js';
|
import { TimeService } from '@/global/TimeService.js';
|
||||||
|
|
||||||
|
|
@ -35,6 +36,7 @@ export class ClipService {
|
||||||
private roleService: RoleService,
|
private roleService: RoleService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private readonly timeService: TimeService,
|
private readonly timeService: TimeService,
|
||||||
|
private readonly collapsedQueueService: CollapsedQueueService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,7 +132,7 @@ export class ClipService {
|
||||||
lastClippedAt: this.timeService.date,
|
lastClippedAt: this.timeService.date,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.notesRepository.increment({ id: noteId }, 'clippedCount', 1);
|
await this.collapsedQueueService.updateNoteQueue.enqueue(noteId, { clippedCountDelta: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -155,6 +157,6 @@ export class ClipService {
|
||||||
clipId: clip.id,
|
clipId: clip.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.notesRepository.decrement({ id: noteId }, 'clippedCount', 1);
|
await this.collapsedQueueService.updateNoteQueue.enqueue(noteId, { clippedCountDelta: -1 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
383
packages/backend/src/core/CollapsedQueueService.ts
Normal file
383
packages/backend/src/core/CollapsedQueueService.ts
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
|
import type Logger from '@/logger.js';
|
||||||
|
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
|
||||||
|
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||||
|
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||||
|
import { EnvService } from '@/global/EnvService.js';
|
||||||
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { InternalEventService } from '@/global/InternalEventService.js';
|
||||||
|
import type { UsersRepository, NotesRepository, AccessTokensRepository, MiAntenna, FollowingsRepository } from '@/models/_.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { AntennaService } from '@/core/AntennaService.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
import { TimeService } from '@/global/TimeService.js';
|
||||||
|
|
||||||
|
export type UpdateInstanceJob = {
|
||||||
|
latestRequestReceivedAt?: Date,
|
||||||
|
notRespondingSince?: Date | null,
|
||||||
|
shouldUnsuspend?: boolean,
|
||||||
|
shouldSuspendGone?: boolean,
|
||||||
|
shouldSuspendNotResponding?: boolean,
|
||||||
|
notesCountDelta?: number,
|
||||||
|
usersCountDelta?: number,
|
||||||
|
followingCountDelta?: number,
|
||||||
|
followersCountDelta?: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateUserJob = {
|
||||||
|
updatedAt?: Date,
|
||||||
|
lastActiveDate?: Date,
|
||||||
|
notesCountDelta?: number,
|
||||||
|
followingCountDelta?: number,
|
||||||
|
followersCountDelta?: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateNoteJob = {
|
||||||
|
repliesCountDelta?: number;
|
||||||
|
renoteCountDelta?: number;
|
||||||
|
clippedCountDelta?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateAccessTokenJob = {
|
||||||
|
lastUsedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateAntennaJob = {
|
||||||
|
isActive: boolean,
|
||||||
|
lastUsedAt?: Date,
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CollapsedQueueService implements OnApplicationShutdown {
|
||||||
|
// Moved from InboxProcessorService
|
||||||
|
public readonly updateInstanceQueue: CollapsedQueue<UpdateInstanceJob>;
|
||||||
|
|
||||||
|
// Moved from NoteCreateService, NoteEditService, and NoteDeleteService
|
||||||
|
public readonly updateUserQueue: CollapsedQueue<UpdateUserJob>;
|
||||||
|
|
||||||
|
public readonly updateNoteQueue: CollapsedQueue<UpdateNoteJob>;
|
||||||
|
public readonly updateAccessTokenQueue: CollapsedQueue<UpdateAccessTokenJob>;
|
||||||
|
public readonly updateAntennaQueue: CollapsedQueue<UpdateAntennaJob>;
|
||||||
|
|
||||||
|
private readonly logger: Logger;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.usersRepository)
|
||||||
|
private readonly usersRepository: UsersRepository,
|
||||||
|
|
||||||
|
@Inject(DI.notesRepository)
|
||||||
|
private readonly notesRepository: NotesRepository,
|
||||||
|
|
||||||
|
@Inject(DI.accessTokensRepository)
|
||||||
|
private readonly accessTokensRepository: AccessTokensRepository,
|
||||||
|
|
||||||
|
@Inject(DI.followingsRepository)
|
||||||
|
private readonly followingsRepository: FollowingsRepository,
|
||||||
|
|
||||||
|
private readonly federatedInstanceService: FederatedInstanceService,
|
||||||
|
private readonly envService: EnvService,
|
||||||
|
private readonly internalEventService: InternalEventService,
|
||||||
|
private readonly antennaService: AntennaService,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
|
private readonly timeService: TimeService,
|
||||||
|
|
||||||
|
loggerService: LoggerService,
|
||||||
|
) {
|
||||||
|
this.logger = loggerService.getLogger('collapsed-queue');
|
||||||
|
|
||||||
|
const fiveMinuteInterval = this.envService.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0;
|
||||||
|
const oneMinuteInterval = this.envService.env.NODE_ENV !== 'test' ? 60 * 1000 : 0;
|
||||||
|
|
||||||
|
this.updateInstanceQueue = new CollapsedQueue(
|
||||||
|
this.internalEventService,
|
||||||
|
this.timeService,
|
||||||
|
'updateInstance',
|
||||||
|
fiveMinuteInterval,
|
||||||
|
(oldJob, newJob) => ({
|
||||||
|
latestRequestReceivedAt: maxDate(oldJob.latestRequestReceivedAt, newJob.latestRequestReceivedAt),
|
||||||
|
notRespondingSince: maxDate(oldJob.notRespondingSince, newJob.notRespondingSince),
|
||||||
|
shouldUnsuspend: oldJob.shouldUnsuspend || newJob.shouldUnsuspend,
|
||||||
|
shouldSuspendGone: oldJob.shouldSuspendGone || newJob.shouldSuspendGone,
|
||||||
|
shouldSuspendNotResponding: oldJob.shouldSuspendNotResponding || newJob.shouldSuspendNotResponding,
|
||||||
|
notesCountDelta: (oldJob.notesCountDelta ?? 0) + (newJob.notesCountDelta ?? 0),
|
||||||
|
usersCountDelta: (oldJob.usersCountDelta ?? 0) + (newJob.usersCountDelta ?? 0),
|
||||||
|
followingCountDelta: (oldJob.followingCountDelta ?? 0) + (newJob.followingCountDelta ?? 0),
|
||||||
|
followersCountDelta: (oldJob.followersCountDelta ?? 0) + (newJob.followersCountDelta ?? 0),
|
||||||
|
}),
|
||||||
|
async (id, job) => {
|
||||||
|
// Have to check this because all properties are optional
|
||||||
|
if (
|
||||||
|
job.latestRequestReceivedAt ||
|
||||||
|
job.notRespondingSince !== undefined ||
|
||||||
|
job.shouldSuspendNotResponding ||
|
||||||
|
job.shouldSuspendGone ||
|
||||||
|
job.shouldUnsuspend ||
|
||||||
|
job.notesCountDelta ||
|
||||||
|
job.usersCountDelta ||
|
||||||
|
job.followingCountDelta ||
|
||||||
|
job.followersCountDelta
|
||||||
|
) {
|
||||||
|
await this.federatedInstanceService.update(id, {
|
||||||
|
// Direct update if defined
|
||||||
|
latestRequestReceivedAt: job.latestRequestReceivedAt,
|
||||||
|
|
||||||
|
// null (responding) > Date (not responding)
|
||||||
|
notRespondingSince: job.latestRequestReceivedAt
|
||||||
|
? null
|
||||||
|
: job.notRespondingSince,
|
||||||
|
|
||||||
|
// false (responding) > true (not responding)
|
||||||
|
isNotResponding: job.latestRequestReceivedAt
|
||||||
|
? false
|
||||||
|
: job.notRespondingSince
|
||||||
|
? true
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
// gone > none > auto
|
||||||
|
suspensionState: job.shouldSuspendGone
|
||||||
|
? 'goneSuspended'
|
||||||
|
: job.shouldUnsuspend
|
||||||
|
? 'none'
|
||||||
|
: job.shouldSuspendNotResponding
|
||||||
|
? 'autoSuspendedForNotResponding'
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
// Increment if defined
|
||||||
|
notesCount: job.notesCountDelta ? () => `"notesCount" + ${job.notesCountDelta}` : undefined,
|
||||||
|
usersCount: job.usersCountDelta ? () => `"usersCount" + ${job.usersCountDelta}` : undefined,
|
||||||
|
followingCount: job.followingCountDelta ? () => `"followingCount" + ${job.followingCountDelta}` : undefined,
|
||||||
|
followersCount: job.followersCountDelta ? () => `"followersCount" + ${job.followersCountDelta}` : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: this.onQueueError,
|
||||||
|
concurrency: 2, // Low concurrency, this table is slow for some reason
|
||||||
|
redisParser: data => ({
|
||||||
|
...data,
|
||||||
|
latestRequestReceivedAt: data.latestRequestReceivedAt != null
|
||||||
|
? new Date(data.latestRequestReceivedAt)
|
||||||
|
: data.latestRequestReceivedAt,
|
||||||
|
notRespondingSince: data.notRespondingSince != null
|
||||||
|
? new Date(data.notRespondingSince)
|
||||||
|
: data.notRespondingSince,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.updateUserQueue = new CollapsedQueue(
|
||||||
|
this.internalEventService,
|
||||||
|
this.timeService,
|
||||||
|
'updateUser',
|
||||||
|
oneMinuteInterval,
|
||||||
|
(oldJob, newJob) => ({
|
||||||
|
updatedAt: maxDate(oldJob.updatedAt, newJob.updatedAt),
|
||||||
|
lastActiveDate: maxDate(oldJob.lastActiveDate, newJob.lastActiveDate),
|
||||||
|
notesCountDelta: (oldJob.notesCountDelta ?? 0) + (newJob.notesCountDelta ?? 0),
|
||||||
|
followingCountDelta: (oldJob.followingCountDelta ?? 0) + (newJob.followingCountDelta ?? 0),
|
||||||
|
followersCountDelta: (oldJob.followersCountDelta ?? 0) + (newJob.followersCountDelta ?? 0),
|
||||||
|
}),
|
||||||
|
async (id, job) => {
|
||||||
|
// Have to check this because all properties are optional
|
||||||
|
if (job.updatedAt || job.lastActiveDate || job.notesCountDelta || job.followingCountDelta || job.followersCountDelta) {
|
||||||
|
// Updating the user should implicitly mark them as active
|
||||||
|
const lastActiveDate = job.lastActiveDate ?? job.updatedAt;
|
||||||
|
const isWakingUp = lastActiveDate && (await this.cacheService.findUserById(id)).isHibernated;
|
||||||
|
|
||||||
|
// Update user before the hibernation cache, because the latter may refresh from DB
|
||||||
|
await this.usersRepository.update({ id }, {
|
||||||
|
updatedAt: job.updatedAt,
|
||||||
|
lastActiveDate,
|
||||||
|
isHibernated: isWakingUp ? false : undefined,
|
||||||
|
notesCount: job.notesCountDelta ? () => `"notesCount" + ${job.notesCountDelta}` : undefined,
|
||||||
|
followingCount: job.followingCountDelta ? () => `"followingCount" + ${job.followingCountDelta}` : undefined,
|
||||||
|
followersCount: job.followersCountDelta ? () => `"followersCount" + ${job.followersCountDelta}` : undefined,
|
||||||
|
});
|
||||||
|
await this.internalEventService.emit('userUpdated', { id });
|
||||||
|
|
||||||
|
// Wake up hibernated users
|
||||||
|
if (isWakingUp) {
|
||||||
|
await this.followingsRepository.update({ followerId: id }, { isFollowerHibernated: false });
|
||||||
|
await this.cacheService.hibernatedUserCache.set(id, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: this.onQueueError,
|
||||||
|
concurrency: 4, // High concurrency - this queue gets a lot of activity
|
||||||
|
redisParser: data => ({
|
||||||
|
...data,
|
||||||
|
updatedAt: data.updatedAt != null
|
||||||
|
? new Date(data.updatedAt)
|
||||||
|
: data.updatedAt,
|
||||||
|
lastActiveDate: data.lastActiveDate != null
|
||||||
|
? new Date(data.lastActiveDate)
|
||||||
|
: data.lastActiveDate,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.updateNoteQueue = new CollapsedQueue(
|
||||||
|
this.internalEventService,
|
||||||
|
this.timeService,
|
||||||
|
'updateNote',
|
||||||
|
oneMinuteInterval,
|
||||||
|
(oldJob, newJob) => ({
|
||||||
|
repliesCountDelta: (oldJob.repliesCountDelta ?? 0) + (newJob.repliesCountDelta ?? 0),
|
||||||
|
renoteCountDelta: (oldJob.renoteCountDelta ?? 0) + (newJob.renoteCountDelta ?? 0),
|
||||||
|
clippedCountDelta: (oldJob.clippedCountDelta ?? 0) + (newJob.clippedCountDelta ?? 0),
|
||||||
|
}),
|
||||||
|
async (id, job) => {
|
||||||
|
// Have to check this because all properties are optional
|
||||||
|
if (job.repliesCountDelta || job.renoteCountDelta || job.clippedCountDelta) {
|
||||||
|
await this.notesRepository.update({ id }, {
|
||||||
|
repliesCount: job.repliesCountDelta ? () => `"repliesCount" + ${job.repliesCountDelta}` : undefined,
|
||||||
|
renoteCount: job.renoteCountDelta ? () => `"renoteCount" + ${job.renoteCountDelta}` : undefined,
|
||||||
|
clippedCount: job.clippedCountDelta ? () => `"clippedCount" + ${job.clippedCountDelta}` : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: this.onQueueError,
|
||||||
|
concurrency: 4, // High concurrency - this queue gets a lot of activity
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.updateAccessTokenQueue = new CollapsedQueue(
|
||||||
|
this.internalEventService,
|
||||||
|
this.timeService,
|
||||||
|
'updateAccessToken',
|
||||||
|
fiveMinuteInterval,
|
||||||
|
(oldJob, newJob) => ({
|
||||||
|
lastUsedAt: maxDate(oldJob.lastUsedAt, newJob.lastUsedAt),
|
||||||
|
}),
|
||||||
|
async (id, job) => await this.accessTokensRepository.update({ id }, {
|
||||||
|
lastUsedAt: job.lastUsedAt,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
onError: this.onQueueError,
|
||||||
|
concurrency: 2,
|
||||||
|
redisParser: data => ({
|
||||||
|
...data,
|
||||||
|
lastUsedAt: new Date(data.lastUsedAt),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.updateAntennaQueue = new CollapsedQueue(
|
||||||
|
this.internalEventService,
|
||||||
|
this.timeService,
|
||||||
|
'updateAntenna',
|
||||||
|
fiveMinuteInterval,
|
||||||
|
(oldJob, newJob) => ({
|
||||||
|
isActive: oldJob.isActive || newJob.isActive,
|
||||||
|
lastUsedAt: maxDate(oldJob.lastUsedAt, newJob.lastUsedAt),
|
||||||
|
}),
|
||||||
|
async (id, job) => await this.antennaService.updateAntenna(id, {
|
||||||
|
isActive: job.isActive,
|
||||||
|
lastUsedAt: job.lastUsedAt,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
onError: this.onQueueError,
|
||||||
|
concurrency: 4,
|
||||||
|
redisParser: data => ({
|
||||||
|
...data,
|
||||||
|
lastUsedAt: data.lastUsedAt != null
|
||||||
|
? new Date(data.lastUsedAt)
|
||||||
|
: data.lastUsedAt,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.internalEventService.on('userChangeDeletedState', this.onUserDeleted);
|
||||||
|
this.internalEventService.on('antennaDeleted', this.onAntennaDeleted);
|
||||||
|
this.internalEventService.on('antennaUpdated', this.onAntennaDeleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async performQueue<V>(queue: CollapsedQueue<V>): Promise<void> {
|
||||||
|
try {
|
||||||
|
const results = await queue.performAllNow();
|
||||||
|
|
||||||
|
const [succeeded, failed] = results.reduce((counts, result) => {
|
||||||
|
counts[result ? 0 : 1]++;
|
||||||
|
return counts;
|
||||||
|
}, [0, 0]);
|
||||||
|
|
||||||
|
this.logger.debug(`Persistence completed for ${queue.name}: ${succeeded} succeeded and ${failed} failed`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Persistence failed for ${queue.name}: ${renderInlineError(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private onQueueError<V>(queue: CollapsedQueue<V>, error: unknown): void {
|
||||||
|
this.logger.error(`Error persisting ${queue.name}: ${renderInlineError(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async onUserDeleted(data: { id: string, isDeleted: boolean }) {
|
||||||
|
if (data.isDeleted) {
|
||||||
|
await this.updateUserQueue.delete(data.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async onAntennaDeleted(data: MiAntenna) {
|
||||||
|
await this.updateAntennaQueue.delete(data.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
async dispose() {
|
||||||
|
this.internalEventService.off('userChangeDeletedState', this.onUserDeleted);
|
||||||
|
this.internalEventService.off('antennaDeleted', this.onAntennaDeleted);
|
||||||
|
this.internalEventService.off('antennaUpdated', this.onAntennaDeleted);
|
||||||
|
|
||||||
|
this.logger.info('Persisting all collapsed queues...');
|
||||||
|
|
||||||
|
await this.performQueue(this.updateInstanceQueue);
|
||||||
|
await this.performQueue(this.updateUserQueue);
|
||||||
|
await this.performQueue(this.updateNoteQueue);
|
||||||
|
await this.performQueue(this.updateAccessTokenQueue);
|
||||||
|
await this.performQueue(this.updateAntennaQueue);
|
||||||
|
|
||||||
|
this.logger.info('Persistence complete.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onApplicationShutdown() {
|
||||||
|
await this.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxDate(first: Date | undefined, second: Date): Date;
|
||||||
|
function maxDate(first: Date, second: Date | undefined): Date;
|
||||||
|
function maxDate(first: Date | undefined, second: Date | undefined): Date | undefined;
|
||||||
|
function maxDate(first: Date | null | undefined, second: Date | null | undefined): Date | null | undefined;
|
||||||
|
|
||||||
|
function maxDate(first: Date | null | undefined, second: Date | null | undefined): Date | null | undefined {
|
||||||
|
if (first !== undefined && second !== undefined) {
|
||||||
|
if (first != null && second != null) {
|
||||||
|
if (first.getTime() > second.getTime()) {
|
||||||
|
return first;
|
||||||
|
} else {
|
||||||
|
return second;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Null is considered infinitely in the future, and is therefore newer than any date.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else if (first !== undefined) {
|
||||||
|
return first;
|
||||||
|
} else if (second !== undefined) {
|
||||||
|
return second;
|
||||||
|
} else {
|
||||||
|
// Undefined in considered infinitely in the past, and is therefore older than any date.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,7 @@ import { WebhookTestService } from '@/core/WebhookTestService.js';
|
||||||
import { FlashService } from '@/core/FlashService.js';
|
import { FlashService } from '@/core/FlashService.js';
|
||||||
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
||||||
import { ApLogService } from '@/core/ApLogService.js';
|
import { ApLogService } from '@/core/ApLogService.js';
|
||||||
import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js';
|
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
|
||||||
import { InstanceStatsService } from '@/core/InstanceStatsService.js';
|
import { InstanceStatsService } from '@/core/InstanceStatsService.js';
|
||||||
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
|
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
|
||||||
import { AccountMoveService } from './AccountMoveService.js';
|
import { AccountMoveService } from './AccountMoveService.js';
|
||||||
|
|
@ -218,7 +218,7 @@ const $UserRenoteMutingService: Provider = { provide: 'UserRenoteMutingService',
|
||||||
const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService };
|
const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService };
|
||||||
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
|
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
|
||||||
const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService };
|
const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService };
|
||||||
const $UpdateInstanceQueue: Provider = { provide: 'UpdateInstanceQueue', useExisting: UpdateInstanceQueue };
|
const $CollapsedQueueService: Provider = { provide: 'CollapsedQueueService', useExisting: CollapsedQueueService };
|
||||||
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
|
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
|
||||||
const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService };
|
const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService };
|
||||||
const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService };
|
const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService };
|
||||||
|
|
@ -377,7 +377,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
||||||
UserSearchService,
|
UserSearchService,
|
||||||
UserSuspendService,
|
UserSuspendService,
|
||||||
UserAuthService,
|
UserAuthService,
|
||||||
UpdateInstanceQueue,
|
CollapsedQueueService,
|
||||||
VideoProcessingService,
|
VideoProcessingService,
|
||||||
UserWebhookService,
|
UserWebhookService,
|
||||||
SystemWebhookService,
|
SystemWebhookService,
|
||||||
|
|
@ -531,7 +531,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
||||||
$UserSearchService,
|
$UserSearchService,
|
||||||
$UserSuspendService,
|
$UserSuspendService,
|
||||||
$UserAuthService,
|
$UserAuthService,
|
||||||
$UpdateInstanceQueue,
|
$CollapsedQueueService,
|
||||||
$VideoProcessingService,
|
$VideoProcessingService,
|
||||||
$UserWebhookService,
|
$UserWebhookService,
|
||||||
$SystemWebhookService,
|
$SystemWebhookService,
|
||||||
|
|
@ -686,7 +686,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
||||||
UserSearchService,
|
UserSearchService,
|
||||||
UserSuspendService,
|
UserSuspendService,
|
||||||
UserAuthService,
|
UserAuthService,
|
||||||
UpdateInstanceQueue,
|
CollapsedQueueService,
|
||||||
VideoProcessingService,
|
VideoProcessingService,
|
||||||
UserWebhookService,
|
UserWebhookService,
|
||||||
SystemWebhookService,
|
SystemWebhookService,
|
||||||
|
|
@ -839,7 +839,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
||||||
$UserSearchService,
|
$UserSearchService,
|
||||||
$UserSuspendService,
|
$UserSuspendService,
|
||||||
$UserAuthService,
|
$UserAuthService,
|
||||||
$UpdateInstanceQueue,
|
$CollapsedQueueService,
|
||||||
$VideoProcessingService,
|
$VideoProcessingService,
|
||||||
$UserWebhookService,
|
$UserWebhookService,
|
||||||
$SystemWebhookService,
|
$SystemWebhookService,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { DriveService } from '@/core/DriveService.js';
|
||||||
import { CacheManagementService, type ManagedQuantumKVCache } from '@/global/CacheManagementService.js';
|
import { CacheManagementService, type ManagedQuantumKVCache } from '@/global/CacheManagementService.js';
|
||||||
import { TimeService } from '@/global/TimeService.js';
|
import { TimeService } from '@/global/TimeService.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.js';
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
|
import { promiseMap } from '@/misc/promise-map.js';
|
||||||
import { isRetryableSymbol } from '@/misc/is-retryable-error.js';
|
import { isRetryableSymbol } from '@/misc/is-retryable-error.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { KeyNotFoundError } from '@/misc/errors/KeyNotFoundError.js';
|
import { KeyNotFoundError } from '@/misc/errors/KeyNotFoundError.js';
|
||||||
|
|
@ -577,7 +578,7 @@ export class CustomEmojiService {
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<Record<string, string>> {
|
public async populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<Record<string, string>> {
|
||||||
const emojis = await Promise.all(emojiNames.map(x => this.populateEmoji(x, noteUserHost)));
|
const emojis = await promiseMap(emojiNames, async x => await this.populateEmoji(x, noteUserHost), { limit: 4 });
|
||||||
const res = {} as Record<string, string>;
|
const res = {} as Record<string, string>;
|
||||||
for (let i = 0; i < emojiNames.length; i++) {
|
for (let i = 0; i < emojiNames.length; i++) {
|
||||||
const resolvedEmoji = emojis[i];
|
const resolvedEmoji = emojis[i];
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,7 @@ export class DriveService {
|
||||||
|
|
||||||
//#region Uploads
|
//#region Uploads
|
||||||
this.registerLogger.debug(`uploading original: ${key}`);
|
this.registerLogger.debug(`uploading original: ${key}`);
|
||||||
const uploads = [
|
const uploads: Promise<void>[] = [
|
||||||
this.upload(key, fs.createReadStream(path), type, null, name),
|
this.upload(key, fs.createReadStream(path), type, null, name),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -470,7 +470,7 @@ export class DriveService {
|
||||||
for (const fileId of exceedFileIds) {
|
for (const fileId of exceedFileIds) {
|
||||||
const file = await this.driveFilesRepository.findOneBy({ id: fileId });
|
const file = await this.driveFilesRepository.findOneBy({ id: fileId });
|
||||||
if (file == null) continue;
|
if (file == null) continue;
|
||||||
this.deleteFile(file, true);
|
await this.deleteFile(file, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -718,14 +718,14 @@ export class DriveService {
|
||||||
if (values.isSensitive !== undefined && values.isSensitive !== file.isSensitive) {
|
if (values.isSensitive !== undefined && values.isSensitive !== file.isSensitive) {
|
||||||
const user = file.userId ? await this.usersRepository.findOneByOrFail({ id: file.userId }) : null;
|
const user = file.userId ? await this.usersRepository.findOneByOrFail({ id: file.userId }) : null;
|
||||||
if (values.isSensitive) {
|
if (values.isSensitive) {
|
||||||
this.moderationLogService.log(updater, 'markSensitiveDriveFile', {
|
await this.moderationLogService.log(updater, 'markSensitiveDriveFile', {
|
||||||
fileId: file.id,
|
fileId: file.id,
|
||||||
fileUserId: file.userId,
|
fileUserId: file.userId,
|
||||||
fileUserUsername: user?.username ?? null,
|
fileUserUsername: user?.username ?? null,
|
||||||
fileUserHost: user?.host ?? null,
|
fileUserHost: user?.host ?? null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.moderationLogService.log(updater, 'unmarkSensitiveDriveFile', {
|
await this.moderationLogService.log(updater, 'unmarkSensitiveDriveFile', {
|
||||||
fileId: file.id,
|
fileId: file.id,
|
||||||
fileUserId: file.userId,
|
fileUserId: file.userId,
|
||||||
fileUserUsername: user?.username ?? null,
|
fileUserUsername: user?.username ?? null,
|
||||||
|
|
@ -740,29 +740,7 @@ export class DriveService {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async deleteFile(file: MiDriveFile, isExpired = false, deleter?: { id: string }) {
|
public async deleteFile(file: MiDriveFile, isExpired = false, deleter?: { id: string }) {
|
||||||
if (file.storedInternal) {
|
await this.queueService.createDeleteFileJob(file.id, isExpired, deleter?.id);
|
||||||
this.deleteLocalFile(file.accessKey!);
|
|
||||||
|
|
||||||
if (file.thumbnailUrl) {
|
|
||||||
this.deleteLocalFile(file.thumbnailAccessKey!);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.webpublicUrl) {
|
|
||||||
this.deleteLocalFile(file.webpublicAccessKey!);
|
|
||||||
}
|
|
||||||
} else if (!file.isLink) {
|
|
||||||
this.queueService.createDeleteObjectStorageFileJob(file.accessKey!);
|
|
||||||
|
|
||||||
if (file.thumbnailUrl) {
|
|
||||||
this.queueService.createDeleteObjectStorageFileJob(file.thumbnailAccessKey!);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.webpublicUrl) {
|
|
||||||
this.queueService.createDeleteObjectStorageFileJob(file.webpublicAccessKey!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.deletePostProcess(file, isExpired, deleter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -793,14 +771,14 @@ export class DriveService {
|
||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
|
|
||||||
this.deletePostProcess(file, isExpired, deleter);
|
await this.deletePostProcess(file, isExpired, deleter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async deletePostProcess(file: MiDriveFile, isExpired = false, deleter?: { id: string }) {
|
private async deletePostProcess(file: MiDriveFile, isExpired = false, deleter?: { id: string }) {
|
||||||
// リモートファイル期限切れ削除後は直リンクにする
|
// リモートファイル期限切れ削除後は直リンクにする
|
||||||
if (isExpired && file.userHost !== null && file.uri != null) {
|
if (isExpired && file.userHost !== null && file.uri != null) {
|
||||||
this.driveFilesRepository.update(file.id, {
|
await this.driveFilesRepository.update(file.id, {
|
||||||
isLink: true,
|
isLink: true,
|
||||||
url: file.uri,
|
url: file.uri,
|
||||||
thumbnailUrl: null,
|
thumbnailUrl: null,
|
||||||
|
|
@ -812,7 +790,7 @@ export class DriveService {
|
||||||
webpublicAccessKey: 'webpublic-' + randomUUID(),
|
webpublicAccessKey: 'webpublic-' + randomUUID(),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.driveFilesRepository.delete(file.id);
|
await this.driveFilesRepository.delete(file.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.driveChart.update(file, false);
|
this.driveChart.update(file, false);
|
||||||
|
|
@ -831,7 +809,7 @@ export class DriveService {
|
||||||
|
|
||||||
if (deleter && await this.roleService.isModerator(deleter) && (file.userId !== deleter.id)) {
|
if (deleter && await this.roleService.isModerator(deleter) && (file.userId !== deleter.id)) {
|
||||||
const user = file.userId ? await this.usersRepository.findOneByOrFail({ id: file.userId }) : null;
|
const user = file.userId ? await this.usersRepository.findOneByOrFail({ id: file.userId }) : null;
|
||||||
this.moderationLogService.log(deleter, 'deleteDriveFile', {
|
await this.moderationLogService.log(deleter, 'deleteDriveFile', {
|
||||||
fileId: file.id,
|
fileId: file.id,
|
||||||
fileUserId: file.userId,
|
fileUserId: file.userId,
|
||||||
fileUserUsername: user?.username ?? null,
|
fileUserUsername: user?.username ?? null,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { bindThis } from '@/decorators.js';
|
||||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||||
import { TimeService } from '@/global/TimeService.js';
|
import { TimeService } from '@/global/TimeService.js';
|
||||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||||
|
import { QueueService } from '@/core/QueueService.js';
|
||||||
import type { CheerioAPI } from 'cheerio/slim';
|
import type { CheerioAPI } from 'cheerio/slim';
|
||||||
|
|
||||||
type NodeInfo = {
|
type NodeInfo = {
|
||||||
|
|
@ -50,6 +51,7 @@ export class FetchInstanceMetadataService {
|
||||||
private redisClient: Redis.Redis,
|
private redisClient: Redis.Redis,
|
||||||
|
|
||||||
private readonly timeService: TimeService,
|
private readonly timeService: TimeService,
|
||||||
|
private readonly queueService: QueueService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.loggerService.getLogger('metadata', 'cyan');
|
this.logger = this.loggerService.getLogger('metadata', 'cyan');
|
||||||
}
|
}
|
||||||
|
|
@ -73,8 +75,21 @@ export class FetchInstanceMetadataService {
|
||||||
return this.redisClient.del(`fetchInstanceMetadata:mutex:v2:${host}`);
|
return this.redisClient.del(`fetchInstanceMetadata:mutex:v2:${host}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules a deferred update on the background task worker.
|
||||||
|
* Duplicate updates are automatically skipped.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async fetchInstanceMetadataLazy(instance: MiInstance): Promise<void> {
|
||||||
|
if (!instance.isBlocked) {
|
||||||
|
await this.queueService.createUpdateInstanceJob(instance.host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async fetchInstanceMetadata(instance: MiInstance, force = false): Promise<void> {
|
public async fetchInstanceMetadata(instance: MiInstance, force = false): Promise<void> {
|
||||||
|
if (instance.isBlocked) return;
|
||||||
|
|
||||||
const host = instance.host;
|
const host = instance.host;
|
||||||
|
|
||||||
// finallyでunlockされてしまうのでtry内でロックチェックをしない
|
// finallyでunlockされてしまうのでtry内でロックチェックをしない
|
||||||
|
|
@ -110,25 +125,30 @@ export class FetchInstanceMetadataService {
|
||||||
this.getDescription(info, dom, manifest).catch(() => null),
|
this.getDescription(info, dom, manifest).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.logger.debug(`Successfuly fetched metadata of ${instance.host}`);
|
this.logger.debug(`Successfully fetched metadata of ${instance.host}`);
|
||||||
|
|
||||||
const updates = {
|
const updates = {
|
||||||
infoUpdatedAt: this.timeService.date,
|
infoUpdatedAt: this.timeService.date,
|
||||||
} as Record<string, any>;
|
} as Record<string, any>;
|
||||||
|
|
||||||
if (info) {
|
if (info) {
|
||||||
updates.softwareName = typeof info.software?.name === 'string' ? info.software.name.toLowerCase() : '?';
|
const softwareName = typeof info.software?.name === 'string' ? info.software.name.toLowerCase() : '?';
|
||||||
updates.softwareVersion = info.software?.version;
|
if (softwareName !== instance.softwareName) updates.softwareName = softwareName;
|
||||||
updates.openRegistrations = info.openRegistrations;
|
const softwareVersion = typeof info.software?.version === 'string' ? info.software.version.toLowerCase() : '?';
|
||||||
updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name ?? null) : null : null;
|
if (softwareVersion !== instance.softwareVersion) updates.softwareVersion = softwareVersion;
|
||||||
updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email ?? null) : null : null;
|
if (info.openRegistrations !== instance.openRegistrations) updates.openRegistrations = info.openRegistrations;
|
||||||
|
const maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name ?? null) : null : null;
|
||||||
|
if (maintainerName !== instance.maintainerName) updates.maintainerName = maintainerName;
|
||||||
|
const maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email ?? null) : null : null;
|
||||||
|
if (maintainerEmail !== instance.maintainerEmail) updates.maintainerEmail = maintainerEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name) updates.name = name;
|
if (name !== instance.name) updates.name = name;
|
||||||
if (description) updates.description = description;
|
if (description !== instance.description) updates.description = description;
|
||||||
if (icon ?? favicon) updates.iconUrl = (icon && !icon.includes('data:image/png;base64')) ? icon : favicon;
|
const iconUrl = (icon && !icon.includes('data:image/png;base64')) ? icon : favicon;
|
||||||
if (favicon) updates.faviconUrl = favicon;
|
if (iconUrl !== instance.iconUrl) updates.iconUrl = iconUrl;
|
||||||
if (themeColor) updates.themeColor = themeColor;
|
if (favicon !== instance.faviconUrl) updates.faviconUrl = favicon;
|
||||||
|
if (themeColor !== instance.themeColor) updates.themeColor = themeColor;
|
||||||
|
|
||||||
await this.federatedInstanceService.update(instance.id, updates);
|
await this.federatedInstanceService.update(instance.id, updates);
|
||||||
|
|
||||||
|
|
@ -169,10 +189,7 @@ export class FetchInstanceMetadataService {
|
||||||
throw new Error('No nodeinfo link provided');
|
throw new Error('No nodeinfo link provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = await this.httpRequestService.getJson(link.href)
|
const info = await this.httpRequestService.getJson(link.href);
|
||||||
.catch(err => {
|
|
||||||
throw err.statusCode ?? err.message;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.debug(`Successfuly fetched nodeinfo of ${instance.host}`);
|
this.logger.debug(`Successfuly fetched nodeinfo of ${instance.host}`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,8 @@ export interface InternalEventTypes {
|
||||||
userListMemberBulkRemoved: { userListIds: MiUserList['id'][]; memberId: MiUser['id']; };
|
userListMemberBulkRemoved: { userListIds: MiUserList['id'][]; memberId: MiUser['id']; };
|
||||||
quantumCacheUpdated: { name: string, keys: string[] };
|
quantumCacheUpdated: { name: string, keys: string[] };
|
||||||
quantumCacheReset: { name: string };
|
quantumCacheReset: { name: string };
|
||||||
|
collapsedQueueDefer: { name: string, key: string, deferred: boolean };
|
||||||
|
collapsedQueueEnqueue: { name: string, key: string, value: unknown };
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventTypesToEventPayload<T> = EventUnionFromDictionary<UndefinedAsNullAll<SerializedAll<T>>>;
|
type EventTypesToEventPayload<T> = EventUnionFromDictionary<UndefinedAsNullAll<SerializedAll<T>>>;
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export class HashtagService {
|
||||||
tag = normalizeForSearch(tag);
|
tag = normalizeForSearch(tag);
|
||||||
|
|
||||||
// TODO: サンプリング
|
// TODO: サンプリング
|
||||||
this.updateHashtagsRanking(tag, user.id);
|
await this.updateHashtagsRanking(tag, user.id);
|
||||||
|
|
||||||
const index = await this.hashtagsRepository.findOneBy({ name: tag });
|
const index = await this.hashtagsRepository.findOneBy({ name: tag });
|
||||||
|
|
||||||
|
|
@ -119,11 +119,11 @@ export class HashtagService {
|
||||||
|
|
||||||
if (Object.keys(set).length > 0) {
|
if (Object.keys(set).length > 0) {
|
||||||
q.set(set);
|
q.set(set);
|
||||||
q.execute();
|
await q.execute();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (isUserAttached) {
|
if (isUserAttached) {
|
||||||
this.hashtagsRepository.insert({
|
await this.hashtagsRepository.insert({
|
||||||
id: this.idService.gen(),
|
id: this.idService.gen(),
|
||||||
name: tag,
|
name: tag,
|
||||||
mentionedUserIds: [],
|
mentionedUserIds: [],
|
||||||
|
|
@ -140,7 +140,7 @@ export class HashtagService {
|
||||||
attachedRemoteUsersCount: isRemoteUser(user) ? 1 : 0,
|
attachedRemoteUsersCount: isRemoteUser(user) ? 1 : 0,
|
||||||
} as MiHashtag);
|
} as MiHashtag);
|
||||||
} else {
|
} else {
|
||||||
this.hashtagsRepository.insert({
|
await this.hashtagsRepository.insert({
|
||||||
id: this.idService.gen(),
|
id: this.idService.gen(),
|
||||||
name: tag,
|
name: tag,
|
||||||
mentionedUserIds: [user.id],
|
mentionedUserIds: [user.id],
|
||||||
|
|
@ -174,7 +174,7 @@ export class HashtagService {
|
||||||
const exist = await this.redisClient.sismember(`hashtagUsers:${hashtag}`, userId);
|
const exist = await this.redisClient.sismember(`hashtagUsers:${hashtag}`, userId);
|
||||||
if (exist === 1) return;
|
if (exist === 1) return;
|
||||||
|
|
||||||
this.featuredService.updateHashtagsRanking(hashtag, 1);
|
await this.featuredService.updateHashtagsRanking(hashtag, 1);
|
||||||
|
|
||||||
const redisPipeline = this.redisClient.pipeline();
|
const redisPipeline = this.redisClient.pipeline();
|
||||||
|
|
||||||
|
|
@ -193,7 +193,7 @@ export class HashtagService {
|
||||||
'NX', // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定
|
'NX', // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定
|
||||||
);
|
);
|
||||||
|
|
||||||
redisPipeline.exec();
|
await redisPipeline.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ export class ImageProcessingService {
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async convertToWebp(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise<IImage> {
|
public async convertToWebp(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise<IImage> {
|
||||||
return this.convertSharpToWebp(sharp(path), width, height, options);
|
return await this.convertSharpToWebp(sharp(path), width, height, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -100,7 +100,7 @@ export class ImageProcessingService {
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async convertToAvif(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise<IImage> {
|
public async convertToAvif(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise<IImage> {
|
||||||
return this.convertSharpToAvif(sharp(path), width, height, options);
|
return await this.convertSharpToAvif(sharp(path), width, height, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -142,7 +142,7 @@ export class ImageProcessingService {
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async convertToPng(path: string, width: number, height: number): Promise<IImage> {
|
public async convertToPng(path: string, width: number, height: number): Promise<IImage> {
|
||||||
return this.convertSharpToPng(sharp(path), width, height);
|
return await this.convertSharpToPng(sharp(path), width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,14 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Not } from 'typeorm';
|
import { Not } from 'typeorm';
|
||||||
import { MiNote } from '@/models/Note.js';
|
import { isPureRenote, MinimalNote } from '@/misc/is-renote.js';
|
||||||
import { isPureRenote } from '@/misc/is-renote.js';
|
|
||||||
import { SkLatestNote } from '@/models/LatestNote.js';
|
import { SkLatestNote } from '@/models/LatestNote.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { LatestNotesRepository, NotesRepository } from '@/models/_.js';
|
import type { LatestNotesRepository, MiNote, NotesRepository } from '@/models/_.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
import Logger from '@/logger.js';
|
import { QueueService } from '@/core/QueueService.js';
|
||||||
import { QueryService } from './QueryService.js';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LatestNoteService {
|
export class LatestNoteService {
|
||||||
private readonly logger: Logger;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.notesRepository)
|
@Inject(DI.notesRepository)
|
||||||
private readonly notesRepository: NotesRepository,
|
private readonly notesRepository: NotesRepository,
|
||||||
|
|
@ -21,19 +17,23 @@ export class LatestNoteService {
|
||||||
private readonly latestNotesRepository: LatestNotesRepository,
|
private readonly latestNotesRepository: LatestNotesRepository,
|
||||||
|
|
||||||
private readonly queryService: QueryService,
|
private readonly queryService: QueryService,
|
||||||
loggerService: LoggerService,
|
private readonly queueService: QueueService,
|
||||||
) {
|
) {}
|
||||||
this.logger = loggerService.getLogger('LatestNoteService');
|
|
||||||
|
async handleUpdatedNoteDeferred(note: MiNote): Promise<void> {
|
||||||
|
await this.queueService.createUpdateLatestNoteJob(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleUpdatedNoteBG(before: MiNote, after: MiNote): void {
|
async handleCreatedNoteDeferred(note: MiNote): Promise<void> {
|
||||||
this
|
await this.queueService.createUpdateLatestNoteJob(note);
|
||||||
.handleUpdatedNote(before, after)
|
|
||||||
.catch(err => this.logger.error('Unhandled exception while updating latest_note (after update):', err));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleUpdatedNote(before: MiNote, after: MiNote): Promise<void> {
|
async handleDeletedNoteDeferred(note: MiNote): Promise<void> {
|
||||||
// If the key didn't change, then there's nothing to update
|
await this.queueService.createUpdateLatestNoteJob(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleUpdatedNote(before: MinimalNote, after: MinimalNote): Promise<void> {
|
||||||
|
// If the key didn't change, then there's nothing to update.
|
||||||
if (SkLatestNote.areEquivalent(before, after)) return;
|
if (SkLatestNote.areEquivalent(before, after)) return;
|
||||||
|
|
||||||
// Simulate update as delete + create
|
// Simulate update as delete + create
|
||||||
|
|
@ -41,13 +41,7 @@ export class LatestNoteService {
|
||||||
await this.handleCreatedNote(after);
|
await this.handleCreatedNote(after);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCreatedNoteBG(note: MiNote): void {
|
async handleCreatedNote(note: MinimalNote): Promise<void> {
|
||||||
this
|
|
||||||
.handleCreatedNote(note)
|
|
||||||
.catch(err => this.logger.error('Unhandled exception while updating latest_note (after create):', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleCreatedNote(note: MiNote): Promise<void> {
|
|
||||||
// Ignore DMs.
|
// Ignore DMs.
|
||||||
// Followers-only posts are *included*, as this table is used to back the "following" feed.
|
// Followers-only posts are *included*, as this table is used to back the "following" feed.
|
||||||
if (note.visibility === 'specified') return;
|
if (note.visibility === 'specified') return;
|
||||||
|
|
@ -71,13 +65,7 @@ export class LatestNoteService {
|
||||||
await this.latestNotesRepository.upsert(latestNote, ['userId', 'isPublic', 'isReply', 'isQuote']);
|
await this.latestNotesRepository.upsert(latestNote, ['userId', 'isPublic', 'isReply', 'isQuote']);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDeletedNoteBG(note: MiNote): void {
|
async handleDeletedNote(note: MinimalNote): Promise<void> {
|
||||||
this
|
|
||||||
.handleDeletedNote(note)
|
|
||||||
.catch(err => this.logger.error('Unhandled exception while updating latest_note (after delete):', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleDeletedNote(note: MiNote): Promise<void> {
|
|
||||||
// If it's a DM, then it can't possibly be the latest note so we can safely skip this.
|
// If it's a DM, then it can't possibly be the latest note so we can safely skip this.
|
||||||
if (note.visibility === 'specified') return;
|
if (note.visibility === 'specified') return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ import { CollapsedQueue } from '@/misc/collapsed-queue.js';
|
||||||
import { CacheService } from '@/core/CacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import { TimeService } from '@/global/TimeService.js';
|
import { TimeService } from '@/global/TimeService.js';
|
||||||
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
|
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
|
||||||
|
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
|
||||||
|
import { promiseMap } from '@/misc/promise-map.js';
|
||||||
|
|
||||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||||
|
|
||||||
|
|
@ -154,7 +156,6 @@ export type PureRenoteOption = Option & { renote: MiNote } & ({ text?: null } |
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NoteCreateService implements OnApplicationShutdown {
|
export class NoteCreateService implements OnApplicationShutdown {
|
||||||
#shutdownController = new AbortController();
|
#shutdownController = new AbortController();
|
||||||
private updateNotesCountQueue: CollapsedQueue<MiNote['id'], number>;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.config)
|
@Inject(DI.config)
|
||||||
|
|
@ -226,8 +227,8 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
private latestNoteService: LatestNoteService,
|
private latestNoteService: LatestNoteService,
|
||||||
private readonly timeService: TimeService,
|
private readonly timeService: TimeService,
|
||||||
private readonly noteVisibilityService: NoteVisibilityService,
|
private readonly noteVisibilityService: NoteVisibilityService,
|
||||||
|
private readonly collapsedQueueService: CollapsedQueueService,
|
||||||
) {
|
) {
|
||||||
this.updateNotesCountQueue = new CollapsedQueue(this.timeService, process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -458,10 +459,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
|
|
||||||
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
|
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
|
||||||
|
|
||||||
setImmediate('post created', { signal: this.#shutdownController.signal }).then(
|
await this.queueService.createPostNoteJob(note.id, silent, 'create');
|
||||||
() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!),
|
|
||||||
() => { /* aborted, ignore this */ },
|
|
||||||
);
|
|
||||||
|
|
||||||
return note;
|
return note;
|
||||||
}
|
}
|
||||||
|
|
@ -474,7 +472,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
isBot: MiUser['isBot'];
|
isBot: MiUser['isBot'];
|
||||||
noindex: MiUser['noindex'];
|
noindex: MiUser['noindex'];
|
||||||
}, data: Option): Promise<MiNote> {
|
}, data: Option): Promise<MiNote> {
|
||||||
return this.create(user, data, true);
|
return await this.create(user, data, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -577,13 +575,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async postNoteCreated(note: MiNote, user: MiUser & {
|
public async postNoteCreated(note: MiNote, user: MiUser, data: MiNote & { poll: MiPoll | null }, silent: boolean, mentionedUsers: MinimumUser[]) {
|
||||||
id: MiUser['id'];
|
|
||||||
username: MiUser['username'];
|
|
||||||
host: MiUser['host'];
|
|
||||||
isBot: MiUser['isBot'];
|
|
||||||
noindex: MiUser['noindex'];
|
|
||||||
}, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) {
|
|
||||||
this.notesChart.update(note, true);
|
this.notesChart.update(note, true);
|
||||||
if (note.visibility !== 'specified' && (this.meta.enableChartsForRemoteUser || (user.host == null))) {
|
if (note.visibility !== 'specified' && (this.meta.enableChartsForRemoteUser || (user.host == null))) {
|
||||||
this.perUserNotesChart.update(user, note, true);
|
this.perUserNotesChart.update(user, note, true);
|
||||||
|
|
@ -594,7 +586,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
if (isRemoteUser(user)) {
|
if (isRemoteUser(user)) {
|
||||||
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
|
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
|
||||||
if (!this.isRenote(note) || this.isQuote(note)) {
|
if (!this.isRenote(note) || this.isQuote(note)) {
|
||||||
this.updateNotesCountQueue.enqueue(i.id, 1);
|
await this.collapsedQueueService.updateInstanceQueue.enqueue(i.id, { notesCountDelta: 1 });
|
||||||
}
|
}
|
||||||
if (this.meta.enableChartsForFederatedInstances) {
|
if (this.meta.enableChartsForFederatedInstances) {
|
||||||
this.instanceChart.updateNote(i.host, note, true);
|
this.instanceChart.updateNote(i.host, note, true);
|
||||||
|
|
@ -606,26 +598,26 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
// ハッシュタグ更新
|
// ハッシュタグ更新
|
||||||
if (data.visibility === 'public' || data.visibility === 'home') {
|
if (data.visibility === 'public' || data.visibility === 'home') {
|
||||||
if (!user.isBot || this.meta.enableBotTrending) {
|
if (!user.isBot || this.meta.enableBotTrending) {
|
||||||
this.hashtagService.updateHashtags(user, tags);
|
await this.queueService.createUpdateNoteTagsJob(note.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.isRenote(note) || this.isQuote(note)) {
|
if (!this.isRenote(note) || this.isQuote(note)) {
|
||||||
// Increment notes count (user)
|
// Increment notes count (user)
|
||||||
this.incNotesCountOfUser(user);
|
await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { notesCountDelta: 1 });
|
||||||
} else {
|
|
||||||
this.usersRepository.update({ id: user.id }, { updatedAt: this.timeService.date });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pushToTl(note, user);
|
await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { updatedAt: this.timeService.date });
|
||||||
|
|
||||||
this.antennaService.addNoteToAntennas({
|
await this.pushToTl(note, user);
|
||||||
|
|
||||||
|
await this.antennaService.addNoteToAntennas({
|
||||||
...note,
|
...note,
|
||||||
channel: data.channel ?? null,
|
channel: data.channel ?? null,
|
||||||
}, user);
|
}, user);
|
||||||
|
|
||||||
if (data.reply) {
|
if (data.reply) {
|
||||||
this.saveReply(data.reply, note);
|
await this.collapsedQueueService.updateNoteQueue.enqueue(data.reply.id, { repliesCountDelta: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.reply == null) {
|
if (data.reply == null) {
|
||||||
|
|
@ -653,13 +645,14 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isRenote(data) && !this.isQuote(data) && data.renote.userId !== user.id && !user.isBot) {
|
if (this.isPureRenote(data)) {
|
||||||
this.incRenoteCount(data.renote, user);
|
await this.collapsedQueueService.updateNoteQueue.enqueue(data.renote.id, { renoteCountDelta: 1 });
|
||||||
|
await this.incRenoteCount(data.renote, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.poll && data.poll.expiresAt) {
|
if (data.poll && data.poll.expiresAt) {
|
||||||
const delay = data.poll.expiresAt.getTime() - this.timeService.now;
|
const delay = data.poll.expiresAt.getTime() - this.timeService.now;
|
||||||
this.queueService.endedPollNotificationQueue.add(note.id, {
|
await this.queueService.endedPollNotificationQueue.add(note.id, {
|
||||||
noteId: note.id,
|
noteId: note.id,
|
||||||
}, {
|
}, {
|
||||||
jobId: `pollEnd_${note.id}`,
|
jobId: `pollEnd_${note.id}`,
|
||||||
|
|
@ -683,9 +676,9 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
|
|
||||||
this.globalEventService.publishNotesStream(noteObj);
|
this.globalEventService.publishNotesStream(noteObj);
|
||||||
|
|
||||||
this.roleService.addNoteToRoleTimeline(noteObj);
|
await this.roleService.addNoteToRoleTimeline(noteObj);
|
||||||
|
|
||||||
this.webhookService.enqueueUserWebhook(user.id, 'note', { note: noteObj });
|
await this.webhookService.enqueueUserWebhook(user.id, 'note', { note: noteObj });
|
||||||
|
|
||||||
const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note);
|
const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note);
|
||||||
|
|
||||||
|
|
@ -714,7 +707,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
if (!isThreadMuted && !muted) {
|
if (!isThreadMuted && !muted) {
|
||||||
nm.push(data.reply.userId, 'reply');
|
nm.push(data.reply.userId, 'reply');
|
||||||
this.globalEventService.publishMainStream(data.reply.userId, 'reply', noteObj);
|
this.globalEventService.publishMainStream(data.reply.userId, 'reply', noteObj);
|
||||||
this.webhookService.enqueueUserWebhook(data.reply.userId, 'reply', { note: noteObj });
|
await this.webhookService.enqueueUserWebhook(data.reply.userId, 'reply', { note: noteObj });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -745,15 +738,15 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
// Publish event
|
// Publish event
|
||||||
if ((user.id !== data.renote.userId) && data.renote.userHost === null) {
|
if ((user.id !== data.renote.userId) && data.renote.userHost === null) {
|
||||||
this.globalEventService.publishMainStream(data.renote.userId, 'renote', noteObj);
|
this.globalEventService.publishMainStream(data.renote.userId, 'renote', noteObj);
|
||||||
this.webhookService.enqueueUserWebhook(data.renote.userId, 'renote', { note: noteObj });
|
await this.webhookService.enqueueUserWebhook(data.renote.userId, 'renote', { note: noteObj });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nm.notify();
|
await nm.notify();
|
||||||
|
|
||||||
//#region AP deliver
|
//#region AP deliver
|
||||||
if (!data.localOnly && isLocalUser(user)) {
|
if (!data.localOnly && isLocalUser(user)) {
|
||||||
trackTask(async () => {
|
await trackTask(async () => {
|
||||||
const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
|
const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
|
||||||
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
|
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
|
||||||
|
|
||||||
|
|
@ -790,12 +783,12 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.channel) {
|
if (data.channel) {
|
||||||
this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1);
|
await this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1);
|
||||||
this.channelsRepository.update(data.channel.id, {
|
await this.channelsRepository.update(data.channel.id, {
|
||||||
lastNotedAt: this.timeService.date,
|
lastNotedAt: this.timeService.date,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.notesRepository.countBy({
|
await this.notesRepository.countBy({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
channelId: data.channel.id,
|
channelId: data.channel.id,
|
||||||
}).then(count => {
|
}).then(count => {
|
||||||
|
|
@ -808,10 +801,10 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the Latest Note index / following feed
|
// Update the Latest Note index / following feed
|
||||||
this.latestNoteService.handleCreatedNoteBG(note);
|
await this.latestNoteService.handleCreatedNoteDeferred(note);
|
||||||
|
|
||||||
// Register to search database
|
// Register to search database
|
||||||
if (!user.noindex) this.index(note);
|
if (!user.noindex) await this.index(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -829,14 +822,11 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
*/
|
*/
|
||||||
readonly isQuote = isQuote;
|
readonly isQuote = isQuote;
|
||||||
|
|
||||||
|
// Note: does not increment the count! used only for featured rankings.
|
||||||
@bindThis
|
@bindThis
|
||||||
private async incRenoteCount(renote: MiNote, user: MiUser) {
|
private async incRenoteCount(renote: MiNote, user: MiUser) {
|
||||||
await this.notesRepository.createQueryBuilder().update()
|
// Moved down from the containing block
|
||||||
.set({
|
if (renote.userId === user.id || user.isBot) return;
|
||||||
renoteCount: () => '"renoteCount" + 1',
|
|
||||||
})
|
|
||||||
.where('id = :id', { id: renote.id })
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// 30%の確率、3日以内に投稿されたノートの場合ハイライト用ランキング更新
|
// 30%の確率、3日以内に投稿されたノートの場合ハイライト用ランキング更新
|
||||||
if (user.isExplorable && Math.random() < 0.3 && (this.timeService.now - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) {
|
if (user.isExplorable && Math.random() < 0.3 && (this.timeService.now - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) {
|
||||||
|
|
@ -844,12 +834,12 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
if (policies.canTrend) {
|
if (policies.canTrend) {
|
||||||
if (renote.channelId != null) {
|
if (renote.channelId != null) {
|
||||||
if (renote.replyId == null) {
|
if (renote.replyId == null) {
|
||||||
this.featuredService.updateInChannelNotesRanking(renote.channelId, renote, 5);
|
await this.featuredService.updateInChannelNotesRanking(renote.channelId, renote, 5);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) {
|
if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) {
|
||||||
this.featuredService.updateGlobalNotesRanking(renote, 5);
|
await this.featuredService.updateGlobalNotesRanking(renote, 5);
|
||||||
this.featuredService.updatePerUserNotesRanking(renote.userId, renote, 5);
|
await this.featuredService.updatePerUserNotesRanking(renote.userId, renote, 5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -883,7 +873,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.globalEventService.publishMainStream(u.id, 'mention', detailPackedNote);
|
this.globalEventService.publishMainStream(u.id, 'mention', detailPackedNote);
|
||||||
this.webhookService.enqueueUserWebhook(u.id, 'mention', { note: detailPackedNote });
|
await this.webhookService.enqueueUserWebhook(u.id, 'mention', { note: detailPackedNote });
|
||||||
|
|
||||||
// Create notification
|
// Create notification
|
||||||
nm.push(u.id, 'mention');
|
nm.push(u.id, 'mention');
|
||||||
|
|
@ -891,43 +881,23 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private saveReply(reply: MiNote, note: MiNote) {
|
private async index(note: MiNote) {
|
||||||
this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@bindThis
|
|
||||||
private index(note: MiNote) {
|
|
||||||
if (note.text == null && note.cw == null) return;
|
if (note.text == null && note.cw == null) return;
|
||||||
|
|
||||||
this.searchService.indexNote(note);
|
await this.searchService.indexNote(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private incNotesCountOfUser(user: { id: MiUser['id']; }) {
|
public async extractMentionedUsers(user: { host: MiUser['host']; }, tokens: mfm.MfmNode[]): Promise<MiUser[]> {
|
||||||
this.usersRepository.createQueryBuilder().update()
|
if (tokens == null || tokens.length === 0) return [];
|
||||||
.set({
|
|
||||||
updatedAt: this.timeService.date,
|
|
||||||
notesCount: () => '"notesCount" + 1',
|
|
||||||
})
|
|
||||||
.where('id = :id', { id: user.id })
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
@bindThis
|
const allMentions = extractMentions(tokens);
|
||||||
private async extractMentionedUsers(user: { host: MiUser['host']; }, tokens: mfm.MfmNode[]): Promise<MiUser[]> {
|
const mentions = new Map(allMentions.map(m => [`${m.username.toLowerCase()}@${m.host?.toLowerCase()}`, m]));
|
||||||
if (tokens == null) return [];
|
|
||||||
|
|
||||||
const mentions = extractMentions(tokens);
|
const allMentionedUsers = await promiseMap(mentions.values(), async m => await this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null), { limit: 2 });
|
||||||
let mentionedUsers = (await Promise.all(mentions.map(m =>
|
const mentionedUsers = new Map(allMentionedUsers.filter(u => u != null).map(u => [u.id, u]));
|
||||||
this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null),
|
|
||||||
))).filter(x => x != null);
|
|
||||||
|
|
||||||
// Drop duplicate users
|
return Array.from(mentionedUsers.values());
|
||||||
mentionedUsers = mentionedUsers.filter((u, i, self) =>
|
|
||||||
i === self.findIndex(u2 => u.id === u2.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
return mentionedUsers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -1040,7 +1010,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
// checkHibernation moved to HibernateUsersProcessorService
|
// checkHibernation moved to HibernateUsersProcessorService
|
||||||
}
|
}
|
||||||
|
|
||||||
r.exec();
|
await r.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkHibernation moved to HibernateUsersProcessorService
|
// checkHibernation moved to HibernateUsersProcessorService
|
||||||
|
|
@ -1062,20 +1032,11 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
// collapseNotesCount moved to CollapsedQueueService
|
||||||
private collapseNotesCount(oldValue: number, newValue: number) {
|
|
||||||
return oldValue + newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
@bindThis
|
|
||||||
private async performUpdateNotesCount(id: MiNote['id'], incrBy: number) {
|
|
||||||
await this.instancesRepository.increment({ id: id }, 'notesCount', incrBy);
|
|
||||||
}
|
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async dispose(): Promise<void> {
|
public async dispose(): Promise<void> {
|
||||||
this.#shutdownController.abort();
|
this.#shutdownController.abort();
|
||||||
await this.updateNotesCountQueue.performAllNow();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -1100,8 +1061,8 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
|
|
||||||
// Instance cannot quote
|
// Instance cannot quote
|
||||||
if (user.host) {
|
if (user.host) {
|
||||||
const instance = await this.federatedInstanceService.fetch(user.host);
|
const instance = await this.federatedInstanceService.fetchOrRegister(user.host);
|
||||||
if (instance?.rejectQuotes) {
|
if (instance.rejectQuotes) {
|
||||||
(data as Option).renote = null;
|
(data as Option).renote = null;
|
||||||
(data.processErrors ??= []).push('quoteUnavailable');
|
(data.processErrors ??= []).push('quoteUnavailable');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,18 +22,15 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { SearchService } from '@/core/SearchService.js';
|
import { SearchService } from '@/core/SearchService.js';
|
||||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||||
import { isQuote, isRenote } from '@/misc/is-renote.js';
|
import { isPureRenote } from '@/misc/is-renote.js';
|
||||||
import { LatestNoteService } from '@/core/LatestNoteService.js';
|
import { LatestNoteService } from '@/core/LatestNoteService.js';
|
||||||
import { ApLogService } from '@/core/ApLogService.js';
|
import { ApLogService } from '@/core/ApLogService.js';
|
||||||
import type Logger from '@/logger.js';
|
|
||||||
import { TimeService } from '@/global/TimeService.js';
|
import { TimeService } from '@/global/TimeService.js';
|
||||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
import { trackTask } from '@/misc/promise-tracker.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.js';
|
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NoteDeleteService {
|
export class NoteDeleteService {
|
||||||
private readonly logger: Logger;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.config)
|
@Inject(DI.config)
|
||||||
private config: Config,
|
private config: Config,
|
||||||
|
|
@ -63,53 +60,56 @@ export class NoteDeleteService {
|
||||||
private latestNoteService: LatestNoteService,
|
private latestNoteService: LatestNoteService,
|
||||||
private readonly apLogService: ApLogService,
|
private readonly apLogService: ApLogService,
|
||||||
private readonly timeService: TimeService,
|
private readonly timeService: TimeService,
|
||||||
|
private readonly collapsedQueueService: CollapsedQueueService,
|
||||||
loggerService: LoggerService,
|
) {}
|
||||||
) {
|
|
||||||
this.logger = loggerService.getLogger('note-delete-service');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 投稿を削除します。
|
* 投稿を削除します。
|
||||||
* @param user 投稿者
|
|
||||||
* @param note 投稿
|
|
||||||
*/
|
*/
|
||||||
async delete(user: { id: MiUser['id']; uri: MiUser['uri']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, quiet = false, deleter?: MiUser) {
|
async delete(user: MiUser, note: MiNote, deleter?: MiUser, immediate = false) {
|
||||||
|
// This kicks off lots of things that can run in parallel, but we should still wait for completion to ensure consistent state and to avoid task flood when calling in a loop.
|
||||||
|
const promises: Promise<unknown>[] = [];
|
||||||
|
|
||||||
const deletedAt = this.timeService.date;
|
const deletedAt = this.timeService.date;
|
||||||
const cascadingNotes = await this.findCascadingNotes(note);
|
const cascadingNotes = await this.findCascadingNotes(note);
|
||||||
|
|
||||||
if (note.replyId) {
|
if (note.replyId) {
|
||||||
await this.notesRepository.decrement({ id: note.replyId }, 'repliesCount', 1);
|
await this.collapsedQueueService.updateNoteQueue.enqueue(note.replyId, { repliesCountDelta: -1 });
|
||||||
|
} else if (isPureRenote(note)) {
|
||||||
|
await this.collapsedQueueService.updateNoteQueue.enqueue(note.renoteId, { renoteCountDelta: -1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) {
|
for (const cascade of cascadingNotes) {
|
||||||
await this.notesRepository.findOneBy({ id: note.renoteId }).then(async (renote) => {
|
if (cascade.replyId) {
|
||||||
if (!renote) return;
|
await this.collapsedQueueService.updateNoteQueue.enqueue(cascade.replyId, { repliesCountDelta: -1 });
|
||||||
if (renote.userId !== user.id) await this.notesRepository.decrement({ id: renote.id }, 'renoteCount', 1);
|
} else if (isPureRenote(cascade)) {
|
||||||
});
|
await this.collapsedQueueService.updateNoteQueue.enqueue(cascade.renoteId, { renoteCountDelta: -1 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!quiet) {
|
// Braces preserved to avoid merge conflicts
|
||||||
this.globalEventService.publishNoteStream(note.id, 'deleted', {
|
{
|
||||||
|
promises.push(this.globalEventService.publishNoteStream(note.id, 'deleted', {
|
||||||
deletedAt: deletedAt,
|
deletedAt: deletedAt,
|
||||||
});
|
}));
|
||||||
|
|
||||||
|
for (const cascade of cascadingNotes) {
|
||||||
|
promises.push(this.globalEventService.publishNoteStream(cascade.id, 'deleted', {
|
||||||
|
deletedAt: deletedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
//#region ローカルの投稿なら削除アクティビティを配送
|
//#region ローカルの投稿なら削除アクティビティを配送
|
||||||
if (isLocalUser(user) && !note.localOnly) {
|
if (isLocalUser(user) && !note.localOnly) {
|
||||||
let renote: MiNote | null = null;
|
const renote = isPureRenote(note)
|
||||||
|
? await this.notesRepository.findOneBy({ id: note.renoteId })
|
||||||
// if deleted note is renote
|
: null;
|
||||||
if (isRenote(note) && !isQuote(note)) {
|
|
||||||
renote = await this.notesRepository.findOneBy({
|
|
||||||
id: note.renoteId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = this.apRendererService.addContext(renote
|
const content = this.apRendererService.addContext(renote
|
||||||
? this.apRendererService.renderUndo(this.apRendererService.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note), user)
|
? this.apRendererService.renderUndo(this.apRendererService.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note), user)
|
||||||
: this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${note.id}`), user));
|
: this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${note.id}`), user));
|
||||||
|
|
||||||
trackPromise(this.deliverToConcerned(user, note, content));
|
promises.push(this.deliverToConcerned(user, note, content));
|
||||||
}
|
}
|
||||||
|
|
||||||
// also deliver delete activity to cascaded notes
|
// also deliver delete activity to cascaded notes
|
||||||
|
|
@ -118,7 +118,7 @@ export class NoteDeleteService {
|
||||||
if (!cascadingNote.user) continue;
|
if (!cascadingNote.user) continue;
|
||||||
if (!isLocalUser(cascadingNote.user)) continue;
|
if (!isLocalUser(cascadingNote.user)) continue;
|
||||||
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${cascadingNote.id}`), cascadingNote.user));
|
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${cascadingNote.id}`), cascadingNote.user));
|
||||||
trackPromise(this.deliverToConcerned(cascadingNote.user, cascadingNote, content));
|
promises.push(this.deliverToConcerned(cascadingNote.user, cascadingNote, content));
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
|
|
@ -127,90 +127,142 @@ export class NoteDeleteService {
|
||||||
this.perUserNotesChart.update(user, note, false);
|
this.perUserNotesChart.update(user, note, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isRenote(note) || isQuote(note)) {
|
for (const cascade of cascadingNotes) {
|
||||||
|
this.notesChart.update(cascade, false);
|
||||||
|
if (this.meta.enableChartsForRemoteUser || (cascade.user.host == null)) {
|
||||||
|
this.perUserNotesChart.update(cascade.user, cascade, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPureRenote(note)) {
|
||||||
// Decrement notes count (user)
|
// Decrement notes count (user)
|
||||||
this.decNotesCountOfUser(user);
|
await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { notesCountDelta: -1 });
|
||||||
} else {
|
}
|
||||||
this.usersRepository.update({ id: user.id }, { updatedAt: this.timeService.date });
|
|
||||||
|
await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { updatedAt: this.timeService.date });
|
||||||
|
|
||||||
|
for (const cascade of cascadingNotes) {
|
||||||
|
if (!isPureRenote(cascade)) {
|
||||||
|
await this.collapsedQueueService.updateUserQueue.enqueue(cascade.user.id, { notesCountDelta: -1 });
|
||||||
|
}
|
||||||
|
// Don't mark cascaded user as updated (active)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.meta.enableStatsForFederatedInstances) {
|
if (this.meta.enableStatsForFederatedInstances) {
|
||||||
if (isRemoteUser(user)) {
|
if (isRemoteUser(user)) {
|
||||||
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
|
if (!isPureRenote(note)) {
|
||||||
if (note.renoteId && note.text || !note.renoteId) {
|
const i = await this.federatedInstanceService.fetchOrRegister(user.host);
|
||||||
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
|
await this.collapsedQueueService.updateInstanceQueue.enqueue(i.id, { notesCountDelta: -1 });
|
||||||
|
}
|
||||||
|
if (this.meta.enableChartsForFederatedInstances) {
|
||||||
|
this.instanceChart.updateNote(user.host, note, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cascade of cascadingNotes) {
|
||||||
|
if (isRemoteUser(cascade.user)) {
|
||||||
|
if (!isPureRenote(cascade)) {
|
||||||
|
const i = await this.federatedInstanceService.fetchOrRegister(cascade.user.host);
|
||||||
|
await this.collapsedQueueService.updateInstanceQueue.enqueue(i.id, { notesCountDelta: -1 });
|
||||||
}
|
}
|
||||||
if (this.meta.enableChartsForFederatedInstances) {
|
if (this.meta.enableChartsForFederatedInstances) {
|
||||||
this.instanceChart.updateNote(i.host, note, false);
|
this.instanceChart.updateNote(cascade.user.host, cascade, false);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const cascadingNote of cascadingNotes) {
|
for (const cascadingNote of cascadingNotes) {
|
||||||
this.searchService.unindexNote(cascadingNote);
|
promises.push(this.searchService.unindexNote(cascadingNote));
|
||||||
}
|
}
|
||||||
this.searchService.unindexNote(note);
|
promises.push(this.searchService.unindexNote(note));
|
||||||
|
|
||||||
|
// Don't put this in the promise array, since it needs to happen before the next section
|
||||||
await this.notesRepository.delete({
|
await this.notesRepository.delete({
|
||||||
id: note.id,
|
id: note.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.latestNoteService.handleDeletedNoteBG(note);
|
// Update the Latest Note index / following feed *after* note is deleted
|
||||||
|
promises.push(immediate
|
||||||
|
? this.latestNoteService.handleDeletedNote(note)
|
||||||
|
: this.latestNoteService.handleDeletedNoteDeferred(note));
|
||||||
|
for (const cascadingNote of cascadingNotes) {
|
||||||
|
promises.push(immediate
|
||||||
|
? this.latestNoteService.handleDeletedNote(cascadingNote)
|
||||||
|
: this.latestNoteService.handleDeletedNoteDeferred(cascadingNote));
|
||||||
|
}
|
||||||
|
|
||||||
if (deleter && (note.userId !== deleter.id)) {
|
if (deleter && (user.id !== deleter.id)) {
|
||||||
const user = await this.usersRepository.findOneByOrFail({ id: note.userId });
|
promises.push(this.moderationLogService.log(deleter, 'deleteNote', {
|
||||||
this.moderationLogService.log(deleter, 'deleteNote', {
|
|
||||||
noteId: note.id,
|
noteId: note.id,
|
||||||
noteUserId: note.userId,
|
noteUserId: note.userId,
|
||||||
noteUserUsername: user.username,
|
noteUserUsername: user.username,
|
||||||
noteUserHost: user.host,
|
noteUserHost: user.host,
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletedUris = [note, ...cascadingNotes]
|
const deletedUris = [note, ...cascadingNotes]
|
||||||
.map(n => n.uri)
|
.map(n => n.uri)
|
||||||
.filter((u): u is string => u != null);
|
.filter((u): u is string => u != null);
|
||||||
if (deletedUris.length > 0) {
|
if (deletedUris.length > 0) {
|
||||||
this.apLogService.deleteObjectLogs(deletedUris)
|
promises.push(immediate
|
||||||
.catch(err => this.logger.error(err, `Failed to delete AP logs for note '${note.uri}'`));
|
? this.apLogService.deleteObjectLogs(deletedUris)
|
||||||
|
: this.apLogService.deleteObjectLogsDeferred(deletedUris));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await trackTask(async () => {
|
||||||
|
await Promise.allSettled(promises);
|
||||||
|
|
||||||
|
// This is deferred to make sure we don't race the enqueue() calls
|
||||||
|
if (immediate) {
|
||||||
|
await Promise.allSettled([
|
||||||
|
this.collapsedQueueService.updateNoteQueue.performAllNow(),
|
||||||
|
this.collapsedQueueService.updateUserQueue.performAllNow(),
|
||||||
|
this.collapsedQueueService.updateInstanceQueue.performAllNow(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private decNotesCountOfUser(user: { id: MiUser['id']; }) {
|
private async findCascadingNotes(note: MiNote): Promise<(MiNote & { user: MiUser })[]> {
|
||||||
this.usersRepository.createQueryBuilder().update()
|
const cascadingNotes: MiNote[] = [];
|
||||||
.set({
|
|
||||||
updatedAt: this.timeService.date,
|
|
||||||
notesCount: () => '"notesCount" - 1',
|
|
||||||
})
|
|
||||||
.where('id = :id', { id: user.id })
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
@bindThis
|
/**
|
||||||
private async findCascadingNotes(note: MiNote): Promise<MiNote[]> {
|
* Finds all replies, quotes, and renotes of the given list of notes.
|
||||||
const recursive = async (noteId: string): Promise<MiNote[]> => {
|
* These are the notes that will be CASCADE deleted when the origin note is deleted.
|
||||||
const query = this.notesRepository.createQueryBuilder('note')
|
*
|
||||||
.where('note.replyId = :noteId', { noteId })
|
* This works by operating in "layers" that radiate out from the origin note like a web.
|
||||||
.orWhere(new Brackets(q => {
|
* The process is roughly like this:
|
||||||
q.where('note.renoteId = :noteId', { noteId })
|
* 1. Find all immediate replies and renotes of the origin.
|
||||||
.andWhere('note.text IS NOT NULL');
|
* 2. Find all immediate replies and renotes of the results from step one.
|
||||||
}))
|
* 3. Repeat until step 2 returns no new results.
|
||||||
.leftJoinAndSelect('note.user', 'user');
|
* 4. Collect all the step 2 results; those are the set of all cascading notes.
|
||||||
const replies = await query.getMany();
|
*/
|
||||||
|
const cascade = async (layer: MiNote[]): Promise<void> => {
|
||||||
|
const layerIds = layer.map(layer => layer.id);
|
||||||
|
const refs = await this.notesRepository.find({
|
||||||
|
where: [
|
||||||
|
{ replyId: In(layerIds) },
|
||||||
|
{ renoteId: In(layerIds) },
|
||||||
|
],
|
||||||
|
relations: { user: true },
|
||||||
|
});
|
||||||
|
|
||||||
return [
|
// Stop when we reach the end of all threads
|
||||||
replies,
|
if (refs.length === 0) return;
|
||||||
...await Promise.all(replies.map(reply => recursive(reply.id))),
|
|
||||||
].flat();
|
cascadingNotes.push(...refs);
|
||||||
|
await cascade(refs);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cascadingNotes: MiNote[] = await recursive(note.id);
|
// Start with the origin, which should *not* be in the result set!
|
||||||
|
await cascade([note]);
|
||||||
|
|
||||||
return cascadingNotes;
|
// Type cast is safe - we load the relation above.
|
||||||
|
return cascadingNotes as (MiNote & { user: MiUser })[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,16 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { setImmediate } from 'node:timers/promises';
|
|
||||||
import * as mfm from 'mfm-js';
|
import * as mfm from 'mfm-js';
|
||||||
import { DataSource, In, IsNull, LessThan } from 'typeorm';
|
import { DataSource, In } from 'typeorm';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import { UnrecoverableError } from 'bullmq';
|
import { UnrecoverableError } from 'bullmq';
|
||||||
import { extractMentions } from '@/misc/extract-mentions.js';
|
|
||||||
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
|
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
|
||||||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||||
import type { IMentionedRemoteUsers } from '@/models/Note.js';
|
import type { IMentionedRemoteUsers } from '@/models/Note.js';
|
||||||
import { MiNote } from '@/models/Note.js';
|
import { MiNote } from '@/models/Note.js';
|
||||||
import type { NoteEditRepository, ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository, PollsRepository } from '@/models/_.js';
|
import type { NoteEditsRepository, ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository, PollsRepository } from '@/models/_.js';
|
||||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||||
import type { MiApp } from '@/models/App.js';
|
import type { MiApp } from '@/models/App.js';
|
||||||
import { concat } from '@/misc/prelude/array.js';
|
import { concat } from '@/misc/prelude/array.js';
|
||||||
|
|
@ -50,11 +48,11 @@ import { trackTask } from '@/misc/promise-tracker.js';
|
||||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import { LatestNoteService } from '@/core/LatestNoteService.js';
|
import { LatestNoteService } from '@/core/LatestNoteService.js';
|
||||||
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
|
|
||||||
import { NoteCreateService } from '@/core/NoteCreateService.js';
|
import { NoteCreateService } from '@/core/NoteCreateService.js';
|
||||||
import { TimeService } from '@/global/TimeService.js';
|
import { TimeService } from '@/global/TimeService.js';
|
||||||
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
|
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
|
||||||
import { isPureRenote } from '@/misc/is-renote.js';
|
import { isPureRenote } from '@/misc/is-renote.js';
|
||||||
|
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
|
||||||
|
|
||||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention' | 'edited';
|
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention' | 'edited';
|
||||||
|
|
||||||
|
|
@ -150,7 +148,6 @@ export type Option = {
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NoteEditService implements OnApplicationShutdown {
|
export class NoteEditService implements OnApplicationShutdown {
|
||||||
#shutdownController = new AbortController();
|
#shutdownController = new AbortController();
|
||||||
private updateNotesCountQueue: CollapsedQueue<MiNote['id'], number>;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.config)
|
@Inject(DI.config)
|
||||||
|
|
@ -195,8 +192,8 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
@Inject(DI.channelFollowingsRepository)
|
@Inject(DI.channelFollowingsRepository)
|
||||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||||
|
|
||||||
@Inject(DI.noteEditRepository)
|
@Inject(DI.noteEditsRepository)
|
||||||
private noteEditRepository: NoteEditRepository,
|
private noteEditsRepository: NoteEditsRepository,
|
||||||
|
|
||||||
@Inject(DI.pollsRepository)
|
@Inject(DI.pollsRepository)
|
||||||
private pollsRepository: PollsRepository,
|
private pollsRepository: PollsRepository,
|
||||||
|
|
@ -224,8 +221,8 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
private noteCreateService: NoteCreateService,
|
private noteCreateService: NoteCreateService,
|
||||||
private readonly timeService: TimeService,
|
private readonly timeService: TimeService,
|
||||||
private readonly noteVisibilityService: NoteVisibilityService,
|
private readonly noteVisibilityService: NoteVisibilityService,
|
||||||
|
private readonly collapsedQueueService: CollapsedQueueService,
|
||||||
) {
|
) {
|
||||||
this.updateNotesCountQueue = new CollapsedQueue(this.timeService, process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -234,29 +231,29 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
throw new UnrecoverableError('edit failed: missing editid');
|
throw new UnrecoverableError('edit failed: missing editid');
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldnote = await this.notesRepository.findOneBy({
|
const oldNote = await this.notesRepository.findOneBy({
|
||||||
id: editid,
|
id: editid,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (oldnote == null) {
|
if (oldNote == null) {
|
||||||
throw new UnrecoverableError(`edit failed for ${editid}: missing oldnote`);
|
throw new UnrecoverableError(`edit failed for ${editid}: missing oldnote`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oldnote.userId !== user.id) {
|
if (oldNote.userId !== user.id) {
|
||||||
throw new UnrecoverableError(`edit failed for ${editid}: user is not the note author`);
|
throw new UnrecoverableError(`edit failed for ${editid}: user is not the note author`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// we never want to change the replyId, so fetch the original "parent"
|
// we never want to change the replyId, so fetch the original "parent"
|
||||||
if (oldnote.replyId) {
|
if (oldNote.replyId) {
|
||||||
data.reply = await this.notesRepository.findOneBy({ id: oldnote.replyId });
|
data.reply = await this.notesRepository.findOneBy({ id: oldNote.replyId });
|
||||||
} else {
|
} else {
|
||||||
data.reply = undefined;
|
data.reply = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// changing visibility on an edit is ill-defined, let's try to
|
// changing visibility on an edit is ill-defined, let's try to
|
||||||
// keep the same visibility as the original note
|
// keep the same visibility as the original note
|
||||||
data.visibility = oldnote.visibility;
|
data.visibility = oldNote.visibility;
|
||||||
data.localOnly = oldnote.localOnly;
|
data.localOnly = oldNote.localOnly;
|
||||||
|
|
||||||
// チャンネル外にリプライしたら対象のスコープに合わせる
|
// チャンネル外にリプライしたら対象のスコープに合わせる
|
||||||
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
|
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
|
||||||
|
|
@ -354,12 +351,12 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for recursion
|
// Check for recursion
|
||||||
if (data.renote.id === oldnote.id) {
|
if (data.renote.id === oldNote.id) {
|
||||||
throw new IdentifiableError('33510210-8452-094c-6227-4a6c05d99f02', `edit failed for ${oldnote.id}: note cannot quote itself`);
|
throw new IdentifiableError('33510210-8452-094c-6227-4a6c05d99f02', `edit failed for ${oldNote.id}: note cannot quote itself`);
|
||||||
}
|
}
|
||||||
for (let nextRenoteId = data.renote.renoteId; nextRenoteId != null;) {
|
for (let nextRenoteId = data.renote.renoteId; nextRenoteId != null;) {
|
||||||
if (nextRenoteId === oldnote.id) {
|
if (nextRenoteId === oldNote.id) {
|
||||||
throw new IdentifiableError('ea93b7c2-3d6c-4e10-946b-00d50b1a75cb', `edit failed for ${oldnote.id}: note cannot quote a quote of itself`);
|
throw new IdentifiableError('ea93b7c2-3d6c-4e10-946b-00d50b1a75cb', `edit failed for ${oldNote.id}: note cannot quote a quote of itself`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO create something like threadId but for quotes, that way we don't need full recursion
|
// TODO create something like threadId but for quotes, that way we don't need full recursion
|
||||||
|
|
@ -432,7 +429,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
|
|
||||||
emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens);
|
emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens);
|
||||||
|
|
||||||
mentionedUsers = data.apMentions ?? await this.extractMentionedUsers(user, combinedTokens);
|
mentionedUsers = data.apMentions ?? await this.noteCreateService.extractMentionedUsers(user, combinedTokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the host is media-silenced, custom emojis are not allowed
|
// if the host is media-silenced, custom emojis are not allowed
|
||||||
|
|
@ -463,46 +460,52 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
const update: Partial<MiNote> = {};
|
const update: Partial<MiNote> = {};
|
||||||
if (data.text !== undefined && data.text !== oldnote.text) {
|
if (data.text !== undefined && data.text !== oldNote.text) {
|
||||||
update.text = data.text;
|
update.text = data.text;
|
||||||
}
|
}
|
||||||
if (data.cw !== undefined && data.cw !== oldnote.cw) {
|
if (data.cw !== undefined && data.cw !== oldNote.cw) {
|
||||||
update.cw = data.cw;
|
update.cw = data.cw;
|
||||||
}
|
}
|
||||||
if (data.poll !== undefined && oldnote.hasPoll !== !!data.poll) {
|
if (data.poll !== undefined && oldNote.hasPoll !== !!data.poll) {
|
||||||
update.hasPoll = !!data.poll;
|
update.hasPoll = !!data.poll;
|
||||||
}
|
}
|
||||||
if (data.mandatoryCW !== undefined && oldnote.mandatoryCW !== data.mandatoryCW) {
|
if (data.mandatoryCW !== undefined && oldNote.mandatoryCW !== data.mandatoryCW) {
|
||||||
update.mandatoryCW = data.mandatoryCW;
|
update.mandatoryCW = data.mandatoryCW;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO deep-compare files
|
// TODO deep-compare files
|
||||||
const filesChanged = oldnote.fileIds.length || data.files?.length;
|
const filesChanged = oldNote.fileIds.length || data.files?.length;
|
||||||
|
|
||||||
const poll = await this.pollsRepository.findOneBy({ noteId: oldnote.id });
|
const oldPoll = await this.pollsRepository.findOneBy({ noteId: oldNote.id });
|
||||||
|
const oldPollData = oldPoll ? { choices: oldPoll.choices, multiple: oldPoll.multiple, expiresAt: oldPoll.expiresAt?.toISOString() ?? null } : null;
|
||||||
const oldPoll = poll ? { choices: poll.choices, multiple: poll.multiple, expiresAt: poll.expiresAt } : null;
|
const newPollData = data.poll ? { choices: data.poll.choices, multiple: data.poll.multiple, expiresAt: data.poll.expiresAt ?? null } : null;
|
||||||
const pollChanged = data.poll != null && JSON.stringify(data.poll) !== JSON.stringify(oldPoll);
|
const pollChanged = data.poll !== undefined && JSON.stringify(oldPollData) !== JSON.stringify(newPollData);
|
||||||
|
|
||||||
if (Object.keys(update).length > 0 || filesChanged || pollChanged) {
|
if (Object.keys(update).length > 0 || filesChanged || pollChanged) {
|
||||||
const exists = await this.noteEditRepository.findOneBy({ noteId: oldnote.id });
|
const exists = await this.noteEditsRepository.findOneBy({ noteId: oldNote.id });
|
||||||
|
|
||||||
await this.noteEditRepository.insert({
|
await this.noteEditsRepository.insert({
|
||||||
id: this.idService.gen(),
|
id: this.idService.gen(),
|
||||||
noteId: oldnote.id,
|
userId: oldNote.userId,
|
||||||
oldText: oldnote.text || undefined,
|
noteId: oldNote.id,
|
||||||
|
renoteId: oldNote.renoteId,
|
||||||
|
replyId: oldNote.replyId,
|
||||||
|
visibility: oldNote.visibility,
|
||||||
|
text: oldNote.text || undefined,
|
||||||
newText: update.text || undefined,
|
newText: update.text || undefined,
|
||||||
cw: update.cw || undefined,
|
cw: oldNote.cw || undefined,
|
||||||
fileIds: undefined,
|
newCw: update.cw || undefined,
|
||||||
oldDate: exists ? oldnote.updatedAt as Date : this.idService.parse(oldnote.id).date,
|
fileIds: oldNote.fileIds,
|
||||||
|
oldDate: exists ? oldNote.updatedAt as Date : this.idService.parse(oldNote.id).date,
|
||||||
updatedAt: this.timeService.date,
|
updatedAt: this.timeService.date,
|
||||||
|
hasPoll: oldPoll != null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const note = new MiNote({
|
const note = new MiNote({
|
||||||
id: oldnote.id,
|
id: oldNote.id,
|
||||||
updatedAt: data.updatedAt ? data.updatedAt : this.timeService.date,
|
updatedAt: data.updatedAt ? data.updatedAt : this.timeService.date,
|
||||||
fileIds: data.files ? data.files.map(file => file.id) : [],
|
fileIds: data.files ? data.files.map(file => file.id) : [],
|
||||||
replyId: oldnote.replyId,
|
replyId: oldNote.replyId,
|
||||||
renoteId: data.renote ? data.renote.id : null,
|
renoteId: data.renote ? data.renote.id : null,
|
||||||
channelId: data.channel ? data.channel.id : null,
|
channelId: data.channel ? data.channel.id : null,
|
||||||
threadId: data.reply
|
threadId: data.reply
|
||||||
|
|
@ -516,7 +519,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
cw: data.cw ?? null,
|
cw: data.cw ?? null,
|
||||||
tags: tags.map(tag => normalizeForSearch(tag)),
|
tags: tags.map(tag => normalizeForSearch(tag)),
|
||||||
emojis,
|
emojis,
|
||||||
reactions: oldnote.reactions,
|
reactions: oldNote.reactions,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
localOnly: data.localOnly!,
|
localOnly: data.localOnly!,
|
||||||
reactionAcceptance: data.reactionAcceptance,
|
reactionAcceptance: data.reactionAcceptance,
|
||||||
|
|
@ -535,7 +538,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
renoteUserId: data.renote ? data.renote.userId : null,
|
renoteUserId: data.renote ? data.renote.userId : null,
|
||||||
renoteUserHost: data.renote ? data.renote.userHost : null,
|
renoteUserHost: data.renote ? data.renote.userHost : null,
|
||||||
userHost: user.host,
|
userHost: user.host,
|
||||||
reactionAndUserPairCache: oldnote.reactionAndUserPairCache,
|
reactionAndUserPairCache: oldNote.reactionAndUserPairCache,
|
||||||
mandatoryCW: data.mandatoryCW,
|
mandatoryCW: data.mandatoryCW,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -561,58 +564,55 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
if (pollChanged) {
|
if (pollChanged) {
|
||||||
// Start transaction
|
// Start transaction
|
||||||
await this.db.transaction(async transactionalEntityManager => {
|
await this.db.transaction(async transactionalEntityManager => {
|
||||||
await transactionalEntityManager.update(MiNote, oldnote.id, note);
|
await transactionalEntityManager.update(MiNote, oldNote.id, note);
|
||||||
|
|
||||||
const poll = new MiPoll({
|
// Insert or update poll
|
||||||
noteId: note.id,
|
if (data.poll) {
|
||||||
choices: data.poll!.choices,
|
const poll = new MiPoll({
|
||||||
expiresAt: data.poll!.expiresAt,
|
noteId: note.id,
|
||||||
multiple: data.poll!.multiple,
|
choices: data.poll.choices,
|
||||||
votes: new Array(data.poll!.choices.length).fill(0),
|
expiresAt: data.poll.expiresAt,
|
||||||
noteVisibility: note.visibility,
|
multiple: data.poll.multiple,
|
||||||
userId: user.id,
|
votes: new Array(data.poll.choices.length).fill(0),
|
||||||
userHost: user.host,
|
noteVisibility: note.visibility,
|
||||||
channelId: data.channel?.id ?? null,
|
userId: user.id,
|
||||||
});
|
userHost: user.host,
|
||||||
|
channelId: data.channel?.id ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
if (!oldnote.hasPoll) {
|
if (oldPoll) {
|
||||||
await transactionalEntityManager.insert(MiPoll, poll);
|
await transactionalEntityManager.update(MiPoll, { noteId: oldPoll.noteId }, poll);
|
||||||
} else {
|
} else {
|
||||||
await transactionalEntityManager.update(MiPoll, oldnote.id, poll);
|
await transactionalEntityManager.insert(MiPoll, poll);
|
||||||
|
}
|
||||||
|
// Delete poll
|
||||||
|
} else if (oldPoll) {
|
||||||
|
await transactionalEntityManager.delete(MiPoll, { noteId: oldPoll.noteId });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await this.notesRepository.update(oldnote.id, note);
|
await this.notesRepository.update(oldNote.id, note);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-fetch note to get the default values of null / unset fields.
|
// Re-fetch note to get the default values of null / unset fields.
|
||||||
const edited = await this.notesRepository.findOneByOrFail({ id: note.id });
|
const edited = await this.notesRepository.findOneByOrFail({ id: note.id });
|
||||||
|
|
||||||
setImmediate('post edited', { signal: this.#shutdownController.signal }).then(
|
await this.queueService.createPostNoteJob(note.id, silent, 'edit');
|
||||||
() => this.postNoteEdited(edited, oldnote, user, data, silent, tags!, mentionedUsers!),
|
|
||||||
() => { /* aborted, ignore this */ },
|
|
||||||
);
|
|
||||||
|
|
||||||
return edited;
|
return edited;
|
||||||
} else {
|
} else {
|
||||||
return oldnote;
|
return oldNote;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async postNoteEdited(note: MiNote, oldNote: MiNote, user: MiUser & {
|
public async postNoteEdited(note: MiNote, user: MiUser, data: MiNote & { poll: MiPoll | null }, silent: boolean, mentionedUsers: MinimumUser[]) {
|
||||||
id: MiUser['id'];
|
|
||||||
username: MiUser['username'];
|
|
||||||
host: MiUser['host'];
|
|
||||||
isBot: MiUser['isBot'];
|
|
||||||
noindex: MiUser['noindex'];
|
|
||||||
}, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) {
|
|
||||||
// Register host
|
// Register host
|
||||||
if (this.meta.enableStatsForFederatedInstances) {
|
if (this.meta.enableStatsForFederatedInstances) {
|
||||||
if (isRemoteUser(user)) {
|
if (isRemoteUser(user)) {
|
||||||
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
|
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
|
||||||
if (note.renote && note.text || !note.renote) {
|
if (note.renote && note.text || !note.renote) {
|
||||||
this.updateNotesCountQueue.enqueue(i.id, 1);
|
await this.collapsedQueueService.updateInstanceQueue.enqueue(i.id, { notesCountDelta: 1 });
|
||||||
}
|
}
|
||||||
if (this.meta.enableChartsForFederatedInstances) {
|
if (this.meta.enableChartsForFederatedInstances) {
|
||||||
this.instanceChart.updateNote(i.host, note, true);
|
this.instanceChart.updateNote(i.host, note, true);
|
||||||
|
|
@ -621,15 +621,15 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.usersRepository.update({ id: user.id }, { updatedAt: this.timeService.date });
|
await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { updatedAt: this.timeService.date });
|
||||||
|
|
||||||
// ハッシュタグ更新
|
// ハッシュタグ更新
|
||||||
this.pushToTl(note, user);
|
await this.pushToTl(note, user);
|
||||||
|
|
||||||
if (data.poll && data.poll.expiresAt) {
|
if (data.poll && data.poll.expiresAt) {
|
||||||
const delay = data.poll.expiresAt.getTime() - this.timeService.now;
|
const delay = data.poll.expiresAt.getTime() - this.timeService.now;
|
||||||
this.queueService.endedPollNotificationQueue.remove(`pollEnd:${note.id}`);
|
await this.queueService.endedPollNotificationQueue.remove(`pollEnd:${note.id}`);
|
||||||
this.queueService.endedPollNotificationQueue.add(note.id, {
|
await this.queueService.endedPollNotificationQueue.add(note.id, {
|
||||||
noteId: note.id,
|
noteId: note.id,
|
||||||
}, {
|
}, {
|
||||||
jobId: `pollEnd_${note.id}`,
|
jobId: `pollEnd_${note.id}`,
|
||||||
|
|
@ -648,9 +648,9 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
text: note.text ?? '',
|
text: note.text ?? '',
|
||||||
});
|
});
|
||||||
|
|
||||||
this.roleService.addNoteToRoleTimeline(noteObj);
|
await this.roleService.addNoteToRoleTimeline(noteObj);
|
||||||
|
|
||||||
this.webhookService.enqueueUserWebhook(user.id, 'note', { note: noteObj });
|
await this.webhookService.enqueueUserWebhook(user.id, 'note', { note: noteObj });
|
||||||
|
|
||||||
const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note);
|
const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note);
|
||||||
|
|
||||||
|
|
@ -673,16 +673,16 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
if (!isThreadMuted && !muted) {
|
if (!isThreadMuted && !muted) {
|
||||||
nm.push(data.reply.userId, 'edited');
|
nm.push(data.reply.userId, 'edited');
|
||||||
this.globalEventService.publishMainStream(data.reply.userId, 'edited', noteObj);
|
this.globalEventService.publishMainStream(data.reply.userId, 'edited', noteObj);
|
||||||
this.webhookService.enqueueUserWebhook(data.reply.userId, 'reply', { note: noteObj });
|
await this.webhookService.enqueueUserWebhook(data.reply.userId, 'reply', { note: noteObj });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nm.notify();
|
await nm.notify();
|
||||||
|
|
||||||
//#region AP deliver
|
//#region AP deliver
|
||||||
if (!data.localOnly && isLocalUser(user)) {
|
if (!data.localOnly && isLocalUser(user)) {
|
||||||
trackTask(async () => {
|
await trackTask(async () => {
|
||||||
const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
|
const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
|
||||||
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
|
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
|
||||||
|
|
||||||
|
|
@ -737,8 +737,8 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.channel) {
|
if (data.channel) {
|
||||||
this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1);
|
await this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1);
|
||||||
this.channelsRepository.update(data.channel.id, {
|
await this.channelsRepository.update(data.channel.id, {
|
||||||
lastNotedAt: this.timeService.date,
|
lastNotedAt: this.timeService.date,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -755,10 +755,10 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the Latest Note index / following feed
|
// Update the Latest Note index / following feed
|
||||||
this.latestNoteService.handleUpdatedNoteBG(oldNote, note);
|
await this.latestNoteService.handleUpdatedNoteDeferred(note);
|
||||||
|
|
||||||
// Register to search database
|
// Register to search database
|
||||||
if (!user.noindex) this.index(note);
|
if (!user.noindex) await this.index(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -779,27 +779,10 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private index(note: MiNote) {
|
private async index(note: MiNote) {
|
||||||
if (note.text == null && note.cw == null) return;
|
if (note.text == null && note.cw == null) return;
|
||||||
|
|
||||||
this.searchService.indexNote(note);
|
await this.searchService.indexNote(note);
|
||||||
}
|
|
||||||
|
|
||||||
@bindThis
|
|
||||||
private async extractMentionedUsers(user: { host: MiUser['host']; }, tokens: mfm.MfmNode[]): Promise<MiUser[]> {
|
|
||||||
if (tokens == null) return [];
|
|
||||||
|
|
||||||
const mentions = extractMentions(tokens);
|
|
||||||
let mentionedUsers = (await Promise.all(mentions.map(m =>
|
|
||||||
this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null),
|
|
||||||
))).filter(x => x !== null) as MiUser[];
|
|
||||||
|
|
||||||
// Drop duplicate users
|
|
||||||
mentionedUsers = mentionedUsers.filter((u, i, self) =>
|
|
||||||
i === self.findIndex(u2 => u.id === u2.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
return mentionedUsers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -912,25 +895,14 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
// checkHibernation moved to HibernateUsersProcessorService
|
// checkHibernation moved to HibernateUsersProcessorService
|
||||||
}
|
}
|
||||||
|
|
||||||
r.exec();
|
await r.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkHibernation moved to HibernateUsersProcessorService
|
// checkHibernation moved to HibernateUsersProcessorService
|
||||||
|
|
||||||
@bindThis
|
|
||||||
private collapseNotesCount(oldValue: number, newValue: number) {
|
|
||||||
return oldValue + newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
@bindThis
|
|
||||||
private async performUpdateNotesCount(id: MiNote['id'], incrBy: number) {
|
|
||||||
await this.instancesRepository.increment({ id: id }, 'notesCount', incrBy);
|
|
||||||
}
|
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async dispose(): Promise<void> {
|
public async dispose(): Promise<void> {
|
||||||
this.#shutdownController.abort();
|
this.#shutdownController.abort();
|
||||||
await this.updateNotesCountQueue.performAllNow();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ
|
||||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
|
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||||
import type { DataSource } from 'typeorm';
|
import type { DataSource } from 'typeorm';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -84,7 +85,7 @@ export class NotePiningService {
|
||||||
|
|
||||||
// Deliver to remote followers
|
// Deliver to remote followers
|
||||||
if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) {
|
if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) {
|
||||||
await this.deliverPinnedChange(user, note.id, true);
|
trackPromise(this.deliverPinnedChange(user, note.id, true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,7 +113,7 @@ export class NotePiningService {
|
||||||
|
|
||||||
// Deliver to remote followers
|
// Deliver to remote followers
|
||||||
if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) {
|
if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) {
|
||||||
await this.deliverPinnedChange(user, noteId, false);
|
trackPromise(this.deliverPinnedChange(user, noteId, false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,9 +72,9 @@ export class NotificationService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private postReadAllNotifications(userId: MiUser['id']) {
|
private async postReadAllNotifications(userId: MiUser['id']) {
|
||||||
this.globalEventService.publishMainStream(userId, 'readAllNotifications');
|
this.globalEventService.publishMainStream(userId, 'readAllNotifications');
|
||||||
this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined);
|
await this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ export class PollService {
|
||||||
public async deliverQuestionUpdate(note: MiNote) {
|
public async deliverQuestionUpdate(note: MiNote) {
|
||||||
if (note.localOnly) return;
|
if (note.localOnly) return;
|
||||||
|
|
||||||
const user = await this.usersRepository.findOneBy({ id: note.userId });
|
const user = note.user ?? await this.usersRepository.findOneBy({ id: note.userId });
|
||||||
if (user == null) throw new Error('note not found');
|
if (user == null) throw new Error('note not found');
|
||||||
|
|
||||||
if (isLocalUser(user)) {
|
if (isLocalUser(user)) {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
UserWebhookDeliverJobData,
|
UserWebhookDeliverJobData,
|
||||||
SystemWebhookDeliverJobData,
|
SystemWebhookDeliverJobData,
|
||||||
ScheduleNotePostJobData,
|
ScheduleNotePostJobData,
|
||||||
|
BackgroundTaskJobData,
|
||||||
} from '../queue/types.js';
|
} from '../queue/types.js';
|
||||||
import type { Provider } from '@nestjs/common';
|
import type { Provider } from '@nestjs/common';
|
||||||
|
|
||||||
|
|
@ -33,6 +34,7 @@ export type ObjectStorageQueue = Bull.Queue;
|
||||||
export type UserWebhookDeliverQueue = Bull.Queue<UserWebhookDeliverJobData>;
|
export type UserWebhookDeliverQueue = Bull.Queue<UserWebhookDeliverJobData>;
|
||||||
export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>;
|
export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>;
|
||||||
export type ScheduleNotePostQueue = Bull.Queue<ScheduleNotePostJobData>;
|
export type ScheduleNotePostQueue = Bull.Queue<ScheduleNotePostJobData>;
|
||||||
|
export type BackgroundTaskQueue = Bull.Queue<BackgroundTaskJobData>;
|
||||||
|
|
||||||
const $system: Provider = {
|
const $system: Provider = {
|
||||||
provide: 'queue:system',
|
provide: 'queue:system',
|
||||||
|
|
@ -94,6 +96,12 @@ const $scheduleNotePost: Provider = {
|
||||||
inject: [DI.config],
|
inject: [DI.config],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const $backgroundTask: Provider = {
|
||||||
|
provide: 'queue:backgroundTask',
|
||||||
|
useFactory: (config: Config) => new Bull.Queue(QUEUE.BACKGROUND_TASK, baseQueueOptions(config, QUEUE.BACKGROUND_TASK)),
|
||||||
|
inject: [DI.config],
|
||||||
|
};
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
],
|
],
|
||||||
|
|
@ -108,6 +116,7 @@ const $scheduleNotePost: Provider = {
|
||||||
$userWebhookDeliver,
|
$userWebhookDeliver,
|
||||||
$systemWebhookDeliver,
|
$systemWebhookDeliver,
|
||||||
$scheduleNotePost,
|
$scheduleNotePost,
|
||||||
|
$backgroundTask,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
$system,
|
$system,
|
||||||
|
|
@ -120,6 +129,7 @@ const $scheduleNotePost: Provider = {
|
||||||
$userWebhookDeliver,
|
$userWebhookDeliver,
|
||||||
$systemWebhookDeliver,
|
$systemWebhookDeliver,
|
||||||
$scheduleNotePost,
|
$scheduleNotePost,
|
||||||
|
$backgroundTask,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class QueueModule implements OnApplicationShutdown {
|
export class QueueModule implements OnApplicationShutdown {
|
||||||
|
|
@ -136,6 +146,7 @@ export class QueueModule implements OnApplicationShutdown {
|
||||||
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
||||||
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
||||||
@Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue,
|
@Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue,
|
||||||
|
@Inject('queue:backgroundTask') public readonly backgroundTaskQueue: BackgroundTaskQueue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async dispose(): Promise<void> {
|
public async dispose(): Promise<void> {
|
||||||
|
|
@ -155,6 +166,7 @@ export class QueueModule implements OnApplicationShutdown {
|
||||||
this.userWebhookDeliverQueue.close(),
|
this.userWebhookDeliverQueue.close(),
|
||||||
this.systemWebhookDeliverQueue.close(),
|
this.systemWebhookDeliverQueue.close(),
|
||||||
this.scheduleNotePostQueue.close(),
|
this.scheduleNotePostQueue.close(),
|
||||||
|
this.backgroundTaskQueue.close(),
|
||||||
]).then(res => {
|
]).then(res => {
|
||||||
for (const result of res) {
|
for (const result of res) {
|
||||||
if (result.status === 'rejected') {
|
if (result.status === 'rejected') {
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,10 @@ import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
|
||||||
import { TimeService } from '@/global/TimeService.js';
|
import { TimeService } from '@/global/TimeService.js';
|
||||||
import type { SystemWebhookPayload } from '@/core/SystemWebhookService.js';
|
import type { SystemWebhookPayload } from '@/core/SystemWebhookService.js';
|
||||||
import type { MiNote } from '@/models/Note.js';
|
import type { MiNote } from '@/models/Note.js';
|
||||||
|
import type { MinimalNote } from '@/misc/is-renote.js';
|
||||||
import { type UserWebhookPayload } from './UserWebhookService.js';
|
import { type UserWebhookPayload } from './UserWebhookService.js';
|
||||||
import type {
|
import type {
|
||||||
|
BackgroundTaskJobData,
|
||||||
DbJobData,
|
DbJobData,
|
||||||
DeliverJobData,
|
DeliverJobData,
|
||||||
RelationshipJobData,
|
RelationshipJobData,
|
||||||
|
|
@ -39,6 +41,7 @@ import type {
|
||||||
SystemWebhookDeliverQueue,
|
SystemWebhookDeliverQueue,
|
||||||
UserWebhookDeliverQueue,
|
UserWebhookDeliverQueue,
|
||||||
ScheduleNotePostQueue,
|
ScheduleNotePostQueue,
|
||||||
|
BackgroundTaskQueue,
|
||||||
} from './QueueModule.js';
|
} from './QueueModule.js';
|
||||||
import type httpSignature from '@peertube/http-signature';
|
import type httpSignature from '@peertube/http-signature';
|
||||||
import type * as Bull from 'bullmq';
|
import type * as Bull from 'bullmq';
|
||||||
|
|
@ -54,6 +57,7 @@ export const QUEUE_TYPES = [
|
||||||
'userWebhookDeliver',
|
'userWebhookDeliver',
|
||||||
'systemWebhookDeliver',
|
'systemWebhookDeliver',
|
||||||
'scheduleNotePost',
|
'scheduleNotePost',
|
||||||
|
'backgroundTask',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -72,6 +76,7 @@ export class QueueService implements OnModuleInit {
|
||||||
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
||||||
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
||||||
@Inject('queue:scheduleNotePost') public ScheduleNotePostQueue: ScheduleNotePostQueue,
|
@Inject('queue:scheduleNotePost') public ScheduleNotePostQueue: ScheduleNotePostQueue,
|
||||||
|
@Inject('queue:backgroundTask') public readonly backgroundTaskQueue: BackgroundTaskQueue,
|
||||||
|
|
||||||
private readonly timeService: TimeService,
|
private readonly timeService: TimeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
@ -839,6 +844,107 @@ export class QueueService implements OnModuleInit {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async createUpdateUserJob(userId: string) {
|
||||||
|
return await this.createBackgroundTask({ type: 'update-user', userId }, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async createUpdateFeaturedJob(userId: string) {
|
||||||
|
return await this.createBackgroundTask({ type: 'update-featured', userId }, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async createUpdateInstanceJob(host: string) {
|
||||||
|
return await this.createBackgroundTask({ type: 'update-instance', host }, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async createPostDeliverJob(host: string, result: 'success' | 'temp-fail' | 'perm-fail') {
|
||||||
|
return await this.createBackgroundTask({ type: 'post-deliver', host, result });
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async createPostInboxJob(host: string) {
|
||||||
|
return await this.createBackgroundTask({ type: 'post-inbox', host });
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async createPostNoteJob(noteId: string, silent: boolean, type: 'create' | 'edit') {
|
||||||
|
const edit = type === 'edit';
|
||||||
|
const duplication = `${noteId}_${type}`;
|
||||||
|
|
||||||
|
return await this.createBackgroundTask({ type: 'post-note', noteId, silent, edit }, duplication);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async createUpdateUserTagsJob(userId: string) {
|
||||||
|
return await this.createBackgroundTask({ type: 'update-user-tags', userId }, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async createUpdateNoteTagsJob(noteId: string) {
|
||||||
|
return await this.createBackgroundTask({ type: 'update-note-tags', noteId }, noteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async createDeleteFileJob(fileId: string, isExpired?: boolean, deleterId?: string) {
|
||||||
|
return await this.createBackgroundTask({ type: 'delete-file', fileId, isExpired, deleterId }, fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async createUpdateLatestNoteJob(note: MinimalNote) {
|
||||||
|
// Compact the note to avoid storing the entire thing in Redis, when all we need is minimal data for categorization
|
||||||
|
const minimizedNote: MinimalNote = {
|
||||||
|
id: note.id,
|
||||||
|
visibility: note.visibility,
|
||||||
|
userId: note.userId,
|
||||||
|
replyId: note.replyId,
|
||||||
|
renoteId: note.renoteId,
|
||||||
|
hasPoll: note.hasPoll,
|
||||||
|
text: note.text ? '1' : null,
|
||||||
|
cw: note.text ? '1' : null,
|
||||||
|
fileIds: note.fileIds.length > 0 ? ['1'] : [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.createBackgroundTask({ type: 'update-latest-note', note: minimizedNote }, note.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async createPostSuspendJob(userId: string) {
|
||||||
|
return await this.createBackgroundTask({ type: 'post-suspend', userId }, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async createPostUnsuspendJob(userId: string) {
|
||||||
|
return await this.createBackgroundTask({ type: 'post-unsuspend', userId }, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async createDeleteApLogsJob(dataType: 'inbox' | 'object', data: string | string[]) {
|
||||||
|
return await this.createBackgroundTask({ type: 'delete-ap-logs', dataType, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createBackgroundTask<T extends BackgroundTaskJobData>(data: T, duplication?: string | { id: string, ttl?: number }) {
|
||||||
|
return await this.backgroundTaskQueue.add(
|
||||||
|
data.type,
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
// https://docs.bullmq.io/guide/retrying-failing-jobs#custom-back-off-strategies
|
||||||
|
attempts: this.config.backgroundJobMaxAttempts ?? 8,
|
||||||
|
backoff: {
|
||||||
|
// Resolves to QueueProcessorService::HttpRelatedBackoff()
|
||||||
|
type: 'custom',
|
||||||
|
},
|
||||||
|
|
||||||
|
// https://docs.bullmq.io/guide/jobs/deduplication
|
||||||
|
deduplication: typeof(duplication) === 'string'
|
||||||
|
? { id: `${data.type}_${duplication}` }
|
||||||
|
: duplication,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see UserWebhookDeliverJobData
|
* @see UserWebhookDeliverJobData
|
||||||
* @see UserWebhookDeliverProcessorService
|
* @see UserWebhookDeliverProcessorService
|
||||||
|
|
@ -927,6 +1033,7 @@ export class QueueService implements OnModuleInit {
|
||||||
case 'userWebhookDeliver': return this.userWebhookDeliverQueue;
|
case 'userWebhookDeliver': return this.userWebhookDeliverQueue;
|
||||||
case 'systemWebhookDeliver': return this.systemWebhookDeliverQueue;
|
case 'systemWebhookDeliver': return this.systemWebhookDeliverQueue;
|
||||||
case 'scheduleNotePost': return this.ScheduleNotePostQueue;
|
case 'scheduleNotePost': return this.ScheduleNotePostQueue;
|
||||||
|
case 'backgroundTask': return this.backgroundTaskQueue;
|
||||||
default: throw new Error(`Unrecognized queue type: ${type}`);
|
default: throw new Error(`Unrecognized queue type: ${type}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js';
|
||||||
import { CacheService } from '@/core/CacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
|
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
|
||||||
import { TimeService } from '@/global/TimeService.js';
|
import { TimeService } from '@/global/TimeService.js';
|
||||||
|
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
|
||||||
import type { DataSource } from 'typeorm';
|
import type { DataSource } from 'typeorm';
|
||||||
|
|
||||||
const FALLBACK = '\u2764';
|
const FALLBACK = '\u2764';
|
||||||
|
|
@ -110,6 +111,7 @@ export class ReactionService implements OnModuleInit {
|
||||||
private readonly cacheService: CacheService,
|
private readonly cacheService: CacheService,
|
||||||
private readonly noteVisibilityService: NoteVisibilityService,
|
private readonly noteVisibilityService: NoteVisibilityService,
|
||||||
private readonly timeService: TimeService,
|
private readonly timeService: TimeService,
|
||||||
|
private readonly collapsedQueueService: CollapsedQueueService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,7 +121,7 @@ export class ReactionService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async create(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot'] }, note: MiNote, _reaction?: string | null) {
|
public async create(user: MiUser, note: MiNote, _reaction?: string | null) {
|
||||||
// Check blocking
|
// Check blocking
|
||||||
if (note.userId !== user.id) {
|
if (note.userId !== user.id) {
|
||||||
const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id);
|
const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id);
|
||||||
|
|
@ -224,7 +226,7 @@ export class ReactionService implements OnModuleInit {
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.usersRepository.update({ id: user.id }, { updatedAt: this.timeService.date });
|
await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { updatedAt: this.timeService.date });
|
||||||
|
|
||||||
// 30%の確率、セルフではない、3日以内に投稿されたノートの場合ハイライト用ランキング更新
|
// 30%の確率、セルフではない、3日以内に投稿されたノートの場合ハイライト用ランキング更新
|
||||||
if (
|
if (
|
||||||
|
|
@ -289,16 +291,18 @@ export class ReactionService implements OnModuleInit {
|
||||||
const content = this.apRendererService.addContext(await this.apRendererService.renderLike(record, note));
|
const content = this.apRendererService.addContext(await this.apRendererService.renderLike(record, note));
|
||||||
const dm = this.apDeliverManagerService.createDeliverManager(user, content);
|
const dm = this.apDeliverManagerService.createDeliverManager(user, content);
|
||||||
if (note.userHost !== null) {
|
if (note.userHost !== null) {
|
||||||
const reactee = await this.usersRepository.findOneBy({ id: note.userId });
|
const reactee = await this.cacheService.findRemoteUserById(note.userId);
|
||||||
dm.addDirectRecipe(reactee as MiRemoteUser);
|
dm.addDirectRecipe(reactee as MiRemoteUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (['public', 'home', 'followers'].includes(note.visibility)) {
|
if (['public', 'home', 'followers'].includes(note.visibility)) {
|
||||||
dm.addFollowersRecipe();
|
dm.addFollowersRecipe();
|
||||||
} else if (note.visibility === 'specified') {
|
} else if (note.visibility === 'specified') {
|
||||||
const visibleUsers = await Promise.all(note.visibleUserIds.map(id => this.usersRepository.findOneBy({ id })));
|
const visibleUsers = await this.cacheService.findUsersById(note.visibleUserIds);
|
||||||
for (const u of visibleUsers.filter(u => u && isRemoteUser(u))) {
|
for (const u of visibleUsers.values()) {
|
||||||
dm.addDirectRecipe(u as MiRemoteUser);
|
if (isRemoteUser(u)) {
|
||||||
|
dm.addDirectRecipe(u as MiRemoteUser);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -308,7 +312,7 @@ export class ReactionService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, exist?: MiNoteReaction | null) {
|
public async delete(user: MiUser, note: MiNote, exist?: MiNoteReaction | null) {
|
||||||
// if already unreacted
|
// if already unreacted
|
||||||
exist ??= await this.noteReactionsRepository.findOneBy({
|
exist ??= await this.noteReactionsRepository.findOneBy({
|
||||||
noteId: note.id,
|
noteId: note.id,
|
||||||
|
|
@ -340,7 +344,7 @@ export class ReactionService implements OnModuleInit {
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.usersRepository.update({ id: user.id }, { updatedAt: this.timeService.date });
|
await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { updatedAt: this.timeService.date });
|
||||||
|
|
||||||
this.globalEventService.publishNoteStream(note.id, 'unreacted', {
|
this.globalEventService.publishNoteStream(note.id, 'unreacted', {
|
||||||
reaction: this.decodeReaction(exist.reaction).reaction,
|
reaction: this.decodeReaction(exist.reaction).reaction,
|
||||||
|
|
@ -352,7 +356,7 @@ export class ReactionService implements OnModuleInit {
|
||||||
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(await this.apRendererService.renderLike(exist, note), user));
|
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(await this.apRendererService.renderLike(exist, note), user));
|
||||||
const dm = this.apDeliverManagerService.createDeliverManager(user, content);
|
const dm = this.apDeliverManagerService.createDeliverManager(user, content);
|
||||||
if (note.userHost !== null) {
|
if (note.userHost !== null) {
|
||||||
const reactee = await this.usersRepository.findOneBy({ id: note.userId });
|
const reactee = await this.cacheService.findRemoteUserById(note.userId);
|
||||||
dm.addDirectRecipe(reactee as MiRemoteUser);
|
dm.addDirectRecipe(reactee as MiRemoteUser);
|
||||||
}
|
}
|
||||||
dm.addFollowersRecipe();
|
dm.addFollowersRecipe();
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import chalk from 'chalk';
|
||||||
import { IsNull } from 'typeorm';
|
import { IsNull } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { UsersRepository } from '@/models/_.js';
|
import type { UsersRepository } from '@/models/_.js';
|
||||||
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { UtilityService } from '@/core/UtilityService.js';
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
|
|
@ -59,7 +59,7 @@ export class RemoteUserResolveService {
|
||||||
const acct = Acct.toString({ username, host }); // username+host -> acct (handle)
|
const acct = Acct.toString({ username, host }); // username+host -> acct (handle)
|
||||||
|
|
||||||
// Try fetch from DB
|
// Try fetch from DB
|
||||||
let user = await this.cacheService.findUserByAcct(acct).catch(() => null); // Error is expected if the user doesn't exist yet
|
let user: MiUser | null | undefined = await this.cacheService.findOptionalUserByAcct(acct);
|
||||||
|
|
||||||
// Opportunistically update remote users
|
// Opportunistically update remote users
|
||||||
if (user != null && isRemoteUser(user)) {
|
if (user != null && isRemoteUser(user)) {
|
||||||
|
|
|
||||||
|
|
@ -587,6 +587,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
||||||
updatedAt: parsed.user1.updatedAt != null ? new Date(parsed.user1.updatedAt) : null,
|
updatedAt: parsed.user1.updatedAt != null ? new Date(parsed.user1.updatedAt) : null,
|
||||||
lastActiveDate: parsed.user1.lastActiveDate != null ? new Date(parsed.user1.lastActiveDate) : null,
|
lastActiveDate: parsed.user1.lastActiveDate != null ? new Date(parsed.user1.lastActiveDate) : null,
|
||||||
lastFetchedAt: parsed.user1.lastFetchedAt != null ? new Date(parsed.user1.lastFetchedAt) : null,
|
lastFetchedAt: parsed.user1.lastFetchedAt != null ? new Date(parsed.user1.lastFetchedAt) : null,
|
||||||
|
lastFetchedFeaturedAt: parsed.user1.lastFetchedFeaturedAt != null ? new Date(parsed.user1.lastFetchedFeaturedAt) : null,
|
||||||
movedAt: parsed.user1.movedAt != null ? new Date(parsed.user1.movedAt) : null,
|
movedAt: parsed.user1.movedAt != null ? new Date(parsed.user1.movedAt) : null,
|
||||||
instance: null,
|
instance: null,
|
||||||
userProfile: null,
|
userProfile: null,
|
||||||
|
|
@ -599,6 +600,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
||||||
updatedAt: parsed.user2.updatedAt != null ? new Date(parsed.user2.updatedAt) : null,
|
updatedAt: parsed.user2.updatedAt != null ? new Date(parsed.user2.updatedAt) : null,
|
||||||
lastActiveDate: parsed.user2.lastActiveDate != null ? new Date(parsed.user2.lastActiveDate) : null,
|
lastActiveDate: parsed.user2.lastActiveDate != null ? new Date(parsed.user2.lastActiveDate) : null,
|
||||||
lastFetchedAt: parsed.user2.lastFetchedAt != null ? new Date(parsed.user2.lastFetchedAt) : null,
|
lastFetchedAt: parsed.user2.lastFetchedAt != null ? new Date(parsed.user2.lastFetchedAt) : null,
|
||||||
|
lastFetchedFeaturedAt: parsed.user2.lastFetchedFeaturedAt != null ? new Date(parsed.user2.lastFetchedFeaturedAt) : null,
|
||||||
movedAt: parsed.user2.movedAt != null ? new Date(parsed.user2.movedAt) : null,
|
movedAt: parsed.user2.movedAt != null ? new Date(parsed.user2.movedAt) : null,
|
||||||
instance: null,
|
instance: null,
|
||||||
userProfile: null,
|
userProfile: null,
|
||||||
|
|
|
||||||
|
|
@ -844,7 +844,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||||
this.globalEventService.publishRoleTimelineStream(role.id, 'note', note);
|
this.globalEventService.publishRoleTimelineStream(role.id, 'note', note);
|
||||||
}
|
}
|
||||||
|
|
||||||
redisPipeline.exec();
|
await redisPipeline.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ export class S3Service implements OnApplicationShutdown {
|
||||||
@bindThis
|
@bindThis
|
||||||
public async upload(input: PutObjectCommandInput) {
|
public async upload(input: PutObjectCommandInput) {
|
||||||
const client = this.getS3Client();
|
const client = this.getS3Client();
|
||||||
return new Upload({
|
return await new Upload({
|
||||||
client,
|
client,
|
||||||
params: input,
|
params: input,
|
||||||
partSize: (client.config.endpoint && (await client.config.endpoint()).hostname === 'storage.googleapis.com')
|
partSize: (client.config.endpoint && (await client.config.endpoint()).hostname === 'storage.googleapis.com')
|
||||||
|
|
|
||||||
|
|
@ -256,10 +256,10 @@ export class SearchService {
|
||||||
case 'sqlTsvector': {
|
case 'sqlTsvector': {
|
||||||
// ほとんど内容に差がないのでsqlLikeとsqlPgroongaを同じ処理にしている.
|
// ほとんど内容に差がないのでsqlLikeとsqlPgroongaを同じ処理にしている.
|
||||||
// 今後の拡張で差が出る用であれば関数を分ける.
|
// 今後の拡張で差が出る用であれば関数を分ける.
|
||||||
return this.searchNoteByLike(q, me, opts, pagination);
|
return await this.searchNoteByLike(q, me, opts, pagination);
|
||||||
}
|
}
|
||||||
case 'meilisearch': {
|
case 'meilisearch': {
|
||||||
return this.searchNoteByMeiliSearch(q, me, opts, pagination);
|
return await this.searchNoteByMeiliSearch(q, me, opts, pagination);
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ export class SponsorsService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// TODO use HTTP service
|
||||||
const backers = await fetch(`${this.meta.donationUrl}/members/users.json`).then((response) => response.json() as Promise<Sponsor[]>);
|
const backers = await fetch(`${this.meta.donationUrl}/members/users.json`).then((response) => response.json() as Promise<Sponsor[]>);
|
||||||
|
|
||||||
// Merge both together into one array and make sure it only has Active subscriptions
|
// Merge both together into one array and make sure it only has Active subscriptions
|
||||||
|
|
@ -76,6 +77,7 @@ export class SponsorsService {
|
||||||
@bindThis
|
@bindThis
|
||||||
private async fetchSharkeySponsors(): Promise<Sponsor[]> {
|
private async fetchSharkeySponsors(): Promise<Sponsor[]> {
|
||||||
try {
|
try {
|
||||||
|
// TODO use HTTP service
|
||||||
const backers = await fetch('https://opencollective.com/sharkey/tiers/backer/all.json').then((response) => response.json() as Promise<Sponsor[]>);
|
const backers = await fetch('https://opencollective.com/sharkey/tiers/backer/all.json').then((response) => response.json() as Promise<Sponsor[]>);
|
||||||
const sponsorsOC = await fetch('https://opencollective.com/sharkey/tiers/sponsor/all.json').then((response) => response.json() as Promise<Sponsor[]>);
|
const sponsorsOC = await fetch('https://opencollective.com/sharkey/tiers/sponsor/all.json').then((response) => response.json() as Promise<Sponsor[]>);
|
||||||
|
|
||||||
|
|
@ -92,12 +94,12 @@ export class SponsorsService {
|
||||||
@bindThis
|
@bindThis
|
||||||
public async instanceSponsors(forceUpdate: boolean) {
|
public async instanceSponsors(forceUpdate: boolean) {
|
||||||
if (forceUpdate) await this.cache.refresh('instance');
|
if (forceUpdate) await this.cache.refresh('instance');
|
||||||
return this.cache.fetch('instance');
|
return await this.cache.fetch('instance');
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async sharkeySponsors(forceUpdate: boolean) {
|
public async sharkeySponsors(forceUpdate: boolean) {
|
||||||
if (forceUpdate) await this.cache.refresh('sharkey');
|
if (forceUpdate) await this.cache.refresh('sharkey');
|
||||||
return this.cache.fetch('sharkey');
|
return await this.cache.fetch('sharkey');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
|
|
||||||
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
|
|
||||||
import { bindThis } from '@/decorators.js';
|
|
||||||
import { MiNote } from '@/models/Note.js';
|
|
||||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
|
||||||
import { TimeService } from '@/global/TimeService.js';
|
|
||||||
|
|
||||||
type UpdateInstanceJob = {
|
|
||||||
latestRequestReceivedAt: Date,
|
|
||||||
shouldUnsuspend: boolean,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Moved from InboxProcessorService to allow access from ApInboxService
|
|
||||||
@Injectable()
|
|
||||||
export class UpdateInstanceQueue extends CollapsedQueue<MiNote['id'], UpdateInstanceJob> implements OnApplicationShutdown {
|
|
||||||
constructor(
|
|
||||||
private readonly federatedInstanceService: FederatedInstanceService,
|
|
||||||
timeService: TimeService,
|
|
||||||
) {
|
|
||||||
super(timeService, process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, (id, job) => this.collapseUpdateInstanceJobs(id, job), (id, job) => this.performUpdateInstance(id, job));
|
|
||||||
}
|
|
||||||
|
|
||||||
@bindThis
|
|
||||||
private collapseUpdateInstanceJobs(oldJob: UpdateInstanceJob, newJob: UpdateInstanceJob) {
|
|
||||||
const latestRequestReceivedAt = oldJob.latestRequestReceivedAt < newJob.latestRequestReceivedAt
|
|
||||||
? newJob.latestRequestReceivedAt
|
|
||||||
: oldJob.latestRequestReceivedAt;
|
|
||||||
const shouldUnsuspend = oldJob.shouldUnsuspend || newJob.shouldUnsuspend;
|
|
||||||
return {
|
|
||||||
latestRequestReceivedAt,
|
|
||||||
shouldUnsuspend,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@bindThis
|
|
||||||
private async performUpdateInstance(id: string, job: UpdateInstanceJob) {
|
|
||||||
await this.federatedInstanceService.update(id, {
|
|
||||||
latestRequestReceivedAt: this.timeService.date,
|
|
||||||
isNotResponding: false,
|
|
||||||
// もしサーバーが死んでるために配信が止まっていた場合には自動的に復活させてあげる
|
|
||||||
suspensionState: job.shouldUnsuspend ? 'none' : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@bindThis
|
|
||||||
async onApplicationShutdown() {
|
|
||||||
await this.performAllNow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -30,6 +30,8 @@ import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import type { ThinUser } from '@/queue/types.js';
|
import type { ThinUser } from '@/queue/types.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.js';
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
import { InternalEventService } from '@/global/InternalEventService.js';
|
import { InternalEventService } from '@/global/InternalEventService.js';
|
||||||
|
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||||
|
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
|
||||||
import type Logger from '../logger.js';
|
import type Logger from '../logger.js';
|
||||||
|
|
||||||
type Local = MiLocalUser | {
|
type Local = MiLocalUser | {
|
||||||
|
|
@ -88,6 +90,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
private perUserFollowingChart: PerUserFollowingChart,
|
private perUserFollowingChart: PerUserFollowingChart,
|
||||||
private instanceChart: InstanceChart,
|
private instanceChart: InstanceChart,
|
||||||
private readonly internalEventService: InternalEventService,
|
private readonly internalEventService: InternalEventService,
|
||||||
|
private readonly collapsedQueueService: CollapsedQueueService,
|
||||||
|
|
||||||
loggerService: LoggerService,
|
loggerService: LoggerService,
|
||||||
) {
|
) {
|
||||||
|
|
@ -102,7 +105,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
@bindThis
|
@bindThis
|
||||||
public async deliverAccept(follower: MiRemoteUser, followee: MiPartialLocalUser, requestId?: string) {
|
public async deliverAccept(follower: MiRemoteUser, followee: MiPartialLocalUser, requestId?: string) {
|
||||||
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee));
|
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee));
|
||||||
this.queueService.deliver(followee, content, follower.inbox, false);
|
await this.queueService.deliver(followee, content, follower.inbox, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -152,7 +155,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
// すでにフォロー関係が存在している場合
|
// すでにフォロー関係が存在している場合
|
||||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||||
// リモート → ローカル: acceptを送り返しておしまい
|
// リモート → ローカル: acceptを送り返しておしまい
|
||||||
this.deliverAccept(follower, followee, requestId);
|
trackPromise(this.deliverAccept(follower, followee, requestId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.userEntityService.isLocalUser(follower)) {
|
if (this.userEntityService.isLocalUser(follower)) {
|
||||||
|
|
@ -206,7 +209,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
await this.insertFollowingDoc(followee, follower, silent, withReplies);
|
await this.insertFollowingDoc(followee, follower, silent, withReplies);
|
||||||
|
|
||||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||||
this.deliverAccept(follower, followee, requestId);
|
trackPromise(this.deliverAccept(follower, followee, requestId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -285,24 +288,22 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
// Neither followee nor follower has moved.
|
// Neither followee nor follower has moved.
|
||||||
if (!followeeUser.movedToUri && !followerUser.movedToUri) {
|
if (!followeeUser.movedToUri && !followerUser.movedToUri) {
|
||||||
//#region Increment counts
|
//#region Increment counts
|
||||||
await Promise.all([
|
await this.collapsedQueueService.updateUserQueue.enqueue(follower.id, { followingCountDelta: 1 });
|
||||||
this.usersRepository.increment({ id: follower.id }, 'followingCount', 1),
|
await this.collapsedQueueService.updateUserQueue.enqueue(followee.id, { followersCountDelta: 1 });
|
||||||
this.usersRepository.increment({ id: followee.id }, 'followersCount', 1),
|
|
||||||
]);
|
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
//#region Update instance stats
|
//#region Update instance stats
|
||||||
if (this.meta.enableStatsForFederatedInstances) {
|
if (this.meta.enableStatsForFederatedInstances) {
|
||||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||||
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
|
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
|
||||||
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
|
await this.collapsedQueueService.updateInstanceQueue.enqueue(i.id, { followingCountDelta: 1 });
|
||||||
if (this.meta.enableChartsForFederatedInstances) {
|
if (this.meta.enableChartsForFederatedInstances) {
|
||||||
this.instanceChart.updateFollowing(i.host, true);
|
this.instanceChart.updateFollowing(i.host, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||||
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
|
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
|
||||||
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
|
await this.collapsedQueueService.updateInstanceQueue.enqueue(i.id, { followersCountDelta: 1 });
|
||||||
if (this.meta.enableChartsForFederatedInstances) {
|
if (this.meta.enableChartsForFederatedInstances) {
|
||||||
this.instanceChart.updateFollowers(i.host, true);
|
this.instanceChart.updateFollowers(i.host, true);
|
||||||
}
|
}
|
||||||
|
|
@ -397,24 +398,22 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
// Neither followee nor follower has moved.
|
// Neither followee nor follower has moved.
|
||||||
if (!follower.movedToUri && !followee.movedToUri) {
|
if (!follower.movedToUri && !followee.movedToUri) {
|
||||||
//#region Decrement following / followers counts
|
//#region Decrement following / followers counts
|
||||||
await Promise.all([
|
await this.collapsedQueueService.updateUserQueue.enqueue(follower.id, { followingCountDelta: -1 });
|
||||||
this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1),
|
await this.collapsedQueueService.updateUserQueue.enqueue(followee.id, { followersCountDelta: -1 });
|
||||||
this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1),
|
|
||||||
]);
|
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
//#region Update instance stats
|
//#region Update instance stats
|
||||||
if (this.meta.enableStatsForFederatedInstances) {
|
if (this.meta.enableStatsForFederatedInstances) {
|
||||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||||
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
|
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
|
||||||
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
|
await this.collapsedQueueService.updateInstanceQueue.enqueue(i.id, { followingCountDelta: -1 });
|
||||||
if (this.meta.enableChartsForFederatedInstances) {
|
if (this.meta.enableChartsForFederatedInstances) {
|
||||||
this.instanceChart.updateFollowing(i.host, false);
|
this.instanceChart.updateFollowing(i.host, false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||||
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
|
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
|
||||||
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
|
await this.collapsedQueueService.updateInstanceQueue.enqueue(i.id, { followersCountDelta: -1 });
|
||||||
if (this.meta.enableChartsForFederatedInstances) {
|
if (this.meta.enableChartsForFederatedInstances) {
|
||||||
this.instanceChart.updateFollowers(i.host, false);
|
this.instanceChart.updateFollowers(i.host, false);
|
||||||
}
|
}
|
||||||
|
|
@ -581,7 +580,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
await this.insertFollowingDoc(followee, follower, false, request.withReplies);
|
await this.insertFollowingDoc(followee, follower, false, request.withReplies);
|
||||||
|
|
||||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||||
this.deliverAccept(follower, followee as MiPartialLocalUser, request.requestId ?? undefined);
|
trackPromise(this.deliverAccept(follower, followee as MiPartialLocalUser, request.requestId ?? undefined));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.userEntityService.pack(followee.id, followee, {
|
this.userEntityService.pack(followee.id, followee, {
|
||||||
|
|
@ -595,14 +594,13 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox'];
|
id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox'];
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const requests = await this.followRequestsRepository.findBy({
|
const requests = await this.followRequestsRepository.find({ where: {
|
||||||
followeeId: user.id,
|
followeeId: user.id,
|
||||||
});
|
}, relations: {
|
||||||
|
follower: true,
|
||||||
|
} });
|
||||||
|
|
||||||
for (const request of requests) {
|
await Promise.all(requests.map(request => this.acceptFollowRequest(user, request.follower as MiUser)));
|
||||||
const follower = await this.usersRepository.findOneByOrFail({ id: request.followerId });
|
|
||||||
this.acceptFollowRequest(user, follower);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -611,7 +609,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
@bindThis
|
@bindThis
|
||||||
public async rejectFollowRequest(user: Local, follower: Both): Promise<void> {
|
public async rejectFollowRequest(user: Local, follower: Both): Promise<void> {
|
||||||
if (this.userEntityService.isRemoteUser(follower)) {
|
if (this.userEntityService.isRemoteUser(follower)) {
|
||||||
this.deliverReject(user, follower);
|
trackPromise(this.deliverReject(user, follower));
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.removeFollowRequest(user, follower);
|
await this.removeFollowRequest(user, follower);
|
||||||
|
|
@ -627,7 +625,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
@bindThis
|
@bindThis
|
||||||
public async rejectFollow(user: Local, follower: Both): Promise<void> {
|
public async rejectFollow(user: Local, follower: Both): Promise<void> {
|
||||||
if (this.userEntityService.isRemoteUser(follower)) {
|
if (this.userEntityService.isRemoteUser(follower)) {
|
||||||
this.deliverReject(user, follower);
|
trackPromise(this.deliverReject(user, follower));
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.removeFollow(user, follower);
|
await this.removeFollow(user, follower);
|
||||||
|
|
@ -696,7 +694,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
});
|
});
|
||||||
|
|
||||||
const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request?.requestId ?? undefined), followee));
|
const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request?.requestId ?? undefined), followee));
|
||||||
this.queueService.deliver(followee, content, follower.inbox, false);
|
await this.queueService.deliver(followee, content, follower.inbox, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -720,7 +718,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) {
|
public async isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) {
|
||||||
return this.cacheService.isFollowing(followerId, followeeId);
|
return await this.cacheService.isFollowing(followerId, followeeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ export class UserSearchService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.userEntityService.packMany<'UserLite' | 'UserDetailed'>(
|
return await this.userEntityService.packMany<'UserLite' | 'UserDetailed'>(
|
||||||
[...resultSet].slice(0, limit),
|
[...resultSet].slice(0, limit),
|
||||||
me,
|
me,
|
||||||
{ schema: opts?.detail ? 'UserDetailed' : 'UserLite' },
|
{ schema: opts?.detail ? 'UserDetailed' : 'UserLite' },
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { DI } from '@/di-symbols.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
import { CacheService } from '@/core/CacheService.js';
|
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
|
||||||
import { TimeService } from '@/global/TimeService.js';
|
import { TimeService } from '@/global/TimeService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -22,43 +22,14 @@ export class UserService {
|
||||||
private followingsRepository: FollowingsRepository,
|
private followingsRepository: FollowingsRepository,
|
||||||
private systemWebhookService: SystemWebhookService,
|
private systemWebhookService: SystemWebhookService,
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
private readonly cacheService: CacheService,
|
private readonly collapsedQueueService: CollapsedQueueService,
|
||||||
private readonly timeService: TimeService,
|
private readonly timeService: TimeService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async updateLastActiveDate(user: MiUser): Promise<void> {
|
public async updateLastActiveDate(user: MiUser): Promise<void> {
|
||||||
if (user.isHibernated) {
|
await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { lastActiveDate: this.timeService.date });
|
||||||
const result = await this.usersRepository.createQueryBuilder().update()
|
|
||||||
.set({
|
|
||||||
lastActiveDate: this.timeService.date,
|
|
||||||
})
|
|
||||||
.where('id = :id', { id: user.id })
|
|
||||||
.returning('*')
|
|
||||||
.execute()
|
|
||||||
.then((response) => {
|
|
||||||
return response.raw[0];
|
|
||||||
});
|
|
||||||
const wokeUp = result.isHibernated;
|
|
||||||
if (wokeUp) {
|
|
||||||
await Promise.all([
|
|
||||||
this.usersRepository.update(user.id, {
|
|
||||||
isHibernated: false,
|
|
||||||
}),
|
|
||||||
this.followingsRepository.update({
|
|
||||||
followerId: user.id,
|
|
||||||
}, {
|
|
||||||
isFollowerHibernated: false,
|
|
||||||
}),
|
|
||||||
this.cacheService.hibernatedUserCache.set(user.id, false),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.usersRepository.update(user.id, {
|
|
||||||
lastActiveDate: this.timeService.date,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -70,6 +41,6 @@ export class UserService {
|
||||||
@bindThis
|
@bindThis
|
||||||
public async notifySystemWebhook(user: MiUser, type: 'userCreated') {
|
public async notifySystemWebhook(user: MiUser, type: 'userCreated') {
|
||||||
const packedUser = await this.userEntityService.pack(user, null, { schema: 'UserLite' });
|
const packedUser = await this.userEntityService.pack(user, null, { schema: 'UserLite' });
|
||||||
return this.systemWebhookService.enqueueSystemWebhook(type, packedUser);
|
return await this.systemWebhookService.enqueueSystemWebhook(type, packedUser);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,10 @@ import { RelationshipJobData } from '@/queue/types.js';
|
||||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||||
import { isSystemAccount } from '@/misc/is-system-account.js';
|
import { isSystemAccount } from '@/misc/is-system-account.js';
|
||||||
import { CacheService } from '@/core/CacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.js';
|
|
||||||
import type Logger from '@/logger.js';
|
|
||||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
|
||||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
|
||||||
import { InternalEventService } from '@/global/InternalEventService.js';
|
import { InternalEventService } from '@/global/InternalEventService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserSuspendService {
|
export class UserSuspendService {
|
||||||
private readonly logger: Logger;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.usersRepository)
|
@Inject(DI.usersRepository)
|
||||||
private usersRepository: UsersRepository,
|
private usersRepository: UsersRepository,
|
||||||
|
|
@ -47,11 +41,7 @@ export class UserSuspendService {
|
||||||
private moderationLogService: ModerationLogService,
|
private moderationLogService: ModerationLogService,
|
||||||
private readonly cacheService: CacheService,
|
private readonly cacheService: CacheService,
|
||||||
private readonly internalEventService: InternalEventService,
|
private readonly internalEventService: InternalEventService,
|
||||||
|
) {}
|
||||||
loggerService: LoggerService,
|
|
||||||
) {
|
|
||||||
this.logger = loggerService.getLogger('user-suspend');
|
|
||||||
}
|
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async suspend(user: MiUser, moderator: MiUser): Promise<void> {
|
public async suspend(user: MiUser, moderator: MiUser): Promise<void> {
|
||||||
|
|
@ -69,10 +59,7 @@ export class UserSuspendService {
|
||||||
userHost: user.host,
|
userHost: user.host,
|
||||||
});
|
});
|
||||||
|
|
||||||
trackPromise((async () => {
|
await this.queueService.createPostSuspendJob(user.id);
|
||||||
await this.postSuspend(user);
|
|
||||||
await this.freezeAll(user);
|
|
||||||
})().catch(e => this.logger.error(`Error suspending user ${user.id}: ${renderInlineError(e)}`)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -89,14 +76,11 @@ export class UserSuspendService {
|
||||||
userHost: user.host,
|
userHost: user.host,
|
||||||
});
|
});
|
||||||
|
|
||||||
trackPromise((async () => {
|
await this.queueService.createPostUnsuspendJob(user.id);
|
||||||
await this.postUnsuspend(user);
|
|
||||||
await this.unFreezeAll(user);
|
|
||||||
})().catch(e => this.logger.error(`Error un-suspending for user ${user.id}: ${renderInlineError(e)}`)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise<void> {
|
public async postSuspend(user: MiUser): Promise<void> {
|
||||||
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
|
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -132,10 +116,12 @@ export class UserSuspendService {
|
||||||
|
|
||||||
await this.queueService.deliverMany(user, content, queue);
|
await this.queueService.deliverMany(user, content, queue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.freezeAll(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async postUnsuspend(user: MiUser): Promise<void> {
|
public async postUnsuspend(user: MiUser): Promise<void> {
|
||||||
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false });
|
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false });
|
||||||
|
|
||||||
if (this.userEntityService.isLocalUser(user)) {
|
if (this.userEntityService.isLocalUser(user)) {
|
||||||
|
|
@ -162,6 +148,8 @@ export class UserSuspendService {
|
||||||
|
|
||||||
await this.queueService.deliverMany(user, content, queue);
|
await this.queueService.deliverMany(user, content, queue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.unFreezeAll(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ export class UserWebhookService implements OnApplicationShutdown {
|
||||||
) {
|
) {
|
||||||
const webhooks = await this.getActiveWebhooks()
|
const webhooks = await this.getActiveWebhooks()
|
||||||
.then(webhooks => webhooks.filter(webhook => webhook.userId === userId && webhook.on.includes(type)));
|
.then(webhooks => webhooks.filter(webhook => webhook.userId === userId && webhook.on.includes(type)));
|
||||||
return Promise.all(
|
return await Promise.all(
|
||||||
webhooks.map(webhook => {
|
webhooks.map(webhook => {
|
||||||
return this.queueService.userWebhookDeliver(webhook, type, content);
|
return this.queueService.userWebhookDeliver(webhook, type, content);
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import promiseLimit from 'promise-limit';
|
||||||
import type { MiRemoteUser, MiUser } from '@/models/User.js';
|
import type { MiRemoteUser, MiUser } from '@/models/User.js';
|
||||||
import { concat, unique } from '@/misc/prelude/array.js';
|
import { concat, unique } from '@/misc/prelude/array.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { promiseMap } from '@/misc/promise-map.js';
|
||||||
import { getApIds } from './type.js';
|
import { getApIds } from './type.js';
|
||||||
import { ApPersonService } from './models/ApPersonService.js';
|
import { ApPersonService } from './models/ApPersonService.js';
|
||||||
import type { ApObject } from './type.js';
|
import type { ApObject } from './type.js';
|
||||||
|
|
@ -37,10 +38,12 @@ export class ApAudienceService {
|
||||||
|
|
||||||
const others = unique(concat([toGroups.other, ccGroups.other]));
|
const others = unique(concat([toGroups.other, ccGroups.other]));
|
||||||
|
|
||||||
const limit = promiseLimit<MiUser | null>(2);
|
const resolved = await promiseMap(others, async x => {
|
||||||
const mentionedUsers = (await Promise.all(
|
return await this.apPersonService.resolvePerson(x, resolver).catch(() => null) as MiUser | null;
|
||||||
others.map(id => limit(() => this.apPersonService.resolvePerson(id, resolver).catch(() => null))),
|
}, {
|
||||||
)).filter(x => x != null);
|
limit: 2,
|
||||||
|
});
|
||||||
|
const mentionedUsers = resolved.filter(x => x != null);
|
||||||
|
|
||||||
// If no audience is specified, then assume public
|
// If no audience is specified, then assume public
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -92,10 +92,9 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
||||||
key: MiUserPublickey;
|
key: MiUserPublickey;
|
||||||
} | null> {
|
} | null> {
|
||||||
const key = await this.apPersonService.findPublicKeyByKeyId(keyId);
|
const key = await this.apPersonService.findPublicKeyByKeyId(keyId);
|
||||||
|
|
||||||
if (key == null) return null;
|
if (key == null) return null;
|
||||||
|
|
||||||
const user = await this.cacheService.findUserById(key.userId).catch(() => null) as MiRemoteUser | null;
|
const user = await this.cacheService.findOptionalRemoteUserById(key.userId);
|
||||||
if (user == null) return null;
|
if (user == null) return null;
|
||||||
if (user.isDeleted) return null;
|
if (user.isDeleted) return null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,6 @@ import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||||
import { fromTuple } from '@/misc/from-tuple.js';
|
import { fromTuple } from '@/misc/from-tuple.js';
|
||||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||||
import InstanceChart from '@/core/chart/charts/instance.js';
|
|
||||||
import FederationChart from '@/core/chart/charts/federation.js';
|
|
||||||
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
|
|
||||||
import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js';
|
|
||||||
import { CacheService } from '@/core/CacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
|
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
|
||||||
import { TimeService } from '@/global/TimeService.js';
|
import { TimeService } from '@/global/TimeService.js';
|
||||||
|
|
@ -97,10 +93,6 @@ export class ApInboxService {
|
||||||
private queueService: QueueService,
|
private queueService: QueueService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private readonly federatedInstanceService: FederatedInstanceService,
|
private readonly federatedInstanceService: FederatedInstanceService,
|
||||||
private readonly fetchInstanceMetadataService: FetchInstanceMetadataService,
|
|
||||||
private readonly instanceChart: InstanceChart,
|
|
||||||
private readonly federationChart: FederationChart,
|
|
||||||
private readonly updateInstanceQueue: UpdateInstanceQueue,
|
|
||||||
private readonly cacheService: CacheService,
|
private readonly cacheService: CacheService,
|
||||||
private readonly noteVisibilityService: NoteVisibilityService,
|
private readonly noteVisibilityService: NoteVisibilityService,
|
||||||
private readonly timeService: TimeService,
|
private readonly timeService: TimeService,
|
||||||
|
|
@ -115,7 +107,7 @@ export class ApInboxService {
|
||||||
const results = [] as [string, string | void][];
|
const results = [] as [string, string | void][];
|
||||||
resolver ??= this.apResolverService.createResolver();
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
const items = await resolver.resolveCollectionItems(activity);
|
const items = await resolver.resolveCollectionItems(activity, true, getNullableApId(activity) ?? undefined);
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const act = items[i];
|
const act = items[i];
|
||||||
if (act.id != null) {
|
if (act.id != null) {
|
||||||
|
|
@ -153,11 +145,10 @@ export class ApInboxService {
|
||||||
// ついでにリモートユーザーの情報が古かったら更新しておく
|
// ついでにリモートユーザーの情報が古かったら更新しておく
|
||||||
if (actor.uri) {
|
if (actor.uri) {
|
||||||
if (actor.lastFetchedAt == null || this.timeService.now - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
|
if (actor.lastFetchedAt == null || this.timeService.now - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
|
||||||
setImmediate(() => {
|
{
|
||||||
// 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
|
// 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
|
||||||
this.apPersonService.updatePerson(actor.uri)
|
await this.apPersonService.updatePersonLazy(actor);
|
||||||
.catch(err => this.logger.error(`Failed to update person: ${renderInlineError(err)}`));
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -424,42 +415,14 @@ export class ApInboxService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update stats (adapted from InboxProcessorService)
|
// Update stats (adapted from InboxProcessorService)
|
||||||
this.federationChart.inbox(actor.host).then();
|
await this.queueService.createPostInboxJob(actor.host);
|
||||||
process.nextTick(async () => {
|
|
||||||
const i = await (this.meta.enableStatsForFederatedInstances
|
|
||||||
? this.federatedInstanceService.fetchOrRegister(actor.host)
|
|
||||||
: this.federatedInstanceService.fetch(actor.host));
|
|
||||||
|
|
||||||
if (i == null) return;
|
|
||||||
|
|
||||||
this.updateInstanceQueue.enqueue(i.id, {
|
|
||||||
latestRequestReceivedAt: this.timeService.date,
|
|
||||||
shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.meta.enableChartsForFederatedInstances) {
|
|
||||||
this.instanceChart.requestReceived(i.host).then();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i).then();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Process it!
|
// Process it!
|
||||||
return await this.performOneActivity(actor, activity, resolver)
|
try {
|
||||||
.finally(() => {
|
return await this.performOneActivity(actor, activity, resolver);
|
||||||
// Update user (adapted from performActivity)
|
} finally {
|
||||||
if (actor.lastFetchedAt == null || this.timeService.now - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
|
await this.apPersonService.updatePersonLazy(actor);
|
||||||
setImmediate(() => {
|
}
|
||||||
// Don't re-use the resolver, or it may throw recursion errors.
|
|
||||||
// Instead, create a new resolver with an appropriately-reduced recursion limit.
|
|
||||||
const subResolver = this.apResolverService.createResolver({
|
|
||||||
recursionLimit: resolver.getRecursionLimit() - resolver.getHistory().length,
|
|
||||||
});
|
|
||||||
this.apPersonService.updatePerson(actor.uri, subResolver)
|
|
||||||
.catch(err => this.logger.error(`Failed to update person: ${renderInlineError(err)}`));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { IsNull, Not } from 'typeorm';
|
import { IsNull, Not } from 'typeorm';
|
||||||
import promiseLimit from 'promise-limit';
|
|
||||||
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||||
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta, SkApFetchLog } from '@/models/_.js';
|
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta, SkApFetchLog } from '@/models/_.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
|
|
@ -23,6 +22,9 @@ import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import { toArray } from '@/misc/prelude/array.js';
|
import { toArray } from '@/misc/prelude/array.js';
|
||||||
import { isPureRenote } from '@/misc/is-renote.js';
|
import { isPureRenote } from '@/misc/is-renote.js';
|
||||||
import { CacheService } from '@/core/CacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
import { promiseMap } from '@/misc/promise-map.js';
|
||||||
|
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||||
|
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||||
import { AnyCollection, getApId, getNullableApId, IObjectWithId, isCollection, isCollectionOrOrderedCollection, isCollectionPage, isOrderedCollection, isOrderedCollectionPage } from './type.js';
|
import { AnyCollection, getApId, getNullableApId, IObjectWithId, isCollection, isCollectionOrOrderedCollection, isCollectionPage, isOrderedCollection, isOrderedCollectionPage } from './type.js';
|
||||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||||
import { ApRendererService } from './ApRendererService.js';
|
import { ApRendererService } from './ApRendererService.js';
|
||||||
|
|
@ -68,27 +70,21 @@ export class Resolver {
|
||||||
return this.recursionLimit;
|
return this.recursionLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async resolveCollection(value: string | IObjectWithId, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection & IObjectWithId>;
|
|
||||||
public async resolveCollection(value: string | IObject, allowAnonymous: boolean | undefined, sentFromUri: string): Promise<AnyCollection & IObjectWithId>;
|
|
||||||
public async resolveCollection(value: string | IObject, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection>;
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async resolveCollection(value: string | IObject, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection> {
|
public async resolveCollection(value: string | IObject, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection> {
|
||||||
const collection = typeof value === 'string'
|
const collection = sentFromUri
|
||||||
? sentFromUri
|
? await this.secureResolve(value, sentFromUri, allowAnonymous)
|
||||||
? await this.secureResolve(value, sentFromUri, allowAnonymous)
|
: allowAnonymous
|
||||||
: await this.resolve(value, allowAnonymous)
|
? await this.resolveAnonymous(value)
|
||||||
: value; // TODO try and remove this eventually, as it's a major security foot-gun
|
: await this.resolve(value, allowAnonymous);
|
||||||
|
|
||||||
if (isCollectionOrOrderedCollection(collection)) {
|
if (isCollectionOrOrderedCollection(collection)) {
|
||||||
return collection;
|
return collection;
|
||||||
} else {
|
} else {
|
||||||
throw new IdentifiableError('f100eccf-f347-43fb-9b45-96a0831fb635', `collection ${getApId(value)} has unsupported type: ${collection.type}`);
|
throw new IdentifiableError('f100eccf-f347-43fb-9b45-96a0831fb635', `collection ${getNullableApId(value)} has unsupported type: ${collection.type}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async resolveCollectionItems(collection: IAnonymousObject, limit?: number | null, allowAnonymousItems?: true, concurrency?: number): Promise<IAnonymousObject[]>;
|
|
||||||
public async resolveCollectionItems(collection: string | IObjectWithId, limit?: number | null, allowAnonymousItems?: boolean, concurrency?: number): Promise<IObjectWithId[]>;
|
|
||||||
public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean, concurrency?: number): Promise<IObject[]>;
|
|
||||||
/**
|
/**
|
||||||
* Recursively resolves items from a collection.
|
* Recursively resolves items from a collection.
|
||||||
* Stops when reaching the resolution limit or an optional item limit - whichever is lower.
|
* Stops when reaching the resolution limit or an optional item limit - whichever is lower.
|
||||||
|
|
@ -96,11 +92,13 @@ export class Resolver {
|
||||||
* Malformed collections (mixing Ordered and un-Ordered types) are also supported.
|
* Malformed collections (mixing Ordered and un-Ordered types) are also supported.
|
||||||
* @param collection Collection to resolve from - can be a URL or object of any supported collection type.
|
* @param collection Collection to resolve from - can be a URL or object of any supported collection type.
|
||||||
* @param limit Maximum number of items to resolve. If null or undefined (default), then items will be resolved until reaching the recursion limit.
|
* @param limit Maximum number of items to resolve. If null or undefined (default), then items will be resolved until reaching the recursion limit.
|
||||||
* @param allowAnonymousItems If true, collection items can be anonymous (lack an ID). If false (default), then an error is thrown when reaching an item without ID.
|
* @param allowAnonymous If true, collection items can be anonymous (lack an ID). If false (default), then an error is thrown when reaching an item without ID.
|
||||||
|
* @param sentFromUri If collection is an object, this is the URI where it was sent from.
|
||||||
* @param concurrency Maximum number of items to resolve at once. (default: 4)
|
* @param concurrency Maximum number of items to resolve at once. (default: 4)
|
||||||
|
* @param ignoreErrors If true (default), inaccessible items will be skipped instead of causing an exception. Inaccessible collections will still throw.
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean, concurrency = 4): Promise<IObject[]> {
|
public async resolveCollectionItems(collection: string | IObject, allowAnonymous = false, sentFromUri?: string, limit?: number | null, concurrency = 4, ignoreErrors = true): Promise<IObject[]> {
|
||||||
const resolvedItems: IObject[] = [];
|
const resolvedItems: IObject[] = [];
|
||||||
|
|
||||||
// This is pulled up to avoid code duplication below
|
// This is pulled up to avoid code duplication below
|
||||||
|
|
@ -108,11 +106,10 @@ export class Resolver {
|
||||||
const sentFrom = current.id;
|
const sentFrom = current.id;
|
||||||
const itemArr = toArray(items);
|
const itemArr = toArray(items);
|
||||||
const itemLimit = limit ?? Number.MAX_SAFE_INTEGER;
|
const itemLimit = limit ?? Number.MAX_SAFE_INTEGER;
|
||||||
const allowAnonymous = allowAnonymousItems ?? false;
|
await this.resolveItemArray(itemArr, sentFrom, itemLimit, concurrency, allowAnonymous, resolvedItems, ignoreErrors);
|
||||||
await this.resolveItemArray(itemArr, sentFrom, itemLimit, concurrency, allowAnonymous, resolvedItems);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let current: AnyCollection | null = await this.resolveCollection(collection);
|
let current: AnyCollection | null = await this.resolveCollection(collection, allowAnonymous, sentFromUri);
|
||||||
do {
|
do {
|
||||||
// Iterate all items in the current page
|
// Iterate all items in the current page
|
||||||
if (current.items) {
|
if (current.items) {
|
||||||
|
|
@ -130,10 +127,10 @@ export class Resolver {
|
||||||
current = null;
|
current = null;
|
||||||
} else if (isCollection(current) || isOrderedCollection(current)) {
|
} else if (isCollection(current) || isOrderedCollection(current)) {
|
||||||
// Continue to first page
|
// Continue to first page
|
||||||
current = current.first ? await this.resolveCollection(current.first, true, current.id) : null;
|
current = current.first ? await this.resolveCollection(current.first, allowAnonymous, current.id) : null;
|
||||||
} else if (isCollectionPage(current) || isOrderedCollectionPage(current)) {
|
} else if (isCollectionPage(current) || isOrderedCollectionPage(current)) {
|
||||||
// Continue to next page
|
// Continue to next page
|
||||||
current = current.next ? await this.resolveCollection(current.next, true, current.id) : null;
|
current = current.next ? await this.resolveCollection(current.next, allowAnonymous, current.id) : null;
|
||||||
} else {
|
} else {
|
||||||
// Stop in all other conditions
|
// Stop in all other conditions
|
||||||
current = null;
|
current = null;
|
||||||
|
|
@ -143,17 +140,12 @@ export class Resolver {
|
||||||
return resolvedItems;
|
return resolvedItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveItemArray(source: (string | IObject)[], sentFrom: undefined, itemLimit: number, concurrency: number, allowAnonymousItems: true, destination: IAnonymousObject[]): Promise<void>;
|
private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[], ignoreErrors?: boolean): Promise<void> {
|
||||||
private async resolveItemArray(source: (string | IObject)[], sentFrom: string, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObjectWithId[]): Promise<void>;
|
|
||||||
private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[]): Promise<void>;
|
|
||||||
private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[]): Promise<void> {
|
|
||||||
const recursionLimit = this.recursionLimit - this.history.size;
|
const recursionLimit = this.recursionLimit - this.history.size;
|
||||||
const batchLimit = Math.min(source.length, recursionLimit, itemLimit);
|
const batchLimit = Math.min(source.length, recursionLimit, itemLimit);
|
||||||
|
|
||||||
const limiter = promiseLimit<IObject>(concurrency);
|
const batch = await promiseMap(source.slice(0, batchLimit), async item => {
|
||||||
const batch = await Promise.all(source
|
try {
|
||||||
.slice(0, batchLimit)
|
|
||||||
.map(item => limiter(async () => {
|
|
||||||
if (sentFrom) {
|
if (sentFrom) {
|
||||||
// Use secureResolve to avoid re-fetching items that were included inline.
|
// Use secureResolve to avoid re-fetching items that were included inline.
|
||||||
return await this.secureResolve(item, sentFrom, allowAnonymousItems);
|
return await this.secureResolve(item, sentFrom, allowAnonymousItems);
|
||||||
|
|
@ -164,9 +156,22 @@ export class Resolver {
|
||||||
const id = getApId(item);
|
const id = getApId(item);
|
||||||
return await this.resolve(id);
|
return await this.resolve(id);
|
||||||
}
|
}
|
||||||
})));
|
} catch (err) {
|
||||||
|
if (ignoreErrors) {
|
||||||
|
this.logger.warn(`Ignoring error in collection item ${getNullableApId(item)}: ${renderInlineError(err)}`);
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
limit: concurrency,
|
||||||
|
});
|
||||||
|
|
||||||
destination.push(...batch);
|
// Items will be null if a request fails and ignoreErrors is true
|
||||||
|
const batchItems = batch.filter(item => item != null);
|
||||||
|
|
||||||
|
destination.push(...batchItems);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -269,8 +274,8 @@ export class Resolver {
|
||||||
log.duration = calculateDurationSince(startTime);
|
log.duration = calculateDurationSince(startTime);
|
||||||
|
|
||||||
// Save or finalize asynchronously
|
// Save or finalize asynchronously
|
||||||
this.apLogService.saveFetchLog(log)
|
trackPromise(this.apLogService.saveFetchLog(log)
|
||||||
.catch(err => this.logger.error('Failed to record AP object fetch:', err));
|
.catch(err => this.logger.error('Failed to record AP object fetch:', err)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ export class JsonLdService {
|
||||||
const customLoader = this.getLoader();
|
const customLoader = this.getLoader();
|
||||||
// XXX: Importing jsonld dynamically since Jest frequently fails to import it statically
|
// XXX: Importing jsonld dynamically since Jest frequently fails to import it statically
|
||||||
// https://github.com/misskey-dev/misskey/pull/9894#discussion_r1103753595
|
// https://github.com/misskey-dev/misskey/pull/9894#discussion_r1103753595
|
||||||
return (await import('jsonld')).default.compact(data, context, {
|
return await (await import('jsonld')).default.compact(data, context, {
|
||||||
documentLoader: customLoader,
|
documentLoader: customLoader,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -142,7 +142,7 @@ export class JsonLdService {
|
||||||
@bindThis
|
@bindThis
|
||||||
public async normalize(data: Document): Promise<string> {
|
public async normalize(data: Document): Promise<string> {
|
||||||
const customLoader = this.getLoader();
|
const customLoader = this.getLoader();
|
||||||
return (await import('jsonld')).default.normalize(data, {
|
return await (await import('jsonld')).default.normalize(data, {
|
||||||
documentLoader: customLoader,
|
documentLoader: customLoader,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { isMention } from '../type.js';
|
||||||
import { Resolver } from '../ApResolverService.js';
|
import { Resolver } from '../ApResolverService.js';
|
||||||
import { ApPersonService } from './ApPersonService.js';
|
import { ApPersonService } from './ApPersonService.js';
|
||||||
import type { IObject, IApMention } from '../type.js';
|
import type { IObject, IApMention } from '../type.js';
|
||||||
|
import { promiseMap } from '@/misc/promise-map.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApMentionService {
|
export class ApMentionService {
|
||||||
|
|
@ -24,12 +25,13 @@ export class ApMentionService {
|
||||||
public async extractApMentions(tags: IObject | IObject[] | null | undefined, resolver: Resolver): Promise<MiUser[]> {
|
public async extractApMentions(tags: IObject | IObject[] | null | undefined, resolver: Resolver): Promise<MiUser[]> {
|
||||||
const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href));
|
const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href));
|
||||||
|
|
||||||
const limit = promiseLimit<MiUser | null>(2);
|
const mentionedUsers = await promiseMap(hrefs, async x => {
|
||||||
const mentionedUsers = (await Promise.all(
|
return await this.apPersonService.resolvePerson(x, resolver).catch(() => null) as MiUser | null;
|
||||||
hrefs.map(x => limit(() => this.apPersonService.resolvePerson(x, resolver).catch(() => null))),
|
}, {
|
||||||
)).filter(x => x != null);
|
limit: 2,
|
||||||
|
});
|
||||||
|
|
||||||
return mentionedUsers;
|
return mentionedUsers.filter(resolved => resolved != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
|
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
import { In } from 'typeorm';
|
import { In } from 'typeorm';
|
||||||
import { UnrecoverableError } from 'bullmq';
|
import { UnrecoverableError } from 'bullmq';
|
||||||
import promiseLimit from 'promise-limit';
|
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { UsersRepository, PollsRepository, EmojisRepository, NotesRepository, MiMeta } from '@/models/_.js';
|
import type { UsersRepository, PollsRepository, EmojisRepository, NotesRepository, MiMeta } from '@/models/_.js';
|
||||||
|
|
@ -32,6 +31,7 @@ import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||||
import { extractMediaFromHtml } from '@/core/activitypub/misc/extract-media-from-html.js';
|
import { extractMediaFromHtml } from '@/core/activitypub/misc/extract-media-from-html.js';
|
||||||
import { extractMediaFromMfm } from '@/core/activitypub/misc/extract-media-from-mfm.js';
|
import { extractMediaFromMfm } from '@/core/activitypub/misc/extract-media-from-mfm.js';
|
||||||
import { getContentByType } from '@/core/activitypub/misc/get-content-by-type.js';
|
import { getContentByType } from '@/core/activitypub/misc/get-content-by-type.js';
|
||||||
|
import { promiseMap } from '@/misc/promise-map.js';
|
||||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||||
import { CustomEmojiService, encodeEmojiKey, isValidEmojiName } from '@/core/CustomEmojiService.js';
|
import { CustomEmojiService, encodeEmojiKey, isValidEmojiName } from '@/core/CustomEmojiService.js';
|
||||||
import { TimeService } from '@/global/TimeService.js';
|
import { TimeService } from '@/global/TimeService.js';
|
||||||
|
|
@ -277,7 +277,7 @@ export class ApNoteService implements OnModuleInit {
|
||||||
|
|
||||||
return x;
|
return x;
|
||||||
})
|
})
|
||||||
.catch(async err => {
|
.catch(err => {
|
||||||
this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`);
|
this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`);
|
||||||
throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to create note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true, err);
|
throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to create note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true, err);
|
||||||
})
|
})
|
||||||
|
|
@ -456,7 +456,7 @@ export class ApNoteService implements OnModuleInit {
|
||||||
|
|
||||||
return x;
|
return x;
|
||||||
})
|
})
|
||||||
.catch(async err => {
|
.catch(err => {
|
||||||
this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`);
|
this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`);
|
||||||
throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to update note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true, err);
|
throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to update note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true, err);
|
||||||
})
|
})
|
||||||
|
|
@ -583,8 +583,8 @@ export class ApNoteService implements OnModuleInit {
|
||||||
const emojiKeys = eomjiTags.map(tag => encodeEmojiKey({ name: tag.name, host }));
|
const emojiKeys = eomjiTags.map(tag => encodeEmojiKey({ name: tag.name, host }));
|
||||||
const existingEmojis = await this.customEmojiService.emojisByKeyCache.fetchMany(emojiKeys);
|
const existingEmojis = await this.customEmojiService.emojisByKeyCache.fetchMany(emojiKeys);
|
||||||
|
|
||||||
return await Promise.all(eomjiTags.map(async tag => {
|
return await promiseMap(eomjiTags, async tag => {
|
||||||
const name = tag.name;
|
const name = tag.name.replaceAll(':', '');
|
||||||
tag.icon = toSingle(tag.icon);
|
tag.icon = toSingle(tag.icon);
|
||||||
|
|
||||||
const exists = existingEmojis.values.find(x => x.name === name);
|
const exists = existingEmojis.values.find(x => x.name === name);
|
||||||
|
|
@ -627,7 +627,9 @@ export class ApNoteService implements OnModuleInit {
|
||||||
// _misskey_license が存在しなければ `null`
|
// _misskey_license が存在しなければ `null`
|
||||||
license: (tag._misskey_license?.freeText ?? null),
|
license: (tag._misskey_license?.freeText ?? null),
|
||||||
});
|
});
|
||||||
}));
|
}, {
|
||||||
|
limit: 4,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -691,7 +693,7 @@ export class ApNoteService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const results = await Promise.all(Array.from(quoteUris).map(u => resolveQuote(u)));
|
const results = await promiseMap(quoteUris, async u => resolveQuote(u), { limit: 2 });
|
||||||
|
|
||||||
// Success - return the quote
|
// Success - return the quote
|
||||||
const quote = results.find(r => typeof(r) === 'object');
|
const quote = results.find(r => typeof(r) === 'object');
|
||||||
|
|
@ -753,14 +755,10 @@ export class ApNoteService implements OnModuleInit {
|
||||||
|
|
||||||
// Resolve all files w/ concurrency 2.
|
// Resolve all files w/ concurrency 2.
|
||||||
// This prevents one big file from blocking the others.
|
// This prevents one big file from blocking the others.
|
||||||
const limiter = promiseLimit<MiDriveFile | null>(2);
|
const results = await promiseMap(attachments.values(), async attach => {
|
||||||
const results = await Promise
|
attach.sensitive ??= note.sensitive;
|
||||||
.all(Array
|
return await this.resolveImage(actor, attach);
|
||||||
.from(attachments.values())
|
}, { limit: 2 });
|
||||||
.map(attach => limiter(async () => {
|
|
||||||
attach.sensitive ??= note.sensitive;
|
|
||||||
return await this.resolveImage(actor, attach);
|
|
||||||
})));
|
|
||||||
|
|
||||||
// Process results
|
// Process results
|
||||||
let hasFileError = false;
|
let hasFileError = false;
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import type { MiNote } from '@/models/Note.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import type { MfmService } from '@/core/MfmService.js';
|
import type { MfmService } from '@/core/MfmService.js';
|
||||||
import { toArray } from '@/misc/prelude/array.js';
|
import { toArray } from '@/misc/prelude/array.js';
|
||||||
import type { GlobalEventService } from '@/core/GlobalEventService.js';
|
|
||||||
import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||||
import type { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
|
import type { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
|
||||||
import { MiUserProfile } from '@/models/UserProfile.js';
|
import { MiUserProfile } from '@/models/UserProfile.js';
|
||||||
|
|
@ -45,9 +44,12 @@ import { TimeService } from '@/global/TimeService.js';
|
||||||
import { verifyFieldLinks } from '@/misc/verify-field-link.js';
|
import { verifyFieldLinks } from '@/misc/verify-field-link.js';
|
||||||
import { isRetryableError } from '@/misc/is-retryable-error.js';
|
import { isRetryableError } from '@/misc/is-retryable-error.js';
|
||||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
import { errorCodes, IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import { QueueService } from '@/core/QueueService.js';
|
import { QueueService } from '@/core/QueueService.js';
|
||||||
import { getApId, getApType, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
|
import { InternalEventService } from '@/global/InternalEventService.js';
|
||||||
|
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
|
||||||
|
import { promiseMap } from '@/misc/promise-map.js';
|
||||||
|
import { getApId, getApType, getNullableApId, isActor, isPost, isPropertyValue } from '../type.js';
|
||||||
import { ApLoggerService } from '../ApLoggerService.js';
|
import { ApLoggerService } from '../ApLoggerService.js';
|
||||||
import { extractApHashtags } from './tag.js';
|
import { extractApHashtags } from './tag.js';
|
||||||
import type { OnModuleInit } from '@nestjs/common';
|
import type { OnModuleInit } from '@nestjs/common';
|
||||||
|
|
@ -72,7 +74,6 @@ export class ApPersonService implements OnModuleInit {
|
||||||
private readonly publicKeyByUserIdCache: ManagedQuantumKVCache<MiUserPublickey>;
|
private readonly publicKeyByUserIdCache: ManagedQuantumKVCache<MiUserPublickey>;
|
||||||
|
|
||||||
private driveFileEntityService: DriveFileEntityService;
|
private driveFileEntityService: DriveFileEntityService;
|
||||||
private globalEventService: GlobalEventService;
|
|
||||||
private federatedInstanceService: FederatedInstanceService;
|
private federatedInstanceService: FederatedInstanceService;
|
||||||
private fetchInstanceMetadataService: FetchInstanceMetadataService;
|
private fetchInstanceMetadataService: FetchInstanceMetadataService;
|
||||||
private cacheService: CacheService;
|
private cacheService: CacheService;
|
||||||
|
|
@ -86,6 +87,7 @@ export class ApPersonService implements OnModuleInit {
|
||||||
private instanceChart: InstanceChart;
|
private instanceChart: InstanceChart;
|
||||||
private accountMoveService: AccountMoveService;
|
private accountMoveService: AccountMoveService;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
private idService: IdService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private moduleRef: ModuleRef,
|
private moduleRef: ModuleRef,
|
||||||
|
|
@ -120,9 +122,10 @@ export class ApPersonService implements OnModuleInit {
|
||||||
private readonly cacheManagementService: CacheManagementService,
|
private readonly cacheManagementService: CacheManagementService,
|
||||||
private readonly utilityService: UtilityService,
|
private readonly utilityService: UtilityService,
|
||||||
private readonly apUtilityService: ApUtilityService,
|
private readonly apUtilityService: ApUtilityService,
|
||||||
private readonly idService: IdService,
|
|
||||||
private readonly timeService: TimeService,
|
private readonly timeService: TimeService,
|
||||||
private readonly queueService: QueueService,
|
private readonly queueService: QueueService,
|
||||||
|
private readonly collapsedQueueService: CollapsedQueueService,
|
||||||
|
private readonly internalEventService: InternalEventService,
|
||||||
|
|
||||||
apLoggerService: ApLoggerService,
|
apLoggerService: ApLoggerService,
|
||||||
) {
|
) {
|
||||||
|
|
@ -181,7 +184,6 @@ export class ApPersonService implements OnModuleInit {
|
||||||
@bindThis
|
@bindThis
|
||||||
onModuleInit(): void {
|
onModuleInit(): void {
|
||||||
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
|
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
|
||||||
this.globalEventService = this.moduleRef.get('GlobalEventService');
|
|
||||||
this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
|
this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
|
||||||
this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService');
|
this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService');
|
||||||
this.cacheService = this.moduleRef.get('CacheService');
|
this.cacheService = this.moduleRef.get('CacheService');
|
||||||
|
|
@ -194,6 +196,7 @@ export class ApPersonService implements OnModuleInit {
|
||||||
this.usersChart = this.moduleRef.get('UsersChart');
|
this.usersChart = this.moduleRef.get('UsersChart');
|
||||||
this.instanceChart = this.moduleRef.get('InstanceChart');
|
this.instanceChart = this.moduleRef.get('InstanceChart');
|
||||||
this.accountMoveService = this.moduleRef.get('AccountMoveService');
|
this.accountMoveService = this.moduleRef.get('AccountMoveService');
|
||||||
|
this.idService = this.moduleRef.get('IdService');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -301,14 +304,14 @@ export class ApPersonService implements OnModuleInit {
|
||||||
withSuspended: opts?.withSuspended ?? true,
|
withSuspended: opts?.withSuspended ?? true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let userId;
|
let userId: string | null | undefined;
|
||||||
|
|
||||||
// Resolve URI -> User ID
|
// Resolve URI -> User ID
|
||||||
const parsed = this.utilityService.parseUri(uri);
|
const parsed = this.utilityService.parseUri(uri);
|
||||||
if (parsed.local) {
|
if (parsed.local) {
|
||||||
userId = parsed.type === 'users' ? parsed.id : null;
|
userId = parsed.type === 'users' ? parsed.id : null;
|
||||||
} else {
|
} else {
|
||||||
userId = await this.uriPersonCache.fetch(uri).catch(() => null);
|
userId = await this.uriPersonCache.fetchMaybe(uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No match
|
// No match
|
||||||
|
|
@ -316,8 +319,7 @@ export class ApPersonService implements OnModuleInit {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.cacheService.findUserById(userId)
|
const user = await this.cacheService.findOptionalUserById(userId) as MiLocalUser | MiRemoteUser | null;
|
||||||
.catch(() => null) as MiLocalUser | MiRemoteUser | null;
|
|
||||||
|
|
||||||
if (user?.isDeleted && !_opts.withDeleted) {
|
if (user?.isDeleted && !_opts.withDeleted) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -329,8 +331,9 @@ export class ApPersonService implements OnModuleInit {
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO fix these "any" types
|
||||||
private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any, bgimg: any): Promise<Partial<Pick<MiRemoteUser, 'avatarId' | 'bannerId' | 'backgroundId' | 'avatarUrl' | 'bannerUrl' | 'backgroundUrl' | 'avatarBlurhash' | 'bannerBlurhash' | 'backgroundBlurhash'>>> {
|
private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any, bgimg: any): Promise<Partial<Pick<MiRemoteUser, 'avatarId' | 'bannerId' | 'backgroundId' | 'avatarUrl' | 'bannerUrl' | 'backgroundUrl' | 'avatarBlurhash' | 'bannerBlurhash' | 'backgroundBlurhash'>>> {
|
||||||
const [avatar, banner, background] = await Promise.all([icon, image, bgimg].map(img => {
|
const [avatar, banner, background] = await Promise.all([icon, image, bgimg].map(async img => {
|
||||||
// icon and image may be arrays
|
// icon and image may be arrays
|
||||||
// see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon
|
// see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon
|
||||||
if (Array.isArray(img)) {
|
if (Array.isArray(img)) {
|
||||||
|
|
@ -343,7 +346,7 @@ export class ApPersonService implements OnModuleInit {
|
||||||
return { id: null, url: null, blurhash: null };
|
return { id: null, url: null, blurhash: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.apImageService.resolveImage(user, img).catch(() => null);
|
return await this.apImageService.resolveImage(user, img).catch(() => null);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (((avatar != null && avatar.id != null) || (banner != null && banner.id != null))
|
if (((avatar != null && avatar.id != null) || (banner != null && banner.id != null))
|
||||||
|
|
@ -574,28 +577,23 @@ export class ApPersonService implements OnModuleInit {
|
||||||
|
|
||||||
// Register host
|
// Register host
|
||||||
if (this.meta.enableStatsForFederatedInstances) {
|
if (this.meta.enableStatsForFederatedInstances) {
|
||||||
this.federatedInstanceService.fetchOrRegister(host).then(i => {
|
this.federatedInstanceService.fetchOrRegister(host).then(async i => {
|
||||||
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
|
await this.collapsedQueueService.updateInstanceQueue.enqueue(i.id, { usersCountDelta: 1 });
|
||||||
if (this.meta.enableChartsForFederatedInstances) {
|
if (this.meta.enableChartsForFederatedInstances) {
|
||||||
this.instanceChart.newUser(i.host);
|
this.instanceChart.newUser(i.host);
|
||||||
}
|
}
|
||||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
await this.fetchInstanceMetadataService.fetchInstanceMetadataLazy(i);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.usersChart.update(user, true);
|
this.usersChart.update(user, true);
|
||||||
|
|
||||||
// ハッシュタグ更新
|
|
||||||
this.hashtagService.updateUsertags(user, tags);
|
|
||||||
|
|
||||||
//#region アバターとヘッダー画像をフェッチ
|
//#region アバターとヘッダー画像をフェッチ
|
||||||
try {
|
try {
|
||||||
const updates = await this.resolveAvatarAndBanner(user, person.icon, person.image, person.backgroundUrl);
|
const updates = await this.resolveAvatarAndBanner(user, person.icon, person.image, person.backgroundUrl);
|
||||||
await this.usersRepository.update(user.id, updates);
|
await this.usersRepository.update(user.id, updates);
|
||||||
|
await this.internalEventService.emit('remoteUserUpdated', { id: user.id });
|
||||||
user = { ...user, ...updates };
|
user = { ...user, ...updates };
|
||||||
|
|
||||||
// Register to the cache
|
|
||||||
await this.uriPersonCache.set(user.uri, user.id);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||||
if (isRetryableError(err)) {
|
if (isRetryableError(err)) {
|
||||||
|
|
@ -604,16 +602,29 @@ export class ApPersonService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
await this.updateFeatured(user.id, resolver).catch(err => {
|
// ハッシュタグ更新
|
||||||
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
await this.queueService.createUpdateUserTagsJob(user.id);
|
||||||
if (isRetryableError(err)) {
|
|
||||||
this.logger.error(`Error updating featured notes: ${renderInlineError(err)}`);
|
await this.updateFeaturedLazy(user);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules a deferred update on the background task worker.
|
||||||
|
* Duplicate updates are automatically skipped.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async updatePersonLazy(uriOrUser: string | MiUser): Promise<void> {
|
||||||
|
const user = typeof(uriOrUser) === 'string'
|
||||||
|
? await this.fetchPerson(uriOrUser)
|
||||||
|
: uriOrUser;
|
||||||
|
|
||||||
|
if (user && user.host != null) {
|
||||||
|
await this.queueService.createUpdateUserJob(user.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Personの情報を更新します。
|
* Personの情報を更新します。
|
||||||
* Misskeyに対象のPersonが登録されていなければ無視します。
|
* Misskeyに対象のPersonが登録されていなければ無視します。
|
||||||
|
|
@ -688,13 +699,16 @@ export class ApPersonService implements OnModuleInit {
|
||||||
|
|
||||||
const profileUrls = url ? [url, person.id] : [person.id];
|
const profileUrls = url ? [url, person.id] : [person.id];
|
||||||
const verifiedLinks = await verifyFieldLinks(fields, profileUrls, this.httpRequestService);
|
const verifiedLinks = await verifyFieldLinks(fields, profileUrls, this.httpRequestService);
|
||||||
|
const featuredUri = person.featured ? getApId(person.featured) : undefined;
|
||||||
|
|
||||||
const updates = {
|
const updates = {
|
||||||
lastFetchedAt: this.timeService.date,
|
lastFetchedAt: this.timeService.date,
|
||||||
inbox: person.inbox,
|
inbox: person.inbox,
|
||||||
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
|
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
|
||||||
followersUri: person.followers ? getApId(person.followers) : undefined,
|
followersUri: person.followers ? getApId(person.followers) : undefined,
|
||||||
featured: person.featured ? getApId(person.featured) : undefined,
|
// If the featured collection changes, then reset the fetch timeout.
|
||||||
|
lastFetchedFeaturedAt: featuredUri !== exist.featured ? null : undefined,
|
||||||
|
featured: featuredUri,
|
||||||
emojis: emojiNames,
|
emojis: emojiNames,
|
||||||
name: truncate(person.name, nameLength),
|
name: truncate(person.name, nameLength),
|
||||||
tags,
|
tags,
|
||||||
|
|
@ -751,9 +765,15 @@ export class ApPersonService implements OnModuleInit {
|
||||||
return `skip: user ${exist.id} is deleted`;
|
return `skip: user ${exist.id} is deleted`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify event ASAP
|
||||||
|
await this.internalEventService.emit('remoteUserUpdated', { id: exist.id });
|
||||||
|
|
||||||
|
// Do not use "exist" after this point!!
|
||||||
|
const updated = { ...exist, ...updates };
|
||||||
|
|
||||||
if (person.publicKey) {
|
if (person.publicKey) {
|
||||||
const publicKey = new MiUserPublickey({
|
const publicKey = new MiUserPublickey({
|
||||||
userId: exist.id,
|
userId: updated.id,
|
||||||
keyId: person.publicKey.id,
|
keyId: person.publicKey.id,
|
||||||
keyPem: person.publicKey.publicKeyPem,
|
keyPem: person.publicKey.publicKeyPem,
|
||||||
});
|
});
|
||||||
|
|
@ -767,7 +787,7 @@ export class ApPersonService implements OnModuleInit {
|
||||||
this.publicKeyByUserIdCache.set(publicKey.userId, publicKey),
|
this.publicKeyByUserIdCache.set(publicKey.userId, publicKey),
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
const existingPublicKey = await this.userPublickeysRepository.findOneBy({ userId: exist.id });
|
const existingPublicKey = await this.userPublickeysRepository.findOneBy({ userId: updated.id });
|
||||||
if (existingPublicKey) {
|
if (existingPublicKey) {
|
||||||
// Delete key
|
// Delete key
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|
@ -786,7 +806,7 @@ export class ApPersonService implements OnModuleInit {
|
||||||
_description = this.apMfmService.htmlToMfm(truncate(person.summary, this.config.maxRemoteBioLength), person.tag);
|
_description = this.apMfmService.htmlToMfm(truncate(person.summary, this.config.maxRemoteBioLength), person.tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.userProfilesRepository.update({ userId: exist.id }, {
|
await this.userProfilesRepository.update({ userId: updated.id }, {
|
||||||
url,
|
url,
|
||||||
fields,
|
fields,
|
||||||
verifiedLinks,
|
verifiedLinks,
|
||||||
|
|
@ -798,33 +818,25 @@ export class ApPersonService implements OnModuleInit {
|
||||||
location: person['vcard:Address'] ?? null,
|
location: person['vcard:Address'] ?? null,
|
||||||
listenbrainz: person.listenbrainz ?? null,
|
listenbrainz: person.listenbrainz ?? null,
|
||||||
});
|
});
|
||||||
|
await this.cacheService.userProfileCache.delete(updated.id);
|
||||||
this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: exist.id });
|
|
||||||
|
|
||||||
// ハッシュタグ更新
|
|
||||||
this.hashtagService.updateUsertags(exist, tags);
|
|
||||||
|
|
||||||
// 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする
|
// 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする
|
||||||
if (exist.inbox !== person.inbox || exist.sharedInbox !== (person.sharedInbox ?? person.endpoints?.sharedInbox)) {
|
if (updated.inbox !== person.inbox || updated.sharedInbox !== (person.sharedInbox ?? person.endpoints?.sharedInbox)) {
|
||||||
await this.followingsRepository.update(
|
await this.followingsRepository.update(
|
||||||
{ followerId: exist.id },
|
{ followerId: updated.id },
|
||||||
{
|
{
|
||||||
followerInbox: person.inbox,
|
followerInbox: person.inbox,
|
||||||
followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
|
followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.cacheService.refreshFollowRelationsFor(exist.id);
|
await this.cacheService.refreshFollowRelationsFor(updated.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.updateFeatured(exist.id, resolver).catch(err => {
|
// ハッシュタグ更新
|
||||||
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
await this.queueService.createUpdateUserTagsJob(updated.id);
|
||||||
if (isRetryableError(err)) {
|
|
||||||
this.logger.error(`Error updating featured notes: ${renderInlineError(err)}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const updated = { ...exist, ...updates };
|
await this.updateFeaturedLazy(updated);
|
||||||
|
|
||||||
// 移行処理を行う
|
// 移行処理を行う
|
||||||
if (updated.movedAt && (
|
if (updated.movedAt && (
|
||||||
|
|
@ -902,43 +914,71 @@ export class ApPersonService implements OnModuleInit {
|
||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules a deferred update on the background task worker.
|
||||||
|
* Duplicate updates are automatically skipped.
|
||||||
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async updateFeatured(userId: MiUser['id'], resolver?: Resolver): Promise<void> {
|
public async updateFeaturedLazy(userOrId: MiRemoteUser | MiUser['id']): Promise<void> {
|
||||||
const user = await this.usersRepository.findOneByOrFail({ id: userId, isDeleted: false });
|
const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId;
|
||||||
if (!isRemoteUser(user)) return;
|
const user = typeof(userOrId) === 'object' ? userOrId : await this.cacheService.findRemoteUserById(userId);
|
||||||
if (!user.featured) return;
|
|
||||||
|
|
||||||
this.logger.info(`Updating the featured: ${user.uri}`);
|
if (user.isDeleted || user.isSuspended) {
|
||||||
|
this.logger.debug(`Not updating featured for ${userId}: user is deleted`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const _resolver = resolver ?? this.apResolverService.createResolver();
|
if (!user.featured) {
|
||||||
|
this.logger.debug(`Not updating featured for ${userId}: no featured collection`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve to (Ordered)Collection Object
|
await this.queueService.createUpdateFeaturedJob(userId);
|
||||||
const collection = user.featured ? await _resolver.resolveCollection(user.featured, true, user.uri).catch(err => {
|
}
|
||||||
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
|
||||||
if (isRetryableError(err)) {
|
|
||||||
this.logger.warn(`Failed to update featured notes: ${renderInlineError(err)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
@bindThis
|
||||||
}) : null;
|
public async updateFeatured(userOrId: MiRemoteUser | MiUser['id'], resolver?: Resolver): Promise<void> {
|
||||||
if (!collection) return;
|
const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId;
|
||||||
|
const user = typeof(userOrId) === 'object' ? userOrId : await this.cacheService.findRemoteUserById(userId);
|
||||||
|
|
||||||
if (!isCollectionOrOrderedCollection(collection)) throw new UnrecoverableError(`failed to update user ${user.uri}: featured ${user.featured} is not Collection or OrderedCollection`);
|
if (user.isDeleted) throw new IdentifiableError(errorCodes.userIsDeleted, `Can't update featured for ${userId}: user is deleted`);
|
||||||
|
if (user.isSuspended) throw new IdentifiableError(errorCodes.userIsSuspended, `Can't update featured for ${userId}: user is suspended`);
|
||||||
|
if (!user.featured) throw new IdentifiableError(errorCodes.noFeaturedCollection, `Can't update featured for ${userId}: no featured collection`);
|
||||||
|
|
||||||
// Resolve to Object(may be Note) arrays
|
this.logger.info(`Updating featured notes for: ${user.uri}`);
|
||||||
const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems;
|
|
||||||
const items = await Promise.all(toArray(unresolvedItems).map(x => _resolver.resolve(x)));
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
|
// Mark as updated
|
||||||
|
await this.usersRepository.update({ id: userId }, { lastFetchedFeaturedAt: this.timeService.date });
|
||||||
|
await this.internalEventService.emit('remoteUserUpdated', { id: userId });
|
||||||
|
|
||||||
// Resolve and regist Notes
|
// Resolve and regist Notes
|
||||||
const limit = promiseLimit<MiNote | null>(2);
|
|
||||||
const maxPinned = (await this.roleService.getUserPolicies(user.id)).pinLimit;
|
const maxPinned = (await this.roleService.getUserPolicies(user.id)).pinLimit;
|
||||||
const featuredNotes = await Promise.all(items
|
const items = await resolver.resolveCollectionItems(user.featured, true, user.uri, maxPinned, 2);
|
||||||
.filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも
|
const featuredNotes = await promiseMap(items, async item => {
|
||||||
.slice(0, maxPinned)
|
const itemId = getNullableApId(item);
|
||||||
.map(item => limit(() => this.apNoteService.resolveNote(item, {
|
if (itemId && isPost(item)) {
|
||||||
resolver: _resolver,
|
try {
|
||||||
sentFrom: user.uri,
|
const note = await this.apNoteService.resolveNote(item, {
|
||||||
}))));
|
resolver: resolver,
|
||||||
|
sentFrom: itemId, // resolveCollectionItems has already verified this, so we can re-use it to avoid double fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
if (note && note.userId !== user.id) {
|
||||||
|
this.logger.warn(`Ignoring cross-note pin: user ${user.id} tried to pin note ${note.id} belonging to other user ${note.userId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return note;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Couldn't fetch pinned note ${itemId} for user ${user.id} (@${user.username}@${user.host}): ${renderInlineError(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, {
|
||||||
|
limit: 2,
|
||||||
|
});
|
||||||
|
|
||||||
await this.db.transaction(async transactionalEntityManager => {
|
await this.db.transaction(async transactionalEntityManager => {
|
||||||
await transactionalEntityManager.delete(MiUserNotePining, { userId: user.id });
|
await transactionalEntityManager.delete(MiUserNotePining, { userId: user.id });
|
||||||
|
|
@ -947,7 +987,7 @@ export class ApPersonService implements OnModuleInit {
|
||||||
let td = 0;
|
let td = 0;
|
||||||
for (const note of featuredNotes.filter(x => x != null)) {
|
for (const note of featuredNotes.filter(x => x != null)) {
|
||||||
td -= 1000;
|
td -= 1000;
|
||||||
transactionalEntityManager.insert(MiUserNotePining, {
|
await transactionalEntityManager.insert(MiUserNotePining, {
|
||||||
id: this.idService.gen(this.timeService.now + td),
|
id: this.idService.gen(this.timeService.now + td),
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
noteId: note.id,
|
noteId: note.id,
|
||||||
|
|
@ -971,6 +1011,7 @@ export class ApPersonService implements OnModuleInit {
|
||||||
let dst = await this.fetchPerson(src.movedToUri);
|
let dst = await this.fetchPerson(src.movedToUri);
|
||||||
|
|
||||||
if (dst && isLocalUser(dst)) {
|
if (dst && isLocalUser(dst)) {
|
||||||
|
// TODO this branch should not be possible
|
||||||
// targetがローカルユーザーだった場合データベースから引っ張ってくる
|
// targetがローカルユーザーだった場合データベースから引っ張ってくる
|
||||||
dst = await this.usersRepository.findOneByOrFail({ uri: src.movedToUri }) as MiLocalUser;
|
dst = await this.usersRepository.findOneByOrFail({ uri: src.movedToUri }) as MiLocalUser;
|
||||||
} else if (dst) {
|
} else if (dst) {
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,9 @@ export default class ActiveUsersChart extends Chart<typeof schema> { // eslint-d
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async read(user: { id: MiUser['id'], host: null }): Promise<void> {
|
public read(user: { id: MiUser['id'], host: null }): void {
|
||||||
const createdAt = this.idService.parse(user.id).date;
|
const createdAt = this.idService.parse(user.id).date;
|
||||||
await this.commit({
|
this.commit({
|
||||||
'read': [user.id],
|
'read': [user.id],
|
||||||
'registeredWithinWeek': (this.timeService.now - createdAt.getTime() < week) ? [user.id] : [],
|
'registeredWithinWeek': (this.timeService.now - createdAt.getTime() < week) ? [user.id] : [],
|
||||||
'registeredWithinMonth': (this.timeService.now - createdAt.getTime() < month) ? [user.id] : [],
|
'registeredWithinMonth': (this.timeService.now - createdAt.getTime() < month) ? [user.id] : [],
|
||||||
|
|
@ -64,8 +64,8 @@ export default class ActiveUsersChart extends Chart<typeof schema> { // eslint-d
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async write(user: { id: MiUser['id'], host: null }): Promise<void> {
|
public write(user: { id: MiUser['id'], host: null }): void {
|
||||||
await this.commit({
|
this.commit({
|
||||||
'write': [user.id],
|
'write': [user.id],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,22 +43,22 @@ export default class ApRequestChart extends Chart<typeof schema> { // eslint-dis
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async deliverSucc(): Promise<void> {
|
public deliverSucc(): void {
|
||||||
await this.commit({
|
this.commit({
|
||||||
'deliverSucceeded': 1,
|
'deliverSucceeded': 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async deliverFail(): Promise<void> {
|
public deliverFail(): void {
|
||||||
await this.commit({
|
this.commit({
|
||||||
'deliverFailed': 1,
|
'deliverFailed': 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async inbox(): Promise<void> {
|
public inbox(): void {
|
||||||
await this.commit({
|
this.commit({
|
||||||
'inboxReceived': 1,
|
'inboxReceived': 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,9 @@ export default class DriveChart extends Chart<typeof schema> { // eslint-disable
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async update(file: MiDriveFile, isAdditional: boolean): Promise<void> {
|
public update(file: MiDriveFile, isAdditional: boolean): void {
|
||||||
const fileSizeKb = file.size / 1000;
|
const fileSizeKb = file.size / 1000;
|
||||||
await this.commit(file.userHost === null ? {
|
this.commit(file.userHost === null ? {
|
||||||
'local.incCount': isAdditional ? 1 : 0,
|
'local.incCount': isAdditional ? 1 : 0,
|
||||||
'local.incSize': isAdditional ? fileSizeKb : 0,
|
'local.incSize': isAdditional ? fileSizeKb : 0,
|
||||||
'local.decCount': isAdditional ? 0 : 1,
|
'local.decCount': isAdditional ? 0 : 1,
|
||||||
|
|
|
||||||
|
|
@ -118,8 +118,8 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async deliverd(host: string, succeeded: boolean): Promise<void> {
|
public deliverd(host: string, succeeded: boolean): void {
|
||||||
await this.commit(succeeded ? {
|
this.commit(succeeded ? {
|
||||||
'deliveredInstances': [host],
|
'deliveredInstances': [host],
|
||||||
} : {
|
} : {
|
||||||
'stalled': [host],
|
'stalled': [host],
|
||||||
|
|
@ -127,8 +127,8 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async inbox(host: string): Promise<void> {
|
public inbox(host: string): void {
|
||||||
await this.commit({
|
this.commit({
|
||||||
'inboxInstances': [host],
|
'inboxInstances': [host],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,31 +80,31 @@ export default class InstanceChart extends Chart<typeof schema> { // eslint-disa
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async requestReceived(host: string): Promise<void> {
|
public requestReceived(host: string): void {
|
||||||
await this.commit({
|
this.commit({
|
||||||
'requests.received': 1,
|
'requests.received': 1,
|
||||||
}, this.utilityService.toPuny(host));
|
}, this.utilityService.toPuny(host));
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async requestSent(host: string, isSucceeded: boolean): Promise<void> {
|
public requestSent(host: string, isSucceeded: boolean): void {
|
||||||
await this.commit({
|
this.commit({
|
||||||
'requests.succeeded': isSucceeded ? 1 : 0,
|
'requests.succeeded': isSucceeded ? 1 : 0,
|
||||||
'requests.failed': isSucceeded ? 0 : 1,
|
'requests.failed': isSucceeded ? 0 : 1,
|
||||||
}, this.utilityService.toPuny(host));
|
}, this.utilityService.toPuny(host));
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async newUser(host: string): Promise<void> {
|
public newUser(host: string): void {
|
||||||
await this.commit({
|
this.commit({
|
||||||
'users.total': 1,
|
'users.total': 1,
|
||||||
'users.inc': 1,
|
'users.inc': 1,
|
||||||
}, this.utilityService.toPuny(host));
|
}, this.utilityService.toPuny(host));
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async updateNote(host: string, note: MiNote, isAdditional: boolean): Promise<void> {
|
public updateNote(host: string, note: MiNote, isAdditional: boolean): void {
|
||||||
await this.commit({
|
this.commit({
|
||||||
'notes.total': isAdditional ? 1 : -1,
|
'notes.total': isAdditional ? 1 : -1,
|
||||||
'notes.inc': isAdditional ? 1 : 0,
|
'notes.inc': isAdditional ? 1 : 0,
|
||||||
'notes.dec': isAdditional ? 0 : 1,
|
'notes.dec': isAdditional ? 0 : 1,
|
||||||
|
|
@ -116,8 +116,8 @@ export default class InstanceChart extends Chart<typeof schema> { // eslint-disa
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async updateFollowing(host: string, isAdditional: boolean): Promise<void> {
|
public updateFollowing(host: string, isAdditional: boolean): void {
|
||||||
await this.commit({
|
this.commit({
|
||||||
'following.total': isAdditional ? 1 : -1,
|
'following.total': isAdditional ? 1 : -1,
|
||||||
'following.inc': isAdditional ? 1 : 0,
|
'following.inc': isAdditional ? 1 : 0,
|
||||||
'following.dec': isAdditional ? 0 : 1,
|
'following.dec': isAdditional ? 0 : 1,
|
||||||
|
|
@ -125,8 +125,8 @@ export default class InstanceChart extends Chart<typeof schema> { // eslint-disa
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async updateFollowers(host: string, isAdditional: boolean): Promise<void> {
|
public updateFollowers(host: string, isAdditional: boolean): void {
|
||||||
await this.commit({
|
this.commit({
|
||||||
'followers.total': isAdditional ? 1 : -1,
|
'followers.total': isAdditional ? 1 : -1,
|
||||||
'followers.inc': isAdditional ? 1 : 0,
|
'followers.inc': isAdditional ? 1 : 0,
|
||||||
'followers.dec': isAdditional ? 0 : 1,
|
'followers.dec': isAdditional ? 0 : 1,
|
||||||
|
|
@ -134,9 +134,9 @@ export default class InstanceChart extends Chart<typeof schema> { // eslint-disa
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async updateDrive(file: MiDriveFile, isAdditional: boolean): Promise<void> {
|
public updateDrive(file: MiDriveFile, isAdditional: boolean): void {
|
||||||
const fileSizeKb = file.size / 1000;
|
const fileSizeKb = file.size / 1000;
|
||||||
await this.commit({
|
this.commit({
|
||||||
'drive.totalFiles': isAdditional ? 1 : -1,
|
'drive.totalFiles': isAdditional ? 1 : -1,
|
||||||
'drive.incFiles': isAdditional ? 1 : 0,
|
'drive.incFiles': isAdditional ? 1 : 0,
|
||||||
'drive.incUsage': isAdditional ? fileSizeKb : 0,
|
'drive.incUsage': isAdditional ? fileSizeKb : 0,
|
||||||
|
|
|
||||||
|
|
@ -56,10 +56,10 @@ export default class NotesChart extends Chart<typeof schema> { // eslint-disable
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async update(note: MiNote, isAdditional: boolean): Promise<void> {
|
public update(note: MiNote, isAdditional: boolean): void {
|
||||||
const prefix = note.userHost === null ? 'local' : 'remote';
|
const prefix = note.userHost === null ? 'local' : 'remote';
|
||||||
|
|
||||||
await this.commit({
|
this.commit({
|
||||||
[`${prefix}.total`]: isAdditional ? 1 : -1,
|
[`${prefix}.total`]: isAdditional ? 1 : -1,
|
||||||
[`${prefix}.inc`]: isAdditional ? 1 : 0,
|
[`${prefix}.inc`]: isAdditional ? 1 : 0,
|
||||||
[`${prefix}.dec`]: isAdditional ? 0 : 1,
|
[`${prefix}.dec`]: isAdditional ? 0 : 1,
|
||||||
|
|
|
||||||
|
|
@ -58,9 +58,9 @@ export default class PerUserDriveChart extends Chart<typeof schema> { // eslint-
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async update(file: MiDriveFile, isAdditional: boolean): Promise<void> {
|
public update(file: MiDriveFile, isAdditional: boolean): void {
|
||||||
const fileSizeKb = file.size / 1000;
|
const fileSizeKb = file.size / 1000;
|
||||||
await this.commit({
|
this.commit({
|
||||||
'totalCount': isAdditional ? 1 : -1,
|
'totalCount': isAdditional ? 1 : -1,
|
||||||
'totalSize': isAdditional ? fileSizeKb : -fileSizeKb,
|
'totalSize': isAdditional ? fileSizeKb : -fileSizeKb,
|
||||||
'incCount': isAdditional ? 1 : 0,
|
'incCount': isAdditional ? 1 : 0,
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ export default class PerUserFollowingChart extends Chart<typeof schema> { // esl
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async update(follower: { id: MiUser['id']; host: MiUser['host']; }, followee: { id: MiUser['id']; host: MiUser['host']; }, isFollow: boolean): Promise<void> {
|
public update(follower: { id: MiUser['id']; host: MiUser['host']; }, followee: { id: MiUser['id']; host: MiUser['host']; }, isFollow: boolean): void {
|
||||||
const prefixFollower = this.userEntityService.isLocalUser(follower) ? 'local' : 'remote';
|
const prefixFollower = this.userEntityService.isLocalUser(follower) ? 'local' : 'remote';
|
||||||
const prefixFollowee = this.userEntityService.isLocalUser(followee) ? 'local' : 'remote';
|
const prefixFollowee = this.userEntityService.isLocalUser(followee) ? 'local' : 'remote';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,16 +44,16 @@ export default class PerUserPvChart extends Chart<typeof schema> { // eslint-dis
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async commitByUser(user: { id: MiUser['id'] }, key: string): Promise<void> {
|
public commitByUser(user: { id: MiUser['id'] }, key: string): void {
|
||||||
await this.commit({
|
this.commit({
|
||||||
'upv.user': [key],
|
'upv.user': [key],
|
||||||
'pv.user': 1,
|
'pv.user': 1,
|
||||||
}, user.id);
|
}, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async commitByVisitor(user: { id: MiUser['id'] }, key: string): Promise<void> {
|
public commitByVisitor(user: { id: MiUser['id'] }, key: string): void {
|
||||||
await this.commit({
|
this.commit({
|
||||||
'upv.visitor': [key],
|
'upv.visitor': [key],
|
||||||
'pv.visitor': 1,
|
'pv.visitor': 1,
|
||||||
}, user.id);
|
}, user.id);
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ export default class PerUserReactionsChart extends Chart<typeof schema> { // esl
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async update(user: { id: MiUser['id'], host: MiUser['host'] }, note: MiNote): Promise<void> {
|
public update(user: { id: MiUser['id'], host: MiUser['host'] }, note: MiNote): void {
|
||||||
const prefix = this.userEntityService.isLocalUser(user) ? 'local' : 'remote';
|
const prefix = this.userEntityService.isLocalUser(user) ? 'local' : 'remote';
|
||||||
this.commit({
|
this.commit({
|
||||||
[`${prefix}.count`]: 1,
|
[`${prefix}.count`]: 1,
|
||||||
|
|
|
||||||
|
|
@ -48,12 +48,12 @@ export default class TestGroupedChart extends Chart<typeof schema> { // eslint-d
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async increment(group: string): Promise<void> {
|
public increment(group: string): void {
|
||||||
if (this.total[group] == null) this.total[group] = 0;
|
if (this.total[group] == null) this.total[group] = 0;
|
||||||
|
|
||||||
this.total[group]++;
|
this.total[group]++;
|
||||||
|
|
||||||
await this.commit({
|
this.commit({
|
||||||
'foo.total': 1,
|
'foo.total': 1,
|
||||||
'foo.inc': 1,
|
'foo.inc': 1,
|
||||||
}, group);
|
}, group);
|
||||||
|
|
|
||||||
|
|
@ -44,15 +44,15 @@ export default class TestIntersectionChart extends Chart<typeof schema> { // esl
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async addA(key: string): Promise<void> {
|
public addA(key: string): void {
|
||||||
await this.commit({
|
this.commit({
|
||||||
a: [key],
|
a: [key],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async addB(key: string): Promise<void> {
|
public addB(key: string): void {
|
||||||
await this.commit({
|
this.commit({
|
||||||
b: [key],
|
b: [key],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,8 @@ export default class TestUniqueChart extends Chart<typeof schema> { // eslint-di
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async uniqueIncrement(key: string): Promise<void> {
|
public uniqueIncrement(key: string): void {
|
||||||
await this.commit({
|
this.commit({
|
||||||
foo: [key],
|
foo: [key],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,20 +48,20 @@ export default class TestChart extends Chart<typeof schema> { // eslint-disable-
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async increment(): Promise<void> {
|
public increment(): void {
|
||||||
this.total++;
|
this.total++;
|
||||||
|
|
||||||
await this.commit({
|
this.commit({
|
||||||
'foo.total': 1,
|
'foo.total': 1,
|
||||||
'foo.inc': 1,
|
'foo.inc': 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async decrement(): Promise<void> {
|
public decrement(): void {
|
||||||
this.total--;
|
this.total--;
|
||||||
|
|
||||||
await this.commit({
|
this.commit({
|
||||||
'foo.total': -1,
|
'foo.total': -1,
|
||||||
'foo.dec': 1,
|
'foo.dec': 1,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,10 @@ export default class UsersChart extends Chart<typeof schema> { // eslint-disable
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async update(user: { id: MiUser['id'], host: MiUser['host'] }, isAdditional: boolean): Promise<void> {
|
public update(user: { id: MiUser['id'], host: MiUser['host'] }, isAdditional: boolean): void {
|
||||||
const prefix = this.userEntityService.isLocalUser(user) ? 'local' : 'remote';
|
const prefix = this.userEntityService.isLocalUser(user) ? 'local' : 'remote';
|
||||||
|
|
||||||
await this.commit({
|
this.commit({
|
||||||
[`${prefix}.total`]: isAdditional ? 1 : -1,
|
[`${prefix}.total`]: isAdditional ? 1 : -1,
|
||||||
[`${prefix}.inc`]: isAdditional ? 1 : 0,
|
[`${prefix}.inc`]: isAdditional ? 1 : 0,
|
||||||
[`${prefix}.dec`]: isAdditional ? 0 : 1,
|
[`${prefix}.dec`]: isAdditional ? 0 : 1,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { dateUTC, isTimeSame, isTimeBefore, subtractTime, addTime } from '@/misc
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { MiRepository, miRepository } from '@/models/_.js';
|
import { MiRepository, miRepository } from '@/models/_.js';
|
||||||
|
import { promiseMap } from '@/misc/promise-map.js';
|
||||||
import type { DataSource, Repository } from 'typeorm';
|
import type { DataSource, Repository } from 'typeorm';
|
||||||
import type { Lock } from 'redis-lock';
|
import type { Lock } from 'redis-lock';
|
||||||
|
|
||||||
|
|
@ -526,13 +527,13 @@ export default abstract class Chart<T extends Schema> {
|
||||||
|
|
||||||
const groups = removeDuplicates(this.buffer.map(log => log.group));
|
const groups = removeDuplicates(this.buffer.map(log => log.group));
|
||||||
|
|
||||||
await Promise.all(
|
await promiseMap(groups, async group => {
|
||||||
groups.map(group =>
|
const logHour = await this.claimCurrentLog(group, 'hour');
|
||||||
Promise.all([
|
const logDay = await this.claimCurrentLog(group, 'day');
|
||||||
this.claimCurrentLog(group, 'hour'),
|
await update(logHour, logDay);
|
||||||
this.claimCurrentLog(group, 'day'),
|
}, {
|
||||||
]).then(([logHour, logDay]) =>
|
limit: 2,
|
||||||
update(logHour, logDay))));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -564,7 +565,7 @@ export default abstract class Chart<T extends Schema> {
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
return Promise.all([
|
return await Promise.all([
|
||||||
this.claimCurrentLog(group, 'hour'),
|
this.claimCurrentLog(group, 'hour'),
|
||||||
this.claimCurrentLog(group, 'day'),
|
this.claimCurrentLog(group, 'day'),
|
||||||
]).then(([logHour, logDay]) =>
|
]).then(([logHour, logDay]) =>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export class BlockingEntityService {
|
||||||
): Promise<Packed<'Blocking'>> {
|
): Promise<Packed<'Blocking'>> {
|
||||||
const blocking = typeof src === 'object' ? src : await this.blockingsRepository.findOneByOrFail({ id: src });
|
const blocking = typeof src === 'object' ? src : await this.blockingsRepository.findOneByOrFail({ id: src });
|
||||||
|
|
||||||
|
// noinspection ES6MissingAwait
|
||||||
return await awaitAll({
|
return await awaitAll({
|
||||||
id: blocking.id,
|
id: blocking.id,
|
||||||
createdAt: this.idService.parse(blocking.id).date.toISOString(),
|
createdAt: this.idService.parse(blocking.id).date.toISOString(),
|
||||||
|
|
@ -53,6 +54,6 @@ export class BlockingEntityService {
|
||||||
const _blockees = blockings.map(({ blockee, blockeeId }) => blockee ?? blockeeId);
|
const _blockees = blockings.map(({ blockee, blockeeId }) => blockee ?? blockeeId);
|
||||||
const _userMap = await this.userEntityService.packMany(_blockees, me, { schema: 'UserDetailedNotMe' })
|
const _userMap = await this.userEntityService.packMany(_blockees, me, { schema: 'UserDetailedNotMe' })
|
||||||
.then(users => new Map(users.map(u => [u.id, u])));
|
.then(users => new Map(users.map(u => [u.id, u])));
|
||||||
return Promise.all(blockings.map(blocking => this.pack(blocking, me, { blockee: _userMap.get(blocking.blockeeId) })));
|
return await Promise.all(blockings.map(blocking => this.pack(blocking, me, { blockee: _userMap.get(blocking.blockeeId) })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ export class ChatEntityService {
|
||||||
.then(rooms => new Map(rooms.map(r => [r.id, r]))),
|
.then(rooms => new Map(rooms.map(r => [r.id, r]))),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return Promise.all(messages.map(message => this.packMessageDetailed(message, me, { _hint_: { packedUsers, packedFiles, packedRooms } })));
|
return await Promise.all(messages.map(message => this.packMessageDetailed(message, me, { _hint_: { packedUsers, packedFiles, packedRooms } })));
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -165,7 +165,7 @@ export class ChatEntityService {
|
||||||
.then(files => new Map(files.map(f => [f.id, f]))),
|
.then(files => new Map(files.map(f => [f.id, f]))),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return Promise.all(messages.map(message => this.packMessageLiteFor1on1(message, { _hint_: { packedFiles } })));
|
return await Promise.all(messages.map(message => this.packMessageLiteFor1on1(message, { _hint_: { packedFiles } })));
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -228,7 +228,7 @@ export class ChatEntityService {
|
||||||
.then(files => new Map(files.map(f => [f.id, f]))),
|
.then(files => new Map(files.map(f => [f.id, f]))),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return Promise.all(messages.map(message => this.packMessageLiteForRoom(message, { _hint_: { packedFiles, packedUsers } })));
|
return await Promise.all(messages.map(message => this.packMessageLiteForRoom(message, { _hint_: { packedFiles, packedUsers } })));
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -289,7 +289,7 @@ export class ChatEntityService {
|
||||||
}).then(memberships => new Map(_rooms.map(r => [r.id, memberships.find(m => m.roomId === r.id)]))),
|
}).then(memberships => new Map(_rooms.map(r => [r.id, memberships.find(m => m.roomId === r.id)]))),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return Promise.all(_rooms.map(room => this.packRoom(room, me, { _hint_: { packedOwners, memberships } })));
|
return await Promise.all(_rooms.map(room => this.packRoom(room, me, { _hint_: { packedOwners, memberships } })));
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -322,7 +322,7 @@ export class ChatEntityService {
|
||||||
) {
|
) {
|
||||||
if (invitations.length === 0) return [];
|
if (invitations.length === 0) return [];
|
||||||
|
|
||||||
return Promise.all(invitations.map(invitation => this.packRoomInvitation(invitation, me)));
|
return await Promise.all(invitations.map(invitation => this.packRoomInvitation(invitation, me)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -371,6 +371,6 @@ export class ChatEntityService {
|
||||||
.then(rooms => new Map(rooms.map(r => [r.id, r]))),
|
.then(rooms => new Map(rooms.map(r => [r.id, r]))),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return Promise.all(memberships.map(membership => this.packRoomMembership(membership, me, { ...options, _hint_: { packedUsers, packedRooms } })));
|
return await Promise.all(memberships.map(membership => this.packRoomMembership(membership, me, { ...options, _hint_: { packedUsers, packedRooms } })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export class ClipEntityService {
|
||||||
const meId = me ? me.id : null;
|
const meId = me ? me.id : null;
|
||||||
const clip = typeof src === 'object' ? src : await this.clipsRepository.findOneByOrFail({ id: src });
|
const clip = typeof src === 'object' ? src : await this.clipsRepository.findOneByOrFail({ id: src });
|
||||||
|
|
||||||
|
// noinspection ES6MissingAwait
|
||||||
return await awaitAll({
|
return await awaitAll({
|
||||||
id: clip.id,
|
id: clip.id,
|
||||||
createdAt: this.idService.parse(clip.id).date.toISOString(),
|
createdAt: this.idService.parse(clip.id).date.toISOString(),
|
||||||
|
|
@ -65,7 +66,7 @@ export class ClipEntityService {
|
||||||
const _users = clips.map(({ user, userId }) => user ?? userId);
|
const _users = clips.map(({ user, userId }) => user ?? userId);
|
||||||
const _userMap = await this.userEntityService.packMany(_users, me)
|
const _userMap = await this.userEntityService.packMany(_users, me)
|
||||||
.then(users => new Map(users.map(u => [u.id, u])));
|
.then(users => new Map(users.map(u => [u.id, u])));
|
||||||
return Promise.all(clips.map(clip => this.pack(clip, me, { packedUser: _userMap.get(clip.userId) })));
|
return await Promise.all(clips.map(clip => this.pack(clip, me, { packedUser: _userMap.get(clip.userId) })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,7 @@ export class DriveFileEntityService implements OnModuleInit {
|
||||||
|
|
||||||
const file = typeof src === 'object' ? src : await this.driveFilesRepository.findOneByOrFail({ id: src });
|
const file = typeof src === 'object' ? src : await this.driveFilesRepository.findOneByOrFail({ id: src });
|
||||||
|
|
||||||
|
// noinspection ES6MissingAwait
|
||||||
return await awaitAll<Packed<'DriveFile'>>({
|
return await awaitAll<Packed<'DriveFile'>>({
|
||||||
id: file.id,
|
id: file.id,
|
||||||
createdAt: this.idService.parse(file.id).date.toISOString(),
|
createdAt: this.idService.parse(file.id).date.toISOString(),
|
||||||
|
|
@ -239,6 +240,7 @@ export class DriveFileEntityService implements OnModuleInit {
|
||||||
const file = typeof src === 'object' ? src : await this.driveFilesRepository.findOneBy({ id: src });
|
const file = typeof src === 'object' ? src : await this.driveFilesRepository.findOneBy({ id: src });
|
||||||
if (file == null) return null;
|
if (file == null) return null;
|
||||||
|
|
||||||
|
// noinspection ES6MissingAwait
|
||||||
return await awaitAll<Packed<'DriveFile'>>({
|
return await awaitAll<Packed<'DriveFile'>>({
|
||||||
id: file.id,
|
id: file.id,
|
||||||
createdAt: this.idService.parse(file.id).date.toISOString(),
|
createdAt: this.idService.parse(file.id).date.toISOString(),
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,7 @@ export class EmojiEntityService implements OnModuleInit {
|
||||||
hintRoles = new Map(roles.map(x => [x.id, x]));
|
hintRoles = new Map(roles.map(x => [x.id, x]));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.all(emojis.map(x => this.packDetailedAdmin(x, { roles: hintRoles })));
|
return await Promise.all(emojis.map(x => this.packDetailedAdmin(x, { roles: hintRoles })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ export class FlashEntityService {
|
||||||
.getRawMany<{ flashLike_flashId: string }>()
|
.getRawMany<{ flashLike_flashId: string }>()
|
||||||
.then(likes => [...new Set(likes.map(like => like.flashLike_flashId))])
|
.then(likes => [...new Set(likes.map(like => like.flashLike_flashId))])
|
||||||
: [];
|
: [];
|
||||||
return Promise.all(
|
return await Promise.all(
|
||||||
flashes.map(flash => this.pack(flash, me, {
|
flashes.map(flash => this.pack(flash, me, {
|
||||||
packedUser: _userMap.get(flash.userId),
|
packedUser: _userMap.get(flash.userId),
|
||||||
likedFlashIds: _likedFlashIds,
|
likedFlashIds: _likedFlashIds,
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ export class FollowRequestEntityService {
|
||||||
const _followees = requests.map(({ followee, followeeId }) => followee ?? followeeId);
|
const _followees = requests.map(({ followee, followeeId }) => followee ?? followeeId);
|
||||||
const _userMap = await this.userEntityService.packMany([..._followers, ..._followees], me)
|
const _userMap = await this.userEntityService.packMany([..._followers, ..._followees], me)
|
||||||
.then(users => new Map(users.map(u => [u.id, u])));
|
.then(users => new Map(users.map(u => [u.id, u])));
|
||||||
return Promise.all(
|
return await Promise.all(
|
||||||
requests.map(req => {
|
requests.map(req => {
|
||||||
const packedFollower = _userMap.get(req.followerId);
|
const packedFollower = _userMap.get(req.followerId);
|
||||||
const packedFollowee = _userMap.get(req.followeeId);
|
const packedFollowee = _userMap.get(req.followeeId);
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,7 @@ export class FollowingEntityService {
|
||||||
|
|
||||||
if (opts == null) opts = {};
|
if (opts == null) opts = {};
|
||||||
|
|
||||||
|
// noinspection ES6MissingAwait
|
||||||
return await awaitAll({
|
return await awaitAll({
|
||||||
id: following.id,
|
id: following.id,
|
||||||
createdAt: this.idService.parse(following.id).date.toISOString(),
|
createdAt: this.idService.parse(following.id).date.toISOString(),
|
||||||
|
|
@ -166,7 +167,7 @@ export class FollowingEntityService {
|
||||||
const _followers = opts?.populateFollower ? followings.map(({ follower, followerId }) => follower ?? followerId) : [];
|
const _followers = opts?.populateFollower ? followings.map(({ follower, followerId }) => follower ?? followerId) : [];
|
||||||
const _userMap = await this.userEntityService.packMany([..._followees, ..._followers], me, { schema: 'UserDetailedNotMe' })
|
const _userMap = await this.userEntityService.packMany([..._followees, ..._followers], me, { schema: 'UserDetailedNotMe' })
|
||||||
.then(users => new Map(users.map(u => [u.id, u])));
|
.then(users => new Map(users.map(u => [u.id, u])));
|
||||||
return Promise.all(
|
return await Promise.all(
|
||||||
followings.map(following => {
|
followings.map(following => {
|
||||||
const packedFollowee = opts?.populateFollowee ? _userMap.get(following.followeeId) : undefined;
|
const packedFollowee = opts?.populateFollowee ? _userMap.get(following.followeeId) : undefined;
|
||||||
const packedFollower = opts?.populateFollower ? _userMap.get(following.followerId) : undefined;
|
const packedFollower = opts?.populateFollower ? _userMap.get(following.followerId) : undefined;
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export class GalleryPostEntityService {
|
||||||
const meId = me ? me.id : null;
|
const meId = me ? me.id : null;
|
||||||
const post = typeof src === 'object' ? src : await this.galleryPostsRepository.findOneByOrFail({ id: src });
|
const post = typeof src === 'object' ? src : await this.galleryPostsRepository.findOneByOrFail({ id: src });
|
||||||
|
|
||||||
|
// noinspection ES6MissingAwait
|
||||||
return await awaitAll({
|
return await awaitAll({
|
||||||
id: post.id,
|
id: post.id,
|
||||||
createdAt: this.idService.parse(post.id).date.toISOString(),
|
createdAt: this.idService.parse(post.id).date.toISOString(),
|
||||||
|
|
@ -68,7 +69,7 @@ export class GalleryPostEntityService {
|
||||||
const _users = posts.map(({ user, userId }) => user ?? userId);
|
const _users = posts.map(({ user, userId }) => user ?? userId);
|
||||||
const _userMap = await this.userEntityService.packMany(_users, me)
|
const _userMap = await this.userEntityService.packMany(_users, me)
|
||||||
.then(users => new Map(users.map(u => [u.id, u])));
|
.then(users => new Map(users.map(u => [u.id, u])));
|
||||||
return Promise.all(posts.map(post => this.pack(post, me, { packedUser: _userMap.get(post.userId) })));
|
return await Promise.all(posts.map(post => this.pack(post, me, { packedUser: _userMap.get(post.userId) })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export class InviteCodeEntityService {
|
||||||
const _usedBys = tickets.map(({ usedBy, usedById }) => usedBy ?? usedById).filter(x => x != null);
|
const _usedBys = tickets.map(({ usedBy, usedById }) => usedBy ?? usedById).filter(x => x != null);
|
||||||
const _userMap = await this.userEntityService.packMany([..._createdBys, ..._usedBys], me)
|
const _userMap = await this.userEntityService.packMany([..._createdBys, ..._usedBys], me)
|
||||||
.then(users => new Map(users.map(u => [u.id, u])));
|
.then(users => new Map(users.map(u => [u.id, u])));
|
||||||
return Promise.all(
|
return await Promise.all(
|
||||||
tickets.map(ticket => {
|
tickets.map(ticket => {
|
||||||
const packedCreatedBy = ticket.createdById != null ? _userMap.get(ticket.createdById) : undefined;
|
const packedCreatedBy = ticket.createdById != null ? _userMap.get(ticket.createdById) : undefined;
|
||||||
const packedUsedBy = ticket.usedById != null ? _userMap.get(ticket.usedById) : undefined;
|
const packedUsedBy = ticket.usedById != null ? _userMap.get(ticket.usedById) : undefined;
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ export class ModerationLogEntityService {
|
||||||
) {
|
) {
|
||||||
const log = typeof src === 'object' ? src : await this.moderationLogsRepository.findOneByOrFail({ id: src });
|
const log = typeof src === 'object' ? src : await this.moderationLogsRepository.findOneByOrFail({ id: src });
|
||||||
|
|
||||||
|
// noinspection ES6MissingAwait
|
||||||
return await awaitAll({
|
return await awaitAll({
|
||||||
id: log.id,
|
id: log.id,
|
||||||
createdAt: this.idService.parse(log.id).date.toISOString(),
|
createdAt: this.idService.parse(log.id).date.toISOString(),
|
||||||
|
|
@ -53,7 +54,7 @@ export class ModerationLogEntityService {
|
||||||
const _users = reports.map(({ user, userId }) => user ?? userId);
|
const _users = reports.map(({ user, userId }) => user ?? userId);
|
||||||
const _userMap = await this.userEntityService.packMany(_users, null, { schema: 'UserDetailedNotMe' })
|
const _userMap = await this.userEntityService.packMany(_users, null, { schema: 'UserDetailedNotMe' })
|
||||||
.then(users => new Map(users.map(u => [u.id, u])));
|
.then(users => new Map(users.map(u => [u.id, u])));
|
||||||
return Promise.all(reports.map(report => this.pack(report, { packedUser: _userMap.get(report.userId) })));
|
return await Promise.all(reports.map(report => this.pack(report, { packedUser: _userMap.get(report.userId) })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export class MutingEntityService {
|
||||||
): Promise<Packed<'Muting'>> {
|
): Promise<Packed<'Muting'>> {
|
||||||
const muting = typeof src === 'object' ? src : await this.mutingsRepository.findOneByOrFail({ id: src });
|
const muting = typeof src === 'object' ? src : await this.mutingsRepository.findOneByOrFail({ id: src });
|
||||||
|
|
||||||
|
// noinspection ES6MissingAwait
|
||||||
return await awaitAll({
|
return await awaitAll({
|
||||||
id: muting.id,
|
id: muting.id,
|
||||||
createdAt: this.idService.parse(muting.id).date.toISOString(),
|
createdAt: this.idService.parse(muting.id).date.toISOString(),
|
||||||
|
|
@ -55,7 +56,7 @@ export class MutingEntityService {
|
||||||
const _mutees = mutings.map(({ mutee, muteeId }) => mutee ?? muteeId);
|
const _mutees = mutings.map(({ mutee, muteeId }) => mutee ?? muteeId);
|
||||||
const _userMap = await this.userEntityService.packMany(_mutees, me, { schema: 'UserDetailedNotMe' })
|
const _userMap = await this.userEntityService.packMany(_mutees, me, { schema: 'UserDetailedNotMe' })
|
||||||
.then(users => new Map(users.map(u => [u.id, u])));
|
.then(users => new Map(users.map(u => [u.id, u])));
|
||||||
return Promise.all(mutings.map(muting => this.pack(muting, me, { packedMutee: _userMap.get(muting.muteeId) })));
|
return await Promise.all(mutings.map(muting => this.pack(muting, me, { packedMutee: _userMap.get(muting.muteeId) })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -592,6 +592,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
|
|
||||||
const bypassSilence = opts.bypassSilence || note.userId === meId;
|
const bypassSilence = opts.bypassSilence || note.userId === meId;
|
||||||
|
|
||||||
|
// noinspection ES6MissingAwait
|
||||||
const packed: Packed<'Note'> = await awaitAll({
|
const packed: Packed<'Note'> = await awaitAll({
|
||||||
id: note.id,
|
id: note.id,
|
||||||
threadId,
|
threadId,
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,13 @@
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { NoteFavoritesRepository } from '@/models/_.js';
|
import type { MiNote, NoteFavoritesRepository } from '@/models/_.js';
|
||||||
import type { } from '@/models/Blocking.js';
|
import type { } from '@/models/Blocking.js';
|
||||||
import type { MiUser } from '@/models/User.js';
|
import type { MiUser } from '@/models/User.js';
|
||||||
import type { MiNoteFavorite } from '@/models/NoteFavorite.js';
|
import type { MiNoteFavorite } from '@/models/NoteFavorite.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
import type { Packed } from '@/misc/json-schema.js';
|
||||||
import { NoteEntityService } from './NoteEntityService.js';
|
import { NoteEntityService } from './NoteEntityService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -28,6 +29,7 @@ export class NoteFavoriteEntityService {
|
||||||
public async pack(
|
public async pack(
|
||||||
src: MiNoteFavorite['id'] | MiNoteFavorite,
|
src: MiNoteFavorite['id'] | MiNoteFavorite,
|
||||||
me?: { id: MiUser['id'] } | null | undefined,
|
me?: { id: MiUser['id'] } | null | undefined,
|
||||||
|
notes?: Map<string, Packed<'Note'>>,
|
||||||
) {
|
) {
|
||||||
const favorite = typeof src === 'object' ? src : await this.noteFavoritesRepository.findOneByOrFail({ id: src });
|
const favorite = typeof src === 'object' ? src : await this.noteFavoritesRepository.findOneByOrFail({ id: src });
|
||||||
|
|
||||||
|
|
@ -35,15 +37,18 @@ export class NoteFavoriteEntityService {
|
||||||
id: favorite.id,
|
id: favorite.id,
|
||||||
createdAt: this.idService.parse(favorite.id).date.toISOString(),
|
createdAt: this.idService.parse(favorite.id).date.toISOString(),
|
||||||
noteId: favorite.noteId,
|
noteId: favorite.noteId,
|
||||||
note: await this.noteEntityService.pack(favorite.note ?? favorite.noteId, me),
|
note: notes?.get(favorite.noteId) ?? await this.noteEntityService.pack(favorite.note ?? favorite.noteId, me),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public packMany(
|
public async packMany(
|
||||||
favorites: any[],
|
favorites: (MiNoteFavorite & { note: MiNote })[],
|
||||||
me: { id: MiUser['id'] },
|
me: { id: MiUser['id'] },
|
||||||
) {
|
) {
|
||||||
return Promise.all(favorites.map(x => this.pack(x, me)));
|
const packedNotes = await this.noteEntityService.packMany(favorites.map(f => f.note), me);
|
||||||
|
const packedNotesMap = new Map(packedNotes.map(n => [n.id, n]));
|
||||||
|
|
||||||
|
return Promise.all(favorites.map(x => this.pack(x, me, packedNotesMap)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,6 @@ export class NoteReactionEntityService implements OnModuleInit {
|
||||||
const _users = reactions.map(({ user, userId }) => user ?? userId);
|
const _users = reactions.map(({ user, userId }) => user ?? userId);
|
||||||
const _userMap = await this.userEntityService.packMany(_users, me)
|
const _userMap = await this.userEntityService.packMany(_users, me)
|
||||||
.then(users => new Map(users.map(u => [u.id, u])));
|
.then(users => new Map(users.map(u => [u.id, u])));
|
||||||
return Promise.all(reactions.map(reaction => this.pack(reaction, me, opts, { packedUser: _userMap.get(reaction.userId) })));
|
return await Promise.all(reactions.map(reaction => this.pack(reaction, me, opts, { packedUser: _userMap.get(reaction.userId) })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,11 +83,12 @@ export class PageEntityService {
|
||||||
};
|
};
|
||||||
migrate(page.content);
|
migrate(page.content);
|
||||||
if (migrated) {
|
if (migrated) {
|
||||||
this.pagesRepository.update(page.id, {
|
await this.pagesRepository.update(page.id, {
|
||||||
content: page.content,
|
content: page.content,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// noinspection ES6MissingAwait
|
||||||
return await awaitAll({
|
return await awaitAll({
|
||||||
id: page.id,
|
id: page.id,
|
||||||
createdAt: this.idService.parse(page.id).date.toISOString(),
|
createdAt: this.idService.parse(page.id).date.toISOString(),
|
||||||
|
|
@ -104,10 +105,13 @@ export class PageEntityService {
|
||||||
font: page.font,
|
font: page.font,
|
||||||
script: page.script,
|
script: page.script,
|
||||||
eyeCatchingImageId: page.eyeCatchingImageId,
|
eyeCatchingImageId: page.eyeCatchingImageId,
|
||||||
eyeCatchingImage: page.eyeCatchingImageId ? await this.driveFileEntityService.pack(page.eyeCatchingImageId) : null,
|
eyeCatchingImage: page.eyeCatchingImageId ? this.driveFileEntityService.pack(page.eyeCatchingImageId) : null,
|
||||||
attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter(x => x != null)),
|
attachedFiles: Promise
|
||||||
|
.all(attachedFiles)
|
||||||
|
.then(fs => fs.filter(x => x != null))
|
||||||
|
.then(fs => this.driveFileEntityService.packMany(fs)),
|
||||||
likedCount: page.likedCount,
|
likedCount: page.likedCount,
|
||||||
isLiked: meId ? await this.pageLikesRepository.exists({ where: { pageId: page.id, userId: meId } }) : undefined,
|
isLiked: meId ? this.pageLikesRepository.exists({ where: { pageId: page.id, userId: meId } }) : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,7 +123,7 @@ export class PageEntityService {
|
||||||
const _users = pages.map(({ user, userId }) => user ?? userId);
|
const _users = pages.map(({ user, userId }) => user ?? userId);
|
||||||
const _userMap = await this.userEntityService.packMany(_users, me)
|
const _userMap = await this.userEntityService.packMany(_users, me)
|
||||||
.then(users => new Map(users.map(u => [u.id, u])));
|
.then(users => new Map(users.map(u => [u.id, u])));
|
||||||
return Promise.all(pages.map(page => this.pack(page, me, { packedUser: _userMap.get(page.userId) })));
|
return await Promise.all(pages.map(page => this.pack(page, me, { packedUser: _userMap.get(page.userId) })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export class RenoteMutingEntityService {
|
||||||
): Promise<Packed<'RenoteMuting'>> {
|
): Promise<Packed<'RenoteMuting'>> {
|
||||||
const muting = typeof src === 'object' ? src : await this.renoteMutingsRepository.findOneByOrFail({ id: src });
|
const muting = typeof src === 'object' ? src : await this.renoteMutingsRepository.findOneByOrFail({ id: src });
|
||||||
|
|
||||||
|
// noinspection ES6MissingAwait
|
||||||
return await awaitAll({
|
return await awaitAll({
|
||||||
id: muting.id,
|
id: muting.id,
|
||||||
createdAt: this.idService.parse(muting.id).date.toISOString(),
|
createdAt: this.idService.parse(muting.id).date.toISOString(),
|
||||||
|
|
@ -54,7 +55,7 @@ export class RenoteMutingEntityService {
|
||||||
const _users = mutings.map(({ mutee, muteeId }) => mutee ?? muteeId);
|
const _users = mutings.map(({ mutee, muteeId }) => mutee ?? muteeId);
|
||||||
const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailedNotMe' })
|
const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailedNotMe' })
|
||||||
.then(users => new Map(users.map(u => [u.id, u])));
|
.then(users => new Map(users.map(u => [u.id, u])));
|
||||||
return Promise.all(mutings.map(muting => this.pack(muting, me, { packedMutee: _userMap.get(muting.muteeId) })));
|
return await Promise.all(mutings.map(muting => this.pack(muting, me, { packedMutee: _userMap.get(muting.muteeId) })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -485,7 +485,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
if (user.avatarId != null && user.avatarUrl === null) {
|
if (user.avatarId != null && user.avatarUrl === null) {
|
||||||
const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId });
|
const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId });
|
||||||
user.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
|
user.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
|
||||||
this.usersRepository.update(user.id, {
|
await this.usersRepository.update(user.id, {
|
||||||
avatarUrl: user.avatarUrl,
|
avatarUrl: user.avatarUrl,
|
||||||
avatarBlurhash: avatar.blurhash,
|
avatarBlurhash: avatar.blurhash,
|
||||||
});
|
});
|
||||||
|
|
@ -493,7 +493,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
if (user.bannerId != null && user.bannerUrl === null) {
|
if (user.bannerId != null && user.bannerUrl === null) {
|
||||||
const banner = await this.driveFilesRepository.findOneByOrFail({ id: user.bannerId });
|
const banner = await this.driveFilesRepository.findOneByOrFail({ id: user.bannerId });
|
||||||
user.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
|
user.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
|
||||||
this.usersRepository.update(user.id, {
|
await this.usersRepository.update(user.id, {
|
||||||
bannerUrl: user.bannerUrl,
|
bannerUrl: user.bannerUrl,
|
||||||
bannerBlurhash: banner.blurhash,
|
bannerBlurhash: banner.blurhash,
|
||||||
});
|
});
|
||||||
|
|
@ -501,7 +501,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
if (user.backgroundId != null && user.backgroundUrl === null) {
|
if (user.backgroundId != null && user.backgroundUrl === null) {
|
||||||
const background = await this.driveFilesRepository.findOneByOrFail({ id: user.backgroundId });
|
const background = await this.driveFilesRepository.findOneByOrFail({ id: user.backgroundId });
|
||||||
user.backgroundUrl = this.driveFileEntityService.getPublicUrl(background);
|
user.backgroundUrl = this.driveFileEntityService.getPublicUrl(background);
|
||||||
this.usersRepository.update(user.id, {
|
await this.usersRepository.update(user.id, {
|
||||||
backgroundUrl: user.backgroundUrl,
|
backgroundUrl: user.backgroundUrl,
|
||||||
backgroundBlurhash: background.blurhash,
|
backgroundBlurhash: background.blurhash,
|
||||||
});
|
});
|
||||||
|
|
@ -581,6 +581,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
|
|
||||||
const bypassSilence = isMe || (myFollowings ? myFollowings.has(user.id) : false);
|
const bypassSilence = isMe || (myFollowings ? myFollowings.has(user.id) : false);
|
||||||
|
|
||||||
|
// noinspection ES6MissingAwait
|
||||||
const packed = {
|
const packed = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
|
|
@ -644,6 +645,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
...(isDetailed ? {
|
...(isDetailed ? {
|
||||||
url: profile!.url,
|
url: profile!.url,
|
||||||
uri: user.uri,
|
uri: user.uri,
|
||||||
|
// TODO hints for all of this
|
||||||
movedTo: user.movedToUri ? Promise.resolve(opts.userIdsByUri?.get(user.movedToUri) ?? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null)) : null,
|
movedTo: user.movedToUri ? Promise.resolve(opts.userIdsByUri?.get(user.movedToUri) ?? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null)) : null,
|
||||||
movedToUri: user.movedToUri,
|
movedToUri: user.movedToUri,
|
||||||
// alsoKnownAs moved from packedUserDetailedNotMeOnly for privacy
|
// alsoKnownAs moved from packedUserDetailedNotMeOnly for privacy
|
||||||
|
|
@ -894,7 +896,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
myFollowingsPromise,
|
myFollowingsPromise,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return Promise.all(
|
return await Promise.all(
|
||||||
_users.map(u => this.pack(
|
_users.map(u => this.pack(
|
||||||
u,
|
u,
|
||||||
me,
|
me,
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export class UserListEntityService {
|
||||||
const _users = memberships.map(({ user, userId }) => user ?? userId);
|
const _users = memberships.map(({ user, userId }) => user ?? userId);
|
||||||
const _userMap = await this.userEntityService.packMany(_users)
|
const _userMap = await this.userEntityService.packMany(_users)
|
||||||
.then(users => new Map(users.map(u => [u.id, u])));
|
.then(users => new Map(users.map(u => [u.id, u])));
|
||||||
return Promise.all(memberships.map(async x => ({
|
return await Promise.all(memberships.map(async x => ({
|
||||||
id: x.id,
|
id: x.id,
|
||||||
createdAt: this.idService.parse(x.id).date.toISOString(),
|
createdAt: this.idService.parse(x.id).date.toISOString(),
|
||||||
userId: x.userId,
|
userId: x.userId,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export interface StatsEntry {
|
||||||
export interface Stats {
|
export interface Stats {
|
||||||
deliver: StatsEntry,
|
deliver: StatsEntry,
|
||||||
inbox: StatsEntry,
|
inbox: StatsEntry,
|
||||||
|
background: StatsEntry,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ev = new Xev();
|
const ev = new Xev();
|
||||||
|
|
@ -35,9 +36,11 @@ export class QueueStatsService implements OnApplicationShutdown {
|
||||||
private intervalId?: TimerHandle;
|
private intervalId?: TimerHandle;
|
||||||
private activeDeliverJobs = 0;
|
private activeDeliverJobs = 0;
|
||||||
private activeInboxJobs = 0;
|
private activeInboxJobs = 0;
|
||||||
|
private activeBackgroundJobs = 0;
|
||||||
|
|
||||||
private deliverQueueEvents?: Bull.QueueEvents;
|
private deliverQueueEvents?: Bull.QueueEvents;
|
||||||
private inboxQueueEvents?: Bull.QueueEvents;
|
private inboxQueueEvents?: Bull.QueueEvents;
|
||||||
|
private backgroundQueueEvents?: Bull.QueueEvents;
|
||||||
|
|
||||||
private log?: Stats[];
|
private log?: Stats[];
|
||||||
|
|
||||||
|
|
@ -60,6 +63,11 @@ export class QueueStatsService implements OnApplicationShutdown {
|
||||||
this.activeInboxJobs++;
|
this.activeInboxJobs++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private onBackgroundActive() {
|
||||||
|
this.activeBackgroundJobs++;
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private onRequestQueueStatsLog(x: { id: string, length?: number }) {
|
private onRequestQueueStatsLog(x: { id: string, length?: number }) {
|
||||||
if (this.log) {
|
if (this.log) {
|
||||||
|
|
@ -80,13 +88,16 @@ export class QueueStatsService implements OnApplicationShutdown {
|
||||||
|
|
||||||
this.deliverQueueEvents = new Bull.QueueEvents(QUEUE.DELIVER, baseQueueOptions(this.config, QUEUE.DELIVER));
|
this.deliverQueueEvents = new Bull.QueueEvents(QUEUE.DELIVER, baseQueueOptions(this.config, QUEUE.DELIVER));
|
||||||
this.inboxQueueEvents = new Bull.QueueEvents(QUEUE.INBOX, baseQueueOptions(this.config, QUEUE.INBOX));
|
this.inboxQueueEvents = new Bull.QueueEvents(QUEUE.INBOX, baseQueueOptions(this.config, QUEUE.INBOX));
|
||||||
|
this.backgroundQueueEvents = new Bull.QueueEvents(QUEUE.BACKGROUND_TASK, baseQueueOptions(this.config, QUEUE.BACKGROUND_TASK));
|
||||||
|
|
||||||
this.deliverQueueEvents.on('active', this.onDeliverActive);
|
this.deliverQueueEvents.on('active', this.onDeliverActive);
|
||||||
this.inboxQueueEvents.on('active', this.onInboxActive);
|
this.inboxQueueEvents.on('active', this.onInboxActive);
|
||||||
|
this.backgroundQueueEvents.on('active', this.onBackgroundActive);
|
||||||
|
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
const deliverJobCounts = await this.queueService.deliverQueue.getJobCounts();
|
const deliverJobCounts = await this.queueService.deliverQueue.getJobCounts();
|
||||||
const inboxJobCounts = await this.queueService.inboxQueue.getJobCounts();
|
const inboxJobCounts = await this.queueService.inboxQueue.getJobCounts();
|
||||||
|
const backgroundJobCounts = await this.queueService.backgroundTaskQueue.getJobCounts();
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
deliver: {
|
deliver: {
|
||||||
|
|
@ -101,6 +112,12 @@ export class QueueStatsService implements OnApplicationShutdown {
|
||||||
waiting: inboxJobCounts.waiting,
|
waiting: inboxJobCounts.waiting,
|
||||||
delayed: inboxJobCounts.delayed,
|
delayed: inboxJobCounts.delayed,
|
||||||
},
|
},
|
||||||
|
background: {
|
||||||
|
activeSincePrevTick: this.activeBackgroundJobs,
|
||||||
|
active: backgroundJobCounts.active,
|
||||||
|
waiting: backgroundJobCounts.waiting,
|
||||||
|
delayed: backgroundJobCounts.delayed,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
ev.emit('queueStats', stats);
|
ev.emit('queueStats', stats);
|
||||||
|
|
@ -112,6 +129,7 @@ export class QueueStatsService implements OnApplicationShutdown {
|
||||||
|
|
||||||
this.activeDeliverJobs = 0;
|
this.activeDeliverJobs = 0;
|
||||||
this.activeInboxJobs = 0;
|
this.activeInboxJobs = 0;
|
||||||
|
this.activeBackgroundJobs = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
tick();
|
tick();
|
||||||
|
|
@ -120,7 +138,7 @@ export class QueueStatsService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async stop() {
|
public async stop(): Promise<void> {
|
||||||
if (this.intervalId) {
|
if (this.intervalId) {
|
||||||
this.timeService.stopTimer(this.intervalId);
|
this.timeService.stopTimer(this.intervalId);
|
||||||
}
|
}
|
||||||
|
|
@ -130,12 +148,15 @@ export class QueueStatsService implements OnApplicationShutdown {
|
||||||
|
|
||||||
this.deliverQueueEvents?.off('active', this.onDeliverActive);
|
this.deliverQueueEvents?.off('active', this.onDeliverActive);
|
||||||
this.inboxQueueEvents?.off('active', this.onInboxActive);
|
this.inboxQueueEvents?.off('active', this.onInboxActive);
|
||||||
|
this.backgroundQueueEvents?.off('active', this.onBackgroundActive);
|
||||||
|
|
||||||
await this.deliverQueueEvents?.close();
|
await this.deliverQueueEvents?.close();
|
||||||
await this.inboxQueueEvents?.close();
|
await this.inboxQueueEvents?.close();
|
||||||
|
await this.backgroundQueueEvents?.close();
|
||||||
|
|
||||||
this.activeDeliverJobs = 0;
|
this.activeDeliverJobs = 0;
|
||||||
this.activeInboxJobs = 0;
|
this.activeInboxJobs = 0;
|
||||||
|
this.activeBackgroundJobs = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ export const DI = {
|
||||||
chatRoomsRepository: Symbol('chatRoomsRepository'),
|
chatRoomsRepository: Symbol('chatRoomsRepository'),
|
||||||
chatRoomMembershipsRepository: Symbol('chatRoomMembershipsRepository'),
|
chatRoomMembershipsRepository: Symbol('chatRoomMembershipsRepository'),
|
||||||
chatRoomInvitationsRepository: Symbol('chatRoomInvitationsRepository'),
|
chatRoomInvitationsRepository: Symbol('chatRoomInvitationsRepository'),
|
||||||
noteEditRepository: Symbol('noteEditRepository'),
|
noteEditsRepository: Symbol('noteEditsRepository'),
|
||||||
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
|
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
|
||||||
reversiGamesRepository: Symbol('reversiGamesRepository'),
|
reversiGamesRepository: Symbol('reversiGamesRepository'),
|
||||||
noteScheduleRepository: Symbol('noteScheduleRepository'),
|
noteScheduleRepository: Symbol('noteScheduleRepository'),
|
||||||
|
|
|
||||||
|
|
@ -3,45 +3,169 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import promiseLimit from 'promise-limit';
|
||||||
import type { TimeService, TimerHandle } from '@/global/TimeService.js';
|
import type { TimeService, TimerHandle } from '@/global/TimeService.js';
|
||||||
|
import { InternalEventService } from '@/global/InternalEventService.js';
|
||||||
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { Serialized } from '@/types.js';
|
||||||
|
|
||||||
type Job<V> = {
|
type Job<V> = {
|
||||||
value: V;
|
value: V;
|
||||||
timer: TimerHandle;
|
timer: TimerHandle;
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: redis使えるようにする
|
// TODO document IPC sync process
|
||||||
export class CollapsedQueue<K, V> {
|
|
||||||
private jobs: Map<K, Job<V>> = new Map();
|
// sync cross-process:
|
||||||
|
// 1. Emit internal events when scheduling timer, performing queue, and enqueuing data
|
||||||
|
// 2. On enqueue, mark ID as deferred.
|
||||||
|
// 3. On perform, clear mark.
|
||||||
|
// 4. On performAll, skip deferred IDs.
|
||||||
|
// 5. On enqueue when ID is deferred, send data as event instead.
|
||||||
|
// 6. On delete, clear mark.
|
||||||
|
// 7. On delete when ID is deferred, do nothing.
|
||||||
|
|
||||||
|
export class CollapsedQueue<V> {
|
||||||
|
private readonly limiter?: ReturnType<typeof promiseLimit<void>>;
|
||||||
|
private readonly jobs: Map<string, Job<V>> = new Map();
|
||||||
|
private readonly deferredKeys = new Set<string>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected readonly timeService: TimeService,
|
private readonly internalEventService: InternalEventService,
|
||||||
private timeout: number,
|
private readonly timeService: TimeService,
|
||||||
private collapse: (oldValue: V, newValue: V) => V,
|
public readonly name: string,
|
||||||
private perform: (key: K, value: V) => Promise<void>,
|
private readonly timeout: number,
|
||||||
) {}
|
private readonly collapse: (oldValue: V, newValue: V) => V,
|
||||||
|
private readonly perform: (key: string, value: V) => Promise<void | unknown>,
|
||||||
enqueue(key: K, value: V) {
|
private readonly opts?: {
|
||||||
if (this.jobs.has(key)) {
|
onError?: (queue: CollapsedQueue<V>, error: unknown) => void | Promise<void>,
|
||||||
const old = this.jobs.get(key)!;
|
concurrency?: number,
|
||||||
const merged = this.collapse(old.value, value);
|
redisParser?: (data: Serialized<V>) => V,
|
||||||
this.jobs.set(key, { ...old, value: merged });
|
},
|
||||||
} else {
|
) {
|
||||||
const timer = this.timeService.startTimer(() => {
|
if (opts?.concurrency) {
|
||||||
const job = this.jobs.get(key)!;
|
this.limiter = promiseLimit<void>(opts.concurrency);
|
||||||
this.jobs.delete(key);
|
|
||||||
this.perform(key, job.value);
|
|
||||||
}, this.timeout);
|
|
||||||
this.jobs.set(key, { value, timer });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.internalEventService.on('collapsedQueueDefer', this.onDefer, { ignoreLocal: true });
|
||||||
|
this.internalEventService.on('collapsedQueueEnqueue', this.onEnqueue, { ignoreLocal: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
async enqueue(key: string, value: V) {
|
||||||
|
// If deferred, then send it out to the owning process
|
||||||
|
if (this.deferredKeys.has(key)) {
|
||||||
|
await this.internalEventService.emit('collapsedQueueEnqueue', { name: this.name, key, value });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already queued, then merge
|
||||||
|
const job = this.jobs.get(key);
|
||||||
|
if (job) {
|
||||||
|
job.value = this.collapse(job.value, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, create a new job
|
||||||
|
const timer = this.timeService.startTimer(async () => {
|
||||||
|
const job = this.jobs.get(key);
|
||||||
|
if (!job) return;
|
||||||
|
|
||||||
|
this.jobs.delete(key);
|
||||||
|
await this._perform(key, job.value);
|
||||||
|
}, this.timeout);
|
||||||
|
this.jobs.set(key, { value, timer });
|
||||||
|
|
||||||
|
// Mark as deferred so other processes will forward their state to us
|
||||||
|
await this.internalEventService.emit('collapsedQueueDefer', { name: this.name, key, deferred: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
async delete(key: string) {
|
||||||
|
const job = this.jobs.get(key);
|
||||||
|
if (!job) return;
|
||||||
|
|
||||||
|
this.timeService.stopTimer(job.timer);
|
||||||
|
this.jobs.delete(key);
|
||||||
|
await this.internalEventService.emit('collapsedQueueDefer', { name: this.name, key, deferred: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
async performAllNow() {
|
async performAllNow() {
|
||||||
const entries = [...this.jobs.entries()];
|
for (const job of this.jobs.values()) {
|
||||||
this.jobs.clear();
|
|
||||||
for (const [_key, job] of entries) {
|
|
||||||
this.timeService.stopTimer(job.timer);
|
this.timeService.stopTimer(job.timer);
|
||||||
}
|
}
|
||||||
await Promise.allSettled(entries.map(([key, job]) => this.perform(key, job.value)));
|
|
||||||
|
const entries = Array.from(this.jobs.entries());
|
||||||
|
this.jobs.clear();
|
||||||
|
|
||||||
|
return await Promise.all(entries.map(([key, job]) => this._perform(key, job.value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _perform(key: string, value: V) {
|
||||||
|
try {
|
||||||
|
await this.internalEventService.emit('collapsedQueueDefer', { name: this.name, key, deferred: false });
|
||||||
|
|
||||||
|
if (this.limiter) {
|
||||||
|
await this.limiter(async () => {
|
||||||
|
await this.perform(key, value);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.perform(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
await this.opts?.onError?.(this, err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//#region Events from other processes
|
||||||
|
@bindThis
|
||||||
|
private async onDefer(data: { name: string, key: string, deferred: boolean }) {
|
||||||
|
if (data.name !== this.name) return;
|
||||||
|
|
||||||
|
// Check for and recover from de-sync conditions where multiple processes try to "own" the same job.
|
||||||
|
const job = this.jobs.get(data.key);
|
||||||
|
if (job) {
|
||||||
|
if (data.deferred) {
|
||||||
|
// If another process tries to claim our job, then give it to them and queue our latest state.
|
||||||
|
this.timeService.stopTimer(job.timer);
|
||||||
|
this.jobs.delete(data.key);
|
||||||
|
await this.internalEventService.emit('collapsedQueueEnqueue', { name: this.name, key: data.key, value: job.value });
|
||||||
|
} else {
|
||||||
|
// If another process tries to release our job, then just continue.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.deferred) {
|
||||||
|
this.deferredKeys.add(data.key);
|
||||||
|
} else {
|
||||||
|
this.deferredKeys.delete(data.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async onEnqueue(data: { name: string, key: string, value: unknown }) {
|
||||||
|
if (data.name !== this.name) return;
|
||||||
|
|
||||||
|
// Only enqueue if not deferred
|
||||||
|
if (!this.deferredKeys.has(data.key)) {
|
||||||
|
const value = this.opts?.redisParser
|
||||||
|
? this.opts.redisParser(data.value as Serialized<V>)
|
||||||
|
: data.value as V;
|
||||||
|
|
||||||
|
await this.enqueue(data.key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
async dispose() {
|
||||||
|
this.internalEventService.off('collapsedQueueDefer', this.onDefer);
|
||||||
|
this.internalEventService.off('collapsedQueueEnqueue', this.onEnqueue);
|
||||||
|
|
||||||
|
return await this.performAllNow();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,3 +25,15 @@ export class IdentifiableError extends Error {
|
||||||
this.isRetryable = isRetryable;
|
this.isRetryable = isRetryable;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard error codes to reference throughout the app
|
||||||
|
*/
|
||||||
|
export const errorCodes = {
|
||||||
|
// User has been deleted (hard or soft deleted)
|
||||||
|
userIsDeleted: '4cac9436-baa3-4955-a368-7628aea676cf',
|
||||||
|
// User is suspended (directly or by instance)
|
||||||
|
userIsSuspended: '1e56d624-737f-48e4-beb6-0bdddb9fa809',
|
||||||
|
// User has no valid featured collection (not defined, invalid, etc)
|
||||||
|
noFeaturedCollection: '2aa4766e-b7d8-4291-a671-56800498b085',
|
||||||
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -5,42 +5,51 @@
|
||||||
|
|
||||||
import type { MiNote } from '@/models/Note.js';
|
import type { MiNote } from '@/models/Note.js';
|
||||||
import type { Packed } from '@/misc/json-schema.js';
|
import type { Packed } from '@/misc/json-schema.js';
|
||||||
|
import type { NoteEdit } from '@/models/NoteEdit.js';
|
||||||
|
|
||||||
// NoteEntityService.isPureRenote とよしなにリンク
|
// NoteEntityService.isPureRenote とよしなにリンク
|
||||||
|
|
||||||
type Renote =
|
export type Renote =
|
||||||
MiNote & {
|
MiNote & {
|
||||||
renoteId: NonNullable<MiNote['renoteId']>
|
renoteId: NonNullable<MiNote['renoteId']>
|
||||||
};
|
};
|
||||||
|
|
||||||
type Quote =
|
export type Quote =
|
||||||
Renote & ({
|
Renote & ({
|
||||||
text: NonNullable<MiNote['text']>
|
text: NonNullable<MiNote['text']>
|
||||||
} | {
|
} | {
|
||||||
cw: NonNullable<MiNote['cw']>
|
cw: NonNullable<MiNote['cw']>
|
||||||
} | {
|
} | {
|
||||||
replyId: NonNullable<MiNote['replyId']>
|
replyId: NonNullable<MiNote['replyId']>
|
||||||
reply: NonNullable<MiNote['reply']>
|
reply: NonNullable<MiNote['reply']> // TODO this is wrong
|
||||||
} | {
|
} | {
|
||||||
hasPoll: true
|
hasPoll: true
|
||||||
|
} | {
|
||||||
|
fileIds: [string, ...string[]]
|
||||||
});
|
});
|
||||||
|
|
||||||
type PureRenote =
|
export type PureRenote =
|
||||||
Renote & {
|
Renote & {
|
||||||
text: null,
|
text: null,
|
||||||
cw: null,
|
cw: null,
|
||||||
replyId: null,
|
replyId: null,
|
||||||
hasPoll: false,
|
hasPoll: false,
|
||||||
fileIds: {
|
fileIds: [],
|
||||||
length: 0,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isRenote(note: MiNote): note is Renote {
|
export function isRenote(note: MiNote): note is Renote;
|
||||||
|
export function isRenote(note: NoteEdit): note is RenoteEdit;
|
||||||
|
export function isRenote(note: MinimalNote): note is MinimalRenote;
|
||||||
|
export function isRenote(note: MiNote | NoteEdit | MinimalNote): note is Renote | RenoteEdit | MinimalRenote;
|
||||||
|
export function isRenote(note: MiNote | NoteEdit | MinimalNote): note is Renote | RenoteEdit | MinimalRenote {
|
||||||
return note.renoteId != null;
|
return note.renoteId != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isQuote(note: Renote): note is Quote {
|
export function isQuote(note: Renote): note is Quote;
|
||||||
|
export function isQuote(note: RenoteEdit): note is QuoteEdit;
|
||||||
|
export function isQuote(note: MinimalNote): note is MinimalQuote;
|
||||||
|
export function isQuote(note: Renote | RenoteEdit | MinimalNote): note is Quote | QuoteEdit | MinimalQuote;
|
||||||
|
export function isQuote(note: Renote | RenoteEdit | MinimalNote): note is Quote | QuoteEdit | MinimalQuote {
|
||||||
// NOTE: SYNC WITH NoteCreateService.isQuote
|
// NOTE: SYNC WITH NoteCreateService.isQuote
|
||||||
return note.text != null ||
|
return note.text != null ||
|
||||||
note.cw != null ||
|
note.cw != null ||
|
||||||
|
|
@ -49,7 +58,11 @@ export function isQuote(note: Renote): note is Quote {
|
||||||
note.fileIds.length > 0;
|
note.fileIds.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPureRenote(note: MiNote): note is PureRenote {
|
export function isPureRenote(note: MiNote): note is PureRenote;
|
||||||
|
export function isPureRenote(note: NoteEdit): note is PureRenoteEdit;
|
||||||
|
export function isPureRenote(note: MinimalNote): note is MinimalPureRenote;
|
||||||
|
export function isPureRenote(note: MiNote | NoteEdit | MinimalNote): note is PureRenote | PureRenoteEdit | MinimalPureRenote;
|
||||||
|
export function isPureRenote(note: MiNote | NoteEdit | MinimalNote): note is PureRenote | PureRenoteEdit | MinimalPureRenote {
|
||||||
return isRenote(note) && !isQuote(note);
|
return isRenote(note) && !isQuote(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,15 +81,16 @@ type PackedQuote =
|
||||||
} | {
|
} | {
|
||||||
poll: NonNullable<Packed<'Note'>['poll']>
|
poll: NonNullable<Packed<'Note'>['poll']>
|
||||||
} | {
|
} | {
|
||||||
fileIds: NonNullable<Packed<'Note'>['fileIds']>
|
fileIds: [string, ...string[]]
|
||||||
});
|
});
|
||||||
|
|
||||||
type PackedPureRenote = PackedRenote & {
|
type PackedPureRenote = PackedRenote & {
|
||||||
text: NonNullable<Packed<'Note'>['text']>;
|
text: null;
|
||||||
cw: NonNullable<Packed<'Note'>['cw']>;
|
cw: null;
|
||||||
replyId: NonNullable<Packed<'Note'>['replyId']>;
|
replyId: null;
|
||||||
poll: NonNullable<Packed<'Note'>['poll']>;
|
reply: null;
|
||||||
fileIds: NonNullable<Packed<'Note'>['fileIds']>;
|
poll: null;
|
||||||
|
fileIds: [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isRenotePacked(note: Packed<'Note'>): note is PackedRenote {
|
export function isRenotePacked(note: Packed<'Note'>): note is PackedRenote {
|
||||||
|
|
@ -94,3 +108,58 @@ export function isQuotePacked(note: PackedRenote): note is PackedQuote {
|
||||||
export function isPackedPureRenote(note: Packed<'Note'>): note is PackedPureRenote {
|
export function isPackedPureRenote(note: Packed<'Note'>): note is PackedPureRenote {
|
||||||
return isRenotePacked(note) && !isQuotePacked(note);
|
return isRenotePacked(note) && !isQuotePacked(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RenoteEdit =
|
||||||
|
NoteEdit & {
|
||||||
|
renoteId: NonNullable<NoteEdit['renoteId']>
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuoteEdit =
|
||||||
|
RenoteEdit & ({
|
||||||
|
text: NonNullable<NoteEdit['text']>
|
||||||
|
} | {
|
||||||
|
cw: NonNullable<NoteEdit['cw']>
|
||||||
|
} | {
|
||||||
|
replyId: NonNullable<NoteEdit['replyId']>
|
||||||
|
} | {
|
||||||
|
hasPoll: true
|
||||||
|
} | {
|
||||||
|
fileIds: [string, ...string[]],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PureRenoteEdit =
|
||||||
|
RenoteEdit & {
|
||||||
|
text: null,
|
||||||
|
cw: null,
|
||||||
|
replyId: null,
|
||||||
|
reply: null,
|
||||||
|
hasPoll: false,
|
||||||
|
fileIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MinimalNote = Pick<MiNote, 'id' | 'visibility' | 'userId' | 'replyId' | 'renoteId' | 'text' | 'cw' | 'hasPoll' | 'fileIds'>;
|
||||||
|
|
||||||
|
export type MinimalRenote = MinimalNote & {
|
||||||
|
renoteId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MinimalQuote = MinimalRenote & ({
|
||||||
|
text: NonNullable<MinimalNote['text']>
|
||||||
|
} | {
|
||||||
|
cw: NonNullable<MinimalNote['cw']>
|
||||||
|
} | {
|
||||||
|
replyId: NonNullable<MinimalNote['replyId']>
|
||||||
|
} | {
|
||||||
|
hasPoll: true
|
||||||
|
} | {
|
||||||
|
fileIds: [string, ...string[]],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MinimalPureRenote = MinimalRenote & {
|
||||||
|
text: null,
|
||||||
|
cw: null,
|
||||||
|
replyId: null,
|
||||||
|
reply: null,
|
||||||
|
hasPoll: false,
|
||||||
|
fileIds: [],
|
||||||
|
};
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue