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,127 @@
|
||||
package qqbot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
)
|
||||
|
||||
var _ im.Adapter = (*Adapter)(nil)
|
||||
|
||||
type Adapter struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
func NewAdapter(client *Client) *Adapter {
|
||||
return &Adapter{client: client}
|
||||
}
|
||||
|
||||
func (a *Adapter) Platform() im.Platform {
|
||||
return im.PlatformQQBot
|
||||
}
|
||||
|
||||
func (a *Adapter) HandleURLVerification(c *gin.Context) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *Adapter) VerifyCallback(c *gin.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
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 payload gatewayPayload
|
||||
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
|
||||
return nil, fmt.Errorf("parse payload: %w", err)
|
||||
}
|
||||
return parseGatewayPayload(&payload)
|
||||
}
|
||||
|
||||
func (a *Adapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
|
||||
content := strings.TrimSpace(im.FormatIMDisplayContent(reply.Content, im.StreamDisplayFinal))
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
msgID := ""
|
||||
if incoming.Extra != nil {
|
||||
msgID = incoming.Extra[extraKeyMessageID]
|
||||
}
|
||||
if incoming.ChatType == im.ChatTypeGroup {
|
||||
return a.client.SendGroupMessage(ctx, incoming.ChatID, content, msgID)
|
||||
}
|
||||
return a.client.SendC2CMessage(ctx, incoming.UserID, content, msgID)
|
||||
}
|
||||
|
||||
func parseGatewayPayload(payload *gatewayPayload) (*im.IncomingMessage, error) {
|
||||
if payload == nil || payload.Op != opDispatch {
|
||||
return nil, nil
|
||||
}
|
||||
var event messageEvent
|
||||
if err := json.Unmarshal(payload.D, &event); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch payload.T {
|
||||
case eventC2CMessageCreate:
|
||||
return parseC2CMessage(&event), nil
|
||||
case eventGroupAtMessageCreate:
|
||||
return parseGroupMessage(&event), nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func parseC2CMessage(event *messageEvent) *im.IncomingMessage {
|
||||
userID := firstNonEmpty(event.Author.UserOpenID, event.Author.ID)
|
||||
return &im.IncomingMessage{
|
||||
Platform: im.PlatformQQBot,
|
||||
MessageType: im.MessageTypeText,
|
||||
UserID: userID,
|
||||
UserName: event.Author.Username,
|
||||
ChatID: "",
|
||||
ChatType: im.ChatTypeDirect,
|
||||
Content: strings.TrimSpace(event.Content),
|
||||
MessageID: event.ID,
|
||||
Extra: map[string]string{
|
||||
extraKeyMessageID: event.ID,
|
||||
extraKeyChatKind: "c2c",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func parseGroupMessage(event *messageEvent) *im.IncomingMessage {
|
||||
return &im.IncomingMessage{
|
||||
Platform: im.PlatformQQBot,
|
||||
MessageType: im.MessageTypeText,
|
||||
UserID: firstNonEmpty(event.Author.MemberOpenID, event.Author.UserOpenID, event.Author.ID),
|
||||
UserName: event.Author.Username,
|
||||
ChatID: event.GroupOpenID,
|
||||
ChatType: im.ChatTypeGroup,
|
||||
Content: strings.TrimSpace(event.Content),
|
||||
MessageID: event.ID,
|
||||
Extra: map[string]string{
|
||||
extraKeyMessageID: event.ID,
|
||||
extraKeyChatKind: "group",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package qqbot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
func withQQBotSSRFWhitelist(t *testing.T, whitelist string) {
|
||||
t.Helper()
|
||||
t.Setenv("SSRF_WHITELIST", whitelist)
|
||||
secutils.ResetSSRFWhitelistForTest()
|
||||
t.Cleanup(secutils.ResetSSRFWhitelistForTest)
|
||||
}
|
||||
|
||||
func TestParseGatewayPayloadC2CMessage(t *testing.T) {
|
||||
event := messageEvent{
|
||||
ID: "msg-1",
|
||||
Content: " hello ",
|
||||
Author: qqbotAuthor{UserOpenID: "user-openid", Username: "tester"},
|
||||
}
|
||||
raw, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
msg, err := parseGatewayPayload(&gatewayPayload{Op: opDispatch, T: eventC2CMessageCreate, D: raw})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg == nil {
|
||||
t.Fatal("expected message")
|
||||
}
|
||||
if msg.Platform != im.PlatformQQBot || msg.ChatType != im.ChatTypeDirect || msg.UserID != "user-openid" || msg.Content != "hello" {
|
||||
t.Fatalf("unexpected message: %#v", msg)
|
||||
}
|
||||
if msg.Extra[extraKeyMessageID] != "msg-1" || msg.Extra[extraKeyChatKind] != "c2c" {
|
||||
t.Fatalf("unexpected extra: %#v", msg.Extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGatewayPayloadGroupMessage(t *testing.T) {
|
||||
event := messageEvent{
|
||||
ID: "msg-2",
|
||||
Content: " group hello ",
|
||||
GroupOpenID: "group-openid",
|
||||
Author: qqbotAuthor{MemberOpenID: "member-openid"},
|
||||
}
|
||||
raw, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
msg, err := parseGatewayPayload(&gatewayPayload{Op: opDispatch, T: eventGroupAtMessageCreate, D: raw})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg == nil {
|
||||
t.Fatal("expected message")
|
||||
}
|
||||
if msg.ChatType != im.ChatTypeGroup || msg.ChatID != "group-openid" || msg.UserID != "member-openid" || msg.Content != "group hello" {
|
||||
t.Fatalf("unexpected message: %#v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExpiresIn(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want int
|
||||
}{
|
||||
{name: "number", raw: `3600`, want: 3600},
|
||||
{name: "string", raw: `"1800"`, want: 1800},
|
||||
{name: "empty", raw: ``, want: 7200},
|
||||
{name: "invalid", raw: `"bad"`, want: 7200},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := parseExpiresIn(json.RawMessage(tc.raw)); got != tc.want {
|
||||
t.Fatalf("parseExpiresIn() = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientRejectsPrivateAPIBaseURL(t *testing.T) {
|
||||
withQQBotSSRFWhitelist(t, "")
|
||||
|
||||
if _, err := NewClient("app", "secret", "http://127.0.0.1:8080", ""); err == nil {
|
||||
t.Fatal("expected private qqbot api_base_url to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientRejectsInsecureGatewayURL(t *testing.T) {
|
||||
withQQBotSSRFWhitelist(t, "127.0.0.1")
|
||||
|
||||
if _, err := NewClient("app", "secret", "http://127.0.0.1:8080", "ws://127.0.0.1/gateway"); err == nil {
|
||||
t.Fatal("expected non-wss qqbot gateway_url to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientAllowsWhitelistedPrivateDeployment(t *testing.T) {
|
||||
withQQBotSSRFWhitelist(t, "127.0.0.1")
|
||||
|
||||
if _, err := NewClient("app", "secret", "http://127.0.0.1:8080", "wss://127.0.0.1/gateway"); err != nil {
|
||||
t.Fatalf("expected whitelisted private qqbot endpoints to pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessageRequestUsesMarkdownMessage(t *testing.T) {
|
||||
req := sendMessageRequest{
|
||||
Content: "fallback",
|
||||
MsgType: 2,
|
||||
Markdown: &markdownMessage{Content: "# title\n**answer**"},
|
||||
MsgID: "msg-1",
|
||||
MsgSeq: 1,
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["msg_type"] != float64(2) {
|
||||
t.Fatalf("msg_type = %v, want 2", payload["msg_type"])
|
||||
}
|
||||
markdown, ok := payload["markdown"].(map[string]any)
|
||||
if !ok || markdown["content"] != "# title\n**answer**" {
|
||||
t.Fatalf("markdown payload = %#v", payload["markdown"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package qqbot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
appID string
|
||||
clientSecret string
|
||||
apiBaseURL string
|
||||
gatewayURL string
|
||||
httpClient *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
accessToken string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func NewClient(appID, clientSecret, apiBaseURL, gatewayURL string) (*Client, error) {
|
||||
appID = strings.TrimSpace(appID)
|
||||
clientSecret = strings.TrimSpace(clientSecret)
|
||||
if appID == "" {
|
||||
return nil, fmt.Errorf("qqbot app_id is required")
|
||||
}
|
||||
if clientSecret == "" {
|
||||
return nil, fmt.Errorf("qqbot client_secret is required")
|
||||
}
|
||||
if apiBaseURL == "" {
|
||||
apiBaseURL = defaultAPIBaseURL
|
||||
}
|
||||
apiBaseURL = strings.TrimRight(strings.TrimSpace(apiBaseURL), "/")
|
||||
if err := validateHTTPAPIBaseURL(apiBaseURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gatewayURL = strings.TrimSpace(gatewayURL)
|
||||
if err := validateGatewayURL(gatewayURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Client{
|
||||
appID: appID,
|
||||
clientSecret: clientSecret,
|
||||
apiBaseURL: apiBaseURL,
|
||||
gatewayURL: gatewayURL,
|
||||
httpClient: secutils.NewSSRFSafeHTTPClient(secutils.SSRFSafeHTTPClientConfig{
|
||||
Timeout: 15 * time.Second,
|
||||
MaxRedirects: 5,
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GatewayURL(ctx context.Context) (string, error) {
|
||||
if c.gatewayURL != "" {
|
||||
return c.gatewayURL, nil
|
||||
}
|
||||
var result gatewayResponse
|
||||
if err := c.doJSON(ctx, http.MethodGet, defaultGatewayURL, nil, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.URL == "" {
|
||||
return "", fmt.Errorf("empty qqbot gateway url")
|
||||
}
|
||||
if err := validateGatewayURL(result.URL); err != nil {
|
||||
return "", fmt.Errorf("invalid qqbot gateway url: %w", err)
|
||||
}
|
||||
return result.URL, nil
|
||||
}
|
||||
|
||||
func validateHTTPAPIBaseURL(raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return fmt.Errorf("invalid qqbot api_base_url: must be a valid http(s) URL")
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("invalid qqbot api_base_url: must use http or https")
|
||||
}
|
||||
if err := secutils.ValidateURLForSSRF(raw); err != nil {
|
||||
return fmt.Errorf("invalid qqbot api_base_url: %w (for private deployments, add the hostname to SSRF_WHITELIST)", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGatewayURL(raw string) error {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return fmt.Errorf("gateway_url must be a valid wss URL")
|
||||
}
|
||||
if u.Scheme != "wss" {
|
||||
return fmt.Errorf("gateway_url must use wss")
|
||||
}
|
||||
checkURL := *u
|
||||
checkURL.Scheme = "https"
|
||||
if err := secutils.ValidateURLForSSRF(checkURL.String()); err != nil {
|
||||
return fmt.Errorf(
|
||||
"gateway_url failed SSRF validation: %w (for private deployments, add the hostname to SSRF_WHITELIST)",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) SendC2CMessage(ctx context.Context, openID, content, msgID string) error {
|
||||
path := fmt.Sprintf("/v2/users/%s/messages", openID)
|
||||
return c.sendText(ctx, path, content, msgID)
|
||||
}
|
||||
|
||||
func (c *Client) SendGroupMessage(ctx context.Context, groupOpenID, content, msgID string) error {
|
||||
path := fmt.Sprintf("/v2/groups/%s/messages", groupOpenID)
|
||||
return c.sendText(ctx, path, content, msgID)
|
||||
}
|
||||
|
||||
func (c *Client) sendText(ctx context.Context, path, content, msgID string) error {
|
||||
body := sendMessageRequest{
|
||||
MsgType: 2,
|
||||
Markdown: &markdownMessage{Content: content},
|
||||
MsgID: msgID,
|
||||
MsgSeq: 1,
|
||||
}
|
||||
return c.doJSON(ctx, http.MethodPost, c.apiBaseURL+path, body, nil)
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(ctx context.Context, method, url string, body any, out any) error {
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader = bytes.NewReader(payload)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if !strings.Contains(url, "getAppAccessToken") {
|
||||
token, err := c.AccessToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "QQBot "+token)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("qqbot api %s %s failed: %s", method, url, resp.Status)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("decode qqbot response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) AccessToken(ctx context.Context) (string, error) {
|
||||
c.mu.Lock()
|
||||
if c.accessToken != "" && time.Until(c.expiresAt) > time.Minute {
|
||||
token := c.accessToken
|
||||
c.mu.Unlock()
|
||||
return token, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
body := map[string]string{
|
||||
"appId": c.appID,
|
||||
"clientSecret": c.clientSecret,
|
||||
}
|
||||
var result tokenResponse
|
||||
if err := c.doJSON(ctx, http.MethodPost, appTokenURL, body, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.AccessToken == "" {
|
||||
return "", fmt.Errorf("empty qqbot access token: code=%d message=%s", result.Code, result.Message)
|
||||
}
|
||||
expiresIn := parseExpiresIn(result.ExpiresIn)
|
||||
|
||||
c.mu.Lock()
|
||||
c.accessToken = result.AccessToken
|
||||
c.expiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
c.mu.Unlock()
|
||||
return result.AccessToken, nil
|
||||
}
|
||||
|
||||
func parseExpiresIn(raw json.RawMessage) int {
|
||||
if len(raw) == 0 {
|
||||
return 7200
|
||||
}
|
||||
var number int
|
||||
if err := json.Unmarshal(raw, &number); err == nil && number > 0 {
|
||||
return number
|
||||
}
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err == nil {
|
||||
var parsed int
|
||||
if _, err := fmt.Sscanf(text, "%d", &parsed); err == nil && parsed > 0 {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return 7200
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package qqbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
)
|
||||
|
||||
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 qqbot credentials: %w", err)
|
||||
}
|
||||
|
||||
mode := im.ResolveMode(channel, "websocket")
|
||||
if mode != "websocket" {
|
||||
return nil, nil, fmt.Errorf("unsupported qqbot mode: %s (only websocket is supported)", mode)
|
||||
}
|
||||
|
||||
client, err := NewClient(
|
||||
im.GetString(creds, "app_id"),
|
||||
im.GetString(creds, "client_secret"),
|
||||
im.GetString(creds, "api_base_url"),
|
||||
im.GetString(creds, "gateway_url"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
longConn := NewLongConnClient(client, msgHandler)
|
||||
wsCtx, wsCancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
if err := longConn.Start(wsCtx); err != nil && wsCtx.Err() == nil {
|
||||
logger.Errorf(context.Background(), "[IM] QQBot long connection stopped for channel %s: %v", channel.ID, err)
|
||||
}
|
||||
}()
|
||||
|
||||
adapter := NewAdapter(client)
|
||||
return adapter, func() {
|
||||
wsCancel()
|
||||
longConn.Stop()
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package qqbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
ws "github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type MessageHandler func(ctx context.Context, msg *im.IncomingMessage) error
|
||||
|
||||
type LongConnClient struct {
|
||||
client *Client
|
||||
handler MessageHandler
|
||||
|
||||
mu sync.Mutex
|
||||
conn *ws.Conn
|
||||
seq *int64
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewLongConnClient(client *Client, handler MessageHandler) *LongConnClient {
|
||||
return &LongConnClient{client: client, handler: handler}
|
||||
}
|
||||
|
||||
func (c *LongConnClient) Start(ctx context.Context) error {
|
||||
logger.Infof(ctx, "[IM] QQBot WebSocket connecting...")
|
||||
attempt := 0
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if err := c.connectAndRun(ctx); err != nil {
|
||||
if ctx.Err() != nil || c.isClosed() {
|
||||
return ctx.Err()
|
||||
}
|
||||
attempt++
|
||||
delay := reconnectDelay(attempt)
|
||||
logger.Warnf(ctx, "[QQBot] connection lost: %v, reconnecting in %v", err, delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
continue
|
||||
}
|
||||
attempt = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LongConnClient) Stop() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.closed = true
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LongConnClient) connectAndRun(ctx context.Context) error {
|
||||
gatewayURL, err := c.client.GatewayURL(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dialer := *ws.DefaultDialer
|
||||
dialer.NetDialContext = secutils.SSRFSafeDialContext
|
||||
conn, _, err := dialer.DialContext(ctx, gatewayURL, nil)
|
||||
if err != nil {
|
||||
return 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()
|
||||
}()
|
||||
|
||||
for {
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var payload gatewayPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
logger.Warnf(ctx, "[QQBot] invalid payload: %v", err)
|
||||
continue
|
||||
}
|
||||
if payload.S != nil {
|
||||
c.seq = payload.S
|
||||
}
|
||||
switch payload.Op {
|
||||
case opHello:
|
||||
if err := c.handleHello(ctx, conn, payload.D); err != nil {
|
||||
return err
|
||||
}
|
||||
case opDispatch:
|
||||
msg, err := parseGatewayPayload(&payload)
|
||||
if err != nil {
|
||||
logger.Warnf(ctx, "[QQBot] parse event failed: %v", err)
|
||||
continue
|
||||
}
|
||||
if msg != nil {
|
||||
if err := c.handler(ctx, msg); err != nil {
|
||||
logger.Errorf(ctx, "[QQBot] handle message failed: %v", err)
|
||||
}
|
||||
}
|
||||
case opReconnect, opInvalidSession:
|
||||
return fmt.Errorf("gateway requested reconnect op=%d", payload.Op)
|
||||
case opHeartbeatACK:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LongConnClient) handleHello(ctx context.Context, conn *ws.Conn, raw json.RawMessage) error {
|
||||
var hello helloData
|
||||
if err := json.Unmarshal(raw, &hello); err != nil {
|
||||
return err
|
||||
}
|
||||
if hello.HeartbeatInterval <= 0 {
|
||||
hello.HeartbeatInterval = 45000
|
||||
}
|
||||
token, err := c.client.AccessToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
identify := identifyData{
|
||||
Token: "QQBot " + token,
|
||||
Intents: intentGroupAndC2C,
|
||||
Shard: []int{0, 1},
|
||||
}
|
||||
payloadBytes, err := json.Marshal(identify)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := conn.WriteJSON(gatewayPayload{Op: opIdentify, D: payloadBytes}); err != nil {
|
||||
return err
|
||||
}
|
||||
go c.heartbeatLoop(ctx, conn, time.Duration(hello.HeartbeatInterval)*time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *LongConnClient) heartbeatLoop(ctx context.Context, conn *ws.Conn, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
heartbeat, err := c.heartbeatPayload()
|
||||
if err == nil {
|
||||
err = conn.WriteJSON(heartbeat)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LongConnClient) heartbeatPayload() (gatewayPayload, error) {
|
||||
c.mu.Lock()
|
||||
seq := c.seq
|
||||
c.mu.Unlock()
|
||||
data, err := json.Marshal(seq)
|
||||
if err != nil {
|
||||
return gatewayPayload{}, err
|
||||
}
|
||||
return gatewayPayload{Op: opHeartbeat, D: data}, nil
|
||||
}
|
||||
|
||||
func (c *LongConnClient) isClosed() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.closed
|
||||
}
|
||||
|
||||
func reconnectDelay(attempt int) time.Duration {
|
||||
if attempt <= 0 {
|
||||
return time.Second
|
||||
}
|
||||
delay := time.Duration(attempt) * time.Second
|
||||
if delay > 30*time.Second {
|
||||
return 30 * time.Second
|
||||
}
|
||||
return delay
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package qqbot
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
defaultAPIBaseURL = "https://api.sgroup.qq.com"
|
||||
appTokenURL = "https://bots.qq.com/app/getAppAccessToken"
|
||||
defaultGatewayURL = "https://api.sgroup.qq.com/gateway"
|
||||
|
||||
opDispatch = 0
|
||||
opHeartbeat = 1
|
||||
opIdentify = 2
|
||||
opReconnect = 7
|
||||
opInvalidSession = 9
|
||||
opHello = 10
|
||||
opHeartbeatACK = 11
|
||||
intentGroupAndC2C = 1 << 25
|
||||
|
||||
eventC2CMessageCreate = "C2C_MESSAGE_CREATE"
|
||||
eventGroupAtMessageCreate = "GROUP_AT_MESSAGE_CREATE"
|
||||
|
||||
extraKeyMessageID = "message_id"
|
||||
extraKeyChatKind = "chat_kind"
|
||||
)
|
||||
|
||||
type gatewayPayload struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Op int `json:"op"`
|
||||
D json.RawMessage `json:"d,omitempty"`
|
||||
S *int64 `json:"s,omitempty"`
|
||||
T string `json:"t,omitempty"`
|
||||
}
|
||||
|
||||
type helloData struct {
|
||||
HeartbeatInterval int `json:"heartbeat_interval"`
|
||||
}
|
||||
|
||||
type identifyData struct {
|
||||
Token string `json:"token"`
|
||||
Intents int `json:"intents"`
|
||||
Shard []int `json:"shard,omitempty"`
|
||||
}
|
||||
|
||||
type tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn json.RawMessage `json:"expires_in"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type gatewayResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type messageEvent struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
GroupOpenID string `json:"group_openid"`
|
||||
Author qqbotAuthor `json:"author"`
|
||||
Attachments []qqAttachment `json:"attachments"`
|
||||
}
|
||||
|
||||
type qqbotAuthor struct {
|
||||
UserOpenID string `json:"user_openid"`
|
||||
MemberOpenID string `json:"member_openid"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Bot bool `json:"bot"`
|
||||
}
|
||||
|
||||
type qqAttachment struct {
|
||||
ContentType string `json:"content_type"`
|
||||
Filename string `json:"filename"`
|
||||
Size int64 `json:"size"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type sendMessageRequest struct {
|
||||
Content string `json:"content,omitempty"`
|
||||
MsgType int `json:"msg_type"`
|
||||
Markdown *markdownMessage `json:"markdown,omitempty"`
|
||||
MsgID string `json:"msg_id,omitempty"`
|
||||
MsgSeq int `json:"msg_seq,omitempty"`
|
||||
}
|
||||
|
||||
type markdownMessage struct {
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user