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
8 changes: 7 additions & 1 deletion packages/api-main/.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
PG_URI="postgresql://default:password@localhost:5432/postgres"
JWT=default_jwt_secret
JWT_STRICTNESS=lax
DISCORD_WEBHOOK_URL=
DISCORD_WEBHOOK_URL=

TELEGRAM_BOT_TOKEN=
TELEGRAM_BOT_USERNAME=@bot_username
TELEGRAM_FEED_CHANNEL_ID=
TELEGRAM_FEED_CHANNEL_NAME==@channel_name
TELEGRAM_MINIAPP_URL=https://dither.chat
3 changes: 3 additions & 0 deletions packages/api-main/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,17 @@
"elysia": "~1.2.25",
"jsonwebtoken": "^9.0.2",
"pg": "^8.16.3",
"pino": "^10.1.0",
"postgres": "^3.4.7",
"telegraf": "^4.16.3",
"ws": "^8.18.3"
},
"devDependencies": {
"@cosmjs/amino": "^0.33.1",
"@types/jsonwebtoken": "^9.0.10",
"@types/pg": "^8.15.5",
"drizzle-kit": "^0.31.5",
"pino-pretty": "^13.1.2",
"tsx": "^4.20.6",
"vite-node": "^3.2.4",
"vitest": "^3.2.4"
Expand Down
2 changes: 2 additions & 0 deletions packages/api-main/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { moderatorRoutes } from './routes/moderator';
import { publicRoutes } from './routes/public';
import { readerRoutes } from './routes/reader';
import { userRoutes } from './routes/user';
import { startBot as startTelegramBot } from './telegram/bot';

const config = useConfig();
const app = new Elysia({ adapter: node(), prefix: '/v1' });
Expand All @@ -22,6 +23,7 @@ export function start() {
app.use(userRoutes);
app.use(moderatorRoutes);

startTelegramBot();
app.listen(config.PORT);
}

Expand Down
148 changes: 148 additions & 0 deletions packages/api-main/src/telegram/bot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import type { Context } from 'telegraf';

import type { Post } from '../types/feed.js';

import process from 'node:process';

import { Markup, Telegraf } from 'telegraf';

import { telegramConfig } from './config.js';
import { logger } from './logger.js';
import { compressHash, decompressHash } from './utils.js';

const telegramErrors = {
cannot_initiate: 'Forbidden: bot can\'t initiate conversation with a user',
};

const chatId = telegramConfig.telegram.chatId;
const bot = new Telegraf(telegramConfig.telegram.botToken);

/**
* Send a message to the Telegram channel with action buttons.
*/
export async function sendMessageToChannel(post: Post) {
const text = `From: ${post.author}\n\n${post.message}`;
const encodedHash = compressHash(post.hash);

try {
await bot.telegram.sendMessage(
chatId,
text,
Markup.inlineKeyboard([
[Markup.button.callback('💬 Reply', `reply:${encodedHash}`)],
[
Markup.button.callback('👍', `like:${encodedHash}`),
Markup.button.callback('👎', `dislike:${encodedHash}`),
],
]),
);
logger.info(`Message sent to Telegram for post hash: ${post.hash}`);
} catch (error) {
logger.error(`Failed to send message to Telegram: ${(error as Error).message}`);
throw error;
}
}

/**
* Send a private message to a user with a sign action link.
*/
async function sendMessageToUser(userId: number, action: string, hash: string) {
const actionLabel = action.charAt(0).toUpperCase() + action.slice(1);
const message = `Visit the link below to complete your ${action} action:`;
const buttonUrl = `${telegramConfig.webApp.url}/${action}?hash=${hash}`;

await bot.telegram.sendMessage(
userId,
message,
Markup.inlineKeyboard([Markup.button.url(`Sign ${actionLabel}`, buttonUrl)]),
);
}

bot.command('feed', async (ctx) => {
const username = ctx.from.username || ctx.from.first_name;
logger.info(`User "${username}" requested feed link`);

await ctx.reply(`Join our Dither feed channel: @ditherbottest`);
});

bot.command('keplr', async (ctx) => {
await ctx.reply(`To link your wallet, please visit:`, Markup.inlineKeyboard([
Markup.button.url('Connect Wallet', `https://deeplink.keplr.app/web-browser?url=https://dither.chat`),
]));
});

bot.help((ctx) => {
const helpMessage = `
Available commands:
/feed - Get the Dither feed channel link
/app - Open the Dither Web App
/keplr - Link your Keplr wallet
`.trim();

ctx.reply(helpMessage);
});
bot.command('app', ctx => sendMiniAppLink(ctx, 'Open the Dither Web App below:'));

bot.start(ctx => sendMiniAppLink(ctx, 'Welcome to Dither! Open the Web App below to get started.'));

bot.on('callback_query', async (ctx) => {
const data = (ctx.callbackQuery as any).data;
const userId = ctx.from.id;
const username = ctx.from.username || ctx.from.first_name;

if (!data) return;

const [action, b64hash] = data.split(':');
const hash = decompressHash(b64hash);

try {
await sendMessageToUser(userId, action, hash);
} catch (error) {
const description = (error as any).description || (error as Error).message;
logger.error(`Failed to send private message to user "${username}": ${description}`);

if (description === telegramErrors.cannot_initiate) {
await ctx.answerCbQuery(
`To use this feature, you must start our bot first. Please go to "${telegramConfig.telegram.botUsername}" and click "Start", then try again.`,
{
show_alert: true,
},
);
}
}
});

async function sendMiniAppLink(ctx: Context, message: string) {
await ctx.reply(
message,
Markup.inlineKeyboard([Markup.button.webApp('Open Web App', telegramConfig.webApp.url)]),
);
}

export async function startBot() {
if (!telegramConfig.telegram.botToken) {
logger.warn('Telegram bot token is not set. Skipping bot startup.');
return;
}

await bot.telegram.setMyCommands([
{ command: 'feed', description: 'Get the Dither feed channel link' },
{
command: 'app',
description: 'Open the Dither Web App',
},
{ command: 'keplr', description: 'Link your Keplr wallet' },
{ command: 'help', description: 'Show help information' },
]);

bot.launch();
logger.info('Telegram bot started');

process.once('SIGINT', () => bot.stop('SIGINT'));
process.once('SIGTERM', () => bot.stop('SIGTERM'));
}

export function stopBot() {
bot.stop();
logger.info('Telegram bot stopped');
}
13 changes: 13 additions & 0 deletions packages/api-main/src/telegram/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import process from 'node:process';

export const telegramConfig = {
telegram: {
botUsername: process.env.TELEGRAM_BOT_USERNAME || '',
botToken: process.env.TELEGRAM_BOT_TOKEN || '',
chatId: process.env.TELEGRAM_FEED_CHANNEL_ID || '',
channelLink: process.env.TELEGRAM_FEED_CHANNEL_NAME || '',
},
webApp: {
url: process.env.TELEGRAM_MINIAPP_URL || '',
},
};
15 changes: 15 additions & 0 deletions packages/api-main/src/telegram/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import process from 'node:process';

import pino from 'pino';

export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname,module',
},
},
});
13 changes: 13 additions & 0 deletions packages/api-main/src/telegram/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Buffer } from 'node:buffer';

/**
* Compresses hash to fit within Telegram's 64-byte callback_data limit.
* @see https://core.telegram.org/bots/api#inlinekeyboardbutton
*/
export function compressHash(hash: string) {
return Buffer.from(hash, 'hex').toString('base64url');
}

export function decompressHash(hash: string) {
return Buffer.from(hash, 'base64url').toString('hex');
}
Loading