package repo import ( "context" "fmt" "strings" "unicode" "unicode/utf8" "github.com/agentmail/gateway/internal/db" "github.com/google/uuid" ) // 自动别名 —— 让 `.new` 建出来的会话立刻可被寻址。 // // # 为什么必须自动命名 // // `session` 位三态里 `new` 是**一次性动作**:它建出会话就用完了。之后要再投进 // 同一条会话,只有两条路 —— `reply_to` 某封具体邮件,或者 `name@path.<别名>`。 // 而 `CreateSession(alias=nil)` 建出来的会话别名是 NULL,于是: // // - `FindNamedSessionFor` 查不到它(`WHERE session_alias = $1` 对 NULL 不成立) // - `SuggestSessionCandidates` 跳过它(`session_alias IS NOT NULL AND <> ''`) // - 参与方拿到的 `new_mail` 里 `session_alias` 是空串 // // 结果是:被抄送方收到一封 `x@/p.new` 的邮件,**除了回复那一封之外无法再投进这条 // 会话**。再发一次 `x@/p.new` 只会建第三条会话。这不是能力缺失,是寻址断链。 // // 原先的设计假定平台插件会通过 `POST /sessions/{id}/sync` 把模型生成的标题回写成 // 别名,于是「未命名」只是短暂状态。但两件事让这个假定不成立: // // 1. 人类发的邮件根本没有平台侧,永远等不到回写; // 2. 回写发生在模型跑完第一轮之后,而抄送方**在那之前**就要决定回信地址。 // // 因此本侧先给一个可用的别名,平台随后仍可用 `SyncSessionAlias` 改写它 —— // `alias_source` 保持 `platform` 正是为此:自动名不是人定的名,不该挡住平台命名。 // // # 为什么不复用 SyncSessionAlias // // 那个函数假定「会话已存在、现在要改名」,并且会跳过 `manual`。这里的场景是 // 「刚建完、还没有名字」,且必须在**建会话的同一个请求里**完成,否则中间那一瞬 // 发出的 SSE 仍然带空别名。 // aliasMaxBytes 与 normalizeAlias 的截断上限一致(sessions.session_alias 为 VARCHAR(128))。 const aliasMaxBytes = 128 // autoAliasAttempts 是撞名后追加 -2、-3… 的尝试次数上限。 // 与 SyncSessionAlias 取同一个数量级:同一主题在同一天内开几十条会话已属异常, // 真到了上限说明调用方在刷会话,此时报错比继续找空位更有价值。 const autoAliasAttempts = 50 // AutoAliasFor 依据收件人与主题拼一个候选别名(未做唯一性检查)。 // // 形如 `dsh-重构导入路径`:前缀用收件方名字,后缀用主题。**两者都要**—— // 只用主题时「服务恢复验证」这类通用主题会在不同 Agent 之间反复撞名, // 只用名字则同一个 Agent 的所有会话都叫 `dsh-2`、`dsh-3`,看不出在聊什么。 // // 主题为空(少见但合法)时退回单独的名字,由调用方靠后缀去重。 func AutoAliasFor(toName, subject string) string { base := sanitizeAliasPart(toName) topic := sanitizeAliasPart(subject) switch { case base == "" && topic == "": // 两边都拿不出可用字符(例如主题全是标点、名字为空)。 // 返回空串让调用方走随机兜底,不要在这里编造。 return "" case base == "": return truncateAlias(topic) case topic == "": return truncateAlias(base) default: return truncateAlias(base + "-" + topic) } } // EnsureSessionAlias 保证会话拥有一个可寻址的别名,返回最终别名。 // // 已有别名时原样返回,不做任何写入 —— 这让它可以被无条件调用, // 包括「默认会话」路径上那条可能是刚建的、也可能是复用的会话。 // // 撞名时追加 -2、-3… 后缀;`want` 为空或全部被占用时退回 // `session-`:一个能寻址的丑名字,远胜于没有名字。 func EnsureSessionAlias(ctx context.Context, id uuid.UUID, want string) (string, error) { if cur := SessionAliasOf(ctx, id); cur != "" { return cur, nil } cands := make([]string, 0, autoAliasAttempts+1) if want != "" { for i := 0; i < autoAliasAttempts; i++ { if i == 0 { cands = append(cands, want) continue } cands = append(cands, truncateAlias(fmt.Sprintf("%s-%d", want, i+1))) } } // 兜底:uuid 前 8 位。碰撞概率可忽略,且与 want 无关, // 因此即便主题里一个可用字符都没有也总能拿到别名。 cands = append(cands, "session-"+id.String()[:8]) for _, c := range cands { // 条件写入:`session_alias IS NULL OR = ''` 保证并发下只有一方写成功, // 另一方 RowsAffected=0,随后重读拿到对方写的名字 —— // 两个请求都返回同一个别名,而不是各自以为自己命名成功。 res, err := db.DB.ExecContext(ctx, `UPDATE sessions SET session_alias = $1, updated_at = NOW() WHERE session_id = $2 AND (session_alias IS NULL OR session_alias = '')`, c, id) if err != nil { if db.IsUniqueViolation(err) { continue // 别名被别的会话占了,试下一个后缀 } return "", err } if n, _ := res.RowsAffected(); n == 0 { // 期间别人(并发请求或平台同步)已经命名过,尊重那个名字 if cur := SessionAliasOf(ctx, id); cur != "" { return cur, nil } // 写不进去且读不到名字,只可能是会话刚被删 return "", fmt.Errorf("会话 %s 已不存在,无法分配别名", id) } return c, nil } return "", fmt.Errorf("别名 %q 连同 -2..-%d 后缀与 uuid 兜底均被占用", want, autoAliasAttempts) } // sanitizeAliasPart 把任意文本压成别名可用的片段。 // // 规则与 normalizeAlias 一致(非法字符换 -、压缩连续 -、去首尾 -), // 另外多做两件事: // // - **去掉 Markdown / 标点噪声**:主题里的 `[联调]`、`—`、`:` 变成一串 // 破折号毫无信息量。只保留字母、数字与非标点的 Unicode 字符(中文、日文等)。 // - **压缩空白**:`Re: 服务恢复验证` → `Re-服务恢复验证`,而不是 `Re--服务恢复验证`。 // // 保留中文是刻意的:本项目的会话主题多为中文,转拼音需要额外依赖, // 而 `dsh-重构导入路径` 作为地址完全可用(三维寻址只忌 `. / @` 与空白)。 func sanitizeAliasPart(s string) string { var b strings.Builder lastDash := false for _, r := range s { keep := unicode.IsLetter(r) || unicode.IsDigit(r) if keep { b.WriteRune(r) lastDash = false continue } // 其余一切(空白、标点、符号、寻址保留字符)都折成单个 - if !lastDash && b.Len() > 0 { b.WriteByte('-') lastDash = true } } out := strings.Trim(b.String(), "-") // "new" 是寻址保留字,作为整体别名时必须避开。 // 加前缀而不是拒绝:调用方给的素材没有错,是这个词恰好被占用。 if out == "new" { return "session-new" } return out } // truncateAlias 按字节截断且不切坏多字节字符(中文主题很容易超 128 字节)。 func truncateAlias(s string) string { if len(s) <= aliasMaxBytes { return strings.Trim(s, "-") } cut := s[:aliasMaxBytes] for len(cut) > 0 && !utf8.ValidString(cut) { cut = cut[:len(cut)-1] } return strings.Trim(cut, "-") }