Разработка

Как экспортировать групповой чат Microsoft Teams в Markdown: полное руководство

Как экспортировать групповой чат Microsoft Teams в Markdown: полное руководство

** TL;DR: Регистрируем приложение в Azure, пишем Python-скрипт, авторизуемся через браузер, получаем все сообщения из группового чата и сохраняем в красивый .md-файл. Весь процесс — 15 минут.


Зачем это нужно

Знакомая ситуация: в групповом чате Teams за полгода накопились сотни важных сообщений — договорённости, ссылки, решения. Искать что-то через встроенный поиск Teams — мучение. Хочется иметь локальную копию в читаемом формате.

Microsoft не даёт кнопку «Экспортировать чат». Зато даёт Graph API — мощный программный интерфейс ко всем данным Microsoft 365. Через него мы и заберём сообщения.

Что получим в итоге:

  • Полную историю группового чата в .md-файле

  • Имена авторов, временны́е метки, форматирование

  • Ссылки на вложения

  • Возможность запускать экспорт повторно одной командой

Что понадобится:

  • Аккаунт Microsoft 365 (рабочий)

  • Python 3.10+

  • Доступ к Azure Portal (достаточно обычного пользователя — admin consent не нужен)

  • 15 минут времени


Шаг 1. Регистрация приложения в Azure AD

Чтобы обращаться к Graph API, нужно зарегистрировать приложение. Звучит страшно, но по факту — это форма на пять полей.

1.1. Создание регистрации

Откройте portal.azure.com и перейдите:

Azure Active Directory → App registrations → New registration

Заполните:

ПолеЗначение
Nameteams-chat-exporter
Supported account typesAccounts in this organizational directory only (Single tenant)
Redirect URIоставьте пустым

Нажмите Register.

1.2. Запишите идентификаторы

На странице приложения скопируйте два значения — они понадобятся дальше:

  • Application (client) ID — например, a1b2c3d4-e5f6-7890-abcd-ef1234567890

  • Directory (tenant) ID — например, f0e1d2c3-b4a5-6789-0abc-def123456789

1.3. Настройте разрешения

Перейдите в раздел API permissionsAdd a permissionMicrosoft GraphDelegated permissions.

Найдите и отметьте:

✅ Chat.Read
✅ ChatMessage.Read

Нажмите Add permissions.

Важно: это delegated permissions — приложение будет работать от вашего имени и видеть только те чаты, в которых вы состоите. Admin consent не требуется.

1.4. Разрешите public client flow

Перейдите в Authentication → прокрутите вниз до Advanced settings:

Allow public client flows → Yes

Нажмите Save.

Это нужно для device code flow — способа авторизации, при котором вы вводите код в браузере. Без этой настройки авторизация не заработает.


Шаг 2. Подготовка проекта

2.1. Структура файлов

teams-export/
├── .env                  # секреты (не коммитим в git)
├── .gitignore
├── requirements.txt
├── main.py               # основной скрипт
└── output/               # сюда сохраняются .md-файлы

2.2. Зависимости

Создайте файл requirements.txt:

msal>=1.24.0
requests>=2.31.0
python-dotenv>=1.0.0

Установите:

mkdir teams-export && cd teams-export
mkdir output
python -m venv venv
source venv/bin/activate   # Windows: venv\Scripts\activate
pip install -r requirements.txt

2.3. Переменные окружения

Создайте файл .env:

AZURE_CLIENT_ID=a1b2c3d4-e5f6-7890-abcd-ef1234567890
AZURE_TENANT_ID=f0e1d2c3-b4a5-6789-0abc-def123456789
OUTPUT_DIR=./output

И .gitignore:

.env
.token_cache.json
venv/
output/
__pycache__/

Шаг 3. Код

Весь скрипт — один файл main.py. Разберём его по частям.

3.1. Импорты и конфигурация

#!/usr/bin/env python3
"""
Teams Chat → Markdown Exporter

Экспортирует сообщения из группового чата Microsoft Teams
в файл формата Markdown.

Использование:
    python main.py --list                  # показать групповые чаты
    python main.py --interactive           # выбрать чат из списка
    python main.py --chat-id *          # экспорт конкретного чата
    python main.py --chat-id <id> --since 2024-01-01
"""

import os
import re
import argparse
from datetime import datetime, timezone
from html import unescape

