@youmind-openlab/rettiwt-api 1.0.3 → 1.0.4

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.
Files changed (61) hide show
  1. package/.devcontainer/devcontainer.json +20 -0
  2. package/README.md +326 -256
  3. package/dist/collections/Extractors.d.ts +9 -2
  4. package/dist/collections/Extractors.js +8 -1
  5. package/dist/collections/Extractors.js.map +1 -1
  6. package/dist/collections/Groups.js +5 -0
  7. package/dist/collections/Groups.js.map +1 -1
  8. package/dist/collections/Requests.js +5 -0
  9. package/dist/collections/Requests.js.map +1 -1
  10. package/dist/commands/User.js +126 -0
  11. package/dist/commands/User.js.map +1 -1
  12. package/dist/enums/Resource.d.ts +6 -1
  13. package/dist/enums/Resource.js +5 -0
  14. package/dist/enums/Resource.js.map +1 -1
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/models/args/PostArgs.d.ts +16 -1
  18. package/dist/models/args/PostArgs.js +44 -1
  19. package/dist/models/args/PostArgs.js.map +1 -1
  20. package/dist/requests/Tweet.d.ts +4 -0
  21. package/dist/requests/Tweet.js +57 -0
  22. package/dist/requests/Tweet.js.map +1 -1
  23. package/dist/requests/User.d.ts +25 -0
  24. package/dist/requests/User.js +59 -0
  25. package/dist/requests/User.js.map +1 -1
  26. package/dist/services/public/FetcherService.d.ts +14 -2
  27. package/dist/services/public/FetcherService.js +21 -6
  28. package/dist/services/public/FetcherService.js.map +1 -1
  29. package/dist/services/public/TweetService.js +9 -6
  30. package/dist/services/public/TweetService.js.map +1 -1
  31. package/dist/services/public/UserService.d.ts +45 -0
  32. package/dist/services/public/UserService.js +211 -0
  33. package/dist/services/public/UserService.js.map +1 -1
  34. package/dist/types/args/PostArgs.d.ts +44 -1
  35. package/dist/types/raw/tweet/Post.d.ts +16 -1
  36. package/dist/types/raw/user/ChangePassword.d.ts +8 -0
  37. package/dist/types/raw/user/ChangePassword.js +3 -0
  38. package/dist/types/raw/user/ChangePassword.js.map +1 -0
  39. package/dist/types/raw/user/ProfileUpdate.d.ts +1 -0
  40. package/dist/types/raw/user/Settings.d.ts +21 -0
  41. package/dist/types/raw/user/Settings.js +4 -0
  42. package/dist/types/raw/user/Settings.js.map +1 -0
  43. package/package.json +4 -2
  44. package/src/collections/Extractors.ts +15 -3
  45. package/src/collections/Groups.ts +5 -0
  46. package/src/collections/Requests.ts +6 -0
  47. package/src/commands/User.ts +146 -0
  48. package/src/enums/Resource.ts +5 -0
  49. package/src/index.ts +2 -0
  50. package/src/models/args/PostArgs.ts +49 -1
  51. package/src/requests/Tweet.ts +59 -0
  52. package/src/requests/User.ts +63 -0
  53. package/src/services/public/FetcherService.ts +27 -7
  54. package/src/services/public/TweetService.ts +10 -7
  55. package/src/services/public/UserService.ts +265 -0
  56. package/src/types/args/PostArgs.ts +50 -1
  57. package/src/types/raw/tweet/Post.ts +19 -1
  58. package/src/types/raw/user/ChangePassword.ts +8 -0
  59. package/src/types/raw/user/ProfileUpdate.ts +1 -0
  60. package/src/types/raw/user/Settings.ts +23 -0
  61. package/.claude/settings.local.json +0 -9
@@ -1,7 +1,12 @@
1
+ import axios from 'axios';
2
+
3
+ import { Cookie } from 'cookiejar';
4
+
1
5
  import { Extractors } from '../../collections/Extractors';
2
6
  import { RawAnalyticsGranularity, RawAnalyticsMetric } from '../../enums/raw/Analytics';
3
7
  import { ResourceType } from '../../enums/Resource';
4
8
  import { ProfileUpdateOptions } from '../../models/args/ProfileArgs';
9
+ import { AuthCredential } from '../../models/auth/AuthCredential';
5
10
  import { Analytics } from '../../models/data/Analytics';
6
11
  import { BookmarkFolder } from '../../models/data/BookmarkFolder';
7
12
  import { CursoredData } from '../../models/data/CursoredData';
