diff --git a/cmd/gateway.go b/cmd/gateway.go index 9e95800c..c7b824d4 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -17,6 +17,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/channels" "github.com/nextlevelbuilder/goclaw/internal/channels/discord" "github.com/nextlevelbuilder/goclaw/internal/channels/feishu" + googlechatchannel "github.com/nextlevelbuilder/goclaw/internal/channels/googlechat" slackchannel "github.com/nextlevelbuilder/goclaw/internal/channels/slack" "github.com/nextlevelbuilder/goclaw/internal/channels/telegram" "github.com/nextlevelbuilder/goclaw/internal/channels/whatsapp" @@ -425,6 +426,7 @@ func runGateway() { instanceLoader.RegisterFactory(channels.TypeZaloPersonal, zalopersonal.FactoryWithPendingStore(pgStores.PendingMessages)) instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.Factory) instanceLoader.RegisterFactory(channels.TypeSlack, slackchannel.FactoryWithPendingStore(pgStores.PendingMessages)) + instanceLoader.RegisterFactory(channels.TypeGoogleChat, googlechatchannel.Factory) if err := instanceLoader.LoadAll(context.Background()); err != nil { slog.Error("failed to load channel instances from DB", "error", err) } @@ -727,11 +729,13 @@ func runGateway() { // Compiled via build tags: `go build -tags tsnet` to enable. mux := server.BuildMux() - // Mount channel webhook handlers on the main mux (e.g. Feishu /feishu/events). - // This allows webhook-based channels to share the main server port. - for _, route := range channelMgr.WebhookHandlers() { - mux.Handle(route.Path, route.Handler) - slog.Info("webhook route mounted on gateway", "path", route.Path) + // Mount channel webhook handlers dynamically (e.g. Feishu /feishu/events, Google Chat). + // Uses wrapper handlers so channels added later via Reload() are served without re-mounting. + channelMgr.MountNewWebhookRoutes(mux) + + // Pass mux to instanceLoader so Reload() can mount webhook routes for new channels. + if instanceLoader != nil { + instanceLoader.SetMux(mux) } tsCleanup := initTailscale(ctx, cfg, mux) diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go index 2ff838bc..fcc8c897 100644 --- a/cmd/gateway_channels_setup.go +++ b/cmd/gateway_channels_setup.go @@ -13,6 +13,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/channels" "github.com/nextlevelbuilder/goclaw/internal/channels/discord" "github.com/nextlevelbuilder/goclaw/internal/channels/feishu" + googlechatchannel "github.com/nextlevelbuilder/goclaw/internal/channels/googlechat" slackchannel "github.com/nextlevelbuilder/goclaw/internal/channels/slack" "github.com/nextlevelbuilder/goclaw/internal/channels/telegram" "github.com/nextlevelbuilder/goclaw/internal/channels/whatsapp" @@ -97,6 +98,17 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms slog.Info("feishu/lark channel enabled (config)") } } + + gcCfg := cfg.Channels.GoogleChat + if gcCfg.Enabled && gcCfg.ServiceAccountJSON != "" && instanceLoader == nil { + gc, err := googlechatchannel.New(gcCfg, msgBus, pgStores.Pairing) + if err != nil { + slog.Error("failed to initialize googlechat channel", "error", err) + } else { + channelMgr.RegisterChannel(channels.TypeGoogleChat, gc) + slog.Info("google chat channel enabled (config)") + } + } } // wireChannelRPCMethods registers WS RPC methods for channels, instances, agent links, and teams. diff --git a/go.mod b/go.mod index 47b1782f..80d85937 100644 --- a/go.mod +++ b/go.mod @@ -24,11 +24,13 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 go.opentelemetry.io/otel/sdk v1.40.0 go.opentelemetry.io/otel/trace v1.40.0 + golang.org/x/oauth2 v0.35.0 golang.org/x/time v0.14.0 tailscale.com v1.94.2 ) require ( + cloud.google.com/go/compute/metadata v0.9.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/akutz/memconn v0.1.0 // indirect github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect @@ -103,8 +105,7 @@ require ( go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/image v0.27.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/term v0.39.0 // indirect + golang.org/x/term v0.40.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect @@ -149,14 +150,14 @@ require ( go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect - golang.org/x/crypto v0.47.0 // indirect + golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/net v0.49.0 + golang.org/x/net v0.50.0 golang.org/x/sync v0.19.0 - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.78.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index e0d02565..4bea893d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ 9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q= 9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= @@ -453,8 +455,8 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/W golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= @@ -462,13 +464,13 @@ golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= @@ -476,20 +478,20 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= @@ -498,10 +500,10 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/channels/channel.go b/internal/channels/channel.go index a867ba7f..5197895a 100644 --- a/internal/channels/channel.go +++ b/internal/channels/channel.go @@ -58,6 +58,7 @@ const ( TypeWhatsApp = "whatsapp" TypeZaloOA = "zalo_oa" TypeZaloPersonal = "zalo_personal" + TypeGoogleChat = "google_chat" ) // Channel defines the interface that all channel implementations must satisfy. diff --git a/internal/channels/googlechat/api.go b/internal/channels/googlechat/api.go new file mode 100644 index 00000000..d8da6ef8 --- /dev/null +++ b/internal/channels/googlechat/api.go @@ -0,0 +1,117 @@ +package googlechat + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +const ( + chatAPIBase = "https://chat.googleapis.com/v1/" + chatScope = "https://www.googleapis.com/auth/chat.bot" +) + +// ChatClient is a lightweight Google Chat REST API client. +type ChatClient struct { + httpClient *http.Client + tokenSource oauth2.TokenSource +} + +// NewChatClient creates a client authenticated via Service Account JSON content. +func NewChatClient(saJSON []byte) (*ChatClient, error) { + conf, err := google.JWTConfigFromJSON(saJSON, chatScope) + if err != nil { + return nil, fmt.Errorf("parse service account: %w", err) + } + + ts := conf.TokenSource(context.Background()) + + return &ChatClient{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + tokenSource: ts, + }, nil +} + +// doJSON performs an authenticated JSON request to the Google Chat API. +func (c *ChatClient) doJSON(ctx context.Context, method, url string, body interface{}) ([]byte, error) { + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal body: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return nil, err + } + + token, err := c.tokenSource.Token() + if err != nil { + return nil, fmt.Errorf("get token: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token.AccessToken) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("google chat api %s: %w", method, err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("google chat api %d: %s", resp.StatusCode, string(respBody)) + } + return respBody, nil +} + +// SendMessage sends a text message to a Google Chat space. +// spaceName format: "spaces/SPACE_ID" +func (c *ChatClient) SendMessage(ctx context.Context, spaceName, text string) error { + url := chatAPIBase + spaceName + "/messages" + msg := gcSendMessage{Text: text} + _, err := c.doJSON(ctx, "POST", url, msg) + return err +} + +// AddReaction adds an emoji reaction to a message. +// messageName format: "spaces/SPACE_ID/messages/MSG_ID" +// Returns the reaction resource name for deletion. +func (c *ChatClient) AddReaction(ctx context.Context, messageName, unicode string) (string, error) { + url := chatAPIBase + messageName + "/reactions" + body := gcReaction{Emoji: &gcEmoji{Unicode: unicode}} + respBody, err := c.doJSON(ctx, "POST", url, body) + if err != nil { + return "", err + } + var result struct { + Name string `json:"name"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return "", fmt.Errorf("parse reaction response: %w", err) + } + return result.Name, nil +} + +// DeleteReaction removes a reaction from a message. +// reactionName format: "spaces/SPACE_ID/messages/MSG_ID/reactions/REACTION_ID" +func (c *ChatClient) DeleteReaction(ctx context.Context, reactionName string) error { + url := chatAPIBase + reactionName + _, err := c.doJSON(ctx, "DELETE", url, nil) + return err +} diff --git a/internal/channels/googlechat/bot.go b/internal/channels/googlechat/bot.go new file mode 100644 index 00000000..bbbf3218 --- /dev/null +++ b/internal/channels/googlechat/bot.go @@ -0,0 +1,134 @@ +package googlechat + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/channels" +) + +// handleMessageEvent processes an inbound MESSAGE event. +func (c *Channel) handleMessageEvent(ctx context.Context, event *SpaceEvent) { + if event == nil || event.Message == nil { + return + } + + messageName := event.Message.Name + if messageName == "" { + return + } + + // 1. Dedup + if c.isDuplicate(messageName) { + slog.Debug("googlechat message deduplicated", "message", messageName) + return + } + + // 2. Extract sender info (skip bot messages to prevent self-echo) + senderID, senderName := extractSenderInfo(event) + if senderID == "" { + return + } + if event.Message.Sender != nil && event.Message.Sender.Type == "BOT" { + return + } + + // 3. Determine peer kind + spaceName := "" + peerKind := "direct" + if event.Space != nil { + spaceName = event.Space.Name + if event.Space.Type != "DM" { + peerKind = "group" + } + } + if spaceName == "" { + return + } + + // 4. Policy check + dmPolicy := c.cfg.DMPolicy + if dmPolicy == "" { + dmPolicy = "pairing" + } + groupPolicy := c.cfg.GroupPolicy + if groupPolicy == "" { + groupPolicy = "pairing" + } + + if !c.CheckPolicy(peerKind, dmPolicy, groupPolicy, senderID) { + slog.Debug("googlechat message rejected by policy", + "sender_id", senderID, "space", spaceName, "peer_kind", peerKind) + return + } + + // 5. RequireMention gating for groups + mentioned := isBotMentioned(event) + if peerKind == "group" { + requireMention := true + if c.cfg.RequireMention != nil { + requireMention = *c.cfg.RequireMention + } + if requireMention && !mentioned { + c.groupHistory.Record(spaceName, channels.HistoryEntry{ + Sender: senderName, + Body: event.Message.Text, + Timestamp: time.Now(), + MessageID: messageName, + }, c.historyLimit) + return + } + } + + // 6. Build content — use argumentText (bot mention stripped) when mentioned, else full text + content := event.Message.ArgumentText + if content == "" { + content = event.Message.Text + } + if content == "" { + content = "[empty message]" + } + + // 7. Group context + if peerKind == "group" && senderName != "" { + annotated := fmt.Sprintf("[From: %s]\n%s", senderName, content) + if c.historyLimit > 0 { + content = c.groupHistory.BuildContext(spaceName, annotated, c.historyLimit) + } else { + content = annotated + } + } + + // 8. Build metadata + metadata := map[string]string{ + "message_name": messageName, + "platform": "googlechat", + "sender_name": senderName, + "mentioned_bot": fmt.Sprintf("%t", mentioned), + } + + slog.Debug("googlechat message received", + "sender_id", senderID, "space", spaceName, + "peer_kind", peerKind, "preview", channels.Truncate(content, 50)) + + // 9. Publish to bus + c.HandleMessage(senderID, spaceName, content, nil, metadata, peerKind) + + // 10. Clear pending history after sending + if peerKind == "group" { + c.groupHistory.Clear(spaceName) + } +} + +// isDuplicate returns true if messageName was already processed. +func (c *Channel) isDuplicate(messageName string) bool { + _, loaded := c.dedup.LoadOrStore(messageName, struct{}{}) + if !loaded { + time.AfterFunc(5*time.Minute, func() { + c.dedup.Delete(messageName) + }) + } + return loaded +} diff --git a/internal/channels/googlechat/bot_test.go b/internal/channels/googlechat/bot_test.go new file mode 100644 index 00000000..737dbd9b --- /dev/null +++ b/internal/channels/googlechat/bot_test.go @@ -0,0 +1,92 @@ +package googlechat + +import ( + "sync" + "testing" + "time" +) + +func TestIsDuplicate_FirstCall(t *testing.T) { + ch := &Channel{dedup: sync.Map{}} + if ch.isDuplicate("msg-1") { + t.Fatal("first call should not be duplicate") + } +} + +func TestIsDuplicate_SecondCall(t *testing.T) { + ch := &Channel{dedup: sync.Map{}} + ch.isDuplicate("msg-1") + if !ch.isDuplicate("msg-1") { + t.Fatal("second call should be duplicate") + } +} + +func TestIsDuplicate_DifferentMessages(t *testing.T) { + ch := &Channel{dedup: sync.Map{}} + ch.isDuplicate("msg-1") + if ch.isDuplicate("msg-2") { + t.Fatal("different message should not be duplicate") + } +} + +func TestIsDuplicate_CleanupAfterExpiry(t *testing.T) { + // Verify the dedup entry is stored and can be manually cleaned + ch := &Channel{dedup: sync.Map{}} + ch.dedup.Store("msg-old", struct{}{}) + + if !ch.isDuplicate("msg-old") { + t.Fatal("stored message should be duplicate") + } + + // Simulate cleanup (time.AfterFunc would do this after 5 min) + ch.dedup.Delete("msg-old") + if ch.isDuplicate("msg-old") { + t.Fatal("deleted message should not be duplicate") + } +} + +func TestIsDuplicate_ConcurrentAccess(t *testing.T) { + ch := &Channel{dedup: sync.Map{}} + const goroutines = 100 + + var wg sync.WaitGroup + results := make([]bool, goroutines) + + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func(idx int) { + defer wg.Done() + results[idx] = ch.isDuplicate("same-msg") + }(i) + } + wg.Wait() + + // Exactly one goroutine should see it as non-duplicate + nonDup := 0 + for _, dup := range results { + if !dup { + nonDup++ + } + } + if nonDup != 1 { + t.Fatalf("expected exactly 1 non-duplicate, got %d", nonDup) + } +} + +func TestIsDuplicate_AfterFuncScheduled(t *testing.T) { + // Verify time.AfterFunc is used (entry gets cleaned up) + ch := &Channel{dedup: sync.Map{}} + + // Override: use a very short timer to test cleanup + // We can't directly test time.AfterFunc(5min), but we verify the entry exists + ch.isDuplicate("msg-timer") + + // Entry should exist immediately after + if _, ok := ch.dedup.Load("msg-timer"); !ok { + t.Fatal("dedup entry should exist after isDuplicate call") + } + + // Note: actual cleanup happens after 5 minutes via time.AfterFunc + // We just verify the mechanism is correct by checking the entry is stored + _ = time.AfterFunc // reference to confirm import is valid +} diff --git a/internal/channels/googlechat/channel.go b/internal/channels/googlechat/channel.go new file mode 100644 index 00000000..d709cb84 --- /dev/null +++ b/internal/channels/googlechat/channel.go @@ -0,0 +1,200 @@ +package googlechat + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "strings" + "sync" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const ( + defaultWebhookPath = "/googlechat/events" + defaultTextChunkLimit = 4000 + typingEmoji = "\u23f3" // Unicode hourglass for thinking indicator +) + +// Channel connects to Google Chat via webhook. +type Channel struct { + *channels.BaseChannel + cfg config.GoogleChatConfig + client *ChatClient + pairingService store.PairingStore + dedup sync.Map // message name → struct{} + reactions sync.Map // chatID → *reactionState + groupHistory *channels.PendingHistory + historyLimit int +} + +type reactionState struct { + messageName string // full message resource name + reactionName string // reaction resource name for deletion +} + +// Compile-time interface assertions. +var _ channels.Channel = (*Channel)(nil) +var _ channels.WebhookChannel = (*Channel)(nil) +var _ channels.ReactionChannel = (*Channel)(nil) +var _ channels.BlockReplyChannel = (*Channel)(nil) + +// New creates a new Google Chat channel. +func New(cfg config.GoogleChatConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore) (*Channel, error) { + saJSON, err := resolveServiceAccountJSON(cfg) + if err != nil { + return nil, fmt.Errorf("google_chat credentials: %w", err) + } + + client, err := NewChatClient(saJSON) + if err != nil { + return nil, fmt.Errorf("google_chat api client: %w", err) + } + + base := channels.NewBaseChannel(channels.TypeGoogleChat, msgBus, cfg.AllowFrom) + base.ValidatePolicy(cfg.DMPolicy, cfg.GroupPolicy) + + switch cfg.ReactionLevel { + case "", "off", "minimal", "full": + // valid + default: + slog.Warn("googlechat: unrecognized reaction_level, defaulting to off", "value", cfg.ReactionLevel) + } + + historyLimit := cfg.HistoryLimit + if historyLimit == 0 { + historyLimit = channels.DefaultGroupHistoryLimit + } + + return &Channel{ + BaseChannel: base, + cfg: cfg, + client: client, + pairingService: pairingSvc, + groupHistory: channels.NewPendingHistory(), + historyLimit: historyLimit, + }, nil +} + +func (c *Channel) Start(_ context.Context) error { + slog.Info("starting google chat bot (webhook mode)") + c.SetRunning(true) + return nil +} + +func (c *Channel) Stop(_ context.Context) error { + slog.Info("stopping google chat bot") + c.SetRunning(false) + return nil +} + +// BlockReplyEnabled returns the per-channel block_reply override (nil = inherit). +func (c *Channel) BlockReplyEnabled() *bool { return c.cfg.BlockReply } + +// WebhookHandler returns the webhook path and handler for mounting on the main gateway mux. +func (c *Channel) WebhookHandler() (string, http.Handler) { + path := c.cfg.WebhookPath + if path == "" { + path = defaultWebhookPath + } + + handler := NewWebhookHandler(c.cfg.ProjectNumber, func(event *SpaceEvent) { + c.handleMessageEvent(context.Background(), event) + }) + + return path, http.HandlerFunc(handler) +} + +// Send delivers an outbound message to a Google Chat space. +func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return fmt.Errorf("googlechat bot not running") + } + if msg.ChatID == "" { + return fmt.Errorf("empty chat ID for googlechat send") + } + + text := msg.Content + if text == "" { + return nil + } + + // Chunk at 4000 chars (Google Chat limit is 4096) + for len(text) > 0 { + chunk := text + if len(chunk) > defaultTextChunkLimit { + cutAt := defaultTextChunkLimit + if idx := strings.LastIndex(text[:defaultTextChunkLimit], "\n"); idx > defaultTextChunkLimit/2 { + cutAt = idx + 1 + } + chunk = text[:cutAt] + text = text[cutAt:] + } else { + text = "" + } + + if err := c.client.SendMessage(ctx, msg.ChatID, chunk); err != nil { + return fmt.Errorf("googlechat send: %w", err) + } + } + return nil +} + +// OnReactionEvent handles agent status changes by adding/removing emoji reactions. +func (c *Channel) OnReactionEvent(ctx context.Context, chatID, messageID, status string) error { + if c.cfg.ReactionLevel == "off" || messageID == "" { + return nil + } + if c.cfg.ReactionLevel == "minimal" && status != "done" && status != "error" { + return nil + } + + // Terminal states: remove reaction + if status == "done" || status == "error" { + return c.removeReaction(ctx, chatID) + } + + // Active states: add reaction if not present + if _, loaded := c.reactions.Load(chatID); loaded { + return nil + } + + reactionName, err := c.client.AddReaction(ctx, messageID, typingEmoji) + if err != nil { + slog.Debug("googlechat: add reaction failed", "message", messageID, "error", err) + return nil + } + + c.reactions.Store(chatID, &reactionState{ + messageName: messageID, + reactionName: reactionName, + }) + return nil +} + +// ClearReaction removes the typing reaction from a message. +func (c *Channel) ClearReaction(ctx context.Context, chatID, _ string) error { + return c.removeReaction(ctx, chatID) +} + +func (c *Channel) removeReaction(ctx context.Context, chatID string) error { + val, ok := c.reactions.LoadAndDelete(chatID) + if !ok { + return nil + } + rs, ok := val.(*reactionState) + if !ok { + return nil + } + if rs.reactionName == "" { + return nil + } + if err := c.client.DeleteReaction(ctx, rs.reactionName); err != nil { + slog.Debug("googlechat: remove reaction failed", "reaction", rs.reactionName, "error", err) + } + return nil +} diff --git a/internal/channels/googlechat/chat_oidc_verifier.go b/internal/channels/googlechat/chat_oidc_verifier.go new file mode 100644 index 00000000..2806e06c --- /dev/null +++ b/internal/channels/googlechat/chat_oidc_verifier.go @@ -0,0 +1,223 @@ +package googlechat + +import ( + "context" + "crypto" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "net/http" + "strings" + "sync" + "time" +) + +const ( + // chatJWKURL is the JWK endpoint for Google Chat's signing keys. + // Standard idtoken.Validate() uses googleapis.com/oauth2/v3/certs which does NOT + // include Chat's signing keys, causing "could not find matching cert keyId" errors. + chatJWKURL = "https://www.googleapis.com/service_accounts/v1/jwk/chat@system.gserviceaccount.com" + + // chatIssuer is the expected issuer claim in Google Chat JWT tokens. + chatIssuer = "chat@system.gserviceaccount.com" + + // jwkCacheTTL is how long to cache fetched JWKs before refreshing. + jwkCacheTTL = 1 * time.Hour + + // clockSkewLeeway allows for minor clock differences between servers. + clockSkewLeeway = 5 * time.Minute +) + +// chatCertCache stores fetched Google Chat JWKs with a TTL. +var chatCertCache = &jwkCache{keys: make(map[string]*rsa.PublicKey)} + +type jwkCache struct { + mu sync.RWMutex + keys map[string]*rsa.PublicKey // kid → public key + fetched time.Time +} + +// jwkSet is the JSON structure from Google's JWK endpoint. +type jwkSet struct { + Keys []jwkKey `json:"keys"` +} + +type jwkKey struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + N string `json:"n"` + E string `json:"e"` +} + +// verifyChatToken verifies a Google Chat JWT token against the Chat-specific JWK endpoint. +// audiences is a list of acceptable audience values (webhook URL, project number, etc.). +func verifyChatToken(ctx context.Context, token string, audiences []string) error { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return fmt.Errorf("invalid JWT format") + } + + // 1. Parse header to get kid + headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return fmt.Errorf("decode header: %w", err) + } + var header struct { + Kid string `json:"kid"` + Alg string `json:"alg"` + } + if err := json.Unmarshal(headerJSON, &header); err != nil { + return fmt.Errorf("parse header: %w", err) + } + if header.Alg != "RS256" { + return fmt.Errorf("unsupported algorithm: %s", header.Alg) + } + + // 2. Get signing key (fetch + cache, force-refresh on miss) + pubKey, err := getChatSigningKey(ctx, header.Kid) + if err != nil { + return err + } + + // 3. Verify RS256 signature + signed := []byte(parts[0] + "." + parts[1]) + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return fmt.Errorf("decode signature: %w", err) + } + h := crypto.SHA256.New() + h.Write(signed) + if err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, h.Sum(nil), sig); err != nil { + return fmt.Errorf("invalid signature: %w", err) + } + + // 4. Validate claims + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return fmt.Errorf("decode claims: %w", err) + } + var claims struct { + Iss string `json:"iss"` + Aud string `json:"aud"` + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(claimsJSON, &claims); err != nil { + return fmt.Errorf("parse claims: %w", err) + } + + if claims.Iss != chatIssuer { + return fmt.Errorf("invalid issuer: %s", claims.Iss) + } + + if time.Now().Unix() > claims.Exp+int64(clockSkewLeeway.Seconds()) { + return fmt.Errorf("token expired") + } + + for _, aud := range audiences { + if claims.Aud == aud { + return nil + } + } + return fmt.Errorf("audience mismatch: got %s", claims.Aud) +} + +// getChatSigningKey returns the RSA public key for the given kid. +// Fetches from cache first; on miss, force-refreshes the cache and retries. +func getChatSigningKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { + keys, err := fetchChatJWKs(ctx, false) + if err != nil { + return nil, fmt.Errorf("fetch certs: %w", err) + } + if key, ok := keys[kid]; ok { + return key, nil + } + + // Key not found — force refresh (Google may have rotated keys) + keys, err = fetchChatJWKs(ctx, true) + if err != nil { + return nil, fmt.Errorf("refresh certs: %w", err) + } + if key, ok := keys[kid]; ok { + return key, nil + } + return nil, fmt.Errorf("unknown signing key: %s", kid) +} + +// fetchChatJWKs fetches Google Chat JWKs with caching. +// forceRefresh bypasses the cache TTL. +func fetchChatJWKs(ctx context.Context, forceRefresh bool) (map[string]*rsa.PublicKey, error) { + chatCertCache.mu.RLock() + if !forceRefresh && time.Since(chatCertCache.fetched) < jwkCacheTTL && len(chatCertCache.keys) > 0 { + keys := chatCertCache.keys + chatCertCache.mu.RUnlock() + return keys, nil + } + chatCertCache.mu.RUnlock() + + chatCertCache.mu.Lock() + defer chatCertCache.mu.Unlock() + + // Double-check after acquiring write lock + if !forceRefresh && time.Since(chatCertCache.fetched) < jwkCacheTTL && len(chatCertCache.keys) > 0 { + return chatCertCache.keys, nil + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, chatJWKURL, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch JWKs: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("JWK endpoint returned %d", resp.StatusCode) + } + + var jwks jwkSet + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { + return nil, fmt.Errorf("decode JWKs: %w", err) + } + + keys := make(map[string]*rsa.PublicKey, len(jwks.Keys)) + for _, k := range jwks.Keys { + if k.Kty != "RSA" { + continue + } + pub, err := rsaPubFromJWK(k.N, k.E) + if err != nil { + continue + } + keys[k.Kid] = pub + } + + chatCertCache.keys = keys + chatCertCache.fetched = time.Now() + return keys, nil +} + +// rsaPubFromJWK converts base64url-encoded RSA modulus and exponent to an rsa.PublicKey. +func rsaPubFromJWK(nB64, eB64 string) (*rsa.PublicKey, error) { + nBytes, err := base64.RawURLEncoding.DecodeString(nB64) + if err != nil { + return nil, err + } + eBytes, err := base64.RawURLEncoding.DecodeString(eB64) + if err != nil { + return nil, err + } + + e := 0 + for _, b := range eBytes { + e = e*256 + int(b) + } + + return &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: e, + }, nil +} diff --git a/internal/channels/googlechat/credentials.go b/internal/channels/googlechat/credentials.go new file mode 100644 index 00000000..8498de7e --- /dev/null +++ b/internal/channels/googlechat/credentials.go @@ -0,0 +1,22 @@ +package googlechat + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/config" +) + +// resolveServiceAccountJSON returns SA JSON bytes from inline content. +func resolveServiceAccountJSON(cfg config.GoogleChatConfig) ([]byte, error) { + s := strings.TrimSpace(cfg.ServiceAccountJSON) + if s == "" { + return nil, fmt.Errorf("service_account_json is required") + } + data := []byte(s) + if !json.Valid(data) { + return nil, fmt.Errorf("service_account_json is not valid JSON") + } + return data, nil +} diff --git a/internal/channels/googlechat/credentials_test.go b/internal/channels/googlechat/credentials_test.go new file mode 100644 index 00000000..97b222e2 --- /dev/null +++ b/internal/channels/googlechat/credentials_test.go @@ -0,0 +1,57 @@ +package googlechat + +import ( + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/config" +) + +func TestResolveServiceAccountJSON_ValidInline(t *testing.T) { + cfg := config.GoogleChatConfig{ + ServiceAccountJSON: `{"type":"service_account","project_id":"test"}`, + } + data, err := resolveServiceAccountJSON(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(data) != `{"type":"service_account","project_id":"test"}` { + t.Fatalf("unexpected data: %s", data) + } +} + +func TestResolveServiceAccountJSON_TrimsWhitespace(t *testing.T) { + cfg := config.GoogleChatConfig{ + ServiceAccountJSON: ` {"type":"service_account"} `, + } + data, err := resolveServiceAccountJSON(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(data) != `{"type":"service_account"}` { + t.Fatalf("unexpected data: %s", data) + } +} + +func TestResolveServiceAccountJSON_Empty(t *testing.T) { + cfg := config.GoogleChatConfig{} + _, err := resolveServiceAccountJSON(cfg) + if err == nil { + t.Fatal("expected error for empty config") + } + if err.Error() != "service_account_json is required" { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResolveServiceAccountJSON_InvalidJSON(t *testing.T) { + cfg := config.GoogleChatConfig{ + ServiceAccountJSON: "not-json", + } + _, err := resolveServiceAccountJSON(cfg) + if err == nil { + t.Fatal("expected error for invalid JSON") + } + if err.Error() != "service_account_json is not valid JSON" { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/channels/googlechat/factory.go b/internal/channels/googlechat/factory.go new file mode 100644 index 00000000..9c69cb9d --- /dev/null +++ b/internal/channels/googlechat/factory.go @@ -0,0 +1,78 @@ +package googlechat + +import ( + "encoding/json" + "fmt" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// googlechatCreds maps credentials JSON from channel_instances table. +type googlechatCreds struct { + ServiceAccountJSON string `json:"service_account_json"` // SA JSON content (stored encrypted in DB) + ProjectNumber string `json:"project_number,omitempty"` +} + +// googlechatInstanceConfig maps non-secret config from channel_instances table. +type googlechatInstanceConfig struct { + WebhookPath string `json:"webhook_path,omitempty"` + AllowFrom []string `json:"allow_from,omitempty"` + DMPolicy string `json:"dm_policy,omitempty"` + GroupPolicy string `json:"group_policy,omitempty"` + RequireMention *bool `json:"require_mention,omitempty"` + HistoryLimit int `json:"history_limit,omitempty"` + ReactionLevel string `json:"reaction_level,omitempty"` + BlockReply *bool `json:"block_reply,omitempty"` +} + +// Factory creates a Google Chat channel from DB instance data. +func Factory(name string, creds json.RawMessage, cfg json.RawMessage, + msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) { + + var c googlechatCreds + if len(creds) > 0 { + if err := json.Unmarshal(creds, &c); err != nil { + return nil, fmt.Errorf("decode googlechat credentials: %w", err) + } + } + if c.ServiceAccountJSON == "" { + return nil, fmt.Errorf("googlechat service_account_json is required") + } + + var ic googlechatInstanceConfig + if len(cfg) > 0 { + if err := json.Unmarshal(cfg, &ic); err != nil { + return nil, fmt.Errorf("decode googlechat config: %w", err) + } + } + + gcCfg := config.GoogleChatConfig{ + Enabled: true, + ServiceAccountJSON: c.ServiceAccountJSON, + ProjectNumber: c.ProjectNumber, + WebhookPath: ic.WebhookPath, + AllowFrom: ic.AllowFrom, + DMPolicy: ic.DMPolicy, + GroupPolicy: ic.GroupPolicy, + RequireMention: ic.RequireMention, + HistoryLimit: ic.HistoryLimit, + ReactionLevel: ic.ReactionLevel, + BlockReply: ic.BlockReply, + } + + // DB instances default to "pairing" for groups (secure by default). + if gcCfg.GroupPolicy == "" { + gcCfg.GroupPolicy = "pairing" + } + + ch, err := New(gcCfg, msgBus, pairingSvc) + if err != nil { + return nil, err + } + + ch.SetName(name) + return ch, nil +} diff --git a/internal/channels/googlechat/handler.go b/internal/channels/googlechat/handler.go new file mode 100644 index 00000000..d94d6b39 --- /dev/null +++ b/internal/channels/googlechat/handler.go @@ -0,0 +1,148 @@ +package googlechat + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" +) + +// NewWebhookHandler creates an http.HandlerFunc for Google Chat webhook events. +// projectNumber: Google Cloud project number for OIDC verification (empty = skip verify). +// onMessage: callback for MESSAGE events, invoked in a goroutine. +func NewWebhookHandler(projectNumber string, onMessage func(event *SpaceEvent)) http.HandlerFunc { + if projectNumber == "" { + slog.Warn("googlechat: OIDC verification disabled — webhook endpoint is unauthenticated, set project_number to enable") + } + + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // OIDC verification using Google Chat's specific JWK endpoint + if projectNumber != "" { + if err := verifyOIDC(r, projectNumber); err != nil { + slog.Warn("googlechat: OIDC verification failed", "error", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{}`)) + return + } + } + + // Read and parse body (limit 1MB to prevent abuse) + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "read body failed", http.StatusBadRequest) + return + } + + var event SpaceEvent + if err := json.Unmarshal(body, &event); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + + // Return 200 immediately (Google retries on timeout) + w.WriteHeader(http.StatusOK) + + // Process asynchronously + switch event.Type { + case "MESSAGE": + if onMessage != nil { + go onMessage(&event) + } + case "ADDED_TO_SPACE": + slog.Info("googlechat: bot added to space", + "space", event.Space.GetName(), + "user", event.User.GetDisplayName()) + case "REMOVED_FROM_SPACE": + slog.Info("googlechat: bot removed from space", + "space", event.Space.GetName()) + default: + slog.Debug("googlechat: unhandled event type", "type", event.Type) + } + } +} + +// verifyOIDC validates the Google OIDC token from the Authorization header. +// Uses Google Chat's specific JWK endpoint (chat@system.gserviceaccount.com) +// instead of the generic OAuth2 certs endpoint, which doesn't include Chat signing keys. +func verifyOIDC(r *http.Request, projectNumber string) error { + auth := r.Header.Get("Authorization") + if auth == "" { + return fmt.Errorf("missing Authorization header") + } + token := strings.TrimPrefix(auth, "Bearer ") + if token == auth { + return fmt.Errorf("invalid Authorization format") + } + + // Build list of acceptable audiences: webhook URL + project number + var audiences []string + if url := buildRequestURL(r); url != "" { + audiences = append(audiences, url) + } + audiences = append(audiences, projectNumber) + + return verifyChatToken(r.Context(), token, audiences) +} + +// buildRequestURL reconstructs the original full URL from request headers. +// Returns empty string if scheme/host cannot be determined. +func buildRequestURL(r *http.Request) string { + scheme := r.Header.Get("X-Forwarded-Proto") + if scheme == "" { + if r.TLS != nil { + scheme = "https" + } else { + scheme = "http" + } + } + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } + if host == "" { + return "" + } + return scheme + "://" + host + r.URL.Path +} + +// isBotMentioned checks if the bot was @mentioned in the message annotations. +func isBotMentioned(event *SpaceEvent) bool { + if event.Message == nil { + return false + } + for _, ann := range event.Message.Annotations { + if ann.Type == "USER_MENTION" && ann.UserMention != nil { + if ann.UserMention.User != nil && ann.UserMention.User.Type == "BOT" { + return true + } + } + } + return false +} + +// extractSenderInfo returns (senderID, displayName) from the event. +// senderID uses compound format: "users/123456789|DisplayName" +func extractSenderInfo(event *SpaceEvent) (string, string) { + user := event.User + if event.Message != nil && event.Message.Sender != nil { + user = event.Message.Sender + } + if user == nil { + return "", "" + } + + displayName := user.DisplayName + senderID := user.Name + if displayName != "" { + senderID = user.Name + "|" + displayName + } + return senderID, displayName +} diff --git a/internal/channels/googlechat/handler_test.go b/internal/channels/googlechat/handler_test.go new file mode 100644 index 00000000..7cfc5bdd --- /dev/null +++ b/internal/channels/googlechat/handler_test.go @@ -0,0 +1,105 @@ +package googlechat + +import "testing" + +func TestIsBotMentioned_WithBotMention(t *testing.T) { + event := &SpaceEvent{ + Message: &GCMessage{ + Annotations: []GCAnnotation{ + { + Type: "USER_MENTION", + UserMention: &GCUserMention{ + User: &GCUser{Type: "BOT"}, + }, + }, + }, + }, + } + if !isBotMentioned(event) { + t.Fatal("expected bot to be mentioned") + } +} + +func TestIsBotMentioned_WithHumanMention(t *testing.T) { + event := &SpaceEvent{ + Message: &GCMessage{ + Annotations: []GCAnnotation{ + { + Type: "USER_MENTION", + UserMention: &GCUserMention{ + User: &GCUser{Type: "HUMAN"}, + }, + }, + }, + }, + } + if isBotMentioned(event) { + t.Fatal("expected bot NOT to be mentioned") + } +} + +func TestIsBotMentioned_NilMessage(t *testing.T) { + event := &SpaceEvent{} + if isBotMentioned(event) { + t.Fatal("expected false for nil message") + } +} + +func TestIsBotMentioned_NoAnnotations(t *testing.T) { + event := &SpaceEvent{Message: &GCMessage{}} + if isBotMentioned(event) { + t.Fatal("expected false for no annotations") + } +} + +func TestExtractSenderInfo_FromMessageSender(t *testing.T) { + event := &SpaceEvent{ + User: &GCUser{Name: "users/111", DisplayName: "EventUser"}, + Message: &GCMessage{ + Sender: &GCUser{Name: "users/222", DisplayName: "MsgSender"}, + }, + } + id, name := extractSenderInfo(event) + if id != "users/222|MsgSender" { + t.Fatalf("unexpected senderID: %s", id) + } + if name != "MsgSender" { + t.Fatalf("unexpected name: %s", name) + } +} + +func TestExtractSenderInfo_FallbackToEventUser(t *testing.T) { + event := &SpaceEvent{ + User: &GCUser{Name: "users/111", DisplayName: "EventUser"}, + Message: &GCMessage{}, + } + id, name := extractSenderInfo(event) + if id != "users/111|EventUser" { + t.Fatalf("unexpected senderID: %s", id) + } + if name != "EventUser" { + t.Fatalf("unexpected name: %s", name) + } +} + +func TestExtractSenderInfo_NilUser(t *testing.T) { + event := &SpaceEvent{} + id, name := extractSenderInfo(event) + if id != "" || name != "" { + t.Fatalf("expected empty strings, got id=%q name=%q", id, name) + } +} + +func TestExtractSenderInfo_NoDisplayName(t *testing.T) { + event := &SpaceEvent{ + User: &GCUser{Name: "users/111"}, + } + id, name := extractSenderInfo(event) + // When no display name, senderID is just the resource name + if id != "users/111" { + t.Fatalf("unexpected senderID: %s", id) + } + if name != "" { + t.Fatalf("expected empty name, got %q", name) + } +} diff --git a/internal/channels/googlechat/types.go b/internal/channels/googlechat/types.go new file mode 100644 index 00000000..b7253ba9 --- /dev/null +++ b/internal/channels/googlechat/types.go @@ -0,0 +1,98 @@ +package googlechat + +// SpaceEvent is the webhook event envelope from Google Chat. +type SpaceEvent struct { + Type string `json:"type"` // "MESSAGE", "ADDED_TO_SPACE", "REMOVED_FROM_SPACE" + EventTime string `json:"eventTime"` + Message *GCMessage `json:"message,omitempty"` + Space *GCSpace `json:"space,omitempty"` + User *GCUser `json:"user,omitempty"` + ConfigCompleteRedirectURL string `json:"configCompleteRedirectUrl,omitempty"` +} + +// GCMessage represents a Google Chat message. +type GCMessage struct { + Name string `json:"name"` // "spaces/SPACE_ID/messages/MSG_ID" + Text string `json:"text"` // Plain text (includes @mentions) + ArgumentText string `json:"argumentText"` // Text with bot mention stripped + Sender *GCUser `json:"sender,omitempty"` + CreateTime string `json:"createTime"` + Thread *GCThread `json:"thread,omitempty"` + Space *GCSpace `json:"space,omitempty"` + Annotations []GCAnnotation `json:"annotations,omitempty"` + FormattedText string `json:"formattedText,omitempty"` +} + +// GCSpace represents a Google Chat space (room or DM). +type GCSpace struct { + Name string `json:"name"` // "spaces/SPACE_ID" + Type string `json:"type"` // "DM", "ROOM", "SPACE" + DisplayName string `json:"displayName"` + SingleUserBotDm bool `json:"singleUserBotDm,omitempty"` +} + +// GetName returns the space name (nil-safe). +func (s *GCSpace) GetName() string { + if s == nil { + return "" + } + return s.Name +} + +// GCUser represents a Google Chat user. +type GCUser struct { + Name string `json:"name"` // "users/USER_ID" + DisplayName string `json:"displayName"` + Email string `json:"email,omitempty"` + Type string `json:"type"` // "HUMAN", "BOT" +} + +// GetDisplayName returns the user display name (nil-safe). +func (u *GCUser) GetDisplayName() string { + if u == nil { + return "" + } + return u.DisplayName +} + +// GetName returns the user resource name (nil-safe). +func (u *GCUser) GetName() string { + if u == nil { + return "" + } + return u.Name +} + +// GCThread represents a message thread. +type GCThread struct { + Name string `json:"name"` // "spaces/SPACE_ID/threads/THREAD_ID" +} + +// GCAnnotation represents a message annotation (mentions, links, etc). +type GCAnnotation struct { + Type string `json:"type"` // "USER_MENTION", "SLASH_COMMAND" + StartIndex int `json:"startIndex"` + Length int `json:"length"` + UserMention *GCUserMention `json:"userMention,omitempty"` +} + +// GCUserMention represents a user mention annotation. +type GCUserMention struct { + User *GCUser `json:"user"` + Type string `json:"type"` // "MENTION", "ALL" +} + +// gcSendMessage is the request body for spaces.messages.create. +type gcSendMessage struct { + Text string `json:"text"` +} + +// gcReaction is the request body for adding a reaction. +type gcReaction struct { + Emoji *gcEmoji `json:"emoji"` +} + +// gcEmoji represents a Google Chat emoji. +type gcEmoji struct { + Unicode string `json:"unicode"` +} diff --git a/internal/channels/instance_loader.go b/internal/channels/instance_loader.go index 79f38109..721774b6 100644 --- a/internal/channels/instance_loader.go +++ b/internal/channels/instance_loader.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "maps" + "net/http" "sync" "time" @@ -35,6 +36,7 @@ type InstanceLoader struct { pairingSvc store.PairingStore mu sync.Mutex loaded map[string]struct{} // channel names managed by this loader + mux *http.ServeMux // gateway mux for mounting webhook routes after Reload } // NewInstanceLoader creates a new InstanceLoader. @@ -68,6 +70,13 @@ func (l *InstanceLoader) SetPendingCompactionConfig(cfg *config.PendingCompactio l.pendingCompactCfg = cfg } +// SetMux stores the gateway mux so Reload() can mount new webhook routes. +func (l *InstanceLoader) SetMux(mux *http.ServeMux) { + l.mu.Lock() + defer l.mu.Unlock() + l.mux = mux +} + // RegisterFactory registers a factory for a channel type (e.g., "telegram", "discord"). func (l *InstanceLoader) RegisterFactory(channelType string, factory ChannelFactory) { l.factories[channelType] = factory @@ -139,6 +148,11 @@ func (l *InstanceLoader) Reload(ctx context.Context) { } slog.Info("channel instances reloaded", "count", registered) + + // Mount webhook routes for newly loaded channels (paths not yet on the mux). + if l.mux != nil { + l.manager.MountNewWebhookRoutes(l.mux) + } } // Stop stops all managed channels. diff --git a/internal/channels/manager.go b/internal/channels/manager.go index 82f1a486..c6e2bfc3 100644 --- a/internal/channels/manager.go +++ b/internal/channels/manager.go @@ -3,6 +3,7 @@ package channels import ( "context" "log/slog" + "net/http" "sync" "github.com/nextlevelbuilder/goclaw/internal/bus" @@ -51,6 +52,8 @@ type Manager struct { dispatchTask *asyncTask mu sync.RWMutex contactCollector *store.ContactCollector + webhookMap map[string]http.Handler // path → current handler (protected by mu) + mountedPaths map[string]struct{} // paths already on mux (protected by mu) } type asyncTask struct { @@ -61,8 +64,10 @@ type asyncTask struct { // Channels are registered externally via RegisterChannel. func NewManager(msgBus *bus.MessageBus) *Manager { return &Manager{ - channels: make(map[string]Channel), - bus: msgBus, + channels: make(map[string]Channel), + bus: msgBus, + webhookMap: make(map[string]http.Handler), + mountedPaths: make(map[string]struct{}), } } @@ -165,6 +170,7 @@ func (m *Manager) RegisterChannel(name string, channel Channel) { } } m.channels[name] = channel + m.rebuildWebhookMap() } // SetContactCollector sets the contact collector for all current and future channels. @@ -195,4 +201,47 @@ func (m *Manager) UnregisterChannel(name string) { m.mu.Lock() defer m.mu.Unlock() delete(m.channels, name) + m.rebuildWebhookMap() +} + +// rebuildWebhookMap rebuilds path→handler map from current channels. +// Caller must hold m.mu write lock. +func (m *Manager) rebuildWebhookMap() { + m.webhookMap = make(map[string]http.Handler) + for name, ch := range m.channels { + if wh, ok := ch.(WebhookChannel); ok { + if path, handler := wh.WebhookHandler(); path != "" && handler != nil { + if _, exists := m.webhookMap[path]; exists { + slog.Warn("webhook path collision: multiple channels claim same path", "path", path, "channel", name) + } + m.webhookMap[path] = handler + } + } + } +} + +// WebhookServeHTTP dynamically dispatches webhook requests to the current channel handler. +func (m *Manager) WebhookServeHTTP(w http.ResponseWriter, r *http.Request) { + m.mu.RLock() + h := m.webhookMap[r.URL.Path] + m.mu.RUnlock() + if h != nil { + h.ServeHTTP(w, r) + return + } + http.NotFound(w, r) +} + +// MountNewWebhookRoutes mounts wrapper handlers for any webhook paths not yet on the mux. +func (m *Manager) MountNewWebhookRoutes(mux *http.ServeMux) { + m.mu.Lock() + defer m.mu.Unlock() + for path := range m.webhookMap { + if _, ok := m.mountedPaths[path]; ok { + continue + } + mux.Handle(path, http.HandlerFunc(m.WebhookServeHTTP)) + m.mountedPaths[path] = struct{}{} + slog.Info("webhook route mounted on gateway", "path", path) + } } diff --git a/internal/channels/manager_webhook_test.go b/internal/channels/manager_webhook_test.go new file mode 100644 index 00000000..eca9fd11 --- /dev/null +++ b/internal/channels/manager_webhook_test.go @@ -0,0 +1,108 @@ +package channels + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/bus" +) + +// mockWebhookChannel implements Channel + WebhookChannel for testing. +type mockWebhookChannel struct { + name string + path string + handler http.Handler +} + +func (m *mockWebhookChannel) Name() string { return m.name } +func (m *mockWebhookChannel) Type() string { return "mock" } +func (m *mockWebhookChannel) Start(context.Context) error { return nil } +func (m *mockWebhookChannel) Stop(context.Context) error { return nil } +func (m *mockWebhookChannel) Send(context.Context, bus.OutboundMessage) error { return nil } +func (m *mockWebhookChannel) IsRunning() bool { return true } +func (m *mockWebhookChannel) IsAllowed(string) bool { return true } +func (m *mockWebhookChannel) WebhookHandler() (string, http.Handler) { return m.path, m.handler } + +func TestWebhookRouteAfterDynamicRegistration(t *testing.T) { + msgBus := bus.New() + mgr := NewManager(msgBus) + mux := http.NewServeMux() + + // Initially no channels — nothing to mount. + mgr.MountNewWebhookRoutes(mux) + + // Register a webhook channel. + ch1 := &mockWebhookChannel{ + name: "test-ch1", + path: "/test/webhook", + handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }), + } + mgr.RegisterChannel(ch1.name, ch1) + + // Mount the new path. + mgr.MountNewWebhookRoutes(mux) + + // Verify the route works. + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/test/webhook", nil) + mux.ServeHTTP(w, r) + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + if w.Body.String() != "ok" { + t.Fatalf("expected body 'ok', got %q", w.Body.String()) + } + + // Test dynamic handler update: unregister old, register new with same path but different response. + mgr.UnregisterChannel(ch1.name) + ch2 := &mockWebhookChannel{ + name: "test-ch2", + path: "/test/webhook", + handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("updated")) + }), + } + mgr.RegisterChannel(ch2.name, ch2) + + // Do NOT call MountNewWebhookRoutes — path already mounted, delegation is dynamic. + w2 := httptest.NewRecorder() + r2 := httptest.NewRequest("POST", "/test/webhook", nil) + mux.ServeHTTP(w2, r2) + if w2.Code != 200 { + t.Fatalf("expected 200 after update, got %d", w2.Code) + } + if w2.Body.String() != "updated" { + t.Fatalf("expected body 'updated', got %q", w2.Body.String()) + } +} + +func TestWebhookServeHTTP_NotFound(t *testing.T) { + msgBus := bus.New() + mgr := NewManager(msgBus) + mux := http.NewServeMux() + + // Mount with no channels — path registered but no handler in webhookMap. + ch := &mockWebhookChannel{ + name: "temp", + path: "/temp/webhook", + handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("temp")) + }), + } + mgr.RegisterChannel(ch.name, ch) + mgr.MountNewWebhookRoutes(mux) + + // Remove the channel — webhookMap cleared, but mux route still exists. + mgr.UnregisterChannel(ch.name) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/temp/webhook", nil) + mux.ServeHTTP(w, r) + if w.Code != 404 { + t.Fatalf("expected 404 after unregister, got %d", w.Code) + } +} diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 615b3cdf..6f378f94 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -20,9 +20,24 @@ type ChannelsConfig struct { Zalo ZaloConfig `json:"zalo"` ZaloPersonal ZaloPersonalConfig `json:"zalo_personal"` Feishu FeishuConfig `json:"feishu"` + GoogleChat GoogleChatConfig `json:"google_chat"` PendingCompaction *PendingCompactionConfig `json:"pending_compaction,omitempty"` // global pending message compaction settings } +type GoogleChatConfig struct { + Enabled bool `json:"enabled"` + ServiceAccountJSON string `json:"service_account_json,omitempty"` // inline SA JSON content + ProjectNumber string `json:"project_number"` // for OIDC verification + WebhookPath string `json:"webhook_path,omitempty"` // default "/googlechat/events" + AllowFrom FlexibleStringSlice `json:"allow_from"` + DMPolicy string `json:"dm_policy,omitempty"` // "open" (default), "allowlist", "disabled" + GroupPolicy string `json:"group_policy,omitempty"` // "open" (default), "allowlist", "disabled" + RequireMention *bool `json:"require_mention,omitempty"` // require @bot in spaces (default true) + HistoryLimit int `json:"history_limit,omitempty"` // pending group messages (default 50) + ReactionLevel string `json:"reaction_level,omitempty"` // "off" (default), "minimal", "full" + BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) +} + type TelegramConfig struct { Enabled bool `json:"enabled"` Token string `json:"token"` diff --git a/internal/config/config_secrets.go b/internal/config/config_secrets.go index 6e6cf938..e69a6515 100644 --- a/internal/config/config_secrets.go +++ b/internal/config/config_secrets.go @@ -52,6 +52,7 @@ func (c *Config) MaskedCopy() *Config { maskNonEmpty(&cp.Channels.Feishu.AppSecret) maskNonEmpty(&cp.Channels.Feishu.EncryptKey) maskNonEmpty(&cp.Channels.Feishu.VerificationToken) + maskNonEmpty(&cp.Channels.GoogleChat.ServiceAccountJSON) // Mask TTS API keys maskNonEmpty(&cp.Tts.OpenAI.APIKey) @@ -102,6 +103,7 @@ func (c *Config) StripSecrets() { c.Channels.Feishu.AppSecret = "" c.Channels.Feishu.EncryptKey = "" c.Channels.Feishu.VerificationToken = "" + c.Channels.GoogleChat.ServiceAccountJSON = "" // TTS API keys c.Tts.OpenAI.APIKey = "" @@ -157,6 +159,7 @@ func (c *Config) StripMaskedSecrets() { stripIfMasked(&c.Channels.Feishu.AppSecret) stripIfMasked(&c.Channels.Feishu.EncryptKey) stripIfMasked(&c.Channels.Feishu.VerificationToken) + stripIfMasked(&c.Channels.GoogleChat.ServiceAccountJSON) // TTS API keys stripIfMasked(&c.Tts.OpenAI.APIKey) diff --git a/internal/gateway/methods/channel_instances.go b/internal/gateway/methods/channel_instances.go index 7f50ce98..2ad4d8d6 100644 --- a/internal/gateway/methods/channel_instances.go +++ b/internal/gateway/methods/channel_instances.go @@ -255,7 +255,7 @@ func maskInstance(inst store.ChannelInstanceData) map[string]any { // isValidChannelType checks if the channel type is supported. func isValidChannelType(ct string) bool { switch ct { - case "telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu": + case "telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu", "google_chat": return true } return false diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index d1d6e370..e220e0fc 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -462,7 +462,7 @@ func (h *ChannelInstancesHandler) handleResolveContacts(w http.ResponseWriter, r // isValidChannelType checks if the channel type is supported. func isValidChannelType(ct string) bool { switch ct { - case "telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu": + case "telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu", "google_chat": return true } return false diff --git a/ui/web/src/constants/channels.ts b/ui/web/src/constants/channels.ts index ba504285..762f5685 100644 --- a/ui/web/src/constants/channels.ts +++ b/ui/web/src/constants/channels.ts @@ -6,4 +6,5 @@ export const CHANNEL_TYPES = [ { value: "zalo_oa", label: "Zalo OA" }, { value: "zalo_personal", label: "Zalo Personal" }, { value: "whatsapp", label: "WhatsApp" }, + { value: "google_chat", label: "Google Chat" }, ] as const; diff --git a/ui/web/src/pages/channels/channel-fields.tsx b/ui/web/src/pages/channels/channel-fields.tsx index abb9950f..1f807fa0 100644 --- a/ui/web/src/pages/channels/channel-fields.tsx +++ b/ui/web/src/pages/channels/channel-fields.tsx @@ -59,7 +59,7 @@ function FieldRenderer({ const label = t(`fieldConfig.${field.key}.label`, { defaultValue: field.label }); const help = field.help ? t(`fieldConfig.${field.key}.help`, { defaultValue: field.help }) : ""; const labelSuffix = field.required && !isEdit ? " *" : ""; - const editHint = isEdit && field.type === "password" ? ` ${t("form.credentialsHint")}` : ""; + const editHint = isEdit && (field.type === "password" || field.type === "textarea") ? ` ${t("form.credentialsHint")}` : ""; switch (field.type) { case "text": @@ -240,6 +240,24 @@ function FieldRenderer({ ); + case "textarea": + return ( +
+ +