Misskeyに"特定の語録"を発言するとBANされる機能を追加してみた

はしめに

当記事は、東京大学の講義「大規模ソフトウェアを手探る」の提出物として執筆したものです。

拙い点や理解が不十分な点も多いですが、ご容赦ください。

 

また、当授業ではグループで課題に取り組んだため、同じチーム「オワコン人間8」のチームメイトであるharadyharaさんも類似の内容の記事を上げていると思いますので、そちらもぜひご覧ください。

 

 

 

 

 

 

 

こんにちは、tami0410です。

 

みなさんはX(旧Twittter)は使っていますでしょうか。

 

代表が変わって以降、さまざまな変更が行われ、そのたびに移住先のSNSを探している人を見かけるように思います。

そんな方々にオススメのSNSとして、Misskeyを提案したい。

 

Misskeyとは?

・主な機能はXと同じ。Xの"ツイート"の要領で"ポスト"を、"リツイート"の要領で"リポスト"ができる。

 

・Xの”いいねの代わりに"リアクション"があり、ハートマークだけでなく様々な絵文字をつけられる。

 

・「分散型SNS」であり、ユーザーは複数のサーバーから任意のサーバーに参加することができる。同じサーバー内のユーザーだけと交流することも、他のサーバーのユーザーと交流することもできる。

 

このように、機能だけを見るとXの上位互換のようにも思えるのですが、一つ重大な欠点を抱えています。

 

それは、「ユーザーの数と質」です。

 

そこで我々は、「他のSNSから移住したくなるようなキレイな環境を作れば、新規ユーザーもどんどん増えるのではないか」と考え、ユーザーの質の改善に役立つ機能の追加を目指しました。

 

実装した機能とデモンストレーション

実際に実装した機能は、「"特定の語録"を含む投稿をポストしようとすると特殊なエラーメッセージと効果音が再生され、またアカウントがBANされる」機能です。

 

実際のデモ動画を以下に掲載します。

https://youtu.be/L6iL3qjnlYI?si=sg3GjH7509Hp7USK

実装した手順

0.ビルドと実行

ビルドに関しては、以下のブログを大いに参考にしました。

zenn.dev

何度も起こった問題としては、


        docker compose up -d
      

を実行した際に、address already in useというエラーが出る不具合ですが、これはmisskeyフォルダ内の.config内で


        lsof -i :(該当ポート番号)
      

で動いているプロセス番号を特定し、killコマンドで終了する必要があります。

ただし、redisサーバーの方はkillしてもすぐに勝手に新しいプロセス番号で復活してくる?ようだったので、


         sudo kill -9 (プロセス番号)|sudo docker compose up -d
      

のように連続で実行することで解決しました。

 

また、実行時にはpnpm devの代わりにpnpm run startとしました。

 

1."特定の語録"を検出する

実際に実行して管理者権限でサーバーを建ててみたところ、どうやらmisskeyにはデフォルトで「禁止ワード機能」があるようです。

ただし、この「禁止ワード機能」はあくまでサーバー管理者が設定した禁止ワードを含む投稿をしようとするとエラーが出て投稿できないだけで、それ以上のペナルティはないようでした。

 

悪質なユーザーはこれに引っかかっても巧妙に言い換える、読点やスペースを挟む等の穴をつく形で投稿を試みる可能性があり、この機能だけで我々の目標を達成することはできないと判断しました。

 

しかしながらこれをベースに機能を追加していけば所望の機能を実装できるのではないかということで、まず「禁止ワードの検出を行う関数」をコード内から探すことにしました。

 

機能の追加のためにコードを手探る方法について、Webブラウザ上のアプリということもあり(自分が知っているような)デバッグツール等は使えなさそうでした。

 

そのため、バックエンド側で動作する機能については「ブラウザの開発者ツールを開き、該当の操作を行った際のサーバーからのレスポンスを見て、怪しいワードでコード内検索をかける」という方法を多用しました。(フロントエンドの場合は開発者ツールからAPIURLがわかるので、該当のAPIをコード内検索で確認しました。)

 

実際に禁止ワード機能の投稿を試みたときのレスポンスは次の通り。

