viruagent-cli 0.8.0 → 0.9.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.
@@ -0,0 +1,248 @@
1
+ const fs = require('fs');
2
+ const { saveProviderMeta, clearProviderMeta, getProviderMeta } = require('../../storage/sessionStore');
3
+ const createThreadsApiClient = require('./apiClient');
4
+ const { readThreadsCredentials } = require('./utils');
5
+ const { createThreadsWithProviderSession } = require('./session');
6
+ const { createAskForAuthentication } = require('./auth');
7
+
8
+ const createThreadsProvider = ({ sessionPath, account }) => {
9
+ const api = createThreadsApiClient({ sessionPath });
10
+
11
+ const askForAuthentication = createAskForAuthentication({ sessionPath });
12
+
13
+ const withProviderSession = createThreadsWithProviderSession(askForAuthentication, account);
14
+
15
+ return {
16
+ id: 'threads',
17
+ name: 'Threads',
18
+
19
+ async authStatus() {
20
+ return withProviderSession(async () => {
21
+ try {
22
+ const session = api.getSession();
23
+ return {
24
+ provider: 'threads',
25
+ loggedIn: true,
26
+ userId: session.userId,
27
+ hasSession: Boolean(session.token),
28
+ sessionPath,
29
+ metadata: getProviderMeta('threads', account) || {},
30
+ };
31
+ } catch (error) {
32
+ return {
33
+ provider: 'threads',
34
+ loggedIn: false,
35
+ sessionPath,
36
+ error: error.message,
37
+ metadata: getProviderMeta('threads', account) || {},
38
+ };
39
+ }
40
+ });
41
+ },
42
+
43
+ async login({ username, password } = {}) {
44
+ const creds = readThreadsCredentials();
45
+ const resolved = {
46
+ username: username || creds.username,
47
+ password: password || creds.password,
48
+ };
49
+
50
+ if (!resolved.username || !resolved.password) {
51
+ throw new Error(
52
+ 'Threads login requires username/password. ' +
53
+ 'Please set the THREADS_USERNAME / THREADS_PASSWORD (or INSTA_USERNAME / INSTA_PASSWORD) environment variables.',
54
+ );
55
+ }
56
+
57
+ const result = await askForAuthentication(resolved);
58
+ api.resetState();
59
+
60
+ saveProviderMeta('threads', {
61
+ loggedIn: result.loggedIn,
62
+ userId: result.userId,
63
+ username: result.username,
64
+ sessionPath: result.sessionPath,
65
+ }, account);
66
+
67
+ return result;
68
+ },
69
+
70
+ async publish({ content, imageUrls, imageUrl, imagePath, replyTo, caption } = {}) {
71
+ return withProviderSession(async () => {
72
+ const text = content || caption || '';
73
+
74
+ // Image thread
75
+ let resolvedImageUrl = imageUrl;
76
+ if (!resolvedImageUrl && !imagePath && imageUrls?.length > 0) {
77
+ resolvedImageUrl = imageUrls[0];
78
+ }
79
+
80
+ if (resolvedImageUrl || imagePath) {
81
+ let imageBuffer;
82
+ if (imagePath) {
83
+ imageBuffer = fs.readFileSync(imagePath);
84
+ } else {
85
+ const res = await fetch(resolvedImageUrl);
86
+ if (!res.ok) throw new Error(`Image download failed: ${res.status}`);
87
+ imageBuffer = Buffer.from(await res.arrayBuffer());
88
+ }
89
+ const uploadId = await api.uploadImage(imageBuffer);
90
+ const result = await api.publishImageThread(uploadId, text);
91
+ return { provider: 'threads', mode: 'publish', ...result };
92
+ }
93
+
94
+ // Text-only thread
95
+ if (!text) {
96
+ throw new Error('content is required for text threads.');
97
+ }
98
+ const result = await api.publishTextThread(text, replyTo);
99
+ return { provider: 'threads', mode: 'publish', ...result };
100
+ });
101
+ },
102
+
103
+ async comment({ postId, text } = {}) {
104
+ return withProviderSession(async () => {
105
+ if (!postId) throw new Error('postId is required.');
106
+ if (!text) throw new Error('text is required.');
107
+ const result = await api.publishTextThread(text, postId);
108
+ return {
109
+ provider: 'threads',
110
+ mode: 'comment',
111
+ replyTo: postId,
112
+ ...result,
113
+ };
114
+ });
115
+ },
116
+
117
+ async like({ postId } = {}) {
118
+ return withProviderSession(async () => {
119
+ if (!postId) throw new Error('postId is required.');
120
+ const result = await api.likeThread(postId);
121
+ return { provider: 'threads', mode: 'like', postId, status: result.status };
122
+ });
123
+ },
124
+
125
+ async unlike({ postId } = {}) {
126
+ return withProviderSession(async () => {
127
+ if (!postId) throw new Error('postId is required.');
128
+ const result = await api.unlikeThread(postId);
129
+ return { provider: 'threads', mode: 'unlike', postId, status: result.status };
130
+ });
131
+ },
132
+
133
+ async follow({ username } = {}) {
134
+ return withProviderSession(async () => {
135
+ if (!username) throw new Error('username is required.');
136
+ const userId = await api.getUserId(username);
137
+ const result = await api.followUser(userId);
138
+ return {
139
+ provider: 'threads',
140
+ mode: 'follow',
141
+ username,
142
+ userId,
143
+ following: result.following,
144
+ outgoingRequest: result.outgoingRequest,
145
+ status: result.status,
146
+ };
147
+ });
148
+ },
149
+
150
+ async unfollow({ username } = {}) {
151
+ return withProviderSession(async () => {
152
+ if (!username) throw new Error('username is required.');
153
+ const userId = await api.getUserId(username);
154
+ const result = await api.unfollowUser(userId);
155
+ return {
156
+ provider: 'threads',
157
+ mode: 'unfollow',
158
+ username,
159
+ userId,
160
+ following: result.following,
161
+ status: result.status,
162
+ };
163
+ });
164
+ },
165
+
166
+ async getProfile({ username } = {}) {
167
+ return withProviderSession(async () => {
168
+ if (!username) throw new Error('username is required.');
169
+ const userId = await api.getUserId(username);
170
+ const profile = await api.getUserProfile(userId);
171
+ return { provider: 'threads', mode: 'profile', ...profile };
172
+ });
173
+ },
174
+
175
+ async getFeed() {
176
+ return withProviderSession(async () => {
177
+ const items = await api.getTimeline();
178
+ return {
179
+ provider: 'threads',
180
+ mode: 'feed',
181
+ count: items.length,
182
+ items,
183
+ };
184
+ });
185
+ },
186
+
187
+ async listPosts({ username, limit = 20 } = {}) {
188
+ return withProviderSession(async () => {
189
+ if (!username) throw new Error('username is required.');
190
+ const userId = await api.getUserId(username);
191
+ const posts = await api.getUserThreads(userId, limit);
192
+ return {
193
+ provider: 'threads',
194
+ mode: 'posts',
195
+ username,
196
+ totalCount: posts.length,
197
+ posts,
198
+ };
199
+ });
200
+ },
201
+
202
+ async search({ query, limit = 20 } = {}) {
203
+ return withProviderSession(async () => {
204
+ if (!query) throw new Error('query is required.');
205
+ const users = await api.searchUsers(query, limit);
206
+ return {
207
+ provider: 'threads',
208
+ mode: 'search',
209
+ query,
210
+ totalCount: users.length,
211
+ users,
212
+ };
213
+ });
214
+ },
215
+
216
+ async deletePost({ postId } = {}) {
217
+ return withProviderSession(async () => {
218
+ if (!postId) throw new Error('postId is required.');
219
+ const result = await api.deleteThread(postId);
220
+ return {
221
+ provider: 'threads',
222
+ mode: 'delete',
223
+ postId,
224
+ status: result.status,
225
+ };
226
+ });
227
+ },
228
+
229
+ rateLimitStatus() {
230
+ return {
231
+ provider: 'threads',
232
+ mode: 'rateLimitStatus',
233
+ ...api.getRateLimitStatus(),
234
+ };
235
+ },
236
+
237
+ async logout() {
238
+ clearProviderMeta('threads', account);
239
+ return {
240
+ provider: 'threads',
241
+ loggedOut: true,
242
+ sessionPath,
243
+ };
244
+ },
245
+ };
246
+ };
247
+
248
+ module.exports = createThreadsProvider;
@@ -0,0 +1,109 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { saveProviderMeta } = require('../../storage/sessionStore');
4
+ const { readThreadsCredentials, parseThreadsSessionError, buildLoginErrorMessage } = require('./utils');
5
+
6
+ const readSessionFile = (sessionPath) => {
7
+ if (!fs.existsSync(sessionPath)) return null;
8
+ try {
9
+ return JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));
10
+ } catch {
11
+ return null;
12
+ }
13
+ };
14
+
15
+ const writeSessionFile = (sessionPath, data) => {
16
+ fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
17
+ fs.writeFileSync(sessionPath, JSON.stringify(data, null, 2), 'utf-8');
18
+ };
19
+
20
+ const saveThreadsSession = (sessionPath, { token, userId, deviceId }) => {
21
+ const existing = readSessionFile(sessionPath) || {};
22
+ writeSessionFile(sessionPath, {
23
+ ...existing,
24
+ token,
25
+ userId,
26
+ deviceId,
27
+ updatedAt: new Date().toISOString(),
28
+ });
29
+ };
30
+
31
+ const loadThreadsSession = (sessionPath) => {
32
+ const raw = readSessionFile(sessionPath);
33
+ if (!raw?.token) return null;
34
+ return { token: raw.token, userId: raw.userId, deviceId: raw.deviceId };
35
+ };
36
+
37
+ const validateThreadsSession = (sessionPath) => {
38
+ const session = loadThreadsSession(sessionPath);
39
+ return Boolean(session?.token && session?.userId);
40
+ };
41
+
42
+ // ── Rate Limit persistence (per userId) ──
43
+
44
+ const loadRateLimits = (sessionPath, userId) => {
45
+ const raw = readSessionFile(sessionPath);
46
+ return raw?.rateLimits?.[userId] || null;
47
+ };
48
+
49
+ const saveRateLimits = (sessionPath, userId, counters) => {
50
+ const raw = readSessionFile(sessionPath) || {};
51
+ if (!raw.rateLimits) raw.rateLimits = {};
52
+ raw.rateLimits[userId] = {
53
+ ...counters,
54
+ savedAt: new Date().toISOString(),
55
+ };
56
+ writeSessionFile(sessionPath, raw);
57
+ };
58
+
59
+ const createThreadsWithProviderSession = (askForAuthentication, account) => async (fn) => {
60
+ const credentials = readThreadsCredentials();
61
+ const hasCredentials = Boolean(credentials.username && credentials.password);
62
+
63
+ try {
64
+ const result = await fn();
65
+ saveProviderMeta('threads', { loggedIn: true, lastValidatedAt: new Date().toISOString() }, account);
66
+ return result;
67
+ } catch (error) {
68
+ if (!parseThreadsSessionError(error) || !hasCredentials) {
69
+ throw error;
70
+ }
71
+
72
+ try {
73
+ const loginResult = await askForAuthentication({
74
+ username: credentials.username,
75
+ password: credentials.password,
76
+ });
77
+
78
+ saveProviderMeta('threads', {
79
+ loggedIn: loginResult.loggedIn,
80
+ userId: loginResult.userId,
81
+ sessionPath: loginResult.sessionPath,
82
+ lastRefreshedAt: new Date().toISOString(),
83
+ lastError: null,
84
+ }, account);
85
+
86
+ if (!loginResult.loggedIn) {
87
+ throw new Error(loginResult.message || 'Login status could not be confirmed after session refresh.');
88
+ }
89
+
90
+ return fn();
91
+ } catch (reloginError) {
92
+ saveProviderMeta('threads', {
93
+ loggedIn: false,
94
+ lastError: buildLoginErrorMessage(reloginError),
95
+ lastValidatedAt: new Date().toISOString(),
96
+ }, account);
97
+ throw reloginError;
98
+ }
99
+ }
100
+ };
101
+
102
+ module.exports = {
103
+ saveThreadsSession,
104
+ loadThreadsSession,
105
+ validateThreadsSession,
106
+ loadRateLimits,
107
+ saveRateLimits,
108
+ createThreadsWithProviderSession,
109
+ };
@@ -0,0 +1,33 @@
1
+ const readThreadsCredentials = () => {
2
+ const username = process.env.THREADS_USERNAME || process.env.INSTA_USERNAME || process.env.INSTAGRAM_USERNAME;
3
+ const password = process.env.THREADS_PASSWORD || process.env.INSTA_PASSWORD || process.env.INSTAGRAM_PASSWORD;
4
+ return {
5
+ username: typeof username === 'string' && username.trim() ? username.trim() : null,
6
+ password: typeof password === 'string' && password.trim() ? password.trim() : null,
7
+ };
8
+ };
9
+
10
+ const parseThreadsSessionError = (error) => {
11
+ const message = String(error?.message || '').toLowerCase();
12
+ return [
13
+ 'no session file found',
14
+ 'no valid token in session',
15
+ 'session expired',
16
+ 'login required',
17
+ 'login_required',
18
+ 'checkpoint_required',
19
+ '401',
20
+ '403',
21
+ ].some((token) => message.includes(token.toLowerCase()));
22
+ };
23
+
24
+ const buildLoginErrorMessage = (error) => String(error?.message || 'Session validation failed.');
25
+
26
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
27
+
28
+ module.exports = {
29
+ readThreadsCredentials,
30
+ parseThreadsSessionError,
31
+ buildLoginErrorMessage,
32
+ sleep,
33
+ };
package/src/runner.js CHANGED
@@ -132,7 +132,7 @@ const runCommand = async (command, opts = {}) => {
132
132
 
133
133
  case 'publish': {
134
134
  const content = readContent(opts);
135
- if (!content && providerName !== 'insta' && providerName !== 'x' && providerName !== 'reddit') {
135
+ if (!content && providerName !== 'insta' && providerName !== 'x' && providerName !== 'reddit' && providerName !== 'threads') {
136
136
  throw createError('MISSING_CONTENT', 'publish requires --content or --content-file', 'viruagent-cli publish --spec');
137
137
  }
138
138
  return withProvider(() =>
@@ -346,7 +346,8 @@ const runCommand = async (command, opts = {}) => {
346
346
  return withProvider(() => provider.cafeJoin({
347
347
  cafeUrl: opts.cafeUrl,
348
348
  nickname: opts.nickname || undefined,
349
- captchaApiKey: opts.captchaApiKey || undefined,
349
+ captchaValue: opts.captchaValue || undefined,
350
+ captchaKey: opts.captchaKey || undefined,
350
351
  answers: opts.answers ? parseList(opts.answers) : undefined,
351
352
  }))();
352
353
 
@@ -5,6 +5,7 @@ const createNaverProvider = require('../providers/naver');
5
5
  const createInstaProvider = require('../providers/insta');
6
6
  const createXProvider = require('../providers/x');
7
7
  const createRedditProvider = require('../providers/reddit');
8
+ const createThreadsProvider = require('../providers/threads');
8
9
 
9
10
  const providerFactory = {
10
11
  tistory: createTistoryProvider,
@@ -12,9 +13,10 @@ const providerFactory = {
12
13
  insta: createInstaProvider,
13
14
  x: createXProvider,
14
15
  reddit: createRedditProvider,
16
+ threads: createThreadsProvider,
15
17
  };
16
18
 
17
- const providers = ['tistory', 'naver', 'insta', 'x', 'reddit'];
19
+ const providers = ['tistory', 'naver', 'insta', 'x', 'reddit', 'threads'];
18
20
 
19
21
  const createProviderManager = () => {
20
22
  const cache = new Map();
@@ -40,7 +42,7 @@ const createProviderManager = () => {
40
42
  return cache.get(cacheKey);
41
43
  };
42
44
 
43
- const providerNames = { tistory: 'Tistory', naver: 'Naver Blog', insta: 'Instagram', x: 'X (Twitter)', reddit: 'Reddit' };
45
+ const providerNames = { tistory: 'Tistory', naver: 'Naver Blog', insta: 'Instagram', x: 'X (Twitter)', reddit: 'Reddit', threads: 'Threads' };
44
46
  const getAvailableProviders = () => providers.map((provider) => ({
45
47
  id: provider,
46
48
  name: providerNames[provider] || provider,