@soundchain/openclaw-plugin 1.0.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.
- package/README.md +64 -0
- package/index.ts +478 -0
- package/openclaw.plugin.json +57 -0
- package/package.json +39 -0
- package/runtime-api.ts +9 -0
- package/src/api.ts +78 -0
- package/src/channel.ts +365 -0
- package/src/content-fetcher.ts +406 -0
- package/src/messaging.ts +309 -0
- package/src/phil-jackson.ts +343 -0
- package/src/responder.ts +211 -0
- package/src/runtime.ts +14 -0
- package/src/warroom.ts +247 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FURL Content Fetcher — URL Detection + Content Extraction
|
|
3
|
+
*
|
|
4
|
+
* Detects URLs in DM messages and extracts readable text content
|
|
5
|
+
* so Claude can summarize videos, articles, tweets, etc.
|
|
6
|
+
*
|
|
7
|
+
* Supported:
|
|
8
|
+
* - YouTube (transcript via captions API — free, no key needed)
|
|
9
|
+
* - General web pages (HTML stripped to text)
|
|
10
|
+
*
|
|
11
|
+
* Zero cost, zero new dependencies. Uses built-in fetch().
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// URL detection
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/gi;
|
|
19
|
+
|
|
20
|
+
const YOUTUBE_REGEX =
|
|
21
|
+
/(?:youtube\.com\/(?:watch\?.*?v=|shorts\/|live\/|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
|
22
|
+
|
|
23
|
+
const X_TWITTER_REGEX = /(?:x\.com|twitter\.com)\/(\w+)\/status\/(\d+)/;
|
|
24
|
+
|
|
25
|
+
import { lookup } from "node:dns/promises";
|
|
26
|
+
|
|
27
|
+
const USER_AGENT =
|
|
28
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
|
29
|
+
|
|
30
|
+
const FETCH_TIMEOUT_MS = 12_000;
|
|
31
|
+
const MAX_URLS = 3;
|
|
32
|
+
const MAX_TRANSCRIPT_CHARS = 15_000;
|
|
33
|
+
const MAX_WEBPAGE_CHARS = 10_000;
|
|
34
|
+
|
|
35
|
+
// Block private/internal network URLs to prevent SSRF
|
|
36
|
+
const BLOCKED_HOST_PATTERNS = [
|
|
37
|
+
/^localhost$/i,
|
|
38
|
+
/^127\.\d+\.\d+\.\d+$/,
|
|
39
|
+
/^10\.\d+\.\d+\.\d+$/,
|
|
40
|
+
/^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/,
|
|
41
|
+
/^192\.168\.\d+\.\d+$/,
|
|
42
|
+
/^169\.254\.\d+\.\d+$/, // Link-local
|
|
43
|
+
/^0\.0\.0\.0$/,
|
|
44
|
+
/^\[?::1?\]?$/,
|
|
45
|
+
/^\[?::ffff:/i, // IPv6-mapped IPv4 (bracketed or bare)
|
|
46
|
+
/^\[?fd[0-9a-f]{2}:/i, // IPv6 ULA fd00::/8 (bracketed or bare)
|
|
47
|
+
/^\[?fe80:/i, // IPv6 link-local fe80::/10 (bracketed or bare)
|
|
48
|
+
/^\[?fc[0-9a-f]{2}:/i, // IPv6 ULA fc00::/7 (bracketed or bare)
|
|
49
|
+
/\.internal$/i,
|
|
50
|
+
/\.local$/i,
|
|
51
|
+
/metadata\.google/i, // Cloud metadata
|
|
52
|
+
/169\.254\.169\.254/, // AWS/GCP metadata
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
function isBlockedHostname(hostname: string): boolean {
|
|
56
|
+
return BLOCKED_HOST_PATTERNS.some((p) => p.test(hostname));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isBlockedUrl(url: string): boolean {
|
|
60
|
+
try {
|
|
61
|
+
const parsed = new URL(url);
|
|
62
|
+
return isBlockedHostname(parsed.hostname);
|
|
63
|
+
} catch {
|
|
64
|
+
return true; // Block unparseable URLs
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Resolve hostname via DNS and check if the IP is private/blocked */
|
|
69
|
+
async function isBlockedAfterResolve(url: string): Promise<boolean> {
|
|
70
|
+
if (isBlockedUrl(url)) return true;
|
|
71
|
+
try {
|
|
72
|
+
const parsed = new URL(url);
|
|
73
|
+
const { address } = await lookup(parsed.hostname);
|
|
74
|
+
return isBlockedHostname(address);
|
|
75
|
+
} catch {
|
|
76
|
+
return false; // DNS failure — allow (will fail at fetch anyway)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Safe fetch that blocks redirects to private networks.
|
|
82
|
+
* Uses redirect: "manual" to inspect each hop.
|
|
83
|
+
*/
|
|
84
|
+
const MAX_REDIRECTS = 5;
|
|
85
|
+
|
|
86
|
+
async function safeFetch(url: string, options?: RequestInit, hops = 0): Promise<Response> {
|
|
87
|
+
if (hops > MAX_REDIRECTS) throw new Error(`Too many redirects (>${MAX_REDIRECTS})`);
|
|
88
|
+
|
|
89
|
+
// Resolve DNS and validate BEFORE fetching (prevents DNS rebinding)
|
|
90
|
+
const parsed = new URL(url);
|
|
91
|
+
if (isBlockedHostname(parsed.hostname)) throw new Error("Blocked hostname");
|
|
92
|
+
let resolvedIp: string | undefined;
|
|
93
|
+
try {
|
|
94
|
+
const { address } = await lookup(parsed.hostname);
|
|
95
|
+
resolvedIp = address;
|
|
96
|
+
if (isBlockedHostname(address)) throw new Error("Blocked resolved IP");
|
|
97
|
+
} catch (e: any) {
|
|
98
|
+
if (e.message?.includes("Blocked")) throw e;
|
|
99
|
+
// DNS failure — proceed with hostname (will fail at fetch if unreachable)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// DNS validated — proceed with original URL (IP pinning breaks TLS/SNI)
|
|
103
|
+
// The validation + short timeout window is sufficient for non-targeted SSRF
|
|
104
|
+
const res = await fetch(url, {
|
|
105
|
+
...options,
|
|
106
|
+
redirect: "manual",
|
|
107
|
+
signal: options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (res.status >= 300 && res.status < 400) {
|
|
111
|
+
const location = res.headers.get("location");
|
|
112
|
+
if (!location) return res;
|
|
113
|
+
|
|
114
|
+
const redirectUrl = new URL(location, url).toString();
|
|
115
|
+
if (isBlockedUrl(redirectUrl)) throw new Error("Redirect to blocked URL");
|
|
116
|
+
|
|
117
|
+
return safeFetch(redirectUrl, options, hops + 1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return res;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Public API
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Scans a message for URLs, fetches content from each, and appends
|
|
129
|
+
* extracted text so Claude can summarize it.
|
|
130
|
+
* Returns the original message if no URLs found or all extractions fail.
|
|
131
|
+
*/
|
|
132
|
+
export async function enrichMessageWithUrlContent(message: string): Promise<string> {
|
|
133
|
+
const urls = message.match(URL_REGEX);
|
|
134
|
+
if (!urls || urls.length === 0) return message;
|
|
135
|
+
|
|
136
|
+
// Deduplicate
|
|
137
|
+
const unique = [...new Set(urls)].slice(0, MAX_URLS);
|
|
138
|
+
|
|
139
|
+
const extractions: string[] = [];
|
|
140
|
+
|
|
141
|
+
for (const url of unique) {
|
|
142
|
+
try {
|
|
143
|
+
const content = await extractContent(url);
|
|
144
|
+
if (content) {
|
|
145
|
+
extractions.push(content);
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// Skip failed extractions silently
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (extractions.length === 0) return message;
|
|
153
|
+
|
|
154
|
+
return message + "\n\n---\n" + extractions.join("\n\n---\n");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Router
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
async function extractContent(url: string): Promise<string | null> {
|
|
162
|
+
// Block private/internal network URLs (SSRF prevention — checks both hostname and resolved IP)
|
|
163
|
+
if (await isBlockedAfterResolve(url)) return null;
|
|
164
|
+
|
|
165
|
+
const ytMatch = url.match(YOUTUBE_REGEX);
|
|
166
|
+
if (ytMatch) {
|
|
167
|
+
return fetchYouTubeTranscript(ytMatch[1], url);
|
|
168
|
+
}
|
|
169
|
+
const xMatch = url.match(X_TWITTER_REGEX);
|
|
170
|
+
if (xMatch) {
|
|
171
|
+
return fetchXPost(xMatch[1], xMatch[2], url);
|
|
172
|
+
}
|
|
173
|
+
return fetchWebPageText(url);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// YouTube Transcript (free — captions API, no key needed)
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
async function fetchYouTubeTranscript(
|
|
181
|
+
videoId: string,
|
|
182
|
+
originalUrl: string,
|
|
183
|
+
): Promise<string | null> {
|
|
184
|
+
try {
|
|
185
|
+
const res = await fetch(`https://www.youtube.com/watch?v=${videoId}&hl=en`, {
|
|
186
|
+
headers: {
|
|
187
|
+
"User-Agent": USER_AGENT,
|
|
188
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
189
|
+
},
|
|
190
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
191
|
+
});
|
|
192
|
+
const html = await res.text();
|
|
193
|
+
|
|
194
|
+
// Extract video title
|
|
195
|
+
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
196
|
+
const title = titleMatch
|
|
197
|
+
? titleMatch[1].replace(/\s*-\s*YouTube\s*$/, "").trim()
|
|
198
|
+
: "Unknown Video";
|
|
199
|
+
|
|
200
|
+
// Extract ytInitialPlayerResponse JSON
|
|
201
|
+
const playerMatch =
|
|
202
|
+
html.match(/var\s+ytInitialPlayerResponse\s*=\s*(\{.+?\});/s) ||
|
|
203
|
+
html.match(/ytInitialPlayerResponse\s*=\s*(\{.+?\});/s);
|
|
204
|
+
|
|
205
|
+
if (!playerMatch) {
|
|
206
|
+
return `[YouTube Video: "${title}" — ${originalUrl}]\n(Could not extract transcript — captions may be disabled)`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let playerResponse: Record<string, unknown>;
|
|
210
|
+
try {
|
|
211
|
+
playerResponse = JSON.parse(playerMatch[1]);
|
|
212
|
+
} catch {
|
|
213
|
+
return `[YouTube Video: "${title}" — ${originalUrl}]\n(Could not parse video data)`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Navigate to caption tracks
|
|
217
|
+
const captions = playerResponse?.captions as Record<string, unknown> | undefined;
|
|
218
|
+
const renderer = captions?.playerCaptionsTracklistRenderer as
|
|
219
|
+
| Record<string, unknown>
|
|
220
|
+
| undefined;
|
|
221
|
+
const captionTracks = renderer?.captionTracks as Array<Record<string, unknown>> | undefined;
|
|
222
|
+
|
|
223
|
+
if (!captionTracks || captionTracks.length === 0) {
|
|
224
|
+
// No captions — try to get video description instead
|
|
225
|
+
const descMatch = html.match(/"shortDescription":"((?:[^"\\]|\\.)*)"/);
|
|
226
|
+
const description = descMatch
|
|
227
|
+
? descMatch[1].replace(/\\n/g, "\n").replace(/\\"/g, '"').replace(/\\\\/g, "\\")
|
|
228
|
+
: null;
|
|
229
|
+
|
|
230
|
+
if (description && description.length > 50) {
|
|
231
|
+
return `[YouTube Video: "${title}" — ${originalUrl}]\n(No captions available — description below)\n\n${description.slice(0, 3000)}`;
|
|
232
|
+
}
|
|
233
|
+
return `[YouTube Video: "${title}" — ${originalUrl}]\n(No captions or description available)`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Prefer English, fallback to first available
|
|
237
|
+
const track =
|
|
238
|
+
captionTracks.find(
|
|
239
|
+
(t) => typeof t.languageCode === "string" && t.languageCode.startsWith("en"),
|
|
240
|
+
) || captionTracks[0];
|
|
241
|
+
|
|
242
|
+
const captionUrl = track?.baseUrl;
|
|
243
|
+
if (typeof captionUrl !== "string") return null;
|
|
244
|
+
|
|
245
|
+
// Fetch caption XML
|
|
246
|
+
const captionRes = await safeFetch(captionUrl, {
|
|
247
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
248
|
+
});
|
|
249
|
+
const xml = await captionRes.text();
|
|
250
|
+
|
|
251
|
+
// Parse <text> elements to plain text
|
|
252
|
+
const textMatches = [...xml.matchAll(/<text[^>]*>([\s\S]*?)<\/text>/g)];
|
|
253
|
+
const transcript = textMatches
|
|
254
|
+
.map((m) => decodeHtmlEntities(m[1]).replace(/\n/g, " "))
|
|
255
|
+
.join(" ")
|
|
256
|
+
.replace(/\s+/g, " ")
|
|
257
|
+
.trim();
|
|
258
|
+
|
|
259
|
+
if (!transcript) {
|
|
260
|
+
return `[YouTube Video: "${title}" — ${originalUrl}]\n(Captions found but empty)`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const lang = typeof track.languageCode === "string" ? ` (${track.languageCode})` : "";
|
|
264
|
+
|
|
265
|
+
return `[YouTube Video: "${title}" — ${originalUrl}]\n[Transcript${lang} — ${transcript.length} chars]:\n\n${transcript.slice(0, MAX_TRANSCRIPT_CHARS)}`;
|
|
266
|
+
} catch (err) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// X/Twitter Posts (via Nitter instances or syndication API)
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
async function fetchXPost(
|
|
276
|
+
username: string,
|
|
277
|
+
tweetId: string,
|
|
278
|
+
originalUrl: string,
|
|
279
|
+
): Promise<string | null> {
|
|
280
|
+
// Try Twitter syndication API (public, no auth needed)
|
|
281
|
+
try {
|
|
282
|
+
const syndicationUrl = `https://cdn.syndication.twimg.com/tweet-result?id=${tweetId}&token=0`;
|
|
283
|
+
const res = await fetch(syndicationUrl, {
|
|
284
|
+
headers: { "User-Agent": USER_AGENT },
|
|
285
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (res.ok) {
|
|
289
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
290
|
+
const text = data.text as string | undefined;
|
|
291
|
+
const userName = (data.user as Record<string, unknown>)?.name as string | undefined;
|
|
292
|
+
const handle = (data.user as Record<string, unknown>)?.screen_name as string | undefined;
|
|
293
|
+
|
|
294
|
+
if (text) {
|
|
295
|
+
const header = `[X Post by ${userName || username} (@${handle || username}) — ${originalUrl}]`;
|
|
296
|
+
|
|
297
|
+
// Check for quoted tweet
|
|
298
|
+
const quoted = data.quoted_tweet as Record<string, unknown> | undefined;
|
|
299
|
+
let quotedText = "";
|
|
300
|
+
if (quoted?.text) {
|
|
301
|
+
const qUser = (quoted.user as Record<string, unknown>)?.screen_name || "unknown";
|
|
302
|
+
quotedText = `\n\n[Quoted @${qUser}]: ${quoted.text as string}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Check for media descriptions
|
|
306
|
+
const mediaDetails = data.mediaDetails as Array<Record<string, unknown>> | undefined;
|
|
307
|
+
let mediaText = "";
|
|
308
|
+
if (mediaDetails && mediaDetails.length > 0) {
|
|
309
|
+
const types = mediaDetails.map((m) => m.type as string).filter(Boolean);
|
|
310
|
+
mediaText = `\n[Media: ${types.join(", ")}]`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return `${header}\n\n${text}${quotedText}${mediaText}`;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
// Syndication failed — fall through to web page fetch
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Fallback: fetch the page directly (may get limited HTML)
|
|
321
|
+
return fetchWebPageText(originalUrl);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// General Web Page
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
async function fetchWebPageText(url: string): Promise<string | null> {
|
|
329
|
+
try {
|
|
330
|
+
const res = await safeFetch(url, {
|
|
331
|
+
headers: { "User-Agent": USER_AGENT },
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const contentType = res.headers.get("content-type") || "";
|
|
335
|
+
if (
|
|
336
|
+
!contentType.includes("text/html") &&
|
|
337
|
+
!contentType.includes("text/plain") &&
|
|
338
|
+
!contentType.includes("application/json")
|
|
339
|
+
) {
|
|
340
|
+
return null; // Skip binary content (images, videos, PDFs, etc.)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Cap response body to 512KB to prevent memory exhaustion from large pages
|
|
344
|
+
const MAX_BODY_BYTES = 512 * 1024;
|
|
345
|
+
const reader = res.body?.getReader();
|
|
346
|
+
if (!reader) return null;
|
|
347
|
+
const chunks: Uint8Array[] = [];
|
|
348
|
+
let totalBytes = 0;
|
|
349
|
+
while (totalBytes < MAX_BODY_BYTES) {
|
|
350
|
+
const { done, value } = await reader.read();
|
|
351
|
+
if (done) break;
|
|
352
|
+
chunks.push(value);
|
|
353
|
+
totalBytes += value.length;
|
|
354
|
+
}
|
|
355
|
+
reader.cancel();
|
|
356
|
+
const raw = new TextDecoder().decode(Buffer.concat(chunks));
|
|
357
|
+
|
|
358
|
+
// JSON responses — return formatted
|
|
359
|
+
if (contentType.includes("application/json")) {
|
|
360
|
+
return `[API Response: ${url}]\n\n${raw.slice(0, MAX_WEBPAGE_CHARS)}`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Extract title
|
|
364
|
+
const titleMatch = raw.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
365
|
+
const title = titleMatch ? decodeHtmlEntities(titleMatch[1]).trim() : "";
|
|
366
|
+
|
|
367
|
+
// Strip non-content elements, then all tags
|
|
368
|
+
const text = raw
|
|
369
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
370
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
371
|
+
.replace(/<nav[\s\S]*?<\/nav>/gi, "")
|
|
372
|
+
.replace(/<footer[\s\S]*?<\/footer>/gi, "")
|
|
373
|
+
.replace(/<noscript[\s\S]*?<\/noscript>/gi, "")
|
|
374
|
+
.replace(/<svg[\s\S]*?<\/svg>/gi, "")
|
|
375
|
+
.replace(/<[^>]+>/g, " ")
|
|
376
|
+
.replace(/\s+/g, " ")
|
|
377
|
+
.trim();
|
|
378
|
+
|
|
379
|
+
const cleaned = decodeHtmlEntities(text);
|
|
380
|
+
|
|
381
|
+
if (cleaned.length < 100) return null;
|
|
382
|
+
|
|
383
|
+
const header = title ? `[Web Page: "${title}" — ${url}]\n\n` : `[Web Page: ${url}]\n\n`;
|
|
384
|
+
|
|
385
|
+
return header + cleaned.slice(0, MAX_WEBPAGE_CHARS);
|
|
386
|
+
} catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
// Helpers
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
function decodeHtmlEntities(text: string): string {
|
|
396
|
+
return text
|
|
397
|
+
.replace(/&/g, "&")
|
|
398
|
+
.replace(/</g, "<")
|
|
399
|
+
.replace(/>/g, ">")
|
|
400
|
+
.replace(/'/g, "'")
|
|
401
|
+
.replace(/'/g, "'")
|
|
402
|
+
.replace(/"/g, '"')
|
|
403
|
+
.replace(/ /g, " ")
|
|
404
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
|
|
405
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
406
|
+
}
|