Toggl/GitHub/AI連携でチームの生産性をブーストする多機能Bot

![ChatGPT Image 2025年4月10日 08_08_26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/963e18ee-6dba-454e-8fb0-0fa874a305e8.png)

# 目次
- **はじめに**
- **機能紹介**
- **コード紹介**
- **その他の機能**
- **開発を通じて得られた知見**
- **感想**
- **参考文献**
--- 

# はじめに
**チーム内のコミュニケーションとタスク管理の効率化を目指して開発した多機能Discord Botを開発したので、一部をご紹介します!**
- 日々の進捗報告の標準化
- 時間管理の自動化とGitHubとの連携
- AIを活用した勤怠評価や提案機能
![d939aaa1-6c65-4cae-9a51-482edf8163f7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/40249123-5786-4572-82ed-67ec8871340c.png)

---
# 機能紹介
## 1. 進捗報告の自動処理・提案


![ChatGPT Image 2025年4月10日 09_02_30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/6acb95f4-19a7-4be3-9537-f4aada921b98.png)


### 機能
1. **AIを使った内容と推察とルールベースで適切なフォーマット提案**: 
❗️標準フォーマットに合致するかをチェックし、内容から報告タイプ（着手/完了など）やタスク名、参考情報などを補足
-- ユーザーからのリアクション (👍/👎) で精度向上

2. **Google Driveと連携した柔軟なテンプレート管理** 
❗️AIが、与えられた知識を元にチームの運用に合わせた内容へと変更可能
-- 進捗報告のテンプレートやルールは、*Google Drive*ドキュメントまたは用意したファイルから読み込むことができます

---

## 2. Togglによる自動勤怠管理
![ChatGPT Image 2025年4月10日 09_12_22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/b296583c-ac4d-4ab4-af22-c7fc1cd45588.png)

https://toggl.com/

https://qiita.com/makky_tyuyan/items/66e98295c9f5d841580b

### 機能
1. **進捗報告**（着手/完了/休憩/現在状況）と勤怠報告の連動
❗️チャンネル内での勤怠報告を行うと、別アプリ/タブから操作せずとも自動で開始・停止します。

2. **リマインダー機能** 
❗️指定した一定間以上継続しているタスクや、着手報告時に記録された想定時間を超過したタスクについて、ユーザーや管理者にDMでリマインド
-- 適切な進捗報告テンプレートを提示します

3. **簡単連携**
❗️コマンドを使って、TogglのAPIキーとワークスペースIDを登録するだけで容易
-- レポート生成も可能

--- 

# 3. GitHub連携と評価機能
![5530f454-aa4b-43bd-834b-247f3aea88c9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/7af27091-7ed0-4fbb-96c6-36aa0c5d9ec9.png)

### 機能
1. **作業ログの自動評価**
❗️AIがコミットやマージ履歴などから内容を読み取り、あらかじめ定義された評価項目に沿ってスコア
-- Drive上に用意したルール文書またはGitHub Issueに記載された過去の内容を基準
2. **個別FBによる、再評価**
❗️フィードバックを元に再評価することも可能。改善のループを通じて、開発品質の継続的な向上
3. **進捗報告の自動コメント**
❗️チャンネルで報告されたメッセージにGithubの情報が含まれている場合、対応するレポジトリに自動でコメントを投稿します。
-- コメントの内容は、報告タイプに応じて自動整形されます。

--- 
## コード紹介
### 🔧 使っている主な外部技術
1. **Discord_bot.py**
    - メッセージ受信、リアクション、DM送信
2. **Toggl API**
    - タイマーの開始・停止・作業時間の取得
3. **Google Drive API**
    - 規則の取得, 進捗の保存／共有先として選択肢
4. **ChatGPT API**
    - メッセージフォーマットのAI提案生成

### 進捗報告をサポートするBotの処理フロー

