├── raybot ├── actions │ ├── __init__.py │ ├── messages.py │ ├── addr.py │ ├── transfer.py │ └── poi.py ├── version.py ├── __init__.py ├── util │ ├── __init__.py │ ├── PTC75F.ttf │ ├── marker-icon.png │ ├── log.py │ ├── util.py │ └── map.py ├── model │ ├── __init__.py │ ├── create_tables.sql │ ├── entities.py │ └── db.py ├── handlers │ ├── __init__.py │ ├── addr.py │ ├── messages.py │ ├── default.py │ ├── poi.py │ ├── review.py │ └── moderate.py ├── bot.py ├── config │ ├── addr.sample.yml │ ├── config.sample.yml │ ├── responses.sample.yml │ ├── tags │ │ └── tags.ru.yml │ └── strings │ │ └── strings.ru.yml ├── cli │ ├── test_map.py │ ├── buildings.py │ ├── photos.py │ └── missing.py ├── __main__.py └── settings.py ├── setup.cfg ├── docs ├── entrance.jpg ├── map_example.jpg ├── raybot_edit.png ├── raybot_start.jpg ├── review_mode.png ├── mayak_bot_visit.jpg ├── 6-osm.md ├── 5-updates.md ├── 4-usage.md ├── 1-addresses.md ├── 2-install.md └── 3-poi.md ├── .gitignore ├── requirements.txt ├── LICENSE.md ├── README.md └── setup.py /raybot/actions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /raybot/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.1.0' 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_files = LICENSE.md 3 | -------------------------------------------------------------------------------- /raybot/__init__.py: -------------------------------------------------------------------------------- 1 | from .settings import config # noqa 2 | -------------------------------------------------------------------------------- /raybot/util/__init__.py: -------------------------------------------------------------------------------- 1 | from .map import get_map 2 | from .util import * 3 | -------------------------------------------------------------------------------- /docs/entrance.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/bot_na_rayone/main/docs/entrance.jpg -------------------------------------------------------------------------------- /docs/map_example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/bot_na_rayone/main/docs/map_example.jpg -------------------------------------------------------------------------------- /docs/raybot_edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/bot_na_rayone/main/docs/raybot_edit.png -------------------------------------------------------------------------------- /docs/raybot_start.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/bot_na_rayone/main/docs/raybot_start.jpg -------------------------------------------------------------------------------- /docs/review_mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/bot_na_rayone/main/docs/review_mode.png -------------------------------------------------------------------------------- /raybot/model/__init__.py: -------------------------------------------------------------------------------- 1 | from .entities import Location, UserInfo, POI 2 | from . import db 3 | -------------------------------------------------------------------------------- /raybot/util/PTC75F.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/bot_na_rayone/main/raybot/util/PTC75F.ttf -------------------------------------------------------------------------------- /docs/mayak_bot_visit.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/bot_na_rayone/main/docs/mayak_bot_visit.jpg -------------------------------------------------------------------------------- /docs/6-osm.md: -------------------------------------------------------------------------------- 1 | # Загрузка и выгрузка в OpenStreetMap 2 | 3 | ## Заполняем базу 4 | 5 | ## Экспортируем в OSM 6 | -------------------------------------------------------------------------------- /raybot/util/marker-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/bot_na_rayone/main/raybot/util/marker-icon.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | map/ 2 | backup/ 3 | photo*/ 4 | /config/ 5 | scripts/ 6 | venv/ 7 | __pycache__/ 8 | raybot.egg-info/ 9 | *.swp 10 | *.sqlite 11 | *.log 12 | *.geojson 13 | *.csv 14 | Pipfile* 15 | -------------------------------------------------------------------------------- /raybot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from . import edit 2 | from . import addr 3 | from . import poi 4 | from . import moderate 5 | from . import review 6 | from . import messages 7 | from . import default 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiogram ~= 2.25.2 2 | aiosqlite 3 | pyyaml 4 | pillow 5 | astral==1.10.1 6 | lark-parser 7 | babel 8 | osm-humanized-opening-hours @ git+https://github.com/Zverik/humanized_opening_hours.git@master 9 | -------------------------------------------------------------------------------- /raybot/bot.py: -------------------------------------------------------------------------------- 1 | from raybot import config 2 | from raybot.util import log 3 | from aiogram import Bot, Dispatcher 4 | from aiogram.contrib.fsm_storage.memory import MemoryStorage 5 | 6 | 7 | bot = Bot(token=config.TELEGRAM_TOKEN) 8 | storage = MemoryStorage() 9 | dp = Dispatcher(bot, storage=storage) 10 | dp.middleware.setup(log.LoggingMiddleware()) 11 | -------------------------------------------------------------------------------- /raybot/config/addr.sample.yml: -------------------------------------------------------------------------------- 1 | --- 2 | streets: 3 | - name: ул. Петра Мстиславца 4 | keywords: [м, мст, мстис, мстисл, мстислав, мстиславц, мстиславца, петр, петра] 5 | buildings: 6 | '1': mst1 7 | '2': mst2 8 | - name: ул. Кирилла Туровского 9 | keywords: [т, кир, кирил, кирила, кирилла, тур, туров, туровс, туровского, туровский] 10 | buildings: 11 | '2': tur2 12 | apartments: 13 | mst1-1: 1 14 | mst1-2: 46 15 | mst2-1: 1 16 | mst2-2: 41 17 | tur2: [1, 4, 8, 12, 16, 20, 24, 28, 32, 36] 18 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Ilya Zverev 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /raybot/config/config.sample.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Provided by @BotFather 3 | telegram_token: '1234567890:AAFEsdkjfhweiufheiufheirhufiuhfsdfs' 4 | 5 | # Integer user id: run bot once and check access.log for yours 6 | admin_id: 2345678912 7 | 8 | # Path to logs, must be writable by the bot 9 | logs: /var/log/raybot 10 | 11 | # Bounding box for an area where one can add a place 12 | # Use https://boundingbox.klokantech.com/ with "CSV" format 13 | bbox: [27.639915, 53.925492, 27.659763, 53.935321] 14 | 15 | # Set to true to make the POI database read-only 16 | maintenance: false 17 | 18 | # Which strings to use. Alternatively use strings.yml and tags.yml 19 | language: ru 20 | 21 | # Paths to writable files and directories, absolute or relative to the config file 22 | database: raybot.sqlite 23 | photos: photo 24 | tiles: tiles 25 | -------------------------------------------------------------------------------- /raybot/util/log.py: -------------------------------------------------------------------------------- 1 | from raybot import config 2 | import os 3 | from datetime import datetime 4 | from aiogram import types 5 | from aiogram.dispatcher.middlewares import LifetimeControllerMiddleware 6 | 7 | 8 | class LoggingMiddleware(LifetimeControllerMiddleware): 9 | async def pre_process(self, obj, data, *args): 10 | if isinstance(obj, types.Message): 11 | if obj.text and obj.text[0] == '/': 12 | typ = 'command' 13 | else: 14 | typ = 'message' 15 | user_id = obj.from_user.id 16 | elif isinstance(obj, types.CallbackQuery): 17 | typ = 'callback' 18 | user_id = obj.from_user.id 19 | else: 20 | # not logging updates 21 | return 22 | with open(os.path.join(config.LOGS, 'access.log'), 'a') as f: 23 | now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') 24 | f.write(f'{now}\t{user_id}\t{typ}\n') 25 | -------------------------------------------------------------------------------- /raybot/cli/test_map.py: -------------------------------------------------------------------------------- 1 | from raybot.util import get_map 2 | from raybot.model import Location 3 | from raybot import config 4 | import sys 5 | import sqlite3 6 | import logging 7 | 8 | 9 | def run(): 10 | if len(sys.argv) < 3: 11 | print('Usage: {} test_map []'.format(sys.argv[0])) 12 | sys.exit(1) 13 | 14 | locations = [] 15 | with sqlite3.connect(config.DATABASE) as conn: 16 | for str_id in sys.argv[2].split(','): 17 | cursor = conn.execute("select lon, lat from poi where str_id = ?", (str_id,)) 18 | row = cursor.fetchone() 19 | if not row: 20 | print(f'Cannot find {str_id} in the database.') 21 | sys.exit(2) 22 | locations.append(Location(lon=row[0], lat=row[1])) 23 | 24 | logging.basicConfig(level=logging.INFO) 25 | fp = get_map(locations) 26 | filename = 'test_map.jpg' if len(sys.argv) < 4 else sys.argv[3] 27 | with open(filename, 'wb') as f: 28 | data = fp.read() 29 | f.write(data) 30 | fp.close() 31 | -------------------------------------------------------------------------------- /raybot/cli/buildings.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from raybot import config 4 | from raybot.model import db 5 | 6 | 7 | def validate_apartments(): 8 | for k, v in config.ADDR['apartments'].items(): 9 | if isinstance(v, list): 10 | for i in range(len(v) - 1): 11 | if i > 0 and not (2 <= v[i + 1] - v[i] + 1 <= 10): 12 | print(f'Weird apmt sequence in {k}: {v[i]}, {v[i+1]}.') 13 | print(f'Entrance {k}: last floor {len(v)} apmt {v[-1]}') 14 | 15 | 16 | async def aiorun(): 17 | ids = set(config.ADDR['apartments'].keys()) 18 | for street in config.ADDR['streets']: 19 | ids.update(street['buildings'].values()) 20 | 21 | conn = await db.get_db() 22 | cursor = await conn.execute( 23 | "select str_id, photo_out from poi where str_id is not null and photo_out is not null") 24 | async for row in cursor: 25 | if not os.path.exists(os.path.join(config.PHOTOS, row[1] + '.jpg')): 26 | print(f'Missing photo: {row[1]}.jpg') 27 | ids.discard(row[0]) 28 | await db.close() 29 | for str_id in sorted(ids): 30 | print(f'No photo listed: {str_id}') 31 | 32 | 33 | def run(): 34 | validate_apartments() 35 | asyncio.run(aiorun()) 36 | -------------------------------------------------------------------------------- /raybot/__main__.py: -------------------------------------------------------------------------------- 1 | from raybot.model import db 2 | from raybot.bot import dp 3 | from raybot.cli import buildings, photos, test_map, missing 4 | import raybot.handlers # noqa 5 | import logging 6 | import sys 7 | import os 8 | from aiogram import executor 9 | 10 | 11 | async def shutdown(dp): 12 | await db.close() 13 | 14 | 15 | def main(): 16 | if len(sys.argv) < 2 or os.path.isdir(sys.argv[1]): 17 | logging.basicConfig(level=logging.INFO) 18 | executor.start_polling(dp, skip_updates=True, on_shutdown=shutdown) 19 | else: 20 | cmd = sys.argv[1].lower() 21 | if cmd == 'buildings': 22 | buildings.run() 23 | elif cmd == 'photos': 24 | photos.run() 25 | elif cmd == 'map': 26 | test_map.run() 27 | elif cmd == 'missing': 28 | missing.run() 29 | else: 30 | print('Supported commands:') 31 | print() 32 | print('buildings — print missing building photos and entrance info') 33 | print('photos — print missing and stray photos') 34 | print('missing — print pois with missing important keys') 35 | print('map — generate a map image') 36 | 37 | 38 | if __name__ == '__main__': 39 | main() 40 | -------------------------------------------------------------------------------- /raybot/actions/messages.py: -------------------------------------------------------------------------------- 1 | from raybot import config 2 | from raybot.bot import bot 3 | from raybot.util import get_user, tr 4 | from raybot.model import db 5 | from asyncio import sleep 6 | from aiogram import types 7 | 8 | 9 | async def broadcast(message: types.Message): 10 | mods = [config.ADMIN] + (await db.get_role_users('moderator')) 11 | for user_id in mods: 12 | await bot.send_message(user_id, tr('do_reply')) 13 | await message.forward(user_id) 14 | await sleep(0.5) 15 | 16 | 17 | async def broadcast_str(message: str, except_id: int = None): 18 | mods = [config.ADMIN] + (await db.get_role_users('moderator')) 19 | for user_id in mods: 20 | if user_id != except_id: 21 | await bot.send_message(user_id, message) 22 | await sleep(0.5) 23 | 24 | 25 | async def process_reply(message: types.Message): 26 | info = await get_user(message.from_user) 27 | to = await get_user(message.reply_to_message.forward_from) 28 | if info.is_moderator(): 29 | # Notify other moderators that it has been replied 30 | # TODO: can we do it just once per user? 31 | await broadcast_str(tr('reply_sent', to.name), 32 | info.id, disable_notification=True) 33 | await bot.send_message(to.id, tr('do_reply')) 34 | await message.forward(to.id) 35 | -------------------------------------------------------------------------------- /raybot/cli/photos.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from raybot import config 4 | from raybot.model import db 5 | 6 | 7 | async def aiorun(): 8 | photos = set() 9 | for name in os.listdir(config.PHOTOS): 10 | if name.endswith('.jpg'): 11 | photos.add(name.rsplit('.', 1)[0]) 12 | for predef in config.RESP['responses']: 13 | if 'photo' in predef: 14 | if not os.path.exists(os.path.join(config.PHOTOS, predef['photo'])): 15 | print(f'Missing photo for predef resp "{predef["name"]}": {predef["photo"]}') 16 | photos.discard(predef['photo'].rsplit('.', 1)[0]) 17 | 18 | conn = await db.get_db() 19 | cursor = await conn.execute( 20 | "select name, photo_out, photo_in from poi where photo_out is not null " 21 | "or photo_in is not null") 22 | async for row in cursor: 23 | for c in (1, 2): 24 | if row[c]: 25 | if not os.path.exists(os.path.join(config.PHOTOS, row[c] + '.jpg')): 26 | print(f'Missing photo for "{row[0]}": {row[c]}.jpg') 27 | photos.discard(row[c]) 28 | for name in sorted(photos): 29 | print(f'Photo not used: {name}') 30 | 31 | cursor = await conn.execute( 32 | "select poi.name, h.name from poi left join poi h on h.str_id = poi.house " 33 | "where poi.photo_out is null") 34 | async for row in cursor: 35 | print(f'Outside photo is missing: {row[0]} ({row[1]})') 36 | await db.close() 37 | 38 | 39 | def run(): 40 | asyncio.run(aiorun()) 41 | -------------------------------------------------------------------------------- /raybot/handlers/addr.py: -------------------------------------------------------------------------------- 1 | from raybot import config 2 | from raybot.bot import dp 3 | from raybot.util import split_tokens 4 | from raybot.actions.addr import HOUSE_CB, handle_building, print_apartment, AddrState 5 | from aiogram import types 6 | from aiogram.dispatcher import FSMContext 7 | from aiogram.dispatcher.handler import SkipHandler 8 | from typing import Dict 9 | 10 | 11 | @dp.message_handler(state=AddrState.street) 12 | async def process_building(message: types.Message, state: FSMContext): 13 | tokens = split_tokens(message.text, False) 14 | if not tokens: 15 | return 16 | street_name = (await state.get_data())['street'] 17 | streets = [s for s in config.ADDR['streets'] if s['name'] == street_name] 18 | if streets: 19 | street = streets[0] 20 | hid = street['buildings'].get(tokens[0]) 21 | if hid: 22 | await handle_building(message.from_user, street, tokens, state) 23 | return 24 | # If we fail, process it as without context 25 | raise SkipHandler 26 | 27 | 28 | @dp.message_handler(state=AddrState.house) 29 | async def process_house(message: types.Message, state: FSMContext): 30 | try: 31 | apartment = int(message.text.strip()) 32 | except ValueError: 33 | raise SkipHandler 34 | await print_apartment(message.from_user, (await state.get_data())['house'], apartment) 35 | 36 | 37 | @dp.callback_query_handler(HOUSE_CB.filter(), state='*') 38 | async def callback_house(query: types.CallbackQuery, callback_data: Dict[str, str], 39 | state: FSMContext): 40 | hid = callback_data['id'] 41 | await handle_building(query.from_user, None, [query.data], state, hid=hid) 42 | -------------------------------------------------------------------------------- /raybot/cli/missing.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from raybot.model import db 3 | 4 | 5 | async def aiorun(): 6 | conn = await db.get_db() 7 | cursor = await conn.execute( 8 | "select poi.name as name, h.name as house_name, " 9 | "poi.house, poi.tag, poi.hours, poi.keywords, poi.links " 10 | "from poi left join poi h on h.str_id = poi.house " 11 | "where poi.tag is null or poi.tag not in ('building', 'entrance')") 12 | keys = ['house', 'keywords', 'links', 'tag', 'hours'] 13 | missing = {k: [] for k in keys} 14 | async for row in cursor: 15 | v = row['name'] if not row['house_name'] else f"{row['name']} ({row['house_name']})" 16 | for k in keys: 17 | if not row[k]: 18 | missing[k].append(v) 19 | 20 | # Special processing for missing floors. 21 | missing['floor'] = [] 22 | cursor = await conn.execute( 23 | "select poi.name as name, h.name as house_name " 24 | "from poi left join poi h on h.str_id = poi.house " 25 | "where poi.house is not null and poi.flor is null " 26 | "and (poi.tag is null or poi.tag not in ('building', 'entrance')) " 27 | "and poi.house in (select distinct house from poi " 28 | "where house is not null and flor is not null)") 29 | async for row in cursor: 30 | v = f"{row['name']} ({row['house_name']})" 31 | missing['floor'].append(v) 32 | 33 | # Close the connection and print the results. 34 | await db.close() 35 | for k in missing: 36 | print(f'Value for {k} is missing in {len(missing[k])} places.') 37 | if len(missing[k]) <= 30: 38 | for name in missing[k]: 39 | print(f'- {name}') 40 | 41 | 42 | def run(): 43 | asyncio.run(aiorun()) 44 | -------------------------------------------------------------------------------- /raybot/handlers/messages.py: -------------------------------------------------------------------------------- 1 | from raybot.bot import bot, dp 2 | from raybot.util import get_user, tr 3 | from raybot.actions.messages import broadcast, process_reply 4 | from aiogram import types 5 | from aiogram.dispatcher import FSMContext 6 | from aiogram.dispatcher.filters.state import State, StatesGroup 7 | from aiogram.dispatcher.handler import SkipHandler 8 | 9 | 10 | class MsgState(StatesGroup): 11 | sending = State() 12 | 13 | 14 | @dp.message_handler(commands='msg', state='*') 15 | async def message_info(message: types.Message): 16 | info = await get_user(message.from_user) 17 | if info.is_moderator(): 18 | await message.answer(tr('message_self')) 19 | return 20 | await message.answer(tr('message')) 21 | await MsgState.sending.set() 22 | 23 | 24 | @dp.callback_query_handler(text='missing_mod', state='*') 25 | async def message_info_callback(query: types.CallbackQuery): 26 | info = await get_user(query.from_user) 27 | if info.is_moderator(): 28 | await query.answer(tr('message_self')) 29 | return 30 | await bot.send_message(query.from_user.id, tr('message')) 31 | await MsgState.sending.set() 32 | 33 | 34 | @dp.message_handler(state=MsgState.sending) 35 | async def send_message(message: types.Message, state: FSMContext): 36 | await broadcast(message) 37 | await state.finish() 38 | 39 | 40 | @dp.message_handler(content_types=[ 41 | types.ContentType.STICKER, types.ContentType.PHOTO, 42 | types.ContentType.VIDEO, types.ContentType.VIDEO_NOTE, 43 | types.ContentType.VOICE, types.ContentType.LOCATION, 44 | types.ContentType.CONTACT, types.ContentType.TEXT 45 | ], state='*') 46 | async def process_reply_type(message: types.Message, state: FSMContext): 47 | if message.reply_to_message and message.reply_to_message.is_forward(): 48 | await process_reply(message) 49 | else: 50 | raise SkipHandler 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Бот на районе 2 | 3 | Телеграм-бот для поиска адресов и заведений в вашем районе города или 4 | в небольшом городке. Требует недели прогулок по району для создания 5 | базы данных, но потом удивительно красив и удобен для каждого жителя. 6 | Посмотрите на [Навигатор по Маяку Минска](https://t.me/mayak_nav_bot), 7 | чтобы понять, что получите. 8 | 9 | ![Как выглядит бот](docs/raybot_start.jpg) 10 | 11 | ## Документация 12 | 13 | Инструкция по установке — лишь один из шагов. Для создания полноценного 14 | бота вам нужно пройти все пять учебников: 15 | 16 | 1. [Обойти дома и подъезды](docs/1-addresses.md) 17 | 2. [Установить и настроить бота](docs/2-install.md) 18 | 3. [Собрать все заведения](docs/3-poi.md) 19 | 4. [Научиться администрированию](docs/4-usage.md) 20 | 5. [Актуализировать базу](docs/5-updates.md) 21 | 22 | ## Автор и лицензия 23 | 24 | Бота написал Илья Зверев. Опубликовано под лицензией ISC: делайте 25 | с кодом и файлами в `config/` что хотите. Документация (этот текст и всё 26 | в `docs/`) опубликованы под CC-BY 4.0. 27 | 28 | Устанавливая этого бота, вы соглашаетесь на выгрузку заведений из него 29 | в открытый проект OpenStreetMap. 30 | 31 | ## In English 32 | 33 | This is a Telegram bot for searching for addresses and amenities 34 | in your city block or in a smaller town. Filling in the database requires 35 | a week of walking around, but then you'll have a pretty and useful tool 36 | for every neighbour. Take a look at the 37 | [Mayak Minska Navigator](https://t.me/mayak_nav_bot) to see what you can get. 38 | 39 | While the code and comments are in English, all the strings and documetation 40 | are not (yet) translated. The author doesn't plan to add i18n in the nearest 41 | future, but if they get help, they might prepare a different-language 42 | version. 43 | 44 | Written by Ilya Zverev. Code is published under ISC, documentation is CC-BY 4.0. 45 | 46 | Installing this bot, you agree to uploading your shops and amenities into 47 | OpenStreetMap. 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | from os import path 3 | 4 | here = path.abspath(path.dirname(__file__)) 5 | exec(open(path.join(here, 'raybot', 'version.py')).read()) 6 | 7 | hoh_path = 'git+git://github.com/Zverik/humanized_opening_hours.git#egg=osm-humanized-opening-hours' 8 | setup( 9 | name='raybot', 10 | version=__version__, # noqa 11 | author='Ilya Zverev', 12 | author_email='ilya@zverev.info', 13 | packages=['raybot'], 14 | package_data={'raybot': ['raybot.config', 'raybot.util', 'raybot.model']}, 15 | python_requires='~=3.8', 16 | install_requires=[ 17 | 'aiogram', 18 | 'aiosqlite', 19 | 'pyyaml', 20 | 'pillow', 21 | 'astral==1.10.1', 22 | 'lark-parser', 23 | 'babel', 24 | 'osm-humanized-opening-hours @ ' + hoh_path, 25 | ], 26 | url='https://github.com/Zverik/bot_na_rayone', 27 | license='ISC License', 28 | description='Telegram bot for searching for addresses and amenities in your city block', 29 | long_description=open(path.join(here, 'README.md')).read(), 30 | long_description_content_type='text/markdown', 31 | classifiers=[ 32 | 'Development Status :: 4 - Beta', 33 | 'Environment :: Console', 34 | 'Framework :: AsyncIO', 35 | 'Intended Audience :: Information Technology', 36 | 'Intended Audience :: Customer Service', 37 | 'Topic :: Software Development :: Libraries :: Python Modules', 38 | 'Natural Language :: Russian', 39 | 'Operating System :: OS Independent', 40 | 'Topic :: Communications :: Chat', 41 | 'License :: OSI Approved :: ISC License (ISCL)', 42 | 'Programming Language :: Python :: 3 :: Only', 43 | 'Programming Language :: Python :: 3.8', 44 | 'Programming Language :: Python :: 3.9', 45 | 'Programming Language :: Python :: 3.10', 46 | ], 47 | entry_points={ 48 | 'console_scripts': ['raybot = raybot.__main__:main'] 49 | }, 50 | ) 51 | -------------------------------------------------------------------------------- /raybot/config/responses.sample.yml: -------------------------------------------------------------------------------- 1 | --- 2 | responses: 3 | - name: Карта Маяка Минска 4 | photo: map_mayak.jpg 5 | keywords: ['карта', 'маяк*', map] 6 | 7 | buttons: 8 | - [Мстиславца, Туровского, Скорины 5] 9 | - [🗺️, 🍽️, 🛒, 💊, 💐, 🐈] 10 | - [☕, 🍺, 💅, ✂️, 💳, ✉️] 11 | 12 | synonims: 13 | еда: [🍽️] 14 | карта: [🗺️] 15 | аптека: [⚕️, 💊] 16 | банк: [💳] 17 | почта: [📯, ✉️] 18 | зоо: [🐈] 19 | цветы: [💐] 20 | пиво: [🍺] 21 | продукты: [🛒] 22 | парикмахерская: [✂️] 23 | кофейня: [☕] 24 | фонтан: [⛲] 25 | вино: [🍷] 26 | маникюр: [💅] 27 | '/random': [🎲] 28 | 29 | skip: [а, и, к, в, по, из, от, во, ко, ул, улица, где, как, что, чем, чём, для, пройти, найти, находится] 30 | 31 | start: > 32 | Привет! Это навигатор по Маяку Минска. Здесь есть все заведения и подъезды нашего района. Для поиска введите ключевое слово или слова. Например, «суши» или «аптека». 33 | 34 | Заведения можно поправить, в том числе добавить новые. Также, по слову «карта» вы получите карту района, а по адресу (например, «мст 4 144») — подъезд и этаж. Отмечайте заведения звёздочками, чтобы помочь другим. И нажмите «/», чтобы посмотреть список команд. 35 | 36 | Другой похожий бот — каталог Маяка: @mayak_minska_bot. 37 | 38 | help: | 39 | Это бот для поиска заведений и квартир Маяка Минска. Он умеет находить всё — только напишите ему одно-два ключевых слова. Например, «танцы». Для удобства внизу есть кнопки с улицами и некоторыми видами заведений. Немного статистики: 40 | 41 | — В базе {entrances} подъездов в {buildings} домах. 42 | — И {pois} магазинов, заведений и огранизаций. 43 | — Люди поставили {stars} звёзд заведениям. 44 | 45 | Здесь не получится покопаться в каталоге и найти интересненькое (за этим подпишитесь на каталог Маяка @mayak_minska_bot). Но попробуйте посмотреть на несколько случайных заведений из базы: /random. И нажимайте на кнопку «Похожее» в карточках заведений: это точнее, чем вводить слова. Например, от карточки языковой школы можно найти все остальные языковые школы. 46 | 47 | Чтобы списки показывали ближайшие заведения, пришлите свои координаты (скрепка → «Местоположение»). Все ваши данные бот забудет через 5-10 минут, а логи пишутся обезличенно, так что не волнуйтесь за приватность. 48 | 49 | Если в данных что-то не так (отсутствует заведение, опечатка в телефоне и т.п.) — нажмите /msg и напишите модераторам, они поправят. Или поправьте сами. Бота написал Илья Зверев @ilyazver. 50 | -------------------------------------------------------------------------------- /raybot/model/create_tables.sql: -------------------------------------------------------------------------------- 1 | create table poi ( 2 | id integer primary key, 3 | str_id text, 4 | name text not null, 5 | lon float not null, 6 | lat float not null, 7 | created timestamp not null default current_timestamp, 8 | updated timestamp not null default current_timestamp, 9 | needs_check boolean not null default 0, 10 | description text, 11 | keywords text, -- same as in poisearch table (see below) 12 | photo_out text, 13 | photo_in text, 14 | tag text, -- OSM key=value 15 | hours text, -- OSM format 16 | links text, -- json list of tuples: [['name': 'link'], ...] 17 | has_wifi boolean, -- this and next can be null 18 | accepts_cards boolean, 19 | phones text, -- semicolon-separated 20 | comment text, 21 | address text, 22 | in_index boolean not null default 1, 23 | house text, -- reference to a poi / str_id 24 | flor text, 25 | delete_reason text 26 | ); 27 | create unique index poi_str_id_idx on poi (str_id); 28 | 29 | create virtual table poisearch using fts3(name, keywords, tag, tokenize=unicode61); 30 | -- When modifying poi, also modify rows in poisearch, using the "docid" column. 31 | -- Typical search: select * from poi where rowid in (select rowid from poisearch where poisearch match 'tokens') 32 | 33 | create table queue ( 34 | id integer primary key, 35 | user_id integer not null, 36 | user_name text not null, 37 | ts timestamp not null default current_timestamp, 38 | poi_id integer not null, 39 | field text not null, 40 | old_value text, 41 | new_value text 42 | ); 43 | 44 | create table poi_audit ( 45 | id integer primary key, 46 | user_id integer not null, 47 | approved_by integer not null, 48 | ts timestamp not null default current_timestamp, 49 | poi_id integer not null, 50 | field text not null, 51 | old_value text, 52 | new_value text 53 | ); 54 | 55 | create table roles ( 56 | user_id integer not null, 57 | name text, 58 | role text not null, 59 | added_by text, 60 | added_on timestamp not null default current_timestamp 61 | ); 62 | create index roles_user_idx on roles (user_id); 63 | 64 | create table updates ( 65 | id integer primary key, 66 | role text not null, 67 | message text not null, 68 | user_id integer not null, 69 | user_name text not null, 70 | ts timestamp not null default current_timestamp 71 | ); 72 | create index updates_role_idx on updates (role); 73 | 74 | create table file_ids ( 75 | path text not null primary key, 76 | size integer not null, 77 | file_id text not null 78 | ); 79 | 80 | create table stars( 81 | poi_id integer not null, 82 | user_id integer not null, 83 | ts timestamp not null default current_timestamp 84 | ); 85 | create unique index stars_idx on stars (poi_id, user_id); 86 | create index stars_user_idx on stars (user_id); 87 | -------------------------------------------------------------------------------- /raybot/settings.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | import os 3 | import sys 4 | import logging 5 | 6 | 7 | class Config: 8 | def __init__(self, path: str = None): 9 | BASE_DIR = os.path.dirname(__file__) 10 | CONFIG_DIR = os.path.join(BASE_DIR, 'config') 11 | ALT_CONFIG_DIR = path or os.path.join(BASE_DIR, '..', 'config') 12 | 13 | # Configuration options 14 | CONFIG = self.merge_yamls('config.yml', ALT_CONFIG_DIR) 15 | self.TELEGRAM_TOKEN = CONFIG.get('telegram_token') 16 | self.ADMIN = CONFIG.get('admin_id') 17 | self.LOGS = self.rel_expand(CONFIG.get('logs', BASE_DIR), ALT_CONFIG_DIR) 18 | self.MAINTENANCE = CONFIG.get('maintenance', False) 19 | self.BBOX = CONFIG.get('bbox') 20 | self.PRUNE_TIMEOUT = int(CONFIG.get('prune_timeout', 10)) 21 | language = CONFIG.get('language', 'ru') 22 | 23 | # Common paths 24 | self.DATABASE = self.rel_expand( 25 | CONFIG.get('database', 'raybot.sqlite'), ALT_CONFIG_DIR) 26 | self.PHOTOS = self.rel_expand(CONFIG.get('photos', 'photo'), ALT_CONFIG_DIR) 27 | self.TILES = self.rel_expand(CONFIG.get('tiles', 'tiles'), ALT_CONFIG_DIR) 28 | logging.debug(f'Photos: {self.PHOTOS}, tiles: {self.TILES}') 29 | 30 | # Strings and lists 31 | self.MSG = self.merge_yamls(['strings.yml', f'strings.{language}.yml'], 32 | os.path.join(CONFIG_DIR, 'strings'), ALT_CONFIG_DIR) 33 | self.TAGS = self.merge_yamls(['tags.yml', f'tags.{language}.yml'], 34 | os.path.join(CONFIG_DIR, 'tags'), ALT_CONFIG_DIR) 35 | self.RESP = self.merge_yamls('responses.yml', ALT_CONFIG_DIR) 36 | self.ADDR = self.merge_yamls('addr.yml', ALT_CONFIG_DIR) 37 | 38 | @staticmethod 39 | def check_paths(names, *paths): 40 | for path in paths: 41 | if path: 42 | if isinstance(names, str): 43 | names = [names] 44 | for name in names: 45 | p = os.path.join(path, name) 46 | if os.path.exists(p): 47 | yield p 48 | 49 | @staticmethod 50 | def merge_dict(target, other): 51 | for k, v in other.items(): 52 | if isinstance(v, dict): 53 | node = target.setdefault(k, {}) 54 | Config.merge_dict(node, v) 55 | else: 56 | target[k] = v 57 | 58 | @staticmethod 59 | def merge_yamls(names, *paths): 60 | result = {} 61 | for p in Config.check_paths(names, *paths): 62 | logging.debug('Reading %s', p) 63 | with open(p, 'r') as f: 64 | Config.merge_dict(result, yaml.safe_load(f)) 65 | return result 66 | 67 | @staticmethod 68 | def rel_expand(path, base_path): 69 | if not path or not base_path or os.path.isabs(path): 70 | return path 71 | if not os.path.isdir(base_path): 72 | base_path = os.path.dirname(base_path) 73 | return os.path.join(base_path, path) 74 | 75 | 76 | if len(sys.argv) > 1 and os.path.isdir(sys.argv[1]): 77 | config = Config(sys.argv[1]) 78 | else: 79 | config = Config() 80 | -------------------------------------------------------------------------------- /docs/5-updates.md: -------------------------------------------------------------------------------- 1 | # Обновление данных 2 | 3 | Со временем ваша база заведений устареет. И это не через год, а через пару недель: 4 | новые магазины и заведений открываются постоянно, старые закрываются. Если базу 5 | не трогать целый год, то бот покроется плесенью: люди перестанут соотносить его 6 | вывод и то, что работает на самом деле. Поэтому базу нужно регулярно обновлять. 7 | 8 | Есть соблазн смалодушничать и оставить всё на самотёк: в боте же есть кнопки удаления, 9 | «добавить заведение» и «поправить». Что стоит человеку, который видит несоответствие, 10 | нажать одну из них? На практике, даже владельцы заведений предпочитают написать 11 | админу (в личку, а не через бота), чем разбираться с интерфейсом. Смиритесь: даже 12 | самый удобный интерфейс не заставить пользователей делать больше необходимого. 13 | 14 | ## Режим осмотра 15 | 16 | Прислав геопозицию или найдя дом, модераторы увидят одну дополнительную кнопку: 17 | «Осмотреть заведения». Она запускает режим осмотра, когда выводятся до 14 заведений 18 | одновременно, и их можно отмечать просмотренными. 19 | 20 | Схема такая: вы подходите к зданию и оцениваете, нужно ли в нём этажи обходить 21 | отдельно, или можно всё сразу. Ищете адрес этого здания (например, «ленина 5») 22 | и жмёте «осмотреть». Затем выбираете этаж или жмёте «все». 23 | 24 | Осматривать можно не больше 14 заведений за раз (чтобы кнопок было не больше 4 рядов). 25 | Если их больше, то пришлите боту геопозицию. Можно начать осмотр прямо после 26 | её отправки — но тогда не будет органичения по дому. Это удобно, когда 27 | осматриваете торговый центр, занимающий несколько домов, но обычно лучше начать 28 | с поиска адреса. 29 | 30 | ![Панель осмотра](review_mode.png) 31 | 32 | На панели осмотра перечислены заведения и есть по кнопке для каждого. Нажав 33 | на кнопку, вы подтверждаете, что заведение на месте. Повторное нажатие снимет 34 | галочку, если вы нажали случайно. Кнопка с крестиком, очевидно, завершает осмотр. 35 | А кнопка редактирования меняет режим: после её нажатия кнопки заведений открывают 36 | редактор для выбранного заведения. Осмотр можно продолжить после завершения 37 | или отмены редактирования. 38 | 39 | ### Описание заведения 40 | 41 | В списке заведений указано не только имя, но и куча других атрибутов. Так что 42 | отсюда же можно проверить, что данные правильные, и заполнить недостающие поля. 43 | Строка заведения делится на четыре группы: 44 | 45 | 1. Номер и название в кавычках. 46 | 2. Значки wi-fi и карточек / наличных, если указаны. 47 | 3. Слово «нет» и список значков для атрибутов, которые не указаны. 48 | 4. Часы работы или слова «нет часов», если не указаны. 49 | 50 | Со значками во второй группе понятно, а в третьей их многовато. Вот что они 51 | означают: 52 | 53 | - 📞: телефон; 54 | - 🌐: сайт; 55 | - 🚪: местоположение (помещение, вход); 56 | - 🔡: ключевые слова; 57 | - 🌄: фото снаружи; 58 | - 📸: фото изнутри. 59 | 60 | Напомним, что ни один из этих атрибутов — кроме фото снаружи, пожалуй, — не обязателен. 61 | Но если вы, например, видите номер телефона на вывеске, а значок показывает, 62 | что он не указан, то самое время его внести. 63 | 64 | ### Состав списка 65 | 66 | Что делать, когда заведения нет в списке? Велик шанс, что оно новое. Действуйте, 67 | как обычно: пришлите боту подходящие термины для поиска и убедитесь, что заведения 68 | точно нет, а не что там ошибка с этажом или зданием. Потом жмите «добавить» и добавляйте. 69 | Новое заведение не появится в списке, и это нормально. 70 | 71 | Когда заведение из списка не найти, это тоже может означать ошибку, а не что оно закрылось. 72 | Откройте его в редакторе и нажмите `/ephoto`, чтобы посмотреть его фоточки. Если 73 | есть номер помещения, то он тоже поможет найти заведение. И только убедившись, что 74 | оно на самом деле закрыто, жмите из редактора `/delete` и пишите «закрылось». 75 | 76 | Если в списке 14 заведений, то есть шанс, что какие-то не влезли в список. Осмотрев 77 | большую часть, пришлите боту свою геопозицию. Он перестроит список, упорядочив его 78 | по удалённости от вас и убрав осмотренные заведения. Таким образом вы получите 79 | следующую партию и продолжите осмотр. Повторяйте, пока не упрётесь в стену. 80 | 81 | **Обходите все заведения хотя бы раз в два месяца: без этого данные устареют, 82 | и ваш бот разочарует пользователей.** 83 | -------------------------------------------------------------------------------- /raybot/actions/addr.py: -------------------------------------------------------------------------------- 1 | from raybot import config 2 | from raybot.model import db 3 | from raybot.actions.poi import print_poi_by_key 4 | from raybot.bot import bot 5 | from raybot.util import has_keyword, tr 6 | from aiogram import types 7 | from aiogram.utils.callback_data import CallbackData 8 | from aiogram.dispatcher.filters.state import State, StatesGroup 9 | from aiogram.dispatcher import FSMContext 10 | from typing import List 11 | 12 | 13 | HOUSE_CB = CallbackData('house', 'id') 14 | 15 | 16 | class AddrState(StatesGroup): 17 | street = State() 18 | house = State() 19 | 20 | 21 | async def test_address(message: types.Message, tokens: List[str], state: FSMContext) -> bool: 22 | if 'streets' not in config.ADDR: 23 | return False 24 | for street in config.ADDR['streets']: 25 | if has_keyword(tokens[0], street['keywords']): 26 | if len(tokens) == 1: 27 | await AddrState.street.set() 28 | await state.set_data({'street': street['name']}) 29 | await print_street(message, street) 30 | else: 31 | await handle_building(message.from_user, street, tokens[1:], state) 32 | return True 33 | 34 | # Check buildings like "mst6" 35 | for house in street['buildings']: 36 | if has_keyword(tokens[0], street['keywords'], str(house)): 37 | await handle_building(message.from_user, street, [house] + tokens[1:], state) 38 | return True 39 | return False 40 | 41 | 42 | async def print_street(message, street): 43 | buildings = street['buildings'] 44 | if len(buildings) > 30: 45 | kbd = None 46 | else: 47 | kbd = types.InlineKeyboardMarkup(row_width=5 if 1 <= len(buildings) % 6 <= 2 else 6) 48 | for house, hid in buildings.items(): 49 | kbd.insert(types.InlineKeyboardButton(house, callback_data=HOUSE_CB.new(id=hid))) 50 | await message.answer(tr('on_street', street['name']), reply_markup=kbd) 51 | 52 | 53 | async def handle_building(user: types.User, street, tokens, state, hid=None): 54 | if not hid: 55 | hid = street['buildings'].get(tokens[0]) 56 | if hid: 57 | await AddrState.house.set() 58 | await state.set_data({'house': hid}) 59 | if 'apartments' not in config.ADDR: 60 | await print_poi_by_key(user, hid, buttons=False) 61 | elif len(tokens) > 1: 62 | await print_apartment(user, hid, tokens[1]) 63 | else: 64 | await print_poi_by_key(user, hid, buttons=False, comment=tr('send_apt')) 65 | else: 66 | await AddrState.street.set() 67 | await state.set_data({'street': street['name']}) 68 | comment = tr('no_building', house=tokens[-1], street=street['name']) 69 | await bot.send_message(user.id, comment) 70 | return hid 71 | 72 | 73 | async def print_apartment(user: types.User, building: str, apartment): 74 | if 'apartments' not in config.ADDR: 75 | await print_poi_by_key(user, building, buttons=False) 76 | return 77 | 78 | try: 79 | apartment = int(apartment) 80 | except ValueError: 81 | await bot.send_message(user.id, tr('apt_number', apartment)) 82 | await print_poi_by_key(user, building, buttons=False) 83 | return 84 | 85 | entrances = [building] + await db.get_entrances(building) 86 | floor = None 87 | entrance = None 88 | entrance_first = None 89 | for e in entrances: 90 | e_apts = config.ADDR['apartments'].get(e) 91 | if e_apts is None: 92 | continue 93 | elif isinstance(e_apts, list): 94 | if apartment >= e_apts[0]: 95 | if entrance is None or entrance_first < e_apts[0]: 96 | entrance = e 97 | entrance_first = e_apts[0] 98 | floor = len([a for a in e_apts if a <= apartment]) 99 | elif apartment >= e_apts: 100 | if entrance is None or entrance_first < e_apts: 101 | entrance = e 102 | entrance_first = e_apts 103 | floor = None 104 | 105 | if entrance is None: 106 | comment = None 107 | elif floor is None: 108 | comment = tr('apartment', apartment) 109 | else: 110 | comment = tr('floor', apt=apartment, floor=floor) 111 | await print_poi_by_key(user, entrance, comment, buttons=False) 112 | -------------------------------------------------------------------------------- /raybot/util/util.py: -------------------------------------------------------------------------------- 1 | from raybot import config 2 | from raybot.model import db, UserInfo, Location 3 | from aiogram import types 4 | from aiogram.dispatcher import FSMContext 5 | from aiogram.utils.exceptions import TelegramAPIError, MessageToDeleteNotFound 6 | from typing import List, Union, Dict, Sequence 7 | import re 8 | import time 9 | import base64 10 | import struct 11 | 12 | 13 | userdata = {} 14 | # Markdown requires too much escaping, so we're using HTML 15 | HTML = types.ParseMode.HTML 16 | SYNONIMS = {} 17 | DOW = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] 18 | 19 | 20 | def reverse_synonims(): 21 | result = {} 22 | for k, v in config.RESP['synonims'].items(): 23 | for s in v: 24 | result[s] = k 25 | # Add emoji from tags 26 | for k, v in config.TAGS['emoji'].items(): 27 | if k != 'default' and v not in result: 28 | kw = config.TAGS['tags'].get(k) 29 | if kw: 30 | result[v] = kw[0] 31 | return result 32 | 33 | 34 | def has_keyword(token, keywords, kwsuffix=''): 35 | for k in keywords: 36 | if token == k + kwsuffix: 37 | return True 38 | return False 39 | 40 | 41 | async def get_user(user: types.User): 42 | info = userdata.get(user.id) 43 | if not info: 44 | info = UserInfo(user) 45 | info.roles = await db.get_roles(user.id) 46 | userdata[user.id] = info 47 | info.last_access = time.time() 48 | return info 49 | 50 | 51 | async def save_location(message: types.Message): 52 | location = Location(message.location.longitude, message.location.latitude) 53 | info = await get_user(message.from_user) 54 | info.location = location 55 | 56 | 57 | def prune_users(except_id: int) -> List[int]: 58 | pruned = [] 59 | for user_id in list(userdata.keys()): 60 | if user_id != except_id: 61 | data = userdata.get(user_id) 62 | if data and time.time() - data.last_access > config.PRUNE_TIMEOUT * 60: 63 | pruned.append(user_id) 64 | del userdata[user_id] 65 | return pruned 66 | 67 | 68 | def forget_user(user_id: int): 69 | if user_id in userdata: 70 | del userdata[user_id] 71 | 72 | 73 | def split_tokens(message, process=True): 74 | global SYNONIMS 75 | if not SYNONIMS: 76 | SYNONIMS = reverse_synonims() 77 | 78 | skip_tokens = set(config.RESP['skip']) 79 | s = message.strip().lower().replace('ё', 'е') 80 | tokens = re.split(r'[\s,.+=!@#$%^&*()\'"«»<>/?`~|_-]+', s) 81 | if process: 82 | tokens = [SYNONIMS.get(t, t) for t in tokens 83 | if len(t) > 0 and t not in skip_tokens] 84 | else: 85 | tokens = [t for t in tokens if len(t) > 0] 86 | return tokens 87 | 88 | 89 | def h(s: str) -> str: 90 | if not s: 91 | return s 92 | return s.replace('&', '&').replace('<', '<').replace('>', '>') 93 | 94 | 95 | def get_buttons(rows=None): 96 | buttons = [] 97 | for row in rows or config.RESP['buttons']: 98 | buttons.append([types.KeyboardButton(text=btn) for btn in row]) 99 | kbd = types.ReplyKeyboardMarkup(buttons, resize_keyboard=True, one_time_keyboard=True) 100 | return kbd 101 | 102 | 103 | def pack_ids(ids: Sequence[int]) -> str: 104 | return base64.a85encode(struct.pack('h' * len(ids), *ids)).decode() 105 | 106 | 107 | def unpack_ids(s: str) -> List[int]: 108 | b = base64.a85decode(s.encode()) 109 | return list(struct.unpack('h' * (len(b) // 2), b)) 110 | 111 | 112 | def uncap(s: str) -> str: 113 | if not s: 114 | return s 115 | return s[0].lower() + s[1:] 116 | 117 | 118 | async def delete_msg(bot, source: Union[types.Message, types.CallbackQuery], 119 | message_id: Union[int, FSMContext] = None): 120 | user_id = source.from_user.id 121 | if isinstance(message_id, FSMContext): 122 | message_id = (await message_id.get_data()).get('reply') 123 | if isinstance(source, types.CallbackQuery): 124 | if isinstance(message_id, list): 125 | message_id.append(source.message.message_id) 126 | else: 127 | message_id = source.message.message_id 128 | 129 | if message_id: 130 | if not isinstance(message_id, list): 131 | message_id = [message_id] 132 | for msg_id in message_id: 133 | if msg_id: 134 | try: 135 | await bot.delete_message(user_id, msg_id) 136 | except MessageToDeleteNotFound: 137 | pass 138 | except TelegramAPIError: 139 | pass 140 | 141 | 142 | def _format(s: str, value, **kwargs) -> str: 143 | if not s or (value is None and not kwargs): 144 | return s 145 | s = s.replace('%s', str(value)) 146 | for k, v in kwargs.items(): 147 | s = s.replace('{' + k + '}', str(v)) 148 | return s 149 | 150 | 151 | def _format_num(d: Dict[str, str], n: int, **kwargs) -> str: 152 | if not n: 153 | k = '5' 154 | elif n % 100 == 1 and n != 11: 155 | k = '1' 156 | elif n % 100 in (2, 3, 4) and n not in (12, 13, 14): 157 | k = '2' 158 | else: 159 | k = '5' 160 | return format(d.get(k, d['5']), n, **kwargs) 161 | 162 | 163 | def _get_by_key(messages: dict, key: Union[str, Sequence[str]]): 164 | if isinstance(key, str): 165 | return messages[key] 166 | m = messages 167 | for k in key: 168 | m = m[k] 169 | return m 170 | 171 | 172 | def tr(key: Union[str, Sequence[str]], value=None, **kwargs): 173 | d = _get_by_key(config.MSG, key) 174 | if isinstance(d, str): 175 | return _format(d, value, **kwargs) 176 | if not isinstance(d, dict) or '5' not in d or not isinstance(value, int): 177 | return d 178 | return _format_num(d, value) 179 | -------------------------------------------------------------------------------- /raybot/util/map.py: -------------------------------------------------------------------------------- 1 | from PIL import Image, ImageDraw, ImageFont 2 | from typing import Sequence 3 | import math 4 | import os 5 | import tempfile 6 | import logging 7 | from raybot import config 8 | from raybot.model import Location 9 | 10 | 11 | zooms = None 12 | cached_tiles = {} 13 | 14 | 15 | def deg2num(lon_deg, lat_deg, zoom): 16 | lat_rad = math.radians(lat_deg) 17 | n = 2.0 ** zoom 18 | xtile = (lon_deg + 180.0) / 360.0 * n 19 | ytile = (1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n 20 | return xtile, ytile 21 | 22 | 23 | def get_zooms(): 24 | global zooms 25 | if zooms or not os.path.exists(config.TILES): 26 | return zooms 27 | zooms = sorted([int(z) for z in os.listdir(config.TILES) if z.isdecimal()]) 28 | return zooms 29 | 30 | 31 | def load_tile(zoom, x, y, tilesize=256): 32 | k = f'{zoom},{x},{y}' 33 | if k in cached_tiles: 34 | return cached_tiles[k] 35 | path = os.path.join(config.TILES, str(zoom), str(x), f'{y}.png') 36 | tile = None 37 | if os.path.exists(path): 38 | try: 39 | tile = Image.open(path) 40 | except IOError: 41 | pass 42 | found = tile is not None 43 | if not found: 44 | tile = Image.new("RGBA", (tilesize, tilesize), color='#ffeeee') 45 | cached_tiles[k] = (tile, found) 46 | return (tile, found) 47 | 48 | 49 | def merge_tiles(xmin, ymin, xmax, ymax, zoom, tilesize=256): 50 | xsize = xmax - xmin + 1 51 | ysize = ymax - ymin + 1 52 | if xsize * ysize > 20: 53 | logging.error(f'Too many tiles to join: {xsize} × {ysize} ' 54 | f'({xmin}-{xmax}, {ymin}-{ymax}, {zoom})') 55 | return None 56 | 57 | found_any = False 58 | image = Image.new("RGBA", (xsize * tilesize, ysize * tilesize)) 59 | for x in range(xmin, xmax + 1): 60 | for y in range(ymin, ymax + 1): 61 | tile, found = load_tile(zoom, x, y) 62 | image.paste(tile, ((x - xmin) * tilesize, (y - ymin) * tilesize)) 63 | if found: 64 | found_any = True 65 | return image if found_any else None 66 | 67 | 68 | def build_basemap(minlon, minlat, maxlon, maxlat, gutter=100, 69 | minsize=200, maxsize=700, maxzoom=None): 70 | """Finds tile numbers, checks that zoom is not too big. 71 | Returns an image and a function (lon, lat) -> (x, y).""" 72 | zooms = get_zooms() 73 | if not zooms: 74 | return None, None 75 | zoom = (maxzoom or zooms[-1]) + 1 76 | tilesize = 256 77 | while zoom > zooms[0]: 78 | zoom -= 1 79 | xmin, ymax = deg2num(minlon, minlat, zoom) 80 | xmax, ymin = deg2num(maxlon, maxlat, zoom) 81 | xsize = (xmax - xmin) * tilesize + gutter * 2 82 | ysize = (ymax - ymin) * tilesize + gutter * 2 83 | if xsize < maxsize and ysize < maxsize: 84 | break 85 | 86 | txmin = int(xmin - 1.0 * gutter / tilesize) 87 | tymin = int(ymin - 1.0 * gutter / tilesize) 88 | txmax = int(xmax + 1.0 * gutter / tilesize) 89 | tymax = int(ymax + 1.0 * gutter / tilesize) 90 | image = merge_tiles(txmin, tymin, txmax, tymax, zoom) 91 | if not image: 92 | return None, None 93 | 94 | cxmin = int((xmin - txmin) * tilesize) - gutter 95 | cymin = int((ymin - tymin) * tilesize) - gutter 96 | cxmax = int((xmax - txmin) * tilesize) + gutter 97 | cymax = int((ymax - tymin) * tilesize) + gutter 98 | image = image.crop((cxmin, cymin, cxmax, cymax)) 99 | 100 | def get_xy(lon, lat): 101 | tx, ty = deg2num(lon, lat, zoom) 102 | return ( 103 | round((tx - txmin) * tilesize - cxmin), 104 | round((ty - tymin) * tilesize - cymin) 105 | ) 106 | return image, get_xy 107 | 108 | 109 | def find_bounds(coords): 110 | minlon, minlat = 180.0, 180.0 111 | maxlon, maxlat = -180.0, -180.0 112 | for c in coords: 113 | if not c: 114 | continue 115 | if c.lon < minlon: 116 | minlon = c.lon 117 | if c.lon > maxlon: 118 | maxlon = c.lon 119 | if c.lat < minlat: 120 | minlat = c.lat 121 | if c.lat > maxlat: 122 | maxlat = c.lat 123 | return minlon, minlat, maxlon, maxlat 124 | 125 | 126 | def get_map(coords: Sequence[Location], ref: Location = None): 127 | if not coords: 128 | return None 129 | minlon, minlat, maxlon, maxlat = find_bounds(coords + [ref]) 130 | gutter = 200 if len(coords) > 1 else 300 131 | image, get_xy = build_basemap(minlon, minlat, maxlon, maxlat, gutter=gutter, maxzoom=17) 132 | if not image: 133 | return None 134 | 135 | draw = ImageDraw.Draw(image) 136 | draw.text((5, image.height - 15), '© OpenStreetMap', fill='#0f0f0f', anchor='ls') 137 | # Beware of segfault! https://github.com/python-pillow/Pillow/issues/3066 138 | font = ImageFont.truetype(os.path.join(os.path.dirname(__file__), 'PTC75F.ttf'), 20) 139 | if len(coords) == 1: 140 | x, y = get_xy(coords[0].lon, coords[0].lat) 141 | marker = Image.open(os.path.join(os.path.dirname(__file__), 'marker-icon.png')) 142 | image.alpha_composite(marker, (x - 12, y - 41)) 143 | else: 144 | for i, c in enumerate(coords): 145 | x, y = get_xy(c.lon, c.lat) 146 | draw.ellipse([(x - 12, y - 12), (x + 12, y + 12)], fill='#0f0f0f') 147 | draw.text((x + 1, y + 1), str(i + 1), font=font, fill='#f0f0f0', anchor='mm') 148 | 149 | if ref: 150 | x, y = get_xy(ref.lon, ref.lat) 151 | draw.ellipse([(x - 8, y - 8), (x + 8, y + 8)], outline='#F51342', fill='#ffffff') 152 | draw.ellipse([(x - 5, y - 5), (x + 5, y + 5)], fill='#F51342') 153 | 154 | fp = tempfile.NamedTemporaryFile(suffix='.jpg', prefix='raybot-map-') 155 | image.convert('RGB').save(fp, 'JPEG', quality=80) 156 | fp.seek(0) 157 | return fp 158 | -------------------------------------------------------------------------------- /raybot/actions/transfer.py: -------------------------------------------------------------------------------- 1 | import json 2 | import csv 3 | import datetime 4 | from raybot import config 5 | from raybot.model import db 6 | from io import StringIO 7 | 8 | 9 | async def import_geojson(f): 10 | def yesno_to_bool(v): 11 | if not v: 12 | return None 13 | return 1 if v[0] == 'y' else 0 14 | 15 | values = [] 16 | refs = {} 17 | ids = set() 18 | row = 1 19 | data = json.load(f) 20 | for f in data['features']: 21 | if f['geometry']['type'] != 'Point': 22 | continue 23 | g = f['geometry']['coordinates'] 24 | p = f['properties'] 25 | if '$rowid' in p: 26 | row = p['$rowid'] 27 | if 'id' in p: 28 | if p['id'] in ids: 29 | raise ValueError(f'Duplicate id: {p["id"]}') 30 | ids.add(p['id']) 31 | refs[p['id']] = row 32 | links = [l.strip().split() for l in p.get('links', '').split(';') if l.strip()] 33 | links = None if not links else json.dumps(links, ensure_ascii=False) 34 | now = datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') 35 | values.append([ 36 | row, 37 | p.get('id'), p['name'], g[0], g[1], p.get('desc'), 38 | p.get('keywords'), p.get('photo'), p.get('inside'), p.get('tag'), 39 | p.get('hours'), links, yesno_to_bool(p.get('wifi')), yesno_to_bool(p.get('cards')), 40 | p.get('phones'), p.get('comment'), p.get('address'), 0 if p.get('index') == 'no' else 1, 41 | p.get('$created', now), p.get('$updated', now), p.get('floor'), 42 | 1 if p.get('needs_check') == 'yes' else 0, p.get('house'), p.get('reason') 43 | ]) 44 | row += 1 45 | 46 | # Validate house references 47 | for v in values: 48 | if v[-2] and v[-2] not in refs: 49 | raise IndexError(f'POI "{v[2]}" references missing key {v[-2]}.') 50 | 51 | # Upload to the database 52 | conn = await db.get_db() 53 | await conn.execute("delete from poi") 54 | await conn.execute("delete from poisearch") 55 | await conn.executemany("""insert into poi ( 56 | rowid, 57 | str_id, name, lon, lat, description, 58 | keywords, photo_out, photo_in, tag, 59 | hours, links, has_wifi, accepts_cards, 60 | phones, comment, address, in_index, 61 | created, updated, flor, 62 | needs_check, house, delete_reason 63 | ) values ( 64 | ?, 65 | ?, ?, ?, ?, ?, 66 | ?, ?, ?, ?, 67 | ?, ?, ?, ?, 68 | ?, ?, ?, ?, 69 | ?, ?, ?, 70 | ?, ?, ? 71 | )""", values) 72 | await conn.commit() 73 | await db.reindex() 74 | 75 | 76 | async def export_geojson(f): 77 | def bool_to_yesno(b): 78 | if b is None: 79 | return None 80 | return 'yes' if b else 'no' 81 | 82 | conn = await db.get_db() 83 | features = [] 84 | cursor = await conn.execute( 85 | "select p1.*, h.str_id as house_id from poi p1 left join poi h on p1.house = h.rowid") 86 | async for row in cursor: 87 | props = { 88 | '$rowid': row['id'], 89 | 'id': row['str_id'], 90 | 'name': row['name'], 91 | 'desc': row['description'], 92 | 'keywords': row['keywords'], 93 | 'photo': row['photo_out'], 94 | 'inside': row['photo_in'], 95 | 'tag': row['tag'], 96 | 'hours': row['hours'], 97 | 'wifi': bool_to_yesno(row['has_wifi']), 98 | 'cards': bool_to_yesno(row['accepts_cards']), 99 | 'phones': row['phones'], 100 | 'comment': row['comment'], 101 | 'address': row['address'], 102 | 'index': 'no' if not row['in_index'] else None, 103 | '$created': row['created'], 104 | '$updated': row['updated'], 105 | 'needs_check': 'yes' if row['needs_check'] else None, 106 | 'house': row['house'], 107 | 'floor': row['flor'], 108 | 'reason': row['delete_reason'], 109 | } 110 | if row['links']: 111 | props['links'] = '; '.join([' '.join(l) for l in json.loads(row['links'])]) 112 | for k in list(props.keys()): 113 | if props[k] is None: 114 | del props[k] 115 | features.append({ 116 | 'type': 'Feature', 117 | 'geometry': { 118 | 'type': 'Point', 119 | 'coordinates': [row['lon'], row['lat']] 120 | }, 121 | 'properties': props 122 | }) 123 | data = {'type': 'FeatureCollection', 'features': features} 124 | json.dump(data, f, indent=1, ensure_ascii=False) 125 | 126 | 127 | async def export_tags(f): 128 | conn = await db.get_db() 129 | cur = await conn.execute( 130 | "select id, name, tag, '', description, comment, address " 131 | "from poi where tag is null or tag not in ('building', 'entrance')") 132 | w = csv.writer(f) 133 | w.writerow('id name tag type description comment address'.split()) 134 | async for row in cur: 135 | row = list(row) 136 | row[3] = config.TAGS['tags'].get(row[2], [''])[0] 137 | w.writerow(row) 138 | 139 | 140 | async def import_tags(f): 141 | """Returns a StrinIO file with YAML contents for new tags.""" 142 | conn = await db.get_db() 143 | cur = await conn.execute("select id, tag from poi") 144 | poi_tags = {row[0]: row[1] async for row in cur} 145 | new_tags = {} 146 | for row in csv.DictReader(f): 147 | if not row['id'].isdecimal(): 148 | continue 149 | poi_id = int(row['id']) 150 | tag = row['tag'].strip() 151 | if not tag: 152 | continue 153 | if poi_id not in poi_tags: 154 | continue 155 | if tag != poi_tags[poi_id]: 156 | await conn.execute("update poi set tag = ? where id = ?", (tag, poi_id)) 157 | if tag not in config.TAGS['tags']: 158 | if tag not in new_tags or not new_tags[tag]: 159 | new_tags[tag] = row['type'].strip() 160 | await conn.commit() 161 | 162 | if not new_tags: 163 | return None 164 | 165 | outfile = StringIO() 166 | print('tags:', file=outfile) 167 | for k, v in new_tags.items(): 168 | print(f' {k}: [{v}]', file=outfile) 169 | outfile.seek(0) 170 | return outfile 171 | 172 | 173 | def get_file_type(filename): 174 | with open(filename, 'r') as f: 175 | start = f.read(200) 176 | if not start: 177 | return 'empty' 178 | if start[0] == '{': 179 | return 'geojson' 180 | if start.replace(' ', '').startswith('id,name'): 181 | return 'tags' 182 | return 'unknown' 183 | -------------------------------------------------------------------------------- /raybot/model/entities.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import List, Tuple 3 | from raybot import config 4 | import humanized_opening_hours as hoh 5 | import json 6 | from time import time 7 | from datetime import datetime 8 | from math import radians, cos, sqrt 9 | 10 | 11 | @dataclass 12 | class Location: 13 | lon: float 14 | lat: float 15 | 16 | def distance(self, other) -> float: 17 | """Not exact!""" 18 | lat1 = radians(self.lat) 19 | lat2 = radians(other.lat) 20 | lon1 = radians(self.lon) 21 | lon2 = radians(other.lon) 22 | x = (lon2 - lon1) * cos((lat1 + lat2) / 2) 23 | y = lat2 - lat1 24 | return sqrt(x * x + y * y) * 6371e3 25 | 26 | 27 | @dataclass 28 | class POI: 29 | id: int 30 | name: str 31 | location: Location 32 | keywords: str 33 | key: str = None 34 | hours: hoh.OHParser = None 35 | hours_src: str = None 36 | photo_out: str = None 37 | photo_in: str = None 38 | links: List[Tuple[str, str]] = field(default_factory=list) 39 | description: str = None 40 | comment: str = None 41 | address_part: str = None 42 | has_wifi: bool = None 43 | accepts_cards: bool = None 44 | needs_check: bool = None 45 | phones: List[str] = field(default_factory=list) 46 | house: str = None 47 | house_name: str = None 48 | floor: str = None 49 | tag: str = None 50 | delete_reason: str = None 51 | 52 | def __init__(self, row=None, name=None, location=None, keywords=None): 53 | if row: 54 | self.id = row['id'] 55 | self.name = row['name'] 56 | self.key = row['str_id'] 57 | self.hours_src = row['hours'] 58 | self.hours = hoh.OHParser(row['hours']) if row['hours'] else None 59 | self.links = json.loads(row['links'] or '[]') 60 | self.photo_out = row['photo_out'] 61 | self.photo_in = row['photo_in'] 62 | self.location = Location(lon=row['lon'], lat=row['lat']) 63 | self.description = row['description'] 64 | self.comment = row['comment'] 65 | self.house = row['house'] 66 | self.house_name = row['h_address'] if 'h_address' in row.keys() else None 67 | self.address_part = row['address'] 68 | self.keywords = row['keywords'] 69 | if not row['phones']: 70 | self.phones = [] 71 | else: 72 | self.phones = [p.strip() for p in row['phones'].split(';')] 73 | self.has_wifi = None if row['has_wifi'] is None else row['has_wifi'] == 1 74 | self.accepts_cards = None if row['accepts_cards'] is None else row['accepts_cards'] == 1 75 | self.tag = row['tag'] 76 | self.floor = row['flor'] 77 | self.needs_check = row['needs_check'] == 1 78 | self.delete_reason = row['delete_reason'] 79 | else: 80 | self.id = None 81 | self.name = name 82 | self.location = location 83 | self.keywords = keywords 84 | self.phones = [] 85 | self.links = [] 86 | 87 | def get_db_fields(self, orig=None) -> dict: 88 | def bool_to_int(v): 89 | if v is None: 90 | return None 91 | return 1 if v else 0 92 | 93 | fields = { 94 | 'name': self.name, 95 | 'lon': self.location.lon, 96 | 'lat': self.location.lat, 97 | 'description': self.description, 98 | 'keywords': self.keywords, 99 | 'photo_out': self.photo_out, 100 | 'photo_in': self.photo_in, 101 | 'tag': self.tag, 102 | 'hours': self.hours_src, 103 | 'links': None if not self.links else json.dumps(self.links, ensure_ascii=False), 104 | 'has_wifi': bool_to_int(self.has_wifi), 105 | 'accepts_cards': bool_to_int(self.accepts_cards), 106 | 'phones': '; '.join(self.phones) or None, 107 | 'comment': self.comment, 108 | 'address': self.address_part, 109 | 'house': self.house, 110 | 'flor': self.floor, 111 | 'needs_check': 1 if self.needs_check else 0, 112 | } 113 | if orig: 114 | orig_fields = orig.get_db_fields() 115 | for k in list(fields.keys()): 116 | if fields[k] == orig_fields[k]: 117 | del fields[k] 118 | return fields 119 | 120 | 121 | @dataclass(eq=False) 122 | class UserInfo: 123 | id: int 124 | name: str 125 | _location: Location = None # user's location 126 | location_time: int = 0 # To forget location after 5 minutes 127 | last_access: int = 0 # Last access time 128 | roles: List[str] = field(default_factory=list) 129 | review: List[list] = None # For review: list of (poi_id, old_updated) 130 | review_ctx: Tuple[str, str] = None # Tuple of (house, floor) 131 | 132 | def __init__(self, user=None, user_id=None, user_name=None): 133 | if user: 134 | self.id = user.id 135 | self.name = ' '.join(s for s in [user.first_name, user.last_name] if s) 136 | elif user_id: 137 | self.id = user_id 138 | self.name = user_name 139 | else: 140 | raise ValueError('Either a user or an id and name are required.') 141 | self.roles = [] 142 | self.last_access = time() 143 | 144 | @property 145 | def location(self) -> Location: 146 | if time() - self.location_time > 60 * 5: 147 | self._location = None 148 | return self._location 149 | 150 | @location.setter 151 | def location(self, location: Location) -> None: 152 | self._location = location 153 | self.location_time = time() 154 | 155 | def is_moderator(self) -> bool: 156 | return self.id == config.ADMIN or 'moderator' in self.roles 157 | 158 | 159 | @dataclass 160 | class QueueMessage: 161 | id: int 162 | user_id: int 163 | approved_by: int 164 | user_name: str 165 | ts: datetime 166 | poi_id: int 167 | poi_name: str 168 | field: str 169 | old_value: str 170 | new_value: str 171 | 172 | def __init__(self, row): 173 | self.id = row['id'] 174 | self.user_id = row['user_id'] 175 | self.approved_by = row['approved_by'] if 'approved_by' in row.keys() else None 176 | self.user_name = row['user_name'] if 'user_name' in row.keys() else None 177 | if not row['ts'] or isinstance(row['ts'], datetime): 178 | self.ts = row['ts'] 179 | else: 180 | try: 181 | self.ts = datetime.strptime(row['ts'].split('.')[0], '%Y-%m-%d %H:%M:%S') 182 | except ValueError: 183 | # Setting it to string, since we have no choice 184 | self.ts = row['ts'] 185 | self.poi_id = row['poi_id'] 186 | self.poi_name = row['poi_name'] if 'poi_name' in row.keys() else None 187 | self.field = row['field'] 188 | self.old_value = row['old_value'] 189 | self.new_value = row['new_value'] 190 | -------------------------------------------------------------------------------- /raybot/handlers/default.py: -------------------------------------------------------------------------------- 1 | from raybot import config 2 | from raybot.model import db, Location 3 | from raybot.bot import dp 4 | from raybot.util import split_tokens, has_keyword, get_user, h, HTML, get_buttons, prune_users, tr 5 | from raybot.actions.addr import test_address 6 | from raybot.actions.poi import PoiState, print_poi, print_poi_list 7 | from raybot.actions.messages import process_reply 8 | import os 9 | import csv 10 | import logging 11 | from aiogram import types 12 | from aiogram.dispatcher import FSMContext 13 | 14 | 15 | @dp.message_handler(commands=['start'], state='*') 16 | async def welcome(message: types.Message, state: FSMContext): 17 | await state.finish() 18 | await message.answer(config.RESP['start'], reply_markup=get_buttons()) 19 | payload = message.get_args() 20 | if payload: 21 | try: 22 | poi = await db.get_poi_by_id(int(payload)) 23 | await PoiState.poi.set() 24 | await state.set_data({'poi': poi.id}) 25 | await print_poi(message.from_user, poi) 26 | except ValueError: 27 | tokens = split_tokens(payload) 28 | if tokens: 29 | await process_query(message, state, tokens) 30 | 31 | 32 | @dp.message_handler(commands=['help'], state='*') 33 | async def help(message: types.Message, state: FSMContext): 34 | await state.finish() 35 | msg = config.RESP['help'] 36 | stats = await db.get_stats() 37 | for k, v in stats.items(): 38 | msg = msg.replace('{' + k + '}', h(str(v))) 39 | await message.answer(msg, parse_mode=HTML, disable_web_page_preview=True, 40 | reply_markup=get_buttons()) 41 | 42 | 43 | def write_search_log(message, tokens, result): 44 | row = [message.date.strftime('%Y-%m-%d'), message.text.strip(), 45 | None if not tokens else ' '.join(tokens), result] 46 | try: 47 | with open(os.path.join(config.LOGS, 'search.log'), 'a') as f: 48 | w = csv.writer(f, delimiter='\t') 49 | w.writerow(row) 50 | except IOError: 51 | logging.warning('Failed to write log line: %s', row) 52 | 53 | 54 | @dp.message_handler(state='*') 55 | async def process(message: types.Message, state: FSMContext): 56 | if message.from_user.is_bot: 57 | return 58 | if message.reply_to_message and message.reply_to_message.is_forward(): 59 | await process_reply(message) 60 | return 61 | for user_id in prune_users(message.from_user.id): 62 | await state.storage.finish(user=user_id) 63 | # We used to send a message here, but "disable_notification" only 64 | # disables a buzz, not an unread notification. 65 | 66 | tokens = split_tokens(message.text) 67 | if not tokens: 68 | write_search_log(message, None, 'empty') 69 | return 70 | 71 | # Reset state 72 | await state.finish() 73 | 74 | # First check for pre-defined replies 75 | if await test_predefined(message, tokens): 76 | write_search_log(message, tokens, 'predefined') 77 | return 78 | 79 | # Now check for streets 80 | if await test_address(message, tokens, state): 81 | write_search_log(message, tokens, 'address') 82 | return 83 | 84 | # Finally check keywords 85 | await process_query(message, state, tokens) 86 | 87 | 88 | async def process_query(message, state, tokens): 89 | query = ' '.join(tokens) 90 | pois = await db.find_poi(query) 91 | if not pois and len(tokens) > 2: 92 | # Attempt a search with one less tokens 93 | for ti in range(len(tokens)): 94 | query = ' '.join(tokens[i] for i in range(len(tokens)) if i != ti) 95 | new_pois = await db.find_poi(query) 96 | if new_pois and (not pois or len(pois) > len(new_pois)): 97 | pois = new_pois 98 | if not pois and len(tokens) > 1: 99 | # Attemt a search with just one token 100 | for t in tokens: 101 | new_pois = await db.find_poi(t) 102 | if new_pois and (not pois or len(pois) > len(new_pois)): 103 | pois = new_pois 104 | 105 | if len(pois) == 1: 106 | write_search_log(message, tokens, f'poi {pois[0].id}') 107 | await PoiState.poi.set() 108 | await state.set_data({'poi': pois[0].id}) 109 | await print_poi(message.from_user, pois[0]) 110 | elif len(pois) > 1: 111 | write_search_log(message, tokens, f'{len(pois)} results') 112 | await PoiState.poi_list.set() 113 | await state.set_data({'query': query, 'poi': [p.id for p in pois]}) 114 | await print_poi_list(message.from_user, message.text, pois) 115 | else: 116 | write_search_log(message, tokens, 'not found') 117 | new_kbd = types.InlineKeyboardMarkup().add( 118 | types.InlineKeyboardButton('💬 ' + tr('notify_mods'), callback_data='missing_mod'), 119 | types.InlineKeyboardButton('➕ ' + tr('add_poi'), callback_data='new') 120 | ) 121 | user = await get_user(message.from_user) 122 | if user.review: 123 | new_kbd.insert(types.InlineKeyboardButton( 124 | '🗒️ ' + tr(('review', 'continue')), callback_data='continue_review')) 125 | await message.answer(tr('not_found', message.text), reply_markup=new_kbd) 126 | 127 | 128 | async def test_predefined(message, tokens) -> bool: 129 | all_tokens = ' '.join(tokens) 130 | query = message.text.lower().strip() 131 | for resp in config.RESP['responses']: 132 | kw = [k.lower() for k in resp['keywords']] 133 | if has_keyword(all_tokens, kw) or has_keyword(query, kw): 134 | if 'role' in resp: 135 | user = await get_user(message.from_user) 136 | if resp['role'] not in user.roles: 137 | continue 138 | content = resp.get('name', '') 139 | photo = None 140 | if 'photo' in resp: 141 | photo_path = os.path.join(config.PHOTOS, resp['photo']) 142 | if os.path.exists(photo_path): 143 | file_ids = await db.find_file_ids( 144 | {resp['photo']: os.path.getsize(photo_path)}) 145 | if file_ids: 146 | photo = file_ids[resp['photo']] 147 | else: 148 | photo = types.InputFile(photo_path) 149 | if 'message' in resp: 150 | if content: 151 | content += '\n\n' 152 | content += resp['message'] 153 | kbd = get_buttons(resp.get('buttons')) 154 | 155 | if photo: 156 | msg = await message.answer_photo( 157 | photo, caption=content, parse_mode=HTML, reply_markup=kbd) 158 | if not isinstance(photo, str): 159 | file_id = msg.photo[0].file_id 160 | await db.store_file_id(resp['photo'], os.path.getsize(photo_path), file_id) 161 | else: 162 | await message.answer(content, parse_mode=HTML, reply_markup=kbd) 163 | return True 164 | return False 165 | 166 | 167 | @dp.message_handler(content_types=types.ContentType.LOCATION, state='*') 168 | async def set_loc(message): 169 | location = Location(message.location.longitude, message.location.latitude) 170 | info = await get_user(message.from_user) 171 | info.location = location 172 | if info.is_moderator(): 173 | # Suggest review mode 174 | kbd = types.InlineKeyboardMarkup().add( 175 | types.InlineKeyboardButton( 176 | tr(('review', 'start')), callback_data='start_review') 177 | ) 178 | else: 179 | kbd = get_buttons() 180 | await message.answer(tr('location'), reply_markup=kbd) 181 | -------------------------------------------------------------------------------- /raybot/handlers/poi.py: -------------------------------------------------------------------------------- 1 | from raybot.actions.poi import ( 2 | PoiState, 3 | print_poi, print_poi_list, make_poi_keyboard, 4 | POI_LIST_CB, POI_FULL_CB, POI_LOCATION_CB, 5 | POI_HOUSE_CB, POI_SIMILAR_CB, POI_STAR_CB 6 | ) 7 | from raybot.model import db 8 | from raybot.bot import dp, bot 9 | from raybot.util import split_tokens, unpack_ids, save_location, tr 10 | from raybot import config 11 | from typing import Dict 12 | from aiogram import types 13 | from aiogram.dispatcher import FSMContext, filters 14 | from aiogram.utils.exceptions import MessageNotModified 15 | 16 | 17 | @dp.callback_query_handler(POI_FULL_CB.filter(), state='*') 18 | async def all_pois(query: types.CallbackQuery, callback_data: Dict[str, str], 19 | state: FSMContext): 20 | cur_state = None if not state else await state.get_state() 21 | if cur_state == PoiState.poi_list.state: 22 | data = await state.get_data() 23 | txt = data['query'] 24 | pois = await db.get_poi_by_ids(data['poi']) 25 | else: 26 | txt = callback_data['query'] 27 | ids = callback_data['ids'] 28 | if len(ids) < 2: 29 | tokens = split_tokens(txt) 30 | pois = await db.find_poi(' '.join(tokens)) 31 | else: 32 | pois = await db.get_poi_by_ids(unpack_ids(ids)) 33 | await print_poi_list(query.from_user, txt, pois, True) 34 | 35 | 36 | @dp.callback_query_handler(POI_LIST_CB.filter(), state='*') 37 | async def poi_from_list(query: types.CallbackQuery, callback_data: Dict[str, str], 38 | state: FSMContext): 39 | poi = await db.get_poi_by_id(int(callback_data['id'])) 40 | if not poi: 41 | await query.answer(tr('query_fail')) 42 | else: 43 | await PoiState.poi.set() 44 | await state.set_data({'poi': poi.id}) 45 | await print_poi(query.from_user, poi) 46 | 47 | 48 | @dp.callback_query_handler(POI_LOCATION_CB.filter(), state='*') 49 | async def poi_location(query: types.CallbackQuery, callback_data: Dict[str, str]): 50 | poi = await db.get_poi_by_id(int(callback_data['id'])) 51 | await bot.send_location(query.from_user.id, latitude=poi.location.lat, 52 | longitude=poi.location.lon) 53 | 54 | 55 | @dp.callback_query_handler(POI_STAR_CB.filter(), state='*') 56 | async def star_poi(query: types.CallbackQuery, callback_data: Dict[str, str]): 57 | user = query.from_user 58 | poi = await db.get_poi_by_id(int(callback_data['id'])) 59 | action = callback_data['action'] 60 | if action == 'set': 61 | await db.set_star(user.id, poi.id, True) 62 | elif action == 'del': 63 | await db.set_star(user.id, poi.id, False) 64 | kbd = await make_poi_keyboard(user, poi) 65 | try: 66 | await bot.edit_message_reply_markup( 67 | user.id, query.message.message_id, reply_markup=kbd) 68 | except MessageNotModified: 69 | pass 70 | 71 | 72 | @dp.message_handler(filters.RegexpCommandsFilter(regexp_commands=['poi([0-9]+)']), state='*') 73 | async def print_specific_poi(message: types.Message, regexp_command, state: FSMContext): 74 | poi = await db.get_poi_by_id(int(regexp_command.group(1))) 75 | if not poi: 76 | await message.answer(tr('no_poi_key')) 77 | else: 78 | await PoiState.poi.set() 79 | await state.set_data({'poi': poi.id}) 80 | await print_poi(message.from_user, poi) 81 | 82 | 83 | @dp.callback_query_handler(POI_HOUSE_CB.filter(), state='*') 84 | async def in_house_callback(query: types.CallbackQuery, callback_data: Dict[str, str], 85 | state: FSMContext): 86 | house = callback_data['house'] 87 | floor = callback_data['floor'] 88 | data = await db.get_poi_by_key(house) 89 | pois = await db.get_poi_by_house(house, None if floor == '-' else floor) 90 | if floor == '-' and len(pois) > 9: 91 | floors = await db.get_floors_by_house(house) 92 | if len(floors) >= 2 and None not in floors: 93 | # We have floors - add another selection 94 | kbd = types.InlineKeyboardMarkup(row_width=3) 95 | for ifloor in floors: 96 | kbd.insert(types.InlineKeyboardButton( 97 | ifloor, callback_data=POI_HOUSE_CB.new(house=house, floor=ifloor))) 98 | await bot.edit_message_reply_markup( 99 | query.from_user.id, query.message.message_id, reply_markup=kbd) 100 | return 101 | 102 | if not pois: 103 | await query.answer(tr('no_poi_in_house')) 104 | elif len(pois) == 1: 105 | await PoiState.poi.set() 106 | await state.set_data({'poi': pois[0].id}) 107 | await print_poi(query.from_user, pois[0]) 108 | else: 109 | await PoiState.poi_list.set() 110 | await state.set_data({'query': query, 'poi': [p.id for p in pois]}) 111 | await print_poi_list(query.from_user, data.name, pois, True) 112 | 113 | 114 | @dp.callback_query_handler(POI_SIMILAR_CB.filter(), state='*') 115 | async def simlar_poi(query: types.CallbackQuery, callback_data: Dict[str, str], 116 | state: FSMContext): 117 | poi = await db.get_poi_by_id(int(callback_data['id'])) 118 | if not poi or not poi.tag: 119 | await query.answer(tr('query_fail')) 120 | else: 121 | pois = await db.get_poi_by_tag(poi.tag) 122 | if len(pois) == 1: 123 | await query.answer(tr('no_similar')) 124 | else: 125 | tag_names = config.TAGS['tags'].get(poi.tag) 126 | pquery = poi.tag if not tag_names else tag_names[0] 127 | await PoiState.poi_list.set() 128 | await state.set_data({'query': pquery, 'poi': [p.id for p in pois]}) 129 | await print_poi_list(query.from_user, pquery, pois, relative_to=poi.location) 130 | 131 | 132 | @dp.message_handler(commands='last', state='*') 133 | async def print_last(message: types.Message, state: FSMContext): 134 | pois = await db.get_last_poi(6) 135 | await PoiState.poi_list.set() 136 | await state.set_data({'query': 'last', 'poi': [p.id for p in pois]}) 137 | await print_poi_list(message.from_user, 'last', pois, shuffle=False) 138 | 139 | 140 | @dp.message_handler(commands='random', state='*') 141 | async def print_random(message: types.Message, state: FSMContext): 142 | pois = await db.get_random_poi(6) 143 | await PoiState.poi_list.set() 144 | await state.set_data({'query': 'random', 'poi': [p.id for p in pois]}) 145 | await print_poi_list(message.from_user, 'random', pois, shuffle=False) 146 | 147 | 148 | @dp.message_handler(commands='my', state='*') 149 | async def print_starred(message: types.Message, state: FSMContext): 150 | pois = await db.get_starred_poi(message.from_user.id) 151 | if not pois: 152 | await message.answer(tr('no_starred')) 153 | return 154 | await PoiState.poi_list.set() 155 | await state.set_data({'query': 'my', 'poi': [p.id for p in pois]}) 156 | await print_poi_list(message.from_user, 'my', pois) 157 | 158 | 159 | @dp.message_handler(commands='popular', state='*') 160 | async def print_popular(message: types.Message, state: FSMContext): 161 | pois = await db.get_popular_poi(9) 162 | if not pois: 163 | await message.answer(tr('no_popular')) 164 | return 165 | await PoiState.poi_list.set() 166 | await state.set_data({'query': 'popular', 'poi': [p.id for p in pois]}) 167 | await print_poi_list(message.from_user, 'popular', pois) 168 | 169 | 170 | @dp.message_handler(content_types=types.ContentType.LOCATION, state=PoiState.poi_list) 171 | async def set_loc(message: types.Message, state: FSMContext): 172 | await save_location(message) 173 | data = await state.get_data() 174 | pois = await db.get_poi_by_ids(data['poi']) 175 | await print_poi_list(message.from_user, data['query'], pois) 176 | -------------------------------------------------------------------------------- /docs/4-usage.md: -------------------------------------------------------------------------------- 1 | # Использование и поддержка бота 2 | 3 | После прочтения прошлых трёх разделов документации у вас должен быть 4 | рабочий бот. Он отвечает как на запросы адресов, так и на ключевые слова, 5 | выдавая заведения с фотографиями и описаниями. Всё выглядит готовым к запуску. 6 | 7 | Но если присмотреться, не всё идеально. Какие-то фотографии отсутствуют, 8 | какие-то важные поля пустуют. Возможно, не все дома и подъезды связаны. 9 | И самое обескураживающее, что эти проблемы будут накапливаться. Давайте 10 | посмотрим на способы поддержки базы, а потом — как работают модераторы 11 | и администратор бота. 12 | 13 | ## Скрипты 14 | 15 | Если запустить `python -m raybot help` из виртуального окружения, вы 16 | получите список команд. Команду `buildings` 17 | мы уже использовали. Остальные предназначены для ухода за базой или 18 | для проверки функций. Например, команда `map` сделает фрагмент карты 19 | вокруг объектов с заполненным идентификатором — обычно домов и подъездов. 20 | 21 | Собственно, из оставшихся команд (`photos` рассмотрена ниже) не описана 22 | только `missing`. Она выводит количество и список заведений, у которых 23 | не проставлены — отдельно — поля house, floor, keywords, links, tag и hours. 24 | Исправить эти предупреждения можно и из самого бота, просто ища по названиям. 25 | Для полей house, floor, keywords и tag списки встроены прямо в бота (см. ниже). 26 | 27 | ### Лишние фотографии 28 | 29 | Фотографии хранятся отдельно от остальной базы, поэтому иногда содержимое 30 | каталога `photo` рассинхронизируется с записями в базе. Узнать о расхождениях 31 | помогает команда `python -m raybot photos`. Она выдаёт список проблем 32 | четырёх видов: 33 | 34 | * "Photo not used" — фотографию загрузили, но не прикрепили ни к одному 35 | заведению. Такое бывает при обновлении фотографий, или когда человек 36 | не нажал «удали» при редактировании заведения. Эти фотографии, скорее 37 | всего, можно удалить. 38 | * "Outside photo is missing" — у заведения нет фотографии снаружи. 39 | Идите и сделайте. Эта проверка встроена и в бот, см. ниже. 40 | * "Missing photo" — пропал файл с фотографией для заведения. Обычно такое 41 | бывает от опечаток в ручной расстановке атрибутов `photo` и `inside` 42 | в редакторе точек. Но иногда пропадают и фотографии, загруженные через 43 | бота. Я такое видел только раз и буду благодарен за помощь в поисках 44 | причины. 45 | * "Missing photo for predef resp" — один из ответов в файле 46 | `config/responses.yml` ссылается на файл фотографии, которого нет 47 | в каталоге `photo`. Поправьте имя файла или загрузите фото 48 | и перезапустите бота. 49 | 50 | ### Работа с базой 51 | 52 | Важно, что при работающем боте запись в базу не работает. Это не 53 | Postgres и не MySQL: все данные в одном файле, который можно открыть 54 | на запись только один раз. Поэтому если вам нужно поправить набор 55 | экспортированных заведений или просто сделать `update poi set ... where ...`, 56 | остановите бота. Нажмите Ctrl+C или введите `sudo systemctl stop nav_bot`. 57 | 58 | Когда операция над заведениями длительная, останавливать бота, разумеется, 59 | нежелательно. Правка заведений может затянуться на пару часов — едва 60 | ли все соседи согласятся подождать. Выход прост: в файле `config/config.yml` 61 | установите ключ `maintenance` в значение `true` и перезапустите бота. 62 | Всё будет как раньше, только создавать и редактировать заведения будет 63 | нельзя. 64 | 65 | Закончив правку, остановите бота, импортируйте новый geojson, верните 66 | значение `maintenance: false` и запустите бота обратно. 67 | 68 | ## Модерирование 69 | 70 | Поддерживать базу заведений в актуальном состоянии — титаническая и бесконечная 71 | работа, делать её в одиночку сложно и надоест. Поэтому у бота есть список 72 | модераторов. Ниже разберёмся, как его пополнять, а тут — про то, что можно 73 | делать модераторам и админу, чего нельзя обычным пользователям. 74 | 75 | Самое важное — очередь изменений. Когда пользователи правят заведения, 76 | эти правки не сразу падают в базу, а добавляются в очередь. Вы увидите 77 | её, введя команду `/queue`. Сообщения в неё бывают трёх видов: 78 | 79 | * Запрос на изменение одного поля — вам покажут старое и предложенное 80 | значения, выберите одно из двух. 81 | * Запрос на добавление заведения — оно уже в базе и в поиске, но помечено 82 | как не проверенное. Посмотрите на его поля, заполните пропуски, если есть, 83 | и нажмите «Проверено». 84 | * Сообщение в контексте заведения — обычно просьба что-то уточнить. 85 | Выполните просьбу, если можете, и удаляйте сообщение. 86 | 87 | Проверяйте очередь время от времени, потому что напоминания о ней ещё 88 | не сделаны. 89 | 90 | Кроме того, модераторы получают сообщения, которые пользователи шлют 91 | командой `/msg`. Получив такое, просто ответьте на него с цитированием, 92 | и отправитель получит ответ. Так можно общаться довольно долго, с фоточками 93 | и стикерами. Не злоупотребляйте, потому что все остальные модераторы получают 94 | уведомления, что вы отвечаете на сообщения. 95 | 96 | наконец, удаление заведений. Из бота их можно удалить формально: в базе 97 | они остаются, но у них непустое поле причины удаления. Модераторы 98 | могут восстановить удалённое командой `/undelete` (она будет в карточке 99 | редактора). И могут удалить запись об удалённом заведении навсегда, 100 | введя `/delete` для уже удалённого заведения. 101 | 102 | Список удалённых заведений выводится по команде `/deleted`, так же 103 | как список недавно добавленных — по `/last`. Эти команды доступны 104 | всем, не только модераторам. 105 | 106 | ### Панель администратора 107 | 108 | Модераторы и администратор получат ответ на команду `/admin`: там 109 | будут несколько кнопок для ухода за базой данных. 110 | 111 | * «Аудит» — список последних изменений базы заведений. 112 | * «Перестроить индекс» — обычно не нужно, но иногда индекс поиска 113 | рассинхронизируется с содержимым таблицы заведений. Выражается это 114 | в пропаже заведений из поиска, или в появлении удалённых заведений. 115 | Если столкнётесь с таким, сообщите в Issues этого репозитория. 116 | * «Нет адреса», «нет фото» и т.п. — выдают список заведений 117 | с пустыми полями, которые не должны быть пустыми. 118 | 119 | И пара кнопок, которые доступны только администратору: 120 | 121 | * «Модераторы» — после нажатия этой кнопки перешлите боту сообщение 122 | от человека, чтобы сделать её/его модератором. Она/он получит 123 | уведомление, так что сохранить это в секрете не получится. Отсюда 124 | же можно разжаловать из модераторов. 125 | * «Дедубл. фото» — иногда несколько заведений имеют один и тот же 126 | вход. Соответственно, одну и ту же фотографию входа. Телеграм 127 | понимает, когда вы прикрепляете одну и ту же фоточку, и не обрабатывает 128 | её повторно. А вот бот скачивает то же самое заново. Эта кнопка 129 | пройдётся по всем фоточкам на диске и удалит дубликаты. Заведения 130 | в базе после этого будут ссылаться на одну и ту же фотографию. 131 | * «Подчистить фото» — удалит все фотографии, на которые не ссылается 132 | ни одно заведение из базы. Если вы используете для заведений 133 | редактор точек, то подождите жать эту кнопку, пока все файлы из 134 | каталога `photo` не найдут применение. 135 | * «База заведений» — скачивание и загрузка базы заведений, описанные 136 | в прошлой главе. 137 | 138 | ### Логи 139 | 140 | Главным инструментом, доступным только администратору, остаются файлы 141 | конфигурации, файл базы данных и логи. Про первые вы читали во второй главе. 142 | А какие логи доступны? Их три, каждый является TSV-файлом, т.е. таблицей, 143 | в которой значения в строках разделены символом табуляции. 144 | 145 | * `access.log` — точное время и идентификатор пользователя, обратившегося 146 | к боту. Третьим значение записан вид запроса (сообщение, команда или callback), 147 | но от него немного пользы. 148 | * `poi.log` — дата, идентификатор и название POI (дома, подъезда, магазина, 149 | заведения, организации), которое показали пользователю. 150 | * `search.log` — дата, сообщение от пользователя, ключевые слова, на которые 151 | бот разбил это сообщение, и результат поиска. 152 | 153 | Обычный вопрос к логу посещений — сколько человек воспользовались ботом 154 | за день? Вы можете автоматизировать ответ любым обработчиком, но пока я 155 | ввожу в командную строку такую сложную команду: 156 | 157 | grep '^2021-01-17' /var/log/nav_bot/access.log | cut -f 2|sort|uniq|wc -l 158 | 159 | **Теперь вы всё знаете о своём районном боте. Не забудьте прорекламировать 160 | его как можно шире! Столкнётесь с проблемами — [пишите](https://github.com/Zverik/bot_na_rayone/issues).** 161 | 162 | Следующая часть пригодится через несколько недель: база заведений 163 | устареет — и их нужно будет [обходить заново](5-updates.md). 164 | -------------------------------------------------------------------------------- /docs/1-addresses.md: -------------------------------------------------------------------------------- 1 | # Сбор адресов 2 | 3 | Подготовка базы для районного бота начинается со зданий. Вам понадобятся 4 | телефон, фотоаппарат (опционально, достаточно камеры в телефоне) и сайт 5 | [редактора точек](https://zverik.github.io/point_ed/). Всё. Телеграм-бот 6 | в этом этапе не участвует. 7 | 8 | ## Ваш район 9 | 10 | Самый первый шаг: определиться с районом. Не слишком маленький, чтобы людям 11 | не был нужен бот, но и не слишком большой, чтобы не офигеть от сбора данных. 12 | Идеально между 20 и 40 многоквартирными домами и парой торговых центров между 13 | ними. Главное, чтобы все заведения в районе были в пешей доступности: 14 | найдя нужное заведение на другом его конце, человек не должен разочароваться. 15 | 16 | В «Маяке Минска», для которого создавался этот бот, 38 домов, 73 подъезда и 17 | порядка 450 заведений. Это много: запомнить невозможно, и на обход ушла неделя. 18 | 19 | Район должен быть явно отделён от окружающих, чтобы пользователи не задавались 20 | каждый раз вопросом, обращаться к боту или к другим картам. 21 | 22 | ## Разметить дома 23 | 24 | Дома нужны для адресации и просто чтобы ответить на вопрос, где этот дом. 25 | Не внеся дом в базу, нет смысла обходить заведения в нём. 26 | 27 | Открывайте редактор точек и ставьте маркер (двойным щелчком) в середине каждого 28 | дома на районе. Ставьте им такие атрибуты (это пример): 29 | 30 | ``` 31 | id tur10 32 | name К. Туровского, 10 33 | tag building 34 | photo tur10 35 | ``` 36 | 37 | Идентификатор из букв и номера дома, без пунктуации. Название — это адрес. С тегом 38 | понятно, а у фотографии будет то же значение, что у идентификатора: это не обязательно, 39 | но для простоты. 40 | 41 | Отметив все дома, вы узнаете фронт работ и осмотрите карту местности. 42 | 43 | **Если вам не нужны фотографии домов и навигация по подъездам, на этом можно закончить 44 | и сразу идти [устанавливать бота](2-install.md).** 45 | 46 | ## Идём фотографировать 47 | 48 | Теперь, когда район знаком, откладывайте компьютер и берите фотоаппарат. И телефон. 49 | Фотик нужен, чтобы качество снимков было хорошее, но если у вас топовый телефон, то 50 | его достаточно. Выходим на улицу. 51 | 52 | Для каждого дома нужны: 53 | 54 | - Фотография всего дома, можно со стороны улицы. Она будет на карточке дома: по ней 55 | должно быть несложно дом идентифицировать. Хорошо, если сбоку торчат какие-то уникальные 56 | штуки. 57 | - Фотографии подъездов. Лучше не каждый отдельно, а по нескольким сразу, чтобы на вид 58 | они не были похожи друг на друга. Лучше добавить чуть контекста, чтобы по фото было 59 | понятно, с какой стороны дома подъезды. 60 | - Диапазоны квартир в каждом подъезде. 61 | - Номер первой квартиры на каждом этаже каждого подъезда. 62 | 63 | Последнее — не шутка, я действительно зашёл в 73 подъезда и в полусотне сфотографировал 64 | табличку у лифта, где написано, на каком этаже какие квартиры. А в остальных пришлось 65 | подняться на верхний этаж и потом спускаться по лестнице, записывая диапазоны квартир 66 | на каждом втором этаже. 67 | 68 | Если в подъезд не пробраться, то диапазона квартир на весь 69 | подъезд достаточно. Без диапазона бот не сработает. Придумайте, как выведать коды домофонов 70 | или постойте у каждого подъезда и зайдите за жителем. 71 | 72 | ## Обработка 73 | 74 | ### Карта 75 | 76 | Снова открываем редактор точек (кнопка «restore» восстановит автоматически сохранённые 77 | данные) и добавляем на карту каждый подъезд. Тегов на них будет больше: 78 | 79 | ``` 80 | id mst6-2 81 | name Мстиславца 6, подъезд 2 82 | tag entrance 83 | photo mst6-2 84 | house mst6 85 | index no 86 | ``` 87 | 88 | Как видно, идентификатор и фотография созданы из идентификатора дома с суффиксом номера 89 | подъезда. Этот формат не важен, потому что дом и его подъезды связываются через атрибут 90 | `house`, но так удобнее. Атрибут `index` нужен, чтобы поиск по заведениям не выдавал 91 | кучу подъездов. Можно поставить его и домам, если у них нет своих альтернативных названий. 92 | 93 | ### Номера квартир 94 | 95 | Скопируйте в каталоге `config` файл `addr.sample.yml` в `addr.yml`. Внутри должно быть 96 | два списка: `streets` со списком домов по улицам и `apartments` со списком квартир 97 | по подъездам. 98 | 99 | В первом списке важно придумать ключевые слова для поиска улицы. Важно писать их в нижнем 100 | регистре. Не стесняйтесь добавить слова из одной буквы: удобно по «л10» получить дом 10 101 | по улице Ленина. Обратите внимание, что в списке домов ключи — это строки. Значения — 102 | это идентификаторы (значения `id`) точек, что мы добавили на карту выше. 103 | 104 | В списке `apartments` ключи — это идентификаторы подъездов, а значения — это первые 105 | квартиры в них. То есть, первый подъезд всегда будет иметь число 1. Если у вас есть 106 | номера квартир по этажам — то делайте значение списком, где N-е число является 107 | первом квартирой на этом этаже. Например, `[1, 5, 9]` может описывать подъезд из 108 | трёх этажей по четыре квартиры на каждом. 109 | 110 | Что делать, если на первом этаже, а то и на втором, нет квартир? Пользуемся тем, что 111 | есть дробные числа, но нет дробных номеров квартир. Делаем так: `[11.8, 11.9, 12, 16, ...]`. 112 | Тогда квартира 11 останется в прошлом подъезде, а 12 окажется уже на третьем этаже. 113 | Делать нормальный пропуск этажей сложновато, извините. 114 | 115 | ### OpenStreetMap 116 | 117 | Раз уж мы собрали данные, запустите JOSM или iD и отметьте все подъезды на карте. 118 | Подъезды обозначаются `entrance=staircase`, а диапазоны квартир в них — `addr:flats` со 119 | значениями типа `1-119`. [Читайте вики](https://wiki.openstreetmap.org/wiki/RU:Key:addr:flats). 120 | 121 | ### Фотографии подъездов 122 | 123 | На выходе нам нужно получить отдельные фото для каждого подъезда. Именно 124 | названия этих фотографий, в формате `len10-5` (подъезд №5 по ул. Ленина, например), 125 | вы прописывали в тегах подъездов. 126 | 127 | Обработать фото просто: берёте графический редактор (хоть GIMP, хоть MS Paint), 128 | открываете в нём очередную фотографию части дома с 2-3 подъездами, и рисуете 129 | отчётливую стрелку в сторону входа в каждый подъезд, сохраняя результаты в файлы. 130 | Получится что-то типа такого: 131 | 132 | ![Пример фотографии подъездов со стрелкой](entrance.jpg) 133 | 134 | ### Все фотографии 135 | 136 | Допереименуйте все фотографии в формат, который вы использовали в теге photo данных 137 | выше. Осталась самая мелочь. 138 | 139 | В каталоге репозитория сделайте подкаталог `photo`. Или где-нибудь в другом месте, 140 | но тогда 1) убедитесь, что у бота есть права на запись в него, 2) пропишите путь 141 | в ключе `photos` конфига (см. раздел 2). 142 | 143 | Теперь фотографии нужно пожать. Ваша камера выдала файлы размером в несколько 144 | гигабайт, телеграм же пожимает все фоточки в 100-200 килобайт. Кроме того, телеграм 145 | уменьшает их до примерно 1280 точек по длинной стороне. Так давайте сделаем 146 | примерно то же самое — заодно и место на сервере сэкономим. 147 | 148 | Проще всего это сделать через [ImageMagick](https://ru.wikipedia.org/wiki/ImageMagick): 149 | 150 | for i in *.JPG; do convert "$i" -resize 1280x1280 -strip -quality 85 //photo/$(basename $i .JPG).jpg; done 151 | 152 | Что эта строчка делает: берёт все файлы с расширением `.JPG` (заглавными буквами, как 153 | обычно делает фотоаппарат), уменьшает до 1280 точек по длинной стороне (`-resize`), 154 | удаляет EXIF и прочую муть (`-strip`), пожимает в JPEG с качеством 85 (`-quality`), 155 | меняет расширение на `.jpg` строчными буквами (`$(basename)`) и сохраняет 156 | в созданный ранее каталог `photo`. 157 | 158 | Если это слишком сложно, воспользуйтесь средствами пакетного перекодирования 159 | изображений вашей системы. Обращу внимание, что расширение должно быть `.jpg` 160 | строчными буквами. На регистронезависимость файловой системы не полагайтесь. 161 | 162 | ## Проверить хвосты 163 | 164 | Итак, у вас около сотни фотографий (если вы амбициозны) домов и их подъездов. 165 | Всяко что-то забыли или опечатались. Как проверить, что все фотографии на 166 | месте? 167 | 168 | Для этого нужно немного забежать вперёд. Установите бота, как написано 169 | в следующем разделе. Загрузите в него данные, как написано там же. И запустите 170 | 171 | python3 -m raybot buildings 172 | 173 | Скрипт проверит содержимое базы, файла `addr.yml` и каталога `photos`, и выдаст 174 | сводку, в которой будут сообщения четырёх типов: 175 | 176 | * "Missing photo: kur1-6.jpg" — на эту фотографию ссылается здание или подъезд, 177 | но её нет. 178 | * "No photo listed: pil10-1" — у подъезда с таким идентификаторов не прописан 179 | тег `photo`, и поэтому на фотографию не ссылаются. 180 | * "Weird apmt sequence in kur15-2: 0.5, 1" — на этаже меньше двух или больше 181 | десяти квартир. Иногда срабатывает на хак, когда мы пропускаем второй-третий 182 | этажи. 183 | * "Entrance len33-1: last floor 20 apmt 205" — проверьте по своим записям, 184 | что в этом подъезде действительно 20 этажей. 185 | 186 | **Когда всё готово и подчищено, приступайте ко второй части: [установке бота](2-install.md).** 187 | -------------------------------------------------------------------------------- /raybot/config/tags/tags.ru.yml: -------------------------------------------------------------------------------- 1 | tags: 2 | amenity=art_school: [изостудия, студия, школа, рисование, рисования, изо, искусство, искусств, художественная] 3 | amenity=atm: [банкомат, atm, банк, снять, деньги] 4 | amenity=bank: [банк, кредит, кредиты, деньги, валюта] 5 | amenity=bar: [бар, пиво, паб, beer, bar, pub] 6 | amenity=beauty_school: [школа красоты, школа, обучение, красоты] 7 | amenity=cafe: [кафе, еда, кофе, гарнир, суп, бистро, ресторан, поесть, салат, горячее] 8 | amenity=car_rental: [прокат автомобилей, прокат, машин, аренда, авто, автомобилей] 9 | amenity=childcare: [сад, детсад, детский, ясли, развитие, развивашка, развивашки, детей, обучение] 10 | amenity=clinic: [клиника, доктор, врач, прививка, медицинская, медицинский, центр, клиника, анализы] 11 | amenity=coffee: [кофейня, кофе, круассан, круассаны, пирожные, кафе, завтрак, напитки, coffee, выпечка] 12 | amenity=dancing_school: [танцы, танцев, школа, танцевальная, студия, хореография] 13 | amenity=dentist: [дантист, зубы, зубной, врач, зуб, стоматолог, стоматология] 14 | amenity=driving_school: [автошкола, авто, школа, вождения, вождение] 15 | amenity=fast_food: [фастфуд, фаст, фуд, еда] 16 | amenity=fountain: [фонтан] 17 | amenity=hookah_lounge: [кальянная, кальян] 18 | amenity=hospital: [больница, госпиталь, доктор, врач] 19 | amenity=kindergarten: [детский сад, детский, сад, детсад, ясли, детсад, детей, детям] 20 | amenity=language_school: [языки, языковая, школа, иностранный, иностранных, языков, иностранные] 21 | amenity=library: [библиотека, книги] 22 | amenity=music_school: [музшкола, музыкальная, музыка, школа] 23 | amenity=parking: [стоянка, парковка] 24 | amenity=pharmacy: [аптека, лекарства, таблетки, маски, маска] 25 | amenity=post_box: [почтовый ящик, почтовый, ящик, письма] 26 | amenity=post_office: [почта, пошта, письма, посылки, бандероль, посылка, письмо] 27 | amenity=school: [школа, образование, детей, детям, школьник] 28 | amenity=toilets: [туалет, wc, мж, '00'] 29 | craft=dressmaker: [пошив платьев, пошив, платьев, платье, платья, ателье] 30 | craft=electronics_repair: [ремонт, восстановление, техники] 31 | craft=house: [интерьер, архитектор, дизайн, ремонт, мастер] 32 | craft=roofer: [кровля, черепица] 33 | craft=tailor: [ателье] 34 | leisure=fitness_centre: [фитнес, тренировки, спорт, клуб, фитнесс, фит, качалка, жим, тренер] 35 | leisure=playground: [детская площадка, детская, площадка] 36 | leisure=tanning_salon: [солярий, загар, студия, загара] 37 | natural=tree: [дерево] 38 | office=company: [офис, компания, компании, организация, организации] 39 | office=estate_agent: [риэлтор, недвижимость, квартир, квартира, аренда, продажа, съем] 40 | office=insurance: [страхование, страх, дмс, страховка] 41 | office=lawyer: [адвокат, адвокаты, юрист, юристы] 42 | office=property_management: [жэк, жк, тс, тсж, управляющая, компания, обслуживание, обслуживания, жилконтора, центр] 43 | shop=alcohol: [алкоголь, вино, пиво, водка, коньяк, виски, ликер, спирт, алкоголя, магазин, вина, винный, водочный] 44 | shop=baby_goods: [детские товары, детские, товары, кресла, стульчики, коляски, сиденья] 45 | shop=bag: [сумки, сумка, клатч, клатчи, косметичка] 46 | shop=bakery: [пекарня, выпечка, хлеб, багет, багеты, булка, батон] 47 | shop=bathroom_furnishing: [сантехника, туалет, ванная, смеситель, смесители, душ, душевая, ванна] 48 | shop=beauty: [салон красоты, салон, красоты, стрижка, парикмахерская, макияж, укладка, студия] 49 | shop=instagram_beauty: [инстаграм красоты, салон, красоты, стрижка, макияж, укладка, студия] 50 | shop=bicycle: [веломагазин, вело, велосипед, велосипеды, велозапчасти, запчасти, байк] 51 | shop=books: [книжный, книги] 52 | shop=car_parts: [автозапчасти, авто, запчасти, детали] 53 | shop=chemist: [бытовая химия, бытовая, химия, порошок, стиральный, шампунь, гель, душа, средство, чистки, мыло] 54 | shop=clothes: [одежда, одежды, верхняя, верхней, гардероб] 55 | shop=confectionery: [кондитерская, сласти, сладости, пирожное, пирожные, торт, торты] 56 | shop=convenience: [продукты, супермаркет, магазин] 57 | shop=copyshop: [копи-центр, фото, печать, копи, центр, копии, копия, ксерокс, ксерокопии, ксерокопия] 58 | shop=cosmetics: [косметика, помада, духи, крем, краска, шампунь, волос, лак, ногтей] 59 | shop=craft: [творчество, творчества] 60 | shop=curtain: [шторы, салон, штор, текстиль, дома, ткани, ткань, занавески, занавес] 61 | shop=doors: [двери, дверь, дверей, салон] 62 | shop=electrical: [электротовары, электрика, электро, товары, запчасти, лампы, лампочки, лампочка, электроника] 63 | shop=electronics: [бытовая техника, бытовая, техника, электроника] 64 | shop=erotic: [секс-шоп, секс, интим, шоп, интимные, товары] 65 | shop=fabric: [ткани, нитки, пряжа, тканей, салон] 66 | shop=flooring: [напольные покрытия, покрытие, пол, напольные, покрытия, плинтус, плитка, ламинат, линолеум, паркет] 67 | shop=florist: [цветы, розы, роза, тюльпан, тюльпаны, букет, букеты, флорист, кветки, цветок, открытки, растения, флористика] 68 | shop=furniture: [мебель, мебельный] 69 | shop=games: [игры, настолки, настольные, карты] 70 | shop=gift: [подарок, подарки, открытки, коробки, упаковка, сувенир, сувениры] 71 | shop=greengrocer: [овощной, овощи, фрукты, зелень, яблоки, перцы, апельсины, мандарины] 72 | shop=hair_salon: [волосы, волос, парикмахерская] 73 | shop=hairdresser: [парикмахерская, стрижка, салон, стрижки, барбер, барбершоп, шоп, цирюльник, волосы] 74 | shop=hardware: [стройтовары, строймаркет, строй, строительство, инструмент, метизы, дрель, молоток, гвозди, саморезы, гайки, болты] 75 | shop=jewelry: [ювелирный, золото, кольца, ювелирка, ювелир, украшения, украшение, кольцо, серьги] 76 | shop=lighting: [светильники, лампы, лампа, лампочки, свет, светильник, точечный, торшер, торшеры] 77 | shop=marriage_salon: [свадебный салон, свадьба, платья, салон, свадебный, наряд] 78 | shop=massage: [массаж, массажа, салон] 79 | shop=nail_salon: [маникюр, ноготочки, ногтевой, салон, ногти, ногтей, педикюр, маникюра, педикюра, нейл, nail] 80 | shop=optician: [оптика, очки, линзы, оправы, очков, зрения, зрение] 81 | shop=outdoor: [снаряжение, туризм, товары, турист, туристические, рыбалка, охота, поход, похода, охоты, рыбалки, товары] 82 | shop=pet: [зоомагазин, зоотовары, зоо, кошке, кошек, собаке, собак, корм, ветеринар, ветеринарная] 83 | shop=seafood: [морепродукты, море, продукты, рыба] 84 | shop=shoes: [обувной, обуви, обувь, ботинки, сапоги] 85 | shop=skin_care: [эпиляция и чистка, салон красоты, эпиляция, депиляция, чистка, кожи, лица, ног, спины] 86 | shop=sports: [спорттовары, спорт, товары, одежда, снаряжение, спортивная, спортивные, обувь] 87 | shop=stationery: [канцтовары, карандаши, канцелярские, товары, канцелярия, ручки, бумага, блокноты, тетради, тетрадки] 88 | shop=tattoo: [тату, татуировки, татуировка] 89 | shop=tea: [чай, чайный, магазин, зеленый, черный] 90 | shop=toys: [игрушки, подарок, детям, детские, товары, конструктор, конструкторы, кукла, куклы] 91 | shop=travel_agency: [турагентство, тур, туризм, агентство, путешествия, билеты] 92 | shop=veg_food: [вегетарианцам, здоровое, питание, урбеч, соевое, молоко, каша, супы, веган, выганский, веганских, продукты, продуктов, магазин, веганам] 93 | 94 | suggest_tags: 95 | # Page 1 96 | - shop=beauty 97 | - shop=instagram_beauty 98 | - shop=nail_salon 99 | - shop=hairdresser 100 | - shop=hair_salon 101 | - shop=skin_care 102 | - shop=tattoo 103 | - shop=massage 104 | - shop=cosmetics 105 | 106 | - shop=clothes 107 | - craft=dressmaker 108 | - craft=tailor 109 | - shop=florist 110 | - shop=jewelry 111 | - shop=shoes 112 | 113 | # Page 2 114 | - amenity=cafe 115 | - amenity=coffee 116 | - amenity=bar 117 | - amenity=fast_food 118 | - shop=convenience 119 | - shop=greengrocer 120 | - shop=veg_food 121 | - shop=alcohol 122 | - amenity=toilets 123 | 124 | - amenity=pharmacy 125 | - amenity=dentist 126 | - amenity=clinic 127 | - shop=chemist 128 | - shop=optician 129 | - leisure=fitness_centre 130 | 131 | # Page 3 132 | - amenity=school 133 | - amenity=childcare 134 | - amenity=art_school 135 | - amenity=music_school 136 | - amenity=language_school 137 | - amenity=dancing_school 138 | 139 | - shop=toys 140 | - shop=stationery 141 | - shop=craft 142 | - shop=pet 143 | - shop=outdoor 144 | - shop=car_parts 145 | 146 | - shop=electronics 147 | - shop=lighting 148 | - shop=electrical 149 | 150 | # Page 4 151 | - shop=fabric 152 | - shop=furniture 153 | - shop=curtain 154 | - shop=flooring 155 | - shop=doors 156 | - craft=roofer 157 | - craft=house 158 | - shop=bathroom_furnishing 159 | - craft=electronics_repair 160 | 161 | - amenity=bank 162 | - amenity=atm 163 | - office=insurance 164 | - office=property_management 165 | - office=company 166 | - shop=travel_agency 167 | 168 | emoji: 169 | default: ❔ 170 | shop=beauty: ✨ 171 | shop=instagram_beauty: 🤳 172 | amenity=cafe: 🍴 173 | amenity=coffee: ☕ 174 | shop=clothes: 👕 175 | shop=furniture: 🛋️ 176 | office=company: 🏢 177 | shop=nail_salon: 💅 178 | shop=florist: 💐 179 | shop=travel_agency: ✈️ 180 | shop=cosmetics: 💄 181 | shop=hairdresser: ✂️ 182 | amenity=school: 📚 183 | amenity=childcare: 🧮 184 | amenity=pharmacy: 💊 185 | shop=convenience: 🛒 186 | shop=alcohol: 🍾 187 | amenity=toilets: 🚻 188 | amenity=art_school: 🎨 189 | shop=pet: 🐈 190 | shop=jewelry: 💎 191 | shop=hair_salon: 💇 192 | shop=doors: 🚪 193 | amenity=bar: 🍺 194 | shop=tea: 🍵 195 | craft=dressmaker: 👗 196 | shop=optician: 👓 197 | -------------------------------------------------------------------------------- /raybot/handlers/review.py: -------------------------------------------------------------------------------- 1 | from raybot.model import db, POI, Location 2 | from raybot.bot import bot, dp 3 | from raybot.util import get_user, get_buttons, delete_msg, DOW, tr 4 | from raybot.actions.poi import POI_EDIT_CB, REVIEW_HOUSE_CB 5 | from typing import Dict, List 6 | from aiogram import types 7 | from aiogram.utils.callback_data import CallbackData 8 | from aiogram.dispatcher.handler import SkipHandler 9 | 10 | 11 | FLOOR_CB = CallbackData('sreview', 'house', 'floor') 12 | REVIEW_CB = CallbackData('review_poi', 'id') 13 | EDIT_CB = CallbackData('review_edit', 'mode') 14 | 15 | 16 | async def check_floors(query: types.CallbackQuery, pois: List[POI], house: str = None): 17 | if not pois: 18 | kbd = types.InlineKeyboardMarkup().add( 19 | types.InlineKeyboardButton(tr('add_poi'), callback_data='new') 20 | ) 21 | await bot.send_message(query.from_user.id, tr('no_poi_around'), reply_markup=kbd) 22 | return 23 | 24 | floors = set([p.floor for p in pois]) 25 | if len(floors) >= 2: 26 | khouse = '-' if house is None else house 27 | kbd = types.InlineKeyboardMarkup(row_width=3) 28 | for ifloor in floors: 29 | label = ifloor if ifloor is not None else tr(('review', 'no_floor')) 30 | kbd.insert(types.InlineKeyboardButton( 31 | label, callback_data=FLOOR_CB.new(house=khouse, floor=ifloor or '-'))) 32 | kbd.insert(types.InlineKeyboardButton( 33 | tr(('review', 'all_floors')), 34 | callback_data=FLOOR_CB.new(house=khouse, floor='*'))) 35 | await bot.edit_message_reply_markup( 36 | query.from_user.id, query.message.message_id, reply_markup=kbd) 37 | else: 38 | # Just one floor, so doesn't matter 39 | await start_review(query.from_user, house) 40 | 41 | 42 | @dp.callback_query_handler(state='*', text='start_review') 43 | async def start_review_callback(query: types.CallbackQuery): 44 | # First check the floors around 45 | info = await get_user(query.from_user) 46 | if not info.location: 47 | await query.answer(tr(('review', 'send_loc'))) 48 | return 49 | if info.review_ctx: 50 | # We have an ongoing review session, continue it 51 | await start_review(query.from_user, *info.review_ctx) 52 | return 53 | pois = await db.get_poi_around(info.location, count=10) 54 | # Find floor options 55 | await check_floors(query, pois) 56 | 57 | 58 | @dp.callback_query_handler(REVIEW_HOUSE_CB.filter(), state='*') 59 | async def review_from_house(query: types.CallbackQuery, callback_data: Dict[str, str]): 60 | house = callback_data['house'] 61 | pois = await db.get_poi_by_house(house) 62 | await check_floors(query, pois, house) 63 | 64 | 65 | @dp.callback_query_handler(FLOOR_CB.filter(), state='*') 66 | async def select_floor(query: types.CallbackQuery, callback_data: Dict[str, str]): 67 | house = callback_data['house'] 68 | floor = callback_data['floor'] 69 | if floor == '*': 70 | floor = None 71 | await start_review(query.from_user, None if house == '-' else house, floor) 72 | 73 | 74 | @dp.callback_query_handler(state='*', text='stop_review') 75 | async def stop_review(query: types.CallbackQuery): 76 | info = await get_user(query.from_user) 77 | info.review = None 78 | info.review_ctx = None 79 | await delete_msg(bot, query) 80 | await bot.send_message(query.from_user.id, tr(('review', 'stopped')), 81 | reply_markup=get_buttons()) 82 | 83 | 84 | @dp.callback_query_handler(state='*', text='continue_review') 85 | async def continue_review(query: types.CallbackQuery): 86 | info = await get_user(query.from_user) 87 | if not info.review: 88 | await query.answer(tr(('review', 'no_review'))) 89 | return 90 | await print_review_message(query.from_user) 91 | 92 | 93 | async def start_review(user: types.User, house: str = None, floor: str = None): 94 | """Set floor to "-" to search only absent floors.""" 95 | info = await get_user(user) 96 | if house is not None: 97 | pois = await db.get_poi_by_house(house, floor) 98 | if info.location: 99 | ref = info.location 100 | else: 101 | if len(pois) > 14: 102 | info.review_ctx = (house, floor) 103 | await bot.send_message(user.id, tr(('review', 'too_many'))) 104 | return 105 | ref = pois[0].location 106 | pois.sort(key=lambda p: ref.distance(p.location)) 107 | else: 108 | pois = await db.get_poi_around(info.location, count=30, floor=floor) 109 | if len(pois) > 14: 110 | # Sort by "not reviewed in the past ten hours" 111 | ages = await db.get_poi_ages([p.id for p in pois]) 112 | pois.sort(key=lambda p: 0 if ages[p.id] > 10 else 1) 113 | # Start review and print the review panel 114 | info.review = [[p.id, None] for p in pois[:14]] 115 | info.review_ctx = (house, floor) 116 | await print_review_message(user) 117 | 118 | 119 | async def make_review_keyboard(pois: List[POI], edit: bool = False): 120 | ages = await db.get_poi_ages([p.id for p in pois]) 121 | width = 3 if len(pois) in (3, 4, 7) else 4 122 | kbd = types.InlineKeyboardMarkup(row_width=width) 123 | for i, poi in enumerate(pois, 1): 124 | if ages[poi.id] <= 50: 125 | fresh = '✅' 126 | else: 127 | fresh = '' if not edit else '📝' 128 | data = POI_EDIT_CB.new(id=poi.id, d='1') if edit else REVIEW_CB.new(id=poi.id) 129 | kbd.insert(types.InlineKeyboardButton( 130 | f'{i} {fresh}{poi.name}', callback_data=data)) 131 | if edit: 132 | kbd.insert(types.InlineKeyboardButton('🗒️', callback_data=EDIT_CB.new('check'))) 133 | else: 134 | kbd.insert(types.InlineKeyboardButton('📝', callback_data=EDIT_CB.new('edit'))) 135 | kbd.insert(types.InlineKeyboardButton('✖️', callback_data='stop_review')) 136 | return kbd 137 | 138 | 139 | async def print_review_message(user: types.User, pois: List[POI] = None): 140 | if not pois: 141 | info = await get_user(user) 142 | if not info.review: 143 | return 144 | pois = await db.get_poi_by_ids([r[0] for r in info.review]) 145 | OH_REPL = {DOW[i]: tr(('editor', 'hours_abbr'))[i] for i in range(7)} 146 | content = tr(('review', 'list')) + '\n' 147 | if len(pois) == 14: 148 | content += '\n' + tr(('review', 'incomplete')) + '\n' 149 | for i, poi in enumerate(pois, 1): 150 | p_start = f'\n{i}. «{poi.name}»' 151 | p_icons = '' 152 | p_absent = '' 153 | if poi.has_wifi is not None: 154 | p_icons += '📶' if poi.has_wifi else '📵' 155 | if poi.accepts_cards is not None: 156 | p_icons += '💳' if poi.accepts_cards else '💰' 157 | if not poi.phones: 158 | p_absent += '📞' 159 | if not poi.links: 160 | p_absent += '🌐' 161 | if not poi.address_part: 162 | p_absent += '🚪' 163 | if not poi.keywords: 164 | p_absent += '🔡' 165 | if not poi.photo_out: 166 | p_absent += '🌄' 167 | if not poi.photo_in: 168 | p_absent += '📸' 169 | if p_absent: 170 | p_absent = tr(('review', 'absent')) + ' ' + p_absent 171 | if not poi.hours_src: 172 | p_oh = tr(('review', 'no_hours')) 173 | else: 174 | p_oh = poi.hours_src.replace(':00', '') 175 | for k, v in OH_REPL.items(): 176 | p_oh = p_oh.replace(k, v) 177 | content += ' '.join([p for p in (p_start, p_icons, p_absent, p_oh) if p]) 178 | kbd = await make_review_keyboard(pois) 179 | await bot.send_message(user.id, content, reply_markup=kbd) 180 | 181 | 182 | @dp.callback_query_handler(REVIEW_CB.filter(), state='*') 183 | async def update_review(query: types.CallbackQuery, callback_data: Dict[str, str]): 184 | info = await get_user(query.from_user) 185 | if not info.review: 186 | await query.answer(tr(('review', 'no_review'))) 187 | return 188 | 189 | poi_id = int(callback_data['id']) 190 | review_record = [r for r in info.review if r[0] == poi_id] 191 | if not review_record: 192 | await query.answer(tr(('review', 'no_record'))) 193 | return 194 | 195 | if review_record[0][1]: 196 | # We have old updated, revert to it 197 | await db.set_updated(poi_id, review_record[0][1]) 198 | review_record[0][1] = None 199 | else: 200 | review_record[0][1] = await db.set_updated(poi_id) 201 | 202 | # Update keyboard 203 | pois = await db.get_poi_by_ids([r[0] for r in info.review]) 204 | kbd = await make_review_keyboard(pois) 205 | await bot.edit_message_reply_markup( 206 | query.from_user.id, query.message.message_id, reply_markup=kbd) 207 | 208 | 209 | @dp.callback_query_handler(EDIT_CB.filter(), state='*') 210 | async def edit_mode(query: types.CallbackQuery, callback_data: Dict[str, str]): 211 | info = await get_user(query.from_user) 212 | if not info.review: 213 | await query.answer(tr(('review', 'no_review'))) 214 | return 215 | pois = await db.get_poi_by_ids([r[0] for r in info.review]) 216 | kbd = await make_review_keyboard(pois, callback_data['mode'] == 'edit') 217 | await bot.edit_message_reply_markup( 218 | query.from_user.id, query.message.message_id, reply_markup=kbd) 219 | 220 | 221 | @dp.message_handler(content_types=types.ContentType.LOCATION, state='*') 222 | async def set_loc(message): 223 | info = await get_user(message.from_user) 224 | if not info.review or not info.review_ctx: 225 | raise SkipHandler 226 | # Save location and continue the review 227 | location = Location(message.location.longitude, message.location.latitude) 228 | info.location = location 229 | await start_review(message.from_user, *info.review_ctx) 230 | -------------------------------------------------------------------------------- /docs/2-install.md: -------------------------------------------------------------------------------- 1 | # Установка и настройка бота 2 | 3 | ## Установка 4 | 5 | Начинается установка так же, как и все остальные: клонированием 6 | git-репозитория: 7 | 8 | git clone https://github.com/Zverik/bot_na_rayone.git 9 | 10 | Следующий шаг тоже традиционен: сделайте окружение virtualenv 11 | привычным вам способом. Например: 12 | 13 | * `python3 -m venv venv` 14 | * `pipenv install -r requirements.txt` 15 | * `hatch env na_rayone` 16 | 17 | И иногда нужно будет запустить `pip` из этого окружения: 18 | 19 | pip install -r requirements.txt 20 | 21 | В конце проверьте, что бот запускается, командой (из-под окружения): 22 | 23 | python -m raybot help 24 | 25 | ### Ansible 26 | 27 | На сервер автора бот установлен и управляется через Ansible. Это такая 28 | чудесная система программирования состояний сервера на файлах yaml. 29 | [Вот здесь](https://github.com/Zverik/ansible-tile/tree/master/roles/mayak_nav_bot) 30 | опубликована роль для бота. Роль легко читается и может служить 31 | пошаговой инструкцией для ручной установки. 32 | 33 | Обратите внимание на часть про установку свежего Python: в коде использованы 34 | датаклассы, введённые в Python 3.7, поэтому версии старее этой не поддерживаются. 35 | Это может быть проблемой на Ubuntu 18.04, Debian Stretch и CentOS 8. 36 | 37 | ## Регистрация бота 38 | 39 | Как всегда, новый бот делается через [@BotFather](https://t.me/botfather). 40 | Пишете там `/newbot` и проходите все этапы. В конце получите длинную 41 | строку токена, которую нужно прописать в `config/config.yml` в поле 42 | `telegram_token`. 43 | 44 | Помимо прочего, BotFather командой `/setcommands` предлагает подсказать 45 | пользователям, какие команды бот поддерживает. Скопируйте туда вот это: 46 | 47 | ``` 48 | start - Приветствие и кнопки поиска 49 | help - Справка и статистика 50 | popular - Популярные заведения 51 | random - Несколько случайных заведений 52 | my - Мои отмеченные заведения 53 | msg - Написать модераторам 54 | ``` 55 | 56 | ## Тайлы 57 | 58 | В первой части мы готовили фоточки. Если вы её пропустили, то сделайте 59 | каталог `photo` внутри каталога с ботом. Или где-нибудь в другом месте, но 60 | тогда убедитесь, что бот может туда писать, и пропишите путь в `config.yml` 61 | в поле `photos`. 62 | 63 | Если вы хотите, чтобы кроме фоточек, у заведений была мини-карта, и списки 64 | заведений тоже приходили с картой, на которой расставлены цифры результатов, 65 | то нужны тайлы. Тайлы — это растровые квадратики 256×256 точек, из которых 66 | собраны интерактивные карты на многих сайтах, включая OpenStreetMap. Они 67 | поделены на уровни масштаба, где квадратик на масштабе N состоит из четырёх 68 | квадратиков (2×2) масштаба N+1. Вам нужны тайлы масштабов 15-17, а то и 14, 69 | если город слишком большой. 70 | 71 | ![Пример ответа с картой](map_example.jpg) 72 | 73 | Есть два способа тайлы получить: скачать и сгенерировать. Учитывая небольшой 74 | объём данных (около 200 файлов или 3 МБ), оба варианта хороши. Второй требует 75 | чуть больше работы, но даёт результат красивее. Важно, чтобы на тайлах не было 76 | заведений (POI), но были дороги с их названиями, здания с номерами и побольше 77 | местного контекста. 78 | 79 | Вот несколько ссылок: 80 | 81 | * Скачать выгрузку OpenStreetMap для своего города: BBBike, Interline или OSMaxx 82 | [из этого списка](https://wiki.openstreetmap.org/wiki/Processed_data_providers). 83 | * Стиль [OSM Carto](https://github.com/gravitystorm/openstreetmap-carto/) включает 84 | в себя [образ Docker](https://github.com/gravitystorm/openstreetmap-carto/blob/master/DOCKER.md) 85 | для загрузки файла pbf в базу PostgreSQL. 86 | * Добавив к тому образу [Docker для TileMill](https://github.com/schachmett/docker-tilemill/blob/master/Dockerfile), 87 | получите редактор стилей на CartoCSS с выгрузкой в тайлы (параметры соединения: 88 | `host=db dbname=gis`). 89 | * Установив `python3-mapnik` и пару других модулей, можно сделать тайлы 90 | из базы PostgreSQL и стиля Mapnik скриптом [polytiles](https://github.com/Zverik/polytiles). 91 | * Можно нарисовать карту в QGIS (или купить основу [у NextGIS](https://data.nextgis.com/)) 92 | и установить модуль Processing. В нём найдите команду "Generate XYZ tiles (Directory)". 93 | 94 | Если тайлы в формате MBTiles, то [распакуйте](https://github.com/mapbox/mbtiles-spec/wiki/Implementations) 95 | их в набор файлов `*.png`. Должны получиться каталоги с числовыми названиями: 96 | `15`, `16` и `17`. Создайте каталог `tiles` внутри каталога с ботом (или 97 | в другом месте, прописав путь к нему в `config.yml` в поле `tiles`) и 98 | переместите эти числовые каталоги туда. 99 | 100 | ## Настройка 101 | 102 | Бот ожидает найти несколько файлов в формате yaml в каталоге `config`. 103 | 104 | * `config.yml` — мы его делали на протяжении инструкций. Важные ключи 105 | для него — `telegram_token` и `admin_id`, которые мы заполнили выше. 106 | Остальные ключи, кроме путей, задокументированы в `config.sample.yml`. 107 | Пропишите путь к логам в `logs` (должны быть права на запись!) и 108 | прямоугольник вокруг вашего района в `bbox`. Последний нарисуйте 109 | на [этом сайте](https://boundingbox.klokantech.com/), выберите 110 | внизу формат "CSV" и скопируйте числа. 111 | * `responses.yml` — второй по важности файл после `config.yml`. 112 | Скопируйте его из `responses.sample.yml` и поправьте поля в нём: 113 | - `start` и `help`: сообщения по командам `/start` и `/help` 114 | соответственно. 115 | - `buttons`: список списков для кнопок под полем ввода. То есть, 116 | список строк, каждая строка — список кнопок. Строки на кнопках 117 | отправляются в поиск напрямую. 118 | - `synonims`: список синонимов для ключевых слов. Помогает делать 119 | кнопки из эмодзи, а не из обычных слов. 120 | - `responses`: здесь можно добавить ответов роботу. Поле `name` выводится 121 | как заголовок, `message` содержит полное длинное сообщение, `photo` 122 | содержит имя картинки для ответа (должна быть в каталоге с фотографиями), 123 | `keywords` (единственное обязательное поле) — список ключевых слов, 124 | по которым вернётся этот ответ. Важно, что эти ключевые слова затмевают 125 | все заведения с такими же словами. Все слова обязательно должны быть 126 | в нижнем регистре. 127 | - `skip`: ключевые слова, которые не учитываются в поиске. 128 | * `addr.yml` — дома, подъезды и номера квартир в них. Этот файл описан 129 | в [прошлом разделе](1-addresses.md). 130 | * `tags.yml` — теги OpenStreetMap и дополнительные с описаниями и синонимами. 131 | Используется для поиска и для панели тегов в редакторе. Описан 132 | в [следующем разделе](3-poi.md). 133 | * `strings.yml` — строки, которые видит пользователь. Редактировать не нужно, 134 | разве что вы заходите перевести на другой язык. Мультиязычность пока 135 | не поддерживается. 136 | 137 | ### Вложенные кнопки 138 | 139 | Обычно бот оперирует поисковой строкой: человек вводит слово или несколько, 140 | бот ищет эти слова в своих базах. Поэтому кнопки под полем ввода размещены 141 | больше для подсказки: всё, что они вводят, человек может ввести самостоятельно. 142 | 143 | Альтернативный подход для районного бота — курируемый каталог заведений. 144 | Он работает, когда заведений меньше сотни, или когда мы хотим не только 145 | поиск, но и продвижение. Каталог также облегчает обнаружение новых заведений: 146 | когда ничего не ищешь, всё равно можно занять время нажиманием на кнопочки. 147 | 148 | Механизм предопределённых ответов позволяет сделать подобную систему вложенных 149 | списков. Помимо ключей для построения ответа (`name`, `message`, `photo`), 150 | в элемент списка `responses` можно добавить ключ `buttons`, формат содержимого 151 | которого ровно тот же, что у общего `buttons`: список списков строк для вывода 152 | на кнопках. Некоторые из этих строк могут входить в списки `keywords` других 153 | предопределённых ответов. Если поля `buttons` нет, то выводятся общие кнопки. 154 | 155 | Помните, что ключевые слова для `responses` приоритетнее ключевых слов для 156 | тегов и заведений. То есть, не используйте стандартные слова вроде «еда» 157 | или «школа». Лучше всего воспользоваться особенностью ключевых слов в `responses`: 158 | они могут содержать пробелы, которые сохраняются при проверке. То есть, 159 | «🛒 Магазины» будет проверено целиком, а не разобьётся на два слова, эмодзи 160 | и «магазины». 161 | 162 | Небольшой пример вложенных кнопок: 163 | 164 | ```yaml 165 | responses: 166 | - name: Что ищете? 167 | keywords: [⬅️ начало] 168 | - name: Какие магазины? 169 | keywords: [🛒 магазины] 170 | buttons: 171 | - [⬅️ начало, продукты, веганам, алкоголь] 172 | - [одежда, обувь, сумки, цветы] 173 | - name: Какие заведения? 174 | keywords: [🍽️ еда] 175 | buttons: 176 | - [⬅️ начало, кафе, фастфуд, кофейни] 177 | buttons: 178 | - [🛒 Магазины, 🍽️ Еда, ✉️ Почта] 179 | ``` 180 | 181 | ## Запуск 182 | 183 | Запустить бота просто: из-под виртуального окружения выполните такое: 184 | 185 | python -m raybot 186 | 187 | Появится строчка про start polling, и можно открыть телеграм и нажать 188 | «Start». В самом начале база будет пустая и никакие запросы не сработают — 189 | смотрите ниже, как её заполнить. 190 | 191 | У библиотеки aiogram есть [странный баг](https://github.com/aiogram/aiogram/issues/485): 192 | на некоторых операционных системах второй ответ может занять до двух минут. 193 | В проде этого у меня не случалось. 194 | 195 | ## База данных 196 | 197 | Сначала база пустая, но она не должна быть такой. На прошлом этапе вы нарисовали 198 | на карте, как минимум, все дома в районе, и получили файл GeoJSON. Теперь давайте 199 | зальём его в базу. 200 | 201 | Отправьте боту команду `/admin`. Затем нажмите «База заведений», и там «Прислать файл». 202 | Теперь пришлите боту файл GeoJSON. Если всё будет хорошо, заведения появятся 203 | в базе. После этого можно отправить боту команды или ключевые слова. 204 | Если обходили дома и подъезды, то попробуйте ввести улицы и числа. 205 | 206 | При загрузке вы можете получить одну из двух ошибок: "Duplicate id" требует проверить 207 | идентификаторы на дубликаты, а "POI references missing key" обратит внимание 208 | на теги `house`, которые должны ссылаться на существующие `id`. 209 | 210 | ### Systemd 211 | 212 | На сервер бота лучше ставить не для запуска из командной строки 213 | (это довольно странно для сервера), а как сервис systemd. Это несложно: 214 | сделайте файл `/etc/systemd/system/nav_bot.service` с 215 | [таким содержимым](https://github.com/Zverik/ansible-tile/blob/master/roles/mayak_nav_bot/files/mayak_nav_bot.service). 216 | Поправьте в нём `User`, `Ground`, `WorkingDirectory` и `ExecStart`. 217 | Дальше — как обычно: 218 | 219 | sudo systemd start nav_bot 220 | sudo systemd enable nav_bot 221 | 222 | Чтобы посмотреть логи, сделайте `sudo journalctl -u nav_bot`. Остальные 223 | три лога будут либо в каталоге бота, либо в каталоге, который вы 224 | прописали в ключе `logs` в `config.yml`. 225 | 226 | **Теперь у вас запущен бот и настроены его ответы. Но база заведений пуста. 227 | Как её заполнить, читайте в [третьей части](3-poi.md).** 228 | -------------------------------------------------------------------------------- /raybot/config/strings/strings.ru.yml: -------------------------------------------------------------------------------- 1 | home: Привет! Введи, что ищешь, или нажми кнопку. 2 | not_found: > 3 | По запросу «%s» ничего не найдено. Если видите это заведение, 4 | попробуйте другой запрос или нажмите кнопку, чтобы добавить его в базу. 5 | Или нажмите на /random, если не знаете, что выбрать. 6 | save: Сохранить 7 | cancel: Отменить 8 | default_link: сайт 9 | open_link: Открыть сайт 10 | poi_links: Ссылки 11 | has_wifi: Есть бесплатный Wi-Fi. 12 | accepts_cards: Можно оплатить картой. 13 | no_cards: Оплата только наличными. 14 | open_247: Открыто круглосуточно. 15 | now_open: Открыто сегодня до %s 16 | next_open: '{day} работает с {hour}.' 17 | now_closed: Закрыто. Откроется {day} в {hour}. 18 | tomorrow: завтра 19 | relative_days: [в понедельник, во вторник, в среду, в четверг, в пятницу, в субботу, в воскресенье] 20 | poi_list: 'По запросу «%s» нашли несколько заведений:' 21 | poi_not_full: Список неполный, нажмите последнюю кнопку для запроса всех {total_count}. Пришлите координаты, чтобы посмотреть ближайшие. 22 | poi_too_many: Список неполный, но все {total_count} никак не поместятся. Пришлите координаты, чтобы посмотреть ближайшие. 23 | all: Все 24 | poi_in_house: Заведения в этом доме 25 | query_fail: Что-то пошло не так — повторите запрос, пожалуйста. 26 | no_poi_key: Нет заведения с таким номером. 27 | no_poi_in_house: Заведений нет. 28 | choose_floor: В этом доме заведения на нескольких этажах. Выберите этаж. 29 | location: Спасибо, на ближайшие пять минут учтём ваше местоположение при поиске. 30 | no_similar: Нет похожих заведений 31 | message: Введите сообщение — его увидят модераторы. 32 | message_self: Себе сообщения слать нельзя. 33 | do_reply: Ответьте на сообщение ниже с цитированием, чтобы адресат получил ваш ответ. 34 | reply_sent: Отправлен ответ на сообщение %s. 35 | star: Отметить 36 | starred: Избранное 37 | no_starred: Вы пока не отметили ни одного заведения. 38 | no_popular: Популярных заведений пока нет. Отмечайте хорошее звёздочками! 39 | loc_btn: Координаты 40 | edit_poi: Поправить 41 | notify_mods: Сообщить модераторам 42 | add_poi: Добавить заведение 43 | similar: Похожие 44 | maintenance: 'База временно закрыта на запись, извините. Напишите модераторам, что вы хотите поправить: /msg' 45 | on_street: 'Выберите дом по %s:' 46 | no_building: Нет дома {house} по {street} 47 | send_apt: Пришлите номер квартиры, если хотите. 48 | apt_number: 'Номер квартиры %s должен быть числом. Вот карточка для дома:' 49 | apartment: Квартира %s. 50 | floor: Квартира {apt} на {floor} этаже. 51 | 52 | new_poi: 53 | name: 'Спасибо, что добавляете заведение! Введите название как на вывеске:' 54 | name_too_short: 'Слишком короткое название, введите ещё раз:' 55 | location: 'Теперь прикрепите местоположение с координатами заведения:' 56 | no_location: 'Нажмите на скрепку и прикрепите местоположение (location):' 57 | location_out: 'Координаты слишком далеко от Маяка. Найдите что-нибудь поближе или отмените.' 58 | keywords: 'И последнее из обязательного: введите побольше ключевых слов для поиска этого заведения. Например, «кафе еда багет багеты круассаны круассан кофе».' 59 | no_keywords: 'Пожалуйста, можно побольше слов:' 60 | confirm: Вот как выглядит новое заведение. Если нужно, добавьте полей, особенно полезны фотографии и время работы. 61 | confirm2: | 62 | Нажмите на команду или пришлите фотографию, чтобы поправить заведение. Закончите — жмите «Сохранить». 63 | cancel: Не проблема, добавим в другой раз. 64 | latlon: Взять lat,lon с сайта 65 | 66 | editor: 67 | panel: 68 | desc: Описание 69 | keywords: Ключевые слова 70 | tag: OSM-тег 71 | house: Адрес 72 | floor: Этаж 73 | addr: Местонахождение 74 | hours: Часы работы 75 | loc: Координаты 76 | loc_browse: смотреть 77 | phone: Телефоны 78 | wifi: Есть ли Wi-Fi 79 | card: Оплата картой 80 | links: Ссылки 81 | none: нет 82 | comment: Комментарий 83 | photo: Фотографии 84 | photo_both: обе 85 | photo_out: только вход 86 | photo_in: только внутри 87 | photo_comment: залейте замену или /ephoto для просмотра, /eout для копирования фото снаружи 88 | deleted: Удалено 89 | restore: Восстановить 90 | delete: Удалить 91 | msg: Написать модераторам 92 | photo: Спасибо за фотографию! Это вид откуда? 93 | upload_fail: Не удалось загрузить файл, попробуйте ещё раз. 94 | dash: Введите «-» без кавычек, если нужно очистить поле. 95 | name: Введите новое название. 96 | empty_name: Имя нельзя очищать 97 | desc: Введите новое описание. 98 | tag: Выберите тип заведения или введите тег как в OpenStreetMap. Например, amenity=cafe или shop=convenience. Если не знаете, что это, то лучше и не надо. 99 | tag_format: Тег должен быть в виде ключ=значение, а не %s. 100 | comment: Введите новый комментарий, можно многострочный. 101 | keywords: Ключевые слова можно только добавлять. Введите новые слова через пробел. 102 | location: Прикрепите местоположение или скопируйте lat,lon с сайта. 103 | house: Выберите дом. 104 | floor: 'Выберите этаж или введите название нового этажа. Слово «этаж» пишите: например, «5 этаж».' 105 | address: Введите помещение и/или номер входа, без адреса и этажа. 106 | cancel: Оставить как есть 107 | wifi: Есть ли в заведении бесплатный Wi-Fi? 108 | cards: Можно ли оплатить покупки или услуги картой Visa / Mastercard? 109 | phones: Введите все телефоны через точку с запятой, каждый начиная с +375. 110 | links: 'Введите название из одного слова и ссылку через пробел, например: «сайт https://ya.ru/». Если название уже есть, ссылка будет заменена на новую. Для удаления введите только название существующей ссылки.' 111 | links_have: 'Ссылки, которые есть:' 112 | no_links: Пока нет ни одной ссылки. 113 | instagram: инстаграм 114 | link_replace: 115 | вконтакте: vk 116 | вк: vk 117 | vkontakte: vk 118 | facebook: фейсбук 119 | hours: 'Введите часы работы. Примеры: «8-20», «пн-пт 10-19:30», «пн-чт 10-20 обед 13-14, пт-вс 10-18».' 120 | hours_abbr: [пн, вт, ср, чт, пт, сб, вс] 121 | hours_break: обед 122 | hours_format: 'Непонятный формат: "%s".' 123 | sent: Ваше замечание отправлено модераторам. 124 | saved: Спасибо, заведение сохранено. 125 | sent: Спасибо, ваши изменения отправлены на модерирование. Скоро их посмотрим и применим. 126 | message: Введите сообщение, чтобы передать модераторам. 127 | cant_message: Вы же модератор, сделайте сами! 128 | other_msg: Нажмите на любую команду из списка выше, или /msg для связи с модератором. 129 | msg_sent: Спасибо, ваше замечание отправлено модераторам. 130 | delete: Введите причину для удаления. 131 | deleted: Заведение удалено. 132 | deleted2: Заведение удалено из базы. 133 | delete_twice: Нельзя удалить заведение дважды. 134 | just_deleted: 'Только что удалили заведение /poi{id}: {reason}' 135 | just_added: 'Только что добавили заведение /poi{id}: "{name}"' 136 | error_save: 'Ошибка при сохранении данных: %s. Попробуйте ещё раз или отмените.' 137 | saved_look: Посмотреть заведение 138 | saved_add: Добавить новое 139 | cant_restore: 'Восстановить заведение может только модератор. Напишите им: /msg' 140 | restored: Заведение снова можно найти. 141 | wrong_attr: Атрибут %s пока не редактируем. 142 | bool_yes: да 143 | bool_no: нет 144 | unknown: неизвестно 145 | bool_true: Есть 146 | bool_false: Нет 147 | bool_none: ХЗ 148 | next_page: Ещё 149 | no_photos_around: Нет фотографий вокруг. 150 | choose_photo: Выберите фотографию. 151 | photo_out: Снаружи 152 | photo_in: Изнутри 153 | photo_del: Удалить 154 | photo_lost: Ваша фотография потерялась. Попробуйте ещё раз. 155 | photo_deleted: Хорошо, фоточку удалили. 156 | photo_forgot: Хорошо, фоточку забыли. 157 | 158 | queue: 159 | empty: Очередь пуста, вы свободны! 160 | field: 'Запрос от {user}: у заведения «{name}» уточнить поле {field}.' 161 | message: 'Пользователь {user} попросил уточнить заведение «{name}»:' 162 | look: Карточка 163 | apply: Применить 164 | validated: Проверено 165 | delete: Нет, спасибо 166 | applied: Применили 167 | deleted: Удалили 168 | validated_ok: Заведение проверено. 169 | added: 'Очередь исправлений удлиннилась: нажмите /queue, чтобы её разобрать.' 170 | new_poi: Кто-то добавил заведение выше, проверьте его и нажмите кнопку. 171 | poi_lost: POI пропал, странно. 172 | poi_lost_del: POI пропал, удаляю запись. 173 | nothing: ничего 174 | old: Сейчас 175 | new: Будет 176 | missing: Пропало сообщение с таким номером 177 | 178 | admin: 179 | forward: Форвардните пост от человека, чтобы сделать его модератором. 180 | mod_already: Он/она уже модератор. 181 | mod_added: Пользователь %s теперь модератор. 182 | mod_you: Вы теперь модератор. Попробуйте /queue и /admin. 183 | mod_removed: Пользователь больше не модератор. 184 | no_mods: Нет ни одного модератора. Форвардните пост от человека, чтобы сделать её/его модератором. 185 | mod_list: Список модераторов 186 | mod_help: Нажмите кнопку, чтобы удалить человека из модераторов, либо форвардните пост от нового человека, чтобы сделать её/его модератором. 187 | no_deleted: Ничего не удалено. 188 | no_such: Нет таких. 189 | audit: Последние операции 190 | admin: Администратор 191 | confirmed_by: Подтвердил %s 192 | created: создал 193 | deleted: удалил 194 | modified: изменил у 195 | field: поле 196 | tags_caption: Файл с новыми тегами. Добавьте их в config/tags.yml. 197 | unknown_file: Непонятный тип файла 198 | error: 'Ошибка: %s. Попробуйте снова.' 199 | 200 | admin_base: 201 | down_json: 'Воспользуйтесь редактором GeoJSON и потом нажмите «Прислать файл» и загрузите результат обратно. Редактор: https://zverik.github.io/point_ed/' 202 | down_tags: Заполните пропуски в тегах в любом табличном редакторе, потом скачайте результат в формате CSV, нажмите «Прислать файл» и загрузите его сюда. 203 | maintenance: База заморожена, редактор отключён. 204 | no_maintenance: База разморожена, можно редактировать заведения 205 | up_json: Заведения обновлены из файла GeoJSON. 206 | up_csv: Теги заведений обновлены из файла CSV. 207 | down_json: Скачать заведения 208 | down_tags: Скачать теги 209 | upload: Прислать файл 210 | freeze: Заморозить базу 211 | unfreeze: Разморозить базу 212 | send_file: Пришлите файл с GeoJSON или тегами. 213 | 214 | admin_menu: 215 | mods: Модераторы 216 | dedup: Дедубл. фото 217 | unused: Подчистить фото 218 | base: База заведений... 219 | audit: Аудит 220 | reindex: Перестроить индекс 221 | reindexed: Поисковый индекс перестроен. 222 | no_house: Нет адреса 223 | no_floor: Нет этажа 224 | no_photo: Нет фото 225 | no_tag: Нет тега 226 | no_keywords: Нет ключ. слов 227 | msg: Привет, модератор! Нажми что-нибудь. 228 | wrong_action: 'Неизвестный action: %s' 229 | deduped: Удалили %s дубликатов фото. 230 | del_unused: Удалили %s неиспользованных фото. 231 | 232 | review: 233 | no_poi_around: Вокруг нет заведений. 234 | no_floor: Без этажа 235 | all_floors: Все 236 | send_loc: Пришлите геопозицию, пожалуйста 237 | too_many: В доме (на этаже) больше 14 заведений, поэтому целиком их не отобразить. Пришлите геопозицию, чтобы начать осмотр. 238 | start: Осмотреть заведения 239 | continue: Продолжить осмотр 240 | list: Вот какие заведения есть вокруг. Нажмите на кнопку, чтобы отметить заведение проверенным. Для редактирования нажмите «📝» и затем кнопку заведения. 241 | incomplete: Список может быть неполным. Пришлите геопозицию из другого конца дома, чтобы продолжить. 242 | absent: нет 243 | no_hours: нет часов 244 | stopped: Осмотр закончен. Чтобы продолжить, пришлите геопозицию или поищите дом, в который зашли. 245 | no_review: Осмотр внезапно закончился, перезапустите 246 | no_record: Потеряли запись, попробуйте перезапустить 247 | -------------------------------------------------------------------------------- /raybot/actions/poi.py: -------------------------------------------------------------------------------- 1 | from raybot import config 2 | from raybot.model import db, POI, Location 3 | from raybot.bot import bot 4 | from raybot.util import h, get_user, get_map, pack_ids, uncap, tr 5 | import csv 6 | import re 7 | import os 8 | import random 9 | import logging 10 | from typing import List, Tuple 11 | from datetime import datetime 12 | from aiogram import types 13 | from aiogram.utils.callback_data import CallbackData 14 | from aiogram.dispatcher.filters.state import State, StatesGroup 15 | 16 | 17 | HTML = types.ParseMode.HTML 18 | POI_LIST_CB = CallbackData('poi', 'id') 19 | POI_LOCATION_CB = CallbackData('poiloc', 'id') 20 | POI_SIMILAR_CB = CallbackData('similar', 'id') 21 | POI_EDIT_CB = CallbackData('poiedit', 'id', 'd') 22 | POI_FULL_CB = CallbackData('plst', 'query', 'ids') 23 | POI_HOUSE_CB = CallbackData('poih', 'house', 'floor') 24 | POI_STAR_CB = CallbackData('poistar', 'id', 'action') 25 | REVIEW_HOUSE_CB = CallbackData('hreview', 'house') 26 | 27 | 28 | class PoiState(StatesGroup): 29 | poi = State() 30 | poi_list = State() 31 | 32 | 33 | def star_sort(star: Tuple[int, bool]): 34 | """First sort by has user's, second by stars.""" 35 | if not star: 36 | return 0, 0 37 | if star[0] < 2: 38 | grade = 0 39 | elif star[0] < 5: 40 | grade = 1 41 | elif star[0] < 10: 42 | grade = 2 43 | elif star[0] < 20: 44 | grade = 3 45 | elif star[0] < 50: 46 | grade = 4 47 | else: 48 | grade = 5 49 | return 1 if star[1] else 0, grade 50 | 51 | 52 | async def print_poi_list(user: types.User, query: str, pois: List[POI], 53 | full: bool = False, shuffle: bool = True, 54 | relative_to: Location = None, comment: str = None): 55 | max_buttons = 9 if not full else 20 56 | location = (await get_user(user)).location or relative_to 57 | if shuffle: 58 | if location: 59 | pois.sort(key=lambda p: location.distance(p.location)) 60 | else: 61 | random.shuffle(pois) 62 | stars = await db.stars_for_poi_list(user.id, [p.id for p in pois]) 63 | if stars: 64 | pois.sort(key=lambda p: star_sort(stars.get(p.id)), reverse=True) 65 | pois.sort(key=lambda p: bool(p.hours) and not p.hours.is_open()) 66 | total_count = len(pois) 67 | all_ids = pack_ids([p.id for p in pois]) 68 | if total_count > max_buttons: 69 | pois = pois[:max_buttons if full else max_buttons - 1] 70 | 71 | # Build the message 72 | content = tr('poi_list', query) + '\n' 73 | for i, poi in enumerate(pois, 1): 74 | if poi.description: 75 | content += h(f'\n{i}. {poi.name} — {uncap(poi.description)}') 76 | else: 77 | content += h(f'\n{i}. {poi.name}') 78 | if poi.hours and not poi.hours.is_open(): 79 | content += ' 🌒' 80 | if total_count > max_buttons: 81 | if not full: 82 | content += '\n\n' + tr('poi_not_full', total_count=total_count) 83 | else: 84 | content += '\n\n' + tr('poi_too_many', total_count=total_count) 85 | if comment: 86 | content += '\n\n' + comment 87 | 88 | # Prepare the inline keyboard 89 | if len(pois) == 4: 90 | kbd_width = 2 91 | else: 92 | kbd_width = 4 if len(pois) > 9 else 3 93 | kbd = types.InlineKeyboardMarkup(row_width=kbd_width) 94 | for i, poi in enumerate(pois, 1): 95 | b_title = f'{i} {poi.name}' 96 | kbd.insert(types.InlineKeyboardButton( 97 | b_title, callback_data=POI_LIST_CB.new(id=poi.id))) 98 | if total_count > max_buttons and not full: 99 | try: 100 | callback_data = POI_FULL_CB.new(query=query[:55], ids=all_ids) 101 | except ValueError: 102 | # Too long 103 | callback_data = POI_FULL_CB.new(query=query[:55], ids='-') 104 | kbd.insert(types.InlineKeyboardButton( 105 | f'🔽 {config.MSG["all"]} {total_count}', callback_data=callback_data)) 106 | 107 | # Make a map and send the message 108 | map_file = get_map([poi.location for poi in pois], ref=location) 109 | if not map_file: 110 | await bot.send_message(user.id, content, parse_mode=HTML, reply_markup=kbd) 111 | else: 112 | await bot.send_photo( 113 | user.id, types.InputFile(map_file.name), 114 | caption=content, parse_mode=HTML, 115 | reply_markup=kbd) 116 | map_file.close() 117 | 118 | 119 | def relative_day(next_day): 120 | days = (next_day.date() - datetime.now().date()).days 121 | if days < 1: 122 | opens_day = '' 123 | elif days == 1: 124 | opens_day = tr('tomorrow') 125 | else: 126 | opens_day = tr('relative_days')[next_day.weekday()] 127 | return opens_day 128 | 129 | 130 | def describe_poi(poi: POI): 131 | deleted = '' if not poi.delete_reason else ' 🗑️' 132 | result = [f'{h(poi.name)}{deleted}'] 133 | if poi.description: 134 | result.append(h(poi.description)) 135 | 136 | part2 = [] 137 | if poi.hours: 138 | if poi.hours.is_24_7: 139 | part2.append('🌞 ' + tr('open_247')) 140 | elif poi.hours.is_open(): 141 | closes = poi.hours.next_change() 142 | open_now = '☀️ ' + tr('now_open', closes.strftime("%H:%M")) 143 | if (closes - datetime.now()).seconds <= 3600 * 2: 144 | opens = poi.hours.next_change(closes) 145 | open_now += ' ' + tr('next_open', day=relative_day(opens).capitalize(), 146 | hour=opens.strftime("%H:%M").lstrip("0")) 147 | part2.append(open_now) 148 | else: 149 | opens = poi.hours.next_change() 150 | part2.append('🌒 ' + tr('now_closed', day=relative_day(opens), 151 | hour=opens.strftime("%H:%M").lstrip("0"))) 152 | if poi.links and len(poi.links) > 1: 153 | part2.append('🌐 ' + tr('poi_links') + ': {}.'.format(', '.join( 154 | ['{}'.format(h(link[1]), h(link[0])) 155 | for link in poi.links] 156 | ))) 157 | if poi.house_name or poi.address_part: 158 | address = ', '.join( 159 | [s for s in (poi.house_name, uncap(poi.floor), uncap(poi.address_part)) if s]) 160 | part2.append(f'🏠 {address}.') 161 | if poi.has_wifi is True: 162 | part2.append('📶 ' + tr('has_wifi')) 163 | if poi.accepts_cards is True: 164 | part2.append('💳 ' + tr('accepts_cards')) 165 | elif poi.accepts_cards is False: 166 | part2.append('💰 ' + tr('no_cards')) 167 | if poi.phones: 168 | part2.append('📞 {}.'.format(', '.join( 169 | [re.sub(r'[^0-9+]', '', phone) for phone in poi.phones] 170 | ))) 171 | if part2: 172 | result.append('') 173 | result.extend(part2) 174 | 175 | if poi.comment: 176 | result.append('') 177 | result.append(poi.comment) 178 | return '\n'.join(result) 179 | 180 | 181 | async def make_poi_keyboard(user: types.User, poi: POI): 182 | buttons = [] 183 | stars, given_star = await db.count_stars(user.id, poi.id) 184 | if not given_star: 185 | star_button = '☆ ' + tr('star') 186 | else: 187 | star_button = '⭐ ' + tr('starred') 188 | buttons.append(types.InlineKeyboardButton( 189 | star_button, callback_data=POI_STAR_CB.new( 190 | id=poi.id, action='del' if given_star else 'set') 191 | )) 192 | 193 | buttons.append(types.InlineKeyboardButton( 194 | '📍 ' + tr('loc_btn'), callback_data=POI_LOCATION_CB.new(id=poi.id))) 195 | buttons.append(types.InlineKeyboardButton( 196 | '📝 ' + tr('edit_poi'), callback_data=POI_EDIT_CB.new(id=poi.id, d='0'))) 197 | 198 | if poi.links: 199 | link_dict = dict(poi.links) 200 | if tr('default_link') in link_dict: 201 | link_title = tr('open_link') 202 | link = link_dict[tr('default_link')] 203 | else: 204 | link_title = poi.links[0][0] 205 | link = poi.links[0][1] 206 | buttons.append(types.InlineKeyboardButton('🌐 ' + link_title, url=link)) 207 | 208 | if poi.tag and poi.tag not in ('building', 'entrance'): 209 | emoji = config.TAGS['emoji'].get(poi.tag, config.TAGS['emoji']['default']) 210 | buttons.append(types.InlineKeyboardButton( 211 | emoji + ' ' + tr('similar'), 212 | callback_data=POI_SIMILAR_CB.new(id=poi.id) 213 | )) 214 | 215 | kbd = types.InlineKeyboardMarkup(row_width=2 if len(buttons) < 5 else 3) 216 | kbd.add(*buttons) 217 | return kbd 218 | 219 | 220 | async def make_house_keyboard(user: types.User, poi: POI): 221 | if not poi.key: 222 | return None 223 | pois = await db.get_poi_by_house(poi.key) 224 | if not pois: 225 | return None 226 | 227 | kbd = types.InlineKeyboardMarkup().add( 228 | types.InlineKeyboardButton( 229 | tr('poi_in_house'), 230 | callback_data=POI_HOUSE_CB.new(house=poi.key, floor='-')) 231 | ) 232 | info = await get_user(user) 233 | if info.is_moderator(): 234 | # Suggest reviewing 235 | kbd.insert( 236 | types.InlineKeyboardButton( 237 | tr(('review', 'start')), 238 | callback_data=REVIEW_HOUSE_CB.new(house=poi.key)) 239 | ) 240 | return kbd 241 | 242 | 243 | def log_poi(poi: POI): 244 | row = [datetime.now().strftime('%Y-%m-%d'), poi.id, poi.name] 245 | try: 246 | with open(os.path.join(config.LOGS, 'poi.log'), 'a') as f: 247 | w = csv.writer(f, delimiter='\t') 248 | w.writerow(row) 249 | except IOError: 250 | logging.warning('Failed to write log line: %s', row) 251 | 252 | 253 | async def print_poi(user: types.User, poi: POI, comment: str = None, buttons: bool = True): 254 | log_poi(poi) 255 | chat_id = user.id 256 | content = describe_poi(poi) 257 | if comment: 258 | content += '\n\n' + h(comment) 259 | 260 | # Prepare photos 261 | photos = [] 262 | photo_names = [] 263 | for photo in [poi.photo_in, poi.photo_out]: 264 | if photo: 265 | path = os.path.join(config.PHOTOS, photo + '.jpg') 266 | if os.path.exists(path): 267 | file_ids = await db.find_file_ids({photo: os.path.getsize(path)}) 268 | if photo in file_ids: 269 | photos.append(file_ids[photo]) 270 | photo_names.append(None) 271 | else: 272 | photos.append(types.InputFile(path)) 273 | photo_names.append([photo, os.path.getsize(path)]) 274 | 275 | # Generate a map 276 | location = (await get_user(user)).location 277 | map_file = get_map([poi.location], location) 278 | if map_file: 279 | photos.append(types.InputFile(map_file.name)) 280 | photo_names.append(None) 281 | 282 | # Prepare the inline keyboard 283 | if poi.tag == 'building': 284 | kbd = await make_house_keyboard(user, poi) 285 | else: 286 | kbd = None if not buttons else await make_poi_keyboard(user, poi) 287 | 288 | # Send the message 289 | if not photos: 290 | msg = await bot.send_message(chat_id, content, parse_mode=HTML, 291 | reply_markup=kbd, disable_web_page_preview=True) 292 | elif len(photos) == 1: 293 | msg = await bot.send_photo(chat_id, photos[0], caption=content, parse_mode=HTML, 294 | reply_markup=kbd) 295 | else: 296 | media = types.MediaGroup() 297 | for i, photo in enumerate(photos): 298 | if not kbd and i == 0: 299 | photo = types.input_media.InputMediaPhoto( 300 | photo, caption=content, parse_mode=HTML) 301 | media.attach_photo(photo) 302 | if kbd: 303 | msg = await bot.send_media_group(chat_id, media=media) 304 | await bot.send_message(chat_id, content, parse_mode=HTML, 305 | reply_markup=kbd, disable_web_page_preview=True) 306 | else: 307 | msg = await bot.send_media_group(chat_id, media=media) 308 | if map_file: 309 | map_file.close() 310 | 311 | # Store file_ids for new photos 312 | if isinstance(msg, list): 313 | file_ids = [m.photo[-1].file_id for m in msg if m.photo] 314 | else: 315 | file_ids = [msg.photo[-1]] if msg.photo else [] 316 | for i, file_id in enumerate(file_ids): 317 | if photo_names[i]: 318 | await db.store_file_id(photo_names[i][0], photo_names[i][1], file_id) 319 | 320 | 321 | async def print_poi_by_key(user: types.User, poi_id: str, comment: str = None, 322 | buttons: bool = True): 323 | poi = await db.get_poi_by_key(poi_id) 324 | if not poi: 325 | await bot.send_message(user.id, f'Cannot find POI with id {poi_id}') 326 | else: 327 | await print_poi(user, poi, comment=comment, buttons=buttons) 328 | -------------------------------------------------------------------------------- /docs/3-poi.md: -------------------------------------------------------------------------------- 1 | # Сбор заведений 2 | 3 | Наконец-то мы готовы к сбору заведений! Если вы ещё нет, прочитайте 4 | [вторую главу](2-install.md). Предполагаем, что бот установлен — не важно, 5 | на сервере или на вашем компьютере, который вы не выключаете перед уходом. 6 | 7 | Поскольку вы будете обходить заведения и этим вызывать много вопросов, 8 | советую напечатать и нарезать минимум полсотни «визиток»: квадратиков 9 | бумаги, на которых написаны название и адрес бота, что он делает и как 10 | с вами связаться. Я раздавал такие: 11 | 12 | ![Визитка mayak\_nav\_bot](mayak_bot_visit.jpg) 13 | 14 | Зарядите телефон до 100%. Если он не слишком живучий, возьмите запасную батарейку. 15 | Убедитесь, что мобильный интернет работает, и введите команду для своего бота 16 | в телеграме, чтобы проверить, что он отвечает. Можно выходить! (Но сначала 17 | прочитайте инструкцию до конца, конечно.) 18 | 19 | ## Добавление заведения 20 | 21 | Введите название нового заведения, чтобы убедиться что его нет в базе. 22 | Уверены — вводите любую чушь. К сообщению об отсутствии результатов прилагается 23 | кнопка «Добавить заведение» — жмите. Дальше нужно будет ввести: 24 | 25 | 1. Название, как написано на вывеске. Совершенно нормально писать «Продукты» 26 | или «Цветы». Но желательно как-то различать похожие заведения: например, 27 | списать с таблички номер «Аптека №13» или юрлицо «Продукты "Мария"». 28 | 2. Координаты. Нажмите кнопку со скрепкой и выберите «Location» или 29 | «Геопозиция». Для верности включите спутниковую подложку (кнопка со слоями 30 | справа вверху) и передвиньте маркер как можно ближе ко входу в заведение. 31 | Отправляйте. 32 | 3. Ключевые слова — самое важное для поиска заведения. Введите через пробел 33 | как можно больше терминов, которые могут прийти в голову человеку, который 34 | ищет это заведение. Начните с вариантов транслитерации и опечаток: например, 35 | для магазина «Green» это может быть «грин gren grin». Затем опишите 36 | ассортимент или услуги, как общими, так и конкретными словами, если они 37 | просятся на язык: «бытовая химия мыло шампунь шампуни крем порошок стиральный». 38 | Не стесняйтесь добавлять словоформы («детей детям детские детский»): поисковый 39 | движок не умеет склонять слова. Чем больше — тем лучше, но не переборщите, 40 | чтобы не смешать воедино кафе и продуктовые магазины, например. 41 | 42 | Придумывая ключевые слова, помните, что потом вы сможете указать ещё и тип заведения. 43 | Не обязательно придумывать синонимы к слову «стрижка», если вы добавляете 44 | парикмахерскую: они уже связаны с этим типом заведения в файле `config/tags.yml`, 45 | в списке для тега `shop=hairdresser`. Часто в ключевых словах достаточно просклонять 46 | название и списать несколько терминов с фасада заведения. 47 | 48 | Ответив на три вопроса, вы перейдёте в режим редактирования созданного заведения. 49 | 50 | ## Редактирование заведения 51 | 52 | Этот режим открывается при создании заведения, либо при нажатии кнопки «Поправить» 53 | в карточке магазина или организации. При редактировании вы, по сути, заполняете полтора 54 | десятка полей в записи в базе данных: 55 | 56 | ![Сообщение редактора](raybot_edit.png) 57 | 58 | Нажмите на любую из команд `/e...`, чтобы изменить значение соответствующего поля. 59 | Некоторые из них — обычные строки, которые нужно будет ввести как есть (например, 60 | описание и местонахождение). Некоторые — переключатели между «да», «нет» и «не знаю» 61 | (wi-fi и оплата картой). Поля адреса (дома), этажа и тега предложат несколько вариантов, 62 | хотя в последние можно вписать и свой. А у часов работы и сайтов особый формат строк, 63 | чтобы упростить ввод на улице. 64 | 65 | В качестве сайта можно ввести самую основу: например, `Supershop. Ru`. Это превратится 66 | в `сайт https://supershop.ru`, где `сайт` — это ключ для сайта по умолчанию. Можно 67 | ввести много сайтов с разными ключами; главное — чтобы в ключе не было пробелов. 68 | Для инстаграма, vk и фейсбука сработает короткая форма с названием аккаунта: например, 69 | `инстаграм super_beauty.minsk`. 70 | 71 | Часы работы вводятся просто, но форму нужно запомнить. Бот подсказывает примеры и их, 72 | в принципе, достаточно: 73 | 74 | * `8-20`: если работает всю неделю, то достаточно двух чисел через дефис. 75 | * `пн-пт 10-19:30`: интервал дней недели можно указать и через пробел (`пн пт 10-20`), 76 | минуты пишутся с двоеточием. 77 | * `пн-чт 10-20 обед 13-14, пт-вс 10-18`: интервалы для разных дней недели соединяются 78 | запятыми. Например, `пн пт 10-20, сб 11-19`. Ключевое слово `обед` нужно для указания 79 | одного перерыва. 80 | 81 | Значение строкового поля можно очистить, введя единственный символ `-`. Если всё сломали 82 | и поздно что-то исправлять — не беда, понажимайте «Оставить как есть» и «Отменить». 83 | И главное — не забудьте нажать «Сохранить» после редактирования. 84 | 85 | Какие поля заполнять обязательно? Формально — только название и координаты. но желательно 86 | как минимум половину всех полей: 87 | 88 | - название (можно поправить секретной командой `/ename`) 89 | - координаты 90 | - ключевые слова 91 | - тег 92 | - адрес (дом), если у вас в базе есть дома 93 | - этаж, если заведений в доме много и они на нескольких этажах 94 | - время работы 95 | - хотя бы одну ссылку или телефон 96 | - фотографию снаружи (см. ниже) 97 | 98 | Не стесняйтесь заходить и спрашивать визитку или недостающие данные. Я ещё не встречал 99 | владельца или сотрудницу(-ка), которая была бы против добавления их магазина, заведения 100 | или организации в каталог. Не забудьте в обмен на информацию оставить визитку. 101 | 102 | ### Фотографии 103 | 104 | Добавить фотографию просто: прикрепите её в чатик (обязательно со сжатием!), и затем 105 | ответьте на вопрос, она сделана снаружи заведения, изнутри, или это ошибка и фото 106 | лучше удалить. Но где лучше сделать фотографии? 107 | 108 | Идея фотографий в том, что первая показывает, как на улице найти вход, а вторая — что 109 | вы пришли туда, куда нужно. То есть, в случае магазинов, в которые вход с улицы, 110 | фотография «снаружи» — это дверь магазина с тротуара. Впрочем, для заведений в торговых 111 | центрах, в которые много входов, тоже фотографируем дверь с улицы. Нужно отойти достаточно, 112 | чтобы по вывескам, номерам и прочему контексту было элементарно найти и определить 113 | правильный вход. Но не так далеко, чтобы вход был не видел, или на фото было несколько входов. 114 | 115 | Фотографию изнутри в теории делают прямо ото входа, чтобы было понятно, куда пришли. 116 | С другой стороны, полезнее на фотографии отразить ассортимент магазина или обстановку 117 | заведения. В некотором смысле фотография изнутри должна «продавать» заведение: по ней 118 | могут выбрать кафе из нескольких, или магазин игрушек по ассортименту. 119 | 120 | В торговом центре расстановка фотографий может поменяться. Если фото снаружи содержит 121 | вход в торговый центр, то фото изнутри может показывать фасад магазина из коридора 122 | торгового центра. Это полезнее ассортимента, потому что помогает вообще найти заведение, 123 | заметить его краем глаза издалека. Если фасад полупрозрачный, то даже лучше: две цели 124 | одной фоточкой. 125 | 126 | Чтобы переиспользовать фотографию входа от какого-то из соседних заведений (полезно 127 | в торговых центрах), нажмите `/eout` и выберите фото. 128 | 129 | ### Теги 130 | 131 | Выбрать тип для заведения — редко простая задача. Салон красоты или мастер по ногтям? 132 | Кафе или ресторан? Магазин цветов или подарков? Магазин сыра — это куда? 133 | 134 | Для типизации бот не изобретал велосипед, а использует систему тегирования OpenStreetMap. 135 | В ней используются теги: пары строк `ключ=значение`. Чтобы получить представление о том, 136 | что можно обозначить, прочитайте списки на страницах ключей 137 | [shop](https://wiki.openstreetmap.org/wiki/RU:Key:shop), 138 | [amenity](https://wiki.openstreetmap.org/wiki/RU:Key:shop), 139 | [craft](https://wiki.openstreetmap.org/wiki/RU:Key:shop), 140 | [tourism](https://wiki.openstreetmap.org/wiki/RU:Key:shop) и 141 | [leisure](https://wiki.openstreetmap.org/wiki/RU:Key:shop). Сложные вопросы часто 142 | решаются поиском термина по странице 143 | «[Как обозначить](https://wiki.openstreetmap.org/wiki/RU:%D0%9A%D0%B0%D0%BA_%D0%BE%D0%B1%D0%BE%D0%B7%D0%BD%D0%B0%D1%87%D0%B8%D1%82%D1%8C)». 144 | Наконец, посмотрите, какие ключевые слова соотнесены с тегами в файле 145 | `config/tags.yml`. 146 | 147 | Разумеется, модель OSM — не догма. После обхода своего района я добавил в классификацию 148 | теги, которых нет в OSM, но которые помогают разделить заведения по классам: 149 | 150 | * `amenity=art_school`, `dancing_school`, `driving_school`, `language_school`, 151 | `music_school` — понятно, занятия по рисованию, танцам, вождению, языкам, музыке. 152 | * `amenity=beauty_school` — уроки макияжа и маникюра 153 | * `amenity=coffee` — кофейня. Отличается от кафе отсутствием горячей еды: разве что 154 | булочку могут разогреть. 155 | * `amenity=school` означает кружок для школьников, а `amenity=childcare` — развивашки 156 | и просто передержку вместо детсада. 157 | * `shop=instagram_beauty` — салон красоты, таргетирующийся исключительно на пользователей 158 | инстаграма. Часто кроме названия аккаунта и телефона на вывеске ничего нет. 159 | * `shop=marriage_salon` — свадебный салон. 160 | * `shop=skin_care` — всякая эпиляция-депиляция, `shop=hair_care` — салон ухода за волосами, 161 | что не совсем парикмахерская. 162 | * `amenity=restaurant` убран в пользу `=cafe`, а `shop=supermarket` — в пользу `=convenience`. 163 | * `shop=veg_food` — вегетарианские и веганские продукты. 164 | 165 | Если не знаете, какой тег поставить, оставляйте поле пустым. Позже мы пройдёмся по всем 166 | заведениям и придумаем недостающие теги. 167 | 168 | ## Причёсываем базу 169 | 170 | Когда каждый дом обойдён, осталась самая малость: привести базу в порядок. Координаты, 171 | выбранные по спутниковому слою, неточны, какие-то поля забыли заполнить, тегов не хватает. 172 | Проще всего править объекты скопом не в телеграме, а на компьютере, в интерактивной карте. 173 | 174 | Отправьте боту команду `/admin`. Затем нажмите «База заведений», и там «Скачать заведения». 175 | Вам пришлют файл с названием типа `poi-120116.geojson`. Откройте его 176 | в [редакторе точек](https://zverik.github.io/point_ed/), который мы использовали 177 | на первом этапе. 178 | 179 | Редактировать точки в нём просто: тыкните в маркер, правьте 180 | атрибуты, двигайте этот маркер. Для сохранения жмите «Close», кнопку «Esc» или тыкайте 181 | в пустое место на карте. Думаю, названия атрибутов точек очевидны: посмотрите на несколько 182 | заведений, чтобы понять схему. 183 | 184 | На этом шаге нужно пользоваться строкой фильтра. Её формат похож на формат поиска в JOSM. 185 | Вот несколько примеров: 186 | 187 | * Заведения без адреса — `-house`. 188 | * С адресом, но без этажа — `house -floor`. 189 | * Заведения без тега — `-tag`. 190 | * Подъезды — `tag=entrance`. 191 | * Заведения со словом «вход» в местоположении — `address:вход`. 192 | * Заведения _без_ слова «вход» — `-address:вход`. 193 | * Салоны красоты со ссылкой на инстаграм — `tag=shop=beauty links:инстаграм`. 194 | * Салоны красоты без времени работы — `tag=shop=beauty -hours` 195 | 196 | Перетащите заведения, которые оказались на улице, в помещения, правильно расставьте 197 | ссылки на здания в теги `house` (если в базе есть дома). 198 | 199 | Когда работа закончена, скачайте результат («Download the result») и загрузите обратно в базу: 200 | наберите в боте `/admin`, затем «База заведений» и «Прислать файл». Туда и загружайте. 201 | Если будут ошибки, поправьте их и перезалейте файл снова. 202 | 203 | ### Классификация 204 | 205 | В разных городах и странах водятся разные виды магазинов и заведений. Поэтому классификация, 206 | которую сделали в боте для Минска, может не подойти условному Хабаровску. А в ваших собранных 207 | заведениях осталось много пропусков в тегах, потому что «в поле» было непонятно, куда отнести, 208 | да и знания тегов OpenStreetMap у вас не идеальны. 209 | 210 | Давайте выгрузим список заведений и их тегов (или пропусков вместо них) в таблицу. 211 | Пришлите боту команду `/admin`, затем «База заведений» и «Скачать теги». Вы получите файл 212 | CSV. Откройте его в Excel, LibreOffice Calc или Google Таблицах. Закрепите первую строку 213 | для удобства. Не трогайте первую колонку с идентификаторами. Мы будем работать только с третьей 214 | и с четвёртой. 215 | 216 | Заполните пропуски в третьей колонке, и для них укажите русское название категории в 217 | четвёртой. Используйте список в `config/tags.yml` для справки. Не стесняйтесь придумывать 218 | новые классы заведений. Когда пустых мест останется минимально, вспомним, зачем нам ещё теги: 219 | чтобы находить похожие заведения. Именно поэтому объединены кафе и рестораны: их не так много, 220 | чтобы лишать тех, кто ищет еду, половины списка. Так что попробуйте что-нибудь из нового 221 | объединить. 222 | 223 | Когда закончите, сохраните таблицу в CSV и загрузите её обратно в базу данных: команда 224 | `/admin`, «База заведений», «Прислать файл». После загрузки вы можете получить файл 225 | `new_tags.yml`. В нём строчки с новыми тегами для файла `config/tags.yml`. Скопируйте его 226 | в конец раздела `tags` и досыпьте ключевых слов каждой категории. Ну или переименуйте 227 | теги заведений, добавленные по ошибке. 228 | 229 | **Теперь база заведений должна быть в порядке. В принципе, бота в этот момент можно 230 | анонсировать. Но лучше прочитайте сначала [следующую главу](4-usage.md), чтобы 231 | уметь держать базу в порядке и отвечать на вопросы пользователей.** 232 | -------------------------------------------------------------------------------- /raybot/handlers/moderate.py: -------------------------------------------------------------------------------- 1 | import os 2 | import hashlib 3 | from collections import defaultdict 4 | from io import StringIO 5 | from datetime import datetime 6 | from tempfile import TemporaryDirectory 7 | from PIL import Image 8 | from raybot import config 9 | from raybot.model import db 10 | from raybot.bot import bot, dp 11 | from raybot.util import h, HTML, get_user, forget_user, tr 12 | from raybot.actions import transfer 13 | from raybot.actions.poi import print_poi, POI_EDIT_CB, print_poi_list, PoiState 14 | from typing import Dict 15 | from aiogram import types 16 | from aiogram.utils.callback_data import CallbackData 17 | from aiogram.utils.exceptions import TelegramAPIError 18 | from aiogram.dispatcher import FSMContext 19 | from aiogram.dispatcher.handler import SkipHandler 20 | from aiogram.dispatcher.filters.state import State, StatesGroup 21 | 22 | 23 | MSG_CB = CallbackData('qmsg', 'action', 'id') 24 | POI_VALIDATE_CB = CallbackData('qpoi', 'id') 25 | MOD_REMOVE_CB = CallbackData('modrm', 'id') 26 | ADMIN_CB = CallbackData('admin', 'action') 27 | 28 | 29 | class ModState(StatesGroup): 30 | mod = State() 31 | admin_upload = State() 32 | 33 | 34 | @dp.message_handler(commands='queue', state='*') 35 | async def print_queue(message: types.Message, state: FSMContext): 36 | allowed = await print_next_queued(message.from_user) 37 | if not allowed: 38 | raise SkipHandler 39 | 40 | 41 | async def print_next_added(user: types.User): 42 | info = await get_user(user) 43 | if not info.is_moderator(): 44 | return False 45 | poi = await db.get_next_unchecked() 46 | if not poi: 47 | await bot.send_message(user.id, tr(('queue', 'empty'))) 48 | return True 49 | await print_poi(user, poi) 50 | 51 | content = tr(('queue', 'new_poi')) 52 | kbd = types.InlineKeyboardMarkup().add( 53 | types.InlineKeyboardButton( 54 | '✔️ ' + tr(('queue', 'validated')), 55 | callback_data=POI_VALIDATE_CB.new(id=str(poi.id)) 56 | ) 57 | ) 58 | await bot.send_message(user.id, content, reply_markup=kbd) 59 | 60 | 61 | @dp.callback_query_handler(POI_VALIDATE_CB.filter(), state='*') 62 | async def validate_poi(query: types.CallbackQuery, callback_data: Dict[str, str]): 63 | poi = await db.get_poi_by_id(int(callback_data['id'])) 64 | if not poi: 65 | await query.answer(tr(('queue', 'poi_lost'))) 66 | return 67 | await db.validate_poi(poi.id) 68 | await query.answer(tr(('queue', 'validated_ok'))) 69 | await print_next_queued(query.from_user) 70 | 71 | 72 | async def print_next_queued(user: types.User): 73 | info = await get_user(user) 74 | if not info.is_moderator(): 75 | return False 76 | 77 | queue = await db.get_queue(1) 78 | if not queue: 79 | # This is done inside print_next_added() 80 | # await bot.send_message(user.id, tr(('queue', 'empty'))) 81 | await print_next_added(user) 82 | return True 83 | 84 | q = queue[0] 85 | poi = await db.get_poi_by_id(q.poi_id) 86 | if not poi: 87 | await bot.send_message(user.id, tr(('queue', 'poi_lost_del'))) 88 | await db.delete_queue(q) 89 | return True 90 | 91 | photo = None 92 | if q.field == 'message': 93 | content = tr(('queue', 'message'), user=h(q.user_name), name=h(poi.name)) 94 | content += f'\n\n{h(q.new_value)}' 95 | else: 96 | content = tr(('queue', 'field'), user=h(q.user_name), name=h(poi.name), field=q.field) 97 | content += '\n' 98 | nothing = '' + tr(('queue', 'nothing')) + '' 99 | vold = nothing if q.old_value is None else h(q.old_value) 100 | vnew = nothing if q.new_value is None else h(q.new_value) 101 | content += f'\n{tr(("queue", "old"))}: {vold}' 102 | content += f'\n{tr(("queue", "new"))}: {vnew}' 103 | if q.field in ('photo_in', 'photo_out') and q.new_value: 104 | photo = os.path.join(config.PHOTOS, q.new_value + '.jpg') 105 | if not os.path.exists(photo): 106 | photo = None 107 | 108 | kbd = types.InlineKeyboardMarkup(row_width=3) 109 | if q.field != 'message': 110 | kbd.insert(types.InlineKeyboardButton( 111 | '🔍 ' + tr(('queue', 'look')), 112 | callback_data=MSG_CB.new(action='look', id=str(q.id))) 113 | ) 114 | kbd.insert(types.InlineKeyboardButton( 115 | '✅ ' + tr(('queue', 'apply')), 116 | callback_data=MSG_CB.new(action='apply', id=str(q.id))) 117 | ) 118 | else: 119 | kbd.insert(types.InlineKeyboardButton( 120 | '📝 ' + tr('edit_poi'), callback_data=POI_EDIT_CB.new(id=q.poi_id, d='0'))) 121 | kbd.insert(types.InlineKeyboardButton( 122 | '❌ ' + tr(('queue', 'delete')), 123 | callback_data=MSG_CB.new(action='del', id=str(q.id))) 124 | ) 125 | 126 | if not photo: 127 | await bot.send_message(user.id, content, parse_mode=HTML, reply_markup=kbd) 128 | else: 129 | await bot.send_photo(user.id, types.InputFile(photo), caption=content, 130 | parse_mode=HTML, reply_markup=kbd) 131 | return True 132 | 133 | 134 | @dp.callback_query_handler(MSG_CB.filter(), state='*') 135 | async def process_queue(query: types.CallbackQuery, callback_data: Dict[str, str]): 136 | action = callback_data['action'] 137 | q = await db.get_queue_msg(int(callback_data['id'])) 138 | if not q: 139 | await query.answer(tr(('queue', 'missing'))) 140 | return 141 | 142 | if action == 'del': 143 | await db.delete_queue(q) 144 | await query.answer(tr(('queue', 'deleted'))) 145 | elif action == 'apply': 146 | await db.apply_queue(query.from_user.id, q) 147 | await query.answer(tr(('queue', 'applied'))) 148 | elif action == 'look': 149 | poi = await db.get_poi_by_id(q.poi_id) 150 | if not poi: 151 | await query.answer(tr(('queue', 'poi_lost_del'))) 152 | await db.delete_queue(q) 153 | else: 154 | await print_poi(query.from_user, poi, buttons=False) 155 | return 156 | else: 157 | await query.answer(f'Wrong queue action: "{action}"') 158 | 159 | await print_next_queued(query.from_user) 160 | 161 | 162 | @dp.message_handler(content_types=types.ContentType.ANY, state=ModState.mod) 163 | async def add_mod(message: types.Message, state: FSMContext): 164 | if message.from_user.id != config.ADMIN: 165 | raise SkipHandler 166 | if not message.is_forward(): 167 | await message.answer(tr(('admin', 'forward'))) 168 | return 169 | await state.finish() 170 | me = await get_user(message.from_user) 171 | new_user = await get_user(message.forward_from) 172 | if new_user.is_moderator(): 173 | await message.answer(tr(('admin', 'mod_already'))) 174 | return 175 | await db.add_user_to_role(new_user, 'moderator', me) 176 | forget_user(new_user.id) 177 | await message.answer(tr(('admin', 'mod_added'), new_user.name)) 178 | await bot.send_message(new_user.id, tr(('admin', 'mod_you'))) 179 | 180 | 181 | @dp.callback_query_handler(MOD_REMOVE_CB.filter(), state=ModState.mod) 182 | async def remove_mod(query: types.CallbackQuery, callback_data: Dict[str, str], 183 | state: FSMContext): 184 | if query.from_user.id != config.ADMIN: 185 | return 186 | await state.finish() 187 | user_id = callback_data['id'] 188 | if user_id != '-': 189 | await db.remove_user_from_role(int(user_id), 'moderator') 190 | forget_user(int(user_id)) 191 | await bot.send_message(query.from_user.id, tr(('admin', 'mod_removed'))) 192 | else: 193 | await query.answer('Ok') 194 | 195 | 196 | async def manage_mods(user: types.User, state: FSMContext): 197 | await state.finish() 198 | mods = await db.get_role_users('moderator') 199 | kbd = types.InlineKeyboardMarkup() 200 | if not mods: 201 | content = tr(('admin', 'no_mods')) 202 | else: 203 | content = tr(('admin', 'mod_list')) + ':\n\n' 204 | for i, mod in enumerate(mods, 1): 205 | content += f'{i}. {mod.name}\n' 206 | kbd.insert(types.InlineKeyboardButton( 207 | f'❌ {i} {mod.name}', callback_data=MOD_REMOVE_CB.new(id=str(mod.id)))) 208 | content += '\n' + tr(('admin', 'mod_help')) 209 | kbd.insert(types.InlineKeyboardButton( 210 | tr(('edit', 'cancel')), callback_data=MOD_REMOVE_CB.new(id='-'))) 211 | await bot.send_message(user.id, content, reply_markup=kbd) 212 | await ModState.mod.set() 213 | 214 | 215 | @dp.message_handler(commands='deleted', state='*') 216 | async def print_deleted(message: types.Message, state: FSMContext): 217 | pois = await db.get_last_deleted(6) 218 | if not pois: 219 | await message.answer(tr(('admin', 'no_deleted'))) 220 | return 221 | await PoiState.poi_list.set() 222 | await state.set_data({'query': 'deleted', 'poi': [p.id for p in pois]}) 223 | await print_poi_list(message.from_user, 'deleted', pois, shuffle=False) 224 | 225 | 226 | async def print_missing_value(user: types.User, k: str, state: FSMContext): 227 | pois = await db.poi_with_empty_value(k, buildings=k != 'house', 228 | entrances=k not in ('flor', 'keywords')) 229 | if not pois: 230 | await bot.send_message(user.id, tr(('admin', 'no_such'))) 231 | return 232 | await PoiState.poi_list.set() 233 | await state.set_data({'query': f'empty {k}', 'poi': [p.id for p in pois]}) 234 | await print_poi_list(user, f'empty {k}', pois, shuffle=False) 235 | 236 | 237 | async def print_audit(user: types.User): 238 | content = tr(('admin', 'audit')) + ':' 239 | last_audit = await db.get_last_audit(15) 240 | for a in last_audit: 241 | user_name = a.user_name if a.user_id == a.approved_by else str(a.user_id) 242 | if user_name is None: 243 | user_name = tr(('admin', 'admin')) 244 | line = f'{a.ts.strftime("%Y-%m-%d %H:%S")} {user_name}' 245 | if a.approved_by != a.user_id: 246 | line += ' (' + tr(('admin', 'confirmed_by'), a.user_name) + ')' 247 | if a.field == 'poi': 248 | line += ' ' + tr(('admin', 'created' if not a.old_value else 'deleted')) 249 | line += f' «{a.poi_name}» /poi{a.poi_id}' 250 | else: 251 | line += (' ' + tr(('admin', 'modified')) + f' «{a.poi_name}» /poi{a.poi_id} ' + 252 | tr(('admin', 'field')) + f' {a.field}: "{a.old_value}" → "{a.new_value}"') 253 | content += '\n\n' + h(line) + '.' 254 | await bot.send_message(user.id, content, disable_web_page_preview=True) 255 | 256 | 257 | async def dedup_photos(): 258 | def hashall(photos): 259 | result = defaultdict(list) 260 | for photo in photos: 261 | path = os.path.join(config.PHOTOS, photo + '.jpg') 262 | image = Image.open(path) 263 | h = hashlib.md5() 264 | h.update(image.tobytes()) 265 | result[h.digest()].append(photo) 266 | return result 267 | 268 | conn = await db.get_db() 269 | cursor = await conn.execute("select id, photo_out, photo_in from poi order by id") 270 | photos = set() 271 | refs = {} 272 | async for row in cursor: 273 | for i in ('photo_out', 'photo_in'): 274 | if row[i]: 275 | photos.add(row[i]) 276 | refs[(row[i], i)] = row['id'] 277 | 278 | # Find sizes 279 | sizes = defaultdict(list) 280 | for photo in photos: 281 | path = os.path.join(config.PHOTOS, photo + '.jpg') 282 | if os.path.exists(path): 283 | sizes[os.path.getsize(path)].append(photo) 284 | 285 | # Remove duplicates 286 | removed = 0 287 | hashes = hashall(sum(sizes.values(), [])) 288 | for s, ph in hashes.items(): 289 | if len(ph) > 1: 290 | for k in ('photo_out', 'photo_in'): 291 | ids = [refs[p, k] for p in ph[1:] if (p, k) in refs] 292 | await conn.execute("update poi set {} = ? where id in ({})".format( 293 | k, ','.join('?' * len(ids))), (ph[0], *ids)) 294 | for photo in ph[1:]: 295 | path = os.path.join(config.PHOTOS, photo + '.jpg') 296 | os.remove(path) 297 | removed += 1 298 | await conn.commit() 299 | return removed 300 | 301 | 302 | async def delete_unused_photos(): 303 | photos = set() 304 | for name in os.listdir(config.PHOTOS): 305 | if name.endswith('.jpg'): 306 | photos.add(name.rsplit('.', 1)[0]) 307 | for predef in config.RESP['responses']: 308 | if 'photo' in predef: 309 | photos.discard(predef['photo'].rsplit('.', 1)[0]) 310 | 311 | conn = await db.get_db() 312 | cursor = await conn.execute( 313 | "select name, photo_out, photo_in from poi where photo_out is not null " 314 | "or photo_in is not null") 315 | async for row in cursor: 316 | for c in (1, 2): 317 | if row[c]: 318 | photos.discard(row[c]) 319 | for name in photos: 320 | path = os.path.join(config.PHOTOS, name + '.jpg') 321 | os.remove(path) 322 | return len(photos) 323 | 324 | 325 | @dp.message_handler(state=ModState.admin_upload, content_types=types.ContentType.DOCUMENT) 326 | async def upload_document(message: types.Message, state: FSMContext): 327 | tmp_dir = TemporaryDirectory(prefix='raybot') 328 | file_id = message.document.file_id 329 | try: 330 | f = await bot.get_file(file_id) 331 | path = os.path.join(tmp_dir.name, 'telegram_file') 332 | await f.download(path) 333 | except TelegramAPIError: 334 | tmp_dir.cleanup() 335 | await message.answer(tr(('editor', 'upload_fail'))) 336 | return 337 | if not os.path.exists(path): 338 | tmp_dir.cleanup() 339 | await message.answer(tr(('editor', 'upload_fail'))) 340 | return 341 | 342 | file_type = transfer.get_file_type(path) 343 | try: 344 | if file_type == 'geojson': 345 | with open(path, 'r') as f: 346 | await transfer.import_geojson(f) 347 | await message.answer( 348 | tr(('admin', 'up_json')) + ' ' + tr(('admin', 'no_maintenance'))) 349 | elif file_type == 'tags': 350 | with open(path, 'r') as f: 351 | yaml = await transfer.import_tags(f) 352 | if yaml: 353 | doc = types.InputFile(yaml, filename='new_tags.yml') 354 | await message.answer_document( 355 | doc, caption=tr(('admin', 'tags_caption'))) 356 | yaml.close() 357 | await message.answer( 358 | tr(('admin', 'up_csv')) + ' ' + tr(('admin', 'no_maintenance'))) 359 | else: 360 | raise ValueError(tr(('admin', 'unknown_file'))) 361 | except Exception as e: 362 | await message.answer(tr(('admin', 'error'), e)) 363 | return 364 | finally: 365 | tmp_dir.cleanup() 366 | config.MAINTENANCE = False 367 | await state.finish() 368 | 369 | 370 | @dp.message_handler(commands='admin', state='*') 371 | async def admin_info(message: types.Message): 372 | info = await get_user(message.from_user) 373 | if not info.is_moderator(): 374 | raise SkipHandler 375 | kbd = types.InlineKeyboardMarkup(row_width=2) 376 | if info.id == config.ADMIN: 377 | kbd.insert(types.InlineKeyboardButton(tr(('admin_menu', 'mods')), 378 | callback_data=ADMIN_CB.new(action='mod'))) 379 | kbd.insert(types.InlineKeyboardButton(tr(('admin_menu', 'dedup')), 380 | callback_data=ADMIN_CB.new(action='dedup'))) 381 | kbd.insert(types.InlineKeyboardButton(tr(('admin_menu', 'unused')), 382 | callback_data=ADMIN_CB.new(action='unused'))) 383 | kbd.insert(types.InlineKeyboardButton(tr(('admin_menu', 'base')), 384 | callback_data=ADMIN_CB.new(action='base'))) 385 | kbd.insert(types.InlineKeyboardButton(tr(('admin_menu', 'audit')), 386 | callback_data=ADMIN_CB.new(action='audit'))) 387 | kbd.insert(types.InlineKeyboardButton(tr(('admin_menu', 'reindex')), 388 | callback_data=ADMIN_CB.new(action='reindex'))) 389 | kbd.row( 390 | types.InlineKeyboardButton(tr(('admin_menu', 'no_house')), 391 | callback_data=ADMIN_CB.new(action='mis-house')), 392 | types.InlineKeyboardButton(tr(('admin_menu', 'no_floor')), 393 | callback_data=ADMIN_CB.new(action='mis-floor')), 394 | types.InlineKeyboardButton(tr(('admin_menu', 'no_photo')), 395 | callback_data=ADMIN_CB.new(action='mis-photo')), 396 | ) 397 | kbd.row( 398 | types.InlineKeyboardButton(tr(('admin_menu', 'no_tag')), 399 | callback_data=ADMIN_CB.new(action='mis-tag')), 400 | types.InlineKeyboardButton(tr(('admin_menu', 'no_keywords')), 401 | callback_data=ADMIN_CB.new(action='mis-keywords')), 402 | ) 403 | await message.answer(tr(('admin_menu', 'msg')), reply_markup=kbd) 404 | 405 | 406 | @dp.callback_query_handler(ADMIN_CB.filter(), state='*') 407 | async def admin_command(query: types.CallbackQuery, callback_data: Dict[str, str], 408 | state: FSMContext): 409 | user = query.from_user 410 | info = await get_user(user) 411 | if not info.is_moderator(): 412 | raise SkipHandler 413 | action = callback_data['action'] 414 | if action == 'mod' and user.id == config.ADMIN: 415 | await manage_mods(user, state) 416 | return 417 | elif action == 'reindex': 418 | await db.reindex() 419 | await bot.send_message(user.id, tr(('admin_menu', 'reindexed'))) 420 | elif action == 'dedup' and user.id == config.ADMIN: 421 | cnt = await dedup_photos() 422 | await bot.send_message(query.from_user.id, tr(('admin_menu', 'deduped'), cnt)) 423 | elif action == 'unused' and user.id == config.ADMIN: 424 | cnt = await delete_unused_photos() 425 | await bot.send_message(query.from_user.id, tr(('admin_menu', 'del_unused'), cnt)) 426 | elif action == 'audit': 427 | await print_audit(user) 428 | elif action == 'mis-house': 429 | await print_missing_value(user, 'house', state) 430 | elif action == 'mis-photo': 431 | await print_missing_value(user, 'photo_out', state) 432 | elif action == 'mis-floor': 433 | await print_missing_value(user, 'flor', state) 434 | elif action == 'mis-keywords': 435 | await print_missing_value(user, 'keywords', state) 436 | elif action == 'mis-tag': 437 | await print_missing_value(user, 'tag', state) 438 | elif action == 'base': 439 | # Print a submenu 440 | kbd = types.InlineKeyboardMarkup(row_width=2) 441 | kbd.insert(types.InlineKeyboardButton(tr(('admin_base', 'down_json')), 442 | callback_data=ADMIN_CB.new(action='down-json'))) 443 | kbd.insert(types.InlineKeyboardButton(tr(('admin_base', 'down_tags')), 444 | callback_data=ADMIN_CB.new(action='down-tags'))) 445 | kbd.insert(types.InlineKeyboardButton(tr(('admin_base', 'upload')), 446 | callback_data=ADMIN_CB.new(action='upload'))) 447 | kbd.insert(types.InlineKeyboardButton( 448 | tr(('admin_base', 'freeze' if not config.MAINTENANCE else 'unfreeze')), 449 | callback_data=ADMIN_CB.new(action='maintenance'))) 450 | await bot.edit_message_reply_markup( 451 | query.from_user.id, query.message.message_id, reply_markup=kbd) 452 | elif action == 'upload' and user.id == config.ADMIN: 453 | await bot.send_message(query.from_user.id, tr(('admin_base', 'send_file'))) 454 | await ModState.admin_upload.set() 455 | elif action == 'down-json' and user.id == config.ADMIN: 456 | f = StringIO() 457 | await transfer.export_geojson(f) 458 | f.seek(0) 459 | date = datetime.now().strftime('%y%m%d') 460 | doc = types.InputFile(f, filename=f'poi-{date}.geojson') 461 | caption = tr(('admin_base', 'down_json')) + ' ' + tr(('admin_base', 'maintenance')) 462 | await bot.send_document(query.from_user.id, doc, caption=caption) 463 | config.MAINTENANCE = True 464 | f.close() 465 | elif action == 'down-tags' and user.id == config.ADMIN: 466 | f = StringIO() 467 | await transfer.export_tags(f) 468 | f.seek(0) 469 | date = datetime.now().strftime('%y%m%d') 470 | doc = types.InputFile(f, filename=f'tags-{date}.csv') 471 | caption = tr(('admin_base', 'down_tags')) + ' ' + tr(('admin_base', 'maintenance')) 472 | await bot.send_document(query.from_user.id, doc, caption=caption) 473 | config.MAINTENANCE = True 474 | f.close() 475 | elif action == 'maintenance' and user.id == config.ADMIN: 476 | config.MAINTENANCE = not config.MAINTENANCE 477 | if config.MAINTENANCE: 478 | await query.answer(tr(('admin_base', 'maintenance'))) 479 | else: 480 | await query.answer(tr(('admin_base', 'no_maintenance'))) 481 | else: 482 | await query.answer(tr(('admin_menu', 'wrong_action'), action)) 483 | -------------------------------------------------------------------------------- /raybot/model/db.py: -------------------------------------------------------------------------------- 1 | import aiosqlite 2 | import logging 3 | import os 4 | import json 5 | from raybot import config 6 | from .entities import POI, UserInfo, QueueMessage, Location 7 | from typing import List, Dict, Tuple 8 | 9 | 10 | _db = None 11 | 12 | 13 | async def get_db(): 14 | global _db 15 | if _db is not None and _db._running: 16 | return _db 17 | _db = await aiosqlite.connect(config.DATABASE) 18 | _db.row_factory = aiosqlite.Row 19 | exists_query = ("select count(*) from sqlite_master where type = 'table' " 20 | "and name in ('poi', 'poisearch', 'roles')") 21 | async with _db.execute(exists_query) as cursor: 22 | has_tables = (await cursor.fetchone())[0] == 3 23 | if not has_tables: 24 | logging.info('Creating tables') 25 | with open(os.path.join(os.path.dirname(__file__), 'create_tables.sql'), 'r') as f: 26 | queries = [q.strip() for q in f.read().split(';')] 27 | for q in queries: 28 | if q: 29 | await _db.execute(q) 30 | return _db 31 | 32 | 33 | async def close(): 34 | if _db is not None and _db._running: 35 | await _db.close() 36 | 37 | 38 | async def get_poi_by_id(poi_id: int) -> POI: 39 | query = ("select poi.*, h.name as h_address from poi " 40 | "left join poi h on h.str_id = poi.house where poi.id = ?") 41 | db = await get_db() 42 | cursor = await db.execute(query, (poi_id,)) 43 | row = await cursor.fetchone() 44 | return None if not row else POI(row) 45 | 46 | 47 | async def get_poi_by_ids(poi_ids: List[int]) -> POI: 48 | query = "select * from poi where id in ({})".format(','.join('?' * len(poi_ids))) 49 | db = await get_db() 50 | cursor = await db.execute(query, tuple(poi_ids)) 51 | return [POI(r) async for r in cursor] 52 | 53 | 54 | async def get_poi_by_house(house: str, floor: str = None) -> POI: 55 | """Pass '-' for floor to query only empty floors.""" 56 | query = ("select * from poi where house = ? and in_index and delete_reason is null " 57 | "and (tag is null or tag not in ('entrance', 'building'))") 58 | if floor == '-': 59 | query += " and flor is null" 60 | args = (house, ) 61 | if floor: 62 | query += " and flor = ?" 63 | args = (house, floor) 64 | else: 65 | args = (house,) 66 | db = await get_db() 67 | cursor = await db.execute(query, args) 68 | return [POI(r) async for r in cursor] 69 | 70 | 71 | async def get_poi_by_tag(tag: str) -> POI: 72 | query = "select * from poi where tag = ? and delete_reason is null" 73 | db = await get_db() 74 | cursor = await db.execute(query, (tag,)) 75 | return [POI(r) async for r in cursor] 76 | 77 | 78 | async def get_poi_by_key(str_id: str) -> POI: 79 | query = "select * from poi where str_id = ?" 80 | db = await get_db() 81 | cursor = await db.execute(query, (str_id,)) 82 | row = await cursor.fetchone() 83 | return None if not row else POI(row) 84 | 85 | 86 | async def get_floors_by_house(house: str) -> POI: 87 | if not house: 88 | return [] 89 | query = ("select distinct flor from poi where house = ? and in_index " 90 | "and delete_reason is null " 91 | "and (tag is null or tag not in ('entrance', 'building'))") 92 | db = await get_db() 93 | cursor = await db.execute(query, (house,)) 94 | return [r[0] async for r in cursor] 95 | 96 | 97 | async def count_stars(user_id: int, poi_id: int) -> Tuple[int, bool]: 98 | """Returns start count and whether the user have given a star.""" 99 | query = "select count(*), user_id = ? from stars where poi_id = ? group by user_id = ?" 100 | db = await get_db() 101 | cursor = await db.execute(query, (user_id, poi_id, user_id)) 102 | rows = await cursor.fetchall() 103 | has_users = len(rows) > 1 or (rows and rows[0][1] == 1) 104 | return sum((r[0] for r in rows)), has_users 105 | 106 | 107 | async def stars_for_poi_list(user_id: int, poi_ids: List[int]) -> List[Tuple[int, bool]]: 108 | query = ("select poi_id, count(*), user_id = ? from stars where poi_id in ({}) " 109 | "group by 1, user_id = ?".format(','.join('?' * len(poi_ids)))) 110 | db = await get_db() 111 | cursor = await db.execute(query, (user_id, *poi_ids, user_id)) 112 | result = {} 113 | async for row in cursor: 114 | if row[0] not in result: 115 | result[row[0]] = (row[1], row[2] == 1) 116 | else: 117 | result[row[0]] = (row[1] + result[row[0]][0], True) 118 | return result 119 | 120 | 121 | async def get_starred_poi(user_id: int) -> List[POI]: 122 | query = ("select * from poi where delete_reason is null and " 123 | "id in (select poi_id from stars where user_id = ?)") 124 | db = await get_db() 125 | cursor = await db.execute(query, (user_id,)) 126 | return [POI(r) async for r in cursor] 127 | 128 | 129 | async def get_popular_poi(count: int = 10, min_stars: int = 2, top: int = 30) -> List[POI]: 130 | # TODO: Choose only among top N pois by stars. 131 | # query = ("""\ 132 | # with popular as (select poi_id, count(*) as stars from stars 133 | # group by poi_id having count(*) >= ? order by stars desc limit {}) 134 | # select * from poi left join popular on poi_id = poi.id 135 | # where delete_reason is null and stars is not null 136 | # order by random() limit {}""".format(top, count)) 137 | 138 | query = ("select * from poi where delete_reason is null and " 139 | "id in (select poi_id from stars group by poi_id having count(*) >= ?) " 140 | "order by random() limit {}".format(count)) 141 | db = await get_db() 142 | cursor = await db.execute(query, (min_stars,)) 143 | return [POI(r) async for r in cursor] 144 | 145 | 146 | async def set_star(user_id: int, poi_id: int, star: bool): 147 | db = await get_db() 148 | if star: 149 | query = "insert or ignore into stars (poi_id, user_id) values (?, ?)" 150 | else: 151 | query = "delete from stars where poi_id = ? and user_id = ?" 152 | await db.execute(query, (poi_id, user_id)) 153 | await db.commit() 154 | 155 | 156 | async def get_poi_around(loc: Location, count: int = 40, floor: str = None, 157 | dist: int = 100) -> List[POI]: 158 | # We don't have a spatial index, so we're just inventing a bounding box 159 | # approximately 100 × 100 meters. 160 | args = [] 161 | if floor == '-': 162 | qfloor = 'flor is null and' 163 | elif floor is not None: 164 | qfloor = 'flor = ? and' 165 | args.append(floor) 166 | else: 167 | qfloor = '' 168 | query = (f"select * from poi where {qfloor} " 169 | "lat > ? and lat < ? and lon > ? and lon < ? " 170 | "and (tag is null or tag not in ('building', 'entrance')) " 171 | "and delete_reason is null") 172 | radius = 0.001 173 | args.extend([loc.lat - radius, loc.lat + radius, loc.lon - radius, loc.lon + radius]) 174 | db = await get_db() 175 | cursor = await db.execute(query, tuple(args)) 176 | pois = [POI(r) async for r in cursor] 177 | pois = sorted([p for p in pois if loc.distance(p.location) <= dist], 178 | key=lambda p: loc.distance(p.location)) 179 | return pois[:count] 180 | 181 | 182 | async def find_poi(keywords: str) -> List[POI]: 183 | query = ("select poi.*, h.name as h_address from poi " 184 | "left join poi h on h.str_id = poi.house " 185 | "where poi.in_index and poi.delete_reason is null and " 186 | "poi.rowid in (select docid from poisearch where poisearch match ?)") 187 | db = await get_db() 188 | cursor = await db.execute(query, (keywords,)) 189 | return [POI(r) async for r in cursor] 190 | 191 | 192 | async def poi_with_empty_value(field: str, buildings: bool = False, 193 | entrances: bool = True) -> List[POI]: 194 | no_buildings = "and (poi.tag is null or poi.tag != 'building') " 195 | no_entrances = "and (poi.tag is null or poi.tag not in ('building', 'entrance')) " 196 | needs_floor = ("and poi.house in (select distinct house from poi " 197 | "where house is not null and flor is not null " 198 | "and in_index and delete_reason is null) ") 199 | query = ("select poi.*, h.name as h_address from poi " 200 | "left join poi h on h.str_id = poi.house " 201 | "where poi.in_index and poi.delete_reason is null " 202 | "{b}{e}{f}" 203 | "and poi.{k} is null order by updated desc".format( 204 | k=field, b='' if buildings else no_buildings, 205 | e='' if entrances else no_entrances, 206 | f=needs_floor if field == 'flor' else '')) 207 | db = await get_db() 208 | cursor = await db.execute(query) 209 | return [POI(r) async for r in cursor] 210 | 211 | 212 | async def get_roles(user_id: int) -> List[str]: 213 | query = "select role from roles where user_id = ?" 214 | db = await get_db() 215 | cursor = await db.execute(query, (user_id,)) 216 | return [r[0] async for r in cursor] 217 | 218 | 219 | async def get_role_users(role: str) -> List[UserInfo]: 220 | query = "select user_id, name from roles where role = ?" 221 | db = await get_db() 222 | cursor = await db.execute(query, (role,)) 223 | return [UserInfo(user_id=r[0], user_name=r[1]) async for r in cursor] 224 | 225 | 226 | async def add_user_to_role(user: UserInfo, role: str, added_by: UserInfo): 227 | query = "insert into roles (user_id, name, role, added_by) values (?, ?, ?, ?)" 228 | db = await get_db() 229 | await db.execute(query, (user.id, user.name, role, added_by.name)) 230 | await db.commit() 231 | 232 | 233 | async def remove_user_from_role(user_id: int, role: str): 234 | query = "delete from roles where user_id = ? and role = ?" 235 | db = await get_db() 236 | await db.execute(query, (user_id, role)) 237 | await db.commit() 238 | 239 | 240 | async def get_entrances(building: str) -> List[str]: 241 | query = "select str_id from poi where house = ? and tag = 'entrance'" 242 | db = await get_db() 243 | cursor = await db.execute(query, (building,)) 244 | return [r[0] async for r in cursor] 245 | 246 | 247 | async def store_file_id(path: str, size: int, file_id: str) -> None: 248 | query = "insert or ignore into file_ids (path, size, file_id) values (?, ?, ?)" 249 | db = await get_db() 250 | await db.execute(query, (path, size, file_id)) 251 | await db.commit() 252 | 253 | 254 | async def find_file_ids(paths: Dict[str, int]) -> Dict[str, str]: 255 | """Parameter 1: dict of "file path" -> file size.""" 256 | fpaths = [p for p in paths.keys() if p] 257 | if not fpaths: 258 | return {} 259 | query = "select path, size, file_id from file_ids where path in ({})".format( 260 | ','.join('?' * len(fpaths))) 261 | db = await get_db() 262 | cursor = await db.execute(query, tuple(fpaths)) 263 | return {r['path']: r['file_id'] async for r in cursor 264 | if r['size'] == paths[r['path']]} 265 | 266 | 267 | async def find_path_for_file_id(file_id: str) -> str: 268 | db = await get_db() 269 | query = "select path from file_ids where file_id = ? limit 1" 270 | cursor = await db.execute(query, (file_id,)) 271 | row = await cursor.fetchone() 272 | return None if not row else row[0] 273 | 274 | 275 | async def get_houses() -> List[POI]: 276 | query = "select * from poi where str_id is not null and tag = 'building'" 277 | db = await get_db() 278 | cursor = await db.execute(query) 279 | return [POI(r) async for r in cursor] 280 | 281 | 282 | async def insert_poi(user_id: int, poi: POI): 283 | if poi.id is not None: 284 | return await update_poi(user_id, poi) 285 | 286 | # Insert the row 287 | fields = poi.get_db_fields() 288 | query = "insert into poi ({}) values ({})".format( 289 | ','.join(fields.keys()), 290 | ','.join('?' * len(fields)) 291 | ) 292 | db = await get_db() 293 | await db.execute(query, tuple(fields.values())) 294 | 295 | # Update audit 296 | cursor = await db.execute("select last_insert_rowid()") 297 | rowid = (await cursor.fetchone())[0] 298 | poi.id = rowid 299 | await save_audit(user_id, user_id, None, poi) 300 | 301 | # Now update the search index 302 | tagkw = ' '.join(config.TAGS['tags'].get(poi.tag, [])) or None 303 | query2 = ("insert into poisearch (docid, name, keywords, tag) " 304 | "select rowid, replace(replace(name, 'Ё', 'Е'), 'ё', 'е') as name, " 305 | " replace(keywords, 'ё', 'е') as keywords, ? " 306 | "from poi where id = ?") 307 | await db.execute(query2, (tagkw, rowid)) 308 | await db.commit() 309 | return poi.id 310 | 311 | 312 | async def update_poi(user_id: int, poi: POI): 313 | orig = await get_poi_by_id(poi.id) 314 | fields = poi.get_db_fields(orig) 315 | if not fields: 316 | return poi.id 317 | 318 | query = "update poi set {}, updated = current_timestamp where id = ?".format( 319 | ','.join([f'{k} = ?' for k in fields.keys()])) 320 | db = await get_db() 321 | await db.execute(query, (*fields.values(), poi.id)) 322 | await save_audit(user_id, user_id, orig, poi) 323 | if 'keywords' in fields or 'tag' in fields or 'name' in fields: 324 | tagkw = ' '.join(config.TAGS['tags'].get(poi.tag, [])) or None 325 | query2 = ("update poisearch set keywords = ?, name = ?, " 326 | "tag = ? where docid = ?") 327 | kw = None if not poi.keywords else poi.keywords.lower().replace('ё', 'е') 328 | await db.execute(query2, (kw, poi.name.replace('Ё', 'Е').replace('ё', 'е'), 329 | tagkw, poi.id)) 330 | await db.commit() 331 | return poi.id 332 | 333 | 334 | async def delete_poi(user_id: int, poi: POI, reason: str): 335 | db = await get_db() 336 | query = ("insert into poi_audit (user_id, approved_by, poi_id, field, " 337 | "old_value, new_value) values (?, ?, ?, 'delete_reason', ?, ?)") 338 | await db.execute(query, (user_id, user_id, poi.id, None, reason)) 339 | await db.execute("delete from poisearch where docid = ?", (poi.id,)) 340 | await db.execute("update poi set delete_reason = ?, updated = current_timestamp " 341 | "where id = ?", (reason, poi.id)) 342 | await db.commit() 343 | 344 | 345 | async def delete_poi_forever(user_id: int, poi: POI): 346 | db = await get_db() 347 | save_audit(user_id, user_id, poi, None) 348 | await db.execute("delete from poisearch where docid = ?", (poi.id,)) 349 | await db.execute("delete from poi where id = ?", (poi.id,)) 350 | await db.commit() 351 | 352 | 353 | async def restore_poi(user_id: int, poi: POI): 354 | db = await get_db() 355 | query = ("insert into poi_audit (user_id, approved_by, poi_id, field, " 356 | "old_value, new_value) values (?, ?, ?, 'delete_reason', ?, ?)") 357 | await db.execute(query, (user_id, user_id, poi.id, poi.delete_reason, None)) 358 | await db.execute("update poi set delete_reason = null, updated = current_timestamp " 359 | "where id = ?", (poi.id, )) 360 | tagkw = ' '.join(config.TAGS['tags'].get(poi.tag, [])) or None 361 | query2 = "insert into poisearch (keywords, name, tag, docid) values (?, ?, ?, ?)" 362 | kw = None if not poi.keywords else poi.keywords.lower().replace('ё', 'е') 363 | await db.execute(query2, (kw, poi.name.replace('Ё', 'Е').replace('ё', 'е'), 364 | tagkw, poi.id)) 365 | await db.commit() 366 | 367 | 368 | async def save_audit(user_id: int, approved_by: int, oldpoi: POI, poi: POI): 369 | """Warning: does not do db.commit().""" 370 | db = await get_db() 371 | if oldpoi is None: 372 | query = ("insert into poi_audit (user_id, approved_by, poi_id, field, new_value) " 373 | "values (?, ?, ?, 'poi', ?)") 374 | data = json.dumps(poi.get_db_fields()) 375 | await db.execute(query, (user_id, approved_by, poi.id, data)) 376 | elif poi is None: 377 | query = ("insert into poi_audit (user_id, approved_by, poi_id, field, old_value) " 378 | "values (?, ?, ?, 'poi', ?)") 379 | data = json.dumps(oldpoi.get_db_fields()) 380 | await db.execute(query, (user_id, approved_by, oldpoi.id, data)) 381 | else: 382 | query = ("insert into poi_audit (user_id, approved_by, poi_id, field, " 383 | "old_value, new_value) values (?, ?, ?, ?, ?, ?)") 384 | old_fields = oldpoi.get_db_fields() 385 | fields = poi.get_db_fields(oldpoi) 386 | for field in fields: 387 | await db.execute(query, (user_id, approved_by, poi.id, field, 388 | old_fields[field], fields[field])) 389 | 390 | 391 | async def add_to_queue(user: UserInfo, poi: POI, message: str = None): 392 | if poi.id is None: 393 | raise ValueError(f'POI id should not be None. Msg = "{message}"') 394 | db = await get_db() 395 | if message: 396 | query = ("insert into queue (user_id, user_name, poi_id, field, new_value) " 397 | "values (?, ?, ?, 'message', ?)") 398 | await db.execute(query, (user.id, user.name, poi.id, message)) 399 | else: 400 | query = ("insert into queue (user_id, user_name, poi_id, field, old_value, new_value) " 401 | "values (?, ?, ?, ?, ?, ?)") 402 | orig = await get_poi_by_id(poi.id) 403 | old_fields = orig.get_db_fields() 404 | fields = poi.get_db_fields(orig) 405 | for field in fields: 406 | await db.execute(query, (user.id, user.name, poi.id, field, 407 | old_fields[field], fields[field])) 408 | await db.commit() 409 | 410 | 411 | async def get_queue(count: int = 1): 412 | query = f"select * from queue order by ts desc limit {count}" 413 | db = await get_db() 414 | cursor = await db.execute(query) 415 | return [QueueMessage(r) async for r in cursor] 416 | 417 | 418 | async def get_last_audit(count: int = 10): 419 | query = ("select a.*, r.name as user_name, poi.name as poi_name " 420 | "from poi_audit a left join roles r on r.user_id = a.approved_by " 421 | "left join poi on poi.id = a.poi_id " 422 | f"order by a.ts desc limit {count}") 423 | db = await get_db() 424 | cursor = await db.execute(query) 425 | return [QueueMessage(r) async for r in cursor] 426 | 427 | 428 | async def get_queue_msg(qid: int): 429 | query = f"select * from queue where id = ?" 430 | db = await get_db() 431 | cursor = await db.execute(query, (qid,)) 432 | row = await cursor.fetchone() 433 | return None if not row else QueueMessage(row) 434 | 435 | 436 | async def delete_queue(q: QueueMessage): 437 | db = await get_db() 438 | await db.execute("delete from queue where id = ?", (q.id,)) 439 | await db.commit() 440 | 441 | 442 | async def apply_queue(user_id: int, q: QueueMessage): 443 | db = await get_db() 444 | query = "update poi set {} = ?, updated = current_timestamp where id = ?".format(q.field) 445 | await db.execute(query, (q.new_value, q.poi_id)) 446 | query = ("insert into poi_audit (user_id, approved_by, poi_id, field, " 447 | "old_value, new_value) values (?, ?, ?, ?, ?, ?)") 448 | if q.field == 'keywords': 449 | query2 = ("update poisearch set keywords = (select replace(keywords, 'ё', 'е') " 450 | "from poi where id = ?) where docid = ?") 451 | await db.execute(query2, (q.poi_id, q.poi_id)) 452 | elif q.field == 'tag': 453 | tagkw = ' '.join(config.TAGS['tags'].get(q.new_value, [])) or None 454 | query2 = "update poisearch set tag = ? where docid = ?" 455 | await db.execute(query2, (tagkw, q.poi_id)) 456 | await db.execute(query, (q.user_id, user_id, q.poi_id, q.field, q.old_value, q.new_value)) 457 | await db.execute("delete from queue where id = ?", (q.id,)) 458 | await db.commit() 459 | 460 | 461 | async def get_next_unchecked(): 462 | query = "select * from poi where needs_check order by created limit 1" 463 | db = await get_db() 464 | cursor = await db.execute(query) 465 | row = await cursor.fetchone() 466 | return None if not row else POI(row) 467 | 468 | 469 | async def validate_poi(poi_id: int): 470 | query = "update poi set needs_check = 0 where id = ?" 471 | db = await get_db() 472 | await db.execute(query, (poi_id,)) 473 | await db.commit() 474 | 475 | 476 | async def get_last_poi(count: int = 1): 477 | db = await get_db() 478 | query = ("select * from poi where delete_reason is null " 479 | f"order by created desc limit {count}") 480 | db = await get_db() 481 | cursor = await db.execute(query) 482 | return [POI(r) async for r in cursor] 483 | 484 | 485 | async def get_last_deleted(count: int = 1): 486 | db = await get_db() 487 | query = ("select * from poi where delete_reason is not null " 488 | f"order by updated desc limit {count}") 489 | db = await get_db() 490 | cursor = await db.execute(query) 491 | return [POI(r) async for r in cursor] 492 | 493 | 494 | async def get_random_poi(count: int = 10): 495 | db = await get_db() 496 | query = ("select * from poi where id in (select id from poi " 497 | "where (tag is null or tag not in ('building', 'entrance')) " 498 | f"and delete_reason is null order by random() limit {count})") 499 | db = await get_db() 500 | cursor = await db.execute(query) 501 | return [POI(r) async for r in cursor] 502 | 503 | 504 | async def get_stats(): 505 | stats = {} 506 | db = await get_db() 507 | cursor = await db.execute("select count(*) from poi where tag = 'building'") 508 | stats['buildings'] = (await cursor.fetchone())[0] 509 | cursor = await db.execute("select count(*) from poi where tag = 'entrance'") 510 | stats['entrances'] = (await cursor.fetchone())[0] 511 | cursor = await db.execute("select count(*) from poi where delete_reason is null " 512 | "and (tag is null or tag not in ('building', 'entrance'))") 513 | stats['pois'] = (await cursor.fetchone())[0] 514 | cursor = await db.execute("select count(*) from stars") 515 | stats['stars'] = (await cursor.fetchone())[0] 516 | return stats 517 | 518 | 519 | async def reindex(): 520 | conn = await get_db() 521 | await conn.execute("delete from poisearch") 522 | # Create temporary tag table 523 | await conn.execute("create table tag_keywords (tag text not null, tagkw text not null)") 524 | await conn.executemany("insert into tag_keywords (tag, tagkw) values (?, ?)", 525 | [(k, ' '.join(v)) for k, v in config.TAGS['tags'].items()]) 526 | await conn.execute( 527 | "insert into poisearch (docid, name, keywords, tag) " 528 | "select poi.rowid, replace(replace(name, 'Ё', 'Е'), 'ё', 'е') as name, " 529 | " replace(keywords, 'ё', 'е') as keywords, tagkw as tag from poi " 530 | "left join tag_keywords on poi.tag = tag_keywords.tag " 531 | "where in_index and delete_reason is null" 532 | ) 533 | await conn.execute("drop table tag_keywords") 534 | await conn.commit() 535 | 536 | 537 | async def get_poi_ages(poi_ids: List[int]) -> Dict[int, int]: 538 | """Receives a list of poi and returns a dict poi_id -> age in hours.""" 539 | query = ("select id, strftime('%s', current_timestamp) - strftime('%s', updated) " 540 | "from poi where id in ({})".format(','.join('?' * len(poi_ids)))) 541 | db = await get_db() 542 | cursor = await db.execute(query, tuple(poi_ids)) 543 | return {r[0]: round(r[1] / 3600) async for r in cursor} 544 | 545 | 546 | async def set_updated(poi_id: int, updated: str = None) -> str: 547 | db = await get_db() 548 | cursor = await db.execute("select updated from poi where id = ?", (poi_id,)) 549 | old = await cursor.fetchone() 550 | if not updated: 551 | await db.execute("update poi set updated = current_timestamp where id = ?", (poi_id,)) 552 | else: 553 | await db.execute("update poi set updated = ? where id = ?", (updated, poi_id)) 554 | await db.commit() 555 | return old[0] 556 | --------------------------------------------------------------------------------