Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
6748771
feat(channels): add Bitrix24 channel integration (Phase 03)
tech-synity Apr 21, 2026
736721b
refactor(bitrix24): clarify MarkStopped override + simplify stripMention
tech-synity Apr 21, 2026
ec6f4cf
fix(build): anchor pkg-helper gitignore rule to repo root
tech-synity Apr 21, 2026
e9c9d67
feat(gateway): serve JSON index at / when no embedded UI
tech-synity Apr 21, 2026
6d78482
feat(ui/channels): add Bitrix24 to channel picker + schemas
tech-synity Apr 21, 2026
f28f5ed
feat(cmd): add `goclaw bitrix-portal create` for seeding portal rows
tech-synity Apr 21, 2026
1727134
fix(channels): add bitrix24 to channel_type whitelist (+ facebook/pan…
tech-synity Apr 21, 2026
d5c1258
feat(bitrix24): support Local Application install flow
tech-synity Apr 21, 2026
f4d7f2b
feat(bitrix24): log install + event entry for observability
tech-synity Apr 21, 2026
715859b
fix(bitrix24): call BX24.installFinish() to unblock event delivery
tech-synity Apr 22, 2026
4c16f6d
chore: ignore local build artifacts + Railway-exclude debug probe dirs
tech-synity Apr 22, 2026
2624205
chore(deps): bump wailsapp/wails v2.11.0 → v2.12.0
tech-synity Apr 22, 2026
dcea69e
fix(providers): set additionalProperties:false on bare object schemas…
tech-synity Apr 22, 2026
e8763a4
feat(bitrix24): convert Markdown to BBCode before sending chat chunks
tech-synity Apr 22, 2026
078421b
feat(bitrix24): bootstrap app_token from first event + opt-in raw dump
tech-synity Apr 22, 2026
5261cd7
refactor(bitrix24): phase A — remove parallel MCP mapping layer
tech-synity Apr 22, 2026
8836006
refactor(bitrix24): phase B — wire channel into partner's ContactColl…
tech-synity Apr 22, 2026
cf368cd
feat(bitrix24): configurable bot_type (B internal / O open channel)
tech-synity Apr 22, 2026
d0ae8c5
feat(bitrix24): phase C — lazy MCP provisioner with Open Channel skip
tech-synity Apr 22, 2026
02dcfd3
refactor(bitrix24): per-server MCP admin token + user degradation notice
tech-synity Apr 22, 2026
3975ab8
feat(bitrix24): resolve contact names via user.get
tech-synity Apr 22, 2026
6226249
feat(ui): expose bot_type + MCP config fields for bitrix24 channel
tech-synity Apr 22, 2026
596e5ad
refactor(bitrix24): drop admin token, use Bitrix access_token as MCP …
tech-synity Apr 23, 2026
7e7fb48
docs(bitrix24): add Rev5 MCP integration plan — Path B shipped
tech-synity Apr 23, 2026
d8fec86
feat(mcp): admin-authored tool description hints via Settings.tool_hints
tech-synity Apr 23, 2026
7c1cdf3
fix(bitrix24): detect group @mention via MENTIONED_LIST + MESSAGE_ORI…
tech-synity Apr 26, 2026
4224cbe
fix(bitrix24): classify CHAT_TYPE=X (Tasks/entity chat) as group
tech-synity Apr 26, 2026
f28bda0
feat(bitrix24): forward CHAT_ENTITY_TYPE + CHAT_ENTITY_ID to bus meta…
tech-synity Apr 26, 2026
237dd80
feat(gateway): inject Bitrix24 entity binding hint into agent system …
tech-synity Apr 26, 2026
b709e74
feat(bitrix24): @mention asker in group reply for multi-user clarity
tech-synity Apr 27, 2026
9c9f0c7
fix(agent): use SenderID for per-user MCP credential lookup in group …
tech-synity Apr 27, 2026
e3ed2d1
fix(bitrix24): scope MCP server lookup by channel tenant_id
tech-synity Apr 28, 2026
4a3c242
feat(bitrix24): BITRIX24_FORCE_REREGISTER env to refresh public_url
tech-synity Apr 28, 2026
1936ca9
fix(bitrix24): sanitize webhook entity metadata before system-prompt …
tech-synity Apr 28, 2026
8c29149
fix(bitrix24): explicit migration-missing warning in BootstrapPortals
tech-synity Apr 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
25 changes: 25 additions & 0 deletions .railwayignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.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
._*
_statics
_readmes
examples
CHANGELOG.md
CONTRIBUTING.md
api-reference.md
websocket-protocol.md
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
}

