@jackwener/opencli 1.7.13 → 1.7.15
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/cli-manifest.json +326 -44
- package/clis/bilibili/subtitle.js +1 -1
- package/clis/dianping/cityResolver.js +185 -0
- package/clis/dianping/dianping.test.js +154 -0
- package/clis/dianping/search.js +6 -3
- package/clis/douyin/_shared/browser-fetch.js +14 -2
- package/clis/douyin/_shared/browser-fetch.test.js +13 -0
- package/clis/douyin/stats.js +1 -1
- package/clis/douyin/update.js +1 -1
- package/clis/jike/search.js +1 -1
- package/clis/reddit/search.js +1 -1
- package/clis/reddit/subreddit.js +1 -1
- package/clis/reddit/user-comments.js +1 -1
- package/clis/reddit/user-posts.js +1 -1
- package/clis/reddit/user.js +1 -1
- package/clis/twitter/article.js +2 -1
- package/clis/twitter/bookmark-folder.js +189 -0
- package/clis/twitter/bookmark-folder.test.js +334 -0
- package/clis/twitter/bookmark-folders.js +117 -0
- package/clis/twitter/bookmark-folders.test.js +150 -0
- package/clis/twitter/bookmark.js +15 -6
- package/clis/twitter/bookmark.test.js +74 -0
- package/clis/twitter/bookmarks.js +7 -5
- package/clis/twitter/delete.js +11 -35
- package/clis/twitter/delete.test.js +21 -9
- package/clis/twitter/download.js +5 -5
- package/clis/twitter/followers.js +9 -3
- package/clis/twitter/following.js +11 -5
- package/clis/twitter/hide-reply.js +24 -5
- package/clis/twitter/hide-reply.test.js +76 -0
- package/clis/twitter/like.js +21 -11
- package/clis/twitter/like.test.js +73 -0
- package/clis/twitter/likes.js +8 -6
- package/clis/twitter/list-add.js +4 -4
- package/clis/twitter/list-remove.js +4 -4
- package/clis/twitter/list-tweets.js +6 -4
- package/clis/twitter/lists.js +3 -3
- package/clis/twitter/notifications.js +2 -2
- package/clis/twitter/profile.js +4 -3
- package/clis/twitter/quote.js +167 -0
- package/clis/twitter/quote.test.js +194 -0
- package/clis/twitter/reply.js +24 -178
- package/clis/twitter/reply.test.js +29 -11
- package/clis/twitter/retweet.js +94 -0
- package/clis/twitter/retweet.test.js +73 -0
- package/clis/twitter/search.js +175 -23
- package/clis/twitter/search.test.js +266 -1
- package/clis/twitter/shared.js +81 -0
- package/clis/twitter/shared.test.js +134 -1
- package/clis/twitter/thread.js +6 -4
- package/clis/twitter/timeline.js +8 -6
- package/clis/twitter/tweets.js +5 -3
- package/clis/twitter/unbookmark.js +13 -6
- package/clis/twitter/unbookmark.test.js +73 -0
- package/clis/twitter/unlike.js +80 -0
- package/clis/twitter/unlike.test.js +75 -0
- package/clis/twitter/unretweet.js +94 -0
- package/clis/twitter/unretweet.test.js +73 -0
- package/clis/twitter/utils.js +286 -0
- package/clis/twitter/utils.test.js +169 -0
- package/dist/src/browser/ax-snapshot.d.ts +37 -0
- package/dist/src/browser/ax-snapshot.js +217 -0
- package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
- package/dist/src/browser/ax-snapshot.test.js +91 -0
- package/dist/src/browser/base-page.d.ts +51 -0
- package/dist/src/browser/base-page.js +545 -2
- package/dist/src/browser/base-page.test.js +520 -4
- package/dist/src/browser/bridge.js +47 -45
- package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
- package/dist/src/browser/cdp-click-fixture.test.js +87 -0
- package/dist/src/browser/cdp.js +5 -0
- package/dist/src/browser/cdp.test.js +1 -0
- package/dist/src/browser/daemon-client.d.ts +3 -1
- package/dist/src/browser/find.d.ts +9 -1
- package/dist/src/browser/find.js +219 -0
- package/dist/src/browser/find.test.js +61 -1
- package/dist/src/browser/page.d.ts +2 -1
- package/dist/src/browser/page.js +13 -0
- package/dist/src/browser/page.test.js +28 -0
- package/dist/src/browser/target-errors.d.ts +3 -1
- package/dist/src/browser/target-errors.js +2 -0
- package/dist/src/browser/target-resolver.d.ts +14 -0
- package/dist/src/browser/target-resolver.js +28 -0
- package/dist/src/browser/visual-refs.d.ts +11 -0
- package/dist/src/browser/visual-refs.js +108 -0
- package/dist/src/browser.test.js +18 -0
- package/dist/src/build-manifest.d.ts +23 -0
- package/dist/src/build-manifest.js +34 -0
- package/dist/src/build-manifest.test.js +108 -1
- package/dist/src/cli.js +560 -58
- package/dist/src/cli.test.js +689 -1
- package/dist/src/commanderAdapter.js +23 -4
- package/dist/src/help.d.ts +36 -0
- package/dist/src/help.js +301 -5
- package/dist/src/types.d.ts +82 -0
- package/package.json +1 -1
- package/scripts/typed-error-lint-baseline.json +18 -18
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
6
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
7
|
+
import { __test__ } from './quote.js';
|
|
8
|
+
import './quote.js';
|
|
9
|
+
import { createPageMock } from '../test-utils.js';
|
|
10
|
+
|
|
11
|
+
describe('twitter quote helpers', () => {
|
|
12
|
+
it('builds the quote composer URL with the source tweet attached as ?url=...', () => {
|
|
13
|
+
const composeUrl = __test__.buildQuoteComposerUrl('https://x.com/alice/status/2040254679301718161?s=20');
|
|
14
|
+
// The full source URL is round-tripped via encodeURIComponent — decoding it
|
|
15
|
+
// back must yield the original URL. This guards against accidental drops of
|
|
16
|
+
// query parameters or fragment characters in future refactors.
|
|
17
|
+
const parsed = new URL(composeUrl);
|
|
18
|
+
expect(parsed.origin + parsed.pathname).toBe('https://x.com/compose/post');
|
|
19
|
+
expect(parsed.searchParams.get('url')).toBe('https://x.com/alice/status/2040254679301718161?s=20');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('rejects malformed URLs before any browser interaction', () => {
|
|
23
|
+
expect(() => __test__.buildQuoteComposerUrl('https://x.com/alice/home')).toThrow(/Could not extract tweet ID/);
|
|
24
|
+
expect(() => __test__.buildQuoteComposerUrl('not a url')).toThrow(/Invalid tweet URL/);
|
|
25
|
+
expect(() => __test__.buildQuoteComposerUrl('https://evil.com/?next=https://x.com/alice/status/2040254679301718161')).toThrow(ArgumentError);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('twitter quote command', () => {
|
|
30
|
+
it('navigates to the quote composer and reports success when the script confirms', async () => {
|
|
31
|
+
const cmd = getRegistry().get('twitter/quote');
|
|
32
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
33
|
+
const page = createPageMock([
|
|
34
|
+
{ ok: true, message: 'Quote tweet posted successfully.' },
|
|
35
|
+
]);
|
|
36
|
+
const result = await cmd.func(page, {
|
|
37
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
38
|
+
text: 'great take',
|
|
39
|
+
});
|
|
40
|
+
expect(page.goto).toHaveBeenCalledWith(
|
|
41
|
+
'https://x.com/compose/post?url=https%3A%2F%2Fx.com%2Falice%2Fstatus%2F2040254679301718161',
|
|
42
|
+
{ waitUntil: 'load', settleMs: 2500 },
|
|
43
|
+
);
|
|
44
|
+
expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
|
|
45
|
+
expect(page.wait).toHaveBeenNthCalledWith(2, 3);
|
|
46
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
47
|
+
// Quote-attachment guard: the script must verify the quoted card rendered
|
|
48
|
+
// before submitting; otherwise we'd silently post a plain tweet without
|
|
49
|
+
// the quote attachment. Detection now uses the shared helper's
|
|
50
|
+
// __twHasLinkToTarget(document) — JSDOM coverage in shared.test.js
|
|
51
|
+
// proves it does an exact (not substring) match on the status id.
|
|
52
|
+
expect(script).toContain('Quote target did not render');
|
|
53
|
+
expect(script).toContain('document.execCommand');
|
|
54
|
+
expect(script).toContain('tweetButton');
|
|
55
|
+
expect(script).toContain('__twHasLinkToTarget(document)');
|
|
56
|
+
expect(script).toContain('__twGetStatusIdFromHref');
|
|
57
|
+
expect(script).toContain('Quote tweet submission did not complete before timeout');
|
|
58
|
+
expect(script).toContain('[role="alert"], [data-testid="toast"]');
|
|
59
|
+
expect(result).toEqual([
|
|
60
|
+
{
|
|
61
|
+
status: 'success',
|
|
62
|
+
message: 'Quote tweet posted successfully.',
|
|
63
|
+
text: 'great take',
|
|
64
|
+
},
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('uploads a local image through the quote composer when --image is provided', async () => {
|
|
69
|
+
const cmd = getRegistry().get('twitter/quote');
|
|
70
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
71
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-twitter-quote-'));
|
|
72
|
+
const imagePath = path.join(tempDir, 'banner.png');
|
|
73
|
+
fs.writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
|
74
|
+
const setFileInput = vi.fn().mockResolvedValue(undefined);
|
|
75
|
+
const page = createPageMock([
|
|
76
|
+
{ ok: true, previewCount: 1 },
|
|
77
|
+
{ ok: true, message: 'Quote tweet posted successfully.' },
|
|
78
|
+
], {
|
|
79
|
+
setFileInput,
|
|
80
|
+
});
|
|
81
|
+
const result = await cmd.func(page, {
|
|
82
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
83
|
+
text: 'check this',
|
|
84
|
+
image: imagePath,
|
|
85
|
+
});
|
|
86
|
+
expect(page.goto).toHaveBeenCalledWith(
|
|
87
|
+
'https://x.com/compose/post?url=https%3A%2F%2Fx.com%2Falice%2Fstatus%2F2040254679301718161',
|
|
88
|
+
{ waitUntil: 'load', settleMs: 2500 },
|
|
89
|
+
);
|
|
90
|
+
expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
|
|
91
|
+
expect(page.wait).toHaveBeenNthCalledWith(2, { selector: 'input[type="file"][data-testid="fileInput"]', timeout: 20 });
|
|
92
|
+
expect(setFileInput).toHaveBeenCalledWith([imagePath], 'input[type="file"][data-testid="fileInput"]');
|
|
93
|
+
expect(result).toEqual([
|
|
94
|
+
{
|
|
95
|
+
status: 'success',
|
|
96
|
+
message: 'Quote tweet posted successfully.',
|
|
97
|
+
text: 'check this',
|
|
98
|
+
image: imagePath,
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('downloads a remote image before uploading when --image-url is provided', async () => {
|
|
105
|
+
const cmd = getRegistry().get('twitter/quote');
|
|
106
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
107
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
108
|
+
ok: true,
|
|
109
|
+
headers: {
|
|
110
|
+
get: vi.fn().mockReturnValue('image/png'),
|
|
111
|
+
},
|
|
112
|
+
arrayBuffer: vi.fn().mockResolvedValue(Uint8Array.from([0x89, 0x50, 0x4e, 0x47]).buffer),
|
|
113
|
+
});
|
|
114
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
115
|
+
const setFileInput = vi.fn().mockResolvedValue(undefined);
|
|
116
|
+
const page = createPageMock([
|
|
117
|
+
{ ok: true, previewCount: 1 },
|
|
118
|
+
{ ok: true, message: 'Quote tweet posted successfully.' },
|
|
119
|
+
], {
|
|
120
|
+
setFileInput,
|
|
121
|
+
});
|
|
122
|
+
const result = await cmd.func(page, {
|
|
123
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
124
|
+
text: 'remote attach',
|
|
125
|
+
'image-url': 'https://example.com/banner',
|
|
126
|
+
});
|
|
127
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/banner');
|
|
128
|
+
expect(setFileInput).toHaveBeenCalledTimes(1);
|
|
129
|
+
const uploadedPath = setFileInput.mock.calls[0][0][0];
|
|
130
|
+
expect(uploadedPath).toMatch(/opencli-twitter-.*\/image\.png$/);
|
|
131
|
+
// Per-call tmp dir is removed in the adapter's finally block.
|
|
132
|
+
expect(fs.existsSync(uploadedPath)).toBe(false);
|
|
133
|
+
expect(result).toEqual([
|
|
134
|
+
{
|
|
135
|
+
status: 'success',
|
|
136
|
+
message: 'Quote tweet posted successfully.',
|
|
137
|
+
text: 'remote attach',
|
|
138
|
+
'image-url': 'https://example.com/banner',
|
|
139
|
+
},
|
|
140
|
+
]);
|
|
141
|
+
vi.unstubAllGlobals();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('rejects using --image and --image-url together', async () => {
|
|
145
|
+
const cmd = getRegistry().get('twitter/quote');
|
|
146
|
+
const page = createPageMock([]);
|
|
147
|
+
await expect(cmd.func(page, {
|
|
148
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
149
|
+
text: 'nope',
|
|
150
|
+
image: '/tmp/a.png',
|
|
151
|
+
'image-url': 'https://example.com/a.png',
|
|
152
|
+
})).rejects.toThrow(CommandExecutionError);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('returns a failed row when the quote target fails to render', async () => {
|
|
156
|
+
const cmd = getRegistry().get('twitter/quote');
|
|
157
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
158
|
+
const page = createPageMock([
|
|
159
|
+
{ ok: false, message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.' },
|
|
160
|
+
]);
|
|
161
|
+
const result = await cmd.func(page, {
|
|
162
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
163
|
+
text: 'orphaned quote',
|
|
164
|
+
});
|
|
165
|
+
expect(result).toEqual([
|
|
166
|
+
{
|
|
167
|
+
status: 'failed',
|
|
168
|
+
message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.',
|
|
169
|
+
text: 'orphaned quote',
|
|
170
|
+
},
|
|
171
|
+
]);
|
|
172
|
+
// Only the textarea wait should run when ok is false (no extra 3s post-submit wait).
|
|
173
|
+
expect(page.wait).toHaveBeenCalledTimes(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('throws CommandExecutionError when no page is provided', async () => {
|
|
177
|
+
const cmd = getRegistry().get('twitter/quote');
|
|
178
|
+
await expect(cmd.func(undefined, {
|
|
179
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
180
|
+
text: 'hi',
|
|
181
|
+
})).rejects.toThrow(CommandExecutionError);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('rejects invalid tweet URLs before navigation', async () => {
|
|
185
|
+
const cmd = getRegistry().get('twitter/quote');
|
|
186
|
+
const page = createPageMock([]);
|
|
187
|
+
await expect(cmd.func(page, {
|
|
188
|
+
url: 'https://x.com.evil.com/alice/status/2040254679301718161',
|
|
189
|
+
text: 'hi',
|
|
190
|
+
})).rejects.toThrow(ArgumentError);
|
|
191
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
192
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
});
|
package/clis/twitter/reply.js
CHANGED
|
@@ -1,174 +1,24 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
-
import * as os from 'node:os';
|
|
4
3
|
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
5
4
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
]);
|
|
14
|
-
const MAX_IMAGE_SIZE_BYTES = 20 * 1024 * 1024; // 20 MB (Twitter allows 5MB images, 15MB GIFs)
|
|
15
|
-
const CONTENT_TYPE_TO_EXTENSION = {
|
|
16
|
-
'image/jpeg': '.jpg',
|
|
17
|
-
'image/jpg': '.jpg',
|
|
18
|
-
'image/png': '.png',
|
|
19
|
-
'image/gif': '.gif',
|
|
20
|
-
'image/webp': '.webp',
|
|
21
|
-
};
|
|
22
|
-
function resolveImagePath(imagePath) {
|
|
23
|
-
const absPath = path.resolve(imagePath);
|
|
24
|
-
if (!fs.existsSync(absPath)) {
|
|
25
|
-
throw new Error(`Image file not found: ${absPath}`);
|
|
26
|
-
}
|
|
27
|
-
const ext = path.extname(absPath).toLowerCase();
|
|
28
|
-
if (!SUPPORTED_IMAGE_EXTENSIONS.has(ext)) {
|
|
29
|
-
throw new Error(`Unsupported image format "${ext}". Supported: jpg, jpeg, png, gif, webp`);
|
|
30
|
-
}
|
|
31
|
-
const stat = fs.statSync(absPath);
|
|
32
|
-
if (stat.size > MAX_IMAGE_SIZE_BYTES) {
|
|
33
|
-
throw new Error(`Image too large: ${(stat.size / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
|
|
34
|
-
}
|
|
35
|
-
return absPath;
|
|
36
|
-
}
|
|
37
|
-
function extractTweetId(url) {
|
|
38
|
-
let pathname = '';
|
|
39
|
-
try {
|
|
40
|
-
pathname = new URL(url).pathname;
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
throw new Error(`Invalid tweet URL: ${url}`);
|
|
44
|
-
}
|
|
45
|
-
const match = pathname.match(/\/status\/(\d+)/);
|
|
46
|
-
if (!match?.[1]) {
|
|
47
|
-
throw new Error(`Could not extract tweet ID from URL: ${url}`);
|
|
48
|
-
}
|
|
49
|
-
return match[1];
|
|
50
|
-
}
|
|
51
|
-
function buildReplyComposerUrl(url) {
|
|
52
|
-
return `https://x.com/compose/post?in_reply_to=${extractTweetId(url)}`;
|
|
53
|
-
}
|
|
54
|
-
function resolveImageExtension(url, contentType) {
|
|
55
|
-
const normalizedContentType = (contentType || '').split(';')[0].trim().toLowerCase();
|
|
56
|
-
if (normalizedContentType && CONTENT_TYPE_TO_EXTENSION[normalizedContentType]) {
|
|
57
|
-
return CONTENT_TYPE_TO_EXTENSION[normalizedContentType];
|
|
58
|
-
}
|
|
59
|
-
try {
|
|
60
|
-
const pathname = new URL(url).pathname;
|
|
61
|
-
const ext = path.extname(pathname).toLowerCase();
|
|
62
|
-
if (SUPPORTED_IMAGE_EXTENSIONS.has(ext))
|
|
63
|
-
return ext;
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
// Fall through to the final error below.
|
|
67
|
-
}
|
|
68
|
-
throw new Error(`Unsupported remote image format "${normalizedContentType || 'unknown'}". ` +
|
|
69
|
-
'Supported: jpg, jpeg, png, gif, webp');
|
|
70
|
-
}
|
|
71
|
-
async function downloadRemoteImage(imageUrl) {
|
|
72
|
-
let parsed;
|
|
73
|
-
try {
|
|
74
|
-
parsed = new URL(imageUrl);
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
throw new Error(`Invalid image URL: ${imageUrl}`);
|
|
78
|
-
}
|
|
79
|
-
if (!/^https?:$/.test(parsed.protocol)) {
|
|
80
|
-
throw new Error(`Unsupported image URL protocol: ${parsed.protocol}`);
|
|
81
|
-
}
|
|
82
|
-
const response = await fetch(imageUrl);
|
|
83
|
-
if (!response.ok) {
|
|
84
|
-
throw new Error(`Image download failed: HTTP ${response.status}`);
|
|
85
|
-
}
|
|
86
|
-
const contentLength = Number(response.headers.get('content-length') || '0');
|
|
87
|
-
if (contentLength > MAX_IMAGE_SIZE_BYTES) {
|
|
88
|
-
throw new Error(`Image too large: ${(contentLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
|
|
89
|
-
}
|
|
90
|
-
const ext = resolveImageExtension(imageUrl, response.headers.get('content-type'));
|
|
91
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-twitter-reply-'));
|
|
92
|
-
const tmpPath = path.join(tmpDir, `image${ext}`);
|
|
93
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
94
|
-
if (buffer.byteLength > MAX_IMAGE_SIZE_BYTES) {
|
|
95
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
96
|
-
throw new Error(`Image too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
|
|
97
|
-
}
|
|
98
|
-
fs.writeFileSync(tmpPath, buffer);
|
|
99
|
-
return tmpPath;
|
|
100
|
-
}
|
|
101
|
-
async function attachReplyImage(page, absImagePath) {
|
|
102
|
-
let uploaded = false;
|
|
103
|
-
if (page.setFileInput) {
|
|
104
|
-
try {
|
|
105
|
-
await page.setFileInput([absImagePath], REPLY_FILE_INPUT_SELECTOR);
|
|
106
|
-
uploaded = true;
|
|
107
|
-
}
|
|
108
|
-
catch (err) {
|
|
109
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
110
|
-
if (!msg.includes('Unknown action') && !msg.includes('not supported')) {
|
|
111
|
-
throw new Error(`Image upload failed: ${msg}`);
|
|
112
|
-
}
|
|
113
|
-
// setFileInput not supported by extension — fall through to base64 fallback
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
if (!uploaded) {
|
|
117
|
-
const ext = path.extname(absImagePath).toLowerCase();
|
|
118
|
-
const mimeType = ext === '.png'
|
|
119
|
-
? 'image/png'
|
|
120
|
-
: ext === '.gif'
|
|
121
|
-
? 'image/gif'
|
|
122
|
-
: ext === '.webp'
|
|
123
|
-
? 'image/webp'
|
|
124
|
-
: 'image/jpeg';
|
|
125
|
-
const base64 = fs.readFileSync(absImagePath).toString('base64');
|
|
126
|
-
if (base64.length > 500_000) {
|
|
127
|
-
console.warn(`[warn] Image base64 payload is ${(base64.length / 1024 / 1024).toFixed(1)}MB. ` +
|
|
128
|
-
'This may fail with the browser bridge. Update the extension to v1.6+ for CDP-based upload, ' +
|
|
129
|
-
'or compress the image before attaching.');
|
|
130
|
-
}
|
|
131
|
-
const upload = await page.evaluate(`
|
|
132
|
-
(() => {
|
|
133
|
-
const input = document.querySelector(${JSON.stringify(REPLY_FILE_INPUT_SELECTOR)});
|
|
134
|
-
if (!input) return { ok: false, error: 'No file input found on page' };
|
|
135
|
-
|
|
136
|
-
const binary = atob(${JSON.stringify(base64)});
|
|
137
|
-
const bytes = new Uint8Array(binary.length);
|
|
138
|
-
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
139
|
-
|
|
140
|
-
const dt = new DataTransfer();
|
|
141
|
-
const blob = new Blob([bytes], { type: ${JSON.stringify(mimeType)} });
|
|
142
|
-
dt.items.add(new File([blob], ${JSON.stringify(path.basename(absImagePath))}, { type: ${JSON.stringify(mimeType)} }));
|
|
5
|
+
import { parseTweetUrl } from './shared.js';
|
|
6
|
+
import {
|
|
7
|
+
COMPOSER_FILE_INPUT_SELECTOR,
|
|
8
|
+
attachComposerImage,
|
|
9
|
+
downloadRemoteImage,
|
|
10
|
+
resolveImagePath,
|
|
11
|
+
} from './utils.js';
|
|
143
12
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
throw new Error(`Image upload failed: ${upload?.error ?? 'unknown error'}`);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
await page.wait(2);
|
|
155
|
-
const uploadState = await page.evaluate(`
|
|
156
|
-
(() => {
|
|
157
|
-
const previewCount = document.querySelectorAll(
|
|
158
|
-
'[data-testid="attachments"] img, [data-testid="attachments"] video, [data-testid="tweetPhoto"]'
|
|
159
|
-
).length;
|
|
160
|
-
const hasMedia = previewCount > 0
|
|
161
|
-
|| !!document.querySelector('[data-testid="attachments"]')
|
|
162
|
-
|| !!Array.from(document.querySelectorAll('button,[role="button"]')).find((el) =>
|
|
163
|
-
/remove media|remove image|remove/i.test((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || ''))
|
|
164
|
-
);
|
|
165
|
-
return { ok: hasMedia, previewCount };
|
|
166
|
-
})()
|
|
167
|
-
`);
|
|
168
|
-
if (!uploadState?.ok) {
|
|
169
|
-
throw new Error('Image upload failed: preview did not appear.');
|
|
170
|
-
}
|
|
13
|
+
function buildReplyComposerUrl(rawUrl) {
|
|
14
|
+
// Replaces the legacy local extractTweetId which used `/\/status\/(\d+)/`
|
|
15
|
+
// (silent: matched `/status/1234567` on substring `/status/123` and
|
|
16
|
+
// accepted any host). parseTweetUrl bubbles ArgumentError on
|
|
17
|
+
// malformed/off-domain inputs.
|
|
18
|
+
const target = parseTweetUrl(rawUrl);
|
|
19
|
+
return `https://x.com/compose/post?in_reply_to=${target.id}`;
|
|
171
20
|
}
|
|
21
|
+
|
|
172
22
|
async function submitReply(page, text) {
|
|
173
23
|
return page.evaluate(`(async () => {
|
|
174
24
|
try {
|
|
@@ -210,6 +60,7 @@ async function submitReply(page, text) {
|
|
|
210
60
|
}
|
|
211
61
|
})()`);
|
|
212
62
|
}
|
|
63
|
+
|
|
213
64
|
cli({
|
|
214
65
|
site: 'twitter',
|
|
215
66
|
name: 'reply',
|
|
@@ -229,24 +80,24 @@ cli({
|
|
|
229
80
|
if (!page)
|
|
230
81
|
throw new CommandExecutionError('Browser session required for twitter reply');
|
|
231
82
|
if (kwargs.image && kwargs['image-url']) {
|
|
232
|
-
throw new
|
|
83
|
+
throw new CommandExecutionError('Use either --image or --image-url, not both.');
|
|
233
84
|
}
|
|
234
85
|
let localImagePath;
|
|
235
86
|
let cleanupDir;
|
|
236
87
|
try {
|
|
237
88
|
if (kwargs.image) {
|
|
238
89
|
localImagePath = resolveImagePath(kwargs.image);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
localImagePath =
|
|
242
|
-
cleanupDir =
|
|
90
|
+
} else if (kwargs['image-url']) {
|
|
91
|
+
const downloaded = await downloadRemoteImage(kwargs['image-url']);
|
|
92
|
+
localImagePath = downloaded.absPath;
|
|
93
|
+
cleanupDir = downloaded.cleanupDir;
|
|
243
94
|
}
|
|
244
95
|
// Dedicated composer is more reliable than the inline tweet page reply box.
|
|
245
96
|
await page.goto(buildReplyComposerUrl(kwargs.url), { waitUntil: 'load', settleMs: 2500 });
|
|
246
97
|
await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
|
|
247
98
|
if (localImagePath) {
|
|
248
|
-
await page.wait({ selector:
|
|
249
|
-
await
|
|
99
|
+
await page.wait({ selector: COMPOSER_FILE_INPUT_SELECTOR, timeout: 20 });
|
|
100
|
+
await attachComposerImage(page, localImagePath);
|
|
250
101
|
}
|
|
251
102
|
const result = await submitReply(page, kwargs.text);
|
|
252
103
|
if (result.ok) {
|
|
@@ -259,8 +110,7 @@ cli({
|
|
|
259
110
|
...(kwargs.image ? { image: kwargs.image } : {}),
|
|
260
111
|
...(kwargs['image-url'] ? { 'image-url': kwargs['image-url'] } : {}),
|
|
261
112
|
}];
|
|
262
|
-
}
|
|
263
|
-
finally {
|
|
113
|
+
} finally {
|
|
264
114
|
if (cleanupDir) {
|
|
265
115
|
fs.rmSync(cleanupDir, { recursive: true, force: true });
|
|
266
116
|
}
|
|
@@ -269,8 +119,4 @@ cli({
|
|
|
269
119
|
});
|
|
270
120
|
export const __test__ = {
|
|
271
121
|
buildReplyComposerUrl,
|
|
272
|
-
downloadRemoteImage,
|
|
273
|
-
extractTweetId,
|
|
274
|
-
resolveImageExtension,
|
|
275
|
-
resolveImagePath,
|
|
276
122
|
};
|
|
@@ -2,9 +2,12 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as os from 'node:os';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
5
6
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
6
7
|
import { __test__ } from './reply.js';
|
|
8
|
+
import { __test__ as utilsTest } from './utils.js';
|
|
7
9
|
import { createPageMock } from '../test-utils.js';
|
|
10
|
+
|
|
8
11
|
describe('twitter reply command', () => {
|
|
9
12
|
it('uses the dedicated reply composer for text-only replies too', async () => {
|
|
10
13
|
const cmd = getRegistry().get('twitter/reply');
|
|
@@ -83,7 +86,11 @@ describe('twitter reply command', () => {
|
|
|
83
86
|
expect(fetchMock).toHaveBeenCalledWith('https://example.com/qr');
|
|
84
87
|
expect(setFileInput).toHaveBeenCalledTimes(1);
|
|
85
88
|
const uploadedPath = setFileInput.mock.calls[0][0][0];
|
|
86
|
-
|
|
89
|
+
// Tmp dir is created by utils.downloadRemoteImage with the
|
|
90
|
+
// 'opencli-twitter-' prefix; final extension comes from Content-Type.
|
|
91
|
+
expect(uploadedPath).toMatch(/opencli-twitter-.*\/image\.png$/);
|
|
92
|
+
// Per-call tmp dir is removed in the adapter's finally block, so the
|
|
93
|
+
// downloaded file no longer exists once the command returns.
|
|
87
94
|
expect(fs.existsSync(uploadedPath)).toBe(false);
|
|
88
95
|
expect(result).toEqual([
|
|
89
96
|
{
|
|
@@ -95,10 +102,6 @@ describe('twitter reply command', () => {
|
|
|
95
102
|
]);
|
|
96
103
|
vi.unstubAllGlobals();
|
|
97
104
|
});
|
|
98
|
-
it('rejects invalid image paths early', async () => {
|
|
99
|
-
await expect(() => __test__.resolveImagePath('/tmp/does-not-exist.png'))
|
|
100
|
-
.toThrow('Image file not found');
|
|
101
|
-
});
|
|
102
105
|
it('rejects using --image and --image-url together', async () => {
|
|
103
106
|
const cmd = getRegistry().get('twitter/reply');
|
|
104
107
|
expect(cmd?.func).toBeTypeOf('function');
|
|
@@ -108,16 +111,31 @@ describe('twitter reply command', () => {
|
|
|
108
111
|
text: 'nope',
|
|
109
112
|
image: '/tmp/a.png',
|
|
110
113
|
'image-url': 'https://example.com/a.png',
|
|
111
|
-
})).rejects.toThrow(
|
|
114
|
+
})).rejects.toThrow(CommandExecutionError);
|
|
115
|
+
});
|
|
116
|
+
it('rejects malformed tweet URLs before any browser interaction', () => {
|
|
117
|
+
// buildReplyComposerUrl runs parseTweetUrl synchronously; substring matches
|
|
118
|
+
// and off-domain hosts now throw ArgumentError instead of silently
|
|
119
|
+
// producing a wrong-host /compose/post URL.
|
|
120
|
+
expect(() => __test__.buildReplyComposerUrl('https://x.com/alice/home')).toThrow(ArgumentError);
|
|
121
|
+
expect(() => __test__.buildReplyComposerUrl('https://x.com.evil.com/alice/status/2040254679301718161')).toThrow(ArgumentError);
|
|
122
|
+
expect(() => __test__.buildReplyComposerUrl('not a url')).toThrow(ArgumentError);
|
|
112
123
|
});
|
|
113
|
-
it('
|
|
114
|
-
expect(__test__.
|
|
115
|
-
|
|
124
|
+
it('builds the reply composer URL for both /<user>/status/<id> and /i/status/<id> shapes', () => {
|
|
125
|
+
expect(__test__.buildReplyComposerUrl('https://x.com/_kop6/status/2040254679301718161?s=20'))
|
|
126
|
+
.toBe('https://x.com/compose/post?in_reply_to=2040254679301718161');
|
|
116
127
|
expect(__test__.buildReplyComposerUrl('https://x.com/i/status/2040318731105313143'))
|
|
117
128
|
.toBe('https://x.com/compose/post?in_reply_to=2040318731105313143');
|
|
118
129
|
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('twitter image helpers (utils.js)', () => {
|
|
133
|
+
it('rejects invalid image paths early', () => {
|
|
134
|
+
expect(() => utilsTest.resolveImagePath('/tmp/does-not-exist.png'))
|
|
135
|
+
.toThrow(ArgumentError);
|
|
136
|
+
});
|
|
119
137
|
it('prefers content-type when resolving remote image extensions', () => {
|
|
120
|
-
expect(
|
|
121
|
-
expect(
|
|
138
|
+
expect(utilsTest.resolveImageExtension('https://example.com/no-ext', 'image/webp')).toBe('.webp');
|
|
139
|
+
expect(utilsTest.resolveImageExtension('https://example.com/a.jpeg?x=1', null)).toBe('.jpeg');
|
|
122
140
|
});
|
|
123
141
|
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'twitter',
|
|
7
|
+
name: 'retweet',
|
|
8
|
+
access: 'write',
|
|
9
|
+
description: 'Retweet a specific tweet',
|
|
10
|
+
domain: 'x.com',
|
|
11
|
+
strategy: Strategy.UI,
|
|
12
|
+
browser: true,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to retweet' },
|
|
15
|
+
],
|
|
16
|
+
columns: ['status', 'message'],
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
if (!page)
|
|
19
|
+
throw new CommandExecutionError('Browser session required for twitter retweet');
|
|
20
|
+
const target = parseTweetUrl(kwargs.url);
|
|
21
|
+
await page.goto(target.url);
|
|
22
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
23
|
+
const result = await page.evaluate(`(async () => {
|
|
24
|
+
try {
|
|
25
|
+
${buildTwitterArticleScopeSource(target.id)}
|
|
26
|
+
// Poll for the tweet to render. State probes scoped to the article
|
|
27
|
+
// matching the requested status id — bare querySelector on a
|
|
28
|
+
// conversation page would silently grab the first article (e.g.
|
|
29
|
+
// the parent tweet) and retweet the wrong one.
|
|
30
|
+
let attempts = 0;
|
|
31
|
+
let retweetBtn = null;
|
|
32
|
+
let unretweetBtn = null;
|
|
33
|
+
let targetArticle = null;
|
|
34
|
+
|
|
35
|
+
while (attempts < 20) {
|
|
36
|
+
targetArticle = findTargetArticle();
|
|
37
|
+
unretweetBtn = targetArticle?.querySelector('[data-testid="unretweet"]') || null;
|
|
38
|
+
retweetBtn = targetArticle?.querySelector('[data-testid="retweet"]') || null;
|
|
39
|
+
|
|
40
|
+
if (unretweetBtn || retweetBtn) break;
|
|
41
|
+
|
|
42
|
+
await new Promise(r => setTimeout(r, 500));
|
|
43
|
+
attempts++;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Already retweeted: idempotent success
|
|
47
|
+
if (unretweetBtn) {
|
|
48
|
+
return { ok: true, message: 'Tweet is already retweeted.' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!retweetBtn) {
|
|
52
|
+
return { ok: false, message: 'Could not find the Retweet button on this tweet after waiting 10 seconds. Are you logged in?' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Step 1: click Retweet button → opens menu
|
|
56
|
+
retweetBtn.click();
|
|
57
|
+
|
|
58
|
+
// Step 2: wait for and click the confirm menu item. The confirm
|
|
59
|
+
// popover renders at the document root, not inside the article,
|
|
60
|
+
// so this lookup is intentionally document-scoped.
|
|
61
|
+
let confirmBtn = null;
|
|
62
|
+
for (let i = 0; i < 20; i++) {
|
|
63
|
+
await new Promise(r => setTimeout(r, 250));
|
|
64
|
+
confirmBtn = document.querySelector('[data-testid="retweetConfirm"]');
|
|
65
|
+
if (confirmBtn) break;
|
|
66
|
+
}
|
|
67
|
+
if (!confirmBtn) {
|
|
68
|
+
return { ok: false, message: 'Retweet menu opened but the confirm option did not appear.' };
|
|
69
|
+
}
|
|
70
|
+
confirmBtn.click();
|
|
71
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
72
|
+
|
|
73
|
+
// Verify success by checking if the 'unretweet' button appeared
|
|
74
|
+
const verifyArticle = findTargetArticle() || targetArticle;
|
|
75
|
+
const verifyBtn = verifyArticle?.querySelector('[data-testid="unretweet"]');
|
|
76
|
+
if (verifyBtn) {
|
|
77
|
+
return { ok: true, message: 'Tweet successfully retweeted.' };
|
|
78
|
+
} else {
|
|
79
|
+
return { ok: false, message: 'Retweet action was initiated but UI did not update as expected.' };
|
|
80
|
+
}
|
|
81
|
+
} catch (e) {
|
|
82
|
+
return { ok: false, message: e.toString() };
|
|
83
|
+
}
|
|
84
|
+
})()`);
|
|
85
|
+
if (result.ok) {
|
|
86
|
+
// Wait for the retweet network request to be processed
|
|
87
|
+
await page.wait(2);
|
|
88
|
+
}
|
|
89
|
+
return [{
|
|
90
|
+
status: result.ok ? 'success' : 'failed',
|
|
91
|
+
message: result.message
|
|
92
|
+
}];
|
|
93
|
+
}
|
|
94
|
+
});
|