@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.
@@ -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, which is only
19
- sensible for an unattended one.
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": false,
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 pi session may start the watcher without being asked |
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` | only wake where you are mentioned |
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.9.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: false
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
@@ -123,14 +123,62 @@ export interface WatchConfig {
123
123
  * listens, `from` decides *to whom*.
124
124
  */
125
125
  from?: string[];
126
- /** Only wake pi where the signed-in user is mentioned (default: false) */
127
- mentionOnly?: boolean;
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: boolean;
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") ?? WATCH_DEFAULTS.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
  *
@@ -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 ? "mentions only" : "every message",
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
- if (connection.watch.autoStart) startWatch(ctx, { explicit: true });
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
 
@@ -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: boolean;
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
- `- mentions only: ${watch.mentionOnly}`,
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.Boolean({ description: "Only wake pi where the signed-in user is mentioned" }),
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(
@@ -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.mentionOnly && !mentionsMe(message, me)) return skip("the chat is set to mentions only");
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",