Build a Private Discord Digest

Build a private bot that summarizes Discord channel activity each day.

Introduction

30 Second Summary

A busy group chat can bury yesterday's useful updates beneath hundreds of new messages. Catching up often means scrolling through conversations that have already moved on.

In this project, you will build a private daily digest for a permitted Discord channel. Your bot will process a 24-hour window in memory before posting a compact report to an admin-only channel.

What You'll Build

You will run one command to see a fresh report of the last day's activity in your private Discord channel.

By the end of this project, you'll have:

  • A private daily digest that shows activity, contributors, topics, highlights, and shared links. Mention-like text in excerpts will stay silent.
  • Complete REST pagination that retrieves every qualifying message in the 24-hour window when the source channel exceeds 100 messages.
  • A non-overlapping local schedule that posts at your chosen time while your Mac remains awake.
  • Secret Mission: Expand the digest across at least three permitted channels. The combined report will preserve channel context while ranking every message together.

Are there any prerequisites?

You need a Mac with Node.js plus VS Code. You also need an existing Discord developer app with administrator access to your target server.

Get permission from the server owner and members before processing messages for this digest.

Before We Start

A clear privacy boundary keeps your Discord digest focused on messages that members have permitted you to process. This short commitment defines one source channel and its private purpose before any hands-on work begins.

Set Up the Private Bot Workspace

Your private Discord digest handles member messages. It also relies on a credential that controls your bot.

A current Node.js runtime keeps the pinned libraries compatible. A protected local configuration keeps the bot token out of Git.

The discord.js library provides the REST client for Discord. node-cron later runs the digest on a local schedule.

In this step, get ready to:
  • Verify Node.js plus npm.
  • Create the protected project workspace with pinned dependencies.
  • Give the bot narrow access to one source channel plus the private digest channel.
Verify the runtime and create the project

The project targets Node.js v24.22.1 so both dependencies run on a supported runtime. npm installs the exact versions declared in package.json.

  • Press Cmd+Space to open Spotlight on your Mac.
  • Type Terminal into Spotlight.
  • Press Enter to open Terminal.
  • Check the installed Node.js plus npm versions by running:
node --version
npm --version

What do these checks prove?

  • The first command confirms which Node.js runtime executes the project.
  • The second command confirms npm is available to install the pinned libraries.

Choose the tab that matches your Node.js result.

✔️ I see v24.22.1

Your runtime matches the project target. Keep this Terminal window available for the dependency installation.

ⓧ I see an older version

Your current runtime is below the project target. The official installer updates the active Node.js installation on your Mac.

  • Open the Node.js download page in your browser.
  • Download the macOS installer for Node.js v24.22.1 LTS.
  • Run the downloaded installer.
  • Return to Terminal after the installation finishes.
  • Repeat the version checks above.

ⓧ Command not found

Your Mac cannot find Node.js yet. Installing the current LTS release also provides npm.

  • Open the Node.js download page in your browser.
  • Download the macOS installer for Node.js v24.22.1 LTS.
  • Run the downloaded installer.
  • Close Terminal after the installation finishes.
  • Reopen Terminal through Spotlight.
  • Repeat the version checks above.

Your code will live inside a named folder on the Desktop. VS Code keeps that folder visible while you create each project file.

  • Press Cmd+Space to open Spotlight.
  • Type VS Code into Spotlight.
  • Press Enter to open VS Code.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select your Desktop in the folder dialog.
  • Create a folder named private-discord-digest.
  • Select private-discord-digest as the folder to open.

You should see private-discord-digest at the top of the Explorer sidebar.

  • Click the New File icon in the Explorer sidebar.
  • Name the file package.json.
  • Define the project scripts plus pinned dependencies by pasting:
{
  "name": "private-discord-digest",
  "private": true,
  "type": "module",
  "scripts": {
    "digest": "node src/index.js --once",
    "start": "node src/index.js"
  },
  "dependencies": {
    "discord.js": "14.27.0",
    "node-cron": "4.6.0"
  }
}

What does this file control?

  • The module setting allows the project to use JavaScript import syntax.
  • The digest script reserves the immediate one-shot path for later steps.
  • The start script reserves the long-running scheduler path.
  • The dependency declarations pin discord.js to 14.27.0 plus node-cron to 4.6.0.
  • Save package.json.
  • Click Terminal in the VS Code menu bar.
  • Click New Terminal.
  • Install the pinned project dependencies by running:
npm install

What does this command do?

npm reads the exact dependency declarations from package.json. It installs those packages inside node_modules.

Good progress. Your workspace now has the exact libraries used by the digest.

Dependency installation failing?

Confirm the VS Code terminal prompt points to private-discord-digest. Check that package.json has no red syntax markers.

If the failure mentions network access, confirm your Mac can reach the npm package service.

Help me diagnose the npm installation failure

The token belongs only in the local configuration. The ignore file protects it from future Git commits while also excluding installed dependencies.

  • Click the New File icon in the Explorer sidebar.
  • Name the file .gitignore.
  • Add the protected file rules by pasting:
node_modules/
config.json
.DS_Store

What do these rules protect?

  • The dependency folder stays outside version control because npm can rebuild it.
  • The local configuration stays outside version control because it contains the bot token.
  • The macOS metadata file stays out of the project history.
  • Save .gitignore.
  • Confirm .gitignore appears in the Explorer sidebar.

A shareable configuration template documents every required setting. Its placeholder token is safe because it cannot authenticate a bot.

  • Click the New File icon in the Explorer sidebar.
  • Name the file config.example.json.
  • Create the safe configuration template by pasting:
{
  "botToken": "your-bot-token-here",
  "sourceChannelIds": [
    "paste-source-channel-id-here"
  ],
  "digestChannelId": "paste-digest-channel-id-here",
  "lookbackHours": 24,
  "cron": "0 8 * * *"
}

What does this template define?

  • The token field authenticates the bot.
  • The source channel array limits which configured channel the digest processes.
  • The digest channel field identifies the private destination.
  • The final settings define a 24-hour window plus a daily local schedule.
  • Save config.example.json.
  • Confirm config.example.json appears in the Explorer sidebar.
  • Click the New Folder icon in the Explorer sidebar.
  • Name the folder src.

You should now see package.json, .gitignore, config.example.json, node_modules, plus the empty src folder in Explorer.

Use these tabs to confirm the shareable project files before configuring the secret local copy.

✔️ Awesome, I've got everything!

Your three shareable files are saved. Your empty src folder is ready for the first working fetch in the next step.

ⓧ I'd like to double check the full code

Compare each file with the complete versions below. Keep every dependency pin plus placeholder exactly as shown.

{
  "name": "private-discord-digest",
  "private": true,
  "type": "module",
  "scripts": {
    "digest": "node src/index.js --once",
    "start": "node src/index.js"
  },
  "dependencies": {
    "discord.js": "14.27.0",
    "node-cron": "4.6.0"
  }
}

What should match in package.json?

The project uses ES modules. Both scripts plus both pinned dependencies must match this reference.

node_modules/
config.json
.DS_Store

What should match in .gitignore?

The file contains three rules. The config.json rule is the safeguard for your real bot token.

