@soyeht/soyeht 0.1.1
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/README.md +73 -0
- package/docs/PROTOCOL.md +388 -0
- package/openclaw.plugin.json +14 -0
- package/package.json +49 -0
- package/src/channel.ts +157 -0
- package/src/config.ts +203 -0
- package/src/crypto.ts +227 -0
- package/src/envelope-v2.ts +201 -0
- package/src/http.ts +175 -0
- package/src/identity.ts +157 -0
- package/src/index.ts +120 -0
- package/src/media.ts +100 -0
- package/src/openclaw-plugin-sdk.d.ts +209 -0
- package/src/outbound.ts +198 -0
- package/src/pairing.ts +324 -0
- package/src/ratchet.ts +262 -0
- package/src/rpc.ts +503 -0
- package/src/security.ts +158 -0
- package/src/service.ts +177 -0
- package/src/types.ts +213 -0
- package/src/version.ts +1 -0
- package/src/x3dh.ts +105 -0
package/src/http.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
|
+
import { normalizeAccountId } from "./config.js";
|
|
4
|
+
import { decryptEnvelopeV2, validateEnvelopeV2, type EnvelopeV2 } from "./envelope-v2.js";
|
|
5
|
+
import { cloneRatchetSession } from "./ratchet.js";
|
|
6
|
+
import type { SecurityV2Deps } from "./service.js";
|
|
7
|
+
import { PLUGIN_VERSION } from "./version.js";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Helpers
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
14
|
+
const json = JSON.stringify(body);
|
|
15
|
+
res.writeHead(status, {
|
|
16
|
+
"Content-Type": "application/json",
|
|
17
|
+
"Content-Length": Buffer.byteLength(json),
|
|
18
|
+
});
|
|
19
|
+
res.end(json);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MAX_BODY_BYTES = 1024 * 1024; // 1 MB
|
|
23
|
+
|
|
24
|
+
async function readRawBodyBuffer(req: IncomingMessage): Promise<Buffer> {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const chunks: Buffer[] = [];
|
|
27
|
+
let totalBytes = 0;
|
|
28
|
+
|
|
29
|
+
req.on("data", (chunk: Buffer) => {
|
|
30
|
+
totalBytes += chunk.length;
|
|
31
|
+
if (totalBytes > MAX_BODY_BYTES) {
|
|
32
|
+
req.destroy();
|
|
33
|
+
reject(new Error("body_too_large"));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
chunks.push(chunk);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
40
|
+
req.on("error", reject);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { readRawBodyBuffer };
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// GET /soyeht/health
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
export function healthHandler(_api: OpenClawPluginApi) {
|
|
51
|
+
return async (_req: IncomingMessage, res: ServerResponse) => {
|
|
52
|
+
sendJson(res, 200, { ok: true, plugin: "soyeht", version: PLUGIN_VERSION });
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// POST /soyeht/webhook/deliver
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
export function webhookHandler(
|
|
61
|
+
api: OpenClawPluginApi,
|
|
62
|
+
v2deps?: SecurityV2Deps,
|
|
63
|
+
) {
|
|
64
|
+
return async (req: IncomingMessage, res: ServerResponse) => {
|
|
65
|
+
if (req.method !== "POST") {
|
|
66
|
+
sendJson(res, 405, { ok: false, error: "method_not_allowed" });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const headers = req.headers ?? {};
|
|
71
|
+
const headerAccountIdRaw = (headers["x-soyeht-account-id"] as string | undefined)?.trim();
|
|
72
|
+
const hintedAccountId = headerAccountIdRaw ? normalizeAccountId(headerAccountIdRaw) : undefined;
|
|
73
|
+
|
|
74
|
+
if (!v2deps?.ready) {
|
|
75
|
+
sendJson(res, 503, { ok: false, error: "service_unavailable" });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Rate limit per-account
|
|
80
|
+
if (v2deps.rateLimiter) {
|
|
81
|
+
const { allowed, retryAfterMs } = v2deps.rateLimiter.check(`webhook:${hintedAccountId ?? "_unknown"}`);
|
|
82
|
+
if (!allowed) {
|
|
83
|
+
res.setHeader("Retry-After", String(Math.ceil((retryAfterMs ?? 60_000) / 1000)));
|
|
84
|
+
sendJson(res, 429, { ok: false, error: "rate_limited" });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const rawBody = await readRawBodyBuffer(req);
|
|
91
|
+
|
|
92
|
+
// Parse the envelope
|
|
93
|
+
let envelope: EnvelopeV2;
|
|
94
|
+
try {
|
|
95
|
+
envelope = JSON.parse(rawBody.toString("utf8")) as EnvelopeV2;
|
|
96
|
+
} catch {
|
|
97
|
+
sendJson(res, 400, { ok: false, error: "invalid_json" });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const envelopeAccountId = normalizeAccountId(envelope.accountId);
|
|
102
|
+
if (hintedAccountId && hintedAccountId !== envelopeAccountId) {
|
|
103
|
+
sendJson(res, 401, { ok: false, error: "account_mismatch" });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const accountId = hintedAccountId ?? envelopeAccountId;
|
|
108
|
+
const session = v2deps.sessions.get(accountId);
|
|
109
|
+
if (!session) {
|
|
110
|
+
sendJson(res, 401, { ok: false, error: "session_required" });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (session.expiresAt < Date.now()) {
|
|
115
|
+
sendJson(res, 401, { ok: false, error: "session_expired" });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (session.accountId !== envelopeAccountId) {
|
|
120
|
+
sendJson(res, 401, { ok: false, error: "account_mismatch" });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Validate envelope (trust only the parsed body — no header overrides)
|
|
125
|
+
const validation = validateEnvelopeV2(envelope, session);
|
|
126
|
+
if (!validation.valid) {
|
|
127
|
+
api.logger.warn("[soyeht] Webhook validation failed", { error: validation.error, accountId });
|
|
128
|
+
sendJson(res, 401, { ok: false, error: validation.error });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Deep-clone session before decryption so in-place Buffer mutations
|
|
133
|
+
// (rootKey.fill(0), chainKey.fill(0)) cannot corrupt the stored session on failure.
|
|
134
|
+
const sessionClone = cloneRatchetSession(session);
|
|
135
|
+
|
|
136
|
+
// Decrypt
|
|
137
|
+
let plaintext: string;
|
|
138
|
+
let updatedSession;
|
|
139
|
+
try {
|
|
140
|
+
const result = decryptEnvelopeV2({ session: sessionClone, envelope });
|
|
141
|
+
plaintext = result.plaintext;
|
|
142
|
+
updatedSession = result.updatedSession;
|
|
143
|
+
} catch (err) {
|
|
144
|
+
const msg = err instanceof Error ? err.message : "decryption_failed";
|
|
145
|
+
api.logger.warn("[soyeht] Webhook decryption failed", { error: msg, accountId });
|
|
146
|
+
sendJson(res, 401, { ok: false, error: msg });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Update session
|
|
151
|
+
v2deps.sessions.set(accountId, updatedSession);
|
|
152
|
+
|
|
153
|
+
api.logger.info("[soyeht] Webhook delivery received", { accountId });
|
|
154
|
+
sendJson(res, 200, { ok: true, received: true });
|
|
155
|
+
} catch (err) {
|
|
156
|
+
const message = err instanceof Error ? err.message : "unknown_error";
|
|
157
|
+
sendJson(res, 400, { ok: false, error: message });
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// POST /soyeht/livekit/token — stub
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
export function livekitTokenHandler(_api: OpenClawPluginApi) {
|
|
167
|
+
return async (_req: IncomingMessage, res: ServerResponse) => {
|
|
168
|
+
sendJson(res, 501, {
|
|
169
|
+
ok: false,
|
|
170
|
+
error: "not_enabled",
|
|
171
|
+
pipeline: "stt->llm->tts",
|
|
172
|
+
availableIn: "v2",
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
}
|
package/src/identity.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, readdir, unlink, chmod } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
ed25519GenerateKeyPair,
|
|
5
|
+
generateX25519KeyPair,
|
|
6
|
+
exportPrivateKeyPkcs8,
|
|
7
|
+
exportPublicKeyRaw,
|
|
8
|
+
importEd25519PublicKey,
|
|
9
|
+
importX25519PublicKey,
|
|
10
|
+
importPrivateKeyPkcs8,
|
|
11
|
+
base64UrlEncode,
|
|
12
|
+
base64UrlDecode,
|
|
13
|
+
type Ed25519KeyPair,
|
|
14
|
+
type X25519KeyPair,
|
|
15
|
+
} from "./crypto.js";
|
|
16
|
+
import type { RatchetState } from "./ratchet.js";
|
|
17
|
+
import { serializeSession, deserializeSession } from "./ratchet.js";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Types
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
export type IdentityBundle = {
|
|
24
|
+
signKey: Ed25519KeyPair;
|
|
25
|
+
dhKey: X25519KeyPair;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type PeerIdentity = {
|
|
29
|
+
accountId: string;
|
|
30
|
+
identityKeyB64: string; // Ed25519 pub raw b64url (32 bytes)
|
|
31
|
+
dhKeyB64: string; // X25519 pub raw b64url (32 bytes)
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Account ID validation — reject path traversal attempts
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
const SAFE_ACCOUNT_ID = /^[a-zA-Z0-9_-]+$/;
|
|
39
|
+
|
|
40
|
+
function validateAccountId(accountId: string): void {
|
|
41
|
+
if (!SAFE_ACCOUNT_ID.test(accountId)) {
|
|
42
|
+
throw new Error(`Invalid accountId for file operation: ${accountId}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Paths
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
function secDir(stateDir: string): string {
|
|
51
|
+
return join(stateDir, "soyeht-security");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Identity load / generate
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
export async function loadOrGenerateIdentity(stateDir: string): Promise<IdentityBundle> {
|
|
59
|
+
const dir = secDir(stateDir);
|
|
60
|
+
await mkdir(dir, { recursive: true });
|
|
61
|
+
const identityPath = join(dir, "identity.json");
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const raw = JSON.parse(await readFile(identityPath, "utf8")) as Record<string, unknown>;
|
|
65
|
+
const signPriv = importPrivateKeyPkcs8(base64UrlDecode(raw["signPriv"] as string));
|
|
66
|
+
const signPubB64 = raw["signPub"] as string;
|
|
67
|
+
const signPub = importEd25519PublicKey(signPubB64);
|
|
68
|
+
const dhPriv = importPrivateKeyPkcs8(base64UrlDecode(raw["dhPriv"] as string));
|
|
69
|
+
const dhPubB64 = raw["dhPub"] as string;
|
|
70
|
+
const dhPub = importX25519PublicKey(dhPubB64);
|
|
71
|
+
const dhPubRaw = base64UrlDecode(dhPubB64);
|
|
72
|
+
return {
|
|
73
|
+
signKey: { publicKey: signPub, privateKey: signPriv, publicKeyB64: signPubB64 },
|
|
74
|
+
dhKey: { publicKey: dhPub, privateKey: dhPriv, publicKeyRaw: dhPubRaw, publicKeyB64: dhPubB64 },
|
|
75
|
+
};
|
|
76
|
+
} catch {
|
|
77
|
+
// Generate fresh identity
|
|
78
|
+
const signKp = ed25519GenerateKeyPair();
|
|
79
|
+
const dhKp = generateX25519KeyPair();
|
|
80
|
+
const data = {
|
|
81
|
+
signPub: signKp.publicKeyB64,
|
|
82
|
+
signPriv: base64UrlEncode(exportPrivateKeyPkcs8(signKp.privateKey)),
|
|
83
|
+
dhPub: dhKp.publicKeyB64,
|
|
84
|
+
dhPriv: base64UrlEncode(exportPrivateKeyPkcs8(dhKp.privateKey)),
|
|
85
|
+
};
|
|
86
|
+
await writeFile(identityPath, JSON.stringify(data, null, 2), "utf8");
|
|
87
|
+
try { await chmod(identityPath, 0o600); } catch { /* no-op on Windows */ }
|
|
88
|
+
return { signKey: signKp, dhKey: dhKp };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Peer store
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
export async function savePeer(stateDir: string, peer: PeerIdentity): Promise<void> {
|
|
97
|
+
validateAccountId(peer.accountId);
|
|
98
|
+
const dir = join(secDir(stateDir), "peers");
|
|
99
|
+
await mkdir(dir, { recursive: true });
|
|
100
|
+
const filePath = join(dir, `${peer.accountId}.json`);
|
|
101
|
+
await writeFile(filePath, JSON.stringify(peer, null, 2), "utf8");
|
|
102
|
+
try { await chmod(filePath, 0o600); } catch { /* no-op on Windows */ }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function loadPeers(stateDir: string): Promise<Map<string, PeerIdentity>> {
|
|
106
|
+
const dir = join(secDir(stateDir), "peers");
|
|
107
|
+
const map = new Map<string, PeerIdentity>();
|
|
108
|
+
try {
|
|
109
|
+
const files = await readdir(dir);
|
|
110
|
+
for (const file of files) {
|
|
111
|
+
if (!file.endsWith(".json")) continue;
|
|
112
|
+
try {
|
|
113
|
+
const peer = JSON.parse(await readFile(join(dir, file), "utf8")) as PeerIdentity;
|
|
114
|
+
map.set(peer.accountId, peer);
|
|
115
|
+
} catch { /* skip corrupt */ }
|
|
116
|
+
}
|
|
117
|
+
} catch { /* dir not exist */ }
|
|
118
|
+
return map;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Session store
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
export async function saveSession(stateDir: string, session: RatchetState): Promise<void> {
|
|
126
|
+
validateAccountId(session.accountId);
|
|
127
|
+
const dir = join(secDir(stateDir), "sessions");
|
|
128
|
+
await mkdir(dir, { recursive: true });
|
|
129
|
+
const filePath = join(dir, `${session.accountId}.json`);
|
|
130
|
+
await writeFile(filePath, JSON.stringify(serializeSession(session), null, 2), "utf8");
|
|
131
|
+
try { await chmod(filePath, 0o600); } catch { /* no-op on Windows */ }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function loadSessions(stateDir: string): Promise<Map<string, RatchetState>> {
|
|
135
|
+
const dir = join(secDir(stateDir), "sessions");
|
|
136
|
+
const map = new Map<string, RatchetState>();
|
|
137
|
+
try {
|
|
138
|
+
const files = await readdir(dir);
|
|
139
|
+
for (const file of files) {
|
|
140
|
+
if (!file.endsWith(".json")) continue;
|
|
141
|
+
try {
|
|
142
|
+
const raw = JSON.parse(await readFile(join(dir, file), "utf8"));
|
|
143
|
+
const session = deserializeSession(raw);
|
|
144
|
+
if (session.expiresAt < Date.now()) continue; // skip expired
|
|
145
|
+
map.set(session.accountId, session);
|
|
146
|
+
} catch { /* skip corrupt */ }
|
|
147
|
+
}
|
|
148
|
+
} catch { /* dir not exist */ }
|
|
149
|
+
return map;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function deleteSession(stateDir: string, accountId: string): Promise<void> {
|
|
153
|
+
validateAccountId(accountId);
|
|
154
|
+
try {
|
|
155
|
+
await unlink(join(secDir(stateDir), "sessions", `${accountId}.json`));
|
|
156
|
+
} catch { /* ignore */ }
|
|
157
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { createSoyehtChannel } from "./channel.js";
|
|
3
|
+
import {
|
|
4
|
+
handleStatus,
|
|
5
|
+
handleCapabilities,
|
|
6
|
+
handleNotify,
|
|
7
|
+
handleLiveKitPrepare,
|
|
8
|
+
handleSecurityHandshake,
|
|
9
|
+
handleSecurityHandshakeFinish,
|
|
10
|
+
handleSecurityRotate,
|
|
11
|
+
} from "./rpc.js";
|
|
12
|
+
import {
|
|
13
|
+
healthHandler,
|
|
14
|
+
webhookHandler,
|
|
15
|
+
livekitTokenHandler,
|
|
16
|
+
} from "./http.js";
|
|
17
|
+
import {
|
|
18
|
+
createSoyehtService,
|
|
19
|
+
createSecurityV2Deps,
|
|
20
|
+
} from "./service.js";
|
|
21
|
+
import {
|
|
22
|
+
handleSecurityIdentity,
|
|
23
|
+
handleSecurityPair,
|
|
24
|
+
handleSecurityPairingStart,
|
|
25
|
+
} from "./pairing.js";
|
|
26
|
+
import { PLUGIN_VERSION } from "./version.js";
|
|
27
|
+
|
|
28
|
+
type OpenClawPluginDefinition = {
|
|
29
|
+
id?: string;
|
|
30
|
+
name?: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
version?: string;
|
|
33
|
+
configSchema?: {
|
|
34
|
+
safeParse(value: unknown): { success: boolean; data?: unknown; error?: unknown };
|
|
35
|
+
jsonSchema: Record<string, unknown>;
|
|
36
|
+
};
|
|
37
|
+
register?: (api: OpenClawPluginApi) => void | Promise<void>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const emptyPluginConfigSchema = {
|
|
41
|
+
safeParse(value: unknown) {
|
|
42
|
+
if (value === undefined) {
|
|
43
|
+
return { success: true, data: undefined };
|
|
44
|
+
}
|
|
45
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
46
|
+
return {
|
|
47
|
+
success: false,
|
|
48
|
+
error: { issues: [{ path: [], message: "expected config object" }] },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (Object.keys(value as Record<string, unknown>).length > 0) {
|
|
52
|
+
return {
|
|
53
|
+
success: false,
|
|
54
|
+
error: { issues: [{ path: [], message: "config must be empty" }] },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return { success: true, data: value };
|
|
58
|
+
},
|
|
59
|
+
jsonSchema: {
|
|
60
|
+
type: "object",
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
properties: {},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const soyehtPlugin: OpenClawPluginDefinition = {
|
|
67
|
+
id: "soyeht",
|
|
68
|
+
name: "Soyeht",
|
|
69
|
+
description: "Channel plugin for the Soyeht Flutter mobile app",
|
|
70
|
+
version: PLUGIN_VERSION,
|
|
71
|
+
|
|
72
|
+
configSchema: emptyPluginConfigSchema,
|
|
73
|
+
|
|
74
|
+
async register(api) {
|
|
75
|
+
// V2 deps — identity/sessions loaded in service.start()
|
|
76
|
+
const v2deps = createSecurityV2Deps();
|
|
77
|
+
|
|
78
|
+
// Channel
|
|
79
|
+
api.registerChannel({ plugin: createSoyehtChannel(v2deps) });
|
|
80
|
+
|
|
81
|
+
// Gateway RPC methods
|
|
82
|
+
api.registerGatewayMethod("soyeht.status", handleStatus(api));
|
|
83
|
+
api.registerGatewayMethod("soyeht.capabilities", handleCapabilities(api));
|
|
84
|
+
api.registerGatewayMethod("soyeht.notify", handleNotify(api, v2deps));
|
|
85
|
+
api.registerGatewayMethod("soyeht.livekit.prepare", handleLiveKitPrepare(api));
|
|
86
|
+
|
|
87
|
+
// Security RPC
|
|
88
|
+
api.registerGatewayMethod("soyeht.security.identity", handleSecurityIdentity(api, v2deps));
|
|
89
|
+
api.registerGatewayMethod("soyeht.security.pairing.start", handleSecurityPairingStart(api, v2deps));
|
|
90
|
+
api.registerGatewayMethod("soyeht.security.pair", handleSecurityPair(api, v2deps));
|
|
91
|
+
api.registerGatewayMethod("soyeht.security.handshake.init", handleSecurityHandshake(api, v2deps));
|
|
92
|
+
api.registerGatewayMethod("soyeht.security.handshake.finish", handleSecurityHandshakeFinish(api, v2deps));
|
|
93
|
+
api.registerGatewayMethod("soyeht.security.handshake", handleSecurityHandshake(api, v2deps));
|
|
94
|
+
api.registerGatewayMethod("soyeht.security.rotate", handleSecurityRotate(api, v2deps));
|
|
95
|
+
|
|
96
|
+
// HTTP routes
|
|
97
|
+
api.registerHttpRoute({
|
|
98
|
+
path: "/soyeht/health",
|
|
99
|
+
auth: "plugin",
|
|
100
|
+
handler: healthHandler(api),
|
|
101
|
+
});
|
|
102
|
+
api.registerHttpRoute({
|
|
103
|
+
path: "/soyeht/webhook/deliver",
|
|
104
|
+
auth: "plugin",
|
|
105
|
+
handler: webhookHandler(api, v2deps),
|
|
106
|
+
});
|
|
107
|
+
api.registerHttpRoute({
|
|
108
|
+
path: "/soyeht/livekit/token",
|
|
109
|
+
auth: "plugin",
|
|
110
|
+
handler: livekitTokenHandler(api),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Background service (manages state lifecycle)
|
|
114
|
+
api.registerService(createSoyehtService(api, v2deps));
|
|
115
|
+
|
|
116
|
+
api.logger.info("[soyeht] Plugin registered");
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export default soyehtPlugin;
|
package/src/media.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { PluginRuntime, OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { resolveSoyehtAccount } from "./config.js";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// STT — transcribe inbound audio
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export type TranscribeResult = {
|
|
9
|
+
text: string | undefined;
|
|
10
|
+
skipped: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function transcribeInboundAudio(params: {
|
|
14
|
+
runtime: PluginRuntime;
|
|
15
|
+
cfg: OpenClawConfig;
|
|
16
|
+
filePath: string;
|
|
17
|
+
mime?: string;
|
|
18
|
+
accountId?: string;
|
|
19
|
+
}): Promise<TranscribeResult> {
|
|
20
|
+
const account = resolveSoyehtAccount(params.cfg, params.accountId);
|
|
21
|
+
|
|
22
|
+
if (!account.audio.transcribeInbound) {
|
|
23
|
+
return { text: undefined, skipped: true };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const result = await params.runtime.stt.transcribeAudioFile({
|
|
27
|
+
filePath: params.filePath,
|
|
28
|
+
cfg: params.cfg,
|
|
29
|
+
mime: params.mime,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return { text: result.text, skipped: false };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// TTS — generate outbound speech
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
export type TtsResult = {
|
|
40
|
+
success: boolean;
|
|
41
|
+
audioBuffer?: Buffer;
|
|
42
|
+
mimeType: string;
|
|
43
|
+
error?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export async function generateOutboundTts(params: {
|
|
47
|
+
runtime: PluginRuntime;
|
|
48
|
+
cfg: OpenClawConfig;
|
|
49
|
+
text: string;
|
|
50
|
+
accountId?: string;
|
|
51
|
+
}): Promise<TtsResult> {
|
|
52
|
+
const account = resolveSoyehtAccount(params.cfg, params.accountId);
|
|
53
|
+
|
|
54
|
+
if (!account.audio.ttsOutbound) {
|
|
55
|
+
return { success: false, mimeType: "audio/wav", error: "tts_disabled" };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result = await params.runtime.tts.textToSpeechTelephony({
|
|
59
|
+
text: params.text,
|
|
60
|
+
cfg: params.cfg,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
success: result.success,
|
|
65
|
+
audioBuffer: result.audioBuffer,
|
|
66
|
+
mimeType: result.outputFormat === "mp3" ? "audio/mpeg" : "audio/wav",
|
|
67
|
+
error: result.error,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// File validation
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
export type FileValidationResult = {
|
|
76
|
+
allowed: boolean;
|
|
77
|
+
reason?: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function validateInboundFile(params: {
|
|
81
|
+
cfg: OpenClawConfig;
|
|
82
|
+
accountId?: string;
|
|
83
|
+
sizeBytes: number;
|
|
84
|
+
mimeType: string;
|
|
85
|
+
}): FileValidationResult {
|
|
86
|
+
const account = resolveSoyehtAccount(params.cfg, params.accountId);
|
|
87
|
+
|
|
88
|
+
if (!account.files.acceptInbound) {
|
|
89
|
+
return { allowed: false, reason: "files_disabled" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (params.sizeBytes > account.files.maxBytes) {
|
|
93
|
+
return {
|
|
94
|
+
allowed: false,
|
|
95
|
+
reason: `file_too_large: ${params.sizeBytes} > ${account.files.maxBytes}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { allowed: true };
|
|
100
|
+
}
|