按协议而不是按厂商做适配,与现有短信、对象存储的做法一致:openai 兼容格式 一个适配器即可覆盖 DeepSeek、通义、智谱、火山方舟、Ollama 等,新增厂商通常 只需在后台加一条模型记录;anthropic、gemini、webhook、debug 各一个适配器。 密钥沿用 integration.go 的 AES-GCM 加密存储。 测试账号托管的回复走 persistMessage 同一条落库和推送路径,因此未读数、 WebSocket 推送和会话排序全部复用现有逻辑,客户端无需改动。为此把 persistMessage 抽出 persistMessageContext,因为 worker 没有 *http.Request。 任务队列用数据库表而非内存:重启不丢回复,多实例不重复消费。领取用 UPDATE 打 claim_token 再回读,没有用 SELECT ... FOR UPDATE SKIP LOCKED——生产存在 MySQL 5.7 环境,那里该语法无法解析。同一会话同时只允许一个待处理任务 (pending_key 生成列 + 唯一键),所以用户连发多条消息只会得到一条回复。 闸门:仅 is_test=1 且在允许批次内的账号生效,托管账号之间不互相触发,三层 配额,调用失败默认静默。会话内是否标注 AI 身份由 ai.disclose_in_chat 控制 并默认开启,关闭前需确认所在地区的监管要求。 app.go、integration.go、im.go 三个文件同时包含本次改动之前工作区里就已存在 的未提交修改,一并带入。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
88 lines
3.3 KiB
Go
88 lines
3.3 KiB
Go
package app
|
||
|
||
import (
|
||
"encoding/json"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func TestSanitizeAIReplyFlattensAssistantFormatting(t *testing.T) {
|
||
tests := []struct {
|
||
name, input, want string
|
||
}{
|
||
{name: "trim", input: " 在的 ", want: "在的"},
|
||
{name: "markdown", input: "**在的**,__刚看到__", want: "在的,刚看到"},
|
||
{name: "bullets", input: "- 第一点\n- 第二点", want: "第一点 第二点"},
|
||
{name: "numbered", input: "1. 先这样\n2. 再那样", want: "先这样 再那样"},
|
||
{name: "quote", input: "> 引用\n正文", want: "引用 正文"},
|
||
{name: "code fence", input: "好的\n```go\nfmt.Println()\n```", want: "好的"},
|
||
{name: "blank", input: "\n\n \n", want: ""},
|
||
}
|
||
for _, test := range tests {
|
||
if got := sanitizeAIReply(test.input); got != test.want {
|
||
t.Errorf("sanitizeAIReply(%s) = %q, want %q", test.name, got, test.want)
|
||
}
|
||
}
|
||
long := strings.Repeat("字", aiReplyMaxRunes+40)
|
||
if got := []rune(sanitizeAIReply(long)); len(got) != aiReplyMaxRunes {
|
||
t.Errorf("a long reply must be cut to %d runes, got %d", aiReplyMaxRunes, len(got))
|
||
}
|
||
}
|
||
|
||
func TestAISystemPromptFallsBackToProfile(t *testing.T) {
|
||
binding := aiAgentBinding{Nickname: "季若宁", Age: 27, City: "青岛", Bio: "喜欢电影与绘画"}
|
||
prompt := aiSystemPrompt(binding, true)
|
||
for _, fragment := range []string{"季若宁", "27 岁", "青岛", "喜欢电影与绘画"} {
|
||
if !strings.Contains(prompt, fragment) {
|
||
t.Errorf("profile-derived persona must mention %q: %s", fragment, prompt)
|
||
}
|
||
}
|
||
// Disclosure is a monitored requirement, so it has to be switchable and
|
||
// present by default.
|
||
if !strings.Contains(prompt, "AI 助手") {
|
||
t.Error("disclosure must add the honesty rule")
|
||
}
|
||
if strings.Contains(aiSystemPrompt(binding, false), "如果对方询问") {
|
||
t.Error("disclosure off must drop the rule")
|
||
}
|
||
custom := aiSystemPrompt(aiAgentBinding{Persona: "你是一个健身教练", Nickname: "忽略我"}, false)
|
||
if !strings.Contains(custom, "健身教练") || strings.Contains(custom, "忽略我") {
|
||
t.Errorf("an explicit persona must replace the profile text: %s", custom)
|
||
}
|
||
if !strings.Contains(custom, "不索取或提供手机号") {
|
||
t.Error("safety rules must survive a custom persona")
|
||
}
|
||
}
|
||
|
||
func TestAIMessageTextDegradesMedia(t *testing.T) {
|
||
text, _ := json.Marshal(map[string]any{"text": " 你好 "})
|
||
if got := aiMessageText(1, text); got != "你好" {
|
||
t.Errorf("text message = %q", got)
|
||
}
|
||
if got := aiMessageText(2, []byte(`{"url":"https://example.com/a.jpg"}`)); got != "[图片]" {
|
||
t.Errorf("image placeholder = %q", got)
|
||
}
|
||
if got := aiMessageText(3, []byte(`{"url":"https://example.com/a.m4a","duration":3}`)); got != "[语音]" {
|
||
t.Errorf("audio placeholder = %q", got)
|
||
}
|
||
if got := aiMessageText(1, []byte(`not json`)); got != "" {
|
||
t.Errorf("a broken body must be skipped, got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestAIBatchAllowList(t *testing.T) {
|
||
if !aiBatchAllowed("", "any-batch") || !aiBatchAllowed("", "") {
|
||
t.Error("an empty allow list must accept every test account")
|
||
}
|
||
if !aiBatchAllowed("batch-a, batch-b", "batch-b") {
|
||
t.Error("a listed batch must be accepted")
|
||
}
|
||
if aiBatchAllowed("batch-a", "batch-c") {
|
||
t.Error("an unlisted batch must be refused")
|
||
}
|
||
// An account with no batch must not slip through a non-empty allow list.
|
||
if aiBatchAllowed("batch-a", "") {
|
||
t.Error("an empty batch must not match a configured allow list")
|
||
}
|
||
}
|