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
29 changes: 29 additions & 0 deletions backend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ import { createSep10Routes, createRequireWalletAuth } from './routes/sep10.js';
import { createZkInputsRoutes } from './routes/zkInputs.js';
import { createSocialTaskRoutes } from './routes/socialTasks.js';
import { createTwitterVerificationService } from './services/twitterVerificationService.js';
import { createDiscordBotService } from './services/discordBotService.js';
import { createDiscordBotRoutes } from './routes/discordBot.js';
import { createTelegramBotService } from './services/telegramBotService.js';
import { createTelegramBotRoutes } from './routes/telegramBot.js';
import {
createNotificationRoutes,
createNotificationPreferencesRoutes,
Expand Down Expand Up @@ -3569,6 +3573,31 @@ export async function createApp(options = {}) {
}),
});
app.use(`${prefix}/social-tasks`, rateLimiter, ...guard, socialTaskRouter);

// #1349 — Discord bot slash-command `/trivela-stats`
const discordBotRouter = createDiscordBotRoutes({
discordBotService: createDiscordBotService({ campaignRepository }),
});
app.use(`${prefix}/discord`, rateLimiter, discordBotRouter);

// #1350 — Telegram bot `/claim` command
const telegramBotRouter = createTelegramBotRoutes({
telegramBotService: createTelegramBotService({
claimableBalanceRepository: {
listByRecipient: (identity) => {
try {
return dal.db
.prepare('SELECT * FROM claimable_balances WHERE recipient = ?')
.all(identity);
} catch {
return [];
}
},
},
identityResolver: (id) => id,
}),
});
app.use(`${prefix}/telegram`, rateLimiter, telegramBotRouter);
}