``` Python
# discord_bot.py
async def handle_progress_report(message: discord.Message):
    """進捗報告メッセージのハンドラー"""
    user_id = str(message.author.id)

    # ... (Toggl/GitHubクライアント準備) ...

    # デフォルトでGoogle Driveを使用
    use_google_drive = True
    if "--json" in message.content.lower() or "--local" in message.content.lower():
         use_google_drive = False
         # フラグをメッセージから削除
         message.content = message.content.replace("--json", "").replace

    # 進捗報告チャンネルかどうかの判定
    is_progress_channel = str(message.channel.id) == PROGRESS_CHANNEL_ID
    # ルール確認はルール用ドキュメントで行う (for_toggl=False)
    for_toggl = False 

    logger.info(f"進捗報告処理開始: ユーザー={message.author.name}, チャンネル={message.channel.name}, toggl_client有={toggl_client is not None}, github_client有={github_client is not None}")

    try:
        # progress_handler.py の関数を呼び出し
        await process_progress_report(message, toggl_client, github_client, use_google_drive=use_google_drive, for_toggl=for_toggl)
        logger.info(f"進捗報告処理完了: ユーザー={message.author.name}")
    except Exception as e:
        logger.error(f"進捗報告処理中の例外: {str(e)}")
        await message.channel.send(f"⚠️ 進捗報告処理中にエラーが発生しました: {str(e)}")
```
```Python
# progress_handler.py
async def handle_progress_message(message: discord.Message) -> None:
    """進捗報告メッセージを処理する"""
    # ... (データ読み込み) ...

    # メッセージを解析
    report_data = parse_progress_report(message.content, user_id)

    if report_data.get("is_standard_format", False):
        # 標準フォーマットの場合の処理 (リアクション追加、履歴更新)
        await message.add_reaction(CHECK_EMOJI)
        update_user_history(...)
        logger.info(f"正常な進捗報告: {report_data['type']} by {message.author.name}")
        # ここで process_progress_report を呼び出すフローが必要（discord_bot.py側に実装）
    else:
        # 非標準フォーマットの場合の処理 (提案生成、リアクション待ち)
        await message.add_reaction(THINKING_EMOJI)
        suggestion = await generate_ai_suggestion(message, message.content, progress_data) # AI提案
        if not suggestion:
            # ルールベース提案
            _, suggestion = suggest_correct_format(message.content, user_id) 

        if suggestion:
             suggestion_msg = await message.channel.send(f"{message.author.mention} ... 提案メッセージ ...")
             await suggestion_msg.add_reaction("👍")
             await suggestion_msg.add_reaction("👎")
             # 提案情報を保存
             progress_data[str(suggestion_msg.id)] = { ... }
             save_progress_data(progress_data)
        else:
             await message.add_reaction(ERROR_EMOJI)
             await message.channel.send(...)

async def handle_reaction_add(reaction: discord.Reaction, user: discord.User) -> None:
    """リアクション追加時の処理（進捗報告の提案に対する応答）"""
    # ... (Bot自身のリアクションや他ユーザーのリアクションは無視) ...

    progress_data = load_progress_data()
    suggestion_data = progress_data.get(str(message.id))

    if not suggestion_data: return # 提案データなし

    # ... (元のメッセージ情報、Toggl/GitHubクライアント取得) ...

    if emoji == "👍": # 提案受け入れ
        # 提案されたフォーマットで新しいメッセージを送信
        final_message_to_process = await channel.send(suggested_format, reference=original_message)
        await message.edit(content="処理しました。") 
    elif emoji == "👎": # 提案拒否
         # 元のメッセージを処理対象とする
        final_message_to_process = original_message
        await message.edit(content="元の投稿内容を処理します。") 

    if final_message_to_process:
        # process_progress_report を呼び出して連携処理実行 (discord_bot.py側で)
         await process_progress_report(final_message_to_process, toggl_client, github_client, ...)

    # 関連データを追加と削除
    if emoji in ["👍", "👎"] and str(message.id) in progress_data:
        del progress_data[str(message.id)]
        save_progress_data(progress_data)
```
#### 💡全体構造

1. 内容を解析し、
2. 標準フォーマットならリアクションや履歴更新、
3. 非標準フォーマットならAI/ルールベースの提案を返す、
4. リアクション（👍/👎）に応じて、最終的にレポート機能を実行する
という流れで動作します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/aeb949bc-9c0d-4b02-845d-15bf0f93a3de.png)


