**
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
Заполните:
| Поле | Значение |
|---|---|
| Name | teams-chat-exporter |
| Supported account types | Accounts 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 permissions → Add a permission → Microsoft Graph → Delegated 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-сущности (& → &, и т.д.)
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 — никакие админы не нужны. Вы получаете доступ только к своим чатам, в которых состоите как участник.
Скрипт можно поставить на распис