// #551 — SEP-1 stellar.toml (public, no auth, correct content-type + CORS)
Expand Down
46 changes: 46 additions & 0 deletions backend/src/routes/discordBot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Discord bot webhook routes (#1349).
*
* POST /discord/interactions — entry point for Discord slash-commands.
* Currently handles `/trivela-stats`, returning real-time campaign metrics.
*/

import { Router } from 'express';

/**
* @param {{
* discordBotService: ReturnType<import('../services/discordBotService.js').createDiscordBotService>,
* verifyDiscordSignature?: import('express').RequestHandler,
* }} deps
* @returns {import('express').Router}
*/
export function createDiscordBotRoutes({ discordBotService, verifyDiscordSignature } = {}) {
const router = Router();

// Discord requires a PING interaction to be answered with PONG type 1.
// Subsequent commands arrive as type 2 (APPLICATION_COMMAND).
router.post(
'/interactions',
...(verifyDiscordSignature ? [verifyDiscordSignature] : []),
(req, res) => {
const body = req.body ?? {};

if (body.type === 1) {
return res.json({ type: 1 });
}

const commandName = body.data?.name ?? body.command ?? '';

if (commandName === 'trivela-stats') {
return res.json(discordBotService.buildStatsResponse());
}

return res.json({
type: 4,
data: { content: `Unknown command: \`/${commandName}\`. Try \`/trivela-stats\`.` },
});
},
);

return router;
}
32 changes: 32 additions & 0 deletions backend/src/routes/telegramBot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Telegram bot webhook routes (#1350).
*
* POST /telegram/webhook — entry point for Telegram bot updates.
* Currently handles `/claim`, returning claimable reward balances.
*/

import { Router } from 'express';

/**
* @param {{
* telegramBotService: ReturnType<import('../services/telegramBotService.js').createTelegramBotService>,
* verifyTelegramSecret?: import('express').RequestHandler,
* }} deps
* @returns {import('express').Router}
*/
export function createTelegramBotRoutes({ telegramBotService, verifyTelegramSecret } = {}) {
const router = Router();

router.post('/webhook', ...(verifyTelegramSecret ? [verifyTelegramSecret] : []), (req, res) => {
const reply = telegramBotService.handleUpdate(req.body ?? {});

if (!reply) {
return res.status(400).json({ error: 'invalid update payload', code: 'VALIDATION_ERROR' });
}

// Telegram expects 200 even for replies we send via the Bot API.
return res.json(reply);
});

return router;
}
91 changes: 91 additions & 0 deletions backend/src/services/discordBotService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Discord bot service for `/trivela-stats` (#1349).
*
* Builds a real-time campaign metrics summary suitable for a Discord
* slash-command response. Data is read from the campaign repository so
* results stay consistent with the REST API.
*/

/**
* @typedef {{ active: number, total: number, participants: number, rewardPool: number }} CampaignMetrics
*/

/**
* @param {{
* campaignRepository: {
* list?: (opts?: object) => Array<any> | { data: Array<any> },
* getAll?: () => Array<any>,
* },
* rewardPoolResolver?: (campaign: any) => number,
* }} deps
*/
export function createDiscordBotService({
campaignRepository,
rewardPoolResolver = defaultRewardPool,
} = {}) {
/**
* Return aggregate campaign metrics for the `/trivela-stats` command.
* @returns {CampaignMetrics}
*/
function getTrivelaStats() {
const campaigns = normalizeList(
campaignRepository?.list?.() ?? campaignRepository?.getAll?.() ?? [],
);

let active = 0;
let participants = 0;
let rewardPool = 0;

for (const campaign of campaigns) {
const isActive = isCampaignActive(campaign);
if (isActive) active += 1;
participants += Number(campaign.participants ?? campaign.participantCount ?? 0);
rewardPool += rewardPoolResolver(campaign);
}

return {
active,
total: campaigns.length,
participants,
rewardPool: Math.round(rewardPool * 100) / 100,
};
}

/**
* Format metrics as a Discord interaction response payload.
* @returns {{ type: 4, data: { content: string } }}
*/
function buildStatsResponse() {
const stats = getTrivelaStats();
const content = [
'**Trivela Campaign Stats**',
`Active campaigns: **${stats.active}**`,
`Total campaigns: **${stats.total}**`,
`Total participants: **${stats.participants.toLocaleString()}**`,
`Reward pool: **${stats.rewardPool.toLocaleString()}**`,
].join('\n');

return { type: 4, data: { content } };
}

return { getTrivelaStats, buildStatsResponse };
}

function isCampaignActive(campaign) {
const status = String(campaign.status ?? '').toLowerCase();
if (status) return status === 'active' || status === 'published';
const end = campaign.endDate ?? campaign.end_date;
return end ? new Date(end).getTime() > Date.now() : false;
}

function defaultRewardPool(campaign) {
const per = Number(campaign.rewardPerAction ?? campaign.reward_per_action ?? 0);
const max = Number(campaign.maxParticipants ?? campaign.max_participants ?? 0);
return per * max;
}

function normalizeList(list) {
if (Array.isArray(list)) return list;
if (Array.isArray(list?.data)) return list.data;
return [];
}
73 changes: 73 additions & 0 deletions backend/src/services/discordBotService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// @ts-check
/**
* Tests for the Discord bot `/trivela-stats` command (#1349).
* Run with: node --test src/services/discordBotService.test.js
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import { createDiscordBotService } from './discordBotService.js';

function makeRepo(campaigns) {
return { list: () => campaigns };
}

test('returns zero metrics for an empty campaign list', () => {
const svc = createDiscordBotService({ campaignRepository: makeRepo([]) });
const stats = svc.getTrivelaStats();
assert.deepEqual(stats, { active: 0, total: 0, participants: 0, rewardPool: 0 });
});

test('counts active campaigns and sums participants and reward pool', () => {
const svc = createDiscordBotService({
campaignRepository: makeRepo([
{
status: 'active',
participants: 10,
rewardPerAction: 5,
maxParticipants: 100,
},
{
status: 'ended',
participants: 3,
rewardPerAction: 2,
maxParticipants: 50,
},
{
status: 'draft',
participants: 0,
rewardPerAction: 1,
maxParticipants: 10,
},
]),
});
const stats = svc.getTrivelaStats();
assert.equal(stats.total, 3);
assert.equal(stats.active, 1);
assert.equal(stats.participants, 13);
assert.equal(stats.rewardPool, 5 * 100 + 2 * 50 + 1 * 10);
});

test('buildStatsResponse returns a Discord interaction payload', () => {
const svc = createDiscordBotService({
campaignRepository: makeRepo([
{ status: 'active', participants: 4, rewardPerAction: 1, maxParticipants: 10 },
]),
});
const res = svc.buildStatsResponse();
assert.equal(res.type, 4);
assert.match(res.data.content, /Active campaigns: \*\*1\*\*/);
assert.match(res.data.content, /Total participants: \*\*4\*\*/);
assert.match(res.data.content, /Reward pool: \*\*10\*\*/);
});

