TeamsでBotが作れない？“Bot風AI”で資格受験サポートを実現する（AWS + Power Automate）

# はじめに
「資格試験を応援するAIがTeamsで励ましてくれたら面白いのでは？」
社内研修で資格取得をテーマに企画していた際、そうした発想が生まれました。

ただ、当社ではTeamsで独自Botを利用できないという制約があります。Azure Bot Serviceの登録は不可、Graph APIの利用にも制限があります。

そのような環境でもBot風の通知体験を実現したいと考え、本記事ではAWSを用いて応援メッセージを自動投稿する仕組みを紹介します。

## 想定読者
* セキュリティ制約下で、独自Teams Botを構築できない企業の方
* Power Automateを活用してカスタム通知を行いたい方
* ChatGPTやClaudeなどの生成AIを社内通知に活用したい方

# 注意事項
* 本記事で紹介するのは正式なBotではなく、WebhookとLambdaを活用したBot風の実装例です
* Teamsへのメッセージ送信は、Power Automateで制御しています
* 「Botを作れる環境」の方には不要な苦労ばかりなので、スキップ推奨です

# 機能概要（できること）
* Markdown形式で登録された社員ごとの資格目標をS3から読み込み
* 当日のマイルストーンに一致する場合、応援メッセージを生成
* トーン（やさしめ／スパルタ）に応じたメッセージをTeamsでメンション付きで通知

通知例（AWS SAA資格を6/1に勉強開始する人の場合）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3191704/fadba2fb-3382-4020-839c-b62b2fdbda56.png)

# 技術構成（ざっくり）
実装は次の3段階で構成されています：
* Markdown記述ルール
* AWS構成（S3 + Lambda + EventBridge）
* Power AutomateによるTeams通知

# 実装詳細
## Markdown記述ルール

ユーザーごとの資格情報はMarkdownで管理します。以下のような形式で記述してください

テンプレート：
```markdown
# 資格名：<資格名>
## トーン：<やさしめ、スパルタ>
## 受験マイルストーン
- 勉強開始：<yyyy/mm/dd>
- 申込期限：<yyyy/mm/dd>
- 受験日：<yyyy/mm/dd>
```

記載例：

```markdown
# 資格名：AWS SAA
## トーン：やさしめ
## 受験マイルストーン
- 勉強開始：2024/05/14
- 申込期限：2024/05/18
- 受験日：2024/05/30
```
作成したMarkdownファイルは、メールアドレスをファイル名とし、S3にアップロードします（例：taro@example.com.md）

## AWS構成
以下のサービスを使用します：S3、Lambda、EventBridge、IAM

### S3
* 任意のバケット名で作成
* Lambda内の環境変数 S3_BUCKET に設定
* Lambdaに付与するIAMポリシー内でのバケット名を参照

### Lambda
* Markdownを読み込み、期日に合致するマイルストーンを検出
* Bedrock Claudeで応援メッセージを生成し、Webhookに送信
* 環境変数には S3_BUCKET、WEBHOOK_URL を指定

<details><summary>Pythonスクリプト</summary>

