viruagent-cli 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +16 -0
- package/README.md +16 -0
- package/package.json +1 -1
- package/src/providers/x/apiClient.js +626 -0
- package/src/providers/x/auth.js +87 -0
- package/src/providers/x/graphqlSync.js +140 -0
- package/src/providers/x/index.js +251 -0
- package/src/providers/x/session.js +114 -0
- package/src/providers/x/utils.js +32 -0
- package/src/runner.js +33 -1
- package/src/services/providerManager.js +4 -2
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const CACHE_TTL_MS = 3600000; // 1 hour
|
|
5
|
+
|
|
6
|
+
const X_BASE_URL = 'https://x.com';
|
|
7
|
+
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36';
|
|
8
|
+
|
|
9
|
+
let memoryCache = null;
|
|
10
|
+
let memoryCacheTime = 0;
|
|
11
|
+
|
|
12
|
+
const getCachePath = () => {
|
|
13
|
+
const dir = path.join(require('os').homedir(), '.viruagent-cli');
|
|
14
|
+
return path.join(dir, 'x-graphql-cache.json');
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const loadFileCache = () => {
|
|
18
|
+
const cachePath = getCachePath();
|
|
19
|
+
if (!fs.existsSync(cachePath)) return null;
|
|
20
|
+
try {
|
|
21
|
+
const raw = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
|
|
22
|
+
if (Date.now() - raw.syncedAt > CACHE_TTL_MS) return null;
|
|
23
|
+
return raw;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const saveFileCache = (data) => {
|
|
30
|
+
const cachePath = getCachePath();
|
|
31
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
32
|
+
fs.writeFileSync(cachePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const fetchMainJsUrl = async () => {
|
|
36
|
+
const res = await fetch(X_BASE_URL, {
|
|
37
|
+
headers: { 'User-Agent': USER_AGENT },
|
|
38
|
+
redirect: 'follow',
|
|
39
|
+
});
|
|
40
|
+
const html = await res.text();
|
|
41
|
+
const match = html.match(/main\.[a-f0-9]+\.js/);
|
|
42
|
+
if (!match) throw new Error('Failed to find main.js URL from x.com');
|
|
43
|
+
return `https://abs.twimg.com/responsive-web/client-web/${match[0]}`;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const parseOperations = (jsContent) => {
|
|
47
|
+
const operations = new Map();
|
|
48
|
+
|
|
49
|
+
// Pattern: queryId:"...",operationName:"...",operationType:"...",metadata:{featureSwitches:[...],fieldToggles:[...]}
|
|
50
|
+
const regex = /queryId:"([^"]+)",operationName:"([^"]+)",operationType:"([^"]+)",metadata:\{featureSwitches:\[([^\]]*)\],fieldToggles:\[([^\]]*)\]\}/g;
|
|
51
|
+
let match;
|
|
52
|
+
while ((match = regex.exec(jsContent)) !== null) {
|
|
53
|
+
const [, queryId, operationName, operationType, featureSwitchesRaw, fieldTogglesRaw] = match;
|
|
54
|
+
|
|
55
|
+
const parseStringArray = (raw) =>
|
|
56
|
+
raw ? raw.match(/"([^"]+)"/g)?.map((s) => s.slice(1, -1)) || [] : [];
|
|
57
|
+
|
|
58
|
+
operations.set(operationName, {
|
|
59
|
+
queryId,
|
|
60
|
+
operationType,
|
|
61
|
+
featureSwitches: parseStringArray(featureSwitchesRaw),
|
|
62
|
+
fieldToggles: parseStringArray(fieldTogglesRaw),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return operations;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const syncGraphqlOperations = async ({ force = false } = {}) => {
|
|
70
|
+
// Check memory cache
|
|
71
|
+
if (!force && memoryCache && Date.now() - memoryCacheTime < CACHE_TTL_MS) {
|
|
72
|
+
return memoryCache;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check file cache
|
|
76
|
+
if (!force) {
|
|
77
|
+
const fileCache = loadFileCache();
|
|
78
|
+
if (fileCache?.operations) {
|
|
79
|
+
memoryCache = new Map(Object.entries(fileCache.operations));
|
|
80
|
+
memoryCacheTime = fileCache.syncedAt;
|
|
81
|
+
return memoryCache;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Fetch and parse from x.com
|
|
86
|
+
const mainJsUrl = await fetchMainJsUrl();
|
|
87
|
+
const res = await fetch(mainJsUrl, {
|
|
88
|
+
headers: { 'User-Agent': USER_AGENT },
|
|
89
|
+
});
|
|
90
|
+
const jsContent = await res.text();
|
|
91
|
+
const operations = parseOperations(jsContent);
|
|
92
|
+
|
|
93
|
+
if (operations.size === 0) {
|
|
94
|
+
throw new Error('Failed to parse any GraphQL operations from main.js');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Cache
|
|
98
|
+
memoryCache = operations;
|
|
99
|
+
memoryCacheTime = Date.now();
|
|
100
|
+
|
|
101
|
+
const cacheObj = {
|
|
102
|
+
syncedAt: Date.now(),
|
|
103
|
+
mainJsUrl,
|
|
104
|
+
operationCount: operations.size,
|
|
105
|
+
operations: Object.fromEntries(operations),
|
|
106
|
+
};
|
|
107
|
+
saveFileCache(cacheObj);
|
|
108
|
+
|
|
109
|
+
return operations;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const getOperation = async (operationName) => {
|
|
113
|
+
let ops = await syncGraphqlOperations();
|
|
114
|
+
let op = ops.get(operationName);
|
|
115
|
+
|
|
116
|
+
// If not found, force re-sync (queryId may have changed)
|
|
117
|
+
if (!op) {
|
|
118
|
+
ops = await syncGraphqlOperations({ force: true });
|
|
119
|
+
op = ops.get(operationName);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!op) {
|
|
123
|
+
throw new Error(`GraphQL operation not found: ${operationName}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return op;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const invalidateCache = () => {
|
|
130
|
+
memoryCache = null;
|
|
131
|
+
memoryCacheTime = 0;
|
|
132
|
+
const cachePath = getCachePath();
|
|
133
|
+
if (fs.existsSync(cachePath)) fs.unlinkSync(cachePath);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
syncGraphqlOperations,
|
|
138
|
+
getOperation,
|
|
139
|
+
invalidateCache,
|
|
140
|
+
};
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { saveProviderMeta, clearProviderMeta, getProviderMeta } = require('../../storage/sessionStore');
|
|
4
|
+
const createXApiClient = require('./apiClient');
|
|
5
|
+
const { readXCredentials } = require('./utils');
|
|
6
|
+
const { createXWithProviderSession } = require('./session');
|
|
7
|
+
const { createSetCredentials } = require('./auth');
|
|
8
|
+
const { syncGraphqlOperations } = require('./graphqlSync');
|
|
9
|
+
|
|
10
|
+
const createXProvider = ({ sessionPath, account }) => {
|
|
11
|
+
const xApi = createXApiClient({ sessionPath });
|
|
12
|
+
|
|
13
|
+
const setCredentials = createSetCredentials({ sessionPath });
|
|
14
|
+
|
|
15
|
+
const withProviderSession = createXWithProviderSession(setCredentials, account);
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
id: 'x',
|
|
19
|
+
name: 'X (Twitter)',
|
|
20
|
+
|
|
21
|
+
async authStatus() {
|
|
22
|
+
return withProviderSession(async () => {
|
|
23
|
+
try {
|
|
24
|
+
const viewer = await xApi.getViewer();
|
|
25
|
+
return {
|
|
26
|
+
provider: 'x',
|
|
27
|
+
loggedIn: true,
|
|
28
|
+
username: viewer.username,
|
|
29
|
+
name: viewer.name,
|
|
30
|
+
followerCount: viewer.followerCount,
|
|
31
|
+
tweetCount: viewer.tweetCount,
|
|
32
|
+
sessionPath,
|
|
33
|
+
metadata: getProviderMeta('x', account) || {},
|
|
34
|
+
};
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return {
|
|
37
|
+
provider: 'x',
|
|
38
|
+
loggedIn: false,
|
|
39
|
+
sessionPath,
|
|
40
|
+
error: error.message,
|
|
41
|
+
metadata: getProviderMeta('x', account) || {},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
async login({ authToken, ct0 } = {}) {
|
|
48
|
+
const creds = readXCredentials();
|
|
49
|
+
const resolved = {
|
|
50
|
+
authToken: authToken || creds.authToken,
|
|
51
|
+
ct0: ct0 || creds.ct0,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
if (!resolved.authToken || !resolved.ct0) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
'X login requires auth_token and ct0 cookies. ' +
|
|
57
|
+
'Please set X_AUTH_TOKEN / X_CT0 environment variables.',
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const result = await setCredentials(resolved);
|
|
62
|
+
xApi.resetState();
|
|
63
|
+
|
|
64
|
+
saveProviderMeta('x', {
|
|
65
|
+
loggedIn: result.loggedIn,
|
|
66
|
+
username: result.username,
|
|
67
|
+
sessionPath: result.sessionPath,
|
|
68
|
+
}, account);
|
|
69
|
+
|
|
70
|
+
return result;
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async getProfile({ username } = {}) {
|
|
74
|
+
return withProviderSession(async () => {
|
|
75
|
+
if (!username) throw new Error('username is required.');
|
|
76
|
+
const profile = await xApi.getUserByScreenName(username);
|
|
77
|
+
return { provider: 'x', mode: 'profile', ...profile };
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
async getFeed({ count = 20 } = {}) {
|
|
82
|
+
return withProviderSession(async () => {
|
|
83
|
+
const items = await xApi.getHomeTimeline(count);
|
|
84
|
+
return {
|
|
85
|
+
provider: 'x',
|
|
86
|
+
mode: 'feed',
|
|
87
|
+
count: items.length,
|
|
88
|
+
items,
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
async listPosts({ username, limit = 20 } = {}) {
|
|
94
|
+
return withProviderSession(async () => {
|
|
95
|
+
if (!username) throw new Error('username is required.');
|
|
96
|
+
const user = await xApi.getUserByScreenName(username);
|
|
97
|
+
const posts = await xApi.getUserTweets(user.id, limit);
|
|
98
|
+
return {
|
|
99
|
+
provider: 'x',
|
|
100
|
+
mode: 'posts',
|
|
101
|
+
username,
|
|
102
|
+
totalCount: posts.length,
|
|
103
|
+
posts,
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async getPost({ postId } = {}) {
|
|
109
|
+
return withProviderSession(async () => {
|
|
110
|
+
const tweetId = String(postId || '').trim();
|
|
111
|
+
if (!tweetId) throw new Error('postId (tweet ID) is required.');
|
|
112
|
+
const tweet = await xApi.getTweetDetail(tweetId);
|
|
113
|
+
return { provider: 'x', mode: 'post', ...tweet };
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
async search({ query, limit = 20 } = {}) {
|
|
118
|
+
return withProviderSession(async () => {
|
|
119
|
+
if (!query) throw new Error('query is required.');
|
|
120
|
+
const results = await xApi.searchTimeline(query, limit);
|
|
121
|
+
return {
|
|
122
|
+
provider: 'x',
|
|
123
|
+
mode: 'search',
|
|
124
|
+
query,
|
|
125
|
+
totalCount: results.length,
|
|
126
|
+
results,
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async publish({ text, content, mediaUrl, mediaPath, replyTo } = {}) {
|
|
132
|
+
return withProviderSession(async () => {
|
|
133
|
+
const tweetText = text || content || '';
|
|
134
|
+
if (!tweetText) throw new Error('Tweet text is required.');
|
|
135
|
+
|
|
136
|
+
let mediaIds;
|
|
137
|
+
if (mediaUrl || mediaPath) {
|
|
138
|
+
let buffer;
|
|
139
|
+
if (mediaPath) {
|
|
140
|
+
buffer = fs.readFileSync(path.resolve(mediaPath));
|
|
141
|
+
} else {
|
|
142
|
+
const res = await fetch(mediaUrl);
|
|
143
|
+
if (!res.ok) throw new Error(`Image download failed: ${res.status}`);
|
|
144
|
+
buffer = Buffer.from(await res.arrayBuffer());
|
|
145
|
+
}
|
|
146
|
+
const mediaId = await xApi.uploadMedia(buffer);
|
|
147
|
+
mediaIds = [mediaId];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const result = await xApi.createTweet(tweetText, { mediaIds, replyTo });
|
|
151
|
+
return {
|
|
152
|
+
provider: 'x',
|
|
153
|
+
mode: 'publish',
|
|
154
|
+
...result,
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
async delete({ postId } = {}) {
|
|
160
|
+
return withProviderSession(async () => {
|
|
161
|
+
const tweetId = String(postId || '').trim();
|
|
162
|
+
if (!tweetId) throw new Error('postId (tweet ID) is required.');
|
|
163
|
+
const result = await xApi.deleteTweet(tweetId);
|
|
164
|
+
return { provider: 'x', mode: 'delete', postId: tweetId, ...result };
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
async like({ postId } = {}) {
|
|
169
|
+
return withProviderSession(async () => {
|
|
170
|
+
const tweetId = String(postId || '').trim();
|
|
171
|
+
if (!tweetId) throw new Error('postId (tweet ID) is required.');
|
|
172
|
+
const result = await xApi.likeTweet(tweetId);
|
|
173
|
+
return { provider: 'x', mode: 'like', postId: tweetId, ...result };
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
async unlike({ postId } = {}) {
|
|
178
|
+
return withProviderSession(async () => {
|
|
179
|
+
const tweetId = String(postId || '').trim();
|
|
180
|
+
if (!tweetId) throw new Error('postId (tweet ID) is required.');
|
|
181
|
+
const result = await xApi.unlikeTweet(tweetId);
|
|
182
|
+
return { provider: 'x', mode: 'unlike', postId: tweetId, ...result };
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
async follow({ username } = {}) {
|
|
187
|
+
return withProviderSession(async () => {
|
|
188
|
+
if (!username) throw new Error('username is required.');
|
|
189
|
+
const user = await xApi.getUserByScreenName(username);
|
|
190
|
+
const result = await xApi.followUser(user.id);
|
|
191
|
+
return { provider: 'x', mode: 'follow', username, userId: user.id, ...result };
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
async unfollow({ username } = {}) {
|
|
196
|
+
return withProviderSession(async () => {
|
|
197
|
+
if (!username) throw new Error('username is required.');
|
|
198
|
+
const user = await xApi.getUserByScreenName(username);
|
|
199
|
+
const result = await xApi.unfollowUser(user.id);
|
|
200
|
+
return { provider: 'x', mode: 'unfollow', username, userId: user.id, ...result };
|
|
201
|
+
});
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
async retweet({ postId } = {}) {
|
|
205
|
+
return withProviderSession(async () => {
|
|
206
|
+
const tweetId = String(postId || '').trim();
|
|
207
|
+
if (!tweetId) throw new Error('postId (tweet ID) is required.');
|
|
208
|
+
const result = await xApi.retweet(tweetId);
|
|
209
|
+
return { provider: 'x', mode: 'retweet', postId: tweetId, ...result };
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
async unretweet({ postId } = {}) {
|
|
214
|
+
return withProviderSession(async () => {
|
|
215
|
+
const tweetId = String(postId || '').trim();
|
|
216
|
+
if (!tweetId) throw new Error('postId (tweet ID) is required.');
|
|
217
|
+
const result = await xApi.unretweet(tweetId);
|
|
218
|
+
return { provider: 'x', mode: 'unretweet', postId: tweetId, ...result };
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
rateLimitStatus() {
|
|
223
|
+
return {
|
|
224
|
+
provider: 'x',
|
|
225
|
+
mode: 'rateLimitStatus',
|
|
226
|
+
...xApi.getRateLimitStatus(),
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
async syncOperations() {
|
|
231
|
+
const ops = await syncGraphqlOperations({ force: true });
|
|
232
|
+
return {
|
|
233
|
+
provider: 'x',
|
|
234
|
+
mode: 'syncOperations',
|
|
235
|
+
operationCount: ops.size,
|
|
236
|
+
message: `Synced ${ops.size} GraphQL operations from x.com`,
|
|
237
|
+
};
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
async logout() {
|
|
241
|
+
clearProviderMeta('x', account);
|
|
242
|
+
return {
|
|
243
|
+
provider: 'x',
|
|
244
|
+
loggedOut: true,
|
|
245
|
+
sessionPath,
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
module.exports = createXProvider;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { saveProviderMeta } = require('../../storage/sessionStore');
|
|
4
|
+
const { readXCredentials, parseXSessionError, buildLoginErrorMessage } = require('./utils');
|
|
5
|
+
|
|
6
|
+
const ESSENTIAL_COOKIES = ['auth_token', 'ct0'];
|
|
7
|
+
|
|
8
|
+
const readSessionFile = (sessionPath) => {
|
|
9
|
+
if (!fs.existsSync(sessionPath)) return null;
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const writeSessionFile = (sessionPath, data) => {
|
|
18
|
+
fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
|
|
19
|
+
fs.writeFileSync(sessionPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const saveXSession = (sessionPath, cookies) => {
|
|
23
|
+
const existing = readSessionFile(sessionPath) || {};
|
|
24
|
+
writeSessionFile(sessionPath, {
|
|
25
|
+
...existing,
|
|
26
|
+
cookies,
|
|
27
|
+
updatedAt: new Date().toISOString(),
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const loadXSession = (sessionPath) => {
|
|
32
|
+
const raw = readSessionFile(sessionPath);
|
|
33
|
+
return Array.isArray(raw?.cookies) ? raw.cookies : null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ── Rate Limit persistence ──
|
|
37
|
+
|
|
38
|
+
const loadRateLimits = (sessionPath) => {
|
|
39
|
+
const raw = readSessionFile(sessionPath);
|
|
40
|
+
return raw?.rateLimits || null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const saveRateLimits = (sessionPath, counters) => {
|
|
44
|
+
const raw = readSessionFile(sessionPath) || {};
|
|
45
|
+
raw.rateLimits = {
|
|
46
|
+
...counters,
|
|
47
|
+
savedAt: new Date().toISOString(),
|
|
48
|
+
};
|
|
49
|
+
writeSessionFile(sessionPath, raw);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const validateXSession = (sessionPath) => {
|
|
53
|
+
const cookies = loadXSession(sessionPath);
|
|
54
|
+
if (!cookies) return false;
|
|
55
|
+
return ESSENTIAL_COOKIES.every((name) =>
|
|
56
|
+
cookies.some((c) => c.name === name && c.value),
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const cookiesToHeader = (cookies) =>
|
|
61
|
+
cookies.map((c) => `${c.name}=${c.value}`).join('; ');
|
|
62
|
+
|
|
63
|
+
const createXWithProviderSession = (setCredentials, account) => async (fn) => {
|
|
64
|
+
const credentials = readXCredentials();
|
|
65
|
+
const hasCredentials = Boolean(credentials.authToken && credentials.ct0);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const result = await fn();
|
|
69
|
+
saveProviderMeta('x', { loggedIn: true, lastValidatedAt: new Date().toISOString() }, account);
|
|
70
|
+
return result;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (!parseXSessionError(error) || !hasCredentials) {
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const loginResult = await setCredentials({
|
|
78
|
+
authToken: credentials.authToken,
|
|
79
|
+
ct0: credentials.ct0,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
saveProviderMeta('x', {
|
|
83
|
+
loggedIn: loginResult.loggedIn,
|
|
84
|
+
username: loginResult.username,
|
|
85
|
+
sessionPath: loginResult.sessionPath,
|
|
86
|
+
lastRefreshedAt: new Date().toISOString(),
|
|
87
|
+
lastError: null,
|
|
88
|
+
}, account);
|
|
89
|
+
|
|
90
|
+
if (!loginResult.loggedIn) {
|
|
91
|
+
throw new Error(loginResult.message || 'Login status could not be confirmed after session refresh.');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return fn();
|
|
95
|
+
} catch (reloginError) {
|
|
96
|
+
saveProviderMeta('x', {
|
|
97
|
+
loggedIn: false,
|
|
98
|
+
lastError: buildLoginErrorMessage(reloginError),
|
|
99
|
+
lastValidatedAt: new Date().toISOString(),
|
|
100
|
+
}, account);
|
|
101
|
+
throw reloginError;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
module.exports = {
|
|
107
|
+
saveXSession,
|
|
108
|
+
loadXSession,
|
|
109
|
+
validateXSession,
|
|
110
|
+
cookiesToHeader,
|
|
111
|
+
loadRateLimits,
|
|
112
|
+
saveRateLimits,
|
|
113
|
+
createXWithProviderSession,
|
|
114
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const readXCredentials = () => {
|
|
2
|
+
const authToken = process.env.X_AUTH_TOKEN || process.env.TWITTER_AUTH_TOKEN;
|
|
3
|
+
const ct0 = process.env.X_CT0 || process.env.TWITTER_CT0;
|
|
4
|
+
return {
|
|
5
|
+
authToken: typeof authToken === 'string' && authToken.trim() ? authToken.trim() : null,
|
|
6
|
+
ct0: typeof ct0 === 'string' && ct0.trim() ? ct0.trim() : null,
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const parseXSessionError = (error) => {
|
|
11
|
+
const message = String(error?.message || '').toLowerCase();
|
|
12
|
+
return [
|
|
13
|
+
'no session file found',
|
|
14
|
+
'no valid cookies in session',
|
|
15
|
+
'session expired',
|
|
16
|
+
'authentication error',
|
|
17
|
+
'unauthorized',
|
|
18
|
+
'401',
|
|
19
|
+
'403',
|
|
20
|
+
].some((token) => message.includes(token.toLowerCase()));
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const buildLoginErrorMessage = (error) => String(error?.message || 'Session validation failed.');
|
|
24
|
+
|
|
25
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
26
|
+
|
|
27
|
+
module.exports = {
|
|
28
|
+
readXCredentials,
|
|
29
|
+
parseXSessionError,
|
|
30
|
+
buildLoginErrorMessage,
|
|
31
|
+
sleep,
|
|
32
|
+
};
|
package/src/runner.js
CHANGED
|
@@ -116,12 +116,14 @@ const runCommand = async (command, opts = {}) => {
|
|
|
116
116
|
username: opts.username || undefined,
|
|
117
117
|
password: opts.password || undefined,
|
|
118
118
|
twoFactorCode: opts.twoFactorCode || undefined,
|
|
119
|
+
authToken: opts.authToken || undefined,
|
|
120
|
+
ct0: opts.ct0 || undefined,
|
|
119
121
|
})
|
|
120
122
|
)();
|
|
121
123
|
|
|
122
124
|
case 'publish': {
|
|
123
125
|
const content = readContent(opts);
|
|
124
|
-
if (!content && providerName !== 'insta') {
|
|
126
|
+
if (!content && providerName !== 'insta' && providerName !== 'x') {
|
|
125
127
|
throw createError('MISSING_CONTENT', 'publish requires --content or --content-file', 'viruagent-cli publish --spec');
|
|
126
128
|
}
|
|
127
129
|
return withProvider(() =>
|
|
@@ -275,6 +277,36 @@ const runCommand = async (command, opts = {}) => {
|
|
|
275
277
|
case 'rate-limit-status':
|
|
276
278
|
return withProvider(() => Promise.resolve(provider.rateLimitStatus()))();
|
|
277
279
|
|
|
280
|
+
// ── X (Twitter)-specific commands ──
|
|
281
|
+
|
|
282
|
+
case 'search':
|
|
283
|
+
if (!opts.query) {
|
|
284
|
+
throw createError('MISSING_PARAM', 'search requires --query');
|
|
285
|
+
}
|
|
286
|
+
return withProvider(() => provider.search({ query: opts.query, limit: parseIntOrNull(opts.limit) || 20 }))();
|
|
287
|
+
|
|
288
|
+
case 'retweet':
|
|
289
|
+
if (!opts.postId) {
|
|
290
|
+
throw createError('MISSING_PARAM', 'retweet requires --post-id');
|
|
291
|
+
}
|
|
292
|
+
return withProvider(() => provider.retweet({ postId: opts.postId }))();
|
|
293
|
+
|
|
294
|
+
case 'unretweet':
|
|
295
|
+
if (!opts.postId) {
|
|
296
|
+
throw createError('MISSING_PARAM', 'unretweet requires --post-id');
|
|
297
|
+
}
|
|
298
|
+
return withProvider(() => provider.unretweet({ postId: opts.postId }))();
|
|
299
|
+
|
|
300
|
+
case 'delete':
|
|
301
|
+
case 'delete-post':
|
|
302
|
+
if (!opts.postId) {
|
|
303
|
+
throw createError('MISSING_PARAM', 'delete requires --post-id');
|
|
304
|
+
}
|
|
305
|
+
return withProvider(() => provider.delete ? provider.delete({ postId: opts.postId }) : provider.deletePost({ postId: opts.postId }))();
|
|
306
|
+
|
|
307
|
+
case 'sync-operations':
|
|
308
|
+
return withProvider(() => provider.syncOperations())();
|
|
309
|
+
|
|
278
310
|
default:
|
|
279
311
|
throw createError('UNKNOWN_COMMAND', `Unknown command: ${command}`, 'viruagent-cli --spec');
|
|
280
312
|
}
|
|
@@ -3,14 +3,16 @@ const { getSessionPath } = require('../storage/sessionStore');
|
|
|
3
3
|
const createTistoryProvider = require('../providers/tistory');
|
|
4
4
|
const createNaverProvider = require('../providers/naver');
|
|
5
5
|
const createInstaProvider = require('../providers/insta');
|
|
6
|
+
const createXProvider = require('../providers/x');
|
|
6
7
|
|
|
7
8
|
const providerFactory = {
|
|
8
9
|
tistory: createTistoryProvider,
|
|
9
10
|
naver: createNaverProvider,
|
|
10
11
|
insta: createInstaProvider,
|
|
12
|
+
x: createXProvider,
|
|
11
13
|
};
|
|
12
14
|
|
|
13
|
-
const providers = ['tistory', 'naver', 'insta'];
|
|
15
|
+
const providers = ['tistory', 'naver', 'insta', 'x'];
|
|
14
16
|
|
|
15
17
|
const createProviderManager = () => {
|
|
16
18
|
const cache = new Map();
|
|
@@ -36,7 +38,7 @@ const createProviderManager = () => {
|
|
|
36
38
|
return cache.get(cacheKey);
|
|
37
39
|
};
|
|
38
40
|
|
|
39
|
-
const providerNames = { tistory: 'Tistory', naver: 'Naver Blog', insta: 'Instagram' };
|
|
41
|
+
const providerNames = { tistory: 'Tistory', naver: 'Naver Blog', insta: 'Instagram', x: 'X (Twitter)' };
|
|
40
42
|
const getAvailableProviders = () => providers.map((provider) => ({
|
|
41
43
|
id: provider,
|
|
42
44
|
name: providerNames[provider] || provider,
|