LoginSignup
6
1

More than 1 year has passed since last update.

はらちょ(ネタBot)に詰め込まれたすごい技術

Last updated at Posted at 2022-12-04

この記事は限界開発鯖 Advent Calendar 2022 の 5 日目です. 25 面ダイスを振って決めました.

はらちょとは

はらちょは 2020 年 2 月ころ (サーバーが発足してすぐ) に限界開発鯖で開発が始まった Bot です. アプリケーションとしての正式名称は OreOreBot です. はらちょという名前は, サーバー管理者のいっそうが好きな響 (艦これ) の「ハラショー」が元なのですが, この Bot に響らしい要素はあまりありませんでした. あまり響らしさを感じさせない機能などの Issue には need more hibikiness のタグが付けられたりしていました. 当初はサーバーのメンバーにより Python と discord.py を用いて継ぎ足しながら開発されていましたが, 2021 年になってくると開発が停滞していました. あまりにもコードの見通しが悪いために Go 言語での再実装も計画されていましたが, 誰もやりたがらなかったため頓挫しています.

2021 年の 12 月に discord.py の開発停止が発表されたり, Discord API v6-7 の廃止ではらちょが動作しなくなる懸念があったりしたため, いよいよリプレースする必要がありました (通称: 初代はらちょぶっ殺しプロジェクト). そこで 2022 年 1 月ころに TypeScript と dicsord.js を用いた作り直しが始まり, 開発途中でとあるメンバーが間違って はらちょ Bot をキックしてしまうなどのきっかけもあって勢いよく開発が進みました. 2022 年 2 月 27 日には元の OreOreBot に存在していた機能のほとんどを移植した OreOreBot2 が完成しました.

このプロジェクトのメイン開発者は @m2en ですが, システムの設計や改修しやすくなるリファクタ, コード品質の向上などを私が積極的に働きかけています. そのため, 現状は私が OreOreBot2 の仕組みについて一番詳しくなっています. あまり良くないことではありますが, @m2en はまだプログラミングを勉強している身ですのでご容赦ください.

さて, この OreOreBot2 の仕組みや設計, ソースコードのいじり方についてサーバー内で割と聞かれることがあったので, ドキュメント化も兼ねてこの記事を書くことにしました.

前提知識

  • TypeScript の型システムの基礎
  • discord.js のチュートリアルレベル
  • 依存性注入の概念

基本設計

まず, 前回の OreOreBot の開発経験からプロジェクトの性質を洗い出しました. すると,

  • 長い期間, 継ぎ足しながら開発される
  • 単体では小さな機能が多い
  • 普段は開発に関わらないメンバーでも新機能を実装しながら提案したい
  • 実行形態が即時のものや, 一定時間後に起動するものがあるためそれらを管理したい
  • discord.js などのパッケージの破壊的変更にすぐ対応したい

といった性質が見られました. そこで, これらに柔軟に対応できるようなモジュール構成を考案し, モジュールごとに責務を分割することにしました. 実行形態が複数あることも加味した結果, 以下のように既存のソフトウェアアーキテクチャ (DDD, Clean Architecture など) とは異なるオリジナルな構成になりました. ちなみに私はこのソフトウェアアーキテクチャで卒業論文を書きました.

ここで, 実線矢印 (ー>) は通常の依存を示しており, 白抜き矢印 (ー▷) はそれよりも強い「そのパッケージの interface を実装する」依存を示しています. 点線矢印 (‥>) はそれよりも弱い「interface を実装しているオブジェクトを作成する」依存を示しています.

Model にはシステム全体で用いられるデータ構造や, 制約が追加されたデータ型などを定義します. このシステムでは「なんらかの ID」,「タイムスタンプ」,「Embed メッセージ」,「コマンド実行の予約」,「コマンドのスキーマ」などが定義されています. 外的要因に依存することは許されませんが, なんらかのプロトコルに依存してそのシリアライズ/デシリアライズ手段を提供することはできます.

