@xquik/tweetclaw 1.6.5 → 1.6.7
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 +22 -13
- package/dist/api-spec.d.ts +3 -0
- package/dist/api-spec.js +1427 -0
- package/dist/api-spec.js.map +1 -0
- package/dist/commands/xstatus.d.ts +17 -0
- package/dist/commands/xstatus.js +52 -0
- package/dist/commands/xstatus.js.map +1 -0
- package/dist/commands/xtrends.d.ts +16 -0
- package/dist/commands/xtrends.js +39 -0
- package/dist/commands/xtrends.js.map +1 -0
- package/dist/index.d.ts +107 -0
- package/dist/index.js +249 -0
- package/dist/index.js.map +1 -0
- package/dist/mpp.d.ts +7 -0
- package/dist/mpp.js +39 -0
- package/dist/mpp.js.map +1 -0
- package/dist/request.d.ts +7 -0
- package/dist/request.js +88 -0
- package/dist/request.js.map +1 -0
- package/dist/services/event-poller.d.ts +7 -0
- package/dist/services/event-poller.js +69 -0
- package/dist/services/event-poller.js.map +1 -0
- package/dist/tools/catalog.d.ts +17 -0
- package/dist/tools/catalog.js +110 -0
- package/dist/tools/catalog.js.map +1 -0
- package/dist/tools/explore.d.ts +5 -0
- package/dist/tools/explore.js +28 -0
- package/dist/tools/explore.js.map +1 -0
- package/dist/tools/result.d.ts +5 -0
- package/dist/tools/result.js +15 -0
- package/dist/tools/result.js.map +1 -0
- package/dist/tools/tweetclaw.d.ts +13 -0
- package/dist/tools/tweetclaw.js +62 -0
- package/dist/tools/tweetclaw.js.map +1 -0
- package/dist/truncate.d.ts +3 -0
- package/dist/truncate.js +25 -0
- package/dist/truncate.js.map +1 -0
- package/dist/types.d.ts +64 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/openclaw.plugin.json +7 -8
- package/package.json +19 -18
- package/skills/tweetclaw/SKILL.md +33 -42
- package/src/api-spec.ts +480 -12
- package/src/index.ts +135 -36
- package/src/mpp.ts +9 -11
- package/src/request.ts +27 -2
- package/src/tools/catalog.ts +145 -0
- package/src/tools/explore.ts +18 -44
- package/src/tools/result.ts +19 -0
- package/src/tools/tweetclaw.ts +49 -296
- package/src/types.ts +19 -0
- package/src/tools/executor.ts +0 -125
package/src/index.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
|
|
1
2
|
import { handleXStatus } from './commands/xstatus.js';
|
|
2
3
|
import { handleXTrends } from './commands/xtrends.js';
|
|
3
4
|
import { initMpp } from './mpp.js';
|
|
4
5
|
import { createProxiedRequest } from './request.js';
|
|
5
6
|
import { createEventPoller } from './services/event-poller.js';
|
|
7
|
+
import { normalizeMethod, requestNeedsApproval } from './tools/catalog.js';
|
|
6
8
|
import { handleExplore, SEARCH_DESCRIPTION } from './tools/explore.js';
|
|
7
9
|
import { EXECUTE_DESCRIPTION, handleTweetclaw } from './tools/tweetclaw.js';
|
|
8
|
-
import type { FetchFunction, PluginConfig } from './types.js';
|
|
10
|
+
import type { ExploreParams, FetchFunction, PluginConfig, TweetclawParams } from './types.js';
|
|
9
11
|
|
|
10
12
|
interface PollerEvent {
|
|
11
13
|
readonly eventType?: string;
|
|
@@ -23,6 +25,34 @@ function isPluginConfig(value: unknown): value is PluginConfig {
|
|
|
23
25
|
|
|
24
26
|
const DEFAULT_POLLING_INTERVAL_SECONDS = 60;
|
|
25
27
|
|
|
28
|
+
const CONFIG_SCHEMA = {
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
anyOf: [
|
|
31
|
+
{ required: ['apiKey'] },
|
|
32
|
+
{ required: ['tempoSigningKey'] },
|
|
33
|
+
],
|
|
34
|
+
properties: {
|
|
35
|
+
apiKey: {
|
|
36
|
+
description: 'Xquik API key (get one at dashboard.xquik.com). Required for account-backed X automation.',
|
|
37
|
+
minLength: 1,
|
|
38
|
+
type: 'string',
|
|
39
|
+
},
|
|
40
|
+
baseUrl: { default: 'https://xquik.com', type: 'string' },
|
|
41
|
+
pollingEnabled: { default: true, type: 'boolean' },
|
|
42
|
+
pollingInterval: {
|
|
43
|
+
default: 60,
|
|
44
|
+
description: 'Event polling interval in seconds',
|
|
45
|
+
type: 'number',
|
|
46
|
+
},
|
|
47
|
+
tempoSigningKey: {
|
|
48
|
+
description: 'MPP signing key for pay-per-use mode. No account needed. 32 read-only X-API endpoints.',
|
|
49
|
+
minLength: 1,
|
|
50
|
+
type: 'string',
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
type: 'object',
|
|
54
|
+
};
|
|
55
|
+
|
|
26
56
|
interface ToolResult {
|
|
27
57
|
readonly content: ReadonlyArray<{ readonly text: string; readonly type: string }>;
|
|
28
58
|
readonly isError?: true;
|
|
@@ -78,7 +108,7 @@ interface OpenClawApi {
|
|
|
78
108
|
readonly registerTool: (
|
|
79
109
|
tool: {
|
|
80
110
|
readonly description: string;
|
|
81
|
-
readonly execute: (toolCallId: string, params:
|
|
111
|
+
readonly execute: (toolCallId: string, params: unknown) => Promise<ToolResult>;
|
|
82
112
|
readonly name: string;
|
|
83
113
|
readonly parameters: unknown;
|
|
84
114
|
},
|
|
@@ -96,44 +126,93 @@ interface OpenClawApi {
|
|
|
96
126
|
) => void;
|
|
97
127
|
}
|
|
98
128
|
|
|
99
|
-
const
|
|
129
|
+
const EXPLORE_PARAMETERS = {
|
|
100
130
|
properties: {
|
|
101
|
-
|
|
131
|
+
category: { description: 'Endpoint category filter', type: 'string' },
|
|
132
|
+
free: { description: 'Filter by free or paid endpoints', type: 'boolean' },
|
|
133
|
+
limit: { default: 25, description: 'Maximum endpoint descriptors to return', maximum: 100, minimum: 1, type: 'number' },
|
|
134
|
+
method: { description: 'HTTP method filter', enum: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'], type: 'string' },
|
|
135
|
+
mpp: { description: 'Filter by MPP eligibility', type: 'boolean' },
|
|
136
|
+
path: { description: 'Exact or partial API path filter', type: 'string' },
|
|
137
|
+
query: { description: 'Keyword search across endpoint metadata', type: 'string' },
|
|
102
138
|
},
|
|
103
|
-
required: ['code'],
|
|
104
139
|
type: 'object',
|
|
105
140
|
};
|
|
106
141
|
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
142
|
+
const TWEETCLAW_PARAMETERS = {
|
|
143
|
+
additionalProperties: false,
|
|
144
|
+
properties: {
|
|
145
|
+
body: { description: 'JSON request body', type: ['object', 'array', 'string', 'number', 'boolean', 'null'] },
|
|
146
|
+
method: { default: 'GET', description: 'HTTP method', enum: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'], type: 'string' },
|
|
147
|
+
path: { description: 'Concrete /api/v1/... endpoint path from the catalog', type: 'string' },
|
|
148
|
+
query: {
|
|
149
|
+
additionalProperties: { type: ['string', 'number', 'boolean'] },
|
|
150
|
+
description: 'Query parameters',
|
|
151
|
+
type: 'object',
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
required: ['path'],
|
|
155
|
+
type: 'object',
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
function asObject(value: unknown): Readonly<Record<string, unknown>> | undefined {
|
|
159
|
+
if (typeof value !== 'object' || value === null) {
|
|
124
160
|
return undefined;
|
|
125
161
|
}
|
|
162
|
+
return Object.fromEntries(Object.entries(value));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function asExploreParams(params: unknown): Readonly<ExploreParams> {
|
|
166
|
+
const value = asObject(params);
|
|
167
|
+
if (value === undefined) return {};
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
...(typeof value.category === 'string' ? { category: value.category } : {}),
|
|
171
|
+
...(typeof value.free === 'boolean' ? { free: value.free } : {}),
|
|
172
|
+
...(typeof value.limit === 'number' ? { limit: value.limit } : {}),
|
|
173
|
+
...(typeof value.method === 'string' ? { method: value.method } : {}),
|
|
174
|
+
...(typeof value.mpp === 'boolean' ? { mpp: value.mpp } : {}),
|
|
175
|
+
...(typeof value.path === 'string' ? { path: value.path } : {}),
|
|
176
|
+
...(typeof value.query === 'string' ? { query: value.query } : {}),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function asQueryParams(value: unknown): Readonly<Record<string, boolean | number | string>> | undefined {
|
|
181
|
+
const query = asObject(value);
|
|
182
|
+
if (query === undefined) return undefined;
|
|
183
|
+
|
|
184
|
+
const entries = Object.entries(query).filter(
|
|
185
|
+
(entry): entry is [string, boolean | number | string] =>
|
|
186
|
+
['boolean', 'number', 'string'].includes(typeof entry[1]),
|
|
187
|
+
);
|
|
188
|
+
return Object.fromEntries(entries);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function asTweetclawParams(params: unknown): Readonly<TweetclawParams> {
|
|
192
|
+
const value = asObject(params);
|
|
193
|
+
if (value === undefined || typeof value.path !== 'string') {
|
|
194
|
+
return { path: '' };
|
|
195
|
+
}
|
|
126
196
|
|
|
127
|
-
const
|
|
128
|
-
return
|
|
197
|
+
const query = asQueryParams(value.query);
|
|
198
|
+
return {
|
|
199
|
+
...(value.body === undefined ? {} : { body: value.body }),
|
|
200
|
+
...(typeof value.method === 'string' ? { method: value.method } : {}),
|
|
201
|
+
path: value.path,
|
|
202
|
+
...(query === undefined ? {} : { query }),
|
|
203
|
+
};
|
|
129
204
|
}
|
|
130
205
|
|
|
131
|
-
function
|
|
132
|
-
|
|
133
|
-
|
|
206
|
+
function toolCallParams(event: BeforeToolCallEvent): Readonly<TweetclawParams> | undefined {
|
|
207
|
+
const params = asTweetclawParams(event.params);
|
|
208
|
+
if (params.path.length === 0) {
|
|
209
|
+
return undefined;
|
|
134
210
|
}
|
|
211
|
+
return params;
|
|
212
|
+
}
|
|
135
213
|
|
|
136
|
-
|
|
214
|
+
function requiresTweetclawApproval(params: Readonly<TweetclawParams>): boolean {
|
|
215
|
+
return requestNeedsApproval(normalizeMethod(params.method), params.path);
|
|
137
216
|
}
|
|
138
217
|
|
|
139
218
|
function registerWriteApprovalHook(api: OpenClawApi): void {
|
|
@@ -153,15 +232,15 @@ function registerWriteApprovalHook(api: OpenClawApi): void {
|
|
|
153
232
|
return undefined;
|
|
154
233
|
}
|
|
155
234
|
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
235
|
+
const params = toolCallParams(event);
|
|
236
|
+
if (params === undefined || !requiresTweetclawApproval(params)) {
|
|
158
237
|
return undefined;
|
|
159
238
|
}
|
|
160
239
|
|
|
161
240
|
return {
|
|
162
241
|
requireApproval: {
|
|
163
242
|
description:
|
|
164
|
-
'TweetClaw is about to
|
|
243
|
+
'TweetClaw is about to invoke an endpoint that can change X accounts, create jobs, or expose private data. Review the tool call before allowing it.',
|
|
165
244
|
pluginId: 'tweetclaw',
|
|
166
245
|
severity: 'warning',
|
|
167
246
|
timeoutBehavior: 'deny',
|
|
@@ -174,7 +253,7 @@ function registerWriteApprovalHook(api: OpenClawApi): void {
|
|
|
174
253
|
);
|
|
175
254
|
}
|
|
176
255
|
|
|
177
|
-
|
|
256
|
+
function register(api: OpenClawApi, fetchFunction?: FetchFunction): void {
|
|
178
257
|
const config: unknown = api.pluginConfig;
|
|
179
258
|
if (!isPluginConfig(config)) {
|
|
180
259
|
api.logger.warn(
|
|
@@ -206,9 +285,12 @@ export default function register(api: OpenClawApi, fetchFunction?: FetchFunction
|
|
|
206
285
|
api.registerTool(
|
|
207
286
|
{
|
|
208
287
|
description: SEARCH_DESCRIPTION,
|
|
209
|
-
execute: async (_toolCallId,
|
|
288
|
+
execute: async (_toolCallId, params) => {
|
|
289
|
+
await Promise.resolve();
|
|
290
|
+
return handleExplore(asExploreParams(params));
|
|
291
|
+
},
|
|
210
292
|
name: 'explore',
|
|
211
|
-
parameters:
|
|
293
|
+
parameters: EXPLORE_PARAMETERS,
|
|
212
294
|
},
|
|
213
295
|
{ name: 'explore' },
|
|
214
296
|
);
|
|
@@ -216,9 +298,15 @@ export default function register(api: OpenClawApi, fetchFunction?: FetchFunction
|
|
|
216
298
|
api.registerTool(
|
|
217
299
|
{
|
|
218
300
|
description: EXECUTE_DESCRIPTION,
|
|
219
|
-
execute: async (_toolCallId,
|
|
301
|
+
execute: async (_toolCallId, params) => handleTweetclaw({
|
|
302
|
+
apiKey: credential,
|
|
303
|
+
baseUrl,
|
|
304
|
+
fetchFunction,
|
|
305
|
+
mppMode: isMppMode,
|
|
306
|
+
params: asTweetclawParams(params),
|
|
307
|
+
}),
|
|
220
308
|
name: 'tweetclaw',
|
|
221
|
-
parameters:
|
|
309
|
+
parameters: TWEETCLAW_PARAMETERS,
|
|
222
310
|
},
|
|
223
311
|
{ name: 'tweetclaw', optional: true },
|
|
224
312
|
);
|
|
@@ -273,3 +361,14 @@ export default function register(api: OpenClawApi, fetchFunction?: FetchFunction
|
|
|
273
361
|
|
|
274
362
|
api.logger.info('TweetClaw: Plugin registered successfully');
|
|
275
363
|
}
|
|
364
|
+
|
|
365
|
+
const plugin = definePluginEntry({
|
|
366
|
+
configSchema: CONFIG_SCHEMA,
|
|
367
|
+
description: 'Structured X/Twitter automation through Xquik',
|
|
368
|
+
id: 'tweetclaw',
|
|
369
|
+
name: 'TweetClaw',
|
|
370
|
+
register,
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
export { register };
|
|
374
|
+
export default plugin;
|
package/src/mpp.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { resolveAsyncFunctionConstructor } from './tools/executor.js';
|
|
2
|
-
|
|
3
1
|
type ModuleLoader = (name: string) => Promise<Record<string, unknown>>;
|
|
4
2
|
|
|
5
3
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -10,16 +8,16 @@ function isCallable(value: unknown): value is (...args: readonly unknown[]) => u
|
|
|
10
8
|
return typeof value === 'function';
|
|
11
9
|
}
|
|
12
10
|
|
|
11
|
+
async function loadDynamicModule(name: string): Promise<Record<string, unknown>> {
|
|
12
|
+
const mod: unknown = await import(name);
|
|
13
|
+
if (!isRecord(mod)) {
|
|
14
|
+
throw new Error(`Failed to load ${name}`);
|
|
15
|
+
}
|
|
16
|
+
return mod;
|
|
17
|
+
}
|
|
18
|
+
|
|
13
19
|
function createModuleLoader(): ModuleLoader {
|
|
14
|
-
|
|
15
|
-
const dynamicImport = new loader('n', 'return import(n)');
|
|
16
|
-
return async (name: string): Promise<Record<string, unknown>> => {
|
|
17
|
-
const mod: unknown = await dynamicImport(name);
|
|
18
|
-
if (!isRecord(mod)) {
|
|
19
|
-
throw new Error(`Failed to load ${name}`);
|
|
20
|
-
}
|
|
21
|
-
return mod;
|
|
22
|
-
};
|
|
20
|
+
return loadDynamicModule;
|
|
23
21
|
}
|
|
24
22
|
|
|
25
23
|
async function initMpp(tempoSigningKey: string, loadModule?: ModuleLoader): Promise<void> {
|
package/src/request.ts
CHANGED
|
@@ -7,6 +7,7 @@ const AUTHORIZATION_HEADER = 'authorization';
|
|
|
7
7
|
const BEARER_PREFIX = 'Bearer ';
|
|
8
8
|
const API_KEY_PREFIX = 'xq_';
|
|
9
9
|
const API_V1_PREFIX = '/api/v1/';
|
|
10
|
+
const SUPPORT_TICKETS_PREFIX = '/api/v1/support/tickets';
|
|
10
11
|
|
|
11
12
|
function buildAuthHeader(credential: string): Record<string, string> {
|
|
12
13
|
if (credential.startsWith(API_KEY_PREFIX)) {
|
|
@@ -34,18 +35,42 @@ function buildFetchUrl(baseUrl: string, path: string, query?: Readonly<Record<st
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
const PROHIBITED_PATHS: ReadonlyArray<readonly [string, string]> = [
|
|
38
|
+
['PATCH', '/api/v1/account'],
|
|
39
|
+
['PUT', '/api/v1/account/x-identity'],
|
|
40
|
+
['GET', '/api/v1/api-keys'],
|
|
41
|
+
['POST', '/api/v1/api-keys'],
|
|
42
|
+
['POST', '/api/v1/credits/topup'],
|
|
43
|
+
['GET', '/api/v1/credits/topup/status'],
|
|
44
|
+
['POST', '/api/v1/credits/quick-topup'],
|
|
45
|
+
['POST', '/api/v1/subscribe'],
|
|
37
46
|
['POST', '/api/v1/x/accounts'],
|
|
38
47
|
['POST', '/api/v1/x/accounts/'],
|
|
48
|
+
['POST', '/api/v1/x/accounts/bulk-retry'],
|
|
39
49
|
];
|
|
40
50
|
|
|
41
|
-
const
|
|
51
|
+
const PROHIBITED_PATH_PATTERNS: ReadonlyArray<readonly [string, RegExp]> = [
|
|
52
|
+
['DELETE', /^\/api\/v1\/api-keys\/[^/]+\/?$/u],
|
|
53
|
+
['DELETE', /^\/api\/v1\/x\/accounts\/[^/]+\/?$/u],
|
|
54
|
+
['GET', /^\/api\/v1\/x\/accounts\/[^/]+\/?$/u],
|
|
55
|
+
['POST', /^\/api\/v1\/x\/accounts\/[^/]+\/reauth\/?$/u],
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const SUPPORT_TICKET_METHODS: ReadonlySet<string> = new Set(['GET', 'PATCH', 'POST']);
|
|
59
|
+
|
|
60
|
+
function isSupportTicketPath(method: string, path: string): boolean {
|
|
61
|
+
return SUPPORT_TICKET_METHODS.has(method)
|
|
62
|
+
&& (path === SUPPORT_TICKETS_PREFIX || path.startsWith(`${SUPPORT_TICKETS_PREFIX}/`));
|
|
63
|
+
}
|
|
42
64
|
|
|
43
65
|
function isProhibitedRequest(method: string, path: string): boolean {
|
|
44
66
|
const upperMethod = method.toUpperCase();
|
|
45
67
|
const matchesStaticPath = PROHIBITED_PATHS.some(
|
|
46
68
|
([blockedMethod, blockedPath]) => upperMethod === blockedMethod && path === blockedPath,
|
|
47
69
|
);
|
|
48
|
-
|
|
70
|
+
const matchesPattern = PROHIBITED_PATH_PATTERNS.some(
|
|
71
|
+
([blockedMethod, pattern]) => upperMethod === blockedMethod && pattern.test(path),
|
|
72
|
+
);
|
|
73
|
+
return matchesStaticPath || matchesPattern || isSupportTicketPath(upperMethod, path);
|
|
49
74
|
}
|
|
50
75
|
|
|
51
76
|
function validateRequestPath(method: string, path: string): void {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { API_SPEC } from '../api-spec.js';
|
|
2
|
+
import type { EndpointInfo, ExploreParams, TweetclawParams } from '../types.js';
|
|
3
|
+
|
|
4
|
+
const API_V1_PREFIX = '/api/v1/';
|
|
5
|
+
const DEFAULT_EXPLORE_LIMIT = 25;
|
|
6
|
+
const MAX_EXPLORE_LIMIT = 100;
|
|
7
|
+
|
|
8
|
+
const specEndpoints: readonly EndpointInfo[] = API_SPEC.filter((endpoint) => endpoint.agentProhibited !== true);
|
|
9
|
+
|
|
10
|
+
function normalizeMethod(method?: string): string {
|
|
11
|
+
return (method ?? 'GET').toUpperCase();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizeLimit(limit?: number): number {
|
|
15
|
+
if (limit === undefined || !Number.isFinite(limit)) {
|
|
16
|
+
return DEFAULT_EXPLORE_LIMIT;
|
|
17
|
+
}
|
|
18
|
+
return Math.min(Math.max(Math.trunc(limit), 1), MAX_EXPLORE_LIMIT);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pathSegments(path: string): readonly string[] {
|
|
22
|
+
const normalized = path.endsWith('/') ? path.slice(0, -1) : path;
|
|
23
|
+
return normalized.split('/');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function matchesEndpointPath(endpointPath: string, requestPath: string): boolean {
|
|
27
|
+
if (endpointPath === requestPath) return true;
|
|
28
|
+
const endpointSegments = pathSegments(endpointPath);
|
|
29
|
+
const requestSegments = pathSegments(requestPath);
|
|
30
|
+
if (endpointSegments.length !== requestSegments.length) return false;
|
|
31
|
+
|
|
32
|
+
return endpointSegments.every((segment, index) => {
|
|
33
|
+
const requestSegment = String(requestSegments.at(index));
|
|
34
|
+
return segment.startsWith(':') ? requestSegment.length > 0 : segment === requestSegment;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function assertSafePath(path: string): void {
|
|
39
|
+
if (!path.startsWith(API_V1_PREFIX)) {
|
|
40
|
+
throw new Error(`Path must start with /api/v1/ but got: ${path}`);
|
|
41
|
+
}
|
|
42
|
+
if (path.includes('?') || path.includes('#')) {
|
|
43
|
+
throw new Error('Pass query parameters through the query object, not in the path.');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function findEndpoint(method: string, path: string): EndpointInfo | undefined {
|
|
48
|
+
return specEndpoints.find(
|
|
49
|
+
(endpoint) => endpoint.method === method && matchesEndpointPath(endpoint.path, path),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeQuery(query?: Readonly<Record<string, boolean | number | string>>): Readonly<Record<string, string>> | undefined {
|
|
54
|
+
if (query === undefined) return undefined;
|
|
55
|
+
return Object.fromEntries(Object.entries(query).map(([key, value]) => [key, String(value)]));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function requestNeedsApproval(method: string, path: string): boolean {
|
|
59
|
+
if (method !== 'GET') {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return path.startsWith('/api/v1/events')
|
|
64
|
+
|| path.startsWith('/api/v1/webhooks')
|
|
65
|
+
|| path === '/api/v1/x/accounts'
|
|
66
|
+
|| path.startsWith('/api/v1/x/accounts/')
|
|
67
|
+
|| path.startsWith('/api/v1/x/bookmarks')
|
|
68
|
+
|| path.startsWith('/api/v1/x/dm/')
|
|
69
|
+
|| path.startsWith('/api/v1/x/notifications')
|
|
70
|
+
|| path.startsWith('/api/v1/x/timeline');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolveCatalogRequest(
|
|
74
|
+
params: Readonly<TweetclawParams>,
|
|
75
|
+
options?: Readonly<{ mppMode?: boolean }>,
|
|
76
|
+
): {
|
|
77
|
+
readonly body?: unknown;
|
|
78
|
+
readonly endpoint: EndpointInfo;
|
|
79
|
+
readonly method: string;
|
|
80
|
+
readonly path: string;
|
|
81
|
+
readonly query?: Readonly<Record<string, string>>;
|
|
82
|
+
} {
|
|
83
|
+
const method = normalizeMethod(params.method);
|
|
84
|
+
const { body, path } = params;
|
|
85
|
+
assertSafePath(path);
|
|
86
|
+
const endpoint = findEndpoint(method, path);
|
|
87
|
+
if (endpoint === undefined) {
|
|
88
|
+
throw new Error(`Endpoint is not in the TweetClaw catalog: ${method} ${path}`);
|
|
89
|
+
}
|
|
90
|
+
if (options?.mppMode === true && endpoint.mpp === undefined) {
|
|
91
|
+
throw new Error(`Endpoint is not available in MPP mode: ${method} ${endpoint.path}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const query = normalizeQuery(params.query);
|
|
95
|
+
if (query === undefined) {
|
|
96
|
+
return { body, endpoint, method, path };
|
|
97
|
+
}
|
|
98
|
+
return { body, endpoint, method, path, query };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function endpointMatchesQuery(endpoint: EndpointInfo, query: string): boolean {
|
|
102
|
+
const normalized = query.toLowerCase();
|
|
103
|
+
const { category, method, parameters, path, responseShape, summary } = endpoint;
|
|
104
|
+
const haystack = [
|
|
105
|
+
category,
|
|
106
|
+
method,
|
|
107
|
+
path,
|
|
108
|
+
responseShape,
|
|
109
|
+
summary,
|
|
110
|
+
...(parameters ?? []).flatMap((parameter) => [
|
|
111
|
+
parameter.description,
|
|
112
|
+
parameter.name,
|
|
113
|
+
parameter.type,
|
|
114
|
+
]),
|
|
115
|
+
].join(' ').toLowerCase();
|
|
116
|
+
|
|
117
|
+
return haystack.includes(normalized);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function exploreCatalog(params: Readonly<ExploreParams> = {}): readonly EndpointInfo[] {
|
|
121
|
+
const method = params.method === undefined ? undefined : normalizeMethod(params.method);
|
|
122
|
+
const query = params.query?.trim();
|
|
123
|
+
const category = params.category?.trim().toLowerCase();
|
|
124
|
+
const path = params.path?.trim();
|
|
125
|
+
const limit = normalizeLimit(params.limit);
|
|
126
|
+
|
|
127
|
+
return specEndpoints
|
|
128
|
+
.filter((endpoint) => method === undefined || endpoint.method === method)
|
|
129
|
+
.filter((endpoint) => category === undefined || endpoint.category.toLowerCase() === category)
|
|
130
|
+
.filter((endpoint) => params.free === undefined || endpoint.free === params.free)
|
|
131
|
+
.filter((endpoint) => params.mpp === undefined || (endpoint.mpp !== undefined) === params.mpp)
|
|
132
|
+
.filter((endpoint) => path === undefined || matchesEndpointPath(endpoint.path, path) || endpoint.path.includes(path))
|
|
133
|
+
.filter((endpoint) => query === undefined || query.length === 0 || endpointMatchesQuery(endpoint, query))
|
|
134
|
+
.slice(0, limit);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export {
|
|
138
|
+
exploreCatalog,
|
|
139
|
+
findEndpoint,
|
|
140
|
+
matchesEndpointPath,
|
|
141
|
+
normalizeMethod,
|
|
142
|
+
requestNeedsApproval,
|
|
143
|
+
resolveCatalogRequest,
|
|
144
|
+
specEndpoints,
|
|
145
|
+
};
|
package/src/tools/explore.ts
CHANGED
|
@@ -1,53 +1,27 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { errorResult,
|
|
3
|
-
import type { EndpointInfo, ToolResult } from '../types.js';
|
|
1
|
+
import { exploreCatalog, specEndpoints } from './catalog.js';
|
|
2
|
+
import { errorResult, successResult } from './result.js';
|
|
3
|
+
import type { EndpointInfo, ExploreParams, ToolResult } from '../types.js';
|
|
4
4
|
|
|
5
|
-
const categories = [...new Set(
|
|
5
|
+
const categories = [...new Set(specEndpoints.map((endpoint) => endpoint.category))]
|
|
6
|
+
.toSorted((a, b) => a.localeCompare(b))
|
|
7
|
+
.join(', ');
|
|
6
8
|
|
|
7
|
-
const SEARCH_DESCRIPTION = `Search the X (Twitter) API
|
|
9
|
+
const SEARCH_DESCRIPTION = `Search the X (Twitter) API endpoint catalog. No network calls and no code execution.
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
Use structured filters:
|
|
12
|
+
- query: keyword search across summaries, paths, response shapes, and parameters
|
|
13
|
+
- category: one of ${categories}
|
|
14
|
+
- method: GET, POST, PATCH, PUT, or DELETE
|
|
15
|
+
- path: exact or partial API path
|
|
16
|
+
- free: true for free endpoints, false for paid endpoints
|
|
17
|
+
- mpp: true for MPP-eligible endpoints only
|
|
18
|
+
- limit: 1-100 results, default 25
|
|
10
19
|
|
|
11
|
-
|
|
12
|
-
interface EndpointInfo {
|
|
13
|
-
method: string;
|
|
14
|
-
path: string;
|
|
15
|
-
summary: string;
|
|
16
|
-
category: string; // ${categories}
|
|
17
|
-
free: boolean;
|
|
18
|
-
parameters?: Array<{ name: string; in: 'query' | 'path' | 'body'; required: boolean; type: string; description: string }>;
|
|
19
|
-
responseShape?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
declare const spec: { endpoints: EndpointInfo[] };
|
|
23
|
-
\`\`\`
|
|
24
|
-
|
|
25
|
-
## Examples
|
|
26
|
-
|
|
27
|
-
### Find all free endpoints
|
|
28
|
-
\`\`\`javascript
|
|
29
|
-
async () => {
|
|
30
|
-
return spec.endpoints.filter(e => e.free);
|
|
31
|
-
}
|
|
32
|
-
\`\`\`
|
|
33
|
-
|
|
34
|
-
### Find endpoints by category
|
|
35
|
-
\`\`\`javascript
|
|
36
|
-
async () => {
|
|
37
|
-
return spec.endpoints.filter(e => e.category === 'composition');
|
|
38
|
-
}
|
|
39
|
-
\`\`\`
|
|
40
|
-
|
|
41
|
-
### Search by keyword
|
|
42
|
-
\`\`\`javascript
|
|
43
|
-
async () => {
|
|
44
|
-
return spec.endpoints.filter(e => e.summary.toLowerCase().includes('tweet'));
|
|
45
|
-
}
|
|
46
|
-
\`\`\``;
|
|
20
|
+
Returns endpoint descriptors with method, path, summary, category, parameters, cost, and response shape.`;
|
|
47
21
|
|
|
48
|
-
async function handleExplore(
|
|
22
|
+
async function handleExplore(params: Readonly<ExploreParams> = {}): Promise<ToolResult> {
|
|
49
23
|
try {
|
|
50
|
-
const result
|
|
24
|
+
const result = await Promise.resolve(exploreCatalog(params));
|
|
51
25
|
return successResult(result);
|
|
52
26
|
} catch (error: unknown) {
|
|
53
27
|
return errorResult(error);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { truncateResponse } from '../truncate.js';
|
|
2
|
+
import type { ToolResult } from '../types.js';
|
|
3
|
+
|
|
4
|
+
function extractErrorMessage(error: unknown): string {
|
|
5
|
+
if (error instanceof Error) {
|
|
6
|
+
return `${error.constructor.name}: ${error.message}`;
|
|
7
|
+
}
|
|
8
|
+
return String(error);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function successResult(content: unknown): ToolResult {
|
|
12
|
+
return { content: [{ text: truncateResponse(content), type: 'text' }] };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function errorResult(error: unknown): ToolResult {
|
|
16
|
+
return { content: [{ text: extractErrorMessage(error), type: 'text' }], isError: true };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { errorResult, extractErrorMessage, successResult };
|