@@ -18,6 +23,7 @@ import { IUserAnalyticsResponse } from '../../types/raw/user/Analytics';
18
23
  import { IUserBookmarkFoldersResponse } from '../../types/raw/user/BookmarkFolders';
19
24
  import { IUserBookmarkFolderTweetsResponse } from '../../types/raw/user/BookmarkFolderTweets';
20
25
  import { IUserBookmarksResponse } from '../../types/raw/user/Bookmarks';
26
+ import { IUserChangePasswordResponse } from '../../types/raw/user/ChangePassword';
21
27
  import { IUserDetailsResponse } from '../../types/raw/user/Details';
22
28
  import { IUserDetailsBulkResponse } from '../../types/raw/user/DetailsBulk';
23
29
  import { IUserFollowResponse } from '../../types/raw/user/Follow';
@@ -32,11 +38,14 @@ import { IUserNotificationsResponse } from '../../types/raw/user/Notifications';
32
38
  import { IUserProfileUpdateResponse } from '../../types/raw/user/ProfileUpdate';
33
39
  import { IUserRecommendedResponse } from '../../types/raw/user/Recommended';
34
40
  import { IUserSearchResponse } from '../../types/raw/user/Search';
41
+ import { IUserSettingsResponse } from '../../types/raw/user/Settings';
35
42
  import { IUserSubscriptionsResponse } from '../../types/raw/user/Subscriptions';
36
43
  import { IUserTweetsResponse } from '../../types/raw/user/Tweets';
37
44
  import { IUserTweetsAndRepliesResponse } from '../../types/raw/user/TweetsAndReplies';
38
45
  import { IUserUnfollowResponse } from '../../types/raw/user/Unfollow';
39
46
 
47
+ import { AuthService } from '../internal/AuthService';
48
+
40
49
  import { FetcherService } from './FetcherService';
41
50
 
