🤖

Discordで管理者以外が招待リンクを貼ると例外なく削除してくれるボットを作った

2024/12/21に公開2

Discordで管理者以外が招待リンクを貼ると例外なく削除してくれるボットを作った

ある時、自分は思いました。 荒らし対策ボットの招待リンクブロックって糞じゃね? と...

短縮URLだと送信できてしまったり、全角だと送信できてしまったり、色々抜け穴を発見しました。

既存のボットを探すのもいいですが、自分は面倒くさがりなので、作ってみました。
ちなみにOSSです。この解説で満足できない方は是非コードを見てください。
追加機能の案がある方はPull Requestやissueにて教えてください。

https://github.com/ROBOTofficial/DiscordInviteLinkBlocker

Step1: 技術選定

色々悩みましたが今回は以下のような技術構成でいこうと思います。

  • Bun
  • ES Module
  • Discord.js
  • Prisma Client

自分が無難に作りやすい構成を選んだって感じです。
ここに関しては後々変更するかもしれません。

Step2: 基礎作り

※clientの宣言などまで解説流石に面倒くさいので今回は省略させていただきます。主要な部分のみの解説です。

まずはメッセージ検知のため以下のようなコードを書きました。

this.client.on("messageCreate", async message => {
    if (!message.member) {
        return;
    }
    if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
        //ここにコードを書く
    }
});

この部分で管理者権限を持っていないユーザーと持っているユーザーをふるいにかけます。

Step3: 短縮URLを粉砕

ここでは正規表現でURLを検知した後、それがDiscordの招待リンクかどうかを見極める関数について書いていきます。

export async function inviteLinkChecker(url: string) {
    const response = await fetch(url, {
        method: "GET",
        redirect: "follow",
    });
    return response.url.startsWith("https://discord.com/invite/");
}

nodeの標準apiであるfetchはredirect: "follow"にするとリダイレクト先がurlに代入されます。
あとはそれが https://discord.com/invite/ で始まるかどうかを検知すれば完了です。

余談ですが、discord.ggなどの招待リンクも最終的には https://discord.com/invite/ にリダイレクトされます。

Step4: 全角文字を粉砕

Step4とStep5はDiscordの特殊英数字を全て通常の英数字に変換するという仕様に対応するといった作業です。

export function toHalfWidth(content: string) {
    return content.replace(/[---]/g, function(s) {
        return String.fromCharCode(s.charCodeAt(0) - 0xFEE0);
    });
}

これに関しては全角文字の文字コードから0xFEE0(10進数だと65248)を引くと半角になるという有名しようがあるので、それで実装したといった具合です。

Step5: 特殊文字を粉砕

突然で申し訳ないのですが、実はこの部分を作ったのは自分ではなくてyuzuさんです。

なので自分でもあまり理解していない部分があります。

export class SpecialChar {
	protected static exceptionChars: Record<string, string> = {: "B",: "C",: "C",: "E",: "F",: "H",: "H",: "H",: "J",: "J",: "L",: "M",: "N",: "P",: "Q",: "R",: "R",: "R",: "Z",: "Z",: "e",: "g",: "h",: "o",: "A",: "B",: "D",: "E",: "G",: "H",: "I",: "J",: "K",: "L",: "M",: "N",: "O",: "P",
		ᴿ: "R",: "T",: "U",: "W",: "a",: "b",: "d",: "e",: "g",: "k",: "m",: "o",: "p",: "t",: "u",: "v",: "i",: "r",: "u",: "v",
	};
	protected static specialChar2ASCII = (char: string): string => {
		let a: number | undefined = char.codePointAt(0);
		if (a == null) {
			return "";
		}
		// 例外文字の対応
		if (char in this.exceptionChars) {
			return this.exceptionChars[char];
		}
		// 全角文字対応
		if (0xff21 <= a && a <= 0xff5a) {
			a -= 0xfee0;
		}
		// 数学用英数字記号対応 英文字
		if (0x1d400 <= a && a <= 0x1d6a3) {
			a -= 0x1d400;
			a %= 2 * 26;
			if (a >= 26) {
				a += 6;
			}
			a += 0x41;
		}
		// 数学用英数字記号対応 数字
		if (0x1d7ce <= a && a <= 0x1d7ff) {
			a -= 0x1d7ce;
			a %= 10;
			a += 0x30;
		}
		return String.fromCharCode(a);
	};

