Skip to content

Commit 8e2bf3c

Browse files
committed
feat: add multi-account weixin channel
1 parent 88fccb2 commit 8e2bf3c

10 files changed

Lines changed: 1548 additions & 1 deletion

File tree

cmd/cmd_gateway.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,12 @@ func gatewayCmd() {
188188
if whatsAppBridge != nil {
189189
registryServer.SetWhatsAppBridge(whatsAppBridge, embeddedWhatsAppBridgeBasePath)
190190
}
191+
if rawWeixin, ok := channelManager.GetChannel("weixin"); ok {
192+
if weixinChannel, ok := rawWeixin.(*channels.WeixinChannel); ok {
193+
weixinChannel.SetConfigPath(getConfigPath())
194+
registryServer.SetWeixinChannel(weixinChannel)
195+
}
196+
}
191197
registryServer.SetCronHandler(func(action string, args map[string]interface{}) (interface{}, error) {
192198
getStr := func(k string) string {
193199
v, _ := args[k].(string)
@@ -424,6 +430,14 @@ func gatewayCmd() {
424430
registryServer.SetWorkspacePath(cfg.WorkspacePath())
425431
registryServer.SetLogFilePath(cfg.LogFilePath())
426432
registryServer.SetWhatsAppBridge(whatsAppBridge, embeddedWhatsAppBridgeBasePath)
433+
if rawWeixin, ok := channelManager.GetChannel("weixin"); ok {
434+
if weixinChannel, ok := rawWeixin.(*channels.WeixinChannel); ok {
435+
weixinChannel.SetConfigPath(getConfigPath())
436+
registryServer.SetWeixinChannel(weixinChannel)
437+
}
438+
} else {
439+
registryServer.SetWeixinChannel(nil)
440+
}
427441
sentinelService.Stop()
428442
sentinelService = sentinel.NewService(
429443
getConfigPath(),

config.example.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,13 @@
145145
"inbound_message_id_dedupe_ttl_seconds": 600,
146146
"inbound_content_dedupe_window_seconds": 12,
147147
"outbound_dedupe_window_seconds": 12,
148+
"weixin": {
149+
"enabled": false,
150+
"base_url": "https://ilinkai.weixin.qq.com",
151+
"default_bot_id": "",
152+
"accounts": [],
153+
"allow_from": []
154+
},
148155
"telegram": {
149156
"enabled": false,
150157
"token": "YOUR_TELEGRAM_BOT_TOKEN",

pkg/api/server.go

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ type Server struct {
5353
onToolsCatalog func() interface{}
5454
whatsAppBridge *channels.WhatsAppBridgeService
5555
whatsAppBase string
56+
weixinChannel *channels.WeixinChannel
5657
oauthFlowMu sync.Mutex
5758
oauthFlows map[string]*providers.OAuthPendingFlow
5859
extraRoutesMu sync.RWMutex
@@ -109,6 +110,10 @@ func (s *Server) SetWhatsAppBridge(service *channels.WhatsAppBridgeService, base
109110
s.whatsAppBase = strings.TrimSpace(basePath)
110111
}
111112

113+
func (s *Server) SetWeixinChannel(ch *channels.WeixinChannel) {
114+
s.weixinChannel = ch
115+
}
116+
112117
func (s *Server) handleWhatsAppBridgeWS(w http.ResponseWriter, r *http.Request) {
113118
if s.whatsAppBridge == nil {
114119
http.Error(w, "whatsapp bridge unavailable", http.StatusServiceUnavailable)
@@ -190,6 +195,12 @@ func (s *Server) Start(ctx context.Context) error {
190195
mux.HandleFunc("/api/whatsapp/status", s.handleWebUIWhatsAppStatus)
191196
mux.HandleFunc("/api/whatsapp/logout", s.handleWebUIWhatsAppLogout)
192197
mux.HandleFunc("/api/whatsapp/qr.svg", s.handleWebUIWhatsAppQR)
198+
mux.HandleFunc("/api/weixin/status", s.handleWebUIWeixinStatus)
199+
mux.HandleFunc("/api/weixin/login/start", s.handleWebUIWeixinLoginStart)
200+
mux.HandleFunc("/api/weixin/login/cancel", s.handleWebUIWeixinLoginCancel)
201+
mux.HandleFunc("/api/weixin/qr.svg", s.handleWebUIWeixinQR)
202+
mux.HandleFunc("/api/weixin/accounts/remove", s.handleWebUIWeixinAccountRemove)
203+
mux.HandleFunc("/api/weixin/accounts/default", s.handleWebUIWeixinAccountDefault)
193204
mux.HandleFunc("/api/upload", s.handleWebUIUpload)
194205
mux.HandleFunc("/api/cron", s.handleWebUICron)
195206
mux.HandleFunc("/api/skills", s.handleWebUISkills)
@@ -1318,6 +1329,227 @@ func (s *Server) webUIWhatsAppStatusPayload(ctx context.Context) (map[string]int
13181329
}, http.StatusOK
13191330
}
13201331

1332+
func (s *Server) handleWebUIWeixinStatus(w http.ResponseWriter, r *http.Request) {
1333+
if !s.checkAuth(r) {
1334+
http.Error(w, "unauthorized", http.StatusUnauthorized)
1335+
return
1336+
}
1337+
if r.Method != http.MethodGet {
1338+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
1339+
return
1340+
}
1341+
payload, code := s.webUIWeixinStatusPayload(r.Context())
1342+
writeJSONStatus(w, code, payload)
1343+
}
1344+
1345+
func (s *Server) handleWebUIWeixinLoginStart(w http.ResponseWriter, r *http.Request) {
1346+
if !s.checkAuth(r) {
1347+
http.Error(w, "unauthorized", http.StatusUnauthorized)
1348+
return
1349+
}
1350+
if r.Method != http.MethodPost {
1351+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
1352+
return
1353+
}
1354+
if s.weixinChannel == nil {
1355+
http.Error(w, "weixin channel unavailable", http.StatusServiceUnavailable)
1356+
return
1357+
}
1358+
if _, err := s.weixinChannel.StartLogin(r.Context()); err != nil {
1359+
http.Error(w, err.Error(), http.StatusBadGateway)
1360+
return
1361+
}
1362+
payload, code := s.webUIWeixinStatusPayload(r.Context())
1363+
writeJSONStatus(w, code, payload)
1364+
}
1365+
1366+
func (s *Server) handleWebUIWeixinLoginCancel(w http.ResponseWriter, r *http.Request) {
1367+
if !s.checkAuth(r) {
1368+
http.Error(w, "unauthorized", http.StatusUnauthorized)
1369+
return
1370+
}
1371+
if r.Method != http.MethodPost {
1372+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
1373+
return
1374+
}
1375+
if s.weixinChannel == nil {
1376+
http.Error(w, "weixin channel unavailable", http.StatusServiceUnavailable)
1377+
return
1378+
}
1379+
var body struct {
1380+
LoginID string `json:"login_id"`
1381+
}
1382+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
1383+
http.Error(w, "invalid json body", http.StatusBadRequest)
1384+
return
1385+
}
1386+
if !s.weixinChannel.CancelPendingLogin(body.LoginID) {
1387+
http.Error(w, "login_id not found", http.StatusNotFound)
1388+
return
1389+
}
1390+
payload, code := s.webUIWeixinStatusPayload(r.Context())
1391+
writeJSONStatus(w, code, payload)
1392+
}
1393+
1394+
func (s *Server) handleWebUIWeixinQR(w http.ResponseWriter, r *http.Request) {
1395+
if !s.checkAuth(r) {
1396+
http.Error(w, "unauthorized", http.StatusUnauthorized)
1397+
return
1398+
}
1399+
if r.Method != http.MethodGet {
1400+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
1401+
return
1402+
}
1403+
payload, code := s.webUIWeixinStatusPayload(r.Context())
1404+
if code != http.StatusOK {
1405+
http.Error(w, "qr unavailable", http.StatusNotFound)
1406+
return
1407+
}
1408+
qrCode := ""
1409+
loginID := strings.TrimSpace(r.URL.Query().Get("login_id"))
1410+
if loginID != "" && s.weixinChannel != nil {
1411+
if pending := s.weixinChannel.PendingLoginByID(loginID); pending != nil {
1412+
qrCode = fallbackString(pending.QRCodeImgContent, pending.QRCode)
1413+
}
1414+
}
1415+
if qrCode == "" {
1416+
pendingItems, _ := payload["pending_logins"].([]interface{})
1417+
if len(pendingItems) > 0 {
1418+
if pending, ok := pendingItems[0].(map[string]interface{}); ok {
1419+
qrCode = fallbackString(stringFromMap(pending, "qr_code_img_content"), stringFromMap(pending, "qr_code"))
1420+
}
1421+
}
1422+
}
1423+
if strings.TrimSpace(qrCode) == "" {
1424+
http.Error(w, "qr unavailable", http.StatusNotFound)
1425+
return
1426+
}
1427+
qrImage, err := qr.Encode(strings.TrimSpace(qrCode), qr.M)
1428+
if err != nil {
1429+
http.Error(w, err.Error(), http.StatusBadGateway)
1430+
return
1431+
}
1432+
w.Header().Set("Content-Type", "image/svg+xml")
1433+
_, _ = io.WriteString(w, renderQRCodeSVG(qrImage, 8, 24))
1434+
}
1435+
1436+
func (s *Server) handleWebUIWeixinAccountRemove(w http.ResponseWriter, r *http.Request) {
1437+
if !s.checkAuth(r) {
1438+
http.Error(w, "unauthorized", http.StatusUnauthorized)
1439+
return
1440+
}
1441+
if r.Method != http.MethodPost {
1442+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
1443+
return
1444+
}
1445+
if s.weixinChannel == nil {
1446+
http.Error(w, "weixin channel unavailable", http.StatusServiceUnavailable)
1447+
return
1448+
}
1449+
var body struct {
1450+
BotID string `json:"bot_id"`
1451+
}
1452+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
1453+
http.Error(w, "invalid json body", http.StatusBadRequest)
1454+
return
1455+
}
1456+
if err := s.weixinChannel.RemoveAccount(body.BotID); err != nil {
1457+
http.Error(w, err.Error(), http.StatusBadRequest)
1458+
return
1459+
}
1460+
payload, code := s.webUIWeixinStatusPayload(r.Context())
1461+
writeJSONStatus(w, code, payload)
1462+
}
1463+
1464+
func (s *Server) handleWebUIWeixinAccountDefault(w http.ResponseWriter, r *http.Request) {
1465+
if !s.checkAuth(r) {
1466+
http.Error(w, "unauthorized", http.StatusUnauthorized)
1467+
return
1468+
}
1469+
if r.Method != http.MethodPost {
1470+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
1471+
return
1472+
}
1473+
if s.weixinChannel == nil {
1474+
http.Error(w, "weixin channel unavailable", http.StatusServiceUnavailable)
1475+
return
1476+
}
1477+
var body struct {
1478+
BotID string `json:"bot_id"`
1479+
}
1480+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
1481+
http.Error(w, "invalid json body", http.StatusBadRequest)
1482+
return
1483+
}
1484+
if err := s.weixinChannel.SetDefaultAccount(body.BotID); err != nil {
1485+
http.Error(w, err.Error(), http.StatusBadRequest)
1486+
return
1487+
}
1488+
payload, code := s.webUIWeixinStatusPayload(r.Context())
1489+
writeJSONStatus(w, code, payload)
1490+
}
1491+
1492+
func (s *Server) webUIWeixinStatusPayload(ctx context.Context) (map[string]interface{}, int) {
1493+
cfg, err := s.loadConfig()
1494+
if err != nil {
1495+
return map[string]interface{}{
1496+
"ok": false,
1497+
"error": err.Error(),
1498+
}, http.StatusInternalServerError
1499+
}
1500+
weixinCfg := cfg.Channels.Weixin
1501+
if s.weixinChannel == nil {
1502+
return map[string]interface{}{
1503+
"ok": false,
1504+
"enabled": weixinCfg.Enabled,
1505+
"base_url": weixinCfg.BaseURL,
1506+
"error": "weixin channel unavailable",
1507+
}, http.StatusOK
1508+
}
1509+
pendingLogins, err := s.weixinChannel.RefreshLoginStatuses(ctx)
1510+
if err != nil {
1511+
return map[string]interface{}{
1512+
"ok": false,
1513+
"enabled": weixinCfg.Enabled,
1514+
"base_url": weixinCfg.BaseURL,
1515+
"error": err.Error(),
1516+
}, http.StatusOK
1517+
}
1518+
accounts := s.weixinChannel.ListAccounts()
1519+
pendingPayload := make([]map[string]interface{}, 0, len(pendingLogins))
1520+
for _, pending := range pendingLogins {
1521+
pendingPayload = append(pendingPayload, map[string]interface{}{
1522+
"login_id": pendingString(pending, "login_id"),
1523+
"qr_code": pendingString(pending, "qr_code"),
1524+
"qr_code_img_content": pendingString(pending, "qr_code_img_content"),
1525+
"status": pendingString(pending, "status"),
1526+
"last_error": pendingString(pending, "last_error"),
1527+
"updated_at": pendingString(pending, "updated_at"),
1528+
"qr_available": pending != nil && strings.TrimSpace(fallbackString(pending.QRCodeImgContent, pending.QRCode)) != "",
1529+
})
1530+
}
1531+
var firstPending *channels.WeixinPendingLogin
1532+
if len(pendingLogins) > 0 {
1533+
firstPending = pendingLogins[0]
1534+
}
1535+
return map[string]interface{}{
1536+
"ok": true,
1537+
"enabled": weixinCfg.Enabled,
1538+
"base_url": fallbackString(weixinCfg.BaseURL, "https://ilinkai.weixin.qq.com"),
1539+
"pending_logins": pendingPayload,
1540+
"pending_login": map[string]interface{}{
1541+
"login_id": pendingString(firstPending, "login_id"),
1542+
"qr_code": pendingString(firstPending, "qr_code"),
1543+
"qr_code_img_content": pendingString(firstPending, "qr_code_img_content"),
1544+
"status": pendingString(firstPending, "status"),
1545+
"last_error": pendingString(firstPending, "last_error"),
1546+
"updated_at": pendingString(firstPending, "updated_at"),
1547+
"qr_available": firstPending != nil && strings.TrimSpace(fallbackString(firstPending.QRCodeImgContent, firstPending.QRCode)) != "",
1548+
},
1549+
"accounts": accounts,
1550+
}, http.StatusOK
1551+
}
1552+
13211553
func (s *Server) loadConfig() (*cfgpkg.Config, error) {
13221554
configPath := strings.TrimSpace(s.configPath)
13231555
if configPath == "" {
@@ -1768,6 +2000,28 @@ func fallbackString(value, fallback string) string {
17682000
return strings.TrimSpace(fallback)
17692001
}
17702002

2003+
func pendingString(item *channels.WeixinPendingLogin, key string) string {
2004+
if item == nil {
2005+
return ""
2006+
}
2007+
switch strings.TrimSpace(key) {
2008+
case "login_id":
2009+
return strings.TrimSpace(item.LoginID)
2010+
case "qr_code":
2011+
return strings.TrimSpace(item.QRCode)
2012+
case "qr_code_img_content":
2013+
return strings.TrimSpace(item.QRCodeImgContent)
2014+
case "status":
2015+
return strings.TrimSpace(item.Status)
2016+
case "last_error":
2017+
return strings.TrimSpace(item.LastError)
2018+
case "updated_at":
2019+
return strings.TrimSpace(item.UpdatedAt)
2020+
default:
2021+
return ""
2022+
}
2023+
}
2024+
17712025
func (s *Server) handleWebUICron(w http.ResponseWriter, r *http.Request) {
17722026
if !s.checkAuth(r) {
17732027
http.Error(w, "unauthorized", http.StatusUnauthorized)

pkg/channels/compiled_channels.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package channels
33
import "sort"
44

55
func CompiledChannelKeys() []string {
6-
out := make([]string, 0, 7)
6+
out := make([]string, 0, 8)
7+
if weixinCompiled {
8+
out = append(out, "weixin")
9+
}
710
if telegramCompiled {
811
out = append(out, "telegram")
912
}

pkg/channels/manager.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,28 @@ func (m *Manager) initChannels() error {
9292
}
9393
}
9494

95+
if m.config.Channels.Weixin.Enabled {
96+
if len(m.config.Channels.Weixin.Accounts) == 0 && strings.TrimSpace(m.config.Channels.Weixin.BotToken) == "" {
97+
logger.WarnCF("channels", 0, map[string]interface{}{
98+
"channel": "weixin",
99+
"error": "missing accounts",
100+
})
101+
} else {
102+
weixin, err := NewWeixinChannel(m.config.Channels.Weixin, m.bus)
103+
if err != nil {
104+
logger.ErrorCF("channels", 0, map[string]interface{}{
105+
logger.FieldChannel: "weixin",
106+
logger.FieldError: err.Error(),
107+
})
108+
} else {
109+
m.channels["weixin"] = weixin
110+
logger.InfoCF("channels", 0, map[string]interface{}{
111+
logger.FieldChannel: "weixin",
112+
})
113+
}
114+
}
115+
}
116+
95117
if m.config.Channels.WhatsApp.Enabled {
96118
if m.config.Channels.WhatsApp.BridgeURL == "" {
97119
logger.WarnC("channels", logger.C0009)
@@ -415,6 +437,12 @@ func (m *Manager) GetEnabledChannels() []string {
415437
return names
416438
}
417439

440+
func (m *Manager) GetChannel(name string) (Channel, bool) {
441+
cur, _ := m.snapshot.Load().(map[string]Channel)
442+
ch, ok := cur[strings.TrimSpace(name)]
443+
return ch, ok
444+
}
445+
418446
func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error {
419447
m.mu.RLock()
420448
channel, exists := m.channels[channelName]

0 commit comments

Comments
 (0)