Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 特定のロールにのみお知らせ #18

Merged
merged 2 commits into from
Aug 2, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,21 @@

## 独自機能
- ノートを一定期間で自動消去する「すぐ消す」機能
- チャンネル内お知らせ機能 #2
- チャンネル内お知らせ機能 https://github.com/team-shahu/misskey/pull/2
- 他インスタンスの絵文字でもローカルに存在すればリアクションできるように
- フォローリクエストを自動的に拒否する機能
- 絵文字のクリックメニューに「絵文字ピッカーに追加」を追加
- 投稿フォームのツールバーを任意にカスタマイズできるように
- 下書き機能
- 同じ音源が短時間で重複して流れないように
- 通知にフォロバボタンを表示 #5
- 通知にフォロバボタンを表示 https://github.com/team-shahu/misskey/pull/5
- 二要素認証のバックアップコードを保存するように促すダイアログを表示するように
- カスタムフォント機能
- 絵文字を登録したユーザーがアカウントを消去しても継続して絵文字の使用ができるように #11
- 絵文字を登録したユーザーがアカウントを消去しても継続して絵文字の使用ができるように https://github.com/team-shahu/misskey/pull/11
- アバターデコレーションを登録したユーザーがアカウントを消去しても継続して使用ができるように
- アバターデコレーションをmisskeyUI上から登録できるように #12
- TL上のサーバー情報をアイコン表示に切り替えられるように #13
- アバターデコレーションをmisskeyUI上から登録できるように https://github.com/team-shahu/misskey/pull/12
- TL上のサーバー情報をアイコン表示に切り替えられるように https://github.com/team-shahu/misskey/pull/13
- 特定のロールにのみお知らせを発行する機能 https://github.com/team-shahu/misskey/pull/18

