Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5cf55a3
feat(channels): Microsoft Teams adapter via Graph polling (closes #75)
initializ-mk May 20, 2026
5e96855
feat(channels): expose msteams in the forge init TUI picker
initializ-mk May 21, 2026
c7d4dbb
feat(channels/msteams): forge channel msteams-login device-code helper
initializ-mk May 21, 2026
bbba610
feat(channels/msteams): inline device-code flow in the init TUI (auto…
initializ-mk May 21, 2026
fd498fb
fix(channels/msteams): pass client_secret in device-code token poll
initializ-mk May 22, 2026
31b0266
fix(channels/msteams): point AADSTS7000218 diagnostic at the actual fix
initializ-mk May 22, 2026
bf15a68
fix(channels/msteams): persist MSTEAMS_* env vars via buildEnvVars
initializ-mk May 22, 2026
b657cc8
fix(channels/msteams): auto-detect public-client apps via AADSTS700025
initializ-mk May 22, 2026
2ac5be9
fix(channels/msteams): per-chat delta polling for the delegated flow
initializ-mk May 22, 2026
cd97012
fix(channels/msteams): drop $filter from chat delta; drain initial sync
initializ-mk May 22, 2026
66b26ee
fix(channels/msteams): poll /chats/{id}/messages directly (no delta)
initializ-mk May 22, 2026
be1a549
fix(channels/msteams): 403 is per-chat, not global; don't block others
initializ-mk May 22, 2026
a9966c3
fix(channels/msteams): track sent IDs in dedup; admit self-authored msgs
initializ-mk May 22, 2026
6d0f40e
diag(channels/msteams): show mentions[] when admission drops on no-me…
initializ-mk May 22, 2026
f84c856
docs(channels/msteams): warn TUI users to sign in as the agent account
initializ-mk May 22, 2026
805e216
feat(channels/msteams): prepend recent chat history to dispatched events
initializ-mk May 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 53 additions & 9 deletions forge-cli/cmd/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 <slack|telegram>",
Use: "add <slack|telegram|msteams>",
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 <slack|telegram>",
Use: "serve <slack|telegram|msteams>",
Short: "Run a standalone channel adapter (for container use)",
Args: cobra.ExactArgs(1),
ValidArgs: []string{"slack", "telegram"},
ValidArgs: []string{"slack", "telegram", "msteams"},
RunE: runChannelServe,
}

Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
222 changes: 222 additions & 0 deletions forge-cli/cmd/channel_msteams_login.go
Original file line number Diff line number Diff line change
@@ -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=<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 ""
}
29 changes: 29 additions & 0 deletions forge-cli/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
})
}
}
}

Expand Down
Loading
Loading