Files
xinmi/internal/application/service/digital_human_wechat.go
T
yawan f7fc992f57
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
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

200 lines
6.9 KiB
Go

package service
import (
"context"
"encoding/json"
"strings"
"sync"
"time"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/im/wechat"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/google/uuid"
)
type digitalHumanChannels interface {
InvalidateDeletedChannels([]string)
ListChannelsByAgent(string, uint64) ([]im.IMChannel, error)
GetChannelByIDAndTenant(string, uint64) (*im.IMChannel, error)
CreateChannel(*im.IMChannel) error
UpdateChannel(*im.IMChannel) error
DeleteChannel(string, uint64) error
}
type digitalHumanQRProvider interface {
GetLoginQRCode(context.Context) (*wechat.QRCodeResult, error)
PollQRCodeStatus(context.Context, string) (*wechat.LoginResult, error)
}
type digitalHumanQRLogin struct {
mu sync.Mutex
scope types.DigitalHumanScope
humanID string
code string
expires time.Time
channel *im.IMChannelSummary
}
type DigitalHumanWeChatStatus struct {
Status string `json:"status"`
Channel *im.IMChannelSummary `json:"channel,omitempty"`
}
// Personal WeChat credentials are scoped to the creator even for tenant admins.
func (s *DigitalHumanService) ownedHuman(ctx context.Context, id string) (*types.DigitalHumanDetail, types.DigitalHumanScope, error) {
scope, err := digitalHumanScope(ctx)
if err != nil {
return nil, scope, err
}
human, err := s.repo.Get(ctx, scope, id)
if err == nil && human.OwnerID != scope.OwnerID {
return nil, scope, types.ErrDigitalHumanNotFound
}
return human, scope, err
}
func (s *DigitalHumanService) CheckOwner(ctx context.Context, id string) error {
_, _, err := s.ownedHuman(ctx, id)
return err
}
func (s *DigitalHumanService) WeChatChannels(ctx context.Context, id string) ([]im.IMChannelSummary, error) {
human, scope, err := s.ownedHuman(ctx, id)
if err != nil {
return nil, err
}
channels, err := s.channels.ListChannelsByAgent(human.PrimaryAgentID, scope.TenantID)
if err != nil {
return nil, err
}
result := make([]im.IMChannelSummary, 0, len(channels))
for _, channel := range channels {
if channel.Platform == "wechat" {
result = append(result, im.SummarizeIMChannel(channel))
}
}
return result, nil
}
func (s *DigitalHumanService) WeChatQRCode(ctx context.Context, id string) (*wechat.QRCodeResult, error) {
_, scope, err := s.ownedHuman(ctx, id)
if err != nil {
return nil, err
}
result, err := s.qr.GetLoginQRCode(ctx)
if err != nil {
logger.Errorf(ctx, "Digital human WeChat QR request failed: %v", err)
return nil, digitalHumanInputError("微信二维码获取失败,请检查网络后重试")
}
if result == nil || result.QRCode == "" || result.QRCodeURL == "" {
return nil, digitalHumanInputError("微信未返回有效二维码,请重试")
}
s.qrMu.Lock()
defer s.qrMu.Unlock()
if s.qrLogins == nil {
s.qrLogins = make(map[string]*digitalHumanQRLogin)
}
for token, login := range s.qrLogins {
if time.Now().After(login.expires) {
delete(s.qrLogins, token)
}
}
if len(s.qrLogins) >= 1024 {
return nil, digitalHumanInputError("正在连接的微信账号较多,请稍后重试")
}
// The browser receives an owner-bound ticket, never an upstream polling token.
token := uuid.NewString()
s.qrLogins[token] = &digitalHumanQRLogin{scope: scope, humanID: id, code: result.QRCode, expires: time.Now().Add(5 * time.Minute)}
return &wechat.QRCodeResult{QRCodeURL: result.QRCodeURL, QRCode: token}, nil
}
func (s *DigitalHumanService) WeChatPoll(ctx context.Context, id, ticket string) (*DigitalHumanWeChatStatus, error) {
human, scope, err := s.ownedHuman(ctx, id)
if err != nil {
return nil, err
}
s.qrMu.Lock()
login := s.qrLogins[ticket]
s.qrMu.Unlock()
if login == nil || login.scope != scope || login.humanID != id || time.Now().After(login.expires) {
return nil, digitalHumanInputError("二维码不存在、已过期或不属于当前账号,请重新连接")
}
login.mu.Lock()
defer login.mu.Unlock()
if time.Now().After(login.expires) {
return nil, digitalHumanInputError("二维码已过期,请重新连接")
}
if login.channel != nil {
if _, err := s.ownedWeChatChannel(ctx, id, login.channel.ID); err != nil {
return nil, err
}
return &DigitalHumanWeChatStatus{Status: "confirmed", Channel: login.channel}, nil
}
result, err := s.qr.PollQRCodeStatus(ctx, login.code)
if err != nil {
logger.Errorf(ctx, "Digital human WeChat status request failed: %v", err)
return nil, digitalHumanInputError("微信连接状态获取失败,请重试")
}
if result == nil {
return nil, digitalHumanInputError("微信未返回连接状态,请重试")
}
if result.Status != "confirmed" {
return &DigitalHumanWeChatStatus{Status: result.Status}, nil
}
if result.BotToken == "" || result.ILinkBotID == "" {
return nil, digitalHumanInputError("微信授权未返回完整凭证,请重新扫码")
}
// Recheck after the long poll in case the human was deleted while scanning.
if err := s.CheckOwner(ctx, id); err != nil {
return nil, err
}
credentials, _ := json.Marshal(map[string]string{
"bot_token": result.BotToken, "ilink_bot_id": result.ILinkBotID,
"ilink_user_id": result.ILinkUserID, "baseurl": result.BaseURL,
})
channel := &im.IMChannel{TenantID: scope.TenantID, AgentID: human.PrimaryAgentID,
Platform: "wechat", Name: human.Name + " · 微信", Mode: "longpoll", OutputMode: "full", SessionMode: "user",
Enabled: true, Credentials: types.JSON(credentials)}
if err := s.channels.CreateChannel(channel); err != nil {
logger.Errorf(ctx, "Digital human WeChat channel creation failed: %v", err)
if strings.HasPrefix(err.Error(), "duplicate_bot:") {
return nil, digitalHumanInputError("此微信账号已经连接,请先删除原连接或使用其他账号扫码")
}
return nil, digitalHumanInputError("微信已确认,但保存连接失败,请重试")
}
summary := im.SummarizeIMChannel(*channel)
login.channel = &summary
return &DigitalHumanWeChatStatus{Status: "confirmed", Channel: login.channel}, nil
}
func (s *DigitalHumanService) ownedWeChatChannel(ctx context.Context, humanID, channelID string) (*im.IMChannel, error) {
human, scope, err := s.ownedHuman(ctx, humanID)
if err != nil {
return nil, err
}
channel, err := s.channels.GetChannelByIDAndTenant(channelID, scope.TenantID)
if err != nil || channel == nil || channel.AgentID != human.PrimaryAgentID || channel.Platform != "wechat" {
return nil, types.ErrDigitalHumanNotFound
}
return channel, nil
}
func (s *DigitalHumanService) DeleteWeChatChannel(ctx context.Context, humanID, channelID string) error {
channel, err := s.ownedWeChatChannel(ctx, humanID, channelID)
if err != nil {
return err
}
return s.channels.DeleteChannel(channel.ID, channel.TenantID)
}
func (s *DigitalHumanService) SetWeChatChannelEnabled(ctx context.Context, humanID, channelID string, enabled bool) error {
channel, err := s.ownedWeChatChannel(ctx, humanID, channelID)
if err != nil {
return err
}
channel.Enabled = enabled
return s.channels.UpdateChannel(channel)
}