見出し画像

Discord bot を作ってリモートワークを少し便利にした話

Web開発チーム(フロントエンド)のDです。社内でちょっとしたbotを作った話をします。

tl;dr

  • サーバーサイドTypeScript (ts-node) で Discord bot を作りました

  • HIKKYには色んな働き方をしている人がいます

  • 自分で仕様を決められると色々と楽しい

発端

フロントエンジニアS 22:14
@フロントエンド 明日の定例後、[***issues説明会」の予定を入れさせていただきました。文字通り難しい以下のissuesについての説明となります。
https://github.com/…

フロントエンジニアY
(休日や深夜に緊急でない@での連絡は控えて欲しい気持ちがあります)


フロントエンジニアY
自分の場合終業後のリプは、すぐに対応がいらないとわかると内容読まずにスルーして通知バッジだけ消してそのまま忘れてしまうことが多いです。
朝10時に予約投稿とかできないかな……

“VR法人(メタバース法人)” HIKKY はVRSNS上にバーチャルオフィスワールド等を持っていますが、VR空間上のオフィスで全ての業務を進めているかというと全くそんな事はなくて、同期的・非同期的なコミュニケーションに Google Meet や Discord, Slack 等を活用しています。

定時出勤・定時退社する人もいれば朝の5時~夕方までの人や、昼頃からコードを書き始めて夜中にエンジンがかかってくるタイプの人もいて(メチャメチャ変則的な業務時間も許可されています。アウトプット上がってくればよし)、テキストチャット上のメンションは重要な連絡手段の一つとして機能しています。

それは同時に、すごい時間にメンションが飛んでくることも日常茶飯事ということです。こういうの耐性がない人はめっちゃストレス受ける気がする

Slackには高機能な リマインダー 機能があり、翌日の朝に投稿されるよう予約ができるので夜中のメンション問題を軽減できます。Discordには相当する機能がなかったので、簡易版ですが Discord bot を作りました。

/remind コマンド

/remind <when> <message>

指定した時刻<when> に、botアカウントから自分へ向けて <message> を内容とするDMが届きます。

botからダイレクトメッセージが届いている画像
<message> とコマンド発行したチャンネルを含むDMが届く

(コマンドを叩いたユーザーに成り代わってメンションを飛ばすのはbotの挙動として少し怖いので、使用者とbotとの間で完結する簡易版です。
夜中に連絡を書いた人が送信する代わりに使うもよし、夜中に連絡を受け取った人が翌朝にもう一度受け取る用に使うもよしです)

<when> の選択肢に 10:00, 19:00, "月曜日" などを用意しました。この中でもお気に入りは "月曜日" で、主に週末に使うことを想定して作りました。DMが月曜10時に飛んできます。

case 'monday':
  let d = setHour(new Date(), 10)
  while (d.getDay() !== 1) d = new Date(d.valueOf() + oneDay)
  return toTimestamp(d)

コードはTypeScript なので、これら選択肢の一覧の型を定義しておくこともできます。せっかくなので、discord.js に渡す選択肢リストの { name, value }[] の型から選択肢を表すユニオン型 type When を導いてみます。

ユニオン型 '10m' | '30m' | '1h' | ... | 'monday' を得るには以下のようにしますtype When = typeof whenChoices[number]['value']

const whenChoices = [
  { name: '10分後', value: '10m' },
  { name: '30分後', value: '30m' },
  { name: '1時間後', value: '1h', },
  { name: '10:00', value: 'at10_00' },
  { name: '16:00', value: 'at16_00' },
  { name: '19:00', value: 'at19_00' },
  { name: '月曜日', value: 'monday' },
] as const
type When = typeof whenChoices[number]['value']

ここで as const を忘れると type When が string になってしまうので注意します

技術選定について

保守できる人が居なくなったプログラムの最期は悲惨なものと相場が決まっているので、小さなbotでも他の人が触れる状態を保ちたいです。

使用言語は Python か TypeScript と行きたいところです。ここで、Python は型が必須ではないうえ、開発チームのメンバーが全員最新の型ヒントの記法に習熟しているわけでないので避けることにしました。
HIKKYのWeb開発チーム(フロントエンド)はTypeScriptをメインの開発言語として使っているので、その点は心配が要りません。

TypeScript で Discord bot を書く場合、ライブラリは discord.js か discordeno あたりが良さそうです。 Denoはとても楽しそうな選択肢で、TypeScriptとの親和性が高いですが、Web開発チームがDenoの流儀をよく知らないので事故を避けるため見送ることにします。
discord.js は利用者も多くドキュメントも分厚いので、そちらの利をとったかたちです。

ほか細かい話

Discordのトークンは dotenv ライブラリを使って .env ファイルから読み込んでいます。

ちょっとした注意ですが、今回のbotだと(途中で面倒になったので)悪名高い Date() API を直接触っています。dayjs や date-fns といった時刻系ライブラリの使用を強く推奨します。

(TypeScript はその性質上、JavaScriptの仕様バグや使いづらいAPIを改善しません。その辺の話は TypeScriptは何ではないか? | TypeScript入門『サバイバルTypeScript』 に詳しい)

この記事を書いている途中に知ったのですが、 ts-node の代替として esbuild-register というのもあるので、それも気になっています

むすび

Web開発チームが普段使っている Typescript を使って Discord bot を書いてみよう、というのをやりました。このbotは開発チームが好きに使っていいサンドボックスの EC2 に突っ込んで実際に動かしています。フロントエンジニアのSさんがヘビーユーザーです。

Discordの仕様の都合でいくらか最初想像していたものから迂回するように変更した仕様もありますが、こういったものは自分で仕様を決めてガッと書けるのが楽しいところ。(タイムゾーンがJST固定だったり、botからのDMで届いたりするあたりですね)

HIKKYのWeb開発チーム(フロントエンド)には、プロダクトのコードをガンガン書く以外のことをやる『職域業務タイム』という制度が用意されていて、業務時間の割合にして5%から25%程度を自由にリファクタリング・勉強・調査・VRゲームの研究・開催中のVケットへ遊びに行く、などに使っていいことになっています。今回の bot とブログ記事はこの職域業務タイムの時間で書かれました。


Appendix

コード全体は以下のようになっています

package.json

{
  "type": "module",
  "scripts": {
    "start": "node --loader ts-node/esm main.ts"
  },
  "dependencies": {
    "@discordjs/builders": "^0.15.0",
    "@discordjs/rest": "^0.5.0",
    "discord-api-types": "^0.33.5",
    "discord.js": "^13.8.0",
    "dotenv": "^16.0.1",
    "ts-node": "^10.8.1"
  },
  "devDependencies": {
    "typescript": "^4.7.4"
  }
}

main.ts

import fs from 'fs'
import dotenv from 'dotenv'
import { REST } from '@discordjs/rest'
import { SlashCommandBuilder } from '@discordjs/builders'
import { GatewayIntentBits, Routes } from 'discord-api-types/v10'
import { Client } from 'discord.js'
import type { CommandInteraction } from 'discord.js'

dotenv.config()

const rest = new REST({ version: '10' }).setToken(process.env.BOT_TOKEN!)

const whenChoices = [
  { name: '10分後', value: '10m' },
  { name: '30分後', value: '30m' },
  { name: '1時間後', value: '1h', },
  { name: '10:00', value: 'at10_00' },
  { name: '16:00', value: 'at16_00' },
  { name: '19:00', value: 'at19_00' },
  { name: '月曜日', value: 'monday' },
] as const
type When = typeof whenChoices[number]['value']

const commands = [
  new SlashCommandBuilder()
    .setName('reminder')
    .setDescription('see reminders')
    .addSubcommand(list => list.setName('list')
      .setDescription('list your reminders')),
  new SlashCommandBuilder()
    .setName('remind')
    .setDescription('create reminder')
    .addStringOption(option =>
      option.setName('when')
      .setDescription('When the reminder will be send')
      .addChoices(...whenChoices)
      .setRequired(true))
    .addStringOption(option =>
      option.setName('message')
        .setDescription('The input remind message')
        .setRequired(true))
]
type Reminder = {
  userId: string
  timestamp: number
  channelId: string
  message: string
}
const reminders: Reminder[] = JSON.parse(
  fs.readFileSync('./reminders.json', 'utf-8')
)
const save = (reminders: Reminder[]) =>
  fs.writeFileSync('./reminders.json', JSON.stringify(reminders), 'utf-8')

const oneDay = 24 * 60 * 60 * 1000
const setHour = (d: Date, h: number) => {
  d.setHours(h, 0, 0)
  return d
}
const tommorowIfPast = (d: Date) =>
  (d < new Date()) ? new Date(d.valueOf() + oneDay) : d

// js の Date はミリ秒単位だがDiscord的にはタイムスタンプは秒なので
const toTimestamp = (d: Date) => Math.floor(d.valueOf() / 1000)

const getWhen = (when: When) => {
  switch (when) {
    case '10m': return Math.floor(Date.now() / 1000) + 10 * 60
    case '30m': return Math.floor(Date.now() / 1000) + 30 * 60
    case '1h': return Math.floor(Date.now() / 1000) + 60 * 60
    case 'at10_00': return toTimestamp(tommorowIfPast(setHour(new Date(), 10)))
    case 'at16_00': return toTimestamp(tommorowIfPast(setHour(new Date(), 16)))
    case 'at19_00': return toTimestamp(tommorowIfPast(setHour(new Date(), 19)))
    case 'monday':
      let d = setHour(new Date(), 10)
      while (d.getDay() !== 1) d = new Date(d.valueOf() + oneDay)
      return toTimestamp(d)
  }
}

const buildReminder = (interaction: CommandInteraction) => {
  const userId = interaction.user.id
  const message = interaction.options.getString('message')!
  const channelId = interaction.channelId
  // コマンドオプションのChoicesにより入力が制限されるため
  const when: When = interaction.options.getString('when') as When
  const timestamp = getWhen(when)

  return { userId, timestamp, channelId, message }
}

let client: Client

const sendReminder = (reminder: Reminder) => {
  try {
    const user = client.users.cache.get(reminder.userId)!
    user.send({
      'content': `${reminder.message} (<#${reminder.channelId}>)`,
    })
  } catch (error) {
    console.error(error)
  }
}
const processReminder = () => {
  const timestamp = Math.floor(Date.now() / 1000)
  const fired = reminders.filter(r => r.timestamp <= timestamp)
  if (fired.length === 0) { return }
  fired.forEach(sendReminder)
  // const教対策
  reminders.splice(
    0,
    reminders.length,
    ...reminders.filter(r => r.timestamp > timestamp)
  )
  save(reminders)
  console.log(`${reminders.length} reminders.`)
}

try {
  console.log('Started refreshing application (/) commands.')
  await rest.put(
    Routes.applicationGuildCommands(
      process.env.CLIENT_ID!,
      process.env.GUILD_ID!
    ),
    { body: commands }
  )
  console.log('Successfully reloaded application (/) commands.')
} catch (error) {
  console.error(error)
}

const processListCommand = (interaction: CommandInteraction) => {
  const list = reminders
      .filter(r => r.userId === interaction.user.id)
      .sort((a, b) => a.timestamp - b.timestamp)
  interaction.reply({
    'content': `${list.length} reminders:\n` + list
      .map(r => `<t:${r.timestamp}> ` + '`' + r.message + '`')
      .join('\n'),
    ephemeral: true,
  })
}

const processRemindCommand = async (interaction: CommandInteraction) => {
  try {
    // 直接 reply しないのは unknown interaction エラー対策
    // https://stackoverflow.com/questions/67413046/slash-commands-unknown-interaction
    await interaction.deferReply({ ephemeral: true })
    const reminder = buildReminder(interaction)
    reminders.push(reminder)
    save(reminders)
    interaction.editReply({
      content:
        `<t:${reminder.timestamp}> <#${reminder.channelId}>`
        + '`' + reminder.message + '`',
    })
    console.log(`${reminders.length} reminders.`)
  } catch (error) {
    console.error(error)
  }
}
const processReminderMetaCommand = (interaction: CommandInteraction) => {
  if (interaction.options.getSubcommand() === 'list') {
    return processListCommand(interaction)
  }
}

setInterval(() => processReminder(), 10_000)

client = new Client({ intents: [GatewayIntentBits.Guilds] })

client.on('ready', () => {
  console.log(`Logged in as ${client.user?.tag}!`)
})

client.on('interactionCreate', async (interaction) => {
  if (!interaction.isCommand()) { return }

  switch (interaction.commandName) {
    case 'remind': return processRemindCommand(interaction)
    case 'reminder': return processReminderMetaCommand(interaction)
  }
})

client.login(process.env.BOT_TOKEN!)

この記事が参加している募集