Initialize LineUp app server

This commit is contained in:
2026-08-07 13:36:56 +08:00
commit d6f6a92bab
24 changed files with 2184 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
package modules
import (
"crypto/subtle"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
)
// AgentModule is the app-server-facing control plane for external Agent
// adapters. Message delivery remains in MessageModule; this module makes the
// adapter's liveness and delivery cursor observable without exposing Hermes.
type AgentModule struct {
vp *viper.Viper
mu sync.RWMutex
entries map[string]agentHeartbeat
}
type agentHeartbeat struct {
Status string `json:"status"`
LastMessageSeq uint64 `json:"last_message_seq"`
Detail string `json:"detail,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type agentHeartbeatRequest struct {
Status string `json:"status" binding:"required"`
LastMessageSeq uint64 `json:"last_message_seq"`
Detail string `json:"detail"`
}
func NewAgentModule(vp *viper.Viper) *AgentModule {
return &AgentModule{vp: vp, entries: make(map[string]agentHeartbeat)}
}
func (m *AgentModule) RegisterRoutes(r *gin.Engine) {
r.GET("/agents/:uid/health", m.handleHealth)
r.POST("/agents/:uid/heartbeat", m.handleHeartbeat)
}
func (m *AgentModule) configuredSecret() string {
return m.vp.GetString("agent.shared_secret")
}
func (m *AgentModule) authorize(c *gin.Context) bool {
secret := m.configuredSecret()
if secret == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Agent heartbeat 未配置 shared_secret"})
return false
}
provided := c.GetHeader("X-LineUp-Agent-Secret")
if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Agent heartbeat 认证失败"})
return false
}
return true
}
func (m *AgentModule) handleHeartbeat(c *gin.Context) {
if !m.authorize(c) {
return
}
var req agentHeartbeatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: status必填"})
return
}
entry := agentHeartbeat{
Status: req.Status, LastMessageSeq: req.LastMessageSeq, Detail: req.Detail, UpdatedAt: time.Now().UTC(),
}
m.mu.Lock()
m.entries[c.Param("uid")] = entry
m.mu.Unlock()
c.JSON(http.StatusOK, gin.H{"status": "ok", "updated_at": entry.UpdatedAt})
}
func (m *AgentModule) handleHealth(c *gin.Context) {
uid := c.Param("uid")
m.mu.RLock()
entry, found := m.entries[uid]
m.mu.RUnlock()
if !found {
c.JSON(http.StatusOK, gin.H{"uid": uid, "status": "offline", "known": false})
return
}
// A worker normally reports once per poll. Keep a generous threshold so a
// slow Hermes turn does not look dead while it is still being supervised.
online := time.Since(entry.UpdatedAt) <= 45*time.Second
c.JSON(http.StatusOK, gin.H{
"uid": uid, "status": entry.Status, "known": true, "online": online,
"last_message_seq": entry.LastMessageSeq, "detail": entry.Detail, "updated_at": entry.UpdatedAt,
})
}
+47
View File
@@ -0,0 +1,47 @@
package modules
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
)
func TestAgentHeartbeatRequiresConfiguredSecret(t *testing.T) {
gin.SetMode(gin.TestMode)
vp := viper.New()
vp.Set("agent.shared_secret", "test-secret")
router := gin.New()
NewAgentModule(vp).RegisterRoutes(router)
body := []byte(`{"status":"running","last_message_seq":42}`)
unauthorized := httptest.NewRequest(http.MethodPost, "/agents/agent_hermes_main/heartbeat", bytes.NewReader(body))
unauthorized.Header.Set("Content-Type", "application/json")
unauthorizedResult := httptest.NewRecorder()
router.ServeHTTP(unauthorizedResult, unauthorized)
if unauthorizedResult.Code != http.StatusUnauthorized {
t.Fatalf("unauthorized status = %d, want %d", unauthorizedResult.Code, http.StatusUnauthorized)
}
heartbeat := httptest.NewRequest(http.MethodPost, "/agents/agent_hermes_main/heartbeat", bytes.NewReader(body))
heartbeat.Header.Set("Content-Type", "application/json")
heartbeat.Header.Set("X-LineUp-Agent-Secret", "test-secret")
heartbeatResult := httptest.NewRecorder()
router.ServeHTTP(heartbeatResult, heartbeat)
if heartbeatResult.Code != http.StatusOK {
t.Fatalf("heartbeat status = %d, body = %s", heartbeatResult.Code, heartbeatResult.Body.String())
}
health := httptest.NewRequest(http.MethodGet, "/agents/agent_hermes_main/health", nil)
healthResult := httptest.NewRecorder()
router.ServeHTTP(healthResult, health)
if healthResult.Code != http.StatusOK {
t.Fatalf("health status = %d, body = %s", healthResult.Code, healthResult.Body.String())
}
if !bytes.Contains(healthResult.Body.Bytes(), []byte(`"online":true`)) {
t.Fatalf("health body = %s, want online=true", healthResult.Body.String())
}
}
+75
View File
@@ -0,0 +1,75 @@
package modules
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/lineup/app-server/internal/wkapi"
)
// ChannelModule handles channel creation and query.
type ChannelModule struct {
wk *wkapi.Client
}
// NewChannelModule creates a new ChannelModule.
func NewChannelModule(wk *wkapi.Client) *ChannelModule {
return &ChannelModule{wk: wk}
}
// RegisterRoutes registers channel routes.
func (m *ChannelModule) RegisterRoutes(r *gin.Engine) {
r.POST("/channels", m.handleCreate)
r.GET("/channels/:channel_id", m.handleGet)
r.POST("/channels/subscriber_add", m.handleSubscriberAdd)
}
type createChannelRequest struct {
ChannelID string `json:"channel_id" binding:"required"`
ChannelType uint8 `json:"channel_type" binding:"required"`
Subscribers []string `json:"subscribers"`
}
func (m *ChannelModule) handleCreate(c *gin.Context) {
var req createChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: channel_id和channel_type必填"})
return
}
if err := m.wk.UpsertChannel(req.ChannelID, req.ChannelType, req.Subscribers); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建频道失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"channel_id": req.ChannelID,
"channel_type": req.ChannelType,
})
}
func (m *ChannelModule) handleGet(c *gin.Context) {
channelID := c.Param("channel_id")
_ = channelID
c.JSON(http.StatusOK, gin.H{
"channel_id": channelID,
"channel_type": 2,
})
}
type subscriberAddRequest struct {
ChannelID string `json:"channel_id" binding:"required"`
ChannelType uint8 `json:"channel_type" binding:"required"`
UIDs []string `json:"uids" binding:"required"`
}
func (m *ChannelModule) handleSubscriberAdd(c *gin.Context) {
var req subscriberAddRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: channel_id, channel_type, uids必填"})
return
}
if err := m.wk.SubscriberAdd(req.ChannelID, req.ChannelType, req.UIDs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "添加订阅者失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
+88
View File
@@ -0,0 +1,88 @@
package modules
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/lineup/app-server/internal"
"github.com/lineup/app-server/internal/wkapi"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// ClientModule manages user WebSocket connections for real-time push.
type ClientModule struct {
hub *internal.Hub
wk *wkapi.Client
}
// NewClientModule creates a new ClientModule.
func NewClientModule(hub *internal.Hub, wk *wkapi.Client) *ClientModule {
return &ClientModule{hub: hub, wk: wk}
}
// RegisterRoutes registers WebSocket routes.
func (m *ClientModule) RegisterRoutes(r *gin.Engine) {
r.GET("/ws", m.handleWS)
}
func (m *ClientModule) handleWS(c *gin.Context) {
token := c.Query("token")
uid := c.Query("uid")
if token == "" || uid == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: token和uid必填"})
return
}
// Verify token with WuKongIM (register if not exists, or validate)
if err := m.wk.RegisterToken(uid, token); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token验证失败: " + err.Error()})
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("WebSocket升级失败: %v", err)
return
}
m.hub.Register(uid, conn)
log.Printf("客户端连接: uid=%s", uid)
// Send a welcome message
welcome, _ := json.Marshal(map[string]interface{}{
"event": "connected",
"uid": uid,
})
conn.WriteMessage(websocket.TextMessage, welcome)
// Read loop (keep connection alive, handle pings)
go m.readLoop(uid, conn)
}
func (m *ClientModule) readLoop(uid string, conn *websocket.Conn) {
defer func() {
m.hub.Unregister(uid)
conn.Close()
log.Printf("客户端断开: uid=%s", uid)
}()
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
}
+143
View File
@@ -0,0 +1,143 @@
package modules
import (
"encoding/base64"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/lineup/app-server/internal/wkapi"
)
// MessageModule handles message sending and syncing.
type MessageModule struct {
wk *wkapi.Client
}
// NewMessageModule creates a new MessageModule.
func NewMessageModule(wk *wkapi.Client) *MessageModule {
return &MessageModule{wk: wk}
}
// RegisterRoutes registers message routes.
func (m *MessageModule) RegisterRoutes(r *gin.Engine) {
r.POST("/messages/send", m.handleSend)
r.GET("/messages/sync", m.handleSync)
r.POST("/messages/sync", m.handleSyncPost)
}
type sendMessageRequest struct {
FromUID string `json:"from_uid" binding:"required"`
ChannelID string `json:"channel_id" binding:"required"`
ChannelType uint8 `json:"channel_type" binding:"required"`
Payload string `json:"payload" binding:"required"` // raw text, will be base64-encoded
}
func (m *MessageModule) handleSend(c *gin.Context) {
var req sendMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: from_uid, channel_id, channel_type, payload必填"})
return
}
// Base64-encode the payload for WuKongIM
encoded := base64.StdEncoding.EncodeToString([]byte(req.Payload))
resp, err := m.wk.SendMessage(wkapi.SendMessageRequest{
FromUID: req.FromUID,
ChannelID: req.ChannelID,
ChannelType: req.ChannelType,
ClientMsgNo: fmt.Sprintf("msg_%d", time.Now().UnixNano()),
Payload: encoded,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "发送消息失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message_id": resp.MessageID,
"message_seq": resp.MessageSeq,
})
}
type syncMessagesRequest struct {
ChannelID string `form:"channel_id" binding:"required"`
ChannelType uint8 `form:"channel_type" binding:"required"`
StartMessageSeq uint64 `form:"start_message_seq"`
Limit int `form:"limit"`
}
func (m *MessageModule) handleSync(c *gin.Context) {
var req syncMessagesRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: channel_id, channel_type必填"})
return
}
m.doSync(c, req.ChannelID, req.ChannelType, req.StartMessageSeq, req.Limit, "")
}
type syncMessagesPostRequest struct {
ChannelID string `json:"channel_id" binding:"required"`
ChannelType uint8 `json:"channel_type" binding:"required"`
LoginUID string `json:"login_uid"`
StartMessageSeq uint64 `json:"start_message_seq"`
Limit int `json:"limit"`
}
func (m *MessageModule) handleSyncPost(c *gin.Context) {
var req syncMessagesPostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: channel_id, channel_type必填"})
return
}
m.doSync(c, req.ChannelID, req.ChannelType, req.StartMessageSeq, req.Limit, req.LoginUID)
}
func (m *MessageModule) doSync(c *gin.Context, channelID string, channelType uint8, startSeq uint64, limit int, loginUID string) {
if limit <= 0 {
limit = 20
}
resp, err := m.wk.SyncChannelMessages(wkapi.SyncChannelMessagesRequest{
LoginUID: loginUID,
ChannelID: channelID,
ChannelType: channelType,
StartMessageSeq: startSeq,
Limit: limit,
PullMode: 1,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "同步消息失败: " + err.Error()})
return
}
// Decode base64 payloads for the response
type msgItem struct {
MessageID uint64 `json:"message_id"`
MessageSeq uint64 `json:"message_seq"`
FromUID string `json:"from_uid"`
ChannelID string `json:"channel_id"`
Payload string `json:"payload"`
Timestamp int32 `json:"timestamp"`
}
messages := make([]msgItem, 0, len(resp.Messages))
for _, m := range resp.Messages {
decoded, _ := base64.StdEncoding.DecodeString(m.Payload)
messages = append(messages, msgItem{
MessageID: m.MessageID,
MessageSeq: m.MessageSeq,
FromUID: m.FromUID,
ChannelID: m.ChannelID,
Payload: string(decoded),
Timestamp: m.Timestamp,
})
}
c.JSON(http.StatusOK, gin.H{
"start_message_seq": resp.StartMessageSeq,
"end_message_seq": resp.EndMessageSeq,
"more": resp.More,
"messages": messages,
})
}
+58
View File
@@ -0,0 +1,58 @@
package modules
import (
"github.com/gin-gonic/gin"
"github.com/lineup/app-server/internal"
"github.com/lineup/app-server/internal/wkapi"
"github.com/spf13/viper"
)
// SetupRoutes 注册所有模块路由
func SetupRoutes(r *gin.Engine, vp *viper.Viper) {
// 共享依赖
hub := internal.NewHub()
wk := wkapi.New(vp.GetString("wukongim.apiURL"))
// 自动创建 Agent 频道
agentChannelID := vp.GetString("agent.channelID")
agentUID := vp.GetString("agent.uid")
agentChannelType := uint8(vp.GetInt("agent.channelType"))
if agentChannelType == 0 {
agentChannelType = 2
}
if agentChannelID != "" && agentUID != "" {
if err := wk.UpsertChannel(agentChannelID, agentChannelType, []string{agentUID}); err != nil {
// ignore upsert error, channel may already exist
_ = err
}
}
// 健康检查
r.GET("/healthz", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
// 用户模块
userModule := &UserModule{vp: vp, wk: wk}
userModule.RegisterRoutes(r)
// Channel 模块
channelModule := NewChannelModule(wk)
channelModule.RegisterRoutes(r)
// Message 模块
messageModule := NewMessageModule(wk)
messageModule.RegisterRoutes(r)
// Agent 控制面:外部 Hermes Adapter 的存活与消费游标。
agentModule := NewAgentModule(vp)
agentModule.RegisterRoutes(r)
// Webhook 模块
webhookModule := NewWebhookModule(hub)
webhookModule.RegisterRoutes(r)
// Client WebSocket 模块
clientModule := NewClientModule(hub, wk)
clientModule.RegisterRoutes(r)
}
+95
View File
@@ -0,0 +1,95 @@
package modules
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/lineup/app-server/internal/wkapi"
"github.com/spf13/viper"
)
// UserModule 用户模块: 登录 / 注册
type UserModule struct {
vp *viper.Viper
wk *wkapi.Client
}
// RegisterRoutes 注册路由
func (m *UserModule) RegisterRoutes(r *gin.Engine) {
r.POST("/login", m.handleLogin)
}
// LoginRequest 登录请求
type LoginRequest struct {
Phone string `json:"phone" binding:"required"`
Code string `json:"code" binding:"required"`
}
// LoginResponse 登录响应
type LoginResponse struct {
UID string `json:"uid"`
Token string `json:"token"`
IMAddr string `json:"im_addr"` // WuKongIM WS地址
AgentUID string `json:"agent_uid"` // Agent的UID
ChannelID string `json:"channel_id"` // Agent频道ID
ChannelType uint8 `json:"channel_type"` // Agent频道类型
}
// handleLogin 处理登录请求
func (m *UserModule) handleLogin(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: phone和code必填"})
return
}
// 生成用户 UID
uid := fmt.Sprintf("user_%s", req.Phone)
// 生成 Token (随机32字节)
token := generateToken()
// 注册 Token 到 WuKongIM
if err := m.wk.RegisterToken(uid, token); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("注册Token失败: %v", err)})
return
}
// 确保 Agent 频道存在并将用户加入订阅者
agentChannelID := m.vp.GetString("agent.channelID")
agentUID := m.vp.GetString("agent.uid")
agentChannelType := uint8(m.vp.GetInt("agent.channelType"))
if agentChannelType == 0 {
agentChannelType = 2
}
// 创建/更新频道,确保 Agent 和用户都是订阅者
m.wk.UpsertChannel(agentChannelID, agentChannelType, []string{agentUID})
// 将用户加入频道订阅者
m.wk.SubscriberAdd(agentChannelID, agentChannelType, []string{uid})
// 获取 WebSocket 地址
route, err := m.wk.GetRoute()
wsAddr := m.vp.GetString("wukongim.wsAddr")
if err == nil && route.WSAddr != "" {
wsAddr = route.WSAddr
}
c.JSON(http.StatusOK, LoginResponse{
UID: uid,
Token: token,
IMAddr: wsAddr,
AgentUID: m.vp.GetString("agent.uid"),
ChannelID: m.vp.GetString("agent.channelID"),
ChannelType: uint8(m.vp.GetInt("agent.channelType")),
})
}
// generateToken 生成32字节随机Token
func generateToken() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
+151
View File
@@ -0,0 +1,151 @@
package modules
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/gin-gonic/gin"
"github.com/lineup/app-server/internal"
)
// WebhookModule receives WuKongIM 3.0 HTTP webhook callbacks.
type WebhookModule struct {
hub *internal.Hub
}
// NewWebhookModule creates a new WebhookModule.
func NewWebhookModule(hub *internal.Hub) *WebhookModule {
return &WebhookModule{hub: hub}
}
// RegisterRoutes registers webhook routes.
func (m *WebhookModule) RegisterRoutes(r *gin.Engine) {
r.POST("/v1/webhook", m.handleWebhook)
}
// wkMsgResp mirrors WuKongIM 3.0's msg.notify message format.
type wkMsgResp struct {
Header wkMsgHeader `json:"header"`
Setting uint8 `json:"setting"`
MessageID uint64 `json:"message_id"`
MessageSeq uint64 `json:"message_seq"`
FromUID string `json:"from_uid"`
ChannelID string `json:"channel_id"`
ChannelType uint8 `json:"channel_type"`
Timestamp int32 `json:"timestamp"`
Payload string `json:"payload"` // base64-encoded
}
type wkMsgHeader struct {
NoPersist int `json:"no_persist"`
RedDot int `json:"red_dot"`
SyncOnce int `json:"sync_once"`
}
// wkOfflineResp mirrors WuKongIM 3.0's msg.offline format.
type wkOfflineResp struct {
wkMsgResp
ToUIDs []string `json:"to_uids"`
Compress string `json:"compress"`
CompressToUIDs string `json:"compress_to_uids"`
SourceID int64 `json:"source_id"`
}
func (m *WebhookModule) handleWebhook(c *gin.Context) {
event := c.Query("event")
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取请求体失败"})
return
}
switch event {
case "msg.notify":
m.handleMsgNotify(body)
case "msg.offline":
m.handleMsgOffline(body)
case "user.onlinestatus":
m.handleOnlineStatus(body)
default:
// Unknown event, still return 200 per WK webhook contract
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
func (m *WebhookModule) handleMsgNotify(data []byte) {
var msgs []wkMsgResp
if err := json.Unmarshal(data, &msgs); err != nil {
return
}
for _, msg := range msgs {
m.pushToClient(msg.ChannelID, msg)
}
}
func (m *WebhookModule) handleMsgOffline(data []byte) {
var msg wkOfflineResp
if err := json.Unmarshal(data, &msg); err != nil {
return
}
// Push to each offline recipient
for _, uid := range msg.ToUIDs {
m.pushToClient(uid, msg.wkMsgResp)
}
// Handle gzip-compressed UIDs
if msg.Compress == "gzip" && len(msg.CompressToUIDs) > 0 {
decoded, err := base64.StdEncoding.DecodeString(msg.CompressToUIDs)
if err == nil {
var uids []string
if json.Unmarshal(decoded, &uids) == nil {
for _, uid := range uids {
m.pushToClient(uid, msg.wkMsgResp)
}
}
}
}
}
func (m *WebhookModule) handleOnlineStatus(data []byte) {
// Online status format: ["uid-flag-status", ...]
var statuses []string
if err := json.Unmarshal(data, &statuses); err != nil {
return
}
// For now, just log. Could be used to notify clients.
_ = statuses
}
// pushToClient decodes the payload and pushes to the connected client.
func (m *WebhookModule) pushToClient(uid string, msg wkMsgResp) {
if m.hub == nil {
return
}
decoded, _ := base64.StdEncoding.DecodeString(msg.Payload)
pushMsg := map[string]interface{}{
"event": "message",
"message_id": msg.MessageID,
"message_seq": msg.MessageSeq,
"from_uid": msg.FromUID,
"channel_id": msg.ChannelID,
"channel_type": msg.ChannelType,
"payload": string(decoded),
"timestamp": msg.Timestamp,
}
pushData, _ := json.Marshal(pushMsg)
if err := m.hub.PushToClient(uid, pushData); err != nil {
// Client not connected, that's OK
_ = err
}
// Also push to the other participant if it's a person channel
// This ensures both sides get the message
if msg.ChannelType == 1 && msg.FromUID != uid {
_ = m.hub.PushToClient(msg.FromUID, pushData)
}
}
// Ensure fmt is used (for potential future logging)
var _ = fmt.Sprintf