{
  "botToken": "your-bot-token-here",
  "sourceChannelIds": [
    "paste-source-channel-id-here"
  ],
  "digestChannelId": "paste-digest-channel-id-here",
  "lookbackHours": 24,
  "cron": "0 8 * * *"
}

What should match in config.example.json?

This public template retains placeholders. The real credential plus channel IDs belong in the ignored config.json file created later in this step.

Give the bot narrow Discord access

Discord checks the bot token before every API request. Message Content Intent allows the REST response to include the text needed for this digest.

Resetting a bot token invalidates the previous credential. Store any newly revealed token in your password manager immediately.

  • Open the Discord Developer Portal in your browser.
  • Select your existing developer app.
  • Select Bot in the left navigation.
  • Enable Message Content Intent.
  • Save the intent change.
  • Confirm that you still have the bot token stored securely.
  • Click Reset Token only if the existing token is unavailable.
  • Complete the account confirmation if Discord requests it.

If you reset the token, Discord reveals the replacement once. Keep the credential away from screenshots plus chat messages.

  • Copy the newly revealed token into your password manager if you reset it.
  • Close the token view after the credential is stored.
  • Select Installation in the left navigation.
  • Enable Guild Install.
  • Add the bot scope.
  • Leave installation-level bot permissions unselected.
  • Open the Discord Provided Link.
  • Select the target server.
  • Authorize the installation.
  • Confirm the bot appears in the server member list.

Why use narrow channel permissions?

The bot needs history access only where the digest reads messages. The private destination needs permission to receive the finished report.

Channel-specific permission overwrites keep the bot focused on its stated function. The local source channel list adds a second processing boundary.

The digest destination holds derived member content. Creating it as a private channel limits who can read each report.

  • Press Cmd+Space to open Spotlight.
  • Type Discord into Spotlight.
  • Press Enter to open Discord desktop.
  • Select the target server.
  • Open the server menu.
  • Select Create Channel.
  • Choose a text channel.
  • Enter daily-digest as the channel name.
  • Enable Private Channel.
  • Create the channel.
  • Confirm that #daily-digest appears with private access in the server channel list.

The source channel needs read-only bot access. Sending messages there remains blocked.

  • Right-click the permitted source channel.
  • Select Edit Channel.
  • Select Permissions.
  • Add the bot to the channel permission overwrites.
  • Allow View Channel.
  • Allow Read Message History.
  • Deny Send Messages.
  • Save the source channel permissions.

The private destination needs only enough access to receive the digest. It does not need history-reading access for the project workflow.

  • Right-click #daily-digest.
  • Select Edit Channel.
  • Select Permissions.
  • Add the bot to the channel permission overwrites.
  • Allow View Channel.
  • Allow Send Messages.
  • Save the digest channel permissions.
  • Confirm the bot can see only the intended private destination among private channels.
Protect the configuration and verify imports

Discord channel IDs let the service target exact channels. Developer Mode adds the menu option that copies those IDs.

  • Click User Settings in Discord desktop.
  • Select Advanced.
  • Enable Developer Mode.
  • Return to the target server.
  • Right-click the permitted source channel.
  • Select Copy Channel ID.
  • Record the copied value as your permitted source channel ID.
  • Right-click #daily-digest.
  • Select Copy Channel ID.
  • Record the copied value as your private digest channel ID.

The public template now becomes a private local configuration. The existing config.json ignore rule keeps this copy outside future Git commits.

  • Switch back to VS Code.
  • Select config.example.json in the Explorer sidebar.
  • Click File in the top menu bar.
  • Select Save As.
  • Name the copied file config.json.
  • Save the copied file inside private-discord-digest.

You should see both configuration files in Explorer. Only config.json receives the real credential plus channel IDs.

  • Replace your-bot-token-here in config.json with the bot token from your password manager.
  • Replace paste-source-channel-id-here with your permitted source channel ID.

The file now points at one permitted source channel. Keep the token hidden while you finish the destination setting.

  • Replace paste-digest-channel-id-here with your private digest channel ID.

The copied template already retains lookbackHours at 24. It also retains cron at 0 8 * * *.

  • Save config.json.
  • Close the config.json editor tab so the token is no longer visible.

Keep the token private

Never paste the bot token into a screenshot or support message. Reset it from the app's Bot page if it becomes exposed.

The ignored local configuration is the only project file that contains the live credential.

Before you run the final checks, what four results do you expect from the runtime plus dependency imports?

  • Return to the VS Code terminal.
  • Verify the runtime plus both installed imports by running:
node --version
npm --version
node -e "import('discord.js').then(({version}) => console.log(version))"
node -e "import('node-cron').then(() => console.log('node-cron ready'))"

What do the import checks prove?

  • The first two checks confirm the active Node.js plus npm installations.
  • The discord.js check imports the installed package before printing its version.
  • The node-cron check imports the scheduler package before printing its readiness message.

You should see v24.22.1, an npm version, 14.27.0, plus node-cron ready on separate lines.

That is the sensitive setup complete. Your bot now has a protected credential plus narrowly scoped channel access.

Import check failing?

Confirm the terminal prompt points to private-discord-digest. An import cannot find these packages when the command runs from another folder.

Confirm the earlier npm installation completed successfully. The Explorer sidebar should contain node_modules.

Help me fix the dependency import check

Your private workspace is ready. Next, you will use its token plus source channel ID to fetch the first page of recent messages.

Fetch the First Page of Messages

Your Discord bot now has narrow access to one source channel. A live fetch reveals whether its token or channel ID still blocks the digest.

A small REST API request gives you that proof before the summarization logic grows. You will print the result locally without posting anything to the private digest channel.

In this step, get ready to:
  • Validate the protected configuration before using it.
  • Request recent text from the permitted source channel.
  • Run the manual path to expose the first-page limitation.
Validate the local configuration

The ignored config.json contains the values that connect this project to your bot. A loader checks the expected JSON shape before those values reach the request.

  • In VS Code, select the src directory in the Explorer sidebar.
  • Click the New File button in the Explorer toolbar.
  • Enter config.js as the file name.
  • Add the required-string helper by pasting this code into the empty file:
import { readFile } from 'node:fs/promises';

const configUrl = new URL('../config.json', import.meta.url);

function requireString(config, key) {
  // Reject missing values before Discord receives a request.
  const value = config[key];
  if (typeof value !== 'string' || value.trim() === '') {
    throw new Error(`config.json must contain a non-empty "${key}" string.`);
  }
  return value.trim();
}

What Does This Helper Do?

  • The file location points to the ignored config.json beside your source directory.
  • The requireString() helper rejects a missing token or digest channel ID.
  • Trimming each value prevents invisible spaces from corrupting a request.
  • Save src/config.js.

You should now see config.js beneath src in the Explorer sidebar.

Seeing a Syntax Warning?

Check that the template literal around the error message uses matching backticks. Confirm that each opening brace has a closing brace.

Ask for help with the helper code.

  • Append the exported configuration loader below the closing brace of requireString() by pasting this code:
export async function loadConfig() {
  // Read the ignored local settings for this run.
  const raw = await readFile(configUrl, 'utf8');
  const config = JSON.parse(raw);
  const botToken = requireString(config, 'botToken');
  const digestChannelId = requireString(config, 'digestChannelId');

  // Validate every source before using its channel ID.
  if (!Array.isArray(config.sourceChannelIds) || config.sourceChannelIds.length === 0) {
    throw new Error('config.json must contain at least one sourceChannelIds entry.');
  }
  const sourceChannelIds = config.sourceChannelIds.map((channelId) => {
    if (typeof channelId !== 'string' || channelId.trim() === '') {
      throw new Error('Every sourceChannelIds entry must be a non-empty string.');
    }
    return channelId.trim();
  });

  const lookbackHours = Number(config.lookbackHours ?? 24);
  if (!Number.isFinite(lookbackHours) || lookbackHours <= 0) {
    throw new Error('lookbackHours must be a positive number.');
  }

  const cronExpression =
    typeof config.cron === 'string' && config.cron.trim() !== ''
      ? config.cron.trim()
      : '0 8 * * *';

  return Object.freeze({ botToken, sourceChannelIds, digestChannelId, lookbackHours, cronExpression });
}

What Does the Loader Check?

  • The loader reads the local configuration during each new process.
  • The source-channel check requires at least one non-empty channel ID.
  • The lookback check requires a positive number of hours.
  • The cron check uses the configured expression when one is present.
  • Save src/config.js.

The file now exports loadConfig() without exposing the token anywhere in your source code.

Is the Configuration Underlined?

Check that the second code block sits below the helper's final closing brace. Confirm that config.json remains beside package.json.

Ask for help with the configuration loader.

Fetch one page without posting

The first request reads recent history from the configured source channel. It keeps only non-empty messages inside the configured lookback window.

  • Select the src directory in the Explorer sidebar.
  • Click the New File button in the Explorer toolbar.
  • Enter digest.js as the file name.
  • Build the temporary history request by pasting this code into src/digest.js:
import { REST, Routes } from 'discord.js';

export async function generateAndPostDigest(config) {
  // Authenticate a Discord API v10 client with the local bot token.
  const rest = new REST({ version: '10' }).setToken(config.botToken);
  const cutoffMs = Date.now() - config.lookbackHours * 60 * 60 * 1000;
  const channelId = config.sourceChannelIds[0];
  const query = new URLSearchParams({ limit: '100' });

  const page = await rest.get(Routes.channelMessages(channelId), { query });
  if (!Array.isArray(page)) {
    throw new Error(`Discord returned an unexpected response for channel ${channelId}.`);
  }

  // Keep qualifying text while the first-page boundary stays visible.
  const messages = page.filter(
    (message) =>
      Date.parse(message.timestamp) >= cutoffMs &&
      String(message.content ?? '').trim() !== '',
  );

  console.log('Fetched first page:', messages.length);
  const sample = messages[0]?.content ?? 'No qualifying text messages.';
  console.log('Sample message:', sample);
  console.log('[LIMITATION] Only the first API page was fetched.');

  return { messageCount: messages.length };
}

What Does This Request Do?

  • The REST client authenticates with the protected bot token.
  • The route targets the first channel ID from sourceChannelIds.
  • The limit value requests up to 100 messages.
  • The cutoff keeps qualifying text from the last configured number of hours.
  • Save src/digest.js.

You should now see digest.js beside config.js in the src directory.

Is the Request Code Underlined?

Confirm that the import names are REST and Routes. Check the parentheses around the page filter.

Ask for help with the one-page request.

  • Select the src directory in the Explorer sidebar.
  • Click the New File button in the Explorer toolbar.
  • Enter index.js as the file name.
  • Connect the existing manual script to the loader by pasting this code into src/index.js:
import { loadConfig } from './config.js';
import { generateAndPostDigest } from './digest.js';

