朝起きられないからDiscord botに起こしてもらう

皆さん、朝起きてますか？

私は駄目です。

　

　　終
制作・著作
━━━━━



# 人はなぜ時間通りに起きられないのか

一説によると、人間が起床に失敗するにはおおむね2つほど原因があると言われています。

1.アラームに気付かない
2.実は起きてるけど二度寝する

なお、ここでは「起床」という概念を「目覚まし時計またはそれに順する機能によって睡眠から目覚めること」と定義し、自然起床については考慮しません。~~それができたらこの記事は書かれていません~~

話を戻すと、上記原因の対策はいくつか考えられます。

- アラームに気付かない問題
    - 音をめちゃくちゃでかくする
    - アラーム以外の起こし方を試みる
        - 振動で起こす
        - 光で起こす
- 二度寝する問題
    - 布団から出ないとアラームが止まらないようにする

つまり、これらの要件をいい感じに解決できるようにしたく、Discord botの実装をしたという話です。

# つくったもの

![スクリーンショット 2024-12-15 21.05.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/528082/4e552b5d-22a5-3a43-5184-d193b727e9c6.png)

大体こんな感じです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/528082/154b32c3-3133-796d-7263-0a78176bedcf.png" width="30%">

こちらは今回の隠れた主役、超高性能目覚まし時計別名スマートバンドです。

先ほど起きられない原因の1つとしてアラームに気付かないと書きましたが、このガジェットはアラームと共にバイブレーション機能を内蔵しています。物理的に身に着けているためその振動による起床効果は絶大であり、強力な目覚めを期待できます。

しかし、単にスマートバンドで組み込みのアラームを鳴らすだけでは、2つ目の問題である二度寝に対して対策ができません。身につけているがゆえの効果の高さが仇となり、手元でタップするだけで布団の中から簡単に解除できてしまいます。起床効果が高くてもそうなってしまうとすぐ二度寝の罠にかかってしまうわけです。

そこでひと工夫の余地が出来てきます。スマートバンドはスマートフォンと通知を同期させることで遠隔でバイブレーションを起動させられます。これによって、「手元で容易に解除できない、予め決められたスケジュールに従ってバイブレーションを起こすデバイス」が実装できます。

ここでようやくDiscord botの出番です。ここまでの理屈からして、正直ここは通知のスケジュールをある程度制御できるアプリであれば何でもいいのですが、バックグラウンド処理や対話機能を備え、簡単に実装できるプラットフォームとしてDiscord botを選びました。

