summaryrefslogtreecommitdiff
path: root/packages/backend/src/core/UserFollowingService.ts
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2022-09-18 03:27:08 +0900
committerGitHub <noreply@github.com>2022-09-18 03:27:08 +0900
commitb75184ec8e3436200bacdcd832e3324702553d20 (patch)
tree8b7e316f29e95df921db57289c8b8da476d18f07 /packages/backend/src/core/UserFollowingService.ts
parentUpdate ROADMAP.md (diff)
downloadsharkey-b75184ec8e3436200bacdcd832e3324702553d20.tar.gz
sharkey-b75184ec8e3436200bacdcd832e3324702553d20.tar.bz2
sharkey-b75184ec8e3436200bacdcd832e3324702553d20.zip
なんかもうめっちゃ変えた
Diffstat (limited to 'packages/backend/src/core/UserFollowingService.ts')
-rw-r--r--packages/backend/src/core/UserFollowingService.ts574
1 files changed, 574 insertions, 0 deletions
diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts
new file mode 100644
index 0000000000..6aae5a6b99
--- /dev/null
+++ b/packages/backend/src/core/UserFollowingService.ts
@@ -0,0 +1,574 @@
+import { Inject, Injectable } from '@nestjs/common';
+import type { CacheableUser, ILocalUser, IRemoteUser, User } from '@/models/entities/User.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
+import { QueueService } from '@/core/QueueService.js';
+import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { IdService } from '@/core/IdService.js';
+import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
+import type { Packed } from '@/misc/schema.js';
+import InstanceChart from '@/core/chart/charts/instance.js';
+import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
+import { WebhookService } from '@/core/WebhookService.js';
+import { CreateNotificationService } from '@/core/CreateNotificationService.js';
+import { DI } from '@/di-symbols.js';
+import Logger from '../logger.js';
+import { UserEntityService } from './entities/UserEntityService.js';
+import { ApRendererService } from './remote/activitypub/ApRendererService.js';
+
+const logger = new Logger('following/create');
+
+type Local = ILocalUser | {
+ id: ILocalUser['id'];
+ host: ILocalUser['host'];
+ uri: ILocalUser['uri']
+};
+type Remote = IRemoteUser | {
+ id: IRemoteUser['id'];
+ host: IRemoteUser['host'];
+ uri: IRemoteUser['uri'];
+ inbox: IRemoteUser['inbox'];
+};
+type Both = Local | Remote;
+
+@Injectable()
+export class UserFollowingService {
+ constructor(
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.userProfilesRepository)
+ private userProfilesRepository: UserProfilesRepository,
+
+ @Inject(DI.followingsRepository)
+ private followingsRepository: FollowingsRepository,
+
+ @Inject(DI.followRequestsRepository)
+ private followRequestsRepository: FollowRequestsRepository,
+
+ @Inject(DI.blockingsRepository)
+ private blockingsRepository: BlockingsRepository,
+
+ @Inject(DI.instancesRepository)
+ private instancesRepository: InstancesRepository,
+
+ private userEntityService: UserEntityService,
+ private idService: IdService,
+ private queueService: QueueService,
+ private globalEventServie: GlobalEventService,
+ private createNotificationService: CreateNotificationService,
+ private federatedInstanceService: FederatedInstanceService,
+ private webhookService: WebhookService,
+ private apRendererService: ApRendererService,
+ private perUserFollowingChart: PerUserFollowingChart,
+ private instanceChart: InstanceChart,
+ ) {
+ }
+
+ public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string): Promise<void> {
+ const [follower, followee] = await Promise.all([
+ this.usersRepository.findOneByOrFail({ id: _follower.id }),
+ this.usersRepository.findOneByOrFail({ id: _followee.id }),
+ ]);
+
+ // check blocking
+ const [blocking, blocked] = await Promise.all([
+ this.blockingsRepository.findOneBy({
+ blockerId: follower.id,
+ blockeeId: followee.id,
+ }),
+ this.blockingsRepository.findOneBy({
+ blockerId: followee.id,
+ blockeeId: follower.id,
+ }),
+ ]);
+
+ if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocked) {
+ // リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, requestId), followee));
+ this.queueService.deliver(followee, content, follower.inbox);
+ return;
+ } else if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocking) {
+ // リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。
+ await this.blockingsRepository.delete(blocking.id);
+ } else {
+ // それ以外は単純に例外
+ if (blocking != null) throw new IdentifiableError('710e8fb0-b8c3-4922-be49-d5d93d8e6a6e', 'blocking');
+ if (blocked != null) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked');
+ }
+
+ const followeeProfile = await this.userProfilesRepository.findOneByOrFail({ userId: followee.id });
+
+ // フォロー対象が鍵アカウントである or
+ // フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or
+ // フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである
+ // 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく
+ if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee))) {
+ let autoAccept = false;
+
+ // 鍵アカウントであっても、既にフォローされていた場合はスルー
+ const following = await this.followingsRepository.findOneBy({
+ followerId: follower.id,
+ followeeId: followee.id,
+ });
+ if (following) {
+ autoAccept = true;
+ }
+
+ // フォローしているユーザーは自動承認オプション
+ if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) {
+ const followed = await this.followingsRepository.findOneBy({
+ followerId: followee.id,
+ followeeId: follower.id,
+ });
+
+ if (followed) autoAccept = true;
+ }
+
+ if (!autoAccept) {
+ await this.createFollowRequest(follower, followee, requestId);
+ return;
+ }
+ }
+
+ await this.#insertFollowingDoc(followee, follower);
+
+ if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee));
+ this.queueService.deliver(followee, content, follower.inbox);
+ }
+ }
+
+ async #insertFollowingDoc(
+ followee: {
+ id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']
+ },
+ follower: {
+ id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']
+ },
+ ): Promise<void> {
+ if (follower.id === followee.id) return;
+
+ let alreadyFollowed = false as boolean;
+
+ await this.followingsRepository.insert({
+ id: this.idService.genId(),
+ createdAt: new Date(),
+ followerId: follower.id,
+ followeeId: followee.id,
+
+ // 非正規化
+ followerHost: follower.host,
+ followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : null,
+ followerSharedInbox: this.userEntityService.isRemoteUser(follower) ? follower.sharedInbox : null,
+ followeeHost: followee.host,
+ followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : null,
+ followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : null,
+ }).catch(err => {
+ if (isDuplicateKeyValueError(err) && this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
+ logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`);
+ alreadyFollowed = true;
+ } else {
+ throw err;
+ }
+ });
+
+ const req = await this.followRequestsRepository.findOneBy({
+ followeeId: followee.id,
+ followerId: follower.id,
+ });
+
+ if (req) {
+ await this.followRequestsRepository.delete({
+ followeeId: followee.id,
+ followerId: follower.id,
+ });
+
+ // 通知を作成
+ this.createNotificationService.createNotification(follower.id, 'followRequestAccepted', {
+ notifierId: followee.id,
+ });
+ }
+
+ if (alreadyFollowed) return;
+
+ //#region Increment counts
+ await Promise.all([
+ this.usersRepository.increment({ id: follower.id }, 'followingCount', 1),
+ this.usersRepository.increment({ id: followee.id }, 'followersCount', 1),
+ ]);
+ //#endregion
+
+ //#region Update instance stats
+ if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
+ this.federatedInstanceService.registerOrFetchInstanceDoc(follower.host).then(i => {
+ this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
+ this.instanceChart.updateFollowing(i.host, true);
+ });
+ } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
+ this.federatedInstanceService.registerOrFetchInstanceDoc(followee.host).then(i => {
+ this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
+ this.instanceChart.updateFollowers(i.host, true);
+ });
+ }
+ //#endregion
+
+ this.perUserFollowingChart.update(follower, followee, true);
+
+ // Publish follow event
+ if (this.userEntityService.isLocalUser(follower)) {
+ this.userEntityService.pack(followee.id, follower, {
+ detail: true,
+ }).then(async packed => {
+ this.globalEventServie.publishUserEvent(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>);
+ this.globalEventServie.publishMainStream(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>);
+
+ const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow'));
+ for (const webhook of webhooks) {
+ this.queueService.webhookDeliver(webhook, 'follow', {
+ user: packed,
+ });
+ }
+ });
+ }
+
+ // Publish followed event
+ if (this.userEntityService.isLocalUser(followee)) {
+ this.userEntityService.pack(follower.id, followee).then(async packed => {
+ this.globalEventServie.publishMainStream(followee.id, 'followed', packed);
+
+ const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed'));
+ for (const webhook of webhooks) {
+ this.queueService.webhookDeliver(webhook, 'followed', {
+ user: packed,
+ });
+ }
+ });
+
+ // 通知を作成
+ this.createNotificationService.createNotification(followee.id, 'follow', {
+ notifierId: follower.id,
+ });
+ }
+ }
+
+ public async unfollow(
+ follower: {
+ id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
+ },
+ followee: {
+ id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
+ },
+ silent = false,
+ ): Promise<void> {
+ const following = await this.followingsRepository.findOneBy({
+ followerId: follower.id,
+ followeeId: followee.id,
+ });
+
+ if (following == null) {
+ logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした');
+ return;
+ }
+
+ await this.followingsRepository.delete(following.id);
+
+ this.#decrementFollowing(follower, followee);
+
+ // Publish unfollow event
+ if (!silent && this.userEntityService.isLocalUser(follower)) {
+ this.userEntityService.pack(followee.id, follower, {
+ detail: true,
+ }).then(async packed => {
+ this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed);
+ this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed);
+
+ const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
+ for (const webhook of webhooks) {
+ this.queueService.webhookDeliver(webhook, 'unfollow', {
+ user: packed,
+ });
+ }
+ });
+ }
+
+ if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
+ this.queueService.deliver(follower, content, followee.inbox);
+ }
+
+ if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) {
+ // local user has null host
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee));
+ this.queueService.deliver(followee, content, follower.inbox);
+ }
+ }
+
+ async #decrementFollowing(
+ follower: {id: User['id']; host: User['host']; },
+ followee: { id: User['id']; host: User['host']; },
+ ): Promise<void> {
+ //#region Decrement following / followers counts
+ await Promise.all([
+ this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1),
+ this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1),
+ ]);
+ //#endregion
+
+ //#region Update instance stats
+ if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
+ this.federatedInstanceService.registerOrFetchInstanceDoc(follower.host).then(i => {
+ this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
+ this.instanceChart.updateFollowing(i.host, false);
+ });
+ } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
+ this.federatedInstanceService.registerOrFetchInstanceDoc(followee.host).then(i => {
+ this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
+ this.instanceChart.updateFollowers(i.host, false);
+ });
+ }
+ //#endregion
+
+ this.perUserFollowingChart.update(follower, followee, false);
+ }
+
+ public async createFollowRequest(
+ follower: {
+ id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
+ },
+ followee: {
+ id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
+ },
+ requestId?: string,
+ ): Promise<void> {
+ if (follower.id === followee.id) return;
+
+ // check blocking
+ const [blocking, blocked] = await Promise.all([
+ this.blockingsRepository.findOneBy({
+ blockerId: follower.id,
+ blockeeId: followee.id,
+ }),
+ this.blockingsRepository.findOneBy({
+ blockerId: followee.id,
+ blockeeId: follower.id,
+ }),
+ ]);
+
+ if (blocking != null) throw new Error('blocking');
+ if (blocked != null) throw new Error('blocked');
+
+ const followRequest = await this.followRequestsRepository.insert({
+ id: this.idService.genId(),
+ createdAt: new Date(),
+ followerId: follower.id,
+ followeeId: followee.id,
+ requestId,
+
+ // 非正規化
+ followerHost: follower.host,
+ followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : undefined,
+ followerSharedInbox: this.userEntityService.isRemoteUser(follower) ? follower.sharedInbox : undefined,
+ followeeHost: followee.host,
+ followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : undefined,
+ followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : undefined,
+ }).then(x => this.followRequestsRepository.findOneByOrFail(x.identifiers[0]));
+
+ // Publish receiveRequest event
+ if (this.userEntityService.isLocalUser(followee)) {
+ this.userEntityService.pack(follower.id, followee).then(packed => this.globalEventServie.publishMainStream(followee.id, 'receiveFollowRequest', packed));
+
+ this.userEntityService.pack(followee.id, followee, {
+ detail: true,
+ }).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed));
+
+ // 通知を作成
+ this.createNotificationService.createNotification(followee.id, 'receiveFollowRequest', {
+ notifierId: follower.id,
+ followRequestId: followRequest.id,
+ });
+ }
+
+ if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderFollow(follower, followee));
+ this.queueService.deliver(follower, content, followee.inbox);
+ }
+ }
+
+ public async cancelFollowRequest(
+ followee: {
+ id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']
+ },
+ follower: {
+ id: User['id']; host: User['host']; uri: User['host']
+ },
+ ): Promise<void> {
+ if (this.userEntityService.isRemoteUser(followee)) {
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
+
+ if (this.userEntityService.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので
+ this.queueService.deliver(follower, content, followee.inbox);
+ }
+ }
+
+ const request = await this.followRequestsRepository.findOneBy({
+ followeeId: followee.id,
+ followerId: follower.id,
+ });
+
+ if (request == null) {
+ throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found');
+ }
+
+ await this.followRequestsRepository.delete({
+ followeeId: followee.id,
+ followerId: follower.id,
+ });
+
+ this.userEntityService.pack(followee.id, followee, {
+ detail: true,
+ }).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed));
+ }
+
+ public async acceptFollowRequest(
+ followee: {
+ id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
+ },
+ follower: CacheableUser,
+ ): Promise<void> {
+ const request = await this.followRequestsRepository.findOneBy({
+ followeeId: followee.id,
+ followerId: follower.id,
+ });
+
+ if (request == null) {
+ throw new IdentifiableError('8884c2dd-5795-4ac9-b27e-6a01d38190f9', 'No follow request.');
+ }
+
+ await this.#insertFollowingDoc(followee, follower);
+
+ if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee));
+ this.queueService.deliver(followee, content, follower.inbox);
+ }
+
+ this.userEntityService.pack(followee.id, followee, {
+ detail: true,
+ }).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed));
+ }
+
+ public async acceptAllFollowRequests(
+ user: {
+ id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
+ },
+ ): Promise<void> {
+ const requests = await this.followRequestsRepository.findBy({
+ followeeId: user.id,
+ });
+
+ for (const request of requests) {
+ const follower = await this.usersRepository.findOneByOrFail({ id: request.followerId });
+ this.acceptFollowRequest(user, follower);
+ }
+ }
+
+ /**
+ * API following/request/reject
+ */
+ public async rejectFollowRequest(user: Local, follower: Both): Promise<void> {
+ if (this.userEntityService.isRemoteUser(follower)) {
+ this.#deliverReject(user, follower);
+ }
+
+ await this.#removeFollowRequest(user, follower);
+
+ if (this.userEntityService.isLocalUser(follower)) {
+ this.#publishUnfollow(user, follower);
+ }
+ }
+
+ /**
+ * API following/reject
+ */
+ public async rejectFollow(user: Local, follower: Both): Promise<void> {
+ if (this.userEntityService.isRemoteUser(follower)) {
+ this.#deliverReject(user, follower);
+ }
+
+ await this.#removeFollow(user, follower);
+
+ if (this.userEntityService.isLocalUser(follower)) {
+ this.#publishUnfollow(user, follower);
+ }
+ }
+
+ /**
+ * AP Reject/Follow
+ */
+ public async remoteReject(actor: Remote, follower: Local): Promise<void> {
+ await this.#removeFollowRequest(actor, follower);
+ await this.#removeFollow(actor, follower);
+ this.#publishUnfollow(actor, follower);
+ }
+
+ /**
+ * Remove follow request record
+ */
+ async #removeFollowRequest(followee: Both, follower: Both): Promise<void> {
+ const request = await this.followRequestsRepository.findOneBy({
+ followeeId: followee.id,
+ followerId: follower.id,
+ });
+
+ if (!request) return;
+
+ await this.followRequestsRepository.delete(request.id);
+ }
+
+ /**
+ * Remove follow record
+ */
+ async #removeFollow(followee: Both, follower: Both): Promise<void> {
+ const following = await this.followingsRepository.findOneBy({
+ followeeId: followee.id,
+ followerId: follower.id,
+ });
+
+ if (!following) return;
+
+ await this.followingsRepository.delete(following.id);
+ this.#decrementFollowing(follower, followee);
+ }
+
+ /**
+ * Deliver Reject to remote
+ */
+ async #deliverReject(followee: Local, follower: Remote): Promise<void> {
+ const request = await this.followRequestsRepository.findOneBy({
+ followeeId: followee.id,
+ followerId: follower.id,
+ });
+
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request?.requestId ?? undefined), followee));
+ this.queueService.deliver(followee, content, follower.inbox);
+ }
+
+ /**
+ * Publish unfollow to local
+ */
+ async #publishUnfollow(followee: Both, follower: Local): Promise<void> {
+ const packedFollowee = await this.userEntityService.pack(followee.id, follower, {
+ detail: true,
+ });
+
+ this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packedFollowee);
+ this.globalEventServie.publishMainStream(follower.id, 'unfollow', packedFollowee);
+
+ const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
+ for (const webhook of webhooks) {
+ this.queueService.webhookDeliver(webhook, 'unfollow', {
+ user: packedFollowee,
+ });
+ }
+ }
+}