Spaces:
Sleeping
Sleeping
Sasha commited on
Commit ·
e0e1da7
1
Parent(s): 78d6a51
Optimize moderation logging: fix duplicate collisions, auto-refresh streamer OAuth tokens, and implement IRC keep-alive timeouts. Add unit tests.
Browse files- local_worker/chat_mod_worker.py +7 -0
- local_worker/worker.py +7 -0
- server/db.js +76 -36
- server/eventsub.js +57 -4
- server/scratch/test_mod_duplicates.js +119 -0
- server/scratch/test_token_refresh.js +172 -0
local_worker/chat_mod_worker.py
CHANGED
|
@@ -76,8 +76,14 @@ def twitch_irc_listener():
|
|
| 76 |
|
| 77 |
buffer = ""
|
| 78 |
irc_sock.setblocking(False)
|
|
|
|
| 79 |
|
| 80 |
while not stop_flag.is_set():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
ready = select.select([irc_sock], [], [], 1.0)
|
| 82 |
if not ready[0]:
|
| 83 |
continue
|
|
@@ -91,6 +97,7 @@ def twitch_irc_listener():
|
|
| 91 |
print("[Twitch IRC] Connection closed by remote host.")
|
| 92 |
break
|
| 93 |
|
|
|
|
| 94 |
buffer += data
|
| 95 |
while "\r\n" in buffer:
|
| 96 |
line, buffer = buffer.split("\r\n", 1)
|
|
|
|
| 76 |
|
| 77 |
buffer = ""
|
| 78 |
irc_sock.setblocking(False)
|
| 79 |
+
last_msg_time = time.time()
|
| 80 |
|
| 81 |
while not stop_flag.is_set():
|
| 82 |
+
# Check for network timeout (no messages for 5 minutes)
|
| 83 |
+
if time.time() - last_msg_time > 300:
|
| 84 |
+
print("[Twitch IRC] Network timeout (no messages for 5 minutes). Reconnecting...")
|
| 85 |
+
break
|
| 86 |
+
|
| 87 |
ready = select.select([irc_sock], [], [], 1.0)
|
| 88 |
if not ready[0]:
|
| 89 |
continue
|
|
|
|
| 97 |
print("[Twitch IRC] Connection closed by remote host.")
|
| 98 |
break
|
| 99 |
|
| 100 |
+
last_msg_time = time.time()
|
| 101 |
buffer += data
|
| 102 |
while "\r\n" in buffer:
|
| 103 |
line, buffer = buffer.split("\r\n", 1)
|
local_worker/worker.py
CHANGED
|
@@ -102,8 +102,14 @@ def twitch_chat_listener():
|
|
| 102 |
|
| 103 |
buffer = ""
|
| 104 |
irc_sock.setblocking(False)
|
|
|
|
| 105 |
|
| 106 |
while not stop_flag.is_set():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
# Use select for non-blocking read with timeout to allow exit checks
|
| 108 |
ready = select.select([irc_sock], [], [], 1.0)
|
| 109 |
if not ready[0]:
|
|
@@ -118,6 +124,7 @@ def twitch_chat_listener():
|
|
| 118 |
print("[Twitch IRC] Connection closed by remote host.")
|
| 119 |
break
|
| 120 |
|
|
|
|
| 121 |
buffer += data
|
| 122 |
while "\r\n" in buffer:
|
| 123 |
line, buffer = buffer.split("\r\n", 1)
|
|
|
|
| 102 |
|
| 103 |
buffer = ""
|
| 104 |
irc_sock.setblocking(False)
|
| 105 |
+
last_msg_time = time.time()
|
| 106 |
|
| 107 |
while not stop_flag.is_set():
|
| 108 |
+
# Check for network timeout (no messages for 5 minutes)
|
| 109 |
+
if time.time() - last_msg_time > 300:
|
| 110 |
+
print("[Twitch IRC] Network timeout (no messages for 5 minutes). Reconnecting...")
|
| 111 |
+
break
|
| 112 |
+
|
| 113 |
# Use select for non-blocking read with timeout to allow exit checks
|
| 114 |
ready = select.select([irc_sock], [], [], 1.0)
|
| 115 |
if not ready[0]:
|
|
|
|
| 124 |
print("[Twitch IRC] Connection closed by remote host.")
|
| 125 |
break
|
| 126 |
|
| 127 |
+
last_msg_time = time.time()
|
| 128 |
buffer += data
|
| 129 |
while "\r\n" in buffer:
|
| 130 |
line, buffer = buffer.split("\r\n", 1)
|
server/db.js
CHANGED
|
@@ -30,7 +30,7 @@ console.log(`[Database] Initializing in Mode: ${dbMode.toUpperCase()}`);
|
|
| 30 |
let sqliteDb = null;
|
| 31 |
|
| 32 |
if (dbMode === 'sqlite') {
|
| 33 |
-
const dbPath = path.resolve('database.db');
|
| 34 |
console.log(`[Database] SQLite file location: ${dbPath}`);
|
| 35 |
sqliteDb = new Database(dbPath);
|
| 36 |
|
|
@@ -625,18 +625,23 @@ export async function logModAction(action) {
|
|
| 625 |
const streamId = stream ? stream.id : null;
|
| 626 |
|
| 627 |
// Prevent duplicate actions (e.g., from both EventSub and local IRC worker running together)
|
|
|
|
| 628 |
if (action.targetUser) {
|
| 629 |
const targetUserLower = action.targetUser.toLowerCase();
|
| 630 |
if (dbMode === 'sqlite') {
|
| 631 |
const existing = sqliteDb.prepare(`
|
| 632 |
-
SELECT id FROM mod_actions
|
| 633 |
WHERE action_type = ?
|
| 634 |
AND target_user = ?
|
| 635 |
AND abs(strftime('%s', timestamp) - strftime('%s', ?)) < 15
|
| 636 |
`).get(action.actionType, targetUserLower, time);
|
| 637 |
if (existing) {
|
| 638 |
-
|
| 639 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 640 |
}
|
| 641 |
} else {
|
| 642 |
const timeMs = new Date(time).getTime();
|
|
@@ -645,7 +650,7 @@ export async function logModAction(action) {
|
|
| 645 |
|
| 646 |
const { data: existing, error } = await supabase
|
| 647 |
.from('mod_actions')
|
| 648 |
-
.select('id')
|
| 649 |
.eq('action_type', action.actionType)
|
| 650 |
.eq('target_user', targetUserLower)
|
| 651 |
.gte('timestamp', startTimeRange)
|
|
@@ -653,8 +658,13 @@ export async function logModAction(action) {
|
|
| 653 |
.limit(1);
|
| 654 |
|
| 655 |
if (!error && existing && existing.length > 0) {
|
| 656 |
-
|
| 657 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
}
|
| 659 |
}
|
| 660 |
}
|
|
@@ -706,36 +716,66 @@ export async function logModAction(action) {
|
|
| 706 |
}
|
| 707 |
|
| 708 |
if (dbMode === 'sqlite') {
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 723 |
} else {
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 739 |
}
|
| 740 |
}
|
| 741 |
}
|
|
|
|
| 30 |
let sqliteDb = null;
|
| 31 |
|
| 32 |
if (dbMode === 'sqlite') {
|
| 33 |
+
const dbPath = path.resolve(process.env.SQLITE_DB_PATH || 'database.db');
|
| 34 |
console.log(`[Database] SQLite file location: ${dbPath}`);
|
| 35 |
sqliteDb = new Database(dbPath);
|
| 36 |
|
|
|
|
| 625 |
const streamId = stream ? stream.id : null;
|
| 626 |
|
| 627 |
// Prevent duplicate actions (e.g., from both EventSub and local IRC worker running together)
|
| 628 |
+
let duplicateToUpdate = null;
|
| 629 |
if (action.targetUser) {
|
| 630 |
const targetUserLower = action.targetUser.toLowerCase();
|
| 631 |
if (dbMode === 'sqlite') {
|
| 632 |
const existing = sqliteDb.prepare(`
|
| 633 |
+
SELECT id, moderator FROM mod_actions
|
| 634 |
WHERE action_type = ?
|
| 635 |
AND target_user = ?
|
| 636 |
AND abs(strftime('%s', timestamp) - strftime('%s', ?)) < 15
|
| 637 |
`).get(action.actionType, targetUserLower, time);
|
| 638 |
if (existing) {
|
| 639 |
+
if (existing.moderator === 'TwitchIRC' && action.moderator !== 'TwitchIRC') {
|
| 640 |
+
duplicateToUpdate = existing.id;
|
| 641 |
+
} else {
|
| 642 |
+
console.log(`[Database] Duplicate SQLite mod action ${action.actionType} for ${targetUserLower} ignored.`);
|
| 643 |
+
return;
|
| 644 |
+
}
|
| 645 |
}
|
| 646 |
} else {
|
| 647 |
const timeMs = new Date(time).getTime();
|
|
|
|
| 650 |
|
| 651 |
const { data: existing, error } = await supabase
|
| 652 |
.from('mod_actions')
|
| 653 |
+
.select('id, moderator')
|
| 654 |
.eq('action_type', action.actionType)
|
| 655 |
.eq('target_user', targetUserLower)
|
| 656 |
.gte('timestamp', startTimeRange)
|
|
|
|
| 658 |
.limit(1);
|
| 659 |
|
| 660 |
if (!error && existing && existing.length > 0) {
|
| 661 |
+
const first = existing[0];
|
| 662 |
+
if (first.moderator === 'TwitchIRC' && action.moderator !== 'TwitchIRC') {
|
| 663 |
+
duplicateToUpdate = first.id;
|
| 664 |
+
} else {
|
| 665 |
+
console.log(`[Database] Duplicate Supabase mod action ${action.actionType} for ${targetUserLower} ignored.`);
|
| 666 |
+
return;
|
| 667 |
+
}
|
| 668 |
}
|
| 669 |
}
|
| 670 |
}
|
|
|
|
| 716 |
}
|
| 717 |
|
| 718 |
if (dbMode === 'sqlite') {
|
| 719 |
+
if (duplicateToUpdate) {
|
| 720 |
+
sqliteDb.prepare(`
|
| 721 |
+
UPDATE mod_actions
|
| 722 |
+
SET moderator = ?, reason = ?, reaction_time = ?
|
| 723 |
+
WHERE id = ?
|
| 724 |
+
`).run(
|
| 725 |
+
action.moderator,
|
| 726 |
+
action.reason || null,
|
| 727 |
+
reactionTime,
|
| 728 |
+
duplicateToUpdate
|
| 729 |
+
);
|
| 730 |
+
console.log(`[Database] Duplicate SQLite mod action ${action.actionType} updated with real moderator ${action.moderator}.`);
|
| 731 |
+
} else {
|
| 732 |
+
sqliteDb.prepare(`
|
| 733 |
+
INSERT INTO mod_actions (stream_id, action_type, moderator, target_user, duration, reason, message_text, timestamp, reaction_time)
|
| 734 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 735 |
+
`).run(
|
| 736 |
+
streamId,
|
| 737 |
+
action.actionType,
|
| 738 |
+
action.moderator,
|
| 739 |
+
action.targetUser ? action.targetUser.toLowerCase() : null,
|
| 740 |
+
action.duration || null,
|
| 741 |
+
action.reason || null,
|
| 742 |
+
action.messageText || null,
|
| 743 |
+
time,
|
| 744 |
+
reactionTime
|
| 745 |
+
);
|
| 746 |
+
}
|
| 747 |
} else {
|
| 748 |
+
if (duplicateToUpdate) {
|
| 749 |
+
const { error } = await supabase
|
| 750 |
+
.from('mod_actions')
|
| 751 |
+
.update({
|
| 752 |
+
moderator: action.moderator,
|
| 753 |
+
reason: action.reason || null,
|
| 754 |
+
reaction_time: reactionTime
|
| 755 |
+
})
|
| 756 |
+
.eq('id', duplicateToUpdate);
|
| 757 |
+
if (error) {
|
| 758 |
+
console.error('[Supabase] Error updating mod action duplicate:', error);
|
| 759 |
+
} else {
|
| 760 |
+
console.log(`[Database] Duplicate Supabase mod action ${action.actionType} updated with real moderator ${action.moderator}.`);
|
| 761 |
+
}
|
| 762 |
+
} else {
|
| 763 |
+
const { error } = await supabase
|
| 764 |
+
.from('mod_actions')
|
| 765 |
+
.insert([{
|
| 766 |
+
stream_id: streamId,
|
| 767 |
+
action_type: action.actionType,
|
| 768 |
+
moderator: action.moderator,
|
| 769 |
+
target_user: action.targetUser ? action.targetUser.toLowerCase() : null,
|
| 770 |
+
duration: action.duration || null,
|
| 771 |
+
reason: action.reason || null,
|
| 772 |
+
message_text: action.messageText || null,
|
| 773 |
+
timestamp: time,
|
| 774 |
+
reaction_time: reactionTime
|
| 775 |
+
}]);
|
| 776 |
+
if (error) {
|
| 777 |
+
console.error('[Supabase] Error logging mod action:', error);
|
| 778 |
+
}
|
| 779 |
}
|
| 780 |
}
|
| 781 |
}
|
server/eventsub.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import WebSocket from 'ws';
|
| 2 |
import axios from 'axios';
|
| 3 |
-
import { getSetting, logModAction } from './db.js';
|
| 4 |
|
| 5 |
let ws = null;
|
| 6 |
let broadcasterUserId = null;
|
|
@@ -8,6 +8,59 @@ let keepAliveTimer = null;
|
|
| 8 |
let lastKeepAlive = null;
|
| 9 |
let reconnecting = false;
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
/**
|
| 12 |
* Initialize Twitch EventSub WebSocket connection
|
| 13 |
*/
|
|
@@ -24,9 +77,9 @@ export async function initializeEventSub(streamerId = null) {
|
|
| 24 |
return;
|
| 25 |
}
|
| 26 |
|
| 27 |
-
const token = await
|
| 28 |
if (!token) {
|
| 29 |
-
console.log('[EventSub] Streamer access token is missing. Waiting for Streamer login.');
|
| 30 |
return;
|
| 31 |
}
|
| 32 |
|
|
@@ -168,7 +221,7 @@ async function handleNotification(payload) {
|
|
| 168 |
*/
|
| 169 |
async function subscribeToEvents(sessionId) {
|
| 170 |
const clientId = process.env.TWITCH_CLIENT_ID;
|
| 171 |
-
const token = await
|
| 172 |
|
| 173 |
if (!clientId || !token) {
|
| 174 |
console.error('[EventSub] Cannot subscribe: Client ID or token is missing!');
|
|
|
|
| 1 |
import WebSocket from 'ws';
|
| 2 |
import axios from 'axios';
|
| 3 |
+
import { getSetting, setSetting, logModAction } from './db.js';
|
| 4 |
|
| 5 |
let ws = null;
|
| 6 |
let broadcasterUserId = null;
|
|
|
|
| 8 |
let lastKeepAlive = null;
|
| 9 |
let reconnecting = false;
|
| 10 |
|
| 11 |
+
/**
|
| 12 |
+
* Validate current access token and refresh it if expired/invalid
|
| 13 |
+
*/
|
| 14 |
+
export async function getOrRefreshAccessToken() {
|
| 15 |
+
let token = await getSetting('twitch_streamer_access_token');
|
| 16 |
+
const refreshToken = await getSetting('twitch_streamer_refresh_token');
|
| 17 |
+
|
| 18 |
+
if (!token) {
|
| 19 |
+
return null;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
// Validate the current token via Twitch API
|
| 23 |
+
try {
|
| 24 |
+
await axios.get('https://id.twitch.tv/oauth2/validate', {
|
| 25 |
+
headers: {
|
| 26 |
+
'Authorization': `Bearer ${token}`
|
| 27 |
+
}
|
| 28 |
+
});
|
| 29 |
+
// Token is valid!
|
| 30 |
+
return token;
|
| 31 |
+
} catch (err) {
|
| 32 |
+
const status = err.response?.status;
|
| 33 |
+
console.log(`[EventSub] Access token validation failed (Status: ${status}). Attempting to refresh...`);
|
| 34 |
+
|
| 35 |
+
if (!refreshToken) {
|
| 36 |
+
console.error('[EventSub] Refresh token is missing in database. Cannot refresh access token.');
|
| 37 |
+
return null;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
try {
|
| 41 |
+
const response = await axios.post('https://id.twitch.tv/oauth2/token', null, {
|
| 42 |
+
params: {
|
| 43 |
+
client_id: process.env.TWITCH_CLIENT_ID,
|
| 44 |
+
client_secret: process.env.TWITCH_CLIENT_SECRET,
|
| 45 |
+
grant_type: 'refresh_token',
|
| 46 |
+
refresh_token: refreshToken
|
| 47 |
+
}
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
const { access_token, refresh_token: newRefreshToken } = response.data;
|
| 51 |
+
await setSetting('twitch_streamer_access_token', access_token);
|
| 52 |
+
if (newRefreshToken) {
|
| 53 |
+
await setSetting('twitch_streamer_refresh_token', newRefreshToken);
|
| 54 |
+
}
|
| 55 |
+
console.log('[EventSub] Access token refreshed successfully.');
|
| 56 |
+
return access_token;
|
| 57 |
+
} catch (refreshErr) {
|
| 58 |
+
console.error('[EventSub] Token refresh request failed:', refreshErr.response?.data || refreshErr.message);
|
| 59 |
+
return null;
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
/**
|
| 65 |
* Initialize Twitch EventSub WebSocket connection
|
| 66 |
*/
|
|
|
|
| 77 |
return;
|
| 78 |
}
|
| 79 |
|
| 80 |
+
const token = await getOrRefreshAccessToken();
|
| 81 |
if (!token) {
|
| 82 |
+
console.log('[EventSub] Streamer access token is missing or invalid. Waiting for Streamer login.');
|
| 83 |
return;
|
| 84 |
}
|
| 85 |
|
|
|
|
| 221 |
*/
|
| 222 |
async function subscribeToEvents(sessionId) {
|
| 223 |
const clientId = process.env.TWITCH_CLIENT_ID;
|
| 224 |
+
const token = await getOrRefreshAccessToken();
|
| 225 |
|
| 226 |
if (!clientId || !token) {
|
| 227 |
console.error('[EventSub] Cannot subscribe: Client ID or token is missing!');
|
server/scratch/test_mod_duplicates.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Setup testing database environment variables BEFORE any imports
|
| 2 |
+
process.env.DB_MODE = 'sqlite';
|
| 3 |
+
process.env.SQLITE_DB_PATH = './test_database.db';
|
| 4 |
+
// Clear Supabase keys to force SQLite mode in db.js
|
| 5 |
+
process.env.SUPABASE_URL = '';
|
| 6 |
+
process.env.SUPABASE_KEY = '';
|
| 7 |
+
process.env.SUPABASE_SERVICE_ROLE_KEY = '';
|
| 8 |
+
|
| 9 |
+
import Database from 'better-sqlite3';
|
| 10 |
+
import fs from 'fs';
|
| 11 |
+
|
| 12 |
+
// Clean up test database
|
| 13 |
+
if (fs.existsSync('./test_database.db')) {
|
| 14 |
+
fs.unlinkSync('./test_database.db');
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const sqliteDb = new Database('./test_database.db');
|
| 18 |
+
|
| 19 |
+
// Create minimal schema for testing
|
| 20 |
+
sqliteDb.exec(`
|
| 21 |
+
CREATE TABLE IF NOT EXISTS streams (
|
| 22 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 23 |
+
start_time TEXT,
|
| 24 |
+
end_time TEXT,
|
| 25 |
+
title TEXT,
|
| 26 |
+
twitch_stream_id TEXT UNIQUE,
|
| 27 |
+
category TEXT
|
| 28 |
+
);
|
| 29 |
+
|
| 30 |
+
CREATE TABLE IF NOT EXISTS mod_actions (
|
| 31 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 32 |
+
stream_id INTEGER,
|
| 33 |
+
action_type TEXT,
|
| 34 |
+
moderator TEXT,
|
| 35 |
+
target_user TEXT,
|
| 36 |
+
duration INTEGER,
|
| 37 |
+
reason TEXT,
|
| 38 |
+
message_text TEXT,
|
| 39 |
+
timestamp TEXT,
|
| 40 |
+
reaction_time REAL
|
| 41 |
+
);
|
| 42 |
+
`);
|
| 43 |
+
|
| 44 |
+
// Insert a fake stream
|
| 45 |
+
sqliteDb.prepare(`
|
| 46 |
+
INSERT INTO streams (id, start_time, title, twitch_stream_id, category)
|
| 47 |
+
VALUES (1, datetime('now'), 'Test Stream', 'test-stream-123', 'Testing')
|
| 48 |
+
`).run();
|
| 49 |
+
|
| 50 |
+
// Dynamically import db.js so it reads the env vars we just set
|
| 51 |
+
const { logModAction } = await import('../db.js');
|
| 52 |
+
|
| 53 |
+
async function runTest() {
|
| 54 |
+
console.log('--- STARTING DUPLICATE MERGE TEST ---');
|
| 55 |
+
|
| 56 |
+
const timestamp = new Date().toISOString();
|
| 57 |
+
|
| 58 |
+
// 1. Simulate IRC event (anonymous, moderator = TwitchIRC, no reason)
|
| 59 |
+
console.log('1. Logging IRC mod action (anonymous)...');
|
| 60 |
+
await logModAction({
|
| 61 |
+
actionType: 'timeout',
|
| 62 |
+
moderator: 'TwitchIRC',
|
| 63 |
+
targetUser: 'spammer_user',
|
| 64 |
+
duration: 600,
|
| 65 |
+
timestamp: timestamp
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
// Verify it exists in DB
|
| 69 |
+
let record = sqliteDb.prepare('SELECT * FROM mod_actions WHERE target_user = ?').get('spammer_user');
|
| 70 |
+
console.log('Record after IRC:', record);
|
| 71 |
+
if (!record || record.moderator !== 'TwitchIRC' || record.reason !== null) {
|
| 72 |
+
console.error('FAIL: Initial IRC record is incorrect.');
|
| 73 |
+
process.exit(1);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// 2. Simulate EventSub event (real moderator, custom reason, same timestamp)
|
| 77 |
+
console.log('2. Logging EventSub mod action (with real mod & reason)...');
|
| 78 |
+
await logModAction({
|
| 79 |
+
actionType: 'timeout',
|
| 80 |
+
moderator: 'GoodModName',
|
| 81 |
+
targetUser: 'spammer_user',
|
| 82 |
+
duration: 600,
|
| 83 |
+
reason: 'Links spamming in chat',
|
| 84 |
+
timestamp: timestamp
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
// Verify it updated in DB instead of creating a duplicate or ignoring
|
| 88 |
+
const records = sqliteDb.prepare('SELECT * FROM mod_actions WHERE target_user = ?').all('spammer_user');
|
| 89 |
+
console.log('All records in DB after EventSub:', records);
|
| 90 |
+
|
| 91 |
+
if (records.length !== 1) {
|
| 92 |
+
console.error(`FAIL: Expected exactly 1 record, found ${records.length}`);
|
| 93 |
+
process.exit(1);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
record = records[0];
|
| 97 |
+
if (record.moderator !== 'goodmodname' && record.moderator !== 'GoodModName') { // depending on casing
|
| 98 |
+
console.error(`FAIL: Expected moderator to be GoodModName/goodmodname, got: ${record.moderator}`);
|
| 99 |
+
process.exit(1);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
if (record.reason !== 'Links spamming in chat') {
|
| 103 |
+
console.error(`FAIL: Expected reason "Links spamming in chat", got: ${record.reason}`);
|
| 104 |
+
process.exit(1);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
console.log('SUCCESS: Duplicate mod action successfully merged/enriched!');
|
| 108 |
+
|
| 109 |
+
// Clean up
|
| 110 |
+
sqliteDb.close();
|
| 111 |
+
if (fs.existsSync('./test_database.db')) {
|
| 112 |
+
fs.unlinkSync('./test_database.db');
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
runTest().catch(err => {
|
| 117 |
+
console.error('Test crashed:', err);
|
| 118 |
+
process.exit(1);
|
| 119 |
+
});
|
server/scratch/test_token_refresh.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Setup testing database environment variables BEFORE any imports
|
| 2 |
+
process.env.DB_MODE = 'sqlite';
|
| 3 |
+
process.env.SQLITE_DB_PATH = './test_database.db';
|
| 4 |
+
// Clear Supabase keys to force SQLite mode
|
| 5 |
+
process.env.SUPABASE_URL = '';
|
| 6 |
+
process.env.SUPABASE_KEY = '';
|
| 7 |
+
process.env.SUPABASE_SERVICE_ROLE_KEY = '';
|
| 8 |
+
|
| 9 |
+
// Mock environment variables for Twitch client
|
| 10 |
+
process.env.TWITCH_CLIENT_ID = 'mocked_client_id';
|
| 11 |
+
process.env.TWITCH_CLIENT_SECRET = 'mocked_client_secret';
|
| 12 |
+
|
| 13 |
+
import Database from 'better-sqlite3';
|
| 14 |
+
import fs from 'fs';
|
| 15 |
+
import axios from 'axios';
|
| 16 |
+
|
| 17 |
+
// Clean up test database
|
| 18 |
+
if (fs.existsSync('./test_database.db')) {
|
| 19 |
+
fs.unlinkSync('./test_database.db');
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const sqliteDb = new Database('./test_database.db');
|
| 23 |
+
|
| 24 |
+
// Create settings table
|
| 25 |
+
sqliteDb.exec(`
|
| 26 |
+
CREATE TABLE IF NOT EXISTS settings (
|
| 27 |
+
key TEXT PRIMARY KEY,
|
| 28 |
+
value TEXT NOT NULL
|
| 29 |
+
);
|
| 30 |
+
`);
|
| 31 |
+
|
| 32 |
+
// Insert initial expired token and refresh token
|
| 33 |
+
sqliteDb.prepare(`
|
| 34 |
+
INSERT INTO settings (key, value)
|
| 35 |
+
VALUES ('twitch_streamer_access_token', 'expired_token_value')
|
| 36 |
+
`).run();
|
| 37 |
+
|
| 38 |
+
sqliteDb.prepare(`
|
| 39 |
+
INSERT INTO settings (key, value)
|
| 40 |
+
VALUES ('twitch_streamer_refresh_token', 'valid_refresh_token_value')
|
| 41 |
+
`).run();
|
| 42 |
+
|
| 43 |
+
// Mock Axios methods
|
| 44 |
+
let validateCalls = 0;
|
| 45 |
+
let refreshCalls = 0;
|
| 46 |
+
let refreshParamsReceived = null;
|
| 47 |
+
|
| 48 |
+
axios.get = async (url, config) => {
|
| 49 |
+
if (url === 'https://id.twitch.tv/oauth2/validate') {
|
| 50 |
+
validateCalls++;
|
| 51 |
+
const authHeader = config?.headers?.Authorization;
|
| 52 |
+
if (authHeader === 'Bearer valid_mocked_token') {
|
| 53 |
+
// Mock success for valid token
|
| 54 |
+
return { data: { client_id: 'mocked_client_id' } };
|
| 55 |
+
} else {
|
| 56 |
+
// Mock expiration/invalid token error
|
| 57 |
+
const err = new Error('Unauthorized');
|
| 58 |
+
err.response = { status: 401, data: { message: 'invalid oauth token' } };
|
| 59 |
+
throw err;
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
throw new Error(`Unexpected GET request to ${url}`);
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
axios.post = async (url, data, config) => {
|
| 66 |
+
if (url === 'https://id.twitch.tv/oauth2/token') {
|
| 67 |
+
refreshCalls++;
|
| 68 |
+
refreshParamsReceived = config?.params;
|
| 69 |
+
return {
|
| 70 |
+
data: {
|
| 71 |
+
access_token: 'new_refreshed_access_token',
|
| 72 |
+
refresh_token: 'new_refreshed_refresh_token'
|
| 73 |
+
}
|
| 74 |
+
};
|
| 75 |
+
}
|
| 76 |
+
throw new Error(`Unexpected POST request to ${url}`);
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
// Dynamically import db.js and eventsub.js so they read correct environment variables and use the mocked axios
|
| 80 |
+
const { getSetting } = await import('../db.js');
|
| 81 |
+
const { getOrRefreshAccessToken } = await import('../eventsub.js');
|
| 82 |
+
|
| 83 |
+
async function runTest() {
|
| 84 |
+
console.log('--- STARTING TOKEN REFRESH TEST ---');
|
| 85 |
+
|
| 86 |
+
// Scenario 1: Initial call with expired token. Should fail validate, call refresh, and return new token.
|
| 87 |
+
console.log('Scenario 1: Testing token validation failure and refresh flow...');
|
| 88 |
+
const token = await getOrRefreshAccessToken();
|
| 89 |
+
|
| 90 |
+
console.log('Returned token:', token);
|
| 91 |
+
console.log('Validate calls:', validateCalls);
|
| 92 |
+
console.log('Refresh calls:', refreshCalls);
|
| 93 |
+
console.log('Params received on refresh:', refreshParamsReceived);
|
| 94 |
+
|
| 95 |
+
// Assertions
|
| 96 |
+
if (token !== 'new_refreshed_access_token') {
|
| 97 |
+
console.error('FAIL: Expected new refreshed access token, got:', token);
|
| 98 |
+
process.exit(1);
|
| 99 |
+
}
|
| 100 |
+
if (validateCalls !== 1) {
|
| 101 |
+
console.error('FAIL: Expected 1 validate call, got:', validateCalls);
|
| 102 |
+
process.exit(1);
|
| 103 |
+
}
|
| 104 |
+
if (refreshCalls !== 1) {
|
| 105 |
+
console.error('FAIL: Expected 1 refresh call, got:', refreshCalls);
|
| 106 |
+
process.exit(1);
|
| 107 |
+
}
|
| 108 |
+
if (!refreshParamsReceived || refreshParamsReceived.refresh_token !== 'valid_refresh_token_value') {
|
| 109 |
+
console.error('FAIL: Incorrect refresh token sent in params:', refreshParamsReceived);
|
| 110 |
+
process.exit(1);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// Verify DB updated settings
|
| 114 |
+
const dbAccessToken = sqliteDb.prepare("SELECT value FROM settings WHERE key = 'twitch_streamer_access_token'").get()?.value;
|
| 115 |
+
const dbRefreshToken = sqliteDb.prepare("SELECT value FROM settings WHERE key = 'twitch_streamer_refresh_token'").get()?.value;
|
| 116 |
+
|
| 117 |
+
console.log('DB Access Token:', dbAccessToken);
|
| 118 |
+
console.log('DB Refresh Token:', dbRefreshToken);
|
| 119 |
+
|
| 120 |
+
if (dbAccessToken !== 'new_refreshed_access_token' || dbRefreshToken !== 'new_refreshed_refresh_token') {
|
| 121 |
+
console.error('FAIL: Settings not updated in database correctly');
|
| 122 |
+
process.exit(1);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// Scenario 2: Subsequent call with the now valid token. Should validate successfully and not call refresh.
|
| 126 |
+
console.log('Scenario 2: Testing valid token bypasses refresh...');
|
| 127 |
+
// Update mock token validation check to succeed for this new token
|
| 128 |
+
axios.get = async (url, config) => {
|
| 129 |
+
if (url === 'https://id.twitch.tv/oauth2/validate') {
|
| 130 |
+
validateCalls++;
|
| 131 |
+
const authHeader = config?.headers?.Authorization;
|
| 132 |
+
if (authHeader === 'Bearer new_refreshed_access_token') {
|
| 133 |
+
return { data: { client_id: 'mocked_client_id' } };
|
| 134 |
+
}
|
| 135 |
+
const err = new Error('Unauthorized');
|
| 136 |
+
err.response = { status: 401 };
|
| 137 |
+
throw err;
|
| 138 |
+
}
|
| 139 |
+
throw new Error(`Unexpected GET request to ${url}`);
|
| 140 |
+
};
|
| 141 |
+
|
| 142 |
+
const tokenScenario2 = await getOrRefreshAccessToken();
|
| 143 |
+
console.log('Scenario 2 Returned token:', tokenScenario2);
|
| 144 |
+
console.log('Scenario 2 Validate calls total:', validateCalls);
|
| 145 |
+
console.log('Scenario 2 Refresh calls total:', refreshCalls);
|
| 146 |
+
|
| 147 |
+
if (tokenScenario2 !== 'new_refreshed_access_token') {
|
| 148 |
+
console.error('FAIL: Expected token to remain new_refreshed_access_token, got:', tokenScenario2);
|
| 149 |
+
process.exit(1);
|
| 150 |
+
}
|
| 151 |
+
if (validateCalls !== 2) {
|
| 152 |
+
console.error('FAIL: Expected validate calls to increment to 2, got:', validateCalls);
|
| 153 |
+
process.exit(1);
|
| 154 |
+
}
|
| 155 |
+
if (refreshCalls !== 1) {
|
| 156 |
+
console.error('FAIL: Expected refresh calls to remain 1, got:', refreshCalls);
|
| 157 |
+
process.exit(1);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
console.log('SUCCESS: Token refresh flow successfully verified and tested!');
|
| 161 |
+
|
| 162 |
+
// Clean up
|
| 163 |
+
sqliteDb.close();
|
| 164 |
+
if (fs.existsSync('./test_database.db')) {
|
| 165 |
+
fs.unlinkSync('./test_database.db');
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
runTest().catch(err => {
|
| 170 |
+
console.error('Test crashed:', err);
|
| 171 |
+
process.exit(1);
|
| 172 |
+
});
|