@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
package/src/api.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SoundChain Agent API Client
|
|
3
|
+
*
|
|
4
|
+
* Wraps the SoundChain Agent REST API at /api/agent/*
|
|
5
|
+
* All read endpoints require NO authentication.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface SoundChainConfig {
|
|
9
|
+
apiUrl: string;
|
|
10
|
+
agentName: string;
|
|
11
|
+
agentWallet?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function request(baseUrl: string, path: string, options?: RequestInit): Promise<unknown> {
|
|
15
|
+
const url = `${baseUrl}${path}`;
|
|
16
|
+
const res = await fetch(url, {
|
|
17
|
+
...options,
|
|
18
|
+
headers: {
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
...options?.headers,
|
|
21
|
+
},
|
|
22
|
+
signal: options?.signal ?? AbortSignal.timeout(15_000),
|
|
23
|
+
});
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
throw new Error(`SoundChain API error ${res.status}: ${res.statusText}`);
|
|
26
|
+
}
|
|
27
|
+
return res.json();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createSoundChainApi(config: SoundChainConfig) {
|
|
31
|
+
const base = config.apiUrl.replace(/\/+$/, "");
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
async searchTracks(query: string, limit = 10): Promise<unknown> {
|
|
35
|
+
return request(base, `/api/agent/tracks?q=${encodeURIComponent(query)}&limit=${limit}`);
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async getRadio(): Promise<unknown> {
|
|
39
|
+
return request(base, "/api/agent/radio");
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
async reportPlay(trackId: string, trackTitle: string, scid?: string): Promise<unknown> {
|
|
43
|
+
return request(base, "/api/agent/play", {
|
|
44
|
+
method: "POST",
|
|
45
|
+
body: JSON.stringify({
|
|
46
|
+
track_id: trackId,
|
|
47
|
+
track_title: trackTitle,
|
|
48
|
+
agent_name: config.agentName,
|
|
49
|
+
agent_wallet: config.agentWallet,
|
|
50
|
+
scid,
|
|
51
|
+
source: "openclaw",
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
async getPlayStats(): Promise<unknown> {
|
|
57
|
+
return request(base, "/api/agent/play");
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async getPlatformStats(): Promise<unknown> {
|
|
61
|
+
return request(base, "/api/agent/stats");
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async getTrending(): Promise<unknown> {
|
|
65
|
+
return request(base, "/api/agent/trending");
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async getDiscover(): Promise<unknown> {
|
|
69
|
+
return request(base, "/api/agent/discover");
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
async getLeaderboard(): Promise<unknown> {
|
|
73
|
+
return request(base, "/api/agent/leaderboard");
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type SoundChainApi = ReturnType<typeof createSoundChainApi>;
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SoundChain Channel Plugin — OpenClaw Messaging Channel
|
|
3
|
+
*
|
|
4
|
+
* Turns SoundChain into an OpenClaw messaging channel. This means:
|
|
5
|
+
* - OpenClaw agents can send DMs to SoundChain users via `sendText`
|
|
6
|
+
* - Inbound messages are detected via 3s polling
|
|
7
|
+
* - FURL replies via SMITH gateway (AI-powered, zero cost per user)
|
|
8
|
+
*
|
|
9
|
+
* Config (in openclaw config file under `channels.soundchain`):
|
|
10
|
+
* apiUrl: GraphQL endpoint (default: https://api.soundchain.io)
|
|
11
|
+
* apiToken: JWT for the bot account (required)
|
|
12
|
+
* accountName: Display name (default: "SoundChain")
|
|
13
|
+
*
|
|
14
|
+
* Architecture:
|
|
15
|
+
* Pulse (PWA) ←→ SoundChain GraphQL ←→ OpenClaw Channel Plugin
|
|
16
|
+
* ↕
|
|
17
|
+
* OpenClaw Gateway (WebSocket)
|
|
18
|
+
* ↕
|
|
19
|
+
* WhatsApp / Telegram / Nostr
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ChannelPlugin } from "../runtime-api.js";
|
|
23
|
+
import { createMessagingClient, type MessagingClient } from "./messaging.js";
|
|
24
|
+
import { generateReply } from "./responder.js";
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Account types
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
const DEFAULT_ACCOUNT_ID = "default";
|
|
31
|
+
const POLL_INTERVAL_MS = 3_000; // 3s — balanced between responsiveness and API load
|
|
32
|
+
const MAX_SEEN_IDS = 5_000;
|
|
33
|
+
|
|
34
|
+
export interface ResolvedSoundChainAccount {
|
|
35
|
+
accountId: string;
|
|
36
|
+
name: string;
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
configured: boolean;
|
|
39
|
+
apiUrl: string;
|
|
40
|
+
apiToken: string;
|
|
41
|
+
autoFollow: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Module-level state (mirrors Nostr channel pattern)
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/** Active messaging clients per account — used by outbound.sendText */
|
|
49
|
+
const activeClients = new Map<string, MessagingClient>();
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Config helpers
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
function extractChannelConfig(cfg: Record<string, unknown>): Record<string, unknown> {
|
|
56
|
+
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
|
|
57
|
+
return (channels.soundchain ?? {}) as Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveAccount(
|
|
61
|
+
cfg: Record<string, unknown>,
|
|
62
|
+
accountId?: string,
|
|
63
|
+
): ResolvedSoundChainAccount {
|
|
64
|
+
const sc = extractChannelConfig(cfg);
|
|
65
|
+
const apiUrl =
|
|
66
|
+
typeof sc.apiUrl === "string" && sc.apiUrl ? sc.apiUrl : "https://api.soundchain.io";
|
|
67
|
+
const apiToken = typeof sc.apiToken === "string" ? sc.apiToken : "";
|
|
68
|
+
const accountName =
|
|
69
|
+
typeof sc.accountName === "string" && sc.accountName ? sc.accountName : "SoundChain";
|
|
70
|
+
const autoFollow = sc.autoFollow === true; // Opt-in, defaults to false
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
|
|
74
|
+
name: accountName,
|
|
75
|
+
enabled: !!apiToken,
|
|
76
|
+
configured: !!apiToken,
|
|
77
|
+
apiUrl,
|
|
78
|
+
apiToken,
|
|
79
|
+
autoFollow,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Channel plugin
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
export const soundchainChannelPlugin: ChannelPlugin<ResolvedSoundChainAccount> = {
|
|
88
|
+
id: "soundchain",
|
|
89
|
+
|
|
90
|
+
meta: {
|
|
91
|
+
id: "soundchain",
|
|
92
|
+
label: "SoundChain",
|
|
93
|
+
selectionLabel: "SoundChain",
|
|
94
|
+
docsPath: "/channels/soundchain",
|
|
95
|
+
docsLabel: "soundchain",
|
|
96
|
+
blurb: "DMs via SoundChain — decentralized music social network",
|
|
97
|
+
order: 200,
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
capabilities: {
|
|
101
|
+
chatTypes: ["direct"],
|
|
102
|
+
media: false,
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
reload: { configPrefixes: ["channels.soundchain"] },
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Config adapter — account resolution from OpenClaw config
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
config: {
|
|
112
|
+
listAccountIds: (cfg) => {
|
|
113
|
+
const sc = extractChannelConfig(cfg);
|
|
114
|
+
return sc.apiToken ? [DEFAULT_ACCOUNT_ID] : [];
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
resolveAccount: (cfg, accountId) => resolveAccount(cfg, accountId ?? undefined),
|
|
118
|
+
|
|
119
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
120
|
+
|
|
121
|
+
isConfigured: (account) => account.configured,
|
|
122
|
+
|
|
123
|
+
describeAccount: (account) => ({
|
|
124
|
+
accountId: account.accountId,
|
|
125
|
+
name: account.name,
|
|
126
|
+
enabled: account.enabled,
|
|
127
|
+
configured: account.configured,
|
|
128
|
+
}),
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Messaging adapter — target normalization
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
messaging: {
|
|
136
|
+
normalizeTarget: (target) => target.trim(),
|
|
137
|
+
targetResolver: {
|
|
138
|
+
looksLikeId: (input) => {
|
|
139
|
+
const trimmed = input.trim();
|
|
140
|
+
// SoundChain profile IDs are MongoDB ObjectIds (24 hex chars)
|
|
141
|
+
return /^[0-9a-fA-F]{24}$/.test(trimmed);
|
|
142
|
+
},
|
|
143
|
+
hint: "<SoundChain profile ID (24-char hex)>",
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Outbound adapter — send DMs via SoundChain GraphQL
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
outbound: {
|
|
152
|
+
deliveryMode: "direct",
|
|
153
|
+
textChunkLimit: 2000,
|
|
154
|
+
|
|
155
|
+
sendText: async ({ to, text, accountId }) => {
|
|
156
|
+
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
157
|
+
const client = activeClients.get(aid);
|
|
158
|
+
|
|
159
|
+
if (!client) {
|
|
160
|
+
throw new Error(`SoundChain messaging client not running for account ${aid}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const result = await client.sendMessage(to, text ?? "");
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
channel: "soundchain" as const,
|
|
167
|
+
to,
|
|
168
|
+
messageId: result.id ?? `sc-${Date.now()}`,
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Gateway adapter — lifecycle + inbound message polling
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
gateway: {
|
|
178
|
+
startAccount: (ctx) => {
|
|
179
|
+
const account = ctx.account;
|
|
180
|
+
|
|
181
|
+
if (!account.configured) {
|
|
182
|
+
throw new Error("SoundChain API token not configured — set channels.soundchain.apiToken");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Return a long-running Promise that stays pending while the channel
|
|
186
|
+
// is active. The OpenClaw gateway auto-restarts channels whose
|
|
187
|
+
// startAccount Promise resolves — so we must block until aborted.
|
|
188
|
+
return new Promise<void>((resolve, reject) => {
|
|
189
|
+
void (async () => {
|
|
190
|
+
try {
|
|
191
|
+
// Handle already-aborted signal before starting any work
|
|
192
|
+
if (ctx.abortSignal.aborted) {
|
|
193
|
+
resolve();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
ctx.log?.info(`[${account.accountId}] Starting SoundChain channel (${account.name})`);
|
|
198
|
+
|
|
199
|
+
// Create and store the messaging client
|
|
200
|
+
const client = createMessagingClient(account.apiUrl, account.apiToken);
|
|
201
|
+
activeClients.set(account.accountId, client);
|
|
202
|
+
|
|
203
|
+
// Track last seen message timestamp per conversation.
|
|
204
|
+
// The chats query returns one entry per conversation (id = conversation ID),
|
|
205
|
+
// so we compare createdAt to detect new messages in existing conversations.
|
|
206
|
+
const lastSeen = new Map<string, string>();
|
|
207
|
+
let interval: ReturnType<typeof setInterval> | undefined;
|
|
208
|
+
let followInterval: ReturnType<typeof setInterval> | undefined;
|
|
209
|
+
let aborted = false;
|
|
210
|
+
|
|
211
|
+
// Cleanup helper
|
|
212
|
+
const cleanup = () => {
|
|
213
|
+
aborted = true;
|
|
214
|
+
if (interval) clearInterval(interval);
|
|
215
|
+
if (followInterval) clearInterval(followInterval);
|
|
216
|
+
activeClients.delete(account.accountId);
|
|
217
|
+
ctx.log?.info(`[${account.accountId}] SoundChain channel stopped`);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// Listen for gateway abort signal (manual stop or shutdown)
|
|
221
|
+
ctx.abortSignal.addEventListener(
|
|
222
|
+
"abort",
|
|
223
|
+
() => {
|
|
224
|
+
cleanup();
|
|
225
|
+
resolve();
|
|
226
|
+
},
|
|
227
|
+
{ once: true },
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Seed with current chat timestamps so we don't replay history
|
|
231
|
+
// MUST complete before polling starts to avoid duplicate replies on cold start
|
|
232
|
+
try {
|
|
233
|
+
const seedChats = await client.getChats();
|
|
234
|
+
for (const chat of seedChats) {
|
|
235
|
+
if (chat.id) {
|
|
236
|
+
lastSeen.set(chat.id, chat.createdAt ?? "");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
ctx.log?.debug?.(
|
|
240
|
+
`[${account.accountId}] Seeded ${lastSeen.size} existing conversations`,
|
|
241
|
+
);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
ctx.log?.warn?.(`[${account.accountId}] Initial chat seed failed: ${err}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// If aborted during seed, stop here — don't register timers
|
|
247
|
+
if (aborted) return;
|
|
248
|
+
|
|
249
|
+
// Auto-follow all users on startup, then re-check every 5 minutes
|
|
250
|
+
// Single-flight guard prevents overlapping runs on slow networks
|
|
251
|
+
let followRunning = false;
|
|
252
|
+
const autoFollowAll = async () => {
|
|
253
|
+
if (followRunning) return;
|
|
254
|
+
followRunning = true;
|
|
255
|
+
try {
|
|
256
|
+
const users = await client.getAllUsers();
|
|
257
|
+
if (aborted) return;
|
|
258
|
+
let followed = 0;
|
|
259
|
+
for (const user of users) {
|
|
260
|
+
if (aborted) break;
|
|
261
|
+
if (!user.isFollowed) {
|
|
262
|
+
try {
|
|
263
|
+
await client.followUser(user.id);
|
|
264
|
+
followed++;
|
|
265
|
+
} catch {
|
|
266
|
+
// skip individual follow errors (already following, etc)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (followed > 0) {
|
|
271
|
+
ctx.log?.info(
|
|
272
|
+
`[${account.accountId}] Auto-followed ${followed} new users (${users.length} total)`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
} catch (err) {
|
|
276
|
+
ctx.log?.warn?.(`[${account.accountId}] Auto-follow error: ${err}`);
|
|
277
|
+
} finally {
|
|
278
|
+
followRunning = false;
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// Auto-follow is opt-in (channels.soundchain.autoFollow: true)
|
|
283
|
+
if (account.autoFollow) {
|
|
284
|
+
autoFollowAll();
|
|
285
|
+
followInterval = setInterval(autoFollowAll, 5 * 60 * 1000);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Poll for new inbound messages (starts AFTER seed completes)
|
|
289
|
+
// Single-flight guard prevents overlapping iterations
|
|
290
|
+
let polling = false;
|
|
291
|
+
interval = setInterval(async () => {
|
|
292
|
+
if (polling || aborted) return;
|
|
293
|
+
polling = true;
|
|
294
|
+
try {
|
|
295
|
+
const chats = await client.getChats();
|
|
296
|
+
if (aborted) {
|
|
297
|
+
polling = false;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
for (const chat of chats) {
|
|
302
|
+
if (!chat.id || aborted) continue;
|
|
303
|
+
|
|
304
|
+
const prevTimestamp = lastSeen.get(chat.id);
|
|
305
|
+
const currentTimestamp = chat.createdAt ?? "";
|
|
306
|
+
|
|
307
|
+
// Skip if we've already seen this exact message
|
|
308
|
+
if (prevTimestamp === currentTimestamp) continue;
|
|
309
|
+
|
|
310
|
+
// Skip read messages (only process unread)
|
|
311
|
+
if (!chat.unread) continue;
|
|
312
|
+
|
|
313
|
+
// Cap tracked conversations to prevent unbounded growth
|
|
314
|
+
if (lastSeen.size > MAX_SEEN_IDS) {
|
|
315
|
+
const entries = Array.from(lastSeen.keys());
|
|
316
|
+
for (let i = 0; i < entries.length - MAX_SEEN_IDS; i++) {
|
|
317
|
+
lastSeen.delete(entries[i]);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const sender = chat.profile?.displayName ?? "unknown";
|
|
322
|
+
const preview = chat.message?.slice(0, 80) ?? "";
|
|
323
|
+
|
|
324
|
+
ctx.log?.info(
|
|
325
|
+
`[${account.accountId}] New DM from ${sender}: ${preview}${(chat.message?.length ?? 0) > 80 ? "..." : ""}`,
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
// Generate AI reply via SMITH gateway and send back
|
|
329
|
+
const profileId = chat.profile?.id;
|
|
330
|
+
if (profileId && chat.message) {
|
|
331
|
+
try {
|
|
332
|
+
ctx.log?.info(`[${account.accountId}] Generating reply for ${sender}...`);
|
|
333
|
+
const reply = await generateReply(sender, chat.message);
|
|
334
|
+
if (aborted) break;
|
|
335
|
+
await client.sendMessage(profileId, reply);
|
|
336
|
+
// Only update timestamp AFTER successful reply (retry on failure)
|
|
337
|
+
lastSeen.set(chat.id, currentTimestamp);
|
|
338
|
+
ctx.log?.info(
|
|
339
|
+
`[${account.accountId}] Replied to ${sender}: ${reply.slice(0, 80)}${reply.length > 80 ? "..." : ""}`,
|
|
340
|
+
);
|
|
341
|
+
} catch (err) {
|
|
342
|
+
ctx.log?.warn?.(`[${account.accountId}] Reply failed for ${sender}: ${err}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
} catch (err) {
|
|
347
|
+
ctx.log?.warn?.(`[${account.accountId}] Poll error: ${err}`);
|
|
348
|
+
} finally {
|
|
349
|
+
polling = false;
|
|
350
|
+
}
|
|
351
|
+
}, POLL_INTERVAL_MS);
|
|
352
|
+
|
|
353
|
+
ctx.log?.info(
|
|
354
|
+
`[${account.accountId}] SoundChain channel started — polling every ${POLL_INTERVAL_MS / 1000}s`,
|
|
355
|
+
);
|
|
356
|
+
} catch (err) {
|
|
357
|
+
// Bootstrap failed — reject the promise so gateway marks channel as errored
|
|
358
|
+
// instead of leaving it in a broken "started" state
|
|
359
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
360
|
+
}
|
|
361
|
+
})();
|
|
362
|
+
});
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
};
|