Skip to content

[Feature]: Microsoft Teams channel adapter via Graph polling (outbound-only) #75

@initializ-mk

Description

@initializ-mk

Component

forge-plugins (channel plugins, markdown converter)

Scope

Large (new module / architectural change)

Problem statement

The Phase 4 design previously removed MS Teams support because the standard Bot Framework integration requires a public HTTPS endpoint registered with Azure Bot Service and inbound webhook delivery — which violates Forge's first design principle: outbound-only, no public URLs, no tunnels, no inbound webhooks.

Two facts have since changed the analysis:

  1. Microsoft Graph chats/getAllMessages/delta enables outbound-only polling — same shape as Telegram long-polling, no inbound endpoint required.
  2. Teams APIs are no longer metered. As of 2025-08-25, Teams APIs (including getAllMessages/delta) are no longer billed per-message; the model=A/B query parameter is ignored; no Azure billing subscription required. This removes the previous per-message cost blocker.

The polling approach is therefore additive to the architecture, not a violation of it. The Slack/Telegram outbound-only invariant is preserved.

Proposed solution

Add a first-party MS Teams channel adapter at forge-plugins/channels/msteams/ that authenticates as a single Teams user via OAuth2 (delegated refresh-token flow or client credentials), polls /users/{id}/chats/getAllMessages/delta on a 5-second cadence, dispatches mentions / DMs to the A2A router, and posts replies via POST /chats/{id}/messages.

One-sentence operational model:

An Entra ID app authenticates as a single Teams user (the "agent identity"), polls Graph delta on 5s cadence, dispatches new mention/DM messages to the A2A router, and posts replies via Graph.

Architecture decision: what this is NOT

  • ❌ Bot Framework activity handlers / /api/messages inbound webhook
  • ❌ Azure Bot Service registration
  • ❌ Adaptive Cards (text + HTML subset only)
  • Channel messages in Teams (this targets chats: 1:1, group, meeting chats — not channels inside teams; the v1.0 channels endpoint has a known Microsoft bug with @odata.nextLink calls)
  • ❌ Application permissions / multi-tenant (delegated permissions only — single Entra ID app, single user inbox)

File changes

Path Action Purpose
forge-plugins/channels/msteams/msteams.go CREATE Adapter implementing ChannelPlugin. Owns polling loop, delta cursor, message dispatch.
forge-plugins/channels/msteams/auth.go CREATE Graph OAuth2 token acquisition (delegated refresh-token + client credentials flows).
forge-plugins/channels/msteams/graph.go CREATE Typed Graph API client: getAllMessages/delta, POST chats/{id}/messages, /me.
forge-plugins/channels/msteams/dedup.go CREATE Message-ID dedup ring (sliding window of last 1000 IDs).
forge-plugins/channels/msteams/admission.go CREATE Mention/DM admission logic + self-loop guard (mirrors Slack admitBotEvent).
forge-plugins/channels/msteams/cursor.go CREATE Delta cursor persistence (.forge/channels/msteams-cursor.json, atomic rename).
forge-plugins/channels/msteams/msteams_test.go CREATE Unit tests with httptest.Server stub for Graph.
forge-plugins/channels/markdown/teams.go CREATE Markdown → Teams HTML subset + plain-text extraction + 24 KB split.
forge-cli/cmd/channel_msteams.go CREATE forge channel add msteams interactive setup wizard.
forge-cli/cmd/serve.go, run.go EDIT Add msteams to the --with allowlist.
forge-core/security/capabilities.go EDIT Add msteams capability bundle: graph.microsoft.com, login.microsoftonline.com.
templates/msteams-config.yaml.tmpl CREATE Channel config template emitted by the wizard.
docs/channels/msteams.md CREATE User-facing setup guide.
FORGE_PROJECT_DESIGN.md EDIT Channel Connectors table row; remove any prior "Teams not implemented" note.

