Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
7 changes: 6 additions & 1 deletion backend/src/jobs/decayCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -48,8 +49,12 @@ async function runDecayCheck(client: Client): Promise<void> {
}
}

// 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);
Expand Down
70 changes: 63 additions & 7 deletions backend/src/services/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,20 @@ 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,
messageAuthorId: string,
reactorId: string,
reactorRole: Role
): Promise<boolean> {
// 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();

Expand All @@ -79,27 +85,29 @@ 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<Reaction[]> {
const results = await sql<Reaction[]>`
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
`;

return results;
}

/**
* 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<Reaction[]> {
const results = await sql<Reaction[]>`
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
`;
Expand All @@ -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<Reaction[]> {
const cutoffTimestamp = Date.now() - days * 24 * 60 * 60 * 1000;
Expand All @@ -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
Expand All @@ -126,27 +135,29 @@ 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<string[]> {
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})
`;

return results.map((r) => r.reactor_id);
}

/**
* 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<number> {
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})
`;

Expand Down Expand Up @@ -174,6 +185,7 @@ export async function getReactionBreakdown(userId: string): Promise<ReactionCoun
SUM(CASE WHEN reactor_role = 'Sensei' THEN 1 ELSE 0 END) as fromSensei
FROM reactions
WHERE author_id = ${userId}
AND author_id != reactor_id
`;

const row = results[0];
Expand All @@ -197,7 +209,8 @@ export async function getLeaderboard(role?: Role, limit: number = 20): Promise<L
const results = await sql<{ user_id: string; reaction_count: string }[]>`
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}
Expand All @@ -208,3 +221,46 @@ export async function getLeaderboard(role?: Role, limit: number = 20): Promise<L
reaction_count: parseInt(r.reaction_count, 10),
}));
}

/**
* Get top recipients of Sensei reactions within a time window (excludes self-reactions)
* Returns all users tied for the highest count
*/
export async function getTopSenseiReactionRecipients(days: number): Promise<LeaderboardEntry[]> {
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),
}));
}
88 changes: 88 additions & 0 deletions backend/src/services/meijin.ts
Original file line number Diff line number Diff line change
@@ -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<MeijinCheckResult> {
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;
}
}
4 changes: 4 additions & 0 deletions backend/src/services/roleManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ function getRoleId(role: Role): string {
return config.senpaiRoleId;
case Role.Sensei:
return config.senseiRoleId;
case Role.Meijin:
return config.meijinRoleId;
}
}

Expand Down Expand Up @@ -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,
};
}

Expand Down
6 changes: 6 additions & 0 deletions backend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export enum Role {
Kohai = 'Kohai',
Senpai = 'Senpai',
Sensei = 'Sensei',
Meijin = 'Meijin',
}

/**
Expand Down Expand Up @@ -40,6 +41,7 @@ export interface Config {
kohaiRoleId: string;
senpaiRoleId: string;
senseiRoleId: string;
meijinRoleId: string;
feltRoleId: string;
teamRoleId: string;

Expand All @@ -57,6 +59,9 @@ export interface Config {
senseiReactionThreshold: number;
senseiUniquePercent: number;

// Meijin
meijinWindowDays: number;

// Content Pipeline
contentChannelIds: string[];
contentPipelineDaysBack: number;
Expand Down Expand Up @@ -121,6 +126,7 @@ export interface RoleCounts {
kohai: number;
senpai: number;
sensei: number;
meijin: number;
}

// ============================================
Expand Down
4 changes: 4 additions & 0 deletions backend/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),

Expand All @@ -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(',')
Expand Down