178 lines
7.1 KiB
Go
178 lines
7.1 KiB
Go
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-<uuid 前 8 位>`:一个能寻址的丑名字,远胜于没有名字。
|
||
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, "-")
|
||
}
|