32 changes: 32 additions & 0 deletions cmd/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/consolidation"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
kg "github.com/nextlevelbuilder/goclaw/internal/knowledgegraph"
"github.com/nextlevelbuilder/goclaw/internal/channels/bitrix24"
"github.com/nextlevelbuilder/goclaw/internal/channels/discord"
"github.com/nextlevelbuilder/goclaw/internal/channels/facebook"
"github.com/nextlevelbuilder/goclaw/internal/channels/pancake"
Expand Down Expand Up @@ -466,9 +467,40 @@ func runGateway() {
instanceLoader.RegisterFactory(channels.TypeSlack, slackchannel.FactoryWithPendingStore(pgStores.PendingMessages))
instanceLoader.RegisterFactory(channels.TypeFacebook, facebook.Factory)
instanceLoader.RegisterFactory(channels.TypePancake, pancake.Factory)
// Bitrix24: factory needs the portal store + encKey injected so each
// Channel can resolve its portal on Start(). The encKey here mirrors
// the one used by pg.NewPGStores → NewPGBitrixPortalStore.
bitrixEncKey := os.Getenv("GOCLAW_ENCRYPTION_KEY")
// Use the MCP-aware factory variant so channels that opt into
// lazy per-user credential provisioning (via mcp_server_name +
// mcp_base_url in their instance config) can reach the partner's
// MCPServerStore. The MCP server authenticates each onboard call
// via the caller-supplied Bitrix access_token (Path B) — no shared
// admin secret is required. Channels with none of those set operate
// identically to before — the MCPStore arg is nil-safe inside the
// factory.
instanceLoader.RegisterFactory(channels.TypeBitrix24, bitrix24.FactoryWithPortalStoreAndMCP(pgStores.BitrixPortals, pgStores.MCP, bitrixEncKey))
if err := instanceLoader.LoadAll(context.Background()); err != nil {
slog.Error("failed to load channel instances from DB", "error", err)
}

// Warm the shared Bitrix24 router with every portal row so inbound
// webhooks land on the right *Portal even before a channel instance
// is loaded for that portal. Idempotent; no-op on sqlite-lite.
if pgStores.BitrixPortals != nil {
if err := bitrix24.BootstrapPortals(context.Background(), pgStores.BitrixPortals, bitrixEncKey); err != nil {
// Surface the missing-table case loudly so an operator notices
// without having to grep logs — bitrix24 channels silently
// no-op until `goclaw migrate up` runs migration 000058.
if strings.Contains(err.Error(), "bitrix_portals") &&
(strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "no such table")) {
slog.Warn("bitrix24 bootstrap skipped — bitrix_portals table missing; run `goclaw migrate up` (migration 000058) to enable Bitrix24 channels",
"err", err)
} else {
slog.Warn("bitrix24 bootstrap failed", "err", err)
}
}
}
}

// Register config-based channels as fallback when no DB instances loaded.
Expand Down
63 changes: 63 additions & 0 deletions cmd/gateway_consumer_normal.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,14 @@ func processNormalMessage(
if mid := msg.Metadata["message_id"]; mid != "" {
outMeta["reply_to_message_id"] = mid
}
// Address the asker so multi-user group chats render a clear "this
// reply is for X" signal. Today this is Bitrix24-specific (channel
// renders [USER=<id>][/USER] BBCode); other channels ignore the key.
// Skip synthetic senders (ticker, notification, system) — those have
// no real user to @mention.
if msg.SenderID != "" && !bus.IsInternalSender(msg.SenderID) {
outMeta["bitrix_address_user_id"] = msg.SenderID
}
}

