@patimweb/pi-teams 1.1.0 → 1.2.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.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * teams_watch — switch listen mode on and off, and report what it is doing.
3
+ *
4
+ * The actual polling lives in the extension, because only it can inject a
5
+ * message into the session and update the status line. This tool owns the
6
+ * configuration and the explanation; the extension restarts the loop when the
7
+ * config changes.
8
+ */
9
+
10
+ import { Type } from "typebox";
11
+ import {
12
+ getConfigPath,
13
+ resolveConnection,
14
+ setWatchConfig,
15
+ type WatchConfig,
16
+ } from "../config/index.ts";
17
+ import { getActiveWatchLoop, type WatchRuntimeStatus } from "../watch/loop.ts";
18
+ import { errorResult, run, textResult, type ToolContext, type ToolResult } from "./shared.ts";
19
+
20
+ interface WatchParams {
21
+ action?: string;
22
+ account?: string;
23
+ tenant?: string;
24
+ intervalSeconds?: number;
25
+ chats?: string[];
26
+ from?: string[];
27
+ mentionOnly?: boolean;
28
+ cooldownSeconds?: number;
29
+ maxTriggersPerHour?: number;
30
+ }
31
+
32
+ /** Renders the effective settings the same way the config file would. */
33
+ function describe(watch: {
34
+ enabled: boolean;
35
+ intervalSeconds: number;
36
+ chats: string[];
37
+ from: string[];
38
+ mentionOnly: boolean;
39
+ cooldownSeconds: number;
40
+ maxTriggersPerHour: number;
41
+ }): string {
42
+ return [
43
+ `- enabled: ${watch.enabled}`,
44
+ `- poll every: ${watch.intervalSeconds} s`,
45
+ `- chats: ${watch.chats.length > 0 ? watch.chats.map((c) => `\`${c}\``).join(", ") : "every chat with recent activity"}`,
46
+ `- people: ${watch.from.length > 0 ? watch.from.map((p) => `\`${p}\``).join(", ") : "any sender"}`,
47
+ `- mentions only: ${watch.mentionOnly}`,
48
+ `- cooldown per chat: ${watch.cooldownSeconds} s`,
49
+ `- wake limit: ${watch.maxTriggersPerHour} per hour`,
50
+ ].join("\n");
51
+ }
52
+
53
+ /**
54
+ * The live facts about a running watcher, for the status action.
55
+ *
56
+ * Distinguishes "configured on" from "actually polling": after an edit to the
57
+ * config file the two can differ until the extension picks it up.
58
+ */
59
+ function runtimeLines(runtime: WatchRuntimeStatus | undefined): string[] {
60
+ if (!runtime) {
61
+ return [
62
+ "Nothing is polling in this session.",
63
+ "",
64
+ "Listen mode is read when a pi session starts. If the config says `enabled: true` and this "
65
+ + "says otherwise, start a new session — or switch it with the `/teams-listen` command.",
66
+ ];
67
+ }
68
+
69
+ const lines = [
70
+ "### Running in this session",
71
+ "",
72
+ `- running: ${runtime.running}`,
73
+ `- chats tracked: ${runtime.trackedChats}`,
74
+ `- woken this hour: ${runtime.wakesThisHour}`,
75
+ ];
76
+ if (runtime.lastTickAt) {
77
+ lines.push(`- last poll: ${new Date(runtime.lastTickAt).toLocaleTimeString()}`);
78
+ }
79
+ if (runtime.lastWakeChat) {
80
+ lines.push(`- last wake: ${runtime.lastWakeChat}`);
81
+ }
82
+ if (runtime.lastError) {
83
+ lines.push(`- ⚠️ last error: ${runtime.lastError}`);
84
+ }
85
+ return lines;
86
+ }
87
+
88
+ export const teamsWatchTool = {
89
+ name: "teams_watch",
90
+ description:
91
+ "Control listen mode: pi polls your Teams chats and turns an incoming message into a prompt it answers. " +
92
+ "Use action 'status' to see the current settings, 'enable' or 'disable' to switch it, and the optional " +
93
+ "fields to narrow what may wake pi (chat patterns, mentions only, interval, cooldown, hourly limit). " +
94
+ "The watcher runs in the extension session; this tool changes the configuration it reads.",
95
+ parameters: Type.Object({
96
+ action: Type.Optional(
97
+ Type.String({ description: "'status' (default), 'enable' or 'disable'" }),
98
+ ),
99
+ account: Type.Optional(
100
+ Type.String({ description: "Account the settings apply to; omit to set them globally" }),
101
+ ),
102
+ tenant: Type.Optional(
103
+ Type.String({
104
+ description:
105
+ "Tenant beneath that account. Used by action 'status' to report the settings in force for it — listen mode itself is configured per account, not per tenant.",
106
+ }),
107
+ ),
108
+ intervalSeconds: Type.Optional(
109
+ Type.Number({ description: "Seconds between polls (minimum 15, default 60)" }),
110
+ ),
111
+ chats: Type.Optional(
112
+ Type.Array(Type.String(), {
113
+ description:
114
+ "Glob patterns for the chats to watch — matched against topic, label, chat ID and participant names",
115
+ }),
116
+ ),
117
+ from: Type.Optional(
118
+ Type.Array(Type.String(), {
119
+ description:
120
+ "Glob patterns for the people pi listens to — matched against display name, UPN and e-mail. Omit for any sender.",
121
+ }),
122
+ ),
123
+ mentionOnly: Type.Optional(
124
+ Type.Boolean({ description: "Only wake pi where the signed-in user is mentioned" }),
125
+ ),
126
+ cooldownSeconds: Type.Optional(
127
+ Type.Number({ description: "Seconds to stay quiet in a chat after waking pi for it (default 300)" }),
128
+ ),
129
+ maxTriggersPerHour: Type.Optional(
130
+ Type.Number({ description: "Hard cap on wakes per hour (default 10)" }),
131
+ ),
132
+ }),
133
+ promptSnippet: "Turn Teams listen mode on or off",
134
+ promptGuidelines: [
135
+ "Use teams_watch when the user wants pi to notice incoming Teams messages on its own.",
136
+ "Tell the user plainly that listen mode spends a model turn per incoming message, and that pi answers in their name.",
137
+ "Enabling listen mode takes effect immediately; the settings survive a restart.",
138
+ ],
139
+
140
+ async execute(
141
+ _toolCallId: string,
142
+ params: WatchParams,
143
+ _signal: AbortSignal | undefined,
144
+ _onUpdate: undefined,
145
+ _ctx: ToolContext,
146
+ ): Promise<ToolResult> {
147
+ return run(async () => {
148
+ const action = (params.action ?? "status").toLowerCase();
149
+
150
+ // Reported from the resolved connection, so the answer shows what is in
151
+ // force for that account and tenant — not just what the file says.
152
+ const inspect = () => {
153
+ try {
154
+ return resolveConnection(params.account, params.tenant).watch;
155
+ } catch {
156
+ return undefined;
157
+ }
158
+ };
159
+
160
+ const patch: WatchConfig = {};
161
+ if (params.intervalSeconds !== undefined) patch.intervalSeconds = params.intervalSeconds;
162
+ if (params.chats !== undefined) patch.chats = params.chats;
163
+ if (params.from !== undefined) patch.from = params.from;
164
+ if (params.mentionOnly !== undefined) patch.mentionOnly = params.mentionOnly;
165
+ if (params.cooldownSeconds !== undefined) patch.cooldownSeconds = params.cooldownSeconds;
166
+ if (params.maxTriggersPerHour !== undefined) {
167
+ patch.maxTriggersPerHour = params.maxTriggersPerHour;
168
+ }
169
+
170
+ switch (action) {
171
+ case "status": {
172
+ const watch = inspect();
173
+ if (!watch) {
174
+ return errorResult(
175
+ `No usable Teams account for these settings. Check ${getConfigPath()}.`,
176
+ );
177
+ }
178
+ const loop = getActiveWatchLoop();
179
+ const runtime = loop?.status();
180
+ return textResult(
181
+ [
182
+ `## Listen mode — ${watch.enabled ? "on" : "off"}`,
183
+ "",
184
+ describe(watch),
185
+ "",
186
+ ...runtimeLines(runtime),
187
+ ].join("\n"),
188
+ { watch, runtime },
189
+ );
190
+ }
191
+
192
+ case "enable": {
193
+ const watch = setWatchConfig({ ...patch, enabled: true }, params.account);
194
+ return textResult(
195
+ [
196
+ "## Listen mode — on",
197
+ "",
198
+ describe(watch),
199
+ "",
200
+ "pi now polls these chats and treats a new message as a prompt. It answers in your name " +
201
+ "and under the safety level in force, so anything it decides to send still follows the " +
202
+ "usual confirmation rules.",
203
+ "",
204
+ `Written to ${getConfigPath()}.`,
205
+ ].join("\n"),
206
+ { watch },
207
+ );
208
+ }
209
+
210
+ case "disable": {
211
+ const watch = setWatchConfig({ ...patch, enabled: false }, params.account);
212
+ return textResult(
213
+ [
214
+ "## Listen mode — off",
215
+ "",
216
+ describe(watch),
217
+ "",
218
+ "Polling stops now; the settings stay in the file for the next time.",
219
+ ].join("\n"),
220
+ { watch },
221
+ );
222
+ }
223
+
224
+ default:
225
+ return errorResult(`Unknown action "${action}". Use 'status', 'enable' or 'disable'.`);
226
+ }
227
+ });
228
+ },
229
+ };
@@ -27,4 +27,5 @@ export const MUTATION_TOOLS = new Set<string>([
27
27
  // Local configuration and credentials
28
28
  "teams_setup",
29
29
  "teams_logout",
30
+ "teams_watch",
30
31
  ]);
