feat(qq): add QQ platform support via OneBot v11/NapCat

generated by llmgit

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
chenhg5andClaude committed 2026-03-02 11:50:53 +08:00
1 parent 7cb23971bf
commit c88d46a973
8 files changed
+642 -4

No files matched your search

+25 -1
View File
@@ -4,7 +4,7 @@
## What is cc-connect?
cc-connect bridges local AI coding assistants to messaging platforms (Feishu, DingTalk, Telegram, Slack, Discord, LINE, WeChat Work), enabling bidirectional chat with your local AI agent from anywhere.
cc-connect bridges local AI coding assistants to messaging platforms (Feishu, DingTalk, Telegram, Slack, Discord, LINE, WeChat Work, QQ), enabling bidirectional chat with your local AI agent from anywhere.
GitHub: https://github.com/chenhg5/cc-connect
@@ -319,6 +319,30 @@ enable_markdown = false # true = Markdown messages (WeChat Work app only; perso
**Detailed guide:** [docs/wecom.md](docs/wecom.md)
### QQ (via NapCat / OneBot v11) — No public IP needed `Beta`
QQ integration requires a third-party OneBot v11 implementation (e.g., NapCat) as a bridge.
1. Deploy NapCat (recommended via Docker):
```bash
docker run -d --name napcat -e ACCOUNT=<QQ号> -p 3001:3001 -p 6099:6099 --restart unless-stopped mlikiowa/napcat-docker:latest
```
2. First launch: check `docker logs -f napcat` for a QR code, scan with QQ mobile app to log in
3. Open NapCat WebUI at `http://localhost:6099`, enable **Forward WebSocket** on port 3001
4. Add to `config.toml`:
```toml
[[projects.platforms]]
type = "qq"
[projects.platforms.options]
ws_url = "ws://127.0.0.1:3001" # NapCat Forward WebSocket URL
token = "" # optional: access_token (must match NapCat config)
allow_from = "*" # allowed QQ user IDs: "12345,67890" or "*" for all
```
**Detailed guide:** [docs/qq.md](docs/qq.md)
---
## Step 5: Run cc-connect
+11 -1
View File
@@ -47,6 +47,7 @@ All components are decoupled via Go interfaces — fully pluggable and extensibl
| Platform | Discord | ✅ Gateway — no public IP needed |
| Platform | LINE | ✅ Webhook — public URL required |
| Platform | WeChat Work (企业微信) | ✅ Webhook — public URL required |
| Platform | QQ (via NapCat/OneBot) | ✅ Beta — WebSocket, no public IP needed |
| Platform | WhatsApp | 🔜 Planned (Business Cloud API) |
| Platform | Microsoft Teams | 🔜 Planned (Bot Framework) |
| Platform | Google Chat | 🔜 Planned (Chat API) |
@@ -152,6 +153,7 @@ Each platform requires creating a bot/app on the platform's developer console. W
| Discord | [docs/discord.md](docs/discord.md) | Gateway | No |
| LINE | [INSTALL.md](./INSTALL.md#line--requires-public-url) | Webhook | Yes |
| WeChat Work | [docs/wecom.md](docs/wecom.md) | Webhook | Yes |
| QQ (NapCat) | [docs/qq.md](docs/qq.md) | WebSocket (OneBot v11) | No |
Quick config examples for each platform:
@@ -208,6 +210,13 @@ callback_token = "xxx"
callback_aes_key = "xxx"
port = "8081"
enable_markdown = false # true only if all users use WeChat Work app (not personal WeChat)
# QQ (via NapCat/OneBot v11, no public IP needed)
[[projects.platforms]]
type = "qq"
[projects.platforms.options]
ws_url = "ws://127.0.0.1:3001"
allow_from = "*" # QQ user IDs, e.g. "12345,67890" or "*" for all
```
## Permission Modes
@@ -490,7 +499,8 @@ cc-connect/
│ ├── slack/ # Slack (Socket Mode)
│ ├── discord/ # Discord (Gateway WebSocket)
│ ├── line/ # LINE (HTTP Webhook)
│ └── wecom/ # WeChat Work (HTTP Webhook)
│ ├── wecom/ # WeChat Work (HTTP Webhook)
│ └── qq/ # QQ (NapCat / OneBot v11 WebSocket)
├── agent/ # Agent adapters
│ ├── claudecode/ # Claude Code CLI (interactive sessions)
│ ├── codex/ # OpenAI Codex CLI (exec --json)
+11 -1
View File
@@ -52,6 +52,7 @@
| Platform | Discord | ✅ Gateway — 无需公网 IP |
| Platform | LINE | ✅ Webhook — 需要公网 URL |
| Platform | 企业微信 (WeChat Work) | ✅ Webhook — 需要公网 URL |
| Platform | QQ (通过 NapCat/OneBot) | ✅ Beta — WebSocket,无需公网 IP |
| Platform | WhatsApp | 🔜 计划中 (Business Cloud API) |
| Platform | Microsoft Teams | 🔜 计划中 (Bot Framework) |
| Platform | Google Chat | 🔜 计划中 (Chat API) |
@@ -155,6 +156,7 @@ cc-connect update --pre # 内测版(含 pre-release)
| Discord | [docs/discord.md](docs/discord.md) | Gateway | 不需要 |
| LINE | [INSTALL.md](./INSTALL.md#line--requires-public-url) | Webhook | 需要 |
| 企业微信 | [docs/wecom.md](docs/wecom.md) | Webhook | 需要 |
| QQ (NapCat) | [docs/qq.md](docs/qq.md) | WebSocket (OneBot v11) | 不需要 |
各平台快速配置示例:
@@ -211,6 +213,13 @@ callback_token = "xxx"
callback_aes_key = "xxx"
port = "8081"
enable_markdown = false # 设为 true 则发送 Markdown 消息(仅企业微信应用内可渲染,个人微信显示"暂不支持")
# QQ(通过 NapCat/OneBot v11,无需公网 IP)
[[projects.platforms]]
type = "qq"
[projects.platforms.options]
ws_url = "ws://127.0.0.1:3001"
allow_from = "*" # 允许的 QQ 号,如 "12345,67890","*" 表示所有
```
## 权限模式
@@ -493,7 +502,8 @@ cc-connect/
│ ├── slack/ # Slack(Socket Mode)
│ ├── discord/ # Discord(Gateway WebSocket)
│ ├── line/ # LINE(HTTP Webhook)
│ └── wecom/ # 企业微信(HTTP Webhook)
│ ├── wecom/ # 企业微信(HTTP Webhook)
│ └── qq/ # QQ(NapCat / OneBot v11 WebSocket)
├── agent/ # AI 助手适配器
│ ├── claudecode/ # Claude Code CLI(交互式会话)
│ ├── codex/ # OpenAI Codex CLI(exec --json)
+1
View File
@@ -26,6 +26,7 @@ import (
_ "github.com/chenhg5/cc-connect/platform/line"
_ "github.com/chenhg5/cc-connect/platform/slack"
_ "github.com/chenhg5/cc-connect/platform/telegram"
_ "github.com/chenhg5/cc-connect/platform/qq"
_ "github.com/chenhg5/cc-connect/platform/wecom"
)
+23
View File
@@ -367,6 +367,29 @@ app_secret = "your-feishu-app-secret"
# [projects.platforms.options]
# token = "your-telegram-bot-token"
# QQ (via NapCat / OneBot v11) (uncomment to enable / 取消注释以启用)
# Requires a OneBot v11 implementation running alongside your QQ client:
# 需要一个 OneBot v11 实现配合 QQ 客户端运行:
# - NapCat (recommended / 推荐): https://github.com/NapNeko/NapCatQQ
# - Docker: docker run -d --name napcat -e ACCOUNT=<QQ号> -p 3001:3001 -p 6099:6099 mlikiowa/napcat-docker:latest
# - Enable Forward WebSocket in NapCat WebUI (http://localhost:6099), default port 3001
# 在 NapCat WebUI 中启用正向 WebSocket,默认端口 3001
# - LLOneBot: https://github.com/LLOneBot/LLOneBot (NTQQ plugin / NTQQ 插件)
#
# allow_from: comma-separated QQ user IDs that are allowed to interact, or "*" for all.
# 允许交互的 QQ 号列表(逗号分隔),设为 "*" 允许所有人。
# token: optional access_token for WebSocket authentication (must match NapCat config).
# 可选的 WebSocket 鉴权 token(需与 NapCat 配置一致)。
# [[projects.platforms]]
# type = "qq"
#
# [projects.platforms.options]
# ws_url = "ws://127.0.0.1:3001" # NapCat Forward WebSocket URL
# token = "" # optional: access_token / 可选鉴权 token
# allow_from = "*" # allowed QQ user IDs, e.g. "12345,67890" or "*" for all
# # 允许的 QQ 号,如 "12345,67890","*" 表示所有
# =============================================================================
# Project 5: Another example / 项目 5:更多示例 (uncomment to enable / 取消注释以启用)
# =============================================================================
+129
View File
@@ -0,0 +1,129 @@
# QQ 平台接入指南 / QQ Platform Setup Guide
cc-connect 通过 [OneBot v11](https://github.com/botuniverse/onebot-11) 协议连接 QQ,需要搭配一个 OneBot 实现(如 NapCat)使用。
cc-connect connects to QQ via the [OneBot v11](https://github.com/botuniverse/onebot-11) protocol. You need a OneBot implementation (e.g., NapCat) running alongside.
## 架构 / Architecture
```
QQ Client ←→ NapCat (OneBot v11) ←WebSocket→ cc-connect ←→ Agent (Claude Code / etc.)
```
## 前置条件 / Prerequisites
- 一个 QQ 账号用作机器人 / A QQ account to act as the bot
- [NapCat](https://github.com/NapNeko/NapCatQQ) 或其他 OneBot v11 实现 / NapCat or another OneBot v11 implementation
## 步骤 / Steps
### 1. 部署 NapCat / Deploy NapCat
推荐使用 Docker(最简单)/ Docker is recommended (easiest):
```bash
docker run -d \
--name napcat \
-e ACCOUNT=<你的QQ号> \
-p 3001:3001 \
-p 6099:6099 \
mlikiowa/napcat-docker:latest
```
首次启动需要扫码登录 / First launch requires QR code login:
```bash
docker logs -f napcat
```
在日志中找到二维码,用手机 QQ 扫码登录。
Find the QR code in the logs and scan it with your QQ mobile app.
### 2. 配置 NapCat 正向 WebSocket / Configure Forward WebSocket
打开 NapCat WebUI / Open the NapCat WebUI:
```
http://localhost:6099
```
在网络配置中:/ In network settings:
- 启用 **正向 WebSocket** (Forward WebSocket) / Enable **Forward WebSocket**
- 端口设为 `3001`(默认)/ Port: `3001` (default)
- 如果需要鉴权,设置 Access Token / Set Access Token if needed
### 3. 配置 cc-connect / Configure cc-connect
在 `config.toml` 中添加 QQ 平台 / Add QQ platform to `config.toml`:
```toml
[[projects.platforms]]
type = "qq"
[projects.platforms.options]
ws_url = "ws://127.0.0.1:3001" # NapCat 正向 WebSocket 地址
token = "" # 可选:Access Token(需与 NapCat 一致)
allow_from = "*" # 允许交互的 QQ 号,"*" 表示所有人
```
**`allow_from` 配置说明 / `allow_from` options:**
- `"*"` — 允许所有人 / Allow everyone
- `"12345"` — 仅允许 QQ 号 12345 / Only allow QQ user 12345
- `"12345,67890"` — 允许多个 QQ 号 / Allow multiple QQ users
### 4. 启动 / Start
```bash
cc-connect
```
看到如下日志表示连接成功 / You should see:
```
qq: connected to OneBot url=ws://127.0.0.1:3001
qq: logged in qq=123456789 nickname=MyBot
```
现在可以在 QQ 上私聊或群聊机器人了!
Now you can chat with the bot via QQ private or group messages!
## 群聊使用 / Group Chat
支持群聊消息。在群中发送消息时,机器人会以独立的会话(按用户区分)处理每个人的请求。
Group chat is supported. Each user gets their own independent session, even in group chats.
## 支持的消息类型 / Supported Message Types
| 类型 / Type | 接收 / Receive | 发送 / Send |
|------------|----------------|-------------|
| 文字 / Text | ✅ | ✅ |
| 图片 / Image | ✅ | ❌ (文本描述) |
| 语音 / Voice | ✅ (需配置 STT) | ❌ |
| @提及 / @mention | ✅ (忽略) | — |
## 常见问题 / FAQ
**Q: 连接失败?/ Connection failed?**
- 确认 NapCat 正在运行且端口正确 / Check that NapCat is running and port is correct
- 确认 NapCat 已启用正向 WebSocket / Verify Forward WebSocket is enabled in NapCat
- 如果设置了 Token,确保两边一致 / If using Token, ensure it matches on both sides
**Q: 收不到消息?/ Not receiving messages?**
- 检查 `allow_from` 配置,确认你的 QQ 号在允许列表中 / Check `allow_from` includes your QQ ID
- 查看 NapCat 日志确认消息是否正确转发 / Check NapCat logs for message forwarding
**Q: NapCat 掉线?/ NapCat disconnected?**
- NapCat 使用 NTQQ 协议,长时间挂机可能需要重新登录 / NapCat may need re-login after long periods
- 建议使用 Docker restart policy: `--restart unless-stopped`
## 其他 OneBot 实现 / Other OneBot Implementations
除了 NapCat,以下 OneBot v11 实现也应该兼容 / Besides NapCat, these should also work:
- [LLOneBot](https://github.com/LLOneBot/LLOneBot) — NTQQ 插件 / NTQQ plugin
- [Lagrange.Core](https://github.com/LagrangeDev/Lagrange.Core) — 跨平台 / Cross-platform
- [OpenShamrock](https://github.com/whitechi73/OpenShamrock) — Xposed 模块 / Xposed module (Android)
只要支持正向 WebSocket 的 OneBot v11 实现都可以使用。
Any OneBot v11 implementation with Forward WebSocket support should work.
+1 -1
View File
@@ -6,6 +6,7 @@ require (
github.com/BurntSushi/toml v1.6.0
github.com/bwmarrin/discordgo v0.29.0
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/gorilla/websocket v1.5.0
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/line/line-bot-sdk-go/v8 v8.19.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
@@ -15,7 +16,6 @@ require (
require (
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect
)
+441
View File
@@ -0,0 +1,441 @@
package qq
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/chenhg5/cc-connect/core"
"github.com/gorilla/websocket"
)
func init() {
core.RegisterPlatform("qq", New)
}
// Platform connects to a OneBot v11 implementation (NapCat, LLOneBot, etc.)
// via forward WebSocket. It receives message events and sends messages back
// through the same WS connection.
type Platform struct {
wsURL string // e.g. "ws://127.0.0.1:3001"
token string // optional access_token
allowFrom string // comma-separated user IDs or "*"
handler core.MessageHandler
conn *websocket.Conn
mu sync.Mutex
echoSeq atomic.Int64
echoCh sync.Map // echo -> chan json.RawMessage
cancel context.CancelFunc
selfID int64
}
func New(opts map[string]any) (core.Platform, error) {
wsURL, _ := opts["ws_url"].(string)
if wsURL == "" {
wsURL = "ws://127.0.0.1:3001"
}
token, _ := opts["token"].(string)
allowFrom, _ := opts["allow_from"].(string)
return &Platform{
wsURL: wsURL,
token: token,
allowFrom: allowFrom,
}, nil
}
func (p *Platform) Name() string { return "qq" }
func (p *Platform) Start(handler core.MessageHandler) error {
p.handler = handler
header := http.Header{}
if p.token != "" {
header.Set("Authorization", "Bearer "+p.token)
}
conn, _, err := websocket.DefaultDialer.Dial(p.wsURL, header)
if err != nil {
return fmt.Errorf("qq: ws connect failed (%s): %w", p.wsURL, err)
}
p.conn = conn
slog.Info("qq: connected to OneBot", "url", p.wsURL)
// Get bot self info
if info, err := p.callAPI("get_login_info", nil); err == nil {
if uid, ok := info["user_id"].(float64); ok {
p.selfID = int64(uid)
}
nick, _ := info["nickname"].(string)
slog.Info("qq: logged in", "qq", p.selfID, "nickname", nick)
}
ctx, cancel := context.WithCancel(context.Background())
p.cancel = cancel
go p.readLoop(ctx)
return nil
}
func (p *Platform) readLoop(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
}
_, raw, err := p.conn.ReadMessage()
if err != nil {
if ctx.Err() != nil {
return
}
slog.Error("qq: ws read error, reconnecting...", "error", err)
p.reconnect()
continue
}
var payload map[string]any
if json.Unmarshal(raw, &payload) != nil {
continue
}
// If this is an API response (has "echo" field), route to caller
if echo, ok := payload["echo"].(string); ok {
if ch, loaded := p.echoCh.LoadAndDelete(echo); loaded {
if dataCh, ok := ch.(chan json.RawMessage); ok {
dataCh <- raw
}
}
continue
}
// Otherwise it's an event
postType, _ := payload["post_type"].(string)
if postType == "message" {
p.handleMessage(payload)
}
}
}
func (p *Platform) reconnect() {
for i := 1; i <= 30; i++ {
time.Sleep(time.Duration(i) * 2 * time.Second)
header := http.Header{}
if p.token != "" {
header.Set("Authorization", "Bearer "+p.token)
}
conn, _, err := websocket.DefaultDialer.Dial(p.wsURL, header)
if err != nil {
slog.Warn("qq: reconnect attempt failed", "attempt", i, "error", err)
continue
}
p.mu.Lock()
p.conn = conn
p.mu.Unlock()
slog.Info("qq: reconnected")
return
}
slog.Error("qq: failed to reconnect after 30 attempts")
}
func (p *Platform) handleMessage(payload map[string]any) {
msgType, _ := payload["message_type"].(string)
userID := jsonInt64(payload, "user_id")
groupID := jsonInt64(payload, "group_id")
messageID := jsonInt64(payload, "message_id")
if userID == p.selfID {
return
}
if !p.isAllowed(userID) {
return
}
// Extract sender info
var userName string
if sender, ok := payload["sender"].(map[string]any); ok {
card, _ := sender["card"].(string)
nick, _ := sender["nickname"].(string)
if card != "" {
userName = card
} else {
userName = nick
}
}
// Parse message content from CQ message array or raw_message
text, images, audio := p.parseMessage(payload)
if text == "" && len(images) == 0 && audio == nil {
return
}
var sessionKey string
if msgType == "group" {
sessionKey = fmt.Sprintf("qq:%d:%d", groupID, userID)
} else {
sessionKey = fmt.Sprintf("qq:%d", userID)
}
rctx := &replyContext{
messageType: msgType,
userID: userID,
groupID: groupID,
messageID: int32(messageID),
}
msg := &core.Message{
SessionKey: sessionKey,
Platform: "qq",
UserID: strconv.FormatInt(userID, 10),
UserName: userName,
Content: text,
Images: images,
Audio: audio,
ReplyCtx: rctx,
}
slog.Debug("qq: message received", "type", msgType, "user", userID, "text_len", len(text))
p.handler(p, msg)
}
func (p *Platform) parseMessage(payload map[string]any) (string, []core.ImageAttachment, *core.AudioAttachment) {
var textParts []string
var images []core.ImageAttachment
var audio *core.AudioAttachment
// OneBot message can be array of segments or a string
switch msg := payload["message"].(type) {
case []any:
for _, seg := range msg {
s, ok := seg.(map[string]any)
if !ok {
continue
}
segType, _ := s["type"].(string)
data, _ := s["data"].(map[string]any)
if data == nil {
continue
}
switch segType {
case "text":
if text, ok := data["text"].(string); ok {
textParts = append(textParts, text)
}
case "image":
if url, ok := data["url"].(string); ok && url != "" {
imgData, mime, err := downloadFile(url)
if err != nil {
slog.Warn("qq: download image failed", "error", err)
continue
}
images = append(images, core.ImageAttachment{
MimeType: mime,
Data: imgData,
})
}
case "record":
if url, ok := data["url"].(string); ok && url != "" {
audioData, _, err := downloadFile(url)
if err != nil {
slog.Warn("qq: download audio failed", "error", err)
continue
}
format := "silk"
if f, ok := data["file"].(string); ok {
if strings.HasSuffix(f, ".amr") {
format = "amr"
} else if strings.HasSuffix(f, ".mp3") {
format = "mp3"
}
}
audio = &core.AudioAttachment{
Data: audioData,
Format: format,
}
}
case "at":
// Ignore @mentions in parsed text
}
}
default:
// raw_message fallback (string with CQ codes)
if raw, ok := payload["raw_message"].(string); ok {
textParts = append(textParts, stripCQCodes(raw))
}
}
return strings.TrimSpace(strings.Join(textParts, "")), images, audio
}
// Reply sends a message as a reply to an incoming message.
func (p *Platform) Reply(ctx context.Context, replyCtx any, content string) error {
return p.Send(ctx, replyCtx, content)
}
// Send sends a message to the conversation identified by replyCtx.
func (p *Platform) Send(ctx context.Context, replyCtx any, content string) error {
rctx, ok := replyCtx.(*replyContext)
if !ok {
return fmt.Errorf("qq: invalid reply context")
}
params := map[string]any{
"message": content,
}
if rctx.messageType == "group" {
params["group_id"] = rctx.groupID
_, err := p.callAPI("send_group_msg", params)
return err
}
params["user_id"] = rctx.userID
_, err := p.callAPI("send_private_msg", params)
return err
}
func (p *Platform) Stop() error {
if p.cancel != nil {
p.cancel()
}
if p.conn != nil {
return p.conn.Close()
}
return nil
}
// ── OneBot API call via WebSocket ───────────────────────────────
func (p *Platform) callAPI(action string, params map[string]any) (map[string]any, error) {
seq := p.echoSeq.Add(1)
echo := strconv.FormatInt(seq, 10)
req := map[string]any{
"action": action,
"echo": echo,
}
if params != nil {
req["params"] = params
}
ch := make(chan json.RawMessage, 1)
p.echoCh.Store(echo, ch)
defer p.echoCh.Delete(echo)
data, err := json.Marshal(req)
if err != nil {
return nil, err
}
p.mu.Lock()
err = p.conn.WriteMessage(websocket.TextMessage, data)
p.mu.Unlock()
if err != nil {
return nil, fmt.Errorf("qq: ws write: %w", err)
}
select {
case raw := <-ch:
var resp struct {
Status string `json:"status"`
RetCode int `json:"retcode"`
Data json.RawMessage `json:"data"`
}
if json.Unmarshal(raw, &resp) != nil {
return nil, fmt.Errorf("qq: invalid API response")
}
if resp.RetCode != 0 {
return nil, fmt.Errorf("qq: API %s failed (retcode=%d)", action, resp.RetCode)
}
var result map[string]any
json.Unmarshal(resp.Data, &result)
return result, nil
case <-time.After(15 * time.Second):
return nil, fmt.Errorf("qq: API %s timeout", action)
}
}
// ── Helpers ─────────────────────────────────────────────────────
type replyContext struct {
messageType string // "private" or "group"
userID int64
groupID int64
messageID int32
}
func (p *Platform) isAllowed(userID int64) bool {
if p.allowFrom == "" || p.allowFrom == "*" {
return true
}
uid := strconv.FormatInt(userID, 10)
for _, allowed := range strings.Split(p.allowFrom, ",") {
if strings.TrimSpace(allowed) == uid {
return true
}
}
return false
}
func jsonInt64(m map[string]any, key string) int64 {
switch v := m[key].(type) {
case float64:
return int64(v)
case json.Number:
n, _ := v.Int64()
return n
}
return 0
}
func stripCQCodes(s string) string {
var result strings.Builder
for len(s) > 0 {
idx := strings.Index(s, "[CQ:")
if idx < 0 {
result.WriteString(s)
break
}
result.WriteString(s[:idx])
end := strings.Index(s[idx:], "]")
if end < 0 {
break
}
s = s[idx+end+1:]
}
return result.String()
}
func downloadFile(url string) ([]byte, string, error) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", err
}
mime := resp.Header.Get("Content-Type")
if mime == "" {
mime = http.DetectContentType(data)
}
return data, mime, nil
}