--- 
### TooglとのAPIとのやりとり

**TogglとDiscordを連携させて「作業時間の計測・監視・通知」を行うボットの実装方法**

```python
# discord_bot.py
class TogglClient:
    # ... (初期化、_make_request) ...
    #　タイマー開始
    async def start_time_entry(self, project_name: str, description: str = "", task_id: str = None) -> dict | None:
        project_id = await self.get_project_id_by_name(project_name)
        if not project_id:
            raise ValueError(f"プロジェクト '{project_name}' が見つかりません")
            # 事前に確認を行うことで、存在しないプロジェクトへの登録を防ぐ
        tags = []
        if task_id:
            clean_task_id = task_id.strip('#')
            if clean_task_id.isdigit():
                tags.append(f"issue-{clean_task_id}") # Issue番号をタグに設定

        payload = {
            "created_with": "Discord Bot",
            "workspace_id": int(self.workspace_id),
            "description": description or project_name,
            "project_id": project_id,
            "start": datetime.now(pytz.utc).isoformat(),
            "duration": -1, # 計測開始を示す
            "tags": tags if tags else None
        }
        return await self._make_request(
            "POST",
            f"{self.BASE_URL}/workspaces/{self.workspace_id}/time_entries",
            json=payload
        )
    #　タイマー停止
    async def stop_current_entry(self) -> dict | None:
        current = await self.get_current_entry()
        if current:
            return await self._make_request(
                "PATCH",
                f"{self.BASE_URL}/workspaces/{self.workspace_id}/time_entries/{current['id']}/stop"
            )
        return None
    # レポート機能
    async def get_report(self, start_date: datetime, end_date: datetime) -> list:
         # ... (Reports API呼び出し) ...

# jsonによる、進捗情報の管理
@tasks.loop(seconds=REMIND_INTERVAL) # tasks.loopに変更
async def check_long_entries():
    # ... (ユーザーデータ読み込み、TogglClient初期化) ...
    for discord_id, creds in users.items():
        toggl = TogglClient(creds["api_key"], creds["workspace_id"])
        entry = await toggl.get_current_entry()
        if entry and entry.get("duration", 0) < 0: # 実行中
            start_time = datetime.fromisoformat(entry["start"]).replace(tzinfo=pytz.utc)
            elapsed = (datetime.now(pytz.utc) - start_time).total_seconds()

            # 想定時間をprogress_data.jsonから取得
            estimated_time_minutes = get_estimated_time_from_data(...) 

            # 長時間リマインド
            if elapsed > REMIND_THRESHOLD:
                 if needs_remind(entry_id, last_reminded):
                    user = await client.fetch_user(int(discord_id))
                    await user.send(f"⚠️ 長時間実行リマインド...")
                    last_reminded[entry_id] = datetime.now(pytz.utc)

            # 想定時間超過リマインド
            if estimated_time_minutes and elapsed > (estimated_time_minutes * 60):
                 if needs_remind(entry_id, last_reminded):
                     user = await client.fetch_user(int(discord_id))
                     await user.send(f"⏰ 想定時間超過リマインド...")
                     last_reminded[entry_id] = datetime.now(pytz.utc)
    # await asyncio.sleep(REMIND_INTERVAL) # tasks.loopが間隔を管理
```
#### 👀補足

自己管理・タスク報告を効率化する仕組みは、学生や開発チーム向けにかなり実用的AI提案と組み合わせることで、*Slack*では真似できない **進捗文化の自動化**を狙っています

---
# その他の機能

### Togglには必要な情報のみを指定されたフォーマットで投稿
Toggl API v9の仕様変更:
**調査結果1（Togglコミュニティ）から、時間エントリ作成時にproject_nameではなくproject_idが必要！**

