├── .npmrc ├── .gitignore ├── db └── settings.json ├── src ├── commands │ ├── index.ts │ └── help.ts ├── scenes │ ├── index.ts │ ├── pastvu.ts │ └── settings.ts ├── i18n │ ├── index.ts │ └── locales │ │ ├── ru.json │ │ └── en.json ├── helpers │ ├── createKeyboard.ts │ ├── sendPhotos.ts │ ├── getPastvuRandomPhotos.ts │ ├── setGeoData.ts │ └── getPastvuPhotos.ts └── index.ts ├── .prettierignore ├── .render-buildpacks.json ├── .dockerignore ├── assets ├── pastvu-logo.png ├── pastvu-result-1.png └── pastvu-result-2.png ├── .env.example ├── .prettierrc.json ├── Dockerfile ├── tsconfig.json ├── render.yaml ├── .github └── workflows │ └── main.yml ├── README.md ├── .eslintrc.json ├── package.json └── Dockerfile.render /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules 3 | /build 4 | -------------------------------------------------------------------------------- /db/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "sessions": [] 3 | } 4 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | export * from './help' 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /.render-buildpacks.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildpacks": ["heroku/nodejs"] 3 | } 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | node_modules 3 | **/.git 4 | .env 5 | README.md 6 | -------------------------------------------------------------------------------- /src/scenes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './pastvu' 2 | export * from './settings' 3 | -------------------------------------------------------------------------------- /assets/pastvu-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ratmirslv/pastvu-bot/HEAD/assets/pastvu-logo.png -------------------------------------------------------------------------------- /assets/pastvu-result-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ratmirslv/pastvu-bot/HEAD/assets/pastvu-result-1.png -------------------------------------------------------------------------------- /assets/pastvu-result-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ratmirslv/pastvu-bot/HEAD/assets/pastvu-result-2.png -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Telegram token 2 | # BOT_TOKEN= 3 | 4 | # Webhook URL 5 | # WEBHOOK_URL= 6 | 7 | # Webhook port 8 | # PORT=8080 9 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 90, 3 | "useTabs": true, 4 | "semi": false, 5 | "singleQuote": true, 6 | "quoteProps": "consistent", 7 | "trailingComma": "all", 8 | "overrides": [ 9 | { 10 | "files": "*.md", 11 | "options": { "useTabs": false } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | import TelegrafI18n from 'telegraf-i18n' 4 | 5 | const i18n = new TelegrafI18n({ 6 | defaultLanguage: 'en', 7 | defaultLanguageOnMissing: true, 8 | useSession: false, 9 | directory: path.resolve(__dirname, 'locales'), 10 | }) 11 | 12 | export default i18n 13 | -------------------------------------------------------------------------------- /src/commands/help.ts: -------------------------------------------------------------------------------- 1 | import { createKeyboard } from '../helpers/createKeyboard' 2 | import { ContextBot } from '../index' 3 | 4 | export function help(ctx: ContextBot): Promise { 5 | return ctx.reply( 6 | ctx.i18n.t('help', { 7 | userName: ctx.message?.from.first_name, 8 | }), 9 | createKeyboard(ctx), 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.16.0-alpine 2 | 3 | WORKDIR /bot 4 | 5 | RUN npm install -g npm@9.6.7 6 | 7 | COPY package*.json ./ 8 | 9 | COPY .npmrc ./ 10 | 11 | RUN npm ci 12 | 13 | COPY . . 14 | 15 | RUN npm run build \ 16 | && npm prune --production \ 17 | && npm cache clean --force 18 | 19 | ENV NODE_ENV=$NODE_ENV 20 | 21 | CMD ["node", "build/index.js"] 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "lib": ["esnext"], 5 | "allowJs": false, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "CommonJS", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "experimentalDecorators": true, 16 | "emitDecoratorMetadata": true, 17 | "outDir": "build" 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /render.yaml: -------------------------------------------------------------------------------- 1 | # This file was generated by Render's heroku-import Heroku CLI plugin 2 | # https://www.npmjs.com/package/@renderinc/heroku-import 3 | # Schema documented at https://render.com/docs/yaml-spec 4 | services: 5 | - type: web # valid values: https://render.com/docs/yaml-spec#type 6 | name: pastvu-bot 7 | env: docker # valid values: https://render.com/docs/yaml-spec#environment 8 | dockerfilePath: Dockerfile.render 9 | plan: free # optional; defaults to starter 10 | numInstances: 1 11 | envVars: 12 | - key: WEBHOOK_URL # Imported from Heroku app 13 | -------------------------------------------------------------------------------- /src/helpers/createKeyboard.ts: -------------------------------------------------------------------------------- 1 | import { Markup } from 'telegraf' 2 | import { ReplyKeyboardMarkup } from 'telegraf/src/core/types/typegram' 3 | 4 | import { ContextBot } from '../index' 5 | 6 | export const createKeyboard = (ctx: ContextBot): Markup.Markup => 7 | Markup.keyboard([ 8 | Markup.button.locationRequest(ctx.i18n.t('buttons.location')), 9 | Markup.button.callback(ctx.i18n.t('buttons.randomPhotos'), 'randomPhotos'), 10 | Markup.button.callback(ctx.i18n.t('buttons.morePhotos'), 'morePhotos'), 11 | Markup.button.callback(ctx.i18n.t('buttons.settings'), 'settings'), 12 | ]).resize() 13 | -------------------------------------------------------------------------------- /src/helpers/sendPhotos.ts: -------------------------------------------------------------------------------- 1 | import { PastvuItem } from '../helpers/getPastvuPhotos' 2 | import { ContextBot } from '../index' 3 | 4 | export const sendPhotos = async ( 5 | ctx: ContextBot, 6 | pastvuData: PastvuItem[], 7 | ): Promise => { 8 | try { 9 | await ctx.replyWithMediaGroup( 10 | pastvuData.map((item) => ({ 11 | media: { url: `https://pastvu.com/_p/d/${item.file}` }, 12 | caption: `${item.year} ${item.title} https://pastvu.com/p/${item.cid}`, 13 | parse_mode: 'HTML', 14 | type: 'photo', 15 | })), 16 | ) 17 | } catch (error) { 18 | throw new Error(ctx.i18n.t('errors.errorSendPhotos')) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v3 16 | 17 | - name: Set up Node.js 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 18.x 21 | 22 | - name: Install dependencies 23 | run: npm ci --legacy-peer-deps 24 | 25 | - name: Check code 26 | run: npm run validate 27 | 28 | - name: Build 29 | run: npm run build 30 | -------------------------------------------------------------------------------- /src/helpers/getPastvuRandomPhotos.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | 3 | import { PastvuItem } from './getPastvuPhotos' 4 | 5 | export type PastvuPhotos = { 6 | result: { photos: PastvuItem[] } 7 | } 8 | export const getPastvuRandomPhotos = async (): Promise => { 9 | const response = await fetch( 10 | `https://pastvu.com/api2?method=photo.givePS¶ms={"cid":1,"random":true}`, 11 | ) 12 | 13 | if (!response.ok) { 14 | throw new Error(`Request failed: ${response.url}: ${response.status}`) 15 | } 16 | 17 | const json = (await response.json()) as { error: { error_msg: string } } | PastvuPhotos 18 | 19 | if ('error' in json) { 20 | throw new Error(`Request failed: ${response.url}: ${json.error.error_msg}`) 21 | } 22 | 23 | return json 24 | } 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 9 | 10 |
11 |

