Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
dfcb72e
Bump qs and express in /src
dependabot[bot] Feb 12, 2026
0a9b1f0
Merge branch 'main' into dev
fboucher Apr 2, 2026
e4c1f96
fix stream filename and add hide button
fboucher Apr 3, 2026
548aafc
fix version
fboucher Apr 3, 2026
ed036f2
jumping version for test
fboucher Apr 3, 2026
0facd3e
Merge branch 'main' into dev
fboucher Apr 3, 2026
a1d9683
feat: implement Ceebee AI Chat feature
fboucher May 25, 2026
1bd6251
Refactor Ceebee context to file and add soul.md
fboucher May 25, 2026
614ea7e
Merge branch 'implement-ceebee-ai-chat' into dev
fboucher May 25, 2026
c4be3fd
Add dual Twitch bot connections and @ceebee_bot mention detection
fboucher May 25, 2026
03dc716
Add soul_sample.md and allow it in .gitignore
fboucher May 25, 2026
9494c76
clean sample
fboucher May 25, 2026
4a266d1
Fix hide button in admin panel
fboucher May 25, 2026
5a2af94
feat: add custom CSS and HTML structure for project banners and annou…
fboucher May 28, 2026
1e98376
feat: add CloudBot client-side logic for session management, user tra…
fboucher May 28, 2026
498974d
Merge branch 'fixing-a-few-things' into dev
fboucher May 28, 2026
8b7e316
Merge branch 'main' into dev
fboucher May 28, 2026
fd3e8b5
feat(ceebee): implement auto-participation trigger and configuration …
fboucher May 29, 2026
4bb1ab2
fix(ceebee): allow broadcaster messages and add logging for auto-part…
fboucher May 29, 2026
28f69cb
fix(ceebee): allow broadcaster messages and add logging for auto-part…
fboucher May 29, 2026
c379999
Merge branch 'main' into dev
fboucher May 29, 2026
ffd7398
style: clean up whitespace and unnecessary console logs
fboucher May 29, 2026
e0b8e6c
Merge branch 'feature/ceebee-auto-participation-fixes' into dev
fboucher May 29, 2026
17c2ff6
Merge remote-tracking branch 'origin/dev' into dev
fboucher May 29, 2026
f94dad0
feat: add regulars flagging and dynamic AI greeting (#103)
fboucher May 29, 2026
f947e3b
fix(attention): wrap text at 30 characters to prevent off-screen over…
fboucher May 29, 2026
a5f6be1
Merge branch 'dev' into dependabot/npm-express-bump
fboucher May 29, 2026
b28cc06
Merge remote-tracking branch 'origin/dev' into dependabot-express-qs
fboucher May 29, 2026
e24f291
Implement RPG Loot Game, dynamic weather overlay, and visual/audio re…
fboucher May 29, 2026
b918423
feat: initialize core CloudBot client logic and data structures
fboucher May 29, 2026
61f24dd
Merge branch 'main' into dev to resolve PR #105 merge conflicts
fboucher May 29, 2026
db396bf
fix: per-cloud base speed with event-driven multipliers (#111)
fboucher Jun 23, 2026
11d86f3
fix(ceebee): prevent infinite chat loop by ignoring ceebee_bot messag…
fboucher Jun 28, 2026
bba0e03
fix(notes): resolve event duplication and add missing subscription da…
fboucher Jun 28, 2026
61691d0
chore: begin merge conflict resolution
Copilot Jun 28, 2026
5f35430
fix: resolve merge conflicts between dev and main branches
Copilot Jun 28, 2026
b6a6453
quickfix: handle null raiders viewers in export/overlay (#118)
fboucher Jul 3, 2026
a456376
Merge remote-tracking branch 'origin/main' into dev
fboucher Jul 3, 2026
336629e
Refactor loot item effects into dedicated fx.js module (#133)
fboucher Oct 8, 2026
508221d
New CeeBee GIFs and overlay layout fixes (#134)
fboucher Oct 9, 2026
be2b7a2
v1.11.0 and merge duplicate !drop player scores (#135)
fboucher Oct 9, 2026
4fbd895
Merge main into dev to resolve PR #136 conflicts
claude Oct 9, 2026
188da3d
Update base image in Dockerfile from node:18-bullseye-slim to node:18…
fboucher Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM node:18-bullseye-slim
FROM node:18-bookworm-slim
WORKDIR /usr/src/app

# Basic tools for healthcheck and build deps for canvas
Expand Down
79 changes: 66 additions & 13 deletions src/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,37 @@ const path = require("path");
let db;
const dbPath = path.join(__dirname, "io", "cloudbot.db") + "?nolock=1";

const sanitizeUsername = (name) =>
String(name || "")
.trim()
.replace(/^@+/, "")
.replace(/[,.:;!?]+$/, "")
.trim()
.toLowerCase();

// Collapse game score entries that belong to the same player ("@User", "@@user", "user")
// into one: counts are added up, scores keep the best value.
function mergeUserScores(users) {
const merged = new Map();
for (const u of users) {
const name = sanitizeUsername(u.user);
if (!name) continue;
const prev = merged.get(name);
if (!prev) {
merged.set(name, { ...u, user: name });
continue;
}
prev.dropCount = (prev.dropCount || 0) + (u.dropCount || 0);
prev.landedCount = (prev.landedCount || 0) + (u.landedCount || 0);
prev.highScore = Math.max(prev.highScore || 0, u.highScore || 0);
prev.bestHighScore = Math.max(prev.bestHighScore || 0, u.bestHighScore || 0);
if (u.lastUpdate && (!prev.lastUpdate || new Date(u.lastUpdate) > new Date(prev.lastUpdate))) {
prev.lastUpdate = u.lastUpdate;
}
}
return [...merged.values()];
}

async function initDb() {
const { connect } = await import("@tursodatabase/database");
db = await connect(dbPath);
Expand Down Expand Up @@ -218,6 +249,37 @@ async function createTables() {
console.error("Migration error cleaning '@' usernames:", e.message);
}

// Migration: merge duplicate game score rows for the same player in a session
try {
const rows = await db.prepare("SELECT * FROM users ORDER BY session_id, id").all();
const bySession = new Map();
for (const r of rows) {
if (!bySession.has(r.session_id)) bySession.set(r.session_id, []);
bySession.get(r.session_id).push(r);
}
let mergedSessions = 0;
for (const [sessionId, sessionRows] of bySession) {
const users = sessionRows.map(r => ({
user: r.username, dropCount: r.drop_count, landedCount: r.landed_count,
highScore: r.high_score, bestHighScore: r.best_high_score, lastUpdate: r.last_update
}));
const merged = mergeUserScores(users);
const unchanged = merged.length === sessionRows.length && sessionRows.every(r => r.username === sanitizeUsername(r.username));
if (unchanged) continue;
await db.prepare("DELETE FROM users WHERE session_id = ?").run(sessionId);
for (const u of merged) {
await db.prepare(
`INSERT INTO users (session_id, username, drop_count, landed_count, high_score, best_high_score, last_update)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(sessionId, u.user, u.dropCount || 0, u.landedCount || 0, u.highScore || 0, u.bestHighScore || 0, u.lastUpdate || null);
}
mergedSessions++;
}
if (mergedSessions > 0) console.log(`Migration: Merged duplicate player scores in ${mergedSessions} session(s)`);
} catch (e) {
console.error("Migration error merging duplicate player scores:", e.message);
}

const counterRow = await db.prepare("SELECT * FROM stream_counter WHERE id = 1").get();
if (!counterRow) {
await db.prepare("INSERT INTO stream_counter (id, current_stream_number, last_stream_date) VALUES (?, ?, ?)").run(1, 0, "");
Expand Down Expand Up @@ -287,19 +349,10 @@ async function getAllSessions() {
async function saveSessionData(sessionId, data) {
if (!db) await initDb();

const sanitizeUsername = (name) =>
String(name || "")
.trim()
.replace(/^@+/, "")
.replace(/[,.:;!?]+$/, "")
.trim()
.toLowerCase();

if (data.UserSession) {
await db.prepare("DELETE FROM users WHERE session_id = ?").run(sessionId);
for (const user of data.UserSession) {
const cleanUsername = sanitizeUsername(user.user);
if (!cleanUsername) continue;
for (const user of mergeUserScores(data.UserSession)) {
const cleanUsername = user.user;
await db.prepare(
`INSERT INTO users (session_id, username, drop_count, landed_count, high_score, best_high_score, last_update)
VALUES (?, ?, ?, ?, ?, ?, ?)`
Expand Down Expand Up @@ -419,14 +472,14 @@ async function loadSessionData(sessionId) {
data.Notes = notesRows.map(n => n.text);
}

data.UserSession = (await db.prepare("SELECT * FROM users WHERE session_id = ?").all(sessionId)).map(u => ({
data.UserSession = mergeUserScores((await db.prepare("SELECT * FROM users WHERE session_id = ?").all(sessionId)).map(u => ({
user: u.username,
dropCount: u.drop_count,
landedCount: u.landed_count,
highScore: u.high_score,
bestHighScore: u.best_high_score,
lastUpdate: u.last_update
}));
})));

data.Todos = (await db.prepare("SELECT * FROM todos WHERE session_id = ?").all(sessionId)).map(t => ({
id: t.id,
Expand Down
4 changes: 2 additions & 2 deletions src/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cloudbot",
"version": "1.10.0",
"version": "1.11.0",
"description": "Twitch ChatBot and Score keeper",
"main": "index.js",
"scripts": {
Expand Down
78 changes: 59 additions & 19 deletions src/public/cloudbot.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,34 @@ sanitizeUsername = function (name) {
.toLowerCase();
}

// Load saved scores as UserSession objects, merging entries for the same player
// ("@User", "@@user", "user") so one player never shows up more than once.
loadUserSessions = function (savedUsers) {
const merged = [];
for (const o of savedUsers || []) {
const name = sanitizeUsername(o.user);
if (!name) continue;
const existing = merged.find(u => u.user === name);
if (!existing) {
const newUser = new UserSession();
for (const [key, value] of Object.entries(o)) {
newUser[key] = value;
}
newUser.user = name;
merged.push(newUser);
continue;
}
existing.dropCount = (existing.dropCount || 0) + (o.dropCount || 0);
existing.landedCount = (existing.landedCount || 0) + (o.landedCount || 0);
existing.highScore = Math.max(existing.highScore || 0, o.highScore || 0);
existing.bestHighScore = Math.max(existing.bestHighScore || 0, o.bestHighScore || 0);
if (o.lastUpdate && (!existing.lastUpdate || new Date(o.lastUpdate) > new Date(existing.lastUpdate))) {
existing.lastUpdate = o.lastUpdate;
}
}
return merged;
}

getUserPosition = function (userName) {
console.log("... Searching for: " + userName);
userName = sanitizeUsername(userName);
Expand Down Expand Up @@ -212,11 +240,33 @@ cloud = function (expression) {
ChatBotShow = function (expression, imgText) {

const fileName = "CB-" + expression + ".gif";
document.querySelector("#imageViewer").innerHTML = "<img src='public/medias/" + fileName + "' class='nuage'>";
document.querySelector("#imageViewer").innerHTML += "<img src='public/medias/generated/" + imgText + "' class='textBubble'>";
const viewer = document.querySelector("#imageViewer");
// Attention messages are long: push Ceebee further left to leave room for the text
const ceebeeClass = expression === 'Talking' ? 'nuage nuage-left' : 'nuage';
viewer.innerHTML = "<img src='public/medias/" + fileName + "' class='" + ceebeeClass + "'>";
if (imgText) {
const bubble = new Image();
bubble.className = 'textBubble';
bubble.style.visibility = 'hidden';
// Attention text is long: keep it at its natural size at most
const maxScale = expression === 'Talking' ? 1 : 2;
bubble.onload = () => fitTextBubble(bubble, maxScale);
bubble.src = 'public/medias/generated/' + imgText;
viewer.appendChild(bubble);
}
setTimeout(() => { clean(); }, 5000);
}

// Scale the text bubble up to maxScale, but never past the right edge or the screen height
fitTextBubble = function (bubble, maxScale) {
const left = bubble.getBoundingClientRect().left;
const availableWidth = window.innerWidth - left - 20;
const availableHeight = window.innerHeight * 0.9;
const scale = Math.min(maxScale, availableWidth / bubble.naturalWidth, availableHeight / bubble.naturalHeight);
bubble.style.transform = 'scale(' + scale + ')';
bubble.style.visibility = 'visible';
}

sleep = function (ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
Expand Down Expand Up @@ -425,7 +475,7 @@ hello = function (user) {
//ChatBotSay(result.msg);

setTimeout(() => {
ChatBotShow('Thumbs-up', result.msg)
ChatBotShow('Hello', result.msg)
}, 1000);

})
Expand All @@ -451,7 +501,7 @@ Attention = function (user, message) {
console.log('Success:', result);

setTimeout(() => {
ChatBotShow('Thumbs-up', result.msg)
ChatBotShow('Talking', result.msg)
playSound("hmmhmm", SoundEnum.hmmhmm);
}, 1000);

Expand Down Expand Up @@ -637,12 +687,7 @@ LoadStreamSession = function (data, projectName, isReload, callback) {


// loading users scores
_streamSession.UserSession = data.UserSession.map((o) => {
const newUser = new UserSession();
for (const [key, value] of Object.entries(o)) {
newUser[key] = value;
} return newUser;
});
_streamSession.UserSession = loadUserSessions(data.UserSession);

if (callback !== undefined && callback !== null) {
callback(projectName);
Expand Down Expand Up @@ -1599,16 +1644,16 @@ function handleEffect(effect) {
switch (effect.type) {
case 'hello':
if (effect.image) {
ChatBotShow('Thumbs-up', effect.image);
ChatBotShow('Hello', effect.image);
} else {
cloud('Thumbs-up');
cloud('Hello');
}
playSound('yeah', SoundEnum.yeah);
break;

case 'attention':
if (effect.image) {
ChatBotShow('Thumbs-up', effect.image);
ChatBotShow('Talking', effect.image);
}
playSound('hmmhmm', SoundEnum.hmmhmm);
break;
Expand Down Expand Up @@ -1767,12 +1812,7 @@ async function loadSessionFromDb() {
}

if (data.UserSession) {
_streamSession.UserSession = data.UserSession.map((o) => {
const newUser = new UserSession();
for (const [key, value] of Object.entries(o)) {
newUser[key] = value;
} return newUser;
});
_streamSession.UserSession = loadUserSessions(data.UserSession);
}

if (data.NewFollowers) {
Expand Down
Loading
Loading