test('uses endDate when status is missing to determine active', () => {
const future = new Date(Date.now() + 86_400_000).toISOString();
const past = new Date(Date.now() - 86_400_000).toISOString();
const svc = createDiscordBotService({
campaignRepository: makeRepo([
{ endDate: future, participants: 1, rewardPerAction: 0, maxParticipants: 0 },
{ endDate: past, participants: 1, rewardPerAction: 0, maxParticipants: 0 },
]),
});
assert.equal(svc.getTrivelaStats().active, 1);
});
106 changes: 106 additions & 0 deletions backend/src/services/telegramBotService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* Telegram bot service for `/claim` (#1350).
*
* Lets users check claimable reward balances via a Telegram bot command.
* The handler resolves the user's linked wallet identity and returns a
* human-readable summary of claimable rewards.
*/

/**
* @typedef {{ identity: string, claimable: number, currency: string, streakDays: number }} ClaimSummary
*/

/**
* @param {{
* claimableBalanceRepository?: {
* listByRecipient: (identity: string) => Array<any>,
* },
* streakService?: ReturnType<import('./streakService.js').createStreakService>,
* identityResolver?: (telegramId: string) => string | null,
* rewardCurrency?: string,
* }} deps
*/
export function createTelegramBotService({
claimableBalanceRepository,
streakService,
identityResolver = (id) => id,
rewardCurrency = 'XLM',
} = {}) {
/**
* Resolve claimable reward summary for a Telegram user.
* @param {string} telegramUserId
* @returns {ClaimSummary | null} null when the user has no linked wallet
*/
function getClaimSummary(telegramUserId) {
const identity = identityResolver(telegramUserId);
if (!identity) return null;

const balances = claimableBalanceRepository?.listByRecipient?.(identity) ?? [];
const claimable = balances
.filter((b) => (b.status ?? 'pending') !== 'claimed')
.reduce((sum, b) => sum + Number(b.amount ?? 0), 0);

const streak = streakService?.getStreak?.(identity);
const streakDays = streak?.streakDays ?? 0;

return {
identity,
claimable: Math.round(claimable * 100) / 100,
currency: rewardCurrency,
streakDays,
};
}

/**
* Build a Telegram sendMessage payload for the `/claim` command.
* @param {string} telegramUserId
* @returns {{ chat_id: string, text: string }}
*/
function buildClaimResponse(telegramUserId) {
const summary = getClaimSummary(telegramUserId);

if (!summary) {
return {
chat_id: String(telegramUserId),
text: 'Link your wallet first to check claimable rewards.',
};
}

const lines = [
'Your claimable rewards:',
`Balance: ${summary.claimable.toLocaleString()} ${summary.currency}`,
`Current streak: ${summary.streakDays} day${summary.streakDays === 1 ? '' : 's'}`,
];

return {
chat_id: String(telegramUserId),
text: lines.join('\n'),
};
}

/**
* Handle an incoming Telegram update (webhook body).
* Only `/claim` is supported; other messages get a hint reply.
* @param {{ message?: { chat?: { id?: number|string }, from?: { id?: number|string }, text?: string } }} update
* @returns {{ chat_id: string, text: string } | null}
*/
function handleUpdate(update) {
const message = update?.message;
const chatId = message?.chat?.id ?? message?.from?.id;
if (chatId == null) return null;

const text = String(message?.text ?? '').trim();
const command = text.split(/\s+/)[0]?.split('@')[0]?.toLowerCase() ?? '';

if (command === '/claim') {
return buildClaimResponse(chatId);
}

return {
chat_id: String(chatId),
text: 'Available commands:\n/claim — check your claimable reward balance',
};
}

return { getClaimSummary, buildClaimResponse, handleUpdate };
}
Loading