Zero edits required to: k8s_stage.go, requirements_stage.go, template_data.go, K8s manifest templates, cmd/package.go, forge-cli/build/channels_stage.go, forge-core/channels/env.go. The existing ChannelsStage design (PR #54) handles new channels through config files alone — msteams-config.yaml's _env-suffix settings are auto-discovered.

Config surface — msteams-config.yaml

Emitted by forge channel add msteams:

adapter: msteams

settings:
  tenant_id_env: MSTEAMS_TENANT_ID
  client_id_env: MSTEAMS_CLIENT_ID
  client_secret_env: MSTEAMS_CLIENT_SECRET

  auth_flow: delegated         # \"delegated\" (default) or \"client_credentials\"
  refresh_token_env: MSTEAMS_REFRESH_TOKEN   # delegated flow only
  user_id_env: MSTEAMS_USER_ID

  # graph_base_url_env: MSTEAMS_GRAPH_BASE_URL   # sovereign clouds (US Gov, China)

  poll_interval_seconds: 5     # floor 3, default 5, ceiling 60
  admit: mention_or_dm         # \"mention\" / \"dm\" / \"mention_or_dm\"
  allow_bot_ids: []

forge.yaml surface stays minimal — identical to Slack/Telegram:

channels:
  - msteams

Tool / behaviour catalog (per-method spec)

Start() sequence:

  1. Resolve config envs via shared channels.ResolveEnvVars
  2. Acquire access token via auth.Manager (delegated or client_credentials)
  3. GET /me (delegated) or /users/{userID} (client_creds) → cache ownUserID + ownDisplay
  4. Load .forge/channels/msteams-cursor.json or initialise from $filter=lastModifiedDateTime gt <now>
  5. Start poll goroutine

Poll loop:

  • Follow @odata.nextLink to drain the current batch in one tick (no mid-batch sleep)
  • Persist @odata.deltaLink (caught-up cursor) — not nextLink (pagination state)
  • Inflight dedup ring tolerates restart mid-batch

handleMessage() admission order (mirrors Slack):

  1. Self-loop guard (from.user.id == ownUserID) — hard rule, beats allowlist
  2. Dedup
  3. Bot admission (from.application non-nil → must be in allow_bot_ids)
  4. Mode filter (mention / dm / mention_or_dm)
  5. HTML → plain (TeamsHTMLToPlain) for the LLM
  6. Strip own @-mention from prompt
  7. Dispatch via router.Dispatch

SendResponse():

  • Body ≤ 24 KB → PostChatMessage with contentType: \"html\"
  • Body > 24 KB → SplitSummaryAndReport + hosted-content attachment (research-report.md)
  • Attachment fails → fall back to chunked sends

Markdown converter (new forge-plugins/channels/markdown/teams.go)

Mirrors the Telegram/Slack pattern already in the package:

func MarkdownToTeamsHTML(md string) string
func TeamsHTMLToPlain(html string) string
func SplitMessageTeams(text string) []string                      // 24 KB cap (Teams limit ~28 KB; leave headroom for HTML tags)
func ExtractMention(body string, mentions []TeamsMention, userID string) bool

Conversion table covered: bold, italic, inline code, fenced code, links, ordered/unordered lists, headings, @mentions, blockquotes.

Error handling

Graph status Behaviour
401 Force auth.Refresh(), retry once. Still 401 → 60s backoff.
403 Log with docs/channels/msteams.md remediation hint. 60s backoff.
429 Respect Retry-After. Min 10s, max 300s.
410 Gone (delta cursor expired) Critical — discard cursor, re-init from $filter=lastModifiedDateTime gt <now>. WARN log. Must never crash.
5xx / network Exponential backoff 5s → 10s → 20s → 40s → 60s capped; reset on first success.
400 on nextLink (known DeltaToken bug) ERROR + URL log, fall back to re-init from "now".

Capability bundle

forge-core/security/capabilities.go:

\"msteams\": {
    \"graph.microsoft.com\",
    \"login.microsoftonline.com\",
},

Sovereign clouds (US Gov: graph.microsoft.us, login.microsoftonline.us / China: microsoftgraph.chinacloudapi.cn, login.chinacloudapi.cn) stay out of the default bundle — operators add them via egress.allowed_domains to avoid widening the surface for the 99% of users on commercial cloud.

Attachment uploads (hosted contents)

Teams has no files.upload equivalent for chats. Decision: use hosted contents (inline base64 in attachments[].contentUrl), not OneDrive uploads. Rationale: OneDrive would require Files.ReadWrite scope + *.sharepoint.com / *.onedrive.com egress + extra consent. Hosted contents keep the egress surface to two domains.

4 MB cap per message. Beyond that → fall back to chunked text. Documented in user guide.

CLI wizard — forge channel add msteams

