Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
251 lines
8.7 KiB
Go
251 lines
8.7 KiB
Go
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
|
|
}
|