summaryrefslogtreecommitdiff
path: root/packages/backend/src
diff options
context:
space:
mode:
authorMarie <github@yuugi.dev>2024-12-12 12:50:11 +0000
committerMarie <github@yuugi.dev>2024-12-12 12:50:11 +0000
commit8eb9c20df73960baf78834da65349fbfb7014f23 (patch)
treeab8326ede2f2a475069ad7b820c6689401798c79 /packages/backend/src
parentmerge: fix icons in dev mode (!813) (diff)
parentApply suggestions (diff)
downloadsharkey-8eb9c20df73960baf78834da65349fbfb7014f23.tar.gz
sharkey-8eb9c20df73960baf78834da65349fbfb7014f23.tar.bz2
sharkey-8eb9c20df73960baf78834da65349fbfb7014f23.zip
merge: Schedule Notes (!804)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/804 Approved-by: dakkar <dakkar@thenautilus.net> Approved-by: Hazelnoot <acomputerdog@gmail.com>
Diffstat (limited to 'packages/backend/src')
-rw-r--r--packages/backend/src/core/QueueModule.ts12
-rw-r--r--packages/backend/src/core/QueueService.ts2
-rw-r--r--packages/backend/src/core/RoleService.ts3
-rw-r--r--packages/backend/src/core/entities/NotificationEntityService.ts5
-rw-r--r--packages/backend/src/di-symbols.ts1
-rw-r--r--packages/backend/src/models/NoteSchedule.ts58
-rw-r--r--packages/backend/src/models/Notification.ts10
-rw-r--r--packages/backend/src/models/RepositoryModule.ts9
-rw-r--r--packages/backend/src/models/_.ts3
-rw-r--r--packages/backend/src/models/json-schema/notification.ts39
-rw-r--r--packages/backend/src/models/json-schema/role.ts4
-rw-r--r--packages/backend/src/postgres.ts2
-rw-r--r--packages/backend/src/queue/QueueProcessorModule.ts2
-rw-r--r--packages/backend/src/queue/QueueProcessorService.ts14
-rw-r--r--packages/backend/src/queue/const.ts1
-rw-r--r--packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts144
-rw-r--r--packages/backend/src/queue/types.ts4
-rw-r--r--packages/backend/src/server/api/EndpointsModule.ts12
-rw-r--r--packages/backend/src/server/api/endpoints.ts6
-rw-r--r--packages/backend/src/server/api/endpoints/admin/queue/stats.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/notes/schedule/create.ts370
-rw-r--r--packages/backend/src/server/api/endpoints/notes/schedule/delete.ts67
-rw-r--r--packages/backend/src/server/api/endpoints/notes/schedule/list.ts131
-rw-r--r--packages/backend/src/server/web/ClientServerService.ts3
-rw-r--r--packages/backend/src/types.ts2
25 files changed, 905 insertions, 2 deletions
diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts
index b10b8e5899..6dd48927c1 100644
--- a/packages/backend/src/core/QueueModule.ts
+++ b/packages/backend/src/core/QueueModule.ts
@@ -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(),
]);
}
diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
index dc13aa21bf..d9d282a168 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -32,6 +32,7 @@ import type {
SystemQueue,
UserWebhookDeliverQueue,
SystemWebhookDeliverQueue,
+ ScheduleNotePostQueue,
} from './QueueModule.js';
import type httpSignature from '@peertube/http-signature';
import type * as Bull from 'bullmq';
@@ -52,6 +53,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', {
}, {
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 64f7539031..5651b04ac2 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -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,
@@ -377,6 +379,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)),
diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts
index bbaf0cb7c8..31a9809323 100644
--- a/packages/backend/src/core/entities/NotificationEntityService.ts
+++ b/packages/backend/src/core/entities/NotificationEntityService.ts
@@ -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,
diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts
index 5ea500ac77..296cc4815b 100644
--- a/packages/backend/src/di-symbols.ts
+++ b/packages/backend/src/di-symbols.ts
@@ -86,5 +86,6 @@ export const DI = {
noteEditRepository: Symbol('noteEditRepository'),
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
reversiGamesRepository: Symbol('reversiGamesRepository'),
+ noteScheduleRepository: Symbol('noteScheduleRepository'),
//#endregion
};
diff --git a/packages/backend/src/models/NoteSchedule.ts b/packages/backend/src/models/NoteSchedule.ts
new file mode 100644
index 0000000000..dde0af6ad7
--- /dev/null
+++ b/packages/backend/src/models/NoteSchedule.ts
@@ -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;
+}
diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts
index c4f046c565..53003a0a5a 100644
--- a/packages/backend/src/models/Notification.ts
+++ b/packages/backend/src/models/Notification.ts
@@ -122,6 +122,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 | {
diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts
index eb45b9a631..3a1158a42a 100644
--- a/packages/backend/src/models/RepositoryModule.ts
+++ b/packages/backend/src/models/RepositoryModule.ts
@@ -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 {
diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts
index ac2dd62aa2..9a4ebfc90f 100644
--- a/packages/backend/src/models/_.ts
+++ b/packages/backend/src/models/_.ts
@@ -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>;
diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts
index 990e8957cf..26498e3e9d 100644
--- a/packages/backend/src/models/json-schema/notification.ts
+++ b/packages/backend/src/models/json-schema/notification.ts
@@ -376,6 +376,45 @@ export const packedNotificationSchema = {
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: {
+ ...baseSchema.properties,
+ type: {
+ type: 'string',
+ optional: false, nullable: false,
enum: ['reaction:grouped'],
},
note: {
diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts
index 19ea6263c9..ef0bb9f141 100644
--- a/packages/backend/src/models/json-schema/role.ts
+++ b/packages/backend/src/models/json-schema/role.ts
@@ -296,6 +296,10 @@ export const packedRolePoliciesSchema = {
type: 'boolean',
optional: false, nullable: false,
},
+ scheduleNoteMax: {
+ type: 'integer',
+ optional: false, nullable: false,
+ },
},
} as const;
diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts
index 2d66e6e445..c964c3ffee 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -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,
diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts
index 7c6675b15d..dd588e0115 100644
--- a/packages/backend/src/queue/QueueProcessorModule.ts
+++ b/packages/backend/src/queue/QueueProcessorModule.ts
@@ -42,6 +42,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: [
@@ -85,6 +86,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
InboxProcessorService,
AggregateRetentionProcessorService,
QueueProcessorService,
+ ScheduleNotePostProcessorService,
],
exports: [
QueueProcessorService,
diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts
index f130314e74..4cc5446062 100644
--- a/packages/backend/src/queue/QueueProcessorService.ts
+++ b/packages/backend/src/queue/QueueProcessorService.ts
@@ -44,6 +44,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';
@@ -86,6 +87,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)
@@ -126,6 +128,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService,
private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService,
private cleanProcessorService: CleanProcessorService,
+ private scheduleNotePostProcessorService: ScheduleNotePostProcessorService,
) {
this.logger = this.queueLoggerService.logger;
@@ -530,6 +533,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
@@ -544,6 +556,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
this.relationshipQueueWorker.run(),
this.objectStorageQueueWorker.run(),
this.endedPollNotificationQueueWorker.run(),
+ this.schedulerNotePostQueueWorker.run(),
]);
}
@@ -559,6 +572,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
this.relationshipQueueWorker.close(),
this.objectStorageQueueWorker.close(),
this.endedPollNotificationQueueWorker.close(),
+ this.schedulerNotePostQueueWorker.close(),
]);
}
diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts
index 67f689b618..fdf012f149 100644
--- a/packages/backend/src/queue/const.ts
+++ b/packages/backend/src/queue/const.ts
@@ -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 {
diff --git a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
new file mode 100644
index 0000000000..62e3d1072f
--- /dev/null
+++ b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
@@ -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,
+ });
+ }
+ });
+ }
+}
diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts
index c0d246ebbc..9433392df5 100644
--- a/packages/backend/src/queue/types.ts
+++ b/packages/backend/src/queue/types.ts
@@ -155,3 +155,7 @@ export type UserWebhookDeliverJobData = {
export type ThinUser = {
id: MiUser['id'];
};
+
+export type ScheduleNotePostJobData = {
+ scheduleNoteId: MiNote['id'];
+}
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index 5bdd7cf650..c478bebdaf 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -311,6 +311,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';
@@ -711,6 +714,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 };
@@ -1117,6 +1123,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,
@@ -1516,6 +1525,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,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 7eb18fbfe2..269afbf14b 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -316,6 +316,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';
@@ -716,6 +719,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],
diff --git a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts
index d7f9e4eaa3..e2bd38aac6 100644
--- a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts
+++ b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts
@@ -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();
diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
new file mode 100644
index 0000000000..7d20b6b82a
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
@@ -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 '';
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts b/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts
new file mode 100644
index 0000000000..628fd89926
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts
@@ -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}`);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts
new file mode 100644
index 0000000000..4895733d4e
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts
@@ -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;
+ });
+ }
+}
diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts
index fbb8321730..aca98c4d37 100644
--- a/packages/backend/src/server/web/ClientServerService.ts
+++ b/packages/backend/src/server/web/ClientServerService.ts
@@ -33,6 +33,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';
@@ -124,6 +125,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);
}
@@ -254,6 +256,7 @@ export class ClientServerService {
this.objectStorageQueue,
this.userWebhookDeliverQueue,
this.systemWebhookDeliverQueue,
+ this.scheduleNotePostQueue,
].map(q => new BullMQAdapter(q)),
serverAdapter: bullBoardServerAdapter,
});
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index 2aa4f279ea..95f049f768 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -35,6 +35,8 @@ export const notificationTypes = [
'roleAssigned',
'achievementEarned',
'exportCompleted',
+ 'scheduledNoteFailed',
+ 'scheduledNotePosted',
'app',
'test',
] as const;