H-162: sync XST reception before outbound delivery (#39)

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-16 21:47:46 +08:00
committed by GitHub
co-authored by rogee
parent 11b48bde97
commit f374fb9856
12 changed files with 329 additions and 30 deletions
@@ -134,7 +134,7 @@ func (q *Queries) FailInboundEvent(ctx context.Context, arg FailInboundEventPara
}
const getConversationMap = `-- name: GetConversationMap :one
SELECT account_id, swt_sid, gochat_contact_source_id, gochat_contact_id, gochat_conversation_id, gochat_display_id, swt_assignee_name, created_at, updated_at, xst_required, xst_token, xst_cid, xst_kfname FROM conversation_maps
SELECT account_id, swt_sid, gochat_contact_source_id, gochat_contact_id, gochat_conversation_id, gochat_display_id, swt_assignee_name, created_at, updated_at, xst_required, xst_token, xst_cid, xst_kfname, xst_sync_key FROM conversation_maps
WHERE account_id = ? AND swt_sid = ?
LIMIT 1
`
@@ -161,6 +161,7 @@ func (q *Queries) GetConversationMap(ctx context.Context, arg GetConversationMap
&i.XstToken,
&i.XstCid,
&i.XstKfname,
&i.XstSyncKey,
)
return &i, err
}
@@ -218,6 +219,30 @@ func (q *Queries) GetLatestConversationState(ctx context.Context, arg GetLatestC
return text, err
}
const getLatestXSTOperator = `-- name: GetLatestXSTOperator :one
SELECT text, seq_id FROM inbound_events
WHERE account_id = ? AND swt_sid = ? AND kind = 6
ORDER BY id DESC
LIMIT 1
`
type GetLatestXSTOperatorParams struct {
AccountID int64 `json:"account_id"`
SwtSid string `json:"swt_sid"`
}
type GetLatestXSTOperatorRow struct {
Text *string `json:"text"`
SeqID int64 `json:"seq_id"`
}
func (q *Queries) GetLatestXSTOperator(ctx context.Context, arg GetLatestXSTOperatorParams) (*GetLatestXSTOperatorRow, error) {
row := q.db.QueryRowContext(ctx, getLatestXSTOperator, arg.AccountID, arg.SwtSid)
var i GetLatestXSTOperatorRow
err := row.Scan(&i.Text, &i.SeqID)
return &i, err
}
const getMessageMapBySWTMessageID = `-- name: GetMessageMapBySWTMessageID :one
SELECT account_id, swt_sid, swt_message_id, swt_seq_id, kind, child_index, direction, gochat_message_id, gochat_source_id, content_fingerprint, retracted_at, created_at, updated_at FROM message_maps
WHERE account_id = ? AND swt_sid = ? AND swt_message_id = ?
@@ -361,6 +386,33 @@ func (q *Queries) InsertMessageMap(ctx context.Context, arg InsertMessageMapPara
return err
}
const markConversationXSTSynced = `-- name: MarkConversationXSTSynced :execrows
UPDATE conversation_maps SET
xst_sync_key = ?,
updated_at = CURRENT_TIMESTAMP
WHERE account_id = ? AND swt_sid = ? AND xst_token = ?
`
type MarkConversationXSTSyncedParams struct {
XstSyncKey *string `json:"xst_sync_key"`
AccountID int64 `json:"account_id"`
SwtSid string `json:"swt_sid"`
XstToken *string `json:"xst_token"`
}
func (q *Queries) MarkConversationXSTSynced(ctx context.Context, arg MarkConversationXSTSyncedParams) (int64, error) {
result, err := q.db.ExecContext(ctx, markConversationXSTSynced,
arg.XstSyncKey,
arg.AccountID,
arg.SwtSid,
arg.XstToken,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const markMessageMapRetracted = `-- name: MarkMessageMapRetracted :exec
UPDATE message_maps SET
retracted_at = CURRENT_TIMESTAMP,
@@ -426,7 +478,7 @@ ON CONFLICT(account_id, swt_sid) DO UPDATE SET
gochat_display_id = COALESCE(excluded.gochat_display_id, conversation_maps.gochat_display_id),
swt_assignee_name = COALESCE(excluded.swt_assignee_name, conversation_maps.swt_assignee_name),
updated_at = CURRENT_TIMESTAMP
RETURNING account_id, swt_sid, gochat_contact_source_id, gochat_contact_id, gochat_conversation_id, gochat_display_id, swt_assignee_name, created_at, updated_at, xst_required, xst_token, xst_cid, xst_kfname
RETURNING account_id, swt_sid, gochat_contact_source_id, gochat_contact_id, gochat_conversation_id, gochat_display_id, swt_assignee_name, created_at, updated_at, xst_required, xst_token, xst_cid, xst_kfname, xst_sync_key
`
type UpsertConversationMapParams struct {
@@ -464,6 +516,7 @@ func (q *Queries) UpsertConversationMap(ctx context.Context, arg UpsertConversat
&i.XstToken,
&i.XstCid,
&i.XstKfname,
&i.XstSyncKey,
)
return &i, err
}
@@ -479,7 +532,7 @@ ON CONFLICT(account_id, swt_sid) DO UPDATE SET
xst_cid = COALESCE(excluded.xst_cid, conversation_maps.xst_cid),
xst_kfname = COALESCE(excluded.xst_kfname, conversation_maps.xst_kfname),
updated_at = CURRENT_TIMESTAMP
RETURNING account_id, swt_sid, gochat_contact_source_id, gochat_contact_id, gochat_conversation_id, gochat_display_id, swt_assignee_name, created_at, updated_at, xst_required, xst_token, xst_cid, xst_kfname
RETURNING account_id, swt_sid, gochat_contact_source_id, gochat_contact_id, gochat_conversation_id, gochat_display_id, swt_assignee_name, created_at, updated_at, xst_required, xst_token, xst_cid, xst_kfname, xst_sync_key
`
type UpsertConversationXSTRouteParams struct {
@@ -517,6 +570,7 @@ func (q *Queries) UpsertConversationXSTRoute(ctx context.Context, arg UpsertConv
&i.XstToken,
&i.XstCid,
&i.XstKfname,
&i.XstSyncKey,
)
return &i, err
}
@@ -59,6 +59,7 @@ type ConversationMap struct {
XstToken *string `json:"xst_token"`
XstCid *string `json:"xst_cid"`
XstKfname *string `json:"xst_kfname"`
XstSyncKey *string `json:"xst_sync_key"`
}
type InboundEvent struct {
@@ -0,0 +1 @@
ALTER TABLE conversation_maps DROP COLUMN xst_sync_key;
@@ -0,0 +1 @@
ALTER TABLE conversation_maps ADD COLUMN xst_sync_key TEXT;
@@ -88,6 +88,12 @@ WHERE account_id = ? AND swt_sid = ? AND kind = 0
ORDER BY id DESC
LIMIT 1;
-- name: GetLatestXSTOperator :one
SELECT text, seq_id FROM inbound_events
WHERE account_id = ? AND swt_sid = ? AND kind = 6
ORDER BY id DESC
LIMIT 1;
-- name: UpsertConversationXSTRoute :one
INSERT INTO conversation_maps (
account_id, swt_sid, gochat_contact_source_id,
@@ -101,6 +107,12 @@ ON CONFLICT(account_id, swt_sid) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP
RETURNING *;
-- name: MarkConversationXSTSynced :execrows
UPDATE conversation_maps SET
xst_sync_key = ?,
updated_at = CURRENT_TIMESTAMP
WHERE account_id = ? AND swt_sid = ? AND xst_token = ?;
-- name: UpsertConversationMap :one
INSERT INTO conversation_maps (
account_id, swt_sid, gochat_contact_source_id, gochat_contact_id,
@@ -87,7 +87,7 @@ func TestOperationalCommands(t *testing.T) {
t.Fatalf("%v produced no output", args)
}
}
if version, err := store.InspectDatabase(context.Background(), backupPath); err != nil || version != 6 {
if version, err := store.InspectDatabase(context.Background(), backupPath); err != nil || version != 7 {
t.Fatalf("backup version = %d, %v", version, err)
}
@@ -32,6 +32,7 @@ type SessionProvider interface {
type MessageSender interface {
SendText(context.Context, swt.Session, string, string) (swt.SendResult, error)
SyncXSTReception(context.Context, swt.Session, swt.XSTRoute) (swt.SendResult, error)
SendXSTText(context.Context, swt.Session, swt.XSTRoute, string) (swt.SendResult, error)
SendImage(context.Context, swt.Session, string, swt.Upload) (swt.SendResult, error)
SendFile(context.Context, swt.Session, string, swt.Upload) (swt.SendResult, error)
@@ -46,6 +47,12 @@ type ResultClient interface {
UpdateMessageStatus(context.Context, int64, int64, gochat.MessageResult) error
}
type xstDeliveryRoute struct {
swt.XSTRoute
syncKey string
synced bool
}
type Outbound struct {
store *store.Store
sessions SessionProvider
@@ -235,7 +242,7 @@ func (o *Outbound) processOutbound(ctx context.Context) (bool, error) {
return true, err
}
func (o *Outbound) sendPart(ctx context.Context, message *dbgen.OutboundMessage, part *dbgen.OutboundPart, xstRoute *swt.XSTRoute) error {
func (o *Outbound) sendPart(ctx context.Context, message *dbgen.OutboundMessage, part *dbgen.OutboundPart, xstRoute *xstDeliveryRoute) error {
if part.PartType == "video" || part.PartType == "unsupported" {
return &permanentDeliveryError{code: "unsupported_outbound_content", err: fmt.Errorf("outbound %s is not supported", part.PartType)}
}
@@ -265,7 +272,18 @@ func (o *Outbound) sendPart(ctx context.Context, message *dbgen.OutboundMessage,
if err != nil || rows != 1 {
return errors.Join(err, errors.New("XST stage was not claimable"))
}
if _, err := o.sender.SendXSTText(ctx, session, *xstRoute, content); err != nil {
if !xstRoute.synced {
if _, err := o.sender.SyncXSTReception(ctx, session, xstRoute.XSTRoute); err != nil {
return &xstDeliveryError{err: err}
}
rows, err = o.store.Writer().MarkConversationXSTSynced(ctx, dbgen.MarkConversationXSTSyncedParams{
XstSyncKey: &xstRoute.syncKey, AccountID: message.AccountID, SwtSid: message.SwtSid, XstToken: &xstRoute.Token,
})
if err != nil || rows != 1 {
return &xstDeliveryError{err: &swt.Error{Operation: "persist_xst_reception_sync", Code: "persistence_error", Retryable: true, Err: errors.Join(err, errors.New("XST reception sync was not persisted"))}}
}
}
if _, err := o.sender.SendXSTText(ctx, session, xstRoute.XSTRoute, content); err != nil {
return &xstDeliveryError{err: err}
}
rows, err = o.store.Writer().CompleteOutboundXST(ctx, message.ID)
@@ -315,7 +333,7 @@ func (o *Outbound) sendPart(ctx context.Context, message *dbgen.OutboundMessage,
})
}
func (o *Outbound) xstRoute(ctx context.Context, message *dbgen.OutboundMessage) (*swt.XSTRoute, error) {
func (o *Outbound) xstRoute(ctx context.Context, message *dbgen.OutboundMessage) (*xstDeliveryRoute, error) {
mapping, err := o.store.Reader().GetConversationMap(ctx, dbgen.GetConversationMapParams{AccountID: message.AccountID, SwtSid: message.SwtSid})
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
@@ -327,7 +345,26 @@ func (o *Outbound) xstRoute(ctx context.Context, message *dbgen.OutboundMessage)
if err != nil {
return nil, fmt.Errorf("load XST conversation state: %w", err)
}
route := &swt.XSTRoute{SID: message.SwtSid, CID: value(mapping.XstCid), Token: value(mapping.XstToken), State: value(state), KFName: firstText(value(mapping.XstKfname), value(mapping.SwtAssigneeName))}
operator, err := o.store.Reader().GetLatestXSTOperator(ctx, dbgen.GetLatestXSTOperatorParams{AccountID: message.AccountID, SwtSid: message.SwtSid})
if err != nil {
return nil, fmt.Errorf("load XST operator assignment: %w", err)
}
account, err := o.store.Reader().GetAccountByID(ctx, message.AccountID)
if err != nil {
return nil, fmt.Errorf("load XST account: %w", err)
}
if !strings.EqualFold(strings.TrimSpace(value(operator.Text)), strings.TrimSpace(account.Username)) {
return nil, errors.New("XST conversation is not assigned to the current operator")
}
syncKey := fmt.Sprintf("%s\x1a%s\x1a%d", value(mapping.XstToken), account.Username, operator.SeqID)
route := &xstDeliveryRoute{
XSTRoute: swt.XSTRoute{
SID: message.SwtSid, CID: value(mapping.XstCid), Token: value(mapping.XstToken), State: value(state),
KFName: firstText(value(mapping.XstKfname), value(mapping.SwtAssigneeName), value(operator.Text)),
},
syncKey: syncKey,
synced: mapping.XstSyncKey != nil && *mapping.XstSyncKey == syncKey,
}
if err := route.Validate(); err != nil {
return nil, err
}
@@ -6,6 +6,7 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -60,11 +61,21 @@ func TestXSTOutboundRequiresBothStagesAndEcho(t *testing.T) {
}, false); err != nil {
t.Fatal(err)
}
primaryCalls, xstCalls := 0, 0
primaryCalls, syncCalls, xstCalls := 0, 0, 0
order := []string{}
sender := senderStub{
beforeReturn: func() { primaryCalls++ },
beforeReturn: func() { primaryCalls++; order = append(order, "swt") },
syncXST: func(route swt.XSTRoute) (swt.SendResult, error) {
syncCalls++
order = append(order, "sync")
if route.SID != "visitor" || route.CID != "cid-1" || route.State != "5" {
t.Fatalf("XST sync route = %#v", route)
}
return swt.SendResult{Status: "ok"}, nil
},
xst: func(route swt.XSTRoute, _ string) (swt.SendResult, error) {
xstCalls++
order = append(order, "xst")
if route.KFName != "口腔客服2" {
t.Fatalf("XST KFName = %q", route.KFName)
}
@@ -77,8 +88,8 @@ func TestXSTOutboundRequiresBothStagesAndEcho(t *testing.T) {
t.Fatalf("outbound = %v, %v", worked, err)
}
queued, err := database.Reader().GetOutboundByGoChatMessageID(ctx, 77)
if err != nil || primaryCalls != 1 || xstCalls != 1 || queued.DeliveryStatus != "uncertain" || queued.XstDeliveryStatus != "delivered" {
t.Fatalf("queued=%#v primary=%d xst=%d err=%v", queued, primaryCalls, xstCalls, err)
if err != nil || primaryCalls != 1 || syncCalls != 1 || xstCalls != 1 || strings.Join(order, ",") != "swt,sync,xst" || queued.DeliveryStatus != "uncertain" || queued.XstDeliveryStatus != "delivered" {
t.Fatalf("queued=%#v primary=%d sync=%d xst=%d order=%v err=%v", queued, primaryCalls, syncCalls, xstCalls, order, err)
}
if worked, err := outbound.processStatus(ctx); err != nil || worked || results.result.Status != "" {
t.Fatalf("premature status = %v, %v, %#v", worked, err, results)
@@ -143,6 +154,51 @@ func TestXSTRetryOnlyRepeatsIncompleteStage(t *testing.T) {
}
}
func TestXSTReceptionSyncRetryDoesNotRepeatPrimarySend(t *testing.T) {
ctx := context.Background()
database, account := deliveryDatabase(t, ctx)
seedXSTRoute(t, database, account)
if _, _, err := database.EnqueueOutbound(ctx, store.OutboundInput{
AccountID: account.ID, SWTSessionID: "visitor", EventID: "message:77:created", OccurredAt: time.Now(), GoChatMessageID: 77,
MessageType: "text", Content: stringPointer("hello"), Payload: deliveryPayload(t, nil),
}, false); err != nil {
t.Fatal(err)
}
primaryCalls, syncCalls, xstCalls := 0, 0, 0
sender := senderStub{
beforeReturn: func() { primaryCalls++ },
syncXST: func(swt.XSTRoute) (swt.SendResult, error) {
syncCalls++
if syncCalls == 1 {
return swt.SendResult{}, &swt.Error{Operation: "sync_xst_reception", Code: "network_error", Retryable: true}
}
return swt.SendResult{Status: "ok"}, nil
},
xst: func(swt.XSTRoute, string) (swt.SendResult, error) {
xstCalls++
return swt.SendResult{Status: "ok"}, nil
},
}
outbound, _ := NewOutbound(database, sessionStub{}, sender, &resultRecorder{}, nil, 1)
if worked, err := outbound.processOutbound(ctx); err != nil || !worked {
t.Fatalf("first delivery = %v, %v", worked, err)
}
queued, err := database.Reader().GetOutboundByGoChatMessageID(ctx, 77)
if err != nil || queued.SwtSentAt == nil || queued.DeliveryStatus != "pending" || primaryCalls != 1 || syncCalls != 1 || xstCalls != 0 {
t.Fatalf("first queued=%#v primary=%d sync=%d xst=%d err=%v", queued, primaryCalls, syncCalls, xstCalls, err)
}
if delay := time.Until(*queued.NextAttemptAt); delay > 0 {
time.Sleep(delay)
}
if worked, err := outbound.processOutbound(ctx); err != nil || !worked {
t.Fatalf("retry delivery = %v, %v", worked, err)
}
mapping, err := database.Reader().GetConversationMap(ctx, dbgen.GetConversationMapParams{AccountID: account.ID, SwtSid: "visitor"})
if err != nil || primaryCalls != 1 || syncCalls != 2 || xstCalls != 1 || mapping.XstSyncKey == nil {
t.Fatalf("mapping=%#v primary=%d sync=%d xst=%d err=%v", mapping, primaryCalls, syncCalls, xstCalls, err)
}
}
func TestXSTRejectThenEchoRetriesOnlyXST(t *testing.T) {
ctx := context.Background()
database, account := deliveryDatabase(t, ctx)
@@ -681,7 +737,7 @@ func deliveryDatabase(t *testing.T, ctx context.Context) (*store.Store, *dbgen.A
t.Cleanup(func() { _ = database.Close() })
account, err := database.Writer().CreateAccount(ctx, dbgen.CreateAccountParams{
GochatAccountID: 1, GochatInboxID: 10, GochatInboxIdentifier: "identifier", ConfigVersion: 1,
SessionID: "BYT99917999", Username: "agent", Password: "password", Enabled: 1,
SessionID: "BYT99917999", Username: "口腔客服2", Password: "password", Enabled: 1,
DesiredPresence: "online", GochatHmacToken: "hmac", GochatWebhookSecret: "secret",
})
if err != nil {
@@ -694,11 +750,12 @@ func seedXSTRoute(t *testing.T, database *store.Store, account *dbgen.Account) {
t.Helper()
ctx := context.Background()
events, err := swt.ParseHeartbeatBody(
"visitor 65 %7Cxst%7Csbox%1Axst-route-token%1Aquery%1Aword%1Acity%1A48989266 41 639183484622475271\r\n" +
"visitor 31 %7Cdistribute_lastoname%7C%E5%8F%A3%E8%85%94%E5%AE%A2%E6%9C%8D2 42 639183484622475272\r\n" +
"visitor 31 %7CACT_XST%7CNotShow%7CQuDaoVisitorInfoMsg%7Cprivate 43 639183484622475273\r\n" +
"visitor 0 %7C5 44 639183484622475274\r\n" +
"visitor 24 cid-1 45 639183484622475275\r\n")
"visitor 24 cid-1 40 639183484622475270\r\n" +
"visitor 65 %7Cxst%7Csbox%1Axst-route-token%1Aquery%1Aword%1Acity%1A48989266 41 639183484622475271\r\n" +
"visitor 6 %7C%E5%8F%A3%E8%85%94%E5%AE%A2%E6%9C%8D2 42 639183484622475272\r\n" +
"visitor 0 %7C5 43 639183484622475273\r\n" +
"visitor 31 %7Cdistribute_lastoname%7C%E5%8F%A3%E8%85%94%E5%AE%A2%E6%9C%8D2 44 639183484622475274\r\n" +
"visitor 31 %7CACT_XST%7CNotShow%7CQuDaoVisitorInfoMsg%7Cprivate 45 639183484622475275\r\n")
if err != nil {
t.Fatal(err)
}
@@ -736,7 +793,7 @@ func deliveryPayload(t *testing.T, attachments []gochat.WebhookAttachment) strin
type sessionStub struct{}
func (sessionStub) WithSession(ctx context.Context, _ int64, operation func(swt.Session) error) error {
return operation(swt.Session{BaseURL: "http://example.test/", SiteID: "site", LoginName: "agent", MAToken: "token"})
return operation(swt.Session{BaseURL: "http://example.test/", SiteID: "site", LoginName: "口腔客服2", MAToken: "token"})
}
func (sessionStub) InvalidateSession(context.Context, int64) error { return nil }
@@ -745,13 +802,13 @@ type allowedSessionStub struct{ sessionStub }
func (allowedSessionStub) WithSession(ctx context.Context, _ int64, operation func(swt.Session) error) error {
purview := uint64(0)
return operation(swt.Session{BaseURL: "http://example.test/", SiteID: "site", LoginName: "agent", MAToken: "token", Purview: &purview})
return operation(swt.Session{BaseURL: "http://example.test/", SiteID: "site", LoginName: "口腔客服2", MAToken: "token", Purview: &purview})
}
type sessionRecorder struct{ invalidations int }
func (*sessionRecorder) WithSession(ctx context.Context, _ int64, operation func(swt.Session) error) error {
return operation(swt.Session{BaseURL: "http://example.test/", SiteID: "site", LoginName: "agent", MAToken: "token"})
return operation(swt.Session{BaseURL: "http://example.test/", SiteID: "site", LoginName: "口腔客服2", MAToken: "token"})
}
func (s *sessionRecorder) InvalidateSession(context.Context, int64) error {
@@ -762,6 +819,7 @@ func (s *sessionRecorder) InvalidateSession(context.Context, int64) error {
type senderStub struct {
err error
beforeReturn func()
syncXST func(swt.XSTRoute) (swt.SendResult, error)
xst func(swt.XSTRoute, string) (swt.SendResult, error)
}
@@ -784,6 +842,13 @@ func (s senderStub) SendVoice(context.Context, swt.Session, string, swt.Upload)
return swt.SendResult{Status: "ok"}, s.err
}
func (s senderStub) SyncXSTReception(_ context.Context, _ swt.Session, route swt.XSTRoute) (swt.SendResult, error) {
if s.syncXST != nil {
return s.syncXST(route)
}
return swt.SendResult{Status: "ok"}, s.err
}
func (s senderStub) SendXSTText(_ context.Context, _ swt.Session, route swt.XSTRoute, content string) (swt.SendResult, error) {
if s.xst != nil {
return s.xst(route, content)
@@ -827,6 +892,10 @@ func (*operationSender) SendVoice(context.Context, swt.Session, string, swt.Uplo
return swt.SendResult{}, nil
}
func (*operationSender) SyncXSTReception(context.Context, swt.Session, swt.XSTRoute) (swt.SendResult, error) {
return swt.SendResult{}, nil
}
func (*operationSender) SendXSTText(context.Context, swt.Session, swt.XSTRoute, string) (swt.SendResult, error) {
return swt.SendResult{}, nil
}
@@ -876,6 +945,10 @@ func (*partialSender) SendVoice(context.Context, swt.Session, string, swt.Upload
return swt.SendResult{Status: "ok"}, nil
}
func (*partialSender) SyncXSTReception(context.Context, swt.Session, swt.XSTRoute) (swt.SendResult, error) {
return swt.SendResult{Status: "ok"}, nil
}
func (*partialSender) SendXSTText(context.Context, swt.Session, swt.XSTRoute, string) (swt.SendResult, error) {
return swt.SendResult{Status: "ok"}, nil
}
@@ -620,7 +620,7 @@ func TestOnlineBackupCanBeOpenedReadOnly(t *testing.T) {
t.Fatalf("backup mode = %v", info.Mode().Perm())
}
version, err := InspectDatabase(ctx, backup)
if err != nil || version != 6 {
if err != nil || version != 7 {
t.Fatalf("backup version = %d, %v", version, err)
}
}
+83 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/md5"
"crypto/rand"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
@@ -222,27 +223,72 @@ func (c *Client) SendXSTText(ctx context.Context, session Session, route XSTRout
"msgkind": {"1"},
"kfversion": {xstClientVersion},
}
body, response, requestWritten, err := c.postFormTargetTracked(ctx, target+"?"+query.Encode(), form)
return c.sendXSTForm(ctx, target+"?"+query.Encode(), form, "send_xst_text", "xst_rejected", false, session, route)
}
func (c *Client) SyncXSTReception(ctx context.Context, session Session, route XSTRoute) (SendResult, error) {
if err := session.Validate(); err != nil {
return SendResult{}, err
}
if err := route.Validate(); err != nil {
return SendResult{}, err
}
deviceID := xstDeviceID(session)
ticks := nativeXSTTicks(c.now())
query := url.Values{
"t": {strconv.FormatInt(ticks, 10)},
"sign": {xstReceptionSignature(ticks, session, route, deviceID, xstClientVersion)},
"swtidhead": {session.SiteID},
"onamehead": {session.LoginName},
"kfversionhead": {xstClientVersion},
}
target, err := resolveEndpoint(xstBaseURL, "api/SwtSyncKfInfo.ashx")
if err != nil {
return SendResult{}, err
}
form := url.Values{
"RESET": {""},
"cid": {route.CID},
"sid": {route.SID},
"clienttype": {"2"},
"clientkind": {"3"},
"imeiaddress": {deviceID},
"accounttype": {"2"},
"state": {route.State},
"oname": {session.LoginName},
"siteid": {session.SiteID},
"kfversion": {xstClientVersion},
}
return c.sendXSTForm(ctx, target+"?"+query.Encode(), form, "sync_xst_reception", "xst_sync_rejected", true, session, route)
}
func (c *Client) sendXSTForm(ctx context.Context, target string, form url.Values, operation, rejectionCode string, idempotent bool, session Session, route XSTRoute) (SendResult, error) {
body, response, requestWritten, err := c.postFormTargetTracked(ctx, target, form)
if err != nil {
if response != nil && (response.StatusCode < 200 || response.StatusCode >= 300) {
return SendResult{}, &Error{Operation: "send_xst_text", Code: "http_error", Retryable: response.StatusCode >= 500, Err: err}
return SendResult{}, &Error{Operation: operation, Code: "http_error", Retryable: response.StatusCode >= 500, Err: err}
}
if requestWritten {
return SendResult{}, &Error{Operation: "send_xst_text", Code: "network_result_uncertain", Uncertain: true, Err: err}
if requestWritten && !idempotent {
return SendResult{}, &Error{Operation: operation, Code: "network_result_uncertain", Uncertain: true, Err: err}
}
return SendResult{}, &Error{Operation: "send_xst_text", Code: "network_error", Retryable: true, Err: err}
return SendResult{}, &Error{Operation: operation, Code: "network_error", Retryable: true, Err: err}
}
var payload struct {
Message string `json:"msg"`
}
if err := json.Unmarshal([]byte(body), &payload); err != nil {
return SendResult{Body: body}, &Error{Operation: "send_xst_text", Code: "invalid_response", Retryable: true, Err: err}
return SendResult{Body: body}, &Error{Operation: operation, Code: "invalid_response", Retryable: true, Err: err}
}
result := SendResult{Status: strings.TrimSpace(payload.Message), Body: body}
if strings.EqualFold(result.Status, "ok") {
return result, nil
}
return result, &Error{Operation: "send_xst_text", Code: "xst_rejected", Retryable: true}
detail := sanitizeXSTMessage(payload.Message, session.MAToken, route.Token, route.SID, route.CID, session.SiteID, session.LoginName)
var detailErr error
if detail != "" {
detailErr = errors.New(detail)
}
return result, &Error{Operation: operation, Code: rejectionCode, Retryable: true, Err: detailErr}
}
func javaFormEscape(value string) string {
@@ -271,6 +317,36 @@ func xstSignature(ticks int64, session Session, route XSTRoute, content, version
return hex.EncodeToString(digest[:])
}
func xstReceptionSignature(ticks int64, session Session, route XSTRoute, deviceID, version string) string {
separator := string(rune(26))
plain := strings.Join([]string{
strconv.FormatInt(ticks+10, 10), session.SiteID, "2", "3", deviceID, route.State,
"2", session.LoginName, route.CID, route.SID, version, "",
}, separator)
digest := md5.Sum([]byte(plain))
return hex.EncodeToString(digest[:])
}
func xstDeviceID(session Session) string {
digest := sha1.Sum([]byte("gochat-shangwutong\x1a" + session.SiteID + "\x1a" + session.LoginName))
return strings.ToUpper(hex.EncodeToString(digest[:]))
}
func sanitizeXSTMessage(message string, sensitive ...string) string {
message = strings.ToValidUTF8(message, "")
for _, value := range sensitive {
if strings.TrimSpace(value) != "" {
message = strings.ReplaceAll(message, value, "[redacted]")
}
}
message = strings.Join(strings.Fields(message), " ")
runes := []rune(message)
if len(runes) > 256 {
message = string(runes[:256])
}
return message
}
func (c *Client) sendHTML(ctx context.Context, session Session, sid, content, operation string) (SendResult, error) {
if err := session.Validate(); err != nil {
return SendResult{}, err
@@ -43,6 +43,17 @@ func TestXSTSignatureKnownVector(t *testing.T) {
}
}
func TestXSTReceptionSignatureKnownVector(t *testing.T) {
route := XSTRoute{SID: "sid-1", CID: "cid-1", Token: "xst-1", State: "5", KFName: "客服"}
deviceID := xstDeviceID(testSession())
if deviceID != "3352A6B5AE0FA4ED75745D33D454E3E61A1E860B" {
t.Fatalf("device ID = %q", deviceID)
}
if got := xstReceptionSignature(638900000000000000, testSession(), route, deviceID, xstClientVersion); got != "81a5ace9d65e85f519af28482ba6a200" {
t.Fatalf("signature = %q", got)
}
}
func TestNativeXSTTicksKnownVector(t *testing.T) {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
@@ -81,9 +92,38 @@ func TestClientSendXSTTextMatchesNativeWireProtocol(t *testing.T) {
}
}
func TestClientSyncXSTReceptionMatchesNativeWireProtocol(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
query := request.URL.Query()
if request.URL.Path != "/api/SwtSyncKfInfo.ashx" || query.Get("sign") == "" || query.Get("t") == "" ||
query.Get("swtidhead") != "99917999" || query.Get("onamehead") != "agent" || query.Get("kfversionhead") != xstClientVersion {
t.Fatalf("XST reception sync URL = %s", request.URL.String())
}
if err := request.ParseForm(); err != nil {
t.Fatal(err)
}
for key, want := range map[string]string{
"cid": "cid-1", "sid": "sid-1", "clienttype": "2", "clientkind": "3",
"imeiaddress": "3352A6B5AE0FA4ED75745D33D454E3E61A1E860B", "accounttype": "2",
"state": "5", "oname": "agent", "siteid": "99917999", "kfversion": xstClientVersion, "RESET": "",
} {
if got := request.Form.Get(key); got != want {
t.Fatalf("form[%s] = %q, want %q", key, got, want)
}
}
_, _ = response.Write([]byte(`{"msg":"ok"}`))
}))
defer server.Close()
client := NewClient(rewriteTransportClient(server.URL))
client.now = func() time.Time { return time.Date(2026, time.August, 16, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60)) }
if _, err := client.SyncXSTReception(context.Background(), testSession(), XSTRoute{SID: "sid-1", CID: "cid-1", Token: "xst-1", State: "5", KFName: "客服"}); err != nil {
t.Fatal(err)
}
}
func TestClientSendXSTTextRejectsNonOK(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
_, _ = response.Write([]byte(`{"msg":"failed"}`))
_, _ = response.Write([]byte(`{"msg":"failed for sid-1 xst-1\nplease sync reception"}`))
}))
defer server.Close()
_, err := NewClient(rewriteTransportClient(server.URL)).SendXSTText(context.Background(), testSession(), XSTRoute{
@@ -93,6 +133,9 @@ func TestClientSendXSTTextRejectsNonOK(t *testing.T) {
if !errors.As(err, &protocolErr) || protocolErr.Code != "xst_rejected" || !protocolErr.Retryable || protocolErr.Uncertain {
t.Fatalf("error = %#v", err)
}
if detail := protocolErr.Error(); !strings.Contains(detail, "please sync reception") || strings.Contains(detail, "sid-1") || strings.Contains(detail, "xst-1") || strings.Contains(detail, "\n") {
t.Fatalf("error detail = %q", detail)
}
}
func TestClientHeartbeatParsesEvents(t *testing.T) {
+1
View File
@@ -4,6 +4,7 @@ sql:
schema:
- "db/migrations/001_init.up.sql"
- "db/migrations/006_add_xst_outbound_stages.up.sql"
- "db/migrations/007_add_xst_reception_sync.up.sql"
queries: "db/queries"
gen:
go: