Add permanent user-memory via: remember, forget, memories
This commit is contained in:
@@ -3,6 +3,7 @@ node_modules/
|
||||
games.json
|
||||
owned.json
|
||||
steamlinks.json
|
||||
memory.yaml
|
||||
.link-steam-profiles-owner.json
|
||||
bot.log
|
||||
bot.error.log
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Discord Game Price Bot
|
||||
|
||||
A Discord bot for your server. Type `!watch GAME NAME` in any channel to start tracking a PC game's price, `!remove GAME NAME` (or `!unwatch GAME NAME`) to stop, `!list` to see what is being watched, `!price GAME NAME` to check a price without watching it, `!history GAME NAME` to see every recorded sale over the past 12 months with date and price, `!predict GAME NAME` to guess when it might go on sale next, `!info GAME NAME` to see its platforms, multiplayer/co-op/cross-play info, a link to its store page, and how many people on the server own it, `!own GAME NAME` to mark a game as owned by you, `!unown GAME NAME` to remove it from your owned list, `!my-steam-profile LINK` to link your Steam profile so `!info` can count you automatically, `!bulk-link` to link several members' Steam profiles at once if you already have them, `!who GAME NAME` to list who has confirmed they own a game, `!check` to run the sale check on demand, `!shops` to see which stores it covers. Once a day it checks the best current price across PC stores for everything watched and posts in the channel it was added from when something is on sale, with the title, sale price, and which store it is at.
|
||||
A Discord bot for your server. Type `!watch GAME NAME` in any channel to start tracking a PC game's price, `!remove GAME NAME` (or `!unwatch GAME NAME`) to stop, `!list` to see what is being watched, `!price GAME NAME` to check a price without watching it, `!history GAME NAME` to see every recorded sale over the past 12 months with date and price, `!predict GAME NAME` to guess when it might go on sale next, `!info GAME NAME` to see its platforms, multiplayer/co-op/cross-play info, a link to its store page, and how many people on the server own it, `!own GAME NAME` to mark a game as owned by you, `!unown GAME NAME` to remove it from your owned list, `!my-steam-profile LINK` to link your Steam profile so `!info` can count you automatically, `!bulk-link` to link several members' Steam profiles at once if you already have them, `!who GAME NAME` to list who has confirmed they own a game, `!remember SOMETHING` to permanently teach it a fact about you that survives restarts, `!forget SOMETHING` (or `!forget all`) to make it forget something you had it remember, `!memories` to see everything it remembers about you, `!check` to run the sale check on demand, `!shops` to see which stores it covers. Once a day it checks the best current price across PC stores for everything watched and posts in the channel it was added from when something is on sale, with the title, sale price, and which store it is at.
|
||||
|
||||
Mention the bot's trigger word anywhere in a message (that doesn't start with `!`) and, if Ollama is installed and running (and `AI_ENABLED` isn't turned off), it thinks up a short, in-character answer to whatever you said using a free AI model running right on your own computer - no API key, no cost, nothing sent anywhere over the internet. Without Ollama running (or with `AI_ENABLED=false`), it doesn't reply to mentions at all. It never fires on `!` commands, even ones that happen to contain the trigger word. The bot's name, trigger word, and personality are entirely up to you - edit `personality.yaml` to set them (see [Notes](#notes)); it ships pre-filled as BMO from Adventure Time as an example.
|
||||
|
||||
@@ -72,7 +72,7 @@ To change the token or key later, edit `.env` directly, then run the installer a
|
||||
- The daily check runs at 8am Arizona time. To change it, edit the `cron.schedule("0 15 * * *", ...)` line in `index.js` (that time is in UTC).
|
||||
- The daily check only alerts once per discount level per game. If the price drops further while still on sale, you get a new alert. If it goes back to full price and later goes on sale again, that is a new alert too.
|
||||
- Everything watched is stored in `games.json` in this folder, in plain text, so you can also open it directly if you ever want to see or edit the list by hand.
|
||||
- `games.json`, `owned.json`, `steamlinks.json`, and `.env` hold your server's actual data and are left out of git (`.gitignore`) so they never end up in a public repo by accident. The installer creates the three JSON files from their `.json.example` templates automatically if they don't exist yet.
|
||||
- `games.json`, `owned.json`, `steamlinks.json`, `memory.yaml`, and `.env` hold your server's actual data and are left out of git (`.gitignore`) so they never end up in a public repo by accident. The installer creates each of these from its matching `.example` template automatically if it doesn't exist yet.
|
||||
- The installer manages the bot with [pm2](https://pm2.keymetrics.io), so once it's set up you can check on it with plain pm2 commands any time: `pm2 status` (is it running), `pm2 logs discord-game-price-bot` (live logs), `pm2 restart discord-game-price-bot`, `pm2 stop discord-game-price-bot`, and `pm2 delete discord-game-price-bot` (remove it from pm2 entirely - re-run the installer to add it back).
|
||||
- If you're upgrading from an older version of this bot that used launchd directly on macOS, `install.sh` detects and automatically removes that old service the first time you run it, so you don't end up with two copies of the bot running and double-replying in Discord.
|
||||
- To add, remove, or change greetings, edit `greetings.yaml`. Use `{user}` anywhere in a greeting and it gets replaced with a mention of the person who joined.
|
||||
@@ -89,7 +89,8 @@ To change the token or key later, edit `.env` directly, then run the installer a
|
||||
@Sam https://steamcommunity.com/profiles/76561198012345678
|
||||
```
|
||||
Each @mention has to actually be picked from Discord's autocomplete popup while typing (so it turns into a real link, shown highlighted) - plain typed text like `@Alex` that was never selected from that popup will not be recognized, and that line will be skipped. It replies back with who it managed to link and which lines it could not, so you can fix and re-run just those.
|
||||
- The bot only replies to mentions of its trigger word if Ollama is installed, running, and has the model in `OLLAMA_MODEL` downloaded - without that, mentioning it gets no reply at all, and nothing is logged (check `bot.error.log` if you expect it to be working and it isn't replying). Each person can only trigger an AI answer once every 15 seconds; asking again sooner (or the Ollama request failing) just gets no reply rather than a second request, which keeps a burst of questions from bogging down your computer generating several replies at once.
|
||||
- The bot only replies to mentions of its trigger word if Ollama is installed, running, and has the model in `OLLAMA_MODEL` downloaded - without that, mentioning it gets no reply at all, and nothing is logged (check `bot.error.log` if you expect it to be working and it isn't replying). Each person can only trigger an AI answer once every 5 seconds; asking again sooner (or the Ollama request failing) just gets no reply rather than a second request, which keeps a burst of questions from bogging down your computer generating several replies at once.
|
||||
- `!remember SOMETHING` teaches it a permanent fact about you - a name, a preference, a correction like "don't call me buddy" - that it will always take into account when it replies to you, even after the bot restarts. These facts are saved to `memory.yaml`, per person, and never expire on their own, unlike the short-term memory described below. `!forget SOMETHING` removes one again (it has to match what you typed to `!remember`, word for word - use `!memories` to see the exact wording it has stored), and `!forget all` clears everything it remembers about you at once. Each person can have up to 20 things remembered at a time.
|
||||
- `OLLAMA_MODEL` in `.env` controls which model it uses (`llama3.2` by default - small, fast, and good enough for short in-character replies). To use a different one, pull it yourself with `ollama pull MODEL_NAME`, set `OLLAMA_MODEL=MODEL_NAME` in `.env`, and restart it (re-run the installer, or `pm2 restart discord-game-price-bot`). Bigger models give better answers but take longer to reply and use more of your computer's memory.
|
||||
- It remembers each person's last 3 exchanges (their message and its reply) so it can follow up naturally and stick to things you told it earlier in the conversation, like a correction or a preference. This memory is per-person, kept only in the bot's running memory (not saved to disk), and forgotten automatically after 30 minutes of that person not mentioning it.
|
||||
- The bot's name, trigger word, and personality all come from `personality.yaml`. It ships set up as BMO from Adventure Time:
|
||||
|
||||
@@ -26,6 +26,9 @@ const GREETINGS_PATH = path.join(__dirname, "greetings.yaml");
|
||||
const GOODBYES_PATH = path.join(__dirname, "goodbyes.yaml");
|
||||
const README_PATH = path.join(__dirname, "README.md");
|
||||
const PERSONALITY_PATH = path.join(__dirname, "personality.yaml");
|
||||
const MEMORY_PATH = path.join(__dirname, "memory.yaml");
|
||||
const MAX_MEMORIES_PER_USER = 20;
|
||||
const MAX_MEMORY_LENGTH = 300;
|
||||
const MONTH_NAMES = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"
|
||||
@@ -117,7 +120,22 @@ function trimIncompleteSentence(text) {
|
||||
return trimmed.slice(0, lastEnd + 1);
|
||||
}
|
||||
|
||||
async function askAI(question, history, displayName) {
|
||||
async function askAI(question, history, displayName, memories) {
|
||||
const messages = [
|
||||
{ role: "system", content: AI_SYSTEM_PROMPT },
|
||||
{ role: "system", content: "The Discord member you are talking to is called " + displayName + ". Do not address them by any other name." }
|
||||
];
|
||||
|
||||
if (memories && memories.length > 0) {
|
||||
messages.push({
|
||||
role: "system",
|
||||
content: "Permanent facts this member has asked you to remember about them - always follow these, " +
|
||||
"they override your default personality habits:\n" + memories.map((m) => "- " + m).join("\n")
|
||||
});
|
||||
}
|
||||
|
||||
messages.push(...history, { role: "user", content: question });
|
||||
|
||||
const res = await fetch(OLLAMA_URL + "/api/chat", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -127,12 +145,7 @@ async function askAI(question, history, displayName) {
|
||||
model: OLLAMA_MODEL,
|
||||
stream: false,
|
||||
options: { num_predict: 120 },
|
||||
messages: [
|
||||
{ role: "system", content: AI_SYSTEM_PROMPT },
|
||||
{ role: "system", content: "The Discord member you are talking to is called " + displayName + ". Do not address them by any other name." },
|
||||
...history,
|
||||
{ role: "user", content: question }
|
||||
]
|
||||
messages: messages
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -182,6 +195,19 @@ function saveSteamLinks(links) {
|
||||
fs.writeFileSync(STEAMLINKS_PATH, JSON.stringify(links, null, 2) + "\n");
|
||||
}
|
||||
|
||||
function loadMemory() {
|
||||
try {
|
||||
const data = yaml.load(fs.readFileSync(MEMORY_PATH, "utf8"));
|
||||
return data || {};
|
||||
} catch (err) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveMemory(memory) {
|
||||
fs.writeFileSync(MEMORY_PATH, yaml.dump(memory));
|
||||
}
|
||||
|
||||
async function searchGame(title) {
|
||||
const url = ITAD_BASE + "/games/search/v1?key=" + ITAD_API_KEY +
|
||||
"&title=" + encodeURIComponent(title) + "&results=5";
|
||||
@@ -846,6 +872,89 @@ async function handleUnown(message, query) {
|
||||
await message.reply("Removed \"" + match.title + "\" from your owned list.");
|
||||
}
|
||||
|
||||
async function handleRemember(message, text) {
|
||||
if (!text) {
|
||||
await message.reply("Usage: !remember SOMETHING (e.g. !remember my name is Justin, or !remember don't call me buddy)");
|
||||
return;
|
||||
}
|
||||
|
||||
const memory = loadMemory();
|
||||
const list = memory[message.author.id] || [];
|
||||
|
||||
if (list.length >= MAX_MEMORIES_PER_USER) {
|
||||
await message.reply(
|
||||
"I'm already remembering " + list.length + " things about you, that's my limit. " +
|
||||
"Use !memories to see them and !forget one first."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const fact = text.length > MAX_MEMORY_LENGTH ? text.slice(0, MAX_MEMORY_LENGTH) : text;
|
||||
|
||||
if (list.some((m) => m.toLowerCase() === fact.toLowerCase())) {
|
||||
await message.reply("I already remember that.");
|
||||
return;
|
||||
}
|
||||
|
||||
list.push(fact);
|
||||
memory[message.author.id] = list;
|
||||
saveMemory(memory);
|
||||
|
||||
await message.reply("Got it, I'll remember: " + fact);
|
||||
}
|
||||
|
||||
async function handleForget(message, text) {
|
||||
if (!text) {
|
||||
await message.reply("Usage: !forget SOMETHING (must match what you had me remember), or !forget all");
|
||||
return;
|
||||
}
|
||||
|
||||
const memory = loadMemory();
|
||||
const list = memory[message.author.id] || [];
|
||||
|
||||
if (list.length === 0) {
|
||||
await message.reply("I don't have anything remembered about you.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.toLowerCase() === "all") {
|
||||
delete memory[message.author.id];
|
||||
saveMemory(memory);
|
||||
await message.reply("Forgot everything I knew about you.");
|
||||
return;
|
||||
}
|
||||
|
||||
const index = list.findIndex((m) => m.toLowerCase() === text.toLowerCase());
|
||||
if (index === -1) {
|
||||
await message.reply("I don't have that saved. Use !memories to see what I remember about you.");
|
||||
return;
|
||||
}
|
||||
|
||||
list.splice(index, 1);
|
||||
if (list.length === 0) {
|
||||
delete memory[message.author.id];
|
||||
} else {
|
||||
memory[message.author.id] = list;
|
||||
}
|
||||
saveMemory(memory);
|
||||
|
||||
await message.reply("Forgot it.");
|
||||
}
|
||||
|
||||
async function handleMemories(message) {
|
||||
const memory = loadMemory();
|
||||
const list = memory[message.author.id] || [];
|
||||
|
||||
if (list.length === 0) {
|
||||
await message.reply("I don't have anything remembered about you yet. Use !remember SOMETHING to teach me.");
|
||||
return;
|
||||
}
|
||||
|
||||
await message.reply(
|
||||
"Here's what I remember about you:\n" + list.map((m, i) => (i + 1) + ". " + m).join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
async function handleLinkSteam(message, input) {
|
||||
if (!STEAM_API_KEY) {
|
||||
await message.reply("Steam linking is not set up on this bot yet - it needs a Steam Web API key added to .env.");
|
||||
@@ -1068,6 +1177,9 @@ async function handleHelp(message) {
|
||||
"!my-steam-profile LINK - link your Steam profile so !info can count you automatically\n" +
|
||||
"!bulk-link - link multiple members' Steam profiles at once, one @member and link per line\n" +
|
||||
"!who GAME - list who has confirmed they own a game\n" +
|
||||
"!remember SOMETHING - permanently teach me a fact about you (a name, a preference), I'll remember it across restarts\n" +
|
||||
"!forget SOMETHING - make me forget something you had me remember (must match exactly), or !forget all\n" +
|
||||
"!memories - show everything I remember about you\n" +
|
||||
"!check - run the sale check right now instead of waiting for the daily run\n" +
|
||||
"!shops - list every store I check prices at"
|
||||
);
|
||||
@@ -1224,6 +1336,12 @@ client.on("messageCreate", async (message) => {
|
||||
await handleLinkSteam(message, content.slice(18).trim());
|
||||
} else if (lower.startsWith("!who ")) {
|
||||
await handleWho(message, content.slice(5).trim());
|
||||
} else if (lower.startsWith("!remember ")) {
|
||||
await handleRemember(message, content.slice(10).trim());
|
||||
} else if (lower.startsWith("!forget ")) {
|
||||
await handleForget(message, content.slice(8).trim());
|
||||
} else if (lower === "!memories") {
|
||||
await handleMemories(message);
|
||||
} else if (lower.startsWith("!bulk-link")) {
|
||||
const lines = content.split("\n");
|
||||
await handleBulkLink(message, lines.slice(1).join("\n"));
|
||||
@@ -1241,7 +1359,9 @@ client.on("messageCreate", async (message) => {
|
||||
const history = getAiHistory(message.author.id);
|
||||
const displayName = (message.member && message.member.displayName) ||
|
||||
message.author.globalName || message.author.username;
|
||||
const aiReply = await askAI(content, history, displayName);
|
||||
const memory = loadMemory();
|
||||
const memories = memory[message.author.id] || [];
|
||||
const aiReply = await askAI(content, history, displayName, memories);
|
||||
if (aiReply) {
|
||||
await message.reply(aiReply);
|
||||
rememberAiExchange(message.author.id, content, aiReply);
|
||||
|
||||
@@ -75,6 +75,10 @@ if (-not (Test-Path "steamlinks.json")) {
|
||||
Copy-Item "steamlinks.json.example" "steamlinks.json"
|
||||
}
|
||||
|
||||
if (-not (Test-Path "memory.yaml")) {
|
||||
Copy-Item "memory.yaml.example" "memory.yaml"
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace((Get-EnvValue "DISCORD_BOT_TOKEN"))) {
|
||||
Write-Host ""
|
||||
Write-Host "Need a Discord bot token:"
|
||||
|
||||
@@ -78,6 +78,10 @@ if [[ ! -f steamlinks.json ]]; then
|
||||
cp steamlinks.json.example steamlinks.json
|
||||
fi
|
||||
|
||||
if [[ ! -f memory.yaml ]]; then
|
||||
cp memory.yaml.example memory.yaml
|
||||
fi
|
||||
|
||||
CURRENT_TOKEN=$(grep -E "^DISCORD_BOT_TOKEN=" .env | cut -d "=" -f2-)
|
||||
CURRENT_KEY=$(grep -E "^ITAD_API_KEY=" .env | cut -d "=" -f2-)
|
||||
|
||||
|
||||
Generated
+3
-2
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "game-price-bot",
|
||||
"name": "discord-game-price-bot",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "game-price-bot",
|
||||
"name": "discord-game-price-bot",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"discord.js": "^14.15.3",
|
||||
"dotenv": "^16.4.5",
|
||||
|
||||
Reference in New Issue
Block a user