@patimweb/pi-teams 1.6.0 → 1.7.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/docs/entra-app-registration.md +47 -4
- package/package.json +1 -1
- package/src/auth/index.ts +25 -2
- package/src/auth/msal.ts +180 -20
- package/src/extension/index.ts +2 -0
- package/src/graph/client.ts +47 -0
- package/src/graph/mappers.ts +14 -0
- package/src/graph/media.ts +174 -0
- package/src/tools/teams-download-files.ts +145 -0
- package/src/types.ts +9 -0
- package/src/utils/attachments.ts +76 -0
- package/src/utils/environment.ts +21 -4
- package/src/utils/formatting.ts +43 -0
package/README.md
CHANGED
|
@@ -267,6 +267,14 @@ account with `"authMode": "device-code"`, or per call with
|
|
|
267
267
|
Either way the session is cached on disk and renewed silently, so signing in is
|
|
268
268
|
a once-per-account affair.
|
|
269
269
|
|
|
270
|
+
In **WSL** the URL is handed to Windows PowerShell, so the browser on the
|
|
271
|
+
Windows side opens: no X server, no `DISPLAY`, and the authorize URL survives
|
|
272
|
+
whole — including its `&`, which a `cmd.exe` handoff would cut at the first one.
|
|
273
|
+
If the built-in launchers do not fit the machine, set `PI_TEAMS_BROWSER` to a
|
|
274
|
+
command line that opens a URL: `{}` is replaced by the URL, otherwise the URL is
|
|
275
|
+
appended. Every failure carries the URL in its message, so it can be pasted
|
|
276
|
+
into a browser by hand — the one route that needs nothing installed.
|
|
277
|
+
|
|
270
278
|
Microsoft Graph only permits app-only writes to chats and channels for
|
|
271
279
|
migration scenarios (`Teamwork.Migrate.All`), so pi-teams refuses those calls
|
|
272
280
|
up front instead of letting them fail deep inside a Graph request. Use
|
|
@@ -467,6 +475,7 @@ teams_permissions:
|
|
|
467
475
|
| `teams_list_members` | Members of a team or channel |
|
|
468
476
|
| `teams_find_user` | Directory lookup by name, e-mail or UPN |
|
|
469
477
|
| `teams_list_files` | Files in a channel's SharePoint folder |
|
|
478
|
+
| `teams_download_files` | Download the images and files of messages to a local directory |
|
|
470
479
|
| `teams_get_presence` | Your status, a colleague's, or a whole chat's |
|
|
471
480
|
| `teams_availability` | When several people are all free, from calendar free/busy |
|
|
472
481
|
| `teams_list_meetings` | Calendar events and meetings in a range |
|
|
@@ -590,6 +599,7 @@ teams_send_channel_message:
|
|
|
590
599
|
| `~/.pi/agent/pi-teams-tokens/` | one MSAL token cache per account+tenant | `0600` |
|
|
591
600
|
| `~/.pi/agent/pi-teams-audit.jsonl` | one line per write: when, who, where, what | `0600` |
|
|
592
601
|
| `~/.pi/agent/pi-teams-watch/` | listen mode's cursor: the newest message it has looked at per chat | `0600` |
|
|
602
|
+
| `~/.pi/agent/pi-teams-files/` | images and files downloaded from messages, one folder per chat or channel | — |
|
|
593
603
|
|
|
594
604
|
Both the config and the token cache are written through a private temp file and
|
|
595
605
|
an atomic rename, so an interrupted write cannot truncate them. The watch
|
|
@@ -648,8 +658,9 @@ network connection.
|
|
|
648
658
|
| `AADSTS50011` (redirect URI mismatch) | Add `http://localhost` under *Mobile and desktop applications*, or pin `loopbackPort` |
|
|
649
659
|
| `AADSTS50020` | The account is not a member/guest of that tenant, or the app is single-tenant |
|
|
650
660
|
| Signed out after about an hour | The tenant grants no `offline_access`, so there is no refresh token. `teams_doctor` names the missing scope; otherwise sign in again when it expires |
|
|
651
|
-
| Browser never opens |
|
|
652
|
-
| Browser opens, page never returns |
|
|
661
|
+
| Browser never opens | The launcher failed: the error names it, and carries the URL to open by hand. `teams_doctor` says whether a browser counts as reachable here, and `PI_TEAMS_BROWSER` points pi at your own command |
|
|
662
|
+
| Browser opens, page never returns | Read what the browser showed before suspecting a firewall: a pending admin consent (`AADSTS65004`), a Conditional Access policy (`AADSTS53003`), or a redirect URI that does not match the port (`AADSTS50011`). The timeout message names all three. A blocked loopback port is the fourth possibility — pin `loopbackPort` and allow it |
|
|
663
|
+
| A screenshot in a message is missing from the output | The text of a message cannot carry a picture. `teams_read_chat` says how many images a message has; `teams_download_files` fetches them so they can be opened |
|
|
653
664
|
| "Not signed in" | Run `teams_login`; the saved session may have been revoked |
|
|
654
665
|
| Status line says "not signed in" although sign-in worked | The line is written at session start and after every auth tool; if it lags, `/teams-status` repaints it |
|
|
655
666
|
| "Blocked by configuration" | Your own scope rules. `teams_permissions action: test` shows which rule |
|
|
@@ -81,8 +81,23 @@ AADSTS7000218: The request body must contain the following parameter:
|
|
|
81
81
|
|
|
82
82
|
## 3. API permissions
|
|
83
83
|
|
|
84
|
-
**
|
|
85
|
-
**
|
|
84
|
+
**Entra admin center** → **Entra ID** → **App registrations** → your app →
|
|
85
|
+
**API permissions**. Four clicks, and the third one is the one people get
|
|
86
|
+
wrong:
|
|
87
|
+
|
|
88
|
+
1. **Add a permission** — the button above the permission list.
|
|
89
|
+
2. **Microsoft Graph** — the first tile under *Commonly used Microsoft APIs*.
|
|
90
|
+
Not *Azure DevOps*, not *Azure Storage*: those are different APIs and their
|
|
91
|
+
permissions will not help.
|
|
92
|
+
3. **Delegated permissions**, *not* **Application permissions**. pi acts as you.
|
|
93
|
+
An application permission would give the app its own identity and a
|
|
94
|
+
completely different consent model — and the two tiles sit directly under
|
|
95
|
+
each other, which is why this is the usual mistake.
|
|
96
|
+
4. Type each name into **Start typing a permission to filter these results**,
|
|
97
|
+
tick the box, and press **Add permissions** once at the end. Nothing is
|
|
98
|
+
saved until that button is pressed, so a half-filled list can be closed.
|
|
99
|
+
|
|
100
|
+
What you should end up ticking:
|
|
86
101
|
|
|
87
102
|
| Permission | Needed for |
|
|
88
103
|
|---|---|
|
|
@@ -99,7 +114,25 @@ AADSTS7000218: The request body must contain the following parameter:
|
|
|
99
114
|
| `Files.Read.All`, `Sites.Read.All` | channel files |
|
|
100
115
|
|
|
101
116
|
`openid`, `profile` and `offline_access` are deliberately absent: MSAL always
|
|
102
|
-
requests them and rejects them in an explicit scope list.
|
|
117
|
+
requests them and rejects them in an explicit scope list. (You will still see
|
|
118
|
+
them in the picker, under **OpenId permissions**. Leave them alone.)
|
|
119
|
+
|
|
120
|
+
### The Admin consent required column
|
|
121
|
+
|
|
122
|
+
After adding them, the list has a column that decides whether step 4 is a
|
|
123
|
+
five-second job or a ticket to your administrator:
|
|
124
|
+
|
|
125
|
+
| Permission | Admin consent required |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `ChannelMessage.Read.All` | **Yes** — reading channels and threads |
|
|
128
|
+
| `ChannelMember.Read.All` | **Yes** — channel member lists |
|
|
129
|
+
| `ChatMember.ReadWrite` | **Yes** — only if you added it, see below |
|
|
130
|
+
| everything else in the list above | No |
|
|
131
|
+
|
|
132
|
+
The column shows the *default* for an organization, and a tenant can relax it
|
|
133
|
+
per permission, per user or per app — read it as "expect to need an admin", not
|
|
134
|
+
as a property of the permission itself. The scopes showing **Yes** are the ones
|
|
135
|
+
step 4 is about.
|
|
103
136
|
|
|
104
137
|
**What is not needed:** no client secret, no certificate, no redirect URI beyond
|
|
105
138
|
step 2, no application (as opposed to delegated) permissions.
|
|
@@ -112,7 +145,7 @@ again:
|
|
|
112
145
|
|
|
113
146
|
| Permission | Unlocks | Consent |
|
|
114
147
|
|---|---|---|
|
|
115
|
-
| `ChatMember.ReadWrite` | removing someone from a chat (`teams_chat_members`) |
|
|
148
|
+
| `ChatMember.ReadWrite` | removing someone from a chat (`teams_chat_members`) | **admin** — the portal shows it as *Admin consent required: Yes*, and the tool says which scope is missing until it is granted |
|
|
116
149
|
|
|
117
150
|
Without it the tool answers with the exact scope it is missing instead of
|
|
118
151
|
failing at Graph with a consent error. Editing and deleting chat messages,
|
|
@@ -183,6 +216,12 @@ Or edit `~/.pi/agent/pi-teams.json` directly — the same file, mode `0600`:
|
|
|
183
216
|
with your own credentials and MFA, and the page closes itself. Nothing to
|
|
184
217
|
copy or type. Over SSH or in a container, pi falls back to the device code
|
|
185
218
|
flow and shows a short code to enter on any device.
|
|
219
|
+
|
|
220
|
+
In **WSL** the URL is handed to Windows PowerShell, so the browser on the
|
|
221
|
+
Windows side opens — no X server and no `DISPLAY` needed. A launcher that
|
|
222
|
+
cannot be started is reported as an error; if you are upgrading from a
|
|
223
|
+
version that hung here instead, see the last row of the troubleshooting
|
|
224
|
+
table.
|
|
186
225
|
2. `teams_status` — who pi acts as, which token is cached, what it may do.
|
|
187
226
|
3. `teams_doctor` — checks configuration, sign-in, granted scopes and
|
|
188
227
|
connectivity in one go. It reports a missing consent as a **named
|
|
@@ -223,9 +262,13 @@ that tenant on its own.
|
|
|
223
262
|
| `AADSTS50011` — *redirect URI mismatch* | an exact URI is registered that does not match the chosen port | register the port-less `http://localhost`, or pin the port in the same file |
|
|
224
263
|
| `AADSTS7000218` — *client_assertion or client_secret* | **Allow public client flows** is off | step 2b |
|
|
225
264
|
| `AADSTS65001` — *consent required* | permissions added but never consented | sign in again; for the two admin scopes, step 4 |
|
|
265
|
+
| `AADSTS65004` — *request pending* | an **admin consent request** is waiting for approval, or consent was declined once | step 4: an administrator presses **Grant admin consent**. A pending request is under **Enterprise applications** → your app → **Permissions**. Until it is approved the browser comes back with this code instead of a token |
|
|
266
|
+
| `AADSTS53003` — *blocked by Conditional Access* | a policy requires a managed or compliant device, which the **device code** flow cannot satisfy | this is why the browser sign-in has to work rather than quietly fall back: on such a tenant the device code route is a dead end, not a fallback. Use a machine that satisfies the policy, or add pi's loopback redirect to it |
|
|
226
267
|
| `AADSTS50020` — *not a member of the tenant* | wrong tenant, no guest account, or the app is single-tenant | steps 1 and 7 |
|
|
227
268
|
| `AADSTS700016` — *application not found in directory* | wrong tenant ID or client ID | recheck `<DIRECTORY_TENANT_ID>` and `<APPLICATION_CLIENT_ID>` |
|
|
228
269
|
| signed out after about an hour | the tenant grants no `offline_access` | the app needs a refresh token to renew silently; without it, run `teams_login` when the session expires. `teams_doctor` names the missing scope |
|
|
270
|
+
| browser never opens from WSL | older versions called `xdg-open`, which a plain WSL distro does not have — and the failure was swallowed | update pi-teams: current versions hand the URL to Windows PowerShell. If the Windows drive is not mounted where the handoff expects it, set `PI_TEAMS_BROWSER` to your own command |
|
|
271
|
+
| browser opens, no callback, five-minute timeout | three unrelated causes look identical from inside pi | read what the browser showed: a consent still pending (`AADSTS65004`), Conditional Access (`AADSTS53003`), or a redirect URI that does not match the port (`AADSTS50011`). The timeout text says the same. An exit code of 0 proves nothing — a launcher can exit 0 without having opened anything |
|
|
229
272
|
|
|
230
273
|
Run `teams_doctor` first for anything in this table: it validates the
|
|
231
274
|
configuration, the cached session, the granted scopes and the connection to
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patimweb/pi-teams",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0-next.1",
|
|
4
4
|
"description": "Microsoft Teams integration for pi coding agent — acts fully as the signed-in user across multiple accounts and tenants: chats, channels, meetings, presence, with configurable guardrails",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/auth/index.ts
CHANGED
|
@@ -235,10 +235,15 @@ export function defaultSignInMode(): SignInMode {
|
|
|
235
235
|
|
|
236
236
|
export { browserUnavailableReason, canOpenBrowser };
|
|
237
237
|
|
|
238
|
-
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
238
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, label: string, hint?: string): Promise<T> {
|
|
239
239
|
return new Promise<T>((resolve, reject) => {
|
|
240
240
|
const timer = setTimeout(() => {
|
|
241
|
-
reject(
|
|
241
|
+
reject(
|
|
242
|
+
new AuthError(
|
|
243
|
+
"timeout",
|
|
244
|
+
`${label} timed out after ${Math.round(ms / 1000)}s.${hint ? ` ${hint}` : ""}`,
|
|
245
|
+
),
|
|
246
|
+
);
|
|
242
247
|
}, ms);
|
|
243
248
|
promise.then(
|
|
244
249
|
(value) => {
|
|
@@ -253,6 +258,23 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
|
|
|
253
258
|
});
|
|
254
259
|
}
|
|
255
260
|
|
|
261
|
+
/**
|
|
262
|
+
* What it means when a browser sign-in never comes back.
|
|
263
|
+
*
|
|
264
|
+
* Three unrelated causes look identical from here: the browser opens and no
|
|
265
|
+
* callback ever arrives. Naming all of them in the timeout text is the
|
|
266
|
+
* difference between a five-second fix and hunting the launcher, which is
|
|
267
|
+
* usually innocent — so the text asks the one question that separates them.
|
|
268
|
+
*/
|
|
269
|
+
const BROWSER_TIMEOUT_HINT =
|
|
270
|
+
"The browser opened but nothing came back. Check what the browser showed: " +
|
|
271
|
+
"a consent that is still pending (AADSTS65004 — an admin request waiting under " +
|
|
272
|
+
"Enterprise applications), a Conditional Access policy that blocks the sign-in " +
|
|
273
|
+
"(AADSTS53003), or a redirect URI on the app registration that does not match " +
|
|
274
|
+
"this port (AADSTS50011). If no browser opened at all, the launcher is the " +
|
|
275
|
+
"problem: set PI_TEAMS_BROWSER to your own command, or open the URL that came " +
|
|
276
|
+
"with the error by hand.";
|
|
277
|
+
|
|
256
278
|
/**
|
|
257
279
|
* Sign in as the user.
|
|
258
280
|
*
|
|
@@ -289,6 +311,7 @@ export async function signIn(
|
|
|
289
311
|
}),
|
|
290
312
|
timeoutMs,
|
|
291
313
|
"Browser sign-in",
|
|
314
|
+
BROWSER_TIMEOUT_HINT,
|
|
292
315
|
);
|
|
293
316
|
if (!result) throw new AuthError("no_token", "Microsoft returned no token.");
|
|
294
317
|
return { ...toTokenInfo(result), mode: "interactive" };
|
package/src/auth/msal.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* whether a browser is actually reachable, and open it.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { spawn } from "node:child_process";
|
|
10
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
11
11
|
import {
|
|
12
12
|
ConfidentialClientApplication,
|
|
13
13
|
LogLevel,
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
type Configuration,
|
|
16
16
|
} from "@azure/msal-node";
|
|
17
17
|
import type { TeamsConnection } from "../config/index.ts";
|
|
18
|
-
import { browserUnavailableReason, canOpenBrowser } from "../utils/environment.ts";
|
|
18
|
+
import { browserUnavailableReason, canOpenBrowser, isWsl } from "../utils/environment.ts";
|
|
19
19
|
import { createCachePlugin } from "./cache-plugin.ts";
|
|
20
20
|
|
|
21
21
|
export { browserUnavailableReason, canOpenBrowser };
|
|
@@ -101,32 +101,192 @@ export function resetApps(): void {
|
|
|
101
101
|
// Browser
|
|
102
102
|
// ---------------------------------------------------------------------------
|
|
103
103
|
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
104
|
+
/**
|
|
105
|
+
* The Windows-side shell a WSL session hands its URL to.
|
|
106
|
+
*
|
|
107
|
+
* Windows PowerShell rather than `cmd.exe`: the authorize URL carries several
|
|
108
|
+
* `&`, and `cmd /c start` reads the first of them as a command separator and
|
|
109
|
+
* opens a truncated URL. Single quotes survive them.
|
|
110
|
+
*/
|
|
111
|
+
const WSL_POWERSHELL = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe";
|
|
112
|
+
|
|
113
|
+
/** How long to wait for a launcher that neither exits nor errors. */
|
|
114
|
+
const LAUNCH_EXIT_GRACE_MS = 2000;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The command that puts `url` in front of the user in a browser.
|
|
118
|
+
*
|
|
119
|
+
* Split out of `openBrowser` so the shapes stay testable: the Windows and WSL
|
|
120
|
+
* forms are easy to get subtly wrong, and all of them fail quietly when they
|
|
121
|
+
* are.
|
|
122
|
+
*/
|
|
123
|
+
export function browserLaunch(
|
|
124
|
+
url: string,
|
|
125
|
+
target: { platform?: NodeJS.Platform; wsl?: boolean; browser?: string } = {},
|
|
126
|
+
): { command: string; args: string[] } {
|
|
127
|
+
const override = (target.browser ?? process.env.PI_TEAMS_BROWSER)?.trim();
|
|
128
|
+
if (override) return overrideLaunch(url, override);
|
|
129
|
+
|
|
130
|
+
const platform = target.platform ?? process.platform;
|
|
131
|
+
|
|
132
|
+
if (target.wsl ?? isWsl()) {
|
|
133
|
+
// Escaping a quote inside a PowerShell single-quoted string means doubling
|
|
134
|
+
// it. Nothing else in the URL needs attention.
|
|
135
|
+
const quoted = url.replace(/'/g, "''");
|
|
136
|
+
return {
|
|
137
|
+
command: WSL_POWERSHELL,
|
|
138
|
+
args: ["-NoProfile", "-NonInteractive", "-Command", `Start-Process '${quoted}'`],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
115
141
|
|
|
142
|
+
if (platform === "darwin") return { command: "open", args: [url] };
|
|
143
|
+
|
|
144
|
+
if (platform === "win32") {
|
|
145
|
+
// `start` is a shell builtin, and the empty string is the window title
|
|
146
|
+
// cmd.exe would otherwise take the URL for.
|
|
147
|
+
return { command: "cmd.exe", args: ["/c", "start", "", url] };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { command: "xdg-open", args: [url] };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A launcher the user supplied through `PI_TEAMS_BROWSER`.
|
|
155
|
+
*
|
|
156
|
+
* Takes a complete command line. `{}` in any argument is replaced by the URL;
|
|
157
|
+
* without it the URL is appended. This is the escape hatch for a WSL distro
|
|
158
|
+
* with no `xdg-open`: point it at a script, or at a Windows binary, instead of
|
|
159
|
+
* installing a shim for every program on the machine.
|
|
160
|
+
*/
|
|
161
|
+
function overrideLaunch(url: string, override: string): { command: string; args: string[] } {
|
|
162
|
+
const [command, ...rest] = splitCommandLine(override);
|
|
163
|
+
if (!command) throw new Error("PI_TEAMS_BROWSER is set but names no command.");
|
|
164
|
+
|
|
165
|
+
if (rest.some((part) => part.includes("{}"))) {
|
|
166
|
+
return { command, args: rest.map((part) => part.replaceAll("{}", url)) };
|
|
167
|
+
}
|
|
168
|
+
return { command, args: [...rest, url] };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Split on whitespace, honouring simple quoting. No expansion, no escaping. */
|
|
172
|
+
function splitCommandLine(input: string): string[] {
|
|
173
|
+
const parts: string[] = [];
|
|
174
|
+
let current = "";
|
|
175
|
+
let quote: string | undefined;
|
|
176
|
+
|
|
177
|
+
for (const char of input) {
|
|
178
|
+
if (quote) {
|
|
179
|
+
if (char === quote) quote = undefined;
|
|
180
|
+
else current += char;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (char === '"' || char === "'") {
|
|
184
|
+
quote = char;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (/\s/.test(char)) {
|
|
188
|
+
if (current) parts.push(current);
|
|
189
|
+
current = "";
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
current += char;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (current) parts.push(current);
|
|
196
|
+
return parts;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Run a launcher and wait until it is clear whether it worked.
|
|
201
|
+
*
|
|
202
|
+
* Two things have to be caught, and both were lost when this resolved on spawn.
|
|
203
|
+
* A missing launcher — `xdg-open` on a fresh WSL distro — reports ENOENT on
|
|
204
|
+
* `error` and never exits. A launcher that is present but finds no browser
|
|
205
|
+
* exits non-zero, which is how `xdg-open` says so.
|
|
206
|
+
*
|
|
207
|
+
* Resolving on spawn made both look like success, so the sign-in sat waiting
|
|
208
|
+
* for a redirect from a browser that had never opened, until the five minute
|
|
209
|
+
* timeout replaced the real cause with "Browser sign-in timed out".
|
|
210
|
+
*/
|
|
211
|
+
export function launchBrowser(
|
|
212
|
+
command: string,
|
|
213
|
+
args: string[],
|
|
214
|
+
graceMs = LAUNCH_EXIT_GRACE_MS,
|
|
215
|
+
): Promise<void> {
|
|
216
|
+
return new Promise((resolve, reject) => {
|
|
217
|
+
let child: ChildProcess;
|
|
116
218
|
try {
|
|
117
|
-
|
|
118
|
-
detached: true,
|
|
119
|
-
stdio: "ignore",
|
|
120
|
-
});
|
|
121
|
-
child.on("error", reject);
|
|
122
|
-
child.unref();
|
|
123
|
-
resolve();
|
|
219
|
+
child = spawn(command, args, { detached: true, stdio: ["ignore", "ignore", "pipe"] });
|
|
124
220
|
} catch (err) {
|
|
125
221
|
reject(err instanceof Error ? err : new Error(String(err)));
|
|
222
|
+
return;
|
|
126
223
|
}
|
|
224
|
+
|
|
225
|
+
let settled = false;
|
|
226
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
227
|
+
let stderr = "";
|
|
228
|
+
|
|
229
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
230
|
+
stderr += String(chunk);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const settle = (err?: Error) => {
|
|
234
|
+
if (settled) return;
|
|
235
|
+
settled = true;
|
|
236
|
+
if (timer) clearTimeout(timer);
|
|
237
|
+
child.unref();
|
|
238
|
+
if (err) reject(err);
|
|
239
|
+
else resolve();
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// A launcher that stays alive is waiting on the browser, not failing.
|
|
243
|
+
timer = setTimeout(() => settle(), graceMs);
|
|
244
|
+
|
|
245
|
+
child.once("error", (err: NodeJS.ErrnoException) => {
|
|
246
|
+
const detail = err.code === "ENOENT" ? `${command} was not found` : err.message;
|
|
247
|
+
settle(new Error(`could not open a browser: ${detail}`));
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
child.once("close", (code, signal) => {
|
|
251
|
+
if (code === 0) settle();
|
|
252
|
+
else {
|
|
253
|
+
// `close`, not `exit`: the exit event can fire before the stderr pipe has
|
|
254
|
+
// drained, and the reason xdg-open gives is on stderr.
|
|
255
|
+
const why = stderr.trim() ? `: ${stderr.trim()}` : "";
|
|
256
|
+
settle(
|
|
257
|
+
new Error(
|
|
258
|
+
`could not open a browser: ${command} exited with ` +
|
|
259
|
+
`${code === null ? `signal ${signal ?? "unknown"}` : `code ${code}`}${why}`,
|
|
260
|
+
),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
127
264
|
});
|
|
128
265
|
}
|
|
129
266
|
|
|
267
|
+
/** Open a URL in the user's default browser, detached from this process. */
|
|
268
|
+
export async function openBrowser(url: string): Promise<void> {
|
|
269
|
+
const { command, args } = browserLaunch(url);
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
await launchBrowser(command, args);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
275
|
+
// The URL goes in every failure message. An exact exit code is only ever a
|
|
276
|
+
// hint — a shim and some real xdg-open implementations exit 0 without
|
|
277
|
+
// showing anything — so the one thing that always works is the address
|
|
278
|
+
// itself, pasted into a browser by hand.
|
|
279
|
+
const fallback = `Open it by hand instead: ${url}`;
|
|
280
|
+
if (isWsl()) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
`${cause}. ${fallback} — from WSL the handoff expects Windows PowerShell at ` +
|
|
283
|
+
`${WSL_POWERSHELL}; set PI_TEAMS_BROWSER to use your own command instead.`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
throw new Error(`${cause}. ${fallback}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
130
290
|
// ---------------------------------------------------------------------------
|
|
131
291
|
// Browser response pages
|
|
132
292
|
// ---------------------------------------------------------------------------
|
package/src/extension/index.ts
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
teamsSendChannelMessageTool,
|
|
54
54
|
} from "../tools/teams-send-channel-message.ts";
|
|
55
55
|
import { teamsListFilesTool } from "../tools/teams-list-files.ts";
|
|
56
|
+
import { teamsDownloadFilesTool } from "../tools/teams-download-files.ts";
|
|
56
57
|
import { teamsSearchMessagesTool } from "../tools/teams-search-messages.ts";
|
|
57
58
|
import { teamsReactTool } from "../tools/teams-react.ts";
|
|
58
59
|
import { teamsMarkReadTool } from "../tools/teams-mark-read.ts";
|
|
@@ -107,6 +108,7 @@ const tools = [
|
|
|
107
108
|
teamsSendChannelMessageTool,
|
|
108
109
|
teamsReplyChannelMessageTool,
|
|
109
110
|
teamsListFilesTool,
|
|
111
|
+
teamsDownloadFilesTool,
|
|
110
112
|
// Cross-cutting
|
|
111
113
|
teamsSearchMessagesTool,
|
|
112
114
|
teamsReactTool,
|
package/src/graph/client.ts
CHANGED
|
@@ -131,6 +131,53 @@ export async function graphRequest<T = unknown>(
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Download a Graph resource as bytes.
|
|
136
|
+
*
|
|
137
|
+
* `graphRequest` parses JSON, so the two things that are not JSON — the images
|
|
138
|
+
* embedded in a message and the files attached to it — come through here. Same
|
|
139
|
+
* token handling and throttling back-off; the body is returned as it arrived,
|
|
140
|
+
* because its content type is the only hint at what the file is.
|
|
141
|
+
*/
|
|
142
|
+
export async function graphDownload(
|
|
143
|
+
conn: TeamsConnection,
|
|
144
|
+
path: string,
|
|
145
|
+
options: RequestOptions = {},
|
|
146
|
+
): Promise<{ data: Buffer; contentType?: string }> {
|
|
147
|
+
const maxRetries = options.maxRetries ?? 3;
|
|
148
|
+
// An absolute URL is allowed here too: Graph answers a hostedContent with a
|
|
149
|
+
// redirect to a pre-authenticated location, and `fetch` follows it.
|
|
150
|
+
const url = buildUrl(conn, path, options);
|
|
151
|
+
|
|
152
|
+
let attempt = 0;
|
|
153
|
+
for (;;) {
|
|
154
|
+
const token = await getAccessToken(conn, options.signal);
|
|
155
|
+
|
|
156
|
+
const response = await fetch(url, {
|
|
157
|
+
method: "GET",
|
|
158
|
+
headers: { Authorization: `Bearer ${token.accessToken}`, ...options.headers },
|
|
159
|
+
signal: options.signal,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (response.ok) {
|
|
163
|
+
return {
|
|
164
|
+
data: Buffer.from(await response.arrayBuffer()),
|
|
165
|
+
contentType: response.headers.get("content-type") ?? undefined,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if ((response.status === 429 || response.status === 503) && attempt < maxRetries) {
|
|
170
|
+
const retryAfter = Number(response.headers.get("Retry-After") ?? "0");
|
|
171
|
+
const waitMs = retryAfter > 0 ? retryAfter * 1000 : Math.min(2 ** attempt * 1000, 8000);
|
|
172
|
+
attempt += 1;
|
|
173
|
+
await sleep(waitMs);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
throw await toGraphError(response);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
134
181
|
async function toGraphError(response: Response): Promise<GraphError> {
|
|
135
182
|
let code = `HTTP${response.status}`;
|
|
136
183
|
let message = response.statusText || `Request failed with status ${response.status}`;
|
package/src/graph/mappers.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
SignedInUser,
|
|
24
24
|
TeamSummary,
|
|
25
25
|
} from "../types.ts";
|
|
26
|
+
import { imageUrlsInHtml } from "../utils/attachments.ts";
|
|
26
27
|
import { htmlToText } from "../utils/formatting.ts";
|
|
27
28
|
|
|
28
29
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
@@ -216,9 +217,21 @@ function mapAttachments(raw: Raw): MessageAttachment[] {
|
|
|
216
217
|
name: a.name ?? undefined,
|
|
217
218
|
contentType: a.contentType ?? undefined,
|
|
218
219
|
contentUrl: a.contentUrl ?? undefined,
|
|
220
|
+
contentBytes: a.contentBytes ?? undefined,
|
|
219
221
|
}));
|
|
220
222
|
}
|
|
221
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Inline images, pulled out of the body before it is flattened to text.
|
|
226
|
+
*
|
|
227
|
+
* `htmlToText` turns every `<img>` into `[image: alt]`, which is right for a
|
|
228
|
+
* reader that only wants prose and wrong for one that could fetch the image:
|
|
229
|
+
* the URL it drops points at Graph.
|
|
230
|
+
*/
|
|
231
|
+
function mapImageUrls(raw: Raw): string[] {
|
|
232
|
+
return imageUrlsInHtml(raw.body?.content ?? "");
|
|
233
|
+
}
|
|
234
|
+
|
|
222
235
|
function mapMentions(raw: Raw): PersonRef[] {
|
|
223
236
|
return (raw.mentions ?? [])
|
|
224
237
|
.map((m: Raw) => mapPerson(m.mentioned) ?? { displayName: m.mentionText ?? "(mention)" })
|
|
@@ -241,6 +254,7 @@ export function mapMessage(raw: Raw, location?: MessageLocation): MessageSummary
|
|
|
241
254
|
mentions: mapMentions(raw),
|
|
242
255
|
reactions: mapReactions(raw),
|
|
243
256
|
attachments: mapAttachments(raw),
|
|
257
|
+
imageUrls: mapImageUrls(raw),
|
|
244
258
|
webUrl: raw.webUrl ?? undefined,
|
|
245
259
|
location,
|
|
246
260
|
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Downloading what a message carries.
|
|
3
|
+
*
|
|
4
|
+
* A tool result is text, so bytes have to reach the disk before an agent can
|
|
5
|
+
* look at them — that is the whole job here. Both sources are Graph: inline
|
|
6
|
+
* images live in `hostedContents`, files are `attachments`, and a small
|
|
7
|
+
* attachment arrives inline as base64 instead of a URL.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { TeamsConnection } from "../config/index.ts";
|
|
13
|
+
import type { MessageAttachment, MessageSummary } from "../types.ts";
|
|
14
|
+
import { extensionForContentType, safeFileName } from "../utils/attachments.ts";
|
|
15
|
+
import { formatGraphError } from "../utils/errors.ts";
|
|
16
|
+
import { graphDownload } from "./client.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Refuse anything larger.
|
|
20
|
+
*
|
|
21
|
+
* A message can carry a two gigabyte video, and a tool call that quietly fills
|
|
22
|
+
* the disk is worse than one that says no.
|
|
23
|
+
*/
|
|
24
|
+
export const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
export interface SavedFile {
|
|
27
|
+
path: string;
|
|
28
|
+
name: string;
|
|
29
|
+
bytes: number;
|
|
30
|
+
kind: "image" | "attachment";
|
|
31
|
+
contentType?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SkippedFile {
|
|
35
|
+
name: string;
|
|
36
|
+
reason: string;
|
|
37
|
+
/** The URL to fetch by hand, when there is one. */
|
|
38
|
+
url?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SaveResult {
|
|
42
|
+
saved: SavedFile[];
|
|
43
|
+
skipped: SkippedFile[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function tooBig(bytes: number): string {
|
|
47
|
+
const mb = Math.round((bytes / (1024 * 1024)) * 10) / 10;
|
|
48
|
+
return `larger than ${Math.round(MAX_FILE_BYTES / (1024 * 1024))} MB (${mb} MB)`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Append the extension the content type implies, when the name has none. */
|
|
52
|
+
function nameWithExtension(name: string | undefined, contentType: string | undefined, fallback: string): string {
|
|
53
|
+
const safe = safeFileName(name, fallback);
|
|
54
|
+
if (/\.[a-z0-9]{1,5}$/i.test(safe)) return safe;
|
|
55
|
+
return safe + extensionForContentType(contentType);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Keep two attachments with the same name from overwriting each other. */
|
|
59
|
+
function uniqueName(used: Set<string>, name: string): string {
|
|
60
|
+
if (!used.has(name)) {
|
|
61
|
+
used.add(name);
|
|
62
|
+
return name;
|
|
63
|
+
}
|
|
64
|
+
const dot = name.lastIndexOf(".");
|
|
65
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
66
|
+
const suffix = dot > 0 ? name.slice(dot) : "";
|
|
67
|
+
for (let n = 2; ; n += 1) {
|
|
68
|
+
const candidate = `${stem}-${n}${suffix}`;
|
|
69
|
+
if (!used.has(candidate)) {
|
|
70
|
+
used.add(candidate);
|
|
71
|
+
return candidate;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Save every image and attachment of one message into `dir`.
|
|
78
|
+
*
|
|
79
|
+
* Never throws for a single failed file: a message with three images and one
|
|
80
|
+
* broken link should still leave the three on disk, with the failure reported
|
|
81
|
+
* next to them.
|
|
82
|
+
*/
|
|
83
|
+
export async function saveMessageFiles(
|
|
84
|
+
conn: TeamsConnection,
|
|
85
|
+
message: MessageSummary,
|
|
86
|
+
dir: string,
|
|
87
|
+
options: { signal?: AbortSignal } = {},
|
|
88
|
+
): Promise<SaveResult> {
|
|
89
|
+
const saved: SavedFile[] = [];
|
|
90
|
+
const skipped: SkippedFile[] = [];
|
|
91
|
+
const used = new Set<string>();
|
|
92
|
+
|
|
93
|
+
await mkdir(dir, { recursive: true });
|
|
94
|
+
|
|
95
|
+
const write = async (
|
|
96
|
+
baseName: string,
|
|
97
|
+
contentType: string | undefined,
|
|
98
|
+
data: Buffer,
|
|
99
|
+
kind: SavedFile["kind"],
|
|
100
|
+
): Promise<void> => {
|
|
101
|
+
const name = uniqueName(used, baseName);
|
|
102
|
+
const path = join(dir, name);
|
|
103
|
+
await writeFile(path, data);
|
|
104
|
+
saved.push({ path, name, bytes: data.byteLength, kind, contentType });
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Inline images carry no name, so they are numbered the way they appear.
|
|
108
|
+
let imageIndex = 0;
|
|
109
|
+
for (const url of message.imageUrls) {
|
|
110
|
+
imageIndex += 1;
|
|
111
|
+
const label = `image ${imageIndex}`;
|
|
112
|
+
try {
|
|
113
|
+
const { data, contentType } = await graphDownload(conn, url, { signal: options.signal });
|
|
114
|
+
if (data.byteLength > MAX_FILE_BYTES) {
|
|
115
|
+
skipped.push({ name: label, reason: tooBig(data.byteLength), url });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const extension = extensionForContentType(contentType) || ".bin";
|
|
119
|
+
await write(`${message.id}-${imageIndex}${extension}`, contentType, data, "image");
|
|
120
|
+
} catch (err) {
|
|
121
|
+
skipped.push({ name: label, reason: formatGraphError(err), url });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const attachment of message.attachments) {
|
|
126
|
+
const label = attachment.name ?? "attachment";
|
|
127
|
+
|
|
128
|
+
if (attachment.contentBytes) {
|
|
129
|
+
// Small attachments arrive with the message itself.
|
|
130
|
+
const data = Buffer.from(attachment.contentBytes, "base64");
|
|
131
|
+
if (data.byteLength > MAX_FILE_BYTES) {
|
|
132
|
+
skipped.push({ name: label, reason: tooBig(data.byteLength), url: attachment.contentUrl });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
await write(
|
|
136
|
+
nameWithExtension(attachment.name, attachment.contentType, `attachment-${message.id}`),
|
|
137
|
+
attachment.contentType,
|
|
138
|
+
data,
|
|
139
|
+
"attachment",
|
|
140
|
+
);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!attachment.contentUrl) {
|
|
145
|
+
skipped.push({ name: label, reason: describeMissing(attachment) });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const { data, contentType } = await graphDownload(conn, attachment.contentUrl, {
|
|
151
|
+
signal: options.signal,
|
|
152
|
+
});
|
|
153
|
+
if (data.byteLength > MAX_FILE_BYTES) {
|
|
154
|
+
skipped.push({ name: label, reason: tooBig(data.byteLength), url: attachment.contentUrl });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
await write(
|
|
158
|
+
nameWithExtension(attachment.name, contentType ?? attachment.contentType, `attachment-${message.id}`),
|
|
159
|
+
contentType ?? attachment.contentType,
|
|
160
|
+
data,
|
|
161
|
+
"attachment",
|
|
162
|
+
);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
skipped.push({ name: label, reason: formatGraphError(err), url: attachment.contentUrl });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { saved, skipped };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function describeMissing(attachment: MessageAttachment): string {
|
|
172
|
+
const type = attachment.contentType ? ` (${attachment.contentType})` : "";
|
|
173
|
+
return `no content URL and no inline bytes${type} — likely a card or a code snippet, not a file`;
|
|
174
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* teams_download_files — save the files and images a message carries.
|
|
3
|
+
*
|
|
4
|
+
* The read tools flatten a message to text, which is right for prose and wrong
|
|
5
|
+
* for anything with bytes in it. This is the counterpart: it fetches the images
|
|
6
|
+
* embedded in messages and the files attached to them into a directory, and
|
|
7
|
+
* returns the paths, so a reader can open them.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
import { getAgentDir } from "../config/index.ts";
|
|
13
|
+
import { saveMessageFiles, type SavedFile, type SkippedFile } from "../graph/media.ts";
|
|
14
|
+
import {
|
|
15
|
+
getChannelMessage,
|
|
16
|
+
getChatMessage,
|
|
17
|
+
listChannelMessages,
|
|
18
|
+
listChatMessages,
|
|
19
|
+
} from "../graph/messages.ts";
|
|
20
|
+
import type { MessageSummary } from "../types.ts";
|
|
21
|
+
import { safeFileName } from "../utils/attachments.ts";
|
|
22
|
+
import { formatDownloadResult } from "../utils/formatting.ts";
|
|
23
|
+
import { requireChannel, requireChat, TargetError } from "./resolve.ts";
|
|
24
|
+
import {
|
|
25
|
+
AccountParam,
|
|
26
|
+
TenantParam,
|
|
27
|
+
connectionFor,
|
|
28
|
+
run,
|
|
29
|
+
textResult,
|
|
30
|
+
type ToolContext,
|
|
31
|
+
type ToolResult,
|
|
32
|
+
} from "./shared.ts";
|
|
33
|
+
|
|
34
|
+
export const teamsDownloadFilesTool = {
|
|
35
|
+
name: "teams_download_files",
|
|
36
|
+
description:
|
|
37
|
+
"Download the images embedded in Teams messages and the files attached to them to a local " +
|
|
38
|
+
"directory, and return the paths so they can be opened. Covers files attached to a message, not " +
|
|
39
|
+
"a channel's SharePoint folder (see teams_list_files).",
|
|
40
|
+
parameters: Type.Object({
|
|
41
|
+
chat: Type.Optional(Type.String({ description: "Chat ID, topic, or a participant's name/e-mail" })),
|
|
42
|
+
channel: Type.Optional(Type.String({ description: "Channel name or ID, or a 'Team/Channel' path" })),
|
|
43
|
+
team: Type.Optional(Type.String({ description: "Team name or ID (omit when using a path)" })),
|
|
44
|
+
messageId: Type.Optional(
|
|
45
|
+
Type.String({ description: "Download only this message instead of scanning recent ones" }),
|
|
46
|
+
),
|
|
47
|
+
limit: Type.Optional(
|
|
48
|
+
Type.Number({ description: "How many recent messages to scan (default 10, max 50)" }),
|
|
49
|
+
),
|
|
50
|
+
dir: Type.Optional(Type.String({ description: "Directory to save into (default: pi-teams-files)" })),
|
|
51
|
+
account: AccountParam,
|
|
52
|
+
tenant: TenantParam,
|
|
53
|
+
}),
|
|
54
|
+
promptSnippet: "Download files and images from Teams messages",
|
|
55
|
+
|
|
56
|
+
async execute(
|
|
57
|
+
_toolCallId: string,
|
|
58
|
+
params: {
|
|
59
|
+
chat?: string;
|
|
60
|
+
channel?: string;
|
|
61
|
+
team?: string;
|
|
62
|
+
messageId?: string;
|
|
63
|
+
limit?: number;
|
|
64
|
+
dir?: string;
|
|
65
|
+
account?: string;
|
|
66
|
+
tenant?: string;
|
|
67
|
+
},
|
|
68
|
+
signal: AbortSignal | undefined,
|
|
69
|
+
_onUpdate: undefined,
|
|
70
|
+
ctx: ToolContext,
|
|
71
|
+
): Promise<ToolResult> {
|
|
72
|
+
return run(async () => {
|
|
73
|
+
if (params.chat && params.channel) {
|
|
74
|
+
throw new TargetError("Pass either 'chat' or 'channel', not both.");
|
|
75
|
+
}
|
|
76
|
+
if (!params.chat && !params.channel) {
|
|
77
|
+
throw new TargetError("Pass a 'chat' or a 'channel' to download from.");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
81
|
+
const scanLimit = Math.min(Math.max(params.limit ?? 10, 1), 50);
|
|
82
|
+
|
|
83
|
+
let label: string;
|
|
84
|
+
let slug: string;
|
|
85
|
+
let messages: MessageSummary[];
|
|
86
|
+
|
|
87
|
+
if (params.chat) {
|
|
88
|
+
const chat = await requireChat(conn, params.chat, "read", signal);
|
|
89
|
+
label = chat.label;
|
|
90
|
+
slug = safeFileName(chat.label, chat.id);
|
|
91
|
+
messages = params.messageId
|
|
92
|
+
? await one(await getChatMessage(conn, chat.id, params.messageId, signal), params.messageId)
|
|
93
|
+
: await listChatMessages(conn, chat.id, { max: scanLimit, signal });
|
|
94
|
+
} else {
|
|
95
|
+
const { team, channel } = await requireChannel(
|
|
96
|
+
conn,
|
|
97
|
+
params.team,
|
|
98
|
+
params.channel as string,
|
|
99
|
+
"read",
|
|
100
|
+
signal,
|
|
101
|
+
);
|
|
102
|
+
label = `${team.displayName}/${channel.displayName}`;
|
|
103
|
+
slug = safeFileName(`${team.displayName}-${channel.displayName}`, channel.id);
|
|
104
|
+
messages = params.messageId
|
|
105
|
+
? await one(await getChannelMessage(conn, channel, params.messageId, signal), params.messageId)
|
|
106
|
+
: await listChannelMessages(conn, channel, { max: scanLimit, signal });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const dir = params.dir ?? join(getAgentDir(), "pi-teams-files", slug);
|
|
110
|
+
|
|
111
|
+
const saved: SavedFile[] = [];
|
|
112
|
+
const skipped: SkippedFile[] = [];
|
|
113
|
+
for (const message of messages) {
|
|
114
|
+
const result = await saveMessageFiles(conn, message, dir, { signal });
|
|
115
|
+
saved.push(...result.saved);
|
|
116
|
+
skipped.push(...result.skipped);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const withFiles = messages.filter(
|
|
120
|
+
(m) => m.imageUrls.length > 0 || m.attachments.length > 0,
|
|
121
|
+
).length;
|
|
122
|
+
|
|
123
|
+
return textResult(
|
|
124
|
+
[
|
|
125
|
+
formatDownloadResult(label, dir, saved, skipped),
|
|
126
|
+
"",
|
|
127
|
+
`Scanned ${messages.length} message${messages.length === 1 ? "" : "s"}` +
|
|
128
|
+
`${withFiles === 0 ? ", none with files" : ""}.`,
|
|
129
|
+
].join("\n"),
|
|
130
|
+
{
|
|
131
|
+
dir,
|
|
132
|
+
saved: saved.map((file) => file.path),
|
|
133
|
+
skipped: skipped.length,
|
|
134
|
+
scanned: messages.length,
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** A single message, or a clear error — rather than silently downloading nothing. */
|
|
142
|
+
async function one(message: MessageSummary | undefined, messageId: string): Promise<MessageSummary[]> {
|
|
143
|
+
if (!message) throw new TargetError(`No message ${messageId} in this conversation.`);
|
|
144
|
+
return [message];
|
|
145
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -121,6 +121,13 @@ export interface MessageSummary {
|
|
|
121
121
|
mentions: PersonRef[];
|
|
122
122
|
reactions: MessageReaction[];
|
|
123
123
|
attachments: MessageAttachment[];
|
|
124
|
+
/**
|
|
125
|
+
* URLs of the images embedded in the message body.
|
|
126
|
+
*
|
|
127
|
+
* Graph URLs, absolute and ready to fetch — Teams writes them into the HTML
|
|
128
|
+
* as the `src` of each `<img>`. Empty for a text-only message.
|
|
129
|
+
*/
|
|
130
|
+
imageUrls: string[];
|
|
124
131
|
webUrl?: string;
|
|
125
132
|
/** Where the message lives — used for follow-up calls and scope checks */
|
|
126
133
|
location?: MessageLocation;
|
|
@@ -137,6 +144,8 @@ export interface MessageAttachment {
|
|
|
137
144
|
name?: string;
|
|
138
145
|
contentType?: string;
|
|
139
146
|
contentUrl?: string;
|
|
147
|
+
/** Small attachments arrive inline as base64 instead of a URL. */
|
|
148
|
+
contentBytes?: string;
|
|
140
149
|
}
|
|
141
150
|
|
|
142
151
|
export interface MessageLocation {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a message carries besides text.
|
|
3
|
+
*
|
|
4
|
+
* Two different mechanisms hide behind "there was a picture in that message":
|
|
5
|
+
* an inline image is a `hostedContent` referenced from the message HTML, and a
|
|
6
|
+
* file is an `attachment` on the message. For a long time only the second one
|
|
7
|
+
* reached the mapper, so a reader saw `[image: Bild]` and nothing else — the
|
|
8
|
+
* URL was in the body all along, pointing straight at Graph.
|
|
9
|
+
*
|
|
10
|
+
* The helpers here are pure, because the shapes are the part that quietly goes
|
|
11
|
+
* wrong and they can be tested without a Graph call.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Absolute URLs of the inline images in a message body, in the order shown. */
|
|
15
|
+
export function imageUrlsInHtml(html: string): string[] {
|
|
16
|
+
const urls: string[] = [];
|
|
17
|
+
for (const match of html.matchAll(/<img\b[^>]*\bsrc=["']([^"']+)["']/gi)) {
|
|
18
|
+
const src = decodeEntities(match[1] ?? "");
|
|
19
|
+
// An <img> can also point at an outside host. Only content that lives in
|
|
20
|
+
// the message itself is ours to fetch.
|
|
21
|
+
if (/\/hostedContents\//i.test(src) && !urls.includes(src)) urls.push(src);
|
|
22
|
+
}
|
|
23
|
+
return urls;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function decodeEntities(input: string): string {
|
|
27
|
+
return input
|
|
28
|
+
.replace(/&/g, "&")
|
|
29
|
+
.replace(/</g, "<")
|
|
30
|
+
.replace(/>/g, ">")
|
|
31
|
+
.replace(/"/g, '"')
|
|
32
|
+
.replace(/&#(\d+);/g, (_match, code: string) => String.fromCharCode(Number(code)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const CONTENT_TYPE_EXTENSIONS: Record<string, string> = {
|
|
36
|
+
"image/png": ".png",
|
|
37
|
+
"image/jpeg": ".jpg",
|
|
38
|
+
"image/jpg": ".jpg",
|
|
39
|
+
"image/gif": ".gif",
|
|
40
|
+
"image/webp": ".webp",
|
|
41
|
+
"image/bmp": ".bmp",
|
|
42
|
+
"image/svg+xml": ".svg",
|
|
43
|
+
"application/pdf": ".pdf",
|
|
44
|
+
"text/plain": ".txt",
|
|
45
|
+
"text/csv": ".csv",
|
|
46
|
+
"application/json": ".json",
|
|
47
|
+
"application/zip": ".zip",
|
|
48
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
|
49
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
|
50
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Extension for a response content type, including the dot. Empty when unknown. */
|
|
54
|
+
export function extensionForContentType(contentType: string | undefined): string {
|
|
55
|
+
const type = (contentType ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
56
|
+
return CONTENT_TYPE_EXTENSIONS[type] ?? "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A name that can be written on any platform, and is never empty.
|
|
61
|
+
*
|
|
62
|
+
* Keeps the extension when shortening: `slice` from the front would turn
|
|
63
|
+
* `quarterly-report.pdf` into a name ending in `-report.pdf` at best and into
|
|
64
|
+
* an unnamed blob at worst.
|
|
65
|
+
*/
|
|
66
|
+
export function safeFileName(name: string | undefined, fallback: string): string {
|
|
67
|
+
const base = (name ?? "").split(/[\\/]/).pop() ?? "";
|
|
68
|
+
const cleaned = base
|
|
69
|
+
// Reserved on Windows, plus control characters.
|
|
70
|
+
.replace(/[\u0000-\u001f<>:"|?*]/g, "_")
|
|
71
|
+
// A leading dot would make the file invisible in most file managers.
|
|
72
|
+
.replace(/^[.\s]+/, "")
|
|
73
|
+
.trim();
|
|
74
|
+
if (!cleaned) return fallback;
|
|
75
|
+
return cleaned.length > 120 ? cleaned.slice(-120) : cleaned;
|
|
76
|
+
}
|
package/src/utils/environment.ts
CHANGED
|
@@ -12,26 +12,43 @@
|
|
|
12
12
|
* The interactive sign-in needs two things: a browser to open, and a loopback
|
|
13
13
|
* listener that browser can reach. Over SSH both assumptions break — `open`
|
|
14
14
|
* would launch a browser on the *remote* machine, where nobody is looking — so
|
|
15
|
-
* those sessions get the device code flow instead.
|
|
15
|
+
* those sessions get the device code flow instead. In WSL the browser is on
|
|
16
|
+
* the Windows side of the same machine, which is what `isWsl` accounts for.
|
|
16
17
|
*
|
|
17
|
-
* Set `PI_TEAMS_NO_BROWSER=1` to force that fallback anywhere
|
|
18
|
+
* Set `PI_TEAMS_NO_BROWSER=1` to force that fallback anywhere, or
|
|
19
|
+
* `PI_TEAMS_BROWSER` to a command line that opens a URL where the built-in
|
|
20
|
+
* launchers cannot.
|
|
18
21
|
*/
|
|
22
|
+
export function isWsl(): boolean {
|
|
23
|
+
if (process.platform !== "linux") return false;
|
|
24
|
+
return !!(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
export function canOpenBrowser(): boolean {
|
|
20
28
|
if (process.env.PI_TEAMS_NO_BROWSER) return false;
|
|
21
29
|
|
|
30
|
+
// An explicit launcher is a statement that a browser is reachable, even where
|
|
31
|
+
// the heuristics below would not find one — a WSL setup, or a machine whose
|
|
32
|
+
// browser is reached through something custom.
|
|
33
|
+
if (process.env.PI_TEAMS_BROWSER?.trim()) return true;
|
|
34
|
+
|
|
22
35
|
// A remote shell: the browser would appear on the wrong machine.
|
|
23
36
|
if (process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT) return false;
|
|
24
37
|
|
|
25
38
|
if (process.platform === "darwin" || process.platform === "win32") return true;
|
|
26
39
|
|
|
40
|
+
// WSL is not a headless Linux: the browser lives on Windows and `openBrowser`
|
|
41
|
+
// hands the URL over to it. DISPLAY says nothing either way — WSLg sets it on
|
|
42
|
+
// Windows 11 and not on Windows 10, while the handoff works on both.
|
|
43
|
+
if (isWsl()) return true;
|
|
44
|
+
|
|
27
45
|
// Linux and the BSDs need a display server for a browser to appear at all.
|
|
28
46
|
return !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
|
29
47
|
}
|
|
30
48
|
|
|
31
49
|
/** Why the interactive flow is unavailable, phrased for a user-facing message. */
|
|
32
50
|
export function browserUnavailableReason(): string {
|
|
33
|
-
if (process.env.PI_TEAMS_NO_BROWSER) return "PI_TEAMS_NO_BROWSER is set";
|
|
34
|
-
if (process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT) {
|
|
51
|
+
if (process.env.PI_TEAMS_NO_BROWSER) return "PI_TEAMS_NO_BROWSER is set"; if (process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT) {
|
|
35
52
|
return "this is an SSH session, so the browser would open on the wrong machine";
|
|
36
53
|
}
|
|
37
54
|
if (process.platform !== "darwin" && process.platform !== "win32") {
|
package/src/utils/formatting.ts
CHANGED
|
@@ -298,6 +298,12 @@ export function formatMessage(message: MessageSummary, options: { showId?: boole
|
|
|
298
298
|
if (message.attachments.length > 0) {
|
|
299
299
|
meta.push(`attachments: ${message.attachments.map((a) => a.name ?? "file").join(", ")}`);
|
|
300
300
|
}
|
|
301
|
+
if (message.imageUrls.length > 0) {
|
|
302
|
+
// The images themselves are not in the text. Saying how many there are is
|
|
303
|
+
// what tells a reader that something is missing and where to get it.
|
|
304
|
+
const count = message.imageUrls.length;
|
|
305
|
+
meta.push(`${count} image${count === 1 ? "" : "s"} — use teams_download_files`);
|
|
306
|
+
}
|
|
301
307
|
if (message.replyCount !== undefined && message.replyCount > 0) {
|
|
302
308
|
meta.push(`${message.replyCount} repl${message.replyCount === 1 ? "y" : "ies"}`);
|
|
303
309
|
}
|
|
@@ -404,3 +410,40 @@ export function formatFileList(items: readonly DriveItemSummary[], title: string
|
|
|
404
410
|
}
|
|
405
411
|
return lines.join("\n");
|
|
406
412
|
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The result of saving a message's files.
|
|
416
|
+
*
|
|
417
|
+
* Typed structurally on purpose: this module is a leaf and knows nothing about
|
|
418
|
+
* Graph, so the caller passes the shape it already has.
|
|
419
|
+
*/
|
|
420
|
+
export function formatDownloadResult(
|
|
421
|
+
label: string,
|
|
422
|
+
dir: string,
|
|
423
|
+
saved: readonly { path: string; name: string; bytes: number; kind: string }[],
|
|
424
|
+
skipped: readonly { name: string; reason: string; url?: string }[],
|
|
425
|
+
): string {
|
|
426
|
+
const lines = [`## Files from ${label}`, ""];
|
|
427
|
+
|
|
428
|
+
if (saved.length === 0) {
|
|
429
|
+
lines.push("Nothing to download.");
|
|
430
|
+
} else {
|
|
431
|
+
lines.push(`${saved.length} file${saved.length === 1 ? "" : "s"} saved to \`${dir}\`:`, "");
|
|
432
|
+
for (const file of saved) {
|
|
433
|
+
const icon = file.kind === "image" ? "🖼" : "📄";
|
|
434
|
+
lines.push(`- ${icon} \`${file.path}\` (${formatBytes(file.bytes)})`);
|
|
435
|
+
}
|
|
436
|
+
lines.push("", "Open them with the file-reading tool.");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (skipped.length > 0) {
|
|
440
|
+
lines.push("", "Not saved:");
|
|
441
|
+
for (const file of skipped) {
|
|
442
|
+
// The URL is the fallback: it can be opened by hand where the download
|
|
443
|
+
// was refused.
|
|
444
|
+
lines.push(`- **${file.name}** — ${file.reason}${file.url ? `\n ${file.url}` : ""}`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return lines.join("\n");
|
|
449
|
+
}
|