async function main() {
  // Keep this temporary entry point on the one-shot path.
  const config = await loadConfig();

  if (process.argv.includes('--once')) {
    await generateAndPostDigest(config);
    return;
  }
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

How Does the Manual Path Work?

  • The entry point loads the protected configuration before making a request.
  • The --once check keeps this run on the immediate manual path.
  • Any failure reaches the terminal with a non-zero process status.
  • Save src/index.js.

The src directory should now contain config.js, digest.js, and index.js.

Is an Import Underlined?

Check that all three files use lowercase names. Confirm that the relative imports begin with ./.

Ask for help with the entry point.

Run the first fetch

Before you run this, what do you expect the bot to print from its permitted source channel?

  • Run the immediate history fetch from the existing project terminal by running this command:
npm run digest

What Should You See?

The terminal prints Fetched first page: followed by a count. It then prints one sample message or No qualifying text messages..

The final line is [LIMITATION] Only the first API page was fetched. Your private digest channel remains unchanged.

You now have live proof that the bot can read permitted history through the REST API. That first request can return at most 100 messages.

Seeing an Authentication or Access Problem?

A 401 result points to the bot token. Return to the app's Bot page before replacing the token stored in config.json.

A 403 result points to channel access. Confirm that the bot can view the source channel and read its message history.

An empty result can mean the source channel ID is wrong. It can also mean no non-empty text falls inside the 24 hour window.

Ask for help with the observed result.

Use these references to compare all three source files before moving on.

✔️ Awesome, I've got everything!

Your first-page fetch is working. Double-check that all three source files are saved.

ⓧ I'd like to double check the full code

Your src/config.js should match this reference.

import { readFile } from 'node:fs/promises';

const configUrl = new URL('../config.json', import.meta.url);

function requireString(config, key) {
  // Reject missing values before Discord receives a request.
  const value = config[key];
  if (typeof value !== 'string' || value.trim() === '') {
    throw new Error(`config.json must contain a non-empty "${key}" string.`);
  }
  return value.trim();
}

export async function loadConfig() {
  // Read the ignored local settings for this run.
  const raw = await readFile(configUrl, 'utf8');
  const config = JSON.parse(raw);
  const botToken = requireString(config, 'botToken');
  const digestChannelId = requireString(config, 'digestChannelId');

  // Validate every source before using its channel ID.
  if (!Array.isArray(config.sourceChannelIds) || config.sourceChannelIds.length === 0) {
    throw new Error('config.json must contain at least one sourceChannelIds entry.');
  }
  const sourceChannelIds = config.sourceChannelIds.map((channelId) => {
    if (typeof channelId !== 'string' || channelId.trim() === '') {
      throw new Error('Every sourceChannelIds entry must be a non-empty string.');
    }
    return channelId.trim();
  });

  const lookbackHours = Number(config.lookbackHours ?? 24);
  if (!Number.isFinite(lookbackHours) || lookbackHours <= 0) {
    throw new Error('lookbackHours must be a positive number.');
  }

  const cronExpression =
    typeof config.cron === 'string' && config.cron.trim() !== ''
      ? config.cron.trim()
      : '0 8 * * *';

  return Object.freeze({ botToken, sourceChannelIds, digestChannelId, lookbackHours, cronExpression });
}

Your src/digest.js should match this reference.

import { REST, Routes } from 'discord.js';

export async function generateAndPostDigest(config) {
  // Authenticate a Discord API v10 client with the local bot token.
  const rest = new REST({ version: '10' }).setToken(config.botToken);
  const cutoffMs = Date.now() - config.lookbackHours * 60 * 60 * 1000;
  const channelId = config.sourceChannelIds[0];
  const query = new URLSearchParams({ limit: '100' });

  const page = await rest.get(Routes.channelMessages(channelId), { query });
  if (!Array.isArray(page)) {
    throw new Error(`Discord returned an unexpected response for channel ${channelId}.`);
  }

  // Keep qualifying text while the first-page boundary stays visible.
  const messages = page.filter(
    (message) =>
      Date.parse(message.timestamp) >= cutoffMs &&
      String(message.content ?? '').trim() !== '',
  );

  console.log('Fetched first page:', messages.length);
  const sample = messages[0]?.content ?? 'No qualifying text messages.';
  console.log('Sample message:', sample);
  console.log('[LIMITATION] Only the first API page was fetched.');

  return { messageCount: messages.length };
}

Your src/index.js should match this reference.

import { loadConfig } from './config.js';
import { generateAndPostDigest } from './digest.js';

async function main() {
  // Keep this temporary entry point on the one-shot path.
  const config = await loadConfig();

  if (process.argv.includes('--once')) {
    await generateAndPostDigest(config);
    return;
  }
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Your bot can now authenticate and read recent text from its permitted channel. Next, you will make that retrieval cover the complete lookback window.

Retrieve Every Message in the Lookback Window

Your first fetch proved that the bot can read the permitted Discord channel. A busy channel can hold more messages than one API page returns.

REST pagination follows each page backward until the bot reaches the lookback cutoff. This closes the gap that could silently hide older conversations.

In this step, get ready to:
  • Normalize each retained message into the fields needed by the digest.
  • Request successive pages by carrying the oldest message ID into the next request.
  • Compare the complete retrieval count with the earlier first-page count.
Normalize retained messages

Discord message objects contain more data than the digest needs. A normalized object gives every retained message the same predictable shape.

  • Select src/digest.js in the VS Code Explorer sidebar.
  • Keep the existing discord.js import at the top of the file.
  • Add the normalization helpers directly below the import by pasting this code:
function reactionCount(message) {
  if (!Array.isArray(message.reactions)) return 0;
  return message.reactions.reduce((total, reaction) => total + Number(reaction.count ?? 0), 0);
}

function normalizeMessage(message, channelId) {
  return {
    id: message.id,
    channelId,
    authorId: message.author?.id ?? 'unknown',
    authorName: message.author?.username ?? 'unknown',
    content: String(message.content ?? '').trim(),
    timestamp: message.timestamp,
    reactions: reactionCount(message),
    pinned: Boolean(message.pinned),
    isReply: Boolean(message.message_reference),
  };
}

What do these helpers do?

  • The reactionCount() helper adds the counts from every reaction attached to a message.
  • The normalizeMessage() helper keeps the message identity plus its channel context.
  • The normalized object also keeps author details plus the text and timestamp.
  • The final fields record reactions plus pin and reply signals for later ranking.
  • Save src/digest.js.
  • Confirm that the current manual path still runs by using this command:
npm run digest

What does this check prove?

The existing first-page output confirms that the new helpers contain valid JavaScript. The limitation warning still identifies the retrieval gap that the next change closes.

You should still see the first-page count plus [LIMITATION] Only the first API page was fetched.

Seeing a JavaScript error?

Check that both helpers sit outside the temporary retrieval function. Match every closing brace and parenthesis with the code above.

Ask for help with the exact line reported in your terminal: Help me fix the helper syntax in src/digest.js

Add backward pagination

Each response arrives newest first. The oldest message ID becomes the before value for the next request.

  • Add fetchChannelMessages() below normalizeMessage() by pasting this code:
async function fetchChannelMessages(rest, channelId, cutoffMs) {
  const messages = [];
  let before;

  while (true) {
    const query = new URLSearchParams({ limit: '100' });
    if (before) query.set('before', before);

    const page = await rest.get(Routes.channelMessages(channelId), { query });
    if (!Array.isArray(page)) {
      throw new Error(`Discord returned an unexpected response for channel ${channelId}.`);
    }
    if (page.length === 0) break;

    for (const message of page) {
      const timestampMs = Date.parse(message.timestamp);
      if (timestampMs >= cutoffMs && String(message.content ?? '').trim() !== '') {
        messages.push(normalizeMessage(message, channelId));
      }
    }

    const oldest = page.at(-1);
    if (page.length < 100 || Date.parse(oldest.timestamp) < cutoffMs) break;
    before = oldest.id;
  }

  return messages;
}

How does pagination work?

  • The query requests the maximum page size of 100 messages.
  • The first request has no before value, so it retrieves the newest page.
  • Each qualifying message is normalized before it enters the collected array.
  • An empty page ends retrieval because no older messages remain.
  • A page under 100 records also proves that no further page exists.
  • An oldest timestamp before cutoffMs proves that the requested time window has been covered.
  • Replace the exported temporary function containing Fetched first page: with this retrieval function:
export async function generateAndPostDigest(config) {
  const rest = new REST({ version: '10' }).setToken(config.botToken);
  const generatedAtMs = Date.now();
  const cutoffMs = generatedAtMs - config.lookbackHours * 60 * 60 * 1000;
  const messages = [];

  for (const channelId of config.sourceChannelIds) {
    const channelMessages = await fetchChannelMessages(rest, channelId, cutoffMs);
    messages.push(...channelMessages);
  }

  messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));

  console.log('Fetched complete window:', messages.length);
  return { messageCount: messages.length };
}

What does the manual path do?

  • The REST client authenticates with the token already loaded from the ignored config.json file.
  • The cutoff converts lookbackHours into an exact timestamp for this run.
  • The loop retrieves every configured source channel through fetchChannelMessages().
  • The final sort places the retained messages in chronological order.
  • The function reports the complete count without posting a digest.
  • Save src/digest.js.

✔️ Awesome, I've got everything!

Your pagination code is ready for the complete retrieval check.

ⓧ I'd like to double check the full code

import { REST, Routes } from 'discord.js';

function reactionCount(message) {
  if (!Array.isArray(message.reactions)) return 0;
  return message.reactions.reduce((total, reaction) => total + Number(reaction.count ?? 0), 0);
}

function normalizeMessage(message, channelId) {
  return {
    id: message.id,
    channelId,
    authorId: message.author?.id ?? 'unknown',
    authorName: message.author?.username ?? 'unknown',
    content: String(message.content ?? '').trim(),
    timestamp: message.timestamp,
    reactions: reactionCount(message),
    pinned: Boolean(message.pinned),
    isReply: Boolean(message.message_reference),
  };
}

