Logo
Expanding Functionality
Overview

Expanding Functionality

October 24, 2025
7 min read

Ideal Final State

As previously mentioned, we want to implement a virtual currency system with a balance, daily, and gamble system. For this we will leverage DynamoDB, AWS’ fully managed NoSQL database service.

Functionality wise we want the ability to:

  • Create or get a user
  • Get a user’s balance
  • Set a user’s balance
  • Gamble with a user’s balance
  • Daily reward for a user

Table Structure

We will have a users table with the following attributes:

  • User ID
  • Guild ID
  • Balance
  • Last Daily Claim

We will use the User ID as the partition key and Guild ID as the sort key.

Definition (What are Partition and Sort Keys?)

In DynamoDB, a partition key determines where your data is physically stored and distributed across servers, while a sort key allows you to store multiple items with the same partition key in sorted order.

When you combine them into a composite primary key—like using User ID as the partition key and Guild ID as the sort key—both values become required for every item, and together they must be unique. This design is perfect for one-to-many relationships like ours where all guilds for a specific user are stored together on the same partition, sorted by Guild ID, which makes queries like “get all guilds for this user” incredibly fast and efficient. You can retrieve a specific user-guild combination by providing both keys, or query all guilds for a user by providing just the partition key.

Adding Table to SST

The beauty about NoSQL databases is that they are schema-less, which means you can change the setup at any point. This is in contrast to traditional SQL databases, which require you to define a schema upfront. For us this means all we need to define is the partition key and the sort key, everything else is just added on demand!

We will also need to link the users table to lambda function so that the function can have permissions to the table (which SST will handle for us).

sst.config.ts
/// <reference path="./.sst/platform/config.d.ts" />
export default $config({
app(input) {
return {
name: "serverless-bot",
removal: input?.stage === "production" ? "retain" : "remove",
protect: ["production"].includes(input?.stage),
home: "aws",
};
},
async run() {
const botToken = new sst.Secret("DiscordBotToken");
const appId = new sst.Secret("DiscordAppId");
const publicKey = new sst.Secret("DiscordPublicKey");
const usersTable = new sst.aws.Dynamo("Users", {
fields: {
userId: "string",
guildId: "string",
},
primaryIndex: {
hashKey: "userId",
rangeKey: "guildId",
},
});
new sst.aws.Function("DiscordInteractionHandler", {
handler: "lambdas/interaction.handler",
url: true,
link: [usersTable],
environment: {
DISCORD_TOKEN: botToken.value,
DISCORD_APP_ID: appId.value,
DISCORD_PUBLIC_KEY: publicKey.value,
},
});
},
});

As always, just go ahead and npx sst deploy to deploy your changes!

New Balance Command

Let’s get rolling with a new balance command, but we will need a couple packages and some setup. In this case we will use the @aws-sdk/client-dynamodb package to interact with DynamoDB. We’ll also use date-fns to handle date and time operations more reliably.

Terminal window
npm install @aws-sdk/client-dynamodb date-fns

Dynamo Utilities

We will also want some DynamoDB utilities to just make our lives easier.

utilities/dynamodb.ts
import {
DynamoDBClient,
PutItemCommand,
QueryCommand,
} from "@aws-sdk/client-dynamodb";
import { Resource } from "sst";
const client = new DynamoDBClient();
const usersTableName = Resource.Users.name;
export async function getUser(userId, guildId) {
const command = new QueryCommand({
TableName: usersTableName,
KeyConditionExpression: "userId = :userId AND guildId = :guildId",
ExpressionAttributeValues: {
":userId": { S: userId },
":guildId": { S: guildId },
},
});
const { Items } = await client.send(command);
return Items?.[0] ?? null;
}
export async function createUser(user, guildId) {
const putCommand = new PutItemCommand({
TableName: usersTableName,
Item: {
userId: { S: user.id },
guildId: { S: guildId },
// Default starting balance
balance: { N: '1000' },
lastDaily: { N: "0" },
},
});
await client.send(putCommand);
}
export async function getOrCreateUser(user, guildId) {
const dbUser = await getUser(user.id, guildId);
if (dbUser) {
return dbUser;
}
await createUser(user, guildId);
return await getUser(user.id, guildId);
}
export async function updateUser(user, update) {
const command = new PutItemCommand({
TableName: usersTableName,
Item: {
...user,
...update,
},
});
await client.send(command);
}

