## 别名替换(让 .new 邮件可寻址)
repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调
notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new
FormatAddress(name,path,session) 空 path 也必须留 @ 与 .
## Agent 侧寻址发现(五个只读端点)
handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做
repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)
repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空
## 共用模块(三插件逐字节相同)
lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
renderParticipants/renderContacts/renderThread
lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
- selfName 参数(兼容旧调用不传的情况)
check-shared-libs.sh 纳入 addressing + discovery
## 插件侧
opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)
dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)
## 测试
repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
→ dsh 用 session_participants 取到地址 → send_mail 给 opencode
→ 地址取自工具返回值(.crisp-planet),未手工拼写
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, "-")
|
||
}
|