Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- Feat: コントロールパネルから二要素認証を解除できるように
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
(Based on https://github.com/MisskeyIO/misskey/pull/214)
- Feat: お知らせに自動アーカイブ日時を設定できるように

### Client
- 2025.4.0 以前の設定情報の移行処理が削除されました
Expand Down
3 changes: 3 additions & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1653,6 +1653,9 @@ _announcement:
forExistingUsersDescription: "有効にすると、このお知らせ作成時点で存在するユーザーにのみお知らせが表示されます。無効にすると、このお知らせ作成後にアカウントを作成したユーザーにもお知らせが表示されます。"
needConfirmationToRead: "既読にするのに確認が必要"
needConfirmationToReadDescription: "有効にすると、このお知らせを既読にする際に確認ダイアログが表示されます。また、一括既読操作の対象になりません。"
autoArchiveAt: "自動アーカイブ日時"
autoArchiveAtDescription: "指定した日時を過ぎると、このお知らせは自動的にアーカイブされます。空欄の場合は自動的にアーカイブされません。"
autoArchiveAtMustBeInFuture: "自動アーカイブ日時には現在より後の日時を指定してください。"
end: "お知らせを終了"
tooManyActiveAnnouncementDescription: "アクティブなお知らせが多いため、UXが低下する可能性があります。終了したお知らせはアーカイブすることを検討してください。"
readConfirmTitle: "既読にしますか?"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

export class AutoArchiveAnnouncements1783913268595 {
name = 'AutoArchiveAnnouncements1783913268595';

async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "announcement" ADD "autoArchiveAt" TIMESTAMP WITH TIME ZONE`);
await queryRunner.query(`COMMENT ON COLUMN "announcement"."autoArchiveAt" IS 'The date after which the Announcement is automatically archived.'`);
}

async down(queryRunner) {
await queryRunner.query(`COMMENT ON COLUMN "announcement"."autoArchiveAt" IS 'The date after which the Announcement is automatically archived.'`);
await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "autoArchiveAt"`);
}
}
43 changes: 37 additions & 6 deletions packages/backend/src/core/AnnouncementService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,17 @@ export class AnnouncementService {

@bindThis
public async getUnreadAnnouncements(user: MiUser): Promise<MiAnnouncement[]> {
const now = new Date();
const readsQuery = this.announcementReadsRepository.createQueryBuilder('read')
.select('read.announcementId')
.where('read.userId = :userId', { userId: user.id });

const q = this.announcementsRepository.createQueryBuilder('announcement')
.where('announcement.isActive = true')
.andWhere(new Brackets(qb => {
qb.where('announcement.autoArchiveAt IS NULL');
qb.orWhere('announcement.autoArchiveAt > :now', { now });
}))
.andWhere('announcement.silence = false')
.andWhere(new Brackets(qb => {
qb.orWhere('announcement.userId = :userId', { userId: user.id });
Expand Down Expand Up @@ -79,14 +84,19 @@ export class AnnouncementService {
silence: values.silence,
needConfirmationToRead: values.needConfirmationToRead,
userId: values.userId,
autoArchiveAt: values.autoArchiveAt ?? null,
isActive: values.isActive ?? (values.autoArchiveAt == null || values.autoArchiveAt > new Date()),
});

const packed = await this.announcementEntityService.pack(announcement);

// 作成処理中にautoArchiveAtを過ぎる可能性があるため、insert完了時点で非アクティブなお知らせはイベント配信しない
if (values.userId) {
this.globalEventService.publishMainStream(values.userId, 'announcementCreated', {
announcement: packed,
});
if (announcement.isActive) {
this.globalEventService.publishMainStream(values.userId, 'announcementCreated', {
announcement: packed,
});
}

if (moderator) {
const user = await this.usersRepository.findOneByOrFail({ id: values.userId });
Expand All @@ -99,9 +109,11 @@ export class AnnouncementService {
});
}
} else {
this.globalEventService.publishBroadcastStream('announcementCreated', {
announcement: packed,
});
if (announcement.isActive) {
this.globalEventService.publishBroadcastStream('announcementCreated', {
announcement: packed,
});
}

if (moderator) {
this.moderationLogService.log(moderator, 'createGlobalAnnouncement', {
Expand Down Expand Up @@ -131,6 +143,7 @@ export class AnnouncementService {
silence: values.silence,
needConfirmationToRead: values.needConfirmationToRead,
isActive: values.isActive,
autoArchiveAt: values.autoArchiveAt,
});

const after = await this.announcementsRepository.findOneByOrFail({ id: announcement.id });
Expand All @@ -156,6 +169,24 @@ export class AnnouncementService {
}
}

@bindThis
public async archiveExpiredAnnouncements(): Promise<number> {
const now = new Date();

const result = await this.announcementsRepository.createQueryBuilder()
.update()
.set({
isActive: false,
updatedAt: now,
})
.where('isActive = true')
.andWhere('autoArchiveAt IS NOT NULL')
.andWhere('autoArchiveAt <= :now', { now })
.execute();

return result.affected ?? 0;
}

@bindThis
public async delete(announcement: MiAnnouncement, moderator?: MiUser): Promise<void> {
await this.announcementsRepository.delete(announcement.id);
Expand Down
3 changes: 3 additions & 0 deletions packages/backend/src/core/QueueService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ const REPEATABLE_SYSTEM_JOB_DEF = [{
}, {
name: 'checkExpiredMutings',
pattern: '*/5 * * * *',
}, {
name: 'checkExpiredAnnouncements',
pattern: '* * * * *',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1分ごとに実行って結構高頻度な気がするけどそんなもんかしら

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ポーリングじゃなくてジョブの実行時間を直接していすれば済みそう

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed
f3fbc6d

}, {
name: 'bakeBufferedReactions',
pattern: '0 0 * * *',
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/models/Announcement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export class MiAnnouncement {
})
public isActive: boolean;

@Column('timestamp with time zone', {
comment: 'The date after which the Announcement is automatically archived.',
nullable: true,
})
public autoArchiveAt: Date | null;

@Index()
@Column('boolean', {
default: false,
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/queue/QueueProcessorModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { InboxProcessorService } from './processors/InboxProcessorService.js';
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js';
import { CheckExpiredAnnouncementsProcessorService } from './processors/CheckExpiredAnnouncementsProcessorService.js';
import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js';
import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js';
import { CleanProcessorService } from './processors/CleanProcessorService.js';
Expand Down Expand Up @@ -55,6 +56,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
ResyncChartsProcessorService,
CleanChartsProcessorService,
CheckExpiredMutingsProcessorService,
CheckExpiredAnnouncementsProcessorService,
BakeBufferedReactionsProcessorService,
CleanProcessorService,
DeleteDriveFilesProcessorService,
Expand Down
3 changes: 3 additions & 0 deletions packages/backend/src/queue/QueueProcessorService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { TickChartsProcessorService } from './processors/TickChartsProcessorServ
import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js';
import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js';
import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js';
import { CheckExpiredAnnouncementsProcessorService } from './processors/CheckExpiredAnnouncementsProcessorService.js';
import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js';
import { CleanProcessorService } from './processors/CleanProcessorService.js';
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
Expand Down Expand Up @@ -125,6 +126,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private cleanChartsProcessorService: CleanChartsProcessorService,
private aggregateRetentionProcessorService: AggregateRetentionProcessorService,
private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService,
private checkExpiredAnnouncementsProcessorService: CheckExpiredAnnouncementsProcessorService,
private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService,
private checkModeratorsActivityProcessorService: CheckModeratorsActivityProcessorService,
private cleanProcessorService: CleanProcessorService,
Expand Down Expand Up @@ -167,6 +169,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
case 'cleanCharts': return this.cleanChartsProcessorService.process();
case 'aggregateRetention': return this.aggregateRetentionProcessorService.process();
case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process();
case 'checkExpiredAnnouncements': return this.checkExpiredAnnouncementsProcessorService.process();
case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process();
case 'checkModeratorsActivity': return this.checkModeratorsActivityProcessorService.process();
case 'clean': return this.cleanProcessorService.process();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { Injectable } from '@nestjs/common';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';

@Injectable()
export class CheckExpiredAnnouncementsProcessorService {
private logger: Logger;

constructor(
private announcementService: AnnouncementService,
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('check-expired-announcements');
}

@bindThis
public async process(): Promise<void> {
const archivedCount = await this.announcementService.archiveExpiredAnnouncements();

if (archivedCount > 0) {
this.logger.succ(`Archived ${archivedCount} expired announcements.`);
} else {
this.logger.debug('No expired announcements found.');
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import { ApiError } from '../../../error.js';

export const meta = {
tags: ['admin'],
Expand All @@ -14,6 +15,14 @@ export const meta = {
requireModerator: true,
kind: 'write:admin:announcements',

errors: {
invalidAutoArchiveAt: {
message: 'Invalid auto archive date.',
code: 'INVALID_AUTO_ARCHIVE_AT',
id: '2a892bd5-487d-46a2-a5fe-3d85ad51defe',
},
},

res: {
type: 'object',
optional: false, nullable: false,
Expand Down Expand Up @@ -62,6 +71,7 @@ export const paramDef = {
silence: { type: 'boolean', default: false },
needConfirmationToRead: { type: 'boolean', default: false },
userId: { type: 'string', format: 'misskey:id', nullable: true, default: null },
autoArchiveAt: { type: 'integer', nullable: true, default: null },
},
required: ['title', 'text', 'imageUrl'],
} as const;
Expand All @@ -72,6 +82,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private announcementService: AnnouncementService,
) {
super(meta, paramDef, async (ps, me) => {
const autoArchiveAt = ps.autoArchiveAt != null ? new Date(ps.autoArchiveAt) : null;
if (ps.autoArchiveAt != null && (ps.autoArchiveAt <= Date.now() || Number.isNaN(autoArchiveAt?.getTime()))) {
throw new ApiError(meta.errors.invalidAutoArchiveAt);
}

const { packed } = await this.announcementService.create({
updatedAt: null,
title: ps.title,
Expand All @@ -84,6 +99,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
silence: ps.silence,
needConfirmationToRead: ps.needConfirmationToRead,
userId: ps.userId,
autoArchiveAt,
}, me);

return packed;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import type { AnnouncementsRepository, AnnouncementReadsRepository } from '@/models/_.js';
import type { MiAnnouncement } from '@/models/Announcement.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
Expand Down Expand Up @@ -63,6 +64,11 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
autoArchiveAt: {
type: 'string',
optional: false, nullable: true,
format: 'date-time',
},
forExistingUsers: {
type: 'boolean',
optional: false, nullable: false,
Expand Down Expand Up @@ -119,12 +125,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const now = new Date();
const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate);

if (ps.status === 'archived') {
query.andWhere('announcement.isActive = false');
query.andWhere(new Brackets(qb => {
qb.where('announcement.isActive = false');
qb.orWhere('announcement.autoArchiveAt <= :now', { now });
}));
} else if (ps.status === 'active') {
query.andWhere('announcement.isActive = true');
query.andWhere(new Brackets(qb => {
qb.where('announcement.autoArchiveAt IS NULL');
qb.orWhere('announcement.autoArchiveAt > :now', { now });
}));
}

if (ps.userId) {
Expand Down Expand Up @@ -152,7 +166,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
imageUrl: announcement.imageUrl,
icon: announcement.icon,
display: announcement.display,
isActive: announcement.isActive,
isActive: announcement.isActive && (announcement.autoArchiveAt == null || announcement.autoArchiveAt > now),
autoArchiveAt: announcement.autoArchiveAt?.toISOString() ?? null,
forExistingUsers: announcement.forExistingUsers,
silence: announcement.silence,
needConfirmationToRead: announcement.needConfirmationToRead,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export const meta = {
code: 'NO_SUCH_ANNOUNCEMENT',
id: 'd3aae5a7-6372-4cb4-b61c-f511ffc2d7cc',
},
invalidAutoArchiveAt: {
message: 'Invalid auto archive date.',
code: 'INVALID_AUTO_ARCHIVE_AT',
id: '01b83d7b-2fd5-4d7c-86c4-d03144d16355',
},
},
} as const;

Expand All @@ -39,6 +44,7 @@ export const paramDef = {
silence: { type: 'boolean' },
needConfirmationToRead: { type: 'boolean' },
isActive: { type: 'boolean' },
autoArchiveAt: { type: 'integer', nullable: true },
},
required: ['id'],
} as const;
Expand All @@ -52,6 +58,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private announcementService: AnnouncementService,
) {
super(meta, paramDef, async (ps, me) => {
const autoArchiveAt = ps.autoArchiveAt != null ? new Date(ps.autoArchiveAt) : ps.autoArchiveAt;
if (ps.autoArchiveAt != null && (ps.autoArchiveAt < 0 || Number.isNaN(autoArchiveAt?.getTime()))) {
throw new ApiError(meta.errors.invalidAutoArchiveAt);
}

const announcement = await this.announcementsRepository.findOneBy({ id: ps.id });

if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement);
Expand All @@ -68,6 +79,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
silence: ps.silence,
needConfirmationToRead: ps.needConfirmationToRead,
isActive: ps.isActive,
autoArchiveAt,
}, me);
});
}
Expand Down
15 changes: 14 additions & 1 deletion packages/backend/src/server/api/endpoints/announcements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,26 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private announcementEntityService: AnnouncementEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const now = new Date();
const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('announcement.isActive = :isActive', { isActive: ps.isActive })
.andWhere(new Brackets(qb => {
if (me) qb.orWhere('announcement.userId = :meId', { meId: me.id });
qb.orWhere('announcement.userId IS NULL');
}));

if (ps.isActive) {
query.andWhere('announcement.isActive = true');
query.andWhere(new Brackets(qb => {
qb.where('announcement.autoArchiveAt IS NULL');
qb.orWhere('announcement.autoArchiveAt > :now', { now });
}));
} else {
query.andWhere(new Brackets(qb => {
qb.where('announcement.isActive = false');
qb.orWhere('announcement.autoArchiveAt <= :now', { now });
}));
}

const announcements = await query.limit(ps.limit).getMany();

return this.announcementEntityService.packMany(announcements, me);
Expand Down
Loading
Loading