Runner では機能に対する実行形態そのものを interface として定義します. また, その実行形態の機能を実際に実行するためのクラスも定義します. このシステムでは「メッセージに対する反応」,「コマンド発行に対する反応」,「特定の時間に実行」,「ロールの追加に対する反応」などが定義されています.

Service では Runner の実行形態に基づいて機能を実現します. ただし, このモジュールのコードが直接ネットワーク, 乱数, DB などありとあらゆる外的要因に依存することは許されません. 代わりに, そういった外的要因を表す interface を併せて定義し, それらを実装したものを Server に注入してもらう (具体的にはコンストラクタなどで受け取る) ことでこれを解決します. いわゆる依存性の注入のテクニックです.

Adaptor では Service が定義した外的要因の interface について, 実際に外部の API やライブラリを用いて implements で実装します. また, テストで利用できるモック用の実装なども提供しています. これにより, OreOreBot2 は Discord.js に破壊的変更があってもソースコードにほとんど衝撃を与えず, この実装を書き換えるだけで対応できます.

Server では, Runner に Service を注入し, Service に Adaptor を注入して, システム同士を結合し稼働させます. また環境変数を読み取って動作の変更を行ったりします.


大まかには以上のような役割分担となっています. ここからは, 各モジュールの詳細な工夫について解説していきます.

Model

OreOreBot2 はバラバラな機能の集合体です. 一見してシステム全体で使うようなデータ構造は無さそうですが, それでも定義しておいたほうがプログラム上のミスを減らしたり実装ミスをテストで検知したりする恩恵が得られるものが多くあります.

Snowflake 公称型

メンバー ID やチャンネル ID などは discord.js だと string として表現されます. 実際の Discord API は Snowflake と呼ばれる 64 ビット整数ですが, JavaScript ランタイムでは気軽に扱える 64 ビット精度の組み込み数値型が無い (number は倍精度浮動小数点数なので精度不足, BigInt は目的にそぐわない) ので文字列となっているようです. しかし, これは一般的な文章などを入れる文字列と違って特別な意味を持ちます. そのため通常の string と混同してしまうプログラムミスが容易に発生します. そこで, OreOreBot2 では Snowflake を表すために Snowflake という 公称型 を以下のように定義しています.

declare const snowflakeNominal: unique symbol;
export type Snowflake = string & { [snowflakeNominal]: never };

公称型については Qiita 上にもいくつか有用な記事がありますが, 一応解説しておきます. TypeScript は構造的部分型による型付け戦略を取っており, オブジェクトどうしが同じキーの構造であれば, その型どうしに概念上の関係が無くても型強制 (暗黙的型変換) できます.

interface Hoge {
  foo: 'bar';
}
interface Fuga {
  foo: 'bar';
}

const a: Hoge = { foo: 'bar' };
const b: Fuga = a; // ok

const c: Fuga = { foo: 'bar' };
const d: Hoge = c; // ok

一方, TypeScript はアクセスできないようなキーを持つオブジェクト同士の型強制を行いません. これを利用しつつ, declare const ...: unique symbol による架空の Symbol 定数の宣言を組み合わせることで冒頭の定義が完成します.

string から Snowflake のオブジェクトを得るには, as で明示的に変換する必要があります. 逆に言えばそれだけなのですが, 型強制による意図しない動作を回避できるのでかな〜りありがたいです. 間違った使い方をしているかどうかも, as Snowflake で検索すればすぐに分かります.

// 欲しくなったら `as` すればいい
{
  senderId: message.author.id as Snowflake,
  senderGuildId: message.guildId as Snowflake,
  senderChannelId: message.channelId as Snowflake,
  // ...
}

MemeTemplate スキーマ

はらちょでごく稀に使われる機能に, 文字列を入力してインターネットミームの構文に挿入するものがあります. 大抵は大喜利に使われる程度です.

多彩なコマンドラインオプションに対応しつつ柔軟に ノリと勢いとイキリで 開発できるよう, フラグやオプション, エラーメッセージ, 構文生成関数などを纏めたスキーマを定義することで追加できるように設計されています.

export interface ParsedArgs<
  FLAGS_KEY extends string,
  OPTIONS_KEY extends string
