@aion0/forge 0.8.1 → 0.8.2

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.
Files changed (45) hide show
  1. package/RELEASE_NOTES.md +25 -6
  2. package/app/api/connectors/[id]/settings/route.ts +31 -37
  3. package/app/api/connectors/[id]/test/route.ts +260 -0
  4. package/app/api/connectors/install-local/route.ts +211 -0
  5. package/app/api/connectors/marketplace/route.ts +79 -0
  6. package/app/api/connectors/route.ts +41 -46
  7. package/app/api/jobs/route.ts +1 -0
  8. package/app/api/skills/install-local/route.ts +282 -0
  9. package/components/ConnectorsPanel.tsx +526 -211
  10. package/components/SettingsModal.tsx +1 -0
  11. package/components/SkillsPanel.tsx +42 -1
  12. package/lib/agents/claude-adapter.ts +4 -0
  13. package/lib/agents/types.ts +6 -0
  14. package/lib/chat/agent-loop.ts +13 -22
  15. package/lib/chat/protocols/http.ts +1 -1
  16. package/lib/chat/protocols/shell.ts +1 -1
  17. package/lib/chat/tool-dispatcher.ts +20 -20
  18. package/lib/connectors/migration.ts +110 -0
  19. package/lib/connectors/registry.ts +328 -0
  20. package/lib/connectors/sync.ts +305 -0
  21. package/lib/connectors/types.ts +253 -0
  22. package/lib/help-docs/00-overview.md +1 -0
  23. package/lib/help-docs/17-connectors.md +241 -189
  24. package/lib/help-docs/21-build-connector.md +314 -0
  25. package/lib/help-docs/CLAUDE.md +4 -2
  26. package/lib/init.ts +25 -0
  27. package/lib/jobs/dispatcher.ts +28 -8
  28. package/lib/jobs/scheduler.ts +21 -3
  29. package/lib/jobs/store.ts +11 -2
  30. package/lib/jobs/types.ts +12 -0
  31. package/lib/pipeline-scheduler.ts +3 -2
  32. package/lib/pipeline.ts +135 -13
  33. package/lib/plugins/registry.ts +9 -42
  34. package/lib/plugins/types.ts +4 -129
  35. package/lib/settings.ts +7 -0
  36. package/lib/skills.ts +27 -1
  37. package/lib/task-manager.ts +62 -2
  38. package/package.json +3 -1
  39. package/src/core/db/database.ts +4 -0
  40. package/lib/builtin-plugins/github-api.yaml +0 -93
  41. package/lib/builtin-plugins/gitlab.yaml +0 -860
  42. package/lib/builtin-plugins/mantis.probe.js +0 -176
  43. package/lib/builtin-plugins/mantis.yaml +0 -964
  44. package/lib/builtin-plugins/pmdb.yaml +0 -178
  45. package/lib/builtin-plugins/teams.yaml +0 -913
