@yemi33/minions 0.1.1937 → 0.1.1939
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -2
- package/dashboard/js/render-utils.js +3 -1
- package/dashboard/js/settings.js +1 -36
- package/dashboard.js +1 -131
- package/docs/README.md +1 -3
- package/docs/deprecated.json +24 -0
- package/docs/distribution.md +32 -1
- package/docs/rfc-completion-json.md +4 -4
- package/engine/ado.js +0 -17
- package/engine/cc-worker-pool.js +30 -42
- package/engine/cli.js +0 -14
- package/engine/github.js +0 -17
- package/engine/lifecycle.js +0 -29
- package/engine/preflight.js +0 -19
- package/engine/shared.js +0 -13
- package/package.json +1 -4
- package/docs/teams-production.md +0 -370
- package/docs/teams-setup.md +0 -352
- package/engine/teams-cards.js +0 -137
- package/engine/teams.js +0 -647
package/engine/teams.js
DELETED
|
@@ -1,647 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* engine/teams.js — Microsoft Teams integration via Azure Bot Framework.
|
|
3
|
-
* Provides adapter creation, message posting, and conversation reference persistence.
|
|
4
|
-
* All functions are no-ops when Teams is disabled or botbuilder is not installed.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
const path = require('path');
|
|
8
|
-
const shared = require('./shared');
|
|
9
|
-
const queries = require('./queries');
|
|
10
|
-
|
|
11
|
-
const { log, safeRead, safeJson, safeJsonObj, mutateJsonFileLocked, ENGINE_DEFAULTS } = shared;
|
|
12
|
-
const { ENGINE_DIR, getConfig } = queries;
|
|
13
|
-
const cards = require('./teams-cards');
|
|
14
|
-
|
|
15
|
-
const TEAMS_STATE_PATH = path.join(ENGINE_DIR, 'teams-state.json');
|
|
16
|
-
const TEAMS_INBOX_PATH = path.join(ENGINE_DIR, 'teams-inbox.json');
|
|
17
|
-
const TEAMS_INBOX_CAP = 200;
|
|
18
|
-
|
|
19
|
-
// ── Rate Limiting & Circuit Breaker Constants ─────────────────────────────
|
|
20
|
-
const MAX_RETRIES_429 = 2;
|
|
21
|
-
const MAX_RETRIES_5XX = 3;
|
|
22
|
-
const CIRCUIT_FAILURE_THRESHOLD = 5;
|
|
23
|
-
const CIRCUIT_RECOVERY_MS = 10 * 60 * 1000; // 10 minutes
|
|
24
|
-
const OUTBOUND_QUEUE_MAX = 100;
|
|
25
|
-
const OUTBOUND_DRAIN_INTERVAL_MS = 250; // 4 messages per second
|
|
26
|
-
|
|
27
|
-
// Lazy-load botbuilder — may not be installed
|
|
28
|
-
let _botbuilder = null;
|
|
29
|
-
function getBotbuilder() {
|
|
30
|
-
if (_botbuilder) return _botbuilder;
|
|
31
|
-
try {
|
|
32
|
-
_botbuilder = require('botbuilder');
|
|
33
|
-
} catch {
|
|
34
|
-
log('warn', 'botbuilder package not installed — Teams integration unavailable');
|
|
35
|
-
_botbuilder = null;
|
|
36
|
-
}
|
|
37
|
-
return _botbuilder;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Merge user config.teams with ENGINE_DEFAULTS.teams.
|
|
42
|
-
* Returns the merged teams config object.
|
|
43
|
-
*/
|
|
44
|
-
function getTeamsConfig() {
|
|
45
|
-
const config = getConfig();
|
|
46
|
-
const defaults = ENGINE_DEFAULTS.teams;
|
|
47
|
-
const user = config.teams || {};
|
|
48
|
-
return { ...defaults, ...user };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Returns true if Teams integration is enabled and has required credentials.
|
|
53
|
-
* Supports two auth modes:
|
|
54
|
-
* (1) Client secret: appId + appPassword
|
|
55
|
-
* (2) Certificate: appId + certPath + privateKeyPath + tenantId
|
|
56
|
-
*/
|
|
57
|
-
function isTeamsEnabled() {
|
|
58
|
-
const cfg = getTeamsConfig();
|
|
59
|
-
if (cfg.enabled !== true || !cfg.appId) return false;
|
|
60
|
-
const hasSecret = !!cfg.appPassword;
|
|
61
|
-
const hasCert = !!cfg.certPath && !!cfg.privateKeyPath && !!cfg.tenantId;
|
|
62
|
-
return hasSecret || hasCert;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Cached adapter instance — created once per process
|
|
66
|
-
let _adapter = null;
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Create and return a BotFrameworkAdapter instance.
|
|
70
|
-
* Returns null when Teams is disabled or botbuilder is not installed.
|
|
71
|
-
* Supports two auth modes:
|
|
72
|
-
* (1) Client secret: uses ConfigurationBotFrameworkAuthentication with appPassword
|
|
73
|
-
* (2) Certificate: uses CertificateServiceClientCredentialsFactory with PEM cert + key
|
|
74
|
-
*/
|
|
75
|
-
function createAdapter() {
|
|
76
|
-
if (_adapter) return _adapter;
|
|
77
|
-
|
|
78
|
-
if (!isTeamsEnabled()) {
|
|
79
|
-
log('info', 'Teams adapter not created — integration disabled or missing credentials');
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const botbuilder = getBotbuilder();
|
|
84
|
-
if (!botbuilder) return null;
|
|
85
|
-
|
|
86
|
-
const cfg = getTeamsConfig();
|
|
87
|
-
const useCert = !!cfg.certPath && !!cfg.privateKeyPath && !!cfg.tenantId;
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
if (useCert) {
|
|
91
|
-
let connector;
|
|
92
|
-
try {
|
|
93
|
-
connector = require('botframework-connector');
|
|
94
|
-
} catch {
|
|
95
|
-
log('warn', 'botframework-connector not installed — certificate auth unavailable. Install via: npm install botframework-connector');
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
const cert = safeRead(cfg.certPath);
|
|
99
|
-
const privateKey = safeRead(cfg.privateKeyPath);
|
|
100
|
-
if (!cert || !privateKey) {
|
|
101
|
-
log('warn', `Teams cert auth failed — could not read cert (${cfg.certPath}) or key (${cfg.privateKeyPath})`);
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
const credentialsFactory = new connector.CertificateServiceClientCredentialsFactory(
|
|
105
|
-
cfg.appId, cert, privateKey, cfg.tenantId
|
|
106
|
-
);
|
|
107
|
-
_adapter = new botbuilder.CloudAdapter(
|
|
108
|
-
new botbuilder.ConfigurationBotFrameworkAuthentication(
|
|
109
|
-
{ MicrosoftAppId: cfg.appId, MicrosoftAppType: 'SingleTenant', MicrosoftAppTenantId: cfg.tenantId },
|
|
110
|
-
credentialsFactory
|
|
111
|
-
)
|
|
112
|
-
);
|
|
113
|
-
log('info', 'Teams adapter created (certificate auth)');
|
|
114
|
-
} else {
|
|
115
|
-
_adapter = new botbuilder.CloudAdapter(
|
|
116
|
-
new botbuilder.ConfigurationBotFrameworkAuthentication({
|
|
117
|
-
MicrosoftAppId: cfg.appId,
|
|
118
|
-
MicrosoftAppPassword: cfg.appPassword,
|
|
119
|
-
MicrosoftAppType: 'SingleTenant',
|
|
120
|
-
})
|
|
121
|
-
);
|
|
122
|
-
log('info', 'Teams adapter created (client secret auth)');
|
|
123
|
-
}
|
|
124
|
-
return _adapter;
|
|
125
|
-
} catch (err) {
|
|
126
|
-
log('warn', `Teams adapter creation failed: ${err.message}`);
|
|
127
|
-
return null;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Save a conversation reference for later proactive messaging.
|
|
133
|
-
* Uses mutateJsonFileLocked for concurrency safety.
|
|
134
|
-
* @param {string} key — identifier for this conversation (e.g. channel ID or user ID)
|
|
135
|
-
* @param {object} ref — conversation reference from TurnContext.getConversationReference()
|
|
136
|
-
*/
|
|
137
|
-
function saveConversationRef(key, ref) {
|
|
138
|
-
if (!key || !ref) return;
|
|
139
|
-
mutateJsonFileLocked(TEAMS_STATE_PATH, (state) => {
|
|
140
|
-
if (!state.conversations) state.conversations = {};
|
|
141
|
-
state.conversations[key] = { ref, savedAt: shared.ts() };
|
|
142
|
-
});
|
|
143
|
-
log('info', `Teams conversation ref saved: ${key}`);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Retrieve a saved conversation reference.
|
|
148
|
-
* @param {string} key — identifier used in saveConversationRef
|
|
149
|
-
* @returns {object|null} — the conversation reference, or null if not found
|
|
150
|
-
*/
|
|
151
|
-
function getConversationRef(key) {
|
|
152
|
-
if (!key) return null;
|
|
153
|
-
const state = safeJsonObj(TEAMS_STATE_PATH);
|
|
154
|
-
return state.conversations?.[key]?.ref || null;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ── Circuit Breaker ───────────────────────────────────────────────────────
|
|
158
|
-
|
|
159
|
-
const _circuit = {
|
|
160
|
-
state: 'closed', // 'closed' | 'open' | 'half-open'
|
|
161
|
-
failures: 0,
|
|
162
|
-
openedAt: 0,
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
function _isCircuitOpen() {
|
|
166
|
-
if (_circuit.state === 'closed') return false;
|
|
167
|
-
if (_circuit.state === 'open') {
|
|
168
|
-
if (Date.now() - _circuit.openedAt >= CIRCUIT_RECOVERY_MS) {
|
|
169
|
-
_circuit.state = 'half-open';
|
|
170
|
-
log('info', 'Teams circuit breaker: half-open — allowing probe request');
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
173
|
-
return true;
|
|
174
|
-
}
|
|
175
|
-
return false; // half-open allows one probe
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function _onSendSuccess() {
|
|
179
|
-
if (_circuit.state !== 'closed') {
|
|
180
|
-
log('info', `Teams circuit breaker: closed (was ${_circuit.state})`);
|
|
181
|
-
}
|
|
182
|
-
_circuit.state = 'closed';
|
|
183
|
-
_circuit.failures = 0;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function _onSendFailure() {
|
|
187
|
-
_circuit.failures++;
|
|
188
|
-
if (_circuit.state === 'half-open') {
|
|
189
|
-
_circuit.state = 'open';
|
|
190
|
-
_circuit.openedAt = Date.now();
|
|
191
|
-
log('warn', 'Teams circuit breaker: probe failed — reopening for 10 minutes');
|
|
192
|
-
} else if (_circuit.failures >= CIRCUIT_FAILURE_THRESHOLD) {
|
|
193
|
-
_circuit.state = 'open';
|
|
194
|
-
_circuit.openedAt = Date.now();
|
|
195
|
-
log('warn', `Teams circuit breaker: OPEN after ${_circuit.failures} consecutive failures — disabling for 10 minutes`);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// ── Retry Logic ───────────────────────────────────────────────────────────
|
|
200
|
-
|
|
201
|
-
function _sleep(ms) {
|
|
202
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Execute sendFn with retry on 429 (Retry-After) and 5xx (exponential backoff).
|
|
207
|
-
* @param {Function} sendFn — async function that performs the actual send
|
|
208
|
-
*/
|
|
209
|
-
async function _sendWithRetry(sendFn) {
|
|
210
|
-
let lastErr;
|
|
211
|
-
let retries429 = 0;
|
|
212
|
-
let retries5xx = 0;
|
|
213
|
-
const maxAttempts = 1 + MAX_RETRIES_429 + MAX_RETRIES_5XX;
|
|
214
|
-
|
|
215
|
-
for (let i = 0; i < maxAttempts; i++) {
|
|
216
|
-
try {
|
|
217
|
-
await sendFn();
|
|
218
|
-
return;
|
|
219
|
-
} catch (err) {
|
|
220
|
-
lastErr = err;
|
|
221
|
-
const status = err.statusCode || err.status || 0;
|
|
222
|
-
|
|
223
|
-
if (status === 429 && retries429 < MAX_RETRIES_429) {
|
|
224
|
-
retries429++;
|
|
225
|
-
const retryAfterSec = parseInt(err.headers?.['retry-after'] || '1', 10);
|
|
226
|
-
const delayMs = Math.max(retryAfterSec, 1) * 1000;
|
|
227
|
-
log('info', `Teams 429 — retry ${retries429}/${MAX_RETRIES_429} after ${delayMs}ms`);
|
|
228
|
-
await _sleep(delayMs);
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
if (status >= 500 && status < 600 && retries5xx < MAX_RETRIES_5XX) {
|
|
233
|
-
retries5xx++;
|
|
234
|
-
const backoffMs = Math.pow(2, retries5xx - 1) * 1000; // 1s, 2s, 4s
|
|
235
|
-
log('info', `Teams ${status} — retry ${retries5xx}/${MAX_RETRIES_5XX} after ${backoffMs}ms`);
|
|
236
|
-
await _sleep(backoffMs);
|
|
237
|
-
continue;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
throw err;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
if (lastErr) throw lastErr;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// ── Outbound Queue ────────────────────────────────────────────────────────
|
|
247
|
-
|
|
248
|
-
const _outboundQueue = [];
|
|
249
|
-
let _drainTimer = null;
|
|
250
|
-
|
|
251
|
-
function _enqueueMessage(key, content) {
|
|
252
|
-
if (_outboundQueue.length >= OUTBOUND_QUEUE_MAX) {
|
|
253
|
-
log('warn', `Teams outbound queue full (${OUTBOUND_QUEUE_MAX}) — dropping message for ${key}`);
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
_outboundQueue.push({ key, content });
|
|
257
|
-
_startDrainTimer();
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function _startDrainTimer() {
|
|
261
|
-
if (_drainTimer) return;
|
|
262
|
-
_drainTimer = setInterval(_drainQueue, OUTBOUND_DRAIN_INTERVAL_MS);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function _stopDrainTimer() {
|
|
266
|
-
if (_drainTimer) {
|
|
267
|
-
clearInterval(_drainTimer);
|
|
268
|
-
_drainTimer = null;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
async function _drainQueue() {
|
|
273
|
-
if (_outboundQueue.length === 0) {
|
|
274
|
-
_stopDrainTimer();
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const item = _outboundQueue.shift();
|
|
279
|
-
if (!item) return;
|
|
280
|
-
|
|
281
|
-
try {
|
|
282
|
-
await _sendProactive(item.key, item.content);
|
|
283
|
-
} catch (err) {
|
|
284
|
-
log('warn', `Teams queued message failed for ${item.key}: ${err.message}`);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Send a proactive message with circuit breaker and retry.
|
|
290
|
-
* Called by the drain timer — not directly by callers.
|
|
291
|
-
*/
|
|
292
|
-
async function _sendProactive(key, content) {
|
|
293
|
-
if (_isCircuitOpen()) {
|
|
294
|
-
log('info', `Teams circuit open — skipping message to ${key}`);
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const adapter = createAdapter();
|
|
299
|
-
if (!adapter) return;
|
|
300
|
-
|
|
301
|
-
const ref = getConversationRef(key);
|
|
302
|
-
if (!ref) {
|
|
303
|
-
log('warn', `Teams post skipped — no conversation ref for key: ${key}`);
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
try {
|
|
308
|
-
const activity = toActivity(content);
|
|
309
|
-
await _sendWithRetry(async () => {
|
|
310
|
-
await adapter.continueConversationAsync(getTeamsConfig().appId, ref, async (context) => {
|
|
311
|
-
await context.sendActivity(activity);
|
|
312
|
-
});
|
|
313
|
-
});
|
|
314
|
-
_onSendSuccess();
|
|
315
|
-
const len = typeof content === 'string' ? content.length : JSON.stringify(content).length;
|
|
316
|
-
log('info', `Teams proactive message sent to ${key} (${len} chars)`);
|
|
317
|
-
} catch (err) {
|
|
318
|
-
_onSendFailure();
|
|
319
|
-
log('warn', `Teams proactive post failed for ${key}: ${err.message}`);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* Build an activity object from text or Adaptive Card.
|
|
325
|
-
* @param {string|object} content — plain text string or Adaptive Card object (with type: 'AdaptiveCard')
|
|
326
|
-
* @returns {string|object} — activity-compatible value for sendActivity
|
|
327
|
-
*/
|
|
328
|
-
function toActivity(content) {
|
|
329
|
-
if (typeof content === 'string') return content;
|
|
330
|
-
if (content && content.type === 'AdaptiveCard') {
|
|
331
|
-
return {
|
|
332
|
-
type: 'message',
|
|
333
|
-
text: content.fallbackText || '',
|
|
334
|
-
attachments: [{
|
|
335
|
-
contentType: 'application/vnd.microsoft.card.adaptive',
|
|
336
|
-
content,
|
|
337
|
-
}],
|
|
338
|
-
};
|
|
339
|
-
}
|
|
340
|
-
return String(content);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* Reply to an existing Teams conversation turn.
|
|
345
|
-
* No-op when adapter is null (Teams disabled or botbuilder missing).
|
|
346
|
-
* @param {object} context — TurnContext from bot handler
|
|
347
|
-
* @param {string|object} content — message text or Adaptive Card object
|
|
348
|
-
*/
|
|
349
|
-
async function teamsReply(context, content) {
|
|
350
|
-
const adapter = createAdapter();
|
|
351
|
-
if (!adapter || !context) return;
|
|
352
|
-
if (_isCircuitOpen()) {
|
|
353
|
-
log('info', 'Teams circuit open — skipping reply');
|
|
354
|
-
return;
|
|
355
|
-
}
|
|
356
|
-
try {
|
|
357
|
-
await _sendWithRetry(async () => {
|
|
358
|
-
await context.sendActivity(toActivity(content));
|
|
359
|
-
});
|
|
360
|
-
_onSendSuccess();
|
|
361
|
-
const len = typeof content === 'string' ? content.length : JSON.stringify(content).length;
|
|
362
|
-
log('info', `Teams reply sent (${len} chars)`);
|
|
363
|
-
} catch (err) {
|
|
364
|
-
_onSendFailure();
|
|
365
|
-
log('warn', `Teams reply failed: ${err.message}`);
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* Proactively post a message to a saved Teams conversation.
|
|
371
|
-
* No-op when adapter is null or conversation ref is not found.
|
|
372
|
-
* @param {string} key — conversation key used in saveConversationRef
|
|
373
|
-
* @param {string|object} content — message text or Adaptive Card object
|
|
374
|
-
*/
|
|
375
|
-
async function teamsPost(key, content) {
|
|
376
|
-
if (!createAdapter()) return;
|
|
377
|
-
_enqueueMessage(key, content);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Process unread messages in the Teams inbox.
|
|
382
|
-
* Reads engine/teams-inbox.json, sends each unprocessed message through CC
|
|
383
|
-
* via the dashboard HTTP API, posts the CC response as a Teams reply,
|
|
384
|
-
* and marks the message as processed. Prunes oldest processed messages
|
|
385
|
-
* when inbox exceeds TEAMS_INBOX_CAP.
|
|
386
|
-
*/
|
|
387
|
-
async function processTeamsInbox() {
|
|
388
|
-
if (!isTeamsEnabled()) return;
|
|
389
|
-
|
|
390
|
-
// Read inbox — snapshot unprocessed messages, then release lock
|
|
391
|
-
const inbox = safeJson(TEAMS_INBOX_PATH);
|
|
392
|
-
if (!Array.isArray(inbox) || inbox.length === 0) return;
|
|
393
|
-
|
|
394
|
-
const unprocessed = inbox.filter(m => !m._processedAt);
|
|
395
|
-
if (unprocessed.length === 0) return;
|
|
396
|
-
|
|
397
|
-
log('info', `Teams inbox: ${unprocessed.length} unprocessed message(s)`);
|
|
398
|
-
const cfg = getTeamsConfig();
|
|
399
|
-
const port = process.env.PORT || 7331;
|
|
400
|
-
|
|
401
|
-
// Process sequentially to avoid CC session conflicts
|
|
402
|
-
for (const msg of unprocessed) {
|
|
403
|
-
try {
|
|
404
|
-
// Call CC via dashboard HTTP API
|
|
405
|
-
const ccRes = await fetch(`http://localhost:${port}/api/command-center`, {
|
|
406
|
-
method: 'POST',
|
|
407
|
-
headers: { 'Content-Type': 'application/json' },
|
|
408
|
-
body: JSON.stringify({ message: msg.text, tabId: `teams-${msg.id}` }),
|
|
409
|
-
});
|
|
410
|
-
const ccData = await ccRes.json().catch(() => ({}));
|
|
411
|
-
const responseText = ccData.text || ccData.error || 'No response from Command Center';
|
|
412
|
-
|
|
413
|
-
// Track usage under 'teams' category
|
|
414
|
-
const llm = require('./llm');
|
|
415
|
-
llm.trackEngineUsage('teams', ccData.usage || null);
|
|
416
|
-
|
|
417
|
-
// Post reply to Teams
|
|
418
|
-
if (msg.conversationRef?.conversation?.id) {
|
|
419
|
-
await teamsPost(msg.conversationRef.conversation.id, responseText);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// Mark as processed
|
|
423
|
-
mutateJsonFileLocked(TEAMS_INBOX_PATH, (data) => {
|
|
424
|
-
if (!Array.isArray(data)) return data;
|
|
425
|
-
const entry = data.find(m => m.id === msg.id);
|
|
426
|
-
if (entry) entry._processedAt = new Date().toISOString();
|
|
427
|
-
|
|
428
|
-
// Prune oldest processed messages when inbox exceeds cap
|
|
429
|
-
if (data.length > TEAMS_INBOX_CAP) {
|
|
430
|
-
const processed = data.filter(m => m._processedAt).sort((a, b) => (a.receivedAt || '').localeCompare(b.receivedAt || ''));
|
|
431
|
-
const toRemove = data.length - TEAMS_INBOX_CAP;
|
|
432
|
-
const removeIds = new Set(processed.slice(0, toRemove).map(m => m.id));
|
|
433
|
-
return data.filter(m => !removeIds.has(m.id));
|
|
434
|
-
}
|
|
435
|
-
}, { defaultValue: [] });
|
|
436
|
-
|
|
437
|
-
log('info', `Teams inbox: processed message ${msg.id} from ${msg.from}`);
|
|
438
|
-
} catch (err) {
|
|
439
|
-
log('warn', `Teams inbox: failed to process message ${msg.id}: ${err.message}`);
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// ── CC Mirror to Teams ─────────────────────────────────────────────────────
|
|
445
|
-
|
|
446
|
-
const CC_MIRROR_RATE_LIMIT_MS = 5000;
|
|
447
|
-
let _lastCCMirrorPost = 0;
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* Mirror a CC response to Teams so the team sees orchestration activity.
|
|
451
|
-
* Truncates response at 4000 chars with a dashboard link suffix.
|
|
452
|
-
* Rate-limited to 1 post per 5 seconds — excess posts silently skipped.
|
|
453
|
-
* @param {string} userMessage — the user's CC input
|
|
454
|
-
* @param {string} ccResponse — the CC response text
|
|
455
|
-
*/
|
|
456
|
-
async function teamsPostCCResponse(userMessage, ccResponse) {
|
|
457
|
-
if (!isTeamsEnabled()) return;
|
|
458
|
-
const cfg = getTeamsConfig();
|
|
459
|
-
if (!cfg.ccMirror) return;
|
|
460
|
-
|
|
461
|
-
// Rate limit
|
|
462
|
-
const now = Date.now();
|
|
463
|
-
if (now - _lastCCMirrorPost < CC_MIRROR_RATE_LIMIT_MS) return;
|
|
464
|
-
_lastCCMirrorPost = now;
|
|
465
|
-
|
|
466
|
-
const card = cards.buildCCResponseCard(userMessage, ccResponse);
|
|
467
|
-
|
|
468
|
-
// Find first available conversation ref
|
|
469
|
-
const state = safeJsonObj(TEAMS_STATE_PATH);
|
|
470
|
-
const convKeys = Object.keys(state.conversations || {});
|
|
471
|
-
if (convKeys.length === 0) {
|
|
472
|
-
log('info', 'Teams CC mirror skipped — no conversation refs stored');
|
|
473
|
-
return;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
await teamsPost(convKeys[0], card);
|
|
477
|
-
log('info', `Teams CC mirror sent`);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
// ── Post-Completion Notifications ──────────────────────────────────────────
|
|
481
|
-
|
|
482
|
-
/**
|
|
483
|
-
* Notify Teams when an agent completes or fails a task.
|
|
484
|
-
* Only posts if the event type is in config.teams.notifyEvents.
|
|
485
|
-
* @param {object} dispatchItem — the dispatch entry (id, type, task, meta)
|
|
486
|
-
* @param {string} result — 'success', 'error', or 'timeout'
|
|
487
|
-
* @param {string} agentId — the agent that ran the task
|
|
488
|
-
*/
|
|
489
|
-
async function teamsNotifyCompletion(dispatchItem, result, agentId) {
|
|
490
|
-
if (!isTeamsEnabled()) return;
|
|
491
|
-
const cfg = getTeamsConfig();
|
|
492
|
-
const eventType = result === 'success' ? 'agent-completed' : 'agent-failed';
|
|
493
|
-
if (!cfg.notifyEvents || !cfg.notifyEvents.includes(eventType)) return;
|
|
494
|
-
|
|
495
|
-
const title = dispatchItem.task || dispatchItem.meta?.item?.title || dispatchItem.id;
|
|
496
|
-
const prUrl = dispatchItem.meta?.pr?.url || dispatchItem.pr || '';
|
|
497
|
-
const item = { title, id: dispatchItem.meta?.item?.id || dispatchItem.id };
|
|
498
|
-
const card = cards.buildCompletionCard(agentId, item, result, prUrl || undefined);
|
|
499
|
-
|
|
500
|
-
// Find first available conversation ref
|
|
501
|
-
const state = safeJsonObj(TEAMS_STATE_PATH);
|
|
502
|
-
const convKeys = Object.keys(state.conversations || {});
|
|
503
|
-
if (convKeys.length === 0) return;
|
|
504
|
-
|
|
505
|
-
try {
|
|
506
|
-
await teamsPost(convKeys[0], card);
|
|
507
|
-
log('info', `Teams completion notification sent for ${dispatchItem.id} (${result})`);
|
|
508
|
-
} catch (err) {
|
|
509
|
-
log('warn', `Teams completion notification failed: ${err.message}`);
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
// ── PR Lifecycle Notifications ─────────────────────────────────────────────
|
|
514
|
-
|
|
515
|
-
/**
|
|
516
|
-
* Notify Teams about a PR lifecycle event (merge, abandon, build-failed, approved).
|
|
517
|
-
* Deduplicates via _teamsNotifiedEvents on PR object.
|
|
518
|
-
* @param {object} pr — the PR object from pull-requests.json
|
|
519
|
-
* @param {string} event — event type: 'pr-merged', 'pr-abandoned', 'build-failed', 'pr-approved'
|
|
520
|
-
* @param {object} project — the project config object
|
|
521
|
-
* @param {string} prFilePath — path to pull-requests.json for dedup write
|
|
522
|
-
*/
|
|
523
|
-
async function teamsNotifyPrEvent(pr, event, project, prFilePath) {
|
|
524
|
-
if (!isTeamsEnabled()) return;
|
|
525
|
-
const cfg = getTeamsConfig();
|
|
526
|
-
if (!cfg.notifyEvents || !cfg.notifyEvents.includes(event)) return;
|
|
527
|
-
|
|
528
|
-
// Dedup check — don't re-notify the same event
|
|
529
|
-
if (!prFilePath && pr._teamsNotifiedEvents && pr._teamsNotifiedEvents.includes(event)) return;
|
|
530
|
-
|
|
531
|
-
const card = cards.buildPrCard(pr, event, project);
|
|
532
|
-
|
|
533
|
-
// Find first available conversation ref
|
|
534
|
-
const state = safeJsonObj(TEAMS_STATE_PATH);
|
|
535
|
-
const convKeys = Object.keys(state.conversations || {});
|
|
536
|
-
if (convKeys.length === 0) return;
|
|
537
|
-
|
|
538
|
-
let claimedEvent = false;
|
|
539
|
-
let shouldPost = true;
|
|
540
|
-
try {
|
|
541
|
-
if (prFilePath) {
|
|
542
|
-
mutateJsonFileLocked(prFilePath, (prs) => {
|
|
543
|
-
if (!Array.isArray(prs)) return prs;
|
|
544
|
-
const target = shared.findPrRecord(prs, pr);
|
|
545
|
-
if (!target) return prs;
|
|
546
|
-
if (!target._teamsNotifiedEvents) target._teamsNotifiedEvents = [];
|
|
547
|
-
if (target._teamsNotifiedEvents.includes(event)) {
|
|
548
|
-
shouldPost = false;
|
|
549
|
-
return prs;
|
|
550
|
-
}
|
|
551
|
-
target._teamsNotifiedEvents.push(event);
|
|
552
|
-
claimedEvent = true;
|
|
553
|
-
return prs;
|
|
554
|
-
}, { defaultValue: [] });
|
|
555
|
-
}
|
|
556
|
-
if (!shouldPost) return;
|
|
557
|
-
await teamsPost(convKeys[0], card);
|
|
558
|
-
if (pr && typeof pr === 'object') {
|
|
559
|
-
if (!Array.isArray(pr._teamsNotifiedEvents)) pr._teamsNotifiedEvents = [];
|
|
560
|
-
if (!pr._teamsNotifiedEvents.includes(event)) pr._teamsNotifiedEvents.push(event);
|
|
561
|
-
}
|
|
562
|
-
log('info', `Teams PR notification sent: ${event} for ${pr.id}`);
|
|
563
|
-
} catch (err) {
|
|
564
|
-
if (claimedEvent && prFilePath) {
|
|
565
|
-
try {
|
|
566
|
-
mutateJsonFileLocked(prFilePath, (prs) => {
|
|
567
|
-
if (!Array.isArray(prs)) return prs;
|
|
568
|
-
const target = shared.findPrRecord(prs, pr);
|
|
569
|
-
if (!target || !Array.isArray(target._teamsNotifiedEvents)) return prs;
|
|
570
|
-
target._teamsNotifiedEvents = target._teamsNotifiedEvents.filter(e => e !== event);
|
|
571
|
-
return prs;
|
|
572
|
-
}, { defaultValue: [] });
|
|
573
|
-
} catch (revertErr) {
|
|
574
|
-
log('warn', `Teams PR dedup revert failed for ${pr.id}: ${revertErr.message}`);
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
log('warn', `Teams PR notification failed for ${pr.id}: ${err.message}`);
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
// ── Plan Lifecycle Notifications ───────────────────────────────────────────
|
|
582
|
-
|
|
583
|
-
/**
|
|
584
|
-
* Notify Teams about a plan lifecycle event (completed, approved, rejected, verify-created).
|
|
585
|
-
* @param {object} planInfo — { name, file, project, doneCount, totalCount } or similar
|
|
586
|
-
* @param {string} event — 'plan-completed', 'plan-approved', 'plan-rejected', 'verify-created'
|
|
587
|
-
*/
|
|
588
|
-
async function teamsNotifyPlanEvent(planInfo, event) {
|
|
589
|
-
if (!isTeamsEnabled()) return;
|
|
590
|
-
const cfg = getTeamsConfig();
|
|
591
|
-
if (!cfg.notifyEvents || !cfg.notifyEvents.includes(event)) return;
|
|
592
|
-
|
|
593
|
-
const planName = planInfo.name || planInfo.file || 'Unknown plan';
|
|
594
|
-
const card = cards.buildPlanCard(planInfo, event);
|
|
595
|
-
|
|
596
|
-
const state = safeJsonObj(TEAMS_STATE_PATH);
|
|
597
|
-
const convKeys = Object.keys(state.conversations || {});
|
|
598
|
-
if (convKeys.length === 0) return;
|
|
599
|
-
|
|
600
|
-
try {
|
|
601
|
-
await teamsPost(convKeys[0], card);
|
|
602
|
-
log('info', `Teams plan notification sent: ${event} for ${planName}`);
|
|
603
|
-
} catch (err) {
|
|
604
|
-
log('warn', `Teams plan notification failed: ${err.message}`);
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// Reset cached adapter and internal state (for testing)
|
|
609
|
-
function _resetAdapter() {
|
|
610
|
-
_adapter = null;
|
|
611
|
-
_botbuilder = null;
|
|
612
|
-
_lastCCMirrorPost = 0;
|
|
613
|
-
_circuit.state = 'closed';
|
|
614
|
-
_circuit.failures = 0;
|
|
615
|
-
_circuit.openedAt = 0;
|
|
616
|
-
_outboundQueue.length = 0;
|
|
617
|
-
_stopDrainTimer();
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
module.exports = {
|
|
621
|
-
getTeamsConfig,
|
|
622
|
-
isTeamsEnabled,
|
|
623
|
-
createAdapter,
|
|
624
|
-
saveConversationRef,
|
|
625
|
-
getConversationRef,
|
|
626
|
-
teamsReply,
|
|
627
|
-
teamsPost,
|
|
628
|
-
processTeamsInbox,
|
|
629
|
-
teamsPostCCResponse,
|
|
630
|
-
teamsNotifyCompletion,
|
|
631
|
-
teamsNotifyPrEvent,
|
|
632
|
-
teamsNotifyPlanEvent,
|
|
633
|
-
CC_MIRROR_RATE_LIMIT_MS,
|
|
634
|
-
TEAMS_STATE_PATH,
|
|
635
|
-
TEAMS_INBOX_PATH,
|
|
636
|
-
TEAMS_INBOX_CAP,
|
|
637
|
-
MAX_RETRIES_429,
|
|
638
|
-
MAX_RETRIES_5XX,
|
|
639
|
-
CIRCUIT_FAILURE_THRESHOLD,
|
|
640
|
-
CIRCUIT_RECOVERY_MS,
|
|
641
|
-
OUTBOUND_QUEUE_MAX,
|
|
642
|
-
OUTBOUND_DRAIN_INTERVAL_MS,
|
|
643
|
-
_circuit, // exported for testing
|
|
644
|
-
_outboundQueue, // exported for testing
|
|
645
|
-
_resetAdapter, // exported for testing
|
|
646
|
-
_stopDrainTimer, // exported for testing
|
|
647
|
-
};
|