PAYATHON 2026

使用 Microsoft Graph 获取 Teams 未读聊天消息

技术老齐

结论

Microsoft Graph 可以返回当前登录用户在某个聊天中的最后阅读时间。这个时间记录在 chat 的 viewpoint.lastMessageReadDateTime 属性中,单条 chatMessage 和聊天成员对象都不包含该信息。

获取这个属性需要满足两个条件:

  • 使用 Chat.Read 等委托权限,以用户身份获取令牌。
  • 查询聊天时显式请求 viewpoint。

Microsoft Graph 不直接返回聊天的“未读消息数量”。要计算未读消息,通常需要比较 viewpoint.lastMessageReadDateTime 和消息的 createdDateTime。

使用客户端凭据等 application-only 身份时,Graph 无法识别“当前用户”的阅读位置,也就无法通过 viewpoint 获取个人未读状态。即使使用管理员身份或取得管理员同意,这一限制仍然存在。

为什么当前代码取不到阅读时间

lastReadDateTime 和 lastMessageReadDateTime 都不属于 chatMessage,因此下面的写法取不到结果:

m.get("lastReadDateTime")

/chats/{chat-id}/members 返回聊天成员信息,其中也没有每个成员的阅读回执。阅读时间位于:

chat.viewpoint.lastMessageReadDateTime

查询聊天时需要显式选择该属性:

GET https://graph.microsoft.com/v1.0/me/chats?$select=id,topic,chatType,viewpoint
Authorization: Bearer <token>

响应结构通常如下:

{
  "id": "19:...",
  "chatType": "oneOnOne",
  "topic": null,
  "viewpoint": {
    "isHidden": false,
    "lastMessageReadDateTime": "2026-10-28T08:25:41.123Z"
  }
}

如果 viewpoint 仍然为空,先确认访问令牌是否由委托登录流程签发,而非 application-only 令牌。

使用 MSAL 持久化登录状态

每次调用 acquire_token_interactive() 都会打开登录窗口。可以持久化保存 MSAL 令牌缓存,并在程序启动时先调用 acquire_token_silent()。访问令牌过期后,MSAL 会利用缓存中的账户信息和刷新令牌进行续期。

下面的示例会持久化 MSAL 缓存并静默获取令牌,然后查询 viewpoint.lastMessageReadDateTime,筛选创建时间晚于最后阅读时间的消息,同时排除自己发送的消息。

import json
import os
import time
from datetime import datetime
from pathlib import Path

import msal
import requests

CLIENT_ID = "YOUR_CLIENT_ID"
TENANT_ID = "YOUR_TENANT_ID"

AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
SCOPES = ["Chat.Read"]
GRAPH_ROOT = "https://graph.microsoft.com/v1.0"
POLL_INTERVAL = 30

CACHE_FILE = Path.home() / ".cache" / "teams-unread" / "msal-cache.json"

token_cache = msal.SerializableTokenCache()

if CACHE_FILE.exists():
    token_cache.deserialize(CACHE_FILE.read_text(encoding="utf-8"))

app = msal.PublicClientApplication(
    CLIENT_ID,
    authority=AUTHORITY,
    token_cache=token_cache,
)


def save_token_cache():
    if not token_cache.has_state_changed:
        return

    CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
    CACHE_FILE.write_text(token_cache.serialize(), encoding="utf-8")
    os.chmod(CACHE_FILE, 0o600)


def get_access_token():
    accounts = app.get_accounts()
    result = None

    if accounts:
        result = app.acquire_token_silent(
            scopes=SCOPES,
            account=accounts[0],
        )

    if not result:
        result = app.acquire_token_interactive(scopes=SCOPES)

    save_token_cache()

    if "access_token" not in result:
        raise RuntimeError(
            result.get("error_description", "Microsoft 登录失败")
        )

    return result["access_token"]


def graph_get(url, params=None):
    while True:
        headers = {
            "Authorization": f"Bearer {get_access_token()}",
            "Accept": "application/json",
        }

        response = requests.get(
            url,
            headers=headers,
            params=params,
            timeout=30,
        )

        if response.status_code == 429:
            retry_after = int(response.headers.get("Retry-After", "30"))
            time.sleep(retry_after)
            continue

        response.raise_for_status()
        return response.json()


def get_collection(url, params=None):
    items = []

    while url:
        data = graph_get(url, params=params)
        items.extend(data.get("value", []))

        url = data.get("@odata.nextLink")
        params = None

    return items


def parse_graph_time(value):
    return datetime.fromisoformat(value.replace("Z", "+00:00"))


