@@ -0,0 +1,72 @@
|
||||
// Package volcsubtitle implements the native RTS subtitle callback documented at
|
||||
// https://docs.volcengine.com/docs/6348/1337284 (verified 2026-09-06).
|
||||
package volcsubtitle
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var ErrInvalid = errors.New("invalid native subtitle packet")
|
||||
|
||||
type Envelope struct {
|
||||
Message string `json:"message"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
type Segment struct {
|
||||
UserID string `json:"userId"`
|
||||
RoundID *int64 `json:"roundId"`
|
||||
Sequence *int64 `json:"sequence"`
|
||||
Text string `json:"text"`
|
||||
Definite bool `json:"definite"`
|
||||
Paragraph bool `json:"paragraph"`
|
||||
}
|
||||
type Packet struct {
|
||||
Type string `json:"type"`
|
||||
Data []Segment `json:"data"`
|
||||
DeviceName string `json:"deviceName,omitempty"`
|
||||
ProductKey string `json:"productKey,omitempty"`
|
||||
}
|
||||
|
||||
// Bind authentication to the platform conversation. A delayed callback can
|
||||
// never be reassigned by looking up the device's newest active conversation.
|
||||
func Signature(secret []byte, conversationID string) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write([]byte("volc-subtitle-v1\n" + conversationID))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func Decode(message string) (Packet, error) {
|
||||
var p Packet
|
||||
if len(message) > 350000 {
|
||||
return p, ErrInvalid
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(message)
|
||||
if err != nil || len(data) == 0 {
|
||||
return p, ErrInvalid
|
||||
}
|
||||
if data[0] != '{' {
|
||||
if len(data) < 8 || string(data[:4]) != "subv" || uint64(binary.BigEndian.Uint32(data[4:8])) != uint64(len(data)-8) {
|
||||
return p, ErrInvalid
|
||||
}
|
||||
data = data[8:]
|
||||
}
|
||||
if !utf8.Valid(data) {
|
||||
return p, ErrInvalid
|
||||
}
|
||||
if json.Unmarshal(data, &p) != nil || p.Type != "subtitle" || len(p.Data) == 0 || len(p.Data) > 100 || len(p.DeviceName) > 128 || len(p.ProductKey) > 200 {
|
||||
return Packet{}, ErrInvalid
|
||||
}
|
||||
for _, s := range p.Data {
|
||||
if s.UserID == "" || len(s.UserID) > 200 || s.RoundID == nil || s.Sequence == nil ||
|
||||
*s.RoundID < 0 || *s.Sequence < 0 || len([]rune(s.Text)) > 20000 {
|
||||
return Packet{}, ErrInvalid
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package volcsubtitle
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func frame(body string) string {
|
||||
b := append(make([]byte, 8), []byte(body)...)
|
||||
copy(b, "subv")
|
||||
binary.BigEndian.PutUint32(b[4:8], uint32(len(b)-8))
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func TestDecodeNativeSubtitle(t *testing.T) {
|
||||
valid := `{"type":"subtitle","data":[{"userId":"device-a","roundId":0,"sequence":0,"definite":true,"paragraph":false,"text":"你好"},{"userId":"bot-a","roundId":0,"sequence":1,"definite":true,"paragraph":true,"text":"下午好"}]}`
|
||||
p, err := Decode(frame(valid))
|
||||
if err != nil || len(p.Data) != 2 || p.Data[0].Paragraph || p.Data[0].Text != "你好" {
|
||||
t.Fatalf("%+v %v", p, err)
|
||||
}
|
||||
for _, s := range []string{"", "not base64!", base64.StdEncoding.EncodeToString([]byte("subv")), frame(`{"type":"other","data":[]}`), frame(`{"type":"subtitle","data":[{"userId":"a","sequence":0}]}`), frame(`{"type":"subtitle","data":[{"userId":"a","roundId":0,"sequence":-1}]}`)} {
|
||||
if _, err := Decode(s); err == nil {
|
||||
t.Errorf("accepted malformed packet %q", s)
|
||||
}
|
||||
}
|
||||
decoded, _ := base64.StdEncoding.DecodeString(frame(valid))
|
||||
decoded[7]++
|
||||
if _, err := Decode(base64.StdEncoding.EncodeToString(decoded)); err == nil {
|
||||
t.Fatal("accepted incorrect binary length")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureIsConversationScoped(t *testing.T) {
|
||||
secret := []byte("dedicated-test-secret-32-bytes-long")
|
||||
if Signature(secret, "a") == Signature(secret, "b") || Signature(secret, "a") == Signature([]byte("other"), "a") {
|
||||
t.Fatal("signature must bind both secret and conversation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHardwareJSONSubtitle(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"type":"subtitle","deviceName":"test-device","productKey":"test-product","data":[{"userId":"input","roundId":1,"sequence":0,"text":"测试","definite":true}]}`,
|
||||
`{"type":"subtitle","deviceName":"test-device","productKey":"test-product","data":[{"userId":"output","roundId":1,"sequence":1,"text":"","definite":true,"paragraph":true}]}`,
|
||||
} {
|
||||
for _, encoded := range []string{base64.StdEncoding.EncodeToString([]byte(body)), frame(body)} {
|
||||
p, err := Decode(encoded)
|
||||
if err != nil || p.DeviceName != "test-device" || p.ProductKey != "test-product" {
|
||||
t.Fatalf("hardware packet=%+v err=%v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user