実装はpythonで、ライブラリは[discord.py](https://discordpy.readthedocs.io/ja/latest/)を使います。

# bot概要

あらかじめ設定した時間になると通知で教えてくれます。
![Discord 2024_12_15 22_43_50_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/528082/b335e755-8c66-dfad-bde3-5e1e7dae7b67.png)

放置すると5分おきに通知を飛ばしてきます。通知に対して任意のリアクションをすると該当アラームを解除します。ここで手元のスマートバンドをいくら操作しても解除できないので、布団から出てdiscordの操作をするまで一生鳴り続けることになります。
![Discord 2024_12_15 22_43_50_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/528082/e9f1dfe8-33fb-d93c-d42a-2b2a521b3fa5.png)

`add_alarm`, `delete_alarm`, `list_alarm`の3つのスラッシュコマンドを実装し、現在有効なアラームを管理します。
![Discord 2024_12_15 22_43_50_4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/528082/0a7f67c2-e75e-3cd5-1d52-2aa9b8b74d4e.png)


# bot実装

全体の構造を決めた時点であとは出来るだけ労力をかけずに作ろうと決めたので、最小限で書いて実装します。

`client.py` には各種コマンドとコールバックを実装します。`discord.py` には拡張機能を含む多くの機能があり、用途によって違うのでややこしいですが、それぞれ下記を使って実装します。

- アラームの起動
    - バックグラウンド処理 `discord.ext.tasks`
- アラームの登録/削除/確認
    - スラッシュコマンド `discord.app_commands`
- アラームの停止
    - リアクションに対するコールバック

``` client.py
from discord.ext import tasks
from discord import app_commands
import discord
import json
import alarm

ALARM_SETTING_FILE = "./alarm.json"

class AlarmBot(discord.Client):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.alarms = self.load_alarm_settings()
        self.tree = app_commands.CommandTree(self)

        # 各種スラッシュコマンドの定義
        # self:AlarmBotが有効なスコープである必要があるのでここで書く
        @self.tree.command()
        async def add_alarm(interaction: discord.Interaction, time_text: str):
            try:
                hour, minute = (int(text) for text in time_text.split(":"))
                self.alarms.append(alarm.Alarm(
                    channel_id=interaction.channel_id,
                    user_id=interaction.user.id,
                    hour=hour,
                    minute=minute
                ))
                await interaction.response.send_message(
                    f"alarm at {hour}:{minute:02} from {interaction.user.name}"
                )
                self.save_alarm_settings()
            except (ValueError, UnboundLocalError):
                await interaction.response.send_message(
                    f"please input hh:mm format."
                )

        @self.tree.command()
        async def delete_alarm(interaction: discord.Interaction, delete_index: int):
            self.alarms[delete_index].stop_alarm()
            deleted = self.alarms.pop(delete_index)
            await interaction.response.send_message(
                f"{deleted.text_repr()} deleted."
            )
            self.save_alarm_settings()

        @self.tree.command()
        async def list_alarm(interaction: discord.Interaction):
            await interaction.response.send_message(
                f"alarm list:\n{"\n".join([f"ID:{i} {alarm.text_repr()}" for i, alarm in enumerate(self.alarms)])}"
            )

    async def setup_hook(self) -> None:
        self.background_alarm_check.start()

    async def on_ready(self):
        print(f'Logged in as {self.user} (ID: {self.user.id})')
        print('------')
        for guild in self.guilds:
            self.tree.copy_global_to(guild=guild)
            await self.tree.sync(guild=guild)

    # 60秒間隔でアラームの発動をチェックする
    @tasks.loop(seconds=60)
    async def background_alarm_check(self):
        for alarm in self.alarms:
            if alarm.check_alarm():
                self.bg_task = self.loop.create_task(alarm.get_alarm(self))
                print("alarm disptched")

    @background_alarm_check.before_loop
    async def before_my_task(self):
        await self.wait_until_ready()  # wait until the bot logs in

    # リアクションされたメッセージの送信元であるアラームを解除する
    async def on_reaction_add(self, reaction: discord.Reaction, user: discord.User):
        message = reaction.message
        if message.author != self.user:
            return
        
        for alarm in self.alarms:
            if alarm.is_my_alarm_message(message.id):
                alarm.stop_alarm()
                await self.get_channel(alarm.channel_id).send(f"{alarm.text_repr()} stopped.")


    def save_alarm_settings(self) -> None:
        with open(ALARM_SETTING_FILE, 'w') as f:
            json.dump([alarm.to_dict() for alarm in self.alarms], f, indent=2)

    def load_alarm_settings(self) -> list[alarm.Alarm]:
        with open(ALARM_SETTING_FILE) as f:
            raw_objs = json.load(f)
            return [alarm.Alarm.from_dict(obj) for obj in raw_objs]



client = AlarmBot(intents=discord.Intents.default())

# your token
client.run('xxx')
```

`alarm.py`にはアラーム及びスヌーズに関する処理を記述します。

```alarm.py
from datetime import datetime, timedelta, timezone
from typing import Any, Coroutine

import asyncio
import discord

tz = timezone(timedelta(hours=+9), 'Asia/Tokyo')

def _one_day_ago(datetime: datetime) -> datetime:
    return datetime + timedelta(days=-1)

# 与えられたdatetimeの同日0:00:00であるdatetimeを取得する
def _get_0hour(datetime: datetime)-> datetime:
    return datetime.replace(hour=0, minute=0, second=0, microsecond=0)

class Alarm:
    def __init__(self, channel_id: int, user_id: int, hour: int, minute: int):
        self.channel_id = channel_id
        self.user_id = user_id
        self.hour = hour
        self.minute = minute

        now = datetime.now(tz)
        alarmtime = now.replace(hour=hour, minute=minute, second=0)
        # アラームの作成時、現在時刻より前なら起動済みとして扱う
        if now < alarmtime:
            self.last_alerm_day = _get_0hour(_one_day_ago(now))
        else:
            self.last_alerm_day = _get_0hour(now)

        self.stop = False
        self.posted_alarm_id = set()
    
    def to_dict(self) -> dict[str, Any]:
        return {
            "channel_id": self.channel_id,
            "user_id": self.user_id,
            "hour": self.hour,
            "minute": self.minute
        }
    
    @staticmethod
    def from_dict(obj: dict[str, Any]) -> "Alarm":
        return Alarm(**obj)

    # 現在時刻でアラームを鳴らすべきかどうか判定する
    def check_alarm(self) -> bool:
        now = datetime.now(tz)
        alarmtime = now.replace(hour=self.hour, minute=self.minute, second=0)
        return now >= alarmtime and _get_0hour(now) > self.last_alerm_day

    # アラームおよびスヌーズ機能の本体であるコルーチンを取得
    def get_alarm(self, client: discord.Client) -> Coroutine[Any, Any, None]:
        async def alarm_and_snooze():
            channel = client.get_channel(self.channel_id)
            while not client.is_closed():
                if self.stop:
                    break
                posted = await channel.send(f"<@{self.user_id}> good morning.")
                self.posted_alarm_id.add(posted.id)
                await asyncio.sleep(300)  # task runs every 5 minutes
        
        self.last_alerm_day = _get_0hour(datetime.now(tz))
        self.stop = False
        self.posted_alarm_id = set()
        return alarm_and_snooze()

    # 該当のmessage_idが自身から発行されたものかどうか判定する
    def is_my_alarm_message(self, message_id: int) -> bool:
        return message_id in self.posted_alarm_id

    # 起動中のアラームを停止
    def stop_alarm(self) -> None:
        self.stop = True

    def text_repr(self) -> str:
        return f"alarm {self.hour}:{self.minute:02}"
```

# リファレンス

公式のドキュメントが充実しているので、基本的に従っていけば楽に導入できると思います。
[discord.py](https://discordpy.readthedocs.io/ja/latest/)
[クイックスタート](https://discordpy.readthedocs.io/ja/latest/quickstart.html)
[サンプル集](https://github.com/Rapptz/discord.py/tree/master/examples)