H-181: add Shangwutong transfer and purview protocol (#30)

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-15 12:50:03 +08:00
committed by GitHub
co-authored by rogee
parent 992c9bf0e9
commit fe0fb6c6b2
7 changed files with 110 additions and 10 deletions
+16 -6
View File
@@ -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
@@ -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)
}
}
@@ -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 == "" {
@@ -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")
}
}
@@ -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
}
@@ -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)
}
}
@@ -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)
}