采集面:mediafetch 新增 inline_voice.go(音频白名单,与 documents.ExtAllowed 互不污染; 复用既有来源白名单 / 逐跳校验 / 边读边限 / 超时);四个适配器零改动。 消费面:channelbridge 在流水线最前端把「语音字节 → 文本」归一并写回 msg.Content; inbound.go 的丢空判据放宽为「无文本且无可用媒体」。 微信优先平台自带转写(Content 非空 ⇒ 不调 ASR,不重复付费)。 降级(主人定的两层):新增 voice/outcome.go 的 STTOutcome 识破静默档位; errors.go 分类鉴权失败/欠费/限流(CircuitWorthy);voice_breaker.go 临时熔断 + 退避(TTL 恢复); 无论是否熔断,消息面都落一行中文诚实提示(绝不静默、绝不留空消息)。 独立短超时:VOICE_STT_TIMEOUT_SEC(默认 20s,同步 .env.example)。 kook 不再把 CDN 地址写进 Content(地址仍在 Media[].URL)。 设计先行:design/06 §1.1/§1.2、design/08 §4.1、design/24 §1.3/§4.2、开源对照清单。 变异 5 条(静默档位不带 Cause / Transcribed 误判 / 熔断恒放行 / ChatText 退化 / 平台文字判据取反)。 诚实边界:实施时官方音频规格尚未抓到 ⇒ MaxVoiceBytes 自定 5 MiB(保守)、采样率仍 16 kHz; 官方规格随后已抓到(10 MB / 5 min;格式含 amr、无 silk),对齐留待下次改动。
247 lines
8.1 KiB
Go
247 lines
8.1 KiB
Go
package mediafetch
|
|
|
|
// 本文件锁定共享内联循环的口径(切片 IM-1):预筛、限额、条数上限、失败不炸。
|
|
// 「正文里的任意 URL ⇒ 0 次命中」这条红线在各适配器的用例里断言(只有适配器才知道
|
|
// 正文长什么样);这里锁的是「循环只会把**媒体引用**交给 Fetcher」这条结构性事实。
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
|
)
|
|
|
|
// countingFetcher 记录每次被要求取回的媒体引用(引用原串与次数),使「0 次命中」
|
|
// 与「命中次数」都可断言。
|
|
type countingFetcher struct {
|
|
mu sync.Mutex
|
|
seen []string
|
|
data []byte
|
|
err error
|
|
maxHit int
|
|
}
|
|
|
|
func (f *countingFetcher) Fetch(_ context.Context, m adapter.Media) ([]byte, error) {
|
|
f.mu.Lock()
|
|
f.seen = append(f.seen, m.URL)
|
|
n := len(f.seen)
|
|
f.mu.Unlock()
|
|
if f.maxHit > 0 && n > f.maxHit {
|
|
return nil, errors.New("超过了本用例允许的取回次数")
|
|
}
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
return f.data, nil
|
|
}
|
|
|
|
func (f *countingFetcher) calls() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return len(f.seen)
|
|
}
|
|
|
|
// testLogger 返回一个丢弃输出的带来源日志器:用例不关心内容,只保证路径不 panic。
|
|
func testLogger(t *testing.T) *slog.Logger {
|
|
t.Helper()
|
|
return logger.WithSource(logger.New(logger.Options{Out: io.Discard}), "internal/channel/mediafetch")
|
|
}
|
|
|
|
// txtMessage 构造一条「带一个 .txt 附件」的入站消息。
|
|
func txtMessage(url string) *adapter.UnifiedMessage {
|
|
return &adapter.UnifiedMessage{
|
|
Platform: "test",
|
|
ContentType: adapter.ContentFile,
|
|
Content: "看看这个文件 " + url, // 正文里也带同一个 URL:正文**不参与**取回决策
|
|
Media: []adapter.Media{{
|
|
Kind: adapter.MediaFile, URL: url, Name: "notes.txt", MIMEType: "text/plain", Size: 12,
|
|
}},
|
|
}
|
|
}
|
|
|
|
// TestInlineDownloadsAllowedFile 是正向用例:白名单格式的文件附件被内联进 Data。
|
|
func TestInlineDownloadsAllowedFile(t *testing.T) {
|
|
t.Parallel()
|
|
f := &countingFetcher{data: []byte("hello\n")}
|
|
msg := txtMessage("https://cdn.example.com/notes.txt")
|
|
|
|
Inline(context.Background(), testLogger(t), msg, f)
|
|
|
|
if f.calls() != 1 {
|
|
t.Fatalf("取回次数 = %d, want 1", f.calls())
|
|
}
|
|
if string(msg.Media[0].Data) != "hello\n" {
|
|
t.Fatalf("Data = %q, want 取回的字节", msg.Media[0].Data)
|
|
}
|
|
}
|
|
|
|
// TestInlineNeverFetchesFromBody 钉住结构性事实:循环只把 `Media[].URL` 交给
|
|
// Fetcher —— 正文里那个 URL 永远不是取回对象(它是同一条文本,但只作为字符串存在)。
|
|
func TestInlineNeverFetchesFromBody(t *testing.T) {
|
|
t.Parallel()
|
|
f := &countingFetcher{data: []byte("ok")}
|
|
msg := txtMessage("https://cdn.example.com/notes.txt")
|
|
bodyURL := "https://evil.example.com/secret.txt"
|
|
msg.Content = "读一下 " + bodyURL + " 和 " + msg.Media[0].URL
|
|
msg.Media = nil // 只有正文带 URL、没有任何文件媒体
|
|
|
|
Inline(context.Background(), testLogger(t), msg, f)
|
|
|
|
if f.calls() != 0 {
|
|
t.Fatalf("正文里的 URL 被取回了(%v),want 0 次", f.seen)
|
|
}
|
|
}
|
|
|
|
// TestInlineSkipsUnsupportedExtensionWithoutFetch 钉住预筛:非白名单格式**不下载**。
|
|
func TestInlineSkipsUnsupportedExtensionWithoutFetch(t *testing.T) {
|
|
t.Parallel()
|
|
for _, name := range []string{"payload.exe", "script.js", "page.html", "archive.zip", ""} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
f := &countingFetcher{data: []byte("x")}
|
|
msg := txtMessage("https://cdn.example.com/whatever")
|
|
msg.Media[0].Name = name
|
|
|
|
Inline(context.Background(), testLogger(t), msg, f)
|
|
|
|
if f.calls() != 0 {
|
|
t.Fatalf("格式 %q 不在白名单里却发起了取回", name)
|
|
}
|
|
if len(msg.Media[0].Data) != 0 {
|
|
t.Fatalf("格式 %q 不该被内联", name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestInlineSkipsDeclaredOversizeWithoutFetch 钉住「平台声明的体积超上限时连请求都
|
|
// 不发」(省一次必然失败的大传输)。
|
|
func TestInlineSkipsDeclaredOversizeWithoutFetch(t *testing.T) {
|
|
t.Parallel()
|
|
f := &countingFetcher{data: []byte("x")}
|
|
msg := txtMessage("https://cdn.example.com/big.txt")
|
|
msg.Media[0].Size = documents.DefaultMaxBytes + 1
|
|
|
|
Inline(context.Background(), testLogger(t), msg, f)
|
|
|
|
if f.calls() != 0 {
|
|
t.Fatalf("声明超限的附件仍被取回 %d 次,want 0", f.calls())
|
|
}
|
|
}
|
|
|
|
// TestInlineFailureKeepsMessageAndContinues 钉住「失败不炸」:一份取不到不阻断其余
|
|
// 附件,也不改动消息的其他字段。
|
|
func TestInlineFailureKeepsMessageAndContinues(t *testing.T) {
|
|
t.Parallel()
|
|
f := &countingFetcher{err: ErrBadStatus}
|
|
msg := txtMessage("https://cdn.example.com/a.txt")
|
|
msg.Media = append(msg.Media, adapter.Media{
|
|
Kind: adapter.MediaFile, URL: "https://cdn.example.com/b.txt", Name: "b.txt",
|
|
})
|
|
|
|
Inline(context.Background(), testLogger(t), msg, f)
|
|
|
|
if f.calls() != 2 {
|
|
t.Fatalf("取回次数 = %d, want 2(一次失败不阻断第二次)", f.calls())
|
|
}
|
|
for i, m := range msg.Media {
|
|
if len(m.Data) != 0 {
|
|
t.Fatalf("Media[%d] 在失败时被写入了字节", i)
|
|
}
|
|
}
|
|
if !strings.Contains(msg.Content, "https://cdn.example.com/a.txt") {
|
|
t.Fatalf("失败路径改动了正文:%q", msg.Content)
|
|
}
|
|
}
|
|
|
|
// TestInlineCapsAttachmentsPerMessage 钉住条数上限与 Web 侧同源(documents.MaxPerMessage)。
|
|
func TestInlineCapsAttachmentsPerMessage(t *testing.T) {
|
|
t.Parallel()
|
|
f := &countingFetcher{data: []byte("x")}
|
|
msg := txtMessage("https://cdn.example.com/0.txt")
|
|
for i := 1; i <= documents.MaxPerMessage+2; i++ {
|
|
msg.Media = append(msg.Media, adapter.Media{
|
|
Kind: adapter.MediaFile, URL: "https://cdn.example.com/x", Name: "x.txt",
|
|
})
|
|
}
|
|
|
|
Inline(context.Background(), testLogger(t), msg, f)
|
|
|
|
if f.calls() != documents.MaxPerMessage {
|
|
t.Fatalf("取回次数 = %d, want %d(与 Web 侧同一上限)", f.calls(), documents.MaxPerMessage)
|
|
}
|
|
}
|
|
|
|
// TestInlineSkipsMediaThatAlreadyHasBytes 钉住「已有字节不重复下载」(本地产出的
|
|
// 附件照原样通过)。
|
|
func TestInlineSkipsMediaThatAlreadyHasBytes(t *testing.T) {
|
|
t.Parallel()
|
|
f := &countingFetcher{data: []byte("new")}
|
|
msg := txtMessage("https://cdn.example.com/a.txt")
|
|
msg.Media[0].Data = []byte("local")
|
|
|
|
Inline(context.Background(), testLogger(t), msg, f)
|
|
|
|
if f.calls() != 0 {
|
|
t.Fatalf("已有字节的附件被重复下载 %d 次", f.calls())
|
|
}
|
|
if string(msg.Media[0].Data) != "local" {
|
|
t.Fatalf("本地字节被覆盖:%q", msg.Media[0].Data)
|
|
}
|
|
}
|
|
|
|
// TestInlineIgnoresImageAndNonAudioVoice 钉住边界:图片媒体不走本循环(图片走视觉
|
|
// 链路),**格式不可转写**的语音也不取字节(切片 IM-ASR 起,白名单格式的语音**会**
|
|
// 被取回,见 inline_voice_test.go)。
|
|
func TestInlineIgnoresImageAndNonAudioVoice(t *testing.T) {
|
|
t.Parallel()
|
|
f := &countingFetcher{data: []byte("x")}
|
|
msg := &adapter.UnifiedMessage{
|
|
Platform: "test",
|
|
ContentType: adapter.ContentImage,
|
|
Media: []adapter.Media{
|
|
{Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.txt", Name: "a.txt"},
|
|
{Kind: adapter.MediaVoice, URL: "https://cdn.example.com/b.txt", Name: "b.txt"},
|
|
},
|
|
}
|
|
|
|
Inline(context.Background(), testLogger(t), msg, f)
|
|
|
|
if f.calls() != 0 {
|
|
t.Fatalf("非文件类媒体被取回了 %d 次,want 0", f.calls())
|
|
}
|
|
}
|
|
|
|
// TestDisplayNameFallsBackToURLPath 钉住缺名时的兜底:从资产地址路径末段取扩展名。
|
|
func TestDisplayNameFallsBackToURLPath(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
url string
|
|
want string
|
|
}{
|
|
{"文件名优先", "https://cdn.example.com/download?id=1", "report.csv"},
|
|
{"无名字取路径末段", "https://cdn.example.com/2024/report.csv", "report.csv"},
|
|
{"路径无末段则空", "https://cdn.example.com/", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
m := adapter.Media{Name: "", URL: tc.url}
|
|
if tc.name == "文件名优先" {
|
|
m.Name = "report.csv"
|
|
}
|
|
if got := displayName(m); got != tc.want {
|
|
t.Fatalf("displayName = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|