@patimweb/pi-teams 1.9.1 → 1.10.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 +57 -5
- package/package.json +1 -1
- package/skills/teams-collaboration/SKILL.md +9 -1
- package/src/config/index.ts +98 -5
- package/src/extension/index.ts +21 -2
- package/src/tools/teams-watch.ts +33 -4
- package/src/watch/index.ts +83 -2
package/docs/listen-mode.md
CHANGED
|
@@ -15,8 +15,11 @@ understanding before switching on.
|
|
|
15
15
|
Listen mode never starts on its own. It belongs to the session it was switched
|
|
16
16
|
on in and ends with it: quitting pi closes the watcher, and `/new` or `/resume`
|
|
17
17
|
stops it too. A stored `enabled: true` is a setting, not an instruction — set
|
|
18
|
-
`autoStart` if a session is meant to start listening by itself
|
|
19
|
-
|
|
18
|
+
`autoStart` if a session is meant to start listening by itself. Only a session
|
|
19
|
+
with a UI does: a pi started headless by another pi loads the same extensions
|
|
20
|
+
and the same configuration, and a watcher there would answer chats inside a
|
|
21
|
+
throwaway process and move the shared cursor past messages the real session
|
|
22
|
+
never sees.
|
|
20
23
|
|
|
21
24
|
## Settings
|
|
22
25
|
|
|
@@ -27,7 +30,11 @@ sensible for an unattended one.
|
|
|
27
30
|
"intervalSeconds": 60,
|
|
28
31
|
"chats": ["Anna*", "Vertrieb*"],
|
|
29
32
|
"from": ["anna.schmidt@contoso.com"],
|
|
30
|
-
"mentionOnly":
|
|
33
|
+
"mentionOnly": {
|
|
34
|
+
"default": true,
|
|
35
|
+
"chats": { "*Holodeck*": true },
|
|
36
|
+
"people": { "anna.schmidt@contoso.com": false }
|
|
37
|
+
},
|
|
31
38
|
"cooldownSeconds": 300,
|
|
32
39
|
"maxTriggersPerHour": 10
|
|
33
40
|
}
|
|
@@ -36,11 +43,11 @@ sensible for an unattended one.
|
|
|
36
43
|
| Key | Default | Meaning |
|
|
37
44
|
|-----|---------|---------|
|
|
38
45
|
| `enabled` | `false` | whether listen mode is switched on |
|
|
39
|
-
| `autoStart` | `false` | whether a
|
|
46
|
+
| `autoStart` | `false` | whether a session with a UI may start the watcher without being asked |
|
|
40
47
|
| `intervalSeconds` | `60` | seconds between polls (minimum 15) |
|
|
41
48
|
| `chats` | `[]` | **where** pi listens: glob patterns matched against topic, label, chat ID and participant names. Empty means every recent chat. |
|
|
42
49
|
| `from` | `[]` | **who** pi listens to: glob patterns matched against display name, UPN and e-mail. Empty means any sender. |
|
|
43
|
-
| `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). |
|
|
44
51
|
| `cooldownSeconds` | `300` | stay quiet in a chat after waking pi for it |
|
|
45
52
|
| `maxTriggersPerHour` | `10` | hard cap on wakes per hour |
|
|
46
53
|
|
|
@@ -48,6 +55,51 @@ Set it globally, or per account — the account level wins field by field.
|
|
|
48
55
|
`teams_watch` writes the same keys; `/teams-listen` writes to the session's
|
|
49
56
|
account.
|
|
50
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
|
+
|
|
51
103
|
## How a wake is decided
|
|
52
104
|
|
|
53
105
|
A chat wakes pi when two things are true:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patimweb/pi-teams",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.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",
|
|
@@ -365,13 +365,21 @@ teams_watch:
|
|
|
365
365
|
action: enable
|
|
366
366
|
chats: ["Anna*", "Vertrieb*"] # where to listen
|
|
367
367
|
from: ["anna.schmidt@contoso.com"] # who to listen to
|
|
368
|
-
mentionOnly:
|
|
368
|
+
mentionOnly:
|
|
369
|
+
default: true
|
|
370
|
+
people: {"anna.schmidt@contoso.com": false}
|
|
369
371
|
intervalSeconds: 60
|
|
370
372
|
```
|
|
371
373
|
|
|
372
374
|
- `chats` decides **where** pi listens (topic, label, chat ID, participants),
|
|
373
375
|
`from` decides **to whom** (name, UPN, e-mail). Narrow both: a watcher that
|
|
374
376
|
watches everything answers everything.
|
|
377
|
+
- `mentionOnly` decides whether a message has to address the user at all: a
|
|
378
|
+
boolean for everywhere, or `{ default, chats, people }` for overrides, matched
|
|
379
|
+
first-pattern-wins. A message in a **1:1 chat** counts as an address, so the
|
|
380
|
+
overrides are about group and meeting chats — the usual shape is a global
|
|
381
|
+
`true` with one person set to `false`, so that person never has to mention the
|
|
382
|
+
user.
|
|
375
383
|
- `/teams-listen on|off|status` switches it in the session; `teams_watch
|
|
376
384
|
action: status` reports what is configured and what is actually running.
|
|
377
385
|
- 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",
|
|
@@ -429,7 +443,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
429
443
|
// A stored setting is not a decision. A session starts the watcher only when
|
|
430
444
|
// the configuration opts into it; otherwise listen mode waits until it is
|
|
431
445
|
// switched on in this session with `/teams-listen on`.
|
|
432
|
-
|
|
446
|
+
// Only an interactive session listens on its own. A pi started headless by
|
|
447
|
+
// another pi (`--mode json -p`, e.g. a subagent) loads the same extensions
|
|
448
|
+
// and the same config: autoStart there would run a second watcher that
|
|
449
|
+
// answers chats inside a throwaway process and advances the shared cursor,
|
|
450
|
+
// so the real session never sees those messages.
|
|
451
|
+
if (connection.watch.autoStart && ctx.hasUI) startWatch(ctx, { explicit: true });
|
|
433
452
|
|
|
434
453
|
paintStatus(ctx);
|
|
435
454
|
|
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(
|
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",
|