Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是 「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制, 数据库默认内置 SQLite,systemd 托管。 核心设计 - 三维寻址 name@path.session,按最后一个 . 切分;session 位三态: 省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达) - 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的 标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖 - 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构, 再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载 - 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重, 且路径与用户 filename 无关,杜绝 ../ 穿越 - 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与 最终总结走免配额通道,靠上游消息 id 做幂等键而非计数 - 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过 后端 gateway/ - models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL) 共用一份 repo 层 SQL,差异集中在 internal/db - 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界) - 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文 只从客户端流向服务器一次 - 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、 附件挂载),并发下不会刷穿 前端 web/ - 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件 - 全站纯 SVG 图标,不使用 emoji - api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts 插件 plugins/opencode-mail-bridge/ - 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、 session.idle 时转发本轮总结)
1047 lines
35 KiB
Go
1047 lines
35 KiB
Go
package repo
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
"github.com/agentmail/gateway/internal/models"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// ---------- Agent ----------
|
||
|
||
func CreateOrUpdateAgent(ctx context.Context, name, secret, platform string, workspaces []models.Workspace) error {
|
||
wsJSON, _ := json.Marshal(workspaces)
|
||
_, err := db.DB.ExecContext(ctx, `
|
||
INSERT INTO agents (agent_name, secret, workspaces, platform, status, last_seen)
|
||
VALUES ($1, $2, $3, $4, 'online', NOW())
|
||
ON CONFLICT (agent_name) DO UPDATE SET
|
||
secret = EXCLUDED.secret,
|
||
workspaces = EXCLUDED.workspaces,
|
||
platform = EXCLUDED.platform,
|
||
status = 'online',
|
||
last_seen = NOW()
|
||
`, name, secret, wsJSON, platform)
|
||
return err
|
||
}
|
||
|
||
func HeartbeatAgent(ctx context.Context, agentName string) (int, error) {
|
||
_, err := db.DB.ExecContext(ctx,
|
||
`UPDATE agents SET last_seen = NOW(), status = 'online' WHERE agent_name = $1`,
|
||
agentName)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return CountUnread(ctx, agentName)
|
||
}
|
||
|
||
func ListAgents(ctx context.Context, statusFilter string) ([]models.Agent, error) {
|
||
q := `SELECT agent_id, agent_name, workspaces, platform, status FROM agents`
|
||
args := []any{}
|
||
if statusFilter != "" {
|
||
q += ` WHERE status = $1`
|
||
args = append(args, statusFilter)
|
||
}
|
||
q += ` ORDER BY agent_name`
|
||
|
||
rows, err := db.DB.QueryContext(ctx, q, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
agents := []models.Agent{}
|
||
for rows.Next() {
|
||
var a models.Agent
|
||
var wsJSON []byte
|
||
if err := rows.Scan(&a.ID, &a.Name, &wsJSON, &a.Platform, &a.Status); err != nil {
|
||
return nil, err
|
||
}
|
||
if wsJSON != nil {
|
||
json.Unmarshal(wsJSON, &a.Workspaces)
|
||
}
|
||
agents = append(agents, a)
|
||
}
|
||
return agents, nil
|
||
}
|
||
|
||
func VerifyAgent(ctx context.Context, name, secret string) (*models.Agent, error) {
|
||
var a models.Agent
|
||
var wsJSON []byte
|
||
err := db.DB.QueryRowContext(ctx,
|
||
`SELECT agent_id, agent_name, workspaces, platform, status
|
||
FROM agents WHERE agent_name = $1 AND secret = $2`,
|
||
name, secret,
|
||
).Scan(&a.ID, &a.Name, &wsJSON, &a.Platform, &a.Status)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if wsJSON != nil {
|
||
json.Unmarshal(wsJSON, &a.Workspaces)
|
||
}
|
||
return &a, nil
|
||
}
|
||
|
||
// ---------- Session ----------
|
||
|
||
// CreateSession 建会话。
|
||
//
|
||
// 显式传了 alias(发信时的 session_alias 参数)= 调用方亲自命名,标为 manual,
|
||
// 平台后续自动同步不得覆盖;未传则等待平台命名,标为 platform。
|
||
func CreateSession(ctx context.Context, alias *string, fromAgent, subject string) (uuid.UUID, error) {
|
||
source := "platform"
|
||
if alias != nil && *alias != "" {
|
||
source = "manual"
|
||
}
|
||
var id uuid.UUID
|
||
err := db.DB.QueryRowContext(ctx,
|
||
`INSERT INTO sessions (session_alias, from_agent, subject, alias_source)
|
||
VALUES ($1, $2, $3, $4) RETURNING session_id`,
|
||
alias, fromAgent, subject, source,
|
||
).Scan(&id)
|
||
return id, err
|
||
}
|
||
|
||
func GetSessionByID(ctx context.Context, id uuid.UUID) (*models.Session, error) {
|
||
var s models.Session
|
||
var dismissed *string
|
||
err := db.DB.QueryRowContext(ctx,
|
||
`SELECT session_id, session_alias, from_agent, subject, status, owner_user_id,
|
||
created_at, updated_at, rename_dismissed, COALESCE(alias_source, 'platform'),
|
||
COALESCE(max_rounds, 0), COALESCE(used_rounds, 0)
|
||
FROM sessions WHERE session_id = $1`, id,
|
||
).Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status, &s.OwnerUserID,
|
||
&s.CreatedAt, &s.UpdatedAt, &dismissed, &s.AliasSource,
|
||
&s.MaxRounds, &s.UsedRounds)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if dismissed != nil {
|
||
s.RenameDismissed = *dismissed
|
||
}
|
||
return &s, nil
|
||
}
|
||
|
||
func TouchSession(ctx context.Context, id uuid.UUID) error {
|
||
_, err := db.DB.ExecContext(ctx,
|
||
`UPDATE sessions SET updated_at = NOW(), status = 'active' WHERE session_id = $1`, id)
|
||
return err
|
||
}
|
||
|
||
// UpdateSessionAlias 手工改名(人显式指定)。
|
||
//
|
||
// 同时把 alias_source 标为 'manual':人的选择优先于平台自动命名。
|
||
// 否则平台下一次 session.updated 会把人刚定的名字冲掉,
|
||
// 人上一秒记住的寻址地址下一秒失效。
|
||
func UpdateSessionAlias(ctx context.Context, id uuid.UUID, alias string) error {
|
||
_, err := db.DB.ExecContext(ctx,
|
||
`UPDATE sessions SET session_alias = $1, alias_source = 'manual', updated_at = NOW()
|
||
WHERE session_id = $2`,
|
||
alias, id)
|
||
return err
|
||
}
|
||
|
||
func ListSessions(ctx context.Context, statusFilter string, limit int) ([]models.Session, error) {
|
||
q := `SELECT s.session_id, s.session_alias, s.from_agent, s.subject, s.status,
|
||
s.owner_user_id, s.created_at, s.updated_at,
|
||
(SELECT COUNT(*) FROM mails m WHERE m.session_id = s.session_id)
|
||
FROM sessions s`
|
||
args := []any{}
|
||
if statusFilter != "" {
|
||
q += ` WHERE s.status = $1`
|
||
args = append(args, statusFilter)
|
||
}
|
||
q += ` ORDER BY s.updated_at DESC`
|
||
if limit > 0 {
|
||
q += fmt.Sprintf(` LIMIT %d`, limit)
|
||
}
|
||
|
||
rows, err := db.DB.QueryContext(ctx, q, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
sessions := []models.Session{}
|
||
for rows.Next() {
|
||
var s models.Session
|
||
if err := rows.Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status,
|
||
&s.OwnerUserID, &s.CreatedAt, &s.UpdatedAt,
|
||
&s.MaxRounds, &s.UsedRounds, &s.MailCount); err != nil {
|
||
return nil, err
|
||
}
|
||
sessions = append(sessions, s)
|
||
}
|
||
return sessions, nil
|
||
}
|
||
|
||
// ---------- Mail ----------
|
||
|
||
func CreateMail(ctx context.Context, sessionID uuid.UUID, parentMailID *uuid.UUID,
|
||
fromName, fromWorkspace, toName, toWorkspace, subject, body string, ccList []models.Address) (uuid.UUID, error) {
|
||
if ccList == nil {
|
||
ccList = []models.Address{}
|
||
}
|
||
ccJSON, _ := json.Marshal(ccList)
|
||
var id uuid.UUID
|
||
err := db.DB.QueryRowContext(ctx,
|
||
`INSERT INTO mails (session_id, parent_mail_id, from_name, from_workspace,
|
||
to_name, to_workspace, subject, body, cc_list)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING mail_id`,
|
||
sessionID, parentMailID, fromName, fromWorkspace, toName, toWorkspace, subject, body, ccJSON,
|
||
).Scan(&id)
|
||
return id, err
|
||
}
|
||
|
||
// CreatePermissionMail 创建权限请求邮件,toUser 为目标人类用户名
|
||
func CreatePermissionMail(ctx context.Context, sessionID uuid.UUID, fromName, toUser, question, body string, options []string) (uuid.UUID, error) {
|
||
optsJSON, _ := json.Marshal(options)
|
||
var id uuid.UUID
|
||
err := db.DB.QueryRowContext(ctx,
|
||
`INSERT INTO mails (session_id, from_name, to_name, subject, body, mail_type, permission_options)
|
||
VALUES ($1, $2, $3, $4, $5, 'permission_request', $6) RETURNING mail_id`,
|
||
sessionID, fromName, toUser, "权限请求: "+question, body, optsJSON,
|
||
).Scan(&id)
|
||
return id, err
|
||
}
|
||
|
||
// CreateDecisionMail 创建人类决策邮件(fromUser → toAgent)
|
||
func CreateDecisionMail(ctx context.Context, sessionID uuid.UUID, parentMailID uuid.UUID, fromUser, toAgent, decision, note string) (uuid.UUID, error) {
|
||
var id uuid.UUID
|
||
body := decision
|
||
if note != "" {
|
||
body = fmt.Sprintf("%s\n\n备注: %s", decision, note)
|
||
}
|
||
err := db.DB.QueryRowContext(ctx,
|
||
`INSERT INTO mails (session_id, parent_mail_id, from_name, to_name, subject, body)
|
||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING mail_id`,
|
||
sessionID, parentMailID, fromUser, toAgent, "Re: 权限请求 - "+decision, body,
|
||
).Scan(&id)
|
||
return id, err
|
||
}
|
||
|
||
func GetMailByID(ctx context.Context, id uuid.UUID) (*models.Mail, error) {
|
||
var m models.Mail
|
||
var alias *string
|
||
var ccJSON []byte
|
||
var renameAlias, renameReason *string
|
||
err := db.DB.QueryRowContext(ctx,
|
||
`SELECT m.mail_id, m.session_id, m.parent_mail_id,
|
||
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
|
||
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
|
||
m.status, m.created_at, s.session_alias, m.rename_alias, m.rename_reason
|
||
FROM mails m
|
||
JOIN sessions s ON m.session_id = s.session_id
|
||
WHERE m.mail_id = $1`, id,
|
||
).Scan(&m.ID, &m.SessionID, &m.ParentMailID,
|
||
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
|
||
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
|
||
&m.Status, &m.CreatedAt, &alias, &renameAlias, &renameReason)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(ccJSON) > 0 {
|
||
json.Unmarshal(ccJSON, &m.CCList)
|
||
}
|
||
if m.CCList == nil {
|
||
m.CCList = []models.Address{}
|
||
}
|
||
if alias != nil {
|
||
m.SessionAlias = *alias
|
||
}
|
||
// 改名提议随单封返回,让「谁在哪一封里提了什么」可追溯;
|
||
// 【待处理】的提议另有专用端点(GET /sessions/:id/rename-proposal)。
|
||
if renameAlias != nil {
|
||
m.RenameAlias = *renameAlias
|
||
}
|
||
if renameReason != nil {
|
||
m.RenameReason = *renameReason
|
||
}
|
||
return &m, nil
|
||
}
|
||
|
||
func MarkMailRead(ctx context.Context, id uuid.UUID) error {
|
||
_, err := db.DB.ExecContext(ctx, `UPDATE mails SET status = 'read' WHERE mail_id = $1`, id)
|
||
return err
|
||
}
|
||
|
||
func ListInbox(ctx context.Context, agentName, status string, limit int) ([]models.Mail, error) {
|
||
q := `SELECT m.mail_id, m.session_id, m.parent_mail_id,
|
||
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
|
||
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
|
||
m.status, m.created_at, s.session_alias
|
||
FROM mails m
|
||
JOIN sessions s ON m.session_id = s.session_id
|
||
WHERE (m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `)
|
||
AND s.status <> 'archived'`
|
||
args := []any{agentName}
|
||
if status != "" && status != "all" {
|
||
q += ` AND m.status = $2`
|
||
args = append(args, status)
|
||
}
|
||
q += ` ORDER BY m.created_at DESC`
|
||
if limit > 0 {
|
||
q += fmt.Sprintf(` LIMIT %d`, limit)
|
||
}
|
||
|
||
rows, err := db.DB.QueryContext(ctx, q, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
mails := []models.Mail{}
|
||
for rows.Next() {
|
||
var m models.Mail
|
||
var alias *string
|
||
var ccJSON []byte
|
||
if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID,
|
||
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
|
||
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
|
||
&m.Status, &m.CreatedAt, &alias); err != nil {
|
||
return nil, err
|
||
}
|
||
if len(ccJSON) > 0 {
|
||
json.Unmarshal(ccJSON, &m.CCList)
|
||
}
|
||
if m.CCList == nil {
|
||
m.CCList = []models.Address{}
|
||
}
|
||
if alias != nil {
|
||
m.SessionAlias = *alias
|
||
}
|
||
// Body preview
|
||
if len(m.Body) > 200 {
|
||
m.BodyPreview = m.Body[:200] + "..."
|
||
} else {
|
||
m.BodyPreview = m.Body
|
||
}
|
||
mails = append(mails, m)
|
||
}
|
||
return mails, nil
|
||
}
|
||
|
||
func CountUnread(ctx context.Context, agentName string) (int, error) {
|
||
var count int
|
||
err := db.DB.QueryRowContext(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM mails m
|
||
JOIN sessions s ON m.session_id = s.session_id
|
||
WHERE (m.to_name = $1 OR `+db.CCHas("m.cc_list", 1)+`)
|
||
AND m.status = 'unread'
|
||
AND s.status <> 'archived'
|
||
`, agentName).Scan(&count)
|
||
return count, err
|
||
}
|
||
|
||
func GetSessionMails(ctx context.Context, sessionID uuid.UUID) ([]models.Mail, error) {
|
||
rows, err := db.DB.QueryContext(ctx,
|
||
`SELECT m.mail_id, m.session_id, m.parent_mail_id,
|
||
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
|
||
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
|
||
m.status, m.created_at, s.session_alias
|
||
FROM mails m
|
||
JOIN sessions s ON m.session_id = s.session_id
|
||
WHERE m.session_id = $1
|
||
ORDER BY m.created_at ASC`, sessionID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
mails := []models.Mail{}
|
||
for rows.Next() {
|
||
var m models.Mail
|
||
var alias *string
|
||
var ccJSON []byte
|
||
if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID,
|
||
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
|
||
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
|
||
&m.Status, &m.CreatedAt, &alias); err != nil {
|
||
return nil, err
|
||
}
|
||
if len(ccJSON) > 0 {
|
||
json.Unmarshal(ccJSON, &m.CCList)
|
||
}
|
||
if m.CCList == nil {
|
||
m.CCList = []models.Address{}
|
||
}
|
||
if alias != nil {
|
||
m.SessionAlias = *alias
|
||
}
|
||
mails = append(mails, m)
|
||
}
|
||
return mails, nil
|
||
}
|
||
|
||
// ---------- Permission ----------
|
||
|
||
func CreatePermissionRequest(ctx context.Context, mailID, sessionID uuid.UUID, agentName, question string, options []string, contextStr string) error {
|
||
optsJSON, _ := json.Marshal(options)
|
||
_, err := db.DB.ExecContext(ctx,
|
||
`INSERT INTO permission_requests (mail_id, session_id, agent_name, question, options, context)
|
||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||
mailID, sessionID, agentName, question, optsJSON, contextStr)
|
||
return err
|
||
}
|
||
|
||
func DecidePermission(ctx context.Context, mailID uuid.UUID, decision string) (*models.PermissionRequest, error) {
|
||
var pr models.PermissionRequest
|
||
var optsJSON []byte
|
||
|
||
err := db.DB.QueryRowContext(ctx,
|
||
`UPDATE permission_requests SET result = $1, decided_at = NOW()
|
||
WHERE mail_id = $2
|
||
RETURNING request_id, mail_id, session_id, agent_name, question, options, context, result, decided_at, created_at`,
|
||
decision, mailID,
|
||
).Scan(&pr.ID, &pr.MailID, &pr.SessionID, &pr.AgentName, &pr.Question,
|
||
&optsJSON, &pr.Context, &pr.Result, &pr.DecidedAt, &pr.CreatedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
json.Unmarshal(optsJSON, &pr.Options)
|
||
|
||
// Also update the mail
|
||
_, _ = db.DB.ExecContext(context.Background(),
|
||
`UPDATE mails SET permission_result = $1, status = 'read' WHERE mail_id = $2`,
|
||
decision, mailID)
|
||
|
||
return &pr, nil
|
||
}
|
||
|
||
func ListPendingPermissions(ctx context.Context) ([]models.PermissionRequest, error) {
|
||
rows, err := db.DB.QueryContext(ctx,
|
||
`SELECT request_id, mail_id, session_id, agent_name, question, options, context, result, decided_at, created_at
|
||
FROM permission_requests WHERE result IS NULL
|
||
ORDER BY created_at DESC`)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
reqs := []models.PermissionRequest{}
|
||
for rows.Next() {
|
||
var pr models.PermissionRequest
|
||
var optsJSON []byte
|
||
if err := rows.Scan(&pr.ID, &pr.MailID, &pr.SessionID, &pr.AgentName, &pr.Question,
|
||
&optsJSON, &pr.Context, &pr.Result, &pr.DecidedAt, &pr.CreatedAt); err != nil {
|
||
return nil, err
|
||
}
|
||
json.Unmarshal(optsJSON, &pr.Options)
|
||
reqs = append(reqs, pr)
|
||
}
|
||
return reqs, nil
|
||
}
|
||
|
||
// ---------- Check permission ownership ----------
|
||
|
||
func GetPermissionByMailID(ctx context.Context, mailID uuid.UUID) (*models.PermissionRequest, error) {
|
||
var pr models.PermissionRequest
|
||
var optsJSON []byte
|
||
err := db.DB.QueryRowContext(ctx,
|
||
`SELECT request_id, mail_id, session_id, agent_name, question, options, context, result, decided_at, created_at
|
||
FROM permission_requests WHERE mail_id = $1`, mailID,
|
||
).Scan(&pr.ID, &pr.MailID, &pr.SessionID, &pr.AgentName, &pr.Question,
|
||
&optsJSON, &pr.Context, &pr.Result, &pr.DecidedAt, &pr.CreatedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
json.Unmarshal(optsJSON, &pr.Options)
|
||
return &pr, nil
|
||
}
|
||
|
||
func GetSessionMailByID(ctx context.Context, sessionID, mailID uuid.UUID) (*models.Mail, error) {
|
||
var m models.Mail
|
||
var alias *string
|
||
var ccJSON []byte
|
||
err := db.DB.QueryRowContext(ctx,
|
||
`SELECT m.mail_id, m.session_id, m.parent_mail_id,
|
||
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
|
||
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
|
||
m.status, m.created_at, s.session_alias
|
||
FROM mails m JOIN sessions s ON m.session_id = s.session_id
|
||
WHERE m.session_id = $1 AND m.mail_id = $2`, sessionID, mailID,
|
||
).Scan(&m.ID, &m.SessionID, &m.ParentMailID,
|
||
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
|
||
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
|
||
&m.Status, &m.CreatedAt, &alias)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(ccJSON) > 0 {
|
||
json.Unmarshal(ccJSON, &m.CCList)
|
||
}
|
||
if m.CCList == nil {
|
||
m.CCList = []models.Address{}
|
||
}
|
||
if alias != nil {
|
||
m.SessionAlias = *alias
|
||
}
|
||
return &m, nil
|
||
}
|
||
|
||
func FindSessionByAlias(ctx context.Context, alias string) (*models.Session, error) {
|
||
var s models.Session
|
||
err := db.DB.QueryRowContext(ctx,
|
||
`SELECT session_id, session_alias, from_agent, subject, status, owner_user_id, created_at, updated_at
|
||
FROM sessions WHERE session_alias = $1 AND status <> 'archived'`, alias,
|
||
).Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status, &s.OwnerUserID, &s.CreatedAt, &s.UpdatedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &s, nil
|
||
}
|
||
|
||
// ErrSessionNotFound 表示三维地址里指定的 session 别名不存在(或不属于该收件人)。
|
||
// 调用方应据此回 404「无法送达」,而不是悄悄新建一个会话。
|
||
var ErrSessionNotFound = errors.New("session not found")
|
||
|
||
// FindNamedSessionFor 查找收件人 name@path 名下别名为 alias 的会话。
|
||
// 严格匹配:会话必须存在、未归档,且该收件人确实参与过该会话,否则返回 ErrSessionNotFound。
|
||
func FindNamedSessionFor(ctx context.Context, name, path, alias string) (uuid.UUID, error) {
|
||
var id uuid.UUID
|
||
err := db.DB.QueryRowContext(ctx, `
|
||
SELECT s.session_id
|
||
FROM sessions s
|
||
WHERE s.session_alias = $1
|
||
AND s.status <> 'archived'
|
||
AND EXISTS (
|
||
SELECT 1 FROM mails m
|
||
WHERE m.session_id = s.session_id
|
||
AND (m.to_name = $2 OR m.from_name = $2 OR `+db.CCHas("m.cc_list", 2)+`)
|
||
)
|
||
ORDER BY s.updated_at DESC
|
||
LIMIT 1
|
||
`, alias, name).Scan(&id)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return uuid.Nil, ErrSessionNotFound
|
||
}
|
||
return id, err
|
||
}
|
||
|
||
// FindOrCreateDefaultSession 实现 session 位省略时的「默认会话」语义:
|
||
// 复用 name@path 最近一次活跃的非归档会话;从未通过信则建立一个新的作为默认会话。
|
||
func FindOrCreateDefaultSession(ctx context.Context, name, path, fromAgent, subject string) (uuid.UUID, error) {
|
||
var id uuid.UUID
|
||
err := db.DB.QueryRowContext(ctx, `
|
||
SELECT s.session_id
|
||
FROM sessions s
|
||
JOIN mails m ON m.session_id = s.session_id
|
||
WHERE s.status <> 'archived'
|
||
AND (m.to_name = $1 OR m.from_name = $1 OR `+db.CCHas("m.cc_list", 1)+`)
|
||
AND (COALESCE(m.to_workspace,'') = $2 OR COALESCE(m.from_workspace,'') = $2)
|
||
ORDER BY s.updated_at DESC
|
||
LIMIT 1
|
||
`, name, path).Scan(&id)
|
||
if err == nil {
|
||
TouchSession(ctx, id)
|
||
return id, nil
|
||
}
|
||
if !errors.Is(err, sql.ErrNoRows) {
|
||
return uuid.Nil, err
|
||
}
|
||
return CreateSession(ctx, nil, fromAgent, subject)
|
||
}
|
||
|
||
// SessionAliasOf 返回会话别名,未命名或查询失败时返回空串。
|
||
// 仅用于响应体回显,不影响投递路径,所以吞错是可接受的。
|
||
func SessionAliasOf(ctx context.Context, id uuid.UUID) string {
|
||
var alias *string
|
||
if err := db.DB.QueryRowContext(ctx,
|
||
`SELECT session_alias FROM sessions WHERE session_id = $1`, id).Scan(&alias); err != nil {
|
||
return ""
|
||
}
|
||
if alias == nil {
|
||
return ""
|
||
}
|
||
return *alias
|
||
}
|
||
|
||
// AgentCanAccessSession 判断 Agent 是否参与过该会话(发件/收件/被抄送)。
|
||
// Agent 只能改自己参与的会话的别名,避免跨会话改名。
|
||
func AgentCanAccessSession(ctx context.Context, agentName string, sessionID uuid.UUID) (bool, error) {
|
||
var n int
|
||
err := db.DB.QueryRowContext(ctx, `
|
||
SELECT COUNT(*) FROM mails m
|
||
WHERE m.session_id = $1
|
||
AND (m.from_name = $2 OR m.to_name = $2 OR `+db.CCHas("m.cc_list", 2)+`)
|
||
`, sessionID, agentName).Scan(&n)
|
||
return n > 0, err
|
||
}
|
||
|
||
// SyncSessionTitle 更新会话主题(Agent 平台生成的摘要标题)。
|
||
func SyncSessionTitle(ctx context.Context, id uuid.UUID, title string) error {
|
||
_, err := db.DB.ExecContext(ctx,
|
||
`UPDATE sessions SET subject = $1, updated_at = NOW() WHERE session_id = $2`,
|
||
title, id)
|
||
return err
|
||
}
|
||
|
||
// SyncSessionAlias 把 Agent 平台侧的会话标识写为本侧别名。
|
||
// 平台侧标识(如 opencode 的 slug)在平台内不保证全局唯一,而本侧别名负责寻址必须唯一,
|
||
// 因此撞名时自动追加 -2、-3… 后缀而不是报错——同步是后台行为,不该因撞名失败。
|
||
// 返回最终落库的别名。该会话已持有目标别名时直接返回,不做无谓写入。
|
||
//
|
||
// **人显式定过的别名不覆盖**(alias_source = 'manual'):
|
||
// 用户刚接受了 Agent 的改名提议,或手工敲了一个名字,平台下一次 session.updated
|
||
// 不该把它冲掉 —— 那会让人上一秒记住的寻址地址下一秒失效。
|
||
// 此时返回当前别名,调用方据此知道同步未生效。
|
||
func SyncSessionAlias(ctx context.Context, id uuid.UUID, want string) (string, error) {
|
||
const maxAttempts = 50
|
||
|
||
var cur *string
|
||
var source string
|
||
if err := db.DB.QueryRowContext(ctx,
|
||
`SELECT session_alias, COALESCE(alias_source, 'platform') FROM sessions WHERE session_id = $1`,
|
||
id).Scan(&cur, &source); err != nil {
|
||
return "", err
|
||
}
|
||
if source == "manual" && cur != nil && *cur != "" {
|
||
return *cur, nil
|
||
}
|
||
|
||
for i := 0; i < maxAttempts; i++ {
|
||
candidate := want
|
||
if i > 0 {
|
||
candidate = fmt.Sprintf("%s-%d", want, i+1)
|
||
}
|
||
|
||
owner, err := aliasOwner(ctx, candidate)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if owner != nil {
|
||
if *owner == id {
|
||
return candidate, nil // 已经是这个别名,无需写入
|
||
}
|
||
continue // 被别人占用,试下一个后缀
|
||
}
|
||
|
||
// 只在仍是 platform 来源时写入:并发下用户可能刚好接受了改名提议,
|
||
// 条件放进 WHERE 才能保证「检查」与「写入」不被插进来的手工改名割开
|
||
res, err := db.DB.ExecContext(ctx,
|
||
`UPDATE sessions SET session_alias = $1, updated_at = NOW()
|
||
WHERE session_id = $2 AND COALESCE(alias_source, 'platform') <> 'manual'`,
|
||
candidate, id)
|
||
if err == nil {
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
// 期间变成 manual 了,尊重人的选择
|
||
return SessionAliasOf(ctx, id), nil
|
||
}
|
||
return candidate, nil
|
||
}
|
||
// 并发下另一个请求刚占走该别名(唯一索引拦下),继续试下一个后缀
|
||
if db.IsUniqueViolation(err) {
|
||
continue
|
||
}
|
||
return "", err
|
||
}
|
||
return "", fmt.Errorf("alias %q: 连同 -2..-%d 后缀均被占用", want, maxAttempts)
|
||
}
|
||
|
||
// aliasOwner 返回持有该别名的会话 ID;无人持有时返回 nil。
|
||
func aliasOwner(ctx context.Context, alias string) (*uuid.UUID, error) {
|
||
var id uuid.UUID
|
||
err := db.DB.QueryRowContext(ctx,
|
||
`SELECT session_id FROM sessions WHERE session_alias = $1`, alias).Scan(&id)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return nil, nil
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &id, nil
|
||
}
|
||
|
||
func SessionMailCount(ctx context.Context, sessionID uuid.UUID) (int, error) {
|
||
var count int
|
||
err := db.DB.QueryRowContext(ctx,
|
||
`SELECT COUNT(*) FROM mails WHERE session_id = $1`, sessionID).Scan(&count)
|
||
return count, err
|
||
}
|
||
|
||
// ---------- Contacts / Archive ----------
|
||
|
||
// Contact 是「联系人」= 一条 name@path.session 三维地址
|
||
type Contact struct {
|
||
SessionID uuid.UUID `json:"session_id"`
|
||
AgentName string `json:"agent_name"`
|
||
Path string `json:"path"`
|
||
SessionAlias string `json:"session_alias"`
|
||
Address string `json:"address"` // name@path.session
|
||
Status string `json:"status"`
|
||
MailCount int `json:"mail_count"`
|
||
UnreadCount int `json:"unread_count"`
|
||
LastActivity time.Time `json:"last_activity"`
|
||
}
|
||
|
||
// ListContactsFor 按 (agent, path, session) 聚合出联系人清单。
|
||
// forUser 非空时只列该用户参与的会话(owner / 收发 / 抄送);空表示不限(管理员全局视图)。
|
||
// archived=false 只列活跃会话,true 只列归档会话。
|
||
func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Contact, error) {
|
||
op := "<>"
|
||
if archived {
|
||
op = "="
|
||
}
|
||
scope := ""
|
||
args := []any{}
|
||
if forUser != "" {
|
||
scope = ` AND (s.owner_user_id = (SELECT user_id FROM users WHERE username = $1)
|
||
OR EXISTS (
|
||
SELECT 1 FROM mails mm
|
||
WHERE mm.session_id = s.session_id
|
||
AND (mm.from_name = $1 OR mm.to_name = $1
|
||
OR `+db.CCHas("mm.cc_list", 1)+`)
|
||
))`
|
||
args = append(args, forUser)
|
||
}
|
||
// 取会话里最早那封邮件作为联系人身份。
|
||
// PG 用 LATERAL 子查询;SQLite 无 LATERAL,改用关联子查询逐列取值
|
||
// (同一个 min(created_at) 子句,四列取自同一行)。
|
||
var firstMail string
|
||
if db.D == db.Postgres {
|
||
firstMail = `
|
||
JOIN LATERAL (
|
||
SELECT to_name, to_workspace, from_name, from_workspace
|
||
FROM mails
|
||
WHERE session_id = s.session_id
|
||
ORDER BY created_at ASC
|
||
LIMIT 1
|
||
) m ON TRUE`
|
||
} else {
|
||
firstMail = `
|
||
JOIN mails m ON m.mail_id = (
|
||
SELECT mail_id FROM mails
|
||
WHERE session_id = s.session_id
|
||
ORDER BY created_at ASC, mail_id ASC
|
||
LIMIT 1
|
||
)`
|
||
}
|
||
|
||
rows, err := db.DB.QueryContext(ctx, `
|
||
SELECT s.session_id,
|
||
COALESCE(NULLIF(m.to_name, 'human'), m.from_name) AS agent_name,
|
||
COALESCE(NULLIF(m.to_workspace, ''), m.from_workspace) AS path,
|
||
COALESCE(s.session_alias, '') AS alias,
|
||
s.status,
|
||
(SELECT COUNT(*) FROM mails x WHERE x.session_id = s.session_id),
|
||
(SELECT COUNT(*) FROM mails x WHERE x.session_id = s.session_id AND x.status = 'unread'),
|
||
s.updated_at
|
||
FROM sessions s`+firstMail+`
|
||
WHERE s.status `+op+` 'archived'`+scope+`
|
||
ORDER BY s.updated_at DESC
|
||
`, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
contacts := []Contact{}
|
||
for rows.Next() {
|
||
var c Contact
|
||
if err := rows.Scan(&c.SessionID, &c.AgentName, &c.Path, &c.SessionAlias,
|
||
&c.Status, &c.MailCount, &c.UnreadCount, &c.LastActivity); err != nil {
|
||
return nil, err
|
||
}
|
||
c.Address = c.AgentName + "@" + c.Path
|
||
if c.SessionAlias != "" {
|
||
c.Address += "." + c.SessionAlias
|
||
}
|
||
contacts = append(contacts, c)
|
||
}
|
||
return contacts, nil
|
||
}
|
||
|
||
// ArchiveSession 归档一个会话(邮箱界面不再展示,数据保留)
|
||
func ArchiveSession(ctx context.Context, sessionID uuid.UUID) error {
|
||
_, err := db.DB.ExecContext(ctx,
|
||
`UPDATE sessions SET status = 'archived', updated_at = NOW() WHERE session_id = $1`,
|
||
sessionID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// 同时把该会话下的邮件标记为已归档,收件箱不再列出
|
||
_, err = db.DB.ExecContext(ctx,
|
||
`UPDATE mails SET status = 'archived' WHERE session_id = $1 AND status <> 'archived'`,
|
||
sessionID)
|
||
return err
|
||
}
|
||
|
||
// FindSessionByAddress 按 name@path.session 定位会话
|
||
func FindSessionByAddress(ctx context.Context, name, path, alias string) (uuid.UUID, error) {
|
||
var id uuid.UUID
|
||
err := db.DB.QueryRowContext(ctx, `
|
||
SELECT s.session_id
|
||
FROM sessions s
|
||
JOIN mails m ON m.session_id = s.session_id
|
||
WHERE COALESCE(s.session_alias, '') = $1
|
||
AND (m.to_name = $2 OR m.from_name = $2)
|
||
AND (COALESCE(m.to_workspace,'') = $3 OR COALESCE(m.from_workspace,'') = $3)
|
||
LIMIT 1
|
||
`, alias, name, path).Scan(&id)
|
||
return id, err
|
||
}
|
||
|
||
// SuggestPaths 返回某 agent 已注册的工作区名(用于发信补全)
|
||
func SuggestPaths(ctx context.Context, agentName string) ([]string, error) {
|
||
var wsJSON []byte
|
||
err := db.DB.QueryRowContext(ctx,
|
||
`SELECT workspaces FROM agents WHERE agent_name = $1`, agentName).Scan(&wsJSON)
|
||
if err != nil {
|
||
return []string{}, err
|
||
}
|
||
var ws []models.Workspace
|
||
if len(wsJSON) > 0 {
|
||
json.Unmarshal(wsJSON, &ws)
|
||
}
|
||
out := make([]string, 0, len(ws))
|
||
for _, w := range ws {
|
||
out = append(out, w.Name)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// SuggestSessionsFor 返回某 (peer, path) 下当前用户可见的会话别名(用于发信补全)
|
||
func SuggestSessionsFor(ctx context.Context, forUser, peerName, path string) ([]string, error) {
|
||
rows, err := db.DB.QueryContext(ctx, `
|
||
SELECT DISTINCT s.session_alias
|
||
FROM sessions s
|
||
JOIN mails m ON m.session_id = s.session_id
|
||
WHERE s.session_alias IS NOT NULL
|
||
AND s.status <> 'archived'
|
||
AND (m.to_name = $1 OR m.from_name = $1)
|
||
AND ($2 = '' OR COALESCE(m.to_workspace,'') = $2 OR COALESCE(m.from_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.session_alias
|
||
`, peerName, path, forUser)
|
||
if err != nil {
|
||
return []string{}, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := []string{}
|
||
for rows.Next() {
|
||
var alias string
|
||
if err := rows.Scan(&alias); err == nil && alias != "" {
|
||
out = append(out, alias)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// ListSentBy 列出某发件人发出的邮件(发件箱),排除已归档会话
|
||
func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail, error) {
|
||
rows, err := db.DB.QueryContext(ctx, `
|
||
SELECT m.mail_id, m.session_id, m.parent_mail_id,
|
||
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
|
||
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
|
||
m.status, m.created_at, s.session_alias
|
||
FROM mails m
|
||
JOIN sessions s ON m.session_id = s.session_id
|
||
WHERE m.from_name = $1 AND s.status <> 'archived'
|
||
ORDER BY m.created_at DESC
|
||
LIMIT $2
|
||
`, fromName, limit)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
mails := []models.Mail{}
|
||
for rows.Next() {
|
||
var m models.Mail
|
||
var alias *string
|
||
var ccJSON []byte
|
||
if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID,
|
||
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
|
||
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
|
||
&m.Status, &m.CreatedAt, &alias); err != nil {
|
||
return nil, err
|
||
}
|
||
if len(ccJSON) > 0 {
|
||
json.Unmarshal(ccJSON, &m.CCList)
|
||
}
|
||
if m.CCList == nil {
|
||
m.CCList = []models.Address{}
|
||
}
|
||
if alias != nil {
|
||
m.SessionAlias = *alias
|
||
}
|
||
if len(m.Body) > 200 {
|
||
m.BodyPreview = m.Body[:200] + "..."
|
||
} else {
|
||
m.BodyPreview = m.Body
|
||
}
|
||
mails = append(mails, m)
|
||
}
|
||
return mails, nil
|
||
}
|
||
|
||
// ListPendingPermissionsFor 列出待决权限请求;forUser 非空时只列发给该用户的
|
||
func ListPendingPermissionsFor(ctx context.Context, forUser string) ([]models.PermissionRequest, error) {
|
||
q := `SELECT pr.request_id, pr.mail_id, pr.session_id, pr.agent_name, pr.question,
|
||
pr.options, pr.context, pr.result, pr.decided_at, pr.created_at
|
||
FROM permission_requests pr
|
||
JOIN mails m ON m.mail_id = pr.mail_id
|
||
JOIN sessions s ON s.session_id = pr.session_id
|
||
WHERE pr.result IS NULL AND s.status <> 'archived'`
|
||
args := []any{}
|
||
if forUser != "" {
|
||
q += ` AND m.to_name = $1`
|
||
args = append(args, forUser)
|
||
}
|
||
q += ` ORDER BY pr.created_at DESC`
|
||
|
||
rows, err := db.DB.QueryContext(ctx, q, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
reqs := []models.PermissionRequest{}
|
||
for rows.Next() {
|
||
var pr models.PermissionRequest
|
||
var optsJSON []byte
|
||
if err := rows.Scan(&pr.ID, &pr.MailID, &pr.SessionID, &pr.AgentName, &pr.Question,
|
||
&optsJSON, &pr.Context, &pr.Result, &pr.DecidedAt, &pr.CreatedAt); err != nil {
|
||
return nil, err
|
||
}
|
||
json.Unmarshal(optsJSON, &pr.Options)
|
||
reqs = append(reqs, pr)
|
||
}
|
||
return reqs, nil
|
||
}
|
||
|
||
// ListSessionsFor 列出某人类用户参与的会话(owner / 收发 / 抄送);forUser 空表示不限
|
||
func ListSessionsFor(ctx context.Context, forUser string, limit int) ([]models.Session, error) {
|
||
q := `SELECT s.session_id, s.session_alias, s.from_agent, s.subject, s.status,
|
||
s.owner_user_id, s.created_at, s.updated_at,
|
||
COALESCE(s.max_rounds, 0), COALESCE(s.used_rounds, 0),
|
||
(SELECT COUNT(*) FROM mails m WHERE m.session_id = s.session_id)
|
||
FROM sessions s
|
||
WHERE s.status <> 'archived'`
|
||
args := []any{}
|
||
if forUser != "" {
|
||
q += ` AND (s.owner_user_id = (SELECT user_id FROM users WHERE username = $1)
|
||
OR EXISTS (
|
||
SELECT 1 FROM mails mm
|
||
WHERE mm.session_id = s.session_id
|
||
AND (mm.from_name = $1 OR mm.to_name = $1
|
||
OR `+db.CCHas("mm.cc_list", 1)+`)
|
||
))`
|
||
args = append(args, forUser)
|
||
}
|
||
q += ` ORDER BY s.updated_at DESC`
|
||
if limit > 0 {
|
||
q += fmt.Sprintf(` LIMIT %d`, limit)
|
||
}
|
||
|
||
rows, err := db.DB.QueryContext(ctx, q, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
sessions := []models.Session{}
|
||
for rows.Next() {
|
||
var s models.Session
|
||
if err := rows.Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status,
|
||
&s.OwnerUserID, &s.CreatedAt, &s.UpdatedAt, &s.MailCount); err != nil {
|
||
return nil, err
|
||
}
|
||
sessions = append(sessions, s)
|
||
}
|
||
return sessions, nil
|
||
}
|
||
|
||
// CountUnreadInSession 统计某人在某会话内的未读数(含被抄送)
|
||
func CountUnreadInSession(ctx context.Context, name string, sessionID uuid.UUID) (int, error) {
|
||
ccProbe, _ := json.Marshal([]map[string]string{{"name": name}})
|
||
var n int
|
||
err := db.DB.QueryRowContext(ctx, `
|
||
SELECT COUNT(*) FROM mails
|
||
WHERE session_id = $1
|
||
AND status = 'unread'
|
||
AND (to_name = $2 OR cc_list @> $3::jsonb)
|
||
`, sessionID, name, string(ccProbe)).Scan(&n)
|
||
return n, err
|
||
}
|
||
|
||
// SetMailRenameProposal 记录某封邮件里 Agent 提议的新会话别名。
|
||
//
|
||
// 单独一条 UPDATE 而不是塞进 CreateMail 的参数表:提议是可选的旁支信息,
|
||
// 让三个调用点都多传两个几乎总是空串的参数不值当。
|
||
func SetMailRenameProposal(ctx context.Context, mailID uuid.UUID, alias, reason string) error {
|
||
_, err := db.DB.ExecContext(ctx,
|
||
`UPDATE mails SET rename_alias = $1, rename_reason = $2 WHERE mail_id = $3`,
|
||
alias, reason, mailID)
|
||
return err
|
||
}
|
||
|
||
// PendingRenameProposal 返回某会话里**最新一条尚未处理**的改名提议。
|
||
//
|
||
// 「尚未处理」= 提议的别名既不是当前别名(已接受),也不在驳回记录里。
|
||
// 无提议时返回 ("", "", nil)。
|
||
//
|
||
// 只看最新一条:Agent 干活过程中可能多次提议,最后那条才是它现在的结论。
|
||
func PendingRenameProposal(ctx context.Context, sessionID uuid.UUID) (alias, reason string, err error) {
|
||
var cur, dismissed *string
|
||
err = db.DB.QueryRowContext(ctx,
|
||
`SELECT session_alias, rename_dismissed FROM sessions WHERE session_id = $1`,
|
||
sessionID).Scan(&cur, &dismissed)
|
||
if err != nil {
|
||
return "", "", err
|
||
}
|
||
|
||
var a, rs *string
|
||
err = db.DB.QueryRowContext(ctx, `
|
||
SELECT rename_alias, rename_reason FROM mails
|
||
WHERE session_id = $1 AND rename_alias IS NOT NULL AND rename_alias <> ''
|
||
ORDER BY created_at DESC, mail_id DESC
|
||
LIMIT 1
|
||
`, sessionID).Scan(&a, &rs)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return "", "", nil
|
||
}
|
||
if err != nil {
|
||
return "", "", err
|
||
}
|
||
if a == nil || *a == "" {
|
||
return "", "", nil
|
||
}
|
||
// 已经改成这个名字了 = 提议已被接受,不必再提示
|
||
if cur != nil && *cur == *a {
|
||
return "", "", nil
|
||
}
|
||
// 用户驳回过这个建议
|
||
if dismissed != nil && *dismissed == *a {
|
||
return "", "", nil
|
||
}
|
||
if rs != nil {
|
||
reason = *rs
|
||
}
|
||
return *a, reason, nil
|
||
}
|
||
|
||
// DismissRenameProposal 记下用户驳回了哪个建议,好让提示条不再反复弹。
|
||
//
|
||
// 只存最后驳回的那一个而不是一张列表:Agent 每次提的名字都不同,
|
||
// 攒一张历史表除了占地方没有别的用处 —— 需要判断的只是「当前这条提议是否被否过」。
|
||
func DismissRenameProposal(ctx context.Context, sessionID uuid.UUID, alias string) error {
|
||
_, err := db.DB.ExecContext(ctx,
|
||
`UPDATE sessions SET rename_dismissed = $1 WHERE session_id = $2`,
|
||
alias, sessionID)
|
||
return err
|
||
}
|