import msal
import requests
from dotenv import load_dotenv

load_dotenv()

CLIENT_ID  = os.getenv("AZURE_CLIENT_ID")
TENANT_ID  = os.getenv("AZURE_TENANT_ID")
OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./output")

GRAPH_BASE   = "https://graph.microsoft.com/v1.0"
SCOPES       = ["Chat.Read", "ChatMessage.Read"]
TOKEN_CACHE  = ".token_cache.json"

Здесь всё стандартно: загружаем переменные окружения и задаём константы. TOKEN_CACHE — файл, в который будем сохранять токен, чтобы не логиниться каждый раз.

3.2. Авторизация

# ── Авторизация ───────────────────────────────────────

def get_token() -> str:
    """
    Получить access token через device code flow.

    При первом запуске откроется ссылка в браузере.
    При повторных — токен берётся из кэша.
    """
    # Загружаем кэш токенов
    cache = msal.SerializableTokenCache()
    if os.path.exists(TOKEN_CACHE):
        with open(TOKEN_CACHE) as f:
            cache.deserialize(f.read())

    app = msal.PublicClientApplication(
        CLIENT_ID,
        authority=f"https://login.microsoftonline.com/{TENANT_ID}",
        token_cache=cache,
    )

    # Пробуем получить токен из кэша (без логина)
    accounts = app.get_accounts()
    if accounts:
        result = app.acquire_token_silent(SCOPES, account=accounts[0])
        if result and "access_token" in result:
            _save_cache(cache)
            return result["access_token"]

    # Кэш пуст или протух — запускаем device code flow
    flow = app.initiate_device_flow(scopes=SCOPES)
    if "user_code" not in flow:
        raise RuntimeError(f"Не удалось начать device flow: {flow}")

    print()
    print(f"  🔑 Откройте в браузере:  {flow['verification_uri']}")
    print(f"     Введите код:          {flow['user_code']}")
    print()
    print("  Ожидаю авторизацию...")

    result = app.acquire_token_by_device_flow(flow)
    _save_cache(cache)

    if "access_token" in result:
        return result["access_token"]

    raise RuntimeError(
        f"Авторизация не удалась: {result.get('error_description', result)}"
    )


def _save_cache(cache: msal.SerializableTokenCache):
    """Сохранить кэш токенов на диск."""
    if cache.has_state_changed:
        with open(TOKEN_CACHE, "w") as f:
            f.write(cache.serialize())

Как работает device code flow:

  • Скрипт получает одноразовый код (например, ABCD1234)

  • Вы открываете https://microsoft.com/devicelogin в любом браузере

  • Вводите код и логинитесь своим рабочим аккаунтом

  • Скрипт получает токен доступа

Токен кэшируется в .token_cache.json. При следующем запуске логин не нужен — MSAL автоматически обновит токен через refresh token.

3.3. Работа с Graph API

# ── Graph API ─────────────────────────────────────────