```python
import boto3
import requests
from datetime import datetime, timedelta, timezone
import json
import os
import logging

# ロガー設定
logger = logging.getLogger()
logger.setLevel(logging.INFO)

# 環境変数から取得
S3_BUCKET = os.environ["S3_BUCKET"]
S3_PREFIX = os.environ["S3_PREFIX"]
WEBHOOK_URL = os.environ["WEBHOOK_URL"]
MODEL_ID = os.environ.get("MODEL_ID", "anthropic.claude-3-sonnet-20240229-v1:0")

s3 = boto3.client("s3")
bedrock = boto3.client("bedrock-runtime")

def list_md_files():
    result = []
    paginator = s3.get_paginator("list_objects_v2")
    for page in paginator.paginate(Bucket=S3_BUCKET, Prefix=S3_PREFIX):
        for obj in page.get("Contents", []):
            if obj["Key"].endswith(".md"):
                result.append(obj["Key"])
    return result

def parse_markdown_multi(md_text):
    lines = md_text.splitlines()
    blocks = []
    current = None

    for line in lines:
        line = line.replace("：", ":").strip()

        if line.startswith("# 資格名:"):

            if current:
                blocks.append(current)

            current = {
                "資格名": line.split(":", 1)[1].strip(),
                "トーン": "優しめ",
                "マイルストーン": []
            }

        elif current and line.lstrip("#").strip().startswith("トーン:"):
            current["トーン"] = line.split(":", 1)[1].strip()

        elif current and "-" in line and ":" in line:
            try:
                event, date = line.split(":", 1)
                raw = date.strip().replace("/", "-")
                parts = raw.split("-")
                if len(parts) == 3 and all(part.isdigit() for part in parts):
                    year = parts[0]
                    month = parts[1].zfill(2)
                    day = parts[2].zfill(2)
                    normalized = f"{year}-{month}-{day}"
                    try:
                        _ = datetime.strptime(normalized, "%Y-%m-%d")
                    except ValueError:
                        continue

                    current["マイルストーン"].append({
                        "event": event.strip("- ").strip(),
                        "date": normalized
                    })
            except:
                continue

    if current:
        blocks.append(current)

    return blocks

def should_notify_today(milestones):
    jst = timezone(timedelta(hours=9))
    today = datetime.now(jst).date().isoformat()

    for m in milestones:
        if m["date"] == today:
            return [m["event"] for m in milestones if m["date"] == today]

def generate_message_with_bedrock(qualification, event, tone):
    prompt_content = f"""
\n\nHuman:
あなたは「試験みまもりさん」という名前の資格試験Botです。
以下の情報をもとに、**指定されたトーンに応じた応援メッセージ**を生成してください。

---
【入力情報】
資格名: {qualification}
イベント内容: {event}
トーン: {tone}（やさしめ／スパルタのいずれか）
---

【出力形式】
以下のような構成で出力してください（※口調とアイコンはトーンに応じて変えてください）：

- やさしめ  
📘 試験みまもりさんより  
今日は「{qualification}」の{event}です。 
小さな一歩を重ねていきましょう。きっと大丈夫です🌿

- スパルタ  
🔔 試験みまもりさんが通告します  
今日は{event}のはずです。「やるか、やらないか」じゃない。やるです。  
計画した自分を信じて、今すぐ動いてください。

【出力制約】
- トーンに対応した見出し絵文字とBotの口調を変えてください
- 絵文字は1-2個は使用可能です
- 出力は3〜4行とし、完結にしてください
- **前後に余計な解説や補足をつけず、メッセージ本体のみを返してください**

\n\nAssistant:
"""

    response = bedrock.invoke_model(
        modelId=MODEL_ID,
        contentType="application/json",
        accept="application/json",
        body=json.dumps({
            "anthropic_version": "bedrock-2023-05-31",
            "messages": [
                {
                    "role": "user",
                    "content": prompt_content
                }
            ],
            "max_tokens": 512,
            "temperature": 0.7
        })
    )
    body = json.loads(response["body"].read())
    return body["content"][0]["text"].strip()

def send_to_teams(message, email):
    payload = {
        "attachments": [
            {
                "contentType": "application/json",
                "content": {
                    "email": email,
                    "message": message
                }
            }
        ]
    }
    response = requests.post(WEBHOOK_URL, json=payload)
    return response.status_code

def lambda_handler(event, context):
    jst = timezone(timedelta(hours=9))
    today = datetime.now(jst).date().isoformat()

    files = list_md_files()
    results = []

    for key in files:
        try:
            obj = s3.get_object(Bucket=S3_BUCKET, Key=key)
            md_text = obj["Body"].read().decode("utf-8")
            parsed_blocks = parse_markdown_multi(md_text)
            email = os.path.basename(key).replace(".md", "")

            for block in parsed_blocks:
                block["email"] = email
                events_today = should_notify_today(block["マイルストーン"])

                if events_today:
                    logger.info(f"[MATCHED] {today}: Found match in {key} ({email})")
                    logger.info(f"[MATCHED] {block['資格名']} - Events today: {events_today}")
                else:
                    logger.info(f"[NO MATCH] {today}: No matching milestone in {key} ({email})")
                    pass

                if not events_today:
                    results.append({
                        "file": key,
                        "資格名": block["資格名"],
                        "status": 200,
                        "message": f"No milestone today for {block['資格名']}."
                    })
                    continue

                for event_name in events_today:
                    try:
                        message = generate_message_with_bedrock(
                            block["資格名"], event_name, block["トーン"]
                        )
                        status = send_to_teams(message, block["email"])

                        logger.info(f"[SEND] To: {block['email']} | {block['資格名']} - {event_name}\n{message}")

                        results.append({
                            "file": key,
                            "資格名": block["資格名"],
                            "event": event_name,
                            "status": status,
                            "message": message
                        })
                    except Exception as e:
                        results.append({
                            "file": key,
                            "資格名": block["資格名"],
                            "event": event_name,
                            "status": 500,
                            "error": str(e)
                        })

        except Exception as e:
            results.append({"file": key, "status": 500, "error": str(e)})

    return {
        "statusCode": 200,
        "body": results
    }
```
</details>

