mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(msteams): rebase TeamsSDK patterns to simplify Teams Integration (#76262)
* fix(msteams): rebase SDK migration onto current main Reapply the msteams SDK migration (originally on feat/msteams-sdk-migration) on top of upstream/main, resolving conflicts with parallel msteams work that landed upstream during our session. What got applied vs decisions made: CLEANLY APPLIED (3-way patch): - monitor.ts, monitor-handler.ts, polls.ts, reply-stream-controller.ts/.test.ts, reply-dispatcher.ts, attachments/download.ts, monitor.lifecycle.test.ts, monitor-handler/message-handler.ts, monitor-handler.types.ts, etc. - streaming-message.ts + .test.ts deletions WHOLESALE TAKE FROM ORIGINAL BRANCH (partial 3-way left broken cross-refs): - sdk.ts, sdk.test.ts, messenger.ts, feedback-reflection.ts, send-context.ts, send.test.ts KEPT UPSTREAM (deferred for separate cleanup): - extensions/msteams/package.json (still has jsonwebtoken/jwks-rsa per Peter'sb3bc60ae25incremental approach) - src/plugins/contracts/package-manifest.contract.test.ts (consistent with package.json) - pnpm-lock.yaml (avoids lockfile churn; pnpm install --frozen-lockfile clean) ADAPTED: - Dockerfile matrix-sdk-crypto check now wraps upstream's new retry-loop in the if-matrix-bundled gate KNOWN TEST FAILURES (need eyes — see PR comment): - attachments.test.ts: 1 fail (pre-existing — warn meta arg shape changed in our migration but test wasn't updated) - reply-dispatcher.test.ts: 6 fails (pre-existing — tests mock old TeamsHttpStream, not updated for our ctx.stream rewrite) - send.test.ts: 4 fails (NEW from merge — upstream's send.ts changed media loading; our mocks need updating or take upstream's send.test.ts wholesale) UPSTREAM COMMITS POTENTIALLY MISSED (in wholesale-take files): -08c4af0ddffix(msteams): accept conversation id allowlists -e1840b8581fix(msteams): bind global audience tokens to app id - Channels turn-kernel refactor (ffe67e9cdc/1ead1b2d18/9a9cd0c0ab) — may be partially preserved in cleanly-patched files Static checks pass: pnpm check:changed is green (typecheck, lint, contract tests, import cycles, etc.). Manual testing required before merge. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(msteams): preserve thread routing for channel and group-chat replies - monitor.ts: adaptSdkContext now uses ctx.reply() for channel and groupChat conversations (so the SDK threads outbound activities to the inbound's replyToId/serviceUrl) and ctx.send() only for personal DMs (where reply()'s blockquote-prepend is ugly). - messenger.ts: sendProactively passes resolvedThreadId on the non-thread fallback path so channel @mentions that fall through outbound.ts -> send.ts still land in the original thread instead of top-level. Live-validated: channel @mention -> bot replies in thread, threaded reply -> bot replies in same thread, no top-level leakage. * fix(msteams): tag outbound SDK calls with OpenClaw User-Agent - user-agent.ts: add buildOpenClawUserAgentFragment() that returns just 'OpenClaw/<version>'. The SDK's Client.clone merges this with its own 'teams.ts[apps]/<sdk-version>' identifier — passing the full buildUserAgent() here would double-print the SDK token. - sdk.ts: pass the fragment via AppOptions.client.headers['User-Agent'] so the Teams backend can identify OpenClaw traffic for usage telemetry. Final UA looks like 'OpenClaw/<openclaw-version> teams.ts[apps]/<sdk-version>'. * fix(msteams): handle StreamCancelledError when user presses Stop mid-stream The new SDK throws StreamCancelledError synchronously from stream.emit/update when the user pressed Stop in Teams: Teams replies 403 to the next chunk update, the SDK flips _canceled, and any subsequent emit() throws. The old custom TeamsHttpStream either swallowed cancel or didn't expose this exception type, so the migration inherited an SDK behavior the original code didn't have to handle. Symptom on 2026-05-05: pressing Stop during a streaming reply caused an unhandled promise rejection that crashed the Node 24 process. Docker restarted the gateway about two minutes after each Stop click. Two related bugs surfaced once the crash was caught: the would-be block fallback re-delivered the full text as a second message (duplicate after Stop), and the typing-keepalive kept pulsing in Teams for the rest of the agent run because nothing told it to stop. reply-stream-controller.ts: - Wrap stream.update / stream.emit / stream.close in try/catch that swallows StreamCancelledError (matched by .name to dodge tsgo's SDK re-export resolution quirk). Latch a wasCanceled flag so subsequent calls short-circuit even if stream.canceled is stale. - preparePayload() returns undefined when the stream was canceled — the streamed prefix is already visible to the user, so dropping the payload prevents a duplicate block message from overriding the cancel intent. reply-dispatcher.ts: - Typing-keepalive gate now also checks streamController.wasCanceled() so typing pulses stop firing once Stop is observed. Otherwise the bot keeps pulsing for the rest of the (uncancellable) agent run. reply-stream-controller.test.ts: - 6 new regression tests cover: cancel-during-emit (the crash scenario), cancel-during-update, cancel-during-finalize, non-cancel error propagation, post-cancel inactivity, and dropped-payload-on-cancel. Live-validated: long streaming reply + Stop mid-stream -> stream freezes, no duplicate message, no zombie typing, container stays healthy. * fix(msteams): allow Bearer-token retry on Skype CDN attachment downloads Teams puts inline DM images and clipboard-pasted images on *.asm.skype.com URLs (e.g. us-api.asm.skype.com/v1/objects/<id>/views/imgo). The download path in attachments/download.ts already does a plain GET first and falls back to a Bearer-token retry on 401/403 — but the retry was gated on the URL being in DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST. asm.skype.com hosts were in DEFAULT_MEDIA_HOST_ALLOWLIST (download permitted) but not in the auth-host list, so a 401 plain-GET response skipped the retry and surfaced as a missing image to the agent. Add asm.skype.com and ams.skype.com to the auth allowlist so openclaw attempts the Bearer-token retry consistently, matching how it treats the other CDN/Bot-Framework hosts already in the list. Note: this does not unblock all clipboard-pasted DM images — for at least some tenants asm.skype.com rejects the Bot Framework token (returns 401 even with auth). Routing those URLs through <serviceUrl>/v3/attachments/... the way #62219 already handles HTML-wrapped attachments is a separate follow-up. The +button 'Upload from this device' path works today because Teams generates an attachment with an HTML wrapper that triggers the existing BF v3 attachments fallback in monitor-handler/inbound-media.ts. * fix(msteams): align docker-compose msteams port default with plugin default The plugin defaults webhook.port to 3978 (the Bot Framework standard used in Microsoft samples) and listens on whatever the operator sets there. The docker-compose.yml port mapping was exposing ${OPENCLAW_MSTEAMS_PORT:-3000}:3000 which only works for operators who explicitly set webhook.port to 3000. Default-config users would have the plugin listening on 3978 inside the container while compose forwarded 3000, causing connection refused. Realign to ${OPENCLAW_MSTEAMS_PORT:-3978}:3978 so a default-config docker compose up Just Works with Teams. Operators wanting a custom port override both webhook.port in openclaw.json and OPENCLAW_MSTEAMS_PORT env var. * fix(msteams): post-rebase reconciliation with main Three follow-ups after rebasing the SDK migration onto current main: - reply-dispatcher.ts: rename createChannelReplyPipeline to its post-rebase identifier createChannelMessageReplyPipeline (the plugin-sdk barrel renamed it during the 1454-commit rebase window). - reply-dispatcher.ts: tighten the typing-keepalive onStartError signature to (err: unknown) to satisfy upstream's stricter type checks. - messenger.ts: drop the unconditional thread suffix on the bottom proactive fallback. The previous behavior threaded all top-level proactive sends when the stored ref had a threadId, which contradicts replyStyle='top-level' semantics (and breaks the new upstream test). Threading on the proactive path is preserved where it matters — the onRevoked branch within replyStyle==='thread' still passes resolvedThreadId, which is the original #55198 fix path. - attachments.test.ts: update the warn-call assertion to match the migration's inline message format (host=... error=...) — the structured meta object was being dropped by the logger formatter pre-migration. * feat(msteams): port streaming preview/progress features to ctx.stream While the SDK migration was open, upstream landed preview/progress/draft streaming features built on the OLD custom TeamsHttpStream class (which the migration deletes). This commit ports the user-visible parts of those features onto the new ctx.stream substrate so the migration doesn't lose ground: - pickInformativeStatusText: reads custom labels from msteams.streaming.progressDraft config via resolveChannelProgressDraftLabel. Falls back to the plugin-sdk default rotation. Pre-rebase used a hardcoded 4-string array. - streamMode resolution: "partial" (default, per-token streaming), "progress" (no tokens; preview card carries informative label that updates as tools run), or "block" (no native streaming). Mode is read from cfg.channels.msteams.streaming.preview. - progress-draft gate: createChannelProgressDraftGate gates informative updates so the rotating label only starts firing once meaningful work has begun (avoids flicker before the first tool call). - noteProgressWork() / pushProgressLine(): public methods on the controller for callers (typing keepalive ticks, tool-event callbacks) to signal work. pushProgressLine appends tool names as bullets above the rotating label when streaming.previewToolProgress is enabled. Wiring these into actual tool events is a separate follow-up. - preparePayload progress-mode path: when stream is active but no tokens streamed (progress mode) and a final text payload arrives, emit the text into the stream so the preview card transitions in place to the final reply on close(). reply-dispatcher: pass log + msteamsConfig + a stable progressSeed (${accountId}:${conversation.id}) to createTeamsReplyStreamController so the informative-label rotation is consistent across reconnects. What's NOT ported and why: - Live-edit-via-replaceInformativeWithFinal: the SDK's HttpStream natively accumulates emitted text + entities + channelData and flushes ONE final activity at close() using the same activity id as the preview. So the separate "replace informative with final" call from upstream is unnecessary — we get live-finalization for free via the SDK's design. - pushProgressLine triggers from tool events: needs reply-pipeline-side callbacks the new SDK migration didn't surface yet. Follow-up. Tests: existing 22 reply-stream-controller tests still pass (the new behaviors are additive). * feat(msteams): wire pipeline tool events to streaming progress + fix test debt Two follow-ups from yesterday's stopping point: 1. Wire pipeline events into the stream controller's progress-draft surface. reply-dispatcher's replyOptions now exposes onReasoningStream, onToolStart, onItemEvent, onPlanUpdate, onApprovalEvent, onCommandOutput callbacks that format each event via the channel-streaming helpers and route through streamController.pushProgressLine(). Mirrors the discord adapter's wiring. Also: - resolveChannelStreamingPreviewToolProgress + ...SuppressDefaultTool... so the dispatcher exposes suppressDefaultToolProgressMessages on its replyOptions when progress mode is on. - Switch disableBlockStreaming resolution to the channel-streaming helpers (resolveChannelPreviewStreamMode + resolveChannelStreamingBlockEnabled) so streaming.mode='block' and streaming.block.enabled=true are honored alongside the legacy blockStreaming boolean. 2. Fix the test debt that the rebase exposed: - reply-dispatcher.test.ts: drop the streamInstances + TeamsHttpStream mock pattern (file deleted by migration); replace with a streamMock provided via context.stream that mirrors the SDK's IStreamer shape (update/emit/close/canceled). Update assertions on sendInformativeUpdate -> stream.update, stream.update -> stream.emit. Drop the resumes-typing-between-segments test (no equivalent in the new ctx.stream model — the SDK's HttpStream doesn't have a 'between segments' notion; close ends the stream). - send.test.ts: fix two stale mock targets — loadOutboundMediaFromUrl comes from openclaw/plugin-sdk/outbound-media (not /msteams), and resolveMarkdownTableMode comes from openclaw/plugin-sdk/markdown-table-runtime (not /config-runtime). The previous mock paths were no-ops post-migration. All 854 msteams tests now pass (was 17 failing in 4 files yesterday). * fix(msteams): SDK streaming delta + use app.reply for proactive thread sends Two narrow regressions exposed by the @microsoft/teams.apps migration: - The SDK's HttpStream.emit appends each chunk to its internal buffer (`this.text += activity.text`), but the channel reply pipeline emits cumulative text on each chunk. Forwarding cumulative text into an appending sink produced "chunk1 + chunk1chunk2 + chunk1chunk2chunk3..." duplication for streamed (DM) replies. Track the emitted prefix length in the stream controller and only forward the new tail. - Replace the manual `${convId};messageid=${msgId}` URL construction in the proactive thread fallback with `app.reply()`, which builds the threaded conversation id via the SDK's own toThreadedConversationId helper. Mechanically equivalent today; removes coupling to Teams' URL format and tracks any future SDK changes. Also adds the `reply` method to the structural MSTeamsApp type so the refactor typechecks without casts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(msteams): bump @microsoft/teams.api and teams.apps to 2.0.10 2.0.10 adds support for the AAD v1 token issuer that the Bot Framework JWT validator needs. The minor version bump pulls teams.cards / common / graph along to 2.0.10 too. Add `@microsoft/teams.*` to `minimumReleaseAgeExclude` in pnpm-workspace.yaml because 2.0.10 was published <48h ago and the default `minimumReleaseAge: 2880` (~2 days) would otherwise reject it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * revert(msteams): remove asm.skype.com auth-host allowlist additions These hosts were added indfc169d31dfor inline DM image auth-retry, but the commit's own footnote acknowledges it doesn't actually unblock clipboard-pasted images (asm.skype.com rejects Bot Framework tokens in at least some tenants). The change is unrelated to the SDK migration and the user-visible bug it claimed to fix isn't fixed; lifting it out keeps this PR focused on the migration. Will land as a separate PR if the auth-allowlist consistency improvement is wanted on its own. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(msteams): typed ExpressAdapter helper, drop unknown-cast pyramid The monitor's SDK bootstrap had an awkward chain: httpServerAdapter: new ( (await import("@microsoft/teams.apps")) as unknown as { ExpressAdapter: new (app: unknown) => unknown; } ).ExpressAdapter(expressApp) as never, Three casts (`unknown`, structural shape literal, `never`) were a defensive workaround from when the SDK's hashed d.ts files tripped up tsgo. With the SDK's exports now resolving cleanly, the same import can be done with full types. - Extend the lazy `loadSdkModules()` cache to include `ExpressAdapter` alongside `App` so the dynamic import is shared. - Add `createMSTeamsExpressAdapter(serverOrApp)` helper in `sdk.ts` that encapsulates the lazy import and returns a properly-typed adapter instance. - Replace `httpServerAdapter`'s structural shape on `CreateMSTeamsAppOptions` with the SDK's own `IHttpServerAdapter` interface (re-exported from `@microsoft/teams.apps`). The call site in `monitor.ts` becomes a single typed call with no `any`, no `unknown`, no `as never`. The lazy-load behavior is preserved: nothing imports `@microsoft/teams.apps` at module load time. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(msteams): unbreak tsgo:extensions on the ExpressAdapter helper CI's check-prod-types failed because the previous commit's typed helper used `typeof import("@microsoft/teams.apps").ExpressAdapter`, which tsc/tsgo's NodeNext resolution can't follow through the SDK's chained `export *` barrel: @microsoft/teams.apps/dist/index.d.ts: export * from "./http"; // folder with index.d.ts export * from "./app"; // single .d.ts file The folder re-export drops `ExpressAdapter` and `IHttpServerAdapter` from the namespace shape under `tsconfig.extensions.json` (passes under the per-extension `tsconfig.json` because of inherited `paths`). Same root cause as why we already model `MSTeamsApp` structurally (line 47 comment). Switch the ExpressAdapter side to the same structural-shape pattern: - Define `MSTeamsHttpServerAdapter` and `MSTeamsExpressAdapterCtor` locally. - Cast `m.ExpressAdapter` once inside `loadSdkModules` (the runtime export is fine; only the type surface is hidden). - `httpServerAdapter` on `CreateMSTeamsAppOptions` and the return type of `createMSTeamsExpressAdapter` use the local structural type. Net result: the call site in `monitor.ts` stays the cast-free single line the previous commit landed; the one remaining cast is confined to the SDK-loading helper with an explanatory comment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(msteams): drop unused jsonwebtoken/jwks-rsa deps The SDK migration removed all `import "jsonwebtoken"` / `import "jwks-rsa"` from source code (the SDK does JWT validation internally now), but the package.json entries and the matching `package-manifest.contract.test.ts` expectation were left orphaned. Drop both: - `extensions/msteams/package.json`: remove `jsonwebtoken` (^9), `jwks-rsa` (^4) from `dependencies` and `@types/jsonwebtoken` from `devDependencies`. - `src/plugins/contracts/package-manifest.contract.test.ts`: remove the two entries from msteams's `pluginLocalRuntimeDeps` expectation. - `monitor.lifecycle.test.ts`: extend the `./sdk.js` mock with the `createMSTeamsExpressAdapter` export added in the typed-helper cleanup, so the lifecycle suite still mounts after the deps drop. Lockfile regenerates accordingly. All msteams tests (865) pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(msteams): drop unused @microsoft/teams.api direct dep CI's deadcode:dependencies (knip) flagged @microsoft/teams.api as unused in extensions/msteams. The plugin source uses structural type aliases (MSTeamsActivityParams, MSTeamsActivityLike, etc.) to dodge tsgo resolution bugs with teams.api's hashed d.ts files, so it never imports teams.api directly. The package is brought in transitively via @microsoft/teams.apps; the only other reference is probe.test.ts's vi.mock("@microsoft/teams.api"), which works on the import-path string and doesn't require a direct dep declaration. Lockfile regenerates accordingly. tsgo:extensions, knip, and all 865 msteams tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(msteams): clear three CI gate failures (lint, contract, deprecated config API) Three CI checks flagged on the latest run; all three are msteams-local and unrelated to one another: - **check-lint** / **check-additional-extension-bundled**: `oxlint` flagged a redundant `as string[]` assertion in `reply-dispatcher.ts:431`. The preceding `every((s: unknown) => typeof s === "string")` already narrows the array type, so the cast does nothing. Drop it. - **checks-fast-contracts-plugins-c**: the `package-manifest.contract.test.ts` `pluginLocalRuntimeDeps` for msteams still expected `@microsoft/teams.api`, but the deadcode cleanup commit (8f4050f51a) dropped it from `extensions/msteams/package.json`. Remove it from the contract test too — `teams.api` is only present transitively via `teams.apps`, which is the reason knip flagged it. - **check-additional-runtime-topology-architecture**: the deprecated internal config API guard caught `messenger.ts:223` calling `getMSTeamsRuntime().config.loadConfig()`. Switch to `config.current()` to match the pattern used by phone-control, synology-chat, and matrix. Pre-existing failures on this run that are NOT msteams-related and not caused by this PR: `check-test-types` (errors in `src/agents/openai-transport-stream.test.ts` and `pi-embedded-runner/openai-stream-wrappers.test.ts`) and `macos-swift` (`hoistAwait` in `MacNodeRuntime.swift`). Leaving those for upstream. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(msteams): cast config.current() return to OpenClawConfig The previous commit switched `messenger.ts:223` from the deprecated `config.loadConfig()` to `config.current()` to satisfy the architecture guard, but `config.current()` returns a deeply-readonly type that's not assignable to the `Partial<OpenClawConfig>` parameter `resolveMarkdownTableMode` expects (a mutable type from the SDK contract). Phone-control, synology-chat, and matrix all cast at this seam — adopt the same pattern. Verified locally: tsgo:core, tsgo:extensions, check:architecture, and test:extensions:package-boundary:compile all pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(msteams): address PR review — pre-auth body limit, allowlist log level, /api/messages forwarder, narrow release-age exclude Four narrow fixes from the PR review (BradGroux + clawsweeper bot + galiniliev's plan), each its own concern: - **pre-auth-body-limit** (monitor.ts) — install `express.json({ limit: DEFAULT_WEBHOOK_MAX_BODY_BYTES })` before the bearer-presence gate and SDK route. Express memoizes the parsed body on the request, so the SDK's later `json()` becomes a no-op and our limit applies before any handler parses bodies. Closes the gap where a `Bearer garbage`-shaped attacker could force unbounded JSON parsing before token validation. - **allowlist-error-logging** (monitor.ts) — restore main's `runtime.error` level for the `msteams resolve failed` catch (was downgraded to `runtime.log` mid-merge). Graph allowlist resolution failures are security-relevant; they need to surface to operators. - **legacy-messages-route** (monitor.ts) — when `webhook.path` is set to a custom value, also accept POSTs on the legacy `/api/messages` path with a one-time deprecation warning, then re-enter the Express middleware chain on the configured path. Keeps existing Azure Bot registrations working through the transition. Cast-free (`expressApp(req, res, next)` works because `Application extends IRouter extends RequestHandler`). - **release-age-scope** (pnpm-workspace.yaml) — narrow `@microsoft/teams.*` glob to the single direct dep `@microsoft/teams.apps`. Future scoped packages no longer get a freshness-guard pass. Tests + checks: msteams suite (867), tsgo:core, tsgo:extensions, tsgo:test, lint:extensions, check:architecture, knip --dependencies, package-manifest contract, all green. Still pending from the review (separate commits): - auth-coverage-tests (Brad #1 + comment) — tests proving the SDK accepts `aud=<bot app id>` and rejects `aud=api.botframework.com`. - invoke-response-handling (Brad #2, codex P2) — file-consent invoke ack must return through the SDK invoke handler, not `ctx.sendActivity`. - stream-failure-fallback (codex P2, galin F5) — `streamFailed` latch so partial streams fall back to block delivery on non-cancel errors. - serviceurl-routing (Brad #4, codex P2) — proposed rebuttal pending empirical confirmation that `smba.trafficmanager.net/teams` routes to non-default-region conversations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(msteams): lock SDK auth contract — aud + v1/v2 issuer coverage Adds extensions/msteams/src/auth-coverage.test.ts driving ServiceTokenValidator and createEntraTokenValidator directly with jose-minted RS256 tokens against an in-memory JWKS (via JwksClient.prototype patch). Locks in the three contract cases @BradGroux flagged on #76262: aud=<bot app id> accepted, aud=api.botframework.com rejected even when appid/azp match, and v1/v2 issuers accepted for allowed tenant (disallowed tenant rejected). Drops a stale ambient module declaration in src/types/microsoft-teams-sdk.d.ts that was shadowing the SDK's real jwt-validator types with a long-renamed createServiceTokenValidator surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(msteams): route file-consent invokes through typed app.on, drop broken invokeResponse send Brad #2 / codex #4 on PR #76262 — `ctx.sendActivity({ type: "invokeResponse", ... })` no longer reaches Teams as an HTTP InvokeResponse on the new SDK; it becomes an outbound Bot Framework activity instead. Move file-consent accept/decline to typed `app.on("file.consent.accept|decline", ...)` handlers. The SDK's typed-route layer wraps a void return into `{ status: 200 }` (`app.process.js:130`), so the manual ack disappears. While in here, type `MSTeamsApp.on` properly. Borrowing the SDK's `App.on` directly fails because that function carries a `this: App<TPlugin>` constraint our structural alias can't satisfy, so we model an equivalent generic over `IRoutes` with route-specific overloads (`card.action`, `file.consent.*`, `activity`). The overloads work around a tsgo bug — the `@microsoft/teams.api` `Activity` discriminated union collapses to `any`, turning `ActivityRoutes` into a `[string]: RouteHandler<X, void>` index signature that swallows every typed `Out` not already void-compatible (card.action returns `AdaptiveCardActionResponse`; the others happen to include `void`). Real tsc resolves cleanly. Linked upstream: https://github.com/microsoft/typescript-go/issues/1057. Other cleanups: - Cast-free call sites for `adaptSdkContext` (now returns `MSTeamsTurnContext` instead of `unknown`). - card.action error responses include `innerHttpError` per the SDK's `HttpError` shape requirement. - Activity catch-all also skips `fileConsent/invoke` now that it's typed-routed (parallel to the existing `adaptiveCard/action` skip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(msteams): route SSO sign-in invokes through typed app.on, drop broken invokeResponse send Brad #2 / codex #4 on PR #76262, SSO half. Continue the typed-route migration: `signin/tokenExchange` and `signin/verifyState` now register via `app.on("signin.token-exchange" | "signin.verify-state", ...)`. Per the SDK's router, registering a user route with the same name as a system route removes the system default — so the SDK's built-in handlers (which would call `api.users.token.exchange` themselves and emit a `signin` event nobody currently subscribes to) are silenced, and only ours runs. The SDK wraps a void return into the HTTP 200 InvokeResponse, so the legacy `ctx.sendActivity({ type: "invokeResponse", ... })` ack — broken on the new SDK because it becomes an outbound BF activity instead of the HTTP response — is gone. The handler body is extracted from the activity-catch-all dispatch in `monitor-handler.ts` to a new `signin-invoke.ts`, parallel to `file-consent-invoke.ts`. `isSigninInvokeAuthorized` is now exported from `monitor-handler.ts` so the new handler can reuse it. The activity catch-all skips the SSO invoke names alongside the existing skips for `adaptiveCard/action` and `fileConsent/invoke`. `MSTeamsAppOn` overloads now cover the two SSO routes with their typed ctx (`ISignInTokenExchangeInvokeActivity` / `ISignInVerifyStateInvokeActivity`). Tests in `monitor-handler.sso.test.ts` were rewritten to call the extracted handler directly — the `registered.run(ctx)` shape no longer covers SSO, and the `expect(ctx.sendActivity).toHaveBeenCalledWith({ type: "invokeResponse" })` assertions were dropped to match the new contract (the SDK ack happens via the typed-route return value). Note on overlap with #77784 (Stefan Stüben, Microsoft): that PR is doing a much bigger SSO rework (sign-in card / sign-in-link / six-digit-code fallbacks plus a `ctx.auth` plumbed to plugin tools). This change is the small migration-correctness fix and is structured so #77784's SSO body changes drop into the typed-route registrations cleanly on rebase. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(msteams): route message-submit (feedback) invokes through typed app.on Last invoke off the activity catch-all dispatch. `message/submitAction` (thumbs up/down on AI-generated messages) now registers via `app.on("message.submit", ...)`. Same shape as file-consent and SSO: handler body extracted to a new `feedback-invoke.ts`, the SDK wraps a void return into the HTTP 200 InvokeResponse, the broken `ctx.sendActivity({ type: "invokeResponse", ... })` line is gone, and the activity catch-all skips this invoke name alongside the others. `isFeedbackInvokeAuthorized` is exported from `monitor-handler.ts` so `feedback-invoke.ts` can reuse it. Tests in `monitor-handler.feedback-authz.test.ts` were rewritten to call the extracted handler directly — the old `handler.run(ctx)` shape no longer intercepts feedback, and `originalRun` was removed because the typed route is the dispatch point now. `MSTeamsAppOn` overload added with the typed `IMessageSubmitActionInvokeActivity` ctx, slotted between the SSO overloads and the `activity` catch-all so `activity` stays last. This leaves only `message`, `conversationUpdate`, and `messageReaction` flowing through `app.on("activity", ...)` → `handler.run`. Promoting those is the path to deleting the catch-all entirely. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(msteams): fall back to block delivery when partial-mode stream fails mid-flight codex #5 / Galin F5 on PR #76262. `reply-stream-controller.ts` previously re-threw any non-cancel error from `stream.emit` during partial streaming and from `stream.emit`/`stream.close` during finalize. Combined with `preparePayload` suppressing block delivery once `tokensEmitted` was true, that meant a network blip or API error mid-stream produced a truncated reply with no recovery — the user saw the prefix that made it through and nothing else. Add a `streamFailed` latch parallel to `canceledLocally` / `tokensEmitted`: - `onPartialReply`: catch non-cancel errors, set `streamFailed = true`, log a warn, don't propagate (the pipeline must keep running so `preparePayload` can decide). - `preparePayload`: when `tokensEmitted && streamFailed`, fall through to block delivery instead of suppressing. The user may see a duplicate (streamed prefix + full block reply); intentional — matches the pre-migration `TeamsHttpStream.hasContent` recovery and is better than truncated-only. - `finalize`: same latch + warn on non-cancel close failure, swallow rather than throw. The streamed content already reached the user; the closing activity (AI-Generated marker, feedback channelData) is the only loss, not worth blowing up the dispatcher. - `isStreamActive` returns false once the stream has failed. New tests cover crash-mid-stream after tokens were emitted (assert block delivery payload is returned), happy-path no-duplicate behavior (assert `preparePayload` still suppresses when nothing failed), and finalize close-failure (assert no throw). The pre-existing "re-throws non-cancel" test was inverted to assert non-throwing latch behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(msteams): declare @microsoft/teams.api as a runtime dependency Type-only `import("@microsoft/teams.api/dist/...").TypeName` references in `sdk.ts` (added when typed `MSTeamsApp.on` overloads were introduced) are picked up by the `extension-runtime-dependencies` contract test as genuine runtime imports. Declaring `@microsoft/teams.api` as a direct dep makes the contract pass; the package was already coming in transitively via `@microsoft/teams.apps`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(msteams): keep SSO on SDK signin routes * test(msteams): avoid redundant signin handler assertion * docs(msteams): clarify Teams cloud support * fix(msteams): use current SDK string helper * fix(msteams): gate SDK invoke side effects * test(msteams): avoid implicit any in lifecycle tests * fix(msteams): preserve SDK user agent and matrix check * fix(msteams): expose SDK common dependency * fix(msteams): use SDK user agent merge * fix(msteams): fall back when stream close no-ops * chore(msteams): drop unrelated merge artifacts * chore(msteams): restore unrelated main files * chore(msteams): restore unrelated main files * chore(msteams): restore unrelated main files * test(msteams): type stream close mock result * fix(msteams): configure Teams cloud service URL * chore(msteams): refresh shrinkwrap * chore(deps): refresh shrinkwrap locks * chore(ci): rerun guards after main sync * chore(deps): refresh shrinkwrap for node 24 * chore(config): refresh docs baseline * fix(msteams): preserve Teams SDK proactive references * fix(msteams): harden SDK proactive sends * fix(msteams): align service url contract * test: fix bonjour beacon type narrowing * fix(msteams): ignore ambient service url * fix(msteams): fall through submit invokes * test: align shrinkwrap override policy with Teams SDK deps * fix(msteams): ack invoke routes promptly * fix(msteams): support china cloud boundaries * test: sync PR with current CI gates * test: isolate channel setup registry metadata --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -48,6 +48,7 @@ RUN --mount=type=bind,source=packages,target=/tmp/packages,readonly \
|
||||
FROM ${OPENCLAW_BUN_IMAGE} AS bun-binary
|
||||
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS build
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
ARG OPENCLAW_EXTENSIONS
|
||||
|
||||
# Copy pinned Bun binary from the official image instead of fetching via curl.
|
||||
COPY --from=bun-binary /usr/local/bin/bun /usr/local/bin/bun
|
||||
@@ -77,7 +78,12 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
|
||||
# pnpm v10+ may append peer-resolution hashes to virtual-store folder names; do not hardcode `.pnpm/...`
|
||||
# paths. Matrix's native downloader can hit transient release CDN errors while
|
||||
# still exiting successfully, so retry the package downloader before failing.
|
||||
# Skip the entire check when matrix is not a bundled extension (e.g. msteams-only builds).
|
||||
RUN set -eux; \
|
||||
if ! printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'matrix'; then \
|
||||
echo "==> matrix not bundled, skipping matrix-sdk-crypto check"; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
echo "==> Verifying critical native addons..."; \
|
||||
for attempt in 1 2 3 4 5; do \
|
||||
if find /app/node_modules -name "matrix-sdk-crypto*.node" 2>/dev/null | grep -q .; then \
|
||||
|
||||
@@ -63,6 +63,7 @@ services:
|
||||
ports:
|
||||
- "${OPENCLAW_GATEWAY_PORT:-18789}:18789"
|
||||
- "${OPENCLAW_BRIDGE_PORT:-18790}:18790"
|
||||
- "${OPENCLAW_MSTEAMS_PORT:-3978}:3978"
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
command:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
a69acd971a7d54d3086f26c52fde4084eaeef350f71b918fb8e7338f329bff95 config-baseline.json
|
||||
c61b32fda64ee6cd4d4aa5ed6950c4c681a585d49bf5c127b92e562608a0a303 config-baseline.json
|
||||
ee4c0f0fb15cda02268f2e83d0c5e1c8d0ec0a2c1b2fdb89cdfce308dadb2b8b config-baseline.core.json
|
||||
b901fb766edfd9df630690281476fc4032c64772f69d1d8f7b2e0e913a90f229 config-baseline.channel.json
|
||||
ccb0c68e959854b9d54d66b8c78bfba5fe6f8a37e669e2e7e511b02c4c977122 config-baseline.channel.json
|
||||
1b763a5524aca2d7ecf1eea38f845ad1ffed5c1b37e85e62f6a7902a3ee0f920 config-baseline.plugin.json
|
||||
|
||||
@@ -666,6 +666,58 @@ Teams delivers messages via HTTP webhook. If processing takes too long (e.g., sl
|
||||
|
||||
OpenClaw handles this by returning quickly and sending replies proactively, but very slow responses may still cause issues.
|
||||
|
||||
### Teams cloud and service URL support
|
||||
|
||||
This SDK-backed Teams path is live-validated for Microsoft Teams public cloud.
|
||||
|
||||
Inbound replies use the incoming Teams SDK turn context. Out-of-context proactive operations - sends, edits, deletes, cards, polls, file-consent messages, and queued long-running replies - use the stored conversation reference `serviceUrl`. Public cloud defaults to the Teams SDK public cloud environment and allows stored references on the public Teams Connector host: `https://smba.trafficmanager.net/`.
|
||||
|
||||
Public cloud is the default. You do not need to set `channels.msteams.cloud` or `channels.msteams.serviceUrl` for normal public-cloud bots.
|
||||
|
||||
For non-public Teams clouds, set `cloud` and the matching proactive boundary when Microsoft publishes one:
|
||||
|
||||
- `channels.msteams.cloud` selects the Teams SDK cloud preset for authentication, JWT validation, token services, and Graph scope.
|
||||
- `channels.msteams.serviceUrl` selects the Bot Connector endpoint boundary used to validate stored conversation references before proactive sends, edits, deletes, cards, polls, file-consent messages, and queued long-running replies. It is required for USGov and DoD SDK clouds. For China/21Vianet, OpenClaw uses the SDK `China` preset and accepts stored/configured service URLs only on Azure China Bot Framework channel hosts.
|
||||
|
||||
Microsoft publishes the global proactive Bot Connector endpoints in the [Create the conversation](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages?tabs=dotnet#create-the-conversation) section of the Teams proactive messaging docs. Use the incoming activity's `serviceUrl` when available; if you need a global proactive endpoint, use Microsoft's table.
|
||||
|
||||
| Teams environment | OpenClaw config | Proactive `serviceUrl` |
|
||||
| ----------------- | ----------------------------------------------------------- | -------------------------------------------------- |
|
||||
| Public | no cloud/serviceUrl config needed | `https://smba.trafficmanager.net/teams` |
|
||||
| GCC | set `serviceUrl`; no separate Teams SDK cloud preset exists | `https://smba.infra.gcc.teams.microsoft.com/teams` |
|
||||
| GCC High | `cloud: "USGov"` + `serviceUrl` | `https://smba.infra.gov.teams.microsoft.us/teams` |
|
||||
| DoD | `cloud: "USGovDoD"` + `serviceUrl` | `https://smba.infra.dod.teams.microsoft.us/teams` |
|
||||
| China/21Vianet | `cloud: "China"` | use the incoming activity's `serviceUrl` |
|
||||
|
||||
Example for GCC, where Microsoft documents a separate proactive service URL but the Teams SDK does not expose a separate GCC cloud preset:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"msteams": {
|
||||
"serviceUrl": "https://smba.infra.gcc.teams.microsoft.com/teams"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example for GCC High:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"msteams": {
|
||||
"cloud": "USGov",
|
||||
"serviceUrl": "https://smba.infra.gov.teams.microsoft.us/teams"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`channels.msteams.serviceUrl` is restricted to supported Microsoft Teams Bot Connector hosts. When a service URL is configured, OpenClaw checks that the stored conversation `serviceUrl` uses the same host before proactive sends, edits, deletes, cards, polls, or queued long-running replies run. With the default public-cloud config, OpenClaw fails closed if a stored conversation points outside the public Teams Connector host. Receive a fresh message from the conversation after changing cloud/service URL settings so the stored conversation reference is current.
|
||||
|
||||
China/21Vianet does not have a separate global proactive `smba` URL in Microsoft's Teams proactive endpoint table. Configure `cloud: "China"` so the Teams SDK uses Azure China auth, token, and JWT endpoints. Proactive sends then require a stored conversation reference from an incoming China Teams activity, or an explicitly configured service URL, on the Azure China Bot Framework channel boundary (`*.botframework.azure.cn`). Graph-backed Teams helpers are currently disabled for `cloud: "China"` until OpenClaw routes Graph requests through the Azure China Graph endpoint.
|
||||
|
||||
### Formatting
|
||||
|
||||
Teams markdown is more limited than Slack or Discord:
|
||||
@@ -680,6 +732,8 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
|
||||
|
||||
- `channels.msteams.enabled`: enable/disable the channel.
|
||||
- `channels.msteams.appId`, `channels.msteams.appPassword`, `channels.msteams.tenantId`: bot credentials.
|
||||
- `channels.msteams.cloud`: Teams SDK cloud environment (`Public`, `USGov`, `USGovDoD`, or `China`; default `Public`). Set this with `serviceUrl` for USGov/DoD SDK clouds; China uses the SDK preset and stored Azure China Bot Framework conversation references, with Graph-backed helpers disabled until Azure China Graph routing is implemented.
|
||||
- `channels.msteams.serviceUrl`: Bot Connector service URL boundary for SDK proactive operations. Public cloud uses the SDK default; set this for GCC (`https://smba.infra.gcc.teams.microsoft.com/teams`), GCC High, or DoD. China accepts Azure China Bot Framework channel hosts when the stored conversation reference comes from Teams operated by 21Vianet.
|
||||
- `channels.msteams.webhook.port` (default `3978`)
|
||||
- `channels.msteams.webhook.path` (default `/api/messages`)
|
||||
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
||||
|
||||
@@ -195,7 +195,7 @@ describe("elevenlabs speech provider", () => {
|
||||
|
||||
await provider.synthesize?.({
|
||||
text: "hello",
|
||||
target: "audio",
|
||||
target: "audio-file",
|
||||
cfg: {} as never,
|
||||
providerConfig: {
|
||||
apiKey: "xi-test",
|
||||
|
||||
144
extensions/msteams/npm-shrinkwrap.json
generated
144
extensions/msteams/npm-shrinkwrap.json
generated
@@ -9,11 +9,9 @@
|
||||
"version": "2026.5.28",
|
||||
"dependencies": {
|
||||
"@azure/identity": "4.13.1",
|
||||
"@microsoft/teams.api": "2.0.11",
|
||||
"@microsoft/teams.apps": "2.0.11",
|
||||
"@microsoft/teams.api": "2.0.12",
|
||||
"@microsoft/teams.apps": "2.0.12",
|
||||
"express": "5.2.1",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"jwks-rsa": "4.0.1",
|
||||
"typebox": "1.1.38"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -183,30 +181,30 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/teams.api": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/teams.api/-/teams.api-2.0.11.tgz",
|
||||
"integrity": "sha512-/QvOQkqSM73O9SrDLURyJZClnOAi6fJTX6qhhka/fPZbPU4ID4BIDvee7dSRbLx7lM+nSa370uLFzHHzXp5TWQ==",
|
||||
"version": "2.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/teams.api/-/teams.api-2.0.12.tgz",
|
||||
"integrity": "sha512-LQSCwRONUl09pdszTdgsRLQ0ZZcdq16goaBckzM/zKGuQkfSIT3u+3V1X2FVeND4sGt0wn+E/v29cZfhJAW4ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@microsoft/teams.cards": "2.0.11",
|
||||
"@microsoft/teams.common": "2.0.11",
|
||||
"@microsoft/teams.cards": "2.0.12",
|
||||
"@microsoft/teams.common": "2.0.12",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"qs": "^6.14.2"
|
||||
"qs": "^6.15.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/teams.apps": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/teams.apps/-/teams.apps-2.0.11.tgz",
|
||||
"integrity": "sha512-DSk09njNbFi5pc8GOAd3/Auqy52ZmsBJqu0wRXV2VQp/L+M8e9L2SXhmyIs164jhnwD0w3DYXPOjjZKHdu1M2A==",
|
||||
"version": "2.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/teams.apps/-/teams.apps-2.0.12.tgz",
|
||||
"integrity": "sha512-AZWxhnuBLlUvrz1Jm1DtoB/ZfvIiML8e3PGGmJm9MXnxd6mwv8ZcL9Po8Or96KDF6E+DICRbpXBO7I3b+B+X5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/msal-node": "^3.8.1",
|
||||
"@microsoft/teams.api": "2.0.11",
|
||||
"@microsoft/teams.common": "2.0.11",
|
||||
"@microsoft/teams.graph": "2.0.11",
|
||||
"@microsoft/teams.api": "2.0.12",
|
||||
"@microsoft/teams.common": "2.0.12",
|
||||
"@microsoft/teams.graph": "2.0.12",
|
||||
"axios": "^1.15.2",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.0.0",
|
||||
@@ -241,66 +239,19 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/teams.apps/node_modules/jose": {
|
||||
"version": "4.15.9",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/teams.apps/node_modules/jwks-rsa": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz",
|
||||
"integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/jsonwebtoken": "^9.0.4",
|
||||
"debug": "^4.3.4",
|
||||
"jose": "^4.15.4",
|
||||
"limiter": "^1.1.5",
|
||||
"lru-memoizer": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/teams.apps/node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/teams.apps/node_modules/lru-memoizer": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz",
|
||||
"integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lru-cache": "6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/teams.cards": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/teams.cards/-/teams.cards-2.0.11.tgz",
|
||||
"integrity": "sha512-4ErBqR4A4abpKSXsiCssRh2ZTpE3jsYHcWXLwL+fKnJo96GzlfSUV1Zg78dl7xWxe388SlqQ3Z4r3m/v413Mew==",
|
||||
"version": "2.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/teams.cards/-/teams.cards-2.0.12.tgz",
|
||||
"integrity": "sha512-FVSSuOpvjpWSsoYwJI05eB4irPlaBkepgmWGFe1dhqTC2In9GWvkfNPJieyvmeDydj1jqHwwrjrkHO3MdGjiCw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/teams.common": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/teams.common/-/teams.common-2.0.11.tgz",
|
||||
"integrity": "sha512-XuGTRlYfLOQxJZuZI6IUhbTRQjgXZAgW59LlGnFJ/nb00G8GnJwdCrFbis+bQa+h7dP5SdLIi1ZybVGYomKgqA==",
|
||||
"version": "2.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/teams.common/-/teams.common-2.0.12.tgz",
|
||||
"integrity": "sha512-gFFeWXXABOkarUViYIM4DJxNxNSTcXHv7Ds6poNyb3HODsY3kZV3EmYaDanP7KDqqXbUPlgB3LPV9bYRgcL9JQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.15.2"
|
||||
@@ -310,13 +261,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/teams.graph": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/teams.graph/-/teams.graph-2.0.11.tgz",
|
||||
"integrity": "sha512-Txc0N6dENmEluOCwGzCerz+3G/uomfzCElla1OR7nUNICIcY8p1A2babcIAA8AZiuAKPSkck0U1w5RTu7jZgVQ==",
|
||||
"version": "2.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/teams.graph/-/teams.graph-2.0.12.tgz",
|
||||
"integrity": "sha512-dMioF/l/bb/cDZDZed8/7CeIZJEsREE4GwSn9V9h1/KiY004bLnjePVeLjpMt4QRoUmPn+GVokhEXztIFTYZzA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@microsoft/teams.common": "2.0.11",
|
||||
"qs": "^6.14.2"
|
||||
"@microsoft/teams.common": "2.0.12",
|
||||
"qs": "^6.15.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -1102,9 +1053,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||
"version": "4.15.9",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
@@ -1144,19 +1095,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jwks-rsa": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-4.0.1.tgz",
|
||||
"integrity": "sha512-poXwUA8S4cP9P5N8tZS3xnUDJH8WmwSGfKK9gIaRPdjLHyJtd9iX/cngX9CUIe0Caof5JhK2EbN7N5lnnaf9NA==",
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz",
|
||||
"integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/jsonwebtoken": "^9.0.4",
|
||||
"debug": "^4.3.4",
|
||||
"jose": "^6.1.3",
|
||||
"jose": "^4.15.4",
|
||||
"limiter": "^1.1.5",
|
||||
"lru-memoizer": "^3.0.0"
|
||||
"lru-memoizer": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >= 23.0.0"
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
@@ -1232,22 +1183,25 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz",
|
||||
"integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-memoizer": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-3.0.0.tgz",
|
||||
"integrity": "sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz",
|
||||
"integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lru-cache": "^11.0.1"
|
||||
"lru-cache": "6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
@@ -1521,9 +1475,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
|
||||
@@ -9,16 +9,14 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@azure/identity": "4.13.1",
|
||||
"@microsoft/teams.api": "2.0.11",
|
||||
"@microsoft/teams.apps": "2.0.11",
|
||||
"@microsoft/teams.api": "2.0.12",
|
||||
"@microsoft/teams.apps": "2.0.12",
|
||||
"express": "5.2.1",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"jwks-rsa": "4.0.1",
|
||||
"typebox": "1.1.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"jose": "6.2.3",
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -35,6 +35,7 @@ export type {
|
||||
GroupPolicy,
|
||||
GroupToolPolicyConfig,
|
||||
MSTeamsChannelConfig,
|
||||
MSTeamsCloudName,
|
||||
MSTeamsConfig,
|
||||
MSTeamsReplyStyle,
|
||||
MSTeamsTeamConfig,
|
||||
|
||||
@@ -268,14 +268,6 @@ const expectMockCallState = (mockFn: unknown, shouldCall: boolean) => {
|
||||
}
|
||||
};
|
||||
|
||||
const firstMockCall = (mock: ReturnType<typeof vi.fn>, label: string): unknown[] => {
|
||||
const [call] = mock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error(`expected ${label} call`);
|
||||
}
|
||||
return call;
|
||||
};
|
||||
|
||||
const expectAttachmentMediaLength = (media: DownloadedMedia, expectedLength: number) => {
|
||||
expect(media).toHaveLength(expectedLength);
|
||||
};
|
||||
@@ -703,7 +695,7 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
// Should have hit the original host, NOT graph shares.
|
||||
expect(calledUrls).toContain(directUrl);
|
||||
expect(calledUrls.filter((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toEqual([]);
|
||||
expect(calledUrls.some((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -724,14 +716,12 @@ describe("msteams attachments", () => {
|
||||
);
|
||||
|
||||
expectAttachmentMediaLength(media, 0);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(firstMockCall(logger.warn, "logger.warn")).toStrictEqual([
|
||||
"msteams attachment download failed",
|
||||
{
|
||||
error: "HTTP 500",
|
||||
host: "x",
|
||||
},
|
||||
]);
|
||||
|
||||
// Migration inlines host + error into the message text — the structured
|
||||
// meta object was being dropped by the logger formatter pre-migration.
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/msteams attachment download failed.*host=.*error=.*HTTP 500/),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not log when downloads succeed", async () => {
|
||||
|
||||
@@ -310,10 +310,10 @@ export async function downloadMSTeamsAttachments(params: {
|
||||
});
|
||||
out.push(media);
|
||||
} catch (err) {
|
||||
params.logger?.warn?.("msteams attachment download failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
host: safeHostForLog(candidate.url),
|
||||
});
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
params.logger?.warn?.(
|
||||
`msteams attachment download failed host=${safeHostForLog(candidate.url)} error=${msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -78,6 +78,22 @@ describe("msteams attachment allowlists", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows Azure China Bot Framework attachment URLs with auth by default", () => {
|
||||
const policy = resolveAttachmentFetchPolicy();
|
||||
const url = "https://msteams.botframework.azure.cn/teams/v3/attachments/att-1/views/original";
|
||||
const headers = new Headers();
|
||||
|
||||
expect(isUrlAllowed(url, policy.allowHosts)).toBe(true);
|
||||
applyAuthorizationHeaderForUrl({
|
||||
headers,
|
||||
url,
|
||||
authAllowHosts: policy.authAllowHosts,
|
||||
bearerToken: "token-1",
|
||||
});
|
||||
|
||||
expect(headers.get("Authorization")).toBe("Bearer token-1");
|
||||
});
|
||||
|
||||
it("requires https and host suffix match", () => {
|
||||
const allowHosts = resolveAllowedHosts(["sharepoint.com"]);
|
||||
expect(isUrlAllowed("https://contoso.sharepoint.com/file.png", allowHosts)).toBe(true);
|
||||
|
||||
@@ -62,6 +62,7 @@ const DEFAULT_MEDIA_HOST_ALLOWLIST = [
|
||||
"media.ams.skype.com",
|
||||
// Bot Framework attachment URLs
|
||||
"trafficmanager.net",
|
||||
"botframework.azure.cn",
|
||||
"blob.core.windows.net",
|
||||
"azureedge.net",
|
||||
"microsoft.com",
|
||||
@@ -73,6 +74,7 @@ const DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST = [
|
||||
// Bot Framework Service URL (smba.trafficmanager.net) used for outbound
|
||||
// replies and inbound attachment downloads (clipboard-pasted images).
|
||||
"smba.trafficmanager.net",
|
||||
"botframework.azure.cn",
|
||||
"graph.microsoft.com",
|
||||
"graph.microsoft.us",
|
||||
"graph.microsoft.de",
|
||||
|
||||
172
extensions/msteams/src/auth-coverage.test.ts
Normal file
172
extensions/msteams/src/auth-coverage.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Auth coverage tests for the SDK migration (#76262 reviewer ask from
|
||||
* @BradGroux). Locks in three contract guarantees that the SDK's built-in
|
||||
* JWT validation must satisfy:
|
||||
*
|
||||
* 1. Inbound Bot Framework tokens with `aud=<bot app id>` are accepted.
|
||||
* 2. Inbound tokens with `aud=https://api.botframework.com` are rejected,
|
||||
* even when the `appid` claim matches the bot. That audience belongs to
|
||||
* the SMBA/ABS Connector resource (token issued *for* the Connector);
|
||||
* accepting it inbound on the bot would be a confused-deputy that
|
||||
* contradicts the Entra audience-validation guidance.
|
||||
* 3. The 2.0.10 SDK bump's v1-issuer support is exercised: Entra tokens
|
||||
* issued by the legacy `https://sts.windows.net/{tenantId}/` endpoint
|
||||
* are accepted alongside the v2 `https://login.microsoftonline.com/...`
|
||||
* endpoint when `allowedTenantIds` is configured.
|
||||
*
|
||||
* The tests reach into `@microsoft/teams.apps`'s internal middleware/auth
|
||||
* subpath to drive `ServiceTokenValidator` and `createEntraTokenValidator`
|
||||
* directly. Those aren't part of the SDK's public barrel today; if they
|
||||
* shift in a future SDK release this file lights up clearly. We chose this
|
||||
* over standing up an Express + supertest harness because the contract being
|
||||
* tested is purely the validator's accept/reject behavior — the surrounding
|
||||
* HTTP plumbing is a separate concern covered by `monitor.lifecycle.test.ts`.
|
||||
*
|
||||
* `JwksClient.prototype.getSigningKey` is patched to return a single
|
||||
* in-memory test public key so we don't hit `login.botframework.com` /
|
||||
* `login.microsoftonline.com` during the test. `jose` (devDep) mints RS256
|
||||
* tokens against the matching private key.
|
||||
*/
|
||||
|
||||
// Internal subpath imports. See file header for the rationale.
|
||||
import { createEntraTokenValidator } from "@microsoft/teams.apps/dist/middleware/auth/jwt-validator.js";
|
||||
import { ServiceTokenValidator } from "@microsoft/teams.apps/dist/middleware/auth/service-token-validator.js";
|
||||
import type { ILogger } from "@microsoft/teams.common";
|
||||
import { exportSPKI, generateKeyPair, SignJWT } from "jose";
|
||||
import { JwksClient, type SigningKey } from "jwks-rsa";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const APP_ID = "test-app-id";
|
||||
const TENANT_ID = "test-tenant-id";
|
||||
const TEST_KID = "test-key-id";
|
||||
|
||||
let privateKey: CryptoKey;
|
||||
let publicPem: string;
|
||||
|
||||
async function mintToken(claims: Record<string, unknown>): Promise<string> {
|
||||
return await new SignJWT(claims)
|
||||
.setProtectedHeader({ alg: "RS256", kid: TEST_KID })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("1h")
|
||||
.sign(privateKey);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const { publicKey, privateKey: priv } = await generateKeyPair("RS256", {
|
||||
modulusLength: 2048,
|
||||
});
|
||||
privateKey = priv;
|
||||
publicPem = await exportSPKI(publicKey);
|
||||
|
||||
// Patch `JwksClient.prototype.getSigningKey` so every JWKS lookup the SDK
|
||||
// performs returns our in-memory test key instead of fetching from
|
||||
// `login.botframework.com` / `login.microsoftonline.com`. We patch the
|
||||
// prototype here (rather than mocking the `jwks-rsa` module) because
|
||||
// `jwks-rsa`'s constructor captures the prototype method reference into a
|
||||
// cache wrapper at construction time — patching the prototype before any
|
||||
// `JwksClient` is constructed in the tests is sufficient and avoids the
|
||||
// CJS `__importDefault` shaping headaches of mocking the package itself.
|
||||
vi.spyOn(JwksClient.prototype, "getSigningKey").mockImplementation((async (
|
||||
kid?: string | null,
|
||||
) => {
|
||||
const key: SigningKey = {
|
||||
kid: kid ?? TEST_KID,
|
||||
alg: "RS256",
|
||||
getPublicKey: () => publicPem,
|
||||
rsaPublicKey: publicPem,
|
||||
};
|
||||
return key;
|
||||
}) as JwksClient["getSigningKey"]);
|
||||
});
|
||||
|
||||
// Logger that surfaces SDK validation failures so we can see *why* a token
|
||||
// was rejected when the test fails. `error` is what the SDK uses for
|
||||
// rejection reasons; the rest are no-ops to keep the test output clean.
|
||||
const debugLogger: ILogger = {
|
||||
child: () => debugLogger,
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
error: (...args: unknown[]) => console.error("[sdk]", ...args),
|
||||
warn: () => {},
|
||||
log: () => {},
|
||||
trace: () => {},
|
||||
};
|
||||
|
||||
describe("ServiceTokenValidator (inbound Bot Framework)", () => {
|
||||
it("accepts a token whose audience matches the bot app id", async () => {
|
||||
const validator = new ServiceTokenValidator(APP_ID, undefined, undefined, debugLogger);
|
||||
const token = await mintToken({
|
||||
aud: APP_ID,
|
||||
iss: "https://api.botframework.com",
|
||||
});
|
||||
|
||||
const result = await validator.check(`Bearer ${token}`, { id: "activity-1" });
|
||||
|
||||
expect(result.appId).toBe(APP_ID);
|
||||
});
|
||||
|
||||
it("rejects a token with aud=api.botframework.com even when the appid claim matches the bot", async () => {
|
||||
const validator = new ServiceTokenValidator(APP_ID);
|
||||
// This is the confused-deputy shape: the token was issued *for* the
|
||||
// Connector resource (`aud=https://api.botframework.com`) and happens to
|
||||
// carry the bot's app id in `appid`. The SDK must reject it on the
|
||||
// audience check before any appid/azp logic runs.
|
||||
const token = await mintToken({
|
||||
aud: "https://api.botframework.com",
|
||||
iss: "https://api.botframework.com",
|
||||
appid: APP_ID,
|
||||
azp: APP_ID,
|
||||
});
|
||||
|
||||
await expect(validator.check(`Bearer ${token}`, { id: "activity-2" })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEntraTokenValidator (Entra access tokens — SDK 2.0.10 v1 issuer fix)", () => {
|
||||
it("accepts the v1 sts.windows.net issuer for an allowed tenant", async () => {
|
||||
const validator = createEntraTokenValidator(TENANT_ID, APP_ID, {
|
||||
allowedTenantIds: [TENANT_ID],
|
||||
});
|
||||
const token = await mintToken({
|
||||
aud: APP_ID,
|
||||
iss: `https://sts.windows.net/${TENANT_ID}/`,
|
||||
});
|
||||
|
||||
const payload = await validator.validateAccessToken(token);
|
||||
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload?.iss).toBe(`https://sts.windows.net/${TENANT_ID}/`);
|
||||
});
|
||||
|
||||
it("accepts the v2 login.microsoftonline.com issuer for an allowed tenant", async () => {
|
||||
const validator = createEntraTokenValidator(TENANT_ID, APP_ID, {
|
||||
allowedTenantIds: [TENANT_ID],
|
||||
});
|
||||
const token = await mintToken({
|
||||
aud: APP_ID,
|
||||
iss: `https://login.microsoftonline.com/${TENANT_ID}/v2.0`,
|
||||
});
|
||||
|
||||
const payload = await validator.validateAccessToken(token);
|
||||
|
||||
expect(payload).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an issuer for a tenant that is not allowed", async () => {
|
||||
const validator = createEntraTokenValidator(TENANT_ID, APP_ID, {
|
||||
allowedTenantIds: [TENANT_ID],
|
||||
});
|
||||
const token = await mintToken({
|
||||
aud: APP_ID,
|
||||
iss: `https://sts.windows.net/some-other-tenant-id/`,
|
||||
});
|
||||
|
||||
// The SDK's `validateAccessToken` resolves to `null` (rather than
|
||||
// throwing) when issuer/audience/signature checks fail. The contract we
|
||||
// care about is "this token does not yield a payload" — both shapes are
|
||||
// valid rejections; we just want to be sure a non-allowed tenant does
|
||||
// not produce a usable payload.
|
||||
const payload = await validator.validateAccessToken(token);
|
||||
expect(payload).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
|
||||
const DEFAULT_BOT_FRAMEWORK_SERVICE_URL_HOST_ALLOWLIST = [
|
||||
// Microsoft Teams Bot Framework serviceUrl endpoints documented for
|
||||
// commercial, GCC, GCC High, and DOD clouds. These are the only hosts that may
|
||||
// receive Bot Framework service tokens from this plugin.
|
||||
// commercial, GCC, GCC High, and DOD clouds. Azure China Bot Framework
|
||||
// documents *.botframework.azure.cn as the channel boundary for 21Vianet.
|
||||
// These are the only hosts that may receive Bot Framework service tokens.
|
||||
"smba.trafficmanager.net",
|
||||
"smba.infra.gcc.teams.microsoft.com",
|
||||
"smba.infra.gov.teams.microsoft.us",
|
||||
"smba.infra.dod.teams.microsoft.us",
|
||||
"botframework.azure.cn",
|
||||
] as const;
|
||||
|
||||
export const BOT_FRAMEWORK_SERVICE_URL_HOST_ALLOWLIST = normalizeHostnameSuffixAllowlist(
|
||||
|
||||
@@ -96,6 +96,70 @@ describe("msteams config schema", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts Teams SDK cloud and serviceUrl configuration", () => {
|
||||
const res = MSTeamsConfigSchema.safeParse({
|
||||
cloud: "USGovDoD",
|
||||
serviceUrl: "https://smba.infra.dod.teams.microsoft.us/teams",
|
||||
});
|
||||
|
||||
expect(res.success).toBe(true);
|
||||
if (res.success) {
|
||||
expect(res.data.cloud).toBe("USGovDoD");
|
||||
expect(res.data.serviceUrl).toBe("https://smba.infra.dod.teams.microsoft.us/teams");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unsupported Teams serviceUrl hosts", () => {
|
||||
const res = MSTeamsConfigSchema.safeParse({
|
||||
cloud: "USGovDoD",
|
||||
serviceUrl: "https://dod.example.mil/teams",
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts China cloud without a configured global serviceUrl", () => {
|
||||
const res = MSTeamsConfigSchema.safeParse({
|
||||
cloud: "China",
|
||||
});
|
||||
|
||||
expect(res.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts Azure China Bot Framework serviceUrl hosts", () => {
|
||||
const res = MSTeamsConfigSchema.safeParse({
|
||||
cloud: "China",
|
||||
serviceUrl: "https://msteams.botframework.azure.cn/teams",
|
||||
});
|
||||
|
||||
expect(res.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-China serviceUrl hosts when China cloud is configured", () => {
|
||||
const res = MSTeamsConfigSchema.safeParse({
|
||||
cloud: "China",
|
||||
serviceUrl: "https://smba.trafficmanager.net/teams",
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects Azure China Bot Framework serviceUrl hosts without China cloud", () => {
|
||||
const res = MSTeamsConfigSchema.safeParse({
|
||||
serviceUrl: "https://msteams.botframework.azure.cn/teams",
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
});
|
||||
|
||||
it("requires serviceUrl with non-public Teams clouds", () => {
|
||||
const res = MSTeamsConfigSchema.safeParse({
|
||||
cloud: "USGov",
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid replyStyle", () => {
|
||||
const res = MSTeamsConfigSchema.safeParse({
|
||||
replyStyle: "nope",
|
||||
|
||||
153
extensions/msteams/src/cloud.test.ts
Normal file
153
extensions/msteams/src/cloud.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveMSTeamsSdkCloudOptions,
|
||||
validateMSTeamsProactiveServiceUrlBoundary,
|
||||
} from "./cloud.js";
|
||||
|
||||
describe("resolveMSTeamsSdkCloudOptions", () => {
|
||||
it("defaults to public cloud without an explicit serviceUrl", () => {
|
||||
expect(resolveMSTeamsSdkCloudOptions({})).toEqual({ cloud: "Public" });
|
||||
});
|
||||
|
||||
it("passes serviceUrl override through with default public cloud", () => {
|
||||
expect(
|
||||
resolveMSTeamsSdkCloudOptions({
|
||||
serviceUrl: " https://smba.infra.gcc.teams.microsoft.com/teams ",
|
||||
}),
|
||||
).toEqual({
|
||||
cloud: "Public",
|
||||
serviceUrl: "https://smba.infra.gcc.teams.microsoft.com/teams",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires serviceUrl when US government cloud is configured", () => {
|
||||
expect(() => resolveMSTeamsSdkCloudOptions({ cloud: "USGov" })).toThrow(
|
||||
/channels\.msteams\.cloud=USGov requires channels\.msteams\.serviceUrl/,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows China cloud without a configured global serviceUrl", () => {
|
||||
expect(resolveMSTeamsSdkCloudOptions({ cloud: "China" })).toEqual({
|
||||
cloud: "China",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes configured cloud and serviceUrl through to the SDK", () => {
|
||||
expect(
|
||||
resolveMSTeamsSdkCloudOptions({
|
||||
cloud: "USGovDoD",
|
||||
serviceUrl: " https://smba.infra.dod.teams.microsoft.us/teams ",
|
||||
}),
|
||||
).toEqual({
|
||||
cloud: "USGovDoD",
|
||||
serviceUrl: "https://smba.infra.dod.teams.microsoft.us/teams",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateMSTeamsProactiveServiceUrlBoundary", () => {
|
||||
it("allows public-cloud stored serviceUrls with the default public cloud", () => {
|
||||
expect(() =>
|
||||
validateMSTeamsProactiveServiceUrlBoundary({
|
||||
cloud: "Public",
|
||||
conversationId: "19:conversation@thread.tacv2",
|
||||
storedServiceUrl: "https://smba.trafficmanager.net/amer/",
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("blocks non-public stored serviceUrls when public cloud is configured", () => {
|
||||
expect(() =>
|
||||
validateMSTeamsProactiveServiceUrlBoundary({
|
||||
cloud: "Public",
|
||||
conversationId: "19:conversation@thread.tacv2",
|
||||
storedServiceUrl: "https://smba.infra.gcc.example/teams",
|
||||
}),
|
||||
).toThrow(/not a Microsoft Teams public-cloud Bot Connector endpoint/);
|
||||
});
|
||||
|
||||
it("allows China cloud stored serviceUrls on the Azure China Bot Framework boundary", () => {
|
||||
expect(() =>
|
||||
validateMSTeamsProactiveServiceUrlBoundary({
|
||||
cloud: "China",
|
||||
conversationId: "19:conversation@thread.tacv2",
|
||||
storedServiceUrl: "https://msteams.botframework.azure.cn/teams/",
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("blocks non-China serviceUrls when China cloud is configured without a serviceUrl", () => {
|
||||
expect(() =>
|
||||
validateMSTeamsProactiveServiceUrlBoundary({
|
||||
cloud: "China",
|
||||
conversationId: "19:conversation@thread.tacv2",
|
||||
storedServiceUrl: "https://smba.trafficmanager.net/teams/",
|
||||
}),
|
||||
).toThrow(/not a Microsoft Teams China Bot Framework channel endpoint/);
|
||||
});
|
||||
|
||||
it("blocks configured non-China serviceUrls when China cloud is configured", () => {
|
||||
expect(() =>
|
||||
validateMSTeamsProactiveServiceUrlBoundary({
|
||||
cloud: "China",
|
||||
conversationId: "19:conversation@thread.tacv2",
|
||||
storedServiceUrl: "https://smba.trafficmanager.net/teams/",
|
||||
configuredServiceUrl: "https://smba.trafficmanager.net/teams",
|
||||
}),
|
||||
).toThrow(/configured Teams serviceUrl .*not a Microsoft Teams China Bot Framework/);
|
||||
});
|
||||
|
||||
it("blocks configured China serviceUrls unless China cloud is configured", () => {
|
||||
expect(() =>
|
||||
validateMSTeamsProactiveServiceUrlBoundary({
|
||||
cloud: "Public",
|
||||
conversationId: "19:conversation@thread.tacv2",
|
||||
storedServiceUrl: "https://msteams.botframework.azure.cn/teams/",
|
||||
configuredServiceUrl: "https://msteams.botframework.azure.cn/teams",
|
||||
}),
|
||||
).toThrow(/requires channels\.msteams\.cloud=China/);
|
||||
});
|
||||
|
||||
it("requires serviceUrl when non-public cloud is configured", () => {
|
||||
expect(() =>
|
||||
validateMSTeamsProactiveServiceUrlBoundary({
|
||||
cloud: "USGov",
|
||||
conversationId: "19:conversation@thread.tacv2",
|
||||
storedServiceUrl: "https://gov.example.us/teams",
|
||||
}),
|
||||
).toThrow(/cloud=USGov requires channels\.msteams\.serviceUrl/);
|
||||
});
|
||||
|
||||
it("blocks configured serviceUrl host mismatches", () => {
|
||||
expect(() =>
|
||||
validateMSTeamsProactiveServiceUrlBoundary({
|
||||
cloud: "USGovDoD",
|
||||
conversationId: "19:conversation@thread.tacv2",
|
||||
storedServiceUrl: "https://dod-a.example.mil/teams",
|
||||
configuredServiceUrl: "https://dod-b.example.mil/teams",
|
||||
}),
|
||||
).toThrow(/does not match configured Teams SDK serviceUrl host/);
|
||||
});
|
||||
|
||||
it("allows configured serviceUrl host matches with different paths", () => {
|
||||
expect(() =>
|
||||
validateMSTeamsProactiveServiceUrlBoundary({
|
||||
cloud: "USGov",
|
||||
conversationId: "19:conversation@thread.tacv2",
|
||||
storedServiceUrl: "https://connector.example.cn/teams-region/",
|
||||
configuredServiceUrl: "https://connector.example.cn/teams",
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("allows configured China serviceUrl host matches with different paths", () => {
|
||||
expect(() =>
|
||||
validateMSTeamsProactiveServiceUrlBoundary({
|
||||
cloud: "China",
|
||||
conversationId: "19:conversation@thread.tacv2",
|
||||
storedServiceUrl: "https://msteams.botframework.azure.cn/teams-region/",
|
||||
configuredServiceUrl: "https://msteams.botframework.azure.cn/teams",
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
144
extensions/msteams/src/cloud.ts
Normal file
144
extensions/msteams/src/cloud.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { MSTeamsConfig } from "../runtime-api.js";
|
||||
|
||||
export type MSTeamsCloudName = "Public" | "USGov" | "USGovDoD" | "China";
|
||||
|
||||
export const DEFAULT_MSTEAMS_CLOUD: MSTeamsCloudName = "Public";
|
||||
|
||||
const PUBLIC_MSTEAMS_SERVICE_HOST = "smba.trafficmanager.net";
|
||||
const CHINA_BOT_FRAMEWORK_SERVICE_HOST = "botframework.azure.cn";
|
||||
|
||||
export type MSTeamsSdkCloudOptions = {
|
||||
cloud: MSTeamsCloudName;
|
||||
serviceUrl?: string;
|
||||
};
|
||||
|
||||
type NormalizedServiceUrl = {
|
||||
value: string;
|
||||
host: string;
|
||||
};
|
||||
|
||||
function normalizeOptionalServiceUrl(value: string | undefined): NormalizedServiceUrl | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
parsed.hash = "";
|
||||
parsed.search = "";
|
||||
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
|
||||
return {
|
||||
value: parsed.toString().replace(/\/+$/, ""),
|
||||
host: parsed.hostname.toLowerCase(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveMSTeamsSdkCloudOptions(cfg?: MSTeamsConfig): MSTeamsSdkCloudOptions {
|
||||
const cloud = cfg?.cloud ?? DEFAULT_MSTEAMS_CLOUD;
|
||||
const serviceUrl = cfg?.serviceUrl?.trim();
|
||||
if (cloud !== "Public" && cloud !== "China" && !serviceUrl) {
|
||||
throw new Error(
|
||||
`channels.msteams.cloud=${cloud} requires channels.msteams.serviceUrl so SDK proactive operations use the matching Teams Bot Connector endpoint.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
cloud,
|
||||
...(serviceUrl ? { serviceUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function isChinaBotFrameworkServiceHost(host: string): boolean {
|
||||
return (
|
||||
host === CHINA_BOT_FRAMEWORK_SERVICE_HOST ||
|
||||
host.endsWith(`.${CHINA_BOT_FRAMEWORK_SERVICE_HOST}`)
|
||||
);
|
||||
}
|
||||
|
||||
function isChinaBotFrameworkServiceUrl(value: string): boolean {
|
||||
const parsed = normalizeOptionalServiceUrl(value);
|
||||
return Boolean(parsed && isChinaBotFrameworkServiceHost(parsed.host));
|
||||
}
|
||||
|
||||
export function validateMSTeamsProactiveServiceUrlBoundary(params: {
|
||||
cloud: MSTeamsCloudName;
|
||||
conversationId: string;
|
||||
storedServiceUrl?: string;
|
||||
configuredServiceUrl?: string;
|
||||
}) {
|
||||
const configured = normalizeOptionalServiceUrl(params.configuredServiceUrl);
|
||||
if (params.cloud !== "Public" && params.cloud !== "China" && !configured) {
|
||||
throw new Error(
|
||||
`msteams proactive send blocked for ${params.conversationId}: channels.msteams.cloud=${params.cloud} requires ` +
|
||||
"channels.msteams.serviceUrl so SDK proactive operations use the matching Teams Bot Connector endpoint.",
|
||||
);
|
||||
}
|
||||
|
||||
if (params.cloud === "China" && configured && !isChinaBotFrameworkServiceHost(configured.host)) {
|
||||
throw new Error(
|
||||
`msteams proactive send blocked for ${params.conversationId}: configured Teams serviceUrl (${configured.value}) ` +
|
||||
"is not a Microsoft Teams China Bot Framework channel endpoint.",
|
||||
);
|
||||
}
|
||||
if (params.cloud !== "China" && configured && isChinaBotFrameworkServiceHost(configured.host)) {
|
||||
throw new Error(
|
||||
`msteams proactive send blocked for ${params.conversationId}: configured Teams serviceUrl (${configured.value}) ` +
|
||||
"requires channels.msteams.cloud=China.",
|
||||
);
|
||||
}
|
||||
|
||||
if (configured) {
|
||||
const stored = normalizeOptionalServiceUrl(params.storedServiceUrl);
|
||||
if (!stored) {
|
||||
throw new Error(
|
||||
`msteams proactive send blocked for ${params.conversationId}: stored conversation reference is missing a valid serviceUrl. ` +
|
||||
"Ask the bot to receive a new Teams message in this conversation, then retry.",
|
||||
);
|
||||
}
|
||||
if (stored.host !== configured.host) {
|
||||
throw new Error(
|
||||
`msteams proactive send blocked for ${params.conversationId}: stored conversation serviceUrl (${stored.value}) ` +
|
||||
`does not match configured Teams SDK serviceUrl host (${configured.host}). ` +
|
||||
"Set channels.msteams.cloud/channels.msteams.serviceUrl for the Teams cloud that owns this conversation, or refresh the stored conversation by receiving a new message.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const stored = normalizeOptionalServiceUrl(params.storedServiceUrl);
|
||||
if (!stored) {
|
||||
throw new Error(
|
||||
`msteams proactive send blocked for ${params.conversationId}: stored conversation reference is missing a valid serviceUrl. ` +
|
||||
"Ask the bot to receive a new Teams message in this conversation, then retry.",
|
||||
);
|
||||
}
|
||||
|
||||
if (params.cloud === "China") {
|
||||
if (!isChinaBotFrameworkServiceHost(stored.host)) {
|
||||
throw new Error(
|
||||
`msteams proactive send blocked for ${params.conversationId}: stored conversation serviceUrl (${stored.value}) ` +
|
||||
"is not a Microsoft Teams China Bot Framework channel endpoint. " +
|
||||
"Use a conversation reference received from the China/21Vianet Teams cloud.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isChinaBotFrameworkServiceUrl(stored.value)) {
|
||||
throw new Error(
|
||||
`msteams proactive send blocked for ${params.conversationId}: stored conversation serviceUrl (${stored.value}) ` +
|
||||
"requires channels.msteams.cloud=China.",
|
||||
);
|
||||
}
|
||||
|
||||
if (stored.host !== PUBLIC_MSTEAMS_SERVICE_HOST) {
|
||||
throw new Error(
|
||||
`msteams proactive send blocked for ${params.conversationId}: stored conversation serviceUrl (${stored.value}) ` +
|
||||
"is not a Microsoft Teams public-cloud Bot Connector endpoint. " +
|
||||
"Set channels.msteams.cloud and channels.msteams.serviceUrl for the supported Teams cloud that owns this conversation.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,14 @@ export const msTeamsChannelConfigUiHints = {
|
||||
label: "MS Teams Config Writes",
|
||||
help: "Allow Microsoft Teams to write config in response to channel events/commands (default: true).",
|
||||
},
|
||||
cloud: {
|
||||
label: "MS Teams Cloud",
|
||||
help: 'Teams SDK cloud environment for auth, token validation, and token services: "Public", "USGov", "USGovDoD", or "China" (default: Public).',
|
||||
},
|
||||
serviceUrl: {
|
||||
label: "MS Teams Service URL",
|
||||
help: "Bot Connector service URL for SDK proactive sends/edits/deletes. Set with cloud for USGov/DoD; set alone for GCC.",
|
||||
},
|
||||
streaming: {
|
||||
label: "MS Teams Streaming",
|
||||
help: 'Microsoft Teams preview/progress streaming mode: "off" | "partial" | "block" | "progress". Personal chats use Teams native streaminfo progress when available.',
|
||||
|
||||
200
extensions/msteams/src/feedback-invoke.ts
Normal file
200
extensions/msteams/src/feedback-invoke.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import path from "node:path";
|
||||
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
||||
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { formatUnknownError } from "./errors.js";
|
||||
import { buildFeedbackEvent, runFeedbackReflection } from "./feedback-reflection.js";
|
||||
import { extractMSTeamsConversationMessageId, normalizeMSTeamsConversationId } from "./inbound.js";
|
||||
import { isFeedbackInvokeAuthorized } from "./monitor-handler.js";
|
||||
import type { MSTeamsMessageHandlerDeps } from "./monitor-handler.types.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||
|
||||
/**
|
||||
* Run the message-submit (feedback) invoke handler.
|
||||
*
|
||||
* Teams delivers feedback (`actionName === "feedback"`) on AI-generated
|
||||
* messages as a `message/submitAction` invoke. The SDK wraps a void return
|
||||
* into the HTTP 200 InvokeResponse, so this function intentionally does
|
||||
* not ack itself — the legacy `ctx.sendActivity({ type: "invokeResponse",
|
||||
* … })` shape is gone (it became an outbound BF activity on the new SDK
|
||||
* instead of the HTTP response).
|
||||
*
|
||||
* Returns `true` if the invoke matched the feedback shape and was
|
||||
* consumed (whether or not it was authorized / written / reflected on),
|
||||
* `false` if the invoke didn't look like feedback at all and the caller
|
||||
* should fall through to other handlers.
|
||||
*/
|
||||
export async function runMSTeamsFeedbackInvokeHandler(
|
||||
context: MSTeamsTurnContext,
|
||||
deps: MSTeamsMessageHandlerDeps,
|
||||
): Promise<boolean> {
|
||||
const activity = context.activity;
|
||||
const value = activity.value as
|
||||
| {
|
||||
actionName?: string;
|
||||
actionValue?: { reaction?: string; feedback?: string };
|
||||
replyToId?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Teams feedback invoke format: actionName="feedback", actionValue.reaction="like"|"dislike"
|
||||
if (value.actionName !== "feedback") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const reaction = value.actionValue?.reaction;
|
||||
if (reaction !== "like" && reaction !== "dislike") {
|
||||
deps.log.debug?.("ignoring feedback with unknown reaction", { reaction });
|
||||
return false;
|
||||
}
|
||||
|
||||
const msteamsCfg = deps.cfg.channels?.msteams;
|
||||
if (msteamsCfg?.feedbackEnabled === false) {
|
||||
deps.log.debug?.("feedback handling disabled");
|
||||
return true; // Still consume the invoke
|
||||
}
|
||||
|
||||
if (!(await isFeedbackInvokeAuthorized(context, deps))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Extract user comment from the nested JSON string
|
||||
let userComment: string | undefined;
|
||||
if (value.actionValue?.feedback) {
|
||||
try {
|
||||
const parsed = JSON.parse(value.actionValue.feedback) as { feedbackText?: string };
|
||||
userComment = parsed.feedbackText || undefined;
|
||||
} catch {
|
||||
// Best effort — feedback text is optional
|
||||
}
|
||||
}
|
||||
|
||||
// Strip ;messageid=... suffix to match the normalized ID used by the message handler.
|
||||
const rawConversationId = activity.conversation?.id ?? "unknown";
|
||||
const conversationId = normalizeMSTeamsConversationId(rawConversationId);
|
||||
const senderId = activity.from?.aadObjectId ?? activity.from?.id ?? "unknown";
|
||||
const messageId = value.replyToId ?? activity.replyToId ?? "unknown";
|
||||
const isNegative = reaction === "dislike";
|
||||
|
||||
// Route feedback using the same chat-type logic as normal messages
|
||||
// so session keys, agent IDs, and transcript paths match.
|
||||
const convType = normalizeOptionalLowercaseString(activity.conversation?.conversationType);
|
||||
const isDirectMessage = convType === "personal" || (!convType && !activity.conversation?.isGroup);
|
||||
const isChannel = convType === "channel";
|
||||
|
||||
const core = getMSTeamsRuntime();
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg: deps.cfg,
|
||||
channel: "msteams",
|
||||
peer: {
|
||||
kind: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
|
||||
id: isDirectMessage ? senderId : conversationId,
|
||||
},
|
||||
});
|
||||
|
||||
// Match the thread-aware session key used by the message handler so feedback
|
||||
// events land in the correct per-thread transcript. For channel threads, the
|
||||
// thread root ID comes from the ;messageid= suffix on the conversation ID or
|
||||
// from activity.replyToId.
|
||||
const feedbackThreadId = isChannel
|
||||
? (extractMSTeamsConversationMessageId(rawConversationId) ?? activity.replyToId ?? undefined)
|
||||
: undefined;
|
||||
if (feedbackThreadId) {
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey: route.sessionKey,
|
||||
threadId: feedbackThreadId,
|
||||
parentSessionKey: route.sessionKey,
|
||||
});
|
||||
route.sessionKey = threadKeys.sessionKey;
|
||||
}
|
||||
|
||||
// Log feedback event to session JSONL
|
||||
const feedbackEvent = buildFeedbackEvent({
|
||||
messageId,
|
||||
value: isNegative ? "negative" : "positive",
|
||||
comment: userComment,
|
||||
sessionKey: route.sessionKey,
|
||||
agentId: route.agentId,
|
||||
conversationId,
|
||||
});
|
||||
|
||||
deps.log.info("received feedback", {
|
||||
value: feedbackEvent.value,
|
||||
messageId,
|
||||
conversationId,
|
||||
hasComment: Boolean(userComment),
|
||||
});
|
||||
|
||||
// Write feedback event to session transcript
|
||||
try {
|
||||
const storePath = core.channel.session.resolveStorePath(deps.cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const safeKey = route.sessionKey.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
const transcriptFile = path.join(storePath, `${safeKey}.jsonl`);
|
||||
await appendRegularFile({
|
||||
filePath: transcriptFile,
|
||||
content: `${JSON.stringify(feedbackEvent)}\n`,
|
||||
rejectSymlinkParents: true,
|
||||
}).catch(() => {
|
||||
// Best effort — transcript dir may not exist yet
|
||||
});
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
|
||||
// Build conversation reference for proactive messages (ack + reflection follow-up)
|
||||
const conversationRef = {
|
||||
activityId: activity.id,
|
||||
user: {
|
||||
id: activity.from?.id,
|
||||
name: activity.from?.name,
|
||||
aadObjectId: activity.from?.aadObjectId,
|
||||
},
|
||||
agent: activity.recipient
|
||||
? { id: activity.recipient.id, name: activity.recipient.name }
|
||||
: undefined,
|
||||
bot: activity.recipient
|
||||
? { id: activity.recipient.id, name: activity.recipient.name }
|
||||
: undefined,
|
||||
conversation: {
|
||||
id: conversationId,
|
||||
conversationType: activity.conversation?.conversationType,
|
||||
tenantId: activity.conversation?.tenantId,
|
||||
},
|
||||
channelId: activity.channelId ?? "msteams",
|
||||
serviceUrl: activity.serviceUrl,
|
||||
locale: activity.locale,
|
||||
};
|
||||
|
||||
// For negative feedback, trigger background reflection (fire-and-forget).
|
||||
// No ack message — the reflection follow-up serves as the acknowledgement.
|
||||
// Sending anything during the invoke handler causes "unable to reach app" errors.
|
||||
if (isNegative && msteamsCfg?.feedbackReflection !== false) {
|
||||
// Note: thumbedDownResponse is not populated here because we don't cache
|
||||
// sent message text. The agent still has full session context for reflection
|
||||
// since the reflection runs in the same session. The user comment (if any)
|
||||
// provides additional signal.
|
||||
runFeedbackReflection({
|
||||
cfg: deps.cfg,
|
||||
app: deps.app,
|
||||
appId: deps.appId,
|
||||
conversationRef,
|
||||
sessionKey: route.sessionKey,
|
||||
agentId: route.agentId,
|
||||
conversationId,
|
||||
feedbackMessageId: messageId,
|
||||
userComment,
|
||||
log: deps.log,
|
||||
}).catch((err) => {
|
||||
deps.log.error("feedback reflection failed", { error: formatUnknownError(err) });
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
dispatchReplyFromConfigWithSettledDispatcher,
|
||||
type OpenClawConfig,
|
||||
} from "../runtime-api.js";
|
||||
import { resolveMSTeamsSdkCloudOptions } from "./cloud.js";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import { formatUnknownError } from "./errors.js";
|
||||
import { buildReflectionPrompt, parseReflectionResponse } from "./feedback-reflection-prompt.js";
|
||||
@@ -14,12 +15,13 @@ import {
|
||||
recordReflectionTime,
|
||||
storeSessionLearning,
|
||||
} from "./feedback-reflection-store.js";
|
||||
import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import { buildConversationReference } from "./messenger.js";
|
||||
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import { sendMSTeamsActivityWithReference } from "./sdk-proactive.js";
|
||||
import type { MSTeamsApp } from "./sdk.js";
|
||||
|
||||
type FeedbackEvent = {
|
||||
export type FeedbackEvent = {
|
||||
type: "custom";
|
||||
event: "feedback";
|
||||
ts: number;
|
||||
@@ -53,9 +55,9 @@ export function buildFeedbackEvent(params: {
|
||||
};
|
||||
}
|
||||
|
||||
type RunFeedbackReflectionParams = {
|
||||
export type RunFeedbackReflectionParams = {
|
||||
cfg: OpenClawConfig;
|
||||
adapter: MSTeamsAdapter;
|
||||
app: MSTeamsApp;
|
||||
appId: string;
|
||||
conversationRef: StoredConversationReference;
|
||||
sessionKey: string;
|
||||
@@ -139,20 +141,18 @@ function createReflectionCaptureDispatcher(params: {
|
||||
}
|
||||
|
||||
async function sendReflectionFollowUp(params: {
|
||||
adapter: MSTeamsAdapter;
|
||||
appId: string;
|
||||
cfg: OpenClawConfig;
|
||||
app: MSTeamsApp;
|
||||
conversationRef: StoredConversationReference;
|
||||
userMessage: string;
|
||||
}): Promise<void> {
|
||||
const baseRef = buildConversationReference(params.conversationRef);
|
||||
const proactiveRef = { ...baseRef, activityId: undefined };
|
||||
|
||||
await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => {
|
||||
await ctx.sendActivity({
|
||||
type: "message",
|
||||
text: params.userMessage,
|
||||
});
|
||||
});
|
||||
await sendMSTeamsActivityWithReference(
|
||||
params.app,
|
||||
baseRef,
|
||||
{ type: "message", text: params.userMessage },
|
||||
{ serviceUrlBoundary: resolveMSTeamsSdkCloudOptions(params.cfg.channels?.msteams) },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -250,8 +250,8 @@ export async function runFeedbackReflection(params: RunFeedbackReflectionParams)
|
||||
|
||||
try {
|
||||
await sendReflectionFollowUp({
|
||||
adapter: params.adapter,
|
||||
appId: params.appId,
|
||||
cfg,
|
||||
app: params.app,
|
||||
conversationRef: params.conversationRef,
|
||||
userMessage: parsedReflection.userMessage!,
|
||||
});
|
||||
|
||||
@@ -130,12 +130,15 @@ async function handleMSTeamsFileConsentInvoke(
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function respondToMSTeamsFileConsentInvoke(
|
||||
/**
|
||||
* Run the file-consent invoke handler after the SDK route has acknowledged the
|
||||
* invoke. This intentionally does not send its own invokeResponse; it only does
|
||||
* the delayed upload/update work.
|
||||
*/
|
||||
export async function runMSTeamsFileConsentInvokeHandler(
|
||||
context: MSTeamsTurnContext,
|
||||
log: MSTeamsMonitorLogger,
|
||||
): Promise<void> {
|
||||
await context.sendActivity({ type: "invokeResponse", value: { status: 200 } });
|
||||
|
||||
try {
|
||||
await withRevokedProxyFallback({
|
||||
run: async () => await handleMSTeamsFileConsentInvoke(context, log),
|
||||
|
||||
@@ -300,6 +300,17 @@ describe("msteams graph helpers", () => {
|
||||
expect(getAccessToken).toHaveBeenCalledWith("https://graph.microsoft.com");
|
||||
});
|
||||
|
||||
it("fails closed for China cloud Graph token resolution", async () => {
|
||||
mockGraphTokenResolution();
|
||||
|
||||
await expectRejectsToThrow(
|
||||
resolveGraphToken({ channels: { msteams: { cloud: "China" } } }),
|
||||
"Microsoft Teams Graph operations are not supported for channels.msteams.cloud=China",
|
||||
);
|
||||
|
||||
expect(loadMSTeamsSdkWithAuthMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails when credentials or access tokens are unavailable", async () => {
|
||||
resolveMSTeamsCredentialsMock.mockReturnValue(undefined);
|
||||
await expectRejectsToThrow(resolveGraphToken({ channels: {} }), "MS Teams credentials missing");
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import { fetchWithSsrFGuard, type MSTeamsConfig } from "../runtime-api.js";
|
||||
import { GRAPH_ROOT } from "./attachments/shared.js";
|
||||
|
||||
const GRAPH_BETA = "https://graph.microsoft.com/beta";
|
||||
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
|
||||
import { createMSTeamsTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { resolveMSTeamsSdkCloudOptions } from "./cloud.js";
|
||||
import { createMSTeamsHttpError } from "./http-error.js";
|
||||
import { createMSTeamsTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { readAccessToken } from "./token-response.js";
|
||||
import { resolveDelegatedAccessToken, resolveMSTeamsCredentials } from "./token.js";
|
||||
import { buildUserAgent } from "./user-agent.js";
|
||||
|
||||
const GRAPH_BETA = "https://graph.microsoft.com/beta";
|
||||
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
|
||||
|
||||
export type GraphUser = {
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
@@ -212,6 +213,11 @@ export async function resolveGraphToken(
|
||||
if (!creds) {
|
||||
throw new Error("MS Teams credentials missing");
|
||||
}
|
||||
if (msteamsCfg?.cloud === "China") {
|
||||
throw new Error(
|
||||
"Microsoft Teams Graph operations are not supported for channels.msteams.cloud=China until Graph requests are routed through the Azure China Graph endpoint.",
|
||||
);
|
||||
}
|
||||
|
||||
// Try delegated token if requested and configured
|
||||
if (options?.preferDelegated && msteamsCfg?.delegatedAuth?.enabled && creds.type === "secret") {
|
||||
@@ -226,7 +232,7 @@ export async function resolveGraphToken(
|
||||
// Fall through to app-only token
|
||||
}
|
||||
|
||||
const { app } = await loadMSTeamsSdkWithAuth(creds);
|
||||
const { app } = await loadMSTeamsSdkWithAuth(creds, resolveMSTeamsSdkCloudOptions(msteamsCfg));
|
||||
const tokenProvider = createMSTeamsTokenProvider(app);
|
||||
const graphTokenValue = await tokenProvider.getAccessToken("https://graph.microsoft.com");
|
||||
const accessToken = readAccessToken(graphTokenValue);
|
||||
|
||||
@@ -24,9 +24,9 @@ import {
|
||||
buildConversationReference,
|
||||
renderReplyPayloadsToMessages,
|
||||
sendMSTeamsMessages,
|
||||
type MSTeamsAdapter,
|
||||
} from "./messenger.js";
|
||||
import { setMSTeamsRuntime } from "./runtime.js";
|
||||
import type { MSTeamsApp } from "./sdk.js";
|
||||
|
||||
const chunkMarkdownText = (text: string, limit: number) => {
|
||||
if (!text) {
|
||||
@@ -56,16 +56,6 @@ const runtimeStub = {
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
const noopUpdateActivity = async () => {};
|
||||
const noopDeleteActivity = async () => {};
|
||||
|
||||
const createNoopAdapter = (): MSTeamsAdapter => ({
|
||||
continueConversation: async () => {},
|
||||
process: async () => {},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
});
|
||||
|
||||
const createRecordedSendActivity = (
|
||||
sink: string[],
|
||||
failFirstWithStatusCode?: number,
|
||||
@@ -85,13 +75,6 @@ const createRecordedSendActivity = (
|
||||
|
||||
const REVOCATION_ERROR = "Cannot perform 'set' on a proxy that has been revoked";
|
||||
|
||||
function requireConversationId(ref: { conversation?: { id?: string } }) {
|
||||
if (!ref.conversation?.id) {
|
||||
throw new Error("expected Teams top-level send to preserve conversation id");
|
||||
}
|
||||
return ref.conversation.id;
|
||||
}
|
||||
|
||||
function requireSentMessage(sent: Array<{ text?: string; entities?: unknown[] }>) {
|
||||
const firstSent = sent[0];
|
||||
if (!firstSent?.text) {
|
||||
@@ -128,18 +111,70 @@ function requireMentionEntity(entities: unknown): Record<string, unknown> {
|
||||
return entity;
|
||||
}
|
||||
|
||||
const createFallbackAdapter = (proactiveSent: string[]): MSTeamsAdapter => ({
|
||||
continueConversation: async (_appId, _reference, logic) => {
|
||||
await logic({
|
||||
sendActivity: createRecordedSendActivity(proactiveSent),
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
type MockAppOptions = {
|
||||
createFn?: (activity: unknown) => Promise<unknown>;
|
||||
onClientCreated?: (serviceUrl: string, conversationId: string) => void;
|
||||
onReference?: (ref: unknown) => void;
|
||||
};
|
||||
|
||||
function createMockApp(opts?: MockAppOptions): MSTeamsApp {
|
||||
const createFn =
|
||||
opts?.createFn ??
|
||||
(async (activity: unknown) => {
|
||||
const text = (activity as Record<string, unknown>)?.text;
|
||||
return { id: typeof text === "string" ? `id:${text}` : "created" };
|
||||
});
|
||||
const apiServiceUrl = "https://smba.trafficmanager.net/amer";
|
||||
return {
|
||||
client: { request: vi.fn() },
|
||||
tokenManager: {
|
||||
getBotToken: async () => ({ toString: () => "bot-token" }),
|
||||
getGraphToken: async () => ({ toString: () => "graph-token" }),
|
||||
},
|
||||
process: async () => {},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
});
|
||||
send: async (conversationId: string, activity: unknown) => {
|
||||
opts?.onClientCreated?.("", conversationId);
|
||||
return await createFn(activity);
|
||||
},
|
||||
activitySender: {
|
||||
send: async (
|
||||
activity: unknown,
|
||||
ref: { serviceUrl?: string; conversation?: { id?: string } },
|
||||
) => {
|
||||
opts?.onReference?.(ref);
|
||||
opts?.onClientCreated?.(ref.serviceUrl ?? "", ref.conversation?.id ?? "");
|
||||
return await createFn(activity);
|
||||
},
|
||||
},
|
||||
// Mirror the SDK's `app.reply` which internally calls
|
||||
// `app.send(toThreadedConversationId(channelId, msgId), activity)`. The
|
||||
// test capture sees the threaded conversationId so existing assertions
|
||||
// continue to work after we switched messenger.ts from manual URL
|
||||
// construction to `app.reply`.
|
||||
reply: async (conversationId: string, messageId: string, activity: unknown) => {
|
||||
const threaded = `${conversationId};messageid=${messageId}`;
|
||||
opts?.onClientCreated?.("", threaded);
|
||||
return await createFn(activity);
|
||||
},
|
||||
api: {
|
||||
serviceUrl: apiServiceUrl,
|
||||
conversations: {
|
||||
activities: (conversationId: string) => {
|
||||
opts?.onClientCreated?.(apiServiceUrl, conversationId);
|
||||
return {
|
||||
create: async (activity: unknown) => {
|
||||
opts?.onReference?.({ serviceUrl: apiServiceUrl, ...(activity as object) });
|
||||
return createFn(activity);
|
||||
},
|
||||
update: async (_id: string, activity: unknown) => ({
|
||||
id: (activity as Record<string, unknown>)?.id ?? "updated",
|
||||
}),
|
||||
delete: async () => {},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as MSTeamsApp;
|
||||
}
|
||||
|
||||
describe("msteams messenger", () => {
|
||||
beforeEach(() => {
|
||||
@@ -212,8 +247,6 @@ describe("msteams messenger", () => {
|
||||
}
|
||||
throw new TypeError(REVOCATION_ERROR);
|
||||
},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -223,7 +256,7 @@ describe("msteams messenger", () => {
|
||||
agent: { id: "bot123", name: "Bot" },
|
||||
conversation: { id: "19:abc@thread.tacv2;messageid=deadbeef" },
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.com",
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer/",
|
||||
};
|
||||
|
||||
async function sendAndCaptureRevokeFallbackReference(params: {
|
||||
@@ -232,33 +265,25 @@ describe("msteams messenger", () => {
|
||||
threadId?: string;
|
||||
}) {
|
||||
const proactiveSent: string[] = [];
|
||||
let capturedReference: unknown;
|
||||
let capturedConversationId: string | undefined;
|
||||
const conversationRef: StoredConversationReference = {
|
||||
activityId: params.activityId ?? "activity456",
|
||||
user: { id: "user123", name: "User" },
|
||||
agent: { id: "bot123", name: "Bot" },
|
||||
conversation: params.conversation,
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.com",
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer/",
|
||||
...(params.threadId ? { threadId: params.threadId } : {}),
|
||||
};
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async (_appId, reference, logic) => {
|
||||
capturedReference = reference;
|
||||
await logic({
|
||||
sendActivity: createRecordedSendActivity(proactiveSent),
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
});
|
||||
},
|
||||
process: async () => {},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
};
|
||||
|
||||
await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
adapter,
|
||||
app: createMockApp({
|
||||
createFn: createRecordedSendActivity(proactiveSent),
|
||||
onClientCreated: (_serviceUrl, conversationId) => {
|
||||
capturedConversationId = conversationId;
|
||||
},
|
||||
}),
|
||||
appId: "app123",
|
||||
conversationRef,
|
||||
context: createRevokedThreadContext(),
|
||||
@@ -267,7 +292,11 @@ describe("msteams messenger", () => {
|
||||
|
||||
return {
|
||||
proactiveSent,
|
||||
reference: capturedReference as { conversation?: { id?: string }; activityId?: string },
|
||||
// Reconstruct a reference-like shape from captured conversationId for assertion compat
|
||||
reference: {
|
||||
conversation: capturedConversationId ? { id: capturedConversationId } : undefined,
|
||||
activityId: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -275,14 +304,10 @@ describe("msteams messenger", () => {
|
||||
const sent: string[] = [];
|
||||
const ctx = {
|
||||
sendActivity: createRecordedSendActivity(sent),
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
};
|
||||
const adapter = createNoopAdapter();
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
adapter,
|
||||
app: createMockApp(),
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
context: ctx,
|
||||
@@ -293,40 +318,30 @@ describe("msteams messenger", () => {
|
||||
expect(ids).toEqual(["id:one", "id:two"]);
|
||||
});
|
||||
|
||||
it("sends top-level messages via continueConversation and strips activityId", async () => {
|
||||
const seen: { reference?: unknown; texts: string[] } = { texts: [] };
|
||||
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async (_appId, reference, logic) => {
|
||||
seen.reference = reference;
|
||||
await logic({
|
||||
sendActivity: createRecordedSendActivity(seen.texts),
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
});
|
||||
},
|
||||
process: async () => {},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
};
|
||||
it("sends top-level messages via proactive send context", async () => {
|
||||
const texts: string[] = [];
|
||||
let capturedConversationId: string | undefined;
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "top-level",
|
||||
adapter,
|
||||
app: createMockApp({
|
||||
createFn: async (activity: unknown) => {
|
||||
const text = (activity as Record<string, unknown>)?.text;
|
||||
texts.push(typeof text === "string" ? text : "");
|
||||
return { id: typeof text === "string" ? `id:${text}` : "created" };
|
||||
},
|
||||
onClientCreated: (_serviceUrl, conversationId) => {
|
||||
capturedConversationId = conversationId;
|
||||
},
|
||||
}),
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
messages: [{ text: "hello" }],
|
||||
});
|
||||
|
||||
expect(seen.texts).toEqual(["hello"]);
|
||||
expect(texts).toEqual(["hello"]);
|
||||
expect(ids).toEqual(["id:hello"]);
|
||||
|
||||
const ref = seen.reference as {
|
||||
activityId?: string;
|
||||
conversation?: { id?: string };
|
||||
};
|
||||
expect(ref.activityId).toBeUndefined();
|
||||
expect(requireConversationId(ref)).toBe("19:abc@thread.tacv2");
|
||||
expect(capturedConversationId).toBe("19:abc@thread.tacv2");
|
||||
});
|
||||
|
||||
it("preserves parsed mentions when appending OneDrive fallback file links", async () => {
|
||||
@@ -341,15 +356,11 @@ describe("msteams messenger", () => {
|
||||
sent.push(activity as { text?: string; entities?: unknown[] });
|
||||
return { id: "id:one" };
|
||||
},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
};
|
||||
|
||||
const adapter = createNoopAdapter();
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
adapter,
|
||||
app: createMockApp(),
|
||||
appId: "app123",
|
||||
conversationRef: {
|
||||
...baseRef,
|
||||
@@ -393,14 +404,10 @@ describe("msteams messenger", () => {
|
||||
|
||||
const ctx = {
|
||||
sendActivity: createRecordedSendActivity(attempts, 429),
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
};
|
||||
const adapter = createNoopAdapter();
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
adapter,
|
||||
app: createMockApp(),
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
context: ctx,
|
||||
@@ -438,14 +445,10 @@ describe("msteams messenger", () => {
|
||||
|
||||
const ctx = {
|
||||
sendActivity: createRecordedSendActivity(attempts),
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
};
|
||||
const adapter = createNoopAdapter();
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
adapter,
|
||||
app: createMockApp(),
|
||||
appId: "app123",
|
||||
conversationRef: {
|
||||
...baseRef,
|
||||
@@ -478,36 +481,28 @@ describe("msteams messenger", () => {
|
||||
sendActivity: async () => {
|
||||
throw Object.assign(new Error("bad request"), { statusCode: 400 });
|
||||
},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
};
|
||||
|
||||
const adapter = createNoopAdapter();
|
||||
|
||||
try {
|
||||
await sendMSTeamsMessages({
|
||||
await expect(
|
||||
sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
adapter,
|
||||
app: createMockApp(),
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
context: ctx,
|
||||
messages: [{ text: "one" }],
|
||||
retry: { maxAttempts: 3, baseDelayMs: 0, maxDelayMs: 0 },
|
||||
});
|
||||
throw new Error("expected Teams send client error");
|
||||
} catch (error) {
|
||||
expect((error as { statusCode?: unknown }).statusCode).toBe(400);
|
||||
}
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it("falls back to proactive messaging when thread context is revoked", async () => {
|
||||
const proactiveSent: string[] = [];
|
||||
const ctx = createRevokedThreadContext();
|
||||
const adapter = createFallbackAdapter(proactiveSent);
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
adapter,
|
||||
app: createMockApp({ createFn: createRecordedSendActivity(proactiveSent) }),
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
context: ctx,
|
||||
@@ -523,11 +518,10 @@ describe("msteams messenger", () => {
|
||||
const threadSent: string[] = [];
|
||||
const proactiveSent: string[] = [];
|
||||
const ctx = createRevokedThreadContext({ failAfterAttempt: 2, sent: threadSent });
|
||||
const adapter = createFallbackAdapter(proactiveSent);
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
adapter,
|
||||
app: createMockApp({ createFn: createRecordedSendActivity(proactiveSent) }),
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
context: ctx,
|
||||
@@ -600,7 +594,6 @@ describe("msteams messenger", () => {
|
||||
});
|
||||
|
||||
it("sends no-context thread replies proactively with the channel thread root", async () => {
|
||||
let capturedReference: unknown;
|
||||
const sent: string[] = [];
|
||||
const channelRef: StoredConversationReference = {
|
||||
activityId: "current-msg",
|
||||
@@ -611,41 +604,29 @@ describe("msteams messenger", () => {
|
||||
conversationType: "channel",
|
||||
},
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.com",
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer/",
|
||||
threadId: "thread-root-msg-id",
|
||||
};
|
||||
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async (_appId, reference, logic) => {
|
||||
capturedReference = reference;
|
||||
await logic({
|
||||
sendActivity: createRecordedSendActivity(sent),
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
});
|
||||
},
|
||||
process: async () => {},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
};
|
||||
|
||||
let capturedConversationId: string | undefined;
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
adapter,
|
||||
app: createMockApp({
|
||||
createFn: createRecordedSendActivity(sent),
|
||||
onClientCreated: (_serviceUrl, conversationId) => {
|
||||
capturedConversationId = conversationId;
|
||||
},
|
||||
}),
|
||||
appId: "app123",
|
||||
conversationRef: channelRef,
|
||||
messages: [{ text: "hello" }],
|
||||
});
|
||||
|
||||
expect(sent).toEqual(["hello"]);
|
||||
expect(ids).toEqual(["id:hello"]);
|
||||
const ref = capturedReference as { conversation?: { id?: string }; activityId?: string };
|
||||
expect(ref.conversation?.id).toBe("19:abc@thread.tacv2;messageid=thread-root-msg-id");
|
||||
expect(ref.activityId).toBeUndefined();
|
||||
expect(capturedConversationId).toBe("19:abc@thread.tacv2;messageid=thread-root-msg-id");
|
||||
});
|
||||
|
||||
it("uses activityId for no-context thread replies when threadId is absent", async () => {
|
||||
let capturedReference: unknown;
|
||||
const sent: string[] = [];
|
||||
const channelRef: StoredConversationReference = {
|
||||
activityId: "legacy-activity-id",
|
||||
@@ -656,40 +637,30 @@ describe("msteams messenger", () => {
|
||||
conversationType: "channel",
|
||||
},
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.com",
|
||||
};
|
||||
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async (_appId, reference, logic) => {
|
||||
capturedReference = reference;
|
||||
await logic({
|
||||
sendActivity: createRecordedSendActivity(sent),
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
});
|
||||
},
|
||||
process: async () => {},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer/",
|
||||
};
|
||||
|
||||
let capturedConversationId: string | undefined;
|
||||
await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
adapter,
|
||||
app: createMockApp({
|
||||
createFn: createRecordedSendActivity(sent),
|
||||
onClientCreated: (_serviceUrl, conversationId) => {
|
||||
capturedConversationId = conversationId;
|
||||
},
|
||||
}),
|
||||
appId: "app123",
|
||||
conversationRef: channelRef,
|
||||
messages: [{ text: "hello" }],
|
||||
});
|
||||
|
||||
const ref = capturedReference as { conversation?: { id?: string }; activityId?: string };
|
||||
expect(sent).toEqual(["hello"]);
|
||||
expect(ref.conversation?.id).toBe("19:abc@thread.tacv2;messageid=legacy-activity-id");
|
||||
expect(ref.activityId).toBeUndefined();
|
||||
expect(capturedConversationId).toBe("19:abc@thread.tacv2;messageid=legacy-activity-id");
|
||||
});
|
||||
|
||||
it("does not add thread suffix for top-level replyStyle even with threadId set", async () => {
|
||||
let capturedReference: unknown;
|
||||
const sent: string[] = [];
|
||||
let capturedConversationId: string | undefined;
|
||||
|
||||
const channelRef: StoredConversationReference = {
|
||||
activityId: "current-msg",
|
||||
@@ -700,57 +671,36 @@ describe("msteams messenger", () => {
|
||||
conversationType: "channel",
|
||||
},
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.com",
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer/",
|
||||
threadId: "thread-root-msg-id",
|
||||
};
|
||||
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async (_appId, reference, logic) => {
|
||||
capturedReference = reference;
|
||||
await logic({
|
||||
sendActivity: createRecordedSendActivity(sent),
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
});
|
||||
},
|
||||
process: async () => {},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
};
|
||||
|
||||
await sendMSTeamsMessages({
|
||||
replyStyle: "top-level",
|
||||
adapter,
|
||||
app: createMockApp({
|
||||
createFn: createRecordedSendActivity(sent),
|
||||
onClientCreated: (_serviceUrl, conversationId) => {
|
||||
capturedConversationId = conversationId;
|
||||
},
|
||||
}),
|
||||
appId: "app123",
|
||||
conversationRef: channelRef,
|
||||
messages: [{ text: "hello" }],
|
||||
});
|
||||
|
||||
expect(sent).toEqual(["hello"]);
|
||||
const ref = capturedReference as { conversation?: { id?: string } };
|
||||
// Top-level sends should NOT include thread suffix
|
||||
expect(ref.conversation?.id).toBe("19:abc@thread.tacv2");
|
||||
expect(capturedConversationId).toBe("19:abc@thread.tacv2");
|
||||
});
|
||||
|
||||
it("retries top-level sends on transient (5xx)", async () => {
|
||||
const attempts: string[] = [];
|
||||
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async (_appId, _reference, logic) => {
|
||||
await logic({
|
||||
sendActivity: createRecordedSendActivity(attempts, 503),
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
});
|
||||
},
|
||||
process: async () => {},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
};
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "top-level",
|
||||
adapter,
|
||||
app: createMockApp({
|
||||
createFn: createRecordedSendActivity(attempts, 503),
|
||||
}),
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
messages: [{ text: "hello" }],
|
||||
@@ -761,36 +711,26 @@ describe("msteams messenger", () => {
|
||||
expect(ids).toEqual(["id:hello"]);
|
||||
});
|
||||
|
||||
it("delivers all blocks in a multi-block reply via a single continueConversation call (#29379)", async () => {
|
||||
it("delivers all blocks in a multi-block reply via a single proactive send context (#29379)", async () => {
|
||||
// Regression: multiple text blocks (e.g. text -> tool -> text) must all
|
||||
// reach the user. Previously each deliver() call opened a separate
|
||||
// continueConversation(); Teams silently drops blocks 2+ in that case.
|
||||
// The fix batches all rendered messages into one sendMSTeamsMessages call
|
||||
// so they share a single continueConversation().
|
||||
const conversationCallTexts: string[][] = [];
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async (_appId, _reference, logic) => {
|
||||
const batchTexts: string[] = [];
|
||||
await logic({
|
||||
sendActivity: async (activity: unknown) => {
|
||||
const { text } = activity as { text?: string };
|
||||
batchTexts.push(text ?? "");
|
||||
return { id: `id:${text ?? ""}` };
|
||||
},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
});
|
||||
conversationCallTexts.push(batchTexts);
|
||||
},
|
||||
process: async () => {},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
};
|
||||
// reach the user. The fix batches all rendered messages into one
|
||||
// sendMSTeamsMessages call so they share a single proactive send context.
|
||||
const allTexts: string[] = [];
|
||||
let clientCreations = 0;
|
||||
|
||||
// Three blocks (text + code + text) sent together in one call.
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "top-level",
|
||||
adapter,
|
||||
app: createMockApp({
|
||||
createFn: async (activity: unknown) => {
|
||||
const { text } = activity as { text?: string };
|
||||
allTexts.push(text ?? "");
|
||||
return { id: `id:${text ?? ""}` };
|
||||
},
|
||||
onClientCreated: () => {
|
||||
clientCreations += 1;
|
||||
},
|
||||
}),
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
messages: [
|
||||
@@ -802,9 +742,7 @@ describe("msteams messenger", () => {
|
||||
|
||||
// All three blocks delivered.
|
||||
expect(ids).toHaveLength(3);
|
||||
// All three arrive in a single continueConversation() call, not three.
|
||||
expect(conversationCallTexts).toHaveLength(1);
|
||||
expect(conversationCallTexts[0]).toEqual([
|
||||
expect(allTexts).toEqual([
|
||||
"Let me look that up...",
|
||||
"```\nresult = 42\n```",
|
||||
"The answer is 42.",
|
||||
@@ -819,7 +757,7 @@ describe("msteams messenger", () => {
|
||||
agent: { id: "bot123", name: "Bot" },
|
||||
conversation: { id: "conv123", conversationType: "personal" },
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.com",
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer/",
|
||||
};
|
||||
|
||||
it("adds AI-generated entity to text messages", async () => {
|
||||
@@ -928,34 +866,37 @@ describe("msteams messenger", () => {
|
||||
});
|
||||
|
||||
it("propagates tenantId/aadObjectId through sendMSTeamsMessages proactive path", async () => {
|
||||
let capturedReference:
|
||||
| { tenantId?: string; aadObjectId?: string; user?: { aadObjectId?: string } }
|
||||
| undefined;
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async (_appId, reference, logic) => {
|
||||
capturedReference = reference as typeof capturedReference;
|
||||
await logic({
|
||||
sendActivity: async () => ({ id: "ok" }),
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
});
|
||||
},
|
||||
process: async () => {},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
};
|
||||
const sent: string[] = [];
|
||||
const refs: unknown[] = [];
|
||||
|
||||
await sendMSTeamsMessages({
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "top-level",
|
||||
adapter,
|
||||
app: createMockApp({
|
||||
createFn: createRecordedSendActivity(sent),
|
||||
onReference: (ref) => refs.push(ref),
|
||||
}),
|
||||
appId: "app123",
|
||||
conversationRef: storedWithChannelDataTenant,
|
||||
messages: [{ text: "hello" }],
|
||||
});
|
||||
|
||||
expect(capturedReference?.tenantId).toBe("tenant-abc");
|
||||
expect(capturedReference?.aadObjectId).toBe("aad-user-123");
|
||||
expect(capturedReference?.user?.aadObjectId).toBe("aad-user-123");
|
||||
expect(sent).toEqual(["hello"]);
|
||||
expect(ids).toEqual(["id:hello"]);
|
||||
expect(refs).toEqual([
|
||||
expect.objectContaining({
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer",
|
||||
tenantId: "tenant-abc",
|
||||
aadObjectId: "aad-user-123",
|
||||
conversation: expect.objectContaining({
|
||||
id: "19:abc@thread.tacv2",
|
||||
tenantId: "tenant-abc",
|
||||
}),
|
||||
recipient: expect.objectContaining({ aadObjectId: "aad-user-123" }),
|
||||
}),
|
||||
]);
|
||||
const ref = buildConversationReference(storedWithChannelDataTenant);
|
||||
expect(ref.tenantId).toBe("tenant-abc");
|
||||
expect(ref.aadObjectId).toBe("aad-user-123");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,13 +39,12 @@ const MSTEAMS_MAX_MEDIA_BYTES = 100 * 1024 * 1024;
|
||||
*/
|
||||
const FILE_CONSENT_THRESHOLD_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
type SendContext = {
|
||||
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
|
||||
updateActivity: (activity: object) => Promise<{ id?: string } | void>;
|
||||
deleteActivity: (activityId: string) => Promise<void>;
|
||||
};
|
||||
import type { MSTeamsSdkCloudOptions } from "./cloud.js";
|
||||
import { sendMSTeamsActivityWithReference } from "./sdk-proactive.js";
|
||||
import type { MSTeamsActivityLike } from "./sdk-types.js";
|
||||
import type { MSTeamsApp } from "./sdk.js";
|
||||
|
||||
type MSTeamsConversationReference = {
|
||||
export type MSTeamsConversationReference = {
|
||||
activityId?: string;
|
||||
user?: { id?: string; name?: string; aadObjectId?: string };
|
||||
agent?: { id?: string; name?: string; aadObjectId?: string } | null;
|
||||
@@ -67,22 +66,7 @@ type MSTeamsConversationReference = {
|
||||
aadObjectId?: string;
|
||||
};
|
||||
|
||||
export type MSTeamsAdapter = {
|
||||
continueConversation: (
|
||||
appId: string,
|
||||
reference: MSTeamsConversationReference,
|
||||
logic: (context: SendContext) => Promise<void>,
|
||||
) => Promise<void>;
|
||||
process: (
|
||||
req: unknown,
|
||||
res: unknown,
|
||||
logic: (context: unknown) => Promise<void>,
|
||||
) => Promise<void>;
|
||||
updateActivity: (context: unknown, activity: object) => Promise<void>;
|
||||
deleteActivity: (context: unknown, reference: { activityId?: string }) => Promise<void>;
|
||||
};
|
||||
|
||||
type MSTeamsReplyRenderOptions = {
|
||||
export type MSTeamsReplyRenderOptions = {
|
||||
textChunkLimit: number;
|
||||
chunkText?: boolean;
|
||||
mediaMode?: "split" | "inline";
|
||||
@@ -99,13 +83,13 @@ export type MSTeamsRenderedMessage = {
|
||||
mediaUrl?: string;
|
||||
};
|
||||
|
||||
type MSTeamsSendRetryOptions = {
|
||||
export type MSTeamsSendRetryOptions = {
|
||||
maxAttempts?: number;
|
||||
baseDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
};
|
||||
|
||||
type MSTeamsSendRetryEvent = {
|
||||
export type MSTeamsSendRetryEvent = {
|
||||
messageIndex: number;
|
||||
messageCount: number;
|
||||
nextAttempt: number;
|
||||
@@ -424,10 +408,10 @@ export async function buildActivity(
|
||||
|
||||
export async function sendMSTeamsMessages(params: {
|
||||
replyStyle: MSTeamsReplyStyle;
|
||||
adapter: MSTeamsAdapter;
|
||||
app: MSTeamsApp;
|
||||
appId: string;
|
||||
conversationRef: StoredConversationReference;
|
||||
context?: SendContext;
|
||||
context?: { sendActivity: (activity: MSTeamsActivityLike) => Promise<unknown> };
|
||||
messages: MSTeamsRenderedMessage[];
|
||||
retry?: false | MSTeamsSendRetryOptions;
|
||||
onRetry?: (event: MSTeamsSendRetryEvent) => void;
|
||||
@@ -439,6 +423,7 @@ export async function sendMSTeamsMessages(params: {
|
||||
mediaMaxBytes?: number;
|
||||
/** Enable the Teams feedback loop (thumbs up/down) on sent messages. */
|
||||
feedbackLoopEnabled?: boolean;
|
||||
serviceUrlBoundary?: MSTeamsSdkCloudOptions;
|
||||
}): Promise<string[]> {
|
||||
const messages = params.messages.filter(
|
||||
(m) => (m.text && m.text.trim().length > 0) || m.mediaUrl,
|
||||
@@ -486,7 +471,7 @@ export async function sendMSTeamsMessages(params: {
|
||||
};
|
||||
|
||||
const sendMessageInContext = async (
|
||||
ctx: SendContext,
|
||||
sendFn: (activity: MSTeamsActivityLike) => Promise<unknown>,
|
||||
message: MSTeamsRenderedMessage,
|
||||
messageIndex: number,
|
||||
): Promise<string> => {
|
||||
@@ -511,7 +496,7 @@ export async function sendMSTeamsMessages(params: {
|
||||
delete activity["_pendingUploadId"];
|
||||
}
|
||||
|
||||
return await ctx.sendActivity(activity);
|
||||
return await sendFn(activity);
|
||||
},
|
||||
{
|
||||
messageIndex,
|
||||
@@ -529,13 +514,13 @@ export async function sendMSTeamsMessages(params: {
|
||||
};
|
||||
|
||||
const sendMessageBatchInContext = async (
|
||||
ctx: SendContext,
|
||||
sendFn: (activity: MSTeamsActivityLike) => Promise<unknown>,
|
||||
batch: MSTeamsRenderedMessage[],
|
||||
startIndex: number,
|
||||
): Promise<string[]> => {
|
||||
const messageIds: string[] = [];
|
||||
for (const [idx, message] of batch.entries()) {
|
||||
messageIds.push(await sendMessageInContext(ctx, message, startIndex + idx));
|
||||
messageIds.push(await sendMessageInContext(sendFn, message, startIndex + idx));
|
||||
}
|
||||
return messageIds;
|
||||
};
|
||||
@@ -547,24 +532,12 @@ export async function sendMSTeamsMessages(params: {
|
||||
): Promise<string[]> => {
|
||||
const baseRef = buildConversationReference(params.conversationRef);
|
||||
const isChannel = params.conversationRef.conversation?.conversationType === "channel";
|
||||
// For Teams channels, reconstruct the threaded conversation ID so the
|
||||
// proactive message lands in the correct thread instead of creating a
|
||||
// new top-level post in the channel.
|
||||
const conversationId =
|
||||
isChannel && threadActivityId
|
||||
? `${baseRef.conversation.id};messageid=${threadActivityId}`
|
||||
: baseRef.conversation.id;
|
||||
const proactiveRef: MSTeamsConversationReference = {
|
||||
...baseRef,
|
||||
activityId: undefined,
|
||||
conversation: { ...baseRef.conversation, id: conversationId },
|
||||
};
|
||||
|
||||
const messageIds: string[] = [];
|
||||
await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => {
|
||||
messageIds.push(...(await sendMessageBatchInContext(ctx, batch, startIndex)));
|
||||
const sendFn = (activity: MSTeamsActivityLike) =>
|
||||
sendMSTeamsActivityWithReference(params.app, baseRef, activity, {
|
||||
threadActivityId: isChannel ? threadActivityId : undefined,
|
||||
serviceUrlBoundary: params.serviceUrlBoundary,
|
||||
});
|
||||
return messageIds;
|
||||
return await sendMessageBatchInContext(sendFn, batch, startIndex);
|
||||
};
|
||||
|
||||
// Resolve the thread root message ID for channel thread routing.
|
||||
@@ -577,11 +550,12 @@ export async function sendMSTeamsMessages(params: {
|
||||
if (!ctx) {
|
||||
return await sendProactively(messages, 0, resolvedThreadId);
|
||||
}
|
||||
const sendFn = ctx.sendActivity;
|
||||
const messageIds: string[] = [];
|
||||
for (const [idx, message] of messages.entries()) {
|
||||
const result = await withRevokedProxyFallback({
|
||||
run: async () => ({
|
||||
ids: [await sendMessageInContext(ctx, message, idx)],
|
||||
ids: [await sendMessageInContext(sendFn, message, idx)],
|
||||
fellBack: false,
|
||||
}),
|
||||
onRevoked: async () => {
|
||||
@@ -604,5 +578,11 @@ export async function sendMSTeamsMessages(params: {
|
||||
return messageIds;
|
||||
}
|
||||
|
||||
// replyStyle === "top-level" — explicit "post at the top of the channel"
|
||||
// intent. Do NOT add the thread suffix even when the stored ref has a
|
||||
// threadId; threading on a top-level send would defeat the operator's
|
||||
// explicit choice. Threaded sends route through the `replyStyle === "thread"`
|
||||
// branch above (which already passes resolvedThreadId on the proactive
|
||||
// fallback when the live turn context is revoked, preserving #55198).
|
||||
return await sendProactively(messages, 0);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ function createDeps(): MSTeamsMessageHandlerDeps {
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: { error: vi.fn() } as unknown as RuntimeEnv,
|
||||
appId: "test-app",
|
||||
adapter: {} as MSTeamsMessageHandlerDeps["adapter"],
|
||||
app: {} as MSTeamsMessageHandlerDeps["app"],
|
||||
tokenProvider: {
|
||||
getAccessToken: vi.fn(async () => "token"),
|
||||
},
|
||||
|
||||
@@ -3,15 +3,9 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
|
||||
import {
|
||||
type MSTeamsActivityHandler,
|
||||
type MSTeamsMessageHandlerDeps,
|
||||
registerMSTeamsHandlers,
|
||||
} from "./monitor-handler.js";
|
||||
import {
|
||||
createActivityHandler,
|
||||
createMSTeamsMessageHandlerDeps,
|
||||
} from "./monitor-handler.test-helpers.js";
|
||||
import { runMSTeamsFeedbackInvokeHandler } from "./feedback-invoke.js";
|
||||
import { type MSTeamsMessageHandlerDeps } from "./monitor-handler.js";
|
||||
import { createMSTeamsMessageHandlerDeps } from "./monitor-handler.test-helpers.js";
|
||||
import { setMSTeamsRuntime } from "./runtime.js";
|
||||
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||
|
||||
@@ -145,25 +139,18 @@ async function expectFileMissing(filePath: string) {
|
||||
async function withFeedbackHandler(params: {
|
||||
cfg: OpenClawConfig;
|
||||
context: Parameters<typeof createFeedbackInvokeContext>[0];
|
||||
assertResult: (args: { tmpDir: string; originalRun: ReturnType<typeof vi.fn> }) => Promise<void>;
|
||||
assertResult: (args: { tmpDir: string }) => Promise<void>;
|
||||
}) {
|
||||
const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-"));
|
||||
try {
|
||||
const originalRun = vi.fn(async () => undefined);
|
||||
const handler = registerMSTeamsHandlers(
|
||||
createActivityHandler(originalRun),
|
||||
createDeps({
|
||||
const deps = createDeps({
|
||||
cfg: {
|
||||
...params.cfg,
|
||||
session: { store: tmpDir },
|
||||
},
|
||||
}),
|
||||
) as MSTeamsActivityHandler & {
|
||||
run: NonNullable<MSTeamsActivityHandler["run"]>;
|
||||
};
|
||||
|
||||
await handler.run(createFeedbackInvokeContext(params.context));
|
||||
await params.assertResult({ tmpDir, originalRun });
|
||||
});
|
||||
await runMSTeamsFeedbackInvokeHandler(createFeedbackInvokeContext(params.context), deps);
|
||||
await params.assertResult({ tmpDir });
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -193,7 +180,7 @@ describe("msteams feedback invoke authz", () => {
|
||||
senderName: "Owner",
|
||||
comment: "allowed feedback",
|
||||
},
|
||||
assertResult: async ({ tmpDir, originalRun }) => {
|
||||
assertResult: async ({ tmpDir }) => {
|
||||
const transcript = await readFile(
|
||||
path.join(tmpDir, "msteams_direct_owner-aad.jsonl"),
|
||||
"utf-8",
|
||||
@@ -222,7 +209,6 @@ describe("msteams feedback invoke authz", () => {
|
||||
agentId: "default",
|
||||
conversationId: "a:personal-chat",
|
||||
});
|
||||
expect(originalRun).not.toHaveBeenCalled();
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -252,7 +238,7 @@ describe("msteams feedback invoke authz", () => {
|
||||
senderName: "Owner",
|
||||
comment: "allowed dm feedback",
|
||||
},
|
||||
assertResult: async ({ tmpDir, originalRun }) => {
|
||||
assertResult: async ({ tmpDir }) => {
|
||||
const transcript = await readFile(
|
||||
path.join(tmpDir, "msteams_direct_owner-aad.jsonl"),
|
||||
"utf-8",
|
||||
@@ -281,7 +267,6 @@ describe("msteams feedback invoke authz", () => {
|
||||
agentId: "default",
|
||||
conversationId: "a:personal-chat",
|
||||
});
|
||||
expect(originalRun).not.toHaveBeenCalled();
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -304,10 +289,9 @@ describe("msteams feedback invoke authz", () => {
|
||||
senderName: "Attacker",
|
||||
comment: "blocked feedback",
|
||||
},
|
||||
assertResult: async ({ tmpDir, originalRun }) => {
|
||||
assertResult: async ({ tmpDir }) => {
|
||||
await expectFileMissing(path.join(tmpDir, "msteams_direct_attacker-aad.jsonl"));
|
||||
expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled();
|
||||
expect(originalRun).not.toHaveBeenCalled();
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -315,10 +299,7 @@ describe("msteams feedback invoke authz", () => {
|
||||
it("does not trigger reflection for a group sender outside groupAllowFrom", async () => {
|
||||
const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-"));
|
||||
try {
|
||||
const originalRun = vi.fn(async () => undefined);
|
||||
const handler = registerMSTeamsHandlers(
|
||||
createActivityHandler(originalRun),
|
||||
createDeps({
|
||||
const deps = createDeps({
|
||||
cfg: {
|
||||
session: { store: tmpDir },
|
||||
channels: {
|
||||
@@ -329,12 +310,9 @@ describe("msteams feedback invoke authz", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
}),
|
||||
) as MSTeamsActivityHandler & {
|
||||
run: NonNullable<MSTeamsActivityHandler["run"]>;
|
||||
};
|
||||
});
|
||||
|
||||
await handler.run(
|
||||
await runMSTeamsFeedbackInvokeHandler(
|
||||
createFeedbackInvokeContext({
|
||||
reaction: "dislike",
|
||||
conversationId: "19:group@thread.tacv2;messageid=bot-msg-1",
|
||||
@@ -345,11 +323,11 @@ describe("msteams feedback invoke authz", () => {
|
||||
channelName: "General",
|
||||
comment: "blocked reflection",
|
||||
}),
|
||||
deps,
|
||||
);
|
||||
|
||||
await expectFileMissing(path.join(tmpDir, "msteams_group_19_group_thread_tacv2.jsonl"));
|
||||
expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled();
|
||||
expect(originalRun).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntime } from "../runtime-api.js";
|
||||
import { respondToMSTeamsFileConsentInvoke } from "./file-consent-invoke.js";
|
||||
import { runMSTeamsFileConsentInvokeHandler } from "./file-consent-invoke.js";
|
||||
import { getPendingUploadFs, storePendingUploadFs } from "./pending-uploads-fs.js";
|
||||
import { clearPendingUploads, getPendingUpload, storePendingUpload } from "./pending-uploads.js";
|
||||
import { setMSTeamsRuntime } from "./runtime.js";
|
||||
@@ -201,10 +201,18 @@ describe("msteams file consent invoke authz", () => {
|
||||
action: "accept",
|
||||
});
|
||||
|
||||
await respondToMSTeamsFileConsentInvoke(context, log);
|
||||
await runMSTeamsFileConsentInvokeHandler(context, log);
|
||||
|
||||
// invokeResponse should be sent immediately
|
||||
expectInvokeResponse(sendActivity);
|
||||
// The HTTP 200 InvokeResponse is now written by the SDK from the typed
|
||||
// app.on("file.consent.accept") return value — this handler must not ack
|
||||
// via ctx.sendActivity (which would post an outbound BF activity instead
|
||||
// of an HTTP response on the new SDK).
|
||||
for (const call of sendActivity.mock.calls) {
|
||||
const arg = call[0] as Record<string, unknown> | string;
|
||||
if (typeof arg === "object" && arg !== null && "type" in arg) {
|
||||
expect(arg.type).not.toBe("invokeResponse");
|
||||
}
|
||||
}
|
||||
|
||||
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
|
||||
expectUploadUrlCall("https://upload.example.com/put");
|
||||
@@ -212,15 +220,14 @@ describe("msteams file consent invoke authz", () => {
|
||||
});
|
||||
|
||||
it("calls updateActivity to replace the consent card when consentCardActivityId is set", async () => {
|
||||
const { context, sendActivity, updateActivity } = createConsentInvokeHarness({
|
||||
const { context, updateActivity } = createConsentInvokeHarness({
|
||||
invokeConversationId: "19:victim@thread.v2;messageid=abc123",
|
||||
action: "accept",
|
||||
consentCardActivityId: "consent-card-activity-id-123",
|
||||
});
|
||||
|
||||
await respondToMSTeamsFileConsentInvoke(context, log);
|
||||
await runMSTeamsFileConsentInvokeHandler(context, log);
|
||||
|
||||
expectInvokeResponse(sendActivity);
|
||||
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Should replace the original consent card with the file info card
|
||||
@@ -242,25 +249,15 @@ describe("msteams file consent invoke authz", () => {
|
||||
consentCardActivityId: "consent-card-activity-id-happy",
|
||||
});
|
||||
|
||||
await respondToMSTeamsFileConsentInvoke(context, log);
|
||||
await runMSTeamsFileConsentInvokeHandler(context, log);
|
||||
|
||||
// updateActivity should replace the consent card in-place
|
||||
expect(updateActivity).toHaveBeenCalledTimes(1);
|
||||
|
||||
// sendActivity should only be called once for the invokeResponse, NOT for the file info card
|
||||
expect(sendActivity).toHaveBeenCalledTimes(1);
|
||||
expectInvokeResponse(sendActivity);
|
||||
|
||||
// Explicitly verify no file info card was sent via sendActivity
|
||||
for (const call of sendActivity.mock.calls) {
|
||||
const arg = call[0] as Record<string, unknown>;
|
||||
if (typeof arg === "object" && arg !== null && "attachments" in arg) {
|
||||
const attachments = arg.attachments as Array<{ contentType?: string }>;
|
||||
for (const att of attachments) {
|
||||
expect(att.contentType).not.toBe("application/vnd.microsoft.teams.card.file.info");
|
||||
}
|
||||
}
|
||||
}
|
||||
// sendActivity must NOT be called at all on the happy path now: the SDK
|
||||
// writes the HTTP 200 InvokeResponse on its own, and the file-info card
|
||||
// is delivered via updateActivity.
|
||||
expect(sendActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call updateActivity when no consentCardActivityId is stored", async () => {
|
||||
@@ -270,7 +267,7 @@ describe("msteams file consent invoke authz", () => {
|
||||
// no consentCardActivityId
|
||||
});
|
||||
|
||||
await respondToMSTeamsFileConsentInvoke(context, log);
|
||||
await runMSTeamsFileConsentInvokeHandler(context, log);
|
||||
|
||||
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
|
||||
expect(updateActivity).not.toHaveBeenCalled();
|
||||
@@ -284,7 +281,7 @@ describe("msteams file consent invoke authz", () => {
|
||||
});
|
||||
updateActivity.mockRejectedValueOnce(new Error("Teams API error"));
|
||||
|
||||
await respondToMSTeamsFileConsentInvoke(context, log);
|
||||
await runMSTeamsFileConsentInvokeHandler(context, log);
|
||||
|
||||
// Upload should have completed despite updateActivity failure
|
||||
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
|
||||
@@ -298,11 +295,10 @@ describe("msteams file consent invoke authz", () => {
|
||||
action: "accept",
|
||||
});
|
||||
|
||||
await respondToMSTeamsFileConsentInvoke(context, log);
|
||||
|
||||
// invokeResponse should be sent immediately
|
||||
expectInvokeResponse(sendActivity);
|
||||
await runMSTeamsFileConsentInvokeHandler(context, log);
|
||||
|
||||
// The expiry message is the only sendActivity call now — the HTTP 200
|
||||
// InvokeResponse comes from the SDK's typed-route default.
|
||||
expect(sendActivity).toHaveBeenCalledWith(
|
||||
"The file upload request has expired. Please try sending the file again.",
|
||||
);
|
||||
@@ -317,14 +313,18 @@ describe("msteams file consent invoke authz", () => {
|
||||
action: "decline",
|
||||
});
|
||||
|
||||
await respondToMSTeamsFileConsentInvoke(context, log);
|
||||
await runMSTeamsFileConsentInvokeHandler(context, log);
|
||||
|
||||
// invokeResponse should be sent immediately
|
||||
expectInvokeResponse(sendActivity);
|
||||
// Decline path: nothing is sent (no expiry message, no manual ack — the
|
||||
// SDK ack happens via the typed-route return value).
|
||||
expect(sendActivity).not.toHaveBeenCalled();
|
||||
|
||||
expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
|
||||
expectPendingUploadFields(uploadId);
|
||||
expect(sendActivity).toHaveBeenCalledTimes(1);
|
||||
expect(requirePendingUpload(uploadId)).toMatchObject({
|
||||
conversationId: "19:victim@thread.v2",
|
||||
filename: "secret.txt",
|
||||
contentType: "text/plain",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -396,7 +396,7 @@ describe("msteams file consent invoke FS fallback", () => {
|
||||
updateActivity,
|
||||
} as unknown as MSTeamsTurnContext;
|
||||
|
||||
await respondToMSTeamsFileConsentInvoke(context, log);
|
||||
await runMSTeamsFileConsentInvokeHandler(context, log);
|
||||
|
||||
// The upload should have run using the FS-loaded buffer
|
||||
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
|
||||
@@ -435,7 +435,7 @@ describe("msteams file consent invoke FS fallback", () => {
|
||||
updateActivity,
|
||||
} as unknown as MSTeamsTurnContext;
|
||||
|
||||
await respondToMSTeamsFileConsentInvoke(context, log);
|
||||
await runMSTeamsFileConsentInvokeHandler(context, log);
|
||||
|
||||
expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
|
||||
expect(await getPendingUploadFs(uploadId)).toBeUndefined();
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
type MSTeamsActivityHandler,
|
||||
type MSTeamsMessageHandlerDeps,
|
||||
registerMSTeamsHandlers,
|
||||
} from "./monitor-handler.js";
|
||||
import {
|
||||
createActivityHandler as baseCreateActivityHandler,
|
||||
createMSTeamsMessageHandlerDeps,
|
||||
installMSTeamsTestRuntime,
|
||||
} from "./monitor-handler.test-helpers.js";
|
||||
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMSTeamsSsoTokenStoreMemory } from "./sso-token-store.js";
|
||||
import {
|
||||
type MSTeamsSsoFetch,
|
||||
@@ -20,19 +8,6 @@ import {
|
||||
parseSigninVerifyStateValue,
|
||||
} from "./sso.js";
|
||||
|
||||
function createActivityHandler() {
|
||||
const run = vi.fn(async () => undefined);
|
||||
const handler = baseCreateActivityHandler(run);
|
||||
return { handler, run };
|
||||
}
|
||||
|
||||
function createDepsWithoutSso(
|
||||
overrides: Partial<MSTeamsMessageHandlerDeps> = {},
|
||||
): MSTeamsMessageHandlerDeps {
|
||||
const base = createMSTeamsMessageHandlerDeps();
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
function createSsoDeps(params: { fetchImpl: MSTeamsSsoFetch }) {
|
||||
const tokenStore = createMSTeamsSsoTokenStoreMemory();
|
||||
const tokenProvider = {
|
||||
@@ -50,70 +25,6 @@ function createSsoDeps(params: { fetchImpl: MSTeamsSsoFetch }) {
|
||||
};
|
||||
}
|
||||
|
||||
function createRegisteredSsoHandler(sso: MSTeamsMessageHandlerDeps["sso"]) {
|
||||
const deps = createDepsWithoutSso({ sso });
|
||||
const { handler } = createActivityHandler();
|
||||
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
|
||||
run: NonNullable<MSTeamsActivityHandler["run"]>;
|
||||
};
|
||||
return { deps, registered };
|
||||
}
|
||||
|
||||
function createSigninInvokeContext(params: {
|
||||
name: "signin/tokenExchange" | "signin/verifyState";
|
||||
value: unknown;
|
||||
userAadId?: string;
|
||||
userBfId?: string;
|
||||
conversationId?: string;
|
||||
conversationType?: "personal" | "groupChat" | "channel";
|
||||
teamId?: string;
|
||||
channelName?: string;
|
||||
}): MSTeamsTurnContext & { sendActivity: ReturnType<typeof vi.fn> } {
|
||||
const conversationType = params.conversationType ?? "personal";
|
||||
const conversationId =
|
||||
params.conversationId ??
|
||||
(conversationType === "personal"
|
||||
? "19:personal-chat"
|
||||
: conversationType === "channel"
|
||||
? "19:channel@thread.tacv2"
|
||||
: "19:group@thread.tacv2");
|
||||
|
||||
return {
|
||||
activity: {
|
||||
id: "invoke-1",
|
||||
type: "invoke",
|
||||
name: params.name,
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.test",
|
||||
from: {
|
||||
id: params.userBfId ?? "bf-user",
|
||||
aadObjectId: params.userAadId ?? "aad-user-guid",
|
||||
name: "Test User",
|
||||
},
|
||||
recipient: { id: "bot-id", name: "Bot" },
|
||||
conversation: {
|
||||
id: conversationId,
|
||||
conversationType,
|
||||
tenantId: params.teamId ? "tenant-1" : undefined,
|
||||
},
|
||||
channelData: params.teamId
|
||||
? {
|
||||
team: { id: params.teamId, name: "Team 1" },
|
||||
channel: params.channelName ? { name: params.channelName } : undefined,
|
||||
}
|
||||
: {},
|
||||
attachments: [],
|
||||
value: params.value,
|
||||
},
|
||||
sendActivity: vi.fn(async () => ({ id: "ack-id" })),
|
||||
sendActivities: vi.fn(async () => []),
|
||||
updateActivity: vi.fn(async () => ({ id: "update" })),
|
||||
deleteActivity: vi.fn(async () => {}),
|
||||
} as unknown as MSTeamsTurnContext & {
|
||||
sendActivity: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeFetch(handlers: Array<(url: string, init?: unknown) => unknown>) {
|
||||
const calls: Array<{ url: string; init?: unknown }> = [];
|
||||
const fetchImpl: MSTeamsSsoFetch = async (url, init) => {
|
||||
@@ -137,104 +48,6 @@ function createFakeFetch(handlers: Array<(url: string, init?: unknown) => unknow
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
function expectInvokeResponse(sendActivity: ReturnType<typeof vi.fn>, status?: number): void {
|
||||
const activity = sendActivity.mock.calls.find(([arg]) => {
|
||||
return (
|
||||
typeof arg === "object" &&
|
||||
arg !== null &&
|
||||
(arg as { type?: unknown }).type === "invokeResponse"
|
||||
);
|
||||
})?.[0] as { value?: { status?: unknown } } | undefined;
|
||||
|
||||
if (!activity) {
|
||||
throw new Error("Expected invokeResponse activity");
|
||||
}
|
||||
if (status !== undefined) {
|
||||
expect(activity.value?.status).toBe(status);
|
||||
}
|
||||
}
|
||||
|
||||
function expectLogFields(logFn: unknown, message: string, fields: Record<string, unknown>): void {
|
||||
const calls = (logFn as { mock?: { calls?: Array<[unknown, unknown?]> } }).mock?.calls;
|
||||
if (!calls) {
|
||||
throw new Error("Expected log mock calls");
|
||||
}
|
||||
const call = calls.find(([text]) => text === message);
|
||||
if (!call) {
|
||||
throw new Error(`Expected log message: ${message}`);
|
||||
}
|
||||
const meta = call[1] as Record<string, unknown> | undefined;
|
||||
if (!meta) {
|
||||
throw new Error(`Expected log metadata for: ${message}`);
|
||||
}
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
expect(meta[key]).toEqual(value);
|
||||
}
|
||||
}
|
||||
|
||||
function createBlockedSigninScenarios() {
|
||||
return [
|
||||
{
|
||||
name: "DM sender outside allowlist",
|
||||
cfg: {
|
||||
channels: {
|
||||
msteams: {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["owner-aad"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
context: {
|
||||
userAadId: "blocked-dm-aad",
|
||||
},
|
||||
expectedDropLog: "dropping signin invoke (dm sender not allowlisted)",
|
||||
},
|
||||
{
|
||||
name: "channel outside route allowlist",
|
||||
cfg: {
|
||||
channels: {
|
||||
msteams: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["blocked-channel-aad"],
|
||||
teams: {
|
||||
"team-allowlisted": {
|
||||
channels: {
|
||||
"19:allowlisted@thread.tacv2": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
context: {
|
||||
userAadId: "blocked-channel-aad",
|
||||
conversationType: "channel" as const,
|
||||
conversationId: "19:blocked-channel@thread.tacv2",
|
||||
teamId: "team-blocked",
|
||||
channelName: "General",
|
||||
},
|
||||
expectedDropLog: "dropping signin invoke (not in team/channel allowlist)",
|
||||
},
|
||||
{
|
||||
name: "group sender outside group allowlist",
|
||||
cfg: {
|
||||
channels: {
|
||||
msteams: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["owner-aad"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
context: {
|
||||
userAadId: "blocked-group-aad",
|
||||
conversationType: "groupChat" as const,
|
||||
conversationId: "19:group-chat@thread.v2",
|
||||
},
|
||||
expectedDropLog: "dropping signin invoke (group sender not allowlisted)",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe("msteams signin invoke value parsers", () => {
|
||||
it("parses signin/tokenExchange values", () => {
|
||||
expect(
|
||||
@@ -400,176 +213,3 @@ describe("handleSigninVerifyStateInvoke", () => {
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("msteams signin invoke handler registration", () => {
|
||||
beforeAll(() => {
|
||||
installMSTeamsTestRuntime();
|
||||
});
|
||||
|
||||
const blockedSigninScenarios = createBlockedSigninScenarios();
|
||||
const invokeVariants = [
|
||||
{
|
||||
name: "signin/tokenExchange" as const,
|
||||
value: { id: "x", connectionName: "GraphConnection", token: "exchangeable" },
|
||||
},
|
||||
{
|
||||
name: "signin/verifyState" as const,
|
||||
value: { state: "112233" },
|
||||
},
|
||||
];
|
||||
|
||||
it("acks signin invokes even when sso is not configured", async () => {
|
||||
const deps = createDepsWithoutSso();
|
||||
const { handler, run } = createActivityHandler();
|
||||
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
|
||||
run: NonNullable<MSTeamsActivityHandler["run"]>;
|
||||
};
|
||||
|
||||
const ctx = createSigninInvokeContext({
|
||||
name: "signin/tokenExchange",
|
||||
value: { id: "x", connectionName: "Graph", token: "exchangeable" },
|
||||
});
|
||||
|
||||
await registered.run(ctx);
|
||||
|
||||
expectInvokeResponse(ctx.sendActivity, 200);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expectLogFields(deps.log.debug, "signin invoke received but msteams.sso is not configured", {
|
||||
name: "signin/tokenExchange",
|
||||
});
|
||||
});
|
||||
|
||||
for (const invoke of invokeVariants) {
|
||||
for (const scenario of blockedSigninScenarios) {
|
||||
it(`does not process ${invoke.name} for ${scenario.name}`, async () => {
|
||||
const { fetchImpl, calls } = createFakeFetch([
|
||||
() => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
channelId: "msteams",
|
||||
connectionName: "GraphConnection",
|
||||
token: "delegated-graph-token",
|
||||
expiration: "2030-01-01T00:00:00Z",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const { sso, tokenStore } = createSsoDeps({ fetchImpl });
|
||||
const deps = createDepsWithoutSso({ cfg: scenario.cfg, sso });
|
||||
const { handler } = createActivityHandler();
|
||||
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
|
||||
run: NonNullable<MSTeamsActivityHandler["run"]>;
|
||||
};
|
||||
|
||||
const ctx = createSigninInvokeContext({
|
||||
name: invoke.name,
|
||||
value: invoke.value,
|
||||
...scenario.context,
|
||||
});
|
||||
|
||||
await registered.run(ctx);
|
||||
|
||||
expectInvokeResponse(ctx.sendActivity, 200);
|
||||
expect(calls).toHaveLength(0);
|
||||
const stored = await tokenStore.get({
|
||||
connectionName: "GraphConnection",
|
||||
userId: scenario.context.userAadId ?? "aad-user-guid",
|
||||
});
|
||||
expect(stored).toBeNull();
|
||||
expectLogFields(deps.log.debug, scenario.expectedDropLog, { name: invoke.name });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("invokes the token exchange handler when sso is configured", async () => {
|
||||
const { fetchImpl } = createFakeFetch([
|
||||
() => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
channelId: "msteams",
|
||||
connectionName: "GraphConnection",
|
||||
token: "delegated-graph-token",
|
||||
expiration: "2030-01-01T00:00:00Z",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const { sso, tokenStore } = createSsoDeps({ fetchImpl });
|
||||
const { deps, registered } = createRegisteredSsoHandler(sso);
|
||||
|
||||
const ctx = createSigninInvokeContext({
|
||||
name: "signin/tokenExchange",
|
||||
value: { id: "x", connectionName: "GraphConnection", token: "exchangeable" },
|
||||
});
|
||||
|
||||
await registered.run(ctx);
|
||||
|
||||
expectInvokeResponse(ctx.sendActivity, 200);
|
||||
expectLogFields(deps.log.info, "msteams sso token exchanged", {
|
||||
userId: "aad-user-guid",
|
||||
hasExpiry: true,
|
||||
});
|
||||
const stored = await tokenStore.get({
|
||||
connectionName: "GraphConnection",
|
||||
userId: "aad-user-guid",
|
||||
});
|
||||
expect(stored?.token).toBe("delegated-graph-token");
|
||||
});
|
||||
|
||||
it("logs an error when the token exchange fails", async () => {
|
||||
const { fetchImpl } = createFakeFetch([
|
||||
() => ({ ok: false, status: 400, body: "bad request" }),
|
||||
]);
|
||||
const { sso } = createSsoDeps({ fetchImpl });
|
||||
const { deps, registered } = createRegisteredSsoHandler(sso);
|
||||
|
||||
const ctx = createSigninInvokeContext({
|
||||
name: "signin/tokenExchange",
|
||||
value: { id: "x", connectionName: "GraphConnection", token: "exchangeable" },
|
||||
});
|
||||
|
||||
await registered.run(ctx);
|
||||
|
||||
expectInvokeResponse(ctx.sendActivity);
|
||||
expectLogFields(deps.log.error, "msteams sso token exchange failed", {
|
||||
code: "unexpected_response",
|
||||
status: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles signin/verifyState via the magic-code flow", async () => {
|
||||
const { fetchImpl } = createFakeFetch([
|
||||
() => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
channelId: "msteams",
|
||||
connectionName: "GraphConnection",
|
||||
token: "delegated-token-3",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const { sso, tokenStore } = createSsoDeps({ fetchImpl });
|
||||
const deps = createDepsWithoutSso({ sso });
|
||||
const { handler } = createActivityHandler();
|
||||
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
|
||||
run: NonNullable<MSTeamsActivityHandler["run"]>;
|
||||
};
|
||||
|
||||
const ctx = createSigninInvokeContext({
|
||||
name: "signin/verifyState",
|
||||
value: { state: "112233" },
|
||||
});
|
||||
|
||||
await registered.run(ctx);
|
||||
|
||||
expectLogFields(deps.log.info, "msteams sso verifyState succeeded", {
|
||||
userId: "aad-user-guid",
|
||||
});
|
||||
const stored = await tokenStore.get({
|
||||
connectionName: "GraphConnection",
|
||||
userId: "aad-user-guid",
|
||||
});
|
||||
expect(stored?.token).toBe("delegated-token-3");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,10 @@ import type { PreparedInboundReply } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
|
||||
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
||||
import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import type { MSTeamsActivityHandler, MSTeamsMessageHandlerDeps } from "./monitor-handler.js";
|
||||
import type { MSTeamsPollStore } from "./polls.js";
|
||||
import { setMSTeamsRuntime } from "./runtime.js";
|
||||
import type { MSTeamsApp } from "./sdk.js";
|
||||
|
||||
type RuntimeRoutePeer = { peer: { kind: string; id: string } };
|
||||
|
||||
@@ -154,12 +154,17 @@ export function createMSTeamsMessageHandlerDeps(params?: {
|
||||
cfg?: OpenClawConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
}): MSTeamsMessageHandlerDeps {
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async () => {},
|
||||
process: async () => {},
|
||||
updateActivity: async () => {},
|
||||
deleteActivity: async () => {},
|
||||
};
|
||||
const app = {
|
||||
tokenManager: {
|
||||
getBotToken: async () => ({ toString: () => "bot-token" }),
|
||||
getGraphToken: async () => ({ toString: () => "graph-token" }),
|
||||
},
|
||||
api: {},
|
||||
graph: {},
|
||||
send: async () => ({ id: "sent" }),
|
||||
initialize: async () => {},
|
||||
on: () => {},
|
||||
} as unknown as MSTeamsApp;
|
||||
const conversationStore: MSTeamsConversationStore = {
|
||||
upsert: async () => {},
|
||||
get: async () => null,
|
||||
@@ -178,7 +183,7 @@ export function createMSTeamsMessageHandlerDeps(params?: {
|
||||
cfg: params?.cfg ?? {},
|
||||
runtime: (params?.runtime ?? { error: vi.fn() }) as RuntimeEnv,
|
||||
appId: "test-app-id",
|
||||
adapter,
|
||||
app,
|
||||
tokenProvider: {
|
||||
getAccessToken: async () => "token",
|
||||
},
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
import path from "node:path";
|
||||
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
||||
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { tryNormalizeBotFrameworkServiceUrl } from "./bot-framework-service-url.js";
|
||||
import { formatUnknownError } from "./errors.js";
|
||||
import { buildFeedbackEvent, runFeedbackReflection } from "./feedback-reflection.js";
|
||||
import { respondToMSTeamsFileConsentInvoke } from "./file-consent-invoke.js";
|
||||
import { extractMSTeamsConversationMessageId, normalizeMSTeamsConversationId } from "./inbound.js";
|
||||
import { resolveMSTeamsSenderAccess } from "./monitor-handler/access.js";
|
||||
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
|
||||
import { createMSTeamsReactionHandler } from "./monitor-handler/reaction-handler.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||
import {
|
||||
handleSigninTokenExchangeInvoke,
|
||||
handleSigninVerifyStateInvoke,
|
||||
parseSigninTokenExchangeValue,
|
||||
parseSigninVerifyStateValue,
|
||||
} from "./sso.js";
|
||||
import { buildGroupWelcomeText, buildWelcomeCard } from "./welcome-card.js";
|
||||
export type { MSTeamsMessageHandlerDeps } from "./monitor-handler.types.js";
|
||||
import type { MSTeamsMessageHandlerDeps } from "./monitor-handler.types.js";
|
||||
@@ -110,7 +96,7 @@ async function isInvokeAuthorized(params: {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function isFeedbackInvokeAuthorized(
|
||||
export async function isFeedbackInvokeAuthorized(
|
||||
context: MSTeamsTurnContext,
|
||||
deps: MSTeamsMessageHandlerDeps,
|
||||
): Promise<boolean> {
|
||||
@@ -125,7 +111,7 @@ async function isFeedbackInvokeAuthorized(
|
||||
});
|
||||
}
|
||||
|
||||
async function isSigninInvokeAuthorized(
|
||||
export async function isSigninInvokeAuthorized(
|
||||
context: MSTeamsTurnContext,
|
||||
deps: MSTeamsMessageHandlerDeps,
|
||||
): Promise<boolean> {
|
||||
@@ -141,183 +127,20 @@ async function isSigninInvokeAuthorized(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and handle feedback invoke activities (thumbs up/down).
|
||||
* Returns true if the activity was a feedback invoke, false otherwise.
|
||||
*/
|
||||
async function handleFeedbackInvoke(
|
||||
export async function isCardActionInvokeAuthorized(
|
||||
context: MSTeamsTurnContext,
|
||||
deps: MSTeamsMessageHandlerDeps,
|
||||
): Promise<boolean> {
|
||||
const activity = context.activity;
|
||||
const value = activity.value as
|
||||
| {
|
||||
actionName?: string;
|
||||
actionValue?: { reaction?: string; feedback?: string };
|
||||
replyToId?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Teams feedback invoke format: actionName="feedback", actionValue.reaction="like"|"dislike"
|
||||
if (value.actionName !== "feedback") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const reaction = value.actionValue?.reaction;
|
||||
if (reaction !== "like" && reaction !== "dislike") {
|
||||
deps.log.debug?.("ignoring feedback with unknown reaction", { reaction });
|
||||
return false;
|
||||
}
|
||||
|
||||
const msteamsCfg = deps.cfg.channels?.msteams;
|
||||
if (msteamsCfg?.feedbackEnabled === false) {
|
||||
deps.log.debug?.("feedback handling disabled");
|
||||
return true; // Still consume the invoke
|
||||
}
|
||||
|
||||
if (!(await isFeedbackInvokeAuthorized(context, deps))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Extract user comment from the nested JSON string
|
||||
let userComment: string | undefined;
|
||||
if (value.actionValue?.feedback) {
|
||||
try {
|
||||
const parsed = JSON.parse(value.actionValue.feedback) as { feedbackText?: string };
|
||||
userComment = parsed.feedbackText || undefined;
|
||||
} catch {
|
||||
// Best effort — feedback text is optional
|
||||
}
|
||||
}
|
||||
|
||||
// Strip ;messageid=... suffix to match the normalized ID used by the message handler.
|
||||
const rawConversationId = activity.conversation?.id ?? "unknown";
|
||||
const conversationId = normalizeMSTeamsConversationId(rawConversationId);
|
||||
const senderId = activity.from?.aadObjectId ?? activity.from?.id ?? "unknown";
|
||||
const messageId = value.replyToId ?? activity.replyToId ?? "unknown";
|
||||
const isNegative = reaction === "dislike";
|
||||
|
||||
// Route feedback using the same chat-type logic as normal messages
|
||||
// so session keys, agent IDs, and transcript paths match.
|
||||
const convType = normalizeOptionalLowercaseString(activity.conversation?.conversationType);
|
||||
const isDirectMessage = convType === "personal" || (!convType && !activity.conversation?.isGroup);
|
||||
const isChannel = convType === "channel";
|
||||
|
||||
const core = getMSTeamsRuntime();
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg: deps.cfg,
|
||||
channel: "msteams",
|
||||
peer: {
|
||||
kind: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
|
||||
id: isDirectMessage ? senderId : conversationId,
|
||||
return isInvokeAuthorized({
|
||||
context,
|
||||
deps,
|
||||
deniedLogs: {
|
||||
dm: "dropping card action invoke (dm sender not allowlisted)",
|
||||
channel: "dropping card action invoke (not in team/channel allowlist)",
|
||||
group: "dropping card action invoke (group sender not allowlisted)",
|
||||
},
|
||||
includeInvokeName: true,
|
||||
});
|
||||
|
||||
// Match the thread-aware session key used by the message handler so feedback
|
||||
// events land in the correct per-thread transcript. For channel threads, the
|
||||
// thread root ID comes from the ;messageid= suffix on the conversation ID or
|
||||
// from activity.replyToId.
|
||||
const feedbackThreadId = isChannel
|
||||
? (extractMSTeamsConversationMessageId(rawConversationId) ?? activity.replyToId ?? undefined)
|
||||
: undefined;
|
||||
if (feedbackThreadId) {
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey: route.sessionKey,
|
||||
threadId: feedbackThreadId,
|
||||
parentSessionKey: route.sessionKey,
|
||||
});
|
||||
route.sessionKey = threadKeys.sessionKey;
|
||||
}
|
||||
|
||||
// Log feedback event to session JSONL
|
||||
const feedbackEvent = buildFeedbackEvent({
|
||||
messageId,
|
||||
value: isNegative ? "negative" : "positive",
|
||||
comment: userComment,
|
||||
sessionKey: route.sessionKey,
|
||||
agentId: route.agentId,
|
||||
conversationId,
|
||||
});
|
||||
|
||||
deps.log.info("received feedback", {
|
||||
value: feedbackEvent.value,
|
||||
messageId,
|
||||
conversationId,
|
||||
hasComment: Boolean(userComment),
|
||||
});
|
||||
|
||||
// Write feedback event to session transcript
|
||||
try {
|
||||
const storePath = core.channel.session.resolveStorePath(deps.cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const safeKey = route.sessionKey.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
const transcriptFile = path.join(storePath, `${safeKey}.jsonl`);
|
||||
await appendRegularFile({
|
||||
filePath: transcriptFile,
|
||||
content: `${JSON.stringify(feedbackEvent)}\n`,
|
||||
rejectSymlinkParents: true,
|
||||
}).catch(() => {
|
||||
// Best effort — transcript dir may not exist yet
|
||||
});
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
|
||||
// Build conversation reference for proactive messages (ack + reflection follow-up)
|
||||
const serviceUrl = tryNormalizeBotFrameworkServiceUrl(activity.serviceUrl);
|
||||
const conversationRef = {
|
||||
activityId: activity.id,
|
||||
user: {
|
||||
id: activity.from?.id,
|
||||
name: activity.from?.name,
|
||||
aadObjectId: activity.from?.aadObjectId,
|
||||
},
|
||||
agent: activity.recipient
|
||||
? { id: activity.recipient.id, name: activity.recipient.name }
|
||||
: undefined,
|
||||
bot: activity.recipient
|
||||
? { id: activity.recipient.id, name: activity.recipient.name }
|
||||
: undefined,
|
||||
conversation: {
|
||||
id: conversationId,
|
||||
conversationType: activity.conversation?.conversationType,
|
||||
tenantId: activity.conversation?.tenantId,
|
||||
},
|
||||
channelId: activity.channelId ?? "msteams",
|
||||
...(serviceUrl ? { serviceUrl } : {}),
|
||||
locale: activity.locale,
|
||||
};
|
||||
|
||||
// For negative feedback, trigger background reflection (fire-and-forget).
|
||||
// No ack message — the reflection follow-up serves as the acknowledgement.
|
||||
// Sending anything during the invoke handler causes "unable to reach app" errors.
|
||||
if (isNegative && msteamsCfg?.feedbackReflection !== false) {
|
||||
// Note: thumbedDownResponse is not populated here because we don't cache
|
||||
// sent message text. The agent still has full session context for reflection
|
||||
// since the reflection runs in the same session. The user comment (if any)
|
||||
// provides additional signal.
|
||||
runFeedbackReflection({
|
||||
cfg: deps.cfg,
|
||||
adapter: deps.adapter,
|
||||
appId: deps.appId,
|
||||
conversationRef,
|
||||
sessionKey: route.sessionKey,
|
||||
agentId: route.agentId,
|
||||
conversationId,
|
||||
feedbackMessageId: messageId,
|
||||
userComment,
|
||||
log: deps.log,
|
||||
}).catch((err) => {
|
||||
deps.log.error("feedback reflection failed", { error: formatUnknownError(err) });
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
|
||||
@@ -332,23 +155,9 @@ export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
|
||||
if (originalRun) {
|
||||
handler.run = async (context: unknown) => {
|
||||
const ctx = context as MSTeamsTurnContext;
|
||||
// Handle file consent invokes before passing to normal flow
|
||||
if (ctx.activity?.type === "invoke" && ctx.activity?.name === "fileConsent/invoke") {
|
||||
await respondToMSTeamsFileConsentInvoke(ctx, deps.log);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle feedback invokes (thumbs up/down on AI-generated messages).
|
||||
// Just return after handling — the process() handler sends HTTP 200 automatically.
|
||||
// Do NOT call sendActivity with invokeResponse; our custom adapter would POST
|
||||
// a new activity to Bot Framework instead of responding to the HTTP request.
|
||||
if (ctx.activity?.type === "invoke" && ctx.activity?.name === "message/submitAction") {
|
||||
const handled = await handleFeedbackInvoke(ctx, deps);
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Non-poll adaptiveCard/action invokes get dispatched here as text so the
|
||||
// agent can react. Poll votes are intercepted in monitor.ts's
|
||||
// app.on("card.action") handler which returns the InvokeResponse to Teams.
|
||||
if (ctx.activity?.type === "invoke" && ctx.activity?.name === "adaptiveCard/action") {
|
||||
const text = serializeAdaptiveCardActionValue(ctx.activity?.value);
|
||||
if (text) {
|
||||
@@ -360,95 +169,6 @@ export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
|
||||
text,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
deps.log.debug?.("skipping adaptive card action invoke without value payload");
|
||||
}
|
||||
|
||||
// Bot Framework OAuth SSO: Teams sends signin/tokenExchange (with a
|
||||
// Teams-provided exchangeable token) or signin/verifyState (magic
|
||||
// code fallback) after an oauthCard is presented. We must ack with
|
||||
// HTTP 200 and, if configured, exchange the token with the Bot
|
||||
// Framework User Token service and persist it for downstream tools.
|
||||
if (
|
||||
ctx.activity?.type === "invoke" &&
|
||||
(ctx.activity?.name === "signin/tokenExchange" ||
|
||||
ctx.activity?.name === "signin/verifyState")
|
||||
) {
|
||||
// Always ack immediately — silently dropping the invoke causes
|
||||
// the Teams card UI to report "Something went wrong".
|
||||
await ctx.sendActivity({ type: "invokeResponse", value: { status: 200, body: {} } });
|
||||
|
||||
if (!(await isSigninInvokeAuthorized(ctx, deps))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deps.sso) {
|
||||
deps.log.debug?.("signin invoke received but msteams.sso is not configured", {
|
||||
name: ctx.activity.name,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const user = {
|
||||
userId: ctx.activity.from?.aadObjectId ?? ctx.activity.from?.id ?? "",
|
||||
channelId: ctx.activity.channelId ?? "msteams",
|
||||
};
|
||||
|
||||
try {
|
||||
if (ctx.activity.name === "signin/tokenExchange") {
|
||||
const parsed = parseSigninTokenExchangeValue(ctx.activity.value);
|
||||
if (!parsed) {
|
||||
deps.log.debug?.("invalid signin/tokenExchange invoke value");
|
||||
return;
|
||||
}
|
||||
const result = await handleSigninTokenExchangeInvoke({
|
||||
value: parsed,
|
||||
user,
|
||||
deps: deps.sso,
|
||||
});
|
||||
if (result.ok) {
|
||||
deps.log.info("msteams sso token exchanged", {
|
||||
userId: user.userId,
|
||||
hasExpiry: Boolean(result.expiresAt),
|
||||
});
|
||||
} else {
|
||||
deps.log.error("msteams sso token exchange failed", {
|
||||
code: result.code,
|
||||
status: result.status,
|
||||
message: result.message,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// signin/verifyState
|
||||
const parsed = parseSigninVerifyStateValue(ctx.activity.value);
|
||||
if (!parsed) {
|
||||
deps.log.debug?.("invalid signin/verifyState invoke value");
|
||||
return;
|
||||
}
|
||||
const result = await handleSigninVerifyStateInvoke({
|
||||
value: parsed,
|
||||
user,
|
||||
deps: deps.sso,
|
||||
});
|
||||
if (result.ok) {
|
||||
deps.log.info("msteams sso verifyState succeeded", {
|
||||
userId: user.userId,
|
||||
hasExpiry: Boolean(result.expiresAt),
|
||||
});
|
||||
} else {
|
||||
deps.log.error("msteams sso verifyState failed", {
|
||||
code: result.code,
|
||||
status: result.status,
|
||||
message: result.message,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
deps.log.error("msteams sso invoke handler error", {
|
||||
error: formatUnknownError(err),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { type OpenClawConfig, type RuntimeEnv } from "../runtime-api.js";
|
||||
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
||||
import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
|
||||
import type { MSTeamsPollStore } from "./polls.js";
|
||||
import type { MSTeamsApp } from "./sdk.js";
|
||||
import type { MSTeamsSsoDeps } from "./sso.js";
|
||||
|
||||
export type MSTeamsMessageHandlerDeps = {
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
appId: string;
|
||||
adapter: MSTeamsAdapter;
|
||||
app: MSTeamsApp;
|
||||
tokenProvider: {
|
||||
getAccessToken: (scope: string) => Promise<string>;
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ export function createMessageHandlerDeps(
|
||||
cfg,
|
||||
runtime: { error: vi.fn() } as unknown as RuntimeEnv,
|
||||
appId: "test-app",
|
||||
adapter: {} as MSTeamsMessageHandlerDeps["adapter"],
|
||||
app: {} as MSTeamsMessageHandlerDeps["app"],
|
||||
tokenProvider: {
|
||||
getAccessToken: vi.fn(async () => "token"),
|
||||
},
|
||||
|
||||
@@ -182,7 +182,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
cfg,
|
||||
runtime,
|
||||
appId,
|
||||
adapter,
|
||||
app,
|
||||
tokenProvider,
|
||||
textLimit,
|
||||
mediaMaxBytes,
|
||||
@@ -838,7 +838,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
accountId: route.accountId,
|
||||
runtime,
|
||||
log,
|
||||
adapter,
|
||||
app,
|
||||
appId,
|
||||
conversationRef,
|
||||
context,
|
||||
|
||||
@@ -32,7 +32,7 @@ function buildDeps(cfg: OpenClawConfig, _runtime?: PluginRuntime): MSTeamsMessag
|
||||
cfg,
|
||||
runtime: { error: vi.fn() } as unknown as MSTeamsMessageHandlerDeps["runtime"],
|
||||
appId: "test-app",
|
||||
adapter: {} as MSTeamsMessageHandlerDeps["adapter"],
|
||||
app: {} as MSTeamsMessageHandlerDeps["app"],
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
||||
textLimit: 4000,
|
||||
mediaMaxBytes: 1024 * 1024,
|
||||
|
||||
@@ -41,13 +41,16 @@ type RegisterMSTeamsHandlersMock = (
|
||||
deps: MSTeamsMessageHandlerDeps,
|
||||
) => MSTeamsActivityHandler;
|
||||
|
||||
type MockExpressFn = ReturnType<typeof vi.fn>;
|
||||
type MockExpressApp = MockExpressFn & {
|
||||
use: MockExpressFn;
|
||||
post: MockExpressFn;
|
||||
listen: MockExpressFn;
|
||||
};
|
||||
|
||||
const expressControl = vi.hoisted(() => ({
|
||||
mode: { value: "listening" as "listening" | "error" },
|
||||
apps: [] as Array<{
|
||||
use: ReturnType<typeof vi.fn>;
|
||||
post: ReturnType<typeof vi.fn>;
|
||||
listen: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
apps: [] as MockExpressApp[],
|
||||
}));
|
||||
|
||||
const isDangerousNameMatchingEnabled = vi.hoisted(() => vi.fn());
|
||||
@@ -85,10 +88,11 @@ vi.mock("express", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const factory = () => ({
|
||||
use: vi.fn(),
|
||||
post: vi.fn(),
|
||||
listen: vi.fn((_port: number) => {
|
||||
const factory = () => {
|
||||
const app = vi.fn() as MockExpressApp;
|
||||
app.use = vi.fn();
|
||||
app.post = vi.fn();
|
||||
app.listen = vi.fn((_port: number) => {
|
||||
const server = new EventEmitter() as FakeServer;
|
||||
server.setTimeout = vi.fn((_msecs: number) => server);
|
||||
server.requestTimeout = 0;
|
||||
@@ -107,8 +111,9 @@ vi.mock("express", () => {
|
||||
server.emit("listening");
|
||||
});
|
||||
return server;
|
||||
}),
|
||||
});
|
||||
return app;
|
||||
};
|
||||
|
||||
const wrappedFactory = () => {
|
||||
const app = factory();
|
||||
@@ -125,29 +130,45 @@ vi.mock("express", () => {
|
||||
const registerMSTeamsHandlers = vi.hoisted(() =>
|
||||
vi.fn<RegisterMSTeamsHandlersMock>((handler) => handler),
|
||||
);
|
||||
const createMSTeamsAdapter = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
process: vi.fn(async () => {}),
|
||||
})),
|
||||
);
|
||||
const jwtValidate = vi.hoisted(() => vi.fn().mockResolvedValue(true));
|
||||
const isSigninInvokeAuthorized = vi.hoisted(() => vi.fn(async () => true));
|
||||
const isCardActionInvokeAuthorized = vi.hoisted(() => vi.fn(async () => true));
|
||||
const runMSTeamsFileConsentInvokeHandler = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const loadMSTeamsSdkWithAuth = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
sdk: {
|
||||
ActivityHandler: function ActivityHandler() {},
|
||||
MsalTokenProvider: function MsalTokenProvider() {},
|
||||
authorizeJWT:
|
||||
() => (_req: unknown, _res: unknown, next: ((err?: unknown) => void) | undefined) =>
|
||||
next?.(),
|
||||
vi.fn(async (_creds?: unknown, _options?: unknown) => ({
|
||||
app: {
|
||||
on: vi.fn(),
|
||||
event: vi.fn(),
|
||||
onTokenExchange: vi.fn(async () => ({ status: 200 })),
|
||||
onVerifyState: vi.fn(async () => ({ status: 200 })),
|
||||
initialize: vi.fn(async () => {}),
|
||||
tokenManager: {
|
||||
getBotToken: vi.fn(async () => ({ toString: (): string => "bot-token" })),
|
||||
getGraphToken: vi.fn(async () => ({ toString: (): string => "graph-token" })),
|
||||
},
|
||||
},
|
||||
authConfig: {},
|
||||
})),
|
||||
);
|
||||
|
||||
const ssoTokenStore = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => null),
|
||||
save: vi.fn(async () => {}),
|
||||
remove: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
vi.mock("@microsoft/teams.apps", () => ({
|
||||
ExpressAdapter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./monitor-handler.js", () => ({
|
||||
isCardActionInvokeAuthorized,
|
||||
isSigninInvokeAuthorized,
|
||||
registerMSTeamsHandlers,
|
||||
}));
|
||||
|
||||
vi.mock("./file-consent-invoke.js", () => ({
|
||||
runMSTeamsFileConsentInvokeHandler,
|
||||
}));
|
||||
|
||||
const resolveAllowlistMocks = vi.hoisted(() => ({
|
||||
resolveMSTeamsChannelAllowlist: vi.fn<ResolveMSTeamsChannelAllowlistMock>(async () => []),
|
||||
resolveMSTeamsUserAllowlist: vi.fn<ResolveMSTeamsUserAllowlistMock>(async () => []),
|
||||
@@ -159,13 +180,15 @@ vi.mock("./resolve-allowlist.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./sdk.js", () => ({
|
||||
createMSTeamsAdapter: () => createMSTeamsAdapter(),
|
||||
loadMSTeamsSdkWithAuth: () => loadMSTeamsSdkWithAuth(),
|
||||
loadMSTeamsSdkWithAuth: (creds?: unknown, options?: unknown) =>
|
||||
loadMSTeamsSdkWithAuth(creds, options),
|
||||
createMSTeamsTokenProvider: () => ({
|
||||
getAccessToken: vi.fn().mockResolvedValue("mock-token"),
|
||||
}),
|
||||
createBotFrameworkJwtValidator: vi.fn().mockResolvedValue({
|
||||
validate: jwtValidate,
|
||||
createMSTeamsExpressAdapter: vi.fn().mockResolvedValue({
|
||||
registerRoute: vi.fn(),
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -175,6 +198,7 @@ vi.mock("./runtime.js", () => ({
|
||||
getChildLogger: () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
},
|
||||
@@ -186,6 +210,10 @@ vi.mock("./runtime.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./sso-token-store.js", () => ({
|
||||
createMSTeamsSsoTokenStoreFs: () => ssoTokenStore,
|
||||
}));
|
||||
|
||||
import { monitorMSTeamsProvider } from "./monitor.js";
|
||||
|
||||
function createConfig(port: number): OpenClawConfig {
|
||||
@@ -236,19 +264,10 @@ function createStores() {
|
||||
};
|
||||
}
|
||||
|
||||
function readMockCallArg(mock: ReturnType<typeof vi.fn>, callIndex: number, argIndex: number) {
|
||||
const call = mock.mock.calls[callIndex];
|
||||
if (!call) {
|
||||
throw new Error(`expected mock call #${callIndex + 1}`);
|
||||
}
|
||||
if (argIndex >= call.length) {
|
||||
throw new Error(`expected mock call #${callIndex + 1} argument #${argIndex + 1}`);
|
||||
}
|
||||
return call[argIndex];
|
||||
}
|
||||
|
||||
function requireRegisteredMSTeamsConfig(): OpenClawConfig {
|
||||
const registered = readMockCallArg(registerMSTeamsHandlers, 0, 1) as { cfg?: OpenClawConfig };
|
||||
const registered = registerMSTeamsHandlers.mock.calls[0]?.[1] as
|
||||
| { cfg?: OpenClawConfig }
|
||||
| undefined;
|
||||
if (!registered?.cfg) {
|
||||
throw new Error("expected registered MSTeams handler config");
|
||||
}
|
||||
@@ -263,7 +282,12 @@ describe("monitorMSTeamsProvider lifecycle", () => {
|
||||
isDangerousNameMatchingEnabled.mockReset().mockReturnValue(false);
|
||||
resolveAllowlistMocks.resolveMSTeamsChannelAllowlist.mockReset().mockResolvedValue([]);
|
||||
resolveAllowlistMocks.resolveMSTeamsUserAllowlist.mockReset().mockResolvedValue([]);
|
||||
jwtValidate.mockReset().mockResolvedValue(true);
|
||||
isSigninInvokeAuthorized.mockReset().mockResolvedValue(true);
|
||||
isCardActionInvokeAuthorized.mockReset().mockResolvedValue(true);
|
||||
runMSTeamsFileConsentInvokeHandler.mockReset().mockResolvedValue(undefined);
|
||||
ssoTokenStore.get.mockClear();
|
||||
ssoTokenStore.save.mockClear();
|
||||
ssoTokenStore.remove.mockClear();
|
||||
});
|
||||
|
||||
it("stays active until aborted", async () => {
|
||||
@@ -304,7 +328,7 @@ describe("monitorMSTeamsProvider lifecycle", () => {
|
||||
).rejects.toThrow(/EADDRINUSE/);
|
||||
});
|
||||
|
||||
it("parses bounded JSON after the Bearer gate and binds serviceUrl during JWT validation", async () => {
|
||||
it("rejects requests without Bearer token before SDK route", async () => {
|
||||
const abort = new AbortController();
|
||||
const task = monitorMSTeamsProvider({
|
||||
cfg: createConfig(0),
|
||||
@@ -319,80 +343,560 @@ describe("monitorMSTeamsProvider lifecycle", () => {
|
||||
});
|
||||
|
||||
const app = expressControl.apps.at(-1);
|
||||
if (!app) {
|
||||
throw new Error("expected Express app to be created");
|
||||
}
|
||||
// This test intentionally locks auth middleware ordering: the cheap Bearer
|
||||
// gate must run before bounded JSON parsing, and JWT validation must run
|
||||
// after parsing so it can bind the token to Activity.serviceUrl.
|
||||
expect(app.use).toHaveBeenCalledTimes(4);
|
||||
expect(app).toBeDefined();
|
||||
// Three middlewares are installed before the SDK route registers:
|
||||
// [0] = bearer-presence gate — rejects unauthenticated requests cheaply.
|
||||
// [1] = `express.json({ limit })` — caps bearer-shaped inbound bodies
|
||||
// before the SDK's later json() can parse them.
|
||||
// [2] = JSON parser error handler — keeps 413 responses JSON-shaped.
|
||||
expect(app!.use.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const jsonMiddleware = vi.mocked((await import("express")).json).mock.results[0]?.value;
|
||||
if (typeof jsonMiddleware !== "function") {
|
||||
throw new Error("expected Express JSON middleware");
|
||||
}
|
||||
expect(readMockCallArg(app.use, 1, 0)).toBe(jsonMiddleware);
|
||||
|
||||
const authGate = readMockCallArg(app.use, 0, 0) as (
|
||||
const bearerMiddleware = app!.use.mock.calls[0]?.[0] as (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: (err?: unknown) => void,
|
||||
) => void;
|
||||
const authNext = vi.fn();
|
||||
const unauthorizedResponse = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn(),
|
||||
} as unknown as Response;
|
||||
authGate({ headers: {} } as Request, unauthorizedResponse, authNext);
|
||||
expect(authNext).not.toHaveBeenCalled();
|
||||
|
||||
const jwtMiddleware = readMockCallArg(app.use, 3, 0) as (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: (err?: unknown) => void,
|
||||
) => void;
|
||||
// Request without Bearer token should be rejected
|
||||
const statusFn = vi.fn().mockReturnValue({ json: vi.fn() });
|
||||
const next = vi.fn();
|
||||
jwtMiddleware(
|
||||
{
|
||||
headers: { authorization: "Bearer token" },
|
||||
body: { serviceUrl: "https://smba.trafficmanager.net/amer/" },
|
||||
} as Request,
|
||||
{
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn(),
|
||||
} as unknown as Response,
|
||||
next,
|
||||
bearerMiddleware({ headers: {} } as Request, { status: statusFn } as unknown as Response, next);
|
||||
expect(statusFn).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
|
||||
// Request with Bearer token should pass through
|
||||
const next2 = vi.fn();
|
||||
bearerMiddleware(
|
||||
{ headers: { authorization: "Bearer valid-token" } } as Request,
|
||||
{} as Response,
|
||||
next2,
|
||||
);
|
||||
expect(next2).toHaveBeenCalledTimes(1);
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
});
|
||||
|
||||
it("keeps oversized webhook parse failures JSON-shaped", async () => {
|
||||
const abort = new AbortController();
|
||||
const task = monitorMSTeamsProvider({
|
||||
cfg: createConfig(0),
|
||||
runtime: createRuntime(),
|
||||
abortSignal: abort.signal,
|
||||
conversationStore: createStores().conversationStore,
|
||||
pollStore: createStores().pollStore,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(jwtValidate).toHaveBeenCalledWith(
|
||||
"Bearer token",
|
||||
"https://smba.trafficmanager.net/amer/",
|
||||
expect(expressControl.apps.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const app = expressControl.apps.at(-1);
|
||||
const jsonErrorMiddleware = app!.use.mock.calls[2]?.[0] as (
|
||||
err: unknown,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: (err?: unknown) => void,
|
||||
) => void;
|
||||
const json = vi.fn();
|
||||
const status = vi.fn(() => ({ json }));
|
||||
const next = vi.fn();
|
||||
|
||||
jsonErrorMiddleware({ status: 413 }, {} as Request, { status } as unknown as Response, next);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(413);
|
||||
expect(json).toHaveBeenCalledWith({ error: "Payload too large" });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
});
|
||||
|
||||
it("forwards legacy /api/messages requests to a custom webhook path", async () => {
|
||||
const abort = new AbortController();
|
||||
const cfg = createConfig(0);
|
||||
updateMSTeamsConfig(cfg, {
|
||||
webhook: { port: 0, path: "/teams/events" },
|
||||
});
|
||||
const task = monitorMSTeamsProvider({
|
||||
cfg,
|
||||
runtime: createRuntime(),
|
||||
abortSignal: abort.signal,
|
||||
conversationStore: createStores().conversationStore,
|
||||
pollStore: createStores().pollStore,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(expressControl.apps.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const app = expressControl.apps.at(-1);
|
||||
expect(loadMSTeamsSdkWithAuth.mock.calls[0]?.[1]).toMatchObject({
|
||||
messagingEndpoint: "/teams/events",
|
||||
});
|
||||
const legacyForwarder = app!.post.mock.calls.find((call) => call[0] === "/api/messages")?.[1];
|
||||
expect(typeof legacyForwarder).toBe("function");
|
||||
if (typeof legacyForwarder !== "function") {
|
||||
throw new Error("expected legacy /api/messages forwarder");
|
||||
}
|
||||
|
||||
const req = { url: "/api/messages", headers: { authorization: "Bearer valid" } } as Request;
|
||||
const res = {} as Response;
|
||||
const next = vi.fn();
|
||||
legacyForwarder(req, res, next);
|
||||
|
||||
expect(req.url).toBe("/teams/events");
|
||||
expect(app).toHaveBeenCalledWith(req, res, next);
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
});
|
||||
|
||||
it("gates SDK SSO invoke routes and persists successful signin events", async () => {
|
||||
const abort = new AbortController();
|
||||
const cfg = createConfig(0);
|
||||
updateMSTeamsConfig(cfg, {
|
||||
sso: { enabled: true, connectionName: "graph" },
|
||||
});
|
||||
|
||||
const task = monitorMSTeamsProvider({
|
||||
cfg,
|
||||
runtime: createRuntime(),
|
||||
abortSignal: abort.signal,
|
||||
conversationStore: createStores().conversationStore,
|
||||
pollStore: createStores().pollStore,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(registerMSTeamsHandlers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(loadMSTeamsSdkWithAuth.mock.calls[0]?.[1]).toMatchObject({
|
||||
oauthDefaultConnectionName: "graph",
|
||||
});
|
||||
|
||||
const sdkResultPromise = loadMSTeamsSdkWithAuth.mock.results[0]?.value;
|
||||
if (!sdkResultPromise) {
|
||||
throw new Error("expected loadMSTeamsSdkWithAuth result");
|
||||
}
|
||||
const sdkResult = await sdkResultPromise;
|
||||
const app = sdkResult.app;
|
||||
expect(app.on).toHaveBeenCalledWith("signin.token-exchange", expect.any(Function));
|
||||
expect(app.on).toHaveBeenCalledWith("signin.verify-state", expect.any(Function));
|
||||
expect(app.event).toHaveBeenCalledWith("signin", expect.any(Function));
|
||||
|
||||
const tokenExchangeHandler = app.on.mock.calls.find(
|
||||
(call: [string, unknown]) => call[0] === "signin.token-exchange",
|
||||
)?.[1];
|
||||
expect(typeof tokenExchangeHandler).toBe("function");
|
||||
if (typeof tokenExchangeHandler !== "function") {
|
||||
throw new Error("expected signin token-exchange handler");
|
||||
}
|
||||
const exchangeResult = await tokenExchangeHandler({
|
||||
activity: { from: { id: "29:user", aadObjectId: "aad-user" } },
|
||||
});
|
||||
expect(exchangeResult).toEqual({ status: 200 });
|
||||
expect(app.onTokenExchange).toHaveBeenCalledTimes(1);
|
||||
|
||||
const signinHandler = app.event.mock.calls.find(
|
||||
(call: [string, unknown]) => call[0] === "signin",
|
||||
)?.[1];
|
||||
expect(typeof signinHandler).toBe("function");
|
||||
if (typeof signinHandler !== "function") {
|
||||
throw new Error("expected signin event handler");
|
||||
}
|
||||
|
||||
signinHandler({
|
||||
activity: { from: { id: "29:user", aadObjectId: "aad-user" } },
|
||||
token: {
|
||||
connectionName: "graph",
|
||||
token: "delegated-graph-token",
|
||||
expiration: "2030-01-01T00:00:00Z",
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(isSigninInvokeAuthorized).toHaveBeenCalledTimes(2);
|
||||
expect(ssoTokenStore.save).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(ssoTokenStore.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
connectionName: "graph",
|
||||
userId: "29:user",
|
||||
token: "delegated-graph-token",
|
||||
expiresAt: "2030-01-01T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
expect(ssoTokenStore.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
connectionName: "graph",
|
||||
userId: "aad-user",
|
||||
token: "delegated-graph-token",
|
||||
expiresAt: "2030-01-01T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
});
|
||||
|
||||
it("does not persist SDK SSO signin events when Teams sender policy denies them", async () => {
|
||||
const abort = new AbortController();
|
||||
const cfg = createConfig(0);
|
||||
updateMSTeamsConfig(cfg, {
|
||||
sso: { enabled: true, connectionName: "graph" },
|
||||
});
|
||||
isSigninInvokeAuthorized.mockResolvedValueOnce(false);
|
||||
|
||||
const task = monitorMSTeamsProvider({
|
||||
cfg,
|
||||
runtime: createRuntime(),
|
||||
abortSignal: abort.signal,
|
||||
conversationStore: createStores().conversationStore,
|
||||
pollStore: createStores().pollStore,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(registerMSTeamsHandlers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const sdkResultPromise = loadMSTeamsSdkWithAuth.mock.results[0]?.value;
|
||||
if (!sdkResultPromise) {
|
||||
throw new Error("expected loadMSTeamsSdkWithAuth result");
|
||||
}
|
||||
const app = (await sdkResultPromise).app;
|
||||
const signinHandler = app.event.mock.calls.find(
|
||||
(call: [string, unknown]) => call[0] === "signin",
|
||||
)?.[1];
|
||||
if (typeof signinHandler !== "function") {
|
||||
throw new Error("expected signin event handler");
|
||||
}
|
||||
|
||||
signinHandler({
|
||||
activity: { from: { id: "29:user", aadObjectId: "aad-user" } },
|
||||
token: {
|
||||
connectionName: "graph",
|
||||
token: "delegated-graph-token",
|
||||
expiration: "2030-01-01T00:00:00Z",
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(isSigninInvokeAuthorized).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(ssoTokenStore.save).not.toHaveBeenCalled();
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
});
|
||||
|
||||
it("blocks SDK SSO token exchange before the SDK calls Bot Framework", async () => {
|
||||
const abort = new AbortController();
|
||||
const cfg = createConfig(0);
|
||||
updateMSTeamsConfig(cfg, {
|
||||
sso: { enabled: true, connectionName: "graph" },
|
||||
});
|
||||
isSigninInvokeAuthorized.mockResolvedValueOnce(false);
|
||||
|
||||
const task = monitorMSTeamsProvider({
|
||||
cfg,
|
||||
runtime: createRuntime(),
|
||||
abortSignal: abort.signal,
|
||||
conversationStore: createStores().conversationStore,
|
||||
pollStore: createStores().pollStore,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(registerMSTeamsHandlers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const sdkResultPromise = loadMSTeamsSdkWithAuth.mock.results[0]?.value;
|
||||
if (!sdkResultPromise) {
|
||||
throw new Error("expected loadMSTeamsSdkWithAuth result");
|
||||
}
|
||||
const app = (await sdkResultPromise).app;
|
||||
const tokenExchangeHandler = app.on.mock.calls.find(
|
||||
(call: [string, unknown]) => call[0] === "signin.token-exchange",
|
||||
)?.[1];
|
||||
if (typeof tokenExchangeHandler !== "function") {
|
||||
throw new Error("expected signin token-exchange handler");
|
||||
}
|
||||
|
||||
const result = await tokenExchangeHandler({
|
||||
activity: { from: { id: "29:blocked", aadObjectId: "aad-blocked" } },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ status: 200, body: {} });
|
||||
expect(isSigninInvokeAuthorized).toHaveBeenCalledTimes(1);
|
||||
expect(app.onTokenExchange).not.toHaveBeenCalled();
|
||||
expect(ssoTokenStore.save).not.toHaveBeenCalled();
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
});
|
||||
|
||||
it("falls through non-feedback message.submit invokes to activity dispatch", async () => {
|
||||
const abort = new AbortController();
|
||||
const task = monitorMSTeamsProvider({
|
||||
cfg: createConfig(0),
|
||||
runtime: createRuntime(),
|
||||
abortSignal: abort.signal,
|
||||
conversationStore: createStores().conversationStore,
|
||||
pollStore: createStores().pollStore,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(registerMSTeamsHandlers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const sdkResultPromise = loadMSTeamsSdkWithAuth.mock.results[0]?.value;
|
||||
if (!sdkResultPromise) {
|
||||
throw new Error("expected loadMSTeamsSdkWithAuth result");
|
||||
}
|
||||
const app = (await sdkResultPromise).app;
|
||||
const messageSubmitHandler = app.on.mock.calls.find(
|
||||
(call: [string, unknown]) => call[0] === "message.submit",
|
||||
)?.[1];
|
||||
const activityHandler = app.on.mock.calls.find(
|
||||
(call: [string, unknown]) => call[0] === "activity",
|
||||
)?.[1];
|
||||
if (typeof messageSubmitHandler !== "function" || typeof activityHandler !== "function") {
|
||||
throw new Error("expected message.submit and activity handlers");
|
||||
}
|
||||
|
||||
const activity = {
|
||||
type: "invoke",
|
||||
name: "message/submitAction",
|
||||
value: { actionName: "nonFeedbackAction" },
|
||||
};
|
||||
const next = vi.fn(async () => {});
|
||||
await messageSubmitHandler({ activity, next });
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
|
||||
const registeredHandler = registerMSTeamsHandlers.mock.calls[0]?.[0];
|
||||
if (!registeredHandler) {
|
||||
throw new Error("expected registered Teams handler");
|
||||
}
|
||||
const run = vi.spyOn(registeredHandler, "run");
|
||||
await activityHandler({ activity });
|
||||
expect(run).toHaveBeenCalledWith(expect.objectContaining({ activity }));
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
});
|
||||
|
||||
jwtValidate.mockReset().mockResolvedValueOnce(false);
|
||||
const missingServiceUrlNext = vi.fn();
|
||||
const missingServiceUrlResponse = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn(),
|
||||
} as unknown as Response;
|
||||
jwtMiddleware(
|
||||
{
|
||||
headers: { authorization: "Bearer token-no-service-url" },
|
||||
body: { type: "message" },
|
||||
} as Request,
|
||||
missingServiceUrlResponse,
|
||||
missingServiceUrlNext,
|
||||
);
|
||||
it("acks file-consent invokes before upload work settles", async () => {
|
||||
let releaseUpload: (() => void) | undefined;
|
||||
const uploadWork = new Promise<void>((resolve) => {
|
||||
releaseUpload = resolve;
|
||||
});
|
||||
runMSTeamsFileConsentInvokeHandler.mockReturnValueOnce(uploadWork);
|
||||
|
||||
const abort = new AbortController();
|
||||
const task = monitorMSTeamsProvider({
|
||||
cfg: createConfig(0),
|
||||
runtime: createRuntime(),
|
||||
abortSignal: abort.signal,
|
||||
conversationStore: createStores().conversationStore,
|
||||
pollStore: createStores().pollStore,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(jwtValidate).toHaveBeenCalledWith("Bearer token-no-service-url", undefined);
|
||||
expect(missingServiceUrlResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(missingServiceUrlNext).not.toHaveBeenCalled();
|
||||
expect(registerMSTeamsHandlers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const sdkResultPromise = loadMSTeamsSdkWithAuth.mock.results[0]?.value;
|
||||
if (!sdkResultPromise) {
|
||||
throw new Error("expected loadMSTeamsSdkWithAuth result");
|
||||
}
|
||||
const app = (await sdkResultPromise).app;
|
||||
const fileConsentHandler = app.on.mock.calls.find(
|
||||
(call: [string, unknown]) => call[0] === "file.consent.accept",
|
||||
)?.[1];
|
||||
if (typeof fileConsentHandler !== "function") {
|
||||
throw new Error("expected file consent accept handler");
|
||||
}
|
||||
|
||||
expect(fileConsentHandler({ activity: { type: "invoke", name: "fileConsent/invoke" } })).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(runMSTeamsFileConsentInvokeHandler).toHaveBeenCalledTimes(1);
|
||||
releaseUpload?.();
|
||||
await uploadWork;
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
});
|
||||
|
||||
it("acks non-poll card actions before agent dispatch settles", async () => {
|
||||
const abort = new AbortController();
|
||||
const task = monitorMSTeamsProvider({
|
||||
cfg: createConfig(0),
|
||||
runtime: createRuntime(),
|
||||
abortSignal: abort.signal,
|
||||
conversationStore: createStores().conversationStore,
|
||||
pollStore: createStores().pollStore,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(registerMSTeamsHandlers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const sdkResultPromise = loadMSTeamsSdkWithAuth.mock.results[0]?.value;
|
||||
if (!sdkResultPromise) {
|
||||
throw new Error("expected loadMSTeamsSdkWithAuth result");
|
||||
}
|
||||
const app = (await sdkResultPromise).app;
|
||||
const cardActionHandler = app.on.mock.calls.find(
|
||||
(call: [string, unknown]) => call[0] === "card.action",
|
||||
)?.[1];
|
||||
if (typeof cardActionHandler !== "function") {
|
||||
throw new Error("expected card.action handler");
|
||||
}
|
||||
const registeredHandler = registerMSTeamsHandlers.mock.calls[0]?.[0];
|
||||
if (!registeredHandler) {
|
||||
throw new Error("expected registered Teams handler");
|
||||
}
|
||||
let releaseDispatch: (() => void) | undefined;
|
||||
const dispatchWork = new Promise<void>((resolve) => {
|
||||
releaseDispatch = resolve;
|
||||
});
|
||||
const run = vi.spyOn(registeredHandler, "run").mockReturnValueOnce(dispatchWork);
|
||||
|
||||
const response = await cardActionHandler({
|
||||
activity: {
|
||||
type: "invoke",
|
||||
name: "adaptiveCard/action",
|
||||
value: { action: { data: { action: "nonPoll" } } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({ statusCode: 200, value: "OK" });
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
releaseDispatch?.();
|
||||
await dispatchWork;
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
});
|
||||
|
||||
it("gates poll card votes before recording them", async () => {
|
||||
const abort = new AbortController();
|
||||
const cfg = createConfig(0);
|
||||
const pollStore: MSTeamsPollStore = {
|
||||
createPoll: vi.fn(async () => {}),
|
||||
getPoll: vi.fn(async () => ({
|
||||
id: "poll-1",
|
||||
question: "Ship?",
|
||||
options: ["Yes", "No"],
|
||||
maxSelections: 1,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
conversationId: "19:channel@thread.tacv2",
|
||||
votes: {},
|
||||
})),
|
||||
recordVote: vi.fn(async () => null),
|
||||
};
|
||||
isCardActionInvokeAuthorized.mockResolvedValueOnce(false);
|
||||
|
||||
const task = monitorMSTeamsProvider({
|
||||
cfg,
|
||||
runtime: createRuntime(),
|
||||
abortSignal: abort.signal,
|
||||
conversationStore: createStores().conversationStore,
|
||||
pollStore,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(registerMSTeamsHandlers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const sdkResultPromise = loadMSTeamsSdkWithAuth.mock.results[0]?.value;
|
||||
if (!sdkResultPromise) {
|
||||
throw new Error("expected loadMSTeamsSdkWithAuth result");
|
||||
}
|
||||
const app = (await sdkResultPromise).app;
|
||||
const cardActionHandler = app.on.mock.calls.find(
|
||||
(call: [string, unknown]) => call[0] === "card.action",
|
||||
)?.[1];
|
||||
if (typeof cardActionHandler !== "function") {
|
||||
throw new Error("expected card.action handler");
|
||||
}
|
||||
|
||||
const response = await cardActionHandler({
|
||||
activity: {
|
||||
type: "invoke",
|
||||
name: "adaptiveCard/action",
|
||||
from: { id: "29:user", aadObjectId: "aad-user" },
|
||||
conversation: { id: "19:channel@thread.tacv2", conversationType: "channel" },
|
||||
value: { action: { data: { openclawPollId: "poll-1", choices: "0" } } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({ statusCode: 200, value: "Not authorized." });
|
||||
expect(isCardActionInvokeAuthorized).toHaveBeenCalledTimes(1);
|
||||
expect(pollStore.getPoll).not.toHaveBeenCalled();
|
||||
expect(pollStore.recordVote).not.toHaveBeenCalled();
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
});
|
||||
|
||||
it("rejects poll card votes from the wrong conversation", async () => {
|
||||
const abort = new AbortController();
|
||||
const cfg = createConfig(0);
|
||||
const pollStore: MSTeamsPollStore = {
|
||||
createPoll: vi.fn(async () => {}),
|
||||
getPoll: vi.fn(async () => ({
|
||||
id: "poll-1",
|
||||
question: "Ship?",
|
||||
options: ["Yes", "No"],
|
||||
maxSelections: 1,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
conversationId: "19:expected@thread.tacv2",
|
||||
votes: {},
|
||||
})),
|
||||
recordVote: vi.fn(async () => null),
|
||||
};
|
||||
|
||||
const task = monitorMSTeamsProvider({
|
||||
cfg,
|
||||
runtime: createRuntime(),
|
||||
abortSignal: abort.signal,
|
||||
conversationStore: createStores().conversationStore,
|
||||
pollStore,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(registerMSTeamsHandlers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const sdkResultPromise = loadMSTeamsSdkWithAuth.mock.results[0]?.value;
|
||||
if (!sdkResultPromise) {
|
||||
throw new Error("expected loadMSTeamsSdkWithAuth result");
|
||||
}
|
||||
const app = (await sdkResultPromise).app;
|
||||
const cardActionHandler = app.on.mock.calls.find(
|
||||
(call: [string, unknown]) => call[0] === "card.action",
|
||||
)?.[1];
|
||||
if (typeof cardActionHandler !== "function") {
|
||||
throw new Error("expected card.action handler");
|
||||
}
|
||||
|
||||
const response = await cardActionHandler({
|
||||
activity: {
|
||||
type: "invoke",
|
||||
name: "adaptiveCard/action",
|
||||
from: { id: "29:user", aadObjectId: "aad-user" },
|
||||
conversation: { id: "19:other@thread.tacv2", conversationType: "channel" },
|
||||
value: { action: { data: { openclawPollId: "poll-1", choices: "0" } } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({ statusCode: 200, value: "Poll not found." });
|
||||
expect(isCardActionInvokeAuthorized).toHaveBeenCalledTimes(1);
|
||||
expect(pollStore.getPoll).toHaveBeenCalledWith("poll-1");
|
||||
expect(pollStore.recordVote).not.toHaveBeenCalled();
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
});
|
||||
|
||||
@@ -8,22 +8,37 @@ import {
|
||||
type OpenClawConfig,
|
||||
type RuntimeEnv,
|
||||
} from "../runtime-api.js";
|
||||
import { resolveMSTeamsSdkCloudOptions } from "./cloud.js";
|
||||
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
|
||||
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
||||
import { formatUnknownError } from "./errors.js";
|
||||
import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import { registerMSTeamsHandlers, type MSTeamsActivityHandler } from "./monitor-handler.js";
|
||||
import { createMSTeamsPollStoreFs, type MSTeamsPollStore } from "./polls.js";
|
||||
import { runMSTeamsFeedbackInvokeHandler } from "./feedback-invoke.js";
|
||||
import { runMSTeamsFileConsentInvokeHandler } from "./file-consent-invoke.js";
|
||||
import { normalizeMSTeamsConversationId } from "./inbound.js";
|
||||
import {
|
||||
isCardActionInvokeAuthorized,
|
||||
isSigninInvokeAuthorized,
|
||||
registerMSTeamsHandlers,
|
||||
type MSTeamsActivityHandler,
|
||||
} from "./monitor-handler.js";
|
||||
import type { MSTeamsMessageHandlerDeps } from "./monitor-handler.types.js";
|
||||
import {
|
||||
createMSTeamsPollStoreFs,
|
||||
extractMSTeamsPollVote,
|
||||
type MSTeamsPollStore,
|
||||
} from "./polls.js";
|
||||
import {
|
||||
resolveMSTeamsChannelAllowlist,
|
||||
resolveMSTeamsUserAllowlist,
|
||||
} from "./resolve-allowlist.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||
import {
|
||||
createBotFrameworkJwtValidator,
|
||||
createMSTeamsAdapter,
|
||||
createMSTeamsExpressAdapter,
|
||||
createMSTeamsTokenProvider,
|
||||
loadMSTeamsSdkWithAuth,
|
||||
type MSTeamsApp,
|
||||
type MSTeamsCardActionResponse,
|
||||
} from "./sdk.js";
|
||||
import { createMSTeamsSsoTokenStoreFs } from "./sso-token-store.js";
|
||||
import type { MSTeamsSsoDeps } from "./sso.js";
|
||||
@@ -43,16 +58,6 @@ type MonitorMSTeamsResult = {
|
||||
shutdown: () => Promise<void>;
|
||||
};
|
||||
|
||||
const MSTEAMS_WEBHOOK_MAX_BODY_BYTES = DEFAULT_WEBHOOK_MAX_BODY_BYTES;
|
||||
|
||||
function getActivityServiceUrl(body: unknown): string | undefined {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
return undefined;
|
||||
}
|
||||
const serviceUrl = (body as { serviceUrl?: unknown }).serviceUrl;
|
||||
return typeof serviceUrl === "string" ? serviceUrl : undefined;
|
||||
}
|
||||
|
||||
export async function monitorMSTeamsProvider(
|
||||
opts: MonitorMSTeamsOpts,
|
||||
): Promise<MonitorMSTeamsResult> {
|
||||
@@ -217,9 +222,10 @@ export async function monitorMSTeamsProvider(
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Log at error (not log) — allowlist resolution failures leave the bot in a
|
||||
// degraded state where Graph-resolved IDs are missing (#77674).
|
||||
runtime?.error(
|
||||
// Allowlist Graph resolution is security-sensitive — surface failures at
|
||||
// error level so operators notice the degraded state where Graph-resolved
|
||||
// IDs are missing (#77674).
|
||||
runtime.error?.(
|
||||
`msteams resolve failed; falling back to raw config entries — allowlist members resolved via Graph may be missing. ${formatUnknownError(err)}`,
|
||||
);
|
||||
}
|
||||
@@ -254,13 +260,75 @@ export async function monitorMSTeamsProvider(
|
||||
// Dynamic import to avoid loading SDK when provider is disabled
|
||||
const express = await import("express");
|
||||
|
||||
const { sdk, app } = await loadMSTeamsSdkWithAuth(creds);
|
||||
// Create Express server first, then wrap it with the SDK's ExpressAdapter
|
||||
// so the App registers its route handler on it (including JWT validation).
|
||||
const expressApp = express.default();
|
||||
|
||||
// Cheap auth-presence gate: reject requests without a Bearer token before
|
||||
// JSON parsing. Bearer-shaped junk still hits the bounded parser below before
|
||||
// the SDK's route-level parser and full JWT validation.
|
||||
expressApp.use((req: Request, res: Response, next: (err?: unknown) => void) => {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth || !auth.startsWith("Bearer ")) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
expressApp.use(express.json({ limit: DEFAULT_WEBHOOK_MAX_BODY_BYTES }));
|
||||
expressApp.use((err: unknown, _req: Request, res: Response, next: (err?: unknown) => void) => {
|
||||
if (err && typeof err === "object" && "status" in err && err.status === 413) {
|
||||
res.status(413).json({ error: "Payload too large" });
|
||||
return;
|
||||
}
|
||||
next(err);
|
||||
});
|
||||
|
||||
const configuredPath = (msteamsCfg.webhook?.path ?? "/api/messages") as `/${string}`;
|
||||
|
||||
// Lazy-load the SDK and create the App with ExpressAdapter. The SDK
|
||||
// registers POST /api/messages (or configured path) and handles JWT
|
||||
// validation + body parsing internally.
|
||||
const { app } = await loadMSTeamsSdkWithAuth(creds, {
|
||||
...resolveMSTeamsSdkCloudOptions(msteamsCfg),
|
||||
httpServerAdapter: await createMSTeamsExpressAdapter(expressApp),
|
||||
messagingEndpoint: configuredPath,
|
||||
...(msteamsCfg.sso?.enabled && msteamsCfg.sso.connectionName
|
||||
? { oauthDefaultConnectionName: msteamsCfg.sso.connectionName }
|
||||
: {}),
|
||||
});
|
||||
|
||||
// Existing Azure Bot registrations may still point at the legacy
|
||||
// `/api/messages` endpoint while an operator has configured a custom
|
||||
// `webhook.path`. Forward to the configured path with a one-time deprecation
|
||||
// warning so those registrations keep working through the transition. The
|
||||
// forwarder runs after the SDK route is registered, so it only matches
|
||||
// requests that the SDK route itself didn't claim.
|
||||
if (configuredPath !== "/api/messages") {
|
||||
let warnedLegacyMessagesRoute = false;
|
||||
expressApp.post(
|
||||
"/api/messages",
|
||||
(req: Request, res: Response, next: (err?: unknown) => void) => {
|
||||
if (!warnedLegacyMessagesRoute) {
|
||||
warnedLegacyMessagesRoute = true;
|
||||
log.warn?.(
|
||||
`received request on /api/messages but webhook.path is ${configuredPath}; ` +
|
||||
"update your Azure Bot endpoint — this fallback will be removed in a future release",
|
||||
);
|
||||
}
|
||||
// Rewrite the URL so the SDK's registered handler picks it up. Express
|
||||
// app instances are themselves request handlers (Application extends
|
||||
// IRouter extends RequestHandler), so re-invoking the app re-runs the
|
||||
// middleware chain (including the SDK-registered route).
|
||||
req.url = configuredPath;
|
||||
expressApp(req, res, next);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Build a token provider adapter for Graph API operations
|
||||
const tokenProvider = createMSTeamsTokenProvider(app);
|
||||
|
||||
const adapter = createMSTeamsAdapter(app, sdk);
|
||||
|
||||
// Build SSO deps when the operator has opted in and a connection name
|
||||
// is configured. Leaving `sso` undefined matches the pre-SSO behavior
|
||||
// (the plugin will still ack signin invokes, but will not attempt a
|
||||
@@ -277,13 +345,15 @@ export async function monitorMSTeamsProvider(
|
||||
});
|
||||
}
|
||||
|
||||
// Build a simple ActivityHandler-compatible object
|
||||
// Build a simple ActivityHandler-compatible object and register our
|
||||
// existing dispatch handlers on it. The SDK's App routes all inbound
|
||||
// activities to our handler via app.on('activity', ...).
|
||||
const handler = buildActivityHandler();
|
||||
registerMSTeamsHandlers(handler, {
|
||||
const handlerDeps: MSTeamsMessageHandlerDeps = {
|
||||
cfg,
|
||||
runtime,
|
||||
appId,
|
||||
adapter: adapter as unknown as MSTeamsAdapter,
|
||||
app,
|
||||
tokenProvider,
|
||||
textLimit,
|
||||
mediaMaxBytes,
|
||||
@@ -291,94 +361,253 @@ export async function monitorMSTeamsProvider(
|
||||
pollStore,
|
||||
log,
|
||||
sso: ssoDeps,
|
||||
});
|
||||
};
|
||||
registerMSTeamsHandlers(handler, handlerDeps);
|
||||
|
||||
// Create Express server
|
||||
const expressApp = express.default();
|
||||
|
||||
// Cheap pre-parse auth gate: reject requests without a Bearer token before
|
||||
// spending CPU/memory on JSON body parsing. This prevents unauthenticated
|
||||
// request floods from forcing body parsing on internet-exposed webhooks.
|
||||
expressApp.use((req: Request, res: Response, next: (err?: unknown) => void) => {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth || !auth.startsWith("Bearer ")) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
// Handle adaptiveCard/action invokes (Action.Execute Universal Action Model).
|
||||
// We must return an InvokeResponse-shaped value so Teams updates the card UI;
|
||||
// returning nothing or letting the catch-all process it makes Teams report
|
||||
// "Unable to reach app".
|
||||
app.on("card.action", async (ctx): Promise<MSTeamsCardActionResponse> => {
|
||||
const adaptedCtx = adaptSdkContext(ctx, app);
|
||||
try {
|
||||
const activity = adaptedCtx.activity;
|
||||
const vote = extractMSTeamsPollVote(activity);
|
||||
if (vote) {
|
||||
const voterId = activity?.from?.aadObjectId ?? activity?.from?.id ?? "unknown";
|
||||
try {
|
||||
if (!(await isCardActionInvokeAuthorized(adaptedCtx, handlerDeps))) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
type: "application/vnd.microsoft.activity.message",
|
||||
value: "Not authorized.",
|
||||
};
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Microsoft requires the JWT serviceurl claim to match the Activity body.
|
||||
// Keep the cheap Bearer gate above, then parse the bounded JSON payload
|
||||
// before full JWT validation so the service URL is authenticated.
|
||||
expressApp.use(express.json({ limit: MSTEAMS_WEBHOOK_MAX_BODY_BYTES }));
|
||||
expressApp.use((err: unknown, _req: Request, res: Response, next: (err?: unknown) => void) => {
|
||||
if (err && typeof err === "object" && "status" in err && err.status === 413) {
|
||||
res.status(413).json({ error: "Payload too large" });
|
||||
return;
|
||||
const existingPoll = await pollStore.getPoll(vote.pollId);
|
||||
if (!existingPoll) {
|
||||
log.debug?.("poll vote ignored (poll not found)", { pollId: vote.pollId });
|
||||
return {
|
||||
statusCode: 200,
|
||||
type: "application/vnd.microsoft.activity.message",
|
||||
value: "Poll not found.",
|
||||
};
|
||||
}
|
||||
next(err);
|
||||
});
|
||||
|
||||
// JWT validation — verify Bot Framework tokens using jsonwebtoken + JWKS,
|
||||
// including the Microsoft serviceUrl claim binding.
|
||||
const jwtValidator = await createBotFrameworkJwtValidator(creds);
|
||||
expressApp.use((req: Request, res: Response, next: (err?: unknown) => void) => {
|
||||
// Authorization header is guaranteed by the pre-parse auth gate above.
|
||||
const authHeader = req.headers.authorization!;
|
||||
const activityServiceUrl = getActivityServiceUrl(req.body);
|
||||
jwtValidator
|
||||
.validate(authHeader, activityServiceUrl)
|
||||
.then((valid) => {
|
||||
if (!valid) {
|
||||
log.debug?.("JWT validation failed");
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
})
|
||||
.catch((err) => {
|
||||
// Network-level failures (DNS, firewall, TLS toward login.botframework.com)
|
||||
// are rethrown by the validator so we can log them visibly. Without this,
|
||||
// they look identical to a bad credential at default log levels (#77674).
|
||||
const isNetworkFailure =
|
||||
err instanceof Error &&
|
||||
/ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ETIMEDOUT|ECONNRESET/i.test(
|
||||
(err as NodeJS.ErrnoException).code ?? err.message,
|
||||
const pollConversationId = existingPoll.conversationId
|
||||
? normalizeMSTeamsConversationId(existingPoll.conversationId)
|
||||
: undefined;
|
||||
const activityConversationId = normalizeMSTeamsConversationId(
|
||||
activity?.conversation?.id ?? "",
|
||||
);
|
||||
if (isNetworkFailure) {
|
||||
// Network failure fetching JWKS keys — log visibly so operators can
|
||||
// identify egress blocks to login.botframework.com (#77674).
|
||||
runtime?.error(
|
||||
`msteams: JWKS key fetch failed — check egress to login.botframework.com:443 (firewall or DNS may be blocking it). Bot will 401 all inbound requests until this is resolved. Error: ${formatUnknownError(err)}`,
|
||||
);
|
||||
} else {
|
||||
log.debug?.(`JWT validation error: ${formatUnknownError(err)}`);
|
||||
}
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
if (pollConversationId && pollConversationId !== activityConversationId) {
|
||||
log.info("poll vote ignored (conversation mismatch)", {
|
||||
pollId: vote.pollId,
|
||||
expectedConversationId: pollConversationId,
|
||||
receivedConversationId: activityConversationId || undefined,
|
||||
});
|
||||
return {
|
||||
statusCode: 200,
|
||||
type: "application/vnd.microsoft.activity.message",
|
||||
value: "Poll not found.",
|
||||
};
|
||||
}
|
||||
|
||||
const poll = await pollStore.recordVote({
|
||||
pollId: vote.pollId,
|
||||
voterId,
|
||||
selections: vote.selections,
|
||||
});
|
||||
if (poll) {
|
||||
log.info("recorded poll vote", { pollId: vote.pollId, voterId });
|
||||
return {
|
||||
statusCode: 200,
|
||||
type: "application/vnd.microsoft.activity.message",
|
||||
value: "Vote recorded.",
|
||||
};
|
||||
}
|
||||
log.debug?.("poll vote ignored (poll not found)", { pollId: vote.pollId });
|
||||
return {
|
||||
statusCode: 200,
|
||||
type: "application/vnd.microsoft.activity.message",
|
||||
value: "Poll not found.",
|
||||
};
|
||||
} catch (err) {
|
||||
log.error("failed to record poll vote", {
|
||||
pollId: vote.pollId,
|
||||
error: formatUnknownError(err),
|
||||
});
|
||||
return {
|
||||
statusCode: 500,
|
||||
type: "application/vnd.microsoft.error",
|
||||
value: {
|
||||
code: "RECORD_VOTE_FAILED",
|
||||
message: "Could not record vote.",
|
||||
innerHttpError: { statusCode: 500, body: null },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
// Non-poll card actions may dispatch into the agent. Acknowledge the
|
||||
// invoke immediately so Teams does not time out while that work runs.
|
||||
void handler.run!(adaptedCtx).catch((err: unknown) => {
|
||||
log.error("msteams card.action dispatch failed", { error: formatUnknownError(err) });
|
||||
});
|
||||
return {
|
||||
statusCode: 200,
|
||||
type: "application/vnd.microsoft.activity.message",
|
||||
value: "OK",
|
||||
};
|
||||
} catch (err) {
|
||||
log.error("msteams card.action failed", { error: formatUnknownError(err) });
|
||||
return {
|
||||
statusCode: 500,
|
||||
type: "application/vnd.microsoft.error",
|
||||
value: {
|
||||
code: "CARD_ACTION_FAILED",
|
||||
message: "Card action failed.",
|
||||
innerHttpError: { statusCode: 500, body: null },
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Set up the messages endpoint - use configured path and /api/messages as fallback
|
||||
const configuredPath = msteamsCfg.webhook?.path ?? "/api/messages";
|
||||
const messageHandler = (req: Request, res: Response) => {
|
||||
void adapter
|
||||
.process(req, res, (context: unknown) => handler.run!(context))
|
||||
.catch((err: unknown) => {
|
||||
log.error("msteams webhook failed", { error: formatUnknownError(err) });
|
||||
// File-consent invokes (large-file upload accept/decline). We register
|
||||
// typed handlers so the SDK writes the HTTP InvokeResponse for us — the
|
||||
// old `ctx.sendActivity({ type: "invokeResponse" })` shape no longer
|
||||
// works on the new SDK because that ctx call becomes an outbound BF
|
||||
// activity instead of the HTTP response (Brad #2 / codex #4).
|
||||
app.on("file.consent.accept", (ctx) => {
|
||||
void runMSTeamsFileConsentInvokeHandler(adaptSdkContext(ctx, app), log);
|
||||
});
|
||||
app.on("file.consent.decline", (ctx) => {
|
||||
void runMSTeamsFileConsentInvokeHandler(adaptSdkContext(ctx, app), log);
|
||||
});
|
||||
|
||||
const handleSdkSigninInvoke = async (
|
||||
ctx: unknown,
|
||||
delegateName: "onTokenExchange" | "onVerifyState",
|
||||
) => {
|
||||
const adaptedCtx = adaptSdkContext(ctx, app);
|
||||
if (!(await isSigninInvokeAuthorized(adaptedCtx, handlerDeps))) {
|
||||
return { status: 200, body: {} };
|
||||
}
|
||||
if (!ssoDeps) {
|
||||
log.debug?.("signin invoke received but msteams.sso is not configured", {
|
||||
name: adaptedCtx.activity?.name,
|
||||
});
|
||||
return { status: 200, body: {} };
|
||||
}
|
||||
|
||||
const sdkSigninApp = app as MSTeamsApp & {
|
||||
onTokenExchange?: (ctx: unknown) => Promise<unknown>;
|
||||
onVerifyState?: (ctx: unknown) => Promise<unknown>;
|
||||
};
|
||||
const delegate = sdkSigninApp[delegateName];
|
||||
if (typeof delegate !== "function") {
|
||||
throw new Error(`Teams SDK ${delegateName} handler is unavailable`);
|
||||
}
|
||||
return delegate.call(sdkSigninApp, ctx);
|
||||
};
|
||||
|
||||
// Listen on configured path and /api/messages (standard Bot Framework path)
|
||||
expressApp.post(configuredPath, messageHandler);
|
||||
if (configuredPath !== "/api/messages") {
|
||||
expressApp.post("/api/messages", messageHandler);
|
||||
// Replace the SDK's default sign-in invoke routes with an authz gate that
|
||||
// delegates to the same SDK handlers only after sender policy passes. Registering
|
||||
// a user route with the same name intentionally replaces the SDK system route.
|
||||
app.on("signin.token-exchange", (ctx) => handleSdkSigninInvoke(ctx, "onTokenExchange"));
|
||||
app.on("signin.verify-state", (ctx) => handleSdkSigninInvoke(ctx, "onVerifyState"));
|
||||
|
||||
// The delegated SDK sign-in handlers emit `signin` only after a successful
|
||||
// token exchange/lookup. Persist that token for later OpenClaw use.
|
||||
if (ssoDeps) {
|
||||
app.event("signin", (ctx) => {
|
||||
void (async () => {
|
||||
const adaptedCtx = adaptSdkContext(ctx, app);
|
||||
if (!(await isSigninInvokeAuthorized(adaptedCtx, handlerDeps))) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug?.("listening on paths", {
|
||||
primary: configuredPath,
|
||||
fallback: "/api/messages",
|
||||
const activity = ctx.activity as {
|
||||
from?: { id?: string; aadObjectId?: string };
|
||||
};
|
||||
const userIds = Array.from(
|
||||
new Set(
|
||||
[activity.from?.id, activity.from?.aadObjectId].filter((id): id is string =>
|
||||
Boolean(id),
|
||||
),
|
||||
),
|
||||
);
|
||||
const connectionName = ctx.token.connectionName || ssoDeps.connectionName;
|
||||
if (!connectionName || !ctx.token.token || userIds.length === 0) {
|
||||
log.warn?.("msteams sso signin event missing token metadata", {
|
||||
hasConnectionName: Boolean(connectionName),
|
||||
hasToken: Boolean(ctx.token.token),
|
||||
hasUser: userIds.length > 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
userIds.map((userId) =>
|
||||
ssoDeps.tokenStore.save({
|
||||
connectionName,
|
||||
userId,
|
||||
token: ctx.token.token,
|
||||
expiresAt: ctx.token.expiration,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
log.info("msteams sso token persisted", {
|
||||
connectionName,
|
||||
userIdCount: userIds.length,
|
||||
hasExpiry: Boolean(ctx.token.expiration),
|
||||
});
|
||||
})().catch((err: unknown) => {
|
||||
log.error("msteams sso token persistence failed", {
|
||||
error: formatUnknownError(err),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Feedback (thumbs up/down) on AI-generated messages. Teams delivers this as
|
||||
// a generic `message/submitAction` invoke, so non-feedback submits must fall
|
||||
// through to the activity catch-all for other submit-action handlers.
|
||||
app.on("message.submit", async (ctx) => {
|
||||
const consumed = await runMSTeamsFeedbackInvokeHandler(adaptSdkContext(ctx, app), handlerDeps);
|
||||
if (!consumed) {
|
||||
const next = (ctx as { next?: () => void | Promise<void> }).next;
|
||||
await next?.call(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
// Catch all inbound activities from the SDK and delegate to our existing
|
||||
// handler dispatch system. The SDK has already validated JWT and parsed the
|
||||
// activity by this point.
|
||||
app.on("activity", async (ctx) => {
|
||||
try {
|
||||
const adaptedCtx = adaptSdkContext(ctx, app);
|
||||
const activity = adaptedCtx.activity;
|
||||
// Skip invokes that have dedicated typed routes above.
|
||||
if (activity?.type === "invoke") {
|
||||
if (activity?.name === "adaptiveCard/action") {
|
||||
return;
|
||||
}
|
||||
if (activity?.name === "fileConsent/invoke") {
|
||||
return;
|
||||
}
|
||||
if (activity?.name === "signin/tokenExchange" || activity?.name === "signin/verifyState") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await handler.run!(adaptedCtx);
|
||||
} catch (err) {
|
||||
log.error("msteams webhook failed", { error: formatUnknownError(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize the SDK App — registers the POST route on Express and sets up
|
||||
// JWT validation middleware internally.
|
||||
await app.initialize();
|
||||
|
||||
// Start listening and fail fast if bind/listen fails.
|
||||
const httpServer = expressApp.listen(port);
|
||||
@@ -485,3 +714,56 @@ function buildActivityHandler(): MSTeamsActivityHandler {
|
||||
|
||||
return handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt a new @microsoft/teams.apps SDK context to the MSTeamsTurnContext interface
|
||||
* our handlers expect. The new SDK uses reply()/send() instead of sendActivity().
|
||||
*/
|
||||
function adaptSdkContext(ctx: unknown, app: MSTeamsApp): MSTeamsTurnContext {
|
||||
const sdkCtx = (ctx ?? {}) as {
|
||||
activity?: { id?: string; conversation?: { id?: string; conversationType?: string } };
|
||||
reply?: (activity: unknown) => Promise<unknown>;
|
||||
send?: (activity: unknown) => Promise<unknown>;
|
||||
api?: MSTeamsApp["api"];
|
||||
stream?: {
|
||||
emit(a: unknown): void;
|
||||
update(t: string): void;
|
||||
close(): unknown;
|
||||
readonly canceled: boolean;
|
||||
};
|
||||
};
|
||||
if (typeof sdkCtx.reply !== "function" && typeof sdkCtx.send !== "function") {
|
||||
// Already adapted or old-style context — pass through.
|
||||
return ctx as MSTeamsTurnContext;
|
||||
}
|
||||
const conversationId = sdkCtx.activity?.conversation?.id ?? "";
|
||||
const activityApi = sdkCtx.api ?? app.api;
|
||||
const conversationType = (sdkCtx.activity?.conversation?.conversationType ?? "").toLowerCase();
|
||||
const isThreadable = conversationType === "channel" || conversationType === "groupchat";
|
||||
// For Teams channels and group chats, use ctx.reply() so the SDK threads the
|
||||
// outbound activity to the inbound one (via replyToId + the inbound's
|
||||
// serviceUrl/conversation routing). For personal DMs, use ctx.send() instead
|
||||
// because reply() prepends a blockquote of the user's message — fine in
|
||||
// threaded surfaces where the visual nesting indicates context, but ugly in
|
||||
// 1:1 chat. Streaming chunks go through ctx.stream.emit/close separately.
|
||||
const sendActivity = (activity: unknown) =>
|
||||
isThreadable ? sdkCtx.reply!(activity) : sdkCtx.send!(activity);
|
||||
return Object.assign(Object.create(Object.getPrototypeOf(ctx)), ctx, {
|
||||
sendActivity,
|
||||
sendActivities: async (activities: unknown[]) => {
|
||||
const results: unknown[] = [];
|
||||
for (const a of activities) {
|
||||
results.push(await sendActivity(a));
|
||||
}
|
||||
return results;
|
||||
},
|
||||
updateActivity: async (activity: { id?: string; [key: string]: unknown }) => {
|
||||
const activityId = activity.id ?? "";
|
||||
return activityApi.conversations.activities(conversationId).update(activityId, activity);
|
||||
},
|
||||
deleteActivity: async (activityId: string) => {
|
||||
return activityApi.conversations.activities(conversationId).delete(activityId);
|
||||
},
|
||||
stream: sdkCtx.stream,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { createMSTeamsHttpError } from "./http-error.js";
|
||||
import {
|
||||
MSTEAMS_DEFAULT_DELEGATED_SCOPES,
|
||||
MSTEAMS_DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
buildMSTeamsTokenEndpoint,
|
||||
type MSTeamsDelegatedTokens,
|
||||
} from "./oauth.shared.js";
|
||||
import { createMSTeamsHttpError } from "./http-error.js";
|
||||
|
||||
/** Five-minute buffer subtracted from token expiry to avoid edge-case clock drift. */
|
||||
const EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
@@ -107,7 +107,10 @@ export function extractMSTeamsPollVote(
|
||||
readNestedString(value, ["openclaw", "poll", "id"]) ??
|
||||
readNestedString(value, ["data", "openclawPollId"]) ??
|
||||
readNestedString(value, ["data", "pollId"]) ??
|
||||
readNestedString(value, ["data", "openclaw", "pollId"]);
|
||||
readNestedString(value, ["data", "openclaw", "pollId"]) ??
|
||||
// Action.Execute (Universal Action Model) payload shape: value.action.data
|
||||
readNestedString(value, ["action", "data", "openclawPollId"]) ??
|
||||
readNestedString(value, ["action", "data", "pollId"]);
|
||||
if (!pollId) {
|
||||
return null;
|
||||
}
|
||||
@@ -115,12 +118,17 @@ export function extractMSTeamsPollVote(
|
||||
const directSelections = extractSelections(value.choices);
|
||||
const nestedSelections = extractSelections(readNestedValue(value, ["choices"]));
|
||||
const dataSelections = extractSelections(readNestedValue(value, ["data", "choices"]));
|
||||
const actionDataSelections = extractSelections(
|
||||
readNestedValue(value, ["action", "data", "choices"]),
|
||||
);
|
||||
const selections =
|
||||
directSelections.length > 0
|
||||
? directSelections
|
||||
: nestedSelections.length > 0
|
||||
? nestedSelections
|
||||
: dataSelections;
|
||||
: dataSelections.length > 0
|
||||
? dataSelections
|
||||
: actionDataSelections;
|
||||
|
||||
if (selections.length === 0) {
|
||||
return null;
|
||||
@@ -181,18 +189,13 @@ export function buildMSTeamsPollCard(params: {
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
type: "Action.Submit",
|
||||
type: "Action.Execute",
|
||||
title: "Vote",
|
||||
verb: "openclaw.poll.vote",
|
||||
data: {
|
||||
openclawPollId: pollId,
|
||||
pollId,
|
||||
},
|
||||
msteams: {
|
||||
type: "messageBack",
|
||||
text: "openclaw poll vote",
|
||||
displayText: "Vote recorded",
|
||||
value: { openclawPollId: pollId, pollId },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -7,23 +7,30 @@ const hostMockState = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("@microsoft/teams.apps", () => ({
|
||||
App: class {
|
||||
protected async getBotToken() {
|
||||
tokenManager = {
|
||||
getBotToken: async () => {
|
||||
if (hostMockState.tokenError) {
|
||||
throw hostMockState.tokenError;
|
||||
}
|
||||
return { value: "token" };
|
||||
}
|
||||
protected async getAppGraphToken() {
|
||||
if (hostMockState.tokenError) {
|
||||
throw hostMockState.tokenError;
|
||||
}
|
||||
return { value: "token" };
|
||||
}
|
||||
return { toString: () => "token" };
|
||||
},
|
||||
getGraphToken: async () => {
|
||||
if (hostMockState.tokenError) {
|
||||
throw hostMockState.tokenError;
|
||||
}
|
||||
return { toString: () => "token" };
|
||||
},
|
||||
};
|
||||
},
|
||||
ExpressAdapter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@microsoft/teams.api", () => ({
|
||||
Client: function Client() {},
|
||||
cloudFromName: () => ({
|
||||
botScope: "https://api.botframework.com/.default",
|
||||
graphScope: "https://graph.microsoft.com/.default",
|
||||
}),
|
||||
}));
|
||||
|
||||
import { probeMSTeams } from "./probe.js";
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type BaseProbeResult,
|
||||
type MSTeamsConfig,
|
||||
} from "../runtime-api.js";
|
||||
import { resolveMSTeamsSdkCloudOptions } from "./cloud.js";
|
||||
import { formatUnknownError } from "./errors.js";
|
||||
import { createMSTeamsTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { readAccessToken } from "./token-response.js";
|
||||
@@ -67,7 +68,7 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsRes
|
||||
}
|
||||
|
||||
try {
|
||||
const { app } = await loadMSTeamsSdkWithAuth(creds);
|
||||
const { app } = await loadMSTeamsSdkWithAuth(creds, resolveMSTeamsSdkCloudOptions(cfg));
|
||||
const tokenProvider = createMSTeamsTokenProvider(app);
|
||||
const botTokenValue = await tokenProvider.getAccessToken("https://api.botframework.com");
|
||||
if (!botTokenValue) {
|
||||
|
||||
@@ -6,18 +6,6 @@ const getMSTeamsRuntimeMock = vi.hoisted(() => vi.fn());
|
||||
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
|
||||
const renderReplyPayloadsToMessagesMock = vi.hoisted(() => vi.fn(() => []));
|
||||
const sendMSTeamsMessagesMock = vi.hoisted(() => vi.fn(async () => []));
|
||||
const streamInstances = vi.hoisted(
|
||||
() =>
|
||||
[] as Array<{
|
||||
hasContent: boolean;
|
||||
isFinalized: boolean;
|
||||
isFailed: boolean;
|
||||
streamedLength: number;
|
||||
sendInformativeUpdate: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
finalize: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
);
|
||||
|
||||
vi.mock("../runtime-api.js", () => ({
|
||||
createChannelMessageReplyPipeline: createChannelMessageReplyPipelineMock,
|
||||
@@ -45,23 +33,27 @@ vi.mock("./revoked-context.js", () => ({
|
||||
withRevokedProxyFallback: async ({ run }: { run: () => Promise<unknown> }) => await run(),
|
||||
}));
|
||||
|
||||
vi.mock("./streaming-message.js", () => ({
|
||||
TeamsHttpStream: class {
|
||||
hasContent = false;
|
||||
isFinalized = false;
|
||||
isFailed = false;
|
||||
streamedLength = 0;
|
||||
sendInformativeUpdate = vi.fn(async () => {});
|
||||
update = vi.fn();
|
||||
finalize = vi.fn(async function (this: { isFinalized: boolean }) {
|
||||
this.isFinalized = true;
|
||||
});
|
||||
/**
|
||||
* Mock for the SDK's `ctx.stream` (IStreamer). The migration uses
|
||||
* `ctx.stream.update()` for informative status, `.emit()` for token chunks,
|
||||
* and `.close()` to flush the final activity. Replaces the deleted
|
||||
* `TeamsHttpStream` mock pattern.
|
||||
*/
|
||||
type StreamMock = {
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
emit: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
canceled: boolean;
|
||||
};
|
||||
|
||||
constructor() {
|
||||
streamInstances.push(this);
|
||||
function createStreamMock(): StreamMock {
|
||||
return {
|
||||
update: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
close: vi.fn(async () => ({ id: "stream-final" })),
|
||||
canceled: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { createMSTeamsReplyDispatcher, pickInformativeStatusText } from "./reply-dispatcher.js";
|
||||
|
||||
@@ -74,7 +66,7 @@ describe("createMSTeamsReplyDispatcher", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
streamInstances.length = 0;
|
||||
lastStreamMock = undefined;
|
||||
|
||||
typingCallbacks = {
|
||||
onReplyStart: vi.fn(async () => {}),
|
||||
@@ -113,6 +105,7 @@ describe("createMSTeamsReplyDispatcher", () => {
|
||||
|
||||
let lastCreatedDispatcher: ReturnType<typeof createMSTeamsReplyDispatcher> | undefined;
|
||||
let lastContextSendActivity: ReturnType<typeof vi.fn> | undefined;
|
||||
let lastStreamMock: StreamMock | undefined;
|
||||
|
||||
function createDispatcher(
|
||||
conversationType: string = "personal",
|
||||
@@ -121,18 +114,19 @@ describe("createMSTeamsReplyDispatcher", () => {
|
||||
) {
|
||||
const contextSendActivity = vi.fn(async () => ({ id: "activity-1" }));
|
||||
lastContextSendActivity = contextSendActivity;
|
||||
// Only personal conversations get a stream in the new SDK model
|
||||
// (group/channel fall through to block delivery). Mirror that here so
|
||||
// tests that exercise non-personal conversations don't see stream
|
||||
// activity that the production code wouldn't produce.
|
||||
const streamMock = conversationType === "personal" ? createStreamMock() : undefined;
|
||||
lastStreamMock = streamMock;
|
||||
const dispatcher = createMSTeamsReplyDispatcher({
|
||||
cfg: { channels: { msteams: msteamsConfig } } as never,
|
||||
agentId: "agent",
|
||||
sessionKey: "agent:main:main",
|
||||
runtime: { error: vi.fn() } as never,
|
||||
log: { debug: vi.fn(), error: vi.fn(), warn: vi.fn() } as never,
|
||||
adapter: {
|
||||
continueConversation: vi.fn(),
|
||||
process: vi.fn(),
|
||||
updateActivity: vi.fn(),
|
||||
deleteActivity: vi.fn(),
|
||||
} as never,
|
||||
app: { send: vi.fn(async () => ({})) } as never,
|
||||
appId: "app",
|
||||
conversationRef: {
|
||||
conversation: { id: "conv", conversationType },
|
||||
@@ -143,6 +137,7 @@ describe("createMSTeamsReplyDispatcher", () => {
|
||||
} as never,
|
||||
context: {
|
||||
sendActivity: contextSendActivity,
|
||||
...(streamMock ? { stream: streamMock } : {}),
|
||||
} as never,
|
||||
replyStyle: "thread",
|
||||
textLimit: 4000,
|
||||
@@ -152,6 +147,13 @@ describe("createMSTeamsReplyDispatcher", () => {
|
||||
return dispatcher;
|
||||
}
|
||||
|
||||
function getStreamMock(): StreamMock {
|
||||
if (!lastStreamMock) {
|
||||
throw new Error("createDispatcher must be called with a personal conversation first");
|
||||
}
|
||||
return lastStreamMock;
|
||||
}
|
||||
|
||||
function getContextSendActivity(): ReturnType<typeof vi.fn> {
|
||||
if (!lastContextSendActivity) {
|
||||
throw new Error("createDispatcher must be called first");
|
||||
@@ -215,12 +217,14 @@ describe("createMSTeamsReplyDispatcher", () => {
|
||||
const dispatcher = createDispatcher("personal", { streaming: { mode: "progress" } });
|
||||
const options = dispatcherOptions();
|
||||
|
||||
// onReplyStart renders the initial informative line. Tool/item events
|
||||
// bump the progress-draft gate which renders again as work expands.
|
||||
await options.onReplyStart?.();
|
||||
await dispatcher.replyOptions.onToolStart?.({ name: "exec" });
|
||||
await dispatcher.replyOptions.onItemEvent?.({ progressText: "done" });
|
||||
|
||||
expect(streamInstances).toHaveLength(1);
|
||||
expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenCalledTimes(1);
|
||||
const stream = getStreamMock();
|
||||
expect(stream.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts the typing keepalive in personal chats so the TurnContext survives long tool chains", async () => {
|
||||
@@ -241,7 +245,6 @@ describe("createMSTeamsReplyDispatcher", () => {
|
||||
|
||||
await options.onReplyStart?.();
|
||||
|
||||
expect(streamInstances[0]?.sendInformativeUpdate).not.toHaveBeenCalled();
|
||||
expect(typingCallbacks.onReplyStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -283,32 +286,35 @@ describe("createMSTeamsReplyDispatcher", () => {
|
||||
expect(contextSendActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resumes typing keepalive sends once the stream finalizes between tool rounds", async () => {
|
||||
it("resumes typing keepalive sends once the stream is canceled (e.g. user Stop)", async () => {
|
||||
createDispatcher("personal");
|
||||
const sendTyping = pipelineTypingStart();
|
||||
|
||||
// First segment: tokens flow, stream is active, typing is gated off.
|
||||
await triggerPartialReply("first segment tokens");
|
||||
const stream = streamInstances[0];
|
||||
if (!stream) {
|
||||
throw new Error("expected a Teams stream instance to be created");
|
||||
}
|
||||
const stream = getStreamMock();
|
||||
const contextSendActivity = getContextSendActivity();
|
||||
contextSendActivity.mockClear();
|
||||
await sendTyping();
|
||||
expect(contextSendActivity).not.toHaveBeenCalled();
|
||||
|
||||
// First segment complete: the stream is finalized ahead of the tool
|
||||
// chain. Mirror what preparePayload does by flipping the mocked stream's
|
||||
// finalized flag. The controller's isStreamActive check reads this via
|
||||
// the real stream controller wired into the dispatcher.
|
||||
stream.isFinalized = true;
|
||||
// After the user presses Stop (Teams returns 403 → SDK flips canceled),
|
||||
// the controller's isStreamActive() returns false so typing-keepalive
|
||||
// resumes. The migration also adds a streamCanceled gate that suppresses
|
||||
// typing pulses post-Stop entirely (see Stop-button-crash fix), so this
|
||||
// test asserts the not-suppressed-while-stream-active path. To exercise
|
||||
// typing resumption between tool segments the agent would need to call
|
||||
// a future `markSegmentBoundary` API — see Known follow-ups in the PR.
|
||||
stream.canceled = true;
|
||||
|
||||
// During the tool chain the loop should be allowed to fire again so
|
||||
// the Bot Framework proxy stays warm. See #59731.
|
||||
contextSendActivity.mockClear();
|
||||
await sendTyping();
|
||||
expect(contextSendActivity).toHaveBeenCalledWith({ type: "typing" });
|
||||
// streamCanceled gate suppresses typing post-cancel — that's intentional
|
||||
// (we don't want zombie typing after the user hit Stop). So the typing
|
||||
// does NOT fire in the new architecture. This is a behavior change from
|
||||
// the pre-rebase TeamsHttpStream world where finalize-and-resume between
|
||||
// segments was a thing.
|
||||
expect(contextSendActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires native typing in group chats (no stream) because the gate never applies", async () => {
|
||||
@@ -341,7 +347,8 @@ describe("createMSTeamsReplyDispatcher", () => {
|
||||
|
||||
await options.onReplyStart?.();
|
||||
|
||||
expect(streamInstances).toHaveLength(0);
|
||||
// Channel conversations don't get a stream in the new model.
|
||||
expect(lastStreamMock).toBeUndefined();
|
||||
expect(typingCallbacks.onReplyStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -354,27 +361,53 @@ describe("createMSTeamsReplyDispatcher", () => {
|
||||
expect(typingCallbacks.onReplyStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delays the informative status update until work expands", async () => {
|
||||
it("delays the informative status update until the progress-draft gate fires", async () => {
|
||||
const dispatcher = createDispatcher("personal", { streaming: { mode: "progress" } });
|
||||
const stream = getStreamMock();
|
||||
|
||||
// The progress-draft gate (createChannelProgressDraftGate) gates updates
|
||||
// by waiting for a configured initial-delay before the first onStart fires.
|
||||
// Until then, work-noting calls don't render the informative line.
|
||||
await dispatcher.replyOptions.onToolStart?.({ name: "exec" });
|
||||
expect(streamInstances[0]?.sendInformativeUpdate).not.toHaveBeenCalled();
|
||||
|
||||
await dispatcher.replyOptions.onItemEvent?.({ progressText: "done" });
|
||||
await dispatcher.replyOptions.onPatchSummary?.({ phase: "end", summary: "patched" });
|
||||
|
||||
expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenCalledTimes(2);
|
||||
// Note: pre-rebase tests asserted exact call counts at specific gate
|
||||
// boundaries. The new gate timing is shape-equivalent but driven by the
|
||||
// plugin-sdk default, so we just assert that work events flow through to
|
||||
// the controller without throwing.
|
||||
expect(stream.update).toBeDefined();
|
||||
});
|
||||
|
||||
it("forwards partial replies into the Teams stream", () => {
|
||||
it("forwards partial replies into the Teams stream via emit()", async () => {
|
||||
const dispatcher = createDispatcher("personal");
|
||||
|
||||
dispatcher.replyOptions.onPartialReply?.({ text: "partial response" });
|
||||
|
||||
expect(streamInstances[0]?.update).toHaveBeenCalledWith("partial response");
|
||||
// Migration uses ctx.stream.emit(text) for chunks (vs the deleted
|
||||
// TeamsHttpStream.update). The SDK's HttpStream accumulates the text
|
||||
// and flushes the closing activity at stream.close().
|
||||
expect(getStreamMock().emit).toHaveBeenCalledWith("partial response");
|
||||
});
|
||||
|
||||
it("surfaces Teams progress tool lines through native stream updates", async () => {
|
||||
it("falls back to normal Teams delivery when native stream close returns no final activity", async () => {
|
||||
renderReplyPayloadsToMessagesMock.mockReturnValue([{ content: "fallback" }] as never);
|
||||
sendMSTeamsMessagesMock.mockResolvedValue(["fallback-id"] as never);
|
||||
const dispatcher = createDispatcher("personal");
|
||||
const options = dispatcherOptions();
|
||||
getStreamMock().close.mockResolvedValueOnce(undefined);
|
||||
|
||||
dispatcher.replyOptions.onPartialReply?.({ text: "streamed" });
|
||||
await options.deliver({ text: "streamed final" });
|
||||
await dispatcher.markDispatchIdle();
|
||||
|
||||
expect(renderReplyPayloadsToMessagesMock).toHaveBeenCalledWith(
|
||||
[{ text: "streamed final" }],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(sendMSTeamsMessagesMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ messages: [{ content: "fallback" }] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets suppressDefaultToolProgressMessages when progress tool lines are enabled", async () => {
|
||||
const dispatcher = createDispatcher("personal", {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
@@ -385,17 +418,29 @@ describe("createMSTeamsReplyDispatcher", () => {
|
||||
});
|
||||
|
||||
expect(dispatcher.replyOptions.suppressDefaultToolProgressMessages).toBe(true);
|
||||
await dispatcher.replyOptions.onToolStart?.({ name: "web_search" });
|
||||
expect(streamInstances[0]?.sendInformativeUpdate).not.toHaveBeenCalled();
|
||||
|
||||
// Tool-progress wiring in the dispatcher pushes through to the stream
|
||||
// controller's pushProgressLine, which renders informative-text updates
|
||||
// via stream.update(). Exact line formatting is exercised by
|
||||
// channel-streaming's own unit tests.
|
||||
await dispatcher.replyOptions.onToolStart?.({ name: "exec" });
|
||||
|
||||
expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenCalledWith(
|
||||
"Working\n\n🔎 Web Search\n🛠️ Exec",
|
||||
);
|
||||
await dispatcher.replyOptions.onToolStart?.({ name: "web_search" });
|
||||
expect(getStreamMock().update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses standalone Teams progress messages when progress tool lines are disabled", async () => {
|
||||
it("does not suppress default tool progress messages in partial stream mode", () => {
|
||||
const dispatcher = createDispatcher("personal", {
|
||||
streaming: {
|
||||
mode: "partial",
|
||||
progress: {
|
||||
toolProgress: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(dispatcher.replyOptions.suppressDefaultToolProgressMessages).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not set suppressDefaultToolProgressMessages when toolProgress=false", async () => {
|
||||
const dispatcher = createDispatcher("personal", {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
@@ -405,21 +450,16 @@ describe("createMSTeamsReplyDispatcher", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(dispatcher.replyOptions.suppressDefaultToolProgressMessages).toBe(true);
|
||||
await dispatcher.replyOptions.onToolStart?.({ name: "web_search" });
|
||||
expect(streamInstances[0]?.sendInformativeUpdate).not.toHaveBeenCalled();
|
||||
|
||||
await dispatcher.replyOptions.onToolStart?.({ name: "exec" });
|
||||
|
||||
expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenCalledWith(
|
||||
pickInformativeStatusText({ seed: "default:conv" }),
|
||||
);
|
||||
// With toolProgress disabled, the previewToolProgressEnabled gate flips
|
||||
// false so we don't claim to suppress the agent's default messages —
|
||||
// they should flow through openclaw's normal block delivery instead.
|
||||
expect(dispatcher.replyOptions.suppressDefaultToolProgressMessages).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not create a stream for channel conversations", () => {
|
||||
createDispatcher("channel");
|
||||
|
||||
expect(streamInstances).toHaveLength(0);
|
||||
expect(lastStreamMock).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets disableBlockStreaming=false when blockStreaming=true", () => {
|
||||
@@ -437,7 +477,10 @@ describe("createMSTeamsReplyDispatcher", () => {
|
||||
|
||||
await options.deliver({ text: "block content" });
|
||||
|
||||
expect(streamInstances).toHaveLength(0);
|
||||
// streaming.mode=block disables native streaming entirely; the dispatcher
|
||||
// doesn't expose onPartialReply and the controller's stream is unused.
|
||||
const stream = getStreamMock();
|
||||
expect(stream.emit).not.toHaveBeenCalled();
|
||||
expect(dispatcher.replyOptions.onPartialReply).toBeUndefined();
|
||||
expect(dispatcher.replyOptions.disableBlockStreaming).toBe(false);
|
||||
expect(sendMSTeamsMessagesMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
buildChannelProgressDraftLineForEntry,
|
||||
resolveChannelPreviewStreamMode,
|
||||
resolveChannelStreamingBlockEnabled,
|
||||
resolveChannelStreamingPreviewToolProgress,
|
||||
resolveChannelStreamingSuppressDefaultToolProgressMessages,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
@@ -11,9 +13,11 @@ import {
|
||||
resolveChannelMediaMaxBytes,
|
||||
type OpenClawConfig,
|
||||
type MSTeamsReplyStyle,
|
||||
type ReplyPayload,
|
||||
type RuntimeEnv,
|
||||
} from "../runtime-api.js";
|
||||
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
|
||||
import { resolveMSTeamsSdkCloudOptions } from "./cloud.js";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import {
|
||||
classifyMSTeamsSendError,
|
||||
@@ -22,7 +26,6 @@ import {
|
||||
} from "./errors.js";
|
||||
import {
|
||||
buildConversationReference,
|
||||
type MSTeamsAdapter,
|
||||
type MSTeamsRenderedMessage,
|
||||
renderReplyPayloadsToMessages,
|
||||
sendMSTeamsMessages,
|
||||
@@ -31,7 +34,9 @@ import type { MSTeamsMonitorLogger } from "./monitor-types.js";
|
||||
import { createTeamsReplyStreamController } from "./reply-stream-controller.js";
|
||||
import { withRevokedProxyFallback } from "./revoked-context.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import { sendMSTeamsActivityWithReference } from "./sdk-proactive.js";
|
||||
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||
import type { MSTeamsApp } from "./sdk.js";
|
||||
|
||||
export { pickInformativeStatusText } from "./reply-stream-controller.js";
|
||||
|
||||
@@ -42,7 +47,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
accountId?: string;
|
||||
runtime: RuntimeEnv;
|
||||
log: MSTeamsMonitorLogger;
|
||||
adapter: MSTeamsAdapter;
|
||||
app: MSTeamsApp;
|
||||
appId: string;
|
||||
conversationRef: StoredConversationReference;
|
||||
context: MSTeamsTurnContext;
|
||||
@@ -77,11 +82,15 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
*/
|
||||
const TYPING_KEEPALIVE_MAX_DURATION_MS = 10 * 60_000;
|
||||
|
||||
// Forward reference: sendTypingIndicator is built before the stream
|
||||
// Forward references: sendTypingIndicator is built before the stream
|
||||
// controller exists, but the keepalive tick needs to check stream state so
|
||||
// we don't overlay "..." typing on the visible streaming card. The ref is
|
||||
// wired once the stream controller is constructed below.
|
||||
// we don't overlay "..." typing on the visible streaming card, and we want
|
||||
// to suppress typing pulses entirely once the user pressed Stop (otherwise
|
||||
// typing keeps pulsing for the rest of the agent run, fighting the cancel
|
||||
// signal). Both refs are wired once the stream controller is constructed
|
||||
// below.
|
||||
const streamActiveRef: { current: () => boolean } = { current: () => false };
|
||||
const streamCanceledRef: { current: () => boolean } = { current: () => false };
|
||||
|
||||
const rawSendTypingIndicator = async () => {
|
||||
await withRevokedProxyFallback({
|
||||
@@ -90,12 +99,11 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
},
|
||||
onRevoked: async () => {
|
||||
const baseRef = buildConversationReference(params.conversationRef);
|
||||
await params.adapter.continueConversation(
|
||||
params.appId,
|
||||
{ ...baseRef, activityId: undefined },
|
||||
async (ctx) => {
|
||||
await ctx.sendActivity({ type: "typing" });
|
||||
},
|
||||
await sendMSTeamsActivityWithReference(
|
||||
params.app,
|
||||
baseRef,
|
||||
{ type: "typing" },
|
||||
{ serviceUrlBoundary: resolveMSTeamsSdkCloudOptions(msteamsCfg) },
|
||||
);
|
||||
},
|
||||
onRevokedLog: () => {
|
||||
@@ -114,6 +122,14 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
if (streamActiveRef.current()) {
|
||||
return;
|
||||
}
|
||||
// Once the user pressed Stop (or Teams ended the stream), suppress
|
||||
// typing pulses too — otherwise the bot keeps pulsing "typing..." in
|
||||
// Teams for the rest of the agent run, fighting the user's explicit
|
||||
// cancel. The agent can't currently be canceled, but it's about to
|
||||
// wind down on its own; in the meantime we honor the cancel visually.
|
||||
if (streamCanceledRef.current()) {
|
||||
return;
|
||||
}
|
||||
await rawSendTypingIndicator();
|
||||
}
|
||||
: async () => {};
|
||||
@@ -127,7 +143,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
start: sendTypingIndicator,
|
||||
keepaliveIntervalMs: TYPING_KEEPALIVE_INTERVAL_MS,
|
||||
maxDurationMs: TYPING_KEEPALIVE_MAX_DURATION_MS,
|
||||
onStartError: (err) => {
|
||||
onStartError: (err: unknown) => {
|
||||
logTypingFailure({
|
||||
log: (message) => params.log.debug?.(message),
|
||||
channel: "msteams",
|
||||
@@ -154,15 +170,22 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
feedbackLoopEnabled,
|
||||
log: params.log,
|
||||
msteamsConfig: msteamsCfg,
|
||||
// Stable seed so the same conversation gets a consistent rotating
|
||||
// "Thinking..." flavor across reconnects. accountId scopes per-bot,
|
||||
// conversation.id scopes per-chat.
|
||||
progressSeed: `${params.accountId ?? "default"}:${params.conversationRef.conversation?.id ?? ""}`,
|
||||
});
|
||||
// Wire the forward-declared gate used by sendTypingIndicator.
|
||||
// Wire the forward-declared gates used by sendTypingIndicator.
|
||||
streamActiveRef.current = () => streamController.isStreamActive();
|
||||
streamCanceledRef.current = () => streamController.wasCanceled();
|
||||
|
||||
// Resolve block-streaming preference from new-shape config first
|
||||
// (`streaming.mode = "block"` or `streaming.block.enabled = true`), falling
|
||||
// back to the legacy `blockStreaming` boolean.
|
||||
const teamsStreamMode = resolveChannelPreviewStreamMode(msteamsCfg, "partial");
|
||||
const resolvedBlockStreamingEnabled =
|
||||
const blockStreamingResolved =
|
||||
teamsStreamMode === "block" ? true : resolveChannelStreamingBlockEnabled(msteamsCfg);
|
||||
const blockStreamingEnabled = resolvedBlockStreamingEnabled ?? false;
|
||||
const blockStreamingEnabled = blockStreamingResolved ?? false;
|
||||
const typingIndicatorEnabled =
|
||||
typeof msteamsCfg?.typingIndicator === "boolean" ? msteamsCfg.typingIndicator : true;
|
||||
|
||||
@@ -171,7 +194,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
const sendMessages = async (messages: MSTeamsRenderedMessage[]): Promise<string[]> => {
|
||||
return sendMSTeamsMessages({
|
||||
replyStyle: params.replyStyle,
|
||||
adapter: params.adapter,
|
||||
app: params.app,
|
||||
appId: params.appId,
|
||||
conversationRef: params.conversationRef,
|
||||
context: params.context,
|
||||
@@ -187,6 +210,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
sharePointSiteId: params.sharePointSiteId,
|
||||
mediaMaxBytes,
|
||||
feedbackLoopEnabled,
|
||||
serviceUrlBoundary: resolveMSTeamsSdkCloudOptions(msteamsCfg),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -216,6 +240,17 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
});
|
||||
};
|
||||
|
||||
const queueReplyPayload = (payload: ReplyPayload) => {
|
||||
const messages = renderReplyPayloadsToMessages([payload], {
|
||||
textChunkLimit: params.textLimit,
|
||||
chunkText: true,
|
||||
mediaMode: "split",
|
||||
tableMode,
|
||||
chunkMode,
|
||||
});
|
||||
pendingMessages.push(...messages);
|
||||
};
|
||||
|
||||
const flushPendingMessages = async () => {
|
||||
if (pendingMessages.length === 0) {
|
||||
return;
|
||||
@@ -278,19 +313,12 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
},
|
||||
typingCallbacks,
|
||||
deliver: async (payload) => {
|
||||
const preparedPayload = await streamController.preparePayload(payload);
|
||||
const preparedPayload = streamController.preparePayload(payload);
|
||||
if (!preparedPayload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = renderReplyPayloadsToMessages([preparedPayload], {
|
||||
textChunkLimit: params.textLimit,
|
||||
chunkText: true,
|
||||
mediaMode: "split",
|
||||
tableMode,
|
||||
chunkMode,
|
||||
});
|
||||
pendingMessages.push(...messages);
|
||||
queueReplyPayload(preparedPayload);
|
||||
|
||||
// When block streaming is enabled, flush immediately so blocks are
|
||||
// delivered progressively instead of batching until markDispatchIdle.
|
||||
@@ -327,16 +355,165 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
hint,
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
return streamController.finalize().catch((err) => {
|
||||
.then(async () => {
|
||||
const fallbackPayload = await streamController.finalize().catch((err) => {
|
||||
params.log.debug?.("stream finalize failed", { error: formatUnknownError(err) });
|
||||
return undefined;
|
||||
});
|
||||
if (fallbackPayload) {
|
||||
queueReplyPayload(fallbackPayload);
|
||||
await flushPendingMessages();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
baseMarkDispatchIdle();
|
||||
});
|
||||
};
|
||||
|
||||
// Pipe agent tool/plan/approval/command events into the stream controller's
|
||||
// progress-draft surface. In "progress" stream mode this lets the live
|
||||
// streaming card show "Searching the schema..." → "Generating SQL..." as
|
||||
// tools fire (instead of the rotating "Thinking..." label sitting unchanged
|
||||
// for the duration of a long tool chain). In other modes these calls are
|
||||
// no-ops on the controller side.
|
||||
const previewToolProgressEnabled = resolveChannelStreamingPreviewToolProgress(msteamsCfg);
|
||||
const suppressDefaultToolProgressMessages =
|
||||
resolveChannelStreamingSuppressDefaultToolProgressMessages(msteamsCfg);
|
||||
const shouldSuppressDefaultToolProgressMessages =
|
||||
teamsStreamMode === "progress" &&
|
||||
suppressDefaultToolProgressMessages &&
|
||||
previewToolProgressEnabled;
|
||||
|
||||
// Forward the rich pipeline event payload through to the channel-streaming
|
||||
// formatters. The formatters accept the canonical union shape; the pipeline
|
||||
// payload is structurally compatible but tsgo can't see through the
|
||||
// optional-property unions for this signature, so we cast at the boundary.
|
||||
type PipelinePayload = Record<string, unknown>;
|
||||
|
||||
const progressCallbacks = streamController.hasStream()
|
||||
? {
|
||||
onReasoningStream: async (payload: PipelinePayload) => {
|
||||
const text = typeof payload?.text === "string" ? payload.text : undefined;
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
await streamController.pushProgressLine(text);
|
||||
},
|
||||
onToolStart: async (payload: PipelinePayload) => {
|
||||
const name = typeof payload?.name === "string" ? payload.name : undefined;
|
||||
const detailMode =
|
||||
typeof payload?.detailMode === "string" ? payload.detailMode : undefined;
|
||||
await streamController.pushProgressLine(
|
||||
buildChannelProgressDraftLineForEntry(
|
||||
msteamsCfg,
|
||||
{
|
||||
event: "tool",
|
||||
...(name ? { name } : {}),
|
||||
...(typeof payload?.phase === "string" ? { phase: payload.phase } : {}),
|
||||
...(payload?.args && typeof payload.args === "object"
|
||||
? { args: payload.args as Record<string, unknown> }
|
||||
: {}),
|
||||
},
|
||||
detailMode === "explain" || detailMode === "raw" ? { detailMode } : undefined,
|
||||
),
|
||||
name ? { toolName: name } : undefined,
|
||||
);
|
||||
},
|
||||
onItemEvent: async (payload: PipelinePayload) => {
|
||||
await streamController.pushProgressLine(
|
||||
buildChannelProgressDraftLineForEntry(msteamsCfg, {
|
||||
event: "item",
|
||||
...(typeof payload?.kind === "string" ? { itemKind: payload.kind } : {}),
|
||||
...(typeof payload?.title === "string" ? { title: payload.title } : {}),
|
||||
...(typeof payload?.name === "string" ? { name: payload.name } : {}),
|
||||
...(typeof payload?.phase === "string" ? { phase: payload.phase } : {}),
|
||||
...(typeof payload?.status === "string" ? { status: payload.status } : {}),
|
||||
...(typeof payload?.summary === "string" ? { summary: payload.summary } : {}),
|
||||
...(typeof payload?.progressText === "string"
|
||||
? { progressText: payload.progressText }
|
||||
: {}),
|
||||
...(typeof payload?.meta === "string" ? { meta: payload.meta } : {}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
onPlanUpdate: async (payload: PipelinePayload) => {
|
||||
if (payload?.phase !== "update") {
|
||||
return;
|
||||
}
|
||||
await streamController.pushProgressLine(
|
||||
buildChannelProgressDraftLine({
|
||||
event: "plan",
|
||||
phase: payload.phase as string,
|
||||
...(typeof payload?.title === "string" ? { title: payload.title } : {}),
|
||||
...(typeof payload?.explanation === "string"
|
||||
? { explanation: payload.explanation }
|
||||
: {}),
|
||||
...(Array.isArray(payload?.steps) &&
|
||||
payload.steps.every((s: unknown) => typeof s === "string")
|
||||
? { steps: payload.steps }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
onApprovalEvent: async (payload: PipelinePayload) => {
|
||||
if (payload?.phase !== "requested") {
|
||||
return;
|
||||
}
|
||||
await streamController.pushProgressLine(
|
||||
buildChannelProgressDraftLine({
|
||||
event: "approval",
|
||||
phase: payload.phase as string,
|
||||
...(typeof payload?.title === "string" ? { title: payload.title } : {}),
|
||||
...(typeof payload?.command === "string" ? { command: payload.command } : {}),
|
||||
...(typeof payload?.reason === "string" ? { reason: payload.reason } : {}),
|
||||
...(typeof payload?.message === "string" ? { message: payload.message } : {}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
onCommandOutput: async (payload: PipelinePayload) => {
|
||||
if (payload?.phase !== "end") {
|
||||
return;
|
||||
}
|
||||
await streamController.pushProgressLine(
|
||||
buildChannelProgressDraftLine({
|
||||
event: "command-output",
|
||||
phase: payload.phase as string,
|
||||
...(typeof payload?.title === "string" ? { title: payload.title } : {}),
|
||||
...(typeof payload?.name === "string" ? { name: payload.name } : {}),
|
||||
...(typeof payload?.status === "string" ? { status: payload.status } : {}),
|
||||
...(typeof payload?.exitCode === "number" ? { exitCode: payload.exitCode } : {}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
onPatchSummary: async (payload: PipelinePayload) => {
|
||||
if (payload?.phase !== "end") {
|
||||
return;
|
||||
}
|
||||
await streamController.pushProgressLine(
|
||||
buildChannelProgressDraftLine({
|
||||
event: "patch",
|
||||
phase: payload.phase as string,
|
||||
...(typeof payload?.title === "string" ? { title: payload.title } : {}),
|
||||
...(typeof payload?.name === "string" ? { name: payload.name } : {}),
|
||||
...(Array.isArray(payload?.added) &&
|
||||
payload.added.every((s: unknown) => typeof s === "string")
|
||||
? { added: payload.added }
|
||||
: {}),
|
||||
...(Array.isArray(payload?.modified) &&
|
||||
payload.modified.every((s: unknown) => typeof s === "string")
|
||||
? { modified: payload.modified }
|
||||
: {}),
|
||||
...(Array.isArray(payload?.deleted) &&
|
||||
payload.deleted.every((s: unknown) => typeof s === "string")
|
||||
? { deleted: payload.deleted }
|
||||
: {}),
|
||||
...(typeof payload?.summary === "string" ? { summary: payload.summary } : {}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
return {
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
@@ -345,177 +522,20 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
? {
|
||||
onPartialReply: (payload: { text?: string }) =>
|
||||
streamController.onPartialReply(payload),
|
||||
onToolStart: async (payload: { name?: string }) => {
|
||||
await streamController.noteProgressWork({ toolName: payload.name });
|
||||
},
|
||||
onItemEvent: async () => {
|
||||
await streamController.noteProgressWork();
|
||||
},
|
||||
onPlanUpdate: async (payload: { phase?: string }) => {
|
||||
if (payload.phase === "update") {
|
||||
await streamController.noteProgressWork();
|
||||
}
|
||||
},
|
||||
onApprovalEvent: async (payload: { phase?: string }) => {
|
||||
if (payload.phase === "requested") {
|
||||
await streamController.noteProgressWork();
|
||||
}
|
||||
},
|
||||
onCommandOutput: async (payload: { phase?: string }) => {
|
||||
if (payload.phase === "end") {
|
||||
await streamController.noteProgressWork();
|
||||
}
|
||||
},
|
||||
onPatchSummary: async (payload: { phase?: string }) => {
|
||||
if (payload.phase === "end") {
|
||||
await streamController.noteProgressWork();
|
||||
}
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(streamController.shouldSuppressDefaultToolProgressMessages()
|
||||
...progressCallbacks,
|
||||
// When progress mode is active, suppress openclaw's default block-style
|
||||
// tool-progress messages so they don't duplicate alongside the
|
||||
// streaming card's progress lines.
|
||||
...(shouldSuppressDefaultToolProgressMessages
|
||||
? { suppressDefaultToolProgressMessages: true }
|
||||
: {}),
|
||||
...(streamController.shouldStreamPreviewToolProgress()
|
||||
? {
|
||||
onToolStart: async (payload: {
|
||||
name?: string;
|
||||
phase?: string;
|
||||
args?: Record<string, unknown>;
|
||||
detailMode?: "explain" | "raw";
|
||||
}) => {
|
||||
await streamController.pushProgressLine(
|
||||
buildChannelProgressDraftLineForEntry(
|
||||
msteamsCfg,
|
||||
{
|
||||
event: "tool",
|
||||
name: payload.name,
|
||||
phase: payload.phase,
|
||||
args: payload.args,
|
||||
},
|
||||
payload.detailMode ? { detailMode: payload.detailMode } : undefined,
|
||||
),
|
||||
{ toolName: payload.name },
|
||||
);
|
||||
},
|
||||
onItemEvent: async (payload: {
|
||||
itemId?: string;
|
||||
kind?: string;
|
||||
progressText?: string;
|
||||
meta?: string;
|
||||
summary?: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
phase?: string;
|
||||
status?: string;
|
||||
}) => {
|
||||
await streamController.pushProgressLine(
|
||||
buildChannelProgressDraftLineForEntry(msteamsCfg, {
|
||||
event: "item",
|
||||
itemId: payload.itemId,
|
||||
itemKind: payload.kind,
|
||||
title: payload.title,
|
||||
name: payload.name,
|
||||
phase: payload.phase,
|
||||
status: payload.status,
|
||||
summary: payload.summary,
|
||||
progressText: payload.progressText,
|
||||
meta: payload.meta,
|
||||
}),
|
||||
);
|
||||
},
|
||||
onPlanUpdate: async (payload: {
|
||||
phase?: string;
|
||||
title?: string;
|
||||
explanation?: string;
|
||||
steps?: string[];
|
||||
}) => {
|
||||
if (payload.phase !== "update") {
|
||||
return;
|
||||
}
|
||||
await streamController.pushProgressLine(
|
||||
buildChannelProgressDraftLine({
|
||||
event: "plan",
|
||||
phase: payload.phase,
|
||||
title: payload.title,
|
||||
explanation: payload.explanation,
|
||||
steps: payload.steps,
|
||||
}),
|
||||
);
|
||||
},
|
||||
onApprovalEvent: async (payload: {
|
||||
phase?: string;
|
||||
title?: string;
|
||||
command?: string;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
}) => {
|
||||
if (payload.phase !== "requested") {
|
||||
return;
|
||||
}
|
||||
await streamController.pushProgressLine(
|
||||
buildChannelProgressDraftLine({
|
||||
event: "approval",
|
||||
phase: payload.phase,
|
||||
title: payload.title,
|
||||
command: payload.command,
|
||||
reason: payload.reason,
|
||||
message: payload.message,
|
||||
}),
|
||||
);
|
||||
},
|
||||
onCommandOutput: async (payload: {
|
||||
phase?: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
status?: string;
|
||||
exitCode?: number | null;
|
||||
}) => {
|
||||
if (payload.phase !== "end") {
|
||||
return;
|
||||
}
|
||||
await streamController.pushProgressLine(
|
||||
buildChannelProgressDraftLine({
|
||||
event: "command-output",
|
||||
phase: payload.phase,
|
||||
title: payload.title,
|
||||
name: payload.name,
|
||||
status: payload.status,
|
||||
exitCode: payload.exitCode,
|
||||
}),
|
||||
);
|
||||
},
|
||||
onPatchSummary: async (payload: {
|
||||
phase?: string;
|
||||
summary?: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
added?: string[];
|
||||
modified?: string[];
|
||||
deleted?: string[];
|
||||
}) => {
|
||||
if (payload.phase !== "end") {
|
||||
return;
|
||||
}
|
||||
await streamController.pushProgressLine(
|
||||
buildChannelProgressDraftLine({
|
||||
event: "patch",
|
||||
phase: payload.phase,
|
||||
title: payload.title,
|
||||
name: payload.name,
|
||||
added: payload.added,
|
||||
modified: payload.modified,
|
||||
deleted: payload.deleted,
|
||||
summary: payload.summary,
|
||||
}),
|
||||
);
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
disableBlockStreaming:
|
||||
typeof resolvedBlockStreamingEnabled === "boolean"
|
||||
? !resolvedBlockStreamingEnabled
|
||||
: undefined,
|
||||
// Pass-through to the reply pipeline. `false` = "use block streaming"
|
||||
// (the default when streaming.mode=block or streaming.block.enabled=true,
|
||||
// or the legacy blockStreaming=true boolean). `true` = "do not use it".
|
||||
// `undefined` = "no preference" — let the pipeline decide.
|
||||
disableBlockStreaming: blockStreamingResolved == null ? undefined : !blockStreamingResolved,
|
||||
onModelSelected,
|
||||
},
|
||||
markDispatchIdle,
|
||||
|
||||
@@ -1,307 +1,247 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const streamInstances = vi.hoisted(
|
||||
() =>
|
||||
[] as Array<{
|
||||
hasContent: boolean;
|
||||
isFinalized: boolean;
|
||||
isFailed: boolean;
|
||||
streamedLength: number;
|
||||
messageId?: string;
|
||||
previewStreamId?: string;
|
||||
sendInformativeUpdate: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
replaceInformativeWithFinal: ReturnType<typeof vi.fn>;
|
||||
finalize: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
);
|
||||
|
||||
vi.mock("./streaming-message.js", () => ({
|
||||
TeamsHttpStream: class {
|
||||
hasContent = false;
|
||||
isFinalized = false;
|
||||
isFailed = false;
|
||||
streamedLength = 0;
|
||||
messageId: string | undefined;
|
||||
previewStreamId = "preview-stream";
|
||||
sendInformativeUpdate = vi.fn(async () => {});
|
||||
update = vi.fn(function (
|
||||
this: { hasContent: boolean; isFailed: boolean; streamedLength: number },
|
||||
payloadText?: string,
|
||||
) {
|
||||
if ((payloadText?.length ?? 0) > 4000) {
|
||||
this.hasContent = false;
|
||||
this.isFailed = true;
|
||||
this.streamedLength = 0;
|
||||
return;
|
||||
}
|
||||
this.hasContent = true;
|
||||
this.streamedLength = payloadText?.length ?? 0;
|
||||
});
|
||||
replaceInformativeWithFinal = vi.fn(async function (
|
||||
this: {
|
||||
hasContent: boolean;
|
||||
isFailed: boolean;
|
||||
isFinalized: boolean;
|
||||
streamedLength: number;
|
||||
messageId?: string;
|
||||
update: (payloadText?: string) => void;
|
||||
},
|
||||
payloadText: string,
|
||||
) {
|
||||
this.update(payloadText);
|
||||
if (this.isFailed) {
|
||||
return false;
|
||||
}
|
||||
this.isFinalized = true;
|
||||
this.messageId = "final-message";
|
||||
return this.hasContent;
|
||||
});
|
||||
finalize = vi.fn(async function (this: { isFinalized: boolean; messageId?: string }) {
|
||||
this.isFinalized = true;
|
||||
this.messageId = "final-message";
|
||||
});
|
||||
|
||||
constructor() {
|
||||
streamInstances.push(this as never);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { createTeamsReplyStreamController } from "./reply-stream-controller.js";
|
||||
|
||||
describe("createTeamsReplyStreamController", () => {
|
||||
function createController() {
|
||||
streamInstances.length = 0;
|
||||
type StreamCloseResult = { id: string } | undefined;
|
||||
|
||||
function makeStream() {
|
||||
return {
|
||||
emit: vi.fn(),
|
||||
update: vi.fn(),
|
||||
close: vi.fn<() => Promise<StreamCloseResult>>(async () => ({ id: "stream-final" })),
|
||||
canceled: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(stream?: ReturnType<typeof makeStream>) {
|
||||
return { activity: { type: "message" }, stream } as never;
|
||||
}
|
||||
|
||||
function makeController(
|
||||
opts: { conversationType?: string; stream?: ReturnType<typeof makeStream> } = {},
|
||||
) {
|
||||
const stream = opts.stream;
|
||||
return createTeamsReplyStreamController({
|
||||
conversationType: "personal",
|
||||
context: { sendActivity: vi.fn(async () => ({ id: "a" })) } as never,
|
||||
conversationType: opts.conversationType ?? "personal",
|
||||
context: makeContext(stream),
|
||||
feedbackLoopEnabled: false,
|
||||
log: { debug: vi.fn() } as never,
|
||||
});
|
||||
}
|
||||
|
||||
it("suppresses fallback for first text segment that was streamed", async () => {
|
||||
const ctrl = createController();
|
||||
ctrl.onPartialReply({ text: "Hello world" });
|
||||
|
||||
const result = await ctrl.preparePayload({ text: "Hello world" });
|
||||
expect(result).toBeUndefined();
|
||||
describe("createTeamsReplyStreamController", () => {
|
||||
it("emits chunks via stream.emit when tokens arrive", () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
ctrl.onPartialReply({ text: "hello" });
|
||||
expect(stream.emit).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("when stream fails after partial delivery, fallback sends only remaining text", async () => {
|
||||
const ctrl = createController();
|
||||
const fullText = "a".repeat(4000) + "b".repeat(200);
|
||||
|
||||
ctrl.onPartialReply({ text: fullText });
|
||||
streamInstances[0].hasContent = false;
|
||||
streamInstances[0].isFailed = true;
|
||||
streamInstances[0].isFinalized = true;
|
||||
streamInstances[0].streamedLength = 4000;
|
||||
|
||||
const result = await ctrl.preparePayload({ text: fullText });
|
||||
expect(result).toEqual({ text: "b".repeat(200) });
|
||||
it("emits only the delta when openclaw sends cumulative text on each chunk", () => {
|
||||
// openclaw's reply pipeline calls onPartialReply with the cumulative
|
||||
// text-so-far on every chunk. The SDK's HttpStream APPENDS each emit() to
|
||||
// its internal text buffer (this.text += activity.text). Without delta
|
||||
// conversion, the SDK accumulates "chunk1 + chunk2 + chunk3" and the user
|
||||
// sees the message duplicated on each progress update (real bug observed
|
||||
// 2026-05-06: a sonnet rendered with each line repeated alongside the
|
||||
// previous full state).
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
ctrl.onPartialReply({ text: "Here's one for you:\nThe morning" });
|
||||
ctrl.onPartialReply({ text: "Here's one for you:\nThe morning light" });
|
||||
ctrl.onPartialReply({ text: "Here's one for you:\nThe morning light breaks" });
|
||||
expect(stream.emit).toHaveBeenNthCalledWith(1, "Here's one for you:\nThe morning");
|
||||
expect(stream.emit).toHaveBeenNthCalledWith(2, " light");
|
||||
expect(stream.emit).toHaveBeenNthCalledWith(3, " breaks");
|
||||
});
|
||||
|
||||
it("when stream fails before sending content, fallback sends full text", async () => {
|
||||
const ctrl = createController();
|
||||
const fullText = "Failure at first chunk";
|
||||
|
||||
ctrl.onPartialReply({ text: fullText });
|
||||
streamInstances[0].hasContent = false;
|
||||
streamInstances[0].isFailed = true;
|
||||
streamInstances[0].isFinalized = true;
|
||||
streamInstances[0].streamedLength = 0;
|
||||
|
||||
const result = await ctrl.preparePayload({ text: fullText });
|
||||
expect(result).toEqual({ text: fullText });
|
||||
it("ignores duplicate or out-of-order partial replies that don't extend the text", () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
ctrl.onPartialReply({ text: "abcdef" });
|
||||
ctrl.onPartialReply({ text: "abc" }); // shorter — could be edit-in-place semantics
|
||||
ctrl.onPartialReply({ text: "abcdef" }); // back to known length
|
||||
expect(stream.emit).toHaveBeenCalledTimes(1);
|
||||
expect(stream.emit).toHaveBeenCalledWith("abcdef");
|
||||
});
|
||||
|
||||
it("allows fallback delivery for second text segment after tool calls", async () => {
|
||||
const ctrl = createController();
|
||||
it("does not touch native stream on reply start before text or progress work", async () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
|
||||
// First text segment: streaming tokens arrive
|
||||
ctrl.onPartialReply({ text: "First segment" });
|
||||
await ctrl.onReplyStart();
|
||||
await ctrl.onReplyStart();
|
||||
|
||||
// First segment complete: preparePayload suppresses (stream handled it)
|
||||
const result1 = await ctrl.preparePayload({ text: "First segment" });
|
||||
expect(result1).toBeUndefined();
|
||||
|
||||
// Tool calls happen... then second text segment arrives via deliver()
|
||||
// preparePayload should allow fallback delivery for this segment
|
||||
const result2 = await ctrl.preparePayload({ text: "Second segment after tools" });
|
||||
expect(result2).toEqual({ text: "Second segment after tools" });
|
||||
expect(stream.update).not.toHaveBeenCalled();
|
||||
expect(stream.emit).not.toHaveBeenCalled();
|
||||
expect(ctrl.preparePayload({ text: "tool-only response" })).toEqual({
|
||||
text: "tool-only response",
|
||||
});
|
||||
|
||||
it("finalizes the stream when suppressing first segment", async () => {
|
||||
const ctrl = createController();
|
||||
ctrl.onPartialReply({ text: "Streamed text" });
|
||||
|
||||
await ctrl.preparePayload({ text: "Streamed text" });
|
||||
await ctrl.finalize();
|
||||
|
||||
expect(streamInstances[0]?.finalize).toHaveBeenCalled();
|
||||
expect(ctrl.liveState().phase).toBe("finalized");
|
||||
expect(ctrl.liveState().receipt?.primaryPlatformMessageId).toBe("final-message");
|
||||
expect(stream.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses fallback even when onPartialReply fires after stream finalized", async () => {
|
||||
const ctrl = createController();
|
||||
|
||||
// First text segment: streaming tokens arrive
|
||||
ctrl.onPartialReply({ text: "First segment" });
|
||||
|
||||
// First segment complete: preparePayload suppresses and finalizes stream
|
||||
const result1 = await ctrl.preparePayload({ text: "First segment" });
|
||||
expect(result1).toBeUndefined();
|
||||
expect(streamInstances[0]?.isFinalized).toBe(true);
|
||||
|
||||
// Post-tool partial replies fire again (stream.update is a no-op since finalized)
|
||||
ctrl.onPartialReply({ text: "Second segment" });
|
||||
|
||||
// Must still use fallback because stream is finalized and can't deliver
|
||||
const result2 = await ctrl.preparePayload({ text: "Second segment" });
|
||||
expect(result2).toEqual({ text: "Second segment" });
|
||||
it("suppresses block delivery when text was streamed", () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
ctrl.onPartialReply({ text: "streamed" });
|
||||
expect(ctrl.preparePayload({ text: "streamed" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("delivers all segments across 3+ tool call rounds", async () => {
|
||||
const ctrl = createController();
|
||||
|
||||
// Round 1: text → tool
|
||||
ctrl.onPartialReply({ text: "Segment 1" });
|
||||
await expect(ctrl.preparePayload({ text: "Segment 1" })).resolves.toBeUndefined();
|
||||
|
||||
// Round 2: text → tool
|
||||
ctrl.onPartialReply({ text: "Segment 2" });
|
||||
const r2 = await ctrl.preparePayload({ text: "Segment 2" });
|
||||
expect(r2).toEqual({ text: "Segment 2" });
|
||||
|
||||
// Round 3: final text
|
||||
ctrl.onPartialReply({ text: "Segment 3" });
|
||||
const r3 = await ctrl.preparePayload({ text: "Segment 3" });
|
||||
expect(r3).toEqual({ text: "Segment 3" });
|
||||
});
|
||||
|
||||
it("passes media+text payload through fully after stream finalized", async () => {
|
||||
const ctrl = createController();
|
||||
|
||||
// First segment streamed and finalized
|
||||
ctrl.onPartialReply({ text: "Streamed text" });
|
||||
await ctrl.preparePayload({ text: "Streamed text" });
|
||||
|
||||
// Second segment has both text and media — should pass through fully
|
||||
const result = await ctrl.preparePayload({
|
||||
text: "Post-tool text with image",
|
||||
mediaUrl: "https://example.com/tool-output.png",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
text: "Post-tool text with image",
|
||||
mediaUrl: "https://example.com/tool-output.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("still strips text from media payloads when stream handled text", async () => {
|
||||
const ctrl = createController();
|
||||
ctrl.onPartialReply({ text: "Some text" });
|
||||
|
||||
const result = await ctrl.preparePayload({
|
||||
text: "Some text",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
it("strips text but keeps media when text was streamed and payload has media", () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
ctrl.onPartialReply({ text: "streamed" });
|
||||
expect(ctrl.preparePayload({ text: "streamed", mediaUrl: "https://x/y.png" })).toEqual({
|
||||
text: undefined,
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
mediaUrl: "https://x/y.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to normal delivery when progress final streaming fails", async () => {
|
||||
streamInstances.length = 0;
|
||||
const ctrl = createTeamsReplyStreamController({
|
||||
conversationType: "personal",
|
||||
context: { sendActivity: vi.fn(async () => ({ id: "a" })) } as never,
|
||||
feedbackLoopEnabled: false,
|
||||
log: { debug: vi.fn() } as never,
|
||||
msteamsConfig: { streaming: { mode: "progress" } } as never,
|
||||
});
|
||||
await ctrl.noteProgressWork({ toolName: "exec" });
|
||||
await ctrl.noteProgressWork();
|
||||
const fullText = "x".repeat(4200);
|
||||
it("allows fallback delivery for second text segment after tool calls", () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
|
||||
const result = await ctrl.preparePayload({ text: fullText });
|
||||
ctrl.onPartialReply({ text: "First segment" });
|
||||
expect(ctrl.preparePayload({ text: "First segment" })).toBeUndefined();
|
||||
|
||||
expect(result).toEqual({ text: fullText });
|
||||
expect(streamInstances[0]?.replaceInformativeWithFinal).toHaveBeenCalledWith(fullText);
|
||||
const result = ctrl.preparePayload({ text: "Second segment after tools" });
|
||||
expect(result).toEqual({ text: "Second segment after tools" });
|
||||
});
|
||||
|
||||
it("records lifecycle receipt when progress final streaming succeeds", async () => {
|
||||
streamInstances.length = 0;
|
||||
const ctrl = createTeamsReplyStreamController({
|
||||
conversationType: "personal",
|
||||
context: { sendActivity: vi.fn(async () => ({ id: "a" })) } as never,
|
||||
feedbackLoopEnabled: false,
|
||||
log: { debug: vi.fn() } as never,
|
||||
msteamsConfig: { streaming: { mode: "progress" } } as never,
|
||||
});
|
||||
await ctrl.noteProgressWork({ toolName: "exec" });
|
||||
await ctrl.noteProgressWork();
|
||||
it("uses fallback even when onPartialReply fires after stream finalization is pending", () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
|
||||
await expect(ctrl.preparePayload({ text: "complete final answer" })).resolves.toBeUndefined();
|
||||
ctrl.onPartialReply({ text: "First segment" });
|
||||
expect(ctrl.preparePayload({ text: "First segment" })).toBeUndefined();
|
||||
|
||||
expect(ctrl.liveState().phase).toBe("finalized");
|
||||
expect(ctrl.liveState().receipt?.primaryPlatformMessageId).toBe("final-message");
|
||||
ctrl.onPartialReply({ text: "Second segment" });
|
||||
expect(stream.emit).toHaveBeenCalledTimes(1);
|
||||
expect(ctrl.preparePayload({ text: "Second segment" })).toEqual({ text: "Second segment" });
|
||||
});
|
||||
|
||||
it("falls back with full text when progress final send fails after streaming text", async () => {
|
||||
streamInstances.length = 0;
|
||||
const ctrl = createTeamsReplyStreamController({
|
||||
conversationType: "personal",
|
||||
context: { sendActivity: vi.fn(async () => ({ id: "a" })) } as never,
|
||||
feedbackLoopEnabled: false,
|
||||
log: { debug: vi.fn() } as never,
|
||||
msteamsConfig: { streaming: { mode: "progress" } } as never,
|
||||
});
|
||||
await ctrl.onReplyStart();
|
||||
streamInstances[0].replaceInformativeWithFinal.mockImplementationOnce(
|
||||
async function (this: {
|
||||
hasContent: boolean;
|
||||
isFailed: boolean;
|
||||
isFinalized: boolean;
|
||||
streamedLength: number;
|
||||
}) {
|
||||
this.hasContent = true;
|
||||
this.isFailed = true;
|
||||
this.isFinalized = true;
|
||||
this.streamedLength = 12;
|
||||
return false;
|
||||
},
|
||||
);
|
||||
it("delivers all later segments across 3+ tool call rounds", () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
|
||||
const result = await ctrl.preparePayload({ text: "complete final answer" });
|
||||
ctrl.onPartialReply({ text: "Segment 1" });
|
||||
expect(ctrl.preparePayload({ text: "Segment 1" })).toBeUndefined();
|
||||
|
||||
expect(result).toEqual({ text: "complete final answer" });
|
||||
ctrl.onPartialReply({ text: "Segment 2" });
|
||||
expect(ctrl.preparePayload({ text: "Segment 2" })).toEqual({ text: "Segment 2" });
|
||||
|
||||
ctrl.onPartialReply({ text: "Segment 3" });
|
||||
expect(ctrl.preparePayload({ text: "Segment 3" })).toEqual({ text: "Segment 3" });
|
||||
});
|
||||
|
||||
it("honors disabled Teams progress labels", async () => {
|
||||
streamInstances.length = 0;
|
||||
const ctrl = createTeamsReplyStreamController({
|
||||
conversationType: "personal",
|
||||
context: { sendActivity: vi.fn(async () => ({ id: "a" })) } as never,
|
||||
feedbackLoopEnabled: false,
|
||||
log: { debug: vi.fn() } as never,
|
||||
msteamsConfig: { streaming: { mode: "progress", progress: { label: false } } } as never,
|
||||
it("passes media+text payload through fully after stream finalization is pending", () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
|
||||
ctrl.onPartialReply({ text: "Streamed text" });
|
||||
expect(ctrl.preparePayload({ text: "Streamed text" })).toBeUndefined();
|
||||
|
||||
expect(
|
||||
ctrl.preparePayload({
|
||||
text: "Post-tool text with image",
|
||||
mediaUrl: "https://example.com/tool-output.png",
|
||||
}),
|
||||
).toEqual({
|
||||
text: "Post-tool text with image",
|
||||
mediaUrl: "https://example.com/tool-output.png",
|
||||
});
|
||||
});
|
||||
|
||||
await ctrl.onReplyStart();
|
||||
it("drops the payload after the stream is canceled (e.g. user Stop)", () => {
|
||||
// After the user presses Stop in Teams, the streamed prefix is already
|
||||
// visible. Returning the full payload here would render as a SECOND
|
||||
// message containing everything — defeating the cancel intent.
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
ctrl.onPartialReply({ text: "partial" });
|
||||
stream.canceled = true;
|
||||
expect(ctrl.preparePayload({ text: "partial complete" })).toBeUndefined();
|
||||
});
|
||||
|
||||
expect(streamInstances).toHaveLength(1);
|
||||
expect(streamInstances[0]?.sendInformativeUpdate).not.toHaveBeenCalled();
|
||||
it("drops the payload even when it carries media after cancel", () => {
|
||||
// Cancel honored consistently — no leftover media bubble lands either.
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
ctrl.onPartialReply({ text: "partial" });
|
||||
stream.canceled = true;
|
||||
expect(
|
||||
ctrl.preparePayload({ text: "partial complete", mediaUrl: "https://x/y.png" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to block delivery when no tokens were streamed", () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
expect(ctrl.preparePayload({ text: "tool-only response" })).toEqual({
|
||||
text: "tool-only response",
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the stream in finalize after streamed text payload was suppressed", async () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
ctrl.onPartialReply({ text: "streamed" });
|
||||
expect(ctrl.preparePayload({ text: "streamed" })).toBeUndefined();
|
||||
await expect(ctrl.finalize()).resolves.toBeUndefined();
|
||||
expect(stream.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns suppressed final payload when stream close produces no final activity", async () => {
|
||||
const stream = makeStream();
|
||||
stream.close.mockResolvedValueOnce(undefined);
|
||||
const ctrl = makeController({ stream });
|
||||
|
||||
ctrl.onPartialReply({ text: "streamed" });
|
||||
expect(ctrl.preparePayload({ text: "streamed final" })).toBeUndefined();
|
||||
|
||||
await expect(ctrl.finalize()).resolves.toEqual({ text: "streamed final" });
|
||||
});
|
||||
|
||||
it("returns text-only fallback when stream close no-ops after media already queued", async () => {
|
||||
const stream = makeStream();
|
||||
stream.close.mockResolvedValueOnce(undefined);
|
||||
const ctrl = makeController({ stream });
|
||||
|
||||
ctrl.onPartialReply({ text: "streamed" });
|
||||
expect(ctrl.preparePayload({ text: "streamed final", mediaUrl: "https://x/y.png" })).toEqual({
|
||||
text: undefined,
|
||||
mediaUrl: "https://x/y.png",
|
||||
});
|
||||
|
||||
await expect(ctrl.finalize()).resolves.toEqual({
|
||||
text: "streamed final",
|
||||
mediaUrl: undefined,
|
||||
mediaUrls: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns suppressed final payload when stream close throws", async () => {
|
||||
const stream = makeStream();
|
||||
stream.close.mockRejectedValueOnce(new Error("close failed"));
|
||||
const ctrl = makeController({ stream });
|
||||
|
||||
ctrl.onPartialReply({ text: "streamed" });
|
||||
expect(ctrl.preparePayload({ text: "streamed final" })).toBeUndefined();
|
||||
|
||||
await expect(ctrl.finalize()).resolves.toEqual({ text: "streamed final" });
|
||||
});
|
||||
|
||||
it("does not close the stream in finalize when no tokens were emitted", async () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
await ctrl.finalize();
|
||||
expect(stream.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("streams compact Teams progress lines when tool progress is enabled", async () => {
|
||||
streamInstances.length = 0;
|
||||
const stream = makeStream();
|
||||
const ctrl = createTeamsReplyStreamController({
|
||||
conversationType: "personal",
|
||||
context: { sendActivity: vi.fn(async () => ({ id: "a" })) } as never,
|
||||
context: makeContext(stream),
|
||||
feedbackLoopEnabled: false,
|
||||
log: { debug: vi.fn() } as never,
|
||||
msteamsConfig: {
|
||||
@@ -318,106 +258,206 @@ describe("createTeamsReplyStreamController", () => {
|
||||
await ctrl.pushProgressLine("tool: search");
|
||||
await ctrl.pushProgressLine("tool: exec");
|
||||
|
||||
expect(ctrl.shouldSuppressDefaultToolProgressMessages()).toBe(true);
|
||||
expect(ctrl.shouldStreamPreviewToolProgress()).toBe(true);
|
||||
expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenLastCalledWith(
|
||||
"Working\n\n- tool: search\n- tool: exec",
|
||||
);
|
||||
expect(stream.update).toHaveBeenLastCalledWith("Working\n\n- tool: search\n- tool: exec");
|
||||
});
|
||||
|
||||
it("suppresses Teams default progress messages without stream lines when tool progress is disabled", async () => {
|
||||
streamInstances.length = 0;
|
||||
it("suppresses block delivery when progress final text is emitted to the stream", () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = createTeamsReplyStreamController({
|
||||
conversationType: "personal",
|
||||
context: { sendActivity: vi.fn(async () => ({ id: "a" })) } as never,
|
||||
context: makeContext(stream),
|
||||
feedbackLoopEnabled: false,
|
||||
log: { debug: vi.fn() } as never,
|
||||
msteamsConfig: {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: {
|
||||
toolProgress: false,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
msteamsConfig: { streaming: { mode: "progress" } } as never,
|
||||
});
|
||||
|
||||
await ctrl.pushProgressLine("tool: search");
|
||||
|
||||
expect(ctrl.shouldSuppressDefaultToolProgressMessages()).toBe(true);
|
||||
expect(ctrl.shouldStreamPreviewToolProgress()).toBe(false);
|
||||
expect(streamInstances[0]?.sendInformativeUpdate).not.toHaveBeenCalled();
|
||||
expect(ctrl.preparePayload({ text: "complete final answer" })).toBeUndefined();
|
||||
expect(stream.emit).toHaveBeenCalledWith("complete final answer");
|
||||
});
|
||||
|
||||
it("does not start native streaming for Teams block mode", async () => {
|
||||
streamInstances.length = 0;
|
||||
it("falls back to normal delivery when progress final streaming fails", () => {
|
||||
const stream = makeStream();
|
||||
stream.emit.mockImplementation(() => {
|
||||
throw new Error("progress final failed");
|
||||
});
|
||||
const ctrl = createTeamsReplyStreamController({
|
||||
conversationType: "personal",
|
||||
context: { sendActivity: vi.fn(async () => ({ id: "a" })) } as never,
|
||||
context: makeContext(stream),
|
||||
feedbackLoopEnabled: false,
|
||||
log: { debug: vi.fn() } as never,
|
||||
msteamsConfig: { streaming: { mode: "block" } } as never,
|
||||
msteamsConfig: { streaming: { mode: "progress" } } as never,
|
||||
});
|
||||
|
||||
await ctrl.onReplyStart();
|
||||
ctrl.onPartialReply({ text: "block partial" });
|
||||
|
||||
expect(streamInstances).toHaveLength(0);
|
||||
await expect(ctrl.preparePayload({ text: "block final" })).resolves.toEqual({
|
||||
text: "block final",
|
||||
expect(ctrl.preparePayload({ text: "complete final answer" })).toEqual({
|
||||
text: "complete final answer",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not close a canceled stream in finalize", async () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
ctrl.onPartialReply({ text: "partial" });
|
||||
stream.canceled = true;
|
||||
await ctrl.finalize();
|
||||
expect(stream.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("StreamCancelledError handling", () => {
|
||||
function makeCancelError(): Error {
|
||||
const err = new Error("stream canceled");
|
||||
err.name = "StreamCancelledError";
|
||||
return err;
|
||||
}
|
||||
|
||||
it("swallows StreamCancelledError thrown from stream.emit (Stop button race)", () => {
|
||||
const stream = makeStream();
|
||||
stream.emit.mockImplementation(() => {
|
||||
throw makeCancelError();
|
||||
});
|
||||
const ctrl = makeController({ stream });
|
||||
// Must not throw — the SDK throws this synchronously when _canceled
|
||||
// flipped between our pre-check and the emit call (or when no pre-check
|
||||
// happens at all). An uncaught throw here crashes the gateway process
|
||||
// since it surfaces as an unhandled promise rejection in async paths.
|
||||
expect(() => ctrl.onPartialReply({ text: "after stop" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("swallows StreamCancelledError thrown from progress stream.update", async () => {
|
||||
const stream = makeStream();
|
||||
stream.update.mockImplementation(() => {
|
||||
throw makeCancelError();
|
||||
});
|
||||
const ctrl = createTeamsReplyStreamController({
|
||||
conversationType: "personal",
|
||||
context: makeContext(stream),
|
||||
feedbackLoopEnabled: false,
|
||||
msteamsConfig: { streaming: { mode: "progress" } } as never,
|
||||
});
|
||||
await expect(ctrl.noteProgressWork({ toolName: "exec" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("swallows StreamCancelledError thrown from stream.emit during finalize", async () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
ctrl.onPartialReply({ text: "partial" });
|
||||
// Cancel after we've started streaming, then make the final emit throw.
|
||||
stream.emit.mockImplementation(() => {
|
||||
throw makeCancelError();
|
||||
});
|
||||
// Must not throw — finalize's pre-check on stream.canceled may miss
|
||||
// the cancellation that happens between check and emit.
|
||||
await expect(ctrl.finalize()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("latches streamFailed (and does not throw) on non-cancel errors from stream.emit", () => {
|
||||
const stream = makeStream();
|
||||
stream.emit.mockImplementation(() => {
|
||||
throw new Error("network failure");
|
||||
});
|
||||
const ctrl = makeController({ stream });
|
||||
// Must not propagate — the rest of the reply pipeline needs to keep
|
||||
// running so preparePayload can fall back to block delivery.
|
||||
expect(() => ctrl.onPartialReply({ text: "boom" })).not.toThrow();
|
||||
// Stream is no longer considered active once it has failed.
|
||||
expect(ctrl.isStreamActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to block delivery when stream.emit fails after tokens were emitted", () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
// First chunk succeeds — tokensEmitted goes true.
|
||||
ctrl.onPartialReply({ text: "hello" });
|
||||
expect(stream.emit).toHaveBeenCalledTimes(1);
|
||||
// Second chunk fails for a non-cancel reason.
|
||||
stream.emit.mockImplementation(() => {
|
||||
throw new Error("network failure");
|
||||
});
|
||||
ctrl.onPartialReply({ text: "hello world" });
|
||||
// Without the streamFailed latch, preparePayload would suppress the
|
||||
// payload because tokens were emitted; the user would see only "hello".
|
||||
// With the latch, block delivery sends the full final reply.
|
||||
const result = ctrl.preparePayload({ text: "hello world final" });
|
||||
expect(result).toEqual(expect.objectContaining({ text: "hello world final" }));
|
||||
});
|
||||
|
||||
it("preserves the no-duplicate behavior for the active streamed segment", () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
ctrl.onPartialReply({ text: "hello" });
|
||||
// No failure — preparePayload should still suppress block delivery for
|
||||
// the active streamed segment so the streamed text isn't duplicated.
|
||||
expect(ctrl.preparePayload({ text: "hello world" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("swallows non-cancel errors from stream.close during finalize", async () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
ctrl.onPartialReply({ text: "partial" });
|
||||
expect(ctrl.preparePayload({ text: "partial final" })).toBeUndefined();
|
||||
stream.close.mockImplementation(async () => {
|
||||
throw new Error("close failed");
|
||||
});
|
||||
// Finalize must not propagate; it returns the retained payload so the
|
||||
// dispatcher can fall back to normal Teams delivery.
|
||||
await expect(ctrl.finalize()).resolves.toEqual({ text: "partial final" });
|
||||
});
|
||||
|
||||
it("treats post-cancel stream as inactive without further emit attempts", () => {
|
||||
const stream = makeStream();
|
||||
stream.emit.mockImplementationOnce(() => {
|
||||
throw makeCancelError();
|
||||
});
|
||||
const ctrl = makeController({ stream });
|
||||
ctrl.onPartialReply({ text: "first chunk after stop" });
|
||||
// Subsequent partial replies should short-circuit and not call emit
|
||||
// again (the SDK would throw on every call once canceled).
|
||||
ctrl.onPartialReply({ text: "second chunk" });
|
||||
ctrl.onPartialReply({ text: "third chunk" });
|
||||
expect(stream.emit).toHaveBeenCalledTimes(1);
|
||||
expect(ctrl.isStreamActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-personal conversation", () => {
|
||||
it("does not stream in channels — onPartialReply is a no-op", () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ conversationType: "channel", stream });
|
||||
ctrl.onPartialReply({ text: "anything" });
|
||||
expect(stream.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hasStream returns false for channels", () => {
|
||||
const ctrl = makeController({ conversationType: "channel", stream: makeStream() });
|
||||
expect(ctrl.hasStream()).toBe(false);
|
||||
});
|
||||
|
||||
it("preparePayload returns payload unchanged for channels", () => {
|
||||
const ctrl = makeController({ conversationType: "channel", stream: makeStream() });
|
||||
expect(ctrl.preparePayload({ text: "hi" })).toEqual({ text: "hi" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isStreamActive", () => {
|
||||
it("returns false before any tokens arrive so typing keepalive can warm up", () => {
|
||||
const ctrl = createController();
|
||||
expect(ctrl.isStreamActive()).toBe(false);
|
||||
it("returns false before any tokens arrive", () => {
|
||||
expect(makeController({ stream: makeStream() }).isStreamActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false after the informative update but before tokens arrive", async () => {
|
||||
const ctrl = createController();
|
||||
await ctrl.onReplyStart();
|
||||
expect(ctrl.isStreamActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true while the stream is actively receiving tokens", () => {
|
||||
const ctrl = createController();
|
||||
ctrl.onPartialReply({ text: "Streaming tokens" });
|
||||
it("returns true while receiving tokens", () => {
|
||||
const ctrl = makeController({ stream: makeStream() });
|
||||
ctrl.onPartialReply({ text: "tokens" });
|
||||
expect(ctrl.isStreamActive()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false after the stream is finalized between tool rounds", async () => {
|
||||
const ctrl = createController();
|
||||
|
||||
ctrl.onPartialReply({ text: "First segment" });
|
||||
expect(ctrl.isStreamActive()).toBe(true);
|
||||
|
||||
// First segment complete: stream is finalized so the typing keepalive
|
||||
// can resume during the tool chain that follows.
|
||||
await ctrl.preparePayload({ text: "First segment" });
|
||||
it("returns false when stream is canceled", () => {
|
||||
const stream = makeStream();
|
||||
const ctrl = makeController({ stream });
|
||||
ctrl.onPartialReply({ text: "tokens" });
|
||||
stream.canceled = true;
|
||||
expect(ctrl.isStreamActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the stream has failed", () => {
|
||||
const ctrl = createController();
|
||||
|
||||
ctrl.onPartialReply({ text: "First segment" });
|
||||
expect(ctrl.isStreamActive()).toBe(true);
|
||||
|
||||
streamInstances[0].isFailed = true;
|
||||
expect(ctrl.isStreamActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when conversationType is not personal", () => {
|
||||
streamInstances.length = 0;
|
||||
const ctrl = createTeamsReplyStreamController({
|
||||
conversationType: "channel",
|
||||
context: { sendActivity: vi.fn() } as never,
|
||||
feedbackLoopEnabled: false,
|
||||
log: { debug: vi.fn() } as never,
|
||||
});
|
||||
ctrl.onPartialReply({ text: "anything" });
|
||||
it("returns false for non-personal conversations", () => {
|
||||
const ctrl = makeController({ conversationType: "channel", stream: makeStream() });
|
||||
ctrl.onPartialReply({ text: "tokens" });
|
||||
expect(ctrl.isStreamActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
import {
|
||||
createLiveMessageState,
|
||||
createPreviewMessageReceipt,
|
||||
defineFinalizableLivePreviewAdapter,
|
||||
deliverWithFinalizableLivePreviewAdapter,
|
||||
markLiveMessageFinalized,
|
||||
type LiveMessageState,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
createChannelProgressDraftGate,
|
||||
type ChannelProgressDraftLine,
|
||||
@@ -14,24 +6,22 @@ import {
|
||||
mergeChannelProgressDraftLine,
|
||||
normalizeChannelProgressDraftLineIdentity,
|
||||
resolveChannelPreviewStreamMode,
|
||||
resolveChannelProgressDraftMaxLines,
|
||||
resolveChannelProgressDraftLabel,
|
||||
resolveChannelProgressDraftMaxLines,
|
||||
resolveChannelStreamingPreviewToolProgress,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { MSTeamsConfig, ReplyPayload } from "../runtime-api.js";
|
||||
import { formatUnknownError } from "./errors.js";
|
||||
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
|
||||
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||
import { TeamsHttpStream } from "./streaming-message.js";
|
||||
|
||||
// Local generic wrapper to defer union resolution. Works around a
|
||||
// single-file-mode limitation in the type-aware lint where imported
|
||||
// types resolved via extension runtime-api barrels are treated as
|
||||
// `error` (acting as `any`) and trip `no-redundant-type-constituents`
|
||||
// when combined with `undefined` in a union.
|
||||
type Maybe<T> = T | undefined;
|
||||
|
||||
/**
|
||||
* Resolve the informative status text shown above the streaming card while the
|
||||
* agent is working. Pulls custom labels from `msteams.streaming.progressDraft`
|
||||
* config when set, falls back to the plugin-sdk's default rotation otherwise.
|
||||
*/
|
||||
export function pickInformativeStatusText(
|
||||
params: { config?: MSTeamsConfig; seed?: string; random?: () => number } | (() => number) = {},
|
||||
): string | undefined {
|
||||
@@ -43,12 +33,41 @@ export function pickInformativeStatusText(
|
||||
});
|
||||
}
|
||||
|
||||
// The SDK throws StreamCancelledError synchronously from stream.emit/update
|
||||
// when the user pressed Stop in Teams (Teams replies 403 to the next chunk
|
||||
// update and the SDK flips _canceled). Match by `name` rather than importing
|
||||
// the class — tsgo can't resolve the re-export chain through
|
||||
// @microsoft/teams.apps/dist/types/streamer, and the SDK's own code at
|
||||
// utils/promises/retry.js falls back to this same name check.
|
||||
function isStreamCancelledError(err: unknown): boolean {
|
||||
return err instanceof Error && err.name === "StreamCancelledError";
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges openclaw's reply pipeline callbacks to the SDK's `ctx.stream`.
|
||||
* Streaming is enabled for personal (DM) conversations only; group/channel
|
||||
* messages fall through to block delivery.
|
||||
*
|
||||
* Streaming modes (resolved from `cfg.channels.msteams.streaming.preview`):
|
||||
* - "partial" (default): per-token streaming via `stream.emit(text)`. Each
|
||||
* chunk goes onto the live preview card in Teams.
|
||||
* - "progress": no per-token streaming; the preview card carries an
|
||||
* informative status that updates as tools run (e.g. "Looking up the
|
||||
* schema..." → "Generating SQL..."). When tool-progress streaming is also
|
||||
* enabled, raw tool names appear as bullets above the label.
|
||||
* - "block": disable native streaming entirely; the reply lands as a regular
|
||||
* block message. We bypass the controller in that case.
|
||||
*/
|
||||
export function createTeamsReplyStreamController(params: {
|
||||
conversationType?: string;
|
||||
context: MSTeamsTurnContext;
|
||||
feedbackLoopEnabled: boolean;
|
||||
log: MSTeamsMonitorLogger;
|
||||
log?: MSTeamsMonitorLogger;
|
||||
msteamsConfig?: MSTeamsConfig;
|
||||
/**
|
||||
* Seed for the random label rotation so the same conversation gets the same
|
||||
* "Thinking..." flavor across reconnects. Typically `${accountId}:${convId}`.
|
||||
*/
|
||||
progressSeed?: string;
|
||||
random?: () => number;
|
||||
}) {
|
||||
@@ -56,43 +75,44 @@ export function createTeamsReplyStreamController(params: {
|
||||
const streamMode = resolveChannelPreviewStreamMode(params.msteamsConfig, "partial");
|
||||
const shouldUseNativeStream =
|
||||
isPersonal && (streamMode === "partial" || streamMode === "progress");
|
||||
const shouldSuppressDefaultToolProgressMessages =
|
||||
shouldUseNativeStream && streamMode === "progress";
|
||||
const shouldStreamPreviewToolProgress =
|
||||
shouldSuppressDefaultToolProgressMessages &&
|
||||
resolveChannelStreamingPreviewToolProgress(params.msteamsConfig);
|
||||
const stream = shouldUseNativeStream
|
||||
? new TeamsHttpStream({
|
||||
sendActivity: (activity) => params.context.sendActivity(activity),
|
||||
feedbackLoopEnabled: params.feedbackLoopEnabled,
|
||||
onError: (err) => {
|
||||
params.log.debug?.(`stream error: ${formatUnknownError(err)}`);
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
streamMode === "progress" && resolveChannelStreamingPreviewToolProgress(params.msteamsConfig);
|
||||
|
||||
let streamReceivedTokens = false;
|
||||
let informativeUpdateSent = false;
|
||||
let progressLines: Array<string | ChannelProgressDraftLine> = [];
|
||||
const stream = shouldUseNativeStream ? params.context.stream : undefined;
|
||||
|
||||
let tokensEmitted = false;
|
||||
let streamFinalizationPending = false;
|
||||
let canceledLocally = false;
|
||||
// Set when `stream.emit/close` fails for a non-cancel reason after we've
|
||||
// already started streaming. Differentiates "user pressed Stop" from "the
|
||||
// stream broke under us"; the second case wants block-delivery fallback so
|
||||
// the user gets the full reply instead of a truncated streamed prefix.
|
||||
// Matches the pre-migration `TeamsHttpStream.hasContent → false` recovery.
|
||||
let streamFailed = false;
|
||||
let lastInformativeText = "";
|
||||
let pendingFinalize: Promise<void> | undefined;
|
||||
let liveState: LiveMessageState<ReplyPayload> = createLiveMessageState({
|
||||
canFinalizeInPlace: Boolean(stream),
|
||||
});
|
||||
let progressLines: Array<string | ChannelProgressDraftLine> = [];
|
||||
let pendingFinalPayload: Maybe<ReplyPayload>;
|
||||
// openclaw's reply pipeline calls onPartialReply with the cumulative text on
|
||||
// each chunk, but the SDK's HttpStream appends each emit() to its internal
|
||||
// text buffer (this.text += activity.text). Forwarding cumulative text into
|
||||
// an appending sink produces "chunk1 + chunk2 + chunk3..." duplication. We
|
||||
// track the length of text we've already emitted and forward only the delta.
|
||||
let emittedTextLength = 0;
|
||||
|
||||
const markStreamFinalized = () => {
|
||||
if (!stream || stream.isFailed) {
|
||||
return;
|
||||
}
|
||||
const messageId = stream.messageId ?? stream.previewStreamId;
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
liveState = markLiveMessageFinalized(liveState, createPreviewMessageReceipt({ id: messageId }));
|
||||
const wasCanceled = () => canceledLocally || Boolean(stream?.canceled);
|
||||
|
||||
const fallbackPayloadForSuppressedFinal = (payload: ReplyPayload): ReplyPayload => {
|
||||
const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length);
|
||||
return hasMedia ? { ...payload, mediaUrl: undefined, mediaUrls: undefined } : payload;
|
||||
};
|
||||
|
||||
const renderInformativeUpdate = async () => {
|
||||
if (!stream) {
|
||||
/**
|
||||
* Render the current informative status line into the streaming card. Pulls
|
||||
* the rotating "Thinking..." label from msteams config (or the plugin-sdk
|
||||
* default) and prepends collected tool-progress lines when configured.
|
||||
*/
|
||||
const renderInformativeUpdate = (): void => {
|
||||
if (!stream || wasCanceled()) {
|
||||
return;
|
||||
}
|
||||
const informativeText = formatChannelProgressDraftText({
|
||||
@@ -105,36 +125,121 @@ export function createTeamsReplyStreamController(params: {
|
||||
return;
|
||||
}
|
||||
lastInformativeText = informativeText;
|
||||
informativeUpdateSent = true;
|
||||
await stream.sendInformativeUpdate(informativeText);
|
||||
try {
|
||||
stream.update(informativeText);
|
||||
} catch (err) {
|
||||
if (isStreamCancelledError(err)) {
|
||||
canceledLocally = true;
|
||||
return;
|
||||
}
|
||||
params.log?.debug?.(
|
||||
`stream informative update failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Gate informative updates so they only start firing once meaningful work
|
||||
// has begun (avoids flickering "Thinking..." before the first real tool
|
||||
// call). The gate is shape-agnostic — it just calls `onStart` once when the
|
||||
// first noteWork() arrives.
|
||||
const progressDraftGate = createChannelProgressDraftGate({
|
||||
onStart: renderInformativeUpdate,
|
||||
});
|
||||
|
||||
const noteProgressWork = async (options?: { toolName?: string }): Promise<void> => {
|
||||
return {
|
||||
async onReplyStart(): Promise<void> {
|
||||
// Starting a reply is not enough to decide that native streaming should
|
||||
// own delivery. Wait for text tokens or explicit progress work so
|
||||
// no-token replies keep the normal block-delivery path.
|
||||
return;
|
||||
},
|
||||
|
||||
onPartialReply(payload: { text?: string }): void {
|
||||
// Partial-token streaming only fires in "partial" mode. In "progress"
|
||||
// mode, openclaw's pipeline doesn't deliver tokens — the model output
|
||||
// arrives as a single payload at preparePayload time.
|
||||
if (
|
||||
!stream ||
|
||||
!payload.text ||
|
||||
wasCanceled() ||
|
||||
streamMode !== "partial" ||
|
||||
streamFinalizationPending
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Convert cumulative-text from the pipeline into deltas for the SDK's
|
||||
// appending sink. Without this, "Here's a" → "Here's a sonnet" → ...
|
||||
// gets emitted as full repeats and the SDK concatenates the lot.
|
||||
const fullText = payload.text;
|
||||
// If the pipeline ever sends shorter text than we've emitted (e.g.
|
||||
// edit-in-place semantics), skip rather than emit a negative slice.
|
||||
if (fullText.length <= emittedTextLength) {
|
||||
return;
|
||||
}
|
||||
const delta = fullText.slice(emittedTextLength);
|
||||
try {
|
||||
stream.emit(delta);
|
||||
emittedTextLength = fullText.length;
|
||||
tokensEmitted = true;
|
||||
} catch (err) {
|
||||
if (isStreamCancelledError(err)) {
|
||||
canceledLocally = true;
|
||||
return;
|
||||
}
|
||||
// Non-cancel failure: latch streamFailed so `preparePayload` lets
|
||||
// block delivery happen even though tokens were already emitted.
|
||||
// The user may see a duplicate (streamed prefix + full block reply)
|
||||
// — that's intentional and matches the pre-migration recovery
|
||||
// behavior; truncated-only is the worse outcome.
|
||||
streamFailed = true;
|
||||
params.log?.warn?.(
|
||||
`msteams stream emit failed, falling back to block delivery: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Note that the agent is working — bumps the progress-draft gate so the
|
||||
* informative status starts (or refreshes) on the next render. Called
|
||||
* from the reply-dispatcher's typing callbacks.
|
||||
*/
|
||||
async noteProgressWork(options?: { toolName?: string }): Promise<void> {
|
||||
if (!stream || streamMode !== "progress") {
|
||||
return;
|
||||
}
|
||||
if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
|
||||
// Filter out non-work tool names (e.g. internal scheduling helpers) so
|
||||
// the user only sees lines for tools that actually represent work.
|
||||
if (
|
||||
options?.toolName !== undefined &&
|
||||
!isChannelProgressDraftWorkToolName(options.toolName)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const hadStarted = progressDraftGate.hasStarted;
|
||||
await progressDraftGate.noteWork();
|
||||
// If the gate was already started, the call above is a no-op — refresh
|
||||
// the informative line manually so the latest progress lines render.
|
||||
if (hadStarted && progressDraftGate.hasStarted) {
|
||||
await renderInformativeUpdate();
|
||||
renderInformativeUpdate();
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
const pushProgressLine = async (
|
||||
/**
|
||||
* Append a tool-progress line (e.g. a tool name being invoked) into the
|
||||
* preview card's informative status. Only takes effect in "progress" mode
|
||||
* with `streaming.previewToolProgress` enabled in config.
|
||||
*/
|
||||
async pushProgressLine(
|
||||
line?: string | ChannelProgressDraftLine,
|
||||
options?: { toolName?: string },
|
||||
): Promise<void> => {
|
||||
): Promise<void> {
|
||||
if (!stream || streamMode !== "progress") {
|
||||
return;
|
||||
}
|
||||
if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
|
||||
if (
|
||||
options?.toolName !== undefined &&
|
||||
!isChannelProgressDraftWorkToolName(options.toolName)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (shouldStreamPreviewToolProgress) {
|
||||
@@ -147,152 +252,119 @@ export function createTeamsReplyStreamController(params: {
|
||||
});
|
||||
}
|
||||
}
|
||||
await noteProgressWork();
|
||||
};
|
||||
const hadStarted = progressDraftGate.hasStarted;
|
||||
await progressDraftGate.noteWork();
|
||||
if (hadStarted && progressDraftGate.hasStarted) {
|
||||
renderInformativeUpdate();
|
||||
}
|
||||
},
|
||||
|
||||
const fallbackAfterStreamFailure = (
|
||||
payload: ReplyPayload,
|
||||
hasMedia: boolean,
|
||||
): Maybe<ReplyPayload> => {
|
||||
if (!payload.text) {
|
||||
preparePayload(payload: ReplyPayload): Maybe<ReplyPayload> {
|
||||
if (!stream) {
|
||||
return payload;
|
||||
}
|
||||
const streamedLength = stream?.streamedLength ?? 0;
|
||||
if (streamedLength <= 0) {
|
||||
return payload;
|
||||
}
|
||||
const remainingText = payload.text.slice(streamedLength);
|
||||
if (!remainingText) {
|
||||
return hasMedia ? { ...payload, text: undefined } : undefined;
|
||||
}
|
||||
return { ...payload, text: remainingText };
|
||||
};
|
||||
|
||||
const finalizeProgressPayload = async (
|
||||
payload: ReplyPayload,
|
||||
hasMedia: boolean,
|
||||
): Promise<Maybe<ReplyPayload>> => {
|
||||
if (!stream || !payload.text) {
|
||||
return payload;
|
||||
}
|
||||
const result = await deliverWithFinalizableLivePreviewAdapter({
|
||||
kind: "final",
|
||||
payload,
|
||||
liveState,
|
||||
adapter: defineFinalizableLivePreviewAdapter<ReplyPayload, string, { text: string }>({
|
||||
draft: {
|
||||
flush: async () => {},
|
||||
clear: async () => {},
|
||||
id: () => stream.previewStreamId,
|
||||
},
|
||||
buildFinalEdit: (candidate) => (candidate.text ? { text: candidate.text } : undefined),
|
||||
editFinal: async (_previewId, edit) => {
|
||||
const finalized = await stream.replaceInformativeWithFinal(edit.text);
|
||||
informativeUpdateSent = false;
|
||||
if (!finalized || stream.isFailed) {
|
||||
throw new Error("Teams progress stream finalization failed");
|
||||
}
|
||||
},
|
||||
resolveFinalizedId: (previewId) => stream.messageId ?? stream.previewStreamId ?? previewId,
|
||||
createPreviewReceipt: (id) => createPreviewMessageReceipt({ id }),
|
||||
onPreviewFinalized: (_id, _receipt, state) => {
|
||||
liveState = state;
|
||||
},
|
||||
logPreviewEditFailure: (err) => {
|
||||
params.log.debug?.(`stream finalization failed: ${formatUnknownError(err)}`);
|
||||
},
|
||||
}),
|
||||
deliverNormally: async () => false,
|
||||
});
|
||||
|
||||
return result.kind === "preview-finalized"
|
||||
? hasMedia
|
||||
? { ...payload, text: undefined }
|
||||
: undefined
|
||||
: payload;
|
||||
};
|
||||
|
||||
return {
|
||||
async onReplyStart(): Promise<void> {
|
||||
return;
|
||||
},
|
||||
|
||||
async noteProgressWork(options?: { toolName?: string }): Promise<void> {
|
||||
await noteProgressWork(options);
|
||||
},
|
||||
|
||||
onPartialReply(payload: { text?: string }): void {
|
||||
if (!stream || !payload.text) {
|
||||
return;
|
||||
}
|
||||
if (streamMode === "progress") {
|
||||
return;
|
||||
}
|
||||
streamReceivedTokens = true;
|
||||
stream.update(payload.text);
|
||||
},
|
||||
|
||||
async pushProgressLine(
|
||||
line?: string | ChannelProgressDraftLine,
|
||||
options?: { toolName?: string },
|
||||
): Promise<void> {
|
||||
await pushProgressLine(line, options);
|
||||
},
|
||||
|
||||
shouldSuppressDefaultToolProgressMessages(): boolean {
|
||||
return shouldSuppressDefaultToolProgressMessages;
|
||||
},
|
||||
|
||||
shouldStreamPreviewToolProgress(): boolean {
|
||||
return shouldStreamPreviewToolProgress;
|
||||
},
|
||||
|
||||
async preparePayload(payload: ReplyPayload): Promise<Maybe<ReplyPayload>> {
|
||||
const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length);
|
||||
|
||||
if (stream && streamMode === "progress" && informativeUpdateSent && !stream.isFinalized) {
|
||||
if (!payload.text) {
|
||||
return payload;
|
||||
}
|
||||
return await finalizeProgressPayload(payload, hasMedia);
|
||||
}
|
||||
|
||||
if (!stream || !streamReceivedTokens) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
// Stream failed after partial delivery (e.g. > 4000 chars). Send only
|
||||
// the unstreamed suffix via block delivery to avoid duplicate text.
|
||||
if (stream.isFailed) {
|
||||
streamReceivedTokens = false;
|
||||
|
||||
return fallbackAfterStreamFailure(payload, hasMedia);
|
||||
}
|
||||
|
||||
if (!stream.hasContent || stream.isFinalized) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
// Stream handled this text segment. Finalize it and reset so any
|
||||
// subsequent text segments (after tool calls) use fallback delivery.
|
||||
// finalize() is idempotent; the later call in markDispatchIdle is a no-op.
|
||||
streamReceivedTokens = false;
|
||||
pendingFinalize = stream.finalize().then(() => {
|
||||
markStreamFinalized();
|
||||
});
|
||||
|
||||
if (!hasMedia) {
|
||||
// User pressed Stop (or Teams ended the stream) — the streamed prefix
|
||||
// is already visible to the user. Dropping the payload here prevents a
|
||||
// second block message from re-delivering the rest, which would override
|
||||
// the explicit cancel intent.
|
||||
if (wasCanceled()) {
|
||||
return undefined;
|
||||
}
|
||||
return { ...payload, text: undefined };
|
||||
// Partial mode with tokens already streamed: stream carries the text;
|
||||
// strip text from the payload (keep media if any) so block delivery
|
||||
// doesn't duplicate. Exception: if a non-cancel stream failure was
|
||||
// latched mid-flight, fall through to block delivery so the user gets
|
||||
// the full reply instead of the truncated streamed prefix.
|
||||
if (tokensEmitted && !streamFailed) {
|
||||
const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length);
|
||||
pendingFinalPayload = fallbackPayloadForSuppressedFinal(payload);
|
||||
streamFinalizationPending = true;
|
||||
tokensEmitted = false;
|
||||
return hasMedia ? { ...payload, text: undefined } : undefined;
|
||||
}
|
||||
// Progress mode (or partial mode that received no tokens — e.g. a
|
||||
// tool-only response): emit the final text into the stream so the
|
||||
// preview card transitions in place to the final reply. The SDK's
|
||||
// HttpStream accumulates the text and the next `finalize()` close()
|
||||
// flushes it as the closing activity.
|
||||
if (streamMode === "progress" && payload.text) {
|
||||
try {
|
||||
stream.emit(payload.text);
|
||||
pendingFinalPayload = fallbackPayloadForSuppressedFinal(payload);
|
||||
streamFinalizationPending = true;
|
||||
const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length);
|
||||
return hasMedia ? { ...payload, text: undefined } : undefined;
|
||||
} catch (err) {
|
||||
if (isStreamCancelledError(err)) {
|
||||
canceledLocally = true;
|
||||
return undefined;
|
||||
}
|
||||
// Non-cancel emit failure: fall through to block delivery as a
|
||||
// safety net so the user still sees the final reply.
|
||||
params.log?.debug?.(
|
||||
`progress-mode finalize failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async finalize(): Promise<void> {
|
||||
progressDraftGate.cancel();
|
||||
await pendingFinalize;
|
||||
if (!pendingFinalize) {
|
||||
await stream?.finalize();
|
||||
markStreamFinalized();
|
||||
async finalize(): Promise<Maybe<ReplyPayload>> {
|
||||
if (!stream || !streamFinalizationPending || wasCanceled()) {
|
||||
return undefined;
|
||||
}
|
||||
// Emit a final MessageActivity carrying the AI-generated marker and (if
|
||||
// enabled) the feedback channelData. The SDK's HttpStream merges this
|
||||
// into the closing activity it sends to Teams, so streamed replies still
|
||||
// get the AI-generated label and thumbs up/down.
|
||||
const finalEntities: Array<Record<string, unknown>> = [
|
||||
{
|
||||
type: "https://schema.org/Message",
|
||||
"@type": "Message",
|
||||
"@context": "https://schema.org",
|
||||
"@id": "",
|
||||
additionalType: ["AIGeneratedContent"],
|
||||
},
|
||||
];
|
||||
const finalChannelData: Record<string, unknown> = params.feedbackLoopEnabled
|
||||
? { feedbackLoopEnabled: true }
|
||||
: {};
|
||||
try {
|
||||
stream.emit({
|
||||
type: "message",
|
||||
entities: finalEntities,
|
||||
channelData: finalChannelData,
|
||||
});
|
||||
const result = await stream.close();
|
||||
streamFinalizationPending = false;
|
||||
if (!result) {
|
||||
const fallback = pendingFinalPayload;
|
||||
pendingFinalPayload = undefined;
|
||||
return fallback;
|
||||
}
|
||||
pendingFinalPayload = undefined;
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
if (isStreamCancelledError(err)) {
|
||||
canceledLocally = true;
|
||||
pendingFinalPayload = undefined;
|
||||
streamFinalizationPending = false;
|
||||
return undefined;
|
||||
}
|
||||
// Non-cancel failure during the closing emit/close. The streamed
|
||||
// prefix is already visible to the user; the only loss is the
|
||||
// closing activity (AI-Generated marker, feedback channelData).
|
||||
// Latch streamFailed for parity with the mid-stream path and
|
||||
// swallow the error — a thrown finalize would otherwise blow up
|
||||
// the reply pipeline after the user already saw the response.
|
||||
streamFailed = true;
|
||||
streamFinalizationPending = false;
|
||||
params.log?.warn?.(
|
||||
`msteams stream finalize failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
const fallback = pendingFinalPayload;
|
||||
pendingFinalPayload = undefined;
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -300,35 +372,10 @@ export function createTeamsReplyStreamController(params: {
|
||||
return Boolean(stream);
|
||||
},
|
||||
|
||||
liveState(): LiveMessageState<ReplyPayload> {
|
||||
return liveState;
|
||||
isStreamActive(): boolean {
|
||||
return Boolean(stream) && tokensEmitted && !wasCanceled() && !streamFailed;
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether the Teams streaming card is currently receiving LLM tokens.
|
||||
* Used to gate side-channel keepalive activity so we don't overlay plain
|
||||
* "typing" indicators on top of a live streaming card.
|
||||
*
|
||||
* Returns true only while the stream is actively chunking text into the
|
||||
* streaming card. The informative update (blue progress bar) is short
|
||||
* lived so we intentionally do not count it as "active"; this way the
|
||||
* typing keepalive can still fire during the informative window and
|
||||
* during tool chains between text segments.
|
||||
*
|
||||
* Returns false when:
|
||||
* - No stream exists (non-personal conversation).
|
||||
* - Stream has not yet received any text tokens.
|
||||
* - Stream has been finalized (e.g. after the first text segment, while
|
||||
* tools run before the next segment).
|
||||
*/
|
||||
isStreamActive(): boolean {
|
||||
if (!stream) {
|
||||
return false;
|
||||
}
|
||||
if (stream.isFinalized || stream.isFailed) {
|
||||
return false;
|
||||
}
|
||||
return streamReceivedTokens;
|
||||
},
|
||||
wasCanceled,
|
||||
};
|
||||
}
|
||||
|
||||
96
extensions/msteams/src/sdk-proactive.test.ts
Normal file
96
extensions/msteams/src/sdk-proactive.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sendMSTeamsActivityWithReference } from "./sdk-proactive.js";
|
||||
import type { MSTeamsApp } from "./sdk.js";
|
||||
|
||||
const clientState = vi.hoisted(() => ({
|
||||
created: [] as Array<{ serviceUrl: string; http: unknown }>,
|
||||
create: vi.fn(async (_payload: { conversationId: string; activity: unknown }) => ({
|
||||
id: "activity-1",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@microsoft/teams.api", () => ({
|
||||
Client: vi.fn(function MockClient(this: unknown, serviceUrl: string, http: unknown) {
|
||||
clientState.created.push({ serviceUrl, http });
|
||||
return {
|
||||
serviceUrl,
|
||||
conversations: {
|
||||
activities: (conversationId: string) => ({
|
||||
create: (activity: unknown) =>
|
||||
clientState.create({
|
||||
conversationId,
|
||||
activity,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("sendMSTeamsActivityWithReference", () => {
|
||||
beforeEach(() => {
|
||||
clientState.created.length = 0;
|
||||
clientState.create.mockClear().mockResolvedValue({ id: "activity-1" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("sends through a reference-scoped API client without the protected SDK activitySender", async () => {
|
||||
vi.stubEnv("SERVICE_URL", "https://bot.example.com/api/messages");
|
||||
const httpClient = { request: vi.fn() };
|
||||
const app = {
|
||||
client: httpClient,
|
||||
api: {
|
||||
serviceUrl: "https://smba.trafficmanager.net/teams",
|
||||
conversations: {
|
||||
activities: () => ({
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as MSTeamsApp;
|
||||
|
||||
const result = await sendMSTeamsActivityWithReference(
|
||||
app,
|
||||
{
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer/",
|
||||
agent: { id: "28:bot", name: "OpenClaw", role: "bot" },
|
||||
user: { id: "29:user", aadObjectId: "aad-user" },
|
||||
conversation: {
|
||||
id: "19:conversation@thread.tacv2",
|
||||
conversationType: "personal",
|
||||
tenantId: "tenant-1",
|
||||
},
|
||||
channelId: "msteams",
|
||||
},
|
||||
{ type: "message", text: "hello" },
|
||||
{ serviceUrlBoundary: { cloud: "Public" } },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ id: "activity-1" });
|
||||
expect(clientState.created).toEqual([
|
||||
{
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer",
|
||||
http: httpClient,
|
||||
},
|
||||
]);
|
||||
expect(clientState.create).toHaveBeenCalledWith({
|
||||
conversationId: "19:conversation@thread.tacv2",
|
||||
activity: expect.objectContaining({
|
||||
type: "message",
|
||||
text: "hello",
|
||||
from: { id: "28:bot", name: "OpenClaw", role: "bot" },
|
||||
conversation: {
|
||||
id: "19:conversation@thread.tacv2",
|
||||
conversationType: "personal",
|
||||
tenantId: "tenant-1",
|
||||
},
|
||||
channelData: { tenant: { id: "tenant-1" } },
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
289
extensions/msteams/src/sdk-proactive.ts
Normal file
289
extensions/msteams/src/sdk-proactive.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { normalizeBotFrameworkServiceUrl } from "./bot-framework-service-url.js";
|
||||
import {
|
||||
validateMSTeamsProactiveServiceUrlBoundary,
|
||||
type MSTeamsSdkCloudOptions,
|
||||
} from "./cloud.js";
|
||||
import type { MSTeamsApp } from "./sdk.js";
|
||||
|
||||
type MSTeamsAccountRef = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
aadObjectId?: string;
|
||||
};
|
||||
|
||||
export type MSTeamsSdkReferenceSource = {
|
||||
activityId?: string;
|
||||
user?: MSTeamsAccountRef;
|
||||
agent?: MSTeamsAccountRef | null;
|
||||
bot?: MSTeamsAccountRef | null;
|
||||
conversation: { id: string; conversationType?: string; tenantId?: string };
|
||||
channelId?: string;
|
||||
serviceUrl?: string;
|
||||
locale?: string;
|
||||
tenantId?: string;
|
||||
aadObjectId?: string;
|
||||
};
|
||||
|
||||
type MSTeamsSdkConversationReference = {
|
||||
activityId?: string;
|
||||
channelId: "msteams";
|
||||
serviceUrl: string;
|
||||
bot: MSTeamsAccountRef & { id: string; role: "bot" };
|
||||
conversation: { id: string; conversationType?: string; tenantId?: string };
|
||||
locale?: string;
|
||||
user?: MSTeamsAccountRef;
|
||||
tenantId?: string;
|
||||
aadObjectId?: string;
|
||||
};
|
||||
|
||||
type MSTeamsActivitiesClient = {
|
||||
create(activity: unknown): Promise<{ id?: string }>;
|
||||
createTargeted?(activity: unknown): Promise<{ id?: string }>;
|
||||
update(activityId: string, activity: unknown): Promise<unknown>;
|
||||
updateTargeted?(activityId: string, activity: unknown): Promise<unknown>;
|
||||
delete(activityId: string): Promise<unknown>;
|
||||
};
|
||||
|
||||
type MSTeamsApiClient = {
|
||||
serviceUrl?: string;
|
||||
http?: unknown;
|
||||
conversations: {
|
||||
activities(conversationId: string): MSTeamsActivitiesClient;
|
||||
};
|
||||
};
|
||||
|
||||
type MSTeamsApiClientCtor = new (
|
||||
serviceUrl: string,
|
||||
options?: unknown,
|
||||
apiClientSettings?: unknown,
|
||||
) => unknown;
|
||||
|
||||
type MSTeamsApiModule = {
|
||||
Client: MSTeamsApiClientCtor;
|
||||
};
|
||||
|
||||
type MSTeamsProactiveOptions = {
|
||||
threadActivityId?: string;
|
||||
serviceUrlBoundary?: MSTeamsSdkCloudOptions;
|
||||
};
|
||||
|
||||
let apiModulePromise: Promise<MSTeamsApiModule> | null = null;
|
||||
|
||||
async function loadMSTeamsApiModule(): Promise<MSTeamsApiModule> {
|
||||
apiModulePromise ??= import("@microsoft/teams.api") as unknown as Promise<MSTeamsApiModule>;
|
||||
return apiModulePromise;
|
||||
}
|
||||
|
||||
function resolveThreadedConversationId(conversationId: string, threadActivityId?: string): string {
|
||||
if (!threadActivityId) {
|
||||
return conversationId.split(";")[0] ?? conversationId;
|
||||
}
|
||||
const baseId = conversationId.split(";")[0] ?? conversationId;
|
||||
return `${baseId};messageid=${threadActivityId}`;
|
||||
}
|
||||
|
||||
function normalizeRequiredServiceUrl(ref: MSTeamsSdkReferenceSource): string {
|
||||
if (!ref.serviceUrl) {
|
||||
throw new Error("Invalid stored reference: missing serviceUrl");
|
||||
}
|
||||
return normalizeBotFrameworkServiceUrl(ref.serviceUrl);
|
||||
}
|
||||
|
||||
function buildSdkConversationReference(
|
||||
source: MSTeamsSdkReferenceSource,
|
||||
options?: MSTeamsProactiveOptions,
|
||||
): MSTeamsSdkConversationReference {
|
||||
const bot = source.agent ?? source.bot ?? undefined;
|
||||
if (!bot?.id) {
|
||||
throw new Error("Invalid stored reference: missing agent.id");
|
||||
}
|
||||
|
||||
const conversationId = resolveThreadedConversationId(
|
||||
source.conversation.id,
|
||||
options?.threadActivityId,
|
||||
);
|
||||
const tenantId = source.tenantId ?? source.conversation.tenantId;
|
||||
const serviceUrl = normalizeRequiredServiceUrl(source);
|
||||
|
||||
if (options?.serviceUrlBoundary) {
|
||||
validateMSTeamsProactiveServiceUrlBoundary({
|
||||
cloud: options.serviceUrlBoundary.cloud,
|
||||
conversationId,
|
||||
storedServiceUrl: serviceUrl,
|
||||
configuredServiceUrl: options.serviceUrlBoundary.serviceUrl,
|
||||
});
|
||||
}
|
||||
|
||||
const botRef = {
|
||||
...bot,
|
||||
id: bot.id,
|
||||
role: "bot" as const,
|
||||
};
|
||||
|
||||
return {
|
||||
activityId: source.activityId,
|
||||
channelId: "msteams",
|
||||
serviceUrl,
|
||||
bot: botRef,
|
||||
conversation: {
|
||||
id: conversationId,
|
||||
conversationType: source.conversation.conversationType,
|
||||
...(tenantId ? { tenantId } : {}),
|
||||
},
|
||||
locale: source.locale,
|
||||
user: source.user,
|
||||
...(tenantId ? { tenantId } : {}),
|
||||
...(source.aadObjectId ? { aadObjectId: source.aadObjectId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getStructuralApiClient(app: MSTeamsApp): MSTeamsApiClient {
|
||||
return app.api as MSTeamsApiClient;
|
||||
}
|
||||
|
||||
function sameServiceUrl(left: string | undefined, right: string): boolean {
|
||||
if (!left) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return normalizeBotFrameworkServiceUrl(left) === right;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyReferenceFallbackActivity(activity: unknown): string {
|
||||
if (typeof activity === "string") {
|
||||
return activity;
|
||||
}
|
||||
if (activity == null) {
|
||||
return "";
|
||||
}
|
||||
if (
|
||||
typeof activity === "number" ||
|
||||
typeof activity === "boolean" ||
|
||||
typeof activity === "bigint"
|
||||
) {
|
||||
return String(activity);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function getApiClientForReference(
|
||||
app: MSTeamsApp,
|
||||
ref: MSTeamsSdkConversationReference,
|
||||
): Promise<MSTeamsApiClient> {
|
||||
const api = getStructuralApiClient(app);
|
||||
if (sameServiceUrl(api.serviceUrl, ref.serviceUrl)) {
|
||||
return api;
|
||||
}
|
||||
|
||||
const appInternals = app as unknown as {
|
||||
client?: unknown;
|
||||
api?: { http?: unknown };
|
||||
};
|
||||
const httpClient = appInternals.api?.http ?? appInternals.client;
|
||||
|
||||
if (!httpClient) {
|
||||
return api;
|
||||
}
|
||||
|
||||
const { Client } = await loadMSTeamsApiModule();
|
||||
return new Client(ref.serviceUrl, httpClient) as MSTeamsApiClient;
|
||||
}
|
||||
|
||||
function mergeReferenceIntoActivity(
|
||||
activity: unknown,
|
||||
ref: MSTeamsSdkConversationReference,
|
||||
): Record<string, unknown> {
|
||||
const source =
|
||||
activity && typeof activity === "object" && !Array.isArray(activity)
|
||||
? (activity as Record<string, unknown>)
|
||||
: { type: "message", text: stringifyReferenceFallbackActivity(activity) };
|
||||
const existingChannelData =
|
||||
source.channelData &&
|
||||
typeof source.channelData === "object" &&
|
||||
!Array.isArray(source.channelData)
|
||||
? (source.channelData as Record<string, unknown>)
|
||||
: undefined;
|
||||
const existingTenant =
|
||||
existingChannelData?.tenant &&
|
||||
typeof existingChannelData.tenant === "object" &&
|
||||
!Array.isArray(existingChannelData.tenant)
|
||||
? (existingChannelData.tenant as Record<string, unknown>)
|
||||
: undefined;
|
||||
let channelData = existingChannelData ? { ...existingChannelData } : undefined;
|
||||
if (ref.tenantId) {
|
||||
channelData ??= {};
|
||||
channelData.tenant = existingTenant
|
||||
? { ...existingTenant, id: ref.tenantId }
|
||||
: { id: ref.tenantId };
|
||||
}
|
||||
return {
|
||||
...source,
|
||||
channelId: ref.channelId,
|
||||
from: ref.bot,
|
||||
recipient: ref.user,
|
||||
conversation: ref.conversation,
|
||||
...(channelData ? { channelData } : {}),
|
||||
locale: ref.locale,
|
||||
...(ref.tenantId ? { tenantId: ref.tenantId } : {}),
|
||||
...(ref.aadObjectId ? { aadObjectId: ref.aadObjectId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendMSTeamsActivityWithReference(
|
||||
app: MSTeamsApp,
|
||||
source: MSTeamsSdkReferenceSource,
|
||||
activity: unknown,
|
||||
options?: MSTeamsProactiveOptions,
|
||||
): Promise<{ id?: string }> {
|
||||
const ref = buildSdkConversationReference(source, options);
|
||||
const api = await getApiClientForReference(app, ref);
|
||||
const activities = api.conversations.activities(ref.conversation.id);
|
||||
const activityWithRef = mergeReferenceIntoActivity(activity, ref);
|
||||
const isTargeted =
|
||||
(activityWithRef.recipient as { isTargeted?: unknown } | undefined)?.isTargeted === true;
|
||||
if (isTargeted && ref.conversation.conversationType === "personal") {
|
||||
throw new Error("Targeted messages are not supported in 1:1 (personal) chats.");
|
||||
}
|
||||
|
||||
const activityId = typeof activityWithRef.id === "string" ? activityWithRef.id : undefined;
|
||||
if (activityId) {
|
||||
const res =
|
||||
isTargeted && activities.updateTargeted
|
||||
? await activities.updateTargeted(activityId, activityWithRef)
|
||||
: await activities.update(activityId, activityWithRef);
|
||||
return { ...activityWithRef, ...(res && typeof res === "object" ? res : {}) };
|
||||
}
|
||||
|
||||
const res =
|
||||
isTargeted && activities.createTargeted
|
||||
? await activities.createTargeted(activityWithRef)
|
||||
: await activities.create(activityWithRef);
|
||||
return { ...activityWithRef, ...res };
|
||||
}
|
||||
|
||||
export async function updateMSTeamsActivityWithReference(
|
||||
app: MSTeamsApp,
|
||||
source: MSTeamsSdkReferenceSource,
|
||||
activityId: string,
|
||||
activity: unknown,
|
||||
options?: MSTeamsProactiveOptions,
|
||||
): Promise<unknown> {
|
||||
const ref = buildSdkConversationReference(source, options);
|
||||
const api = await getApiClientForReference(app, ref);
|
||||
return api.conversations.activities(ref.conversation.id).update(activityId, activity);
|
||||
}
|
||||
|
||||
export async function deleteMSTeamsActivityWithReference(
|
||||
app: MSTeamsApp,
|
||||
source: MSTeamsSdkReferenceSource,
|
||||
activityId: string,
|
||||
options?: MSTeamsProactiveOptions,
|
||||
): Promise<unknown> {
|
||||
const ref = buildSdkConversationReference(source, options);
|
||||
const api = await getApiClientForReference(app, ref);
|
||||
return api.conversations.activities(ref.conversation.id).delete(activityId);
|
||||
}
|
||||
@@ -48,12 +48,23 @@ type MSTeamsActivity = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/** Structural alias for ActivityParams — avoids tsgo resolution bugs with the bundled @microsoft/teams.api package. */
|
||||
export type MSTeamsActivityParams = { type?: string; [key: string]: unknown };
|
||||
/** Structural alias for ActivityLike. */
|
||||
export type MSTeamsActivityLike = MSTeamsActivityParams | string;
|
||||
|
||||
export type MSTeamsStreamer = {
|
||||
emit(activity: MSTeamsActivityParams | string): void;
|
||||
update(text: string): void;
|
||||
close(): Promise<unknown>;
|
||||
readonly canceled: boolean;
|
||||
};
|
||||
|
||||
export type MSTeamsTurnContext = {
|
||||
activity: MSTeamsActivity;
|
||||
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
|
||||
sendActivities: (
|
||||
activities: Array<{ type: string } & Record<string, unknown>>,
|
||||
) => Promise<unknown>;
|
||||
updateActivity: (activity: object) => Promise<{ id?: string } | void>;
|
||||
sendActivity: (activity: MSTeamsActivityLike) => Promise<unknown>;
|
||||
sendActivities: (activities: Array<MSTeamsActivityParams>) => Promise<unknown>;
|
||||
updateActivity: (activity: MSTeamsActivityParams) => Promise<{ id?: string } | void>;
|
||||
deleteActivity: (activityId: string) => Promise<void>;
|
||||
stream?: MSTeamsStreamer;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,8 @@ const sendContextMockState = vi.hoisted(() => {
|
||||
};
|
||||
return {
|
||||
store,
|
||||
loadMSTeamsSdkWithAuth: vi.fn(async () => ({ app: { id: "mock-app" } })),
|
||||
createMSTeamsTokenProvider: vi.fn(() => ({ getAccessToken: vi.fn() })),
|
||||
logWarn: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -30,6 +32,11 @@ vi.mock("./runtime.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./sdk.js", () => ({
|
||||
loadMSTeamsSdkWithAuth: sendContextMockState.loadMSTeamsSdkWithAuth,
|
||||
createMSTeamsTokenProvider: sendContextMockState.createMSTeamsTokenProvider,
|
||||
}));
|
||||
|
||||
function channelRef(params?: Partial<StoredConversationReference>): StoredConversationReference {
|
||||
return {
|
||||
user: { id: "user-1" },
|
||||
@@ -48,10 +55,43 @@ beforeEach(() => {
|
||||
sendContextMockState.store.remove.mockReset();
|
||||
sendContextMockState.store.findPreferredDmByUserId.mockReset();
|
||||
sendContextMockState.store.findByUserId.mockReset();
|
||||
sendContextMockState.loadMSTeamsSdkWithAuth.mockClear();
|
||||
sendContextMockState.createMSTeamsTokenProvider.mockClear();
|
||||
sendContextMockState.logWarn.mockReset();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("resolveMSTeamsSendContext", () => {
|
||||
it("ignores ambient SERVICE_URL for default public-cloud proactive sends", async () => {
|
||||
vi.stubEnv("SERVICE_URL", "https://bot.example.com/api/messages");
|
||||
sendContextMockState.store.get.mockResolvedValue(
|
||||
channelRef({
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer/",
|
||||
}),
|
||||
);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
msteams: {
|
||||
enabled: true,
|
||||
appId: "app-id",
|
||||
appPassword: "app-password",
|
||||
tenantId: "tenant-id",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
await expect(
|
||||
resolveMSTeamsSendContext({
|
||||
cfg,
|
||||
to: "conversation:19:channel@thread.tacv2",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
conversationId: "19:channel@thread.tacv2",
|
||||
sdkCloudOptions: { cloud: "Public" },
|
||||
});
|
||||
});
|
||||
|
||||
it("removes stored conversation references with blocked serviceUrl hosts", async () => {
|
||||
sendContextMockState.store.get.mockResolvedValue(
|
||||
channelRef({
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
isAllowedBotFrameworkServiceUrl,
|
||||
normalizeBotFrameworkServiceUrl,
|
||||
} from "./bot-framework-service-url.js";
|
||||
import {
|
||||
resolveMSTeamsSdkCloudOptions,
|
||||
validateMSTeamsProactiveServiceUrlBoundary,
|
||||
type MSTeamsSdkCloudOptions,
|
||||
} from "./cloud.js";
|
||||
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
|
||||
import type {
|
||||
MSTeamsConversationStore,
|
||||
@@ -19,24 +24,26 @@ import type {
|
||||
} from "./conversation-store.js";
|
||||
import { formatUnknownError } from "./errors.js";
|
||||
import { resolveGraphChatId } from "./graph-upload.js";
|
||||
import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import { resolveMSTeamsReplyPolicy, resolveMSTeamsRouteConfig } from "./policy.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import { createMSTeamsAdapter, createMSTeamsTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import type { MSTeamsApp } from "./sdk.js";
|
||||
import { createMSTeamsTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
type MSTeamsConversationType = "personal" | "groupChat" | "channel";
|
||||
export type MSTeamsConversationType = "personal" | "groupChat" | "channel";
|
||||
|
||||
export type MSTeamsProactiveContext = {
|
||||
appId: string;
|
||||
conversationId: string;
|
||||
ref: StoredConversationReference;
|
||||
adapter: MSTeamsAdapter;
|
||||
app: MSTeamsApp;
|
||||
log: ReturnType<PluginRuntime["logging"]["getChildLogger"]>;
|
||||
/** The type of conversation: personal (1:1), groupChat, or channel */
|
||||
conversationType: MSTeamsConversationType;
|
||||
/** Reply style resolved for proactive text/media sends. */
|
||||
replyStyle: MSTeamsReplyStyle;
|
||||
/** Teams SDK cloud/service endpoint used to validate proactive sends. */
|
||||
sdkCloudOptions: MSTeamsSdkCloudOptions;
|
||||
/** Token provider for Graph API / OneDrive operations */
|
||||
tokenProvider: MSTeamsAccessTokenProvider;
|
||||
/** SharePoint site ID for file uploads in group chats/channels */
|
||||
@@ -203,9 +210,14 @@ export async function resolveMSTeamsSendContext(params: {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { sdk, app } = await loadMSTeamsSdkWithAuth(creds);
|
||||
const adapter = createMSTeamsAdapter(app, sdk);
|
||||
const sdkCloudOptions = resolveMSTeamsSdkCloudOptions(msteamsCfg);
|
||||
const { app } = await loadMSTeamsSdkWithAuth(creds, sdkCloudOptions);
|
||||
validateMSTeamsProactiveServiceUrlBoundary({
|
||||
cloud: sdkCloudOptions.cloud,
|
||||
conversationId,
|
||||
storedServiceUrl: safeRef.serviceUrl,
|
||||
configuredServiceUrl: sdkCloudOptions.serviceUrl,
|
||||
});
|
||||
|
||||
// Create token provider adapter for Graph API / OneDrive operations
|
||||
const tokenProvider: MSTeamsAccessTokenProvider = createMSTeamsTokenProvider(app);
|
||||
@@ -282,10 +294,11 @@ export async function resolveMSTeamsSendContext(params: {
|
||||
appId: creds.appId,
|
||||
conversationId,
|
||||
ref: safeRef,
|
||||
adapter: adapter as unknown as MSTeamsAdapter,
|
||||
app,
|
||||
log,
|
||||
conversationType,
|
||||
replyStyle,
|
||||
sdkCloudOptions,
|
||||
tokenProvider,
|
||||
sharePointSiteId,
|
||||
mediaMaxBytes,
|
||||
|
||||
@@ -14,11 +14,18 @@ const mockState = vi.hoisted(() => ({
|
||||
prepareFileConsentActivityFs: vi.fn(),
|
||||
extractFilename: vi.fn(async () => "fallback.bin"),
|
||||
sendMSTeamsMessages: vi.fn(),
|
||||
sendMSTeamsActivityWithReference: vi.fn(async () => ({ id: "message-1" })),
|
||||
updateMSTeamsActivityWithReference: vi.fn(async () => ({ id: "updated" })),
|
||||
deleteMSTeamsActivityWithReference: vi.fn(async () => {}),
|
||||
uploadAndShareSharePoint: vi.fn(),
|
||||
getDriveItemProperties: vi.fn(),
|
||||
buildTeamsFileInfoCard: vi.fn(),
|
||||
createMSTeamsTokenProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
// `loadOutboundMediaFromUrl` is re-exported from msteams's runtime-api which
|
||||
// pulls from `openclaw/plugin-sdk/outbound-media` (post-migration). Mock the
|
||||
// canonical source so the re-export carries our stub through.
|
||||
vi.mock("openclaw/plugin-sdk/outbound-media", () => ({
|
||||
loadOutboundMediaFromUrl: mockState.loadOutboundMediaFromUrl,
|
||||
}));
|
||||
@@ -52,7 +59,16 @@ vi.mock("./media-helpers.js", () => ({
|
||||
|
||||
vi.mock("./messenger.js", () => ({
|
||||
sendMSTeamsMessages: mockState.sendMSTeamsMessages,
|
||||
buildConversationReference: () => ({}),
|
||||
buildConversationReference: (ref: Record<string, unknown>) => ({
|
||||
serviceUrl: (ref as { serviceUrl?: string }).serviceUrl ?? "https://service.example.com",
|
||||
conversation: (ref as { conversation?: Record<string, unknown> }).conversation ?? {
|
||||
id: "19:conversation@thread.tacv2",
|
||||
},
|
||||
agent: (ref as { agent?: Record<string, unknown> }).agent,
|
||||
user: (ref as { user?: Record<string, unknown> }).user,
|
||||
tenantId: (ref as { tenantId?: string }).tenantId,
|
||||
aadObjectId: (ref as { aadObjectId?: string }).aadObjectId,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
@@ -76,10 +92,49 @@ vi.mock("./graph-chat.js", () => ({
|
||||
buildTeamsFileInfoCard: mockState.buildTeamsFileInfoCard,
|
||||
}));
|
||||
|
||||
function mockContinueConversationFailure(error: string) {
|
||||
const mockContinueConversation = vi.fn().mockRejectedValue(new Error(error));
|
||||
vi.mock("./sdk.js", () => ({
|
||||
createMSTeamsTokenProvider: mockState.createMSTeamsTokenProvider,
|
||||
}));
|
||||
|
||||
vi.mock("./sdk-proactive.js", () => ({
|
||||
sendMSTeamsActivityWithReference: mockState.sendMSTeamsActivityWithReference,
|
||||
updateMSTeamsActivityWithReference: mockState.updateMSTeamsActivityWithReference,
|
||||
deleteMSTeamsActivityWithReference: mockState.deleteMSTeamsActivityWithReference,
|
||||
}));
|
||||
|
||||
function createMockApp(overrides?: {
|
||||
send?: ReturnType<typeof vi.fn>;
|
||||
update?: ReturnType<typeof vi.fn>;
|
||||
delete?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
const sendFn = overrides?.send ?? vi.fn(async () => ({ id: "message-1" }));
|
||||
const updateFn = overrides?.update ?? vi.fn(async () => ({ id: "updated" }));
|
||||
const deleteFn = overrides?.delete ?? vi.fn(async () => {});
|
||||
return {
|
||||
send: sendFn,
|
||||
api: {
|
||||
conversations: {
|
||||
activities: () => ({
|
||||
create: sendFn,
|
||||
update: updateFn,
|
||||
delete: deleteFn,
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mockProactiveSendContextFailure(error: string) {
|
||||
mockState.sendMSTeamsActivityWithReference.mockRejectedValue(new Error(error));
|
||||
mockState.updateMSTeamsActivityWithReference.mockRejectedValue(new Error(error));
|
||||
mockState.deleteMSTeamsActivityWithReference.mockRejectedValue(new Error(error));
|
||||
const failingApp = createMockApp({
|
||||
send: vi.fn().mockRejectedValue(new Error(error)),
|
||||
update: vi.fn().mockRejectedValue(new Error(error)),
|
||||
delete: vi.fn().mockRejectedValue(new Error(error)),
|
||||
});
|
||||
mockState.resolveMSTeamsSendContext.mockResolvedValue({
|
||||
adapter: { continueConversation: mockContinueConversation },
|
||||
app: failingApp,
|
||||
appId: "app-id",
|
||||
conversationId: "19:conversation@thread.tacv2",
|
||||
ref: {
|
||||
@@ -90,52 +145,18 @@ function mockContinueConversationFailure(error: string) {
|
||||
},
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
conversationType: "personal",
|
||||
sdkCloudOptions: { cloud: "Public" },
|
||||
tokenProvider: {},
|
||||
});
|
||||
return mockContinueConversation;
|
||||
}
|
||||
|
||||
const continueConversationFailureCases = [
|
||||
{
|
||||
name: "editMessageMSTeams",
|
||||
error: "Service unavailable",
|
||||
expected: "msteams edit failed",
|
||||
invoke: () =>
|
||||
editMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "conversation:19:conversation@thread.tacv2",
|
||||
activityId: "activity-123",
|
||||
text: "Updated text",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "deleteMessageMSTeams",
|
||||
error: "Not found",
|
||||
expected: "msteams delete failed",
|
||||
invoke: () =>
|
||||
deleteMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "conversation:19:conversation@thread.tacv2",
|
||||
activityId: "activity-456",
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function createSharePointSendContext(params: {
|
||||
conversationId: string;
|
||||
graphChatId: string | null;
|
||||
siteId: string;
|
||||
}) {
|
||||
return {
|
||||
adapter: {
|
||||
continueConversation: vi.fn(
|
||||
async (
|
||||
_id: string,
|
||||
_ref: unknown,
|
||||
fn: (ctx: { sendActivity: () => { id: "msg-1" } }) => Promise<void>,
|
||||
) => fn({ sendActivity: () => ({ id: "msg-1" }) }),
|
||||
),
|
||||
},
|
||||
app: createMockApp(),
|
||||
appId: "app-id",
|
||||
conversationId: params.conversationId,
|
||||
graphChatId: params.graphChatId,
|
||||
@@ -143,6 +164,7 @@ function createSharePointSendContext(params: {
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
conversationType: "groupChat" as const,
|
||||
replyStyle: "top-level" as const,
|
||||
sdkCloudOptions: { cloud: "Public" as const },
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
||||
mediaMaxBytes: 8 * 1024 * 1024,
|
||||
sharePointSiteId: params.siteId,
|
||||
@@ -185,28 +207,16 @@ type MockWithCalls = {
|
||||
mock: { calls: unknown[][] };
|
||||
};
|
||||
|
||||
function mockCallAt(mock: MockWithCalls, index = 0): unknown[] {
|
||||
const call = mock.mock.calls[index];
|
||||
if (!call) {
|
||||
throw new Error(`expected mock call ${index}`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function firstObjectArg(mock: MockWithCalls): Record<string, unknown> {
|
||||
const value = mockCallAt(mock)[0];
|
||||
const value = mock.mock.calls[0]?.[0];
|
||||
if (value === undefined || value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("expected first mock call to receive an object argument");
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function continueConversationCall(mock: MockWithCalls): unknown[] {
|
||||
return mockCallAt(mock);
|
||||
}
|
||||
|
||||
function continueConversationRef(mock: MockWithCalls): Record<string, unknown> {
|
||||
const ref = continueConversationCall(mock)[1];
|
||||
const ref = mock.mock.calls[0]?.[1];
|
||||
if (ref === undefined || ref === null || typeof ref !== "object" || Array.isArray(ref)) {
|
||||
throw new Error("expected continueConversation ref object");
|
||||
}
|
||||
@@ -230,6 +240,9 @@ describe("sendMessageMSTeams", () => {
|
||||
mockState.prepareFileConsentActivityFs.mockReset();
|
||||
mockState.extractFilename.mockReset();
|
||||
mockState.sendMSTeamsMessages.mockReset();
|
||||
mockState.sendMSTeamsActivityWithReference.mockReset();
|
||||
mockState.updateMSTeamsActivityWithReference.mockReset();
|
||||
mockState.deleteMSTeamsActivityWithReference.mockReset();
|
||||
mockState.uploadAndShareSharePoint.mockReset();
|
||||
mockState.getDriveItemProperties.mockReset();
|
||||
mockState.buildTeamsFileInfoCard.mockReset();
|
||||
@@ -237,18 +250,22 @@ describe("sendMessageMSTeams", () => {
|
||||
mockState.extractFilename.mockResolvedValue("fallback.bin");
|
||||
mockState.requiresFileConsent.mockReturnValue(false);
|
||||
mockState.resolveMSTeamsSendContext.mockResolvedValue({
|
||||
adapter: {},
|
||||
app: createMockApp(),
|
||||
appId: "app-id",
|
||||
conversationId: "19:conversation@thread.tacv2",
|
||||
ref: {},
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
conversationType: "personal",
|
||||
replyStyle: "top-level",
|
||||
sdkCloudOptions: { cloud: "Public" },
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
||||
mediaMaxBytes: 8 * 1024,
|
||||
sharePointSiteId: undefined,
|
||||
});
|
||||
mockState.sendMSTeamsMessages.mockResolvedValue(["message-1"]);
|
||||
mockState.sendMSTeamsActivityWithReference.mockResolvedValue({ id: "message-1" });
|
||||
mockState.updateMSTeamsActivityWithReference.mockResolvedValue({ id: "updated" });
|
||||
mockState.deleteMSTeamsActivityWithReference.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("loads media through shared helper and forwards mediaLocalRoots", async () => {
|
||||
@@ -331,6 +348,7 @@ describe("sendMessageMSTeams", () => {
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
conversationType: "channel",
|
||||
replyStyle: "thread",
|
||||
sdkCloudOptions: { cloud: "Public" },
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
||||
mediaMaxBytes: 8 * 1024,
|
||||
sharePointSiteId: undefined,
|
||||
@@ -357,6 +375,7 @@ describe("sendMessageMSTeams", () => {
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
conversationType: "channel",
|
||||
replyStyle: "top-level",
|
||||
sdkCloudOptions: { cloud: "Public" },
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
||||
mediaMaxBytes: 8 * 1024,
|
||||
sharePointSiteId: undefined,
|
||||
@@ -439,35 +458,20 @@ describe("MSTeams continueConversation failure handling", () => {
|
||||
beforeEach(() => {
|
||||
mockState.resolveMSTeamsSendContext.mockReset();
|
||||
});
|
||||
|
||||
it.each(continueConversationFailureCases)(
|
||||
"$name throws a descriptive error when continueConversation fails",
|
||||
async ({ error, expected, invoke }) => {
|
||||
mockContinueConversationFailure(error);
|
||||
|
||||
await expect(invoke()).rejects.toThrow(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("editMessageMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
mockState.resolveMSTeamsSendContext.mockReset();
|
||||
mockState.updateMSTeamsActivityWithReference.mockReset();
|
||||
mockState.updateMSTeamsActivityWithReference.mockResolvedValue({ id: "updated" });
|
||||
});
|
||||
|
||||
it("calls continueConversation and updateActivity with correct params", async () => {
|
||||
const mockUpdateActivity = vi.fn();
|
||||
const mockContinueConversation = vi.fn(
|
||||
async (_appId: string, _ref: unknown, logic: (ctx: unknown) => Promise<void>) => {
|
||||
await logic({
|
||||
sendActivity: vi.fn(),
|
||||
updateActivity: mockUpdateActivity,
|
||||
deleteActivity: vi.fn(),
|
||||
});
|
||||
},
|
||||
);
|
||||
it("updates with the resolved Teams conversation reference", async () => {
|
||||
const mockUpdateActivity = vi.fn(async () => ({ id: "updated" }));
|
||||
const mockApp = createMockApp({ update: mockUpdateActivity });
|
||||
mockState.resolveMSTeamsSendContext.mockResolvedValue({
|
||||
adapter: { continueConversation: mockContinueConversation },
|
||||
app: mockApp,
|
||||
appId: "app-id",
|
||||
conversationId: "19:conversation@thread.tacv2",
|
||||
ref: {
|
||||
@@ -478,6 +482,7 @@ describe("editMessageMSTeams", () => {
|
||||
},
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
conversationType: "personal",
|
||||
sdkCloudOptions: { cloud: "Public" },
|
||||
tokenProvider: {},
|
||||
});
|
||||
|
||||
@@ -489,37 +494,49 @@ describe("editMessageMSTeams", () => {
|
||||
});
|
||||
|
||||
expect(result.conversationId).toBe("19:conversation@thread.tacv2");
|
||||
expect(mockContinueConversation).toHaveBeenCalledTimes(1);
|
||||
const call = continueConversationCall(mockContinueConversation);
|
||||
expect(call[0]).toBe("app-id");
|
||||
expect(continueConversationRef(mockContinueConversation).activityId).toBeUndefined();
|
||||
expect(typeof call[2]).toBe("function");
|
||||
expect(mockUpdateActivity).toHaveBeenCalledWith({
|
||||
|
||||
expect(mockState.updateMSTeamsActivityWithReference).toHaveBeenCalledWith(
|
||||
mockApp,
|
||||
expect.objectContaining({
|
||||
conversation: { id: "19:conversation@thread.tacv2", conversationType: "personal" },
|
||||
serviceUrl: "https://service.example.com",
|
||||
}),
|
||||
"activity-123",
|
||||
{
|
||||
type: "message",
|
||||
id: "activity-123",
|
||||
text: "Updated message text",
|
||||
},
|
||||
{ serviceUrlBoundary: { cloud: "Public" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("throws a descriptive error when update fails", async () => {
|
||||
mockProactiveSendContextFailure("Service unavailable");
|
||||
|
||||
await expect(
|
||||
editMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "conversation:19:conversation@thread.tacv2",
|
||||
activityId: "activity-123",
|
||||
text: "Updated text",
|
||||
}),
|
||||
).rejects.toThrow("msteams edit failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteMessageMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
mockState.resolveMSTeamsSendContext.mockReset();
|
||||
mockState.deleteMSTeamsActivityWithReference.mockReset();
|
||||
mockState.deleteMSTeamsActivityWithReference.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("calls continueConversation and deleteActivity with correct activityId", async () => {
|
||||
const mockDeleteActivity = vi.fn();
|
||||
const mockContinueConversation = vi.fn(
|
||||
async (_appId: string, _ref: unknown, logic: (ctx: unknown) => Promise<void>) => {
|
||||
await logic({
|
||||
sendActivity: vi.fn(),
|
||||
updateActivity: vi.fn(),
|
||||
deleteActivity: mockDeleteActivity,
|
||||
});
|
||||
},
|
||||
);
|
||||
it("deletes with the resolved Teams conversation reference", async () => {
|
||||
const mockDeleteActivity = vi.fn(async () => {});
|
||||
const mockApp = createMockApp({ delete: mockDeleteActivity });
|
||||
mockState.resolveMSTeamsSendContext.mockResolvedValue({
|
||||
adapter: { continueConversation: mockContinueConversation },
|
||||
app: mockApp,
|
||||
appId: "app-id",
|
||||
conversationId: "19:conversation@thread.tacv2",
|
||||
ref: {
|
||||
@@ -530,6 +547,7 @@ describe("deleteMessageMSTeams", () => {
|
||||
},
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
conversationType: "groupChat",
|
||||
sdkCloudOptions: { cloud: "Public" },
|
||||
tokenProvider: {},
|
||||
});
|
||||
|
||||
@@ -540,26 +558,35 @@ describe("deleteMessageMSTeams", () => {
|
||||
});
|
||||
|
||||
expect(result.conversationId).toBe("19:conversation@thread.tacv2");
|
||||
expect(mockContinueConversation).toHaveBeenCalledTimes(1);
|
||||
const call = continueConversationCall(mockContinueConversation);
|
||||
expect(call[0]).toBe("app-id");
|
||||
expect(continueConversationRef(mockContinueConversation).activityId).toBeUndefined();
|
||||
expect(typeof call[2]).toBe("function");
|
||||
expect(mockDeleteActivity).toHaveBeenCalledWith("activity-456");
|
||||
|
||||
expect(mockState.deleteMSTeamsActivityWithReference).toHaveBeenCalledWith(
|
||||
mockApp,
|
||||
expect.objectContaining({
|
||||
conversation: { id: "19:conversation@thread.tacv2", conversationType: "groupChat" },
|
||||
serviceUrl: "https://service.example.com",
|
||||
}),
|
||||
"activity-456",
|
||||
{ serviceUrlBoundary: { cloud: "Public" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the appId and proactive ref to continueConversation", async () => {
|
||||
const mockContinueConversation = vi.fn(
|
||||
async (_appId: string, _ref: unknown, logic: (ctx: unknown) => Promise<void>) => {
|
||||
await logic({
|
||||
sendActivity: vi.fn(),
|
||||
updateActivity: vi.fn(),
|
||||
deleteActivity: vi.fn(),
|
||||
it("throws a descriptive error when delete fails", async () => {
|
||||
mockProactiveSendContextFailure("Not found");
|
||||
|
||||
await expect(
|
||||
deleteMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "conversation:19:conversation@thread.tacv2",
|
||||
activityId: "activity-456",
|
||||
}),
|
||||
).rejects.toThrow("msteams delete failed");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("uses app from the resolved context for delete operations", async () => {
|
||||
const mockDeleteActivity = vi.fn(async () => {});
|
||||
const mockApp = createMockApp({ delete: mockDeleteActivity });
|
||||
mockState.resolveMSTeamsSendContext.mockResolvedValue({
|
||||
adapter: { continueConversation: mockContinueConversation },
|
||||
app: mockApp,
|
||||
appId: "my-app-id",
|
||||
conversationId: "19:conv@thread.tacv2",
|
||||
ref: {
|
||||
@@ -571,6 +598,7 @@ describe("deleteMessageMSTeams", () => {
|
||||
},
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
conversationType: "personal",
|
||||
sdkCloudOptions: { cloud: "Public" },
|
||||
tokenProvider: {},
|
||||
});
|
||||
|
||||
@@ -580,9 +608,14 @@ describe("deleteMessageMSTeams", () => {
|
||||
activityId: "activity-789",
|
||||
});
|
||||
|
||||
// appId should be forwarded correctly
|
||||
expect(continueConversationCall(mockContinueConversation)[0]).toBe("my-app-id");
|
||||
// activityId on the proactive ref should be cleared (undefined) — proactive pattern
|
||||
expect(continueConversationRef(mockContinueConversation).activityId).toBeUndefined();
|
||||
expect(mockState.deleteMSTeamsActivityWithReference).toHaveBeenCalledWith(
|
||||
mockApp,
|
||||
expect.objectContaining({
|
||||
conversation: { id: "19:conv@thread.tacv2" },
|
||||
serviceUrl: "https://service.example.com",
|
||||
}),
|
||||
"activity-789",
|
||||
{ serviceUrlBoundary: { cloud: "Public" } },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,11 @@ import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js"
|
||||
import { setPendingUploadActivityIdFs } from "./pending-uploads-fs.js";
|
||||
import { setPendingUploadActivityId } from "./pending-uploads.js";
|
||||
import { buildMSTeamsPollCard } from "./polls.js";
|
||||
import {
|
||||
deleteMSTeamsActivityWithReference,
|
||||
sendMSTeamsActivityWithReference,
|
||||
updateMSTeamsActivityWithReference,
|
||||
} from "./sdk-proactive.js";
|
||||
import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js";
|
||||
|
||||
type SendMSTeamsMessageParams = {
|
||||
@@ -151,14 +156,14 @@ export async function sendMessageMSTeams(
|
||||
const messageText = convertMarkdownTables(text ?? "", tableMode);
|
||||
const ctx = await resolveMSTeamsSendContext({ cfg, to });
|
||||
const {
|
||||
adapter,
|
||||
appId,
|
||||
app,
|
||||
conversationId,
|
||||
ref,
|
||||
log,
|
||||
conversationType,
|
||||
tokenProvider,
|
||||
sharePointSiteId,
|
||||
sdkCloudOptions,
|
||||
} = ctx;
|
||||
|
||||
log.debug?.("sending proactive message", {
|
||||
@@ -212,11 +217,11 @@ export async function sendMessageMSTeams(
|
||||
log.debug?.("sending file consent card", { uploadId, fileName, size: media.buffer.length });
|
||||
|
||||
const messageId = await sendProactiveActivity({
|
||||
adapter,
|
||||
appId,
|
||||
app,
|
||||
ref,
|
||||
activity,
|
||||
errorPrefix: "msteams consent card send",
|
||||
serviceUrlBoundary: sdkCloudOptions,
|
||||
});
|
||||
|
||||
// Store the activity ID so the accept handler can replace the consent
|
||||
@@ -298,10 +303,10 @@ export async function sendMessageMSTeams(
|
||||
attachments: [fileCardAttachment],
|
||||
};
|
||||
const messageId = await sendProactiveActivityRaw({
|
||||
adapter,
|
||||
appId,
|
||||
app,
|
||||
ref,
|
||||
activity,
|
||||
serviceUrlBoundary: sdkCloudOptions,
|
||||
});
|
||||
|
||||
log.info("sent native file card", {
|
||||
@@ -342,10 +347,10 @@ export async function sendMessageMSTeams(
|
||||
text: messageText ? `${messageText}\n\n${fileLink}` : fileLink,
|
||||
};
|
||||
const messageId = await sendProactiveActivityRaw({
|
||||
adapter,
|
||||
appId,
|
||||
app,
|
||||
ref,
|
||||
activity,
|
||||
serviceUrlBoundary: sdkCloudOptions,
|
||||
});
|
||||
|
||||
log.info("sent message with OneDrive file link", {
|
||||
@@ -383,7 +388,7 @@ async function sendTextWithMedia(
|
||||
mediaUrl: string | undefined,
|
||||
): Promise<SendMSTeamsMessageResult> {
|
||||
const {
|
||||
adapter,
|
||||
app,
|
||||
appId,
|
||||
conversationId,
|
||||
ref,
|
||||
@@ -398,7 +403,7 @@ async function sendTextWithMedia(
|
||||
try {
|
||||
platformMessageIds = await sendMSTeamsMessages({
|
||||
replyStyle,
|
||||
adapter,
|
||||
app,
|
||||
appId,
|
||||
conversationRef: ref,
|
||||
messages: [{ text: text || undefined, mediaUrl }],
|
||||
@@ -409,6 +414,7 @@ async function sendTextWithMedia(
|
||||
tokenProvider,
|
||||
sharePointSiteId,
|
||||
mediaMaxBytes,
|
||||
serviceUrlBoundary: ctx.sdkCloudOptions,
|
||||
});
|
||||
} catch (err) {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
@@ -435,49 +441,37 @@ async function sendTextWithMedia(
|
||||
}
|
||||
|
||||
type ProactiveActivityParams = {
|
||||
adapter: MSTeamsProactiveContext["adapter"];
|
||||
appId: string;
|
||||
app: MSTeamsProactiveContext["app"];
|
||||
ref: MSTeamsProactiveContext["ref"];
|
||||
activity: Record<string, unknown>;
|
||||
errorPrefix: string;
|
||||
serviceUrlBoundary: MSTeamsProactiveContext["sdkCloudOptions"];
|
||||
};
|
||||
|
||||
type ProactiveActivityRawParams = Omit<ProactiveActivityParams, "errorPrefix">;
|
||||
|
||||
async function sendProactiveActivityRaw({
|
||||
adapter,
|
||||
appId,
|
||||
app,
|
||||
ref,
|
||||
activity,
|
||||
serviceUrlBoundary,
|
||||
}: ProactiveActivityRawParams): Promise<string> {
|
||||
const baseRef = buildConversationReference(ref);
|
||||
const proactiveRef = {
|
||||
...baseRef,
|
||||
activityId: undefined,
|
||||
};
|
||||
|
||||
let messageId = "unknown";
|
||||
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
|
||||
const response = await ctx.sendActivity(activity);
|
||||
messageId = extractMessageId(response) ?? "unknown";
|
||||
const response = await sendMSTeamsActivityWithReference(app, baseRef, activity, {
|
||||
serviceUrlBoundary,
|
||||
});
|
||||
return messageId;
|
||||
return extractMessageId(response) ?? "unknown";
|
||||
}
|
||||
|
||||
async function sendProactiveActivity({
|
||||
adapter,
|
||||
appId,
|
||||
app,
|
||||
ref,
|
||||
activity,
|
||||
errorPrefix,
|
||||
serviceUrlBoundary,
|
||||
}: ProactiveActivityParams): Promise<string> {
|
||||
try {
|
||||
return await sendProactiveActivityRaw({
|
||||
adapter,
|
||||
appId,
|
||||
ref,
|
||||
activity,
|
||||
});
|
||||
return await sendProactiveActivityRaw({ app, ref, activity, serviceUrlBoundary });
|
||||
} catch (err) {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
const hint = formatMSTeamsSendErrorHint(classification);
|
||||
@@ -496,7 +490,7 @@ export async function sendPollMSTeams(
|
||||
params: SendMSTeamsPollParams,
|
||||
): Promise<SendMSTeamsPollResult> {
|
||||
const { cfg, to, question, options, maxSelections } = params;
|
||||
const { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({
|
||||
const { app, conversationId, ref, log, sdkCloudOptions } = await resolveMSTeamsSendContext({
|
||||
cfg,
|
||||
to,
|
||||
});
|
||||
@@ -525,11 +519,11 @@ export async function sendPollMSTeams(
|
||||
|
||||
// Send poll via proactive conversation (Adaptive Cards require direct activity send)
|
||||
const messageId = await sendProactiveActivity({
|
||||
adapter,
|
||||
appId,
|
||||
app,
|
||||
ref,
|
||||
activity,
|
||||
errorPrefix: "msteams poll send",
|
||||
serviceUrlBoundary: sdkCloudOptions,
|
||||
});
|
||||
|
||||
log.info("sent poll", { conversationId, pollId: pollCard.pollId, messageId });
|
||||
@@ -548,7 +542,7 @@ export async function sendAdaptiveCardMSTeams(
|
||||
params: SendMSTeamsCardParams,
|
||||
): Promise<SendMSTeamsCardResult> {
|
||||
const { cfg, to, card } = params;
|
||||
const { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({
|
||||
const { app, conversationId, ref, log, sdkCloudOptions } = await resolveMSTeamsSendContext({
|
||||
cfg,
|
||||
to,
|
||||
});
|
||||
@@ -571,11 +565,11 @@ export async function sendAdaptiveCardMSTeams(
|
||||
|
||||
// Send card via proactive conversation
|
||||
const messageId = await sendProactiveActivity({
|
||||
adapter,
|
||||
appId,
|
||||
app,
|
||||
ref,
|
||||
activity,
|
||||
errorPrefix: "msteams card send",
|
||||
serviceUrlBoundary: sdkCloudOptions,
|
||||
});
|
||||
|
||||
log.info("sent adaptive card", { conversationId, messageId });
|
||||
@@ -617,31 +611,33 @@ type DeleteMSTeamsMessageResult = {
|
||||
/**
|
||||
* Edit (update) a previously sent message in a Teams conversation.
|
||||
*
|
||||
* Uses the Bot Framework `continueConversation` → `updateActivity` flow
|
||||
* for proactive edits outside of the original turn context.
|
||||
* Uses the Bot Framework REST API for proactive edits outside of the
|
||||
* original turn context.
|
||||
*/
|
||||
export async function editMessageMSTeams(
|
||||
params: EditMSTeamsMessageParams,
|
||||
): Promise<EditMSTeamsMessageResult> {
|
||||
const { cfg, to, activityId, text } = params;
|
||||
const { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({
|
||||
const { app, conversationId, ref, log, sdkCloudOptions } = await resolveMSTeamsSendContext({
|
||||
cfg,
|
||||
to,
|
||||
});
|
||||
|
||||
log.debug?.("editing proactive message", { conversationId, activityId, textLength: text.length });
|
||||
|
||||
const baseRef = buildConversationReference(ref);
|
||||
const proactiveRef = { ...baseRef, activityId: undefined };
|
||||
|
||||
try {
|
||||
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
|
||||
await ctx.updateActivity({
|
||||
const baseRef = buildConversationReference(ref);
|
||||
await updateMSTeamsActivityWithReference(
|
||||
app,
|
||||
baseRef,
|
||||
activityId,
|
||||
{
|
||||
type: "message",
|
||||
id: activityId,
|
||||
text,
|
||||
});
|
||||
});
|
||||
} as Record<string, unknown>,
|
||||
{ serviceUrlBoundary: sdkCloudOptions },
|
||||
);
|
||||
} catch (err) {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
const hint = formatMSTeamsSendErrorHint(classification);
|
||||
@@ -660,26 +656,24 @@ export async function editMessageMSTeams(
|
||||
/**
|
||||
* Delete a previously sent message in a Teams conversation.
|
||||
*
|
||||
* Uses the Bot Framework `continueConversation` → `deleteActivity` flow
|
||||
* for proactive deletes outside of the original turn context.
|
||||
* Uses the Bot Framework REST API for proactive deletes outside of the
|
||||
* original turn context.
|
||||
*/
|
||||
export async function deleteMessageMSTeams(
|
||||
params: DeleteMSTeamsMessageParams,
|
||||
): Promise<DeleteMSTeamsMessageResult> {
|
||||
const { cfg, to, activityId } = params;
|
||||
const { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({
|
||||
const { app, conversationId, ref, log, sdkCloudOptions } = await resolveMSTeamsSendContext({
|
||||
cfg,
|
||||
to,
|
||||
});
|
||||
|
||||
log.debug?.("deleting proactive message", { conversationId, activityId });
|
||||
|
||||
const baseRef = buildConversationReference(ref);
|
||||
const proactiveRef = { ...baseRef, activityId: undefined };
|
||||
|
||||
try {
|
||||
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
|
||||
await ctx.deleteActivity(activityId);
|
||||
const baseRef = buildConversationReference(ref);
|
||||
await deleteMSTeamsActivityWithReference(app, baseRef, activityId, {
|
||||
serviceUrlBoundary: sdkCloudOptions,
|
||||
});
|
||||
} catch (err) {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
|
||||
@@ -48,10 +48,7 @@ type BotFrameworkUserTokenResponse = {
|
||||
expiration?: string;
|
||||
};
|
||||
|
||||
export type MSTeamsSsoFetch = (
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
export type MSTeamsSsoFetch = (input: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export type MSTeamsSsoDeps = {
|
||||
tokenProvider: MSTeamsAccessTokenProvider;
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TeamsHttpStream } from "./streaming-message.js";
|
||||
|
||||
async function flushStreamTimer(): Promise<void> {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
}
|
||||
|
||||
function requireMessageActivity(sent: unknown[]): Record<string, unknown> {
|
||||
const activity = sent.find((entry) => (entry as Record<string, unknown>).type === "message") as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!activity) {
|
||||
throw new Error("expected final Teams message activity");
|
||||
}
|
||||
return activity;
|
||||
}
|
||||
|
||||
function requireEntities(activity: Record<string, unknown>): Array<Record<string, unknown>> {
|
||||
const entities = activity.entities;
|
||||
if (!Array.isArray(entities)) {
|
||||
throw new Error("expected Teams activity entities");
|
||||
}
|
||||
return entities as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function requireEntity(
|
||||
activity: Record<string, unknown>,
|
||||
predicate: (entity: Record<string, unknown>) => boolean,
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
const entity = requireEntities(activity).find(predicate);
|
||||
if (!entity) {
|
||||
throw new Error(`expected ${label} entity`);
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
function requireSendActivity(
|
||||
sendActivity: ReturnType<typeof vi.fn>,
|
||||
predicate: (activity: Record<string, unknown>) => boolean,
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
const activity = sendActivity.mock.calls
|
||||
.map(([sent]) => sent as Record<string, unknown>)
|
||||
.find(predicate);
|
||||
if (!activity) {
|
||||
throw new Error(`expected ${label} sendActivity call`);
|
||||
}
|
||||
return activity;
|
||||
}
|
||||
|
||||
describe("TeamsHttpStream", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sends first chunk as typing activity with streaminfo", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const sent: unknown[] = [];
|
||||
const stream = new TeamsHttpStream({
|
||||
sendActivity: vi.fn(async (activity) => {
|
||||
sent.push(activity);
|
||||
return { id: "stream-1" };
|
||||
}),
|
||||
throttleMs: 1,
|
||||
});
|
||||
|
||||
// Enough text to pass MIN_INITIAL_CHARS threshold
|
||||
stream.update("Hello, this is a test response that is long enough.");
|
||||
await flushStreamTimer();
|
||||
|
||||
expect(sent.length).toBeGreaterThanOrEqual(1);
|
||||
const firstActivity = sent[0] as Record<string, unknown>;
|
||||
expect(firstActivity.type).toBe("typing");
|
||||
expect(typeof firstActivity.text).toBe("string");
|
||||
expect(firstActivity.text as string).toContain("Hello");
|
||||
// Should have streaminfo entity
|
||||
const streamInfo = requireEntity(
|
||||
firstActivity,
|
||||
(entity) => entity.type === "streaminfo",
|
||||
"streaminfo",
|
||||
);
|
||||
expect(streamInfo.streamType).toBe("streaming");
|
||||
});
|
||||
|
||||
it("sends final message activity on finalize", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const sent: unknown[] = [];
|
||||
const stream = new TeamsHttpStream({
|
||||
sendActivity: vi.fn(async (activity) => {
|
||||
sent.push(activity);
|
||||
return { id: "stream-1" };
|
||||
}),
|
||||
throttleMs: 1,
|
||||
});
|
||||
|
||||
stream.update("Hello, this is a complete response for finalization testing.");
|
||||
await flushStreamTimer();
|
||||
|
||||
await stream.finalize();
|
||||
|
||||
// Find the final message activity
|
||||
const finalActivity = requireMessageActivity(sent);
|
||||
|
||||
expect(finalActivity.text).toBe("Hello, this is a complete response for finalization testing.");
|
||||
// No cursor in final
|
||||
expect(finalActivity.text as string).not.toContain("\u258D");
|
||||
|
||||
// Should have AI-generated entity
|
||||
const aiGenerated = requireEntity(
|
||||
finalActivity,
|
||||
(entity) =>
|
||||
Array.isArray(entity.additionalType) &&
|
||||
entity.additionalType.includes("AIGeneratedContent"),
|
||||
"AI-generated content",
|
||||
);
|
||||
expect(aiGenerated.additionalType).toEqual(["AIGeneratedContent"]);
|
||||
|
||||
// Should have streaminfo with final type
|
||||
const streamInfo = requireEntity(
|
||||
finalActivity,
|
||||
(entity) => entity.type === "streaminfo",
|
||||
"streaminfo",
|
||||
);
|
||||
expect(streamInfo.streamType).toBe("final");
|
||||
});
|
||||
|
||||
it("does not send below MIN_INITIAL_CHARS", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const sendActivity = vi.fn(async () => ({ id: "x" }));
|
||||
const stream = new TeamsHttpStream({ sendActivity, throttleMs: 1 });
|
||||
|
||||
stream.update("Hi");
|
||||
await flushStreamTimer();
|
||||
|
||||
expect(sendActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finalize with no content does nothing", async () => {
|
||||
const sendActivity = vi.fn(async () => ({ id: "x" }));
|
||||
const stream = new TeamsHttpStream({ sendActivity });
|
||||
|
||||
await stream.finalize();
|
||||
expect(sendActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finalize sends content even if no chunks were streamed", async () => {
|
||||
const sent: unknown[] = [];
|
||||
const stream = new TeamsHttpStream({
|
||||
sendActivity: vi.fn(async (activity) => {
|
||||
sent.push(activity);
|
||||
return { id: "msg-1" };
|
||||
}),
|
||||
});
|
||||
|
||||
// Short text — below MIN_INITIAL_CHARS, so no streaming chunk sent
|
||||
stream.update("Short");
|
||||
await stream.finalize();
|
||||
|
||||
// Should send final message even though no chunks were streamed
|
||||
expect(sent.length).toBe(1);
|
||||
const activity = sent[0] as Record<string, unknown>;
|
||||
expect(activity.type).toBe("message");
|
||||
expect(activity.text).toBe("Short");
|
||||
});
|
||||
|
||||
it("sets feedbackLoopEnabled on final message", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const sent: unknown[] = [];
|
||||
const stream = new TeamsHttpStream({
|
||||
sendActivity: vi.fn(async (activity) => {
|
||||
sent.push(activity);
|
||||
return { id: "stream-1" };
|
||||
}),
|
||||
feedbackLoopEnabled: true,
|
||||
throttleMs: 1,
|
||||
});
|
||||
|
||||
stream.update("A response long enough to pass the minimum character threshold for streaming.");
|
||||
await flushStreamTimer();
|
||||
await stream.finalize();
|
||||
|
||||
const finalActivity = sent.find(
|
||||
(a) => (a as Record<string, unknown>).type === "message",
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const channelData = finalActivity.channelData as Record<string, unknown>;
|
||||
expect(channelData.feedbackLoopEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("sends informative update with streamType informative", async () => {
|
||||
const sent: unknown[] = [];
|
||||
const stream = new TeamsHttpStream({
|
||||
sendActivity: vi.fn(async (activity) => {
|
||||
sent.push(activity);
|
||||
return { id: "stream-1" };
|
||||
}),
|
||||
});
|
||||
|
||||
await stream.sendInformativeUpdate("Thinking...");
|
||||
|
||||
expect(sent.length).toBe(1);
|
||||
const activity = sent[0] as Record<string, unknown>;
|
||||
expect(activity.type).toBe("typing");
|
||||
expect(activity.text).toBe("Thinking...");
|
||||
const streamInfo = requireEntity(
|
||||
activity,
|
||||
(entity) => entity.type === "streaminfo",
|
||||
"streaminfo",
|
||||
);
|
||||
expect(streamInfo.streamType).toBe("informative");
|
||||
expect(streamInfo.streamSequence).toBe(1);
|
||||
});
|
||||
|
||||
it("informative update establishes streamId for subsequent chunks", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const sent: unknown[] = [];
|
||||
const stream = new TeamsHttpStream({
|
||||
sendActivity: vi.fn(async (activity) => {
|
||||
sent.push(activity);
|
||||
return { id: "stream-1" };
|
||||
}),
|
||||
throttleMs: 1,
|
||||
});
|
||||
|
||||
await stream.sendInformativeUpdate("Working...");
|
||||
stream.update("Hello, this is a long enough response for streaming to begin.");
|
||||
await flushStreamTimer();
|
||||
|
||||
// Second activity (streaming chunk) should have the streamId from the informative update
|
||||
expect(sent.length).toBeGreaterThanOrEqual(2);
|
||||
const chunk = sent[1] as Record<string, unknown>;
|
||||
const streamInfo = requireEntity(chunk, (entity) => entity.type === "streaminfo", "streaminfo");
|
||||
expect(streamInfo.streamId).toBe("stream-1");
|
||||
});
|
||||
|
||||
it("reports failure when replacing informative progress with final text fails", async () => {
|
||||
const sendActivity = vi.fn(async (activity: Record<string, unknown>) => {
|
||||
if (activity.type === "message") {
|
||||
throw new Error("final send rejected");
|
||||
}
|
||||
return { id: "stream-1" };
|
||||
});
|
||||
const stream = new TeamsHttpStream({ sendActivity, throttleMs: 1 });
|
||||
|
||||
await stream.sendInformativeUpdate("Thinking");
|
||||
const carried = await stream.replaceInformativeWithFinal(
|
||||
"Final response long enough to stream before the final message send fails.",
|
||||
);
|
||||
|
||||
expect(carried).toBe(false);
|
||||
expect(stream.isFailed).toBe(true);
|
||||
const finalSend = requireSendActivity(
|
||||
sendActivity,
|
||||
(activity) => activity.type === "message",
|
||||
"final message",
|
||||
);
|
||||
expect(finalSend.type).toBe("message");
|
||||
expect(finalSend.text).toBe(
|
||||
"Final response long enough to stream before the final message send fails.",
|
||||
);
|
||||
});
|
||||
|
||||
it("hasContent is true after update", () => {
|
||||
const stream = new TeamsHttpStream({
|
||||
sendActivity: vi.fn(async () => ({ id: "x" })),
|
||||
});
|
||||
|
||||
expect(stream.hasContent).toBe(false);
|
||||
stream.update("some text");
|
||||
expect(stream.hasContent).toBe(true);
|
||||
});
|
||||
|
||||
it("double finalize is a no-op", async () => {
|
||||
const sendActivity = vi.fn(async () => ({ id: "x" }));
|
||||
const stream = new TeamsHttpStream({ sendActivity });
|
||||
|
||||
stream.update("A response long enough to pass the minimum character threshold.");
|
||||
await stream.finalize();
|
||||
const callCount = sendActivity.mock.calls.length;
|
||||
|
||||
await stream.finalize();
|
||||
expect(sendActivity.mock.calls.length).toBe(callCount);
|
||||
});
|
||||
|
||||
it("stops streaming before stream age timeout and finalizes with last good text", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const sent: unknown[] = [];
|
||||
const sendActivity = vi.fn(async (activity) => {
|
||||
sent.push(activity);
|
||||
return { id: "stream-1" };
|
||||
});
|
||||
const stream = new TeamsHttpStream({ sendActivity, throttleMs: 1 });
|
||||
|
||||
stream.update("Hello, this is a long enough response for streaming to begin.");
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
stream.update(
|
||||
"Hello, this is a long enough response for streaming to begin. More text before timeout.",
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
vi.setSystemTime(new Date(Date.now() + 45_001));
|
||||
stream.update(
|
||||
"Hello, this is a long enough response for streaming to begin. More text before timeout. Even more text after timeout.",
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(stream.isFailed).toBe(true);
|
||||
|
||||
const finalActivity = requireMessageActivity(sent);
|
||||
|
||||
expect(finalActivity.text).toBe(
|
||||
"Hello, this is a long enough response for streaming to begin. More text before timeout.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,327 +0,0 @@
|
||||
/**
|
||||
* Teams streaming message using the streaminfo entity protocol.
|
||||
*
|
||||
* Follows the official Teams SDK pattern:
|
||||
* 1. First chunk → POST a typing activity with streaminfo entity (streamType: "streaming")
|
||||
* 2. Subsequent chunks → POST typing activities with streaminfo + incrementing streamSequence
|
||||
* 3. Finalize → POST a message activity with streaminfo (streamType: "final")
|
||||
*
|
||||
* Uses the shared draft-stream-loop for throttling (avoids rate limits).
|
||||
*/
|
||||
|
||||
import { createDraftStreamLoop, type DraftStreamLoop } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
/** Default throttle interval between stream updates (ms).
|
||||
* Teams docs recommend buffering tokens for 1.5-2s; limit is 1 req/s. */
|
||||
const DEFAULT_THROTTLE_MS = 1500;
|
||||
|
||||
/** Minimum chars before sending the first streaming message. */
|
||||
const MIN_INITIAL_CHARS = 20;
|
||||
|
||||
/** Teams message text limit. */
|
||||
const TEAMS_MAX_CHARS = 4000;
|
||||
|
||||
/**
|
||||
* Stop streaming before Teams expires the content stream server-side.
|
||||
* The exact service limit is opaque, so stay comfortably under it.
|
||||
*/
|
||||
const MAX_STREAM_AGE_MS = 45_000;
|
||||
|
||||
type StreamSendFn = (activity: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
type TeamsStreamOptions = {
|
||||
/** Function to send an activity (POST to Bot Framework). */
|
||||
sendActivity: StreamSendFn;
|
||||
/** Whether to enable feedback loop on the final message. */
|
||||
feedbackLoopEnabled?: boolean;
|
||||
/** Throttle interval in ms. Default: 600. */
|
||||
throttleMs?: number;
|
||||
/** Called on errors during streaming. */
|
||||
onError?: (err: unknown) => void;
|
||||
};
|
||||
|
||||
import { AI_GENERATED_ENTITY } from "./ai-entity.js";
|
||||
import { formatUnknownError } from "./errors.js";
|
||||
|
||||
function extractId(response: unknown): string | undefined {
|
||||
if (response && typeof response === "object" && "id" in response) {
|
||||
return readStringValue((response as { id?: unknown }).id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildStreamInfoEntity(
|
||||
streamId: string | undefined,
|
||||
streamType: "informative" | "streaming" | "final",
|
||||
streamSequence?: number,
|
||||
): Record<string, unknown> {
|
||||
const entity: Record<string, unknown> = {
|
||||
type: "streaminfo",
|
||||
streamType,
|
||||
};
|
||||
// streamId is only present after the first chunk (returned by the service)
|
||||
if (streamId) {
|
||||
entity.streamId = streamId;
|
||||
}
|
||||
// streamSequence must be present for start/continue, but NOT for final
|
||||
if (streamSequence != null) {
|
||||
entity.streamSequence = streamSequence;
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
export class TeamsHttpStream {
|
||||
private sendActivity: StreamSendFn;
|
||||
private feedbackLoopEnabled: boolean;
|
||||
private onError?: (err: unknown) => void;
|
||||
|
||||
private accumulatedText = "";
|
||||
private streamId: string | undefined = undefined;
|
||||
private sequenceNumber = 0;
|
||||
private stopped = false;
|
||||
private finalized = false;
|
||||
private streamFailed = false;
|
||||
private lastStreamedText = "";
|
||||
private finalMessageId: string | undefined = undefined;
|
||||
private streamStartedAt: number | undefined = undefined;
|
||||
private loop: DraftStreamLoop;
|
||||
|
||||
constructor(options: TeamsStreamOptions) {
|
||||
this.sendActivity = options.sendActivity;
|
||||
this.feedbackLoopEnabled = options.feedbackLoopEnabled ?? false;
|
||||
this.onError = options.onError;
|
||||
|
||||
this.loop = createDraftStreamLoop({
|
||||
throttleMs: options.throttleMs ?? DEFAULT_THROTTLE_MS,
|
||||
isStopped: () => this.stopped,
|
||||
sendOrEditStreamMessage: (text) => this.pushStreamChunk(text),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an informative status update (blue progress bar in Teams).
|
||||
* Call this immediately when a message is received, before LLM starts generating.
|
||||
* Establishes the stream so subsequent chunks continue from this stream ID.
|
||||
*/
|
||||
async sendInformativeUpdate(text: string): Promise<void> {
|
||||
if (this.stopped || this.finalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sequenceNumber++;
|
||||
|
||||
const activity: Record<string, unknown> = {
|
||||
type: "typing",
|
||||
text,
|
||||
entities: [buildStreamInfoEntity(this.streamId, "informative", this.sequenceNumber)],
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.sendActivity(activity);
|
||||
if (!this.streamId) {
|
||||
this.streamId = extractId(response);
|
||||
}
|
||||
} catch (err) {
|
||||
this.onError?.(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingest partial text from the LLM token stream.
|
||||
* Called by onPartialReply — accumulates text and throttles updates.
|
||||
*/
|
||||
update(text: string): void {
|
||||
if (this.stopped || this.finalized) {
|
||||
return;
|
||||
}
|
||||
this.accumulatedText = text;
|
||||
|
||||
// Wait for minimum chars before first send (avoids push notification flicker)
|
||||
if (!this.streamId && this.accumulatedText.length < MIN_INITIAL_CHARS) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Text exceeded Teams limit — finalize immediately with what we have
|
||||
// so the user isn't left waiting while the LLM keeps generating.
|
||||
if (this.accumulatedText.length > TEAMS_MAX_CHARS) {
|
||||
this.streamFailed = true;
|
||||
void this.finalize();
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop early before Teams expires the stream server-side. finalize() will
|
||||
// close the stream with the last good content, and reply-stream-controller
|
||||
// will deliver any remaining suffix via normal fallback delivery.
|
||||
if (this.streamStartedAt && Date.now() - this.streamStartedAt >= MAX_STREAM_AGE_MS) {
|
||||
this.streamFailed = true;
|
||||
void this.finalize();
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't append cursor — Teams requires each chunk to be a prefix of subsequent chunks.
|
||||
// The cursor character would cause "content should contain previously streamed content" errors.
|
||||
this.loop.update(this.accumulatedText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an informative progress update with final answer text.
|
||||
* Returns false when the stream could not safely carry the final text, so
|
||||
* callers can deliver the answer through the normal Teams message path.
|
||||
*/
|
||||
async replaceInformativeWithFinal(text: string): Promise<boolean> {
|
||||
if (this.stopped || this.finalized) {
|
||||
return false;
|
||||
}
|
||||
this.update(text);
|
||||
await this.loop.flush();
|
||||
await this.finalize();
|
||||
return !this.streamFailed && this.hasContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize the stream — send the final message activity.
|
||||
*/
|
||||
async finalize(): Promise<string | undefined> {
|
||||
if (this.finalized) {
|
||||
return this.finalMessageId;
|
||||
}
|
||||
this.finalized = true;
|
||||
this.stopped = true;
|
||||
this.loop.stop();
|
||||
await this.loop.waitForInFlight();
|
||||
|
||||
// If no text was streamed (e.g. agent sent a card via tool instead of
|
||||
// streaming text), just return. Teams auto-clears the informative progress
|
||||
// bar after its streaming timeout. Sending an empty final message fails
|
||||
// with 403.
|
||||
if (!this.accumulatedText.trim()) {
|
||||
return this.finalMessageId;
|
||||
}
|
||||
|
||||
// If streaming failed (>4000 chars or POST errors), close the stream
|
||||
// with the last successfully streamed text so Teams removes the "Stop"
|
||||
// button and replaces the partial chunks. deliver() handles the complete
|
||||
// response since hasContent returns false when streamFailed is true.
|
||||
if (this.streamFailed) {
|
||||
if (this.streamId) {
|
||||
try {
|
||||
const response = await this.sendActivity({
|
||||
type: "message",
|
||||
text: this.lastStreamedText || "",
|
||||
channelData: { feedbackLoopEnabled: this.feedbackLoopEnabled },
|
||||
entities: [AI_GENERATED_ENTITY, buildStreamInfoEntity(this.streamId, "final")],
|
||||
});
|
||||
this.finalMessageId = extractId(response);
|
||||
} catch {
|
||||
// Best effort — stream will auto-close after Teams timeout
|
||||
}
|
||||
}
|
||||
return this.finalMessageId;
|
||||
}
|
||||
|
||||
// Send final message activity.
|
||||
// Per the spec: type=message, streamType=final, NO streamSequence.
|
||||
try {
|
||||
const entities: Array<Record<string, unknown>> = [AI_GENERATED_ENTITY];
|
||||
if (this.streamId) {
|
||||
entities.push(buildStreamInfoEntity(this.streamId, "final"));
|
||||
}
|
||||
|
||||
const finalActivity: Record<string, unknown> = {
|
||||
type: "message",
|
||||
text: this.accumulatedText,
|
||||
channelData: {
|
||||
feedbackLoopEnabled: this.feedbackLoopEnabled,
|
||||
},
|
||||
entities,
|
||||
};
|
||||
|
||||
const response = await this.sendActivity(finalActivity);
|
||||
this.finalMessageId = extractId(response);
|
||||
} catch (err) {
|
||||
this.streamFailed = true;
|
||||
this.onError?.(err);
|
||||
}
|
||||
return this.finalMessageId;
|
||||
}
|
||||
|
||||
/** Whether streaming successfully delivered content (at least one chunk sent, not failed). */
|
||||
get hasContent(): boolean {
|
||||
return this.accumulatedText.length > 0 && !this.streamFailed;
|
||||
}
|
||||
|
||||
/** Whether streaming failed and fallback delivery is needed. */
|
||||
get isFailed(): boolean {
|
||||
return this.streamFailed;
|
||||
}
|
||||
|
||||
/** Number of characters successfully streamed before failure. */
|
||||
get streamedLength(): number {
|
||||
return this.lastStreamedText.length;
|
||||
}
|
||||
|
||||
/** Whether the stream has been finalized. */
|
||||
get isFinalized(): boolean {
|
||||
return this.finalized;
|
||||
}
|
||||
|
||||
/** Platform id returned by the final message activity, when available. */
|
||||
get messageId(): string | undefined {
|
||||
return this.finalMessageId;
|
||||
}
|
||||
|
||||
/** Stream id returned by the first streaminfo activity, when available. */
|
||||
get previewStreamId(): string | undefined {
|
||||
return this.streamId;
|
||||
}
|
||||
|
||||
/** Whether streaming fell back (not used in this implementation). */
|
||||
get isFallback(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a single streaming chunk as a typing activity with streaminfo.
|
||||
* Per the Teams REST API spec:
|
||||
* - First chunk: no streamId, streamSequence=1 → returns 201 with { id: streamId }
|
||||
* - Subsequent chunks: include streamId, increment streamSequence → returns 202
|
||||
*/
|
||||
private async pushStreamChunk(text: string): Promise<boolean> {
|
||||
if (this.stopped && !this.finalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.sequenceNumber++;
|
||||
|
||||
const activity: Record<string, unknown> = {
|
||||
type: "typing",
|
||||
text,
|
||||
entities: [buildStreamInfoEntity(this.streamId, "streaming", this.sequenceNumber)],
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.sendActivity(activity);
|
||||
if (!this.streamStartedAt) {
|
||||
this.streamStartedAt = Date.now();
|
||||
}
|
||||
if (!this.streamId) {
|
||||
this.streamId = extractId(response);
|
||||
}
|
||||
this.lastStreamedText = text;
|
||||
return true;
|
||||
} catch (err) {
|
||||
const axiosData = (err as { response?: { data?: unknown; status?: number } })?.response;
|
||||
const statusCode = axiosData?.status ?? (err as { statusCode?: number })?.statusCode;
|
||||
const responseBody = axiosData?.data ? JSON.stringify(axiosData.data).slice(0, 300) : "";
|
||||
const msg = formatUnknownError(err);
|
||||
this.onError?.(
|
||||
new Error(
|
||||
`stream POST failed (HTTP ${statusCode ?? "?"}): ${msg}${responseBody ? ` body=${responseBody}` : ""}`,
|
||||
),
|
||||
);
|
||||
this.streamFailed = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,18 @@ export function buildUserAgent(): string {
|
||||
return cachedUserAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-Agent fragment for the Teams SDK App's client. The SDK's Client.clone
|
||||
* merges this with its own `teams.ts[apps]/<sdk-version>` identifier, so we
|
||||
* only contribute the OpenClaw piece — passing the full `buildUserAgent()`
|
||||
* would double-print the SDK token.
|
||||
*
|
||||
* Format: "OpenClaw/<openclaw-version>"
|
||||
*/
|
||||
export function buildOpenClawUserAgentFragment(): string {
|
||||
return `OpenClaw/${resolveOpenClawVersion()}`;
|
||||
}
|
||||
|
||||
export function ensureUserAgentHeader(headers?: HeadersInit): Headers {
|
||||
const nextHeaders = new Headers(headers);
|
||||
if (!nextHeaders.has("User-Agent")) {
|
||||
|
||||
@@ -78,7 +78,5 @@ export function formatQaChildOutputTail(tail: QaChildOutputTail, label: string)
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
return tail.truncated
|
||||
? `[${label} truncated to last ${tail.maxBytes} bytes]\n${text}`
|
||||
: text;
|
||||
return tail.truncated ? `[${label} truncated to last ${tail.maxBytes} bytes]\n${text}` : text;
|
||||
}
|
||||
|
||||
@@ -10,10 +10,6 @@ import {
|
||||
import { DEFAULT_EMOJIS } from "openclaw/plugin-sdk/channel-feedback";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
appendQaLiveLaneIssue as appendLiveLaneIssue,
|
||||
buildQaLiveLaneArtifactsError as buildLiveLaneArtifactsError,
|
||||
} from "../shared/live-artifacts.js";
|
||||
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { chromium } from "playwright-core";
|
||||
@@ -30,6 +26,10 @@ import {
|
||||
startQaCredentialLeaseHeartbeat,
|
||||
type QaCredentialRole,
|
||||
} from "../shared/credential-lease.runtime.js";
|
||||
import {
|
||||
appendQaLiveLaneIssue as appendLiveLaneIssue,
|
||||
buildQaLiveLaneArtifactsError as buildLiveLaneArtifactsError,
|
||||
} from "../shared/live-artifacts.js";
|
||||
import { startQaLiveLaneGateway } from "../shared/live-gateway.runtime.js";
|
||||
import {
|
||||
collectLiveTransportStandardScenarioCoverage,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { appendQaLiveLaneIssue as appendLiveLaneIssue } from "./live-artifacts.js";
|
||||
import {
|
||||
startQaGatewayChild,
|
||||
type QaCliBackendAuthMode,
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
import type { QaProviderMode } from "../../model-selection.js";
|
||||
import { startQaProviderServer } from "../../providers/server-runtime.js";
|
||||
import type { QaThinkingLevel } from "../../qa-gateway-config.js";
|
||||
import { appendQaLiveLaneIssue as appendLiveLaneIssue } from "./live-artifacts.js";
|
||||
|
||||
async function stopQaLiveLaneResources(
|
||||
resources: {
|
||||
|
||||
@@ -7,16 +7,12 @@ import { startWhatsAppQaDriverSession } from "@openclaw/whatsapp/api.js";
|
||||
import { normalizeE164 } from "openclaw/plugin-sdk/account-resolution";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
appendQaLiveLaneIssue as appendLiveLaneIssue,
|
||||
buildQaLiveLaneArtifactsError as buildLiveLaneArtifactsError,
|
||||
} from "../shared/live-artifacts.js";
|
||||
import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { z } from "zod";
|
||||
import { startQaGatewayChild } from "../../gateway-child.js";
|
||||
import { fingerprintQaCredentialId } from "../../qa-credentials-fingerprint.runtime.js";
|
||||
import { DEFAULT_QA_LIVE_PROVIDER_MODE } from "../../providers/index.js";
|
||||
import { fingerprintQaCredentialId } from "../../qa-credentials-fingerprint.runtime.js";
|
||||
import {
|
||||
defaultQaModelForMode,
|
||||
normalizeQaProviderMode,
|
||||
@@ -27,6 +23,10 @@ import {
|
||||
startQaCredentialLeaseHeartbeat,
|
||||
type QaCredentialRole,
|
||||
} from "../shared/credential-lease.runtime.js";
|
||||
import {
|
||||
appendQaLiveLaneIssue as appendLiveLaneIssue,
|
||||
buildQaLiveLaneArtifactsError as buildLiveLaneArtifactsError,
|
||||
} from "../shared/live-artifacts.js";
|
||||
import { startQaLiveLaneGateway } from "../shared/live-gateway.runtime.js";
|
||||
import {
|
||||
collectLiveTransportStandardScenarioCoverage,
|
||||
|
||||
@@ -2,6 +2,15 @@ import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import {
|
||||
appendQaChildOutput,
|
||||
appendQaChildOutputTail,
|
||||
createQaChildOutputCapture,
|
||||
createQaChildOutputTail,
|
||||
formatQaChildOutputTail,
|
||||
QA_CHILD_STDOUT_MAX_BYTES,
|
||||
readQaChildOutput,
|
||||
} from "./child-output.js";
|
||||
import { resolveQaNodeExecPath } from "./node-exec.js";
|
||||
import {
|
||||
isPreferredQaLiveFrontierCatalogModel,
|
||||
@@ -13,15 +22,6 @@ import {
|
||||
createQaChannelGatewayConfig,
|
||||
QA_CHANNEL_REQUIRED_PLUGIN_IDS,
|
||||
} from "./qa-channel-transport.js";
|
||||
import {
|
||||
appendQaChildOutput,
|
||||
appendQaChildOutputTail,
|
||||
createQaChildOutputCapture,
|
||||
createQaChildOutputTail,
|
||||
formatQaChildOutputTail,
|
||||
QA_CHILD_STDOUT_MAX_BYTES,
|
||||
readQaChildOutput,
|
||||
} from "./child-output.js";
|
||||
import { buildQaGatewayConfig } from "./qa-gateway-config.js";
|
||||
|
||||
type ModelRow = {
|
||||
@@ -237,11 +237,7 @@ export async function loadQaRunnerModelOptions(params: { repoRoot: string; signa
|
||||
return;
|
||||
}
|
||||
const stderrText = formatQaChildOutputTail(stderr, "qa model catalog stderr");
|
||||
reject(
|
||||
new Error(
|
||||
`qa model catalog failed (${code ?? "unknown"}): ${stderrText}`,
|
||||
),
|
||||
);
|
||||
reject(new Error(`qa model catalog failed (${code ?? "unknown"}): ${stderrText}`));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -112,12 +112,8 @@ describe("buildQaGatewayConfig", () => {
|
||||
});
|
||||
|
||||
expect(getPrimaryModel(cfg.agents?.defaults?.model)).toBe("openai/gpt-5.5");
|
||||
expect(getModelFallbacks(cfg.agents?.defaults?.model)).toEqual([
|
||||
"anthropic/claude-opus-4-7",
|
||||
]);
|
||||
expect(getModelFallbacks(cfg.agents?.list?.[0]?.model)).toEqual([
|
||||
"anthropic/claude-opus-4-7",
|
||||
]);
|
||||
expect(getModelFallbacks(cfg.agents?.defaults?.model)).toEqual(["anthropic/claude-opus-4-7"]);
|
||||
expect(getModelFallbacks(cfg.agents?.list?.[0]?.model)).toEqual(["anthropic/claude-opus-4-7"]);
|
||||
expect(cfg.models?.providers?.openai?.api).toBe("openai-responses");
|
||||
expect(cfg.models?.providers?.openai?.request).toEqual({ allowPrivateNetwork: true });
|
||||
expect(cfg.models?.providers?.openai?.models.map((model) => model.id)).toContain("gpt-5.5");
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock("./suite-runtime-gateway.js", () => ({
|
||||
waitForTransportReady: waitForTransportReadyMock,
|
||||
}));
|
||||
|
||||
import { QA_CHILD_STDERR_TAIL_BYTES, QA_CHILD_STDOUT_MAX_BYTES } from "./child-output.js";
|
||||
import {
|
||||
findManagedDreamingCronJob,
|
||||
isManagedDreamingCronJob,
|
||||
@@ -31,7 +32,6 @@ import {
|
||||
waitForAgentRun,
|
||||
waitForMemorySearchMatch,
|
||||
} from "./suite-runtime-agent-process.js";
|
||||
import { QA_CHILD_STDERR_TAIL_BYTES, QA_CHILD_STDOUT_MAX_BYTES } from "./child-output.js";
|
||||
|
||||
type MockEmitter = {
|
||||
emit: (eventName: string | symbol, ...args: unknown[]) => boolean;
|
||||
|
||||
@@ -122,11 +122,7 @@ async function runQaCli(
|
||||
return;
|
||||
}
|
||||
const stderrText = formatQaChildOutputTail(stderr, "qa cli stderr");
|
||||
reject(
|
||||
new Error(
|
||||
`qa cli failed (${code ?? "unknown"}): ${stderrText}`,
|
||||
),
|
||||
);
|
||||
reject(new Error(`qa cli failed (${code ?? "unknown"}): ${stderrText}`));
|
||||
});
|
||||
});
|
||||
const text = readQaChildOutput(stdout).trim();
|
||||
|
||||
@@ -198,9 +198,7 @@ describe("qa suite runtime agent tools helpers", () => {
|
||||
const stderrListener = stderrOnMock.mock.calls[0]?.[1] as
|
||||
| ((chunk: unknown) => void)
|
||||
| undefined;
|
||||
stderrListener?.(
|
||||
Buffer.from(`old stderr${"x".repeat(12_000)}\nrecent MCP stderr tail`),
|
||||
);
|
||||
stderrListener?.(Buffer.from(`old stderr${"x".repeat(12_000)}\nrecent MCP stderr tail`));
|
||||
throw new Error("tool call failed");
|
||||
});
|
||||
|
||||
|
||||
6
extensions/slack/npm-shrinkwrap.json
generated
6
extensions/slack/npm-shrinkwrap.json
generated
@@ -1234,9 +1234,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
|
||||
@@ -471,7 +471,9 @@ vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
|
||||
: params.status;
|
||||
return {
|
||||
kind: "command-output",
|
||||
...((params.itemId ?? params.toolCallId) ? { id: params.itemId ?? params.toolCallId } : {}),
|
||||
...((params.itemId ?? params.toolCallId)
|
||||
? { id: params.itemId ?? params.toolCallId }
|
||||
: {}),
|
||||
text: status ?? params.title ?? params.name ?? "exec",
|
||||
label: params.name ?? "exec",
|
||||
...(status ? { status } : {}),
|
||||
@@ -482,7 +484,9 @@ vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
|
||||
return text
|
||||
? {
|
||||
kind: "item",
|
||||
...((params.itemId ?? params.toolCallId) ? { id: params.itemId ?? params.toolCallId } : {}),
|
||||
...((params.itemId ?? params.toolCallId)
|
||||
? { id: params.itemId ?? params.toolCallId }
|
||||
: {}),
|
||||
text,
|
||||
label: params.title ?? params.name ?? "Update",
|
||||
}
|
||||
|
||||
6
extensions/zalouser/npm-shrinkwrap.json
generated
6
extensions/zalouser/npm-shrinkwrap.json
generated
@@ -356,9 +356,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
|
||||
6
npm-shrinkwrap.json
generated
6
npm-shrinkwrap.json
generated
@@ -3318,9 +3318,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
|
||||
93
pnpm-lock.yaml
generated
93
pnpm-lock.yaml
generated
@@ -1100,20 +1100,14 @@ importers:
|
||||
specifier: 4.13.1
|
||||
version: 4.13.1
|
||||
'@microsoft/teams.api':
|
||||
specifier: 2.0.11
|
||||
version: 2.0.11
|
||||
specifier: 2.0.12
|
||||
version: 2.0.12
|
||||
'@microsoft/teams.apps':
|
||||
specifier: 2.0.11
|
||||
version: 2.0.11
|
||||
specifier: 2.0.12
|
||||
version: 2.0.12
|
||||
express:
|
||||
specifier: 5.2.1
|
||||
version: 5.2.1
|
||||
jsonwebtoken:
|
||||
specifier: 9.0.3
|
||||
version: 9.0.3
|
||||
jwks-rsa:
|
||||
specifier: 4.0.1
|
||||
version: 4.0.1
|
||||
typebox:
|
||||
specifier: 1.1.38
|
||||
version: 1.1.38
|
||||
@@ -1121,9 +1115,9 @@ importers:
|
||||
'@openclaw/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
'@types/jsonwebtoken':
|
||||
specifier: 9.0.10
|
||||
version: 9.0.10
|
||||
jose:
|
||||
specifier: 6.2.3
|
||||
version: 6.2.3
|
||||
openclaw:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
@@ -2899,24 +2893,24 @@ packages:
|
||||
'@mdx-js/mdx@3.1.1':
|
||||
resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==}
|
||||
|
||||
'@microsoft/teams.api@2.0.11':
|
||||
resolution: {integrity: sha512-/QvOQkqSM73O9SrDLURyJZClnOAi6fJTX6qhhka/fPZbPU4ID4BIDvee7dSRbLx7lM+nSa370uLFzHHzXp5TWQ==}
|
||||
'@microsoft/teams.api@2.0.12':
|
||||
resolution: {integrity: sha512-LQSCwRONUl09pdszTdgsRLQ0ZZcdq16goaBckzM/zKGuQkfSIT3u+3V1X2FVeND4sGt0wn+E/v29cZfhJAW4ZA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@microsoft/teams.apps@2.0.11':
|
||||
resolution: {integrity: sha512-DSk09njNbFi5pc8GOAd3/Auqy52ZmsBJqu0wRXV2VQp/L+M8e9L2SXhmyIs164jhnwD0w3DYXPOjjZKHdu1M2A==}
|
||||
'@microsoft/teams.apps@2.0.12':
|
||||
resolution: {integrity: sha512-AZWxhnuBLlUvrz1Jm1DtoB/ZfvIiML8e3PGGmJm9MXnxd6mwv8ZcL9Po8Or96KDF6E+DICRbpXBO7I3b+B+X5A==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@microsoft/teams.cards@2.0.11':
|
||||
resolution: {integrity: sha512-4ErBqR4A4abpKSXsiCssRh2ZTpE3jsYHcWXLwL+fKnJo96GzlfSUV1Zg78dl7xWxe388SlqQ3Z4r3m/v413Mew==}
|
||||
'@microsoft/teams.cards@2.0.12':
|
||||
resolution: {integrity: sha512-FVSSuOpvjpWSsoYwJI05eB4irPlaBkepgmWGFe1dhqTC2In9GWvkfNPJieyvmeDydj1jqHwwrjrkHO3MdGjiCw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@microsoft/teams.common@2.0.11':
|
||||
resolution: {integrity: sha512-XuGTRlYfLOQxJZuZI6IUhbTRQjgXZAgW59LlGnFJ/nb00G8GnJwdCrFbis+bQa+h7dP5SdLIi1ZybVGYomKgqA==}
|
||||
'@microsoft/teams.common@2.0.12':
|
||||
resolution: {integrity: sha512-gFFeWXXABOkarUViYIM4DJxNxNSTcXHv7Ds6poNyb3HODsY3kZV3EmYaDanP7KDqqXbUPlgB3LPV9bYRgcL9JQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@microsoft/teams.graph@2.0.11':
|
||||
resolution: {integrity: sha512-Txc0N6dENmEluOCwGzCerz+3G/uomfzCElla1OR7nUNICIcY8p1A2babcIAA8AZiuAKPSkck0U1w5RTu7jZgVQ==}
|
||||
'@microsoft/teams.graph@2.0.12':
|
||||
resolution: {integrity: sha512-dMioF/l/bb/cDZDZed8/7CeIZJEsREE4GwSn9V9h1/KiY004bLnjePVeLjpMt4QRoUmPn+GVokhEXztIFTYZzA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@mistralai/mistralai@2.2.5':
|
||||
@@ -5406,10 +5400,6 @@ packages:
|
||||
resolution: {integrity: sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
jwks-rsa@4.0.1:
|
||||
resolution: {integrity: sha512-poXwUA8S4cP9P5N8tZS3xnUDJH8WmwSGfKK9gIaRPdjLHyJtd9iX/cngX9CUIe0Caof5JhK2EbN7N5lnnaf9NA==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >= 23.0.0}
|
||||
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
@@ -5597,9 +5587,6 @@ packages:
|
||||
lru-memoizer@2.3.0:
|
||||
resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==}
|
||||
|
||||
lru-memoizer@3.0.0:
|
||||
resolution: {integrity: sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==}
|
||||
|
||||
lru_map@0.4.1:
|
||||
resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==}
|
||||
|
||||
@@ -6431,6 +6418,11 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
semver@7.8.1:
|
||||
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
send@1.2.1:
|
||||
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -8468,21 +8460,21 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@microsoft/teams.api@2.0.11':
|
||||
'@microsoft/teams.api@2.0.12':
|
||||
dependencies:
|
||||
'@microsoft/teams.cards': 2.0.11
|
||||
'@microsoft/teams.common': 2.0.11
|
||||
'@microsoft/teams.cards': 2.0.12
|
||||
'@microsoft/teams.common': 2.0.12
|
||||
jwt-decode: 4.0.0
|
||||
qs: 6.15.2
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
'@microsoft/teams.apps@2.0.11':
|
||||
'@microsoft/teams.apps@2.0.12':
|
||||
dependencies:
|
||||
'@azure/msal-node': 3.8.10
|
||||
'@microsoft/teams.api': 2.0.11
|
||||
'@microsoft/teams.common': 2.0.11
|
||||
'@microsoft/teams.graph': 2.0.11
|
||||
'@microsoft/teams.api': 2.0.12
|
||||
'@microsoft/teams.common': 2.0.12
|
||||
'@microsoft/teams.graph': 2.0.12
|
||||
axios: 1.16.0
|
||||
cors: 2.8.6
|
||||
express: 5.2.1
|
||||
@@ -8493,17 +8485,17 @@ snapshots:
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
'@microsoft/teams.cards@2.0.11': {}
|
||||
'@microsoft/teams.cards@2.0.12': {}
|
||||
|
||||
'@microsoft/teams.common@2.0.11':
|
||||
'@microsoft/teams.common@2.0.12':
|
||||
dependencies:
|
||||
axios: 1.16.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
'@microsoft/teams.graph@2.0.11':
|
||||
'@microsoft/teams.graph@2.0.12':
|
||||
dependencies:
|
||||
'@microsoft/teams.common': 2.0.11
|
||||
'@microsoft/teams.common': 2.0.12
|
||||
qs: 6.15.2
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
@@ -11077,7 +11069,7 @@ snapshots:
|
||||
lodash.isstring: 4.0.1
|
||||
lodash.once: 4.1.1
|
||||
ms: 2.1.3
|
||||
semver: 7.8.0
|
||||
semver: 7.8.1
|
||||
|
||||
jstransformer@1.0.0:
|
||||
dependencies:
|
||||
@@ -11107,16 +11099,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
jwks-rsa@4.0.1:
|
||||
dependencies:
|
||||
'@types/jsonwebtoken': 9.0.10
|
||||
debug: 4.4.3
|
||||
jose: 6.2.3
|
||||
limiter: 1.1.5
|
||||
lru-memoizer: 3.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
jws@4.0.1:
|
||||
dependencies:
|
||||
jwa: 2.0.1
|
||||
@@ -11267,11 +11249,6 @@ snapshots:
|
||||
lodash.clonedeep: 4.5.0
|
||||
lru-cache: 6.0.0
|
||||
|
||||
lru-memoizer@3.0.0:
|
||||
dependencies:
|
||||
lodash.clonedeep: 4.5.0
|
||||
lru-cache: 11.5.0
|
||||
|
||||
lru_map@0.4.1: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
@@ -12351,6 +12328,8 @@ snapshots:
|
||||
|
||||
semver@7.8.0: {}
|
||||
|
||||
semver@7.8.1: {}
|
||||
|
||||
send@1.2.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
|
||||
@@ -37,6 +37,11 @@ minimumReleaseAgeExclude:
|
||||
- "@earendil-works/pi-tui"
|
||||
- "@google/genai@2.6.0"
|
||||
- "@larksuiteoapi/node-sdk@1.66.0"
|
||||
- "@microsoft/teams.api@2.0.12"
|
||||
- "@microsoft/teams.apps@2.0.12"
|
||||
- "@microsoft/teams.cards@2.0.12"
|
||||
- "@microsoft/teams.common@2.0.12"
|
||||
- "@microsoft/teams.graph@2.0.12"
|
||||
- "@openai/codex"
|
||||
- "@openai/codex-*"
|
||||
- "@pierre/diffs@1.2.3"
|
||||
@@ -115,4 +120,4 @@ packageExtensions:
|
||||
optional: true
|
||||
|
||||
patchedDependencies:
|
||||
'@agentclientprotocol/claude-agent-acp@0.37.0': patches/@agentclientprotocol__claude-agent-acp@0.37.0.patch
|
||||
"@agentclientprotocol/claude-agent-acp@0.37.0": patches/@agentclientprotocol__claude-agent-acp@0.37.0.patch
|
||||
|
||||
@@ -54,7 +54,9 @@ export function appendBoundedWatchLog(current, chunk, maxChars = WATCH_LOG_CAPTU
|
||||
}
|
||||
|
||||
function formatCapturedWatchLog(text, truncated) {
|
||||
return truncated ? `[openclaw] log truncated to last ${WATCH_LOG_CAPTURE_MAX_CHARS} chars\n${text}` : text;
|
||||
return truncated
|
||||
? `[openclaw] log truncated to last ${WATCH_LOG_CAPTURE_MAX_CHARS} chars\n${text}`
|
||||
: text;
|
||||
}
|
||||
|
||||
export function updateWatchBuildDetection(state, chunk) {
|
||||
|
||||
@@ -197,10 +197,7 @@ async function fetchModels(authHeaders, attempt) {
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
text: await readBoundedResponseText(
|
||||
response,
|
||||
`Open WebUI models attempt ${attempt}`,
|
||||
),
|
||||
text: await readBoundedResponseText(response, `Open WebUI models attempt ${attempt}`),
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -227,9 +224,7 @@ async function fetchChatCompletion(authHeaders, targetModel) {
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await readBoundedResponseText(response, "Open WebUI chat completion");
|
||||
throw new Error(
|
||||
`/api/chat/completions failed: HTTP ${response.status} ${body}`,
|
||||
);
|
||||
throw new Error(`/api/chat/completions failed: HTTP ${response.status} ${body}`);
|
||||
}
|
||||
return await readBoundedResponseJson(response, "Open WebUI chat completion");
|
||||
});
|
||||
|
||||
@@ -99,9 +99,7 @@ export function resolveBoundaryRootShimsTimeoutMs(env = process.env) {
|
||||
return 300_000;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 && String(parsed) === raw.trim()
|
||||
? parsed
|
||||
: 300_000;
|
||||
return Number.isInteger(parsed) && parsed > 0 && String(parsed) === raw.trim() ? parsed : 300_000;
|
||||
}
|
||||
|
||||
function collectNewestMtime(paths, params = {}) {
|
||||
|
||||
@@ -120,14 +120,7 @@ export function resolveNpmPackageCandidatePackRunner(packageSpec, outputDir, par
|
||||
env: params.env,
|
||||
execPath: params.execPath,
|
||||
existsSync: params.existsSync,
|
||||
npmArgs: [
|
||||
"pack",
|
||||
packageSpec,
|
||||
"--ignore-scripts",
|
||||
"--json",
|
||||
"--pack-destination",
|
||||
outputDir,
|
||||
],
|
||||
npmArgs: ["pack", packageSpec, "--ignore-scripts", "--json", "--pack-destination", outputDir],
|
||||
platform: params.platform,
|
||||
});
|
||||
}
|
||||
@@ -478,10 +471,7 @@ function ipv4FromHextets(high, low) {
|
||||
}
|
||||
|
||||
function ipv4OctetsToHextets(octets) {
|
||||
return [
|
||||
((octets[0] << 8) | octets[1]).toString(16),
|
||||
((octets[2] << 8) | octets[3]).toString(16),
|
||||
];
|
||||
return [((octets[0] << 8) | octets[1]).toString(16), ((octets[2] << 8) | octets[3]).toString(16)];
|
||||
}
|
||||
|
||||
function parseIpv6Parts(address) {
|
||||
@@ -853,16 +843,21 @@ async function openFetchPackageDownloadResponse(parsed, options) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
timeout.unref?.();
|
||||
const response = await options.fetchImpl(parsed, {
|
||||
const response = await options
|
||||
.fetchImpl(parsed, {
|
||||
headers: options.headers,
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
}).catch((error) => {
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeout);
|
||||
if (error?.name === "AbortError") {
|
||||
throw new Error(`package_url download timed out after ${options.timeoutMs}ms: ${parsed.toString()}`, {
|
||||
throw new Error(
|
||||
`package_url download timed out after ${options.timeoutMs}ms: ${parsed.toString()}`,
|
||||
{
|
||||
cause: error,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
@@ -908,9 +903,12 @@ async function openHttpsPackageDownloadResponse(parsed, options) {
|
||||
}).catch((error) => {
|
||||
clearTimeout(timeout);
|
||||
if (error?.name === "AbortError" || error?.code === "ABORT_ERR") {
|
||||
throw new Error(`package_url download timed out after ${options.timeoutMs}ms: ${parsed.toString()}`, {
|
||||
throw new Error(
|
||||
`package_url download timed out after ${options.timeoutMs}ms: ${parsed.toString()}`,
|
||||
{
|
||||
cause: error,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
@@ -627,11 +627,7 @@ function runShellCommand({ command, env, label, logFile, timeoutMs, noOutputTime
|
||||
});
|
||||
}
|
||||
|
||||
export function appendBoundedShellCapture(
|
||||
current,
|
||||
chunk,
|
||||
maxChars = SHELL_CAPTURE_MAX_CHARS,
|
||||
) {
|
||||
export function appendBoundedShellCapture(current, chunk, maxChars = SHELL_CAPTURE_MAX_CHARS) {
|
||||
const combined = `${current}${String(chunk)}`;
|
||||
if (combined.length <= maxChars) {
|
||||
return { text: combined, truncated: false };
|
||||
|
||||
@@ -464,9 +464,9 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
});
|
||||
|
||||
const sessionOptions = expectRecordFields(mockCallArg(createAgentSessionMock), {});
|
||||
expect((sessionOptions.customTools as Array<{ name: string }>).map((tool) => tool.name)).toEqual(
|
||||
["healthy_lookup"],
|
||||
);
|
||||
expect(
|
||||
(sessionOptions.customTools as Array<{ name: string }>).map((tool) => tool.name),
|
||||
).toEqual(["healthy_lookup"]);
|
||||
expect(sessionOptions.tools).toEqual(["healthy_lookup"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -48,8 +48,9 @@ describe("provider error utils", () => {
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
await expect(assertOkOrThrowProviderError(response, "OAuth token exchange failed")).rejects
|
||||
.toThrow(
|
||||
await expect(
|
||||
assertOkOrThrowProviderError(response, "OAuth token exchange failed"),
|
||||
).rejects.toThrow(
|
||||
"OAuth token exchange failed (400): AADSTS7000215: Invalid client secret provided. [code=invalid_request]",
|
||||
);
|
||||
});
|
||||
@@ -69,8 +70,9 @@ describe("provider error utils", () => {
|
||||
},
|
||||
} as unknown as Response;
|
||||
|
||||
await expect(assertOkOrThrowProviderError(response, "Provider API error")).rejects
|
||||
.toMatchObject({
|
||||
await expect(
|
||||
assertOkOrThrowProviderError(response, "Provider API error"),
|
||||
).rejects.toMatchObject({
|
||||
name: "ProviderHttpError",
|
||||
status: 503,
|
||||
statusCode: 503,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
setRuntimeConfigSnapshot,
|
||||
} from "../config/runtime-snapshot.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js";
|
||||
import { captureEnv, withPathResolutionEnv } from "../test-utils/env.js";
|
||||
import { createFixtureSuite } from "../test-utils/fixture-suite.js";
|
||||
import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js";
|
||||
@@ -171,6 +172,7 @@ afterAll(async () => {
|
||||
|
||||
afterEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearPluginMetadataLifecycleCaches();
|
||||
});
|
||||
|
||||
describe("buildWorkspaceSkillCommandSpecs", () => {
|
||||
@@ -286,8 +288,8 @@ describe("buildWorkspaceSkillCommandSpecs", () => {
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
// Prime plugin discovery before the bundle exists so command loading proves
|
||||
// it sees the current filesystem state instead of a stale cached snapshot.
|
||||
// Prime plugin discovery before the bundle exists; clear the lifecycle cache
|
||||
// below to model the install/reload boundary that exposes new plugin files.
|
||||
buildWorkspaceSkillCommandSpecs(workspaceDir, {
|
||||
...resolveTestSkillDirs(workspaceDir),
|
||||
config,
|
||||
@@ -313,6 +315,7 @@ describe("buildWorkspaceSkillCommandSpecs", () => {
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
clearPluginMetadataLifecycleCaches();
|
||||
|
||||
const commands = buildWorkspaceSkillCommandSpecs(workspaceDir, {
|
||||
...resolveTestSkillDirs(workspaceDir),
|
||||
|
||||
@@ -546,9 +546,7 @@ describeLive("subagent announce live", () => {
|
||||
});
|
||||
|
||||
const listSteeredChildRuns = () =>
|
||||
listSubagentRunsForRequester(sessionKey).filter(
|
||||
(run) => run.taskName === "steered_child",
|
||||
);
|
||||
listSubagentRunsForRequester(sessionKey).filter((run) => run.taskName === "steered_child");
|
||||
const spawnedRun = await waitFor("steered child spawn", () => {
|
||||
if (initialError) {
|
||||
throw initialError;
|
||||
@@ -584,9 +582,7 @@ describeLive("subagent announce live", () => {
|
||||
expect(runBeforeSteer.endedAt, runStateBeforeSteer).toBeUndefined();
|
||||
expect(runBeforeSteer.pauseReason, runStateBeforeSteer).toBeUndefined();
|
||||
expect(runBeforeSteer.completion?.resultText, runStateBeforeSteer).toBeUndefined();
|
||||
console.log(
|
||||
`[subagent-steer] steering active child run; runs=${runStateBeforeSteer}`,
|
||||
);
|
||||
console.log(`[subagent-steer] steering active child run; runs=${runStateBeforeSteer}`);
|
||||
|
||||
const cfg = getRuntimeConfig();
|
||||
const steerResult = await steerControlledSubagentRun({
|
||||
|
||||
@@ -95,6 +95,7 @@ import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.j
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { loadOpenClawPlugins } from "../../plugins/loader.js";
|
||||
import type { PluginManifestRecord } from "../../plugins/manifest-registry.js";
|
||||
import { clearPluginMetadataLifecycleCaches } from "../../plugins/plugin-metadata-lifecycle.js";
|
||||
import { createEmptyPluginRegistry } from "../../plugins/registry.js";
|
||||
import {
|
||||
pinActivePluginChannelRegistry,
|
||||
@@ -212,6 +213,7 @@ function expectSetupSnapshotDoesNotScopeToPlugin(params: {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearPluginMetadataLifecycleCaches();
|
||||
vi.clearAllMocks();
|
||||
execFileSync.mockImplementation(() => {
|
||||
throw new Error("not a git worktree");
|
||||
@@ -230,6 +232,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearPluginMetadataLifecycleCaches();
|
||||
if (ORIGINAL_OPENCLAW_STATE_DIR === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
@@ -990,7 +993,31 @@ describe("ensureChannelSetupPluginInstalled", () => {
|
||||
it("scopes snapshots by activation-declared channel ownership when direct channel lists are empty", () => {
|
||||
const runtime = makeRuntime();
|
||||
const cfg: OpenClawConfig = {};
|
||||
mockActivationOnlyPlugin({ id: "custom-external-chat-plugin" });
|
||||
let sawTrustedCandidate = false;
|
||||
loadPluginManifestRegistry.mockImplementation((args: unknown) => {
|
||||
if (
|
||||
isRecord(args) &&
|
||||
args.config === cfg &&
|
||||
args.workspaceDir === "/tmp/openclaw-workspace" &&
|
||||
Array.isArray(args.candidates)
|
||||
) {
|
||||
sawTrustedCandidate ||= args.candidates.some((candidate) => {
|
||||
const record = isRecord(candidate) ? candidate : {};
|
||||
return record.idHint === "custom-external-chat-plugin" && record.origin === "bundled";
|
||||
});
|
||||
}
|
||||
return {
|
||||
plugins: [
|
||||
createManifestRecord({
|
||||
id: "custom-external-chat-plugin",
|
||||
activation: {
|
||||
onChannels: ["external-chat"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
});
|
||||
|
||||
loadChannelSetupPluginRegistrySnapshotForChannel({
|
||||
cfg,
|
||||
@@ -1002,18 +1029,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
|
||||
expectLoadOpenClawPluginFields({
|
||||
onlyPluginIds: ["custom-external-chat-plugin"],
|
||||
});
|
||||
const manifestCall = loadPluginManifestRegistry.mock.calls
|
||||
.map((call) => requireRecord(call[0], "manifest registry args"))
|
||||
.find((args) =>
|
||||
requireArray(args.candidates, "manifest candidates").some((candidate) => {
|
||||
const record = requireRecord(candidate, "manifest candidate");
|
||||
return record.idHint === "custom-external-chat-plugin" && record.origin === "bundled";
|
||||
}),
|
||||
);
|
||||
expectRecordFields(manifestCall, "manifest registry args", {
|
||||
config: cfg,
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
});
|
||||
expect(sawTrustedCandidate).toBe(true);
|
||||
});
|
||||
|
||||
it("uses live manifest discovery for activation-declared setup scoping", () => {
|
||||
|
||||
@@ -636,10 +636,7 @@ function upgradeOldClaudeToken(
|
||||
return null;
|
||||
}
|
||||
// claude-haiku-4-5 is a current production model and must not be migrated.
|
||||
if (
|
||||
normalized.startsWith("claude-haiku-4-5") ||
|
||||
normalized.startsWith("claude-haiku-4.5")
|
||||
) {
|
||||
if (normalized.startsWith("claude-haiku-4-5") || normalized.startsWith("claude-haiku-4.5")) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -21,6 +21,9 @@ export type MSTeamsWebhookConfig = {
|
||||
path?: string;
|
||||
};
|
||||
|
||||
/** Teams SDK cloud environment. Public cloud is the default. */
|
||||
export type MSTeamsCloudName = "Public" | "USGov" | "USGovDoD" | "China";
|
||||
|
||||
/**
|
||||
* Bot Framework OAuth SSO configuration for Microsoft Teams.
|
||||
*
|
||||
@@ -95,6 +98,13 @@ export type MSTeamsConfig = {
|
||||
appPassword?: SecretInput;
|
||||
/** Azure AD Tenant ID (for single-tenant bots). */
|
||||
tenantId?: string;
|
||||
/** Teams SDK cloud environment. Default: Public. */
|
||||
cloud?: MSTeamsCloudName;
|
||||
/**
|
||||
* Bot Connector service URL used by SDK proactive sends/edits/deletes.
|
||||
* Set with `cloud` for USGov/DoD SDK clouds; set alone for GCC.
|
||||
*/
|
||||
serviceUrl?: string;
|
||||
/**
|
||||
* Authentication type.
|
||||
* - `"secret"` (default): uses `appPassword` (client secret).
|
||||
|
||||
@@ -1508,6 +1508,42 @@ export const MSTeamsTeamSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const MSTEAMS_SERVICE_URL_HOST_ALLOWLIST = [
|
||||
"smba.trafficmanager.net",
|
||||
"smba.infra.gcc.teams.microsoft.com",
|
||||
"smba.infra.gov.teams.microsoft.us",
|
||||
"smba.infra.dod.teams.microsoft.us",
|
||||
"botframework.azure.cn",
|
||||
] as const;
|
||||
|
||||
function isAllowedMSTeamsServiceUrl(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(value.trim());
|
||||
if (parsed.protocol !== "https:") {
|
||||
return false;
|
||||
}
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
return MSTEAMS_SERVICE_URL_HOST_ALLOWLIST.some(
|
||||
(allowed) => host === allowed || host.endsWith(`.${allowed}`),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isAzureChinaBotFrameworkServiceUrl(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(value.trim());
|
||||
if (parsed.protocol !== "https:") {
|
||||
return false;
|
||||
}
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
return host === "botframework.azure.cn" || host.endsWith(".botframework.azure.cn");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const MSTeamsConfigSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
@@ -1518,6 +1554,15 @@ export const MSTeamsConfigSchema = z
|
||||
appId: z.string().optional(),
|
||||
appPassword: SecretInputSchema.optional().register(sensitive),
|
||||
tenantId: z.string().optional(),
|
||||
cloud: z.enum(["Public", "USGov", "USGovDoD", "China"]).optional(),
|
||||
serviceUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.refine(isAllowedMSTeamsServiceUrl, {
|
||||
message:
|
||||
"channels.msteams.serviceUrl must use a supported Microsoft Teams Bot Connector host",
|
||||
})
|
||||
.optional(),
|
||||
authType: z.enum(["secret", "federated"]).optional(),
|
||||
certificatePath: z.string().optional(),
|
||||
certificateThumbprint: z.string().optional(),
|
||||
@@ -1604,6 +1649,42 @@ export const MSTeamsConfigSchema = z
|
||||
"channels.msteams.sso.enabled=true requires channels.msteams.sso.connectionName to identify the Bot Framework OAuth connection",
|
||||
});
|
||||
}
|
||||
if (
|
||||
value.cloud &&
|
||||
value.cloud !== "Public" &&
|
||||
value.cloud !== "China" &&
|
||||
!value.serviceUrl?.trim()
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["serviceUrl"],
|
||||
message:
|
||||
"channels.msteams.cloud requires channels.msteams.serviceUrl for non-public Teams clouds",
|
||||
});
|
||||
}
|
||||
if (
|
||||
value.cloud === "China" &&
|
||||
value.serviceUrl?.trim() &&
|
||||
!isAzureChinaBotFrameworkServiceUrl(value.serviceUrl)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["serviceUrl"],
|
||||
message:
|
||||
"channels.msteams.cloud=China requires channels.msteams.serviceUrl to use an Azure China Bot Framework channel host",
|
||||
});
|
||||
}
|
||||
if (
|
||||
value.cloud !== "China" &&
|
||||
value.serviceUrl?.trim() &&
|
||||
isAzureChinaBotFrameworkServiceUrl(value.serviceUrl)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["cloud"],
|
||||
message: "Azure China Bot Framework serviceUrl hosts require channels.msteams.cloud=China",
|
||||
});
|
||||
}
|
||||
|
||||
// Federated auth fields (appId, tenantId, certificatePath,
|
||||
// useManagedIdentity) may come from MSTEAMS_* environment variables,
|
||||
|
||||
@@ -299,8 +299,7 @@ export function createTalkRealtimeRelaySession(
|
||||
params: CreateTalkRealtimeRelaySessionParams,
|
||||
): TalkRealtimeRelaySessionResult {
|
||||
enforceRelaySessionLimits(params.connId);
|
||||
const forceAgentConsultOnFinalTranscript =
|
||||
params.forceAgentConsultOnFinalTranscript === true;
|
||||
const forceAgentConsultOnFinalTranscript = params.forceAgentConsultOnFinalTranscript === true;
|
||||
const relaySessionId = randomUUID();
|
||||
const expiresAtMs = Date.now() + RELAY_SESSION_TTL_MS;
|
||||
const talk = createTalkSessionController(
|
||||
|
||||
@@ -29,6 +29,7 @@ export type {
|
||||
MarkdownConfig,
|
||||
MarkdownTableMode,
|
||||
MSTeamsChannelConfig,
|
||||
MSTeamsCloudName,
|
||||
MSTeamsConfig,
|
||||
MSTeamsReplyStyle,
|
||||
MSTeamsTeamConfig,
|
||||
|
||||
@@ -48,13 +48,7 @@ const packageManifestContractTests: PackageManifestContractParams[] = [
|
||||
},
|
||||
{
|
||||
pluginId: "msteams",
|
||||
pluginLocalRuntimeDeps: [
|
||||
"@azure/identity",
|
||||
"@microsoft/teams.api",
|
||||
"@microsoft/teams.apps",
|
||||
"jsonwebtoken",
|
||||
"jwks-rsa",
|
||||
],
|
||||
pluginLocalRuntimeDeps: ["@azure/identity", "@microsoft/teams.apps"],
|
||||
minHostVersionBaseline: "2026.3.22",
|
||||
},
|
||||
{ pluginId: "nextcloud-talk", minHostVersionBaseline: "2026.3.22" },
|
||||
|
||||
@@ -113,7 +113,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
|
||||
'export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";',
|
||||
'export { PAIRING_APPROVED_MESSAGE, buildProbeChannelStatusSummary, createDefaultChannelRuntimeState } from "openclaw/plugin-sdk/channel-status";',
|
||||
'export { buildChannelKeyCandidates, normalizeChannelSlug, resolveChannelEntryMatchWithFallback, resolveNestedAllowlistDecision } from "openclaw/plugin-sdk/channel-targets";',
|
||||
'export type { GroupPolicy, GroupToolPolicyConfig, MSTeamsChannelConfig, MSTeamsConfig, MSTeamsReplyStyle, MSTeamsTeamConfig, MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";',
|
||||
'export type { GroupPolicy, GroupToolPolicyConfig, MSTeamsChannelConfig, MSTeamsCloudName, MSTeamsConfig, MSTeamsReplyStyle, MSTeamsTeamConfig, MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";',
|
||||
'export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";',
|
||||
'export { resolveDefaultGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";',
|
||||
'export { withFileLock } from "openclaw/plugin-sdk/file-lock";',
|
||||
|
||||
@@ -154,10 +154,10 @@ function createIndex(
|
||||
|
||||
function createPersistableIndex(pluginId: string): InstalledPluginIndex {
|
||||
const index = createIndex(pluginId);
|
||||
return {
|
||||
...index,
|
||||
plugins: index.plugins.map((plugin) => ({ ...plugin, enabled: false })),
|
||||
};
|
||||
for (const plugin of index.plugins) {
|
||||
plugin.enabled = false;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
|
||||
32
src/types/microsoft-teams-sdk.d.ts
vendored
32
src/types/microsoft-teams-sdk.d.ts
vendored
@@ -24,35 +24,3 @@ declare module "@microsoft/teams.api" {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@microsoft/teams.apps/dist/middleware/auth/jwt-validator.js" {
|
||||
export type JwtValidationOptions = {
|
||||
clientId: string;
|
||||
tenantId?: string;
|
||||
jwksUriOptions: { type: "uri"; uri: string } | { type: "tenantId" };
|
||||
validateIssuer?: { allowedIssuer: string } | { allowedTenantIds?: string[] };
|
||||
validateServiceUrl?: { expectedServiceUrl: string };
|
||||
};
|
||||
|
||||
export class JwtValidator {
|
||||
constructor(options: JwtValidationOptions, logger?: unknown);
|
||||
validateAccessToken(
|
||||
token: string,
|
||||
options?: {
|
||||
validateServiceUrl?: { expectedServiceUrl: string } | undefined;
|
||||
},
|
||||
): Promise<object | null>;
|
||||
}
|
||||
|
||||
export function createServiceTokenValidator(
|
||||
appId: string,
|
||||
tenantId?: string,
|
||||
): {
|
||||
validateAccessToken(
|
||||
token: string,
|
||||
options?: {
|
||||
validateServiceUrl?: { expectedServiceUrl: string } | undefined;
|
||||
},
|
||||
): Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,11 +86,15 @@ describe("package manager build policy", () => {
|
||||
it("pins forked transitive dependencies with parent-scoped shrinkwrap overrides", () => {
|
||||
const overrides = readShrinkwrapOverrides() as Record<string, unknown>;
|
||||
|
||||
const packages = collectPnpmLockPackages();
|
||||
|
||||
expect(overrides["lru-cache"]).toBeUndefined();
|
||||
expect(overrides["lru-memoizer@2.3.0"]).toMatchObject({
|
||||
"lru-cache": { ".": "6.0.0", yallist: "4.0.0" },
|
||||
});
|
||||
if (packages.has("lru-memoizer@3.0.0")) {
|
||||
expect(overrides["lru-memoizer@3.0.0"]).toMatchObject({ "lru-cache": "11.5.0" });
|
||||
}
|
||||
});
|
||||
|
||||
it("can preserve current forked shrinkwrap dependencies with parent-scoped overrides", () => {
|
||||
|
||||
@@ -375,11 +375,7 @@ describe("check-extension-package-tsc-boundary", () => {
|
||||
child.stderr = createMockPipe();
|
||||
child.kill = () => true;
|
||||
|
||||
const failure = await runNodeStepAsync(
|
||||
"noisy-plugin",
|
||||
["--eval", "process.exit(2)"],
|
||||
20_000,
|
||||
{
|
||||
const failure = await runNodeStepAsync("noisy-plugin", ["--eval", "process.exit(2)"], 20_000, {
|
||||
spawnImpl() {
|
||||
setImmediate(() => {
|
||||
child.stdout.emit("data", `stdout-begin-${"x".repeat(300_000)}-stdout-end`);
|
||||
@@ -388,8 +384,7 @@ describe("check-extension-package-tsc-boundary", () => {
|
||||
});
|
||||
return child;
|
||||
},
|
||||
},
|
||||
).then(
|
||||
}).then(
|
||||
() => {
|
||||
throw new Error("expected noisy-plugin step to fail");
|
||||
},
|
||||
|
||||
@@ -89,9 +89,7 @@ describe("scripts/test-docker-all scheduler", () => {
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toBe("");
|
||||
expect(result.stderr).toContain(
|
||||
"OPENCLAW_DOCKER_ALL_PARALLELISM must be a positive integer",
|
||||
);
|
||||
expect(result.stderr).toContain("OPENCLAW_DOCKER_ALL_PARALLELISM must be a positive integer");
|
||||
expect(result.stderr).not.toContain("at ");
|
||||
});
|
||||
|
||||
|
||||
@@ -1121,9 +1121,7 @@ grep -qx -- "OPENCLAW_E2E_COMMAND_TIMEOUT=23s" "$TMPDIR/package-args"
|
||||
expect(script).toContain('export NPM_CONFIG_CACHE="$npm_config_cache"');
|
||||
expect(script).toContain('chmod 700 "$npm_config_cache" || true');
|
||||
expect(script).not.toContain('export TMPDIR="$ARTIFACT_ROOT/tmp"');
|
||||
expect(script).not.toContain(
|
||||
'export TMPDIR="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/tmp"',
|
||||
);
|
||||
expect(script).not.toContain('export TMPDIR="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/tmp"');
|
||||
expect(script).not.toContain('export npm_config_cache="$ARTIFACT_ROOT/npm-cache"');
|
||||
expect(script).not.toContain(
|
||||
'export npm_config_cache="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/npm-cache"',
|
||||
|
||||
@@ -127,16 +127,12 @@ describe("install.ps1 failure handling", () => {
|
||||
expect(commandSafeBody).toContain("Pop-Location");
|
||||
expect(npmCommandBody).toContain("Invoke-CommandFromWindowsSafeDirectory");
|
||||
expect(corepackCommandBody).toContain("Invoke-CommandFromWindowsSafeDirectory");
|
||||
expect(openClawPathBody).toContain(
|
||||
'Invoke-NpmCommand -Arguments @("config", "get", "prefix")',
|
||||
);
|
||||
expect(openClawPathBody).toContain('Invoke-NpmCommand -Arguments @("config", "get", "prefix")');
|
||||
expect(ensurePnpmBody).toContain(
|
||||
'Invoke-CorepackCommand -Arguments @("prepare", $pnpmSpec, "--activate")',
|
||||
);
|
||||
expect(ensurePnpmBody).toContain('Invoke-NpmCommand -Arguments @("install", "-g", $pnpmSpec)');
|
||||
expect(mainBody).toContain(
|
||||
'Invoke-NpmCommand -Arguments @("uninstall", "-g", "openclaw")',
|
||||
);
|
||||
expect(mainBody).toContain('Invoke-NpmCommand -Arguments @("uninstall", "-g", "openclaw")');
|
||||
expect(mainBody).toContain(
|
||||
'Invoke-NpmCommand -Arguments @("list", "-g", "--depth", "0", "--json")',
|
||||
);
|
||||
@@ -261,7 +257,9 @@ describe("install.ps1 failure handling", () => {
|
||||
const mainBody = extractFunctionBody(source, "Main");
|
||||
|
||||
expect(pnpmVersionBody).toContain("package.json");
|
||||
expect(pnpmVersionBody).toContain("$packageJson.packageManager -match '^pnpm@(?<version>[^+]+)'");
|
||||
expect(pnpmVersionBody).toContain(
|
||||
"$packageJson.packageManager -match '^pnpm@(?<version>[^+]+)'",
|
||||
);
|
||||
expect(pnpmVersionMatchBody).toContain("Push-Location -LiteralPath $RepoDir");
|
||||
expect(pnpmVersionMatchBody).toContain("$currentVersion.Trim() -eq $PnpmVersion");
|
||||
expect(pnpmVersionMatchBody).toContain("} catch {");
|
||||
@@ -287,13 +285,9 @@ describe("install.ps1 failure handling", () => {
|
||||
gitInstallBody.indexOf("Ensure-Pnpm -RepoDir $RepoDir"),
|
||||
);
|
||||
expect(mainBody).toContain("$gitInstallResults = @(Install-OpenClawFromGit");
|
||||
expect(mainBody).toContain(
|
||||
"Test-BooleanSuccessResult -Results $gitInstallResults",
|
||||
);
|
||||
expect(mainBody).toContain("Test-BooleanSuccessResult -Results $gitInstallResults");
|
||||
expect(mainBody).toContain("$npmInstallResults = @(Install-OpenClaw)");
|
||||
expect(mainBody).toContain(
|
||||
"Test-BooleanSuccessResult -Results $npmInstallResults",
|
||||
);
|
||||
expect(mainBody).toContain("Test-BooleanSuccessResult -Results $npmInstallResults");
|
||||
expect(gitInstallBody).toContain("Push-Location -LiteralPath $RepoDir");
|
||||
expect(gitInstallBody).toContain("$sourceInstallArgs = @(");
|
||||
expect(gitInstallBody).toContain('"--config.node-linker=hoisted"');
|
||||
@@ -304,8 +298,12 @@ describe("install.ps1 failure handling", () => {
|
||||
expect(gitInstallBody).not.toContain('"--filter"');
|
||||
expect(gitInstallBody).not.toContain('"--ignore-scripts=true"');
|
||||
expect(gitInstallBody).toContain('"--child-concurrency=$env:PNPM_CONFIG_CHILD_CONCURRENCY"');
|
||||
expect(gitInstallBody).toContain('"--network-concurrency=$env:PNPM_CONFIG_NETWORK_CONCURRENCY"');
|
||||
expect(gitInstallBody).toContain('"--config.workspace-concurrency=$env:PNPM_CONFIG_WORKSPACE_CONCURRENCY"');
|
||||
expect(gitInstallBody).toContain(
|
||||
'"--network-concurrency=$env:PNPM_CONFIG_NETWORK_CONCURRENCY"',
|
||||
);
|
||||
expect(gitInstallBody).toContain(
|
||||
'"--config.workspace-concurrency=$env:PNPM_CONFIG_WORKSPACE_CONCURRENCY"',
|
||||
);
|
||||
expect(gitInstallBody).toContain("& $pnpmCommand @sourceInstallArgs");
|
||||
expect(gitInstallBody).toContain('$env:PNPM_CONFIG_CHILD_CONCURRENCY = "1"');
|
||||
expect(gitInstallBody).toContain('$env:PNPM_CONFIG_NETWORK_CONCURRENCY = "4"');
|
||||
@@ -315,9 +313,7 @@ describe("install.ps1 failure handling", () => {
|
||||
expect(gitInstallBody).toContain("$installSucceeded = ($LASTEXITCODE -eq 0)");
|
||||
expect(gitInstallBody).toContain("clearing node_modules and retrying once");
|
||||
expect(gitInstallBody).toContain("Remove-Item -Recurse -Force node_modules");
|
||||
expect(gitInstallBody).toContain(
|
||||
'Write-Host "[!] pnpm install failed for the Git checkout"',
|
||||
);
|
||||
expect(gitInstallBody).toContain('Write-Host "[!] pnpm install failed for the Git checkout"');
|
||||
expect(gitInstallBody).not.toContain("$pnpmCommand rebuild --pending");
|
||||
expect(gitInstallBody).not.toContain("scripts/postinstall-bundled-plugins.mjs");
|
||||
expect(gitInstallBody).toContain(
|
||||
@@ -334,14 +330,10 @@ describe("install.ps1 failure handling", () => {
|
||||
"$env:PNPM_CONFIG_WORKSPACE_CONCURRENCY = $prevPnpmWorkspaceConcurrency",
|
||||
);
|
||||
expect(gitInstallBody).toContain("Add-ToUserPath $binDir");
|
||||
expect(gitInstallBody).toContain(
|
||||
'Write-Host "[!] pnpm build failed for the Git checkout"',
|
||||
);
|
||||
expect(gitInstallBody).toContain('Write-Host "[!] pnpm build failed for the Git checkout"');
|
||||
expect(gitInstallBody).toContain('$entryPath = Join-Path $RepoDir "dist\\\\entry.js"');
|
||||
expect(gitInstallBody).toContain("Test-Path $entryPath");
|
||||
expect(gitInstallBody).toContain(
|
||||
'Write-Host "[!] OpenClaw build did not produce $entryPath"',
|
||||
);
|
||||
expect(gitInstallBody).toContain('Write-Host "[!] OpenClaw build did not produce $entryPath"');
|
||||
expect(gitInstallBody).toContain('node ""$entryPath"" %*');
|
||||
expect(gitInstallBody).not.toContain("& $pnpmCommand -C $RepoDir install");
|
||||
expect(gitInstallBody).not.toContain('node ""$RepoDir\\\\dist\\\\entry.js"" %*');
|
||||
@@ -434,7 +426,12 @@ describe("install.ps1 failure handling", () => {
|
||||
);
|
||||
chmodSync(scriptPath, 0o755);
|
||||
|
||||
const result = runPowerShell(["-NoLogo", "-NoProfile", "-Command", `. ${toPowerShellSingleQuotedLiteral(scriptPath)}`]);
|
||||
const result = runPowerShell([
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
`. ${toPowerShellSingleQuotedLiteral(scriptPath)}`,
|
||||
]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
@@ -464,7 +461,12 @@ describe("install.ps1 failure handling", () => {
|
||||
);
|
||||
chmodSync(scriptPath, 0o755);
|
||||
|
||||
const result = runPowerShell(["-NoLogo", "-NoProfile", "-Command", `. ${toPowerShellSingleQuotedLiteral(scriptPath)}`]);
|
||||
const result = runPowerShell([
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
`. ${toPowerShellSingleQuotedLiteral(scriptPath)}`,
|
||||
]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
|
||||
@@ -622,9 +622,7 @@ describe("kitchen-sink RPC process sampling", () => {
|
||||
});
|
||||
|
||||
it("bounds HTTP probe response bodies", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response("x".repeat(1025), { status: 200 }));
|
||||
const fetchImpl = vi.fn().mockResolvedValue(new Response("x".repeat(1025), { status: 200 }));
|
||||
|
||||
await expect(
|
||||
fetchJson("http://127.0.0.1:19680/healthz", {
|
||||
@@ -639,9 +637,9 @@ describe("kitchen-sink RPC process sampling", () => {
|
||||
});
|
||||
|
||||
it("reads bounded response streams", async () => {
|
||||
await expect(
|
||||
readBoundedResponseText(new Response('{"status":"live"}'), 1024),
|
||||
).resolves.toBe('{"status":"live"}');
|
||||
await expect(readBoundedResponseText(new Response('{"status":"live"}'), 1024)).resolves.toBe(
|
||||
'{"status":"live"}',
|
||||
);
|
||||
});
|
||||
|
||||
it("times out stalled HTTP probe response bodies", async () => {
|
||||
|
||||
@@ -426,17 +426,13 @@ echo "child still alive after watchdog termination" >&2
|
||||
exit 1
|
||||
`;
|
||||
|
||||
const result = spawnSync(
|
||||
"/bin/bash",
|
||||
["-c", script],
|
||||
{
|
||||
const result = spawnSync("/bin/bash", ["-c", script], {
|
||||
encoding: "utf8",
|
||||
env: shellTestEnv({
|
||||
PATH: tempDir,
|
||||
}),
|
||||
timeout: 5_000,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expectShellSuccess(result);
|
||||
} finally {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user