禁止ワードは"prohibited words"として管理されていそうだということが分かったので、"prohibitedwords"でコード内検索をしたところ、NotesCreareService.tsというノートの作成を管理していそうなファイル内に、以下の"hasProhibitedWords"


         const hasProhibitedWords = this.checkProhibitedWordsContain({
			cw: data.cw,
			text: data.text,
			pollChoices: data.poll?.choices,
		}, this.meta.prohibitedWords);
      

と、以下の"chackProhibitedWordsContain"


        public checkProhibitedWordsContain(content: Parameters<UtilityService['concatNoteContentsForKeyWordCheck']>[0], prohibitedWords?: string[]) {
		if (prohibitedWords == null) {
			prohibitedWords = this.meta.prohibitedWords;
		}

		if (
			this.utilityService.isKeyWordIncluded(
				this.utilityService.concatNoteContentsForKeyWordCheck(content),
				prohibitedWords,
			)
		) {
			return true;
		}

		return false;
	}

      

を発見しました。

これに倣って、"hasINMwords"と"chackINMwordsContain"を作成します。

 

まずhasINMwordsは以下。


    const hasINMWords = this.checkINMWordsContain({
			cw: data.cw,
			text: data.text,
			pollChoices: data.poll?.choices,
		}, ["やりますねぇ", "やりませんねぇ"]);
      

ノート内のテキストデータと、"特定の語録"のリスト(以下、INMWordsと呼ぶ)をchackINMWordsContainに渡して照合させます。

禁止ワード機能の場合は禁止ワードをデータベースとして管理していましたが、データベース周りの知識に疎いこともあり、一旦はリストとして管理することにしました。

 

続いて、checkINMWordsContainは以下。


        public checkINMWordsContain(content: Parameters<UtilityService['concatNoteContentsForKeyWordCheck']>[0], iNMWords?: string[]) {
		if (iNMWords == null) {
			iNMWords = [];
		}

		if (
			this.utilityService.isKeyWordIncluded(
				this.utilityService.concatNoteContentsForKeyWordCheck(content),
				iNMWords,
			)
		) {
			return true;
		}

		return false;
	}
      

禁止ワード検出と同じ要領で、ポストの文字列とINMWordsをisKeyWordInclude関数に渡し、その判定結果に応じて真偽値をreturnします。

 

この2つの関数で、禁止ワード判定と同様にINMWords判定ができるようになります。

 

2.エラーコードをカスタマイズする

次に、特殊なエラーメッセージを表示する機能を実装します。

 

禁止ワードを投稿しようとした際のレスポンスからエラーIDが分かるので、エラーIDでコード内検索してみると、エラーの作成は複数の箇所にまたがっているようでした。

 

まず、ノート作成関数内で、禁止ワード判定の結果がTrueなら内部的なエラーを投げる部分が以下。(Notecreateservice.ts)


    if (hasProhibitedWords) {
			throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words');
		}
      

エラーをキャッチし、クライアントにエラーを返すAPIをリクエストする部分が以下。(create.ts)


    catch(e){
        if (e instanceof IdentifiableError) {
			if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
				throw new ApiError(meta.errors.containsProhibitedWords);
			} 
			...
	    }
	}
      

