/opt/canhelp/apps/api/src/lib
Edit: /opt/canhelp/apps/api/src/lib/telegram.ts (6207B)
import { db } from '../db.js'
import { settings as settingsTable } from '@canhelp/db'
import { inArray } from 'drizzle-orm'
// ─── Config cache (TTL = 60s) ─────────────────────────────────────────────
export const LOCALES = ['el', 'en', 'ru', 'uk'] as const
export type Locale = typeof LOCALES[number]
type TelegramConfig = {
botToken: string
adminChatId: string
channels: Record
// per-language channel IDs
notifyRegistrations: boolean
postNewTasks: boolean
}
let _cache: TelegramConfig | null = null
let _cacheAt = 0
const CACHE_TTL = 60_000
async function loadConfig(): Promise {
if (Date.now() - _cacheAt < CACHE_TTL && _cache) return _cache
const rows = await db
.select()
.from(settingsTable)
.where(
inArray(settingsTable.key, [
'telegram.botToken',
'telegram.adminChatId',
'telegram.channelId.el',
'telegram.channelId.en',
'telegram.channelId.ru',
'telegram.channelId.uk',
'telegram.notifyRegistrations',
'telegram.postNewTasks',
]),
)
.catch(() => [])
const kv: Record = {}
for (const r of rows) kv[r.key] = r.value
_cache = {
botToken: kv['telegram.botToken'] ?? '',
adminChatId: kv['telegram.adminChatId'] ?? '',
channels: {
el: kv['telegram.channelId.el'] ?? '',
en: kv['telegram.channelId.en'] ?? '',
ru: kv['telegram.channelId.ru'] ?? '',
uk: kv['telegram.channelId.uk'] ?? '',
},
notifyRegistrations: kv['telegram.notifyRegistrations'] === 'true',
postNewTasks: kv['telegram.postNewTasks'] === 'true',
}
_cacheAt = Date.now()
return _cache
}
export function invalidateTelegramCache() {
_cacheAt = 0
_cache = null
}
// ─── Core send ────────────────────────────────────────────────────────────
async function sendMessage(botToken: string, chatId: string, text: string): Promise {
if (!botToken || !chatId) return
const url = `https://api.telegram.org/bot${botToken}/sendMessage`
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'HTML' }),
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`Telegram API error ${res.status}: ${body}`)
}
}
// ─── Public helpers ────────────────────────────────────────────────────────
/** Send a notification to the admin chat (e.g. new registration) */
export async function notifyAdmin(text: string): Promise {
const cfg = await loadConfig()
if (!cfg.botToken || !cfg.adminChatId) return
await sendMessage(cfg.botToken, cfg.adminChatId, text)
}
/** Returns true if registration notifications are enabled */
export async function isRegistrationNotifyEnabled(): Promise {
const cfg = await loadConfig()
return !!(cfg.botToken && cfg.adminChatId && cfg.notifyRegistrations)
}
/** Returns true if new-task channel posts are enabled (at least one channel configured) */
export async function isNewTaskPostEnabled(): Promise {
const cfg = await loadConfig()
return !!(cfg.botToken && cfg.postNewTasks && LOCALES.some((l) => cfg.channels[l]))
}
type TaskPayload = {
id: string
title: string
titleEl?: string | null
titleEn?: string | null
titleRu?: string | null
titleUk?: string | null
description: string
descriptionEl?: string | null
descriptionEn?: string | null
descriptionRu?: string | null
descriptionUk?: string | null
budget?: string | null
location?: string | null
}
/** Post a new task to all configured language channels */
export async function postTaskToChannels(task: TaskPayload, siteUrl: string): Promise {
const cfg = await loadConfig()
if (!cfg.botToken || !cfg.postNewTasks) return
const localeTitle: Record = {
el: task.titleEl || task.title,
en: task.titleEn || task.title,
ru: task.titleRu || task.title,
uk: task.titleUk || task.title,
}
const localeDesc: Record = {
el: task.descriptionEl || task.description,
en: task.descriptionEn || task.description,
ru: task.descriptionRu || task.description,
uk: task.descriptionUk || task.description,
}
const link = `${siteUrl}/tasks/${task.id}`
await Promise.allSettled(
LOCALES.filter((l) => cfg.channels[l]).map((l) => {
const title = localeTitle[l]
const desc = localeDesc[l]
const budget = task.budget ? `💰 ${task.budget} €` : ''
const location = task.location ? `📍 ${task.location}` : ''
const lines = [
`📋 ${title}`,
desc ? desc.slice(0, 200) + (desc.length > 200 ? '…' : '') : '',
[budget, location].filter(Boolean).join(' '),
`🔗 ${link}`,
].filter(Boolean)
return sendMessage(cfg.botToken, cfg.channels[l], lines.join('\n'))
}),
)
}
/**
* Test connection using the provided credentials (not the cached ones).
* Sends a test message to the given chatId.
*/
export async function testTelegramConnection(botToken: string, chatId: string): Promise<{ ok: true; botName: string } | { ok: false; error: string }> {
try {
const meRes = await fetch(`https://api.telegram.org/bot${botToken}/getMe`)
if (!meRes.ok) {
const body = await meRes.text().catch(() => '')
return { ok: false, error: `Invalid bot token: ${body}` }
}
const me = (await meRes.json()) as { result?: { username?: string; first_name?: string } }
const botName = me.result?.username || me.result?.first_name || 'Bot'
await sendMessage(botToken, chatId, `✅ CanHelp connected!\nBot: @${botName}`)
return { ok: true, botName }
} catch (err) {
return { ok: false, error: String(err) }
}
}