From 7aba4567f3b38417990b61f5eb0d73502c91c681 Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Tue, 27 Jan 2026 18:08:42 -0700 Subject: [PATCH] feat: add Meijin role for top contributor recognition Add a rotating "Meijin" title awarded monthly to whoever receives the most Sensei reactions in a rolling 30-day window. Ties share the role. Also filters out self-reactions system-wide - users can no longer endorse their own messages (both at insert time and in all queries). Co-Authored-By: Claude Opus 4.5 --- backend/.env.example | 5 ++ backend/src/jobs/decayCheck.ts | 7 ++- backend/src/services/database.ts | 70 ++++++++++++++++++++--- backend/src/services/meijin.ts | 88 +++++++++++++++++++++++++++++ backend/src/services/roleManager.ts | 4 ++ backend/src/types.ts | 6 ++ backend/src/utils/config.ts | 4 ++ 7 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 backend/src/services/meijin.ts diff --git a/backend/.env.example b/backend/.env.example index 1d35cd9..9b6eae1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,6 +11,7 @@ DOJO_EMOJI_ID=optional_custom_emoji_id KOHAI_ROLE_ID=your_kohai_role_id SENPAI_ROLE_ID=your_senpai_role_id SENSEI_ROLE_ID=your_sensei_role_id +MEIJIN_ROLE_ID=your_meijin_role_id FELT_ROLE_ID=your_felt_role_id TEAM_ROLE_ID=your_team_role_id @@ -27,6 +28,10 @@ SENPAI_UNIQUE_PERCENT=0.10 SENSEI_REACTION_THRESHOLD=30 SENSEI_UNIQUE_PERCENT=0.20 +# Meijin (rotating title for top contributor) +# Rolling window for counting Sensei reactions (default: 30 days) +MEIJIN_WINDOW_DAYS=30 + # Channel IDs OHAYO_CHANNEL_ID=your_ohayo_channel_id diff --git a/backend/src/jobs/decayCheck.ts b/backend/src/jobs/decayCheck.ts index 5698c10..94591e9 100644 --- a/backend/src/jobs/decayCheck.ts +++ b/backend/src/jobs/decayCheck.ts @@ -4,6 +4,7 @@ import { config } from '../utils/config.js'; import { getUsersWithRole } from '../services/roleManager.js'; import { checkSenseiDecay } from '../services/decay.js'; import { checkPromotion } from '../services/reputation.js'; +import { checkAndUpdateMeijin } from '../services/meijin.js'; import { Role } from '../types.js'; /** @@ -48,8 +49,12 @@ async function runDecayCheck(client: Client): Promise { } } + // Check and update Meijin title + console.log('Checking Meijin title...'); + const meijinResult = await checkAndUpdateMeijin(guild); + console.log( - `✅ Decay check complete: ${demotionCount} demotions, ${promotionCount} promotions` + `✅ Decay check complete: ${demotionCount} demotions, ${promotionCount} promotions, ${meijinResult.newMeijin.length} new Meijin, ${meijinResult.removedMeijin.length} removed Meijin` ); } catch (error) { console.error('Error during decay check:', error); diff --git a/backend/src/services/database.ts b/backend/src/services/database.ts index c6155fd..2cfff75 100644 --- a/backend/src/services/database.ts +++ b/backend/src/services/database.ts @@ -48,7 +48,7 @@ export function setSql(newSql: Sql): void { /** * Insert a new reaction into the database - * Returns true if inserted, false if duplicate + * Returns true if inserted, false if duplicate or self-reaction */ export async function insertReaction( messageId: string, @@ -56,6 +56,12 @@ export async function insertReaction( reactorId: string, reactorRole: Role ): Promise { + // Reject self-reactions - users cannot endorse their own messages + if (messageAuthorId === reactorId) { + console.debug(`Self-reaction ignored: ${reactorId} -> ${messageId}`); + return false; + } + try { const timestamp = Date.now(); @@ -79,13 +85,14 @@ export async function insertReaction( } /** - * Get all reactions received by a user + * Get all reactions received by a user (excludes self-reactions) */ export async function getReactionsForUser(userId: string): Promise { const results = await sql` SELECT id, message_id, author_id, reactor_id, reactor_role, timestamp FROM reactions WHERE author_id = ${userId} + AND author_id != reactor_id ORDER BY timestamp DESC `; @@ -93,13 +100,14 @@ export async function getReactionsForUser(userId: string): Promise { } /** - * Get reactions for a user filtered by reactor role(s) + * Get reactions for a user filtered by reactor role(s) (excludes self-reactions) */ export async function getReactionsByRole(userId: string, roles: Role[]): Promise { const results = await sql` SELECT id, message_id, author_id, reactor_id, reactor_role, timestamp FROM reactions WHERE author_id = ${userId} + AND author_id != reactor_id AND reactor_role = ANY(${roles}) ORDER BY timestamp DESC `; @@ -108,7 +116,7 @@ export async function getReactionsByRole(userId: string, roles: Role[]): Promise } /** - * Get recent Sensei reactions for a user (within time window) + * Get recent Sensei reactions for a user (within time window, excludes self-reactions) */ export async function getRecentSenseiReactions(userId: string, days: number): Promise { const cutoffTimestamp = Date.now() - days * 24 * 60 * 60 * 1000; @@ -117,6 +125,7 @@ export async function getRecentSenseiReactions(userId: string, days: number): Pr SELECT id, message_id, author_id, reactor_id, reactor_role, timestamp FROM reactions WHERE author_id = ${userId} + AND author_id != reactor_id AND reactor_role = 'Sensei' AND timestamp >= ${cutoffTimestamp} ORDER BY timestamp DESC @@ -126,13 +135,14 @@ export async function getRecentSenseiReactions(userId: string, days: number): Pr } /** - * Get unique reactors for a user from specific roles + * Get unique reactors for a user from specific roles (excludes self-reactions) */ export async function getUniqueReactors(userId: string, roles: Role[]): Promise { const results = await sql<{ reactor_id: string }[]>` SELECT DISTINCT reactor_id FROM reactions WHERE author_id = ${userId} + AND author_id != reactor_id AND reactor_role = ANY(${roles}) `; @@ -140,13 +150,14 @@ export async function getUniqueReactors(userId: string, roles: Role[]): Promise< } /** - * Get count of reactions for a user from specific roles + * Get count of reactions for a user from specific roles (excludes self-reactions) */ export async function getReactionCount(userId: string, roles: Role[]): Promise { const results = await sql<{ count: string }[]>` SELECT COUNT(*) as count FROM reactions WHERE author_id = ${userId} + AND author_id != reactor_id AND reactor_role = ANY(${roles}) `; @@ -174,6 +185,7 @@ export async function getReactionBreakdown(userId: string): Promise` SELECT author_id as user_id, COUNT(*) as reaction_count FROM reactions - ${role ? sql`WHERE reactor_role = ${role}` : sql``} + WHERE author_id != reactor_id + ${role ? sql`AND reactor_role = ${role}` : sql``} GROUP BY author_id ORDER BY reaction_count DESC LIMIT ${limit} @@ -208,3 +221,46 @@ export async function getLeaderboard(role?: Role, limit: number = 20): Promise { + const cutoffTimestamp = Date.now() - days * 24 * 60 * 60 * 1000; + + // First, get the max count + const maxResult = await sql<{ max_count: string }[]>` + SELECT COALESCE(MAX(cnt), 0) as max_count + FROM ( + SELECT COUNT(*) as cnt + FROM reactions + WHERE reactor_role = 'Sensei' + AND author_id != reactor_id + AND timestamp >= ${cutoffTimestamp} + GROUP BY author_id + ) counts + `; + + const maxCount = parseInt(maxResult[0]?.max_count || '0', 10); + + if (maxCount === 0) { + return []; + } + + // Then get all users with that max count + const results = await sql<{ user_id: string; reaction_count: string }[]>` + SELECT author_id as user_id, COUNT(*) as reaction_count + FROM reactions + WHERE reactor_role = 'Sensei' + AND author_id != reactor_id + AND timestamp >= ${cutoffTimestamp} + GROUP BY author_id + HAVING COUNT(*) = ${maxCount} + `; + + return results.map((r) => ({ + user_id: r.user_id, + reaction_count: parseInt(r.reaction_count, 10), + })); +} diff --git a/backend/src/services/meijin.ts b/backend/src/services/meijin.ts new file mode 100644 index 0000000..242811a --- /dev/null +++ b/backend/src/services/meijin.ts @@ -0,0 +1,88 @@ +import { Guild } from 'discord.js'; +import { config } from '../utils/config.js'; +import { getTopSenseiReactionRecipients } from './database.js'; +import { sendDM } from './roleManager.js'; + +/** + * Result of the Meijin check + */ +export interface MeijinCheckResult { + newMeijin: string[]; + removedMeijin: string[]; + topCount: number; +} + +/** + * Check and update Meijin role assignments + * Meijin goes to whoever has the most Sensei reactions in the last 30 days + * Ties share the role + */ +export async function checkAndUpdateMeijin(guild: Guild): Promise { + const result: MeijinCheckResult = { + newMeijin: [], + removedMeijin: [], + topCount: 0, + }; + + try { + // Get current Meijin holders + const meijinRole = guild.roles.cache.get(config.meijinRoleId); + if (!meijinRole) { + console.error('Meijin role not found in guild'); + return result; + } + + const currentMeijin = new Set(meijinRole.members.map((m) => m.id)); + + // Get top Sensei reaction recipients + const topRecipients = await getTopSenseiReactionRecipients(config.meijinWindowDays); + const newMeijinSet = new Set(topRecipients.map((r) => r.user_id)); + + if (topRecipients.length > 0) { + result.topCount = topRecipients[0].reaction_count; + } + + console.log( + `Meijin check: ${topRecipients.length} user(s) with ${result.topCount} Sensei reactions in last ${config.meijinWindowDays} days` + ); + + // Remove Meijin from those who no longer qualify + for (const userId of currentMeijin) { + if (!newMeijinSet.has(userId)) { + const member = guild.members.cache.get(userId); + if (member) { + await member.roles.remove(config.meijinRoleId); + result.removedMeijin.push(userId); + console.log(`Removed Meijin from ${member.user.tag} (${userId})`); + + await sendDM( + member.user, + `🎌 Your **Meijin** title has passed to another.\n\nThe Meijin title goes to whoever receives the most Sensei reactions in a rolling 30-day window. Keep contributing to reclaim it!` + ); + } + } + } + + // Add Meijin to new qualifiers + for (const userId of newMeijinSet) { + if (!currentMeijin.has(userId)) { + const member = guild.members.cache.get(userId); + if (member) { + await member.roles.add(config.meijinRoleId); + result.newMeijin.push(userId); + console.log(`Added Meijin to ${member.user.tag} (${userId})`); + + await sendDM( + member.user, + `🎌 Congratulations! You've been recognized as **Meijin**!\n\nYou received the most Sensei reactions in the last 30 days (${result.topCount} reactions). This title reflects your current excellence in the community.\n\n*The Meijin title is checked monthly and goes to whoever has the highest Sensei reaction count.*` + ); + } + } + } + + return result; + } catch (error) { + console.error('Error checking/updating Meijin:', error); + return result; + } +} diff --git a/backend/src/services/roleManager.ts b/backend/src/services/roleManager.ts index 41a37de..d10b8c6 100644 --- a/backend/src/services/roleManager.ts +++ b/backend/src/services/roleManager.ts @@ -13,6 +13,8 @@ function getRoleId(role: Role): string { return config.senpaiRoleId; case Role.Sensei: return config.senseiRoleId; + case Role.Meijin: + return config.meijinRoleId; } } @@ -105,11 +107,13 @@ export function getRoleCounts(guild: Guild): RoleCounts { const kohaiRole = guild.roles.cache.get(config.kohaiRoleId); const senpaiRole = guild.roles.cache.get(config.senpaiRoleId); const senseiRole = guild.roles.cache.get(config.senseiRoleId); + const meijinRole = guild.roles.cache.get(config.meijinRoleId); return { kohai: kohaiRole?.members.size || 0, senpai: senpaiRole?.members.size || 0, sensei: senseiRole?.members.size || 0, + meijin: meijinRole?.members.size || 0, }; } diff --git a/backend/src/types.ts b/backend/src/types.ts index 090e802..3aa7120 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -9,6 +9,7 @@ export enum Role { Kohai = 'Kohai', Senpai = 'Senpai', Sensei = 'Sensei', + Meijin = 'Meijin', } /** @@ -40,6 +41,7 @@ export interface Config { kohaiRoleId: string; senpaiRoleId: string; senseiRoleId: string; + meijinRoleId: string; feltRoleId: string; teamRoleId: string; @@ -57,6 +59,9 @@ export interface Config { senseiReactionThreshold: number; senseiUniquePercent: number; + // Meijin + meijinWindowDays: number; + // Content Pipeline contentChannelIds: string[]; contentPipelineDaysBack: number; @@ -121,6 +126,7 @@ export interface RoleCounts { kohai: number; senpai: number; sensei: number; + meijin: number; } // ============================================ diff --git a/backend/src/utils/config.ts b/backend/src/utils/config.ts index 01d3422..131c652 100644 --- a/backend/src/utils/config.ts +++ b/backend/src/utils/config.ts @@ -55,6 +55,7 @@ export function loadConfig(): Config { kohaiRoleId: getRequiredEnv('KOHAI_ROLE_ID'), senpaiRoleId: getRequiredEnv('SENPAI_ROLE_ID'), senseiRoleId: getRequiredEnv('SENSEI_ROLE_ID'), + meijinRoleId: getRequiredEnv('MEIJIN_ROLE_ID'), feltRoleId: getRequiredEnv('FELT_ROLE_ID'), teamRoleId: getRequiredEnv('TEAM_ROLE_ID'), @@ -72,6 +73,9 @@ export function loadConfig(): Config { senseiReactionThreshold: getNumberEnv('SENSEI_REACTION_THRESHOLD', 30), senseiUniquePercent: getNumberEnv('SENSEI_UNIQUE_PERCENT', 0.2), + // Meijin + meijinWindowDays: getNumberEnv('MEIJIN_WINDOW_DAYS', 30), + // Content Pipeline contentChannelIds: (getOptionalEnv('CONTENT_CHANNEL_IDS') || '') .split(',')