> {
  flags: Record<FLAGS_KEY, boolean | undefined>; // それぞれの FLAGS_KEY が指定されたかどうか
  options: Record<OPTIONS_KEY, string | undefined>; // それぞれの OPTIONS_KEY に指定された値
  body: string; // フラグやオプションではない残りの引数
}

export interface MemeTemplate<
  FLAGS_KEY extends string,
  OPTIONS_KEY extends string
> {
  // 対応しているコマンド名のリスト
  commandNames: readonly string[];
  // `--help` オプションを与えた時に表示される説明
  description: string;
  // フラグとなるキーのリスト
  flagsKeys: readonly FLAGS_KEY[];
  // オプションとなるキーのリスト
  optionsKeys: readonly OPTIONS_KEY[];
  // 無関係なフラグ/オプションがあったときに表示されるエラーメッセージ
  errorMessage: string;
  // - args: 上で定義してあるパース結果
  // - author: コマンドの発行者 (構文によってはこれも利用します)
  generate(args: ParsedArgs<FLAGS_KEY, OPTIONS_KEY>, author: string): string;
}

例えば, 例の いちばんやさしい教本 の構文は以下のように実装されています. これはフラグもオプションも無いシンプルなタイプです.

import type { MemeTemplate } from '../../../model/meme-template.js';

export const web3: MemeTemplate<never, never> = {
  commandNames: ['web3'],
  description: '「いちばんやさしい〇〇の教本」',
  flagsKeys: [],
  optionsKeys: [],
  errorMessage: 'TCP/IP、SMTP、HTTPはGoogleやAmazonに独占されています。',
  generate(args) {
    const we3Meme = `「いちばんやさしい${args.body}の教本」 - インプレス `;
    return '```\n' + we3Meme + '\n```';
  }
};

他の例だと, 惨めな学生らしさを感じさせるタコピー構文は以下のように実装されています. フラグとして -f を受け付けるようになっています.

import type { MemeTemplate } from '../../../model/meme-template.js';

const takopiFlags = ['f'] as const;

export const takopi: MemeTemplate<typeof takopiFlags[number], never> = {
  commandNames: ['takopi'],
  description:
    '「〜、出して」\n`-f` で教員と自分の名前の位置を反対にします。([idea: フライさん](https://github.com/approvers/OreOreBot2/issues/90))',
  flagsKeys: takopiFlags,
  optionsKeys: [],
  errorMessage: '(引数が)わ、わかんないっピ.......',
  generate(args, author) {
    if (args.flags.f)
      return `${author}${args.body}、出して」\n教員「わ、わかんないっピ.......」`;
    return `教員「${args.body}、出して」\n${author}「わ、わかんないっピ.......」`;
  }
};

いずれも流行り廃りが激しく, あまり響らしさを感じさせないのが悩み所です.

CommandSchema スキーマ

OreOreBot2 の多くは, ユーザが発行するコマンドに応答する機能となっています. Discord API には Application Commands という機能及びコマンドの定義がありますが, これを参考にしつつ Discord API の機能に依存しない (破壊的変更に対処できる) ものとして再定義しています. なお, サーバーのメンバーの多くが Application Commands を正常に使用できないという原因不明の重要な課題に直面しているため, そもそも Application Commands を利用せずに従来のメッセージに直接返信する方式でうまく実装しています (詳細は Adaptor にて).

スキーマそのものの型定義と, それが実際にパースされた後のオブジェクトの型定義を定義してあるのですが, 非常に巨大かつドキュメントコメントを付けてありますので, ここではコードを挙げずに 定義しているファイルの permalink を載せておきます.

この定義があることにより, メッセージの文字列を Service 側で直接パースしなくても必要な整数や選択肢を抽出でき, 不正な入力に対するエラーをコマンドパーサ側に任せられます.

例えば, 現在実行中のバージョンを表示するコマンド version のスキーマは以下のように定義されています. サブコマンドも引数も無いシンプルなコマンドです.

const SCHEMA = {
  names: ['version'],
  subCommands: {}
} as const;

