@spectratools/xapi-cli 0.2.4 → 0.4.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/README.md +7 -0
- package/dist/cli.js +242 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,10 +54,15 @@ xapi-cli trends location 1 --format json
|
|
|
54
54
|
xapi-cli posts search "AI agents" --sort relevancy --max-results 20 --format json
|
|
55
55
|
|
|
56
56
|
# 2) User intelligence pass
|
|
57
|
+
xapi-cli users me --format json
|
|
57
58
|
xapi-cli users get jack --format json
|
|
58
59
|
xapi-cli users posts jack --max-results 20 --format json
|
|
59
60
|
xapi-cli users followers jack --max-results 100 --format json
|
|
60
61
|
|
|
62
|
+
# 2b) Client-side follower delta (new followers since a known baseline)
|
|
63
|
+
# Baseline file format: one follower ID per line
|
|
64
|
+
xapi-cli users followers jack --new-only --seen-ids-file ./seen-followers.txt --format json
|
|
65
|
+
|
|
61
66
|
# 3) Moderation helper flow
|
|
62
67
|
xapi-cli posts get 1234567890 --format json
|
|
63
68
|
xapi-cli posts likes 1234567890 --max-results 100 --format json
|
|
@@ -75,5 +80,7 @@ xapi-cli dm send 12345 --text "hello from agent" --format json
|
|
|
75
80
|
## Notes
|
|
76
81
|
|
|
77
82
|
- All commands support JSON output with `--format json`.
|
|
83
|
+
- `users followers --new-only` performs **client-side diffing** against `--seen-ids-file`; it does not use an API-native `since_id` filter for follower deltas.
|
|
84
|
+
- Baseline files are read-only input (newline-delimited follower IDs) and are never mutated by the CLI.
|
|
78
85
|
- `X_BEARER_TOKEN` is for read-only app auth.
|
|
79
86
|
- `X_ACCESS_TOKEN` is required for write actions (`posts create`, `posts delete`, `dm send`).
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { readFileSync, realpathSync } from "fs";
|
|
4
|
+
import { readFileSync as readFileSync2, realpathSync } from "fs";
|
|
5
5
|
import { dirname, resolve } from "path";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
7
|
import { Cli as Cli7 } from "incur";
|
|
@@ -186,6 +186,12 @@ function createXApiClient(bearerToken) {
|
|
|
186
186
|
...nextToken ? { pagination_token: nextToken } : {}
|
|
187
187
|
});
|
|
188
188
|
}
|
|
189
|
+
function likePost(userId, tweetId) {
|
|
190
|
+
return post(`/users/${userId}/likes`, { tweet_id: tweetId });
|
|
191
|
+
}
|
|
192
|
+
function retweetPost(userId, tweetId) {
|
|
193
|
+
return post(`/users/${userId}/retweets`, { tweet_id: tweetId });
|
|
194
|
+
}
|
|
189
195
|
const USER_FIELDS = "id,name,username,description,public_metrics,created_at";
|
|
190
196
|
function getUserByUsername(username) {
|
|
191
197
|
return get(`/users/by/username/${username}`, {
|
|
@@ -211,6 +217,14 @@ function createXApiClient(bearerToken) {
|
|
|
211
217
|
...nextToken ? { pagination_token: nextToken } : {}
|
|
212
218
|
});
|
|
213
219
|
}
|
|
220
|
+
function followUser(sourceUserId, targetUserId) {
|
|
221
|
+
return post(`/users/${sourceUserId}/following`, {
|
|
222
|
+
target_user_id: targetUserId
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
function unfollowUser(sourceUserId, targetUserId) {
|
|
226
|
+
return del(`/users/${sourceUserId}/following/${targetUserId}`);
|
|
227
|
+
}
|
|
214
228
|
function getUserPosts(id, maxResults, nextToken) {
|
|
215
229
|
return get(`/users/${id}/tweets`, {
|
|
216
230
|
max_results: maxResults,
|
|
@@ -231,18 +245,20 @@ function createXApiClient(bearerToken) {
|
|
|
231
245
|
"user.fields": USER_FIELDS
|
|
232
246
|
});
|
|
233
247
|
}
|
|
234
|
-
function getHomeTimeline(userId, maxResults, nextToken) {
|
|
248
|
+
function getHomeTimeline(userId, maxResults, nextToken, sinceId) {
|
|
235
249
|
return get(`/users/${userId}/timelines/reverse_chronological`, {
|
|
236
250
|
max_results: maxResults,
|
|
237
251
|
"tweet.fields": POST_FIELDS,
|
|
238
|
-
...nextToken ? { pagination_token: nextToken } : {}
|
|
252
|
+
...nextToken ? { pagination_token: nextToken } : {},
|
|
253
|
+
...sinceId ? { since_id: sinceId } : {}
|
|
239
254
|
});
|
|
240
255
|
}
|
|
241
|
-
function getMentionsTimeline(userId, maxResults, nextToken) {
|
|
256
|
+
function getMentionsTimeline(userId, maxResults, nextToken, sinceId) {
|
|
242
257
|
return get(`/users/${userId}/mentions`, {
|
|
243
258
|
max_results: maxResults,
|
|
244
259
|
"tweet.fields": POST_FIELDS,
|
|
245
|
-
...nextToken ? { pagination_token: nextToken } : {}
|
|
260
|
+
...nextToken ? { pagination_token: nextToken } : {},
|
|
261
|
+
...sinceId ? { since_id: sinceId } : {}
|
|
246
262
|
});
|
|
247
263
|
}
|
|
248
264
|
function getList(id) {
|
|
@@ -301,10 +317,14 @@ function createXApiClient(bearerToken) {
|
|
|
301
317
|
deletePost,
|
|
302
318
|
getPostLikes,
|
|
303
319
|
getPostRetweets,
|
|
320
|
+
likePost,
|
|
321
|
+
retweetPost,
|
|
304
322
|
getUserByUsername,
|
|
305
323
|
getUserById,
|
|
306
324
|
getUserFollowers,
|
|
307
325
|
getUserFollowing,
|
|
326
|
+
followUser,
|
|
327
|
+
unfollowUser,
|
|
308
328
|
getUserPosts,
|
|
309
329
|
getUserMentions,
|
|
310
330
|
searchUsers,
|
|
@@ -771,6 +791,54 @@ posts.command("delete", {
|
|
|
771
791
|
}
|
|
772
792
|
}
|
|
773
793
|
});
|
|
794
|
+
posts.command("like", {
|
|
795
|
+
description: "Like a post by ID.",
|
|
796
|
+
args: z4.object({
|
|
797
|
+
id: z4.string().describe("Post ID to like")
|
|
798
|
+
}),
|
|
799
|
+
env: xApiWriteEnv,
|
|
800
|
+
output: z4.object({
|
|
801
|
+
liked: z4.boolean(),
|
|
802
|
+
id: z4.string()
|
|
803
|
+
}),
|
|
804
|
+
examples: [{ args: { id: "1234567890" }, description: "Like a post" }],
|
|
805
|
+
async run(c) {
|
|
806
|
+
try {
|
|
807
|
+
const client = createXApiClient(writeAuthToken(c.env));
|
|
808
|
+
const me = await client.getMe();
|
|
809
|
+
const res = await client.likePost(me.data.id, c.args.id);
|
|
810
|
+
return c.ok({ liked: res.data.liked, id: c.args.id });
|
|
811
|
+
} catch (error) {
|
|
812
|
+
const authError = toWriteAuthError("posts like", error);
|
|
813
|
+
if (authError) return c.error(authError);
|
|
814
|
+
throw error;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
posts.command("retweet", {
|
|
819
|
+
description: "Retweet a post by ID.",
|
|
820
|
+
args: z4.object({
|
|
821
|
+
id: z4.string().describe("Post ID to retweet")
|
|
822
|
+
}),
|
|
823
|
+
env: xApiWriteEnv,
|
|
824
|
+
output: z4.object({
|
|
825
|
+
retweeted: z4.boolean(),
|
|
826
|
+
id: z4.string()
|
|
827
|
+
}),
|
|
828
|
+
examples: [{ args: { id: "1234567890" }, description: "Retweet a post" }],
|
|
829
|
+
async run(c) {
|
|
830
|
+
try {
|
|
831
|
+
const client = createXApiClient(writeAuthToken(c.env));
|
|
832
|
+
const me = await client.getMe();
|
|
833
|
+
const res = await client.retweetPost(me.data.id, c.args.id);
|
|
834
|
+
return c.ok({ retweeted: res.data.retweeted, id: c.args.id });
|
|
835
|
+
} catch (error) {
|
|
836
|
+
const authError = toWriteAuthError("posts retweet", error);
|
|
837
|
+
if (authError) return c.error(authError);
|
|
838
|
+
throw error;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
});
|
|
774
842
|
posts.command("likes", {
|
|
775
843
|
description: "List users who liked a post.",
|
|
776
844
|
args: z4.object({
|
|
@@ -1045,6 +1113,7 @@ trends.command("location", {
|
|
|
1045
1113
|
});
|
|
1046
1114
|
|
|
1047
1115
|
// src/commands/users.ts
|
|
1116
|
+
import { readFileSync } from "fs";
|
|
1048
1117
|
import { Cli as Cli6, z as z7 } from "incur";
|
|
1049
1118
|
var users = Cli6.create("users", {
|
|
1050
1119
|
description: "Look up X users."
|
|
@@ -1055,6 +1124,79 @@ async function resolveUser(client, usernameOrId) {
|
|
|
1055
1124
|
}
|
|
1056
1125
|
return client.getUserByUsername(usernameOrId.replace(/^@/, ""));
|
|
1057
1126
|
}
|
|
1127
|
+
function formatUserProfile(user, verbose) {
|
|
1128
|
+
return {
|
|
1129
|
+
id: user.id,
|
|
1130
|
+
name: user.name,
|
|
1131
|
+
username: user.username,
|
|
1132
|
+
description: user.description ? verbose ? user.description : truncateText(user.description) : void 0,
|
|
1133
|
+
followers: user.public_metrics?.followers_count,
|
|
1134
|
+
following: user.public_metrics?.following_count,
|
|
1135
|
+
tweets: user.public_metrics?.tweet_count,
|
|
1136
|
+
joined: user.created_at ? relativeTime(user.created_at) : void 0
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
function readSeenIds(filePath) {
|
|
1140
|
+
const fileContents = readFileSync(filePath, "utf8");
|
|
1141
|
+
return new Set(
|
|
1142
|
+
fileContents.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
var followersOptionsSchema = z7.object({
|
|
1146
|
+
maxResults: z7.number().default(100).describe("Maximum followers to return"),
|
|
1147
|
+
seenIdsFile: z7.string().optional().describe(
|
|
1148
|
+
"Path to newline-delimited follower IDs used as a baseline for client-side diffing"
|
|
1149
|
+
),
|
|
1150
|
+
newOnly: z7.boolean().default(false).describe(
|
|
1151
|
+
"Return only followers not found in --seen-ids-file (client-side baseline diff; not API-native since_id)"
|
|
1152
|
+
)
|
|
1153
|
+
}).refine((options) => !options.newOnly || Boolean(options.seenIdsFile), {
|
|
1154
|
+
path: ["seenIdsFile"],
|
|
1155
|
+
message: "--seen-ids-file is required when --new-only is set"
|
|
1156
|
+
});
|
|
1157
|
+
users.command("me", {
|
|
1158
|
+
description: "Get the authenticated user profile and metrics.",
|
|
1159
|
+
options: z7.object({
|
|
1160
|
+
verbose: z7.boolean().optional().describe("Show full bio without truncation")
|
|
1161
|
+
}),
|
|
1162
|
+
env: xApiReadEnv,
|
|
1163
|
+
output: z7.object({
|
|
1164
|
+
id: z7.string(),
|
|
1165
|
+
name: z7.string(),
|
|
1166
|
+
username: z7.string(),
|
|
1167
|
+
description: z7.string().optional(),
|
|
1168
|
+
followers: z7.number().optional(),
|
|
1169
|
+
following: z7.number().optional(),
|
|
1170
|
+
tweets: z7.number().optional(),
|
|
1171
|
+
joined: z7.string().optional()
|
|
1172
|
+
}),
|
|
1173
|
+
examples: [{ description: "Get your authenticated profile", options: {} }],
|
|
1174
|
+
async run(c) {
|
|
1175
|
+
const client = createXApiClient(readAuthToken(c.env));
|
|
1176
|
+
const res = await client.getMe();
|
|
1177
|
+
const user = res.data;
|
|
1178
|
+
return c.ok(
|
|
1179
|
+
formatUserProfile(user, c.options.verbose),
|
|
1180
|
+
c.format === "json" || c.format === "jsonl" ? void 0 : {
|
|
1181
|
+
cta: {
|
|
1182
|
+
description: "Explore your account:",
|
|
1183
|
+
commands: [
|
|
1184
|
+
{
|
|
1185
|
+
command: "users followers",
|
|
1186
|
+
args: { username: user.username },
|
|
1187
|
+
description: "View your followers"
|
|
1188
|
+
},
|
|
1189
|
+
{
|
|
1190
|
+
command: "users posts",
|
|
1191
|
+
args: { username: user.username },
|
|
1192
|
+
description: "View your recent posts"
|
|
1193
|
+
}
|
|
1194
|
+
]
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1058
1200
|
users.command("get", {
|
|
1059
1201
|
description: "Get a user by username or ID.",
|
|
1060
1202
|
args: z7.object({
|
|
@@ -1083,16 +1225,7 @@ users.command("get", {
|
|
|
1083
1225
|
const res = await resolveUser(client, c.args.username);
|
|
1084
1226
|
const user = res.data;
|
|
1085
1227
|
return c.ok(
|
|
1086
|
-
|
|
1087
|
-
id: user.id,
|
|
1088
|
-
name: user.name,
|
|
1089
|
-
username: user.username,
|
|
1090
|
-
description: user.description ? c.options.verbose ? user.description : truncateText(user.description) : void 0,
|
|
1091
|
-
followers: user.public_metrics?.followers_count,
|
|
1092
|
-
following: user.public_metrics?.following_count,
|
|
1093
|
-
tweets: user.public_metrics?.tweet_count,
|
|
1094
|
-
joined: user.created_at ? relativeTime(user.created_at) : void 0
|
|
1095
|
-
},
|
|
1228
|
+
formatUserProfile(user, c.options.verbose),
|
|
1096
1229
|
c.format === "json" || c.format === "jsonl" ? void 0 : {
|
|
1097
1230
|
cta: {
|
|
1098
1231
|
description: "Explore this user:",
|
|
@@ -1113,15 +1246,77 @@ users.command("get", {
|
|
|
1113
1246
|
);
|
|
1114
1247
|
}
|
|
1115
1248
|
});
|
|
1249
|
+
users.command("follow", {
|
|
1250
|
+
description: "Follow a user by username or ID.",
|
|
1251
|
+
args: z7.object({
|
|
1252
|
+
username: z7.string().describe("Username (with or without @) or user ID")
|
|
1253
|
+
}),
|
|
1254
|
+
env: xApiWriteEnv,
|
|
1255
|
+
output: z7.object({
|
|
1256
|
+
id: z7.string(),
|
|
1257
|
+
username: z7.string(),
|
|
1258
|
+
following: z7.boolean(),
|
|
1259
|
+
pending_follow: z7.boolean().optional()
|
|
1260
|
+
}),
|
|
1261
|
+
examples: [{ args: { username: "jack" }, description: "Follow @jack" }],
|
|
1262
|
+
async run(c) {
|
|
1263
|
+
try {
|
|
1264
|
+
const client = createXApiClient(writeAuthToken(c.env));
|
|
1265
|
+
const me = await client.getMe();
|
|
1266
|
+
const targetRes = await resolveUser(client, c.args.username);
|
|
1267
|
+
const target = targetRes.data;
|
|
1268
|
+
const res = await client.followUser(me.data.id, target.id);
|
|
1269
|
+
return c.ok({
|
|
1270
|
+
id: target.id,
|
|
1271
|
+
username: target.username,
|
|
1272
|
+
following: res.data.following,
|
|
1273
|
+
pending_follow: res.data.pending_follow
|
|
1274
|
+
});
|
|
1275
|
+
} catch (error) {
|
|
1276
|
+
const authError = toWriteAuthError("users follow", error);
|
|
1277
|
+
if (authError) return c.error(authError);
|
|
1278
|
+
throw error;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
});
|
|
1282
|
+
users.command("unfollow", {
|
|
1283
|
+
description: "Unfollow a user by username or ID.",
|
|
1284
|
+
args: z7.object({
|
|
1285
|
+
username: z7.string().describe("Username (with or without @) or user ID")
|
|
1286
|
+
}),
|
|
1287
|
+
env: xApiWriteEnv,
|
|
1288
|
+
output: z7.object({
|
|
1289
|
+
id: z7.string(),
|
|
1290
|
+
username: z7.string(),
|
|
1291
|
+
following: z7.boolean()
|
|
1292
|
+
}),
|
|
1293
|
+
examples: [{ args: { username: "jack" }, description: "Unfollow @jack" }],
|
|
1294
|
+
async run(c) {
|
|
1295
|
+
try {
|
|
1296
|
+
const client = createXApiClient(writeAuthToken(c.env));
|
|
1297
|
+
const me = await client.getMe();
|
|
1298
|
+
const targetRes = await resolveUser(client, c.args.username);
|
|
1299
|
+
const target = targetRes.data;
|
|
1300
|
+
const res = await client.unfollowUser(me.data.id, target.id);
|
|
1301
|
+
return c.ok({
|
|
1302
|
+
id: target.id,
|
|
1303
|
+
username: target.username,
|
|
1304
|
+
following: res.data.following
|
|
1305
|
+
});
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
const authError = toWriteAuthError("users unfollow", error);
|
|
1308
|
+
if (authError) return c.error(authError);
|
|
1309
|
+
throw error;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1116
1313
|
users.command("followers", {
|
|
1117
|
-
description: "List followers of a user.",
|
|
1314
|
+
description: "List followers of a user. Supports optional client-side baseline diffing for new follower detection.",
|
|
1118
1315
|
args: z7.object({
|
|
1119
1316
|
username: z7.string().describe("Username or user ID")
|
|
1120
1317
|
}),
|
|
1121
|
-
options:
|
|
1122
|
-
|
|
1123
|
-
}),
|
|
1124
|
-
alias: { maxResults: "n" },
|
|
1318
|
+
options: followersOptionsSchema,
|
|
1319
|
+
alias: { maxResults: "n", seenIdsFile: "s" },
|
|
1125
1320
|
env: xApiReadEnv,
|
|
1126
1321
|
output: z7.object({
|
|
1127
1322
|
users: z7.array(
|
|
@@ -1134,7 +1329,14 @@ users.command("followers", {
|
|
|
1134
1329
|
),
|
|
1135
1330
|
count: z7.number()
|
|
1136
1331
|
}),
|
|
1137
|
-
examples: [
|
|
1332
|
+
examples: [
|
|
1333
|
+
{ args: { username: "jack" }, description: "List followers of jack" },
|
|
1334
|
+
{
|
|
1335
|
+
args: { username: "jack" },
|
|
1336
|
+
options: { seenIdsFile: "./seen-followers.txt", newOnly: true },
|
|
1337
|
+
description: "Show only followers not in your baseline file (client-side diffing; the X API does not support since_id here)"
|
|
1338
|
+
}
|
|
1339
|
+
],
|
|
1138
1340
|
async run(c) {
|
|
1139
1341
|
const client = createXApiClient(readAuthToken(c.env));
|
|
1140
1342
|
const userRes = await resolveUser(client, c.args.username);
|
|
@@ -1150,7 +1352,15 @@ users.command("followers", {
|
|
|
1150
1352
|
c.options.maxResults,
|
|
1151
1353
|
1e3
|
|
1152
1354
|
);
|
|
1153
|
-
|
|
1355
|
+
if (!c.options.newOnly) {
|
|
1356
|
+
return c.ok({ users: allUsers, count: allUsers.length });
|
|
1357
|
+
}
|
|
1358
|
+
if (!c.options.seenIdsFile) {
|
|
1359
|
+
throw new Error("--seen-ids-file is required when --new-only is set");
|
|
1360
|
+
}
|
|
1361
|
+
const seenIds = readSeenIds(c.options.seenIdsFile);
|
|
1362
|
+
const newUsers = allUsers.filter((user) => !seenIds.has(user.id));
|
|
1363
|
+
return c.ok({ users: newUsers, count: newUsers.length });
|
|
1154
1364
|
}
|
|
1155
1365
|
});
|
|
1156
1366
|
users.command("following", {
|
|
@@ -1336,12 +1546,20 @@ users.command("search", {
|
|
|
1336
1546
|
|
|
1337
1547
|
// src/cli.ts
|
|
1338
1548
|
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
1339
|
-
var pkg = JSON.parse(
|
|
1549
|
+
var pkg = JSON.parse(readFileSync2(resolve(__dirname, "../package.json"), "utf8"));
|
|
1340
1550
|
var cli = Cli7.create("xapi", {
|
|
1341
1551
|
version: pkg.version,
|
|
1342
1552
|
description: "X (Twitter) API CLI for spectra-the-bot."
|
|
1343
1553
|
});
|
|
1344
|
-
var WRITE_OPERATIONS = /* @__PURE__ */ new Set([
|
|
1554
|
+
var WRITE_OPERATIONS = /* @__PURE__ */ new Set([
|
|
1555
|
+
"posts create",
|
|
1556
|
+
"posts delete",
|
|
1557
|
+
"posts like",
|
|
1558
|
+
"posts retweet",
|
|
1559
|
+
"users follow",
|
|
1560
|
+
"users unfollow",
|
|
1561
|
+
"dm send"
|
|
1562
|
+
]);
|
|
1345
1563
|
cli.use(async ({ command, error }, next) => {
|
|
1346
1564
|
try {
|
|
1347
1565
|
return await next();
|