Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 13 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
# Test artifacts
tests/integration/testdata/

# Binary
openclaw-go
pkg-helper
# Binary (anchored to repo root so we don't accidentally ignore
# the cmd/pkg-helper/ source directory, which breaks Docker builds
# on uploaders that pattern-match .gitignore against the whole tree
# — notably `railway up`).
/openclaw-go
/pkg-helper
/goclaw.exe
/goclaw-local.exe

# Ad-hoc debug probes live here — never commit (they import internal/
# packages from outside cmd/ and only exist for one-shot diagnostics).
/tmp-reset-bot/
/tmp-probe*/

# IDE
.idea/
Expand Down
18 changes: 18 additions & 0 deletions .railwayignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.git
.github
.vscode
.idea
.claude
ui/desktop
ui/simple-saas
ui/web/node_modules
ui/web/dist
**/node_modules
plans
skills-store
docs
tests
tmp
tmp-*
*.exe
._*
206 changes: 206 additions & 0 deletions cmd/bitrix_portal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package cmd

import (
"database/sql"
"encoding/json"
"fmt"
"os"
"strings"

"github.com/google/uuid"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/spf13/cobra"

"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)

// bitrixPortalCmd wires `goclaw bitrix-portal ...` — direct-DB management of
// `bitrix_portals` rows. Phase 03 ships the OAuth install flow
// (`/bitrix24/install`) but no RPC/UI for seeding the portal row the install
// flow needs beforehand, so operators currently have no way to register a
// new portal without shelling into Postgres. This command fills that gap.
//
// Writes go through PGBitrixPortalStore so GOCLAW_ENCRYPTION_KEY is applied
// to the credentials column the same way the runtime would. Reads via `list`
// deliberately don't print secrets — credentials stay encrypted at rest, and
// a debug tool dumping them would be a regression.
func bitrixPortalCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "bitrix-portal",
Short: "Manage Bitrix24 portals (direct DB access; postgres only)",
Long: `Manage Bitrix24 portal rows in the database.

Phase 03 expects a ` + "`bitrix_portals`" + ` row to exist before an operator runs the
OAuth install flow at ` + "`/bitrix24/install`" + `. This command seeds that row without
requiring SQL access to the database.`,
}
cmd.AddCommand(bitrixPortalCreateCmd())
cmd.AddCommand(bitrixPortalListCmd())
return cmd
}

func bitrixPortalCreateCmd() *cobra.Command {
var (
tenantID string
name string
domain string
clientID string
clientSecret string
)
cmd := &cobra.Command{
Use: "create",
Short: "Create a bitrix_portals row with client_id/client_secret",
Long: `Create a new Bitrix24 portal registration.

After the row exists, direct the portal admin to
` + "`https://<public_url>/bitrix24/install?state=<tenant_id>:<name>`" + `
to authorize the app — the install handler writes the OAuth token into the
` + "`state`" + ` column of this same row.`,
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(name) == "" ||
strings.TrimSpace(domain) == "" || strings.TrimSpace(clientID) == "" ||
strings.TrimSpace(clientSecret) == "" {
return fmt.Errorf("--tenant-id, --name, --domain, --client-id, --client-secret are all required")
}
tid, err := uuid.Parse(tenantID)
if err != nil {
return fmt.Errorf("invalid --tenant-id: %w", err)
}
// Strip protocol + trailing slash from domain; Bitrix24 identifies
// the portal by bare host (e.g. `tamgiac.bitrix24.com`).
dom := normalizeBitrixDomain(domain)

dsn, err := resolveDSN()
if err != nil {
return err
}
db, err := sql.Open("pgx", dsn)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
if err := db.PingContext(cmd.Context()); err != nil {
return fmt.Errorf("ping db: %w", err)
}

encKey := os.Getenv("GOCLAW_ENCRYPTION_KEY")
if encKey == "" {
// Not fatal — pg store passes plaintext through when the key is
// empty — but the runtime gateway would also run unencrypted,
// which is almost never what a production deploy wants. Warn
// loud so the operator notices instead of silently storing
// client_secret as cleartext.
fmt.Fprintln(os.Stderr, "WARNING: GOCLAW_ENCRYPTION_KEY is not set — credentials will be stored UNENCRYPTED")
}

creds := store.BitrixPortalCredentials{
ClientID: clientID,
ClientSecret: clientSecret,
}
credsJSON, err := json.Marshal(creds)
if err != nil {
return fmt.Errorf("marshal credentials: %w", err)
}

portalStore := pg.NewPGBitrixPortalStore(db, encKey)
data := &store.BitrixPortalData{
TenantID: tid,
Name: name,
Domain: dom,
Credentials: credsJSON,
// State stays empty — it's populated by /bitrix24/install
// after the portal admin authorizes the app.
}
if err := portalStore.Create(cmd.Context(), data); err != nil {
return fmt.Errorf("create portal: %w", err)
}

fmt.Printf("Created bitrix_portals row:\n")
fmt.Printf(" id: %s\n", data.ID)
fmt.Printf(" tenant_id: %s\n", data.TenantID)
fmt.Printf(" name: %s\n", data.Name)
fmt.Printf(" domain: %s\n", data.Domain)
fmt.Printf("\nNext step — have the portal admin visit:\n")
fmt.Printf(" https://<public_url>/bitrix24/install?state=%s:%s\n", data.TenantID, data.Name)
fmt.Printf("(public_url must match the `public_url` field on the channel_instance config.)\n")
return nil
},
}
cmd.Flags().StringVar(&tenantID, "tenant-id", "", "Tenant UUID this portal belongs to (required)")
cmd.Flags().StringVar(&name, "name", "", "Short portal name, referenced by channel_instance.config.portal (required)")
cmd.Flags().StringVar(&domain, "domain", "", "Bitrix24 portal host, e.g. tamgiac.bitrix24.com (required)")
cmd.Flags().StringVar(&clientID, "client-id", "", "Bitrix24 application client_id / application_id (required)")
cmd.Flags().StringVar(&clientSecret, "client-secret", "", "Bitrix24 application client_secret / application key (required)")
return cmd
}

