Skip to content

Commit c3f6f29

Browse files
committed
feat(channels): add native Microsoft Teams channel with full E2E support
Complete Teams channel implementation via Azure Bot Framework REST API: Core channel (internal/channels/teams/): - JWT auth with JWKS rotation, Bot Framework webhook handler - Outbound messaging with retry, typing indicators, rate limiting - LLM markdown → Teams markdown sanitization, message chunking (80KB) - Per-instance webhook paths for multi-bot deployments - DB instance factory for UI-created instances - serviceURL recovery from metadata (survives restart) App package generator: - CLI: goclaw teams app-package --name "Bot" -o teams-app.zip - HTTP: GET /v1/teams/app-package?name=Bot&bot_id=UUID - Web UI: download button on Teams channel detail page - Teams v1.21 manifest schema with embedded default icons Platform fixes (cross-channel): - CoerceStringBools: normalize "true"/"false"/"inherit" at save+load - Webhook path dedup prevents mux.Handle panic - Permission scope auto-normalize in config_permissions.grant RPC - Bootstrap exception for file_writer in new groups - Explicit system prompt for tool access in bootstrap mode - service_url propagated through all outbound paths Channel type registration: - "teams" added to isValidChannelType (HTTP + WS handlers) - IsDefaultChannelInstance updated with all channel types - contacts-page CHANNEL_TYPES filter updated Tests: 19 channel + 23 appmanifest + 14 HTTP handler tests Docs: architecture, channels, changelog, HTTP API updated
1 parent 7ef6d1d commit c3f6f29

56 files changed

Lines changed: 3938 additions & 51 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,10 @@ POSTGRES_PASSWORD=
1717
# Docker socket GID: 999 on Linux, 0 on Windows/macOS Docker Desktop.
1818
# DOCKER_GID=0
1919