@@ -82,18 +82,6 @@ export function htmlToText(content: string, contentType = "html"): string {
82
82
  .trim();
83
83
  }
84
84
 
85
- /** Convert plain text to the minimal HTML Teams expects for a message body. */
86
- export function textToHtml(text: string): string {
87
- const escaped = text
88
- .replace(/&/g, "&amp;")
89
- .replace(/</g, "&lt;")
90
- .replace(/>/g, "&gt;");
91
- return escaped
92
- .split(/\n{2,}/)
93
- .map((para) => `<p>${para.replace(/\n/g, "<br>")}</p>`)
94
- .join("");
95
- }
96
-
97
85
  // ---------------------------------------------------------------------------
98
86
  // Dates
99
87
  // ---------------------------------------------------------------------------
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Markdown-ish text → the HTML subset Microsoft Teams renders in a message.
3
+ *
4
+ * Why this exists: a message body is HTML, but the model writes markdown. Sent
5
+ * raw, `**wichtig**` arrives as literal asterisks and `- Punkt` as literal
6
+ * dashes — which is exactly the "wall of text" a person sees in a chat when
7
+ * nothing was formatted for the medium.
8
+ *
9
+ * Teams supports far less HTML than a browser. This translates only what
10
+ * survives in a chat bubble:
11
+ *
12
+ * `**bold**` → `<b>` `- item` → `<ul><li>`
13
+ * `*italic*` / `_italic_` → `<i>` `1. item` → `<ol><li>`
14
+ * `` `code` `` → `<code>` ``` fences → `<pre>`
15
+ * `~~struck~~` → `<s>` `# Heading` → a bold line
16
+ * `[label](url)` → `<a href>`
17
+ *
18
+ * Tables and images are deliberately unsupported: Teams renders neither in a
19
+ * chat, so pretending otherwise would produce worse output than plain text.
20
+ * Everything else is escaped and kept as written.
21
+ */
22
+
23
+ const HTML_ESCAPES: Record<string, string> = {
24
+ "&": "&amp;",
25
+ "<": "&lt;",
26
+ ">": "&gt;",
27
+ };
28
+
29
+ function escapeHtml(text: string): string {
30
+ return text.replace(/[&<>]/g, (char) => HTML_ESCAPES[char] ?? char);
31
+ }
32
+
33
+ /**
34
+ * Inline markup of a single line.
35
+ *
36
+ * Order matters: code spans are protected first (so `**` inside them stays
37
+ * literal), then links, then emphasis. The underscore rule requires a boundary
38
+ * on both sides so `snake_case_name` survives untouched.
39
+ */
40
+ function inline(raw: string): string {
41
+ let text = escapeHtml(raw);
42
+
43
+ text = text.replace(/`([^`]+)`/g, "<code>$1</code>");
44
+ text = text.replace(
45
+ /\[([^\]]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g,
46
+ '<a href="$2">$1</a>',
47
+ );
48
+ text = text.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>");
49
+ text = text.replace(/(^|[^*\w])\*([^*\n]+)\*/g, "$1<i>$2</i>");
50
+ text = text.replace(/(^|[\s(])_([^_\n]+)_(?=$|[\s).,;:!?])/g, "$1<i>$2</i>");
51
+ text = text.replace(/~~([^~]+)~~/g, "<s>$1</s>");
52
+
53
+ return text;
54
+ }
55
+
56
+ const BULLET = /^\s*[-*+]\s+(.*)$/;
57
+ const NUMBERED = /^\s*\d+[.)]\s+(.*)$/;
58
+ const HEADING = /^\s{0,3}#{1,6}\s+(.*)$/;
59
+
60
+ /**
61
+ * Convert a message body to Teams HTML.
62
+ *
63
+ * Blank lines separate paragraphs, single newlines become `<br>` inside one —
64
+ * which is what a person typing into the chat box gets, and the safest
65
+ * assumption for text that was generated, not composed.
66
+ */
67
+ export function markdownToTeamsHtml(text: string): string {
68
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
69
+ const blocks: string[] = [];
70
+
71
+ let paragraph: string[] = [];
72
+ let list: { ordered: boolean; items: string[] } | undefined;
73
+ let code: string[] | undefined;
74
+
75
+ const flushParagraph = () => {
76
+ if (paragraph.length === 0) return;
77
+ blocks.push(`<p>${paragraph.map(inline).join("<br>")}</p>`);
78
+ paragraph = [];
79
+ };
80
+
81
+ const flushList = () => {
82
+ if (!list) return;
83
+ const tag = list.ordered ? "ol" : "ul";
84
+ const items = list.items.map((item) => `<li>${inline(item)}</li>`).join("");
85
+ blocks.push(`<${tag}>${items}</${tag}>`);
86
+ list = undefined;
87
+ };
88
+
89
+ const flushBlock = () => {
90
+ flushParagraph();
91
+ flushList();
92
+ };
93
+
94
+ for (const line of lines) {
95
+ // A fence swallows everything until the next one, verbatim.
96
+ if (/^\s*```/.test(line)) {
97
+ if (code) {
98
+ blocks.push(`<pre>${escapeHtml(code.join("\n"))}</pre>`);
99
+ code = undefined;
100
+ } else {
101
+ flushBlock();
102
+ code = [];
103
+ }
104
+ continue;
105
+ }
106
+ if (code) {
107
+ code.push(line);
108
+ continue;
109
+ }
110
+
111
+ if (line.trim() === "") {
112
+ flushBlock();
113
+ continue;
114
+ }
115
+
116
+ const heading = HEADING.exec(line);
117
+ if (heading) {
118
+ flushBlock();
119
+ blocks.push(`<p><b>${inline(heading[1])}</b></p>`);
120
+ continue;
121
+ }
122
+
123
+ const bullet = BULLET.exec(line);
124
+ if (bullet) {
125
+ flushParagraph();
126
+ if (list && list.ordered) flushList();
127
+ list ??= { ordered: false, items: [] };
128
+ list.items.push(bullet[1]);
129
+ continue;
130
+ }
131
+
132
+ const numbered = NUMBERED.exec(line);
133
+ if (numbered) {
134
+ flushParagraph();
135
+ if (list && !list.ordered) flushList();
136
+ list ??= { ordered: true, items: [] };
137
+ list.items.push(numbered[1]);
138
+ continue;
139
+ }
140
+
141
+ flushList();
142
+ paragraph.push(line);
143
+ }
144
+
145
+ // An unterminated fence is still code, not lost text.
146
+ if (code) blocks.push(`<pre>${escapeHtml(code.join("\n"))}</pre>`);
147
+ flushBlock();
148
+
149
+ return blocks.join("");
150
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Listen mode — the pure half.
3
+ *
4
+ * "Watching" Teams means answering one question over and over: *is there
5
+ * anything here that should wake pi up?* That question is decided entirely
6
+ * from data — the chat list, one message, and what we have already seen — so it
7
+ * lives here as pure functions. The polling, the Graph calls and the prompt
8
+ * injection are in ./loop.ts.
9
+ *
10
+ * Two things are deliberately not pure: the clock and the fetch. Both are
11
+ * injected, which is what makes the decision rules testable without a tenant.
12
+ */
13
+
14
+ import type { ChatSummary, MessageSummary, PersonRef, SignedInUser } from "../types.ts";
15
+ import type { ResolvedWatchConfig } from "../config/index.ts";
16
+ import { matchesPattern } from "../config/scope.ts";
17
+ import { chatCandidates } from "../graph/mappers.ts";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // State
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * What the watcher remembers between ticks.
25
+ *
26
+ * `seen` is keyed by chat and holds the activity marker of the last message we
27
+ * looked at, so a tick can skip every chat that has not moved. It is a
28
+ * high-water mark, not a read receipt: nothing here is ever sent to Teams.
29
+ */
30
+ export interface WatchState {
31
+ /** chatId → activity marker of the last examined message */
32
+ seen: Map<string, string>;
33
+ /** chatId → epoch ms of the last wake in that chat */
34
+ lastTriggered: Map<string, number>;
35
+ /** epoch ms of every wake, pruned to the last hour */
36
+ triggers: number[];
37
+ }
38
+
39
+ export function createWatchState(): WatchState {
40
+ return { seen: new Map(), lastTriggered: new Map(), triggers: [] };
41
+ }
42
+
43
+ /** Marker for "this is the newest state of that chat we have looked at". */
44
+ export function activityMarker(chat: ChatSummary): string {
45
+ return chat.lastUpdated ?? "";
46
+ }
47
+
48
+ /** How many wakes happened within the last hour. */
49
+ export function wakesThisHour(state: WatchState, now: number): number {
50
+ return state.triggers.filter((at) => now - at < 3600_000).length;
51
+ }
52
+
53
+ /**
54
+ * Drop bookkeeping that can no longer influence a decision.
55
+ *
56
+ * A session can run for days; without this the two maps grow with every chat
57
+ * the user ever touches. The hour is the longest window any rule looks at.
58
+ */
59
+ export function pruneState(state: WatchState, now: number): void {
60
+ state.triggers = state.triggers.filter((at) => now - at < 3600_000);
61
+ for (const [chatId, at] of state.lastTriggered) {
62
+ if (now - at > 24 * 3600_000) state.lastTriggered.delete(chatId);
63
+ }
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Which chats are worth a look
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /**
71
+ * Chats that are both allowed by the watch filter and have moved since we last
72
+ * looked.
73
+ *
74
+ * @param freshnessMs How far back "new" reaches. Enabling listen mode must not
75
+ * wake pi for every conversation that happened to be active this morning, so
76
+ * anything older than the window is recorded as seen and then ignored.
77
+ */
78
+ export function chatsToExamine(
79
+ state: WatchState,
80
+ chats: ChatSummary[],
81
+ watch: ResolvedWatchConfig,
82
+ now: number,
83
+ freshnessMs: number,
84
+ ): ChatSummary[] {
85
+ const cutoff = now - freshnessMs;
86
+
87
+ return chats.filter((chat) => {
88
+ if (!isWatchedChat(chat, watch)) return false;
89
+ if (state.seen.get(chat.id) === activityMarker(chat)) return false;
90
+
91
+ const updated = chat.lastUpdated ? new Date(chat.lastUpdated).getTime() : 0;
92
+ // A chat with no activity timestamp cannot be dated, so it is never new.
93
+ if (!updated || updated < cutoff) return false;
94
+
95
+ return true;
96
+ });
97
+ }
98
+
99
+ /** Does this chat fall inside the configured chat filter? */
100
+ export function isWatchedChat(chat: ChatSummary, watch: ResolvedWatchConfig): boolean {
101
+ if (watch.chats.length === 0) return true;
102
+ const candidates = chatCandidates(chat).filter((value): value is string => !!value);
103
+ return watch.chats.some((pattern) => candidates.some((candidate) => matchesPattern(pattern, candidate)));
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Who pi listens to
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * Does the sender fall inside the configured listen list?
112
+ *
113
+ * `chats` says where pi listens, this says to whom — the person filter is what
114
+ * keeps listen mode from becoming "answer the whole company". Matched against
115
+ * every identifier the message carries, like the scope rules do.
116
+ */
117
+ export function isWatchedSender(message: MessageSummary, watch: ResolvedWatchConfig): boolean {
118
+ if (watch.from.length === 0) return true;
119
+
120
+ const sender = message.from;
121
+ if (!sender) return false;
122
+
123
+ const candidates = [sender.displayName, sender.upn, sender.mail, sender.id].filter(
124
+ (value): value is string => !!value,
125
+ );
126
+ return watch.from.some((pattern) => candidates.some((candidate) => matchesPattern(pattern, candidate)));
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Whether a message should wake pi
131
+ // ---------------------------------------------------------------------------
132
+
133
+ export type WakeDecision = { wake: true } | { wake: false; reason: string };
134
+
135
+ const WAKE: WakeDecision = { wake: true };
136
+ const skip = (reason: string): WakeDecision => ({ wake: false, reason });
137
+
138
+ /**
139
+ * Should this message wake pi?
140
+ *
141
+ * Ordered so the cheapest and most obvious reason to stay quiet is also the
142
+ * one that gets reported — a wrong wake costs a model turn, a wrong silence
143
+ * costs nothing but the reason in the log.
144
+ */
145
+ export function shouldWake(
146
+ chat: ChatSummary,
147
+ message: MessageSummary,
148
+ me: SignedInUser | undefined,
149
+ watch: ResolvedWatchConfig,
150
+ state: WatchState,
151
+ now: number,
152
+ ): WakeDecision {
153
+ if (message.deletedDateTime) return skip("the message was deleted");
154
+ if (isFromMe(message.from, me)) return skip("the last message is my own");
155
+
156
+ if (!isWatchedChat(chat, watch)) return skip("the chat is outside the configured watch list");
157
+ if (!isWatchedSender(message, watch)) return skip("the sender is outside the configured listen list");
158
+ if (watch.mentionOnly && !mentionsMe(message, me)) return skip("the chat is set to mentions only");
159
+ if (watch.maxTriggersPerHour > 0 && wakesThisHour(state, now) >= watch.maxTriggersPerHour) {
160
+ return skip("the hourly wake limit is reached");
161
+ }
162
+
163
+ const last = state.lastTriggered.get(chat.id);
164
+ if (last !== undefined && now - last < watch.cooldownSeconds * 1000) {
165
+ return skip("this chat is inside its cooldown");
166
+ }
167
+
168
+ return WAKE;
169
+ }
170
+
171
+ /**
172
+ * Record that pi was woken for a chat.
173
+ *
174
+ * Split from the decision so `shouldWake` stays free of side effects and can be
175
+ * called speculatively.
176
+ */
177
+ export function noteWake(state: WatchState, chatId: string, now: number): void {
178
+ state.lastTriggered.set(chatId, now);
179
+ state.triggers.push(now);
180
+ }
181
+
182
+ /**
183
+ * Was this message sent by the signed-in user?
184
+ *
185
+ * pi posts as the user, so its own replies come back as "my own message" on the
186
+ * next tick — which is what stops the watcher from waking on itself. Matching
187
+ * on the ID first and the display name second covers both a chat member entry
188
+ * that carries an ID and one that only carries a name.
189
+ */
190
+ export function isFromMe(from: PersonRef | undefined, me: SignedInUser | undefined): boolean {
191
+ if (!from || !me) return false;
192
+ if (from.id && me.id && from.id === me.id) return true;
193
+ return !!from.displayName && from.displayName === me.displayName;
194
+ }
195
+
196
+ /**
197
+ * Does this message mention the signed-in user?
198
+ *
199
+ * Graph fills `mentions` for real @-mentions; the text fallback covers the
200
+ * common case of someone typing a first name, which is what "mentions only"
201
+ * means to a person waiting for an answer.
202
+ */
203
+ export function mentionsMe(message: MessageSummary, me: SignedInUser | undefined): boolean {
204
+ if (!me) return false;
205
+
206
+ const mentioned = message.mentions.some(
207
+ (person) =>
208
+ (person.id && person.id === me.id) ||
209
+ (person.displayName && person.displayName === me.displayName),
210
+ );
211
+ if (mentioned) return true;
212
+
213
+ const text = message.text ?? "";
214
+ if (me.displayName && text.includes(`@${me.displayName}`)) return true;
215
+
216
+ // First name only: "Patrick, kannst du…". Word-boundary-ish, case-insensitive.
217
+ const firstName = me.displayName.split(/\s+/)[0];
218
+ if (!firstName || firstName.length < 3) return false;
219
+ return new RegExp(`(^|[^\\p{L}])${escapeRegExp(firstName)}($|[^\\p{L}])`, "iu").test(text);
220
+ }
221
+
222
+ function escapeRegExp(value: string): string {
223
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
224
+ }