@patimweb/pi-teams 1.1.0 → 1.3.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/README.md +137 -11
- package/docs/entra-app-registration.md +243 -0
- package/package.json +1 -1
- package/prompts/teams-schedule.md +23 -0
- package/screenshot.png +0 -0
- package/skills/teams-collaboration/SKILL.md +177 -2
- package/src/config/index.ts +145 -1
- package/src/extension/index.ts +186 -4
- package/src/graph/calendar.ts +87 -0
- package/src/graph/chats.ts +134 -3
- package/src/graph/mappers.ts +20 -0
- package/src/graph/messages.ts +64 -40
- package/src/safety/index.ts +17 -3
- package/src/status.ts +22 -1
- package/src/tools/shared.ts +24 -0
- package/src/tools/teams-availability.ts +224 -0
- package/src/tools/teams-chat-members.ts +148 -0
- package/src/tools/teams-delete-message.ts +21 -48
- package/src/tools/teams-inbox.ts +1 -0
- package/src/tools/teams-mark-read.ts +84 -0
- package/src/tools/teams-meetings.ts +98 -0
- package/src/tools/teams-send-channel-message.ts +7 -2
- package/src/tools/teams-send-chat-message.ts +7 -2
- package/src/tools/teams-update-message.ts +88 -0
- package/src/tools/teams-watch.ts +229 -0
- package/src/tools/tool-names.ts +5 -2
- package/src/types.ts +11 -0
- package/src/utils/errors.ts +1 -0
- package/src/utils/formatting.ts +62 -12
- package/src/utils/richtext.ts +150 -0
- package/src/utils/slots.ts +171 -0
- package/src/watch/index.ts +224 -0
- package/src/watch/loop.ts +304 -0
- package/src/watch/prompt.ts +59 -0
- package/src/tools/teams-create-channel.ts +0 -89
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: teams-collaboration
|
|
3
|
-
description: Microsoft Teams chats, channels, meetings and presence, acting as the signed-in user. Use when the user asks to read, search, summarize, send or reply to Teams messages; to catch up on what they missed; to post in a channel or a thread; to
|
|
3
|
+
description: Microsoft Teams chats, channels, meetings and presence, acting as the signed-in user. Use when the user asks to read, search, summarize, send, edit, retract or reply to Teams messages; to add or remove someone from a chat; to catch up on what they missed and clear it; to post in a channel or a thread; to react to a message; to mark a chat read or unread; to check or set their Teams status; to see who is available; to find a time everyone is free; to schedule, change, answer, or cancel a Teams meeting; or to make pi notice incoming Teams messages on its own (listen mode). Also covers how to format a message for a chat rather than a document, multiple Teams accounts and multiple company tenants, and the allow/deny rules that decide what pi may touch.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Microsoft Teams
|
|
@@ -47,6 +47,20 @@ rule set. Show them the rule, do not work around it, and do not try a different
|
|
|
47
47
|
tool to achieve the same thing. `teams_permissions` with `action: "test"`
|
|
48
48
|
checks a target without touching it.
|
|
49
49
|
|
|
50
|
+
## When a tool says a scope is missing
|
|
51
|
+
|
|
52
|
+
One optional action needs a permission the account does not request by default:
|
|
53
|
+
removing someone from a **chat** (`ChatMember.ReadWrite`). The tool names the
|
|
54
|
+
exact scope. Pass that sentence on as the fix — add it to `scopes` in
|
|
55
|
+
`pi-teams.json`, sign in again — or do it in Teams. Do not look for a roundabout
|
|
56
|
+
way to achieve the same thing, and do not report it as a permission bug.
|
|
57
|
+
|
|
58
|
+
A few things cannot be done at all with the permissions pi asks for: creating a
|
|
59
|
+
channel, and editing or deleting a **channel** post. Those need
|
|
60
|
+
`Channel.Create` / `ChannelMessage.ReadWrite`, they are not requested, and they
|
|
61
|
+
are not assumed to be grantable. Say so plainly and point at the Teams client
|
|
62
|
+
instead of trying anyway.
|
|
63
|
+
|
|
50
64
|
## Tools
|
|
51
65
|
|
|
52
66
|
### Session
|
|
@@ -76,6 +90,7 @@ checks a target without touching it.
|
|
|
76
90
|
| `teams_find_user` | Resolving a first name to a real person |
|
|
77
91
|
| `teams_list_files` | Files shared in a channel |
|
|
78
92
|
| `teams_get_presence` | Is someone available right now |
|
|
93
|
+
| `teams_availability` | "When are we all free?" — before booking, not after |
|
|
79
94
|
| `teams_list_meetings` / `teams_get_meeting` | Calendar and meeting details |
|
|
80
95
|
|
|
81
96
|
### Writing
|
|
@@ -87,10 +102,14 @@ checks a target without touching it.
|
|
|
87
102
|
| `teams_send_channel_message` | Posting a new channel message |
|
|
88
103
|
| `teams_reply_channel_message` | Answering inside an existing thread |
|
|
89
104
|
| `teams_react` | Adding or removing an emoji reaction |
|
|
105
|
+
| `teams_update_message` | Fixing a message the user already sent |
|
|
106
|
+
| `teams_chat_members` | Bringing someone into a group chat, or taking them out |
|
|
107
|
+
| `teams_mark_read` | Clearing the unread marker after a catch-up |
|
|
90
108
|
| `teams_delete_message` | Taking back something the user sent |
|
|
91
|
-
| `teams_create_channel` | Adding a channel to a team |
|
|
92
109
|
| `teams_set_presence` / `teams_set_status_message` | Changing the user's status |
|
|
93
110
|
| `teams_create_meeting` / `teams_update_meeting` / `teams_cancel_meeting` | Scheduling work |
|
|
111
|
+
| `teams_respond_invite` | Accepting, declining or tentatively answering an invitation |
|
|
112
|
+
| `teams_watch` | Letting pi notice incoming messages on its own |
|
|
94
113
|
|
|
95
114
|
## How to write as the user
|
|
96
115
|
|
|
@@ -105,6 +124,48 @@ checks a target without touching it.
|
|
|
105
124
|
- Prefer `teams_reply_channel_message` over a new post when answering something
|
|
106
125
|
— starting a second thread fragments the conversation.
|
|
107
126
|
|
|
127
|
+
## How to format a Teams message
|
|
128
|
+
|
|
129
|
+
A Teams chat is a chat bubble, not a document. What arrives badly is long
|
|
130
|
+
prose: the reader has to hunt for the point, and it reads as if a machine wrote
|
|
131
|
+
it. The body is rendered as HTML — markdown is converted for you, so write
|
|
132
|
+
markdown and let the tool do the rest.
|
|
133
|
+
|
|
134
|
+
**Reach for this shape, not for paragraphs:**
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
Kurz: die QA-Umgebung läuft wieder.
|
|
138
|
+
|
|
139
|
+
- Ursache: abgelaufenes Zertifikat
|
|
140
|
+
- Fix: erneuert und neu deployt
|
|
141
|
+
- Offen: Monitoring für 90 Tage
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
- **Lead with the answer.** The first line should work as the whole message for
|
|
145
|
+
someone who reads nothing else.
|
|
146
|
+
- **Three short paragraphs at most.** If it needs more, it is a document — put
|
|
147
|
+
it in a file or a wiki page and link it.
|
|
148
|
+
- **Bullets for anything enumerable** — causes, options, next steps. Three items
|
|
149
|
+
and up belong in a list, not in a sentence with commas.
|
|
150
|
+
- **Bold the one thing that must not be missed**, and only that. Bold used
|
|
151
|
+
everywhere is bold nowhere.
|
|
152
|
+
- **Numbers, names and dates exactly as they are.** Do not round a figure or
|
|
153
|
+
paraphrase a deadline into something friendlier.
|
|
154
|
+
- **One question per message.** Two questions get one answer.
|
|
155
|
+
|
|
156
|
+
**What Teams renders:** `**bold**`, `*italic*`, `` `code` ``, `~~struck~~`,
|
|
157
|
+
`[label](url)`, `- bullets`, `1. numbered`, ``` fences. A `# heading` arrives as
|
|
158
|
+
a bold line — fine for structure, not for hierarchy. **Tables are not supported
|
|
159
|
+
in a chat**: convert them to bullets, or put the table in a file and link it.
|
|
160
|
+
|
|
161
|
+
**Language and tone:** answer in the language of the conversation, in the
|
|
162
|
+
register of the people in it. Short forms are normal in a chat ("passt", "ok,
|
|
163
|
+
5 min") — but never shorten a fact into ambiguity.
|
|
164
|
+
|
|
165
|
+
When pi listens on its own (see *Listen mode*), this matters most: an answer
|
|
166
|
+
that arrives unrequested must be readable at a glance, because the recipient is
|
|
167
|
+
not expecting it.
|
|
168
|
+
|
|
108
169
|
## Channel addressing
|
|
109
170
|
|
|
110
171
|
Channels are addressed as `Team/Channel` or via separate `team` and `channel`
|
|
@@ -143,3 +204,117 @@ summarize from openers alone.
|
|
|
143
204
|
Before replying anywhere, read the recent messages first. It prevents
|
|
144
205
|
answering a question that was already answered, and it is what tells you the
|
|
145
206
|
tone to match.
|
|
207
|
+
|
|
208
|
+
## Read state
|
|
209
|
+
|
|
210
|
+
`teams_mark_read` moves the user's read cursor on a **chat** — the same thing
|
|
211
|
+
as opening it, or re-marking it unread, in the app. Two rules:
|
|
212
|
+
|
|
213
|
+
- It is a **write**, and it goes through the safety gates like any other. Mark
|
|
214
|
+
a chat read when the user asks, or when they clearly want the list cleared —
|
|
215
|
+
not on your own initiative after reading it.
|
|
216
|
+
- Where the tenant shows **read receipts**, the sender can see that the chat was
|
|
217
|
+
opened. That is not a reason to refuse, only a reason not to do it silently.
|
|
218
|
+
|
|
219
|
+
Channels have no equivalent: channel posts cannot be marked read or unread
|
|
220
|
+
through Graph, so "mark the channel as read" is done in the Teams client.
|
|
221
|
+
|
|
222
|
+
## Correcting a message
|
|
223
|
+
|
|
224
|
+
Both actions work on **chat** messages the user themselves sent, which is also
|
|
225
|
+
the only case that makes sense: pi wrote them in the user's name. A channel post
|
|
226
|
+
cannot be edited or deleted with the permissions pi asks for — for that, point
|
|
227
|
+
at the Teams client.
|
|
228
|
+
|
|
229
|
+
- **`teams_update_message` replaces the text and keeps the message in place.**
|
|
230
|
+
Teams shows it as edited in the same bubble. This is the right answer to a
|
|
231
|
+
typo, a wrong time, a missing detail.
|
|
232
|
+
- **`teams_delete_message` takes it back.** Right when the message should not
|
|
233
|
+
exist at all: wrong chat, wrong content, sent too early.
|
|
234
|
+
|
|
235
|
+
Never correct by deleting and resending. The replacement arrives as a brand-new
|
|
236
|
+
notification, everyone who already read the first message has to notice the gap,
|
|
237
|
+
and the thread loses its order. Editing is the polite version.
|
|
238
|
+
|
|
239
|
+
An edit sends the text only: mentions and attachments from the original are not
|
|
240
|
+
re-created. That is deliberate — re-sending mention entities would ping people a
|
|
241
|
+
second time for a message they were already notified about.
|
|
242
|
+
|
|
243
|
+
## Chat membership
|
|
244
|
+
|
|
245
|
+
`teams_chat_members` adds a person to a group chat or removes one. Adding
|
|
246
|
+
someone hands them the conversation, so:
|
|
247
|
+
|
|
248
|
+
- Resolve the person first (`teams_find_user`) when only a first name was given,
|
|
249
|
+
and say the full name in your reply. "Tom" is not an identity.
|
|
250
|
+
- A **1:1** chat cannot be extended — Graph refuses. Use `teams_create_chat`
|
|
251
|
+
with everyone who belongs in it.
|
|
252
|
+
- Removing someone needs an extra permission (`ChatMember.ReadWrite`); the tool
|
|
253
|
+
says so. Adding works with the default scopes.
|
|
254
|
+
|
|
255
|
+
## Finding a time before booking one
|
|
256
|
+
|
|
257
|
+
`teams_availability` reads **free/busy**, never calendars: "is Tuesday at two
|
|
258
|
+
possible" is answerable, "what is Anna doing on Tuesday" is not, and must never
|
|
259
|
+
be presented as if it were.
|
|
260
|
+
|
|
261
|
+
1. Run it with everybody who has to be there and the length of the meeting.
|
|
262
|
+
2. Offer the user two or three of the slots it returns — not all of them.
|
|
263
|
+
3. Only once they pick one, book it with `teams_create_meeting`.
|
|
264
|
+
|
|
265
|
+
Never book a slot because it looked free, and never dress the busy blocks up as
|
|
266
|
+
anything more than times: knowing that somebody is busy is the point, knowing why
|
|
267
|
+
is their business.
|
|
268
|
+
|
|
269
|
+
## Answering an invitation
|
|
270
|
+
|
|
271
|
+
`teams_respond_invite` accepts, declines or marks an invitation as tentative,
|
|
272
|
+
and tells the organizer unless `sendResponse: false`. This fills the user's
|
|
273
|
+
calendar and answers another person, so it needs their decision — "say yes to
|
|
274
|
+
whatever looks relevant" is not one.
|
|
275
|
+
|
|
276
|
+
A meeting the user **organizes** has no invitation to answer: change it with
|
|
277
|
+
`teams_update_meeting`, cancel it with `teams_cancel_meeting`.
|
|
278
|
+
|
|
279
|
+
## Listen mode
|
|
280
|
+
|
|
281
|
+
Listen mode is the only case where pi acts without being asked: it polls the
|
|
282
|
+
user's chats, and an incoming message is turned into a prompt. Off by default,
|
|
283
|
+
and it costs one model turn per message that wakes it.
|
|
284
|
+
|
|
285
|
+
```yaml
|
|
286
|
+
teams_watch:
|
|
287
|
+
action: enable
|
|
288
|
+
chats: ["Anna*", "Vertrieb*"] # where to listen
|
|
289
|
+
from: ["anna.schmidt@contoso.com"] # who to listen to
|
|
290
|
+
mentionOnly: false
|
|
291
|
+
intervalSeconds: 60
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
- `chats` decides **where** pi listens (topic, label, chat ID, participants),
|
|
295
|
+
`from` decides **to whom** (name, UPN, e-mail). Narrow both: a watcher that
|
|
296
|
+
watches everything answers everything.
|
|
297
|
+
- `/teams-listen on|off|status` switches it in the session; `teams_watch
|
|
298
|
+
action: status` reports what is configured and what is actually running.
|
|
299
|
+
- The first poll only records what is there — switching it on never answers the
|
|
300
|
+
backlog, only what arrives afterwards.
|
|
301
|
+
- pi never wakes for its own messages, so it cannot answer itself.
|
|
302
|
+
- Listen mode does **not** bypass anything: a reply it decides to send is
|
|
303
|
+
subject to the same safety level and scope rules as a reply you asked for. At
|
|
304
|
+
`safetyLevel: confirm` every one of them is confirmed with the user first.
|
|
305
|
+
|
|
306
|
+
**When a listen prompt arrives**, do this:
|
|
307
|
+
|
|
308
|
+
1. Judge the message before answering. "Danke, passt" needs no reply; a
|
|
309
|
+
question, a blocker or a decision does.
|
|
310
|
+
2. If an answer is warranted, send it with `teams_send_chat_message` to the
|
|
311
|
+
**chat ID from the prompt** — never re-resolve the chat by name. pi posts as
|
|
312
|
+
the user, so the wrong chat is a wrong statement in the user's name.
|
|
313
|
+
3. Keep it to the shape in *How to format a Teams message*: answer first, three
|
|
314
|
+
short paragraphs at most, bullets for lists. An unrequested message has to be
|
|
315
|
+
readable at a glance.
|
|
316
|
+
4. If no answer is warranted, say so in one line and stop. Do not react, do not
|
|
317
|
+
touch other chats, do not post to a channel.
|
|
318
|
+
|
|
319
|
+
Never let listen mode turn into a conversation with itself: if the incoming
|
|
320
|
+
message is already an answer to something pi sent, the loop ends there.
|
package/src/config/index.ts
CHANGED
|
@@ -89,9 +89,54 @@ export interface TenantConfig {
|
|
|
89
89
|
permissions?: PermissionBlock;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Settings for "listen" mode — where pi is woken by an incoming Teams chat.
|
|
94
|
+
*
|
|
95
|
+
* Every field is optional and merges field by field down the cascade
|
|
96
|
+
* (global → account), so an interval can be set once while the chat filter is
|
|
97
|
+
* narrowed per account.
|
|
98
|
+
*/
|
|
99
|
+
export interface WatchConfig {
|
|
100
|
+
/** Whether the watcher runs at all (default: false) */
|
|
101
|
+
enabled?: boolean;
|
|
102
|
+
/** Seconds between polls (default: 60, minimum: 15) */
|
|
103
|
+
intervalSeconds?: number;
|
|
104
|
+
/**
|
|
105
|
+
* Glob patterns for the chats to watch — matched against topic, label,
|
|
106
|
+
* chat ID and participant names, exactly like the scope rules. Empty or
|
|
107
|
+
* absent means every recent chat.
|
|
108
|
+
*/
|
|
109
|
+
chats?: string[];
|
|
110
|
+
/**
|
|
111
|
+
* Only wake pi for messages from these people — matched against display
|
|
112
|
+
* name, UPN and e-mail. Empty or absent means any sender.
|
|
113
|
+
*
|
|
114
|
+
* This is the "listen to these people" switch: `chats` decides *where* pi
|
|
115
|
+
* listens, `from` decides *to whom*.
|
|
116
|
+
*/
|
|
117
|
+
from?: string[];
|
|
118
|
+
/** Only wake pi where the signed-in user is mentioned (default: false) */
|
|
119
|
+
mentionOnly?: boolean;
|
|
120
|
+
/** Seconds to stay quiet in a chat after waking pi for it (default: 300) */
|
|
121
|
+
cooldownSeconds?: number;
|
|
122
|
+
/** Hard cap on wakes per hour, across all chats (default: 10) */
|
|
123
|
+
maxTriggersPerHour?: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A watch config after every default and clamp has been applied. */
|
|
127
|
+
export interface ResolvedWatchConfig {
|
|
128
|
+
enabled: boolean;
|
|
129
|
+
intervalSeconds: number;
|
|
130
|
+
chats: string[];
|
|
131
|
+
from: string[];
|
|
132
|
+
mentionOnly: boolean;
|
|
133
|
+
cooldownSeconds: number;
|
|
134
|
+
maxTriggersPerHour: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
92
137
|
/** A Teams identity pi can sign in as. */
|
|
93
138
|
export interface AccountConfig {
|
|
94
|
-
/** Short label used in tool parameters, e.g. "
|
|
139
|
+
/** Short label used in tool parameters, e.g. "work" */
|
|
95
140
|
name: string;
|
|
96
141
|
/** Friendly name for status output */
|
|
97
142
|
displayName?: string;
|
|
@@ -117,6 +162,8 @@ export interface AccountConfig {
|
|
|
117
162
|
safetyLevel?: SafetyLevel;
|
|
118
163
|
/** Scope rules layered on top of the global ones */
|
|
119
164
|
permissions?: PermissionBlock;
|
|
165
|
+
/** Listen-mode overrides for this identity */
|
|
166
|
+
watch?: WatchConfig;
|
|
120
167
|
/** Additional tenants this identity reaches */
|
|
121
168
|
tenants?: TenantConfig[];
|
|
122
169
|
}
|
|
@@ -132,6 +179,8 @@ export interface TeamsRootConfig {
|
|
|
132
179
|
safetyLevel?: SafetyLevel;
|
|
133
180
|
/** Global scope rules */
|
|
134
181
|
permissions?: PermissionBlock;
|
|
182
|
+
/** Global listen-mode settings */
|
|
183
|
+
watch?: WatchConfig;
|
|
135
184
|
/** Default page size for message listings (default: 25) */
|
|
136
185
|
maxMessages?: number;
|
|
137
186
|
/** Append every write to ~/.pi/agent/pi-teams-audit.jsonl (default: true) */
|
|
@@ -161,6 +210,8 @@ export interface TeamsConnection {
|
|
|
161
210
|
loopbackPort?: number;
|
|
162
211
|
safetyLevel: SafetyLevel;
|
|
163
212
|
permissions: ResolvedPermissions;
|
|
213
|
+
/** Listen-mode settings in force for this account+tenant */
|
|
214
|
+
watch: ResolvedWatchConfig;
|
|
164
215
|
maxMessages: number;
|
|
165
216
|
audit: boolean;
|
|
166
217
|
graphBaseUrl: string;
|
|
@@ -220,6 +271,29 @@ const DEFAULTS = {
|
|
|
220
271
|
authorityHost: "https://login.microsoftonline.com",
|
|
221
272
|
};
|
|
222
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Listen-mode defaults and the bounds they are clamped to.
|
|
276
|
+
*
|
|
277
|
+
* The lower bound on `intervalSeconds` is what keeps a watcher from turning
|
|
278
|
+
* into a Graph-throttling loop: every tick costs at least one `/me/chats`
|
|
279
|
+
* round-trip, and each wake costs a full model turn.
|
|
280
|
+
*/
|
|
281
|
+
export const WATCH_DEFAULTS: ResolvedWatchConfig = {
|
|
282
|
+
enabled: false,
|
|
283
|
+
intervalSeconds: 60,
|
|
284
|
+
chats: [],
|
|
285
|
+
from: [],
|
|
286
|
+
mentionOnly: false,
|
|
287
|
+
cooldownSeconds: 300,
|
|
288
|
+
maxTriggersPerHour: 10,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const WATCH_BOUNDS = {
|
|
292
|
+
intervalSeconds: { min: 15, max: 3600 },
|
|
293
|
+
cooldownSeconds: { min: 0, max: 86400 },
|
|
294
|
+
maxTriggersPerHour: { min: 0, max: 1000 },
|
|
295
|
+
};
|
|
296
|
+
|
|
223
297
|
const VALID_AUTH_MODES = new Set<string>([
|
|
224
298
|
"interactive",
|
|
225
299
|
"device-code",
|
|
@@ -454,6 +528,7 @@ export function resolveConnection(
|
|
|
454
528
|
scopes,
|
|
455
529
|
safetyLevel: resolveEffectiveSafetyLevel(config.safetyLevel, account, tenant),
|
|
456
530
|
permissions: resolvePermissions([config.permissions, account.permissions, tenant?.permissions]),
|
|
531
|
+
watch: resolveWatchConfig(config.watch, account),
|
|
457
532
|
maxMessages: config.maxMessages ?? DEFAULTS.maxMessages,
|
|
458
533
|
audit: config.audit ?? DEFAULTS.audit,
|
|
459
534
|
graphBaseUrl: (config.graphBaseUrl ?? DEFAULTS.graphBaseUrl).replace(/\/+$/, ""),
|
|
@@ -593,6 +668,75 @@ export function setDefaultAccount(name: string, tenantName?: string): void {
|
|
|
593
668
|
writeRootConfig(config);
|
|
594
669
|
}
|
|
595
670
|
|
|
671
|
+
/**
|
|
672
|
+
* Effective listen-mode settings: account > global > defaults.
|
|
673
|
+
*
|
|
674
|
+
* Exported for tests — every field is clamped here, so the watcher itself can
|
|
675
|
+
* assume sane numbers instead of defending against a hand-edited config file.
|
|
676
|
+
*/
|
|
677
|
+
export function resolveWatchConfig(
|
|
678
|
+
global: WatchConfig | undefined,
|
|
679
|
+
account: AccountConfig | undefined,
|
|
680
|
+
): ResolvedWatchConfig {
|
|
681
|
+
const local = account?.watch;
|
|
682
|
+
const pick = <K extends keyof WatchConfig>(key: K): WatchConfig[K] => local?.[key] ?? global?.[key];
|
|
683
|
+
|
|
684
|
+
return {
|
|
685
|
+
enabled: pick("enabled") ?? WATCH_DEFAULTS.enabled,
|
|
686
|
+
intervalSeconds: clamp(pick("intervalSeconds"), WATCH_BOUNDS.intervalSeconds, WATCH_DEFAULTS.intervalSeconds),
|
|
687
|
+
chats: normalizePatterns(pick("chats")),
|
|
688
|
+
from: normalizePatterns(pick("from")),
|
|
689
|
+
mentionOnly: pick("mentionOnly") ?? WATCH_DEFAULTS.mentionOnly,
|
|
690
|
+
cooldownSeconds: clamp(pick("cooldownSeconds"), WATCH_BOUNDS.cooldownSeconds, WATCH_DEFAULTS.cooldownSeconds),
|
|
691
|
+
maxTriggersPerHour: clamp(
|
|
692
|
+
pick("maxTriggersPerHour"),
|
|
693
|
+
WATCH_BOUNDS.maxTriggersPerHour,
|
|
694
|
+
WATCH_DEFAULTS.maxTriggersPerHour,
|
|
695
|
+
),
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function clamp(value: unknown, bounds: { min: number; max: number }, fallback: number): number {
|
|
700
|
+
const number = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
701
|
+
return Math.min(bounds.max, Math.max(bounds.min, Math.round(number)));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function normalizePatterns(patterns: string[] | undefined): string[] {
|
|
705
|
+
if (!Array.isArray(patterns)) return [];
|
|
706
|
+
const cleaned = patterns
|
|
707
|
+
.filter((pattern): pattern is string => typeof pattern === "string")
|
|
708
|
+
.map((pattern) => pattern.trim())
|
|
709
|
+
.filter((pattern) => pattern.length > 0);
|
|
710
|
+
return [...new Set(cleaned)];
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Merge a watch patch into the config file and report what is now in force.
|
|
715
|
+
*
|
|
716
|
+
* Writes to the account block when an account is named and to the global block
|
|
717
|
+
* otherwise — matching how `watch` itself is resolved. The resolved settings
|
|
718
|
+
* are returned rather than the patch, so a caller reports the effect and not
|
|
719
|
+
* the request.
|
|
720
|
+
*/
|
|
721
|
+
export function setWatchConfig(patch: WatchConfig, accountName?: string): ResolvedWatchConfig {
|
|
722
|
+
const config = readRootConfig();
|
|
723
|
+
|
|
724
|
+
if (!accountName) {
|
|
725
|
+
config.watch = { ...config.watch, ...patch };
|
|
726
|
+
writeRootConfig(config);
|
|
727
|
+
return resolveWatchConfig(config.watch, undefined);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const account = config.accounts.find((a) => a.name.toLowerCase() === accountName.toLowerCase());
|
|
731
|
+
if (!account) {
|
|
732
|
+
throw new ConfigError([`account "${accountName}"`], `Account "${accountName}" not found.`);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
account.watch = { ...account.watch, ...patch };
|
|
736
|
+
writeRootConfig(config);
|
|
737
|
+
return resolveWatchConfig(config.watch, account);
|
|
738
|
+
}
|
|
739
|
+
|
|
596
740
|
/** Set the safety level globally, on an account, or on a tenant. */
|
|
597
741
|
export function setSafetyLevel(
|
|
598
742
|
level: SafetyLevel,
|