Discord ユーザーの 誕生日 と 活動記念日 を、毎日 JST 00:00 に自動でお祝いする常駐型 Discord Bot です。
従来の Google Apps Script + スプレッドシート + Webhook 構成から、discord.py + SQLite による自己完結型へ移行したものです。
| 機能 | コマンド | 説明 |
|---|---|---|
| プロフィール登録 / 編集 | /profile [user] |
Modal フォームで一括入力。user を指定すると他ユーザーを代理登録できます(要オーナー権限・既定)。誕生日・活動開始日・Twitter ID はいずれも任意。既存値はプリフィルされます。サーバーごとに独立して保持 されます。 |
| プロフィール削除 | /profile_delete [user] |
このサーバーからプロフィールを削除します。 |
| プロフィール表示 | /show [user] [public] |
自分または指定ユーザーの情報を Embed 表示。既定では自分にしか見えません (ephemeral)。public:True でチャンネル公開表示。Twitter 未登録でも崩れません。 |
| プロフィール一覧 | /list [sort] [public] |
当サーバー所属の登録ユーザー一覧。name / birthday / anniversary で並び替え可、ページャー付き。既定では自分にしか見えません (ephemeral)。public:True でチャンネル公開表示。 |
| 通知チャンネル設定 | /config channel <channel> |
お祝い投稿先をサーバーごとに設定(要 サーバー管理 権限)。 |
| 不在ユーザー通知設定 | /config absent <true|false> |
サーバーに不在のユーザーを通知するか切り替え。既定は false(在籍者のみ通知)。 |
| アバター取得元設定 | /config avatar <twitter|discord> |
通知カード右上に表示するアバターをどちらを優先して取得するか設定。既定は twitter。未登録 / 取得不能時はもう一方へフォールバック。 |
| 権限設定 | /config permission <command> <mode> [role1..3] |
コマンド実行権限を owner / everyone / role から選択(要 サーバー管理 権限)。 |
| 設定確認 | /config show |
通知チャンネルとコマンド権限の現状を一覧表示。 |
| 自動通知 | (自動) | 毎日 JST 00:00 に誕生日・活動記念日を投稿。記念日は「○周年」を自動計算。 |
すべての通知 / プロフィール表示には Twitter プロフィールへ即座に飛べる リンクボタン が付きます(登録している場合のみ)。
- Python 3.10 以上
- 依存ライブラリ(requirements.txt)
discord.py >= 2.3aiosqlite >= 0.19python-dotenv >= 1.0
AnniversaryBot/
├── main.py # エントリポイント / Bot 初期化 / コマンド同期
├── requirements.txt
├── .env.example # 環境変数テンプレート
├── .gitignore
├── db/
│ └── database.py # aiosqlite による非同期 DAO + スキーマ
├── cogs/
│ ├── profile_cog.py # /profile, /show, /list, /profile_delete
│ ├── config_cog.py # /config channel, permission, absent, avatar, show
│ ├── admin_cog.py # /admin trigger, next_run, sync
│ └── anniversary_cog.py # tasks.loop による JST 00:00 通知
├── ui/
│ └── modals.py # ProfileModal + Twitter リンクボタン View
├── utils/
│ ├── validators.py # 日付・Twitter ID パース / バリデーション
│ ├── permissions.py # コマンド権限判定
│ ├── avatar.py # アバター URL 解決 (X / Discord)
│ └── error_notifier.py # エラー通知 Webhook (HTTP POST)
└── scripts/
├── clear_global_commands.py # グローバル登録を一括削除するワンショット
└── clear_guild_commands.py # 特定ギルドの登録を一括削除するワンショット
git clone <this-repo-url> AnniversaryBot
cd AnniversaryBot
python -m venv venv
.\venv\Scripts\Activate.ps1 # macOS/Linux: source venv/bin/activatepip install -r requirements.txt- https://discord.com/developers/applications でアプリケーションを作成。
- Bot タブで Bot を作成し、Token を控える。
- 以下の Privileged Gateway Intents を ON にする。
- ✅
SERVER MEMBERS INTENT(ギルド所属判定で使用)
- ✅
- OAuth2 → URL Generator で次のスコープと権限を選択し、生成された URL でサーバーへ招待。
- スコープ:
bot,applications.commands - 権限:
View Channels,Send Messages,Embed Links,Mention Everyone(必要に応じて)
- スコープ:
.env.example をコピーして .env を作成し、自分の値を記入してください。
DISCORD_TOKEN=(Developer Portal で取得した Bot トークン)
TEST_GUILD_ID=(任意: テストサーバーの ID。指定するとそのサーバーへ即時同期)
DB_PATH=anniversary.db
ENV_NAME=development # 任意。エラー通知 JSON の "env" になります
ERROR_WEBHOOK_URL= # 任意。未設定ならコード内デフォルトの Power Automate URL へ送信。後述「エラー通知 Webhook」参照
⚠️ .envは 絶対にコミットしないでください(.gitignore済)。 もし誤ってトークンを公開してしまった場合は、Developer Portal から Reset Token で必ず再発行してください。
python main.py初回起動時に anniversary.db が自動生成され、必要なテーブルが作成されます。
- サーバーで
/profileを実行。 - 表示された Modal に入力して送信。
| 項目 | 形式 | 例 | 必須 |
|---|---|---|---|
| 表示名 | 自由文字列(最大64) | たろう |
✅ |
| Twitter ID | @username(半角英数+_、最大15) |
@example_user |
任意 |
| 誕生日 | MM/DD(空欄で未登録) |
04/15 |
任意 |
| 活動開始日 | YYYY/MM/DD(空欄で未登録) |
2018/04/15 |
任意 |
区切り文字は
/-.のいずれも受け付けます(例:2018-04-15)。 うるう年・月別最大日数も自動チェックします。 誕生日 / 活動開始日 / Twitter ID はいずれも未登録可で、未登録項目は表示・一覧・通知で自動的にスキップされます。
/show # 自分のプロフィール(自分にしか見えない)
/show user:@たろう # 他のメンバーのプロフィール
/show user:@たろう public:True # チャンネルに公開して表示
- 既定は ephemeral(実行者にしか見えない)。
public:Trueを付けるとチャンネルに公開されます。 - Twitter / 誕生日 / 活動開始日が未登録の項目は自動で非表示になります。
/config channel channel:#general
- 実行には サーバー管理 権限が必要です。
- 設定したチャンネルに Bot が
メッセージを送信/埋め込みリンク権限を持っているか自動チェックします。
登録済みだがサーバーを退出済みのユーザーに対する振る舞いを切り替えられます。
/config absent notify:False # サーバー在籍メンバーのみ通知 (既定)
/config absent notify:True # 不在ユーザーにも通知する (旧振る舞い)
False(既定): そのサーバーのメンバー一覧に含まれていないユーザーはスキップされるため、退出した人の誕生日メンションが連連受したりしません。True: 登録されているユーザー全員を通知します。- 事前に
/config channelでチャンネルを設定している必要があります。 - Bot 側でメンバー一覧を取得できない(
Server Members Intent未許可等)場合は、False設定下では安全側で送信をスキップします。
/profile /show /list の各コマンドに対して、サーバーごとに実行可否を設定できます。
/config permission command:<profile|show|list> mode:<owner|everyone|role> [role1] [role2] [role3]
| モード | 意味 |
|---|---|
owner |
サーバーオーナーのみ実行可(既定) |
everyone |
全員実行可 |
role |
指定ロール(最大3つ)のいずれかを保持しているメンバーのみ実行可 |
例:
/config permission command:list mode:role role1:@運営
/config permission command:profile mode:everyone # 全員に自分のプロフィール登録を許可
/config permission command:show mode:owner
💡 既定はオーナーのみ です。一般メンバーに
/profile/show/listを使わせたい場合は上記のようにeveryoneまたはroleを明示的に設定してください。
現在の設定確認:
/config show
ℹ️
/config自体は常に Discord の サーバー管理 権限が必要で、上記の権限制御の対象外です。
/list # 名前順(自分にしか見えない)
/list sort:birthday # 誕生日順 (1/1 → 12/31)
/list sort:anniversary # 活動開始日順 (古い順)
/list public:True # チャンネルに公開して表示
- 既定は ephemeral(実行者にしか見えない)。
public:Trueを付けるとチャンネルに公開されます。 - 当サーバーに所属している かつ プロフィール登録済みのメンバーのみ表示されます。
- 1 ページ 10 件。
◀ 前へ次へ ▶ボタンでページ送り。public:False(既定 / ephemeral): 実行者のみ操作可。public:True(チャンネル公開): 誰でもページ操作可。- ボタン有効期限は 15 分。15 分を超えた場合はボタンが無効化され、
:hourglass: コマンドを再実行してくださいと案内されます。
設定後は何もする必要はありません。毎日 JST 00:00 に以下を自動投稿します。
- 🎂 誕生日 が今日のメンバーへお祝いメッセージ
- 🎉 活動記念日 が今日のメンバーへ「○周年」表記付きメッセージ
メンションされた本人へ Embed と(登録されていれば)Twitter ボタンが届きます。誕生日 / 活動開始日が未登録のユーザーはそもそも通知対象になりません(「準備中」用途に便利)。
通知カードの右上 (Embed thumbnail) にアバター画像を表示します。/config avatar で取得元を切り替えられます。
| 設定 | 動作 |
|---|---|
twitter(既定) |
X(Twitter) アバターを優先。Twitter ID 未登録のときは Discord アバターにフォールバック。 |
discord |
Discord アバターを優先。サーバーに本人が不在などで取得できないときは X アバターにフォールバック。 |
X アバターの取得には unavatar.io を使用しています (
https://unavatar.io/x/<handle>)。API キー不要で、取得できない場合は unavatar.io 側のフォールバック画像が表示されます。
SQLite ファイル(既定: anniversary.db)にすべて保存されます。
プロフィールはサーバー単位で管理されます。同一ユーザーでもサーバーが違えば別レコードです。
| カラム | 型 | 備考 |
|---|---|---|
guild_id |
INTEGER | 複合主キー (Discord Guild ID) |
user_id |
INTEGER | 複合主キー (Discord User ID) |
name |
TEXT | 表示名 |
twitter_id |
TEXT | @handle 形式(NULL 可) |
birth_month / birth_day |
INTEGER | 誕生月日(NULL 可) |
start_year / start_month / start_day |
INTEGER | 活動開始日(NULL 可) |
updated_at |
TIMESTAMP | 自動更新 |
⚠️ 旧バージョンからアップグレードした場合: 旧スキーマのuser_profilesは起動時に自動的に_legacy_user_profilesにリネームされ、退避されます。所属サーバーが特定できないため自動移行はされません。各サーバーで/profileを再登録するか、必要なら_legacy_user_profilesから手動で SQL でコピーしてください。
| カラム | 型 | 備考 |
|---|---|---|
guild_id |
INTEGER | PRIMARY KEY |
channel_id |
INTEGER | 通知投稿チャンネル |
notify_absent |
INTEGER | 0=不在ユーザーを通知しない (既定) / 1=通知する |
avatar_source |
TEXT | twitter (既定) / discord。通知カードアバターの優先取得元 |
updated_at |
TIMESTAMP | 自動更新 |
| カラム | 型 | 備考 |
|---|---|---|
guild_id |
INTEGER | 複合主キー |
command_name |
TEXT | 複合主キー(profile / show / list) |
mode |
TEXT | owner / everyone / role |
role_ids |
TEXT | role モード時の許可ロール ID(カンマ区切り) |
updated_at |
TIMESTAMP | 自動更新 |
Copy-Item anniversary.db anniversary.db.bakBot 停止中に行うのが安全です。
- 常駐運用:
pm2/systemd/ Windows サービス(NSSM)/ Docker などでプロセスを保持してください。 - ログ: 標準出力に
INFOレベルで出力されます。リダイレクトしてファイル保存を推奨。python main.py *>> bot.log
- タイムゾーン: コード内で JST 固定(
utils/・cogs/anniversary_cog.py)。サーバー OS の TZ には依存しません。 - コマンド同期:
TEST_GUILD_ID指定時は即時、未指定(グローバル同期)時は反映に最大 1 時間程度かかります。
DB / バリデーション / 権限判定 / 一覧ソート / 通知ロジックの集計までをユニットテストでカバーしています。
pip install -r requirements-dev.txt
python -m pytest -qテスト構成(tests/):
| ファイル | 内容 |
|---|---|
| tests/test_validators.py | 日付・Twitter ID パース |
| tests/test_database.py | upsert / 検索 / 権限 / 一覧 |
| tests/test_permissions.py | owner / everyone / role 判定 |
| tests/test_sort.py | /list ソートキー |
| tests/test_anniversary.py | 通知の対象抽出と配信回数 |
- テスト専用 の Discord アプリケーションを Developer Portal で別途作成(本番トークンを使い回さない)。
- プライベートサーバーを 1 つ用意し、
bot+applications.commandsスコープで招待。 .envのDISCORD_TOKENをテスト用に差し替え、TEST_GUILD_IDにそのサーバーの ID を入れてpython main.pyを起動(即時にコマンドが反映される)。- 下記シナリオを順に実施。
| # | 手順 | 期待結果 |
|---|---|---|
| 1 | /profile を実行し Modal を送信 |
「保存しました」が表示され、DB に行が増える |
| 2 | 不正値 (例: 誕生日に 13/40) で送信 |
入力エラーメッセージが表示される |
| 2b | 誕生日・活動開始日を空欄で送信 | エラーにならず保存され、/show でその項目が非表示 |
| 3 | /show を実行 |
自分にしか見えない (ephemeral) Embed が出る |
| 3b | /show public:True |
チャンネルに公開される |
| 3c | Twitter 未登録のユーザーを /show |
Twitter フィールド・ボタンが出ず、エラーも出ない |
| 4 | /show user:@他人 |
他人の情報が出る、未登録なら未登録メッセージ |
| 5 | /list sort:birthday |
月日順に並び、ページネーションが動く(既定 ephemeral) |
| 5b | /list public:True |
チャンネルに公開される |
| 6 | /config channel #任意 |
通知チャンネルが保存される |
| 7 | /config permission command:list mode:role role1:@テストロール |
ロール未保有メンバーで /list が拒否される |
| 8 | /config permission command:profile mode:owner |
オーナー以外で /profile が拒否される |
| 9 | /config show |
現在の設定がすべて見える |
| 10 | /profile user:@他人(オーナーで実行) |
代理登録できるモーダルタイトルに対象名が出る |
| 11 | /config avatar source:twitter 後に /admin trigger |
通知カード右上に X(Twitter) のアバター画像が表示される |
| 12 | /config avatar source:discord 後に /admin trigger |
通知カード右上に Discord のアバター画像が表示される |
| 13 | Twitter 未登録ユーザーで source:twitter で通知 |
Discord アバターへフォールバックされる |
24 時間待たずに通知ロジックを発火させるためのオーナー専用コマンドを用意してあります(cogs/admin_cog.py)。
/admin trigger # 今日(JST)で即時実行
/admin trigger date_str:2026-04-15 # 任意日付で実行
/admin trigger date_str:2026-04-15 dry_run:True # 送信せず対象だけ確認
/admin next_run # 次回 daily_notify の発火予定
/admin sync # このサーバーへスラッシュコマンドを即時同期
/admin sync scope:global # 全サーバーへ反映 (最大1時間)
/admin sync clear:True # このサーバー登録分を一旦消してから同期
💡 コマンドの新オプションが反映されない時 は
/admin syncをオーナー権限で実行してください。scope:guild(既定) なら 即時 に当サーバーへ反映されます。scope:globalは全サーバーに反映されますが Discord のキャッシュ都合で各クライアントへの反映に最大 1 時間かかります。
推奨手順:
- 自分のプロフィールを
/profileで登録(誕生日・活動開始日を「明日」など適当な値に) /admin trigger date_str:<明日の日付>で発火/config channelで設定したチャンネルに Embed が届くこと、@メンション付き、Twitter ボタン付きであることを確認dry_run:Trueで対象抽出のみ確認することも可
- ログレベルは
INFO。発火・対象件数・送信失敗はすべてログに残ります。詳しく見たい場合は main.py のlogging.basicConfigをDEBUGに変更してください。 - DB の中身を直接確認する場合:
sqlite3 anniversary.db ".dump user_profiles" sqlite3 anniversary.db "SELECT * FROM command_permissions;"
| 症状 | 対処 |
|---|---|
/profile などが Discord に表示されない |
グローバル同期は時間がかかります。.env の TEST_GUILD_ID にテストサーバー ID を入れて再起動してください。 |
| 通知が来ない | 1) /config channel が設定済みか確認 / 2) Bot にチャンネル送信権限があるか / 3) Bot プロセスが 24h 起動継続しているか / 4) Server Members Intent が ON か |
DISCORD_TOKEN が未設定です |
.env が無い、またはキー名が違います。.env.example を参照。 |
| 入力エラーで Modal が弾かれる | 月日は MM/DD、活動開始日は YYYY/MM/DD。Twitter ID は @英数_ のみ(最大15文字)。 |
| 「このコマンドを実行できるロールを持っていません」と出る | 管理者に /config permission の設定を確認してもらってください。/config show で現状確認できます。 |
| 同じスラッシュコマンドが二重に表示される | グローバル登録とギルド登録が両方残っています。scripts/clear_global_commands.py --yes(グローバル全削除)または scripts/clear_guild_commands.py --yes(テストギルド全削除)を実行 → python main.py で再起動して再登録してください。詳細は次節「コマンド同期スクリプト」参照。 |
| トークンを誤って公開した | 直ちに Developer Portal で Reset Token → 新しいトークンを .env に再設定。 |
scripts/ に、スラッシュコマンドの登録状態をクリーンアップするためのワンショットスクリプトを用意しています。
| スクリプト | 用途 | 反映 |
|---|---|---|
scripts/clear_global_commands.py |
グローバル登録を全削除 | 各クライアントへの反映に最大 1 時間 |
scripts/clear_guild_commands.py |
指定ギルド(既定は .env の TEST_GUILD_ID)の登録を全削除 |
即時 |
実行例:
# 安全のため --yes が必須
.\venv\Scripts\python.exe scripts\clear_global_commands.py --yes
.\venv\Scripts\python.exe scripts\clear_guild_commands.py --yes
.\venv\Scripts\python.exe scripts\clear_guild_commands.py --yes --guild 1234567890123456789実行後はそのまま python main.py で起動すれば、ソースの定義から再登録されます(TEST_GUILD_ID 設定下なら即時にギルドへ)。サーバーオーナーであれば、わざわざスクリプトを実行せず Discord 上から /admin sync clear:True でも同等のことができます。
本セクションは開発者向けの記述です。 変更した場合、開発者側でバグ通知を受け取れなくなるため十分ご注意ください。
未捕捉エラーや logger.exception で記録された ERROR 以上のログ を、指定 URL に JSON で POST します。
- 送信先の優先順位:
.envのERROR_WEBHOOK_URL(設定されていればこちら)- 未設定の場合は main.py に base64 難読化 された Power Automate トリガー URL が使われます。
- 送信を完全に無効化したい場合は
.envにERROR_WEBHOOK_URL=(空値) を設定し、main.py の_DEFAULT_ERROR_WEBHOOK_URLを空文字列に置き換えてください。
⚠️ セキュリティ注意: コード内の難読化 (base64) は機械クローラ / GitHub Secret Scanning 対策であり、ソースを読める者には復元可能です。真の秘匿ではありません。公開リポジトリに push した場合は Power Automate 側でフローの URL を再生成 (sig 失効) し、新しい URL を.envのERROR_WEBHOOK_URLに設定してください。
Sentry / Slack 互換 / Discord Webhook をそのまま受けることはできません(汎用 JSON のため)。受け先には次のような 任意 JSON を受け取れるエンドポイント を指定してください。
- Power Automate「When a HTTP request is received」トリガー(約下のスキーマを貼り付ける)
- 自前 API / Cloudflare Worker / AWS Lambda Function URL
- iPaaS(Pipedream / n8n / Make / Zapier の Webhook)
- 動作確認用の webhook.site
- メソッド:
POST - ヘッダ:
Content-Type: application/json - タイムアウト: 5 秒(失敗しても Bot 動作には影響しません)
{
"type": "object",
"required": ["bot", "level", "timestamp", "message"],
"properties": {
"bot": { "type": "string", "const": "AnniversaryBot" },
"env": { "type": "string", "description": ".env の ENV_NAME (未設定なら 'unknown')" },
"level": { "type": "string", "enum": ["ERROR", "CRITICAL"] },
"timestamp": { "type": "string", "format": "date-time", "description": "UTC ISO 8601 (末尾 Z)" },
"logger": { "type": "string", "description": "Python logger 名" },
"message": { "type": "string" },
"exception": {
"oneOf": [
{ "type": "null" },
{
"type": "object",
"required": ["type", "message", "traceback"],
"properties": {
"type": { "type": "string", "description": "例外クラス名" },
"message": { "type": "string", "description": "str(exception)" },
"traceback": { "type": "string", "description": "完全なトレースバック (最大約 6KB で打ち切り)" }
}
}
]
},
"context": {
"type": "object",
"description": "任意の追加情報。コマンド由来エラーでは下記キーが入ります。",
"properties": {
"guild_id": { "type": ["integer", "null"] },
"channel_id": { "type": ["integer", "null"] },
"user_id": { "type": ["integer", "null"] },
"command": { "type": ["string", "null"] },
"module": { "type": "string" },
"func": { "type": "string" }
}
}
}
}{
"bot": "AnniversaryBot",
"env": "production",
"level": "ERROR",
"timestamp": "2026-05-05T03:21:48.512Z",
"logger": "anniversarybot",
"message": "Unhandled app command error: AttributeError",
"exception": {
"type": "AttributeError",
"message": "'NoneType' object has no attribute 'foo'",
"traceback": "Traceback (most recent call last):\n File ...\nAttributeError: ..."
},
"context": {
"guild_id": 1486024857043861504,
"channel_id": 1500000000000000000,
"user_id": 200,
"command": "show"
}
}社内 / 個人利用想定。必要に応じて追記してください。