他には, Kaere一葉 (深夜までボイスチャットしているメンバーに就寝を促す機能) のスキーマが以下のように定義されています. サブコマンドが多く, 時分のスキーマ部分を使いまわしているのが特徴です.

const TIME_OPTIONS = [
  {
    type: 'STRING',
    name: '時刻',
    description: '[HH]:[MM] 形式の時刻'
  }
] as const;

const SCHEMA = {
  names: ['kaere'],
  subCommands: {
    bed: {
      type: 'SUB_COMMAND_GROUP',
      subCommands: {
        enable: {
          type: 'SUB_COMMAND'
        },
        disable: {
          type: 'SUB_COMMAND'
        },
        status: {
          type: 'SUB_COMMAND'
        }
      }
    },
    reserve: {
      type: 'SUB_COMMAND_GROUP',
      subCommands: {
        add: {
          type: 'SUB_COMMAND',
          params: TIME_OPTIONS
        },
        cancel: {
          type: 'SUB_COMMAND',
          params: TIME_OPTIONS
        },
        list: {
          type: 'SUB_COMMAND'
        }
      }
    }
  }
} as const;

このスキーマを元に, コマンドに応答するクラスを実装できるようになっています (Service を参照).

Runner

OreOreBot2 には様々な実行形態を持つ機能が存在します. 機能によってはコマンドと指定時間それぞれで動作するものや, コマンドとコマンドでない通常メッセージに反応するもの, 特殊なイベントにしか反応しないもの, 他に実行できる機能を列挙する機能などがあります. これらの実行状態を制御したり管理するために, 実行形態ごとの ランナー を作成しています.

このモジュールのコードは, 外的要因に依存することができませんが, ランタイムや OS のなんらかのタスク管理 (非同期ランタイムなど, ここでは Promise) に依存することはできます. そのレベルで変更が起こるとそもそもアプリケーションとしての存在が危ぶまれるので, そのくらいは許容しています.

ScheduleRunner

特定の時間に応じた機能を実行するランナーとして, ScheduleRunner クラスを作成しています. これは, 指定ミリ秒後に実行するタスクを追加したり, 特定の時刻に実行するタスクを追加したりする機能を持っています.

runner.runAfter("hoge", () => {
  console.log("hello");
}, 1000);
runner.runOnNextTime("fuga", () => {
  console.log("hi!");
}, new Date(2045, 0, 1, 0, 0, 0));

// 登録したタスクを強制中断. GENOCIDE!
runner.killAll();

このクラスのコンストラクタでは, 何秒経過したのかを確認するための時計 interface Clock { now(): Date; } を受け取ります. そのため, モック用の自在に動かせる時計を渡せばテストでも使用できます. 以下は実際に用意してある Clock の実装 (Adaptor のコード) です.

export class MockClock implements Clock {
  constructor(public placeholder: Date) {}

  now(): Date {
    return this.placeholder;
  }
}

export class ActualClock implements Clock {
  now(): Date {
    return new Date();
  }
}

RoleResponseRunner などのイベントに対して応答するランナー

弊 Discord サーバー上では, すべてのユーザーがロールの追加, 絵文字の追加, メッセージの削除などを自由に行っていて非常にカオスです. そういったイベントを検知して知らせる機能などの需要があるため, イベントに応答する という実行形態ごとのランナーが存在しています. 以下は, ロールの追加/更新に反応するための RoleResponseRunner の実装です. 実際に Service のクラスへと運ばれるロールの型は, 型引数 R を使ってジェネリックにしてあります. RoleResponseRunnertriggerEvent を Adaptor のコードが呼び出すことで, これに登録されたレスポンダーが起動されます.

export type RoleEvent = 'CREATE' | 'UPDATE';

export interface RoleEventResponder<R> {
  on(event: RoleEvent, role: R): Promise<void>;
}

export const composeRoleEventResponders = <R>(
  ...responders: readonly RoleEventResponder<R>[]
): RoleEventResponder<R> => ({
  async on(event, role) {
    await Promise.all(responders.map((responder) => responder.on(event, role)));
  }
});