def list_chats():
    return get_collection(
        f"{GRAPH_ROOT}/me/chats",
        params={
            "$select": "id,topic,chatType,viewpoint",
        },
    )


def get_current_user_id():
    data = graph_get(
        f"{GRAPH_ROOT}/me",
        params={"$select": "id"},
    )
    return data["id"]


def get_unread_messages(chat, current_user_id):
    viewpoint = chat.get("viewpoint") or {}
    last_read_value = viewpoint.get("lastMessageReadDateTime")

    if not last_read_value:
        # Graph 没有返回可用的阅读位置时,不应把全部历史记录
        # 直接判定为未读。
        return []

    last_read = parse_graph_time(last_read_value)

    messages = get_collection(
        f"{GRAPH_ROOT}/chats/{chat['id']}/messages",
        params={
            "$orderby": "createdDateTime desc",
            "$top": "50",
        },
    )

    unread = []

    for message in messages:
        created_value = message.get("createdDateTime")
        if not created_value:
            continue

        created = parse_graph_time(created_value)
        if created <= last_read:
            continue

        sender = (message.get("from") or {}).get("user") or {}
        if sender.get("id") == current_user_id:
            continue

        # 排除成员变更等系统事件
        if message.get("messageType") != "message":
            continue

        unread.append(message)

    return unread


def main():
    current_user_id = get_current_user_id()

    while True:
        total_unread = 0

        for chat in list_chats():
            unread = get_unread_messages(chat, current_user_id)
            if not unread:
                continue

            total_unread += len(unread)
            name = chat.get("topic") or chat.get("chatType") or chat["id"]

            print(f"{name}: {len(unread)} 条未读消息")

            for message in sorted(
                unread,
                key=lambda item: item["createdDateTime"],
            ):
                sender = (
                    ((message.get("from") or {}).get("user") or {})
                    .get("displayName", "未知发送者")
                )
                content = (message.get("body") or {}).get("content", "")
                print(f"  {sender}: {content}")

        if total_unread == 0:
            print("当前没有检测到未读聊天消息")

        time.sleep(POLL_INTERVAL)


if __name__ == "__main__":
    try:
        main()
    finally:
        save_token_cache()

应用注册需要启用公共客户端登录,并配置 Microsoft Graph 委托权限 Chat.Read。遇到组织的条件访问策略、多重身份验证、密码变更或管理员撤销会话时,用户仍可能需要重新登录。令牌缓存可以避免程序正常启动时反复登录,但不能绕过这些安全策略。

如果运行环境不方便使用浏览器交互,可以将交互式登录改为设备代码流程。首次运行仍需用户完成授权,后续运行则可以继续使用同一份持久化缓存。

关于“未读”的准确性

计算方式为:

消息 createdDateTime > viewpoint.lastMessageReadDateTime

实际使用时还要处理以下边界情况:

  • 自己发送的消息通常不计入未读提醒。
  • 系统事件、删除消息和应用消息可能没有普通用户发送者。
  • body.content 通常是 HTML,直接输出可能带有标签。
  • 未读消息数量超过 $top 时,需要继续读取 @odata.nextLink。示例中的 get_collection() 已经处理了分页。
  • lastMessageReadDateTime 取决于 Teams 的阅读状态,其更新可能存在延迟。
  • 该值为空时,不宜把整个聊天历史全部视为未读。首次运行可以建立本地基线,此后只检测新增消息。

Graph 提供的是最后阅读时间,没有权威的 unreadCount 字段,所以这里得到的未读数量是根据时间戳推算的。

降低轮询带来的问题

每隔 10 秒遍历全部聊天和消息会增加请求量,也可能触发 Graph 限流。用于个人桌面提醒时,可以将轮询间隔设为 30 至 60 秒,并按照 429 Too Many Requests 响应中的 Retry-After 等待后重试。

需要长期运行并接近实时接收消息时,可以通过 Microsoft Graph change notifications 订阅聊天消息变化。这种方案需要提供 Graph 能够访问的 HTTPS 回调地址,并定期续订。通知只能表明消息发生了变化;要判断消息是否已读,仍需查询 viewpoint 或维护本地状态。

如果只是要实现 Linux 托盘提醒,也可以维护本地消息水位,记录上次看到的消息 ID 或时间。Graph 返回更新的消息后,再调用 notify-send 或托盘程序发出提示。这种方式可以可靠发现“脚本启动后出现的新消息”,却无法知道用户是否已在 Teams、Firefox 或其他设备上读过,因此不能替代 Teams 的真实阅读状态。

备注:内容仅供参考。