async function fetchChannelMessages(rest, channelId, cutoffMs) {
  const messages = [];
  let before;

  while (true) {
    const query = new URLSearchParams({ limit: '100' });
    if (before) query.set('before', before);

    const page = await rest.get(Routes.channelMessages(channelId), { query });
    if (!Array.isArray(page)) {
      throw new Error(`Discord returned an unexpected response for channel ${channelId}.`);
    }
    if (page.length === 0) break;

    for (const message of page) {
      const timestampMs = Date.parse(message.timestamp);
      if (timestampMs >= cutoffMs && String(message.content ?? '').trim() !== '') {
        messages.push(normalizeMessage(message, channelId));
      }
    }

    const oldest = page.at(-1);
    if (page.length < 100 || Date.parse(oldest.timestamp) < cutoffMs) break;
    before = oldest.id;
  }

  return messages;
}

export async function generateAndPostDigest(config) {
  const rest = new REST({ version: '10' }).setToken(config.botToken);
  const generatedAtMs = Date.now();
  const cutoffMs = generatedAtMs - config.lookbackHours * 60 * 60 * 1000;
  const messages = [];

  for (const channelId of config.sourceChannelIds) {
    const channelMessages = await fetchChannelMessages(rest, channelId, cutoffMs);
    messages.push(...channelMessages);
  }

  messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));

  console.log('Fetched complete window:', messages.length);
  return { messageCount: messages.length };
}

How to use this reference

Compare the reference with src/digest.js from top to bottom. Keep your populated config.json closed so its bot token stays private.

Verify the complete retrieval

Before you run the finished path, do you expect the complete count to equal or exceed the earlier first-page count?

  • Retrieve the complete lookback window by running this command:
npm run digest

What does this command verify?

The script now follows older pages until it covers the configured lookback window. It reports the number of retained non-empty messages after sorting them chronologically.

You should see Fetched complete window: followed by the total count. The earlier one-page limitation warning should be gone.

A channel with more than 100 recent messages should produce a larger count than the first-page version. A quieter channel can produce the same count because its first page already contained the whole window.

Retrieval failed or returned no messages?

A 401 result points to the bot token. Confirm that the populated config.json contains the current token.

A 403 result points to channel access. Confirm that the bot has View Channel plus Read Message History in the configured source channel.

An empty result can mean the channel has no non-empty messages inside the configured lookback window. Help me diagnose my Discord message retrieval result

That is the retrieval gap closed. Next, you will turn the complete message window into a compact private digest.

Build and Post the Digest

Your REST pagination now retrieves every qualifying message across the configured time window. The result is still raw history that an administrator must read message by message.

This step turns that history into a compact report for your private Discord channel. The post suppresses mention parsing so quoted user text stays quiet.

In this step, get ready to:
  • Calculate deterministic activity statistics from the retrieved messages.
  • Build ranked highlights from engagement signals plus repeated terms.
  • Post a private digest with mention parsing suppressed.
Add deterministic topic analysis

Deterministic tokenization turns each message into comparable words. A stop-word set removes common terms so recurring topics reflect the substance of the conversation.

Why use local heuristics?

Local scoring keeps raw message text inside the Node process for one run. It avoids sending server conversations to an external AI service.

The same input always produces the same ranking. That transparency makes each highlight easier to explain.

  • In src/digest.js, locate the Discord import at the top of the file.
  • Add the stop-word set plus tokenize() directly below the import by pasting:
const stopWords = new Set(
  `a about after again all also am an and any are as at be because been before being between both but by can could did do does doing down during each few for from further had has have having he her here hers herself him himself his how i if in into is it its itself just me more most my myself no nor not now of off on once only or other our ours ourselves out over own same she should so some such than that the their theirs them themselves then there these they this those through to too under until up very was we were what when where which while who why will with would you your yours yourself yourselves`.split(
    /\s+/,
  ),
);