パラメータdurationが-1に設定されているか確認が必要
```python
def has_github_issue_url(content: str) -> bool:
    """メッセージ内にGitHub issue URLが含まれているかチェック"""
    url_pattern = re.compile(r'https?://(?:www\.)?github\.com/[^/]+/[^/]+/issues/\d+')
    return bool(url_pattern.search(content))

def get_github_issue_urls(issue_numbers: List[str], repo_url: str) -> List[str]:
    """GitHubのIssue番号からURLのリストを生成する"""
    if not repo_url or not issue_numbers:
        return []
```
### 提案された内容に対して、再度botが反応しないように、自分自身のリアクションに対しては無視する。
```python
    @bot.event
    async def on_reaction_add(reaction, user):
        # 自分自身のリアクションは無視
        if user.bot:
            return
        
        # 進捗報告関連のリアクション処理
        await handle_reaction_add(reaction, user)
```
### botへのアクセス制御
```python
# discord_bot.py
ALLOWED_ROLES = {
    "register": ["Admin"], # Togglコマンドは 'Admin' ロールを持つ人のみ
    "github": ["dev"],
    # ... 他のコマンドタイプ ...
    "ranking": [], # 利用ランキングは誰でもOK
}

def has_required_roles(member, command_type: str) -> bool:
    if command_type not in ALLOWED_ROLES: return True # 設定なければ誰でもOK
    if not isinstance(member, discord.Member): # DMの場合
        return not ALLOWED_ROLES[command_type]

    user_roles = [role.name for role in member.roles]
    for allowed_role in ALLOWED_ROLES[command_type]:
        if allowed_role in user_roles:
            return True
    return False

```
### 紹介した以外で実装した、必須の事項
 - データ処理
     - **タイムゾーン調整**: Togglのデフォルトタイムゾーン（UTC）とユーザーローカルタイムの変換
     - **未分類タスク処理**:プロジェクト未設定エントリーへのデフォルト値割当
     - **並べ替えアルゴリズム**: 開始時間（start字段）をキーとしたソート実装
 - 通知機能
   - **切り忘れ防止機能の拡張インターバル時間の追加**
   - **投稿内容をベースにした分類処理** 
   - **必要項目が記載されているかチェック**と**不足情報の指摘メッセージ生成**
     - 柔軟な記述（表現ゆれ）にも対応できるようにする（NLP・ChatGPT活用）
     - 過去の投稿履歴に基づく補完提案
     - 進捗報告をGitHub Issuesにコメントとして投稿
 - データ活用
   - GitHub上のIssueの作業履歴のトラッキング
   - 変更内容と時間のから作業評価
   - 同一ユーザーの過去の作業履歴の参照
   - 先行事例や蓄積されたナレッジから、コーディングの提案

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/efec6aa8-792d-4b5b-a6a4-f3ec5c27fc1f.png)



---
## 開発を通じて得られた知見
### 1. AzureWebAppからbackendのAPIを建てるときに、Applicationエラーになる
### ✅ **結論：FTPでファイルをアップロードして、sshで入って直接コマンド打つ**
```
nohup python discord_bot.py > discord_bot.log 2>&1 &
```
- コマンドの各部分の説明
  - > *nohup*
    - ターミナルを切断してもプロセスを継続して実行するためのコマンドです。
  - > *python discord_bot.py*
    - 実行するPythonスクリプトです。ローカルで python discord_bot.py で動作していたスクリプトをそのまま使用できます
  - > discord_bot.log
    - スクリプトの標準出力（stdout）を discord_bot.log というファイルにリダイレクトします。ログ出力先はお好みのファイル名に変更可能です

  - 2>&1
    - エラー出力（stderr）も標準出力と同じファイルにリダイレクトしています。これにより、全てのログが1つのファイルにまとめられます

  - &
    - コマンドをバックグラウンドで実行するための指定です。これにより、実行後にターミナルがすぐに使える状態となります
---
### 2. ローカルで問題なく動いているが、Azure App Service on Windows の場合Application Errorになる。 
理由は複数考えられるが...

### ✅ **結論：`reload=True` が原因でした！**
```python
reload=os.getenv("DEBUG", "false").lower() == "true"
```
この行により、Azure 環境で環境変数 `DEBUG=true` が設定されていた場合に `reload=True` となり、それが Windows 環境ではうまく動作しません。

