@openclaw-china/shared 0.1.29 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw-china/shared",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -0,0 +1,100 @@
1
+ # ASR 接入说明(供各插件复用)
2
+
3
+ 本文说明如何在任意渠道插件中复用 `@openclaw-china/shared` 的语音转文本能力。
4
+
5
+ 当前已提供:
6
+ - 腾讯云录音文件识别极速版(`asr/flash/v1`)
7
+ - 统一错误类型(便于插件侧日志和降级)
8
+
9
+ ## 职责边界
10
+
11
+ `shared` 负责:
12
+ - 调用 ASR 服务(签名、请求、响应解析、超时)
13
+ - 抛出结构化错误(`ASRError` 及子类)
14
+
15
+ 单个插件负责:
16
+ - 平台事件解析(如何识别语音消息)
17
+ - 媒体下载(URL/鉴权/大小限制)
18
+ - 业务策略(失败是否降级、回复文案、是否中断本次处理)
19
+ - 密钥配置(建议保留在插件配置内)
20
+
21
+ ## 可直接使用的导出
22
+
23
+ ```ts
24
+ import {
25
+ transcribeTencentFlash,
26
+ ASRError,
27
+ ASRTimeoutError,
28
+ ASRAuthError,
29
+ ASRRequestError,
30
+ ASRResponseParseError,
31
+ ASRServiceError,
32
+ ASREmptyResultError,
33
+ } from "@openclaw-china/shared";
34
+ ```
35
+
36
+ ## 插件接入步骤
37
+
38
+ 1. 在插件配置中增加 ASR 字段(示例)
39
+ ```ts
40
+ asr: {
41
+ enabled?: boolean;
42
+ appId?: string;
43
+ secretId?: string;
44
+ secretKey?: string;
45
+ }
46
+ ```
47
+
48
+ 2. 在入站消息里识别语音附件,并下载到 `Buffer`
49
+ - 推荐复用 `shared/media` 的下载能力:
50
+ ```ts
51
+ import { fetchMediaFromUrl } from "@openclaw-china/shared";
52
+ ```
53
+
54
+ 3. 调用转写
55
+ ```ts
56
+ const transcript = await transcribeTencentFlash({
57
+ audio: media.buffer,
58
+ config: {
59
+ appId: asr.appId,
60
+ secretId: asr.secretId,
61
+ secretKey: asr.secretKey,
62
+ // 可选,默认如下:
63
+ // engineType: "16k_zh",
64
+ // voiceFormat: "silk",
65
+ // timeoutMs: 30000,
66
+ },
67
+ });
68
+ ```
69
+
70
+ 4. 按错误类型做日志与降级
71
+ ```ts
72
+ try {
73
+ // transcribeTencentFlash(...)
74
+ } catch (err) {
75
+ if (err instanceof ASRError) {
76
+ logger.warn(
77
+ `asr failed kind=${err.kind} provider=${err.provider} retryable=${err.retryable} msg=${err.message}`
78
+ );
79
+ } else {
80
+ logger.warn(`asr failed: ${String(err)}`);
81
+ }
82
+ // 在这里决定是否回退文本、提示用户、或继续其他链路
83
+ }
84
+ ```
85
+
86
+ ## 错误类型语义
87
+
88
+ - `ASRTimeoutError`:请求超时,通常可重试
89
+ - `ASRAuthError`:鉴权失败(密钥错误/权限问题),通常不可重试
90
+ - `ASRRequestError`:请求失败(网络或 HTTP 失败)
91
+ - `ASRResponseParseError`:服务返回非 JSON 或格式异常
92
+ - `ASRServiceError`:服务端返回业务错误码(`code != 0`)
93
+ - `ASREmptyResultError`:识别成功但无文本
94
+
95
+ ## 最佳实践
96
+
97
+ - 不要把密钥硬编码到仓库;仅通过配置注入。
98
+ - 给下载和 ASR 都设置超时与大小上限。
99
+ - 将“ASR 失败文案”放在插件侧,不放到 `shared`。
100
+ - 先记录结构化日志,再决定业务降级策略。
@@ -0,0 +1,61 @@
1
+ export type ASRErrorKind =
2
+ | "timeout"
3
+ | "auth"
4
+ | "request"
5
+ | "response_parse"
6
+ | "service"
7
+ | "empty_result";
8
+
9
+ export class ASRError extends Error {
10
+ constructor(
11
+ message: string,
12
+ public readonly kind: ASRErrorKind,
13
+ public readonly provider: string,
14
+ public readonly retryable: boolean = false
15
+ ) {
16
+ super(message);
17
+ this.name = "ASRError";
18
+ }
19
+ }
20
+
21
+ export class ASRTimeoutError extends ASRError {
22
+ constructor(provider: string, public readonly timeoutMs: number) {
23
+ super(`ASR request timeout after ${timeoutMs}ms`, "timeout", provider, true);
24
+ this.name = "ASRTimeoutError";
25
+ }
26
+ }
27
+
28
+ export class ASRAuthError extends ASRError {
29
+ constructor(provider: string, message: string, public readonly status?: number) {
30
+ super(message, "auth", provider, false);
31
+ this.name = "ASRAuthError";
32
+ }
33
+ }
34
+
35
+ export class ASRRequestError extends ASRError {
36
+ constructor(provider: string, message: string, public readonly status?: number) {
37
+ super(message, "request", provider, true);
38
+ this.name = "ASRRequestError";
39
+ }
40
+ }
41
+
42
+ export class ASRResponseParseError extends ASRError {
43
+ constructor(provider: string, public readonly bodySnippet: string) {
44
+ super("ASR response is not valid JSON", "response_parse", provider, false);
45
+ this.name = "ASRResponseParseError";
46
+ }
47
+ }
48
+
49
+ export class ASRServiceError extends ASRError {
50
+ constructor(provider: string, message: string, public readonly serviceCode?: number) {
51
+ super(message, "service", provider, false);
52
+ this.name = "ASRServiceError";
53
+ }
54
+ }
55
+
56
+ export class ASREmptyResultError extends ASRError {
57
+ constructor(provider: string) {
58
+ super("ASR returned empty transcript", "empty_result", provider, false);
59
+ this.name = "ASREmptyResultError";
60
+ }
61
+ }
@@ -0,0 +1,11 @@
1
+ export { transcribeTencentFlash, type TencentFlashASRConfig } from "./tencent-flash.js";
2
+ export {
3
+ ASRError,
4
+ ASRTimeoutError,
5
+ ASRAuthError,
6
+ ASRRequestError,
7
+ ASRResponseParseError,
8
+ ASRServiceError,
9
+ ASREmptyResultError,
10
+ type ASRErrorKind,
11
+ } from "./errors.js";
@@ -0,0 +1,165 @@
1
+ import { createHmac } from "node:crypto";
2
+ import {
3
+ ASRAuthError,
4
+ ASREmptyResultError,
5
+ ASRRequestError,
6
+ ASRResponseParseError,
7
+ ASRServiceError,
8
+ ASRTimeoutError,
9
+ } from "./errors.js";
10
+
11
+ const ASR_FLASH_HOST = "asr.cloud.tencent.com";
12
+ const ASR_FLASH_PATH_PREFIX = "/asr/flash/v1";
13
+ const ASR_FLASH_URL_PREFIX = `https://${ASR_FLASH_HOST}${ASR_FLASH_PATH_PREFIX}`;
14
+ const ASR_PROVIDER = "tencent-flash";
15
+
16
+ export interface TencentFlashASRConfig {
17
+ appId: string;
18
+ secretId: string;
19
+ secretKey: string;
20
+ engineType?: string;
21
+ voiceFormat?: string;
22
+ timeoutMs?: number;
23
+ }
24
+
25
+ interface TencentFlashResponseSentence {
26
+ text?: string;
27
+ }
28
+
29
+ interface TencentFlashResponseItem {
30
+ text?: string;
31
+ sentence_list?: TencentFlashResponseSentence[];
32
+ }
33
+
34
+ interface TencentFlashResponse {
35
+ code?: number;
36
+ message?: string;
37
+ flash_result?: TencentFlashResponseItem[];
38
+ }
39
+
40
+ function encodeQueryValue(value: string): string {
41
+ return encodeURIComponent(value)
42
+ .replace(/%20/g, "+")
43
+ .replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
44
+ }
45
+
46
+ function buildSignedQuery(params: Record<string, string>): string {
47
+ return Object.entries(params)
48
+ .sort(([a], [b]) => a.localeCompare(b))
49
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeQueryValue(value)}`)
50
+ .join("&");
51
+ }
52
+
53
+ function extractTranscript(payload: TencentFlashResponse): string {
54
+ const items = Array.isArray(payload.flash_result) ? payload.flash_result : [];
55
+ const lines: string[] = [];
56
+
57
+ for (const item of items) {
58
+ if (typeof item?.text === "string" && item.text.trim()) {
59
+ lines.push(item.text.trim());
60
+ continue;
61
+ }
62
+ const sentenceList = Array.isArray(item?.sentence_list) ? item.sentence_list : [];
63
+ for (const sentence of sentenceList) {
64
+ if (typeof sentence?.text === "string" && sentence.text.trim()) {
65
+ lines.push(sentence.text.trim());
66
+ }
67
+ }
68
+ }
69
+
70
+ return lines.join("\n").trim();
71
+ }
72
+
73
+ export async function transcribeTencentFlash(params: {
74
+ audio: Buffer;
75
+ config: TencentFlashASRConfig;
76
+ }): Promise<string> {
77
+ const { audio, config } = params;
78
+ const timestamp = Math.floor(Date.now() / 1000).toString();
79
+ const engineType = config.engineType ?? "16k_zh";
80
+ const voiceFormat = config.voiceFormat ?? "silk";
81
+ const query = buildSignedQuery({
82
+ engine_type: engineType,
83
+ secretid: config.secretId,
84
+ timestamp,
85
+ voice_format: voiceFormat,
86
+ });
87
+
88
+ const signText = `POST${ASR_FLASH_HOST}${ASR_FLASH_PATH_PREFIX}/${config.appId}?${query}`;
89
+ const authorization = createHmac("sha1", config.secretKey).update(signText).digest("base64");
90
+ const url = `${ASR_FLASH_URL_PREFIX}/${config.appId}?${query}`;
91
+ const timeoutMs = config.timeoutMs ?? 30000;
92
+ const controller = new AbortController();
93
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
94
+
95
+ try {
96
+ const response = await fetch(url, {
97
+ method: "POST",
98
+ headers: {
99
+ Authorization: authorization,
100
+ "Content-Type": "application/octet-stream",
101
+ },
102
+ body: audio,
103
+ signal: controller.signal,
104
+ });
105
+
106
+ const bodyText = await response.text();
107
+ let payload: TencentFlashResponse;
108
+ try {
109
+ payload = JSON.parse(bodyText) as TencentFlashResponse;
110
+ } catch {
111
+ throw new ASRResponseParseError(ASR_PROVIDER, bodyText.slice(0, 300));
112
+ }
113
+
114
+ if (!response.ok) {
115
+ const message = payload.message ?? `HTTP ${response.status}`;
116
+ if (response.status === 401 || response.status === 403) {
117
+ throw new ASRAuthError(
118
+ ASR_PROVIDER,
119
+ `Tencent Flash ASR authentication failed: ${message}`,
120
+ response.status
121
+ );
122
+ }
123
+ throw new ASRRequestError(
124
+ ASR_PROVIDER,
125
+ `Tencent Flash ASR request failed: ${message}`,
126
+ response.status
127
+ );
128
+ }
129
+
130
+ if (payload.code !== 0) {
131
+ throw new ASRServiceError(
132
+ ASR_PROVIDER,
133
+ `Tencent Flash ASR failed: ${payload.message ?? "unknown error"} (code=${payload.code})`
134
+ ,
135
+ payload.code
136
+ );
137
+ }
138
+
139
+ const transcript = extractTranscript(payload);
140
+ if (!transcript) {
141
+ throw new ASREmptyResultError(ASR_PROVIDER);
142
+ }
143
+ return transcript;
144
+ } catch (error) {
145
+ if (error instanceof Error && error.name === "AbortError") {
146
+ throw new ASRTimeoutError(ASR_PROVIDER, timeoutMs);
147
+ }
148
+ if (
149
+ error instanceof ASRResponseParseError ||
150
+ error instanceof ASRAuthError ||
151
+ error instanceof ASRRequestError ||
152
+ error instanceof ASRServiceError ||
153
+ error instanceof ASREmptyResultError ||
154
+ error instanceof ASRTimeoutError
155
+ ) {
156
+ throw error;
157
+ }
158
+ throw new ASRRequestError(
159
+ ASR_PROVIDER,
160
+ `Tencent Flash ASR request failed: ${error instanceof Error ? error.message : String(error)}`
161
+ );
162
+ } finally {
163
+ clearTimeout(timeoutId);
164
+ }
165
+ }
package/src/index.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  export * from "./logger/index.js";
5
5
  export * from "./policy/index.js";
6
6
  export * from "./http/index.js";
7
- export * from "./types/common.js";
8
- export * from "./file/index.js";
9
- export * from "./media/index.js";
10
- export * from "./cron/index.js";
7
+ export * from "./types/common.js";
8
+ export * from "./file/index.js";
9
+ export * from "./media/index.js";
10
+ export * from "./cron/index.js";
11
+ export * from "./asr/index.js";
@@ -1,180 +0,0 @@
1
- import { afterEach, describe, expect, it } from "vitest";
2
- import * as fs from "node:fs";
3
- import * as fsPromises from "node:fs/promises";
4
- import * as os from "node:os";
5
- import * as path from "node:path";
6
- import {
7
- FileSizeLimitError,
8
- MediaTimeoutError,
9
- cleanupFileSafe,
10
- downloadToTempFile,
11
- finalizeInboundMediaFile,
12
- pruneInboundMediaDir,
13
- } from "./media-io.js";
14
-
15
- const tempDirs: string[] = [];
16
-
17
- async function createTempDir(prefix: string): Promise<string> {
18
- const dir = await fsPromises.mkdtemp(path.join(os.tmpdir(), prefix));
19
- tempDirs.push(dir);
20
- return dir;
21
- }
22
-
23
- afterEach(async () => {
24
- for (const dir of tempDirs.splice(0, tempDirs.length)) {
25
- await fsPromises.rm(dir, { recursive: true, force: true });
26
- }
27
- });
28
-
29
- describe("downloadToTempFile", () => {
30
- it("downloads HTTP response and stores a temp file", async () => {
31
- const dir = await createTempDir("shared-media-io-");
32
- const body = Buffer.from("hello-media", "utf8");
33
- const fetchFn: typeof globalThis.fetch = async () =>
34
- new Response(body, {
35
- status: 200,
36
- headers: {
37
- "content-type": "image/png",
38
- "content-length": String(body.length),
39
- },
40
- });
41
-
42
- const result = await downloadToTempFile("https://example.com/a.png", {
43
- fetch: fetchFn,
44
- tempDir: dir,
45
- tempPrefix: "dingtalk-file",
46
- });
47
-
48
- expect(result.path.startsWith(dir)).toBe(true);
49
- expect(result.fileName.endsWith(".png")).toBe(true);
50
- expect(result.size).toBe(body.length);
51
- expect(result.contentType).toBe("image/png");
52
-
53
- const saved = await fsPromises.readFile(result.path);
54
- expect(saved.equals(body)).toBe(true);
55
- });
56
-
57
- it("throws FileSizeLimitError when Content-Length exceeds maxSize", async () => {
58
- const fetchFn: typeof globalThis.fetch = async () =>
59
- new Response("too-large", {
60
- status: 200,
61
- headers: {
62
- "content-type": "application/octet-stream",
63
- "content-length": "1024",
64
- },
65
- });
66
-
67
- await expect(
68
- downloadToTempFile("https://example.com/too-large.bin", {
69
- fetch: fetchFn,
70
- maxSize: 100,
71
- })
72
- ).rejects.toBeInstanceOf(FileSizeLimitError);
73
- });
74
-
75
- it("throws MediaTimeoutError on timeout", async () => {
76
- const fetchFn: typeof globalThis.fetch = async (_url, init) =>
77
- await new Promise<Response>((_resolve, reject) => {
78
- const signal = init?.signal;
79
- signal?.addEventListener("abort", () => {
80
- const err = new Error("aborted");
81
- (err as Error & { name: string }).name = "AbortError";
82
- reject(err);
83
- });
84
- });
85
-
86
- await expect(
87
- downloadToTempFile("https://example.com/slow.bin", {
88
- fetch: fetchFn,
89
- timeout: 10,
90
- })
91
- ).rejects.toBeInstanceOf(MediaTimeoutError);
92
- });
93
- });
94
-
95
- describe("cleanupFileSafe", () => {
96
- it("removes file and ignores missing file", async () => {
97
- const dir = await createTempDir("shared-media-clean-");
98
- const filePath = path.join(dir, "a.txt");
99
- await fsPromises.writeFile(filePath, "x", "utf8");
100
- expect(fs.existsSync(filePath)).toBe(true);
101
-
102
- await cleanupFileSafe(filePath);
103
- expect(fs.existsSync(filePath)).toBe(false);
104
-
105
- await expect(cleanupFileSafe(filePath)).resolves.toBeUndefined();
106
- await expect(cleanupFileSafe(undefined)).resolves.toBeUndefined();
107
- });
108
- });
109
-
110
- describe("inbound media retention", () => {
111
- it("finalizes temp media into inbound/YYYY-MM-DD", async () => {
112
- const tempDir = await createTempDir("shared-media-temp-");
113
- const inboundDir = await createTempDir("shared-media-inbound-");
114
- const sourcePath = path.join(tempDir, "img-1.jpg");
115
- await fsPromises.writeFile(sourcePath, "abc", "utf8");
116
-
117
- const finalPath = await finalizeInboundMediaFile({
118
- filePath: sourcePath,
119
- tempDir,
120
- inboundDir,
121
- });
122
-
123
- expect(finalPath.startsWith(inboundDir)).toBe(true);
124
- expect(fs.existsSync(finalPath)).toBe(true);
125
- expect(fs.existsSync(sourcePath)).toBe(false);
126
- });
127
-
128
- it("does not move files outside tempDir", async () => {
129
- const tempDir = await createTempDir("shared-media-temp-");
130
- const inboundDir = await createTempDir("shared-media-inbound-");
131
- const outsideDir = await createTempDir("shared-media-outside-");
132
- const sourcePath = path.join(outsideDir, "a.txt");
133
- await fsPromises.writeFile(sourcePath, "x", "utf8");
134
-
135
- const finalPath = await finalizeInboundMediaFile({
136
- filePath: sourcePath,
137
- tempDir,
138
- inboundDir,
139
- });
140
-
141
- expect(finalPath).toBe(sourcePath);
142
- expect(fs.existsSync(sourcePath)).toBe(true);
143
- });
144
-
145
- it("prunes only expired files in date dirs and keeps recent files", async () => {
146
- const inboundDir = await createTempDir("shared-media-prune-");
147
- const oldDir = path.join(inboundDir, "2024-01-01");
148
- const newDir = path.join(inboundDir, "2024-01-02");
149
- await fsPromises.mkdir(oldDir, { recursive: true });
150
- await fsPromises.mkdir(newDir, { recursive: true });
151
-
152
- const oldFile = path.join(oldDir, "old.jpg");
153
- const newFile = path.join(newDir, "new.jpg");
154
- const nestedDir = path.join(oldDir, "nested");
155
- const nestedFile = path.join(nestedDir, "nested.jpg");
156
- await fsPromises.writeFile(oldFile, "old", "utf8");
157
- await fsPromises.writeFile(newFile, "new", "utf8");
158
- await fsPromises.mkdir(nestedDir, { recursive: true });
159
- await fsPromises.writeFile(nestedFile, "nested", "utf8");
160
-
161
- const oldTs = new Date("2024-01-01T00:00:00.000Z");
162
- const newTs = new Date("2024-01-02T00:00:00.000Z");
163
- await fsPromises.utimes(oldDir, oldTs, oldTs);
164
- await fsPromises.utimes(oldFile, oldTs, oldTs);
165
- await fsPromises.utimes(newDir, newTs, newTs);
166
- await fsPromises.utimes(newFile, newTs, newTs);
167
-
168
- const nowMs = new Date("2024-01-03T00:00:00.000Z").getTime();
169
- await pruneInboundMediaDir({
170
- inboundDir,
171
- keepDays: 1,
172
- nowMs,
173
- });
174
-
175
- expect(fs.existsSync(oldFile)).toBe(false);
176
- expect(fs.existsSync(newFile)).toBe(true);
177
- expect(fs.existsSync(nestedFile)).toBe(true);
178
- expect(fs.existsSync(oldDir)).toBe(true);
179
- });
180
- });