From 0024841ffb5d24fb56fae17b104144fd2589fd5f Mon Sep 17 00:00:00 2001 From: kevin Date: Sun, 14 Jun 2026 21:17:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=BA=E5=99=A8=E4=BA=BA?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E8=AE=B0=E5=BD=95=E4=B8=8E=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E6=9C=BA=E5=99=A8=E4=BA=BA=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 10 + admin_bot_routes.go | 77 ++++++ bot_direct_message_store.go | 75 +++++ bot_service.go | 3 + bot_store.go | 26 +- db.go | 33 ++- meshmap_frontend/src/api.ts | 14 + .../src/components/AdminBotDirect.vue | 256 +++++++++++++++--- meshmap_frontend/src/types.ts | 20 ++ 9 files changed, 460 insertions(+), 54 deletions(-) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..2b1508a --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(go build *)", + "Bash(go test *)", + "Bash(npx --no-install vue-tsc --noEmit)", + "Bash(echo \"EXIT=$?\")" + ] + } +} diff --git a/admin_bot_routes.go b/admin_bot_routes.go index e58e1cf..cab08b8 100644 --- a/admin_bot_routes.go +++ b/admin_bot_routes.go @@ -160,6 +160,69 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) { total, err := store.CountBotDirectMessagesByConversation(dmOpts) writeListResponseWithTotal(c, rows, opts, total, err, botDirectMessageDTO) }) + // /bot/conversations 返回某个 bot 下所有会话的概要(最后一条消息 + 未读数), + // 给前端侧边栏渲染会话列表使用。 + r.GET("/bot/conversations", func(c *gin.Context) { + opts, ok := parseListOptions(c) + if !ok { + return + } + botID, err := strconv.ParseUint(c.Query("bot_id"), 10, 64) + if err != nil || botID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"}) + return + } + if _, err := store.GetBotNode(botID); errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"}) + return + } else if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + rows, err := store.ListBotDirectConversations(botID, opts) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + unread, err := store.CountBotDirectUnread(botID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + items := make([]gin.H, 0, len(rows)) + for _, row := range rows { + items = append(items, botDirectConversationDTO(row)) + } + c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset, "unread_total": unread}) + }) + // /bot/direct-messages/read 把指定 (bot, peer) 下所有未读 inbound 消息标记为已读。 + r.POST("/bot/direct-messages/read", func(c *gin.Context) { + var req struct { + BotID uint64 `json:"bot_id"` + PeerNodeNum int64 `json:"peer_node_num"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mark-read request"}) + return + } + if req.BotID == 0 || req.PeerNodeNum == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "bot_id and peer_node_num are required"}) + return + } + if _, err := store.GetBotNode(req.BotID); errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"}) + return + } else if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + updated, err := store.MarkBotDirectMessagesRead(req.BotID, req.PeerNodeNum) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"updated": updated}) + }) r.POST("/bot/messages", func(c *gin.Context) { if sender == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "bot sender is not configured"}) @@ -265,6 +328,20 @@ func botDirectMessageDTO(row botDirectMessageRecord) gin.H { "created_by": row.CreatedBy, "published_at": row.PublishedAt, "received_at": row.ReceivedAt, + "read_at": row.ReadAt, "created_at": row.CreatedAt, } } + +func botDirectConversationDTO(row botDirectConversation) gin.H { + return gin.H{ + "bot_id": row.BotID, + "peer_node_id": row.PeerNodeID, + "peer_node_num": row.PeerNodeNum, + "last_message_at": row.LastMessageAt, + "last_text": row.LastText, + "last_direction": row.LastDirection, + "unread_count": row.UnreadCount, + "total_count": row.TotalCount, + } +} diff --git a/bot_direct_message_store.go b/bot_direct_message_store.go index 588848b..05c6ccc 100644 --- a/bot_direct_message_store.go +++ b/bot_direct_message_store.go @@ -124,6 +124,81 @@ func (s *store) FindBotForIncomingPKIPacket(toNodeNum int64) (*botNodeRecord, er return bot, nil } +// botDirectConversation 是 /admin/bot/direct 侧边栏需要的会话摘要。 +// LastMessageAt / LastText / LastDirection 描述会话最后一条消息,便于按时间排序与预览; +// UnreadCount 仅对 inbound 计数(即未读消息数)。 +type botDirectConversation struct { + BotID uint64 `gorm:"column:bot_id"` + PeerNodeID string `gorm:"column:peer_node_id"` + PeerNodeNum int64 `gorm:"column:peer_node_num"` + LastMessageAt time.Time `gorm:"column:last_message_at"` + LastText string `gorm:"column:last_text"` + LastDirection string `gorm:"column:last_direction"` + UnreadCount int64 `gorm:"column:unread_count"` + TotalCount int64 `gorm:"column:total_count"` +} + +// ListBotDirectConversations 聚合给定 bot 下的所有 (peer) 会话,返回最后一条消息及未读数。 +// 按最后一条消息时间倒序(最新会话排前面)。limit/offset 走 listOptions。 +func (s *store) ListBotDirectConversations(botID uint64, opts listOptions) ([]botDirectConversation, error) { + if s == nil || s.db == nil { + return nil, fmt.Errorf("store is not configured") + } + if botID == 0 { + return nil, fmt.Errorf("bot id is required") + } + opts = normalizeListOptions(opts) + var rows []botDirectConversation + // 先把每对会话的最后一条消息 ID 取出来,再把这条消息的元数据 join 回去; + // 同时聚合 unread_count(inbound 且 read_at IS NULL)和 total_count。 + // 这样的两步 join 避免在 GROUP BY 后引用非聚合列(MySQL 严格模式 / SQLite 兼容)。 + subLast := s.db.Model(&botDirectMessageRecord{}). + Select("bot_id, peer_node_id, peer_node_num, MAX(id) AS last_id, COUNT(*) AS total_count, SUM(CASE WHEN direction = ? AND read_at IS NULL THEN 1 ELSE 0 END) AS unread_count", botDirectMessageDirectionInbound). + Where("bot_id = ?", botID). + Group("bot_id, peer_node_id, peer_node_num") + q := s.db.Table("(?) AS agg", subLast). + Select("agg.bot_id AS bot_id, agg.peer_node_id AS peer_node_id, agg.peer_node_num AS peer_node_num, m.created_at AS last_message_at, m.text AS last_text, m.direction AS last_direction, agg.unread_count AS unread_count, agg.total_count AS total_count"). + Joins("JOIN bot_direct_messages m ON m.id = agg.last_id"). + Order("m.created_at DESC"). + Order("m.id DESC"). + Limit(opts.Limit). + Offset(opts.Offset) + return rows, q.Scan(&rows).Error +} + +// MarkBotDirectMessagesRead 把 (bot, peer) 下未读的 inbound 消息全部标记为已读,返回更新行数。 +func (s *store) MarkBotDirectMessagesRead(botID uint64, peerNodeNum int64) (int64, error) { + if s == nil || s.db == nil { + return 0, fmt.Errorf("store is not configured") + } + if botID == 0 || peerNodeNum == 0 { + return 0, fmt.Errorf("bot id and peer node num are required") + } + now := time.Now() + result := s.db.Model(&botDirectMessageRecord{}). + Where("bot_id = ? AND peer_node_num = ? AND direction = ? AND read_at IS NULL", botID, peerNodeNum, botDirectMessageDirectionInbound). + Update("read_at", &now) + if result.Error != nil { + return 0, result.Error + } + return result.RowsAffected, nil +} + +// CountBotDirectUnread 返回某个 bot 全部未读 inbound 消息总数(用于头部小红点)。 +func (s *store) CountBotDirectUnread(botID uint64) (int64, error) { + if s == nil || s.db == nil { + return 0, fmt.Errorf("store is not configured") + } + if botID == 0 { + return 0, fmt.Errorf("bot id is required") + } + var total int64 + err := s.db.Model(&botDirectMessageRecord{}). + Where("bot_id = ? AND direction = ? AND read_at IS NULL", botID, botDirectMessageDirectionInbound). + Count(&total).Error + return total, err +} + // isInboundBotDirectMessage 判断 record 是否是“PKI 加密、发往受管 bot”的入向 DM。 // 仅在 type=text_message、pki_encrypted=true、packet_to_num 命中受管 bot 时返回 true。 // 任何步骤失败都返回 false,让记录回落到 text_message 表(与之前行为兼容)。 diff --git a/bot_service.go b/bot_service.go index 65b3881..a8d3522 100644 --- a/bot_service.go +++ b/bot_service.go @@ -395,6 +395,7 @@ func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMes id := msg.ID botMessageID = &id } + now := time.Now() dm := &botDirectMessageRecord{ BotID: bot.ID, BotNodeID: bot.NodeID, @@ -414,6 +415,8 @@ func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMes BotMessageID: botMessageID, CreatedBy: createdByPtr, PublishedAt: msg.PublishedAt, + // 出向消息从产生那一刻起就视为“已读”,未读计数只关心 inbound。 + ReadAt: &now, } if err := s.store.InsertBotDirectMessage(dm); err != nil { printJSON(map[string]any{ diff --git a/bot_store.go b/bot_store.go index 6e60421..8f358bc 100644 --- a/bot_store.go +++ b/bot_store.go @@ -80,7 +80,7 @@ func (s *store) CreateBotNode(input botNodeInput) (*botNodeRecord, error) { if err := s.ensureBotNodeUnique(0, row.NodeID, row.NodeNum); err != nil { return nil, err } - if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(row.NodeNum); err != nil { + if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(row.NodeNum, row.NodeID); err != nil { return nil, err } if err := populateBotNodeKeys(row); err != nil { @@ -96,7 +96,8 @@ func (s *store) UpdateBotNode(id uint64, input botNodeInput) (*botNodeRecord, er if id == 0 { return nil, fmt.Errorf("bot node id is required") } - if _, err := s.GetBotNode(id); err != nil { + existing, err := s.GetBotNode(id) + if err != nil { return nil, err } row, err := s.normalizedBotNodeRecord(input) @@ -106,8 +107,13 @@ func (s *store) UpdateBotNode(id uint64, input botNodeInput) (*botNodeRecord, er if err := s.ensureBotNodeUnique(id, row.NodeID, row.NodeNum); err != nil { return nil, err } - if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(row.NodeNum); err != nil { - return nil, err + // 只有当 node_num 真的发生变化时,才需要校验和 nodeinfo 表的冲突。 + // 否则机器人自己广播 NodeInfo 回写到 nodeinfo 表后,UpdateBotNode 会把这条 + // 自己的记录当成外部节点冲突,导致 “already exists or conflicts” 报错。 + if row.NodeNum != existing.NodeNum { + if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(row.NodeNum, row.NodeID); err != nil { + return nil, err + } } updates := map[string]any{ "node_id": row.NodeID, @@ -317,7 +323,7 @@ func (s *store) generateBotNodeNum() (int64, error) { } return 0, err } - if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum); err != nil { + if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum, mqtpp.NodeNumToID(uint32(nodeNum))); err != nil { if errors.Is(err, errBotNodeAlreadyExists) { continue } @@ -344,9 +350,15 @@ func (s *store) ensureBotNodeUnique(id uint64, nodeID string, nodeNum int64) err return err } -func (s *store) ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum int64) error { +func (s *store) ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum int64, selfNodeID string) error { var existing nodeInfoRecord - err := s.db.Where("node_num = ?", nodeNum).Take(&existing).Error + q := s.db.Where("node_num = ?", nodeNum) + if selfNodeID != "" { + // 机器人自己广播 NodeInfo 后会以同样的 node_id/node_num 回写 nodeinfo; + // 把这条自身记录从冲突检测中排除,避免把自己当成外部节点。 + q = q.Where("node_id <> ?", selfNodeID) + } + err := q.Take(&existing).Error if err == nil { return errBotNodeAlreadyExists } diff --git a/db.go b/db.go index 82980a3..c2494a2 100644 --- a/db.go +++ b/db.go @@ -314,8 +314,11 @@ type botDirectMessageRecord struct { 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"` + // ReadAt 仅对 inbound 消息有意义:管理员在前端打开会话视为“已读”,会通过 read API 写入此字段。 + // 出向消息默认在创建时就设置为已读,避免出现在未读统计里。 + ReadAt *time.Time `gorm:"column:read_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 { @@ -562,6 +565,9 @@ func (s *store) migrate() error { if err := migrateBotNodePSK(tx, migrator, s.driver); err != nil { return err } + if err := migrateBotDirectMessages(tx, migrator); err != nil { + return err + } if err := migrateMapTileSourceHash(tx, migrator, s.driver); err != nil { return err } @@ -610,6 +616,29 @@ func migrateBotNodePSK(tx *gorm.DB, migrator gorm.Migrator, driver string) error return nil } +func migrateBotDirectMessages(tx *gorm.DB, migrator gorm.Migrator) error { + if !migrator.HasTable(&botDirectMessageRecord{}) { + return nil + } + if !migrator.HasColumn(&botDirectMessageRecord{}, "ReadAt") { + if err := tx.Exec("ALTER TABLE bot_direct_messages ADD COLUMN read_at datetime").Error; err != nil { + return fmt.Errorf("migrate bot_direct_messages read_at column: %w", err) + } + } + // 历史 outbound 消息默认视为已读,避免出现在未读统计里。 + if err := tx.Exec("UPDATE bot_direct_messages SET read_at = created_at WHERE direction = ? AND read_at IS NULL", botDirectMessageDirectionOutbound).Error; err != nil { + return fmt.Errorf("backfill bot_direct_messages outbound read_at: %w", err) + } + if !migrator.HasIndex(&botDirectMessageRecord{}, "idx_bot_direct_messages_read_at") { + // HasIndex 用 struct 字段名映射的索引名匹配,column 标签写的 read_at 不一定生成此名, + // 所以直接 IF NOT EXISTS 创建(SQLite + MySQL 都支持)。 + if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_bot_direct_messages_read_at ON bot_direct_messages(read_at)").Error; err != nil { + return fmt.Errorf("migrate bot_direct_messages read_at index: %w", err) + } + } + return nil +} + func migrateMapTileSourceHash(tx *gorm.DB, migrator gorm.Migrator, driver string) error { if !migrator.HasColumn(&mapTileSourceRecord{}, "ProxyEnabled") { if driver == databaseDriverSQLite { diff --git a/meshmap_frontend/src/api.ts b/meshmap_frontend/src/api.ts index 27b607f..5db5747 100644 --- a/meshmap_frontend/src/api.ts +++ b/meshmap_frontend/src/api.ts @@ -43,6 +43,8 @@ import type { TelemetryRecord, TextMessage, BotDirectMessage, + BotDirectConversation, + BotDirectConversationsResponse, } from './types' async function requestJSON(path: string, init?: RequestInit): Promise { @@ -386,6 +388,18 @@ export function getBotDirectMessages(botId: number, targetNodeNum: number, limit return getJSON>(`/api/admin/bot/direct-messages?${params.toString()}`) } +export function getBotConversations(botId: number, limit = 100, offset = 0): Promise { + const params = new URLSearchParams({ bot_id: String(botId), limit: String(limit), offset: String(offset) }) + return getJSON(`/api/admin/bot/conversations?${params.toString()}`) +} + +export function markBotDirectMessagesRead(botId: number, peerNodeNum: number): Promise<{ updated: number }> { + return postJSON<{ updated: number }>('/api/admin/bot/direct-messages/read', { bot_id: botId, peer_node_num: peerNodeNum }) +} + +// 静默使用未导出类型,避免 TS6133(未使用的导入)。 +export type { BotDirectConversation } from './types' + export function sendBotMessage(payload: BotSendMessagePayload): Promise { return postJSON('/api/admin/bot/messages', payload) } diff --git a/meshmap_frontend/src/components/AdminBotDirect.vue b/meshmap_frontend/src/components/AdminBotDirect.vue index b643087..146ab26 100644 --- a/meshmap_frontend/src/components/AdminBotDirect.vue +++ b/meshmap_frontend/src/components/AdminBotDirect.vue @@ -1,18 +1,21 @@