Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

156 lines
7.2 KiB
JavaScript

import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { unwrapBrowserResult } from './shared.js';
import { TWITTER_BEARER_TOKEN } from './utils.js';
const CREATE_LIST_QUERY_ID = 'UQRa0jJ9doxGEIQRea1Y0w';
const NAME_MAX = 25;
const DESCRIPTION_MAX = 100;
// Minimal feature set as observed in the real CreateList web request payload.
// Twitter rejects requests with extra/unknown features (DecodeException).
const FEATURES = {
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: false,
rweb_tipjar_consumption_enabled: false,
verified_phone_label_enabled: false,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
responsive_web_graphql_timeline_navigation_enabled: true,
};
export function parseListCreateArgs(kwargs) {
const name = String(kwargs.name || '').trim();
const description = String(kwargs.description || '').trim();
const modeRaw = String(kwargs.mode || 'public').trim().toLowerCase();
if (!name) {
throw new ArgumentError('List name is required', 'Example: opencli twitter list-create "My List"');
}
if (name.length > NAME_MAX) {
throw new ArgumentError(`List name too long: ${name.length} chars (max ${NAME_MAX})`);
}
if (description.length > DESCRIPTION_MAX) {
throw new ArgumentError(`Description too long: ${description.length} chars (max ${DESCRIPTION_MAX})`);
}
if (modeRaw !== 'public' && modeRaw !== 'private') {
throw new ArgumentError(`Invalid mode: ${JSON.stringify(kwargs.mode)}. Expected "public" or "private".`);
}
return { listName: name, listDescription: description, listMode: modeRaw, privateFlag: modeRaw === 'private' };
}
function requireCreateListResult(result, expectedName, expectedMode) {
if (!result || typeof result !== 'object') {
throw new CommandExecutionError(`Unexpected result from twitter list-create: ${JSON.stringify(result)}`);
}
if (result.httpStatus === 401 || result.httpStatus === 403) {
throw new AuthRequiredError('x.com', `Twitter CreateList returned HTTP ${result.httpStatus}`);
}
if (!result.ok) {
const snippet = String(result.bodyText || '').slice(0, 300);
throw new CommandExecutionError(`HTTP ${result.httpStatus} from CreateList: ${snippet}`);
}
if (!result.bodyJson || typeof result.bodyJson !== 'object') {
throw new CommandExecutionError(`CreateList returned malformed JSON payload. Body: ${String(result.bodyText || '').slice(0, 300)}`);
}
const list = result.bodyJson?.data?.list;
if (!list || typeof list !== 'object') {
const errors = result.bodyJson?.errors;
if (Array.isArray(errors) && errors.length > 0) {
throw new CommandExecutionError(`CreateList failed: ${errors[0].message || JSON.stringify(errors[0])}`);
}
throw new CommandExecutionError(`CreateList returned no list payload. Body: ${String(result.bodyText || '').slice(0, 300)}`);
}
const id = String(list.id_str || list.id || '');
if (!/^\d+$/.test(id)) {
throw new CommandExecutionError('CreateList returned a list payload without a numeric list id.');
}
if (typeof list.name !== 'string' || !list.name.trim()) {
throw new CommandExecutionError('CreateList returned a list payload without a list name.');
}
if (list.name.trim() !== expectedName) {
throw new CommandExecutionError(`CreateList returned name ${JSON.stringify(list.name)}, expected ${JSON.stringify(expectedName)}.`);
}
const modeValue = typeof list.mode === 'string' ? list.mode : '';
if (!modeValue) {
throw new CommandExecutionError('CreateList returned a list payload without list mode.');
}
const mode = /private/i.test(modeValue) ? 'private' : 'public';
if (mode !== expectedMode) {
throw new CommandExecutionError(`CreateList returned mode ${mode}, expected ${expectedMode}.`);
}
return { createdList: list, listId: id, listMode: mode };
}
export function buildListCreateRow({ result, name, description, mode }) {
const { createdList, listId, listMode } = requireCreateListResult(result, name, mode);
return {
id: listId,
name: createdList.name,
description: typeof createdList.description === 'string' ? createdList.description : description,
mode: listMode,
status: 'success',
};
}
cli({
site: 'twitter',
name: 'list-create',
description: 'Create a new Twitter/X list (returns the new list id)',
access: 'write',
domain: 'x.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'name', positional: true, type: 'string', required: true, help: `List name (max ${NAME_MAX} chars)` },
{ name: 'description', type: 'string', default: '', help: `Optional list description (max ${DESCRIPTION_MAX} chars)` },
{ name: 'mode', type: 'string', default: 'public', help: 'public | private' },
],
columns: ['id', 'name', 'description', 'mode', 'status'],
func: async (page, kwargs) => {
const { listName: name, listDescription: description, listMode: mode, privateFlag: isPrivate } = parseListCreateArgs(kwargs);
await page.goto('https://x.com');
await page.wait(3);
const cookies = await page.getCookies({ url: 'https://x.com' });
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
// Hardcode queryId: it must match the FEATURES schema below.
// Letting resolveTwitterQueryId() drift would pull a newer queryId
// whose schema would reject our simplified features payload.
const queryId = CREATE_LIST_QUERY_ID;
const headers = JSON.stringify({
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
'X-Csrf-Token': ct0,
'X-Twitter-Auth-Type': 'OAuth2Session',
'X-Twitter-Active-User': 'yes',
'Content-Type': 'application/json',
});
const body = JSON.stringify({
variables: { isPrivate, name, description },
features: FEATURES,
queryId,
});
const apiUrl = `/i/api/graphql/${queryId}/CreateList`;
const result = unwrapBrowserResult(await page.evaluate(`async () => {
const r = await fetch(${JSON.stringify(apiUrl)}, {
method: 'POST',
headers: ${headers},
credentials: 'include',
body: ${JSON.stringify(body)},
});
const bodyText = await r.text();
let bodyJson = null;
try { bodyJson = JSON.parse(bodyText); } catch {}
return { ok: r.ok, httpStatus: r.status, bodyJson, bodyText };
}`));
// Note: Twitter sometimes returns a non-fatal `errors` array (e.g. a
// strato DecodeException from a side-effect serializer) WHILE STILL
// creating the list. So check for a valid list payload FIRST and
// only treat errors as fatal if no list came back.
return [buildListCreateRow({ result, name, description, mode })];
},
});