---

### 🔍 `reload=True` の副作用（Azure App Service on Windows の場合）
- `reload=True` は、`watchdog` というモジュールを使ってファイルの変更を監視し、再起動する開発用機能です。
- **プロセスを再起動する必要があるため、内部的に子プロセス（multiprocessing）を使います。**
- Windows 環境では `__name__ == "__main__"` ブロックの外で `uvicorn.run()` を使うと **子プロセスがうまく起動せず、アプリケーションエラー（タイムアウトやプロセス停止）になります。**
- Azure Web Apps の Windows コンテナでは、`reload=True` を使うと子プロセス起動がブロックされる場合があり、アプリが落ちる。

---

### ✅ **動くコード（reload=False の安全設計）**
```python
if __name__ == '__main__':
    import uvicorn
    port = int(os.environ.get("PORT", 8000))
    uvicorn.run("main:app", host="0.0.0.0", port=port)
```
このようにすれば、`reload` を明示的に使わず、本番環境にふさわしいシングルプロセスで起動するため、Azure でも問題が起きません。

---
### 🛠 対策・改善案
#### 1. **環境変数 `DEBUG` を本番環境で false にする**
```bash
DEBUG=false
```
#### 2. **reload フラグの指定を避ける or OS 判定を入れる**
```python
import platform
reload_flag = os.getenv("DEBUG", "false").lower() == "true" and platform.system() != "Windows"

uvicorn.run(
    "main:app",
    host="0.0.0.0",
    port=int(os.getenv("PORT", "8000")),
    reload=reload_flag
)
```

---

### 💡補足
Azure Linux プランであればこの問題は発生しにくいですが、Windows 環境では開発用の `reload=True` を避けた方が無難です。

---
### 制限がある中で、同期・非同期の処理を並列でどう行えば良いか？
### ✅ **結論：レートリミット回避にはトークンバケットアルゴリズムが有効**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/a1c51cdd-c7b2-4579-97cb-71890c439162.png)

セッションライフサイクルの適切な管理が安定性の要です。DNSキャッシュの導入で初期接続時間を最大 *30%* 短縮可能!
AWS c5.xlargeインスタンスでの比較（1万リクエスト）：
| 方式 | 時間 | メモリ使用量 | 成功率 |
|------|------|-------------|--------|
| 同期(requests) | 182秒 | 1.2GB | 99.8% |
| aiohttp(基本) | 23秒 | 680MB | 98.5% |
| aiohttp(最適化後) | 15秒 | 450MB | 99.9% |
---
# 感想
1. **外部APIを利用する際は？**
公式ドキュメントを熟読することはもちろん
タイムアウト設定、指数バックオフを用いたリトライ戦略、想定されるエラーコードごとのハンドリング、レート制限への対応が不可欠であることを再認識しました。
2. **Discord Bot開発の奥深さ** 
単純なコマンド応答だけでなく、メッセージ解析、リアクションイベントのハンドリング、ロールベースの権限管理、バックグラウンドタスクの実行など、Discord APIが提供する豊富な機能を活用することで、ユーザー体験を大きく向上させられることを実感しました。


# 参考文献
### Quita - toggleとtoggle!の使い方 
https://qiita.com/TK_WebSE/items/82f6752a26167de0a325

### Azure App serviceでカスタム404ページを稼働させる。

https://learn.microsoft.com/en-us/answers/questions/1826974/how-to-fix-404-not-found-nginx-1-26-1-on-app-servi

### Google Drive API v3 JavaでDrive APIを使う

https://qiita.com/doran/items/15b2c59adb410ddeeb8a

### GithubPAT関連

https://qiita.com/ko-he-8/items/29d72226b93065c676ff


https://qiita.com/pokapu/items/51d4b028b7c843a55929

### Togglとのやりとり関連

https://github.com/toggl/toggl_api_docs/blob/master/reports.md

http://docs.aiohttp.org

https://brightdata.jp/blog/web-scraping-with-aiohttp

https://jp-seemore.com/iot/python/29545/

https://qiita.com/TK_WebSE/items/82f6752a26167de0a325