Files

251 lines
8.7 KiB
Go
Raw Permalink Normal View History

package im
import (
"crypto/sha256"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/google/uuid"
"gorm.io/gorm"
)
// IMChannel represents an IM channel configuration stored in the database.
// Each channel binds to an agent and contains platform-specific credentials.
type IMChannel struct {
ID string `json:"id" gorm:"type:varchar(36);primaryKey;default:uuid_generate_v4()"`
TenantID uint64 `json:"tenant_id" gorm:"not null;index:idx_im_channels_tenant"`
AgentID string `json:"agent_id" gorm:"type:varchar(36);not null;index:idx_im_channels_agent"`
Platform string `json:"platform" gorm:"type:varchar(20);not null"`
Name string `json:"name" gorm:"type:varchar(255);not null;default:''"`
Enabled bool `json:"enabled" gorm:"not null"`
Mode string `json:"mode" gorm:"type:varchar(20);not null;default:'websocket'"`
OutputMode string `json:"output_mode" gorm:"type:varchar(20);not null;default:'stream'"`
KnowledgeBaseID string `json:"knowledge_base_id" gorm:"type:varchar(36);default:''"`
BotIdentity string `json:"bot_identity" gorm:"type:varchar(255);not null;default:'';uniqueIndex:idx_im_channels_bot_identity,where:deleted_at IS NULL AND bot_identity != ''"`
SessionMode string `json:"session_mode" gorm:"type:varchar(20);not null;default:'user'"`
Credentials types.JSON `json:"credentials" gorm:"type:jsonb;not null;default:'{}'"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
}
func (IMChannel) TableName() string {
return "im_channels"
}
// IMChannelSummary is the HTTP-safe list shape for IM channels. Credentials
// are never included — use Admin+ Create/Update responses to read back
// values immediately after a mutation.
type IMChannelSummary struct {
ID string `json:"id"`
TenantID uint64 `json:"tenant_id"`
AgentID string `json:"agent_id"`
Platform string `json:"platform"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
OutputMode string `json:"output_mode"`
KnowledgeBaseID string `json:"knowledge_base_id"`
BotIdentity string `json:"bot_identity"`
SessionMode string `json:"session_mode"`
CredentialsConfigured bool `json:"credentials_configured"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// SummarizeIMChannel converts a stored channel into its list response shape.
func SummarizeIMChannel(ch IMChannel) IMChannelSummary {
return IMChannelSummary{
ID: ch.ID,
TenantID: ch.TenantID,
AgentID: ch.AgentID,
Platform: ch.Platform,
Name: ch.Name,
Enabled: ch.Enabled,
Mode: ch.Mode,
OutputMode: ch.OutputMode,
KnowledgeBaseID: ch.KnowledgeBaseID,
BotIdentity: ch.BotIdentity,
SessionMode: ch.SessionMode,
CredentialsConfigured: imCredentialsConfigured(ch.Credentials),
CreatedAt: ch.CreatedAt,
UpdatedAt: ch.UpdatedAt,
}
}
// SummarizeIMChannels converts stored channels into list response shapes.
func SummarizeIMChannels(channels []IMChannel) []IMChannelSummary {
out := make([]IMChannelSummary, 0, len(channels))
for _, ch := range channels {
out = append(out, SummarizeIMChannel(ch))
}
return out
}
func imCredentialsConfigured(cred types.JSON) bool {
s := strings.TrimSpace(string(cred))
return s != "" && s != "{}"
}
func (ch *IMChannel) BeforeCreate(tx *gorm.DB) error {
if ch.ID == "" {
ch.ID = uuid.New().String()
}
if ch.Mode == "" {
if ch.Platform == "mattermost" || ch.Platform == "yunzhijia" {
ch.Mode = "webhook"
} else {
ch.Mode = "websocket"
}
}
if ch.OutputMode == "" {
ch.OutputMode = "stream"
}
if ch.SessionMode == "" {
ch.SessionMode = string(SessionModeUser)
}
if err := ch.validateSessionMode(); err != nil {
return err
}
ch.BotIdentity = ch.computeBotIdentity()
return nil
}
// BeforeSave ensures bot_identity is recomputed and session_mode is validated
// on every save (create + update).
func (ch *IMChannel) BeforeSave(tx *gorm.DB) error {
if ch.SessionMode == "" {
ch.SessionMode = string(SessionModeUser)
}
if err := ch.validateSessionMode(); err != nil {
return err
}
ch.BotIdentity = ch.computeBotIdentity()
return nil
}
// validateSessionMode checks that SessionMode holds a supported value.
func (ch *IMChannel) validateSessionMode() error {
switch SessionMode(ch.SessionMode) {
case SessionModeUser, SessionModeThread:
return nil
default:
return fmt.Errorf("invalid session_mode: %s", ch.SessionMode)
}
}
// computeBotIdentity derives a unique bot identity string from the channel's
// platform, mode, and credentials. Returns "" if no identity can be extracted.
func (ch *IMChannel) computeBotIdentity() string {
creds := make(map[string]interface{})
if err := json.Unmarshal([]byte(ch.Credentials), &creds); err != nil {
return ""
}
str := func(key string) string {
if v, ok := creds[key]; ok {
switch val := v.(type) {
case string:
return val
case float64:
return fmt.Sprintf("%.0f", val)
}
}
return ""
}
switch ch.Platform {
case "wecom":
switch ch.Mode {
case "websocket":
if botID := str("bot_id"); botID != "" {
return "wecom:ws:" + botID
}
case "webhook":
corpID := str("corp_id")
agentID := str("corp_agent_id")
if corpID != "" && agentID != "" {
return "wecom:wh:" + corpID + ":" + agentID
}
}
// Feishu and Lark app_ids live in separate clouds and never collide, so the
// platform prefix keeps the same app_id on both from looking like one bot.
case "feishu", "lark":
if appID := str("app_id"); appID != "" {
return ch.Platform + ":" + appID
}
case "telegram":
if botToken := str("bot_token"); botToken != "" {
// Use the bot ID part (before the colon) as identity.
if idx := strings.Index(botToken, ":"); idx > 0 {
return "telegram:" + botToken[:idx]
}
return "telegram:" + botToken
}
case "dingtalk":
if clientID := str("client_id"); clientID != "" {
return "dingtalk:" + clientID
}
case "mattermost":
if tok := str("outgoing_token"); tok != "" {
return "mattermost:wh:" + tok
}
case "wechat":
if botID := str("ilink_bot_id"); botID != "" {
return "wechat:" + botID
}
case "qqbot":
if appID := str("app_id"); appID != "" {
return "qqbot:" + appID
}
case "yunzhijia":
if sendMsgURL := str("send_msg_url"); sendMsgURL != "" {
parsed, err := url.Parse(sendMsgURL)
if err != nil {
return ""
}
if token := strings.TrimSpace(parsed.Query().Get("yzjtoken")); token != "" {
return fmt.Sprintf("yunzhijia:%x", sha256.Sum256([]byte(token)))
}
}
}
return ""
}
// ChannelSession maps an IM channel (user+chat combination) to a WeKnora session.
// This allows the IM integration to maintain conversation continuity.
type ChannelSession struct {
ID string `json:"id" gorm:"type:varchar(36);primaryKey;default:uuid_generate_v4()"`
Platform string `json:"platform" gorm:"type:varchar(20);not null"`
UserID string `json:"user_id" gorm:"type:varchar(128);not null"`
ChatID string `json:"chat_id" gorm:"type:varchar(128);not null;default:''"`
ThreadID string `json:"thread_id" gorm:"type:varchar(128);not null;default:''"`
SessionID string `json:"session_id" gorm:"type:varchar(36);not null;index"`
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
AgentID string `json:"agent_id" gorm:"type:varchar(36);default:''"`
IMChannelID string `json:"im_channel_id" gorm:"type:varchar(36);default:''"`
Status string `json:"status" gorm:"type:varchar(20);not null;default:'active'"`
Metadata types.JSON `json:"metadata" gorm:"type:jsonb;default:'{}'"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
}
func (ChannelSession) TableName() string {
return "im_channel_sessions"
}
func (cs *ChannelSession) BeforeCreate(tx *gorm.DB) error {
if cs.ID == "" {
cs.ID = uuid.New().String()
}
if cs.Status == "" {
cs.Status = "active"
}
return nil
}