From c00d8ba050d13c3370c469b912723f697a740733 Mon Sep 17 00:00:00 2001 From: Chris Sev Date: Sat, 24 Jan 2026 07:52:34 -0700 Subject: [PATCH 1/2] feat: add delete command for deleting tweets - Add DeleteTweet to fallback query IDs (VaenaVgh5q5ih7kvyVjgtg) - Add deleteTweet method to TwitterClient posting mixin - Create new delete.ts command file - Register delete command in CLI program Usage: bird delete Supports both tweet IDs and full URLs, can delete multiple tweets in one call. --- src/cli/program.ts | 3 ++ src/commands/delete.ts | 43 ++++++++++++++++++ src/lib/twitter-client-constants.ts | 1 + src/lib/twitter-client-posting.ts | 67 ++++++++++++++++++++++++++++- 4 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 src/commands/delete.ts diff --git a/src/cli/program.ts b/src/cli/program.ts index 6c5c605..2e1180a 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { registerBookmarksCommand } from '../commands/bookmarks.js'; import { registerCheckCommand } from '../commands/check.js'; +import { registerDeleteCommand } from '../commands/delete.js'; import { registerFollowCommands } from '../commands/follow.js'; import { registerHelpCommand } from '../commands/help.js'; import { registerHomeCommand } from '../commands/home.js'; @@ -19,6 +20,7 @@ import { type CliContext, collectCookieSource } from './shared.js'; export const KNOWN_COMMANDS = new Set([ 'tweet', 'reply', + 'delete', 'query-ids', 'read', 'replies', @@ -141,6 +143,7 @@ export function createProgram(ctx: CliContext): Command { registerHelpCommand(program, ctx); registerQueryIdsCommand(program, ctx); registerPostCommands(program, ctx); + registerDeleteCommand(program, ctx); registerReadCommands(program, ctx); registerSearchCommands(program, ctx); registerBookmarksCommand(program, ctx); diff --git a/src/commands/delete.ts b/src/commands/delete.ts new file mode 100644 index 0000000..89b5bb9 --- /dev/null +++ b/src/commands/delete.ts @@ -0,0 +1,43 @@ +import type { Command } from 'commander'; +import type { CliContext } from '../cli/shared.js'; +import { TwitterClient } from '../lib/twitter-client.js'; + +export function registerDeleteCommand(program: Command, ctx: CliContext): void { + program + .command('delete') + .description('Delete a tweet') + .argument('', 'Tweet IDs or URLs to delete') + .action(async (tweetIdOrUrls: string[]) => { + const opts = program.opts(); + const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + + const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); + + for (const warning of warnings) { + console.error(`${ctx.p('warn')}${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error(`${ctx.p('err')}Missing required credentials`); + process.exit(1); + } + + const client = new TwitterClient({ cookies, timeoutMs }); + let failures = 0; + + for (const input of tweetIdOrUrls) { + const tweetId = ctx.extractTweetId(input); + const result = await client.deleteTweet(tweetId); + if (result.success) { + console.log(`${ctx.p('ok')}Deleted tweet ${tweetId}`); + } else { + failures += 1; + console.error(`${ctx.p('err')}Failed to delete tweet ${tweetId}: ${result.error}`); + } + } + + if (failures > 0) { + process.exit(1); + } + }); +} diff --git a/src/lib/twitter-client-constants.ts b/src/lib/twitter-client-constants.ts index bcc70cc..8df5932 100644 --- a/src/lib/twitter-client-constants.ts +++ b/src/lib/twitter-client-constants.ts @@ -15,6 +15,7 @@ export const SETTINGS_NAME_REGEX = /"name":"([^"\\]*(?:\\.[^"\\]*)*)"/; // the file is missing or incomplete. export const FALLBACK_QUERY_IDS = { CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg', + DeleteTweet: 'VaenaVgh5q5ih7kvyVjgtg', CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA', DeleteRetweet: 'iQtK4dl5hBmXewYZuEOKVw', CreateFriendship: '8h9JVdV8dlSyqyRDJEPCsA', diff --git a/src/lib/twitter-client-posting.ts b/src/lib/twitter-client-posting.ts index 693ffe6..ed4aea6 100644 --- a/src/lib/twitter-client-posting.ts +++ b/src/lib/twitter-client-posting.ts @@ -1,11 +1,12 @@ import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js'; import { TWITTER_API_BASE, TWITTER_GRAPHQL_POST_URL, TWITTER_STATUS_UPDATE_URL } from './twitter-client-constants.js'; import { buildTweetCreateFeatures } from './twitter-client-features.js'; -import type { CreateTweetResponse, TweetResult } from './twitter-client-types.js'; +import type { BookmarkMutationResult, CreateTweetResponse, TweetResult } from './twitter-client-types.js'; export interface TwitterClientPostingMethods { tweet(text: string, mediaIds?: string[]): Promise; reply(text: string, replyToTweetId: string, mediaIds?: string[]): Promise; + deleteTweet(tweetId: string): Promise; } export function withPosting>( @@ -59,6 +60,70 @@ export function withPosting return this.createTweet(variables, features); } + /** + * Delete a tweet + */ + async deleteTweet(tweetId: string): Promise { + await this.ensureClientUserId(); + let queryId = await this.getQueryId('DeleteTweet'); + let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/DeleteTweet`; + + const variables = { + tweet_id: tweetId, + dark_request: false, + }; + + const buildBody = () => JSON.stringify({ variables, queryId }); + let body = buildBody(); + + try { + const headers = { ...this.getHeaders(), referer: 'https://x.com/' }; + let response = await this.fetchWithTimeout(urlWithOperation, { + method: 'POST', + headers, + body, + }); + + // If 404, refresh query IDs and retry + if (response.status === 404) { + await this.refreshQueryIds(); + queryId = await this.getQueryId('DeleteTweet'); + urlWithOperation = `${TWITTER_API_BASE}/${queryId}/DeleteTweet`; + body = buildBody(); + + response = await this.fetchWithTimeout(urlWithOperation, { + method: 'POST', + headers: { ...this.getHeaders(), referer: 'https://x.com/' }, + body, + }); + } + + if (!response.ok) { + const text = await response.text(); + return { + success: false, + error: `HTTP ${response.status}: ${text.slice(0, 200)}`, + }; + } + + const data = (await response.json()) as { data?: { delete_tweet?: { tweet_results?: unknown } }; errors?: Array<{ message: string; code?: number }> }; + + if (data.errors && data.errors.length > 0) { + return { + success: false, + error: data.errors.map((e) => e.message).join(', '), + }; + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + private async createTweet( variables: Record, features: Record, From 975ab2547a37ff23ca9aefebf7d8721fed26ae10 Mon Sep 17 00:00:00 2001 From: Chris Sev Date: Sat, 24 Jan 2026 08:02:28 -0700 Subject: [PATCH 2/2] fix: improve delete command based on code review - Add fallback to TWITTER_GRAPHQL_POST_URL on persistent 404 - Use tweet-specific referer header (matches other mutations) - Verify delete_tweet exists in response before reporting success - Add --json flag for JSON output support --- src/commands/delete.ts | 32 ++++++++++++++++++++++++------- src/lib/twitter-client-posting.ts | 24 ++++++++++++++++++++--- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/commands/delete.ts b/src/commands/delete.ts index 89b5bb9..c8f00dd 100644 --- a/src/commands/delete.ts +++ b/src/commands/delete.ts @@ -7,35 +7,53 @@ export function registerDeleteCommand(program: Command, ctx: CliContext): void { .command('delete') .description('Delete a tweet') .argument('', 'Tweet IDs or URLs to delete') - .action(async (tweetIdOrUrls: string[]) => { + .option('--json', 'Output results as JSON') + .action(async (tweetIdOrUrls: string[], cmdOpts: { json?: boolean }) => { const opts = program.opts(); const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + const jsonOutput = cmdOpts.json ?? opts.json; const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); for (const warning of warnings) { - console.error(`${ctx.p('warn')}${warning}`); + if (!jsonOutput) { + console.error(`${ctx.p('warn')}${warning}`); + } } if (!cookies.authToken || !cookies.ct0) { - console.error(`${ctx.p('err')}Missing required credentials`); + if (jsonOutput) { + console.log(JSON.stringify({ success: false, error: 'Missing required credentials' })); + } else { + console.error(`${ctx.p('err')}Missing required credentials`); + } process.exit(1); } const client = new TwitterClient({ cookies, timeoutMs }); - let failures = 0; + const results: Array<{ tweetId: string; success: boolean; error?: string }> = []; for (const input of tweetIdOrUrls) { const tweetId = ctx.extractTweetId(input); const result = await client.deleteTweet(tweetId); if (result.success) { - console.log(`${ctx.p('ok')}Deleted tweet ${tweetId}`); + results.push({ tweetId, success: true }); + if (!jsonOutput) { + console.log(`${ctx.p('ok')}Deleted tweet ${tweetId}`); + } } else { - failures += 1; - console.error(`${ctx.p('err')}Failed to delete tweet ${tweetId}: ${result.error}`); + results.push({ tweetId, success: false, error: result.error }); + if (!jsonOutput) { + console.error(`${ctx.p('err')}Failed to delete tweet ${tweetId}: ${result.error}`); + } } } + if (jsonOutput) { + console.log(JSON.stringify(results.length === 1 ? results[0] : results, null, 2)); + } + + const failures = results.filter((r) => !r.success).length; if (failures > 0) { process.exit(1); } diff --git a/src/lib/twitter-client-posting.ts b/src/lib/twitter-client-posting.ts index ed4aea6..53cadbe 100644 --- a/src/lib/twitter-client-posting.ts +++ b/src/lib/twitter-client-posting.ts @@ -67,6 +67,7 @@ export function withPosting await this.ensureClientUserId(); let queryId = await this.getQueryId('DeleteTweet'); let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/DeleteTweet`; + const referer = `https://x.com/i/status/${tweetId}`; const variables = { tweet_id: tweetId, @@ -77,14 +78,14 @@ export function withPosting let body = buildBody(); try { - const headers = { ...this.getHeaders(), referer: 'https://x.com/' }; + const headers = { ...this.getHeaders(), referer }; let response = await this.fetchWithTimeout(urlWithOperation, { method: 'POST', headers, body, }); - // If 404, refresh query IDs and retry + // If 404, refresh query IDs and retry with operation URL if (response.status === 404) { await this.refreshQueryIds(); queryId = await this.getQueryId('DeleteTweet'); @@ -93,9 +94,18 @@ export function withPosting response = await this.fetchWithTimeout(urlWithOperation, { method: 'POST', - headers: { ...this.getHeaders(), referer: 'https://x.com/' }, + headers: { ...this.getHeaders(), referer }, body, }); + + // If still 404, fallback to generic GraphQL endpoint + if (response.status === 404) { + response = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, { + method: 'POST', + headers: { ...this.getHeaders(), referer }, + body, + }); + } } if (!response.ok) { @@ -115,6 +125,14 @@ export function withPosting }; } + // Verify the deletion was acknowledged + if (!data.data?.delete_tweet) { + return { + success: false, + error: 'Delete request succeeded but no confirmation returned', + }; + } + return { success: true }; } catch (error) { return {