Microsoft Teams integration β issue proposals
Ready-to-file issue drafts for the plan in teams-integration-prd.md. Each is scoped to be executable by an agent without further discovery: exact files, exact contract, explicit acceptance criteria and required tests.
Baseline: origin/dev at a5d43165e. Branch: feat/teams-integration-0824.
Dependency order. 1 β 2 β 3 unblock everything. 4 β 5 β 6 are the functional core and are strictly ordered. 7, 8, 9 are parallel once 5 lands. 10 and 11 are last.
Applies to every issue below. ADR-046 governs. The adapter transports, authenticates, persists, observes and delivers. It must not assemble prompts, retrieve KB, classify the turn, narrow tools, or gate delivery. No issue below may introduce a Humanwork-side review, approval or content gate.
TEAMS-1 β [P0][security] Teams webhook JWT validation fails open when no app id resolvesβ
Labels: priority:p0, area:backend, type:bug
Contextβ
channels.controller.ts teamsWebhook() resolves the app id by querying integration_credentials with activity.channelData.tenant.id as orgId. That value is an Azure AD tenant GUID; orgId is a Humanwork UUID. Different key spaces, so the lookup never matches, and the code falls back to process.env.TEAMS_APP_ID. Validation is then wrapped in if (appId) β so when the env var is unset, the endpoint accepts unauthenticated activities.
#2112 was a P0 for this exact pattern on other channel webhooks. Teams still has it.
Implementationβ
In api/src/channels/channels.controller.ts (teamsWebhook, teamsMessages):
- Delete the
orgId: possibleOrgIdlookup entirely. It cannot work and its presence implies a binding that does not exist. - Resolve the app id from the org binding introduced by TEAMS-3 when available, else
process.env.TEAMS_APP_ID. - Replace the
if (appId)guard with fail-closed logic mirroringslack.controller.ts:- app id resolves β validate; invalid β
401; - no app id and
NODE_ENV === "production"β401withTeams app id not configured(do not process the activity); - no app id outside production β log a warning, process (preserves the local dev path).
- app id resolves β validate; invalid β
- Return
401, not400β this is authentication, andslack.controller.tsthrowingBadRequestExceptionfor a bad signature is a wart not worth copying. - Never log any part of the token or a computed value. Log only
timestampand whether a header was present, matching the Slack comment's reasoning.
Acceptance criteriaβ
- With
NODE_ENV=productionand no app id resolvable, an activity is rejected401androuteTeamsMessageis never called. - With an app id resolvable and a valid JWT, the activity routes normally.
- With an app id resolvable and a tampered JWT, the request is rejected
401. - Outside production with no app id, the activity routes and a warning is logged.
- No token material appears in any log line.
-
teamsMessages()andteamsWebhook()share one validation path β no drift.
Testsβ
Extend api/test/teams.spec.ts with controller-level cases for each criterion. The fail-closed production case is the one that must exist; assert on the mock for routeTeamsMessage not being called, not merely on the status code.
TEAMS-2 β [P0] Teams inbound dedup and bot-message filterβ
Labels: priority:p0, area:backend, type:bug
Contextβ
Two independent defects with the same blast radius β duplicate or looping turns, each one a real model spend.
No dedup. InboundDedupService has extractSlackDedupKey, extractWhatsAppDedupKey, extractTelegramDedupKey, extractEmailDedupKey β nothing for Teams. Bot Framework redelivers on non-2xx. Slack's gate exists because a retry storm doubled every inbound message under multi-pod.
No bot filter. Nothing compares the sender against the bot's own identity. SlackOsaRoutingService.decide() checks isBot before anything else. Without it the bot's own reply can re-enter as an activity and loop.
Implementationβ
-
api/src/channels/inbound-dedup.service.tsβ add:extractTeamsDedupKey(activity: TeamsActivity): string | nullKey on
activity.id(stable per Bot Framework delivery). Fall back to a composite ofconversation.id+timestampwhenidis absent. Returnnullwhen neither is available; a null key must not be treated as "seen". -
channels.controller.tsteamsWebhook()β callcheckDuplicate("teams", key)after JWT validation and before enqueue. On duplicate: return200 { ok: true }, increment the dedup-drop counter (TEAMS-9), and do not enqueue. Returning 200 is deliberate β a non-2xx triggers another redelivery. -
Reuse the dedup key as the BullMQ
jobId, matchingslack.controller.ts, for a second line of defence inside the queue. -
routeTeamsMessage()β before any routing work, drop the activity when:activity.from.id === activity.recipient.id, or- the sender id matches the configured bot app id, or
activity.channelData?.eventTypeindicates a system message.
Acceptance criteriaβ
- The same activity delivered twice produces exactly one turn.
- A duplicate returns
200, not an error. - An activity whose
from.idequalsrecipient.idproduces no turn. - An activity with no
idand notimestampstill routes (fails open on dedup, not closed). - The dedup check runs after JWT validation β an unauthenticated request must never consume a dedup key.
Testsβ
Unit tests for extractTeamsDedupKey (present id, missing id with timestamp, neither). Controller tests for double delivery and the echo case. Assert the second delivery does not reach dispatchInbound.
TEAMS-3 β [P0][security] Bind Teams tenants to orgs explicitly instead of scanning every credentialβ
Labels: priority:p0, area:backend, type:feature
Contextβ
resolveTeamsOrgId() loads every Teams credential for every org via find({ where: { integrationType: "teams" } }), then matches in application code against tenant / team / channel / conversation / recipient ids taken from the request body. Two problems: the scan is unbounded as tenants grow, and tenancy is resolved from attacker-suppliable values with no binding proof.
Implementationβ
-
New entity
api/src/channels/teams/teams-tenant-binding.entity.ts, tableteams_tenant_bindings:Column Type Notes iduuid PK org_iduuid azure_tenant_idvarchar(64) app_idvarchar(64) the bot app id serving this tenant created_at/updated_attimestamptz Unique on
azure_tenant_idβ one Azure tenant maps to exactly one org. Index(org_id). -
Migration following the existing naming convention in
api/src/migrations/. Backfill from existingintegration_credentialsrows of typeteamswhere atenant_idis present. -
Replace
resolveTeamsOrgId()with a single indexed lookup onazure_tenant_id. Keep the existing quarantine path for a miss. -
Reject any activity carrying no
channelData.tenant.idβ without it there is no tenancy claim to verify.
4b. Stop testTeams() reading config.tenant_id. It currently does
config.tenant_id ?? "botframework.com", so an operator who sets the
customer tenant GUID (as the setup runbook originally instructed) sends the
health check to the wrong authority and it fails against a perfectly working
bot. dispatchTeams() already hardcodes the botframework.com authority
correctly. Hardcode it in testTeams() too, so authentication and routing
stop sharing one overloaded field.
- Delete the credential-scan code path. Do not leave it as a fallback; a fallback that scans re-introduces the problem under load.
Acceptance criteriaβ
- Org resolution performs one indexed query, independent of tenant count.
- An activity from an unbound tenant is quarantined as
org_resolution_requiredand produces no turn. - An activity with no tenant id is rejected before routing.
- Two orgs cannot bind the same
azure_tenant_id(DB constraint, proven by test). - Migration backfills existing credentials without data loss.
Testsβ
Migration test (up, down, backfill). Resolution tests: bound tenant, unbound tenant, missing tenant, duplicate-binding rejection. A test asserting exactly one query is issued for resolution.
TEAMS-4 β [P1] Teams thread model β one session per thread, not one per channelβ
Labels: priority:p1, area:backend, type:bug
Depends on: TEAMS-3
Contextβ
The single biggest functional gap. routeTeamsMessage() calls resolveOrMintForChannel({ chatId: channelId }) with no threadId. Every message in a Teams channel therefore collapses into one Hermes session forever β unrelated conversations from different people on different days share one session and its entire history.
Slack keys on the thread root (thread_ts, else the message's own ts), so every thread is its own conversation. Teams must match.
Implementationβ
In routeTeamsMessage() (moving to teams.service.ts under TEAMS-5):
-
Derive the thread key per conversation type. Pin these rules explicitly:
conversation.conversationTypechatIdthreadIdpersonalconversation.idconversation.idgroupChatconversation.idconversation.idchannelchannelData.channel.idconversation.id(carries;messageid=<root>)For a channel thread,
conversation.idalready encodes the thread root in its;messageid=suffix, which is what makes it a correct per-thread key. Do not strip it. -
Pass
threadIdtoresolveOrMintForChannel. The resultingsession_keybecomes${specialistId}:teams:${chatType}:${chatId}:${threadId}, matching how Slack's key is built. -
Set
chatTypefrom the conversation type β"direct"forpersonal,"group"otherwise β rather than the current hardcoded"group". -
Set
channelThreadIdondispatchInboundto the composite${chatId}:${threadId}, mirroring Slack'ssessionChannelThreadId, so the dispatcher can reconstruct reply coordinates. -
Populate
teamsReplyToIdfrom the thread root, not the current message, so replies nest under the thread rather than the latest message.
Acceptance criteriaβ
- Two top-level messages in the same Teams channel resolve to two distinct sessions.
- Two messages in the same thread resolve to one session.
- A personal chat resolves to one stable session across messages.
- A group chat is keyed separately from a channel with the same underlying id.
- The reply posts inside the originating thread, not as a new channel post.
-
chatTypereflects the real conversation type.
Testsβ
Fixture activities for all three conversation types β capture real Bot Framework payload shapes, do not hand-invent them. Assert on the exact session_key produced. A regression test proving two unrelated channel messages do not share a session is the point of this issue and must exist.
TEAMS-5 β [P1][refactor] Extract TeamsModule from the channels.controller monolithβ
Labels: priority:p1, area:backend, type:refactor
Depends on: TEAMS-1, TEAMS-2
Contextβ
Teams lives inside channels.controller.ts, which is ~2,556 lines. Slack, Telegram and WhatsApp each own a module. #167 already called for this split and it was never done. Every issue after this one is harder while the code stays in the monolith.
Implementationβ
Create api/src/channels/teams/ mirroring slack.module.ts structure:
teams.controller.tsβPOST /channels/teams/webhookand/messages, JWT validation, dedup gate, enqueue. Route paths must not change.teams.service.tsβhandleActivity(),routeMessage(), org resolution, outbound helpers. MoverouteTeamsMessage,resolveTeamsOrgIdand theTeamsActivityinterface verbatim first, then adapt.teams.module.tsβ wiring. Copyslack.module.ts'sbuildModuleMetadata()pattern including the Redis-present/absent branch. Register controllers on one shared list across both branches β #4624 caught a controller registered only on the no-Redis branch, 404ing every production shortcut.index.tsβ re-exports.
Register TeamsModule in channels.module.ts and delete the Teams code from channels.controller.ts.
Add a teams-route job handler to ChannelsInboundProcessor mirroring slack-route.
This issue is a pure move. No behavior change. Land it separately from TEAMS-6 so the diff stays reviewable.
Acceptance criteriaβ
- Both Teams routes respond identically before and after.
-
channels.controller.tscontains no Teams code. - Controllers are registered on both the Redis and no-Redis branches.
-
teams-routejobs process through BullMQ whenREDIS_URLis set, and fall back tosetImmediatewhen not. - Existing
teams.spec.tspasses unchanged.
TEAMS-6 β [P1][feat] Teams routing semantics β mention-gated channels, DM full-engage, engagement TTLβ
Labels: priority:p1, area:backend, type:feature
Depends on: TEAMS-4, TEAMS-5
Contextβ
Teams routes every message activity with non-empty text. In a shared channel the bot answers everyone, forever β a product failure and an uncapped cost. Slack decides via SlackOsaRoutingService.decide() against slack_osa_thread_routes.
Mirror those semantics. Do not copy the Slack table; Teams needs its own with Teams identity columns.
Implementationβ
-
Entity
teams-thread-route.entity.ts, tableteams_thread_routes, modelled onslack-osa-thread-route.entity.ts:- scope columns
org_id,azure_tenant_id,external_channel_id,external_thread_idwith a unique constraint across all four; assignment_id,specialist_id,binding_id(nullable),session_id(nullable, unique where not null);initial_triggerβdm|mention;engaged_at,engagement_expires_at,statusβactive|completed|archived;- the same three check constraints Slack uses, including
engagement_expires_at >= engaged_at.
- scope columns
-
teams-routing.service.tswithdecide(input): TeamsRouteDecisionmirroringSlackOsaRoutingService:- ignore bot messages and non-message activity types;
- a mention (an
entities[]entry of typementionwhosementioned.idmatches the bot) or apersonalconversation opens a route; - an unaddressed message with no active route β ignore, reason
unaddressed; - an active route within TTL continues β route, trigger
engaged_thread; - text starting
[done](case-insensitive) completes the route; - an expired route is archived and ignored;
- pin the route row under a pessimistic write lock, and use
insert().orIgnore()+ re-read for the create race, exactly as Slack does.
-
Default engagement TTL 24h. Read the org override through the same path Slack uses (
getThreadEngageTtlHours). -
Link a newly minted session to its route with the same CAS update Slack performs in
linkSlackOsaThreadRouteβ including the ambiguity and already-linked error cases. These are not defensive extras; they are how concurrent deliveries stay correct.
Acceptance criteriaβ
- An unaddressed channel message with no active route produces no turn.
- A mention opens a route and produces a turn.
- A personal-chat message produces a turn with no mention required.
- A reply in an engaged thread within TTL produces a turn without a mention.
- A reply after TTL expiry produces no turn and archives the route.
-
[done]completes the route; subsequent messages produce no turn. - Two concurrent first-messages in one thread create exactly one route row.
- Bot messages are ignored before any route lookup.
Testsβ
Port the slack-osa-routing.service.spec.ts case list to Teams β it already enumerates the state machine. Add a concurrency test for the create race. Every ignore reason needs a case.
TEAMS-7 β [P1][feat] Teams channelβSpecialist binding and installation lifecycleβ
Labels: priority:p1, area:backend, type:feature
Depends on: TEAMS-6
Contextβ
Teams always routes to the org's primary Specialist via resolvePrimarySpecialistId(). Slack supports binding a channel to a specific Specialist (SlackChannelBinding), auto-binds on join with a welcome message, and unbinds on leave. Teams also handles no installation lifecycle at all, so an uninstall leaves stale credentials and bindings.
Implementationβ
-
teams-channel-binding.service.ts+teams_channel_bindingstable, mirroringSlackChannelBindingService:getBinding,autoBindToPrimary,removeBinding,removeAllForOrg. Unique on(org_id, external_channel_id). -
Handle
conversationUpdateactivities:membersAddedcontaining the bot βautoBindToPrimary+ post a welcome message naming the bound Specialist;membersRemovedcontaining the bot βremoveBinding.
-
Handle
installationUpdate:action: "add"β record the installation;action: "remove"β clear the org's Teams credentials and all bindings, mirroring Slack'shandleSlackUninstalled.
-
TeamsRoutingService.decide()resolves the assignment through the binding when one exists, falling back to the org's primary assignment β same precedence asSlackOsaRoutingService.resolveAssignment.
Acceptance criteriaβ
- Adding the bot to a channel binds it to the org's primary Specialist and posts one welcome message.
- Removing the bot unbinds the channel.
- Uninstalling clears credentials and every binding for the org.
- A bound channel routes to its bound Specialist, not the primary.
- Binding is idempotent β repeated
membersAddeddoes not duplicate rows or re-post the welcome. - An org with no Specialist assignment logs a warning and does not bind.
TEAMS-8 β [P1][feat] Teams message normalization and attachmentsβ
Labels: priority:p1, area:backend, type:feature
Depends on: TEAMS-5
Contextβ
routeTeamsMessage() reads activity.text and nothing else. There is no normalizeTeams in normalize.ts, which has normalizers for every other channel. Inbound activity.attachments are dropped; outbound is text-only; Teams HTML and <at> mention markup are never stripped.
ADR-046 Β§1 requires a real binary attachment to be written to the org's cloud AgentFS before dispatch and referenced as a standard ACP resource block β not copied into R2 or base64, not folded into a text row.
Implementationβ
-
Add
normalizeTeams(activity): NormalizedMessagetoapi/src/channels/normalize.ts, followingnormalizeSlack:- strip
<at>...</at>mention markup from the text; - convert Teams HTML content to plain text when
textFormat === "xml"or anhtmlcontent type is present; - map
activity.attachments[]toNormalizedAttachment[], skipping thetext/htmlentry Teams adds for rich text (it duplicates the body and is not a real attachment); - set
customer_idtoteams:${tenantId}:${conversationId}.
- strip
-
Route attachments through the same AgentFS ingest path Slack uses. Follow the existing implementation rather than writing a second one β the ADR forbids a parallel attachment mechanism.
-
Downloading a Teams attachment requires a bearer token on the content URL. Extend the media proxy with a Teams fetch alongside
fetchSlack, reusing the AAD token acquisition already indispatchTeams(). -
Outbound: extend
dispatchTeams()to sendattachmentswhenDispatchExpertReplyParams.attachmentsis present, matching the per-channel outbound media contract.
Acceptance criteriaβ
- A text-only message normalizes with mention markup stripped.
- An attachment-only message (no text) routes rather than being dropped β the current early return on empty text must not swallow it.
- An inbound file lands in the conversation's AgentFS
artifacts/and reaches Hermes as an ACP resource block, with no R2 or base64 copy. - The
text/htmlpseudo-attachment is not treated as a file. - An agent-produced file is delivered back to the Teams thread.
- HTML-formatted inbound text arrives as clean plain text.
Testsβ
Fixtures for: plain text, mention markup, HTML body, single file, multiple files, attachment-with-no-text. Assert the ADR-046 contract directly β that the ACP block references the AgentFS object and no base64 copy exists.
TEAMS-9 β [P2] Teams operational parity β metrics, correlation, backpressure, throttleβ
Labels: priority:p2, area:backend, type:chore
Depends on: TEAMS-5
Contextβ
Slack exports webhook outcome counts, latency by phase, and dedup drops split retry-vs-event. It mints a correlation id at the webhook, applies a backpressure guard and a throttle, and uses a stepped retry backoff. Teams has none of this, so a Teams incident is invisible.
Implementationβ
-
teams.metrics.tsmirroringslack.metrics.ts:recordTeamsWebhookRequest(outcome)βvalid|duplicate|rejected_auth|rejected_config|malformed;observeTeamsWebhookLatency(phase, ms)βdeduplication|queue_dispatch|total, recorded in afinallyso rejection paths are measured too;recordTeamsDedupDrop(source).
-
deriveCorrelationId("teams", dedupKey)at the webhook, carried explicitly on job data. Bind it on thesetImmediatefallback too β the Slack fallback originally had no binding, and messages persisted without a correlation id. -
@UseGuards(BackpressureGuard)+@BackpressureQueues("channels-inbound")and@Throttle({ default: { limit: 1000, ttl: 60_000 } })on the Teams webhook. -
Register a
teams-steppedbackoff strategy on the worker withattempts: 4. Note the Slack comment: BullMQ resolves custom strategies fromWorkerOptions.settings, notQueueOptions.settings. -
Sender name resolution β Teams channel activities frequently omit
from.name. Add a cached Graph lookup mirroringresolveSlackSenderName's 6h TTL, best-effort, never blocking routing.
Acceptance criteriaβ
- All three metric families are exported for Teams and appear on
/metrics. - Total latency is recorded even when the request is rejected.
- Every Teams message persists with a correlation id, on both the queue and fallback paths.
- The webhook sheds load under backpressure.
- Retries follow the stepped schedule, not the queue default.
TEAMS-10 β [P2][feat] Teams receipt signal and Adaptive Card clarifying questionsβ
Labels: priority:p2, area:backend, type:feature
Depends on: TEAMS-6
Contextβ
Two client-visible gaps. Slack acknowledges receipt with a pending reaction swapped to a checkmark on reply; Teams gives no feedback while the agent works. And the ADR-046 Β§4 amendment defines a <<<HW_QUESTION β¦>>> clarifying-card directive that the managed wrapper lifts out of a reply β Teams has no renderer for it, so a clarifying question degrades to prose.
Implementationβ
-
Send a Bot Framework
typingactivity when a message routes. Teams has no reaction API equivalent, so this is the closest analogue. Best-effort; a failure must never break routing. -
Render a lifted clarifying card as an Adaptive Card in
dispatchTeams(). Read the existing card structure from the governance row β do not re-parse the directive here. Per the ADR, the wrapper does the lift; the adapter renders what it is given. -
A malformed or unrenderable card degrades to prose, matching the ADR's stated behaviour.
Acceptance criteriaβ
- A typing indicator appears while the agent works.
- A reply carrying a clarifying card renders as an Adaptive Card.
- A card that cannot be rendered degrades to prose and still delivers exactly once.
- No directive parsing is added to the adapter.
TEAMS-11 β [P1][chore] Clear BLK-032 and enable Teams for clientsβ
Labels: priority:p1, area:backend, status:needs-human, type:chore
Depends on: TEAMS-1 through TEAMS-8
Contextβ
Closes out #122, which was closed in May without the work being done. This is the only issue in the set that requires an Azure tenant and a human β which is exactly why it is last. Every issue before it is testable with synthetic activities, so the credential blocker cannot stall the engineering again as it has since May.
Implementationβ
- Follow TEAMS_SETUP.md: register the Azure Bot as multi-tenant, enable the Teams channel, set the messaging endpoint, generate the client secret.
- Set
TEAMS_APP_ID/TEAMS_APP_PASSWORDin Railway for staging and production. - Build the Teams app package (manifest + icons) with
supportsFiles: trueandscopes: ["personal", "team", "groupchat"]. - Create the
teams_tenant_bindingsrow for the pilot org (TEAMS-3). - Remove
"teams"from the coming-soon sets infrontend/src/components/client/settings/ChannelsTab.tsx(lines ~117 and ~131) and updatefrontend/src/app/client/settings/channels/__tests__/coming-soon.test.ts. - Update
docs/features/channels.md,docs/implementation-status.mdanddocs/FUTURE_REQUIREMENTS.mdto reflect shipped status and drop BLK-032.
Acceptance criteriaβ
- A real Teams tenant can install the app and hold a threaded conversation with a Specialist.
- A file sent in Teams reaches the agent; a file produced by the agent arrives in Teams.
- Teams is connectable from client settings.
- The five verification steps in TEAMS_SETUP.md all pass against the pilot tenant.
- BLK-032 no longer appears as an open blocker in the docs.
Summaryβ
| # | Title | Priority | Depends on |
|---|---|---|---|
| TEAMS-1 | JWT validation fails open | P0 | β |
| TEAMS-2 | Inbound dedup + bot-message filter | P0 | β |
| TEAMS-3 | Explicit tenantβorg binding | P0 | β |
| TEAMS-4 | Thread model β session per thread | P1 | 3 |
| TEAMS-5 | Extract TeamsModule | P1 | 1, 2 |
| TEAMS-6 | Mention-gating + engagement TTL | P1 | 4, 5 |
| TEAMS-7 | Channel binding + install lifecycle | P1 | 6 |
| TEAMS-8 | Normalization + attachments | P1 | 5 |
| TEAMS-9 | Metrics, correlation, backpressure | P2 | 5 |
| TEAMS-10 | Typing indicator + Adaptive Cards | P2 | 6 |
| TEAMS-11 | Clear BLK-032, enable for clients | P1 | 1β8 |
Phases 1β3 (issues 1β9) need no Azure credentials.