@patimweb/pi-teams 1.7.1 → 1.7.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patimweb/pi-teams",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
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",
@@ -16,7 +16,13 @@ import {
16
16
  tryResolveConnection,
17
17
  type TeamsConnection,
18
18
  } from "../config/index.ts";
19
- import { buildConnectionCard, buildConnectionLabel, formatStatusText, type ConnectionCard } from "../status.ts";
19
+ import {
20
+ buildConnectionCard,
21
+ buildConnectionLabel,
22
+ buildStartupNotice,
23
+ formatStatusText,
24
+ type ConnectionCard,
25
+ } from "../status.ts";
20
26
  import {
21
27
  blockReason,
22
28
  formatMutationSummary,
@@ -416,10 +422,8 @@ export default function (pi: ExtensionAPI) {
416
422
  "warning",
417
423
  );
418
424
  } else {
419
- ctx.ui.notify(
420
- `@patimweb/pi-teams loaded (${card.account}${card.user ? ` as ${card.user}` : ""}, safety: ${card.safetyLevel})`,
421
- "info",
422
- );
425
+ const notice = buildStartupNotice(card, connection.watch);
426
+ ctx.ui.notify(notice.message, notice.level);
423
427
  }
424
428
 
425
429
  // Survives /reload, so the transcript keeps saying who pi is acting as.
@@ -23,7 +23,7 @@ import type {
23
23
  SignedInUser,
24
24
  TeamSummary,
25
25
  } from "../types.ts";
26
- import { imageUrlsInHtml } from "../utils/attachments.ts";
26
+ import { collectStrings, imageUrlsInHtml } from "../utils/attachments.ts";
27
27
  import { htmlToText } from "../utils/formatting.ts";
28
28
 
29
29
  /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -227,9 +227,31 @@ function mapAttachments(raw: Raw): MessageAttachment[] {
227
227
  * `htmlToText` turns every `<img>` into `[image: alt]`, which is right for a
228
228
  * reader that only wants prose and wrong for one that could fetch the image:
229
229
  * the URL it drops points at Graph.
230
+ *
231
+ * A forwarded message keeps its images out of the outer body. The original
232
+ * travels as an attachment of type `forwardedMessageReference`, with the whole
233
+ * message in its `content` — as a JSON string, and nested again if that one had
234
+ * been forwarded too. Searching every string inside it finds the images without
235
+ * modelling that shape, which would only ever be one Graph change out of date.
230
236
  */
231
237
  function mapImageUrls(raw: Raw): string[] {
232
- return imageUrlsInHtml(raw.body?.content ?? "");
238
+ const candidates: string[] = [];
239
+ if (typeof raw.body?.content === "string") candidates.push(raw.body.content);
240
+
241
+ for (const attachment of raw.attachments ?? []) {
242
+ if (typeof attachment?.content !== "string") continue;
243
+ let parsed: unknown;
244
+ try {
245
+ parsed = JSON.parse(attachment.content);
246
+ } catch {
247
+ // Not JSON: search it as it stands rather than dropping it.
248
+ candidates.push(attachment.content);
249
+ continue;
250
+ }
251
+ collectStrings(parsed, candidates);
252
+ }
253
+
254
+ return imageUrlsInHtml(candidates.join("\n"));
233
255
  }
234
256
 
235
257
  function mapMentions(raw: Raw): PersonRef[] {
@@ -142,6 +142,11 @@ export async function saveMessageFiles(
142
142
  }
143
143
 
144
144
  if (!attachment.contentUrl) {
145
+ // A forwarded message is not a file being withheld: its images and files
146
+ // are hosted content of the message that carries it, and they are fetched
147
+ // above. Listing it as unsaved would put a line of noise under every
148
+ // forwarded message.
149
+ if (attachment.contentType === "forwardedMessageReference") continue;
145
150
  skipped.push({ name: label, reason: describeMissing(attachment) });
146
151
  continue;
147
152
  }
package/src/status.ts CHANGED
@@ -90,6 +90,31 @@ export function buildConnectionLabel(card: ConnectionCard | undefined): string {
90
90
  return `✓ Teams · ${scope}${card.user ? ` · ${card.user}` : ""}${listening}`;
91
91
  }
92
92
 
93
+ /**
94
+ * The line a session starts with.
95
+ *
96
+ * It names listen mode on purpose. The watcher starts from the saved setting, so
97
+ * a session can begin polling — and answering in your name — without the
98
+ * transcript ever saying so, which is how a listener nobody remembers starting
99
+ * turns up, and how it gets to answer before anyone notices.
100
+ *
101
+ * The level is part of the answer: being listened to is the state worth a louder
102
+ * notice, not the ordinary one of it being off.
103
+ */
104
+ export function buildStartupNotice(
105
+ card: ConnectionCard,
106
+ watch: { enabled: boolean; intervalSeconds: number },
107
+ ): { message: string; level: "info" | "warning" } {
108
+ const identity = `@patimweb/pi-teams loaded (${card.account}${card.user ? ` as ${card.user}` : ""}, safety: ${card.safetyLevel})`;
109
+
110
+ if (!watch.enabled) return { message: `${identity} · listen mode off`, level: "info" };
111
+
112
+ return {
113
+ message: `${identity} · listen mode ON — every ${watch.intervalSeconds} s, /teams-listen off to stop`,
114
+ level: "warning",
115
+ };
116
+ }
117
+
93
118
  /** Rich markdown for `/teams-status` and the expanded card. */
94
119
  export function formatStatusText(card: ConnectionCard | undefined): string {
95
120
  if (!card) {
@@ -11,6 +11,44 @@
11
11
  * wrong and they can be tested without a Graph call.
12
12
  */
13
13
 
14
+ /**
15
+ * Every string inside a value, however it is nested.
16
+ *
17
+ * Exists for the JSON that Graph hands back for a forwarded message: the
18
+ * original message is in there as data of an undocumented depth, and the only
19
+ * thing needed from it is whatever strings it contains.
20
+ */
21
+ export function collectStrings(value: unknown, out: string[] = [], depth = 0): string[] {
22
+ // A guard against a pathological document, not a real limit.
23
+ if (depth > 8) return out;
24
+
25
+ if (typeof value === "string") {
26
+ out.push(value);
27
+ // A forwarded message inside a forwarded message is JSON stored as a string
28
+ // inside JSON, and its quotes stay escaped until it is parsed. Without this
29
+ // the images two levels down are simply not there to be found.
30
+ const trimmed = value.trim();
31
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
32
+ try {
33
+ collectStrings(JSON.parse(trimmed), out, depth + 1);
34
+ } catch {
35
+ /* a string that merely starts with a brace */
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+ if (Array.isArray(value)) {
41
+ for (const entry of value) collectStrings(entry, out, depth + 1);
42
+ return out;
43
+ }
44
+ if (value && typeof value === "object") {
45
+ for (const entry of Object.values(value as Record<string, unknown>)) {
46
+ collectStrings(entry, out, depth + 1);
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+
14
52
  /** Absolute URLs of the inline images in a message body, in the order shown. */
15
53
  export function imageUrlsInHtml(html: string): string[] {
16
54
  const urls: string[] = [];