From 5cf55a36b81852dc14d67c4df23b1e221ccfcc20 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 20 May 2026 18:03:00 -0400 Subject: [PATCH 01/16] feat(channels): Microsoft Teams adapter via Graph polling (closes #75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Phases A-D of the FORGE_MSTEAMS_CHANNEL_GRAPH_POLLING design: core adapter + Graph client + auth + markdown converter + unit tests. Outbound-only — no inbound webhooks, no public endpoint, no Bot Framework. CLI wizard (Phase E), user-facing setup docs, and live end-to-end testing against a real tenant are deferred to a follow-up PR — see #75 OPEN QUESTIONS for the wizard UX decisions still to settle (delegated vs client_credentials default, mention-strip behavior). The plumbing already supports `forge channel add msteams` non-interactively via the existing add/serve flow. Files forge-plugins/channels/msteams/ - msteams.go Plugin shell (Init/Start/Stop/SendResponse/ NormalizeEvent), poll loop with 3/5/60s clamp, backoff state machine, dispatch + admission gate + dedup, large-response handling that mirrors slack/telegram (summary + hosted-content attachment, fallback to chunked). - auth.go OAuth2 token manager: delegated refresh-token flow + client_credentials. Caches tokens with a 60s expiry window. Refresh-token rotation hook for the runner to persist back to .env / K8s Secret. - graph.go Typed Graph client: /me, /users/{id}, delta paging via getAllMessages/delta, POST chats/{id}/messages, hosted-content attachments (4 MiB cap enforced). Classifies responses into errUnauthorized / errForbidden / errCursorExpired (410) / *rateLimitedError (Retry-After clamped 10-300s) / generic 5xx. No `model=A/B` query param (metering era ended 2025-08-25). - admission.go 4-stage gate: self-loop FIRST (beats allowlist even on misconfiguration), then bot-allowlist check, then mode filter (mention / dm / mention_or_dm). stripBotMention drops the leading "@DisplayName" so the LLM doesn't see the bot's own name as the first word. - cursor.go .forge/channels/msteams-cursor.json with atomic write (tmp + rename), 0700 dir perm. Corrupt file loads as empty. - dedup.go Sliding 1000-ID ring; oldest evicted first; concurrent-safe. forge-plugins/channels/markdown/ - teams.go MarkdownToTeamsHTML (headings, bold, italic, inline code, fenced code, links, ul, ol, blockquote, strikethrough; HTML-safe escaping). TeamsHTMLToPlain (strips Teams chatMessage HTML to text, preserves @mention display names, decodes entities). SplitMessageTeams (24KB threshold — leaves headroom for HTML-tag overhead under the ~28KB Teams body limit). ExtractMention (mentions[] + belt-and-braces). - teams_test.go 18 tests across all converter surfaces. forge-core/security/capabilities.go - msteams bundle: graph.microsoft.com + login.microsoftonline.com. Sovereign clouds (graph.microsoft.us, microsoftgraph.chinacloudapi.cn) stay out of the default bundle — operators add via egress.allowed_domains. forge-cli/cmd/channel.go - ValidArgs include msteams. createPlugin + defaultRegistry register msteams.New(). addChannelEgressToForgeYAML adds msteams to egress.capabilities (same pattern as slack). printSetupInstructions prints the Entra app registration steps. forge-cli/templates/init/ - msteams-config.yaml.tmpl Config emitted by `forge channel add msteams`. All settings ending in _env are auto-discovered by the existing ChannelsStage from PR #54 for K8s manifest emission — zero edits to k8s_stage.go or channels_stage.go required. - env-msteams.tmpl .env placeholders for the five MSTEAMS_* variables. Tests (all under unit-test budget — no network, no real Graph calls) 47 tests added across two packages: - channels/markdown: 18 (MarkdownToTeamsHTML / TeamsHTMLToPlain / SplitMessageTeams / ExtractMention) - channels/msteams: 29 covering: * dedup: seen-and-mark, ring eviction (oldest first), duplicate-mark non-eviction * cursor: atomic save + load round-trip, 0700 dir perm, corrupt-file recovery * admission: self-loop beats allowlist (HARD rule), DM-only, mention-only, mention_or_dm, bot allowlist admit/drop * stripBotMention: 6 cases including case- insensitive prefix and non-start non-strip * parseAllowBotIDs: comma/space/newline splits * graph: /me happy path, delta paging (@odata.nextLink shape), all four error branches (401/403/410/429), Retry-After parsing including floor/ceiling clamps, POST chats/{id}/messages payload shape * auth: delegated refresh-token rotation hook fires + cache prevents second HTTP call, client_credentials happy path, refresh-token-expired error surfacing * Plugin Init validation: tenant required, delegated needs refresh_token, client_creds needs user_id, poll_interval_seconds clamped to 3/5/60 Zero edits to channels_stage.go, k8s_stage.go, requirements_stage.go, template_data.go, K8s manifest templates, or cmd/package.go — the existing _env-suffix scanner from PR #54 picks up msteams-config.yaml automatically. go test ./... forge-core ✅ forge-cli ✅ forge-plugins ✅ golangci-lint 0 issues across all three modules gofmt clean Deferred to a follow-up PR (Phase E + F from #75): - forge-cli/cmd/channel_msteams.go interactive bubbletea wizard with device-code flow capture and live /me validation - docs/channels/msteams.md user-facing setup guide (Entra app registration, permissions, troubleshooting, sovereign-cloud override) - End-to-end manual test against a real tenant - FORGE_PROJECT_DESIGN.md Channel Connectors table row --- forge-cli/cmd/channel.go | 62 +- forge-cli/templates/init/env-msteams.tmpl | 11 + .../templates/init/msteams-config.yaml.tmpl | 39 ++ forge-core/security/capabilities.go | 5 + forge-plugins/channels/markdown/teams.go | 278 ++++++++ forge-plugins/channels/markdown/teams_test.go | 170 +++++ forge-plugins/channels/msteams/admission.go | 105 +++ forge-plugins/channels/msteams/auth.go | 191 ++++++ forge-plugins/channels/msteams/cursor.go | 80 +++ forge-plugins/channels/msteams/dedup.go | 63 ++ forge-plugins/channels/msteams/graph.go | 283 ++++++++ forge-plugins/channels/msteams/msteams.go | 473 ++++++++++++++ .../channels/msteams/msteams_test.go | 612 ++++++++++++++++++ 13 files changed, 2363 insertions(+), 9 deletions(-) create mode 100644 forge-cli/templates/init/env-msteams.tmpl create mode 100644 forge-cli/templates/init/msteams-config.yaml.tmpl create mode 100644 forge-plugins/channels/markdown/teams.go create mode 100644 forge-plugins/channels/markdown/teams_test.go create mode 100644 forge-plugins/channels/msteams/admission.go create mode 100644 forge-plugins/channels/msteams/auth.go create mode 100644 forge-plugins/channels/msteams/cursor.go create mode 100644 forge-plugins/channels/msteams/dedup.go create mode 100644 forge-plugins/channels/msteams/graph.go create mode 100644 forge-plugins/channels/msteams/msteams.go create mode 100644 forge-plugins/channels/msteams/msteams_test.go diff --git a/forge-cli/cmd/channel.go b/forge-cli/cmd/channel.go index caec511..39e028a 100644 --- a/forge-cli/cmd/channel.go +++ b/forge-cli/cmd/channel.go @@ -13,6 +13,7 @@ import ( "github.com/initializ/forge/forge-cli/templates" "github.com/initializ/forge/forge-core/auth" corechannels "github.com/initializ/forge/forge-core/channels" + "github.com/initializ/forge/forge-plugins/channels/msteams" "github.com/initializ/forge/forge-plugins/channels/slack" "github.com/initializ/forge/forge-plugins/channels/telegram" "github.com/spf13/cobra" @@ -22,22 +23,22 @@ import ( var channelCmd = &cobra.Command{ Use: "channel", Short: "Manage agent communication channels", - Long: "Add and serve channel adapters (Slack, Telegram) for your agent.", + Long: "Add and serve channel adapters (Slack, Telegram, MS Teams) for your agent.", } var channelAddCmd = &cobra.Command{ - Use: "add ", + Use: "add ", Short: "Add a channel adapter to the project", Args: cobra.ExactArgs(1), - ValidArgs: []string{"slack", "telegram"}, + ValidArgs: []string{"slack", "telegram", "msteams"}, RunE: runChannelAdd, } var channelServeCmd = &cobra.Command{ - Use: "serve ", + Use: "serve ", Short: "Run a standalone channel adapter (for container use)", Args: cobra.ExactArgs(1), - ValidArgs: []string{"slack", "telegram"}, + ValidArgs: []string{"slack", "telegram", "msteams"}, RunE: runChannelServe, } @@ -48,8 +49,8 @@ func init() { func runChannelAdd(cmd *cobra.Command, args []string) error { adapter := args[0] - if adapter != "slack" && adapter != "telegram" { - return fmt.Errorf("unsupported adapter: %s (supported: slack, telegram)", adapter) + if adapter != "slack" && adapter != "telegram" && adapter != "msteams" { + return fmt.Errorf("unsupported adapter: %s (supported: slack, telegram, msteams)", adapter) } wd, err := os.Getwd() @@ -101,8 +102,8 @@ func runChannelAdd(cmd *cobra.Command, args []string) error { func runChannelServe(cmd *cobra.Command, args []string) error { adapter := args[0] - if adapter != "slack" && adapter != "telegram" { - return fmt.Errorf("unsupported adapter: %s (supported: slack, telegram)", adapter) + if adapter != "slack" && adapter != "telegram" && adapter != "msteams" { + return fmt.Errorf("unsupported adapter: %s (supported: slack, telegram, msteams)", adapter) } // Load channel config @@ -164,6 +165,8 @@ func createPlugin(name string) corechannels.ChannelPlugin { return slack.New() case "telegram": return telegram.New() + case "msteams": + return msteams.New() default: return nil } @@ -174,6 +177,7 @@ func defaultRegistry() *corechannels.Registry { r := corechannels.NewRegistry() r.Register(slack.New()) r.Register(telegram.New()) + r.Register(msteams.New()) return r } @@ -312,6 +316,32 @@ func addChannelEgressToForgeYAML(path, adapter string) error { domainsAny[i] = s } egressMap["allowed_domains"] = domainsAny + + case "msteams": + // Add "msteams" to egress.capabilities (same pattern as slack). + // The capability resolves to graph.microsoft.com + login.microsoftonline.com + // via DefaultCapabilityBundles in forge-core/security/capabilities.go. + var caps []string + if existing, ok := egressMap["capabilities"]; ok { + if arr, ok := existing.([]any); ok { + for _, v := range arr { + if s, ok := v.(string); ok { + caps = append(caps, s) + } + } + } + } + for _, c := range caps { + if c == "msteams" { + return nil // already present + } + } + caps = append(caps, "msteams") + capsAny := make([]any, len(caps)) + for i, s := range caps { + capsAny[i] = s + } + egressMap["capabilities"] = capsAny } doc["egress"] = egressMap @@ -348,6 +378,20 @@ func printSetupInstructions(adapter string) { fmt.Println(" For webhook mode (requires public URL):") fmt.Println(" Set mode: webhook in telegram-config.yaml") fmt.Println(" Set your webhook URL via Telegram Bot API") + case "msteams": + fmt.Println("Microsoft Teams setup instructions:") + fmt.Println(" 1. Register an Entra ID app at https://entra.microsoft.com") + fmt.Println(" 2. Add delegated API permissions: Chat.Read, Chat.ReadWrite, User.Read") + fmt.Println(" 3. (Optional) Grant admin consent if your tenant requires it") + fmt.Println(" 4. Create a client secret under \"Certificates & secrets\"") + fmt.Println(" 5. Capture a refresh token via the device-code flow — see") + fmt.Println(" docs/channels/msteams.md for the exact curl invocation") + fmt.Println(" 6. Fill MSTEAMS_TENANT_ID, MSTEAMS_CLIENT_ID, MSTEAMS_CLIENT_SECRET,") + fmt.Println(" and MSTEAMS_REFRESH_TOKEN in .env") + fmt.Println(" 7. Run: forge run --with msteams") + fmt.Println() + fmt.Println(" This adapter is outbound-only — no public endpoint required.") + fmt.Println(" Default poll cadence is 5s (configurable in msteams-config.yaml).") } fmt.Println() fmt.Println(strings.Repeat("─", 40)) diff --git a/forge-cli/templates/init/env-msteams.tmpl b/forge-cli/templates/init/env-msteams.tmpl new file mode 100644 index 0000000..f847287 --- /dev/null +++ b/forge-cli/templates/init/env-msteams.tmpl @@ -0,0 +1,11 @@ + +# Microsoft Teams channel adapter — Entra ID app credentials. +# See docs/channels/msteams.md for the device-code flow that produces +# MSTEAMS_REFRESH_TOKEN. +MSTEAMS_TENANT_ID= +MSTEAMS_CLIENT_ID= +MSTEAMS_CLIENT_SECRET= +MSTEAMS_REFRESH_TOKEN= +# MSTEAMS_USER_ID is only required for client_credentials flow. +# Delegated flow resolves it automatically via Graph /me. +MSTEAMS_USER_ID= diff --git a/forge-cli/templates/init/msteams-config.yaml.tmpl b/forge-cli/templates/init/msteams-config.yaml.tmpl new file mode 100644 index 0000000..6486db3 --- /dev/null +++ b/forge-cli/templates/init/msteams-config.yaml.tmpl @@ -0,0 +1,39 @@ +adapter: msteams + +settings: + # Entra ID app registration (https://entra.microsoft.com) + tenant_id_env: MSTEAMS_TENANT_ID + client_id_env: MSTEAMS_CLIENT_ID + client_secret_env: MSTEAMS_CLIENT_SECRET + + # Auth flow: "delegated" (recommended) or "client_credentials". + # delegated uses a refresh token captured via the device-code flow at setup. + # client_credentials requires admin consent + RSC permissions on chats. + auth_flow: delegated + + # Delegated flow only — refresh token captured during interactive setup. + refresh_token_env: MSTEAMS_REFRESH_TOKEN + + # The agent user's objectId. For delegated flow this is auto-resolved via + # /me at startup; for client_credentials it MUST be set explicitly. + user_id_env: MSTEAMS_USER_ID + + # Sovereign clouds: override the Graph base URL. + # US Gov: https://graph.microsoft.us + # China: https://microsoftgraph.chinacloudapi.cn + # graph_base_url_env: MSTEAMS_GRAPH_BASE_URL + + # Polling cadence in seconds. Floor 3, default 5, ceiling 60. + poll_interval_seconds: 5 + + # Admission policy: which message types reach the agent. + # - "mention": only messages where the agent user is @-mentioned + # - "dm": only 1:1 chat messages + # - "mention_or_dm" (default, recommended): either + admit: mention_or_dm + + # Allowed bot user IDs (Application.ID values from Graph from.application). + # Default behaviour: drop all messages where from.application is non-nil. + # Add entries here to admit specific bots (CI bot, scheduler, etc.). + # Comma- or whitespace-separated. + allow_bot_ids: "" diff --git a/forge-core/security/capabilities.go b/forge-core/security/capabilities.go index 4f3449c..b210d72 100644 --- a/forge-core/security/capabilities.go +++ b/forge-core/security/capabilities.go @@ -4,6 +4,11 @@ package security var DefaultCapabilityBundles = map[string][]string{ "slack": {"slack.com", "wss-primary.slack.com", "api.slack.com", "files.slack.com"}, "telegram": {"api.telegram.org"}, + // MS Teams via Microsoft Graph polling. Sovereign clouds (US Gov, China) + // stay out of this default bundle — operators add the appropriate domains + // (graph.microsoft.us / microsoftgraph.chinacloudapi.cn / their respective + // login hosts) via egress.allowed_domains. + "msteams": {"graph.microsoft.com", "login.microsoftonline.com"}, } // ResolveCapabilities returns a deduplicated list of domains for the given capability names. diff --git a/forge-plugins/channels/markdown/teams.go b/forge-plugins/channels/markdown/teams.go new file mode 100644 index 0000000..87ca7d1 --- /dev/null +++ b/forge-plugins/channels/markdown/teams.go @@ -0,0 +1,278 @@ +package markdown + +import ( + "html" + "regexp" + "strings" +) + +// Teams body size: Microsoft enforces ~28 KB per chat message body. +// We split at 24 KB to leave headroom for the HTML-tag overhead added by +// MarkdownToTeamsHTML (bold/italic/code/link wrappers can easily double a +// single-line markdown to its HTML equivalent in worst-case inputs). +const teamsBodyLimit = 24000 + +// TeamsMention is the inbound representation of a Microsoft Graph mention +// entry (mentions[i] in a chatMessage). Only the fields the adapter needs +// are modelled — extras decode silently. +type TeamsMention struct { + ID int `json:"id"` + Text string `json:"mentionText"` + Mentioned struct { + User struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + } `json:"user"` + } `json:"mentioned"` +} + +// MarkdownToTeamsHTML converts standard markdown to the HTML subset Teams +// renders in chatMessage bodies when contentType == "html". +// +// Supported: headings, bold, italic, inline code, fenced code, links, +// ordered/unordered lists, blockquotes. Unsupported features (e.g. tables, +// images) degrade to escaped plain text rather than raw HTML so the output +// is always safe to drop into body.content. +func MarkdownToTeamsHTML(md string) string { + lines := strings.Split(md, "\n") + var out []string + inFence := false + var fence []string + var fenceLang string + + flushFence := func() { + code := html.EscapeString(strings.Join(fence, "\n")) + if fenceLang != "" { + out = append(out, `
`+code+"
") + } else { + out = append(out, "
"+code+"
") + } + fence = nil + fenceLang = "" + } + + // List grouping: we collect consecutive list lines into a single
    /
      . + var listKind byte // 'u' for ul, 'o' for ol, 0 for none + var listItems []string + + flushList := func() { + if len(listItems) == 0 { + return + } + tag := "ul" + if listKind == 'o' { + tag = "ol" + } + var b strings.Builder + b.WriteString("<") + b.WriteString(tag) + b.WriteString(">") + for _, item := range listItems { + b.WriteString("
    1. ") + b.WriteString(item) + b.WriteString("
    2. ") + } + b.WriteString("") + out = append(out, b.String()) + listItems = nil + listKind = 0 + } + + for _, line := range lines { + // Fenced code block delimiters. + if rest, ok := strings.CutPrefix(line, "```"); ok { + if !inFence { + flushList() + inFence = true + fenceLang = strings.TrimSpace(rest) + continue + } + inFence = false + flushFence() + continue + } + + if inFence { + fence = append(fence, line) + continue + } + + // Headings (# through ######). + if m := headerRe.FindStringSubmatch(line); m != nil { + flushList() + level := len(m[1]) + out = append(out, ""+html.EscapeString(m[2])+"") + continue + } + + // Blockquote. + if m := blockquoteRe.FindStringSubmatch(line); m != nil { + flushList() + out = append(out, "
      "+applyTeamsInline(html.EscapeString(m[1]))+"
      ") + continue + } + + // Ordered list item: "1. text", "2. text", ... + if m := orderedListRe.FindStringSubmatch(line); m != nil { + if listKind != 'o' { + flushList() + listKind = 'o' + } + listItems = append(listItems, applyTeamsInline(html.EscapeString(m[1]))) + continue + } + + // Unordered list item: "- text" or "* text". + if m := bulletRe.FindStringSubmatch(line); m != nil { + if listKind != 'u' { + flushList() + listKind = 'u' + } + listItems = append(listItems, applyTeamsInline(html.EscapeString(m[1]))) + continue + } + + // Blank line — flush any open list. + if strings.TrimSpace(line) == "" { + flushList() + out = append(out, "") + continue + } + + flushList() + out = append(out, applyTeamsInline(html.EscapeString(line))) + } + + if inFence { + // Unclosed fence — emit what we have. + flushFence() + } + flushList() + + return strings.Join(out, "\n") +} + +// applyTeamsInline applies inline markdown transforms (bold, italic, code, +// links, strikethrough) on already-HTML-escaped text. Bold is processed +// before italic so `**...**` doesn't get caught by the single-asterisk rule. +func applyTeamsInline(line string) string { + // Inline code first — protects its contents from other transforms. + line = inlineCodeRe.ReplaceAllString(line, "$1") + line = boldRe.ReplaceAllString(line, "$1") + line = strikethroughRe.ReplaceAllString(line, "$1") + line = italicRe.ReplaceAllString(line, "$1") + line = linkRe.ReplaceAllString(line, `$1`) + return line +} + +// TeamsHTMLToPlain extracts plain text from a Teams chatMessage HTML body. +// +// Strategy: replace block-level tags with newlines, replace tags with +// their visible text, strip all remaining tags, decode entities, collapse +// runs of whitespace. The result is suitable for sending to the LLM as the +// user's prompt. +func TeamsHTMLToPlain(s string) string { + // Preserve @mention display text. + s = atTagRe.ReplaceAllString(s, "@$1") + + // Block-level boundaries become newlines. + s = blockOpenRe.ReplaceAllString(s, "\n") + s = blockCloseRe.ReplaceAllString(s, "\n") + s = brRe.ReplaceAllString(s, "\n") + + // Strip remaining tags. + s = anyTagRe.ReplaceAllString(s, "") + + // Decode entities. + s = html.UnescapeString(s) + + // Collapse internal runs of whitespace to a single space; preserve newlines. + var b strings.Builder + for _, line := range strings.Split(s, "\n") { + trimmed := strings.TrimSpace(wsRunRe.ReplaceAllString(line, " ")) + if trimmed == "" { + continue + } + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(trimmed) + } + return b.String() +} + +// SplitMessageTeams splits text into chunks that each fit within the Teams +// body limit. Prefers paragraph boundaries, then newlines, then hard splits. +// Mirrors the existing SplitMessage helper but uses the Teams threshold. +func SplitMessageTeams(text string) []string { + return SplitMessage(text, teamsBodyLimit) +} + +// ExtractMention reports whether userID is @-mentioned in the message body. +// It checks the parsed mentions[] array first (authoritative), then falls +// back to scanning the body for tags whose corresponding +// mentions entry resolves to userID. +func ExtractMention(body string, mentions []TeamsMention, userID string) bool { + if userID == "" { + return false + } + for _, m := range mentions { + if m.Mentioned.User.ID == userID { + return true + } + } + // Belt-and-braces: an tag whose N corresponds to a mention + // entry for our user. This catches edge cases where the body contains + // the tag but the mentions[] array was elided or malformed. + for _, m := range mentions { + if m.Mentioned.User.ID != userID { + continue + } + needle := `]*>([^<]*)`) + blockOpenRe = regexp.MustCompile(`(?i)<(p|div|li|h[1-6]|blockquote|pre)[^>]*>`) + blockCloseRe = regexp.MustCompile(`(?i)`) + brRe = regexp.MustCompile(`(?i)`) + anyTagRe = regexp.MustCompile(`<[^>]+>`) + wsRunRe = regexp.MustCompile(`[ \t]+`) +) + +// itoa is a fast int→string for small positive ints. Avoids strconv import +// noise in this file; the markdown package keeps its zero-dep footprint. +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/forge-plugins/channels/markdown/teams_test.go b/forge-plugins/channels/markdown/teams_test.go new file mode 100644 index 0000000..186ce3b --- /dev/null +++ b/forge-plugins/channels/markdown/teams_test.go @@ -0,0 +1,170 @@ +package markdown + +import ( + "strings" + "testing" +) + +func TestMarkdownToTeamsHTML_Bold(t *testing.T) { + got := MarkdownToTeamsHTML("**hello** world") + want := "hello world" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMarkdownToTeamsHTML_Italic(t *testing.T) { + got := MarkdownToTeamsHTML("be *brave*") + if !strings.Contains(got, "brave") { + t.Errorf("expected brave, got %q", got) + } +} + +func TestMarkdownToTeamsHTML_InlineCode(t *testing.T) { + got := MarkdownToTeamsHTML("call `foo()` here") + if !strings.Contains(got, "foo()") { + t.Errorf("expected foo(), got %q", got) + } +} + +func TestMarkdownToTeamsHTML_FencedCode(t *testing.T) { + got := MarkdownToTeamsHTML("```go\nfmt.Println(\"hi\")\n```") + if !strings.Contains(got, `
      `) {
      +		t.Errorf("expected pre+code with language-go, got %q", got)
      +	}
      +	if !strings.Contains(got, "fmt.Println(") {
      +		t.Errorf("expected escaped code body, got %q", got)
      +	}
      +}
      +
      +func TestMarkdownToTeamsHTML_Heading(t *testing.T) {
      +	got := MarkdownToTeamsHTML("# Title")
      +	want := "

      Title

      " + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMarkdownToTeamsHTML_UnorderedList(t *testing.T) { + got := MarkdownToTeamsHTML("- a\n- b\n- c") + if !strings.Contains(got, "
      • a
      • b
      • c
      ") { + t.Errorf("ul not formed: %q", got) + } +} + +func TestMarkdownToTeamsHTML_OrderedList(t *testing.T) { + got := MarkdownToTeamsHTML("1. one\n2. two") + if !strings.Contains(got, "
      1. one
      2. two
      ") { + t.Errorf("ol not formed: %q", got) + } +} + +func TestMarkdownToTeamsHTML_Link(t *testing.T) { + got := MarkdownToTeamsHTML("see [docs](https://x.example/y)") + if !strings.Contains(got, `docs`) { + t.Errorf("link not formed: %q", got) + } +} + +func TestMarkdownToTeamsHTML_Blockquote(t *testing.T) { + got := MarkdownToTeamsHTML("> quoted") + want := "
      quoted
      " + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMarkdownToTeamsHTML_HTMLEscape(t *testing.T) { + got := MarkdownToTeamsHTML("a