42
51
  /**
@@ -54,6 +63,149 @@ export class UserService extends FetcherService {
54
63
  super(config);
55
64
  }
56
65
 
66
+ private _base64ByteSize(base64Data: string): number {
67
+ const paddingMatch = base64Data.match(/=+$/);
68
+ const paddingLength = paddingMatch ? paddingMatch[0].length : 0;
69
+
70
+ return (base64Data.length * 3) / 4 - paddingLength;
71
+ }
72
+
73
+ private _normalizeBase64(payload: string): string {
74
+ const trimmedPayload = payload.trim();
75
+ const lowerCasePayload = trimmedPayload.toLowerCase();
76
+ const base64Marker = ';base64,';
77
+
78
+ if (lowerCasePayload.startsWith('data:')) {
79
+ const markerIndex = lowerCasePayload.indexOf(base64Marker);
80
+ if (markerIndex !== -1) {
81
+ return trimmedPayload.slice(markerIndex + base64Marker.length).trim();
82
+ }
83
+ }
84
+
85
+ return trimmedPayload;
86
+ }
87
+
88
+ private _refreshApiKeyFromResponseCookies(setCookieHeader: string | string[] | undefined): void {
89
+ if (!this.config.apiKey || !setCookieHeader) {
90
+ return;
91
+ }
92
+
93
+ const requiredCookieNames = new Set(['auth_token', 'ct0', 'kdt', 'twid']);
94
+ const currentCookieString = AuthService.decodeCookie(this.config.apiKey);
95
+ const cookiePairs = this._splitSetCookieHeader(setCookieHeader);
96
+ const cookiesMap = new Map<string, string>();
97
+
98
+ for (const cookieEntry of currentCookieString.split(';')) {
99
+ const trimmedEntry = cookieEntry.trim();
100
+ const separatorIndex = trimmedEntry.indexOf('=');
101
+
102
+ if (!trimmedEntry || separatorIndex < 1) {
103
+ continue;
104
+ }
105
+
106
+ const key = trimmedEntry.slice(0, separatorIndex).trim();
107
+ const value = trimmedEntry.slice(separatorIndex + 1).trim();
108
+ if (!key || !value || !requiredCookieNames.has(key)) {
109
+ continue;
110
+ }
111
+
112
+ cookiesMap.set(key, value);
113
+ }
114
+
115
+ let hasUpdate = false;
116
+ for (const cookiePair of cookiePairs) {
117
+ const cookieValuePair = cookiePair.split(';', 1)[0]?.trim();
118
+ const separatorIndex = cookieValuePair?.indexOf('=') ?? -1;
119
+
120
+ if (!cookieValuePair || separatorIndex < 1) {
121
+ continue;
122
+ }
123
+
124
+ const key = cookieValuePair.slice(0, separatorIndex).trim();
125
+ const value = cookieValuePair.slice(separatorIndex + 1).trim();
126
+ if (!key || !value || !requiredCookieNames.has(key)) {
127
+ continue;
128
+ }
129
+
130
+ cookiesMap.set(key, value);
131
+ hasUpdate = true;
132
+ }
133
+
134
+ if (!hasUpdate || !cookiesMap.has('twid')) {
135
+ return;
136
+ }
137
+
138
+ let mergedCookieString = '';
139
+ for (const [key, value] of cookiesMap.entries()) {
140
+ mergedCookieString += `${key}=${value};`;
141
+ }
142
+
143
+ if (!mergedCookieString) {
144
+ return;
145
+ }
146
+
147
+ try {
148
+ this.config.apiKey = AuthService.encodeCookie(mergedCookieString);
149
+ } catch {
150
+ // Ignore cookie rotation errors and leave existing apiKey unchanged.
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Fetches a fresh ct0 (CSRF token) from Twitter by making a lightweight
156
+ * authenticated request, then rotates the apiKey with the updated cookie.
157
+ */
158
+ private async _refreshCsrfToken(): Promise<void> {
159
+ if (!this.config.apiKey) {
160
+ return;
161
+ }
162
+
163
+ try {
164
+ const cred = new AuthCredential(
165
+ AuthService.decodeCookie(this.config.apiKey)
166
+ .split(';')
167
+ .map((item) => new Cookie(item)),
168
+ );
169
+
170
+ const refreshResponse = await axios.get('https://x.com/i/api/1.1/account/verify_credentials.json', {
171
+ headers: {
172
+ ...cred.toHeader(),
173
+ authorization:
174
+ 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
175
+ },
176
+ httpAgent: this.config.httpsAgent,
177
+ httpsAgent: this.config.httpsAgent,
178
+ validateStatus: () => true,
179
+ });
180
+
181
+ this._refreshApiKeyFromResponseCookies(refreshResponse.headers['set-cookie']);
182
+ } catch {
183
+ // Best-effort: if ct0 refresh fails, leave apiKey as-is
184
+ }
185
+ }
186
+
187
+ private _splitSetCookieHeader(setCookieHeader: string | string[]): string[] {
188
+ if (Array.isArray(setCookieHeader)) {
189
+ return setCookieHeader;
190
+ }
191
+
192
+ return setCookieHeader.split(/,(?=\s*[^;,]+=)/g);
193
+ }
194
+
195
+ private _validateBase64Payload(payload: string, fieldName: string): string {
196
+ const normalizedPayload = this._normalizeBase64(payload).replace(/\s+/g, '');
197
+
198
+ if (normalizedPayload.length === 0) {
199
+ throw new Error(`${fieldName} cannot be empty`);
200
+ }
201
+
202
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalizedPayload)) {
203
+ throw new Error(`${fieldName} must be valid base64`);
204
+ }
205
+
206
+ return normalizedPayload;
207
+ }
208
+
57
209
  /**
58
210
  * Get the about profile of a user.
59
211
  *
@@ -319,6 +471,67 @@ export class UserService extends FetcherService {
319
471
  return data;
320
472
  }
321
473
 
474
+ /**
475
+ * Changes the password of the authenticated user.
476
+ *
477
+ * @param currentPassword - The current account password.
478
+ * @param newPassword - The new password to set.
479
+ * @returns Whether the password was changed successfully.
480
+ *
481
+ * @remarks
482
+ * After a successful password change, this method attempts to rotate the current
483
+ * `apiKey` using cookies returned by Twitter. If rotation is not possible, you
484
+ * must re-authenticate and obtain a new `apiKey` to continue making authenticated
485
+ * requests.
486
+ */
487
+ public async changePassword(currentPassword: string, newPassword: string): Promise<boolean> {
488
+ const resource = ResourceType.USER_PASSWORD_CHANGE;
489
+
490
+ const response = await this.requestWithResponse<IUserChangePasswordResponse>(resource, {
491
+ changePassword: { currentPassword, newPassword },
492
+ });
493
+
494
+ const data = Extractors[resource](response.data) ?? false;
495
+ if (data) {
496
+ this._refreshApiKeyFromResponseCookies(response.headers['set-cookie']);
497
+ await this._refreshCsrfToken();
498
+ }
499
+
500
+ return data;
501
+ }
502
+
503
+ /**
504
+ * Changes the username (screen_name) of the authenticated user.
505
+ *
506
+ * @param newUsername - The new username (with or without `@`).
507
+ * @returns Whether the username was changed successfully.
508
+ */
509
+ public async changeUsername(newUsername: string): Promise<boolean> {
510
+ const resource = ResourceType.USER_USERNAME_CHANGE;
511
+
512
+ // Strip @ prefix if present
513
+ const username = newUsername.startsWith('@') ? newUsername.slice(1) : newUsername;
514
+
515
+ // Username validation
516
+ if (username.length < 4) {
517
+ throw new Error('Username must be at least 4 characters long');
518
+ }
519
+ if (username.length > 15) {
520
+ throw new Error('Username cannot exceed 15 characters');
521
+ }
522
+ if (!/^[A-Za-z0-9_]+$/.test(username)) {
523
+ throw new Error('Username can only contain letters, numbers, and underscores');
524
+ }
525
+
526
+ const response = await this.request<IUserSettingsResponse>(resource, {
527
+ username,
528
+ });
529
+
530
+ const updatedUsername = Extractors[resource](response);
531
+
532
+ return updatedUsername?.toLowerCase() === username.toLowerCase();
533
+ }
534
+
322
535
  /**
323
536
  * Get the details of the logged in user.
324
537
  *
@@ -1194,4 +1407,56 @@ export class UserService extends FetcherService {
1194
1407
 
1195
1408
  return data;
1196
1409
  }
1410
+
1411
+ /**
1412
+ * Updates the profile banner of the authenticated user.
1413
+ *
1414
+ * @param bannerBase64 - The base64-encoded banner image data.
1415
+ * @returns Whether the profile banner was updated successfully.
1416
+ */
1417
+ public async updateProfileBanner(bannerBase64: string): Promise<boolean> {
1418
+ const resource = ResourceType.USER_PROFILE_BANNER_UPDATE;
1419
+
1420
+ const validatedBanner = this._validateBase64Payload(bannerBase64, 'Profile banner');
1421
+
1422
+ // Banner size validation (max 5 MB)
1423
+ const bannerSizeBytes = this._base64ByteSize(validatedBanner);
1424
+ if (bannerSizeBytes > 5 * 1024 * 1024) {
1425
+ throw new Error('Profile banner cannot exceed 5 MB');
1426
+ }
1427
+
1428
+ const response = await this.request<IUserProfileUpdateResponse>(resource, {
1429
+ profileBanner: validatedBanner,
1430
+ });
1431
+
1432
+ const data = Extractors[resource](response) ?? false;
1433
+
1434
+ return data;
1435
+ }
1436
+
1437
+ /**
1438
+ * Updates the profile image of the authenticated user.
1439
+ *
1440
+ * @param imageBase64 - The base64-encoded image data.
1441
+ * @returns Whether the profile image was updated successfully.
1442
+ */
1443
+ public async updateProfileImage(imageBase64: string): Promise<boolean> {
1444
+ const resource = ResourceType.USER_PROFILE_IMAGE_UPDATE;
1445
+
1446
+ const validatedImage = this._validateBase64Payload(imageBase64, 'Profile image');
1447
+
1448
+ // Image size validation (max 2 MB)
1449
+ const imageSizeBytes = this._base64ByteSize(validatedImage);
1450
+ if (imageSizeBytes > 2 * 1024 * 1024) {
1451
+ throw new Error('Profile image cannot exceed 2 MB');
1452
+ }
1453
+
1454
+ const response = await this.request<IUserProfileUpdateResponse>(resource, {
1455
+ profileImage: validatedImage,
1456
+ });
1457
+
1458
+ const data = Extractors[resource](response) ?? false;
1459
+
1460
+ return data;
1461
+ }
1197
1462
  }
