Merge branch 'develop' into feature/2024.10
This commit is contained in:
commit
6d4ae93592
54 changed files with 1630 additions and 20 deletions
17
packages/backend/migration/1699437894737-scheduleNote.js
Normal file
17
packages/backend/migration/1699437894737-scheduleNote.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class ScheduleNote1699437894737 {
|
||||
name = 'ScheduleNote1699437894737'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE TABLE "note_schedule" ("id" character varying(32) NOT NULL, "note" jsonb NOT NULL, "userId" character varying(260) NOT NULL, "scheduledAt" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_3a1ae2db41988f4994268218436" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_e798958c40009bf0cdef4f28b5" ON "note_schedule" ("userId") `);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP TABLE "note_schedule"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
RelationshipJobData,
|
||||
UserWebhookDeliverJobData,
|
||||
SystemWebhookDeliverJobData,
|
||||
ScheduleNotePostJobData,
|
||||
} from '../queue/types.js';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
|
||||
|
|
@ -28,6 +29,7 @@ export type RelationshipQueue = Bull.Queue<RelationshipJobData>;
|
|||
export type ObjectStorageQueue = Bull.Queue;
|
||||
export type UserWebhookDeliverQueue = Bull.Queue<UserWebhookDeliverJobData>;
|
||||
export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>;
|
||||
export type ScheduleNotePostQueue = Bull.Queue<ScheduleNotePostJobData>;
|
||||
|
||||
const $system: Provider = {
|
||||
provide: 'queue:system',
|
||||
|
|
@ -83,6 +85,12 @@ const $systemWebhookDeliver: Provider = {
|
|||
inject: [DI.config],
|
||||
};
|
||||
|
||||
const $scheduleNotePost: Provider = {
|
||||
provide: 'queue:scheduleNotePost',
|
||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.SCHEDULE_NOTE_POST, baseQueueOptions(config, QUEUE.SCHEDULE_NOTE_POST)),
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
],
|
||||
|
|
@ -96,6 +104,7 @@ const $systemWebhookDeliver: Provider = {
|
|||
$objectStorage,
|
||||
$userWebhookDeliver,
|
||||
$systemWebhookDeliver,
|
||||
$scheduleNotePost,
|
||||
],
|
||||
exports: [
|
||||
$system,
|
||||
|
|
@ -107,6 +116,7 @@ const $systemWebhookDeliver: Provider = {
|
|||
$objectStorage,
|
||||
$userWebhookDeliver,
|
||||
$systemWebhookDeliver,
|
||||
$scheduleNotePost,
|
||||
],
|
||||
})
|
||||
export class QueueModule implements OnApplicationShutdown {
|
||||
|
|
@ -120,6 +130,7 @@ export class QueueModule implements OnApplicationShutdown {
|
|||
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
|
||||
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
||||
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
||||
@Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue,
|
||||
) {}
|
||||
|
||||
public async dispose(): Promise<void> {
|
||||
|
|
@ -136,6 +147,7 @@ export class QueueModule implements OnApplicationShutdown {
|
|||
this.objectStorageQueue.close(),
|
||||
this.userWebhookDeliverQueue.close(),
|
||||
this.systemWebhookDeliverQueue.close(),
|
||||
this.scheduleNotePostQueue.close(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import type {
|
|||
SystemQueue,
|
||||
SystemWebhookDeliverQueue,
|
||||
UserWebhookDeliverQueue,
|
||||
ScheduleNotePostQueue,
|
||||
} from './QueueModule.js';
|
||||
import type httpSignature from '@peertube/http-signature';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
|
@ -54,6 +55,7 @@ export class QueueService {
|
|||
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
|
||||
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
||||
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
||||
@Inject('queue:scheduleNotePost') public ScheduleNotePostQueue: ScheduleNotePostQueue,
|
||||
) {
|
||||
this.systemQueue.add('tickCharts', {
|
||||
}, {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export type RolePolicies = {
|
|||
ltlAvailable: boolean;
|
||||
btlAvailable: boolean;
|
||||
canPublicNote: boolean;
|
||||
scheduleNoteMax: number;
|
||||
mentionLimit: number;
|
||||
canInvite: boolean;
|
||||
inviteLimit: number;
|
||||
|
|
@ -72,6 +73,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||
ltlAvailable: true,
|
||||
btlAvailable: false,
|
||||
canPublicNote: true,
|
||||
scheduleNoteMax: 5,
|
||||
mentionLimit: 20,
|
||||
canInvite: false,
|
||||
inviteLimit: 0,
|
||||
|
|
@ -379,6 +381,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
btlAvailable: calc('btlAvailable', vs => vs.some(v => v === true)),
|
||||
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
|
||||
canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
|
||||
scheduleNoteMax: calc('scheduleNoteMax', vs => Math.max(...vs)),
|
||||
mentionLimit: calc('mentionLimit', vs => Math.max(...vs)),
|
||||
canInvite: calc('canInvite', vs => vs.some(v => v === true)),
|
||||
inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import type { OnModuleInit } from '@nestjs/common';
|
|||
import type { UserEntityService } from './UserEntityService.js';
|
||||
import type { NoteEntityService } from './NoteEntityService.js';
|
||||
|
||||
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited'] as (typeof groupedNotificationTypes[number])[]);
|
||||
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited', 'scheduledNotePosted'] as (typeof groupedNotificationTypes[number])[]);
|
||||
|
||||
@Injectable()
|
||||
export class NotificationEntityService implements OnModuleInit {
|
||||
|
|
@ -169,6 +169,9 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
exportedEntity: notification.exportedEntity,
|
||||
fileId: notification.fileId,
|
||||
} : {}),
|
||||
...(notification.type === 'scheduledNoteFailed' ? {
|
||||
reason: notification.reason,
|
||||
} : {}),
|
||||
...(notification.type === 'app' ? {
|
||||
body: notification.customBody,
|
||||
header: notification.customHeader,
|
||||
|
|
|
|||
|
|
@ -86,5 +86,6 @@ export const DI = {
|
|||
noteEditRepository: Symbol('noteEditRepository'),
|
||||
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
|
||||
reversiGamesRepository: Symbol('reversiGamesRepository'),
|
||||
noteScheduleRepository: Symbol('noteScheduleRepository'),
|
||||
//#endregion
|
||||
};
|
||||
|
|
|
|||
58
packages/backend/src/models/NoteSchedule.ts
Normal file
58
packages/backend/src/models/NoteSchedule.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Entity, Index, Column, PrimaryColumn } from 'typeorm';
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
import { id } from './util/id.js';
|
||||
import { MiUser } from './User.js';
|
||||
import { MiChannel } from './Channel.js';
|
||||
import type { MiDriveFile } from './DriveFile.js';
|
||||
|
||||
type MinimumUser = {
|
||||
id: MiUser['id'];
|
||||
host: MiUser['host'];
|
||||
username: MiUser['username'];
|
||||
uri: MiUser['uri'];
|
||||
};
|
||||
|
||||
export type MiScheduleNoteType={
|
||||
visibility: 'public' | 'home' | 'followers' | 'specified';
|
||||
visibleUsers: MinimumUser[];
|
||||
channel?: MiChannel['id'];
|
||||
poll: {
|
||||
multiple: boolean;
|
||||
choices: string[];
|
||||
/** Date.toISOString() */
|
||||
expiresAt: string | null
|
||||
} | undefined;
|
||||
renote?: MiNote['id'];
|
||||
localOnly: boolean;
|
||||
cw?: string | null;
|
||||
reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null;
|
||||
files: MiDriveFile['id'][];
|
||||
text?: string | null;
|
||||
reply?: MiNote['id'];
|
||||
apMentions?: MinimumUser[] | null;
|
||||
apHashtags?: string[] | null;
|
||||
apEmojis?: string[] | null;
|
||||
}
|
||||
|
||||
@Entity('note_schedule')
|
||||
export class MiNoteSchedule {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Column('jsonb')
|
||||
public note: MiScheduleNoteType;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 260,
|
||||
})
|
||||
public userId: MiUser['id'];
|
||||
|
||||
@Column('timestamp with time zone')
|
||||
public scheduledAt: Date;
|
||||
}
|
||||
|
|
@ -126,6 +126,16 @@ export type MiNotification = {
|
|||
createdAt: string;
|
||||
notifierId: MiUser['id'];
|
||||
noteId: MiNote['id'];
|
||||
} | {
|
||||
type: 'scheduledNoteFailed';
|
||||
id: string;
|
||||
createdAt: string;
|
||||
reason: string;
|
||||
} | {
|
||||
type: 'scheduledNotePosted';
|
||||
id: string;
|
||||
createdAt: string;
|
||||
noteId: MiNote['id'];
|
||||
};
|
||||
|
||||
export type MiGroupedNotification = MiNotification | {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import {
|
|||
MiNote,
|
||||
MiNoteFavorite,
|
||||
MiNoteReaction,
|
||||
MiNoteSchedule,
|
||||
MiNoteThreadMuting,
|
||||
MiNoteUnread,
|
||||
MiPage,
|
||||
|
|
@ -509,6 +510,12 @@ const $reversiGamesRepository: Provider = {
|
|||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $noteScheduleRepository: Provider = {
|
||||
provide: DI.noteScheduleRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MiNoteSchedule).extend(miRepository as MiRepository<MiNoteSchedule>),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: [
|
||||
|
|
@ -583,6 +590,7 @@ const $reversiGamesRepository: Provider = {
|
|||
$noteEditRepository,
|
||||
$bubbleGameRecordsRepository,
|
||||
$reversiGamesRepository,
|
||||
$noteScheduleRepository,
|
||||
],
|
||||
exports: [
|
||||
$usersRepository,
|
||||
|
|
@ -656,6 +664,7 @@ const $reversiGamesRepository: Provider = {
|
|||
$noteEditRepository,
|
||||
$bubbleGameRecordsRepository,
|
||||
$reversiGamesRepository,
|
||||
$noteScheduleRepository,
|
||||
],
|
||||
})
|
||||
export class RepositoryModule {
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ import { MiUserListFavorite } from '@/models/UserListFavorite.js';
|
|||
import { NoteEdit } from '@/models/NoteEdit.js';
|
||||
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
|
||||
import { MiReversiGame } from '@/models/ReversiGame.js';
|
||||
import { MiNoteSchedule } from '@/models/NoteSchedule.js';
|
||||
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
|
||||
|
||||
export interface MiRepository<T extends ObjectLiteral> {
|
||||
|
|
@ -160,6 +161,7 @@ export {
|
|||
MiNote,
|
||||
MiNoteFavorite,
|
||||
MiNoteReaction,
|
||||
MiNoteSchedule,
|
||||
MiNoteThreadMuting,
|
||||
MiNoteUnread,
|
||||
MiPage,
|
||||
|
|
@ -271,3 +273,4 @@ export type UserMemoRepository = Repository<MiUserMemo> & MiRepository<MiUserMem
|
|||
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
|
||||
export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;
|
||||
export type NoteEditRepository = Repository<NoteEdit> & MiRepository<NoteEdit>;
|
||||
export type NoteScheduleRepository = Repository<MiNoteSchedule>;
|
||||
|
|
|
|||
|
|
@ -379,6 +379,45 @@ export const packedNotificationSchema = {
|
|||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...baseSchema.properties,
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: ['scheduledNoteFailed'],
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...baseSchema.properties,
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: ['scheduledNotePosted'],
|
||||
},
|
||||
user: {
|
||||
type: 'object',
|
||||
ref: 'UserLite',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
note: {
|
||||
type: 'object',
|
||||
ref: 'Note',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
|
|||
|
|
@ -296,6 +296,10 @@ export const packedRolePoliciesSchema = {
|
|||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
scheduleNoteMax: {
|
||||
type: 'integer',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ import { MiUserMemo } from '@/models/UserMemo.js';
|
|||
import { NoteEdit } from '@/models/NoteEdit.js';
|
||||
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
|
||||
import { MiReversiGame } from '@/models/ReversiGame.js';
|
||||
import { MiNoteSchedule } from '@/models/NoteSchedule.js';
|
||||
|
||||
import { Config } from '@/config.js';
|
||||
import MisskeyLogger from '@/logger.js';
|
||||
|
|
@ -158,6 +159,7 @@ export const entities = [
|
|||
MiNote,
|
||||
MiNoteFavorite,
|
||||
MiNoteReaction,
|
||||
MiNoteSchedule,
|
||||
MiNoteThreadMuting,
|
||||
MiNoteUnread,
|
||||
MiPage,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { TickChartsProcessorService } from './processors/TickChartsProcessorServ
|
|||
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
|
||||
import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js';
|
||||
import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js';
|
||||
import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostProcessorService.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -88,6 +89,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
|
|||
CheckExpiredMutingsProcessorService,
|
||||
CheckModeratorsActivityProcessorService,
|
||||
QueueProcessorService,
|
||||
ScheduleNotePostProcessorService,
|
||||
],
|
||||
exports: [
|
||||
QueueProcessorService,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMu
|
|||
import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js';
|
||||
import { CleanProcessorService } from './processors/CleanProcessorService.js';
|
||||
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
|
||||
import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostProcessorService.js';
|
||||
import { QueueLoggerService } from './QueueLoggerService.js';
|
||||
import { QUEUE, baseQueueOptions } from './const.js';
|
||||
import { ImportNotesProcessorService } from './processors/ImportNotesProcessorService.js';
|
||||
|
|
@ -87,6 +88,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
|||
private relationshipQueueWorker: Bull.Worker;
|
||||
private objectStorageQueueWorker: Bull.Worker;
|
||||
private endedPollNotificationQueueWorker: Bull.Worker;
|
||||
private schedulerNotePostQueueWorker: Bull.Worker;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
|
|
@ -128,6 +130,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
|||
private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService,
|
||||
private checkModeratorsActivityProcessorService: CheckModeratorsActivityProcessorService,
|
||||
private cleanProcessorService: CleanProcessorService,
|
||||
private scheduleNotePostProcessorService: ScheduleNotePostProcessorService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger;
|
||||
|
||||
|
|
@ -533,6 +536,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
|||
});
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region schedule note post
|
||||
{
|
||||
this.schedulerNotePostQueueWorker = new Bull.Worker(QUEUE.SCHEDULE_NOTE_POST, (job) => this.scheduleNotePostProcessorService.process(job), {
|
||||
...baseQueueOptions(this.config, QUEUE.SCHEDULE_NOTE_POST),
|
||||
autorun: false,
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -547,6 +559,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
|||
this.relationshipQueueWorker.run(),
|
||||
this.objectStorageQueueWorker.run(),
|
||||
this.endedPollNotificationQueueWorker.run(),
|
||||
this.schedulerNotePostQueueWorker.run(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -562,6 +575,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
|||
this.relationshipQueueWorker.close(),
|
||||
this.objectStorageQueueWorker.close(),
|
||||
this.endedPollNotificationQueueWorker.close(),
|
||||
this.schedulerNotePostQueueWorker.close(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export const QUEUE = {
|
|||
OBJECT_STORAGE: 'objectStorage',
|
||||
USER_WEBHOOK_DELIVER: 'userWebhookDeliver',
|
||||
SYSTEM_WEBHOOK_DELIVER: 'systemWebhookDeliver',
|
||||
SCHEDULE_NOTE_POST: 'scheduleNotePost',
|
||||
};
|
||||
|
||||
export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.QueueOptions {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { NoteCreateService } from '@/core/NoteCreateService.js';
|
||||
import type { ChannelsRepository, DriveFilesRepository, MiDriveFile, NoteScheduleRepository, NotesRepository, UsersRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import type { MiScheduleNoteType } from '@/models/NoteSchedule.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { ScheduleNotePostJobData } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
export class ScheduleNotePostProcessorService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.noteScheduleRepository)
|
||||
private noteScheduleRepository: NoteScheduleRepository,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
|
||||
private noteCreateService: NoteCreateService,
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
private notificationService: NotificationService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger.createSubLogger('schedule-note-post');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async isValidNoteSchedule(note: MiScheduleNoteType, id: string): Promise<boolean> {
|
||||
const reply = note.reply ? await this.notesRepository.findOneBy({ id: note.reply }) : undefined;
|
||||
const renote = note.reply ? await this.notesRepository.findOneBy({ id: note.renote }) : undefined;
|
||||
const channel = note.channel ? await this.channelsRepository.findOneBy({ id: note.channel, isArchived: false }) : undefined;
|
||||
if (note.reply && !reply) {
|
||||
this.logger.warn('Schedule Note Failed Reason: parent note to reply does not exist');
|
||||
this.notificationService.createNotification(id, 'scheduledNoteFailed', {
|
||||
reason: 'Replied to note on your scheduled note no longer exists',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (note.renote && !renote) {
|
||||
this.logger.warn('Schedule Note Failed Reason: attached quote note no longer exists');
|
||||
this.notificationService.createNotification(id, 'scheduledNoteFailed', {
|
||||
reason: 'A quoted note from one of your scheduled notes no longer exists',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (note.channel && !channel) {
|
||||
this.logger.warn('Schedule Note Failed Reason: Channel does not exist');
|
||||
this.notificationService.createNotification(id, 'scheduledNoteFailed', {
|
||||
reason: 'An attached channel on your scheduled note no longer exists',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<ScheduleNotePostJobData>): Promise<void> {
|
||||
this.noteScheduleRepository.findOneBy({ id: job.data.scheduleNoteId }).then(async (data) => {
|
||||
if (!data) {
|
||||
this.logger.warn(`Schedule note ${job.data.scheduleNoteId} not found`);
|
||||
} else {
|
||||
const me = await this.usersRepository.findOneBy({ id: data.userId });
|
||||
const note = data.note;
|
||||
const reply = note.reply ? await this.notesRepository.findOneBy({ id: note.reply }) : undefined;
|
||||
const renote = note.reply ? await this.notesRepository.findOneBy({ id: note.renote }) : undefined;
|
||||
const channel = note.channel ? await this.channelsRepository.findOneBy({ id: note.channel, isArchived: false }) : undefined;
|
||||
|
||||
let files: MiDriveFile[] = [];
|
||||
const fileIds = note.files;
|
||||
|
||||
if (fileIds.length > 0 && me) {
|
||||
files = await this.driveFilesRepository.createQueryBuilder('file')
|
||||
.where('file.userId = :userId AND file.id IN (:...fileIds)', {
|
||||
userId: me.id,
|
||||
fileIds,
|
||||
})
|
||||
.orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
|
||||
.setParameters({ fileIds })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
if (!data.userId || !me) {
|
||||
this.logger.warn('Schedule Note Failed Reason: User Not Found');
|
||||
await this.noteScheduleRepository.remove(data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await this.isValidNoteSchedule(note, me.id)) {
|
||||
await this.noteScheduleRepository.remove(data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (note.files.length !== files.length) {
|
||||
this.logger.warn('Schedule Note Failed Reason: files are missing in the user\'s drive');
|
||||
this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
|
||||
reason: 'Some attached files on your scheduled note no longer exist',
|
||||
});
|
||||
await this.noteScheduleRepository.remove(data);
|
||||
return;
|
||||
}
|
||||
|
||||
const createdNote = await this.noteCreateService.create(me, {
|
||||
...note,
|
||||
createdAt: new Date(),
|
||||
files,
|
||||
poll: note.poll ? {
|
||||
choices: note.poll.choices,
|
||||
multiple: note.poll.multiple,
|
||||
expiresAt: note.poll.expiresAt ? new Date(note.poll.expiresAt) : null,
|
||||
} : undefined,
|
||||
reply,
|
||||
renote,
|
||||
channel,
|
||||
}).catch(async (err: IdentifiableError) => {
|
||||
this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
|
||||
reason: err.message,
|
||||
});
|
||||
await this.noteScheduleRepository.remove(data);
|
||||
throw this.logger.error(`Schedule Note Failed Reason: ${err.message}`);
|
||||
});
|
||||
await this.noteScheduleRepository.remove(data);
|
||||
this.notificationService.createNotification(me.id, 'scheduledNotePosted', {
|
||||
noteId: createdNote.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -155,3 +155,7 @@ export type UserWebhookDeliverJobData = {
|
|||
export type ThinUser = {
|
||||
id: MiUser['id'];
|
||||
};
|
||||
|
||||
export type ScheduleNotePostJobData = {
|
||||
scheduleNoteId: MiNote['id'];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -313,6 +313,9 @@ import * as ep___notes_renotes from './endpoints/notes/renotes.js';
|
|||
import * as ep___notes_replies from './endpoints/notes/replies.js';
|
||||
import * as ep___notes_edit from './endpoints/notes/edit.js';
|
||||
import * as ep___notes_versions from './endpoints/notes/versions.js';
|
||||
import * as ep___notes_schedule_create from './endpoints/notes/schedule/create.js';
|
||||
import * as ep___notes_schedule_delete from './endpoints/notes/schedule/delete.js';
|
||||
import * as ep___notes_schedule_list from './endpoints/notes/schedule/list.js';
|
||||
import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js';
|
||||
import * as ep___notes_search from './endpoints/notes/search.js';
|
||||
import * as ep___notes_show from './endpoints/notes/show.js';
|
||||
|
|
@ -715,6 +718,9 @@ const $notes_reactions_delete: Provider = { provide: 'ep:notes/reactions/delete'
|
|||
const $notes_like: Provider = { provide: 'ep:notes/like', useClass: ep___notes_like.default };
|
||||
const $notes_renotes: Provider = { provide: 'ep:notes/renotes', useClass: ep___notes_renotes.default };
|
||||
const $notes_replies: Provider = { provide: 'ep:notes/replies', useClass: ep___notes_replies.default };
|
||||
const $notes_schedule_create: Provider = { provide: 'ep:notes/schedule/create', useClass: ep___notes_schedule_create.default };
|
||||
const $notes_schedule_delete: Provider = { provide: 'ep:notes/schedule/delete', useClass: ep___notes_schedule_delete.default };
|
||||
const $notes_schedule_list: Provider = { provide: 'ep:notes/schedule/list', useClass: ep___notes_schedule_list.default };
|
||||
const $notes_searchByTag: Provider = { provide: 'ep:notes/search-by-tag', useClass: ep___notes_searchByTag.default };
|
||||
const $notes_search: Provider = { provide: 'ep:notes/search', useClass: ep___notes_search.default };
|
||||
const $notes_show: Provider = { provide: 'ep:notes/show', useClass: ep___notes_show.default };
|
||||
|
|
@ -1123,6 +1129,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
|||
$notes_like,
|
||||
$notes_renotes,
|
||||
$notes_replies,
|
||||
$notes_schedule_create,
|
||||
$notes_schedule_delete,
|
||||
$notes_schedule_list,
|
||||
$notes_searchByTag,
|
||||
$notes_search,
|
||||
$notes_show,
|
||||
|
|
@ -1524,6 +1533,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
|||
$notes_like,
|
||||
$notes_renotes,
|
||||
$notes_replies,
|
||||
$notes_schedule_create,
|
||||
$notes_schedule_delete,
|
||||
$notes_schedule_list,
|
||||
$notes_searchByTag,
|
||||
$notes_search,
|
||||
$notes_show,
|
||||
|
|
|
|||
|
|
@ -318,6 +318,9 @@ import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete
|
|||
import * as ep___notes_like from './endpoints/notes/like.js';
|
||||
import * as ep___notes_renotes from './endpoints/notes/renotes.js';
|
||||
import * as ep___notes_replies from './endpoints/notes/replies.js';
|
||||
import * as ep___notes_schedule_create from './endpoints/notes/schedule/create.js';
|
||||
import * as ep___notes_schedule_delete from './endpoints/notes/schedule/delete.js';
|
||||
import * as ep___notes_schedule_list from './endpoints/notes/schedule/list.js';
|
||||
import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js';
|
||||
import * as ep___notes_search from './endpoints/notes/search.js';
|
||||
import * as ep___notes_show from './endpoints/notes/show.js';
|
||||
|
|
@ -720,6 +723,9 @@ const eps = [
|
|||
['notes/like', ep___notes_like],
|
||||
['notes/renotes', ep___notes_renotes],
|
||||
['notes/replies', ep___notes_replies],
|
||||
['notes/schedule/create', ep___notes_schedule_create],
|
||||
['notes/schedule/delete', ep___notes_schedule_delete],
|
||||
['notes/schedule/list', ep___notes_schedule_list],
|
||||
['notes/search-by-tag', ep___notes_searchByTag],
|
||||
['notes/search', ep___notes_search],
|
||||
['notes/show', ep___notes_show],
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue } from '@/core/QueueModule.js';
|
||||
import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue, ScheduleNotePostQueue } from '@/core/QueueModule.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
|
@ -55,6 +55,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
|
||||
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
||||
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
||||
@Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const deliverJobCounts = await this.deliverQueue.getJobCounts();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,370 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import ms from 'ms';
|
||||
import { In } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { isPureRenote } from '@/misc/is-renote.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type {
|
||||
UsersRepository,
|
||||
NotesRepository,
|
||||
BlockingsRepository,
|
||||
DriveFilesRepository,
|
||||
ChannelsRepository,
|
||||
NoteScheduleRepository,
|
||||
} from '@/models/_.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import type { MiChannel } from '@/models/Channel.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { MiScheduleNoteType } from '@/models/NoteSchedule.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
limit: {
|
||||
duration: ms('1hour'),
|
||||
max: 300,
|
||||
},
|
||||
|
||||
kind: 'write:notes-schedule',
|
||||
|
||||
errors: {
|
||||
scheduleNoteMax: {
|
||||
message: 'Schedule note max.',
|
||||
code: 'SCHEDULE_NOTE_MAX',
|
||||
id: '168707c3-e7da-4031-989e-f42aa3a274b2',
|
||||
},
|
||||
noSuchRenoteTarget: {
|
||||
message: 'No such renote target.',
|
||||
code: 'NO_SUCH_RENOTE_TARGET',
|
||||
id: 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4',
|
||||
},
|
||||
|
||||
cannotReRenote: {
|
||||
message: 'You can not Renote a pure Renote.',
|
||||
code: 'CANNOT_RENOTE_TO_A_PURE_RENOTE',
|
||||
id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a',
|
||||
},
|
||||
|
||||
cannotRenoteDueToVisibility: {
|
||||
message: 'You can not Renote due to target visibility.',
|
||||
code: 'CANNOT_RENOTE_DUE_TO_VISIBILITY',
|
||||
id: 'be9529e9-fe72-4de0-ae43-0b363c4938af',
|
||||
},
|
||||
|
||||
noSuchReplyTarget: {
|
||||
message: 'No such reply target.',
|
||||
code: 'NO_SUCH_REPLY_TARGET',
|
||||
id: '749ee0f6-d3da-459a-bf02-282e2da4292c',
|
||||
},
|
||||
|
||||
cannotReplyToPureRenote: {
|
||||
message: 'You can not reply to a pure Renote.',
|
||||
code: 'CANNOT_REPLY_TO_A_PURE_RENOTE',
|
||||
id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15',
|
||||
},
|
||||
|
||||
cannotCreateAlreadyExpiredPoll: {
|
||||
message: 'Poll is already expired.',
|
||||
code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
|
||||
id: '04da457d-b083-4055-9082-955525eda5a5',
|
||||
},
|
||||
|
||||
cannotCreateAlreadyExpiredSchedule: {
|
||||
message: 'Schedule is already expired.',
|
||||
code: 'CANNOT_CREATE_ALREADY_EXPIRED_SCHEDULE',
|
||||
id: '8a9bfb90-fc7e-4878-a3e8-d97faaf5fb07',
|
||||
},
|
||||
|
||||
noSuchChannel: {
|
||||
message: 'No such channel.',
|
||||
code: 'NO_SUCH_CHANNEL',
|
||||
id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb',
|
||||
},
|
||||
noSuchSchedule: {
|
||||
message: 'No such schedule.',
|
||||
code: 'NO_SUCH_SCHEDULE',
|
||||
id: '44dee229-8da1-4a61-856d-e3a4bbc12032',
|
||||
},
|
||||
youHaveBeenBlocked: {
|
||||
message: 'You have been blocked by this user.',
|
||||
code: 'YOU_HAVE_BEEN_BLOCKED',
|
||||
id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3',
|
||||
},
|
||||
|
||||
noSuchFile: {
|
||||
message: 'Some files are not found.',
|
||||
code: 'NO_SUCH_FILE',
|
||||
id: 'b6992544-63e7-67f0-fa7f-32444b1b5306',
|
||||
},
|
||||
|
||||
cannotRenoteOutsideOfChannel: {
|
||||
message: 'Cannot renote outside of channel.',
|
||||
code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL',
|
||||
id: '33510210-8452-094c-6227-4a6c05d99f00',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], default: 'public' },
|
||||
visibleUserIds: { type: 'array', uniqueItems: true, items: {
|
||||
type: 'string', format: 'misskey:id',
|
||||
} },
|
||||
cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 },
|
||||
reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null },
|
||||
noExtractMentions: { type: 'boolean', default: false },
|
||||
noExtractHashtags: { type: 'boolean', default: false },
|
||||
noExtractEmojis: { type: 'boolean', default: false },
|
||||
replyId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||
renoteId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||
|
||||
// anyOf内にバリデーションを書いても最初の一つしかチェックされない
|
||||
// See https://github.com/misskey-dev/misskey/pull/10082
|
||||
text: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
nullable: true,
|
||||
},
|
||||
fileIds: {
|
||||
type: 'array',
|
||||
uniqueItems: true,
|
||||
minItems: 1,
|
||||
maxItems: 16,
|
||||
items: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
mediaIds: {
|
||||
type: 'array',
|
||||
uniqueItems: true,
|
||||
minItems: 1,
|
||||
maxItems: 16,
|
||||
items: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
poll: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
properties: {
|
||||
choices: {
|
||||
type: 'array',
|
||||
uniqueItems: true,
|
||||
minItems: 2,
|
||||
maxItems: 10,
|
||||
items: { type: 'string', minLength: 1, maxLength: 50 },
|
||||
},
|
||||
multiple: { type: 'boolean' },
|
||||
expiresAt: { type: 'integer', nullable: true },
|
||||
expiredAfter: { type: 'integer', nullable: true, minimum: 1 },
|
||||
},
|
||||
required: ['choices'],
|
||||
},
|
||||
scheduleNote: {
|
||||
type: 'object',
|
||||
nullable: false,
|
||||
properties: {
|
||||
scheduledAt: { type: 'integer', nullable: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
// (re)note with text, files and poll are optional
|
||||
anyOf: [
|
||||
{ required: ['text'] },
|
||||
{ required: ['renoteId'] },
|
||||
{ required: ['fileIds'] },
|
||||
{ required: ['mediaIds'] },
|
||||
{ required: ['poll'] },
|
||||
],
|
||||
required: ['scheduleNote'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.noteScheduleRepository)
|
||||
private noteScheduleRepository: NoteScheduleRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
|
||||
private queueService: QueueService,
|
||||
private roleService: RoleService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const scheduleNoteCount = await this.noteScheduleRepository.countBy({ userId: me.id });
|
||||
const scheduleNoteMax = (await this.roleService.getUserPolicies(me.id)).scheduleNoteMax;
|
||||
if (scheduleNoteCount >= scheduleNoteMax) {
|
||||
throw new ApiError(meta.errors.scheduleNoteMax);
|
||||
}
|
||||
let visibleUsers: MiUser[] = [];
|
||||
if (ps.visibleUserIds) {
|
||||
visibleUsers = await this.usersRepository.findBy({
|
||||
id: In(ps.visibleUserIds),
|
||||
});
|
||||
}
|
||||
|
||||
let files: MiDriveFile[] = [];
|
||||
const fileIds = ps.fileIds ?? ps.mediaIds ?? null;
|
||||
if (fileIds != null) {
|
||||
files = await this.driveFilesRepository.createQueryBuilder('file')
|
||||
.where('file.userId = :userId AND file.id IN (:...fileIds)', {
|
||||
userId: me.id,
|
||||
fileIds,
|
||||
})
|
||||
.orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
|
||||
.setParameters({ fileIds })
|
||||
.getMany();
|
||||
|
||||
if (files.length !== fileIds.length) {
|
||||
throw new ApiError(meta.errors.noSuchFile);
|
||||
}
|
||||
}
|
||||
|
||||
let renote: MiNote | null = null;
|
||||
if (ps.renoteId != null) {
|
||||
// Fetch renote to note
|
||||
renote = await this.notesRepository.findOneBy({ id: ps.renoteId });
|
||||
|
||||
if (renote == null) {
|
||||
throw new ApiError(meta.errors.noSuchRenoteTarget);
|
||||
} else if (isPureRenote(renote)) {
|
||||
throw new ApiError(meta.errors.cannotReRenote);
|
||||
}
|
||||
|
||||
// Check blocking
|
||||
if (renote.userId !== me.id) {
|
||||
const blockExist = await this.blockingsRepository.exist({
|
||||
where: {
|
||||
blockerId: renote.userId,
|
||||
blockeeId: me.id,
|
||||
},
|
||||
});
|
||||
if (blockExist) {
|
||||
throw new ApiError(meta.errors.youHaveBeenBlocked);
|
||||
}
|
||||
}
|
||||
|
||||
if (renote.visibility === 'followers' && renote.userId !== me.id) {
|
||||
// 他人のfollowers noteはreject
|
||||
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
|
||||
} else if (renote.visibility === 'specified') {
|
||||
// specified / direct noteはreject
|
||||
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
|
||||
}
|
||||
}
|
||||
|
||||
let reply: MiNote | null = null;
|
||||
if (ps.replyId != null) {
|
||||
// Fetch reply
|
||||
reply = await this.notesRepository.findOneBy({ id: ps.replyId });
|
||||
|
||||
if (reply == null) {
|
||||
throw new ApiError(meta.errors.noSuchReplyTarget);
|
||||
} else if (isPureRenote(reply)) {
|
||||
throw new ApiError(meta.errors.cannotReplyToPureRenote);
|
||||
}
|
||||
|
||||
// Check blocking
|
||||
if (reply.userId !== me.id) {
|
||||
const blockExist = await this.blockingsRepository.exists({
|
||||
where: {
|
||||
blockerId: reply.userId,
|
||||
blockeeId: me.id,
|
||||
},
|
||||
});
|
||||
if (blockExist) {
|
||||
throw new ApiError(meta.errors.youHaveBeenBlocked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ps.poll) {
|
||||
let scheduleNote_scheduledAt = Date.now();
|
||||
if (typeof ps.scheduleNote.scheduledAt === 'number') {
|
||||
scheduleNote_scheduledAt = ps.scheduleNote.scheduledAt;
|
||||
}
|
||||
if (typeof ps.poll.expiresAt === 'number') {
|
||||
if (ps.poll.expiresAt < scheduleNote_scheduledAt) {
|
||||
throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
|
||||
}
|
||||
} else if (typeof ps.poll.expiredAfter === 'number') {
|
||||
ps.poll.expiresAt = scheduleNote_scheduledAt + ps.poll.expiredAfter;
|
||||
}
|
||||
}
|
||||
if (typeof ps.scheduleNote.scheduledAt === 'number') {
|
||||
if (ps.scheduleNote.scheduledAt < Date.now()) {
|
||||
throw new ApiError(meta.errors.cannotCreateAlreadyExpiredSchedule);
|
||||
}
|
||||
} else {
|
||||
throw new ApiError(meta.errors.cannotCreateAlreadyExpiredSchedule);
|
||||
}
|
||||
const note: MiScheduleNoteType = {
|
||||
files: files.map(f => f.id),
|
||||
poll: ps.poll ? {
|
||||
choices: ps.poll.choices,
|
||||
multiple: ps.poll.multiple ?? false,
|
||||
expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt).toISOString() : null,
|
||||
} : undefined,
|
||||
text: ps.text ?? undefined,
|
||||
reply: reply?.id,
|
||||
renote: renote?.id,
|
||||
cw: ps.cw,
|
||||
localOnly: false,
|
||||
reactionAcceptance: ps.reactionAcceptance,
|
||||
visibility: ps.visibility,
|
||||
visibleUsers,
|
||||
apMentions: ps.noExtractMentions ? [] : undefined,
|
||||
apHashtags: ps.noExtractHashtags ? [] : undefined,
|
||||
apEmojis: ps.noExtractEmojis ? [] : undefined,
|
||||
};
|
||||
|
||||
if (ps.scheduleNote.scheduledAt) {
|
||||
me.token = null;
|
||||
const noteId = this.idService.gen(new Date().getTime());
|
||||
await this.noteScheduleRepository.insert({
|
||||
id: noteId,
|
||||
note: note,
|
||||
userId: me.id,
|
||||
scheduledAt: new Date(ps.scheduleNote.scheduledAt),
|
||||
});
|
||||
|
||||
const delay = new Date(ps.scheduleNote.scheduledAt).getTime() - Date.now();
|
||||
await this.queueService.ScheduleNotePostQueue.add(String(delay), {
|
||||
scheduleNoteId: noteId,
|
||||
}, {
|
||||
delay,
|
||||
removeOnComplete: true,
|
||||
jobId: `schedNote:${noteId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import ms from 'ms';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { NoteScheduleRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
||||
requireCredential: true,
|
||||
kind: 'write:notes-schedule',
|
||||
|
||||
limit: {
|
||||
duration: ms('1hour'),
|
||||
max: 300,
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchNote: {
|
||||
message: 'No such note.',
|
||||
code: 'NO_SUCH_NOTE',
|
||||
id: 'a58056ba-8ba1-4323-8ebf-e0b585bc244f',
|
||||
},
|
||||
permissionDenied: {
|
||||
message: 'Permission denied.',
|
||||
code: 'PERMISSION_DENIED',
|
||||
id: 'c0da2fed-8f61-4c47-a41d-431992607b5c',
|
||||
httpStatusCode: 403,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
noteId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['noteId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.noteScheduleRepository)
|
||||
private noteScheduleRepository: NoteScheduleRepository,
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const note = await this.noteScheduleRepository.findOneBy({ id: ps.noteId });
|
||||
if (note === null) {
|
||||
throw new ApiError(meta.errors.noSuchNote);
|
||||
}
|
||||
if (note.userId !== me.id) {
|
||||
throw new ApiError(meta.errors.permissionDenied);
|
||||
}
|
||||
await this.noteScheduleRepository.delete({ id: ps.noteId });
|
||||
await this.queueService.ScheduleNotePostQueue.remove(`schedNote:${ps.noteId}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
131
packages/backend/src/server/api/endpoints/notes/schedule/list.ts
Normal file
131
packages/backend/src/server/api/endpoints/notes/schedule/list.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import ms from 'ms';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MiNote, MiNoteSchedule, NoteScheduleRepository } from '@/models/_.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { Packed } from '@/misc/json-schema.js';
|
||||
import { noteVisibilities } from '@/types.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
||||
requireCredential: true,
|
||||
kind: 'read:notes-schedule',
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
id: { type: 'string', format: 'misskey:id', optional: false, nullable: false },
|
||||
note: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
createdAt: { type: 'string', optional: false, nullable: false },
|
||||
text: { type: 'string', optional: true, nullable: false },
|
||||
cw: { type: 'string', optional: true, nullable: true },
|
||||
fileIds: { type: 'array', optional: false, nullable: false, items: { type: 'string', format: 'misskey:id', optional: false, nullable: false } },
|
||||
visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], optional: false, nullable: false },
|
||||
visibleUsers: {
|
||||
type: 'array', optional: false, nullable: false, items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'UserLite',
|
||||
},
|
||||
},
|
||||
user: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'User',
|
||||
},
|
||||
reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null },
|
||||
isSchedule: { type: 'boolean', optional: false, nullable: false },
|
||||
},
|
||||
},
|
||||
userId: { type: 'string', optional: false, nullable: false },
|
||||
scheduledAt: { type: 'string', optional: false, nullable: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
limit: {
|
||||
duration: ms('1hour'),
|
||||
max: 300,
|
||||
},
|
||||
|
||||
errors: {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.noteScheduleRepository)
|
||||
private noteScheduleRepository: NoteScheduleRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private driveFileEntityService: DriveFileEntityService,
|
||||
private queryService: QueryService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.queryService.makePaginationQuery(this.noteScheduleRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
.andWhere('note.userId = :userId', { userId: me.id });
|
||||
const scheduleNotes = await query.limit(ps.limit).getMany();
|
||||
const user = await this.userEntityService.pack(me, me);
|
||||
const scheduleNotesPack: {
|
||||
id: string;
|
||||
note: {
|
||||
text?: string;
|
||||
cw?: string|null;
|
||||
fileIds: string[];
|
||||
visibility: typeof noteVisibilities[number];
|
||||
visibleUsers: Packed<'UserLite'>[];
|
||||
reactionAcceptance: MiNote['reactionAcceptance'];
|
||||
user: Packed<'User'>;
|
||||
createdAt: string;
|
||||
isSchedule: boolean;
|
||||
};
|
||||
userId: string;
|
||||
scheduledAt: string;
|
||||
}[] = await Promise.all(scheduleNotes.map(async (item: MiNoteSchedule) => {
|
||||
return {
|
||||
...item,
|
||||
scheduledAt: item.scheduledAt.toISOString(),
|
||||
note: {
|
||||
...item.note,
|
||||
text: item.note.text ?? '',
|
||||
user: user,
|
||||
visibility: item.note.visibility ?? 'public',
|
||||
reactionAcceptance: item.note.reactionAcceptance ?? null,
|
||||
visibleUsers: item.note.visibleUsers ? await userEntityService.packMany(item.note.visibleUsers.map(u => u.id), me) : [],
|
||||
fileIds: item.note.files ? item.note.files : [],
|
||||
files: await this.driveFileEntityService.packManyByIds(item.note.files),
|
||||
createdAt: item.scheduledAt.toISOString(),
|
||||
isSchedule: true,
|
||||
id: item.id,
|
||||
},
|
||||
};
|
||||
}));
|
||||
|
||||
return scheduleNotesPack;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ import type {
|
|||
SystemQueue,
|
||||
UserWebhookDeliverQueue,
|
||||
SystemWebhookDeliverQueue,
|
||||
ScheduleNotePostQueue,
|
||||
} from '@/core/QueueModule.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
|
|
@ -143,6 +144,7 @@ export class ClientServerService {
|
|||
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
|
||||
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
||||
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
||||
@Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue,
|
||||
) {
|
||||
//this.createServer = this.createServer.bind(this);
|
||||
}
|
||||
|
|
@ -274,6 +276,7 @@ export class ClientServerService {
|
|||
this.objectStorageQueue,
|
||||
this.userWebhookDeliverQueue,
|
||||
this.systemWebhookDeliverQueue,
|
||||
this.scheduleNotePostQueue,
|
||||
].map(q => new BullMQAdapter(q)),
|
||||
serverAdapter: bullBoardServerAdapter,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ export const notificationTypes = [
|
|||
'achievementEarned',
|
||||
'exportCompleted',
|
||||
'login',
|
||||
'scheduledNoteFailed',
|
||||
'scheduledNotePosted',
|
||||
'app',
|
||||
'test',
|
||||
] as const;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue