Skill: ginhoor/discord-purge-bot (version 0.1.2)

--- name: discord-purge-bot description: "Operate a Discord message cleanup workflow with an official bot token and Discord HTTP API. Use when asked to purge guild channel history, delete messages by user/keyword/time window, run dry-run previews, execute guarded bulk deletion jobs, or recreate a channel for fast wipe. Do not use for self-bot cleanup or personal DM history wipe." --- # Discord Purge Bot Run controlled cleanup in Discord guild channels with safety gates and audit-friendly outputs. ## Safety Contract - Use only a bot token (`DISCORD_BOT_TOKEN` or `--token`). - Refuse user-account token flows and self-bot behavior. - Run `purge-preview.mjs` first for every destructive request. - Require explicit confirmation code before running deletion. - Abort if channel is not a guild channel. - Keep logs and JSON summaries for each run. ## Workflow 1. Collect scope: `channel-id`, optional `author-id`, `contains`, `regex`, `after`, `before`. 2. Read `references/discord-limits.md` before deletion runs. 3. Run preview to estimate impact and get confirm code. 4. Run deletion with confirm code. 5. Share summary: scanned, matched, deleted, failed, old-vs-recent split. ## Commands ### Preview ```bash node scripts/purge-preview.mjs \ --channel-id 123456789012345678 \ --author-id 987654321098765432 \ --contains "error" \ --after "2026-03-01T00:00:00Z" \ --max-scan 5000 \ --out ./tmp/purge-preview.json ``` ### Run Purge ```bash node scripts/purge-runner.mjs \ --channel-id 123456789012345678 \ --author-id 987654321098765432 \ --contains "error" \ --after "2026-03-01T00:00:00Z" \ --confirm "PURGE-XXXXXXXX" \ --state-file ./tmp/purge-state.json \ --out ./tmp/purge-result.json ``` ### Dry Run ```bash node scripts/purge-runner.mjs --channel-id 123456789012345678 --confirm "PURGE-XXXXXXXX" --dry-run ``` ### Clone or Nuke Channel ```bash node scripts/purge-nuke.mjs --channel-id 123456789012345678 --confirm "NUKE-XXXXXXXX" --out ./tmp/nuke.json node scripts/purge-nuke.mjs --channel-id 123456789012345678 --confirm "NUKE-XXXXXXXX" --delete-old --out ./tmp/nuke.json ``` ## Script Roles - `scripts/purge-preview.mjs`: scan channel messages, apply filters, return counts and confirm code. - `scripts/purge-runner.mjs`: execute deletion with 2-week split (`bulk-delete` + single delete fallback). - `scripts/purge-nuke.mjs`: create replacement channel; optionally delete original channel. - `scripts/scan-filter.mjs`: reusable scan and filtering logic. - `scripts/discord-api.mjs`: Discord API wrapper with rate-limit retries. - `scripts/job-code.mjs`: deterministic confirm code helpers. ## Operator Rules - Keep `max-scan` bounded for broad channels. - Use `--state-file` on long jobs. - Prefer content/user/time filters over whole-channel wipes. - Use nuke mode only when preserving channel history is unnecessary. - Treat pinned messages as protected unless `--include-pinned` is set. ## Troubleshooting - 401/403: verify bot token and channel permissions. - Empty preview with expected history: check `READ_MESSAGE_HISTORY`. - Heavy 429: reduce parallelism and keep retry handling enabled. - `bulk-delete` failures: expect messages older than 14 days, runner falls back to single deletes. - Cannot reach Discord behind a proxy: export `HTTP_PROXY`/`HTTPS_PROXY` (uppercase recommended) and run with `NODE_USE_ENV_PROXY=1`, or use a recent Node that supports `setGlobalProxyFromEnv()`. - If your proxy tool exposes both HTTP and SOCKS ports, point `HTTP_PROXY`/`HTTPS_PROXY` at the HTTP port; `ALL_PROXY=socks5://...` alone is not enough for this skill. ### Proxy Example ```bash export HTTP_PROXY=http://127.0.0.1:7890 export HTTPS_PROXY=http://127.0.0.1:7890 export NODE_USE_ENV_PROXY=1 node scripts/purge-preview.mjs --channel-id 123456789012345678 --max-scan 200 ```

path: README.md
content: # discord-purge-bot

OpenClaw skill for controlled Discord message cleanup with preview, guarded execution, and audit-friendly output.

## Usage
See `SKILL.md` for workflow and commands.


path: agents/openai.yaml
content: interface:
  display_name: "Discord Purge Bot"
  short_description: "Safely purge Discord channel history"
  default_prompt: "Use $discord-purge-bot to preview or purge guild channel messages with scoped filters and a confirmation code."


path: references/discord-limits.md
content: # Discord Cleanup Limits

## Required Permissions

- `VIEW_CHANNEL`
- `READ_MESSAGE_HISTORY`
- `MANAGE_MESSAGES` for deleting others' messages
- `MANAGE_CHANNELS` for channel recreation (`purge-nuke.mjs`)

## Core Endpoints

- List messages: `GET /channels/{channel.id}/messages`
- Delete one message: `DELETE /channels/{channel.id}/messages/{message.id}`
- Bulk delete: `POST /channels/{channel.id}/messages/bulk-delete`
- Get channel: `GET /channels/{channel.id}`
- Create guild channel: `POST /guilds/{guild.id}/channels`
- Delete channel: `DELETE /channels/{channel.id}`

## Bulk Delete Rules

- Works only in guild channels.
- Message count per request: 2 to 100 IDs.
- Rejects messages older than 2 weeks.
- Rejects duplicate IDs in the same request.

## Rate Limit Rules

- Never hard-code limits.
- Respect `429` and use `Retry-After` or `retry_after`.
- Use `X-RateLimit-*` headers to avoid bursts.
- Expect global and per-route buckets.

## Operational Guidance

- Always run preview before destructive execution.
- Split delete plan into recent (bulk) and old (single).
- Persist results and errors in JSON artifacts.
- Use bounded scans (`max-scan`) for large channels.


path: scripts/common.mjs
content: import fs from 'node:fs/promises';
import path from 'node:path';

export const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000;

export function parseArgs(argv = process.argv.slice(2)) {
  const args = {};
  for (let index = 0; index < argv.length; index += 1) {
    const [REDACTED_SECRET];
    if (!token.startsWith('--')) {
      if (!Array.isArray(args._)) args._ = [];
      args._.push(token);
      continue;
    }

    const rawKey = token.slice(2);
    if (rawKey.includes('=')) {
      const [key, ...rest] = rawKey.split('=');
      args[key] = rest.join('=');
      continue;
    }

    const next = argv[index + 1];
    if (!next || next.startsWith('--')) {
      args[rawKey] = true;
      continue;
    }

    args[rawKey] = next;
    index += 1;
  }
  return args;
}

export function asBoolean(value, fallback = false) {
  if (value === undefined || value === null) return fallback;
  if (typeof value === 'boolean') return value;
  const normalized = String(value).trim().toLowerCase();
  if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
  if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
  return fallback;
}

export function asInteger(value, fallback) {
  if (value === undefined || value === null || value === '') return fallback;
  const parsed = Number.parseInt(String(value), 10);
  if (Number.isNaN(parsed)) return fallback;
  return parsed;
}

export function parseDateToTs(value, fallback = undefined) {
  if (!value) return fallback;
  const timestamp = Date.parse(value);
  if (Number.isNaN(timestamp)) {
    throw new Error(`Invalid date value: ${value}`);
  }
  return timestamp;
}

export function requireArg(args, key, message) {
  const value = args[key];
  if (value === undefined || value === null || value === '') {
    throw new Error(message ?? `Missing required argument --${key}`);
  }
  return String(value);
}

export function resolveToken(args) {
  const value = args.token || process.env.DISCORD_BOT_TOKEN;
  if (!value) {
    throw new Error('Missing Discord token. Provide --token or DISCORD_BOT_TOKEN.');
  }
  return String(value).trim();
}

export function normalizeToken(token) {
  return token.startsWith('Bot ') ? token : `Bot ${token}`;
}

export async function sleep(milliseconds) {
  const duration = Math.max(0, Number(milliseconds) || 0);
  if (duration === 0) return;
  await new Promise((resolve) => setTimeout(resolve, duration));
}

export function chunkList(input, size) {
  const chunkSize = Math.max(1, size);
  const output = [];
  for (let start = 0; start < input.length; start += chunkSize) {
    output.push(input.slice(start, start + chunkSize));
  }
  return output;
}

export function sanitizeObject(input) {
  return Object.fromEntries(
    Object.entries(input).filter(([, value]) => value !== undefined)
  );
}

export function isOlderThanTwoWeeks(messageTimestamp, nowTs = Date.now()) {
  return nowTs - Date.parse(messageTimestamp) > TWO_WEEKS_MS;
}

export async function writeJson(filePath, data) {
  const outputPath = path.resolve(filePath);
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
  await fs.writeFile(outputPath, JSON.stringify(data, null, 2), 'utf8');
}

export function printJson(data) {
  process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
}


path: scripts/discord-api.mjs
content: import * as http from 'node:http';

import { normalizeToken, sanitizeObject, sleep } from './common.mjs';

const API_BASE = 'https://discord.com/api/v10';

function enableProxyFromEnvironment() {
  const hasProxyEnv = [
    'HTTP_PROXY',
    'http_proxy',
    'HTTPS_PROXY',
    'https_proxy',
    'NO_PROXY',
    'no_proxy',
  ].some((name) => Boolean(process.env[name]));

  if (!hasProxyEnv || typeof http.setGlobalProxyFromEnv !== 'function') {
    return;
  }

  try {
    http.setGlobalProxyFromEnv();
  } catch (error) {
    process.emitWarning(`Failed to configure proxy from environment: ${error.message}`);
  }
}

enableProxyFromEnvironment();

function buildUrl(pathname, query) {
  const url = new URL(pathname, API_BASE);
  const queryValues = sanitizeObject(query ?? {});
  for (const [key, value] of Object.entries(queryValues)) {
    url.searchParams.set(key, String(value));
  }
  return url;
}

async function parseResponseBody(response) {
  const contentType = response.headers.get('content-type') ?? '';
  if (response.status === 204) return null;
  if (contentType.includes('application/json')) return response.json();
  return response.text();
}

async function parseRetryAfter(response) {
  const retryHeader = response.headers.get('retry-after');
  if (retryHeader) {
    const retryHeaderNumber = Number.parseFloat(retryHeader);
    if (!Number.isNaN(retryHeaderNumber)) return retryHeaderNumber * 1000;
  }

  try {
    const data = await response.clone().json();
    const retryAfter = Number(data.retry_after);
    if (!Number.isNaN(retryAfter)) return retryAfter * 1000;
  } catch {
    return 1000;
  }

  return 1000;
}

export async function discordRequest({ token, method = 'GET', path, query, body, reason, retryLimit = 8 }) {
  const requestUrl = buildUrl(path, query);
  const headers = {
    [REDACTED_SECRET](token),
  };

  if (body !== undefined) {
    headers['Content-Type'] = 'application/json';
  }

  if (reason) {
    headers['X-Audit-Log-Reason'] = encodeURIComponent(reason);
  }

  let attempts = 0;
  while (true) {
    let response;
    try {
      response = await fetch(requestUrl, {
        method,
        headers,
        body: body === undefined ? undefined : JSON.stringify(body),
      });
    } catch (error) {
      attempts += 1;
      if (attempts > retryLimit) throw error;
      await sleep(750 * attempts);
      continue;
    }

    if (response.status === 429) {
      attempts += 1;
      if (attempts > retryLimit) {
        const bodyText = await response.text();
        throw new Error(`Discord API 429 after retries: ${bodyText}`);
      }
      const waitMs = await parseRetryAfter(response);
      await sleep(waitMs + 100);
      continue;
    }

    if (response.status >= 500 && response.status <= 504) {
      attempts += 1;
      if (attempts > retryLimit) {
        const bodyText = await response.text();
        throw new Error(`Discord API ${response.status} after retries: ${bodyText}`);
      }
      await sleep(500 * attempts);
      continue;
    }

    const remaining = Number.parseInt(response.headers.get('x-ratelimit-remaining') ?? '', 10);
    const resetAfter = Number.parseFloat(response.headers.get('x-ratelimit-reset-after') ?? '');

    if (!response.ok) {
      const failedBody = await parseResponseBody(response);
      const error = new Error(`Discord API ${response.status} ${method} ${requestUrl.pathname}`);
      error.status = response.status;
      error.body = failedBody;
      throw error;
    }

    const payload = await parseResponseBody(response);

    if (!Number.isNaN(remaining) && remaining === 0 && !Number.isNaN(resetAfter) && resetAfter > 0) {
      await sleep(resetAfter * 1000);
    }

    return payload;
  }
}

export async function fetchMessagesPage({ token, channelId, before, after, around, limit = 100 }) {
  return discordRequest({
    token,
    method: 'GET',
    path: `/channels/${channelId}/messages`,
    query: {
      before,
      after,
      around,
      limit,
    },
  });
}

export async function bulkDeleteMessages({ token, channelId, messageIds, reason }) {
  return discordRequest({
    token,
    method: 'POST',
    path: `/channels/${channelId}/messages/bulk-delete`,
    body: {
      messages: messageIds,
    },
    reason,
  });
}

export async function deleteMessage({ token, channelId, messageId, reason }) {
  return discordRequest({
    token,
    method: 'DELETE',
    path: `/channels/${channelId}/messages/${messageId}`,
    reason,
  });
}

export async function getChannel({ token, channelId }) {
  return discordRequest({
    token,
    method: 'GET',
    path: `/channels/${channelId}`,
  });
}

export async function createGuildChannel({ token, guildId, body, reason }) {
  return discordRequest({
    token,
    method: 'POST',
    path: `/guilds/${guildId}/channels`,
    body,
    reason,
  });
}

export async function deleteChannel({ token, channelId, reason }) {
  return discordRequest({
    token,
    method: 'DELETE',
    path: `/channels/${channelId}`,
    reason,
  });
}


path: scripts/job-code.mjs
content: import crypto from 'node:crypto';

function normalizeValue(value) {
  if (value === undefined || value === null || value === '') return '*';
  return String(value);
}

export function buildConfirmCode({
  channelId,
  authorId,
  contains,
  regex,
  after,
  before,
  includePinned,
}) {
  const raw = [
    normalizeValue(channelId),
    normalizeValue(authorId),
    normalizeValue(contains),
    normalizeValue(regex),
    normalizeValue(after),
    normalizeValue(before),
    normalizeValue(includePinned),
  ].join('|');

  const hash = crypto.createHash('sha1').update(raw).digest('hex').slice(0, 8).toUpperCase();
  return `PURGE-${hash}`;
}

export function buildNukeCode({ channelId }) {
  const hash = crypto.createHash('sha1').update(normalizeValue(channelId)).digest('hex').slice(0, 8).toUpperCase();
  return `NUKE-${hash}`;
}

export function buildJobId(prefix = 'purge') {
  const timestamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
  const random = crypto.randomBytes(2).toString('hex');
  return `${prefix}-${timestamp}-${random}`;
}


path: scripts/purge-nuke.mjs
content: #!/usr/bin/env node
import { parseArgs, printJson, resolveToken, sanitizeObject, writeJson } from './common.mjs';
import { createGuildChannel, deleteChannel, getChannel } from './discord-api.mjs';
import { buildNukeCode } from './job-code.mjs';

const helpText = `Usage:
  node scripts/purge-nuke.mjs --channel-id <id> --confirm <code> [options]

Options:
  --token <token>            Discord bot token (or use DISCORD_BOT_TOKEN)
  --channel-id <id>          Target guild channel id
  --confirm <code>           Confirmation code
  --delete-old               Delete original channel after clone
  --reason <text>            Audit log reason
  --out <path>               Persist result JSON
  --help                     Show this message
`;

function buildClonePayload(channel) {
  return sanitizeObject({
    name: channel.name,
    type: channel.type,
    topic: channel.topic,
    bitrate: channel.bitrate,
    user_limit: channel.user_limit,
    rate_limit_per_user: channel.rate_limit_per_user,
    position: channel.position,
    permission_overwrites: channel.permission_overwrites,
    parent_id: channel.parent_id,
    nsfw: channel.nsfw,
    rtc_region: channel.rtc_region,
    video_quality_mode: channel.video_quality_mode,
    default_auto_archive_duration: channel.default_auto_archive_duration,
    default_thread_rate_limit_per_user: channel.default_thread_rate_limit_per_user,
    default_sort_order: channel.default_sort_order,
    default_forum_layout: channel.default_forum_layout,
    available_tags: channel.available_tags,
    default_reaction_emoji: channel.default_reaction_emoji,
    default_tag_ids: channel.default_tag_ids,
    flags: channel.flags,
  });
}

async function main() {
  const args = parseArgs();

  if (args.help) {
    process.stdout.write(helpText);
    return;
  }

  const channelId = String(args['channel-id'] ?? '');
  if (!channelId) throw new Error('Missing required argument --channel-id');

  const [REDACTED_SECRET]);
  const expectedCode = buildNukeCode({ channelId });
  const providedCode = String(args.confirm ?? '');

  if (!providedCode) throw new Error('Missing required argument --confirm');
  if (providedCode !== expectedCode) {
    throw new Error(`Confirmation mismatch. Expected ${expectedCode}`);
  }

  const channel = await getChannel({ token, channelId });
  if (!channel.guild_id) {
    throw new Error('Target channel is not in a guild. Nuke flow supports guild channels only.');
  }

  const clonePayload = buildClonePayload(channel);
  const reason = args.reason ? String(args.reason) : undefined;
  const newChannel = await createGuildChannel({
    token,
    guildId: String(channel.guild_id),
    body: clonePayload,
    reason,
  });

  let deletedOriginal = false;
  if (args['delete-old']) {
    await deleteChannel({ token, channelId, reason });
    deletedOriginal = true;
  }

  const result = {
    generatedAt: new Date().toISOString(),
    guildId: String(channel.guild_id),
    originalChannelId: channelId,
    newChannelId: String(newChannel.id),
    deletedOriginal,
    expectedConfirmCode: expectedCode,
  };

  if (args.out) {
    await writeJson(String(args.out), result);
  }

  printJson(result);
}

main().catch((error) => {
  process.stderr.write(`${error.message}\n`);
  process.exitCode = 1;
});


path: scripts/purge-preview.mjs
content: #!/usr/bin/env node
import { buildConfirmCode, buildJobId } from './job-code.mjs';
import { normalizeFilters, scanMessages, splitMessagesByAge } from './scan-filter.mjs';
import { parseArgs, printJson, resolveToken, writeJson } from './common.mjs';

const helpText = `Usage:
  node scripts/purge-preview.mjs --channel-id <id> [options]

Options:
  --token <token>            Discord bot token (or use DISCORD_BOT_TOKEN)
  --channel-id <id>          Target guild channel id
  --author-id <id>           Only include messages from this author
  --contains <text>          Include messages containing text
  --regex <pattern>          Include messages matching regex pattern
  --regexFlags <flags>       Regex flags (default: i)
  --after <iso>              Include messages after datetime
  --before <iso>             Include messages before datetime
  --include-pinned           Include pinned messages
  --max-scan <n>             Max scanned messages (default: 5000)
  --max-matches <n>          Stop after this many matches
  --out <path>               Write JSON to file
  --help                     Show this message
`;

async function main() {
  const args = parseArgs();

  if (args.help) {
    process.stdout.write(helpText);
    return;
  }

  if (!args['channel-id']) {
    throw new Error('Missing required argument --channel-id');
  }

  const [REDACTED_SECRET]);
  const channelId = String(args['channel-id']);
  const filters = normalizeFilters(args);

  const { scannedCount, pageCount, matchedMessages } = await scanMessages({
    token,
    channelId,
    filters,
  });

  const { recentMessages, oldMessages } = splitMessagesByAge(matchedMessages);

  const confirmCode = buildConfirmCode({
    channelId,
    authorId: args['author-id'],
    contains: args.contains,
    regex: args.regex,
    after: args.after,
    before: args.before,
    includePinned: filters.includePinned,
  });

  const result = {
    jobId: buildJobId('preview'),
    generatedAt: new Date().toISOString(),
    channelId,
    filters: {
      authorId: args['author-id'] ?? null,
      contains: args.contains ?? null,
      regex: args.regex ?? null,
      regexFlags: args.regexFlags ?? 'i',
      after: args.after ?? null,
      before: args.before ?? null,
      includePinned: filters.includePinned,
      maxScan: filters.maxScan,
      maxMatches: filters.maxMatches ?? null,
    },
    scannedCount,
    pageCount,
    matchedCount: matchedMessages.length,
    deletableRecentCount: recentMessages.length,
    needsSingleDeleteCount: oldMessages.length,
    estimatedBulkRequests: Math.ceil(Math.max(0, recentMessages.length - 1) / 100),
    estimatedSingleRequests: oldMessages.length,
    confirmCode,
    runCommand: `node scripts/purge-runner.mjs --channel-id ${channelId} --confirm ${confirmCode}`,
  };

  if (args.out) {
    await writeJson(String(args.out), result);
  }

  printJson(result);
}

main().catch((error) => {
  process.stderr.write(`${error.message}\n`);
  process.exitCode = 1;
});


path: scripts/purge-runner.mjs
content: #!/usr/bin/env node
import { buildConfirmCode, buildJobId } from './job-code.mjs';
import {
  asBoolean,
  chunkList,
  parseArgs,
  printJson,
  resolveToken,
  sleep,
  writeJson,
} from './common.mjs';
import { bulkDeleteMessages, deleteMessage } from './discord-api.mjs';
import { normalizeFilters, scanMessages, splitMessagesByAge } from './scan-filter.mjs';

const helpText = `Usage:
  node scripts/purge-runner.mjs --channel-id <id> --confirm <code> [options]

Options:
  --token <token>            Discord bot token (or use DISCORD_BOT_TOKEN)
  --channel-id <id>          Target guild channel id
  --confirm <code>           Confirmation code from preview
  --author-id <id>           Only include messages from this author
  --contains <text>          Include messages containing text
  --regex <pattern>          Include messages matching regex pattern
  --regexFlags <flags>       Regex flags (default: i)
  --after <iso>              Include messages after datetime
  --before <iso>             Include messages before datetime
  --include-pinned           Include pinned messages
  --max-scan <n>             Max scanned messages (default: 5000)
  --max-matches <n>          Stop after this many matches
  --state-file <path>        Persist run state JSON
  --out <path>               Persist final result JSON
  --reason <text>            Audit log reason
  --dry-run                  Do not delete messages
  --single-only              Disable bulk-delete
  --delete-delay-ms <n>      Delay after each delete request
  --help                     Show this message
`;

function summarizeFilters(args, filters) {
  return {
    authorId: args['author-id'] ?? null,
    contains: args.contains ?? null,
    regex: args.regex ?? null,
    regexFlags: args.regexFlags ?? 'i',
    after: args.after ?? null,
    before: args.before ?? null,
    includePinned: filters.includePinned,
    maxScan: filters.maxScan,
    maxMatches: filters.maxMatches ?? null,
  };
}

async function writeState(stateFile, state) {
  if (!stateFile) return;
  await writeJson(stateFile, state);
}

async function deleteRecentMessages({ token, channelId, messages, reason, singleOnly, deleteDelayMs }) {
  let deleted = 0;
  let failed = 0;

  const ids = Array.from(new Set(messages.map((message) => message.id)));
  const batches = chunkList(ids, 100);

  for (const batch of batches) {
    if (singleOnly || batch.length === 1) {
      for (const messageId of batch) {
        try {
          await deleteMessage({ token, channelId, messageId, reason });
          deleted += 1;
        } catch (error) {
          failed += 1;
          process.stderr.write(`Failed single delete for ${messageId}: ${error.message}\n`);
        }
        await sleep(deleteDelayMs);
      }
      continue;
    }

    try {
      await bulkDeleteMessages({ token, channelId, messageIds: batch, reason });
      deleted += batch.length;
    } catch (error) {
      process.stderr.write(`Bulk delete failed for ${batch.length} messages: ${error.message}\n`);
      for (const messageId of batch) {
        try {
          await deleteMessage({ token, channelId, messageId, reason });
          deleted += 1;
        } catch (singleError) {
          failed += 1;
          process.stderr.write(`Fallback single delete failed for ${messageId}: ${singleError.message}\n`);
        }
        await sleep(deleteDelayMs);
      }
    }
  }

  return { deleted, failed };
}

async function deleteOldMessages({ token, channelId, messages, reason, deleteDelayMs }) {
  let deleted = 0;
  let failed = 0;

  for (const message of messages) {
    try {
      await deleteMessage({ token, channelId, messageId: message.id, reason });
      deleted += 1;
    } catch (error) {
      failed += 1;
      process.stderr.write(`Failed old-message delete for ${message.id}: ${error.message}\n`);
    }
    await sleep(deleteDelayMs);
  }

  return { deleted, failed };
}

async function main() {
  const args = parseArgs();

  if (args.help) {
    process.stdout.write(helpText);
    return;
  }

  const channelId = String(args['channel-id'] ?? '');
  if (!channelId) throw new Error('Missing required argument --channel-id');

  const [REDACTED_SECRET]);
  const filters = normalizeFilters(args);
  const dryRun = asBoolean(args['dry-run'], false);
  const singleOnly = asBoolean(args['single-only'], false);
  const deleteDelayMs = Number.parseInt(String(args['delete-delay-ms'] ?? '0'), 10) || 0;

  const expectedCode = buildConfirmCode({
    channelId,
    authorId: args['author-id'],
    contains: args.contains,
    regex: args.regex,
    after: args.after,
    before: args.before,
    includePinned: filters.includePinned,
  });

  const providedCode = String(args.confirm ?? '');
  if (!providedCode) throw new Error('Missing required argument --confirm');
  if (providedCode !== expectedCode) {
    throw new Error(`Confirmation mismatch. Expected ${expectedCode}`);
  }

  const stateFile = args['state-file'] ? String(args['state-file']) : undefined;
  const result = {
    jobId: buildJobId('run'),
    startedAt: new Date().toISOString(),
    phase: 'scanning',
    channelId,
    filters: summarizeFilters(args, filters),
    dryRun,
    singleOnly,
    scannedCount: 0,
    matchedCount: 0,
    recentCount: 0,
    oldCount: 0,
    deletedCount: 0,
    failedCount: 0,
    expectedConfirmCode: expectedCode,
  };

  await writeState(stateFile, result);

  const scanResult = await scanMessages({
    token,
    channelId,
    filters,
    onPage: async (progress) => {
      result.scannedCount = progress.scannedCount;
      result.matchedCount = progress.matchedCount;
      await writeState(stateFile, result);
    },
  });

  result.scannedCount = scanResult.scannedCount;
  result.matchedCount = scanResult.matchedMessages.length;

  const split = splitMessagesByAge(scanResult.matchedMessages);
  result.recentCount = split.recentMessages.length;
  result.oldCount = split.oldMessages.length;

  if (dryRun) {
    result.phase = 'done';
    result.finishedAt = new Date().toISOString();
    await writeState(stateFile, result);
    if (args.out) await writeJson(String(args.out), result);
    printJson(result);
    return;
  }

  result.phase = 'deleting-recent';
  await writeState(stateFile, result);

  const recentDelete = await deleteRecentMessages({
    token,
    channelId,
    messages: split.recentMessages,
    reason: args.reason ? String(args.reason) : undefined,
    singleOnly,
    deleteDelayMs,
  });

  result.deletedCount += recentDelete.deleted;
  result.failedCount += recentDelete.failed;
  await writeState(stateFile, result);

  result.phase = 'deleting-old';
  await writeState(stateFile, result);

  const oldDelete = await deleteOldMessages({
    token,
    channelId,
    messages: split.oldMessages,
    reason: args.reason ? String(args.reason) : undefined,
    deleteDelayMs,
  });

  result.deletedCount += oldDelete.deleted;
  result.failedCount += oldDelete.failed;
  result.phase = 'done';
  result.finishedAt = new Date().toISOString();

  await writeState(stateFile, result);
  if (args.out) await writeJson(String(args.out), result);
  printJson(result);
}