// Register run with channel manager for streaming/reaction event forwarding.
Expand Down Expand Up @@ -265,6 +273,42 @@ func processNormalMessage(
extraPrompt += identity
}

// Append Bitrix24 entity binding hint so MCP-equipped agents can resolve
// "this deal/task/lead" deterministically. The channel layer (bitrix24/handle.go)
// forwards data[PARAMS][CHAT_ENTITY_TYPE] + CHAT_ENTITY_ID into Metadata
// whenever the chat is bound to a Bitrix24 module entity. Plain user-created
// chats omit both keys → no hint added (avoids polluting unrelated chats).
//
// We deliberately keep this simple "system prompt injection" approach for now.
// The LLM still has to call MCP tools to fetch fresh data — we only tell it
// WHICH entity is in scope, not WHAT the data is. See
// plans/bitrix24-mcp-refactor/reports/event-payloads.md for the metadata
// contract and the phase plan for the optional pre-fetch upgrade.
if et, eid := msg.Metadata["bitrix_chat_entity_type"], msg.Metadata["bitrix_chat_entity_id"]; et != "" && eid != "" {
// Defense-in-depth against prompt injection from webhook-sourced metadata.
// Bitrix server-side normally constrains these to short alphanumeric ids
// (e.g. "DEAL|2064", "TASKS"), but treating them as untrusted prevents a
// malicious or compromised portal from steering the system prompt via
// crafted CHAT_ENTITY_ID values.
if isSafeBitrixEntityToken(et, 64) && isSafeBitrixEntityToken(eid, 128) {
if extraPrompt != "" {
extraPrompt += "\n\n"
}
extraPrompt += fmt.Sprintf(
"## Channel context — Bitrix24 entity binding\n"+
"This chat is bound to a Bitrix24 entity (type=%s, id=%s).\n"+
"When the user refers to \"this deal\", \"this task\", \"this lead\", or similar deictic phrases, treat them as referring to id %s.\n"+
"CRM ids use pipe format (e.g. \"DEAL|2064\" — split on '|' and use the numeric part with the matching MCP tool such as crm.deal.get / crm.lead.get / crm.contact.get / crm.company.get).\n"+
"Tasks ids are plain numbers — pass directly to tasks.task.get.\n"+
"Do not ask the user which deal/task this is; you already know.",
et, eid, eid,
)
} else {
slog.Warn("security.bitrix24.entity_metadata_rejected",
"channel", msg.Channel, "et_len", len(et), "eid_len", len(eid))
}
}

// Per-topic skill filter override (from group/topic config hierarchy).
var skillFilter []string
if ts := msg.Metadata[tools.MetaTopicSkills]; ts != "" {
Expand Down Expand Up @@ -529,3 +573,22 @@ func processNormalMessage(
}
}(agentID, msg.Channel, msg.ChatID, sessionKey, runID, peerKind, msg.Content, outMeta, blockReply, ptd, msg.TenantID, agentLoop.UUID(), agentLoop.OtherConfig())
}

// isSafeBitrixEntityToken validates a webhook-sourced Bitrix entity token before
// it is interpolated into the agent system prompt. Rejects empty, oversized, or
// control-character payloads to prevent prompt-injection from a crafted portal
// event. Allowed character set is intentionally permissive (Bitrix entity ids
// include letters, digits, '|', '_', '-') — the goal is to block newlines and
// formatting characters that could break out of the prompt template, not to
// enforce a strict id grammar.
func isSafeBitrixEntityToken(s string, maxLen int) bool {
if s == "" || len(s) > maxLen {
return false
}
for _, r := range s {
if r < 0x20 || r == 0x7f {
return false
}
}
return true
}
Loading