PastvuBot

12 |

Telegram bot that sends historical photos from pastvu.com. 13 |

14 |

Send me your location or link Google Maps and I will send historical photos that were taken in that location. You can set a specific period for photos in the settings.

15 | 16 |
17 |
18 | 24 | 30 |
31 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint", "import"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 9 | "plugin:import/errors", 10 | "plugin:import/warnings", 11 | "plugin:import/typescript" 12 | ], 13 | "parserOptions": { 14 | "project": "./tsconfig.json" 15 | }, 16 | "env": { 17 | "es6": true 18 | }, 19 | "rules": { 20 | "no-mixed-spaces-and-tabs": "off", 21 | "no-console": "warn", 22 | "import/order": [ 23 | "warn", 24 | { "alphabetize": { "order": "asc" }, "newlines-between": "always" } 25 | ], 26 | "import/newline-after-import": "warn", 27 | "@typescript-eslint/no-explicit-any": "off", 28 | "@typescript-eslint/prefer-optional-chain": "warn", 29 | "@typescript-eslint/explicit-module-boundary-types": [ 30 | "warn", 31 | { "allowArgumentsExplicitlyTypedAsAny": true } 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/helpers/setGeoData.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | 3 | import { ContextBot } from '../index' 4 | 5 | const PARSE_COORDINATES_REGEX = /[@/](-?\d+\.\d+),(-?\d+\.\d+)/ 6 | 7 | export async function setGeoData(message: string, ctx: ContextBot): Promise { 8 | try { 9 | const res = await fetch(message, { 10 | redirect: 'manual', 11 | }) 12 | 13 | if (res.status >= 200 && res.status < 400) { 14 | const fullUrl = res.headers.get('location') ?? res.url 15 | 16 | const match = RegExp(PARSE_COORDINATES_REGEX).exec(fullUrl) 17 | 18 | if (match) { 19 | const latitude = match[1] 20 | const longitude = match[2] 21 | ctx.geo = { 22 | latitude: Number(latitude), 23 | longitude: Number(longitude), 24 | } 25 | } else { 26 | throw new Error(ctx.i18n.t('errors.errorParseURL')) 27 | } 28 | } 29 | } catch (error) { 30 | if (error instanceof Error) { 31 | throw new Error(error.message) 32 | } else { 33 | throw new Error(ctx.i18n.t('errors.errorLetsTry')) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/helpers/getPastvuPhotos.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | 3 | export type PastvuItem = { 4 | ccount: number 5 | cid: number 6 | dir: string 7 | file: string 8 | geo: [number, number] 9 | title: string 10 | year: number 11 | s: number 12 | } 13 | 14 | type PropsGetPastvuPhotos = { 15 | latitude: number 16 | longitude: number 17 | startYear: number 18 | endYear: number 19 | } 20 | 21 | export type PastvuPhotos = { 22 | result: { photos: PastvuItem[] } 23 | } 24 | export const getPastvuPhotos = async ( 25 | props: PropsGetPastvuPhotos, 26 | ): Promise => { 27 | const { latitude, longitude, startYear, endYear } = props 28 | const response = await fetch( 29 | `https://pastvu.com/api2?method=photo.giveNearestPhotos¶ms={"geo":[${latitude}, ${longitude}],"year":${startYear},"year2":${endYear}}`, 30 | ) 31 | 32 | if (!response.ok) { 33 | throw new Error(`Request failed: ${response.url}: ${response.status}`) 34 | } 35 | 36 | const json = (await response.json()) as { error: { error_msg: string } } | PastvuPhotos 37 | 38 | if ('error' in json) { 39 | throw new Error(`Request failed: ${response.url}: ${json.error.error_msg}`) 40 | } 41 | 42 | return json 43 | } 44 | -------------------------------------------------------------------------------- /src/i18n/locales/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "start": "Отправьте местоположение или ссылку Google Maps для получения исторических фотографий.", 3 | "help": "Привет, ${userName}! Я - бот отправки исторических фотографий с сайта pastvu.com.\nПришли мне свое местоположение или ссылку Google Maps и я отправлю исторические фотографии, которые были сняты в этом месте.\nТы можешь задать в настройках конкретный период для фотографий.", 4 | "emptyPhotos": "Фотографий в данной локации нет. Попробуйте изменить период или отправьте новую локацию.", 5 | "successSetPeriod": "Новый период назначен.", 6 | "settingsInfo": "Текущий период: ${startYear}-${endYear}\nВыберите даты из истории, или введите период в формате: 1941-1945\nP.S. Нижняя и верхняя граница - 1839 и 2000.", 7 | "errors": { 8 | "error": "Ошибка", 9 | "errorLetsTry": "Ошибка, попробуйте еще раз.", 10 | "errorSave": "Ошибка сохранения настроек.", 11 | "errorSetPeriod": "Ошибка, введите корректный период.", 12 | "errorParseURL": "Что то с ссылкой. Попробуйте выбрать конкретное местоположение.", 13 | "errorSendPhotos": "Ошибка отправки фотографий" 14 | }, 15 | "buttons": { 16 | "location": "🧭 Отправить местоположение", 17 | "randomPhotos": "🎲 Случайные фотографии", 18 | "morePhotos": "🔍 Еще фотографий", 19 | "settings": "⚙️ Настройки" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/i18n/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "start": "Submit your location or link Google Maps to receive historical photos.", 3 | "help": "Hey ${userName}! I am a historical photo sending bot from pastvu.com.\nSend me your location or link Google Maps and I will send historical photos that were taken in that location.\nYou can set a specific period for photos in the settings. There is also the possibility of receiving photos if you send me a Google Maps link.", 4 | "emptyPhotos": "There are no photos in this location. Try changing the period or submit a new location.", 5 | "successSetPeriod": "A new period has been set.", 6 | "settingsInfo": "Current period: ${startYear}-${endYear}\nSelect dates from history, or enter a period in the format: 1941-1945\nP.S. Lower and upper bound - 1839 and 2000.", 7 | "errors": { 8 | "error": "Error.", 9 | "errorLetsTry": "Error, please try again.", 10 | "errorSave": "Error saving settings.", 11 | "errorSetPeriod": "Error, please enter a valid period.", 12 | "errorParseURL": "Something with the link. Try choosing a specific location.", 13 | "errorSendPhotos": "Error sending photos" 14 | }, 15 | "buttons": { 16 | "location": "🧭 Send location", 17 | "randomPhotos": "🎲 Random photos", 18 | "morePhotos": "🔍 More photos", 19 | "settings": "⚙️ Settings" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pastvu-bot", 3 | "version": "1.2.0", 4 | "description": "Get historical photos in Pastvu", 5 | "main": "index.js", 6 | "engines": { 7 | "node": ">=18.16.0" 8 | }, 9 | "scripts": { 10 | "prebuild": "rimraf ./build/*", 11 | "build": "tsc && npm run copyFiles", 12 | "start": "cross-env NODE_ENV=production node build/index.js", 13 | "dev": "npm run copyFiles && tsc-watch --onSuccess \"cross-env NODE_ENV=development DEBUG=telegraf:* node build/index.js\"", 14 | "validate": "npm run check-format && npm run check-types && npm run lint", 15 | "check-format": "prettier src \"*{js,json}\" --check", 16 | "lint": "eslint --ext .js,.ts src", 17 | "check-types": "tsc --noEmit", 18 | "copyFiles": "copyfiles --error --up 1 ./db/*.* ./build/db && copyfiles --error --up 1 ./src/i18n/locales/*.* ./build" 19 | }, 20 | "author": "Ratmir Aitov ", 21 | "license": "ISC", 22 | "simple-git-hooks": { 23 | "pre-commit": "npx pretty-quick --staged" 24 | }, 25 | "dependencies": { 26 | "cross-env": "^7.0.3", 27 | "dotenv-safe": "^8.2.0", 28 | "lodash": "^4.17.21", 29 | "node-fetch": "^2.6.11", 30 | "telegraf": "^4.12.2", 31 | "telegraf-i18n": "^6.6.0", 32 | "telegraf-session-local": "^2.1.1", 33 | "telegraf-throttler": "^0.6.0" 34 | }, 35 | "devDependencies": { 36 | "@babel/preset-typescript": "^7.21.5", 37 | "@types/lodash": "^4.14.195", 38 | "@types/node": "^20.2.5", 39 | "@types/node-fetch": "^2.6.4", 40 | "@typescript-eslint/eslint-plugin": "^5.59.8", 41 | "@typescript-eslint/parser": "^5.59.8", 42 | "copyfiles": "^2.4.1", 43 | "eslint": "^8.41.0", 44 | "eslint-plugin-import": "^2.27.5", 45 | "prettier": "^2.8.8", 46 | "pretty-quick": "^3.1.3", 47 | "rimraf": "^5.0.1", 48 | "simple-git-hooks": "^2.8.1", 49 | "tsc-watch": "^6.0.4", 50 | "typescript": "^5.0.4" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/scenes/pastvu.ts: -------------------------------------------------------------------------------- 1 | import chunk from 'lodash/chunk' 2 | import isEmpty from 'lodash/isEmpty' 3 | import { Scenes } from 'telegraf' 4 | 5 | import { getPastvuPhotos } from '../helpers/getPastvuPhotos' 6 | import { getPastvuRandomPhotos } from '../helpers/getPastvuRandomPhotos' 7 | import { sendPhotos } from '../helpers/sendPhotos' 8 | import { ContextBot } from '../index' 9 | 10 | export const pastvu = new Scenes.BaseScene('pastvu') 11 | 12 | pastvu.enter(async (ctx: ContextBot) => { 13 | if (ctx.geo || ctx.random) { 14 | ctx.scene.session.pastvuData = undefined 15 | const startYear = ctx.data.startYear || 1839 16 | const endYear = ctx.data.endYear || 2000 17 | 18 | try { 19 | const { result } = ctx.random 20 | ? await getPastvuRandomPhotos() 21 | : await getPastvuPhotos({ 22 | latitude: ctx.geo.latitude, 23 | longitude: ctx.geo.longitude, 24 | startYear, 25 | endYear, 26 | }) 27 | 28 | if (result.photos.length === 0) { 29 | await ctx.scene.leave() 30 | return await ctx.reply(ctx.i18n.t('emptyPhotos')) 31 | } 32 | 33 | const [firstChunk, ...otherChunks] = chunk(result.photos, 5) 34 | 35 | await sendPhotos(ctx, firstChunk) 36 | 37 | if (isEmpty(otherChunks)) { 38 | ctx.random = false 39 | await ctx.scene.leave() 40 | } 41 | ctx.scene.session.pastvuData = otherChunks 42 | ctx.scene.session.counterData = 0 43 | ctx.random = false 44 | } catch (err) { 45 | if (err instanceof Error) { 46 | return await ctx.reply(`${ctx.i18n.t('errors.error')} ${err.message}`) 47 | } 48 | await ctx.reply(ctx.i18n.t('errors.errorLetsTry')) 49 | } 50 | } 51 | }) 52 | 53 | pastvu.hears(new RegExp('🔍'), async (ctx) => { 54 | try { 55 | if ( 56 | ctx.scene.session.pastvuData && 57 | ctx.scene.session.counterData < ctx.scene.session.pastvuData.length 58 | ) { 59 | await sendPhotos(ctx, ctx.scene.session.pastvuData[ctx.scene.session.counterData]) 60 | ctx.scene.session.counterData += 1 61 | } else { 62 | await ctx.reply(ctx.i18n.t('emptyPhotos')) 63 | } 64 | } catch (err) { 65 | if (err instanceof Error) { 66 | return await ctx.reply(`${ctx.i18n.t('errors.error')} ${err.message}`) 67 | } 68 | await ctx.reply(ctx.i18n.t('errors.errorLetsTry')) 69 | } 70 | }) 71 | -------------------------------------------------------------------------------- /src/scenes/settings.ts: -------------------------------------------------------------------------------- 1 | import isEqual from 'lodash/isEqual' 2 | import uniqWith from 'lodash/uniqWith' 3 | import { Scenes, Markup } from 'telegraf' 4 | 5 | import { ContextBot } from '../index' 6 | 7 | export const settings = new Scenes.BaseScene('settings') 8 | //photography period from 1839 to 2000 9 | const REGEX_YEARS = /^(18[3-9][0-9]|19\d{2}|2000)-(18[3-9][0-9]|19\d{2}|2000)$/g 10 | 11 | settings.enter(async (ctx) => { 12 | try { 13 | ctx.data.startYear = ctx.data.startYear || 1839 14 | ctx.data.endYear = ctx.data.endYear || 2000 15 | ctx.data.history = ctx.data.history || [] 16 | 17 | await ctx.reply( 18 | ctx.i18n.t('settingsInfo', { 19 | startYear: ctx.data.startYear, 20 | endYear: ctx.data.endYear, 21 | }), 22 | Markup.inlineKeyboard( 23 | ctx.data.history.map( 24 | (range) => 25 | Markup.button.callback( 26 | `${range.startYear}-${range.endYear}`, 27 | `${range.startYear}-${range.endYear}`, 28 | ), 29 | { columns: 1 }, 30 | ), 31 | ), 32 | ) 33 | } catch (err) { 34 | if (err instanceof Error) { 35 | return await ctx.reply(`${ctx.i18n.t('errors.errorSave')} ${err.message}`) 36 | } 37 | } 38 | }) 39 | async function handlerYearsAction( 40 | parseStartYear: number, 41 | parseEndYear: number, 42 | ctx: ContextBot, 43 | ) { 44 | try { 45 | if (parseStartYear > parseEndYear) { 46 | return await ctx.reply(ctx.i18n.t('errors.errorSetPeriod')) 47 | } 48 | ctx.data.history = uniqWith( 49 | [...ctx.data.history, { startYear: ctx.data.startYear, endYear: ctx.data.endYear }], 50 | isEqual, 51 | ) 52 | .reverse() 53 | .slice(0, 3) 54 | ctx.data.startYear = parseStartYear 55 | ctx.data.endYear = parseEndYear 56 | 57 | return await ctx.reply(ctx.i18n.t('successSetPeriod')) 58 | } catch (err) { 59 | if (err instanceof Error) { 60 | return await ctx.reply(`${ctx.i18n.t('errors.errorSave')} ${err.message}`) 61 | } 62 | } 63 | } 64 | settings.hears(REGEX_YEARS, async (ctx) => { 65 | const [, parseStartYear, parseEndYear] = ctx.match 66 | 67 | await handlerYearsAction(Number(parseStartYear), Number(parseEndYear), ctx) 68 | 69 | settings.leave() 70 | }) 71 | settings.action(REGEX_YEARS, async (ctx) => { 72 | const [, parseStartYear, parseEndYear] = ctx.match 73 | 74 | await handlerYearsAction(Number(parseStartYear), Number(parseEndYear), ctx) 75 | 76 | settings.leave() 77 | }) 78 | -------------------------------------------------------------------------------- /Dockerfile.render: -------------------------------------------------------------------------------- 1 | # "v4-" stacks use our new, more rigorous buildpacks management system. They 2 | # allow you to use multiple buildpacks in a single application, as well as to 3 | # use custom buildpacks. 4 | # 5 | # - `v2-` images work with heroku-import v3.x. 6 | # - `v4-` images work with heroku-import v4.x. (We synced the tags.) 7 | 8 | ARG IMPORT_VERSION=v4 9 | ARG HEROKU_STACK=${IMPORT_VERSION}-heroku-20 10 | FROM ghcr.io/renderinc/heroku-app-builder:${HEROKU_STACK} AS builder 11 | 12 | 13 | # Below, please specify any build-time environment variables that you need to 14 | # reference in your build (as called by your buildpacks). If you don't specify 15 | # the arg below, you won't be able to access it in your build. You can also 16 | # specify a default value, as with any Docker `ARG`, if appropriate for your 17 | # use case. 18 | 19 | # ARG MY_BUILD_TIME_ENV_VAR 20 | # ARG DATABASE_URL 21 | 22 | # The FROM statement above refers to an image with the base buildpacks already 23 | # in place. We then run the apply-buildpacks.py script here because, unlike our 24 | # `v2` image, this allows us to expose build-time env vars to your app. 25 | RUN /render/build-scripts/apply-buildpacks.py ${HEROKU_STACK} 26 | 27 | # We strongly recommend that you package a Procfile with your application, but 28 | # if you don't, we'll try to guess one for you. If this is incorrect, please 29 | # add a Procfile that tells us what you need us to run. 30 | RUN if [[ -f /app/Procfile ]]; then \ 31 | /render/build-scripts/create-process-types "/app/Procfile"; \ 32 | fi; 33 | 34 | # For running the app, we use a clean base image and also one without Ubuntu development packages 35 | # https://devcenter.heroku.com/articles/heroku-20-stack#heroku-20-docker-image 36 | FROM ghcr.io/renderinc/heroku-app-runner:${HEROKU_STACK} AS runner 37 | 38 | # Here we copy your build artifacts from the build image to the runner so that 39 | # the image that we deploy to Render is smaller and, therefore, can start up 40 | # faster. 41 | COPY --from=builder --chown=1000:1000 /render /render/ 42 | COPY --from=builder --chown=1000:1000 /app /app/ 43 | 44 | # Here we're switching to a non-root user in the container to remove some categories 45 | # of container-escape attack. 46 | USER 1000:1000 47 | WORKDIR /app 48 | 49 | # This sources all /app/.profile.d/*.sh files before process start. 50 | # These are created by buildpacks, and you probably don't have to worry about this. 51 | # https://devcenter.heroku.com/articles/buildpack-api#profile-d-scripts 52 | ENTRYPOINT [ "/render/setup-env" ] 53 | 54 | # 3. By default, we run the 'web' process type defined in the app's Procfile 55 | # You may override the process type that is run by replacing 'web' with another 56 | # process type name in the CMD line below. That process type must have been 57 | # defined in the app's Procfile during build. 58 | CMD [ "/render/process/web" ] 59 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv-safe/config' 2 | import { Telegraf, Scenes, session, Context } from 'telegraf' 3 | import { message } from 'telegraf/filters' 4 | import TelegrafI18n from 'telegraf-i18n' 5 | import TelegrafSessionLocal from 'telegraf-session-local' 6 | import { telegrafThrottler } from 'telegraf-throttler' 7 | 8 | import * as commands from './commands' 9 | import { createKeyboard } from './helpers/createKeyboard' 10 | import { PastvuItem } from './helpers/getPastvuPhotos' 11 | import { setGeoData } from './helpers/setGeoData' 12 | import i18n from './i18n' 13 | import { pastvu, settings } from './scenes' 14 | 15 | if (process.env.BOT_TOKEN === undefined) { 16 | throw new TypeError('BOT_TOKEN must be provided!') 17 | } 18 | interface BotSceneSession extends Scenes.SceneSessionData { 19 | pastvuData: PastvuItem[][] | undefined 20 | counterData: number 21 | } 22 | 23 | interface YearsRange { 24 | startYear: number 25 | endYear: number 26 | } 27 | 28 | interface DatabaseData extends YearsRange { 29 | history: YearsRange[] 30 | } 31 | 32 | export interface ContextBot extends Context { 33 | scene: Scenes.SceneContextScene 34 | data: DatabaseData 35 | i18n: TelegrafI18n 36 | geo: { 37 | latitude: number 38 | longitude: number 39 | } 40 | random: boolean 41 | } 42 | 43 | const bot = new Telegraf(process.env.BOT_TOKEN) 44 | 45 | const stage = new Scenes.Stage([pastvu, settings]) 46 | 47 | const localSession = new TelegrafSessionLocal({ 48 | database: 'db/settings.json', 49 | }) 50 | 51 | bot.use(localSession.middleware('data')) 52 | bot.use(session()) 53 | bot.use(i18n.middleware()) 54 | bot.use(stage.middleware()) 55 | bot.use( 56 | telegrafThrottler({ 57 | out: { 58 | minTime: 1000 / 1.2, // 1 message every ~0.83 seconds (to avoid hitting the 1-sec limit) 59 | reservoir: 5, // Allows sending up to 5 messages at once (short bursts) 60 | reservoirRefreshAmount: 1, // Restores 1 message per second 61 | reservoirRefreshInterval: 1000, // Refreshes the limit every second 62 | }, 63 | }), 64 | ) 65 | async function main() { 66 | bot.start((ctx: ContextBot) => { 67 | return ctx.reply(ctx.i18n.t('start'), createKeyboard(ctx)) 68 | }) 69 | 70 | bot.help(commands.help) 71 | 72 | bot.on(message('location'), (ctx: ContextBot) => 73 | ctx 74 | .reply(ctx.i18n.t('buttons.location'), createKeyboard(ctx)) 75 | .then(() => { 76 | if (ctx?.message && 'location' in ctx.message) { 77 | ctx.geo = ctx.message.location 78 | } 79 | }) 80 | .then(() => ctx.scene.enter('pastvu')), 81 | ) 82 | //we can't use telegraf-i18n/match because handler reacts only with currently selected language 83 | //https://github.com/telegraf/telegraf-i18n/issues/21#issuecomment-522180837 84 | bot.hears(new RegExp('⚙️'), (ctx: ContextBot) => { 85 | return ctx 86 | .reply(ctx.i18n.t('buttons.settings'), createKeyboard(ctx)) 87 | .then(() => ctx.scene.enter('settings')) 88 | }) 89 | bot.hears(new RegExp('🔍'), (ctx: ContextBot) => { 90 | return ctx 91 | .reply(ctx.i18n.t('buttons.morePhotos'), createKeyboard(ctx)) 92 | .then(() => { 93 | if (ctx?.message && 'location' in ctx.message) { 94 | ctx.geo = ctx.message.location 95 | } 96 | }) 97 | .then(() => ctx.scene.enter('pastvu')) 98 | }) 99 | 100 | bot.hears(new RegExp('🎲'), (ctx: ContextBot) => { 101 | return ctx 102 | .reply(ctx.i18n.t('buttons.randomPhotos'), createKeyboard(ctx)) 103 | .then(() => (ctx.random = true)) 104 | .then(() => ctx.scene.enter('pastvu')) 105 | }) 106 | 107 | bot.hears( 108 | new RegExp('https://maps.app.goo.gl|https://www.google.com/maps/place'), 109 | (ctx) => { 110 | return ctx 111 | .reply(ctx.i18n.t('buttons.location'), createKeyboard(ctx)) 112 | .then(() => setGeoData(ctx.message.text, ctx)) 113 | .then(() => ctx.scene.enter('pastvu')) 114 | .catch((err) => { 115 | if (err instanceof Error) { 116 | return ctx.reply(`${ctx.i18n.t('errors.error')} ${err.message}`) 117 | } 118 | return ctx.reply(ctx.i18n.t('errors.errorLetsTry')) 119 | }) 120 | }, 121 | ) 122 | 123 | await bot.launch({ 124 | webhook: process.env.WEBHOOK_URL 125 | ? { 126 | port: process.env.PORT ? Number(process.env.PORT) : 8080, 127 | domain: process.env.WEBHOOK_URL, 128 | } 129 | : undefined, 130 | }) 131 | } 132 | main().catch((err) => { 133 | throw err 134 | }) 135 | 136 | process.on('unhandledRejection', (err) => { 137 | throw err 138 | }) 139 | --------------------------------------------------------------------------------