H-181: add Shangwutong transfer and purview protocol (#30)
Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user