You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
227 lines
9.6 KiB
227 lines
9.6 KiB
|
2 months ago
|
---
|
||
|
|
name: "teams-export"
|
||
|
|
description: "Export Microsoft Teams messages (1:1 chats, group chats, and channel posts) into the Obsidian vault at D:\\Repos\\Obsidian\\00 - Chats\\ as markdown. Reads the user-maintained source list at D:\\Repos\\Obsidian\\00 - Chats\\sources.yaml to know what to copy. The invoking prompt or automation decides the time range (e.g. last N messages, today, since last export, explicit date range). Triggers: 'teams export', 'export chat', 'export channel', 'copy teams messages', '/teams-export'."
|
||
|
|
---
|
||
|
|
|
||
|
|
## Teams Export Skill
|
||
|
|
|
||
|
|
Copies Microsoft Teams messages into the Obsidian vault as plain markdown.
|
||
|
|
Read-only against Teams; only writes inside `D:\Repos\Obsidian\00 - Chats\`.
|
||
|
|
|
||
|
|
### Source list (authoritative)
|
||
|
|
|
||
|
|
**File:** `D:\Repos\Obsidian\00 - Chats\sources.yaml`
|
||
|
|
|
||
|
|
The user maintains this file by hand. The skill MUST read it on every run and
|
||
|
|
treat it as the single source of truth for what to export. Never invent sources,
|
||
|
|
never write back to this file, never silently skip malformed entries — if an
|
||
|
|
entry is broken or its identifiers can't be resolved, report it to the user and
|
||
|
|
continue with the rest.
|
||
|
|
|
||
|
|
Entry shapes (see file for inline examples):
|
||
|
|
|
||
|
|
| type | required fields | optional fields |
|
||
|
|
|-----------|------------------------------|----------------------------------------------|
|
||
|
|
| oneOnOne | `alias`, `email` | `chat_id`, `notes`, `enabled` |
|
||
|
|
| group | `alias`, `chat_id` | `topic`, `notes`, `enabled` |
|
||
|
|
| channel | `alias`, `team_id`, `channel_id` | `team`, `channel`, `notes`, `enabled` |
|
||
|
|
|
||
|
|
Skip any entry where `enabled: false`.
|
||
|
|
|
||
|
|
### Resolving identifiers
|
||
|
|
|
||
|
|
- **oneOnOne**:
|
||
|
|
- If `chat_id` is present in the entry, use it directly — DO NOT call
|
||
|
|
`m365_create_chat_by_email`. 1:1 chatIds are stable per user pair, so
|
||
|
|
cached values stay valid indefinitely and skipping the lookup saves a
|
||
|
|
round-trip per entry.
|
||
|
|
- Only fall back to `m365_create_chat_by_email` with `email` when `chat_id`
|
||
|
|
is missing. If resolution returns `user_not_found`, try
|
||
|
|
`m365_search_people` with the alias / name to find the correct UPN, then
|
||
|
|
retry. Report the resolved chatId in the run summary so the user can paste
|
||
|
|
it into `sources.yaml`.
|
||
|
|
- **group**: use `chat_id` directly with `m365_list_chat_messages`.
|
||
|
|
- **channel**: try `m365_list_channel_messages` first with `team_id` + `channel_id`
|
||
|
|
(expand replies). If it fails with a permission error (403 / "missing
|
||
|
|
ChannelMessage.Read.All"), fall back to the **bundled scraper** described below.
|
||
|
|
|
||
|
|
### Listing messages (Graph quirks)
|
||
|
|
|
||
|
|
`m365_list_chat_messages` rejects most `$filter` combinations. The shape that
|
||
|
|
works for date-windowed exports is:
|
||
|
|
|
||
|
|
```
|
||
|
|
filter: lastModifiedDateTime gt <ISO>
|
||
|
|
orderBy: lastModifiedDateTime desc
|
||
|
|
```
|
||
|
|
|
||
|
|
Notes:
|
||
|
|
- `createdDateTime` only supports the `lt` operator on this endpoint.
|
||
|
|
- `lastModifiedDateTime` requires `orderBy: lastModifiedDateTime desc` —
|
||
|
|
otherwise Graph returns "filter must target createdDateTime when orderBy is
|
||
|
|
createdDateTime desc".
|
||
|
|
- Page with `skipToken` until the window is fully covered (50-message cap per
|
||
|
|
call). Stop early once messages fall outside the requested range.
|
||
|
|
|
||
|
|
### Channel fallback: `scrape-channel.mjs`
|
||
|
|
|
||
|
|
Clawpilot's Graph app registration typically does **not** have
|
||
|
|
`ChannelMessage.Read.All`. The fallback uses the user's signed-in Teams web
|
||
|
|
session via Playwright to capture a bearer token and replay paginated requests
|
||
|
|
against Teams' internal chat service. Output shape is Graph-compatible so the
|
||
|
|
same markdown renderer handles it.
|
||
|
|
|
||
|
|
**Location:** `C:\Users\dkucinski\.scout\m-skills\teams-export\scrape-channel.mjs`
|
||
|
|
|
||
|
|
**One-time setup (first run only):**
|
||
|
|
1. Run the script with `--headed`. An Edge window opens controlled by Playwright,
|
||
|
|
using a persistent profile at `.pw-profile/`.
|
||
|
|
2. Sign in to teams.microsoft.com if prompted.
|
||
|
|
3. Click into the target channel and let messages load. The script auto-captures
|
||
|
|
the bearer token from the first matching network request and caches it to
|
||
|
|
`.token-cache.json`.
|
||
|
|
|
||
|
|
After that, subsequent runs reuse the profile and cached token (~1 hr lifetime).
|
||
|
|
When the token expires the script re-captures automatically — `--headed` is only
|
||
|
|
needed if AAD requires interactive sign-in again.
|
||
|
|
|
||
|
|
**Invocation pattern (from this skill, when Graph 403s):**
|
||
|
|
```pwsh
|
||
|
|
node "C:\Users\dkucinski\.scout\m-skills\teams-export\scrape-channel.mjs" `
|
||
|
|
--team-id "<team_id>" `
|
||
|
|
--channel-id "<channel_id>" `
|
||
|
|
--since "2026-05-21T00:00:00Z" `
|
||
|
|
--limit 200 `
|
||
|
|
[--headed] [--refresh-token] [--no-replies]
|
||
|
|
```
|
||
|
|
|
||
|
|
stdout is JSON `{ value: [root,...], count, replyCount, capturedAt, endpoint }`.
|
||
|
|
Each root has Graph fields: `id`, `createdDateTime`, `lastModifiedDateTime`,
|
||
|
|
`from.user.{displayName,id}`, `body.contentType`, `body.content`, `attachments`,
|
||
|
|
`messageType`, `subject`, plus `replies: [reply,...]` (same shape, oldest-first).
|
||
|
|
Replies also include `replyToId` pointing to their root for cross-referencing.
|
||
|
|
|
||
|
|
**Threading:** replies are on by default. Use `--no-replies` to suppress them
|
||
|
|
(roots only). Replies are detected via the `messageid=<rootId>` suffix in the
|
||
|
|
chat-service `conversationLink`, so threaded conversations in the OMR channels
|
||
|
|
come through correctly.
|
||
|
|
|
||
|
|
**Name resolution:** real users resolve to display names via Teams' profile
|
||
|
|
endpoint. Service identities (meeting-recording bot, channel-email, webhooks)
|
||
|
|
may show as `User <guid-prefix>` or `Channel` — that's expected and means the
|
||
|
|
post wasn't authored by a person.
|
||
|
|
|
||
|
|
**Diagnostics:** set `$env:SCRAPE_DEBUG="1"` to log every Teams API request seen,
|
||
|
|
which helps when Teams web routing changes and the URL matcher needs updating.
|
||
|
|
|
||
|
|
**Profile lock recovery:** when the scraper aborts with
|
||
|
|
`Failed to create a ProcessSingleton`, an orphaned Edge from a previous run is
|
||
|
|
still holding `.pw-profile\lockfile`. Find and kill it:
|
||
|
|
|
||
|
|
```pwsh
|
||
|
|
Get-CimInstance Win32_Process -Filter "Name='msedge.exe'" |
|
||
|
|
Where-Object { $_.CommandLine -like "*pw-profile*" } |
|
||
|
|
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
|
||
|
|
Remove-Item "C:\Users\dkucinski\.scout\m-skills\teams-export\.pw-profile\lockfile" -Force
|
||
|
|
```
|
||
|
|
|
||
|
|
If `Stop-Process` reports "Cannot find a process" but `tasklist /fi "PID eq <pid>"`
|
||
|
|
still shows it (zombie session), the process is in a different session and
|
||
|
|
can't be killed from this one — the user needs to reboot or close it from the
|
||
|
|
session that spawned it. Surface this clearly and skip the channel.
|
||
|
|
|
||
|
|
**Guardrails for the fallback:**
|
||
|
|
- Never commit `.pw-profile/` or `.token-cache.json` (covered by local
|
||
|
|
`.gitignore`). These contain auth state.
|
||
|
|
- Treat the captured token as a credential — never echo it to the user, never
|
||
|
|
write it outside `.token-cache.json`.
|
||
|
|
- If capture fails after 5 min, surface the failure and skip that channel; do
|
||
|
|
not block the rest of the run.
|
||
|
|
|
||
|
|
### Range selection (caller-driven)
|
||
|
|
|
||
|
|
The skill does NOT pick a default range. The invoking prompt or automation must
|
||
|
|
specify one of:
|
||
|
|
- `last_n` — e.g. last 50 messages
|
||
|
|
- `since` — ISO timestamp; include messages with `createdDateTime >= since`
|
||
|
|
- `date_range` — explicit start/end ISO timestamps
|
||
|
|
- `today` — local-day window in America/Los_Angeles
|
||
|
|
|
||
|
|
If no range is supplied, ask the user before exporting anything.
|
||
|
|
|
||
|
|
Page through Graph results (50/call cap) using `skipToken` until the window is
|
||
|
|
covered. Stop early once messages fall outside the requested range.
|
||
|
|
|
||
|
|
### Output
|
||
|
|
|
||
|
|
**Folder:** `D:\Repos\Obsidian\00 - Chats\YYYY-MM\` (create if missing,
|
||
|
|
month derived from the message timestamp in America/Los_Angeles).
|
||
|
|
|
||
|
|
**Filename:** `<alias>.md` — one file per source per month. Append-only:
|
||
|
|
if the file exists, add new messages under a new date divider; never rewrite
|
||
|
|
existing content. Deduplicate by message `id` to avoid double-writing on reruns.
|
||
|
|
|
||
|
|
**File structure:**
|
||
|
|
|
||
|
|
```markdown
|
||
|
|
---
|
||
|
|
alias: omr-c3
|
||
|
|
type: channel
|
||
|
|
team: OMR
|
||
|
|
channel: XP - C3 - OMR Reliability Tooling (OMRHealth)
|
||
|
|
team_id: db3900a4-...
|
||
|
|
channel_id: 19:134dc34e...@thread.tacv2
|
||
|
|
---
|
||
|
|
|
||
|
|
# omr-c3 — 2026-05
|
||
|
|
|
||
|
|
## 2026-05-18
|
||
|
|
|
||
|
|
### 09:14 — Daniel Kucinski
|
||
|
|
Message body in markdown. HTML is converted to markdown; emoji and basic
|
||
|
|
formatting (bold, italic, lists, links, code) are preserved.
|
||
|
|
|
||
|
|
> Reply by Alex Smith — 09:18
|
||
|
|
> Threaded reply body.
|
||
|
|
|
||
|
|
**Attachments:** `dashboard.png`, `notes.docx` *(metadata only — files not downloaded)*
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 09:32 — Alex Smith
|
||
|
|
...
|
||
|
|
```
|
||
|
|
|
||
|
|
Conventions:
|
||
|
|
- Headings: `##` for date (YYYY-MM-DD), `###` for each message (HH:mm — Author).
|
||
|
|
- Replies are rendered as blockquotes nested under their parent root message.
|
||
|
|
- **Media simplification:** attachments and inline images become a single
|
||
|
|
`**Attachments:** name1, name2` line. Do NOT download binaries.
|
||
|
|
- **Metadata simplification:** keep author, timestamp, and reply structure.
|
||
|
|
Drop reactions, read receipts, mention metadata (just render @Name inline),
|
||
|
|
and raw HTML wrappers.
|
||
|
|
- Convert message HTML → markdown (preserve links, bold/italic, lists, inline
|
||
|
|
code, code blocks, blockquotes).
|
||
|
|
- Timestamps in America/Los_Angeles, 24-hour.
|
||
|
|
- Separate root messages with a horizontal rule (`---`).
|
||
|
|
|
||
|
|
### Run summary
|
||
|
|
|
||
|
|
After every run, print a short summary to the user:
|
||
|
|
- Sources processed / skipped (with reasons)
|
||
|
|
- Messages written per source
|
||
|
|
- Output file paths
|
||
|
|
- Any errors
|
||
|
|
- **Any newly resolved 1:1 `chat_id` values** (so the user can paste them into
|
||
|
|
`sources.yaml` to skip the lookup next time)
|
||
|
|
|
||
|
|
### Guardrails
|
||
|
|
|
||
|
|
- Never write outside `D:\Repos\Obsidian\00 - Chats\`.
|
||
|
|
- Never edit `sources.yaml`.
|
||
|
|
- Respect MIP sensitivity labels — if a source surfaces classified content,
|
||
|
|
note it in the run summary so the user knows the output file inherits that
|
||
|
|
sensitivity.
|
||
|
|
- If a `chat_id` or `channel_id` can't be resolved (403/404), report it and
|
||
|
|
move on — don't crash the whole run.
|