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..c8f00dd --- /dev/null +++ b/src/commands/delete.ts @@ -0,0 +1,61 @@ +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') + .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) { + if (!jsonOutput) { + console.error(`${ctx.p('warn')}${warning}`); + } + } + + if (!cookies.authToken || !cookies.ct0) { + 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 }); + 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) { + results.push({ tweetId, success: true }); + if (!jsonOutput) { + console.log(`${ctx.p('ok')}Deleted tweet ${tweetId}`); + } + } else { + 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-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..53cadbe 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,88 @@ 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 referer = `https://x.com/i/status/${tweetId}`; + + const variables = { + tweet_id: tweetId, + dark_request: false, + }; + + const buildBody = () => JSON.stringify({ variables, queryId }); + let body = buildBody(); + + try { + const headers = { ...this.getHeaders(), referer }; + let response = await this.fetchWithTimeout(urlWithOperation, { + method: 'POST', + headers, + body, + }); + + // If 404, refresh query IDs and retry with operation URL + 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 }, + 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) { + 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(', '), + }; + } + + // 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 { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + private async createTweet( variables: Record, features: Record,