## Special Thanks
- [Misskey](https://github.com/misskey-dev/misskey)
Expand Down
4 changes: 4 additions & 0 deletions locales/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4696,6 +4696,10 @@ export interface Locale extends ILocale {
* あなたへ
*/
"forYou": string;
/**
* あなたのロールへ
*/
"forYourRoles": string;
/**
* 現在のお知らせ
*/
Expand Down
1 change: 1 addition & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1170,6 +1170,7 @@ iHaveReadXCarefullyAndAgree: "「{x}」の内容をよく読み、同意しま
dialog: "ダイアログ"
icon: "アイコン"
forYou: "あなたへ"
forYourRoles: "あなたのロールへ"
currentAnnouncements: "現在のお知らせ"
pastAnnouncements: "過去のお知らせ"
youHaveUnreadAnnouncements: "未読のお知らせがあります。"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* SPDX-License-Identifier: taichan and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/

export class AddTargetRolesToAnnouncements1720899566130 {
name = 'AddTargetRolesToAnnouncements1720899566130'

async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "announcement_role" ("id" character varying(32) NOT NULL, "announcementId" character varying(32) NOT NULL, "roleId" character varying(32) NOT NULL, CONSTRAINT "PK_cb76dfa429c742b1a273ef18d71983ea" PRIMARY KEY ("announcementId", "roleId"))`);
await queryRunner.query(`CREATE INDEX "IDX_56b0c35e2d1449e987b1c43a779b14ce" ON "announcement_role" ("announcementId") `);
await queryRunner.query(`CREATE INDEX "IDX_53351cbca4544b04937d10f64f98f682" ON "announcement_role" ("roleId") `);
await queryRunner.query(`CREATE UNIQUE INDEX "090a6806f09446228a3ddd501eb63270" ON "announcement_role" ("announcementId", "roleId") `);
await queryRunner.query(`ALTER TABLE "announcement_role" ADD CONSTRAINT "FK_56b0c35e2d1449e987b1c43a779" FOREIGN KEY ("announcementId") REFERENCES "announcement"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "announcement_role" ADD CONSTRAINT "FK_53351cbca4544b04937d10f64f9" FOREIGN KEY ("roleId") REFERENCES "role"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);

await queryRunner.query(`ALTER TABLE "announcement" ADD "isRoleSpecified" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`CREATE INDEX "IDX_b2dbc3e04c3443eca1ff9c488e904660" ON "announcement" ("isRoleSpecified") `);
}

async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "announcement_role" DROP CONSTRAINT "FK_53351cbca4544b04937d10f64f9"`);
await queryRunner.query(`ALTER TABLE "announcement_role" DROP CONSTRAINT "FK_56b0c35e2d1449e987b1c43a779"`);
await queryRunner.query(`DROP INDEX "public"."090a6806f09446228a3ddd501eb63270"`);
await queryRunner.query(`DROP INDEX "public"."IDX_53351cbca4544b04937d10f64f98f682"`);
await queryRunner.query(`DROP INDEX "public"."IDX_56b0c35e2d1449e987b1c43a779b14ce"`);
await queryRunner.query(`DROP TABLE "announcement_role"`);

await queryRunner.query(`DROP INDEX "public"."IDX_b2dbc3e04c3443eca1ff9c488e904660"`);
await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "isRoleSpecified"`);
}
}
113 changes: 107 additions & 6 deletions packages/backend/src/core/AnnouncementService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,25 @@ import { Inject, Injectable } from '@nestjs/common';
import { Brackets, EntityNotFoundError } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MiUser } from '@/models/User.js';
import type { AnnouncementReadsRepository, AnnouncementsRepository, MiAnnouncement, MiAnnouncementRead, UsersRepository } from '@/models/_.js';
import type { MiRole } from '@/models/Role.js';
import type { AnnouncementReadsRepository, AnnouncementsRepository, AnnouncementRolesRepository, MiAnnouncement, MiAnnouncementRead, UsersRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { Packed } from '@/misc/json-schema.js';
import { IdService } from '@/core/IdService.js';
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { RoleService } from '@/core/RoleService.js';

@Injectable()
export class AnnouncementService {
constructor(
@Inject(DI.announcementsRepository)
private announcementsRepository: AnnouncementsRepository,

@Inject(DI.announcementRolesRepository)
private announcementRolesRepository: AnnouncementRolesRepository,

@Inject(DI.announcementReadsRepository)
private announcementReadsRepository: AnnouncementReadsRepository,

Expand All @@ -31,6 +36,7 @@ export class AnnouncementService {
private globalEventService: GlobalEventService,
private moderationLogService: ModerationLogService,
private announcementEntityService: AnnouncementEntityService,
private roleService: RoleService,
) {
}

Expand All @@ -47,26 +53,48 @@ export class AnnouncementService {
.select('read.announcementId')
.where('read.userId = :userId', { userId: user.id });

const userRoles = await this.roleService.getUserRoles(user.id);

const announcementRolesQuery = this.announcementRolesRepository.createQueryBuilder('ar')
.select('ar.announcementId')
.where('ar.roleId IN (:...roles)', { roles: userRoles.map(x => x.id) });

const q = this.announcementsRepository.createQueryBuilder('announcement')
.where('announcement.isActive = true')
.andWhere('announcement.silence = false')
.andWhere(new Brackets(qb => {
qb.orWhere('announcement.userId = :userId', { userId: user.id });
qb.orWhere('announcement.userId IS NULL');
qb.orWhere(new Brackets(qb2 => {
qb2.orWhere('announcement.userId = :userId', { userId: user.id });
qb2.orWhere('announcement.userId IS NULL');
}));
qb.andWhere(new Brackets(qb2 => {
if (userRoles.length > 0) {
qb2.orWhere(new Brackets(qb3 => {
qb3.andWhere('announcement.isRoleSpecified = true');
qb3.andWhere(`announcement.id IN (${ announcementRolesQuery.getQuery() })`);
}));
}
qb2.orWhere('announcement.isRoleSpecified = false');
}));
}))
.andWhere(new Brackets(qb => {
qb.orWhere('announcement.forExistingUsers = false');
qb.orWhere('announcement.id > :userId', { userId: user.id });
}))
.andWhere(`announcement.id NOT IN (${ readsQuery.getQuery() })`);
.andWhere(`announcement.id NOT IN (${ readsQuery.getQuery() })`)
.setParameters({
...announcementRolesQuery.getParameters(),
...readsQuery.getParameters(),
userId: user.id,
});

q.setParameters(readsQuery.getParameters());

return q.getMany();
}

@bindThis
public async create(values: Partial<MiAnnouncement>, moderator?: MiUser): Promise<{ raw: MiAnnouncement; packed: Packed<'Announcement'> }> {
public async create(values: Partial<MiAnnouncement & { roleIds: MiRole['id'][] }>, moderator?: MiUser): Promise<{ raw: MiAnnouncement; packed: Packed<'Announcement'> }> {
const announcement = await this.announcementsRepository.insertOne({
id: this.idService.gen(),
updatedAt: null,
Expand All @@ -79,6 +107,7 @@ export class AnnouncementService {
silence: values.silence,
needConfirmationToRead: values.needConfirmationToRead,
userId: values.userId,
isRoleSpecified: values.isRoleSpecified ?? false,
});

const packed = await this.announcementEntityService.pack(announcement);
Expand All @@ -98,6 +127,33 @@ export class AnnouncementService {
userHost: user.host,
});
}
} else if (values.isRoleSpecified === true) {
if (values.roleIds == null) return { raw: announcement, packed: packed };

const roleIds = values.roleIds;

this.announcementRolesRepository.insert(roleIds.map(roleId => ({
id: this.idService.gen(),
announcementId: announcement.id,
roleId: roleId,
})).flat());

const users = new Set(...(await Promise.all(roleIds.map(async (roleId) => await this.roleService.getRoleUsers(roleId)).flat())));
users.forEach(async (user) => {
this.globalEventService.publishMainStream(user.id, 'announcementCreated', {
announcement: packed,
});
});

if (moderator) {
const roles = (await this.roleService.getRoles()).filter(role => roleIds.includes(role.id));
this.moderationLogService.log(moderator, 'createRolesAnnouncement', {
announcementId: announcement.id,
announcement: announcement,
roleIds: roleIds,
roles: roles,
});
}
} else {
this.globalEventService.publishBroadcastStream('announcementCreated', {
announcement: packed,
Expand All @@ -118,7 +174,7 @@ export class AnnouncementService {
}

@bindThis
public async update(announcement: MiAnnouncement, values: Partial<MiAnnouncement>, moderator?: MiUser): Promise<void> {
public async update(announcement: MiAnnouncement, values: Partial<MiAnnouncement & { roleIds: MiRole['id'][] }>, moderator?: MiUser): Promise<void> {
await this.announcementsRepository.update(announcement.id, {
updatedAt: new Date(),
title: values.title,
Expand All @@ -131,10 +187,46 @@ export class AnnouncementService {
silence: values.silence,
needConfirmationToRead: values.needConfirmationToRead,
isActive: values.isActive,
isRoleSpecified: values.isRoleSpecified,
});

const after = await this.announcementsRepository.findOneByOrFail({ id: announcement.id });

if (values.isRoleSpecified === true) {
const roleIds = values.roleIds ?? [];
const currentRoles = await this.announcementRolesRepository.findBy({
announcementId: announcement.id,
});

const removedRoles = currentRoles.filter(x => !roleIds.includes(x.roleId));
const addedRoles = roleIds.filter(x => !currentRoles.map(x => x.roleId).includes(x));

if (removedRoles.length > 0) {
await this.announcementRolesRepository.delete(removedRoles.map(x => x.id));
}
if (addedRoles.length > 0) {
await this.announcementRolesRepository.insert(addedRoles.map(roleId => ({
id: this.idService.gen(),
announcementId: announcement.id,
roleId: roleId,
})).flat());
}

if (moderator) {
const roleIds = values.roleIds ?? [];
const roles = (await this.roleService.getRoles()).filter(role => roleIds.includes(role.id));
this.moderationLogService.log(moderator, 'updateRolesAnnouncement', {
announcementId: announcement.id,
before: announcement,
after: after,
roleIds: roleIds,
roles: roles,
});
}

return;
}

if (moderator) {
if (announcement.userId) {
const user = await this.usersRepository.findOneByOrFail({ id: announcement.userId });
Expand All @@ -158,6 +250,7 @@ export class AnnouncementService {

@bindThis
public async delete(announcement: MiAnnouncement, moderator?: MiUser): Promise<void> {
const announcementRoles = await this.announcementRolesRepository.findBy({ announcementId: announcement.id });
await this.announcementsRepository.delete(announcement.id);

if (moderator) {
Expand All @@ -170,6 +263,14 @@ export class AnnouncementService {
userUsername: user.username,
userHost: user.host,
});
} else if (announcementRoles.length > 0) {
const roles = await this.roleService.getRoles();
this.moderationLogService.log(moderator, 'deleteRolesAnnouncement', {
announcementId: announcement.id,
announcement: announcement,
roleIds: announcementRoles.map(x => x.roleId),
roles: roles.filter(x => announcementRoles.map(x => x.roleId).includes(x.id)),
});
} else {
this.moderationLogService.log(moderator, 'deleteGlobalAnnouncement', {
announcementId: announcement.id,
Expand Down
51 changes: 50 additions & 1 deletion packages/backend/src/core/RoleService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { In } from 'typeorm';
import { In, IsNull } from 'typeorm';
import { ModuleRef } from '@nestjs/core';
import type {
MiRole,
Expand Down Expand Up @@ -93,6 +93,8 @@ export const DEFAULT_POLICIES: RolePolicies = {
export class RoleService implements OnApplicationShutdown, OnModuleInit {
private rolesCache: MemorySingleCache<MiRole[]>;
private roleAssignmentByUserIdCache: MemoryKVCache<MiRoleAssignment[]>;
private roleAssignmentByRoleIdCache: MemoryKVCache<MiRoleAssignment[]>;
private conditionalRoleUserIdsCache: MemoryKVCache<MiUser[]>;
private notificationService: NotificationService;

public static AlreadyAssignedError = class extends Error {};
Expand Down Expand Up @@ -131,6 +133,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {

this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60 * 1);
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 60 * 1);
this.roleAssignmentByRoleIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 60 * 1);
this.conditionalRoleUserIdsCache = new MemoryKVCache<MiUser[]>(1000 * 60 * 60 * 1);

this.redisForSub.on('message', this.onMessage);
}
Expand Down Expand Up @@ -180,6 +184,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
}
case 'userRoleAssigned': {
const cached = this.roleAssignmentByUserIdCache.get(body.userId);
const roleCached = this.roleAssignmentByRoleIdCache.get(body.roleId);
if (cached) {
cached.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
Expand All @@ -188,13 +193,25 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
role: null, // joinなカラムは通常取ってこないので
});
}
if (roleCached) {
roleCached.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
user: null, // joinなカラムは通常取ってこないので
role: null, // joinなカラムは通常取ってこないので
});
}
break;
}
case 'userRoleUnassigned': {
const cached = this.roleAssignmentByUserIdCache.get(body.userId);
const roleCached = this.roleAssignmentByRoleIdCache.get(body.roleId);
if (cached) {
this.roleAssignmentByUserIdCache.set(body.userId, cached.filter(x => x.id !== body.id));
}
if (roleCached) {
this.roleAssignmentByRoleIdCache.set(body.roleId, roleCached.filter(x => x.id !== body.id));
}
break;
}
default:
Expand Down Expand Up @@ -317,6 +334,38 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
return [...assignedRoles, ...matchedCondRoles];
}

@bindThis
public async getRoleAssigns(roleId: MiRole['id']) {
const now = Date.now();
let assigns = await this.roleAssignmentByRoleIdCache.fetch(roleId, () => this.roleAssignmentsRepository.findBy({ roleId }));
// 期限切れのロールを除外
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
return assigns;
}

@bindThis
public async getRoleUsers(roleId: MiRole['id']) : Promise<MiUser[]> {
const role = (await this.getRoles()).find(r => r.id === roleId);
if (role == null) return [];
const assigns = await this.getRoleAssigns(roleId);
const assignedUsers = (await Promise.all(assigns.map(async assign => {
const user = await this.cacheService.findUserById(assign.userId);
return user;
})));

const matchedCondUsers = role.target === 'conditional' ? await (async () => {
// このロールにマッチする条件を持ったユーザーを取得
return await this.conditionalRoleUserIdsCache.fetch(roleId, (async () => {
// TODO: 全件取得は重いので、条件に合致するユーザーを取得するようにする
// 現状はユーザー情報から判定しているため、ロール側からユーザーを取得するのが難しい
// せめてローカルユーザーのみを対象にするようにした
const users = (await this.usersRepository.findBy({ host: IsNull() })).filter((u) => this.evalCond(u, [role], role.condFormula));
return users;
}));
})() : [] as MiUser[];
return [...assignedUsers, ...matchedCondUsers];
}

/**
* 指定ユーザーのバッジロール一覧取得
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export class AnnouncementEntityService {
icon: announcement.icon,
display: announcement.display,
forYou: announcement.userId === me?.id,
forYourRoles: announcement.isRoleSpecified === true,
needConfirmationToRead: announcement.needConfirmationToRead,
silence: announcement.silence,
isRead: announcement.isRead !== null ? announcement.isRead : undefined,
Expand Down
Loading
Loading