Skip to content

Commit d1c714e

Browse files
huanghehuanghekaryhe1019jackwener
authored
feat(twitter): 新增 list-create 命令(GraphQL CreateList mutation) (#1656)
* feat(twitter): add list-create command Adds a new `twitter list-create` command so users can create Twitter/X lists from the CLI (the existing list commands only covered reading, adding, and removing members). Uses the GraphQL CreateList mutation with the same cookie + CSRF pattern as list-add, no UI clicks needed. Args: name (positional, max 25), --description (max 100), --mode (public|private). QueryId resolved at runtime via resolveTwitterQueryId, with a known fallback for offline / bundle-scan misses. * fix(twitter): pin list-create queryId + features to a working pair Twitter's GraphQL rejects CreateList when queryId and the features schema drift apart (DecodeException). Stop resolving the queryId dynamically (which would pull a newer schema), hardcode a known-good queryId, and trim features to the minimal set the real web client sends. Also: Twitter sometimes returns a non-fatal errors array from a side-effect serializer while still creating the list. Check for a valid list payload first and only treat errors as fatal when no list came back. * fix(twitter): add missing access:'write' on list-create (#9) `twitter/list-create` was missing the required `access` field, which made manifest validation fail on every opencli invocation and spam stderr with: ⚠ Failed to load manifest .../cli-manifest.json: Command twitter/list-create must declare access: 'read' | 'write' Per docs/conventions/convention-audit.md (rule missing-access-metadata), every adapter command must declare access. Since list-create is a create action, set access: 'write'. Also rebuilds cli-manifest.json — picks up missing `quoted_tweet` columns on list-tweets / search / list-tweets-username from PR #8 (which didn't rebuild the manifest). * fix(twitter): harden list-create mutation contract * fix(twitter): verify created list name --------- Co-authored-by: huanghe <he.huang@extremevision.mo> Co-authored-by: Kary <karyhe1019@gmail.com> Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent 8182ffb commit d1c714e

4 files changed

Lines changed: 373 additions & 0 deletions

File tree