export class RoleResponseRunner<R> {
  async triggerEvent(event: RoleEvent, role: R): Promise<void> {
    try {
      await Promise.all(this.responder.map((res) => res.on(event, role)));
    } catch (e) {
      console.error(e);
    }
  }

  private responder: RoleEventResponder<R>[] = [];

  addResponder(responder: RoleEventResponder<R>) {
    this.responder.push(responder);
  }
}

他にも同様の実装で, EmojiResponseRunner, VoiceRoomResponseRunner, MessageResponseRunner, CommandRunner などを用意しています.

Service

OreOreBot2 は,「これいる?」と感じる謎の機能からサーバーで生活するうえでありがたい機能まで, いろいろ雑多な機能を取り揃えています. 非常に数が多いので, ここではそれなりに工夫を凝らした機能をご紹介します.

HelpCommand

OreOreBot2 のコマンド機能は CommandResponderimplements することで実装できます. この CommandResponderhelp: Readonly<HelpInfo> フィールドを定義することを要求するようになっているのですが, これはこの HelpCommand で実現するヘルプ機能のためのものです.

HelpCommand クラスは, コンストラクタで他のコマンドが (もちろん自身も) 登録されている CommandRunner を受け取ります. CommandRunner には登録されているレスポンダーのリストを返す機能を付けてあるので, これを使ってヘルプを全部読み取り, ヘルプとなる Embed メッセージのページを自動生成しています.

const SCHEMA = {
  names: ['help', 'h'],
  subCommands: {}
} as const;

export class HelpCommand implements CommandResponder<typeof SCHEMA> {
  help: Readonly<HelpInfo> = {
    title: 'はらちょヘルプ',
    description: 'こんな機能が搭載されてるよ'
  };
  readonly schema = SCHEMA;

  constructor(private readonly runner: CommandRunner) {}

  async on(message: CommandMessage<typeof SCHEMA>): Promise<void> {
    const helpAndSchema = this.runner
      .getResponders()
      .map((responder) => ({ ...responder.help, ...responder.schema }));
    const pages: EmbedPage[] = helpAndSchema.map((helpScheme) =>
      this.buildField(helpScheme)
    );
    await message.replyPages(pages);
  }

  // buildField の実装は省略
}

TypoRecorder / TypoReporter

弊サーバーには, 自分の愚かな打ち間違いの発言を「〇〇だカス」といって自尊心を踏みにじるように訂正することがあります (諸説アリ). これを利用して, 「だカス」で終わるメッセージを Typo の記録として残そうとする機能が OreOreBot にはありました.

あまり使われている機能ではありませんが, OreOreBot2 にもこの機能は移植されました. 移植にあたって, この機能は記録係である TypoRecorder と, その日の Typo を表示するコマンド係である TypoReporter の 2 つのクラスに分割されました (2 つの実装は同じファイルに記述されています). 機能の実現のために TypoRepository も定義して Adaptor にデータベース (現状はインメモリデータベース) 実装を用意している数少ない機能でもあります.

export interface TypoRepository {
  // `id` に対応した新しい Typo を追加する。
  addTypo(id: Snowflake, newTypo: string): Promise<void>;

  // `id` に対応した Typo 一覧を日時順で取得する。
  allTyposByDate(id: Snowflake): Promise<readonly string[]>;

  // Typo をすべて消去する。
  clear(): Promise<void>;
}

TypoRecorder はメッセージへ応答する機能として定義しています. 「だカス」で終わっていたら登録するだけです.

export class TypoRecorder implements MessageEventResponder<TypoObservable> {
  constructor(private readonly repo: TypoRepository) {}

  async on(event: MessageEvent, message: TypoObservable): Promise<void> {
    if (event !== 'CREATE') {
      return;
    }
    const { authorId: id, content } = message;
    if (!content.endsWith('だカス')) {
      return;
    }
    const sliced = content.trim().slice(0, -3);
    if (sliced === '') {
      return;
    }
    await this.repo.addTypo(id, sliced);
  }
}

