feat: add customer session refresh functionality and improve session management
- Introduced RealtimeCustomerSessionRefreshPayload and RealtimeCustomerSessionRefreshEvent types for handling session refresh events. - Updated ws_service to verify customer session and handle session refresh notifications. - Enhanced API client to manage customer session tokens and expiration. - Implemented customer session validation and storage in session storage. - Added functions to exchange and ensure customer sessions. - Updated IM real-time connection to include customer session tokens in WebSocket requests. - Modified SDK and widget configurations to support external IDs and user tokens.
This commit is contained in:
@@ -20,6 +20,14 @@ auth:
|
|||||||
maxFailedAttempts: 5
|
maxFailedAttempts: 5
|
||||||
credentialLockMinute: 15
|
credentialLockMinute: 15
|
||||||
|
|
||||||
|
customerSession:
|
||||||
|
# 客服会话 token 签名密钥。必须使用独立高强度随机字符串,不要复用渠道 userTokenSecret。
|
||||||
|
secret: ""
|
||||||
|
# 客服会话 token 默认有效期,单位分钟。
|
||||||
|
ttlMinutes: 120
|
||||||
|
# token 剩余有效期小于该值时自动续期,单位分钟。
|
||||||
|
refreshThresholdMinutes: 30
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
default: local
|
default: local
|
||||||
maxUploadSizeMB: 20
|
maxUploadSizeMB: 20
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ func NewServer() (*iris.Application, error) {
|
|||||||
app := iris.New()
|
app := iris.New()
|
||||||
corsHandler := cors.New().
|
corsHandler := cors.New().
|
||||||
AllowOrigin("*").
|
AllowOrigin("*").
|
||||||
AllowHeaders("Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With", "X-Guest-Id", "X-Channel-Id", "X-External-Id", "X-External-Name").
|
AllowHeaders("Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With", "X-Guest-Id", "X-Channel-Id", "X-External-Id", "X-External-Name", "X-Customer-Session-Token", "X-Customer-Session-Expires-At").
|
||||||
MaxAge(600).
|
MaxAge(600).
|
||||||
ExposeHeaders("Content-Length", "Content-Type", "Authorization", "X-Guest-Id", "X-Channel-Id", "X-External-Id", "X-External-Name").
|
ExposeHeaders("Content-Length", "Content-Type", "Authorization", "X-Guest-Id", "X-Channel-Id", "X-External-Id", "X-External-Name", "X-Customer-Session-Token", "X-Customer-Session-Expires-At").
|
||||||
Handler()
|
Handler()
|
||||||
app.UseRouter(func(ctx iris.Context) {
|
app.UseRouter(func(ctx iris.Context) {
|
||||||
// WebSocket upgrade is validated by the upgrader's origin policy.
|
// WebSocket upgrade is validated by the upgrader's origin policy.
|
||||||
@@ -95,6 +95,7 @@ func addRouter(app *iris.Application) {
|
|||||||
mvc.Configure(app.Party("/api"), func(m *mvc.Application) {
|
mvc.Configure(app.Party("/api"), func(m *mvc.Application) {
|
||||||
m.Party("/auth").Handle(new(api.AuthController))
|
m.Party("/auth").Handle(new(api.AuthController))
|
||||||
m.Party("/channel").Handle(new(api.ChannelController))
|
m.Party("/channel").Handle(new(api.ChannelController))
|
||||||
|
m.Party("/customer").Handle(new(api.CustomerController))
|
||||||
m.Party("/conversation", middleware.ExternalUserMiddleware).Handle(new(api.ConversationController))
|
m.Party("/conversation", middleware.ExternalUserMiddleware).Handle(new(api.ConversationController))
|
||||||
m.Party("/message", middleware.ExternalUserMiddleware).Handle(new(api.MessageController))
|
m.Party("/message", middleware.ExternalUserMiddleware).Handle(new(api.MessageController))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cs-agent/internal/pkg/openidentity"
|
||||||
|
"cs-agent/internal/services"
|
||||||
|
|
||||||
|
"github.com/kataras/iris/v12"
|
||||||
|
"github.com/mlogclub/simple/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CustomerController struct {
|
||||||
|
Ctx iris.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CustomerController) PostSession_exchange() *web.JsonResult {
|
||||||
|
channel := services.ChannelService.GetEnabledChannel(c.Ctx)
|
||||||
|
if channel == nil {
|
||||||
|
return web.JsonErrorMsg("接入渠道不存在或已停用")
|
||||||
|
}
|
||||||
|
externalUser, err := openidentity.GetExternalUser(c.Ctx, services.ChannelService.GetUserTokenSecret(channel))
|
||||||
|
if err != nil {
|
||||||
|
return web.JsonError(err)
|
||||||
|
}
|
||||||
|
resp, err := services.CustomerSessionService.Exchange(channel, *externalUser)
|
||||||
|
if err != nil {
|
||||||
|
return web.JsonError(err)
|
||||||
|
}
|
||||||
|
return web.JsonData(resp)
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"cs-agent/internal/pkg/irisx"
|
"cs-agent/internal/pkg/irisx"
|
||||||
"cs-agent/internal/pkg/openidentity"
|
|
||||||
"cs-agent/internal/services"
|
"cs-agent/internal/services"
|
||||||
|
|
||||||
"github.com/kataras/iris/v12"
|
"github.com/kataras/iris/v12"
|
||||||
@@ -16,13 +15,13 @@ func ExternalUserMiddleware(ctx iris.Context) {
|
|||||||
_ = ctx.JSON(web.JsonErrorMsg("接入渠道异常"))
|
_ = ctx.JSON(web.JsonErrorMsg("接入渠道异常"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
secret := services.ChannelService.GetUserTokenSecret(channel)
|
result, err := services.CustomerSessionService.VerifyRequest(ctx, channel)
|
||||||
ext, err := openidentity.GetExternalUser(ctx, secret)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.StopExecution()
|
ctx.StopExecution()
|
||||||
_ = ctx.JSON(web.JsonError(err))
|
_ = ctx.JSON(web.JsonError(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
irisx.SetExternalUser(ctx, ext)
|
services.CustomerSessionService.SetRefreshHeaders(ctx, result)
|
||||||
|
irisx.SetExternalUser(ctx, result.ExternalUser)
|
||||||
ctx.Next()
|
ctx.Next()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `yaml:"server"`
|
Server ServerConfig `yaml:"server"`
|
||||||
DB DBConfig `yaml:"db"`
|
DB DBConfig `yaml:"db"`
|
||||||
Logger LoggerConfig `yaml:"logger"`
|
Logger LoggerConfig `yaml:"logger"`
|
||||||
Auth AuthConfig `yaml:"auth"`
|
Auth AuthConfig `yaml:"auth"`
|
||||||
Storage StorageConfig `yaml:"storage"`
|
Storage StorageConfig `yaml:"storage"`
|
||||||
VectorDB VectorDBConfig `yaml:"vectorDB"`
|
VectorDB VectorDBConfig `yaml:"vectorDB"`
|
||||||
MCP MCPConfig `yaml:"mcp"`
|
MCP MCPConfig `yaml:"mcp"`
|
||||||
WxWork WxWorkConfig `yaml:"wxWork"`
|
WxWork WxWorkConfig `yaml:"wxWork"`
|
||||||
|
CustomerSession CustomerSessionConfig `yaml:"customerSession"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WxWorkNotifyConfig struct {
|
type WxWorkNotifyConfig struct {
|
||||||
@@ -60,6 +61,26 @@ type AuthConfig struct {
|
|||||||
CredentialLockMinute int `yaml:"credentialLockMinute"`
|
CredentialLockMinute int `yaml:"credentialLockMinute"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CustomerSessionConfig struct {
|
||||||
|
Secret string `yaml:"secret"`
|
||||||
|
TTLMinutes int `yaml:"ttlMinutes"`
|
||||||
|
RefreshThresholdMinutes int `yaml:"refreshThresholdMinutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c CustomerSessionConfig) TTL() int {
|
||||||
|
if c.TTLMinutes <= 0 {
|
||||||
|
return 120
|
||||||
|
}
|
||||||
|
return c.TTLMinutes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c CustomerSessionConfig) RefreshThreshold() int {
|
||||||
|
if c.RefreshThresholdMinutes <= 0 {
|
||||||
|
return 30
|
||||||
|
}
|
||||||
|
return c.RefreshThresholdMinutes
|
||||||
|
}
|
||||||
|
|
||||||
type StorageConfig struct {
|
type StorageConfig struct {
|
||||||
Default enums.AssetProvider `yaml:"default"`
|
Default enums.AssetProvider `yaml:"default"`
|
||||||
MaxUploadSizeMB int64 `yaml:"maxUploadSizeMB"`
|
MaxUploadSizeMB int64 `yaml:"maxUploadSizeMB"`
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package response
|
||||||
|
|
||||||
|
type CustomerSessionCustomerResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomerSessionExchangeResponse struct {
|
||||||
|
CustomerSessionToken string `json:"customerSessionToken"`
|
||||||
|
ExpiresAt string `json:"expiresAt"`
|
||||||
|
IdentityKey string `json:"identityKey"`
|
||||||
|
Customer CustomerSessionCustomerResponse `json:"customer"`
|
||||||
|
}
|
||||||
@@ -251,6 +251,7 @@ const (
|
|||||||
IMRealtimeEventConversationClosed = "conversation.closed"
|
IMRealtimeEventConversationClosed = "conversation.closed"
|
||||||
IMRealtimeEventConversationRead = "conversation.read"
|
IMRealtimeEventConversationRead = "conversation.read"
|
||||||
IMRealtimeEventNotificationCreated = "notification.created"
|
IMRealtimeEventNotificationCreated = "notification.created"
|
||||||
|
IMRealtimeEventCustomerSessionRefresh = "customer_session.refresh"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cs-agent/internal/models"
|
||||||
|
"cs-agent/internal/pkg/config"
|
||||||
|
"cs-agent/internal/pkg/dto/response"
|
||||||
|
"cs-agent/internal/pkg/enums"
|
||||||
|
"cs-agent/internal/pkg/errorsx"
|
||||||
|
"cs-agent/internal/pkg/openidentity"
|
||||||
|
"cs-agent/internal/repositories"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/kataras/iris/v12"
|
||||||
|
"github.com/mlogclub/simple/sqls"
|
||||||
|
"github.com/mlogclub/simple/web/params"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
customerSessionTokenType = "customer_session"
|
||||||
|
customerSessionHeader = "X-Customer-Session-Token"
|
||||||
|
customerSessionExpHeader = "X-Customer-Session-Expires-At"
|
||||||
|
)
|
||||||
|
|
||||||
|
var CustomerSessionService = newCustomerSessionService()
|
||||||
|
|
||||||
|
func newCustomerSessionService() *customerSessionService {
|
||||||
|
return &customerSessionService{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type customerSessionService struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
type customerSessionClaims struct {
|
||||||
|
TokenType string `json:"typ"`
|
||||||
|
ChannelID int64 `json:"channelId"`
|
||||||
|
ChannelCode string `json:"channelCode"`
|
||||||
|
CustomerID int64 `json:"customerId"`
|
||||||
|
CustomerName string `json:"customerName"`
|
||||||
|
IdentityKey string `json:"identityKey"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomerSessionVerifyResult struct {
|
||||||
|
ExternalUser *openidentity.ExternalUser
|
||||||
|
Token string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
Refreshed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *customerSessionService) Exchange(channel *models.Channel, externalUser openidentity.ExternalUser) (*response.CustomerSessionExchangeResponse, error) {
|
||||||
|
if channel == nil || channel.Status != enums.StatusOk {
|
||||||
|
return nil, errorsx.InvalidParam("接入渠道不存在或已停用")
|
||||||
|
}
|
||||||
|
var customerID int64
|
||||||
|
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||||
|
id, err := CustomerService.EnsureExternalCustomer(ctx.Tx, externalUser)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
customerID = id
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
customer := CustomerService.Get(customerID)
|
||||||
|
if customer == nil || customer.Status == enums.StatusDeleted {
|
||||||
|
return nil, errorsx.InvalidParam("客户不存在")
|
||||||
|
}
|
||||||
|
token, expiresAt, err := s.Sign(channel, customer, externalUser)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &response.CustomerSessionExchangeResponse{
|
||||||
|
CustomerSessionToken: token,
|
||||||
|
ExpiresAt: expiresAt.Format(time.DateTime),
|
||||||
|
IdentityKey: s.identityKey(externalUser),
|
||||||
|
Customer: response.CustomerSessionCustomerResponse{
|
||||||
|
ID: customer.ID,
|
||||||
|
Name: strings.TrimSpace(customer.Name),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *customerSessionService) Sign(channel *models.Channel, customer *models.Customer, externalUser openidentity.ExternalUser) (string, time.Time, error) {
|
||||||
|
cfg := config.Current().CustomerSession
|
||||||
|
secret := strings.TrimSpace(cfg.Secret)
|
||||||
|
if secret == "" {
|
||||||
|
return "", time.Time{}, errorsx.BusinessError(1, "客服会话密钥未配置")
|
||||||
|
}
|
||||||
|
if channel == nil || customer == nil {
|
||||||
|
return "", time.Time{}, errorsx.InvalidParam("客服会话参数不完整")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
expiresAt := now.Add(time.Duration(cfg.TTL()) * time.Minute)
|
||||||
|
claims := customerSessionClaims{
|
||||||
|
TokenType: customerSessionTokenType,
|
||||||
|
ChannelID: channel.ID,
|
||||||
|
ChannelCode: strings.TrimSpace(channel.ChannelID),
|
||||||
|
CustomerID: customer.ID,
|
||||||
|
CustomerName: strings.TrimSpace(customer.Name),
|
||||||
|
IdentityKey: s.identityKey(externalUser),
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
return token, expiresAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *customerSessionService) VerifyRequest(ctx iris.Context, channel *models.Channel) (*CustomerSessionVerifyResult, error) {
|
||||||
|
token := s.getCustomerSessionToken(ctx)
|
||||||
|
if token == "" {
|
||||||
|
return nil, errorsx.Unauthorized("客服会话不能为空")
|
||||||
|
}
|
||||||
|
claims, err := s.verifyToken(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if channel == nil || channel.Status != enums.StatusOk {
|
||||||
|
return nil, errorsx.InvalidParam("接入渠道不存在或已停用")
|
||||||
|
}
|
||||||
|
if claims.ChannelID != channel.ID || strings.TrimSpace(claims.ChannelCode) != strings.TrimSpace(channel.ChannelID) {
|
||||||
|
return nil, errorsx.Unauthorized("客服会话校验失败")
|
||||||
|
}
|
||||||
|
customer := CustomerService.Get(claims.CustomerID)
|
||||||
|
if customer == nil || customer.Status == enums.StatusDeleted {
|
||||||
|
return nil, errorsx.Unauthorized("客服会话校验失败")
|
||||||
|
}
|
||||||
|
external, err := s.externalUserFromClaims(claims, customer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := &CustomerSessionVerifyResult{
|
||||||
|
ExternalUser: external,
|
||||||
|
Token: token,
|
||||||
|
ExpiresAt: claims.ExpiresAt.Time,
|
||||||
|
}
|
||||||
|
if s.shouldRefresh(claims.ExpiresAt.Time) {
|
||||||
|
newToken, expiresAt, err := s.Sign(channel, customer, *external)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.Token = newToken
|
||||||
|
result.ExpiresAt = expiresAt
|
||||||
|
result.Refreshed = true
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *customerSessionService) SetRefreshHeaders(ctx iris.Context, result *CustomerSessionVerifyResult) {
|
||||||
|
if ctx == nil || result == nil || !result.Refreshed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Header(customerSessionHeader, result.Token)
|
||||||
|
ctx.Header(customerSessionExpHeader, result.ExpiresAt.Format(time.DateTime))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *customerSessionService) verifyToken(rawToken string) (*customerSessionClaims, error) {
|
||||||
|
cfg := config.Current().CustomerSession
|
||||||
|
secret := strings.TrimSpace(cfg.Secret)
|
||||||
|
if secret == "" {
|
||||||
|
return nil, errorsx.BusinessError(1, "客服会话密钥未配置")
|
||||||
|
}
|
||||||
|
claims := &customerSessionClaims{}
|
||||||
|
token, err := jwt.ParseWithClaims(rawToken, claims, func(token *jwt.Token) (any, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, errors.New("unsupported signing method")
|
||||||
|
}
|
||||||
|
return []byte(secret), nil
|
||||||
|
}, jwt.WithExpirationRequired(), jwt.WithValidMethods([]string{
|
||||||
|
jwt.SigningMethodHS256.Alg(),
|
||||||
|
jwt.SigningMethodHS384.Alg(),
|
||||||
|
jwt.SigningMethodHS512.Alg(),
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||||
|
return nil, errorsx.Unauthorized("客服会话已过期")
|
||||||
|
}
|
||||||
|
return nil, errorsx.Unauthorized("客服会话校验失败")
|
||||||
|
}
|
||||||
|
if token == nil || !token.Valid || claims.TokenType != customerSessionTokenType || claims.ExpiresAt == nil {
|
||||||
|
return nil, errorsx.Unauthorized("客服会话校验失败")
|
||||||
|
}
|
||||||
|
if claims.ChannelID <= 0 || strings.TrimSpace(claims.ChannelCode) == "" || claims.CustomerID <= 0 || strings.TrimSpace(claims.IdentityKey) == "" {
|
||||||
|
return nil, errorsx.Unauthorized("客服会话校验失败")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *customerSessionService) externalUserFromClaims(claims *customerSessionClaims, customer *models.Customer) (*openidentity.ExternalUser, error) {
|
||||||
|
identityKey := strings.TrimSpace(claims.IdentityKey)
|
||||||
|
parts := strings.SplitN(identityKey, ":", 2)
|
||||||
|
if len(parts) != 2 || strings.TrimSpace(parts[1]) == "" {
|
||||||
|
return nil, errorsx.Unauthorized("客服会话校验失败")
|
||||||
|
}
|
||||||
|
var source enums.ExternalSource
|
||||||
|
switch parts[0] {
|
||||||
|
case "user":
|
||||||
|
source = enums.ExternalSourceUser
|
||||||
|
case "guest":
|
||||||
|
source = enums.ExternalSourceGuest
|
||||||
|
default:
|
||||||
|
return nil, errorsx.Unauthorized("客服会话校验失败")
|
||||||
|
}
|
||||||
|
identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), source, parts[1])
|
||||||
|
if identity == nil || identity.CustomerID != claims.CustomerID {
|
||||||
|
return nil, errorsx.Unauthorized("客服会话校验失败")
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(claims.CustomerName)
|
||||||
|
if customer != nil && strings.TrimSpace(customer.Name) != "" {
|
||||||
|
name = strings.TrimSpace(customer.Name)
|
||||||
|
}
|
||||||
|
return &openidentity.ExternalUser{
|
||||||
|
ExternalSource: source,
|
||||||
|
ExternalID: parts[1],
|
||||||
|
ExternalName: name,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *customerSessionService) shouldRefresh(expiresAt time.Time) bool {
|
||||||
|
threshold := config.Current().CustomerSession.RefreshThreshold()
|
||||||
|
return time.Until(expiresAt) <= time.Duration(threshold)*time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *customerSessionService) identityKey(externalUser openidentity.ExternalUser) string {
|
||||||
|
switch externalUser.ExternalSource {
|
||||||
|
case enums.ExternalSourceUser:
|
||||||
|
return "user:" + strings.TrimSpace(externalUser.ExternalID)
|
||||||
|
default:
|
||||||
|
return "guest:" + strings.TrimSpace(externalUser.ExternalID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *customerSessionService) getCustomerSessionToken(ctx iris.Context) string {
|
||||||
|
auth := strings.TrimSpace(ctx.GetHeader("Authorization"))
|
||||||
|
if len(auth) > 7 && strings.EqualFold(auth[:7], "Bearer ") {
|
||||||
|
if token := strings.TrimSpace(auth[7:]); token != "" {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token, _ := params.Get(ctx, "customerSessionToken")
|
||||||
|
return strings.TrimSpace(token)
|
||||||
|
}
|
||||||
@@ -239,6 +239,25 @@ func (e RealtimeNotificationCreatedEvent) EventPayload() RealtimeEventPayload {
|
|||||||
return e.Payload
|
return e.Payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RealtimeCustomerSessionRefreshPayload struct {
|
||||||
|
CustomerSessionToken string `json:"customerSessionToken"`
|
||||||
|
ExpiresAt string `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RealtimeCustomerSessionRefreshPayload) realtimeEventPayload() {}
|
||||||
|
|
||||||
|
type RealtimeCustomerSessionRefreshEvent struct {
|
||||||
|
Payload RealtimeCustomerSessionRefreshPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e RealtimeCustomerSessionRefreshEvent) EventType() string {
|
||||||
|
return enums.IMRealtimeEventCustomerSessionRefresh
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e RealtimeCustomerSessionRefreshEvent) EventPayload() RealtimeEventPayload {
|
||||||
|
return e.Payload
|
||||||
|
}
|
||||||
|
|
||||||
type realtimeClientMessage struct {
|
type realtimeClientMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Topics []string `json:"topics,omitempty"`
|
Topics []string `json:"topics,omitempty"`
|
||||||
|
|||||||
@@ -75,25 +75,27 @@ func (s *wsService) HandleOpenWS(ctx iris.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
principal = AuthService.GetAuthPrincipal(ctx)
|
principal = AuthService.GetAuthPrincipal(ctx)
|
||||||
external *openidentity.ExternalUser
|
external *openidentity.ExternalUser
|
||||||
|
customerSessionInfo *CustomerSessionVerifyResult
|
||||||
)
|
)
|
||||||
if principal == nil {
|
if principal == nil {
|
||||||
ext, err := openidentity.GetExternalUser(ctx, ChannelService.GetUserTokenSecret(channel))
|
result, err := CustomerSessionService.VerifyRequest(ctx, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = ctx.StopWithJSON(iris.StatusUnauthorized, web.JsonError(err))
|
_ = ctx.StopWithJSON(iris.StatusUnauthorized, web.JsonError(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
external = ext
|
external = result.ExternalUser
|
||||||
|
customerSessionInfo = result
|
||||||
}
|
}
|
||||||
if err := s.upgradeConnection(ctx, principal, external, realtimeRoleUser); err != nil {
|
if err := s.upgradeConnection(ctx, principal, external, realtimeRoleUser, customerSessionInfo); err != nil {
|
||||||
slog.Error("upgrade open im websocket failed", "error", err, "path", ctx.Path(), "channelId", channel.ChannelID, "channel_id", channel.ID)
|
slog.Error("upgrade open im websocket failed", "error", err, "path", ctx.Path(), "channelId", channel.ChannelID, "channel_id", channel.ID)
|
||||||
ctx.StopExecution()
|
ctx.StopExecution()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *wsService) upgradeConnection(ctx iris.Context, principal *dto.AuthPrincipal, external *openidentity.ExternalUser, role string) error {
|
func (s *wsService) upgradeConnection(ctx iris.Context, principal *dto.AuthPrincipal, external *openidentity.ExternalUser, role string, customerSessionInfo ...*CustomerSessionVerifyResult) error {
|
||||||
conn, err := s.upgrader.Upgrade(ctx.ResponseWriter().Naive(), ctx.Request(), nil)
|
conn, err := s.upgrader.Upgrade(ctx.ResponseWriter().Naive(), ctx.Request(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -151,6 +153,14 @@ func (s *wsService) upgradeConnection(ctx iris.Context, principal *dto.AuthPrinc
|
|||||||
Topics: session.topicList(),
|
Topics: session.topicList(),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
if len(customerSessionInfo) > 0 && customerSessionInfo[0] != nil && customerSessionInfo[0].Refreshed {
|
||||||
|
session.enqueueEvent(s.newEvent("", RealtimeCustomerSessionRefreshEvent{
|
||||||
|
Payload: RealtimeCustomerSessionRefreshPayload{
|
||||||
|
CustomerSessionToken: customerSessionInfo[0].Token,
|
||||||
|
ExpiresAt: customerSessionInfo[0].ExpiresAt.Format(time.DateTime),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type RequestOptions = RequestInit & {
|
|||||||
skipAuth?: boolean
|
skipAuth?: boolean
|
||||||
retryOnAuthError?: boolean
|
retryOnAuthError?: boolean
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
|
onResponse?: (response: Response) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseResult<T>(response: Response) {
|
async function parseResult<T>(response: Response) {
|
||||||
@@ -58,9 +59,10 @@ export async function request<T>(
|
|||||||
options: RequestOptions = {},
|
options: RequestOptions = {},
|
||||||
retryOnAuthError = true
|
retryOnAuthError = true
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const { headers, skipAuth, baseUrl, ...rest } = options
|
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
|
||||||
delete (rest as RequestOptions).retryOnAuthError
|
delete (rest as RequestOptions).retryOnAuthError
|
||||||
delete (rest as RequestOptions).baseUrl
|
delete (rest as RequestOptions).baseUrl
|
||||||
|
delete (rest as RequestOptions).onResponse
|
||||||
const session = readSession()
|
const session = readSession()
|
||||||
const authHeaders = new Headers(headers)
|
const authHeaders = new Headers(headers)
|
||||||
|
|
||||||
@@ -81,6 +83,7 @@ export async function request<T>(
|
|||||||
headers: authHeaders,
|
headers: authHeaders,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
})
|
})
|
||||||
|
onResponse?.(response)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await parseResult<T>(response)
|
return await parseResult<T>(response)
|
||||||
|
|||||||
+176
-4
@@ -112,12 +112,33 @@ export type ImWidgetConfig = {
|
|||||||
width?: string
|
width?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImCustomerSessionCustomer = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImCustomerSessionExchangeResponse = {
|
||||||
|
customerSessionToken: string
|
||||||
|
expiresAt: string
|
||||||
|
identityKey: string
|
||||||
|
customer: ImCustomerSessionCustomer
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImCustomerSession = ImCustomerSessionExchangeResponse & {
|
||||||
|
channelId: string
|
||||||
|
}
|
||||||
|
|
||||||
const GUEST_STORAGE_KEY = "cs_agent_im_guest_id"
|
const GUEST_STORAGE_KEY = "cs_agent_im_guest_id"
|
||||||
|
const CUSTOMER_SESSION_STORAGE_KEY = "cs_agent_customer_session"
|
||||||
|
const CUSTOMER_SESSION_TOKEN_HEADER = "X-Customer-Session-Token"
|
||||||
|
const CUSTOMER_SESSION_EXPIRES_HEADER = "X-Customer-Session-Expires-At"
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || ""
|
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || ""
|
||||||
const OPEN_IM_CHANNEL_ID =
|
const OPEN_IM_CHANNEL_ID =
|
||||||
process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() || ""
|
process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() || ""
|
||||||
|
|
||||||
|
let entryUserTokenExchangeKey = ""
|
||||||
|
|
||||||
function buildGuestId() {
|
function buildGuestId() {
|
||||||
return `guest_${generateUUID()}`
|
return `guest_${generateUUID()}`
|
||||||
}
|
}
|
||||||
@@ -149,7 +170,95 @@ function getRuntimeImConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createImHeaders() {
|
function parseExpiresAt(value: string) {
|
||||||
|
const normalized = value.trim().replace(" ", "T")
|
||||||
|
const timestamp = Date.parse(normalized)
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCustomerSessionValid(
|
||||||
|
session: ImCustomerSession | null,
|
||||||
|
channelId?: string,
|
||||||
|
identityKey?: string
|
||||||
|
) {
|
||||||
|
if (!session?.customerSessionToken || !session.expiresAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (channelId && session.channelId !== channelId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (identityKey && session.identityKey !== identityKey) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return parseExpiresAt(session.expiresAt) > Date.now() + 5000
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCustomerSession(): ImCustomerSession | null {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const raw = window.sessionStorage.getItem(CUSTOMER_SESSION_STORAGE_KEY)
|
||||||
|
if (!raw) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as ImCustomerSession
|
||||||
|
} catch {
|
||||||
|
window.sessionStorage.removeItem(CUSTOMER_SESSION_STORAGE_KEY)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCustomerSession(session: ImCustomerSession) {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.sessionStorage.setItem(CUSTOMER_SESSION_STORAGE_KEY, JSON.stringify(session))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCustomerSessionToken() {
|
||||||
|
const config = getRuntimeImConfig()
|
||||||
|
const session = readCustomerSession()
|
||||||
|
return isCustomerSessionValid(session, config.channelId)
|
||||||
|
? session?.customerSessionToken ?? ""
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyCustomerSessionRefresh(payload?: {
|
||||||
|
customerSessionToken?: string
|
||||||
|
expiresAt?: string
|
||||||
|
}) {
|
||||||
|
const token = payload?.customerSessionToken?.trim()
|
||||||
|
const expiresAt = payload?.expiresAt?.trim()
|
||||||
|
if (!token || !expiresAt) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const current = readCustomerSession()
|
||||||
|
if (!current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeCustomerSession({
|
||||||
|
...current,
|
||||||
|
customerSessionToken: token,
|
||||||
|
expiresAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCustomerSessionHeaders(response: Response) {
|
||||||
|
applyCustomerSessionRefresh({
|
||||||
|
customerSessionToken: response.headers.get(CUSTOMER_SESSION_TOKEN_HEADER) ?? "",
|
||||||
|
expiresAt: response.headers.get(CUSTOMER_SESSION_EXPIRES_HEADER) ?? "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChannelHeaders() {
|
||||||
|
const config = getRuntimeImConfig()
|
||||||
|
return {
|
||||||
|
"X-Channel-Id": config.channelId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExchangeHeaders() {
|
||||||
const config = getRuntimeImConfig()
|
const config = getRuntimeImConfig()
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"X-Channel-Id": config.channelId,
|
"X-Channel-Id": config.channelId,
|
||||||
@@ -167,9 +276,24 @@ function createImHeaders() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createImHeaders() {
|
||||||
|
const sessionToken = getCustomerSessionToken()
|
||||||
|
if (!sessionToken) {
|
||||||
|
throw new Error("客服会话未初始化")
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...createChannelHeaders(),
|
||||||
|
Authorization: `Bearer ${sessionToken}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createRequestOptions(
|
function createRequestOptions(
|
||||||
init?: RequestInit
|
init?: RequestInit
|
||||||
): RequestInit & { baseUrl?: string; skipAuth?: boolean } {
|
): RequestInit & {
|
||||||
|
baseUrl?: string
|
||||||
|
skipAuth?: boolean
|
||||||
|
onResponse?: (response: Response) => void
|
||||||
|
} {
|
||||||
return {
|
return {
|
||||||
...init,
|
...init,
|
||||||
skipAuth: true,
|
skipAuth: true,
|
||||||
@@ -177,6 +301,7 @@ function createRequestOptions(
|
|||||||
...createImHeaders(),
|
...createImHeaders(),
|
||||||
...(init?.headers as Record<string, string> | undefined),
|
...(init?.headers as Record<string, string> | undefined),
|
||||||
},
|
},
|
||||||
|
onResponse: applyCustomerSessionHeaders,
|
||||||
baseUrl: getRuntimeImConfig().baseUrl,
|
baseUrl: getRuntimeImConfig().baseUrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,6 +322,50 @@ function toQueryString(query?: Record<string, string | number | undefined>) {
|
|||||||
return output ? `?${output}` : ""
|
return output ? `?${output}` : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function exchangeCustomerSession() {
|
||||||
|
const config = getRuntimeImConfig()
|
||||||
|
const result = await request<ImCustomerSessionExchangeResponse>(
|
||||||
|
"/api/customer/session_exchange",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
skipAuth: true,
|
||||||
|
baseUrl: config.baseUrl,
|
||||||
|
headers: createExchangeHeaders(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const session = {
|
||||||
|
...result,
|
||||||
|
channelId: config.channelId,
|
||||||
|
}
|
||||||
|
writeCustomerSession(session)
|
||||||
|
if (config.userToken) {
|
||||||
|
entryUserTokenExchangeKey = `${config.channelId}:${config.userToken}`
|
||||||
|
}
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureCustomerSession() {
|
||||||
|
const config = getRuntimeImConfig()
|
||||||
|
const cached = readCustomerSession()
|
||||||
|
if (config.userToken) {
|
||||||
|
const exchangeKey = `${config.channelId}:${config.userToken}`
|
||||||
|
if (
|
||||||
|
entryUserTokenExchangeKey === exchangeKey &&
|
||||||
|
isCustomerSessionValid(cached, config.channelId)
|
||||||
|
) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
return exchangeCustomerSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalId = config.externalId || getGuestId()
|
||||||
|
const identityKey = `guest:${externalId}`
|
||||||
|
if (isCustomerSessionValid(cached, config.channelId, identityKey)) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
return exchangeCustomerSession()
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchImConversationDetail(id: number) {
|
export function fetchImConversationDetail(id: number) {
|
||||||
return request<ImConversationDetail>(`/api/conversation/${id}`, {
|
return request<ImConversationDetail>(`/api/conversation/${id}`, {
|
||||||
...createRequestOptions(),
|
...createRequestOptions(),
|
||||||
@@ -212,7 +381,6 @@ export function fetchImMessages(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 外部身份仅通过 createImHeaders()(Authorization 或 X-External-Id/Name)传递,无 JSON body */
|
|
||||||
export function createOrMatchImConversation() {
|
export function createOrMatchImConversation() {
|
||||||
return request<ImConversation>("/api/conversation/create_or_match", {
|
return request<ImConversation>("/api/conversation/create_or_match", {
|
||||||
...createRequestOptions({ method: "POST" }),
|
...createRequestOptions({ method: "POST" }),
|
||||||
@@ -224,7 +392,11 @@ export function fetchImWidgetConfig() {
|
|||||||
`/api/channel/config${toQueryString({
|
`/api/channel/config${toQueryString({
|
||||||
channelId: getRuntimeImConfig().channelId,
|
channelId: getRuntimeImConfig().channelId,
|
||||||
})}`,
|
})}`,
|
||||||
createRequestOptions()
|
{
|
||||||
|
skipAuth: true,
|
||||||
|
baseUrl: getRuntimeImConfig().baseUrl,
|
||||||
|
headers: createChannelHeaders(),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-18
@@ -1,5 +1,5 @@
|
|||||||
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
|
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
|
||||||
import { getGuestId, type ImMessage } from "@/lib/api/im"
|
import { getCustomerSessionToken, type ImMessage } from "@/lib/api/im"
|
||||||
import { readKefuWidgetConfig } from "@/lib/kefu-widget-config"
|
import { readKefuWidgetConfig } from "@/lib/kefu-widget-config"
|
||||||
import type {
|
import type {
|
||||||
RealtimeConversationPatch,
|
RealtimeConversationPatch,
|
||||||
@@ -9,8 +9,16 @@ import type {
|
|||||||
export type ImRealtimeEnvelope = {
|
export type ImRealtimeEnvelope = {
|
||||||
type: string
|
type: string
|
||||||
topic?: string
|
topic?: string
|
||||||
data?: RealtimeMessageCreatedPayload<ImMessage> & RealtimeConversationPatch
|
data?: RealtimeMessageCreatedPayload<ImMessage> &
|
||||||
payload?: RealtimeMessageCreatedPayload<ImMessage> & RealtimeConversationPatch
|
RealtimeConversationPatch & {
|
||||||
|
customerSessionToken?: string
|
||||||
|
expiresAt?: string
|
||||||
|
}
|
||||||
|
payload?: RealtimeMessageCreatedPayload<ImMessage> &
|
||||||
|
RealtimeConversationPatch & {
|
||||||
|
customerSessionToken?: string
|
||||||
|
expiresAt?: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createImRealtimeConnection() {
|
export function createImRealtimeConnection() {
|
||||||
@@ -19,22 +27,9 @@ export function createImRealtimeConnection() {
|
|||||||
const baseUrl = apiBaseUrl
|
const baseUrl = apiBaseUrl
|
||||||
? apiBaseUrl.replace(/^http/, "ws").replace(/\/$/, "")
|
? apiBaseUrl.replace(/^http/, "ws").replace(/\/$/, "")
|
||||||
: createWebSocketBaseUrl()
|
: createWebSocketBaseUrl()
|
||||||
const resolvedExternalId = encodeURIComponent(
|
|
||||||
(config.externalId ?? "").trim() || getGuestId()
|
|
||||||
)
|
|
||||||
const channelId = encodeURIComponent(config.channelId || "")
|
const channelId = encodeURIComponent(config.channelId || "")
|
||||||
const userToken = (config.userToken ?? "").trim()
|
const customerSessionToken = getCustomerSessionToken()
|
||||||
if (userToken) {
|
|
||||||
return new WebSocket(
|
|
||||||
`${baseUrl}/api/ws/open?channelId=${channelId}&userToken=${encodeURIComponent(userToken)}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const externalName = (config.externalName ?? "").trim()
|
|
||||||
const nameQuery =
|
|
||||||
externalName !== ""
|
|
||||||
? `&externalName=${encodeURIComponent(externalName)}`
|
|
||||||
: ""
|
|
||||||
return new WebSocket(
|
return new WebSocket(
|
||||||
`${baseUrl}/api/ws/open?externalId=${resolvedExternalId}&channelId=${channelId}${nameQuery}`
|
`${baseUrl}/api/ws/open?channelId=${channelId}&customerSessionToken=${encodeURIComponent(customerSessionToken)}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ export type KefuWidgetHostConfig = {
|
|||||||
apiBaseUrl?: string
|
apiBaseUrl?: string
|
||||||
/** 外部访客稳定标识;未传时使用浏览器本地访客 ID */
|
/** 外部访客稳定标识;未传时使用浏览器本地访客 ID */
|
||||||
externalId?: string
|
externalId?: string
|
||||||
/** 访客展示名,随请求以 X-External-Name / WS query externalName 传给后端 */
|
/** 访客展示名,仅用于首次换取客服会话 token */
|
||||||
externalName?: string
|
externalName?: string
|
||||||
/** 业务系统签发的前台用户 JWT */
|
/** 业务系统签发的前台用户 JWT,仅用于首次换取客服会话 token */
|
||||||
userToken?: string
|
userToken?: string
|
||||||
title?: string
|
title?: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
|
|||||||
@@ -46,6 +46,9 @@
|
|||||||
delete merged.apiBaseUrl;
|
delete merged.apiBaseUrl;
|
||||||
}
|
}
|
||||||
merged.channelId = String(merged.channelId || "");
|
merged.channelId = String(merged.channelId || "");
|
||||||
|
if (merged.externalId) {
|
||||||
|
merged.externalId = String(merged.externalId);
|
||||||
|
}
|
||||||
if (merged.userToken) {
|
if (merged.userToken) {
|
||||||
merged.userToken = String(merged.userToken);
|
merged.userToken = String(merged.userToken);
|
||||||
}
|
}
|
||||||
@@ -66,6 +69,7 @@
|
|||||||
frameUrl.searchParams.set("channelId", config.channelId);
|
frameUrl.searchParams.set("channelId", config.channelId);
|
||||||
frameUrl.searchParams.set("baseUrl", config.baseUrl);
|
frameUrl.searchParams.set("baseUrl", config.baseUrl);
|
||||||
if (config.apiBaseUrl) frameUrl.searchParams.set("apiBaseUrl", config.apiBaseUrl);
|
if (config.apiBaseUrl) frameUrl.searchParams.set("apiBaseUrl", config.apiBaseUrl);
|
||||||
|
if (config.externalId) frameUrl.searchParams.set("externalId", config.externalId);
|
||||||
if (config.externalName) frameUrl.searchParams.set("externalName", config.externalName);
|
if (config.externalName) frameUrl.searchParams.set("externalName", config.externalName);
|
||||||
if (config.userToken) frameUrl.searchParams.set("userToken", config.userToken);
|
if (config.userToken) frameUrl.searchParams.set("userToken", config.userToken);
|
||||||
return frameUrl;
|
return frameUrl;
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { create } from "zustand"
|
|||||||
import {
|
import {
|
||||||
closeImConversation,
|
closeImConversation,
|
||||||
createOrMatchImConversation,
|
createOrMatchImConversation,
|
||||||
|
ensureCustomerSession,
|
||||||
fetchImMessages,
|
fetchImMessages,
|
||||||
fetchImWidgetConfig,
|
fetchImWidgetConfig,
|
||||||
markImMessageRead,
|
markImMessageRead,
|
||||||
sendImMessage,
|
sendImMessage,
|
||||||
uploadImAttachment,
|
uploadImAttachment,
|
||||||
uploadImImage,
|
uploadImImage,
|
||||||
|
applyCustomerSessionRefresh,
|
||||||
type ImAsset,
|
type ImAsset,
|
||||||
type ImConversation,
|
type ImConversation,
|
||||||
type ImMessage,
|
type ImMessage,
|
||||||
@@ -159,11 +161,17 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payload = event.data ?? event.payload
|
||||||
|
if (event.type === "customer_session.refresh") {
|
||||||
|
applyCustomerSessionRefresh(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const conversationId = get().conversation?.id
|
const conversationId = get().conversation?.id
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const payload = event.data ?? event.payload
|
|
||||||
if (event.type === "resyncRequired") {
|
if (event.type === "resyncRequired") {
|
||||||
void get().refreshMessages()
|
void get().refreshMessages()
|
||||||
return
|
return
|
||||||
@@ -281,6 +289,11 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
|||||||
themeColor: widgetConfig.themeColor || "#2563eb",
|
themeColor: widgetConfig.themeColor || "#2563eb",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await ensureCustomerSession()
|
||||||
|
if (bootstrapToken !== token || !get().isOpen) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let currentConversation = get().conversation
|
let currentConversation = get().conversation
|
||||||
if (!get().initialized || !currentConversation) {
|
if (!get().initialized || !currentConversation) {
|
||||||
currentConversation = await createOrMatchImConversation()
|
currentConversation = await createOrMatchImConversation()
|
||||||
|
|||||||
@@ -41,6 +41,9 @@
|
|||||||
merged.baseUrl = String(merged.baseUrl || window.location.origin).replace(/\/$/, "");
|
merged.baseUrl = String(merged.baseUrl || window.location.origin).replace(/\/$/, "");
|
||||||
merged.apiBaseUrl = String(merged.apiBaseUrl || merged.baseUrl).replace(/\/$/, "");
|
merged.apiBaseUrl = String(merged.apiBaseUrl || merged.baseUrl).replace(/\/$/, "");
|
||||||
merged.channelId = String(merged.channelId || "");
|
merged.channelId = String(merged.channelId || "");
|
||||||
|
if (merged.externalId) {
|
||||||
|
merged.externalId = String(merged.externalId);
|
||||||
|
}
|
||||||
if (merged.userToken) {
|
if (merged.userToken) {
|
||||||
merged.userToken = String(merged.userToken);
|
merged.userToken = String(merged.userToken);
|
||||||
}
|
}
|
||||||
@@ -66,6 +69,7 @@
|
|||||||
if (config.position) frameUrl.searchParams.set("position", config.position);
|
if (config.position) frameUrl.searchParams.set("position", config.position);
|
||||||
if (config.themeColor) frameUrl.searchParams.set("themeColor", config.themeColor);
|
if (config.themeColor) frameUrl.searchParams.set("themeColor", config.themeColor);
|
||||||
if (config.width) frameUrl.searchParams.set("width", config.width);
|
if (config.width) frameUrl.searchParams.set("width", config.width);
|
||||||
|
if (config.externalId) frameUrl.searchParams.set("externalId", config.externalId);
|
||||||
if (config.externalName) frameUrl.searchParams.set("externalName", config.externalName);
|
if (config.externalName) frameUrl.searchParams.set("externalName", config.externalName);
|
||||||
if (config.userToken) frameUrl.searchParams.set("userToken", config.userToken);
|
if (config.userToken) frameUrl.searchParams.set("userToken", config.userToken);
|
||||||
return frameUrl;
|
return frameUrl;
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user