机器人功能还差发送nodeinfo
This commit is contained in:
+41
-2
@@ -16,6 +16,9 @@ type botNodeRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
DefaultChannelID string `json:"default_channel_id"`
|
||||
TopicPrefix string `json:"topic_prefix"`
|
||||
PSK string `json:"psk"`
|
||||
NodeInfoBroadcastEnabled bool `json:"nodeinfo_broadcast_enabled"`
|
||||
NodeInfoBroadcastIntervalSeconds int64 `json:"nodeinfo_broadcast_interval_seconds"`
|
||||
}
|
||||
|
||||
type botSendMessageRequest struct {
|
||||
@@ -77,6 +80,42 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
r.POST("/bot/nodes/:id/keys/regenerate", func(c *gin.Context) {
|
||||
id, ok := parseBotID(c, "invalid bot node id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
row, err := store.RegenerateBotNodeKeys(id)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"item": botNodeDTO(*row)})
|
||||
})
|
||||
r.POST("/bot/nodes/:id/nodeinfo", func(c *gin.Context) {
|
||||
if sender == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "bot sender is not configured"})
|
||||
return
|
||||
}
|
||||
id, ok := parseBotID(c, "invalid bot node id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
row, err := sender.PublishNodeInfoByID(c.Request.Context(), id)
|
||||
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(http.StatusOK, gin.H{"item": botNodeDTO(*row)})
|
||||
})
|
||||
r.GET("/bot/messages", func(c *gin.Context) {
|
||||
opts, ok := parseBotMessageListOptions(c)
|
||||
if !ok {
|
||||
@@ -120,7 +159,7 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
|
||||
}
|
||||
|
||||
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}
|
||||
return botNodeInput{NodeNum: req.NodeNum, LongName: req.LongName, ShortName: req.ShortName, Enabled: req.Enabled, DefaultChannelID: req.DefaultChannelID, TopicPrefix: req.TopicPrefix, PSK: req.PSK, NodeInfoBroadcastEnabled: req.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: req.NodeInfoBroadcastIntervalSeconds}
|
||||
}
|
||||
|
||||
func parseBotID(c *gin.Context, message string) (uint64, bool) {
|
||||
@@ -166,7 +205,7 @@ func writeBotNodeMutationResponse(c *gin.Context, status int, row *botNodeRecord
|
||||
}
|
||||
|
||||
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}
|
||||
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, "psk": row.PSK, "public_key": row.PublicKey, "private_key_set": row.PrivateKey != "", "nodeinfo_broadcast_enabled": row.NodeInfoBroadcastEnabled, "nodeinfo_broadcast_interval_seconds": row.NodeInfoBroadcastIntervalSeconds, "last_nodeinfo_broadcast_at": row.LastNodeInfoBroadcastAt, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||
}
|
||||
|
||||
func botMessageDTO(row botMessageRecord) gin.H {
|
||||
|
||||
+150
-3
@@ -28,6 +28,7 @@ type botSendTextRequest struct {
|
||||
|
||||
type botTextSender interface {
|
||||
SendText(ctx context.Context, req botSendTextRequest) (*botMessageRecord, error)
|
||||
PublishNodeInfoByID(ctx context.Context, id uint64) (*botNodeRecord, error)
|
||||
}
|
||||
|
||||
type botService struct {
|
||||
@@ -40,6 +41,13 @@ func newBotService(store *store, server *mqtt.Server, key []byte) *botService {
|
||||
return &botService{store: store, server: server, key: key}
|
||||
}
|
||||
|
||||
func (s *botService) StartNodeInfoBroadcaster(ctx context.Context) {
|
||||
if s == nil || s.store == nil || s.server == nil {
|
||||
return
|
||||
}
|
||||
go s.runNodeInfoBroadcaster(ctx)
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -80,22 +88,32 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
psk := strings.TrimSpace(bot.PSK)
|
||||
if psk == "" {
|
||||
psk = botDefaultPSK
|
||||
}
|
||||
key, err := mqtpp.ExpandPSK(psk)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fromNodeNum := uint32(bot.NodeNum)
|
||||
raw, err := mqtpp.BuildTextMessageServiceEnvelope(mqtpp.TextMessageBuildOptions{
|
||||
PacketBuildOptions: mqtpp.PacketBuildOptions{
|
||||
FromNodeNum: fromNodeNum,
|
||||
ToNodeNum: uint32(toNodeNum),
|
||||
PacketID: packetID,
|
||||
ChannelID: channelID,
|
||||
GatewayID: bot.NodeID,
|
||||
Text: text,
|
||||
PSK: s.key,
|
||||
PSK: key,
|
||||
Encrypt: true,
|
||||
ViaMQTT: true,
|
||||
},
|
||||
Text: text,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
topic := strings.Trim(bot.TopicPrefix, "/") + "/" + channelID + "/" + bot.NodeID
|
||||
topic := botMQTTTopic(bot.TopicPrefix, channelID, bot.NodeID)
|
||||
row := &botMessageRecord{
|
||||
BotID: bot.ID,
|
||||
BotNodeID: bot.NodeID,
|
||||
@@ -137,6 +155,124 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (s *botService) runNodeInfoBroadcaster(ctx context.Context) {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
s.broadcastDueNodeInfo(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.broadcastDueNodeInfo(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *botService) broadcastDueNodeInfo(ctx context.Context) {
|
||||
rows, err := s.store.ListBotNodes(listOptions{Limit: 500})
|
||||
if err != nil {
|
||||
printJSON(map[string]any{"event": "bot_nodeinfo_broadcast_failed", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
for _, bot := range rows {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if !bot.Enabled || !bot.NodeInfoBroadcastEnabled {
|
||||
continue
|
||||
}
|
||||
interval := time.Duration(bot.NodeInfoBroadcastIntervalSeconds) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = time.Duration(botDefaultNodeInfoBroadcastSeconds) * time.Second
|
||||
}
|
||||
if bot.LastNodeInfoBroadcastAt != nil && now.Sub(*bot.LastNodeInfoBroadcastAt) < interval {
|
||||
continue
|
||||
}
|
||||
if err := s.PublishNodeInfo(ctx, bot); err != nil {
|
||||
printJSON(map[string]any{"event": "bot_nodeinfo_broadcast_failed", "bot_id": bot.ID, "node_id": bot.NodeID, "error": err.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *botService) PublishNodeInfoByID(ctx context.Context, id uint64) (*botNodeRecord, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return nil, fmt.Errorf("bot service is not configured")
|
||||
}
|
||||
bot, err := s.store.GetBotNode(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !bot.Enabled {
|
||||
return nil, fmt.Errorf("bot node is disabled")
|
||||
}
|
||||
if err := s.PublishNodeInfo(ctx, *bot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := s.store.GetBotNode(id)
|
||||
if err != nil {
|
||||
return bot, nil
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error {
|
||||
if s == nil || s.server == nil {
|
||||
return fmt.Errorf("mqtt server is not configured")
|
||||
}
|
||||
if strings.TrimSpace(bot.PublicKey) == "" && s.store != nil {
|
||||
updated, err := s.store.RegenerateBotNodeKeys(bot.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bot = *updated
|
||||
}
|
||||
psk := strings.TrimSpace(bot.PSK)
|
||||
if psk == "" {
|
||||
psk = botDefaultPSK
|
||||
}
|
||||
key, err := mqtpp.ExpandPSK(psk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
packetID, err := randomPacketID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
publicKey, err := decodeBotPublicKey(bot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := mqtpp.BuildNodeInfoServiceEnvelope(mqtpp.NodeInfoBuildOptions{
|
||||
PacketBuildOptions: mqtpp.PacketBuildOptions{
|
||||
FromNodeNum: uint32(bot.NodeNum),
|
||||
ToNodeNum: mqtpp.NodeNumBroadcast,
|
||||
PacketID: packetID,
|
||||
ChannelID: bot.DefaultChannelID,
|
||||
GatewayID: bot.NodeID,
|
||||
PSK: key,
|
||||
Encrypt: true,
|
||||
ViaMQTT: true,
|
||||
},
|
||||
NodeID: bot.NodeID,
|
||||
LongName: bot.LongName,
|
||||
ShortName: bot.ShortName,
|
||||
HWModel: 255,
|
||||
Role: 0,
|
||||
IsLicensed: false,
|
||||
PublicKey: publicKey,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
topic := botMQTTTopic(bot.TopicPrefix, bot.DefaultChannelID, bot.NodeID)
|
||||
if err := s.server.Publish(topic, raw, false, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.UpdateBotNodeInfoBroadcastAt(bot.ID, time.Now())
|
||||
}
|
||||
|
||||
func normalizeBotMessageType(value string) (string, error) {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "", botMessageTypeChannel:
|
||||
@@ -174,6 +310,17 @@ func botMessageTarget(messageType string, req botSendTextRequest) (int64, *strin
|
||||
return int64(nodeNum), &normalized, nil
|
||||
}
|
||||
|
||||
func botMQTTTopic(topicPrefix, channelID, nodeID string) string {
|
||||
prefix := strings.Trim(strings.TrimSpace(topicPrefix), "/")
|
||||
if prefix == "" {
|
||||
prefix = botDefaultTopicPrefix
|
||||
}
|
||||
if strings.HasSuffix(prefix, "/2/e") {
|
||||
return prefix + "/" + channelID + "/" + nodeID
|
||||
}
|
||||
return prefix + "/2/e/" + channelID + "/" + nodeID
|
||||
}
|
||||
|
||||
func randomPacketID() (uint32, error) {
|
||||
for i := 0; i < 8; i++ {
|
||||
var buf [4]byte
|
||||
|
||||
+79
-2
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -15,7 +17,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
botDefaultTopicPrefix = "msh/2/e"
|
||||
botDefaultTopicPrefix = "msh/CN"
|
||||
botDefaultPSK = "AQ=="
|
||||
botDefaultNodeInfoBroadcastSeconds = int64(3600)
|
||||
botMessageTypeChannel = "channel"
|
||||
botMessageTypeDirect = "direct"
|
||||
botMessageStatusPending = "pending"
|
||||
@@ -32,6 +36,9 @@ type botNodeInput struct {
|
||||
Enabled bool
|
||||
DefaultChannelID string
|
||||
TopicPrefix string
|
||||
PSK string
|
||||
NodeInfoBroadcastEnabled bool
|
||||
NodeInfoBroadcastIntervalSeconds int64
|
||||
}
|
||||
|
||||
type botMessageListOptions struct {
|
||||
@@ -76,6 +83,9 @@ func (s *store) CreateBotNode(input botNodeInput) (*botNodeRecord, error) {
|
||||
if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(row.NodeNum); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := populateBotNodeKeys(row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Create(row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -107,6 +117,9 @@ func (s *store) UpdateBotNode(id uint64, input botNodeInput) (*botNodeRecord, er
|
||||
"enabled": row.Enabled,
|
||||
"default_channel_id": row.DefaultChannelID,
|
||||
"topic_prefix": row.TopicPrefix,
|
||||
"psk": row.PSK,
|
||||
"nodeinfo_broadcast_enabled": row.NodeInfoBroadcastEnabled,
|
||||
"nodeinfo_broadcast_interval_seconds": row.NodeInfoBroadcastIntervalSeconds,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if err := s.db.Model(&botNodeRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
@@ -142,6 +155,35 @@ func (s *store) UpdateBotMessageStatus(id uint64, status, errText string, publis
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) UpdateBotNodeInfoBroadcastAt(id uint64, t time.Time) error {
|
||||
result := s.db.Model(&botNodeRecord{}).Where("id = ?", id).Updates(map[string]any{"last_nodeinfo_broadcast_at": &t, "updated_at": time.Now()})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) RegenerateBotNodeKeys(id uint64) (*botNodeRecord, error) {
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("bot node id is required")
|
||||
}
|
||||
row, err := s.GetBotNode(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := populateBotNodeKeys(row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updates := map[string]any{"public_key": row.PublicKey, "private_key": row.PrivateKey, "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) ListBotMessages(opts botMessageListOptions) ([]botMessageRecord, error) {
|
||||
opts.listOptions = normalizeListOptions(opts.listOptions)
|
||||
var rows []botMessageRecord
|
||||
@@ -182,6 +224,13 @@ func (s *store) normalizedBotNodeRecord(input botNodeInput) (*botNodeRecord, err
|
||||
longName := strings.TrimSpace(input.LongName)
|
||||
shortName := strings.TrimSpace(input.ShortName)
|
||||
channelID := strings.TrimSpace(input.DefaultChannelID)
|
||||
psk := strings.TrimSpace(input.PSK)
|
||||
if psk == "" {
|
||||
psk = botDefaultPSK
|
||||
}
|
||||
if _, err := mqtpp.ExpandPSK(psk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
topicPrefix := strings.Trim(strings.TrimSpace(input.TopicPrefix), "/")
|
||||
if topicPrefix == "" {
|
||||
topicPrefix = botDefaultTopicPrefix
|
||||
@@ -201,6 +250,13 @@ func (s *store) normalizedBotNodeRecord(input botNodeInput) (*botNodeRecord, err
|
||||
if channelID == "" {
|
||||
return nil, fmt.Errorf("default channel id is required")
|
||||
}
|
||||
interval := input.NodeInfoBroadcastIntervalSeconds
|
||||
if interval <= 0 {
|
||||
interval = botDefaultNodeInfoBroadcastSeconds
|
||||
}
|
||||
if interval < 60 {
|
||||
return nil, fmt.Errorf("nodeinfo broadcast interval must be at least 60 seconds")
|
||||
}
|
||||
var nodeNum int64
|
||||
if input.NodeNum == nil || *input.NodeNum == 0 {
|
||||
generated, err := s.generateBotNodeNum()
|
||||
@@ -214,7 +270,28 @@ func (s *store) normalizedBotNodeRecord(input botNodeInput) (*botNodeRecord, err
|
||||
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
|
||||
return &botNodeRecord{NodeID: mqtpp.NodeNumToID(uint32(nodeNum)), NodeNum: nodeNum, LongName: longName, ShortName: shortName, Enabled: input.Enabled, DefaultChannelID: channelID, TopicPrefix: topicPrefix, PSK: psk, NodeInfoBroadcastEnabled: input.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: interval}, nil
|
||||
}
|
||||
|
||||
func populateBotNodeKeys(row *botNodeRecord) error {
|
||||
privateKey, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row.PrivateKey = base64.StdEncoding.EncodeToString(privateKey.Bytes())
|
||||
row.PublicKey = base64.StdEncoding.EncodeToString(privateKey.PublicKey().Bytes())
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeBotPublicKey(row botNodeRecord) ([]byte, error) {
|
||||
if strings.TrimSpace(row.PublicKey) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
key, err := base64.StdEncoding.DecodeString(row.PublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid bot public key: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func validateBotNodeNum(nodeNum int64) error {
|
||||
|
||||
@@ -246,6 +246,12 @@ type botNodeRecord struct {
|
||||
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"`
|
||||
PSK string `gorm:"column:psk;not null;size:64"`
|
||||
PublicKey string `gorm:"column:public_key;type:text"`
|
||||
PrivateKey string `gorm:"column:private_key;type:text"`
|
||||
NodeInfoBroadcastEnabled bool `gorm:"column:nodeinfo_broadcast_enabled;not null;index"`
|
||||
NodeInfoBroadcastIntervalSeconds int64 `gorm:"column:nodeinfo_broadcast_interval_seconds;not null"`
|
||||
LastNodeInfoBroadcastAt *time.Time `gorm:"column:last_nodeinfo_broadcast_at;index"`
|
||||
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"`
|
||||
@@ -510,6 +516,9 @@ func (s *store) migrate() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := migrateBotNodePSK(tx, migrator, s.driver); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateMapTileSourceHash(tx, migrator, s.driver); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -517,6 +526,47 @@ func (s *store) migrate() error {
|
||||
})
|
||||
}
|
||||
|
||||
func migrateBotNodePSK(tx *gorm.DB, migrator gorm.Migrator, driver string) error {
|
||||
if !migrator.HasTable(&botNodeRecord{}) {
|
||||
return nil
|
||||
}
|
||||
if !migrator.HasColumn(&botNodeRecord{}, "PSK") {
|
||||
if driver == databaseDriverSQLite {
|
||||
if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN psk TEXT NOT NULL DEFAULT 'AQ=='").Error; err != nil {
|
||||
return fmt.Errorf("migrate bot_nodes psk column: %w", err)
|
||||
}
|
||||
} else if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN psk VARCHAR(64) NOT NULL DEFAULT 'AQ=='").Error; err != nil {
|
||||
return fmt.Errorf("migrate bot_nodes psk column: %w", err)
|
||||
}
|
||||
}
|
||||
if !migrator.HasColumn(&botNodeRecord{}, "PublicKey") {
|
||||
if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN public_key text").Error; err != nil {
|
||||
return fmt.Errorf("migrate bot_nodes public_key column: %w", err)
|
||||
}
|
||||
}
|
||||
if !migrator.HasColumn(&botNodeRecord{}, "PrivateKey") {
|
||||
if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN private_key text").Error; err != nil {
|
||||
return fmt.Errorf("migrate bot_nodes private_key column: %w", err)
|
||||
}
|
||||
}
|
||||
if !migrator.HasColumn(&botNodeRecord{}, "NodeInfoBroadcastEnabled") {
|
||||
if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN nodeinfo_broadcast_enabled numeric NOT NULL DEFAULT true").Error; err != nil {
|
||||
return fmt.Errorf("migrate bot_nodes nodeinfo_broadcast_enabled column: %w", err)
|
||||
}
|
||||
}
|
||||
if !migrator.HasColumn(&botNodeRecord{}, "NodeInfoBroadcastIntervalSeconds") {
|
||||
if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN nodeinfo_broadcast_interval_seconds bigint NOT NULL DEFAULT 3600").Error; err != nil {
|
||||
return fmt.Errorf("migrate bot_nodes nodeinfo_broadcast_interval_seconds column: %w", err)
|
||||
}
|
||||
}
|
||||
if !migrator.HasColumn(&botNodeRecord{}, "LastNodeInfoBroadcastAt") {
|
||||
if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN last_nodeinfo_broadcast_at datetime NULL").Error; err != nil {
|
||||
return fmt.Errorf("migrate bot_nodes last_nodeinfo_broadcast_at column: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateMapTileSourceHash(tx *gorm.DB, migrator gorm.Migrator, driver string) error {
|
||||
if !migrator.HasColumn(&mapTileSourceRecord{}, "ProxyEnabled") {
|
||||
if driver == databaseDriverSQLite {
|
||||
|
||||
@@ -226,6 +226,9 @@ func run(cfg *config) error {
|
||||
return err
|
||||
}
|
||||
botSender := newBotService(store, server, cfg.key)
|
||||
botCtx, stopBotBroadcaster := context.WithCancel(context.Background())
|
||||
defer stopBotBroadcaster()
|
||||
botSender.StartNodeInfoBroadcaster(botCtx)
|
||||
forwardManager := newMQTTForwardManager(store)
|
||||
if err := forwardManager.StartFromStore(); err != nil {
|
||||
server.Close()
|
||||
|
||||
@@ -357,6 +357,14 @@ export function deleteBotNode(id: number): Promise<{ status: string }> {
|
||||
return deleteJSON<{ status: string }>(`/api/admin/bot/nodes/${id}`)
|
||||
}
|
||||
|
||||
export function broadcastBotNodeInfo(id: number): Promise<BotNodeMutationResponse> {
|
||||
return postJSON<BotNodeMutationResponse>(`/api/admin/bot/nodes/${id}/nodeinfo`)
|
||||
}
|
||||
|
||||
export function regenerateBotNodeKeys(id: number): Promise<BotNodeMutationResponse> {
|
||||
return postJSON<BotNodeMutationResponse>(`/api/admin/bot/nodes/${id}/keys/regenerate`)
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { createBotNode, deleteBotNode, getBotMessages, getBotNodes, getNodeInfo, sendBotMessage, updateBotNode } from '../api'
|
||||
import { broadcastBotNodeInfo, createBotNode, deleteBotNode, getBotMessages, getBotNodes, getNodeInfo, regenerateBotNodeKeys, sendBotMessage, updateBotNode } from '../api'
|
||||
import type { BotMessage, BotMessageStatus, BotMessageType, BotNode, BotNodePayload, NodeInfo } from '../types'
|
||||
|
||||
const botPageSize = 100
|
||||
@@ -15,12 +15,14 @@ const loading = ref(false)
|
||||
const messageLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const sending = ref(false)
|
||||
const broadcastingNodeInfo = ref(false)
|
||||
const regeneratingKeys = 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 newBot = ref({ node_num: '', long_name: '', short_name: '', default_channel_id: 'LongFast', topic_prefix: 'msh/CN', psk: 'AQ==', nodeinfo_broadcast_enabled: true, nodeinfo_broadcast_interval_seconds: '3600', enabled: true })
|
||||
const edits = ref<Record<number, { node_num: string; long_name: string; short_name: string; default_channel_id: string; topic_prefix: string; psk: string; nodeinfo_broadcast_enabled: boolean; nodeinfo_broadcast_interval_seconds: 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)
|
||||
@@ -52,14 +54,18 @@ watch(selectedBot, (bot) => {
|
||||
}
|
||||
})
|
||||
|
||||
function botPayload(form: { node_num: string; long_name: string; short_name: string; default_channel_id: string; topic_prefix?: string; enabled: boolean }): BotNodePayload {
|
||||
function botPayload(form: { node_num: string; long_name: string; short_name: string; default_channel_id: string; topic_prefix?: string; psk?: string; nodeinfo_broadcast_enabled?: boolean; nodeinfo_broadcast_interval_seconds?: string | number; enabled: boolean }): BotNodePayload {
|
||||
const nodeNumText = form.node_num.trim()
|
||||
const interval = Number(form.nodeinfo_broadcast_interval_seconds || 3600)
|
||||
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',
|
||||
topic_prefix: form.topic_prefix?.trim() || 'msh/CN',
|
||||
psk: form.psk?.trim() || 'AQ==',
|
||||
nodeinfo_broadcast_enabled: form.nodeinfo_broadcast_enabled ?? true,
|
||||
nodeinfo_broadcast_interval_seconds: Number.isFinite(interval) && interval > 0 ? interval : 3600,
|
||||
enabled: form.enabled,
|
||||
}
|
||||
}
|
||||
@@ -71,6 +77,9 @@ function resetEdits() {
|
||||
short_name: bot.short_name,
|
||||
default_channel_id: bot.default_channel_id,
|
||||
topic_prefix: bot.topic_prefix,
|
||||
psk: bot.psk || 'AQ==',
|
||||
nodeinfo_broadcast_enabled: bot.nodeinfo_broadcast_enabled,
|
||||
nodeinfo_broadcast_interval_seconds: String(bot.nodeinfo_broadcast_interval_seconds || 3600),
|
||||
enabled: bot.enabled,
|
||||
}]))
|
||||
}
|
||||
@@ -127,13 +136,22 @@ function selectBot(bot: BotNode) {
|
||||
refreshMessages()
|
||||
}
|
||||
|
||||
function applyBotUpdate(bot: BotNode) {
|
||||
const idx = bots.value.findIndex((item) => item.id === bot.id)
|
||||
if (idx >= 0) {
|
||||
bots.value.splice(idx, 1, bot)
|
||||
}
|
||||
resetEdits()
|
||||
selectedBotId.value = bot.id
|
||||
}
|
||||
|
||||
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 }
|
||||
await createBotNode(botPayload(newBot.value))
|
||||
newBot.value = { node_num: '', long_name: '', short_name: '', default_channel_id: 'LongFast', topic_prefix: 'msh/CN', psk: 'AQ==', nodeinfo_broadcast_enabled: true, nodeinfo_broadcast_interval_seconds: '3600', enabled: true }
|
||||
message.value = '机器人已创建'
|
||||
await refreshBots()
|
||||
} catch (err) {
|
||||
@@ -178,6 +196,45 @@ async function removeBot(bot: BotNode) {
|
||||
}
|
||||
}
|
||||
|
||||
async function broadcastNodeInfoNow() {
|
||||
if (!selectedBot.value) {
|
||||
error.value = '请先选择机器人'
|
||||
return
|
||||
}
|
||||
broadcastingNodeInfo.value = true
|
||||
error.value = ''
|
||||
message.value = ''
|
||||
try {
|
||||
const response = await broadcastBotNodeInfo(selectedBot.value.id)
|
||||
applyBotUpdate(response.item)
|
||||
message.value = 'NodeInfo 已广播'
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
broadcastingNodeInfo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerateKeys() {
|
||||
if (!selectedBot.value) {
|
||||
error.value = '请先选择机器人'
|
||||
return
|
||||
}
|
||||
if (!window.confirm('确定要重新生成该机器人的密钥吗?重新生成后,旧密钥将不能再用于 PKI 私聊。')) return
|
||||
regeneratingKeys.value = true
|
||||
error.value = ''
|
||||
message.value = ''
|
||||
try {
|
||||
const response = await regenerateBotNodeKeys(selectedBot.value.id)
|
||||
applyBotUpdate(response.item)
|
||||
message.value = '机器人密钥已重新生成'
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
regeneratingKeys.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!selectedBot.value) {
|
||||
error.value = '请先选择机器人'
|
||||
@@ -262,6 +319,10 @@ onMounted(() => {
|
||||
<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>MQTT 根地址 <small>最终发布到 根地址/2/e/频道/节点</small><input v-model="newBot.topic_prefix" placeholder="msh/CN" /></label>
|
||||
<label>频道密钥 PSK <small>默认 AQ==</small><input v-model="newBot.psk" placeholder="AQ==" /></label>
|
||||
<label>NodeInfo 间隔秒数<input v-model="newBot.nodeinfo_broadcast_interval_seconds" type="number" min="60" /></label>
|
||||
<label class="inline"><input v-model="newBot.nodeinfo_broadcast_enabled" type="checkbox" /> 定期广播 NodeInfo</label>
|
||||
<label class="inline"><input v-model="newBot.enabled" type="checkbox" /> 启用</label>
|
||||
<button class="admin-button full" @click="createBot" :disabled="saving">创建机器人</button>
|
||||
</div>
|
||||
@@ -290,7 +351,10 @@ onMounted(() => {
|
||||
<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>MQTT 根地址<input v-model="edits[bot.id].topic_prefix" placeholder="msh/CN" /></label>
|
||||
<label>频道密钥 PSK<input v-model="edits[bot.id].psk" placeholder="AQ==" /></label>
|
||||
<label>NodeInfo 间隔秒数<input v-model="edits[bot.id].nodeinfo_broadcast_interval_seconds" type="number" min="60" /></label>
|
||||
<label class="inline"><input v-model="edits[bot.id].nodeinfo_broadcast_enabled" type="checkbox" /> 定期广播 NodeInfo</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>
|
||||
@@ -309,10 +373,23 @@ onMounted(() => {
|
||||
<p class="eyebrow">Selected Bot</p>
|
||||
<h2>{{ selectedBot.long_name }} <small>{{ selectedBot.short_name }}</small></h2>
|
||||
</div>
|
||||
<div class="summary-actions">
|
||||
<button class="admin-button secondary" @click="regenerateKeys" :disabled="regeneratingKeys">
|
||||
{{ regeneratingKeys ? '生成中...' : '重新生成密钥' }}
|
||||
</button>
|
||||
<button class="admin-button secondary" @click="broadcastNodeInfoNow" :disabled="broadcastingNodeInfo || !selectedBot.enabled">
|
||||
{{ broadcastingNodeInfo ? '广播中...' : '立即广播 NodeInfo' }}
|
||||
</button>
|
||||
</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.topic_prefix || 'msh/CN' }}</strong><small>MQTT 根地址</small></span>
|
||||
<span><strong>{{ selectedBot.psk || 'AQ==' }}</strong><small>频道 PSK</small></span>
|
||||
<span><strong>{{ selectedBot.private_key_set ? '已生成' : '未生成' }}</strong><small>机器人密钥</small></span>
|
||||
<span class="public-key"><strong>{{ selectedBot.public_key || '-' }}</strong><small>Public Key</small></span>
|
||||
<span><strong>{{ selectedBot.nodeinfo_broadcast_enabled ? `${selectedBot.nodeinfo_broadcast_interval_seconds}s` : '关闭' }}</strong><small>NodeInfo 广播</small></span>
|
||||
<span><strong>{{ selectedBot.enabled ? '启用' : '停用' }}</strong><small>状态</small></span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -386,7 +463,7 @@ onMounted(() => {
|
||||
|
||||
<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, .selected-summary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; 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; }
|
||||
@@ -418,9 +495,12 @@ input:focus, select:focus, textarea:focus { outline: 2px solid #bfdbfe; border-c
|
||||
.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 { display: grid; grid-template-columns: repeat(4, minmax(110px, 1fr)); gap: 8px; flex: 1 1 100%; min-width: min(620px, 100%); }
|
||||
.summary-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.summary-grid span, .stat-chip { display: grid; gap: 3px; border-radius: 12px; padding: 10px 12px; background: #f8fafc; }
|
||||
.summary-grid strong { color: #0f172a; }
|
||||
.summary-grid .public-key { grid-column: span 2; }
|
||||
.summary-grid .public-key strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; }
|
||||
.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; }
|
||||
|
||||
@@ -452,6 +452,12 @@ export interface BotNode {
|
||||
enabled: boolean
|
||||
default_channel_id: string
|
||||
topic_prefix: string
|
||||
psk: string
|
||||
public_key: string
|
||||
private_key_set: boolean
|
||||
nodeinfo_broadcast_enabled: boolean
|
||||
nodeinfo_broadcast_interval_seconds: number
|
||||
last_nodeinfo_broadcast_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -463,6 +469,9 @@ export interface BotNodePayload {
|
||||
enabled: boolean
|
||||
default_channel_id: string
|
||||
topic_prefix?: string
|
||||
psk?: string
|
||||
nodeinfo_broadcast_enabled?: boolean
|
||||
nodeinfo_broadcast_interval_seconds?: number
|
||||
}
|
||||
|
||||
export interface BotNodeMutationResponse {
|
||||
|
||||
+78
-5
@@ -11,18 +11,33 @@ import (
|
||||
|
||||
const NodeNumBroadcast uint32 = 0xffffffff
|
||||
|
||||
type TextMessageBuildOptions struct {
|
||||
type PacketBuildOptions struct {
|
||||
FromNodeNum uint32
|
||||
ToNodeNum uint32
|
||||
PacketID uint32
|
||||
ChannelID string
|
||||
GatewayID string
|
||||
Text string
|
||||
PSK []byte
|
||||
Encrypt bool
|
||||
ViaMQTT bool
|
||||
}
|
||||
|
||||
type TextMessageBuildOptions struct {
|
||||
PacketBuildOptions
|
||||
Text string
|
||||
}
|
||||
|
||||
type NodeInfoBuildOptions struct {
|
||||
PacketBuildOptions
|
||||
NodeID string
|
||||
LongName string
|
||||
ShortName string
|
||||
HWModel uint32
|
||||
Role uint32
|
||||
IsLicensed bool
|
||||
PublicKey []byte
|
||||
}
|
||||
|
||||
func BuildTextMessageServiceEnvelope(opts TextMessageBuildOptions) ([]byte, error) {
|
||||
if opts.FromNodeNum == 0 {
|
||||
return nil, fmt.Errorf("from node number is required")
|
||||
@@ -44,7 +59,26 @@ func BuildTextMessageServiceEnvelope(opts TextMessageBuildOptions) ([]byte, erro
|
||||
}
|
||||
|
||||
data := buildDataPacket(textMessageApp, []byte(opts.Text))
|
||||
packet, err := buildMeshPacket(opts, data)
|
||||
packet, err := buildMeshPacket(opts.PacketBuildOptions, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildServiceEnvelope(packet, opts.ChannelID, opts.GatewayID), nil
|
||||
}
|
||||
|
||||
func BuildNodeInfoServiceEnvelope(opts NodeInfoBuildOptions) ([]byte, error) {
|
||||
if opts.NodeID == "" {
|
||||
opts.NodeID = NodeNumToID(opts.FromNodeNum)
|
||||
}
|
||||
if strings.TrimSpace(opts.LongName) == "" {
|
||||
return nil, fmt.Errorf("long name is required")
|
||||
}
|
||||
if strings.TrimSpace(opts.ShortName) == "" {
|
||||
return nil, fmt.Errorf("short name is required")
|
||||
}
|
||||
user := buildUserPacket(opts)
|
||||
data := buildDataPacket(nodeInfoApp, user)
|
||||
packet, err := buildMeshPacket(opts.PacketBuildOptions, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -80,7 +114,46 @@ func buildDataPacket(portnum uint32, payload []byte) []byte {
|
||||
return out
|
||||
}
|
||||
|
||||
func buildMeshPacket(opts TextMessageBuildOptions, data []byte) ([]byte, error) {
|
||||
func buildUserPacket(opts NodeInfoBuildOptions) []byte {
|
||||
var out []byte
|
||||
out = protowire.AppendTag(out, 1, protowire.BytesType)
|
||||
out = protowire.AppendBytes(out, []byte(opts.NodeID))
|
||||
out = protowire.AppendTag(out, 2, protowire.BytesType)
|
||||
out = protowire.AppendBytes(out, []byte(opts.LongName))
|
||||
out = protowire.AppendTag(out, 3, protowire.BytesType)
|
||||
out = protowire.AppendBytes(out, []byte(opts.ShortName))
|
||||
if opts.HWModel != 0 {
|
||||
out = protowire.AppendTag(out, 5, protowire.VarintType)
|
||||
out = protowire.AppendVarint(out, uint64(opts.HWModel))
|
||||
}
|
||||
out = protowire.AppendTag(out, 6, protowire.VarintType)
|
||||
if opts.IsLicensed {
|
||||
out = protowire.AppendVarint(out, 1)
|
||||
} else {
|
||||
out = protowire.AppendVarint(out, 0)
|
||||
}
|
||||
out = protowire.AppendTag(out, 7, protowire.VarintType)
|
||||
out = protowire.AppendVarint(out, uint64(opts.Role))
|
||||
if len(opts.PublicKey) > 0 {
|
||||
out = protowire.AppendTag(out, 8, protowire.BytesType)
|
||||
out = protowire.AppendBytes(out, opts.PublicKey)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildMeshPacket(opts PacketBuildOptions, data []byte) ([]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)
|
||||
}
|
||||
var out []byte
|
||||
out = protowire.AppendTag(out, 1, protowire.Fixed32Type)
|
||||
out = protowire.AppendFixed32(out, opts.FromNodeNum)
|
||||
@@ -89,7 +162,7 @@ func buildMeshPacket(opts TextMessageBuildOptions, data []byte) ([]byte, error)
|
||||
|
||||
if opts.Encrypt {
|
||||
if len(opts.PSK) == 0 {
|
||||
return nil, fmt.Errorf("psk is required for encrypted text message")
|
||||
return nil, fmt.Errorf("psk is required for encrypted packet")
|
||||
}
|
||||
ciphertext, err := cryptAESCTR(opts.PSK, opts.FromNodeNum, opts.PacketID, data)
|
||||
if err != nil {
|
||||
|
||||
+62
-2
@@ -9,15 +9,17 @@ func TestBuildTextMessageServiceEnvelopeRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
raw, err := BuildTextMessageServiceEnvelope(TextMessageBuildOptions{
|
||||
PacketBuildOptions: PacketBuildOptions{
|
||||
FromNodeNum: 0x12345678,
|
||||
ToNodeNum: NodeNumBroadcast,
|
||||
PacketID: 0x87654321,
|
||||
ChannelID: "LongFast",
|
||||
GatewayID: "!12345678",
|
||||
Text: "hello from bot",
|
||||
PSK: key,
|
||||
Encrypt: true,
|
||||
ViaMQTT: true,
|
||||
},
|
||||
Text: "hello from bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildTextMessageServiceEnvelope() error = %v", err)
|
||||
@@ -51,15 +53,17 @@ func TestBuildTextMessageServiceEnvelopeDirectRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
raw, err := BuildTextMessageServiceEnvelope(TextMessageBuildOptions{
|
||||
PacketBuildOptions: PacketBuildOptions{
|
||||
FromNodeNum: 0x12345678,
|
||||
ToNodeNum: 0x10203040,
|
||||
PacketID: 0x11111111,
|
||||
ChannelID: "LongFast",
|
||||
GatewayID: "!12345678",
|
||||
Text: "direct hello",
|
||||
PSK: key,
|
||||
Encrypt: true,
|
||||
ViaMQTT: true,
|
||||
},
|
||||
Text: "direct hello",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildTextMessageServiceEnvelope() error = %v", err)
|
||||
@@ -80,6 +84,62 @@ func TestBuildTextMessageServiceEnvelopeDirectRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNodeInfoServiceEnvelopeRoundTrip(t *testing.T) {
|
||||
key, err := ExpandPSK("AQ==")
|
||||
if err != nil {
|
||||
t.Fatalf("ExpandPSK() error = %v", err)
|
||||
}
|
||||
|
||||
raw, err := BuildNodeInfoServiceEnvelope(NodeInfoBuildOptions{
|
||||
PacketBuildOptions: PacketBuildOptions{
|
||||
FromNodeNum: 0x12345678,
|
||||
ToNodeNum: NodeNumBroadcast,
|
||||
PacketID: 0x22222222,
|
||||
ChannelID: "LongFast",
|
||||
GatewayID: "!12345678",
|
||||
PSK: key,
|
||||
Encrypt: true,
|
||||
ViaMQTT: true,
|
||||
},
|
||||
NodeID: "!12345678",
|
||||
LongName: "MQTT Bot",
|
||||
ShortName: "BT",
|
||||
HWModel: 255,
|
||||
Role: 0,
|
||||
IsLicensed: false,
|
||||
PublicKey: []byte{1, 2, 3},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildNodeInfoServiceEnvelope() 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"] != "nodeinfo" {
|
||||
t.Fatalf("record type = %v", record["type"])
|
||||
}
|
||||
if record["long_name"] != "MQTT Bot" {
|
||||
t.Fatalf("long_name = %v", record["long_name"])
|
||||
}
|
||||
if record["short_name"] != "BT" {
|
||||
t.Fatalf("short_name = %v", record["short_name"])
|
||||
}
|
||||
if record["hw_model"] != "PRIVATE_HW" {
|
||||
t.Fatalf("hw_model = %v", record["hw_model"])
|
||||
}
|
||||
if record["role"] != "CLIENT" {
|
||||
t.Fatalf("role = %v", record["role"])
|
||||
}
|
||||
if record["is_licensed"] != false {
|
||||
t.Fatalf("is_licensed = %v", record["is_licensed"])
|
||||
}
|
||||
if record["public_key"] != "010203" {
|
||||
t.Fatalf("public_key = %v", record["public_key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNodeID(t *testing.T) {
|
||||
num, err := ParseNodeID("!1234abcd")
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user