新增机器人功能
This commit is contained in:
@@ -0,0 +1,174 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type botNodeRequest struct {
|
||||||
|
NodeNum *int64 `json:"node_num"`
|
||||||
|
LongName string `json:"long_name"`
|
||||||
|
ShortName string `json:"short_name"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
DefaultChannelID string `json:"default_channel_id"`
|
||||||
|
TopicPrefix string `json:"topic_prefix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type botSendMessageRequest struct {
|
||||||
|
BotID uint64 `json:"bot_id"`
|
||||||
|
MessageType string `json:"message_type"`
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
ToNodeID string `json:"to_node_id"`
|
||||||
|
ToNodeNum *int64 `json:"to_node_num"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
|
||||||
|
r.GET("/bot/nodes", func(c *gin.Context) {
|
||||||
|
opts, ok := parseListOptions(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := store.ListBotNodes(opts)
|
||||||
|
if err != nil {
|
||||||
|
writeListResponse(c, rows, opts, err, botNodeDTO)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := store.CountBotNodes(opts)
|
||||||
|
writeListResponseWithTotal(c, rows, opts, total, err, botNodeDTO)
|
||||||
|
})
|
||||||
|
r.POST("/bot/nodes", func(c *gin.Context) {
|
||||||
|
var req botNodeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot node request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := store.CreateBotNode(botNodeInputFromRequest(req))
|
||||||
|
writeBotNodeMutationResponse(c, http.StatusCreated, row, err)
|
||||||
|
})
|
||||||
|
r.PUT("/bot/nodes/:id", func(c *gin.Context) {
|
||||||
|
id, ok := parseBotID(c, "invalid bot node id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req botNodeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot node request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := store.UpdateBotNode(id, botNodeInputFromRequest(req))
|
||||||
|
writeBotNodeMutationResponse(c, http.StatusOK, row, err)
|
||||||
|
})
|
||||||
|
r.DELETE("/bot/nodes/:id", func(c *gin.Context) {
|
||||||
|
id, ok := parseBotID(c, "invalid bot node id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := store.DeleteBotNode(id); errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"})
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
r.GET("/bot/messages", func(c *gin.Context) {
|
||||||
|
opts, ok := parseBotMessageListOptions(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := store.ListBotMessages(opts)
|
||||||
|
if err != nil {
|
||||||
|
writeListResponse(c, rows, opts.listOptions, err, botMessageDTO)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := store.CountBotMessages(opts)
|
||||||
|
writeListResponseWithTotal(c, rows, opts.listOptions, total, err, botMessageDTO)
|
||||||
|
})
|
||||||
|
r.POST("/bot/messages", func(c *gin.Context) {
|
||||||
|
if sender == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "bot sender is not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req botSendMessageRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot message request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims := c.MustGet("admin_claims").(*sessionClaims)
|
||||||
|
row, err := sender.SendText(c.Request.Context(), botSendTextRequest{BotID: req.BotID, MessageType: req.MessageType, ChannelID: req.ChannelID, ToNodeID: req.ToNodeID, ToNodeNum: req.ToNodeNum, Text: req.Text, CreatedBy: claims.Username})
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusBadRequest
|
||||||
|
if row != nil && row.ID != 0 {
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"item": botMessageDTO(*row), "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"item": botMessageDTO(*row)})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func botNodeInputFromRequest(req botNodeRequest) botNodeInput {
|
||||||
|
return botNodeInput{NodeNum: req.NodeNum, LongName: req.LongName, ShortName: req.ShortName, Enabled: req.Enabled, DefaultChannelID: req.DefaultChannelID, TopicPrefix: req.TopicPrefix}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBotID(c *gin.Context, message string) (uint64, bool) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": message})
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBotMessageListOptions(c *gin.Context) (botMessageListOptions, bool) {
|
||||||
|
listOpts, ok := parseListOptions(c)
|
||||||
|
if !ok {
|
||||||
|
return botMessageListOptions{}, false
|
||||||
|
}
|
||||||
|
opts := botMessageListOptions{listOptions: listOpts, MessageType: c.Query("message_type"), ChannelID: c.Query("channel_id")}
|
||||||
|
if value := c.Query("bot_id"); value != "" {
|
||||||
|
id, err := strconv.ParseUint(value, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"})
|
||||||
|
return botMessageListOptions{}, false
|
||||||
|
}
|
||||||
|
opts.BotID = id
|
||||||
|
}
|
||||||
|
return opts, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBotNodeMutationResponse(c *gin.Context, status int, row *botNodeRecord, err error) {
|
||||||
|
if errors.Is(err, errBotNodeAlreadyExists) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "bot node already exists or conflicts with existing node"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"item": botNodeDTO(*row)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func botNodeDTO(row botNodeRecord) gin.H {
|
||||||
|
return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": row.NodeNum, "long_name": row.LongName, "short_name": row.ShortName, "enabled": row.Enabled, "default_channel_id": row.DefaultChannelID, "topic_prefix": row.TopicPrefix, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func botMessageDTO(row botMessageRecord) gin.H {
|
||||||
|
return gin.H{"id": row.ID, "bot_id": row.BotID, "bot_node_id": row.BotNodeID, "bot_node_num": row.BotNodeNum, "message_type": row.MessageType, "channel_id": row.ChannelID, "to_node_id": row.ToNodeID, "to_node_num": row.ToNodeNum, "topic": row.Topic, "packet_id": row.PacketID, "text": row.Text, "payload_len": row.PayloadLen, "encrypted": row.Encrypted, "status": row.Status, "error": row.Error, "published_at": row.PublishedAt, "created_by": row.CreatedBy, "created_at": row.CreatedAt}
|
||||||
|
}
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"meshtastic_mqtt_server/mqtpp"
|
||||||
|
|
||||||
|
mqtt "github.com/mochi-mqtt/server/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const botMaxTextBytes = 200
|
||||||
|
|
||||||
|
type botSendTextRequest struct {
|
||||||
|
BotID uint64
|
||||||
|
MessageType string
|
||||||
|
ChannelID string
|
||||||
|
ToNodeID string
|
||||||
|
ToNodeNum *int64
|
||||||
|
Text string
|
||||||
|
CreatedBy string
|
||||||
|
}
|
||||||
|
|
||||||
|
type botTextSender interface {
|
||||||
|
SendText(ctx context.Context, req botSendTextRequest) (*botMessageRecord, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type botService struct {
|
||||||
|
store *store
|
||||||
|
server *mqtt.Server
|
||||||
|
key []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBotService(store *store, server *mqtt.Server, key []byte) *botService {
|
||||||
|
return &botService{store: store, server: server, key: key}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMessageRecord, error) {
|
||||||
|
if s == nil || s.store == nil {
|
||||||
|
return nil, fmt.Errorf("bot service is not configured")
|
||||||
|
}
|
||||||
|
bot, err := s.store.GetBotNode(req.BotID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !bot.Enabled {
|
||||||
|
return nil, fmt.Errorf("bot node is disabled")
|
||||||
|
}
|
||||||
|
messageType, err := normalizeBotMessageType(req.MessageType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(req.Text)
|
||||||
|
if text == "" {
|
||||||
|
return nil, fmt.Errorf("text is required")
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(text) {
|
||||||
|
return nil, fmt.Errorf("text must be valid utf-8")
|
||||||
|
}
|
||||||
|
if len([]byte(text)) > botMaxTextBytes {
|
||||||
|
return nil, fmt.Errorf("text is too long, max %d bytes", botMaxTextBytes)
|
||||||
|
}
|
||||||
|
channelID := strings.TrimSpace(req.ChannelID)
|
||||||
|
if channelID == "" {
|
||||||
|
channelID = bot.DefaultChannelID
|
||||||
|
}
|
||||||
|
if channelID == "" {
|
||||||
|
return nil, fmt.Errorf("channel id is required")
|
||||||
|
}
|
||||||
|
toNodeNum, toNodeID, err := botMessageTarget(messageType, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
packetID, err := randomPacketID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fromNodeNum := uint32(bot.NodeNum)
|
||||||
|
raw, err := mqtpp.BuildTextMessageServiceEnvelope(mqtpp.TextMessageBuildOptions{
|
||||||
|
FromNodeNum: fromNodeNum,
|
||||||
|
ToNodeNum: uint32(toNodeNum),
|
||||||
|
PacketID: packetID,
|
||||||
|
ChannelID: channelID,
|
||||||
|
GatewayID: bot.NodeID,
|
||||||
|
Text: text,
|
||||||
|
PSK: s.key,
|
||||||
|
Encrypt: true,
|
||||||
|
ViaMQTT: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
topic := strings.Trim(bot.TopicPrefix, "/") + "/" + channelID + "/" + bot.NodeID
|
||||||
|
row := &botMessageRecord{
|
||||||
|
BotID: bot.ID,
|
||||||
|
BotNodeID: bot.NodeID,
|
||||||
|
BotNodeNum: bot.NodeNum,
|
||||||
|
MessageType: messageType,
|
||||||
|
ChannelID: channelID,
|
||||||
|
ToNodeID: toNodeID,
|
||||||
|
ToNodeNum: int64PtrOrNil(toNodeNum, messageType == botMessageTypeDirect),
|
||||||
|
Topic: topic,
|
||||||
|
PacketID: int64(packetID),
|
||||||
|
Text: text,
|
||||||
|
PayloadLen: int64(len(raw)),
|
||||||
|
Encrypted: true,
|
||||||
|
Status: botMessageStatusPending,
|
||||||
|
CreatedBy: strings.TrimSpace(req.CreatedBy),
|
||||||
|
}
|
||||||
|
if err := s.store.InsertBotMessage(row); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s.server == nil {
|
||||||
|
_ = s.store.UpdateBotMessageStatus(row.ID, botMessageStatusFailed, "mqtt server is not configured", nil)
|
||||||
|
row.Status = botMessageStatusFailed
|
||||||
|
row.Error = "mqtt server is not configured"
|
||||||
|
return row, fmt.Errorf("mqtt server is not configured")
|
||||||
|
}
|
||||||
|
if err := s.server.Publish(topic, raw, false, 0); err != nil {
|
||||||
|
_ = s.store.UpdateBotMessageStatus(row.ID, botMessageStatusFailed, err.Error(), nil)
|
||||||
|
row.Status = botMessageStatusFailed
|
||||||
|
row.Error = err.Error()
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if err := s.store.UpdateBotMessageStatus(row.ID, botMessageStatusPublished, "", &now); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
row.Status = botMessageStatusPublished
|
||||||
|
row.Error = ""
|
||||||
|
row.PublishedAt = &now
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBotMessageType(value string) (string, error) {
|
||||||
|
switch strings.TrimSpace(value) {
|
||||||
|
case "", botMessageTypeChannel:
|
||||||
|
return botMessageTypeChannel, nil
|
||||||
|
case botMessageTypeDirect:
|
||||||
|
return botMessageTypeDirect, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("message type must be channel or direct")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func botMessageTarget(messageType string, req botSendTextRequest) (int64, *string, error) {
|
||||||
|
if messageType == botMessageTypeChannel {
|
||||||
|
return int64(mqtpp.NodeNumBroadcast), nil, nil
|
||||||
|
}
|
||||||
|
if req.ToNodeNum != nil && *req.ToNodeNum > 0 {
|
||||||
|
if err := validateBotNodeNum(*req.ToNodeNum); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
nodeID := mqtpp.NodeNumToID(uint32(*req.ToNodeNum))
|
||||||
|
return *req.ToNodeNum, &nodeID, nil
|
||||||
|
}
|
||||||
|
toNodeID := strings.TrimSpace(req.ToNodeID)
|
||||||
|
if toNodeID == "" {
|
||||||
|
return 0, nil, fmt.Errorf("target node is required for direct message")
|
||||||
|
}
|
||||||
|
nodeNum, err := mqtpp.ParseNodeID(toNodeID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
if err := validateBotNodeNum(int64(nodeNum)); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
normalized := mqtpp.NodeNumToID(nodeNum)
|
||||||
|
return int64(nodeNum), &normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomPacketID() (uint32, error) {
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
var buf [4]byte
|
||||||
|
if _, err := rand.Read(buf[:]); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
id := binary.LittleEndian.Uint32(buf[:])
|
||||||
|
if id != 0 {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("generate packet id failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64PtrOrNil(value int64, ok bool) *int64 {
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &value
|
||||||
|
}
|
||||||
+280
@@ -0,0 +1,280 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"meshtastic_mqtt_server/mqtpp"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
botDefaultTopicPrefix = "msh/2/e"
|
||||||
|
botMessageTypeChannel = "channel"
|
||||||
|
botMessageTypeDirect = "direct"
|
||||||
|
botMessageStatusPending = "pending"
|
||||||
|
botMessageStatusPublished = "published"
|
||||||
|
botMessageStatusFailed = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errBotNodeAlreadyExists = errors.New("bot node already exists")
|
||||||
|
|
||||||
|
type botNodeInput struct {
|
||||||
|
NodeNum *int64
|
||||||
|
LongName string
|
||||||
|
ShortName string
|
||||||
|
Enabled bool
|
||||||
|
DefaultChannelID string
|
||||||
|
TopicPrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
type botMessageListOptions struct {
|
||||||
|
listOptions
|
||||||
|
BotID uint64
|
||||||
|
MessageType string
|
||||||
|
ChannelID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) ListBotNodes(opts listOptions) ([]botNodeRecord, error) {
|
||||||
|
opts = normalizeListOptions(opts)
|
||||||
|
var rows []botNodeRecord
|
||||||
|
q := s.db.Model(&botNodeRecord{}).
|
||||||
|
Order("updated_at DESC").
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(opts.Limit).
|
||||||
|
Offset(opts.Offset)
|
||||||
|
return rows, q.Find(&rows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) CountBotNodes(opts listOptions) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
return total, s.db.Model(&botNodeRecord{}).Count(&total).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) GetBotNode(id uint64) (*botNodeRecord, error) {
|
||||||
|
var row botNodeRecord
|
||||||
|
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) CreateBotNode(input botNodeInput) (*botNodeRecord, error) {
|
||||||
|
row, err := s.normalizedBotNodeRecord(input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.ensureBotNodeUnique(0, row.NodeID, row.NodeNum); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(row.NodeNum); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.db.Create(row).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) UpdateBotNode(id uint64, input botNodeInput) (*botNodeRecord, error) {
|
||||||
|
if id == 0 {
|
||||||
|
return nil, fmt.Errorf("bot node id is required")
|
||||||
|
}
|
||||||
|
if _, err := s.GetBotNode(id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
row, err := s.normalizedBotNodeRecord(input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.ensureBotNodeUnique(id, row.NodeID, row.NodeNum); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(row.NodeNum); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
updates := map[string]any{
|
||||||
|
"node_id": row.NodeID,
|
||||||
|
"node_num": row.NodeNum,
|
||||||
|
"long_name": row.LongName,
|
||||||
|
"short_name": row.ShortName,
|
||||||
|
"enabled": row.Enabled,
|
||||||
|
"default_channel_id": row.DefaultChannelID,
|
||||||
|
"topic_prefix": row.TopicPrefix,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}
|
||||||
|
if err := s.db.Model(&botNodeRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.GetBotNode(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) DeleteBotNode(id uint64) error {
|
||||||
|
result := s.db.Where("id = ?", id).Delete(&botNodeRecord{})
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) InsertBotMessage(row *botMessageRecord) error {
|
||||||
|
return s.db.Create(row).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) UpdateBotMessageStatus(id uint64, status, errText string, publishedAt *time.Time) error {
|
||||||
|
updates := map[string]any{"status": status, "error": strings.TrimSpace(errText), "published_at": publishedAt}
|
||||||
|
result := s.db.Model(&botMessageRecord{}).Where("id = ?", id).Updates(updates)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) ListBotMessages(opts botMessageListOptions) ([]botMessageRecord, error) {
|
||||||
|
opts.listOptions = normalizeListOptions(opts.listOptions)
|
||||||
|
var rows []botMessageRecord
|
||||||
|
q := applyBotMessageFilters(s.db.Model(&botMessageRecord{}), opts).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(opts.Limit).
|
||||||
|
Offset(opts.Offset)
|
||||||
|
return rows, q.Find(&rows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) CountBotMessages(opts botMessageListOptions) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
q := applyBotMessageFilters(s.db.Model(&botMessageRecord{}), opts)
|
||||||
|
return total, q.Count(&total).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyBotMessageFilters(q *gorm.DB, opts botMessageListOptions) *gorm.DB {
|
||||||
|
if opts.BotID != 0 {
|
||||||
|
q = q.Where("bot_id = ?", opts.BotID)
|
||||||
|
}
|
||||||
|
if opts.MessageType != "" {
|
||||||
|
q = q.Where("message_type = ?", opts.MessageType)
|
||||||
|
}
|
||||||
|
if opts.ChannelID != "" {
|
||||||
|
q = q.Where("channel_id = ?", opts.ChannelID)
|
||||||
|
}
|
||||||
|
if opts.Since != nil {
|
||||||
|
q = q.Where("created_at >= ?", *opts.Since)
|
||||||
|
}
|
||||||
|
if opts.Until != nil {
|
||||||
|
q = q.Where("created_at <= ?", *opts.Until)
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) normalizedBotNodeRecord(input botNodeInput) (*botNodeRecord, error) {
|
||||||
|
longName := strings.TrimSpace(input.LongName)
|
||||||
|
shortName := strings.TrimSpace(input.ShortName)
|
||||||
|
channelID := strings.TrimSpace(input.DefaultChannelID)
|
||||||
|
topicPrefix := strings.Trim(strings.TrimSpace(input.TopicPrefix), "/")
|
||||||
|
if topicPrefix == "" {
|
||||||
|
topicPrefix = botDefaultTopicPrefix
|
||||||
|
}
|
||||||
|
if longName == "" {
|
||||||
|
return nil, fmt.Errorf("long name is required")
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(longName) {
|
||||||
|
return nil, fmt.Errorf("long name must be valid utf-8")
|
||||||
|
}
|
||||||
|
if shortName == "" {
|
||||||
|
return nil, fmt.Errorf("short name is required")
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(shortName) {
|
||||||
|
return nil, fmt.Errorf("short name must be valid utf-8")
|
||||||
|
}
|
||||||
|
if channelID == "" {
|
||||||
|
return nil, fmt.Errorf("default channel id is required")
|
||||||
|
}
|
||||||
|
var nodeNum int64
|
||||||
|
if input.NodeNum == nil || *input.NodeNum == 0 {
|
||||||
|
generated, err := s.generateBotNodeNum()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
nodeNum = generated
|
||||||
|
} else {
|
||||||
|
nodeNum = *input.NodeNum
|
||||||
|
}
|
||||||
|
if err := validateBotNodeNum(nodeNum); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &botNodeRecord{NodeID: mqtpp.NodeNumToID(uint32(nodeNum)), NodeNum: nodeNum, LongName: longName, ShortName: shortName, Enabled: input.Enabled, DefaultChannelID: channelID, TopicPrefix: topicPrefix}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateBotNodeNum(nodeNum int64) error {
|
||||||
|
if nodeNum <= 0 || nodeNum >= int64(mqtpp.NodeNumBroadcast) {
|
||||||
|
return fmt.Errorf("node num must be between 1 and 4294967294")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) generateBotNodeNum() (int64, error) {
|
||||||
|
for i := 0; i < 32; i++ {
|
||||||
|
var buf [4]byte
|
||||||
|
if _, err := rand.Read(buf[:]); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
nodeNum := int64(binary.LittleEndian.Uint32(buf[:]) & 0x7fffffff)
|
||||||
|
if err := validateBotNodeNum(nodeNum); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.ensureBotNodeUnique(0, mqtpp.NodeNumToID(uint32(nodeNum)), nodeNum); err != nil {
|
||||||
|
if errors.Is(err, errBotNodeAlreadyExists) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum); err != nil {
|
||||||
|
if errors.Is(err, errBotNodeAlreadyExists) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return nodeNum, nil
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("generate bot node num failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) ensureBotNodeUnique(id uint64, nodeID string, nodeNum int64) error {
|
||||||
|
var existing botNodeRecord
|
||||||
|
q := s.db.Where("node_id = ? OR node_num = ?", nodeID, nodeNum)
|
||||||
|
if id != 0 {
|
||||||
|
q = q.Where("id <> ?", id)
|
||||||
|
}
|
||||||
|
err := q.Take(&existing).Error
|
||||||
|
if err == nil {
|
||||||
|
return errBotNodeAlreadyExists
|
||||||
|
}
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum int64) error {
|
||||||
|
var existing nodeInfoRecord
|
||||||
|
err := s.db.Where("node_num = ?", nodeNum).Take(&existing).Error
|
||||||
|
if err == nil {
|
||||||
|
return errBotNodeAlreadyExists
|
||||||
|
}
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -219,6 +219,49 @@ func (mqttForwardTopicRecord) TableName() string {
|
|||||||
return "mqtt_forward_topics"
|
return "mqtt_forward_topics"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type botNodeRecord struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
NodeID string `gorm:"column:node_id;not null;uniqueIndex"`
|
||||||
|
NodeNum int64 `gorm:"column:node_num;not null;uniqueIndex"`
|
||||||
|
LongName string `gorm:"column:long_name;not null"`
|
||||||
|
ShortName string `gorm:"column:short_name;not null"`
|
||||||
|
Enabled bool `gorm:"column:enabled;not null;index"`
|
||||||
|
DefaultChannelID string `gorm:"column:default_channel_id;not null;index"`
|
||||||
|
TopicPrefix string `gorm:"column:topic_prefix;not null"`
|
||||||
|
LastPacketID int64 `gorm:"column:last_packet_id;not null"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (botNodeRecord) TableName() string {
|
||||||
|
return "bot_nodes"
|
||||||
|
}
|
||||||
|
|
||||||
|
type botMessageRecord struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
BotID uint64 `gorm:"column:bot_id;not null;index:idx_bot_message_bot_created_at,priority:1"`
|
||||||
|
BotNodeID string `gorm:"column:bot_node_id;not null;index"`
|
||||||
|
BotNodeNum int64 `gorm:"column:bot_node_num;not null;index"`
|
||||||
|
MessageType string `gorm:"column:message_type;not null;index"`
|
||||||
|
ChannelID string `gorm:"column:channel_id;not null;index"`
|
||||||
|
ToNodeID *string `gorm:"column:to_node_id;index"`
|
||||||
|
ToNodeNum *int64 `gorm:"column:to_node_num;index"`
|
||||||
|
Topic string `gorm:"column:topic;not null"`
|
||||||
|
PacketID int64 `gorm:"column:packet_id;not null;index"`
|
||||||
|
Text string `gorm:"column:text;type:text;not null"`
|
||||||
|
PayloadLen int64 `gorm:"column:payload_len;not null"`
|
||||||
|
Encrypted bool `gorm:"column:encrypted;not null;index"`
|
||||||
|
Status string `gorm:"column:status;not null;index"`
|
||||||
|
Error string `gorm:"column:error;type:text"`
|
||||||
|
PublishedAt *time.Time `gorm:"column:published_at;index"`
|
||||||
|
CreatedBy string `gorm:"column:created_by;index"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_bot_message_bot_created_at,priority:2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (botMessageRecord) TableName() string {
|
||||||
|
return "bot_messages"
|
||||||
|
}
|
||||||
|
|
||||||
type nodeInfoRecord struct {
|
type nodeInfoRecord struct {
|
||||||
NodeID string `gorm:"column:node_id;primaryKey;not null"`
|
NodeID string `gorm:"column:node_id;primaryKey;not null"`
|
||||||
NodeNum int64 `gorm:"column:node_num;not null;index"`
|
NodeNum int64 `gorm:"column:node_num;not null;index"`
|
||||||
@@ -421,6 +464,8 @@ func (s *store) migrate() error {
|
|||||||
{label: "forbidden_word_blocking", model: &forbiddenWordBlockingRecord{}},
|
{label: "forbidden_word_blocking", model: &forbiddenWordBlockingRecord{}},
|
||||||
{label: "mqtt_forwarders", model: &mqttForwarderRecord{}},
|
{label: "mqtt_forwarders", model: &mqttForwarderRecord{}},
|
||||||
{label: "mqtt_forward_topics", model: &mqttForwardTopicRecord{}},
|
{label: "mqtt_forward_topics", model: &mqttForwardTopicRecord{}},
|
||||||
|
{label: "bot_nodes", model: &botNodeRecord{}},
|
||||||
|
{label: "bot_messages", model: &botMessageRecord{}},
|
||||||
{label: "nodeinfo", model: &nodeInfoRecord{}},
|
{label: "nodeinfo", model: &nodeInfoRecord{}},
|
||||||
{label: "map_report", model: &mapReportRecord{}},
|
{label: "map_report", model: &mapReportRecord{}},
|
||||||
{label: "text_message", model: &textMessageRecord{}},
|
{label: "text_message", model: &textMessageRecord{}},
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ func run(cfg *config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
botSender := newBotService(store, server, cfg.key)
|
||||||
forwardManager := newMQTTForwardManager(store)
|
forwardManager := newMQTTForwardManager(store)
|
||||||
if err := forwardManager.StartFromStore(); err != nil {
|
if err := forwardManager.StartFromStore(); err != nil {
|
||||||
server.Close()
|
server.Close()
|
||||||
@@ -239,7 +240,7 @@ func run(cfg *config) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
mqttStatus := mqttRuntimeStatus{server: server, address: mqttAddr, tls: cfg.MQTT.TLS.Enabled, stats: messageStats, dbQueue: dbQueue}
|
mqttStatus := mqttRuntimeStatus{server: server, address: mqttAddr, tls: cfg.MQTT.TLS.Enabled, stats: messageStats, dbQueue: dbQueue}
|
||||||
httpServer = newHTTPServer(cfg.Web, store, sessions, mqttStatus, blocking, forwardManager, settings)
|
httpServer = newHTTPServer(cfg.Web, store, sessions, mqttStatus, blocking, forwardManager, settings, botSender)
|
||||||
webAddress := httpServer.Addr
|
webAddress := httpServer.Addr
|
||||||
go func() {
|
go func() {
|
||||||
if cfg.Web.SocketPath != "" {
|
if cfg.Web.SocketPath != "" {
|
||||||
@@ -281,7 +282,7 @@ func run(cfg *config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startMQTTServer(cfg *config, dbQueue *dbWriteQueue, stats *meshtasticMessageStats, blocking *blockingCache, settings *runtimeSettingsCache) (*mqtt.Server, string, error) {
|
func startMQTTServer(cfg *config, dbQueue *dbWriteQueue, stats *meshtasticMessageStats, blocking *blockingCache, settings *runtimeSettingsCache) (*mqtt.Server, string, error) {
|
||||||
server := mqtt.New(nil)
|
server := mqtt.New(&mqtt.Options{InlineClient: true})
|
||||||
if err := server.AddHook(new(auth.AllowHook), nil); err != nil {
|
if err := server.AddHook(new(auth.AllowHook), nil); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { adminLogout, createNodeBlockingRule, deleteNode, deleteTextMessage, getAdminMe, getHealth, getMapReportViewport, getNodeInfo, getPositions, getTextMessages } from './api'
|
import { adminLogout, createNodeBlockingRule, deleteNode, deleteTextMessage, getAdminMe, getHealth, getMapReportViewport, getNodeInfo, getPositions, getTextMessages } from './api'
|
||||||
import AdminBlockingManagement from './components/AdminBlockingManagement.vue'
|
import AdminBlockingManagement from './components/AdminBlockingManagement.vue'
|
||||||
|
import AdminBot from './components/AdminBot.vue'
|
||||||
import AdminDashboard from './components/AdminDashboard.vue'
|
import AdminDashboard from './components/AdminDashboard.vue'
|
||||||
import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
|
import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
|
||||||
import AdminHelpEdit from './components/AdminHelpEdit.vue'
|
import AdminHelpEdit from './components/AdminHelpEdit.vue'
|
||||||
@@ -21,6 +22,7 @@ const currentPath = window.location.pathname
|
|||||||
const adminPath = currentPath
|
const adminPath = currentPath
|
||||||
const isAdminPage = adminPath.startsWith('/admin')
|
const isAdminPage = adminPath.startsWith('/admin')
|
||||||
const isMqttForwardAdminPage = adminPath === '/admin/mqtt_forward' || adminPath === '/admin/mqtt_forward/'
|
const isMqttForwardAdminPage = adminPath === '/admin/mqtt_forward' || adminPath === '/admin/mqtt_forward/'
|
||||||
|
const isBotAdminPage = adminPath === '/admin/bot' || adminPath === '/admin/bot/'
|
||||||
const detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
|
const detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
|
||||||
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
|
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
|
||||||
const isDetailedPage = !!detailedNodeId
|
const isDetailedPage = !!detailedNodeId
|
||||||
@@ -493,6 +495,7 @@ onBeforeUnmount(() => {
|
|||||||
<a href="/admin/users" :class="{ active: adminPath === '/admin/users' }">用户管理</a>
|
<a href="/admin/users" :class="{ active: adminPath === '/admin/users' }">用户管理</a>
|
||||||
<a href="/admin/blocking_management" :class="{ active: adminPath === '/admin/blocking_management' }">屏蔽管理</a>
|
<a href="/admin/blocking_management" :class="{ active: adminPath === '/admin/blocking_management' }">屏蔽管理</a>
|
||||||
<a href="/admin/mqtt_forward/" :class="{ active: isMqttForwardAdminPage }">MQTT转发</a>
|
<a href="/admin/mqtt_forward/" :class="{ active: isMqttForwardAdminPage }">MQTT转发</a>
|
||||||
|
<a href="/admin/bot" :class="{ active: isBotAdminPage }">机器人</a>
|
||||||
<a href="/admin/help_edit" :class="{ active: adminPath === '/admin/help_edit' }">帮助编辑</a>
|
<a href="/admin/help_edit" :class="{ active: adminPath === '/admin/help_edit' }">帮助编辑</a>
|
||||||
<a href="/admin/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</a>
|
<a href="/admin/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</a>
|
||||||
<a href="/admin/discard_details" :class="{ active: adminPath === '/admin/discard_details' }">丢弃数据</a>
|
<a href="/admin/discard_details" :class="{ active: adminPath === '/admin/discard_details' }">丢弃数据</a>
|
||||||
@@ -532,6 +535,7 @@ onBeforeUnmount(() => {
|
|||||||
<AdminUsers v-if="adminPath === '/admin/users'" :user="adminUser" />
|
<AdminUsers v-if="adminPath === '/admin/users'" :user="adminUser" />
|
||||||
<AdminBlockingManagement v-else-if="adminPath === '/admin/blocking_management'" />
|
<AdminBlockingManagement v-else-if="adminPath === '/admin/blocking_management'" />
|
||||||
<AdminMqttForward v-else-if="isMqttForwardAdminPage" />
|
<AdminMqttForward v-else-if="isMqttForwardAdminPage" />
|
||||||
|
<AdminBot v-else-if="isBotAdminPage" />
|
||||||
<AdminHelpEdit v-else-if="adminPath === '/admin/help_edit'" />
|
<AdminHelpEdit v-else-if="adminPath === '/admin/help_edit'" />
|
||||||
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
|
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
|
||||||
<AdminDiscardDetails v-else-if="adminPath === '/admin/discard_details'" />
|
<AdminDiscardDetails v-else-if="adminPath === '/admin/discard_details'" />
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import type {
|
|||||||
AdminRuntimeSettingsPayload,
|
AdminRuntimeSettingsPayload,
|
||||||
AdminRuntimeSettingsResponse,
|
AdminRuntimeSettingsResponse,
|
||||||
AdminUsersResponse,
|
AdminUsersResponse,
|
||||||
|
BotMessage,
|
||||||
|
BotMessageMutationResponse,
|
||||||
|
BotNode,
|
||||||
|
BotNodeMutationResponse,
|
||||||
|
BotNodePayload,
|
||||||
|
BotSendMessagePayload,
|
||||||
BlockingRuleResponse,
|
BlockingRuleResponse,
|
||||||
DiscardDetails,
|
DiscardDetails,
|
||||||
ForbiddenWordBlockingRule,
|
ForbiddenWordBlockingRule,
|
||||||
@@ -288,3 +294,31 @@ export function deleteMQTTForwardTopic(id: number): Promise<{ status: string }>
|
|||||||
export function getMQTTForwardStatus(): Promise<MQTTForwardStatusResponse> {
|
export function getMQTTForwardStatus(): Promise<MQTTForwardStatusResponse> {
|
||||||
return getJSON<MQTTForwardStatusResponse>('/api/admin/mqtt-forward/status')
|
return getJSON<MQTTForwardStatusResponse>('/api/admin/mqtt-forward/status')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getBotNodes(limit = 100, offset = 0): Promise<ListResponse<BotNode>> {
|
||||||
|
return getJSON<ListResponse<BotNode>>(listPath('/api/admin/bot/nodes', limit, offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBotNode(payload: BotNodePayload): Promise<BotNodeMutationResponse> {
|
||||||
|
return postJSON<BotNodeMutationResponse>('/api/admin/bot/nodes', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBotNode(id: number, payload: BotNodePayload): Promise<BotNodeMutationResponse> {
|
||||||
|
return putJSON<BotNodeMutationResponse>(`/api/admin/bot/nodes/${id}`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBotNode(id: number): Promise<{ status: string }> {
|
||||||
|
return deleteJSON<{ status: string }>(`/api/admin/bot/nodes/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBotMessages(botId = 0, limit = 100, offset = 0): Promise<ListResponse<BotMessage>> {
|
||||||
|
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })
|
||||||
|
if (botId > 0) {
|
||||||
|
params.set('bot_id', String(botId))
|
||||||
|
}
|
||||||
|
return getJSON<ListResponse<BotMessage>>(`/api/admin/bot/messages?${params.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendBotMessage(payload: BotSendMessagePayload): Promise<BotMessageMutationResponse> {
|
||||||
|
return postJSON<BotMessageMutationResponse>('/api/admin/bot/messages', payload)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,461 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { createBotNode, deleteBotNode, getBotMessages, getBotNodes, getNodeInfo, sendBotMessage, updateBotNode } from '../api'
|
||||||
|
import type { BotMessage, BotMessageStatus, BotMessageType, BotNode, BotNodePayload, NodeInfo } from '../types'
|
||||||
|
|
||||||
|
const botPageSize = 100
|
||||||
|
const messagePageSize = 100
|
||||||
|
const maxTextBytes = 200
|
||||||
|
|
||||||
|
const bots = ref<BotNode[]>([])
|
||||||
|
const messages = ref<BotMessage[]>([])
|
||||||
|
const targets = ref<NodeInfo[]>([])
|
||||||
|
const selectedBotId = ref<number | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const messageLoading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const sending = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const message = ref('')
|
||||||
|
const targetQuery = ref('')
|
||||||
|
|
||||||
|
const newBot = ref({ node_num: '', long_name: '', short_name: '', default_channel_id: 'LongFast', enabled: true })
|
||||||
|
const edits = ref<Record<number, { node_num: string; long_name: string; short_name: string; default_channel_id: string; topic_prefix: string; enabled: boolean }>>({})
|
||||||
|
const sendForm = ref<{ message_type: BotMessageType; channel_id: string; to_node_id: string; text: string }>({ message_type: 'channel', channel_id: 'LongFast', to_node_id: '', text: '' })
|
||||||
|
|
||||||
|
const selectedBot = computed(() => bots.value.find((bot) => bot.id === selectedBotId.value) ?? null)
|
||||||
|
const enabledBots = computed(() => bots.value.filter((bot) => bot.enabled).length)
|
||||||
|
const sendTextBytes = computed(() => new TextEncoder().encode(sendForm.value.text).length)
|
||||||
|
const isTextTooLong = computed(() => sendTextBytes.value > maxTextBytes)
|
||||||
|
const recentMessages = computed(() => [...messages.value].sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)))
|
||||||
|
const targetOptions = computed(() => {
|
||||||
|
const query = targetQuery.value.trim().toLowerCase()
|
||||||
|
return targets.value
|
||||||
|
.filter((node) => node.node_id !== selectedBot.value?.node_id)
|
||||||
|
.filter((node) => {
|
||||||
|
if (!query) return true
|
||||||
|
return [node.node_id, node.long_name, node.short_name, String(node.node_num)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((value) => String(value).toLowerCase().includes(query))
|
||||||
|
})
|
||||||
|
.slice(0, 80)
|
||||||
|
})
|
||||||
|
const canSend = computed(() => {
|
||||||
|
if (!selectedBot.value || sending.value || isTextTooLong.value || !sendForm.value.text.trim()) return false
|
||||||
|
if (sendForm.value.message_type === 'direct' && !sendForm.value.to_node_id) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedBot, (bot) => {
|
||||||
|
if (bot) {
|
||||||
|
sendForm.value.channel_id = bot.default_channel_id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function botPayload(form: { node_num: string; long_name: string; short_name: string; default_channel_id: string; topic_prefix?: string; enabled: boolean }): BotNodePayload {
|
||||||
|
const nodeNumText = form.node_num.trim()
|
||||||
|
return {
|
||||||
|
node_num: nodeNumText ? Number(nodeNumText) : null,
|
||||||
|
long_name: form.long_name.trim(),
|
||||||
|
short_name: form.short_name.trim(),
|
||||||
|
default_channel_id: form.default_channel_id.trim(),
|
||||||
|
topic_prefix: form.topic_prefix?.trim() || 'msh/2/e',
|
||||||
|
enabled: form.enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetEdits() {
|
||||||
|
edits.value = Object.fromEntries(bots.value.map((bot) => [bot.id, {
|
||||||
|
node_num: String(bot.node_num),
|
||||||
|
long_name: bot.long_name,
|
||||||
|
short_name: bot.short_name,
|
||||||
|
default_channel_id: bot.default_channel_id,
|
||||||
|
topic_prefix: bot.topic_prefix,
|
||||||
|
enabled: bot.enabled,
|
||||||
|
}]))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshBots() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const response = await getBotNodes(botPageSize, 0)
|
||||||
|
bots.value = response.items
|
||||||
|
resetEdits()
|
||||||
|
if (!selectedBotId.value && bots.value.length > 0) {
|
||||||
|
selectBot(bots.value[0])
|
||||||
|
}
|
||||||
|
if (selectedBotId.value && !bots.value.some((bot) => bot.id === selectedBotId.value)) {
|
||||||
|
selectedBotId.value = bots.value[0]?.id ?? null
|
||||||
|
if (bots.value[0]) selectBot(bots.value[0])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshMessages() {
|
||||||
|
if (!selectedBotId.value) {
|
||||||
|
messages.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messageLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await getBotMessages(selectedBotId.value, messagePageSize, 0)
|
||||||
|
messages.value = response.items
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
messageLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTargets() {
|
||||||
|
try {
|
||||||
|
const response = await getNodeInfo(500, 0)
|
||||||
|
targets.value = response.items
|
||||||
|
} catch {
|
||||||
|
targets.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBot(bot: BotNode) {
|
||||||
|
selectedBotId.value = bot.id
|
||||||
|
sendForm.value.channel_id = bot.default_channel_id
|
||||||
|
refreshMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBot() {
|
||||||
|
saving.value = true
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
try {
|
||||||
|
await createBotNode(botPayload({ ...newBot.value, topic_prefix: 'msh/2/e' }))
|
||||||
|
newBot.value = { node_num: '', long_name: '', short_name: '', default_channel_id: 'LongFast', enabled: true }
|
||||||
|
message.value = '机器人已创建'
|
||||||
|
await refreshBots()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBot(bot: BotNode) {
|
||||||
|
const edit = edits.value[bot.id]
|
||||||
|
if (!edit) return
|
||||||
|
saving.value = true
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
try {
|
||||||
|
await updateBotNode(bot.id, botPayload(edit))
|
||||||
|
message.value = '机器人已保存'
|
||||||
|
await refreshBots()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeBot(bot: BotNode) {
|
||||||
|
if (!window.confirm(`确定删除机器人 ${bot.long_name} (${bot.node_id}) 吗?`)) return
|
||||||
|
saving.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await deleteBotNode(bot.id)
|
||||||
|
if (selectedBotId.value === bot.id) {
|
||||||
|
selectedBotId.value = null
|
||||||
|
messages.value = []
|
||||||
|
}
|
||||||
|
await refreshBots()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
if (!selectedBot.value) {
|
||||||
|
error.value = '请先选择机器人'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isTextTooLong.value) {
|
||||||
|
error.value = `消息过长,最多 ${maxTextBytes} bytes`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sending.value = true
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
try {
|
||||||
|
const response = await sendBotMessage({
|
||||||
|
bot_id: selectedBot.value.id,
|
||||||
|
message_type: sendForm.value.message_type,
|
||||||
|
channel_id: sendForm.value.channel_id || selectedBot.value.default_channel_id,
|
||||||
|
to_node_id: sendForm.value.message_type === 'direct' ? sendForm.value.to_node_id : undefined,
|
||||||
|
text: sendForm.value.text,
|
||||||
|
})
|
||||||
|
if (response.error) {
|
||||||
|
error.value = response.error
|
||||||
|
} else {
|
||||||
|
message.value = '消息已发送'
|
||||||
|
sendForm.value.text = ''
|
||||||
|
}
|
||||||
|
await refreshMessages()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value: string | null) {
|
||||||
|
return value ? new Date(value).toLocaleString() : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(status: BotMessageStatus) {
|
||||||
|
return status === 'published' ? '已发送' : status === 'failed' ? '失败' : '等待中'
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetLabel(item: BotMessage) {
|
||||||
|
if (item.message_type === 'channel') return '频道广播'
|
||||||
|
return item.to_node_id ? `私聊 ${item.to_node_id}` : '定向消息'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refreshBots()
|
||||||
|
refreshTargets()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-bot-page">
|
||||||
|
<div class="panel bot-hero">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Meshtastic Bot</p>
|
||||||
|
<h2>机器人节点</h2>
|
||||||
|
<p class="hint">当前阶段使用频道 PSK 发送频道消息和定向消息;PKI 端到端私聊将在后续实现。</p>
|
||||||
|
</div>
|
||||||
|
<div class="bot-hero-actions">
|
||||||
|
<span class="stat-chip">总数 {{ bots.length }}</span>
|
||||||
|
<span class="stat-chip ok">启用 {{ enabledBots }}</span>
|
||||||
|
<button class="admin-button" @click="refreshBots" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<p v-if="message" class="success">{{ message }}</p>
|
||||||
|
|
||||||
|
<div class="bot-layout">
|
||||||
|
<aside class="panel bot-sidebar">
|
||||||
|
<div class="section-title">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Create</p>
|
||||||
|
<h3>新建机器人</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bot-form compact-form">
|
||||||
|
<label>节点号 <small>留空自动生成</small><input v-model="newBot.node_num" type="number" placeholder="305419896" /></label>
|
||||||
|
<label>长名称<input v-model="newBot.long_name" placeholder="MQTT Bot" /></label>
|
||||||
|
<label>短名称<input v-model="newBot.short_name" placeholder="BOT" /></label>
|
||||||
|
<label>默认频道<input v-model="newBot.default_channel_id" placeholder="LongFast" /></label>
|
||||||
|
<label class="inline"><input v-model="newBot.enabled" type="checkbox" /> 启用</label>
|
||||||
|
<button class="admin-button full" @click="createBot" :disabled="saving">创建机器人</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title list-title">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Nodes</p>
|
||||||
|
<h3>机器人列表</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="bots.length === 0" class="empty-state">暂无机器人</div>
|
||||||
|
<div class="bot-list">
|
||||||
|
<article v-for="bot in bots" :key="bot.id" class="bot-card" :class="{ selected: selectedBotId === bot.id, disabled: !bot.enabled }">
|
||||||
|
<button class="bot-select" @click="selectBot(bot)">
|
||||||
|
<span class="avatar">{{ bot.short_name.slice(0, 2).toUpperCase() }}</span>
|
||||||
|
<span class="bot-main">
|
||||||
|
<strong>{{ bot.long_name }}</strong>
|
||||||
|
<small>{{ bot.node_id }} · {{ bot.default_channel_id }}</small>
|
||||||
|
</span>
|
||||||
|
<span class="state-dot" :class="{ ok: bot.enabled }"></span>
|
||||||
|
</button>
|
||||||
|
<details class="bot-details">
|
||||||
|
<summary>编辑节点</summary>
|
||||||
|
<div v-if="edits[bot.id]" class="bot-edit compact-form">
|
||||||
|
<label>节点号<input v-model="edits[bot.id].node_num" type="number" /></label>
|
||||||
|
<label>长名称<input v-model="edits[bot.id].long_name" /></label>
|
||||||
|
<label>短名称<input v-model="edits[bot.id].short_name" /></label>
|
||||||
|
<label>默认频道<input v-model="edits[bot.id].default_channel_id" /></label>
|
||||||
|
<label>Topic 前缀<input v-model="edits[bot.id].topic_prefix" /></label>
|
||||||
|
<label class="inline"><input v-model="edits[bot.id].enabled" type="checkbox" /> 启用</label>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="admin-button" @click="saveBot(bot)" :disabled="saving">保存</button>
|
||||||
|
<button class="admin-button danger" @click="removeBot(bot)" :disabled="saving">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="bot-main-panel">
|
||||||
|
<template v-if="selectedBot">
|
||||||
|
<section class="panel selected-summary">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Selected Bot</p>
|
||||||
|
<h2>{{ selectedBot.long_name }} <small>{{ selectedBot.short_name }}</small></h2>
|
||||||
|
</div>
|
||||||
|
<div class="summary-grid">
|
||||||
|
<span><strong>{{ selectedBot.node_id }}</strong><small>Node ID</small></span>
|
||||||
|
<span><strong>{{ selectedBot.node_num }}</strong><small>Node Num</small></span>
|
||||||
|
<span><strong>{{ selectedBot.default_channel_id }}</strong><small>默认频道</small></span>
|
||||||
|
<span><strong>{{ selectedBot.enabled ? '启用' : '停用' }}</strong><small>状态</small></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel send-panel">
|
||||||
|
<div class="section-title">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Compose</p>
|
||||||
|
<h3>发送消息</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="segmented-control">
|
||||||
|
<button :class="{ active: sendForm.message_type === 'channel' }" @click="sendForm.message_type = 'channel'">频道广播</button>
|
||||||
|
<button :class="{ active: sendForm.message_type === 'direct' }" @click="sendForm.message_type = 'direct'">定向消息</button>
|
||||||
|
</div>
|
||||||
|
<div class="send-grid">
|
||||||
|
<label>频道 ID<input v-model="sendForm.channel_id" /></label>
|
||||||
|
<label v-if="sendForm.message_type === 'direct'">搜索目标<input v-model="targetQuery" placeholder="节点名 / !nodeid / node_num" /></label>
|
||||||
|
<label v-if="sendForm.message_type === 'direct'" class="wide">目标节点
|
||||||
|
<select v-model="sendForm.to_node_id">
|
||||||
|
<option value="">选择目标节点</option>
|
||||||
|
<option v-for="node in targetOptions" :key="node.node_id" :value="node.node_id">
|
||||||
|
{{ node.long_name || node.short_name || node.node_id }} · {{ node.node_id }} · {{ node.node_num }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">消息内容
|
||||||
|
<textarea v-model="sendForm.text" rows="4" placeholder="输入要发送的文本,真实设备是否接受定向消息取决于固件兼容性。"></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="send-actions">
|
||||||
|
<span class="hint" :class="{ warn: isTextTooLong }">{{ sendTextBytes }} / {{ maxTextBytes }} bytes</span>
|
||||||
|
<button class="admin-button send-button" @click="sendMessage" :disabled="!canSend">{{ sending ? '发送中...' : '发送消息' }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel history-panel">
|
||||||
|
<div class="history-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">History</p>
|
||||||
|
<h3>发送历史</h3>
|
||||||
|
</div>
|
||||||
|
<button class="admin-button secondary" @click="refreshMessages" :disabled="messageLoading">{{ messageLoading ? '刷新中...' : '刷新历史' }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="message-list">
|
||||||
|
<div v-if="recentMessages.length === 0" class="empty-state">暂无发送记录</div>
|
||||||
|
<article v-for="item in recentMessages" :key="item.id" class="message-card" :class="item.status">
|
||||||
|
<div class="message-head">
|
||||||
|
<div>
|
||||||
|
<span class="message-target">{{ targetLabel(item) }}</span>
|
||||||
|
<span class="message-time">{{ formatTime(item.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge" :class="item.status">{{ statusText(item.status) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="message-text">{{ item.text }}</p>
|
||||||
|
<div class="message-meta">
|
||||||
|
<span>{{ item.channel_id }}</span>
|
||||||
|
<span>#{{ item.packet_id }}</span>
|
||||||
|
<span>{{ item.encrypted ? 'AES-CTR' : '明文' }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="item.error" class="message-error">{{ item.error }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<div v-else class="panel empty-state large">请选择或创建一个机器人。</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-bot-page { display: grid; gap: 12px; }
|
||||||
|
.bot-hero, .selected-summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px; }
|
||||||
|
.bot-hero-actions, .row-actions, .history-header, .send-actions, .section-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||||
|
.hint { color: #64748b; font-size: 13px; }
|
||||||
|
.hint.warn { color: #b91c1c; font-weight: 800; }
|
||||||
|
.bot-layout { display: grid; grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); gap: 12px; align-items: start; }
|
||||||
|
.bot-sidebar, .bot-main-panel { display: grid; gap: 12px; }
|
||||||
|
.bot-sidebar { padding: 14px; }
|
||||||
|
.compact-form { display: grid; gap: 10px; }
|
||||||
|
.bot-form { border-bottom: 1px solid #e2e8f0; padding-bottom: 14px; }
|
||||||
|
.list-title { margin-top: 2px; }
|
||||||
|
label { display: grid; gap: 5px; color: #334155; font-size: 13px; font-weight: 800; }
|
||||||
|
label small { color: #64748b; font-weight: 600; }
|
||||||
|
label.inline { display: flex; align-items: center; gap: 8px; }
|
||||||
|
input, select, textarea { box-sizing: border-box; width: 100%; border: 1px solid #cbd5e1; border-radius: 10px; padding: 9px 11px; color: #0f172a; font: inherit; background: #fff; }
|
||||||
|
textarea { resize: vertical; line-height: 1.45; }
|
||||||
|
input:focus, select:focus, textarea:focus { outline: 2px solid #bfdbfe; border-color: #2563eb; }
|
||||||
|
.full { width: 100%; }
|
||||||
|
.bot-list { display: grid; gap: 10px; }
|
||||||
|
.bot-card { border: 1px solid #e2e8f0; border-radius: 14px; padding: 10px; background: #f8fafc; transition: border-color .15s, box-shadow .15s, background .15s; }
|
||||||
|
.bot-card.selected { border-color: #2563eb; background: #eff6ff; box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12); }
|
||||||
|
.bot-card.disabled { opacity: 0.72; }
|
||||||
|
.bot-select { display: grid; grid-template-columns: 42px 1fr auto; align-items: center; gap: 10px; width: 100%; border: 0; padding: 0; color: inherit; text-align: left; background: transparent; }
|
||||||
|
.avatar { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 12px; color: #1d4ed8; font-weight: 900; background: #dbeafe; }
|
||||||
|
.bot-main { display: grid; gap: 2px; min-width: 0; }
|
||||||
|
.bot-main strong, .bot-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.bot-main small { color: #64748b; }
|
||||||
|
.state-dot { width: 10px; height: 10px; border-radius: 999px; background: #cbd5e1; }
|
||||||
|
.state-dot.ok { background: #22c55e; }
|
||||||
|
.bot-details { margin-top: 10px; }
|
||||||
|
.bot-details summary { color: #2563eb; font-size: 13px; font-weight: 800; cursor: pointer; }
|
||||||
|
.bot-edit { margin-top: 10px; }
|
||||||
|
.selected-summary small { color: #64748b; font-size: 14px; }
|
||||||
|
.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(110px, 1fr)); gap: 8px; min-width: min(620px, 100%); }
|
||||||
|
.summary-grid span, .stat-chip { display: grid; gap: 3px; border-radius: 12px; padding: 10px 12px; background: #f8fafc; }
|
||||||
|
.summary-grid strong { color: #0f172a; }
|
||||||
|
.summary-grid small { color: #64748b; font-size: 12px; }
|
||||||
|
.stat-chip { display: inline-flex; align-items: center; color: #334155; font-size: 13px; font-weight: 800; background: #e2e8f0; }
|
||||||
|
.stat-chip.ok { color: #166534; background: #dcfce7; }
|
||||||
|
.send-panel, .history-panel { padding: 16px; display: grid; gap: 14px; }
|
||||||
|
.segmented-control { display: inline-flex; width: fit-content; border: 1px solid #cbd5e1; border-radius: 999px; padding: 3px; background: #f8fafc; }
|
||||||
|
.segmented-control button { border: 0; border-radius: 999px; padding: 8px 13px; color: #475569; font-weight: 800; background: transparent; }
|
||||||
|
.segmented-control button.active { color: #fff; background: #2563eb; }
|
||||||
|
.send-grid { display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 12px; }
|
||||||
|
.send-grid .wide { grid-column: 1 / -1; }
|
||||||
|
.send-button { min-width: 120px; }
|
||||||
|
.admin-button.secondary { color: #334155; background: #e2e8f0; }
|
||||||
|
.admin-button.danger { background: #dc2626; }
|
||||||
|
.message-list { display: grid; gap: 10px; max-height: 520px; overflow: auto; padding-right: 4px; }
|
||||||
|
.message-card { border: 1px solid #e2e8f0; border-radius: 14px; padding: 12px; background: #fff; }
|
||||||
|
.message-card.published { border-color: #bbf7d0; }
|
||||||
|
.message-card.failed { border-color: #fecaca; background: #fff7f7; }
|
||||||
|
.message-head { display: flex; justify-content: space-between; gap: 10px; }
|
||||||
|
.message-target { display: block; color: #0f172a; font-weight: 900; }
|
||||||
|
.message-time, .message-meta { color: #64748b; font-size: 12px; }
|
||||||
|
.message-text { margin: 10px 0; color: #0f172a; line-height: 1.45; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.message-meta { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.status-badge { border-radius: 999px; padding: 4px 8px; font-size: 12px; font-weight: 900; white-space: nowrap; }
|
||||||
|
.status-badge.published { color: #166534; background: #dcfce7; }
|
||||||
|
.status-badge.failed { color: #991b1b; background: #fee2e2; }
|
||||||
|
.status-badge.pending { color: #92400e; background: #fef3c7; }
|
||||||
|
.message-error { margin: 10px 0 0; border-radius: 10px; padding: 8px 10px; color: #991b1b; background: #fee2e2; }
|
||||||
|
.empty-state { color: #64748b; padding: 16px; border: 1px dashed #cbd5e1; border-radius: 14px; text-align: center; background: #f8fafc; }
|
||||||
|
.empty-state.large { min-height: 260px; display: grid; place-items: center; }
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.bot-layout { grid-template-columns: 1fr; }
|
||||||
|
.summary-grid { grid-template-columns: repeat(2, minmax(120px, 1fr)); }
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.bot-hero, .selected-summary { align-items: stretch; flex-direction: column; }
|
||||||
|
.send-grid, .summary-grid { grid-template-columns: 1fr; }
|
||||||
|
.bot-hero-actions { justify-content: flex-start; flex-wrap: wrap; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -401,3 +401,67 @@ export interface MQTTForwardMutationResponse<T> {
|
|||||||
export interface MQTTForwardStatusResponse {
|
export interface MQTTForwardStatusResponse {
|
||||||
items: MQTTForwardRuntimeStatus[]
|
items: MQTTForwardRuntimeStatus[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BotMessageType = 'channel' | 'direct'
|
||||||
|
export type BotMessageStatus = 'pending' | 'published' | 'failed'
|
||||||
|
|
||||||
|
export interface BotNode {
|
||||||
|
id: number
|
||||||
|
node_id: string
|
||||||
|
node_num: number
|
||||||
|
long_name: string
|
||||||
|
short_name: string
|
||||||
|
enabled: boolean
|
||||||
|
default_channel_id: string
|
||||||
|
topic_prefix: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BotNodePayload {
|
||||||
|
node_num?: number | null
|
||||||
|
long_name: string
|
||||||
|
short_name: string
|
||||||
|
enabled: boolean
|
||||||
|
default_channel_id: string
|
||||||
|
topic_prefix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BotNodeMutationResponse {
|
||||||
|
item: BotNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BotMessage {
|
||||||
|
id: number
|
||||||
|
bot_id: number
|
||||||
|
bot_node_id: string
|
||||||
|
bot_node_num: number
|
||||||
|
message_type: BotMessageType
|
||||||
|
channel_id: string
|
||||||
|
to_node_id: string | null
|
||||||
|
to_node_num: number | null
|
||||||
|
topic: string
|
||||||
|
packet_id: number
|
||||||
|
text: string
|
||||||
|
payload_len: number
|
||||||
|
encrypted: boolean
|
||||||
|
status: BotMessageStatus
|
||||||
|
error: string
|
||||||
|
published_at: string | null
|
||||||
|
created_by: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BotSendMessagePayload {
|
||||||
|
bot_id: number
|
||||||
|
message_type: BotMessageType
|
||||||
|
channel_id: string
|
||||||
|
to_node_id?: string
|
||||||
|
to_node_num?: number | null
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BotMessageMutationResponse {
|
||||||
|
item: BotMessage
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package mqtpp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/encoding/protowire"
|
||||||
|
)
|
||||||
|
|
||||||
|
const NodeNumBroadcast uint32 = 0xffffffff
|
||||||
|
|
||||||
|
type TextMessageBuildOptions struct {
|
||||||
|
FromNodeNum uint32
|
||||||
|
ToNodeNum uint32
|
||||||
|
PacketID uint32
|
||||||
|
ChannelID string
|
||||||
|
GatewayID string
|
||||||
|
Text string
|
||||||
|
PSK []byte
|
||||||
|
Encrypt bool
|
||||||
|
ViaMQTT bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildTextMessageServiceEnvelope(opts TextMessageBuildOptions) ([]byte, error) {
|
||||||
|
if opts.FromNodeNum == 0 {
|
||||||
|
return nil, fmt.Errorf("from node number is required")
|
||||||
|
}
|
||||||
|
if opts.PacketID == 0 {
|
||||||
|
return nil, fmt.Errorf("packet id is required")
|
||||||
|
}
|
||||||
|
if opts.ChannelID == "" {
|
||||||
|
return nil, fmt.Errorf("channel id is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(opts.GatewayID) == "" {
|
||||||
|
opts.GatewayID = NodeNumToID(opts.FromNodeNum)
|
||||||
|
}
|
||||||
|
if opts.Text == "" {
|
||||||
|
return nil, fmt.Errorf("text is required")
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(opts.Text) {
|
||||||
|
return nil, fmt.Errorf("text must be valid utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
data := buildDataPacket(textMessageApp, []byte(opts.Text))
|
||||||
|
packet, err := buildMeshPacket(opts, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buildServiceEnvelope(packet, opts.ChannelID, opts.GatewayID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NodeNumToID(nodeNum uint32) string {
|
||||||
|
return nodeNumToID(nodeNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseNodeID(nodeID string) (uint32, error) {
|
||||||
|
value := strings.TrimSpace(nodeID)
|
||||||
|
if value == "" {
|
||||||
|
return 0, fmt.Errorf("node id is required")
|
||||||
|
}
|
||||||
|
value = strings.TrimPrefix(value, "!")
|
||||||
|
if len(value) != 8 {
|
||||||
|
return 0, fmt.Errorf("node id must be !xxxxxxxx")
|
||||||
|
}
|
||||||
|
num, err := strconv.ParseUint(value, 16, 32)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid node id: %w", err)
|
||||||
|
}
|
||||||
|
return uint32(num), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDataPacket(portnum uint32, payload []byte) []byte {
|
||||||
|
var out []byte
|
||||||
|
out = protowire.AppendTag(out, 1, protowire.VarintType)
|
||||||
|
out = protowire.AppendVarint(out, uint64(portnum))
|
||||||
|
out = protowire.AppendTag(out, 2, protowire.BytesType)
|
||||||
|
out = protowire.AppendBytes(out, payload)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMeshPacket(opts TextMessageBuildOptions, data []byte) ([]byte, error) {
|
||||||
|
var out []byte
|
||||||
|
out = protowire.AppendTag(out, 1, protowire.Fixed32Type)
|
||||||
|
out = protowire.AppendFixed32(out, opts.FromNodeNum)
|
||||||
|
out = protowire.AppendTag(out, 2, protowire.Fixed32Type)
|
||||||
|
out = protowire.AppendFixed32(out, opts.ToNodeNum)
|
||||||
|
|
||||||
|
if opts.Encrypt {
|
||||||
|
if len(opts.PSK) == 0 {
|
||||||
|
return nil, fmt.Errorf("psk is required for encrypted text message")
|
||||||
|
}
|
||||||
|
ciphertext, err := cryptAESCTR(opts.PSK, opts.FromNodeNum, opts.PacketID, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = protowire.AppendTag(out, 3, protowire.VarintType)
|
||||||
|
out = protowire.AppendVarint(out, uint64(channelHash(opts.ChannelID, opts.PSK)))
|
||||||
|
out = protowire.AppendTag(out, 5, protowire.BytesType)
|
||||||
|
out = protowire.AppendBytes(out, ciphertext)
|
||||||
|
} else {
|
||||||
|
out = protowire.AppendTag(out, 4, protowire.BytesType)
|
||||||
|
out = protowire.AppendBytes(out, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
out = protowire.AppendTag(out, 6, protowire.Fixed32Type)
|
||||||
|
out = protowire.AppendFixed32(out, opts.PacketID)
|
||||||
|
if opts.ViaMQTT {
|
||||||
|
out = protowire.AppendTag(out, 14, protowire.VarintType)
|
||||||
|
out = protowire.AppendVarint(out, 1)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildServiceEnvelope(packet []byte, channelID string, gatewayID string) []byte {
|
||||||
|
var out []byte
|
||||||
|
out = protowire.AppendTag(out, 1, protowire.BytesType)
|
||||||
|
out = protowire.AppendBytes(out, packet)
|
||||||
|
out = protowire.AppendTag(out, 2, protowire.BytesType)
|
||||||
|
out = protowire.AppendBytes(out, []byte(channelID))
|
||||||
|
out = protowire.AppendTag(out, 3, protowire.BytesType)
|
||||||
|
out = protowire.AppendBytes(out, []byte(gatewayID))
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package mqtpp
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBuildTextMessageServiceEnvelopeRoundTrip(t *testing.T) {
|
||||||
|
key, err := ExpandPSK("AQ==")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExpandPSK() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := BuildTextMessageServiceEnvelope(TextMessageBuildOptions{
|
||||||
|
FromNodeNum: 0x12345678,
|
||||||
|
ToNodeNum: NodeNumBroadcast,
|
||||||
|
PacketID: 0x87654321,
|
||||||
|
ChannelID: "LongFast",
|
||||||
|
GatewayID: "!12345678",
|
||||||
|
Text: "hello from bot",
|
||||||
|
PSK: key,
|
||||||
|
Encrypt: true,
|
||||||
|
ViaMQTT: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildTextMessageServiceEnvelope() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, _, record := MQTTPP("msh/2/e/LongFast/!12345678", raw, key, Options{})
|
||||||
|
if !valid {
|
||||||
|
t.Fatalf("MQTTPP() valid = false, record = %#v", record)
|
||||||
|
}
|
||||||
|
if record["type"] != "text_message" {
|
||||||
|
t.Fatalf("record type = %v", record["type"])
|
||||||
|
}
|
||||||
|
if record["text"] != "hello from bot" {
|
||||||
|
t.Fatalf("text = %v", record["text"])
|
||||||
|
}
|
||||||
|
if record["from_num"] != uint32(0x12345678) {
|
||||||
|
t.Fatalf("from_num = %v", record["from_num"])
|
||||||
|
}
|
||||||
|
if record["packet_to_num"] != uint32(NodeNumBroadcast) {
|
||||||
|
t.Fatalf("packet_to_num = %v", record["packet_to_num"])
|
||||||
|
}
|
||||||
|
if record["decrypt_success"] != true {
|
||||||
|
t.Fatalf("decrypt_success = %v", record["decrypt_success"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTextMessageServiceEnvelopeDirectRoundTrip(t *testing.T) {
|
||||||
|
key, err := ExpandPSK("AQ==")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExpandPSK() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := BuildTextMessageServiceEnvelope(TextMessageBuildOptions{
|
||||||
|
FromNodeNum: 0x12345678,
|
||||||
|
ToNodeNum: 0x10203040,
|
||||||
|
PacketID: 0x11111111,
|
||||||
|
ChannelID: "LongFast",
|
||||||
|
GatewayID: "!12345678",
|
||||||
|
Text: "direct hello",
|
||||||
|
PSK: key,
|
||||||
|
Encrypt: true,
|
||||||
|
ViaMQTT: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildTextMessageServiceEnvelope() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, _, record := MQTTPP("msh/2/e/LongFast/!12345678", raw, key, Options{})
|
||||||
|
if !valid {
|
||||||
|
t.Fatalf("MQTTPP() valid = false, record = %#v", record)
|
||||||
|
}
|
||||||
|
if record["text"] != "direct hello" {
|
||||||
|
t.Fatalf("text = %v", record["text"])
|
||||||
|
}
|
||||||
|
if record["packet_to"] != "!10203040" {
|
||||||
|
t.Fatalf("packet_to = %v", record["packet_to"])
|
||||||
|
}
|
||||||
|
if record["packet_to_num"] != uint32(0x10203040) {
|
||||||
|
t.Fatalf("packet_to_num = %v", record["packet_to_num"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNodeID(t *testing.T) {
|
||||||
|
num, err := ParseNodeID("!1234abcd")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseNodeID() error = %v", err)
|
||||||
|
}
|
||||||
|
if num != 0x1234abcd {
|
||||||
|
t.Fatalf("num = %#x", num)
|
||||||
|
}
|
||||||
|
if NodeNumToID(num) != "!1234abcd" {
|
||||||
|
t.Fatalf("NodeNumToID() = %s", NodeNumToID(num))
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-3
@@ -944,6 +944,11 @@ func channelHash(channelName string, key []byte) byte {
|
|||||||
|
|
||||||
// decryptAESCTR 按 Meshtastic nonce 规则使用 AES-CTR 解密 payload。
|
// decryptAESCTR 按 Meshtastic nonce 规则使用 AES-CTR 解密 payload。
|
||||||
func decryptAESCTR(key []byte, fromNum, packetID uint32, ciphertext []byte) ([]byte, error) {
|
func decryptAESCTR(key []byte, fromNum, packetID uint32, ciphertext []byte) ([]byte, error) {
|
||||||
|
return cryptAESCTR(key, fromNum, packetID, ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cryptAESCTR 按 Meshtastic nonce 规则执行 AES-CTR;CTR 加密和解密是同一个 XOR 流操作。
|
||||||
|
func cryptAESCTR(key []byte, fromNum, packetID uint32, input []byte) ([]byte, error) {
|
||||||
block, err := aes.NewCipher(key)
|
block, err := aes.NewCipher(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -951,9 +956,9 @@ func decryptAESCTR(key []byte, fromNum, packetID uint32, ciphertext []byte) ([]b
|
|||||||
nonce := make([]byte, aes.BlockSize)
|
nonce := make([]byte, aes.BlockSize)
|
||||||
binary.LittleEndian.PutUint64(nonce[0:8], uint64(packetID))
|
binary.LittleEndian.PutUint64(nonce[0:8], uint64(packetID))
|
||||||
binary.LittleEndian.PutUint32(nonce[8:12], fromNum)
|
binary.LittleEndian.PutUint32(nonce[8:12], fromNum)
|
||||||
plaintext := make([]byte, len(ciphertext))
|
output := make([]byte, len(input))
|
||||||
cipher.NewCTR(block, nonce).XORKeyStream(plaintext, ciphertext)
|
cipher.NewCTR(block, nonce).XORKeyStream(output, input)
|
||||||
return plaintext, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// enumName 把已知枚举值转换成名称,未知值保留为数字。
|
// enumName 把已知枚举值转换成名称,未知值保留为数字。
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newHTTPServer(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache) *http.Server {
|
func newHTTPServer(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache, botSender botTextSender) *http.Server {
|
||||||
return &http.Server{
|
return &http.Server{
|
||||||
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
|
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
|
||||||
Handler: newRouter(cfg, store, sessions, mqttStatus, blocking, forwarder, settings),
|
Handler: newRouter(cfg, store, sessions, mqttStatus, blocking, forwarder, settings, botSender),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,12 +47,12 @@ func serveHTTPUnixSocket(server *http.Server, socketPath string) error {
|
|||||||
return server.Serve(listener)
|
return server.Serve(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache) *gin.Engine {
|
func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache, botSender botTextSender) *gin.Engine {
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Logger(), gin.Recovery())
|
r.Use(gin.Logger(), gin.Recovery())
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
registerAPIRoutes(api, store)
|
registerAPIRoutes(api, store)
|
||||||
registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings)
|
registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings, botSender)
|
||||||
registerStaticRoutes(r, cfg.StaticDir)
|
registerStaticRoutes(r, cfg.StaticDir)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ func registerAPIRoutes(r gin.IRouter, store *store) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache) {
|
func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache, botSender botTextSender) {
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -187,6 +187,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
|||||||
registerAdminMQTTForwardRoutes(protected, store, forwarder)
|
registerAdminMQTTForwardRoutes(protected, store, forwarder)
|
||||||
registerAdminRuntimeSettingsRoutes(protected, store, settings)
|
registerAdminRuntimeSettingsRoutes(protected, store, settings)
|
||||||
registerAdminHelpRoutes(protected, store)
|
registerAdminHelpRoutes(protected, store)
|
||||||
|
registerAdminBotRoutes(protected, store, botSender)
|
||||||
protected.GET("/me", func(c *gin.Context) {
|
protected.GET("/me", func(c *gin.Context) {
|
||||||
claims := c.MustGet("admin_claims").(*sessionClaims)
|
claims := c.MustGet("admin_claims").(*sessionClaims)
|
||||||
c.JSON(http.StatusOK, gin.H{"user": adminUserDTO{Username: claims.Username, Role: claims.Role}})
|
c.JSON(http.StatusOK, gin.H{"user": adminUserDTO{Username: claims.Username, Role: claims.Role}})
|
||||||
|
|||||||
Reference in New Issue
Block a user