feat(msteams): Teams voice (CVI) + chat + governance integration#92081
feat(msteams): Teams voice (CVI) + chat + governance integration#92081ahenawy wants to merge 117 commits into
Conversation
|
ClawSweeper review: did not complete due to Codex infrastructure failure. Reviewed June 16, 2026, 1:18 AM ET / 05:18 UTC. Summary Reproducibility: unclear. The review failed before ClawSweeper could establish a reproduction path. Review metrics: none identified. Stored data model Merge readiness This is a ClawSweeper/Codex infrastructure failure, not a PR readiness or patch-quality verdict. Risk before merge
Maintainer options:
Next step before merge
Review detailsBest possible solution: Retry the Codex review after fixing the execution failure. Do we have a high-confidence way to reproduce the issue? Unclear. The review failed before ClawSweeper could establish a reproduction path. Is this the best way to solve the issue? Unclear. Retry the review first so ClawSweeper can evaluate the actual issue and fix direction. AGENTS.md: unclear because the file could not be read completely. Codex review notes: model internal, reasoning high; reviewed against 7ac2bbaaf0ae. Label changesLabel changes:
Evidence reviewedWhat I checked:
Likely related people:
How this review workflow works
|
Opt-in msteams.transcribeVoiceMessages: when a chat message carries an audio attachment (a Teams voice clip), each is transcribed via media-understanding STT (transcribeAudioFile) and the text is folded into the message — replacing the opaque '<media:document>' placeholder with a quoted '🎙️ Voice message: "…"' block — so the agent reads what was said. Off by default (incurs an STT call); transcription failures keep the placeholder and never block the reply. voice-message.ts holds the pure detection + merge logic (5 tests). Outbound TTS replies are a separate follow-up.
Opt-in channels.msteams.dlp: scrubs sensitive values out of text the bot SENDS
to Teams so the agent can't leak data into a chat. Built-in detectors —
credit cards (Luhn-validated, so random 16-digit numbers are left alone),
emails, US SSNs, IBANs, AWS keys, common provider secret prefixes (sk-/ghp_/
xox.-/AIza), and phone numbers — plus org-specific customPatterns and a
configurable {category} placeholder. categories[] narrows the built-ins.
Applied at both outbound chokepoints on the FULL text before chunking (so a
split value can't slip through): renderReplyPayloadsToMessages (agent replies)
and sendMessageMSTeams (proactive / cron sends). Off by default; a malformed
custom pattern is skipped rather than throwing on the send path.
dlp.ts is pure + deterministic; 7 tests. lint + types clean.
…aw#15) Opt-in channels.msteams.auditChannel: a compact, DLP-redacted line of each agent reply ('🧾 Reply for <name> in <conversation>: <excerpt>') is mirrored to a configured Teams audit conversation, giving admins a governance trail without reading session files. Fire-and-forget from the reply dispatcher so it never blocks or fails the user-facing reply; the mirror goes through sendMessageMSTeams (so DLP redaction applies and it can't recurse through the dispatcher), and a loop guard skips replies that are themselves in the audit channel. Off by default. audit.ts is pure (resolve target + format line); 5 tests. lint + types clean.
…he validation schema The openclaw#13/openclaw#15/openclaw#16 features added these fields to the MSTeamsConfig TS type + wired them, but the runtime VALIDATOR (zod MSTeamsConfigSchema, strict) didn't list them — so the gateway would reject channels.msteams.{dlp,auditChannel, transcribeVoiceMessages} as additional properties at config load. Add them to the zod (mirrors the meetingRecap manifest fix on the voice-call side) and refresh the generated config docs baseline. Confirmed: MSTeamsConfigSchema.safeParse now accepts all three.
buildEvent hardcoded direction:"inbound", so outbound call.answered/initiated events carried wrong metadata (OpenClaw couldn't tell a call it placed from one that dialed in). Derive it: a call tracked in pendingOutbound / outboundRealtimeInternalIds is "outbound", otherwise "inbound".
…, C2)
C1 — realtime mode never wired the CallManager: runtime.ts's realtime branch
called only setRealtimeRuntime, so this.manager stayed null → outbound call.answered
no-op'd → CallRecord never got answeredAt → the stale-call reaper force-ended
connected outbound realtime callbacks ~2 min in, and notify auto-hangup silently
no-op'd. Now the realtime branch wires setCallManager + setResponseRuntime too
(parity with the streaming wireMsteamsRuntime). (The provider unit test masked
this by calling setCallManager directly.)
C2 — the msteams safe-allowlist inbound default was dead code: the schema filled
inboundPolicy.default("disabled"), so resolve's 'inboundPolicy === undefined →
allowlist' branch never fired in production (schema parses before resolve). Result:
msteams+streaming rejected ALL inbound calls, msteams+realtime failed startup with
a misleading error. Make inboundPolicy .optional() so resolve sees 'unset' and
applies the provider default — msteams → allowlist, others → disabled. Verified:
resolveVoiceCallConfig({provider:'msteams'}).inboundPolicy === 'allowlist'.
Type-clean; config+provider suites (56) green.
…window (review S4) A future-dated handshake (worker NTP skew δ) stays signature-valid until ts+window, but its replay record was swept at now+window — leaving a δ-wide window where the same verified tuple could be replayed. Key the record's expiry off the timestamp's own validity end. Lint clean.
…/B2/B3/B5) Four per-call teardown paths leaked state or stranded the call; one design fix funnels every teardown through a single owner. B1 — closeSession deleted the connection meta before the ws close event, so a host-initiated close (realtime connect failure, policy reject, hangup) suppressed onSessionEnd: the realtimeCalls entry leaked forever and getCallStatus reported a dead call in-progress. closeSession now delivers onSessionEnd itself (started + not-ended guard keeps session.end-driven closes single-delivery). B2 — the realtime bridge's onClose (provider WS drop) set closed=true but closed NOTHING; a later close(reason) early-returned, so the caller sat in silence on a Teams call that could never be hung up, and the recap was skipped. onClose, a failed connect(), and the returned close() now funnel through one closeCall() that also closes the Teams worker session (and handles recap + vision timer). B3 — manager-driven hangups never released vision frames / recordingActiveByCall (~1-2 MB/call); cleanup lived only in handleSessionEnd, which B1 could suppress. New provider-level releaseCallState(providerCallId) (vision, recording gate, no-answer timer, timed-out guard, pendingOutbound) runs unconditionally at the end of teardownCall, so every end path releases it. B5 — session.end before session.start (declined/busy placement) cancelled the no-answer timer but finalized nothing: the CallRecord stayed active forever (counting against maxConcurrentCalls) and pendingOutbound leaked. handleSessionEnd now resolves the internal id from streaming state, the outbound realtime map, OR the pending entry, and finalizes the record. Tests: host-close onSessionEnd single-delivery; bridge onClose + failed connect hang up the Teams session; connect-failure releases realtime state (terminal status); manager hangup releases vision; declined-before-start finalizes the CallRecord. Suites green (91 msteams + full voice-call); tsgo prod+test clean; oxfmt clean.
B4 — streaming outbound still fired call.answered at media-WS attach, TTS'ing the notify result into a RINGING phone (the realtime path was already fixed via greetingOnRecordingActive). Defer call.answered until recording goes active (= the callee picked up); fire immediately when the session starts answered. B6 — an operator-configured custom realtime tool was advertised to the model but had no handler: the tool call got no submitToolResult and the model's turn stalled forever. Unknown tool names now get an explicit 'not available' result. B7 — transcript coalescing merged ALL consecutive same-role fragments: one entry could grow unbounded (defeating the 40-entry cap) and a group call's 'Name:' attribution absorbed other speakers' words. Coalescing now never crosses speakers and caps an entry at 1000 chars. B8 — 'Ask OpenClaw about this' (and card actions) in group chats acked 'On it' then never replied: the synthetic dispatch had no @mention, so mention-gating dropped it after recording it into group history. The invoke wrapper now stamps a bot-mention entity (the user explicitly invoked the bot). B9 — a history-scope look_at_screen wrote the live-look cache, so later live looks on a static screen returned the history answer. History runs no longer cache. B10 — post_meeting_minutes was the only agent-running tool that skipped the recording gate (Media Access API); gate it like consult/look/show/task. The async-task tool acked delivery then silently dropped when the task arg was missing; it now refuses up front instead. B11 — the session.start schema permits an empty-string aadId, which survived 'aadId ?? fallback' everywhere: all such callers shared ONE consult session key (cross-caller memory bleed). Blank caller ids are normalized to null at the media-stream boundary, and the realtime/provider fallbacks use || not ??. B12 — the ambient vision push latched 'already pushed' and consumed budget BEFORE sendImage, which can throw: the frame was lost (never retried) and the burned budget starved look_at_screen. Latch only after a successful send and refund the budget hit on failure (new VisionBudget.refund). B13 — mixed voice-clip batches: a failed clip's placeholder was removed anyway (the attachment vanished) and a combined '<media:document> (N files)' token left a dangling '(N files)' residue. Only transcribed clips consume a placeholder, and the combined counter is decremented properly. Tests per fix (12 new). Affected suites green (115 targeted; full voice-call + msteams runs show only the documented Windows EBUSY sqlite flake and a pre-existing messenger.test.ts runtime-stub failure, both reproduced on the clean tree). tsgo prod+test clean; oxfmt clean.
…ards & friends)
The B-series bugs lived where the structure was hard to audit; this pass
removes the duplication they hid in. No behavior change except where noted.
- realtime bridge: extract withConsultGuards — the recording-gate refusal,
wired-deps check, error reporting, and 'working on it' filler shared by the
agent-running tool handlers (consult / look_at_screen / show_to_caller /
post_meeting_minutes) now live in ONE wrapper; review B10 existed because a
handler restated (and partially missed) that boilerplate. onToolCall routes
by name with the unknown-tool (B6) answer as the explicit fallback.
Behavior note: post_meeting_minutes with unwired deps now answers 'I can't
post minutes' instead of acking and silently doing nothing.
- realtime bridge: hoist consultAgentId/consultSessionKey — previously
recomputed verbatim in five handlers.
- realtime bridge: move the declarative tool surface (4 tool definitions,
3 consult system prompts, 6 canned spoken results) to a new
msteams-realtime-tools.ts; msteams-realtime.ts now holds behavior only
(1652 -> 1449 lines).
- provider: emitCallEnded() — one construction for the three hand-rolled
call.ended events (the B5 leak hid in one of the copies); name the
thrice-restated PendingOutboundCall tuple; drop an orphaned doc comment.
- runtime: skip the webhook-plane RealtimeCallHandler for msteams — Teams
never arrives on the webhook plane, so the handler, the stream-session
issuer only telephony providers read, and the duplicate realtime
instructions build were dead weight.
- config: groupCall schema defaults come from GROUP_CALL_GATE_DEFAULTS
instead of restating the literals; document the worker endpoint as
/api/calls (the URL actually POSTed) not /api/calls/place.
- message-handler: extract applyMSTeamsVoiceTranscription from the ~760-line
handleTeamsMessageNow (the voice-clip transcription block).
- small: drop dead SURPRISED_PUNCT alternations ('\?!|!\?' are subsets of
'[?!]{2,}'); correct the stale speech.marks comment (current workers ignore
viseme marks — RMS-driven lip-sync; the marks are a forward-compat hint).
Verified: 189 tests across the 12 affected suites green; full voice-call +
msteams runs match the pre-refactor baseline exactly (only the documented
Windows EBUSY flake + pre-existing messenger.test.ts failures); tsgo
prod+test clean; oxfmt clean.
…ic layers (review weak area)
The bilingual claim was only true for the model: the deterministic layers were
Latin-only. verbal-interrupt stripped all non-Latin letters ([^a-z]) — an
Arabic "توقف" could never trigger the instant cut — and the viseme map covered
only a-z, so Arabic replies fell back to RMS-only lip sync. group-call-gate
already did Unicode correctly (\p{L}); apply the same approach:
- verbal-interrupt: normalize with \p{L} (any script) and delete combining
marks + tatweel in place (so tashkeel doesn't split words); add a
conservative whole-utterance Arabic interrupt set (توقف / خلاص / اسكت /
انتظر / لحظة / كفى …) and Arabic fillers (طيب / لا / يا) so "يا مساعد توقف"
cuts like "hey ⟨name⟩, stop".
- viseme-estimate: map Arabic graphemes to the same Azure viseme classes
(long vowels carry the shape; consonants by articulation class; tashkeel
short vowels when present) in both the estimator and the alignment path.
Tests: Arabic interrupts (incl. tashkeel + wake-phrase stripping, and the
"stop by the store" negative); Arabic visemes in both paths. 108 tests green
across the 5 affected suites; tsgo prod+test clean; oxfmt clean.
…3/S5) The governance features redacted and audited only the block-delivery text path; every other outbound surface bypassed them. With dlp.enabled the DEFAULT personal-chat path (native streaming) sent raw agent text. S1 — native streaming bypassed DLP entirely. Per-token streaming cannot be redacted retroactively: a secret that only completes across chunk boundaries is already on the caller's screen, and the SDK stream is append-only. With DLP enabled, 'partial' mode now downgrades to 'progress' (the live card shows working status; no token ever reaches the wire) and the single full-text final emit — the one place agent text reaches the native stream — is redacted in the controller. S2 — the audit mirror lived in the block-delivery path, so natively-streamed replies (again: the DM default) left no governance trail. The mirror now fires at the deliver() choke point, on the payload BEFORE the stream controller decides which path carries it. Loop guard unchanged. S3 — editMessageMSTeams and sendAdaptiveCardMSTeams bypassed redaction: 'send something innocuous, then EDIT the secret in' and 'put the secret in a card' were agent-reachable loopholes. Edits redact like sends; cards get a new redactOutboundMSTeamsCard that deep-redacts every string value. S5 — the secret detector's sk- class now includes - and _ so segmented keys (sk-proj-…, sk-ant-api03-…) match; buildMSTeamsAuditLine redacts BEFORE truncating, so the excerpt cap can no longer slice a secret mid-match into an unredactable fragment. Tests: streamed-reply audit mirror + loop guard at the choke point; DLP stream downgrade + redacted final emit; card deep-redaction (no mutation, no-op when off); sk-proj-/sk-ant- patterns; redact-before-truncate. msteams suites green (1005 passed; only the documented EBUSY flake + pre-existing messenger failures); tsgo prod+test clean; oxfmt clean.
…e keys a298657 added dlp / auditChannel / transcribeVoiceMessages to the msteams zod schema but the checked-in bundled-channel-config-metadata.generated.ts was never regenerated — the gateway's raw-channel AJV validation uses THAT artifact, so a config using the new keys failed startup with 'must not have additional properties'. Root cause for the missed regen: scripts/generate-bundled-channel-config- metadata.ts guards its main with import.meta.url === new URL(process.argv[1], 'file://'), which never matches on Windows (new URL('C:\...', 'file://') parses the drive letter as a URL scheme) — so 'pnpm config:channels:gen' exits 0 having written nothing. Regenerated by invoking writeBundledChannelConfigMetadataModule directly; the Windows guard bug is upstream-wide (same pattern in other generators) and left as-is here. Verified: built CLI now loads a config with dlp/transcribeVoiceMessages (previously: startup failure).
…ers consume the marks The refactor pass over-corrected this comment to claim current workers ignore speech.marks; the companion worker's AvatarViseme layer consumes them (coarse open/wide/round/closed shapes blended over RMS openness). Restore the accurate framing: capable workers blend, older workers fall back to RMS-only.
…alignment
The msteams speech.marks timeline was an even spread of the reply text across
the audio duration (CVI Phase 5 spike). Now the elevenlabs provider's telephony
synthesis calls /v1/text-to-speech/{voiceId}/with-timestamps and surfaces the
per-character alignment (normalized_alignment preferred; falls back to plain
synthesis if the endpoint fails, so audio never depends on timing), an optional
'alignment' field flows through SpeechTelephonySynthesisResult ->
textToSpeechTelephony -> the voice-call TTS adapter, and msteams playback emits
visemesFromAlignment marks - real per-sound mouth timing - estimating only when
no alignment arrived. Alignment is wall-clock seconds, so the 22050->16k
resample leaves it valid.
ClawSweeper Codex flagged a DLP bypass: sendPollMSTeams sent the poll
Adaptive Card unredacted while the S3 fix only covered the generic
sendAdaptiveCardMSTeams path. A secret in a poll question or option label
('put the secret in a poll') therefore reached the wire. Redact the
agent-supplied question and option strings before building the card — the
visible text only, leaving the internal option indices and pollId untouched so
vote matching is unaffected. Two send.test cases cover the redaction and the
DLP-off passthrough.
Co-Authored-By: Claude Fable 5 <[email protected]>
mock.calls[0] types as an empty tuple, so indexing [2] and the card cast tripped check-test-types (TS2493/TS2352). Cast through unknown first. Co-Authored-By: Claude Fable 5 <[email protected]>
…stream rebase The rebase onto current upstream/main carried the governance schema keys (dlp/auditChannel/transcribeVoiceMessages) forward; regenerate the two derived artifacts against the merged schema so the generated-files-up-to-date checks pass. (config:channels:gen no-ops on Windows — main-guard URL parse bug — so the metadata module was regenerated via a direct writeBundledChannelConfigMetadataModule call.) Co-Authored-By: Claude Fable 5 <[email protected]>
…enclaw#91438 + openclaw#92081) docs/channels/msteams.md never carried the Teams integration's feature config — add the channel governance keys (dlp / auditChannel / transcribeVoiceMessages), a Governance section (DLP redaction, audit-log mirror, voice-message transcription), the 'Ask OpenClaw about this' message action, and a Voice/CVI summary that cross-links the voice-call plugin doc. In docs/plugins/voice-call.md fill the msteams-voice gaps: greet-by-name + deterministic verbal interrupts (EN/AR), bilingual, meetingRecap, DTMF/IVR, outbound notify modes (defaultMode/notifyHangupDelaySec/greet-on-answer), and the realtime echo / self-answer guard (suppressInputDuringPlayback + window/RMS knobs). Covers the foundation (openclaw#91438) features too since that PR is frozen. Co-Authored-By: Claude Fable 5 <[email protected]>
… (msteams realtime) Reported: caller stays silent after the greeting, bot keeps re-greeting in different ways. Cause: the bot's own greeting echoes back on a speakerphone loud enough to clear the echo guard's barge-in RMS, so it reaches the realtime server-VAD, which (with interruptResponseOnInputAudio + autoRespondToAudio) makes the model interrupt itself and re-greet — an echo loop while the caller is silent. Fix: until the caller's first real turn, the echo guard allows NO barge-in — every in-playback-window frame is treated as echo (allowBargeIn:false), so the opening greeting can't trigger the model. Normal RMS barge-in resumes once a real caller transcript arrives. Streaming path unaffected (allowBargeIn defaults true). Co-Authored-By: Claude Fable 5 <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
…ss API) ClawSweeper Codex flagged that DTMF keypad input reached the realtime provider before the call's recording status was active, bypassing the recording gate that governs audio, video, transcripts, consults, and background tasks. DTMF tones are in-band, media-derived caller input, so notifyDtmf now returns early when recordingGateBlocks() — no forward, no transcript — until setRecordingActive(true). No-op when requireRecordingStatus is off. Regression test: DTMF ignored while recording inactive, accepted after recording active. Co-Authored-By: Claude Fable 5 <[email protected]>
…ate baseline - Bump plugin-sdk public export budget to upstream 10284/5163 + RealtimeVoiceImageInput (+1) = 10285/5164. - Regenerate docs/.generated/config-baseline.sha256 after upstream config-surface drift. Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
Re-synced onto current
The commit→feature map and range header in the description are updated to the new SHAs. One note for the review queue: this PR is stacked on #91438 (the foundation), so its diff still carries #91438's commits until that lands. Taking #91438 first would collapse this to just the ~55-commit increment and remove the recurring rebase churn — I'll rebase to the pure increment the moment #91438 merges. |
|
Consolidating the Microsoft Teams CVI voice/video work into a single refreshed PR (rebased onto current upstream); the foundation PR #91438 stays open. Closing as superseded - its content/summary is carried into the consolidated PR. |
Summary
Builds on the
msteamsvoice provider (#91438) along six lines:A post-feature self-review hardening pass then audited the whole increment and fixed everything it found (2 critical, 13 bugs, 1 security finding, structural refactors) — see "Review hardening" below.
suppressInputDuringPlayback→ on), replay-proof HMAC tuples, deterministic group gate on realtime, playout clock for echo + notify drain, greet-on-answer (outbound call-backs no longer deliver to a ringing phone)/summarize, DOCX minutes artifact, per-speaker attribution (unmixed audio)withConsultGuards+ structural refactorsrealtime.suppressInputDuringPlaybackdefault flipped off → on: default-off was the live self-answer bug — the bot answered its own echo on every realtime deployment. Setfalseto restore; tune viarealtime.echoSuppressionWindowMs/realtime.echoBargeInRms.outbound.defaultMode(default notify = state the result, then hang up after the audio drains) and speak only once the callee answers (recording-active), not when the bridge connects — on both the realtime and streaming paths. Previously they greeted on connect — i.e. while the phone was still ringing — so the result was lost and the idle model could surface unrelated context. Set"conversation"to keep the lingering behavior.msteams.meetingRecap(default off) — when on, the bot posts meeting minutes to the caller's Teams chat (1:1) or the meeting thread (group) at call end. The bot posting unprompted is gated behind this opt-in.msteams.bilingual(default off) — when on, the realtime model is pinned to detect/mirror Arabic↔English and translate on request. The deterministic layers (verbal interrupts, visemes) understand Arabic regardless.channels.msteams.transcribeVoiceMessages(default off) — when on, inbound Teams voice clips are transcribed (STT cost) and folded into the agent's message text.channels.msteams.auditChannel(unset by default) — when set to a conversation id, agent replies are mirrored there for compliance review.channels.msteams.dlp.enabled(default off) — when on, built-in categories andcustomPatternsare redacted from every outbound surface (block messages, streamed replies, edits, adaptive cards). Because per-token streaming cannot be redacted retroactively (a secret completing across chunk boundaries would already be on screen), DLP also downgradesstreaming.mode: "partial"to"progress": the live card shows working status and the redacted full reply lands on it in place.🛡️ Security & correctness hardening
(callId, timestamp, signature)tuples are single-use; a legitimate reconnect carries a fresh timestamp; only verified tuples are recorded (no unauthenticated map growth) and entries expire with the timestamp's own validity window (a future-dated handshake from worker clock skew can't outlive its record). Tested: connect OK → exact replay 401 → fresh-timestamp reconnect OK.shouldRespondToGroupTurncore the streaming path uses; an unaddressed meeting turn's reply audio is dropped at the egress. 1:1 never gates.📞 Call experience
\p{L}approach as the group gate).notifyHangupDelaySecgrace; a failed hangup falls back to a direct teardown.🎥 CVI extensions
look_at_screen scope:"history"attaches up to 6 timestamped, attributed keyframes to one budgeted consult. No eager OCR-per-keyframe.show_to_callercaptions + slideshow + PiP overlay — caption strip on the tile; multi-image results play as a paced slideshow; images ride as a bottom-right inset over the live avatar (display.image mode:"overlay") so the bot stays visible. Additive protocol; older renderers fall back to fullscreen./with-timestampsand surfaces per-character alignment (optional field throughtextToSpeechTelephony); msteamsspeech.marksnow carry each sound's actual start time instead of an even spread, falling back to the estimator when alignment is absent. Audio never depends on timing: a failing/with-timestampsendpoint degrades to plain synthesis.sessionScope: "per-thread"keys the agent session by the Teams meeting/chat thread; parallel conversations stay isolated. PSTN falls through to per-phone.📝 Meeting productivity
msteams.meetingRecap) — key points / decisions / action items posted to the caller's Teams chat (1:1) or into the meeting thread (group) when the call ends.post_meeting_minutestool).💬 Chat & governance
composeExtension/submitAction) quotes the selected chat message into a prompt and dispatches it through the normal message path, so the answer posts back into the conversation. (Requires the Teams app manifest to declare the command:composeExtensions > commands,type: "action",context: ["message"],id: "askOpenClaw".)transcribeVoiceMessages) — inbound Teams audio clips are transcribed and folded into the agent's turn as a quoted block; a clip that fails transcription keeps its attachment placeholder.auditChannel) — agent replies are mirrored to a named Teams conversation for compliance review. The mirror fires at the delivery choke point, so natively-streamed replies (the DM default) are in the trail too, not only block deliveries; the loop guard keeps the audit channel's own traffic out.dlp.enabled) — built-in categories plus custom patterns are redacted with a configurable placeholder, on every outbound surface: block messages, streamed replies (see Behavior changes), message edits, and adaptive cards (deep-redacted per string value). The audit excerpt is redacted before truncation so the cap can never slice a secret into an unrecognizable fragment.🔎 Review hardening (post-feature self-audit)
After the feature work, a full self-review of the increment found and fixed — each with a regression test:
c38d360642)ts+window, notnow+window, closing a replay window for future-dated (clock-skewed) handshakes. (bd5dbc06f3)releaseCallStateowner that every teardown funnels through: host-initiated closes no longer suppressonSessionEnd(dead calls reported in-progress forever); a realtime provider WS drop no longer strands the caller on an un-hangup-able call; manager hangups release vision/recording state (~1–2 MB/call); a declined outbound call no longer leaves its CallRecord active againstmaxConcurrentCalls. (15f5efbe2e)aadIdcross-caller session bleed; lost-and-budget-burning ambient vision pushes; voice-clip placeholder mishandling. (cb29dcae1b)withConsultGuards(one copy of the recording-gate/deps/error/filler boilerplate for all agent-running tools),emitCallEnded, a single hoisted consult session key, the tool surface split intomsteams-realtime-tools.ts, and the msteams runtime no longer building the webhook-plane realtime handler Teams never uses. (9305586f1a)eace301545)sk-proj-/sk-ant-keys and the audit excerpt redacts before truncating. (4bf60c2850)sendPollMSTeamssent the poll Adaptive Card unredacted (the S3 fix only covered the generic adaptive-card path), so a secret in a poll question or option label reached the wire. The poll's agent-supplied question and option strings are now redacted before the card is built — visible text only, leaving the internal option indices andpollIdintact so vote matching is unaffected. (fe2f789935)notifyDtmfnow drops keypresses until recording is active (regression test included). (63f7f30e88)Commit → feature map (increment beyond #91438)
df7c31a106,f5cf55f9957dadcdfb12,05733cf2e6,93173defcf54f6e6acc1,741040a22341be3c690a,c7fbb6e356,2cfcb9ddad8556d2b7a4,303a49a0c80b4396b737323188145c,bd5dbc06f3208329c03e074fcd6a4a54bbe7f30ee93b06676e,4ef6fe9d5f,eace3015454be87282dd,79d9c845df,ee1b8f7baa,6a5a1f3214,86dd65ebc8,bbd4cff526f5c8700ee1c1d1b6a8ec7e151742ce,eace3015454ac7780eed6005f6538315517f6657ef5edc119c0d2d12fd45,4f40555b4ec38d360642,f37488cf1f,15f5efbe2e,cb29dcae1b,9305586f1a4bf60c285090b7805cfd0ef6f8e570fe2f789935a447350c52a20d9077fdbe9cff461563f7f30e88a4477208de6285200889Each feature ships with unit tests (matcher/positive-negative units, display-path, history-ring, group-gate egress drop, replay 401/reconnect, recap prompt, DTMF mapping, message-action extraction, teardown/leak regressions, Arabic interrupt/viseme cases, etc.).
✅ Live validation (2026-06-12, real Teams calls)
Re-validated end-to-end on this branch's exact head against a production Teams tenant (gateway from source, companion media worker as a Windows service), in two calls:
openclaw_agent_consultwith the "thinking" avatar expression held across six tool runs (worker log); emotion cues (Happy/Neutral transitions) tracking reply tone; screen-share vision (look_at_screen, scene-change awareness, retroactive keyframe history — two VBSS share windows in the worker log);show_to_callerrendered two images as a PiP overlay with caption, paced as a slideshow with exactly the coded hold times (4500 ms non-final / 5000 ms final).outbound call placed → Established → recording-active gate opened → result spoken only after answer (greet-on-answer) → notify-mode auto-hangup after the audio drained— complete, un-clipped delivery, clean teardown, no leaked call state.posting meeting recap) and the minutes landed in the Teams chat; in-call background task delivered its answer to chat.connection closedwith no lingering state) — the review-hardening teardown paths behaving live.90b7805cfd; the gateway now starts withdlp/transcribeVoiceMessages/auditChannelconfigured.Chat & governance (same day, follow-up session):
auditChannelset to a dedicated group chat, every streamed DM reply produced a🧾 Reply for <sender> in <conversation>: <excerpt>line in the audit chat; the bot's own reply inside the audit chat produced no self-mirror (loop guard).…([REDACTED:email])in the Teams client: the model emitted the address, the redaction layer scrubbed it on the streamed path — the exact bypass this PR closes. (Thesecretcategory never reached the wire live because the model itself refused to repeat even a dummysk-proj-…token — twice; the category patterns are unit-tested and the wire path is category-agnostic.)Real lip-sync timing (2026-06-13, follow-up): the ElevenLabs
/with-timestampsalignment path (0ef6f8e570) was validated on a live Teams call with the SVG face avatar and debug logging. The greeting's mouth track was driven by real per-character timing, not the even-spread estimator:(aligned), not(estimated)— the with-timestamps endpoint succeeded with no fallback, and the 60 marks carry each character's actual start time. The caller-hangup teardown was clean on both sides (GraphTerminated→ bridge closed → disposed, no leak). Full evidence:docs/proofs/LIVE-TEST-92081.mdin the companion worker repo.Real behavior proof
Behavior or issue addressed: End-to-end behavior of this PR on real Microsoft Teams calls and chats: realtime voice (echo self-answer fix, deterministic verbal interrupts incl. Arabic, roster greeting, bilingual), CVI vision (
look_at_screen, scene-change ambient push, retroactive history,show_to_callerPiP/caption/slideshow), outbound notify call-back (greet-on-answer, drain-aware auto-hangup), meeting minutes, and governance (audit-channel mirror on streamed replies, DLP redaction on the wire).Real environment tested: Production Microsoft 365 tenant (PCFC, Dubai) with the Teams desktop client; OpenClaw gateway run from source at this branch's head on a Windows Server VM; the companion app-hosted media worker running as a Windows service; Azure OpenAI realtime voice model; ElevenLabs STT/TTS.
Exact steps or command run after this patch:
pnpm openclaw gateway runon the branch head withmsteams.dlp.enabled,auditChannel,meetingRecap, andbilingualconfigured → placed a 1:1 Teams voice call to the bot and exercised greeting, echo, interrupts ("stop" / "assistant, stop" / "توقف"), Arabic conversation, screen-share vision, "show me a screenshot", and "summarize the meeting" → said "look up the time in Tokyo and call me back", hung up, and answered the bot's call-back → ran the chat pass: DM questions including "reply with exactly: contact our support team at [email protected]", with an "Audit Log" group chat configured as the audit channel.Evidence after fix: Copied (redacted) gateway runtime log from the live calls:
Worker runtime log excerpt:
[display] showing image for 4500 ms (mode=overlay, caption=True)followed by5000 ms— theshow_to_callerslideshow pacing constants observed on the wire. Copied live output from the Teams client: the bot's streamed DM reply rendered asNoted — thanks, Alaaeldin. I've got the support contact ([REDACTED:email])., and the audit chat received🧾 Reply for Eng. Alaaeldin Mohamed Elhenawy in a:180Gt…: …mirror lines — one per streamed DM reply.Observed result after fix: Caller greeted by name; the bot never answered its own echo; all three interrupt forms cut playback instantly; vision answered correctly across two screen-share windows including "what did the FIRST screen say"; the captioned PiP slideshow displayed; the call-back spoke only after the callee answered, delivered the complete un-clipped result, and hung itself up; recap + minutes landed in the Teams chat; the email in a reply reached the client as
[REDACTED:email]; every streamed DM reply was mirrored to the audit channel and the bot's own reply inside the audit chat produced no self-mirror.What was not tested: Voice-message transcription live (this tenant exposes no audio voice-message capability on either Teams client — the desktop mic-only "video clip" delivers no media to bots, and the bot's graceful no-media reply WAS observed live); the DLP
secretcategory on the wire (the model refused to repeat even a stated-dummysk-proj-…token, twice — the email category proves the same redaction path); the group-chat form of the message action; DTMF (no dialpad on VoIP 1:1 calls); group/meeting calls with a second human participant.Foundation recap (from #91438, included in this diff until it lands)
The
msteamsprovider: inbound Teams voice + video vision (look_at_screen, streaming auto-attach, realtime continuous vision viaRealtimeVoiceBridge.sendImage), group/meeting awareness (per-participant attribution + speak-only-when-addressed), outbound "call me back" with voicemail fallback, avatar rendering drivers (expression cues, viseme lip-shape,show_to_caller), caller allowlist (closed by default), recording-status gate on every media-derived path, HMAC-authenticated bridge WebSocket, SSRF-guarded outbound. Live-proven on 1:1 and 3-party group Teams calls.Notes
AvatarImagePath=avatar, the default), and a Lottie loop with speech-reactive zoom (AvatarImagePath=jarvis) — plus explicit file paths and a procedural fallback. Theexpression/speech.marks/display.imagemessages this PR emits drive whichever style is configured.Acknowledgements
Contributed by the team at the Ports, Customs and Free Zone Corporation (PCFC) — Dubai, United Arab Emirates — bringing Microsoft Teams voice & video (the Conversational Video Interface) to OpenClaw for public-sector use. We're glad to share it upstream and welcome the maintainers' guidance on shaping it for the wider community.