cli-manifest.json

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24112,6 +24112,49 @@
2411224112
"sourceFile": "twitter/list-add.js",
2411324113
"navigateBefore": true
2411424114
},
24115+
{
24116+
"site": "twitter",
24117+
"name": "list-create",
24118+
"description": "Create a new Twitter/X list (returns the new list id)",
24119+
"access": "write",
24120+
"domain": "x.com",
24121+
"strategy": "cookie",
24122+
"browser": true,
24123+
"args": [
24124+
{
24125+
"name": "name",
24126+
"type": "string",
24127+
"required": true,
24128+
"positional": true,
24129+
"help": "List name (max 25 chars)"
24130+
},
24131+
{
24132+
"name": "description",
24133+
"type": "string",
24134+
"default": "",
24135+
"required": false,
24136+
"help": "Optional list description (max 100 chars)"
24137+
},
24138+
{
24139+
"name": "mode",
24140+
"type": "string",
24141+
"default": "public",
24142+
"required": false,
24143+
"help": "public | private"
24144+
}
24145+
],
24146+
"columns": [
24147+
"id",
24148+
"name",
24149+
"description",
24150+
"mode",
24151+
"status"
24152+
],
24153+
"type": "js",
24154+
"modulePath": "twitter/list-create.js",
24155+
"sourceFile": "twitter/list-create.js",
24156+
"navigateBefore": "https://x.com"
24157+
},
2411524158
{
2411624159
"site": "twitter",
2411724160
"name": "list-remove",

clis/twitter/list-create.js

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { cli, Strategy } from '@jackwener/opencli/registry';
2+
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3+
import { unwrapBrowserResult } from './shared.js';
4+
import { TWITTER_BEARER_TOKEN } from './utils.js';
5+
6+
const CREATE_LIST_QUERY_ID = 'UQRa0jJ9doxGEIQRea1Y0w';
7+
const NAME_MAX = 25;
8+
const DESCRIPTION_MAX = 100;
9+
10+
// Minimal feature set as observed in the real CreateList web request payload.
11+
// Twitter rejects requests with extra/unknown features (DecodeException).
12+
const FEATURES = {
13+
profile_label_improvements_pcf_label_in_post_enabled: true,
14+
responsive_web_profile_redirect_enabled: false,
15+
rweb_tipjar_consumption_enabled: false,
16+
verified_phone_label_enabled: false,
17+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
18+
responsive_web_graphql_timeline_navigation_enabled: true,
19+
};
20+
21+
export function parseListCreateArgs(kwargs) {
22+
const name = String(kwargs.name || '').trim();
23+
const description = String(kwargs.description || '').trim();
24+
const modeRaw = String(kwargs.mode || 'public').trim().toLowerCase();
25+
if (!name) {
26+
throw new ArgumentError('List name is required', 'Example: opencli twitter list-create "My List"');
27+
}
28+
if (name.length > NAME_MAX) {
29+
throw new ArgumentError(`List name too long: ${name.length} chars (max ${NAME_MAX})`);
30+
}
31+
if (description.length > DESCRIPTION_MAX) {
32+
throw new ArgumentError(`Description too long: ${description.length} chars (max ${DESCRIPTION_MAX})`);
33+
}
34+
if (modeRaw !== 'public' && modeRaw !== 'private') {
35+
throw new ArgumentError(`Invalid mode: ${JSON.stringify(kwargs.mode)}. Expected "public" or "private".`);
36+
}
37+
return { listName: name, listDescription: description, listMode: modeRaw, privateFlag: modeRaw === 'private' };
38+
}
39+
40+
function requireCreateListResult(result, expectedName, expectedMode) {
41+
if (!result || typeof result !== 'object') {
42+
throw new CommandExecutionError(`Unexpected result from twitter list-create: ${JSON.stringify(result)}`);
43+
}
44+
if (result.httpStatus === 401 || result.httpStatus === 403) {
45+
throw new AuthRequiredError('x.com', `Twitter CreateList returned HTTP ${result.httpStatus}`);
46+
}
47+
if (!result.ok) {
48+
const snippet = String(result.bodyText || '').slice(0, 300);
49+
throw new CommandExecutionError(`HTTP ${result.httpStatus} from CreateList: ${snippet}`);
50+
}
51+
if (!result.bodyJson || typeof result.bodyJson !== 'object') {
52+
throw new CommandExecutionError(`CreateList returned malformed JSON payload. Body: ${String(result.bodyText || '').slice(0, 300)}`);
53+
}
54+
const list = result.bodyJson?.data?.list;
55+
if (!list || typeof list !== 'object') {
56+
const errors = result.bodyJson?.errors;
57+
if (Array.isArray(errors) && errors.length > 0) {
58+
throw new CommandExecutionError(`CreateList failed: ${errors[0].message || JSON.stringify(errors[0])}`);
59+
}
60+
throw new CommandExecutionError(`CreateList returned no list payload. Body: ${String(result.bodyText || '').slice(0, 300)}`);
61+
}
62+
const id = String(list.id_str || list.id || '');
63+
if (!/^\d+$/.test(id)) {
64+
throw new CommandExecutionError('CreateList returned a list payload without a numeric list id.');
65+
}
66+
if (typeof list.name !== 'string' || !list.name.trim()) {
67+
throw new CommandExecutionError('CreateList returned a list payload without a list name.');
68+
}
69+
if (list.name.trim() !== expectedName) {
70+
throw new CommandExecutionError(`CreateList returned name ${JSON.stringify(list.name)}, expected ${JSON.stringify(expectedName)}.`);
71+
}
72+
const modeValue = typeof list.mode === 'string' ? list.mode : '';
73+
if (!modeValue) {
74+
throw new CommandExecutionError('CreateList returned a list payload without list mode.');
75+
}
76+
const mode = /private/i.test(modeValue) ? 'private' : 'public';
77+
if (mode !== expectedMode) {
78+
throw new CommandExecutionError(`CreateList returned mode ${mode}, expected ${expectedMode}.`);
79+
}
80+
return { createdList: list, listId: id, listMode: mode };
81+
}
82+
83+
export function buildListCreateRow({ result, name, description, mode }) {
84+
const { createdList, listId, listMode } = requireCreateListResult(result, name, mode);
85+
return {
86+
id: listId,
87+
name: createdList.name,
88+
description: typeof createdList.description === 'string' ? createdList.description : description,
89+
mode: listMode,
90+
status: 'success',
91+
};
92+
}
93+
94+
cli({
95+
site: 'twitter',
96+
name: 'list-create',
97+
description: 'Create a new Twitter/X list (returns the new list id)',
98+
access: 'write',
99+
domain: 'x.com',
100+
strategy: Strategy.COOKIE,
101+
browser: true,
102+
args: [
103+
{ name: 'name', positional: true, type: 'string', required: true, help: `List name (max ${NAME_MAX} chars)` },
104+
{ name: 'description', type: 'string', default: '', help: `Optional list description (max ${DESCRIPTION_MAX} chars)` },
105+
{ name: 'mode', type: 'string', default: 'public', help: 'public | private' },
106+
],
107+
columns: ['id', 'name', 'description', 'mode', 'status'],
108+
func: async (page, kwargs) => {
109+
const { listName: name, listDescription: description, listMode: mode, privateFlag: isPrivate } = parseListCreateArgs(kwargs);
110+
111+
await page.goto('https://x.com');
112+
await page.wait(3);
113+
const cookies = await page.getCookies({ url: 'https://x.com' });
114+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
115+
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
116+
117+
// Hardcode queryId: it must match the FEATURES schema below.
118+
// Letting resolveTwitterQueryId() drift would pull a newer queryId
119+
// whose schema would reject our simplified features payload.
120+
const queryId = CREATE_LIST_QUERY_ID;
121+
122+
const headers = JSON.stringify({
123+
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
124+
'X-Csrf-Token': ct0,
125+
'X-Twitter-Auth-Type': 'OAuth2Session',
126+
'X-Twitter-Active-User': 'yes',
127+
'Content-Type': 'application/json',
128+
});
129+
const body = JSON.stringify({
130+
variables: { isPrivate, name, description },
131+
features: FEATURES,
132+
queryId,
133+
});
134+
const apiUrl = `/i/api/graphql/${queryId}/CreateList`;
135+
136+
const result = unwrapBrowserResult(await page.evaluate(`async () => {
137+
const r = await fetch(${JSON.stringify(apiUrl)}, {
138+
method: 'POST',
139+
headers: ${headers},
140+
credentials: 'include',
141+
body: ${JSON.stringify(body)},
142+
});
143+
const bodyText = await r.text();
144+
let bodyJson = null;
145+
try { bodyJson = JSON.parse(bodyText); } catch {}
146+
return { ok: r.ok, httpStatus: r.status, bodyJson, bodyText };
147+
}`));
148+
149+
// Note: Twitter sometimes returns a non-fatal `errors` array (e.g. a
150+
// strato DecodeException from a side-effect serializer) WHILE STILL
151+
// creating the list. So check for a valid list payload FIRST and
152+
// only treat errors as fatal if no list came back.
153+
return [buildListCreateRow({ result, name, description, mode })];
154+
},
155+
});

clis/twitter/list-create.test.js

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { getRegistry } from '@jackwener/opencli/registry';
3+
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
4+
import { buildListCreateRow, parseListCreateArgs } from './list-create.js';
5+
import './list-create.js';
6+
7+
function createPayload(overrides = {}) {
8+
return {
9+
ok: true,
10+
httpStatus: 200,
11+
bodyText: '{}',
12+
bodyJson: {
13+
data: {
14+
list: {
15+
id_str: '123456789',
16+
name: 'My List',
17+
description: 'A list',
18+
mode: 'Private',
19+
...overrides,
20+
},
21+
},
22+
},
23+
};
24+
}
25+
26+
describe('twitter list-create registration', () => {
27+
it('registers the list-create command with the expected shape', () => {
28+
const cmd = getRegistry().get('twitter/list-create');
29+
expect(cmd?.func).toBeTypeOf('function');
30+
expect(cmd?.columns).toEqual(['id', 'name', 'description', 'mode', 'status']);
31+
const nameArg = cmd?.args?.find((a) => a.name === 'name');
32+
expect(nameArg).toBeTruthy();
33+
expect(nameArg?.required).toBe(true);
34+
expect(nameArg?.positional).toBe(true);
35+
const modeArg = cmd?.args?.find((a) => a.name === 'mode');
36+
expect(modeArg?.default).toBe('public');
37+
const descArg = cmd?.args?.find((a) => a.name === 'description');
38+
expect(descArg?.default).toBe('');
39+
});
40+
41+
it('rejects empty name', async () => {
42+
const cmd = getRegistry().get('twitter/list-create');
43+
await expect(cmd.func({}, { name: ' ' })).rejects.toBeInstanceOf(ArgumentError);
44+
});
45+
46+
it('rejects names over 25 chars', async () => {
47+
const cmd = getRegistry().get('twitter/list-create');
48+
await expect(cmd.func({}, { name: 'x'.repeat(26) })).rejects.toBeInstanceOf(ArgumentError);
49+
});
50+
51+
it('rejects descriptions over 100 chars', () => {
52+
expect(() => parseListCreateArgs({ name: 'ok', description: 'x'.repeat(101) })).toThrow(ArgumentError);
53+
});
54+
55+
it('rejects invalid mode', async () => {
56+
const cmd = getRegistry().get('twitter/list-create');
57+
await expect(cmd.func({}, { name: 'ok', mode: 'secret' })).rejects.toBeInstanceOf(ArgumentError);
58+
});
59+
60+
it('reads ct0 from cookies and unwraps Browser Bridge mutation envelopes', async () => {
61+
const cmd = getRegistry().get('twitter/list-create');
62+
const page = {
63+
goto: vi.fn().mockResolvedValue(undefined),
64+
wait: vi.fn().mockResolvedValue(undefined),
65+
getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'csrf-token' }]),
66+
evaluate: vi.fn().mockResolvedValue({ session: 'browser:default', data: createPayload() }),
67+
};
68+
69+
const rows = await cmd.func(page, { name: 'My List', description: 'A list', mode: 'private' });
70+
71+
expect(page.goto).toHaveBeenCalledWith('https://x.com');
72+
expect(page.wait).toHaveBeenCalledWith(3);
73+
expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
74+
expect(rows).toEqual([{ id: '123456789', name: 'My List', description: 'A list', mode: 'private', status: 'success' }]);
75+
});
76+
77+
it('rejects missing ct0 before mutation', async () => {
78+
const cmd = getRegistry().get('twitter/list-create');
79+
const page = {
80+
goto: vi.fn().mockResolvedValue(undefined),
81+
wait: vi.fn().mockResolvedValue(undefined),
82+
getCookies: vi.fn().mockResolvedValue([]),
83+
evaluate: vi.fn(),
84+
};
85+
86+
await expect(cmd.func(page, { name: 'My List' })).rejects.toBeInstanceOf(AuthRequiredError);
87+
expect(page.evaluate).not.toHaveBeenCalled();
88+
});
89+
90+
it('keeps non-fatal GraphQL errors when a valid created list payload exists', () => {
91+
const row = buildListCreateRow({
92+
result: {
93+
...createPayload(),
94+
bodyJson: {
95+
...createPayload().bodyJson,
96+
errors: [{ message: 'DecodeException' }],
97+
},
98+
},
99+
name: 'My List',
100+
description: 'A list',
101+
mode: 'private',
102+
});
103+
104+
expect(row).toEqual({ id: '123456789', name: 'My List', description: 'A list', mode: 'private', status: 'success' });
105+
});
106+
107+
it('maps mutation auth and HTTP failures to typed errors', () => {
108+
expect(() => buildListCreateRow({
109+
result: { ok: false, httpStatus: 401, bodyText: 'login' },
110+
name: 'My List',
111+
description: '',
112+
mode: 'public',
113+
})).toThrow(AuthRequiredError);
114+
115+
expect(() => buildListCreateRow({
116+
result: { ok: false, httpStatus: 500, bodyText: 'server' },
117+
name: 'My List',
118+
description: '',
119+
mode: 'public',
120+
})).toThrow(CommandExecutionError);
121+
});
122+
123+
it('fails typed when the mutation response lacks post-condition evidence', () => {
124+
for (const result of [
125+
createPayload({ id_str: '', id: '' }),
126+
createPayload({ name: '' }),
127+
createPayload({ mode: '' }),
128+
]) {
129+
expect(() => buildListCreateRow({
130+
result,
131+
name: 'My List',
132+
description: '',
133+
mode: 'public',
134+
})).toThrow(CommandExecutionError);
135+
}
136+
});
137+
138+
it('fails typed when returned list name does not match the requested name', () => {
139+
expect(() => buildListCreateRow({
140+
result: createPayload({ name: 'Other List' }),
141+
name: 'My List',
142+
description: '',
143+
mode: 'private',
144+
})).toThrow(/expected "My List"/);
145+
});
146+
147+
it('fails typed when returned list mode does not match requested mode', () => {
148+
expect(() => buildListCreateRow({
149+
result: createPayload({ mode: 'Public' }),
150+
name: 'My List',
151+
description: '',
152+
mode: 'private',
153+
})).toThrow(/expected private/);
154+
});
155+
156+
it('fails typed when errors appear without a list payload', () => {
157+
expect(() => buildListCreateRow({
158+
result: {
159+
ok: true,
160+
httpStatus: 200,
161+
bodyText: '{}',
162+
bodyJson: { errors: [{ message: 'duplicate name' }] },
163+
},
164+
name: 'My List',
165+
description: '',
166+
mode: 'public',
167+
})).toThrow(/duplicate name/);
168+
});
169+
});

docs/adapters/browser/twitter.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
| `opencli twitter likes` | |
2323
| `opencli twitter lists` | |
2424
| `opencli twitter list-tweets` | |
25+
| `opencli twitter list-create` | Create a Twitter/X list via GraphQL and return the created list id |
2526
| `opencli twitter list-add` | |
2627
| `opencli twitter list-remove` | |
2728
| `opencli twitter article` | |
@@ -62,6 +63,11 @@ opencli twitter download @elonmusk --limit 50 --output ./twitter-media
6263
# Download media from a single tweet
6364
opencli twitter download --tweet-url https://x.com/jack/status/20 --output ./twitter-media
6465

66+
# Create a list and then manage members (requires login)
67+
opencli twitter list-create "AI research" --description "Papers and labs" --mode private
68+
opencli twitter list-add 123456789 alice
69+
opencli twitter list-remove 123456789 alice
70+
6571
# Write actions (require login). Idempotent — calling twice is safe.
6672
opencli twitter like https://x.com/jack/status/20
6773
opencli twitter unlike https://x.com/jack/status/20

0 commit comments

Comments
 (0)