openclaw-adspirer 0.2.0 → 0.3.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/LICENSE +5 -18
- package/README.md +2 -1
- package/package.json +12 -6
- package/src/commands/cli.ts +42 -1
- package/src/config.ts +2 -1
- package/src/index.ts +21 -7
- package/src/tool-registry.ts +1 -1
- package/tests/auth.test.ts +0 -75
- package/tests/client.test.ts +0 -131
- package/tests/config.test.ts +0 -115
- package/tests/errors.test.ts +0 -33
- package/tests/helpers/mock-fetch.ts +0 -12
- package/tests/tool-groups.test.ts +0 -41
- package/tests/tool-registry.test.ts +0 -118
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -9
package/LICENSE
CHANGED
|
@@ -1,21 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
Copyright (c) 2026 Adspirer, Inc. All rights reserved.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This software is proprietary and confidential. Unauthorized copying, modification,
|
|
4
|
+
distribution, or use of this software, via any medium, is strictly prohibited.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
6
|
+
This software is provided "as is" without warranty of any kind, express or implied.
|
|
11
7
|
|
|
12
|
-
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
8
|
+
For licensing inquiries, contact: support@adspirer.com
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ OpenClaw plugin for [Adspirer](https://adspirer.com) — manage Google, Meta, Ti
|
|
|
13
13
|
## Installation
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
openclaw
|
|
16
|
+
openclaw plugins install openclaw-adspirer
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
## Setup
|
|
@@ -48,6 +48,7 @@ openclaw adspirer status
|
|
|
48
48
|
| `openclaw adspirer status` | Show auth status, connections, tool count |
|
|
49
49
|
| `openclaw adspirer accounts` | List connected ad accounts |
|
|
50
50
|
| `openclaw adspirer tools` | List registered tools by platform |
|
|
51
|
+
| `openclaw adspirer connect` | Open Adspirer to connect ad platforms |
|
|
51
52
|
|
|
52
53
|
## Tool Groups
|
|
53
54
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-adspirer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw plugin for Adspirer — manage Google, Meta, TikTok & LinkedIn ads via natural language",
|
|
6
|
-
"license": "
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
+
"files": [
|
|
8
|
+
"src/",
|
|
9
|
+
"types/",
|
|
10
|
+
"openclaw.plugin.json",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE",
|
|
13
|
+
"CHANGELOG.md"
|
|
14
|
+
],
|
|
7
15
|
"scripts": {
|
|
8
16
|
"check-types": "tsc --noEmit",
|
|
9
|
-
"test": "
|
|
17
|
+
"test": "tsx --test tests/*.test.ts",
|
|
10
18
|
"build": "tsc"
|
|
11
19
|
},
|
|
12
20
|
"peerDependencies": {
|
|
@@ -18,10 +26,8 @@
|
|
|
18
26
|
]
|
|
19
27
|
},
|
|
20
28
|
"devDependencies": {
|
|
21
|
-
"@esbuild/linux-x64": "^0.27.2",
|
|
22
29
|
"@types/node": "^22.0.0",
|
|
23
30
|
"tsx": "^4.21.0",
|
|
24
|
-
"typescript": "^5.9.3"
|
|
25
|
-
"vitest": "^3.0.0"
|
|
31
|
+
"typescript": "^5.9.3"
|
|
26
32
|
}
|
|
27
33
|
}
|
package/src/commands/cli.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
|
|
18
18
|
const PLUGIN_ID = "openclaw-adspirer";
|
|
19
|
+
const PLUGIN_VERSION = "0.3.0";
|
|
19
20
|
|
|
20
21
|
function getConfigPath(): string {
|
|
21
22
|
return join(process.env.HOME || process.env.USERPROFILE || ".", ".openclaw", "openclaw.json");
|
|
@@ -85,6 +86,13 @@ export function registerCommands(
|
|
|
85
86
|
.action(async () => {
|
|
86
87
|
await handleTools(config);
|
|
87
88
|
});
|
|
89
|
+
|
|
90
|
+
cmd
|
|
91
|
+
.command("connect")
|
|
92
|
+
.description("Open Adspirer to connect ad platforms")
|
|
93
|
+
.action(async () => {
|
|
94
|
+
await handleConnect();
|
|
95
|
+
});
|
|
88
96
|
},
|
|
89
97
|
{ commands: ["adspirer"] },
|
|
90
98
|
);
|
|
@@ -121,6 +129,7 @@ async function handleLogin(api: OpenClawPluginApi, config: AdspirerConfig): Prom
|
|
|
121
129
|
|
|
122
130
|
// Persist client_id so we don't re-register every login
|
|
123
131
|
patchPluginConfig({ oauthClientId: clientId });
|
|
132
|
+
config.oauthClientId = clientId;
|
|
124
133
|
} catch (regErr: any) {
|
|
125
134
|
callbackServer.close();
|
|
126
135
|
throw new Error(`Client registration failed: ${regErr.message}`);
|
|
@@ -159,7 +168,7 @@ async function handleLogin(api: OpenClawPluginApi, config: AdspirerConfig): Prom
|
|
|
159
168
|
// Browser open failed, user can use the URL
|
|
160
169
|
}
|
|
161
170
|
|
|
162
|
-
console.log("⏳ Waiting for authentication (
|
|
171
|
+
console.log("⏳ Waiting for authentication (120s timeout)...\n");
|
|
163
172
|
|
|
164
173
|
const { code, state: returnedState } = await callbackServer.waitForCallback();
|
|
165
174
|
callbackServer.close();
|
|
@@ -185,6 +194,7 @@ async function handleLogin(api: OpenClawPluginApi, config: AdspirerConfig): Prom
|
|
|
185
194
|
});
|
|
186
195
|
|
|
187
196
|
console.log(`✅ Connected to Adspirer! Restart the gateway to load all tools.`);
|
|
197
|
+
console.log(`\n 📌 Connect your ad platforms at https://www.adspirer.com`);
|
|
188
198
|
} catch (err: any) {
|
|
189
199
|
console.log(`❌ Login failed: ${err.message}`);
|
|
190
200
|
}
|
|
@@ -200,6 +210,8 @@ async function handleLogout(api: OpenClawPluginApi): Promise<void> {
|
|
|
200
210
|
}
|
|
201
211
|
|
|
202
212
|
async function handleStatus(client: AdspirerMCPClient, config: AdspirerConfig): Promise<void> {
|
|
213
|
+
console.log(`\n📦 openclaw-adspirer v${PLUGIN_VERSION}\n`);
|
|
214
|
+
|
|
203
215
|
if (config.testMode) {
|
|
204
216
|
console.log("🧪 Test Mode Active");
|
|
205
217
|
console.log(` Server: ${config.serverUrl}`);
|
|
@@ -230,6 +242,16 @@ async function handleStatus(client: AdspirerMCPClient, config: AdspirerConfig):
|
|
|
230
242
|
console.log("\n⚠️ Could not fetch connection status.");
|
|
231
243
|
}
|
|
232
244
|
|
|
245
|
+
// Try to get usage/quota status
|
|
246
|
+
try {
|
|
247
|
+
const result = await client.callTool("get_usage_status", {});
|
|
248
|
+
if (result.content?.[0]?.text) {
|
|
249
|
+
console.log(`\n📊 Plan & Usage:\n${result.content[0].text}`);
|
|
250
|
+
}
|
|
251
|
+
} catch {
|
|
252
|
+
// Quota info not available
|
|
253
|
+
}
|
|
254
|
+
|
|
233
255
|
// Show tool count
|
|
234
256
|
const tools = config.enabledTools.length > 0
|
|
235
257
|
? config.enabledTools
|
|
@@ -276,3 +298,22 @@ async function handleTools(config: AdspirerConfig): Promise<void> {
|
|
|
276
298
|
|
|
277
299
|
console.log(`\nTotal enabled: ${total}`);
|
|
278
300
|
}
|
|
301
|
+
|
|
302
|
+
async function handleConnect(): Promise<void> {
|
|
303
|
+
const url = "https://www.adspirer.com";
|
|
304
|
+
console.log("🔗 Connect your ad platforms at Adspirer:\n");
|
|
305
|
+
console.log(` ${url}\n`);
|
|
306
|
+
console.log(" Supported platforms:");
|
|
307
|
+
console.log(" • Google Ads");
|
|
308
|
+
console.log(" • Meta Ads");
|
|
309
|
+
console.log(" • LinkedIn Ads");
|
|
310
|
+
console.log(" • TikTok Ads");
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
const { execFile } = await import("node:child_process");
|
|
314
|
+
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
315
|
+
execFile(openCmd, [url], (err) => { /* ignore browser open failures */ });
|
|
316
|
+
} catch {
|
|
317
|
+
// Browser open failed, user can visit the URL manually
|
|
318
|
+
}
|
|
319
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -41,7 +41,8 @@ export function parseConfig(raw: unknown): AdspirerConfig {
|
|
|
41
41
|
export function isAuthenticated(config: AdspirerConfig): boolean {
|
|
42
42
|
if (config.testMode) return true;
|
|
43
43
|
if (!config.accessToken) return false;
|
|
44
|
-
if
|
|
44
|
+
// Allow expired tokens through if we have a refresh token — getToken() will refresh
|
|
45
|
+
if (config.tokenExpiresAt && config.tokenExpiresAt < Date.now() && !config.refreshToken) return false;
|
|
45
46
|
return true;
|
|
46
47
|
}
|
|
47
48
|
|
package/src/index.ts
CHANGED
|
@@ -71,6 +71,18 @@ export default {
|
|
|
71
71
|
api.logger,
|
|
72
72
|
);
|
|
73
73
|
|
|
74
|
+
// If token is expired but we have a refresh token, refresh before registering tools
|
|
75
|
+
if (isAuthenticated(config) && config.tokenExpiresAt && config.tokenExpiresAt < Date.now()) {
|
|
76
|
+
try {
|
|
77
|
+
api.logger.info("adspirer: token expired, refreshing...");
|
|
78
|
+
const tokens = await doRefresh();
|
|
79
|
+
saveTokens(api, config, tokens);
|
|
80
|
+
api.logger.info("adspirer: token refreshed successfully");
|
|
81
|
+
} catch (err) {
|
|
82
|
+
api.logger.warn(`adspirer: token refresh failed: ${err}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
74
86
|
// Register tools
|
|
75
87
|
if (isAuthenticated(config)) {
|
|
76
88
|
try {
|
|
@@ -100,13 +112,15 @@ function saveTokens(api: OpenClawPluginApi, config: AdspirerConfig, tokens: Toke
|
|
|
100
112
|
const path = require("node:path");
|
|
101
113
|
const configPath = path.join(process.env.HOME || ".", ".openclaw", "openclaw.json");
|
|
102
114
|
const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
103
|
-
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
115
|
+
if (!raw.plugins) raw.plugins = {};
|
|
116
|
+
if (!raw.plugins.entries) raw.plugins.entries = {};
|
|
117
|
+
if (!raw.plugins.entries["openclaw-adspirer"]) raw.plugins.entries["openclaw-adspirer"] = { enabled: true, config: {} };
|
|
118
|
+
if (!raw.plugins.entries["openclaw-adspirer"].config) raw.plugins.entries["openclaw-adspirer"].config = {};
|
|
119
|
+
const entry = raw.plugins.entries["openclaw-adspirer"];
|
|
120
|
+
entry.config.accessToken = tokens.accessToken;
|
|
121
|
+
entry.config.refreshToken = tokens.refreshToken;
|
|
122
|
+
entry.config.tokenExpiresAt = tokens.expiresAt;
|
|
123
|
+
fs.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n");
|
|
110
124
|
} catch {
|
|
111
125
|
api.logger.warn("Could not persist tokens to config file");
|
|
112
126
|
}
|
package/src/tool-registry.ts
CHANGED
|
@@ -101,7 +101,7 @@ export function registerConnectTool(api: OpenClawPluginApi): void {
|
|
|
101
101
|
execute: async () => ({
|
|
102
102
|
content: [{
|
|
103
103
|
type: "text" as const,
|
|
104
|
-
text: "🔑 You're not connected to Adspirer yet.\n\nRun `openclaw adspirer login` in your terminal to authenticate.\n\nOnce connected, you'll have access to 103 ad management tools across Google, Meta, TikTok, and LinkedIn.",
|
|
104
|
+
text: "🔑 You're not connected to Adspirer yet.\n\nRun `openclaw adspirer login` in your terminal to authenticate.\n\nThen connect your ad platforms at https://www.adspirer.com\n\nOnce connected, you'll have access to 103 ad management tools across Google, Meta, TikTok, and LinkedIn.",
|
|
105
105
|
}],
|
|
106
106
|
}),
|
|
107
107
|
},
|
package/tests/auth.test.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { test, describe } from "node:test";
|
|
2
|
-
import { strict as assert } from "node:assert";
|
|
3
|
-
import { generateCodeVerifier, generateCodeChallenge, buildAuthUrl } from "../src/auth.ts";
|
|
4
|
-
|
|
5
|
-
describe("generateCodeVerifier", () => {
|
|
6
|
-
test("length 43-128", () => {
|
|
7
|
-
const v = generateCodeVerifier();
|
|
8
|
-
assert.ok(v.length >= 43, `verifier too short: ${v.length}`);
|
|
9
|
-
assert.ok(v.length <= 128, `verifier too long: ${v.length}`);
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
test("base64url charset only", () => {
|
|
13
|
-
const v = generateCodeVerifier();
|
|
14
|
-
assert.ok(/^[A-Za-z0-9\-_]+$/.test(v), `invalid chars in verifier: ${v}`);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test("unique each time", () => {
|
|
18
|
-
const a = generateCodeVerifier();
|
|
19
|
-
const b = generateCodeVerifier();
|
|
20
|
-
assert.notEqual(a, b);
|
|
21
|
-
});
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
describe("generateCodeChallenge", () => {
|
|
25
|
-
test("produces base64url string", async () => {
|
|
26
|
-
const v = generateCodeVerifier();
|
|
27
|
-
const c = await generateCodeChallenge(v);
|
|
28
|
-
assert.ok(/^[A-Za-z0-9\-_]+$/.test(c));
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("known vector: dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", async () => {
|
|
32
|
-
// RFC 7636 Appendix B test vector
|
|
33
|
-
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
|
|
34
|
-
const challenge = await generateCodeChallenge(verifier);
|
|
35
|
-
assert.equal(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("deterministic", async () => {
|
|
39
|
-
const v = "test_verifier_string";
|
|
40
|
-
const a = await generateCodeChallenge(v);
|
|
41
|
-
const b = await generateCodeChallenge(v);
|
|
42
|
-
assert.equal(a, b);
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
describe("buildAuthUrl", () => {
|
|
47
|
-
test("contains all params using discovered authorize endpoint", () => {
|
|
48
|
-
const url = buildAuthUrl({
|
|
49
|
-
authorizeEndpoint: "https://mcp.adspirer.com/oauth/authorize",
|
|
50
|
-
clientId: "openclaw-abc123",
|
|
51
|
-
codeChallenge: "abc123",
|
|
52
|
-
redirectUri: "http://localhost:52847/callback",
|
|
53
|
-
scope: "ads:read ads:write accounts:read",
|
|
54
|
-
state: "state123",
|
|
55
|
-
});
|
|
56
|
-
assert.ok(url.includes("client_id=openclaw-abc123"), "missing client_id");
|
|
57
|
-
assert.ok(url.includes("redirect_uri=http%3A%2F%2Flocalhost%3A52847%2Fcallback"), "missing redirect_uri");
|
|
58
|
-
assert.ok(url.includes("response_type=code"), "missing response_type");
|
|
59
|
-
assert.ok(url.includes("code_challenge=abc123"), "missing code_challenge");
|
|
60
|
-
assert.ok(url.includes("code_challenge_method=S256"), "missing code_challenge_method");
|
|
61
|
-
assert.ok(url.includes("state=state123"), "missing state");
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("uses full authorize endpoint URL (not relative path)", () => {
|
|
65
|
-
const url = buildAuthUrl({
|
|
66
|
-
authorizeEndpoint: "https://auth.example.com/oauth/authorize",
|
|
67
|
-
clientId: "test-client",
|
|
68
|
-
codeChallenge: "challenge",
|
|
69
|
-
redirectUri: "http://localhost:3000/callback",
|
|
70
|
-
scope: "read",
|
|
71
|
-
state: "s",
|
|
72
|
-
});
|
|
73
|
-
assert.ok(url.startsWith("https://auth.example.com/oauth/authorize?"), `unexpected URL prefix: ${url}`);
|
|
74
|
-
});
|
|
75
|
-
});
|
package/tests/client.test.ts
DELETED
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import { test, describe, afterEach } from "node:test";
|
|
2
|
-
import { strict as assert } from "node:assert";
|
|
3
|
-
import { AdspirerMCPClient } from "../src/client.ts";
|
|
4
|
-
import { parseConfig } from "../src/config.ts";
|
|
5
|
-
import { installMockFetch, jsonResponse } from "./helpers/mock-fetch.ts";
|
|
6
|
-
|
|
7
|
-
function makeClient(overrides: Record<string, unknown> = {}, fetchHandler?: (url: string | URL | Request, init?: RequestInit) => Promise<Response>) {
|
|
8
|
-
const config = parseConfig({
|
|
9
|
-
serverUrl: "https://test.example.com",
|
|
10
|
-
testMode: true,
|
|
11
|
-
...overrides,
|
|
12
|
-
});
|
|
13
|
-
const logger = {
|
|
14
|
-
info: () => {},
|
|
15
|
-
warn: () => {},
|
|
16
|
-
error: () => {},
|
|
17
|
-
debug: () => {},
|
|
18
|
-
};
|
|
19
|
-
const client = new AdspirerMCPClient(
|
|
20
|
-
config,
|
|
21
|
-
async () => "test-token",
|
|
22
|
-
() => {},
|
|
23
|
-
async () => ({ accessToken: "new", refreshToken: "new", expiresIn: 3600, expiresAt: Date.now() + 3600000 }),
|
|
24
|
-
logger,
|
|
25
|
-
);
|
|
26
|
-
return client;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
describe("AdspirerMCPClient", () => {
|
|
30
|
-
let restore: (() => void) | null = null;
|
|
31
|
-
afterEach(() => { if (restore) { restore(); restore = null; } });
|
|
32
|
-
|
|
33
|
-
test("callTool happy path", async () => {
|
|
34
|
-
const client = makeClient();
|
|
35
|
-
restore = installMockFetch(async () =>
|
|
36
|
-
jsonResponse({
|
|
37
|
-
jsonrpc: "2.0",
|
|
38
|
-
result: { content: [{ type: "text", text: "hello" }] },
|
|
39
|
-
id: "1",
|
|
40
|
-
}),
|
|
41
|
-
);
|
|
42
|
-
const result = await client.callTool("echo_test", { message: "hi" });
|
|
43
|
-
assert.equal(result.content[0].text, "hello");
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test("callTool JSON-RPC error", async () => {
|
|
47
|
-
const client = makeClient();
|
|
48
|
-
restore = installMockFetch(async () =>
|
|
49
|
-
jsonResponse({
|
|
50
|
-
jsonrpc: "2.0",
|
|
51
|
-
error: { code: -32602, message: "bad param" },
|
|
52
|
-
id: "1",
|
|
53
|
-
}),
|
|
54
|
-
);
|
|
55
|
-
const result = await client.callTool("echo_test", {});
|
|
56
|
-
assert.ok(result.content[0].text.includes("bad param"));
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("callTool wraps non-content result", async () => {
|
|
60
|
-
const client = makeClient();
|
|
61
|
-
restore = installMockFetch(async () =>
|
|
62
|
-
jsonResponse({
|
|
63
|
-
jsonrpc: "2.0",
|
|
64
|
-
result: { data: "raw" },
|
|
65
|
-
id: "1",
|
|
66
|
-
}),
|
|
67
|
-
);
|
|
68
|
-
const result = await client.callTool("echo_test", {});
|
|
69
|
-
assert.ok(result.content[0].text.includes("raw"));
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("callTool 401 → refresh → retry (production mode)", async () => {
|
|
73
|
-
let callCount = 0;
|
|
74
|
-
const config = parseConfig({
|
|
75
|
-
serverUrl: "https://test.example.com",
|
|
76
|
-
testMode: false,
|
|
77
|
-
accessToken: "old-token",
|
|
78
|
-
refreshToken: "ref-token",
|
|
79
|
-
tokenExpiresAt: Date.now() + 3600000,
|
|
80
|
-
});
|
|
81
|
-
const logger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
|
|
82
|
-
let refreshCalled = false;
|
|
83
|
-
const client = new AdspirerMCPClient(
|
|
84
|
-
config,
|
|
85
|
-
async () => "old-token",
|
|
86
|
-
() => {},
|
|
87
|
-
async () => { refreshCalled = true; return { accessToken: "new", refreshToken: "new", expiresIn: 3600, expiresAt: Date.now() + 3600000 }; },
|
|
88
|
-
logger,
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
restore = installMockFetch(async () => {
|
|
92
|
-
callCount++;
|
|
93
|
-
if (callCount === 1) return new Response("Unauthorized", { status: 401 });
|
|
94
|
-
return jsonResponse({ jsonrpc: "2.0", result: { content: [{ type: "text", text: "ok" }] }, id: "1" });
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
const result = await client.callTool("echo_test", {});
|
|
98
|
-
assert.equal(refreshCalled, true);
|
|
99
|
-
assert.equal(result.content[0].text, "ok");
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("callTool 402 → upgrade message", async () => {
|
|
103
|
-
const client = makeClient({ testMode: false, accessToken: "tok", tokenExpiresAt: Date.now() + 3600000 });
|
|
104
|
-
restore = installMockFetch(async () => new Response("Payment Required", { status: 402 }));
|
|
105
|
-
const result = await client.callTool("premium_tool", {});
|
|
106
|
-
assert.ok(result.content[0].text.includes("Upgrade"));
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test("listTools happy path", async () => {
|
|
110
|
-
const client = makeClient();
|
|
111
|
-
restore = installMockFetch(async () =>
|
|
112
|
-
jsonResponse({
|
|
113
|
-
jsonrpc: "2.0",
|
|
114
|
-
result: { tools: [{ name: "echo_test", description: "test", inputSchema: {} }] },
|
|
115
|
-
id: "1",
|
|
116
|
-
}),
|
|
117
|
-
);
|
|
118
|
-
const tools = await client.listTools();
|
|
119
|
-
assert.equal(tools.length, 1);
|
|
120
|
-
assert.equal(tools[0].name, "echo_test");
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test("malformed response", async () => {
|
|
124
|
-
const client = makeClient();
|
|
125
|
-
restore = installMockFetch(async () =>
|
|
126
|
-
jsonResponse({ invalid: true }),
|
|
127
|
-
);
|
|
128
|
-
const result = await client.callTool("echo_test", {});
|
|
129
|
-
assert.ok(result.content[0].text.includes("Malformed"));
|
|
130
|
-
});
|
|
131
|
-
});
|
package/tests/config.test.ts
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import { test, describe } from "node:test";
|
|
2
|
-
import { strict as assert } from "node:assert";
|
|
3
|
-
import { parseConfig, isAuthenticated, isTokenExpiringSoon, shouldRegisterTool } from "../src/config.ts";
|
|
4
|
-
|
|
5
|
-
describe("parseConfig", () => {
|
|
6
|
-
test("empty config → defaults", () => {
|
|
7
|
-
const c = parseConfig({});
|
|
8
|
-
assert.equal(c.serverUrl, "https://mcp.adspirer.com");
|
|
9
|
-
assert.equal(c.requestTimeoutMs, 60000);
|
|
10
|
-
assert.equal(c.testMode, false);
|
|
11
|
-
assert.equal(c.enabledGroups.length, 6);
|
|
12
|
-
assert.equal(c.enabledTools.length, 0);
|
|
13
|
-
assert.equal(c.accessToken, undefined);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
test("full config", () => {
|
|
17
|
-
const c = parseConfig({
|
|
18
|
-
serverUrl: "http://localhost:8000",
|
|
19
|
-
testMode: true,
|
|
20
|
-
testUserEmail: "test@test.com",
|
|
21
|
-
accessToken: "tok",
|
|
22
|
-
refreshToken: "ref",
|
|
23
|
-
tokenExpiresAt: 99999999999999,
|
|
24
|
-
defaultAccountId: "123",
|
|
25
|
-
enabledGroups: ["google_ads"],
|
|
26
|
-
enabledTools: ["echo_test"],
|
|
27
|
-
requestTimeoutMs: 120000,
|
|
28
|
-
});
|
|
29
|
-
assert.equal(c.serverUrl, "http://localhost:8000");
|
|
30
|
-
assert.equal(c.testMode, true);
|
|
31
|
-
assert.equal(c.testUserEmail, "test@test.com");
|
|
32
|
-
assert.equal(c.accessToken, "tok");
|
|
33
|
-
assert.equal(c.refreshToken, "ref");
|
|
34
|
-
assert.equal(c.enabledGroups.length, 1);
|
|
35
|
-
assert.equal(c.enabledTools.length, 1);
|
|
36
|
-
assert.equal(c.requestTimeoutMs, 120000);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("partial config fills defaults", () => {
|
|
40
|
-
const c = parseConfig({ serverUrl: "https://custom.com" });
|
|
41
|
-
assert.equal(c.serverUrl, "https://custom.com");
|
|
42
|
-
assert.equal(c.requestTimeoutMs, 60000);
|
|
43
|
-
assert.equal(c.enabledGroups.length, 6);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test("null/undefined/non-object → defaults", () => {
|
|
47
|
-
for (const v of [null, undefined, "string", 42, []]) {
|
|
48
|
-
const c = parseConfig(v);
|
|
49
|
-
assert.equal(c.serverUrl, "https://mcp.adspirer.com");
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
describe("isAuthenticated", () => {
|
|
55
|
-
test("with valid token → true", () => {
|
|
56
|
-
const c = parseConfig({ accessToken: "tok", tokenExpiresAt: Date.now() + 3600000 });
|
|
57
|
-
assert.equal(isAuthenticated(c), true);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("no token → false", () => {
|
|
61
|
-
assert.equal(isAuthenticated(parseConfig({})), false);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("expired token → false", () => {
|
|
65
|
-
const c = parseConfig({ accessToken: "tok", tokenExpiresAt: Date.now() - 1000 });
|
|
66
|
-
assert.equal(isAuthenticated(c), false);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("testMode → true regardless", () => {
|
|
70
|
-
const c = parseConfig({ testMode: true });
|
|
71
|
-
assert.equal(isAuthenticated(c), true);
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
describe("isTokenExpiringSoon", () => {
|
|
76
|
-
test("expiring within 5min → true", () => {
|
|
77
|
-
const c = parseConfig({ accessToken: "tok", tokenExpiresAt: Date.now() + 60000 });
|
|
78
|
-
assert.equal(isTokenExpiringSoon(c), true);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("not expiring soon → false", () => {
|
|
82
|
-
const c = parseConfig({ accessToken: "tok", tokenExpiresAt: Date.now() + 600000 });
|
|
83
|
-
assert.equal(isTokenExpiringSoon(c), false);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test("testMode → false", () => {
|
|
87
|
-
const c = parseConfig({ testMode: true, tokenExpiresAt: Date.now() + 1000 });
|
|
88
|
-
assert.equal(isTokenExpiringSoon(c), false);
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
describe("shouldRegisterTool", () => {
|
|
93
|
-
test("system tool → always true", () => {
|
|
94
|
-
const c = parseConfig({ enabledGroups: ["google_ads"] });
|
|
95
|
-
assert.equal(shouldRegisterTool("get_usage_status", "system", c), true);
|
|
96
|
-
assert.equal(shouldRegisterTool("get_connections_status", "system", c), true);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test("group filter works", () => {
|
|
100
|
-
const c = parseConfig({ enabledGroups: ["google_ads"] });
|
|
101
|
-
assert.equal(shouldRegisterTool("get_campaign_performance", "google_ads", c), true);
|
|
102
|
-
assert.equal(shouldRegisterTool("pause_meta_campaign", "meta_ads", c), false);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test("enabledTools override", () => {
|
|
106
|
-
const c = parseConfig({ enabledTools: ["pause_meta_campaign"] });
|
|
107
|
-
assert.equal(shouldRegisterTool("pause_meta_campaign", "meta_ads", c), true);
|
|
108
|
-
assert.equal(shouldRegisterTool("get_campaign_performance", "google_ads", c), false);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test("default → all-on", () => {
|
|
112
|
-
const c = parseConfig({});
|
|
113
|
-
assert.equal(shouldRegisterTool("pause_meta_campaign", "meta_ads", c), true);
|
|
114
|
-
});
|
|
115
|
-
});
|
package/tests/errors.test.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { test, describe } from "node:test";
|
|
2
|
-
import { strict as assert } from "node:assert";
|
|
3
|
-
import { mapHTTPError, mapJSONRPCError, createErrorResult, isRetryableError } from "../src/errors.ts";
|
|
4
|
-
|
|
5
|
-
describe("mapHTTPError", () => {
|
|
6
|
-
test("401", () => assert.ok(mapHTTPError(401).includes("login")));
|
|
7
|
-
test("402", () => assert.ok(mapHTTPError(402).includes("Upgrade")));
|
|
8
|
-
test("403", () => assert.ok(mapHTTPError(403).includes("Access denied")));
|
|
9
|
-
test("429", () => assert.ok(mapHTTPError(429).includes("Rate limit")));
|
|
10
|
-
test("500", () => assert.ok(mapHTTPError(500).includes("service error")));
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
describe("mapJSONRPCError", () => {
|
|
14
|
-
test("-32601", () => assert.ok(mapJSONRPCError({ code: -32601, message: "" }).includes("not found")));
|
|
15
|
-
test("-32602", () => assert.ok(mapJSONRPCError({ code: -32602, message: "bad param" }).includes("bad param")));
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
describe("createErrorResult", () => {
|
|
19
|
-
test("wraps in content format", () => {
|
|
20
|
-
const r = createErrorResult("oops");
|
|
21
|
-
assert.equal(r.content.length, 1);
|
|
22
|
-
assert.equal(r.content[0].type, "text");
|
|
23
|
-
assert.ok(r.content[0].text.includes("oops"));
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
describe("isRetryableError", () => {
|
|
28
|
-
test("401 → true", () => assert.equal(isRetryableError(401), true));
|
|
29
|
-
test("429 → true", () => assert.equal(isRetryableError(429), true));
|
|
30
|
-
test("503 → true", () => assert.equal(isRetryableError(503), true));
|
|
31
|
-
test("500 → false", () => assert.equal(isRetryableError(500), false));
|
|
32
|
-
test("200 → false", () => assert.equal(isRetryableError(200), false));
|
|
33
|
-
});
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
export function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
|
2
|
-
return new Response(JSON.stringify(body), {
|
|
3
|
-
status,
|
|
4
|
-
headers: { "Content-Type": "application/json", ...headers },
|
|
5
|
-
});
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export function installMockFetch(handler: (url: string | URL | Request, init?: RequestInit) => Promise<Response>): () => void {
|
|
9
|
-
const original = globalThis.fetch;
|
|
10
|
-
globalThis.fetch = handler as typeof fetch;
|
|
11
|
-
return () => { globalThis.fetch = original; };
|
|
12
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { test, describe } from "node:test";
|
|
2
|
-
import { strict as assert } from "node:assert";
|
|
3
|
-
import {
|
|
4
|
-
ALL_TOOLS, GOOGLE_ADS_TOOLS, META_ADS_TOOLS, TIKTOK_ADS_TOOLS,
|
|
5
|
-
LINKEDIN_ADS_TOOLS, MANUS_TOOLS, SYSTEM_TOOLS,
|
|
6
|
-
getGroupForTool, getToolsForGroups,
|
|
7
|
-
} from "../src/tool-groups.ts";
|
|
8
|
-
|
|
9
|
-
describe("tool counts", () => {
|
|
10
|
-
test("ALL_TOOLS = 104 (with 1 duplicate)", () => assert.equal(ALL_TOOLS.length, 104));
|
|
11
|
-
test("Google Ads = 40", () => assert.equal(GOOGLE_ADS_TOOLS.length, 40));
|
|
12
|
-
test("Meta Ads = 20", () => assert.equal(META_ADS_TOOLS.length, 20));
|
|
13
|
-
test("TikTok Ads = 4", () => assert.equal(TIKTOK_ADS_TOOLS.length, 4));
|
|
14
|
-
test("LinkedIn Ads = 28", () => assert.equal(LINKEDIN_ADS_TOOLS.length, 28));
|
|
15
|
-
test("Manus = 8", () => assert.equal(MANUS_TOOLS.length, 8));
|
|
16
|
-
test("System = 4", () => assert.equal(SYSTEM_TOOLS.length, 4));
|
|
17
|
-
|
|
18
|
-
test("ALL_TOOLS has 104 entries (get_usage_status in google_ads and system)", () => {
|
|
19
|
-
assert.equal(ALL_TOOLS.length, 104);
|
|
20
|
-
const unique = new Set(ALL_TOOLS);
|
|
21
|
-
assert.equal(unique.size, 103); // 103 unique tools
|
|
22
|
-
});
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
describe("getGroupForTool", () => {
|
|
26
|
-
test("known google tool", () => assert.equal(getGroupForTool("get_campaign_performance"), "google_ads"));
|
|
27
|
-
test("known meta tool", () => assert.equal(getGroupForTool("pause_meta_campaign"), "meta_ads"));
|
|
28
|
-
test("known system tool", () => assert.equal(getGroupForTool("switch_primary_account"), "system"));
|
|
29
|
-
test("unknown tool", () => assert.equal(getGroupForTool("nonexistent_tool"), null));
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
describe("getToolsForGroups", () => {
|
|
33
|
-
test("meta_ads + tiktok_ads = 24", () => {
|
|
34
|
-
const tools = getToolsForGroups(["meta_ads", "tiktok_ads"]);
|
|
35
|
-
assert.equal(tools.length, 24);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("empty groups = 0", () => {
|
|
39
|
-
assert.equal(getToolsForGroups([]).length, 0);
|
|
40
|
-
});
|
|
41
|
-
});
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import { test, describe, afterEach } from "node:test";
|
|
2
|
-
import { strict as assert } from "node:assert";
|
|
3
|
-
import { isWriteTool, fetchAndRegisterTools, registerStaticFallbacks, registerConnectTool, createToolExecutor } from "../src/tool-registry.ts";
|
|
4
|
-
import { parseConfig } from "../src/config.ts";
|
|
5
|
-
import { AdspirerMCPClient } from "../src/client.ts";
|
|
6
|
-
import { installMockFetch, jsonResponse } from "./helpers/mock-fetch.ts";
|
|
7
|
-
|
|
8
|
-
function makeMockApi() {
|
|
9
|
-
const registered: { name: string; opts?: any }[] = [];
|
|
10
|
-
return {
|
|
11
|
-
pluginConfig: {},
|
|
12
|
-
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
|
13
|
-
registerTool: (tool: any, opts?: any) => { registered.push({ name: tool.name, opts }); },
|
|
14
|
-
registerCommand: () => {},
|
|
15
|
-
registerCli: () => {},
|
|
16
|
-
registerService: () => {},
|
|
17
|
-
on: () => {},
|
|
18
|
-
updateConfig: () => {},
|
|
19
|
-
_registered: registered,
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function makeClient(config = parseConfig({ testMode: true, serverUrl: "https://test.example.com" })) {
|
|
24
|
-
return new AdspirerMCPClient(
|
|
25
|
-
config,
|
|
26
|
-
async () => "",
|
|
27
|
-
() => {},
|
|
28
|
-
async () => ({ accessToken: "", refreshToken: "", expiresIn: 3600, expiresAt: 0 }),
|
|
29
|
-
{ info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
describe("isWriteTool", () => {
|
|
34
|
-
test("create_ prefix → write", () => assert.equal(isWriteTool("create_search_campaign"), true));
|
|
35
|
-
test("update_ prefix → write", () => assert.equal(isWriteTool("update_campaign"), true));
|
|
36
|
-
test("pause_ prefix → write", () => assert.equal(isWriteTool("pause_campaign"), true));
|
|
37
|
-
test("get_ prefix → read", () => assert.equal(isWriteTool("get_campaign_performance"), false));
|
|
38
|
-
test("list_ prefix → read", () => assert.equal(isWriteTool("list_campaigns"), false));
|
|
39
|
-
test("analyze_ prefix → read", () => assert.equal(isWriteTool("analyze_wasted_spend"), false));
|
|
40
|
-
test("explicit write: switch_primary_account", () => assert.equal(isWriteTool("switch_primary_account"), true));
|
|
41
|
-
test("explicit write: infer_business_profile", () => assert.equal(isWriteTool("infer_business_profile"), true));
|
|
42
|
-
test("echo_test → read", () => assert.equal(isWriteTool("echo_test"), false));
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
describe("fetchAndRegisterTools", () => {
|
|
46
|
-
let restore: (() => void) | null = null;
|
|
47
|
-
afterEach(() => { if (restore) { restore(); restore = null; } });
|
|
48
|
-
|
|
49
|
-
test("registers tools from server", async () => {
|
|
50
|
-
const api = makeMockApi();
|
|
51
|
-
const config = parseConfig({ testMode: true, serverUrl: "https://test.example.com" });
|
|
52
|
-
const client = makeClient(config);
|
|
53
|
-
|
|
54
|
-
restore = installMockFetch(async () =>
|
|
55
|
-
jsonResponse({
|
|
56
|
-
jsonrpc: "2.0",
|
|
57
|
-
result: {
|
|
58
|
-
tools: [
|
|
59
|
-
{ name: "echo_test", description: "test", inputSchema: {} },
|
|
60
|
-
{ name: "create_search_campaign", description: "create", inputSchema: {} },
|
|
61
|
-
],
|
|
62
|
-
},
|
|
63
|
-
id: "1",
|
|
64
|
-
}),
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
const count = await fetchAndRegisterTools(api as any, client, config);
|
|
68
|
-
assert.equal(count, 2);
|
|
69
|
-
assert.equal(api._registered.length, 2);
|
|
70
|
-
// echo_test is read, no confirmation needed
|
|
71
|
-
assert.equal(api._registered[0].opts, undefined);
|
|
72
|
-
// create_search_campaign is write, needs confirmation
|
|
73
|
-
assert.deepEqual(api._registered[1].opts, { needsConfirmation: true });
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test("filters by enabledGroups", async () => {
|
|
77
|
-
const api = makeMockApi();
|
|
78
|
-
const config = parseConfig({ testMode: true, serverUrl: "https://test.example.com", enabledGroups: ["meta_ads"] });
|
|
79
|
-
const client = makeClient(config);
|
|
80
|
-
|
|
81
|
-
restore = installMockFetch(async () =>
|
|
82
|
-
jsonResponse({
|
|
83
|
-
jsonrpc: "2.0",
|
|
84
|
-
result: {
|
|
85
|
-
tools: [
|
|
86
|
-
{ name: "get_campaign_performance", description: "google", inputSchema: {} },
|
|
87
|
-
{ name: "get_meta_campaign_performance", description: "meta", inputSchema: {} },
|
|
88
|
-
{ name: "get_usage_status", description: "system", inputSchema: {} }, // system always
|
|
89
|
-
],
|
|
90
|
-
},
|
|
91
|
-
id: "1",
|
|
92
|
-
}),
|
|
93
|
-
);
|
|
94
|
-
|
|
95
|
-
const count = await fetchAndRegisterTools(api as any, client, config);
|
|
96
|
-
// get_campaign_performance filtered out (google_ads), meta + system stay
|
|
97
|
-
assert.equal(count, 2);
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
describe("registerStaticFallbacks", () => {
|
|
102
|
-
test("registers 6 fallback tools", () => {
|
|
103
|
-
const api = makeMockApi();
|
|
104
|
-
const config = parseConfig({ testMode: true });
|
|
105
|
-
const client = makeClient(config);
|
|
106
|
-
registerStaticFallbacks(api as any, client, config);
|
|
107
|
-
assert.equal(api._registered.length, 6);
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
describe("registerConnectTool", () => {
|
|
112
|
-
test("registers adspirer_connect", () => {
|
|
113
|
-
const api = makeMockApi();
|
|
114
|
-
registerConnectTool(api as any);
|
|
115
|
-
assert.equal(api._registered.length, 1);
|
|
116
|
-
assert.equal(api._registered[0].name, "adspirer_connect");
|
|
117
|
-
});
|
|
118
|
-
});
|
package/tsconfig.json
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "ES2022",
|
|
5
|
-
"moduleResolution": "bundler",
|
|
6
|
-
"strict": true,
|
|
7
|
-
"esModuleInterop": true,
|
|
8
|
-
"skipLibCheck": true,
|
|
9
|
-
"forceConsistentCasingInFileNames": true,
|
|
10
|
-
"resolveJsonModule": true,
|
|
11
|
-
"allowImportingTsExtensions": true,
|
|
12
|
-
"noEmit": true,
|
|
13
|
-
"rootDir": "."
|
|
14
|
-
},
|
|
15
|
-
"include": [
|
|
16
|
-
"src/**/*.ts",
|
|
17
|
-
"types/*.d.ts"
|
|
18
|
-
],
|
|
19
|
-
"exclude": ["node_modules", "dist", "tests"]
|
|
20
|
-
}
|