対して, TypoReporter は特定ユーザーのその日の Typo 一覧を表示するコマンドとなっています. ちょっと要リファクタなやり方ではありますが, 省略しているコンストラクタにて, 一日ごとに Typo の記録をリセットする処理を ScheduleRunner に登録することもしています.

const SCHEMA = {
  names: ['typo'],
  subCommands: {
    by: {
      type: 'SUB_COMMAND',
      params: [
        {
          type: 'USER',
          name: '表示するユーザID',
          description:
            'この後にユーザ ID を入れると, そのユーザ ID の今日の Typo を表示するよ',
          defaultValue: 'me'
        }
      ]
    }
  }
} as const;

export class TypoReporter implements CommandResponder<typeof SCHEMA> {
  help: Readonly<HelpInfo> = {
    title: '今日のTypo',
    description: '「〜だカス」をTypoとして一日間記録するよ'
  };
  readonly schema = SCHEMA;

  // コンストラクタは省略

  async on(message: CommandMessage<typeof SCHEMA>): Promise<void> {
    const { senderId, senderName, args } = message;

    if (!args.subCommand) {
      await this.replyTypos(message, senderId, senderName);
      return;
    }
    const [userId] = args.subCommand.params;
    await this.replyTypos(message, userId as Snowflake, `<@${userId}>`);
  }

  private async replyTypos(
    message: CommandMessage<typeof SCHEMA>,
    senderId: Snowflake,
    senderName: string
  ) {
    const typos = await this.repo.allTyposByDate(senderId);
    const description =
      `***† 今日の${senderName}のtypo †***\n` +
      typos.map((typo) => `- ${typo}`).join('\n');
    await message.reply({
      description
    });
  }
}

Meme

Model の節で説明した MemeTemplate は, この機能のために使われています. このコマンドスキーマでは可変長引数を受け取るようになっており, この可変長引数の機能を使っているのは今のところこのコマンドだけです.

ミーム構文のリスト MemeTemplate[] を元に, コマンド名ごとの辞書を作っておきます. そして受け取った可変長引数を cli-argparse でパースし, フラグやオプションを抽出して対応する構文生成処理を呼び出しています. cli-argparse には型定義ファイルが用意されていないので, 自前で型定義を記述しています. 実装が割と長いので, 実装ファイルの permalink を載せておきます.

--help のオプションが与えられた場合は, 各テンプレートに用意されたヘルプを提供します. これによりこの Meme レスポンダー自体のヘルプ (help コマンドで出るもの) の肥大化を防ぎつつ, 直感的に CLI コマンドのように使えるようにしています.

Adaptor

OreOreBot2 では, discord.js とのやり取りを直接行うのではなく, Adaptor のコードでラップして触るようにしています. 他にも時刻, 乱数, データベース, その他ネットワークリクエストなど外的要因をこのモジュールに分けており, Service のプログラムに対して外的要因を気にせずにテストできるようにしています.

ほとんどは Service が要求する interface を実装したものなので, ここではそれ以外の工夫を挙げておきます.

Middleware<M, N>

メッセージやコマンドのイベントを作るにあたって, discord.js の MessageOreOreBot2 のためのメッセージへと加工する必要があります. しかし, それを 1 つのクラスや関数としてまとめることは, 拡張性が乏しく, 肥大化も招いてしまいます.

そこで, メッセージの型 M をメッセージの型 N へと加工する関数の型 Middleware<M, N> を以下のように定義し, このミドルウェアを複数定義して合成することで変換処理を作ることにしました. いわゆる関数型プログラミングのアイデアを採り入れたものです.

export interface Middleware<M, N> {
  (message: M): Promise<N>;
}

const connectMiddleware =
  <M, N, O>(
    first: Middleware<M, N>,
    second: Middleware<N, O>
  ): Middleware<M, O> =>
  (message: M) =>
    first(message).then(second);

const liftTuple =
  <T, U>(m: Middleware<T, U>): Middleware<[T, T], [U, U]> =>
  ([t1, t2]) =>
    Promise.all([m(t1), m(t2)]);

