├── 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 |
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 | 
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 | 
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 |
--------------------------------------------------------------------------------