The New Command

We will now create a new command that will display the user’s balance using our handy DynamoDB utilities.

bot/commands/balance.ts
import type { CommandConfig, CommandInteraction } from "dressed";
import { getOrCreateUser } from "../../utilities/dynamodb";
export const config = {
description: "Balance",
integration_type: "Guild",
contexts: ["Guild"],
guilds: ["YOUR_GUILD_ID"],
} satisfies CommandConfig;
export default async function(interaction: CommandInteraction) {
const user = await getOrCreateUser(interaction.user, interaction.guild!.id);
const balance = user.balance?.N ?? 0;
return interaction.reply(`Your current balance is $${balance}`);
}

Deploying the New Command

Now that we’re using DynamoDB, we need to use the npm scripts we set up in Part 2. The sst shell wrapper ensures that linked resources like our DynamoDB table are available during the build process.

Deploy your changes:

Terminal window
npm run deploy

Register the new command with Discord:

Terminal window
npm run register

At this point, you should be able to use your fancy new /balance command in your Discord server.

Balance

Exploring the DynamoDB Table

You should be able to go directly to the DynamoDB Console and you should see a new table for your project. If you click into it and explore the items you should also see your new user entry.

DynamoDB Table

Daily Command

We have all the scaffolding we need to do a daily command. However we have a couple specific requirements for a daily, mainly the user can only claim a daily every 24 hours, for this we will leverage date-fns to handle the date logic.

bot/commands/daily.ts
import { differenceInHours, getTime, subHours } from "date-fns";
import type { CommandConfig, CommandInteraction } from "dressed";
import { getOrCreateUser, updateUser } from "../../utilities/dynamodb";
export const config = {
description: "Daily",
integration_type: "Guild",
contexts: ["Guild"],
guilds: ["YOUR_GUILD_ID"],
} satisfies CommandConfig;
export default async function(interaction: CommandInteraction) {
const user = await getOrCreateUser(interaction.user, interaction.guild!.id);
const dailyAmount = 100;
const now = Date.now();
const lastDaily = getTime(
Number(user?.lastDaily?.N) < 1
? subHours(new Date(), 25)
: Number(user?.lastDaily?.N)
);
if (differenceInHours(now, lastDaily) < 24) {
const hoursRemaining = Math.ceil(24 - differenceInHours(now, lastDaily));
return interaction.reply(`You can claim your daily in ${hoursRemaining} hours`);
}
const newBalance = Number.parseInt(user?.balance?.N ?? "0", 10) + dailyAmount;
await updateUser(user, {
lastDaily: { N: now.toString() },
balance: { N: newBalance.toString() },
});
return interaction.reply(
`You claimed your daily and received $100, you now have $${newBalance}`
);
}

You’ll see us taking full advantage of our utilities here as we get the user, check their last daily, and if they can claim their daily, we update their balance and last daily timestamp.

Now just:

Terminal window
npm run register && npm run deploy

And you’ll have your daily up and running!

Daily

High Risk, High Reward. Our Gamble Command

Next up, a gamble command! Again, we’ll use the same utilities we’ve been using so far. We’ll also take advantage of Dressed’s built in options to allow people to place custom bets.

First, install the Discord API types package we’ll need:

Terminal window
npm install discord-api-types

Now create the gamble command:

