Skip to content

Commit 8ef7e90

Browse files
authored
feat(twitter): default tweets to logged-in user + fix sibling envelope-unwrap silent bug (#1531)
* feat(twitter): default tweets to logged-in user + fix sibling envelope-unwrap silent bug Primary: make `opencli twitter tweets` default to the logged-in user when no username is given, so agents can pull their own posts without needing to know their own handle. Mirrors the existing self-detection pattern in twitter/profile and twitter/likes (AppTabBar_Profile_Link probe on /home, then UserByScreenName lookup). Description + help string now mention the default so agents discover it. Consistency pass — profile/likes/following/followers: the self-detection in these four siblings was silently broken because page.evaluate() primitive returns come back through the CDP bridge wrapped as `{session: 'site:twitter', data: '/<handle>'}` (same envelope root cause as #1525). They called `.replace()` directly on the envelope object → TypeError surfaced as AUTH_REQUIRED 'Could not detect logged-in user', even for logged-in users. Wrap each probe with unwrapBrowserResult so the bare href string survives. Also: - Add an explicit page.goto('/home') + page.wait(primaryColumn) before the probe in likes/following so the AppTabBar sidebar is guaranteed rendered (framework pre-nav lands on bare x.com without the sidebar mounted). - following.js: switch its probe from the function-literal form `() => {...}` to a template-string. Confirmed live: function-literal silently drops primitive returns entirely — bridge returns `{session}` with no `data` field at all, while template-string returns `{session, data}` as expected. Out of scope (pre-existing, flagged as follow-up): likes/following have additional downstream evaluate paths (userId/GraphQL fetch) that still drop or envelope their results; they return [] or 'Could not find user' even after this PR. Same daemon-side bug class as #1525. Live-verified: opencli twitter tweets --limit 2 → own tweets (@jakevin7) opencli twitter profile → own profile Tests 227/227, audits typed-error-lint 189 + silent-column-drop 103 unchanged, manifest stable at 816 entries. * fix(twitter): validate self-detected handles * fix(twitter): unwrap downstream self evaluate results
1 parent 7c5bafd commit 8ef7e90

13 files changed

Lines changed: 582 additions & 58 deletions

cli-manifest.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23707,7 +23707,7 @@
2370723707
{
2370823708
"site": "twitter",
2370923709
"name": "tweets",
23710-
"description": "Fetch a Twitter user's most recent tweets (chronological, excludes pinned)",
23710+
"description": "Fetch a Twitter user's most recent tweets (chronological, excludes pinned; defaults to the logged-in user when no username is given)",
2371123711
"access": "read",
2371223712
"domain": "x.com",
2371323713
"strategy": "cookie",
@@ -23716,9 +23716,9 @@
2371623716
{
2371723717
"name": "username",
2371823718
"type": "string",
23719-
"required": true,
23719+
"required": false,
2372023720
"positional": true,
23721-
"help": "Twitter screen name (with or without @)"
23721+
"help": "Twitter screen name (with or without @). Defaults to the logged-in user when omitted."
2372223722
},
2372323723
{
2372423724
"name": "limit",

clis/twitter/followers.js

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ArgumentError, AuthRequiredError, selectorError, EmptyResultError } from '@jackwener/opencli/errors';
22
import { cli, Strategy } from '@jackwener/opencli/registry';
3+
import { normalizeTwitterScreenName, unwrapBrowserResult } from './shared.js';
34

45
/**
56
* Extract follower rows from Twitter/X follower-list SPA cells.
@@ -72,7 +73,7 @@ async function extractFollowersFromDOM(page) {
7273
}
7374

7475
function normalizeScreenName(value) {
75-
return String(value ?? '').trim().replace(/^\/+/, '').replace(/^@+/, '');
76+
return normalizeTwitterScreenName(value);
7677
}
7778

7879
cli({
@@ -103,18 +104,27 @@ cli({
103104
throw new ArgumentError('limit must be a positive integer');
104105
}
105106

106-
let targetUser = normalizeScreenName(kwargs.user);
107+
const rawUser = String(kwargs.user ?? '').trim();
108+
let targetUser = normalizeScreenName(rawUser);
109+
if (rawUser && !targetUser) {
110+
throw new ArgumentError('twitter followers user must be a valid Twitter/X handle', 'Example: opencli twitter followers @elonmusk --limit 100');
111+
}
107112
if (!targetUser) {
108113
await page.goto('https://x.com/home');
109114
await page.wait({ selector: '[data-testid="primaryColumn"]' });
110-
const href = await page.evaluate(`() => {
115+
// Bridge wraps primitive page.evaluate returns as { session, data:<value> };
116+
// unwrap so the href string is usable downstream.
117+
const href = unwrapBrowserResult(await page.evaluate(`() => {
111118
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
112119
return link ? link.getAttribute('href') : null;
113-
}`);
114-
if (!href) {
120+
}`));
121+
if (!href || typeof href !== 'string') {
115122
throw new AuthRequiredError('x.com', 'Could not find logged-in user profile link. Are you logged in?');
116123
}
117124
targetUser = normalizeScreenName(href);
125+
if (!targetUser) {
126+
throw new AuthRequiredError('x.com', 'Could not find logged-in user profile link. Are you logged in?');
127+
}
118128
}
119129
if (!targetUser) {
120130
throw new ArgumentError('twitter followers user cannot be empty', 'Example: opencli twitter followers @elonmusk --limit 100');
@@ -173,3 +183,8 @@ cli({
173183
return allFollowers.slice(0, limit);
174184
}
175185
});
186+
187+
export const __test__ = {
188+
extractFollowersFromDOM,
189+
normalizeScreenName,
190+
};

clis/twitter/followers.test.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { getRegistry } from '@jackwener/opencli/registry';
3+
import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
4+
import { __test__ } from './followers.js';
5+
6+
describe('twitter followers command', () => {
7+
it('normalizes exact profile handles and rejects route-like hrefs', () => {
8+
expect(__test__.normalizeScreenName('@viewer')).toBe('viewer');
9+
expect(__test__.normalizeScreenName('/viewer')).toBe('viewer');
10+
expect(__test__.normalizeScreenName('https://x.com/viewer')).toBe('viewer');
11+
expect(__test__.normalizeScreenName('/home')).toBe('');
12+
expect(__test__.normalizeScreenName('/viewer/extra')).toBe('');
13+
});
14+
15+
it('rejects invalid explicit users before navigation', async () => {
16+
const command = getRegistry().get('twitter/followers');
17+
const page = {
18+
goto: vi.fn(),
19+
wait: vi.fn(),
20+
evaluate: vi.fn(),
21+
};
22+
23+
await expect(command.func(page, { user: 'viewer/extra', limit: 10 })).rejects.toBeInstanceOf(ArgumentError);
24+
expect(page.goto).not.toHaveBeenCalled();
25+
expect(page.wait).not.toHaveBeenCalled();
26+
expect(page.evaluate).not.toHaveBeenCalled();
27+
});
28+
29+
it('rejects non-profile AppTabBar hrefs instead of navigating to route followers', async () => {
30+
const command = getRegistry().get('twitter/followers');
31+
const page = {
32+
goto: vi.fn().mockResolvedValue(undefined),
33+
wait: vi.fn().mockResolvedValue(undefined),
34+
evaluate: vi.fn(async (script) => {
35+
if (String(script).includes('AppTabBar_Profile_Link')) return '/home';
36+
throw new Error(`Unexpected evaluate: ${String(script).slice(0, 80)}`);
37+
}),
38+
};
39+
40+
await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
41+
expect(page.goto).toHaveBeenCalledWith('https://x.com/home');
42+
expect(page.goto).not.toHaveBeenCalledWith('https://x.com/home/followers');
43+
});
44+
});

clis/twitter/following.js

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { cli, Strategy } from '@jackwener/opencli/registry';
22
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3-
import { resolveTwitterQueryId, sanitizeQueryId } from './shared.js';
3+
import { normalizeTwitterScreenName, resolveTwitterQueryId, sanitizeQueryId, unwrapBrowserResult } from './shared.js';
44
import { TWITTER_BEARER_TOKEN } from './utils.js';
55

66
const FOLLOWING_QUERY_ID = 'zx6e-TLzRkeDO_a7p4b3JQ'; // Following fallback
@@ -129,7 +129,7 @@ function parseFollowing(data) {
129129
}
130130

131131
function normalizeScreenName(value) {
132-
return String(value || '').trim().replace(/^\/+/, '').replace(/^@+/, '');
132+
return normalizeTwitterScreenName(value);
133133
}
134134

135135
cli({
@@ -157,24 +157,37 @@ cli({
157157
if (!Number.isInteger(limit) || limit <= 0) {
158158
throw new ArgumentError('twitter following --limit must be a positive integer', 'Example: opencli twitter following @elonmusk --limit 200');
159159
}
160-
let targetUser = normalizeScreenName(kwargs.user);
160+
const rawUser = String(kwargs.user ?? '').trim();
161+
let targetUser = normalizeScreenName(rawUser);
162+
if (rawUser && !targetUser) {
163+
throw new ArgumentError('twitter following user must be a valid Twitter/X handle', 'Example: opencli twitter following @elonmusk --limit 200');
164+
}
161165

162166
const cookies = await page.getCookies({ url: 'https://x.com' });
163167
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
164168
if (!ct0)
165169
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
166170

167-
// opencli >=1.7.x wraps primitive page.evaluate returns as { session, data: <value> }.
168-
const unwrap = (v) => (v && typeof v === 'object' && 'session' in v && 'data' in v ? v.data : v);
169171
if (!targetUser) {
170-
const hrefRaw = await page.evaluate(`() => {
171-
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
172-
return link ? link.getAttribute('href') : null;
173-
}`);
174-
const href = unwrap(hrefRaw);
172+
// Force a navigation to the home surface so the AppTabBar sidebar
173+
// is rendered; the framework pre-nav lands on bare x.com which
174+
// does not always expose AppTabBar_Profile_Link.
175+
await page.goto('https://x.com/home');
176+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
177+
// Bridge wraps primitive page.evaluate returns as { session, data:<value> };
178+
// unwrap so the href string is usable downstream.
179+
// NOTE: the function-literal form `() => ...` silently drops
180+
// primitive return values through the bridge — only the template
181+
// string form preserves the `data` field.
182+
const href = unwrapBrowserResult(await page.evaluate(`() => {
183+
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
184+
return link ? link.getAttribute('href') : null;
185+
}`));
175186
if (!href || typeof href !== 'string')
176187
throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
177-
targetUser = normalizeScreenName(href.replace('/', ''));
188+
targetUser = normalizeScreenName(href);
189+
if (!targetUser)
190+
throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
178191
}
179192
if (!targetUser) {
180193
throw new ArgumentError('twitter following user cannot be empty', 'Example: opencli twitter following @elonmusk --limit 200');
@@ -190,12 +203,12 @@ cli({
190203
};
191204

192205
// Get userId from screen_name
193-
const userLookup = await page.evaluate(async (url, headers) => {
206+
const userLookup = unwrapBrowserResult(await page.evaluate(async (url, headers) => {
194207
const resp = await fetch(url, { headers, credentials: 'include' });
195208
if (!resp.ok) return { error: resp.status };
196209
const d = await resp.json();
197210
return { userId: d.data?.user?.result?.rest_id || null };
198-
}, buildUserByScreenNameUrl(userByScreenNameQueryId, targetUser), headers);
211+
}, buildUserByScreenNameUrl(userByScreenNameQueryId, targetUser), headers));
199212
if (userLookup?.error === 401 || userLookup?.error === 403) {
200213
throw new AuthRequiredError('x.com', `Twitter user lookup failed (HTTP ${userLookup.error})`);
201214
}
@@ -214,10 +227,10 @@ cli({
214227
for (let i = 0; i < MAX_PAGINATION_PAGES && allUsers.length < limit; i++) {
215228
const fetchCount = Math.min(50, limit - allUsers.length + 10);
216229
const apiUrl = buildFollowingUrl(followingQueryId, userId, fetchCount, cursor);
217-
const data = await page.evaluate(async (url, headers) => {
230+
const data = unwrapBrowserResult(await page.evaluate(async (url, headers) => {
218231
const r = await fetch(url, { headers, credentials: 'include' });
219232
return r.ok ? await r.json() : { error: r.status };
220-
}, apiUrl, headers);
233+
}, apiUrl, headers));
221234
if (data?.error) {
222235
if (data.error === 401 || data.error === 403)
223236
throw new AuthRequiredError('x.com', `Twitter following request failed (HTTP ${data.error})`);

clis/twitter/following.test.js

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ describe('twitter following helpers', () => {
157157
expect(__test__.normalizeScreenName('@elonmusk')).toBe('elonmusk');
158158
expect(__test__.normalizeScreenName('/elonmusk')).toBe('elonmusk');
159159
expect(__test__.normalizeScreenName(' @@alice ')).toBe('alice');
160+
expect(__test__.normalizeScreenName('/home')).toBe('');
161+
expect(__test__.normalizeScreenName('/elonmusk/extra')).toBe('');
160162
});
161163
});
162164

@@ -201,23 +203,28 @@ function followingPayload(users, cursor) {
201203
};
202204
}
203205

204-
function createFollowingPage(followingResponses, { ct0 = 'token', userLookup = { userId: '42' } } = {}) {
206+
function bridgeEnvelope(data) {
207+
return { session: 'site:twitter', data };
208+
}
209+
210+
function createFollowingPage(followingResponses, { ct0 = 'token', userLookup = { userId: '42' }, envelope = false } = {}) {
205211
const page = {
206212
goto: vi.fn().mockResolvedValue(undefined),
207213
wait: vi.fn().mockResolvedValue(undefined),
208214
getCookies: vi.fn(async () => (ct0 ? [{ name: 'ct0', value: ct0 }] : [])),
209215
evaluate: vi.fn(async (script, ...args) => {
216+
const wrap = (value) => envelope ? bridgeEnvelope(value) : value;
210217
if (typeof script === 'function') {
211218
const haystack = [script.toString(), ...args.map((arg) => String(arg))].join('\n');
212-
if (haystack.includes('/UserByScreenName')) return userLookup;
213-
if (haystack.includes('/Following')) return followingResponses.shift() || followingPayload([], null);
214-
if (haystack.includes('AppTabBar_Profile_Link')) return '/viewer';
219+
if (haystack.includes('/UserByScreenName')) return wrap(userLookup);
220+
if (haystack.includes('/Following')) return wrap(followingResponses.shift() || followingPayload([], null));
221+
if (haystack.includes('AppTabBar_Profile_Link')) return wrap('/viewer');
215222
throw new Error(`Unexpected evaluate function: ${haystack.slice(0, 80)}`);
216223
}
217224
if (script.includes('operationName')) return null;
218-
if (script.includes('/UserByScreenName')) return userLookup;
219-
if (script.includes('/Following')) return followingResponses.shift() || followingPayload([], null);
220-
if (script.includes('AppTabBar_Profile_Link')) return '/viewer';
225+
if (script.includes('/UserByScreenName')) return wrap(userLookup);
226+
if (script.includes('/Following')) return wrap(followingResponses.shift() || followingPayload([], null));
227+
if (script.includes('AppTabBar_Profile_Link')) return wrap('/viewer');
221228
throw new Error(`Unexpected evaluate script: ${script.slice(0, 80)}`);
222229
}),
223230
};
@@ -253,6 +260,29 @@ describe('twitter following command', () => {
253260
expect(page.goto).not.toHaveBeenCalled();
254261
});
255262

263+
it('rejects invalid explicit users before cookies or navigation', async () => {
264+
const command = getRegistry().get('twitter/following');
265+
const page = createFollowingPage([]);
266+
267+
await expect(command.func(page, { user: 'elonmusk/extra', limit: 10 })).rejects.toBeInstanceOf(ArgumentError);
268+
expect(page.getCookies).not.toHaveBeenCalled();
269+
expect(page.goto).not.toHaveBeenCalled();
270+
expect(page.evaluate).not.toHaveBeenCalled();
271+
});
272+
273+
it('rejects route-like AppTabBar hrefs as AuthRequiredError', async () => {
274+
const command = getRegistry().get('twitter/following');
275+
const page = createFollowingPage([]);
276+
page.evaluate.mockImplementation(async (script, ...args) => {
277+
const haystack = [typeof script === 'function' ? script.toString() : String(script), ...args.map((arg) => String(arg))].join('\n');
278+
if (haystack.includes('AppTabBar_Profile_Link')) return '/home';
279+
throw new Error(`Unexpected evaluate: ${haystack.slice(0, 80)}`);
280+
});
281+
282+
await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
283+
expect(page.goto).toHaveBeenCalledWith('https://x.com/home');
284+
});
285+
256286
it('maps first-page auth failures to AuthRequiredError', async () => {
257287
const command = getRegistry().get('twitter/following');
258288
const page = createFollowingPage([{ error: 401 }]);
@@ -277,6 +307,20 @@ describe('twitter following command', () => {
277307
await expect(command.func(page, { user: 'elonmusk', limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
278308
});
279309

310+
it('unwraps Browser Bridge envelopes for user lookup and following payloads', async () => {
311+
const command = getRegistry().get('twitter/following');
312+
const page = createFollowingPage([followingPayload(['alice'], null)], { envelope: true });
313+
314+
const rows = await command.func(page, { user: 'elonmusk', limit: 10 });
315+
316+
expect(rows.map((row) => row.screen_name)).toEqual(['alice']);
317+
const callText = (call) => call.map((part) => typeof part === 'function' ? part.toString() : String(part)).join('\n');
318+
const followingCall = page.evaluate.mock.calls.find((call) => callText(call).includes('/Following')) || [];
319+
const followingUrl = String(followingCall[1] || '');
320+
expect(decodeURIComponent(followingUrl)).toContain('"userId":"42"');
321+
expect(decodeURIComponent(followingUrl)).not.toContain('[object Object]');
322+
});
323+
280324
it('fails fast when the following timeline is empty', async () => {
281325
const command = getRegistry().get('twitter/following');
282326
const page = createFollowingPage([followingPayload([], null)]);

0 commit comments

Comments
 (0)