From fe0fb6c6b2714e4e5a747b46944445a07a0b3113 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 15 Aug 2026 12:50:03 +0800 Subject: [PATCH] H-181: add Shangwutong transfer and purview protocol (#30) Co-authored-by: Rogee --- channels/shangwutong/internal/swt/client.go | 22 +++++++++---- .../shangwutong/internal/swt/client_test.go | 4 +-- channels/shangwutong/internal/swt/login.go | 14 ++++++++ .../shangwutong/internal/swt/login_test.go | 15 +++++++++ .../shangwutong/internal/swt/operations.go | 32 +++++++++++++++++-- .../internal/swt/operations_test.go | 23 +++++++++++++ channels/shangwutong/internal/swt/types.go | 10 ++++++ 7 files changed, 110 insertions(+), 10 deletions(-) diff --git a/channels/shangwutong/internal/swt/client.go b/channels/shangwutong/internal/swt/client.go index 3294c28b..5b66e1d9 100644 --- a/channels/shangwutong/internal/swt/client.go +++ b/channels/shangwutong/internal/swt/client.go @@ -111,11 +111,16 @@ func (c *Client) Login(ctx context.Context, credentials Credentials, presence Pr } return Session{}, &Error{Operation: "login", Code: code, Retryable: retryable, Err: err} } + purview, err := result.Purview() + if err != nil { + return Session{}, &Error{Operation: "login", Code: "invalid_response", Err: err} + } return Session{ BaseURL: baseURL, SiteID: credentials.SessionID[3:], LoginName: credentials.Username, MAToken: result.MAToken(), + Purview: purview, }, nil } @@ -197,13 +202,8 @@ func (c *Client) sendHTML(ctx context.Context, session Session, sid, content, op } return SendResult{}, &Error{Operation: operation, Code: "network_error", Retryable: true, Err: err} } - status := strings.TrimSpace(response.Header.Get("r")) + status := protocolStatus(response) successful := strings.EqualFold(status, "ok") - if !successful { - if protocolError := strings.TrimSpace(response.Header.Get("error")); protocolError != "" && !strings.EqualFold(protocolError, "ok") { - status = protocolError - } - } result := SendResult{Status: status, Body: body} if successful { return result, nil @@ -212,6 +212,16 @@ func (c *Client) sendHTML(ctx context.Context, session Session, sid, content, op return result, &Error{Operation: operation, Code: code, Retryable: code == "server_err"} } +func protocolStatus(response *http.Response) string { + status := strings.TrimSpace(response.Header.Get("r")) + if !strings.EqualFold(status, "ok") { + if protocolError := strings.TrimSpace(response.Header.Get("error")); protocolError != "" && !strings.EqualFold(protocolError, "ok") { + return protocolError + } + } + return status +} + func (c *Client) SetPresence(ctx context.Context, session Session, presence Presence) error { if err := session.Validate(); err != nil { return err diff --git a/channels/shangwutong/internal/swt/client_test.go b/channels/shangwutong/internal/swt/client_test.go index 59986224..1d78def7 100644 --- a/channels/shangwutong/internal/swt/client_test.go +++ b/channels/shangwutong/internal/swt/client_test.go @@ -21,7 +21,7 @@ func TestClientLogin(t *testing.T) { if request.Form.Get("pwd") == "" || request.Form.Get("cid") == "" || request.Form.Get("t0") != "3" { t.Fatalf("invalid login form: %#v", request.Form) } - _, _ = response.Write([]byte("r|ok\nma|token")) + _, _ = response.Write([]byte("r|ok\nma|token\npurview|8388610")) })) defer server.Close() @@ -30,7 +30,7 @@ func TestClientLogin(t *testing.T) { if err != nil { t.Fatal(err) } - if session.MAToken != "token" || session.SiteID != "99917999" { + if session.MAToken != "token" || session.SiteID != "99917999" || session.Purview == nil || *session.Purview != 8388610 || !session.AllowsJoiningOtherOperatorDialogue() { t.Fatalf("unexpected session: %#v", session) } } diff --git a/channels/shangwutong/internal/swt/login.go b/channels/shangwutong/internal/swt/login.go index 92279e42..8c581212 100644 --- a/channels/shangwutong/internal/swt/login.go +++ b/channels/shangwutong/internal/swt/login.go @@ -2,7 +2,9 @@ package swt import ( "errors" + "fmt" "net/url" + "strconv" "strings" ) @@ -18,6 +20,18 @@ type LoginResult struct { func (r LoginResult) MAToken() string { return r.Values["ma"] } +func (r LoginResult) Purview() (*uint64, error) { + raw := strings.TrimSpace(r.Values["purview"]) + if raw == "" { + return nil, nil + } + value, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid purview %q: %w", raw, err) + } + return &value, nil +} + func ParseLoginResponse(body string) (LoginResult, error) { body = strings.TrimSpace(strings.ReplaceAll(body, "\r\n", "\n")) if body == "" { diff --git a/channels/shangwutong/internal/swt/login_test.go b/channels/shangwutong/internal/swt/login_test.go index 35ee9981..36cecf03 100644 --- a/channels/shangwutong/internal/swt/login_test.go +++ b/channels/shangwutong/internal/swt/login_test.go @@ -46,3 +46,18 @@ func TestParseLoginResponseRequiresMAToken(t *testing.T) { t.Fatal("expected missing ma token error") } } + +func TestPurviewJoinPermission(t *testing.T) { + admin := uint64(2 | 1<<23) + allowed := uint64(0) + prohibited := uint64(1 << 23) + if !(Session{Purview: &admin}).AllowsJoiningOtherOperatorDialogue() || !(Session{Purview: &allowed}).AllowsJoiningOtherOperatorDialogue() { + t.Fatal("administrator or unrestricted operator must be allowed to join other operator dialogues") + } + if (Session{Purview: &prohibited}).AllowsJoiningOtherOperatorDialogue() || (Session{}).AllowsJoiningOtherOperatorDialogue() { + t.Fatal("prohibited or unknown purview must fail closed") + } + if _, err := (LoginResult{Values: map[string]string{"purview": "invalid"}}).Purview(); err == nil { + t.Fatal("expected invalid purview error") + } +} diff --git a/channels/shangwutong/internal/swt/operations.go b/channels/shangwutong/internal/swt/operations.go index dbab23a6..0830a852 100644 --- a/channels/shangwutong/internal/swt/operations.go +++ b/channels/shangwutong/internal/swt/operations.go @@ -20,6 +20,8 @@ import ( const voiceUploadURL = "https://lgnvoicefile.zoosnet.net/API/UploadAPI.ashx" +const transferSiteIDKey = "69557093" + type Upload struct { Name string ContentType string @@ -111,6 +113,26 @@ func (c *Client) AcceptTransfer(ctx context.Context, session Session, sid string return c.sessionOperation(ctx, session, "oc/accepttransfer.aspx", map[string]string{"sid": sid}, "accept_transfer") } +func (c *Client) TransferConversation(ctx context.Context, session Session, sid, targetLoginName string) error { + if err := session.Validate(); err != nil { + return err + } + if strings.TrimSpace(sid) == "" || strings.TrimSpace(targetLoginName) == "" { + return errors.New("sid and target login name are required") + } + encryptedSiteID, err := DESEncrypt(session.SiteID, transferSiteIDKey) + if err != nil { + return fmt.Errorf("encrypt transfer site id: %w", err) + } + return c.formOperation(ctx, session, "oc/Transfer0.aspx", url.Values{ + "id": {encryptedSiteID}, + "oname": {session.LoginName}, + "oname1": {targetLoginName}, + "sid": {sid}, + "sn": {session.MAToken}, + }, "transfer_conversation") +} + func (c *Client) InviteVisitor(ctx context.Context, session Session, sid, words string) error { if words == "" { words = wrapHTML("您好,请问有什么可以帮您?") @@ -147,6 +169,10 @@ func (c *Client) sessionOperation(ctx context.Context, session Session, endpoint } form.Set(key, value) } + return c.formOperation(ctx, session, endpoint, form, operation) +} + +func (c *Client) formOperation(ctx context.Context, session Session, endpoint string, form url.Values, operation string) error { _, response, requestWritten, err := c.postFormTracked(ctx, session.BaseURL, endpoint, form) if err != nil { if response != nil && (response.StatusCode < 200 || response.StatusCode >= 300) { @@ -157,8 +183,10 @@ func (c *Client) sessionOperation(ctx context.Context, session Session, endpoint } return &Error{Operation: operation, Code: "network_error", Retryable: true, Err: err} } - if status := response.Header.Get("r"); status != "ok" { - return &Error{Operation: operation, Code: normalizeCode(status), Retryable: status == "server err"} + status := protocolStatus(response) + if !strings.EqualFold(status, "ok") { + code := normalizeCode(status) + return &Error{Operation: operation, Code: code, Retryable: code == "server_err"} } return nil } diff --git a/channels/shangwutong/internal/swt/operations_test.go b/channels/shangwutong/internal/swt/operations_test.go index 58513b51..56c1ec3d 100644 --- a/channels/shangwutong/internal/swt/operations_test.go +++ b/channels/shangwutong/internal/swt/operations_test.go @@ -118,3 +118,26 @@ func TestChangeContactNameUsesDocumentedForm(t *testing.T) { t.Fatal(err) } } + +func TestTransferConversationUsesDocumentedForm(t *testing.T) { + wantID, err := DESEncrypt("99917999", transferSiteIDKey) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/oc/Transfer0.aspx" { + t.Fatalf("path = %q", request.URL.Path) + } + if err := request.ParseForm(); err != nil { + t.Fatal(err) + } + if request.Form.Get("id") != wantID || request.Form.Get("oname") != "agent" || request.Form.Get("oname1") != "target" || request.Form.Get("sid") != "visitor" || request.Form.Get("sn") != "token" || request.Form.Has("siteid") { + t.Fatalf("form = %#v", request.Form) + } + response.Header().Set("r", "ok") + })) + defer server.Close() + if err := NewClient(rewriteTransportClient(server.URL)).TransferConversation(context.Background(), testSession(), "visitor", "target"); err != nil { + t.Fatal(err) + } +} diff --git a/channels/shangwutong/internal/swt/types.go b/channels/shangwutong/internal/swt/types.go index 41a98271..383d4db8 100644 --- a/channels/shangwutong/internal/swt/types.go +++ b/channels/shangwutong/internal/swt/types.go @@ -10,6 +10,11 @@ import ( var sessionIDPattern = regexp.MustCompile(`^[A-Za-z0-9]{11}$`) +const ( + purviewAdmin uint64 = 2 + purviewProhibitJoinOtherDialogue uint64 = 1 << 23 +) + type Presence string const ( @@ -83,6 +88,7 @@ type Session struct { SiteID string LoginName string MAToken string + Purview *uint64 } func (s Session) Validate() error { @@ -91,3 +97,7 @@ func (s Session) Validate() error { } return nil } + +func (s Session) AllowsJoiningOtherOperatorDialogue() bool { + return s.Purview != nil && (*s.Purview&purviewAdmin != 0 || *s.Purview&purviewProhibitJoinOtherDialogue == 0) +}