01Workflows の Webhook にテキストを POST して通知する確認済
Microsoft 365 コネクタ(旧 Office 365 コネクタ)は廃止に向かい、新しい作成は止められる予定である。代わりに Teams の Workflows で Webhook を受けるワークフローを作り、発行された URL へ JSON を POST する。URL は環境変数に置き、既定は DRY_RUN にする。
- Teams で通知先のチャネルの[More options (...)]から[Workflows]を開く
- [Send webhook alerts to a channel]などのテンプレートを選び、設定して[Save]を選ぶ
- 作成後に表示される Webhook のリンクをコピーし、環境変数 TEAMS_WEBHOOK_URL に入れる
- build_payload で {"text": ...} の JSON を作り、28 KB を超えないか確かめる
- DRY_RUN=0 のときだけ POST する。429 が返ったら待ち時間を倍にしながら再試行する
"""Post a plain-text notice to a Teams channel through a Workflows webhook (DRY_RUN by default)."""
import json
import os
import time
import requests
DRY_RUN = os.environ.get("DRY_RUN", "1") != "0"
WEBHOOK_URL = os.environ["TEAMS_WEBHOOK_URL"] # a secret: never hard-code it
MAX_BYTES = 28 * 1024 # size limit stated on the Incoming Webhook page
def build_payload(text):
data = json.dumps({"text": text}, ensure_ascii=False).encode("utf-8")
if len(data) > MAX_BYTES:
raise ValueError("payload is %d bytes; keep it under 28 KB" % len(data))
return data
def send(data, tries=4):
for attempt in range(tries):
r = requests.post(WEBHOOK_URL, data=data, timeout=30,
headers={"Content-Type": "application/json"})
if r.status_code != 429:
r.raise_for_status()
return r.status_code
time.sleep(2 ** attempt) # exponential backoff when throttled
raise RuntimeError("still throttled after %d tries" % tries)
def main():
text = os.environ.get("MESSAGE", "Nightly check finished: 3 documents are waiting for review.")
data = build_payload(text)
if DRY_RUN:
print("DRY_RUN: would POST %d bytes to the workflow" % len(data))
print(data.decode("utf-8"))
return
print("status:", send(data))
if __name__ == "__main__":
main()実行結果(2026-09-12)
DRY_RUN: would POST 71 bytes to the workflow
{"text": "Nightly check finished: 3 documents are waiting for review."}Teams の Workflows の Webhook に requests でテキストを POST する Python を書いて。URL は環境変数 TEAMS_WEBHOOK_URL から読み、DRY_RUN が 0 でない限り送らずに JSON を表示するだけにして。本文が 28 KB を超えたら止め、429 が返ったら指数的に待って再試行して。
Webhook の URL は秘密情報として扱い、コードや共有の文書に書かない。ワークフローはチームではなく作成者(所有者)に結び付くので、所有者がいなくなる前に共同所有者を置く。28 KB と毎秒 4 回の制限は、ページでは Incoming Webhook の説明の中に書かれている。プライベート チャネルへのフロー ボットとしての投稿は開発中とされている。
出典の該当箇所
To automatically post to a chat or channel when a webhook request is received, use the predefined workflow templates or create a workflow from scratch using the When a Teams webhook request is received trigger.
Microsoft 365 Connectors (previously called Office 365 Connectors) are nearing deprecation, and the creation of new Microsoft 365 Connectors will soon be blocked.
The message size limit is 28 KB.
If more than four requests are made in a second, the client connection is throttled until the window refreshes for the duration of the fixed rate.
Workflows are linked only to specific users (referred to as owners of the workflow) and not to a Teams team or channel.
02依頼台帳の状態別件数を Adaptive Card にして Workflows に送る要確認
依頼台帳(CSV)の「状態」ごとの件数を FactSet にまとめ、Adaptive Card の JSON を作る。JSON は type が message で、attachments の contentType が application/vnd.microsoft.card.adaptive の形である。カードを作る部分は関数に分け、手元で JSON を確かめてから送る。
- count_status で CSV の「状態」列を数える
- build_card で TextBlock(題)と FactSet(状態と件数)を持つカードを作る。version はページの例と同じ 1.2 にする
- Action.Submit は Incoming Webhook のカードでは使えないので入れない
- DRY_RUN のまま JSON を表示し、テスト用のチャネルで表示を確かめてから DRY_RUN=0 にする
"""Build an Adaptive Card with ledger status counts and post it to a Workflows webhook (DRY_RUN)."""
import csv
import json
import os
from collections import Counter
import requests
DRY_RUN = os.environ.get("DRY_RUN", "1") != "0"
LEDGER = os.environ.get("LEDGER_CSV", "samples/ledger.csv")
def count_status(path):
with open(path, encoding="utf-8-sig", newline="") as f:
return Counter(row["状態"] for row in csv.DictReader(f))
def build_card(title, counts):
facts = [{"title": k, "value": str(v)} for k, v in counts.most_common()]
return {
"type": "message",
"attachments": [{
"contentType": "application/vnd.microsoft.card.adaptive",
"contentUrl": None,
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.2",
"body": [
{"type": "TextBlock", "text": title, "weight": "bolder", "wrap": True},
{"type": "FactSet", "facts": facts},
],
},
}],
}
def main():
counts = count_status(LEDGER)
card = build_card("Request ledger: %d rows" % sum(counts.values()), counts)
body = json.dumps(card, ensure_ascii=False)
if DRY_RUN:
print("DRY_RUN: would POST this card")
print(body)
return
r = requests.post(os.environ["TEAMS_WEBHOOK_URL"], data=body.encode("utf-8"), timeout=30,
headers={"Content-Type": "application/json"})
r.raise_for_status()
print("status:", r.status_code)
if __name__ == "__main__":
main()実行結果(2026-09-12)
DRY_RUN: would POST this card
{"type": "message", "attachments": [{"contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": null, "content": {"$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.2", "body": [{"type": "TextBlock", "text": "Request ledger: 40 rows", "weight": "bolder", "wrap": true}, {"type": "FactSet", "facts": [{"title": "完了", "value": "14"}, {"title": "差戻し", "value": "11"}, {"title": "確認中", "value": "10"}, {"title": "受付", "value": "5"}]}]}}]}CSV の「状態」列を数えて、Teams 向けの Adaptive Card(TextBlock の題と FactSet の件数)の JSON を作る Python を書いて。JSON は type が message、attachments の contentType が application/vnd.microsoft.card.adaptive の形にして。送信は requests で、DRY_RUN が 0 でない限り JSON を表示するだけにして。
Workflows は Adaptive Card と Message Card の両方に対応するが、ボタンは表示されない。テンプレートのワークフローがこの JSON をそのまま投稿するかは、保存したページでは確かめられなかった。テスト用のチャネルで表示を確かめる。Teams のモバイル版が対応するのは Adaptive Card の 1.6 まで。件数は送る前に元の台帳と照合する。
出典の該当箇所
Workflows support both Adaptive Cards and Message Card format (button rendering won't be supported).
For Adaptive Cards in Incoming Webhooks, all native Adaptive Card schema elements, except Action.Submit, are fully supported.
Microsoft Teams mobile app supports Adaptive Cards up to version 1.6.
03参加しているチームとチャネルの一覧を CSV にする確認済
me.joined_teams でサインインした人が参加しているチームを取り、各チームの channels を $select 付きで読む。チーム名・チャネル名・種類と ID を CSV に書き、投稿先や読み取り先の ID を調べるのに使う。
- SCOPES を Team.ReadBasic.All と Channel.ReadBasic.All にする(どちらも委任で最小の権限)
- joinedTeams は OData のクエリ パラメーターに対応しないので、そのまま get() を呼ぶ
- channels には $select で id・displayName・membershipType を指定する。email の取得は遅いので外す
- flatten で 1 チャネル 1 行の表にし、out_channels.csv に書く
- 他の例で使う TEAM_ID と CHANNEL_ID は、この CSV から環境変数に写す
"""List joined teams and their channels into a CSV (Team.ReadBasic.All + Channel.ReadBasic.All)."""
import asyncio
import csv
import os
from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.teams.item.channels.channels_request_builder import ChannelsRequestBuilder
SCOPES = ["Team.ReadBasic.All", "Channel.ReadBasic.All"]
FIELDS = ["team", "channel", "membership", "team_id", "channel_id"]
def enum_value(v):
return getattr(v, "value", v) or ""
def flatten(teams):
"""teams: [(team_id, team_name, [(channel_id, channel_name, membership), ...]), ...]"""
rows = []
for team_id, team_name, channels in teams:
for channel_id, channel_name, membership in channels:
rows.append({"team": team_name, "channel": channel_name, "membership": membership,
"team_id": team_id, "channel_id": channel_id})
return sorted(rows, key=lambda r: (r["team"], r["channel"]))
async def collect(client):
joined = await client.me.joined_teams.get() # this API takes no OData query options
query = ChannelsRequestBuilder.ChannelsRequestBuilderGetQueryParameters(
select=["id", "displayName", "membershipType"]) # leaving out email is faster
out = []
for team in joined.value or []:
page = await client.teams.by_team_id(team.id).channels.get(
request_configuration=RequestConfiguration(query_parameters=query))
out.append((team.id, team.display_name,
[(c.id, c.display_name, enum_value(c.membership_type)) for c in page.value or []]))
return out
def write_csv(rows, path="out_channels.csv"):
with open(path, "w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=FIELDS)
w.writeheader()
w.writerows(rows)
async def main():
cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
client = GraphServiceClient(credentials=cred, scopes=SCOPES)
rows = flatten(await collect(client))
write_csv(rows)
print("rows:", len(rows))
if __name__ == "__main__":
asyncio.run(main())実行結果(2026-09-12)
rows: 3 営業部 Generalmsgraph-sdk で自分が参加しているチームを取り、各チームのチャネルの id・displayName・membershipType を $select で読んで、1 チャネル 1 行の CSV に書く Python を書いて。権限は Team.ReadBasic.All と Channel.ReadBasic.All。表にする部分は Graph を呼ばない関数に分けて。
個人の Microsoft アカウントでは使えない。Directory.Read.All などの広い権限は後方互換のために残されているだけなので使わない。CSV にはチームとチャネルの名前が入るので、社外秘の名前が無いか確かめてから共有する。
出典の該当箇所
This method doesn't currently support the OData query parameters to customize the response.
Populating the email property for a channel is an expensive operation that results in slow performance. Use $select to exclude the email property to improve performance.
The Directory.Read.All and Directory.ReadWrite.All permissions are supported only for backward compatibility.
04チャネルの投稿を読み、投稿者ごと・日ごとに数える確認済
teams/{id}/channels/{id}/messages で、チャネルの投稿(返信を除く)を読む。1 ページは既定で 20 件で、$top で 50 件まで広げられる。システムのメッセージを除き、投稿者ごとと日ごとの件数を数える。
- SCOPES を ChannelMessage.Read.All(委任)にする。この権限には管理者の同意が要る
- TEAM_ID と CHANNEL_ID を環境変数から読み、$top=50 で最初のページを取る
- odata_next_link をたどって続きを取る。応答は、返信を含むスレッド全体の最終更新日時の順に並ぶ
- message_type が message でないもの(システムのイベントなど)を除く
- tally で投稿者ごと・日ごと(UTC の日付)に数えて表示する
"""Count channel posts per author and per day (ChannelMessage.Read.All, delegated)."""
import asyncio
import os
from collections import Counter
from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.teams.item.channels.item.messages.messages_request_builder import (
MessagesRequestBuilder,
)
SCOPES = ["ChannelMessage.Read.All"] # delegated; needs admin consent
MAX_PAGES = int(os.environ.get("MAX_PAGES", "10"))
def to_row(m):
user = m.from_.user if m.from_ and m.from_.user else None
return {"type": getattr(m.message_type, "value", m.message_type),
"author": user.display_name if user else "",
"created": str(m.created_date_time or "")}
def tally(rows):
posts = [r for r in rows if r["type"] == "message"] # skip system events
by_author = Counter(r["author"] for r in posts)
by_day = Counter(r["created"][:10] for r in posts) # UTC date
return by_author, dict(sorted(by_day.items()))
async def main():
cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
client = GraphServiceClient(credentials=cred, scopes=SCOPES)
builder = (client.teams.by_team_id(os.environ["TEAM_ID"])
.channels.by_channel_id(os.environ["CHANNEL_ID"]).messages)
query = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(top=50)
page = await builder.get(request_configuration=RequestConfiguration(query_parameters=query))
rows, pages = [], 0
while page:
rows += [to_row(m) for m in page.value or []]
pages += 1
if not page.odata_next_link or pages >= MAX_PAGES:
break
page = await builder.with_url(page.odata_next_link).get()
by_author, by_day = tally(rows)
print("posts:", sum(by_author.values()), "pages:", pages)
for name, n in by_author.most_common(10):
print(n, name)
for day, n in by_day.items():
print(day, n)
if __name__ == "__main__":
asyncio.run(main())実行結果(2026-09-12)
[('担当A', 2), ('担当B', 2), ('担当C', 2)] {'2026-09-08': 2, '2026-09-09': 2, '2026-09-10': 2}msgraph-sdk でチャネルのメッセージを $top=50 で読み、odata_next_link をたどって、message_type が message のものだけを投稿者ごと・日ごとに数える Python を書いて。チームとチャネルの ID は環境変数から読み、数える部分は Graph を呼ばない関数に分けて。
アプリの権限で読む場合の最小の権限は ChannelMessage.Read.Group で、リソース固有の同意を使う。Teams の API のうち機密のデータに触れるものは protected API とされ、ユーザーのいないアクセスには要件がある。その要件の中身は、保存したページには書かれていない。systemEventMessage は Prefer: include-unknown-enum-members を付けないと別の値で返る。投稿は個人の発言なので、集計の目的と保存期間を決めてから使う。
出典の該当箇所
Retrieve the list of messages (without the replies) in a channel of a team.
The default page size is 20 messages. You can extend up to 50 channel messages per page.
The channel messages in the response are sorted by the last modified date of the entire reply chain, including both the root channel message and its replies.
The ChannelMessage.Read.Group permission uses resource-specific consent.
Microsoft Teams APIs in Microsoft Graph that access sensitive data are considered protected APIs. You can call these APIs as long as the requirements for accessing without a user are met.
Use the Prefer: include-unknown-enum-members request header to get the following members in this evolvable enum: systemEventMessage.
Allows an app to read a channel's messages in Microsoft Teams, on behalf of the signed-in user. AdminConsentRequired Yes Yes
05$expand=replies で、返信の付いていない投稿を見つける確認済
チャネルのメッセージを $expand=replies で読むと、各投稿に返信が付いて返る。返信が無いまま一定の時間が過ぎた投稿を一覧にし、対応漏れの確認に使う。既定で返信は 200 件まで含まれ、それを超える分は [email protected] で取る。
- MessagesRequestBuilderGetQueryParameters に expand=["replies"] と top を指定する
- to_row で投稿ごとに返信の数と、タグを除いた本文の冒頭を取る
- unanswered に現在時刻と HOURS(既定は 1 日分)を渡し、返信が無く古い投稿を選ぶ
- 一覧を担当者が見て、返事が要るものかを判断する
"""List channel posts that still have no reply ($expand=replies, ChannelMessage.Read.All)."""
import asyncio
import os
import re
from datetime import datetime, timedelta, timezone
from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.teams.item.channels.item.messages.messages_request_builder import (
MessagesRequestBuilder,
)
SCOPES = ["ChannelMessage.Read.All"]
HOURS = float(os.environ.get("HOURS", "24"))
TAG = re.compile(r"<[^>]+>")
def as_dt(v):
if isinstance(v, str):
return datetime.fromisoformat(v.replace("Z", "+00:00"))
return v
def to_row(m):
user = m.from_.user if m.from_ and m.from_.user else None
text = TAG.sub("", (m.body.content if m.body else "") or "")
return {"id": m.id, "type": getattr(m.message_type, "value", m.message_type),
"author": user.display_name if user else "", "created": m.created_date_time,
"replies": len(m.replies or []), "preview": text[:40]}
def unanswered(rows, now, hours):
limit = now - timedelta(hours=hours)
return [r for r in rows
if r["type"] == "message" and r["replies"] == 0 and as_dt(r["created"]) < limit]
async def main():
cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
client = GraphServiceClient(credentials=cred, scopes=SCOPES)
builder = (client.teams.by_team_id(os.environ["TEAM_ID"])
.channels.by_channel_id(os.environ["CHANNEL_ID"]).messages)
query = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(top=50, expand=["replies"])
page = await builder.get(request_configuration=RequestConfiguration(query_parameters=query))
rows = [to_row(m) for m in page.value or []] # first page only
hits = unanswered(rows, datetime.now(timezone.utc), HOURS)
print("posts checked:", len(rows), "no reply:", len(hits))
for r in hits:
print(as_dt(r["created"]).date(), r["author"], r["preview"])
if __name__ == "__main__":
asyncio.run(main())実行結果(2026-09-12)
170100 担当B R-2026-011 の Web 記事、表記の指摘が 3 件あります。
170300 担当A R-2026-014 のプレスリリース、差し替え版はどこにありますか。msgraph-sdk でチャネルのメッセージを $expand=replies 付きで読み、返信が 0 件で指定した時間より前に投稿されたものを一覧にする Python を書いて。本文は HTML なのでタグを除いて冒頭だけ表示して。判定する関数は、現在時刻を引数で受け取り、Graph を呼ばない形にして。
返信の無い投稿がすべて対応漏れとは限らない(お知らせなど)。最後の判断は人が行う。この例は最初のページだけを見る。本文は HTML で返るので、ここでは簡単にタグを除いただけである。本文の冒頭を画面や CSV に出すときは、見せてよい相手か確かめる。
出典の該当箇所
Apply $expand to get the properties of channel messages that are replies. By default, a response can include up to 200 replies.
For an operation that expands channel messages with more than 200 replies, use the request URL returned in [email protected] to get the next page of replies.
06Graph で未完了の依頼一覧をチャネルに投稿する(既定は DRY_RUN)確認済
依頼台帳から未完了の依頼を選び、HTML の箇条書きにしてチャネルに投稿する。投稿は委任の ChannelMessage.Send で、サインインした本人の名前で出る。既定の DRY_RUN では、投稿先のチャネル名と本文を表示するだけにする。
- SCOPES を Channel.ReadBasic.All(投稿先の確認)と ChannelMessage.Send にする
- open_requests で「状態」が完了でない行を選ぶ
- build_html で値を html.escape に通してから、箇条書きの HTML にする
- channels.by_channel_id(...).get() でチャネル名を取り、投稿先を表示する
- DRY_RUN=0 のときだけ、BodyType.Html の ChatMessage を messages.post に渡す
"""Post open requests from the ledger to a Teams channel via Graph (DRY_RUN by default)."""
import asyncio
import csv
import html
import os
from azure.identity import DeviceCodeCredential
from msgraph import GraphServiceClient
from msgraph.generated.models.body_type import BodyType
from msgraph.generated.models.chat_message import ChatMessage
from msgraph.generated.models.item_body import ItemBody
SCOPES = ["Channel.ReadBasic.All", "ChannelMessage.Send"]
DRY_RUN = os.environ.get("DRY_RUN", "1") != "0"
LEDGER = os.environ.get("LEDGER_CSV", "samples/ledger.csv")
def open_requests(path):
with open(path, encoding="utf-8-sig", newline="") as f:
return [r for r in csv.DictReader(f) if r["状態"] != "完了"]
def build_html(rows, limit=20):
items = "".join("<li>%s %s (%s)</li>" % (html.escape(r["依頼ID"]), html.escape(r["文書名"]),
html.escape(r["状態"])) for r in rows[:limit])
more = "<p>... and %d more</p>" % (len(rows) - limit) if len(rows) > limit else ""
return "<p><b>Open requests: %d</b></p><ul>%s</ul>%s" % (len(rows), items, more)
async def main():
content = build_html(open_requests(LEDGER))
cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
client = GraphServiceClient(credentials=cred, scopes=SCOPES)
channel = client.teams.by_team_id(os.environ["TEAM_ID"]).channels.by_channel_id(os.environ["CHANNEL_ID"])
info = await channel.get() # confirm the target before posting
if DRY_RUN:
print("DRY_RUN: would post to channel:", info.display_name)
print(content)
return
sent = await channel.messages.post(ChatMessage(body=ItemBody(content_type=BodyType.Html, content=content)))
print("posted message id:", sent.id)
if __name__ == "__main__":
asyncio.run(main())実行結果(2026-09-12)
<p><b>Open requests: 26</b></p><ul><li>R-2026-002 文書02(架空) (受付)</li><li>R-2026-003 文書03(架空) (差戻し)</li><li>R-2026-005 文書05(架空) (確認中)</li><li>R-2026-006 文書06(架空) (確認中)</li><li>R-2026-007 文書07(架空) (確認中)</li><li>R-2026-008 文書08(架空) (受付)</li><li>R-2026-009 文書09(架空) (受付)</li><li>R-2026-010 文書10(架空) (確認中)<CSV の依頼台帳から状態が完了でない行を選び、HTML の箇条書きにして msgraph-sdk でチャネルに投稿する Python を書いて。値は html.escape を通し、投稿の前にチャネル名を表示して。DRY_RUN が 0 でない限り投稿せず、本文を表示するだけにして。権限は ChannelMessage.Send と Channel.ReadBasic.All。
Teams をログの置き場として使うのは利用規約に反する。人が読むメッセージだけを送る。アプリの権限(Teamwork.Migrate.All)はデータ移行のためだけに使える。送った後で直すより、DRY_RUN の表示で宛先と本文を確かめる方が確実である。依頼の文書名に社外秘の情報が無いか確かめる。
出典の該当箇所
It is a violation of the terms of use to use Microsoft Teams as a log file. Only send messages that people will read.
Application permissions are only supported for migration.
In the request body, supply a JSON representation of a chatMessage object. Only the body property is mandatory.
Allows an app to send channel messages in Microsoft Teams, on behalf of the signed-in user.
07Agents SDK で、依頼番号の状態を答える最小のボットを作る要確認
microsoft-agents-hosting-aiohttp で /api/messages を待ち受け、AgentApplication のハンドラーで返事を返す。例は「/status 依頼番号」と送ると、依頼台帳の状態を答えるボットである。返事を作る部分は関数に分け、手元で確かめる。
- pip で microsoft-agents-hosting-aiohttp と microsoft-agents-authentication-msal を入れる
- load_configuration_from_env で環境変数から接続の設定を読み、MsalConnectionManager と CloudAdapter(connection_manager=...) を作る
- AgentApplication の activity("message") にハンドラーを登録し、返事は reply_for で作る
- run_app で localhost の 3978 番を待ち受け、Microsoft 365 Agents Playground(teamsapptester)から試す
- Teams で使うときは Azure Bot の[Settings]>[Configuration]で Messaging endpoint を {URL}/api/messages にする
"""Minimal Microsoft 365 Agents SDK bot: '/status <request ID>' answers from a ledger CSV."""
import csv
import os
import re
from aiohttp.web import Application, Request, Response, run_app
from microsoft_agents.activity import load_configuration_from_env
from microsoft_agents.authentication.msal import MsalConnectionManager
from microsoft_agents.hosting.aiohttp import CloudAdapter, jwt_authorization_middleware, start_agent_process
from microsoft_agents.hosting.core import AgentApplication, Authorization, MemoryStorage, TurnContext, TurnState
LEDGER = os.environ.get("LEDGER_CSV", "samples/ledger.csv")
STATUS = re.compile(r"^/status\s+(R-\d{4}-\d{3})$", re.I)
def load_ledger(path):
with open(path, encoding="utf-8-sig", newline="") as f:
return {row["依頼ID"]: row for row in csv.DictReader(f)}
def reply_for(text, ledger):
m = STATUS.match((text or "").strip())
if not m:
return "Send '/status <request ID>', for example /status R-2026-005."
row = ledger.get(m.group(1).upper())
if not row:
return "%s is not in the ledger." % m.group(1)
return "%s %s: %s (owner %s)" % (row["依頼ID"], row["文書名"], row["状態"], row["担当者"])
def build_app():
# Reads CONNECTIONS__SERVICE_CONNECTION__SETTINGS__* etc. from the environment
config = load_configuration_from_env(os.environ)
connections = MsalConnectionManager(**config)
storage = MemoryStorage()
app = AgentApplication[TurnState](
storage=storage, adapter=CloudAdapter(connection_manager=connections),
authorization=Authorization(storage, connections, **config), **config)
ledger = load_ledger(LEDGER)
@app.activity("message")
async def on_message(context: TurnContext, _state: TurnState):
await context.send_activity(reply_for(context.activity.text, ledger))
return app, connections
def serve(agent_app, auth_config):
async def entry_point(req: Request) -> Response:
return await start_agent_process(req, req.app["agent_app"], req.app["adapter"])
web = Application(middlewares=[jwt_authorization_middleware])
web.router.add_post("/api/messages", entry_point)
web["agent_configuration"] = auth_config
web["agent_app"] = agent_app
web["adapter"] = agent_app.adapter
run_app(web, host="localhost", port=int(os.environ.get("PORT", "3978")))
if __name__ == "__main__":
APP, CONNECTIONS = build_app()
serve(APP, CONNECTIONS.get_default_connection_configuration())実行結果(2026-09-12)
AgentApplication CloudAdapter
R-2026-002 文書02(架空): 受付 (owner 担当A)
R-2099-999 is not in the ledger.
Send '/status <request ID>', for example /status R-2026-005.Microsoft 365 Agents SDK for Python(microsoft-agents-hosting-aiohttp)で、「/status 依頼番号」と送ると CSV の依頼台帳から状態を返すボットを書いて。接続の設定は load_configuration_from_env で環境変数から読み、返事を作る部分は SDK に依らない関数に分けて手元で試せるようにして。
Learn のクイックスタートにある CloudAdapter() は、試験した 1.5.0 では connection_manager か channel_service_client_factory が要るという ValueError で止まった。GitHub のサンプルと同じく connection_manager を渡す。サンプルの設定はクライアント シークレットを環境変数に置く形である。シークレットはコードに書かず、置き場所はセキュリティのページに従う。台帳を答えるボットは、使える人の範囲を決めてから公開する。
出典の該当箇所
Use pip to install the microsoft-agents-hosting-aiohttp package with this command:
ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER)
CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config)
Python using version 3.9 to 3.11
The packages should target Python 3.10 or greater
The teamsapptester command opens your default browser and connects to your agent.