main().catch((error) => {
  process.stderr.write(`${error.message}\n`);
  process.exitCode = 1;
});


path: scripts/scan-filter.mjs
content: import { asBoolean, asInteger, isOlderThanTwoWeeks, parseDateToTs } from './common.mjs';
import { fetchMessagesPage } from './discord-api.mjs';

export function normalizeFilters(args) {
  const contains = args.contains ? String(args.contains) : undefined;
  const regexPattern = args.regex ? String(args.regex) : undefined;
  const regexFlags = args.regexFlags ? String(args.regexFlags) : 'i';
  const afterTs = parseDateToTs(args.after, undefined);
  const beforeTs = parseDateToTs(args.before, undefined);

  let regex;
  if (regexPattern) {
    regex = new RegExp(regexPattern, regexFlags);
  }

  return {
    authorId: args['author-id'] ? String(args['author-id']) : undefined,
    contains,
    regex,
    afterTs,
    beforeTs,
    includePinned: asBoolean(args['include-pinned'], false),
    maxScan: asInteger(args['max-scan'], 5000),
    maxMatches: asInteger(args['max-matches'], undefined),
  };
}

export function matchesFilter(message, filters) {
  if (!filters.includePinned && message.pinned) return false;

  if (filters.authorId && message.author?.id !== filters.authorId) return false;

  const content = message.content ?? '';
  if (filters.contains && !content.toLowerCase().includes(filters.contains.toLowerCase())) {
    return false;
  }

  if (filters.regex && !filters.regex.test(content)) {
    return false;
  }

  const messageTs = Date.parse(message.timestamp);
  if (filters.afterTs !== undefined && messageTs < filters.afterTs) return false;
  if (filters.beforeTs !== undefined && messageTs > filters.beforeTs) return false;

  return true;
}

export async function scanMessages({ token, channelId, filters, beforeCursor, onPage }) {
  const matchedMessages = [];
  let scannedCount = 0;
  let pageCount = 0;
  let cursor = beforeCursor;

  while (true) {
    const remaining = filters.maxScan === undefined ? 100 : filters.maxScan - scannedCount;
    if (remaining !== undefined && remaining <= 0) break;

    const pageLimit = Math.min(100, Math.max(1, remaining ?? 100));
    const page = await fetchMessagesPage({
      token,
      channelId,
      before: cursor,
      limit: pageLimit,
    });

    if (!Array.isArray(page) || page.length === 0) break;

    scannedCount += page.length;
    pageCount += 1;

    for (const message of page) {
      if (matchesFilter(message, filters)) {
        matchedMessages.push(message);
      }
    }

    cursor = page[page.length - 1].id;

    if (typeof onPage === 'function') {
      await onPage({
        pageCount,
        scannedCount,
        matchedCount: matchedMessages.length,
        cursor,
      });
    }

    if (filters.maxMatches !== undefined && matchedMessages.length >= filters.maxMatches) {
      break;
    }
  }

  return {
    scannedCount,
    pageCount,
    matchedMessages,
    cursor,
  };
}

export function splitMessagesByAge(messages, nowTs = Date.now()) {
  const recentMessages = [];
  const oldMessages = [];

  for (const message of messages) {
    if (isOlderThanTwoWeeks(message.timestamp, nowTs)) {
      oldMessages.push(message);
    } else {
      recentMessages.push(message);
    }
  }

  return { recentMessages, oldMessages };
}


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: clean (LOW)