def graph_get(token: str, url: str) -> dict:
    """Выполнить GET-запрос к Graph API."""
    resp = requests.get(
        url,
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    resp.raise_for_status()
    return resp.json()


def graph_get_all(token: str, url: str) -> list[dict]:
    """
    GET-запрос с автоматической пагинацией.

    Graph API возвращает максимум 50 элементов за раз.
    Если есть ещё — в ответе будет поле @odata.nextLink.
    Эта функция проходит по всем страницам.
    """
    items = []
    while url:
        data = graph_get(token, url)
        items.extend(data.get("value", []))
        url = data.get("@odata.nextLink")  # None если страниц больше нет
    return items

Все данные в Graph API отдаются постранично. graph_get_all автоматически проходит по всем страницам и собирает результаты в один список.

3.4. Получение чатов и сообщений

def get_group_chats(token: str) -> list[dict]:
    """Получить все групповые чаты текущего пользователя."""
    all_chats = graph_get_all(
        token,
        f"{GRAPH_BASE}/me/chats?$expand=members&$top=50",
    )
    return [c for c in all_chats if c.get("chatType") == "group"]


def get_chat_info(token: str, chat_id: str) -> dict:
    """Получить информацию о конкретном чате."""
    return graph_get(
        token,
        f"{GRAPH_BASE}/chats/{chat_id}?$expand=members",
    )


def get_messages(
    token: str,
    chat_id: str,
    since: datetime | None = None,
) -> list[dict]:
    """
    Получить сообщения из чата.

    Args:
        token: access token
        chat_id: идентификатор чата
        since: если указано — только сообщения после этой даты
    """
    url = (
        f"{GRAPH_BASE}/chats/{chat_id}/messages"
        f"?$top=50&$orderby=createdDateTime asc"
    )
    if since:
        ts = since.strftime("%Y-%m-%dT%H:%M:%SZ")
        url += f"&$filter=createdDateTime ge {ts}"

    return graph_get_all(token, url)

Обратите внимание на $orderby=createdDateTime asc — сообщения сортируются от старых к новым, чтобы в Markdown-файле хронология была правильной.

3.5. Конвертация HTML → Markdown

Сообщения Teams приходят в формате HTML. Нам нужно конвертировать их в Markdown.

# ── Конвертация HTML → Markdown ───────────────────────

def html_to_md(html: str) -> str:
    """
    Конвертировать HTML-разметку Teams в Markdown.

    Teams использует ограниченное подмножество HTML,
    поэтому полноценный парсер не нужен — хватает regex.
    """
    if not html:
        return ""

    t = html

    # Форматирование текста
    t = re.sub(r"<b[^>]*>(.*?)**",        r"**\1**", t, flags=re.S)
    t = re.sub(r"**]*>(.*?)**", r"**\1**", t, flags=re.S)
    t = re.sub(r"<i[^>]*>(.*?)*",        r"*\1*",   t, flags=re.S)
    t = re.sub(r"*]*>(.*?)*",      r"*\1*",   t, flags=re.S)
    t = re.sub(r"]*>(.*?)",        r"~~\1~~", t, flags=re.S)
    t = re.sub(r"]*>(.*?)", r"~~\1~~", t, flags=re.S)

    # Код
    t = re.sub(r"]*>(.*?)</pre>",    r"```\n\1\n```", t, flags=re.S)
    t = re.sub(r"`]*>(.*?)`",  r"`\1`",         t, flags=re.S)

    # Ссылки
    t = re.sub(
        r'<a[^>]*href="([^"]*)"[^>]*>(.*?)</a>',
        r"[\2](\1)",
        t, flags=re.S,
    )

    # Упоминания (@mention)
    t = re.sub(r"<at[^>]*>(.*?)</at>", r"**@\1**", t, flags=re.S)

    # Списки
    t = re.sub(r"- ]*>(.*?)
",  r"- \1\n", t, flags=re.S)
    t = re.sub(r"</?[uo]l[^>]*>",       "\n",      t, flags=re.S)

    # Блочные элементы → переносы строк
    t = re.sub(r"<br\s*/?>",             "\n",      t, flags=re.S)
    t = re.sub(r"]*>(.*?)

",     r"\1\n\n", t, flags=re.S)
    t = re.sub(r"]*>(.*?)", r"\1\n",   t, flags=re.S)

    # Заголовки
    for i in range(1, 7):
        t = re.sub(
            rf"<h{i}[^>]*>(.*?)</h{i}>",
            rf"{'#' * i} \1\n",
            t, flags=re.S,
        )

    # Убираем оставшиеся HTML-теги
    t = re.sub(r"<[^>]+>", "", t)

    # Декодируем HTML-сущности (&amp; → &, и т.д.)
    t = unescape(t)

    # Убираем тройные+ пустые строки
    t = re.sub(r"\n{3,}", "\n\n", t)

    return t.strip()

Порядок замен важен: сначала “, потом <code>, иначе блоки кода будут обработаны неправильно.

3.6. Форматирование сообщений

# ── Форматирование ────────────────────────────────────

def get_sender(msg: dict) -> str:
    """Извлечь имя отправителя из сообщения."""
    sender = msg.get("from")
    if not sender:
        return "Система"

    user = sender.get("user")
    if user:
        return user.get("displayName", "Неизвестный")

    app = sender.get("application")
    if app:
        return f"🤖 {app.get('displayName', 'Бот')}"

    return "Неизвестный"


def fmt_time(iso_str: str) -> str:
    """ISO 8601 → человекочитаемое время."""
    try:
        dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
        return dt.strftime("%Y-%m-%d %H:%M")
    except (ValueError, AttributeError, TypeError):
        return iso_str or ""


def fmt_reactions(msg: dict) -> str:
    """Собрать реакции на сообщение в строку."""
    reactions = msg.get("reactions", [])
    if not reactions:
        return ""

    counts: dict[str, int] = {}
    for r in reactions:
        emoji = r.get("reactionType", "like")
        counts[emoji] = counts.get(emoji, 0) + 1

    parts = [f"{name} ×{cnt}" for name, cnt in counts.items()]
    return f"  [{', '.join(parts)}]"


def fmt_attachments(msg: dict) -> str:
    """Собрать вложения в строку."""
    attachments = msg.get("attachments", [])
    if not attachments:
        return ""

    lines = []
    for att in attachments:
        name = att.get("name", "файл")
        url = att.get("contentUrl", "")
        if url:
            lines.append(f"  📎 [{name}]({url})")
        else:
            lines.append(f"  📎 {name}")

    return "\n" + "\n".join(lines)

3.7. Экспорт в файл

# ── Экспорт ───────────────────────────────────────────

def export_chat(
    token: str,
    chat_id: str,
    chat_info: dict | None = None,
    since: datetime | None = None,
) -> str:
    """
    Экспортировать чат в Markdown-файл.

    Returns:
        Путь к созданному файлу.
    """
    # Получаем информацию о чате
    if not chat_info:
        chat_info = get_chat_info(token, chat_id)

    topic = chat_info.get("topic") or "Групповой чат"
    members = [
        m.get("displayName", "?")
        for m in chat_info.get("members", [])
    ]

    # Загружаем сообщения
    print(f"  📥 Загрузка сообщений...")
    messages = get_messages(token, chat_id, since=since)
    print(f"  📨 Получено: {len(messages)}")

    # Формируем имя файла
    os.makedirs(OUTPUT_DIR, exist_ok=True)
    safe_topic = re.sub(r'[^\w\s-]', '', topic)[:50].strip().replace(' ', '_')
    safe_topic = safe_topic or "chat"
    filename = f"{safe_topic}_{datetime.now():%Y%m%d_%H%M%S}.md"
    filepath = os.path.join(OUTPUT_DIR, filename)

    # Пишем файл
    count = 0
    with open(filepath, "w", encoding="utf-8") as f:
        # Заголовок документа
        f.write(f"# {topic}\n\n")
        f.write(f"**Участники ({len(members)}):** {', '.join(members)}\n\n")
        if since:
            f.write(f"**Сообщения с:** {since:%Y-%m-%d}\n\n")
        f.write(f"**Экспортировано:** {datetime.now():%Y-%m-%d %H:%M}\n\n")
        f.write("---\n\n")

        # Сообщения
        for msg in messages:
            # Пропускаем системные сообщения
            if msg.get("messageType") == "systemEventMessage":
                continue

            # Извлекаем текст
            body = msg.get("body", {})
            content = body.get("content", "").strip()
            if not content:
                continue

            # Конвертируем HTML → Markdown
            if body.get("contentType") == "html":
                content = html_to_md(content)
            if not content:
                continue

            # Собираем строку
            sender = get_sender(msg)
            time = fmt_time(msg.get("createdDateTime", ""))
            reactions = fmt_reactions(msg)
            attachments = fmt_attachments(msg)

            f.write(f"**{sender}** · {time}{reactions}\n\n")
            f.write(f"{content}{attachments}\n\n")
            f.write("---\n\n")
            count += 1

    print(f"  ✅ Записано {count} сообщений → {filepath}")
    return filepath

3.8. CLI-интерфейс

# ── CLI ───────────────────────────────────────────────

def cmd_list(token: str):
    """Команда: показать список групповых чатов."""
    chats = get_group_chats(token)
    print(f"\n📋 Групповые чаты ({len(chats)}):\n")

    if not chats:
        print("  Групповых чатов не найдено.")
        return

    for i, c in enumerate(chats, 1):
        topic = c.get("topic") or "(без темы)"
        members = [m.get("displayName", "?") for m in c.get("members", [])]
        preview = ", ".join(members[:4])
        if len(members) > 4:
            preview += f" и ещё {len(members) - 4}"

        print(f"  {i}. {topic}")
        print(f"     👥 {preview}")
        print(f"     🆔 {c['id']}")
        print()


def cmd_interactive(token: str, since: datetime | None = None):
    """Команда: интерактивный выбор чата и экспорт."""
    chats = get_group_chats(token)
    print(f"\n📋 Групповые чаты ({len(chats)}):\n")

    for i, c in enumerate(chats, 1):
        topic = c.get("topic") or "(без темы)"
        members = [m.get("displayName", "?") for m in c.get("members", [])]
        print(f"  {i}. {topic}  —  {', '.join(members[:4])}")

    print()
    try:
        choice = int(input(f"Выберите чат (1–{len(chats)}): ")) - 1
        if not (0 <= choice < len(chats)):
            print("❌ Неверный номер")
            return
    except (ValueError, KeyboardInterrupt):
        print("\n❌ Отмена")
        return

    chat = chats[choice]
    print(f"\n📤 Экспорт: {chat.get('topic') or 'Групповой чат'}\n")
    export_chat(token, chat["id"], chat_info=chat, since=since)


def cmd_export(token: str, chat_id: str, since: datetime | None = None):
    """Команда: экспорт конкретного чата по ID."""
    print(f"\n📤 Экспорт чата: {chat_id[:40]}...\n")
    export_chat(token, chat_id, since=since)


def main():
    parser = argparse.ArgumentParser(
        description="Экспорт группового чата Microsoft Teams в Markdown",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Примеры:
  python main.py --list
  python main.py --interactive
  python main.py --interactive --since 2024-06-01
  python main.py --chat-id "19:meeting_abc@thread.v2"
        """,
    )
    parser.add_argument(
        "--list", action="store_true",
        help="Показать все групповые чаты",
    )
    parser.add_argument(
        "--interactive", action="store_true",
        help="Выбрать чат из списка и экспортировать",
    )
    parser.add_argument(
        "--chat-id", type=str,
        help="ID конкретного чата для экспорта",
    )
    parser.add_argument(
        "--since", type=str,
        help="Экспортировать сообщения начиная с даты (YYYY-MM-DD)",
    )
    args = parser.parse_args()

    # Парсим дату
    since = None
    if args.since:
        since = datetime.strptime(args.since, "%Y-%m-%d").replace(
            tzinfo=timezone.utc
        )

    # Авторизация
    print("\n🔐 Авторизация...")
    token = get_token()
    print("✅ Авторизация успешна")

    # Выполняем команду
    if args.list:
        cmd_list(token)
    elif args.interactive:
        cmd_interactive(token, since)
    elif args.chat_id:
        cmd_export(token, args.chat_id, since)
    else:
        parser.print_help()


if __name__ == "__main__":
    main()

Шаг 4. Запуск

Первый запуск — авторизация

python main.py --list

Скрипт выведет:

🔐 Авторизация...

  🔑 Откройте в браузере:  https://microsoft.com/devicelogin
     Введите код:          ABCD1234

  Ожидаю авторизацию...
  • Откройте ссылку в браузере

  • Введите код ABCD1234

  • Войдите рабочим аккаунтом Microsoft

  • Подтвердите разрешения

Скрипт продолжит работу и покажет список чатов:

✅ Авторизация успешна

📋 Групповые чаты (3):

  1. Проект Alpha — ревью
     👥 Иван Петров, Мария Сидорова, Алексей Козлов
     🆔 19:meeting_MjQ5NTg4...@thread.v2

  2. (без темы)
     👥 Ольга Новикова, Дмитрий Волков
     🆔 19:meeting_ZGFkYWRh...@thread.v2

  3. Деплой и инфра
     👥 Сергей Иванов, Анна Петрова, Максим Кузнецов и ещё 2
     🆔 19:meeting_YWJjZGVm...@thread.v2

Интерактивный экспорт

python main.py --interactive
📋 Групповые чаты (3):

  1. Проект Alpha — ревью  —  Иван, Мария, Алексей
  2. (без темы)  —  Ольга, Дмитрий
  3. Деплой и инфра  —  Сергей, Анна, Максим, Елена

Выберите чат (1–3): 1

📤 Экспорт: Проект Alpha — ревью

  📥 Загрузка сообщений...
  📨 Получено: 147
  ✅ Записано 134 сообщений → output/Проект_Alpha__ревью_20240615_143022.md

Экспорт по ID с фильтром по дате

python main.py --chat-id "19:meeting_MjQ5NTg4...@thread.v2" --since 2024-06-01

Повторный запуск

При повторных запусках логин не нужен — токен берётся из кэша:

python main.py --interactive
# Сразу показывает список чатов, без ввода кода

Шаг 5. Результат

Экспортированный файл выглядит так:

# Проект Alpha — ревью

**Участники (3):** Иван Петров, Мария Сидорова, Алексей Козлов

**Экспортировано:** 2024-06-15 14:30

---

**Иван Петров** · 2024-06-10 09:15

Всем привет! Давайте обсудим план на спринт.

---

**Мария Сидорова** · 2024-06-10 09:16

Привет! Я подготовила документ.
  📎 [Sprint Plan.docx](https://sharepoint.com/sites/...)

---

**Алексей Козлов** · 2024-06-10 09:20  [like ×2]

**@Мария Сидорова** отлично, посмотрю!

Основные задачи:
- **Рефакторинг** модуля авторизации
- Написание тестов
- Обновление документации

---

Файл можно открыть в любом Markdown-редакторе (Obsidian, Typora, VS Code), закоммитить в git или конвертировать в PDF.


Бонус: автоматический периодический экспорт

Если хотите, чтобы новые сообщения подтягивались автоматически — добавьте инкрементальный экспорт.

Скрипт auto_export.py

#!/usr/bin/env python3
"""
Инкрементальный экспорт: забирает только новые сообщения
с момента последнего запуска.
"""

import json
import os
from datetime import datetime, timezone, timedelta
from main import get_token, get_chat_info, get_messages, export_chat

STATE_FILE = ".last_export.json"
CHAT_IDS_FILE = "chats.json"


def load_state() -> dict:
    if os.path.exists(STATE_FILE):
        with open(STATE_FILE) as f:
            return json.load(f)
    return {}


def save_state(state: dict):
    with open(STATE_FILE, "w") as f:
        json.dump(state, f, indent=2)


def load_chat_ids() -> list[str]:
    """
    Загрузить список chat_id из файла chats.json.

    Формат файла:
    [
        "19:meeting_abc@thread.v2",
        "19:meeting_xyz@thread.v2"
    ]
    """
    if not os.path.exists(CHAT_IDS_FILE):
        print(f"❌ Файл {CHAT_IDS_FILE} не найден.")
        print(f"   Создайте его или используйте main.py --list для получения ID.")
        return []

    with open(CHAT_IDS_FILE) as f:
        return json.load(f)


def run():
    token = get_token()
    state = load_state()
    chat_ids = load_chat_ids()

    if not chat_ids:
        return

    for chat_id in chat_ids:
        print(f"\n📥 Чат: {chat_id[:40]}...")

        # Определяем точку отсчёта
        last_run = state.get(chat_id)
        if last_run:
            since = datetime.fromisoformat(last_run)
            print(f"   Последний экспорт: {since:%Y-%m-%d %H:%M}")
        else:
            since = datetime.now(timezone.utc) - timedelta(days=7)
            print(f"   Первый запуск — берём за последние 7 дней")

        # Экспортируем
        export_chat(token, chat_id, since=since)

        # Обновляем состояние
        state[chat_id] = datetime.now(timezone.utc).isoformat()

    save_state(state)
    print("\n✅ Готово")


if __name__ == "__main__":
    run()

Файл chats.json

Создайте вручную после python main.py --list:

[
    "19:meeting_MjQ5NTg4...@thread.v2",
    "19:meeting_ZGFkYWRh...@thread.v2"
]

Настройка расписания

Linux/macOS — cron (каждый час):

crontab -e
0 * * * * cd /home/user/teams-export && /home/user/teams-export/venv/bin/python auto_export.py >> export.log 2>&1

Windows — Task Scheduler:

# PowerShell — создать задачу
$action = New-ScheduledTaskAction `
    -Execute "C:\projects\teams-export\venv\Scripts\python.exe" `
    -Argument "auto_export.py" `
    -WorkingDirectory "C:\projects\teams-export"

$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) `
    -RepetitionInterval (New-TimeSpan -Hours 1)

Register-ScheduledTask `
    -TaskName "TeamsExport" `
    -Action $action `
    -Trigger $trigger `
    -Description "Экспорт чатов Teams в Markdown"

Нюанс с автоматическим запуском: device code flow требует интерактивного ввода при первом запуске. После первой авторизации токен кэшируется и обновляется автоматически. Однако через ~90 дней refresh token может протухнуть — тогда потребуется повторный логин.


Возможные проблемы и решения

«Insufficient privileges» при запросе сообщений

403 Forbidden — Insufficient privileges to complete the operation

Причина: не добавлены permissions или не включён public client flow.

Решение:

  • Проверьте, что в Azure Portal добавлены Chat.Read и ChatMessage.Read

  • Убедитесь, что Allow public client flows = Yes

  • Удалите .token_cache.json и авторизуйтесь заново

«InvalidAuthenticationToken»

401 Unauthorized — InvalidAuthenticationToken

Причина: токен протух.

Решение:

rm .token_cache.json
python main.py --list
# Пройдите авторизацию заново

Чат есть в Teams, но не появляется в --list

Причина: это не групповой чат, а канал команды Teams. У каналов другой API.

Как отличить:

  • Групповой чат — иконка с кружками людей, открывается в разделе «Чат»

  • Канал — находится внутри команды, в разделе «Команды»

Для каналов нужны другие endpoints (см. раздел «Расширение» ниже).

Пустой экспорт — 0 сообщений

Возможные причины:

  • Все сообщения — системные (добавление/удаление участников). Скрипт их пропускает

  • Указана слишком поздняя дата в --since

  • Graph API ещё не проиндексировал старые сообщения (редко, но бывает)

Rate limiting

429 Too Many Requests

Причина: слишком много запросов. Лимит — 30 запросов в секунду.

Решение: добавьте retry-логику в graph_get:

import time

def graph_get(token: str, url: str, max_retries: int = 3) -> dict:
    for attempt in range(max_retries):
        resp = requests.get(
            url,
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        if resp.status_code == 429:
            retry_after = int(resp.headers.get("Retry-After", 5))
            print(f"  ⏳ Rate limit, жду {retry_after}с...")
            time.sleep(retry_after)
            continue
        resp.raise_for_status()
        return resp.json()

    raise Exception(f"Не удалось выполнить запрос после {max_retries} попыток")

Расширение: экспорт каналов Teams

Если нужны сообщения не из группового чата, а из канала команды — добавьте эти функции:

def get_my_teams(token: str) -> list[dict]:
    """Получить список команд, в которых я состою."""
    return graph_get_all(token, f"{GRAPH_BASE}/me/joinedTeams")


def get_channels(token: str, team_id: str) -> list[dict]:
    """Получить каналы команды."""
    return graph_get_all(token, f"{GRAPH_BASE}/teams/{team_id}/channels")


def get_channel_messages(
    token: str,
    team_id: str,
    channel_id: str,
) -> list[dict]:
    """Получить сообщения канала."""
    msgs = graph_get_all(
        token,
        f"{GRAPH_BASE}/teams/{team_id}/channels/{channel_id}/messages?$top=50",
    )
    # Загрузить ответы в тредах
    for msg in msgs:
        msg_id = msg.get("id")
        if msg.get("replyCount", 0) > 0:
            replies = graph_get_all(
                token,
                f"{GRAPH_BASE}/teams/{team_id}/channels/{channel_id}"
                f"/messages/{msg_id}/replies",
            )
            msg["_replies"] = replies
    return msgs

Для каналов нужен дополнительный permission:

Delegated: ChannelMessage.Read.All

Расширение: экспорт в другие форматы

Markdown легко конвертируется во что угодно через Pandoc:

# Markdown → PDF
pandoc output/chat.md -o chat.pdf

# Markdown → HTML
pandoc output/chat.md -o chat.html --standalone

# Markdown → DOCX
pandoc output/chat.md -o chat.docx

Итого

Что мы сделали:

  • Зарегистрировали приложение в Azure AD — 5 минут

  • Написали скрипт на Python — один файл, ~250 строк

  • Авторизовались через браузер — одноразово

  • Экспортировали чат в читаемый Markdown

Весь код работает с delegated permissions — никакие админы не нужны. Вы получаете доступ только к своим чатам, в которых состоите как участник.

Скрипт можно поставить на распис