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

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+127
View File
@@ -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 ""
}
+137
View File
@@ -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"])
}
}
+222
View File
@@ -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
}
+47
View File
@@ -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
}
}
+199
View File
@@ -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
}
+88
View File
@@ -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"`
}