最終的に返ってくるエラーid,エラーメッセージが格納されている部分が以下。(create.ts)


    export const meta={
       ...
       errors: {
          ...
          containsProhibitedWords: {
			message: 'Cannot post because it contains prohibited words.',
			code: 'CONTAINS_PROHIBITED_WORDS',
			id: 'aa6e01d3-a85c-669d-758a-76aab43af334',
			}
		  ...

これに倣って、INMWordsが含まれていた場合に表示されるエラーを実装します。

 

まず、ノート作成関数内に内部的なエラーを投げる部分を作成。(Notecreateservice.ts)


    if (hasProhibitedWords) {
			throw new IdentifiableError('114514-1919-810-364364', 'Since you seem to know about Inmu, I\'ll add it to the Inmu list.');	
		}
      

クライアントにエラーを返すAPIをリクエストする部分を以下のように作成。(create.ts)


    catch(e){
        if (e instanceof IdentifiableError) {
           ... 
           else if (e.id === '114514-1919-810-364364'){
			    throw new ApiError(meta.errors.containsINMWords);
				}
		   ...
	   }
	}
      

表示されるエラーコードやエラーメッセージを以下のように定義。(create.ts)


    export const meta={
       ...
       errors: {
          ...
          containsINMWords: {
			message: 'Since you seem to know about Inmu, I\'ll add it to the Inmu list.',
			code: 'CONTAINS_INM_WORDS',
			id: '1145141919810',
		}
		  ...
      

これにより、INMWordsを含むポストを投稿しようとすると次のようなエラーが返ってくるようになりました。

 

3.自動BAN機能の実装

続いて、上のエラーが出ると同時にアカウントを凍結する機能を実装します。

 

まず、実際にユーザーを凍結してみると、suspend-userというAPIが呼び出されていることが分かりました。

 

suspendでコード内検索してみると、ユーザー状態に"isSuspended"なるステータスが存在し、これにより凍結済みか否かを管理していることが分かりました。

 

また、これを実際に切り替えてユーザーの凍結を実現しているとみられるsuspend関数が以下のように実装されていました。


    public async suspend(user: MiUser, moderator: MiUser): Promise {
		await this.usersRepository.update(user.id, {
			isSuspended: true,
		});

		this.moderationLogService.log(moderator, 'suspend', {
			userId: user.id,
			userUsername: user.username,
			userHost: user.host,
		});

		(async () => {
			await this.postSuspend(user).catch(e => {});
			await this.unFollowAll(user).catch(e => {});
		})();
	}
      

 

この関数をhasINMWordsがTrueの場合に呼び出すのが最初の実装方針でした。

 

しかしながら、この関数は引数にmoderatorを取っており、「管理者が(コントロールパネルから)あるユーザーをBANする」という方法でのBANしか想定しておらず、「BANを実行する人」が存在しない自動BANにおいてこの関数を呼び出すのは適切でないと判断しました。

 

最終的な実装としては、単純にhasINMWordsがTrueであれば、ユーザー情報を更新するUpdate関数を呼び出すことでポストしようとしたユーザーのisSusupendedをTrueにすることで自動BANを実装しました。


    if (hasINMWords) {
		this.usersRepository.update(user.id, {isSuspended:true,});
		throw new IdentifiableError('114514-1919-810-364364', 'Since you seem to know about Inmu, I\'ll add it to the Inmu list.');	
	    }
      

 

しかしながら、Misskeyで元々実装されているBAN機能はすべてサーバー側の機能であるにも関わらずクライアント側で勝手に状態を書き換えているため、サーバー側とクライアント側でいつ状態が同期されるのかが不明確で、実際にBANされてポストできなくなるまでにややタイムラグがあるという問題を抱えています。

 

一度ログアウトしてから再ログインしようとすると即座にBAN状態になっていることが分かりますが、ログアウトしなければしばらくの間はポストが可能であるため、これに関しては今後改善していく必要がありそうです。

 

4.管理者はBANされないようにする

ここまでの機能追加で無事「特定の語録」を投稿しようとしたものはBANされるようになり、平和なSNSが誕生したかのように思えました。

 

しかし、このサーバーで色々と遊んでいたところ、管理者アカウントで「特定の語録」を発してしまい、管理者アカウントがBANされるという事態に陥りました。

 

Misskeyのサーバーはデフォルトでは招待制になっていることと管理者しか招待コードを発行できないことにより、完全に誰も入れない禁足地になってしまった。

 

Misskeyにデフォルトで実装されているBAN機能は管理者が他のユーザーをBANする機能だけであり、管理者がBANされるというのはありえない状況であるので特にBANから保護する機能や、BANされてしまった場合に解除する方法もないので、データベースをいじる以外にこの状態を脱する方法はありませんでした。

 

こうなってしまうと厄介なので、管理者は「特定の語録」を投稿しようとしてもBANされないようにします。

 

コードとしては簡単で、isSuspended関数と同様に管理者かどうかを判定するisModerator関数があることが分かったので、この返り値がTrueの場合はエラーの発生とBANの実行を行わないようにしました。


    if (hasINMWords) {
		if (!(await this.roleService.isModerator(user as MiUser))) {
			this.usersRepository.update(user.id, {
				isSuspended:true,
			});
			throw new IdentifiableError('114514-1919-810-364364', 'Since you seem to know about Inmu, I\'ll add it to the Inmu list.');	
			}
	    }
      

 

Typescriptを初めて触るのではじめは分からなかったのですが、ifの条件式内でawaitを使わないと、isModeratorから返り値が戻ってくるのを待たずに条件判定が行われてしまい正しく判定が行えないため、条件式の前にawaitをつける必要があるようです。

 

これにより、管理者だけは「特定の語録」を発しても咎められない特権階級ということになりました。

 

5.効果音を鳴らす

最後にオマケ機能として、特定の語録をポストしようとしてエラーメッセージが出るときに警告音が鳴る機能を追加しました。

 

Misskeyではリアクションを押したときに効果音が鳴っており、これがどのように実現されているのかを探っていると、sound.Playmisskeysfx('reactinon')と呼び出されているplaymisskeysfxなる関数があり、これが効果音を鳴らしている関数であるようです。

 

playmisskeysfx()はフロントエンド側のファイル内で以下のように実装されており、例えばreactionを引数に渡すとdefaultstore内で定義されたsound_reactionで指定された音源が指定された音量でplayMisskeySfxFileinternal()によって再生されるようです。


    export function playMisskeySfx(operationType: OperationType) {
	const sound = defaultStore.state[`sound_${operationType}`];
	playMisskeySfxFile(sound).then((succeed) => {
		if (!succeed && sound.type === '_driveFile_') {
			// ドライブファイルが存在しない場合はデフォルトのサウンドを再生する
			const soundName = defaultStore.def[`sound_${operationType}`].default.type as Exclude<SoundType, '_driveFile_'>;
			if (_DEV_) console.log(`Failed to play sound: ${sound.fileUrl}, so play default sound: ${soundName}`);
			playMisskeySfxFileInternal({
				type: soundName,
				volume: sound.volume,
			});
		}
	});
}
      

 

他の音源に倣って、store.ts内のdefaultstore音源'sound.inmu'を追加します。


    sound_inmu: {
		where: 'device',
		default: { type: 'syuilo/dededon', volume: 11.4514 } as SoundStore,
	}
      

 

さて、これをバックエンドのNotesCreateService()内で、hasINMWordsがtrueの時に呼び出せば良いと考えたのですが、フロントエンド用に実装された関数をバックエンド側でインポートして呼び出そうとするとエラーが発生するため、この方針では実装ができないということが分かりました。

 

フロントエンド側で、hasINMWordsの場合にのみ効果音を鳴らすべく、ノートを投稿する際に呼び出されるAPIを開発者ツールから調べることにしました。

 

呼び出されるAPIはnotes/createであり、これをフロントエンドから呼び出しているコードをMkpostform内に発見しました。

 

このファイル内に、バックエンドから投げられたエラーをキャッチしてエラーメッセージの表示等を行っているコードを見つけました。

 

ここにエラーコードが1145141919810のエラーをキャッチした際、PlayMisskeySfx()を以下のように呼び出すことで、無事inmu_soundを鳴らすことに成功しました。


    misskeyApi('notes/create', postData, token).then(() => {
        ...
        }).catch(err => {
		posting.value = false;
		if ((err as any).id === '1145141919810') {
			sound.playMisskeySfx('inmu');
		}
	    ...
      

 

 

今後の展望と感想

いかかでしたか?

今後の展望としては、

・「特定の語録」をDBで管理すること

 

・危険なユーザーの処置に関して、BANする以外にも様々なオプションを用意する

コード中に「アイコン変更を許可するか否か」を管理するステータスがあったので、語録を含むポストを検知するとこれをfalseにした上でプログラムからアイコンを変更することで、過去に語録を含むポストをしようとした危険ユーザーを一目で見分けることができるようにするなど...

 

さて、感想としましては、MIsskey自体のコードがおそらくかなり上手く機能が関数に分割されていたり、読みやすいコードになっていたおかげで、全容を把握しているとは言い難い自分でも機能を追加することができ、また機能を追加する際もほとんど既存の関数を組み合わせるだけで可能で、とてもありがたく感じました。

 

同時に、このようにオープンソースのソフトウェアで多くの人が開発に関わるような大規模なものの場合は、読みやすく適切に機能を分割するべきだというのを、このコードを1つのお手本として意識すべきだと感じました。

 

以上になります。最後まで読んでいただいただき、ありがとうございました。

Â