@shadowob/connector 1.1.3-dev.261 → 1.1.3-dev.281
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
CHANGED
|
@@ -26,6 +26,14 @@ npx @shadowob/connector@latest connect \
|
|
|
26
26
|
|
|
27
27
|
Use `--dry-run` to preview writes and commands. Use `--json` with `plan` when embedding the plan in another tool.
|
|
28
28
|
|
|
29
|
+
`connect` merges existing configuration instead of replacing it:
|
|
30
|
+
|
|
31
|
+
- OpenClaw JSON defaults to `~/.shadowob/openclaw.json` or `--openclaw-config`.
|
|
32
|
+
- Hermes updates `~/.hermes/.env` and merges `~/.hermes/config.yaml`.
|
|
33
|
+
- cc-connect merges the ShadowOB platform into `~/.cc-connect/config.toml`.
|
|
34
|
+
|
|
35
|
+
Existing model providers, plugins, projects, platforms, and unrelated keys are preserved.
|
|
36
|
+
|
|
29
37
|
## OpenClaw
|
|
30
38
|
|
|
31
39
|
```bash
|
|
@@ -57,7 +65,7 @@ npx @shadowob/connector@latest connect \
|
|
|
57
65
|
|
|
58
66
|
The Hermes plugin is bundled in `hermes-shadowob-plugin/`. The connector copies it to `~/.hermes/plugins/shadowob`, writes the Shadow token/base URL, and enables the plugin.
|
|
59
67
|
|
|
60
|
-
Hermes does not need `agentId` or `channelId` in the setup command. The plugin calls `/api/auth/me` to resolve the Buddy agent id, then `/api/agents/:id/config` to receive channel access policy dynamically, matching the OpenClaw plugin behavior.
|
|
68
|
+
Hermes does not need `agentId` or `channelId` in the setup command. The plugin calls `/api/auth/me` to resolve the Buddy agent id, then `/api/agents/:id/config` to receive channel access policy dynamically, matching the OpenClaw plugin behavior. If no channel is available yet, Hermes stays online and waits for a DM, server join, channel membership, or policy update. By default it creates/uses the DM with the Buddy owner as the home channel.
|
|
61
69
|
|
|
62
70
|
Manual config shape:
|
|
63
71
|
|
|
@@ -148,7 +156,7 @@ pnpm -C packages/connector build
|
|
|
148
156
|
uv run --project .tmp/hermes-agent --with pytest python -m pytest packages/connector/hermes-shadowob-plugin/tests
|
|
149
157
|
```
|
|
150
158
|
|
|
151
|
-
The tests cover plan generation for OpenClaw
|
|
159
|
+
The tests cover plan generation, config merging for OpenClaw/Hermes/cc-connect, and Hermes dynamic channel behavior without static `agentId` or `channelId` arguments.
|
|
152
160
|
|
|
153
161
|
## Capability Coverage
|
|
154
162
|
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,182 @@ import { homedir } from "os";
|
|
|
7
7
|
import { dirname, resolve } from "path";
|
|
8
8
|
import { fileURLToPath } from "url";
|
|
9
9
|
|
|
10
|
+
// src/config-writers.ts
|
|
11
|
+
import { parse as parseDotenv } from "dotenv";
|
|
12
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
13
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
14
|
+
var SHADOW_ENV_VALUES = {
|
|
15
|
+
SHADOW_ALLOW_ALL_USERS: "true",
|
|
16
|
+
SHADOW_HEARTBEAT_INTERVAL_SECONDS: "30",
|
|
17
|
+
SHADOW_SLASH_COMMANDS_JSON: "[]"
|
|
18
|
+
};
|
|
19
|
+
function isRecord(value) {
|
|
20
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
function asRecord(value) {
|
|
23
|
+
return isRecord(value) ? { ...value } : {};
|
|
24
|
+
}
|
|
25
|
+
function uniqueStrings(values, required) {
|
|
26
|
+
return [
|
|
27
|
+
.../* @__PURE__ */ new Set([...values.filter((value) => typeof value === "string"), required])
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
function ensureTrailingNewline(value) {
|
|
31
|
+
return value.endsWith("\n") ? value : `${value}
|
|
32
|
+
`;
|
|
33
|
+
}
|
|
34
|
+
function quoteEnv(value) {
|
|
35
|
+
return /^[A-Za-z0-9_./:@-]+$/.test(value) ? value : JSON.stringify(value);
|
|
36
|
+
}
|
|
37
|
+
function normalizeJsonRoot(existing, label) {
|
|
38
|
+
if (!existing.trim()) return {};
|
|
39
|
+
const parsed = JSON.parse(existing);
|
|
40
|
+
if (!isRecord(parsed)) {
|
|
41
|
+
throw new Error(`${label} config must be a JSON object`);
|
|
42
|
+
}
|
|
43
|
+
return { ...parsed };
|
|
44
|
+
}
|
|
45
|
+
function parseYamlRoot(existing, label) {
|
|
46
|
+
if (!existing.trim()) return {};
|
|
47
|
+
const parsed = parseYaml(existing);
|
|
48
|
+
if (parsed == null) return {};
|
|
49
|
+
if (!isRecord(parsed)) {
|
|
50
|
+
throw new Error(`${label} config must be a YAML object`);
|
|
51
|
+
}
|
|
52
|
+
return { ...parsed };
|
|
53
|
+
}
|
|
54
|
+
function parseTomlRoot(existing, label) {
|
|
55
|
+
if (!existing.trim()) return {};
|
|
56
|
+
const parsed = parseToml(existing);
|
|
57
|
+
if (!isRecord(parsed)) {
|
|
58
|
+
throw new Error(`${label} config must be a TOML table`);
|
|
59
|
+
}
|
|
60
|
+
return parsed;
|
|
61
|
+
}
|
|
62
|
+
function mergeEnvContent(existing, values) {
|
|
63
|
+
parseDotenv(existing);
|
|
64
|
+
const updates = {
|
|
65
|
+
SHADOW_BASE_URL: values.serverUrl,
|
|
66
|
+
SHADOW_TOKEN: values.token,
|
|
67
|
+
...SHADOW_ENV_VALUES
|
|
68
|
+
};
|
|
69
|
+
const seen = /* @__PURE__ */ new Set();
|
|
70
|
+
const lines = existing.length > 0 ? existing.split(/\r?\n/) : [];
|
|
71
|
+
const next = [];
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
const match = line.match(/^(\s*(?:export\s+)?)((?:[A-Za-z_][A-Za-z0-9_]*))\s*=/);
|
|
74
|
+
const key = match?.[2];
|
|
75
|
+
if (!key || !(key in updates)) {
|
|
76
|
+
next.push(line);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (seen.has(key)) continue;
|
|
80
|
+
seen.add(key);
|
|
81
|
+
next.push(`${match[1] ?? ""}${key}=${quoteEnv(updates[key] ?? "")}`);
|
|
82
|
+
}
|
|
83
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
84
|
+
if (!seen.has(key)) next.push(`${key}=${quoteEnv(value)}`);
|
|
85
|
+
}
|
|
86
|
+
while (next.length > 0 && next[next.length - 1] === "") next.pop();
|
|
87
|
+
return ensureTrailingNewline(next.join("\n"));
|
|
88
|
+
}
|
|
89
|
+
function mergeOpenClawConfigContent(existing, values) {
|
|
90
|
+
const root = normalizeJsonRoot(existing, "OpenClaw");
|
|
91
|
+
const channels = asRecord(root.channels);
|
|
92
|
+
const legacyShadow = asRecord(channels["openclaw-shadowob"]);
|
|
93
|
+
const shadow = asRecord(channels.shadowob);
|
|
94
|
+
channels.shadowob = {
|
|
95
|
+
...legacyShadow,
|
|
96
|
+
...shadow,
|
|
97
|
+
token: values.token,
|
|
98
|
+
serverUrl: values.serverUrl
|
|
99
|
+
};
|
|
100
|
+
delete channels["openclaw-shadowob"];
|
|
101
|
+
root.channels = channels;
|
|
102
|
+
const plugins = asRecord(root.plugins);
|
|
103
|
+
plugins.enabled = plugins.enabled ?? true;
|
|
104
|
+
plugins.allow = uniqueStrings(
|
|
105
|
+
Array.isArray(plugins.allow) ? plugins.allow : [],
|
|
106
|
+
"openclaw-shadowob"
|
|
107
|
+
);
|
|
108
|
+
const entries = asRecord(plugins.entries);
|
|
109
|
+
entries["openclaw-shadowob"] = {
|
|
110
|
+
...asRecord(entries["openclaw-shadowob"]),
|
|
111
|
+
enabled: true
|
|
112
|
+
};
|
|
113
|
+
plugins.entries = entries;
|
|
114
|
+
root.plugins = plugins;
|
|
115
|
+
return ensureTrailingNewline(JSON.stringify(root, null, 2));
|
|
116
|
+
}
|
|
117
|
+
function mergeHermesConfigContent(existing, values) {
|
|
118
|
+
const root = parseYamlRoot(existing, "Hermes");
|
|
119
|
+
const plugins = asRecord(root.plugins);
|
|
120
|
+
plugins.enabled = uniqueStrings(Array.isArray(plugins.enabled) ? plugins.enabled : [], "shadowob");
|
|
121
|
+
root.plugins = plugins;
|
|
122
|
+
const platforms = asRecord(root.platforms);
|
|
123
|
+
const shadowob = asRecord(platforms.shadowob);
|
|
124
|
+
const extra = asRecord(shadowob.extra);
|
|
125
|
+
platforms.shadowob = {
|
|
126
|
+
...shadowob,
|
|
127
|
+
enabled: true,
|
|
128
|
+
token: values.token,
|
|
129
|
+
extra: {
|
|
130
|
+
mention_only: false,
|
|
131
|
+
rest_only: false,
|
|
132
|
+
catchup_minutes: 0,
|
|
133
|
+
download_media: true,
|
|
134
|
+
slash_commands: [],
|
|
135
|
+
...extra,
|
|
136
|
+
base_url: values.serverUrl
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
root.platforms = platforms;
|
|
140
|
+
return ensureTrailingNewline(stringifyYaml(root));
|
|
141
|
+
}
|
|
142
|
+
function asTomlTable(value) {
|
|
143
|
+
return isRecord(value) && !Array.isArray(value) ? { ...value } : {};
|
|
144
|
+
}
|
|
145
|
+
function tomlArray(value) {
|
|
146
|
+
if (!Array.isArray(value)) return [];
|
|
147
|
+
const tables = [];
|
|
148
|
+
for (const item of value) {
|
|
149
|
+
if (isRecord(item)) tables.push({ ...item });
|
|
150
|
+
}
|
|
151
|
+
return tables;
|
|
152
|
+
}
|
|
153
|
+
function mergeCcConnectConfigContent(existing, values) {
|
|
154
|
+
const root = parseTomlRoot(existing, "cc-connect");
|
|
155
|
+
const projects = tomlArray(root.projects);
|
|
156
|
+
let project = projects.find((item) => item.name === values.projectName) ?? projects.find((item) => item.work_dir === values.workDir);
|
|
157
|
+
if (!project) {
|
|
158
|
+
project = {};
|
|
159
|
+
projects.push(project);
|
|
160
|
+
}
|
|
161
|
+
project.name = values.projectName;
|
|
162
|
+
project.work_dir = values.workDir;
|
|
163
|
+
project.agent_type = values.agentType;
|
|
164
|
+
const platforms = tomlArray(project.platforms);
|
|
165
|
+
let shadowPlatform = platforms.find((item) => item.type === "shadowob");
|
|
166
|
+
if (!shadowPlatform) {
|
|
167
|
+
shadowPlatform = {};
|
|
168
|
+
platforms.push(shadowPlatform);
|
|
169
|
+
}
|
|
170
|
+
const options = asTomlTable(shadowPlatform.options);
|
|
171
|
+
shadowPlatform.type = "shadowob";
|
|
172
|
+
shadowPlatform.options = {
|
|
173
|
+
allow_from: "*",
|
|
174
|
+
listen_dms: true,
|
|
175
|
+
share_session_in_channel: false,
|
|
176
|
+
progress_style: "compact",
|
|
177
|
+
...options,
|
|
178
|
+
token: values.token,
|
|
179
|
+
server_url: values.serverUrl
|
|
180
|
+
};
|
|
181
|
+
project.platforms = platforms;
|
|
182
|
+
root.projects = projects;
|
|
183
|
+
return ensureTrailingNewline(stringifyToml(root));
|
|
184
|
+
}
|
|
185
|
+
|
|
10
186
|
// src/index.ts
|
|
11
187
|
var DEFAULT_SERVER_URL = "https://shadowob.com";
|
|
12
188
|
var DEFAULT_WORK_DIR = ".";
|
|
@@ -286,6 +462,7 @@ function usage() {
|
|
|
286
462
|
" shadowob-connector connect --target <openclaw|hermes|cc-connect> --server-url <url> --token <token>",
|
|
287
463
|
"",
|
|
288
464
|
"Options:",
|
|
465
|
+
" --openclaw-config <path> OpenClaw JSON config, default $OPENCLAW_CONFIG or ~/.shadowob/openclaw.json",
|
|
289
466
|
" --hermes-home <path> Hermes config directory, default $HERMES_HOME or ~/.hermes",
|
|
290
467
|
" --work-dir <path> cc-connect project work directory",
|
|
291
468
|
" --project-name <name> cc-connect project name",
|
|
@@ -316,6 +493,7 @@ function parseArgs(args) {
|
|
|
316
493
|
target,
|
|
317
494
|
serverUrl: readOption(optionArgs, "--server-url") ?? "https://shadowob.com",
|
|
318
495
|
token: readOption(optionArgs, "--token") ?? "",
|
|
496
|
+
openclawConfig: readOption(optionArgs, "--openclaw-config"),
|
|
319
497
|
hermesHome: readOption(optionArgs, "--hermes-home"),
|
|
320
498
|
workDir: readOption(optionArgs, "--work-dir"),
|
|
321
499
|
projectName: readOption(optionArgs, "--project-name"),
|
|
@@ -363,25 +541,19 @@ function writeFile(path, content, dryRun) {
|
|
|
363
541
|
writeFileSync(path, content.endsWith("\n") ? content : `${content}
|
|
364
542
|
`);
|
|
365
543
|
}
|
|
366
|
-
function upsertManagedBlock(path, name, content, dryRun) {
|
|
367
|
-
const begin = `# BEGIN ShadowOB ${name}`;
|
|
368
|
-
const end = `# END ShadowOB ${name}`;
|
|
369
|
-
const block = `${begin}
|
|
370
|
-
${content}
|
|
371
|
-
${end}`;
|
|
372
|
-
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
373
|
-
const pattern = new RegExp(
|
|
374
|
-
`${begin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${end.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`
|
|
375
|
-
);
|
|
376
|
-
const next = existing.match(pattern) ? existing.replace(pattern, block) : [existing.trimEnd(), block].filter(Boolean).join("\n\n");
|
|
377
|
-
writeFile(path, next, dryRun);
|
|
378
|
-
}
|
|
379
544
|
function packageRoot() {
|
|
380
545
|
return resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
381
546
|
}
|
|
382
547
|
function expandHome(value) {
|
|
383
548
|
return value.startsWith("~/") ? resolve(homedir(), value.slice(2)) : resolve(value);
|
|
384
549
|
}
|
|
550
|
+
function readExisting(path) {
|
|
551
|
+
return existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
552
|
+
}
|
|
553
|
+
function normalizeServerUrl2(value) {
|
|
554
|
+
const trimmed = value.trim() || "https://shadowob.com";
|
|
555
|
+
return trimmed.endsWith("/api") ? trimmed.slice(0, -4) : trimmed.replace(/\/$/, "");
|
|
556
|
+
}
|
|
385
557
|
function hermesPluginSource() {
|
|
386
558
|
const candidates = [
|
|
387
559
|
resolve(packageRoot(), "hermes-shadowob-plugin"),
|
|
@@ -393,9 +565,21 @@ function hermesPluginSource() {
|
|
|
393
565
|
}
|
|
394
566
|
function applyOpenClaw(options) {
|
|
395
567
|
const plan = createConnectorPlan(options);
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
568
|
+
const configPath = expandHome(
|
|
569
|
+
options.openclawConfig ?? process.env.OPENCLAW_CONFIG ?? "~/.shadowob/openclaw.json"
|
|
570
|
+
);
|
|
571
|
+
console.log("Applying: Install plugin");
|
|
572
|
+
runShell("openclaw plugins install @shadowob/openclaw-shadowob", options.dryRun);
|
|
573
|
+
console.log(`Applying: Merge OpenClaw config ${configPath}`);
|
|
574
|
+
const next = mergeOpenClawConfigContent(readExisting(configPath), {
|
|
575
|
+
token: options.token,
|
|
576
|
+
serverUrl: normalizeServerUrl2(options.serverUrl)
|
|
577
|
+
});
|
|
578
|
+
writeFile(configPath, next, options.dryRun);
|
|
579
|
+
const restart = plan.commands.find((step) => step.label === "Restart gateway");
|
|
580
|
+
if (restart) {
|
|
581
|
+
console.log(`Applying: ${restart.label}`);
|
|
582
|
+
runShell(restart.command, options.dryRun);
|
|
399
583
|
}
|
|
400
584
|
}
|
|
401
585
|
function applyHermes(options) {
|
|
@@ -404,23 +588,24 @@ function applyHermes(options) {
|
|
|
404
588
|
const pluginTarget = resolve(hermesDir, "plugins/shadowob");
|
|
405
589
|
const envPath = resolve(hermesDir, ".env");
|
|
406
590
|
const configPath = resolve(hermesDir, "config.yaml");
|
|
407
|
-
const generatedConfigPath = resolve(hermesDir, "config.shadowob.yaml");
|
|
408
591
|
const envBlock = plan.configBlocks.find((block) => block.label === "~/.hermes/.env");
|
|
409
|
-
|
|
410
|
-
if (!envBlock || !yamlBlock) throw new Error("Hermes plan is missing config blocks");
|
|
592
|
+
if (!envBlock) throw new Error("Hermes plan is missing config blocks");
|
|
411
593
|
if (options.dryRun) {
|
|
412
594
|
console.log(`[dry-run] copy ${hermesPluginSource()} -> ${pluginTarget}`);
|
|
413
595
|
} else {
|
|
414
596
|
mkdirSync(resolve(hermesDir, "plugins"), { recursive: true });
|
|
415
597
|
cpSync(hermesPluginSource(), pluginTarget, { recursive: true, force: true });
|
|
416
598
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
599
|
+
const nextEnv = options.force ? envBlock.content : mergeEnvContent(readExisting(envPath), {
|
|
600
|
+
token: options.token,
|
|
601
|
+
serverUrl: normalizeServerUrl2(options.serverUrl)
|
|
602
|
+
});
|
|
603
|
+
writeFile(envPath, nextEnv, options.dryRun);
|
|
604
|
+
const nextConfig = mergeHermesConfigContent(options.force ? "" : readExisting(configPath), {
|
|
605
|
+
token: options.token,
|
|
606
|
+
serverUrl: normalizeServerUrl2(options.serverUrl)
|
|
607
|
+
});
|
|
608
|
+
writeFile(configPath, nextConfig, options.dryRun);
|
|
424
609
|
if (options.install) {
|
|
425
610
|
runShell(
|
|
426
611
|
`python -m pip install -r "${resolve(pluginTarget, "requirements.txt")}"`,
|
|
@@ -437,13 +622,14 @@ function applyCcConnect(options) {
|
|
|
437
622
|
const configBlock = plan.configBlocks.find((block) => block.label === "~/.cc-connect/config.toml");
|
|
438
623
|
if (!configBlock) throw new Error("cc-connect plan is missing config block");
|
|
439
624
|
const configPath = resolve(homedir(), ".cc-connect/config.toml");
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
}
|
|
625
|
+
const nextConfig = options.force ? configBlock.content : mergeCcConnectConfigContent(readExisting(configPath), {
|
|
626
|
+
token: options.token,
|
|
627
|
+
serverUrl: normalizeServerUrl2(options.serverUrl),
|
|
628
|
+
projectName: options.projectName?.trim() || "shadow-buddy",
|
|
629
|
+
workDir: options.workDir?.trim() || ".",
|
|
630
|
+
agentType: options.agentType?.trim() || "codex"
|
|
631
|
+
});
|
|
632
|
+
writeFile(configPath, nextConfig, options.dryRun);
|
|
447
633
|
if (options.install) {
|
|
448
634
|
runShell("npm install -g cc-connect", options.dryRun);
|
|
449
635
|
}
|
|
@@ -150,8 +150,8 @@ This iteration fixes issues found during static review against the uploaded Shad
|
|
|
150
150
|
- Fixed REST polling shutdown condition so the polling loop stops when the adapter is disconnected.
|
|
151
151
|
- Fixed `env_enablement_fn` to return a flat seed dict, because Hermes merges all non-`home_channel` keys directly into `PlatformConfig.extra`.
|
|
152
152
|
- Changed env auto-enable and connector setup to require only Shadow endpoint/token; Buddy id and channel policy are now resolved dynamically from Shadow.
|
|
153
|
-
- Added dynamic handling for `channel:member-added
|
|
154
|
-
-
|
|
153
|
+
- Added OpenClaw-aligned dynamic handling for `channel:member-added`, `channel:member-removed`, `server:joined`, and `agent:policy-changed` events.
|
|
154
|
+
- Changed empty-channel startup from fatal to tolerant waiting. The adapter stays online, refreshes channel policy periodically, and uses the owner DM as the default home channel when available.
|
|
155
155
|
- Updated standalone media delivery to support local paths, `MEDIA:` refs, relative paths, Shadow private URLs and remote URLs through the SDK helper.
|
|
156
156
|
|
|
157
157
|
## Known limits in this first version
|
|
@@ -465,6 +465,13 @@ def _remote_listen_channel_entries(
|
|
|
465
465
|
return entries
|
|
466
466
|
|
|
467
467
|
|
|
468
|
+
def _owner_id_from_remote_config(remote_config: dict[str, Any] | None) -> str | None:
|
|
469
|
+
if not isinstance(remote_config, dict):
|
|
470
|
+
return None
|
|
471
|
+
owner_id = str(remote_config.get("ownerId") or remote_config.get("owner_id") or "").strip()
|
|
472
|
+
return owner_id or None
|
|
473
|
+
|
|
474
|
+
|
|
468
475
|
class ShadowOBAdapter(BasePlatformAdapter):
|
|
469
476
|
"""Hermes ``BasePlatformAdapter`` implementation for Shadow."""
|
|
470
477
|
|
|
@@ -479,6 +486,7 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
479
486
|
self.socket: ShadowSocketClient | None = None
|
|
480
487
|
self._poll_task: asyncio.Task | None = None
|
|
481
488
|
self._heartbeat_task: asyncio.Task | None = None
|
|
489
|
+
self._channel_refresh_task: asyncio.Task | None = None
|
|
482
490
|
self._channel_ids: list[str] = _channel_ids_from_config(config)
|
|
483
491
|
self._configured_channel_ids: set[str] = set(self._channel_ids)
|
|
484
492
|
self._remote_channel_ids: set[str] = set()
|
|
@@ -532,26 +540,26 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
532
540
|
await self.client.open()
|
|
533
541
|
await self._load_identity()
|
|
534
542
|
await self._register_slash_commands()
|
|
535
|
-
await self._start_heartbeat()
|
|
536
543
|
await self._resolve_channels()
|
|
537
544
|
if not self._channel_ids:
|
|
538
|
-
|
|
539
|
-
"
|
|
540
|
-
"
|
|
541
|
-
retryable=False,
|
|
545
|
+
logger.warning(
|
|
546
|
+
"[Shadow] No channels are available yet for this Buddy token. "
|
|
547
|
+
"Waiting for the Buddy to be added to a channel or DM.",
|
|
542
548
|
)
|
|
543
|
-
return False
|
|
544
549
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
else:
|
|
550
|
+
use_polling = self._rest_only
|
|
551
|
+
if not self._rest_only:
|
|
548
552
|
try:
|
|
549
553
|
await self._start_socket()
|
|
550
554
|
except Exception as exc:
|
|
551
555
|
logger.warning("[Shadow] Socket.IO connection failed, falling back to REST polling: %s", exc)
|
|
552
|
-
|
|
556
|
+
use_polling = True
|
|
553
557
|
|
|
554
558
|
self._mark_connected()
|
|
559
|
+
if use_polling:
|
|
560
|
+
await self._start_polling()
|
|
561
|
+
await self._start_heartbeat()
|
|
562
|
+
await self._start_channel_refresh()
|
|
555
563
|
logger.info("[Shadow] Connected to %s; channels=%s", self.base_url, ",".join(self._channel_ids))
|
|
556
564
|
return True
|
|
557
565
|
except Exception as exc:
|
|
@@ -573,6 +581,13 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
573
581
|
except asyncio.CancelledError:
|
|
574
582
|
pass
|
|
575
583
|
self._heartbeat_task = None
|
|
584
|
+
if self._channel_refresh_task is not None and not self._channel_refresh_task.done():
|
|
585
|
+
self._channel_refresh_task.cancel()
|
|
586
|
+
try:
|
|
587
|
+
await self._channel_refresh_task
|
|
588
|
+
except asyncio.CancelledError:
|
|
589
|
+
pass
|
|
590
|
+
self._channel_refresh_task = None
|
|
576
591
|
if self._poll_task is not None and not self._poll_task.done():
|
|
577
592
|
self._poll_task.cancel()
|
|
578
593
|
try:
|
|
@@ -909,19 +924,47 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
909
924
|
self._remote_channel_ids = new_remote_ids
|
|
910
925
|
logger.info("[Shadow] Refreshed remote config for agent %s; channels=%s", self._agent_id, len(new_remote_ids))
|
|
911
926
|
|
|
912
|
-
if sync_socket
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
927
|
+
if sync_socket:
|
|
928
|
+
await self._sync_socket_channels(old_channel_ids)
|
|
929
|
+
|
|
930
|
+
async def _sync_socket_channels(self, old_channel_ids: set[str]) -> None:
|
|
931
|
+
if self.socket is None:
|
|
932
|
+
return
|
|
933
|
+
next_channel_ids = set(self._channel_ids)
|
|
934
|
+
for channel_id in old_channel_ids - next_channel_ids:
|
|
935
|
+
try:
|
|
936
|
+
await self.socket.leave_channel(channel_id)
|
|
937
|
+
except Exception:
|
|
938
|
+
pass
|
|
939
|
+
for channel_id in next_channel_ids - old_channel_ids:
|
|
940
|
+
try:
|
|
941
|
+
ack = await self.socket.join_channel(channel_id)
|
|
942
|
+
logger.info("[Shadow] Joined channel %s after config refresh ack=%s", channel_id, ack)
|
|
943
|
+
except Exception as exc:
|
|
944
|
+
logger.warning("[Shadow] Failed to join refreshed channel %s: %s", channel_id, exc)
|
|
945
|
+
|
|
946
|
+
async def _ensure_owner_dm_home_channel(self) -> None:
|
|
947
|
+
if self.client is None:
|
|
948
|
+
return
|
|
949
|
+
owner_id = _owner_id_from_remote_config(self._remote_config)
|
|
950
|
+
if not owner_id or owner_id == self._bot_user_id:
|
|
951
|
+
return
|
|
952
|
+
try:
|
|
953
|
+
channel = await self.client.create_direct_channel(owner_id)
|
|
954
|
+
except Exception as exc:
|
|
955
|
+
logger.debug("[Shadow] Owner DM home channel is not available yet: %s", exc)
|
|
956
|
+
return
|
|
957
|
+
channel_id = str(channel.get("id") or "").strip()
|
|
958
|
+
if not channel_id:
|
|
959
|
+
return
|
|
960
|
+
self._channel_cache[channel_id] = {
|
|
961
|
+
**channel,
|
|
962
|
+
"kind": channel.get("kind") or channel.get("type") or "dm",
|
|
963
|
+
}
|
|
964
|
+
self._channel_policies.setdefault(channel_id, _default_policy_from_remote_config(self._remote_config))
|
|
965
|
+
if channel_id not in self._channel_ids:
|
|
966
|
+
self._channel_ids.append(channel_id)
|
|
967
|
+
logger.info("[Shadow] Using owner DM %s as the default home channel", channel_id)
|
|
925
968
|
|
|
926
969
|
async def _register_slash_commands(self) -> None:
|
|
927
970
|
if self.client is None or not self._agent_id or not self._slash_commands:
|
|
@@ -952,6 +995,26 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
952
995
|
except Exception as exc:
|
|
953
996
|
logger.debug("[Shadow] heartbeat failed for agent %s: %s", self._agent_id, exc)
|
|
954
997
|
|
|
998
|
+
async def _start_channel_refresh(self) -> None:
|
|
999
|
+
if self._channel_refresh_task is None or self._channel_refresh_task.done():
|
|
1000
|
+
self._channel_refresh_task = asyncio.create_task(
|
|
1001
|
+
self._channel_refresh_loop(),
|
|
1002
|
+
name="shadowob-channel-refresh",
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
async def _channel_refresh_loop(self) -> None:
|
|
1006
|
+
interval = max(10.0, min(60.0, self._heartbeat_interval))
|
|
1007
|
+
while self._running and not self.has_fatal_error:
|
|
1008
|
+
await asyncio.sleep(interval)
|
|
1009
|
+
try:
|
|
1010
|
+
await self._resolve_channels(sync_socket=True)
|
|
1011
|
+
if not self._channel_ids:
|
|
1012
|
+
logger.debug("[Shadow] Still waiting for a channel or owner DM")
|
|
1013
|
+
except asyncio.CancelledError:
|
|
1014
|
+
raise
|
|
1015
|
+
except Exception as exc:
|
|
1016
|
+
logger.debug("[Shadow] Channel refresh failed: %s", exc)
|
|
1017
|
+
|
|
955
1018
|
async def _send_slash_interactive_prompt(
|
|
956
1019
|
self,
|
|
957
1020
|
match: tuple[dict[str, Any], str, str],
|
|
@@ -986,9 +1049,10 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
986
1049
|
logger.info("[Shadow] Sent interactive prompt for slash command /%s", name)
|
|
987
1050
|
return True
|
|
988
1051
|
|
|
989
|
-
async def _resolve_channels(self) -> None:
|
|
1052
|
+
async def _resolve_channels(self, *, sync_socket: bool = False) -> None:
|
|
990
1053
|
if self.client is None:
|
|
991
1054
|
return
|
|
1055
|
+
old_channel_ids = set(self._channel_ids)
|
|
992
1056
|
if self._agent_id:
|
|
993
1057
|
try:
|
|
994
1058
|
await self._refresh_remote_config()
|
|
@@ -1044,6 +1108,8 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
1044
1108
|
except Exception as exc:
|
|
1045
1109
|
logger.debug("[Shadow] Failed to list direct channels: %s", exc)
|
|
1046
1110
|
|
|
1111
|
+
await self._ensure_owner_dm_home_channel()
|
|
1112
|
+
|
|
1047
1113
|
# Best-effort metadata cache for explicitly configured channels.
|
|
1048
1114
|
for channel_id in list(self._channel_ids):
|
|
1049
1115
|
if channel_id in self._channel_cache:
|
|
@@ -1053,6 +1119,9 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
1053
1119
|
except Exception:
|
|
1054
1120
|
self._channel_cache[channel_id] = {"id": channel_id, "name": channel_id, "kind": "channel"}
|
|
1055
1121
|
|
|
1122
|
+
if sync_socket:
|
|
1123
|
+
await self._sync_socket_channels(old_channel_ids)
|
|
1124
|
+
|
|
1056
1125
|
async def _start_socket(self) -> None:
|
|
1057
1126
|
self.socket = ShadowSocketClient(self.base_url, self.token, transports=self._transports, logger=logger)
|
|
1058
1127
|
self.socket.on("connect", self._on_socket_connect)
|
|
@@ -1070,13 +1139,27 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
1070
1139
|
self.socket.on("server:joined", self._on_server_joined)
|
|
1071
1140
|
self.socket.on("agent:policy-changed", self._on_agent_policy_changed)
|
|
1072
1141
|
await self.socket.connect()
|
|
1073
|
-
await self.
|
|
1074
|
-
for channel_id in self._channel_ids:
|
|
1075
|
-
ack = await self.socket.join_channel(channel_id)
|
|
1076
|
-
logger.info("[Shadow] Joined channel %s ack=%s", channel_id, ack)
|
|
1142
|
+
await self._join_current_socket_channels()
|
|
1077
1143
|
if self._catchup_minutes > 0:
|
|
1078
1144
|
await self._catchup_recent_messages()
|
|
1079
1145
|
|
|
1146
|
+
async def _join_current_socket_channels(self) -> None:
|
|
1147
|
+
if self.socket is None:
|
|
1148
|
+
return
|
|
1149
|
+
try:
|
|
1150
|
+
await self.socket.update_presence("online")
|
|
1151
|
+
except Exception as exc:
|
|
1152
|
+
logger.debug("[Shadow] Failed to update socket presence: %s", exc)
|
|
1153
|
+
if not self._channel_ids:
|
|
1154
|
+
logger.info("[Shadow] Socket connected with no channels yet; waiting for channel membership events")
|
|
1155
|
+
return
|
|
1156
|
+
for channel_id in list(self._channel_ids):
|
|
1157
|
+
try:
|
|
1158
|
+
ack = await self.socket.join_channel(channel_id)
|
|
1159
|
+
logger.info("[Shadow] Joined channel %s ack=%s", channel_id, ack)
|
|
1160
|
+
except Exception as exc:
|
|
1161
|
+
logger.warning("[Shadow] Failed to join channel %s: %s", channel_id, exc)
|
|
1162
|
+
|
|
1080
1163
|
async def _start_polling(self) -> None:
|
|
1081
1164
|
if self._catchup_minutes > 0:
|
|
1082
1165
|
await self._catchup_recent_messages()
|
|
@@ -1142,6 +1225,7 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
1142
1225
|
|
|
1143
1226
|
async def _on_socket_connect(self) -> None:
|
|
1144
1227
|
logger.info("[Shadow] Socket connected")
|
|
1228
|
+
await self._join_current_socket_channels()
|
|
1145
1229
|
|
|
1146
1230
|
async def _on_socket_disconnect(self, reason: str | None = None) -> None:
|
|
1147
1231
|
logger.info("[Shadow] Socket disconnected: %s", reason)
|
|
@@ -1163,6 +1247,11 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
1163
1247
|
channel_id = str(payload.get("channelId") or payload.get("channel_id") or "").strip()
|
|
1164
1248
|
if not channel_id:
|
|
1165
1249
|
return
|
|
1250
|
+
old_channel_ids = set(self._channel_ids)
|
|
1251
|
+
try:
|
|
1252
|
+
await self._resolve_channels(sync_socket=False)
|
|
1253
|
+
except Exception as exc:
|
|
1254
|
+
logger.warning("[Shadow] Failed to refresh config after channel member add: %s", exc)
|
|
1166
1255
|
if channel_id not in self._channel_ids:
|
|
1167
1256
|
self._channel_ids.append(channel_id)
|
|
1168
1257
|
if self.client is not None:
|
|
@@ -1170,10 +1259,13 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
1170
1259
|
self._channel_cache[channel_id] = await self.client.get_channel(channel_id)
|
|
1171
1260
|
except Exception:
|
|
1172
1261
|
self._channel_cache[channel_id] = {"id": channel_id, "name": channel_id, "kind": "channel"}
|
|
1262
|
+
self._channel_policies.setdefault(
|
|
1263
|
+
channel_id,
|
|
1264
|
+
_default_policy_from_remote_config(self._remote_config),
|
|
1265
|
+
)
|
|
1173
1266
|
if self.socket is not None:
|
|
1174
1267
|
try:
|
|
1175
|
-
|
|
1176
|
-
logger.info("[Shadow] Joined newly added channel %s ack=%s", channel_id, ack)
|
|
1268
|
+
await self._sync_socket_channels(old_channel_ids)
|
|
1177
1269
|
except Exception as exc:
|
|
1178
1270
|
logger.warning("[Shadow] Failed to join newly added channel %s: %s", channel_id, exc)
|
|
1179
1271
|
if self._catchup_minutes > 0:
|
|
@@ -1200,7 +1292,7 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
1200
1292
|
if payload_agent_id and self._agent_id and payload_agent_id != self._agent_id:
|
|
1201
1293
|
return
|
|
1202
1294
|
try:
|
|
1203
|
-
await self.
|
|
1295
|
+
await self._resolve_channels(sync_socket=True)
|
|
1204
1296
|
except Exception as exc:
|
|
1205
1297
|
logger.warning("[Shadow] Failed to refresh remote config after server join: %s", exc)
|
|
1206
1298
|
|
|
@@ -1209,7 +1301,7 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
1209
1301
|
if payload_agent_id and self._agent_id and payload_agent_id != self._agent_id:
|
|
1210
1302
|
return
|
|
1211
1303
|
try:
|
|
1212
|
-
await self.
|
|
1304
|
+
await self._resolve_channels(sync_socket=True)
|
|
1213
1305
|
except Exception as exc:
|
|
1214
1306
|
logger.warning("[Shadow] Failed to refresh remote config after policy change: %s", exc)
|
|
1215
1307
|
|
|
@@ -231,6 +231,13 @@ class ShadowAsyncClient:
|
|
|
231
231
|
async def list_direct_channels(self) -> list[JsonDict]:
|
|
232
232
|
return await self.request("GET", "/api/channels/dm")
|
|
233
233
|
|
|
234
|
+
async def create_direct_channel(self, user_id: str) -> JsonDict:
|
|
235
|
+
return await self.request(
|
|
236
|
+
"POST",
|
|
237
|
+
"/api/channels/dm",
|
|
238
|
+
json_body={"userId": str(user_id)},
|
|
239
|
+
)
|
|
240
|
+
|
|
234
241
|
async def get_channel(self, channel_id: str) -> JsonDict:
|
|
235
242
|
return await self.request("GET", f"/api/channels/{quote(str(channel_id), safe='')}")
|
|
236
243
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
import sys
|
|
3
|
+
import asyncio
|
|
3
4
|
|
|
4
5
|
ROOT = Path(__file__).resolve().parents[1]
|
|
5
6
|
if str(ROOT) not in sys.path:
|
|
@@ -83,6 +84,107 @@ def test_remote_config_entries_filter_listen_policy():
|
|
|
83
84
|
assert entries[0][1]['serverId'] == 'server-1'
|
|
84
85
|
|
|
85
86
|
|
|
87
|
+
def test_resolve_channels_creates_owner_dm_home_channel_when_empty():
|
|
88
|
+
class FakeClient:
|
|
89
|
+
async def get_agent_config(self, agent_id):
|
|
90
|
+
assert agent_id == 'agent-1'
|
|
91
|
+
return {
|
|
92
|
+
'agentId': 'agent-1',
|
|
93
|
+
'botUserId': 'bot-1',
|
|
94
|
+
'ownerId': 'owner-1',
|
|
95
|
+
'servers': [],
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async def create_direct_channel(self, user_id):
|
|
99
|
+
assert user_id == 'owner-1'
|
|
100
|
+
return {'id': 'dm-owner', 'kind': 'dm', 'name': 'Owner DM'}
|
|
101
|
+
|
|
102
|
+
class FakeSocket:
|
|
103
|
+
def __init__(self):
|
|
104
|
+
self.joined = []
|
|
105
|
+
|
|
106
|
+
async def join_channel(self, channel_id):
|
|
107
|
+
self.joined.append(channel_id)
|
|
108
|
+
return {'ok': True}
|
|
109
|
+
|
|
110
|
+
instance = adapter.ShadowOBAdapter.__new__(adapter.ShadowOBAdapter)
|
|
111
|
+
instance.client = FakeClient()
|
|
112
|
+
instance.socket = FakeSocket()
|
|
113
|
+
instance._agent_id = 'agent-1'
|
|
114
|
+
instance._bot_user_id = 'bot-1'
|
|
115
|
+
instance._slash_commands = []
|
|
116
|
+
instance._channel_ids = []
|
|
117
|
+
instance._configured_channel_ids = set()
|
|
118
|
+
instance._remote_channel_ids = set()
|
|
119
|
+
instance._channel_policies = {}
|
|
120
|
+
instance._remote_config = None
|
|
121
|
+
instance._channel_cache = {}
|
|
122
|
+
instance._server_ids = []
|
|
123
|
+
instance._auto_discover = False
|
|
124
|
+
|
|
125
|
+
asyncio.run(instance._resolve_channels(sync_socket=True))
|
|
126
|
+
|
|
127
|
+
assert instance._channel_ids == ['dm-owner']
|
|
128
|
+
assert instance._channel_cache['dm-owner']['kind'] == 'dm'
|
|
129
|
+
assert instance.socket.joined == ['dm-owner']
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_member_added_refreshes_remote_config_and_joins_channel():
|
|
133
|
+
class FakeClient:
|
|
134
|
+
async def get_agent_config(self, agent_id):
|
|
135
|
+
assert agent_id == 'agent-1'
|
|
136
|
+
return {
|
|
137
|
+
'agentId': 'agent-1',
|
|
138
|
+
'botUserId': 'bot-1',
|
|
139
|
+
'servers': [
|
|
140
|
+
{
|
|
141
|
+
'id': 'server-1',
|
|
142
|
+
'name': 'Server',
|
|
143
|
+
'channels': [
|
|
144
|
+
{
|
|
145
|
+
'id': 'channel-1',
|
|
146
|
+
'name': 'general',
|
|
147
|
+
'policy': {'listen': True, 'reply': True},
|
|
148
|
+
}
|
|
149
|
+
],
|
|
150
|
+
}
|
|
151
|
+
],
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async def get_channel(self, channel_id):
|
|
155
|
+
return {'id': channel_id, 'kind': 'channel', 'name': 'general'}
|
|
156
|
+
|
|
157
|
+
class FakeSocket:
|
|
158
|
+
def __init__(self):
|
|
159
|
+
self.joined = []
|
|
160
|
+
|
|
161
|
+
async def join_channel(self, channel_id):
|
|
162
|
+
self.joined.append(channel_id)
|
|
163
|
+
return {'ok': True}
|
|
164
|
+
|
|
165
|
+
instance = adapter.ShadowOBAdapter.__new__(adapter.ShadowOBAdapter)
|
|
166
|
+
instance.client = FakeClient()
|
|
167
|
+
instance.socket = FakeSocket()
|
|
168
|
+
instance._agent_id = 'agent-1'
|
|
169
|
+
instance._bot_user_id = 'bot-1'
|
|
170
|
+
instance._slash_commands = []
|
|
171
|
+
instance._channel_ids = []
|
|
172
|
+
instance._configured_channel_ids = set()
|
|
173
|
+
instance._remote_channel_ids = set()
|
|
174
|
+
instance._channel_policies = {}
|
|
175
|
+
instance._remote_config = None
|
|
176
|
+
instance._channel_cache = {}
|
|
177
|
+
instance._server_ids = []
|
|
178
|
+
instance._auto_discover = False
|
|
179
|
+
instance._catchup_minutes = 0
|
|
180
|
+
|
|
181
|
+
asyncio.run(instance._on_channel_member_added({'channelId': 'channel-1'}))
|
|
182
|
+
|
|
183
|
+
assert instance._channel_ids == ['channel-1']
|
|
184
|
+
assert instance._channel_policies['channel-1']['reply'] is True
|
|
185
|
+
assert instance.socket.joined == ['channel-1']
|
|
186
|
+
|
|
187
|
+
|
|
86
188
|
def test_slash_command_prompt_and_interactive_block():
|
|
87
189
|
commands = [
|
|
88
190
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shadowob/connector",
|
|
3
|
-
"version": "1.1.3-dev.
|
|
3
|
+
"version": "1.1.3-dev.281",
|
|
4
4
|
"description": "Shadow connector helpers for OpenClaw, Hermes Agent, and cc-connect",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -47,6 +47,11 @@
|
|
|
47
47
|
"publishConfig": {
|
|
48
48
|
"access": "public"
|
|
49
49
|
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"dotenv": "^17.4.2",
|
|
52
|
+
"smol-toml": "^1.6.1",
|
|
53
|
+
"yaml": "^2.8.4"
|
|
54
|
+
},
|
|
50
55
|
"scripts": {
|
|
51
56
|
"build": "tsup",
|
|
52
57
|
"dev": "tsup --watch",
|