openclaw-seatalk 0.1.0

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.
@@ -0,0 +1,71 @@
1
+ import { z } from "zod";
2
+ export { z };
3
+
4
+ const DmPolicySchema = z.enum(["open", "allowlist"]);
5
+ const GatewayModeSchema = z.enum(["webhook", "relay"]);
6
+ const GroupPolicySchema = z.enum(["disabled", "allowlist", "open"]);
7
+ const ProcessingIndicatorSchema = z.enum(["typing", "off"]);
8
+
9
+ export const SeaTalkToolsConfigSchema = z
10
+ .object({
11
+ groupInfo: z.boolean().optional().default(true),
12
+ groupHistory: z.boolean().optional().default(true),
13
+ groupList: z.boolean().optional().default(true),
14
+ threadHistory: z.boolean().optional().default(true),
15
+ getMessage: z.boolean().optional().default(true),
16
+ })
17
+ .strict();
18
+
19
+ export const SeaTalkAccountConfigSchema = z
20
+ .object({
21
+ enabled: z.boolean().optional(),
22
+ appId: z.string().optional(),
23
+ appSecret: z.string().optional(),
24
+ signingSecret: z.string().optional(),
25
+ mode: GatewayModeSchema.optional(),
26
+ relayUrl: z.string().optional(),
27
+ webhookPort: z.number().int().positive().optional(),
28
+ webhookPath: z.string().optional(),
29
+ dmPolicy: DmPolicySchema.optional(),
30
+ allowFrom: z.array(z.string()).optional(),
31
+ groupPolicy: GroupPolicySchema.optional(),
32
+ groupAllowFrom: z.array(z.string()).optional(),
33
+ groupSenderAllowFrom: z.array(z.string()).optional(),
34
+ processingIndicator: ProcessingIndicatorSchema.optional(),
35
+ })
36
+ .strict();
37
+
38
+ export const SeaTalkConfigSchema = z
39
+ .object({
40
+ enabled: z.boolean().optional(),
41
+ appId: z.string().optional(),
42
+ appSecret: z.string().optional(),
43
+ signingSecret: z.string().optional(),
44
+ mode: GatewayModeSchema.optional().default("webhook"),
45
+ relayUrl: z.string().optional(),
46
+ webhookPort: z.number().int().positive().optional().default(8080),
47
+ webhookPath: z.string().optional().default("/callback"),
48
+ dmPolicy: DmPolicySchema.optional().default("allowlist"),
49
+ allowFrom: z.array(z.string()).optional(),
50
+ groupPolicy: GroupPolicySchema.optional().default("disabled"),
51
+ groupAllowFrom: z.array(z.string()).optional(),
52
+ groupSenderAllowFrom: z.array(z.string()).optional(),
53
+ processingIndicator: ProcessingIndicatorSchema.optional().default("typing"),
54
+ tools: SeaTalkToolsConfigSchema.optional(),
55
+ accounts: z.record(z.string(), SeaTalkAccountConfigSchema.optional()).optional(),
56
+ })
57
+ .strict()
58
+ .superRefine((value, ctx) => {
59
+ if (value.dmPolicy === "open") {
60
+ const allowFrom = value.allowFrom ?? [];
61
+ const hasWildcard = allowFrom.some((entry) => entry.trim() === "*");
62
+ if (!hasWildcard) {
63
+ ctx.addIssue({
64
+ code: z.ZodIssueCode.custom,
65
+ path: ["allowFrom"],
66
+ message:
67
+ 'channels.seatalk.dmPolicy="open" requires channels.seatalk.allowFrom to include "*"',
68
+ });
69
+ }
70
+ }
71
+ });
package/src/media.ts ADDED
@@ -0,0 +1,163 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { SeaTalkClient } from "./client.js";
4
+ import { getSeatalkRuntime } from "./runtime.js";
5
+ import type { SeaTalkMediaInfo, SeaTalkMessage, SeaTalkOutboundMedia } from "./types.js";
6
+
7
+ const PLACEHOLDER_MAP: Record<string, string> = {
8
+ image: "<media:image>",
9
+ file: "<media:document>",
10
+ video: "<media:video>",
11
+ };
12
+
13
+ const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif"]);
14
+ const MAX_OUTBOUND_RAW_BYTES = 3.75 * 1024 * 1024; // ~3.75MB raw → 5MB base64
15
+ const SMALL_FILE_THRESHOLD = 10 * 1024 * 1024; // 10MB
16
+ const MAX_INBOUND_SAVE_BYTES = 250 * 1024 * 1024; // 250MB
17
+
18
+ export async function resolveInboundMedia(params: {
19
+ message: SeaTalkMessage;
20
+ client: SeaTalkClient;
21
+ log?: (msg: string) => void;
22
+ }): Promise<SeaTalkMediaInfo | null> {
23
+ const { message, client, log } = params;
24
+ const core = getSeatalkRuntime();
25
+
26
+ let url: string | undefined;
27
+ let filename: string | undefined;
28
+ let placeholder = PLACEHOLDER_MAP.file;
29
+
30
+ switch (message.tag) {
31
+ case "image":
32
+ url = message.image?.content;
33
+ placeholder = PLACEHOLDER_MAP.image;
34
+ break;
35
+ case "file":
36
+ url = message.file?.content;
37
+ filename = message.file?.filename;
38
+ break;
39
+ case "video":
40
+ url = message.video?.content;
41
+ placeholder = PLACEHOLDER_MAP.video;
42
+ break;
43
+ default:
44
+ return null;
45
+ }
46
+
47
+ if (!url) return null;
48
+
49
+ const MAX_RETRY = 1;
50
+
51
+ for (let attempt = 0; attempt <= MAX_RETRY; attempt++) {
52
+ try {
53
+ const result = await client.downloadMedia(url);
54
+ let contentType = result.contentType;
55
+
56
+ if (
57
+ (!contentType || contentType === "application/octet-stream") &&
58
+ result.buffer.length < SMALL_FILE_THRESHOLD
59
+ ) {
60
+ contentType = await core.media.detectMime({ buffer: result.buffer });
61
+ }
62
+
63
+ const saved = await core.channel.media.saveMediaBuffer(
64
+ result.buffer,
65
+ contentType,
66
+ "inbound",
67
+ MAX_INBOUND_SAVE_BYTES,
68
+ filename,
69
+ );
70
+
71
+ log?.(`seatalk: downloaded ${message.tag} media, saved to ${saved.path}`);
72
+
73
+ return {
74
+ path: saved.path,
75
+ contentType: saved.contentType,
76
+ filename,
77
+ placeholder,
78
+ };
79
+ } catch (err) {
80
+ if (attempt < MAX_RETRY) {
81
+ log?.(
82
+ `seatalk: retry ${attempt + 1}/${MAX_RETRY} downloading ${message.tag} media: ${String(err)}`,
83
+ );
84
+ continue;
85
+ }
86
+ log?.(
87
+ `seatalk: failed to download ${message.tag} media after ${MAX_RETRY + 1} attempts: ${String(err)}`,
88
+ );
89
+ return null;
90
+ }
91
+ }
92
+
93
+ return null;
94
+ }
95
+
96
+ export async function prepareOutboundMedia(mediaUrl: string): Promise<SeaTalkOutboundMedia | null> {
97
+ let buffer: Buffer;
98
+ let detectedName: string;
99
+
100
+ if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) {
101
+ const controller = new AbortController();
102
+ const timeout = setTimeout(() => controller.abort(), 30_000);
103
+ try {
104
+ const res = await fetch(mediaUrl, { signal: controller.signal });
105
+ if (!res.ok) {
106
+ throw new Error(`Failed to fetch media from ${mediaUrl}: HTTP ${res.status}`);
107
+ }
108
+ const arrayBuffer = await res.arrayBuffer();
109
+ buffer = Buffer.from(arrayBuffer);
110
+ } finally {
111
+ clearTimeout(timeout);
112
+ }
113
+ const urlPath = new URL(mediaUrl).pathname;
114
+ detectedName = path.basename(urlPath) || "file";
115
+ } else {
116
+ const resolved = mediaUrl.startsWith("~")
117
+ ? path.join(process.env.HOME ?? "", mediaUrl.slice(1))
118
+ : mediaUrl.replace(/^file:\/\//, "");
119
+
120
+ if (!fs.existsSync(resolved)) {
121
+ throw new Error(`Media file not found: ${resolved}`);
122
+ }
123
+ buffer = fs.readFileSync(resolved);
124
+ detectedName = path.basename(resolved);
125
+ }
126
+
127
+ if (buffer.length > MAX_OUTBOUND_RAW_BYTES) {
128
+ throw new Error(
129
+ `Media file too large: ${(buffer.length / 1024 / 1024).toFixed(1)}MB exceeds ~3.75MB limit`,
130
+ );
131
+ }
132
+
133
+ const ext = path.extname(detectedName).toLowerCase();
134
+ const sendAs = IMAGE_EXTENSIONS.has(ext) ? "image" : "file";
135
+ const base64 = buffer.toString("base64");
136
+
137
+ return {
138
+ base64,
139
+ sendAs,
140
+ filename: sendAs === "file" ? detectedName.slice(0, 100) : undefined,
141
+ };
142
+ }
143
+
144
+ export function buildSeaTalkMediaPayload(mediaList: SeaTalkMediaInfo[]): {
145
+ MediaPath?: string;
146
+ MediaType?: string;
147
+ MediaUrl?: string;
148
+ MediaPaths?: string[];
149
+ MediaUrls?: string[];
150
+ MediaTypes?: string[];
151
+ } {
152
+ const first = mediaList[0];
153
+ const mediaPaths = mediaList.map((m) => m.path);
154
+ const mediaTypes = mediaList.map((m) => m.contentType).filter(Boolean) as string[];
155
+ return {
156
+ MediaPath: first?.path,
157
+ MediaType: first?.contentType,
158
+ MediaUrl: first?.path,
159
+ MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
160
+ MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
161
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
162
+ };
163
+ }
package/src/monitor.ts ADDED
@@ -0,0 +1,205 @@
1
+ import * as crypto from "node:crypto";
2
+ import * as http from "node:http";
3
+ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
4
+ import { listEnabledSeaTalkAccounts, resolveSeaTalkAccount } from "./accounts.js";
5
+ import { dispatchSeaTalkEvent } from "./bot.js";
6
+ import { resolveSeaTalkClient } from "./client.js";
7
+ import type { ResolvedSeaTalkAccount, SeaTalkCallbackRequest } from "./types.js";
8
+
9
+ export type MonitorSeaTalkOpts = {
10
+ config?: ClawdbotConfig;
11
+ runtime?: RuntimeEnv;
12
+ abortSignal?: AbortSignal;
13
+ accountId?: string;
14
+ };
15
+
16
+ function verifySignature(rawBody: Buffer, signingSecret: string, signature: string): boolean {
17
+ const secretBytes = Buffer.from(signingSecret, "latin1");
18
+ const calculated = crypto
19
+ .createHash("sha256")
20
+ .update(Buffer.concat([rawBody, secretBytes]))
21
+ .digest("hex");
22
+
23
+ try {
24
+ return crypto.timingSafeEqual(
25
+ Buffer.from(calculated, "hex"),
26
+ Buffer.from(signature, "hex"),
27
+ );
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ const MAX_BODY_BYTES = 1024 * 1024; // 1 MB
34
+
35
+ class PayloadTooLargeError extends Error {
36
+ constructor() {
37
+ super("Request body too large");
38
+ this.name = "PayloadTooLargeError";
39
+ }
40
+ }
41
+
42
+ function readBody(req: http.IncomingMessage): Promise<Buffer> {
43
+ return new Promise((resolve, reject) => {
44
+ let received = 0;
45
+ const chunks: Buffer[] = [];
46
+ req.on("data", (chunk: Buffer) => {
47
+ received += chunk.length;
48
+ if (received > MAX_BODY_BYTES) {
49
+ req.destroy(new PayloadTooLargeError());
50
+ return;
51
+ }
52
+ chunks.push(chunk);
53
+ });
54
+ req.on("end", () => resolve(Buffer.concat(chunks)));
55
+ req.on("error", reject);
56
+ });
57
+ }
58
+
59
+ async function monitorSingleAccount(params: {
60
+ cfg: ClawdbotConfig;
61
+ account: ResolvedSeaTalkAccount;
62
+ runtime?: RuntimeEnv;
63
+ abortSignal?: AbortSignal;
64
+ }): Promise<void> {
65
+ const { cfg, account, runtime, abortSignal } = params;
66
+ const { accountId } = account;
67
+ const log = runtime?.log ?? console.log;
68
+ const error = runtime?.error ?? console.error;
69
+
70
+ const port = account.webhookPort;
71
+ const callbackPath = account.webhookPath;
72
+ const signingSecret = account.signingSecret;
73
+
74
+ if (!signingSecret) {
75
+ throw new Error(`SeaTalk account "${accountId}" missing signingSecret`);
76
+ }
77
+
78
+ const client = resolveSeaTalkClient(account);
79
+ if (!client) {
80
+ throw new Error(`SeaTalk client not available for account "${accountId}"`);
81
+ }
82
+
83
+ log(`seatalk[${accountId}]: starting webhook server on port ${port}, path ${callbackPath}...`);
84
+
85
+ const server = http.createServer();
86
+
87
+ server.on("request", async (req, res) => {
88
+ const pathname = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
89
+ if (req.method !== "POST" || pathname !== callbackPath) {
90
+ res.writeHead(404);
91
+ res.end("Not Found");
92
+ return;
93
+ }
94
+
95
+ try {
96
+ const rawBody = await readBody(req);
97
+ const signature = req.headers.signature as string | undefined;
98
+
99
+ if (!signature || !verifySignature(rawBody, signingSecret, signature)) {
100
+ log(`seatalk[${accountId}]: signature verification failed`);
101
+ res.writeHead(403);
102
+ res.end("Forbidden");
103
+ return;
104
+ }
105
+
106
+ const body = JSON.parse(rawBody.toString("utf-8")) as SeaTalkCallbackRequest;
107
+
108
+ if (body.event_type === "event_verification") {
109
+ const challenge = (body.event as { seatalk_challenge?: string })?.seatalk_challenge;
110
+ res.writeHead(200, { "Content-Type": "application/json" });
111
+ res.end(JSON.stringify({ seatalk_challenge: challenge }));
112
+ log(`seatalk[${accountId}]: URL verification challenge responded`);
113
+ return;
114
+ }
115
+
116
+ res.writeHead(200);
117
+ res.end("OK");
118
+
119
+ dispatchSeaTalkEvent({ cfg, event: body, client, runtime, accountId });
120
+ } catch (err) {
121
+ error(`seatalk[${accountId}]: request processing error: ${String(err)}`);
122
+ if (!res.headersSent) {
123
+ if (err instanceof PayloadTooLargeError) {
124
+ res.writeHead(413);
125
+ res.end("Payload Too Large");
126
+ } else {
127
+ res.writeHead(500);
128
+ res.end("Internal Server Error");
129
+ }
130
+ }
131
+ }
132
+ });
133
+
134
+ return new Promise((resolve, reject) => {
135
+ const cleanup = () => {
136
+ server.close();
137
+ };
138
+
139
+ const handleAbort = () => {
140
+ log(`seatalk[${accountId}]: abort signal received, stopping webhook server`);
141
+ cleanup();
142
+ resolve();
143
+ };
144
+
145
+ if (abortSignal?.aborted) {
146
+ cleanup();
147
+ resolve();
148
+ return;
149
+ }
150
+
151
+ abortSignal?.addEventListener("abort", handleAbort, { once: true });
152
+
153
+ server.listen(port, () => {
154
+ log(`seatalk[${accountId}]: webhook server listening on port ${port}`);
155
+ });
156
+
157
+ server.on("error", (err) => {
158
+ error(`seatalk[${accountId}]: webhook server error: ${err}`);
159
+ abortSignal?.removeEventListener("abort", handleAbort);
160
+ reject(err);
161
+ });
162
+ });
163
+ }
164
+
165
+ export async function monitorSeaTalkProvider(opts: MonitorSeaTalkOpts = {}): Promise<void> {
166
+ const cfg = opts.config;
167
+ if (!cfg) {
168
+ throw new Error("Config is required for SeaTalk monitor");
169
+ }
170
+
171
+ const log = opts.runtime?.log ?? console.log;
172
+
173
+ if (opts.accountId) {
174
+ const account = resolveSeaTalkAccount({ cfg, accountId: opts.accountId });
175
+ if (!account.enabled || !account.configured) {
176
+ throw new Error(`SeaTalk account "${opts.accountId}" not configured or disabled`);
177
+ }
178
+ return monitorSingleAccount({
179
+ cfg,
180
+ account,
181
+ runtime: opts.runtime,
182
+ abortSignal: opts.abortSignal,
183
+ });
184
+ }
185
+
186
+ const accounts = listEnabledSeaTalkAccounts(cfg);
187
+ if (accounts.length === 0) {
188
+ throw new Error("No enabled SeaTalk accounts configured");
189
+ }
190
+
191
+ log(
192
+ `seatalk: starting ${accounts.length} account(s): ${accounts.map((a) => a.accountId).join(", ")}`,
193
+ );
194
+
195
+ await Promise.all(
196
+ accounts.map((account) =>
197
+ monitorSingleAccount({
198
+ cfg,
199
+ account,
200
+ runtime: opts.runtime,
201
+ abortSignal: opts.abortSignal,
202
+ }),
203
+ ),
204
+ );
205
+ }