@yemi33/minions 0.1.2448 → 0.1.2449
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/bin/cli-api-client.js +1 -1
- package/bin/install-internal-minions.js +1382 -44
- package/bin/install-layout.js +150 -0
- package/bin/minions.js +460 -167
- package/dashboard/docs/typography.md +65 -12
- package/dashboard/js/command-center.js +58 -4
- package/dashboard/js/detail-panel.js +36 -0
- package/dashboard/js/memory-panel.js +59 -12
- package/dashboard/js/qa.js +179 -20
- package/dashboard/js/refresh.js +148 -12
- package/dashboard/js/render-dispatch.js +3 -4
- package/dashboard/js/render-inbox.js +2 -2
- package/dashboard/js/render-other.js +3 -3
- package/dashboard/js/render-pipelines.js +14 -0
- package/dashboard/js/render-plans.js +57 -9
- package/dashboard/js/render-prd.js +132 -23
- package/dashboard/js/render-prs.js +195 -166
- package/dashboard/js/render-schedules.js +63 -3
- package/dashboard/js/render-utils.js +3 -3
- package/dashboard/js/render-watches.js +19 -3
- package/dashboard/js/render-work-items.js +238 -30
- package/dashboard/js/settings.js +205 -54
- package/dashboard/js/utils.js +51 -1
- package/dashboard/pages/home.html +1 -1
- package/dashboard/pages/qa.html +1 -16
- package/dashboard/pages/work.html +40 -0
- package/dashboard/shared/cc-limits.js +79 -0
- package/dashboard/shared/pr-filters.js +21 -38
- package/dashboard/shared/project-git-summary.js +1 -1
- package/dashboard/shared/record-filters.js +169 -0
- package/dashboard/shared/watches-source.js +1 -1
- package/dashboard/shared/welcome-popup.js +1 -1
- package/dashboard/shared/wi-filters.js +302 -0
- package/dashboard/slim/body.html +1 -0
- package/dashboard/slim/js/command-send.js +26 -0
- package/dashboard/slim/js/modals-tiles.js +380 -39
- package/dashboard/slim/js/status.js +13 -21
- package/dashboard/slim/layout.html +1 -0
- package/dashboard/slim/panel-bootstrap.js +6 -2
- package/dashboard/slim/styles.css +38 -0
- package/dashboard/styles.css +159 -55
- package/dashboard-build.js +13 -2
- package/dashboard.js +956 -423
- package/docs/README.md +11 -6
- package/docs/api-errors.md +2 -2
- package/docs/architecture-review-2026-07-09.md +1 -1
- package/docs/architecture.excalidraw +2 -2
- package/docs/auto-discovery.md +18 -9
- package/docs/branch-derivation.md +4 -4
- package/docs/capture-demos.js +39 -2
- package/docs/ci-runner-canary.md +123 -0
- package/docs/claude-md-propagation.md +2 -2
- package/docs/cloud-agent-dispatch.md +204 -0
- package/docs/command-center.md +7 -7
- package/docs/completion-reports.md +43 -20
- package/docs/constants.md +10 -3
- package/docs/constellation-bridge.md +134 -6
- package/docs/constellation-style-telemetry.md +4 -4
- package/docs/contracts/capability-protocol.v1.json +165 -0
- package/docs/cooldown-merge-semantics.md +12 -12
- package/docs/copilot-cli-schema.md +7 -7
- package/docs/cross-repo-plans.md +10 -10
- package/docs/dead-code-audit-retractions.md +5 -5
- package/docs/default-branch-ci.md +173 -0
- package/docs/deprecated.json +31 -31
- package/docs/design-inbox-entries-schema.md +3 -3
- package/docs/design-language.md +1051 -0
- package/docs/design-state-storage.md +11 -11
- package/docs/diagnostics-crash-reports.md +9 -9
- package/docs/diagnostics-memory.md +5 -5
- package/docs/documentation-audit-2026-07-09.md +7 -7
- package/docs/engine-restart.md +90 -5
- package/docs/harness-mode.md +1 -1
- package/docs/internal-install.md +338 -39
- package/docs/kb-dedup-duplicate-pair-investigation.md +5 -5
- package/docs/kb-pr3223-cascade-archiving.md +1 -1
- package/docs/kb-pr696-merge-conflict-docs.md +6 -6
- package/docs/kb-sweep.md +35 -35
- package/docs/keep-processes.md +1 -1
- package/docs/live-checkout-mode.md +30 -30
- package/docs/managed-spawn.md +18 -14
- package/docs/named-agents.md +7 -7
- package/docs/plan-lifecycle.md +69 -2
- package/docs/pr-author-identity.md +114 -0
- package/docs/pr-auto-fix-dispatch.md +19 -4
- package/docs/pr-comment-followup.md +6 -6
- package/docs/pr-review-fix-loop.md +59 -10
- package/docs/process-termination.md +40 -0
- package/docs/proposals/repo-pool-for-live-checkout.md +13 -13
- package/docs/qa-runbook-lifecycle.md +367 -17
- package/docs/qa-runbooks.md +3 -3
- package/docs/rfc-completion-json.md +18 -18
- package/docs/runtime-adapters.md +26 -21
- package/docs/security.md +6 -6
- package/docs/self-improvement.md +4 -4
- package/docs/shared-lifecycle-module-map.md +473 -472
- package/docs/skills.md +52 -3
- package/docs/slim-ux/concepts.md +121 -116
- package/docs/specs/agent-configurability.md +18 -18
- package/docs/specs/agent-rename.md +18 -18
- package/docs/team-memory.md +38 -21
- package/docs/timeouts-and-liveness.md +118 -10
- package/docs/tutorials/01-install-and-connect.md +1 -1
- package/docs/watches.md +40 -39
- package/docs/workspace-manifests.md +4 -4
- package/docs/worktree-lifecycle.md +293 -14
- package/engine/README.md +46 -0
- package/engine/{ado-comment.js → ado/comment.js} +8 -8
- package/engine/{ado-git-auth.js → ado/git-auth.js} +4 -4
- package/engine/{ado.js → ado/index.js} +417 -63
- package/engine/{ado-status.js → ado/status.js} +6 -8
- package/engine/{ado-token.js → ado/token.js} +1 -1
- package/engine/{acp-transport.js → agents/acp-transport.js} +62 -22
- package/engine/{agent-worker-pool.js → agents/agent-worker-pool.js} +17 -8
- package/engine/{cc-worker-pool.js → agents/cc-worker-pool.js} +16 -6
- package/engine/{claude-md-context.js → agents/claude-md-context.js} +5 -5
- package/engine/{harness-context.js → agents/harness-context.js} +5 -5
- package/engine/{harness.js → agents/harness.js} +3 -3
- package/engine/{llm.js → agents/llm.js} +18 -14
- package/engine/{model-discovery.js → agents/model-discovery.js} +2 -2
- package/engine/{playbook.js → agents/playbook.js} +155 -22
- package/engine/{pooled-agent-process.js → agents/pooled-agent-process.js} +14 -12
- package/engine/{preflight.js → agents/preflight.js} +29 -10
- package/engine/{spawn-agent.js → agents/spawn-agent.js} +25 -14
- package/engine/{spawn-phase-watchdog.js → agents/spawn-phase-watchdog.js} +16 -7
- package/engine/{steering.js → agents/steering.js} +5 -5
- package/engine/{tools-inventory.js → agents/tools-inventory.js} +2 -2
- package/engine/{agent-api-validation.js → api/agent-api-validation.js} +2 -2
- package/engine/{api-validation.js → api/api-validation.js} +1 -1
- package/engine/api/bridge.js +787 -0
- package/engine/{cc-api-validation.js → api/cc-api-validation.js} +1 -1
- package/engine/api/companion.js +560 -0
- package/engine/{content-api-validation.js → api/content-api-validation.js} +2 -2
- package/engine/{pr-issue-validation.js → api/pr-issue-validation.js} +33 -6
- package/engine/{settings-validation.js → api/settings-validation.js} +32 -4
- package/engine/api-contracts/agent-content.js +4 -4
- package/engine/api-contracts/capability-manifest.js +236 -0
- package/engine/api-contracts/capability-protocol.js +333 -0
- package/engine/api-contracts/cc-ops.js +1 -1
- package/engine/api-contracts/config-runtime.js +5 -0
- package/engine/api-contracts/core.js +28 -1
- package/engine/api-contracts/index.js +100 -0
- package/engine/api-contracts/orchestration.js +18 -5
- package/engine/api-contracts/pull-requests.js +37 -6
- package/engine/api-contracts/qa-process.js +29 -6
- package/engine/api-contracts/work-plan-prd.js +21 -1
- package/engine/cloud/contract.js +212 -0
- package/engine/cloud/index.js +159 -0
- package/engine/{execution-model.js → core/execution-model.js} +1 -1
- package/engine/{features.js → core/features.js} +4 -4
- package/engine/{operator-identity.js → core/operator-identity.js} +1 -1
- package/engine/{queries.js → core/queries.js} +201 -36
- package/engine/{safe-expr.js → core/safe-expr.js} +1 -1
- package/engine/{shared.js → core/shared.js} +1637 -175
- package/engine/{stdio-timestamps.js → core/stdio-timestamps.js} +1 -1
- package/engine/{untrusted-fence.js → core/untrusted-fence.js} +3 -3
- package/engine/db/index.js +11 -2
- package/engine/db/migrations/002-dispatches.js +3 -3
- package/engine/db/migrations/003-work-items.js +1 -1
- package/engine/db/migrations/004-pull-requests.js +1 -1
- package/engine/db/migrations/006-metrics.js +1 -1
- package/engine/db/migrations/007-watches.js +2 -2
- package/engine/db/migrations/008-small-state.js +1 -1
- package/engine/db/migrations/009-qa.js +1 -1
- package/engine/db/migrations/010-pr-links.js +1 -1
- package/engine/db/migrations/011-remaining-state.js +1 -1
- package/engine/db/migrations/012-steering-deliveries.js +2 -2
- package/engine/db/migrations/013-backfill-broken-note-links.js +1 -1
- package/engine/db/migrations/014-pr-fix-target-prefs.js +2 -2
- package/engine/db/migrations/015-plans-prds.js +0 -0
- package/engine/db/migrations/018-sql-only-cutover.js +2 -2
- package/engine/db/migrations/021-archived-work-items.js +1 -1
- package/engine/db/migrations/022-global-cc-session.js +1 -1
- package/engine/db/migrations/023-engine-state.js +1 -1
- package/engine/db/migrations/025-malformed-work-item-phantoms.js +1 -1
- package/engine/db/migrations/027-review-learning-lifecycle.js +1 -1
- package/engine/db/migrations/029-repair-reused-versions.js +20 -0
- package/engine/db/migrations/031-pr-author-identity.js +137 -0
- package/engine/{consolidation.js → memory/consolidation.js} +6 -6
- package/engine/{kb-sweep-runner.js → memory/kb-sweep-runner.js} +2 -2
- package/engine/{kb-sweep.js → memory/kb-sweep.js} +9 -7
- package/engine/{memory-retrieval.js → memory/memory-retrieval.js} +46 -4
- package/engine/{memory-store.js → memory/memory-store.js} +3 -3
- package/engine/{promotion.js → memory/promotion.js} +3 -3
- package/engine/{review-learning-backfill.js → memory/review-learning-backfill.js} +6 -6
- package/engine/{review-learning.js → memory/review-learning.js} +10 -5
- package/engine/{diagnostics-memory.js → observability/diagnostics-memory.js} +1 -1
- package/engine/{logs-store.js → observability/logs-store.js} +5 -5
- package/engine/{metrics-store.js → observability/metrics-store.js} +4 -4
- package/engine/{check-status.js → operations/check-status.js} +3 -3
- package/engine/{cli.js → operations/cli.js} +271 -113
- package/engine/{distribution.js → operations/distribution.js} +5 -6
- package/engine/{cleanup.js → orchestration/cleanup.js} +72 -45
- package/engine/{cooldown.js → orchestration/cooldown.js} +5 -5
- package/engine/{dispatch-events.js → orchestration/dispatch-events.js} +2 -2
- package/engine/{dispatch.js → orchestration/dispatch.js} +129 -36
- package/engine/orchestration/failed-scheduled-cleanup.js +274 -0
- package/engine/{lifecycle.js → orchestration/lifecycle.js} +198 -90
- package/engine/{meeting.js → orchestration/meeting.js} +6 -16
- package/engine/{pipeline.js → orchestration/pipeline.js} +12 -12
- package/engine/{pre-dispatch-eval.js → orchestration/pre-dispatch-eval.js} +10 -9
- package/engine/{routing.js → orchestration/routing.js} +3 -3
- package/engine/{schedule-bootstrap.js → orchestration/schedule-bootstrap.js} +4 -4
- package/engine/{scheduler.js → orchestration/scheduler.js} +38 -8
- package/engine/{timeout.js → orchestration/timeout.js} +158 -109
- package/engine/{db-events.js → persistence/db-events.js} +2 -2
- package/engine/{dispatch-store.js → persistence/dispatch-store.js} +7 -7
- package/engine/{inbox-store.js → persistence/inbox-store.js} +2 -2
- package/engine/{note-link-backfill.js → persistence/note-link-backfill.js} +4 -4
- package/engine/{pr-fix-target-store.js → persistence/pr-fix-target-store.js} +8 -8
- package/engine/{pull-requests-store.js → persistence/pull-requests-store.js} +21 -7
- package/engine/{small-state-store.js → persistence/small-state-store.js} +31 -31
- package/engine/persistence/state-operations.js +350 -0
- package/engine/{steering-store.js → persistence/steering-store.js} +6 -6
- package/engine/{issues.js → planning/issues.js} +2 -2
- package/engine/{plan-prd-validation.js → planning/plan-prd-validation.js} +8 -2
- package/engine/planning/prd-result-sidecar.js +190 -0
- package/engine/{prd-store.js → planning/prd-store.js} +17 -17
- package/engine/{project-discovery.js → planning/project-discovery.js} +5 -5
- package/engine/{projects.js → planning/projects.js} +10 -10
- package/engine/{resolve-area.js → planning/resolve-area.js} +1 -1
- package/engine/{work-item-validation.js → planning/work-item-validation.js} +39 -3
- package/engine/{work-items-store.js → planning/work-items-store.js} +29 -21
- package/engine/{keep-process-sweep.js → processes/keep-process-sweep.js} +57 -17
- package/engine/{managed-spawn-launcher.js → processes/managed-spawn-launcher.js} +3 -3
- package/engine/{managed-spawn.js → processes/managed-spawn.js} +97 -46
- package/engine/{process-utils.js → processes/process-utils.js} +599 -55
- package/engine/{abandoned-pr-reconciliation.js → providers/abandoned-pr-reconciliation.js} +17 -7
- package/engine/{comment-classifier.js → providers/comment-classifier.js} +85 -17
- package/engine/{comment-format.js → providers/comment-format.js} +5 -5
- package/engine/{gh-comment.js → providers/gh-comment.js} +15 -15
- package/engine/{gh-token.js → providers/gh-token.js} +4 -4
- package/engine/{github.js → providers/github.js} +131 -54
- package/engine/{pr-action.js → providers/pr-action.js} +13 -12
- package/engine/{pr-clone-keep.js → providers/pr-clone-keep.js} +7 -7
- package/engine/{pr-devbox.js → providers/pr-devbox.js} +6 -6
- package/engine/{pr-fix-target.js → providers/pr-fix-target.js} +13 -13
- package/engine/{pr-remote-patch.js → providers/pr-remote-patch.js} +4 -4
- package/engine/{pr-resolve.js → providers/pr-resolve.js} +7 -7
- package/engine/{pr-temp-clone.js → providers/pr-temp-clone.js} +5 -5
- package/engine/{pr-track.js → providers/pr-track.js} +11 -13
- package/engine/{shared-branch-pr-reconcile.js → providers/shared-branch-pr-reconcile.js} +4 -4
- package/engine/qa/auto-prd-qa.js +313 -0
- package/engine/{qa-from-prd.js → qa/from-prd.js} +42 -12
- package/engine/qa/prd-session.js +240 -0
- package/engine/{qa-process-validation.js → qa/process-validation.js} +14 -9
- package/engine/{qa-runbooks.js → qa/runbooks.js} +1 -1
- package/engine/{qa-runs.js → qa/runs.js} +286 -15
- package/engine/{qa-sessions.js → qa/sessions.js} +595 -49
- package/engine/qa/visual-journey.js +654 -0
- package/engine/{qa-runners.js → qa-runners/index.js} +7 -7
- package/engine/qa-runners/maestro.js +3 -3
- package/engine/qa-runners/playwright.js +2 -2
- package/engine/{restart-health.js → recovery/restart-health.js} +48 -4
- package/engine/recovery/stop-stack.js +607 -0
- package/engine/{supervisor.js → recovery/supervisor.js} +105 -175
- package/engine/{watchdog.js → recovery/watchdog.js} +136 -13
- package/engine/runtimes/claude.js +14 -12
- package/engine/runtimes/codex.js +8 -6
- package/engine/runtimes/copilot.js +17 -16
- package/engine/{watch-actions.js → watches/actions.js} +13 -13
- package/engine/{watches.js → watches/index.js} +43 -32
- package/engine/{watches-store.js → watches/store.js} +4 -4
- package/engine/{create-pr-worktree.js → worktrees/create-pr.js} +1 -1
- package/engine/{worktree-gc.js → worktrees/gc.js} +70 -22
- package/engine/worktrees/inventory.js +671 -0
- package/engine/{live-checkout.js → worktrees/live-checkout.js} +4 -4
- package/engine/{worktree-pool.js → worktrees/pool.js} +2 -2
- package/engine/{worktree-preflight.js → worktrees/preflight.js} +1 -0
- package/engine/worktrees/quarantine-refs.js +173 -0
- package/engine.js +1137 -208
- package/minions.js +147 -77
- package/package.json +10 -6
- package/playbooks/_pr-description-audit.md +110 -78
- package/playbooks/build-fix-complex.md +2 -0
- package/playbooks/fix.md +16 -12
- package/playbooks/implement-shared.md +2 -0
- package/playbooks/implement.md +19 -20
- package/playbooks/plan-to-prd.md +18 -3
- package/playbooks/qa-session-draft.md +136 -1
- package/playbooks/qa-session-execute.md +80 -2
- package/playbooks/qa-session-setup.md +17 -1
- package/playbooks/qa-validate.md +1 -1
- package/playbooks/setup.md +2 -0
- package/playbooks/shared-rules.md +25 -32
- package/playbooks/templates/followup-dispatch.md +4 -3
- package/playbooks/verify.md +1 -1
- package/prompts/cc-system.md +19 -27
- package/watch-plugins/README.md +92 -0
- package/watch-plugins/ado-author-prs.js +336 -0
- package/watch-plugins/gh-author-prs.js +375 -0
- package/watch-plugins/http.js +474 -0
- package/watch-plugins/teams-channel.js +869 -0
- package/docs/dev-composite-workflow.md +0 -101
- package/docs/pr-screenshots/pr-886/after-single-header.png +0 -0
- package/docs/pr-screenshots/pr-886/before-duplicate-header.png +0 -0
- package/docs/pr-screenshots/pr-895/01-cancellation-reason-detail.png +0 -0
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-901/projects-tab-default.png +0 -0
- package/docs/pr-screenshots/pr-901/projects-tab-fmf-selected.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-AFTER-crop.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-BEFORE-crop.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-dropdown-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
- package/docs/visual-evidence-ci.md +0 -103
- package/engine/bridge.js +0 -379
- package/engine/quarantine-refs.js +0 -103
- package/engine/state-operations.js +0 -178
- /package/engine/{steering-constraints.js → agents/steering-constraints.js} +0 -0
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* watch-plugins/teams-channel.js — Watch a Microsoft Teams channel for messages
|
|
3
|
+
*
|
|
4
|
+
* Use case: trigger a registered watch action for each new root message or
|
|
5
|
+
* reply in a configured Microsoft Teams channel.
|
|
6
|
+
*
|
|
7
|
+
* ── Target syntax ───────────────────────────────────────────────────────────
|
|
8
|
+
* target: 'teamId:channelId'
|
|
9
|
+
* → bare colon-delimited string. The first ':' splits team / channel.
|
|
10
|
+
* Channel ids have additional colons (`19:foo@thread.skype`); the
|
|
11
|
+
* parser uses indexOf — NOT lastIndexOf — so the channel side keeps its
|
|
12
|
+
* internal `:`.
|
|
13
|
+
*
|
|
14
|
+
* target: { teamId, channelId }
|
|
15
|
+
* → explicit form. Same semantics.
|
|
16
|
+
*
|
|
17
|
+
* ── Conditions ──────────────────────────────────────────────────────────────
|
|
18
|
+
* new-message — fires once per newly-observed message id. The candidate
|
|
19
|
+
* set includes BOTH root messages and thread replies, with
|
|
20
|
+
* system messages excluded. Each candidate carries
|
|
21
|
+
* `rootMessageId` (= the root for replies, = `id` for roots)
|
|
22
|
+
* and an `isReply` boolean.
|
|
23
|
+
*
|
|
24
|
+
* Burst handling: when N new messages land between ticks, the plugin
|
|
25
|
+
* advances its seen-set by exactly ONE per evaluation. The watch fires once
|
|
26
|
+
* per tick until the backlog drains — zero messages are lost even if a
|
|
27
|
+
* human posts five in the same minute. Costs ~30s × N to drain (one tick
|
|
28
|
+
* per message at the interval cadence).
|
|
29
|
+
*
|
|
30
|
+
* First tick after watch creation MUST NOT fire — the engine seeds
|
|
31
|
+
* `_lastState` by calling captureState() before evaluate() on the first
|
|
32
|
+
* check, so the entire baseline message set lands in prevState before
|
|
33
|
+
* evaluate runs. New messages only start firing from tick 2 onward.
|
|
34
|
+
*
|
|
35
|
+
* ── Templating vars exposed to actions ──────────────────────────────────────
|
|
36
|
+
* {{messageId}}, {{messageBody}} (HTML stripped, truncated to 2KB),
|
|
37
|
+
* {{authorDisplayName}}, {{authorEmail}}, {{createdDateTime}},
|
|
38
|
+
* {{messageWebUrl}}, {{teamId}}, {{channelId}},
|
|
39
|
+
* {{rootMessageId}} — for a root: same as {{messageId}}; for a reply: the
|
|
40
|
+
* parent root's id. Use this to thread follow-ups.
|
|
41
|
+
* {{isReply}} — boolean string ('true' / 'false'). Lets the action
|
|
42
|
+
* prompt distinguish reply context from a new bug
|
|
43
|
+
* report on the root.
|
|
44
|
+
* Plus standard watch vars (target, condition, watchId, message, …).
|
|
45
|
+
*
|
|
46
|
+
* ── Idempotency contract for downstream actions ─────────────────────────────
|
|
47
|
+
* Any work item created by an action wired to this watch SHOULD stamp the
|
|
48
|
+
* following provenance shape on `meta.teams_source` so consumers can
|
|
49
|
+
* deduplicate WIs that map back to the same Teams message:
|
|
50
|
+
*
|
|
51
|
+
* meta.teams_source = {
|
|
52
|
+
* teamId, // GUID
|
|
53
|
+
* channelId, // 19:…@thread.skype
|
|
54
|
+
* messageId, // the candidate id (root OR reply)
|
|
55
|
+
* rootMessageId, // the parent root id (== messageId for roots)
|
|
56
|
+
* messageWebUrl, // deep link back to the chat message
|
|
57
|
+
* authorEmail, // may be '' — Graph doesn't always expose it
|
|
58
|
+
* authorDisplayName, // may be '' — same caveat
|
|
59
|
+
* reportedAt, // ISO-8601 — the message's createdDateTime
|
|
60
|
+
* }
|
|
61
|
+
*
|
|
62
|
+
* This is a CONTRACT for action authors (e.g. the cc-triage prompt), not
|
|
63
|
+
* code in the plugin itself: this plugin does no WI mutation. The shape
|
|
64
|
+
* is intentionally flat — no nested objects — so it round-trips cleanly
|
|
65
|
+
* through JSON storage and is easy to query with `meta.teams_source.messageId`
|
|
66
|
+
* lookups. See the dispatched cc-triage prompt template stored on the watch
|
|
67
|
+
* action params for the canonical "always stamp before dispatching" rule.
|
|
68
|
+
*
|
|
69
|
+
* ── Fetch model ─────────────────────────────────────────────────────────────
|
|
70
|
+
* The engine's checkWatches loop is synchronous and runs inside a
|
|
71
|
+
* mutateJsonFileLocked callback, so this plugin maintains its own
|
|
72
|
+
* background-fetch cache. `fetchEntity` is sync: it returns the cached
|
|
73
|
+
* entity and, when the cached value is older than 60s (or absent), kicks
|
|
74
|
+
* off a background Graph refresh. First tick after a watch is created
|
|
75
|
+
* therefore returns null (no entity yet); the second tick captures the
|
|
76
|
+
* baseline; subsequent ticks detect new messages.
|
|
77
|
+
*
|
|
78
|
+
* Tests short-circuit this entirely by passing a pre-populated map at
|
|
79
|
+
* `state.teamsChannelMessages[targetKey]` (see `_targetKey`) — the plugin
|
|
80
|
+
* uses that shape directly, no shell-out required. Tests can also
|
|
81
|
+
* override the underlying Graph fetcher via `setLister(fn)` to drive the
|
|
82
|
+
* cache path.
|
|
83
|
+
*
|
|
84
|
+
* ── Auth ────────────────────────────────────────────────────────────────────
|
|
85
|
+
* Microsoft Graph bearer token, acquired via (default chain):
|
|
86
|
+
*
|
|
87
|
+
* 1. `az account get-access-token --resource https://graph.microsoft.com
|
|
88
|
+
* --query accessToken -o tsv` (primary)
|
|
89
|
+
* 2. `azureauth aad --resource https://graph.microsoft.com
|
|
90
|
+
* --timeout 1 --output token` (fallback when `az` is missing)
|
|
91
|
+
*
|
|
92
|
+
* The default chain uses the Azure CLI's well-known client app registration
|
|
93
|
+
* (`04b07795-8ddb-461a-bbee-02f9e1bf7b46`), which IS NOT preauthorized for
|
|
94
|
+
* the `ChannelMessage.Read.All` Graph permission required to list channel
|
|
95
|
+
* messages. With the default chain a `403 Forbidden` is the expected outcome
|
|
96
|
+
* on `GET /teams/{id}/channels/{id}/messages` even when the user is a
|
|
97
|
+
* tenant member — the bearer carries delegated scopes like Group.ReadWrite.All
|
|
98
|
+
* (enough to POST messages, per the team's teams-graph posting recipe) but
|
|
99
|
+
* NOT ChannelMessage.Read.All. To make this watch actually fire, configure
|
|
100
|
+
* a custom app registration on the watch target:
|
|
101
|
+
*
|
|
102
|
+
* target: {
|
|
103
|
+
* teamId: "<guid>", channelId: "19:...@thread.skype",
|
|
104
|
+
* auth: {
|
|
105
|
+
* method: "azureauth", // or "az"
|
|
106
|
+
* client: "<app-reg-guid>", // app reg with ChannelMessage.Read.All
|
|
107
|
+
* // admin-consented in the tenant
|
|
108
|
+
* tenant: "<tenant-guid>", // optional — defaults to home tenant
|
|
109
|
+
* scope: ".default" // optional — defaults to <resource>/.default
|
|
110
|
+
* }
|
|
111
|
+
* }
|
|
112
|
+
*
|
|
113
|
+
* Tokens are cached in-process for 25 min (Entra access tokens normally last
|
|
114
|
+
* ~1 h). Cache is keyed by the serialized auth-config so two watches with
|
|
115
|
+
* different `target.auth` never share a token.
|
|
116
|
+
*
|
|
117
|
+
* Security: the token is ONLY passed to Graph via the request
|
|
118
|
+
* `Authorization: Bearer …` header. It never appears on argv, never in
|
|
119
|
+
* logs, never in persisted watch records. Plugin trust level matches playbooks
|
|
120
|
+
* (user-trusted). `target.auth.client` / `tenant` are only forwarded as
|
|
121
|
+
* argv values to `az` or `azureauth` (argv-array spawn — no shell
|
|
122
|
+
* interpolation) so values containing shell metacharacters cannot escape.
|
|
123
|
+
*
|
|
124
|
+
* ── Error surfacing ─────────────────────────────────────────────────────────
|
|
125
|
+
* Graph error responses are parsed (`error.code` + `error.message` +
|
|
126
|
+
* `error.innerError.request-id`) and surfaced verbatim on `_lastState.lastError`
|
|
127
|
+
* alongside a one-line remediation hint — "Missing Graph permission
|
|
128
|
+
* ChannelMessage.Read.All — set target.auth.client to an app registration
|
|
129
|
+
* that has admin-consented this scope" — so the dashboard / `notes/inbox`
|
|
130
|
+
* reader can act on the failure without grepping engine logs.
|
|
131
|
+
*
|
|
132
|
+
* ── Out of scope (left as TODOs) ────────────────────────────────────────────
|
|
133
|
+
* - Attachments, reactions, edit/delete detection, multi-channel watch.
|
|
134
|
+
*/
|
|
135
|
+
|
|
136
|
+
const { spawn } = require('child_process');
|
|
137
|
+
const https = require('https');
|
|
138
|
+
|
|
139
|
+
function ts() { return new Date().toISOString(); }
|
|
140
|
+
function _warn(msg) { try { console.warn(`[${ts()}] [warn] ${msg}`); } catch {} }
|
|
141
|
+
|
|
142
|
+
const CACHE_FRESHNESS_MS = 60 * 1000;
|
|
143
|
+
const TOKEN_TTL_MS = 25 * 60 * 1000;
|
|
144
|
+
const SHELL_TIMEOUT_MS = 15 * 1000;
|
|
145
|
+
const GRAPH_TIMEOUT_MS = 15 * 1000;
|
|
146
|
+
const SEEN_IDS_CAP = 500; // bound state growth per watch
|
|
147
|
+
const MESSAGE_BODY_MAX_BYTES = 2048;
|
|
148
|
+
const GRAPH_RESOURCE = 'https://graph.microsoft.com';
|
|
149
|
+
|
|
150
|
+
// Module-level caches. Survive across ticks; reset on engine restart.
|
|
151
|
+
const _entityCache = new Map(); // key -> { messages, fetchedAt, error? }
|
|
152
|
+
const _inFlight = new Set();
|
|
153
|
+
// Token cache is keyed by serialized auth config so different watches with
|
|
154
|
+
// different `target.auth` settings never clobber each other's tokens. Default
|
|
155
|
+
// (no auth specified) lives under the 'default' key. Tokens never appear in
|
|
156
|
+
// the key itself — only the auth-config shape that produced them.
|
|
157
|
+
const _cachedTokens = new Map(); // authKey -> { token, expiresAt }
|
|
158
|
+
|
|
159
|
+
// Override-able listers — defaults use real Graph fetch. Tests inject mocks.
|
|
160
|
+
//
|
|
161
|
+
// THREE injection points (use whichever fits the test surface):
|
|
162
|
+
// setLister(fn) → override the ENTIRE composed pipeline. The fn
|
|
163
|
+
// receives `cfg` and must return the full
|
|
164
|
+
// { messages: [...], fetchedAt, error? } envelope.
|
|
165
|
+
// Bypasses both rootsLister and replyLister.
|
|
166
|
+
// setRootsLister(fn) → override just the roots fetch. Composer still
|
|
167
|
+
// fans out per-root replies via _replyLister.
|
|
168
|
+
// setReplyLister(fn) → override the per-root replies fetcher used by
|
|
169
|
+
// the default composer. Throwing simulates per-root
|
|
170
|
+
// Graph failure — must NOT break the tick.
|
|
171
|
+
//
|
|
172
|
+
// The composer is the production wiring; test it end-to-end by stubbing
|
|
173
|
+
// rootsLister + replyLister together.
|
|
174
|
+
let _lister = _defaultComposedList;
|
|
175
|
+
let _rootsLister = _defaultGraphListRoots;
|
|
176
|
+
let _replyLister = _defaultGraphListReplies;
|
|
177
|
+
function setLister(fn) { _lister = typeof fn === 'function' ? fn : _defaultComposedList; }
|
|
178
|
+
function setRootsLister(fn) { _rootsLister = typeof fn === 'function' ? fn : _defaultGraphListRoots; }
|
|
179
|
+
function setReplyLister(fn) { _replyLister = typeof fn === 'function' ? fn : _defaultGraphListReplies; }
|
|
180
|
+
function _resetCache() { _entityCache.clear(); _inFlight.clear(); _cachedTokens.clear(); }
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Validate + normalize `target.auth`. Returns null when `auth` is absent or
|
|
184
|
+
* malformed (caller falls back to the default az → azureauth chain). The
|
|
185
|
+
* returned shape is { method, client?, tenant?, scope? } with strings only —
|
|
186
|
+
* forwarded as argv values to `az` / `azureauth` so all fields MUST be
|
|
187
|
+
* strings (no shell interpolation, but we still reject non-string inputs
|
|
188
|
+
* defensively so an accidental `{ client: { foo: 'bar' } }` doesn't crash
|
|
189
|
+
* the spawn call).
|
|
190
|
+
*/
|
|
191
|
+
function _normalizeAuthCfg(auth) {
|
|
192
|
+
if (!auth || typeof auth !== 'object') return null;
|
|
193
|
+
const method = typeof auth.method === 'string' ? auth.method.trim().toLowerCase() : '';
|
|
194
|
+
if (method !== 'az' && method !== 'azureauth') return null;
|
|
195
|
+
const out = { method };
|
|
196
|
+
for (const k of ['client', 'tenant', 'scope']) {
|
|
197
|
+
if (auth[k] != null) {
|
|
198
|
+
if (typeof auth[k] !== 'string') return null;
|
|
199
|
+
const v = auth[k].trim();
|
|
200
|
+
if (v) out[k] = v;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (method === 'azureauth' && !out.client) {
|
|
204
|
+
// azureauth requires --client; without it the tool prompts interactively
|
|
205
|
+
// and we'd hang the tick. Reject up-front so the warn surfaces the cause.
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function _authCacheKey(authCfg) {
|
|
212
|
+
if (!authCfg) return 'default';
|
|
213
|
+
// Stable serialization — keys are sorted, so { tenant, client } and
|
|
214
|
+
// { client, tenant } collapse to the same cache entry.
|
|
215
|
+
const keys = Object.keys(authCfg).sort();
|
|
216
|
+
return keys.map((k) => `${k}=${authCfg[k]}`).join('|');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function _resolveCfg(target) {
|
|
220
|
+
if (typeof target === 'string' && target.includes(':')) {
|
|
221
|
+
// First ':' splits team / channel — channel ids contain internal colons
|
|
222
|
+
// like `19:abc@thread.skype` so lastIndexOf would mangle them.
|
|
223
|
+
const idx = target.indexOf(':');
|
|
224
|
+
const teamId = target.slice(0, idx).trim();
|
|
225
|
+
const channelId = target.slice(idx + 1).trim();
|
|
226
|
+
if (!teamId || !channelId) return null;
|
|
227
|
+
return { teamId, channelId, auth: null };
|
|
228
|
+
}
|
|
229
|
+
if (target && typeof target === 'object'
|
|
230
|
+
&& typeof target.teamId === 'string' && target.teamId
|
|
231
|
+
&& typeof target.channelId === 'string' && target.channelId) {
|
|
232
|
+
return {
|
|
233
|
+
teamId: target.teamId.trim(),
|
|
234
|
+
channelId: target.channelId.trim(),
|
|
235
|
+
auth: _normalizeAuthCfg(target.auth),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function _validateTarget(target) {
|
|
242
|
+
const cfg = _resolveCfg(target);
|
|
243
|
+
if (!cfg || !cfg.teamId || !cfg.channelId) {
|
|
244
|
+
return 'target must be "teamId:channelId" or an object with teamId and channelId';
|
|
245
|
+
}
|
|
246
|
+
if (target && typeof target === 'object'
|
|
247
|
+
&& target.auth !== undefined
|
|
248
|
+
&& target.auth !== null
|
|
249
|
+
&& !_normalizeAuthCfg(target.auth)) {
|
|
250
|
+
return 'target.auth must declare a valid az or azureauth configuration';
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function _targetKey(target) {
|
|
256
|
+
const cfg = _resolveCfg(target);
|
|
257
|
+
if (!cfg) return typeof target === 'string' ? target : JSON.stringify(target);
|
|
258
|
+
// Include auth in the cache key so two watches against the same channel
|
|
259
|
+
// but with different `target.auth` cache entities independently — otherwise
|
|
260
|
+
// a 403 on one would leak to the other's _lastState.lastError.
|
|
261
|
+
const authKey = _authCacheKey(cfg.auth);
|
|
262
|
+
return authKey === 'default'
|
|
263
|
+
? `${cfg.teamId}::${cfg.channelId}`
|
|
264
|
+
: `${cfg.teamId}::${cfg.channelId}::${authKey}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Run a shell command with captured stdout. Never throws on non-zero exit.
|
|
269
|
+
* Returns { code, stdout, stderr }.
|
|
270
|
+
*/
|
|
271
|
+
function _runCmd(cmd, args, timeoutMs) {
|
|
272
|
+
return new Promise((resolve) => {
|
|
273
|
+
// Windows: enable shell so .cmd shims like `az.cmd` resolve via PATHEXT.
|
|
274
|
+
// All args here are hardcoded constants — no injection surface.
|
|
275
|
+
const child = spawn(cmd, args, {
|
|
276
|
+
env: process.env,
|
|
277
|
+
windowsHide: true,
|
|
278
|
+
shell: process.platform === 'win32',
|
|
279
|
+
});
|
|
280
|
+
let stdout = '';
|
|
281
|
+
let stderr = '';
|
|
282
|
+
let settled = false;
|
|
283
|
+
const timer = setTimeout(() => {
|
|
284
|
+
if (settled) return;
|
|
285
|
+
settled = true;
|
|
286
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
287
|
+
resolve({ code: -1, stdout, stderr: stderr + `\n[teams-channel] ${cmd} timeout after ${timeoutMs}ms` });
|
|
288
|
+
}, timeoutMs);
|
|
289
|
+
child.stdout.on('data', (b) => { stdout += b.toString('utf8'); });
|
|
290
|
+
child.stderr.on('data', (b) => { stderr += b.toString('utf8'); });
|
|
291
|
+
child.on('error', (err) => {
|
|
292
|
+
if (settled) return;
|
|
293
|
+
settled = true;
|
|
294
|
+
clearTimeout(timer);
|
|
295
|
+
resolve({ code: -1, stdout, stderr: stderr + `\n[teams-channel] ${cmd} spawn error: ${err.message}` });
|
|
296
|
+
});
|
|
297
|
+
child.on('close', (code) => {
|
|
298
|
+
if (settled) return;
|
|
299
|
+
settled = true;
|
|
300
|
+
clearTimeout(timer);
|
|
301
|
+
resolve({ code: code == null ? -1 : code, stdout, stderr });
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function _resolveToken(authCfg) {
|
|
307
|
+
const cacheKey = _authCacheKey(authCfg);
|
|
308
|
+
const cached = _cachedTokens.get(cacheKey);
|
|
309
|
+
if (cached && cached.expiresAt > Date.now()) return cached.token;
|
|
310
|
+
|
|
311
|
+
// Explicit per-watch auth config — single provider, no fallback chain
|
|
312
|
+
// (the user picked the method; chaining would mask the misconfiguration).
|
|
313
|
+
if (authCfg) {
|
|
314
|
+
const cmd = authCfg.method === 'azureauth' ? 'azureauth' : 'az';
|
|
315
|
+
const args = authCfg.method === 'azureauth'
|
|
316
|
+
? _buildAzureauthArgs(authCfg)
|
|
317
|
+
: _buildAzArgs(authCfg);
|
|
318
|
+
const res = await _runCmd(cmd, args, SHELL_TIMEOUT_MS);
|
|
319
|
+
const token = res.code === 0 ? _extractTokenFromOutput(authCfg.method, res.stdout) : '';
|
|
320
|
+
if (token) {
|
|
321
|
+
_cachedTokens.set(cacheKey, { token, expiresAt: Date.now() + TOKEN_TTL_MS });
|
|
322
|
+
return token;
|
|
323
|
+
}
|
|
324
|
+
_warn(`teams-channel: token fetch failed via ${authCfg.method}. exit=${res.code} (${(res.stderr || '').trim().slice(0, 200)})`);
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Default chain: az then azureauth. NOTE: this chain uses the Azure CLI's
|
|
329
|
+
// well-known client app reg, which lacks ChannelMessage.Read.All. Expect
|
|
330
|
+
// 403 on channel-message reads unless the watch overrides via target.auth.
|
|
331
|
+
const azRes = await _runCmd(
|
|
332
|
+
'az',
|
|
333
|
+
['account', 'get-access-token', '--resource', GRAPH_RESOURCE, '--query', 'accessToken', '-o', 'tsv'],
|
|
334
|
+
SHELL_TIMEOUT_MS,
|
|
335
|
+
);
|
|
336
|
+
if (azRes.code === 0 && azRes.stdout.trim()) {
|
|
337
|
+
const token = azRes.stdout.trim();
|
|
338
|
+
_cachedTokens.set(cacheKey, { token, expiresAt: Date.now() + TOKEN_TTL_MS });
|
|
339
|
+
return token;
|
|
340
|
+
}
|
|
341
|
+
// Fallback: azureauth (1ES helper). `azureauth aad --output token` prints
|
|
342
|
+
// ONLY the raw bearer token on stdout (no extra status text). The `--timeout 1`
|
|
343
|
+
// caps interactive prompts to 1 minute. Note the v0.9 CLI uses `aad`, not
|
|
344
|
+
// `aad token` (there is no `token` subcommand).
|
|
345
|
+
const aaRes = await _runCmd(
|
|
346
|
+
'azureauth',
|
|
347
|
+
['aad', '--resource', GRAPH_RESOURCE, '--timeout', '1', '--output', 'token'],
|
|
348
|
+
SHELL_TIMEOUT_MS,
|
|
349
|
+
);
|
|
350
|
+
if (aaRes.code === 0 && aaRes.stdout.trim()) {
|
|
351
|
+
const token = aaRes.stdout.trim();
|
|
352
|
+
_cachedTokens.set(cacheKey, { token, expiresAt: Date.now() + TOKEN_TTL_MS });
|
|
353
|
+
return token;
|
|
354
|
+
}
|
|
355
|
+
_warn(`teams-channel: token fetch failed. az exit=${azRes.code} (${(azRes.stderr || '').trim().slice(0, 200)}); azureauth exit=${aaRes.code} (${(aaRes.stderr || '').trim().slice(0, 200)})`);
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function _buildAzArgs(authCfg) {
|
|
360
|
+
// `az account get-access-token` produces TSV when --query is used. For
|
|
361
|
+
// user-supplied scope/tenant we forward as argv values (no shell).
|
|
362
|
+
const args = ['account', 'get-access-token'];
|
|
363
|
+
if (authCfg.scope) args.push('--scope', authCfg.scope);
|
|
364
|
+
else args.push('--resource', GRAPH_RESOURCE);
|
|
365
|
+
if (authCfg.tenant) args.push('--tenant', authCfg.tenant);
|
|
366
|
+
args.push('--query', 'accessToken', '-o', 'tsv');
|
|
367
|
+
return args;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function _buildAzureauthArgs(authCfg) {
|
|
371
|
+
// azureauth aad --client <id> --resource <url> [--tenant <id>] [--scope <s>]
|
|
372
|
+
// --timeout 1 --output token
|
|
373
|
+
// --output token prints ONLY the raw bearer on stdout.
|
|
374
|
+
const args = ['aad', '--client', authCfg.client, '--resource', GRAPH_RESOURCE];
|
|
375
|
+
if (authCfg.tenant) args.push('--tenant', authCfg.tenant);
|
|
376
|
+
if (authCfg.scope) args.push('--scope', authCfg.scope);
|
|
377
|
+
args.push('--timeout', '1', '--output', 'token');
|
|
378
|
+
return args;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function _extractTokenFromOutput(method, stdout) {
|
|
382
|
+
const s = String(stdout || '').trim();
|
|
383
|
+
if (!s) return '';
|
|
384
|
+
// Both `az ... -o tsv` and `azureauth ... --output token` emit just the
|
|
385
|
+
// bearer on stdout. Strip whitespace + trailing newlines defensively.
|
|
386
|
+
return s.split(/\s+/)[0] || '';
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Strip HTML to plain text, collapse whitespace, truncate to MESSAGE_BODY_MAX_BYTES.
|
|
391
|
+
* No regex-based sanitization is needed for security — the body is treated as
|
|
392
|
+
* untrusted input wrapped in <UNTRUSTED-INPUT> when handed to CC.
|
|
393
|
+
*/
|
|
394
|
+
function _stripHtml(html) {
|
|
395
|
+
if (typeof html !== 'string' || !html) return '';
|
|
396
|
+
// Remove <script> / <style> blocks outright.
|
|
397
|
+
let s = html
|
|
398
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, ' ')
|
|
399
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, ' ');
|
|
400
|
+
// Convert <br> and block-level closers to newlines so structure survives strip.
|
|
401
|
+
s = s.replace(/<\s*(br|\/p|\/div|\/li|\/h[1-6])\b[^>]*>/gi, '\n');
|
|
402
|
+
// Drop all remaining tags.
|
|
403
|
+
s = s.replace(/<[^>]+>/g, '');
|
|
404
|
+
// Decode the most common HTML entities (Teams only emits these handful).
|
|
405
|
+
s = s
|
|
406
|
+
.replace(/ /g, ' ')
|
|
407
|
+
.replace(/&/g, '&')
|
|
408
|
+
.replace(/</g, '<')
|
|
409
|
+
.replace(/>/g, '>')
|
|
410
|
+
.replace(/"/g, '"')
|
|
411
|
+
.replace(/'/g, "'")
|
|
412
|
+
.replace(/'/g, "'");
|
|
413
|
+
// Collapse runs of whitespace, trim.
|
|
414
|
+
s = s.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').replace(/[ \t]{2,}/g, ' ').trim();
|
|
415
|
+
if (s.length > MESSAGE_BODY_MAX_BYTES) {
|
|
416
|
+
s = s.slice(0, MESSAGE_BODY_MAX_BYTES) + '…';
|
|
417
|
+
}
|
|
418
|
+
return s;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* GET https://graph.microsoft.com/v1.0/teams/{teamId}/channels/{channelId}/messages
|
|
423
|
+
* ?$top=20&$orderby=createdDateTime desc
|
|
424
|
+
* Token passed via Authorization header — never on argv, never logged.
|
|
425
|
+
*/
|
|
426
|
+
function _graphGet(pathPart, token) {
|
|
427
|
+
return new Promise((resolve) => {
|
|
428
|
+
const opts = {
|
|
429
|
+
method: 'GET',
|
|
430
|
+
hostname: 'graph.microsoft.com',
|
|
431
|
+
port: 443,
|
|
432
|
+
path: pathPart,
|
|
433
|
+
headers: {
|
|
434
|
+
Authorization: `Bearer ${token}`,
|
|
435
|
+
Accept: 'application/json',
|
|
436
|
+
'User-Agent': 'minions-watch-teams-channel',
|
|
437
|
+
},
|
|
438
|
+
timeout: GRAPH_TIMEOUT_MS,
|
|
439
|
+
};
|
|
440
|
+
const req = https.request(opts, (res) => {
|
|
441
|
+
const chunks = [];
|
|
442
|
+
res.on('data', (c) => chunks.push(c));
|
|
443
|
+
res.on('end', () => {
|
|
444
|
+
resolve({
|
|
445
|
+
status: res.statusCode || 0,
|
|
446
|
+
body: Buffer.concat(chunks).toString('utf8'),
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
req.on('error', (err) => resolve({ status: 0, body: '', error: err.message || String(err) }));
|
|
451
|
+
req.on('timeout', () => { req.destroy(new Error(`graph timeout after ${GRAPH_TIMEOUT_MS}ms`)); });
|
|
452
|
+
req.end();
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Parse a Graph JSON error body into structured fields. Graph always returns
|
|
458
|
+
* { "error": { "code": "...", "message": "...", "innerError": { "request-id": "..." } } }
|
|
459
|
+
* on 4xx/5xx, so this normalizes the shape. Returns null when the body isn't
|
|
460
|
+
* parseable JSON or doesn't carry an error envelope — caller falls back to
|
|
461
|
+
* the raw body snippet.
|
|
462
|
+
*
|
|
463
|
+
* Also detects the canonical "Missing scope permissions on the request. API
|
|
464
|
+
* requires one of 'X, Y'" message and lifts the required-scope list into a
|
|
465
|
+
* dedicated `missingScopes` array for the remediation hint.
|
|
466
|
+
*/
|
|
467
|
+
function _parseGraphError(body) {
|
|
468
|
+
if (typeof body !== 'string' || !body) return null;
|
|
469
|
+
let parsed;
|
|
470
|
+
try { parsed = JSON.parse(body); } catch { return null; }
|
|
471
|
+
const err = parsed && parsed.error;
|
|
472
|
+
if (!err || typeof err !== 'object') return null;
|
|
473
|
+
const code = typeof err.code === 'string' ? err.code : '';
|
|
474
|
+
const message = typeof err.message === 'string' ? err.message : '';
|
|
475
|
+
const inner = err.innerError && typeof err.innerError === 'object' ? err.innerError : {};
|
|
476
|
+
// Graph uses both `request-id` (kebab) and `requestId` (camel) in different
|
|
477
|
+
// surfaces — accept either so the request-id always makes it into the log.
|
|
478
|
+
const requestId = String(inner['request-id'] || inner.requestId || '');
|
|
479
|
+
// Lift "API requires one of 'X[, Y]'" — single-quoted comma list.
|
|
480
|
+
const scopeMatch = message.match(/API requires one of '([^']+)'/i);
|
|
481
|
+
const missingScopes = scopeMatch
|
|
482
|
+
? scopeMatch[1].split(',').map((s) => s.trim()).filter(Boolean)
|
|
483
|
+
: [];
|
|
484
|
+
return { code, message, requestId, missingScopes };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* One-line remediation hint for a Graph auth failure. Designed to be
|
|
489
|
+
* actionable in the dashboard tooltip without needing to read code or logs.
|
|
490
|
+
* Returns '' when no hint is available (caller appends nothing).
|
|
491
|
+
*/
|
|
492
|
+
function _remediationHint(status, parsed, authCfg) {
|
|
493
|
+
if (parsed && parsed.missingScopes && parsed.missingScopes.length > 0) {
|
|
494
|
+
const scopes = parsed.missingScopes.join(', ');
|
|
495
|
+
if (authCfg && authCfg.method === 'azureauth' && authCfg.client) {
|
|
496
|
+
return `Missing Graph permission ${scopes} on app reg ${authCfg.client} — admin-consent this scope in the tenant.`;
|
|
497
|
+
}
|
|
498
|
+
if (authCfg && authCfg.method === 'az') {
|
|
499
|
+
return `Missing Graph permission ${scopes} — the az CLI client app does not preauthorize this scope. Switch target.auth.method to 'azureauth' with a custom app reg that has ${scopes} admin-consented.`;
|
|
500
|
+
}
|
|
501
|
+
return `Missing Graph permission ${scopes} — default az/azureauth chain uses the Azure CLI client which does not preauthorize this scope. Set target.auth = { method: 'azureauth', client: '<app-reg-guid>' } pointing at an app registration that has ${scopes} admin-consented.`;
|
|
502
|
+
}
|
|
503
|
+
if (status === 401) {
|
|
504
|
+
return authCfg
|
|
505
|
+
? `Graph token rejected (401) — re-acquire via '${authCfg.method} ...' (token may be expired or revoked).`
|
|
506
|
+
: `Graph token rejected (401) — refresh sign-in: 'az login' or 'azureauth aad --client <app-reg>'.`;
|
|
507
|
+
}
|
|
508
|
+
if (status === 403 && parsed && /Authorization_RequestDenied/i.test(parsed.code || '')) {
|
|
509
|
+
return `Graph rejected the request (Authorization_RequestDenied) — verify the signed-in identity is a member of team ${''} and that the app registration is admin-consented in the tenant.`;
|
|
510
|
+
}
|
|
511
|
+
return '';
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Build the final error string surfaced on _lastState.lastError. Always
|
|
516
|
+
* prefixed with `graph auth failed: HTTP <status>` for 401/403 to preserve
|
|
517
|
+
* backward-compatible substring matching (existing tests assert /401/ or
|
|
518
|
+
* /403/). The parsed Graph code/message and the remediation hint are
|
|
519
|
+
* appended after a separator.
|
|
520
|
+
*/
|
|
521
|
+
function _buildGraphAuthError(status, body, authCfg) {
|
|
522
|
+
const parsed = _parseGraphError(body);
|
|
523
|
+
const baseline = `graph auth failed: HTTP ${status}`;
|
|
524
|
+
const parts = [baseline];
|
|
525
|
+
if (parsed && parsed.code) parts.push(parsed.code);
|
|
526
|
+
if (parsed && parsed.message) parts.push(parsed.message.slice(0, 240));
|
|
527
|
+
const hint = _remediationHint(status, parsed, authCfg);
|
|
528
|
+
if (hint) parts.push(`Hint: ${hint}`);
|
|
529
|
+
if (parsed && parsed.requestId) parts.push(`[request-id ${parsed.requestId}]`);
|
|
530
|
+
return parts.join(' — ');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Build the error for non-auth Graph failures (5xx, 4xx other than 401/403,
|
|
535
|
+
* network errors). Same structure but without the auth-specific hint.
|
|
536
|
+
*/
|
|
537
|
+
function _buildGraphHttpError(status, body) {
|
|
538
|
+
const parsed = _parseGraphError(body);
|
|
539
|
+
const baseline = `graph HTTP ${status}`;
|
|
540
|
+
if (!parsed) return `${baseline}: ${(body || '').slice(0, 300)}`;
|
|
541
|
+
const parts = [baseline];
|
|
542
|
+
if (parsed.code) parts.push(parsed.code);
|
|
543
|
+
if (parsed.message) parts.push(parsed.message.slice(0, 240));
|
|
544
|
+
if (parsed.requestId) parts.push(`[request-id ${parsed.requestId}]`);
|
|
545
|
+
return parts.join(' — ');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Normalize a raw Graph chatMessage payload to the plugin's compact shape.
|
|
550
|
+
* `rootIdOverride` is supplied when this is a reply — replies in Graph carry
|
|
551
|
+
* `replyToId` / no `webUrl` of their own (Graph uses the root's webUrl with a
|
|
552
|
+
* fragment), so the parent root id is threaded in by the caller.
|
|
553
|
+
*/
|
|
554
|
+
function _shapeMessage(m, rootIdOverride) {
|
|
555
|
+
const isReply = rootIdOverride != null && rootIdOverride !== m.id;
|
|
556
|
+
const rootMessageId = rootIdOverride != null ? String(rootIdOverride) : String(m.id);
|
|
557
|
+
return {
|
|
558
|
+
id: String(m.id),
|
|
559
|
+
rootMessageId,
|
|
560
|
+
isReply,
|
|
561
|
+
bodyHtml: m.body && typeof m.body.content === 'string' ? m.body.content : '',
|
|
562
|
+
contentType: m.body && m.body.contentType ? String(m.body.contentType) : 'text',
|
|
563
|
+
createdDateTime: String(m.createdDateTime || ''),
|
|
564
|
+
webUrl: String(m.webUrl || ''),
|
|
565
|
+
authorDisplayName: m.from && m.from.user && m.from.user.displayName
|
|
566
|
+
? String(m.from.user.displayName) : '',
|
|
567
|
+
// Graph's chatMessage `from.user` exposes id (oid) but NOT email — that
|
|
568
|
+
// lives in `.from.user.userIdentityType`-dependent extension fields. The
|
|
569
|
+
// dashboard cc-triage prompt links the webUrl so a missing email is not
|
|
570
|
+
// load-bearing; surfacing whatever Graph returns is enough.
|
|
571
|
+
authorEmail: m.from && m.from.user && (m.from.user.email || m.from.user.userPrincipalName)
|
|
572
|
+
? String(m.from.user.email || m.from.user.userPrincipalName) : '',
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Fetch the top-20 root messages for a channel.
|
|
578
|
+
* Returns { messages, error?, fetchedAt }. Never throws.
|
|
579
|
+
*/
|
|
580
|
+
async function _defaultGraphListRoots(cfg) {
|
|
581
|
+
const token = await _resolveToken(cfg && cfg.auth);
|
|
582
|
+
if (!token) {
|
|
583
|
+
return { messages: [], error: 'no graph token available', fetchedAt: ts() };
|
|
584
|
+
}
|
|
585
|
+
// Use encodeURIComponent on ids — channel ids contain `:` and `@` which
|
|
586
|
+
// are reserved in URL paths. teamId is a plain GUID but encode for safety.
|
|
587
|
+
const team = encodeURIComponent(cfg.teamId);
|
|
588
|
+
const channel = encodeURIComponent(cfg.channelId);
|
|
589
|
+
const path = `/v1.0/teams/${team}/channels/${channel}/messages?$top=20&$orderby=createdDateTime%20desc`;
|
|
590
|
+
const res = await _graphGet(path, token);
|
|
591
|
+
if (res.error) {
|
|
592
|
+
return { messages: [], error: `graph request error: ${res.error}`, fetchedAt: ts() };
|
|
593
|
+
}
|
|
594
|
+
if (res.status === 401 || res.status === 403) {
|
|
595
|
+
// Force a token re-fetch on next tick — current token might be expired.
|
|
596
|
+
_cachedTokens.delete(_authCacheKey(cfg && cfg.auth));
|
|
597
|
+
return {
|
|
598
|
+
messages: [],
|
|
599
|
+
error: _buildGraphAuthError(res.status, res.body, cfg && cfg.auth),
|
|
600
|
+
fetchedAt: ts(),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
if (res.status < 200 || res.status >= 300) {
|
|
604
|
+
return {
|
|
605
|
+
messages: [],
|
|
606
|
+
error: _buildGraphHttpError(res.status, res.body),
|
|
607
|
+
fetchedAt: ts(),
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
let parsed;
|
|
611
|
+
try { parsed = JSON.parse(res.body || '{}'); }
|
|
612
|
+
catch (e) {
|
|
613
|
+
return { messages: [], error: `graph JSON parse failed: ${e.message}`, fetchedAt: ts() };
|
|
614
|
+
}
|
|
615
|
+
const arr = Array.isArray(parsed && parsed.value) ? parsed.value : [];
|
|
616
|
+
const messages = arr
|
|
617
|
+
.filter((m) => m && m.id && m.messageType === 'message' && m.from)
|
|
618
|
+
.map((m) => _shapeMessage(m, m.id));
|
|
619
|
+
return { messages, fetchedAt: ts() };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Fetch replies for a single root message. Returns { messages: [...] } or
|
|
624
|
+
* throws — the caller is responsible for swallowing errors so one bad root
|
|
625
|
+
* doesn't break the whole tick.
|
|
626
|
+
*/
|
|
627
|
+
async function _defaultGraphListReplies(cfg, rootId) {
|
|
628
|
+
const token = await _resolveToken(cfg && cfg.auth);
|
|
629
|
+
if (!token) {
|
|
630
|
+
const err = new Error('no graph token available');
|
|
631
|
+
err.code = 'NO_TOKEN';
|
|
632
|
+
throw err;
|
|
633
|
+
}
|
|
634
|
+
const team = encodeURIComponent(cfg.teamId);
|
|
635
|
+
const channel = encodeURIComponent(cfg.channelId);
|
|
636
|
+
const root = encodeURIComponent(rootId);
|
|
637
|
+
const path = `/v1.0/teams/${team}/channels/${channel}/messages/${root}/replies?$top=20&$orderby=createdDateTime%20desc`;
|
|
638
|
+
const res = await _graphGet(path, token);
|
|
639
|
+
if (res.error) {
|
|
640
|
+
throw new Error(`graph request error: ${res.error}`);
|
|
641
|
+
}
|
|
642
|
+
if (res.status === 401 || res.status === 403) {
|
|
643
|
+
_cachedTokens.delete(_authCacheKey(cfg && cfg.auth));
|
|
644
|
+
throw new Error(_buildGraphAuthError(res.status, res.body, cfg && cfg.auth));
|
|
645
|
+
}
|
|
646
|
+
if (res.status < 200 || res.status >= 300) {
|
|
647
|
+
throw new Error(_buildGraphHttpError(res.status, res.body));
|
|
648
|
+
}
|
|
649
|
+
let parsed;
|
|
650
|
+
try { parsed = JSON.parse(res.body || '{}'); }
|
|
651
|
+
catch (e) {
|
|
652
|
+
throw new Error(`graph JSON parse failed: ${e.message}`);
|
|
653
|
+
}
|
|
654
|
+
const arr = Array.isArray(parsed && parsed.value) ? parsed.value : [];
|
|
655
|
+
const messages = arr
|
|
656
|
+
.filter((m) => m && m.id && m.messageType === 'message' && m.from)
|
|
657
|
+
.map((m) => _shapeMessage(m, rootId));
|
|
658
|
+
return { messages };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Default composed lister — fetch roots (via `_rootsLister`), then in
|
|
663
|
+
* parallel fetch replies for each root (via `_replyLister`). Per-root
|
|
664
|
+
* reply-fetch failures are logged and skipped so one bad root doesn't
|
|
665
|
+
* break the whole tick. Returns the merged candidate set with the
|
|
666
|
+
* root-level error (if any) preserved on the envelope.
|
|
667
|
+
*/
|
|
668
|
+
async function _defaultComposedList(cfg) {
|
|
669
|
+
const rootEnv = await _rootsLister(cfg);
|
|
670
|
+
const messages = (rootEnv && Array.isArray(rootEnv.messages))
|
|
671
|
+
? rootEnv.messages.slice() : [];
|
|
672
|
+
// If the roots fetch errored, surface the error but skip reply fetches —
|
|
673
|
+
// there's nothing to fetch replies for.
|
|
674
|
+
if ((rootEnv && rootEnv.error) || messages.length === 0) {
|
|
675
|
+
return {
|
|
676
|
+
messages,
|
|
677
|
+
error: (rootEnv && rootEnv.error) || null,
|
|
678
|
+
fetchedAt: (rootEnv && rootEnv.fetchedAt) || ts(),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
const settled = await Promise.allSettled(
|
|
682
|
+
messages.map((root) => _replyLister(cfg, root.id))
|
|
683
|
+
);
|
|
684
|
+
for (let i = 0; i < settled.length; i++) {
|
|
685
|
+
const s = settled[i];
|
|
686
|
+
const root = messages[i];
|
|
687
|
+
if (s.status === 'fulfilled' && s.value && Array.isArray(s.value.messages)) {
|
|
688
|
+
for (const r of s.value.messages) messages.push(r);
|
|
689
|
+
} else if (s.status === 'rejected') {
|
|
690
|
+
const reason = s.reason && s.reason.message ? s.reason.message : String(s.reason);
|
|
691
|
+
_warn(`teams-channel: replies fetch failed for root ${root.id}: ${reason}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return { messages, fetchedAt: rootEnv.fetchedAt || ts() };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function _scheduleRefresh(target, key) {
|
|
698
|
+
if (_inFlight.has(key)) return;
|
|
699
|
+
const cfg = _resolveCfg(target);
|
|
700
|
+
if (!cfg) return;
|
|
701
|
+
_inFlight.add(key);
|
|
702
|
+
Promise.resolve()
|
|
703
|
+
.then(() => _lister(cfg))
|
|
704
|
+
.then((entity) => { _entityCache.set(key, entity); })
|
|
705
|
+
.catch((e) => {
|
|
706
|
+
_entityCache.set(key, {
|
|
707
|
+
messages: [],
|
|
708
|
+
error: e && e.message ? e.message : String(e),
|
|
709
|
+
fetchedAt: ts(),
|
|
710
|
+
});
|
|
711
|
+
})
|
|
712
|
+
.then(() => { _inFlight.delete(key); });
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function fetchEntity(target, state) {
|
|
716
|
+
const key = _targetKey(target);
|
|
717
|
+
// Test/external override path — state.teamsChannelMessages[key] short-circuits
|
|
718
|
+
// the background-fetch cache so unit tests can drive the plugin without
|
|
719
|
+
// ever invoking the Graph fetcher.
|
|
720
|
+
if (state && state.teamsChannelMessages
|
|
721
|
+
&& Object.prototype.hasOwnProperty.call(state.teamsChannelMessages, key)) {
|
|
722
|
+
const inj = state.teamsChannelMessages[key];
|
|
723
|
+
return inj ? { ...inj, _target: target } : null;
|
|
724
|
+
}
|
|
725
|
+
const cached = _entityCache.get(key);
|
|
726
|
+
const cachedAge = cached && cached.fetchedAt
|
|
727
|
+
? Date.now() - new Date(cached.fetchedAt).getTime()
|
|
728
|
+
: Infinity;
|
|
729
|
+
if (!cached || cachedAge > CACHE_FRESHNESS_MS) {
|
|
730
|
+
_scheduleRefresh(target, key);
|
|
731
|
+
}
|
|
732
|
+
return cached ? { ...cached, _target: target } : null;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* State shape: { ids: string[], lastFetchedAt?: string, lastError?: string }
|
|
737
|
+
* `ids` is the all-time-seen set (insertion-ordered, capped at SEEN_IDS_CAP).
|
|
738
|
+
*
|
|
739
|
+
* First-observation semantics: when `prevState` has no `ids` key the entire
|
|
740
|
+
* current candidate set is absorbed as the baseline so an existing channel
|
|
741
|
+
* backlog never floods actions (per W-mq00x0hx0007acaa spec).
|
|
742
|
+
*
|
|
743
|
+
* Burst handling on subsequent calls: when N new ids appear between ticks,
|
|
744
|
+
* captureState advances by AT MOST ONE per call so bursts drain losslessly
|
|
745
|
+
* across consecutive checks (one fire per tick, in concert with evaluate).
|
|
746
|
+
*/
|
|
747
|
+
function captureState(entity, prevState) {
|
|
748
|
+
// The engine seeds _lastState with `{}` on watch creation, then later calls
|
|
749
|
+
// captureState with the prior `{ ids: [...] }`. Use the presence of the
|
|
750
|
+
// `ids` key — not its length — to distinguish "first observation" from
|
|
751
|
+
// "channel happens to be empty right now". This is what lets a brand-new
|
|
752
|
+
// watch on an empty channel still drain a later burst one-per-tick.
|
|
753
|
+
const isFirstObservation = !prevState || !Object.prototype.hasOwnProperty.call(prevState, 'ids');
|
|
754
|
+
const prevIds = Array.isArray(prevState && prevState.ids) ? prevState.ids : [];
|
|
755
|
+
if (!entity) return { ids: prevIds };
|
|
756
|
+
const seen = new Set(prevIds);
|
|
757
|
+
// Sort by createdDateTime ascending so the oldest unseen message drains
|
|
758
|
+
// first (FIFO across bursts).
|
|
759
|
+
const current = (entity.messages || [])
|
|
760
|
+
.slice()
|
|
761
|
+
.sort((a, b) => String(a.createdDateTime).localeCompare(String(b.createdDateTime)));
|
|
762
|
+
let nextIds;
|
|
763
|
+
if (isFirstObservation) {
|
|
764
|
+
nextIds = current.map((m) => m.id);
|
|
765
|
+
} else {
|
|
766
|
+
const newOnes = current.filter((m) => !seen.has(m.id));
|
|
767
|
+
if (newOnes.length === 0) {
|
|
768
|
+
nextIds = prevIds.slice();
|
|
769
|
+
} else {
|
|
770
|
+
nextIds = [...prevIds, newOnes[0].id];
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
if (nextIds.length > SEEN_IDS_CAP) {
|
|
774
|
+
nextIds = nextIds.slice(nextIds.length - SEEN_IDS_CAP);
|
|
775
|
+
}
|
|
776
|
+
return {
|
|
777
|
+
ids: nextIds,
|
|
778
|
+
lastFetchedAt: entity.fetchedAt || null,
|
|
779
|
+
lastError: entity.error || null,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function _firstUnseenMessage(entity, prevState) {
|
|
784
|
+
if (!entity) return null;
|
|
785
|
+
const prevIds = Array.isArray(prevState && prevState.ids) ? prevState.ids : [];
|
|
786
|
+
const seen = new Set(prevIds);
|
|
787
|
+
const candidates = (entity.messages || [])
|
|
788
|
+
.filter((m) => !seen.has(m.id))
|
|
789
|
+
.sort((a, b) => String(a.createdDateTime).localeCompare(String(b.createdDateTime)));
|
|
790
|
+
return candidates[0] || null;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function evaluate(condition, entity, prevState, target) {
|
|
794
|
+
if (condition !== 'new-message') {
|
|
795
|
+
return { triggered: false, message: '' };
|
|
796
|
+
}
|
|
797
|
+
if (!entity) return { triggered: false, message: '' };
|
|
798
|
+
if (entity.error) {
|
|
799
|
+
// Surface the fetch error so engine/watches/index.js writes it to
|
|
800
|
+
// _history[].reason — otherwise the watch silently sleeps forever
|
|
801
|
+
// with reason="" and no visible signal that Graph auth is broken
|
|
802
|
+
// (e.g. HTTP 403 missing ChannelMessage.Read.All scope).
|
|
803
|
+
return { triggered: false, message: `teams-channel fetch error: ${entity.error}` };
|
|
804
|
+
}
|
|
805
|
+
const next = _firstUnseenMessage(entity, prevState);
|
|
806
|
+
if (!next) return { triggered: false, message: '' };
|
|
807
|
+
const cfg = _resolveCfg(target);
|
|
808
|
+
const channelLabel = cfg ? cfg.channelId : '<unknown>';
|
|
809
|
+
const author = next.authorDisplayName || 'unknown';
|
|
810
|
+
const preview = _stripHtml(next.bodyHtml).slice(0, 80);
|
|
811
|
+
return {
|
|
812
|
+
triggered: true,
|
|
813
|
+
message: `New Teams message in ${channelLabel} from ${author}: ${preview}`,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function contextVars(entity, prevState, _newState) {
|
|
818
|
+
const next = _firstUnseenMessage(entity, prevState);
|
|
819
|
+
if (!next) return {};
|
|
820
|
+
const cfg = _resolveCfg(entity && entity._target);
|
|
821
|
+
return {
|
|
822
|
+
messageId: next.id,
|
|
823
|
+
messageBody: _stripHtml(next.bodyHtml),
|
|
824
|
+
authorDisplayName: next.authorDisplayName,
|
|
825
|
+
authorEmail: next.authorEmail,
|
|
826
|
+
createdDateTime: next.createdDateTime,
|
|
827
|
+
messageWebUrl: next.webUrl,
|
|
828
|
+
teamId: cfg ? cfg.teamId : '',
|
|
829
|
+
channelId: cfg ? cfg.channelId : '',
|
|
830
|
+
rootMessageId: next.rootMessageId || next.id,
|
|
831
|
+
// Templating substitutes via String(), so booleans surface as 'true'/'false'.
|
|
832
|
+
// Downstream prompt authors can branch on the literal string.
|
|
833
|
+
isReply: next.isReply === true,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
module.exports = {
|
|
838
|
+
name: 'teams-channel',
|
|
839
|
+
spec: {
|
|
840
|
+
label: 'Microsoft Teams Channel',
|
|
841
|
+
description: 'Poll a Microsoft Teams channel for new messages. Tracks both root messages and thread replies; fires once per newly-observed message id (system messages excluded). First tick captures baseline without firing.',
|
|
842
|
+
conditions: ['new-message'],
|
|
843
|
+
absoluteConditions: [],
|
|
844
|
+
validateTarget: _validateTarget,
|
|
845
|
+
fetchEntity,
|
|
846
|
+
captureState,
|
|
847
|
+
evaluate,
|
|
848
|
+
contextVars,
|
|
849
|
+
},
|
|
850
|
+
// Exported for tests
|
|
851
|
+
_resolveCfg,
|
|
852
|
+
_targetKey,
|
|
853
|
+
_firstUnseenMessage,
|
|
854
|
+
_stripHtml,
|
|
855
|
+
_shapeMessage,
|
|
856
|
+
_defaultComposedList,
|
|
857
|
+
_normalizeAuthCfg,
|
|
858
|
+
_authCacheKey,
|
|
859
|
+
_parseGraphError,
|
|
860
|
+
_remediationHint,
|
|
861
|
+
_buildGraphAuthError,
|
|
862
|
+
_buildGraphHttpError,
|
|
863
|
+
_buildAzArgs,
|
|
864
|
+
_buildAzureauthArgs,
|
|
865
|
+
setLister,
|
|
866
|
+
setRootsLister,
|
|
867
|
+
setReplyLister,
|
|
868
|
+
_resetCache,
|
|
869
|
+
};
|