この定義に沿って, Bot のメッセージに応答しないようにするミドルウェア, 実際に全ての Service のコードが必要とする型のメッセージへ変換するミドルウェアなどを作成し合成しています. 特に上記の liftTuple があるおかげで, 編集前と編集後のメッセージのタプルが送られてくるメッセージの編集イベントにおいて, 同じミドルウェアを使い回せる強みがあります.

DiscordCommandProxy

元々は Application Commands のいわゆるスラッシュコマンドに対応して, スキーマを自動生成して応答する予定でした. しかし, 実際にスラッシュコマンドの処理を作ってみると, Bot の作成者とサーバー管理者以外のメンバーが Application Commands のスラッシュコマンドやメッセージコマンドを使用できないという弊サーバー特有の不具合が発覚しました. この症状は未だに原因不明であり, Discord 運営に問い合わせ中で, 回復の目処は立っていません.

そのため, Application Commands が登場するより前からよく使われていた手法である, 特定の記号で始まるメッセージをコマンドの発行と見なす処理にしています. スラッシュコマンドへの対応はすぐにできますが, タスクの優先度は低く設定されています.

ページネーション

メッセージのオブジェクトを作成する際に, そのメッセージへの返信としてページめくりできるような Embed メッセージを返す関数も提供しています. これは Message Components を用いて生成したページめくりボタンと組み合わせて実装されており, このページめくりボタンは返信してから 1 分間だけ機能します. 1 分経過して反応しなくなった場合は, 無効化されたボタンへと置き換えられます. 実装にあたっては, discord.js の MessageComponentCollector が非常に便利でした.

const ONE_MINUTE_MS = 60000;

const CONTROLS: APIActionRowComponent<APIMessageActionRowComponent> =
  new ActionRowBuilder<MessageActionRowComponentBuilder>()
    .addComponents(
      new ButtonBuilder()
        .setStyle(ButtonStyle.Secondary)
        .setCustomId('prev')
        .setLabel('戻る')
        .setEmoji(''),
      new ButtonBuilder()
        .setStyle(ButtonStyle.Secondary)
        .setCustomId('next')
        .setLabel('進む')
        .setEmoji('')
    )
    .toJSON();
const CONTROLS_DISABLED: APIActionRowComponent<APIMessageActionRowComponent> =
  new ActionRowBuilder<MessageActionRowComponentBuilder>()
    .addComponents(
      new ButtonBuilder()
        .setStyle(ButtonStyle.Secondary)
        .setCustomId('prev')
        .setLabel('戻る')
        .setEmoji('')
        .setDisabled(true),
      new ButtonBuilder()
        .setStyle(ButtonStyle.Secondary)
        .setCustomId('next')
        .setLabel('進む')
        .setEmoji('')
        .setDisabled(true)
    )
    .toJSON();

const pagesFooter = (currentPage: number, pagesLength: number) =>
  `ページ ${currentPage + 1}/${pagesLength}`;

const replyPages = (message: RawMessage) => async (pages: EmbedPage[]) => {
  if (pages.length === 0) {
    throw new Error('pages must not be empty array');
  }

  const generatePage = (index: number) =>
    convertEmbed(pages[index]).setFooter({
      text: pagesFooter(index, pages.length)
    });

  const paginated = await message.reply({
    embeds: [generatePage(0)],
    components: [CONTROLS]
  });

  const collector = paginated.createMessageComponentCollector({
    time: ONE_MINUTE_MS
  });

  let currentPage = 0;
  collector.on('collect', async (interaction) => {
    switch (interaction.customId) {
      case 'prev':
        if (0 < currentPage) {
          currentPage -= 1;
        } else {
          currentPage = pages.length - 1;
        }
        break;
      case 'next':
        if (currentPage < pages.length - 1) {
          currentPage += 1;
        } else {
          currentPage = 0;
        }
        break;
      default:
        return;
    }
    await interaction.update({ embeds: [generatePage(currentPage)] });
  });
  collector.on('end', async () => {
    if (paginated.editable) {
      await paginated.edit({ components: [CONTROLS_DISABLED] });
    }
  });
};

