├── .gitignore ├── CONTRIBUTING ├── Dockerfile ├── LICENSE.md ├── README.md ├── analysis ├── data │ └── SS22 │ │ ├── Friday.png │ │ ├── Monday.png │ │ ├── Thursday.png │ │ ├── Tuesday.png │ │ ├── Wednesday.png │ │ └── queueReports.csv └── scripts │ └── weekday_analysis.py ├── changelog.psv ├── db └── migrations │ ├── 000001_create_db.down.sql │ ├── 000001_create_db.up.sql │ ├── 000002_add_ab_tests.down.sql │ ├── 000002_add_ab_tests.up.sql │ ├── 000003_add_mensa_menu_table.down.sql │ ├── 000003_add_mensa_menu_table.up.sql │ ├── 000004_add_mensa_preferences_table.down.sql │ └── 000004_add_mensa_preferences_table.up.sql ├── db_connectors ├── db_connector.go ├── db_utilities.go ├── internetpoints_db_connector.go ├── mensa_menus_connector.go ├── mensa_preferences_connector.go ├── userprofile_db_connector.go └── userprofile_db_connector_test.go ├── deployment ├── .env-template ├── Caddyfile ├── deploy_mensa_queue.yaml ├── docker-compose.yml ├── pull_csv.yaml └── pull_db.yaml ├── egraph_test.go ├── emoji_list ├── go.mod ├── go.sum ├── help_handler.go ├── introduction.go ├── main.go ├── mensa_handler.go ├── mensa_locations.json ├── mensa_scraper ├── example.xml ├── mensa_messenger.go ├── mensa_scraper.go └── xml_parser_test.go ├── points_handler.go ├── profile_picture.jpg ├── queue_length_illustrations ├── L00_practically_empty.jpg ├── L01_within_kitchen.jpg ├── L02_up_to_food_trays.jpg ├── L03_within_first_room.jpg ├── L04_starting_to_corner.jpg ├── L05_past_first_desk.jpg ├── L06_past_second_desk.jpg ├── L07_up_to_stairs.jpg ├── L08_even_longer └── top_view.jpg ├── reports_handler.go ├── requests_handler.go ├── settings_handler.go ├── static └── settings.html ├── telegram_connector ├── keyboard_decider.go ├── keyboards │ ├── 00_report_keyboard.json │ ├── 01_main_keyboard.json │ ├── 02_settings_keyboard.json │ └── keyboard.json └── telegram_connector.go └── utils └── utils.go /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | dev-env.sh 3 | pics_with_lines 4 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | Contributors are very welcome. 2 | 3 | Before you start implementing a feature ideally reach out to make sure that your work doesn't conflict with other work that is going on. 4 | Right now this project is small enough that I'll sometimes cut corners that wouldn't be cut in a larger project. 5 | 6 | If you have a feature idea please open an issue before starting implementation, again to avoid conflicts. 7 | 8 | For bugfixes/comparables I don't think prior coordination is required. 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.18 2 | # https://hub.docker.com/_/caddy?tab=description has docker compose example 3 | 4 | RUN apt-get update && apt-get install -y git wget 5 | RUN wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb 6 | RUN apt-get install -y ./google-chrome-stable_current_amd64.deb 7 | 8 | COPY . /go/src/app 9 | COPY ./static /static 10 | WORKDIR /go/src/app 11 | 12 | RUN go get ./... 13 | RUN go build . 14 | 15 | ENTRYPOINT ["./MensaQueueBot"] 16 | 17 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 ADimeo 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mensa Queue Bot 2 | 3 | This is a telegram bot written in go that allows you to record and receive the current length of the Griebnitzsee mensa queue, as well as see the menus currently on offer. 4 | 5 | > Oh shit. Oh shit. Oh shit. 6 | > 7 | > -- Enthusiastic user feedback 8 | 9 | 10 | ## Features 11 | - Allows users to report current queue length 12 | - Stores these reports without allowing direct inference of who reported it 13 | - Reports are stored in a sqlite database 14 | - Users can collect internetpoints for their reports 15 | - Allows users to request the current queue length 16 | - Reports to users are graphic, and contain both historical and current data 17 | - Allows users to receive the mensa menu currently on offer 18 | - Both via request and push 19 | - Includes settings, including weekday and timeslot selection 20 | - Allows to define messages that should be sent to users the next time they interact with the bot 21 | - In praxis, this is mostly used for changelogs 22 | - To define a new message to be sent, edit `changelog.psv` 23 | - That's about it 24 | 25 | 26 | 27 | 28 | ## Repo Structure 29 | This repo is at the point where starting to work on features requires institutional knowledge that is currently not explicitly documented. In general it is well structured, but not all code is documented as thoroughly as it could be. If you want to contribute feel free to request a guided tour from a maintainer. 30 | 31 | ### Folders and modules 32 | - `analysis` includes python scripts and published queue length data. It is not relevant for bot development 33 | - `db/migrations` contains just that. We use golang-migrate to apply these 34 | - `db_connectors` act as "model", and implement all queries against the DB 35 | - `deployment` contains ansible scripts and server/docker-compose configs used for deployment 36 | - `mensa_scraper` is a relatively independent module that is responsible for both getting the current mensa menus, storing them in the db using `db_connectors`, and sending them out to users both when menus change and when requested. 37 | - `queue_length_illustrations` contains images that are sent to bot users to illustrate the different queue lengths 38 | - `static` contains an html file that is used to modify bot settings. It needs to be hosted somewhere 39 | - `telegram_connector` is responsible for all interaction with telegram 40 | - `utils` contains utility functions 41 | 42 | ### Further files of interest 43 | - `changelog.psv` is a csv (except with pipes as a separator) that defines messages to be sent to users. Pleaes keep IDs incrementing one by one 44 | - `mensa_locations` contains links to illustrations, and needs to be consistend with the keyboards defined in `telegram_connector/keyboards` 45 | - `db_connectors/db_utilities.go` contains the `DB_VERSION` variable, which is used to decide whether migrations should be applied. Only increment it, and keep it consistent with `db/migrations` 46 | 47 | ### Debug mode 48 | During development the environment variable `MENSA_QUEUE_BOT_DEBUG_MODE` can be set. If it is set to a telegram user ID that ID will receive debug messages when running `egrap_test.go`. Additionally, if set to any value at all, it will alter behaviour: 49 | - Mensa length reports will be allowed at all times 50 | - The mensa scraper will run every minute of every day instead of every 10 minutes while the mensa is open 51 | - Different default values may be set, e.g. for mensa menu preferences 52 | 53 | 54 | # Development setup 55 | The following steps can be taken to run a fully functional MensaQueueBot locally. Feel free to replace steps where you are more comfortable with alternative solutions 56 | 1. Install go 57 | 2. Create a new telegram bot as described by [telegram documentation](https://core.telegram.org/bots/features#botfather) 58 | 3. Install a proxy service such as [ngrok](https://ngrok.com/) 59 | 4. Set the following environment variables in a shell via `export` 60 | - `MENSA_QUEUE_BOT_DB_PATH` to any path, it's where the DB for reports wil lbe 61 | - `MENSA_QUEUE_BOT_PERSONAL_TOKEN` to an arbitrary string. This string hides the endpoint which accepts requests from telegrams servers. It's a security feature that doesn't need to be user for a development deployment 62 | - `MENSA_QUEUE_BOT_TELEGRAM_TOKEN` to the token you received when creating your bot 63 | - `MENSA_QUEUE_BOT_DEBUG_MODE` can optionally be set to any value. If it is set a couple of things work differently, e.g. you can report mensa lengths at any time. Also used during testing to define the telegram ID of the dev that wants to receive debug messages. 64 | 5. Allow telegrams servers to connect to your development server by telling them where you are 65 | - Start the proxy service, e.g. with `ngrok http 8080` in a second shell 66 | - Tell telegrams servers with `curl -F "url=[url ngrok displays to you]/[string you set as MENSA_QUEUE_BOT_PERSONAL_TOKEN/" "https://api.telegram.org/bot[your MENSA_QUEUE_BOT_PERSONAL_TOKEN/setWebhook"` 67 | - So if your token is `ABCDE` the final request is to `https://api.telegram.org/botABCDE/setWebhook` 68 | 6. In the same shell where you set the environment variables run `go run .` 69 | 70 | 71 | ## Deployment 72 | 1. `mv deployment/.env-template deployment/.env` and modify all variables within it 73 | 2. Advise telegram where your bot will be hosted, e.g. via `curl -F "url=https://your.url.example.com/long-random-string-defined-as-MENSA_QUEUE_BOT_PERSONAL_TOKEN/" "https://api.telegram.org/bot/setWebhook"` 74 | 3. Build the docker container on the machine you want to run it on with `docker build -t mensaqueuebot .` 75 | 4. `cd deployment && docker-compose --env-file .env up --build` to the bot server and a reverse proxy 76 | 77 | Steps 3. and 4. can be automated away with the `deploy-mensa-queue.yaml` ansible file that is provided in the `deployment` folder. 78 | 79 | ## Extracting Data 80 | This assumes that the deployment is identical to the one described above. 81 | 82 | Data is stored in an sqlite file within a docker volume created by docker-compose. To download it to your machine follow these steps: 83 | 84 | 1. ssh into your server 85 | 2. Copy the report file from the docker volume to your home directory via `sudo cp /var/lib/docker/volumes/deployment_db_data/_data/queue_database.db /home/your-user/databases/queue_database.db` 86 | 3. Copy the file from the remote system to your system by using rsync ip-of-your-system:~/queue_database.db . 87 | 88 | You now have a local sqlite3 file. You can view or edit it in a variety of ways, including the [DB browser for SQLITE](https://sqlitebrowser.org/), or the [command line shell for sqlite](https://www.sqlite.org/cli.html) 89 | 90 | Mensa queue length reports are in the queueReports table. You can extract them to csv by running `sqlite3 -header -csv cheue_database.db "select time, queueLength from queueReports" > queueReports.csv` 91 | 92 | All steps can be automated using the `pull_db.yaml` ansible file that is provided in the `deployment` folder. 93 | -------------------------------------------------------------------------------- /analysis/data/SS22/Friday.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ADimeo/MensaQueueBot/79888dad73986018c0877616a094a6f2adaed6b5/analysis/data/SS22/Friday.png -------------------------------------------------------------------------------- /analysis/data/SS22/Monday.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ADimeo/MensaQueueBot/79888dad73986018c0877616a094a6f2adaed6b5/analysis/data/SS22/Monday.png -------------------------------------------------------------------------------- /analysis/data/SS22/Thursday.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ADimeo/MensaQueueBot/79888dad73986018c0877616a094a6f2adaed6b5/analysis/data/SS22/Thursday.png -------------------------------------------------------------------------------- /analysis/data/SS22/Tuesday.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ADimeo/MensaQueueBot/79888dad73986018c0877616a094a6f2adaed6b5/analysis/data/SS22/Tuesday.png -------------------------------------------------------------------------------- /analysis/data/SS22/Wednesday.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ADimeo/MensaQueueBot/79888dad73986018c0877616a094a6f2adaed6b5/analysis/data/SS22/Wednesday.png -------------------------------------------------------------------------------- /analysis/data/SS22/queueReports.csv: -------------------------------------------------------------------------------- 1 | time,queueLength 2 | 1652090544,"L1: Within kitchen" 3 | 1652091929,"L0: Virtually empty" 4 | 1652091955,"L0: Virtually empty" 5 | 1652092401,"L0: Virtually empty" 6 | 1652093004,"L1: Within kitchen" 7 | 1652093012,"L3: Within first room" 8 | 1652095403,"L0: Virtually empty" 9 | 1652124577,"L0: Virtually empty" 10 | 1652125194,"L0: Virtually empty" 11 | 1652127964,"L8: Even longer" 12 | 1652127975,"L0: Virtually empty" 13 | 1652129535,"L0: Virtually empty" 14 | 1652130106,"L0: Virtually empty" 15 | 1652135810,"L0: Virtually empty" 16 | 1652158785,"L0: Virtually empty" 17 | 1652175610,"L0: Virtually empty" 18 | 1652175871,"L5: Past first desk" 19 | 1652175950,"L7: Up to stairs" 20 | 1652177032,"L7: Up to stairs" 21 | 1652177100,"L6: Past second desk" 22 | 1652177153,"L7: Up to stairs" 23 | 1652177190,"L7: Up to stairs" 24 | 1652177245,"L6: Past second desk" 25 | 1652177674,"L6: Past second desk" 26 | 1652177734,"L6: Past second desk" 27 | 1652177736,"L6: Past second desk" 28 | 1652177834,"L5: Past first desk" 29 | 1652177921,"L6: Past second desk" 30 | 1652178227,"L4: Starting to corner" 31 | 1652178240,"L0: Virtually empty" 32 | 1652178512,"L5: Past first desk" 33 | 1652178544,"L4: Starting to corner" 34 | 1652178656,"L8: Even longer" 35 | 1652178737,"L3: Within first room" 36 | 1652178793,"L4: Starting to corner" 37 | 1652178832,"L4: Starting to corner" 38 | 1652178845,"L4: Starting to corner" 39 | 1652178854,"L4: Starting to corner" 40 | 1652178857,"L4: Starting to corner" 41 | 1652178861,"L4: Starting to corner" 42 | 1652178871,"L4: Starting to corner" 43 | 1652178872,"L4: Starting to corner" 44 | 1652178873,"L4: Starting to corner" 45 | 1652178873,"L4: Starting to corner" 46 | 1652178873,"L4: Starting to corner" 47 | 1652178873,"L4: Starting to corner" 48 | 1652178873,"L4: Starting to corner" 49 | 1652178874,"L4: Starting to corner" 50 | 1652178874,"L4: Starting to corner" 51 | 1652178874,"L4: Starting to corner" 52 | 1652178875,"L4: Starting to corner" 53 | 1652178875,"L4: Starting to corner" 54 | 1652178875,"L4: Starting to corner" 55 | 1652178875,"L4: Starting to corner" 56 | 1652178876,"L4: Starting to corner" 57 | 1652178876,"L4: Starting to corner" 58 | 1652178876,"L4: Starting to corner" 59 | 1652178876,"L4: Starting to corner" 60 | 1652178876,"L4: Starting to corner" 61 | 1652178876,"L4: Starting to corner" 62 | 1652178877,"L4: Starting to corner" 63 | 1652178877,"L4: Starting to corner" 64 | 1652178877,"L4: Starting to corner" 65 | 1652178877,"L4: Starting to corner" 66 | 1652178878,"L4: Starting to corner" 67 | 1652178893,"L5: Past first desk" 68 | 1652178910,"L4: Starting to corner" 69 | 1652178994,"L5: Past first desk" 70 | 1652179021,"L5: Past first desk" 71 | 1652179036,"L5: Past first desk" 72 | 1652179060,"L4: Starting to corner" 73 | 1652179094,"L4: Starting to corner" 74 | 1652179155,"L8: Even longer" 75 | 1652179155,"L8: Even longer" 76 | 1652179155,"L8: Even longer" 77 | 1652179166,"L4: Starting to corner" 78 | 1652179166,"L4: Starting to corner" 79 | 1652179180,"L0: Virtually empty" 80 | 1652179181,"L5: Past first desk" 81 | 1652179191,"L8: Even longer" 82 | 1652179194,"L3: Within first room" 83 | 1652179197,"L4: Starting to corner" 84 | 1652179438,"L5: Past first desk" 85 | 1652179658,"L5: Past first desk" 86 | 1652179807,"L5: Past first desk" 87 | 1652179969,"L3: Within first room" 88 | 1652179993,"L3: Within first room" 89 | 1652179996,"L3: Within first room" 90 | 1652180046,"L3: Within first room" 91 | 1652180298,"L2: Up to food trays" 92 | 1652180737,"L1: Within kitchen" 93 | 1652181047,"L0: Virtually empty" 94 | 1652181144,"L1: Within kitchen" 95 | 1652181633,"L0: Virtually empty" 96 | 1652181975,"L4: Starting to corner" 97 | 1652181993,"L1: Within kitchen" 98 | 1652182150,"L7: Up to stairs" 99 | 1652182175,"L0: Virtually empty" 100 | 1652193733,"L0: Virtually empty" 101 | 1652196066,"L0: Virtually empty" 102 | 1652259981,"L0: Virtually empty" 103 | 1652262289,"L1: Within kitchen" 104 | 1652262860,"L6: Past second desk" 105 | 1652263542,"L4: Starting to corner" 106 | 1652263569,"L4: Starting to corner" 107 | 1652263782,"L2: Up to food trays" 108 | 1652263812,"L2: Up to food trays" 109 | 1652264164,"L1: Within kitchen" 110 | 1652264670,"L1: Within kitchen" 111 | 1652264692,"L1: Within kitchen" 112 | 1652265253,"L1: Within kitchen" 113 | 1652265276,"L3: Within first room" 114 | 1652265304,"L2: Up to food trays" 115 | 1652265305,"L3: Within first room" 116 | 1652265316,"L3: Within first room" 117 | 1652265358,"L1: Within kitchen" 118 | 1652265367,"L3: Within first room" 119 | 1652265467,"L3: Within first room" 120 | 1652265495,"L4: Starting to corner" 121 | 1652265639,"L3: Within first room" 122 | 1652266063,"L1: Within kitchen" 123 | 1652266724,"L1: Within kitchen" 124 | 1652267423,"L0: Virtually empty" 125 | 1652270830,"L0: Virtually empty" 126 | 1652347372,"L0: Virtually empty" 127 | 1652347385,"L0: Virtually empty" 128 | 1652349691,"L4: Starting to corner" 129 | 1652349758,"L4: Starting to corner" 130 | 1652349780,"L5: Past first desk" 131 | 1652349783,"L4: Starting to corner" 132 | 1652350358,"L4: Starting to corner" 133 | 1652350812,"L3: Within first room" 134 | 1652350953,"L3: Within first room" 135 | 1652351061,"L2: Up to food trays" 136 | 1652351061,"L2: Up to food trays" 137 | 1652351084,"L3: Within first room" 138 | 1652351087,"L3: Within first room" 139 | 1652351117,"L3: Within first room" 140 | 1652351220,"L2: Up to food trays" 141 | 1652351271,"L3: Within first room" 142 | 1652351285,"L3: Within first room" 143 | 1652351433,"L0: Virtually empty" 144 | 1652351527,"L3: Within first room" 145 | 1652351529,"L3: Within first room" 146 | 1652351550,"L0: Virtually empty" 147 | 1652351553,"L3: Within first room" 148 | 1652351556,"L0: Virtually empty" 149 | 1652351560,"L3: Within first room" 150 | 1652351562,"L0: Virtually empty" 151 | 1652351582,"L3: Within first room" 152 | 1652351586,"L0: Virtually empty" 153 | 1652351616,"L3: Within first room" 154 | 1652351630,"L3: Within first room" 155 | 1652351680,"L3: Within first room" 156 | 1652351696,"L2: Up to food trays" 157 | 1652351712,"L3: Within first room" 158 | 1652351737,"L3: Within first room" 159 | 1652351953,"L3: Within first room" 160 | 1652351979,"L3: Within first room" 161 | 1652352001,"L3: Within first room" 162 | 1652352238,"L1: Within kitchen" 163 | 1652352362,"L2: Up to food trays" 164 | 1652352727,"L0: Virtually empty" 165 | 1652353334,"L0: Virtually empty" 166 | 1652354203,"L1: Within kitchen" 167 | 1652435259,"L0: Virtually empty" 168 | 1652435722,"L3: Within first room" 169 | 1652435741,"L3: Within first room" 170 | 1652436083,"L2: Up to food trays" 171 | 1652436277,"L1: Within kitchen" 172 | 1652436604,"L1: Within kitchen" 173 | 1652437421,"L0: Virtually empty" 174 | 1652437837,"L0: Virtually empty" 175 | 1652437967,"L1: Within kitchen" 176 | 1652438185,"L8: Even longer" 177 | 1652438302,"L4: Starting to corner" 178 | 1652438378,"L1: Within kitchen" 179 | 1652438598,"L1: Within kitchen" 180 | 1652439011,"L0: Virtually empty" 181 | 1652439011,"L0: Virtually empty" 182 | 1652439147,"L1: Within kitchen" 183 | 1652439285,"L0: Virtually empty" 184 | 1652439550,"L0: Virtually empty" 185 | 1652466979,"L0: Virtually empty" 186 | 1652467000,"L8: Even longer" 187 | 1652467001,"L8: Even longer" 188 | 1652467004,"L8: Even longer" 189 | 1652467005,"L8: Even longer" 190 | 1652467006,"L8: Even longer" 191 | 1652467007,"L8: Even longer" 192 | 1652467008,"L8: Even longer" 193 | 1652467009,"L8: Even longer" 194 | 1652467022,"L5: Past first desk" 195 | 1652467038,"L8: Even longer" 196 | 1652467038,"L8: Even longer" 197 | 1652467039,"L8: Even longer" 198 | 1652467039,"L8: Even longer" 199 | 1652467040,"L8: Even longer" 200 | 1652467040,"L8: Even longer" 201 | 1652467040,"L8: Even longer" 202 | 1652467041,"L8: Even longer" 203 | 1652467041,"L8: Even longer" 204 | 1652467041,"L8: Even longer" 205 | 1652467042,"L8: Even longer" 206 | 1652467042,"L8: Even longer" 207 | 1652467042,"L8: Even longer" 208 | 1652467043,"L8: Even longer" 209 | 1652467043,"L8: Even longer" 210 | 1652467049,"L8: Even longer" 211 | 1652467049,"L8: Even longer" 212 | 1652467049,"L8: Even longer" 213 | 1652467055,"L8: Even longer" 214 | 1652467055,"L8: Even longer" 215 | 1652467056,"L8: Even longer" 216 | 1652467056,"L8: Even longer" 217 | 1652467056,"L8: Even longer" 218 | 1652467057,"L8: Even longer" 219 | 1652467057,"L8: Even longer" 220 | 1652467057,"L8: Even longer" 221 | 1652467058,"L8: Even longer" 222 | 1652486562,"L1: Within kitchen" 223 | 1652692409,"L0: Virtually empty" 224 | 1652692513,"L0: Virtually empty" 225 | 1652695392,"L1: Within kitchen" 226 | 1652695796,"L3: Within first room" 227 | 1652695829,"L3: Within first room" 228 | 1652695920,"L3: Within first room" 229 | 1652696304,"L1: Within kitchen" 230 | 1652697153,"L1: Within kitchen" 231 | 1652697169,"L1: Within kitchen" 232 | 1652697256,"L1: Within kitchen" 233 | 1652697278,"L1: Within kitchen" 234 | 1652697402,"L3: Within first room" 235 | 1652697529,"L4: Starting to corner" 236 | 1652697643,"L4: Starting to corner" 237 | 1652697650,"L5: Past first desk" 238 | 1652697652,"L5: Past first desk" 239 | 1652697657,"L5: Past first desk" 240 | 1652697873,"L5: Past first desk" 241 | 1652698264,"L4: Starting to corner" 242 | 1652698522,"L5: Past first desk" 243 | 1652698522,"L5: Past first desk" 244 | 1652698563,"L5: Past first desk" 245 | 1652698836,"L4: Starting to corner" 246 | 1652699085,"L3: Within first room" 247 | 1652699496,"L4: Starting to corner" 248 | 1652703620,"L0: Virtually empty" 249 | 1652780913,"L7: Up to stairs" 250 | 1652781088,"L8: Even longer" 251 | 1652781192,"L8: Even longer" 252 | 1652783544,"L4: Starting to corner" 253 | 1652783551,"L4: Starting to corner" 254 | 1652783555,"L8: Even longer" 255 | 1652783580,"L7: Up to stairs" 256 | 1652783638,"L7: Up to stairs" 257 | 1652783639,"L8: Even longer" 258 | 1652783642,"L8: Even longer" 259 | 1652783644,"L8: Even longer" 260 | 1652783658,"L8: Even longer" 261 | 1652783724,"L8: Even longer" 262 | 1652783744,"L8: Even longer" 263 | 1652783750,"L7: Up to stairs" 264 | 1652783751,"L0: Virtually empty" 265 | 1652783755,"L8: Even longer" 266 | 1652783781,"L7: Up to stairs" 267 | 1652783975,"L7: Up to stairs" 268 | 1652784109,"L6: Past second desk" 269 | 1652784146,"L6: Past second desk" 270 | 1652784168,"L6: Past second desk" 271 | 1652784211,"L6: Past second desk" 272 | 1652784414,"L7: Up to stairs" 273 | 1652785621,"L3: Within first room" 274 | 1652785904,"L2: Up to food trays" 275 | 1652786486,"L0: Virtually empty" 276 | 1652786793,"L6: Past second desk" 277 | 1652866966,"L3: Within first room" 278 | 1652867458,"L5: Past first desk" 279 | 1652867570,"L5: Past first desk" 280 | 1652867635,"L6: Past second desk" 281 | 1652867791,"L6: Past second desk" 282 | 1652867793,"L7: Up to stairs" 283 | 1652867918,"L6: Past second desk" 284 | 1652868022,"L5: Past first desk" 285 | 1652868349,"L6: Past second desk" 286 | 1652868351,"L6: Past second desk" 287 | 1652868552,"L6: Past second desk" 288 | 1652868585,"L6: Past second desk" 289 | 1652869000,"L4: Starting to corner" 290 | 1652869003,"L4: Starting to corner" 291 | 1652869025,"L5: Past first desk" 292 | 1652869042,"L0: Virtually empty" 293 | 1652869050,"L5: Past first desk" 294 | 1652869585,"L4: Starting to corner" 295 | 1652869819,"L5: Past first desk" 296 | 1652869852,"L4: Starting to corner" 297 | 1652869991,"L4: Starting to corner" 298 | 1652870021,"L5: Past first desk" 299 | 1652870037,"L5: Past first desk" 300 | 1652870046,"L5: Past first desk" 301 | 1652870130,"L5: Past first desk" 302 | 1652870134,"L5: Past first desk" 303 | 1652870288,"L6: Past second desk" 304 | 1652870321,"L7: Up to stairs" 305 | 1652870329,"L8: Even longer" 306 | 1652870523,"L7: Up to stairs" 307 | 1652870740,"L7: Up to stairs" 308 | 1652870833,"L6: Past second desk" 309 | 1652870991,"L4: Starting to corner" 310 | 1652871158,"L5: Past first desk" 311 | 1652871898,"L3: Within first room" 312 | 1652872158,"L3: Within first room" 313 | 1652872353,"L2: Up to food trays" 314 | 1652880540,"L8: Even longer" 315 | 1652880540,"L8: Even longer" 316 | 1652880541,"L8: Even longer" 317 | 1652880541,"L8: Even longer" 318 | 1652880541,"L8: Even longer" 319 | 1652880542,"L8: Even longer" 320 | 1652880542,"L8: Even longer" 321 | 1652880543,"L8: Even longer" 322 | 1652880543,"L8: Even longer" 323 | 1652880543,"L8: Even longer" 324 | 1652880543,"L8: Even longer" 325 | 1652880544,"L8: Even longer" 326 | 1652951448,"L0: Virtually empty" 327 | 1652954090,"L5: Past first desk" 328 | 1652954561,"L4: Starting to corner" 329 | 1652954628,"L4: Starting to corner" 330 | 1652954973,"L3: Within first room" 331 | 1652955206,"L3: Within first room" 332 | 1652955350,"L4: Starting to corner" 333 | 1652955354,"L4: Starting to corner" 334 | 1652955467,"L4: Starting to corner" 335 | 1652955655,"L4: Starting to corner" 336 | 1652955746,"L5: Past first desk" 337 | 1652956109,"L3: Within first room" 338 | 1652956128,"L3: Within first room" 339 | 1652956465,"L4: Starting to corner" 340 | 1652956483,"L4: Starting to corner" 341 | 1652956553,"L5: Past first desk" 342 | 1652956553,"L4: Starting to corner" 343 | 1652956561,"L5: Past first desk" 344 | 1652956632,"L5: Past first desk" 345 | 1652956744,"L6: Past second desk" 346 | 1652956755,"L6: Past second desk" 347 | 1652956947,"L5: Past first desk" 348 | 1652957043,"L5: Past first desk" 349 | 1652957154,"L5: Past first desk" 350 | 1652957190,"L4: Starting to corner" 351 | 1652957231,"L4: Starting to corner" 352 | 1652957285,"L4: Starting to corner" 353 | 1652957406,"L3: Within first room" 354 | 1652958616,"L4: Starting to corner" 355 | 1652959220,"L2: Up to food trays" 356 | 1652959805,"L1: Within kitchen" 357 | 1652959834,"L1: Within kitchen" 358 | 1652959864,"L0: Virtually empty" 359 | 1652960956,"L3: Within first room" 360 | 1652961523,"L5: Past first desk" 361 | 1652961527,"L5: Past first desk" 362 | 1652961529,"L5: Past first desk" 363 | 1652961530,"L5: Past first desk" 364 | 1653033737,"L0: Virtually empty" 365 | 1653041249,"L1: Within kitchen" 366 | 1653041707,"L1: Within kitchen" 367 | 1653042605,"L3: Within first room" 368 | 1653042663,"L3: Within first room" 369 | 1653042744,"L2: Up to food trays" 370 | 1653043036,"L1: Within kitchen" 371 | 1653044355,"L0: Virtually empty" 372 | 1653297459,"L0: Virtually empty" 373 | 1653297474,"L0: Virtually empty" 374 | 1653300288,"L1: Within kitchen" 375 | 1653300543,"L2: Up to food trays" 376 | 1653301212,"L1: Within kitchen" 377 | 1653301223,"L1: Within kitchen" 378 | 1653301469,"L0: Virtually empty" 379 | 1653302007,"L0: Virtually empty" 380 | 1653302512,"L3: Within first room" 381 | 1653302550,"L3: Within first room" 382 | 1653302560,"L3: Within first room" 383 | 1653302780,"L3: Within first room" 384 | 1653303396,"L3: Within first room" 385 | 1653306227,"L0: Virtually empty" 386 | 1653384116,"L0: Virtually empty" 387 | 1653386711,"L4: Starting to corner" 388 | 1653387989,"L1: Within kitchen" 389 | 1653388040,"L1: Within kitchen" 390 | 1653388520,"L3: Within first room" 391 | 1653388527,"L3: Within first room" 392 | 1653388558,"L3: Within first room" 393 | 1653388574,"L3: Within first room" 394 | 1653388623,"L3: Within first room" 395 | 1653388648,"L3: Within first room" 396 | 1653388697,"L3: Within first room" 397 | 1653388745,"L4: Starting to corner" 398 | 1653388778,"L3: Within first room" 399 | 1653388887,"L3: Within first room" 400 | 1653388889,"L3: Within first room" 401 | 1653388889,"L3: Within first room" 402 | 1653388890,"L3: Within first room" 403 | 1653388890,"L3: Within first room" 404 | 1653388890,"L3: Within first room" 405 | 1653388890,"L3: Within first room" 406 | 1653388890,"L3: Within first room" 407 | 1653388890,"L3: Within first room" 408 | 1653388891,"L3: Within first room" 409 | 1653388891,"L3: Within first room" 410 | 1653388891,"L3: Within first room" 411 | 1653388891,"L3: Within first room" 412 | 1653388892,"L3: Within first room" 413 | 1653388892,"L3: Within first room" 414 | 1653388892,"L3: Within first room" 415 | 1653388893,"L3: Within first room" 416 | 1653388893,"L3: Within first room" 417 | 1653388893,"L3: Within first room" 418 | 1653388893,"L3: Within first room" 419 | 1653388893,"L3: Within first room" 420 | 1653388894,"L3: Within first room" 421 | 1653388894,"L3: Within first room" 422 | 1653388894,"L3: Within first room" 423 | 1653388894,"L3: Within first room" 424 | 1653388895,"L3: Within first room" 425 | 1653388895,"L3: Within first room" 426 | 1653388895,"L3: Within first room" 427 | 1653389030,"L4: Starting to corner" 428 | 1653389317,"L3: Within first room" 429 | 1653389707,"L3: Within first room" 430 | 1653390900,"L0: Virtually empty" 431 | 1653392950,"L2: Up to food trays" 432 | 1653473481,"L0: Virtually empty" 433 | 1653474263,"L0: Virtually empty" 434 | 1653474881,"L3: Within first room" 435 | 1653475191,"L3: Within first room" 436 | 1653475286,"L3: Within first room" 437 | 1653475395,"L2: Up to food trays" 438 | 1653475499,"L2: Up to food trays" 439 | 1653476179,"L1: Within kitchen" 440 | 1653478485,"L1: Within kitchen" 441 | 1653903249,"L2: Up to food trays" 442 | 1653905040,"L0: Virtually empty" 443 | 1653905041,"L0: Virtually empty" 444 | 1653905048,"L1: Within kitchen" 445 | 1653905687,"L2: Up to food trays" 446 | 1653905928,"L1: Within kitchen" 447 | 1653905948,"L1: Within kitchen" 448 | 1653905957,"L1: Within kitchen" 449 | 1653906449,"L0: Virtually empty" 450 | 1653906620,"L0: Virtually empty" 451 | 1653906905,"L1: Within kitchen" 452 | 1653907011,"L1: Within kitchen" 453 | 1653907118,"L2: Up to food trays" 454 | 1653907317,"L3: Within first room" 455 | 1653907749,"L2: Up to food trays" 456 | 1653991147,"L7: Up to stairs" 457 | 1653992192,"L6: Past second desk" 458 | 1653992510,"L5: Past first desk" 459 | 1653992866,"L4: Starting to corner" 460 | 1653993012,"L4: Starting to corner" 461 | 1653993159,"L3: Within first room" 462 | 1653993278,"L4: Starting to corner" 463 | 1653993431,"L5: Past first desk" 464 | 1653993469,"L5: Past first desk" 465 | 1653993487,"L5: Past first desk" 466 | 1653993554,"L7: Up to stairs" 467 | 1653993582,"L7: Up to stairs" 468 | 1653993593,"L7: Up to stairs" 469 | 1653993597,"L8: Even longer" 470 | 1653993623,"L7: Up to stairs" 471 | 1653993712,"L6: Past second desk" 472 | 1653993723,"L7: Up to stairs" 473 | 1653993730,"L6: Past second desk" 474 | 1653993845,"L6: Past second desk" 475 | 1653993876,"L6: Past second desk" 476 | 1653993958,"L6: Past second desk" 477 | 1653994107,"L7: Up to stairs" 478 | 1653994536,"L5: Past first desk" 479 | 1653994660,"L4: Starting to corner" 480 | 1653994670,"L4: Starting to corner" 481 | 1654076818,"L7: Up to stairs" 482 | 1654078683,"L0: Virtually empty" 483 | 1654079405,"L1: Within kitchen" 484 | 1654079864,"L2: Up to food trays" 485 | 1654079988,"L3: Within first room" 486 | 1654080152,"L3: Within first room" 487 | 1654080219,"L2: Up to food trays" 488 | 1654161640,"L0: Virtually empty" 489 | 1654161642,"L0: Virtually empty" 490 | 1654165023,"L0: Virtually empty" 491 | 1654165886,"L2: Up to food trays" 492 | 1654166075,"L1: Within kitchen" 493 | 1654166188,"L1: Within kitchen" 494 | 1654166372,"L0: Virtually empty" 495 | 1654166509,"L2: Up to food trays" 496 | 1654167084,"L2: Up to food trays" 497 | 1654247743,"L0: Virtually empty" 498 | 1654247757,"L0: Virtually empty" 499 | 1654251091,"L1: Within kitchen" 500 | 1654251343,"L0: Virtually empty" 501 | 1654251626,"L0: Virtually empty" 502 | 1654593150,"L0: Virtually empty" 503 | 1654596644,"L1: Within kitchen" 504 | 1654597745,"L1: Within kitchen" 505 | 1654598406,"L2: Up to food trays" 506 | 1654598406,"L2: Up to food trays" 507 | 1654598423,"L2: Up to food trays" 508 | 1654598623,"L4: Starting to corner" 509 | 1654682707,"L2: Up to food trays" 510 | 1654684488,"L0: Virtually empty" 511 | 1654685290,"L0: Virtually empty" 512 | 1654768325,"L4: Starting to corner" 513 | 1654769617,"L3: Within first room" 514 | 1654770036,"L2: Up to food trays" 515 | 1654770122,"L3: Within first room" 516 | 1654770356,"L3: Within first room" 517 | 1654770472,"L4: Starting to corner" 518 | 1654771095,"L2: Up to food trays" 519 | 1654771115,"L1: Within kitchen" 520 | 1654771141,"L1: Within kitchen" 521 | 1654771191,"L2: Up to food trays" 522 | 1654771219,"L3: Within first room" 523 | 1654771259,"L4: Starting to corner" 524 | 1654771336,"L4: Starting to corner" 525 | 1654771536,"L3: Within first room" 526 | 1654771608,"L4: Starting to corner" 527 | 1654854564,"L0: Virtually empty" 528 | 1654858335,"L0: Virtually empty" 529 | 1654858972,"L0: Virtually empty" 530 | 1655113948,"L4: Starting to corner" 531 | 1655114190,"L5: Past first desk" 532 | 1655114512,"L4: Starting to corner" 533 | 1655116241,"L0: Virtually empty" 534 | 1655116253,"L0: Virtually empty" 535 | 1655118358,"L0: Virtually empty" 536 | 1655118566,"L0: Virtually empty" 537 | 1655118629,"L1: Within kitchen" 538 | 1655201713,"L4: Starting to corner" 539 | 1655202087,"L4: Starting to corner" 540 | 1655202138,"L3: Within first room" 541 | 1655202452,"L3: Within first room" 542 | 1655202546,"L3: Within first room" 543 | 1655203010,"L4: Starting to corner" 544 | 1655286741,"L4: Starting to corner" 545 | 1655288031,"L3: Within first room" 546 | 1655288099,"L5: Past first desk" 547 | 1655288797,"L7: Up to stairs" 548 | 1655288875,"L6: Past second desk" 549 | 1655289003,"L7: Up to stairs" 550 | 1655289054,"L8: Even longer" 551 | 1655289104,"L7: Up to stairs" 552 | 1655289941,"L5: Past first desk" 553 | 1655289987,"L7: Up to stairs" 554 | 1655290281,"L3: Within first room" 555 | 1655290418,"L3: Within first room" 556 | 1655291701,"L0: Virtually empty" 557 | 1655374658,"L2: Up to food trays" 558 | 1655374706,"L3: Within first room" 559 | 1655374844,"L1: Within kitchen" 560 | 1655375971,"L2: Up to food trays" 561 | 1655376141,"L2: Up to food trays" 562 | 1655377060,"L1: Within kitchen" 563 | 1655460778,"L1: Within kitchen" 564 | 1655461637,"L0: Virtually empty" 565 | 1655717665,"L0: Virtually empty" 566 | 1655719488,"L0: Virtually empty" 567 | 1655720487,"L0: Virtually empty" 568 | 1655723229,"L0: Virtually empty" 569 | 1655806522,"L5: Past first desk" 570 | 1655806594,"L6: Past second desk" 571 | 1655806596,"L6: Past second desk" 572 | 1655807376,"L3: Within first room" 573 | 1655807384,"L2: Up to food trays" 574 | 1655807615,"L2: Up to food trays" 575 | 1655807796,"L3: Within first room" 576 | 1655807834,"L5: Past first desk" 577 | 1655807924,"L4: Starting to corner" 578 | 1655808241,"L4: Starting to corner" 579 | 1655808504,"L5: Past first desk" 580 | 1655809156,"L2: Up to food trays" 581 | 1655809180,"L2: Up to food trays" 582 | 1655809657,"L1: Within kitchen" 583 | 1655812249,"L2: Up to food trays" 584 | 1655892850,"L1: Within kitchen" 585 | 1655893904,"L2: Up to food trays" 586 | 1655894488,"L4: Starting to corner" 587 | 1655894525,"L4: Starting to corner" 588 | 1655894731,"L4: Starting to corner" 589 | 1655894808,"L3: Within first room" 590 | 1655894907,"L2: Up to food trays" 591 | 1655895366,"L0: Virtually empty" 592 | 1655902543,"L2: Up to food trays" 593 | 1655977242,"L0: Virtually empty" 594 | 1655979974,"L0: Virtually empty" 595 | 1655980787,"L1: Within kitchen" 596 | 1655981817,"L0: Virtually empty" 597 | 1655983095,"L0: Virtually empty" 598 | 1656064450,"L0: Virtually empty" 599 | 1656065887,"L0: Virtually empty" 600 | 1656067124,"L1: Within kitchen" 601 | 1656067140,"L0: Virtually empty" 602 | 1656322212,"L1: Within kitchen" 603 | 1656322515,"L1: Within kitchen" 604 | 1656324479,"L3: Within first room" 605 | 1656325375,"L1: Within kitchen" 606 | 1656326350,"L1: Within kitchen" 607 | 1656326590,"L2: Up to food trays" 608 | 1656411110,"L3: Within first room" 609 | 1656412444,"L0: Virtually empty" 610 | 1656412800,"L0: Virtually empty" 611 | 1656412822,"L3: Within first room" 612 | 1656412947,"L3: Within first room" 613 | 1656413249,"L5: Past first desk" 614 | 1656413251,"L5: Past first desk" 615 | 1656413255,"L5: Past first desk" 616 | 1656413423,"L4: Starting to corner" 617 | 1656413726,"L2: Up to food trays" 618 | 1656498820,"L0: Virtually empty" 619 | 1656498862,"L1: Within kitchen" 620 | 1656499026,"L2: Up to food trays" 621 | 1656499447,"L1: Within kitchen" 622 | 1656583739,"L1: Within kitchen" 623 | 1656584068,"L1: Within kitchen" 624 | 1656585527,"L2: Up to food trays" 625 | 1656585702,"L1: Within kitchen" 626 | 1656586076,"L1: Within kitchen" 627 | 1656588918,"L1: Within kitchen" 628 | 1656672012,"L0: Virtually empty" 629 | 1656929175,"L1: Within kitchen" 630 | 1656929208,"L1: Within kitchen" 631 | 1656930110,"L0: Virtually empty" 632 | 1656931054,"L1: Within kitchen" 633 | 1656933025,"L0: Virtually empty" 634 | 1657016170,"L1: Within kitchen" 635 | 1657017378,"L1: Within kitchen" 636 | 1657017967,"L0: Virtually empty" 637 | 1657017997,"L3: Within first room" 638 | 1657103800,"L2: Up to food trays" 639 | 1657103834,"L3: Within first room" 640 | 1657104150,"L4: Starting to corner" 641 | 1657105521,"L0: Virtually empty" 642 | 1657106783,"L3: Within first room" 643 | 1657190145,"L0: Virtually empty" 644 | 1657191800,"L1: Within kitchen" 645 | 1657191801,"L0: Virtually empty" 646 | 1657276336,"L0: Virtually empty" 647 | 1657277421,"L0: Virtually empty" 648 | 1657535202,"L1: Within kitchen" 649 | 1657535846,"L0: Virtually empty" 650 | 1657536970,"L0: Virtually empty" 651 | 1657619304,"L5: Past first desk" 652 | 1657622102,"L1: Within kitchen" 653 | 1657622145,"L2: Up to food trays" 654 | 1657622497,"L3: Within first room" 655 | 1657622695,"L4: Starting to corner" 656 | 1657622711,"L4: Starting to corner" 657 | 1657622735,"L3: Within first room" 658 | 1657623494,"L0: Virtually empty" 659 | 1657624108,"L0: Virtually empty" 660 | 1657706881,"L0: Virtually empty" 661 | 1657707887,"L4: Starting to corner" 662 | 1657708137,"L3: Within first room" 663 | 1657708177,"L4: Starting to corner" 664 | 1657708606,"L5: Past first desk" 665 | 1657708624,"L4: Starting to corner" 666 | 1657708742,"L5: Past first desk" 667 | 1657708795,"L5: Past first desk" 668 | 1657708883,"L5: Past first desk" 669 | 1657709322,"L4: Starting to corner" 670 | 1657709542,"L3: Within first room" 671 | 1657710476,"L0: Virtually empty" 672 | 1657711096,"L0: Virtually empty" 673 | 1657793350,"L2: Up to food trays" 674 | 1657793577,"L2: Up to food trays" 675 | 1657793614,"L3: Within first room" 676 | 1657793805,"L3: Within first room" 677 | 1657794742,"L0: Virtually empty" 678 | 1657794798,"L1: Within kitchen" 679 | 1657794841,"L2: Up to food trays" 680 | 1657795150,"L3: Within first room" 681 | 1657795160,"L3: Within first room" 682 | 1657798578,"L0: Virtually empty" 683 | 1657798645,"L1: Within kitchen" 684 | 1657881373,"L2: Up to food trays" 685 | 1657882592,"L0: Virtually empty" 686 | 1658138534,"L0: Virtually empty" 687 | 1658138831,"L0: Virtually empty" 688 | 1658139590,"L0: Virtually empty" 689 | 1658139984,"L1: Within kitchen" 690 | 1658140498,"L0: Virtually empty" 691 | 1658142600,"L0: Virtually empty" 692 | 1658226283,"L3: Within first room" 693 | 1658226776,"L3: Within first room" 694 | 1658227048,"L2: Up to food trays" 695 | 1658227305,"L2: Up to food trays" 696 | 1658229233,"L1: Within kitchen" 697 | 1658229809,"L0: Virtually empty" 698 | 1658314788,"L0: Virtually empty" 699 | 1658399893,"L2: Up to food trays" 700 | 1658400148,"L2: Up to food trays" 701 | 1658400184,"L2: Up to food trays" 702 | 1658401023,"L0: Virtually empty" 703 | 1658485051,"L0: Virtually empty" 704 | 1658492075,"L0: Virtually empty" 705 | 1658745256,"L0: Virtually empty" 706 | 1658745303,"L1: Within kitchen" 707 | 1658745393,"L0: Virtually empty" 708 | 1658745611,"L1: Within kitchen" 709 | 1658745748,"L1: Within kitchen" 710 | 1658753983,"L8: Even longer" 711 | 1658753985,"L0: Virtually empty" 712 | 1658831442,"L1: Within kitchen" 713 | 1658831454,"L1: Within kitchen" 714 | 1658831750,"L0: Virtually empty" 715 | 1658831760,"L1: Within kitchen" 716 | 1658831804,"L2: Up to food trays" 717 | 1658831804,"L2: Up to food trays" 718 | 1658832320,"L2: Up to food trays" 719 | 1658917156,"L2: Up to food trays" 720 | 1658917825,"L1: Within kitchen" 721 | 1658923882,"L0: Virtually empty" 722 | 1659004164,"L1: Within kitchen" 723 | 1659004775,"L0: Virtually empty" 724 | 1659090191,"L0: Virtually empty" 725 | 1659349700,"L0: Virtually empty" 726 | 1659435152,"L0: Virtually empty" 727 | 1659435887,"L1: Within kitchen" 728 | 1659436592,"L0: Virtually empty" 729 | 1659444952,"L8: Even longer" 730 | 1659444967,"L0: Virtually empty" 731 | 1659512737,"L0: Virtually empty" 732 | 1659514870,"L0: Virtually empty" 733 | 1659521129,"L2: Up to food trays" 734 | 1659522367,"L0: Virtually empty" 735 | 1659531026,"L0: Virtually empty" 736 | 1659531405,"L0: Virtually empty" 737 | 1659531408,"L0: Virtually empty" 738 | 1659531408,"L0: Virtually empty" 739 | 1659531409,"L0: Virtually empty" 740 | 1659531410,"L0: Virtually empty" 741 | 1659531410,"L0: Virtually empty" 742 | 1659531411,"L0: Virtually empty" 743 | 1659531411,"L0: Virtually empty" 744 | 1659531411,"L0: Virtually empty" 745 | 1659531412,"L0: Virtually empty" 746 | 1659531412,"L0: Virtually empty" 747 | 1659531412,"L0: Virtually empty" 748 | 1659531413,"L0: Virtually empty" 749 | 1659531413,"L0: Virtually empty" 750 | 1659531413,"L0: Virtually empty" 751 | 1659531413,"L0: Virtually empty" 752 | 1659531413,"L0: Virtually empty" 753 | 1659531414,"L0: Virtually empty" 754 | 1659531414,"L0: Virtually empty" 755 | 1659531414,"L0: Virtually empty" 756 | 1659531414,"L0: Virtually empty" 757 | 1659531415,"L0: Virtually empty" 758 | 1659531415,"L0: Virtually empty" 759 | 1659531415,"L0: Virtually empty" 760 | 1659531415,"L0: Virtually empty" 761 | 1659531416,"L0: Virtually empty" 762 | 1659531416,"L0: Virtually empty" 763 | 1659531417,"L0: Virtually empty" 764 | 1659531417,"L0: Virtually empty" 765 | 1659531417,"L0: Virtually empty" 766 | 1659531418,"L0: Virtually empty" 767 | 1659531418,"L0: Virtually empty" 768 | 1659531418,"L0: Virtually empty" 769 | 1659531418,"L0: Virtually empty" 770 | 1659531419,"L0: Virtually empty" 771 | 1659531419,"L0: Virtually empty" 772 | 1659531419,"L0: Virtually empty" 773 | 1659531419,"L0: Virtually empty" 774 | 1659531420,"L0: Virtually empty" 775 | 1659531420,"L0: Virtually empty" 776 | 1659531420,"L0: Virtually empty" 777 | 1659531420,"L0: Virtually empty" 778 | 1659531420,"L0: Virtually empty" 779 | 1659531421,"L0: Virtually empty" 780 | 1659531421,"L0: Virtually empty" 781 | 1659531421,"L0: Virtually empty" 782 | 1659531421,"L0: Virtually empty" 783 | 1659531422,"L0: Virtually empty" 784 | 1659531422,"L0: Virtually empty" 785 | 1659531422,"L0: Virtually empty" 786 | 1659531423,"L0: Virtually empty" 787 | 1659531423,"L0: Virtually empty" 788 | 1659531423,"L0: Virtually empty" 789 | 1659531423,"L0: Virtually empty" 790 | 1659531424,"L0: Virtually empty" 791 | 1659531424,"L0: Virtually empty" 792 | 1659531424,"L0: Virtually empty" 793 | 1659531424,"L0: Virtually empty" 794 | 1659531425,"L0: Virtually empty" 795 | 1659531425,"L0: Virtually empty" 796 | 1659531425,"L0: Virtually empty" 797 | 1659531425,"L0: Virtually empty" 798 | 1659531426,"L0: Virtually empty" 799 | 1659531426,"L0: Virtually empty" 800 | 1659531426,"L0: Virtually empty" 801 | 1659531427,"L0: Virtually empty" 802 | 1659531427,"L0: Virtually empty" 803 | 1659531427,"L0: Virtually empty" 804 | 1659531427,"L0: Virtually empty" 805 | 1659531428,"L0: Virtually empty" 806 | 1659531428,"L0: Virtually empty" 807 | 1659531428,"L0: Virtually empty" 808 | 1659531428,"L0: Virtually empty" 809 | 1659531428,"L0: Virtually empty" 810 | 1659531429,"L0: Virtually empty" 811 | 1659531429,"L0: Virtually empty" 812 | 1659531429,"L0: Virtually empty" 813 | 1659531429,"L0: Virtually empty" 814 | 1659531429,"L0: Virtually empty" 815 | 1659531430,"L0: Virtually empty" 816 | 1659531430,"L0: Virtually empty" 817 | 1659531430,"L0: Virtually empty" 818 | 1659531430,"L0: Virtually empty" 819 | 1659531431,"L0: Virtually empty" 820 | 1659531431,"L0: Virtually empty" 821 | 1659531431,"L0: Virtually empty" 822 | 1659531432,"L0: Virtually empty" 823 | 1659531432,"L0: Virtually empty" 824 | 1659531432,"L0: Virtually empty" 825 | 1659531432,"L0: Virtually empty" 826 | 1659531432,"L0: Virtually empty" 827 | 1659531432,"L0: Virtually empty" 828 | 1659531445,"L0: Virtually empty" 829 | 1659531446,"L0: Virtually empty" 830 | 1659531447,"L0: Virtually empty" 831 | 1659531448,"L0: Virtually empty" 832 | 1659531448,"L0: Virtually empty" 833 | 1659599583,"L0: Virtually empty" 834 | 1659600134,"L0: Virtually empty" 835 | 1659600258,"L0: Virtually empty" 836 | 1659693688,"L0: Virtually empty" 837 | 1659695890,"L0: Virtually empty" 838 | 1660042872,"L0: Virtually empty" 839 | 1660127940,"L0: Virtually empty" 840 | 1660204030,"L0: Virtually empty" 841 | 1660214264,"L0: Virtually empty" 842 | 1660300541,"L2: Up to food trays" 843 | 1660300779,"L1: Within kitchen" 844 | 1660558591,"L4: Starting to corner" 845 | 1660559682,"L4: Starting to corner" 846 | 1660559807,"L4: Starting to corner" 847 | 1660560044,"L4: Starting to corner" 848 | 1660644937,"L4: Starting to corner" 849 | 1660644952,"L3: Within first room" 850 | 1660645329,"L3: Within first room" 851 | 1660645589,"L3: Within first room" 852 | 1660645991,"L2: Up to food trays" 853 | 1660731992,"L0: Virtually empty" 854 | 1660733118,"L2: Up to food trays" 855 | 1660733139,"L2: Up to food trays" 856 | 1660733139,"L2: Up to food trays" 857 | 1660904618,"L1: Within kitchen" 858 | 1661855123,"L0: Virtually empty" 859 | 1661857253,"L8: Even longer" 860 | 1661857631,"L8: Even longer" 861 | 1661858601,"L6: Past second desk" 862 | 1661858828,"L5: Past first desk" 863 | 1661859026,"L4: Starting to corner" 864 | 1661859205,"L3: Within first room" 865 | 1661945928,"L0: Virtually empty" 866 | 1662374739,"L8: Even longer" 867 | 1662376308,"L5: Past first desk" 868 | 1662462683,"L4: Starting to corner" 869 | 1662544695,"L8: Even longer" 870 | 1662544699,"L8: Even longer" 871 | 1662545979,"L7: Up to stairs" 872 | 1662547552,"L8: Even longer" 873 | 1662547566,"L8: Even longer" 874 | 1662547567,"L8: Even longer" 875 | 1662547568,"L8: Even longer" 876 | 1662550821,"L0: Virtually empty" 877 | 1662979810,"L3: Within first room" 878 | 1663063881,"L3: Within first room" 879 | 1663063892,"L3: Within first room" 880 | 1663066395,"L2: Up to food trays" 881 | 1663151268,"L0: Virtually empty" 882 | 1663237532,"L2: Up to food trays" 883 | 1663238033,"L1: Within kitchen" 884 | 1663238966,"L3: Within first room" 885 | 1663668008,"L0: Virtually empty" 886 | 1663672825,"L3: Within first room" 887 | 1663931387,"L3: Within first room" 888 | 1663931612,"L3: Within first room" 889 | -------------------------------------------------------------------------------- /analysis/scripts/weekday_analysis.py: -------------------------------------------------------------------------------- 1 | """Script that takes the csv files generated by the ansible file 2 | from the mensaqueuebot report DB and generates one graph 3 | per weekday. Path to csv file is defined at the top of this file. 4 | """ 5 | import csv 6 | from datetime import datetime, date 7 | 8 | import pytz 9 | import matplotlib.pyplot as plt 10 | 11 | 12 | CSV_FILE_PATH = "../data/queueReports.csv" 13 | RELEVANT_SEMESTER = "WS22/23" 14 | SEMESTER_START_DATES = { 15 | "SS22": "2022-04-19 +0200", 16 | "WS22/23": "2022-10-17 +0200", 17 | } 18 | 19 | SEMESTER_END_DATES = { 20 | "SS22": "2022-07-29 +0200", 21 | "WS22/23": "2023-02-10 +0200", 22 | } 23 | 24 | 25 | 26 | 27 | def load_csv(): 28 | """Reads the given csv file and returns an array of tuples of (time/length) 29 | expects the csv to be structures as 30 | timestamp, length measurement 31 | """ 32 | array_of_measurements = [] 33 | 34 | with open(CSV_FILE_PATH) as csv_file: 35 | csv_reader = csv.reader(csv_file) 36 | for row in csv_reader: 37 | if row[0].isdigit(): 38 | array_of_measurements.append((int(row[0]), row[1])) 39 | return array_of_measurements 40 | 41 | 42 | def filter_for_semester(array_of_measurements, semester_key): 43 | """For the given list of measurements returns a list which only contains 44 | those measurements that were taken within a semester. 45 | 46 | Which semester is used is defined by the key passed into this function. 47 | Key needs to correspond to keys used in SEMESTER_START_DATES and 48 | SEMESTER_END_DATES.""" 49 | 50 | # Equivalent to Potsdam time, or close enough to not matter: We don't expect 51 | # any reports in the night 52 | semester_start_date_string = SEMESTER_START_DATES[semester_key] 53 | semester_end_date_string = SEMESTER_END_DATES[semester_key] 54 | 55 | 56 | # Take this information from https://www.uni-potsdam.de/de/studium/termine/semestertermine 57 | semester_start_date = datetime.strptime(semester_start_date_string, "%Y-%m-%d %z") 58 | # One day after to catch last day) 59 | semester_end_date = datetime.strptime(semester_end_date_string, "%Y-%m-%d %z") 60 | 61 | 62 | return list(filter(lambda x: \ 63 | semester_start_date \ 64 | <= datetime.fromtimestamp(x[0], tz=pytz.timezone('Europe/Berlin')) \ 65 | <= semester_end_date, array_of_measurements)) 66 | 67 | 68 | 69 | def sort_into_weekdays(array_of_measurements): 70 | """Expects a list of all measurements made. 71 | Returns a list with seven elements, each representing a weekday, 72 | starting with monday. 73 | Each of these elements is a list itself, think bucket, 74 | that contains all measurements that were made on that weekday""" 75 | day_buckets = [] 76 | # order is monday, tuesday, ..., saturday, sunday 77 | for _ in range(7): 78 | day_buckets.append([]) 79 | 80 | 81 | for measurement in array_of_measurements: 82 | timestamp = datetime.fromtimestamp(measurement[0], tz=pytz.timezone('Europe/Berlin')) 83 | weekday_of_measurement = timestamp.weekday() # weekdays are from 0 to 6 84 | day_buckets[weekday_of_measurement].append(measurement) 85 | 86 | return day_buckets 87 | 88 | 89 | def normalize_measurement_tuple(m_tuple): 90 | """Given a tuple of (timestamp, string), this 91 | normalizes the timestamp to include just time, not date, and the 92 | string to an int. \ 93 | All timestamps returned by this function keep their time, 94 | but now happen on the same day. 95 | For the labels each LX value is converted to the appropriate X""" 96 | # Normalize Time 97 | time = datetime.fromtimestamp(m_tuple[0], tz=pytz.timezone('Europe/Berlin')).time() 98 | time_normalized = datetime.combine(date(2022,1,1), time) 99 | # Normalize Label 100 | label_as_int = int(m_tuple[1][1]) 101 | 102 | return (time_normalized, label_as_int) 103 | 104 | def get_list_of_mensa_opening_times(): 105 | """Returns a list containing datetime objects, 106 | one for each hour the mensa is open""" 107 | opening_times = [] 108 | hour_counter = 8 109 | for i in range(8): 110 | opening_times.append(datetime(2022,1,1,hour_counter+i)) 111 | 112 | return opening_times 113 | 114 | 115 | def get_day_of_week_string_from_measurement(measurement): 116 | """Expects a measurement. Returns a string naming 117 | the weekday on which that measurement was taken""" 118 | days = ["Monday", "Tuesday", "Wednesday", 119 | "Thursday", "Friday", "Saturday", "Sunday"] 120 | weekday_number = datetime.fromtimestamp(measurement[0]).weekday() 121 | return days[weekday_number] 122 | 123 | 124 | def illustrate_single_weekday(list_of_measurement_tuples, active_semester): 125 | """Gets a list of touples containing measurements of a single 126 | day. Creates a graph for those measurements""" 127 | 128 | tuples_to_illustrate = [] 129 | x_axis_points = [] 130 | y_axis_points = [] 131 | 132 | 133 | day_of_week = get_day_of_week_string_from_measurement(list_of_measurement_tuples[0]) 134 | 135 | # Normalize all measurements 136 | for measurement in list_of_measurement_tuples: 137 | normalized_tuple = normalize_measurement_tuple(measurement) 138 | if(normalized_tuple[0].hour < 15 # End of mensa opening time 139 | and normalized_tuple[0].hour > 8): # Start of mensa opening time 140 | tuples_to_illustrate.append(normalized_tuple) 141 | 142 | 143 | # Matplotlib requires two sorted lists, so create those 144 | tuples_to_illustrate.sort(key=lambda t: t[0]) 145 | for measurement in tuples_to_illustrate: 146 | x_axis_points.append(measurement[0]) 147 | y_axis_points.append(measurement[1]) 148 | 149 | 150 | 151 | 152 | y_ticks_labels = ["L0: Virtually empty", 153 | "L1: Within kitchen", 154 | "L2: Up to food trays", 155 | "L3: Within first room", 156 | "L4: Starting to corner", 157 | "L5: Past first desk", 158 | "L6: Past second desk", 159 | "L7: Up to stairs", 160 | "L8: Even longer"] 161 | x_ticks_labels = ["08:00", "09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00"] 162 | 163 | _, axe= plt.subplots() 164 | 165 | axe.set_xticks(get_list_of_mensa_opening_times()) 166 | axe.set_xticklabels(x_ticks_labels) 167 | axe.set_yticks([0,1,2,3,4,5,6,7,8]) 168 | axe.set_yticklabels(y_ticks_labels) 169 | axe.plot(x_axis_points, y_axis_points) 170 | 171 | # plt.show() 172 | plt.title(active_semester + ": " + day_of_week) 173 | plt.tight_layout() 174 | plt.savefig(day_of_week + ".png", format='png', dpi=200) 175 | 176 | 177 | 178 | 179 | 180 | def main(): 181 | """Reads the csv defined at the top and 182 | generates five .png in this folder""" 183 | all_measurements = load_csv() 184 | measurements_in_current_semester = filter_for_semester(all_measurements, RELEVANT_SEMESTER) 185 | 186 | if len(measurements_in_current_semester) == 0: 187 | print("No measurements to display") 188 | return 189 | 190 | grouped_by_weekday = sort_into_weekdays(measurements_in_current_semester) 191 | for i in range(5): 192 | illustrate_single_weekday(grouped_by_weekday[i], RELEVANT_SEMESTER) 193 | 194 | 195 | if __name__=="__main__": 196 | main() 197 | -------------------------------------------------------------------------------- /changelog.psv: -------------------------------------------------------------------------------- 1 | 0| By the way, thank you so much for contributing! 2 | 1| The weekday graphs for the last semester can be found at https://github.com/ADimeo/MensaQueueBot/tree/master/analysis/data/SS22 . Do check them out, contributers like you made them possible! But keep in mind that lectures and mensa usage may change this semester 3 | 2| Great news! Not only can you collect points for your reports now (check /points_help for details), we're also proud to present the very first piece of crowdsourced data analysis (for the last semester), over at https://deepnote.com/@skn0tt/Mensa-Queue-Bot-Analytics-367dc54a-fc2f-4a38-a192-d5800af17360 4 | 3| News! In what is our biggest release yet, we've completely overhauled the /jetze command, and you can now look forward to fancy graphical reports. There's also a short survey at https://forms.gle/mq1X35ch1vx1Hb8U9 that we'll use to decide on our next features, feel free to fill it out while waiting in line! 5 | 4| Welcome to MensaQueueBot 2.0, where we go beyond crowdsourcing queue lengths, and into fully customizable mensa menu messaging! Check out /settings for the quick introduction, or https://github.com/ADimeo/MensaQueueBot/releases/tag/v2.0.0 for the full changelog, complain about bugs at @adimeo, and enjoy your newly found luxurious mensa experience! 6 | 5| I'm tired, so very tired, from sending out so many mensa menus for so long. I will sleep soon, but that is fine, all things end. 7 | -------------------------------------------------------------------------------- /db/migrations/000001_create_db.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE queueReports; 2 | DROP TABLE changelogMessages; 3 | DROP TABLE internetpoints; 4 | -------------------------------------------------------------------------------- /db/migrations/000001_create_db.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS queueReports ( 2 | id INTEGER NOT NULL PRIMARY KEY, 3 | reporter TEXT NOT NULL, 4 | time DATETIME NOT NULL, 5 | queueLength TEXT NOT NULL 6 | ); 7 | CREATE TABLE IF NOT EXISTS changelogMessages ( 8 | id INTEGER NOT NULL PRIMARY KEY, 9 | reporterID INTEGER UNIQUE NOT NULL, 10 | lastChangelog INTEGER NOT NULL 11 | ); 12 | 13 | CREATE TABLE IF NOT EXISTS internetpoints ( 14 | id INTEGER NOT NULL PRIMARY KEY, 15 | reporterID INTEGER UNIQUE NOT NULL, 16 | points INTEGER NOT NULL 17 | ); 18 | -------------------------------------------------------------------------------- /db/migrations/000002_add_ab_tests.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE changelogMessages DROP COLUMN ab_tester; 2 | -------------------------------------------------------------------------------- /db/migrations/000002_add_ab_tests.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE changelogMessages ADD COLUMN ab_tester INTEGER NOT NULL DEFAULT 0; 2 | -------------------------------------------------------------------------------- /db/migrations/000003_add_mensa_menu_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE mensaMenus; 2 | -------------------------------------------------------------------------------- /db/migrations/000003_add_mensa_menu_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS mensaMenus ( 2 | id INTEGER NOT NULL PRIMARY KEY, 3 | title TEXT NOT NULL, 4 | description TEXT NOT NULL, 5 | time DATETIME NOT NULL, 6 | counter INTEGER NOT NULL 7 | ); 8 | -------------------------------------------------------------------------------- /db/migrations/000004_add_mensa_preferences_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE mensaPreferences; 2 | -------------------------------------------------------------------------------- /db/migrations/000004_add_mensa_preferences_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS mensaPreferences ( 2 | id INTEGER NOT NULL PRIMARY KEY, 3 | reporterID INTEGER UNIQUE NOT NULL, 4 | wantsMensaMessages INTEGER NOT NULL, 5 | startTimeInCESTMinutes INTEGER, 6 | endTimeInCESTMinutes INTEGER, 7 | weekdayBitmap INTEGER, 8 | lastReportDate INTEGER 9 | ); 10 | -------------------------------------------------------------------------------- /db_connectors/db_connector.go: -------------------------------------------------------------------------------- 1 | /*Implements database logic related to storing and retrieving actual queue length reports 2 | */ 3 | package db_connectors 4 | 5 | import ( 6 | "crypto/rand" 7 | "crypto/sha256" 8 | "database/sql" 9 | "encoding/base64" 10 | "errors" 11 | "fmt" 12 | "time" 13 | 14 | _ "github.com/mattn/go-sqlite3" 15 | "go.uber.org/zap" 16 | 17 | "github.com/ADimeo/MensaQueueBot/utils" 18 | ) 19 | 20 | var globalPseudonymizationAttribute pseudonymizationAttribute 21 | 22 | type pseudonymizationAttribute struct { 23 | Timestamp time.Time 24 | Random string // 32 bytes of random, defined in getReporterPepper 25 | } 26 | 27 | /*Regenerates the global pseudonymization attribute. Fails silently if random 28 | can't be accessed for some reason. 29 | */ 30 | 31 | func initializeNewPseudonymizationAttribute(generationTime time.Time) { 32 | zap.S().Info("Regenerating attribute for pseudonymisation") 33 | newRandom, err := GenerateRandomString(32) 34 | if err == nil { 35 | // If we get an error that means we don't have enough random. Keep reusing old pepper, and hope random comes back. 36 | // But really, this shouldn't happen. 37 | globalPseudonymizationAttribute.Random = newRandom 38 | globalPseudonymizationAttribute.Timestamp = generationTime 39 | } else { 40 | zap.S().Error("Unable to get enough random!") 41 | } 42 | } 43 | 44 | // Returns an up to date pseudonymization attribute 45 | func getPseudonymizationAttribute() pseudonymizationAttribute { 46 | timestampNow := time.Now() 47 | dateToday := timestampNow.YearDay() 48 | 49 | attributeCreationDate := globalPseudonymizationAttribute.Timestamp.YearDay() 50 | if globalPseudonymizationAttribute.Random == "" || dateToday != attributeCreationDate { 51 | // Attribute is to old 52 | initializeNewPseudonymizationAttribute(timestampNow) 53 | } 54 | return globalPseudonymizationAttribute 55 | } 56 | 57 | // Returns the most recently reported queue length, as well as the reporting unix timestamp 58 | func GetLatestQueueLengthReport() (int, string) { 59 | queryString := "SELECT queueLength, MAX(time) from queueReports" 60 | 61 | var retrievedReportTime int 62 | var retrievedQueueLength string 63 | 64 | zap.S().Info("Querying for latest queue length report") 65 | db := GetDBHandle() 66 | if err := db.QueryRow(queryString).Scan(&retrievedQueueLength, &retrievedReportTime); err != nil { 67 | if err == sql.ErrNoRows { 68 | zap.S().Error("No rows returned when querying for latest queue length report") 69 | } else { 70 | zap.S().Error("Error while querying for latest report", err) 71 | } 72 | } 73 | 74 | zap.S().Infof("Queried most recent report from DB: Length %s at time %d", retrievedQueueLength, retrievedReportTime) 75 | return retrievedReportTime, retrievedQueueLength 76 | } 77 | 78 | /* getLengthsAndTimesFromRows Takes a query that contains (queueLength, times) results, 79 | and returns them as two arrays, containing the respective data. 80 | Times are returned in UTC. 81 | */ 82 | func getLengthsAndTimesFromRows(rows *sql.Rows) ([]string, []time.Time, error) { 83 | var err error 84 | var queueLengths []string 85 | var timesUTC []time.Time 86 | 87 | for rows.Next() { 88 | var length string 89 | var time time.Time 90 | if err = rows.Scan(&length, &time); err != nil { 91 | zap.S().Errorf("Error scanning for reports in weekday timeframe, likely data type mismatch", err) 92 | } 93 | queueLengths = append(queueLengths, length) 94 | timesUTC = append(timesUTC, time) 95 | } 96 | if err = rows.Err(); err != nil { 97 | zap.S().Errorf("Error while scanning for reports in timeframe", err) 98 | return queueLengths, timesUTC, err 99 | } 100 | zap.S().Debugf("Query for reports in timeframe returned %d reports", len(queueLengths)) 101 | return queueLengths, timesUTC, err 102 | } 103 | 104 | /* 105 | GetAllQueueLengthReportsInTimeframe returns all length reports that 106 | were made within timeframeIntoPast before now. 107 | Returns two slices: One with the report queue lengths, 108 | one with the times. Returns an err if no reports are 109 | available for that timeframe 110 | */ 111 | func GetAllQueueLengthReportsInTimeframe(nowUTC time.Time, timeframeIntoPast time.Duration) ([]string, []time.Time, error) { 112 | lowerLimit := nowUTC.Add(-timeframeIntoPast).Unix() 113 | 114 | queryString := "SELECT queueLength, time FROM queueReports WHERE time > ? " + // Get reports more recent than timeframe 115 | "AND strftime ('%s', queueReports.time, 'unixepoch') < strftime('%s', CAST(? AS TEXT)) " + // Data is not from the future, important for testing 116 | "ORDER BY time ASC;" 117 | 118 | var queueLengths []string 119 | var times []time.Time 120 | 121 | nowTimeString := nowUTC.Format("2006-01-02 15:04:05") 122 | zap.S().Debugw("Querying for all reports in timeframe", 123 | "interval", timeframeIntoPast, 124 | "lower limit", lowerLimit, 125 | "nowTimeString", nowTimeString) 126 | 127 | db := GetDBHandle() 128 | rows, err := db.Query(queryString, lowerLimit, nowTimeString) 129 | if err != nil { 130 | zap.S().Errorf("Error while querying for reports in timeframe", err) 131 | return queueLengths, times, err 132 | } 133 | defer rows.Close() 134 | return getLengthsAndTimesFromRows(rows) 135 | } 136 | 137 | /* timeObjectsIsInIntervalInCEST checks whether te given time is within the given 138 | time interval, as defined by the intervalStart and intervalEnd. This comparison 139 | happens in CEST, and not UTC, which makes it DST aware for for Germany. 140 | */ 141 | func timeObjectIsInIntervalInCEST(intervalStart time.Time, 142 | intervalEnd time.Time, 143 | timeToCheckInUTC time.Time) (bool, error) { 144 | if intervalStart.Year() != intervalEnd.Year() || intervalStart.YearDay() != intervalEnd.YearDay() { 145 | zap.S().Error("Caller is trying to use start/endtimes from different dates. Technically possible, likely unintended") 146 | return false, errors.New("intervalStart and intervalEnd have different dates!") 147 | } 148 | 149 | location := utils.GetLocalLocation() 150 | timezoneAwareElement := timeToCheckInUTC.In(location) 151 | normalizedTime := time.Date(intervalStart.Year(), intervalStart.Month(), intervalStart.Day(), 152 | timezoneAwareElement.Hour(), timezoneAwareElement.Minute(), timezoneAwareElement.Second(), 0, location) 153 | 154 | return normalizedTime.After(intervalStart) && normalizedTime.Before(intervalEnd), nil 155 | } 156 | 157 | /* removeDataOutsideOfIntervalInCEST takes a list of queue lengths and 158 | times, and removes all elements whose time is farther away from nowTimeUTC 159 | than the given timeframes. This comparison happens in CEST, which 160 | makes this function DST aware for Germany 161 | */ 162 | func removeDataOutsideOfIntervalInCEST(nowTimeUTC time.Time, 163 | timeframeIntoPast time.Duration, 164 | timeframeIntoFuture time.Duration, 165 | queueLengths []string, 166 | times []time.Time) ([]string, []time.Time) { 167 | 168 | location := utils.GetLocalLocation() 169 | nowTimeLocal := nowTimeUTC.In(location) 170 | intervalStartTime := nowTimeLocal.Add(-timeframeIntoPast) 171 | intervalEndTime := nowTimeLocal.Add(timeframeIntoFuture) 172 | 173 | var filteredLengths []string 174 | var filteredTimes []time.Time 175 | 176 | for i, element := range times { 177 | isInInterval, err := timeObjectIsInIntervalInCEST(intervalStartTime, intervalEndTime, element) 178 | if err != nil { 179 | // Something went wrong, but we can't really handle this. 180 | // let's still add the data, the error is being logged for future 181 | filteredLengths = append(filteredLengths, queueLengths[i]) 182 | filteredTimes = append(filteredTimes, times[i]) 183 | } 184 | if isInInterval { 185 | filteredLengths = append(filteredLengths, queueLengths[i]) 186 | filteredTimes = append(filteredTimes, times[i]) 187 | } 188 | } 189 | return filteredLengths, filteredTimes 190 | } 191 | 192 | /*GetQueueLengthReportsByWeekdayAdndTimeframe returns the following reports: 193 | - created at most daysOfDataToConsider before nowTime 194 | - Create at most timeframeIntoPast before noTimes timestamp 195 | - Create at most timeframeIntoFuture after noTimes timestamp 196 | - Not created today 197 | 198 | This function is DST aware: All data that falls into the given interval 199 | in CEST is returned even if it would be outside of the given interval 200 | in a pure UTC implementation. 201 | */ 202 | func GetQueueLengthReportsByWeekdayAndTimeframe(daysOfDataToConsider int8, 203 | nowTimeUTC time.Time, 204 | timeframeIntoPast time.Duration, 205 | timeframeIntoFuture time.Duration) ([]string, []time.Time, error) { 206 | // If daylight saving time changes 12:00 CEST can be 207 | // represented by 11:00 UTC or 10:00 UTC. SQLITE lacks 208 | // the awareness/information/built ins to have that distinction 209 | // in the DB. So we always query in intervals that include 2 extra 210 | // hours of data (one in each direction) and filter out the unnecessary 211 | // times in go, which is timezone/dst aware 212 | dstEqualizer, _ := time.ParseDuration("1h") 213 | 214 | // See https://www.sqlite.org/lang_datefunc.html for reference 215 | queryString := "SELECT queueLength, time from queueReports " + // Return the usual tuple 216 | "WHERE strftime('%s', ? , 'unixepoch') - strftime('%s',queueReports.time, 'unixepoch', CAST(? AS TEXT)) < 0 " + // If it was created within the last 30 days 217 | "AND CAST(? AS TEXT) = strftime('%w', queueReports.time, 'unixepoch') " + // On the given weekday 218 | "AND time(queueReports.time, 'unixepoch') > CAST(? AS TEXT) " + // Start of times we're interested in 219 | "AND time(queueReports.time, 'unixepoch') < CAST(? AS TEXT) " + // End of times we're interested in 220 | "AND date(queueReports.time, 'unixepoch') != CAST(? AS TEXT) " + // Data is not from today 221 | "AND strftime('%s', queueReports.time, 'unixepoch') < strftime('%s', ?, 'unixepoch');" // Data is not from the future, important for testing 222 | 223 | //Sqlite expects days we add in first strftime to be in NNN format, so let's add leading 0 224 | weekday := nowTimeUTC.Weekday() 225 | timeFrameInDaysString := fmt.Sprintf("%03d days", daysOfDataToConsider) 226 | 227 | nowTimestamp := nowTimeUTC.Unix() 228 | lowerTimeLimitString := nowTimeUTC.Add(-timeframeIntoPast).Add(-dstEqualizer).Format("15:04:05") 229 | upperTimeLimitString := nowTimeUTC.Add(timeframeIntoFuture).Add(dstEqualizer).Format("15:04:05") 230 | nowDateUTCString := nowTimeUTC.Format("2006-01-02") 231 | 232 | zap.S().Infow("Querying for weekdays reports in timeframe", 233 | "nowTimestamp", nowTimestamp, 234 | "timeFrameInDaysString", timeFrameInDaysString, 235 | "weekday", int(weekday), 236 | "lowerTimeLimitString", lowerTimeLimitString, 237 | "upperTimeLimitString", upperTimeLimitString, 238 | "nowDateUTCString", nowDateUTCString, 239 | "nowTimestamp", nowTimestamp, 240 | ) 241 | var queueLengths []string 242 | var times []time.Time 243 | 244 | db := GetDBHandle() 245 | rows, err := db.Query(queryString, nowTimestamp, timeFrameInDaysString, int(weekday), lowerTimeLimitString, upperTimeLimitString, nowDateUTCString, nowTimestamp) 246 | 247 | if err != nil { 248 | zap.S().Errorf("Error while querying for reports in timeframe", err) 249 | return queueLengths, times, err 250 | } 251 | defer rows.Close() 252 | unfilteredLengths, unfilteredTimes, err := getLengthsAndTimesFromRows(rows) 253 | if err != nil { 254 | return unfilteredLengths, unfilteredTimes, err 255 | } 256 | filteredLengths, filteredTimes := removeDataOutsideOfIntervalInCEST(nowTimeUTC, timeframeIntoPast, timeframeIntoFuture, unfilteredLengths, unfilteredTimes) 257 | zap.S().Debugf("Filtered query for historical data, %d of %d reports remain", len(filteredTimes), len(unfilteredTimes)) 258 | return filteredLengths, filteredTimes, nil 259 | } 260 | 261 | func WriteReportToDB(reporter string, time int, queueLength string) error { 262 | anonymizedReporter := pseudonymizeReporter(reporter) 263 | 264 | db := GetDBHandle() 265 | 266 | zap.S().Debug("Writing new report into DB") 267 | DBMutex.Lock() 268 | // Nice try 269 | _, err := db.Exec("INSERT INTO queueReports VALUES(NULL,?,?,?);", anonymizedReporter, time, queueLength) 270 | DBMutex.Unlock() 271 | return err 272 | } 273 | 274 | // Returns a pseudonym for the given reporter. The pseudonym is transient, and contained within one day. 275 | func pseudonymizeReporter(reporter string) string { 276 | /* We don't want to be able to track users across days, but we do want to be able to find out whether one user started spamming potentially wrong queue lengths. 277 | The idea is to pseudonymize with attribute day - meaning that within one day a user keeps the same pseudonym, but gets a different (not easily linkable) pseudonym on the next day. 278 | 279 | Specifically, we generate some Random on each day, and hash the user id given to us by telegram and that Random. Within a single day the same Random is used, and the same hash is generated. This allows for correlation within one day. 280 | Across multiple days different Randoms are used, and therefore different hashes that can't be correlated are generated. 281 | 282 | Randoms are discarded once a day is over, so correlation across days shouldn't be (easily) possible. 283 | 284 | This scheme expects that Randoms aren't stored, or otherwise extracted. Users also can't be correlated across server restarts. 285 | This scheme also expects enough randomness to be available. Handling lack of random isn't graceful - we just keep reusing the existing Random longer than we should 286 | Additionally, there's the need to trust whoever operates the infrastructure, since there is no assurance towards clients that this scheme is actually used. 287 | */ 288 | 289 | randomToUse := getPseudonymizationAttribute().Random 290 | 291 | // We're not using bcrypt because https://pkg.go.dev/golang.org/x/crypto/bcrypt adds an additional pepper, and we want to allow for eyeball comparison of the stored values 292 | // for easier queue length analysis. 293 | hashedReporter := sha256.Sum256([]byte(reporter + randomToUse)) 294 | return string(hashedReporter[:]) 295 | } 296 | 297 | // Returns some save random, with the amount specified by n 298 | // Taken from http://blog.questionable.services/article/generating-secure-random-numbers-crypto-rand/ 299 | func GenerateRandomBytes(n int) ([]byte, error) { 300 | b := make([]byte, n) 301 | _, err := rand.Read(b) 302 | // Note that err == nil only if we read len(b) bytes. 303 | if err != nil { 304 | return nil, err 305 | } 306 | return b, nil 307 | } 308 | 309 | // GenerateRandomString returns a URL-safe, base64 encoded 310 | // securely generated random string. 311 | // It will return an error if the system's secure random 312 | // number generator fails to function correctly, in which 313 | // case the caller should not continue. 314 | // Taken from http://blog.questionable.services/article/generating-secure-random-numbers-crypto-rand/ 315 | func GenerateRandomString(s int) (string, error) { 316 | b, err := GenerateRandomBytes(s) 317 | return base64.URLEncoding.EncodeToString(b), err 318 | } 319 | -------------------------------------------------------------------------------- /db_connectors/db_utilities.go: -------------------------------------------------------------------------------- 1 | /* 2 | Utility functions that are useful to all entities that interact with the DB. 3 | Notaby, implements the DB handle 4 | */ 5 | package db_connectors 6 | 7 | import ( 8 | "database/sql" 9 | "os" 10 | "sync" 11 | 12 | "go.uber.org/zap" 13 | ) 14 | 15 | const KEY_DB_BASE_PATH string = "MENSA_QUEUE_BOT_DB_PATH" 16 | const DB_NAME string = "queue_database.db" 17 | const DB_VERSION uint = 4 18 | 19 | var globalDBHandle *sql.DB = nil 20 | 21 | // Needs to be used by all outside functions that request a DB handle 22 | var DBMutex sync.Mutex 23 | 24 | func GetDBHandle() *sql.DB { 25 | dbPath, doesExist := os.LookupEnv(KEY_DB_BASE_PATH) 26 | dbPath = dbPath + DB_NAME 27 | 28 | if !doesExist { 29 | zap.S().Panic("Fatal Error: Environment variable for personal key not set:", KEY_DB_BASE_PATH) 30 | } 31 | 32 | if globalDBHandle == nil { 33 | // init db 34 | db, err := sql.Open("sqlite3", dbPath) 35 | if err != nil { 36 | zap.S().Panicf("Couldn't get DB handle with path %s", dbPath) 37 | 38 | } 39 | globalDBHandle = db 40 | } 41 | return globalDBHandle 42 | } 43 | 44 | // A separate DB, used only for testing 45 | func GetTestDBHandle(dbPath string) *sql.DB { 46 | // Unlike with the actual DB, don't use a global handle. 47 | // Might want to run tests on different DBs, and we do not 48 | // want to touch global state in tests 49 | db, err := sql.Open("sqlite3", dbPath) 50 | if err != nil { 51 | zap.S().Panicf("Couldn't get DB handle with path %s", dbPath) 52 | } 53 | return db 54 | } 55 | 56 | func GetDBVersion() uint { 57 | return DB_VERSION 58 | } 59 | -------------------------------------------------------------------------------- /db_connectors/internetpoints_db_connector.go: -------------------------------------------------------------------------------- 1 | package db_connectors 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "go.uber.org/zap" 7 | ) 8 | 9 | func UserIsCollectingPoints(userID int) bool { 10 | if GetNumberOfPointsByUser(userID) == -1 { 11 | return false 12 | } else { 13 | return true 14 | } 15 | } 16 | 17 | func GetNumberOfPointsByUser(userID int) int { 18 | queryString := "SELECT points FROM internetpoints WHERE reporterID= ?;" 19 | db := GetDBHandle() 20 | var numberOfPoints int 21 | 22 | zap.S().Infof("Querying for points of user %d", userID) 23 | 24 | if err := db.QueryRow(queryString, userID).Scan(&numberOfPoints); err != nil { 25 | if err == sql.ErrNoRows { 26 | zap.S().Info("No points returned, returning -1 ") 27 | numberOfPoints = -1 28 | } else { 29 | zap.S().Errorw("Error while querying for points", err) 30 | numberOfPoints = -1 31 | } 32 | } 33 | return numberOfPoints 34 | } 35 | 36 | func AddInternetPoint(userID int) error { 37 | queryString := "UPDATE internetpoints SET points = points + 1 WHERE reporterID= ?;" 38 | db := GetDBHandle() 39 | 40 | zap.S().Info("Adding point to user") // Don't log user explicitly for anonymity 41 | 42 | DBMutex.Lock() 43 | _, err := db.Exec(queryString, userID) 44 | DBMutex.Unlock() 45 | if err != nil { 46 | zap.S().Errorf("Error adding new internetpoint for user %s", userID, err) 47 | return err 48 | } 49 | return nil 50 | } 51 | 52 | func EnableCollectionOfPoints(userID int) error { 53 | queryString := "INSERT INTO internetpoints VALUES (NULL, ?, 0) ON CONFLICT (reporterID) DO NOTHING;" 54 | db := GetDBHandle() 55 | 56 | zap.S().Infof("Enabling point collection for user %d", userID) 57 | 58 | DBMutex.Lock() 59 | _, err := db.Exec(queryString, userID) 60 | DBMutex.Unlock() 61 | if err != nil { 62 | zap.S().Errorf("Error while enabling internetpoints for user %s", userID, err) 63 | return err 64 | } 65 | return nil 66 | } 67 | 68 | func DisableCollectionOfPoints(userID int) error { 69 | // Note: If this is changed from deleting all user data also modify DeleteAllUserPointData for compliance 70 | queryString := "DELETE FROM internetpoints WHERE reporterID = ?;" 71 | db := GetDBHandle() 72 | 73 | zap.S().Infof("Disabling point collection for user %d", userID) 74 | 75 | DBMutex.Lock() 76 | _, err := db.Exec(queryString, userID) 77 | DBMutex.Unlock() 78 | if err != nil { 79 | zap.S().Errorf("Error while deleting internetpoints of user %s", userID, err) 80 | return err 81 | } 82 | return nil 83 | } 84 | 85 | func DeleteAllUserPointData(userID int) error { 86 | // Deleting user data is exactly what happens when we call DisableCollectionOfPoints 87 | return DisableCollectionOfPoints(userID) 88 | } 89 | -------------------------------------------------------------------------------- /db_connectors/mensa_menus_connector.go: -------------------------------------------------------------------------------- 1 | package db_connectors 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | 7 | "github.com/ADimeo/MensaQueueBot/utils" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | type DBOfferInformation struct { 12 | Title string 13 | Description string 14 | Time time.Time 15 | Counter int 16 | } 17 | 18 | // time, title, decsription, counter 19 | 20 | // Returns latest mensa offers, but for today 21 | func GetLatestMensaOffersFromToday() ([]DBOfferInformation, error) { 22 | queryString := `SELECT time, title, description, counter FROM mensaMenus 23 | WHERE date(time) == ? 24 | AND counter == (SELECT MAX(counter) FROM mensaMenus);` 25 | db := GetDBHandle() 26 | 27 | var latestOffers []DBOfferInformation 28 | 29 | currentDate := time.Now().In(utils.GetLocalLocation()).Format("2006-01-02") 30 | rows, err := db.Query(queryString, currentDate) 31 | if err != nil { 32 | zap.S().Errorf("Error while querying for latest mensa offers", err) 33 | return latestOffers, err 34 | } 35 | defer rows.Close() 36 | 37 | for rows.Next() { 38 | var latestOffer DBOfferInformation 39 | if err = rows.Scan(&latestOffer.Time, &latestOffer.Title, &latestOffer.Description, &latestOffer.Counter); err != nil { 40 | zap.S().Errorf("Error scanning for latest mensa menus, likely data type mismatch", err) 41 | } 42 | latestOffers = append(latestOffers, latestOffer) 43 | } 44 | 45 | return latestOffers, nil 46 | } 47 | 48 | func InsertMensaMenu(offerToInsert *DBOfferInformation) error { 49 | queryString := "INSERT INTO mensaMenus(time, title, description, counter) VALUES(?,?,?,?);" 50 | db := GetDBHandle() 51 | 52 | DBMutex.Lock() 53 | _, err := db.Exec(queryString, offerToInsert.Time, offerToInsert.Title, offerToInsert.Description, offerToInsert.Counter) 54 | DBMutex.Unlock() 55 | return err 56 | } 57 | 58 | func GetMensaMenuCounter() (int, error) { 59 | queryString := "SELECT COALESCE(MAX(counter), -1) FROM mensaMenus;" 60 | db := GetDBHandle() 61 | 62 | var counterValue int 63 | 64 | if err := db.QueryRow(queryString).Scan(&counterValue); err != nil { 65 | if err == sql.ErrNoRows { 66 | zap.S().Error("No rows returned when querying for latest queue length report") 67 | return 0, nil 68 | } else { 69 | zap.S().Error("Error while querying for latest report", err) 70 | return -1, err 71 | 72 | } 73 | } 74 | return counterValue, nil 75 | } 76 | -------------------------------------------------------------------------------- /db_connectors/mensa_preferences_connector.go: -------------------------------------------------------------------------------- 1 | package db_connectors 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/ADimeo/MensaQueueBot/utils" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | // Customise: 14 | // User has userID, times(start) (minutes), time(end) (minutes), Weekdays (binary and), wants_mensa_messages, temp_reported_today (date?) 15 | 16 | type MensaPreferenceSettings struct { 17 | ReportAtAll bool `json:"reportAtall"` 18 | FromTime string `json:"fromTime"` 19 | WeekdayBitmap int `json:"weekdayBitmap"` 20 | ToTime string `json:"toTime"` 21 | } 22 | 23 | func (settingsStruct *MensaPreferenceSettings) SetFromTimeFromCESTMinutes(cestMinutes int) { 24 | baseString := "%02d:%02d" 25 | hours := cestMinutes / 60 26 | minutes := cestMinutes % 60 27 | timeString := fmt.Sprintf(baseString, hours, minutes) 28 | settingsStruct.FromTime = timeString 29 | } 30 | 31 | func (settingsStruct *MensaPreferenceSettings) SetToTimeFromCESTMinutes(cestMinutes int) { 32 | baseString := "%02d:%02d" 33 | hours := cestMinutes / 60 34 | minutes := cestMinutes % 60 35 | timeString := fmt.Sprintf(baseString, hours, minutes) 36 | settingsStruct.ToTime = timeString 37 | } 38 | 39 | func (settingsStruct *MensaPreferenceSettings) GetToTimeAsCESTMinute() (int, error) { 40 | // We expect a format like 12:00 41 | hour, err := strconv.Atoi(settingsStruct.ToTime[0:2]) 42 | if err != nil { 43 | zap.S().Errorw("Can't convert ToTime string to actual int", "FromTime value", settingsStruct.ToTime, "error", err) 44 | return 840, err 45 | } 46 | 47 | minute, err := strconv.Atoi(settingsStruct.ToTime[3:5]) 48 | if err != nil { 49 | zap.S().Errorw("Can't convert ToTime string to actual int", "FromTime value", settingsStruct.ToTime, "error", err) 50 | return 840, err 51 | } 52 | 53 | cestMinute := hour*60 + minute 54 | if err != nil { 55 | zap.S().Errorw("Can't convert ToTime string to actual int", "FromTime value", settingsStruct.ToTime, "error", err) 56 | return 840, err 57 | } 58 | return cestMinute, nil 59 | } 60 | 61 | func (settingsStruct MensaPreferenceSettings) GetFromTimeAsCESTMinute() (int, error) { 62 | // We expect a format like 12:00 63 | hour, err := strconv.Atoi(settingsStruct.FromTime[0:2]) 64 | if err != nil { 65 | zap.S().Errorw("Can't convert FromTime string to actual int", "FromTime value", settingsStruct.FromTime, "error", err) 66 | return 600, err 67 | } 68 | minute, err := strconv.Atoi(settingsStruct.FromTime[3:5]) 69 | if err != nil { 70 | zap.S().Errorw("Can't convert FromTime string to actual int", "FromTime value", settingsStruct.FromTime, "error", err) 71 | return 600, err 72 | } 73 | 74 | cestMinute := hour*60 + minute 75 | if err != nil { 76 | zap.S().Errorw("Can't convert FromTime string to actual int", "FromTime value", settingsStruct.FromTime, "error", err) 77 | return 600, err 78 | } 79 | return cestMinute, nil 80 | } 81 | 82 | func GetUsersToSendMenuToByTimestamp(nowInUTC time.Time) ([]int, error) { 83 | queryString := `SELECT reporterID FROM mensaPreferences 84 | WHERE wantsMensaMessages = 1 85 | AND (lastReportDate IS NULL OR date(lastReportDate) != ?) 86 | AND ? BETWEEN startTimeInCESTMinutes AND endTimeInCESTMinutes 87 | AND ? & weekdayBitmap > 0;` 88 | 89 | currentDate := nowInUTC.Format("2006-01-02") 90 | currentCESTDate := nowInUTC.In(utils.GetLocalLocation()) 91 | currentCESTMinute := currentCESTDate.Hour()*60 + currentCESTDate.Minute() 92 | 93 | weekdayBitmap := getBitmapForToday(nowInUTC) 94 | 95 | db := GetDBHandle() 96 | rows, err := db.Query(queryString, currentDate, currentCESTMinute, weekdayBitmap) 97 | if err != nil { 98 | zap.S().Errorf("Couldn't get users to send menu to", err) 99 | return make([]int, 0), err 100 | } 101 | defer rows.Close() 102 | 103 | var userIDs []int 104 | for rows.Next() { 105 | var userID int 106 | if err = rows.Scan(&userID); err != nil { 107 | zap.S().Errorf("Couldn't put user ID into int, likely data type mismatch", err) 108 | } 109 | userIDs = append(userIDs, userID) 110 | } 111 | if err = rows.Err(); err != nil { 112 | zap.S().Errorf("Error while scanning userids for mensa preferences", err) 113 | return userIDs, err 114 | } 115 | return userIDs, nil 116 | } 117 | 118 | /* 119 | returns the CEST Minute that would next run after the given cestMinuteOfLastRun, 120 | based on some extra conditions (weekday, already reported today, wants messages) 121 | a cest Minute is an int with hh*60 + mm of a timestamp. 122 | Uses nowInUTC to find out the date, and cestMinuteOfLastRun for the time 123 | */ 124 | func GetCESTMinuteForNextIntroMessage(nowInUTC time.Time, cestMinuteOfLastRun int) (int, error) { 125 | queryString := `SELECT startTimeInCESTMinutes FROM mensaPreferences 126 | WHERE startTimeInCESTMinutes > ? 127 | AND wantsMensaMessages = 1 128 | AND (lastReportDate IS NULL OR date(lastReportDate) != ?) 129 | AND ? & weekdayBitmap > 0 130 | ORDER BY startTimeInCESTMinutes ASC 131 | LIMIT 1;` 132 | 133 | currentDate := nowInUTC.Format("2006-01-02") 134 | weekdayBitmap := getBitmapForToday(nowInUTC) 135 | 136 | db := GetDBHandle() 137 | var nextCESTMinute int 138 | 139 | if err := db.QueryRow(queryString, cestMinuteOfLastRun, currentDate, weekdayBitmap).Scan(&nextCESTMinute); err != nil { 140 | if err == sql.ErrNoRows { 141 | zap.S().Debug("No more startTimes scheduled for today") 142 | return GetFirstCESTMinuteForIntroMessage() 143 | } else { 144 | zap.S().Errorw("Error while querying for next mensa report", err) 145 | // We default to scheduling the first "welcome" job at 08:00 146 | return 8 * 60, nil 147 | } 148 | } 149 | return nextCESTMinute, nil 150 | } 151 | 152 | func GetFirstCESTMinuteForIntroMessage() (int, error) { 153 | // We ignore the weekday in this query. 154 | // _technically_ this is a bug, because it will schedule the job 155 | // on hours when there's nothing to run (because the user that would have the 156 | // initial message doesn't want it this weekday) 157 | // But, well, it's invisible to users, 158 | // And getting this behaviour cleanly (so wrapping arround the weekday bitmap, 159 | // etc.) just doesn't feel worth it at all. 160 | queryString := `SELECT IFNULL(MIN(startTimeInCESTMinutes), 0) FROM mensaPreferences 161 | WHERE wantsMensaMessages = 1;` 162 | db := GetDBHandle() 163 | 164 | var firstTime int 165 | if err := db.QueryRow(queryString).Scan(&firstTime); err != nil { 166 | if err == sql.ErrNoRows { 167 | zap.S().Info("Can't find a single mensa report start time") 168 | // We default to scheduling the first "welcome" job at 08:00 169 | return 8 * 60, nil 170 | } else { 171 | zap.S().Errorw("Error while querying for first mensa report", err) 172 | // We default to scheduling the first "welcome" job at 08:00 173 | return 8 * 60, nil 174 | } 175 | } 176 | return firstTime, nil 177 | } 178 | 179 | func GetUsersWithInitialMessageInTimeframe(nowInUTC time.Time, lowerBoundCESTMinute int, upperBoundCESTMinute int) ([]int, error) { 180 | // +1 in BETWEEN because we don't want to include the lower bound in the interval, 181 | // and SQLites BETWEEN statement is inclusive of both upper and lower bound 182 | queryString := `SELECT reporterID FROM mensaPreferences 183 | WHERE wantsMensaMessages = 1 184 | AND (lastReportDate IS NULL OR date(lastReportDate) != ?) 185 | AND startTimeInCESTMinutes BETWEEN ? + 1 AND ? 186 | AND ? & weekdayBitmap > 0;` 187 | 188 | currentDate := nowInUTC.Format("2006-01-02") 189 | weekdayBitmap := getBitmapForToday(nowInUTC) 190 | 191 | db := GetDBHandle() 192 | rows, err := db.Query(queryString, currentDate, lowerBoundCESTMinute, upperBoundCESTMinute, weekdayBitmap) 193 | if err != nil { 194 | zap.S().Errorf("Couldn't get users to send menu to", err) 195 | return make([]int, 0), err 196 | } 197 | defer rows.Close() 198 | 199 | var userIDs []int 200 | for rows.Next() { 201 | var userID int 202 | if err = rows.Scan(&userID); err != nil { 203 | zap.S().Errorf("Couldn't put user ID into int, likely data type mismatch", err) 204 | } 205 | userIDs = append(userIDs, userID) 206 | } 207 | if err = rows.Err(); err != nil { 208 | zap.S().Errorf("Error while scanning userids for mensa preferences", err) 209 | return userIDs, err 210 | } 211 | if len(userIDs) == 0 { 212 | zap.S().Infow("Query for users with initial message returned empty!", 213 | "currentDate", currentDate, 214 | "lowerBoundCESTMinute", lowerBoundCESTMinute, 215 | "upperBoundCESTMinute", upperBoundCESTMinute, 216 | "weekdayBitmap", weekdayBitmap) 217 | } 218 | return userIDs, nil 219 | } 220 | 221 | func UpdateUserPreferences(userID int, wantsMensaMessages bool, startTimeInCESTMinutes int, endTimeInCESTMinutes int, weekdayBitmap int) error { 222 | queryString := "INSERT INTO mensaPreferences(reporterID, wantsMensaMessages, startTimeInCESTMinutes, endTimeInCESTMinutes, weekdayBitmap) VALUES (?,?,?,?,?) ON CONFLICT (reporterID) DO UPDATE SET wantsMensaMessages=?, startTimeInCESTMinutes=?, endTimeInCESTMinutes=?,weekdayBitmap=?;" 223 | db := GetDBHandle() 224 | DBMutex.Lock() 225 | 226 | _, err := db.Exec(queryString, userID, wantsMensaMessages, startTimeInCESTMinutes, endTimeInCESTMinutes, weekdayBitmap, 227 | wantsMensaMessages, startTimeInCESTMinutes, endTimeInCESTMinutes, weekdayBitmap) 228 | DBMutex.Unlock() 229 | return err 230 | } 231 | 232 | // Returns preferences for single user. Sets preferences to default 233 | // And returns those if user doesn't have any associated preferences 234 | func GetUserPreferences(userID int) (MensaPreferenceSettings, error) { 235 | queryString := `SELECT wantsMensaMessages, weekdayBitmap, startTimeInCESTMinutes, endTimeInCESTMinutes 236 | FROM mensaPreferences 237 | WHERE reporterID == ?;` 238 | 239 | db := GetDBHandle() 240 | var usersPreferences MensaPreferenceSettings 241 | var weekdayBitmap int 242 | var startCESTMinutes int 243 | var endCESTMinutes int 244 | 245 | if err := db.QueryRow(queryString, userID).Scan(&usersPreferences.ReportAtAll, &weekdayBitmap, &startCESTMinutes, &endCESTMinutes); err != nil { 246 | if err == sql.ErrNoRows { 247 | zap.S().Info("User doesn't have associated mensa settings yet") 248 | usersPreferences, err = SetDefaultMensaPreferencesForUser(userID) 249 | if err != nil { 250 | return usersPreferences, err 251 | } 252 | } else { 253 | zap.S().Error("Error while querying for latest report", err) 254 | } 255 | } 256 | 257 | usersPreferences.WeekdayBitmap = weekdayBitmap 258 | usersPreferences.SetFromTimeFromCESTMinutes(startCESTMinutes) 259 | usersPreferences.SetToTimeFromCESTMinutes(endCESTMinutes) 260 | 261 | return usersPreferences, nil 262 | } 263 | func SetDefaultMensaPreferencesForUser(userID int) (MensaPreferenceSettings, error) { 264 | var err error 265 | var usersPreferences MensaPreferenceSettings 266 | usersPreferences.ReportAtAll = true 267 | if utils.IsInDebugMode() { 268 | err = UpdateUserPreferences(userID, true, 0, 1440, 0b0111110) // Default from 0:00 to 24:00 269 | usersPreferences.WeekdayBitmap = 0b0111110 270 | usersPreferences.SetFromTimeFromCESTMinutes(0) 271 | usersPreferences.SetToTimeFromCESTMinutes(1440) 272 | 273 | } else { 274 | err = UpdateUserPreferences(userID, true, 600, 840, 0b0111110) // Default from 10:00 to 14:00 275 | usersPreferences.WeekdayBitmap = 0b0111110 276 | usersPreferences.SetFromTimeFromCESTMinutes(600) 277 | usersPreferences.SetToTimeFromCESTMinutes(840) 278 | } 279 | 280 | if err != nil { 281 | zap.S().Errorf("Can't set default preferences for user %d", userID, err) 282 | } 283 | return usersPreferences, err 284 | } 285 | 286 | func DeleteAllUserMensaPreferences(userID int) error { 287 | queryString := `DELETE FROM mensaPreferences WHERE reporterID == ?;` 288 | db := GetDBHandle() 289 | zap.S().Infof("Deleting mensa preferences for user %d", userID) 290 | 291 | DBMutex.Lock() 292 | _, err := db.Exec(queryString, userID) 293 | DBMutex.Unlock() 294 | if err != nil { 295 | zap.S().Errorf("Error while deleting mensa preferences of user %s", userID, err) 296 | return err 297 | } 298 | return nil 299 | } 300 | 301 | func SetUserToReportedOnDate(userID int, nowInUTC time.Time) error { 302 | queryString := `UPDATE mensaPreferences 303 | SET lastReportDate = ? 304 | WHERE reporterID = ?;` 305 | db := GetDBHandle() 306 | 307 | currentDate := nowInUTC.Format("2006-01-02") 308 | 309 | DBMutex.Lock() 310 | _, err := db.Exec(queryString, currentDate, userID) 311 | DBMutex.Unlock() 312 | if err != nil { 313 | zap.S().Errorw("Error while saving users report date %s", 314 | "userID", userID, 315 | "currentDate", currentDate, 316 | "err", err) 317 | return err 318 | } 319 | return nil 320 | } 321 | 322 | func getBitmapForToday(nowInUTC time.Time) int { 323 | weekdayNow := nowInUTC.Weekday() // Sunday is 0, Sunday is left, shift 6 for sunday 324 | weekdayBitmap := 1 << (6 - weekdayNow) 325 | return weekdayBitmap 326 | } 327 | -------------------------------------------------------------------------------- /db_connectors/userprofile_db_connector.go: -------------------------------------------------------------------------------- 1 | package db_connectors 2 | 3 | /* 4 | - Due to the expected low utility of introducing hashes, but the real associated cost, we decide against it, and store plain user IDs instead. Since we need to send push messages for the mensa menu functionality I believe this is justified. 5 | */ 6 | 7 | import ( 8 | "database/sql" 9 | "encoding/csv" 10 | "io" 11 | "os" 12 | "strconv" 13 | 14 | "go.uber.org/zap" 15 | ) 16 | 17 | type changelog struct { 18 | Id int 19 | Text string 20 | } 21 | 22 | const CHANGELOG_FILE_LOCATION = "./changelog.psv" 23 | 24 | var changelogs []changelog 25 | 26 | /* Assumes one changelog per line, with "version" always increasing one-by-one*/ 27 | func GetCurrentChangelog() (changelog, error) { 28 | zap.S().Debug("Getting latest changelog...") 29 | 30 | if len(changelogs) == 0 { 31 | // load changelogs into memory if they aren't loaded yet 32 | zap.S().Info("Loading changelog from disk") 33 | csvFile, err := os.Open(CHANGELOG_FILE_LOCATION) 34 | if err != nil { 35 | zap.S().Panicf("Can't access changelog psv file at %s", CHANGELOG_FILE_LOCATION) 36 | } 37 | defer csvFile.Close() 38 | 39 | psvReader := csv.NewReader(csvFile) 40 | psvReader.Comma = '|' // Pipe separated file 41 | 42 | for { 43 | record, err := psvReader.Read() 44 | if err == io.EOF { 45 | zap.S().Infof("Loaded %d changelogs", len(changelogs)) 46 | break 47 | } 48 | if err != nil { 49 | zap.S().Panicf("Can't read psv file at %s", CHANGELOG_FILE_LOCATION) 50 | } 51 | changelogID, conversionErr := strconv.Atoi(record[0]) 52 | if conversionErr != nil { 53 | zap.S().Panicf("Bad PSV entry: Can't convert changelog ID %s", record[0]) 54 | } 55 | readChangelog := changelog{Id: changelogID, Text: record[1]} 56 | changelogs = append(changelogs, readChangelog) 57 | } 58 | } 59 | return changelogs[len(changelogs)-1], nil 60 | } 61 | 62 | /* 63 | Changelogs start at ID 0, and increment one by one, per line. 64 | Wrapper for testing. 65 | */ 66 | func GetLatestChangelogSentToUser(userID int) int { 67 | db := GetDBHandle() 68 | return getLatestChangelogSentToUserWithDB(userID, db) 69 | } 70 | 71 | func getLatestChangelogSentToUserWithDB(userID int, db *sql.DB) int { 72 | zap.S().Info("Querying for latest changelog for a user") // Don't log which user, that allows correlation with reports 73 | queryString := "SELECT lastChangelog FROM changelogMessages WHERE reporterID = ?" 74 | var retrievedLastChangelog int 75 | 76 | if err := db.QueryRow(queryString, userID).Scan(&retrievedLastChangelog); err != nil { 77 | if err == sql.ErrNoRows { 78 | zap.S().Info("No changelog returned, returning -1 ") 79 | retrievedLastChangelog = -1 80 | } else { 81 | zap.S().Errorw("Error while querying for changelog", err) 82 | retrievedLastChangelog = -1 83 | } 84 | } 85 | 86 | return retrievedLastChangelog 87 | } 88 | 89 | func SaveNewChangelogForUser(userID int, changelogID int) error { 90 | db := GetDBHandle() 91 | return saveNewChangelogForUserWithDB(userID, changelogID, db) 92 | } 93 | func saveNewChangelogForUserWithDB(userID int, changelogID int, db *sql.DB) error { 94 | // Use UPSERT syntax as defined by https://www.sqlite.org/draft/lang_UPSERT.html 95 | queryString := "INSERT INTO changelogMessages VALUES (NULL, ?,?, 0) ON CONFLICT (reporterID) DO UPDATE SET lastChangelog=?;" 96 | 97 | zap.S().Info("Inserting changelog sent into DB") // Don't log which user, that allows correlation with reports 98 | 99 | DBMutex.Lock() 100 | _, err := db.Exec(queryString, userID, changelogID, changelogID) 101 | DBMutex.Unlock() 102 | if err != nil { 103 | zap.S().Errorf("Error while inserting new changelog", err) 104 | return err 105 | } 106 | return nil 107 | } 108 | 109 | func DeleteAllUserChangelogData(userID int) error { 110 | db := GetDBHandle() 111 | return deleteAllUserChangelogDataWithDB(userID, db) 112 | } 113 | 114 | func deleteAllUserChangelogDataWithDB(userID int, db *sql.DB) error { 115 | queryString := "DELETE FROM changelogMessages WHERE reporterID = ?;" 116 | zap.S().Infof("Deleting changelog info for user %d", userID) 117 | DBMutex.Lock() 118 | _, err := db.Exec(queryString, userID) 119 | DBMutex.Unlock() 120 | 121 | if err != nil { 122 | zap.S().Errorf("Error while deleting changelogs of user %s", userID, err) 123 | return err 124 | } 125 | 126 | return nil 127 | } 128 | 129 | func GetIsUserABTester(userID int) bool { 130 | db := GetDBHandle() 131 | return getIsUserABTesterWithDB(userID, db) 132 | } 133 | 134 | func getIsUserABTesterWithDB(userID int, db *sql.DB) bool { 135 | queryString := "SELECT ab_tester FROM changelogMessages WHERE reporterID = ?" 136 | var isABTester int 137 | 138 | if err := db.QueryRow(queryString, userID).Scan(&isABTester); err != nil { 139 | if err == sql.ErrNoRows { 140 | zap.S().Info("No state returned, defaulting to false") 141 | return false 142 | } else { 143 | zap.S().Errorw("Error while querying for A/B tester state", err) 144 | return false 145 | } 146 | } 147 | 148 | return isABTester == 1 149 | } 150 | 151 | func MakeUserABTester(userID int, optingIn bool) error { 152 | db := GetDBHandle() 153 | return makeUserABTesterWithDB(userID, optingIn, db) 154 | } 155 | 156 | func makeUserABTesterWithDB(userID int, optingIn bool, db *sql.DB) error { 157 | // This assumes that all users that opt into/out of A/B tests already have a profile 158 | // But fails gracefully, and just does nothing except return the error if 159 | // that's not the case 160 | queryString := "UPDATE changelogMessages SET ab_tester = ? WHERE reporterID = ?" 161 | 162 | DBMutex.Lock() 163 | _, err := db.Exec(queryString, optingIn, userID) 164 | DBMutex.Unlock() 165 | 166 | if err != nil { 167 | zap.S().Errorf("Error while changing A/B tester status of user %s", userID, err) 168 | return err 169 | } 170 | return nil 171 | } 172 | 173 | func UserHasBeenMigrated(userID int) bool { 174 | // "Updated" means they have been added to the table of mensa preferences" 175 | queryString := `SELECT reporterID FROM mensaPreferences WHERE reporterID = ?` 176 | db := GetDBHandle() 177 | var userHasMensaPreferences int 178 | 179 | if err := db.QueryRow(queryString, userID).Scan(&userHasMensaPreferences); err != nil { 180 | if err == sql.ErrNoRows { 181 | return false 182 | } else { 183 | zap.S().Errorw("Error while querying for A/B tester state", err) 184 | return false 185 | } 186 | } 187 | return true 188 | 189 | } 190 | -------------------------------------------------------------------------------- /db_connectors/userprofile_db_connector_test.go: -------------------------------------------------------------------------------- 1 | package db_connectors 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/golang-migrate/migrate/v4" 8 | "github.com/golang-migrate/migrate/v4/database/sqlite3" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | const TEST_DB_PATH string = "./for_tests.db" 13 | 14 | func initializeForTest() { 15 | logger, _ := zap.NewDevelopment() 16 | zap.ReplaceGlobals(logger) 17 | initializeTestDB() 18 | } 19 | 20 | // Creates a new empty DB 21 | func initializeTestDB() { 22 | db_handle := GetTestDBHandle(TEST_DB_PATH) 23 | // Let's just assume the migrations work... 24 | driver, _ := sqlite3.WithInstance(db_handle, &sqlite3.Config{}) 25 | m, _ := migrate.NewWithDatabaseInstance("file://./db/migrations", "sqlite3", driver) 26 | m.Migrate(DB_VERSION) // Variable from db_utilities 27 | // Initialization done 28 | } 29 | 30 | func resetTestDB() { 31 | // Remove the DB file 32 | err := os.Remove(TEST_DB_PATH) 33 | if err != nil { 34 | zap.S().Panic("Can't delete test DB after test!") 35 | } 36 | } 37 | 38 | // Tests for the functions within changelog_db_connector 39 | func TestWriteAndReadOfChangelogs(t *testing.T) { 40 | initializeForTest() 41 | defer resetTestDB() 42 | userID := 12345 43 | changelogID := 123 44 | 45 | db := GetTestDBHandle(TEST_DB_PATH) 46 | 47 | retrievedChangelogID := getLatestChangelogSentToUserWithDB(userID, db) 48 | if retrievedChangelogID != -1 { 49 | // Default to -1 for users that don't exist 50 | t.Fail() 51 | } 52 | 53 | err := saveNewChangelogForUserWithDB(userID, changelogID, db) 54 | if err != nil { 55 | t.Fail() 56 | } 57 | 58 | retrievedChangelogID = getLatestChangelogSentToUserWithDB(userID, db) 59 | if retrievedChangelogID != changelogID { 60 | t.Fail() 61 | } 62 | } 63 | 64 | func TestDeletionOfUserChangelog(t *testing.T) { 65 | initializeForTest() 66 | defer resetTestDB() 67 | userID := 12346 68 | changelogID := 126 69 | 70 | db := GetTestDBHandle(TEST_DB_PATH) 71 | saveNewChangelogForUserWithDB(userID, changelogID, db) 72 | 73 | retrievedChangelogID := getLatestChangelogSentToUserWithDB(userID, db) 74 | if retrievedChangelogID == -1 { 75 | t.Fail() 76 | } 77 | 78 | deleteAllUserChangelogDataWithDB(userID, db) 79 | 80 | retrievedChangelogID = getLatestChangelogSentToUserWithDB(userID, db) 81 | if retrievedChangelogID != -1 { 82 | t.Fail() 83 | } 84 | 85 | } 86 | 87 | func TestChangingABTesterState(t *testing.T) { 88 | initializeForTest() 89 | defer resetTestDB() 90 | userID := 12347 91 | changelogID := 127 92 | 93 | db := GetTestDBHandle(TEST_DB_PATH) 94 | 95 | saveNewChangelogForUserWithDB(userID, changelogID, db) 96 | isABTester := getIsUserABTesterWithDB(userID, db) 97 | if isABTester { 98 | t.Errorf("Users don't default to not being AB testers") 99 | } 100 | 101 | makeUserABTesterWithDB(userID, true, db) 102 | isABTester = getIsUserABTesterWithDB(userID, db) 103 | if !isABTester { 104 | t.Errorf("Can't make users AB testers") 105 | } 106 | 107 | makeUserABTesterWithDB(userID, false, db) 108 | isABTester = getIsUserABTesterWithDB(userID, db) 109 | if isABTester { 110 | t.Errorf("Can't unmake users AB testers") 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /deployment/.env-template: -------------------------------------------------------------------------------- 1 | # CADDY_SITE_ADDRESS=localhost 2 | CADDY_SITE_ADDRESS=your.url.example.com # Also works with localhost 3 | CADDY_FILES_ADDRESS=different.url.example.com # Also works with localhost 4 | MENSA_QUEUE_BOT_PERSONAL_TOKEN=long-random-string-without-trailing-or-leading-slashes 5 | MENSA_QUEUE_BOT_TELEGRAM_TOKEN=telegram-token-provided-by-botfather 6 | MENSA_QUEUE_BOT_DB_PATH=/filepath-where-db-is-stored-and-volume-is-mounted/ 7 | -------------------------------------------------------------------------------- /deployment/Caddyfile: -------------------------------------------------------------------------------- 1 | https://{$CADDY_SITE_ADDRESS} { 2 | reverse_proxy server:8080 3 | } 4 | 5 | https://{$CADDY_FILES_ADDRESS} { 6 | # Needs to run on a server, can't hardcode it 7 | root * /static/ 8 | file_server browse 9 | 10 | } 11 | 12 | -------------------------------------------------------------------------------- /deployment/deploy_mensa_queue.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Mensaqueuebot deployment playbook 3 | hosts: SOME_TODO_IP 4 | tasks: 5 | - name: Moves application files to remote 6 | ansible.posix.synchronize: 7 | # ansible.builtin.copy Is extremely slow 8 | src: ../../../mensa_queue_bot/ 9 | dest: ./mensaqueuebot 10 | delete: true 11 | 12 | 13 | - name: Builds the container remotely 14 | ansible.builtin.command: 15 | cmd: docker build -t mensaqueuebot ./mensaqueuebot 16 | 17 | 18 | - name: Executes container with docker-compose 19 | community.docker.docker_compose: 20 | env_file: .env # On remote host 21 | project_src: ./mensaqueuebot/deployment 22 | -------------------------------------------------------------------------------- /deployment/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | proxy: 4 | image: caddy:2-alpine 5 | volumes: 6 | - ./Caddyfile:/etc/caddy/Caddyfile 7 | - caddy_data:/data 8 | - static:/static/ 9 | ports: 10 | - 80:80 11 | - 443:443 12 | env_file: .env 13 | restart: always 14 | 15 | server: 16 | image: mensaqueuebot 17 | volumes: 18 | - db_data:${MENSA_QUEUE_BOT_DB_PATH} 19 | - static:/static/ 20 | env_file: .env 21 | environment: 22 | GIN_MODE: release 23 | restart: always 24 | volumes: 25 | caddy_data: 26 | db_data: 27 | static: 28 | -------------------------------------------------------------------------------- /deployment/pull_csv.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Mensaqueuebot CSV download playbook 3 | # Run with -K to ask for password 4 | hosts: SOME_TODO_IP 5 | tasks: 6 | - name: Gather the package facts 7 | ansible.builtin.package_facts: 8 | manager: auto 9 | 10 | - name: Test for sqlite3 install 11 | debug: 12 | msg: "Error expected: sqlite3 is not installed" 13 | when: "'sqlite3' not in ansible_facts.packages" 14 | 15 | 16 | 17 | - name: Copy DB from location within docker to main env 18 | become: yes 19 | ansible.builtin.command: 20 | cmd: cp /var/lib/docker/volumes/deployment_db_data/_data/queue_database.db /home/USER/databases/{{ ansible_date_time.date }}.db 21 | 22 | - name: Extract publishable reports from DB 23 | ansible.builtin.shell: 24 | cmd: sqlite3 -header -csv ~/databases/{{ ansible_date_time.date }}.db "select time, queueLength from queueReports where id>119" > ~/databases/queueReports.csv 25 | 26 | - name: Copy csv from remote system to local 27 | ansible.builtin.fetch: 28 | src: ~/databases/queueReports.csv 29 | dest: ../analysis/data/ 30 | flat: yes 31 | 32 | -------------------------------------------------------------------------------- /deployment/pull_db.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Mensaqueuebot database download playbook 3 | # Run with -K to ask for password 4 | hosts: SOME_TODO_IP 5 | tasks: 6 | - name: Copy DB from location within docker to main env 7 | become: yes 8 | ansible.builtin.command: 9 | cmd: cp /var/lib/docker/volumes/deployment_db_data/_data/queue_database.db /home/USER/databases/{{ ansible_date_time.date }}.db 10 | 11 | - name: Copy database from remote system to local 12 | ansible.builtin.fetch: 13 | src: /home/USER/databases/{{ ansible_date_time.date }}.db 14 | dest: ../analysis/data/ 15 | flat: yes 16 | 17 | -------------------------------------------------------------------------------- /egraph_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // End-to-end style tests that aren't automatic, but greatly speed up 4 | // the creation of stuff that a dev can manually check afterwards 5 | 6 | import ( 7 | "os" 8 | "strconv" 9 | "testing" 10 | "time" 11 | 12 | "go.uber.org/zap" 13 | ) 14 | 15 | /* 16 | This is a manual test that sends a bunch of graphs to the tester. 17 | These are used to verify that things look alright at different times/dates, 18 | which are representative for users. 19 | The Acceptance criterion is "does it look alright", which we can't automatically 20 | test for. Thus, this. 21 | 22 | Run this with a (copy of a) real DB 23 | */ 24 | func TestGenerateAWholeBunchOfGraphs(t *testing.T) { 25 | // Skip this test as a default - We'll get back to some flag 26 | // for running it once testing becomes less ad hoc 27 | t.Skip("Skipping manal graph generation test") 28 | 29 | logger, _ := zap.NewDevelopment() 30 | zap.ReplaceGlobals(logger) 31 | // 9:15, 10:30, 11:45, 13:00, 14:15 32 | //Mon, Di, Mi, Do, Fr 33 | // Current DB has date0 34 | loc, err := time.LoadLocation("Europe/Berlin") 35 | if err != nil { 36 | t.Errorf("Couldn't generate graphs 1") 37 | } 38 | formatString := "2006-01-02T15:04:00 (MST)" 39 | graphTimeframeIntoPast, err := time.ParseDuration("60m") 40 | if err != nil { 41 | t.Errorf("Couldn't generate graphs 2") 42 | } 43 | graphTimeframeIntoFuture, err := time.ParseDuration("30m") 44 | if err != nil { 45 | t.Errorf("Couldn't generate graphs 3") 46 | } 47 | 48 | interestingTimes := []string{ 49 | "2022-11-01T13:00:00 (CEST)", 50 | "2022-11-01T13:30:00 (CEST)", 51 | //"2022-11-01T14:00:00 (CEST)", 52 | //"2022-11-01T14:30:00 (CEST)", 53 | //"2022-11-01T15:00:00 (CEST)", 54 | } 55 | 56 | for _, i := range interestingTimes { 57 | queryTime, _ := time.ParseInLocation(formatString, i, loc) 58 | graphFilepath, _ := generateGraphOfMensaTrendAsHTML(queryTime.UTC(), graphTimeframeIntoPast, graphTimeframeIntoFuture) 59 | pathToPng, _ := renderHTMLGraphToPNG(graphFilepath) 60 | chatIDString, doesExist := os.LookupEnv(KEY_DEBUG_MODE) 61 | if !doesExist { 62 | zap.S().Panicf("Fatal Error: Environment variable for dev to report to not set. Set to telegram ID of dev", KEY_DEBUG_MODE) 63 | 64 | } 65 | chatID, err := strconv.Atoi(chatIDString) 66 | if err != nil { 67 | zap.S().Panicf("Fatal Error: Debug mode flag is not a telegram id", KEY_DEBUG_MODE) 68 | 69 | } 70 | stringReport := i 71 | SendDynamicPhoto(chatID, pathToPng, stringReport) 72 | } 73 | t.Errorf("Error to see logs") 74 | } 75 | -------------------------------------------------------------------------------- /emoji_list: -------------------------------------------------------------------------------- 1 | 🍏🍎🍐🍊🍋🍌🍉🍇🍓🫐🍈🍒🍑🥭🍍🥥🥝🍅🍆🥑🥦🥬🥒🌶🫑🌽🥕🫒🧄🧅🥔🍠🥐🥯🍞🥖🥨🧀🥚🍳🧈🥞🧇🥓🥩🍗🍖🦴🌭🍔🍟🍕🫓🥪🥙🧆🌮🌯🫔🥗🥘🫕🥫🍝🍜🍲🍛🍣🍱🥟🦪🍤🍙🍚🍘🍥🥠🥮🍢🍡🍧🍨🍦🥧🧁🍰🎂🍮🍭🍬🍫🍿🍩🍪🌰🥜🍯🥛🍼🫖☕️🍵🧃🥤🧋🍶🍺🍻🥂🍷🥃🍸🍹🧉🍾🧊🥄🍴🍽🥣🥡🥢🧂😇🥰😘😀😃😄😁🥳🤩🤓😎🥸🤠🤑👻💩😸😺🦄🦆🥇🔪🧾💌📈🆒📧📨📩✉️📥📫🛎💰 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ADimeo/MensaQueueBot 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/adimeo/go-echarts/v2 v2.0.0-20221030040839-b967b9881d42 7 | github.com/gin-gonic/gin v1.7.7 8 | github.com/go-rod/rod v0.112.0 9 | github.com/golang-migrate/migrate/v4 v4.15.2 10 | github.com/mattn/go-sqlite3 v1.14.12 11 | go.uber.org/zap v1.21.0 12 | ) 13 | 14 | require ( 15 | github.com/gin-contrib/sse v0.1.0 // indirect 16 | github.com/go-co-op/gocron v1.18.1 // indirect 17 | github.com/go-playground/locales v0.13.0 // indirect 18 | github.com/go-playground/universal-translator v0.17.0 // indirect 19 | github.com/go-playground/validator/v10 v10.4.1 // indirect 20 | github.com/golang/protobuf v1.5.2 // indirect 21 | github.com/hashicorp/errwrap v1.1.0 // indirect 22 | github.com/hashicorp/go-multierror v1.1.1 // indirect 23 | github.com/json-iterator/go v1.1.12 // indirect 24 | github.com/leodido/go-urn v1.2.0 // indirect 25 | github.com/mattn/go-isatty v0.0.12 // indirect 26 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 27 | github.com/modern-go/reflect2 v1.0.2 // indirect 28 | github.com/robfig/cron/v3 v3.0.1 // indirect 29 | github.com/ugorji/go/codec v1.1.7 // indirect 30 | github.com/ysmood/goob v0.4.0 // indirect 31 | github.com/ysmood/gson v0.7.2 // indirect 32 | github.com/ysmood/leakless v0.8.0 // indirect 33 | go.uber.org/atomic v1.9.0 // indirect 34 | go.uber.org/multierr v1.8.0 // indirect 35 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect 36 | golang.org/x/sync v0.1.0 // indirect 37 | golang.org/x/sys v0.0.0-20220317061510-51cd9980dadf // indirect 38 | google.golang.org/protobuf v1.27.1 // indirect 39 | gopkg.in/yaml.v2 v2.4.0 // indirect 40 | ) 41 | -------------------------------------------------------------------------------- /help_handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "os" 7 | 8 | "github.com/ADimeo/MensaQueueBot/telegram_connector" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | /* 13 | SendHelpMessage sends out a number of messages that try to explain what we do. Use for when /help is called 14 | 15 | */ 16 | func SendHelpMessage(chatID int) { 17 | var helpMessageArray = [...]string{ 18 | "Alright, I'll try to give you a detailed overview. Remember, for questions or other uncertainties either talk to @adimeo, or go directly to https://github.com/ADimeo/MensaQueueBot", 19 | "Let's start with length reports. You can report lengths via choosing \"Report!\", and then tapping one of the buttons. This information is aggregated, and distributed to all users that ask for \"Queue?\".", 20 | "If you're uncertain about which button corresponds to which queue length use /length_illustrations", 21 | "To receive mensa menus, you have two options. First, you can receive the latest menu by using \"Menu?\"", 22 | "Second, you can use /settings to define on which days and at which times you want to be informed about menu changes. This works much like the other mensa bots: At the dedicated time you receive a message that contains whatever is on offer at that specific time.", 23 | "Once you have reported a queue length these automatic updates stop for the day, since we assume that you won't care about what the mensa has on offer once you've already eaten.", 24 | "To suggest changes for how the bot behaves check out https://github.com/ADimeo/MensaQueueBot, or write to @adimeo directly.", 25 | "When in doubt check your /settings, and again, @adimeo is responsible for user satisfaction, so go and bother him if something is weird or doesn't work.", 26 | } 27 | 28 | for _, messageString := range helpMessageArray { 29 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.SETTINGS_INTERACTION, chatID) 30 | err := telegram_connector.SendMessage(chatID, messageString, keyboardIdentifier) 31 | if err != nil { 32 | zap.S().Error("Error while sending help messages.", err) 33 | } 34 | } 35 | } 36 | 37 | /* 38 | GetMensaLocationSlice returns a slice that contains a number of links to photographs and corresponding messages 39 | encoded within a mensaLocation struct. 40 | 41 | Should be sent together with the texts defined in getWelcomeMessageArray 42 | (except for the last queuelength entry "even longer", which doesn't have an image) 43 | The specific logic of how these two interact is encoded within sendWelcomeMessage 44 | 45 | The messages defined for these need to be consistent with the keyboard defined in ./keyboard.json, which is used by telegram_connector.go, 46 | as well as with the regex REPORT_REGEX that is used to identify the type of inbound messages in reactToRequest 47 | 48 | */ 49 | func GetMensaLocationSlice() *[]mensaLocation { 50 | var mensaLocationArray []mensaLocation 51 | 52 | // Read these from json file 53 | jsonFile, err := os.Open(MENSA_LOCATION_JSON_LOCATION) 54 | if err != nil { 55 | zap.S().Panicf("Can't access mensa locations json file at %s", MENSA_LOCATION_JSON_LOCATION) 56 | } 57 | defer jsonFile.Close() 58 | 59 | jsonAsBytes, err := ioutil.ReadAll(jsonFile) 60 | if err != nil { 61 | zap.S().Panicf("Can't read mensa locations json file at %s", MENSA_LOCATION_JSON_LOCATION) 62 | 63 | } 64 | err = json.Unmarshal(jsonAsBytes, &mensaLocationArray) 65 | if err != nil { 66 | zap.S().Panicf("Mensa location json file is malformed, at %s", MENSA_LOCATION_JSON_LOCATION) 67 | } 68 | 69 | return &mensaLocationArray 70 | } 71 | -------------------------------------------------------------------------------- /introduction.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | /* 4 | Functions that are used to send out the introduction messages (/start) 5 | These contain hardcoded business logic 6 | */ 7 | 8 | import ( 9 | "github.com/ADimeo/MensaQueueBot/telegram_connector" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | // Used by SendTopViewOfMensa, static file link 14 | const TOP_VIEW_URL = "https://raw.githubusercontent.com/ADimeo/MensaQueueBot/master/queue_length_illustrations/top_view.jpg" 15 | 16 | /* 17 | Contains a number of messages that should be sent to users as an introduction. 18 | Should be sent together with the image (links) defined in GetMensaLocationSlice. 19 | 20 | The specific logic of how these two interact is encoded within SendWelcomeMessage 21 | */ 22 | 23 | func getWelcomeMessageArray() [5]string { 24 | var messageArray = [...]string{ 25 | "Welcome to MensaQueueBot 2.0, where we minimize wait times, and maximize food enjoyment for you! I'll quickly get you onboarded, if you don't mind.", 26 | "Right now we offer two functionalities: First, crowdsourced mensa lengths. Request past and current lengths of the mensa queue via the \"Queue?\" button, and report queue lengths via \"Report!", 27 | // Send top picture here 28 | "If the queue ends before the line marked as L3 report the length as L3", 29 | "Second, we offer the current mensa menu, including changes which happened during the day. To get the latest menu use the \"Menu?\" button.", 30 | "That's all for now. Check out /settings for additional information, including changing when you receive menu updates, internet points, and more details on length reporting.", 31 | } 32 | return messageArray 33 | } 34 | 35 | /* 36 | SendTopViewOfMensa sends a single message which contains a top down view of the mensa 37 | */ 38 | func SendTopViewOfMensa(chatID int) error { 39 | const topViewText = "I'm an artist" 40 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.IMAGE_REQUEST, chatID) 41 | err := telegram_connector.SendStaticWebPhoto(chatID, TOP_VIEW_URL, topViewText, keyboardIdentifier) 42 | return err 43 | } 44 | 45 | /* 46 | SendWelcomeMessage sends a number of messages to the specified user, explaining the base concept and instructing them on how to act 47 | Tightly coupled with getWelcomeMessageArray and GetMensaLocationSlice 48 | */ 49 | func SendWelcomeMessage(chatID int) { 50 | messageArray := getWelcomeMessageArray() 51 | 52 | var err error 53 | // Send first two messages 54 | for i := 0; i < 2; i++ { 55 | messageString := messageArray[i] 56 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.TUTORIAL_MESSAGE, chatID) 57 | err = telegram_connector.SendMessage(chatID, messageString, keyboardIdentifier) 58 | if err != nil { 59 | zap.S().Error("Error while sending first welcome messages.", err) 60 | } 61 | 62 | } 63 | // Send Top view of mensa 64 | err = SendTopViewOfMensa(chatID) 65 | if err != nil { 66 | zap.S().Error("Error while sending Top View of mensa.", err) 67 | } 68 | 69 | for i := 2; i < 5; i++ { 70 | messageString := messageArray[i] 71 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.TUTORIAL_MESSAGE, chatID) 72 | err = telegram_connector.SendMessage(chatID, messageString, keyboardIdentifier) 73 | if err != nil { 74 | zap.S().Error("Error while sending second welcome messages.", err) 75 | } 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "math/rand" 6 | "os" 7 | "regexp" 8 | "time" 9 | 10 | "github.com/ADimeo/MensaQueueBot/db_connectors" 11 | "github.com/ADimeo/MensaQueueBot/mensa_scraper" 12 | "github.com/ADimeo/MensaQueueBot/telegram_connector" 13 | "github.com/ADimeo/MensaQueueBot/utils" 14 | "github.com/gin-gonic/gin" 15 | "github.com/go-rod/rod" 16 | "github.com/go-rod/rod/lib/launcher" 17 | "github.com/golang-migrate/migrate/v4" 18 | "github.com/golang-migrate/migrate/v4/database/sqlite3" 19 | _ "github.com/golang-migrate/migrate/v4/source/file" 20 | "go.uber.org/zap" 21 | ) 22 | 23 | // const KEY_PERSONAL_TOKEN string = "MENSA_QUEUE_BOT_PERSONAL_TOKEN" // Defined in utils/utils.go, here for reference 24 | 25 | const MENSA_LOCATION_JSON_LOCATION string = "./mensa_locations.json" 26 | 27 | const REPORT_REGEX string = `^L\d: ` // A message that matches this regex is a length report, and should be treated as such 28 | const POINTS_REGEX string = `^/points(_track|_delete|_help|)$` 29 | 30 | var globalEmojiOfTheDay emojiOfTheDay 31 | 32 | type mensaLocation struct { 33 | PhotoUrl string `json:"photo_url"` 34 | Description string `json:"description"` 35 | } 36 | 37 | type emojiOfTheDay struct { 38 | Timestamp time.Time 39 | Emoji rune 40 | } 41 | 42 | /* 43 | People like emoji. People also like slot machines. Return a random, pre-vetted emoji when they 44 | report, for "engagement" 45 | 46 | One emoji per day is chosen. 47 | */ 48 | func GetRandomAcceptableEmoji() rune { 49 | timestampNow := time.Now() 50 | dateToday := timestampNow.YearDay() 51 | if globalEmojiOfTheDay.Emoji == 0 || dateToday != globalEmojiOfTheDay.Timestamp.YearDay() { 52 | // Regenerate 53 | emojiFilepath := "./emoji_list" 54 | emojiFile, err := os.Open(emojiFilepath) 55 | if err != nil { 56 | zap.S().Errorf("Can't access emoji file at", emojiFilepath) 57 | } 58 | defer emojiFile.Close() 59 | 60 | emojiAsBytes, err := ioutil.ReadAll(emojiFile) 61 | if err != nil { 62 | zap.S().Errorf("Can't access emoji file at", emojiFilepath) 63 | } 64 | 65 | emojiRunesSlice := []rune(string(emojiAsBytes)) 66 | pseudorandomPosition := rand.Intn(len(emojiRunesSlice)) 67 | globalEmojiOfTheDay.Emoji = emojiRunesSlice[pseudorandomPosition] 68 | globalEmojiOfTheDay.Timestamp = timestampNow 69 | 70 | } 71 | return globalEmojiOfTheDay.Emoji 72 | } 73 | 74 | func parseRequest(c *gin.Context) (*telegram_connector.WebhookRequestBody, error) { 75 | body := &telegram_connector.WebhookRequestBody{} 76 | err := c.ShouldBind(&body) 77 | return body, err 78 | } 79 | 80 | func updateUserFromLegacy(chatID int) { 81 | // Update state in the DB 82 | err := db_connectors.UpdateUserPreferences(chatID, true, 600, 840, 0b0111110) // Default from 11:00 to 14:00 83 | if err != nil { 84 | zap.S().Error(err) 85 | } 86 | } 87 | 88 | // Gets the newest changelog from the changelog file and sends it to the 89 | // user if they haven't received it yet 90 | func sendChangelogIfNecessary(chatID int) { 91 | numberOfLastSentChangelog := db_connectors.GetLatestChangelogSentToUser(chatID) 92 | changelog, noChangelogWithIDError := db_connectors.GetCurrentChangelog() 93 | 94 | if noChangelogWithIDError != nil { 95 | zap.S().Error("Can't get latest changelog: ", noChangelogWithIDError) 96 | return 97 | } 98 | 99 | if numberOfLastSentChangelog >= changelog.Id { 100 | return 101 | } 102 | 103 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.PUSH_MESSAGE, chatID) 104 | if err := telegram_connector.SendMessage(chatID, changelog.Text, keyboardIdentifier); err != nil { 105 | zap.S().Error("Got an error while sending changelog to user.", err) 106 | } else { 107 | db_connectors.SaveNewChangelogForUser(chatID, changelog.Id) 108 | } 109 | } 110 | 111 | func sendQueueLengthExamples(chatID int) { 112 | mensaLocationArray := *GetMensaLocationSlice() 113 | for _, mensaLocation := range mensaLocationArray { 114 | if mensaLocation.PhotoUrl != "" { 115 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.IMAGE_REQUEST, chatID) 116 | err := telegram_connector.SendStaticWebPhoto(chatID, mensaLocation.PhotoUrl, mensaLocation.Description, keyboardIdentifier) 117 | if err != nil { 118 | zap.S().Error("Error while sending help message photographs.", err) 119 | } 120 | 121 | } 122 | } 123 | SendTopViewOfMensa(chatID) 124 | } 125 | 126 | func legacyRequestSwitch(chatID int, sentMessage string, bodyAsStruct *telegram_connector.WebhookRequestBody) { 127 | lengthReportRegex := regexp.MustCompile(REPORT_REGEX) 128 | pointsRegex := regexp.MustCompile(POINTS_REGEX) 129 | switch { 130 | case sentMessage == "/start": 131 | { 132 | zap.S().Info("Migrating from /start") 133 | requestSwitch(chatID, "/start", bodyAsStruct) 134 | } 135 | case sentMessage == "/help": 136 | { 137 | zap.S().Info("Migrating from /help") 138 | requestSwitch(chatID, "/help", bodyAsStruct) 139 | } 140 | case pointsRegex.Match([]byte(sentMessage)): 141 | { 142 | zap.S().Info("Migrating from points message") 143 | requestSwitch(chatID, "/settings", bodyAsStruct) 144 | } 145 | case sentMessage == "/jetze": 146 | { 147 | zap.S().Info("Migrating from /jetze") 148 | requestSwitch(chatID, "Queue?", bodyAsStruct) 149 | telegram_connector.SendMessage(chatID, "Upgrading your keyboard...", telegram_connector.MainKeyboard) 150 | } 151 | case sentMessage == "/jetze@MensaQueueBot": 152 | zap.S().Info("Migrating from /jetze, but in group") 153 | requestSwitch(chatID, "Queue?", bodyAsStruct) 154 | telegram_connector.SendMessage(chatID, "Upgrading your keyboard...", telegram_connector.MainKeyboard) 155 | case lengthReportRegex.Match([]byte(sentMessage)): 156 | { 157 | zap.S().Info("Migrating from report") 158 | requestSwitch(chatID, sentMessage, bodyAsStruct) 159 | } 160 | case sentMessage == "/forgetme": 161 | { 162 | zap.S().Infof("User requested deletion of their data: %s", sentMessage) 163 | requestSwitch(chatID, "/forgetme", bodyAsStruct) 164 | } 165 | case sentMessage == "/joinABTesters": // In the future reading secret codes might be interesting 166 | { 167 | zap.S().Infof("Migrating from joinABTEsters", chatID) 168 | requestSwitch(chatID, sentMessage, bodyAsStruct) 169 | } 170 | case sentMessage == "": // this likely means that user used a keyboard-html-button thingy 171 | { 172 | zap.S().Infof("Migrating from keyboard?", chatID) 173 | requestSwitch(chatID, sentMessage, bodyAsStruct) 174 | } 175 | case sentMessage == "/platypus": 176 | { 177 | zap.S().Infof("Migrating from platypus?", chatID) 178 | requestSwitch(chatID, sentMessage, bodyAsStruct) 179 | telegram_connector.SendMessage(chatID, "Upgrading your keyboard...", telegram_connector.MainKeyboard) 180 | } 181 | case sentMessage == "/settings": 182 | { 183 | zap.S().Infof("Migrating from /settings", chatID) 184 | requestSwitch(chatID, "/settings", bodyAsStruct) 185 | } 186 | default: 187 | { 188 | zap.S().Infof("Received unknown message in legacy: %s", sentMessage) 189 | } 190 | } 191 | } 192 | 193 | func requestSwitch(chatID int, sentMessage string, bodyAsStruct *telegram_connector.WebhookRequestBody) { 194 | lengthReportRegex := regexp.MustCompile(REPORT_REGEX) 195 | switch { 196 | // CASES FROM MAIN KEYBOARD 197 | case sentMessage == "Queue?": 198 | { 199 | zap.S().Info("Received a 'Queue?' request") 200 | GenerateAndSendGraphicQueueLengthReport(chatID) 201 | sendChangelogIfNecessary(chatID) 202 | } 203 | case sentMessage == "Menu?": 204 | { 205 | zap.S().Info("Received a 'Menu?' request") 206 | if err := mensa_scraper.SendLatestMenuToSingleUser(chatID); err != nil { 207 | message := "I'm so sorry, I can't find the current menu for today 🤕" 208 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.INFO_REQUEST, chatID) 209 | telegram_connector.SendMessage(chatID, message, keyboardIdentifier) 210 | 211 | } 212 | sendChangelogIfNecessary(chatID) 213 | } 214 | case sentMessage == "Report!": 215 | { 216 | zap.S().Info("Received a 'Report!' request") 217 | HandleNavigationToReportKeyboard(sentMessage, chatID) 218 | } 219 | // CASES FROM REPORT KEYBOARD 220 | case lengthReportRegex.Match([]byte(sentMessage)): 221 | { 222 | zap.S().Info("Received a new report: %s", sentMessage) 223 | messageUnixTime := bodyAsStruct.Message.Date 224 | HandleLengthReport(sentMessage, messageUnixTime, chatID) 225 | sendChangelogIfNecessary(chatID) 226 | } 227 | case sentMessage == "Can't tell": 228 | { 229 | zap.S().Info("Received a 'Can't tell' report") 230 | message := "Alrighty" 231 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.LENGTH_REPORT, chatID) 232 | telegram_connector.SendMessage(chatID, message, keyboardIdentifier) 233 | sendChangelogIfNecessary(chatID) 234 | } 235 | // CASES FROM SETTINGS KEYBOARD 236 | case sentMessage == "/settings": 237 | { 238 | // Let's not forget how to get to the settings screen... 239 | zap.S().Info("Received a '/settings' request") 240 | SendSettingsOverviewMessage(chatID, false) 241 | } 242 | case sentMessage == "General Help": 243 | { 244 | // Revamping this is contained within a different issue... 245 | zap.S().Info("Received a 'General Help' requets") 246 | SendHelpMessage(chatID) 247 | } 248 | case sentMessage == "Points Help": 249 | { 250 | zap.S().Info("Received a 'Points Help' requets") 251 | SendPointsHelpMessages(chatID) 252 | } 253 | case sentMessage == "": // this likely means that user used a keyboard-html-button thingy 254 | { 255 | zap.S().Debug("User is changing settings") 256 | HandleSettingsChange(chatID, bodyAsStruct.Message.WebAppData) 257 | } 258 | case sentMessage == "Account Deletion": 259 | { 260 | // This is just for info 261 | zap.S().Info("Received a 'Account Deletion' request") 262 | message := "To delete all data about you from MensaQueueBot type /forgetme in the chat. Be advised that this action is destructive, and nonreversible. If you ever decide to come back you will be an entirely new user." 263 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.SETTINGS_INTERACTION, chatID) 264 | telegram_connector.SendMessage(chatID, message, keyboardIdentifier) 265 | } 266 | case sentMessage == "Back": 267 | { 268 | zap.S().Info("Received a 'Back' report") 269 | message := "Back to my purpose " + string(GetRandomAcceptableEmoji()) 270 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.PREPARE_MAIN, chatID) 271 | telegram_connector.SendMessage(chatID, message, keyboardIdentifier) 272 | sendChangelogIfNecessary(chatID) 273 | } 274 | // OTHER CASES 275 | case sentMessage == "/start": 276 | { 277 | zap.S().Info("Sending onboarding (/start) messages") 278 | SendWelcomeMessage(chatID) 279 | sendChangelogIfNecessary(chatID) 280 | } 281 | case sentMessage == "/help": 282 | { 283 | zap.S().Info("Sending queue length (/help) messages") 284 | SendHelpMessage(chatID) 285 | } 286 | case sentMessage == "/length_illustrations": 287 | { 288 | zap.S().Info("Sending length illustrations") 289 | sendQueueLengthExamples(chatID) 290 | } 291 | case sentMessage == "/forgetme": 292 | { 293 | zap.S().Infof("User requested deletion of their data: %s", sentMessage) 294 | HandleAccountDeletion(chatID) 295 | } 296 | case sentMessage == "/joinABTesters": // In the future reading secret codes might be interesting 297 | { 298 | zap.S().Infof("User %d is joining test group", chatID) 299 | HandleABTestJoining(chatID) 300 | } 301 | case sentMessage == "/platypus": 302 | { 303 | zap.S().Infof("PLATYPUS!") 304 | url := "https://upload.wikimedia.org/wikipedia/commons/4/4a/%22Nam_Sang_Woo_Safety_Matches%22_platypus_matchbox_label_art_-_from%2C_Collectie_NMvWereldculturen%2C_TM-6477-76%2C_Etiketten_van_luciferdoosjes%2C_1900-1949_%28cropped%29.jpg" 305 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.TUTORIAL_MESSAGE, chatID) // Not technically correct, but eh 306 | telegram_connector.SendStaticWebPhoto(chatID, url, "So cute ❤️", keyboardIdentifier) 307 | } 308 | default: 309 | { 310 | zap.S().Infof("Received unknown message: %s", sentMessage) 311 | } 312 | 313 | } 314 | 315 | } 316 | 317 | func reactToRequest(ginContext *gin.Context) { 318 | // Return some 200 or something 319 | 320 | bodyAsStruct, err := parseRequest(ginContext) 321 | if err == nil { 322 | ginContext.JSON(200, gin.H{ 323 | "message": "Thanks nice server", 324 | }) 325 | } else { 326 | zap.S().Error("Inbound data from telegram couldn't be parsed", err) 327 | } 328 | 329 | sentMessage := bodyAsStruct.Message.Text 330 | chatID := bodyAsStruct.Message.Chat.ID 331 | 332 | if db_connectors.UserHasBeenMigrated(chatID) { 333 | requestSwitch(chatID, sentMessage, bodyAsStruct) 334 | } else { 335 | zap.S().Infof("Migrating user from legacy: %d", chatID) 336 | updateUserFromLegacy(chatID) 337 | legacyRequestSwitch(chatID, sentMessage, bodyAsStruct) 338 | } 339 | } 340 | 341 | /* 342 | Initiates our zap logger. We only log to Stdout, which our deployment setup will automatically forward to docker logs 343 | */ 344 | func initiateLogger() { 345 | // As per https://blog.sandipb.net/2018/05/04/using-zap-working-with-global-loggers/ 346 | // Using a single global logger is discouraged, but it's a tradeoff I'm willing to make 347 | logger, _ := zap.NewDevelopment() 348 | zap.ReplaceGlobals(logger) 349 | } 350 | 351 | // Accesses a number of variables in order to crash early 352 | // if some configuration flaw exists 353 | // We only call methods that aren't already called directly in main() 354 | func runEnvironmentTests() { 355 | telegram_connector.GetTelegramToken() 356 | GetMensaLocationSlice() 357 | telegram_connector.LoadAllKeyboardsForTest() 358 | utils.GetLocalLocation() 359 | db_connectors.GetCurrentChangelog() 360 | 361 | // We also init rod, which makes sure that the 362 | // browser interaction works 363 | u := launcher.New().Bin("/usr/bin/google-chrome").MustLaunch() 364 | browser := rod.New().ControlURL(u).MustConnect() 365 | browser.MustPage("https://google.com").MustWaitLoad() 366 | browser.MustClose() 367 | } 368 | 369 | func initDatabases() { 370 | dbHandle := db_connectors.GetDBHandle() 371 | driver, err := sqlite3.WithInstance(dbHandle, &sqlite3.Config{}) 372 | if err != nil { 373 | zap.S().Panic("Can't get DB driver for migrations:", err) 374 | } 375 | m, err := migrate.NewWithDatabaseInstance("file://./db/migrations", "sqlite3", driver) 376 | if err != nil { 377 | zap.S().Panic("Can't get migrate instance: ", err) 378 | } 379 | version, _, err := m.Version() 380 | if err != nil { 381 | zap.S().Panic("Can't get DB version! ", err) 382 | } 383 | if version < db_connectors.GetDBVersion() { 384 | err = m.Migrate(db_connectors.GetDBVersion()) 385 | if err != nil { 386 | zap.S().Panic("Can't run migrations: ", err) 387 | } 388 | } 389 | 390 | } 391 | 392 | func main() { 393 | initiateLogger() 394 | runEnvironmentTests() 395 | zap.S().Info("Initializing Server...") 396 | 397 | // Only used for non-critical operations 398 | rand.Seed(time.Now().UnixNano()) 399 | initDatabases() 400 | personalToken := utils.GetPersonalToken() 401 | 402 | mensa_scraper.ScheduleScrapeJob() 403 | mensa_scraper.ScheduleDailyInitialMessageJob() 404 | 405 | r := gin.Default() 406 | // r.SetTrustedProxies([]string{"172.21.0.2"}) 407 | // We trust all proxies, [as is insecure default in gin](https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies) 408 | // That shouldn't be a problem since we have 409 | // a reverse proxy in front of this server, and it "shouldn't" be 410 | // directly reachable from anywhere else. 411 | // We don't want to trust that reverse proxy explicitly because 412 | // it's wihtin our docker network, and assigning static IP addresses 413 | // to containers [may not be recommended](https://stackoverflow.com/questions/39493490/provide-static-ip-to-docker-containers-via-docker-compose) 414 | 415 | personalURLPath := "/" + personalToken + "/" 416 | zap.S().Infof("Sub-URL is %s", personalURLPath) 417 | 418 | r.POST(personalURLPath, reactToRequest) 419 | r.Run() 420 | } 421 | -------------------------------------------------------------------------------- /mensa_handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/ADimeo/MensaQueueBot/db_connectors" 5 | "github.com/ADimeo/MensaQueueBot/utils" 6 | "go.uber.org/zap" 7 | ) 8 | 9 | // Temporary function that adds user to mensa receivers list (which we are currently A/B Testing) 10 | func ABTestHandler(userID int) { 11 | // Adds them all day every day to all messages 12 | var err error 13 | if utils.IsInDebugMode() { 14 | err = db_connectors.UpdateUserPreferences(userID, true, 0, 1440, 0b0111110) // Default from 0:00 to 24:00 15 | } else { 16 | err = db_connectors.UpdateUserPreferences(userID, true, 600, 840, 0b0111110) // Default from 10:00 to 14:00 17 | } 18 | 19 | if err != nil { 20 | zap.S().Error(err) 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /mensa_locations.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "photo_url": "https://raw.githubusercontent.com/ADimeo/MensaQueueBot/f9670fd5c57d05152951fc89a5a7cf9174aa8b78/queue_length_illustrations/L00_practically_empty.jpg?raw=true", 4 | "description": "L0: Virtually empty" 5 | }, 6 | { 7 | "photo_url": "https://github.com/ADimeo/MensaQueueBot/blob/f9670fd5c57d05152951fc89a5a7cf9174aa8b78/queue_length_illustrations/L01_within_kitchen.jpg?raw=true", 8 | "description": "L1: Within kitchen" 9 | }, 10 | { 11 | "photo_url": "https://github.com/ADimeo/MensaQueueBot/blob/f9670fd5c57d05152951fc89a5a7cf9174aa8b78/queue_length_illustrations/L02_up_to_food_trays.jpg?raw=true", 12 | "description": "L2: Up to food trays" 13 | }, 14 | { 15 | "photo_url": "https://github.com/ADimeo/MensaQueueBot/blob/f9670fd5c57d05152951fc89a5a7cf9174aa8b78/queue_length_illustrations/L03_within_first_room.jpg?raw=true", 16 | "description": "L3: Within first room" 17 | }, 18 | { 19 | "photo_url": "https://github.com/ADimeo/MensaQueueBot/blob/f9670fd5c57d05152951fc89a5a7cf9174aa8b78/queue_length_illustrations/L04_starting_to_corner.jpg?raw=true", 20 | "description": "L4: Starting to corner" 21 | }, 22 | { 23 | "photo_url": "https://github.com/ADimeo/MensaQueueBot/blob/f9670fd5c57d05152951fc89a5a7cf9174aa8b78/queue_length_illustrations/L05_past_first_desk.jpg?raw=true", 24 | "description": "L5: Past first desk" 25 | }, 26 | { 27 | "photo_url": "https://github.com/ADimeo/MensaQueueBot/blob/f9670fd5c57d05152951fc89a5a7cf9174aa8b78/queue_length_illustrations/L06_past_second_desk.jpg?raw=true", 28 | "description": "L6: Past second desk" 29 | }, 30 | { 31 | "photo_url": "https://github.com/ADimeo/MensaQueueBot/blob/f9670fd5c57d05152951fc89a5a7cf9174aa8b78/queue_length_illustrations/L07_up_to_stairs.jpg?raw=true", 32 | "description": "L7: Up to stairs" 33 | }, 34 | { 35 | "photo_url": "", 36 | "description": "L8: Even longer" 37 | } 38 | ] 39 | -------------------------------------------------------------------------------- /mensa_scraper/mensa_messenger.go: -------------------------------------------------------------------------------- 1 | package mensa_scraper 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/ADimeo/MensaQueueBot/db_connectors" 9 | "github.com/ADimeo/MensaQueueBot/telegram_connector" 10 | "github.com/ADimeo/MensaQueueBot/utils" 11 | "github.com/go-co-op/gocron" 12 | "github.com/hashicorp/go-multierror" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | var globalLastInitialMessageCESTMinute int 17 | 18 | // Please only modify in scheduleNextInitialMessage 19 | // Gets overridden regularly 20 | var globalInitialMessageScheduler *gocron.Scheduler 21 | 22 | /* 23 | Responsible for initialising first of two regular jobs. 24 | "DailyInitialMessageJob" sends the initial message to users, 25 | which is to say the message a user receives when their window of interest 26 | for mensa menus opens (sent at 09:00 if user wants info from 09:00 to 10:00). 27 | 28 | This task schedules itself hop-to-hop, so on each execution it queries the DB 29 | for when it should next run, and schedules a new task for that point in time. 30 | 31 | */ 32 | func ScheduleDailyInitialMessageJob() { 33 | nowInUTC := time.Now().UTC() 34 | nowInLocal := nowInUTC.In(utils.GetLocalLocation()) 35 | 36 | nowCESTMinute := nowInLocal.Hour()*60 + nowInLocal.Minute() 37 | globalLastInitialMessageCESTMinute = nowCESTMinute 38 | err := scheduleNextInitialMessage(nowInUTC, nowCESTMinute) 39 | if err != nil { 40 | zap.S().Error("Couldn't' initialise initial messages", err) 41 | } 42 | } 43 | 44 | func initialMessageJob() { 45 | zap.S().Infof("Prepping to send initial messages...") 46 | nowInUTC := time.Now().UTC() 47 | nowInLocal := nowInUTC.In(utils.GetLocalLocation()) 48 | nowCESTMinute := nowInLocal.Hour()*60 + nowInLocal.Minute() 49 | if err := sendInitialMessagesThatShouldBeSentAt(nowInUTC, nowCESTMinute); err != nil { 50 | zap.S().Error("Couldn't' send initial messages", err) 51 | } 52 | globalLastInitialMessageCESTMinute = nowCESTMinute 53 | err := scheduleNextInitialMessage(nowInUTC, nowCESTMinute) 54 | if err != nil { 55 | zap.S().Error("Couldn't' schedule next initial messages", err) 56 | } 57 | } 58 | 59 | /* 60 | For a given timeStringInCEST this updates the next initialMessage job. 61 | Specifically, if we would skip the newly inserted initial message 62 | (Since it would still run today, but the next scheduled job is after 63 | this message should run) we clear the initial job, and recreate it 64 | with this given message in mind. 65 | 66 | Call after DB operation of inserting the new settings have finished! 67 | This queries the DB 68 | 69 | This function will reschedule overeagerly, but that shouldn't be a problem 70 | since underlying functions should be idempotent if run at the same time with 71 | the same db state 72 | */ 73 | func RescheduleNextInitialMessageJobIfNeeded(insertedTimeStringInCEST string) { 74 | if globalInitialMessageScheduler.Len() == 0 { 75 | // Scheduler has no jobs at all 76 | // This should not happen, but let's schedule a job to fix it 77 | zap.S().Error("Initial job scheduler had no jobs during settings change") 78 | nowInUTC := time.Now().UTC() 79 | nowInCEST := nowInUTC.In(utils.GetLocalLocation()) 80 | nowCESTMinute := nowInCEST.Hour()*60 + nowInCEST.Minute() 81 | scheduleNextInitialMessage(nowInUTC, nowCESTMinute) 82 | return 83 | } 84 | 85 | nextInitialJob := globalInitialMessageScheduler.Jobs()[0] 86 | jobTimeInCEST := nextInitialJob.ScheduledAtTime() // Returns 10:00 string 87 | jobTimeAsTime, _ := time.Parse("15:04", jobTimeInCEST) 88 | newTimeAsTime, _ := time.Parse("15:04", insertedTimeStringInCEST) 89 | if newTimeAsTime.Before(jobTimeAsTime) { 90 | // Newly scheduled job would be up first. 91 | // Q: Will it need to be scheduled for today? 92 | // (Technically scheduleNextInitialMessage should take care of that case, 93 | // but this adds some redundancy 94 | nowInUTC := time.Now().UTC() 95 | nowInCEST := nowInUTC.In(utils.GetLocalLocation()) 96 | // Let's add two minutes of leeway, just so we don't accidentally schedule this for tomorrow 97 | twoMinutes, _ := time.ParseDuration("2m") 98 | soonInCEST := nowInCEST.Add(twoMinutes) 99 | soonTimeStringInCEST := soonInCEST.Format("15:04") 100 | soonTime, _ := time.Parse("15:04", soonTimeStringInCEST) 101 | if newTimeAsTime.After(soonTime) { 102 | zap.S().Info("Rescheduling initial menu job during settings change") 103 | // This is would be the next job for today, 104 | // We need to reschedule 105 | globalInitialMessageScheduler.Clear() 106 | nowCESTMinute := nowInCEST.Hour()*60 + nowInCEST.Minute() 107 | scheduleNextInitialMessage(nowInUTC, nowCESTMinute) 108 | } 109 | } 110 | } 111 | 112 | /* 113 | scheduleNextInitialMessage schedules the next initialMessage job based on what is stored in the DB. 114 | Specifically, this will return the next time during which any user wants to 115 | receive a mensa menu update which 116 | - is after nowCESTMinute (hh*60+mm) 117 | - user wants messages 118 | - on this weekday 119 | - and hasn't reported yet 120 | 121 | */ 122 | func scheduleNextInitialMessage(nowInUTC time.Time, nowCESTMinute int) error { 123 | cestMinuteForNextJob, err := db_connectors.GetCESTMinuteForNextIntroMessage(nowInUTC, nowCESTMinute) 124 | if err != nil { 125 | zap.S().Error("Couldn't get next time for initial messages", err) 126 | return err 127 | } 128 | cestHoursForJob := cestMinuteForNextJob / 60 129 | cestMinutesForJob := cestMinuteForNextJob % 60 130 | timestampString := fmt.Sprintf("%02d:%02d", cestHoursForJob, cestMinutesForJob) 131 | 132 | globalInitialMessageScheduler = gocron.NewScheduler(utils.GetLocalLocation()) 133 | globalInitialMessageScheduler.Every(1).Day().At(timestampString).LimitRunsTo(1).Do(initialMessageJob) 134 | zap.S().Infof("Next initial message scheduled for %s", timestampString) 135 | 136 | globalInitialMessageScheduler.StartAsync() 137 | return nil 138 | } 139 | 140 | func sendInitialMessagesThatShouldBeSentAt(nowInUTC time.Time, nowCESTMinute int) error { 141 | var openingCESTMinute int 142 | if globalLastInitialMessageCESTMinute > nowCESTMinute { 143 | // The last message we sent had a timestamp after now, this means it was send on a different day 144 | // -> Re-Start the interval at 0 145 | openingCESTMinute = 0 146 | } else { 147 | openingCESTMinute = globalLastInitialMessageCESTMinute 148 | } 149 | users, err := db_connectors.GetUsersWithInitialMessageInTimeframe(nowInUTC, openingCESTMinute, nowCESTMinute) 150 | if err != nil { 151 | zap.S().Errorw("Can't get users that want initial message in this timeframe", "window lower bound", globalLastInitialMessageCESTMinute, "window upper bound", nowCESTMinute, "error", err) 152 | return err 153 | } 154 | 155 | return sendLatestMenuToUsers(users) 156 | } 157 | 158 | /* 159 | SendLatestMenuToUsersCurrentlyListening gets a list of all users which want to be advised about a mensa change right now, 160 | and sends them a message of the current menu. 161 | Via the queries this calls it keeps in mind additional requirements (weekday, send at all flag), 162 | previously reported on this date) 163 | */ 164 | func SendLatestMenuToUsersCurrentlyListening() error { 165 | // Called by menu scraper 166 | nowInUTC := time.Now().UTC() 167 | idsOfInterestedUsers, err := db_connectors.GetUsersToSendMenuToByTimestamp(nowInUTC) 168 | if err != nil { 169 | return err 170 | } 171 | return sendLatestMenuToUsers(idsOfInterestedUsers) 172 | } 173 | 174 | /* 175 | SendLatestMenuToSingleUser sends tha most recently added menu to the given user 176 | 177 | */ 178 | func SendLatestMenuToSingleUser(userID int) error { 179 | sliceOfUserID := []int{userID} 180 | 181 | return sendLatestMenuToUsers(sliceOfUserID) 182 | } 183 | 184 | func sendLatestMenuToUsers(idsOfInterestedUsers []int) error { 185 | if len(idsOfInterestedUsers) == 0 { 186 | zap.S().Infof("Tried to send latest menu to empty list of users") 187 | return nil 188 | } 189 | latestOffersInDB, err := db_connectors.GetLatestMensaOffersFromToday() 190 | if err != nil { 191 | return err 192 | } 193 | if len(latestOffersInDB) == 0 { 194 | return errors.New("No menu from today available") 195 | } 196 | formattedMessage := buildMessageFrom(latestOffersInDB) 197 | 198 | var errorsForAllSends error 199 | for _, userID := range idsOfInterestedUsers { 200 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.PUSH_MESSAGE, userID) 201 | if err = telegram_connector.SendMessage(userID, formattedMessage, keyboardIdentifier); err != nil { 202 | errorsForAllSends = multierror.Append(errorsForAllSends, err) 203 | } 204 | } 205 | return errorsForAllSends 206 | } 207 | 208 | func buildMessageFrom(offerSlice []db_connectors.DBOfferInformation) string { 209 | if len(offerSlice) == 0 { 210 | return "Griebnitzsee currently offers no menus" 211 | } 212 | 213 | baseMessage := "Current Griebnitzsee Menu:\n" 214 | baseForSingleOffer := "%s: %s\n" 215 | 216 | actualMessage := "" + baseMessage 217 | 218 | for _, offer := range offerSlice { 219 | actualMessage = actualMessage + fmt.Sprintf(baseForSingleOffer, offer.Title, offer.Description) 220 | } 221 | return actualMessage 222 | } 223 | -------------------------------------------------------------------------------- /mensa_scraper/mensa_scraper.go: -------------------------------------------------------------------------------- 1 | package mensa_scraper 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/ADimeo/MensaQueueBot/db_connectors" 11 | "github.com/ADimeo/MensaQueueBot/utils" 12 | "github.com/go-co-op/gocron" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | const MENSA_URL string = "https://swp.webspeiseplan.de/index.php?token=55ed21609e26bbf68ba2b19390bf7961&model=menu&location=9601&languagetype=1&_=1696321056188" // Please be static token, pleeaaaaase! 17 | const MENSA_TITLE_URL string = "https://swp.webspeiseplan.de/index.php?token=55ed21609e26bbf68ba2b19390bf7961&model=mealCategory&location=9601&languagetype=1&_=1696589384933" 18 | 19 | // Structs for representing the menu/title in json 20 | type EssensTitle struct { 21 | Name string `json:"name"` 22 | GerichtskategorieID int `json:"gerichtkategorieID"` 23 | } 24 | 25 | type EssensTitleRoot struct { 26 | Success bool `json:"success"` 27 | Content []EssensTitle `json:"content"` 28 | } 29 | 30 | type SpeiseplanAdvancedGericht struct { 31 | Aktiv bool `json:"aktiv"` 32 | Datum string `json:"datum"` 33 | GerichtKategorieID int `json:"gerichtkategorieID"` 34 | Gerichtname string `json:"gerichtname"` 35 | GerichtTitle string `json:"enrichThisManually,omitempty"` // Enriched manually 36 | } 37 | 38 | type SpeiseplanAdvancedGerichtData struct { 39 | Gericht SpeiseplanAdvancedGericht `json:"SpeiseplanAdvancedGericht"` 40 | } 41 | 42 | // This contains two objects, one for each week - but we only need the Gericht 43 | type SpeiseplanWeek struct { 44 | SpeiseplanGerichtData []SpeiseplanAdvancedGerichtData `json:"speiseplanGerichtData"` 45 | } 46 | 47 | type MenuRoot struct { 48 | Success bool `json:"success"` 49 | Content []SpeiseplanWeek `json:"content"` 50 | } 51 | 52 | func ScheduleScrapeJob() { 53 | schedulerInMensaTimezone := gocron.NewScheduler(utils.GetLocalLocation()) 54 | cronBaseSyntax := "*/10 %d-%d * * 1-5" // Run every 10 minutes, every weekday, between two timestamps 55 | // which should be filled in from mensa opening and closing time 56 | 57 | mensaOpeningHours := utils.GetMensaOpeningTime().Hour() 58 | mensaClosingHours := utils.GetMensaClosingTime().Hour() 59 | formattedCronString := fmt.Sprintf(cronBaseSyntax, mensaOpeningHours, mensaClosingHours) 60 | 61 | if utils.IsInDebugMode() { 62 | formattedCronString = "*/1 * * * *" 63 | } 64 | schedulerInMensaTimezone.Cron(formattedCronString).Do(ScrapeAndAdviseUsers) 65 | 66 | schedulerInMensaTimezone.StartAsync() 67 | // Don't need to care about shutdown, shutdown happens when the container shuts down, 68 | // and startup happens when the container starts up 69 | 70 | } 71 | 72 | func ScrapeAndAdviseUsers() { 73 | zap.S().Info("Running mensa scrape job") 74 | shouldUsersBeNotified := scrapeAndInsertIfMensaMenuIsOld() 75 | if shouldUsersBeNotified { 76 | err := SendLatestMenuToUsersCurrentlyListening() 77 | if err != nil { 78 | zap.S().Error("Couldn't send menu to interested users", err) 79 | } 80 | } 81 | } 82 | 83 | // Returns true if something was inserted 84 | func scrapeAndInsertIfMensaMenuIsOld() bool { 85 | menu, err := getMensaMenuFromWeb() 86 | if err != nil { 87 | zap.S().Errorf("Can't get menu from interweb", err) 88 | return false 89 | } 90 | today := time.Now().In(utils.GetLocalLocation()) 91 | mealsForToday := getMealsForToday(menu, today) 92 | if len(mealsForToday) == 0 { 93 | zap.S().Errorf("Can't find meals for today", err) 94 | return false 95 | } 96 | todaysInformationWithTitles, err := enrichWithTitleData(mealsForToday) 97 | 98 | if isDateInformationFresh(todaysInformationWithTitles) { 99 | zap.S().Debug("Mensa menu is stale") 100 | // No changes in menu, nothing to insert or do. 101 | return false 102 | } 103 | insertDateOffersIntoDBWithFreshCounter(today, todaysInformationWithTitles) 104 | zap.S().Debug("Succesfully inserted new menu into DB") 105 | return true 106 | } 107 | 108 | func enrichWithTitleData(todaysInformation []SpeiseplanAdvancedGericht) ([]SpeiseplanAdvancedGericht, error) { 109 | mensaEnrichmentClient := &http.Client{} 110 | mensaEnrichmentRequest, _ := http.NewRequest("GET", MENSA_TITLE_URL, nil) 111 | mensaEnrichmentRequest.Header.Set("Referer", "https://swp.webspeiseplan.de/Menu") 112 | 113 | response, err := mensaEnrichmentClient.Do(mensaEnrichmentRequest) 114 | 115 | if err != nil { 116 | zap.S().Warn("Can't reach json with meal<->Essen N mapping. Is their service down?", err) 117 | return todaysInformation, err 118 | } 119 | 120 | defer response.Body.Close() 121 | body, err := io.ReadAll(response.Body) 122 | if err != nil { 123 | zap.S().Warn("Can't read json with meal<-> essen mapping. Is their service working?", err) 124 | return todaysInformation, err 125 | } 126 | 127 | titleRoot, err := parseTitleJSON(body) 128 | if err != nil { 129 | zap.S().Error("Can't parse title json. Did the format change?", err) 130 | return todaysInformation, err 131 | } 132 | 133 | for index, meal := range todaysInformation { 134 | mealID := meal.GerichtKategorieID 135 | 136 | for _, titleObject := range titleRoot.Content { 137 | if titleObject.GerichtskategorieID == mealID { 138 | meal.GerichtTitle = titleObject.Name 139 | todaysInformation[index] = meal 140 | break 141 | } 142 | } 143 | } 144 | return todaysInformation, nil 145 | } 146 | 147 | func insertDateOffersIntoDBWithFreshCounter(scrapeTimestamp time.Time, todaysInformationWithTitles []SpeiseplanAdvancedGericht) { 148 | counterValue, err := db_connectors.GetMensaMenuCounter() 149 | if err != nil { 150 | zap.S().Error("Couldn't insert new menus: Unable to get counter value", err) 151 | return 152 | } 153 | 154 | for _, downOffer := range todaysInformationWithTitles { 155 | offerToInsert := new(db_connectors.DBOfferInformation) 156 | offerToInsert.Counter = counterValue + 1 157 | offerToInsert.Title = downOffer.GerichtTitle 158 | offerToInsert.Description = downOffer.Gerichtname 159 | offerToInsert.Time = scrapeTimestamp 160 | 161 | // Few enough that not batching is fine, I think 162 | // But batching this is something we could do 163 | db_connectors.InsertMensaMenu(offerToInsert) 164 | } 165 | } 166 | 167 | // Return true if the offers within this date information are the same as the 168 | // ones we last stored in the DB 169 | func isDateInformationFresh(mealsForToday []SpeiseplanAdvancedGericht) bool { 170 | // Query DB for latest menus 171 | dbOffers, err := db_connectors.GetLatestMensaOffersFromToday() 172 | if err != nil { 173 | zap.S().Errorf("Can not determine freshness of queried menu, defaulting to don't insert", err) 174 | return true 175 | 176 | } 177 | if len(mealsForToday) != len(dbOffers) { 178 | return false 179 | } 180 | 181 | // Needs to be full of true 182 | var comparisonResultsSlice = make([]bool, len(mealsForToday)) 183 | 184 | // This has a runtime of n^2, but for like five elements. 185 | // We have duplicate title keys, so it's either that or a bunch of logic 186 | // that is more complicated than necessary. 187 | for downIndex, downOffer := range mealsForToday { 188 | for dbIndex, dbOffer := range dbOffers { 189 | if dbOffer.Title == downOffer.GerichtTitle && 190 | dbOffer.Description == downOffer.Gerichtname { 191 | dbDayString := dbOffer.Time.Format("2006-01-02") 192 | if dbDayString == downOffer.Datum { 193 | // Elements are the same, including dates. 194 | // Mark this by marking true/deleting element. 195 | // We don't want to delete elements of the array 196 | // we are currently iterating, thus the true. 197 | // We also don't want a db element that doesn't have a download 198 | // partner, thus the deletion 199 | // Yes, this is not elegant. 200 | comparisonResultsSlice[downIndex] = true 201 | dbOffers = append(dbOffers[:dbIndex], dbOffers[dbIndex+1:]...) 202 | } 203 | } 204 | } 205 | } 206 | if len(dbOffers) != 0 { 207 | return false 208 | } 209 | for _, hasPartner := range comparisonResultsSlice { 210 | if !hasPartner { 211 | return false 212 | } 213 | } 214 | return true 215 | } 216 | 217 | func parseJSON(body []byte) (MenuRoot, error) { 218 | // This wants to be its own function so we can 219 | // tets that the unmarshalling works well. 220 | // TODO point a test to this 221 | menu := MenuRoot{} 222 | err := json.Unmarshal(body, &menu) 223 | return menu, err 224 | } 225 | 226 | func parseTitleJSON(body []byte) (EssensTitleRoot, error) { 227 | titleRoot := EssensTitleRoot{} 228 | err := json.Unmarshal(body, &titleRoot) 229 | return titleRoot, err 230 | 231 | } 232 | 233 | func getMensaMenuFromWeb() (MenuRoot, error) { 234 | // We need to set the referer header, or this won't work 235 | mensaMenuClient := &http.Client{} 236 | mensaMenuRequest, _ := http.NewRequest("GET", MENSA_URL, nil) 237 | mensaMenuRequest.Header.Set("Referer", "https://swp.webspeiseplan.de/Menu") 238 | 239 | response, err := mensaMenuClient.Do(mensaMenuRequest) 240 | 241 | if err != nil { 242 | zap.S().Warn("Can't reach mensa json. Is their service down?", err) 243 | return MenuRoot{}, err 244 | } 245 | defer response.Body.Close() 246 | body, err := io.ReadAll(response.Body) 247 | if err != nil { 248 | zap.S().Warn("Can't read mensa json response body. Is their service working?", err) 249 | return MenuRoot{}, err 250 | } 251 | menu, err := parseJSON(body) 252 | if err != nil { 253 | zap.S().Error("Can't parse mensa json. Did the format change?", err) 254 | return MenuRoot{}, err 255 | } 256 | return menu, nil 257 | } 258 | 259 | func getMealsForToday(menu MenuRoot, day time.Time) []SpeiseplanAdvancedGericht { 260 | todayString := day.Format("2006-01-02") 261 | 262 | var todaysMenu []SpeiseplanAdvancedGericht 263 | 264 | for _, week := range menu.Content { 265 | for _, potentialMeal := range week.SpeiseplanGerichtData { 266 | if potentialMeal.Gericht.Aktiv == false { 267 | continue 268 | } 269 | potentialMealDate := potentialMeal.Gericht.Datum[:10] // is iso-formatted, this returns the day part 270 | if potentialMealDate == todayString { 271 | // We want less date precision in our db 272 | potentialMeal.Gericht.Datum = potentialMeal.Gericht.Datum[:10] 273 | todaysMenu = append(todaysMenu, potentialMeal.Gericht) 274 | } 275 | } 276 | if len(todaysMenu) > 0 { 277 | // if we found meals for today in one week we won't find them in a different week 278 | break 279 | } 280 | } 281 | return todaysMenu 282 | } 283 | -------------------------------------------------------------------------------- /mensa_scraper/xml_parser_test.go: -------------------------------------------------------------------------------- 1 | package mensa_scraper 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "testing" 7 | 8 | "go.uber.org/zap" 9 | ) 10 | 11 | func TestParser(t *testing.T) { 12 | logger, _ := zap.NewDevelopment() 13 | zap.ReplaceGlobals(logger) 14 | 15 | file, _ := os.Open("example.xml") 16 | defer file.Close() 17 | data, _ := io.ReadAll(file) 18 | 19 | menu, err := parseXML(data) 20 | if err != nil { 21 | t.Errorf("Parse went wrong %e", err) 22 | } 23 | firstDate := menu.Dates[0] 24 | if firstDate.Index != "21.02.2023" { 25 | t.Errorf("First date has unexpected value") 26 | } 27 | firstOffer := firstDate.Offers[0] 28 | if firstOffer.Titel != "Angebot 2" { 29 | t.Errorf("First offer has unexpected titel") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /points_handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ADimeo/MensaQueueBot/db_connectors" 7 | "github.com/ADimeo/MensaQueueBot/telegram_connector" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | /* 12 | Sends a number of messages explaining how points work, used when requesting points help 13 | */ 14 | func SendPointsHelpMessages(chatID int) { 15 | var messageArray = [...]string{ 16 | "If you want to, you can opt in to collect internetpoints for your reports!", 17 | "To opt into or out of point collection use the \"Change Settings\" button. Your points are displayed on the /settings screen.", 18 | "You get one point for each report, and your points will add up with each report you make!", 19 | "Here at MensaQueueBot, we try to minimize the data we collect. Right now all your reports are anonymized. Your reports will stay anonymous regardless of whether you collect points or not, but if you opt in we'll need to store additional information, specifically how many reports you've made. Just wanted to let you know that.", 20 | "Right now points don't do anything except prove to everybody what a great reporter you are, but we have plans for the future! (Maybe!)", 21 | } 22 | for i := 0; i < len(messageArray); i++ { 23 | messageString := messageArray[i] 24 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.SETTINGS_INTERACTION, chatID) 25 | err := telegram_connector.SendMessage(chatID, messageString, keyboardIdentifier) 26 | if err != nil { 27 | zap.S().Error("Error while sending help message for point", err) 28 | } 29 | } 30 | } 31 | 32 | /* 33 | Returns a string which says how many points a user has, 34 | but in pretty words 35 | */ 36 | func GetPointsRequestResponseText(chatID int) string { 37 | emojiRune := GetRandomAcceptableEmoji() 38 | baseMessage := "You have collected %d points%s" + string(emojiRune) 39 | var encouragements = [...]string{ 40 | ", that's a good start 🐨", 41 | ", which is like two weeks of reporting every singe day 🏋️", 42 | ", way to go! 🎯", 43 | ". You can officially claim that you're a professional mensa queue length reporter, and I'll support that claim. 🌠", 44 | ". Consider me impressed 🛍", 45 | ". Do you always go above and beyond? 🛫", 46 | ". Wow. 📸", 47 | ", and I'll be honest, I don't know what to say 🪕", 48 | } 49 | 50 | explanationMessage := `You are currently not collecting points.` 51 | 52 | currentlyOptedIn := db_connectors.UserIsCollectingPoints(chatID) 53 | if !currentlyOptedIn { 54 | return explanationMessage 55 | } 56 | pointsCollected := db_connectors.GetNumberOfPointsByUser(chatID) 57 | encouragementSelector := pointsCollected / 9 // New encouragement message every 9 points 58 | if encouragementSelector >= len(encouragements) { 59 | encouragementSelector = len(encouragements) - 1 60 | } 61 | 62 | encouragementMessage := encouragements[encouragementSelector] 63 | messageToSend := fmt.Sprintf(baseMessage, pointsCollected, encouragementMessage) 64 | return messageToSend 65 | } 66 | -------------------------------------------------------------------------------- /profile_picture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ADimeo/MensaQueueBot/79888dad73986018c0877616a094a6f2adaed6b5/profile_picture.jpg -------------------------------------------------------------------------------- /queue_length_illustrations/L00_practically_empty.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ADimeo/MensaQueueBot/79888dad73986018c0877616a094a6f2adaed6b5/queue_length_illustrations/L00_practically_empty.jpg -------------------------------------------------------------------------------- /queue_length_illustrations/L01_within_kitchen.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ADimeo/MensaQueueBot/79888dad73986018c0877616a094a6f2adaed6b5/queue_length_illustrations/L01_within_kitchen.jpg -------------------------------------------------------------------------------- /queue_length_illustrations/L02_up_to_food_trays.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ADimeo/MensaQueueBot/79888dad73986018c0877616a094a6f2adaed6b5/queue_length_illustrations/L02_up_to_food_trays.jpg -------------------------------------------------------------------------------- /queue_length_illustrations/L03_within_first_room.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ADimeo/MensaQueueBot/79888dad73986018c0877616a094a6f2adaed6b5/queue_length_illustrations/L03_within_first_room.jpg -------------------------------------------------------------------------------- /queue_length_illustrations/L04_starting_to_corner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ADimeo/MensaQueueBot/79888dad73986018c0877616a094a6f2adaed6b5/queue_length_illustrations/L04_starting_to_corner.jpg -------------------------------------------------------------------------------- /queue_length_illustrations/L05_past_first_desk.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ADimeo/MensaQueueBot/79888dad73986018c0877616a094a6f2adaed6b5/queue_length_illustrations/L05_past_first_desk.jpg -------------------------------------------------------------------------------- /queue_length_illustrations/L06_past_second_desk.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ADimeo/MensaQueueBot/79888dad73986018c0877616a094a6f2adaed6b5/queue_length_illustrations/L06_past_second_desk.jpg -------------------------------------------------------------------------------- /queue_length_illustrations/L07_up_to_stairs.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ADimeo/MensaQueueBot/79888dad73986018c0877616a094a6f2adaed6b5/queue_length_illustrations/L07_up_to_stairs.jpg -------------------------------------------------------------------------------- /queue_length_illustrations/L08_even_longer: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ADimeo/MensaQueueBot/79888dad73986018c0877616a094a6f2adaed6b5/queue_length_illustrations/L08_even_longer -------------------------------------------------------------------------------- /queue_length_illustrations/top_view.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ADimeo/MensaQueueBot/79888dad73986018c0877616a094a6f2adaed6b5/queue_length_illustrations/top_view.jpg -------------------------------------------------------------------------------- /reports_handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "time" 7 | 8 | "github.com/ADimeo/MensaQueueBot/db_connectors" 9 | "github.com/ADimeo/MensaQueueBot/telegram_connector" 10 | "github.com/ADimeo/MensaQueueBot/utils" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | /* 15 | HandleNavigationToReportKeyboard handles navigation to the report keyboard. 16 | This includes the actual navigation (sending out a message with the new keyboard, 17 | as well as storing the users intent in the DB, which is used to id whether they've 18 | reported somethign on this day (for mensa updates) 19 | */ 20 | func HandleNavigationToReportKeyboard(sentMessage string, chatID int) { 21 | message := "Great! How is it looking?" 22 | nowInUTCTime := time.Now().UTC() 23 | db_connectors.SetUserToReportedOnDate(chatID, nowInUTCTime) 24 | 25 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.PREPARE_REPORT, chatID) 26 | telegram_connector.SendMessage(chatID, message, keyboardIdentifier) 27 | } 28 | 29 | /* 30 | HandleLengthReport is the function called on the actual Lx length report. It handles validation 31 | and storage of the given report, as well as the feedback message. 32 | */ 33 | func HandleLengthReport(sentMessage string, messageUnixTime int, chatID int) { 34 | if reportAppearsValid(sentMessage) { 35 | errorWhileSaving := saveQueueLength(sentMessage, messageUnixTime, chatID) 36 | if errorWhileSaving == nil { 37 | if db_connectors.UserIsCollectingPoints(chatID) { 38 | db_connectors.AddInternetPoint(chatID) 39 | } 40 | sendThankYouMessage(chatID, sentMessage) 41 | } 42 | } else { 43 | sendNoThanksMessage(chatID, sentMessage) 44 | } 45 | 46 | } 47 | 48 | /* 49 | Sends a thank you message for a report 50 | */ 51 | func sendThankYouMessage(chatID int, textSentByUser string) { 52 | emojiRune := GetRandomAcceptableEmoji() 53 | baseMessage := "You reported length %s, thanks " + string(emojiRune) 54 | 55 | zap.S().Infof("Sending thank you for %s", textSentByUser) 56 | 57 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.LENGTH_REPORT, chatID) 58 | err := telegram_connector.SendMessage(chatID, fmt.Sprintf(baseMessage, textSentByUser), keyboardIdentifier) 59 | if err != nil { 60 | zap.S().Error("Error while sending thank you message.", err) 61 | } 62 | } 63 | 64 | func sendNoThanksMessage(chatID int, textSentByUser string) { 65 | emojiRune := GetRandomAcceptableEmoji() 66 | baseMessage := "...are you sure?" + string(emojiRune) 67 | 68 | zap.S().Infof("Sending no thanks for %s", textSentByUser) 69 | 70 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.LENGTH_REPORT, chatID) 71 | err := telegram_connector.SendMessage(chatID, baseMessage, keyboardIdentifier) 72 | if err != nil { 73 | zap.S().Error("Error while sending no thanks message.", err) 74 | } 75 | } 76 | 77 | /* 78 | Writes the given queue length to the database 79 | */ 80 | func saveQueueLength(queueLength string, unixTimestamp int, chatID int) error { 81 | chatIDString := strconv.Itoa(chatID) 82 | return db_connectors.WriteReportToDB(chatIDString, unixTimestamp, queueLength) 83 | } 84 | 85 | func reportAppearsValid(reportText string) bool { 86 | // Checking time: It's not on the weekend 87 | if utils.IsInDebugMode() { 88 | zap.S().Info("Running in Debug mode, skipping report validity check") 89 | return true 90 | } 91 | var today = time.Now() 92 | 93 | if today.Weekday() == 0 || today.Weekday() == 6 { 94 | // Sunday or Saturday, per https://golang.google.cn/pkg/time/#Weekday 95 | zap.S().Info("Report is on weekend") 96 | return false 97 | } 98 | 99 | if utils.GetMensaOpeningTime().After(today) || 100 | utils.GetMensaClosingTime().Before(today) { 101 | zap.S().Info("Report is outside of mensa hours") 102 | // Outside of mensa closing times 103 | return false 104 | } 105 | zap.S().Info("Report is considered valid") 106 | return true 107 | } 108 | -------------------------------------------------------------------------------- /requests_handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/base64" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "strconv" 11 | "time" 12 | 13 | "github.com/ADimeo/MensaQueueBot/db_connectors" 14 | "github.com/ADimeo/MensaQueueBot/telegram_connector" 15 | "github.com/ADimeo/MensaQueueBot/utils" 16 | "github.com/adimeo/go-echarts/v2/charts" // Custom dependency because we need features from their master that aren't published yet 17 | "github.com/adimeo/go-echarts/v2/opts" 18 | "github.com/go-rod/rod" 19 | "github.com/go-rod/rod/lib/launcher" 20 | "go.uber.org/zap" 21 | ) 22 | 23 | // Used to store information about the last graph we generated/sent 24 | // to telegram. We don't need cross-reboot persistence, 25 | // and the alternative would be storing this in the DB 26 | var globalLatestGraphDetails graphDetails 27 | 28 | type graphDetails struct { 29 | Timestamp time.Time 30 | TelegramAssignedID string 31 | } 32 | 33 | /* generateSimpleLengthReportString generates the text of a report that is sent 34 | to a user, depending on when the last reported queue length was: 35 | 36 | - For reported lengths within the last 5 minutes 37 | - For reported lengths within the last 59 minutes 38 | - For reported lengths on the same day 39 | - For no reported length on the same day 40 | */ 41 | func generateSimpleLengthReportString(timeOfReport int, reportedQueueLength string) string { 42 | baseMessageReportAvailable := "Current length of mensa queue is %s" 43 | baseMessageRelativeReportAvailable := "%d minutes ago the length was %s" 44 | baseMessageNoRecentReportAvailable := "No recent report, but today at %s the length was %s" 45 | baseMessageNoReportAvailable := "No queue length reported today." 46 | 47 | acceptableDeltaSinceLastReport, _ := time.ParseDuration("5m") 48 | timeDeltaForRelativeTimeSinceLastReport, _ := time.ParseDuration("59m") 49 | 50 | timestampNow := time.Now() 51 | timestampThen := time.Unix(int64(timeOfReport), 0) 52 | 53 | potsdamLocation := utils.GetLocalLocation() 54 | timestampNow = timestampNow.In(potsdamLocation) 55 | timestampThen = timestampThen.In(potsdamLocation) 56 | 57 | zap.S().Infof("Generating queueLengthReport with report from %s Europe/Berlin(Current time is %s Europe/Berlin)", timestampThen.Format("15:04"), timestampNow.Format("15:04")) 58 | 59 | timeSinceLastReport := timestampNow.Sub(timestampThen) 60 | if timeSinceLastReport <= acceptableDeltaSinceLastReport { 61 | return fmt.Sprintf(baseMessageReportAvailable, reportedQueueLength) 62 | } else if timeSinceLastReport <= timeDeltaForRelativeTimeSinceLastReport { 63 | return fmt.Sprintf(baseMessageRelativeReportAvailable, int(timeSinceLastReport.Minutes()), reportedQueueLength) 64 | } else if timestampNow.YearDay() == timestampThen.YearDay() { 65 | return fmt.Sprintf(baseMessageNoRecentReportAvailable, timestampThen.Format("15:04"), reportedQueueLength) 66 | } else { 67 | return baseMessageNoReportAvailable 68 | } 69 | } 70 | 71 | /* 72 | SendQueueLengthReport sends a message to the specified user, depending on when the last reported queue length was. 73 | See generateSimpleLengthReportString for message creation logic. 74 | */ 75 | func sendQueueLengthReport(chatID int, timeOfReport int, reportedQueueLength string) error { 76 | reportMessage := generateSimpleLengthReportString(timeOfReport, reportedQueueLength) 77 | 78 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.INFO_REQUEST, chatID) 79 | err := telegram_connector.SendMessage(chatID, reportMessage, keyboardIdentifier) 80 | if err != nil { 81 | zap.S().Error("Error while sending queue length report", err) 82 | } 83 | return err 84 | } 85 | 86 | /* 87 | getXAxisLabels returns a javascript function that converts 88 | unix timestamps into hh:mm strings, which is the 89 | format we want to have our labels displayed in 90 | */ 91 | func getXAxisLabels() string { 92 | return `function (value) { 93 | let dateAsValue = new Date(1000*value); 94 | let hhmmString = dateAsValue.toLocaleTimeString('de-DE', {timeZone: 'Europe/Berlin', timeStyle: 'short'}); 95 | return hhmmString; 96 | }` 97 | } 98 | 99 | /* 100 | getTimeOfLastGraph returns when the currently active graph 101 | was generated 102 | */ 103 | func getTimeOfLastGraph() time.Time { 104 | return globalLatestGraphDetails.Timestamp 105 | } 106 | 107 | /* 108 | getIdentifierOfLastGraph returns a string that 109 | we can send to telegram which it will interpret 110 | as this graph image 111 | */ 112 | func getIdentifierOfLastGraph() string { 113 | return globalLatestGraphDetails.TelegramAssignedID 114 | } 115 | 116 | /* 117 | updateGlobalLatestGraphDetails is used to keep the active 118 | graph details up to date 119 | */ 120 | func updateGlobalLatestGraphDetails(time time.Time, newTelegramIdentifier string) { 121 | zap.S().Debugf("Updated currently active graph from %s to %s", 122 | globalLatestGraphDetails.Timestamp.Format("15:04:05"), 123 | time.Format("15:04:05")) 124 | 125 | globalLatestGraphDetails.Timestamp = time 126 | globalLatestGraphDetails.TelegramAssignedID = newTelegramIdentifier 127 | } 128 | 129 | /* shouldGenerateNewGraph returns true if we should 130 | generate a new graph. We should generate a new graph 131 | if any of these is true: 132 | - No graph currently exists 133 | - The latest queue length report is newer than the 134 | latest grah 135 | - The latest graph is older than one minute 136 | 137 | */ 138 | func shouldGenerateNewGraph(timeOfReport int64) bool { 139 | latestGraphTime := getTimeOfLastGraph() 140 | if latestGraphTime.IsZero() { 141 | return true 142 | } 143 | // Check if the latest report is newer than the latest graph 144 | // There'll always be _some_ risk or race-condition style inconsistencies, 145 | // if a report happens exactly after querying here, and just before 146 | // generating the graph, but we accept that edge case 147 | latestReportTime := time.Unix(timeOfReport, 0) 148 | if latestGraphTime.Before(latestReportTime) { 149 | return true 150 | } 151 | 152 | // Graph is older than one minute 153 | maximalAcceptableTimeDeltaInSeconds := 60.0 154 | timeDelta := time.Since(latestGraphTime) 155 | return timeDelta.Seconds() > maximalAcceptableTimeDeltaInSeconds 156 | } 157 | 158 | /* createEchartOptions returns a slice of the configuration 159 | options we want to use for our chart. For details see 160 | https://echarts.apache.org/en/option.html 161 | */ 162 | func createEchartOptions(currentTime time.Time, graphEndTime time.Time, graphStartTime time.Time) []charts.GlobalOpts { 163 | echartOptionsSlice := make([]charts.GlobalOpts, 0) 164 | 165 | // Title 166 | mensaLocation := utils.GetLocalLocation() 167 | timeInMensaTimezone := currentTime.In(mensaLocation).Format("15:04") 168 | 169 | title := charts.WithTitleOpts(opts.Title{ 170 | Title: fmt.Sprintf("Queue lengths for %s", timeInMensaTimezone), 171 | Subtitle: "Generated by @MensaQueueBot", 172 | }) 173 | echartOptionsSlice = append(echartOptionsSlice, title) 174 | 175 | // Legend 176 | legend := charts.WithLegendOpts(opts.Legend{ 177 | Show: true, 178 | Left: "right", 179 | }) 180 | echartOptionsSlice = append(echartOptionsSlice, legend) 181 | 182 | // Grid - fix for labels being cut off 183 | // This is what requires our custom dependency: 184 | // It's not supported in echarts v2.2.4 185 | grid := charts.WithGridOpts(opts.Grid{ 186 | ContainLabel: true}) 187 | 188 | echartOptionsSlice = append(echartOptionsSlice, grid) 189 | 190 | // yAxis Options 191 | mensaLocationObjects := GetMensaLocationSlice() 192 | var yAxiLabelStringSlice []string 193 | for _, singleMensaLocation := range *mensaLocationObjects { 194 | yAxiLabelStringSlice = append(yAxiLabelStringSlice, singleMensaLocation.Description) 195 | } 196 | 197 | yAxis := charts.WithYAxisOpts(opts.YAxis{ 198 | Type: "category", 199 | Data: yAxiLabelStringSlice, 200 | // BoundaryGap: false,// I'd like this option, but it's currently not supported in echarts master 201 | AxisLabel: &opts.AxisLabel{ 202 | Interval: "0", 203 | ShowMinLabel: true, 204 | ShowMaxLabel: true, 205 | Show: true, 206 | }, 207 | SplitLine: &opts.SplitLine{ 208 | Show: true, 209 | }, 210 | }) 211 | 212 | echartOptionsSlice = append(echartOptionsSlice, yAxis) 213 | 214 | //xAxisOptions 215 | xAxis := charts.WithXAxisOpts(opts.XAxis{ 216 | Max: graphEndTime.Unix(), 217 | Min: graphStartTime.Unix(), 218 | Scale: true, 219 | Type: "value", // Using the more natural "time" breaks stuff (library bug, https://github.com/go-echarts/go-echarts/issues/194) 220 | AxisLabel: &opts.AxisLabel{ 221 | ShowMaxLabel: true, 222 | Formatter: opts.FuncOpts(getXAxisLabels()), 223 | Show: true, 224 | }, 225 | }) 226 | 227 | echartOptionsSlice = append(echartOptionsSlice, xAxis) 228 | return echartOptionsSlice 229 | } 230 | 231 | /* convertTimesSliceToTimestamps takes a slice of time.Time and 232 | returns a slice of string representations of the unix timestamps 233 | of those times, which is the format that the egraph expects 234 | */ 235 | func convertTimesSliceToTimestampsSlice(timesSlice []time.Time) []string { 236 | var timestampsSlice []string 237 | for _, element := range timesSlice { 238 | timestampsSlice = append(timestampsSlice, strconv.FormatInt(element.Unix(), 10)) 239 | } 240 | return timestampsSlice 241 | } 242 | 243 | /*normalizeTimesToTodaysTimestamps takes a list of times and 244 | returns a list of strings with unix timestamps, one per time. 245 | These unix timestamps 246 | - represent the time of the original time 247 | - But their date is set to today 248 | 249 | Importantly, these unix timestamps are timezone aware for the 250 | mensa timezone, so if a timestamp showed hh:mm when created, 251 | it will show the same hh:mm but for the current date 252 | 253 | This is useful to display at what times events that stretch 254 | over multiple days happened. 255 | */ 256 | func normalizeTimesToTodaysTimestamps(today time.Time, timesSlice []time.Time) []string { 257 | // Daylight saving time adds some steps to this... 258 | var timestampsSlice []string 259 | for _, element := range timesSlice { 260 | timeInMensaTimezone := element.In(utils.GetLocalLocation()) 261 | normalizedTime := time.Date(today.Year(), today.Month(), today.Day(), 262 | timeInMensaTimezone.Hour(), timeInMensaTimezone.Minute(), timeInMensaTimezone.Second(), 0, 263 | utils.GetLocalLocation()) 264 | timestampsSlice = append(timestampsSlice, strconv.FormatInt(normalizedTime.Unix(), 10)) 265 | } 266 | return timestampsSlice 267 | } 268 | 269 | /* 270 | createEchartDataSeries returns the actual data series for todays report 271 | that echart visualizes 272 | */ 273 | func createEchartXDataAndDataSeries(nowUTC time.Time, dataTimeframe time.Duration) ([]string, []opts.LineData, error) { 274 | // Get data 275 | queueLengthsAsStringSlice, timesSlice, err := db_connectors.GetAllQueueLengthReportsInTimeframe(nowUTC, dataTimeframe) 276 | if err == sql.ErrNoRows { 277 | return []string{}, []opts.LineData{}, errors.New("Not enough data in timeframe") 278 | } 279 | 280 | // Create xData 281 | xData := convertTimesSliceToTimestampsSlice(timesSlice) 282 | 283 | // creat data series 284 | yData := queueLengthsAsStringSlice 285 | 286 | seriesData := make([]opts.LineData, 0) 287 | for i := 0; i < len(yData); i++ { 288 | seriesData = append(seriesData, opts.LineData{ 289 | Value: []string{xData[i], yData[i]}}) 290 | } 291 | return xData, seriesData, nil 292 | } 293 | 294 | /* 295 | getHistoricalSeriesForToday returns an array of datapoints that can be used to create a scatterchart. 296 | Each datapoint contains a timestamp for today, and a queue length report string. The array contains data that 297 | 298 | - Was generated within the last 30 days (Variable within function decides this length) 299 | - created in the time between now - timeIntoPast and now.timeIntoFuture, but on all days within the inverval 300 | */ 301 | func getHistoricalSeriesForToday(todayUTC time.Time, timeIntoPast time.Duration, timeIntoFuture time.Duration) ([]opts.ScatterData, error) { 302 | historicalGraphTimeFrameInDays := int8(30) 303 | 304 | queueLengthsAsStringSlice, timesSlice, err := db_connectors.GetQueueLengthReportsByWeekdayAndTimeframe(historicalGraphTimeFrameInDays, todayUTC, timeIntoPast, timeIntoFuture) 305 | if err == sql.ErrNoRows { 306 | return []opts.ScatterData{}, errors.New("No historical data found") 307 | } 308 | // Normalize timestamps for today 309 | 310 | // creat data series 311 | xData := normalizeTimesToTodaysTimestamps(todayUTC, timesSlice) 312 | yData := queueLengthsAsStringSlice 313 | 314 | seriesData := make([]opts.ScatterData, 0) 315 | for i := 0; i < len(yData); i++ { 316 | seriesData = append(seriesData, opts.ScatterData{ 317 | Value: []string{xData[i], yData[i]}}) 318 | } 319 | return seriesData, nil 320 | 321 | } 322 | 323 | /* buildHistoricalScatterChart returns an echartsScatterchars of historical reports within the 324 | given intervals (with the now moment being in the middle of the two vlaues 325 | */ 326 | func buildHistoricalScatterChart(todayUTC time.Time, timeIntoPast time.Duration, timeIntoFuture time.Duration) (*charts.Scatter, error) { 327 | scatter := charts.NewScatter() 328 | 329 | historicalSeries, err := getHistoricalSeriesForToday(todayUTC, timeIntoPast, timeIntoFuture) 330 | if err != nil { 331 | return scatter, err 332 | } 333 | scatter.AddSeries("Reports from last 30 days", historicalSeries) 334 | return scatter, nil 335 | } 336 | 337 | /* generateGraphOfMensaTrendAsHTML generates a graph out of the reports 338 | for a specific timeframe. Writes the graph to a html file 339 | Returns err if it can't generate a report due to lack of data 340 | */ 341 | func generateGraphOfMensaTrendAsHTML(graphCenterTimeUTC time.Time, timeIntoPast time.Duration, timeIntoFuture time.Duration) (string, error) { 342 | line := charts.NewLine() 343 | globalOptions := createEchartOptions(graphCenterTimeUTC, 344 | graphCenterTimeUTC.Add(-timeIntoPast), 345 | graphCenterTimeUTC.Add(timeIntoFuture)) 346 | line.SetGlobalOptions(globalOptions...) 347 | 348 | xData, seriesData, err := createEchartXDataAndDataSeries(graphCenterTimeUTC, timeIntoPast) 349 | if err != nil { 350 | // Likely not enough data 351 | zap.S().Debug("Not enough data to create /jetze graph", err) 352 | } 353 | line.SetXAxis(xData). 354 | AddSeries("Reports from today", seriesData). 355 | SetSeriesOptions( 356 | charts.WithMarkLineNameXAxisItemOpts(opts.MarkLineNameXAxisItem{ 357 | Name: "Now", 358 | XAxis: graphCenterTimeUTC.Unix(), 359 | }), 360 | charts.WithMarkLineStyleOpts(opts.MarkLineStyle{ 361 | Symbol: []string{"none"}, 362 | Label: &opts.Label{ 363 | Formatter: opts.FuncOpts("function (value){return 'Now'}"), 364 | Show: true, 365 | }}), 366 | ) 367 | 368 | fileName := "mensa_queue_bot_length_graph.html" 369 | f, err := os.Create("/tmp/" + fileName) 370 | if err != nil { 371 | zap.S().Error("Couldn't create /jetze .html file, even though we have enough data", err) 372 | return "", err 373 | } 374 | // Add historical data 375 | historicalScatterChart, err := buildHistoricalScatterChart(graphCenterTimeUTC, timeIntoPast, timeIntoFuture) 376 | if err != nil { 377 | zap.S().Error("Couldn't scatter chart with historical data, displaying only todays reports", err) 378 | } else { 379 | line.Overlap(historicalScatterChart) 380 | } 381 | 382 | line.Render(f) 383 | // Return in the format a browser would expect 384 | absoluteFilepath, _ := filepath.Abs(f.Name()) 385 | return "file:///" + absoluteFilepath, nil 386 | } 387 | 388 | /* renderHTMLGraphToPNG does exactly what it says. It expects 389 | a path to a html file, and writes it to a specific file. 390 | Returns path to that file. 391 | 392 | Rendering happens via an external browser. Extraction to PNG via 393 | echarts getDataURL method 394 | */ 395 | func renderHTMLGraphToPNG(pathToGraphHTML string) (string, error) { 396 | u := launcher.New().Bin("/usr/bin/google-chrome").MustLaunch() 397 | browser := rod.New().ControlURL(u).MustConnect() 398 | page := browser.MustPage(pathToGraphHTML).MustWaitLoad() 399 | renderCommand := "() =>{return echarts.getInstanceByDom(document.getElementsByTagName('div')[1]).getDataURL()}" // this is called with javascripts .apply 400 | commandJsonResponse := page.MustEval(renderCommand) 401 | browser.MustClose() 402 | 403 | graphAsB64PNG := commandJsonResponse.Str() 404 | // Data is in the format data:image/png;base64,iVBORw0KGgoAAAANSUhEU... 405 | // So cut away the first 22 symbols, and b64decode the rest 406 | decodedPngData, err := base64.StdEncoding.DecodeString(graphAsB64PNG[22:]) 407 | if err != nil { 408 | zap.S().Error("Render html->png failed", err) 409 | return "", err 410 | } 411 | pathToPng := "/tmp/mensa_queue_bot_length_graph.png" 412 | os.WriteFile(pathToPng, []byte(decodedPngData), 0666) //Read and write permissions 413 | 414 | return pathToPng, nil 415 | } 416 | 417 | /* 418 | sendExistingGraphicQueueLengthReport sends the currently active graph 419 | to our users. That way we don't have to regenerate our graphs on every request 420 | */ 421 | func sendExistingGraphicQueueLengthReport(chatID int, 422 | timeOfLatestReport int, reportedQueueLength string, oldGraphIdentifier string) error { 423 | stringReport := generateSimpleLengthReportString(timeOfLatestReport, reportedQueueLength) 424 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.INFO_REQUEST, chatID) 425 | err := telegram_connector.SendStaticWebPhoto(chatID, oldGraphIdentifier, stringReport, keyboardIdentifier) 426 | return err 427 | } 428 | 429 | /* 430 | sendNewGraphicQueueLengthReport generates a new graph and sends 431 | it to the user. Gracefully falls back to string reports if we 432 | lack data or if errors occur 433 | */ 434 | func sendNewGraphicQueueLengthReport(chatID int, 435 | timeOfLatestReport int, reportedQueueLength string) error { 436 | 437 | graphNowLineUTC := time.Now().UTC() 438 | graphTimeframeIntoPast, _ := time.ParseDuration("60m") 439 | graphTimeframeIntoFuture, _ := time.ParseDuration("30m") 440 | 441 | graphFilepath, err := generateGraphOfMensaTrendAsHTML(graphNowLineUTC, graphTimeframeIntoPast, graphTimeframeIntoFuture) 442 | if err != nil { 443 | // Likely lack of data 444 | // Fallback to simple report 445 | zap.S().Debug("Falling back to sending non-graphic report") 446 | return sendQueueLengthReport(chatID, timeOfLatestReport, reportedQueueLength) 447 | } 448 | 449 | pathToPng, err := renderHTMLGraphToPNG(graphFilepath) 450 | if err != nil { 451 | zap.S().Error("Couldn't render /jetze html to png, fallback to text report", err) 452 | // Might be a parallelism issue? 453 | // Fallback to simple report 454 | return sendQueueLengthReport(chatID, timeOfLatestReport, reportedQueueLength) 455 | } 456 | stringReport := generateSimpleLengthReportString(timeOfLatestReport, reportedQueueLength) 457 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.INFO_REQUEST, chatID) 458 | newTelegramIdentifier, err := telegram_connector.SendDynamicPhoto(chatID, pathToPng, stringReport, keyboardIdentifier) 459 | updateGlobalLatestGraphDetails(graphNowLineUTC, newTelegramIdentifier) 460 | return err 461 | } 462 | 463 | /* 464 | The handling of a /jetze request. If possible we will try to send a graphic 465 | report, but fallbacks to text reports exist. Caching exists, 466 | with the time window of the cache being defined in shouldGenerateNewGraph 467 | 468 | */ 469 | func GenerateAndSendGraphicQueueLengthReport(chatID int) { 470 | timeOfLatestReport, reportedQueueLength := db_connectors.GetLatestQueueLengthReport() 471 | if !shouldGenerateNewGraph(int64(timeOfLatestReport)) { 472 | // Parallelism issue with multiple graphs being generated at the same 473 | // time considered unlikely enough not to handle. 474 | zap.S().Debug("Sending existing graph for graphic report") 475 | oldGraphIdentifier := getIdentifierOfLastGraph() 476 | err := sendExistingGraphicQueueLengthReport(chatID, timeOfLatestReport, reportedQueueLength, oldGraphIdentifier) 477 | if err != nil { 478 | zap.S().Error("Something failed while sending an existing report", err) 479 | } 480 | } else { 481 | telegram_connector.SendTypingIndicator(chatID) 482 | zap.S().Debug("Creating new graph for graphic report") 483 | err := sendNewGraphicQueueLengthReport(chatID, 484 | timeOfLatestReport, reportedQueueLength) 485 | if err != nil { 486 | zap.S().Error("Something failed while sending a new report", err) 487 | } 488 | } 489 | } 490 | -------------------------------------------------------------------------------- /settings_handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/ADimeo/MensaQueueBot/db_connectors" 9 | "github.com/ADimeo/MensaQueueBot/mensa_scraper" 10 | "github.com/ADimeo/MensaQueueBot/telegram_connector" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | /* 15 | PreferenceSettings corresponds with how the settings html in the static folder 16 | structures its uploads 17 | */ 18 | type PreferenceSettings struct { 19 | MensaPreferences db_connectors.MensaPreferenceSettings `json:"mensaPreferences"` 20 | Points bool `json:"points"` 21 | } 22 | 23 | /* 24 | Deletes the accounts with the given chatID from the DB, and sends a confirmation message 25 | */ 26 | func HandleAccountDeletion(chatID int) { 27 | err1 := db_connectors.DeleteAllUserPointData(chatID) 28 | err2 := db_connectors.DeleteAllUserChangelogData(chatID) 29 | err3 := db_connectors.DeleteAllUserMensaPreferences(chatID) 30 | if err1 != nil || err2 != nil || err3 != nil { 31 | zap.S().Infof("Sending error message to user") 32 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.SETTINGS_INTERACTION, chatID) 33 | telegram_connector.SendMessage(chatID, "Something went wrong deleting your data. Contact @adimeo for details and fixes", keyboardIdentifier) 34 | zap.S().Warn("Error in forgetme: ", err1, err2, err3) 35 | } else { 36 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.ACCOUNT_DELETION, chatID) 37 | telegram_connector.SendMessage(chatID, "Who are you again? I have completely forgotten you exist. Remind me with /start, please?", keyboardIdentifier) 38 | } 39 | } 40 | 41 | /* 42 | Adds the given user to the group of AB testers in the DB, and sends a confirmation message 43 | */ 44 | func HandleABTestJoining(chatID int) { 45 | err := db_connectors.MakeUserABTester(chatID, true) 46 | ABTestHandler(chatID) 47 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.PREPARE_MAIN, chatID) 48 | if err != nil { 49 | telegram_connector.SendMessage(chatID, "Something went wrong, please try again later ", keyboardIdentifier) 50 | zap.S().Warn("Error in A/B opt in: ", err) 51 | } else { 52 | telegram_connector.SendMessage(chatID, "Welcome to the test crew 🫡", keyboardIdentifier) 53 | } 54 | } 55 | 56 | func saveNewSettings(chatID int, settings PreferenceSettings, mensaSettings db_connectors.MensaPreferenceSettings) bool { 57 | settingsUpdated := true 58 | startCESTMinutes, _ := mensaSettings.GetFromTimeAsCESTMinute() // These functions default to acceptable values, even on errors 59 | endCESTMinutes, _ := mensaSettings.GetToTimeAsCESTMinute() 60 | 61 | if err := db_connectors.UpdateUserPreferences(chatID, mensaSettings.ReportAtAll, startCESTMinutes, endCESTMinutes, mensaSettings.WeekdayBitmap); err != nil { 62 | zap.S().Errorw("Can't update user mensa preferences", "chatID", chatID, err) 63 | settingsUpdated = false 64 | } 65 | if err := changePointSettings(settings.Points, chatID); err != nil { 66 | settingsUpdated = false 67 | } 68 | return settingsUpdated 69 | } 70 | 71 | func callReschedulerForInitialMensaMessageJob(mensaSettings db_connectors.MensaPreferenceSettings) { 72 | timeStringInCEST := mensaSettings.FromTime 73 | if mensaSettings.ReportAtAll { 74 | // Don't need to update the scheduler if this was disabled 75 | mensa_scraper.RescheduleNextInitialMessageJobIfNeeded(timeStringInCEST) 76 | } 77 | } 78 | 79 | /* 80 | Saves the new settings in the DB and sends a confirmation message. May initiate rescheduling 81 | of inital message mensa job 82 | */ 83 | func HandleSettingsChange(chatID int, webAppData telegram_connector.WebhookRequestBodyWebAppData) { 84 | typeOfKeyboard := webAppData.ButtonText 85 | if typeOfKeyboard == "Change Settings" { 86 | jsonString := webAppData.Data 87 | var settings PreferenceSettings 88 | err := json.Unmarshal([]byte(jsonString), &settings) 89 | if err != nil { 90 | zap.S().Errorw("Can't unmarshal the settings json we got as WebAppData", "json", jsonString, "error", err) 91 | } 92 | mensaSettings := settings.MensaPreferences 93 | settingsUpdated := saveNewSettings(chatID, settings, mensaSettings) 94 | 95 | // Feedback messages to user 96 | if settingsUpdated { 97 | message := "Successfully saved your settings" 98 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.PREPARE_SETTINGS, chatID) 99 | telegram_connector.SendMessage(chatID, message, keyboardIdentifier) 100 | // Display updated settings to the user 101 | SendSettingsOverviewMessage(chatID, true) 102 | // Reschedule initial mensa message job, if needed 103 | callReschedulerForInitialMensaMessageJob(mensaSettings) 104 | } else { 105 | message := "Error saving settings, please try again later" 106 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.PREPARE_SETTINGS, chatID) 107 | telegram_connector.SendMessage(chatID, message, keyboardIdentifier) 108 | } 109 | 110 | } else { 111 | zap.S().Errorw("Unknown button used to send webhook to us", "Button title", typeOfKeyboard, "Data", webAppData.Data) 112 | 113 | } 114 | } 115 | 116 | func changePointSettings(points bool, chatID int) error { 117 | var err error 118 | if points { 119 | if err = db_connectors.EnableCollectionOfPoints(chatID); err != nil { 120 | zap.S().Errorw("Can't enable user point collection", "chatID", chatID, err) 121 | } 122 | } else { 123 | if err = db_connectors.DisableCollectionOfPoints(chatID); err != nil { 124 | zap.S().Errorw("Can't disable user point collection", "chatID", chatID, err) 125 | } 126 | } 127 | return err 128 | } 129 | 130 | /* 131 | Sends the message the user receives when they request /settings. Includes 132 | an overview over settings, which makes it slightly fiddly to generate 133 | */ 134 | func SendSettingsOverviewMessage(chatID int, endInMainMenu bool) error { 135 | baseMessage := `Settings` 136 | var lengthReportMessage string 137 | var pointsReportMessage string 138 | var abTesterMessage string 139 | 140 | userPreferences, err := db_connectors.GetUserPreferences(chatID) 141 | if err != nil { 142 | zap.S().Errorw("User couldn't quey their settings", "userID", chatID, "error", err) 143 | keyboardIdentifier := telegram_connector.GetIdentifierViaRequestType(telegram_connector.PREPARE_SETTINGS, chatID) 144 | return telegram_connector.SendMessage(chatID, "I'm sorry, something went wrong. Please complain @adimeo", keyboardIdentifier) 145 | } 146 | lengthReportMessage = buildLengthReportMessage(userPreferences) 147 | pointsReportMessage = buildPointsReportMessage(chatID) 148 | 149 | message := baseMessage + "\n\n" + lengthReportMessage + "\n\n" + pointsReportMessage 150 | 151 | if db_connectors.GetIsUserABTester(chatID) { 152 | abTesterMessage = buildABTesterMessage(chatID) 153 | message = message + "\n\n" + abTesterMessage 154 | } 155 | var keyboardIdentifier telegram_connector.KeyboardIdentifier 156 | if endInMainMenu { 157 | keyboardIdentifier = telegram_connector.GetIdentifierViaRequestType(telegram_connector.PREPARE_MAIN, chatID) 158 | } else { 159 | keyboardIdentifier = telegram_connector.GetIdentifierViaRequestType(telegram_connector.PREPARE_SETTINGS, chatID) 160 | } 161 | return telegram_connector.SendMessage(chatID, message, keyboardIdentifier) 162 | } 163 | 164 | func buildLengthReportMessage(userPreferences db_connectors.MensaPreferenceSettings) string { 165 | noMessage := "You are not receiving any mensa menus." 166 | yesAlwaysMessage := "You are receiving menus on all weekdays, from %s to %s" 167 | yesMessage := "You are receiving menus on %s, from %s to %s" 168 | 169 | if !userPreferences.ReportAtAll || userPreferences.WeekdayBitmap == 0 { 170 | return noMessage 171 | } else if userPreferences.WeekdayBitmap == 0b0111110 { 172 | return fmt.Sprintf(yesAlwaysMessage, userPreferences.FromTime, userPreferences.ToTime) 173 | } else { 174 | weekdaysString := "" 175 | numberOfWeekdays := 0 176 | if userPreferences.WeekdayBitmap&0b0100000 != 0 { 177 | weekdaysString += ", Mondays" 178 | numberOfWeekdays++ 179 | } 180 | if userPreferences.WeekdayBitmap&0b0010000 != 0 { 181 | weekdaysString += ", Tuesdays" 182 | numberOfWeekdays++ 183 | } 184 | if userPreferences.WeekdayBitmap&0b0001000 != 0 { 185 | weekdaysString += ", Wednesdays" 186 | numberOfWeekdays++ 187 | } 188 | if userPreferences.WeekdayBitmap&0b0000100 != 0 { 189 | weekdaysString += ", Thursdays" 190 | numberOfWeekdays++ 191 | } 192 | if userPreferences.WeekdayBitmap&0b0000010 != 0 { 193 | weekdaysString += ", Fridays" 194 | numberOfWeekdays++ 195 | } 196 | weekdaysString = weekdaysString[2:] 197 | if numberOfWeekdays == 1 { 198 | // "Mondays" 199 | return fmt.Sprintf(yesMessage, weekdaysString, userPreferences.FromTime, userPreferences.ToTime) 200 | } else if numberOfWeekdays == 2 { 201 | // "Mondays and Tuesdays" 202 | lastCommaPosition := strings.LastIndex(weekdaysString, ",") + 1 203 | weekdaysString = weekdaysString[:lastCommaPosition-1] + " and" + weekdaysString[lastCommaPosition:] 204 | return fmt.Sprintf(yesMessage, weekdaysString, userPreferences.FromTime, userPreferences.ToTime) 205 | } else { 206 | // "Mondays, Tuesdays, and Wednesday" 207 | lastCommaPosition := strings.LastIndex(weekdaysString, ",") + 1 208 | weekdaysString = weekdaysString[:lastCommaPosition] + " and" + weekdaysString[lastCommaPosition:] 209 | return fmt.Sprintf(yesMessage, weekdaysString, userPreferences.FromTime, userPreferences.ToTime) 210 | } 211 | } 212 | } 213 | 214 | func buildPointsReportMessage(chatID int) string { 215 | pointsMessage := GetPointsRequestResponseText(chatID) 216 | return pointsMessage 217 | } 218 | 219 | func buildABTesterMessage(chatID int) string { 220 | if db_connectors.GetIsUserABTester(chatID) { 221 | return "You are currently opted in to test new features" 222 | } 223 | return "You are currently not a beta tester" 224 | } 225 | -------------------------------------------------------------------------------- /static/settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 39 | 40 | 41 | Settings 42 | 43 | Do you want to receive mensa messages? 44 | 45 | 46 | Do you want to collect points for reports? 47 | On which days do you want to receive menu messages? 48 | 49 | 50 | 52 | Mon 53 | 54 | 55 | 57 | Tue 58 | 59 | 61 | Wed 62 | 63 | 65 | Thu 66 | 67 | 69 | Fri 70 | 71 | 72 | 73 | 91 | 92 | 93 | From... 94 | 96 | To... 97 | 99 | 100 | Change settings 101 | 102 | 188 | 189 | 190 | 191 | -------------------------------------------------------------------------------- /telegram_connector/keyboard_decider.go: -------------------------------------------------------------------------------- 1 | package telegram_connector 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | 10 | "github.com/ADimeo/MensaQueueBot/db_connectors" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | type UserRequestType string 15 | 16 | // See more detailed descriptions within switch cases of 17 | // GetIdentifierBasedOnInput() 18 | const ( 19 | LENGTH_REPORT UserRequestType = "LENGTH_REPORT" 20 | PREPARE_REPORT UserRequestType = "PREPARE_REPORT" 21 | INFO_REQUEST UserRequestType = "INFO_REQUEST" 22 | PREPARE_SETTINGS UserRequestType = "PREPARE_SETTINGS" 23 | SETTINGS_INTERACTION UserRequestType = "SETTINGS_INTERACTION" 24 | PUSH_MESSAGE UserRequestType = "PUSH_MESSAGE" 25 | TUTORIAL_MESSAGE UserRequestType = "TUTORIAL_MESSAGE" 26 | PREPARE_MAIN UserRequestType = "PREPARE_MAIN" 27 | ACCOUNT_DELETION UserRequestType = "ACCOUNT_DELETION" 28 | IMAGE_REQUEST UserRequestType = "IMAGE_REQUEST" 29 | ) 30 | 31 | type KeyboardIdentifier int 32 | 33 | const ( 34 | LegacyKeyboard KeyboardIdentifier = -1 35 | NilKeyboard KeyboardIdentifier = 0 36 | ReportKeyboard KeyboardIdentifier = 1 37 | MainKeyboard KeyboardIdentifier = 2 38 | SettingsKeyboard KeyboardIdentifier = 3 39 | NoKeyboard KeyboardIdentifier = 4 40 | ) 41 | 42 | const LEGACY_KEYBOARD_FILEPATH = "./telegram_connector/keyboards/keyboard.json" 43 | const REPORT_KEYBOARD_FILEPATH = "./telegram_connector/keyboards/00_report_keyboard.json" 44 | const MAIN_KEYBOARD_FILEPATH = "./telegram_connector/keyboards/01_main_keyboard.json" 45 | const SETTINGS_KEYBOARD_FILEPATH = "./telegram_connector/keyboards/02_settings_keyboard.json" 46 | 47 | // Needs to be consistent with javascript logic in settings.html 48 | const KEYBOARD_SETTINGS_OPENER_BASE_QUERY_STRING = "?reportAtAll=%t&reportingDays=%d&fromTime=%s&toTime=%s&points=%t" 49 | 50 | func GetCustomizedKeyboardFromIdentifier(chatID int, identifier KeyboardIdentifier) (*ReplyKeyboardMarkupStruct, error) { 51 | baseKeyboard, err := getBaseKeyboardFromIdentifier(identifier) 52 | if err != nil { 53 | return baseKeyboard, err 54 | } 55 | return customizeKeyboardForUser(chatID, identifier, baseKeyboard) 56 | 57 | } 58 | 59 | /* 60 | Takes enum values, as defined in keyboard_decider.go 61 | Reads a JSON file and returns a keyboard struct, depending on the requested identifier. 62 | Raises error on unknown keyboard, or if a nil keyboard was requested - in that case 63 | the caller needs to have special logic. 64 | 65 | This function mostly exists to provide some stability for the NilKeyboard case. 66 | Not having this function would lead to some weird things being encoded in 67 | the compined GetIdentifierViaRequestType and getBaseKeyboardFromIdentifier function 68 | */ 69 | func getBaseKeyboardFromIdentifier(identifier KeyboardIdentifier) (*ReplyKeyboardMarkupStruct, error) { 70 | switch identifier { 71 | case LegacyKeyboard: 72 | { 73 | return getReplyKeyboard(LEGACY_KEYBOARD_FILEPATH), nil 74 | } 75 | case NilKeyboard: 76 | { 77 | var nilKeyboard ReplyKeyboardMarkupStruct 78 | return &nilKeyboard, errors.New("Caller requested nil keyboard, should not send keyboard instead") 79 | } 80 | case NoKeyboard: 81 | { 82 | var noKeyboard ReplyKeyboardMarkupStruct 83 | return &noKeyboard, errors.New("Caller requested no keyboard, should send different message instead") 84 | } 85 | case ReportKeyboard: 86 | { 87 | return getReplyKeyboard(REPORT_KEYBOARD_FILEPATH), nil 88 | } 89 | case MainKeyboard: 90 | { 91 | return getReplyKeyboard(MAIN_KEYBOARD_FILEPATH), nil 92 | } 93 | case SettingsKeyboard: 94 | { 95 | return getReplyKeyboard(SETTINGS_KEYBOARD_FILEPATH), nil 96 | } 97 | } 98 | var nilKeyboard ReplyKeyboardMarkupStruct 99 | return &nilKeyboard, errors.New("Caller requested unknown keyboard type") 100 | } 101 | 102 | /* 103 | "Customizes" the base keyboard for a single user. Right now this means exactly one thing: 104 | The settings keyboard is enriched with the current user settings, so that they can be 105 | displayed without serving an additional request. 106 | */ 107 | func customizeKeyboardForUser(userID int, identifier KeyboardIdentifier, baseKeyboard *ReplyKeyboardMarkupStruct) (*ReplyKeyboardMarkupStruct, error) { 108 | if identifier == SettingsKeyboard { 109 | // This is the only one that needs customization right now 110 | // We need to add the users current settings to the web_app url 111 | // which opens the webview with the settings 112 | settingsQueryString, err := getSettingsQueryStringForUser(userID) 113 | if err != nil { 114 | // Can't customize URL. Go back to defaults, which the html/js define 115 | return baseKeyboard, nil 116 | } 117 | customizedURL := baseKeyboard.Keyboard[1][0].WebApp.URL + settingsQueryString 118 | 119 | baseKeyboard.Keyboard[1][0].WebApp.URL = customizedURL 120 | } 121 | return baseKeyboard, nil 122 | } 123 | 124 | func getSettingsQueryStringForUser(userID int) (string, error) { 125 | preferencesStruct, err := db_connectors.GetUserPreferences(userID) 126 | if err != nil { 127 | zap.S().Error("Can't get user preferences", err) 128 | return "", err 129 | } 130 | userPointPreferences := db_connectors.UserIsCollectingPoints(userID) 131 | queryString := fmt.Sprintf(KEYBOARD_SETTINGS_OPENER_BASE_QUERY_STRING, preferencesStruct.ReportAtAll, preferencesStruct.WeekdayBitmap, preferencesStruct.FromTime, preferencesStruct.ToTime, userPointPreferences) 132 | return queryString, nil 133 | 134 | } 135 | 136 | /* Function that takes a requestType enum and returns a keyboard enum, that is then looked up 137 | when a keyboard should be used. 138 | In theory each messageHandler already has the knowledge to call SendMessage by themselves, 139 | a single response handler will very seldomly (never?) send more than one keyboard to the 140 | user. However, I hope that this centralised switch statement will simplify maintenance, since 141 | there's now a single point where this new functionality can be added, and no one will have 142 | to look up "uuuhhh, which message sent that type again?" 143 | */ 144 | func GetIdentifierViaRequestType(requestType UserRequestType, userID int) KeyboardIdentifier { 145 | zap.S().Debugf("User is sending us a %s request, returning corresponding keyboard identifier", requestType) 146 | switch requestType { 147 | case LENGTH_REPORT: 148 | { 149 | // User sent a length report, or declined to report length. Always comes from the REPORT_KEYBOARD, and always leads to MAIN_KEYBOARD 150 | return MainKeyboard 151 | } 152 | case PREPARE_REPORT: 153 | { 154 | // User wants to send a report/navigate to report keyboard. Always comes fom MAIN_KEYBOARD, always leads to REPORT_KEYBOARD 155 | return ReportKeyboard 156 | } 157 | case INFO_REQUEST: 158 | { 159 | // User requested mensa menu or queue length. Always comes from MAIN_KEYBOARD, always stays at MAIN_KEYBOARD 160 | return NilKeyboard 161 | } 162 | case PREPARE_SETTINGS: 163 | { 164 | // User wants to navigate to settings screen. Always comes from MAIN_KEYBOARD, always leads to SETTINGS_KEYBOARD 165 | return SettingsKeyboard 166 | } 167 | case SETTINGS_INTERACTION: 168 | { 169 | // User did something in settings, either change settings, or request help. Always comes from SETTINGS_KEYBOARD, always stays at SETTINGS_KEYBOARD 170 | return SettingsKeyboard 171 | } 172 | case PUSH_MESSAGE: 173 | { 174 | // A message the user didn't actively request. Always stays at current keyboard 175 | return NilKeyboard 176 | } 177 | case TUTORIAL_MESSAGE: 178 | { 179 | // User requested help or a tutorial. We need to init the main keyboard 180 | return MainKeyboard 181 | } 182 | case IMAGE_REQUEST: 183 | { 184 | // We currently haven't implemented setting keyboards with images yet 185 | return NilKeyboard 186 | } 187 | case PREPARE_MAIN: 188 | { 189 | // User wants to navigate to main. Usually starts at settings, always leads to main 190 | return MainKeyboard 191 | } 192 | case ACCOUNT_DELETION: 193 | // User deleted their account. Remove keyboard to make them feel like the interaction ended 194 | return NoKeyboard 195 | } 196 | zap.S().Error("Caller requested unknown keyboard type, returning nil keyboard") 197 | return NilKeyboard 198 | } 199 | 200 | // Returns the struct that represents the custom keyboard that should be shown to the user 201 | // Reads the json from the given file 202 | func getReplyKeyboard(jsonPath string) *ReplyKeyboardMarkupStruct { 203 | var keyboardArray [][]KeyboardButton 204 | 205 | jsonFile, err := os.Open(jsonPath) 206 | if err != nil { 207 | zap.S().Panicf("Can't access keyboard json file at %s", jsonPath) 208 | } 209 | defer jsonFile.Close() 210 | 211 | jsonAsBytes, err := ioutil.ReadAll(jsonFile) 212 | if err != nil { 213 | zap.S().Panicf("Can't read keyboard json file at %s", jsonPath) 214 | } 215 | err = json.Unmarshal(jsonAsBytes, &keyboardArray) 216 | if err != nil { 217 | zap.S().Panicf("Keyboard json file not formatted correctly: %s", err) 218 | } 219 | 220 | keyboardStruct := ReplyKeyboardMarkupStruct{ 221 | Keyboard: keyboardArray, 222 | ResizeKeyboard: true, 223 | } 224 | return &keyboardStruct 225 | } 226 | 227 | func LoadAllKeyboardsForTest() { 228 | getBaseKeyboardFromIdentifier(LegacyKeyboard) 229 | getBaseKeyboardFromIdentifier(ReportKeyboard) 230 | getBaseKeyboardFromIdentifier(MainKeyboard) 231 | getBaseKeyboardFromIdentifier(SettingsKeyboard) 232 | } 233 | -------------------------------------------------------------------------------- /telegram_connector/keyboards/00_report_keyboard.json: -------------------------------------------------------------------------------- 1 | [ 2 | [{"text":"L0: Virtually empty"}, {"text":"L1: Within kitchen"}, {"text":"L2: Up to food trays"}], 3 | [{"text":"L3: Within first room"}, {"text":"L4: Starting to corner"}, {"text":"L5: Past first desk"}], 4 | [{"text":"L6: Past second desk"}, {"text":"L7: Up to stairs"},{"text":"L8: Even longer"}], 5 | [{"text":"Can't tell"}] 6 | ] 7 | -------------------------------------------------------------------------------- /telegram_connector/keyboards/01_main_keyboard.json: -------------------------------------------------------------------------------- 1 | [ 2 | [{"text":"Report!"}], 3 | [{"text":"Queue?"}, {"text":"Menu?"}] 4 | ] 5 | -------------------------------------------------------------------------------- /telegram_connector/keyboards/02_settings_keyboard.json: -------------------------------------------------------------------------------- 1 | [ 2 | [{"text":"General Help"}, {"text":"Points Help"}], 3 | [{"text":"Change Settings","web_app":{"url":"https://files.telegram.dimeo.de/settings.html"}}, {"text":"Account Deletion"}], 4 | [{"text":"Back"}] 5 | ] 6 | -------------------------------------------------------------------------------- /telegram_connector/keyboards/keyboard.json: -------------------------------------------------------------------------------- 1 | [ 2 | [{"text":"L0: Virtually empty"}, {"text":"L1: Within kitchen"}, {"text":"L2: Up to food trays"}], 3 | [{"text":"L3: Within first room"}, {"text":"L4: Starting to corner"}, {"text":"L5: Past first desk"}], 4 | [{"text":"L6: Past second desk"}, {"text":"L7: Up to stairs"},{"text":"L8: Even longer"}], 5 | [{"text":"/jetze"}] 6 | ] 7 | -------------------------------------------------------------------------------- /telegram_connector/telegram_connector.go: -------------------------------------------------------------------------------- 1 | package telegram_connector 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "mime/multipart" 10 | "net/http" 11 | "os" 12 | "path/filepath" 13 | "strconv" 14 | 15 | "go.uber.org/zap" 16 | ) 17 | 18 | const KEY_TELEGRAM_TOKEN string = "MENSA_QUEUE_BOT_TELEGRAM_TOKEN" 19 | 20 | type WebhookRequestBodyWebAppData struct { 21 | ButtonText string `json:"button_text"` 22 | Data string `json:"data"` 23 | } 24 | 25 | // Struct definitions taken from https://www.sohamkamani.com/golang/telegram-bot/ 26 | type WebhookRequestBody struct { 27 | Message struct { 28 | Text string `json:"text"` 29 | Chat struct { 30 | ID int `json:"id"` 31 | } `json:"chat"` 32 | Date int `json:"date"` 33 | WebAppData WebhookRequestBodyWebAppData `json:"web_app_data"` 34 | } `json:"message"` 35 | } 36 | 37 | // Also see sendMessageRequestDeleteKeyboardRequestBody 38 | type sendMessageRequestBody struct { // https://core.telegram.org/bots/api#sendmessage 39 | ChatID int `json:"chat_id"` 40 | Text string `json:"text"` 41 | ParseMode string `json:"parse_mode"` 42 | ReplyKeyboardMarkup *ReplyKeyboardMarkupStruct `json:"reply_markup,omitempty"` 43 | } 44 | 45 | // Variation of sendMessageRequestBody. ReplyKeyboardMarkup allows multiple different types 46 | // of values. sendMessageRequestBody is there for setting a keyboard, 47 | // This is for unsetting it 48 | type sendMessageRequestDeleteKeyboardRequestBody struct { 49 | ChatID int `json:"chat_id"` 50 | Text string `json:"text"` 51 | ParseMode string `json:"parse_mode"` 52 | ReplyKeyboardMarkup *ReplyKeyboardRemoveStruct `json:"reply_markup,omitempty"` 53 | } 54 | 55 | // Used for "typing..." indicators, 56 | // https://core.telegram.org/bots/api#sendchataction 57 | type sendChatActionRequestBody struct { 58 | ChatID int `json:"chat_id"` 59 | Action string `json:"action"` 60 | } 61 | 62 | type WebAppInfo struct { 63 | URL string `json:"url"` 64 | } 65 | 66 | // https://core.telegram.org/bots/api#keyboardbutton 67 | type KeyboardButton struct { 68 | Text string `json:"text"` 69 | WebApp *WebAppInfo `json:"web_app,omitempty"` 70 | } 71 | 72 | type ReplyKeyboardMarkupStruct struct { // https://core.telegram.org/bots/api/#replykeyboardmarkup 73 | Keyboard [][]KeyboardButton `json:"keyboard"` 74 | ResizeKeyboard bool `json:"resize_keyboard,omitempty"` 75 | } 76 | 77 | type ReplyKeyboardRemoveStruct struct { // from https://core.telegram.org/bots/api#replykeyboardremove 78 | RemoveKeyboard bool `json:"remove_keyboard"` 79 | } 80 | 81 | // Used for images whose ID or URL we have. 82 | // No specific struct exists for "dynamic" 83 | // image uploads 84 | // https://core.telegram.org/bots/api#inputmediaphoto I think 85 | type sendWebPhotoRequestBody struct { 86 | ChatID int `json:"chat_id"` 87 | Photo string `json:"photo"` 88 | Caption string `json:"caption"` 89 | } 90 | 91 | type telegramResponseBodyPhoto struct { 92 | FileID string `json:"file_id"` // This is the ID we want to use to re-send this image 93 | } 94 | 95 | type telegramResponseBody struct { 96 | Result struct { 97 | Photo []telegramResponseBodyPhoto `json:"photo"` 98 | } `json:"result"` 99 | } 100 | 101 | /* 102 | Reads the telegram token from an environment variable. 103 | The Telegram token is used to identify us to the telegram server when sending messages. 104 | */ 105 | func GetTelegramToken() string { 106 | telegramKey, doesExist := os.LookupEnv(KEY_TELEGRAM_TOKEN) 107 | if !doesExist { 108 | zap.S().Panicf("Error: Environment variable for personal key (%s) not set", KEY_TELEGRAM_TOKEN) 109 | } 110 | return telegramKey 111 | } 112 | 113 | /* 114 | Sends a POST request to the telegram API that contains the link to a photo. This photo is sent to the identified user. Description is set as the text of the message 115 | https://core.telegram.org/bots/api#sendphoto 116 | */ 117 | func SendStaticWebPhoto(chatID int, photoURL string, description string, keyboardIdentifier KeyboardIdentifier) error { 118 | telegramUrl := fmt.Sprintf("https://api.telegram.org/bot%s/sendPhoto", GetTelegramToken()) 119 | 120 | if keyboardIdentifier != NilKeyboard { 121 | zap.S().Error("Tried to set a keyboard with SendDynamicPhoto. Is that even possible?") 122 | // Initial scan of the documentation says it isn't, but I was really tired, and it's quite hot right now. Definitely recheck if needed. 123 | } 124 | 125 | requestBody := &sendWebPhotoRequestBody{ 126 | ChatID: chatID, 127 | Photo: photoURL, 128 | Caption: description, 129 | } 130 | 131 | reqBytes, err := json.Marshal(requestBody) 132 | if err != nil { 133 | return err 134 | } 135 | 136 | _, err = http.Post(telegramUrl, "application/json", bytes.NewBuffer(reqBytes)) 137 | if err != nil { 138 | return err 139 | } 140 | return nil 141 | } 142 | 143 | /* PrepareMultipartForUpload reads the given file, chatID and caption, and writes them 144 | to a buffer in a format corresponding to a multipart request. 145 | Addicionally, it also returns the FormDataContentType for said multipart request. 146 | */ 147 | func prepareMultipartForUpload(pathToFile string, chatID int, caption string) (*bytes.Buffer, string, error) { 148 | // Read file content 149 | file, err := os.Open(pathToFile) 150 | defer file.Close() 151 | requestBody := new(bytes.Buffer) 152 | if err != nil { 153 | zap.S().Errorf("Can't open graph file for detailed /jetze report: %s", pathToFile) 154 | return requestBody, "", err 155 | } 156 | writer := multipart.NewWriter(requestBody) 157 | defer writer.Close() 158 | part, err := writer.CreateFormFile("photo", filepath.Base(pathToFile)) 159 | if err != nil { 160 | zap.S().Errorf("Can't CreateFormFile for /jetze report: %s", pathToFile) 161 | return nil, "", err 162 | } 163 | io.Copy(part, file) 164 | 165 | writer.WriteField("chat_id", strconv.Itoa(chatID)) 166 | writer.WriteField("caption", caption) 167 | 168 | return requestBody, writer.FormDataContentType(), nil 169 | } 170 | 171 | /* SendDynamicPhoto sends an image that is stored locally on this machine 172 | to the user with the given chatID. A description/caption can also be added. 173 | 174 | Returns telegram assigned identifier and error, if the request should fail 175 | */ 176 | func SendDynamicPhoto(chatID int, photoFilePath string, description string, keyboardIdentifier KeyboardIdentifier) (string, error) { 177 | telegramUrl := fmt.Sprintf("https://api.telegram.org/bot%s/sendPhoto", GetTelegramToken()) 178 | 179 | requestBody, contentType, err := prepareMultipartForUpload(photoFilePath, chatID, description) 180 | 181 | if keyboardIdentifier != NilKeyboard { 182 | zap.S().Error("Tried to set a keyboard with SendDynamicPhoto. Is that even possible?") 183 | // Initial scan of the documentation says it isn't, but I was really tired, and it's quite hot right now. Definitely recheck if needed. 184 | } 185 | 186 | if err != nil { 187 | zap.S().Errorf("Couldn't build request to send detailed /jetze report") 188 | return "", err 189 | } 190 | request, _ := http.NewRequest("POST", telegramUrl, requestBody) 191 | request.Header.Add("Content-Type", contentType) 192 | client := &http.Client{} 193 | response, err := client.Do(request) 194 | defer response.Body.Close() 195 | 196 | if err != nil { 197 | zap.S().Errorw("Dynamic photo request failed", "error", err) 198 | return "", err 199 | } 200 | telegramResponse := &telegramResponseBody{} 201 | responseDecoder := json.NewDecoder(response.Body) 202 | err = responseDecoder.Decode(telegramResponse) 203 | 204 | if err != nil { 205 | return "", err 206 | } 207 | 208 | // Telegram returns a list of images, in different resolutions 209 | // All of them share the same file_id 210 | telegramIdentifier := telegramResponse.Result.Photo[0].FileID 211 | 212 | return telegramIdentifier, nil 213 | } 214 | 215 | /* 216 | Sends a POST request to the telegram API that sends the indicated string to the indicated user. 217 | https://core.telegram.org/bots/api#sendmessage 218 | */ 219 | func SendMessage(chatID int, message string, keyboardIdentifier KeyboardIdentifier) error { 220 | telegramUrl := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", GetTelegramToken()) 221 | var reqBytes []byte 222 | var err error 223 | if keyboardIdentifier == NilKeyboard { 224 | requestBody := &sendMessageRequestBody{ 225 | ChatID: chatID, 226 | Text: message, 227 | ParseMode: "HTML", 228 | } 229 | reqBytes, err = json.Marshal(requestBody) 230 | } else if keyboardIdentifier == NoKeyboard { 231 | noKeyboard := &ReplyKeyboardRemoveStruct{RemoveKeyboard: true} 232 | requestBodyWithKeyboardRemoval := &sendMessageRequestDeleteKeyboardRequestBody{ 233 | ChatID: chatID, 234 | Text: message, 235 | ParseMode: "HTML", 236 | ReplyKeyboardMarkup: noKeyboard, 237 | } 238 | reqBytes, err = json.Marshal(requestBodyWithKeyboardRemoval) 239 | 240 | } else { 241 | keyboard, err := GetCustomizedKeyboardFromIdentifier(chatID, keyboardIdentifier) 242 | if err != nil { 243 | zap.S().Errorw("Error while sending message, can't get keyboard", 244 | "keyboardIdentifier", keyboardIdentifier, 245 | "error", err) 246 | } 247 | requestBody := &sendMessageRequestBody{ 248 | ChatID: chatID, 249 | Text: message, 250 | ParseMode: "HTML", 251 | ReplyKeyboardMarkup: keyboard, 252 | } 253 | reqBytes, err = json.Marshal(requestBody) 254 | } 255 | 256 | if err != nil { 257 | // err from reqBytes 258 | return err 259 | } 260 | 261 | response, err := http.Post(telegramUrl, "application/json", bytes.NewBuffer(reqBytes)) 262 | defer response.Body.Close() 263 | if err != nil { 264 | return err 265 | } 266 | if response.StatusCode != 200 { 267 | body, _ := ioutil.ReadAll(response.Body) 268 | 269 | zap.S().Errorw("Sending message failed:", "Response", body) 270 | 271 | } 272 | return nil 273 | } 274 | 275 | /* SendTypingIndicator sets the bots status to "sending image" 276 | for this specific user*/ 277 | func SendTypingIndicator(chatID int) error { 278 | telegramUrl := fmt.Sprintf("https://api.telegram.org/bot%s/sendChatAction", GetTelegramToken()) 279 | indicatorString := "upload_photo" 280 | requestBody := &sendChatActionRequestBody{ 281 | ChatID: chatID, 282 | Action: indicatorString, 283 | } 284 | reqBytes, err := json.Marshal(requestBody) 285 | if err != nil { 286 | return err 287 | } 288 | _, err = http.Post(telegramUrl, "application/json", bytes.NewBuffer(reqBytes)) 289 | if err != nil { 290 | zap.S().Error("Failure while sending typing indicator", err) 291 | return err 292 | } 293 | return nil 294 | } 295 | -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "go.uber.org/zap" 8 | ) 9 | 10 | const KEY_PERSONAL_TOKEN string = "MENSA_QUEUE_BOT_PERSONAL_TOKEN" 11 | const KEY_DEBUG_MODE string = "MENSA_QUEUE_BOT_DEBUG_MODE" 12 | 13 | func GetLocalLocation() *time.Location { 14 | potsdamLocation, err := time.LoadLocation("Europe/Berlin") 15 | if err != nil { 16 | zap.S().Panic("Can't load location Europe/Berlin!") 17 | } 18 | return potsdamLocation 19 | } 20 | 21 | /* 22 | Reads the personal token from environment variables. 23 | The personal token is part of the url path, and tries to prevent non-authorized users from accessing our webhooks, and therefore spamming our users. 24 | For this purpose it needs to be long, random, and non-public. 25 | */ 26 | func GetPersonalToken() string { 27 | personalKey, doesExist := os.LookupEnv(KEY_PERSONAL_TOKEN) 28 | 29 | if !doesExist { 30 | zap.S().Panicf("Fatal Error: Environment variable for personal key not set: %s", KEY_PERSONAL_TOKEN) 31 | } 32 | return personalKey 33 | } 34 | 35 | func GetMensaOpeningTime() time.Time { 36 | var today = time.Now() 37 | // Mensa opens at 08:00 38 | var openingTime = time.Date(today.Year(), today.Month(), today.Day(), 8, 0, 0, 0, GetLocalLocation()) 39 | return openingTime 40 | } 41 | 42 | func GetMensaClosingTime() time.Time { 43 | var today = time.Now() 44 | // Mensa closes at 18:00 45 | var closingTime = time.Date(today.Year(), today.Month(), today.Day(), 18, 0, 0, 0, GetLocalLocation()) 46 | return closingTime 47 | } 48 | 49 | /*IsInDebugMode can be used to change behaviour 50 | for testing. Currently mostly used to allow 51 | reports at weird times 52 | */ 53 | func IsInDebugMode() bool { 54 | _, doesExist := os.LookupEnv(KEY_DEBUG_MODE) 55 | if !doesExist { 56 | return false 57 | } 58 | return true 59 | } 60 | --------------------------------------------------------------------------------
45 | 46 | Do you want to collect points for reports? 47 | On which days do you want to receive menu messages? 48 | 49 | 50 | 52 | Mon 53 | 54 | 55 | 57 | Tue 58 | 59 | 61 | Wed 62 | 63 | 65 | Thu 66 | 67 | 69 | Fri 70 | 71 | 72 | 73 | 91 | 92 | 93 | From... 94 | 96 | To... 97 | 99 | 100 | Change settings 101 | 102 | 188 | 189 | 190 | 191 | -------------------------------------------------------------------------------- /telegram_connector/keyboard_decider.go: -------------------------------------------------------------------------------- 1 | package telegram_connector 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | 10 | "github.com/ADimeo/MensaQueueBot/db_connectors" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | type UserRequestType string 15 | 16 | // See more detailed descriptions within switch cases of 17 | // GetIdentifierBasedOnInput() 18 | const ( 19 | LENGTH_REPORT UserRequestType = "LENGTH_REPORT" 20 | PREPARE_REPORT UserRequestType = "PREPARE_REPORT" 21 | INFO_REQUEST UserRequestType = "INFO_REQUEST" 22 | PREPARE_SETTINGS UserRequestType = "PREPARE_SETTINGS" 23 | SETTINGS_INTERACTION UserRequestType = "SETTINGS_INTERACTION" 24 | PUSH_MESSAGE UserRequestType = "PUSH_MESSAGE" 25 | TUTORIAL_MESSAGE UserRequestType = "TUTORIAL_MESSAGE" 26 | PREPARE_MAIN UserRequestType = "PREPARE_MAIN" 27 | ACCOUNT_DELETION UserRequestType = "ACCOUNT_DELETION" 28 | IMAGE_REQUEST UserRequestType = "IMAGE_REQUEST" 29 | ) 30 | 31 | type KeyboardIdentifier int 32 | 33 | const ( 34 | LegacyKeyboard KeyboardIdentifier = -1 35 | NilKeyboard KeyboardIdentifier = 0 36 | ReportKeyboard KeyboardIdentifier = 1 37 | MainKeyboard KeyboardIdentifier = 2 38 | SettingsKeyboard KeyboardIdentifier = 3 39 | NoKeyboard KeyboardIdentifier = 4 40 | ) 41 | 42 | const LEGACY_KEYBOARD_FILEPATH = "./telegram_connector/keyboards/keyboard.json" 43 | const REPORT_KEYBOARD_FILEPATH = "./telegram_connector/keyboards/00_report_keyboard.json" 44 | const MAIN_KEYBOARD_FILEPATH = "./telegram_connector/keyboards/01_main_keyboard.json" 45 | const SETTINGS_KEYBOARD_FILEPATH = "./telegram_connector/keyboards/02_settings_keyboard.json" 46 | 47 | // Needs to be consistent with javascript logic in settings.html 48 | const KEYBOARD_SETTINGS_OPENER_BASE_QUERY_STRING = "?reportAtAll=%t&reportingDays=%d&fromTime=%s&toTime=%s&points=%t" 49 | 50 | func GetCustomizedKeyboardFromIdentifier(chatID int, identifier KeyboardIdentifier) (*ReplyKeyboardMarkupStruct, error) { 51 | baseKeyboard, err := getBaseKeyboardFromIdentifier(identifier) 52 | if err != nil { 53 | return baseKeyboard, err 54 | } 55 | return customizeKeyboardForUser(chatID, identifier, baseKeyboard) 56 | 57 | } 58 | 59 | /* 60 | Takes enum values, as defined in keyboard_decider.go 61 | Reads a JSON file and returns a keyboard struct, depending on the requested identifier. 62 | Raises error on unknown keyboard, or if a nil keyboard was requested - in that case 63 | the caller needs to have special logic. 64 | 65 | This function mostly exists to provide some stability for the NilKeyboard case. 66 | Not having this function would lead to some weird things being encoded in 67 | the compined GetIdentifierViaRequestType and getBaseKeyboardFromIdentifier function 68 | */ 69 | func getBaseKeyboardFromIdentifier(identifier KeyboardIdentifier) (*ReplyKeyboardMarkupStruct, error) { 70 | switch identifier { 71 | case LegacyKeyboard: 72 | { 73 | return getReplyKeyboard(LEGACY_KEYBOARD_FILEPATH), nil 74 | } 75 | case NilKeyboard: 76 | { 77 | var nilKeyboard ReplyKeyboardMarkupStruct 78 | return &nilKeyboard, errors.New("Caller requested nil keyboard, should not send keyboard instead") 79 | } 80 | case NoKeyboard: 81 | { 82 | var noKeyboard ReplyKeyboardMarkupStruct 83 | return &noKeyboard, errors.New("Caller requested no keyboard, should send different message instead") 84 | } 85 | case ReportKeyboard: 86 | { 87 | return getReplyKeyboard(REPORT_KEYBOARD_FILEPATH), nil 88 | } 89 | case MainKeyboard: 90 | { 91 | return getReplyKeyboard(MAIN_KEYBOARD_FILEPATH), nil 92 | } 93 | case SettingsKeyboard: 94 | { 95 | return getReplyKeyboard(SETTINGS_KEYBOARD_FILEPATH), nil 96 | } 97 | } 98 | var nilKeyboard ReplyKeyboardMarkupStruct 99 | return &nilKeyboard, errors.New("Caller requested unknown keyboard type") 100 | } 101 | 102 | /* 103 | "Customizes" the base keyboard for a single user. Right now this means exactly one thing: 104 | The settings keyboard is enriched with the current user settings, so that they can be 105 | displayed without serving an additional request. 106 | */ 107 | func customizeKeyboardForUser(userID int, identifier KeyboardIdentifier, baseKeyboard *ReplyKeyboardMarkupStruct) (*ReplyKeyboardMarkupStruct, error) { 108 | if identifier == SettingsKeyboard { 109 | // This is the only one that needs customization right now 110 | // We need to add the users current settings to the web_app url 111 | // which opens the webview with the settings 112 | settingsQueryString, err := getSettingsQueryStringForUser(userID) 113 | if err != nil { 114 | // Can't customize URL. Go back to defaults, which the html/js define 115 | return baseKeyboard, nil 116 | } 117 | customizedURL := baseKeyboard.Keyboard[1][0].WebApp.URL + settingsQueryString 118 | 119 | baseKeyboard.Keyboard[1][0].WebApp.URL = customizedURL 120 | } 121 | return baseKeyboard, nil 122 | } 123 | 124 | func getSettingsQueryStringForUser(userID int) (string, error) { 125 | preferencesStruct, err := db_connectors.GetUserPreferences(userID) 126 | if err != nil { 127 | zap.S().Error("Can't get user preferences", err) 128 | return "", err 129 | } 130 | userPointPreferences := db_connectors.UserIsCollectingPoints(userID) 131 | queryString := fmt.Sprintf(KEYBOARD_SETTINGS_OPENER_BASE_QUERY_STRING, preferencesStruct.ReportAtAll, preferencesStruct.WeekdayBitmap, preferencesStruct.FromTime, preferencesStruct.ToTime, userPointPreferences) 132 | return queryString, nil 133 | 134 | } 135 | 136 | /* Function that takes a requestType enum and returns a keyboard enum, that is then looked up 137 | when a keyboard should be used. 138 | In theory each messageHandler already has the knowledge to call SendMessage by themselves, 139 | a single response handler will very seldomly (never?) send more than one keyboard to the 140 | user. However, I hope that this centralised switch statement will simplify maintenance, since 141 | there's now a single point where this new functionality can be added, and no one will have 142 | to look up "uuuhhh, which message sent that type again?" 143 | */ 144 | func GetIdentifierViaRequestType(requestType UserRequestType, userID int) KeyboardIdentifier { 145 | zap.S().Debugf("User is sending us a %s request, returning corresponding keyboard identifier", requestType) 146 | switch requestType { 147 | case LENGTH_REPORT: 148 | { 149 | // User sent a length report, or declined to report length. Always comes from the REPORT_KEYBOARD, and always leads to MAIN_KEYBOARD 150 | return MainKeyboard 151 | } 152 | case PREPARE_REPORT: 153 | { 154 | // User wants to send a report/navigate to report keyboard. Always comes fom MAIN_KEYBOARD, always leads to REPORT_KEYBOARD 155 | return ReportKeyboard 156 | } 157 | case INFO_REQUEST: 158 | { 159 | // User requested mensa menu or queue length. Always comes from MAIN_KEYBOARD, always stays at MAIN_KEYBOARD 160 | return NilKeyboard 161 | } 162 | case PREPARE_SETTINGS: 163 | { 164 | // User wants to navigate to settings screen. Always comes from MAIN_KEYBOARD, always leads to SETTINGS_KEYBOARD 165 | return SettingsKeyboard 166 | } 167 | case SETTINGS_INTERACTION: 168 | { 169 | // User did something in settings, either change settings, or request help. Always comes from SETTINGS_KEYBOARD, always stays at SETTINGS_KEYBOARD 170 | return SettingsKeyboard 171 | } 172 | case PUSH_MESSAGE: 173 | { 174 | // A message the user didn't actively request. Always stays at current keyboard 175 | return NilKeyboard 176 | } 177 | case TUTORIAL_MESSAGE: 178 | { 179 | // User requested help or a tutorial. We need to init the main keyboard 180 | return MainKeyboard 181 | } 182 | case IMAGE_REQUEST: 183 | { 184 | // We currently haven't implemented setting keyboards with images yet 185 | return NilKeyboard 186 | } 187 | case PREPARE_MAIN: 188 | { 189 | // User wants to navigate to main. Usually starts at settings, always leads to main 190 | return MainKeyboard 191 | } 192 | case ACCOUNT_DELETION: 193 | // User deleted their account. Remove keyboard to make them feel like the interaction ended 194 | return NoKeyboard 195 | } 196 | zap.S().Error("Caller requested unknown keyboard type, returning nil keyboard") 197 | return NilKeyboard 198 | } 199 | 200 | // Returns the struct that represents the custom keyboard that should be shown to the user 201 | // Reads the json from the given file 202 | func getReplyKeyboard(jsonPath string) *ReplyKeyboardMarkupStruct { 203 | var keyboardArray [][]KeyboardButton 204 | 205 | jsonFile, err := os.Open(jsonPath) 206 | if err != nil { 207 | zap.S().Panicf("Can't access keyboard json file at %s", jsonPath) 208 | } 209 | defer jsonFile.Close() 210 | 211 | jsonAsBytes, err := ioutil.ReadAll(jsonFile) 212 | if err != nil { 213 | zap.S().Panicf("Can't read keyboard json file at %s", jsonPath) 214 | } 215 | err = json.Unmarshal(jsonAsBytes, &keyboardArray) 216 | if err != nil { 217 | zap.S().Panicf("Keyboard json file not formatted correctly: %s", err) 218 | } 219 | 220 | keyboardStruct := ReplyKeyboardMarkupStruct{ 221 | Keyboard: keyboardArray, 222 | ResizeKeyboard: true, 223 | } 224 | return &keyboardStruct 225 | } 226 | 227 | func LoadAllKeyboardsForTest() { 228 | getBaseKeyboardFromIdentifier(LegacyKeyboard) 229 | getBaseKeyboardFromIdentifier(ReportKeyboard) 230 | getBaseKeyboardFromIdentifier(MainKeyboard) 231 | getBaseKeyboardFromIdentifier(SettingsKeyboard) 232 | } 233 | -------------------------------------------------------------------------------- /telegram_connector/keyboards/00_report_keyboard.json: -------------------------------------------------------------------------------- 1 | [ 2 | [{"text":"L0: Virtually empty"}, {"text":"L1: Within kitchen"}, {"text":"L2: Up to food trays"}], 3 | [{"text":"L3: Within first room"}, {"text":"L4: Starting to corner"}, {"text":"L5: Past first desk"}], 4 | [{"text":"L6: Past second desk"}, {"text":"L7: Up to stairs"},{"text":"L8: Even longer"}], 5 | [{"text":"Can't tell"}] 6 | ] 7 | -------------------------------------------------------------------------------- /telegram_connector/keyboards/01_main_keyboard.json: -------------------------------------------------------------------------------- 1 | [ 2 | [{"text":"Report!"}], 3 | [{"text":"Queue?"}, {"text":"Menu?"}] 4 | ] 5 | -------------------------------------------------------------------------------- /telegram_connector/keyboards/02_settings_keyboard.json: -------------------------------------------------------------------------------- 1 | [ 2 | [{"text":"General Help"}, {"text":"Points Help"}], 3 | [{"text":"Change Settings","web_app":{"url":"https://files.telegram.dimeo.de/settings.html"}}, {"text":"Account Deletion"}], 4 | [{"text":"Back"}] 5 | ] 6 | -------------------------------------------------------------------------------- /telegram_connector/keyboards/keyboard.json: -------------------------------------------------------------------------------- 1 | [ 2 | [{"text":"L0: Virtually empty"}, {"text":"L1: Within kitchen"}, {"text":"L2: Up to food trays"}], 3 | [{"text":"L3: Within first room"}, {"text":"L4: Starting to corner"}, {"text":"L5: Past first desk"}], 4 | [{"text":"L6: Past second desk"}, {"text":"L7: Up to stairs"},{"text":"L8: Even longer"}], 5 | [{"text":"/jetze"}] 6 | ] 7 | -------------------------------------------------------------------------------- /telegram_connector/telegram_connector.go: -------------------------------------------------------------------------------- 1 | package telegram_connector 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "mime/multipart" 10 | "net/http" 11 | "os" 12 | "path/filepath" 13 | "strconv" 14 | 15 | "go.uber.org/zap" 16 | ) 17 | 18 | const KEY_TELEGRAM_TOKEN string = "MENSA_QUEUE_BOT_TELEGRAM_TOKEN" 19 | 20 | type WebhookRequestBodyWebAppData struct { 21 | ButtonText string `json:"button_text"` 22 | Data string `json:"data"` 23 | } 24 | 25 | // Struct definitions taken from https://www.sohamkamani.com/golang/telegram-bot/ 26 | type WebhookRequestBody struct { 27 | Message struct { 28 | Text string `json:"text"` 29 | Chat struct { 30 | ID int `json:"id"` 31 | } `json:"chat"` 32 | Date int `json:"date"` 33 | WebAppData WebhookRequestBodyWebAppData `json:"web_app_data"` 34 | } `json:"message"` 35 | } 36 | 37 | // Also see sendMessageRequestDeleteKeyboardRequestBody 38 | type sendMessageRequestBody struct { // https://core.telegram.org/bots/api#sendmessage 39 | ChatID int `json:"chat_id"` 40 | Text string `json:"text"` 41 | ParseMode string `json:"parse_mode"` 42 | ReplyKeyboardMarkup *ReplyKeyboardMarkupStruct `json:"reply_markup,omitempty"` 43 | } 44 | 45 | // Variation of sendMessageRequestBody. ReplyKeyboardMarkup allows multiple different types 46 | // of values. sendMessageRequestBody is there for setting a keyboard, 47 | // This is for unsetting it 48 | type sendMessageRequestDeleteKeyboardRequestBody struct { 49 | ChatID int `json:"chat_id"` 50 | Text string `json:"text"` 51 | ParseMode string `json:"parse_mode"` 52 | ReplyKeyboardMarkup *ReplyKeyboardRemoveStruct `json:"reply_markup,omitempty"` 53 | } 54 | 55 | // Used for "typing..." indicators, 56 | // https://core.telegram.org/bots/api#sendchataction 57 | type sendChatActionRequestBody struct { 58 | ChatID int `json:"chat_id"` 59 | Action string `json:"action"` 60 | } 61 | 62 | type WebAppInfo struct { 63 | URL string `json:"url"` 64 | } 65 | 66 | // https://core.telegram.org/bots/api#keyboardbutton 67 | type KeyboardButton struct { 68 | Text string `json:"text"` 69 | WebApp *WebAppInfo `json:"web_app,omitempty"` 70 | } 71 | 72 | type ReplyKeyboardMarkupStruct struct { // https://core.telegram.org/bots/api/#replykeyboardmarkup 73 | Keyboard [][]KeyboardButton `json:"keyboard"` 74 | ResizeKeyboard bool `json:"resize_keyboard,omitempty"` 75 | } 76 | 77 | type ReplyKeyboardRemoveStruct struct { // from https://core.telegram.org/bots/api#replykeyboardremove 78 | RemoveKeyboard bool `json:"remove_keyboard"` 79 | } 80 | 81 | // Used for images whose ID or URL we have. 82 | // No specific struct exists for "dynamic" 83 | // image uploads 84 | // https://core.telegram.org/bots/api#inputmediaphoto I think 85 | type sendWebPhotoRequestBody struct { 86 | ChatID int `json:"chat_id"` 87 | Photo string `json:"photo"` 88 | Caption string `json:"caption"` 89 | } 90 | 91 | type telegramResponseBodyPhoto struct { 92 | FileID string `json:"file_id"` // This is the ID we want to use to re-send this image 93 | } 94 | 95 | type telegramResponseBody struct { 96 | Result struct { 97 | Photo []telegramResponseBodyPhoto `json:"photo"` 98 | } `json:"result"` 99 | } 100 | 101 | /* 102 | Reads the telegram token from an environment variable. 103 | The Telegram token is used to identify us to the telegram server when sending messages. 104 | */ 105 | func GetTelegramToken() string { 106 | telegramKey, doesExist := os.LookupEnv(KEY_TELEGRAM_TOKEN) 107 | if !doesExist { 108 | zap.S().Panicf("Error: Environment variable for personal key (%s) not set", KEY_TELEGRAM_TOKEN) 109 | } 110 | return telegramKey 111 | } 112 | 113 | /* 114 | Sends a POST request to the telegram API that contains the link to a photo. This photo is sent to the identified user. Description is set as the text of the message 115 | https://core.telegram.org/bots/api#sendphoto 116 | */ 117 | func SendStaticWebPhoto(chatID int, photoURL string, description string, keyboardIdentifier KeyboardIdentifier) error { 118 | telegramUrl := fmt.Sprintf("https://api.telegram.org/bot%s/sendPhoto", GetTelegramToken()) 119 | 120 | if keyboardIdentifier != NilKeyboard { 121 | zap.S().Error("Tried to set a keyboard with SendDynamicPhoto. Is that even possible?") 122 | // Initial scan of the documentation says it isn't, but I was really tired, and it's quite hot right now. Definitely recheck if needed. 123 | } 124 | 125 | requestBody := &sendWebPhotoRequestBody{ 126 | ChatID: chatID, 127 | Photo: photoURL, 128 | Caption: description, 129 | } 130 | 131 | reqBytes, err := json.Marshal(requestBody) 132 | if err != nil { 133 | return err 134 | } 135 | 136 | _, err = http.Post(telegramUrl, "application/json", bytes.NewBuffer(reqBytes)) 137 | if err != nil { 138 | return err 139 | } 140 | return nil 141 | } 142 | 143 | /* PrepareMultipartForUpload reads the given file, chatID and caption, and writes them 144 | to a buffer in a format corresponding to a multipart request. 145 | Addicionally, it also returns the FormDataContentType for said multipart request. 146 | */ 147 | func prepareMultipartForUpload(pathToFile string, chatID int, caption string) (*bytes.Buffer, string, error) { 148 | // Read file content 149 | file, err := os.Open(pathToFile) 150 | defer file.Close() 151 | requestBody := new(bytes.Buffer) 152 | if err != nil { 153 | zap.S().Errorf("Can't open graph file for detailed /jetze report: %s", pathToFile) 154 | return requestBody, "", err 155 | } 156 | writer := multipart.NewWriter(requestBody) 157 | defer writer.Close() 158 | part, err := writer.CreateFormFile("photo", filepath.Base(pathToFile)) 159 | if err != nil { 160 | zap.S().Errorf("Can't CreateFormFile for /jetze report: %s", pathToFile) 161 | return nil, "", err 162 | } 163 | io.Copy(part, file) 164 | 165 | writer.WriteField("chat_id", strconv.Itoa(chatID)) 166 | writer.WriteField("caption", caption) 167 | 168 | return requestBody, writer.FormDataContentType(), nil 169 | } 170 | 171 | /* SendDynamicPhoto sends an image that is stored locally on this machine 172 | to the user with the given chatID. A description/caption can also be added. 173 | 174 | Returns telegram assigned identifier and error, if the request should fail 175 | */ 176 | func SendDynamicPhoto(chatID int, photoFilePath string, description string, keyboardIdentifier KeyboardIdentifier) (string, error) { 177 | telegramUrl := fmt.Sprintf("https://api.telegram.org/bot%s/sendPhoto", GetTelegramToken()) 178 | 179 | requestBody, contentType, err := prepareMultipartForUpload(photoFilePath, chatID, description) 180 | 181 | if keyboardIdentifier != NilKeyboard { 182 | zap.S().Error("Tried to set a keyboard with SendDynamicPhoto. Is that even possible?") 183 | // Initial scan of the documentation says it isn't, but I was really tired, and it's quite hot right now. Definitely recheck if needed. 184 | } 185 | 186 | if err != nil { 187 | zap.S().Errorf("Couldn't build request to send detailed /jetze report") 188 | return "", err 189 | } 190 | request, _ := http.NewRequest("POST", telegramUrl, requestBody) 191 | request.Header.Add("Content-Type", contentType) 192 | client := &http.Client{} 193 | response, err := client.Do(request) 194 | defer response.Body.Close() 195 | 196 | if err != nil { 197 | zap.S().Errorw("Dynamic photo request failed", "error", err) 198 | return "", err 199 | } 200 | telegramResponse := &telegramResponseBody{} 201 | responseDecoder := json.NewDecoder(response.Body) 202 | err = responseDecoder.Decode(telegramResponse) 203 | 204 | if err != nil { 205 | return "", err 206 | } 207 | 208 | // Telegram returns a list of images, in different resolutions 209 | // All of them share the same file_id 210 | telegramIdentifier := telegramResponse.Result.Photo[0].FileID 211 | 212 | return telegramIdentifier, nil 213 | } 214 | 215 | /* 216 | Sends a POST request to the telegram API that sends the indicated string to the indicated user. 217 | https://core.telegram.org/bots/api#sendmessage 218 | */ 219 | func SendMessage(chatID int, message string, keyboardIdentifier KeyboardIdentifier) error { 220 | telegramUrl := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", GetTelegramToken()) 221 | var reqBytes []byte 222 | var err error 223 | if keyboardIdentifier == NilKeyboard { 224 | requestBody := &sendMessageRequestBody{ 225 | ChatID: chatID, 226 | Text: message, 227 | ParseMode: "HTML", 228 | } 229 | reqBytes, err = json.Marshal(requestBody) 230 | } else if keyboardIdentifier == NoKeyboard { 231 | noKeyboard := &ReplyKeyboardRemoveStruct{RemoveKeyboard: true} 232 | requestBodyWithKeyboardRemoval := &sendMessageRequestDeleteKeyboardRequestBody{ 233 | ChatID: chatID, 234 | Text: message, 235 | ParseMode: "HTML", 236 | ReplyKeyboardMarkup: noKeyboard, 237 | } 238 | reqBytes, err = json.Marshal(requestBodyWithKeyboardRemoval) 239 | 240 | } else { 241 | keyboard, err := GetCustomizedKeyboardFromIdentifier(chatID, keyboardIdentifier) 242 | if err != nil { 243 | zap.S().Errorw("Error while sending message, can't get keyboard", 244 | "keyboardIdentifier", keyboardIdentifier, 245 | "error", err) 246 | } 247 | requestBody := &sendMessageRequestBody{ 248 | ChatID: chatID, 249 | Text: message, 250 | ParseMode: "HTML", 251 | ReplyKeyboardMarkup: keyboard, 252 | } 253 | reqBytes, err = json.Marshal(requestBody) 254 | } 255 | 256 | if err != nil { 257 | // err from reqBytes 258 | return err 259 | } 260 | 261 | response, err := http.Post(telegramUrl, "application/json", bytes.NewBuffer(reqBytes)) 262 | defer response.Body.Close() 263 | if err != nil { 264 | return err 265 | } 266 | if response.StatusCode != 200 { 267 | body, _ := ioutil.ReadAll(response.Body) 268 | 269 | zap.S().Errorw("Sending message failed:", "Response", body) 270 | 271 | } 272 | return nil 273 | } 274 | 275 | /* SendTypingIndicator sets the bots status to "sending image" 276 | for this specific user*/ 277 | func SendTypingIndicator(chatID int) error { 278 | telegramUrl := fmt.Sprintf("https://api.telegram.org/bot%s/sendChatAction", GetTelegramToken()) 279 | indicatorString := "upload_photo" 280 | requestBody := &sendChatActionRequestBody{ 281 | ChatID: chatID, 282 | Action: indicatorString, 283 | } 284 | reqBytes, err := json.Marshal(requestBody) 285 | if err != nil { 286 | return err 287 | } 288 | _, err = http.Post(telegramUrl, "application/json", bytes.NewBuffer(reqBytes)) 289 | if err != nil { 290 | zap.S().Error("Failure while sending typing indicator", err) 291 | return err 292 | } 293 | return nil 294 | } 295 | -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "go.uber.org/zap" 8 | ) 9 | 10 | const KEY_PERSONAL_TOKEN string = "MENSA_QUEUE_BOT_PERSONAL_TOKEN" 11 | const KEY_DEBUG_MODE string = "MENSA_QUEUE_BOT_DEBUG_MODE" 12 | 13 | func GetLocalLocation() *time.Location { 14 | potsdamLocation, err := time.LoadLocation("Europe/Berlin") 15 | if err != nil { 16 | zap.S().Panic("Can't load location Europe/Berlin!") 17 | } 18 | return potsdamLocation 19 | } 20 | 21 | /* 22 | Reads the personal token from environment variables. 23 | The personal token is part of the url path, and tries to prevent non-authorized users from accessing our webhooks, and therefore spamming our users. 24 | For this purpose it needs to be long, random, and non-public. 25 | */ 26 | func GetPersonalToken() string { 27 | personalKey, doesExist := os.LookupEnv(KEY_PERSONAL_TOKEN) 28 | 29 | if !doesExist { 30 | zap.S().Panicf("Fatal Error: Environment variable for personal key not set: %s", KEY_PERSONAL_TOKEN) 31 | } 32 | return personalKey 33 | } 34 | 35 | func GetMensaOpeningTime() time.Time { 36 | var today = time.Now() 37 | // Mensa opens at 08:00 38 | var openingTime = time.Date(today.Year(), today.Month(), today.Day(), 8, 0, 0, 0, GetLocalLocation()) 39 | return openingTime 40 | } 41 | 42 | func GetMensaClosingTime() time.Time { 43 | var today = time.Now() 44 | // Mensa closes at 18:00 45 | var closingTime = time.Date(today.Year(), today.Month(), today.Day(), 18, 0, 0, 0, GetLocalLocation()) 46 | return closingTime 47 | } 48 | 49 | /*IsInDebugMode can be used to change behaviour 50 | for testing. Currently mostly used to allow 51 | reports at weird times 52 | */ 53 | func IsInDebugMode() bool { 54 | _, doesExist := os.LookupEnv(KEY_DEBUG_MODE) 55 | if !doesExist { 56 | return false 57 | } 58 | return true 59 | } 60 | --------------------------------------------------------------------------------