Loyalty Points Bot Module
A bot module that tracks viewer loyalty points, provides a gambling command, counts hype moments via keyword detection, welcomes new subscribers, and sends periodic reminders. Demonstrates ctx.cache for fast reads and ctx.defer() for background database persistence.
Project structure
loyalty-points/
├── lumio.config.json
├── server/
│ ├── schema.ts
│ └── functions.ts
├── package.json
└── tsconfig.json
lumio.config.json
{
"$schema": "https://lumio.vision/schemas/lumio.config.schema.json",
"extension_id": "ext_placeholder",
"name": "Loyalty Points",
"category": "bot_module",
"version": "1.0.0",
"platforms": ["twitch", "youtube", "kick"],
"server": true,
"permissions": ["chat:send", "chat:read", "events:read"],
"triggers": {
"commands": [
{
"name": "points",
"description": "Show your current points balance",
"cooldown_global": 5,
"cooldown_user": 10,
"min_role": "everyone"
},
{
"name": "gamble",
"description": "Gamble your points (50/50 chance to double or lose)",
"cooldown_global": 10,
"cooldown_user": 30,
"min_role": "subscriber"
}
],
"keywords": ["gg", "nice shot", "pog"],
"events": ["twitch:subscribe", "youtube:member", "kick:subscribe"],
"timers": [
{
"name": "reminder",
"interval": 300,
"handler": "periodicReminder"
}
]
},
"config_schema": [
{
"key": "pointsPerMessage",
"type": "number",
"label": "Points earned per message",
"default_value": 1,
"required": true
},
{
"key": "gamblingEnabled",
"type": "boolean",
"label": "Allow gambling",
"default_value": true,
"required": true
},
{
"key": "maxBet",
"type": "number",
"label": "Maximum bet amount",
"default_value": 1000,
"required": true
},
{
"key": "welcomeBonus",
"type": "number",
"label": "Points bonus for new subscribers",
"default_value": 500,
"required": true
}
]
}
server/schema.ts
import { defineSchema, defineTable, column } from "@zaflun/lumio-sdk/server";
export default defineSchema({
user_points: defineTable({
user_id: column.string(),
platform: column.string(),
balance: column.number(),
total_earned: column.number(),
total_gambled: column.number(),
last_active: column.number(),
}),
point_history: defineTable({
user_id: column.string(),
amount: column.number(),
reason: column.string(),
timestamp: column.number(),
}),
hype_log: defineTable({
user_id: column.string(),
keyword: column.string(),
timestamp: column.number(),
}),
});
server/functions.ts
import { command, keyword, event, timer } from "@zaflun/lumio-sdk/server";
// !points — Show current balance
// Uses cache-first pattern: read from cache (~1ms), fall back to DB if cache miss
export const points = command("points", async (ctx, args) => {
const userId = ctx.user.id;
// Try cache first
let balance = (await ctx.cache.get(`pts:${userId}`)) as number | null;
if (balance === null) {
// Cache miss — read from DB and populate cache
const record = await ctx.db.get("user_points", userId);
balance = (record?.balance as number) ?? 0;
await ctx.cache.set(`pts:${userId}`, balance, 3600);
}
return { reply: `${ctx.user.displayName}: ${balance} points` };
});
// !gamble <amount> — 50/50 chance to double or lose
export const gamble = command("gamble", async (ctx, args) => {
const gamblingEnabled = (ctx.config.gamblingEnabled as boolean) ?? true;
if (!gamblingEnabled) {
return { reply: `${ctx.user.name}, gambling is disabled.` };
}
const maxBet = (ctx.config.maxBet as number) ?? 1000;
const betAmount = Math.min(parseInt(args[0] ?? "0", 10), maxBet);
if (isNaN(betAmount) || betAmount <= 0) {
return { reply: `${ctx.user.name}, usage: !gamble <amount> (max: ${maxBet})` };
}
// Read current balance from cache or DB
let balance = (await ctx.cache.get(`pts:${ctx.user.id}`)) as number | null;
if (balance === null) {
const record = await ctx.db.get("user_points", ctx.user.id);
balance = (record?.balance as number) ?? 0;
}
if (balance < betAmount) {
return { reply: `${ctx.user.name}, you only have ${balance} points.` };
}
// 50/50 gamble
const won = Math.random() >= 0.5;
const newBalance = won ? balance + betAmount : balance - betAmount;
// Update cache immediately
await ctx.cache.set(`pts:${ctx.user.id}`, newBalance, 3600);
// Persist to DB in background
ctx.defer(async () => {
await ctx.db.patch("user_points", ctx.user.id, {
balance: newBalance,
total_gambled: (balance ?? 0) + betAmount,
});
await ctx.db.insert("point_history", {
user_id: ctx.user.id,
amount: won ? betAmount : -betAmount,
reason: "gamble",
timestamp: Date.now(),
});
});
if (won) {
return { reply: `${ctx.user.displayName} won ${betAmount} points! New balance: ${newBalance}` };
}
return { reply: `${ctx.user.displayName} lost ${betAmount} points. Balance: ${newBalance}` };
});
// Keyword handler — fires on "gg", "nice shot", or "pog"
export const hypeDetector = keyword("gg", async (ctx, message) => {
// Increment hype counter in cache
const hypeCount = await ctx.cache.increment("hype_count", 1);
// Log to DB in background
ctx.defer(async () => {
await ctx.db.insert("hype_log", {
user_id: ctx.user.id,
keyword: message.text,
timestamp: Date.now(),
});
});
// Only reply on milestone hype counts
if (hypeCount % 100 === 0) {
return { reply: `Hype level: ${hypeCount}!` };
}
return null;
});
// Event handler — welcome new subscribers with bonus points
export const welcomeSub = event("twitch:subscribe", async (ctx, evt) => {
const bonus = (ctx.config.welcomeBonus as number) ?? 500;
const userName = evt.user_name as string;
// Add bonus points in background
ctx.defer(async () => {
const existing = await ctx.db.get("user_points", evt.user_id as string);
const currentBalance = (existing?.balance as number) ?? 0;
if (existing) {
await ctx.db.patch("user_points", evt.user_id as string, {
balance: currentBalance + bonus,
total_earned: ((existing.total_earned as number) ?? 0) + bonus,
});
} else {
await ctx.db.insert("user_points", {
user_id: evt.user_id,
platform: "twitch",
balance: bonus,
total_earned: bonus,
total_gambled: 0,
last_active: Date.now(),
});
}
// Update cache
await ctx.cache.set(`pts:${evt.user_id}`, currentBalance + bonus, 3600);
});
const months = (evt.cumulative_months as number) ?? 1;
return {
reply: `Welcome ${userName}! ${months} month(s) strong! You earned ${bonus} bonus points!`,
};
});
// Timer — periodic reminder every 5 minutes
export const periodicReminder = timer("reminder", async (ctx) => {
const hypeCount = (await ctx.cache.get("hype_count")) as number ?? 0;
if (hypeCount > 0) {
return { reply: `Current hype level: ${hypeCount}! Type !points to check your balance.` };
}
return { reply: "Earn points by chatting! Type !points to check your balance." };
});
Key concepts demonstrated
- Cache-first reads —
ctx.cache.get()for ~1ms point balance lookups, falling back toctx.dbon cache miss - Background persistence —
ctx.defer()to write to the database after the handler returns, keeping command responses under the 500ms deadline - Atomic increment —
ctx.cache.increment()for the hype counter without read-modify-write race conditions - Config-driven behavior —
ctx.config.gamblingEnabledandctx.config.maxBetlet users customize the module - Cross-platform events — the same
welcomeSubhandler works for Twitch, YouTube, and Kick subscriptions - Conditional replies — the keyword handler only replies on milestone counts, staying silent otherwise