diff --git a/bot_service.go b/bot_service.go index c004ab2..7bc859f 100644 --- a/bot_service.go +++ b/bot_service.go @@ -3,7 +3,10 @@ package main import ( "context" "crypto/rand" + "encoding/base64" "encoding/binary" + "encoding/hex" + "errors" "fmt" "strings" "time" @@ -12,6 +15,7 @@ import ( "meshtastic_mqtt_server/mqtpp" mqtt "github.com/mochi-mqtt/server/v2" + "gorm.io/gorm" ) const botMaxTextBytes = 200 @@ -73,13 +77,6 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe if len([]byte(text)) > botMaxTextBytes { return nil, fmt.Errorf("text is too long, max %d bytes", botMaxTextBytes) } - channelID := strings.TrimSpace(req.ChannelID) - if channelID == "" { - channelID = bot.DefaultChannelID - } - if channelID == "" { - return nil, fmt.Errorf("channel id is required") - } toNodeNum, toNodeID, err := botMessageTarget(messageType, req) if err != nil { return nil, err @@ -88,6 +85,20 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe if err != nil { return nil, err } + fromNodeNum := uint32(bot.NodeNum) + + // direct 私聊走 PKI;channel 群聊保留旧的 AES-CTR + PSK 路径 + if messageType == 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 = botDefaultPSK @@ -96,7 +107,6 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe if err != nil { return nil, err } - fromNodeNum := uint32(bot.NodeNum) raw, err := mqtpp.BuildTextMessageServiceEnvelope(mqtpp.TextMessageBuildOptions{ PacketBuildOptions: mqtpp.PacketBuildOptions{ FromNodeNum: fromNodeNum, @@ -121,7 +131,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe MessageType: messageType, ChannelID: channelID, ToNodeID: toNodeID, - ToNodeNum: int64PtrOrNil(toNodeNum, messageType == botMessageTypeDirect), + ToNodeNum: int64PtrOrNil(toNodeNum, false), Topic: topic, PacketID: int64(packetID), Text: text, @@ -130,6 +140,98 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe Status: 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 *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum uint32, toNodeID *string, packetID uint32, text, createdBy string) (*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 := 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 := &botMessageRecord{ + BotID: bot.ID, + BotNodeID: bot.NodeID, + BotNodeNum: bot.NodeNum, + MessageType: 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: botMessageStatusPending, + CreatedBy: strings.TrimSpace(createdBy), + } + return s.persistAndPublish(row, topic, raw) +} + +// lookupRecipientPublicKey 从 nodeinfo 表中按 node_id 查询目标节点的 X25519 公钥(hex 编码)。 +func (s *botService) 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 *botService) persistAndPublish(row *botMessageRecord, topic string, raw []byte) (*botMessageRecord, error) { if err := s.store.InsertBotMessage(row); err != nil { return nil, err } diff --git a/meshmap_frontend/package-lock.json b/meshmap_frontend/package-lock.json index b6f6783..aeac2c1 100644 --- a/meshmap_frontend/package-lock.json +++ b/meshmap_frontend/package-lock.json @@ -69,10 +69,33 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -381,6 +404,40 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", @@ -728,7 +785,6 @@ "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1339,7 +1395,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1470,7 +1525,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1492,7 +1546,6 @@ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -1577,7 +1630,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz", "integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.35", "@vue/compiler-sfc": "3.5.35", diff --git a/meshmap_frontend/src/components/AdminBotDirect.vue b/meshmap_frontend/src/components/AdminBotDirect.vue index 9921bfd..6d22e8f 100644 --- a/meshmap_frontend/src/components/AdminBotDirect.vue +++ b/meshmap_frontend/src/components/AdminBotDirect.vue @@ -7,13 +7,14 @@ const chatPageSize = 30 const maxTextBytes = 200 const topThreshold = 8 const bottomThreshold = 40 +// 私聊固定走 PKI,channel_id 与固件 ServiceEnvelope 保持一致 +const directChannelId = 'PKI' const bots = ref([]) const targets = ref([]) const messages = ref([]) const selectedBotId = ref(null) const selectedTargetId = ref('') -const channelId = ref('LongFast') const text = ref('') const loading = ref(false) const sending = ref(false) @@ -33,7 +34,7 @@ let restoreMessageCount = 0 const selectedBot = computed(() => bots.value.find((item) => item.id === selectedBotId.value) ?? null) const selectedTarget = computed(() => targets.value.find((item) => item.node_id === selectedTargetId.value) ?? null) const directTextBytes = computed(() => new TextEncoder().encode(text.value).length) -const canSend = computed(() => !!selectedBot.value && !!selectedTarget.value && !!channelId.value.trim() && !!text.value.trim() && directTextBytes.value <= maxTextBytes && !sending.value) +const canSend = computed(() => !!selectedBot.value && !!selectedTarget.value && !!text.value.trim() && directTextBytes.value <= maxTextBytes && !sending.value) const groupedMessages = computed(() => { const groups = new Map() for (const item of messages.value) { @@ -49,8 +50,7 @@ const groupedMessages = computed(() => { return Array.from(groups.values()) }) -watch(selectedBot, (bot) => { - if (bot) channelId.value = bot.default_channel_id +watch(selectedBot, () => { resetChat() loadInitialMessages() }) @@ -60,11 +60,6 @@ watch(selectedTargetId, () => { loadInitialMessages() }) -watch(channelId, () => { - resetChat() - loadInitialMessages() -}) - function resetChat() { messages.value = [] hasMore.value = true @@ -110,7 +105,6 @@ async function refreshLists() { targets.value = nodeResponse.items if (!selectedBotId.value && bots.value.length > 0) { selectedBotId.value = bots.value[0].id - channelId.value = bots.value[0].default_channel_id } } catch (err) { error.value = err instanceof Error ? err.message : String(err) @@ -123,7 +117,7 @@ async function loadInitialMessages() { if (!selectedBot.value || !selectedTarget.value) return loadingOlder.value = true try { - const response = await getBotDirectTextMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, 0, channelId.value) + const response = await getBotDirectTextMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, 0, directChannelId) messages.value = toChronological(response.items) hasMore.value = response.items.length === chatPageSize initialized.value = true @@ -141,7 +135,7 @@ async function loadOlderMessages() { if (!selectedBot.value || !selectedTarget.value || loadingOlder.value || !hasMore.value) return loadingOlder.value = true try { - const response = await getBotDirectTextMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, messages.value.length, channelId.value) + const response = await getBotDirectTextMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, messages.value.length, directChannelId) messages.value = mergeMessages(messages.value, toChronological(response.items)) hasMore.value = response.items.length === chatPageSize } catch (err) { @@ -153,7 +147,7 @@ async function loadOlderMessages() { async function pollLatestMessages() { if (!selectedBot.value || !selectedTarget.value) return - const response = await getBotDirectTextMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, 0, channelId.value) + const response = await getBotDirectTextMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, 0, directChannelId) messages.value = mergeMessages(messages.value, toChronological(response.items)) } @@ -163,7 +157,7 @@ async function sendDirectMessage() { error.value = '' notice.value = '' try { - const response = await sendBotMessage({ bot_id: selectedBot.value.id, message_type: 'direct', channel_id: channelId.value, to_node_id: selectedTarget.value.node_id, text: text.value }) + const response = await sendBotMessage({ bot_id: selectedBot.value.id, message_type: 'direct', channel_id: directChannelId, to_node_id: selectedTarget.value.node_id, text: text.value }) if (response.error) { error.value = response.error } else { @@ -240,7 +234,7 @@ onBeforeUnmount(() => {

Direct Bot Chat

-

机器人私聊(功能未完成)

+

机器人私聊 PKI 加密

返回频道聊天 @@ -264,9 +258,10 @@ onBeforeUnmount(() => { -
+

私聊固定走 PKI(channel_id = "PKI"),需要目标节点已上报 NodeInfo 公钥才能加密。

+
正在加载更早消息...
没有更多历史消息
@@ -293,7 +288,9 @@ onBeforeUnmount(() => {