@opencoredev/social-sdk 0.1.2 → 0.2.1
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 +6 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +234 -0
- package/dist/cloud/common.d.ts +25 -0
- package/dist/cloud/common.js +334 -0
- package/dist/cloud/lifecycle.d.ts +10 -0
- package/dist/cloud/lifecycle.js +112 -0
- package/dist/cloud/media.d.ts +23 -0
- package/dist/cloud/media.js +100 -0
- package/dist/cloud/outcomes.d.ts +9 -0
- package/dist/cloud/outcomes.js +195 -0
- package/dist/cloud/post-for-me.d.ts +69 -0
- package/dist/cloud/post-for-me.js +396 -0
- package/dist/cloud/zernio.d.ts +111 -0
- package/dist/cloud/zernio.js +632 -0
- package/dist/core/adapter.d.ts +158 -0
- package/dist/core/adapter.js +3 -0
- package/dist/core/client.d.ts +141 -0
- package/dist/core/client.js +1285 -0
- package/dist/core/concurrency.d.ts +9 -0
- package/dist/core/concurrency.js +79 -0
- package/dist/core/errors.d.ts +44 -0
- package/dist/core/errors.js +50 -0
- package/dist/core/idempotency.d.ts +33 -0
- package/dist/core/idempotency.js +58 -0
- package/dist/core/index.d.ts +6 -0
- package/dist/core/index.js +6 -0
- package/dist/core/pagination.d.ts +11 -0
- package/dist/core/pagination.js +81 -0
- package/dist/core/types.d.ts +396 -0
- package/dist/core/types.js +16 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/platforms/bluesky.d.ts +261 -0
- package/dist/platforms/bluesky.js +1776 -0
- package/dist/platforms/instagram.d.ts +129 -0
- package/dist/platforms/instagram.js +1031 -0
- package/dist/platforms/linkedin.d.ts +108 -0
- package/dist/platforms/linkedin.js +890 -0
- package/dist/platforms/threads.d.ts +134 -0
- package/dist/platforms/threads.js +945 -0
- package/dist/platforms/tiktok.d.ts +31 -0
- package/dist/platforms/tiktok.js +593 -0
- package/dist/platforms/x-engagement.d.ts +16 -0
- package/dist/platforms/x-engagement.js +50 -0
- package/dist/platforms/x-text.d.ts +2 -0
- package/dist/platforms/x-text.js +123 -0
- package/dist/platforms/x-tlds.d.ts +1 -0
- package/dist/platforms/x-tlds.js +2 -0
- package/dist/platforms/x.d.ts +299 -0
- package/dist/platforms/x.js +1287 -0
- package/dist/platforms/youtube-upload.d.ts +32 -0
- package/dist/platforms/youtube-upload.js +296 -0
- package/dist/platforms/youtube.d.ts +108 -0
- package/dist/platforms/youtube.js +1100 -0
- package/dist/server/connections.d.ts +123 -0
- package/dist/server/connections.js +335 -0
- package/dist/server/credentials.d.ts +51 -0
- package/dist/server/credentials.js +108 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +4 -0
- package/dist/server/oauth.d.ts +52 -0
- package/dist/server/oauth.js +553 -0
- package/dist/server/webhooks.d.ts +57 -0
- package/dist/server/webhooks.js +204 -0
- package/dist/testing/index.d.ts +46 -0
- package/dist/testing/index.js +564 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -0
- package/dist/transport/binary.d.ts +2 -0
- package/dist/transport/binary.js +29 -0
- package/dist/transport/budget.d.ts +3 -0
- package/dist/transport/budget.js +26 -0
- package/dist/transport/http.d.ts +44 -0
- package/dist/transport/http.js +259 -0
- package/dist/transport/json.d.ts +8 -0
- package/dist/transport/json.js +16 -0
- package/dist/transport/upload.d.ts +25 -0
- package/dist/transport/upload.js +153 -0
- package/dist/transport/validation.d.ts +5 -0
- package/dist/transport/validation.js +24 -0
- package/package.json +2 -7
|
@@ -0,0 +1,1287 @@
|
|
|
1
|
+
/* oxlint-disable anti-slop/no-conditional-empty-object-spread, anti-slop/no-runtime-typeof, anti-slop/require-readable-spacing, anti-slop/require-safety-comment-for-type-assertion -- validated external boundary or fixture contract. */
|
|
2
|
+
import { remainingBudget } from "../transport/budget.js";
|
|
3
|
+
import { isValidXText } from "./x-text.js";
|
|
4
|
+
import { defineAdapter } from "../core/adapter.js";
|
|
5
|
+
import { connectedAccountRef, profileRef } from "../core/types.js";
|
|
6
|
+
import { SocialError } from "../core/errors.js";
|
|
7
|
+
import { managedHttp, publicFields } from "../cloud/common.js";
|
|
8
|
+
import { createHttp, HttpError } from "../transport/http.js";
|
|
9
|
+
import { array, object as parseObject, optionalNumber, optionalString, string, } from "../transport/validation.js";
|
|
10
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters, anti-slop/require-safety-comment-for-type-assertion -- transport parser validates the provider boundary.
|
|
11
|
+
const object = (value) => parseObject(value);
|
|
12
|
+
// 53-bit conversation ID hashes, 11 base36 characters each. listConversations returns at
|
|
13
|
+
// most 1,200 distinct conversations, which keeps its cursor well under the client's 16,384
|
|
14
|
+
// character cursor limit.
|
|
15
|
+
const conversationHashWidth = 11;
|
|
16
|
+
const maxConversationHashes = 1200;
|
|
17
|
+
function conversationHash(id) {
|
|
18
|
+
let h1 = 0xdeadbeef;
|
|
19
|
+
let h2 = 0x41c6ce57;
|
|
20
|
+
for (let index = 0; index < id.length; index++) {
|
|
21
|
+
const code = id.charCodeAt(index);
|
|
22
|
+
h1 = Math.imul(h1 ^ code, 2654435761);
|
|
23
|
+
h2 = Math.imul(h2 ^ code, 1597334677);
|
|
24
|
+
}
|
|
25
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
26
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
27
|
+
const value = 4294967296 * (2097151 & h2) + (h1 >>> 0);
|
|
28
|
+
return value.toString(36).padStart(conversationHashWidth, "0");
|
|
29
|
+
}
|
|
30
|
+
export { xLike, xUnlike } from "./x-engagement.js";
|
|
31
|
+
export function x(options) {
|
|
32
|
+
const request = managedHttp("https://api.x.com", {
|
|
33
|
+
apiKey: options.auth.accessToken ?? "app-auth-placeholder",
|
|
34
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
35
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
36
|
+
});
|
|
37
|
+
const appRequest = () => {
|
|
38
|
+
if (!options.appBearerToken?.trim())
|
|
39
|
+
throw new SocialError({
|
|
40
|
+
code: "missing_permission",
|
|
41
|
+
operation: "x.app-auth",
|
|
42
|
+
message: "This X endpoint requires an app-only bearer token. Configure appBearerToken.",
|
|
43
|
+
});
|
|
44
|
+
return managedHttp("https://api.x.com", {
|
|
45
|
+
apiKey: options.appBearerToken,
|
|
46
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated external boundary or fixture contract.
|
|
47
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
const requireUserToken = (operation) => {
|
|
51
|
+
if (!options.auth.accessToken?.trim())
|
|
52
|
+
throw new SocialError({
|
|
53
|
+
code: "missing_permission",
|
|
54
|
+
operation,
|
|
55
|
+
message: "This X operation requires a user access token.",
|
|
56
|
+
});
|
|
57
|
+
return request;
|
|
58
|
+
};
|
|
59
|
+
const readRequest = (path) => ["/2/users/me", "/liked_tweets", "/mentions", "/timelines/reverse_chronological"].some((suffix) => path.includes(suffix))
|
|
60
|
+
? requireUserToken("x.read")
|
|
61
|
+
: options.auth.accessToken?.trim()
|
|
62
|
+
? request
|
|
63
|
+
: appRequest();
|
|
64
|
+
const http = createHttp(options.fetch ? { fetch: options.fetch } : {});
|
|
65
|
+
const now = () => (options.clock?.() ?? new Date()).toISOString();
|
|
66
|
+
const authorize = (ref, context) => {
|
|
67
|
+
if (ref.backend !== context.backendInstance ||
|
|
68
|
+
ref.platform !== "x" ||
|
|
69
|
+
ref.accountId !== options.auth.userId)
|
|
70
|
+
throw new SocialError({
|
|
71
|
+
code: "unauthorized",
|
|
72
|
+
operation: "x",
|
|
73
|
+
message: "Reference does not match this X user authorization.",
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
async function readAccount(context) {
|
|
77
|
+
const user = object(object(await request("/2/users/me", context))["data"]);
|
|
78
|
+
if (user["id"] !== options.auth.userId)
|
|
79
|
+
throw new SocialError({
|
|
80
|
+
code: "unauthorized",
|
|
81
|
+
operation: "accounts.read",
|
|
82
|
+
message: "The authenticated X user differs from the configured account.",
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
ref: {
|
|
86
|
+
kind: "connected-account",
|
|
87
|
+
version: 1,
|
|
88
|
+
backend: context.backendInstance,
|
|
89
|
+
platform: "x",
|
|
90
|
+
accountId: options.auth.userId,
|
|
91
|
+
},
|
|
92
|
+
displayName: string(user["name"]),
|
|
93
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
94
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
|
|
95
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- provider payload is validated at this adapter boundary.
|
|
96
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated external boundary or fixture contract.
|
|
97
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated external boundary or fixture contract.
|
|
98
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated external boundary or fixture contract.
|
|
99
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated external boundary or fixture contract.
|
|
100
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated external boundary or fixture contract.
|
|
101
|
+
...(typeof user["username"] === "string" ? { handle: user["username"] } : {}),
|
|
102
|
+
status: "connected",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
async function readPost(ref, context) {
|
|
106
|
+
authorize(ref, context);
|
|
107
|
+
const result = object(object(await request(`/2/tweets/${encodeURIComponent(ref.postId)}`, context, undefined, {
|
|
108
|
+
"tweet.fields": "author_id,public_metrics,created_at,conversation_id,referenced_tweets",
|
|
109
|
+
}))["data"]);
|
|
110
|
+
if (result["id"] !== ref.postId || result["author_id"] !== ref.accountId)
|
|
111
|
+
throw new SocialError({
|
|
112
|
+
code: "unauthorized",
|
|
113
|
+
operation: "posts.read",
|
|
114
|
+
message: "X post identity or author does not match the declared reference.",
|
|
115
|
+
});
|
|
116
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
async function listPosts(account, input, context) {
|
|
120
|
+
authorize(account, context);
|
|
121
|
+
if (input.limit !== undefined &&
|
|
122
|
+
(!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 100))
|
|
123
|
+
throw new SocialError({
|
|
124
|
+
code: "invalid_input",
|
|
125
|
+
operation: "posts.list",
|
|
126
|
+
message: "X feed limit must be an integer from 1 through 100.",
|
|
127
|
+
});
|
|
128
|
+
const result = object(await request(`/2/users/${encodeURIComponent(account.accountId)}/tweets`, context, undefined, {
|
|
129
|
+
"tweet.fields": "id,text,author_id,created_at,conversation_id",
|
|
130
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
131
|
+
...(input.cursor === undefined ? {} : { pagination_token: input.cursor }),
|
|
132
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
133
|
+
...(input.limit === undefined ? {} : { max_results: String(input.limit) }),
|
|
134
|
+
}));
|
|
135
|
+
const items = (result["data"] === undefined ? [] : array(result["data"])).map((entry) => {
|
|
136
|
+
const row = object(entry);
|
|
137
|
+
const authorId = optionalString(row["author_id"]);
|
|
138
|
+
if (authorId !== account.accountId)
|
|
139
|
+
throw new SocialError({
|
|
140
|
+
code: "unauthorized",
|
|
141
|
+
operation: "posts.list",
|
|
142
|
+
message: "X returned a post owned by a different account.",
|
|
143
|
+
});
|
|
144
|
+
return publicFields(row, ["id", "text", "author_id", "created_at", "conversation_id"]);
|
|
145
|
+
});
|
|
146
|
+
const meta = result["meta"] === undefined ? {} : object(result["meta"]);
|
|
147
|
+
const nextCursor = optionalString(meta["next_token"]);
|
|
148
|
+
return {
|
|
149
|
+
items,
|
|
150
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
151
|
+
...(nextCursor === undefined ? {} : { nextCursor }),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
async function searchPosts(account, input, context, nativeInput) {
|
|
155
|
+
authorize(account, context);
|
|
156
|
+
const query = input.query;
|
|
157
|
+
const scope = input.scope ?? "recent";
|
|
158
|
+
const maxLimit = scope === "all" ? 500 : 100;
|
|
159
|
+
if (!query.trim() || query.length > 4096)
|
|
160
|
+
throw new SocialError({
|
|
161
|
+
code: "invalid_input",
|
|
162
|
+
operation: "search.posts",
|
|
163
|
+
message: "X search queries must contain 1-4096 characters.",
|
|
164
|
+
});
|
|
165
|
+
if (input.limit !== undefined &&
|
|
166
|
+
(!Number.isSafeInteger(input.limit) || input.limit < 10 || input.limit > maxLimit))
|
|
167
|
+
throw new SocialError({
|
|
168
|
+
code: "invalid_input",
|
|
169
|
+
operation: "search.posts",
|
|
170
|
+
message: `X ${scope} search limits must be integers from 10 through ${maxLimit}.`,
|
|
171
|
+
});
|
|
172
|
+
for (const [name, value] of [
|
|
173
|
+
["startTime", input.startTime],
|
|
174
|
+
["endTime", input.endTime],
|
|
175
|
+
]) {
|
|
176
|
+
if (value !== undefined && !Number.isFinite(Date.parse(value)))
|
|
177
|
+
throw new SocialError({
|
|
178
|
+
code: "invalid_input",
|
|
179
|
+
operation: "search.posts",
|
|
180
|
+
message: `${name} must be an ISO 8601 timestamp.`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (input.startTime !== undefined &&
|
|
184
|
+
input.endTime !== undefined &&
|
|
185
|
+
Date.parse(input.startTime) >= Date.parse(input.endTime))
|
|
186
|
+
throw new SocialError({
|
|
187
|
+
code: "invalid_input",
|
|
188
|
+
operation: "search.posts",
|
|
189
|
+
message: "startTime must be earlier than endTime.",
|
|
190
|
+
});
|
|
191
|
+
const result = object(await (options.auth.accessToken?.trim() ? request : appRequest())(`/2/tweets/search/${scope}`, context, undefined, {
|
|
192
|
+
query,
|
|
193
|
+
"tweet.fields": [
|
|
194
|
+
"id",
|
|
195
|
+
"text",
|
|
196
|
+
"edit_history_tweet_ids",
|
|
197
|
+
...(nativeInput?.tweetFields ?? [
|
|
198
|
+
"author_id",
|
|
199
|
+
"created_at",
|
|
200
|
+
"conversation_id",
|
|
201
|
+
"public_metrics",
|
|
202
|
+
"lang",
|
|
203
|
+
"entities",
|
|
204
|
+
"attachments",
|
|
205
|
+
"referenced_tweets",
|
|
206
|
+
]),
|
|
207
|
+
]
|
|
208
|
+
.filter((field, index, fields) => fields.indexOf(field) === index)
|
|
209
|
+
.join(","),
|
|
210
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
211
|
+
...(input.cursor === undefined ? {} : { next_token: input.cursor }),
|
|
212
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
213
|
+
...(input.limit === undefined ? {} : { max_results: String(input.limit) }),
|
|
214
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
215
|
+
...(input.startTime === undefined ? {} : { start_time: input.startTime }),
|
|
216
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
217
|
+
...(input.endTime === undefined ? {} : { end_time: input.endTime }),
|
|
218
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
219
|
+
...(nativeInput?.sortOrder === undefined ? {} : { sort_order: nativeInput.sortOrder }),
|
|
220
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
221
|
+
...(nativeInput?.expansions === undefined
|
|
222
|
+
? {}
|
|
223
|
+
: { expansions: nativeInput.expansions.join(",") }),
|
|
224
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
225
|
+
...(nativeInput?.userFields === undefined
|
|
226
|
+
? {}
|
|
227
|
+
: { "user.fields": nativeInput.userFields.join(",") }),
|
|
228
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
229
|
+
...(nativeInput?.mediaFields === undefined
|
|
230
|
+
? {}
|
|
231
|
+
: { "media.fields": nativeInput.mediaFields.join(",") }),
|
|
232
|
+
}));
|
|
233
|
+
const requestedFields = nativeInput?.tweetFields ?? [
|
|
234
|
+
"id",
|
|
235
|
+
"text",
|
|
236
|
+
"author_id",
|
|
237
|
+
"created_at",
|
|
238
|
+
"conversation_id",
|
|
239
|
+
"public_metrics",
|
|
240
|
+
"lang",
|
|
241
|
+
"entities",
|
|
242
|
+
"attachments",
|
|
243
|
+
"referenced_tweets",
|
|
244
|
+
];
|
|
245
|
+
const items = (result["data"] === undefined ? [] : array(result["data"])).map((entry) => {
|
|
246
|
+
const row = object(entry);
|
|
247
|
+
return Object.fromEntries(["id", "text", ...requestedFields].flatMap((field, index, fields) => fields.indexOf(field) === index && field in row ? [[field, row[field]]] : []));
|
|
248
|
+
});
|
|
249
|
+
const meta = result["meta"] === undefined ? {} : object(result["meta"]);
|
|
250
|
+
const nextCursor = optionalString(meta["next_token"]);
|
|
251
|
+
const includes = result["includes"];
|
|
252
|
+
return {
|
|
253
|
+
items,
|
|
254
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
255
|
+
...(nextCursor === undefined ? {} : { nextCursor }),
|
|
256
|
+
...(includes === undefined
|
|
257
|
+
? {}
|
|
258
|
+
: {
|
|
259
|
+
// SAFETY: X's `includes` member is a JSON object validated by the transport boundary.
|
|
260
|
+
metadata: { includes: object(includes) },
|
|
261
|
+
}),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
async function pageRequest(path, account, input, context, query = {}, fields = "id,name,username,description,created_at,public_metrics", resource = "users") {
|
|
265
|
+
authorize(account, context);
|
|
266
|
+
const isTweets = resource === "tweets";
|
|
267
|
+
const minLimit = 1;
|
|
268
|
+
const maxLimit = 100;
|
|
269
|
+
if (input.limit !== undefined &&
|
|
270
|
+
(!Number.isInteger(input.limit) || input.limit < minLimit || input.limit > maxLimit))
|
|
271
|
+
throw new SocialError({
|
|
272
|
+
code: "invalid_input",
|
|
273
|
+
operation: path,
|
|
274
|
+
message: `X limits must be integers from ${minLimit} through ${maxLimit}.`,
|
|
275
|
+
});
|
|
276
|
+
const result = object(await readRequest(path)(path, context, undefined, {
|
|
277
|
+
...query,
|
|
278
|
+
...(isTweets
|
|
279
|
+
? {
|
|
280
|
+
"tweet.fields": "id,text,author_id,created_at,conversation_id,public_metrics,referenced_tweets",
|
|
281
|
+
expansions: "author_id,referenced_tweets.id",
|
|
282
|
+
}
|
|
283
|
+
: path.endsWith("/owned_lists") || path.endsWith("/pinned_lists")
|
|
284
|
+
? { "list.fields": fields }
|
|
285
|
+
: fields
|
|
286
|
+
? { "user.fields": fields }
|
|
287
|
+
: {}),
|
|
288
|
+
...(input.cursor === undefined ? {} : { pagination_token: input.cursor }),
|
|
289
|
+
...(input.limit === undefined ? {} : { max_results: String(input.limit) }),
|
|
290
|
+
}));
|
|
291
|
+
const items = (result["data"] === undefined ? [] : array(result["data"])).map((entry) => object(entry));
|
|
292
|
+
const meta = result["meta"] === undefined ? {} : object(result["meta"]);
|
|
293
|
+
const nextCursor = optionalString(meta["next_token"]);
|
|
294
|
+
return { items, ...(nextCursor === undefined ? {} : { nextCursor }) };
|
|
295
|
+
}
|
|
296
|
+
async function getAccountMetrics(account, context) {
|
|
297
|
+
authorize(account, context);
|
|
298
|
+
const result = object(await request(`/2/users/${encodeURIComponent(account.accountId)}`, context, undefined, {
|
|
299
|
+
"user.fields": "id,public_metrics",
|
|
300
|
+
}));
|
|
301
|
+
const data = object(result["data"]);
|
|
302
|
+
const id = optionalString(data["id"]);
|
|
303
|
+
if (id !== account.accountId)
|
|
304
|
+
throw new SocialError({
|
|
305
|
+
code: "unauthorized",
|
|
306
|
+
operation: "analytics.read",
|
|
307
|
+
message: "X returned metrics for a different account.",
|
|
308
|
+
});
|
|
309
|
+
const counts = data["public_metrics"] === undefined ? {} : object(data["public_metrics"]);
|
|
310
|
+
const fields = ["followers_count", "following_count", "tweet_count", "listed_count"];
|
|
311
|
+
return fields.flatMap((name) => {
|
|
312
|
+
const value = optionalNumber(counts[name]);
|
|
313
|
+
return value === undefined
|
|
314
|
+
? []
|
|
315
|
+
: [
|
|
316
|
+
{
|
|
317
|
+
name,
|
|
318
|
+
value,
|
|
319
|
+
unit: "count",
|
|
320
|
+
period: "lifetime",
|
|
321
|
+
fetchedAt: now(),
|
|
322
|
+
freshness: "unknown",
|
|
323
|
+
source: "X API v2",
|
|
324
|
+
},
|
|
325
|
+
];
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
async function validateReplyParent(ref, context) {
|
|
329
|
+
authorize(ref, context);
|
|
330
|
+
const parent = object(object(await request(`/2/tweets/${encodeURIComponent(ref.commentId)}`, context, undefined, {
|
|
331
|
+
"tweet.fields": "conversation_id",
|
|
332
|
+
}))["data"]);
|
|
333
|
+
if (parent["id"] !== ref.commentId)
|
|
334
|
+
throw new SocialError({
|
|
335
|
+
code: "unauthorized",
|
|
336
|
+
operation: "comments.write",
|
|
337
|
+
message: "X did not return the declared reply parent.",
|
|
338
|
+
});
|
|
339
|
+
if (ref.postId !== ref.commentId) {
|
|
340
|
+
const root = object(object(await request(`/2/tweets/${encodeURIComponent(ref.postId)}`, context, undefined, {
|
|
341
|
+
"tweet.fields": "conversation_id",
|
|
342
|
+
}))["data"]);
|
|
343
|
+
if (root["id"] !== ref.postId ||
|
|
344
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
|
|
345
|
+
(typeof parent["conversation_id"] === "string" &&
|
|
346
|
+
parent["conversation_id"] !== root["conversation_id"]))
|
|
347
|
+
throw new SocialError({
|
|
348
|
+
code: "unauthorized",
|
|
349
|
+
operation: "comments.write",
|
|
350
|
+
message: "Reply parent does not belong to the declared X conversation.",
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async function uploadImage(media, context) {
|
|
355
|
+
requireUserToken("media.upload");
|
|
356
|
+
if (media.source.kind !== "blob" ||
|
|
357
|
+
media.kind !== "image" ||
|
|
358
|
+
media.source.blob.size > 5 * 1024 * 1024)
|
|
359
|
+
throw new SocialError({
|
|
360
|
+
code: "invalid_input",
|
|
361
|
+
operation: "media.upload",
|
|
362
|
+
message: "This X slice uploads image Blobs up to 5 MiB.",
|
|
363
|
+
});
|
|
364
|
+
const body = new FormData();
|
|
365
|
+
body.set("media", media.source.blob, media.filename ?? "image");
|
|
366
|
+
body.set("media_category", "tweet_image");
|
|
367
|
+
let result;
|
|
368
|
+
try {
|
|
369
|
+
result = await http({
|
|
370
|
+
url: new URL("https://api.x.com/2/media/upload"),
|
|
371
|
+
method: "POST",
|
|
372
|
+
headers: { Authorization: `Bearer ${options.auth.accessToken}` },
|
|
373
|
+
body,
|
|
374
|
+
timeoutMs: remainingBudget(context),
|
|
375
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
376
|
+
...(context.signal ? { signal: context.signal } : {}),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
if (!(error instanceof HttpError))
|
|
381
|
+
throw error;
|
|
382
|
+
throw new SocialError({
|
|
383
|
+
code: "media_error",
|
|
384
|
+
operation: "media.upload",
|
|
385
|
+
message: error.message,
|
|
386
|
+
upstreamStatus: error.status,
|
|
387
|
+
retryDisposition: { kind: "never" },
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
const data = object(object(result)["data"]);
|
|
391
|
+
if (data["processing_info"] && object(data["processing_info"])["state"] !== "succeeded")
|
|
392
|
+
throw new SocialError({
|
|
393
|
+
code: "media_error",
|
|
394
|
+
operation: "media.upload",
|
|
395
|
+
message: "X media processing is not complete. No post was created.",
|
|
396
|
+
});
|
|
397
|
+
return string(data["id"]);
|
|
398
|
+
}
|
|
399
|
+
async function createPost(account, text, context, extra = {}, targetIndex = 0) {
|
|
400
|
+
authorize(account, context);
|
|
401
|
+
const result = object(await request("/2/tweets", context, { text, ...extra }));
|
|
402
|
+
const data = result["data"] ? object(result["data"]) : {};
|
|
403
|
+
const id = optionalString(data["id"]);
|
|
404
|
+
const base = { account, targetIndex, observedAt: now() };
|
|
405
|
+
if (!id)
|
|
406
|
+
return {
|
|
407
|
+
...base,
|
|
408
|
+
state: "unknown",
|
|
409
|
+
reason: "unmapped-state",
|
|
410
|
+
diagnostic: "X create response lacks a native ID. Reconcile before retrying.",
|
|
411
|
+
};
|
|
412
|
+
return {
|
|
413
|
+
...base,
|
|
414
|
+
state: "published",
|
|
415
|
+
post: {
|
|
416
|
+
kind: "platform-post",
|
|
417
|
+
version: 1,
|
|
418
|
+
backend: account.backend,
|
|
419
|
+
platform: "x",
|
|
420
|
+
accountId: account.accountId,
|
|
421
|
+
postId: id,
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
let nativeAdapter;
|
|
426
|
+
const adapter = defineAdapter({
|
|
427
|
+
id: "x",
|
|
428
|
+
capabilities: {
|
|
429
|
+
schemaVersion: 1,
|
|
430
|
+
backend: "x",
|
|
431
|
+
apiRevision: "X API v2 / OpenAPI 2.168",
|
|
432
|
+
runtime: ["node22", "node24", "bun"],
|
|
433
|
+
capabilities: [
|
|
434
|
+
{
|
|
435
|
+
platform: "x",
|
|
436
|
+
operation: "posts.publish",
|
|
437
|
+
availability: "available",
|
|
438
|
+
formats: ["text", "image"],
|
|
439
|
+
requiredScopes: ["tweet.read", "tweet.write", "users.read", "media.write"],
|
|
440
|
+
notes: "User-context OAuth2 token; current X API access/billing required. Up to four static JPEG/PNG image Blobs, each at most 5 MiB. Video/GIF processing is outside this slice.",
|
|
441
|
+
},
|
|
442
|
+
...[
|
|
443
|
+
"accounts.read",
|
|
444
|
+
"posts.list",
|
|
445
|
+
"posts.read",
|
|
446
|
+
"analytics.read",
|
|
447
|
+
"analytics.account.read",
|
|
448
|
+
"comments.write",
|
|
449
|
+
"posts.repost",
|
|
450
|
+
"posts.quote",
|
|
451
|
+
"posts.delete",
|
|
452
|
+
"polls.create",
|
|
453
|
+
"bookmarks.read",
|
|
454
|
+
"bookmarks.write",
|
|
455
|
+
"follows.write",
|
|
456
|
+
"mentions.read",
|
|
457
|
+
"likes.read",
|
|
458
|
+
"likes.write",
|
|
459
|
+
"lists.read",
|
|
460
|
+
"lists.write",
|
|
461
|
+
"timelines.read",
|
|
462
|
+
].map((operation) => ({
|
|
463
|
+
platform: "x",
|
|
464
|
+
operation,
|
|
465
|
+
availability: "available",
|
|
466
|
+
...(["likes.read"].includes(operation)
|
|
467
|
+
? { requiredScopes: ["like.read", "tweet.read", "users.read"] }
|
|
468
|
+
: ["likes.write"].includes(operation)
|
|
469
|
+
? { requiredScopes: ["like.write", "tweet.read", "users.read"] }
|
|
470
|
+
: ["lists.read"].includes(operation)
|
|
471
|
+
? { requiredScopes: ["list.read", "tweet.read", "users.read"] }
|
|
472
|
+
: ["lists.write"].includes(operation)
|
|
473
|
+
? { requiredScopes: ["list.write", "tweet.read", "users.read"] }
|
|
474
|
+
: ["timelines.read", "mentions.read", "posts.list"].includes(operation)
|
|
475
|
+
? { requiredScopes: ["tweet.read", "users.read"] }
|
|
476
|
+
: ["posts.quote", "posts.delete", "posts.publish"].includes(operation)
|
|
477
|
+
? { requiredScopes: ["tweet.read", "tweet.write", "users.read"] }
|
|
478
|
+
: {}),
|
|
479
|
+
})),
|
|
480
|
+
{
|
|
481
|
+
platform: "x",
|
|
482
|
+
operation: "posts.removeFromPlatform",
|
|
483
|
+
availability: "available",
|
|
484
|
+
requiredScopes: ["tweet.read", "tweet.write", "users.read"],
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
platform: "x",
|
|
488
|
+
operation: "graph.read",
|
|
489
|
+
availability: "available",
|
|
490
|
+
requiredScopes: ["users.read", "follows.read", "mute.read", "block.read"],
|
|
491
|
+
notes: "The required read scope depends on the requested relationship kind.",
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
platform: "x",
|
|
495
|
+
operation: "profiles.read",
|
|
496
|
+
availability: "available",
|
|
497
|
+
requiredScopes: ["users.read"],
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
platform: "x",
|
|
501
|
+
operation: "graph.follow",
|
|
502
|
+
availability: "available",
|
|
503
|
+
requiredScopes: ["users.read", "follows.write"],
|
|
504
|
+
notes: "Follow writes require an eligible paid X API tier.",
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
platform: "x",
|
|
508
|
+
operation: "graph.unfollow",
|
|
509
|
+
availability: "available",
|
|
510
|
+
requiredScopes: ["users.read", "follows.write"],
|
|
511
|
+
notes: "Follow writes require an eligible paid X API tier.",
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
platform: "x",
|
|
515
|
+
operation: "graph.mute",
|
|
516
|
+
availability: "available",
|
|
517
|
+
requiredScopes: ["users.read", "mute.write"],
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
platform: "x",
|
|
521
|
+
operation: "graph.unmute",
|
|
522
|
+
availability: "available",
|
|
523
|
+
requiredScopes: ["users.read", "mute.write"],
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
platform: "x",
|
|
527
|
+
operation: "graph.block",
|
|
528
|
+
availability: "available",
|
|
529
|
+
requiredScopes: ["users.read", "block.write"],
|
|
530
|
+
notes: "Enterprise plan only.",
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
platform: "x",
|
|
534
|
+
operation: "graph.unblock",
|
|
535
|
+
availability: "available",
|
|
536
|
+
requiredScopes: ["users.read", "block.write"],
|
|
537
|
+
notes: "Enterprise plan only.",
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
platform: "x",
|
|
541
|
+
operation: "lists.pinned.read",
|
|
542
|
+
availability: "available",
|
|
543
|
+
requiredScopes: ["users.read", "list.read"],
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
platform: "x",
|
|
547
|
+
operation: "lists.pinned.write",
|
|
548
|
+
availability: "available",
|
|
549
|
+
requiredScopes: ["users.read", "list.write"],
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
platform: "x",
|
|
553
|
+
operation: "messages.conversation.write",
|
|
554
|
+
availability: "available",
|
|
555
|
+
requiredScopes: ["dm.write"],
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
platform: "x",
|
|
559
|
+
operation: "messages.group.write",
|
|
560
|
+
availability: "available",
|
|
561
|
+
requiredScopes: ["dm.write"],
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
platform: "x",
|
|
565
|
+
operation: "search.posts",
|
|
566
|
+
availability: "available",
|
|
567
|
+
requiredScopes: ["tweet.read", "users.read"],
|
|
568
|
+
notes: "Recent search covers the last 7 days. Full-archive search requires an eligible X API pay-per-use or Enterprise plan.",
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
platform: "x",
|
|
572
|
+
operation: "media.video",
|
|
573
|
+
availability: "not-implemented-by-adapter",
|
|
574
|
+
formats: ["video"],
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
platform: "x",
|
|
578
|
+
operation: "media.gif",
|
|
579
|
+
availability: "not-implemented-by-adapter",
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
platform: "x",
|
|
583
|
+
operation: "messages.read",
|
|
584
|
+
availability: "available",
|
|
585
|
+
requiredScopes: ["dm.read", "users.read", "tweet.read"],
|
|
586
|
+
notes: "Requires a user-context token and an X API tier that includes Direct Messages.",
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
platform: "x",
|
|
590
|
+
operation: "messages.write",
|
|
591
|
+
availability: "available",
|
|
592
|
+
requiredScopes: ["dm.write", "dm.read", "users.read", "tweet.read"],
|
|
593
|
+
notes: "Requires a user-context token and an X API tier that includes Direct Messages.",
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
platform: "x",
|
|
597
|
+
operation: "streams.read",
|
|
598
|
+
availability: "not-implemented-by-adapter",
|
|
599
|
+
notes: "Filtered stream rules and streaming transport are not implemented by this adapter.",
|
|
600
|
+
},
|
|
601
|
+
],
|
|
602
|
+
},
|
|
603
|
+
accounts: {
|
|
604
|
+
async list(_input, context) {
|
|
605
|
+
return { items: [await readAccount(context)] };
|
|
606
|
+
},
|
|
607
|
+
async get(ref, context) {
|
|
608
|
+
authorize(ref, context);
|
|
609
|
+
return readAccount(context);
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
graph: {
|
|
613
|
+
async getProfile(account, input, context) {
|
|
614
|
+
const value = input.profileId !== undefined
|
|
615
|
+
? await nativeAdapter.getUserById({ account, userId: input.profileId, context })
|
|
616
|
+
: input.handle !== undefined
|
|
617
|
+
? await nativeAdapter.getUserByUsername({ account, username: input.handle, context })
|
|
618
|
+
: await nativeAdapter.getMe({ account, context });
|
|
619
|
+
const user = object(object(value)["data"]);
|
|
620
|
+
const id = string(user["id"]);
|
|
621
|
+
return {
|
|
622
|
+
ref: profileRef({
|
|
623
|
+
backend: context.backendInstance,
|
|
624
|
+
platform: "x",
|
|
625
|
+
accountId: account.accountId,
|
|
626
|
+
profileId: id,
|
|
627
|
+
}),
|
|
628
|
+
...(typeof user["name"] === "string" ? { displayName: user["name"] } : {}),
|
|
629
|
+
...(typeof user["username"] === "string" ? { handle: user["username"] } : {}),
|
|
630
|
+
...(typeof user["description"] === "string" ? { bio: user["description"] } : {}),
|
|
631
|
+
native: user,
|
|
632
|
+
};
|
|
633
|
+
},
|
|
634
|
+
async listRelationships(account, input, context) {
|
|
635
|
+
const pagination = {
|
|
636
|
+
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
|
|
637
|
+
...(input.limit === undefined ? {} : { limit: input.limit }),
|
|
638
|
+
};
|
|
639
|
+
const result = input.kind === "following"
|
|
640
|
+
? await nativeAdapter.following({
|
|
641
|
+
account,
|
|
642
|
+
...pagination,
|
|
643
|
+
userId: account.accountId,
|
|
644
|
+
context,
|
|
645
|
+
})
|
|
646
|
+
: input.kind === "followers"
|
|
647
|
+
? await nativeAdapter.followers({
|
|
648
|
+
account,
|
|
649
|
+
...pagination,
|
|
650
|
+
userId: account.accountId,
|
|
651
|
+
context,
|
|
652
|
+
})
|
|
653
|
+
: input.kind === "blocked"
|
|
654
|
+
? await nativeAdapter.blockedUsers({
|
|
655
|
+
account,
|
|
656
|
+
...pagination,
|
|
657
|
+
context,
|
|
658
|
+
})
|
|
659
|
+
: await nativeAdapter.mutedUsers({
|
|
660
|
+
account,
|
|
661
|
+
...pagination,
|
|
662
|
+
context,
|
|
663
|
+
});
|
|
664
|
+
return {
|
|
665
|
+
items: result.items.map((entry) => {
|
|
666
|
+
const user = object(entry);
|
|
667
|
+
const id = string(user["id"]);
|
|
668
|
+
return {
|
|
669
|
+
profile: profileRef({
|
|
670
|
+
backend: context.backendInstance,
|
|
671
|
+
platform: "x",
|
|
672
|
+
accountId: account.accountId,
|
|
673
|
+
profileId: id,
|
|
674
|
+
}),
|
|
675
|
+
relationship: input.kind === "following"
|
|
676
|
+
? "following"
|
|
677
|
+
: input.kind === "followers"
|
|
678
|
+
? "follower"
|
|
679
|
+
: input.kind,
|
|
680
|
+
};
|
|
681
|
+
}),
|
|
682
|
+
...(result.nextCursor === undefined ? {} : { nextCursor: result.nextCursor }),
|
|
683
|
+
};
|
|
684
|
+
},
|
|
685
|
+
async follow(target, context) {
|
|
686
|
+
const account = connectedAccountRef({
|
|
687
|
+
backend: target.backend,
|
|
688
|
+
platform: "x",
|
|
689
|
+
accountId: target.accountId,
|
|
690
|
+
});
|
|
691
|
+
await nativeAdapter.followUser({ account, userId: target.profileId, context });
|
|
692
|
+
return { profile: target, relationship: "following" };
|
|
693
|
+
},
|
|
694
|
+
async unfollow(target, context) {
|
|
695
|
+
const account = connectedAccountRef({
|
|
696
|
+
backend: target.backend,
|
|
697
|
+
platform: "x",
|
|
698
|
+
accountId: target.accountId,
|
|
699
|
+
});
|
|
700
|
+
await nativeAdapter.unfollowUser({ account, userId: target.profileId, context });
|
|
701
|
+
},
|
|
702
|
+
async block(target, context) {
|
|
703
|
+
const account = connectedAccountRef({
|
|
704
|
+
backend: target.backend,
|
|
705
|
+
platform: "x",
|
|
706
|
+
accountId: target.accountId,
|
|
707
|
+
});
|
|
708
|
+
await nativeAdapter.blockUser({ account, userId: target.profileId, context });
|
|
709
|
+
return { profile: target, relationship: "blocked" };
|
|
710
|
+
},
|
|
711
|
+
async unblock(target, context) {
|
|
712
|
+
const account = connectedAccountRef({
|
|
713
|
+
backend: target.backend,
|
|
714
|
+
platform: "x",
|
|
715
|
+
accountId: target.accountId,
|
|
716
|
+
});
|
|
717
|
+
await nativeAdapter.unblockUser({ account, userId: target.profileId, context });
|
|
718
|
+
},
|
|
719
|
+
async mute(target, context) {
|
|
720
|
+
const account = connectedAccountRef({
|
|
721
|
+
backend: target.backend,
|
|
722
|
+
platform: "x",
|
|
723
|
+
accountId: target.accountId,
|
|
724
|
+
});
|
|
725
|
+
await nativeAdapter.muteUser({ account, userId: target.profileId, context });
|
|
726
|
+
return { profile: target, relationship: "muted" };
|
|
727
|
+
},
|
|
728
|
+
async unmute(target, context) {
|
|
729
|
+
const account = connectedAccountRef({
|
|
730
|
+
backend: target.backend,
|
|
731
|
+
platform: "x",
|
|
732
|
+
accountId: target.accountId,
|
|
733
|
+
});
|
|
734
|
+
await nativeAdapter.unmuteUser({ account, userId: target.profileId, context });
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
search: {
|
|
738
|
+
posts: (account, input, context) => searchPosts(account, input, context),
|
|
739
|
+
},
|
|
740
|
+
posts: {
|
|
741
|
+
list: listPosts,
|
|
742
|
+
prepareTarget(target) {
|
|
743
|
+
const issues = [];
|
|
744
|
+
const fail = (code, message) => issues.push({ code, message, severity: "error", targetIndex: target.targetIndex });
|
|
745
|
+
if (target.account.platform !== "x" || target.account.accountId !== options.auth.userId)
|
|
746
|
+
fail("x.account", "Select the configured X user.");
|
|
747
|
+
const text = target.content.text ?? "";
|
|
748
|
+
if (text && !isValidXText(text))
|
|
749
|
+
fail("x.text", "Text exceeds X's weighted 280-character limit or contains invalid characters.");
|
|
750
|
+
if (target.schedule || target.content.link)
|
|
751
|
+
fail("x.operation", "Scheduling needs an application runner; place URLs explicitly in text.");
|
|
752
|
+
if (target.replyTo &&
|
|
753
|
+
(target.replyTo.platform !== "x" ||
|
|
754
|
+
target.replyTo.backend !== target.account.backend ||
|
|
755
|
+
target.replyTo.accountId !== target.account.accountId ||
|
|
756
|
+
target.replyTo.kind !== "platform-post"))
|
|
757
|
+
fail("x.reply", "Use a platform-post reply reference authorized for this account and backend.");
|
|
758
|
+
const settings = target.options === undefined ? {} : object(target.options);
|
|
759
|
+
if (Object.keys(settings).some((key) => key !== "replySettings") ||
|
|
760
|
+
(settings["replySettings"] !== undefined &&
|
|
761
|
+
!["everyone", "following", "mentionedUsers"].includes(String(settings["replySettings"]))))
|
|
762
|
+
fail("x.options", "Provide only a supported replySettings value.");
|
|
763
|
+
const media = target.content.media ?? [];
|
|
764
|
+
if (media.length > 4)
|
|
765
|
+
fail("x.media_count", "Attach up to four images.");
|
|
766
|
+
for (const item of media) {
|
|
767
|
+
if (item.kind !== "image" ||
|
|
768
|
+
item.source.kind !== "blob" ||
|
|
769
|
+
!["image/jpeg", "image/png"].includes(item.mimeType ?? ""))
|
|
770
|
+
fail("x.image", "This slice requires a JPEG or PNG image Blob.");
|
|
771
|
+
else if (item.source.blob.size > 5 * 1024 * 1024)
|
|
772
|
+
fail("x.image_size", "Image exceeds the 5 MiB limit.");
|
|
773
|
+
if (item.altText !== undefined)
|
|
774
|
+
fail("x.alt_text", "This slice has no verified media metadata mapping. Use a backend that supports alt text.");
|
|
775
|
+
}
|
|
776
|
+
return issues;
|
|
777
|
+
},
|
|
778
|
+
async publishTarget(target, context) {
|
|
779
|
+
authorize(target.account, context);
|
|
780
|
+
const ids = [];
|
|
781
|
+
for (const media of target.content.media ?? [])
|
|
782
|
+
ids.push(await uploadImage(media, context));
|
|
783
|
+
const settings = target.options === undefined ? {} : object(target.options);
|
|
784
|
+
return createPost(target.account, target.content.text ?? "", context, {
|
|
785
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
786
|
+
...(ids.length ? { media: { media_ids: ids } } : {}),
|
|
787
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
788
|
+
...(target.replyTo ? { reply: { in_reply_to_tweet_id: target.replyTo.postId } } : {}),
|
|
789
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
790
|
+
...(settings["replySettings"] && settings["replySettings"] !== "everyone"
|
|
791
|
+
? { reply_settings: string(settings["replySettings"]) }
|
|
792
|
+
: {}),
|
|
793
|
+
}, target.targetIndex);
|
|
794
|
+
},
|
|
795
|
+
async get(ref, context) {
|
|
796
|
+
return publicFields(await readPost(ref, context), [
|
|
797
|
+
"id",
|
|
798
|
+
"text",
|
|
799
|
+
"author_id",
|
|
800
|
+
"created_at",
|
|
801
|
+
"conversation_id",
|
|
802
|
+
]);
|
|
803
|
+
},
|
|
804
|
+
async removeFromPlatform(ref, context) {
|
|
805
|
+
authorize(ref, context);
|
|
806
|
+
await nativeAdapter.deletePost({
|
|
807
|
+
account: connectedAccountRef({
|
|
808
|
+
backend: ref.backend,
|
|
809
|
+
platform: "x",
|
|
810
|
+
accountId: ref.accountId,
|
|
811
|
+
}),
|
|
812
|
+
postId: ref.postId,
|
|
813
|
+
context,
|
|
814
|
+
});
|
|
815
|
+
},
|
|
816
|
+
},
|
|
817
|
+
comments: {
|
|
818
|
+
async list() {
|
|
819
|
+
throw new SocialError({
|
|
820
|
+
code: "unsupported_capability",
|
|
821
|
+
operation: "comments.read",
|
|
822
|
+
message: "X reply search is not implemented in this slice.",
|
|
823
|
+
});
|
|
824
|
+
},
|
|
825
|
+
async reply(ref, content, context) {
|
|
826
|
+
await validateReplyParent(ref, context);
|
|
827
|
+
if (!isValidXText(content.text))
|
|
828
|
+
throw new SocialError({
|
|
829
|
+
code: "invalid_input",
|
|
830
|
+
operation: "comments.write",
|
|
831
|
+
message: "Reply exceeds X's weighted text limits.",
|
|
832
|
+
});
|
|
833
|
+
const result = await createPost({ ...ref, kind: "connected-account" }, content.text, context, { reply: { in_reply_to_tweet_id: ref.commentId } });
|
|
834
|
+
if (result.state !== "published")
|
|
835
|
+
throw new SocialError({
|
|
836
|
+
code: "ambiguous_outcome",
|
|
837
|
+
operation: "comments.write",
|
|
838
|
+
message: "X reply lacks a confirmed native ID. Reconcile before retrying.",
|
|
839
|
+
retryDisposition: { kind: "reconcile-first" },
|
|
840
|
+
});
|
|
841
|
+
return { ...ref, commentId: result.post.postId };
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
messages: {
|
|
845
|
+
async listConversations(account, input, context) {
|
|
846
|
+
// X has no conversation-list endpoint. This page is derived from /2/dm_events,
|
|
847
|
+
// with the latest event for each distinct dm_conversation_id.
|
|
848
|
+
requireUserToken("messages.read");
|
|
849
|
+
if (input.limit !== undefined &&
|
|
850
|
+
(!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100))
|
|
851
|
+
throw new SocialError({
|
|
852
|
+
code: "invalid_input",
|
|
853
|
+
operation: "messages.read",
|
|
854
|
+
message: "X DM limits must be integers from 1 through 100.",
|
|
855
|
+
});
|
|
856
|
+
// The cursor carries the event cursor plus fixed-width hashes of the conversations
|
|
857
|
+
// already returned, so later event pages skip them. To stay under the client cursor
|
|
858
|
+
// size limit, the walk ends after maxConversationHashes distinct conversations
|
|
859
|
+
// rather than forgetting earlier ones and returning them again.
|
|
860
|
+
let eventCursor;
|
|
861
|
+
const seen = [];
|
|
862
|
+
if (input.cursor !== undefined) {
|
|
863
|
+
try {
|
|
864
|
+
const state = parseObject(JSON.parse(input.cursor));
|
|
865
|
+
eventCursor = optionalString(state["c"]);
|
|
866
|
+
const hashes = string(state["s"]);
|
|
867
|
+
if (hashes.length % conversationHashWidth !== 0)
|
|
868
|
+
throw new Error("bad cursor");
|
|
869
|
+
for (let at = 0; at < hashes.length; at += conversationHashWidth)
|
|
870
|
+
seen.push(hashes.slice(at, at + conversationHashWidth));
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
throw new SocialError({
|
|
874
|
+
code: "invalid_input",
|
|
875
|
+
operation: "messages.read",
|
|
876
|
+
message: "Use a cursor returned by listConversations.",
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
const seenSet = new Set(seen);
|
|
881
|
+
const target = input.limit ?? 100;
|
|
882
|
+
const items = [];
|
|
883
|
+
// A walk that has reached the conversation cap has no next page.
|
|
884
|
+
const pageFrom = (c) => seen.length >= maxConversationHashes
|
|
885
|
+
? { items }
|
|
886
|
+
: { items, nextCursor: JSON.stringify({ c, s: seen.join("") }) };
|
|
887
|
+
for (let fetches = 0; fetches < 10; fetches++) {
|
|
888
|
+
const page = await nativeAdapter.listDirectMessages({
|
|
889
|
+
account,
|
|
890
|
+
...(eventCursor === undefined ? {} : { cursor: eventCursor }),
|
|
891
|
+
...(input.limit === undefined ? {} : { limit: input.limit }),
|
|
892
|
+
context,
|
|
893
|
+
});
|
|
894
|
+
for (const event of page.items) {
|
|
895
|
+
const conversationId = optionalString(event["dm_conversation_id"]);
|
|
896
|
+
if (conversationId === undefined)
|
|
897
|
+
continue;
|
|
898
|
+
const hash = conversationHash(conversationId);
|
|
899
|
+
if (seenSet.has(hash))
|
|
900
|
+
continue;
|
|
901
|
+
if (seen.length >= maxConversationHashes)
|
|
902
|
+
return { items };
|
|
903
|
+
// Stop mid-page and reread this event page next time; seen IDs skip the rest.
|
|
904
|
+
if (items.length === target)
|
|
905
|
+
return pageFrom(eventCursor);
|
|
906
|
+
seenSet.add(hash);
|
|
907
|
+
seen.push(hash);
|
|
908
|
+
items.push(event);
|
|
909
|
+
}
|
|
910
|
+
if (page.nextCursor === undefined)
|
|
911
|
+
return { items };
|
|
912
|
+
eventCursor = page.nextCursor;
|
|
913
|
+
if (items.length === target)
|
|
914
|
+
break;
|
|
915
|
+
}
|
|
916
|
+
return pageFrom(eventCursor);
|
|
917
|
+
},
|
|
918
|
+
async listMessages(conversation, input, context) {
|
|
919
|
+
requireUserToken("messages.read");
|
|
920
|
+
return nativeAdapter.listDirectMessages({
|
|
921
|
+
account: connectedAccountRef({
|
|
922
|
+
backend: conversation.backend,
|
|
923
|
+
platform: "x",
|
|
924
|
+
accountId: conversation.accountId,
|
|
925
|
+
}),
|
|
926
|
+
conversationId: conversation.conversationId,
|
|
927
|
+
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
|
|
928
|
+
...(input.limit === undefined ? {} : { limit: input.limit }),
|
|
929
|
+
context,
|
|
930
|
+
});
|
|
931
|
+
},
|
|
932
|
+
async send(conversation, content, context) {
|
|
933
|
+
requireUserToken("messages.write");
|
|
934
|
+
if (!content.text.trim())
|
|
935
|
+
throw new SocialError({
|
|
936
|
+
code: "invalid_input",
|
|
937
|
+
operation: "messages.write",
|
|
938
|
+
message: "X direct messages require non-empty text.",
|
|
939
|
+
});
|
|
940
|
+
return nativeAdapter.sendConversationMessage({
|
|
941
|
+
account: connectedAccountRef({
|
|
942
|
+
backend: conversation.backend,
|
|
943
|
+
platform: "x",
|
|
944
|
+
accountId: conversation.accountId,
|
|
945
|
+
}),
|
|
946
|
+
conversationId: conversation.conversationId,
|
|
947
|
+
text: content.text,
|
|
948
|
+
context,
|
|
949
|
+
});
|
|
950
|
+
},
|
|
951
|
+
},
|
|
952
|
+
analytics: {
|
|
953
|
+
getAccountMetrics,
|
|
954
|
+
async getPostMetrics(ref, context) {
|
|
955
|
+
const row = await readPost(ref, context);
|
|
956
|
+
const counts = row["public_metrics"] ? object(row["public_metrics"]) : {};
|
|
957
|
+
return [
|
|
958
|
+
["likes", "like_count"],
|
|
959
|
+
["reposts", "retweet_count"],
|
|
960
|
+
["replies", "reply_count"],
|
|
961
|
+
["quotes", "quote_count"],
|
|
962
|
+
["impressions", "impression_count"],
|
|
963
|
+
["bookmarks", "bookmark_count"],
|
|
964
|
+
].flatMap(([name, key]) => {
|
|
965
|
+
const value = key ? optionalNumber(counts[key]) : undefined;
|
|
966
|
+
return value === undefined || !name
|
|
967
|
+
? []
|
|
968
|
+
: [
|
|
969
|
+
{
|
|
970
|
+
name,
|
|
971
|
+
value,
|
|
972
|
+
unit: "count",
|
|
973
|
+
period: "lifetime",
|
|
974
|
+
fetchedAt: now(),
|
|
975
|
+
freshness: "unknown",
|
|
976
|
+
source: "X API v2",
|
|
977
|
+
},
|
|
978
|
+
];
|
|
979
|
+
});
|
|
980
|
+
},
|
|
981
|
+
},
|
|
982
|
+
native: (nativeAdapter = {
|
|
983
|
+
async searchRecentPosts({ account, search, context }) {
|
|
984
|
+
return searchPosts(account, { ...search, scope: "recent" }, context, search);
|
|
985
|
+
},
|
|
986
|
+
async searchAllPosts({ account, search, context }) {
|
|
987
|
+
return searchPosts(account, { ...search, scope: "all" }, context, search);
|
|
988
|
+
},
|
|
989
|
+
readPost,
|
|
990
|
+
async repost({ account, postId, context }) {
|
|
991
|
+
authorize(account, context);
|
|
992
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
993
|
+
return object(await request(`/2/users/${encodeURIComponent(account.accountId)}/retweets`, context, {
|
|
994
|
+
tweet_id: postId,
|
|
995
|
+
}));
|
|
996
|
+
},
|
|
997
|
+
async quote({ account, text, quotedPostId, context }) {
|
|
998
|
+
authorize(account, context);
|
|
999
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
1000
|
+
return object(await request("/2/tweets", context, { text, quote_tweet_id: quotedPostId }));
|
|
1001
|
+
},
|
|
1002
|
+
async deletePost({ account, postId, context }) {
|
|
1003
|
+
authorize(account, context);
|
|
1004
|
+
await request(`/2/tweets/${encodeURIComponent(postId)}`, context, undefined, {}, "DELETE");
|
|
1005
|
+
},
|
|
1006
|
+
async createPoll({ account, text, options: pollOptions, durationMinutes, context }) {
|
|
1007
|
+
authorize(account, context);
|
|
1008
|
+
if (pollOptions.length < 2 ||
|
|
1009
|
+
pollOptions.length > 4 ||
|
|
1010
|
+
durationMinutes < 5 ||
|
|
1011
|
+
durationMinutes > 10080)
|
|
1012
|
+
throw new SocialError({
|
|
1013
|
+
code: "invalid_input",
|
|
1014
|
+
operation: "x.polls.create",
|
|
1015
|
+
message: "X polls require 2-4 options and a duration from 5 minutes to 7 days.",
|
|
1016
|
+
});
|
|
1017
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
1018
|
+
return object(await request("/2/tweets", context, {
|
|
1019
|
+
text,
|
|
1020
|
+
poll: { options: [...pollOptions], duration_minutes: durationMinutes },
|
|
1021
|
+
}));
|
|
1022
|
+
},
|
|
1023
|
+
async bookmarks({ account, context }) {
|
|
1024
|
+
authorize(account, context);
|
|
1025
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
1026
|
+
return object(await request(`/2/users/${encodeURIComponent(account.accountId)}/bookmarks`, context));
|
|
1027
|
+
},
|
|
1028
|
+
async bookmark({ account, postId, context }) {
|
|
1029
|
+
authorize(account, context);
|
|
1030
|
+
await request(`/2/users/${encodeURIComponent(account.accountId)}/bookmarks`, context, {
|
|
1031
|
+
tweet_id: postId,
|
|
1032
|
+
});
|
|
1033
|
+
},
|
|
1034
|
+
async removeBookmark({ account, postId, context }) {
|
|
1035
|
+
authorize(account, context);
|
|
1036
|
+
await request(`/2/users/${encodeURIComponent(account.accountId)}/bookmarks/${encodeURIComponent(postId)}`, context, undefined, {}, "DELETE");
|
|
1037
|
+
},
|
|
1038
|
+
async follow({ account, userId, context }) {
|
|
1039
|
+
authorize(account, context);
|
|
1040
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
1041
|
+
return object(await request(`/2/users/${encodeURIComponent(account.accountId)}/following`, context, {
|
|
1042
|
+
target_user_id: userId,
|
|
1043
|
+
}));
|
|
1044
|
+
},
|
|
1045
|
+
async unfollow({ account, userId, context }) {
|
|
1046
|
+
authorize(account, context);
|
|
1047
|
+
await request(`/2/users/${encodeURIComponent(account.accountId)}/following/${encodeURIComponent(userId)}`, context, undefined, {}, "DELETE");
|
|
1048
|
+
},
|
|
1049
|
+
async listDirectMessages({ account, participantId, conversationId, cursor, limit, context }) {
|
|
1050
|
+
authorize(account, context);
|
|
1051
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 100))
|
|
1052
|
+
throw new SocialError({
|
|
1053
|
+
code: "invalid_input",
|
|
1054
|
+
operation: "messages.read",
|
|
1055
|
+
message: "X DM limits must be integers from 1 through 100.",
|
|
1056
|
+
});
|
|
1057
|
+
const path = conversationId
|
|
1058
|
+
? `/2/dm_conversations/${encodeURIComponent(conversationId)}/dm_events`
|
|
1059
|
+
: participantId
|
|
1060
|
+
? `/2/dm_conversations/with/${encodeURIComponent(participantId)}/dm_events`
|
|
1061
|
+
: "/2/dm_events";
|
|
1062
|
+
const result = object(await request(path, context, undefined, {
|
|
1063
|
+
"dm_event.fields": "id,text,event_type,created_at,dm_conversation_id,attachments,entities",
|
|
1064
|
+
expansions: "sender_id,participant_ids",
|
|
1065
|
+
...(cursor === undefined ? {} : { pagination_token: cursor }),
|
|
1066
|
+
...(limit === undefined ? {} : { max_results: String(limit) }),
|
|
1067
|
+
}));
|
|
1068
|
+
const meta = result["meta"] === undefined ? {} : object(result["meta"]);
|
|
1069
|
+
const nextCursor = optionalString(meta["next_token"]);
|
|
1070
|
+
return {
|
|
1071
|
+
items: result["data"] === undefined ? [] : array(result["data"]).map(object),
|
|
1072
|
+
...(nextCursor === undefined ? {} : { nextCursor }),
|
|
1073
|
+
};
|
|
1074
|
+
},
|
|
1075
|
+
async sendDirectMessage({ account, participantId, text, context }) {
|
|
1076
|
+
authorize(account, context);
|
|
1077
|
+
const userRequest = requireUserToken("messages.write");
|
|
1078
|
+
if (!text.trim())
|
|
1079
|
+
throw new SocialError({
|
|
1080
|
+
code: "invalid_input",
|
|
1081
|
+
operation: "messages.write",
|
|
1082
|
+
message: "X direct messages require non-empty text.",
|
|
1083
|
+
});
|
|
1084
|
+
return object(await userRequest(`/2/dm_conversations/with/${encodeURIComponent(participantId)}/messages`, context, { text }));
|
|
1085
|
+
},
|
|
1086
|
+
async sendConversationMessage({ account, conversationId, text, attachments, context }) {
|
|
1087
|
+
authorize(account, context);
|
|
1088
|
+
if (!text.trim())
|
|
1089
|
+
throw new SocialError({
|
|
1090
|
+
code: "invalid_input",
|
|
1091
|
+
operation: "messages.conversation.write",
|
|
1092
|
+
message: "X direct messages require non-empty text.",
|
|
1093
|
+
});
|
|
1094
|
+
return object(await requireUserToken("messages.conversation.write")(`/2/dm_conversations/${encodeURIComponent(conversationId)}/messages`, context, { text, ...(attachments === undefined ? {} : { attachments }) }));
|
|
1095
|
+
},
|
|
1096
|
+
async createGroupConversation({ account, participantIds, message, context }) {
|
|
1097
|
+
authorize(account, context);
|
|
1098
|
+
if (!message.trim())
|
|
1099
|
+
throw new SocialError({
|
|
1100
|
+
code: "invalid_input",
|
|
1101
|
+
operation: "messages.group.write",
|
|
1102
|
+
message: "X direct messages require non-empty text.",
|
|
1103
|
+
});
|
|
1104
|
+
return object(await requireUserToken("messages.group.write")("/2/dm_conversations", context, {
|
|
1105
|
+
conversation_type: "Group",
|
|
1106
|
+
participant_ids: [...participantIds],
|
|
1107
|
+
message: { text: message },
|
|
1108
|
+
}));
|
|
1109
|
+
},
|
|
1110
|
+
async getUserById({ account, userId, context }) {
|
|
1111
|
+
authorize(account, context);
|
|
1112
|
+
const result = object(await readRequest("users")(`/2/users/${encodeURIComponent(userId)}`, context, undefined, {
|
|
1113
|
+
"user.fields": "id,name,username,description,created_at,public_metrics,profile_image_url,verified",
|
|
1114
|
+
}));
|
|
1115
|
+
const errors = result["errors"];
|
|
1116
|
+
if (errors !== undefined && result["data"] === undefined) {
|
|
1117
|
+
const detail = array(errors)[0] === undefined
|
|
1118
|
+
? "X user was not found."
|
|
1119
|
+
: (optionalString(object(array(errors)[0])["detail"]) ?? "X user was not found.");
|
|
1120
|
+
throw new SocialError({ code: "not_found", operation: "profiles.read", message: detail });
|
|
1121
|
+
}
|
|
1122
|
+
return result;
|
|
1123
|
+
},
|
|
1124
|
+
async getUserByUsername({ account, username, context }) {
|
|
1125
|
+
authorize(account, context);
|
|
1126
|
+
const result = object(await readRequest("users")(`/2/users/by/username/${encodeURIComponent(username)}`, context, undefined, {
|
|
1127
|
+
"user.fields": "id,name,username,description,created_at,public_metrics,profile_image_url,verified",
|
|
1128
|
+
}));
|
|
1129
|
+
if (result["errors"] !== undefined && result["data"] === undefined) {
|
|
1130
|
+
const first = array(result["errors"])[0];
|
|
1131
|
+
throw new SocialError({
|
|
1132
|
+
code: "not_found",
|
|
1133
|
+
operation: "profiles.read",
|
|
1134
|
+
message: first === undefined
|
|
1135
|
+
? "X user was not found."
|
|
1136
|
+
: (optionalString(object(first)["detail"]) ?? "X user was not found."),
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
return result;
|
|
1140
|
+
},
|
|
1141
|
+
async getMe({ account, context }) {
|
|
1142
|
+
authorize(account, context);
|
|
1143
|
+
return object(await readRequest("/2/users/me")("/2/users/me", context, undefined, {
|
|
1144
|
+
"user.fields": "id,name,username,description,created_at,public_metrics,profile_image_url,verified",
|
|
1145
|
+
}));
|
|
1146
|
+
},
|
|
1147
|
+
async followers({ account, userId, cursor, limit, context }) {
|
|
1148
|
+
return pageRequest(`/2/users/${encodeURIComponent(userId)}/followers`, account, { cursor, limit }, context);
|
|
1149
|
+
},
|
|
1150
|
+
async following({ account, userId, cursor, limit, context }) {
|
|
1151
|
+
return pageRequest(`/2/users/${encodeURIComponent(userId)}/following`, account, { cursor, limit }, context);
|
|
1152
|
+
},
|
|
1153
|
+
async followUser({ account, userId, context }) {
|
|
1154
|
+
authorize(account, context);
|
|
1155
|
+
return object(await requireUserToken("graph.follow")(`/2/users/${encodeURIComponent(account.accountId)}/following`, context, {
|
|
1156
|
+
target_user_id: userId,
|
|
1157
|
+
}));
|
|
1158
|
+
},
|
|
1159
|
+
async unfollowUser({ account, userId, context }) {
|
|
1160
|
+
authorize(account, context);
|
|
1161
|
+
await requireUserToken("graph.unfollow")(`/2/users/${encodeURIComponent(account.accountId)}/following/${encodeURIComponent(userId)}`, context, undefined, {}, "DELETE");
|
|
1162
|
+
},
|
|
1163
|
+
async muteUser({ account, userId, context }) {
|
|
1164
|
+
authorize(account, context);
|
|
1165
|
+
return object(await requireUserToken("graph.mute")(`/2/users/${encodeURIComponent(account.accountId)}/muting`, context, {
|
|
1166
|
+
target_user_id: userId,
|
|
1167
|
+
}));
|
|
1168
|
+
},
|
|
1169
|
+
async unmuteUser({ account, userId, context }) {
|
|
1170
|
+
authorize(account, context);
|
|
1171
|
+
await requireUserToken("graph.unmute")(`/2/users/${encodeURIComponent(account.accountId)}/muting/${encodeURIComponent(userId)}`, context, undefined, {}, "DELETE");
|
|
1172
|
+
},
|
|
1173
|
+
async mutedUsers({ account, cursor, limit, context }) {
|
|
1174
|
+
return pageRequest(`/2/users/${encodeURIComponent(account.accountId)}/muting`, account, { cursor, limit }, context);
|
|
1175
|
+
},
|
|
1176
|
+
async blockUser({ account, userId, context }) {
|
|
1177
|
+
authorize(account, context);
|
|
1178
|
+
return object(await requireUserToken("graph.block")(`/2/users/${encodeURIComponent(account.accountId)}/blocking`, context, {
|
|
1179
|
+
target_user_id: userId,
|
|
1180
|
+
}));
|
|
1181
|
+
},
|
|
1182
|
+
async unblockUser({ account, userId, context }) {
|
|
1183
|
+
authorize(account, context);
|
|
1184
|
+
await requireUserToken("graph.unblock")(`/2/users/${encodeURIComponent(account.accountId)}/blocking/${encodeURIComponent(userId)}`, context, undefined, {}, "DELETE");
|
|
1185
|
+
},
|
|
1186
|
+
async blockedUsers({ account, cursor, limit, context }) {
|
|
1187
|
+
return pageRequest(`/2/users/${encodeURIComponent(account.accountId)}/blocking`, account, { cursor, limit }, context);
|
|
1188
|
+
},
|
|
1189
|
+
async like({ account, postId, context }) {
|
|
1190
|
+
authorize(account, context);
|
|
1191
|
+
await requireUserToken("likes.write")(`/2/users/${encodeURIComponent(account.accountId)}/likes`, context, {
|
|
1192
|
+
tweet_id: postId,
|
|
1193
|
+
});
|
|
1194
|
+
},
|
|
1195
|
+
async unlike({ account, postId, context }) {
|
|
1196
|
+
authorize(account, context);
|
|
1197
|
+
await requireUserToken("likes.write")(`/2/users/${encodeURIComponent(account.accountId)}/likes/${encodeURIComponent(postId)}`, context, undefined, {}, "DELETE");
|
|
1198
|
+
},
|
|
1199
|
+
async likedPosts({ account, userId, cursor, limit, context }) {
|
|
1200
|
+
return pageRequest(`/2/users/${encodeURIComponent(userId ?? account.accountId)}/liked_tweets`, account, { cursor, limit }, context, {}, "id,name,description,created_at,public_metrics", "tweets");
|
|
1201
|
+
},
|
|
1202
|
+
async likingUsers({ account, postId, cursor, limit, context }) {
|
|
1203
|
+
return pageRequest(`/2/tweets/${encodeURIComponent(postId)}/liking_users`, account, { cursor, limit }, context, {}, "id,name,description,created_at,public_metrics", "users");
|
|
1204
|
+
},
|
|
1205
|
+
async createList({ account, name, description, isPrivate, context }) {
|
|
1206
|
+
authorize(account, context);
|
|
1207
|
+
return object(await request("/2/lists", context, {
|
|
1208
|
+
name,
|
|
1209
|
+
...(description === undefined ? {} : { description }),
|
|
1210
|
+
...(isPrivate === undefined ? {} : { private: isPrivate }),
|
|
1211
|
+
}));
|
|
1212
|
+
},
|
|
1213
|
+
async updateList({ account, listId, name, description, isPrivate, context }) {
|
|
1214
|
+
authorize(account, context);
|
|
1215
|
+
return object(await request(`/2/lists/${encodeURIComponent(listId)}`, context, {
|
|
1216
|
+
...(name === undefined ? {} : { name }),
|
|
1217
|
+
...(description === undefined ? {} : { description }),
|
|
1218
|
+
...(isPrivate === undefined ? {} : { private: isPrivate }),
|
|
1219
|
+
}, {}, "PUT"));
|
|
1220
|
+
},
|
|
1221
|
+
async deleteList({ account, listId, context }) {
|
|
1222
|
+
authorize(account, context);
|
|
1223
|
+
await request(`/2/lists/${encodeURIComponent(listId)}`, context, undefined, {}, "DELETE");
|
|
1224
|
+
},
|
|
1225
|
+
async getList({ account, listId, context }) {
|
|
1226
|
+
authorize(account, context);
|
|
1227
|
+
return object(await request(`/2/lists/${encodeURIComponent(listId)}`, context));
|
|
1228
|
+
},
|
|
1229
|
+
async listMembers({ account, listId, cursor, limit, context }) {
|
|
1230
|
+
return pageRequest(`/2/lists/${encodeURIComponent(listId)}/members`, account, { cursor, limit }, context, {}, "id,name,description,private,member_count,follower_count");
|
|
1231
|
+
},
|
|
1232
|
+
async addListMember({ account, listId, userId, context }) {
|
|
1233
|
+
authorize(account, context);
|
|
1234
|
+
return object(await request(`/2/lists/${encodeURIComponent(listId)}/members`, context, {
|
|
1235
|
+
user_id: userId,
|
|
1236
|
+
}));
|
|
1237
|
+
},
|
|
1238
|
+
async removeListMember({ account, listId, userId, context }) {
|
|
1239
|
+
authorize(account, context);
|
|
1240
|
+
await request(`/2/lists/${encodeURIComponent(listId)}/members/${encodeURIComponent(userId)}`, context, undefined, {}, "DELETE");
|
|
1241
|
+
},
|
|
1242
|
+
async followList({ account, listId, context }) {
|
|
1243
|
+
authorize(account, context);
|
|
1244
|
+
return object(await request(`/2/users/${encodeURIComponent(account.accountId)}/followed_lists`, context, { list_id: listId }));
|
|
1245
|
+
},
|
|
1246
|
+
async unfollowList({ account, listId, context }) {
|
|
1247
|
+
authorize(account, context);
|
|
1248
|
+
await request(`/2/users/${encodeURIComponent(account.accountId)}/followed_lists/${encodeURIComponent(listId)}`, context, undefined, {}, "DELETE");
|
|
1249
|
+
},
|
|
1250
|
+
async ownedLists({ account, cursor, limit, context }) {
|
|
1251
|
+
return pageRequest(`/2/users/${encodeURIComponent(account.accountId)}/owned_lists`, account, { cursor, limit }, context, {}, "id,name,description,private,member_count,follower_count");
|
|
1252
|
+
},
|
|
1253
|
+
async pinList({ account, listId, context }) {
|
|
1254
|
+
authorize(account, context);
|
|
1255
|
+
return object(await requireUserToken("lists.pinned.write")(`/2/users/${encodeURIComponent(account.accountId)}/pinned_lists`, context, {
|
|
1256
|
+
list_id: listId,
|
|
1257
|
+
}));
|
|
1258
|
+
},
|
|
1259
|
+
async unpinList({ account, listId, context }) {
|
|
1260
|
+
authorize(account, context);
|
|
1261
|
+
await requireUserToken("lists.pinned.write")(`/2/users/${encodeURIComponent(account.accountId)}/pinned_lists/${encodeURIComponent(listId)}`, context, undefined, {}, "DELETE");
|
|
1262
|
+
},
|
|
1263
|
+
async pinnedLists({ account, context }) {
|
|
1264
|
+
authorize(account, context);
|
|
1265
|
+
const result = object(await readRequest("pinned_lists")(`/2/users/${encodeURIComponent(account.accountId)}/pinned_lists`, context, undefined, { "list.fields": "id,name,description,private,member_count,follower_count" }));
|
|
1266
|
+
return { items: result["data"] === undefined ? [] : array(result["data"]).map(object) };
|
|
1267
|
+
},
|
|
1268
|
+
async listPosts({ account, listId, cursor, limit, context }) {
|
|
1269
|
+
return pageRequest(`/2/lists/${encodeURIComponent(listId)}/tweets`, account, { cursor, limit }, context, {}, "id,name,username,description,created_at,public_metrics", "tweets");
|
|
1270
|
+
},
|
|
1271
|
+
async userPosts({ account, userId, cursor, limit, context }) {
|
|
1272
|
+
return pageRequest(`/2/users/${encodeURIComponent(userId)}/tweets`, account, { cursor, limit }, context, {}, "id,name,username,description,created_at,public_metrics", "tweets");
|
|
1273
|
+
},
|
|
1274
|
+
async homeTimeline({ account, cursor, limit, context }) {
|
|
1275
|
+
return pageRequest(`/2/users/${encodeURIComponent(account.accountId)}/timelines/reverse_chronological`, account, { cursor, limit }, context, {}, "id,name,username,description,created_at,public_metrics", "tweets");
|
|
1276
|
+
},
|
|
1277
|
+
async mentions({ account, userId, cursor, limit, context }) {
|
|
1278
|
+
return pageRequest(`/2/users/${encodeURIComponent(userId ?? account.accountId)}/mentions`, account, { cursor, limit }, context, {}, "id,name,username,description,created_at,public_metrics", "tweets");
|
|
1279
|
+
},
|
|
1280
|
+
async undoRepost({ account, postId, context }) {
|
|
1281
|
+
authorize(account, context);
|
|
1282
|
+
await request(`/2/users/${encodeURIComponent(account.accountId)}/retweets/${encodeURIComponent(postId)}`, context, undefined, {}, "DELETE");
|
|
1283
|
+
},
|
|
1284
|
+
}),
|
|
1285
|
+
});
|
|
1286
|
+
return adapter;
|
|
1287
|
+
}
|