xbird-mcp 0.1.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.
Potentially problematic release.
This version of xbird-mcp might be problematic. Click here for more details.
- package/docs/listing.md +42 -0
- package/docs/mcp-setup.md +146 -0
- package/package.json +50 -0
- package/src/cli/context.ts +80 -0
- package/src/cli/output.ts +33 -0
- package/src/cli/program.ts +63 -0
- package/src/cli.ts +8 -0
- package/src/commands/about.ts +30 -0
- package/src/commands/bookmark.ts +40 -0
- package/src/commands/bookmarks-list.ts +108 -0
- package/src/commands/check.ts +27 -0
- package/src/commands/engagement.ts +31 -0
- package/src/commands/follow.ts +40 -0
- package/src/commands/followers.ts +132 -0
- package/src/commands/home.ts +59 -0
- package/src/commands/likes.ts +73 -0
- package/src/commands/lists.ts +101 -0
- package/src/commands/mentions.ts +32 -0
- package/src/commands/news.ts +38 -0
- package/src/commands/read.ts +36 -0
- package/src/commands/replies.ts +59 -0
- package/src/commands/search.ts +60 -0
- package/src/commands/tweet.ts +128 -0
- package/src/commands/user-tweets.ts +73 -0
- package/src/commands/user.ts +20 -0
- package/src/commands/whoami.ts +26 -0
- package/src/formatters/common.ts +49 -0
- package/src/formatters/list.ts +22 -0
- package/src/formatters/news.ts +23 -0
- package/src/formatters/tweet.ts +38 -0
- package/src/formatters/user.ts +20 -0
- package/src/index.ts +26 -0
- package/src/lib/auth.ts +105 -0
- package/src/lib/client-bookmarks.ts +62 -0
- package/src/lib/client-engagement.ts +152 -0
- package/src/lib/client-follow.ts +90 -0
- package/src/lib/client-full.ts +48 -0
- package/src/lib/client-likes.ts +62 -0
- package/src/lib/client-lists.ts +227 -0
- package/src/lib/client-media.ts +162 -0
- package/src/lib/client-news.ts +185 -0
- package/src/lib/client-posting.ts +163 -0
- package/src/lib/client-reading.ts +452 -0
- package/src/lib/client-search.ts +156 -0
- package/src/lib/client-timeline.ts +98 -0
- package/src/lib/client-user.ts +518 -0
- package/src/lib/client.ts +134 -0
- package/src/lib/config.ts +22 -0
- package/src/lib/constants.ts +55 -0
- package/src/lib/cookies.ts +132 -0
- package/src/lib/features.ts +175 -0
- package/src/lib/headers.ts +28 -0
- package/src/lib/paginate.ts +39 -0
- package/src/lib/query-ids.ts +190 -0
- package/src/lib/types.ts +147 -0
- package/src/lib/utils.ts +176 -0
- package/src/mcp/executor.ts +178 -0
- package/src/mcp/payment.ts +38 -0
- package/src/mcp/server.ts +53 -0
- package/src/mcp/tools.ts +389 -0
- package/src/server/app.ts +117 -0
- package/src/server/config/accounts.ts +137 -0
- package/src/server/config/pricing.ts +217 -0
- package/src/server/erc8004/register.ts +77 -0
- package/src/server/middleware/account-pool.ts +101 -0
- package/src/server/middleware/error-handler.ts +27 -0
- package/src/server/middleware/payer-extract.ts +43 -0
- package/src/server/middleware/x402.ts +61 -0
- package/src/server/routes/accounts.ts +93 -0
- package/src/server/routes/authorize.ts +19 -0
- package/src/server/routes/bookmarks.ts +21 -0
- package/src/server/routes/engagement.ts +84 -0
- package/src/server/routes/follow.ts +32 -0
- package/src/server/routes/health.ts +14 -0
- package/src/server/routes/lists.ts +38 -0
- package/src/server/routes/media.ts +44 -0
- package/src/server/routes/mentions.ts +20 -0
- package/src/server/routes/news.ts +23 -0
- package/src/server/routes/search.ts +26 -0
- package/src/server/routes/timeline.ts +21 -0
- package/src/server/routes/tweets.ts +82 -0
- package/src/server/routes/users.ts +92 -0
- package/src/server/storage/accounts-db.ts +84 -0
- package/src/server.ts +34 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { TwitterClient } from "./client.ts";
|
|
2
|
+
import { TWITTER_UPLOAD_URL } from "./constants.ts";
|
|
3
|
+
import { buildBaseHeaders } from "./headers.ts";
|
|
4
|
+
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
6
|
+
type Constructor<T = TwitterClient> = new (...args: any[]) => T;
|
|
7
|
+
|
|
8
|
+
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
|
|
9
|
+
const MAX_STATUS_POLLS = 20;
|
|
10
|
+
|
|
11
|
+
export function withMedia<T extends Constructor>(Base: T) {
|
|
12
|
+
return class extends Base {
|
|
13
|
+
async uploadMedia(opts: {
|
|
14
|
+
data: Uint8Array | Buffer;
|
|
15
|
+
mimeType: string;
|
|
16
|
+
alt?: string;
|
|
17
|
+
}): Promise<{ success: boolean; mediaId?: string; error?: string }> {
|
|
18
|
+
try {
|
|
19
|
+
const headers = buildBaseHeaders(this.credentials);
|
|
20
|
+
const totalBytes = opts.data.length;
|
|
21
|
+
|
|
22
|
+
// Determine media category from mime type
|
|
23
|
+
const category = opts.mimeType.startsWith("video/")
|
|
24
|
+
? "tweet_video"
|
|
25
|
+
: opts.mimeType === "image/gif"
|
|
26
|
+
? "tweet_gif"
|
|
27
|
+
: "tweet_image";
|
|
28
|
+
|
|
29
|
+
// INIT
|
|
30
|
+
const initParams = new URLSearchParams({
|
|
31
|
+
command: "INIT",
|
|
32
|
+
total_bytes: String(totalBytes),
|
|
33
|
+
media_type: opts.mimeType,
|
|
34
|
+
media_category: category,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const initRes = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { ...headers, "content-type": "application/x-www-form-urlencoded" },
|
|
40
|
+
body: initParams.toString(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!initRes.ok) {
|
|
44
|
+
const text = await initRes.text();
|
|
45
|
+
return { success: false, error: `INIT failed: HTTP ${initRes.status}: ${text.slice(0, 200)}` };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const initJson = (await initRes.json()) as Record<string, unknown>;
|
|
49
|
+
const mediaId = String(initJson.media_id_string ?? initJson.media_id ?? "");
|
|
50
|
+
|
|
51
|
+
if (!mediaId) {
|
|
52
|
+
return { success: false, error: "No media_id in INIT response" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// APPEND — upload in chunks
|
|
56
|
+
let segmentIndex = 0;
|
|
57
|
+
for (let offset = 0; offset < totalBytes; offset += CHUNK_SIZE) {
|
|
58
|
+
const chunk = opts.data.slice(offset, offset + CHUNK_SIZE);
|
|
59
|
+
const formData = new FormData();
|
|
60
|
+
formData.append("command", "APPEND");
|
|
61
|
+
formData.append("media_id", mediaId);
|
|
62
|
+
formData.append("segment_index", String(segmentIndex));
|
|
63
|
+
formData.append("media", new Blob([chunk], { type: opts.mimeType }));
|
|
64
|
+
|
|
65
|
+
const appendRes = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers,
|
|
68
|
+
body: formData,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (!appendRes.ok) {
|
|
72
|
+
const text = await appendRes.text();
|
|
73
|
+
return { success: false, error: `APPEND failed (segment ${segmentIndex}): HTTP ${appendRes.status}: ${text.slice(0, 200)}` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
segmentIndex++;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// FINALIZE
|
|
80
|
+
const finalizeParams = new URLSearchParams({
|
|
81
|
+
command: "FINALIZE",
|
|
82
|
+
media_id: mediaId,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const finalizeRes = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { ...headers, "content-type": "application/x-www-form-urlencoded" },
|
|
88
|
+
body: finalizeParams.toString(),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (!finalizeRes.ok) {
|
|
92
|
+
const text = await finalizeRes.text();
|
|
93
|
+
return { success: false, error: `FINALIZE failed: HTTP ${finalizeRes.status}: ${text.slice(0, 200)}` };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const finalizeJson = (await finalizeRes.json()) as Record<string, unknown>;
|
|
97
|
+
const processingInfo = finalizeJson.processing_info as Record<string, unknown> | undefined;
|
|
98
|
+
|
|
99
|
+
// Poll STATUS if processing is required (videos)
|
|
100
|
+
if (processingInfo) {
|
|
101
|
+
let state = String(processingInfo.state ?? "");
|
|
102
|
+
let checkAfterMs = Number(processingInfo.check_after_secs ?? 5) * 1000;
|
|
103
|
+
let polls = 0;
|
|
104
|
+
|
|
105
|
+
while (state !== "succeeded" && state !== "failed" && polls < MAX_STATUS_POLLS) {
|
|
106
|
+
await new Promise((r) => setTimeout(r, checkAfterMs));
|
|
107
|
+
polls++;
|
|
108
|
+
|
|
109
|
+
const statusUrl = `${TWITTER_UPLOAD_URL}?command=STATUS&media_id=${mediaId}`;
|
|
110
|
+
const statusRes = await this.fetchWithTimeout(statusUrl, {
|
|
111
|
+
method: "GET",
|
|
112
|
+
headers,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (!statusRes.ok) {
|
|
116
|
+
return { success: false, error: `STATUS poll failed: HTTP ${statusRes.status}` };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const statusJson = (await statusRes.json()) as Record<string, unknown>;
|
|
120
|
+
const info = statusJson.processing_info as Record<string, unknown> | undefined;
|
|
121
|
+
state = String(info?.state ?? "succeeded");
|
|
122
|
+
checkAfterMs = Number(info?.check_after_secs ?? 5) * 1000;
|
|
123
|
+
|
|
124
|
+
if (state === "failed") {
|
|
125
|
+
const errorInfo = info?.error as Record<string, unknown> | undefined;
|
|
126
|
+
return {
|
|
127
|
+
success: false,
|
|
128
|
+
error: `Media processing failed: ${errorInfo?.message ?? "unknown error"}`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Set alt text if provided
|
|
135
|
+
if (opts.alt) {
|
|
136
|
+
const altRes = await this.fetchWithTimeout(
|
|
137
|
+
"https://x.com/i/api/1.1/media/metadata/create.json",
|
|
138
|
+
{
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
141
|
+
body: JSON.stringify({
|
|
142
|
+
media_id: mediaId,
|
|
143
|
+
alt_text: { text: opts.alt },
|
|
144
|
+
}),
|
|
145
|
+
}
|
|
146
|
+
);
|
|
147
|
+
// Alt text failure is non-fatal
|
|
148
|
+
if (!altRes.ok) {
|
|
149
|
+
console.error(`Warning: Failed to set alt text: HTTP ${altRes.status}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { success: true, mediaId };
|
|
154
|
+
} catch (err) {
|
|
155
|
+
return {
|
|
156
|
+
success: false,
|
|
157
|
+
error: err instanceof Error ? err.message : String(err),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { TwitterClient } from "./client.ts";
|
|
2
|
+
import { newsFeatures } from "./features.ts";
|
|
3
|
+
import { TWITTER_V1_BASE } from "./constants.ts";
|
|
4
|
+
import type { NewsItem } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
|
+
type Constructor<T = TwitterClient> = new (...args: any[]) => T;
|
|
8
|
+
|
|
9
|
+
/** Timeline IDs for GenericTimelineById GraphQL */
|
|
10
|
+
const TIMELINE_IDS: Record<string, string> = {
|
|
11
|
+
trending: "trends_home",
|
|
12
|
+
forYou: "trending",
|
|
13
|
+
news: "news",
|
|
14
|
+
sports: "sports",
|
|
15
|
+
entertainment: "entertainment",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function parseNewsFromGraphQL(instructions: unknown[]): NewsItem[] {
|
|
19
|
+
const items: NewsItem[] = [];
|
|
20
|
+
|
|
21
|
+
for (const inst of instructions ?? []) {
|
|
22
|
+
const instruction = inst as Record<string, unknown>;
|
|
23
|
+
const entries = (instruction.entries ?? []) as Array<Record<string, unknown>>;
|
|
24
|
+
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
const content = entry.content as Record<string, unknown> | undefined;
|
|
27
|
+
const itemContent = content?.itemContent as Record<string, unknown> | undefined;
|
|
28
|
+
|
|
29
|
+
if (itemContent) {
|
|
30
|
+
const trend = itemContent.trend as Record<string, unknown> | undefined;
|
|
31
|
+
const name = trend?.name as string | undefined
|
|
32
|
+
?? itemContent.name as string | undefined
|
|
33
|
+
?? itemContent.title as string | undefined;
|
|
34
|
+
|
|
35
|
+
if (name) {
|
|
36
|
+
const socialContext = itemContent.socialContext as Record<string, unknown> | undefined
|
|
37
|
+
?? trend?.trendMetadata as Record<string, unknown> | undefined;
|
|
38
|
+
|
|
39
|
+
items.push({
|
|
40
|
+
id: String(entry.entryId ?? items.length),
|
|
41
|
+
headline: name,
|
|
42
|
+
category: String(socialContext?.text ?? socialContext?.domainContext ?? "Trending"),
|
|
43
|
+
postCount: socialContext?.metaDescription ? parsePostCount(String(socialContext.metaDescription)) : undefined,
|
|
44
|
+
description: socialContext?.metaDescription as string | undefined,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Module items (grouped trends)
|
|
50
|
+
const moduleItems = content?.items as Array<Record<string, unknown>> | undefined;
|
|
51
|
+
if (moduleItems) {
|
|
52
|
+
for (const moduleItem of moduleItems) {
|
|
53
|
+
const modContent = (moduleItem.item as Record<string, unknown>)?.itemContent as Record<string, unknown> | undefined;
|
|
54
|
+
const trend = modContent?.trend as Record<string, unknown> | undefined;
|
|
55
|
+
const name = trend?.name as string | undefined ?? modContent?.name as string | undefined;
|
|
56
|
+
|
|
57
|
+
if (name) {
|
|
58
|
+
const meta = trend?.trendMetadata as Record<string, unknown> | undefined;
|
|
59
|
+
items.push({
|
|
60
|
+
id: String((moduleItem.item as Record<string, unknown>)?.entryId ?? items.length),
|
|
61
|
+
headline: name,
|
|
62
|
+
category: String(meta?.domainContext ?? "Trending"),
|
|
63
|
+
postCount: meta?.metaDescription ? parsePostCount(String(meta.metaDescription)) : undefined,
|
|
64
|
+
description: meta?.metaDescription as string | undefined,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return items;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parsePostCount(text: string): number | undefined {
|
|
76
|
+
const match = text.match(/([\d,.]+)\s*([KkMm])?/);
|
|
77
|
+
if (!match) return undefined;
|
|
78
|
+
let num = parseFloat(match[1]!.replace(/,/g, ""));
|
|
79
|
+
const suffix = match[2]?.toUpperCase();
|
|
80
|
+
if (suffix === "K") num *= 1_000;
|
|
81
|
+
if (suffix === "M") num *= 1_000_000;
|
|
82
|
+
return Math.round(num);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function withNews<T extends Constructor>(Base: T) {
|
|
86
|
+
return class extends Base {
|
|
87
|
+
async getNews(
|
|
88
|
+
count?: number,
|
|
89
|
+
opts?: {
|
|
90
|
+
tab?: string;
|
|
91
|
+
aiOnly?: boolean;
|
|
92
|
+
withTweets?: boolean;
|
|
93
|
+
trendingOnly?: boolean;
|
|
94
|
+
}
|
|
95
|
+
): Promise<{
|
|
96
|
+
success: boolean;
|
|
97
|
+
items: NewsItem[];
|
|
98
|
+
error?: string;
|
|
99
|
+
}> {
|
|
100
|
+
const tab = opts?.trendingOnly ? "trending" : (opts?.tab ?? "trending");
|
|
101
|
+
|
|
102
|
+
// Try GraphQL first
|
|
103
|
+
try {
|
|
104
|
+
const timelineId = TIMELINE_IDS[tab] ?? TIMELINE_IDS.trending!;
|
|
105
|
+
|
|
106
|
+
const { data, errors } = await this.graphqlGet(
|
|
107
|
+
"GenericTimelineById",
|
|
108
|
+
{
|
|
109
|
+
timelineId,
|
|
110
|
+
count: count ?? 20,
|
|
111
|
+
withQuickPromoteEligibilityTweetFields: true,
|
|
112
|
+
},
|
|
113
|
+
newsFeatures()
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
if (!errors?.length && data) {
|
|
117
|
+
const timeline = (data as Record<string, unknown>)?.timeline_by_id as Record<string, unknown> | undefined
|
|
118
|
+
?? (data as Record<string, unknown>)?.timeline as Record<string, unknown> | undefined;
|
|
119
|
+
const tl = timeline?.timeline as Record<string, unknown> | undefined ?? timeline;
|
|
120
|
+
const instructions = tl?.instructions as unknown[] | undefined;
|
|
121
|
+
|
|
122
|
+
if (instructions) {
|
|
123
|
+
let items = parseNewsFromGraphQL(instructions);
|
|
124
|
+
if (opts?.aiOnly) {
|
|
125
|
+
items = items.filter(item => item.description && item.description.length > 0);
|
|
126
|
+
}
|
|
127
|
+
if (items.length > 0) return { success: true, items };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// Fall through to REST
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Fallback: REST v1.1 trends/place.json
|
|
135
|
+
const result = await this._getNewsREST(count);
|
|
136
|
+
if (opts?.aiOnly && result.success) {
|
|
137
|
+
result.items = result.items.filter(item => item.description && item.description.length > 0);
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private async _getNewsREST(
|
|
143
|
+
count?: number
|
|
144
|
+
): Promise<{
|
|
145
|
+
success: boolean;
|
|
146
|
+
items: NewsItem[];
|
|
147
|
+
error?: string;
|
|
148
|
+
}> {
|
|
149
|
+
try {
|
|
150
|
+
const url = `${TWITTER_V1_BASE}/trends/place.json?id=1`;
|
|
151
|
+
const res = await this.fetchWithTimeout(url, {
|
|
152
|
+
method: "GET",
|
|
153
|
+
headers: this.getHeaders(),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (!res.ok) {
|
|
157
|
+
return { success: false, items: [], error: `HTTP ${res.status}` };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const json = (await res.json()) as Array<Record<string, unknown>>;
|
|
161
|
+
const trendsData = json[0] as Record<string, unknown> | undefined;
|
|
162
|
+
const trends = trendsData?.trends as Array<Record<string, unknown>> | undefined;
|
|
163
|
+
|
|
164
|
+
if (!trends) return { success: true, items: [] };
|
|
165
|
+
|
|
166
|
+
const maxItems = count ?? 20;
|
|
167
|
+
const items: NewsItem[] = trends.slice(0, maxItems).map((t, i) => ({
|
|
168
|
+
id: String(i),
|
|
169
|
+
headline: String(t.name ?? ""),
|
|
170
|
+
category: "Trending",
|
|
171
|
+
postCount: t.tweet_volume != null ? Number(t.tweet_volume) : undefined,
|
|
172
|
+
url: t.url ? String(t.url) : undefined,
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
return { success: true, items };
|
|
176
|
+
} catch (err) {
|
|
177
|
+
return {
|
|
178
|
+
success: false,
|
|
179
|
+
items: [],
|
|
180
|
+
error: err instanceof Error ? err.message : String(err),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { TwitterClient } from "./client.ts";
|
|
2
|
+
import { createTweetFeatures } from "./features.ts";
|
|
3
|
+
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
5
|
+
type Constructor<T = TwitterClient> = new (...args: any[]) => T;
|
|
6
|
+
|
|
7
|
+
export function withPosting<T extends Constructor>(Base: T) {
|
|
8
|
+
return class extends Base {
|
|
9
|
+
/**
|
|
10
|
+
* Post a new tweet, optionally with media.
|
|
11
|
+
*/
|
|
12
|
+
async tweet(
|
|
13
|
+
text: string,
|
|
14
|
+
mediaIds?: string[]
|
|
15
|
+
): Promise<{ success: boolean; tweetId?: string; error?: string }> {
|
|
16
|
+
try {
|
|
17
|
+
const mediaEntities = (mediaIds ?? []).map((id) => ({
|
|
18
|
+
media_id: id,
|
|
19
|
+
tagged_users: [],
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const { data, errors } = await this.graphqlPost(
|
|
23
|
+
"CreateTweet",
|
|
24
|
+
{
|
|
25
|
+
tweet_text: text,
|
|
26
|
+
dark_request: false,
|
|
27
|
+
media: {
|
|
28
|
+
media_entities: mediaEntities,
|
|
29
|
+
possibly_sensitive: false,
|
|
30
|
+
},
|
|
31
|
+
semantic_annotation_ids: [],
|
|
32
|
+
},
|
|
33
|
+
createTweetFeatures()
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
if (errors?.length) {
|
|
37
|
+
return { success: false, error: this.formatErrors(errors) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const createTweet = (data as Record<string, unknown>)
|
|
41
|
+
?.create_tweet as Record<string, unknown> | undefined;
|
|
42
|
+
const tweetResults = createTweet?.tweet_results as
|
|
43
|
+
| Record<string, unknown>
|
|
44
|
+
| undefined;
|
|
45
|
+
const result = tweetResults?.result as
|
|
46
|
+
| Record<string, unknown>
|
|
47
|
+
| undefined;
|
|
48
|
+
const tweetId = result?.rest_id as string | undefined;
|
|
49
|
+
|
|
50
|
+
if (!tweetId) {
|
|
51
|
+
return { success: false, error: "No tweet ID in response" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { success: true, tweetId };
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return {
|
|
57
|
+
success: false,
|
|
58
|
+
error: err instanceof Error ? err.message : String(err),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reply to an existing tweet, optionally with media.
|
|
65
|
+
*/
|
|
66
|
+
async reply(
|
|
67
|
+
text: string,
|
|
68
|
+
replyToId: string,
|
|
69
|
+
mediaIds?: string[]
|
|
70
|
+
): Promise<{ success: boolean; tweetId?: string; error?: string }> {
|
|
71
|
+
try {
|
|
72
|
+
const mediaEntities = (mediaIds ?? []).map((id) => ({
|
|
73
|
+
media_id: id,
|
|
74
|
+
tagged_users: [],
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
const { data, errors } = await this.graphqlPost(
|
|
78
|
+
"CreateTweet",
|
|
79
|
+
{
|
|
80
|
+
tweet_text: text,
|
|
81
|
+
dark_request: false,
|
|
82
|
+
media: {
|
|
83
|
+
media_entities: mediaEntities,
|
|
84
|
+
possibly_sensitive: false,
|
|
85
|
+
},
|
|
86
|
+
semantic_annotation_ids: [],
|
|
87
|
+
reply: {
|
|
88
|
+
in_reply_to_tweet_id: replyToId,
|
|
89
|
+
exclude_reply_user_ids: [],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
createTweetFeatures()
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (errors?.length) {
|
|
96
|
+
return { success: false, error: this.formatErrors(errors) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const createTweet = (data as Record<string, unknown>)
|
|
100
|
+
?.create_tweet as Record<string, unknown> | undefined;
|
|
101
|
+
const tweetResults = createTweet?.tweet_results as
|
|
102
|
+
| Record<string, unknown>
|
|
103
|
+
| undefined;
|
|
104
|
+
const result = tweetResults?.result as
|
|
105
|
+
| Record<string, unknown>
|
|
106
|
+
| undefined;
|
|
107
|
+
const tweetId = result?.rest_id as string | undefined;
|
|
108
|
+
|
|
109
|
+
if (!tweetId) {
|
|
110
|
+
return { success: false, error: "No tweet ID in response" };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { success: true, tweetId };
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return {
|
|
116
|
+
success: false,
|
|
117
|
+
error: err instanceof Error ? err.message : String(err),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Post a thread (multiple tweets in sequence).
|
|
124
|
+
* Returns all tweet IDs in order.
|
|
125
|
+
*/
|
|
126
|
+
async thread(
|
|
127
|
+
texts: string[]
|
|
128
|
+
): Promise<{ success: boolean; tweetIds: string[]; error?: string }> {
|
|
129
|
+
if (texts.length === 0) {
|
|
130
|
+
return { success: false, tweetIds: [], error: "No texts provided" };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const tweetIds: string[] = [];
|
|
134
|
+
|
|
135
|
+
// Post the first tweet
|
|
136
|
+
const first = await this.tweet(texts[0]!);
|
|
137
|
+
if (!first.success || !first.tweetId) {
|
|
138
|
+
return {
|
|
139
|
+
success: false,
|
|
140
|
+
tweetIds,
|
|
141
|
+
error: first.error ?? "Failed to post first tweet",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
tweetIds.push(first.tweetId);
|
|
145
|
+
|
|
146
|
+
// Post subsequent tweets as replies
|
|
147
|
+
for (let i = 1; i < texts.length; i++) {
|
|
148
|
+
const previousId = tweetIds[tweetIds.length - 1]!;
|
|
149
|
+
const result = await this.reply(texts[i]!, previousId);
|
|
150
|
+
if (!result.success || !result.tweetId) {
|
|
151
|
+
return {
|
|
152
|
+
success: false,
|
|
153
|
+
tweetIds,
|
|
154
|
+
error: result.error ?? `Failed to post tweet ${i + 1} in thread`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
tweetIds.push(result.tweetId);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { success: true, tweetIds };
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|