mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 20:14:50 +00:00
353 lines
13 KiB
Go
353 lines
13 KiB
Go
package methods
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// channelInstanceAllowed mirrors the HTTP allowlist in internal/http/validate.go.
|
|
var channelInstanceAllowed = map[string]bool{
|
|
"channel_type": true, "credentials": true, "agent_id": true,
|
|
"enabled": true, "group_policy": true, "allow_from": true,
|
|
"metadata": true, "webhook_secret": true, "config": true,
|
|
"display_name": true,
|
|
}
|
|
|
|
// OrphanChannelCleaner runs channel-type-specific cleanup when a delete
|
|
// arrives for a channel that is no longer loaded in the runtime Manager
|
|
// (e.g. admin disabled it earlier). Closure injected from cmd/gateway.go.
|
|
// See identical type in internal/http/channel_instances.go.
|
|
type OrphanChannelCleaner func(ctx context.Context, tenantID uuid.UUID, configJSON []byte) error
|
|
|
|
// ChannelInstancesMethods handles channel instance CRUD via WebSocket RPC.
|
|
// agentStore is held so the create/update handlers can resolve agent_key or
|
|
// UUID input via resolveAgentUUIDCached.
|
|
type ChannelInstancesMethods struct {
|
|
store store.ChannelInstanceStore
|
|
agentStore store.AgentStore
|
|
msgBus *bus.MessageBus
|
|
eventBus bus.EventPublisher
|
|
channelMgr *channels.Manager // optional — enables ChannelDestroyer hook on delete
|
|
// orphanCleaners is keyed by channel_type; called when channelMgr.GetChannel
|
|
// returns false (channel was unloaded — typically due to disable).
|
|
orphanCleaners map[string]OrphanChannelCleaner
|
|
}
|
|
|
|
// NewChannelInstancesMethods creates a new handler for channel instance management.
|
|
// channelMgr is optional; when non-nil and the channel's runtime impl
|
|
// satisfies channels.ChannelDestroyer, handleDelete invokes Destroy() before
|
|
// removing the DB row so external resources (e.g. Bitrix24 bots) get cleaned.
|
|
func NewChannelInstancesMethods(s store.ChannelInstanceStore, as store.AgentStore, msgBus *bus.MessageBus, eventBus bus.EventPublisher, channelMgr *channels.Manager) *ChannelInstancesMethods {
|
|
return &ChannelInstancesMethods{store: s, agentStore: as, msgBus: msgBus, eventBus: eventBus, channelMgr: channelMgr}
|
|
}
|
|
|
|
// RegisterOrphanCleaner registers a per-channel-type cleanup function that
|
|
// fires when handleDelete sees a channel NOT loaded in Manager (typically
|
|
// because admin disabled it). See HTTP twin for full rationale.
|
|
func (m *ChannelInstancesMethods) RegisterOrphanCleaner(channelType string, fn OrphanChannelCleaner) {
|
|
if m.orphanCleaners == nil {
|
|
m.orphanCleaners = make(map[string]OrphanChannelCleaner)
|
|
}
|
|
m.orphanCleaners[channelType] = fn
|
|
}
|
|
|
|
// Register registers all channel instance RPC methods.
|
|
func (m *ChannelInstancesMethods) Register(router *gateway.MethodRouter) {
|
|
router.Register(protocol.MethodChannelInstancesList, m.handleList)
|
|
router.Register(protocol.MethodChannelInstancesGet, m.handleGet)
|
|
router.Register(protocol.MethodChannelInstancesCreate, m.handleCreate)
|
|
router.Register(protocol.MethodChannelInstancesUpdate, m.handleUpdate)
|
|
router.Register(protocol.MethodChannelInstancesDelete, m.handleDelete)
|
|
}
|
|
|
|
func (m *ChannelInstancesMethods) emitCacheInvalidate(key string) {
|
|
if m.msgBus == nil {
|
|
return
|
|
}
|
|
m.msgBus.Broadcast(bus.Event{
|
|
Name: protocol.EventCacheInvalidate,
|
|
Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindChannelInstances, Key: key},
|
|
})
|
|
}
|
|
|
|
func (m *ChannelInstancesMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
instances, err := m.store.ListAll(ctx)
|
|
if err != nil {
|
|
slog.Error("channels.instances.list", "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "channel instances")))
|
|
return
|
|
}
|
|
|
|
// Mask credentials in response — never expose secrets via WS.
|
|
result := make([]map[string]any, 0, len(instances))
|
|
for _, inst := range instances {
|
|
result = append(result, maskInstance(inst))
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"instances": result,
|
|
}))
|
|
}
|
|
|
|
func (m *ChannelInstancesMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if req.Params != nil {
|
|
json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
|
|
id, err := uuid.Parse(params.ID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "instance")))
|
|
return
|
|
}
|
|
|
|
inst, err := m.store.Get(ctx, id)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgInstanceNotFound)))
|
|
return
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, maskInstance(*inst)))
|
|
}
|
|
|
|
func (m *ChannelInstancesMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params struct {
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"display_name"`
|
|
ChannelType string `json:"channel_type"`
|
|
AgentID string `json:"agent_id"`
|
|
Credentials json.RawMessage `json:"credentials"`
|
|
Config json.RawMessage `json:"config"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if req.Params != nil {
|
|
json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
|
|
if params.Name == "" || params.ChannelType == "" || params.AgentID == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name, channel_type, and agent_id")))
|
|
return
|
|
}
|
|
|
|
if !isValidChannelType(params.ChannelType) {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidChannelType)))
|
|
return
|
|
}
|
|
|
|
// Accept both agent_key and UUID via resolveAgentUUIDCached. Router is nil
|
|
// here because channel_instances methods are not wired to the router —
|
|
// falls back to a pure DB lookup, acceptable given create is a rare op.
|
|
agentID, err := resolveAgentUUIDCached(ctx, nil, m.agentStore, params.AgentID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "agent_id")))
|
|
return
|
|
}
|
|
|
|
enabled := true
|
|
if params.Enabled != nil {
|
|
enabled = *params.Enabled
|
|
}
|
|
|
|
inst := &store.ChannelInstanceData{
|
|
Name: params.Name,
|
|
DisplayName: params.DisplayName,
|
|
ChannelType: params.ChannelType,
|
|
AgentID: agentID,
|
|
Credentials: params.Credentials,
|
|
Config: config.NormalizeChannelInstanceConfigRaw(params.ChannelType, params.Config),
|
|
Enabled: enabled,
|
|
}
|
|
|
|
if err := m.store.Create(ctx, inst); err != nil {
|
|
slog.Error("channels.instances.create", "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "instance", err.Error())))
|
|
return
|
|
}
|
|
|
|
m.emitCacheInvalidate(inst.ID.String())
|
|
emitAudit(m.eventBus, client, "channel_instance.created", "channel_instance", inst.ID.String())
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, maskInstance(*inst)))
|
|
}
|
|
|
|
func (m *ChannelInstancesMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params struct {
|
|
ID string `json:"id"`
|
|
Updates json.RawMessage `json:"updates"`
|
|
}
|
|
if req.Params != nil {
|
|
json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
|
|
id, err := uuid.Parse(params.ID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "instance")))
|
|
return
|
|
}
|
|
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(params.Updates, &raw); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidUpdates)))
|
|
return
|
|
}
|
|
|
|
// Allowlist: only permit known channel instance columns (matches HTTP handler).
|
|
updates := make(map[string]any, len(raw))
|
|
for k, v := range raw {
|
|
if channelInstanceAllowed[k] {
|
|
updates[k] = v
|
|
} else {
|
|
slog.Warn("security.filtered_unknown_field", "field", k, "handler", "channels.instances.update")
|
|
}
|
|
}
|
|
m.normalizeChannelInstanceConfigUpdate(ctx, id, updates)
|
|
|
|
if err := m.store.Update(ctx, id, updates); err != nil {
|
|
slog.Error("channels.instances.update", "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "instance", err.Error())))
|
|
return
|
|
}
|
|
|
|
m.emitCacheInvalidate("")
|
|
emitAudit(m.eventBus, client, "channel_instance.updated", "channel_instance", id.String())
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"status": "updated"}))
|
|
}
|
|
|
|
func (m *ChannelInstancesMethods) normalizeChannelInstanceConfigUpdate(ctx context.Context, id uuid.UUID, updates map[string]any) {
|
|
value, ok := updates["config"]
|
|
if !ok {
|
|
return
|
|
}
|
|
channelType, _ := updates["channel_type"].(string)
|
|
if channelType == "" {
|
|
if inst, err := m.store.Get(ctx, id); err == nil {
|
|
channelType = inst.ChannelType
|
|
}
|
|
}
|
|
updates["config"] = config.NormalizeChannelInstanceConfigValue(channelType, value)
|
|
}
|
|
|
|
func (m *ChannelInstancesMethods) handleDelete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if req.Params != nil {
|
|
json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
|
|
id, err := uuid.Parse(params.ID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "instance")))
|
|
return
|
|
}
|
|
|
|
// Look up instance to check if it's a default (seeded) instance.
|
|
inst, err := m.store.Get(ctx, id)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInstanceNotFound)))
|
|
return
|
|
}
|
|
if store.IsDefaultChannelInstance(inst.Name) {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCannotDeleteDefaultInst)))
|
|
return
|
|
}
|
|
|
|
// Best-effort: notify the channel impl so external resources (e.g. the
|
|
// Bitrix24 imbot.register'd bot) get cleaned up BEFORE the DB row is
|
|
// removed. Mirror of HTTP handler — see internal/http/channel_instances.go
|
|
// for full rationale on the two branches.
|
|
if m.channelMgr != nil {
|
|
if ch, ok := m.channelMgr.GetChannel(inst.Name); ok {
|
|
if destroyer, ok := ch.(channels.ChannelDestroyer); ok {
|
|
if err := destroyer.Destroy(ctx); err != nil {
|
|
slog.Warn("channels.instances.delete: destroyer failed — proceeding with DB delete",
|
|
"name", inst.Name, "tenant_id", inst.TenantID, "type", inst.ChannelType, "err", err)
|
|
}
|
|
}
|
|
} else if cleaner, ok := m.orphanCleaners[inst.ChannelType]; ok && cleaner != nil {
|
|
if err := cleaner(ctx, inst.TenantID, inst.Config); err != nil {
|
|
slog.Warn("channels.instances.delete: orphan cleaner failed — proceeding with DB delete",
|
|
"name", inst.Name, "tenant_id", inst.TenantID, "type", inst.ChannelType, "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := m.store.Delete(ctx, id); err != nil {
|
|
slog.Error("channels.instances.delete", "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToDelete, "instance", err.Error())))
|
|
return
|
|
}
|
|
|
|
m.emitCacheInvalidate("")
|
|
emitAudit(m.eventBus, client, "channel_instance.deleted", "channel_instance", id.String())
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"status": "deleted"}))
|
|
}
|
|
|
|
// maskInstance returns a map representation with credentials masked.
|
|
func maskInstance(inst store.ChannelInstanceData) map[string]any {
|
|
result := map[string]any{
|
|
"id": inst.ID,
|
|
"name": inst.Name,
|
|
"display_name": inst.DisplayName,
|
|
"channel_type": inst.ChannelType,
|
|
"agent_id": inst.AgentID,
|
|
"config": inst.Config,
|
|
"enabled": inst.Enabled,
|
|
"is_default": store.IsDefaultChannelInstance(inst.Name),
|
|
"has_credentials": len(inst.Credentials) > 0,
|
|
"created_by": inst.CreatedBy,
|
|
"created_at": inst.CreatedAt,
|
|
"updated_at": inst.UpdatedAt,
|
|
}
|
|
|
|
// Mask credentials: show keys with "***" values
|
|
if len(inst.Credentials) > 0 {
|
|
var raw map[string]any
|
|
if json.Unmarshal(inst.Credentials, &raw) == nil {
|
|
masked := make(map[string]any, len(raw))
|
|
for k := range raw {
|
|
masked[k] = "***"
|
|
}
|
|
result["credentials"] = masked
|
|
} else {
|
|
result["credentials"] = map[string]string{}
|
|
}
|
|
} else {
|
|
result["credentials"] = map[string]string{}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// 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", "facebook", "pancake", "bitrix24":
|
|
return true
|
|
}
|
|
return false
|
|
}
|