@patimweb/pi-teams 1.9.2 → 1.11.0
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/docs/listen-mode.md +51 -2
- package/docs/tools.md +35 -6
- package/package.json +1 -1
- package/skills/teams-collaboration/SKILL.md +30 -4
- package/src/config/index.ts +98 -5
- package/src/extension/index.ts +15 -1
- package/src/graph/messages.ts +25 -0
- package/src/safety/index.ts +15 -3
- package/src/tools/teams-send-channel-message.ts +38 -8
- package/src/tools/teams-send-chat-message.ts +21 -3
- package/src/tools/teams-watch.ts +33 -4
- package/src/utils/hosted-content.ts +105 -0
- package/src/watch/index.ts +83 -2
package/docs/listen-mode.md
CHANGED
|
@@ -30,7 +30,11 @@ never sees.
|
|
|
30
30
|
"intervalSeconds": 60,
|
|
31
31
|
"chats": ["Anna*", "Vertrieb*"],
|
|
32
32
|
"from": ["anna.schmidt@contoso.com"],
|
|
33
|
-
"mentionOnly":
|
|
33
|
+
"mentionOnly": {
|
|
34
|
+
"default": true,
|
|
35
|
+
"chats": { "*Holodeck*": true },
|
|
36
|
+
"people": { "anna.schmidt@contoso.com": false }
|
|
37
|
+
},
|
|
34
38
|
"cooldownSeconds": 300,
|
|
35
39
|
"maxTriggersPerHour": 10
|
|
36
40
|
}
|
|
@@ -43,7 +47,7 @@ never sees.
|
|
|
43
47
|
| `intervalSeconds` | `60` | seconds between polls (minimum 15) |
|
|
44
48
|
| `chats` | `[]` | **where** pi listens: glob patterns matched against topic, label, chat ID and participant names. Empty means every recent chat. |
|
|
45
49
|
| `from` | `[]` | **who** pi listens to: glob patterns matched against display name, UPN and e-mail. Empty means any sender. |
|
|
46
|
-
| `mentionOnly` | `false` |
|
|
50
|
+
| `mentionOnly` | `false` | whether a message has to address you: `true`/`false`, or `{ default, chats, people }` for overrides. See [below](#who-has-to-address-pi). |
|
|
47
51
|
| `cooldownSeconds` | `300` | stay quiet in a chat after waking pi for it |
|
|
48
52
|
| `maxTriggersPerHour` | `10` | hard cap on wakes per hour |
|
|
49
53
|
|
|
@@ -51,6 +55,51 @@ Set it globally, or per account — the account level wins field by field.
|
|
|
51
55
|
`teams_watch` writes the same keys; `/teams-listen` writes to the session's
|
|
52
56
|
account.
|
|
53
57
|
|
|
58
|
+
### Who has to address pi
|
|
59
|
+
|
|
60
|
+
`mentionOnly` answers one question: does a message have to name you before it
|
|
61
|
+
may wake pi? It is easy to get wrong in both directions — too strict and pi goes
|
|
62
|
+
silent in chats that wanted an answer, too loose and it answers conversations it
|
|
63
|
+
was never part of.
|
|
64
|
+
|
|
65
|
+
A message counts as an address when it **@-mentions you** (or names you in the
|
|
66
|
+
text: `Patrick, kannst du…`), or when it arrives in a **1:1 chat**. A chat with
|
|
67
|
+
one other person is addressed to you by definition — there is nobody else it
|
|
68
|
+
could be meant for — so demanding a mention there would only buy silence. Group
|
|
69
|
+
and meeting chats have no such shortcut.
|
|
70
|
+
|
|
71
|
+
The switch takes a boolean for "everywhere", or an object when one answer is not
|
|
72
|
+
enough:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
"mentionOnly": {
|
|
76
|
+
"default": true,
|
|
77
|
+
"chats": { "*Holodeck*": true, "chat-1": false },
|
|
78
|
+
"people": { "anna.schmidt@contoso.com": false, "bernd@contoso.com": true }
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Rules are matched like the scope rules — chats against topic, label, chat ID and
|
|
83
|
+
participants, people against display name, UPN, e-mail and id — and the **first**
|
|
84
|
+
matching pattern wins, so put the specific rule above the general one. Both
|
|
85
|
+
values are settable, which is what makes the switch usable: a global `true` with
|
|
86
|
+
one person set to `false` means everyone has to address you except that person.
|
|
87
|
+
|
|
88
|
+
The order, most specific first:
|
|
89
|
+
|
|
90
|
+
1. a `chats` rule for this conversation,
|
|
91
|
+
2. the 1:1 exemption,
|
|
92
|
+
3. a `people` rule for the sender,
|
|
93
|
+
4. `default`.
|
|
94
|
+
|
|
95
|
+
The 1:1 exemption sits **above** the people rules deliberately: it is about the
|
|
96
|
+
conversation rather than about somebody in it, so a rule meant for group chats
|
|
97
|
+
cannot silence the direct messages that need no ceremony. To require a mention
|
|
98
|
+
in a 1:1, name that chat in `chats`.
|
|
99
|
+
|
|
100
|
+
An account that sets `mentionOnly` replaces the whole switch, exactly as it
|
|
101
|
+
does for `chats` and `from` — the object does not merge with the global one.
|
|
102
|
+
|
|
54
103
|
## How a wake is decided
|
|
55
104
|
|
|
56
105
|
A chat wakes pi when two things are true:
|
package/docs/tools.md
CHANGED
|
@@ -41,10 +41,10 @@ the default account. See [configuration.md](configuration.md).
|
|
|
41
41
|
|
|
42
42
|
| Tool | Description |
|
|
43
43
|
|------|-------------|
|
|
44
|
-
| `teams_send_chat_message` | Send a chat message as you |
|
|
44
|
+
| `teams_send_chat_message` | Send a chat message as you, optionally with images |
|
|
45
45
|
| `teams_create_chat` | Start a 1:1 or group chat, optionally with the first message |
|
|
46
|
-
| `teams_send_channel_message` | Post in a channel as you |
|
|
47
|
-
| `teams_reply_channel_message` | Reply inside an existing thread |
|
|
46
|
+
| `teams_send_channel_message` | Post in a channel as you, optionally with images |
|
|
47
|
+
| `teams_reply_channel_message` | Reply inside an existing thread, optionally with images |
|
|
48
48
|
| `teams_react` | Add or remove an emoji reaction |
|
|
49
49
|
| `teams_mark_read` | Mark a chat as read (or unread again) — the natural end of a catch-up |
|
|
50
50
|
| `teams_update_message` | Edit a chat message you already sent |
|
|
@@ -137,9 +137,11 @@ subset Teams renders in a chat bubble:
|
|
|
137
137
|
| `[label](https://…)` | link |
|
|
138
138
|
| `# Heading` | a bold line — Teams has no headings in a chat |
|
|
139
139
|
|
|
140
|
-
|
|
141
|
-
a chat,
|
|
142
|
-
|
|
140
|
+
A markdown image and a table are deliberately **not** translated: Teams renders
|
|
141
|
+
neither in a chat bubble, and an image URL would only work if the recipient's
|
|
142
|
+
client could reach it. A picture that is already on disk goes out through the
|
|
143
|
+
`images` parameter instead — see below. For a table, put it in a file and link
|
|
144
|
+
to it.
|
|
143
145
|
|
|
144
146
|
Pass `html: true` on a send tool to bypass the conversion and supply raw HTML
|
|
145
147
|
instead. Everything else is escaped, so a message body cannot inject markup.
|
|
@@ -148,3 +150,30 @@ This is also why pi writes the way it does — short, answer-first, bullets for
|
|
|
148
150
|
anything enumerable. The medium is a chat bubble, not a document. The rules the
|
|
149
151
|
model follows are in `skills/teams-collaboration/SKILL.md`, under *How to format
|
|
150
152
|
a Teams message*.
|
|
153
|
+
|
|
154
|
+
## Sending images
|
|
155
|
+
|
|
156
|
+
`teams_send_chat_message`, `teams_send_channel_message` and
|
|
157
|
+
`teams_reply_channel_message` take `images: ["/pfad/bild.png"]` — local paths of
|
|
158
|
+
pictures to attach. They appear under the text, in the order given, and a
|
|
159
|
+
message may consist of images alone.
|
|
160
|
+
|
|
161
|
+
Teams keeps an inline picture inside the message itself: the bytes travel in the
|
|
162
|
+
same request as the text, and the body points at them (`hostedContents`).
|
|
163
|
+
Nothing is uploaded to SharePoint first, which is why this works with the
|
|
164
|
+
permissions the package already asks for and costs no admin consent.
|
|
165
|
+
|
|
166
|
+
- Accepted: png, jpeg, gif, webp, bmp. Anything else is refused before the
|
|
167
|
+
message is built, because Teams would show it as a broken picture.
|
|
168
|
+
- **4 MB per image**, the limit Graph enforces on hosted content.
|
|
169
|
+
- The path must be readable from the process that sends. For a pi running in a
|
|
170
|
+
container that is a path *inside* the container.
|
|
171
|
+
|
|
172
|
+
This is attachments-for-images only. A PDF, a DOCX or a ZIP cannot be sent: a
|
|
173
|
+
real file attachment needs the file in SharePoint or OneDrive and a `contentUrl`
|
|
174
|
+
pointing at it, which means asking for a write scope on the user's drive. The
|
|
175
|
+
package deliberately does not ask for `Files.ReadWrite`, so a non-image is
|
|
176
|
+
refused rather than half-supported — link to the file instead.
|
|
177
|
+
|
|
178
|
+
What was sent, pictures included, is recorded in the audit entry and shown in
|
|
179
|
+
the confirmation prompt at `safetyLevel: confirm`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patimweb/pi-teams",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
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",
|
|
@@ -97,10 +97,10 @@ instead of trying anyway.
|
|
|
97
97
|
|
|
98
98
|
| Tool | Use when |
|
|
99
99
|
|------|----------|
|
|
100
|
-
| `teams_send_chat_message` | Sending a chat message |
|
|
100
|
+
| `teams_send_chat_message` | Sending a chat message, with or without images |
|
|
101
101
|
| `teams_create_chat` | Starting a new conversation |
|
|
102
|
-
| `teams_send_channel_message` | Posting a new channel message |
|
|
103
|
-
| `teams_reply_channel_message` | Answering inside an existing thread |
|
|
102
|
+
| `teams_send_channel_message` | Posting a new channel message, with or without images |
|
|
103
|
+
| `teams_reply_channel_message` | Answering inside an existing thread, with or without images |
|
|
104
104
|
| `teams_react` | Adding or removing an emoji reaction |
|
|
105
105
|
| `teams_update_message` | Fixing a message the user already sent |
|
|
106
106
|
| `teams_chat_members` | Bringing someone into a group chat, or taking them out |
|
|
@@ -240,6 +240,24 @@ deliberate and must not be worked around:
|
|
|
240
240
|
|
|
241
241
|
Ordinary SharePoint and OneDrive attachments download normally.
|
|
242
242
|
|
|
243
|
+
### Sending images
|
|
244
|
+
|
|
245
|
+
The three send tools take `images: ["/path/bild.png"]`: local pictures to attach
|
|
246
|
+
to the message. They are shown under the text, a message may be images only, and
|
|
247
|
+
nothing is uploaded anywhere — Teams keeps them inside the message.
|
|
248
|
+
|
|
249
|
+
- png, jpeg, gif, webp, bmp, **at most 4 MB each**. Anything else is refused
|
|
250
|
+
before the message is built; report that instead of retrying with the same file.
|
|
251
|
+
- The path is read by the process that sends. Inside a container that is a path
|
|
252
|
+
*inside* the container, not on the user's machine.
|
|
253
|
+
- Only images. A PDF, DOCX or ZIP cannot be attached — give the user a link to
|
|
254
|
+
the file instead. Do not promise an attachment you cannot send.
|
|
255
|
+
- The confirmation prompt and the audit entry name the images that go out, so
|
|
256
|
+
what the user approves is what is sent.
|
|
257
|
+
|
|
258
|
+
For a table, the same applies as for any other non-image: put it in a file and
|
|
259
|
+
link it.
|
|
260
|
+
|
|
243
261
|
## Channel addressing
|
|
244
262
|
|
|
245
263
|
Channels are addressed as `Team/Channel` or via separate `team` and `channel`
|
|
@@ -365,13 +383,21 @@ teams_watch:
|
|
|
365
383
|
action: enable
|
|
366
384
|
chats: ["Anna*", "Vertrieb*"] # where to listen
|
|
367
385
|
from: ["anna.schmidt@contoso.com"] # who to listen to
|
|
368
|
-
mentionOnly:
|
|
386
|
+
mentionOnly:
|
|
387
|
+
default: true
|
|
388
|
+
people: {"anna.schmidt@contoso.com": false}
|
|
369
389
|
intervalSeconds: 60
|
|
370
390
|
```
|
|
371
391
|
|
|
372
392
|
- `chats` decides **where** pi listens (topic, label, chat ID, participants),
|
|
373
393
|
`from` decides **to whom** (name, UPN, e-mail). Narrow both: a watcher that
|
|
374
394
|
watches everything answers everything.
|
|
395
|
+
- `mentionOnly` decides whether a message has to address the user at all: a
|
|
396
|
+
boolean for everywhere, or `{ default, chats, people }` for overrides, matched
|
|
397
|
+
first-pattern-wins. A message in a **1:1 chat** counts as an address, so the
|
|
398
|
+
overrides are about group and meeting chats — the usual shape is a global
|
|
399
|
+
`true` with one person set to `false`, so that person never has to mention the
|
|
400
|
+
user.
|
|
375
401
|
- `/teams-listen on|off|status` switches it in the session; `teams_watch
|
|
376
402
|
action: status` reports what is configured and what is actually running.
|
|
377
403
|
- pi answers what is still **unread**: a chat wakes it when it has moved since
|
package/src/config/index.ts
CHANGED
|
@@ -123,14 +123,62 @@ export interface WatchConfig {
|
|
|
123
123
|
* listens, `from` decides *to whom*.
|
|
124
124
|
*/
|
|
125
125
|
from?: string[];
|
|
126
|
-
/**
|
|
127
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Whether a message has to address pi before it wakes it.
|
|
128
|
+
*
|
|
129
|
+
* A plain `true`/`false` is the all-or-nothing form and needs no further
|
|
130
|
+
* explanation; the object form adds per-scope overrides. See
|
|
131
|
+
* `MentionOnlyConfig`.
|
|
132
|
+
*/
|
|
133
|
+
mentionOnly?: boolean | MentionOnlyConfig;
|
|
128
134
|
/** Seconds to stay quiet in a chat after waking pi for it (default: 300) */
|
|
129
135
|
cooldownSeconds?: number;
|
|
130
136
|
/** Hard cap on wakes per hour, across all chats (default: 10) */
|
|
131
137
|
maxTriggersPerHour?: number;
|
|
132
138
|
}
|
|
133
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Where a mention is required, and where it is not.
|
|
142
|
+
*
|
|
143
|
+
* `mentionOnly: true` on its own is all-or-nothing: in a group or a meeting
|
|
144
|
+
* chat nobody has to say the user's name, and everyone must. This is the same
|
|
145
|
+
* switch with per-scope overrides, and a rule may turn the requirement **off**
|
|
146
|
+
* as well as on.
|
|
147
|
+
*
|
|
148
|
+
* One-to-one chats need no override — a direct message is an address — so a
|
|
149
|
+
* person rule is about that person in the chats that have other people in them.
|
|
150
|
+
* `mentionRequired` holds the exact order the rules are applied in.
|
|
151
|
+
*
|
|
152
|
+
* Patterns are matched like every other scope rule: chats against topic,
|
|
153
|
+
* label, chat ID and participant names; people against display name, UPN,
|
|
154
|
+
* e-mail and id. The **first** matching entry wins, so write the specific rule
|
|
155
|
+
* above the general one.
|
|
156
|
+
*/
|
|
157
|
+
export interface MentionOnlyConfig {
|
|
158
|
+
/** Baseline when no override matches (default: false) */
|
|
159
|
+
default?: boolean;
|
|
160
|
+
/** `{ "pattern": true|false }` — whether a mention is required in that chat */
|
|
161
|
+
chats?: Record<string, boolean>;
|
|
162
|
+
/** `{ "pattern": true|false }` — whether that person has to mention pi */
|
|
163
|
+
people?: Record<string, boolean>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** One resolved mention-only rule. Order is preserved: first match wins. */
|
|
167
|
+
export interface MentionOnlyRule {
|
|
168
|
+
pattern: string;
|
|
169
|
+
value: boolean;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The mention-only switch after resolution. */
|
|
173
|
+
export interface ResolvedMentionOnly {
|
|
174
|
+
/** Used when neither a chat nor a person rule matches */
|
|
175
|
+
default: boolean;
|
|
176
|
+
/** Per-chat overrides, in config order */
|
|
177
|
+
chats: MentionOnlyRule[];
|
|
178
|
+
/** Per-person overrides, in config order */
|
|
179
|
+
people: MentionOnlyRule[];
|
|
180
|
+
}
|
|
181
|
+
|
|
134
182
|
/** A watch config after every default and clamp has been applied. */
|
|
135
183
|
export interface ResolvedWatchConfig {
|
|
136
184
|
enabled: boolean;
|
|
@@ -138,7 +186,7 @@ export interface ResolvedWatchConfig {
|
|
|
138
186
|
intervalSeconds: number;
|
|
139
187
|
chats: string[];
|
|
140
188
|
from: string[];
|
|
141
|
-
mentionOnly:
|
|
189
|
+
mentionOnly: ResolvedMentionOnly;
|
|
142
190
|
cooldownSeconds: number;
|
|
143
191
|
maxTriggersPerHour: number;
|
|
144
192
|
}
|
|
@@ -333,7 +381,7 @@ export const WATCH_DEFAULTS: ResolvedWatchConfig = {
|
|
|
333
381
|
intervalSeconds: 60,
|
|
334
382
|
chats: [],
|
|
335
383
|
from: [],
|
|
336
|
-
mentionOnly: false,
|
|
384
|
+
mentionOnly: { default: false, chats: [], people: [] },
|
|
337
385
|
cooldownSeconds: 300,
|
|
338
386
|
maxTriggersPerHour: 10,
|
|
339
387
|
};
|
|
@@ -747,7 +795,7 @@ export function resolveWatchConfig(
|
|
|
747
795
|
intervalSeconds: clamp(pick("intervalSeconds"), WATCH_BOUNDS.intervalSeconds, WATCH_DEFAULTS.intervalSeconds),
|
|
748
796
|
chats: normalizePatterns(pick("chats")),
|
|
749
797
|
from: normalizePatterns(pick("from")),
|
|
750
|
-
mentionOnly: pick("mentionOnly")
|
|
798
|
+
mentionOnly: resolveMentionOnly(pick("mentionOnly")),
|
|
751
799
|
cooldownSeconds: clamp(pick("cooldownSeconds"), WATCH_BOUNDS.cooldownSeconds, WATCH_DEFAULTS.cooldownSeconds),
|
|
752
800
|
maxTriggersPerHour: clamp(
|
|
753
801
|
pick("maxTriggersPerHour"),
|
|
@@ -757,6 +805,51 @@ export function resolveWatchConfig(
|
|
|
757
805
|
};
|
|
758
806
|
}
|
|
759
807
|
|
|
808
|
+
/**
|
|
809
|
+
* Normalise the mention-only switch.
|
|
810
|
+
*
|
|
811
|
+
* Exported for tests. A plain boolean keeps working — `true` is
|
|
812
|
+
* `{ default: true }` with no overrides — so an existing config file needs no
|
|
813
|
+
* change, and the older key still reads the way it always did.
|
|
814
|
+
*/
|
|
815
|
+
export function resolveMentionOnly(
|
|
816
|
+
value: boolean | MentionOnlyConfig | undefined,
|
|
817
|
+
): ResolvedMentionOnly {
|
|
818
|
+
if (typeof value === "boolean") {
|
|
819
|
+
return { default: value, chats: [], people: [] };
|
|
820
|
+
}
|
|
821
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
822
|
+
return { default: false, chats: [], people: [] };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return {
|
|
826
|
+
default: typeof value.default === "boolean" ? value.default : false,
|
|
827
|
+
chats: normalizeMentionRules(value.chats),
|
|
828
|
+
people: normalizeMentionRules(value.people),
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Keep only `pattern → boolean` pairs.
|
|
834
|
+
*
|
|
835
|
+
* A hand-edited config file can carry anything, and a rule that is neither a
|
|
836
|
+
* usable pattern nor a boolean is not worth failing the whole config over: it
|
|
837
|
+
* is dropped, and the default decides instead.
|
|
838
|
+
*/
|
|
839
|
+
function normalizeMentionRules(
|
|
840
|
+
rules: Record<string, boolean> | undefined,
|
|
841
|
+
): MentionOnlyRule[] {
|
|
842
|
+
if (!rules || typeof rules !== "object" || Array.isArray(rules)) return [];
|
|
843
|
+
|
|
844
|
+
const normalized: MentionOnlyRule[] = [];
|
|
845
|
+
for (const [pattern, value] of Object.entries(rules)) {
|
|
846
|
+
const trimmed = typeof pattern === "string" ? pattern.trim() : "";
|
|
847
|
+
if (!trimmed || typeof value !== "boolean") continue;
|
|
848
|
+
normalized.push({ pattern: trimmed, value });
|
|
849
|
+
}
|
|
850
|
+
return normalized;
|
|
851
|
+
}
|
|
852
|
+
|
|
760
853
|
/**
|
|
761
854
|
* Effective AI-footer settings: account > global > default.
|
|
762
855
|
*
|
package/src/extension/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
ensureConfigTemplate,
|
|
14
14
|
getConfigPath,
|
|
15
15
|
setWatchConfig,
|
|
16
|
+
type ResolvedMentionOnly,
|
|
16
17
|
tryResolveConnection,
|
|
17
18
|
type TeamsConnection,
|
|
18
19
|
} from "../config/index.ts";
|
|
@@ -86,6 +87,19 @@ import {
|
|
|
86
87
|
|
|
87
88
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
88
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The mention-only switch in one phrase, for the status line.
|
|
92
|
+
*
|
|
93
|
+
* The override count is part of it on purpose: "mentions only" and "mentions
|
|
94
|
+
* only, with two exemptions" behave differently enough that the difference
|
|
95
|
+
* should not be invisible in the place people check the setting.
|
|
96
|
+
*/
|
|
97
|
+
function mentionSummary(mentionOnly: ResolvedMentionOnly): string {
|
|
98
|
+
const overrides = mentionOnly.chats.length + mentionOnly.people.length;
|
|
99
|
+
const base = mentionOnly.default ? "mentions only" : "every message";
|
|
100
|
+
return overrides > 0 ? `${base} (${overrides} override${overrides === 1 ? "" : "s"})` : base;
|
|
101
|
+
}
|
|
102
|
+
|
|
89
103
|
/** Every tool, in the order they appear in the documentation. */
|
|
90
104
|
const tools = [
|
|
91
105
|
// Setup & session
|
|
@@ -360,7 +374,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
360
374
|
`every ${conn.watch.intervalSeconds} s`,
|
|
361
375
|
conn.watch.chats.length > 0 ? `chats: ${conn.watch.chats.join(", ")}` : "all recent chats",
|
|
362
376
|
conn.watch.from.length > 0 ? `people: ${conn.watch.from.join(", ")}` : "any sender",
|
|
363
|
-
conn.watch.mentionOnly
|
|
377
|
+
mentionSummary(conn.watch.mentionOnly),
|
|
364
378
|
runtime ? `${runtime.wakesThisHour} wake(s) this hour` : "not running in this session",
|
|
365
379
|
].join(" · "),
|
|
366
380
|
"info",
|
package/src/graph/messages.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { ChannelSummary, MessageLocation, MessageSummary, PersonRef } from
|
|
|
12
12
|
import { graphGetOptional, graphList, graphPatch, graphPost } from "./client.ts";
|
|
13
13
|
import { mapMessage } from "./mappers.ts";
|
|
14
14
|
import { markdownToTeamsHtml } from "../utils/richtext.ts";
|
|
15
|
+
import type { HostedImage } from "../utils/hosted-content.ts";
|
|
15
16
|
|
|
16
17
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
17
18
|
type Raw = Record<string, any>;
|
|
@@ -37,6 +38,13 @@ export interface MessageInput {
|
|
|
37
38
|
importance?: string;
|
|
38
39
|
/** People to @-mention; each must appear in the body as @DisplayName */
|
|
39
40
|
mentions?: PersonRef[];
|
|
41
|
+
/**
|
|
42
|
+
* Inline images, already read from disk and base64-encoded.
|
|
43
|
+
*
|
|
44
|
+
* They travel in the same POST as the body — Teams keeps the bytes inside the
|
|
45
|
+
* message — so there is no second request and no file upload anywhere.
|
|
46
|
+
*/
|
|
47
|
+
images?: HostedImage[];
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
/**
|
|
@@ -77,12 +85,29 @@ export function buildMessageBody(input: MessageInput): Record<string, unknown> {
|
|
|
77
85
|
});
|
|
78
86
|
});
|
|
79
87
|
|
|
88
|
+
// The pictures follow the text rather than being placed inside it: the caller
|
|
89
|
+
// writes the message, and a model that also had to pick a position in the HTML
|
|
90
|
+
// would be choosing something nobody asked it to choose. The id is ours to
|
|
91
|
+
// pick — it only has to be the same in the body and in hostedContents, which is
|
|
92
|
+
// the part that quietly goes wrong.
|
|
93
|
+
const hostedContents: Record<string, unknown>[] = [];
|
|
94
|
+
(input.images ?? []).forEach((image, index) => {
|
|
95
|
+
const id = String(index + 1);
|
|
96
|
+
content += `<br><img src="../hostedContents/${id}/$value" alt="${escapeHtml(image.name)}">`;
|
|
97
|
+
hostedContents.push({
|
|
98
|
+
"@microsoft.graph.temporaryId": id,
|
|
99
|
+
contentBytes: image.contentBytes,
|
|
100
|
+
contentType: image.contentType,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
80
104
|
const body: Record<string, unknown> = {
|
|
81
105
|
body: { contentType: "html", content },
|
|
82
106
|
};
|
|
83
107
|
if (input.subject) body.subject = input.subject;
|
|
84
108
|
if (input.importance) body.importance = input.importance;
|
|
85
109
|
if (mentionEntries.length > 0) body.mentions = mentionEntries;
|
|
110
|
+
if (hostedContents.length > 0) body.hostedContents = hostedContents;
|
|
86
111
|
|
|
87
112
|
return body;
|
|
88
113
|
}
|
package/src/safety/index.ts
CHANGED
|
@@ -119,17 +119,29 @@ export function formatMutationSummary(
|
|
|
119
119
|
return str.length > max ? `${str.slice(0, max - 1)}…` : str;
|
|
120
120
|
};
|
|
121
121
|
const disclosed = (value: unknown): string => applyAiFooter(text(value, 400), footer);
|
|
122
|
+
/**
|
|
123
|
+
* The images that travel with the message, by file name.
|
|
124
|
+
*
|
|
125
|
+
* Someone approving a send has to see that a picture is part of it: a
|
|
126
|
+
* confirmation that shows only the text would be asking about half of it.
|
|
127
|
+
*/
|
|
128
|
+
const withImages = (value: unknown): string => {
|
|
129
|
+
const paths = Array.isArray(value) ? value.filter((p): p is string => typeof p === "string") : [];
|
|
130
|
+
if (paths.length === 0) return "";
|
|
131
|
+
const names = paths.map((p) => p.split(/[\\/]/).pop() ?? p).join(", ");
|
|
132
|
+
return `\n\n(with ${paths.length === 1 ? "1 image" : `${paths.length} images`}: ${text(names, 120)})`;
|
|
133
|
+
};
|
|
122
134
|
|
|
123
135
|
switch (toolName) {
|
|
124
136
|
case "teams_send_chat_message":
|
|
125
|
-
return `Send a chat message to "${text(params.chat, 60)}":\n\n${disclosed(params.body)}`;
|
|
137
|
+
return `Send a chat message to "${text(params.chat, 60)}":\n\n${disclosed(params.body)}${withImages(params.images)}`;
|
|
126
138
|
case "teams_send_channel_message": {
|
|
127
139
|
const where = params.team ? `${text(params.team, 40)}/${text(params.channel, 40)}` : text(params.channel, 60);
|
|
128
|
-
return `Post in channel ${where}:\n\n${disclosed(params.body)}`;
|
|
140
|
+
return `Post in channel ${where}:\n\n${disclosed(params.body)}${withImages(params.images)}`;
|
|
129
141
|
}
|
|
130
142
|
case "teams_reply_channel_message": {
|
|
131
143
|
const where = params.team ? `${text(params.team, 40)}/${text(params.channel, 40)}` : text(params.channel, 60);
|
|
132
|
-
return `Reply in thread ${text(params.messageId, 40)} (${where}):\n\n${disclosed(params.body)}`;
|
|
144
|
+
return `Reply in thread ${text(params.messageId, 40)} (${where}):\n\n${disclosed(params.body)}${withImages(params.images)}`;
|
|
133
145
|
}
|
|
134
146
|
case "teams_create_chat": {
|
|
135
147
|
const people = Array.isArray(params.participants) ? params.participants.join(", ") : text(params.participants);
|
|
@@ -12,6 +12,7 @@ import { resolveUserId } from "../graph/me.ts";
|
|
|
12
12
|
import { auditWrite } from "../safety/audit.ts";
|
|
13
13
|
import { CHAT_MESSAGE_BODY_DESCRIPTION, CHAT_SHAPE_GUIDELINE } from "../utils/chat-style.ts";
|
|
14
14
|
import { applyAiFooter } from "../utils/disclosure.ts";
|
|
15
|
+
import { imageNote, readHostedImage } from "../utils/hosted-content.ts";
|
|
15
16
|
import { assertAccess } from "../safety/index.ts";
|
|
16
17
|
import { truncate } from "../utils/formatting.ts";
|
|
17
18
|
import { requireChannel } from "./resolve.ts";
|
|
@@ -67,6 +68,13 @@ const sharedParams = {
|
|
|
67
68
|
),
|
|
68
69
|
importance: Type.Optional(Type.String({ description: "'normal' (default), 'high' or 'urgent'" })),
|
|
69
70
|
html: Type.Optional(Type.Boolean({ description: "Send 'body' as raw HTML instead of converting markdown" })),
|
|
71
|
+
images: Type.Optional(
|
|
72
|
+
Type.Array(Type.String(), {
|
|
73
|
+
description:
|
|
74
|
+
"Local paths of images to attach (png, jpg, gif, webp, bmp; 4 MB each at most). " +
|
|
75
|
+
"They are shown inline, under the text.",
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
70
78
|
};
|
|
71
79
|
|
|
72
80
|
export const teamsSendChannelMessageTool = {
|
|
@@ -74,7 +82,8 @@ export const teamsSendChannelMessageTool = {
|
|
|
74
82
|
description:
|
|
75
83
|
"Post a new message in a Microsoft Teams channel as the signed-in user. It appears exactly as if the " +
|
|
76
84
|
"user posted it. Give the channel as a 'Team/Channel' path or pass 'team' separately. " +
|
|
77
|
-
"'subject' starts a titled post; use teams_reply_channel_message to answer inside an existing thread."
|
|
85
|
+
"'subject' starts a titled post; use teams_reply_channel_message to answer inside an existing thread. " +
|
|
86
|
+
"'images' attaches local image files to the post itself; pi cannot attach other kinds of files.",
|
|
78
87
|
parameters: Type.Object({
|
|
79
88
|
...sharedParams,
|
|
80
89
|
subject: Type.Optional(Type.String({ description: "Optional post title" })),
|
|
@@ -99,6 +108,7 @@ export const teamsSendChannelMessageTool = {
|
|
|
99
108
|
mentions?: string[];
|
|
100
109
|
importance?: string;
|
|
101
110
|
html?: boolean;
|
|
111
|
+
images?: string[];
|
|
102
112
|
},
|
|
103
113
|
signal: AbortSignal | undefined,
|
|
104
114
|
_onUpdate: undefined,
|
|
@@ -106,7 +116,15 @@ export const teamsSendChannelMessageTool = {
|
|
|
106
116
|
): Promise<ToolResult> {
|
|
107
117
|
return run(async () => {
|
|
108
118
|
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
109
|
-
|
|
119
|
+
|
|
120
|
+
// Read before anything is posted: an image that cannot be read has to stop
|
|
121
|
+
// the post, not arrive as text promising one.
|
|
122
|
+
const images = [];
|
|
123
|
+
for (const path of params.images ?? []) images.push(await readHostedImage(path));
|
|
124
|
+
|
|
125
|
+
if (!params.body.trim() && images.length === 0) {
|
|
126
|
+
return errorResult("Refusing to post an empty message.");
|
|
127
|
+
}
|
|
110
128
|
|
|
111
129
|
const { team, channel } = await requireChannel(conn, params.team, params.channel, "write", signal);
|
|
112
130
|
const { mentions, error } = await resolveMentions(conn, params.mentions, signal);
|
|
@@ -128,6 +146,7 @@ export const teamsSendChannelMessageTool = {
|
|
|
128
146
|
subject: params.subject,
|
|
129
147
|
importance: params.importance,
|
|
130
148
|
mentions,
|
|
149
|
+
images,
|
|
131
150
|
},
|
|
132
151
|
signal,
|
|
133
152
|
);
|
|
@@ -138,7 +157,7 @@ export const teamsSendChannelMessageTool = {
|
|
|
138
157
|
tenant: conn.tenant,
|
|
139
158
|
actor: me?.upn,
|
|
140
159
|
target: `channel:${target}`,
|
|
141
|
-
summary: truncate(body, 200),
|
|
160
|
+
summary: truncate(body, 200) + imageNote(images),
|
|
142
161
|
});
|
|
143
162
|
|
|
144
163
|
return textResult(
|
|
@@ -158,7 +177,7 @@ export const teamsSendChannelMessageTool = {
|
|
|
158
177
|
tenant: conn.tenant,
|
|
159
178
|
actor: me?.upn,
|
|
160
179
|
target: `channel:${target}`,
|
|
161
|
-
summary: truncate(body, 200),
|
|
180
|
+
summary: truncate(body, 200) + imageNote(images),
|
|
162
181
|
error: err instanceof Error ? err.message : String(err),
|
|
163
182
|
});
|
|
164
183
|
throw err;
|
|
@@ -172,7 +191,8 @@ export const teamsReplyChannelMessageTool = {
|
|
|
172
191
|
description:
|
|
173
192
|
"Reply inside an existing Microsoft Teams channel thread as the signed-in user. " +
|
|
174
193
|
"Get the messageId from teams_read_channel, teams_read_thread or teams_search_messages. " +
|
|
175
|
-
"Replying keeps the conversation in one thread instead of starting a new post."
|
|
194
|
+
"Replying keeps the conversation in one thread instead of starting a new post. " +
|
|
195
|
+
"'images' attaches local image files to the reply itself.",
|
|
176
196
|
parameters: Type.Object({
|
|
177
197
|
...sharedParams,
|
|
178
198
|
messageId: Type.String({ description: "ID of the thread's opening message" }),
|
|
@@ -192,6 +212,7 @@ export const teamsReplyChannelMessageTool = {
|
|
|
192
212
|
mentions?: string[];
|
|
193
213
|
importance?: string;
|
|
194
214
|
html?: boolean;
|
|
215
|
+
images?: string[];
|
|
195
216
|
},
|
|
196
217
|
signal: AbortSignal | undefined,
|
|
197
218
|
_onUpdate: undefined,
|
|
@@ -199,7 +220,15 @@ export const teamsReplyChannelMessageTool = {
|
|
|
199
220
|
): Promise<ToolResult> {
|
|
200
221
|
return run(async () => {
|
|
201
222
|
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
202
|
-
|
|
223
|
+
|
|
224
|
+
// Read before anything is posted: an image that cannot be read has to stop
|
|
225
|
+
// the reply, not arrive as text promising one.
|
|
226
|
+
const images = [];
|
|
227
|
+
for (const path of params.images ?? []) images.push(await readHostedImage(path));
|
|
228
|
+
|
|
229
|
+
if (!params.body.trim() && images.length === 0) {
|
|
230
|
+
return errorResult("Refusing to post an empty reply.");
|
|
231
|
+
}
|
|
203
232
|
|
|
204
233
|
const { team, channel } = await requireChannel(conn, params.team, params.channel, "write", signal);
|
|
205
234
|
const { mentions, error } = await resolveMentions(conn, params.mentions, signal);
|
|
@@ -221,6 +250,7 @@ export const teamsReplyChannelMessageTool = {
|
|
|
221
250
|
html: params.html,
|
|
222
251
|
importance: params.importance,
|
|
223
252
|
mentions,
|
|
253
|
+
images,
|
|
224
254
|
},
|
|
225
255
|
signal,
|
|
226
256
|
);
|
|
@@ -231,7 +261,7 @@ export const teamsReplyChannelMessageTool = {
|
|
|
231
261
|
tenant: conn.tenant,
|
|
232
262
|
actor: me?.upn,
|
|
233
263
|
target: `channel:${target}#${params.messageId}`,
|
|
234
|
-
summary: truncate(body, 200),
|
|
264
|
+
summary: truncate(body, 200) + imageNote(images),
|
|
235
265
|
});
|
|
236
266
|
|
|
237
267
|
return textResult(
|
|
@@ -251,7 +281,7 @@ export const teamsReplyChannelMessageTool = {
|
|
|
251
281
|
tenant: conn.tenant,
|
|
252
282
|
actor: me?.upn,
|
|
253
283
|
target: `channel:${target}#${params.messageId}`,
|
|
254
|
-
summary: truncate(body, 200),
|
|
284
|
+
summary: truncate(body, 200) + imageNote(images),
|
|
255
285
|
error: err instanceof Error ? err.message : String(err),
|
|
256
286
|
});
|
|
257
287
|
throw err;
|
|
@@ -23,6 +23,7 @@ import { auditWrite } from "../safety/audit.ts";
|
|
|
23
23
|
import { assertAccess } from "../safety/index.ts";
|
|
24
24
|
import { CHAT_MESSAGE_BODY_DESCRIPTION, CHAT_SHAPE_GUIDELINE } from "../utils/chat-style.ts";
|
|
25
25
|
import { applyAiFooter } from "../utils/disclosure.ts";
|
|
26
|
+
import { imageNote, readHostedImage } from "../utils/hosted-content.ts";
|
|
26
27
|
import { truncate } from "../utils/formatting.ts";
|
|
27
28
|
import { requireChat } from "./resolve.ts";
|
|
28
29
|
import {
|
|
@@ -46,6 +47,7 @@ interface SendChatParams {
|
|
|
46
47
|
mentions?: string[];
|
|
47
48
|
importance?: string;
|
|
48
49
|
html?: boolean;
|
|
50
|
+
images?: string[];
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
export const teamsSendChatMessageTool = {
|
|
@@ -54,7 +56,8 @@ export const teamsSendChatMessageTool = {
|
|
|
54
56
|
"Send a message in a Microsoft Teams chat as the signed-in user. The message appears exactly as if the " +
|
|
55
57
|
"user typed it. Accepts a chat ID, group chat topic, or participant name/e-mail. " +
|
|
56
58
|
"Use 'mentions' with names or e-mail addresses to @-mention people — write '@Display Name' in the body " +
|
|
57
|
-
"where the mention should appear."
|
|
59
|
+
"where the mention should appear. " +
|
|
60
|
+
"'images' attaches local image files to the message itself; pi cannot attach other kinds of files.",
|
|
58
61
|
parameters: Type.Object({
|
|
59
62
|
chat: Type.String({ description: "Chat ID, group chat topic, or a participant's name/e-mail" }),
|
|
60
63
|
body: Type.String({ description: CHAT_MESSAGE_BODY_DESCRIPTION }),
|
|
@@ -71,6 +74,13 @@ export const teamsSendChatMessageTool = {
|
|
|
71
74
|
html: Type.Optional(
|
|
72
75
|
Type.Boolean({ description: "Send 'body' as raw HTML instead of converting markdown" }),
|
|
73
76
|
),
|
|
77
|
+
images: Type.Optional(
|
|
78
|
+
Type.Array(Type.String(), {
|
|
79
|
+
description:
|
|
80
|
+
"Local paths of images to attach to the message (png, jpg, gif, webp, bmp; 4 MB each at most). " +
|
|
81
|
+
"They are shown inline, under the text.",
|
|
82
|
+
}),
|
|
83
|
+
),
|
|
74
84
|
}),
|
|
75
85
|
promptSnippet: "Send a message in a Teams chat as the user",
|
|
76
86
|
promptGuidelines: [
|
|
@@ -91,7 +101,14 @@ export const teamsSendChatMessageTool = {
|
|
|
91
101
|
return run(async () => {
|
|
92
102
|
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
93
103
|
|
|
94
|
-
|
|
104
|
+
// Read before anything is sent: a picture that cannot be read has to stop
|
|
105
|
+
// the message, not arrive as text promising one.
|
|
106
|
+
const images = [];
|
|
107
|
+
for (const path of params.images ?? []) images.push(await readHostedImage(path));
|
|
108
|
+
|
|
109
|
+
if (!params.body.trim() && images.length === 0) {
|
|
110
|
+
return errorResult("Refusing to send an empty message.");
|
|
111
|
+
}
|
|
95
112
|
|
|
96
113
|
const chat = await requireChat(conn, params.chat, "write", signal);
|
|
97
114
|
|
|
@@ -129,6 +146,7 @@ export const teamsSendChatMessageTool = {
|
|
|
129
146
|
html: params.html,
|
|
130
147
|
importance: params.importance,
|
|
131
148
|
mentions,
|
|
149
|
+
images,
|
|
132
150
|
},
|
|
133
151
|
signal,
|
|
134
152
|
);
|
|
@@ -139,7 +157,7 @@ export const teamsSendChatMessageTool = {
|
|
|
139
157
|
tenant: conn.tenant,
|
|
140
158
|
actor: me?.upn,
|
|
141
159
|
target: `chat:${chat.label}`,
|
|
142
|
-
summary: truncate(body, 200),
|
|
160
|
+
summary: truncate(body, 200) + imageNote(images),
|
|
143
161
|
});
|
|
144
162
|
|
|
145
163
|
// Replying is reading: clear the unread marker the answer was written for.
|
package/src/tools/teams-watch.ts
CHANGED
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
getConfigPath,
|
|
13
13
|
resolveConnection,
|
|
14
14
|
setWatchConfig,
|
|
15
|
+
type MentionOnlyConfig,
|
|
16
|
+
type ResolvedMentionOnly,
|
|
15
17
|
type TeamsConnection,
|
|
16
18
|
type WatchConfig,
|
|
17
19
|
} from "../config/index.ts";
|
|
@@ -26,7 +28,7 @@ interface WatchParams {
|
|
|
26
28
|
intervalSeconds?: number;
|
|
27
29
|
chats?: string[];
|
|
28
30
|
from?: string[];
|
|
29
|
-
mentionOnly?: boolean;
|
|
31
|
+
mentionOnly?: boolean | MentionOnlyConfig;
|
|
30
32
|
cooldownSeconds?: number;
|
|
31
33
|
maxTriggersPerHour?: number;
|
|
32
34
|
}
|
|
@@ -50,6 +52,16 @@ const CURSOR_NOTES = [
|
|
|
50
52
|
+ "never dropped: it stays open and is answered as soon as the wait is over.",
|
|
51
53
|
].join("\n");
|
|
52
54
|
|
|
55
|
+
/** Renders the mention-only switch, which is a default plus optional overrides. */
|
|
56
|
+
function describeMentionOnly(mentionOnly: ResolvedMentionOnly): string {
|
|
57
|
+
const rules = [
|
|
58
|
+
...mentionOnly.chats.map((rule) => `chat \`${rule.pattern}\` → ${rule.value}`),
|
|
59
|
+
...mentionOnly.people.map((rule) => `person \`${rule.pattern}\` → ${rule.value}`),
|
|
60
|
+
];
|
|
61
|
+
const overrides = rules.length > 0 ? ` (${rules.join(", ")})` : "";
|
|
62
|
+
return `- mentions only: ${mentionOnly.default}${overrides}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
53
65
|
/** Renders the effective settings the same way the config file would. */
|
|
54
66
|
function describe(watch: {
|
|
55
67
|
enabled: boolean;
|
|
@@ -57,7 +69,7 @@ function describe(watch: {
|
|
|
57
69
|
intervalSeconds: number;
|
|
58
70
|
chats: string[];
|
|
59
71
|
from: string[];
|
|
60
|
-
mentionOnly:
|
|
72
|
+
mentionOnly: ResolvedMentionOnly;
|
|
61
73
|
cooldownSeconds: number;
|
|
62
74
|
maxTriggersPerHour: number;
|
|
63
75
|
}): string {
|
|
@@ -67,7 +79,7 @@ function describe(watch: {
|
|
|
67
79
|
`- poll every: ${watch.intervalSeconds} s`,
|
|
68
80
|
`- chats: ${watch.chats.length > 0 ? watch.chats.map((c) => `\`${c}\``).join(", ") : "every chat with recent activity"}`,
|
|
69
81
|
`- people: ${watch.from.length > 0 ? watch.from.map((p) => `\`${p}\``).join(", ") : "any sender"}`,
|
|
70
|
-
|
|
82
|
+
describeMentionOnly(watch.mentionOnly),
|
|
71
83
|
`- cooldown per chat: ${watch.cooldownSeconds} s`,
|
|
72
84
|
`- wake limit: ${watch.maxTriggersPerHour} per hour`,
|
|
73
85
|
].join("\n");
|
|
@@ -159,7 +171,23 @@ export const teamsWatchTool = {
|
|
|
159
171
|
}),
|
|
160
172
|
),
|
|
161
173
|
mentionOnly: Type.Optional(
|
|
162
|
-
Type.
|
|
174
|
+
Type.Union(
|
|
175
|
+
[
|
|
176
|
+
Type.Boolean(),
|
|
177
|
+
Type.Object({
|
|
178
|
+
default: Type.Optional(Type.Boolean()),
|
|
179
|
+
chats: Type.Optional(Type.Record(Type.String(), Type.Boolean())),
|
|
180
|
+
people: Type.Optional(Type.Record(Type.String(), Type.Boolean())),
|
|
181
|
+
}),
|
|
182
|
+
],
|
|
183
|
+
{
|
|
184
|
+
description:
|
|
185
|
+
"Whether a message must address you before it wakes pi. `true`/`false` applies everywhere; "
|
|
186
|
+
+ "`{ default, chats, people }` adds per-chat and per-person overrides, and a rule may be false "
|
|
187
|
+
+ "to exempt someone who then does not have to mention you. 1:1 chats always count as addressed. "
|
|
188
|
+
+ "First matching pattern wins.",
|
|
189
|
+
},
|
|
190
|
+
),
|
|
163
191
|
),
|
|
164
192
|
cooldownSeconds: Type.Optional(
|
|
165
193
|
Type.Number({ description: "Seconds to stay quiet in a chat after waking pi for it (default 300)" }),
|
|
@@ -174,6 +202,7 @@ export const teamsWatchTool = {
|
|
|
174
202
|
"Tell the user plainly that listen mode spends a model turn per incoming message, and that pi answers in their name.",
|
|
175
203
|
"Enabling listen mode takes effect immediately; the settings survive a restart.",
|
|
176
204
|
"Enabling listen mode answers what arrived since the last run, so say how much is waiting rather than implying only new messages count.",
|
|
205
|
+
"`mentionOnly` takes a boolean or `{ default, chats, people }`. A 1:1 chat already counts as a direct address, so the overrides are for group and meeting chats — for example a global `true` with one person set to `false`, so that person never has to mention you.",
|
|
177
206
|
],
|
|
178
207
|
|
|
179
208
|
async execute(
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attaching a local image to a message.
|
|
3
|
+
*
|
|
4
|
+
* Teams keeps an inline image inside the message itself as a `hostedContent`:
|
|
5
|
+
* the body points at `../hostedContents/{id}/$value` and the bytes travel in the
|
|
6
|
+
* same POST. That is what makes this cheap — nothing has to be uploaded to
|
|
7
|
+
* SharePoint first, which is what a real file attachment needs, and which would
|
|
8
|
+
* mean asking for a write scope on the user's drive.
|
|
9
|
+
*
|
|
10
|
+
* Graph caps a hosted content at 4 MB, and an image of a type Teams does not
|
|
11
|
+
* render would reach the reader as a broken picture, so both are checked here,
|
|
12
|
+
* before the message that carries it is built.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile, stat } from "node:fs/promises";
|
|
16
|
+
import { basename, extname } from "node:path";
|
|
17
|
+
|
|
18
|
+
/** Graph rejects a hosted content larger than this. */
|
|
19
|
+
export const MAX_HOSTED_CONTENT_BYTES = 4 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The image types Teams shows inline, by file extension.
|
|
23
|
+
*
|
|
24
|
+
* A list rather than "anything starting with image/": a type Teams does not
|
|
25
|
+
* render arrives as a broken image, and refusing it up front is friendlier than
|
|
26
|
+
* sending it and having the reader find out.
|
|
27
|
+
*/
|
|
28
|
+
export const HOSTED_CONTENT_TYPES: Record<string, string> = {
|
|
29
|
+
".png": "image/png",
|
|
30
|
+
".jpg": "image/jpeg",
|
|
31
|
+
".jpeg": "image/jpeg",
|
|
32
|
+
".gif": "image/gif",
|
|
33
|
+
".webp": "image/webp",
|
|
34
|
+
".bmp": "image/bmp",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The content type for a path, or undefined when Teams does not render it inline. */
|
|
38
|
+
export function hostedContentType(path: string): string | undefined {
|
|
39
|
+
return HOSTED_CONTENT_TYPES[extname(path).toLowerCase()];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One image, ready to send.
|
|
44
|
+
*
|
|
45
|
+
* `contentBytes` is base64 because that is what Graph asks for: the bytes are
|
|
46
|
+
* JSON string data, not a multipart upload.
|
|
47
|
+
*/
|
|
48
|
+
export interface HostedImage {
|
|
49
|
+
/** The file name, as the attachment is described in audit entries */
|
|
50
|
+
name: string;
|
|
51
|
+
contentType: string;
|
|
52
|
+
contentBytes: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A byte count as a human reads it — the limits here are in megabytes. */
|
|
56
|
+
export function formatBytes(bytes: number): string {
|
|
57
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read one local image for sending.
|
|
62
|
+
*
|
|
63
|
+
* Throws with the reason instead of sending something partial: a message that
|
|
64
|
+
* quietly goes out without the picture the user asked for is worse than a tool
|
|
65
|
+
* call that says why it did not.
|
|
66
|
+
*/
|
|
67
|
+
export async function readHostedImage(path: string): Promise<HostedImage> {
|
|
68
|
+
const name = basename(path);
|
|
69
|
+
|
|
70
|
+
const contentType = hostedContentType(path);
|
|
71
|
+
if (!contentType) {
|
|
72
|
+
const supported = [...new Set(Object.values(HOSTED_CONTENT_TYPES))].join(", ");
|
|
73
|
+
throw new Error(`"${name}" is not an image Teams shows inline. Supported types: ${supported}.`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let info;
|
|
77
|
+
try {
|
|
78
|
+
info = await stat(path);
|
|
79
|
+
} catch {
|
|
80
|
+
throw new Error(`"${path}" could not be read. Give a path to a file that exists locally.`);
|
|
81
|
+
}
|
|
82
|
+
if (info.isDirectory()) throw new Error(`"${path}" is a directory, not an image.`);
|
|
83
|
+
if (info.size === 0) throw new Error(`"${name}" is empty.`);
|
|
84
|
+
if (info.size > MAX_HOSTED_CONTENT_BYTES) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`"${name}" is ${formatBytes(info.size)}; Teams accepts at most ` +
|
|
87
|
+
`${formatBytes(MAX_HOSTED_CONTENT_BYTES)} per image.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const bytes = await readFile(path);
|
|
92
|
+
return { name, contentType, contentBytes: bytes.toString("base64") };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The images named in one phrase, for the audit entry.
|
|
97
|
+
*
|
|
98
|
+
* What went out is the thing an audit entry has to answer, and a message with a
|
|
99
|
+
* picture in it is not described by its text alone.
|
|
100
|
+
*/
|
|
101
|
+
export function imageNote(images: readonly HostedImage[]): string {
|
|
102
|
+
if (images.length === 0) return "";
|
|
103
|
+
const label = images.length === 1 ? "image" : "images";
|
|
104
|
+
return ` [${label}: ${images.map((image) => image.name).join(", ")}]`;
|
|
105
|
+
}
|
package/src/watch/index.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import type { ChatSummary, MessageSummary, PersonRef, SignedInUser } from "../types.ts";
|
|
21
|
-
import type { ResolvedWatchConfig } from "../config/index.ts";
|
|
21
|
+
import type { MentionOnlyRule, ResolvedWatchConfig } from "../config/index.ts";
|
|
22
22
|
import { matchesPattern } from "../config/scope.ts";
|
|
23
23
|
import { chatCandidates } from "../graph/mappers.ts";
|
|
24
24
|
|
|
@@ -256,6 +256,85 @@ export function isWatchedSender(message: MessageSummary, watch: ResolvedWatchCon
|
|
|
256
256
|
return watch.from.some((pattern) => candidates.some((candidate) => matchesPattern(pattern, candidate)));
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Whether a message has to address pi at all
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Does this message have to mention the user before it may wake pi?
|
|
265
|
+
*
|
|
266
|
+
* The ladder, most specific first:
|
|
267
|
+
*
|
|
268
|
+
* 1. an explicit chat rule — a statement about this one conversation;
|
|
269
|
+
* 2. a one-to-one chat — a direct message is an address by definition;
|
|
270
|
+
* 3. an explicit person rule;
|
|
271
|
+
* 4. the configured default.
|
|
272
|
+
*
|
|
273
|
+
* Either value can be set at every step, so a global `true` with one person set
|
|
274
|
+
* to `false` means that person does not have to mention the user first. A 1:1
|
|
275
|
+
* chat sits above the person rules deliberately: it is about the conversation
|
|
276
|
+
* rather than about somebody in it, and a rule meant for group chats must not
|
|
277
|
+
* silence the direct messages that need no ceremony. To require a mention even
|
|
278
|
+
* there, name the chat.
|
|
279
|
+
*/
|
|
280
|
+
export function mentionRequired(
|
|
281
|
+
chat: ChatSummary,
|
|
282
|
+
message: MessageSummary,
|
|
283
|
+
me: SignedInUser | undefined,
|
|
284
|
+
watch: ResolvedWatchConfig,
|
|
285
|
+
): boolean {
|
|
286
|
+
const chatRule = firstMentionRule(
|
|
287
|
+
watch.mentionOnly.chats,
|
|
288
|
+
chatCandidates(chat).filter((value): value is string => !!value),
|
|
289
|
+
);
|
|
290
|
+
if (chatRule !== undefined) return chatRule;
|
|
291
|
+
|
|
292
|
+
if (isDirectChat(chat, me)) return false;
|
|
293
|
+
|
|
294
|
+
const sender = message.from;
|
|
295
|
+
const candidates = sender
|
|
296
|
+
? [sender.displayName, sender.upn, sender.mail, sender.id].filter(
|
|
297
|
+
(value): value is string => !!value,
|
|
298
|
+
)
|
|
299
|
+
: [];
|
|
300
|
+
|
|
301
|
+
return firstMentionRule(watch.mentionOnly.people, candidates) ?? watch.mentionOnly.default;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Is this conversation a one-to-one?
|
|
306
|
+
*
|
|
307
|
+
* A message in a chat with a single other person *is* a direct address: there
|
|
308
|
+
* is nobody else it could be meant for, and Teams 1:1 messages carry no
|
|
309
|
+
* @-mention. Counting them as "not addressed" would silence exactly the
|
|
310
|
+
* conversations that need no ceremony.
|
|
311
|
+
*
|
|
312
|
+
* `chatType` is authoritative when Graph sends one. It can come back as
|
|
313
|
+
* "unknown" — the mapper's fallback — and then the member list is the only
|
|
314
|
+
* evidence, so the people other than the signed-in user are counted. Whether
|
|
315
|
+
* the signed-in user appears in that list is up to Graph, which is why the
|
|
316
|
+
* comparison runs per member rather than a plain length check.
|
|
317
|
+
*/
|
|
318
|
+
export function isDirectChat(chat: ChatSummary, me: SignedInUser | undefined): boolean {
|
|
319
|
+
if (chat.chatType === "oneOnOne") return true;
|
|
320
|
+
if (chat.chatType && chat.chatType !== "unknown") return false;
|
|
321
|
+
|
|
322
|
+
return chat.members.filter((member) => !isFromMe(member, me)).length === 1;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The value of the first rule that matches any candidate, in config order. */
|
|
326
|
+
function firstMentionRule(
|
|
327
|
+
rules: readonly MentionOnlyRule[],
|
|
328
|
+
candidates: readonly string[],
|
|
329
|
+
): boolean | undefined {
|
|
330
|
+
for (const rule of rules) {
|
|
331
|
+
for (const candidate of candidates) {
|
|
332
|
+
if (matchesPattern(rule.pattern, candidate)) return rule.value;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
259
338
|
// ---------------------------------------------------------------------------
|
|
260
339
|
// What the answer has to cover
|
|
261
340
|
// ---------------------------------------------------------------------------
|
|
@@ -388,7 +467,9 @@ export function shouldWake(
|
|
|
388
467
|
|
|
389
468
|
if (!isWatchedChat(chat, watch)) return skip("the chat is outside the configured watch list");
|
|
390
469
|
if (!isWatchedSender(message, watch)) return skip("the sender is outside the configured listen list");
|
|
391
|
-
if (watch
|
|
470
|
+
if (mentionRequired(chat, message, me, watch) && !mentionsMe(message, me)) {
|
|
471
|
+
return skip("a mention is required here and the message has none");
|
|
472
|
+
}
|
|
392
473
|
if (watch.maxTriggersPerHour > 0 && wakesThisHour(state, now) >= watch.maxTriggersPerHour) {
|
|
393
474
|
return defer(
|
|
394
475
|
"the hourly wake limit is reached",
|