function tokenize(text) {
  return text
    .toLocaleLowerCase('en-US')
    .replace(/https?:\/\/\S+/gu, ' ')
    .replace(/\u003c[^\u003e]+\u003e/gu, ' ')
    .split(/[^\p{L}\p{N}'-]+/u)
    .filter((word) => word.length > 2 && !stopWords.has(word));
}

What does this code do?

  • The stopWords set holds common words that add little topic value.
  • The tokenize() function converts message text to lowercase before splitting it into words.
  • The URL cleanup prevents shared links from dominating the topic counts.
  • The Discord reference cleanup removes channel-style markup from the topic input.
  • Save src/digest.js.
  • Check that the new parser loads without breaking retrieval by running:
npm run digest

What should you see?

The terminal should still print the complete retrieval count from the previous step. That output confirms Node loaded the new tokenizer without a syntax error.

Seeing a syntax error?

Check that the stop-word template string has a backtick at both ends. Confirm that tokenize() closes after the filtered word list.

Ask for help with the exact line reported by Node: Help me fix the tokenizer syntax in src/digest.js.

Each retained token now needs a frequency count. Alphabetical tie-breaking keeps topics stable when several words have the same count.

  • In src/digest.js, locate the closing brace for fetchChannelMessages().
  • Add the term counters directly below it by pasting:
function countTerms(messages) {
  const counts = new Map();

  for (const message of messages) {
    for (const word of tokenize(message.content)) {
      counts.set(word, (counts.get(word) ?? 0) + 1);
    }
  }

  return counts;
}

function topTopics(termCounts) {
  return [...termCounts.entries()]
    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
    .slice(0, 8);
}

How are topics selected?

  • The Map stores one running count for each retained word.
  • The topic sort puts higher counts first.
  • The alphabetical fallback makes tied results predictable.
  • The final slice limits the report to eight recurring terms.
  • Save src/digest.js.
  • Confirm the term counters load successfully by running:
npm run digest

What should you see now?

The terminal should print the complete message count again. The successful run proves the new topic helpers load alongside the pagination code.

Does the command stop before the count?

Check that countTerms() sits outside fetchChannelMessages(). A missing closing brace can accidentally nest the helper inside the fetch function.

Ask for a focused brace check: Help me find the misplaced brace around countTerms in src/digest.js.

Rank highlights and assemble the report

A useful digest surfaces messages that represent the conversation. The score combines repeated-term coverage with reactions, pins, plus reply context.

  • In src/digest.js, locate topTopics().
  • Add the highlight scoring helpers directly below it by pasting:
function highlightScore(message, termCounts) {
  const repeatedTermScore = [...new Set(tokenize(message.content))].reduce(
    (total, word) => total + (termCounts.get(word) ?? 0),
    0,
  );

  return (
    repeatedTermScore +
    message.reactions * 5 +
    (message.pinned ? 8 : 0) +
    (message.isReply ? 2 : 0)
  );
}

function plainExcerpt(text) {
  return text.replace(/[`*_~|]/gu, '').replace(/\s+/gu, ' ').trim();
}

function truncate(text, maximumCharacters) {
  const characters = [...text];
  if (characters.length <= maximumCharacters) return text;
  return `${characters.slice(0, maximumCharacters - 1).join('')}…`;
}

How does highlight scoring work?

  • Repeated topic terms raise messages that reflect the wider conversation.
  • Each reaction adds five points to the score.
  • A pinned message receives eight additional points.
  • A reply receives two additional points.
  • Excerpt cleanup removes formatting characters before the report quotes a message.
  • Save src/digest.js.
  • Check that the scoring helpers parse successfully by running:
npm run digest

What proves the scoring code loaded?

The terminal should reach the complete retrieval count without a syntax error. Your existing manual path has now loaded the ranking helpers successfully.

Seeing an error near the score?

Check the parentheses around the reduce() call. Confirm that the returned score remains inside highlightScore().

Ask for help with the score expression: Help me check the highlightScore function in src/digest.js.

Shared links need their own compact section. Deduplication keeps the same URL from filling several lines of the report.

  • In src/digest.js, locate truncate().
  • Add the unique-link extractor directly below it by pasting:
function extractLinks(messages) {
  const seen = new Set();
  const links = [];

  for (const message of messages) {
    const matches = message.content.match(/https?:\/\/[^\s\u003c\u003e()]+/gu) ?? [];
    for (const match of matches) {
      const url = match.replace(/[),.!?]+$/u, '');
      if (!seen.has(url)) {
        seen.add(url);
        links.push({ channelId: message.channelId, url });
      }
    }
  }

  return links.slice(0, 4);
}

How are links kept compact?

  • The seen set records URLs that already appear in the report.
  • Trailing punctuation is removed from URLs copied out of prose.
  • Each saved link retains its source channel ID for context.
  • The report includes at most four unique links.
  • Save src/digest.js.
  • Confirm the link extractor loads successfully by running:
npm run digest

What should the terminal show?

The terminal should print the complete retrieval count once more. This confirms the link regular expression loaded successfully.

Does Node point to the link pattern?

Check that the regular expression begins and ends with a forward slash. Confirm that the empty fallback remains an array.

Ask for help with the exact pattern: Help me fix the extractLinks regular expression in src/digest.js.

The report builder combines activity, topics, highlights, plus links into one bounded message. It truncates the final content to 1950 characters so the post stays below Discord's content limit.

  • Make src/digest.js match the full-file comparison below:

✔️ Awesome, I've got everything!

Your digest file now includes the report builder plus the safe posting function.

ⓧ I'd like to double check the full code

  • Compare your complete src/digest.js file with this reference:
import { REST, Routes } from 'discord.js';

const stopWords = new Set(
  `a about after again all also am an and any are as at be because been before being between both but by can could did do does doing down during each few for from further had has have having he her here hers herself him himself his how i if in into is it its itself just me more most my myself no nor not now of off on once only or other our ours ourselves out over own same she should so some such than that the their theirs them themselves then there these they this those through to too under until up very was we were what when where which while who why will with would you your yours yourself yourselves`.split(
    /\s+/,
  ),
);

function tokenize(text) {
  return text
    .toLocaleLowerCase('en-US')
    .replace(/https?:\/\/\S+/gu, ' ')
    .replace(/\u003c[^\u003e]+\u003e/gu, ' ')
    .split(/[^\p{L}\p{N}'-]+/u)
    .filter((word) => word.length > 2 && !stopWords.has(word));
}

function reactionCount(message) {
  if (!Array.isArray(message.reactions)) return 0;
  return message.reactions.reduce((total, reaction) => total + Number(reaction.count ?? 0), 0);
}

function normalizeMessage(message, channelId) {
  return {
    id: message.id,
    channelId,
    authorId: message.author?.id ?? 'unknown',
    authorName: message.author?.username ?? 'unknown',
    content: String(message.content ?? '').trim(),
    timestamp: message.timestamp,
    reactions: reactionCount(message),
    pinned: Boolean(message.pinned),
    isReply: Boolean(message.message_reference),
  };
}

async function fetchChannelMessages(rest, channelId, cutoffMs) {
  const messages = [];
  let before;

  while (true) {
    const query = new URLSearchParams({ limit: '100' });
    if (before) query.set('before', before);

    const page = await rest.get(Routes.channelMessages(channelId), { query });
    if (!Array.isArray(page)) {
      throw new Error(`Discord returned an unexpected response for channel ${channelId}.`);
    }
    if (page.length === 0) break;

    for (const message of page) {
      const timestampMs = Date.parse(message.timestamp);
      if (timestampMs >= cutoffMs && String(message.content ?? '').trim() !== '') {
        messages.push(normalizeMessage(message, channelId));
      }
    }

    const oldest = page.at(-1);
    if (page.length < 100 || Date.parse(oldest.timestamp) < cutoffMs) break;
    before = oldest.id;
  }

  return messages;
}

function countTerms(messages) {
  const counts = new Map();

  for (const message of messages) {
    for (const word of tokenize(message.content)) {
      counts.set(word, (counts.get(word) ?? 0) + 1);
    }
  }

  return counts;
}

function topTopics(termCounts) {
  return [...termCounts.entries()]
    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
    .slice(0, 8);
}

function highlightScore(message, termCounts) {
  const repeatedTermScore = [...new Set(tokenize(message.content))].reduce(
    (total, word) => total + (termCounts.get(word) ?? 0),
    0,
  );

  return (
    repeatedTermScore +
    message.reactions * 5 +
    (message.pinned ? 8 : 0) +
    (message.isReply ? 2 : 0)
  );
}

function plainExcerpt(text) {
  return text.replace(/[`*_~|]/gu, '').replace(/\s+/gu, ' ').trim();
}

function truncate(text, maximumCharacters) {
  const characters = [...text];
  if (characters.length <= maximumCharacters) return text;
  return `${characters.slice(0, maximumCharacters - 1).join('')}…`;
}

function extractLinks(messages) {
  const seen = new Set();
  const links = [];

  for (const message of messages) {
    const matches = message.content.match(/https?:\/\/[^\s\u003c\u003e()]+/gu) ?? [];
    for (const match of matches) {
      const url = match.replace(/[),.!?]+$/u, '');
      if (!seen.has(url)) {
        seen.add(url);
        links.push({ channelId: message.channelId, url });
      }
    }
  }

  return links.slice(0, 4);
}

function buildDigest(messages, sourceChannelIds, cutoffMs, generatedAtMs) {
  const contributors = new Set(messages.map((message) => message.authorId));
  const termCounts = countTerms(messages);
  const topics = topTopics(termCounts);
  const highlights = [...messages]
    .sort(
      (a, b) =>
        highlightScore(b, termCounts) - highlightScore(a, termCounts) ||
        Date.parse(b.timestamp) - Date.parse(a.timestamp),
    )
    .slice(0, 3);
  const links = extractLinks(messages);

  const activityLines = sourceChannelIds.map((channelId) => {
    const count = messages.filter((message) => message.channelId === channelId).length;
    return `- \u003c#${channelId}\u003e: ${count} message(s)`;
  });

  const topicLine = topics.length
    ? topics.map(([word, count]) => `${word} (${count})`).join(', ')
    : 'No repeated terms in this window.';

  const highlightLines = highlights.length
    ? highlights.map((message) => {
        const excerpt = truncate(plainExcerpt(message.content), 180);
        return `- \u003c#${message.channelId}\u003e ${message.authorName}: ${excerpt} [${message.reactions} reaction(s)]`;
      })
    : ['- No qualifying text messages in this window.'];

  const linkLines = links.length
    ? links.map(({ channelId, url }) => `- \u003c#${channelId}\u003e ${truncate(url, 180)}`)
    : ['- No links shared in this window.'];

  const digest = [
    '# Daily Discord Digest',
    `Window: ${new Date(cutoffMs).toLocaleString()} to ${new Date(generatedAtMs).toLocaleString()}`,
    `Messages: ${messages.length} | Contributors: ${contributors.size}`,
    '',
    '## Channel activity',
    ...activityLines,
    '',
    '## Topics',
    topicLine,
    '',
    '## Highlights',
    ...highlightLines,
    '',
    '## Links',
    ...linkLines,
  ].join('\n');

  return truncate(digest, 1950);
}

export async function generateAndPostDigest(config) {
  const rest = new REST({ version: '10' }).setToken(config.botToken);
  const generatedAtMs = Date.now();
  const cutoffMs = generatedAtMs - config.lookbackHours * 60 * 60 * 1000;
  const messages = [];

  for (const channelId of config.sourceChannelIds) {
    const channelMessages = await fetchChannelMessages(rest, channelId, cutoffMs);
    messages.push(...channelMessages);
  }

  messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));

  const content = buildDigest(
    messages,
    config.sourceChannelIds,
    cutoffMs,
    generatedAtMs,
  );

  await rest.post(Routes.channelMessages(config.digestChannelId), {
    body: {
      content,
      allowed_mentions: { parse: [] },
    },
  });

  return { messageCount: messages.length };
}

