重构:拆出 bot / sign / mapsource / llmadmin / web 包

第三批(最终批):把 root 中剩下所有领域文件按功能搬到 internal/ 下。
完成后根目录从 46 个 .go 文件降到 14 个,其中 11 个是 bridge(type alias
+ thin wrapper),仅供尚未改造的 main.go 引用。

新增包
- internal/bot/         Service / TextSender / NewPKIKeyResolver /
                        RegisterRoutes,含 PKI 直连发送、节点信息广播、
                        outbound DM 持久化等业务逻辑。
- internal/sign/        SignDTO / SignDayCountDTO / RegisterAdminRoutes,
                        把原来分散在 admin_sign_routes.go 与 web.go 中的
                        sign DTO/路由收拢到一处。
- internal/mapsource/   AdminDTO / PublicDTO / RegisterAdminRoutes /
                        RegisterPublicRoutes。
- internal/llmadmin/    LLM 消息队列、Provider、ToolRouter、PrimaryConfig 的
                        admin 路由。
- internal/web/         路由总入口(NewRouter/NewHTTPServer/ServeUnixSocket)、
                        各资源的 GET API、admin 用户/登录/MQTT 状态、所有
                        DTO 函数。把 auth.go 的 sessionClaims 升级为
                        auth.SessionClaims;mqtt_status.go 重写成
                        MQTTRuntimeStatus / AdminMQTTStatus 结构体并把
                        client info 解析在 web 包内自带,不再依赖 main 包。
                        map_tile_proxy_routes 与测试一起搬入。

修改
- web.go 中 parseListOptions / writeListResponse / ptrString 等本地 helper
  改为对 internal/webutil 的 thin wrapper,避免重复实现。
- internal/auth 在 step 4 已创建,本批中 web 包正式开始引用其 Manager /
  RequireAdmin / SessionClaims。

根目录新增 bridge:bot_bridge.go / sign_bridge.go / mapsource_bridge.go /
llmadmin_bridge.go / web_bridge.go。后者把 mqttRuntimeStatus 包成
webpkg.MQTTStatusProvider 适配器,使 main.go 中旧字段名保持可用。

