Разработка

Как экспортировать групповой чат 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 * --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"**]*>(.*?)**",        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"]*>(.*?)",        r"~~\1~~", t, flags=re.S)
t = re.sub(r"]*>(.*?)", r"~~\1~~", t, flags=re.S)

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

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

# Упоминания (@mention)
t = re.sub(r"]*>(.*?)", 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"**",             "\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"]*>(.*?)",
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()

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

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 — никакие админы не нужны. Вы получаете доступ только к своим чатам, в которых состоите как участник.

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


Ищете надежного партнера по веб-разработке и автоматизации? Мы помогаем бизнесу расти с помощью современных технологий, автоматизации процессов и экспертного SEO. Свяжитесь с нами, чтобы обсудить вашу задачу.

🚀 Нужна помощь с сайтом на 1С-Битрикс или Аспро?

Я работаю удалённо по всей России и СНГ. Узнайте цены и условия для вашего города:

Все регионы →