diff --git a/.gitignore b/.gitignore index cb86ffa..428a9b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules dist .env -vscode-profile-* \ No newline at end of file +vscode-profile-* +deeplinkUsers.json \ No newline at end of file diff --git a/package.json b/package.json index 23e2622..28ec5a5 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@hattip/response": "0.0.45", "cron": "^3.1.7", "cross-env": "^7.0.3", + "cryptr": "^6.3.0", "d3-color": "^3.1.0", "d3-interpolate": "^3.0.1", "dotenv": "^16.4.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f7bdf7..28db5cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ dependencies: cross-env: specifier: ^7.0.3 version: 7.0.3 + cryptr: + specifier: ^6.3.0 + version: 6.3.0 d3-color: specifier: ^3.1.0 version: 3.1.0 @@ -1582,6 +1585,10 @@ packages: shebang-command: 2.0.0 which: 2.0.2 + /cryptr@6.3.0: + resolution: {integrity: sha512-TA4byAuorT8qooU9H8YJhBwnqD151i1rcauHfJ3Divg6HmukHB2AYMp0hmjv2873J2alr4t15QqC7zAnWFrtfQ==} + dev: false + /css-select@5.1.0: resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} dependencies: diff --git a/src/backend/telegram/bot.ts b/src/backend/telegram/bot.ts index 20eeca8..a40a945 100644 --- a/src/backend/telegram/bot.ts +++ b/src/backend/telegram/bot.ts @@ -3,17 +3,21 @@ import { settings } from '../../settings' import { MenuTemplate, MenuMiddleware } from 'grammy-inline-menu' import { config } from '../config' import { IMode, ISettings } from 'src/typings' -import { allowedTelegramUsers } from '.' import { InlineKeyboardButton, Message } from 'grammy/types' import { TextBody } from 'grammy-inline-menu/dist/source/body' import { dynamic } from '../shared' import { getIsWeekend } from '../night/static' +import Cryptr from 'cryptr' +import { addDeeplinkUser, deeplinkUsers } from './deeplink' + +const allowedTelegramUsers = new Set([...config.tgAllowedUsers.split(',').map(el => parseInt(el)), deeplinkUsers]) +const cryptr = new Cryptr(config.tgAllowedUsers, { encoding: 'base64', saltLength: 1, pbkdf2Iterations: 10 }) const bot = new Bot(config.tgApiKey) const formatBool = (val: boolean, text: string) => val && text -const menuTemplate = new MenuTemplate(ctx => +const menuTemplate = new MenuTemplate(() => [ new Date().toLocaleString('en', { weekday: 'long', hour: 'numeric', minute: '2-digit', hour12: false }), formatBool(dynamic.isAway, 'away'), @@ -33,6 +37,7 @@ const toggleTemplate = (title: string, key: IBooleanSettingsKeys) => set: async (ctx, val) => { settings[key] = val await ctx.answerCallbackQuery(`${title} ${val ? 'on' : 'off'}`) + await updateKeyboard(ctx.chat!.id) return true }, @@ -60,7 +65,8 @@ menuTemplate.select( const selectedMode = IMode[settings.mode] // eslint-disable-next-line no-console console.log('selected mode:', selectedMode) - await ctx.answerCallbackQuery(`Selected mode: ${selectedMode}`) + await ctx.answerCallbackQuery(settings.mode ? `${selectedMode} mode` : 'off') + await updateKeyboard(ctx.chat!.id) return true }, @@ -73,70 +79,96 @@ menuTemplate.manual({ }, text: 'select color', }) + const menuMiddleware = new MenuMiddleware('/', menuTemplate) -let lastContext: CommandContext | undefined -let lastMenu: Message.TextMessage | undefined -let lastReact = 0 + +interface IUserData { + ctx: CommandContext + menu: Message.TextMessage +} + +const userData: Map = new Map() bot.command('start', async ctx => { - if (allowedTelegramUsers.has(ctx.chat.id)) { - await ctx.deleteMessages([ctx.message?.message_id, lastMenu?.message_id].filter(el => el) as number[]) - lastContext = ctx - lastMenu = (await menuMiddleware.replyToContext(lastContext!)) as Message.TextMessage - } else { - const now = Date.now() - const diff = now - lastReact - if (diff > 500) { - lastReact = now - await ctx.react(diff > 1000 ? '👎' : '🤬') + let deeplinked = false + if (ctx.match) { + try { + const username = cryptr.decrypt(ctx.match) + if (ctx.from?.username?.slice(0, 15) === username) { + allowedTelegramUsers.add(ctx.from.id) + await addDeeplinkUser(ctx.from.id) + deeplinked = true + } + } catch (error) { + return console.error(error) } } + + if (deeplinked || allowedTelegramUsers.has(ctx.chat.id)) { + const user = userData.get(ctx.chat.id) + await ctx.deleteMessages([ctx.message?.message_id, user?.menu?.message_id].filter(el => el) as number[]) + userData.set(ctx.chat.id, { ctx, menu: (await menuMiddleware.replyToContext(ctx!)) as Message.TextMessage }) + } else { + await ctx.react('👎') + } }) bot.use(async (ctx: Context, next: NextFunction) => { - const authorized = allowedTelegramUsers.has(ctx.chat!.id) - if (!authorized) { - await ctx.answerCallbackQuery('Unauthorized') + if (!ctx.chat || !allowedTelegramUsers.has(ctx.chat.id)) { + if (ctx.callbackQuery) await ctx.answerCallbackQuery('Unauthorized') } else { - lastContext = ctx as CommandContext - lastMenu = ctx.callbackQuery?.message as Message.TextMessage + userData.set(ctx.chat.id, { + ctx: ctx as CommandContext, + menu: ctx.callbackQuery?.message as Message.TextMessage, + }) await next() } -}).use(menuMiddleware) +}) -export async function updateKeyboard() { - if (!lastContext || !lastMenu) return +bot.use(menuMiddleware) - const keyboard = await menuTemplate.renderKeyboard(lastContext, '/') - try { - await bot.api.editMessageReplyMarkup(lastMenu.chat.id, lastMenu.message_id, { - reply_markup: { inline_keyboard: keyboard as InlineKeyboardButton[][] }, - }) - } catch (error) { - if ( - 'description' in (error as any) && - !(error as GrammyError).description.endsWith('are exactly the same as a current content and reply markup of the message') - ) - throw error +bot.hears(/https:\/\/t.me\/(.+)/, async ctx => { + const [, username] = ctx.match + await ctx.reply(`[control lights](https://t.me/${ctx.me.username}?start=${cryptr.encrypt(username.slice(0, 15))})`, { parse_mode: 'MarkdownV2' }) +}) + +export async function updateKeyboard(except?: number) { + for (const [userId, { ctx: lastContext, menu: lastMenu }] of userData.entries()) { + if (userId === except) continue + + const keyboard = await menuTemplate.renderKeyboard(lastContext, '/') + try { + await bot.api.editMessageReplyMarkup(lastMenu.chat.id, lastMenu.message_id, { + reply_markup: { inline_keyboard: keyboard as InlineKeyboardButton[][] }, + }) + } catch (error) { + if ( + 'description' in (error as any) && + !(error as GrammyError).description.endsWith('are exactly the same as a current content and reply markup of the message') + ) + console.error(error) + } } } -export async function updateMessage() { - if (!lastContext || !lastMenu) return - - const body = await menuTemplate.renderBody(lastContext, '/') - const text = typeof body === 'string' ? body : (body as TextBody).text - const keyboard = await menuTemplate.renderKeyboard(lastContext, '/') - try { - await bot.api.editMessageText(lastMenu.chat.id, lastMenu.message_id, text, { - reply_markup: { inline_keyboard: keyboard as InlineKeyboardButton[][] }, - }) - } catch (error) { - if ( - 'description' in (error as any) && - !(error as GrammyError).description.endsWith('are exactly the same as a current content and reply markup of the message') - ) - throw error +export async function updateMessage(except?: number) { + for (const [userId, { ctx: lastContext, menu: lastMenu }] of userData.entries()) { + if (userId === except) continue + + const body = await menuTemplate.renderBody(lastContext, '/') + const text = typeof body === 'string' ? body : (body as TextBody).text + const keyboard = await menuTemplate.renderKeyboard(lastContext, '/') + try { + await bot.api.editMessageText(lastMenu.chat.id, lastMenu.message_id, text, { + reply_markup: { inline_keyboard: keyboard as InlineKeyboardButton[][] }, + }) + } catch (error) { + if ( + 'description' in (error as any) && + !(error as GrammyError).description.endsWith('are exactly the same as a current content and reply markup of the message') + ) + console.error(error) + } } } diff --git a/src/backend/telegram/deeplink.ts b/src/backend/telegram/deeplink.ts new file mode 100644 index 0000000..ae246e1 --- /dev/null +++ b/src/backend/telegram/deeplink.ts @@ -0,0 +1,16 @@ +import fs from 'fs' +import fsp from 'fs/promises' + +export const deeplinkUsers = getDeeplinkUsers() + +function getDeeplinkUsers(): number[] { + try { + return JSON.parse(fs.readFileSync('./deeplinkUsers.json', 'utf8')) + } catch (error) { + return [] + } +} + +export function addDeeplinkUser(user: number) { + if (!deeplinkUsers.includes(user)) return fsp.writeFile('./deeplinkUsers.json', JSON.stringify([...deeplinkUsers, user])) +}