### Eventbridge
* 毎日決まった時間にLambdaを起動するスケジュールルールを設定
* ターゲットとして、先ほどデプロイしたLambdaを選択

## Power AutomateによるTeams通知
Power Automateで以下のフローを作成します

### Webhook受信設定の作成
1. ワークフロー新規作成画面で、「最初から作成」を選択
1. 「Webhook 要求を受信したとき」アクションから、フローを開始
1. 「Who can trigger the flow?」で「Anyone」を選択
　※ WebhookURLはフロー作成後に、生成されます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3191704/471db054-6582-4604-81c1-9849837e8991.png)

### メッセージの投稿処理の作成
以下のような処理を順に追加します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3191704/614dae8f-95db-4d11-b062-be0e7575292e.png)

「Apply to each」アクションを作成し、「以前の手順から出力を選択」で`triggerBody()?['attachments']`を指定


「JSONの解析」アクションを追加し、コンテンツで`items('Apply_to_each')?['content']`を指定

これは、Lambdaから送信された Webhookペイロード内の `attachments[].content` をパースする処理です。このフィールドには、通知対象のメールアドレスと生成されたメッセージが JSON形式で含まれています

使用するスキーマは以下のとおり
```json

 {
    "type": "object",
    "properties": {
        "email": {
            "type": "string"
        },
        "message": {
            "type": "string"
        }
    },
    "required": [
        "email",
        "message"
    ]
}
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3191704/99ff3c86-6fe7-480f-9cbb-15aa26ca7545.png)

「ユーザーの検索」アクションを追加し、検索条件（Search term）に`body('JSON_の解析')['email']`を指定

これにより、Teams上のユーザー情報を、Markdownファイル名（＝メールアドレス）に基づいて取得できます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3191704/dccea81e-b4c8-4f58-8557-fa9e0ac3d235.png)

検索結果に対して再度「Apply to each」アクションを追加
「以前の手順から出力を選択」には、`outputs('ユーザーの検索_(V2)')?['body/value']`を指定

ループ内で、「作成」アクションを追加し、Adaptive Card形式のメッセージを作成する

以下のようなJSON形式でAdaptive Cardを定義（Teamsに投稿するカード）
```json
{
  "type": "AdaptiveCard",
  "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
  "version": "1.4",
  "body": [
    {
      "type": "TextBlock",
      "text": " <at>user</at> さん、お知らせです📢\n@{body('JSON_の解析')['message']}",
      "wrap": true
    }
  ],
  "msteams": {
    "entities": [
      {
        "type": "mention",
        "text": "<at>user</at>",
        "mentioned": {
          "id": "@{items('Apply_to_each_2')?['Id']}",
          "name": "@{items('Apply_to_each_2')?['Surname']}@{items('Apply_to_each_2')?['GivenName']}"
        }
      }
    ]
  }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3191704/aae35fdd-d408-4334-bace-11eba50cd2a2.png)

ループ内で続けて「チャットやチャネルにカードを投稿する」アクションを追加
   投稿に使用する次の項目は、環境に応じて設定する：
   - 投稿者（フローを実行するアカウント）
   - 投稿先（チーム／チャネル）
   - 投稿メッセージ（Adaptive Card形式）

メッセージ本文には、事前に構築したアダプティブカードの出力を `outputs('作成')` を指定

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3191704/5dfaadee-1ecb-4dd1-b1c5-b8709d19825a.png)

# 最後に：解決できなかったことと今後の展望
本構成により、独自Bot利用が制限された環境下でも、実用的な通知体験を提供できることが確認できました

一方で、以下のような制限も残ります
* Power Automate経由の投稿では投稿アイコンの変更ができない
* 通知はBot名ではなく、Power Automateの投稿者名になる

ということで、独自Botの利用が許可されるような社内環境の整備を進められたらいいなと思います。

最後までお読みいただきありがとうございました。