WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,610 @@
|
||||
package yunzhijia
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
)
|
||||
|
||||
// textMessageType is the Yunzhijia message type value for plain text messages.
|
||||
const textMessageType = 2
|
||||
|
||||
// markdownFormatType requests Yunzhijia to render Content as Markdown.
|
||||
const markdownFormatType = "markdown"
|
||||
|
||||
// Compile-time check.
|
||||
var _ im.Adapter = (*Adapter)(nil)
|
||||
var _ im.FileDownloader = (*Adapter)(nil)
|
||||
|
||||
var yunzhijiaAuthURL = "https://yunzhijia.com/api/oauth2_v12/auth/getAppAccessToken"
|
||||
var yunzhijiaDownloadFileBaseURL = "https://yunzhijia.com/gateway/docrest/doc/file/downloadfileOpen"
|
||||
|
||||
var validateDownloadFileURL = func(rawURL string) error {
|
||||
_, err := validateEndpointURL(rawURL, "https", "yunzhijia.com")
|
||||
return err
|
||||
}
|
||||
|
||||
// maxDownloadFileSize caps the size of a file downloaded from Yunzhijia and read
|
||||
// into memory, to avoid unbounded memory usage from a large/malicious response.
|
||||
const maxDownloadFileSize = 32 << 20 // 32 MiB
|
||||
|
||||
// maxDownloadRedirects limits how many redirects DownloadFile follows manually.
|
||||
// The shared httpClient disables automatic redirects (SSRF safety), but the
|
||||
// Yunzhijia download endpoint may 302 to a signed URL, so we follow a single hop
|
||||
// while re-validating the target host stays within the allowed suffix.
|
||||
const maxDownloadRedirects = 1
|
||||
|
||||
// Adapter implements im.Adapter for Yunzhijia (云之家).
|
||||
type Adapter struct {
|
||||
sendMsgURL string
|
||||
secret string
|
||||
appID string
|
||||
appSecret string
|
||||
httpClient *http.Client
|
||||
allowedWebhookHostSuffix string
|
||||
tokenMu sync.Mutex
|
||||
accessToken string
|
||||
accessTokenExpiresAt time.Time
|
||||
}
|
||||
|
||||
// NewAdapter creates a Yunzhijia adapter.
|
||||
func NewAdapter(sendMsgURL, secret, appID, appSecret string, timeoutSeconds int, allowedHostSuffix string) *Adapter {
|
||||
if timeoutSeconds <= 0 {
|
||||
timeoutSeconds = 10
|
||||
}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
transport.DialContext = safeDialContext
|
||||
return &Adapter{
|
||||
sendMsgURL: strings.TrimSpace(sendMsgURL),
|
||||
secret: strings.TrimSpace(secret),
|
||||
appID: strings.TrimSpace(appID),
|
||||
appSecret: strings.TrimSpace(appSecret),
|
||||
allowedWebhookHostSuffix: strings.TrimSpace(allowedHostSuffix),
|
||||
httpClient: &http.Client{
|
||||
Timeout: time.Duration(timeoutSeconds) * time.Second,
|
||||
Transport: transport,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) Platform() im.Platform {
|
||||
return im.PlatformYunzhijia
|
||||
}
|
||||
|
||||
func (a *Adapter) HandleURLVerification(c *gin.Context) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// VerifyCallback verifies the Yunzhijia callback signature (HmacSHA1).
|
||||
// If secret is not configured, verification is skipped.
|
||||
func (a *Adapter) VerifyCallback(c *gin.Context) error {
|
||||
if a.secret == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
var msg callbackMessage
|
||||
if err := json.Unmarshal(bodyBytes, &msg); err != nil {
|
||||
return fmt.Errorf("parse callback for verification: %w", err)
|
||||
}
|
||||
|
||||
// Read the sign header (case-insensitive via gin's GetHeader).
|
||||
sign := c.GetHeader("sign")
|
||||
if sign == "" {
|
||||
sign = c.GetHeader("Sign")
|
||||
}
|
||||
if sign == "" {
|
||||
sign = c.GetHeader("SIGN")
|
||||
}
|
||||
if sign == "" {
|
||||
return fmt.Errorf("missing sign header")
|
||||
}
|
||||
|
||||
expected := computeSignature(a.secret, &msg)
|
||||
if !hmac.Equal([]byte(sign), []byte(expected)) {
|
||||
return fmt.Errorf("invalid signature")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseCallback parses a Yunzhijia webhook callback into an IncomingMessage.
|
||||
// Returns nil for non-text messages or empty content.
|
||||
func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) {
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
var msg callbackMessage
|
||||
if err := json.Unmarshal(bodyBytes, &msg); err != nil {
|
||||
return nil, fmt.Errorf("parse callback: %w", err)
|
||||
}
|
||||
|
||||
return toIncomingMessage(c.Request.Context(), &msg), nil
|
||||
}
|
||||
|
||||
func toIncomingMessage(ctx context.Context, msg *callbackMessage) *im.IncomingMessage {
|
||||
if msg.Type != textMessageType {
|
||||
logger.Infof(ctx,
|
||||
"[Yunzhijia] Skip non-text message: type=%d msgId=%s", msg.Type, msg.MsgID)
|
||||
return nil
|
||||
}
|
||||
|
||||
param, err := parseMessageParam(msg.MsgParam)
|
||||
if err != nil {
|
||||
logger.Warnf(ctx, "[Yunzhijia] Failed to parse msgParam: msgId=%s err=%v", msg.MsgID, err)
|
||||
}
|
||||
if param != nil {
|
||||
// Keep topic diagnostics to identifiers only: they are sufficient to
|
||||
// validate reply-root stability without recording user message content.
|
||||
logger.Infof(ctx, "[Yunzhijia] Thread callback: msg_id=%s reply_msg_id=%s reply_root_msg_id=%s",
|
||||
msg.MsgID, param.ReplyMsgID, param.ReplyRootMsgID)
|
||||
}
|
||||
image, hasImage := param.firstImage()
|
||||
|
||||
content := strings.TrimSpace(msg.Content)
|
||||
if content == "" && !hasImage {
|
||||
logger.Infof(ctx,
|
||||
"[Yunzhijia] Skip empty content: msgId=%s", msg.MsgID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Conversation bots should only receive messages explicitly addressed to them.
|
||||
var mentioned bool
|
||||
content, mentioned = cleanAtMention(content, msg.RobotName)
|
||||
if !mentioned {
|
||||
mentioned = messageParamMentionsRobot(param, msg.RobotID)
|
||||
}
|
||||
if !mentioned {
|
||||
logger.Infof(ctx, "[Yunzhijia] Skip message without robot mention: msgId=%s", msg.MsgID)
|
||||
return nil
|
||||
}
|
||||
|
||||
if content == "" && !hasImage {
|
||||
logger.Infof(ctx,
|
||||
"[Yunzhijia] Skip after cleaning @mention: msgId=%s", msg.MsgID)
|
||||
return nil
|
||||
}
|
||||
|
||||
userID := firstNonEmpty(msg.OperatorOpenid, msg.OperatorOID, msg.OpenID, msg.SenderID, msg.OperatorID, msg.OperatorUserID)
|
||||
userName := firstNonEmpty(msg.OperatorName, msg.SenderName)
|
||||
chatType := im.ChatTypeGroup
|
||||
chatID := firstNonEmpty(msg.GroupID, msg.RobotID)
|
||||
|
||||
incoming := &im.IncomingMessage{
|
||||
Platform: im.PlatformYunzhijia,
|
||||
MessageType: im.MessageTypeText,
|
||||
UserID: userID,
|
||||
UserName: userName,
|
||||
ChatID: chatID,
|
||||
ChatType: chatType,
|
||||
Content: content,
|
||||
MessageID: msg.MsgID,
|
||||
ThreadID: threadIDForMessage(msg.MsgID, param),
|
||||
Extra: map[string]string{
|
||||
"robot_id": msg.RobotID,
|
||||
"robot_name": msg.RobotName,
|
||||
"group_id": msg.GroupID,
|
||||
"group_type": fmt.Sprintf("%d", msg.GroupType),
|
||||
"operator_name": userName,
|
||||
"time": fmt.Sprintf("%d", msg.Time),
|
||||
},
|
||||
}
|
||||
if hasImage {
|
||||
incoming.MessageType = im.MessageTypeImage
|
||||
incoming.FileKey = image.Data
|
||||
incoming.FileName = defaultImageFileName(msg.MsgID)
|
||||
incoming.Extra["yunzhijia_image_width"] = fmt.Sprintf("%d", image.Width)
|
||||
incoming.Extra["yunzhijia_image_height"] = fmt.Sprintf("%d", image.Height)
|
||||
}
|
||||
return incoming
|
||||
}
|
||||
|
||||
// threadIDForMessage returns the message-thread identifier received from
|
||||
// Yunzhijia. A top-level message starts a new thread with its own msgId;
|
||||
// replies carry replyRootMsgId (normally the bot message at the thread root).
|
||||
func threadIDForMessage(messageID string, param *messageParam) string {
|
||||
if param != nil && param.ReplyRootMsgID != "" {
|
||||
return param.ReplyRootMsgID
|
||||
}
|
||||
return messageID
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func messageParamMentionsRobot(param *messageParam, robotID string) bool {
|
||||
if param == nil || robotID == "" {
|
||||
return false
|
||||
}
|
||||
for _, notifyTo := range param.NotifyTo {
|
||||
if notifyTo == robotID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, desc := range param.Desc {
|
||||
if desc.Type == "at" && desc.Data == robotID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func defaultImageFileName(msgID string) string {
|
||||
if msgID == "" {
|
||||
return "yunzhijia-image.png"
|
||||
}
|
||||
return msgID + ".png"
|
||||
}
|
||||
|
||||
// cleanAtMention removes @RobotName from the beginning of user content.
|
||||
func cleanAtMention(content, robotName string) (string, bool) {
|
||||
if robotName == "" {
|
||||
return content, false
|
||||
}
|
||||
prefix := "@" + robotName
|
||||
trimmed := strings.TrimLeft(content, " \t")
|
||||
if !strings.HasPrefix(trimmed, prefix) {
|
||||
return content, false
|
||||
}
|
||||
rest := trimmed[len(prefix):]
|
||||
if rest == "" {
|
||||
return "", true
|
||||
}
|
||||
separator, _ := utf8.DecodeRuneInString(rest)
|
||||
if !unicode.IsSpace(separator) && !strings.ContainsRune("::,,", separator) {
|
||||
return content, false
|
||||
}
|
||||
return strings.TrimLeftFunc(rest, func(r rune) bool {
|
||||
return unicode.IsSpace(r) || strings.ContainsRune("::,,", r)
|
||||
}), true
|
||||
}
|
||||
|
||||
// SendReply sends a reply to Yunzhijia via the configured sendMsgUrl.
|
||||
func (a *Adapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
|
||||
if a.sendMsgURL == "" {
|
||||
return fmt.Errorf("yunzhijia send_msg_url is not configured")
|
||||
}
|
||||
|
||||
// Validate the send URL to prevent SSRF.
|
||||
if err := a.validateSendURL(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload := sendMessagePayload{
|
||||
MsgType: textMessageType,
|
||||
Content: reply.Content,
|
||||
// WeKnora replies are authored in Markdown by default (see im.ReplyMessage.Content),
|
||||
// so request Markdown rendering from Yunzhijia unless explicitly overridden via
|
||||
// reply.Extra["yunzhijia_format_type"] (empty string disables the param entirely).
|
||||
Param: &sendMessageParam{FormatType: markdownFormatType},
|
||||
}
|
||||
if reply.Extra != nil {
|
||||
if formatType, ok := reply.Extra["yunzhijia_format_type"]; ok {
|
||||
payload.Param.FormatType = formatType
|
||||
}
|
||||
}
|
||||
if incoming.MessageID != "" {
|
||||
payload.ParamType = 3
|
||||
payload.Param.ReplyMsgID = incoming.MessageID
|
||||
payload.Param.IsReference = true
|
||||
payload.Param.ReplySummary = incoming.Content
|
||||
payload.Param.ReplyPersonName = incoming.UserName
|
||||
} else if payload.Param.FormatType == "" {
|
||||
// Keep the prior opt-out behaviour for non-reference replies.
|
||||
payload.Param = nil
|
||||
}
|
||||
|
||||
// When groupType == 3, don't set notifyParams (per reference implementation).
|
||||
groupType := ""
|
||||
if incoming.Extra != nil {
|
||||
groupType = incoming.Extra["group_type"]
|
||||
}
|
||||
if groupType != "3" && incoming.UserID != "" {
|
||||
payload.NotifyParams = []notifyParam{
|
||||
{
|
||||
Type: "openIds",
|
||||
Values: []string{incoming.UserID},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal reply: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.sendMsgURL, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send reply: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("yunzhijia sendMsgUrl returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadFile downloads a file/image resource sent by Yunzhijia.
|
||||
func (a *Adapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (io.ReadCloser, string, error) {
|
||||
if msg.FileKey == "" {
|
||||
return nil, "", fmt.Errorf("yunzhijia file id is required")
|
||||
}
|
||||
fileID := strings.TrimSpace(msg.FileKey)
|
||||
if err := validateFileID(fileID); err != nil {
|
||||
return nil, "", fmt.Errorf("invalid yunzhijia file id: %w", err)
|
||||
}
|
||||
|
||||
accessToken, err := a.getAppAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
downloadURL := buildDownloadFileURL(fileID)
|
||||
if err := validateDownloadFileURL(downloadURL); err != nil {
|
||||
return nil, "", fmt.Errorf("download url rejected: %w", err)
|
||||
}
|
||||
|
||||
resp, err := a.fetchDownload(ctx, downloadURL, accessToken)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
resp.Body.Close()
|
||||
return nil, "", fmt.Errorf("download file returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
fileName := msg.FileName
|
||||
if fileName == "" {
|
||||
fileName = fileID
|
||||
}
|
||||
fileName = resolveDownloadFileName(fileName, resp)
|
||||
return newLimitedReadCloser(resp.Body, maxDownloadFileSize), fileName, nil
|
||||
}
|
||||
|
||||
// fetchDownload performs the download request, following at most maxDownloadRedirects
|
||||
// redirects. Each redirect target is re-validated against the allowed host suffix so
|
||||
// the request cannot be redirected off the trusted domain. The bearer token is only
|
||||
// sent on the initial request and never forwarded across a redirect.
|
||||
func (a *Adapter) fetchDownload(ctx context.Context, downloadURL, accessToken string) (*http.Response, error) {
|
||||
currentURL := downloadURL
|
||||
for redirects := 0; ; redirects++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, currentURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
if redirects == 0 {
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
}
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download file: %w", err)
|
||||
}
|
||||
if resp.StatusCode < http.StatusMultipleChoices || resp.StatusCode >= http.StatusBadRequest {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Redirect (3xx): re-validate the target before following.
|
||||
location := strings.TrimSpace(resp.Header.Get("Location"))
|
||||
resp.Body.Close()
|
||||
if redirects >= maxDownloadRedirects {
|
||||
return nil, fmt.Errorf("download file: too many redirects")
|
||||
}
|
||||
if location == "" {
|
||||
return nil, fmt.Errorf("download file: redirect without Location")
|
||||
}
|
||||
resolved, err := resolveRedirectURL(currentURL, location)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download file: invalid redirect location: %w", err)
|
||||
}
|
||||
if err := validateDownloadFileURL(resolved); err != nil {
|
||||
return nil, fmt.Errorf("download redirect rejected: %w", err)
|
||||
}
|
||||
currentURL = resolved
|
||||
}
|
||||
}
|
||||
|
||||
func resolveRedirectURL(base, location string) (string, error) {
|
||||
baseURL, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
locURL, err := url.Parse(location)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return baseURL.ResolveReference(locURL).String(), nil
|
||||
}
|
||||
|
||||
// newLimitedReadCloser wraps rc so that reading more than limit bytes returns an
|
||||
// error instead of silently truncating, while Close still closes the underlying body.
|
||||
func newLimitedReadCloser(rc io.ReadCloser, limit int64) io.ReadCloser {
|
||||
return &limitedReadCloser{r: io.LimitReader(rc, limit+1), body: rc, limit: limit}
|
||||
}
|
||||
|
||||
type limitedReadCloser struct {
|
||||
r io.Reader
|
||||
body io.Closer
|
||||
limit int64
|
||||
read int64
|
||||
}
|
||||
|
||||
func (l *limitedReadCloser) Read(p []byte) (int, error) {
|
||||
n, err := l.r.Read(p)
|
||||
l.read += int64(n)
|
||||
if l.read > l.limit {
|
||||
return n, fmt.Errorf("yunzhijia file exceeds max download size of %d bytes", l.limit)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (l *limitedReadCloser) Close() error {
|
||||
return l.body.Close()
|
||||
}
|
||||
|
||||
func buildDownloadFileURL(fileID string) string {
|
||||
u, _ := url.Parse(yunzhijiaDownloadFileBaseURL)
|
||||
query := url.Values{}
|
||||
query.Set("fileId", fileID)
|
||||
u.RawQuery = query.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func validateFileID(fileID string) error {
|
||||
if fileID == "" {
|
||||
return fmt.Errorf("empty")
|
||||
}
|
||||
if len(fileID) > 256 {
|
||||
return fmt.Errorf("too long")
|
||||
}
|
||||
if strings.ContainsAny(fileID, `/\?#&`) {
|
||||
return fmt.Errorf("contains path or query separators")
|
||||
}
|
||||
for _, r := range fileID {
|
||||
if unicode.IsControl(r) || unicode.IsSpace(r) {
|
||||
return fmt.Errorf("contains whitespace or control character")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adapter) getAppAccessToken(ctx context.Context) (string, error) {
|
||||
if a.appID == "" || a.appSecret == "" {
|
||||
return "", fmt.Errorf("yunzhijia app_id and app_secret are required to download files")
|
||||
}
|
||||
|
||||
a.tokenMu.Lock()
|
||||
if a.accessToken != "" && time.Now().Before(a.accessTokenExpiresAt) {
|
||||
token := a.accessToken
|
||||
a.tokenMu.Unlock()
|
||||
return token, nil
|
||||
}
|
||||
a.tokenMu.Unlock()
|
||||
|
||||
payload := map[string]any{
|
||||
"appId": a.appID,
|
||||
"secret": a.appSecret,
|
||||
"timestamp": time.Now().UnixMilli(),
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal yunzhijia token request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, yunzhijiaAuthURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create yunzhijia token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request yunzhijia app access token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read yunzhijia token response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("yunzhijia token endpoint returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var tokenResp appAccessTokenResponse
|
||||
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
|
||||
return "", fmt.Errorf("parse yunzhijia token response: %w", err)
|
||||
}
|
||||
if !tokenResp.Success || tokenResp.ErrorCode != 0 || tokenResp.Data.AccessToken == "" {
|
||||
return "", fmt.Errorf("yunzhijia token response failed: errorCode=%d error=%v", tokenResp.ErrorCode, tokenResp.Error)
|
||||
}
|
||||
|
||||
expiresIn := tokenResp.Data.ExpireIn
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = 3600
|
||||
}
|
||||
expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
if expiresIn > 120 {
|
||||
expiresAt = expiresAt.Add(-60 * time.Second)
|
||||
}
|
||||
|
||||
a.tokenMu.Lock()
|
||||
a.accessToken = tokenResp.Data.AccessToken
|
||||
a.accessTokenExpiresAt = expiresAt
|
||||
a.tokenMu.Unlock()
|
||||
return tokenResp.Data.AccessToken, nil
|
||||
}
|
||||
|
||||
func resolveDownloadFileName(fallback string, resp *http.Response) string {
|
||||
fileName := fallback
|
||||
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
|
||||
if _, params, err := mime.ParseMediaType(cd); err == nil {
|
||||
if name := strings.TrimSpace(params["filename"]); name != "" {
|
||||
fileName = name
|
||||
}
|
||||
}
|
||||
}
|
||||
if filepath.Ext(fileName) == "" {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])) {
|
||||
case "image/jpeg":
|
||||
fileName += ".jpg"
|
||||
case "image/png":
|
||||
fileName += ".png"
|
||||
case "image/gif":
|
||||
fileName += ".gif"
|
||||
}
|
||||
}
|
||||
return fileName
|
||||
}
|
||||
|
||||
// validateSendURL checks that sendMsgUrl is safe to call (HTTPS, no internal IPs, allowed host).
|
||||
func (a *Adapter) validateSendURL() error {
|
||||
_, err := validateEndpointURL(a.sendMsgURL, "https", a.allowedWebhookHostSuffix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid send_msg_url: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
package yunzhijia
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestVerifyCallbackSignature(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
msg := callbackMessage{
|
||||
Type: 2, RobotID: "robot", RobotName: "WeKnora", OperatorOpenid: "user",
|
||||
OperatorName: "User", Time: 123, MsgID: "message", Content: "@WeKnora hello",
|
||||
}
|
||||
body, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/callback", bytes.NewReader(body))
|
||||
req.Header.Set("Sign", computeSignature("secret", &msg))
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = req
|
||||
|
||||
adapter := NewAdapter("https://www.yunzhijia.com/send", "secret", "", "", 10, "yunzhijia.com")
|
||||
if err := adapter.VerifyCallback(c); err != nil {
|
||||
t.Fatalf("VerifyCallback() error = %v", err)
|
||||
}
|
||||
parsed, err := adapter.ParseCallback(c)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCallback() error = %v", err)
|
||||
}
|
||||
if parsed == nil || parsed.Content != "hello" {
|
||||
t.Fatalf("parsed message = %#v, want content hello", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToIncomingMessageRequiresRobotMention(t *testing.T) {
|
||||
msg := &callbackMessage{
|
||||
Type: 2, RobotID: "robot", RobotName: "WeKnora", OperatorOpenid: "user",
|
||||
OperatorName: "User", Time: 123, MsgID: "message", Content: "hello",
|
||||
}
|
||||
if got := toIncomingMessage(t.Context(), msg); got != nil {
|
||||
t.Fatalf("toIncomingMessage() = %#v, want nil without robot mention", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanAtMentionRequiresNameBoundary(t *testing.T) {
|
||||
if _, mentioned := cleanAtMention("@WeKnoraPlus hello", "WeKnora"); mentioned {
|
||||
t.Fatal("longer user name must not be treated as a robot mention")
|
||||
}
|
||||
if got, mentioned := cleanAtMention("@WeKnora:你好", "WeKnora"); !mentioned || got != "你好" {
|
||||
t.Fatalf("cleanAtMention() = %q, %v; want 你好, true", got, mentioned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToIncomingMessageParsesMsgParamImage(t *testing.T) {
|
||||
msg := &callbackMessage{
|
||||
Type: 2,
|
||||
MsgType: 23,
|
||||
RobotID: "BOT-1",
|
||||
RobotName: "Websocket",
|
||||
GroupID: "group-1",
|
||||
OperatorOpenid: "user",
|
||||
OperatorName: "User",
|
||||
Time: 123,
|
||||
MsgID: "message",
|
||||
Content: "@Websocket 图片测试一下看看[图片]",
|
||||
GroupType: 2,
|
||||
MsgParam: `{"desc":[{"data":"BOT-1","length":10,"start":0,"type":"at"},` +
|
||||
`{"data":"file-1","h":1032,"length":4,"start":19,"type":"image","w":1920}],` +
|
||||
`"notifyTo":["BOT-1"],"notifyType":1}`,
|
||||
}
|
||||
|
||||
got := toIncomingMessage(t.Context(), msg)
|
||||
if got == nil {
|
||||
t.Fatal("toIncomingMessage() returned nil")
|
||||
}
|
||||
if got.MessageType != im.MessageTypeImage {
|
||||
t.Fatalf("MessageType = %q, want image", got.MessageType)
|
||||
}
|
||||
if got.FileKey != "file-1" {
|
||||
t.Fatalf("FileKey = %q, want file-1", got.FileKey)
|
||||
}
|
||||
if got.FileName != "message.png" {
|
||||
t.Fatalf("FileName = %q, want message.png", got.FileName)
|
||||
}
|
||||
if got.ChatID != "group-1" {
|
||||
t.Fatalf("ChatID = %q, want group-1", got.ChatID)
|
||||
}
|
||||
if got.Content != "图片测试一下看看[图片]" {
|
||||
t.Fatalf("Content = %q", got.Content)
|
||||
}
|
||||
if got.Extra["yunzhijia_image_width"] != "1920" || got.Extra["yunzhijia_image_height"] != "1032" {
|
||||
t.Fatalf("image dimensions extra = %#v", got.Extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToIncomingMessageUsesYunzhijiaReplyRootAsThreadID(t *testing.T) {
|
||||
msg := &callbackMessage{
|
||||
Type: 2,
|
||||
RobotID: "BOT-1",
|
||||
RobotName: "Websocket",
|
||||
GroupID: "group-1",
|
||||
OperatorOpenid: "user-b",
|
||||
Time: 123,
|
||||
MsgID: "message-b",
|
||||
Content: "@Websocket follow up",
|
||||
MsgParam: `{"replyMsgId":"BOT-answer-1","replyRootMsgId":"BOT-answer-1","replyPersonId":"BOT-1"}`,
|
||||
}
|
||||
|
||||
got := toIncomingMessage(t.Context(), msg)
|
||||
if got == nil {
|
||||
t.Fatal("toIncomingMessage() returned nil")
|
||||
}
|
||||
if got.ThreadID != "BOT-answer-1" {
|
||||
t.Fatalf("ThreadID = %q, want BOT-answer-1", got.ThreadID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadIDForTopLevelMessage(t *testing.T) {
|
||||
if got := threadIDForMessage("message-a", &messageParam{}); got != "message-a" {
|
||||
t.Fatalf("threadIDForMessage() = %q, want message-a", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToIncomingMessageAcceptsStringNotifyType(t *testing.T) {
|
||||
msg := &callbackMessage{
|
||||
Type: 2, RobotID: "BOT-1", RobotName: "Websocket", OperatorOpenid: "user",
|
||||
Time: 123, MsgID: "message", Content: "reply text",
|
||||
MsgParam: `{"notifyTo":["BOT-1"],"notifyType":"1","replyMsgId":"BOT-answer"}`,
|
||||
}
|
||||
if got := toIncomingMessage(t.Context(), msg); got == nil {
|
||||
t.Fatal("toIncomingMessage() = nil; string notifyType must not prevent bot mention detection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToIncomingMessageAcceptsMsgParamMention(t *testing.T) {
|
||||
msg := &callbackMessage{
|
||||
Type: 2,
|
||||
RobotID: "BOT-1",
|
||||
RobotName: "Websocket",
|
||||
OperatorOpenid: "user",
|
||||
OperatorName: "User",
|
||||
Time: 123,
|
||||
MsgID: "message",
|
||||
Content: "图片测试一下看看[图片]",
|
||||
MsgParam: `{"desc":[{"data":"BOT-1","type":"at"},{"data":"file-1","type":"image"}]}`,
|
||||
}
|
||||
|
||||
got := toIncomingMessage(t.Context(), msg)
|
||||
if got == nil {
|
||||
t.Fatal("toIncomingMessage() returned nil")
|
||||
}
|
||||
if got.FileKey != "file-1" {
|
||||
t.Fatalf("FileKey = %q, want file-1", got.FileKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToIncomingMessageAcceptsImageWithoutText(t *testing.T) {
|
||||
msg := &callbackMessage{
|
||||
Type: 2,
|
||||
RobotID: "BOT-1",
|
||||
RobotName: "Websocket",
|
||||
OperatorOpenid: "user",
|
||||
OperatorName: "User",
|
||||
Time: 123,
|
||||
MsgID: "message",
|
||||
Content: "",
|
||||
MsgParam: `{"desc":[{"data":"BOT-1","type":"at"},{"data":"file-1","type":"image"}]}`,
|
||||
}
|
||||
|
||||
got := toIncomingMessage(t.Context(), msg)
|
||||
if got == nil {
|
||||
t.Fatal("toIncomingMessage() returned nil")
|
||||
}
|
||||
if got.MessageType != im.MessageTypeImage || got.FileKey != "file-1" || got.Content != "" {
|
||||
t.Fatalf("incoming = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendReplyAcceptsAny2xxAndBuildsPayload(t *testing.T) {
|
||||
adapter := NewAdapter("https://www.yunzhijia.com/send", "", "", "", 10, "yunzhijia.com")
|
||||
var payload sendMessagePayload
|
||||
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: io.NopCloser(bytes.NewReader(nil)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})}
|
||||
|
||||
incoming := &im.IncomingMessage{UserID: "user", Extra: map[string]string{"group_type": "1"}}
|
||||
if err := adapter.SendReply(context.Background(), incoming, &im.ReplyMessage{Content: "answer"}); err != nil {
|
||||
t.Fatalf("SendReply() error = %v", err)
|
||||
}
|
||||
if payload.MsgType != textMessageType || payload.Content != "answer" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if len(payload.NotifyParams) != 1 || len(payload.NotifyParams[0].Values) != 1 || payload.NotifyParams[0].Values[0] != "user" {
|
||||
t.Fatalf("notify params = %#v", payload.NotifyParams)
|
||||
}
|
||||
if payload.Param == nil || payload.Param.FormatType != markdownFormatType {
|
||||
t.Fatalf("expected markdown format param by default, got %#v", payload.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendReplyReferencesIncomingMessage(t *testing.T) {
|
||||
adapter := NewAdapter("https://www.yunzhijia.com/send", "", "", "", 10, "yunzhijia.com")
|
||||
var payload sendMessagePayload
|
||||
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":{"msgId":"BOT-answer-1"}}`)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})}
|
||||
|
||||
incoming := &im.IncomingMessage{MessageID: "message-a", Content: "question", UserName: "Alice"}
|
||||
reply := &im.ReplyMessage{Content: "answer"}
|
||||
if err := adapter.SendReply(context.Background(), incoming, reply); err != nil {
|
||||
t.Fatalf("SendReply() error = %v", err)
|
||||
}
|
||||
if payload.ParamType != 3 || payload.Param == nil || payload.Param.ReplyMsgID != "message-a" || !payload.Param.IsReference {
|
||||
t.Fatalf("reference payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendReplyFormatTypeOverride(t *testing.T) {
|
||||
adapter := NewAdapter("https://www.yunzhijia.com/send", "", "", "", 10, "yunzhijia.com")
|
||||
var payload sendMessagePayload
|
||||
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: io.NopCloser(bytes.NewReader(nil)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})}
|
||||
|
||||
incoming := &im.IncomingMessage{UserID: "user"}
|
||||
|
||||
// Empty string explicitly disables the param.
|
||||
if err := adapter.SendReply(context.Background(), incoming, &im.ReplyMessage{
|
||||
Content: "answer",
|
||||
Extra: map[string]string{"yunzhijia_format_type": ""},
|
||||
}); err != nil {
|
||||
t.Fatalf("SendReply() error = %v", err)
|
||||
}
|
||||
if payload.Param != nil {
|
||||
t.Fatalf("expected no param when format type overridden to empty, got %#v", payload.Param)
|
||||
}
|
||||
|
||||
// Non-empty override replaces the default markdown formatType.
|
||||
if err := adapter.SendReply(context.Background(), incoming, &im.ReplyMessage{
|
||||
Content: "answer",
|
||||
Extra: map[string]string{"yunzhijia_format_type": "text"},
|
||||
}); err != nil {
|
||||
t.Fatalf("SendReply() error = %v", err)
|
||||
}
|
||||
if payload.Param == nil || payload.Param.FormatType != "text" {
|
||||
t.Fatalf("expected overridden format type 'text', got %#v", payload.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDownloadFileURL(t *testing.T) {
|
||||
got := buildDownloadFileURL("file-1")
|
||||
if got != "https://yunzhijia.com/gateway/docrest/doc/file/downloadfileOpen?fileId=file-1" {
|
||||
t.Fatalf("download URL = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFile(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/oauth2_v12/auth/getAppAccessToken":
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("token method = %s", r.Method)
|
||||
}
|
||||
var tokenReq map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&tokenReq); err != nil {
|
||||
t.Fatalf("decode token request: %v", err)
|
||||
}
|
||||
if tokenReq["appId"] != "app-id" || tokenReq["secret"] != "app-secret" {
|
||||
t.Fatalf("token request = %#v", tokenReq)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"accessToken":"token-1","expireIn":7136},"error":null,"errorCode":0,"success":true}`))
|
||||
case "/gateway/docrest/doc/file/downloadfileOpen":
|
||||
if r.Header.Get("Authorization") != "Bearer token-1" {
|
||||
t.Fatalf("Authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
if r.URL.Query().Get("fileId") != "file-1" {
|
||||
t.Fatalf("query = %q", r.URL.RawQuery)
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write([]byte("image-bytes"))
|
||||
default:
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldAuthURL := yunzhijiaAuthURL
|
||||
oldBaseURL := yunzhijiaDownloadFileBaseURL
|
||||
oldValidator := validateDownloadFileURL
|
||||
yunzhijiaAuthURL = server.URL + "/api/oauth2_v12/auth/getAppAccessToken"
|
||||
yunzhijiaDownloadFileBaseURL = server.URL + "/gateway/docrest/doc/file/downloadfileOpen"
|
||||
validateDownloadFileURL = func(string) error { return nil }
|
||||
defer func() {
|
||||
yunzhijiaAuthURL = oldAuthURL
|
||||
yunzhijiaDownloadFileBaseURL = oldBaseURL
|
||||
validateDownloadFileURL = oldValidator
|
||||
}()
|
||||
|
||||
adapter := NewAdapter("https://www.yunzhijia.com/send", "", "app-id", "app-secret", 10, "yunzhijia.com")
|
||||
adapter.httpClient = server.Client()
|
||||
reader, fileName, err := adapter.DownloadFile(context.Background(), &im.IncomingMessage{
|
||||
FileKey: "file-1",
|
||||
FileName: "message.png",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadFile() error = %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
body, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if string(body) != "image-bytes" || fileName != "message.png" {
|
||||
t.Fatalf("body=%q fileName=%q", string(body), fileName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFileFollowsSingleRedirect(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/oauth2_v12/auth/getAppAccessToken":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"accessToken":"token-1","expireIn":7136},"error":null,"errorCode":0,"success":true}`))
|
||||
case "/gateway/docrest/doc/file/downloadfileOpen":
|
||||
http.Redirect(w, r, "/cdn/file-1", http.StatusFound)
|
||||
case "/cdn/file-1":
|
||||
// Bearer token must NOT be forwarded across the redirect.
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
t.Fatalf("Authorization forwarded to redirect target: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write([]byte("image-bytes"))
|
||||
default:
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldAuthURL := yunzhijiaAuthURL
|
||||
oldBaseURL := yunzhijiaDownloadFileBaseURL
|
||||
oldValidator := validateDownloadFileURL
|
||||
yunzhijiaAuthURL = server.URL + "/api/oauth2_v12/auth/getAppAccessToken"
|
||||
yunzhijiaDownloadFileBaseURL = server.URL + "/gateway/docrest/doc/file/downloadfileOpen"
|
||||
validateDownloadFileURL = func(string) error { return nil }
|
||||
defer func() {
|
||||
yunzhijiaAuthURL = oldAuthURL
|
||||
yunzhijiaDownloadFileBaseURL = oldBaseURL
|
||||
validateDownloadFileURL = oldValidator
|
||||
}()
|
||||
|
||||
adapter := NewAdapter("https://www.yunzhijia.com/send", "", "app-id", "app-secret", 10, "yunzhijia.com")
|
||||
adapter.httpClient = noRedirectClient(server)
|
||||
reader, _, err := adapter.DownloadFile(context.Background(), &im.IncomingMessage{FileKey: "file-1", FileName: "message.png"})
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadFile() error = %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
body, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if string(body) != "image-bytes" {
|
||||
t.Fatalf("body = %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// noRedirectClient returns a client bound to the test server transport that does
|
||||
// not auto-follow redirects, mirroring the production adapter's CheckRedirect.
|
||||
func noRedirectClient(server *httptest.Server) *http.Client {
|
||||
client := server.Client()
|
||||
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func TestDownloadFileRejectsRedirectOffAllowedHost(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/oauth2_v12/auth/getAppAccessToken":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"accessToken":"token-1","expireIn":7136},"error":null,"errorCode":0,"success":true}`))
|
||||
case "/gateway/docrest/doc/file/downloadfileOpen":
|
||||
http.Redirect(w, r, "https://evil.example.com/steal", http.StatusFound)
|
||||
default:
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldAuthURL := yunzhijiaAuthURL
|
||||
oldBaseURL := yunzhijiaDownloadFileBaseURL
|
||||
oldValidator := validateDownloadFileURL
|
||||
yunzhijiaAuthURL = server.URL + "/api/oauth2_v12/auth/getAppAccessToken"
|
||||
yunzhijiaDownloadFileBaseURL = server.URL + "/gateway/docrest/doc/file/downloadfileOpen"
|
||||
// Allow the test server host but reject anything off-host, mimicking the
|
||||
// real yunzhijia.com suffix check for redirect targets.
|
||||
validateDownloadFileURL = func(rawURL string) error {
|
||||
if strings.Contains(rawURL, "evil.example.com") {
|
||||
return fmt.Errorf("host not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
yunzhijiaAuthURL = oldAuthURL
|
||||
yunzhijiaDownloadFileBaseURL = oldBaseURL
|
||||
validateDownloadFileURL = oldValidator
|
||||
}()
|
||||
|
||||
adapter := NewAdapter("https://www.yunzhijia.com/send", "", "app-id", "app-secret", 10, "yunzhijia.com")
|
||||
adapter.httpClient = noRedirectClient(server)
|
||||
_, _, err := adapter.DownloadFile(context.Background(), &im.IncomingMessage{FileKey: "file-1"})
|
||||
if err == nil || !strings.Contains(err.Error(), "redirect rejected") {
|
||||
t.Fatalf("DownloadFile() error = %v, want redirect rejected", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitedReadCloserEnforcesMaxSize(t *testing.T) {
|
||||
reader := newLimitedReadCloser(io.NopCloser(strings.NewReader("0123456789")), 4)
|
||||
defer reader.Close()
|
||||
if _, err := io.ReadAll(reader); err == nil || !strings.Contains(err.Error(), "max download size") {
|
||||
t.Fatalf("read error = %v, want max download size error", err)
|
||||
}
|
||||
|
||||
ok := newLimitedReadCloser(io.NopCloser(strings.NewReader("1234")), 4)
|
||||
defer ok.Close()
|
||||
if got, err := io.ReadAll(ok); err != nil || string(got) != "1234" {
|
||||
t.Fatalf("read got=%q err=%v, want full read within limit", string(got), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFileRequiresAppCredentials(t *testing.T) {
|
||||
adapter := NewAdapter("https://www.yunzhijia.com/send", "", "", "", 10, "yunzhijia.com")
|
||||
_, _, err := adapter.DownloadFile(context.Background(), &im.IncomingMessage{FileKey: "file-1"})
|
||||
if err == nil || !strings.Contains(err.Error(), "app_id and app_secret") {
|
||||
t.Fatalf("DownloadFile() error = %v, want app credential error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFileIDRejectsURLishValues(t *testing.T) {
|
||||
for _, fileID := range []string{"", "a/b", "a?b", "a&b", "a b"} {
|
||||
if err := validateFileID(fileID); err == nil {
|
||||
t.Fatalf("validateFileID(%q) = nil, want error", fileID)
|
||||
}
|
||||
}
|
||||
if err := validateFileID("6a605afe5dda8e000121fcea"); err != nil {
|
||||
t.Fatalf("validateFileID(valid) error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package yunzhijia
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
)
|
||||
|
||||
// NewFactory returns an im.AdapterFactory for Yunzhijia channels.
|
||||
// Webhook remains the default; WebSocket uses yzjtoken from send_msg_url.
|
||||
func NewFactory() im.AdapterFactory {
|
||||
return func(factoryCtx context.Context, channel *im.IMChannel, msgHandler func(context.Context, *im.IncomingMessage) error) (im.Adapter, context.CancelFunc, error) {
|
||||
creds, err := im.ParseCredentials(channel.Credentials)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse yunzhijia credentials: %w", err)
|
||||
}
|
||||
|
||||
sendMsgURL := im.GetString(creds, "send_msg_url")
|
||||
if sendMsgURL == "" {
|
||||
return nil, nil, fmt.Errorf("yunzhijia send_msg_url is required")
|
||||
}
|
||||
|
||||
secret := im.GetString(creds, "secret")
|
||||
appID := im.GetString(creds, "app_id")
|
||||
appSecret := im.GetString(creds, "app_secret")
|
||||
allowedHostSuffix := im.GetString(creds, "allowed_webhook_host_suffix")
|
||||
|
||||
timeoutSeconds := positiveIntCredential(creds, "timeout_seconds", 10)
|
||||
|
||||
adapter := NewAdapter(sendMsgURL, secret, appID, appSecret, timeoutSeconds, allowedHostSuffix)
|
||||
if err := adapter.validateSendURL(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
mode := im.ResolveMode(channel, "webhook")
|
||||
switch mode {
|
||||
case "webhook":
|
||||
return adapter, nil, nil
|
||||
case "websocket":
|
||||
webSocketURL, err := deriveWebSocketURL(sendMsgURL, allowedHostSuffix)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
client := NewLongConnClient(webSocketURL, msgHandler)
|
||||
wsCtx, wsCancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
if err := client.Start(wsCtx); err != nil && wsCtx.Err() == nil {
|
||||
logger.Errorf(context.Background(), "[IM] Yunzhijia WebSocket stopped for channel %s: %v", channel.ID, err)
|
||||
}
|
||||
}()
|
||||
stop := func() {
|
||||
client.Stop()
|
||||
wsCancel()
|
||||
}
|
||||
return adapter, stop, nil
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unsupported yunzhijia mode: %s", mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func positiveIntCredential(creds map[string]any, key string, fallback int) int {
|
||||
switch value := creds[key].(type) {
|
||||
case float64:
|
||||
if value > 0 {
|
||||
return int(value)
|
||||
}
|
||||
case string:
|
||||
if parsed, err := strconv.Atoi(value); err == nil && parsed > 0 {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package yunzhijia
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// computeSignature builds the signature string from the callback fields
|
||||
// and returns the Base64(HMAC_SHA1(secret, signatureString)).
|
||||
//
|
||||
// The signature string is formed by joining the following fields in order
|
||||
// with commas: robotId, robotName, operatorOpenid, operatorName, time, msgId, content.
|
||||
func computeSignature(secret string, msg *callbackMessage) string {
|
||||
// Build signature string: field1,field2,...,field7
|
||||
parts := []string{
|
||||
msg.RobotID,
|
||||
msg.RobotName,
|
||||
msg.OperatorOpenid,
|
||||
msg.OperatorName,
|
||||
fmt.Sprintf("%d", msg.Time),
|
||||
msg.MsgID,
|
||||
msg.Content,
|
||||
}
|
||||
signatureString := strings.Join(parts, ",")
|
||||
|
||||
mac := hmac.New(sha1.New, []byte(secret))
|
||||
mac.Write([]byte(signatureString))
|
||||
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package yunzhijia
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// callbackMessage is the JSON structure that Yunzhijia posts to the webhook.
|
||||
type callbackMessage struct {
|
||||
Type int `json:"type"`
|
||||
MsgType int `json:"msgType"`
|
||||
EID string `json:"eid"`
|
||||
ClientID string `json:"clientId"`
|
||||
RobotID string `json:"robotId"`
|
||||
RobotName string `json:"robotName"`
|
||||
OpenID string `json:"openId"`
|
||||
GroupID string `json:"groupId"`
|
||||
OperatorOpenid string `json:"operatorOpenid"`
|
||||
OperatorOID string `json:"operatorOid"`
|
||||
OperatorID string `json:"operatorId"`
|
||||
OperatorUserID string `json:"operatorUserId"`
|
||||
OperatorName string `json:"operatorName"`
|
||||
SenderID string `json:"senderId"`
|
||||
SenderName string `json:"senderName"`
|
||||
Time int64 `json:"time"`
|
||||
MsgID string `json:"msgId"`
|
||||
Content string `json:"content"`
|
||||
GroupType int `json:"groupType"`
|
||||
MsgParam string `json:"msgParam"`
|
||||
}
|
||||
|
||||
type messageParam struct {
|
||||
Desc []messageParamDesc `json:"desc"`
|
||||
NotifyTo []string `json:"notifyTo"`
|
||||
// Yunzhijia sends this as either a number or a string depending on the
|
||||
// message client. We do not use its value, but it must be tolerant so a
|
||||
// reply's notifyTo list remains available for mention detection.
|
||||
NotifyType json.RawMessage `json:"notifyType"`
|
||||
ReplyMsgID string `json:"replyMsgId"`
|
||||
ReplyRootMsgID string `json:"replyRootMsgId"`
|
||||
}
|
||||
|
||||
type messageParamDesc struct {
|
||||
Type string `json:"type"`
|
||||
Data string `json:"data"`
|
||||
Start int `json:"start"`
|
||||
Length int `json:"length"`
|
||||
Width int `json:"w"`
|
||||
Height int `json:"h"`
|
||||
}
|
||||
|
||||
// sendMessagePayload is the JSON body POSTed to sendMsgUrl to reply.
|
||||
type sendMessagePayload struct {
|
||||
MsgType int `json:"msgtype"`
|
||||
Content string `json:"content"`
|
||||
NotifyParams []notifyParam `json:"notifyParams,omitempty"`
|
||||
ParamType int `json:"paramType,omitempty"`
|
||||
Param *sendMessageParam `json:"param,omitempty"`
|
||||
}
|
||||
|
||||
// notifyParam specifies recipients in a Yunzhijia send message request.
|
||||
type notifyParam struct {
|
||||
Type string `json:"type"`
|
||||
Values []string `json:"values"`
|
||||
}
|
||||
|
||||
// sendMessageParam carries extra rendering options for a Yunzhijia reply,
|
||||
// such as requesting Markdown rendering of Content via formatType.
|
||||
type sendMessageParam struct {
|
||||
FormatType string `json:"formatType,omitempty"`
|
||||
ReplyMsgID string `json:"replyMsgId,omitempty"`
|
||||
IsReference bool `json:"isReference,omitempty"`
|
||||
ReplySummary string `json:"replySummary,omitempty"`
|
||||
ReplyPersonName string `json:"replyPersonName,omitempty"`
|
||||
}
|
||||
|
||||
type appAccessTokenResponse struct {
|
||||
Data appAccessTokenData `json:"data"`
|
||||
Error any `json:"error"`
|
||||
ErrorCode int `json:"errorCode"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
type appAccessTokenData struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpireIn int64 `json:"expireIn"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
|
||||
func (m *messageParam) firstImage() (messageParamDesc, bool) {
|
||||
if m == nil {
|
||||
return messageParamDesc{}, false
|
||||
}
|
||||
for _, desc := range m.Desc {
|
||||
if desc.Type == "image" && desc.Data != "" {
|
||||
return desc, true
|
||||
}
|
||||
}
|
||||
return messageParamDesc{}, false
|
||||
}
|
||||
|
||||
func parseMessageParam(raw string) (*messageParam, error) {
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var param messageParam
|
||||
if err := json.Unmarshal([]byte(raw), ¶m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ¶m, nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package yunzhijia
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/ipclass"
|
||||
)
|
||||
|
||||
func deriveWebSocketURL(sendMsgURL, allowedHostSuffix string) (string, error) {
|
||||
u, err := validateEndpointURL(sendMsgURL, "https", allowedHostSuffix)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid send_msg_url for websocket: %w", err)
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(u.Query().Get("yzjtoken"))
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("send_msg_url is missing yzjtoken")
|
||||
}
|
||||
|
||||
wsURL := &url.URL{
|
||||
Scheme: "wss",
|
||||
Host: u.Host,
|
||||
Path: "/xuntong/websocket",
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("yzjtoken", token)
|
||||
wsURL.RawQuery = query.Encode()
|
||||
return wsURL.String(), nil
|
||||
}
|
||||
|
||||
func validateEndpointURL(rawURL, requiredScheme, allowedHostSuffix string) (*url.URL, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.EqualFold(u.Scheme, requiredScheme) {
|
||||
return nil, fmt.Errorf("URL must use %s", requiredScheme)
|
||||
}
|
||||
if u.User != nil {
|
||||
return nil, fmt.Errorf("URL must not contain user information")
|
||||
}
|
||||
|
||||
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
|
||||
if host == "" {
|
||||
return nil, fmt.Errorf("URL has empty host")
|
||||
}
|
||||
if host == "localhost" || strings.HasSuffix(host, ".localhost") || net.ParseIP(host) != nil {
|
||||
return nil, fmt.Errorf("URL host must be a DNS name")
|
||||
}
|
||||
|
||||
suffix := strings.ToLower(strings.Trim(strings.TrimSpace(allowedHostSuffix), "."))
|
||||
if suffix == "" {
|
||||
return nil, fmt.Errorf("allowed host suffix is required")
|
||||
}
|
||||
if host != suffix && !strings.HasSuffix(host, "."+suffix) {
|
||||
return nil, fmt.Errorf("URL host %q does not match allowed suffix %q", host, suffix)
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func safeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("split dial address: %w", err)
|
||||
}
|
||||
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve endpoint host %q: %w", host, err)
|
||||
}
|
||||
if len(addresses) == 0 {
|
||||
return nil, fmt.Errorf("endpoint host %q resolved to no addresses", host)
|
||||
}
|
||||
for _, address := range addresses {
|
||||
if !isPublicIP(address.IP) {
|
||||
return nil, fmt.Errorf("endpoint host %q resolves to a non-public address", host)
|
||||
}
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{}
|
||||
var lastErr error
|
||||
for _, address := range addresses {
|
||||
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(address.IP.String(), port))
|
||||
if dialErr == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
}
|
||||
return nil, fmt.Errorf("dial endpoint host %q: %w", host, lastErr)
|
||||
}
|
||||
|
||||
// isPublicIP reports whether a resolved endpoint address may be dialled. The
|
||||
// webhook endpoint is tenant-supplied, so this is an SSRF check and shares
|
||||
// internal/ipclass with the other outbound guards rather than re-deriving
|
||||
// "private" from net.IP's predicates, which miss carrier-grade NAT, IPv6
|
||||
// site-local, and the encodings that embed an IPv4 link-local address.
|
||||
func isPublicIP(ip net.IP) bool {
|
||||
return ipclass.IsPublic(ip)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package yunzhijia
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeriveWebSocketURL(t *testing.T) {
|
||||
got, err := deriveWebSocketURL(
|
||||
"https://dev.kdweibo.cn:8443/gateway/robot/webhook/send?yzjtype=12&yzjtoken=token%20value",
|
||||
"kdweibo.cn",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("deriveWebSocketURL() error = %v", err)
|
||||
}
|
||||
want := "wss://dev.kdweibo.cn:8443/xuntong/websocket?yzjtoken=token+value"
|
||||
if got != want {
|
||||
t.Fatalf("deriveWebSocketURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveWebSocketURLRejectsMissingToken(t *testing.T) {
|
||||
_, err := deriveWebSocketURL("https://www.yunzhijia.com/gateway/robot/webhook/send", "yunzhijia.com")
|
||||
if err == nil {
|
||||
t.Fatal("deriveWebSocketURL() expected missing-token error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEndpointURLHostSuffixBoundary(t *testing.T) {
|
||||
valid := []string{
|
||||
"https://yunzhijia.com/path",
|
||||
"https://www.yunzhijia.com/path",
|
||||
}
|
||||
for _, rawURL := range valid {
|
||||
if _, err := validateEndpointURL(rawURL, "https", "yunzhijia.com"); err != nil {
|
||||
t.Errorf("validateEndpointURL(%q) unexpected error: %v", rawURL, err)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"http://www.yunzhijia.com/path",
|
||||
"https://evilyunzhijia.com/path",
|
||||
"https://127.0.0.1/path",
|
||||
"https://localhost/path",
|
||||
}
|
||||
for _, rawURL := range invalid {
|
||||
if _, err := validateEndpointURL(rawURL, "https", "yunzhijia.com"); err == nil {
|
||||
t.Errorf("validateEndpointURL(%q) expected error", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEndpointURLRequiresHostSuffix(t *testing.T) {
|
||||
if _, err := validateEndpointURL("https://www.yunzhijia.com/path", "https", ""); err == nil {
|
||||
t.Fatal("validateEndpointURL() expected missing host suffix error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPublicIP(t *testing.T) {
|
||||
for _, rawIP := range []string{"127.0.0.1", "10.0.0.1", "169.254.169.254", "::1", "fc00::1"} {
|
||||
if isPublicIP(net.ParseIP(rawIP)) {
|
||||
t.Errorf("isPublicIP(%q) = true, want false", rawIP)
|
||||
}
|
||||
}
|
||||
if !isPublicIP(net.ParseIP("8.8.8.8")) {
|
||||
t.Error("isPublicIP(8.8.8.8) = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPositiveIntCredential(t *testing.T) {
|
||||
if got := positiveIntCredential(map[string]any{"timeout": float64(25)}, "timeout", 10); got != 25 {
|
||||
t.Fatalf("numeric timeout = %d, want 25", got)
|
||||
}
|
||||
if got := positiveIntCredential(map[string]any{"timeout": "30"}, "timeout", 10); got != 30 {
|
||||
t.Fatalf("string timeout = %d, want 30", got)
|
||||
}
|
||||
if got := positiveIntCredential(map[string]any{"timeout": float64(0)}, "timeout", 10); got != 10 {
|
||||
t.Fatalf("invalid timeout = %d, want fallback 10", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package yunzhijia
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
ws "github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
webSocketHeartbeatInterval = 15 * time.Second
|
||||
webSocketReadTimeout = 45 * time.Second
|
||||
webSocketHandshakeTimeout = 10 * time.Second
|
||||
webSocketMaxMessageSize = 1 << 20
|
||||
webSocketMaxInvalidFrames = 3
|
||||
webSocketMessageQueueSize = 64
|
||||
)
|
||||
|
||||
var webSocketReconnectDelays = [...]time.Duration{
|
||||
1 * time.Second,
|
||||
2 * time.Second,
|
||||
5 * time.Second,
|
||||
10 * time.Second,
|
||||
30 * time.Second,
|
||||
60 * time.Second,
|
||||
}
|
||||
|
||||
type webSocketFrame struct {
|
||||
message *callbackMessage
|
||||
ack []byte
|
||||
control string
|
||||
}
|
||||
|
||||
type LongConnClient struct {
|
||||
url string
|
||||
handler func(context.Context, *im.IncomingMessage) error
|
||||
dialer *ws.Dialer
|
||||
messages chan *im.IncomingMessage
|
||||
|
||||
mu sync.Mutex
|
||||
conn *ws.Conn
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func NewLongConnClient(webSocketURL string, handler func(context.Context, *im.IncomingMessage) error) *LongConnClient {
|
||||
dialer := *ws.DefaultDialer
|
||||
dialer.HandshakeTimeout = webSocketHandshakeTimeout
|
||||
dialer.Proxy = nil
|
||||
dialer.NetDialContext = safeDialContext
|
||||
return &LongConnClient{
|
||||
url: webSocketURL,
|
||||
handler: handler,
|
||||
dialer: &dialer,
|
||||
messages: make(chan *im.IncomingMessage, webSocketMessageQueueSize),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LongConnClient) Start(ctx context.Context) error {
|
||||
logger.Infof(ctx, "[IM] Yunzhijia WebSocket connecting")
|
||||
go c.handleMessages(ctx)
|
||||
attempt := 0
|
||||
for {
|
||||
if ctx.Err() != nil || c.closed.Load() {
|
||||
return nil
|
||||
}
|
||||
|
||||
connectedAt := time.Now()
|
||||
err := c.connectAndRun(ctx)
|
||||
if ctx.Err() != nil || c.closed.Load() {
|
||||
return nil
|
||||
}
|
||||
if time.Since(connectedAt) >= webSocketReconnectDelays[len(webSocketReconnectDelays)-1] {
|
||||
attempt = 0
|
||||
}
|
||||
|
||||
delay := webSocketReconnectDelay(attempt)
|
||||
attempt++
|
||||
logger.Warnf(ctx, "[Yunzhijia] WebSocket connection lost: %v; reconnecting in %v", err, delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LongConnClient) Stop() {
|
||||
c.closed.Store(true)
|
||||
c.closeConn()
|
||||
}
|
||||
|
||||
func (c *LongConnClient) connectAndRun(ctx context.Context) error {
|
||||
conn, _, err := c.dialer.DialContext(ctx, c.url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial websocket: %w", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.conn = conn
|
||||
c.mu.Unlock()
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
if c.conn == conn {
|
||||
c.conn = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
conn.SetReadLimit(webSocketMaxMessageSize)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(webSocketReadTimeout))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(webSocketReadTimeout))
|
||||
})
|
||||
|
||||
heartbeatCtx, cancelHeartbeat := context.WithCancel(ctx)
|
||||
defer cancelHeartbeat()
|
||||
go c.heartbeatLoop(heartbeatCtx, conn)
|
||||
|
||||
logger.Infof(ctx, "[IM] Yunzhijia WebSocket connected")
|
||||
invalidFrames := 0
|
||||
for {
|
||||
messageType, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read websocket message: %w", err)
|
||||
}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(webSocketReadTimeout))
|
||||
|
||||
if messageType != ws.TextMessage {
|
||||
invalidFrames++
|
||||
if invalidFrames >= webSocketMaxInvalidFrames {
|
||||
return fmt.Errorf("too many non-text websocket frames")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
frame, err := parseWebSocketFrame(data)
|
||||
if err != nil {
|
||||
invalidFrames++
|
||||
logger.Warnf(ctx, "[Yunzhijia] Invalid WebSocket frame: %v", err)
|
||||
if invalidFrames >= webSocketMaxInvalidFrames {
|
||||
return fmt.Errorf("too many invalid websocket frames")
|
||||
}
|
||||
continue
|
||||
}
|
||||
invalidFrames = 0
|
||||
|
||||
if len(frame.ack) > 0 {
|
||||
if err := c.writeText(conn, frame.ack); err != nil {
|
||||
return fmt.Errorf("send websocket ack: %w", err)
|
||||
}
|
||||
}
|
||||
if frame.message == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
incoming := toIncomingMessage(ctx, frame.message)
|
||||
if incoming == nil {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case c.messages <- incoming:
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LongConnClient) handleMessages(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg := <-c.messages:
|
||||
if err := c.handler(ctx, msg); err != nil {
|
||||
logger.Errorf(ctx, "[Yunzhijia] Handle WebSocket message failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LongConnClient) heartbeatLoop(ctx context.Context, conn *ws.Conn) {
|
||||
ticker := time.NewTicker(webSocketHeartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := conn.WriteControl(ws.PingMessage, nil, time.Now().Add(5*time.Second)); err != nil {
|
||||
logger.Warnf(ctx, "[Yunzhijia] WebSocket heartbeat failed: %v", err)
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LongConnClient) writeText(conn *ws.Conn, data []byte) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.conn != conn {
|
||||
return fmt.Errorf("websocket connection changed")
|
||||
}
|
||||
return conn.WriteMessage(ws.TextMessage, data)
|
||||
}
|
||||
|
||||
func (c *LongConnClient) closeConn() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
}
|
||||
|
||||
func webSocketReconnectDelay(attempt int) time.Duration {
|
||||
if attempt < 0 {
|
||||
attempt = 0
|
||||
}
|
||||
if attempt >= len(webSocketReconnectDelays) {
|
||||
return webSocketReconnectDelays[len(webSocketReconnectDelays)-1]
|
||||
}
|
||||
return webSocketReconnectDelays[attempt]
|
||||
}
|
||||
|
||||
func parseWebSocketFrame(data []byte) (*webSocketFrame, error) {
|
||||
trimmed := bytes.TrimSpace(data)
|
||||
if len(trimmed) == 0 {
|
||||
return nil, fmt.Errorf("empty frame")
|
||||
}
|
||||
|
||||
plain := strings.ToLower(string(trimmed))
|
||||
if plain == "ping" || plain == "pong" {
|
||||
return &webSocketFrame{control: plain}, nil
|
||||
}
|
||||
|
||||
var stringPayload string
|
||||
if err := json.Unmarshal(trimmed, &stringPayload); err == nil {
|
||||
control := strings.ToLower(strings.TrimSpace(stringPayload))
|
||||
if control == "ping" || control == "pong" {
|
||||
return &webSocketFrame{control: control}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unknown string frame")
|
||||
}
|
||||
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(trimmed, &fields); err != nil {
|
||||
return nil, fmt.Errorf("decode frame: %w", err)
|
||||
}
|
||||
|
||||
if msg := decodeBusinessMessage(trimmed); msg != nil {
|
||||
return &webSocketFrame{message: msg}, nil
|
||||
}
|
||||
|
||||
typeName := rawString(fields["type"])
|
||||
if strings.EqualFold(typeName, "robotMessage") {
|
||||
if msg := decodeBusinessMessage(fields["msg"]); msg != nil {
|
||||
return &webSocketFrame{message: msg}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("robotMessage envelope has invalid msg")
|
||||
}
|
||||
|
||||
cmd := strings.ToLower(strings.TrimSpace(rawString(fields["cmd"])))
|
||||
typeName = strings.ToLower(strings.TrimSpace(typeName))
|
||||
event := strings.ToLower(strings.TrimSpace(rawString(fields["event"])))
|
||||
control := cmd
|
||||
if control == "" {
|
||||
control = typeName
|
||||
}
|
||||
if control == "" {
|
||||
control = event
|
||||
}
|
||||
|
||||
frame := &webSocketFrame{control: control}
|
||||
if cmd == "directpush" || typeName == "msgchg" {
|
||||
var needAck bool
|
||||
_ = json.Unmarshal(fields["needAck"], &needAck)
|
||||
var seq int64
|
||||
if needAck && json.Unmarshal(fields["seq"], &seq) == nil {
|
||||
frame.ack, _ = json.Marshal(struct {
|
||||
Cmd string `json:"cmd"`
|
||||
Seq int64 `json:"seq"`
|
||||
}{Cmd: "ack", Seq: seq})
|
||||
}
|
||||
return frame, nil
|
||||
}
|
||||
if control != "" {
|
||||
return frame, nil
|
||||
}
|
||||
return nil, fmt.Errorf("frame has no business message or control type")
|
||||
}
|
||||
|
||||
func decodeBusinessMessage(data []byte) *callbackMessage {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, key := range []string{"robotId", "robotName", "operatorOpenid", "operatorName", "msgId", "content"} {
|
||||
var value string
|
||||
if json.Unmarshal(fields[key], &value) != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var messageType int
|
||||
if json.Unmarshal(fields["type"], &messageType) != nil {
|
||||
return nil
|
||||
}
|
||||
var messageTime int64
|
||||
if json.Unmarshal(fields["time"], &messageTime) != nil {
|
||||
return nil
|
||||
}
|
||||
var msg callbackMessage
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &msg
|
||||
}
|
||||
|
||||
func rawString(raw json.RawMessage) string {
|
||||
var value string
|
||||
if len(raw) > 0 && json.Unmarshal(raw, &value) == nil {
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package yunzhijia
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
ws "github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const testBusinessMessage = `{
|
||||
"type": 2,
|
||||
"robotId": "bot-1",
|
||||
"robotName": "WeKnora",
|
||||
"operatorOpenid": "user-1",
|
||||
"operatorName": "User",
|
||||
"time": 1719648000000,
|
||||
"msgId": "msg-1",
|
||||
"content": "@WeKnora hello",
|
||||
"groupType": 1
|
||||
}`
|
||||
|
||||
func TestParseWebSocketFrameDirectMessage(t *testing.T) {
|
||||
frame, err := parseWebSocketFrame([]byte(testBusinessMessage))
|
||||
if err != nil {
|
||||
t.Fatalf("parseWebSocketFrame() error = %v", err)
|
||||
}
|
||||
if frame.message == nil || frame.message.MsgID != "msg-1" {
|
||||
t.Fatalf("message = %#v, want msg-1", frame.message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWebSocketFrameRobotMessageEnvelope(t *testing.T) {
|
||||
frame, err := parseWebSocketFrame([]byte(`{"type":"robotMessage","msg":` + testBusinessMessage + `}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parseWebSocketFrame() error = %v", err)
|
||||
}
|
||||
if frame.message == nil || frame.message.OperatorOpenid != "user-1" {
|
||||
t.Fatalf("message = %#v, want user-1", frame.message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWebSocketFrameBuildsDirectPushACK(t *testing.T) {
|
||||
frame, err := parseWebSocketFrame([]byte(`{"cmd":"directPush","needAck":true,"seq":42}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parseWebSocketFrame() error = %v", err)
|
||||
}
|
||||
var ack struct {
|
||||
Cmd string `json:"cmd"`
|
||||
Seq int64 `json:"seq"`
|
||||
}
|
||||
if err := json.Unmarshal(frame.ack, &ack); err != nil {
|
||||
t.Fatalf("unmarshal ack: %v", err)
|
||||
}
|
||||
if ack.Cmd != "ack" || ack.Seq != 42 {
|
||||
t.Fatalf("ack = %#v, want cmd=ack seq=42", ack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWebSocketFrameControlAndInvalid(t *testing.T) {
|
||||
frame, err := parseWebSocketFrame([]byte(`{"event":"pong"}`))
|
||||
if err != nil || frame.control != "pong" {
|
||||
t.Fatalf("control frame = %#v, err=%v", frame, err)
|
||||
}
|
||||
if _, err := parseWebSocketFrame([]byte(`{"unknown":true}`)); err == nil {
|
||||
t.Fatal("expected invalid frame error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToIncomingMessageSharedByWebhookAndWebSocket(t *testing.T) {
|
||||
var msg callbackMessage
|
||||
if err := json.Unmarshal([]byte(testBusinessMessage), &msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
incoming := toIncomingMessage(t.Context(), &msg)
|
||||
if incoming == nil {
|
||||
t.Fatal("toIncomingMessage() returned nil")
|
||||
}
|
||||
if incoming.Content != "hello" {
|
||||
t.Fatalf("content = %q, want hello", incoming.Content)
|
||||
}
|
||||
if incoming.MessageID != "msg-1" || incoming.UserID != "user-1" {
|
||||
t.Fatalf("incoming = %#v", incoming)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketReconnectDelayCaps(t *testing.T) {
|
||||
if got := webSocketReconnectDelay(-1); got != webSocketReconnectDelays[0] {
|
||||
t.Fatalf("negative attempt delay = %v", got)
|
||||
}
|
||||
last := webSocketReconnectDelays[len(webSocketReconnectDelays)-1]
|
||||
if got := webSocketReconnectDelay(100); got != last {
|
||||
t.Fatalf("capped delay = %v, want %v", got, last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongConnClientProcessesMessagesInOrder(t *testing.T) {
|
||||
started := make(chan string, 2)
|
||||
releaseFirst := make(chan struct{})
|
||||
client := NewLongConnClient("wss://example.com/ws", func(_ context.Context, msg *im.IncomingMessage) error {
|
||||
started <- msg.MessageID
|
||||
if msg.MessageID == "first" {
|
||||
<-releaseFirst
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go client.handleMessages(ctx)
|
||||
client.messages <- &im.IncomingMessage{MessageID: "first"}
|
||||
client.messages <- &im.IncomingMessage{MessageID: "second"}
|
||||
|
||||
select {
|
||||
case got := <-started:
|
||||
if got != "first" {
|
||||
t.Fatalf("first handled message = %q, want first", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("first message was not handled")
|
||||
}
|
||||
select {
|
||||
case got := <-started:
|
||||
t.Fatalf("second message started before first completed: %q", got)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(releaseFirst)
|
||||
select {
|
||||
case got := <-started:
|
||||
if got != "second" {
|
||||
t.Fatalf("second handled message = %q, want second", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("second message was not handled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongConnClientAcknowledgesFrameAndStops(t *testing.T) {
|
||||
ackReceived := make(chan struct{})
|
||||
var ackOnce sync.Once
|
||||
upgrader := ws.Upgrader{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := conn.WriteMessage(ws.TextMessage, []byte(`{"cmd":"directPush","needAck":true,"seq":42}`)); err != nil {
|
||||
return
|
||||
}
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err == nil && string(data) == `{"cmd":"ack","seq":42}` {
|
||||
ackOnce.Do(func() { close(ackReceived) })
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewLongConnClient("ws"+strings.TrimPrefix(server.URL, "http"), func(context.Context, *im.IncomingMessage) error {
|
||||
return nil
|
||||
})
|
||||
dialer := &net.Dialer{}
|
||||
client.dialer.NetDialContext = dialer.DialContext
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- client.Start(ctx) }()
|
||||
|
||||
select {
|
||||
case <-ackReceived:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("websocket ACK was not received")
|
||||
}
|
||||
cancel()
|
||||
client.Stop()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Start() error after stop = %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("websocket client did not stop promptly")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user