优化机器人私聊

This commit is contained in:
2026-06-14 20:14:52 +08:00
parent 67330d4656
commit 491876284e
9 changed files with 432 additions and 53 deletions
+36 -6
View File
@@ -139,12 +139,10 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"})
return
}
bot, err := store.GetBotNode(botID)
if errors.Is(err, gorm.ErrRecordNotFound) {
if _, err := store.GetBotNode(botID); errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"})
return
}
if err != nil {
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -153,8 +151,14 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target node num"})
return
}
rows, err := store.ListBotDirectTextMessages(bot.NodeNum, target, opts)
writeListResponse(c, rows, opts, err, textMessageDTO)
dmOpts := botDirectMessageListOptions{listOptions: opts, BotID: botID, PeerNodeNum: target, Direction: c.Query("direction")}
rows, err := store.ListBotDirectMessagesByConversation(dmOpts)
if err != nil {
writeListResponse(c, rows, opts, err, botDirectMessageDTO)
return
}
total, err := store.CountBotDirectMessagesByConversation(dmOpts)
writeListResponseWithTotal(c, rows, opts, total, err, botDirectMessageDTO)
})
r.POST("/bot/messages", func(c *gin.Context) {
if sender == nil {
@@ -238,3 +242,29 @@ func botNodeDTO(row botNodeRecord) gin.H {
func botMessageDTO(row botMessageRecord) gin.H {
return gin.H{"id": row.ID, "bot_id": row.BotID, "bot_node_id": row.BotNodeID, "bot_node_num": row.BotNodeNum, "message_type": row.MessageType, "channel_id": row.ChannelID, "to_node_id": row.ToNodeID, "to_node_num": row.ToNodeNum, "topic": row.Topic, "packet_id": row.PacketID, "text": row.Text, "payload_len": row.PayloadLen, "encrypted": row.Encrypted, "status": row.Status, "error": row.Error, "published_at": row.PublishedAt, "created_by": row.CreatedBy, "created_at": row.CreatedAt}
}
func botDirectMessageDTO(row botDirectMessageRecord) gin.H {
return gin.H{
"id": row.ID,
"bot_id": row.BotID,
"bot_node_id": row.BotNodeID,
"bot_node_num": row.BotNodeNum,
"peer_node_id": row.PeerNodeID,
"peer_node_num": row.PeerNodeNum,
"direction": row.Direction,
"topic": row.Topic,
"packet_id": row.PacketID,
"text": row.Text,
"payload_len": row.PayloadLen,
"pki_encrypted": row.PKIEncrypted,
"want_ack": row.WantAck,
"gateway_id": row.GatewayID,
"status": row.Status,
"error": row.Error,
"bot_message_id": row.BotMessageID,
"created_by": row.CreatedBy,
"published_at": row.PublishedAt,
"received_at": row.ReceivedAt,
"created_at": row.CreatedAt,
}
}
+218
View File
@@ -0,0 +1,218 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
)
type botDirectMessageListOptions struct {
listOptions
BotID uint64
PeerNodeNum int64
Direction string
}
// InsertBotDirectMessage 把一条机器人 DM(出向或入向)写入 bot_direct_messages 表。
func (s *store) InsertBotDirectMessage(row *botDirectMessageRecord) error {
if s == nil || s.db == nil {
return fmt.Errorf("store is not configured")
}
if row == nil {
return fmt.Errorf("bot direct message is required")
}
if row.Direction == "" {
return fmt.Errorf("bot direct message direction is required")
}
return s.db.Create(row).Error
}
// UpdateBotDirectMessageStatus 更新一条出向 DM 的发送状态(pending → published/failed)。
func (s *store) UpdateBotDirectMessageStatus(id uint64, status, errText string, publishedAt *time.Time) error {
if s == nil || s.db == nil {
return fmt.Errorf("store is not configured")
}
if id == 0 {
return fmt.Errorf("bot direct message id is required")
}
updates := map[string]any{
"status": status,
"error": strings.TrimSpace(errText),
"published_at": publishedAt,
}
result := s.db.Model(&botDirectMessageRecord{}).Where("id = ?", id).Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
// ListBotDirectMessagesByConversation 按 (bot, peer) 反序拉取 DM 历史,给 /admin/bot/direct 页面。
func (s *store) ListBotDirectMessagesByConversation(opts botDirectMessageListOptions) ([]botDirectMessageRecord, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("store is not configured")
}
if opts.BotID == 0 {
return nil, fmt.Errorf("bot id is required")
}
if opts.PeerNodeNum == 0 {
return nil, fmt.Errorf("peer node num is required")
}
opts.listOptions = normalizeListOptions(opts.listOptions)
var rows []botDirectMessageRecord
q := s.db.Model(&botDirectMessageRecord{}).
Where("bot_id = ? AND peer_node_num = ?", opts.BotID, opts.PeerNodeNum).
Order("created_at DESC").
Order("id DESC").
Limit(opts.Limit).
Offset(opts.Offset)
if opts.Direction != "" {
q = q.Where("direction = ?", opts.Direction)
}
if opts.Since != nil {
q = q.Where("created_at >= ?", *opts.Since)
}
if opts.Until != nil {
q = q.Where("created_at <= ?", *opts.Until)
}
return rows, q.Find(&rows).Error
}
// CountBotDirectMessagesByConversation 返回会话总条数(前端无限滚动可用,可选)。
func (s *store) CountBotDirectMessagesByConversation(opts botDirectMessageListOptions) (int64, error) {
if s == nil || s.db == nil {
return 0, fmt.Errorf("store is not configured")
}
if opts.BotID == 0 || opts.PeerNodeNum == 0 {
return 0, fmt.Errorf("bot id and peer node num are required")
}
var total int64
q := s.db.Model(&botDirectMessageRecord{}).
Where("bot_id = ? AND peer_node_num = ?", opts.BotID, opts.PeerNodeNum)
if opts.Direction != "" {
q = q.Where("direction = ?", opts.Direction)
}
if opts.Since != nil {
q = q.Where("created_at >= ?", *opts.Since)
}
if opts.Until != nil {
q = q.Where("created_at <= ?", *opts.Until)
}
return total, q.Count(&total).Error
}
// FindBotForIncomingPKIPacket 在 bot_direct_messages 写入路径上判断接收方是否为受管 bot。
// 返回的 bot 用于填充 BotID/BotNodeID/BotNodeNum;不命中时返回 ErrRecordNotFound。
func (s *store) FindBotForIncomingPKIPacket(toNodeNum int64) (*botNodeRecord, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("store is not configured")
}
bot, err := s.GetBotNodeByNodeNum(toNodeNum)
if err != nil {
return nil, err
}
if !bot.Enabled {
return nil, errors.New("bot disabled")
}
return bot, nil
}
// isInboundBotDirectMessage 判断 record 是否是“PKI 加密、发往受管 bot”的入向 DM。
// 仅在 type=text_message、pki_encrypted=true、packet_to_num 命中受管 bot 时返回 true。
// 任何步骤失败都返回 false,让记录回落到 text_message 表(与之前行为兼容)。
func isInboundBotDirectMessage(s *store, record map[string]any) bool {
if s == nil || record == nil {
return false
}
if pki, _ := record["pki_encrypted"].(bool); !pki {
return false
}
toNum, ok := uint32FromRecord(record["packet_to_num"])
if !ok || toNum == 0 {
return false
}
bot, err := s.FindBotForIncomingPKIPacket(int64(toNum))
if err != nil || bot == nil {
return false
}
return true
}
// insertInboundBotDirectMessage 把一条入向 PKI DM 转写入 bot_direct_messages 表。
// 失败时返回错误,由 dbWriteQueue 统一打印 db_error 事件。
func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo mqttClientInfo) error {
if s == nil {
return fmt.Errorf("store is not configured")
}
if record == nil {
return fmt.Errorf("record is required")
}
toNum, ok := uint32FromRecord(record["packet_to_num"])
if !ok || toNum == 0 {
return fmt.Errorf("missing packet_to_num")
}
bot, err := s.FindBotForIncomingPKIPacket(int64(toNum))
if err != nil {
return fmt.Errorf("lookup bot for inbound DM: %w", err)
}
peerNum, ok := uint32FromRecord(record["from_num"])
if !ok || peerNum == 0 {
return fmt.Errorf("missing from_num")
}
peerNodeID, _ := record["from"].(string)
if peerNodeID == "" {
return fmt.Errorf("missing from")
}
packetID, _ := uint32FromRecord(record["packet_id"])
topic, _ := record["topic"].(string)
gateway, _ := record["gateway_id"].(string)
var gatewayPtr *string
if gw := strings.TrimSpace(gateway); gw != "" {
gatewayPtr = &gw
}
text, _ := record["text"].(string)
wantAck, _ := record["want_ack"].(bool)
payloadLen, _ := record["payload_len"].(int)
if payloadLen == 0 {
if v, ok := record["payload_len"].(int64); ok {
payloadLen = int(v)
}
}
contentJSON, encodeErr := json.Marshal(record)
var contentPtr *string
if encodeErr == nil {
s := string(contentJSON)
contentPtr = &s
}
now := time.Now()
dm := &botDirectMessageRecord{
BotID: bot.ID,
BotNodeID: bot.NodeID,
BotNodeNum: bot.NodeNum,
PeerNodeID: peerNodeID,
PeerNodeNum: int64(peerNum),
Direction: botDirectMessageDirectionInbound,
Topic: topic,
PacketID: int64(packetID),
Text: text,
PayloadLen: int64(payloadLen),
PKIEncrypted: true,
WantAck: wantAck,
GatewayID: gatewayPtr,
Status: botMessageStatusPublished,
ReceivedAt: &now,
ContentJSON: contentPtr,
}
if err := s.InsertBotDirectMessage(dm); err != nil {
return fmt.Errorf("insert bot direct message from %s: %w", peerNodeID, err)
}
_ = clientInfo // mqtt 元数据已经记录在 content_json 里,这里保留参数以保持队列签名一致
return nil
}
+69 -1
View File
@@ -355,7 +355,75 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui
Status: botMessageStatusPending,
CreatedBy: strings.TrimSpace(createdBy),
}
return s.persistAndPublish(row, topic, raw)
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 *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *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 = botMessageStatusFailed
} else {
status = 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
}
dm := &botDirectMessageRecord{
BotID: bot.ID,
BotNodeID: bot.NodeID,
BotNodeNum: bot.NodeNum,
PeerNodeID: peerNodeID,
PeerNodeNum: int64(peerNodeNum),
Direction: 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,
}
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 编码)。
+43
View File
@@ -286,6 +286,47 @@ func (botMessageRecord) TableName() string {
return "bot_messages"
}
// botDirectMessageRecord 专门保存机器人参与的 PKI 私聊(DM)。
//
// - 设计原因:text_message 表只存频道消息;DM 是端到端的,逻辑上属于 “一对会话”,需要按
// bot+对端聚合渲染,与 text_message 全表浏览的形态不一样。
// - direction = "outbound" 表示 bot → device"inbound" 表示 device → bot。
// - 出向消息在发送时插入 status=pending,发送成功后更新为 published;入向消息默认直接
// published。两种方向都通过 bot_id/peer_node_num 索引快速回放会话。
type botDirectMessageRecord struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
BotID uint64 `gorm:"column:bot_id;not null;index:idx_bot_dm_bot_peer,priority:1;index:idx_bot_dm_bot_created_at,priority:1"`
BotNodeID string `gorm:"column:bot_node_id;not null;index"`
BotNodeNum int64 `gorm:"column:bot_node_num;not null;index"`
PeerNodeID string `gorm:"column:peer_node_id;not null;index:idx_bot_dm_bot_peer,priority:2"`
PeerNodeNum int64 `gorm:"column:peer_node_num;not null;index"`
Direction string `gorm:"column:direction;not null;index"`
Topic string `gorm:"column:topic;not null"`
PacketID int64 `gorm:"column:packet_id;not null;index"`
Text string `gorm:"column:text;type:text;not null"`
PayloadLen int64 `gorm:"column:payload_len;not null"`
PKIEncrypted bool `gorm:"column:pki_encrypted;not null"`
WantAck bool `gorm:"column:want_ack;not null"`
GatewayID *string `gorm:"column:gateway_id"`
Status string `gorm:"column:status;not null;index"`
Error string `gorm:"column:error;type:text"`
BotMessageID *uint64 `gorm:"column:bot_message_id;index"`
CreatedBy *string `gorm:"column:created_by"`
PublishedAt *time.Time `gorm:"column:published_at;index"`
ReceivedAt *time.Time `gorm:"column:received_at;index"`
ContentJSON *string `gorm:"column:content_json;type:text"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_bot_dm_bot_created_at,priority:2"`
}
func (botDirectMessageRecord) TableName() string {
return "bot_direct_messages"
}
const (
botDirectMessageDirectionInbound = "inbound"
botDirectMessageDirectionOutbound = "outbound"
)
type nodeInfoRecord struct {
NodeID string `gorm:"column:node_id;primaryKey;not null"`
NodeNum int64 `gorm:"column:node_num;not null;index"`
@@ -491,6 +532,7 @@ func (s *store) migrate() error {
{label: "mqtt_forward_topics", model: &mqttForwardTopicRecord{}},
{label: "bot_nodes", model: &botNodeRecord{}},
{label: "bot_messages", model: &botMessageRecord{}},
{label: "bot_direct_messages", model: &botDirectMessageRecord{}},
{label: "nodeinfo", model: &nodeInfoRecord{}},
{label: "map_report", model: &mapReportRecord{}},
{label: "text_message", model: &textMessageRecord{}},
@@ -511,6 +553,7 @@ func (s *store) migrate() error {
indexes []string
}{
{label: "text_message", model: &textMessageRecord{}, indexes: []string{"idx_text_message_from_num_created_at", "idx_text_message_created_at", "idx_text_message_packet_id"}},
{label: "bot_direct_messages", model: &botDirectMessageRecord{}, indexes: []string{"idx_bot_dm_bot_peer", "idx_bot_dm_bot_created_at"}},
} {
if err := createMissingIndexes(migrator, item.model, item.label, item.indexes); err != nil {
return err
+8
View File
@@ -43,6 +43,14 @@ func (q *dbWriteQueue) EnqueueRecord(record map[string]any, clientInfo mqttClien
return q.store.UpsertMapReport(record)
}})
case "text_message":
// 私聊(PKI 加密、发往受管 bot)单独走 bot_direct_messages 表,
// 不再写入 text_message 以避免和频道消息混在一起。
if isInboundBotDirectMessage(q.store, record) {
q.enqueue(dbWriteJob{typeName: "bot_direct_message_inbound", from: record["from"], run: func() error {
return insertInboundBotDirectMessage(q.store, record, clientInfo)
}})
return
}
q.enqueue(dbWriteJob{typeName: "text_message", from: record["from"], run: func() error {
return q.store.InsertTextMessage(record, clientInfo)
}})
+5 -4
View File
@@ -42,6 +42,7 @@ import type {
PublicMapTileSourcesResponse,
TelemetryRecord,
TextMessage,
BotDirectMessage,
} from './types'
async function requestJSON<T>(path: string, init?: RequestInit): Promise<T> {
@@ -377,12 +378,12 @@ export function getBotMessages(botId = 0, limit = 100, offset = 0): Promise<List
return getJSON<ListResponse<BotMessage>>(`/api/admin/bot/messages?${params.toString()}`)
}
export function getBotDirectTextMessages(botId: number, targetNodeNum: number, limit = 100, offset = 0, channelId = ''): Promise<ListResponse<TextMessage>> {
export function getBotDirectMessages(botId: number, targetNodeNum: number, limit = 100, offset = 0, direction = ''): Promise<ListResponse<BotDirectMessage>> {
const params = new URLSearchParams({ bot_id: String(botId), target_node_num: String(targetNodeNum), limit: String(limit), offset: String(offset) })
if (channelId) {
params.set('channel_id', channelId)
if (direction) {
params.set('direction', direction)
}
return getJSON<ListResponse<TextMessage>>(`/api/admin/bot/direct-messages?${params.toString()}`)
return getJSON<ListResponse<BotDirectMessage>>(`/api/admin/bot/direct-messages?${params.toString()}`)
}
export function sendBotMessage(payload: BotSendMessagePayload): Promise<BotMessageMutationResponse> {
@@ -1,18 +1,16 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onBeforeUpdate, onMounted, onUpdated, ref, watch } from 'vue'
import { getBotDirectTextMessages, getBotNodes, getNodeInfo, sendBotMessage } from '../api'
import type { BotNode, NodeInfo, TextMessage } from '../types'
import { getBotDirectMessages, getBotNodes, getNodeInfo, sendBotMessage } from '../api'
import type { BotDirectMessage, BotNode, NodeInfo } from '../types'
const chatPageSize = 30
const maxTextBytes = 200
const topThreshold = 8
const bottomThreshold = 40
// 私聊固定走 PKIchannel_id 与固件 ServiceEnvelope 保持一致
const directChannelId = 'PKI'
const bots = ref<BotNode[]>([])
const targets = ref<NodeInfo[]>([])
const messages = ref<TextMessage[]>([])
const messages = ref<BotDirectMessage[]>([])
const selectedBotId = ref<number | null>(null)
const selectedTargetId = ref('')
const text = ref('')
@@ -36,9 +34,10 @@ const selectedTarget = computed(() => targets.value.find((item) => item.node_id
const directTextBytes = computed(() => new TextEncoder().encode(text.value).length)
const canSend = computed(() => !!selectedBot.value && !!selectedTarget.value && !!text.value.trim() && directTextBytes.value <= maxTextBytes && !sending.value)
const groupedMessages = computed(() => {
const groups = new Map<string, TextMessage & { mergedCount: number; mergedMessages: TextMessage[] }>()
// 同一条 DM 在 inbound/outbound 两侧不会重复(来源是同一个表),但保留按 packet_id+text 合并的能力以容忍重复 publish。
const groups = new Map<string, BotDirectMessage & { mergedCount: number; mergedMessages: BotDirectMessage[] }>()
for (const item of messages.value) {
const key = `${item.packet_id ?? ''}\n${item.text ?? ''}`
const key = `${item.direction}\n${item.packet_id ?? ''}\n${item.text ?? ''}`
const group = groups.get(key)
if (group) {
group.mergedCount += 1
@@ -70,17 +69,17 @@ function resetChat() {
restoreMessageCount = 0
}
function toChronological(items: TextMessage[]) {
function toChronological(items: BotDirectMessage[]) {
return [...items].reverse()
}
function compareMessages(a: TextMessage, b: TextMessage) {
function compareMessages(a: BotDirectMessage, b: BotDirectMessage) {
const timeDiff = Date.parse(a.created_at) - Date.parse(b.created_at)
return timeDiff !== 0 ? timeDiff : a.id - b.id
}
function mergeMessages(existing: TextMessage[], incoming: TextMessage[]) {
const byId = new Map<number, TextMessage>()
function mergeMessages(existing: BotDirectMessage[], incoming: BotDirectMessage[]) {
const byId = new Map<number, BotDirectMessage>()
for (const item of existing) byId.set(item.id, item)
for (const item of incoming) byId.set(item.id, item)
return Array.from(byId.values()).sort(compareMessages)
@@ -117,7 +116,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, directChannelId)
const response = await getBotDirectMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, 0)
messages.value = toChronological(response.items)
hasMore.value = response.items.length === chatPageSize
initialized.value = true
@@ -135,7 +134,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, directChannelId)
const response = await getBotDirectMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, messages.value.length)
messages.value = mergeMessages(messages.value, toChronological(response.items))
hasMore.value = response.items.length === chatPageSize
} catch (err) {
@@ -147,7 +146,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, directChannelId)
const response = await getBotDirectMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, 0)
messages.value = mergeMessages(messages.value, toChronological(response.items))
}
@@ -157,7 +156,7 @@ async function sendDirectMessage() {
error.value = ''
notice.value = ''
try {
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 })
const response = await sendBotMessage({ bot_id: selectedBot.value.id, message_type: 'direct', channel_id: 'PKI', to_node_id: selectedTarget.value.node_id, text: text.value })
if (response.error) {
error.value = response.error
} else {
@@ -183,14 +182,20 @@ function handleScroll() {
loadOlderMessages()
}
function isOwn(item: TextMessage) {
return item.from_id === selectedBot.value?.node_id
function isOwn(item: BotDirectMessage) {
return item.direction === 'outbound'
}
function senderName(item: TextMessage) {
if (item.from_id === selectedBot.value?.node_id) return selectedBot.value?.long_name || item.from_id
if (item.from_id === selectedTarget.value?.node_id) return selectedTarget.value?.long_name || selectedTarget.value?.short_name || item.from_id
return item.from_id
function senderName(item: BotDirectMessage) {
if (item.direction === 'outbound') return selectedBot.value?.long_name || item.bot_node_id
return selectedTarget.value?.long_name || selectedTarget.value?.short_name || item.peer_node_id
}
function statusLabel(item: BotDirectMessage) {
if (item.direction !== 'outbound') return ''
if (item.status === 'failed') return `发送失败${item.error ? '' + item.error : ''}`
if (item.status === 'pending') return '发送中…'
return ''
}
function formatTime(value: string) {
@@ -270,6 +275,7 @@ onBeforeUnmount(() => {
<div class="chat-bubble">
<div class="bubble-meta"><strong>{{ senderName(item) }}</strong><small>{{ formatTime(item.created_at) }}</small></div>
<div class="bubble-text">{{ item.text || '[binary]' }} <span v-if="item.mergedCount > 1" class="message-merge-count">x{{ item.mergedCount }}</span></div>
<div v-if="statusLabel(item)" class="bubble-status">{{ statusLabel(item) }}</div>
<div class="bubble-topic">{{ item.topic }}</div>
</div>
</div>
@@ -303,6 +309,7 @@ input, select, textarea { box-sizing: border-box; width: 100%; border: 1px solid
.bubble-meta small, .bubble-topic, .hint { color: #64748b; }
.hint.warn { color: #b91c1c; font-weight: 800; }
.bubble-text { margin-top: 6px; color: #0f172a; line-height: 1.45; white-space: pre-wrap; word-break: break-word; }
.bubble-status { margin-top: 4px; color: #b91c1c; font-size: 11px; font-weight: 700; }
.bubble-topic { margin-top: 6px; font-size: 11px; word-break: break-all; }
.message-merge-count { display: inline-flex; margin-left: 6px; border-radius: 999px; padding: 1px 6px; color: #1d4ed8; background: #bfdbfe; font-size: 12px; font-weight: 800; }
.direct-composer { display: grid; gap: 10px; }
+25
View File
@@ -149,6 +149,31 @@ export interface TextMessage {
content_json: string
}
// 机器人 PKI 私聊(bot_direct_messages 表)。direction 区分本地 bot 视角的进出方向。
export interface BotDirectMessage {
id: number
bot_id: number
bot_node_id: string
bot_node_num: number
peer_node_id: string
peer_node_num: number
direction: 'inbound' | 'outbound'
topic: string
packet_id: number
text: string
payload_len: number
pki_encrypted: boolean
want_ack: boolean
gateway_id: string | null
status: string
error: string
bot_message_id: number | null
created_by: string | null
published_at: string | null
received_at: string | null
created_at: string
}
export interface PositionRecord {
id: number
from_id: string
-21
View File
@@ -265,27 +265,6 @@ func (s *store) ListTextMessages(opts listOptions) ([]textMessageRecord, error)
return rows, s.listAppendRows(opts, &rows).Error
}
func (s *store) ListBotDirectTextMessages(botNodeNum, targetNodeNum int64, opts listOptions) ([]textMessageRecord, error) {
opts = normalizeListOptions(opts)
var rows []textMessageRecord
q := s.db.Model(&textMessageRecord{}).
Where("(from_num = ? AND packet_to_num = ?) OR (from_num = ? AND packet_to_num = ?)", botNodeNum, targetNodeNum, targetNodeNum, botNodeNum).
Order("created_at DESC").
Order("id DESC").
Limit(opts.Limit).
Offset(opts.Offset)
if opts.ChannelID != "" {
q = q.Where("channel_id = ?", opts.ChannelID)
}
if opts.Since != nil {
q = q.Where("created_at >= ?", *opts.Since)
}
if opts.Until != nil {
q = q.Where("created_at <= ?", *opts.Until)
}
return rows, q.Find(&rows).Error
}
func (s *store) ListDiscardDetails(opts listOptions) ([]discardDetailsRecord, error) {
opts = normalizeListOptions(opts)
var rows []discardDetailsRecord