go build ./... / go test ./... 全部通过;测试数量未变。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-18 18:24:14 +08:00
co-authored by Claude
parent c527a9fd9a
commit 9394aa0f4a
18 changed files with 565 additions and 530 deletions
+681
View File
@@ -0,0 +1,681 @@
package bot
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
mqtt "github.com/mochi-mqtt/server/v2"
"gorm.io/gorm"
storepkg "meshtastic_mqtt_server/internal/store"
"meshtastic_mqtt_server/mqtpp"
)
const botMaxTextBytes = 200
type SendTextRequest struct {
BotID uint64
MessageType string
ChannelID string
ToNodeID string
ToNodeNum *int64
Text string
CreatedBy string
}
type TextSender interface {
SendText(ctx context.Context, req SendTextRequest) (*storepkg.BotMessageRecord, error)
PublishNodeInfoByID(ctx context.Context, id uint64) (*storepkg.BotNodeRecord, error)
}
type Service struct {
store *storepkg.Store
server *mqtt.Server
key []byte
}
func NewService(store *storepkg.Store, server *mqtt.Server, key []byte) *Service {
return &Service{store: store, server: server, key: key}
}
func (s *Service) StartNodeInfoBroadcaster(ctx context.Context) {
if s == nil || s.store == nil || s.server == nil {
return
}
go s.runNodeInfoBroadcaster(ctx)
}
// MaybeAutoAck 在收到一个发往本地 bot 的 want_ack 包时,回送一个 Routing-NONE ACK。
//
// 与固件 ReliableRouter::sniffReceived 行为对齐:
// - PKI 加密包(pki_encrypted=true)用 X25519+AES-CCM 加密 ACKchannel_id="PKI"
// - 其它情况用原 channel + bot PSK 加密
//
// 解析失败、目标不是受管 bot、或缺少必要的密钥时,安静返回不报错——这条路径只是“尽力”。
func (s *Service) MaybeAutoAck(record map[string]any) {
if s == nil || s.store == nil || s.server == nil || record == nil {
return
}
wantAck, _ := record["want_ack"].(bool)
if !wantAck {
return
}
toNum, ok := uint32FromRecord(record["packet_to_num"])
if !ok || toNum == 0 || toNum == mqtpp.NodeNumBroadcast {
return
}
fromNum, ok := uint32FromRecord(record["packet_from_num"])
if !ok || fromNum == 0 {
return
}
requestID, ok := uint32FromRecord(record["packet_id"])
if !ok || requestID == 0 {
return
}
bot, err := s.store.GetBotNodeByNodeNum(int64(toNum))
if err != nil || bot == nil || !bot.Enabled {
return
}
pkiEncrypted, _ := record["pki_encrypted"].(bool)
channelID, _ := record["channel_id"].(string)
ackPacketID, err := randomPacketID()
if err != nil {
return
}
topic := botMQTTTopic(bot.TopicPrefix, fallbackChannelID(channelID, pkiEncrypted, bot.DefaultChannelID), bot.NodeID)
var raw []byte
if pkiEncrypted {
raw, err = s.buildPKIAck(bot, fromNum, ackPacketID, requestID)
if err == nil {
topic = botMQTTTopic(bot.TopicPrefix, mqtpp.PKIChannelID, bot.NodeID)
}
} else {
raw, err = s.buildPSKAck(bot, fromNum, ackPacketID, requestID, channelID)
}
if err != nil || raw == nil {
printJSON(map[string]any{"event": "bot_auto_ack_skipped", "bot_node_id": bot.NodeID, "to": fromNum, "request_id": requestID, "error": errString(err)})
return
}
if err := s.server.Publish(topic, raw, false, 0); err != nil {
printJSON(map[string]any{"event": "bot_auto_ack_publish_failed", "bot_node_id": bot.NodeID, "topic": topic, "error": err.Error()})
}
}
func (s *Service) buildPKIAck(bot *storepkg.BotNodeRecord, toNum, ackPacketID, requestID uint32) ([]byte, error) {
privateKeyB64 := strings.TrimSpace(bot.PrivateKey)
if privateKeyB64 == "" {
return nil, fmt.Errorf("bot has no private key")
}
privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64)
if err != nil {
return nil, err
}
senderPublic, err := storepkg.DecodeBotPublicKey(*bot)
if err != nil {
return nil, err
}
recipientPublic, ok := s.store.LookupNodeInfoPublicKey(toNum)
if !ok {
return nil, fmt.Errorf("recipient %s has no public key on file", mqtpp.NodeNumToID(toNum))
}
return mqtpp.BuildPKIAckServiceEnvelope(mqtpp.PKIAckBuildOptions{
FromNodeNum: uint32(bot.NodeNum),
ToNodeNum: toNum,
PacketID: ackPacketID,
RequestID: requestID,
GatewayID: bot.NodeID,
ViaMQTT: true,
SenderPrivate: privateKey,
RecipientPub: recipientPublic,
SenderPublic: senderPublic,
})
}
func (s *Service) buildPSKAck(bot *storepkg.BotNodeRecord, toNum, ackPacketID, requestID uint32, channelID string) ([]byte, error) {
channel := fallbackChannelID(channelID, false, bot.DefaultChannelID)
if channel == "" || channel == mqtpp.PKIChannelID {
return nil, fmt.Errorf("no channel id available for psk ack")
}
psk := strings.TrimSpace(bot.PSK)
if psk == "" {
psk = storepkg.BotDefaultPSK
}
key, err := mqtpp.ExpandPSK(psk)
if err != nil {
return nil, err
}
return mqtpp.BuildAckServiceEnvelope(mqtpp.AckBuildOptions{
PacketBuildOptions: mqtpp.PacketBuildOptions{
FromNodeNum: uint32(bot.NodeNum),
ToNodeNum: toNum,
PacketID: ackPacketID,
ChannelID: channel,
GatewayID: bot.NodeID,
PSK: key,
Encrypt: true,
ViaMQTT: true,
},
RequestID: requestID,
})
}
func fallbackChannelID(channelID string, pkiEncrypted bool, defaultChannelID string) string {
channelID = strings.TrimSpace(channelID)
if pkiEncrypted {
return mqtpp.PKIChannelID
}
if channelID != "" && channelID != mqtpp.PKIChannelID {
return channelID
}
return defaultChannelID
}
func uint32FromRecord(value any) (uint32, bool) {
switch v := value.(type) {
case uint32:
return v, true
case int:
if v >= 0 {
return uint32(v), true
}
case int64:
if v >= 0 {
return uint32(v), true
}
case uint64:
return uint32(v), true
case float64:
if v >= 0 {
return uint32(v), true
}
}
return 0, false
}
func errString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func (s *Service) SendText(_ context.Context, req SendTextRequest) (*storepkg.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)
}
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)
// direct 私聊走 PKIchannel 群聊保留旧的 AES-CTR + PSK 路径
if messageType == storepkg.BotMessageTypeDirect {
return s.sendPKIDirect(bot, fromNodeNum, uint32(toNodeNum), toNodeID, packetID, text, req.CreatedBy)
}
channelID := strings.TrimSpace(req.ChannelID)
if channelID == "" {
channelID = bot.DefaultChannelID
}
if channelID == "" {
return nil, fmt.Errorf("channel id is required")
}
psk := strings.TrimSpace(bot.PSK)
if psk == "" {
psk = storepkg.BotDefaultPSK
}
key, err := mqtpp.ExpandPSK(psk)
if err != nil {
return nil, err
}
raw, err := mqtpp.BuildTextMessageServiceEnvelope(mqtpp.TextMessageBuildOptions{
PacketBuildOptions: mqtpp.PacketBuildOptions{
FromNodeNum: fromNodeNum,
ToNodeNum: uint32(toNodeNum),
PacketID: packetID,
ChannelID: channelID,
GatewayID: bot.NodeID,
PSK: key,
Encrypt: true,
ViaMQTT: true,
},
Text: text,
})
if err != nil {
return nil, err
}
topic := botMQTTTopic(bot.TopicPrefix, channelID, bot.NodeID)
row := &storepkg.BotMessageRecord{
BotID: bot.ID,
BotNodeID: bot.NodeID,
BotNodeNum: bot.NodeNum,
MessageType: messageType,
ChannelID: channelID,
ToNodeID: toNodeID,
ToNodeNum: int64PtrOrNil(toNodeNum, false),
Topic: topic,
PacketID: int64(packetID),
Text: text,
PayloadLen: int64(len(raw)),
Encrypted: true,
Status: storepkg.BotMessageStatusPending,
CreatedBy: strings.TrimSpace(req.CreatedBy),
}
return s.persistAndPublish(row, topic, raw)
}
// sendPKIDirect 按固件 PKI 流程发送私聊:
// - 从 nodeinfo 中查目标节点的 X25519 公钥
// - 用 bot 自身私钥与对端公钥派生共享密钥,AES-CCM(M=8,L=2) 加密
// - ServiceEnvelope.channel_id = "PKI"topic 也用 "PKI"
func (s *Service) sendPKIDirect(bot *storepkg.BotNodeRecord, fromNodeNum, toNodeNum uint32, toNodeID *string, packetID uint32, text, createdBy string) (*storepkg.BotMessageRecord, error) {
if toNodeID == nil {
return nil, fmt.Errorf("target node id is required for pki direct message")
}
privateKeyB64 := strings.TrimSpace(bot.PrivateKey)
if privateKeyB64 == "" {
return nil, fmt.Errorf("bot has no private key, regenerate keys first")
}
privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64)
if err != nil {
return nil, fmt.Errorf("invalid bot private key: %w", err)
}
senderPublic, err := storepkg.DecodeBotPublicKey(*bot)
if err != nil {
return nil, err
}
recipientPublic, err := s.lookupRecipientPublicKey(*toNodeID)
if err != nil {
return nil, err
}
raw, err := mqtpp.BuildPKITextMessageServiceEnvelope(mqtpp.PKITextMessageBuildOptions{
FromNodeNum: fromNodeNum,
ToNodeNum: toNodeNum,
PacketID: packetID,
GatewayID: bot.NodeID,
ViaMQTT: true,
SenderPrivate: privateKey,
RecipientPub: recipientPublic,
SenderPublic: senderPublic,
Text: text,
})
if err != nil {
return nil, err
}
topic := botMQTTTopic(bot.TopicPrefix, mqtpp.PKIChannelID, bot.NodeID)
row := &storepkg.BotMessageRecord{
BotID: bot.ID,
BotNodeID: bot.NodeID,
BotNodeNum: bot.NodeNum,
MessageType: storepkg.BotMessageTypeDirect,
ChannelID: mqtpp.PKIChannelID,
ToNodeID: toNodeID,
ToNodeNum: int64PtrOrNil(int64(toNodeNum), true),
Topic: topic,
PacketID: int64(packetID),
Text: text,
PayloadLen: int64(len(raw)),
Encrypted: true,
Status: storepkg.BotMessageStatusPending,
CreatedBy: strings.TrimSpace(createdBy),
}
result, err := s.persistAndPublish(row, topic, raw)
// 不论发送结果如何,都把 DM 镜像写入 bot_direct_messages 以驱动 /admin/bot/direct 渲染。
// 这里把发送结果(status/error/published_at)同步过去——成功时 status=published
// 失败时 status=failed,前端就能看到本地视图与发送日志一致。
s.recordOutboundDirectMessage(bot, row, *toNodeID, toNodeNum, text, len(raw), err)
return result, err
}
// recordOutboundDirectMessage 把出向 PKI DM 写入 bot_direct_messages。失败仅打日志。
func (s *Service) recordOutboundDirectMessage(bot *storepkg.BotNodeRecord, msg *storepkg.BotMessageRecord, peerNodeID string, peerNodeNum uint32, text string, payloadLen int, sendErr error) {
if s == nil || s.store == nil || msg == nil || bot == nil {
return
}
status := msg.Status
if status == "" {
if sendErr != nil {
status = storepkg.BotMessageStatusFailed
} else {
status = storepkg.BotMessageStatusPublished
}
}
errText := msg.Error
if errText == "" && sendErr != nil {
errText = sendErr.Error()
}
createdBy := strings.TrimSpace(msg.CreatedBy)
var createdByPtr *string
if createdBy != "" {
createdByPtr = &createdBy
}
gateway := strings.TrimSpace(bot.NodeID)
var gatewayPtr *string
if gateway != "" {
gatewayPtr = &gateway
}
var botMessageID *uint64
if msg.ID != 0 {
id := msg.ID
botMessageID = &id
}
now := time.Now()
dm := &storepkg.BotDirectMessageRecord{
BotID: bot.ID,
BotNodeID: bot.NodeID,
BotNodeNum: bot.NodeNum,
PeerNodeID: peerNodeID,
PeerNodeNum: int64(peerNodeNum),
Direction: storepkg.BotDirectMessageDirectionOutbound,
Topic: msg.Topic,
PacketID: msg.PacketID,
Text: text,
PayloadLen: int64(payloadLen),
PKIEncrypted: true,
WantAck: false, // 我们当前发送的 DM 默认不显式请求 ack
GatewayID: gatewayPtr,
Status: status,
Error: strings.TrimSpace(errText),
BotMessageID: botMessageID,
CreatedBy: createdByPtr,
PublishedAt: msg.PublishedAt,
// 出向消息从产生那一刻起就视为“已读”,未读计数只关心 inbound。
ReadAt: &now,
}
if err := s.store.InsertBotDirectMessage(dm); err != nil {
printJSON(map[string]any{
"event": "bot_direct_message_outbound_persist_failed",
"bot_node_id": bot.NodeID,
"peer_node_id": peerNodeID,
"bot_message_id": msg.ID,
"error": err.Error(),
})
}
}
// lookupRecipientPublicKey 从 nodeinfo 表中按 node_id 查询目标节点的 X25519 公钥(hex 编码)。
func (s *Service) lookupRecipientPublicKey(nodeID string) ([]byte, error) {
node, err := s.store.GetNodeInfo(nodeID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("recipient node %s not found in nodeinfo, cannot send PKI message", nodeID)
}
return nil, err
}
if node.PublicKey == nil || strings.TrimSpace(*node.PublicKey) == "" {
return nil, fmt.Errorf("recipient node %s has no public key on file", nodeID)
}
keyHex := strings.TrimSpace(*node.PublicKey)
keyBytes, err := hex.DecodeString(keyHex)
if err != nil {
// 兼容历史上可能存储为 base64 的情况
if alt, altErr := base64.StdEncoding.DecodeString(keyHex); altErr == nil {
keyBytes = alt
} else {
return nil, fmt.Errorf("invalid recipient public key for %s: %w", nodeID, err)
}
}
if len(keyBytes) != 32 {
return nil, fmt.Errorf("recipient public key for %s has unexpected length %d", nodeID, len(keyBytes))
}
return keyBytes, nil
}
// persistAndPublish 把消息记录入库后发布到 MQTT,统一处理失败状态写回。
func (s *Service) persistAndPublish(row *storepkg.BotMessageRecord, topic string, raw []byte) (*storepkg.BotMessageRecord, error) {
if err := s.store.InsertBotMessage(row); err != nil {
return nil, err
}
if s.server == nil {
_ = s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusFailed, "mqtt server is not configured", nil)
row.Status = storepkg.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, storepkg.BotMessageStatusFailed, err.Error(), nil)
row.Status = storepkg.BotMessageStatusFailed
row.Error = err.Error()
return row, err
}
now := time.Now()
if err := s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusPublished, "", &now); err != nil {
return nil, err
}
row.Status = storepkg.BotMessageStatusPublished
row.Error = ""
row.PublishedAt = &now
return row, nil
}
func (s *Service) 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 *Service) broadcastDueNodeInfo(ctx context.Context) {
rows, err := s.store.ListBotNodes(storepkg.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(storepkg.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 *Service) PublishNodeInfoByID(ctx context.Context, id uint64) (*storepkg.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 *Service) PublishNodeInfo(_ context.Context, bot storepkg.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 = storepkg.BotDefaultPSK
}
key, err := mqtpp.ExpandPSK(psk)
if err != nil {
return err
}
packetID, err := randomPacketID()
if err != nil {
return err
}
publicKey, err := storepkg.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
}
if s.store != nil {
valid, _, record := mqtpp.MQTTPP(topic, raw, key, mqtpp.Options{AllowEncryptedForwarding: true})
if valid && record["type"] == "nodeinfo" {
if err := s.store.UpsertNodeInfo(record); err != nil {
return err
}
}
}
return s.store.UpdateBotNodeInfoBroadcastAt(bot.ID, time.Now())
}
func normalizeBotMessageType(value string) (string, error) {
switch strings.TrimSpace(value) {
case "", storepkg.BotMessageTypeChannel:
return storepkg.BotMessageTypeChannel, nil
case storepkg.BotMessageTypeDirect:
return storepkg.BotMessageTypeDirect, nil
default:
return "", fmt.Errorf("message type must be channel or direct")
}
}
func botMessageTarget(messageType string, req SendTextRequest) (int64, *string, error) {
if messageType == storepkg.BotMessageTypeChannel {
return int64(mqtpp.NodeNumBroadcast), nil, nil
}
if req.ToNodeNum != nil && *req.ToNodeNum > 0 {
if err := storepkg.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 := storepkg.ValidateBotNodeNum(int64(nodeNum)); err != nil {
return 0, nil, err
}
normalized := mqtpp.NodeNumToID(nodeNum)
return int64(nodeNum), &normalized, nil
}
func botMQTTTopic(topicPrefix, channelID, nodeID string) string {
prefix := strings.Trim(strings.TrimSpace(topicPrefix), "/")
if prefix == "" {
prefix = storepkg.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
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
}