Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/cli/program.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,6 +20,7 @@ import { type CliContext, collectCookieSource } from './shared.js';
export const KNOWN_COMMANDS = new Set([
'tweet',
'reply',
'delete',
'query-ids',
'read',
'replies',
Expand Down Expand Up @@ -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);
Expand Down
61 changes: 61 additions & 0 deletions src/commands/delete.ts
Original file line number Diff line number Diff line change
@@ -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-id-or-url...>', '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);
}
});
}
1 change: 1 addition & 0 deletions src/lib/twitter-client-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
85 changes: 84 additions & 1 deletion src/lib/twitter-client-posting.ts
Original file line number Diff line number Diff line change
@@ -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<TweetResult>;
reply(text: string, replyToTweetId: string, mediaIds?: string[]): Promise<TweetResult>;
deleteTweet(tweetId: string): Promise<BookmarkMutationResult>;
}

export function withPosting<TBase extends AbstractConstructor<TwitterClientBase>>(
Expand Down Expand Up @@ -59,6 +60,88 @@ export function withPosting<TBase extends AbstractConstructor<TwitterClientBase>
return this.createTweet(variables, features);
}

/**
* Delete a tweet
*/
async deleteTweet(tweetId: string): Promise<BookmarkMutationResult> {
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<string, unknown>,
features: Record<string, boolean>,
Expand Down