Skip to content

Commit

Permalink
Final commit
Browse files Browse the repository at this point in the history
  • Loading branch information
kojoru committed Oct 10, 2023
1 parent 626b5e2 commit 1738a0b
Show file tree
Hide file tree
Showing 10 changed files with 201 additions and 54 deletions.
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Make sure that the you've deployed at least once and set your own database id in

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.

Do an `npm install` and initialize the database with `npx wrangler d1 execute DB --file .\init.sql --local`.
Do an `npm install` and initialize the database with `npx wrangler d1 execute DB --file .\init.sql --local`.

Now you are ready to run the worker with `npx wrangler dev`. The worker will be waiting for you at <http://localhost:8787/>.

Expand All @@ -118,3 +118,30 @@ while true; do
sleep 3
done
```

## Code information

The backend code is a CloudFlare Worker. Start with `index.js` to get a general idea of how it works.

We export `telegram.js` for working with telegram, `db.js` for working with the database and `cryptoUtils.js` for cryptography.

There are no dependencies except for `itty-router`, which makes the whole affair blazing fast.

For database we use CloudFlare D1, which is a version of SQLite. We initialize it with `init.sql` file.

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.

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.

## Security features

All the needed checks are done:

* The bot checks the signatures of the webhook requests
* The bot checks the signatures of the Mini-app requests and validates the user
* The bot checks the token of initialization request sent during deployment
* CORS between the frontend and the backend is locked down to specifically used domains

## Sample bot

You can try out the bot at [@group_meetup_bot](https://t.me/group_meetup_bot).
10 changes: 10 additions & 0 deletions db.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ class Database {
.bind(tokenHash)
.first();
}

async saveCalendar(calendarJson, calendarRef, userId) {
return await this.db.prepare(
`INSERT
INTO calendars (createdDate, updatedDate, calendarJson, calendarRef, userId)
VALUES (DATETIME('now'), DATETIME('now'), ?, ?, ?)`
)
.bind(calendarJson, calendarRef, userId)
.run();
}
}

export { Database }
3 changes: 2 additions & 1 deletion drop.sql
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
DROP TABLE IF EXISTS settings;
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS initDataCheck;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS tokens;
DROP TABLE IF EXISTS calendars;
DROP TABLE IF EXISTS users;
46 changes: 32 additions & 14 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Router } from 'itty-router';
import { Telegram } from './telegram';
import { Database } from './db';
import { processMessage } from './messageProcessor';
import { generateSecret, sha256 } from './cryptoUtils';

// Create a new router
Expand All @@ -19,22 +20,12 @@ const handle = async (request, env, ctx) => {

return await router.handle(request, app, env, ctx);
}
const processMessage = async (json, app) => {
const {telegram, db} = app;
const chatId = json.message.chat.id;
const reply_to_message_id = json.message.message_id;

const messageToSave = JSON.stringify(json, null, 2);
await telegram.sendMessage(chatId, "```json" + messageToSave + "```", 'MarkdownV2', reply_to_message_id);

await db.addMessage(messageToSave, json.update_id);
};

/*
Our index route, a simple hello world.
*/
router.get('/', () => {
return new Response('Hello, world! This is the root page of your Worker template.');
return new Response('This telegram bot is deployed correctly. No user-serviceable parts inside.', { status: 200 });
});

router.post('/initMiniApp', async (request, app) => {
Expand All @@ -45,13 +36,13 @@ router.post('/initMiniApp', async (request, app) => {
let {expectedHash, calculatedHash, data} = await telegram.calculateHashes(initData);

if(expectedHash !== calculatedHash) {
return new Response('Unauthorized', { status: 401 });
return new Response('Unauthorized', { status: 401, headers: {...app.corsHeaders } });
}

const currentTime = Math.floor(Date.now() / 1000);
let stalenessSeconds = currentTime - data.auth_date;
if (stalenessSeconds > 600) {
return new Response('Stale data, please restart app', { status: 400 });
return new Response('Stale data, please restart app', { status: 400, headers: {...app.corsHeaders } });
}

// Hashes match, the data is fresh enough, we can be fairly sure that the user is who they say they are
Expand All @@ -64,7 +55,8 @@ router.post('/initMiniApp', async (request, app) => {

return new Response(JSON.stringify(
{
'token': token
'token': token,
'startParam': data.start_param,
}),
{ status: 200, headers: {...app.corsHeaders }});
});
Expand All @@ -85,6 +77,32 @@ router.get('/miniApp/me', async (request, app) => {
{ status: 200, headers: {...app.corsHeaders }});
});

router.post('/miniApp/dates', async (request, app) => {
const {db, telegram} = app;

let suppliedToken = request.headers.get('Authorization').replace('Bearer ', '');
const tokenHash = await sha256(suppliedToken);
let user = await db.getUserByTokenHash(tokenHash);

if (user === null) {
return new Response('Unauthorized', { status: 401 });
}

let ref = await generateSecret(8);
let json = await request.json();
let jsonToSave = JSON.stringify({dates: json.dates}); // todo: more validation
if (jsonToSave.length > 4096) { return new Response('Too much data', { status: 400 }); }

await db.saveCalendar(jsonToSave, ref, user.id);

let result = await telegram.sendMessage(user.telegramId, "Your calendar is ready and available for share using [this link](https://t.me/group_meetup_bot/calendar?startapp="+ ref +")\\. Send it to your friends so that they can choose a date\\.", 'MarkdownV2');
console.log(result);

return new Response(JSON.stringify(
{user: user}),
{ status: 200, headers: {...app.corsHeaders }});
});

router.post('/telegramMessage', async (request, app) => {

const {db} = app;
Expand Down
10 changes: 10 additions & 0 deletions init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,15 @@ CREATE TABLE IF NOT EXISTS tokens (
FOREIGN KEY(userId) REFERENCES users(id)
);

CREATE TABLE IF NOT EXISTS calendars (
id integer PRIMARY KEY AUTOINCREMENT,
createdDate text NOT NULL,
updatedDate text NOT NULL,
userId integer NOT NULL,
calendarJson text NOT NULL,
calendarRef text NOT NULL,
FOREIGN KEY(userId) REFERENCES users(id)
);

CREATE UNIQUE INDEX IF NOT EXISTS tokenHashIndex ON tokens (tokenHash);
CREATE UNIQUE INDEX IF NOT EXISTS telegramIdIndex ON users (telegramId);
22 changes: 22 additions & 0 deletions messageProcessor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const sendGreeting = async (chatId, replyToMessageId, telegram) => {
return await telegram.sendMessage(chatId, "Hello\\!\n\n Access [the calendar](https://t.me/group_meetup_bot/calendar) to set your availability and you will receive the voting link back", 'MarkdownV2', replyToMessageId);
}

const processMessage = async (json, app) => {
const {telegram, db} = app;
const chatId = json.message.chat.id;
const replyToMessageId = json.message.message_id;


const messageToSave = JSON.stringify(json, null, 2);

if (json.message.text === '/start') {
return await sendGreeting(chatId, replyToMessageId, telegram);
}

//await telegram.sendMessage(chatId, "```json" + messageToSave + "```", 'MarkdownV2', replyToMessageId);

await db.addMessage(messageToSave, json.update_id);
};

export { processMessage }
2 changes: 2 additions & 0 deletions webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"dependencies": {
"@tanstack/react-query": "^4.36.1",
"@vkruglikov/react-telegram-web-app": "^2.1.4",
"date-fns": "^2.30.0",
"react": "^18.2.0",
"react-day-picker": "^8.8.2",
"react-dom": "^18.2.0"
},
"devDependencies": {
Expand Down
99 changes: 76 additions & 23 deletions webapp/src/MainPage.jsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { useState, useEffect } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import PopupMainButton from './PopupMainButton'
import { initMiniApp, getMe } from './api'
import { useWebApp } from '@vkruglikov/react-telegram-web-app'
import {
useQuery
} from '@tanstack/react-query'
import { useState } from 'react'
import 'react-day-picker/dist/style.css';
import { initMiniApp, getMe, sendDates } from './api'
import { MainButton, useWebApp } from '@vkruglikov/react-telegram-web-app'
import { useQuery, useMutation } from '@tanstack/react-query'
import { format } from 'date-fns';
import { DayPicker } from 'react-day-picker';


const handleClick = () =>
showPopup({
message: 'Hello, I am popup',
});


function MainPage() {
const [count, setCount] = useState(0)

const { initDataUnsafe, initData, backgroundColor } = useWebApp()
const { unsafeInitData, initData, backgroundColor } = useWebApp()

console.log(initData);

const initResult = useQuery({
queryKey: ['initData'],
Expand All @@ -31,32 +38,78 @@ function MainPage() {
enabled: !!token,
});

let sendingError = false;
// send selected dates to backend:
const dateMutation = useMutation({
mutationKey: ['sendDate', token],
mutationFn: async (dates) => {
const result = await sendDates(token, dates);
return result;
},
onSuccess: () => {
window.Telegram.WebApp.close();
},
onError: () => {
sendingError = true;
},
});


const [selectedDates, setSelected] = useState();

let footer = <p>Please pick the days you propose for the meetup.</p>;
let mainButton = "";

if (selectedDates) {
footer = (
<p>
You picked {selectedDates.length}{' '}
{selectedDates.length > 1 ? 'dates' : 'date'}: {' '}
{selectedDates.map((date, index) => (
<span key={date.getTime()}>
{index ? ', ' : ''}
{format(date, 'PP')}
</span>
))}
</p>
);
mainButton = <MainButton text="Select dates" onClick={async () => { dateMutation.mutate(selectedDates) }} />;
}

if (initResult.isError || me.isError || sendingError) {
return <div>Error! Try reloading the app</div>
}
if(initResult.isLoading || me.isLoading) {
return <div>loading...</div>
}
return (
<div
style={{
backgroundColor
}}>
<h2>Pick proposed dates</h2>
<div>
<a href="https://vitejs.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
<DayPicker
mode="multiple"
min={1}
max={5}
selected={selectedDates}
onSelect={setSelected}
footer={footer}
/>
</div>
<h1>Vite + React</h1>
<div className="card">
{/*<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}<br/>
</button>
{/* <code>{JSON.stringify(initResult)}</code> */}
{<code>{JSON.stringify(initResult)}</code>}
<p>
we thing you are {initDataUnsafe?.user?.first_name}<br/>
we think you are {initDataUnsafe?.user?.first_name}<br/>
backend thinks you are {me?.data?.user?.firstName}<br/>
backend is {import.meta.env.VITE_BACKEND_URL}
</p>
</div>
<PopupMainButton />
</p>
</div>*/}
{mainButton}
</div>
)
}
Expand Down
14 changes: 0 additions & 14 deletions webapp/src/PopupMainButton.jsx

This file was deleted.

20 changes: 19 additions & 1 deletion webapp/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,22 @@ const getMe = async (token) => {
return response.json()
}

export { initMiniApp, getMe }
const sendDates = async(token, dates) => {
const response = await fetch(import.meta.env.VITE_BACKEND_URL + '/miniApp/dates', {
method: 'POST',
mode: 'cors',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(
{ dates: dates }
),
})
if (!response.ok) {
throw new Error(`Bot error: ${response.status} ${response.statusText}}`)
}
return response.json()
}

export { initMiniApp, getMe, sendDates }

0 comments on commit 1738a0b

Please sign in to comment.