diff --git a/channels/shangwutong/internal/account/manager_test.go b/channels/shangwutong/internal/account/manager_test.go index 81746bb8..2d338ad7 100644 --- a/channels/shangwutong/internal/account/manager_test.go +++ b/channels/shangwutong/internal/account/manager_test.go @@ -47,6 +47,9 @@ func TestManagerRestoresOneSupervisorAndDoesNotLogoutOnShutdown(t *testing.T) { if heartbeat.cursor != (swt.Cursor{MaxWordID: 42, MaxOTick: 9, MaxTmpID: 7}) { t.Fatalf("restored cursor = %#v", heartbeat.cursor) } + if heartbeat.session.SessionID != account.SessionID || heartbeat.session.SiteID != "99917999" { + t.Fatalf("restored session identifiers = %#v", heartbeat.session) + } waitSignal(t, protocol.presences, "presence restore") if err := manager.WakeInbox(ctx, account.GochatInboxID); err != nil { t.Fatal(err) @@ -223,6 +226,7 @@ type fakeProtocol struct { } type heartbeatCall struct { + session swt.Session currentSID string typingSID string cursor swt.Cursor @@ -242,7 +246,7 @@ func (f *fakeProtocol) Login(context.Context, swt.Credentials, swt.Presence, str return swt.Session{BaseURL: "http://example.test/", SiteID: "99917999", LoginName: "agent", MAToken: "new-token"}, nil } -func (f *fakeProtocol) Heartbeat(_ context.Context, _ swt.Session, cursor swt.Cursor, currentSID, typingSID string) (swt.HeartbeatResult, error) { +func (f *fakeProtocol) Heartbeat(_ context.Context, session swt.Session, cursor swt.Cursor, currentSID, typingSID string) (swt.HeartbeatResult, error) { f.mu.Lock() if f.heartbeatPanics > 0 { f.heartbeatPanics-- @@ -250,7 +254,7 @@ func (f *fakeProtocol) Heartbeat(_ context.Context, _ swt.Session, cursor swt.Cu panic("heartbeat panic") } f.mu.Unlock() - f.heartbeats <- heartbeatCall{currentSID: currentSID, typingSID: typingSID, cursor: cursor} + f.heartbeats <- heartbeatCall{session: session, currentSID: currentSID, typingSID: typingSID, cursor: cursor} return swt.HeartbeatResult{Status: swt.HeartbeatOK}, nil } diff --git a/channels/shangwutong/internal/account/supervisor.go b/channels/shangwutong/internal/account/supervisor.go index e554eec8..e4bb589d 100644 --- a/channels/shangwutong/internal/account/supervisor.go +++ b/channels/shangwutong/internal/account/supervisor.go @@ -326,7 +326,10 @@ func (s *supervisor) restoreSession(account *dbgen.Account) bool { return false } s.gate.Lock() - s.session = swt.Session{BaseURL: *account.BaseUrl, SiteID: *account.SiteID, LoginName: *account.LoginName, MAToken: *account.MaToken} + s.session = swt.Session{ + BaseURL: *account.BaseUrl, SessionID: account.SessionID, SiteID: *account.SiteID, + LoginName: *account.LoginName, MAToken: *account.MaToken, + } s.sessionVersion++ s.gate.Unlock() return true diff --git a/channels/shangwutong/internal/delivery/outbound_test.go b/channels/shangwutong/internal/delivery/outbound_test.go index f106f81a..3ea014ca 100644 --- a/channels/shangwutong/internal/delivery/outbound_test.go +++ b/channels/shangwutong/internal/delivery/outbound_test.go @@ -199,6 +199,78 @@ func TestXSTReceptionSyncRetryDoesNotRepeatPrimarySend(t *testing.T) { } } +func TestXSTRouteInvalidatesReceptionSyncWhenRouteIdentityChanges(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + seedXSTRoute(t, database, account) + outbound, _ := NewOutbound(database, sessionStub{}, senderStub{}, &resultRecorder{}, nil, 1) + message := &dbgen.OutboundMessage{AccountID: account.ID, SwtSid: "visitor"} + + route, err := outbound.xstRoute(ctx, message) + if err != nil || route.synced || route.KFName != "口腔客服2" { + t.Fatalf("initial route = %#v, %v", route, err) + } + if rows, err := database.Writer().MarkConversationXSTSynced(ctx, dbgen.MarkConversationXSTSyncedParams{ + XstSyncKey: &route.syncKey, AccountID: account.ID, SwtSid: "visitor", XstToken: &route.Token, + }); err != nil || rows != 1 { + t.Fatalf("mark synced = %d, %v", rows, err) + } + route, err = outbound.xstRoute(ctx, message) + if err != nil || !route.synced { + t.Fatalf("cached route = %#v, %v", route, err) + } + + dedicatedName := "百度专用客服名" + if _, err := database.Writer().UpsertConversationXSTRoute(ctx, dbgen.UpsertConversationXSTRouteParams{ + AccountID: account.ID, SwtSid: "visitor", GochatContactSourceID: "visitor", + XstRequired: 1, XstKfname: &dedicatedName, + }); err != nil { + t.Fatal(err) + } + route, err = outbound.xstRoute(ctx, message) + if err != nil || !route.synced || route.KFName != dedicatedName { + t.Fatalf("dedicated-name route = %#v, %v", route, err) + } + + newToken := "xst-route-token-2" + if _, err := database.Writer().UpsertConversationXSTRoute(ctx, dbgen.UpsertConversationXSTRouteParams{ + AccountID: account.ID, SwtSid: "visitor", GochatContactSourceID: "visitor", + XstRequired: 1, XstToken: &newToken, + }); err != nil { + t.Fatal(err) + } + route, err = outbound.xstRoute(ctx, message) + if err != nil || route.synced || route.Token != newToken { + t.Fatalf("new-token route = %#v, %v", route, err) + } + if rows, err := database.Writer().MarkConversationXSTSynced(ctx, dbgen.MarkConversationXSTSyncedParams{ + XstSyncKey: &route.syncKey, AccountID: account.ID, SwtSid: "visitor", XstToken: &route.Token, + }); err != nil || rows != 1 { + t.Fatalf("mark new token synced = %d, %v", rows, err) + } + + if _, err := database.PersistHeartbeat(ctx, account, []swt.HeartbeatEvent{{ + SessionID: "visitor", Kind: 6, Text: "口腔客服2", SeqID: 100, + Timestamp: time.Now().Format(time.RFC3339Nano), RawLine: "visitor 6 |口腔客服2 100 timestamp", + }}); err != nil { + t.Fatal(err) + } + route, err = outbound.xstRoute(ctx, message) + if err != nil || route.synced { + t.Fatalf("new-operator-sequence route = %#v, %v", route, err) + } + + if _, err := database.PersistHeartbeat(ctx, account, []swt.HeartbeatEvent{{ + SessionID: "visitor", Kind: 6, Text: "其他客服", SeqID: 101, + Timestamp: time.Now().Format(time.RFC3339Nano), RawLine: "visitor 6 |其他客服 101 timestamp", + }}); err != nil { + t.Fatal(err) + } + if route, err = outbound.xstRoute(ctx, message); err == nil || !strings.Contains(err.Error(), "not assigned to the current operator") { + t.Fatalf("mismatched-operator route = %#v, %v", route, err) + } +} + func TestXSTRejectThenEchoRetriesOnlyXST(t *testing.T) { ctx := context.Background() database, account := deliveryDatabase(t, ctx) diff --git a/channels/shangwutong/internal/swt/client.go b/channels/shangwutong/internal/swt/client.go index 190d82a9..57e1b176 100644 --- a/channels/shangwutong/internal/swt/client.go +++ b/channels/shangwutong/internal/swt/client.go @@ -124,6 +124,7 @@ func (c *Client) Login(ctx context.Context, credentials Credentials, presence Pr } return Session{ BaseURL: baseURL, + SessionID: credentials.SessionID, SiteID: credentials.SessionID[3:], LoginName: credentials.Username, MAToken: result.MAToken(), @@ -190,7 +191,7 @@ func (c *Client) SendText(ctx context.Context, session Session, sid, text string } func (c *Client) SendXSTText(ctx context.Context, session Session, route XSTRoute, text string) (SendResult, error) { - if err := session.Validate(); err != nil { + if err := session.ValidateXST(); err != nil { return SendResult{}, err } if err := route.Validate(); err != nil { @@ -201,7 +202,7 @@ func (c *Client) SendXSTText(ctx context.Context, session Session, route XSTRout query := url.Values{ "t": {strconv.FormatInt(ticks, 10)}, "sign": {xstSignature(ticks, session, route, wireContent, xstClientVersion)}, - "swtidhead": {session.SiteID}, + "swtidhead": {session.SessionID}, "onamehead": {session.LoginName}, "kfversionhead": {xstClientVersion}, } @@ -212,7 +213,7 @@ func (c *Client) SendXSTText(ctx context.Context, session Session, route XSTRout form := url.Values{ "html": {wireContent}, "RESET": {""}, - "siteid": {session.SiteID}, + "siteid": {session.SessionID}, "oname": {session.LoginName}, "cid": {route.CID}, "sid": {route.SID}, @@ -227,7 +228,7 @@ func (c *Client) SendXSTText(ctx context.Context, session Session, route XSTRout } func (c *Client) SyncXSTReception(ctx context.Context, session Session, route XSTRoute) (SendResult, error) { - if err := session.Validate(); err != nil { + if err := session.ValidateXST(); err != nil { return SendResult{}, err } if err := route.Validate(); err != nil { @@ -238,7 +239,7 @@ func (c *Client) SyncXSTReception(ctx context.Context, session Session, route XS query := url.Values{ "t": {strconv.FormatInt(ticks, 10)}, "sign": {xstReceptionSignature(ticks, session, route, deviceID, xstClientVersion)}, - "swtidhead": {session.SiteID}, + "swtidhead": {session.SessionID}, "onamehead": {session.LoginName}, "kfversionhead": {xstClientVersion}, } @@ -256,7 +257,7 @@ func (c *Client) SyncXSTReception(ctx context.Context, session Session, route XS "accounttype": {"2"}, "state": {route.State}, "oname": {session.LoginName}, - "siteid": {session.SiteID}, + "siteid": {session.SessionID}, "kfversion": {xstClientVersion}, } return c.sendXSTForm(ctx, target+"?"+query.Encode(), form, "sync_xst_reception", "xst_sync_rejected", true, session, route) @@ -283,7 +284,7 @@ func (c *Client) sendXSTForm(ctx context.Context, target string, form url.Values if strings.EqualFold(result.Status, "ok") { return result, nil } - detail := sanitizeXSTMessage(payload.Message, session.MAToken, route.Token, route.SID, route.CID, session.SiteID, session.LoginName) + detail := sanitizeXSTMessage(payload.Message, session.MAToken, route.Token, route.SID, route.CID, session.SessionID, session.SiteID, session.LoginName) var detailErr error if detail != "" { detailErr = errors.New(detail) @@ -310,7 +311,7 @@ func nativeXSTTicks(now time.Time) int64 { func xstSignature(ticks int64, session Session, route XSTRoute, content, version string) string { separator := string(rune(26)) plain := strings.Join([]string{ - strconv.FormatInt(ticks+10, 10), session.SiteID, session.LoginName, route.CID, route.SID, + strconv.FormatInt(ticks+10, 10), session.SessionID, session.LoginName, route.CID, route.SID, route.Token, route.State, route.KFName, "1", content, version, "", }, separator) digest := md5.Sum([]byte(plain)) @@ -320,7 +321,7 @@ func xstSignature(ticks int64, session Session, route XSTRoute, content, version 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, + strconv.FormatInt(ticks+10, 10), session.SessionID, "2", "3", deviceID, route.State, "2", session.LoginName, route.CID, route.SID, version, "", }, separator) digest := md5.Sum([]byte(plain)) @@ -328,7 +329,7 @@ func xstReceptionSignature(ticks int64, session Session, route XSTRoute, deviceI } func xstDeviceID(session Session) string { - digest := sha1.Sum([]byte("gochat-shangwutong\x1a" + session.SiteID + "\x1a" + session.LoginName)) + digest := sha1.Sum([]byte("gochat-shangwutong\x1a" + session.SessionID + "\x1a" + session.LoginName)) return strings.ToUpper(hex.EncodeToString(digest[:])) } diff --git a/channels/shangwutong/internal/swt/client_test.go b/channels/shangwutong/internal/swt/client_test.go index 8720633b..3e112b81 100644 --- a/channels/shangwutong/internal/swt/client_test.go +++ b/channels/shangwutong/internal/swt/client_test.go @@ -31,14 +31,14 @@ func TestClientLogin(t *testing.T) { if err != nil { t.Fatal(err) } - if session.MAToken != "token" || session.SiteID != "99917999" || session.Purview == nil || *session.Purview != 8388610 || !session.AllowsJoiningOtherOperatorDialogue() { + if session.MAToken != "token" || session.SessionID != "BYT99917999" || session.SiteID != "99917999" || session.Purview == nil || *session.Purview != 8388610 || !session.AllowsJoiningOtherOperatorDialogue() { t.Fatalf("unexpected session: %#v", session) } } func TestXSTSignatureKnownVector(t *testing.T) { route := XSTRoute{SID: "sid-1", CID: "cid-1", Token: "xst-1", State: "5", KFName: "客服"} - if got := xstSignature(638900000000000000, testSession(), route, "hello", xstClientVersion); got != "92872732c501789da6bf1c882f3ede40" { + if got := xstSignature(638900000000000000, testSession(), route, "hello", xstClientVersion); got != "891ea08d6f894bfb228f9ed2f5bb43d8" { t.Fatalf("signature = %q", got) } } @@ -46,10 +46,10 @@ 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" { + if deviceID != "97CC54346B0198753408C3FC5D788EF23197C93A" { t.Fatalf("device ID = %q", deviceID) } - if got := xstReceptionSignature(638900000000000000, testSession(), route, deviceID, xstClientVersion); got != "81a5ace9d65e85f519af28482ba6a200" { + if got := xstReceptionSignature(638900000000000000, testSession(), route, deviceID, xstClientVersion); got != "28236364a57ae28d0dd07ea633ebec7d" { t.Fatalf("signature = %q", got) } } @@ -68,14 +68,14 @@ func TestClientSendXSTTextMatchesNativeWireProtocol(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { query := request.URL.Query() if request.URL.Path != "/api/swtsynckfmsg.ashx" || query.Get("sign") == "" || query.Get("t") == "" || - query.Get("swtidhead") != "99917999" || query.Get("onamehead") != "agent" || query.Get("kfversionhead") != xstClientVersion { + query.Get("swtidhead") != "BYT99917999" || query.Get("onamehead") != "agent" || query.Get("kfversionhead") != xstClientVersion { t.Fatalf("xst request URL = %s", request.URL.String()) } if err := request.ParseForm(); err != nil { t.Fatal(err) } for key, want := range map[string]string{ - "siteid": "99917999", "oname": "agent", "cid": "cid-1", "sid": "sid-1", "xst": "xst-1", + "siteid": "BYT99917999", "oname": "agent", "cid": "cid-1", "sid": "sid-1", "xst": "xst-1", "state": "5", "kfname": "客服", "html": "hello *~", "content": "hello+*%7E", "msgkind": "1", "kfversion": xstClientVersion, "RESET": "", } { if got := request.Form.Get(key); got != want { @@ -96,7 +96,7 @@ 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 { + query.Get("swtidhead") != "BYT99917999" || query.Get("onamehead") != "agent" || query.Get("kfversionhead") != xstClientVersion { t.Fatalf("XST reception sync URL = %s", request.URL.String()) } if err := request.ParseForm(); err != nil { @@ -104,8 +104,8 @@ func TestClientSyncXSTReceptionMatchesNativeWireProtocol(t *testing.T) { } 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": "", + "imeiaddress": "97CC54346B0198753408C3FC5D788EF23197C93A", "accounttype": "2", + "state": "5", "oname": "agent", "siteid": "BYT99917999", "kfversion": xstClientVersion, "RESET": "", } { if got := request.Form.Get(key); got != want { t.Fatalf("form[%s] = %q, want %q", key, got, want) @@ -138,6 +138,17 @@ func TestClientSendXSTTextRejectsNonOK(t *testing.T) { } } +func TestClientSendXSTTextRejectsMissingFullSessionID(t *testing.T) { + session := testSession() + session.SessionID = "" + _, err := NewClient(nil).SendXSTText(context.Background(), session, XSTRoute{ + SID: "sid-1", CID: "cid-1", Token: "xst-1", State: "5", KFName: "客服", + }, "hello") + if err == nil || !strings.Contains(err.Error(), "XST session_id") { + t.Fatalf("error = %v", err) + } +} + func TestClientHeartbeatParsesEvents(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { response.Header().Set("r", "ok") @@ -159,6 +170,9 @@ func TestClientSendTextEscapesHTML(t *testing.T) { if err := request.ParseForm(); err != nil { t.Fatal(err) } + if got := request.Form.Get("siteid"); got != "99917999" { + t.Fatalf("siteid = %q", got) + } if got := request.Form.Get("html"); got != "<b>x</b>
next" { t.Fatalf("html = %q", got) } @@ -252,7 +266,7 @@ func TestClientSendExplicitServerErrorIsRetryableNotUncertain(t *testing.T) { } func testSession() Session { - return Session{BaseURL: "http://byt.yiaitao.com.cn/", SiteID: "99917999", LoginName: "agent", MAToken: "token"} + return Session{BaseURL: "http://byt.yiaitao.com.cn/", SessionID: "BYT99917999", SiteID: "99917999", LoginName: "agent", MAToken: "token"} } type roundTripFunc func(*http.Request) (*http.Response, error) diff --git a/channels/shangwutong/internal/swt/types.go b/channels/shangwutong/internal/swt/types.go index 56e19dae..32ae9774 100644 --- a/channels/shangwutong/internal/swt/types.go +++ b/channels/shangwutong/internal/swt/types.go @@ -85,7 +85,8 @@ func BuildBaseURL(sessionID string) (string, error) { type Session struct { BaseURL string - SiteID string + SessionID string // Full configured identifier, e.g. DUT88090694; required by XST APIs. + SiteID string // Numeric suffix, e.g. 88090694; required by legacy SWT APIs. LoginName string MAToken string Purview *uint64 @@ -114,6 +115,16 @@ func (s Session) Validate() error { return nil } +func (s Session) ValidateXST() error { + if err := s.Validate(); err != nil { + return err + } + if !sessionIDPattern.MatchString(s.SessionID) { + return errors.New("XST session_id must contain exactly 11 ASCII letters or digits") + } + return nil +} + func (s Session) AllowsJoiningOtherOperatorDialogue() bool { return s.Purview != nil && (*s.Purview&purviewAdmin != 0 || *s.Purview&purviewProhibitJoinOtherDialogue == 0) }