@@ -20,9 +20,20 @@ export interface IPostArgs {
20
20
  * - {@link ResourceType.TWEET_UNRETWEET}
21
21
  * - {@link ResourceType.USER_FOLLOW}
22
22
  * - {@link ResourceType.USER_UNFOLLOW}
23
+ *
24
+ * For {@link ResourceType.USER_USERNAME_CHANGE}, use {@link IPostArgs.username}.
25
+ * `id` is still accepted for backward compatibility.
23
26
  */
24
27
  id?: string;
25
28
 
29
+ /**
30
+ * The new username to set.
31
+ *
32
+ * @remarks
33
+ * Required only when changing username using {@link ResourceType.USER_USERNAME_CHANGE}.
34
+ */
35
+ username?: string;
36
+
26
37
  /**
27
38
  * The tweet that is to be posted.
28
39
  *
@@ -67,6 +78,30 @@ export interface IPostArgs {
67
78
  * Required only when updating user profile using {@link ResourceType.USER_PROFILE_UPDATE}
68
79
  */
69
80
  profileOptions?: IProfileUpdateOptions;
81
+
82
+ /**
83
+ * Base64-encoded profile image data.
84
+ *
85
+ * @remarks
86
+ * Required only when updating profile image using {@link ResourceType.USER_PROFILE_IMAGE_UPDATE}.
87
+ */
88
+ profileImage?: string;
89
+
90
+ /**
91
+ * Base64-encoded profile banner data.
92
+ *
93
+ * @remarks
94
+ * Required only when updating profile banner using {@link ResourceType.USER_PROFILE_BANNER_UPDATE}.
95
+ */
96
+ profileBanner?: string;
97
+
98
+ /**
99
+ * Password change arguments.
100
+ *
101
+ * @remarks
102
+ * Required only when changing password using {@link ResourceType.USER_PASSWORD_CHANGE}.
103
+ */
104
+ changePassword?: IChangePasswordArgs;
70
105
  }