@@ -1,913 +0,0 @@
1
- id: teams
2
- name: Microsoft Teams
3
- icon: "💬"
4
- version: "0.8.1"
5
- author: forge
6
- category: connector
7
- mode: browser-side
8
- runner: isolated
9
- description: |
10
- Microsoft Teams web (v2 UI). Reuses the user's logged-in session via
11
- the runner's ISOLATED world — page CSP doesn't apply, but window
12
- globals (MSAL token etc.) are NOT accessible. Pure DOM extraction.
13
-
14
- Wait strategy:
15
- - Runner checks chrome.tabs.query first and reuses any existing
16
- Teams tab via host_match.
17
- - Each script waits up to ~30s for Teams hydration before extracting,
18
- so cold-load is handled.
19
- - Switching chats/channels polls up to 30s for the new content to
20
- render (Teams can be slow on enterprise networks).
21
-
22
- Selectors verified on teams.microsoft.com/v2/ via chrome-devtools-mcp.
23
- Microsoft rotates Fluent UI hooks — when extraction starts returning
24
- empty, re-probe and bump this manifest.
25
-
26
- settings:
27
- base_url:
28
- type: string
29
- label: Teams base URL
30
- description: "Usually https://teams.microsoft.com"
31
- default: "https://teams.microsoft.com"
32
- required: true
33
-
34
- host_match: "{base_url}/*"
35
- login_redirect: "login.microsoftonline.com"
36
-
37
- tools:
38
- list_chats:
39
- description: |
40
- List chats from the left rail, grouped by section (Favorites,
41
- Chats, Meeting threads, Teams and channels). Returns whatever
42
- Teams has rendered — virtualized for very long lists.
43
- parameters:
44
- sections:
45
- type: string
46
- label: Sections to include (comma-separated)
47
- description: "Defaults to 'Favorites,Chats'. Use 'all' for everything."
48
- default: "Favorites,Chats"
49
- limit:
50
- type: number
51
- default: 100
52
- returns: "{ sections: [{ name, items: [{ title }] }], total }"
53
- page:
54
- url: "{base_url}/v2/"
55
- on_target: "teams.microsoft.com"
56
- settle_after_load_ms: 5000
57
- script: |
58
- // Wait for left rail tree to populate (handles cold Teams load)
59
- const tStart = Date.now();
60
- while (Date.now() - tStart < 30000) {
61
- if (document.querySelectorAll('[role="treeitem"][aria-level="2"]').length > 0) break;
62
- await new Promise(r => setTimeout(r, 500));
63
- }
64
-
65
- const sectionFilter = String(args.sections || 'Favorites,Chats').toLowerCase();
66
- const wantAll = sectionFilter === 'all';
67
- const wanted = new Set(sectionFilter.split(',').map(s => s.trim().toLowerCase()));
68
-
69
- const level1 = Array.from(document.querySelectorAll('[role="treeitem"][aria-level="1"]'));
70
- const sections = level1.map(group => {
71
- const firstLine = (group.innerText || '').split('\n')[0].trim();
72
- const items = Array.from(group.querySelectorAll('[role="treeitem"][aria-level="2"]'))
73
- .map(t => ({
74
- title: (t.innerText || '').split('\n')[0].trim(),
75
- data_tid: t.getAttribute('data-tid') || '',
76
- id: t.id || '',
77
- }))
78
- .filter(it => it.title);
79
- return { name: firstLine, items };
80
- }).filter(s => wantAll || wanted.has(s.name.toLowerCase()));
81
-
82
- const total = sections.reduce((n, s) => n + s.items.length, 0);
83
- const limit = args.limit || 100;
84
- let remaining = limit;
85
- const sliced = sections.map(s => {
86
- const take = Math.max(0, remaining);
87
- const out = { name: s.name, items: s.items.slice(0, take) };
88
- remaining -= out.items.length;
89
- return out;
90
- });
91
-
92
- return {
93
- sections: sliced,
94
- total,
95
- _virtualized: true,
96
- _waited_ms: Date.now() - tStart,
97
- _page: location.href,
98
- };
99
-
100
- list_messages_in_current_chat:
101
- description: |
102
- Extract messages from the currently-open chat (the one visible in
103
- the main pane). Returns id, author, body, timestamp. The user
104
- should open the target chat in Teams first.
105
-
106
- Prefer `read_chat(name)` instead — it does the switching for you.
107
- parameters:
108
- limit:
109
- type: number
110
- label: Max messages (most recent)
111
- default: 50
112
- returns: "{ chat_title, messages: [...], total, _virtualized }"
113
- page:
114
- url: "{base_url}/v2/"
115
- on_target: "teams.microsoft.com"
116
- settle_after_load_ms: 5000
117
- script: |
118
- // Wait up to 30s for chat content to render
119
- const tStart = Date.now();
120
- while (Date.now() - tStart < 30000) {
121
- if (document.querySelectorAll('[data-tid="chat-pane-item"]').length > 0) break;
122
- await new Promise(r => setTimeout(r, 500));
123
- }
124
-
125
- const items = Array.from(document.querySelectorAll('[data-tid="chat-pane-item"]'));
126
- const messages = items.map(item => {
127
- const msg = item.querySelector('[data-tid="chat-pane-message"]');
128
- const mid = msg?.getAttribute('data-mid') || '';
129
- const authorEl = item.querySelector('[data-tid="message-author-name"]');
130
- const contentEl = mid ? document.getElementById('content-' + mid) : null;
131
- const tsEl = mid ? document.getElementById('timestamp-' + mid) : null;
132
- const subjectEl = mid ? document.getElementById('subject-line-' + mid) : null;
133
- const reactionEls = item.querySelectorAll('[data-tid="diverse-reaction-pill-button"], [data-tid*="reaction-pill"]');
134
- const reactions = Array.from(reactionEls).map(r => r.getAttribute('aria-label') || r.textContent.trim()).filter(Boolean);
135
- return {
136
- mid,
137
- author: authorEl?.innerText.trim() || '',
138
- body: contentEl?.innerText.trim() || msg?.innerText.trim() || '',
139
- subject: subjectEl?.innerText.trim() || '',
140
- timestamp: tsEl?.getAttribute('title') || tsEl?.innerText.trim() || '',
141
- reactions,
142
- };
143
- }).filter(m => m.body || m.author);
144
-
145
- const headerParticipant = document.querySelector('[data-tid^="participant-"]');
146
- let chatTitle = '';
147
- if (headerParticipant) {
148
- const node = headerParticipant.closest('div[class*="container"], div[class*="header"]');
149
- chatTitle = (node?.innerText || headerParticipant.innerText || '').split('\n')[0].trim();
150
- }
151
-
152
- const limit = args.limit || 50;
153
- return {
154
- chat_title: chatTitle,
155
- messages: messages.slice(-limit),
156
- total: messages.length,
157
- _virtualized: true,
158
- _waited_ms: Date.now() - tStart,
159
- _page: location.href,
160
- };
161
-
162
- get_current_chat_info:
163
- description: |
164
- Identify the currently-open chat — title, participants if visible.
165
- Useful as a quick "where am I" call before list_messages.
166
- returns: "{ chat_title, participants, url }"
167
- page:
168
- url: "{base_url}/v2/"
169
- on_target: "teams.microsoft.com"
170
- settle_after_load_ms: 5000
171
- script: |
172
- const tStart = Date.now();
173
- while (Date.now() - tStart < 30000) {
174
- if (document.querySelector('[data-tid^="participant-"]')) break;
175
- await new Promise(r => setTimeout(r, 500));
176
- }
177
-
178
- const participantEls = Array.from(document.querySelectorAll('[data-tid^="participant-"]'));
179
- const participants = participantEls.map(p => ({
180
- name: (p.innerText || '').trim().split('\n')[0],
181
- data_tid: p.getAttribute('data-tid') || '',
182
- })).filter(p => p.name);
183
-
184
- const seen = new Set();
185
- const unique = participants.filter(p => {
186
- if (seen.has(p.data_tid)) return false;
187
- seen.add(p.data_tid);
188
- return true;
189
- });
190
-
191
- const chatTitle = unique.map(p => p.name).join(', ');
192
- return { chat_title: chatTitle, participants: unique, url: location.href };
193
-
194
- read_chat:
195
- description: |
196
- Switch to a specific 1:1 or group chat by name (substring match,
197
- case-insensitive) and extract its messages. Combines open + read
198
- into one tool — no need to open the chat manually first.
199
-
200
- Waits up to 30s for Teams to hydrate (handles cold load) and up
201
- to 30s for the chat switch to complete.
202
- parameters:
203
- name:
204
- type: string
205
- label: Chat name (substring, case-insensitive — e.g. "Alice Wang", "Eng Leaders")
206
- required: true
207
- limit:
208
- type: number
209
- label: Max messages (most recent)
210
- default: 50
211
- returns: "{ ok, chat_title, messages: [...], total, switched_in_ms, waited_for_ready_ms }"
212
- page:
213
- url: "{base_url}/v2/"
214
- on_target: "teams.microsoft.com"
215
- settle_after_load_ms: 5000
216
- script: |
217
- const name = String(args.name || '').trim();
218
- if (!name) return { ok: false, error: 'name parameter is required' };
219
-
220
- // 1. Wait for Teams left rail to be ready (cold load handling)
221
- const readyStart = Date.now();
222
- while (Date.now() - readyStart < 30000) {
223
- if (document.querySelectorAll('[role="treeitem"][aria-level="2"]').length > 0) break;
224
- await new Promise(r => setTimeout(r, 500));
225
- }
226
- const waitedForReadyMs = Date.now() - readyStart;
227
- if (document.querySelectorAll('[role="treeitem"][aria-level="2"]').length === 0) {
228
- return { ok: false, error: 'Teams did not finish loading in 30s — try again or refresh the tab.' };
229
- }
230
-
231
- // 2. Find candidate treeitem in the left rail
232
- const candidates = [...document.querySelectorAll('[role="treeitem"][aria-level="2"]')];
233
- const lower = name.toLowerCase();
234
- const match = candidates.find(t => {
235
- const first = (t.innerText || '').split('\n')[0].trim().toLowerCase();
236
- return first.includes(lower);
237
- });
238
- if (!match) {
239
- return {
240
- ok: false,
241
- error: `No chat in left rail matching "${name}". Either the chat is in "See more", or not in your visible list.`,
242
- visible_chats: candidates.slice(0, 30).map(c => (c.innerText || '').split('\n')[0].trim()).filter(Boolean),
243
- };
244
- }
245
-
246
- // 3. If already on this chat, skip the click+wait
247
- const currentTitle = document.title;
248
- const alreadyHere = currentTitle.toLowerCase().includes(lower);
249
-
250
- let waitedMs = 0;
251
- if (!alreadyHere) {
252
- const oldMids = new Set(
253
- [...document.querySelectorAll('[data-tid="chat-pane-message"]')]
254
- .map(e => e.getAttribute('data-mid'))
255
- .filter(Boolean)
256
- );
257
-
258
- match.click();
259
-
260
- // Poll up to 30s for new mids to appear
261
- let switched = false;
262
- for (let i = 0; i < 150; i++) {
263
- await new Promise(r => setTimeout(r, 200));
264
- waitedMs += 200;
265
- const cur = [...document.querySelectorAll('[data-tid="chat-pane-message"]')]
266
- .map(e => e.getAttribute('data-mid'))
267
- .filter(Boolean);
268
- if (cur.length > 0 && cur.some(m => !oldMids.has(m))) { switched = true; break; }
269
- }
270
- if (!switched) {
271
- return { ok: false, error: 'Timed out waiting for chat to switch (30s).', target_name: name };
272
- }
273
- }
274
-
275
- // 4. Extract messages
276
- const items = [...document.querySelectorAll('[data-tid="chat-pane-item"]')];
277
- const messages = items.map(item => {
278
- const msg = item.querySelector('[data-tid="chat-pane-message"]');
279
- const mid = msg?.getAttribute('data-mid') || '';
280
- const authorEl = item.querySelector('[data-tid="message-author-name"]');
281
- const contentEl = mid ? document.getElementById('content-' + mid) : null;
282
- const tsEl = mid ? document.getElementById('timestamp-' + mid) : null;
283
- const reactionEls = item.querySelectorAll('[data-tid="diverse-reaction-pill-button"], [data-tid*="reaction-pill"]');
284
- const reactions = [...reactionEls].map(r => r.getAttribute('aria-label') || r.textContent.trim()).filter(Boolean);
285
- return {
286
- mid,
287
- author: authorEl?.innerText.trim() || '',
288
- body: contentEl?.innerText.trim() || msg?.innerText.trim() || '',
289
- timestamp: tsEl?.getAttribute('title') || tsEl?.innerText.trim() || '',
290
- reactions,
291
- };
292
- }).filter(m => m.body || m.author);
293
-
294
- const titleParts = (document.title || '').split('|').map(s => s.trim()).filter(Boolean);
295
- const chatTitle = titleParts.length >= 3 ? titleParts[1] : (titleParts[0] || '');
296
-
297
- const limit = args.limit || 50;
298
- return {
299
- ok: true,
300
- chat_title: chatTitle,
301
- matched_name: (match.innerText || '').split('\n')[0].trim(),
302
- switched: !alreadyHere,
303
- switched_in_ms: waitedMs,
304
- waited_for_ready_ms: waitedForReadyMs,
305
- messages: messages.slice(-limit),
306
- total: messages.length,
307
- _virtualized: true,
308
- _page: location.href,
309
- };
310
-
311
- read_channel:
312
- description: |
313
- Switch to a specific channel by team name + channel name and
314
- extract its top-level posts. The team must be expanded in the
315
- left rail (collapsed teams have no channels in DOM).
316
- parameters:
317
- team:
318
- type: string
319
- label: Team name (substring, case-insensitive)
320
- required: true
321
- channel:
322
- type: string
323
- label: Channel name (substring, case-insensitive)
324
- required: true
325
- limit:
326
- type: number
327
- default: 20
328
- returns: "{ ok, team, channel, posts: [...] }"
329
- page:
330
- url: "{base_url}/v2/"
331
- on_target: "teams.microsoft.com"
332
- settle_after_load_ms: 5000
333
- script: |
334
- const teamName = String(args.team || '').trim();
335
- const channelName = String(args.channel || '').trim();
336
- if (!teamName || !channelName) {
337
- return { ok: false, error: 'team and channel parameters are required' };
338
- }
339
-
340
- // Wait for Teams to be ready
341
- const readyStart = Date.now();
342
- while (Date.now() - readyStart < 30000) {
343
- if (document.querySelectorAll('[role="treeitem"][aria-level="2"]').length > 0) break;
344
- await new Promise(r => setTimeout(r, 500));
345
- }
346
- if (document.querySelectorAll('[role="treeitem"][aria-level="2"]').length === 0) {
347
- return { ok: false, error: 'Teams did not finish loading in 30s.' };
348
- }
349
-
350
- const level1 = [...document.querySelectorAll('[role="treeitem"][aria-level="1"]')];
351
- const tnc = level1.find(t => /teams\s*and\s*channels/i.test((t.innerText || '').split('\n')[0]));
352
- if (!tnc) return { ok: false, error: 'Teams and channels section not found' };
353
-
354
- const teams = [...tnc.querySelectorAll('[role="treeitem"][aria-level="2"]')];
355
- const team = teams.find(t => (t.innerText || '').split('\n')[0].toLowerCase().includes(teamName.toLowerCase()));
356
- if (!team) {
357
- return {
358
- ok: false,
359
- error: `Team "${teamName}" not found`,
360
- visible_teams: teams.map(t => (t.innerText || '').split('\n')[0].trim()),
361
- };
362
- }
363
-
364
- const channels = [...team.querySelectorAll('[role="treeitem"][aria-level="3"]')];
365
- if (channels.length === 0) {
366
- return {
367
- ok: false,
368
- error: `Team "${teamName}" is collapsed — no channels in DOM. Expand it in Teams first.`,
369
- };
370
- }
371
- const channel = channels.find(c => (c.innerText || '').split('\n')[0].toLowerCase().includes(channelName.toLowerCase()));
372
- if (!channel) {
373
- return {
374
- ok: false,
375
- error: `Channel "${channelName}" not found under team "${teamName}"`,
376
- visible_channels: channels.map(c => (c.innerText || '').split('\n')[0].trim()),
377
- };
378
- }
379
-
380
- const alreadyHere = document.title.toLowerCase().includes(channelName.toLowerCase());
381
- let waitedMs = 0;
382
- if (!alreadyHere) {
383
- const oldKeys = new Set(
384
- [...document.querySelectorAll('[data-tid="channel-pane-message"]')]
385
- .map(e => e.id).filter(Boolean)
386
- );
387
- channel.click();
388
- let switched = false;
389
- for (let i = 0; i < 150; i++) {
390
- await new Promise(r => setTimeout(r, 200));
391
- waitedMs += 200;
392
- const cur = [...document.querySelectorAll('[data-tid="channel-pane-message"]')]
393
- .map(e => e.id).filter(Boolean);
394
- if (cur.length > 0 && cur.some(k => !oldKeys.has(k))) { switched = true; break; }
395
- }
396
- if (!switched) {
397
- return { ok: false, error: 'Timed out waiting for channel to load (30s).' };
398
- }
399
- }
400
-
401
- const posts = [...document.querySelectorAll('[data-tid="channel-pane-message"]')];
402
- const parsed = posts.map(post => {
403
- const midMatch = (post.id || '').match(/reply-chain-summary-(\d+)/);
404
- const mid = midMatch ? midMatch[1] : '';
405
- const subject = post.querySelector('[data-tid="subject-line"]')?.textContent.trim() || '';
406
- const subheader = post.querySelector('[data-tid="post-message-subheader"]')?.textContent.trim() || '';
407
- const ts = post.querySelector('[data-tid="timestamp"]')?.textContent.trim() || '';
408
- const allBodies = [...post.querySelectorAll('[data-tid="message-body"]')];
409
- const bodyEl = allBodies.find(b => b.closest('[role="group"]')?.id === post.id) || allBodies[0];
410
- const author = subheader.replace(new RegExp('\\s*' + ts.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '.*$'), '').replace(/(Edited|New).*$/, '').trim();
411
- const replyContainers = [...post.querySelectorAll('[role="group"][id^="message-body-"]')].filter(r => !r.id.includes(mid));
412
- const replies = replyContainers.map(r => {
413
- const rMid = (r.id.match(/message-body-(\d+)/) || [])[1] || '';
414
- return {
415
- mid: rMid,
416
- author: (rMid ? document.getElementById('author-' + rMid)?.textContent.trim() : '') || '',
417
- body: (rMid ? document.getElementById('content-' + rMid)?.innerText.trim() : '') || r.querySelector('[data-tid="message-body"]')?.innerText.trim() || '',
418
- timestamp: r.querySelector('[data-tid="timestamp"]')?.textContent.trim() || '',
419
- };
420
- });
421
- return { mid, subject, author, timestamp: ts, body: (bodyEl?.innerText || '').trim(), replies };
422
- });
423
-
424
- const ordered = parsed.slice().reverse();
425
- const titleParts = (document.title || '').split('|').map(s => s.trim()).filter(Boolean);
426
- const channelTitle = titleParts.length >= 3 ? titleParts[1] : (titleParts[0] || '');
427
-
428
- return {
429
- ok: true,
430
- team: (team.innerText || '').split('\n')[0].trim(),
431
- channel: channelTitle,
432
- switched: !alreadyHere,
433
- switched_in_ms: waitedMs,
434
- posts: ordered.slice(0, args.limit || 20),
435
- total: parsed.length,
436
- _page: location.href,
437
- };
438
-
439
- list_teams_and_channels:
440
- description: |
441
- List joined Teams (organizations) and their visible channels from
442
- the left rail. Channels under collapsed teams aren't in DOM —
443
- expand the team in Teams to render them.
444
- parameters:
445
- limit:
446
- type: number
447
- default: 50
448
- returns: "{ teams: [{ name, unread, channels: [{ name, unread }] }], total }"
449
- page:
450
- url: "{base_url}/v2/"
451
- on_target: "teams.microsoft.com"
452
- settle_after_load_ms: 5000
453
- script: |
454
- const tStart = Date.now();
455
- while (Date.now() - tStart < 30000) {
456
- if (document.querySelectorAll('[role="treeitem"][aria-level="2"]').length > 0) break;
457
- await new Promise(r => setTimeout(r, 500));
458
- }
459
-
460
- const level1 = Array.from(document.querySelectorAll('[role="treeitem"][aria-level="1"]'));
461
- const tnc = level1.find(t => /teams\s*and\s*channels/i.test((t.innerText || '').split('\n')[0]));
462
- if (!tnc) {
463
- return { teams: [], total: 0, _error: 'Teams and channels section not found in left rail' };
464
- }
465
- const teamItems = Array.from(tnc.querySelectorAll('[role="treeitem"][aria-level="2"]'));
466
- const teams = teamItems.map(team => {
467
- const ariaLabel = team.getAttribute('aria-label') || '';
468
- const visibleText = (team.innerText || '').split('\n')[0];
469
- const label = ariaLabel || visibleText;
470
- const unread = /\bUnread\b/.test(visibleText) || /\bUnread\b/.test(ariaLabel);
471
- const name = label.replace(/^Unread\s+/, '').replace(/^Team\s+/, '').replace(/\s+Has context menu$/, '').trim();
472
- const channelEls = Array.from(team.querySelectorAll('[role="treeitem"][aria-level="3"]'));
473
- const channels = channelEls.map(c => {
474
- const cAriaLabel = c.getAttribute('aria-label') || '';
475
- const cVisible = (c.innerText || '').split('\n')[0];
476
- const cLabel = cAriaLabel || cVisible;
477
- const cUnread = /\bUnread\b/.test(cVisible) || /\bUnread\b/.test(cAriaLabel);
478
- const cName = cLabel.replace(/^Unread\s+/, '').replace(/^Channel\s+/, '').replace(/\s+Channel at mention$/, '').replace(/\s+Has context menu$/, '').trim();
479
- return { name: cName, unread: cUnread };
480
- }).filter(c => c.name && c.name !== 'See all channels');
481
- return { name, unread, channels, channels_visible: channels.length, channels_collapsed: channelEls.length === 0 };
482
- }).filter(t => t.name && t.name !== 'See all your teams');
483
- return {
484
- teams: teams.slice(0, args.limit || 50),
485
- total: teams.length,
486
- _virtualized_channels: true,
487
- _page: location.href,
488
- };
489
-
490
- list_channel_posts:
491
- description: |
492
- Read top-level posts from the currently-open channel. For each
493
- post, returns subject, author, timestamp, body, visible inline
494
- replies, and a hint about hidden reply count.
495
-
496
- Prefer `read_channel(team, channel)` to do switch+read in one tool.
497
- parameters:
498
- limit:
499
- type: number
500
- default: 20
501
- returns: "{ channel, posts: [...], total }"
502
- page:
503
- url: "{base_url}/v2/"
504
- on_target: "teams.microsoft.com"
505
- settle_after_load_ms: 5000
506
- script: |
507
- // Wait up to 30s for channel content to render
508
- const tStart = Date.now();
509
- while (Date.now() - tStart < 30000) {
510
- if (document.querySelectorAll('[data-tid="channel-pane-message"]').length > 0) break;
511
- await new Promise(r => setTimeout(r, 500));
512
- }
513
-
514
- const posts = Array.from(document.querySelectorAll('[data-tid="channel-pane-message"]'));
515
- const titleParts = (document.title || '').split('|').map(s => s.trim()).filter(Boolean);
516
- const channelName = titleParts.length >= 3 ? titleParts[1] : (titleParts[0] || '');
517
-
518
- const parsed = posts.map(post => {
519
- const midMatch = (post.id || '').match(/reply-chain-summary-(\d+)/);
520
- const mid = midMatch ? midMatch[1] : '';
521
- const subjectEl = post.querySelector('[data-tid="subject-line"]');
522
- const subheaderEl = post.querySelector('[data-tid="post-message-subheader"]');
523
- const tsEl = post.querySelector('[data-tid="timestamp"]');
524
- const allBodies = Array.from(post.querySelectorAll('[data-tid="message-body"]'));
525
- const postBody = allBodies.find(b => {
526
- const closest = b.closest('[role="group"]');
527
- return closest === null || closest.id === post.id;
528
- }) || allBodies[0];
529
- const subheaderText = subheaderEl?.textContent.trim() || '';
530
- const tsText = tsEl?.textContent.trim() || '';
531
- const author = subheaderText.replace(new RegExp('\\s*' + tsText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '.*$'), '').replace(/(Edited|New).*$/, '').trim();
532
- const replyContainers = Array.from(post.querySelectorAll('[role="group"][id^="message-body-"]'))
533
- .filter(r => !r.id.includes(mid));
534
- const replies = replyContainers.map(r => {
535
- const rMid = (r.id.match(/message-body-(\d+)/) || [])[1] || '';
536
- const rTs = r.querySelector('[data-tid="timestamp"]');
537
- const rContent = rMid ? document.getElementById('content-' + rMid) : null;
538
- const rAuthor = rMid ? document.getElementById('author-' + rMid) : null;
539
- return {
540
- mid: rMid,
541
- author: rAuthor?.textContent.trim() || '',
542
- timestamp: rTs?.textContent.trim() || '',
543
- body: (rContent?.innerText || r.querySelector('[data-tid="message-body"]')?.innerText || '').trim(),
544
- };
545
- });
546
- let openRepliesBtn = null;
547
- const parent = post.parentElement;
548
- if (parent) {
549
- const allButtons = parent.querySelectorAll('button[aria-label*="replies from"], button[aria-label*="Open"][aria-label*="replies"]');
550
- for (const btn of allButtons) {
551
- if (post.compareDocumentPosition(btn) & Node.DOCUMENT_POSITION_FOLLOWING) {
552
- openRepliesBtn = btn;
553
- break;
554
- }
555
- }
556
- }
557
- const moreLabel = openRepliesBtn?.getAttribute('aria-label') || '';
558
- const moreMatch = moreLabel.match(/(\d+)\s+replies/);
559
- const totalReplyCount = moreMatch ? Number(moreMatch[1]) : replies.length;
560
- const hiddenReplies = Math.max(0, totalReplyCount - replies.length);
561
-
562
- return {
563
- mid,
564
- subject: subjectEl?.textContent.trim() || '',
565
- author,
566
- timestamp: tsText,
567
- body: (postBody?.innerText || '').trim(),
568
- replies,
569
- more_replies: hiddenReplies > 0 ? hiddenReplies : 0,
570
- };
571
- });
572
-
573
- const ordered = parsed.slice().reverse();
574
- return {
575
- channel: channelName,
576
- posts: ordered.slice(0, args.limit || 20),
577
- total: parsed.length,
578
- _virtualized: true,
579
- _waited_ms: Date.now() - tStart,
580
- _page: location.href,
581
- };
582
-
583
- _probe:
584
- description: |
585
- Diagnostic — returns the current Teams page state without touching
586
- anything. Use to debug failed send_message / read_chat. Reports:
587
- where we landed (URL + title), composer + send-button presence,
588
- current chat header info, left-rail group + chat counts, top search
589
- input + popup state, message stream height. Returns rich JSON; run
590
- it after a failed call and share the output.
591
- parameters: {}
592
- returns: "{ url, title, ready, composer, send_btn, chat_header, left_rail, search, messages, time_ms }"
593
- page:
594
- url: "{base_url}/v2/"
595
- on_target: "teams.microsoft.com"
596
- settle_after_load_ms: 2000
597
- script: |
598
- const t0 = Date.now();
599
- function quickCount(sel) { try { return document.querySelectorAll(sel).length; } catch { return 0; } }
600
- function quickEl(sel) {
601
- try {
602
- const el = document.querySelector(sel);
603
- if (!el) return null;
604
- return {
605
- present: true,
606
- disabled: el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true',
607
- dataTid: el.getAttribute('data-tid'),
608
- ariaLabel: (el.getAttribute('aria-label') || '').slice(0, 60),
609
- text: (el.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 80),
610
- };
611
- } catch { return null; }
612
- }
613
- const lefRailLevel1 = [...document.querySelectorAll('[role="treeitem"][aria-level="1"]')].slice(0, 12).map(g => ({
614
- label: (g.innerText || '').split('\n')[0].trim().slice(0, 30),
615
- expanded: g.getAttribute('aria-expanded'),
616
- }));
617
- const composer = quickEl('[role="textbox"][contenteditable="true"][data-tid="ckeditor"]');
618
- const sendBtn = quickEl('[data-tid="sendMessageCommands-send"]');
619
- const chatHeader = quickEl('[data-tid^="participant-"]') || quickEl('[role="banner"]');
620
- const popup = document.querySelector('[data-tid="ms-searchux-popup"]');
621
- const messageStream = document.querySelector('[role="log"], [data-tid="message-pane-list-viewport"]');
622
- return {
623
- ok: true,
624
- url: location.href,
625
- title: document.title,
626
- ready: !!composer && !!sendBtn,
627
- composer,
628
- send_btn: sendBtn,
629
- chat_header: chatHeader,
630
- left_rail: {
631
- level_1_count: quickCount('[role="treeitem"][aria-level="1"]'),
632
- level_2_count: quickCount('[role="treeitem"][aria-level="2"]'),
633
- groups: lefRailLevel1,
634
- },
635
- search: {
636
- input: !!document.querySelector('#ms-searchux-input'),
637
- toggle_btn: !!document.querySelector('[data-tid="title-bar-toggle-search-btn"]'),
638
- popup_present: !!popup,
639
- popup_text_len: popup ? (popup.innerText || '').length : 0,
640
- },
641
- messages: {
642
- stream_found: !!messageStream,
643
- scroll_height: messageStream?.scrollHeight ?? 0,
644
- li_count: quickCount('[role="log"] [role="listitem"], [data-tid="message-pane-list-viewport"] [data-tid^="chat-pane-message"]'),
645
- },
646
- ckeditor_classes_seen: [...document.querySelectorAll('[data-tid="ckeditor"]')].map(e => e.className.toString().slice(0, 30)).slice(0, 3),
647
- time_ms: Date.now() - t0,
648
- };
649
-
650
- send_message:
651
- description: |
652
- Send a text message. Three resolution tiers for `name`:
653
- 1. Left-rail treeitem (existing 1:1 / group chat).
654
- 2. Expand any collapsed left-rail GROUP heading (aria-expanded=false
655
- on level-1 items like "Favorites", "Chats", "Teams and channels")
656
- and retry tier 1 — fixes the case where the chat is hidden under
657
- a folded section.
658
- 3. Top search box (#ms-searchux-input) — type the name, pick the
659
- first matching person/chat from the suggestion popup, click it
660
- to open (creating a NEW 1:1 chat if none exists).
661
-
662
- Without `name`: send to the currently-open chat.
663
-
664
- ⚠ Sends IMMEDIATELY without user confirmation. Caller is responsible
665
- for verifying the target and content before invoking. Plain text only
666
- — CKEditor 5 paste injection + click on the Send button.
667
- parameters:
668
- text:
669
- type: string
670
- label: Message text
671
- required: true
672
- name:
673
- type: string
674
- label: Chat name (optional — substring, case-insensitive). If omitted, sends to the currently-open chat.
675
- required: false
676
- returns: "{ ok, sent_text, chat_title, switched, switched_in_ms, resolve_tier, groups_expanded, search_picked }"
677
- page:
678
- url: "{base_url}/v2/"
679
- on_target: "teams.microsoft.com"
680
- settle_after_load_ms: 5000
681
- script: |
682
- const text = String(args.text || '').trim();
683
- if (!text) return { ok: false, error: 'text is required and must be non-empty' };
684
- const name = String(args.name || '').trim();
685
-
686
- // Wait for Teams ready
687
- const readyStart = Date.now();
688
- while (Date.now() - readyStart < 30000) {
689
- if (document.querySelector('[role="textbox"][contenteditable="true"][data-tid="ckeditor"]')) break;
690
- if (document.querySelectorAll('[role="treeitem"][aria-level="2"]').length > 0 && name) break;
691
- await new Promise(r => setTimeout(r, 500));
692
- }
693
-
694
- // ─── Resolve target chat by name (3-tier) ─────────────────────
695
- let switched = false;
696
- let switchedMs = 0;
697
- let resolveTier = null; // 'already-here' / 'left-rail' / 'expanded-group' / 'search'
698
- let groupsExpanded = [];
699
- let searchPicked = null;
700
-
701
- function findLeftRailMatch(lower) {
702
- const items = [...document.querySelectorAll('[role="treeitem"][aria-level="2"]')];
703
- return items.find(t => (t.innerText || '').split('\n')[0].trim().toLowerCase().includes(lower)) || null;
704
- }
705
- async function waitForChatSwitch(lowerName, titleBefore, maxMs = 30000) {
706
- const t0 = Date.now();
707
- while (Date.now() - t0 < maxMs) {
708
- await new Promise(r => setTimeout(r, 200));
709
- if (document.title !== titleBefore &&
710
- (!lowerName || document.title.toLowerCase().includes(lowerName))) {
711
- return Date.now() - t0;
712
- }
713
- }
714
- return -1;
715
- }
716
-
717
- if (name) {
718
- const lower = name.toLowerCase();
719
- if (document.title.toLowerCase().includes(lower)) {
720
- resolveTier = 'already-here';
721
- } else {
722
- // Tier 1: left rail as-is
723
- let match = findLeftRailMatch(lower);
724
- // Tier 2: expand collapsed level-1 group headings ("Favorites",
725
- // "Chats", "Teams and channels", …) then retry the search.
726
- if (!match) {
727
- const collapsed = [...document.querySelectorAll('[role="treeitem"][aria-level="1"][aria-expanded="false"]')];
728
- for (const g of collapsed) {
729
- const label = (g.innerText || '').split('\n')[0].trim();
730
- try { g.click(); groupsExpanded.push(label); } catch {}
731
- await new Promise(r => setTimeout(r, 250));
732
- }
733
- if (groupsExpanded.length > 0) {
734
- // Settle for the newly-revealed treeitems to mount
735
- await new Promise(r => setTimeout(r, 600));
736
- match = findLeftRailMatch(lower);
737
- }
738
- }
739
-
740
- if (match) {
741
- resolveTier = groupsExpanded.length > 0 ? 'expanded-group' : 'left-rail';
742
- const titleBefore = document.title;
743
- match.click();
744
- const took = await waitForChatSwitch(lower, titleBefore);
745
- if (took < 0) {
746
- return { ok: false, error: 'Timed out waiting for chat switch (30s).', target_name: name, resolve_tier: resolveTier, groups_expanded: groupsExpanded };
747
- }
748
- switchedMs = took; switched = true;
749
- await new Promise(r => setTimeout(r, 800)); // composer remount settle
750
- } else {
751
- // Tier 3: top search box. Open it (some Teams layouts collapse
752
- // the search bar behind a button), then type and pick.
753
- let searchInput = document.querySelector('#ms-searchux-input');
754
- if (!searchInput) {
755
- const toggle = document.querySelector('[data-tid="title-bar-toggle-search-btn"]');
756
- if (toggle) {
757
- try { toggle.click(); } catch {}
758
- for (let i = 0; i < 20 && !searchInput; i++) {
759
- await new Promise(r => setTimeout(r, 150));
760
- searchInput = document.querySelector('#ms-searchux-input');
761
- }
762
- }
763
- }
764
- if (!searchInput) {
765
- return {
766
- ok: false,
767
- error: `No left-rail match for "${name}" and search input not found. Open the chat manually first.`,
768
- groups_expanded: groupsExpanded,
769
- visible_chats: [...document.querySelectorAll('[role="treeitem"][aria-level="2"]')].slice(0, 30).map(c => (c.innerText || '').split('\n')[0].trim()).filter(Boolean),
770
- };
771
- }
772
- // Programmatic type — Teams uses React-controlled input and
773
- // sometimes also listens to keyboard events to trigger the
774
- // backend autosuggest fetch. Empirically: synthetic
775
- // \`input\` alone left the popup empty; pairing it with
776
- // beforeinput/keydown/keyup makes Teams actually call the
777
- // search backend.
778
- searchInput.focus(); searchInput.click();
779
- await new Promise(r => setTimeout(r, 200));
780
- const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
781
- // Send char-by-char so React + Teams' autosuggest get every event
782
- // they need (the all-at-once path was unreliable in probing).
783
- nativeSetter.call(searchInput, '');
784
- searchInput.dispatchEvent(new Event('input', { bubbles: true }));
785
- for (let i = 0; i < name.length; i++) {
786
- const ch = name[i];
787
- const partial = name.slice(0, i + 1);
788
- searchInput.dispatchEvent(new KeyboardEvent('keydown', { key: ch, bubbles: true }));
789
- nativeSetter.call(searchInput, partial);
790
- searchInput.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: ch, bubbles: true, cancelable: true }));
791
- searchInput.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: ch, bubbles: true }));
792
- searchInput.dispatchEvent(new KeyboardEvent('keyup', { key: ch, bubbles: true }));
793
- await new Promise(r => setTimeout(r, 40));
794
- }
795
-
796
- // Wait for the suggestion popup to populate (Teams backend call
797
- // takes ~500-2000ms over corp network).
798
- let popup = null;
799
- for (let i = 0; i < 40; i++) {
800
- await new Promise(r => setTimeout(r, 200));
801
- popup = document.querySelector('[data-tid="ms-searchux-popup"]');
802
- if (popup && (popup.innerText || '').trim().length > 30) break;
803
- }
804
- if (!popup) {
805
- // Clear the search box on failure
806
- nativeSetter.call(searchInput, ''); searchInput.dispatchEvent(new Event('input', { bubbles: true }));
807
- return { ok: false, error: 'Search popup did not appear within 8s', name };
808
- }
809
-
810
- // Pick the first item that mentions the name. Teams puts each
811
- // result inside a role-presentation or role-option wrapper with
812
- // the person's display name in innerText.
813
- const lowerName = name.toLowerCase();
814
- const allRows = [...popup.querySelectorAll('[role="option"], [data-tid^="search-suggestion"], [data-tid$="-result"], li, button, div[tabindex="-1"]')];
815
- const seenTexts = new Set();
816
- const candidates = [];
817
- for (const el of allRows) {
818
- const txt = (el.innerText || '').replace(/\s+/g, ' ').trim();
819
- if (!txt || seenTexts.has(txt) || txt.length > 200) continue;
820
- seenTexts.add(txt);
821
- if (txt.toLowerCase().includes(lowerName)) candidates.push({ el, text: txt });
822
- if (candidates.length >= 8) break;
823
- }
824
- if (candidates.length === 0) {
825
- const preview = (popup.innerText || '').slice(0, 400);
826
- nativeSetter.call(searchInput, ''); searchInput.dispatchEvent(new Event('input', { bubbles: true }));
827
- return { ok: false, error: `Search popup had no item matching "${name}"`, popup_text_preview: preview };
828
- }
829
- searchPicked = candidates[0].text;
830
- resolveTier = 'search';
831
-
832
- const titleBefore = document.title;
833
- try { candidates[0].el.click(); } catch {}
834
- // Search popup might intercept the click — also try keyboard Enter as fallback
835
- const took = await waitForChatSwitch(null, titleBefore, 12000);
836
- if (took < 0) {
837
- // Fallback: synthesize Enter key on the search input
838
- searchInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
839
- const took2 = await waitForChatSwitch(null, titleBefore, 8000);
840
- if (took2 < 0) {
841
- return { ok: false, error: 'Clicking the search result did not open a chat', search_picked: searchPicked };
842
- }
843
- switchedMs = took2;
844
- } else {
845
- switchedMs = took;
846
- }
847
- switched = true;
848
- // The composer takes a beat to mount on a fresh 1:1 chat
849
- await new Promise(r => setTimeout(r, 1200));
850
- }
851
- }
852
- }
853
-
854
- // Find composer (might have just remounted after switch)
855
- let composer = document.querySelector('[role="textbox"][contenteditable="true"][data-tid="ckeditor"]');
856
- for (let i = 0; i < 30 && !composer; i++) {
857
- await new Promise(r => setTimeout(r, 200));
858
- composer = document.querySelector('[role="textbox"][contenteditable="true"][data-tid="ckeditor"]');
859
- }
860
- if (!composer) {
861
- return { ok: false, error: 'Compose box not found after waiting.' };
862
- }
863
-
864
- const sendBtn = document.querySelector('[data-tid="sendMessageCommands-send"]');
865
- if (!sendBtn) {
866
- return { ok: false, error: 'Send button not found.' };
867
- }
868
-
869
- // Focus + paste-inject
870
- composer.focus();
871
- const dt = new DataTransfer();
872
- dt.setData('text/plain', text);
873
- const pasteEvent = new ClipboardEvent('paste', {
874
- clipboardData: dt,
875
- bubbles: true,
876
- cancelable: true,
877
- });
878
- composer.dispatchEvent(pasteEvent);
879
- await new Promise(r => setTimeout(r, 300));
880
-
881
- const composerText = composer.innerText.trim();
882
- if (!composerText.includes(text)) {
883
- return {
884
- ok: false,
885
- error: 'Composer did not accept the input — CKEditor may have rejected the paste.',
886
- composer_text: composerText.slice(0, 100),
887
- };
888
- }
889
-
890
- const disabled = sendBtn.hasAttribute('disabled') || sendBtn.getAttribute('aria-disabled') === 'true';
891
- if (disabled) {
892
- return { ok: false, error: 'Send button is still disabled after injection.' };
893
- }
894
-
895
- const headerParticipant = document.querySelector('[data-tid^="participant-"]');
896
- const chatTitle = headerParticipant
897
- ? (headerParticipant.innerText || '').trim().split('\n')[0]
898
- : (document.title.split('|').map(s => s.trim())[1] || '');
899
-
900
- sendBtn.click();
901
- await new Promise(r => setTimeout(r, 400));
902
-
903
- return {
904
- ok: true,
905
- sent_text: text,
906
- chat_title: chatTitle,
907
- switched,
908
- switched_in_ms: switchedMs,
909
- resolve_tier: resolveTier,
910
- groups_expanded: groupsExpanded,
911
- search_picked: searchPicked,
912
- url: location.href,
913
- };