Files
MailUI4Agents/gateway/internal/models/address.go
JianFeeeee e6fd2fafdc feat: agent 邮件寻址能力全面补齐 + .new 别名替换
## 别名替换(让 .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),未手工拼写
2026-09-03 12:09:12 +08:00

169 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package models
import (
"fmt"
"strings"
)
// Address 是三维寻址 name@path.session 的解析结果
type Address struct {
Name string `json:"name"` // Agent 实例名(或人类用户名)
Path string `json:"path"` // 工作区路径(可含 /,可为空)
Session string `json:"session"` // 会话别名;"new" = 新建;"" = 默认会话
Raw string `json:"raw"` // 原始字符串
}
// SessionMode 是 session 位的三种语义
type SessionMode int
const (
// SessionDefaultsession 位省略 → 投递到 name@path 的默认会话(不存在则建立)
SessionDefault SessionMode = iota
// SessionNewsession 位为 new → 强制新建一个会话
SessionNew
// SessionNamedsession 位为具体别名 → 必须已存在,否则无法送达
SessionNamed
)
// Mode 返回该地址 session 位的语义
func (a Address) Mode() SessionMode {
switch a.Session {
case "":
return SessionDefault
case "new":
return SessionNew
default:
return SessionNamed
}
}
// IsNewSession 表示该地址要求新建会话(仅 session == "new")。
// 注意session 位省略不等于 new那是「默认会话」见 Mode()。
func (a Address) IsNewSession() bool {
return a.Mode() == SessionNew
}
// IsDefaultSession 表示该地址省略了 session 位,走默认会话
func (a Address) IsDefaultSession() bool {
return a.Mode() == SessionDefault
}
func (a Address) String() string {
return a.Raw
}
// ParseAddress 解析 name@path.session 三维地址。
//
// 支持形态:
//
// deepseekharness@/program.updatefeature → name=deepseekharness path=/program session=updatefeature
// pi@root.new → name=pi path=root session=new新建
// builder@ModelRouter.fix-leak → name=builder path=ModelRouter session=fix-leak
// human@.new → name=human path="" session=new
// human → name=human path="" session=""(默认会话)
//
// 规则:
// - 第一个 @ 之前是 name必填
// - @ 之后按【最后一个 .】切成 path 与 session因此 path 内可以包含 . 与 /
// - 没有 . 时,整段视为 pathsession 为空(默认会话)
//
// session 位三态语义见 Address.Mode():省略=默认会话new=新建,其他=必须已存在。
func ParseAddress(s string) (Address, error) {
raw := strings.TrimSpace(s)
if raw == "" {
return Address{}, fmt.Errorf("empty address")
}
// 只有形如 "@name@path.session" 时才剥掉前导 @
// "@ModelRouter.new" 缺少 name应当报错而不是被当成名字。
trimmed := raw
if strings.HasPrefix(raw, "@") && strings.Contains(raw[1:], "@") {
trimmed = raw[1:]
}
at := strings.Index(trimmed, "@")
if at < 0 {
// 只有名字human / builder
name := strings.TrimSpace(trimmed)
if name == "" {
return Address{}, fmt.Errorf("missing agent name in %q", raw)
}
return Address{Name: name, Raw: raw}, nil
}
name := strings.TrimSpace(trimmed[:at])
if name == "" {
return Address{}, fmt.Errorf("missing agent name in %q", raw)
}
rest := trimmed[at+1:]
// 按最后一个 . 切 path / sessionpath 内允许 / 与 .
var path, session string
if dot := strings.LastIndex(rest, "."); dot >= 0 {
path = rest[:dot]
session = rest[dot+1:]
} else {
path = rest
}
return Address{
Name: name,
Path: strings.TrimSpace(path),
Session: strings.TrimSpace(session),
Raw: raw,
}, nil
}
// FormatAddress 把三段拼回可寻址的 name@path.session。
//
// **必须走这个函数而不是自己拼字符串**path 为空时(人类用户没有工作区)
// 朴素拼接得到 "admin.silent-harbor",而它没有 @ParseAddress 会把整串当成
// 名字session 位丢失,地址静默失效。空 path 也必须留下那个 @ 与 . ——
// "admin@.silent-harbor" 才解析成 name=admin path="" session=silent-harbor。
//
// session 传空则省略该位(默认会话语义)。
func FormatAddress(name, path, session string) string {
name = strings.TrimSpace(name)
path = strings.TrimSpace(path)
session = strings.TrimSpace(session)
if name == "" {
return ""
}
if session == "" {
if path == "" {
return name
}
return name + "@" + path
}
return name + "@" + path + "." + session
}
// WithSession 返回同一收件方在指定会话下的地址。
// 用于把 .new 换成刚建出来的会话别名 —— 参与方拿到的地址必须是能再次投递的那个。
func (a Address) WithSession(session string) string {
return FormatAddress(a.Name, a.Path, session)
}
// ParseAddressList 解析逗号/分号/空白分隔的多个地址(用于 CC
func ParseAddressList(s string) ([]Address, error) {
raw := strings.TrimSpace(s)
if raw == "" {
return nil, nil
}
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == ';' || r == '\n' || r == '\t' || r == ' '
})
out := make([]Address, 0, len(fields))
for _, f := range fields {
addr, err := ParseAddress(f)
if err != nil {
return nil, err
}
out = append(out, addr)
}
return out, nil
}