机器人功能还差发送nodeinfo

This commit is contained in:
2026-06-12 19:24:40 +08:00
parent 0db2b181cc
commit 609462252c
10 changed files with 629 additions and 83 deletions
+41 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+50
View File
@@ -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 {
+3
View File
@@ -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()
+8
View File
@@ -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) {
+90 -10
View File
@@ -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; }
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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 {