package repo import ( "context" "strings" "time" "github.com/agentmail/gateway/internal/db" ) // ---------- 平台会话镜像 ---------- // // Agent 平台自己也在开会话:有些经由邮件驱动,有些是人直接在平台界面上开的。 // 写信时想续谈某条会话,得先知道那个工作区下有哪些会话可续 —— 而 Gateway // 只看得见邮件驱动的那部分。 // // **由插件在心跳里上报,Gateway 不反向拉取。** // 当前架构是单向的(Agent 持密钥主动连 Gateway,Gateway 从不外呼); // 让 Gateway 去调平台接口需要它保存各平台的地址与凭证,那是另一套信任模型。 // 代价是插件没运行时同步不了 —— 但插件没运行时邮件本来也投不进去。 // PlatformSession 是插件上报的一条平台侧会话。 type PlatformSession struct { PlatformID string `json:"platform_id"` Workspace string `json:"workspace"` Slug string `json:"slug,omitempty"` Title string `json:"title,omitempty"` MailDriven bool `json:"mail_driven"` UpdatedAt *time.Time `json:"updated_at,omitempty"` } // maxPlatformSessions 限制单次上报的会话数。 // // 一个长期运行的平台可以累积上千条会话,而候选列表上千项对人没有意义。 // 插件按最近活跃排序后上报前 N 条即可。 const maxPlatformSessions = 200 // ReplacePlatformSessions 整表替换某 Agent 的平台会话镜像。 // // 整表替换而非增量合并:镜像是平台当前状态的快照。增量合并会让已经删掉的 // 平台会话永远留在候选列表里,而那正是「选了却送不到」的来源 // —— session 位是三态语义,指向一条不存在的会话会直接 404。 func ReplacePlatformSessions(ctx context.Context, agentName string, list []PlatformSession) error { agentName = strings.TrimSpace(agentName) if agentName == "" { return nil } if len(list) > maxPlatformSessions { list = list[:maxPlatformSessions] } tx, err := db.DB.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if _, err := tx.ExecContext(ctx, `DELETE FROM agent_platform_sessions WHERE agent_name = $1`, agentName); err != nil { return err } seen := map[string]bool{} for _, ps := range list { id := strings.TrimSpace(ps.PlatformID) if id == "" || seen[id] { continue } seen[id] = true driven := 0 if ps.MailDriven { driven = 1 } if _, err := tx.ExecContext(ctx, ` INSERT INTO agent_platform_sessions (agent_name, platform_id, workspace, slug, title, mail_driven, updated_at, reported_at) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) `, agentName, id, strings.TrimSpace(ps.Workspace), strings.TrimSpace(ps.Slug), strings.TrimSpace(ps.Title), driven, ps.UpdatedAt); err != nil { return err } } return tx.Commit() } // SessionCandidate 是「续谈某条会话」的一个候选项。 type SessionCandidate struct { // Alias 是填进 session 位的值 —— 候选项的实际用途就是它 Alias string `json:"alias"` // Title 给人看,用来分辨两条别名相似的会话在谈什么 Title string `json:"title,omitempty"` // Source 说明这条候选从哪来: // mail 本侧邮件线索(可直接送达) // platform 平台侧会话镜像(本侧还没有对应线索) Source string `json:"source"` // Unread 仅 mail 来源有意义 Unread int `json:"unread,omitempty"` } // SuggestSessionCandidates 汇总某 name@path 下可续谈的会话。 // // 两个来源合并: // 1. 本侧邮件线索(sessions.workspace 匹配,或历史数据里靠 mails 反推) // 2. 平台会话镜像里带 slug 的那些 // // 本侧优先:邮件线索是「这个别名一定送得到」的保证,而镜像只是平台的说法。 // 同名时保留本侧那条,并把镜像的标题补上去(镜像通常有更新的标题)。 func SuggestSessionCandidates(ctx context.Context, forUser, peerName, path string) ([]SessionCandidate, error) { out := []SessionCandidate{} seen := map[string]int{} // alias -> out 下标 // ---- 来源 1:本侧邮件线索 ---- // // sessions.workspace 是权威来源。它是新加的列,历史会话为空串, // 因此保留 mails 反推作为兜底:`s.workspace = $2 OR (s.workspace = '' AND )`。 // 反推只看 to_workspace —— Agent 回信时 from_workspace 存的是 Agent 名而非路径, // 拿它比路径永远匹配不上。 rows, err := db.DB.QueryContext(ctx, ` SELECT s.session_alias, COALESCE(s.subject, ''), (SELECT COUNT(*) FROM mails u WHERE u.session_id = s.session_id AND u.status = 'unread') FROM sessions s WHERE s.session_alias IS NOT NULL AND s.session_alias <> '' AND s.status <> 'archived' AND EXISTS ( SELECT 1 FROM mails m WHERE m.session_id = s.session_id AND (m.to_name = $1 OR m.from_name = $1 OR `+db.CCHas("m.cc_list", 1)+`) ) AND ($2 = '' OR s.workspace = $2 OR (s.workspace = '' AND EXISTS ( SELECT 1 FROM mails w WHERE w.session_id = s.session_id AND COALESCE(w.to_workspace,'') = $2 ))) AND ($3 = '' OR s.owner_user_id = (SELECT user_id FROM users WHERE username = $3) OR EXISTS ( SELECT 1 FROM mails mm WHERE mm.session_id = s.session_id AND (mm.from_name = $3 OR mm.to_name = $3 OR `+db.CCHas("mm.cc_list", 3)+`) )) ORDER BY s.updated_at DESC `, peerName, path, forUser) if err != nil { return out, err } defer rows.Close() for rows.Next() { var alias, title string var unread int if err := rows.Scan(&alias, &title, &unread); err != nil { return out, err } if alias == "" { continue } seen[alias] = len(out) out = append(out, SessionCandidate{ Alias: alias, Title: title, Source: "mail", Unread: unread, }) } if err := rows.Err(); err != nil { return out, err } // ---- 来源 2:平台会话镜像 ---- prows, err := db.DB.QueryContext(ctx, ` SELECT slug, title FROM agent_platform_sessions WHERE agent_name = $1 AND slug <> '' AND ($2 = '' OR workspace = $2) -- 不用 NULLS LAST:它要 SQLite 3.30+,而驱动自带的版本不由我们控制。 -- COALESCE 在两个方言里都成立,语义也更直接:没有 updated_at 就用上报时间。 ORDER BY COALESCE(updated_at, reported_at) DESC `, peerName, path) if err != nil { // 镜像查不到不该让整个补全失败:本侧线索已经够用了 return out, nil } defer prows.Close() for prows.Next() { var slug, title string if err := prows.Scan(&slug, &title); err != nil { break } if slug == "" { continue } if i, ok := seen[slug]; ok { // 本侧已有同名线索:保留 mail 来源(它保证送得到), // 但补上镜像的标题 —— 平台侧标题通常比会话建立时的主题更贴切 if out[i].Title == "" && title != "" { out[i].Title = title } continue } seen[slug] = len(out) out = append(out, SessionCandidate{Alias: slug, Title: title, Source: "platform"}) } return out, nil } // SetSessionWorkspace 记下会话所属的工作目录。 // // 只在为空时写入:会话的工作区在建立时就定下了,之后不该被一封发往 // 别处的邮件改掉 —— 那会让这条会话在候选列表里凭空换一个工作区。 func SetSessionWorkspace(ctx context.Context, sessionID interface{ String() string }, workspace string) error { ws := strings.TrimSpace(workspace) if ws == "" { return nil } _, err := db.DB.ExecContext(ctx, `UPDATE sessions SET workspace = $1 WHERE session_id = $2 AND workspace = ''`, ws, sessionID.String()) return err }