├── webapp ├── .env ├── vite.config.js ├── src │ ├── main.jsx │ ├── App.jsx │ ├── App.css │ ├── MainPage.jsx │ ├── Calendar.jsx │ ├── index.css │ ├── api.js │ ├── Home.jsx │ └── assets │ │ └── react.svg ├── .gitignore ├── README.md ├── index.html ├── .eslintrc.cjs ├── package.json └── public │ └── vite.svg ├── .gitignore ├── .dev.vars.example ├── .git-blame-ignore-revs ├── drop.sql ├── .editorconfig ├── wrangler.toml ├── .prettierrc ├── package.json ├── messageProcessor.js ├── LICENSE ├── cryptoUtils.js ├── .github └── workflows │ ├── deploy.yml │ ├── deploy_pages.yml │ ├── deploy_worker.yml │ ├── prepare_db.yml │ ├── finalise.yml │ └── check.yml ├── messageSender.js ├── init.sql ├── telegram.js ├── db.js ├── README.md ├── index.js └── docs └── img ├── cf-accountId.svg └── cf-token.svg /webapp/.env: -------------------------------------------------------------------------------- 1 | VITE_BACKEND_URL=http://localhost:8787 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /node_modules 3 | *-lock.* 4 | *.lock 5 | *.log 6 | .dev.vars 7 | .wrangler 8 | -------------------------------------------------------------------------------- /.dev.vars.example: -------------------------------------------------------------------------------- 1 | TELEGRAM_BOT_TOKEN=0000000000:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 2 | TELEGRAM_USE_TEST_API=true 3 | FRONTEND_URL=https://aaaaaaaa-0000.euw.devtunnels.ms -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # .git-blame-ignore-revs 2 | # spaces -> tabs 3 | 6af45579f34b4ed48bc69b856ee6ec0ccbeb9740 4 | # CRLF -> LF 5 | 47d13893905c815ad5b8cd69f35588b16b745429 6 | -------------------------------------------------------------------------------- /webapp/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /drop.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS settings; 2 | DROP TABLE IF EXISTS messages; 3 | DROP TABLE IF EXISTS initDataCheck; 4 | DROP TABLE IF EXISTS tokens; 5 | DROP TABLE IF EXISTS calendars; 6 | DROP TABLE IF EXISTS users; -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | tab_width = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.yml] 13 | indent_style = space 14 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "telegram-calendar" 2 | main = "index.js" 3 | compatibility_date = "2023-10-07" 4 | 5 | [[d1_databases]] 6 | binding = "DB" # i.e. available in your Worker on env.DB 7 | database_name = "telegram-calendar" 8 | database_id = "4e1c28a9-90e4-41da-8b4b-6cf36e5abb29" -------------------------------------------------------------------------------- /webapp/src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.jsx' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /webapp/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /webapp/src/App.jsx: -------------------------------------------------------------------------------- 1 | 2 | import './App.css' 3 | import MainPage from './MainPage' 4 | import { 5 | QueryClient, 6 | QueryClientProvider, 7 | } from '@tanstack/react-query' 8 | 9 | function App() { 10 | 11 | const queryClient = new QueryClient(); 12 | return ( 13 | 14 | 15 | 16 | ) 17 | } 18 | 19 | export default App 20 | -------------------------------------------------------------------------------- /webapp/README.md: -------------------------------------------------------------------------------- 1 | # React + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | -------------------------------------------------------------------------------- /webapp/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "printWidth": 100, 4 | "singleQuote": true, 5 | "bracketSpacing": true, 6 | "insertPragma": false, 7 | "requirePragma": false, 8 | "jsxSingleQuote": false, 9 | "bracketSameLine": false, 10 | "embeddedLanguageFormatting": "auto", 11 | "htmlWhitespaceSensitivity": "css", 12 | "vueIndentScriptAndStyle": true, 13 | "quoteProps": "consistent", 14 | "proseWrap": "preserve", 15 | "trailingComma": "es5", 16 | "arrowParens": "avoid", 17 | "useTabs": true, 18 | "tabWidth": 2 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "template-worker-router", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "deploy": "wrangler deploy index.js", 7 | "dev": "wrangler dev index.js --local true", 8 | "dev:db:init": "wrangler d1 execute DB --file init.sql --local", 9 | "dev:db:drop": "wrangler d1 execute DB --file drop.sql --local" 10 | }, 11 | "dependencies": { 12 | "itty-router": "^2.6.1", 13 | "telegram-md": "^1.3.1" 14 | }, 15 | "devDependencies": { 16 | "wrangler": "^3.0.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /webapp/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:react/recommended', 7 | 'plugin:react/jsx-runtime', 8 | 'plugin:react-hooks/recommended', 9 | ], 10 | ignorePatterns: ['dist', '.eslintrc.cjs'], 11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 12 | settings: { react: { version: '18.2' } }, 13 | plugins: ['react-refresh'], 14 | rules: { 15 | 'react-refresh/only-export-components': [ 16 | 'warn', 17 | { allowConstantExport: true }, 18 | ], 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /messageProcessor.js: -------------------------------------------------------------------------------- 1 | import { MessageSender } from "./messageSender"; 2 | 3 | const processMessage = async (json, app) => { 4 | const { telegram, db } = app; 5 | 6 | const messageSender = new MessageSender(app, telegram); 7 | 8 | const chatId = json.message.chat.id; 9 | const replyToMessageId = json.message.message_id; 10 | 11 | const messageToSave = JSON.stringify(json, null, 2); 12 | await db.addMessage(messageToSave, json.update_id); 13 | 14 | if (json.message.text === '/start') { 15 | return await messageSender.sendGreeting(chatId, replyToMessageId); 16 | } 17 | 18 | return "Skipped message"; 19 | }; 20 | 21 | export { processMessage } -------------------------------------------------------------------------------- /webapp/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 0.5rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /webapp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webapp", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "dev:tunnel:init": "devtunnel init && devtunnel create && devtunnel port create -p 5173", 9 | "build": "vite build", 10 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@tanstack/react-query": "^4.36.1", 15 | "@vkruglikov/react-telegram-web-app": "^2.1.4", 16 | "date-fns": "^2.30.0", 17 | "react": "^18.2.0", 18 | "react-day-picker": "^8.8.2", 19 | "react-dom": "^18.2.0" 20 | }, 21 | "devDependencies": { 22 | "@types/react": "^18.2.15", 23 | "@types/react-dom": "^18.2.7", 24 | "@vitejs/plugin-react": "^4.0.3", 25 | "eslint": "^8.45.0", 26 | "eslint-plugin-react": "^7.32.2", 27 | "eslint-plugin-react-hooks": "^4.6.0", 28 | "eslint-plugin-react-refresh": "^0.4.3", 29 | "vite": "^4.4.5" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Konstantin Yakushev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cryptoUtils.js: -------------------------------------------------------------------------------- 1 | const sha256 = async (body) => { 2 | const enc = new TextEncoder(); 3 | const hashBuffer = await crypto.subtle.digest("SHA-256", enc.encode(body)); 4 | return new Uint8Array(hashBuffer); 5 | } 6 | 7 | const hmacSha256 = async (body, secret) => { 8 | // similar to https://stackoverflow.com/a/74428751/319229 9 | const enc = new TextEncoder(); 10 | const algorithm = { name: "HMAC", hash: "SHA-256" }; 11 | if (!(secret instanceof Uint8Array)) { 12 | secret = enc.encode(secret); 13 | } 14 | const key = await crypto.subtle.importKey( 15 | "raw", 16 | secret, 17 | algorithm, 18 | false, 19 | ["sign", "verify"] 20 | ); 21 | 22 | const signature = await crypto.subtle.sign( 23 | algorithm.name, 24 | key, 25 | enc.encode(body) 26 | ); 27 | 28 | return new Uint8Array(signature); 29 | } 30 | 31 | const hex = (buffer) => { 32 | const hashArray = Array.from(buffer); 33 | 34 | // convert bytes to hex string 35 | const digest = hashArray 36 | .map((b) => b.toString(16).padStart(2, "0")) 37 | .join(""); 38 | 39 | return digest; 40 | } 41 | 42 | const generateSecret = (bytes) => { 43 | return hex(crypto.getRandomValues(new Uint8Array(bytes))); 44 | } 45 | 46 | 47 | export { sha256, hmacSha256, hex, generateSecret } -------------------------------------------------------------------------------- /webapp/src/MainPage.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import 'react-day-picker/dist/style.css'; 3 | import { initMiniApp } from './api' 4 | import { useWebApp } from '@vkruglikov/react-telegram-web-app' 5 | import { useQuery } from '@tanstack/react-query' 6 | import { DayPicker } from 'react-day-picker'; 7 | import Calendar from './Calendar'; 8 | import Home from './Home'; 9 | 10 | function MainPage() { 11 | const { ready, initData, backgroundColor } = useWebApp() 12 | 13 | useEffect(() => { 14 | ready(); 15 | }); 16 | 17 | const initResult = useQuery({ 18 | queryKey: ['initData'], 19 | queryFn: async () => { 20 | const result = await initMiniApp(initData); 21 | return result; 22 | } 23 | }); 24 | 25 | const {token, startParam } = initResult?.data || {}; 26 | 27 | 28 | if (initResult.isLoading) { 29 | return ( 30 |
34 |
35 | 40 |
41 |
42 | ) 43 | } 44 | if (initResult.isError ) { 45 | return
Error! Try reloading the app
46 | } 47 | if (initResult?.data?.startPage === 'calendar') { 48 | return 49 | } 50 | return 51 | 52 | } 53 | 54 | export default MainPage 55 | -------------------------------------------------------------------------------- /webapp/src/Calendar.jsx: -------------------------------------------------------------------------------- 1 | import 'react-day-picker/dist/style.css'; 2 | import { getCalendarByRef } from './api' 3 | import { useQuery } from '@tanstack/react-query' 4 | import { DayPicker } from 'react-day-picker'; 5 | import { useWebApp } from '@vkruglikov/react-telegram-web-app'; 6 | import { useState } from 'react'; 7 | import { format } from 'date-fns'; 8 | 9 | function Calendar(params) { 10 | const { backgroundColor } = useWebApp(); 11 | const [selectedDates, setSelected] = useState(); 12 | const { token, apiRef } = params; 13 | const initResult = useQuery({ 14 | queryKey: ['calendar', apiRef], 15 | queryFn: async () => { 16 | const result = await getCalendarByRef(token, apiRef); 17 | return result; 18 | } 19 | }); 20 | let disabledMatcher = () => false; 21 | if(initResult.data) { 22 | disabledMatcher = date => { 23 | const dateStr = format(date, 'yyyy-MM-dd'); 24 | return !initResult.data.calendar.dates.includes(dateStr); 25 | } 26 | } 27 | 28 | return ( 29 |
33 |

Pick out of proposed dates

34 |
35 | 43 |
44 |
45 | ) 46 | } 47 | 48 | export default Calendar 49 | -------------------------------------------------------------------------------- /webapp/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | 9 | font-synthesis: none; 10 | text-rendering: optimizeLegibility; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | -webkit-text-size-adjust: 100%; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | button { 39 | border-radius: 8px; 40 | border: 1px solid transparent; 41 | padding: 0.6em 1.2em; 42 | font-size: 1em; 43 | font-weight: 500; 44 | font-family: inherit; 45 | background-color: #1a1a1a; 46 | cursor: pointer; 47 | transition: border-color 0.25s; 48 | } 49 | button:hover { 50 | border-color: #646cff; 51 | } 52 | button:focus, 53 | button:focus-visible { 54 | outline: 4px auto -webkit-focus-ring-color; 55 | } 56 | 57 | @media (prefers-color-scheme: light) { 58 | :root { 59 | color: #213547; 60 | background-color: #ffffff; 61 | } 62 | a:hover { 63 | color: #747bff; 64 | } 65 | button { 66 | background-color: #f9f9f9; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /webapp/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | jobs: 10 | precheck: 11 | name: Check things 12 | uses: ./.github/workflows/check.yml 13 | secrets: inherit 14 | 15 | prepare_db: 16 | name: Prepare database 17 | uses: ./.github/workflows/prepare_db.yml 18 | secrets: inherit 19 | needs: precheck 20 | with: 21 | d1_database_name: ${{ needs.precheck.outputs.d1_database_name }} 22 | d1_database_id: ${{ needs.precheck.outputs.d1_database_id }} 23 | create_database: ${{ needs.precheck.outputs.d1_apparent_database_id == 'null' }} 24 | 25 | deploy_pages: 26 | name: Deploy CloudFlare Pages 27 | uses: ./.github/workflows/deploy_pages.yml 28 | secrets: inherit 29 | needs: precheck 30 | with: 31 | pages_name: ${{ needs.precheck.outputs.worker_name }} 32 | worker_url: ${{ needs.precheck.outputs.worker_url }} 33 | 34 | deploy_worker: 35 | name: Deploy CloudFlare Worker 36 | uses: ./.github/workflows/deploy_worker.yml 37 | secrets: inherit 38 | needs: [precheck, prepare_db, deploy_pages] 39 | with: 40 | d1_database_id: ${{ needs.precheck.outputs.d1_database_id }} 41 | d1_apparent_database_id: ${{ needs.prepare_db.outputs.d1_apparent_database_id }} 42 | pages_url: ${{ needs.deploy_pages.outputs.pages_url }} 43 | worker_url: ${{ needs.precheck.outputs.worker_url }} 44 | 45 | finalise: 46 | name: Final output 47 | uses: ./.github/workflows/finalise.yml 48 | secrets: inherit 49 | needs: [deploy_worker, deploy_pages, prepare_db] 50 | -------------------------------------------------------------------------------- /webapp/src/api.js: -------------------------------------------------------------------------------- 1 | const initMiniApp = async (initData) => { 2 | const response = await fetch(import.meta.env.VITE_BACKEND_URL + '/miniApp/init', { 3 | method: 'POST', 4 | mode: 'cors', 5 | headers: { 6 | 'Content-Type': 'application/json', 7 | }, 8 | body: JSON.stringify( 9 | { initData: initData } 10 | ), 11 | }) 12 | if (!response.ok) { 13 | throw new Error(`Bot error: ${response.status} ${response.statusText}}`) 14 | } 15 | return response.json() 16 | } 17 | 18 | const getMe = async (token) => { 19 | const response = await fetch(import.meta.env.VITE_BACKEND_URL + '/miniApp/me', { 20 | method: 'GET', 21 | mode: 'cors', 22 | headers: { 23 | 'Content-Type': 'application/json', 24 | 'Authorization': `Bearer ${token}` 25 | }, 26 | }) 27 | if (!response.ok) { 28 | throw new Error(`Bot error: ${response.status} ${response.statusText}}`) 29 | } 30 | return response.json() 31 | } 32 | 33 | const getCalendarByRef = async (token, ref) => { 34 | const response = await fetch(import.meta.env.VITE_BACKEND_URL + `/miniApp/calendar/${ref}`, { 35 | method: 'GET', 36 | mode: 'cors', 37 | headers: { 38 | 'Content-Type': 'application/json', 39 | 'Authorization': `Bearer ${token}` 40 | }, 41 | }); 42 | if (!response.ok) { 43 | throw new Error(`Bot error: ${response.status} ${response.statusText}}`) 44 | } 45 | return response.json() 46 | } 47 | 48 | const sendDates = async(token, dates) => { 49 | const response = await fetch(import.meta.env.VITE_BACKEND_URL + '/miniApp/dates', { 50 | method: 'POST', 51 | mode: 'cors', 52 | headers: { 53 | 'Content-Type': 'application/json', 54 | 'Authorization': `Bearer ${token}` 55 | }, 56 | body: JSON.stringify( 57 | { dates: dates } 58 | ), 59 | }) 60 | if (!response.ok) { 61 | throw new Error(`Bot error: ${response.status} ${response.statusText}}`) 62 | } 63 | return response.json() 64 | } 65 | 66 | export { initMiniApp, getMe, sendDates, getCalendarByRef } -------------------------------------------------------------------------------- /.github/workflows/deploy_pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Cloudflare Pages 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | pages_name: 7 | description: 'Name to use for pages deployment' 8 | required: true 9 | type: string 10 | worker_url: 11 | description: 'URL of Worker deployment' 12 | required: true 13 | type: string 14 | outputs: 15 | pages_url: 16 | value: ${{ jobs.deploy.outputs.pages_url }} 17 | description: The URL of the Pages deployment 18 | 19 | jobs: 20 | deploy: 21 | name: Deploy Pages 22 | runs-on: ubuntu-latest 23 | outputs: 24 | pages_url: ${{ steps.get_pages_url.outputs.url }} 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v4 28 | 29 | - name: Install Cloudflare Workers CLI 30 | run: | 31 | npm install -g wrangler 32 | 33 | - name: Deploy webapp to Cloudflare Pages 34 | working-directory: ./webapp 35 | env: 36 | CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 37 | CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 38 | VITE_BACKEND_URL: ${{ inputs.worker_url }} 39 | run: | 40 | npm install 41 | npm run build 42 | wrangler pages project create ${{ inputs.pages_name }} --production-branch main || true 43 | wrangler pages deploy ./dist --project-name ${{ inputs.pages_name }} 44 | 45 | - name: Call CloudFlare API to find Pages URL 46 | id: get_pages_url 47 | env: 48 | CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 49 | CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 50 | run: | 51 | curl -X GET https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/pages/projects/${{ inputs.pages_name }} \ 52 | -H "Authorization: Bearer $CF_API_TOKEN" | jq -r '.result.subdomain' | echo url=https://$(cat) >> $GITHUB_OUTPUT -------------------------------------------------------------------------------- /messageSender.js: -------------------------------------------------------------------------------- 1 | import { md } from 'telegram-md'; 2 | 3 | class MessageSender { 4 | constructor(app, telegram) { 5 | this.botName = app.botName; 6 | this.telegram = telegram; 7 | } 8 | 9 | async sendMessage(chatId, text, reply_to_message_id) { 10 | return await this.telegram.sendMessage(chatId, text, 'MarkdownV2', reply_to_message_id); 11 | } 12 | 13 | async sendGreeting(chatId, replyToMessageId) { 14 | const message = 15 | md`Hello! 16 | 17 | ${md.bold("Group Meetup Facilitator")} helps you organize group meetups, e. g. in-person events or\ 18 | calls. Here's how it works: 19 | 20 | 1. Organizer accesses ${md.link("the calendar", `https://t.me/${this.botName}/calendar`)} \ 21 | to set options for when the group can meet 22 | 2. Organizer receives a link to share with the group 23 | 3. Group members vote for the options that work for them 24 | 4. Organizer receives a summary of the votes and can pick the best option 25 | 26 | And that's it! 27 | 28 | Go to ${md.link("the calendar", `https://t.me/${this.botName}/calendar`)} to get started`; 29 | 30 | return await this.sendMessage(chatId, md.build(message), replyToMessageId); 31 | } 32 | 33 | async sendCalendarLink(chatId, userName, calendarRef) { 34 | const message = 35 | md`Thanks! 36 | 37 | You calendar is submitted and is ready to share. Feel free to share the next message \ 38 | or just copy the link from it.`; 39 | 40 | await this.sendMessage(chatId, md.build(message)); 41 | 42 | const linkMessage = 43 | md`${userName} uses ${md.bold("Group Meetup Facilitator")} to organize a group meetup! 44 | 45 | Please click on the link below to vote for the dates that work for you. You can vote for multiple dates: 46 | 47 | ${md.link(`https://t.me/${this.botName}/calendar?startapp=${calendarRef}`, `https://t.me/${this.botName}/calendar?startapp=${calendarRef}`)}`; 48 | 49 | return await this.sendMessage(chatId, md.build(linkMessage)); 50 | } 51 | } 52 | 53 | export { MessageSender }; -------------------------------------------------------------------------------- /webapp/src/Home.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import 'react-day-picker/dist/style.css'; 3 | import { sendDates } from './api' 4 | import { MainButton, useWebApp } from '@vkruglikov/react-telegram-web-app' 5 | import { useMutation } from '@tanstack/react-query' 6 | import { format } from 'date-fns'; 7 | import { DayPicker } from 'react-day-picker'; 8 | 9 | function Home(props) { 10 | const { token } = props; 11 | const { backgroundColor } = useWebApp() 12 | 13 | let sendingError = false; 14 | // send selected dates to backend: 15 | const dateMutation = useMutation({ 16 | mutationKey: ['sendDate', token], 17 | mutationFn: async (dates) => { 18 | const result = await sendDates(token, dates.map(date => format(date, 'yyyy-MM-dd'))); 19 | return result; 20 | }, 21 | onSuccess: () => { 22 | window.Telegram.WebApp.close(); 23 | }, 24 | onError: () => { 25 | sendingError = true; 26 | }, 27 | }); 28 | 29 | const [selectedDates, setSelected] = useState(); 30 | 31 | let footer =

Please pick the days you propose for the meetup.

; 32 | let mainButton = ""; 33 | 34 | if (selectedDates) { 35 | footer = ( 36 |

37 | You picked {selectedDates.length}{' '} 38 | {selectedDates.length > 1 ? 'dates' : 'date'}: {' '} 39 | {selectedDates.map((date, index) => ( 40 | 41 | {index ? ', ' : ''} 42 | {format(date, 'PP')} 43 | 44 | ))} 45 |

46 | ); 47 | mainButton = { dateMutation.mutate(selectedDates) }} />; 48 | } 49 | 50 | if (sendingError) { 51 | return
Error! Please close the window and try creating the calendar again
52 | } 53 | 54 | return ( 55 |
59 |

Pick proposed dates

60 |
61 | 71 |
72 | {mainButton} 73 |
74 | ) 75 | } 76 | 77 | export default Home 78 | -------------------------------------------------------------------------------- /init.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS settings ( 2 | name text PRIMARY KEY, 3 | createdDate text NOT NULL, 4 | updatedDate text NOT NULL, 5 | value text NOT NULL 6 | ); 7 | 8 | CREATE TABLE IF NOT EXISTS messages ( 9 | id integer PRIMARY KEY AUTOINCREMENT, 10 | createdDate text NOT NULL, 11 | updatedDate text NOT NULL, 12 | message text NOT NULL, 13 | updateId text NOT NULL 14 | ); 15 | 16 | CREATE TABLE IF NOT EXISTS initDataCheck ( 17 | id integer PRIMARY KEY AUTOINCREMENT, 18 | createdDate text NOT NULL, 19 | updatedDate text NOT NULL, 20 | initData text NOT NULL, 21 | expectedHash text NOT NULL, 22 | calculatedHash text NOT NULL 23 | ); 24 | 25 | CREATE TABLE IF NOT EXISTS users ( 26 | id integer PRIMARY KEY AUTOINCREMENT, 27 | createdDate text NOT NULL, 28 | updatedDate text NOT NULL, 29 | lastAuthTimestamp text NOT NULL, 30 | telegramId integer UNIQUE NOT NULL, 31 | username text, 32 | isBot integer, 33 | firstName text, 34 | lastName text, 35 | languageCode text, 36 | isPremium integer, 37 | addedToAttachmentMenu integer, 38 | allowsWriteToPm integer, 39 | photoUrl text 40 | ); 41 | 42 | CREATE TABLE IF NOT EXISTS tokens ( 43 | id integer PRIMARY KEY AUTOINCREMENT, 44 | createdDate text NOT NULL, 45 | updatedDate text NOT NULL, 46 | expiredDate text NOT NULL, 47 | tokenHash text UNIQUE NOT NULL, 48 | userId integer NOT NULL, 49 | FOREIGN KEY(userId) REFERENCES users(id) 50 | ); 51 | 52 | CREATE TABLE IF NOT EXISTS calendars ( 53 | id integer PRIMARY KEY AUTOINCREMENT, 54 | createdDate text NOT NULL, 55 | updatedDate text NOT NULL, 56 | userId integer NOT NULL, 57 | calendarJson text NOT NULL, 58 | calendarRef text NOT NULL, 59 | FOREIGN KEY(userId) REFERENCES users(id) 60 | ); 61 | 62 | CREATE TABLE IF NOT EXISTS selectedDates ( 63 | id integer PRIMARY KEY AUTOINCREMENT, 64 | createdDate text NOT NULL, 65 | updatedDate text NOT NULL, 66 | userId integer NOT NULL, 67 | calendarId integer NOT NULL, 68 | selectedDatesJson text NOT NULL, 69 | FOREIGN KEY(userId) REFERENCES users(id), 70 | FOREIGN KEY(calendarId) REFERENCES calendars(id) 71 | ); 72 | 73 | CREATE UNIQUE INDEX IF NOT EXISTS userSelectedDatesIndex ON selectedDates (userId, calendarId); 74 | 75 | CREATE UNIQUE INDEX IF NOT EXISTS tokenHashIndex ON tokens (tokenHash); 76 | CREATE UNIQUE INDEX IF NOT EXISTS telegramIdIndex ON users (telegramId); -------------------------------------------------------------------------------- /.github/workflows/deploy_worker.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Cloudflare Worker 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | d1_database_id: 7 | description: 'Database id in toml' 8 | required: true 9 | type: string 10 | d1_apparent_database_id: 11 | description: 'Database id to actually use' 12 | required: true 13 | type: string 14 | pages_url: 15 | description: 'URL of frontend deployment in pages for CORS' 16 | required: true 17 | type: string 18 | worker_url: 19 | description: 'URL of worker deployment' 20 | required: true 21 | type: string 22 | 23 | 24 | jobs: 25 | deploy: 26 | name: Deploy Cloudflare Worker 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout code 30 | uses: actions/checkout@v4 31 | 32 | - name: Install Cloudflare Workers CLI 33 | run: | 34 | npm install -g wrangler 35 | 36 | - name: Install dependencies 37 | run: npm install --production 38 | 39 | - name: Replace database id in wrangler.toml 40 | run: | 41 | sed -i -e 's/${{ inputs.d1_database_id }}/${{ inputs.d1_apparent_database_id }}/g' wrangler.toml 42 | cat wrangler.toml 43 | 44 | - name: Build and deploy worker 45 | id: deploy_worker 46 | env: 47 | CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 48 | CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 49 | run: | 50 | wrangler deploy --var FRONTEND_URL:${{ inputs.pages_url }} 51 | echo "${{ secrets.TELEGRAM_BOT_TOKEN }}" | wrangler secret put TELEGRAM_BOT_TOKEN 52 | INIT_SECRET=$(openssl rand -hex 24) 53 | echo $INIT_SECRET | wrangler secret put INIT_SECRET 54 | echo init_secret=$INIT_SECRET >> $GITHUB_OUTPUT 55 | 56 | - name: Send init command to worker 57 | run: | 58 | curl -X POST -H "Authorization: Bearer ${{ steps.deploy_worker.outputs.init_secret }}" \ 59 | -H "Content-Type: application/json" \ 60 | -d '{"externalUrl": "${{ inputs.worker_url }}"}' \ 61 | --max-time 10 \ 62 | --retry 5 \ 63 | --retry-delay 0 \ 64 | --retry-max-time 40 \ 65 | --retry-all-errors \ 66 | --fail \ 67 | ${{ inputs.worker_url }}/init 68 | -------------------------------------------------------------------------------- /.github/workflows/prepare_db.yml: -------------------------------------------------------------------------------- 1 | name: Deploy and initialise D1 Database 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | d1_database_name: 7 | description: 'Database name to use for creation' 8 | required: true 9 | type: string 10 | d1_database_id: 11 | description: 'Database id from wrangler.toml file' 12 | required: false 13 | type: string 14 | workflow_call: 15 | inputs: 16 | d1_database_name: 17 | description: 'Database name to use for creation' 18 | required: true 19 | type: string 20 | d1_database_id: 21 | description: 'Database id from wrangler.toml file' 22 | required: true 23 | type: string 24 | create_database: 25 | description: 'Whether to create the database or not' 26 | required: true 27 | type: boolean 28 | outputs: 29 | d1_apparent_database_id: 30 | value: ${{ jobs.deploy.outputs.database_apparent_id }} 31 | description: The id of the CloudFlare database as determined by its name 32 | 33 | 34 | jobs: 35 | deploy: 36 | name: Deploy and initialise D1 Database 37 | runs-on: ubuntu-latest 38 | outputs: 39 | database_apparent_id: ${{ steps.get_db_id_by_name.outputs.uuid }} 40 | steps: 41 | - name: Checkout code 42 | uses: actions/checkout@v4 43 | 44 | - name: Install Cloudflare Workers CLI and tomlq 45 | run: | 46 | npm install -g wrangler 47 | pip install yq 48 | 49 | - name: Create the D1 database 50 | env: 51 | CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 52 | CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 53 | if: ${{ inputs.create_database }} 54 | run: | 55 | wrangler d1 create ${{ inputs.d1_database_name }} 56 | 57 | - name: Call CloudFlare API to find the database id by name 58 | id: get_db_id_by_name 59 | env: 60 | CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 61 | CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 62 | run: | 63 | curl -X GET "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/d1/database?name=${{ inputs.d1_database_name }}" \ 64 | -H "Authorization: Bearer $CF_API_TOKEN" | jq -r '.result[0].uuid' | echo uuid=$(cat) >> $GITHUB_OUTPUT 65 | 66 | - name: Replace database id in wrangler.toml 67 | run: | 68 | sed -i -e 's/${{ inputs.d1_database_id }}/${{ steps.get_db_id_by_name.outputs.uuid }}/g' wrangler.toml 69 | cat wrangler.toml 70 | 71 | - name: Initialize the database 72 | env: 73 | CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 74 | CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 75 | run: | 76 | wrangler d1 execute DB --file init.sql 77 | -------------------------------------------------------------------------------- /telegram.js: -------------------------------------------------------------------------------- 1 | import { hmacSha256, hex } from './cryptoUtils.js'; 2 | const TELEGRAM_API_BASE_URL = 'https://api.telegram.org/bot'; 3 | 4 | class TelegramAPI { 5 | constructor(token, useTestApi = false) { 6 | this.token = token; 7 | let testApiAddendum = useTestApi ? 'test/' : ''; 8 | this.apiBaseUrl = `${TELEGRAM_API_BASE_URL}${token}/${testApiAddendum}`; 9 | } 10 | 11 | async calculateHashes(initData) { 12 | const urlParams = new URLSearchParams(initData); 13 | 14 | const expectedHash = urlParams.get("hash"); 15 | urlParams.delete("hash"); 16 | urlParams.sort(); 17 | 18 | let dataCheckString = ""; 19 | 20 | for (const [key, value] of urlParams.entries()) { 21 | dataCheckString += `${key}=${value}\n`; 22 | } 23 | 24 | dataCheckString = dataCheckString.slice(0, -1); 25 | let data = Object.fromEntries(urlParams); 26 | data.user = JSON.parse(data.user||null); 27 | data.receiver = JSON.parse(data.receiver||null); 28 | data.chat = JSON.parse(data.chat||null); 29 | 30 | const secretKey = await hmacSha256(this.token, "WebAppData"); 31 | const calculatedHash = hex(await hmacSha256(dataCheckString, secretKey)); 32 | 33 | return {expectedHash, calculatedHash, data}; 34 | } 35 | 36 | async getUpdates(lastUpdateId) { 37 | const url = `${this.apiBaseUrl}getUpdates`; 38 | const params = {}; 39 | if (lastUpdateId) { 40 | params.offset = lastUpdateId + 1; 41 | } 42 | 43 | const response = await fetch(url, { 44 | method: 'POST', 45 | headers: { 46 | 'Content-Type': 'application/json' 47 | }, 48 | body: JSON.stringify(params), 49 | }); 50 | return response.json(); 51 | } 52 | 53 | async sendMessage(chatId, text, parse_mode, reply_to_message_id) { 54 | const url = `${this.apiBaseUrl}sendMessage`; 55 | const params = { 56 | chat_id: chatId, 57 | text: text, 58 | }; 59 | if (parse_mode) { 60 | params.parse_mode = parse_mode; 61 | } 62 | if (reply_to_message_id) { 63 | params.reply_to_message_id = reply_to_message_id; 64 | } 65 | const response = await fetch(url, { 66 | method: 'POST', 67 | headers: { 68 | 'Content-Type': 'application/json' 69 | }, 70 | body: JSON.stringify(params) 71 | }); 72 | return response.json(); 73 | } 74 | 75 | async setWebhook(externalUrl, secretToken) { 76 | const params = { 77 | url: externalUrl, 78 | }; 79 | if (secretToken) { 80 | params.secret_token = secretToken; 81 | } 82 | const url = `${this.apiBaseUrl}setWebhook`; 83 | const response = await fetch(url, { 84 | method: 'POST', 85 | headers: { 86 | 'Content-Type': 'application/json' 87 | }, 88 | body: JSON.stringify(params) 89 | }); 90 | return response.json(); 91 | } 92 | 93 | async getMe() { 94 | const url = `${this.apiBaseUrl}getMe`; 95 | const response = await fetch(url, { 96 | method: 'GET', 97 | headers: { 98 | 'Content-Type': 'application/json' 99 | } 100 | }); 101 | return response.json(); 102 | } 103 | } 104 | 105 | export { TelegramAPI as Telegram } -------------------------------------------------------------------------------- /webapp/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /db.js: -------------------------------------------------------------------------------- 1 | class Database { 2 | constructor(databaseConnection) { 3 | this.db = databaseConnection; 4 | } 5 | 6 | async getSetting(settingName) { 7 | return await this.db.prepare("SELECT value FROM settings WHERE name = ?") 8 | .bind(settingName) 9 | .first('value'); 10 | } 11 | 12 | async getLatestUpdateId() { 13 | let result = await this.db.prepare("SELECT updateId FROM messages ORDER BY updateId DESC LIMIT 1") 14 | .first('updateId'); 15 | 16 | return Number(result); 17 | } 18 | 19 | async setSetting(settingName, settingValue) { 20 | return await this.db.prepare( 21 | `INSERT 22 | INTO settings (createdDate, updatedDate, name, value) 23 | VALUES (DATETIME('now'), DATETIME('now'), ?, ?) 24 | ON CONFLICT(name) DO UPDATE SET 25 | updatedDate = DATETIME('now'), 26 | value = excluded.value 27 | WHERE excluded.value <> settings.value` 28 | ) 29 | .bind(settingName, settingValue) 30 | .run(); 31 | } 32 | 33 | async addMessage(message, updateId) { 34 | return await this.db.prepare( 35 | `INSERT 36 | INTO messages (createdDate, updatedDate, message, updateId) 37 | VALUES (DATETIME('now'), DATETIME('now'), ?, ?)` 38 | ) 39 | .bind(message, updateId) 40 | .run(); 41 | } 42 | 43 | async getUser(telegramId) { 44 | return await this.db.prepare("SELECT * FROM users WHERE telegramId = ?") 45 | .bind(telegramId) 46 | .first(); 47 | } 48 | 49 | async saveUser(user, authTimestamp) { 50 | console.log(user); 51 | console.log(authTimestamp); 52 | // the following is an upsert, see https://sqlite.org/lang_upsert.html for more info 53 | return await this.db.prepare( 54 | `INSERT 55 | INTO users (createdDate, updatedDate, lastAuthTimestamp, 56 | telegramId, isBot, firstName, lastName, username, languageCode, 57 | isPremium, addedToAttachmentMenu, allowsWriteToPm, photoUrl 58 | ) 59 | VALUES (DATETIME('now'), DATETIME('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 60 | ON CONFLICT(telegramId) DO UPDATE SET 61 | updatedDate = DATETIME('now'), 62 | lastAuthTimestamp = COALESCE(excluded.lastAuthTimestamp, lastAuthTimestamp), 63 | isBot = COALESCE(excluded.isBot, isBot), 64 | firstName = excluded.firstName, 65 | lastName = excluded.lastName, 66 | username = excluded.username, 67 | languageCode = COALESCE(excluded.languageCode, languageCode), 68 | isPremium = COALESCE(excluded.isPremium, isPremium), 69 | addedToAttachmentMenu = COALESCE(excluded.addedToAttachmentMenu, addedToAttachmentMenu), 70 | allowsWriteToPm = COALESCE(excluded.allowsWriteToPm, allowsWriteToPm), 71 | photoUrl = COALESCE(excluded.photoUrl, photoUrl) 72 | WHERE excluded.lastAuthTimestamp > users.lastAuthTimestamp` 73 | ) 74 | .bind(authTimestamp, 75 | user.id, +user.is_bot, user.first_name||null, user.last_name||null, user.username||null, user.language_code||null, 76 | +user.is_premium, +user.added_to_attachment_menu, +user.allows_write_to_pm, user.photo_url||null 77 | ) 78 | .run(); 79 | } 80 | 81 | async saveToken(telegramId, tokenHash) { 82 | const user = await this.getUser(telegramId); 83 | console.log(user.id, tokenHash); 84 | return await this.db.prepare( 85 | `INSERT 86 | INTO tokens (createdDate, updatedDate, expiredDate, userId, tokenHash) 87 | VALUES (DATETIME('now'), DATETIME('now'), DATETIME('now', '+1 day'), ?, ?)` 88 | ) 89 | .bind(user.id, tokenHash) 90 | .run(); 91 | } 92 | 93 | async getUserByTokenHash(tokenHash) { 94 | return await this.db.prepare( 95 | `SELECT users.* FROM tokens 96 | INNER JOIN users ON tokens.userId = users.id 97 | WHERE tokenHash = ? AND DATETIME('now') < expiredDate` 98 | ) 99 | .bind(tokenHash) 100 | .first(); 101 | } 102 | 103 | async saveCalendar(calendarJson, calendarRef, userId) { 104 | return await this.db.prepare( 105 | `INSERT 106 | INTO calendars (createdDate, updatedDate, calendarJson, calendarRef, userId) 107 | VALUES (DATETIME('now'), DATETIME('now'), ?, ?, ?)` 108 | ) 109 | .bind(calendarJson, calendarRef, userId) 110 | .run(); 111 | } 112 | 113 | async getCalendarByRef(calendarRef) { 114 | return await this.db.prepare( 115 | `SELECT calendarJson FROM calendars 116 | WHERE calendarRef = ?` 117 | ) 118 | .bind(calendarRef) 119 | .first('calendarJson'); 120 | } 121 | } 122 | 123 | export { Database } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scaffolding for Telegram Bot with Mini App running in CloudFlare 2 | 3 | ## Introduction 4 | 5 | This package is batteries-included package for running [Telegram Bots](https://core.telegram.org/bots) and [Telegram Mini Apps](https://core.telegram.org/bots/webapps) on [CloudFlare Workers Platform](https://workers.cloudflare.com/). 6 | 7 | 🚅 Fork to running Telegram bot with MiniApp in 5 minutes. 8 | 9 | Uses: 10 | 11 | * 🔧 Cloudflare Workers for running backend 12 | * 📊 Cloudflare D1 for storing data and querying with SQL 13 | * ⚛️ React built with Vite for frontend 14 | * ✅ GitHub Actions and Wrangler for local running and deployment 15 | 16 | ## Deploying 17 | 18 | The solution is fully deployable with GitHub Actions included with the repo. All you need to do is create a Cloudflare account and a Telegram Bot. 19 | 20 | Fork the repository, then go to Settings > Secrets and add the following secrets: 21 | 22 | * `CF_API_TOKEN` - Cloudflare API token with permissions to create Workers, D1 databases and Pages 23 | * `CF_ACCOUNT_ID` - Cloudflare account ID 24 | * `TELEGRAM_BOT_TOKEN` - Telegram Bot token 25 | 26 | ### Getting the values for secrets 27 | 28 | ![Getting account ID](./docs/img/cf-accountId.svg) 29 | 30 | Go to [CloudFlare Workers Page](https://dash.cloudflare.com/?to=/:account/workers) and copy the account id from the right sidebar. Note that if you have no workers yet, you'll need to create a worker before you can see the account id. Luckily, there's a button for a "Hello World" worker right there. Once you've gotten the account id, set it in `CF_ACCOUNT_ID` secret. 31 | 32 | While you're in that interface, you can also adjust the subdomain to your liking. 33 | 34 | ![Creating a token](./docs/img/cf-token.svg) 35 | 36 | Go to [CloudFlare Dashboard](https://dash.cloudflare.com/profile/api-tokens) and create a token with the following permissions: 37 | 38 | * `Account:Account Settings:Read` 39 | * `Account:CloudFlare Pages:Edit` 40 | * `Account:D1:Edit` 41 | * `Account:User Details:Read` 42 | * `Account:Workers Scripts:Edit` 43 | * `User:Memberships:Read` 44 | * `Zone:Workers Routes:Edit` 45 | 46 | Once you've generated the token, set it in `CF_API_TOKEN` secret. 47 | 48 | For getting a telegram token, go to [@BotFather](https://t.me/BotFather) and create a new bot with the `/newbot` command. Once you've created the bot, copy the token and set it in `TELEGRAM_BOT_TOKEN` secret. 49 | 50 | ### Running the GitHub Actions workflow 51 | 52 | After you've set up the tokens go to Actions, accept the security prompt and run the `Deploy` workflow. After the workflow is finished, the bot will be ready to go. The workflow will give you the URL to use for your mini-app. 53 | 54 | ### Setting up the mini-app 55 | 56 | To add the mini-app you'll need to go back to [@BotFather](https://t.me/BotFather) and send the `/newapp` command. For some reason, an image is mandatory on this step, you can use a [placeholder](https://placehold.co/640x360) in the beginning. 57 | 58 | Also, it's recommended that you update the `wrangler.toml` file with your own database ID as instructed by the workflow. 59 | 60 | ## Running locally 61 | 62 | To run the bot locally, you'll need to have [Node.js](https://nodejs.org/en/download/) and [Microsoft DevTunnel](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started?tabs=windows). 63 | 64 | Then you need to do three things: 65 | 66 | * Run a local Worker server 67 | * Run a local React server 68 | * Run a tunnel for the React server so that it can be used as a Telegram Mini App 69 | 70 | ### React 71 | 72 | For running a react server: 73 | 74 | ```bash 75 | cd webapp 76 | npm install 77 | npm run dev 78 | ``` 79 | 80 | Remember the port it uses, I'll assume it's 5173. 81 | 82 | ### Tunnel 83 | 84 | In a different terminal, set up the tunnel: 85 | 86 | ```bash 87 | devtunnel user login 88 | devtunnel create # note the tunnel ID 89 | devtunnel port create -p 5173 90 | ``` 91 | 92 | Then you can start the tunnel as needed with `devtunnel host --allow-anonymous`. 93 | 94 | For the mini-app setup you need the URL that uses 443 port, it will look something like `https://aaaaaaaa-5173.euw.devtunnels.ms`. 95 | 96 | This approach allows you to keep the name of the tunnel the same, so you don't need to update the bot every time you restart the tunnel. 97 | 98 | ### Worker 99 | 100 | Make sure that the you've deployed at least once and set your own database id in the `wrangler.toml` file. 101 | 102 | Then create a `.dev.vars` file using `.dev.vars.example` as a template and fill in the values. If you set `TELEGRAM_USE_TEST_API` to true you'll be able to use the bot in the [Telegram test environment](https://core.telegram.org/bots/webapps#testing-mini-apps), otherwise you'll be connected to production. Keep in mind that tokens between the environments are different. 103 | 104 | Do an `npm install` and initialize the database with `npx wrangler d1 execute DB --file .\init.sql --local`. 105 | 106 | Now you are ready to run the worker with `npx wrangler dev`. The worker will be waiting for you at . 107 | 108 | ### Processing telegram messages when running locally 109 | 110 | When you are running locally, the worker is intentionally not set up to process messages automatically. Every time you feel your code is ready, you can open to process one more batch of messages. 111 | 112 | If you do want to process messages automatically in local environment, you can write a trivial script to poke this URL along the lines of: 113 | 114 | ```bash 115 | while true; do 116 | curl http://localhost:8787/updateTelegramMessages 117 | sleep 3 118 | done 119 | ``` 120 | 121 | ## Code information 122 | 123 | The backend code is a CloudFlare Worker. Start with `index.js` to get a general idea of how it works. 124 | 125 | We export `telegram.js` for working with telegram, `db.js` for working with the database and `cryptoUtils.js` for cryptography. 126 | 127 | There are no dependencies except for `itty-router`, which makes the whole affair blazing fast. 128 | 129 | For database we use CloudFlare D1, which is a version of SQLite. We initialize it with `init.sql` file. 130 | 131 | The frontend code is a React app built with Vite. The entry point is `webapp/src/main.jsx`. This is mostly a standard React app, except it uses excellent [@vkruglikov/react-telegram-web-app](https://github.com/vkruglikov/react-telegram-web-app) to wrap around the telegram mini app API. 132 | 133 | The frontend code can be replaced with anything that can be served as a static website. The only requirement is that the built code after `npm run build` is in the `webapp/dist` folder. 134 | 135 | ## Security features 136 | 137 | All the needed checks are done: 138 | 139 | * The bot checks the signatures of the webhook requests 140 | * The bot checks the signatures of the Mini-app requests and validates the user 141 | * The bot checks the token of initialization request sent during deployment 142 | * CORS between the frontend and the backend is locked down to specifically used domains 143 | 144 | ## Sample bot 145 | 146 | You can try out the bot at [@group_meetup_bot](https://t.me/group_meetup_bot). 147 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'itty-router'; 2 | import { Telegram } from './telegram'; 3 | import { Database } from './db'; 4 | import { processMessage } from './messageProcessor'; 5 | import { MessageSender } from './messageSender'; 6 | import { generateSecret, sha256 } from './cryptoUtils'; 7 | 8 | // Create a new router 9 | const router = Router(); 10 | const handle = async (request, env, ctx) => { 11 | let telegram = new Telegram(env.TELEGRAM_BOT_TOKEN, env.TELEGRAM_USE_TEST_API); 12 | let db = new Database(env.DB); 13 | let corsHeaders = { 14 | 'Access-Control-Allow-Origin': env.FRONTEND_URL, 15 | 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 16 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 17 | 'Access-Control-Max-Age': '86400', 18 | }; 19 | let isLocalhost = request.headers.get('Host').match(/^(localhost|127\.0\.0\.1)/); 20 | let botName = await db.getSetting("bot_name"); 21 | if (!botName) { 22 | let me = await telegram.getMe(); 23 | botName = me.result.username; 24 | await db.setSetting("bot_name", botName); 25 | } 26 | 27 | let app = {telegram, db, corsHeaders, isLocalhost, botName}; 28 | 29 | return await router.handle(request, app, env, ctx); 30 | } 31 | 32 | router.get('/', () => { 33 | return new Response('This telegram bot is deployed correctly. No user-serviceable parts inside.', { status: 200 }); 34 | }); 35 | 36 | router.post('/miniApp/init', async (request, app) => { 37 | const {telegram, db} = app; 38 | let json = await request.json(); 39 | let initData = json.initData; 40 | 41 | let {expectedHash, calculatedHash, data} = await telegram.calculateHashes(initData); 42 | 43 | if(expectedHash !== calculatedHash) { 44 | return new Response('Unauthorized', { status: 401, headers: {...app.corsHeaders } }); 45 | } 46 | 47 | const currentTime = Math.floor(Date.now() / 1000); 48 | let stalenessSeconds = currentTime - data.auth_date; 49 | if (stalenessSeconds > 600) { 50 | return new Response('Stale data, please restart the app', { status: 400, headers: {...app.corsHeaders } }); 51 | } 52 | 53 | // Hashes match, the data is fresh enough, we can be fairly sure that the user is who they say they are 54 | // Let's save the user to the database and return a token 55 | 56 | await db.saveUser(data.user, data.auth_date); 57 | let token = generateSecret(16); 58 | const tokenHash = await sha256(token); 59 | await db.saveToken(data.user.id, tokenHash); 60 | 61 | return new Response(JSON.stringify( 62 | { 63 | 'token': token, 64 | 'startParam': data.start_param, 65 | 'startPage': data.start_param? 'calendar' : 'home', 66 | 'user': await db.getUser(data.user.id) 67 | }), 68 | { status: 200, headers: {...app.corsHeaders }}); 69 | }); 70 | 71 | router.get('/miniApp/me', async (request, app) => { 72 | const {db} = app; 73 | 74 | let suppliedToken = request.headers.get('Authorization').replace('Bearer ', ''); 75 | const tokenHash = await sha256(suppliedToken); 76 | let user = await db.getUserByTokenHash(tokenHash); 77 | 78 | if (user === null) { 79 | return new Response('Unauthorized', { status: 401 }); 80 | } 81 | 82 | return new Response(JSON.stringify( 83 | {user: user}), 84 | { status: 200, headers: {...app.corsHeaders }}); 85 | }); 86 | 87 | router.get('/miniApp/calendar/:ref', async (request, app) => { 88 | const {db} = app; 89 | 90 | let ref = request.params.ref; 91 | let calendar = await db.getCalendarByRef(ref); 92 | 93 | if (calendar === null) { 94 | return new Response('Not found', { status: 404 }); 95 | } 96 | 97 | return new Response(JSON.stringify( 98 | {calendar: JSON.parse(calendar)}), 99 | { status: 200, headers: {...app.corsHeaders }}); 100 | }); 101 | 102 | router.post('/miniApp/dates', async (request, app) => { 103 | const {db, telegram, botName} = app; 104 | 105 | let suppliedToken = request.headers.get('Authorization').replace('Bearer ', ''); 106 | const tokenHash = await sha256(suppliedToken); 107 | let user = await db.getUserByTokenHash(tokenHash); 108 | 109 | if (user === null) { 110 | return new Response('Unauthorized', { status: 401 }); 111 | } 112 | 113 | let ref = generateSecret(8); 114 | let json = await request.json(); 115 | // check that all dates are yyyy-mm-dd and that there are no more than 100 dates 116 | let dates = json.dates; 117 | if (dates.length > 100) { return new Response('Too many dates', { status: 400 }); } 118 | for (const date of dates) { 119 | if (!date.match(/^\d{4}-\d{2}-\d{2}$/)) { return new Response('Invalid date', { status: 400 }); } 120 | } 121 | 122 | console.log(json.dates); 123 | let jsonToSave = JSON.stringify({dates: json.dates}); 124 | await db.saveCalendar(jsonToSave, ref, user.id); 125 | 126 | let messageSender = new MessageSender(app, telegram); 127 | await messageSender.sendCalendarLink(user.telegramId, user.firstName, ref); 128 | 129 | return new Response(JSON.stringify( 130 | {user: user}), 131 | { status: 200, headers: {...app.corsHeaders }}); 132 | }); 133 | 134 | router.post('/telegramMessage', async (request, app) => { 135 | 136 | const {db} = app; 137 | const telegramProvidedToken = request.headers.get('X-Telegram-Bot-Api-Secret-Token'); 138 | const savedToken = await db.getSetting("telegram_security_code"); 139 | 140 | if (telegramProvidedToken !== savedToken) { 141 | return new Response('Unauthorized', { status: 401 }); 142 | } 143 | 144 | let messageJson = await request.json(); 145 | await processMessage(messageJson, app); 146 | 147 | return new Response('Success', { status: 200 }); 148 | }); 149 | 150 | router.get('/updateTelegramMessages', async (request, app, env) => { 151 | if(!app.isLocalhost) { 152 | return new Response('This request is only supposed to be used locally', { status: 403 }); 153 | } 154 | 155 | const {telegram, db} = app; 156 | let lastUpdateId = await db.getLatestUpdateId(); 157 | let updates = await telegram.getUpdates(lastUpdateId); 158 | let results = []; 159 | for (const update of updates.result) { 160 | let result = await processMessage(update, app); 161 | results.push(result); 162 | } 163 | 164 | return new Response(`Success! 165 | Last update id: 166 | ${lastUpdateId}\n\n 167 | Updates: 168 | ${JSON.stringify(updates, null, 2)}\n\n 169 | Results: 170 | ${JSON.stringify(results, null, 2)}`, { status: 200 }); 171 | }); 172 | 173 | router.post('/init', async (request, app, env) => { 174 | if(request.headers.get('Authorization') !== `Bearer ${env.INIT_SECRET}`) { 175 | return new Response('Unauthorized', { status: 401 }); 176 | } 177 | 178 | const {telegram, db, botName} = app; 179 | 180 | let token = await db.getSetting("telegram_security_code"); 181 | 182 | if (token === null) { 183 | token = crypto.getRandomValues(new Uint8Array(16)).join(""); 184 | await db.setSetting("telegram_security_code", token); 185 | } 186 | 187 | let json = await request.json(); 188 | let externalUrl = json.externalUrl; 189 | 190 | let response = await telegram.setWebhook(`${externalUrl}/telegramMessage`, token); 191 | 192 | return new Response(`Success! Bot Name: https://t.me/${botName}. Webhook status: ${JSON.stringify(response)}`, { status: 200 }); 193 | }); 194 | 195 | router.options('/miniApp/*', (request, app, env) => new Response('Success', { 196 | headers: { 197 | ...app.corsHeaders 198 | }, 199 | status: 200 })); 200 | 201 | router.all('*', () => new Response('404, not found!', { status: 404 })); 202 | 203 | export default { 204 | fetch: handle, 205 | }; 206 | -------------------------------------------------------------------------------- /docs/img/cf-accountId.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/workflows/finalise.yml: -------------------------------------------------------------------------------- 1 | name: Output the result of the deployment 2 | 3 | on: 4 | workflow_call: 5 | outputs: 6 | worker_name: 7 | value: ${{ jobs.deploy.outputs.worker_name }} 8 | description: The name of the CloudFlare worker 9 | worker_subdomain: 10 | value: ${{ jobs.deploy.outputs.subdomain }} 11 | description: The subdomain of the CloudFlare worker 12 | worker_url: 13 | value: https://${{ jobs.deploy.outputs.worker_name }}.${{ jobs.deploy.outputs.subdomain }} 14 | description: The URL of the CloudFlare worker 15 | pages_url: 16 | value: ${{ jobs.deploy.outputs.pages_url }} 17 | description: The URL of the CloudFlare Pages deployment 18 | d1_database_name: 19 | value: ${{ jobs.deploy.outputs.database_name }} 20 | description: The name of the CloudFlare database as described in wrangler.toml 21 | d1_apparent_database_name: 22 | value: ${{ jobs.deploy.outputs.database_apparent_name }} 23 | description: The name of the CloudFlare database as determined by its id 24 | d1_database_id: 25 | value: ${{ jobs.deploy.outputs.database_id }} 26 | description: The id of the CloudFlare database as described in wrangler.toml 27 | d1_apparent_database_id: 28 | value: ${{ jobs.deploy.outputs.database_apparent_id }} 29 | description: The id of the CloudFlare database as determined by its name 30 | 31 | 32 | 33 | jobs: 34 | deploy: 35 | name: Final checks and outputs 36 | runs-on: ubuntu-latest 37 | outputs: 38 | worker_name: ${{ steps.determine_worker.outputs.name }} 39 | database_name: ${{ steps.determine_database.outputs.name }} 40 | database_id: ${{ steps.determine_database.outputs.id }} 41 | subdomain: ${{ steps.get_subdomain.outputs.subdomain }} 42 | database_apparent_id: ${{ steps.get_db_id_by_name.outputs.uuid }} 43 | database_apparent_name: ${{ steps.get_db_name_by_id.outputs.name }} 44 | pages_url: ${{ steps.get_pages_url.outputs.url }} 45 | steps: 46 | - name: Checkout code 47 | uses: actions/checkout@v4 48 | 49 | - name: Install tomlq 50 | run: | 51 | pip install yq 52 | 53 | - name: Parse toml to get worker name 54 | id: determine_worker 55 | run: | 56 | tomlq -r .name ./wrangler.toml | echo name=$(cat) >> $GITHUB_OUTPUT 57 | 58 | - name: Parse toml to get database name and id 59 | id: determine_database 60 | run: | 61 | tomlq -r .d1_databases[0].database_name ./wrangler.toml | echo name=$(cat) >> $GITHUB_OUTPUT 62 | tomlq -r .d1_databases[0].database_id ./wrangler.toml | echo id=$(cat) >> $GITHUB_OUTPUT 63 | 64 | - name: Call CloudFlare API to determine the workers.dev subdomain 65 | id: get_subdomain 66 | env: 67 | CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 68 | CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 69 | run: | 70 | curl -X GET "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/workers/subdomain" \ 71 | -H "Authorization: Bearer $CF_API_TOKEN" | jq -r '.result.subdomain' | echo subdomain=$(cat).workers.dev >> $GITHUB_OUTPUT 72 | 73 | - name: Call CloudFlare API to find the database id by name 74 | id: get_db_id_by_name 75 | env: 76 | CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 77 | CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 78 | run: | 79 | curl -X GET "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/d1/database?name=${{ steps.determine_database.outputs.name }}" \ 80 | -H "Authorization: Bearer $CF_API_TOKEN" | jq -r '.result[0].uuid' | echo uuid=$(cat) >> $GITHUB_OUTPUT 81 | 82 | - name: Call CloudFlare API to find the database name by id 83 | id: get_db_name_by_id 84 | env: 85 | CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 86 | CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 87 | run: | 88 | curl -X GET "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/d1/database/${{ steps.determine_database.outputs.id }}" \ 89 | -H "Authorization: Bearer $CF_API_TOKEN" | jq -r '.result[0].name' | echo name=$(cat) >> $GITHUB_OUTPUT 90 | 91 | - name: Call CloudFlare API to find Pages URL 92 | id: get_pages_url 93 | env: 94 | CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 95 | CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 96 | run: | 97 | curl -X GET https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/pages/projects/${{ steps.determine_worker.outputs.name }} \ 98 | -H "Authorization: Bearer $CF_API_TOKEN" | jq -r '.result.subdomain' | echo url=https://$(cat) >> $GITHUB_OUTPUT 99 | 100 | - name: Show all the outputs in summary 101 | id: show_outputs 102 | run: | 103 | printf "# 🔥🔥 Deployment successful!\n\n" >> $GITHUB_STEP_SUMMARY 104 | printf "Your Mini-App URL is ${{ steps.get_pages_url.outputs.url }}, you can now register it with @BotFather if you haven't already.\n\n" >> $GITHUB_STEP_SUMMARY 105 | printf "Your Worker URL is . You don't need to access it manually, it's registered to process the messages using the webhook.\n\n" >> $GITHUB_STEP_SUMMARY 106 | 107 | printf "\n\n
\n" >> $GITHUB_STEP_SUMMARY 108 | printf " 🔍 See all data \n\n" >> $GITHUB_STEP_SUMMARY 109 | printf "| Name | Value | Description |\n" >> $GITHUB_STEP_SUMMARY 110 | printf "| --- | --- | --- |\n" >> $GITHUB_STEP_SUMMARY 111 | printf "| worker_name | \`${{ steps.determine_worker.outputs.name }}\` | The name of the worker set in \`wrangler.toml\` |\n" >> $GITHUB_STEP_SUMMARY 112 | printf "| worker_subdomain | \`${{ steps.get_subdomain.outputs.subdomain }}\` | The subdomain for workers set up in your CloudFlare account |\n" >> $GITHUB_STEP_SUMMARY 113 | printf "| worker_url | \`https://${{ steps.determine_worker.outputs.name }}.${{ steps.get_subdomain.outputs.subdomain }}\` | The full URL worker will be deployed to or is already deployed to |\n" >> $GITHUB_STEP_SUMMARY 114 | printf "| pages_url | \`${{ steps.get_pages_url.outputs.url }}\` | The full URL Pages are deployed to. Null if not yet deployed. |\n" >> $GITHUB_STEP_SUMMARY 115 | printf "| d1_database_name | \`${{ steps.determine_database.outputs.name }}\` | The name of the D1 database set in \`wrangler.toml\` |\n" >> $GITHUB_STEP_SUMMARY 116 | printf "| d1_database_id | \`${{ steps.determine_database.outputs.id }}\` | The id of the D1 database set in \`wrangler.toml\` |\n" >> $GITHUB_STEP_SUMMARY 117 | printf "| d1_apparent_database_name | \`${{ steps.get_db_name_by_id.outputs.name }}\` | The name of the database with the id from \`d1_database_id\` |\n" >> $GITHUB_STEP_SUMMARY 118 | printf "| d1_apparent_database_id | \`${{ steps.get_db_id_by_name.outputs.uuid }}\` | The id of the database with the name from \`d1_database_name\` |\n" >> $GITHUB_STEP_SUMMARY 119 | printf "\n\n
\n\n" >> $GITHUB_STEP_SUMMARY 120 | 121 | if [ "${{ steps.get_db_id_by_name.outputs.uuid }}" != "${{ steps.determine_database.outputs.id }}" ] && [ "${{ steps.get_db_id_by_name.outputs.uuid }}" != "null" ] && [ -n "${{ steps.get_db_id_by_name.outputs.uuid }}" ]; then 122 | printf "## ⚠️ Database ID Warning\n\n" >> $GITHUB_STEP_SUMMARY 123 | printf "The id of the database used is different from the id in \`wrangler.toml\`. This is probably because you've forked a repository without changing the database id. The deployment scripts will handle it, but the wrangler tool won't. Please update the database id in \`wrangler.toml\` to be \`${{ steps.get_db_id_by_name.outputs.uuid }}\`.\n\n" >> $GITHUB_STEP_SUMMARY 124 | fi 125 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check inputs and gather data 2 | 3 | on: 4 | workflow_dispatch: 5 | workflow_call: 6 | outputs: 7 | worker_name: 8 | value: ${{ jobs.deploy.outputs.worker_name }} 9 | description: The name of the CloudFlare worker 10 | worker_subdomain: 11 | value: ${{ jobs.deploy.outputs.subdomain }} 12 | description: The subdomain of the CloudFlare worker 13 | worker_url: 14 | value: https://${{ jobs.deploy.outputs.worker_name }}.${{ jobs.deploy.outputs.subdomain }} 15 | description: The URL of the CloudFlare worker 16 | pages_url: 17 | value: ${{ jobs.deploy.outputs.pages_url }} 18 | description: The URL of the CloudFlare Pages deployment 19 | d1_database_name: 20 | value: ${{ jobs.deploy.outputs.database_name }} 21 | description: The name of the CloudFlare database as described in wrangler.toml 22 | d1_apparent_database_name: 23 | value: ${{ jobs.deploy.outputs.database_apparent_name }} 24 | description: The name of the CloudFlare database as determined by its id 25 | d1_database_id: 26 | value: ${{ jobs.deploy.outputs.database_id }} 27 | description: The id of the CloudFlare database as described in wrangler.toml 28 | d1_apparent_database_id: 29 | value: ${{ jobs.deploy.outputs.database_apparent_id }} 30 | description: The id of the CloudFlare database as determined by its name 31 | 32 | jobs: 33 | deploy: 34 | name: Check inputs and gather data 35 | runs-on: ubuntu-latest 36 | outputs: 37 | worker_name: ${{ steps.determine_worker.outputs.name }} 38 | database_name: ${{ steps.determine_database.outputs.name }} 39 | database_id: ${{ steps.determine_database.outputs.id }} 40 | subdomain: ${{ steps.get_subdomain.outputs.subdomain }} 41 | database_apparent_id: ${{ steps.get_db_id_by_name.outputs.uuid }} 42 | database_apparent_name: ${{ steps.get_db_name_by_id.outputs.name }} 43 | pages_url: ${{ steps.get_pages_url.outputs.url }} 44 | steps: 45 | - name: Check that the secrets are set in the repository 46 | id: check_secrets_set 47 | env: 48 | CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 49 | CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 50 | TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} 51 | run: | 52 | result=0 53 | printf "### 🔐 Checking if secrets are set \n\n" >> $GITHUB_STEP_SUMMARY 54 | printf "| Secret | Description |\n" >> $GITHUB_STEP_SUMMARY 55 | printf "| --- | --- |\n" >> $GITHUB_STEP_SUMMARY 56 | if [ -z "$CF_API_TOKEN" ]; then 57 | printf "| ❌ \`CF_API_TOKEN\`| Please go to [CloudFlare Dashboard](https://dash.cloudflare.com/profile/api-tokens) and create a Worker token. For this project we need access to Workers, D1 and Pages. |\n" >> $GITHUB_STEP_SUMMARY 58 | echo "::error::Secret CF_API_TOKEN not set" 59 | result=1 60 | else 61 | printf "| ✅ \`CF_API_TOKEN\` | Found |\n" >> $GITHUB_STEP_SUMMARY 62 | fi 63 | if [ -z "$CF_ACCOUNT_ID" ]; then 64 | printf "| ❌ \`CF_ACCOUNT_ID\` | Please go to [CloudFlare Workers Page](https://dash.cloudflare.com/?to=/:account/workers) and copy the account id from the right sidebar. Note that you might need to create a \"Hello World\" worker before you see the account id in the interface. |\n" >> $GITHUB_STEP_SUMMARY 65 | echo "::error::Secret CF_ACCOUNT_ID not set" 66 | result=1 67 | else 68 | printf "| ✅ \`CF_ACCOUNT_ID\` | Found |\n" >> $GITHUB_STEP_SUMMARY 69 | fi 70 | if [ -z "$TELEGRAM_BOT_TOKEN" ]; then 71 | printf "| ❌ \`TELEGRAM_BOT_TOKEN\` | Please create a Telegram bot via [@BotFather](https://t.me/botfather) and set the token as a secret in your repository. |\n" >> $GITHUB_STEP_SUMMARY 72 | echo "::error::Secret TELEGRAM_BOT_TOKEN not set" 73 | result=1 74 | else 75 | printf "| ✅ \`TELEGRAM_BOT_TOKEN\` | Found |\n" >> $GITHUB_STEP_SUMMARY 76 | fi 77 | 78 | printf "\n\nTo set the secrets, go to [current repository's secrets settings](${{ github.server_url }}/${{ github.repository }}/settings/secrets/actions)\n" >> $GITHUB_STEP_SUMMARY 79 | exit $result 80 | 81 | - name: Checkout code 82 | uses: actions/checkout@v4 83 | 84 | - name: Install tomlq 85 | run: | 86 | pip install yq 87 | 88 | - name: Parse toml to get worker name 89 | id: determine_worker 90 | run: | 91 | tomlq -r .name ./wrangler.toml | echo name=$(cat) >> $GITHUB_OUTPUT 92 | 93 | - name: Parse toml to get database name and id 94 | id: determine_database 95 | run: | 96 | tomlq -r .d1_databases[0].database_name ./wrangler.toml | echo name=$(cat) >> $GITHUB_OUTPUT 97 | tomlq -r .d1_databases[0].database_id ./wrangler.toml | echo id=$(cat) >> $GITHUB_OUTPUT 98 | 99 | - name: Call CloudFlare API to determine the workers.dev subdomain 100 | id: get_subdomain 101 | env: 102 | CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 103 | CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 104 | run: | 105 | curl -X GET "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/workers/subdomain" \ 106 | -H "Authorization: Bearer $CF_API_TOKEN" | jq -r '.result.subdomain' | echo subdomain=$(cat).workers.dev >> $GITHUB_OUTPUT 107 | 108 | - name: Call CloudFlare API to find the database id by name 109 | id: get_db_id_by_name 110 | env: 111 | CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 112 | CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 113 | run: | 114 | curl -X GET "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/d1/database?name=${{ steps.determine_database.outputs.name }}" \ 115 | -H "Authorization: Bearer $CF_API_TOKEN" | jq -r '.result[0].uuid' | echo uuid=$(cat) >> $GITHUB_OUTPUT 116 | 117 | - name: Call CloudFlare API to find the database name by id 118 | id: get_db_name_by_id 119 | env: 120 | CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 121 | CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 122 | run: | 123 | curl -X GET "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/d1/database/${{ steps.determine_database.outputs.id }}" \ 124 | -H "Authorization: Bearer $CF_API_TOKEN" | jq -r '.result[0].name' | echo name=$(cat) >> $GITHUB_OUTPUT 125 | 126 | - name: Call CloudFlare API to find Pages URL 127 | id: get_pages_url 128 | env: 129 | CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 130 | CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 131 | run: | 132 | curl -X GET https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/pages/projects/${{ steps.determine_worker.outputs.name }} \ 133 | -H "Authorization: Bearer $CF_API_TOKEN" | jq -r '.result.subdomain' | echo url=https://$(cat) >> $GITHUB_OUTPUT 134 | 135 | - name: Show all the outputs in summary 136 | id: show_outputs 137 | run: | 138 | printf "\n\n
\n" >> $GITHUB_STEP_SUMMARY 139 | printf " 🔍Gathering data\n\n" >> $GITHUB_STEP_SUMMARY 140 | printf "| Name | Value | Description |\n" >> $GITHUB_STEP_SUMMARY 141 | printf "| --- | --- | --- |\n" >> $GITHUB_STEP_SUMMARY 142 | printf "| worker_name | \`${{ steps.determine_worker.outputs.name }}\` | The name of the worker set in \`wrangler.toml\` |\n" >> $GITHUB_STEP_SUMMARY 143 | printf "| worker_subdomain | \`${{ steps.get_subdomain.outputs.subdomain }}\` | The subdomain for workers set up in your CloudFlare account |\n" >> $GITHUB_STEP_SUMMARY 144 | printf "| worker_url | \`https://${{ steps.determine_worker.outputs.name }}.${{ steps.get_subdomain.outputs.subdomain }}\` | The full URL worker will be deployed to or is already deployed to |\n" >> $GITHUB_STEP_SUMMARY 145 | printf "| pages_url | \`${{ steps.get_pages_url.outputs.url }}\` | The full URL Pages are deployed to. Null if not yet deployed. |\n" >> $GITHUB_STEP_SUMMARY 146 | printf "| d1_database_name | \`${{ steps.determine_database.outputs.name }}\` | The name of the D1 database set in \`wrangler.toml\` |\n" >> $GITHUB_STEP_SUMMARY 147 | printf "| d1_database_id | \`${{ steps.determine_database.outputs.id }}\` | The id of the D1 database set in \`wrangler.toml\` |\n" >> $GITHUB_STEP_SUMMARY 148 | printf "| d1_apparent_database_name | \`${{ steps.get_db_name_by_id.outputs.name }}\` | The name of the database with the id from \`d1_database_id\` |\n" >> $GITHUB_STEP_SUMMARY 149 | printf "| d1_apparent_database_id | \`${{ steps.get_db_id_by_name.outputs.uuid }}\` | The id of the database with the name from \`d1_database_name\` |\n" >> $GITHUB_STEP_SUMMARY 150 | printf "\n\n
\n" >> $GITHUB_STEP_SUMMARY 151 | printf "\n\n" >> $GITHUB_STEP_SUMMARY 152 | 153 | if [ "${{ steps.get_db_id_by_name.outputs.uuid }}" != "${{ steps.determine_database.outputs.id }}" ] && [ "${{ steps.get_db_id_by_name.outputs.uuid }}" != "null" ] && [ -n "${{ steps.get_db_id_by_name.outputs.uuid }}" ]; then 154 | printf "⚠️ The id of the database with the name from \`d1_database_name\` is different from the id in \`wrangler.toml\`. This is probably because you've forked a repository without changing the database id. The deployment scripts will handle it, but the wrangler tool won't. Please update the database id in \`wrangler.toml\` to be \`${{ steps.get_db_id_by_name.outputs.uuid }}\`.\n\n" >> $GITHUB_STEP_SUMMARY 155 | fi 156 | -------------------------------------------------------------------------------- /docs/img/cf-token.svg: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------