@vornrun/connector-x 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.
- package/CHANGELOG.md +40 -0
- package/README.md +189 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +755 -0
- package/package.json +64 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@vornrun/connector-x`.
|
|
4
|
+
|
|
5
|
+
## 0.1.0
|
|
6
|
+
|
|
7
|
+
First release.
|
|
8
|
+
|
|
9
|
+
Trigger a workflow from new mentions of the connected account or from posts
|
|
10
|
+
matching a recent-search query, and let a workflow step create, reply to,
|
|
11
|
+
delete, read and search posts or look up the connected account and a user by
|
|
12
|
+
username.
|
|
13
|
+
|
|
14
|
+
- **Triggers:** `newMention`, `newSearchResult`.
|
|
15
|
+
- **Actions:** `createPost`, `replyToPost`, `deletePost`, `getMe`, `getPost`,
|
|
16
|
+
`getUserByUsername`, `searchRecentPosts`.
|
|
17
|
+
- **Signing in:** the four OAuth 1.0a credentials of an X developer app with
|
|
18
|
+
Read and Write permission, from the app's Keys and tokens tab. Every request
|
|
19
|
+
is signed in the package with `node:crypto` (HMAC-SHA1, the
|
|
20
|
+
`Authorization: OAuth …` header); no OAuth dependency. All four fields are
|
|
21
|
+
stored encrypted, because the SDK refuses a credential field left in the
|
|
22
|
+
clear.
|
|
23
|
+
|
|
24
|
+
Every call goes through one small client rather than a declared SDK request,
|
|
25
|
+
because a per-request OAuth 1.0a signature cannot be written as a static
|
|
26
|
+
header template. The client waits out a `429` until `x-rate-limit-reset`
|
|
27
|
+
(capped at 60 seconds, past which the reset instant is reported) and sends
|
|
28
|
+
once more, retries a `500`, `502`, `503` or `504` once after a second, and
|
|
29
|
+
reports a failure as `<status> <title>: <detail> [<problem>]`, appending a
|
|
30
|
+
`403`'s `reason` when present. A `402`, `client-forbidden` or `usage-capped`
|
|
31
|
+
answer says plainly that the plan or credits do not cover the endpoint and is
|
|
32
|
+
never retried.
|
|
33
|
+
|
|
34
|
+
Both triggers are declarative polls on the SDK's `lastItem` strategy with
|
|
35
|
+
`since_id`; the connected account's id is fetched once per process. Post text
|
|
36
|
+
is counted in code points and refused over 280 characters before sending.
|
|
37
|
+
The README records which reads needed a paid tier and what each call costs.
|
|
38
|
+
|
|
39
|
+
Ships as a pack with a conformance receipt covering the dedupe replay of both
|
|
40
|
+
triggers and the mock run of every action. No runtime dependencies.
|
package/README.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# @vornrun/connector-x
|
|
2
|
+
|
|
3
|
+
Trigger Vorn workflows from new mentions of the connected account or from
|
|
4
|
+
posts matching a search on X (formerly Twitter), and create, reply to, delete,
|
|
5
|
+
read and search posts from a workflow step. Talks to API v2 at
|
|
6
|
+
`https://api.x.com/2`.
|
|
7
|
+
|
|
8
|
+
## Signing in
|
|
9
|
+
|
|
10
|
+
Paste the four OAuth 1.0a user-context credentials of an X developer app.
|
|
11
|
+
There is no X CLI to borrow a login from. Create the app in the
|
|
12
|
+
[Developer Console](https://developer.x.com/en/portal/dashboard) under
|
|
13
|
+
Projects & Apps, open the app's **Keys and tokens** tab, and copy the API Key
|
|
14
|
+
and Secret (the consumer key pair) and the Access Token and Secret generated
|
|
15
|
+
for your own account. Each value is shown once at creation; regenerate it from
|
|
16
|
+
the same tab.
|
|
17
|
+
|
|
18
|
+
Two things about the app's permissions:
|
|
19
|
+
|
|
20
|
+
- Set **User authentication settings** to **Read and Write** before
|
|
21
|
+
`createPost`, `replyToPost` or `deletePost` will work. Read alone "cannot
|
|
22
|
+
post, like, or modify anything".
|
|
23
|
+
- Generate (or regenerate) the access token **after** setting Read and Write.
|
|
24
|
+
Changing permissions "requires users to re-authorize your app to get new
|
|
25
|
+
tokens with the updated scope": an access token generated before the change
|
|
26
|
+
stays read-only, and the API answers `403` to every write.
|
|
27
|
+
|
|
28
|
+
Every request is signed in the package with `node:crypto`: an
|
|
29
|
+
`Authorization: OAuth …` header carrying `oauth_consumer_key`, `oauth_nonce`,
|
|
30
|
+
`oauth_signature` (HMAC-SHA1, base64), `oauth_signature_method`,
|
|
31
|
+
`oauth_timestamp`, `oauth_token` and `oauth_version`, built as the signing
|
|
32
|
+
guide describes. A `401` means one of the four values or the signature is
|
|
33
|
+
wrong; the error says so and names the Keys and tokens tab.
|
|
34
|
+
|
|
35
|
+
All four fields are stored encrypted. The consumer key and access token are
|
|
36
|
+
not secrets in X's own terms, but Vorn stores every field that names a
|
|
37
|
+
credential encrypted, and the SDK refuses to leave one in the clear.
|
|
38
|
+
|
|
39
|
+
## Settings
|
|
40
|
+
|
|
41
|
+
| Setting | Environment | Required | What it does |
|
|
42
|
+
| --- | --- | --- | --- |
|
|
43
|
+
| API key | `X_API_KEY` | yes | The app's API Key (OAuth 1.0a consumer key) |
|
|
44
|
+
| API key secret | `X_API_SECRET` | yes | The app's API Key Secret; half of the signing key |
|
|
45
|
+
| Access token | `X_ACCESS_TOKEN` | yes | The access token generated for your own account |
|
|
46
|
+
| Access token secret | `X_ACCESS_TOKEN_SECRET` | yes | Shown with the access token; the other half of the signing key |
|
|
47
|
+
| Search query | `X_SEARCH_QUERY` | for `newSearchResult` | A recent-search query, 1 to 512 characters, such as `from:xdevelopers -is:retweet` |
|
|
48
|
+
|
|
49
|
+
## Access, pricing and tiers
|
|
50
|
+
|
|
51
|
+
Access to the X API is metered per endpoint, and the connector cannot make a
|
|
52
|
+
call the app's plan or credit balance does not cover. The pages the connector
|
|
53
|
+
was built from describe pay-per-usage pricing: credits bought in the Developer
|
|
54
|
+
Console, deducted per resource read and per write request, deduplicated within
|
|
55
|
+
a UTC day, with a cap of 3 million post reads per billing cycle. Those pages
|
|
56
|
+
no longer name the Free, Basic and Pro tiers; what the older tiers allowed is
|
|
57
|
+
recorded here because the API still refuses in the same shapes:
|
|
58
|
+
|
|
59
|
+
- The **Free** tier allowed writes (create and delete a post) and
|
|
60
|
+
`GET /2/users/me`, with a small monthly post budget and very few reads.
|
|
61
|
+
- The **mentions timeline** and **recent search** were the reads that needed
|
|
62
|
+
**Basic or above**. If your app is on a legacy tier, `newMention`,
|
|
63
|
+
`newSearchResult` and `searchRecentPosts` are the ones that will refuse.
|
|
64
|
+
- **Quote posts** need an Enterprise plan: the create page says quoting is
|
|
65
|
+
"not available on self-serve tiers".
|
|
66
|
+
|
|
67
|
+
What each call costs and how often it may be made, per user, from the
|
|
68
|
+
rate-limit and pricing pages:
|
|
69
|
+
|
|
70
|
+
| Call | Used by | Rate limit | Billing |
|
|
71
|
+
| --- | --- | --- | --- |
|
|
72
|
+
| `GET /2/users/me` | `getMe`, `newMention` once per process | 75 / 15 min | 1 user read ($0.010) |
|
|
73
|
+
| `GET /2/users/:id/mentions` | `newMention` | 300 / 15 min | per post returned: Owned Read ($0.001) when the account owns the app, $0.005 otherwise |
|
|
74
|
+
| `GET /2/tweets/search/recent` | `newSearchResult`, `searchRecentPosts` | 300 / 15 min | $0.005 per post returned |
|
|
75
|
+
| `GET /2/tweets/:id` | `getPost` | 900 / 15 min | 1 post read plus 1 user read |
|
|
76
|
+
| `GET /2/users/by/username/:username` | `getUserByUsername` | 900 / 15 min | 1 user read |
|
|
77
|
+
| `POST /2/tweets` | `createPost`, `replyToPost` | 100 / 15 min, 10,000 / 24 h app-wide | $0.015 per request |
|
|
78
|
+
| `DELETE /2/tweets/:id` | `deletePost` | 50 / 15 min | $0.010 per request |
|
|
79
|
+
|
|
80
|
+
When the plan or the balance is what refuses, the connector says so plainly.
|
|
81
|
+
A `402`, a `403` whose problem type is `client-forbidden` ("App not enrolled
|
|
82
|
+
or lacks required access") and a `429` whose type is `usage-capped` ("Usage
|
|
83
|
+
cap exceeded") are thrown as `<status> <title>: <detail> [<problem>]`
|
|
84
|
+
followed by "The app's plan or credits do not cover this endpoint." None of
|
|
85
|
+
the three is retried: a budget does not come back in a second.
|
|
86
|
+
|
|
87
|
+
## Rate limits
|
|
88
|
+
|
|
89
|
+
Every answer carries `x-rate-limit-limit`, `x-rate-limit-remaining` and
|
|
90
|
+
`x-rate-limit-reset` (Unix seconds). On a `429` the connector reads
|
|
91
|
+
`x-rate-limit-reset`, waits until that instant and sends once more; a second
|
|
92
|
+
`429` is reported. The wait is capped at 60 seconds: a window that resets
|
|
93
|
+
later than that is reported at once with the reset instant in the message,
|
|
94
|
+
so a fresh 15-minute window cannot hold a workflow step or a poll open. A
|
|
95
|
+
`500`, `502`, `503` or `504` is retried once after one second. Any other
|
|
96
|
+
failure is reported as X describes it: `<status> <title>: <detail>` for a v2
|
|
97
|
+
problem, with the last segment of the problem `type` in brackets, or
|
|
98
|
+
`<status>: <message> (code <n>)` for the legacy `errors` list; a `403` that
|
|
99
|
+
carries a `reason` field has it appended.
|
|
100
|
+
|
|
101
|
+
## Triggers
|
|
102
|
+
|
|
103
|
+
Both are declarative polls on the SDK's `lastItem` strategy: X returns the
|
|
104
|
+
feed newest first, and the post id is the only dependable order. Each poll
|
|
105
|
+
sends `since_id` = the newest id already delivered and `max_results` = the
|
|
106
|
+
poll's limit clamped to the endpoint's window (5 to 100 for mentions, 10 to
|
|
107
|
+
100 for search). The connector does not follow `next_token`, so a burst above
|
|
108
|
+
100 new posts between two polls loses the oldest.
|
|
109
|
+
|
|
110
|
+
Every item has the post id as `externalId`, `@username: ` plus the first 80
|
|
111
|
+
characters of the text as `title`, `created_at` as `updatedAt`,
|
|
112
|
+
`https://x.com/{username}/status/{id}` as `url`, and in `data` the post joined
|
|
113
|
+
with its author: `id`, `text`, `createdAt`, `conversationId`,
|
|
114
|
+
`inReplyToUserId`, `author` (`id`, `username`, `name`) and `url`. When the
|
|
115
|
+
author is missing from `includes` (a partial answer) the username is empty and
|
|
116
|
+
the URL takes the `https://x.com/i/status/{id}` form, which X resolves.
|
|
117
|
+
|
|
118
|
+
### `newMention` — a post mentions the connected account
|
|
119
|
+
|
|
120
|
+
`GET /2/users/{id}/mentions` with
|
|
121
|
+
`tweet.fields=created_at,author_id,conversation_id,in_reply_to_user_id`,
|
|
122
|
+
`expansions=author_id` and `user.fields=username,name`. The account id comes
|
|
123
|
+
from `GET /2/users/me`, fetched once per connection and cached for the life of
|
|
124
|
+
the process (the SDK keeps nothing but the cursor between polls, so "once per
|
|
125
|
+
connection" means once per process). Seeds the workflow `X: new mentions`,
|
|
126
|
+
every 5 minutes.
|
|
127
|
+
|
|
128
|
+
### `newSearchResult` — a post matches a search
|
|
129
|
+
|
|
130
|
+
`GET /2/tweets/search/recent?query=…&sort_order=recency` with the same
|
|
131
|
+
fields, using the **Search query** setting. Only posts from the last 7 days
|
|
132
|
+
are searchable, and `start_time` is never sent because at most one of it and
|
|
133
|
+
`since_id` may be. Seeds the workflow `X: search results`, every 5 minutes.
|
|
134
|
+
|
|
135
|
+
## Actions
|
|
136
|
+
|
|
137
|
+
`createPost` and `replyToPost` count the text in code points and refuse
|
|
138
|
+
anything empty or over 280 characters before sending, with `Post text is N
|
|
139
|
+
characters; the limit is 280 for non-Premium accounts`. The pages read do not
|
|
140
|
+
publish the weighted counting rule, so this is a plain count; a text the API
|
|
141
|
+
weighs longer still answers `403`, reported like any other error.
|
|
142
|
+
|
|
143
|
+
| Action | Idempotent | What it does |
|
|
144
|
+
| --- | --- | --- |
|
|
145
|
+
| `createPost` | no | `POST /2/tweets` with `text`, optional `inReplyToPostId` (sent as `reply.in_reply_to_tweet_id`) and `quotePostId` (`quote_tweet_id`, Enterprise only). Returns `id`, `url` (`https://x.com/i/status/{id}`), `text`. |
|
|
146
|
+
| `replyToPost` | no | `POST /2/tweets` with `text` and `reply.in_reply_to_tweet_id` = `postId`. Returns `id`, `url`, `text`. |
|
|
147
|
+
| `deletePost` | no | `DELETE /2/tweets/{postId}`; only the connected account's own posts. Returns `deleted`. |
|
|
148
|
+
| `getMe` | yes | `GET /2/users/me?user.fields=id,username,name`. Returns `id`, `username`, `name`, `url`. The live check. |
|
|
149
|
+
| `getPost` | yes | `GET /2/tweets/{postId}` with `created_at,author_id,conversation_id,public_metrics` and the author expansion. Returns `id`, `text`, `createdAt`, `authorId`, `authorUsername`, `conversationId`, `url`, `metrics`, `raw`. |
|
|
150
|
+
| `getUserByUsername` | yes | `GET /2/users/by/username/{username}` (a leading `@` is stripped). Returns `id`, `username`, `name`, `description`, `createdAt`, `followers`, `following`, `posts`, `url`, `raw`. |
|
|
151
|
+
| `searchRecentPosts` | yes | `GET /2/tweets/search/recent` with `query`, `maxResults` (10 to 100, default 10; a smaller value is raised to 10, a larger one refused) and `sinceId`. Returns `posts` (the trigger's `data` shape), `count`, `newestId`, `nextToken`. |
|
|
152
|
+
|
|
153
|
+
## Checks
|
|
154
|
+
|
|
155
|
+
```sh
|
|
156
|
+
packages/x/scripts/check.sh # typecheck, tests, build, conformance receipt
|
|
157
|
+
packages/x/scripts/check-live.sh # needs the four X_* credentials; exits 0 with a note without them
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Tests make no network calls: the client takes an injected `fetch`, clock and
|
|
161
|
+
sleep. The signer is pinned to the signing guide's worked example with
|
|
162
|
+
stand-in credentials of the same shape. The live check calls the idempotent
|
|
163
|
+
actions on their samples (`getMe`, `getPost` on id `20`,
|
|
164
|
+
`getUserByUsername` on `x`, `searchRecentPosts` on
|
|
165
|
+
`from:xdevelopers -is:retweet`); those are billed reads and need credits on
|
|
166
|
+
the app. Nothing is created or deleted.
|
|
167
|
+
|
|
168
|
+
## Built from
|
|
169
|
+
|
|
170
|
+
- [X API introduction](https://docs.x.com/x-api/introduction)
|
|
171
|
+
- [About the X API](https://docs.x.com/x-api/getting-started/about-x-api)
|
|
172
|
+
and [pricing](https://docs.x.com/x-api/getting-started/pricing)
|
|
173
|
+
- [Authentication overview](https://docs.x.com/resources/fundamentals/authentication),
|
|
174
|
+
[API key and secret](https://docs.x.com/resources/fundamentals/authentication/oauth-1-0a/api-key-and-secret)
|
|
175
|
+
and [developer apps](https://docs.x.com/resources/fundamentals/developer-apps)
|
|
176
|
+
- OAuth 1.0a: [creating a signature](https://docs.x.com/resources/fundamentals/authentication/oauth-1-0a/creating-a-signature),
|
|
177
|
+
[authorizing a request](https://docs.x.com/resources/fundamentals/authentication/oauth-1-0a/authorizing-a-request)
|
|
178
|
+
and [percent encoding](https://docs.x.com/resources/fundamentals/authentication/oauth-1-0a/percent-encoding-parameters)
|
|
179
|
+
- [Fields](https://docs.x.com/x-api/fundamentals/fields)
|
|
180
|
+
- Posts: [create](https://docs.x.com/x-api/posts/creation-of-a-post),
|
|
181
|
+
[delete](https://docs.x.com/x-api/posts/post-delete-by-post-id),
|
|
182
|
+
[lookup by id](https://docs.x.com/x-api/posts/post-lookup-by-post-id),
|
|
183
|
+
[mentions timeline](https://docs.x.com/x-api/posts/user-mention-timeline-by-user-id)
|
|
184
|
+
and [recent search](https://docs.x.com/x-api/posts/recent-search)
|
|
185
|
+
- Users: [me](https://docs.x.com/x-api/users/user-lookup-me)
|
|
186
|
+
and [by username](https://docs.x.com/x-api/users/user-lookup-by-username)
|
|
187
|
+
- [Rate limits overview](https://docs.x.com/resources/fundamentals/rate-limits)
|
|
188
|
+
and [v2 rate limits](https://docs.x.com/x-api/fundamentals/rate-limits)
|
|
189
|
+
- [Response codes and errors](https://docs.x.com/x-api/fundamentals/response-codes-and-errors)
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/connector.ts
|
|
4
|
+
import {
|
|
5
|
+
defineConnector
|
|
6
|
+
} from "@vornrun/connector-sdk";
|
|
7
|
+
|
|
8
|
+
// src/oauth.ts
|
|
9
|
+
import { createHmac, randomBytes } from "crypto";
|
|
10
|
+
function percentEncode(value) {
|
|
11
|
+
return encodeURIComponent(value).replace(
|
|
12
|
+
/[!'()*]/g,
|
|
13
|
+
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
function oauthParams(credentials, nonce, timestamp) {
|
|
17
|
+
return {
|
|
18
|
+
oauth_consumer_key: credentials.consumerKey,
|
|
19
|
+
oauth_nonce: nonce,
|
|
20
|
+
oauth_signature_method: "HMAC-SHA1",
|
|
21
|
+
oauth_timestamp: String(timestamp),
|
|
22
|
+
oauth_token: credentials.token,
|
|
23
|
+
oauth_version: "1.0"
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function makeNonce(random = randomBytes) {
|
|
27
|
+
return random(32).toString("base64").replace(/\W/g, "");
|
|
28
|
+
}
|
|
29
|
+
function byteOrder(a, b) {
|
|
30
|
+
if (a === b) return 0;
|
|
31
|
+
return a < b ? -1 : 1;
|
|
32
|
+
}
|
|
33
|
+
function parameterString(params) {
|
|
34
|
+
return params.map(([key, value]) => [percentEncode(key), percentEncode(value)]).sort(([a], [b]) => byteOrder(a, b)).map(([key, value]) => `${key}=${value}`).join("&");
|
|
35
|
+
}
|
|
36
|
+
function signatureBaseString(method, url, params) {
|
|
37
|
+
const baseUrl = `${url.origin}${url.pathname}`;
|
|
38
|
+
return `${method.toUpperCase()}&${percentEncode(baseUrl)}&${percentEncode(parameterString(params))}`;
|
|
39
|
+
}
|
|
40
|
+
function sign(credentials, input) {
|
|
41
|
+
const url = new URL(input.url);
|
|
42
|
+
const nonce = input.nonce ?? makeNonce();
|
|
43
|
+
const timestamp = input.timestamp ?? Math.floor(Date.now() / 1e3);
|
|
44
|
+
const oauth = oauthParams(credentials, nonce, timestamp);
|
|
45
|
+
const params = [
|
|
46
|
+
...Array.from(url.searchParams.entries()),
|
|
47
|
+
...Object.entries(input.bodyParams ?? {}),
|
|
48
|
+
...Object.entries(oauth)
|
|
49
|
+
];
|
|
50
|
+
const baseString = signatureBaseString(input.method, url, params);
|
|
51
|
+
const signingKey = `${percentEncode(credentials.consumerSecret)}&${percentEncode(credentials.tokenSecret)}`;
|
|
52
|
+
const signature = createHmac("sha1", signingKey).update(baseString).digest("base64");
|
|
53
|
+
const header = "OAuth " + Object.entries({ ...oauth, oauth_signature: signature }).sort(([a], [b]) => byteOrder(a, b)).map(([key, value]) => `${percentEncode(key)}="${percentEncode(value)}"`).join(", ");
|
|
54
|
+
return { header, baseString, signature };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/client.ts
|
|
58
|
+
var API_ROOT = "https://api.x.com/2";
|
|
59
|
+
var MAX_RATE_LIMIT_WAIT_MS = 6e4;
|
|
60
|
+
var SERVER_ERROR_RETRY_MS = 1e3;
|
|
61
|
+
var RETRYABLE_SERVER_STATUSES = /* @__PURE__ */ new Set([500, 502, 503, 504]);
|
|
62
|
+
var MAX_ERROR_BODY = 300;
|
|
63
|
+
var ACCESS_PROBLEMS = /* @__PURE__ */ new Set(["client-forbidden", "usage-capped"]);
|
|
64
|
+
var XApiError = class extends Error {
|
|
65
|
+
status;
|
|
66
|
+
title;
|
|
67
|
+
detail;
|
|
68
|
+
/** The last path segment of the problem `type`, such as `client-forbidden`. */
|
|
69
|
+
problem;
|
|
70
|
+
reason;
|
|
71
|
+
constructor(status, message, body = {}) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = "XApiError";
|
|
74
|
+
this.status = status;
|
|
75
|
+
this.title = body.title;
|
|
76
|
+
this.detail = body.detail;
|
|
77
|
+
this.problem = problemName(body.type);
|
|
78
|
+
this.reason = body.reason;
|
|
79
|
+
}
|
|
80
|
+
/** Whether the plan or the credit balance, not a window, is what refused the call. */
|
|
81
|
+
get accessDenied() {
|
|
82
|
+
return this.status === 402 || this.problem !== void 0 && ACCESS_PROBLEMS.has(this.problem);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
function problemName(type) {
|
|
86
|
+
if (!type || type === "about:blank") return void 0;
|
|
87
|
+
const segment = type.split("/").filter(Boolean).pop();
|
|
88
|
+
return segment || void 0;
|
|
89
|
+
}
|
|
90
|
+
async function readBody(response) {
|
|
91
|
+
const text2 = await response.text().catch(() => "");
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(text2);
|
|
94
|
+
if (typeof parsed === "object" && parsed !== null) return { parsed, text: text2 };
|
|
95
|
+
} catch {
|
|
96
|
+
}
|
|
97
|
+
return { parsed: {}, text: text2 };
|
|
98
|
+
}
|
|
99
|
+
function describeFailure(status, parsed, text2) {
|
|
100
|
+
const legacy = parsed.errors?.find((entry) => typeof entry?.message === "string");
|
|
101
|
+
let message;
|
|
102
|
+
if (parsed.title || parsed.detail) {
|
|
103
|
+
message = `${status} ${parsed.title ?? "Error"}${parsed.detail ? `: ${parsed.detail}` : ""}`;
|
|
104
|
+
} else if (legacy) {
|
|
105
|
+
message = `${status}: ${legacy.message}${legacy.code !== void 0 ? ` (code ${legacy.code})` : ""}`;
|
|
106
|
+
} else {
|
|
107
|
+
const quoted = text2.length > MAX_ERROR_BODY ? `${text2.slice(0, MAX_ERROR_BODY)}\u2026` : text2;
|
|
108
|
+
message = `X API ${status}${quoted ? `: ${quoted}` : ""}`;
|
|
109
|
+
}
|
|
110
|
+
const problem = problemName(parsed.type);
|
|
111
|
+
if (problem) message += ` [${problem}]`;
|
|
112
|
+
if (status === 403 && parsed.reason) message += ` reason: ${parsed.reason}`;
|
|
113
|
+
if (status === 401) {
|
|
114
|
+
message += ". The four credentials or the signature are wrong: check the Keys and tokens tab of the app and regenerate the access token after setting Read and Write.";
|
|
115
|
+
}
|
|
116
|
+
if (status === 402 || problem && ACCESS_PROBLEMS.has(problem)) {
|
|
117
|
+
message += ". The app's plan or credits do not cover this endpoint.";
|
|
118
|
+
}
|
|
119
|
+
return new XApiError(status, message, parsed);
|
|
120
|
+
}
|
|
121
|
+
var wait = (ms) => new Promise((resolve) => {
|
|
122
|
+
setTimeout(resolve, ms);
|
|
123
|
+
});
|
|
124
|
+
function compact(params) {
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const [key, value] of Object.entries(params)) {
|
|
127
|
+
if (value !== void 0 && value !== "") out.push([key, String(value)]);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
var POST_FIELDS = {
|
|
132
|
+
"tweet.fields": "created_at,author_id,conversation_id,in_reply_to_user_id",
|
|
133
|
+
expansions: "author_id",
|
|
134
|
+
"user.fields": "username,name"
|
|
135
|
+
};
|
|
136
|
+
var USER_FIELDS = "id,username,name";
|
|
137
|
+
function createXClient(options) {
|
|
138
|
+
const sleep = options.sleep ?? wait;
|
|
139
|
+
const now = options.now ?? Date.now;
|
|
140
|
+
let fetchImpl = options.fetch;
|
|
141
|
+
async function call(path, callOptions = {}) {
|
|
142
|
+
const method = callOptions.method ?? "GET";
|
|
143
|
+
const url = new URL(`${API_ROOT}${path}`);
|
|
144
|
+
for (const [key, value] of compact(callOptions.query ?? {})) url.searchParams.set(key, value);
|
|
145
|
+
const hasBody = callOptions.body !== void 0;
|
|
146
|
+
for (let attempt = 0; ; attempt++) {
|
|
147
|
+
const { header } = sign(options.credentials, {
|
|
148
|
+
method,
|
|
149
|
+
url: url.toString(),
|
|
150
|
+
timestamp: Math.floor(now() / 1e3)
|
|
151
|
+
});
|
|
152
|
+
const response = await fetchImpl(url.toString(), {
|
|
153
|
+
method,
|
|
154
|
+
headers: { Authorization: header, ...hasBody && { "Content-Type": "application/json" } },
|
|
155
|
+
...hasBody && { body: JSON.stringify(callOptions.body) }
|
|
156
|
+
});
|
|
157
|
+
if (response.ok) {
|
|
158
|
+
const text3 = await response.text();
|
|
159
|
+
return text3 === "" ? {} : JSON.parse(text3);
|
|
160
|
+
}
|
|
161
|
+
const { parsed, text: text2 } = await readBody(response);
|
|
162
|
+
const failure = describeFailure(response.status, parsed, text2);
|
|
163
|
+
if (attempt === 0 && response.status === 429 && !failure.accessDenied) {
|
|
164
|
+
const resetAt = Number(response.headers.get("x-rate-limit-reset")) * 1e3;
|
|
165
|
+
const pending = Number.isFinite(resetAt) && resetAt > 0 ? resetAt - now() : SERVER_ERROR_RETRY_MS;
|
|
166
|
+
if (pending > MAX_RATE_LIMIT_WAIT_MS) {
|
|
167
|
+
throw new XApiError(
|
|
168
|
+
429,
|
|
169
|
+
`${failure.message}. The rate limit resets at ${new Date(resetAt).toISOString()}.`,
|
|
170
|
+
parsed
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
await sleep(Math.max(0, pending));
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (attempt === 0 && RETRYABLE_SERVER_STATUSES.has(response.status)) {
|
|
177
|
+
await sleep(SERVER_ERROR_RETRY_MS);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
throw failure;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
let me;
|
|
184
|
+
return {
|
|
185
|
+
call,
|
|
186
|
+
/** The host hands a fresh resilient fetch to every call; a memoised client takes the latest. */
|
|
187
|
+
setFetch(next) {
|
|
188
|
+
fetchImpl = next;
|
|
189
|
+
},
|
|
190
|
+
getMe: () => call("/users/me", { query: { "user.fields": USER_FIELDS } }),
|
|
191
|
+
// Fetched once per process: the SDK keeps no store beyond the cursor, so a memo is the nearest thing to per connection.
|
|
192
|
+
myId() {
|
|
193
|
+
me ??= this.getMe().then((envelope) => envelope.data ?? {}).catch((error) => {
|
|
194
|
+
me = void 0;
|
|
195
|
+
throw error;
|
|
196
|
+
});
|
|
197
|
+
return me;
|
|
198
|
+
},
|
|
199
|
+
getPost: (id, query = POST_FIELDS) => call(`/tweets/${encodeURIComponent(id)}`, { query }),
|
|
200
|
+
getUserByUsername: (username, query) => call(`/users/by/username/${encodeURIComponent(username)}`, { query }),
|
|
201
|
+
mentions: (userId, query) => call(`/users/${encodeURIComponent(userId)}/mentions`, { query: { ...POST_FIELDS, ...query } }),
|
|
202
|
+
searchRecent: (query) => call("/tweets/search/recent", { query: { ...POST_FIELDS, sort_order: "recency", ...query } }),
|
|
203
|
+
createPost: (body) => call("/tweets", { method: "POST", body }),
|
|
204
|
+
deletePost: (id) => call(`/tweets/${encodeURIComponent(id)}`, { method: "DELETE" })
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/items.ts
|
|
209
|
+
var TITLE_LIMIT = 80;
|
|
210
|
+
function postUrl(id, username) {
|
|
211
|
+
return username ? `https://x.com/${username}/status/${id}` : `https://x.com/i/status/${id}`;
|
|
212
|
+
}
|
|
213
|
+
function joinAuthor(post, users) {
|
|
214
|
+
const id = post.id ?? "";
|
|
215
|
+
const author = users?.find((user) => user.id !== void 0 && user.id === post.author_id);
|
|
216
|
+
const username = author?.username ?? "";
|
|
217
|
+
return {
|
|
218
|
+
id,
|
|
219
|
+
text: post.text ?? "",
|
|
220
|
+
createdAt: post.created_at ?? "",
|
|
221
|
+
conversationId: post.conversation_id ?? "",
|
|
222
|
+
inReplyToUserId: post.in_reply_to_user_id ?? null,
|
|
223
|
+
author: { id: post.author_id ?? "", username, name: author?.name ?? "" },
|
|
224
|
+
url: postUrl(id, username)
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function postToItem(record) {
|
|
228
|
+
const firstLine = record.text.replace(/\s+/g, " ").trim();
|
|
229
|
+
const excerpt = Array.from(firstLine).slice(0, TITLE_LIMIT).join("");
|
|
230
|
+
return {
|
|
231
|
+
externalId: record.id,
|
|
232
|
+
title: `@${record.author.username}: ${excerpt}`,
|
|
233
|
+
...record.createdAt && { updatedAt: record.createdAt },
|
|
234
|
+
url: record.url,
|
|
235
|
+
data: record
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
var SAMPLE_ITEM = {
|
|
239
|
+
externalId: "1346889436626259968",
|
|
240
|
+
title: "@xdevelopers: Hello world!",
|
|
241
|
+
updatedAt: "2024-01-15T12:00:00.000Z",
|
|
242
|
+
url: "https://x.com/xdevelopers/status/1346889436626259968",
|
|
243
|
+
data: {
|
|
244
|
+
id: "1346889436626259968",
|
|
245
|
+
text: "Hello world!",
|
|
246
|
+
createdAt: "2024-01-15T12:00:00.000Z",
|
|
247
|
+
conversationId: "1346889436626259968",
|
|
248
|
+
inReplyToUserId: null,
|
|
249
|
+
author: { id: "2244994945", username: "xdevelopers", name: "X Developers" },
|
|
250
|
+
url: "https://x.com/xdevelopers/status/1346889436626259968"
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// package.json
|
|
255
|
+
var package_default = {
|
|
256
|
+
name: "@vornrun/connector-x",
|
|
257
|
+
version: "0.1.0",
|
|
258
|
+
description: "Trigger workflows from new mentions or posts matching a search on X, and create, reply to, delete, read and search posts from a step.",
|
|
259
|
+
type: "module",
|
|
260
|
+
license: "MIT",
|
|
261
|
+
author: "Javier Canizalez <javier-canizalez@outlook.com>",
|
|
262
|
+
repository: {
|
|
263
|
+
type: "git",
|
|
264
|
+
url: "git+https://github.com/vorn-run/connectors.git",
|
|
265
|
+
directory: "packages/x"
|
|
266
|
+
},
|
|
267
|
+
keywords: [
|
|
268
|
+
"vorn",
|
|
269
|
+
"connector",
|
|
270
|
+
"x",
|
|
271
|
+
"twitter",
|
|
272
|
+
"mcp"
|
|
273
|
+
],
|
|
274
|
+
bin: {
|
|
275
|
+
"vorn-connector-x": "dist/index.js"
|
|
276
|
+
},
|
|
277
|
+
main: "./dist/index.js",
|
|
278
|
+
types: "./dist/index.d.ts",
|
|
279
|
+
exports: {
|
|
280
|
+
".": {
|
|
281
|
+
types: "./dist/index.d.ts",
|
|
282
|
+
default: "./dist/index.js"
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
files: [
|
|
286
|
+
"dist",
|
|
287
|
+
"README.md",
|
|
288
|
+
"CHANGELOG.md"
|
|
289
|
+
],
|
|
290
|
+
scripts: {
|
|
291
|
+
build: "tsup",
|
|
292
|
+
typecheck: "tsc --noEmit",
|
|
293
|
+
test: "vitest run"
|
|
294
|
+
},
|
|
295
|
+
dependencies: {
|
|
296
|
+
"@vornrun/connector-sdk": "^0.7.0-beta.14"
|
|
297
|
+
},
|
|
298
|
+
devDependencies: {
|
|
299
|
+
"@types/node": "^22.10.2",
|
|
300
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
301
|
+
tsup: "^8.5.1",
|
|
302
|
+
typescript: "^6.0.3",
|
|
303
|
+
vitest: "^4.1.10"
|
|
304
|
+
},
|
|
305
|
+
vorn: {
|
|
306
|
+
category: "Social",
|
|
307
|
+
keywords: [
|
|
308
|
+
"x",
|
|
309
|
+
"twitter",
|
|
310
|
+
"posts",
|
|
311
|
+
"tweets",
|
|
312
|
+
"mentions",
|
|
313
|
+
"social"
|
|
314
|
+
],
|
|
315
|
+
auth: "Paste the four OAuth 1.0a credentials of an X developer app with Read and Write permission; there is no X CLI to borrow a login from.",
|
|
316
|
+
packs: true
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// src/connector.ts
|
|
321
|
+
var MAX_POST_LENGTH = 280;
|
|
322
|
+
var MENTIONS_MIN_RESULTS = 5;
|
|
323
|
+
var SEARCH_MIN_RESULTS = 10;
|
|
324
|
+
var MAX_RESULTS = 100;
|
|
325
|
+
var DEFAULT_SEARCH_RESULTS = 10;
|
|
326
|
+
var MAX_QUERY_LENGTH = 512;
|
|
327
|
+
var USERNAME = /^[A-Za-z0-9_]{1,15}$/;
|
|
328
|
+
var CONSOLE_URL = "https://developer.x.com/en/portal/dashboard";
|
|
329
|
+
var ACCESS_NOTE = "Metered per request and per post read; see the README for the price and the rate limit of each call.";
|
|
330
|
+
function text(value) {
|
|
331
|
+
const trimmed = String(value ?? "").trim();
|
|
332
|
+
return trimmed || void 0;
|
|
333
|
+
}
|
|
334
|
+
function required(config, key, env) {
|
|
335
|
+
const value = text(config[key]);
|
|
336
|
+
if (value === void 0) throw new Error(`${env} is required`);
|
|
337
|
+
return value;
|
|
338
|
+
}
|
|
339
|
+
function postText(value) {
|
|
340
|
+
const body = String(value ?? "");
|
|
341
|
+
const length = Array.from(body).length;
|
|
342
|
+
if (body.trim() === "") throw new Error("Post text is required");
|
|
343
|
+
if (length > MAX_POST_LENGTH) {
|
|
344
|
+
throw new Error(`Post text is ${length} characters; the limit is ${MAX_POST_LENGTH} for non-Premium accounts`);
|
|
345
|
+
}
|
|
346
|
+
return body;
|
|
347
|
+
}
|
|
348
|
+
function pageSize(value, floor, fallback) {
|
|
349
|
+
const raw = text(value);
|
|
350
|
+
if (raw === void 0) return fallback;
|
|
351
|
+
const parsed = Number(raw);
|
|
352
|
+
if (!Number.isInteger(parsed)) throw new Error(`maxResults must be a whole number between ${floor} and ${MAX_RESULTS}`);
|
|
353
|
+
if (parsed > MAX_RESULTS) throw new Error(`maxResults must be at most ${MAX_RESULTS}`);
|
|
354
|
+
return Math.max(floor, parsed);
|
|
355
|
+
}
|
|
356
|
+
function searchQuery(value) {
|
|
357
|
+
const query = text(value);
|
|
358
|
+
if (query === void 0) throw new Error("query is required");
|
|
359
|
+
if (query.length > MAX_QUERY_LENGTH) {
|
|
360
|
+
throw new Error(`query is ${query.length} characters; recent search accepts at most ${MAX_QUERY_LENGTH}`);
|
|
361
|
+
}
|
|
362
|
+
return query;
|
|
363
|
+
}
|
|
364
|
+
function handle(value) {
|
|
365
|
+
const username = text(value)?.replace(/^@/, "");
|
|
366
|
+
if (username === void 0 || !USERNAME.test(username)) {
|
|
367
|
+
throw new Error("username must be 1 to 15 letters, digits or underscores, without the @");
|
|
368
|
+
}
|
|
369
|
+
return username;
|
|
370
|
+
}
|
|
371
|
+
function postId(value, key) {
|
|
372
|
+
const id = text(value);
|
|
373
|
+
if (id === void 0) throw new Error(`${key} is required`);
|
|
374
|
+
return id;
|
|
375
|
+
}
|
|
376
|
+
function postsOf(envelope) {
|
|
377
|
+
const posts = Array.isArray(envelope.data) ? envelope.data : [];
|
|
378
|
+
return posts.map((post) => joinAuthor(post, envelope.includes?.users));
|
|
379
|
+
}
|
|
380
|
+
function createdOutput(envelope) {
|
|
381
|
+
const id = envelope.data?.id ?? "";
|
|
382
|
+
return { id, url: id ? postUrl(id) : "", text: envelope.data?.text ?? "" };
|
|
383
|
+
}
|
|
384
|
+
function createXConnector(options = {}) {
|
|
385
|
+
const clock = options.now ?? Date.now;
|
|
386
|
+
const clients = /* @__PURE__ */ new Map();
|
|
387
|
+
function client(context) {
|
|
388
|
+
const credentials = {
|
|
389
|
+
consumerKey: required(context.config, "apiKey", "X_API_KEY"),
|
|
390
|
+
consumerSecret: required(context.config, "apiSecret", "X_API_SECRET"),
|
|
391
|
+
token: required(context.config, "accessToken", "X_ACCESS_TOKEN"),
|
|
392
|
+
tokenSecret: required(context.config, "accessTokenSecret", "X_ACCESS_TOKEN_SECRET")
|
|
393
|
+
};
|
|
394
|
+
const key = Object.values(credentials).join("\n");
|
|
395
|
+
const cached = clients.get(key);
|
|
396
|
+
if (cached) {
|
|
397
|
+
cached.setFetch(context.fetch);
|
|
398
|
+
return cached;
|
|
399
|
+
}
|
|
400
|
+
const created = createXClient({
|
|
401
|
+
credentials,
|
|
402
|
+
fetch: context.fetch,
|
|
403
|
+
now: clock,
|
|
404
|
+
...options.sleep && { sleep: options.sleep }
|
|
405
|
+
});
|
|
406
|
+
clients.set(key, created);
|
|
407
|
+
return created;
|
|
408
|
+
}
|
|
409
|
+
function pageQuery(context, floor) {
|
|
410
|
+
return {
|
|
411
|
+
since_id: context.lastItemId,
|
|
412
|
+
max_results: Math.min(MAX_RESULTS, Math.max(floor, context.limit ?? MAX_RESULTS))
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
async function fetchMentions(context) {
|
|
416
|
+
const api = client(context);
|
|
417
|
+
const me = await api.myId();
|
|
418
|
+
if (!me.id) throw new Error("GET /2/users/me returned no id for the connected account");
|
|
419
|
+
const page = await api.mentions(me.id, pageQuery(context, MENTIONS_MIN_RESULTS));
|
|
420
|
+
return postsOf(page).map(postToItem);
|
|
421
|
+
}
|
|
422
|
+
async function fetchSearch(context) {
|
|
423
|
+
const query = searchQuery(context.config.query);
|
|
424
|
+
const page = await client(context).searchRecent({ query, ...pageQuery(context, SEARCH_MIN_RESULTS) });
|
|
425
|
+
return postsOf(page).map(postToItem);
|
|
426
|
+
}
|
|
427
|
+
return defineConnector({
|
|
428
|
+
id: "x",
|
|
429
|
+
name: "X",
|
|
430
|
+
version: options.version ?? package_default.version,
|
|
431
|
+
description: "Trigger workflows from new mentions or posts matching a search on X, and create, reply to, delete, read and search posts from a step.",
|
|
432
|
+
icon: {
|
|
433
|
+
viewBox: "0 0 24 24",
|
|
434
|
+
paths: [
|
|
435
|
+
"M0 1.2H7.6L24 22.8H16.6Z M4.3 3.4H6.6L19.7 20.6H17.4Z M18.9 1.2H22.7L14.72 10.57L12.93 8.22Z M10.77 15.21L8.97 12.87L0.5 22.8H4.3Z"
|
|
436
|
+
]
|
|
437
|
+
},
|
|
438
|
+
auth: { rung: "key", keys: ["apiKey", "apiSecret", "accessToken", "accessTokenSecret"] },
|
|
439
|
+
config: [
|
|
440
|
+
{
|
|
441
|
+
key: "apiKey",
|
|
442
|
+
label: "API key",
|
|
443
|
+
env: "X_API_KEY",
|
|
444
|
+
required: true,
|
|
445
|
+
secret: true,
|
|
446
|
+
description: "The app's API Key (OAuth 1.0a consumer key).",
|
|
447
|
+
builderHint: `${CONSOLE_URL}: Projects & Apps, the app, Keys and tokens. Shown once at creation; regenerate from the same tab.`
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
key: "apiSecret",
|
|
451
|
+
label: "API key secret",
|
|
452
|
+
env: "X_API_SECRET",
|
|
453
|
+
required: true,
|
|
454
|
+
secret: true,
|
|
455
|
+
description: "The app's API Key Secret (consumer secret).",
|
|
456
|
+
builderHint: "Same tab as the API key; half of the HMAC-SHA1 signing key."
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
key: "accessToken",
|
|
460
|
+
label: "Access token",
|
|
461
|
+
env: "X_ACCESS_TOKEN",
|
|
462
|
+
required: true,
|
|
463
|
+
secret: true,
|
|
464
|
+
description: "The Access Token generated for your own account under Keys and tokens.",
|
|
465
|
+
builderHint: "Set the app permission to Read and Write before generating it: a token made earlier stays read-only."
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
key: "accessTokenSecret",
|
|
469
|
+
label: "Access token secret",
|
|
470
|
+
env: "X_ACCESS_TOKEN_SECRET",
|
|
471
|
+
required: true,
|
|
472
|
+
secret: true,
|
|
473
|
+
description: "The Access Token Secret shown with the access token.",
|
|
474
|
+
builderHint: "Regenerated together with the access token; the other half of the signing key."
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
key: "query",
|
|
478
|
+
label: "Search query",
|
|
479
|
+
env: "X_SEARCH_QUERY",
|
|
480
|
+
description: 'Recent-search query the "new search result" trigger polls, such as `from:xdevelopers -is:retweet`.',
|
|
481
|
+
builderHint: "Only needed by newSearchResult; 1 to 512 characters of recent-search syntax, last 7 days only."
|
|
482
|
+
}
|
|
483
|
+
],
|
|
484
|
+
triggers: [
|
|
485
|
+
{
|
|
486
|
+
type: "newMention",
|
|
487
|
+
label: "New mention",
|
|
488
|
+
description: `A post mentions the connected account. Polls the mentions timeline with since_id; a burst above ${MAX_RESULTS} mentions between polls loses the oldest. Access: 300 requests per 15 minutes per user, billed per post read (an Owned Read when the account owns the app). ${ACCESS_NOTE}`,
|
|
489
|
+
dedupe: "lastItem",
|
|
490
|
+
fetch: fetchMentions,
|
|
491
|
+
sample: [SAMPLE_ITEM],
|
|
492
|
+
defaultWorkflow: { name: "X: new mentions", defaultCronFromMinutes: 5 }
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
type: "newSearchResult",
|
|
496
|
+
label: "New search result",
|
|
497
|
+
description: `A post from the last 7 days matches the search query with since_id; a burst above ${MAX_RESULTS} matches between polls loses the oldest. Access: 300 requests per 15 minutes per user, billed per post returned; recent search is the read that used to need a paid tier. ${ACCESS_NOTE}`,
|
|
498
|
+
dedupe: "lastItem",
|
|
499
|
+
fetch: fetchSearch,
|
|
500
|
+
sample: [SAMPLE_ITEM],
|
|
501
|
+
defaultWorkflow: { name: "X: search results", defaultCronFromMinutes: 5 }
|
|
502
|
+
}
|
|
503
|
+
],
|
|
504
|
+
actions: [
|
|
505
|
+
{
|
|
506
|
+
type: "createPost",
|
|
507
|
+
label: "Create post",
|
|
508
|
+
description: `Publish a post of up to ${MAX_POST_LENGTH} characters, optionally as a reply or a quote. Needs Read and Write permission.`,
|
|
509
|
+
idempotent: false,
|
|
510
|
+
inputs: [
|
|
511
|
+
{
|
|
512
|
+
key: "text",
|
|
513
|
+
label: "Text",
|
|
514
|
+
required: true,
|
|
515
|
+
description: `The post, up to ${MAX_POST_LENGTH} characters for non-Premium accounts.`,
|
|
516
|
+
builderHint: "Counted in code points before sending; the API may still weigh a text longer and answer 403."
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
key: "inReplyToPostId",
|
|
520
|
+
label: "In reply to post id",
|
|
521
|
+
description: "Id of the post to reply to, sent as reply.in_reply_to_tweet_id."
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
key: "quotePostId",
|
|
525
|
+
label: "Quote post id",
|
|
526
|
+
description: "Id of the post to quote, sent as quote_tweet_id; the docs say quoting needs an Enterprise plan."
|
|
527
|
+
}
|
|
528
|
+
],
|
|
529
|
+
outputs: [
|
|
530
|
+
{ key: "id", type: "string", description: "The new post id" },
|
|
531
|
+
{ key: "url", type: "string", description: "https://x.com/i/status/{id}" },
|
|
532
|
+
{ key: "text", type: "string", description: "The text as X stored it" }
|
|
533
|
+
],
|
|
534
|
+
async run(args, context) {
|
|
535
|
+
const body = { text: postText(args.text) };
|
|
536
|
+
const replyTo = text(args.inReplyToPostId);
|
|
537
|
+
const quote = text(args.quotePostId);
|
|
538
|
+
if (replyTo) body.reply = { in_reply_to_tweet_id: replyTo };
|
|
539
|
+
if (quote) body.quote_tweet_id = quote;
|
|
540
|
+
return createdOutput(await client(context).createPost(body));
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
type: "replyToPost",
|
|
545
|
+
label: "Reply to post",
|
|
546
|
+
description: `Reply to a post with up to ${MAX_POST_LENGTH} characters. Needs Read and Write permission.`,
|
|
547
|
+
idempotent: false,
|
|
548
|
+
inputs: [
|
|
549
|
+
{ key: "postId", label: "Post id", required: true, description: "The post being replied to." },
|
|
550
|
+
{
|
|
551
|
+
key: "text",
|
|
552
|
+
label: "Text",
|
|
553
|
+
required: true,
|
|
554
|
+
description: `The reply, up to ${MAX_POST_LENGTH} characters for non-Premium accounts.`
|
|
555
|
+
}
|
|
556
|
+
],
|
|
557
|
+
outputs: [
|
|
558
|
+
{ key: "id", type: "string", description: "The new post id" },
|
|
559
|
+
{ key: "url", type: "string", description: "https://x.com/i/status/{id}" },
|
|
560
|
+
{ key: "text", type: "string", description: "The text as X stored it" }
|
|
561
|
+
],
|
|
562
|
+
async run(args, context) {
|
|
563
|
+
const body = { text: postText(args.text), reply: { in_reply_to_tweet_id: postId(args.postId, "postId") } };
|
|
564
|
+
return createdOutput(await client(context).createPost(body));
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
type: "deletePost",
|
|
569
|
+
label: "Delete post",
|
|
570
|
+
description: "Delete one of the connected account's own posts. Needs Read and Write permission.",
|
|
571
|
+
idempotent: false,
|
|
572
|
+
inputs: [{ key: "postId", label: "Post id", required: true, description: "The post to delete." }],
|
|
573
|
+
outputs: [{ key: "deleted", type: "boolean", description: "Whether X reports the post deleted" }],
|
|
574
|
+
async run(args, context) {
|
|
575
|
+
const answer = await client(context).deletePost(postId(args.postId, "postId"));
|
|
576
|
+
return { deleted: answer.data?.deleted === true };
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
type: "getMe",
|
|
581
|
+
label: "Get me",
|
|
582
|
+
description: `The connected account. Access: 75 requests per 15 minutes per user, one user read. ${ACCESS_NOTE}`,
|
|
583
|
+
idempotent: true,
|
|
584
|
+
inputs: [],
|
|
585
|
+
outputs: [
|
|
586
|
+
{ key: "id", type: "string", description: "The account id" },
|
|
587
|
+
{ key: "username", type: "string", description: "The handle without @" },
|
|
588
|
+
{ key: "name", type: "string", description: "The display name" },
|
|
589
|
+
{ key: "url", type: "string", description: "https://x.com/{username}" }
|
|
590
|
+
],
|
|
591
|
+
sample: {},
|
|
592
|
+
async run(_args, context) {
|
|
593
|
+
const user = (await client(context).getMe()).data ?? {};
|
|
594
|
+
const username = user.username ?? "";
|
|
595
|
+
return { id: user.id ?? "", username, name: user.name ?? "", url: username ? `https://x.com/${username}` : "" };
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
type: "getPost",
|
|
600
|
+
label: "Get post",
|
|
601
|
+
description: `A post by id with its author and public metrics. Access: 900 requests per 15 minutes per user, one post read plus one user read. ${ACCESS_NOTE}`,
|
|
602
|
+
idempotent: true,
|
|
603
|
+
inputs: [{ key: "postId", label: "Post id", required: true, description: "The post to read." }],
|
|
604
|
+
outputs: [
|
|
605
|
+
{ key: "id", type: "string", description: "The post id" },
|
|
606
|
+
{ key: "text", type: "string", description: "The post text" },
|
|
607
|
+
{ key: "createdAt", type: "string", description: "ISO 8601 creation instant" },
|
|
608
|
+
{ key: "authorId", type: "string", description: "The author id" },
|
|
609
|
+
{ key: "authorUsername", type: "string", description: "The author handle, when included" },
|
|
610
|
+
{ key: "conversationId", type: "string", description: "The conversation (thread root) id" },
|
|
611
|
+
{ key: "url", type: "string", description: "The post page" },
|
|
612
|
+
{ key: "metrics", description: "public_metrics as JSON: like, reply, repost, quote, bookmark and impression counts" },
|
|
613
|
+
{ key: "raw", description: "The full answer as JSON" }
|
|
614
|
+
],
|
|
615
|
+
sample: { postId: "20" },
|
|
616
|
+
async run(args, context) {
|
|
617
|
+
const answer = await client(context).getPost(postId(args.postId, "postId"), {
|
|
618
|
+
"tweet.fields": "created_at,author_id,conversation_id,public_metrics",
|
|
619
|
+
expansions: "author_id",
|
|
620
|
+
"user.fields": "username,name"
|
|
621
|
+
});
|
|
622
|
+
const post = answer.data ?? {};
|
|
623
|
+
const record = joinAuthor(post, answer.includes?.users);
|
|
624
|
+
return {
|
|
625
|
+
id: record.id,
|
|
626
|
+
text: record.text,
|
|
627
|
+
createdAt: record.createdAt,
|
|
628
|
+
authorId: record.author.id,
|
|
629
|
+
authorUsername: record.author.username,
|
|
630
|
+
conversationId: record.conversationId,
|
|
631
|
+
url: record.url,
|
|
632
|
+
metrics: post.public_metrics ?? {},
|
|
633
|
+
raw: answer
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
type: "getUserByUsername",
|
|
639
|
+
label: "Get user by username",
|
|
640
|
+
description: `A user by handle. Access: 900 requests per 15 minutes per user, one user read. ${ACCESS_NOTE}`,
|
|
641
|
+
idempotent: true,
|
|
642
|
+
inputs: [
|
|
643
|
+
{
|
|
644
|
+
key: "username",
|
|
645
|
+
label: "Username",
|
|
646
|
+
required: true,
|
|
647
|
+
description: "The handle without @ (1 to 15 letters, digits or underscores); a leading @ is stripped."
|
|
648
|
+
}
|
|
649
|
+
],
|
|
650
|
+
outputs: [
|
|
651
|
+
{ key: "id", type: "string", description: "The user id" },
|
|
652
|
+
{ key: "username", type: "string", description: "The handle" },
|
|
653
|
+
{ key: "name", type: "string", description: "The display name" },
|
|
654
|
+
{ key: "description", type: "string", description: "The profile bio" },
|
|
655
|
+
{ key: "createdAt", type: "string", description: "ISO 8601 account creation instant" },
|
|
656
|
+
{ key: "followers", type: "number", description: "public_metrics.followers_count" },
|
|
657
|
+
{ key: "following", type: "number", description: "public_metrics.following_count" },
|
|
658
|
+
{ key: "posts", type: "number", description: "public_metrics.tweet_count" },
|
|
659
|
+
{ key: "url", type: "string", description: "https://x.com/{username}" },
|
|
660
|
+
{ key: "raw", description: "The full answer as JSON" }
|
|
661
|
+
],
|
|
662
|
+
sample: { username: "x" },
|
|
663
|
+
async run(args, context) {
|
|
664
|
+
const answer = await client(context).getUserByUsername(handle(args.username), {
|
|
665
|
+
"user.fields": `${USER_FIELDS},description,public_metrics,created_at`
|
|
666
|
+
});
|
|
667
|
+
const user = answer.data ?? {};
|
|
668
|
+
const metrics = user.public_metrics ?? {};
|
|
669
|
+
const username = user.username ?? "";
|
|
670
|
+
return {
|
|
671
|
+
id: user.id ?? "",
|
|
672
|
+
username,
|
|
673
|
+
name: user.name ?? "",
|
|
674
|
+
description: user.description ?? "",
|
|
675
|
+
createdAt: user.created_at ?? "",
|
|
676
|
+
followers: metrics.followers_count ?? 0,
|
|
677
|
+
following: metrics.following_count ?? 0,
|
|
678
|
+
posts: metrics.tweet_count ?? 0,
|
|
679
|
+
url: username ? `https://x.com/${username}` : "",
|
|
680
|
+
raw: answer
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
},
|
|
684
|
+
{
|
|
685
|
+
type: "searchRecentPosts",
|
|
686
|
+
label: "Search recent posts",
|
|
687
|
+
description: `Posts from the last 7 days matching a query, newest first. Access: 300 requests per 15 minutes per user, billed per post returned; this is the read that used to need a paid tier. ${ACCESS_NOTE}`,
|
|
688
|
+
idempotent: true,
|
|
689
|
+
inputs: [
|
|
690
|
+
{
|
|
691
|
+
key: "query",
|
|
692
|
+
label: "Query",
|
|
693
|
+
required: true,
|
|
694
|
+
description: `Recent-search query, 1 to ${MAX_QUERY_LENGTH} characters, such as from:xdevelopers -is:retweet.`
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
key: "maxResults",
|
|
698
|
+
label: "Max results",
|
|
699
|
+
type: "number",
|
|
700
|
+
description: `${SEARCH_MIN_RESULTS} to ${MAX_RESULTS}, default ${DEFAULT_SEARCH_RESULTS}; a smaller value is raised to ${SEARCH_MIN_RESULTS}.`
|
|
701
|
+
},
|
|
702
|
+
{ key: "sinceId", label: "Since id", description: "Only posts with an id greater than this one." }
|
|
703
|
+
],
|
|
704
|
+
outputs: [
|
|
705
|
+
{ key: "posts", description: "JSON array of posts: id, text, createdAt, conversationId, inReplyToUserId, author, url" },
|
|
706
|
+
{ key: "count", type: "number", description: "meta.result_count" },
|
|
707
|
+
{ key: "newestId", type: "string", description: "meta.newest_id" },
|
|
708
|
+
{ key: "nextToken", type: "string", description: "meta.next_token, for the page after this one" }
|
|
709
|
+
],
|
|
710
|
+
sample: { query: "from:xdevelopers -is:retweet", maxResults: "10" },
|
|
711
|
+
async run(args, context) {
|
|
712
|
+
const answer = await client(context).searchRecent({
|
|
713
|
+
query: searchQuery(args.query),
|
|
714
|
+
max_results: pageSize(args.maxResults, SEARCH_MIN_RESULTS, DEFAULT_SEARCH_RESULTS),
|
|
715
|
+
since_id: text(args.sinceId)
|
|
716
|
+
});
|
|
717
|
+
return {
|
|
718
|
+
posts: postsOf(answer),
|
|
719
|
+
count: answer.meta?.result_count ?? 0,
|
|
720
|
+
newestId: answer.meta?.newest_id ?? "",
|
|
721
|
+
nextToken: answer.meta?.next_token ?? ""
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
]
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
var connector = createXConnector();
|
|
729
|
+
|
|
730
|
+
// src/entry.ts
|
|
731
|
+
import { realpathSync } from "fs";
|
|
732
|
+
import { fileURLToPath } from "url";
|
|
733
|
+
import { serveConnector } from "@vornrun/connector-sdk";
|
|
734
|
+
function isEntryPoint(moduleUrl, argv = process.argv) {
|
|
735
|
+
const invoked = argv[1];
|
|
736
|
+
if (invoked === void 0) return false;
|
|
737
|
+
try {
|
|
738
|
+
return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(invoked);
|
|
739
|
+
} catch {
|
|
740
|
+
return false;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
async function serveIfEntryPoint(moduleUrl, serve = serveConnector) {
|
|
744
|
+
if (!isEntryPoint(moduleUrl)) return false;
|
|
745
|
+
await serve(connector);
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// src/index.ts
|
|
750
|
+
var index_default = connector;
|
|
751
|
+
await serveIfEntryPoint(import.meta.url);
|
|
752
|
+
export {
|
|
753
|
+
connector,
|
|
754
|
+
index_default as default
|
|
755
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vornrun/connector-x",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Trigger workflows from new mentions or posts matching a search on X, and create, reply to, delete, read and search posts from a step.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Javier Canizalez <javier-canizalez@outlook.com>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/vorn-run/connectors.git",
|
|
11
|
+
"directory": "packages/x"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"vorn",
|
|
15
|
+
"connector",
|
|
16
|
+
"x",
|
|
17
|
+
"twitter",
|
|
18
|
+
"mcp"
|
|
19
|
+
],
|
|
20
|
+
"bin": {
|
|
21
|
+
"vorn-connector-x": "dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"README.md",
|
|
34
|
+
"CHANGELOG.md"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "vitest run"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@vornrun/connector-sdk": "^0.7.0-beta.14"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^22.10.2",
|
|
46
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
47
|
+
"tsup": "^8.5.1",
|
|
48
|
+
"typescript": "^6.0.3",
|
|
49
|
+
"vitest": "^4.1.10"
|
|
50
|
+
},
|
|
51
|
+
"vorn": {
|
|
52
|
+
"category": "Social",
|
|
53
|
+
"keywords": [
|
|
54
|
+
"x",
|
|
55
|
+
"twitter",
|
|
56
|
+
"posts",
|
|
57
|
+
"tweets",
|
|
58
|
+
"mentions",
|
|
59
|
+
"social"
|
|
60
|
+
],
|
|
61
|
+
"auth": "Paste the four OAuth 1.0a credentials of an X developer app with Read and Write permission; there is no X CLI to borrow a login from.",
|
|
62
|
+
"packs": true
|
|
63
|
+
}
|
|
64
|
+
}
|