func bitrixPortalListCmd() *cobra.Command {
var tenantID string
cmd := &cobra.Command{
Use: "list",
Short: "List bitrix_portals rows (optionally scoped to one tenant)",
RunE: func(cmd *cobra.Command, args []string) error {
dsn, err := resolveDSN()
if err != nil {
return err
}
db, err := sql.Open("pgx", dsn)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
if err := db.PingContext(cmd.Context()); err != nil {
return fmt.Errorf("ping db: %w", err)
}

portalStore := pg.NewPGBitrixPortalStore(db, os.Getenv("GOCLAW_ENCRYPTION_KEY"))

var rows []store.BitrixPortalData
if tenantID == "" {
rows, err = portalStore.ListAllForLoader(cmd.Context())
} else {
tid, parseErr := uuid.Parse(tenantID)
if parseErr != nil {
return fmt.Errorf("invalid --tenant-id: %w", parseErr)
}
rows, err = portalStore.ListByTenant(cmd.Context(), tid)
}
if err != nil {
return fmt.Errorf("list portals: %w", err)
}

if len(rows) == 0 {
fmt.Println("(no portals)")
return nil
}
fmt.Printf("%-36s %-36s %-24s %s\n", "ID", "TENANT_ID", "NAME", "DOMAIN")
for _, r := range rows {
// Credentials deliberately not printed. If the runtime couldn't
// decrypt them the scan already logged a warning; we tag that
// case here so operators spot a corrupt row at a glance.
nameCol := r.Name
if len(r.Credentials) == 0 {
nameCol += " (creds:empty)"
}
fmt.Printf("%-36s %-36s %-24s %s\n", r.ID, r.TenantID, nameCol, r.Domain)
}
return nil
},
}
cmd.Flags().StringVar(&tenantID, "tenant-id", "", "Filter to one tenant UUID (optional)")
return cmd
}

// normalizeBitrixDomain strips scheme and trailing slashes so callers can paste
// either `https://tamgiac.bitrix24.com/` or bare `tamgiac.bitrix24.com` and get
// a consistent value in the DB. Bitrix24's OAuth callback compares the bare
// host, so storing it with scheme would silently break the install flow.
func normalizeBitrixDomain(raw string) string {
s := strings.TrimSpace(raw)
s = strings.TrimPrefix(s, "https://")
s = strings.TrimPrefix(s, "http://")
s = strings.TrimSuffix(s, "/")
return s
}