Mirrors forge channel add slack UX (same bubbletea framework, same step component pattern):

  1. Welcome panel — link to docs/channels/msteams.md for Entra app registration steps
  2. Tenant ID (GUID validation)
  3. Client ID (GUID validation)
  4. Client secret (hidden input)
  5. Auth flow radio: delegated (default) / client_credentials
  6. Delegated path: device-code flow against https://login.microsoftonline.com/{tenant}/oauth2/v2.0/devicecode, poll until user completes consent, capture refresh token
  7. Client-creds path: prompt for MSTEAMS_USER_ID (the agent user's objectId)
  8. Validate live: GET /me or /users/{userID}, display displayName + userPrincipalName for confirmation
  9. Write .env + msteams-config.yaml + add msteams to channels: in forge.yaml. Print egress domains being added.

Anti-patterns (do NOT do)

❌ Anti-pattern ✅ Correct approach
Wrap Microsoft Bot Framework SDK / bf-cli Direct HTTP to graph.microsoft.com via net/http + EgressEnforcer
Expose any inbound port for Teams webhooks Outbound HTTPS only. Adapter binds nothing.
Hardcode the Graph base URL Read from graph_base_url_env so sovereign clouds work
Store client secret or refresh token in forge.yaml Secrets in .env (local) or K8s Secret (container). Config holds env-var names with _env suffix.
Use model=A / model=B query parameters Metering era ended 2025-08-25. The model param is ignored. Omit it.
Poll getAllMessages (non-delta) on every tick Use getAllMessages/delta with persisted @odata.deltaLink. Non-delta is first-run only.
Log the access token, refresh token, or full message bodies Redact tokens. Body content at DEBUG only, never INFO.
Forward all received messages to the agent Filter by mention OR DM-chat. Drop self-authored.
Treat Teams body as markdown Teams returns HTML in body.content when contentType: \"html\" — strip and convert to plain
Reuse Telegram's 4096-char split Teams enforces ~28 KB body limit. Use 24 KB threshold to leave HTML-tag headroom.
Call /me on every poll Call once at startup; cache userID + displayName for adapter lifetime
Embed tenant ID in OAuth authority as a literal Read from tenant_id_env; keep the multi-tenant seam clean

Done criteria

Phase A — Skeleton compiles:

  • cd forge-plugins && go build ./channels/msteams/...
  • Adapter loads, implements ChannelPlugin; no-op Start/Stop/SendResponse

Phase B — Graph client + auth:

  • TestGraph_*httptest.Server stub covers /me, delta paging, all four error-class branches (401/403/429/410)
  • TestAuth_* — delegated refresh-token flow, client_credentials flow, refresh-token rotation persisted via secrets.Store

Phase C — Markdown converter:

  • TestTeams_* — round-trip MD → HTML → plain for every row of the conversion table

Phase D — Admission + dedup:

  • TestAdmission_* — each row of the admission flow tested; self-loop guard fires before allowlist
  • TestDedup_* — ring holds exactly 1000; evicts oldest first

Phase E — End-to-end against a real tenant (manual):

  • forge channel add msteams wizard completes; tenant resolves; refresh token captured
  • forge run --with msteams — DM the agent → response arrives within ~5s
  • Group chat: @AgentName what's up → response arrives
  • Stop / restart agent → cursor resumes, no duplicate response
  • forge package --prod → K8s manifest emits MSTEAMS_* env vars in secretKeyRef form (via existing ChannelsStage from PR fix: inject channel env vars into K8s manifests (closes #50) #54)

Phase F — Egress conformance:

  • forge run --with msteams 2>&1 | grep egress_allowed | jq -r .fields.domain | sort -u → only graph.microsoft.com + login.microsoftonline.com. No other domains.

Open questions (for resolution before implementation)

  1. Delegated vs client credentials default? Recommendation: delegated. Lowest-friction onboarding via device-code flow; most users won't have admin consent rights.
  2. Mention-strip behaviour: strip @AgentName text from the prompt before LLM, or pass through? Recommendation: strip (Slack passes through, but Teams renders mentions as <at id=\"0\">…</at> — the literal display name doesn't help the LLM).
  3. Schedule delivery target format: ChannelTarget is the Graph chat.id (URL-safe string like 19:meeting_XXX@thread.v2). Confirm the scheduler prompt handles arbitrary string targets (it should, but verify).
  4. msteams-channels (in-team channel messages) as follow-up? Recommendation: yes. The v1.0 /teams/{id}/channels/{id}/messages/delta endpoint has the known DeltaToken bug. Ship chat-only first; team-channel polling is a separate workstream blocked on the Microsoft bug.
  5. Telemetry: emit a new channel_poll audit event? Recommendation: no — egress_allowed already covers per-call observability.

Alternatives considered

  • Microsoft Bot Framework webhooks — rejected: requires public HTTPS endpoint + Azure Bot Service registration; violates outbound-only invariant.
  • Subscription-based change notifications (/subscriptions resource) — also requires a public endpoint for delivery, plus subscription renewal management. Same architectural objection.
  • OneDrive attachment uploads — rejected for v1: requires Files.ReadWrite scope, two extra egress domains, additional consent step. Hosted contents keep the surface minimal.

Out of scope (deferred)

  • Channels-in-teams (/teams/{id}/channels/{id}) polling — blocked on Microsoft bug, separate workstream
  • Adaptive Cards — Phase 2 enhancement once text path is stable
  • Multi-tenant single-adapter support — current design is single-tenant per adapter; multi-tenant lives at the Forge Hub layer
  • Teams meeting transcript/recording APIs (require M365 Copilot license, separate metered model)
  • Reaction events (reactionAdded, etc.) — delta returns updated reactions but use case isn't established
  • File attachments received from users (only outbound attachments in scope for v1)
  • GCC High / DoD clouds — MSTEAMS_GRAPH_BASE_URL override is the seam; explicit testing is out of scope

Reference patterns

Mirror these existing adapters (do not modify them):

Estimated effort

1.5–2 weeks. The bulk of complexity is in the Graph client + OAuth flows + wizard UX; the adapter shell, admission logic, and markdown converter are mechanical mirrors of existing patterns.

Design doc update (post-implementation)

FORGE_PROJECT_DESIGN.md:

  1. Channel Connectors table — add row:

    MS Teams | Graph polling | 3002 | Outbound HTTPS poll on /chats/getAllMessages/delta. Delegated OAuth2, mention/DM admission, HTML body conversion, hosted-content attachments.

  2. Instruction Documents table — add row for FORGE_MSTEAMS_CHANNEL_GRAPH_POLLING.md as the source spec for this feature
  3. What's NOT Implemented section — remove any prior Teams entry

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions