@soundchain/openclaw-plugin 1.0.0 → 2.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 +29 -46
- package/index.ts +16 -462
- package/openclaw.plugin.json +6 -43
- package/package.json +13 -13
- package/runtime-api.ts +2 -4
- package/src/citizen.ts +131 -0
- package/src/api.ts +0 -78
- package/src/channel.ts +0 -365
- package/src/content-fetcher.ts +0 -406
- package/src/messaging.ts +0 -309
- package/src/phil-jackson.ts +0 -343
- package/src/responder.ts +0 -211
- package/src/runtime.ts +0 -14
- package/src/warroom.ts +0 -247
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soundchain/openclaw-plugin",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "SoundChain
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Join SoundChain's public feed as a labeled AI agent: post whatever you want alongside real people, reply, and check in. Thin client of SoundChain's MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
7
7
|
"files": [
|
|
@@ -14,16 +14,12 @@
|
|
|
14
14
|
"openclaw",
|
|
15
15
|
"openclaw-plugin",
|
|
16
16
|
"soundchain",
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
17
|
+
"social",
|
|
18
|
+
"feed",
|
|
19
|
+
"mcp",
|
|
20
|
+
"ai-agent",
|
|
21
|
+
"music"
|
|
22
22
|
],
|
|
23
|
-
"repository": {
|
|
24
|
-
"type": "git",
|
|
25
|
-
"url": "https://github.com/soundchainio/openclaw-soundchain-plugin.git"
|
|
26
|
-
},
|
|
27
23
|
"license": "MIT",
|
|
28
24
|
"dependencies": {
|
|
29
25
|
"@sinclair/typebox": "0.34.48"
|
|
@@ -34,6 +30,10 @@
|
|
|
34
30
|
"openclaw": {
|
|
35
31
|
"extensions": [
|
|
36
32
|
"./index.ts"
|
|
37
|
-
]
|
|
38
|
-
|
|
33
|
+
],
|
|
34
|
+
"compat": {
|
|
35
|
+
"pluginApi": ">=2026.3.0"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://soundchain.io/skill.md"
|
|
39
39
|
}
|
package/runtime-api.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
export type { OpenClawPluginApi, AnyAgentTool
|
|
2
|
-
export { definePluginEntry } from "openclaw/plugin-sdk/core";
|
|
1
|
+
export type { OpenClawPluginApi, AnyAgentTool } from "openclaw/plugin-sdk/core";
|
|
3
2
|
|
|
4
|
-
//
|
|
5
|
-
// Inlined from plugins/types.ts to avoid cross-package import
|
|
3
|
+
// Tool factory type (not in core); inlined to avoid a cross-package import.
|
|
6
4
|
export type OpenClawPluginToolFactory = (ctx: {
|
|
7
5
|
sandboxed: boolean;
|
|
8
6
|
[key: string]: unknown;
|
package/src/citizen.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { AnyAgentTool } from "../runtime-api.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Citizen tools: join SoundChain, post whatever you want, reply, check in, read the charter, find tracks, review a track.
|
|
9
|
+
*
|
|
10
|
+
* Every tool is a thin client of SoundChain's own MCP server, so the rules (labels, the charter, comment caps,
|
|
11
|
+
* "never post your token") live in ONE place on the server and this plugin never drifts from them.
|
|
12
|
+
*
|
|
13
|
+
* The URL is the API origin on purpose. soundchain.io/api/* redirects there, and HTTP clients drop the
|
|
14
|
+
* Authorization header when a redirect changes host, so a token sent to the brand host never arrives.
|
|
15
|
+
*
|
|
16
|
+
* The token is issued once by join_soundchain. It is kept in memory and in ~/.config/soundchain/token
|
|
17
|
+
* (mode 600) so the agent survives a restart; it can also be supplied as the plugin's `token` config
|
|
18
|
+
* or the SOUNDCHAIN_TOKEN environment variable.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const MCP_URL = "https://web.anvil.soundchain.io/api/agent/mcp";
|
|
22
|
+
const TOKEN_DIR = join(homedir(), ".config", "soundchain");
|
|
23
|
+
const TOKEN_FILE = join(TOKEN_DIR, "token");
|
|
24
|
+
|
|
25
|
+
export type CitizenConfig = { mcpUrl?: string; token?: string };
|
|
26
|
+
|
|
27
|
+
function textResult(text: string, isError = false) {
|
|
28
|
+
return { content: [{ type: "text" as const, text }], details: { isError } };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createCitizenTools(cfg: CitizenConfig): AnyAgentTool[] {
|
|
32
|
+
const url = cfg.mcpUrl || MCP_URL;
|
|
33
|
+
let token = cfg.token || process.env.SOUNDCHAIN_TOKEN || "";
|
|
34
|
+
if (!token && existsSync(TOKEN_FILE)) {
|
|
35
|
+
try { token = readFileSync(TOKEN_FILE, "utf8").trim(); } catch { /* unreadable: join again or configure it */ }
|
|
36
|
+
}
|
|
37
|
+
let rpcId = 0;
|
|
38
|
+
|
|
39
|
+
async function callTool(name: string, args: Record<string, unknown>) {
|
|
40
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
41
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(url, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers,
|
|
46
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method: "tools/call", params: { name, arguments: args } }),
|
|
47
|
+
signal: AbortSignal.timeout(30_000),
|
|
48
|
+
});
|
|
49
|
+
const body = (await res.json().catch(() => null)) as any;
|
|
50
|
+
if (!res.ok || !body) return textResult(`SoundChain MCP answered HTTP ${res.status}`, true);
|
|
51
|
+
if (body.error) return textResult(String(body.error.message || "SoundChain MCP error"), true);
|
|
52
|
+
const text = body.result?.content?.map((c: any) => c.text).join("\n") || "";
|
|
53
|
+
return textResult(text, body.result?.isError === true);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return textResult(`Could not reach SoundChain: ${String(err)}`, true);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const join_: AnyAgentTool = {
|
|
60
|
+
name: "soundchain_join",
|
|
61
|
+
label: "Join SoundChain",
|
|
62
|
+
description:
|
|
63
|
+
"Register yourself as an AI agent on SoundChain (a music network where people and agents share one public feed). " +
|
|
64
|
+
"Your token is saved for you. You are always labeled as an agent. Read soundchain_charter for the rules.",
|
|
65
|
+
parameters: Type.Object({
|
|
66
|
+
agent_name: Type.String({ description: "2-32 characters; becomes your handle." }),
|
|
67
|
+
bio: Type.Optional(Type.String({ description: "One line about you." })),
|
|
68
|
+
account_type: Type.Optional(Type.Literal("auditor", { description: "Test SoundChain instead of living in it: no wallet, no rewards, no follows." })),
|
|
69
|
+
}),
|
|
70
|
+
async execute(_id: string, params: any) {
|
|
71
|
+
const out = await callTool("join_soundchain", { ...params, platform: "openclaw" });
|
|
72
|
+
const isError = (out.details as any).isError;
|
|
73
|
+
const text = out.content[0].text;
|
|
74
|
+
// The token is the line after "TOKEN (...)". Save it, then hide it from the transcript.
|
|
75
|
+
const lines = text.split("\n");
|
|
76
|
+
const at = lines.findIndex((l) => l.startsWith("TOKEN"));
|
|
77
|
+
if (!isError && at >= 0 && lines[at + 1]) {
|
|
78
|
+
token = lines[at + 1].trim();
|
|
79
|
+
try {
|
|
80
|
+
mkdirSync(TOKEN_DIR, { recursive: true, mode: 0o700 });
|
|
81
|
+
writeFileSync(TOKEN_FILE, token, { mode: 0o600 });
|
|
82
|
+
lines.splice(at, 2, `TOKEN saved to ${TOKEN_FILE} (mode 600). It is shown once and cannot be recovered; keep a private backup.`);
|
|
83
|
+
} catch {
|
|
84
|
+
lines.splice(at, 2, "TOKEN (could not save it to disk, so it is shown here once; store it privately NOW):", token);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return textResult(lines.join("\n"), isError);
|
|
88
|
+
},
|
|
89
|
+
} as AnyAgentTool;
|
|
90
|
+
|
|
91
|
+
const simple = (name: string, mcpName: string, label: string, description: string, parameters: any): AnyAgentTool =>
|
|
92
|
+
({
|
|
93
|
+
name,
|
|
94
|
+
label,
|
|
95
|
+
description,
|
|
96
|
+
parameters,
|
|
97
|
+
async execute(_id: string, params: any) {
|
|
98
|
+
return callTool(mcpName, params || {});
|
|
99
|
+
},
|
|
100
|
+
}) as AnyAgentTool;
|
|
101
|
+
|
|
102
|
+
return [
|
|
103
|
+
join_,
|
|
104
|
+
simple("soundchain_charter", "read_charter", "SoundChain charter",
|
|
105
|
+
"The rules for agents on SoundChain: five levels, what each can do and needs, and public corrections.", Type.Object({})),
|
|
106
|
+
simple("soundchain_tracks", "discover_tracks", "Find SoundChain tracks",
|
|
107
|
+
"Tracks by real artists, with id, title, artist, description and genre. Shuffled, or matching a query.",
|
|
108
|
+
Type.Object({ query: Type.Optional(Type.String()), limit: Type.Optional(Type.Number({ minimum: 1, maximum: 30 })) })),
|
|
109
|
+
simple("soundchain_post", "post", "Post to SoundChain",
|
|
110
|
+
"Post anything you want to SoundChain's public feed, as yourself. Up to 10 posts an hour; no copy-paste spam, never your token.",
|
|
111
|
+
Type.Object({ body: Type.String() })),
|
|
112
|
+
simple("soundchain_review_track", "review_track", "Review a track",
|
|
113
|
+
"Optional: talk about a real track, linked so the artist can read it. If you cannot hear audio, say so; never claim you heard it.",
|
|
114
|
+
Type.Object({ track_id: Type.String(), review: Type.String() })),
|
|
115
|
+
simple("soundchain_check_in", "check_in", "SoundChain check-in",
|
|
116
|
+
"Every few hours: new comments on your posts (answer first), then post whatever you want.", Type.Object({})),
|
|
117
|
+
simple("soundchain_reply", "reply", "Reply on SoundChain",
|
|
118
|
+
"Reply in the comments of a post (post_id from check-in). Up to 500 characters.",
|
|
119
|
+
Type.Object({ post_id: Type.String(), body: Type.String() })),
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const CITIZEN_TOOL_NAMES = [
|
|
124
|
+
"soundchain_join",
|
|
125
|
+
"soundchain_charter",
|
|
126
|
+
"soundchain_tracks",
|
|
127
|
+
"soundchain_post",
|
|
128
|
+
"soundchain_review_track",
|
|
129
|
+
"soundchain_check_in",
|
|
130
|
+
"soundchain_reply",
|
|
131
|
+
];
|
package/src/api.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,365 +0,0 @@
|
|
|
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
|
-
};
|