How does the complete file fit together?

The first half retains your paginated retrieval pipeline. The second half derives a report entirely in memory.

The final function fetches every configured source channel before sorting the combined messages chronologically. It posts the bounded report to the configured digest channel.

  • Save src/digest.js after it matches the reference.
Post the digest safely

The posting function sends the report through the configured digest channel route. Its empty mention parse list prevents user-derived excerpts from notifying users or roles.

Why suppress mention parsing?

An excerpt can contain text that resembles a user mention or role mention. The empty parse list tells Discord to deliver that content without turning it into a notification.

This keeps a summary run from disturbing people whose messages appear in the report.

  • In src/index.js, replace the temporary retrieval entry point with this final manual path:
import { loadConfig } from './config.js';
import { generateAndPostDigest } from './digest.js';

async function main() {
  const config = await loadConfig();
  const result = await generateAndPostDigest(config);
  console.log(
    `Posted digest with ${result.messageCount} message(s) at ${new Date().toISOString()}.`,
  );
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

What does this entry point do?

  • The entry point loads the protected local configuration.
  • The generateAndPostDigest() call retrieves messages before building the report.
  • The success log records the number of source messages included in the digest.
  • The error handler gives a failed run a nonzero process exit code.
  • Save src/index.js.

✔️ Awesome, I've got everything!

Your manual entry point now calls the finished digest pipeline.

ⓧ I'd like to double check the full code

  • Compare your complete src/index.js file with this reference:
import { loadConfig } from './config.js';
import { generateAndPostDigest } from './digest.js';

async function main() {
  const config = await loadConfig();
  const result = await generateAndPostDigest(config);
  console.log(
    `Posted digest with ${result.messageCount} message(s) at ${new Date().toISOString()}.`,
  );
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

What should match?

The file should import only the configuration loader plus the finished digest function. The temporary retrieval-only path has been removed.

Before you run the finished pipeline, what sections do you expect to see in the private channel?

  • Post one fresh private digest by running:
npm run digest

What should you see?

The terminal should print a line beginning with Posted digest with. The line includes the qualifying message count plus the run timestamp.

The private digest channel should receive one message headed Daily Discord Digest. It should include channel activity, topics, highlights, plus links.

Did the digest fail to post?

Confirm the bot can view the configured source channel. Confirm it can send messages in the private digest channel.

Ask for help without sharing your token: Help me diagnose why generateAndPostDigest failed without exposing my Discord bot token.

  • Switch back to the private #daily-digest channel from earlier.
  • Inspect the newest digest message.
  • Check any highlighted excerpt that contains mention-like text.

You should see the report window, message count, contributor count, channel activity, topics, highlights, plus links. Any mention-like text in an excerpt should remain quiet without notifying a user or role.

That is the reporting pipeline complete. Your bot now converts paginated history into a private digest without creating a message archive.

Next, you will keep the manual command while adding a non-overlapping daily schedule based on your Mac's local clock.

Schedule the Daily Run

Your private Discord digest already turns permitted channel activity into a safe report whenever you run the manual command.

A daily habit needs the node-cron scheduler to trigger that same report using your Mac's local clock. The scheduler also prevents a new run from starting while an earlier run is still active.

In this step, get ready to:
  • Keep npm run digest as the immediate one-shot path.
  • Add a non-overlapping daily scheduler to src/index.js.
  • Test the scheduler every minute before restoring the daily time.
Preserve the one-shot path

The manual path gives you an immediate digest for testing. A shared runDigest function lets the manual command and scheduler use the same posting logic.

  • Switch back to src/index.js in VS Code.
  • Select the two import lines at the top of the file.
  • Replace the selected import group with the code below:
import cron from 'node-cron';
import { loadConfig } from './config.js';
import { generateAndPostDigest } from './digest.js';

What Does This Import Change Do?

  • The cron import provides the scheduling function used by the long-running path.
  • The existing imports keep configuration loading and digest delivery available.
  • Save src/index.js.

Before you run this, predict whether adding the scheduler import changes the existing one-shot result.

  • Verify the existing manual path by running:
npm run digest

What Should You See?

The terminal prints a line beginning with Posted digest with. You also receive one new report in the private digest channel.

Does the Manual Run Fail?

Check that the import uses node-cron with the same spelling as the installed dependency. Confirm that each import line ends with its closing quote.

Help me diagnose the import or manual digest failure.

Good, the immediate digest path still works after loading the scheduler dependency.

The current main() function runs the digest immediately for every launch. The next edit moves that work into runDigest and limits immediate execution to the one-shot flag.

  • Select the existing digest call and its terminal log inside main().
  • Replace the selected code with this reusable one-shot path:
  const runDigest = async () => {
    const result = await generateAndPostDigest(config);
    console.log(
      `Posted digest with ${result.messageCount} message(s) at ${new Date().toISOString()}.`,
    );
  };

  if (process.argv.includes('--once')) {
    await runDigest();
    return;
  }

How Does the One-Shot Path Work?

  • The runDigest function calls the existing digest generator and logs the posted message count.
  • The --once check runs that function for the manual script before returning from main().
  • A launch without --once continues to the scheduler code you add next.
  • Save src/index.js.

Before you run this, predict whether the refactored one-shot path still posts exactly one digest.

  • Test the refactored one-shot path by running:
npm run digest

What Does This Check Prove?

You receive one new digest in the private channel. The terminal prints its message count with an ISO timestamp.

Missing the New Digest?

Confirm that await runDigest() remains inside the --once condition. Check that the closing braces match the reference snippet.

Help me repair the one-shot path.

Add the non-overlapping scheduler

A scheduled task keeps the Node process open between runs. The configuration supplies the cron expression while your Mac supplies the system timezone.

The noOverlap option skips a scheduled start when the previous digest is still running. This prevents duplicate overlapping work.

  • Locate the final closing brace of main() in src/index.js.
  • Insert the scheduler code immediately before that closing brace:
  console.log(
    `Scheduler active for "${config.cronExpression}" in the Mac's system timezone.`,
  );

  cron.schedule(
    config.cronExpression,
    async () => {
      try {
        await runDigest();
      } catch (error) {
        console.error('Digest run failed:', error);
      }
    },
    {
      name: 'daily-discord-digest',
      noOverlap: true,
    },
  );

What Does the Scheduler Do?

  • The first log confirms which cron expression the loaded configuration provides.
  • The scheduled task calls the same runDigest function as the manual path.
  • The task name daily-discord-digest identifies this scheduled job.
  • The error handler keeps one failed scheduled run from ending the scheduler process.
  • Save src/index.js.

Before you run this, predict which schedule the terminal reports from your current configuration.

  • Start the scheduler by running:
npm start

What Should You See?

The terminal reports that the scheduler is active for 0 8 * * * in the Mac's system timezone. The terminal stays occupied because the scheduler remains active.

  • Press Control-C to stop this test scheduler.

Does the Scheduler Exit Immediately?

Confirm that cron.schedule is inside main() after the one-shot condition. Check that every parenthesis and brace matches the scheduler snippet.

Help me diagnose the scheduler path.

Your scheduler path is now active. Use the tabs below to compare the complete entry file before testing a real scheduled delivery.

✔️ Awesome, I've got everything!

Great, double-check that you saved src/index.js before continuing.

ⓧ I'd like to double check the full code

import cron from 'node-cron';
import { loadConfig } from './config.js';
import { generateAndPostDigest } from './digest.js';

async function main() {
  const config = await loadConfig();

  const runDigest = async () => {
    const result = await generateAndPostDigest(config);
    console.log(
      `Posted digest with ${result.messageCount} message(s) at ${new Date().toISOString()}.`,
    );
  };

  if (process.argv.includes('--once')) {
    await runDigest();
    return;
  }

  console.log(
    `Scheduler active for "${config.cronExpression}" in the Mac's system timezone.`,
  );

  cron.schedule(
    config.cronExpression,
    async () => {
      try {
        await runDigest();
      } catch (error) {
        console.error('Digest run failed:', error);
      }
    },
    {
      name: 'daily-discord-digest',
      noOverlap: true,
    },
  );
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});
Test one scheduled delivery

The daily expression is inconvenient for a quick test. The temporary expression * * * * * runs once per minute so you can watch the scheduler deliver a digest.

  • Switch back to the populated config.json from earlier.
  • Change the cron value from 0 8 * * * to * * * * *.
  • Save config.json.

The first scheduled run can take up to one minute because it waits for the next minute boundary.

Before you start it, predict what the terminal and private digest channel show at that boundary.

  • Start the one-minute scheduler test by running:
npm start

What Happens at the Next Minute?

The terminal first confirms that * * * * * is active. At the next minute boundary, it logs a successful digest with the retrieved message count.

You receive a new report headed Daily Discord Digest in the private digest channel. That delivery proves the scheduled path called the same safe posting function.

No Digest After a Minute?

Confirm that config.json contains the temporary cron value. Keep the terminal process running through the next minute boundary.

If the terminal reports a digest failure, verify that the Mac has network access. Confirm that the bot still has access to the permitted source channel and private digest channel.

Help me troubleshoot the missed scheduled run.

That's the automation working on schedule. Your Mac triggered the same private digest without a manual one-shot command.

  • Press Control-C after the scheduled digest arrives.

The every-minute expression was only a short verification setting. Restoring the daily expression prevents repeated test digests.

  • Change the cron value in config.json from * * * * * back to 0 8 * * *.
  • Save config.json.

Before the final check, predict which expression the restored scheduler reports.

  • Confirm the restored daily schedule by running:
npm start

What Does the Final Check Confirm?

The terminal reports 0 8 * * * as the active expression. The schedule uses your Mac's system timezone.

Scheduled delivery requires the Mac to remain awake. The Node process must also remain running.

  • Leave the process running when you want the daily local digest.
  • Press Control-C when you want to pause local scheduling.

Still Seeing the Test Expression?

Stop the scheduler before editing config.json. Save the restored value before starting a new process because the configuration loads once at startup.

Help me restore the daily schedule.

Your digest now supports immediate manual delivery and non-overlapping daily scheduling from your Mac.

Secret mission

Expand the Digest Across Channels

A single source channel gives you one slice of the conversation. Expand the digest to three permitted channels so one report combines their activity, ranks their messages together, and keeps every source visible.

Clean Up Your Resources

Clean Up Your Resources

Keeping this project costs $0. Decide whether to keep the scheduler running, pause it for later, or delete the local files plus the Discord setup.

Resources you used:

  • Local private-discord-digest folder containing the project files plus pinned dependencies.
  • Ignored config.json containing the bot token plus the permitted channel IDs.
  • Installed Discord bot with scoped access to at least three permitted source channels.
  • Private #daily-digest channel with permission for the bot to post messages.

Keep everything running

This option preserves the finished setup. Daily delivery continues only while the scheduler process runs on an awake Mac.

  • Leave the scheduler process running when you want automatic daily delivery.
  • Keep your Mac awake at the configured delivery time.
  • Keep the bot limited to the permitted source channels plus the private digest channel.

Pause - I'll come back to this later

Pausing stops daily delivery without removing the project setup. Manual digest runs remain available whenever you need one.

  • Switch back to the terminal panel running the scheduler.
  • Press Control-C to stop the scheduler process.

The scheduled run logs stop. Every saved project resource remains available for later.

Delete - I don't want to use this again

Deleting the local folder permanently removes your code plus the stored bot token from this Mac. The remaining actions revoke the bot's server access.

Remove the local files:

  • Switch back to the terminal panel running the scheduler if it is still active.
  • Press Control-C to stop the scheduler process.
  • In the Explorer sidebar in VS Code, locate config.json inside private-discord-digest.
  • Delete config.json to remove the locally stored credential.
  • Use Finder to delete the private-discord-digest folder that is open in VS Code.
  • Empty the Mac's Trash to permanently remove the deleted folder.
  • Confirm that private-discord-digest no longer appears in Finder.

Remove the Discord access:

  • Use the Discord server's member-management controls to remove the digest bot.
  • Delete the private #daily-digest channel from its channel settings.
  • On your application's Bot page in the Discord Developer Portal, select Reset Token to invalidate the deleted credential.
  • Confirm that the digest bot no longer appears in the server member list.
  • Confirm that #daily-digest no longer appears in the server channel list.

Nice Work!

Nice Work!

Strong finish! Your private Discord digest now turns permitted channel history into a safe daily report.

You've learned how to:

  • Built a REST-only bot that retrieves complete lookback windows with explicit 100-message pagination.
  • Generated a deterministic digest showing channel activity, unique contributors, recurring terms, reaction-weighted highlights, and shared links.
  • Protected users from notifications by suppressing mention parsing in reposted excerpts. Added immediate manual runs plus a non-overlapping daily schedule.
  • Secret Mission: Expanded the digest across at least three permitted source channels while preserving per-channel context in the combined ranking.

Ready to quiz yourself?