bot/commands/gamble.ts
import { MessageFlags } from "discord-api-types/v10";
import {
ActionRow,
Button,
type CommandConfig,
type CommandInteraction,
CommandOption,
TextDisplay,
} from "dressed";
import { getOrCreateUser, updateUser } from "../../utilities/dynamodb";
export const config = {
description: "Gamble",
integration_type: "Guild",
contexts: ["Guild"],
guilds: ["YOUR_GUILD_ID"],
options: [
CommandOption({
name: "bet",
description: "Amount to gamble",
type: "Integer",
required: false,
}),
],
} satisfies CommandConfig;
export default async function(interaction: CommandInteraction) {
const user = await getOrCreateUser(interaction.user, interaction.guild!.id);
// 100 is our minimum bet
const bet = interaction.getOption("bet")?.integer() ?? 100;
const balance = Number.parseInt(user?.balance?.N ?? "0", 10);
if (bet < 100) {
return interaction.reply({
content: `You must bet at least $100.`,
ephemeral: true,
});
}
if (bet > balance) {
return interaction.reply({
content: `You don't have enough to gamble! You need at least $${
bet - balance
}.`,
ephemeral: true,
});
}
const result = Math.random() > 0.75 ? bet * 3 : -bet;
const newBalance = balance + result;
await updateUser(user, { balance: { N: newBalance.toString() } });
return interaction.reply({
components: [
TextDisplay(
`You gambled $${bet} and ${result > 0 ? "won" : "lost"} $${Math.abs(
result
)}. You now have $${newBalance}`
)
],
flags: MessageFlags.IsComponentsV2,
});
}

Easy Re-Gamble

We can also add a button that allows us to re-gamble without having to type the command again.

bot/commands/gamble.ts
return interaction.reply({
components: [
TextDisplay(
`You gambled $${bet} and ${result > 0 ? 'won' : 'lost'} $${Math.abs(result)}. You now have $${newBalance}`,
),
ActionRow(
Button({
label: `Gamble Again ($${bet})`,
custom_id: `gamble-again-${bet}`,
}),
),
],
flags: MessageFlags.IsComponentsV2,
})

To make this work…we need a new component to handle the button click.

bot/components/buttons/gamble-again.ts
import {
ActionRow,
Button,
type MessageComponentInteraction,
TextDisplay,
} from "dressed";
import { getOrCreateUser, updateUser } from "../../../utilities/dynamodb";
export const pattern = `gamble-again-:amount(\\d+)`;
export default async function(
interaction: MessageComponentInteraction,
args: { amount: number }
) {
const user = await getOrCreateUser(interaction.user, interaction.guild!.id);
const amountToGamble = args.amount ?? 100;
const balance = Number.parseInt(user?.balance?.N ?? "0", 10);
if (amountToGamble > balance) {
return interaction.update({
components: [
TextDisplay(
`You don't have enough to gamble! You need at least $${
amountToGamble - balance
} more.`
),
],
});
}
const result = Math.random() > 0.75 ? amountToGamble * 3 : -amountToGamble;
const newBalance = balance + result;
await updateUser(user, { balance: { N: newBalance.toString() } });
return interaction.update({
components: [
TextDisplay(
`You gambled $${amountToGamble} and ${
result > 0 ? "won" : "lost"
} $${Math.abs(result)}. You now have $${newBalance}`
),
ActionRow(
Button({
label: `Gamble Again ($${amountToGamble})`,
custom_id: `gamble-again-${amountToGamble}`,
})
),
],
});
}

Now we have the ability to keep on gambling!

Gamble Again Button

Wrapping Up

Now you’ve got a fully functional, no cost, serverless Discord bot that requires no server to run. You can leverage any of the AWS services to continue iterating and expanding the functionality of your bot.

What’s Next?

Here are some ideas to expand your bot:

More Commands

  • Leaderboards using DynamoDB queries
  • Trading between users
  • Daily streaks with bonus rewards
  • More gamble commands such as blackjack, slots, and more

Resources

If you’ve gotten this far, thank you! I appreciate all you’ve gone through and would appreciate a share on social media!