1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-05-29

はじめに

「資格試験を応援する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

技術構成(ざっくり)

実装は次の3段階で構成されています:

  • Markdown記述ルール
  • AWS構成(S3 + Lambda + EventBridge)
  • Power AutomateによるTeams通知

実装詳細

Markdown記述ルール

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

テンプレート:

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

記載例:

# 資格名: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 を指定
Pythonスクリプト
import boto3
import requests
from datetime import datetime, timedelta, timezone
import json
import os

# 環境変数から取得
S3_BUCKET = os.environ["S3_BUCKET"]
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 = date.strip().replace("/", "-")
                if len(raw_date) == 8 and raw_date.isdigit():
                    raw_date = f"{raw_date[:4]}-{raw_date[4:6]}-{raw_date[6:]}"
                current["マイルストーン"].append({
                    "event": event.strip("- ").strip(),
                    "date": raw_date
                })
            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()
    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):
    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 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"])
                        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
    }

Lambdaに付与するIAMロール

  • Lambdaに対し、S3の読み取りとBedrock呼び出し権限を付与
IAMポリシー
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:ListBucket"
            ],
            "Resource": "arn:aws:s3:::<バケット名>"
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": "arn:aws:s3:::<バケット名>/*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "bedrock:InvokeModel"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "*"
        }
    ]
}

Eventbridge

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

Power AutomateによるTeams通知

Power Automateで以下のフローを作成します

Webhook受信設定の作成

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

image.png

メッセージの投稿処理の作成

以下のような処理を順に追加します

image.png

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

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

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

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


 {
    "type": "object",
    "properties": {
        "email": {
            "type": "string"
        },
        "message": {
            "type": "string"
        }
    },
    "required": [
        "email",
        "message"
    ]
}

image.png

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

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

image.png

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

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

以下のようなJSON形式でAdaptive Cardを定義(Teamsに投稿するカード)

{
  "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

ループ内で続けて「チャットやチャネルにカードを投稿する」アクションを追加
投稿に使用する次の項目は、環境に応じて設定する:

  • 投稿者(フローを実行するアカウント)
  • 投稿先(チーム/チャネル)
  • 投稿メッセージ(Adaptive Card形式)

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

image.png

最後に:解決できなかったことと今後の展望

本構成により、独自Bot利用が制限された環境下でも、実用的な通知体験を提供できることが確認できました

一方で、以下のような制限も残ります

  • Power Automate経由の投稿では投稿アイコンの変更ができない
  • 通知はBot名ではなく、Power Automateの投稿者名になる

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

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

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?