20+
# --- Microsoft Teams (optional) ---
21+
# GOCLAW_TEAMS_BOT_ID=
22+
# GOCLAW_TEAMS_BOT_PASSWORD=
23+
# GOCLAW_TEAMS_TENANT_ID=
24+
2025
# --- Debug ---
2126
# GOCLAW_TRACE_VERBOSE=1

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,4 @@ compose.d/*
8888
/mise.*.toml
8989
/mise
9090
/.mise
91+
internal/webui/dist

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ internal/
2323
├── bootstrap/ System prompt files (SOUL.md, IDENTITY.md) + seeding + per-user seed
2424
├── bus/ Event bus system
2525
├── cache/ Caching layer
26-
├── channels/ Channel manager: Telegram, Feishu/Lark, Zalo, Discord, WhatsApp
26+
├── channels/ Channel manager: Telegram, Feishu/Lark, Zalo, Discord, WhatsApp, Teams
2727
├── config/ Config loading (JSON5) + env var overlay
2828
├── crypto/ AES-256-GCM encryption for API keys
2929
├── cron/ Cron scheduling (at/every/cron expr)

cmd/gateway.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/nextlevelbuilder/goclaw/internal/channels/discord"
2222
"github.com/nextlevelbuilder/goclaw/internal/channels/feishu"
2323
slackchannel "github.com/nextlevelbuilder/goclaw/internal/channels/slack"
24+
teamschannel "github.com/nextlevelbuilder/goclaw/internal/channels/teams"
2425
"github.com/nextlevelbuilder/goclaw/internal/channels/telegram"
2526
"github.com/nextlevelbuilder/goclaw/internal/channels/whatsapp"
2627
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo"
@@ -386,6 +387,7 @@ func runGateway() {
386387
}
387388
if channelInstancesH != nil {
388389
server.SetChannelInstancesHandler(channelInstancesH)
390+
server.SetTeamsAppPackageHandler(httpapi.NewTeamsAppPackageHandler(pgStores.ChannelInstances))
389391
}
390392
if providersH != nil {
391393
server.SetProvidersHandler(providersH)
@@ -565,13 +567,16 @@ func runGateway() {
565567
instanceLoader.RegisterFactory(channels.TypeZaloPersonal, zalopersonal.FactoryWithPendingStore(pgStores.PendingMessages))
566568
instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.FactoryWithDB(pgStores.DB, pgStores.PendingMessages, "pgx"))
567569
instanceLoader.RegisterFactory(channels.TypeSlack, slackchannel.FactoryWithPendingStore(pgStores.PendingMessages))
570+
instanceLoader.RegisterFactory(channels.TypeTeams, teamschannel.Factory)
568571
if err := instanceLoader.LoadAll(context.Background()); err != nil {
569572
slog.Error("failed to load channel instances from DB", "error", err)
570573
}
571574
}
572575

573576
// Register config-based channels as fallback when no DB instances loaded.
574577
registerConfigChannels(cfg, channelMgr, msgBus, pgStores, instanceLoader)
578+
// Teams also loads from config (in addition to DB instances) for backward compatibility.
579+
registerTeamsChannel(cfg, channelMgr, msgBus)
575580

576581
// Register channels/instances/links/teams RPC methods
577582
wireChannelRPCMethods(server, pgStores, channelMgr, agentRouter, msgBus, workspace)

cmd/gateway_channels_setup.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/nextlevelbuilder/goclaw/internal/channels/discord"
1515
"github.com/nextlevelbuilder/goclaw/internal/channels/feishu"
1616
slackchannel "github.com/nextlevelbuilder/goclaw/internal/channels/slack"
17+
teamschannel "github.com/nextlevelbuilder/goclaw/internal/channels/teams"
1718
"github.com/nextlevelbuilder/goclaw/internal/channels/telegram"
1819
"github.com/nextlevelbuilder/goclaw/internal/channels/whatsapp"
1920
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo"
@@ -134,6 +135,35 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms
134135
slog.Info("feishu/lark channel enabled (config)")
135136
}
136137
}
138+
139+
}
140+
141+
// registerTeamsChannel registers Teams from config for backward compatibility.
142+
// DB instances are also supported via teams.Factory registered in the instance loader.
143+
// DB instances are also supported via teams.Factory registered in the instance loader.
144+
func registerTeamsChannel(cfg *config.Config, channelMgr *channels.Manager, msgBus *bus.MessageBus) {
145+
if !cfg.Channels.Teams.Enabled {
146+
return
147+
}
148+
if cfg.Channels.Teams.BotID == "" {
149+
channelMgr.RecordHealth(channels.TypeTeams, channels.NewChannelHealthForType(
150+
channels.TypeTeams,
151+
channels.ChannelHealthStateFailed,
152+
"Missing credentials",
153+
"Set channels.teams.bot_id in config.",
154+
channels.ChannelFailureKindConfig,
155+
false,
156+
))
157+
return
158+
}
159+
t, err := teamschannel.New(cfg.Channels.Teams, msgBus)
160+
if err != nil {
161+
channelMgr.RecordFailure(channels.TypeTeams, "", err)
162+
slog.Error("failed to initialize teams channel", "error", err)
163+
return
164+
}
165+
channelMgr.RegisterChannel(channels.TypeTeams, t)
166+
slog.Info("teams channel enabled (config)")
137167
}
138168

139169
// wireChannelRPCMethods registers WS RPC methods for channels, instances, agent links, and teams.

cmd/gateway_consumer_normal.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ func processNormalMessage(
214214
outMeta["reply_to_message_id"] = mid
215215
}
216216
}
217-
for _, k := range []string{tools.MetaMessageThreadID, "local_key", "placeholder_key", "group_id"} {
217+
for _, k := range []string{tools.MetaMessageThreadID, "local_key", "placeholder_key", "group_id", "service_url"} {
218218
if v := msg.Metadata[k]; v != "" {
219219
outMeta[k] = v
220220
}

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ func init() {
3838
rootCmd.AddCommand(configCmd())
3939
rootCmd.AddCommand(modelsCmd())
4040
rootCmd.AddCommand(channelsCmd())
41+
rootCmd.AddCommand(teamsCmd())
4142
rootCmd.AddCommand(cronCmd())
4243
rootCmd.AddCommand(skillsCmd())
4344
rootCmd.AddCommand(sessionsCmd())

cmd/teams_cmd.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/spf13/cobra"
8+
9+
"github.com/nextlevelbuilder/goclaw/internal/channels/teams/appmanifest"
10+
"github.com/nextlevelbuilder/goclaw/internal/config"
11+
)
12+
13+
func teamsCmd() *cobra.Command {
14+
cmd := &cobra.Command{
15+
Use: "teams",
16+
Short: "Microsoft Teams channel management",
17+
}
18+
cmd.AddCommand(teamsAppPackageCmd())
19+
return cmd
20+
}
21+
22+
func teamsAppPackageCmd() *cobra.Command {
23+
var (
24+
name string
25+
fullName string
26+
description string
27+
developer string
28+
botID string
29+
iconColor string
30+
iconOutline string
31+
output string
32+
toStdout bool
33+
)
34+
35+
cmd := &cobra.Command{
36+
Use: "app-package",
37+
Short: "Generate a Teams app package ZIP for sideloading",
38+
Long: "Generates a Teams-compatible app package (manifest.json + icons) for sideloading into Microsoft Teams.",
39+
RunE: func(cmd *cobra.Command, args []string) error {
40+
if output == "" && !toStdout {
41+
return fmt.Errorf("specify --output <file> or --stdout")
42+
}
43+
if output != "" && toStdout {
44+
return fmt.Errorf("--output and --stdout are mutually exclusive")
45+
}
46+
47+
cfg, err := config.Load(resolveConfigPath())
48+
if err != nil {
49+
return fmt.Errorf("loading config: %w", err)
50+
}
51+
if botID == "" {
52+
botID = cfg.Channels.Teams.BotID
53+
}
54+
55+
opts := appmanifest.Options{
56+
BotID: botID,
57+
Name: name,
58+
FullName: fullName,
59+
Description: description,
60+
Developer: developer,
61+
}
62+
63+
if iconColor != "" {
64+
data, err := os.ReadFile(iconColor)
65+
if err != nil {
66+
return fmt.Errorf("reading color icon: %w", err)
67+
}
68+
opts.ColorIcon = data
69+
}
70+
if iconOutline != "" {
71+
data, err := os.ReadFile(iconOutline)
72+
if err != nil {
73+
return fmt.Errorf("reading outline icon: %w", err)
74+
}
75+
opts.OutlineIcon = data
76+
}
77+
78+
zipData, err := appmanifest.GenerateZIP(opts)
79+
if err != nil {
80+
return err
81+
}
82+
83+
if toStdout {
84+
_, err = os.Stdout.Write(zipData)
85+
return err
86+
}
87+
88+
if err := os.WriteFile(output, zipData, 0644); err != nil {
89+
return fmt.Errorf("writing %s: %w", output, err)
90+
}
91+
fmt.Fprintf(os.Stderr, "Teams app package written to %s (%d bytes)\n", output, len(zipData))
92+
return nil
93+
},
94+
}
95+
96+
cmd.Flags().StringVar(&name, "name", "", "bot display name (required, max 30 chars)")
97+
cmd.Flags().StringVar(&fullName, "full-name", "", "full bot name (default: same as --name)")
98+
cmd.Flags().StringVar(&description, "description", "", "short description (max 80 chars)")
99+
cmd.Flags().StringVar(&developer, "developer", "", "developer name (default: GoClaw)")
100+
cmd.Flags().StringVar(&botID, "bot-id", "", "override bot ID from config")
101+
cmd.Flags().StringVar(&iconColor, "icon-color", "", "path to custom 192x192 color icon (PNG)")
102+
cmd.Flags().StringVar(&iconOutline, "icon-outline", "", "path to custom 32x32 outline icon (PNG)")
103+
cmd.Flags().StringVarP(&output, "output", "o", "", "output file path")
104+
cmd.Flags().BoolVar(&toStdout, "stdout", false, "write ZIP to stdout (for piping/Docker)")
105+
_ = cmd.MarkFlagRequired("name")
106+
107+
return cmd
108+
}

docs/00-architecture-overview.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ flowchart TD
1818
ZLP[Zalo Personal]
1919
WA[WhatsApp]
2020
SL[Slack]
21+
TM[Teams]
2122
end
2223
2324
subgraph Gateway["Gateway Server"]
@@ -76,7 +77,7 @@ flowchart TD
7677
7778
WS --> WSS
7879
HTTP --> HTTPS
79-
TG & DC & FS & ZL & ZLP & WA & SL --> CM
80+
TG & DC & FS & ZL & ZLP & WA & SL & TM --> CM
8081
8182
WSS --> MR
8283
HTTPS --> MR
@@ -110,7 +111,7 @@ flowchart TD
110111
| `internal/bootstrap/` | System prompt files (AGENTS.md, SOUL.md, TOOLS.md, IDENTITY.md, USER.md, BOOTSTRAP.md) + seeding + truncation |
111112
| `internal/config/` | Config loading (JSON5) + env var overlay |
112113
| `internal/skills/` | SKILL.md loader (5-tier hierarchy) + BM25 search + hot-reload via fsnotify |
113-
| `internal/channels/` | Channel manager + adapters: Telegram (forum topics, STT, bot commands), Feishu/Lark (streaming cards, media), Zalo OA, Zalo Personal, Discord, WhatsApp, Slack |
114+
| `internal/channels/` | Channel manager + adapters: Telegram (forum topics, STT, bot commands), Feishu/Lark (streaming cards, media), Zalo OA, Zalo Personal, Discord, WhatsApp, Slack, Teams (webhook-based) |
114115
| `internal/mcp/` | MCP server bridge (stdio, SSE, streamable-HTTP transports) |
115116
| `internal/scheduler/` | Lane-based concurrency control (main, subagent, cron, team lanes) with per-session serialization. Per-edition rate limits (`MaxSubagentConcurrent`, `MaxSubagentDepth`) with tenant-scoped concurrency |
116117
| `internal/memory/` | Memory system (pgvector hybrid search) |
@@ -224,7 +225,7 @@ sequenceDiagram
224225
225226
GW->>Engine: 17. Create gateway server (WS + HTTP)
226227
GW->>Engine: 18. Register RPC methods
227-
GW->>Engine: 19. Register + start channels (Telegram, Discord, Feishu, Zalo, WhatsApp)
228+
GW->>Engine: 19. Register + start channels (Telegram, Discord, Feishu, Zalo, WhatsApp, Teams)
228229
GW->>Engine: 20. Start cron, scheduler (4 lanes)
229230
GW->>Engine: 21. Start skills watcher + inbound consumer
230231
GW->>Engine: 22. Listen on host:port

0 commit comments

Comments
 (0)