71
106
 
72
107
  /**
@@ -98,7 +133,8 @@ export interface INewTweet {
98
133
  * The text for the tweet to be created.
99
134
  *
100
135
  * @remarks
101
- * Length of the tweet must be \<= 280 characters.
136
+ * Length of the tweet must be \<= 280 characters for non-premium accounts.
137
+ * X Premium (Blue) accounts can post longer tweets (up to 25,000 characters).
102
138
  */
103
139
  text?: string;
104
140
  }
@@ -143,3 +179,16 @@ export interface IUploadArgs {
143
179
  */
144
180
  size?: number;
145
181
  }
182
+
183
+ /**
184
+ * Arguments for changing the account password.
185
+ *
186
+ * @public
187
+ */
188
+ export interface IChangePasswordArgs {
189
+ /** The current account password. */
190
+ currentPassword: string;
191
+
192
+ /** The new password to set. */
193
+ newPassword: string;
194
+ }
@@ -10,7 +10,8 @@ export interface ITweetPostResponse {
10
10
  }
11
11
 
12
12
  interface Data {
13
- create_tweet: CreateTweet;
13
+ create_tweet?: CreateTweet;
14
+ create_note_tweet?: CreateTweet;
14
15
  }
15
16
 
16
17
  interface CreateTweet {
@@ -148,3 +149,20 @@ interface UserMention {
148
149
  }
149
150
 
150
151
  interface UnmentionInfo {}
152
+
153
+ /**
154
+ * The raw data received after creating a note tweet (long-form tweet for X Premium accounts).
155
+ *
156
+ * @public
157
+ */
158
+ export interface ITweetPostNoteResponse {
159
+ data: NoteTweetData;
160
+ }
161
+
162
+ interface NoteTweetData {
163
+ notetweet_create: NoteTweetCreate;
164
+ }
165
+
166
+ interface NoteTweetCreate {
167
+ tweet_results: TweetResults;
168
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The raw data received when changing the account password.
3
+ *
4
+ * @public
5
+ */
6
+ export interface IUserChangePasswordResponse {
7
+ status: string;
8
+ }
@@ -39,6 +39,7 @@ export interface IUserProfileUpdateResponse {
39
39
  profile_image_url: string;
40
40
  profile_image_url_https: string;
41
41
  profile_banner_url: string;
42
+ profile_banner_url_https?: string;
42
43
  profile_link_color: string;
43
44
  profile_sidebar_border_color: string;
44
45
  profile_sidebar_fill_color: string;
@@ -0,0 +1,23 @@
1
+ /* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any */
2
+
3
+ /**
4
+ * The raw data received from the account settings endpoint.
5
+ *
6
+ * @public
7
+ */
8
+ export interface IUserSettingsResponse {
9
+ screen_name: string;
10
+ protected: boolean;
11
+ language: string;
12
+ geo_enabled: boolean;
13
+ discoverable_by_email: boolean;
14
+ discoverable_by_mobile_phone: boolean;
15
+ use_cookie_personalization: boolean;
16
+ sleep_time: {
17
+ enabled: boolean;
18
+ end_time: any;
19
+ start_time: any;
20
+ };
21
+ display_sensitive_media: boolean;
22
+ allow_media_tagging: string;
23
+ }
@@ -1,9 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(npm run build:*)",
5
- "Bash(npm run build:browser:*)",
6
- "Bash(npm run build:all:*)"
7
- ]
8
- }
9
- }