@aion0/forge 0.6.1 → 0.8.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/.forge/mcp.json +8 -0
- package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-316c6574/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-316c6574/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-44a94121/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-44a94121/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/07-projects.md +1 -1
- package/CLAUDE.md +2 -2
- package/RELEASE_NOTES.md +101 -5
- package/app/api/auth/check/route.ts +18 -0
- package/app/api/browser-bridge/route.ts +70 -0
- package/app/api/chat/sessions/[id]/events/route.ts +17 -0
- package/app/api/chat/sessions/[id]/fork/route.ts +15 -0
- package/app/api/chat/sessions/[id]/messages/route.ts +21 -0
- package/app/api/chat/sessions/[id]/route.ts +23 -0
- package/app/api/chat/sessions/route.ts +12 -0
- package/app/api/chat/temper-ping/route.ts +18 -0
- package/app/api/chat-proxy/[...path]/route.ts +83 -0
- package/app/api/connector-tool/route.ts +38 -0
- package/app/api/connectors/[id]/settings/route.ts +112 -0
- package/app/api/connectors/route.ts +108 -0
- package/app/api/health/tools/route.ts +14 -0
- package/app/api/issue-scanner-gitlab/route.ts +95 -0
- package/app/api/jobs/[id]/reset_dedup/route.ts +15 -0
- package/app/api/jobs/[id]/route.ts +31 -0
- package/app/api/jobs/[id]/run/route.ts +44 -0
- package/app/api/jobs/[id]/runs/[runId]/route.ts +15 -0
- package/app/api/jobs/[id]/runs/route.ts +15 -0
- package/app/api/jobs/preview/route.ts +193 -0
- package/app/api/jobs/route.ts +36 -0
- package/app/api/notify/test/route.ts +39 -7
- package/app/api/pipelines/[id]/route.ts +10 -1
- package/app/api/pipelines/route.ts +16 -2
- package/app/api/plugins/route.ts +40 -8
- package/app/api/project-sessions/route.ts +50 -10
- package/app/api/settings/route.ts +13 -0
- package/app/chat/page.tsx +531 -0
- package/bin/forge-server.mjs +3 -1
- package/cli/chat.ts +283 -0
- package/cli/jobs.ts +176 -0
- package/cli/mw.ts +28 -1
- package/cli/worktree.ts +245 -0
- package/components/ConnectorsPanel.tsx +275 -0
- package/components/Dashboard.tsx +90 -37
- package/components/JobsView.tsx +361 -0
- package/components/LogViewer.tsx +12 -2
- package/components/PipelineView.tsx +275 -56
- package/components/PluginsPanel.tsx +3 -1
- package/components/SettingsModal.tsx +229 -40
- package/components/SkillsPanel.tsx +12 -4
- package/components/TerminalLauncher.tsx +3 -1
- package/components/WebTerminal.tsx +32 -9
- package/components/WorkspaceView.tsx +18 -10
- package/docs/Connector-DeclarativeExtract-Handoff.md +471 -0
- package/docs/Connector-DeclarativeExtract-Spec.md +364 -0
- package/docs/Implementation-Plan-Browser-Agent.md +487 -0
- package/docs/Jobs-Design.md +240 -0
- package/docs/LOCAL-DEPLOY.md +3 -3
- package/docs/RFC-Browser-Connectors.md +509 -0
- package/lib/agents/index.ts +44 -6
- package/lib/agents/types.ts +1 -1
- package/lib/browser-bridge-standalone.ts +317 -0
- package/lib/builtin-plugins/github-api.yaml +93 -0
- package/lib/builtin-plugins/gitlab.yaml +860 -0
- package/lib/builtin-plugins/mantis.probe.js +176 -0
- package/lib/builtin-plugins/mantis.yaml +964 -0
- package/lib/builtin-plugins/pmdb.yaml +178 -0
- package/lib/builtin-plugins/teams.yaml +913 -0
- package/lib/chat/__test__/smoke.ts +30 -0
- package/lib/chat/agent-loop.ts +523 -0
- package/lib/chat/bridge-client.ts +59 -0
- package/lib/chat/llm/anthropic.ts +99 -0
- package/lib/chat/llm/index.ts +20 -0
- package/lib/chat/llm/openai.ts +215 -0
- package/lib/chat/llm/types.ts +42 -0
- package/lib/chat/local-memory.ts +300 -0
- package/lib/chat/memory-store.ts +87 -0
- package/lib/chat/memory-tools.ts +157 -0
- package/lib/chat/protocols/http.ts +118 -0
- package/lib/chat/protocols/shell.ts +101 -0
- package/lib/chat/proxy.ts +51 -0
- package/lib/chat/session-store.ts +272 -0
- package/lib/chat/telegram-bridge.ts +276 -0
- package/lib/chat/temper.ts +281 -0
- package/lib/chat/tool-dispatcher.ts +190 -0
- package/lib/chat/types.ts +50 -0
- package/lib/chat-standalone.ts +286 -0
- package/lib/crypto.ts +1 -1
- package/lib/health.ts +131 -0
- package/lib/help-docs/00-overview.md +2 -1
- package/lib/help-docs/01-settings.md +46 -25
- package/lib/help-docs/07-projects.md +1 -1
- package/lib/help-docs/10-troubleshooting.md +10 -2
- package/lib/help-docs/16-gitlab-autofix.md +114 -0
- package/lib/help-docs/17-connectors.md +322 -0
- package/lib/help-docs/18-chrome-mcp.md +134 -0
- package/lib/help-docs/19-jobs.md +140 -0
- package/lib/help-docs/20-mantis-bug-fix.md +115 -0
- package/lib/help-docs/CLAUDE.md +10 -0
- package/lib/init.ts +137 -50
- package/lib/iso-time.ts +30 -0
- package/lib/issue-scanner-gitlab.ts +281 -0
- package/lib/jobs/dispatcher.ts +217 -0
- package/lib/jobs/scheduler.ts +334 -0
- package/lib/jobs/store.ts +319 -0
- package/lib/jobs/types.ts +117 -0
- package/lib/pipeline-scheduler.ts +1 -6
- package/lib/pipeline.ts +790 -10
- package/lib/plugins/registry.ts +133 -8
- package/lib/plugins/templates.ts +83 -0
- package/lib/plugins/types.ts +140 -1
- package/lib/session-watcher.ts +36 -10
- package/lib/settings.ts +65 -33
- package/lib/skills.ts +3 -1
- package/lib/task-manager.ts +50 -22
- package/lib/telegram-bot.ts +71 -0
- package/lib/terminal-standalone.ts +58 -36
- package/lib/workspace/orchestrator.ts +1 -0
- package/middleware.ts +10 -0
- package/package.json +3 -2
- package/scripts/bench/README.md +1 -1
- package/scripts/bench/tasks/01-text-utils/validator.sh +1 -1
- package/scripts/bench/tasks/02-pagination/setup.sh +1 -1
- package/scripts/bench/tasks/02-pagination/validator.sh +1 -1
- package/scripts/bench/tasks/03-bug-fix/setup.sh +1 -1
- package/scripts/bench/tasks/03-bug-fix/validator.sh +1 -1
- package/src/core/db/database.ts +21 -12
|
@@ -0,0 +1,913 @@
|
|
|
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
|
+
};
|