1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ func init() {
rootCmd.AddCommand(configCmd())
rootCmd.AddCommand(providersCmd())
rootCmd.AddCommand(channelsCmd())
rootCmd.AddCommand(bitrixPortalCmd())
rootCmd.AddCommand(cronCmd())
rootCmd.AddCommand(skillsCmd())
rootCmd.AddCommand(sessionsCmd())
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ require (
github.com/slack-go/slack v0.19.0
github.com/spf13/cobra v1.10.2
github.com/titanous/json5 v1.0.0
github.com/wailsapp/wails/v2 v2.11.0
github.com/wailsapp/wails/v2 v2.12.0
github.com/zalando/go-keyring v0.2.8
go.mau.fi/whatsmeow v0.0.0-20260327181659-02ec817e7cf4
go.opentelemetry.io/otel v1.40.0
Expand All @@ -51,6 +51,7 @@ require (
require (
cel.dev/expr v0.25.1 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
Expand Down
10 changes: 6 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ 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=
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
Expand Down Expand Up @@ -501,8 +503,8 @@ github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6N
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
Expand Down Expand Up @@ -647,8 +649,8 @@ gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 h1:2gap+Kh/3F47cO6hAu3idFvs
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633/go.mod h1:5DMfjtclAbTIjbXqO1qCe2K5GKKxWz2JHvCChuTcJEM=
honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0 h1:5SXjd4ET5dYijLaf0O3aOenC0Z4ZafIWSpjUzsQaNho=
honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0/go.mod h1:EPDDhEZqVHhWuPI5zPAsjU0U7v9xNIWjoOVyZ5ZcniQ=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 h1:eeH1AIcPvSc0Z25ThsYF+Xoqbn0CI/YnXVYoTLFdGQw=
howett.net/plist v1.0.2-0.20250314012144-ee69052608d9/go.mod h1:fyFX5Hj5tP1Mpk8obqA9MZgXT416Q5711SDT7dQLTLk=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
Expand Down
4 changes: 3 additions & 1 deletion internal/agent/loop_mcp_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,10 @@ func (l *Loop) getUserMCPTools(ctx context.Context, userID string) []tools.Tool
// Create BridgeTools pointing to user's connection and register in the
// shared tool registry so ExecuteWithContext can resolve them by name.
reg, _ := l.tools.(*tools.Registry)
hints := mcpbridge.ParseToolHints(srv.Settings)
for _, mcpTool := range entry.MCPTools() {
bt := mcpbridge.NewBridgeTool(srv.Name, mcpTool, entry.ClientPtr(), srv.ToolPrefix, srv.TimeoutSec, entry.Connected(), srv.ID, l.mcpGrantChecker)
bt := mcpbridge.NewBridgeTool(srv.Name, mcpTool, entry.ClientPtr(), srv.ToolPrefix, srv.TimeoutSec, entry.Connected(), srv.ID, l.mcpGrantChecker).
WithHints(hints.Global, hints.HintFor(mcpTool.Name))
// Register in registry so ExecuteWithContext can find them.
// Skip if already registered (another user loaded this server with same tool names).
if reg != nil {
Expand Down
8 changes: 7 additions & 1 deletion internal/gateway/methods/channel_instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,9 +277,15 @@ func maskInstance(inst store.ChannelInstanceData) map[string]any {
}

// isValidChannelType checks if the channel type is supported.
//
// Keep this list in sync with the HTTP twin in internal/http/channel_instances.go
// and with CHANNEL_TYPES in ui/web/src/constants/channels.ts. When the two
// backend switches drift (as happened with facebook/pancake/bitrix24), the
// WS-driven UI rejects channels the HTTP API accepts, and the dropdown offers
// channels neither API accepts.
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", "facebook", "pancake", "bitrix24":
return true
}
return false
Expand Down
26 changes: 26 additions & 0 deletions internal/gateway/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,17 @@ func (s *Server) BuildMux() *http.ServeMux {
}

// Embedded web UI (built with -tags embedui). Catch-all after all API routes.
// When the build does NOT include the embedui tag, webui.Handler() returns nil
// and there's no handler for "/" — http.ServeMux would then return an opaque
// 404 for the root URL, confusing operators who open the deployed URL in a
// browser to check the service. Install a minimal JSON index handler in that
// case so the root responds with something useful (and any unmatched path
// still returns 404, just with a JSON body).
if h := webui.Handler(); h != nil {
mux.Handle("/", h)
slog.Info("serving embedded web UI")
} else {
mux.HandleFunc("/", s.handleIndex)
}

s.mux = mux
Expand Down Expand Up @@ -372,6 +380,24 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"status":"ok","protocol":%d}`, protocol.ProtocolVersion)
}

// handleIndex is the fallback "/" handler when no embedded web UI is present.
// It returns a small JSON service-info document for exact-match "/" requests
// and a JSON 404 for everything else — http.ServeMux routes "/" as a
// catch-all, so unrelated paths fall through here too.
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/" {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"not found"}`))
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w,
`{"service":"goclaw","status":"ok","protocol":%d,`+
`"endpoints":["/health","/v1/chat/completions","/v1/responses","/v1/tools/invoke","/ws"]}`,
protocol.ProtocolVersion)
}

// clientIP extracts the real client IP from the request, checking proxy headers first.
func clientIP(r *http.Request) string {
if ip := r.Header.Get("X-Real-IP"); ip != "" {
Expand Down
6 changes: 5 additions & 1 deletion internal/http/channel_instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -554,9 +554,13 @@ func (h *ChannelInstancesHandler) handleResolveContacts(w http.ResponseWriter, r
}

// isValidChannelType checks if the channel type is supported.
//
// Keep this list in sync with the WS twin in
// internal/gateway/methods/channel_instances.go and with CHANNEL_TYPES in
// ui/web/src/constants/channels.ts.
func isValidChannelType(ct string) bool {
switch ct {
case "telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu", "facebook", "pancake":
case "telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu", "facebook", "pancake", "bitrix24":
return true
}
return false
Expand Down
Loading