@xquik/tweetclaw 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/openclaw.plugin.json +24 -0
- package/package.json +64 -0
- package/src/api-spec.ts +1024 -0
- package/src/commands/xstatus.ts +76 -0
- package/src/commands/xtrends.ts +59 -0
- package/src/index.ts +153 -0
- package/src/request.ts +62 -0
- package/src/services/event-poller.ts +97 -0
- package/src/tools/explore.ts +59 -0
- package/src/tools/sandbox.ts +58 -0
- package/src/tools/tweetclaw.ts +213 -0
- package/src/truncate.ts +28 -0
- package/src/types.ts +56 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { RequestFunction } from '../types.js';
|
|
2
|
+
|
|
3
|
+
interface AccountResponse {
|
|
4
|
+
readonly email?: string;
|
|
5
|
+
readonly locale?: string;
|
|
6
|
+
readonly subscription?: {
|
|
7
|
+
readonly isActive?: boolean;
|
|
8
|
+
readonly plan?: string;
|
|
9
|
+
};
|
|
10
|
+
readonly usage?: {
|
|
11
|
+
readonly percent?: number;
|
|
12
|
+
readonly remaining?: number;
|
|
13
|
+
};
|
|
14
|
+
readonly xUsername?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isAccountResponse(value: unknown): value is AccountResponse {
|
|
18
|
+
return typeof value === 'object' && value !== null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatSubscriptionLine(subscription: AccountResponse['subscription']): string {
|
|
22
|
+
if (subscription === undefined) {
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
const status = subscription.isActive === true ? 'Active' : 'Inactive';
|
|
26
|
+
const planSuffix = subscription.plan === undefined ? '' : ` (${subscription.plan})`;
|
|
27
|
+
return `Subscription: ${status}${planSuffix}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatUsageLines(usage: AccountResponse['usage']): readonly string[] {
|
|
31
|
+
if (usage === undefined) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
const lines: string[] = [];
|
|
35
|
+
if (usage.percent !== undefined) {
|
|
36
|
+
lines.push(`Usage: ${String(usage.percent)}%`);
|
|
37
|
+
}
|
|
38
|
+
if (usage.remaining !== undefined) {
|
|
39
|
+
lines.push(`Remaining: ${String(usage.remaining)}`);
|
|
40
|
+
}
|
|
41
|
+
return lines;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatAccountStatus(account: AccountResponse): string {
|
|
45
|
+
const lines: string[] = [];
|
|
46
|
+
lines.push('--- Xquik Account Status ---');
|
|
47
|
+
|
|
48
|
+
if (account.xUsername !== undefined) {
|
|
49
|
+
lines.push(`X Account: @${account.xUsername}`);
|
|
50
|
+
}
|
|
51
|
+
if (account.email !== undefined) {
|
|
52
|
+
lines.push(`Email: ${account.email}`);
|
|
53
|
+
}
|
|
54
|
+
if (account.locale !== undefined) {
|
|
55
|
+
lines.push(`Locale: ${account.locale}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const subscriptionLine = formatSubscriptionLine(account.subscription);
|
|
59
|
+
if (subscriptionLine.length > 0) {
|
|
60
|
+
lines.push(subscriptionLine);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
lines.push(...formatUsageLines(account.usage));
|
|
64
|
+
|
|
65
|
+
return lines.join('\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function handleXStatus(request: RequestFunction): Promise<string> {
|
|
69
|
+
const result: unknown = await request('/api/v1/account');
|
|
70
|
+
if (!isAccountResponse(result)) {
|
|
71
|
+
return '--- Xquik Account Status ---';
|
|
72
|
+
}
|
|
73
|
+
return formatAccountStatus(result);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export { formatAccountStatus, handleXStatus };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { RequestFunction } from '../types.js';
|
|
2
|
+
|
|
3
|
+
interface TrendItem {
|
|
4
|
+
readonly category?: string;
|
|
5
|
+
readonly publishedAt?: string;
|
|
6
|
+
readonly score?: number;
|
|
7
|
+
readonly source?: string;
|
|
8
|
+
readonly title: string;
|
|
9
|
+
readonly url?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface RadarResponse {
|
|
13
|
+
readonly items: readonly TrendItem[];
|
|
14
|
+
readonly total: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isRadarResponse(value: unknown): value is RadarResponse {
|
|
18
|
+
return typeof value === 'object' && value !== null && 'items' in value && 'total' in value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatTrends(radar: RadarResponse): string {
|
|
22
|
+
const lines: string[] = [];
|
|
23
|
+
lines.push(`--- Trending Topics (${String(radar.total)} items) ---`);
|
|
24
|
+
|
|
25
|
+
for (const [index, item] of radar.items.entries()) {
|
|
26
|
+
const position = String(index + 1);
|
|
27
|
+
let line = `${position}. ${item.title}`;
|
|
28
|
+
if (item.source !== undefined) {
|
|
29
|
+
line += ` [${item.source}]`;
|
|
30
|
+
}
|
|
31
|
+
if (item.score !== undefined) {
|
|
32
|
+
line += ` (score: ${String(item.score)})`;
|
|
33
|
+
}
|
|
34
|
+
if (item.url !== undefined) {
|
|
35
|
+
line += `\n ${item.url}`;
|
|
36
|
+
}
|
|
37
|
+
lines.push(line);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return lines.join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function handleXTrends(request: RequestFunction, categoryFilter?: string): Promise<string> {
|
|
44
|
+
const query: Record<string, string> = {};
|
|
45
|
+
if (categoryFilter !== undefined && categoryFilter.length > 0) {
|
|
46
|
+
const trimmed = categoryFilter.trim();
|
|
47
|
+
if (trimmed.length > 0) {
|
|
48
|
+
query.category = trimmed;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const hasQuery = Object.keys(query).length > 0;
|
|
52
|
+
const result: unknown = await request('/api/v1/radar', hasQuery ? { query } : undefined);
|
|
53
|
+
if (!isRadarResponse(result)) {
|
|
54
|
+
return '--- Trending Topics (0 items) ---';
|
|
55
|
+
}
|
|
56
|
+
return formatTrends(result);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { formatTrends, handleXTrends };
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { handleXStatus } from './commands/xstatus.js';
|
|
2
|
+
import { handleXTrends } from './commands/xtrends.js';
|
|
3
|
+
import { createProxiedRequest } from './request.js';
|
|
4
|
+
import { createEventPoller } from './services/event-poller.js';
|
|
5
|
+
import { handleExplore, SEARCH_DESCRIPTION } from './tools/explore.js';
|
|
6
|
+
import { EXECUTE_DESCRIPTION, handleTweetclaw } from './tools/tweetclaw.js';
|
|
7
|
+
import type { FetchFunction, PluginConfig } from './types.js';
|
|
8
|
+
|
|
9
|
+
interface PollerEvent {
|
|
10
|
+
readonly eventType?: string;
|
|
11
|
+
readonly xUsername?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isPollerEvent(value: unknown): value is PollerEvent {
|
|
15
|
+
return typeof value === 'object' && value !== null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DEFAULT_POLLING_INTERVAL_SECONDS = 60;
|
|
19
|
+
|
|
20
|
+
interface CommandContext {
|
|
21
|
+
readonly args?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface OpenClawApi {
|
|
25
|
+
readonly config: {
|
|
26
|
+
readonly plugins?: {
|
|
27
|
+
readonly entries?: {
|
|
28
|
+
readonly tweetclaw?: {
|
|
29
|
+
readonly config?: PluginConfig;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
readonly logger: {
|
|
35
|
+
readonly info: (message: string) => void;
|
|
36
|
+
readonly warn: (message: string) => void;
|
|
37
|
+
};
|
|
38
|
+
readonly registerCommand: (options: {
|
|
39
|
+
readonly acceptsArguments?: boolean;
|
|
40
|
+
readonly description: string;
|
|
41
|
+
readonly handler: (context: CommandContext) => Promise<{ readonly text: string }>;
|
|
42
|
+
readonly name: string;
|
|
43
|
+
}) => void;
|
|
44
|
+
readonly registerService: (options: {
|
|
45
|
+
readonly id: string;
|
|
46
|
+
readonly start: () => void;
|
|
47
|
+
readonly stop: () => void;
|
|
48
|
+
}) => void;
|
|
49
|
+
readonly registerTool: (
|
|
50
|
+
options: {
|
|
51
|
+
readonly description: string;
|
|
52
|
+
readonly name: string;
|
|
53
|
+
readonly parameters: {
|
|
54
|
+
readonly properties: Readonly<Record<string, { readonly description: string; readonly type: string }>>;
|
|
55
|
+
readonly required: readonly string[];
|
|
56
|
+
readonly type: string;
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
handler: (params: { readonly code: string }) => Promise<{
|
|
60
|
+
readonly content: ReadonlyArray<{ readonly text: string; readonly type: string }>;
|
|
61
|
+
readonly isError?: true;
|
|
62
|
+
}>,
|
|
63
|
+
) => void;
|
|
64
|
+
readonly sendMessage: (text: string) => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const CODE_PARAMETER = {
|
|
68
|
+
properties: {
|
|
69
|
+
code: { description: 'Async arrow function to execute', type: 'string' },
|
|
70
|
+
},
|
|
71
|
+
required: ['code'] as const,
|
|
72
|
+
type: 'object',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export default function register(api: OpenClawApi, fetchFunction?: FetchFunction): void {
|
|
76
|
+
const config = api.config.plugins?.entries?.tweetclaw?.config;
|
|
77
|
+
if (config?.apiKey === undefined) {
|
|
78
|
+
api.logger.warn(
|
|
79
|
+
"TweetClaw: No API key configured. Run: openclaw config set plugins.entries.tweetclaw.config.apiKey 'xq_YOUR_KEY'",
|
|
80
|
+
);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const { apiKey, baseUrl = 'https://xquik.com' } = config;
|
|
85
|
+
const request = createProxiedRequest(baseUrl, apiKey, fetchFunction);
|
|
86
|
+
|
|
87
|
+
// --- Tools (Cloudflare Code Mode pattern) ---
|
|
88
|
+
api.registerTool(
|
|
89
|
+
{
|
|
90
|
+
description: SEARCH_DESCRIPTION,
|
|
91
|
+
name: 'explore',
|
|
92
|
+
parameters: CODE_PARAMETER,
|
|
93
|
+
},
|
|
94
|
+
async ({ code }) => handleExplore(code),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
api.registerTool(
|
|
98
|
+
{
|
|
99
|
+
description: EXECUTE_DESCRIPTION,
|
|
100
|
+
name: 'tweetclaw',
|
|
101
|
+
parameters: CODE_PARAMETER,
|
|
102
|
+
},
|
|
103
|
+
async ({ code }) => handleTweetclaw({ apiKey, baseUrl, code, fetchFunction }),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// --- Commands (instant, no LLM) ---
|
|
107
|
+
api.registerCommand({
|
|
108
|
+
description: 'Show Xquik account status & usage',
|
|
109
|
+
handler: async () => {
|
|
110
|
+
const text = await handleXStatus(request);
|
|
111
|
+
return { text };
|
|
112
|
+
},
|
|
113
|
+
name: 'xstatus',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
api.registerCommand({
|
|
117
|
+
acceptsArguments: true,
|
|
118
|
+
description: 'Show trending topics on X',
|
|
119
|
+
handler: async ({ args }) => {
|
|
120
|
+
const text = await handleXTrends(request, args);
|
|
121
|
+
return { text };
|
|
122
|
+
},
|
|
123
|
+
name: 'xtrends',
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// --- Background event poller ---
|
|
127
|
+
const { pollingEnabled, pollingInterval } = config;
|
|
128
|
+
if (pollingEnabled !== false) {
|
|
129
|
+
const poller = createEventPoller({
|
|
130
|
+
intervalSeconds: pollingInterval ?? DEFAULT_POLLING_INTERVAL_SECONDS,
|
|
131
|
+
onEvents: (events) => {
|
|
132
|
+
for (const event of events) {
|
|
133
|
+
const eventType: string = isPollerEvent(event) && typeof event.eventType === 'string'
|
|
134
|
+
? event.eventType
|
|
135
|
+
: 'unknown';
|
|
136
|
+
const username: string = isPollerEvent(event) && typeof event.xUsername === 'string'
|
|
137
|
+
? event.xUsername
|
|
138
|
+
: '';
|
|
139
|
+
api.sendMessage(`[TweetClaw] ${eventType} from @${username}`);
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
request,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
api.registerService({
|
|
146
|
+
id: 'tweetclaw-poller',
|
|
147
|
+
start: () => { poller.start(); },
|
|
148
|
+
stop: () => { poller.stop(); },
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
api.logger.info('TweetClaw: Plugin registered successfully');
|
|
153
|
+
}
|
package/src/request.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { FetchFunction, RequestFunction, RequestOptions } from './types.js';
|
|
2
|
+
|
|
3
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
4
|
+
const CONTENT_TYPE_HEADER = 'content-type';
|
|
5
|
+
const API_KEY_HEADER = 'x-api-key';
|
|
6
|
+
const AUTHORIZATION_HEADER = 'authorization';
|
|
7
|
+
const BEARER_PREFIX = 'Bearer ';
|
|
8
|
+
const API_KEY_PREFIX = 'xq_';
|
|
9
|
+
const API_V1_PREFIX = '/api/v1/';
|
|
10
|
+
|
|
11
|
+
function buildAuthHeader(credential: string): Record<string, string> {
|
|
12
|
+
if (credential.startsWith(API_KEY_PREFIX)) {
|
|
13
|
+
return { [API_KEY_HEADER]: credential };
|
|
14
|
+
}
|
|
15
|
+
return { [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${credential}` };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function buildFetchHeaders(credential: string, hasBody: boolean): Record<string, string> {
|
|
19
|
+
const auth = buildAuthHeader(credential);
|
|
20
|
+
if (hasBody) {
|
|
21
|
+
return { ...auth, [CONTENT_TYPE_HEADER]: 'application/json' };
|
|
22
|
+
}
|
|
23
|
+
return auth;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildFetchUrl(baseUrl: string, path: string, query?: Readonly<Record<string, string>>): string {
|
|
27
|
+
const url = new URL(path, baseUrl);
|
|
28
|
+
if (query !== undefined) {
|
|
29
|
+
for (const [key, value] of Object.entries(query)) {
|
|
30
|
+
url.searchParams.set(key, value);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return url.toString();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createProxiedRequest(
|
|
37
|
+
baseUrl: string,
|
|
38
|
+
apiKey: string,
|
|
39
|
+
fetchFunction: FetchFunction = fetch,
|
|
40
|
+
): RequestFunction {
|
|
41
|
+
return async (path: string, options?: Readonly<RequestOptions>): Promise<unknown> => {
|
|
42
|
+
if (!path.startsWith(API_V1_PREFIX)) {
|
|
43
|
+
throw new Error(`Path must start with /api/v1/ but got: ${path}`);
|
|
44
|
+
}
|
|
45
|
+
const hasBody = options?.body !== undefined;
|
|
46
|
+
const response = await fetchFunction(buildFetchUrl(baseUrl, path, options?.query), {
|
|
47
|
+
...(hasBody ? { body: JSON.stringify(options.body) } : {}),
|
|
48
|
+
headers: buildFetchHeaders(apiKey, hasBody),
|
|
49
|
+
method: options?.method ?? 'GET',
|
|
50
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
51
|
+
});
|
|
52
|
+
const json: unknown = await response.json();
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`API request failed: ${String(response.status)} ${response.statusText} - ${JSON.stringify(json)}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return json;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { buildAuthHeader, buildFetchHeaders, buildFetchUrl, createProxiedRequest };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { EventPollerOptions } from '../types.js';
|
|
2
|
+
|
|
3
|
+
const MAX_BACKOFF_SECONDS = 300;
|
|
4
|
+
const BACKOFF_BASE = 2;
|
|
5
|
+
const MS_PER_SECOND = 1000;
|
|
6
|
+
|
|
7
|
+
interface EventsResponse {
|
|
8
|
+
readonly events: ReadonlyArray<Record<string, unknown>>;
|
|
9
|
+
readonly hasMore?: boolean;
|
|
10
|
+
readonly nextCursor?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isEventsResponse(value: unknown): value is EventsResponse {
|
|
14
|
+
return typeof value === 'object' && value !== null && 'events' in value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface EventPollerHandle {
|
|
18
|
+
readonly start: () => void;
|
|
19
|
+
readonly stop: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function extractCursor(events: ReadonlyArray<Readonly<Record<string, unknown>>>): string | undefined {
|
|
23
|
+
const lastEvent = events.at(-1);
|
|
24
|
+
return typeof lastEvent?.id === 'string' ? lastEvent.id : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createEventPoller(options: EventPollerOptions): EventPollerHandle {
|
|
28
|
+
let timer: ReturnType<typeof setTimeout> | undefined = undefined;
|
|
29
|
+
let cursor: string | undefined = undefined;
|
|
30
|
+
let consecutiveErrors = 0;
|
|
31
|
+
let stopped = false;
|
|
32
|
+
|
|
33
|
+
async function poll(): Promise<void> {
|
|
34
|
+
try {
|
|
35
|
+
const query: Record<string, string> = {};
|
|
36
|
+
if (cursor !== undefined) {
|
|
37
|
+
query.after = cursor;
|
|
38
|
+
}
|
|
39
|
+
const hasQuery = Object.keys(query).length > 0;
|
|
40
|
+
const result: unknown = await options.request(
|
|
41
|
+
'/api/v1/events',
|
|
42
|
+
hasQuery ? { query } : undefined,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (!isEventsResponse(result)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (result.events.length > 0) {
|
|
50
|
+
options.onEvents(result.events);
|
|
51
|
+
const newCursor = extractCursor(result.events);
|
|
52
|
+
if (newCursor !== undefined) {
|
|
53
|
+
cursor = newCursor;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
consecutiveErrors = 0;
|
|
58
|
+
} catch {
|
|
59
|
+
consecutiveErrors += 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getNextInterval(): number {
|
|
64
|
+
if (consecutiveErrors === 0) {
|
|
65
|
+
return options.intervalSeconds * MS_PER_SECOND;
|
|
66
|
+
}
|
|
67
|
+
const backoffSeconds = Math.min(
|
|
68
|
+
Math.pow(BACKOFF_BASE, consecutiveErrors) * options.intervalSeconds,
|
|
69
|
+
MAX_BACKOFF_SECONDS,
|
|
70
|
+
);
|
|
71
|
+
return backoffSeconds * MS_PER_SECOND;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function loop(): Promise<void> {
|
|
75
|
+
await poll();
|
|
76
|
+
if (stopped) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
timer = setTimeout(() => { void loop(); }, getNextInterval());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
start(): void {
|
|
84
|
+
stopped = false;
|
|
85
|
+
timer = setTimeout(() => { void loop(); }, options.intervalSeconds * MS_PER_SECOND);
|
|
86
|
+
},
|
|
87
|
+
stop(): void {
|
|
88
|
+
stopped = true;
|
|
89
|
+
if (timer !== undefined) {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
timer = undefined;
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export { createEventPoller };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { API_SPEC } from '../api-spec.js';
|
|
2
|
+
import { AsyncFunction, errorResult, specEndpoints, successResult } from './sandbox.js';
|
|
3
|
+
import type { EndpointInfo, ToolResult } from '../types.js';
|
|
4
|
+
|
|
5
|
+
const categories = [...new Set(API_SPEC.map((endpoint) => endpoint.category))].toSorted((a, b) => a.localeCompare(b)).join(', ');
|
|
6
|
+
|
|
7
|
+
const SEARCH_DESCRIPTION = `Search the X (Twitter) API spec for endpoints: tweet search, user lookup, media download, monitoring, giveaways, composition, and more. No network calls - runs against an in-memory endpoint catalog.
|
|
8
|
+
|
|
9
|
+
Write an async arrow function. The sandbox provides:
|
|
10
|
+
|
|
11
|
+
\`\`\`typescript
|
|
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
|
+
\`\`\``;
|
|
47
|
+
|
|
48
|
+
async function handleExplore(code: string): Promise<ToolResult> {
|
|
49
|
+
try {
|
|
50
|
+
const executor = new AsyncFunction('spec', `return (${code})()`);
|
|
51
|
+
const result: unknown = await executor({ endpoints: specEndpoints });
|
|
52
|
+
return successResult(result);
|
|
53
|
+
} catch (error: unknown) {
|
|
54
|
+
return errorResult(error);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { handleExplore, SEARCH_DESCRIPTION };
|
|
59
|
+
export type { EndpointInfo };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { API_SPEC } from '../api-spec.js';
|
|
2
|
+
import { truncateResponse } from '../truncate.js';
|
|
3
|
+
import type { ToolResult } from '../types.js';
|
|
4
|
+
|
|
5
|
+
const specEndpoints: ReadonlyArray<Readonly<Record<string, unknown>>> = API_SPEC.map(
|
|
6
|
+
(endpoint): Readonly<Record<string, unknown>> => ({ ...endpoint }),
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
function extractErrorMessage(error: unknown): string {
|
|
10
|
+
if (error instanceof Error) {
|
|
11
|
+
return `${error.constructor.name}: ${error.message}`;
|
|
12
|
+
}
|
|
13
|
+
return String(error);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isAsyncFunctionConstructor(
|
|
17
|
+
value: unknown,
|
|
18
|
+
): value is new (...parameters: readonly string[]) => (...parameters: readonly unknown[]) => Promise<unknown> {
|
|
19
|
+
return typeof value === 'function';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getConstructorFromPrototype(proto: unknown): unknown {
|
|
23
|
+
if (typeof proto !== 'object' || proto === null) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return 'constructor' in proto ? proto.constructor : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveAsyncFunctionConstructor(prototype?: unknown): new (
|
|
30
|
+
...parameters: readonly string[]
|
|
31
|
+
) => (...parameters: readonly unknown[]) => Promise<unknown> {
|
|
32
|
+
const asyncPrototype: unknown = prototype ?? Object.getPrototypeOf(async (): Promise<void> => {});
|
|
33
|
+
const candidate: unknown = getConstructorFromPrototype(asyncPrototype);
|
|
34
|
+
if (!isAsyncFunctionConstructor(candidate)) {
|
|
35
|
+
throw new Error('AsyncFunction constructor not found');
|
|
36
|
+
}
|
|
37
|
+
return candidate;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const AsyncFunction = resolveAsyncFunctionConstructor();
|
|
41
|
+
|
|
42
|
+
function successResult(content: unknown): ToolResult {
|
|
43
|
+
return { content: [{ text: truncateResponse(content), type: 'text' as const }] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function errorResult(error: unknown): ToolResult {
|
|
47
|
+
return { content: [{ text: extractErrorMessage(error), type: 'text' as const }], isError: true };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export {
|
|
51
|
+
AsyncFunction,
|
|
52
|
+
errorResult,
|
|
53
|
+
extractErrorMessage,
|
|
54
|
+
getConstructorFromPrototype,
|
|
55
|
+
resolveAsyncFunctionConstructor,
|
|
56
|
+
specEndpoints,
|
|
57
|
+
successResult,
|
|
58
|
+
};
|