スキーマに合わせたパース

コマンドとして認識すべき接頭辞がメッセージに含まれていると検知したら, メッセージからその接頭辞を取り除きつつ, 空白区切りの引数リストとして string[] に加工しています. この引数リストとコマンド名に対応するコマンドのパーサを用いて, それをパース結果のオブジェクトに変換する処理が必要です.

スキーマの型を S とすると, スキーマに基づいてパースした結果は Model で書いたとおりに ParsedSchema<S> と定義しています. 結果がそうなるように 相互再帰を用いてパーサを記述しました (permalink). なお, 構文規則をろくに定めておらずコードの見通しも少し悪いです. Visitor パターンでリファクタすればもう少しマシになるかもしれません. 単体テストは記述していますので, バグはあまり無いはずです.

Server

OreOreBot2 も Discord Bot の端くれですので, @m2en のように頻繁に Bot のトークンを漏らすことがあってはいけません. Bot のトークンなどを実行時に設定できるようにする必要があります. さらに, 他のモジュールのコードどうしを結合して動かす注入役が必要です. そういったシステムの起動に必要な エントリポイント を定義しています. 現状では discord.js の Client を用意しながらセットアップする src/server/index.ts の 1 つのみです. このエントリポイントでは大まかに以下の作業を行っています.

  • Bot のトークンやコマンドの接頭辞が設定された環境変数の読み込み
  • discord.js の Client のセットアップ
  • Adaptor のクラスを構築
  • Adaptor のインスタンスを注入して Service のクラスを構築
  • Service のインスタンスを注入して Runner のクラスを構築
  • Client を起動

テスト

Model のコードが正しく制約を守らないと, これを利用する Service や Adaptor のコードの動作に関わります. また, 使用ライブラリやコードの変更によって Service の動作がおかしくなることもあります. 正しい振る舞いであることを確認しつつ, Service のテスト駆動開発を促すために, Model と Service のコードはできるだけ単体テストを実施しています.

テストフレームワークには Vitest を利用しています. 元々は Jest でしたが, ES Modules へ移行することになった際にデフォルトで ESM 対応しているこちらのフレームワークへ移行しました. Jest と比べてすごくテストの実行が速いので, 普段の CI が短くなってありがたいです. また, c8 を用いてコードカバレッジを計測し, codecov でカバレッジの情報を表示しています.

Model の単体テスト

Model では, そのコードで許される操作すべてをできる限り網羅してテストします. 例えば, Kaere 一葉の起動予約システムに使っているアグリゲートの Reservation では, それ自体やそれに集約されている ReservationIdReservationTime についてテストを記述しています. 今のところ, 異常系のテストばかりで正常系のテストはあまり書かれていません.

Service の結合テスト

Service では, いくつかの振る舞いが自明な機能を除いて, ほとんどの機能に結合テストを実施してもらっています. これは,

  • 実装した機能が正しく実現できているか
  • 異常な入力に対して UX を考慮した応答ができるか

といった基準でコードレビューしています. 先ほどの Service モジュールの定義上, 外的要因には依存できないのでテストが容易です. 時計や乱数やデータベースを使う機能でも, それらをモックして振る舞いをテストできます. また, 仕様が曖昧な部分もテストを書かせることで具体的に明文化できるので開発効率が向上しています.

まとめ

以上がはらちょこと OreOreBot2 の全貌です. @m2en の尽力もあって, とても長い間 (1 年間) メンテナンスされているプロジェクトとなりました. ご苦労さまです. これは弊サーバーではとても珍しいことです. 大抵はすぐに飽きて放置されるからです.

おそらく今後も, 突拍子のないふざけた機能が生えては改造されていくことでしょう. 本当に有能な機能は別の Bot に分けるべきですし. ちなみに, OreOreBot に元々あった機能のうち「GitHub のレポジトリの Issues や Pull Requests を一覧する機能」は 拙作の GitHub Secretary として別 Bot に分離されました. こちらもぜひよろしくお願いします (誰も使ってくれません).

6
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
1