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/cmd/channel_msteams_login.go b/forge-cli/cmd/channel_msteams_login.go new file mode 100644 index 0000000..b339daa --- /dev/null +++ b/forge-cli/cmd/channel_msteams_login.go @@ -0,0 +1,222 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/initializ/forge/forge-cli/internal/devicecode" +) + +// channelMsteamsLoginCmd runs the OAuth 2.0 device-code flow against Entra ID +// and prints a refresh token the operator can paste into MSTEAMS_REFRESH_TOKEN. +// +// The device-code flow has two halves: a user-consent half (visit URL, enter +// code, sign in) and a client-polling half (POST /token repeatedly until the +// user finishes). This command runs both halves so the operator only has to +// do the visible part. +var channelMsteamsLoginCmd = &cobra.Command{ + Use: "msteams-login", + Short: "Capture an MS Teams refresh token via the OAuth 2.0 device-code flow", + Long: `Run the OAuth 2.0 device-code flow against Entra ID to capture a refresh +token for the MS Teams channel adapter. + +This command: + 1. Calls /devicecode to obtain a user_code and verification URL + 2. Prints both so you can visit the URL and approve in your browser + 3. Polls /token until you complete the consent + 4. Prints the resulting refresh_token to stdout + +Defaults read MSTEAMS_TENANT_ID and MSTEAMS_CLIENT_ID from .env (or the +shell environment) so this works inside an agent project root with no +arguments. Override with --tenant-id and --client-id when needed. + +The forge init TUI runs this exact flow inline once you reach the MS Teams +refresh-token step — you do not normally need to run this command yourself.`, + RunE: runChannelMsteamsLogin, +} + +var ( + msteamsLoginTenantID string + msteamsLoginClientID string + msteamsLoginClientSecret string + msteamsLoginLoginBase string + msteamsLoginTimeoutSecs int + msteamsLoginWriteEnv bool +) + +func init() { + channelMsteamsLoginCmd.Flags().StringVar(&msteamsLoginTenantID, "tenant-id", "", + "Entra tenant ID (defaults to $MSTEAMS_TENANT_ID or the value in .env)") + channelMsteamsLoginCmd.Flags().StringVar(&msteamsLoginClientID, "client-id", "", + "Entra app client ID (defaults to $MSTEAMS_CLIENT_ID or the value in .env)") + channelMsteamsLoginCmd.Flags().StringVar(&msteamsLoginClientSecret, "client-secret", "", + "Entra app client secret (defaults to $MSTEAMS_CLIENT_SECRET or the value in .env). "+ + "Required for confidential-client (web) app registrations; public-client (native) apps may leave it empty.") + channelMsteamsLoginCmd.Flags().StringVar(&msteamsLoginLoginBase, "login-base", devicecode.DefaultLoginBase, + "OAuth2 authority base URL (override for sovereign clouds: login.microsoftonline.us / login.chinacloudapi.cn)") + channelMsteamsLoginCmd.Flags().IntVar(&msteamsLoginTimeoutSecs, "timeout-seconds", 900, + "Maximum time to wait for the user to complete consent (default 900 / 15 minutes)") + channelMsteamsLoginCmd.Flags().BoolVar(&msteamsLoginWriteEnv, "write-env", false, + "Append MSTEAMS_REFRESH_TOKEN= to .env in the current directory (instead of printing to stdout)") + channelCmd.AddCommand(channelMsteamsLoginCmd) +} + +func runChannelMsteamsLogin(cmd *cobra.Command, args []string) error { + tenant := strings.TrimSpace(msteamsLoginTenantID) + client := strings.TrimSpace(msteamsLoginClientID) + secret := strings.TrimSpace(msteamsLoginClientSecret) + + envFromFile := readEnvFile(".env") + if tenant == "" { + tenant = strings.TrimSpace(firstNonEmpty(os.Getenv("MSTEAMS_TENANT_ID"), envFromFile["MSTEAMS_TENANT_ID"])) + } + if client == "" { + client = strings.TrimSpace(firstNonEmpty(os.Getenv("MSTEAMS_CLIENT_ID"), envFromFile["MSTEAMS_CLIENT_ID"])) + } + if secret == "" { + // Optional — public-client apps don't have one. We still try to + // pick it up from the environment so confidential-client setups + // work without an explicit flag. + secret = strings.TrimSpace(firstNonEmpty(os.Getenv("MSTEAMS_CLIENT_SECRET"), envFromFile["MSTEAMS_CLIENT_SECRET"])) + } + + if tenant == "" { + return errors.New("tenant-id is required: pass --tenant-id or set MSTEAMS_TENANT_ID in .env") + } + if client == "" { + return errors.New("client-id is required: pass --client-id or set MSTEAMS_CLIENT_ID in .env") + } + + ctx, cancel := context.WithTimeout(cmd.Context(), time.Duration(msteamsLoginTimeoutSecs)*time.Second) + defer cancel() + + httpClient := &http.Client{Timeout: 30 * time.Second} + + dc, err := devicecode.RequestDeviceCode(ctx, httpClient, msteamsLoginLoginBase, tenant, client) + if err != nil { + return err + } + + // User-facing instructions. Stderr so stdout can stay clean for piping. + stderr := cmd.ErrOrStderr() + writeln := func(s string) { _, _ = io.WriteString(stderr, s+"\n") } + writef := func(format string, a ...any) { _, _ = fmt.Fprintf(stderr, format, a...) } + + writeln("") + writeln("───────────────────────────────────────────────────────────") + writeln(" To finish signing in, open this URL in a browser:") + writef(" %s\n", dc.VerificationURI) + writeln("") + writeln(" Then enter this one-time code:") + writef(" %s\n", dc.UserCode) + writeln("") + writef(" Code expires in %ds. Polling /token every %ds...\n", dc.ExpiresIn, dc.Interval) + writeln("───────────────────────────────────────────────────────────") + writeln("") + + // Best-effort browser launch — failures are silently ignored because + // the URL is already printed above for manual paste. + _ = devicecode.OpenURL(dc.VerificationURI) + + tok, err := devicecode.PollDeviceToken(ctx, httpClient, msteamsLoginLoginBase, tenant, client, secret, dc) + if err != nil { + return err + } + if tok.RefreshToken == "" { + return errors.New("token endpoint returned no refresh_token — did the scope include offline_access?") + } + + if msteamsLoginWriteEnv { + if err := appendOrReplaceEnv(".env", "MSTEAMS_REFRESH_TOKEN", tok.RefreshToken); err != nil { + return fmt.Errorf("writing .env: %w", err) + } + _, _ = io.WriteString(cmd.ErrOrStderr(), "✓ MSTEAMS_REFRESH_TOKEN written to .env\n") + return nil + } + + // Default: print the token so the operator can paste it. Newline only — + // no labels — so the output is pipe-friendly. + _, _ = io.WriteString(cmd.OutOrStdout(), tok.RefreshToken+"\n") + return nil +} + +// --- .env helpers (deliberately tiny — full .env parsing isn't in scope) --- + +// readEnvFile returns a key→value map of simple KEY=VALUE lines in path. +// Lines starting with # are ignored. Surrounding quotes are stripped. Returns +// an empty map if the file is absent. +func readEnvFile(path string) map[string]string { + out := map[string]string{} + data, err := os.ReadFile(path) + if err != nil { + return out + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + eq := strings.IndexByte(line, '=') + if eq < 0 { + continue + } + key := strings.TrimSpace(line[:eq]) + val := strings.TrimSpace(line[eq+1:]) + val = strings.Trim(val, `"'`) + if key != "" { + out[key] = val + } + } + return out +} + +// appendOrReplaceEnv updates the given key in path's .env-style file. If the +// key already exists (anywhere in the file), the line is replaced; otherwise +// a new line is appended. Creates the file if missing. +func appendOrReplaceEnv(path, key, value string) error { + abs, err := filepath.Abs(path) + if err != nil { + return err + } + existing, _ := os.ReadFile(abs) + lines := strings.Split(string(existing), "\n") + + newLine := key + "=" + value + replaced := false + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, key+"=") { + lines[i] = newLine + replaced = true + break + } + } + if !replaced { + if len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" { + lines[len(lines)-1] = newLine + lines = append(lines, "") + } else { + lines = append(lines, newLine) + } + } + out := strings.Join(lines, "\n") + return os.WriteFile(abs, []byte(out), 0o600) +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} diff --git a/forge-cli/cmd/init.go b/forge-cli/cmd/init.go index 0f4a4c3..480c971 100644 --- a/forge-cli/cmd/init.go +++ b/forge-cli/cmd/init.go @@ -1100,6 +1100,35 @@ func buildEnvVars(opts *initOptions) []envVarEntry { vars = append(vars, envVarEntry{Key: "SLACK_APP_TOKEN", Value: appVal, Comment: "Slack app-level token (xapp-...)"}) botVal := opts.EnvVars["SLACK_BOT_TOKEN"] vars = append(vars, envVarEntry{Key: "SLACK_BOT_TOKEN", Value: botVal, Comment: "Slack bot token (xoxb-...)"}) + case "msteams": + // MS Teams uses Entra delegated OAuth2. All five env vars flow + // from the init TUI's channel step into opts.EnvVars; the + // device-code flow (TUI step 4) populates MSTEAMS_REFRESH_TOKEN + // when consent succeeds and leaves it empty when the user skips. + // Empty refresh-token is intentional: the operator can capture + // it later via `forge channel msteams-login --write-env`. + vars = append(vars, envVarEntry{ + Key: "MSTEAMS_TENANT_ID", Value: opts.EnvVars["MSTEAMS_TENANT_ID"], + Comment: "MS Teams: Entra tenant ID (Directory tenant) GUID", + }) + vars = append(vars, envVarEntry{ + Key: "MSTEAMS_CLIENT_ID", Value: opts.EnvVars["MSTEAMS_CLIENT_ID"], + Comment: "MS Teams: Entra app (client) ID", + }) + vars = append(vars, envVarEntry{ + Key: "MSTEAMS_CLIENT_SECRET", Value: opts.EnvVars["MSTEAMS_CLIENT_SECRET"], + Comment: "MS Teams: Entra app client secret (Value, not Secret ID)", + }) + vars = append(vars, envVarEntry{ + Key: "MSTEAMS_REFRESH_TOKEN", Value: opts.EnvVars["MSTEAMS_REFRESH_TOKEN"], + Comment: "MS Teams: OAuth refresh token captured via device-code flow", + }) + if uid := opts.EnvVars["MSTEAMS_USER_ID"]; uid != "" { + vars = append(vars, envVarEntry{ + Key: "MSTEAMS_USER_ID", Value: uid, + Comment: "MS Teams: agent user objectId (required for client_credentials flow only)", + }) + } } } diff --git a/forge-cli/internal/devicecode/devicecode.go b/forge-cli/internal/devicecode/devicecode.go new file mode 100644 index 0000000..3e77617 --- /dev/null +++ b/forge-cli/internal/devicecode/devicecode.go @@ -0,0 +1,256 @@ +// Package devicecode implements the OAuth 2.0 device-authorization grant +// (RFC 8628) against Microsoft Entra ID. The helpers here are shared between +// the standalone `forge channel msteams-login` CLI subcommand and the +// MS Teams branch of the `forge init` TUI wizard, so both flows produce +// identical refresh tokens and reuse the same polling / error semantics. +// +// The flow has two halves: +// 1. RequestDeviceCode — POST /devicecode → user_code + verification_uri +// 2. PollDeviceToken — POST /token repeatedly until the user completes +// the consent step in their browser +// +// Both halves are network calls; both honour the caller's context for +// cancellation and timeout. OpenURL is a best-effort cross-platform +// browser launcher with the same shape as forge-core/llm/oauth.openBrowser. +package devicecode + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os/exec" + "runtime" + "strings" + "time" +) + +// DefaultLoginBase is the OAuth 2.0 authority for Microsoft Entra ID's +// commercial cloud. Sovereign clouds override via the LoginBase argument. +const DefaultLoginBase = "https://login.microsoftonline.com" + +// DefaultScope is the .default scope marker plus offline_access — the same +// scope set the runtime authManager (forge-plugins/channels/msteams/auth.go) +// requests, so refresh tokens captured by this package are interchangeable +// with ones captured externally. +const DefaultScope = "https://graph.microsoft.com/.default offline_access" + +// DeviceCodeResponse is the trimmed Microsoft response to POST /devicecode. +type DeviceCodeResponse struct { + UserCode string `json:"user_code"` + DeviceCode string `json:"device_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` + Message string `json:"message,omitempty"` +} + +// TokenResponse is the trimmed token endpoint payload. +type TokenResponse struct { + AccessToken string `json:"access_token,omitempty"` + TokenType string `json:"token_type,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope,omitempty"` + Error string `json:"error,omitempty"` + ErrorDesc string `json:"error_description,omitempty"` +} + +// RequestDeviceCode initiates the device-authorization grant. Returns the +// user_code + verification_uri pair the operator must visit in a browser, +// plus the opaque device_code the caller passes to PollDeviceToken. +func RequestDeviceCode(ctx context.Context, client *http.Client, loginBase, tenant, clientID string) (*DeviceCodeResponse, error) { + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + if loginBase == "" { + loginBase = DefaultLoginBase + } + + form := url.Values{} + form.Set("client_id", clientID) + form.Set("scope", DefaultScope) + + endpoint := fmt.Sprintf("%s/%s/oauth2/v2.0/devicecode", loginBase, tenant) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("devicecode: build request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("devicecode: request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("devicecode: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var dc DeviceCodeResponse + if err := json.Unmarshal(body, &dc); err != nil { + return nil, fmt.Errorf("devicecode: parse response: %w", err) + } + if dc.UserCode == "" || dc.DeviceCode == "" || dc.VerificationURI == "" { + return nil, fmt.Errorf("devicecode: response missing required fields: %s", string(body)) + } + if dc.Interval < 1 { + dc.Interval = 5 + } + return &dc, nil +} + +// PollDeviceToken polls the token endpoint until the user completes consent, +// the device code expires, or the context is cancelled. Honours the +// server-advertised interval and the slow_down rate-limit response per +// RFC 8628 §3.5. +// +// clientSecret is optional: pass "" for public-client apps (native/mobile +// registration) and the secret value for confidential-client apps (web +// registration). Entra returns AADSTS7000218 if a confidential client +// omits its secret here, so when in doubt, supply it — public clients +// silently ignore the extra parameter. +func PollDeviceToken(ctx context.Context, client *http.Client, loginBase, tenant, clientID, clientSecret string, dc *DeviceCodeResponse) (*TokenResponse, error) { + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + if loginBase == "" { + loginBase = DefaultLoginBase + } + endpoint := fmt.Sprintf("%s/%s/oauth2/v2.0/token", loginBase, tenant) + interval := time.Duration(dc.Interval) * time.Second + + // sendSecret starts true (confidential-client behaviour) and flips to + // false on AADSTS700025 — Entra's signal that the app is registered as + // a public client and rejects client credentials. After the flip we + // keep polling without the secret in the same flow. + sendSecret := true + + for { + // Wait first — Microsoft rejects the very first poll as + // authorization_pending anyway, and the spec requires waiting at + // least `interval` seconds between attempts. + select { + case <-ctx.Done(): + return nil, fmt.Errorf("device-code flow timed out before user completed consent: %w", ctx.Err()) + case <-time.After(interval): + } + + form := url.Values{} + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + form.Set("client_id", clientID) + form.Set("device_code", dc.DeviceCode) + // sendSecret toggles to false after the first AADSTS700025 from + // Entra, which means the app is a public-client registration that + // rejects client_secret. Subsequent polls in the same call omit + // the secret. + if clientSecret != "" && sendSecret { + form.Set("client_secret", clientSecret) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("token: build request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("token: request: %w", err) + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + _ = resp.Body.Close() + + var tr TokenResponse + if jerr := json.Unmarshal(body, &tr); jerr != nil { + return nil, fmt.Errorf("token: parse response (status=%d): %w", resp.StatusCode, jerr) + } + + switch tr.Error { + case "": + if tr.AccessToken == "" { + return nil, fmt.Errorf("token: response missing access_token: %s", string(body)) + } + return &tr, nil + case "authorization_pending": + continue + case "slow_down": + interval += 5 * time.Second + continue + case "expired_token": + return nil, errors.New("device code expired before user completed consent — restart to get a fresh code") + case "access_denied": + return nil, errors.New("user declined the consent prompt") + default: + msg := tr.ErrorDesc + if msg == "" { + msg = tr.Error + } + // AADSTS700025: Entra says the app is registered as public — + // stop sending the secret and immediately retry on the same + // device code (it's still valid for ~15 minutes). + if sendSecret && clientSecret != "" && strings.Contains(msg, "AADSTS700025") { + sendSecret = false + continue + } + // Diagnostic: when Entra rejects for missing credentials, + // surface whether we sent a client_secret. This catches both + // "secret was empty in our form" and "app is confidential but + // secret was wrong/expired" cases. Newline-prefixed so it + // can't be lost to terminal wrapping of the AADSTS message. + diag := "" + if strings.Contains(msg, "AADSTS7000218") || strings.Contains(msg, "client_assertion") { + if clientSecret == "" { + diag = "\n>> client_secret was NOT sent. Your Entra app is a confidential client; provide MSTEAMS_CLIENT_SECRET." + } else { + diag = "\n>> client_secret WAS sent (len=" + lenStr(clientSecret) + ") but Entra still rejected it." + + "\n>> Most common cause: 'Allow public client flows' is OFF in your Entra app." + + "\n>> Fix: Entra portal → App registrations → your app → Authentication →" + + "\n>> Advanced settings → 'Allow public client flows' = Yes → Save." + + "\n>> Less common: the secret VALUE (not the Secret ID) is wrong or expired." + + "\n>> Fix: Entra portal → Certificates & secrets → + New client secret → copy the Value column." + } + } + return nil, fmt.Errorf("token endpoint error: %s%s", msg, diag) + } + } +} + +// lenStr formats an int as a string without bringing in strconv. +func lenStr(s string) string { + n := len(s) + if n == 0 { + return "0" + } + var buf [16]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} + +// OpenURL launches the host's default browser pointed at u. Best-effort — +// failures (no display, no opener, sandboxed env) are returned so the +// caller can fall back to printing the URL for manual paste. +func OpenURL(u string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", u) + case "linux": + cmd = exec.Command("xdg-open", u) + case "windows": + cmd = exec.Command("cmd", "/c", "start", u) + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + return cmd.Start() +} diff --git a/forge-cli/internal/tui/steps/channel_step.go b/forge-cli/internal/tui/steps/channel_step.go index 6a06293..0fdda06 100644 --- a/forge-cli/internal/tui/steps/channel_step.go +++ b/forge-cli/internal/tui/steps/channel_step.go @@ -1,10 +1,14 @@ package steps import ( + "context" "fmt" + "net/http" + "time" tea "github.com/charmbracelet/bubbletea" + "github.com/initializ/forge/forge-cli/internal/devicecode" "github.com/initializ/forge/forge-cli/internal/tui" "github.com/initializ/forge/forge-cli/internal/tui/components" ) @@ -15,9 +19,32 @@ const ( channelSelectPhase channelPhase = iota channelTokenPhase channelSlackBotTokenPhase + channelMsteamsClientIDPhase + channelMsteamsClientSecretPhase + channelMsteamsDeviceLoginPhase channelDonePhase ) +// msteams device-login sub-states inside channelMsteamsDeviceLoginPhase. +type msteamsLoginStatus int + +const ( + msteamsLoginRequesting msteamsLoginStatus = iota // POST /devicecode in flight + msteamsLoginWaiting // showing URL + code, polling /token + msteamsLoginErr // last attempt failed; show retry/skip +) + +// Tea messages produced by the device-code goroutines. They flow through the +// wizard's main loop and are routed back to ChannelStep.Update. +type msteamsDeviceCodeReadyMsg struct { + dc *devicecode.DeviceCodeResponse + err error +} +type msteamsRefreshTokenReadyMsg struct { + token string + err error +} + // ChannelStep handles channel connector selection. type ChannelStep struct { styles *tui.StyleSet @@ -25,8 +52,14 @@ type ChannelStep struct { selector components.SingleSelect keyInput components.SecretInput complete bool - channel string - tokens map[string]string + + // msteams device-login state. Populated when the user reaches the + // device-login phase via steps 1-3 (tenant / client / secret). + loginStatus msteamsLoginStatus + loginDevice *devicecode.DeviceCodeResponse + loginErr string + channel string + tokens map[string]string } // NewChannelStep creates a new channel step. @@ -35,6 +68,7 @@ func NewChannelStep(styles *tui.StyleSet) *ChannelStep { {Label: "None", Value: "none", Description: "CLI / API only", Icon: "🚫"}, {Label: "Telegram", Value: "telegram", Description: "Easy setup, no public URL needed", Icon: "✈️"}, {Label: "Slack", Value: "slack", Description: "Socket Mode, no public URL needed", Icon: "💬"}, + {Label: "MS Teams", Value: "msteams", Description: "Graph polling, no public URL needed", Icon: "👥"}, } selector := components.NewSingleSelect( @@ -82,6 +116,16 @@ func (s *ChannelStep) Update(msg tea.Msg) (tui.Step, tea.Cmd) { return s.updateTokenPhase(msg) case channelSlackBotTokenPhase: return s.updateSlackBotTokenPhase(msg) + case channelMsteamsClientIDPhase: + return s.updateMsteamsPhase(msg, "MSTEAMS_CLIENT_ID", + "MS Teams Client Secret (from Entra app)", channelMsteamsClientSecretPhase) + case channelMsteamsClientSecretPhase: + // Last secret-input step. After capturing client_secret, transition + // into the inline device-login phase which runs the OAuth flow + // itself instead of asking the operator to paste a refresh token. + return s.updateMsteamsClientSecretPhase(msg) + case channelMsteamsDeviceLoginPhase: + return s.updateMsteamsDeviceLoginPhase(msg) } return s, nil @@ -135,12 +179,39 @@ func (s *ChannelStep) updateSelectPhase(msg tea.Msg) (tui.Step, tea.Cmd) { s.styles.KbdDesc, ) return s, s.keyInput.Init() + case "msteams": + // 4-step flow: tenant_id → client_id → client_secret → refresh_token. + // The refresh token is captured externally via the device-code flow + // documented in printSetupInstructions (forge-cli/cmd/channel.go). + s.phase = channelTokenPhase + s.keyInput = s.newMsteamsInput("MS Teams Tenant ID (GUID from Entra)") + return s, s.keyInput.Init() } } return s, cmd } +// newMsteamsInput builds a SecretInput with the standard theme bindings used +// by the rest of this step. Centralised so the 4 msteams phases stay terse. +func (s *ChannelStep) newMsteamsInput(label string) components.SecretInput { + return components.NewSecretInput( + label, + true, true, + s.styles.Theme.Accent, + s.styles.Theme.Success, + s.styles.Theme.Error, + s.styles.Theme.Border, + s.styles.AccentTxt, + s.styles.InactiveBorder, + s.styles.SuccessTxt, + s.styles.ErrorTxt, + s.styles.DimTxt, + s.styles.KbdKey, + s.styles.KbdDesc, + ) +} + func (s *ChannelStep) updateTokenPhase(msg tea.Msg) (tui.Step, tea.Cmd) { updated, cmd := s.keyInput.Update(msg) s.keyInput = updated @@ -177,12 +248,162 @@ func (s *ChannelStep) updateTokenPhase(msg tea.Msg) (tui.Step, tea.Cmd) { s.styles.KbdDesc, ) return s, s.keyInput.Init() + case "msteams": + // First msteams field captured: MSTEAMS_TENANT_ID. + // Hand off to the 3-stage chain via the shared helper. + if val != "" { + s.tokens["MSTEAMS_TENANT_ID"] = val + } + s.phase = channelMsteamsClientIDPhase + s.keyInput = s.newMsteamsInput("MS Teams Client ID (GUID from Entra app)") + return s, s.keyInput.Init() } } return s, cmd } +// updateMsteamsPhase is the shared advance logic for the intermediate msteams +// secret-input chain (tenant_id → client_id → client_secret). On Done, stores +// the just-collected value under storeKey and moves to nextPhase with the +// prompt nextPrompt. Only valid for the three intermediate phases — the +// terminal device-login phase uses updateMsteamsDeviceLoginPhase instead. +func (s *ChannelStep) updateMsteamsPhase(msg tea.Msg, storeKey, nextPrompt string, nextPhase channelPhase) (tui.Step, tea.Cmd) { + updated, cmd := s.keyInput.Update(msg) + s.keyInput = updated + + if !s.keyInput.Done() { + return s, cmd + } + + if val := s.keyInput.Value(); val != "" { + s.tokens[storeKey] = val + } + + s.phase = nextPhase + s.keyInput = s.newMsteamsInput(nextPrompt) + return s, s.keyInput.Init() +} + +// updateMsteamsClientSecretPhase captures MSTEAMS_CLIENT_SECRET and then +// transitions into the inline device-login phase. Unlike the earlier secret +// inputs, this one does NOT show another text-input prompt afterward — +// instead it kicks off the OAuth device-code flow directly. +func (s *ChannelStep) updateMsteamsClientSecretPhase(msg tea.Msg) (tui.Step, tea.Cmd) { + updated, cmd := s.keyInput.Update(msg) + s.keyInput = updated + + if !s.keyInput.Done() { + return s, cmd + } + + if val := s.keyInput.Value(); val != "" { + s.tokens["MSTEAMS_CLIENT_SECRET"] = val + } + + // Enter the device-login phase. We immediately request a device code. + s.phase = channelMsteamsDeviceLoginPhase + s.loginStatus = msteamsLoginRequesting + s.loginErr = "" + return s, s.requestDeviceCodeCmd() +} + +// updateMsteamsDeviceLoginPhase is the state machine for the inline OAuth +// device-code flow. It handles the two async events (device code received, +// refresh token received) plus the retry/skip key presses available in +// the error state. +func (s *ChannelStep) updateMsteamsDeviceLoginPhase(msg tea.Msg) (tui.Step, tea.Cmd) { + switch m := msg.(type) { + case msteamsDeviceCodeReadyMsg: + if m.err != nil { + s.loginStatus = msteamsLoginErr + s.loginErr = m.err.Error() + return s, nil + } + s.loginDevice = m.dc + s.loginStatus = msteamsLoginWaiting + // Best-effort browser open. Failures are silently ignored — the + // verification URL is also rendered in the View so the operator + // can navigate manually if the open fails. + _ = devicecode.OpenURL(m.dc.VerificationURI) + return s, s.pollTokenCmd(m.dc) + + case msteamsRefreshTokenReadyMsg: + if m.err != nil { + s.loginStatus = msteamsLoginErr + s.loginErr = m.err.Error() + return s, nil + } + if m.token != "" { + s.tokens["MSTEAMS_REFRESH_TOKEN"] = m.token + } + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + + case tea.KeyMsg: + if s.loginStatus != msteamsLoginErr { + return s, nil + } + switch m.String() { + case "r", "R": + // Retry — request a fresh device code. + s.loginStatus = msteamsLoginRequesting + s.loginErr = "" + return s, s.requestDeviceCodeCmd() + case "s", "S": + // Skip — finish the wizard with no refresh token. The agent + // can still be started later after the operator captures the + // token out-of-band with `forge channel msteams-login --write-env`. + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + } + } + + return s, nil +} + +// requestDeviceCodeCmd kicks off RequestDeviceCode against the tenant/client +// the operator just provided. Returns a tea.Cmd that produces a +// msteamsDeviceCodeReadyMsg on completion. +func (s *ChannelStep) requestDeviceCodeCmd() tea.Cmd { + tenant := s.tokens["MSTEAMS_TENANT_ID"] + clientID := s.tokens["MSTEAMS_CLIENT_ID"] + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + dc, err := devicecode.RequestDeviceCode(ctx, &http.Client{Timeout: 30 * time.Second}, + devicecode.DefaultLoginBase, tenant, clientID) + return msteamsDeviceCodeReadyMsg{dc: dc, err: err} + } +} + +// pollTokenCmd kicks off PollDeviceToken. Returns a tea.Cmd that produces a +// msteamsRefreshTokenReadyMsg on completion. Uses a generous 15-minute +// timeout — matches Microsoft's device-code expiry. +// +// We pass the client_secret captured in step 3 because confidential-client +// (web) Entra app registrations return AADSTS7000218 if /token is called +// without it. Public-client (native) apps ignore the extra parameter, so +// always passing it is safe. +func (s *ChannelStep) pollTokenCmd(dc *devicecode.DeviceCodeResponse) tea.Cmd { + tenant := s.tokens["MSTEAMS_TENANT_ID"] + clientID := s.tokens["MSTEAMS_CLIENT_ID"] + clientSecret := s.tokens["MSTEAMS_CLIENT_SECRET"] + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + tok, err := devicecode.PollDeviceToken(ctx, &http.Client{Timeout: 30 * time.Second}, + devicecode.DefaultLoginBase, tenant, clientID, clientSecret, dc) + if err != nil { + return msteamsRefreshTokenReadyMsg{err: err} + } + if tok.RefreshToken == "" { + return msteamsRefreshTokenReadyMsg{err: fmt.Errorf("token endpoint returned no refresh_token — did the scope include offline_access?")} + } + return msteamsRefreshTokenReadyMsg{token: tok.RefreshToken} + } +} + func (s *ChannelStep) updateSlackBotTokenPhase(msg tea.Msg) (tui.Step, tea.Cmd) { updated, cmd := s.keyInput.Update(msg) s.keyInput = updated @@ -221,6 +442,15 @@ func (s *ChannelStep) View(width int) string { s.styles.DimTxt.Render("3. Basic Information → App-Level Tokens → Generate"), s.styles.DimTxt.Render(" → add scope: connections:write → copy the xapp-... token"), ) + case "msteams": + instructions = fmt.Sprintf(" %s\n %s\n %s\n %s\n %s\n %s\n\n", + s.styles.SecondaryTxt.Render("MS Teams Setup (Step 1/4 — Tenant ID):"), + s.styles.DimTxt.Render("1. Register an Entra ID app at https://entra.microsoft.com"), + s.styles.DimTxt.Render("2. Add delegated API permissions: Chat.Read, Chat.ReadWrite, User.Read"), + s.styles.DimTxt.Render("3. Grant admin consent if your tenant requires it"), + s.styles.DimTxt.Render("4. Overview tab → copy the Directory (tenant) ID — paste below"), + s.styles.DimTxt.Render(" (outbound polling only — no public URL required)"), + ) } return instructions + s.keyInput.View(width) case channelSlackBotTokenPhase: @@ -235,6 +465,61 @@ func (s *ChannelStep) View(width int) string { s.styles.DimTxt.Render(" → copy the xoxb-... Bot User OAuth Token"), ) return botInstructions + s.keyInput.View(width) + case channelMsteamsClientIDPhase: + ins := fmt.Sprintf(" %s\n %s\n %s\n\n", + s.styles.SecondaryTxt.Render("MS Teams Setup (Step 2/4 — Client ID):"), + s.styles.DimTxt.Render("Entra app → Overview → Application (client) ID."), + s.styles.DimTxt.Render("Same page as the tenant ID; paste it below."), + ) + return ins + s.keyInput.View(width) + case channelMsteamsClientSecretPhase: + ins := fmt.Sprintf(" %s\n %s\n %s\n %s\n\n", + s.styles.SecondaryTxt.Render("MS Teams Setup (Step 3/4 — Client Secret):"), + s.styles.DimTxt.Render("Entra app → Certificates & secrets → New client secret."), + s.styles.DimTxt.Render("Copy the Value (not the Secret ID) immediately —"), + s.styles.DimTxt.Render("Entra only shows it once."), + ) + return ins + s.keyInput.View(width) + case channelMsteamsDeviceLoginPhase: + return s.viewMsteamsDeviceLogin() + } + return "" +} + +// viewMsteamsDeviceLogin renders the three sub-states of the inline OAuth +// device-code flow. No SecretInput is shown — the operator's only action +// is to complete sign-in in the browser (the URL is already open). +func (s *ChannelStep) viewMsteamsDeviceLogin() string { + switch s.loginStatus { + case msteamsLoginRequesting: + return fmt.Sprintf(" %s\n %s\n\n", + s.styles.SecondaryTxt.Render("MS Teams Setup (Step 4/4 — Sign in to Microsoft):"), + s.styles.AccentTxt.Render("⣾ Requesting a one-time code from Microsoft..."), + ) + + case msteamsLoginWaiting: + dc := s.loginDevice + return fmt.Sprintf(" %s\n\n %s\n %s\n\n %s\n %s\n\n %s\n %s\n %s\n\n %s\n %s\n\n", + s.styles.SecondaryTxt.Render("MS Teams Setup (Step 4/4 — Sign in to Microsoft):"), + s.styles.DimTxt.Render("Your browser should have just opened. If not, go to:"), + s.styles.AccentTxt.Render(" "+dc.VerificationURI), + s.styles.DimTxt.Render("Enter this one-time code when prompted:"), + s.styles.AccentTxt.Render(" "+dc.UserCode), + s.styles.ErrorTxt.Render("IMPORTANT: sign in as the dedicated Microsoft 365 account"), + s.styles.ErrorTxt.Render("you want the agent to ACT AS (e.g. forge-agent@yourtenant)."), + s.styles.DimTxt.Render("Other Teams users will @-mention that account to invoke the agent."), + s.styles.DimTxt.Render("⣾ Waiting for you to complete sign-in..."), + s.styles.DimTxt.Render("(This page will advance automatically once you approve.)"), + ) + + case msteamsLoginErr: + return fmt.Sprintf(" %s\n\n %s\n %s\n\n %s\n %s\n", + s.styles.SecondaryTxt.Render("MS Teams Setup (Step 4/4 — Sign in to Microsoft):"), + s.styles.ErrorTxt.Render("✗ Device-code flow failed:"), + s.styles.DimTxt.Render(" "+s.loginErr), + s.styles.DimTxt.Render("Press R to retry, or S to skip and capture the refresh token"), + s.styles.DimTxt.Render("later with `forge channel msteams-login --write-env`."), + ) } return "" } @@ -251,6 +536,8 @@ func (s *ChannelStep) Summary() string { return "Telegram" case "slack": return "Slack" + case "msteams": + return "MS Teams" } return s.channel } 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..a7c70d5 --- /dev/null +++ b/forge-cli/templates/init/msteams-config.yaml.tmpl @@ -0,0 +1,53 @@ +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: "" + + # Prepend the recent chat-history block to each dispatched message so + # the LLM has conversational context. Teams chats are thread-shaped + # (you @-mention to invoke the agent, but the surrounding messages + # are what the agent should reason about). Set to false to dispatch + # only the literal mention/DM with no surrounding history. + include_recent_history: true + + # How many recent messages to fetch when include_recent_history is on. + # Hard-capped at 50 (Graph's per-request limit on /chats/{id}/messages). + # The formatted block is also soft-capped at ~5000 chars in the code to + # protect the LLM context budget, so very long messages may be + # truncated even when this count is reached. + recent_history_count: 20 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