@xquik/tweetclaw 1.6.4 → 1.6.6
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 +25 -14
- 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 +17 -17
- package/skills/tweetclaw/SKILL.md +37 -44
- package/src/api-spec.ts +491 -12
- package/src/index.ts +212 -10
- 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;
|
|
@@ -34,6 +64,28 @@ interface CommandContext {
|
|
|
34
64
|
readonly senderId?: string;
|
|
35
65
|
}
|
|
36
66
|
|
|
67
|
+
interface BeforeToolCallEvent {
|
|
68
|
+
readonly params?: unknown;
|
|
69
|
+
readonly toolName?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface ToolApprovalRequest {
|
|
73
|
+
readonly description: string;
|
|
74
|
+
readonly pluginId?: string;
|
|
75
|
+
readonly severity?: 'critical' | 'info' | 'warning';
|
|
76
|
+
readonly timeoutBehavior?: 'allow' | 'deny';
|
|
77
|
+
readonly timeoutMs?: number;
|
|
78
|
+
readonly title: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface BeforeToolCallResult {
|
|
82
|
+
readonly requireApproval?: ToolApprovalRequest;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type BeforeToolCallHandler = (
|
|
86
|
+
event: BeforeToolCallEvent,
|
|
87
|
+
) => BeforeToolCallResult | Promise<BeforeToolCallResult | undefined> | undefined;
|
|
88
|
+
|
|
37
89
|
interface OpenClawApi {
|
|
38
90
|
readonly logger: {
|
|
39
91
|
readonly debug?: (message: string) => void;
|
|
@@ -56,23 +108,152 @@ interface OpenClawApi {
|
|
|
56
108
|
readonly registerTool: (
|
|
57
109
|
tool: {
|
|
58
110
|
readonly description: string;
|
|
59
|
-
readonly execute: (toolCallId: string, params:
|
|
111
|
+
readonly execute: (toolCallId: string, params: unknown) => Promise<ToolResult>;
|
|
60
112
|
readonly name: string;
|
|
61
113
|
readonly parameters: unknown;
|
|
62
114
|
},
|
|
63
115
|
options?: { readonly name?: string; readonly optional?: boolean },
|
|
64
116
|
) => void;
|
|
117
|
+
readonly on?: (
|
|
118
|
+
name: 'before_tool_call',
|
|
119
|
+
handler: BeforeToolCallHandler,
|
|
120
|
+
options?: { readonly priority?: number },
|
|
121
|
+
) => void;
|
|
122
|
+
readonly registerHook?: (
|
|
123
|
+
name: 'before_tool_call',
|
|
124
|
+
handler: BeforeToolCallHandler,
|
|
125
|
+
options?: { readonly priority?: number },
|
|
126
|
+
) => void;
|
|
65
127
|
}
|
|
66
128
|
|
|
67
|
-
const
|
|
129
|
+
const EXPLORE_PARAMETERS = {
|
|
130
|
+
properties: {
|
|
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' },
|
|
138
|
+
},
|
|
139
|
+
type: 'object',
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const TWEETCLAW_PARAMETERS = {
|
|
143
|
+
additionalProperties: false,
|
|
68
144
|
properties: {
|
|
69
|
-
|
|
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
|
+
},
|
|
70
153
|
},
|
|
71
|
-
required: ['
|
|
154
|
+
required: ['path'],
|
|
72
155
|
type: 'object',
|
|
73
156
|
};
|
|
74
157
|
|
|
75
|
-
|
|
158
|
+
function asObject(value: unknown): Readonly<Record<string, unknown>> | undefined {
|
|
159
|
+
if (typeof value !== 'object' || value === null) {
|
|
160
|
+
return undefined;
|
|
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
|
+
}
|
|
196
|
+
|
|
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
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function toolCallParams(event: BeforeToolCallEvent): Readonly<TweetclawParams> | undefined {
|
|
207
|
+
const params = asTweetclawParams(event.params);
|
|
208
|
+
if (params.path.length === 0) {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
return params;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function requiresTweetclawApproval(params: Readonly<TweetclawParams>): boolean {
|
|
215
|
+
return requestNeedsApproval(normalizeMethod(params.method), params.path);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function registerWriteApprovalHook(api: OpenClawApi): void {
|
|
219
|
+
const registerHook = api.on ?? api.registerHook;
|
|
220
|
+
if (registerHook === undefined) {
|
|
221
|
+
api.logger.warn(
|
|
222
|
+
'TweetClaw: OpenClaw approval hooks are unavailable. Keep explicit user approval before write actions.',
|
|
223
|
+
);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
registerHook.call(
|
|
228
|
+
api,
|
|
229
|
+
'before_tool_call',
|
|
230
|
+
(event): BeforeToolCallResult | undefined => {
|
|
231
|
+
if (event.toolName !== 'tweetclaw') {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const params = toolCallParams(event);
|
|
236
|
+
if (params === undefined || !requiresTweetclawApproval(params)) {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
requireApproval: {
|
|
242
|
+
description:
|
|
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.',
|
|
244
|
+
pluginId: 'tweetclaw',
|
|
245
|
+
severity: 'warning',
|
|
246
|
+
timeoutBehavior: 'deny',
|
|
247
|
+
timeoutMs: 60_000,
|
|
248
|
+
title: 'Approve TweetClaw Action',
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
},
|
|
252
|
+
{ priority: 50 },
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function register(api: OpenClawApi, fetchFunction?: FetchFunction): void {
|
|
76
257
|
const config: unknown = api.pluginConfig;
|
|
77
258
|
if (!isPluginConfig(config)) {
|
|
78
259
|
api.logger.warn(
|
|
@@ -98,14 +279,18 @@ export default function register(api: OpenClawApi, fetchFunction?: FetchFunction
|
|
|
98
279
|
}
|
|
99
280
|
|
|
100
281
|
const request = createProxiedRequest(baseUrl, credential, fetchFunction);
|
|
282
|
+
registerWriteApprovalHook(api);
|
|
101
283
|
|
|
102
284
|
// --- Tools (2-tool approach, execute inside tool object) ---
|
|
103
285
|
api.registerTool(
|
|
104
286
|
{
|
|
105
287
|
description: SEARCH_DESCRIPTION,
|
|
106
|
-
execute: async (_toolCallId,
|
|
288
|
+
execute: async (_toolCallId, params) => {
|
|
289
|
+
await Promise.resolve();
|
|
290
|
+
return handleExplore(asExploreParams(params));
|
|
291
|
+
},
|
|
107
292
|
name: 'explore',
|
|
108
|
-
parameters:
|
|
293
|
+
parameters: EXPLORE_PARAMETERS,
|
|
109
294
|
},
|
|
110
295
|
{ name: 'explore' },
|
|
111
296
|
);
|
|
@@ -113,9 +298,15 @@ export default function register(api: OpenClawApi, fetchFunction?: FetchFunction
|
|
|
113
298
|
api.registerTool(
|
|
114
299
|
{
|
|
115
300
|
description: EXECUTE_DESCRIPTION,
|
|
116
|
-
execute: async (_toolCallId,
|
|
301
|
+
execute: async (_toolCallId, params) => handleTweetclaw({
|
|
302
|
+
apiKey: credential,
|
|
303
|
+
baseUrl,
|
|
304
|
+
fetchFunction,
|
|
305
|
+
mppMode: isMppMode,
|
|
306
|
+
params: asTweetclawParams(params),
|
|
307
|
+
}),
|
|
117
308
|
name: 'tweetclaw',
|
|
118
|
-
parameters:
|
|
309
|
+
parameters: TWEETCLAW_PARAMETERS,
|
|
119
310
|
},
|
|
120
311
|
{ name: 'tweetclaw', optional: true },
|
|
121
312
|
);
|
|
@@ -170,3 +361,14 @@ export default function register(api: OpenClawApi, fetchFunction?: FetchFunction
|
|
|
170
361
|
|
|
171
362
|
api.logger.info('TweetClaw: Plugin registered successfully');
|
|
172
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 };
|