Skip to main content

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):

  1. Delete the orgId: possibleOrgId lookup entirely. It cannot work and its presence implies a binding that does not exist.
  2. Resolve the app id from the org binding introduced by TEAMS-3 when available, else process.env.TEAMS_APP_ID.
  3. Replace the if (appId) guard with fail-closed logic mirroring slack.controller.ts:
    • app id resolves β†’ validate; invalid β†’ 401;
    • no app id and NODE_ENV === "production" β†’ 401 with Teams app id not configured (do not process the activity);
    • no app id outside production β†’ log a warning, process (preserves the local dev path).
  4. Return 401, not 400 β€” this is authentication, and slack.controller.ts throwing BadRequestException for a bad signature is a wart not worth copying.
  5. Never log any part of the token or a computed value. Log only timestamp and whether a header was present, matching the Slack comment's reasoning.

Acceptance criteria​

  • With NODE_ENV=production and no app id resolvable, an activity is rejected 401 and routeTeamsMessage is 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() and teamsWebhook() 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​

  1. api/src/channels/inbound-dedup.service.ts β€” add:

    extractTeamsDedupKey(activity: TeamsActivity): string | null

    Key on activity.id (stable per Bot Framework delivery). Fall back to a composite of conversation.id + timestamp when id is absent. Return null when neither is available; a null key must not be treated as "seen".

  2. channels.controller.ts teamsWebhook() β€” call checkDuplicate("teams", key) after JWT validation and before enqueue. On duplicate: return 200 { ok: true }, increment the dedup-drop counter (TEAMS-9), and do not enqueue. Returning 200 is deliberate β€” a non-2xx triggers another redelivery.

  3. Reuse the dedup key as the BullMQ jobId, matching slack.controller.ts, for a second line of defence inside the queue.

  4. 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?.eventType indicates 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.id equals recipient.id produces no turn.
  • An activity with no id and no timestamp still 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​

  1. New entity api/src/channels/teams/teams-tenant-binding.entity.ts, table teams_tenant_bindings:

    ColumnTypeNotes
    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).

  2. Migration following the existing naming convention in api/src/migrations/. Backfill from existing integration_credentials rows of type teams where a tenant_id is present.

  3. Replace resolveTeamsOrgId() with a single indexed lookup on azure_tenant_id. Keep the existing quarantine path for a miss.

  4. 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.

  1. 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_required and 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):

  1. Derive the thread key per conversation type. Pin these rules explicitly:

    conversation.conversationTypechatIdthreadId
    personalconversation.idconversation.id
    groupChatconversation.idconversation.id
    channelchannelData.channel.idconversation.id (carries ;messageid=<root>)

    For a channel thread, conversation.id already encodes the thread root in its ;messageid= suffix, which is what makes it a correct per-thread key. Do not strip it.

  2. Pass threadId to resolveOrMintForChannel. The resulting session_key becomes ${specialistId}:teams:${chatType}:${chatId}:${threadId}, matching how Slack's key is built.

  3. Set chatType from the conversation type β€” "direct" for personal, "group" otherwise β€” rather than the current hardcoded "group".

  4. Set channelThreadId on dispatchInbound to the composite ${chatId}:${threadId}, mirroring Slack's sessionChannelThreadId, so the dispatcher can reconstruct reply coordinates.

  5. Populate teamsReplyToId from 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.
  • chatType reflects 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/webhook and /messages, JWT validation, dedup gate, enqueue. Route paths must not change.
  • teams.service.ts β€” handleActivity(), routeMessage(), org resolution, outbound helpers. Move routeTeamsMessage, resolveTeamsOrgId and the TeamsActivity interface verbatim first, then adapt.
  • teams.module.ts β€” wiring. Copy slack.module.ts's buildModuleMetadata() 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.ts contains no Teams code.
  • Controllers are registered on both the Redis and no-Redis branches.
  • teams-route jobs process through BullMQ when REDIS_URL is set, and fall back to setImmediate when not.
  • Existing teams.spec.ts passes 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​

  1. Entity teams-thread-route.entity.ts, table teams_thread_routes, modelled on slack-osa-thread-route.entity.ts:

    • scope columns org_id, azure_tenant_id, external_channel_id, external_thread_id with 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.
  2. teams-routing.service.ts with decide(input): TeamsRouteDecision mirroring SlackOsaRoutingService:

    • ignore bot messages and non-message activity types;
    • a mention (an entities[] entry of type mention whose mentioned.id matches the bot) or a personal conversation 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.
  3. Default engagement TTL 24h. Read the org override through the same path Slack uses (getThreadEngageTtlHours).

  4. 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​

  1. teams-channel-binding.service.ts + teams_channel_bindings table, mirroring SlackChannelBindingService: getBinding, autoBindToPrimary, removeBinding, removeAllForOrg. Unique on (org_id, external_channel_id).

  2. Handle conversationUpdate activities:

    • membersAdded containing the bot β†’ autoBindToPrimary + post a welcome message naming the bound Specialist;
    • membersRemoved containing the bot β†’ removeBinding.
  3. Handle installationUpdate:

    • action: "add" β†’ record the installation;
    • action: "remove" β†’ clear the org's Teams credentials and all bindings, mirroring Slack's handleSlackUninstalled.
  4. TeamsRoutingService.decide() resolves the assignment through the binding when one exists, falling back to the org's primary assignment β€” same precedence as SlackOsaRoutingService.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 membersAdded does 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​

  1. Add normalizeTeams(activity): NormalizedMessage to api/src/channels/normalize.ts, following normalizeSlack:

    • strip <at>...</at> mention markup from the text;
    • convert Teams HTML content to plain text when textFormat === "xml" or an html content type is present;
    • map activity.attachments[] to NormalizedAttachment[], skipping the text/html entry Teams adds for rich text (it duplicates the body and is not a real attachment);
    • set customer_id to teams:${tenantId}:${conversationId}.
  2. 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.

  3. 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 in dispatchTeams().

  4. Outbound: extend dispatchTeams() to send attachments when DispatchExpertReplyParams.attachments is 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/html pseudo-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​

  1. teams.metrics.ts mirroring slack.metrics.ts:

    • recordTeamsWebhookRequest(outcome) β€” valid | duplicate | rejected_auth | rejected_config | malformed;
    • observeTeamsWebhookLatency(phase, ms) β€” deduplication | queue_dispatch | total, recorded in a finally so rejection paths are measured too;
    • recordTeamsDedupDrop(source).
  2. deriveCorrelationId("teams", dedupKey) at the webhook, carried explicitly on job data. Bind it on the setImmediate fallback too β€” the Slack fallback originally had no binding, and messages persisted without a correlation id.

  3. @UseGuards(BackpressureGuard) + @BackpressureQueues("channels-inbound") and @Throttle({ default: { limit: 1000, ttl: 60_000 } }) on the Teams webhook.

  4. Register a teams-stepped backoff strategy on the worker with attempts: 4. Note the Slack comment: BullMQ resolves custom strategies from WorkerOptions.settings, not QueueOptions.settings.

  5. Sender name resolution β€” Teams channel activities frequently omit from.name. Add a cached Graph lookup mirroring resolveSlackSenderName'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​

  1. Send a Bot Framework typing activity when a message routes. Teams has no reaction API equivalent, so this is the closest analogue. Best-effort; a failure must never break routing.

  2. 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.

  3. 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​

  1. Follow TEAMS_SETUP.md: register the Azure Bot as multi-tenant, enable the Teams channel, set the messaging endpoint, generate the client secret.
  2. Set TEAMS_APP_ID / TEAMS_APP_PASSWORD in Railway for staging and production.
  3. Build the Teams app package (manifest + icons) with supportsFiles: true and scopes: ["personal", "team", "groupchat"].
  4. Create the teams_tenant_bindings row for the pilot org (TEAMS-3).
  5. Remove "teams" from the coming-soon sets in frontend/src/components/client/settings/ChannelsTab.tsx (lines ~117 and ~131) and update frontend/src/app/client/settings/channels/__tests__/coming-soon.test.ts.
  6. Update docs/features/channels.md, docs/implementation-status.md and docs/FUTURE_REQUIREMENTS.md to 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​

#TitlePriorityDepends on
TEAMS-1JWT validation fails openP0β€”
TEAMS-2Inbound dedup + bot-message filterP0β€”
TEAMS-3Explicit tenant→org bindingP0—
TEAMS-4Thread model β€” session per threadP13
TEAMS-5Extract TeamsModuleP11, 2
TEAMS-6Mention-gating + engagement TTLP14, 5
TEAMS-7Channel binding + install lifecycleP16
TEAMS-8Normalization + attachmentsP15
TEAMS-9Metrics, correlation, backpressureP25
TEAMS-10Typing indicator + Adaptive CardsP26
TEAMS-11Clear BLK-032, enable for clientsP11–8

Phases 1–3 (issues 1–9) need no Azure credentials.