	public static specialChars2ASCII = (chars: string): string => {
		let charList = [...chars];
		return charList
			.map((char: string) => this.specialChar2ASCII(char))
			.join("");
	};
}

例外文字の対応、全角文字対応の二つに関しては十分理解していますが、数学用英数字記号対応 英文字と数学用英数字記号対応 数字の二つに関しては本当によく分かりません。
恐らく特殊文字と英数字の文字コードの差分の計算だと思います。
詳しい話はyuzuにお願いします。

おまけ: 正規表現

今回作成したボットでは以下のような正規表現を用いて文章からURLだけを抜き出しています。

const URL_REGEXP = /^https?:\/\/[\w\/:%#\$&\?\(\)~\.=\+\-]+$/gim;

const URL_REGEXP_NO_HTTP = /(?:https?:\/\/)?(?:discord).*gg.*([a-zA-Z0-9_-]+)/gim;

const URL_REGEXP_INVITE_NO_HTTP = /(?:https?:\/\/)?(?:discord)\.(?:[a-z]{2,6})\/?.*invite.*([a-zA-Z0-9_-]+)\b|(?:https?:\/\/)?(?:discordapp)\.(?:[a-z]{2,6})\/?.*invite.*([a-zA-Z0-9_-]+)/gim;

const regExp = new RegExp(
	URL_REGEXP.source 
    + URL_REGEXP_NO_HTTP.source
    + URL_REGEXP_INVITE_NO_HTTP.source,
	"gmi"
);

完成品

checkコマンドというURLが含まれるかどうかのテストのためのコマンドを作っておいたので、それで実験した結果がこちらです。

この通り、一見招待リンクに見えないようなものでもちゃんと検知してくれます。
※ちなみに上記の二つは招待リンクとして機能します。

最後に

今回は自分の記事を見ていただきありがとうございます。
ロールごとの権限設定など、まだまだ実装できてない部分が多いように感じます。
必要な機能等ある場合はGitHubのissueやPull Requestで伝えていただけると幸いです。

今回作ったBotのGitHub Repositoryや自分のXなども確認していただけると幸いです。

https://x.com/AlwaysHarapan

https://github.com/ROBOTofficial/DiscordInviteLinkBlocker

Discussion

yuzuyuzu

Step5 の製作者です。一応コードの解説を載せておきます。
Discordの仕様上数学用英数字記号と音声記号拡張はその文字が表すASCII文字に変換されます。
そのため、その文字をbot側でどうにかしてASCIIに落とし込みたい訳です。
String.codePointAt(0);によって1文字目のUTF-16が得られる(真偽不明)のでその値を演算用として使用します。
exceptionCharsは音声記号拡張と数学用英数字記号の一部文字が収録されています。なぜか数学用英数字記号の一部文字は法則性に従わないので、そのリストを用意しました。ここは根性です。
数学用英数字記号の英文字はU+1D400からU+1D6A3までで、A-Za-zA-Za-z...という風に並んでいるため最初の0x1D400を引いて26 * 2を法としてmodを取ってやるとA-Za-zの内、何番目かが得られます。
しかし、ASCIIコードは製作者が何を考えていたのかは知りませんがZaの間に6個文字が入っているため、小文字の場合6を加算しておきます。
それにAのASCIIコード0x41を足してやると無事に変換できました。
数字はこれとやっていることはほぼ同じです。詳しくは以下のWikipediaを参照してください。
数学用英数字記号
音声記号拡張
そしてUnicode5桁文字はfor of構文を使うとものごくバグり散らかしてくれるのでスプレッド構文で配列にしてからmap関数=>join関数で文字列に落としています。
この部分は本体同様MITライセンスで提供しています。ご自由にお使いください。ソースコードに製作者表記があると嬉しいです。