├── .gitignore ├── Dockerfile ├── LICENSE ├── Procfile ├── README.md ├── alembic.ini ├── alembic ├── README ├── env.py ├── script.py.mako └── versions │ ├── 215a632cdf23_.py │ ├── 2e8d45ad3ac3_.py │ ├── 97bba2b7506c_.py │ └── f5f69376d382_.py ├── app.json ├── app.py ├── docs └── index.md ├── requirements.txt ├── runtime.txt ├── scripts └── migrate └── vk_channelify ├── __init__.py ├── manage_worker.py ├── models ├── __init__.py ├── channel.py ├── disabled_channel.py └── time_stamp_mixin.py ├── repost_worker.py └── vk_errors.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### JetBrains template 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 4 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 5 | 6 | # User-specific stuff: 7 | .idea/**/workspace.xml 8 | .idea/**/tasks.xml 9 | .idea/dictionaries 10 | 11 | # Sensitive or high-churn files: 12 | .idea/**/dataSources/ 13 | .idea/**/dataSources.ids 14 | .idea/**/dataSources.xml 15 | .idea/**/dataSources.local.xml 16 | .idea/**/sqlDataSources.xml 17 | .idea/**/dynamic.xml 18 | .idea/**/uiDesigner.xml 19 | 20 | # Gradle: 21 | .idea/**/gradle.xml 22 | .idea/**/libraries 23 | 24 | # Mongo Explorer plugin: 25 | .idea/**/mongoSettings.xml 26 | 27 | ## File-based project format: 28 | *.iws 29 | 30 | ## Plugin-specific files: 31 | 32 | # IntelliJ 33 | /out/ 34 | 35 | # mpeltonen/sbt-idea plugin 36 | .idea_modules/ 37 | 38 | # JIRA plugin 39 | atlassian-ide-plugin.xml 40 | 41 | # Crashlytics plugin (for Android Studio and IntelliJ) 42 | com_crashlytics_export_strings.xml 43 | crashlytics.properties 44 | crashlytics-build.properties 45 | fabric.properties 46 | ### VirtualEnv template 47 | # Virtualenv 48 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 49 | .Python 50 | [Bb]in 51 | [Ii]nclude 52 | [Ll]ib 53 | [Ll]ib64 54 | [Ll]ocal 55 | pyvenv.cfg 56 | .venv 57 | pip-selfcheck.json 58 | ### Python template 59 | # Byte-compiled / optimized / DLL files 60 | __pycache__/ 61 | *.py[cod] 62 | *$py.class 63 | 64 | # C extensions 65 | *.so 66 | 67 | # Distribution / packaging 68 | .Python 69 | env/ 70 | build/ 71 | develop-eggs/ 72 | dist/ 73 | downloads/ 74 | eggs/ 75 | .eggs/ 76 | lib/ 77 | lib64/ 78 | parts/ 79 | sdist/ 80 | var/ 81 | wheels/ 82 | *.egg-info/ 83 | .installed.cfg 84 | *.egg 85 | 86 | # PyInstaller 87 | # Usually these files are written by a python script from a template 88 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 89 | *.manifest 90 | *.spec 91 | 92 | # Installer logs 93 | pip-log.txt 94 | pip-delete-this-directory.txt 95 | 96 | # Unit test / coverage reports 97 | htmlcov/ 98 | .tox/ 99 | .coverage 100 | .coverage.* 101 | .cache 102 | nosetests.xml 103 | coverage.xml 104 | *,cover 105 | .hypothesis/ 106 | 107 | # Translations 108 | *.mo 109 | *.pot 110 | 111 | # Django stuff: 112 | *.log 113 | local_settings.py 114 | 115 | # Flask stuff: 116 | instance/ 117 | .webassets-cache 118 | 119 | # Scrapy stuff: 120 | .scrapy 121 | 122 | # Sphinx documentation 123 | docs/_build/ 124 | 125 | # PyBuilder 126 | target/ 127 | 128 | # Jupyter Notebook 129 | .ipynb_checkpoints 130 | 131 | # pyenv 132 | .python-version 133 | 134 | # celery beat schedule file 135 | celerybeat-schedule 136 | 137 | # SageMath parsed files 138 | *.sage.py 139 | 140 | # dotenv 141 | .env 142 | 143 | # virtualenv 144 | .venv 145 | venv/ 146 | ENV/ 147 | 148 | # Spyder project settings 149 | .spyderproject 150 | 151 | # Rope project settings 152 | .ropeproject 153 | 154 | 155 | .idea 156 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY requirements.txt ./ 6 | RUN pip install --no-cache-dir -r requirements.txt 7 | 8 | COPY . . 9 | 10 | EXPOSE 80 11 | ENV PORT=80 12 | ENV PYTHONPATH=/usr/src/app 13 | CMD ["python", "app.py"] 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2017 Oleg Morozenkov 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: python app.py -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vk-channelify 2 | 3 | Пересылает новости из группы ВК в Телеграм канал. 4 | 5 | Предполагается, что 1 канал = 1 группа ВК. 6 | 7 | Ссылка: [@vk_channelify_bot](https://t.me/vk_channelify_bot) 8 | 9 | ## Как это работает 10 | 11 | ![](https://imgur.com/5rQ8eY5.png) 12 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # max length of characters to apply to the 11 | # "slug" field 12 | #truncate_slug_length = 40 13 | 14 | # set to 'true' to run the environment during 15 | # the 'revision' command, regardless of autogenerate 16 | # revision_environment = false 17 | 18 | # set to 'true' to allow .pyc and .pyo files without 19 | # a source .py file to be detected as revisions in the 20 | # versions/ directory 21 | # sourceless = false 22 | 23 | # version location specification; this defaults 24 | # to alembic/versions. When using multiple version 25 | # directories, initial revisions must be specified with --version-path 26 | # version_locations = %(here)s/bar %(here)s/bat alembic/versions 27 | 28 | # the output encoding used when revision files 29 | # are written from script.py.mako 30 | # output_encoding = utf-8 31 | 32 | # sqlalchemy.url = driver://user:pass@localhost/dbname 33 | 34 | 35 | # Logging configuration 36 | [loggers] 37 | keys = root,sqlalchemy,alembic 38 | 39 | [handlers] 40 | keys = console 41 | 42 | [formatters] 43 | keys = generic 44 | 45 | [logger_root] 46 | level = WARN 47 | handlers = console 48 | qualname = 49 | 50 | [logger_sqlalchemy] 51 | level = WARN 52 | handlers = 53 | qualname = sqlalchemy.engine 54 | 55 | [logger_alembic] 56 | level = INFO 57 | handlers = 58 | qualname = alembic 59 | 60 | [handler_console] 61 | class = StreamHandler 62 | args = (sys.stderr,) 63 | level = NOTSET 64 | formatter = generic 65 | 66 | [formatter_generic] 67 | format = %(levelname)-5.5s [%(name)s] %(message)s 68 | datefmt = %H:%M:%S 69 | -------------------------------------------------------------------------------- /alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /alembic/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import os 4 | 5 | from alembic import context 6 | from logging.config import fileConfig 7 | from sqlalchemy import create_engine 8 | 9 | # this is the Alembic Config object, which provides 10 | # access to the values within the .ini file in use. 11 | config = context.config 12 | 13 | # Interpret the config file for Python logging. 14 | # This line sets up loggers basically. 15 | fileConfig(config.config_file_name) 16 | 17 | # add your model's MetaData object here 18 | # for 'autogenerate' support 19 | # from myapp import mymodel 20 | # target_metadata = mymodel.Base.metadata 21 | from vk_channelify import models 22 | target_metadata = models.Base.metadata 23 | 24 | # other values from the config, defined by the needs of env.py, 25 | # can be acquired: 26 | # my_important_option = config.get_main_option("my_important_option") 27 | # ... etc. 28 | 29 | 30 | def run_migrations_offline(): 31 | """Run migrations in 'offline' mode. 32 | 33 | This configures the context with just a URL 34 | and not an Engine, though an Engine is acceptable 35 | here as well. By skipping the Engine creation 36 | we don't even need a DBAPI to be available. 37 | 38 | Calls to context.execute() here emit the given string to the 39 | script output. 40 | 41 | """ 42 | url = os.getenv('DATABASE_URL') 43 | context.configure( 44 | url=url, target_metadata=target_metadata, literal_binds=True) 45 | 46 | with context.begin_transaction(): 47 | context.run_migrations() 48 | 49 | 50 | def run_migrations_online(): 51 | """Run migrations in 'online' mode. 52 | 53 | In this scenario we need to create an Engine 54 | and associate a connection with the context. 55 | 56 | """ 57 | connectable = create_engine(os.getenv('DATABASE_URL')) 58 | 59 | with connectable.connect() as connection: 60 | context.configure( 61 | connection=connection, 62 | target_metadata=target_metadata 63 | ) 64 | 65 | with context.begin_transaction(): 66 | context.run_migrations() 67 | 68 | if context.is_offline_mode(): 69 | run_migrations_offline() 70 | else: 71 | run_migrations_online() 72 | -------------------------------------------------------------------------------- /alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /alembic/versions/215a632cdf23_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 215a632cdf23 4 | Revises: f5f69376d382 5 | Create Date: 2017-07-09 17:04:22.228617 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '215a632cdf23' 14 | down_revision = 'f5f69376d382' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('disabled_channels', sa.Column('channel_id', sa.String(), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('disabled_channels', 'channel_id') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /alembic/versions/2e8d45ad3ac3_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 2e8d45ad3ac3 4 | Revises: 97bba2b7506c 5 | Create Date: 2017-07-10 01:04:39.286233 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '2e8d45ad3ac3' 14 | down_revision = '97bba2b7506c' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | pass 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | pass 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /alembic/versions/97bba2b7506c_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 97bba2b7506c 4 | Revises: 215a632cdf23 5 | Create Date: 2017-07-09 17:05:36.617297 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '97bba2b7506c' 14 | down_revision = '215a632cdf23' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.alter_column('channels', 'owner_id', 22 | existing_type=sa.VARCHAR(), 23 | nullable=False) 24 | op.alter_column('disabled_channels', 'channel_id', 25 | existing_type=sa.VARCHAR(), 26 | nullable=False) 27 | op.alter_column('disabled_channels', 'last_vk_post_id', 28 | existing_type=sa.INTEGER(), 29 | nullable=False) 30 | op.alter_column('disabled_channels', 'owner_id', 31 | existing_type=sa.VARCHAR(), 32 | nullable=False) 33 | op.alter_column('disabled_channels', 'vk_group_id', 34 | existing_type=sa.VARCHAR(), 35 | nullable=False) 36 | # ### end Alembic commands ### 37 | 38 | 39 | def downgrade(): 40 | # ### commands auto generated by Alembic - please adjust! ### 41 | op.alter_column('disabled_channels', 'vk_group_id', 42 | existing_type=sa.VARCHAR(), 43 | nullable=True) 44 | op.alter_column('disabled_channels', 'owner_id', 45 | existing_type=sa.VARCHAR(), 46 | nullable=True) 47 | op.alter_column('disabled_channels', 'last_vk_post_id', 48 | existing_type=sa.INTEGER(), 49 | nullable=True) 50 | op.alter_column('disabled_channels', 'channel_id', 51 | existing_type=sa.VARCHAR(), 52 | nullable=True) 53 | op.alter_column('channels', 'owner_id', 54 | existing_type=sa.VARCHAR(), 55 | nullable=True) 56 | # ### end Alembic commands ### 57 | -------------------------------------------------------------------------------- /alembic/versions/f5f69376d382_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: f5f69376d382 4 | Revises: 5 | Create Date: 2017-07-09 15:30:59.186558 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'f5f69376d382' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('channels', 22 | sa.Column('channel_id', sa.String(), nullable=False), 23 | sa.Column('vk_group_id', sa.String(), nullable=False), 24 | sa.Column('last_vk_post_id', sa.Integer(), server_default='0', nullable=False), 25 | sa.Column('owner_id', sa.String(), nullable=True), 26 | sa.Column('owner_username', sa.String(), nullable=True), 27 | sa.Column('hashtag_filter', sa.String(), nullable=True), 28 | sa.Column('created_at', sa.DateTime(), nullable=False), 29 | sa.Column('updated_at', sa.DateTime(), nullable=False), 30 | sa.PrimaryKeyConstraint('channel_id') 31 | ) 32 | op.create_table('disabled_channels', 33 | sa.Column('id', sa.Integer(), nullable=False), 34 | sa.Column('vk_group_id', sa.String(), nullable=True), 35 | sa.Column('last_vk_post_id', sa.Integer(), nullable=True), 36 | sa.Column('owner_id', sa.String(), nullable=True), 37 | sa.Column('owner_username', sa.String(), nullable=True), 38 | sa.Column('hashtag_filter', sa.String(), nullable=True), 39 | sa.Column('created_at', sa.DateTime(), nullable=False), 40 | sa.Column('updated_at', sa.DateTime(), nullable=False), 41 | sa.PrimaryKeyConstraint('id') 42 | ) 43 | # ### end Alembic commands ### 44 | 45 | 46 | def downgrade(): 47 | # ### commands auto generated by Alembic - please adjust! ### 48 | op.drop_table('disabled_channels') 49 | op.drop_table('channels') 50 | # ### end Alembic commands ### 51 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vk-channelify", 3 | "scripts": { 4 | "dokku": { 5 | "predeploy": "PYTHONPATH=. alembic upgrade head" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import logging 4 | 5 | from vk_channelify import models, run_manage_worker, run_repost_worker 6 | 7 | if __name__ == '__main__': 8 | telegram_token = os.getenv('TELEGRAM_TOKEN') 9 | vk_token = os.getenv('VK_TOKEN') 10 | db_url = os.getenv('DATABASE_URL') 11 | use_webhook = bool(int(os.getenv('USE_WEBHOOK', False))) 12 | webhook_domain = os.getenv('WEBHOOK_DOMAIN', '127.0.0.1') 13 | webhook_port = int(os.getenv('WEBHOOK_PORT', os.getenv('PORT', 80))) 14 | vk_thread_delay = int(os.getenv('REPOST_DELAY', 15 * 60)) # 15 minutes 15 | 16 | logging.basicConfig(level=logging.INFO) 17 | 18 | db_session_maker = models.make_session_maker(db_url) 19 | telegram_updater = run_manage_worker(telegram_token, db_session_maker, use_webhook, webhook_domain, webhook_port) 20 | repost_thread = run_repost_worker(vk_thread_delay, vk_token, telegram_token, db_session_maker) 21 | 22 | telegram_updater.idle() 23 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # vk-channelify 2 | 3 | Пересылает новости из группы ВК в Телеграм канал. 4 | 5 | Предполагается, что 1 канал = 1 группа ВК. 6 | 7 | Ссылка: [@vk_channelify_bot](https://t.me/vk_channelify_bot) 8 | 9 | ## Как это работает 10 | 11 | ![](https://imgur.com/5rQ8eY5.png) 12 | 13 | --- 14 | 15 | [Telegram](https://t.me/olezhes) 16 | 17 | [Исходный код](https://github.com/reo7sp/vk-channelify) 18 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | SQLAlchemy==1.3 2 | alembic==1.2 3 | psycopg2==2.8 4 | psycopg2-binary 5 | requests==2.22 6 | python-telegram-bot==12.0 7 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.6.1 -------------------------------------------------------------------------------- /scripts/migrate: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # CWD: vk-channelify repo 4 | 5 | alembic upgrade head 6 | -------------------------------------------------------------------------------- /vk_channelify/__init__.py: -------------------------------------------------------------------------------- 1 | from . import models 2 | from .manage_worker import run_worker as run_manage_worker 3 | from .repost_worker import run_worker as run_repost_worker 4 | -------------------------------------------------------------------------------- /vk_channelify/manage_worker.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | from functools import partial 3 | 4 | import logging 5 | import telegram 6 | from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove 7 | from telegram.ext import CommandHandler, Updater, ConversationHandler, Filters, MessageHandler, RegexHandler 8 | 9 | from . import models 10 | from .models import Channel, DisabledChannel 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | ASKED_VK_GROUP_LINK_IN_NEW, ASKED_CHANNEL_ACCESS_IN_NEW, ASKED_CHANNEL_MESSAGE_IN_NEW, \ 15 | ASKED_CHANNEL_ID_IN_FILTER_BY_HASHTAG, ASKED_HASHTAGS_IN_FILTER_BY_HASHTAG, \ 16 | ASKED_CHANNEL_ID_IN_RECOVER = list(range(6)) 17 | 18 | 19 | def run_worker(telegram_token, db_session_maker, use_webhook, webhook_domain='', webhook_port=''): 20 | users_state = dict() 21 | 22 | updater = Updater(telegram_token) 23 | 24 | dp = updater.dispatcher 25 | dp.add_error_handler(on_error) 26 | dp.add_handler(CommandHandler('start', start)) 27 | dp.add_handler(ConversationHandler( 28 | entry_points=[CommandHandler('new', new)], 29 | states={ 30 | ASKED_VK_GROUP_LINK_IN_NEW: [ 31 | RegexHandler('^https://vk.com/', partial(new_in_state_asked_vk_group_link, 32 | users_state=users_state)) 33 | ], 34 | ASKED_CHANNEL_ACCESS_IN_NEW: [ 35 | RegexHandler('^Я сделал$', new_in_state_asked_channel_access) 36 | ], 37 | ASKED_CHANNEL_MESSAGE_IN_NEW: [ 38 | MessageHandler(Filters.forwarded, partial(new_in_state_asked_channel_message, 39 | db_session_maker=db_session_maker, users_state=users_state)) 40 | ] 41 | }, 42 | allow_reentry=True, 43 | fallbacks=[CommandHandler('cancel', partial(cancel_new, users_state=users_state))] 44 | )) 45 | dp.add_handler(ConversationHandler( 46 | entry_points=[CommandHandler('filter_by_hashtag', partial(filter_by_hashtag, 47 | db_session_maker=db_session_maker, users_state=users_state))], 48 | states={ 49 | ASKED_CHANNEL_ID_IN_FILTER_BY_HASHTAG: [ 50 | MessageHandler(Filters.text, partial(filter_by_hashtag_in_state_asked_channel_id, 51 | db_session_maker=db_session_maker, users_state=users_state)) 52 | ], 53 | ASKED_HASHTAGS_IN_FILTER_BY_HASHTAG: [ 54 | MessageHandler(Filters.text, partial(filter_by_hashtag_in_state_asked_hashtags, 55 | db_session_maker=db_session_maker, users_state=users_state)) 56 | ] 57 | }, 58 | allow_reentry=True, 59 | fallbacks=[CommandHandler('cancel', partial(cancel_filter_by_hashtag, 60 | users_state=users_state))] 61 | )) 62 | dp.add_handler(ConversationHandler( 63 | entry_points=[CommandHandler('recover', partial(recover, 64 | db_session_maker=db_session_maker, users_state=users_state))], 65 | states={ 66 | ASKED_CHANNEL_ID_IN_RECOVER: [ 67 | MessageHandler(Filters.text, partial(recover_in_state_asked_channel_id, 68 | db_session_maker=db_session_maker, users_state=users_state)) 69 | ] 70 | }, 71 | allow_reentry=True, 72 | fallbacks=[CommandHandler('cancel', partial(cancel_recover, 73 | users_state=users_state))] 74 | )) 75 | 76 | if use_webhook: 77 | logger.info('Starting webhook at {}:{}'.format(webhook_domain, webhook_port)) 78 | updater.start_webhook('0.0.0.0', webhook_port, telegram_token) 79 | updater.bot.set_webhook('https://{}/{}'.format(webhook_domain, telegram_token)) 80 | else: 81 | logger.info('Starting long poll') 82 | updater.start_polling() 83 | 84 | return updater 85 | 86 | 87 | def del_state(update, users_state): 88 | if update.message.from_user.id in users_state: 89 | del users_state[update.message.from_user.id] 90 | 91 | 92 | def on_error(bot, update, error): 93 | logger.error('Update "{}" caused error "{}"'.format(update, error)) 94 | traceback.print_exc() 95 | 96 | if update is not None: 97 | update.message.reply_text('Внутренняя ошибка') 98 | update.message.reply_text('{}: {}'.format(type(error).__name__, str(error))) 99 | update.message.reply_text('Сообщите @olezhes') 100 | 101 | 102 | def catch_exceptions(func): 103 | def wrapper(bot, update, *args, **kwargs): 104 | try: 105 | return func(bot, update, *args, **kwargs) 106 | except Exception as e: 107 | on_error(bot, update, e) 108 | 109 | return wrapper 110 | 111 | 112 | def make_db_session(func): 113 | def wrapper(*args, db_session_maker, **kwargs): 114 | db = db_session_maker() 115 | result = func(*args, **kwargs, db=db) 116 | db.close() 117 | return result 118 | 119 | return wrapper 120 | 121 | 122 | @catch_exceptions 123 | def start(bot, update): 124 | update.message.reply_text('Команда /new настроит новый канал. В канал будут пересылаться посты из группы ВК') 125 | 126 | 127 | @catch_exceptions 128 | def new(bot, update): 129 | update.message.reply_text('Отправьте ссылку на группу ВК') 130 | return ASKED_VK_GROUP_LINK_IN_NEW 131 | 132 | 133 | @catch_exceptions 134 | def new_in_state_asked_vk_group_link(bot, update, users_state): 135 | vk_url = update.message.text 136 | vk_domain = vk_url.split('/')[-1] 137 | users_state[update.message.from_user.id] = dict() 138 | users_state[update.message.from_user.id]['vk_domain'] = vk_domain 139 | 140 | update.message.reply_text('Отлично! Теперь:') 141 | update.message.reply_text('1. Создайте новый канал. Можно использовать существующий') 142 | keyboard = [['Я сделал']] 143 | update.message.reply_text('2. Добавьте этого бота (@vk_channelify_bot) в администраторы канала', 144 | reply_markup=ReplyKeyboardMarkup(keyboard, one_time_keyboard=True)) 145 | return ASKED_CHANNEL_ACCESS_IN_NEW 146 | 147 | 148 | @catch_exceptions 149 | def new_in_state_asked_channel_access(bot, update): 150 | update.message.reply_text('Хорошо. Перешлите любое сообщение из канала', reply_markup=ReplyKeyboardRemove()) 151 | return ASKED_CHANNEL_MESSAGE_IN_NEW 152 | 153 | 154 | @catch_exceptions 155 | @make_db_session 156 | def new_in_state_asked_channel_message(bot, update, db, users_state): 157 | user_id = update.message.from_user.id 158 | username = update.message.from_user.username 159 | channel_id = str(update.message.forward_from_chat.id) 160 | vk_group_id = users_state[user_id]['vk_domain'] 161 | 162 | try: 163 | channel = Channel(channel_id=channel_id, vk_group_id=vk_group_id, owner_id=user_id, owner_username=username) 164 | db.add(channel) 165 | db.commit() 166 | except: 167 | db.rollback() 168 | raise 169 | 170 | try: 171 | db.query(DisabledChannel).filter(DisabledChannel.channel_id == channel_id).delete() 172 | except: 173 | logger.warning('Cannot delete disabled channel of {}'.format(channel_id)) 174 | traceback.print_exc() 175 | 176 | bot.send_message(channel_id, 'Канал работает с помощью @vk_channelify_bot') 177 | 178 | update.message.reply_text('Готово!') 179 | update.message.reply_text('Бот будет проверять группу каждые 15 минут') 180 | update.message.reply_text('Настроить фильтр по хештегам можно командой /filter_by_hashtag') 181 | update.message.reply_text('Команда /new настроит новый канал') 182 | 183 | del_state(update, users_state) 184 | return ConversationHandler.END 185 | 186 | 187 | @catch_exceptions 188 | def cancel_new(bot, update, users_state): 189 | update.message.reply_text('Ладно', reply_markup=ReplyKeyboardRemove()) 190 | update.message.reply_text('Команда /new настроит новый канал') 191 | 192 | del_state(update, users_state) 193 | return ConversationHandler.END 194 | 195 | 196 | @catch_exceptions 197 | @make_db_session 198 | def filter_by_hashtag(bot, update, db, users_state): 199 | user_id = update.message.from_user.id 200 | 201 | users_state[user_id] = dict() 202 | users_state[user_id]['channels'] = dict() 203 | keyboard = [] 204 | keyboard_row = [] 205 | for channel in db.query(Channel).filter(Channel.owner_id == str(user_id)).order_by(Channel.created_at.desc()): 206 | try: 207 | channel_chat = bot.get_chat(chat_id=channel.channel_id) 208 | users_state[user_id]['channels'][channel_chat.title] = channel.channel_id 209 | keyboard_row.append(channel_chat.title) 210 | if len(keyboard_row) == 2: 211 | keyboard.append(keyboard_row) 212 | keyboard_row = [] 213 | except telegram.TelegramError: 214 | logger.warning('filter_by_hashtag: cannot get title of channel {}'.format(channel.channel_id)) 215 | traceback.print_exc() 216 | if len(keyboard_row) != 0: 217 | keyboard.append(keyboard_row) 218 | 219 | update.message.reply_text('Выберите канал', reply_markup=ReplyKeyboardMarkup(keyboard, one_time_keyboard=True)) 220 | return ASKED_CHANNEL_ID_IN_FILTER_BY_HASHTAG 221 | 222 | 223 | @catch_exceptions 224 | @make_db_session 225 | def filter_by_hashtag_in_state_asked_channel_id(bot, update, db, users_state): 226 | user_id = update.message.from_user.id 227 | channel_title = update.message.text 228 | channel_id = str(users_state[user_id]['channels'][channel_title]) 229 | channel = db.query(Channel).get(channel_id) 230 | users_state[user_id]['channel'] = channel 231 | 232 | if channel.hashtag_filter is not None: 233 | update.message.reply_text('Текущий фильтр по хештегам:') 234 | update.message.reply_text(channel.hashtag_filter) 235 | update.message.reply_text('Напишите новые хештеги (разделяйте запятой):') 236 | return ASKED_HASHTAGS_IN_FILTER_BY_HASHTAG 237 | 238 | 239 | @catch_exceptions 240 | @make_db_session 241 | def filter_by_hashtag_in_state_asked_hashtags(bot, update, db, users_state): 242 | user_id = update.message.from_user.id 243 | channel = users_state[user_id]['channel'] 244 | 245 | try: 246 | channel.hashtag_filter = ','.join(h.strip() for h in update.message.text.split(',')) 247 | db.commit() 248 | except: 249 | db.rollback() 250 | raise 251 | 252 | update.message.reply_text('Сохранено!') 253 | 254 | del_state(update, users_state) 255 | return ConversationHandler.END 256 | 257 | 258 | @catch_exceptions 259 | def cancel_filter_by_hashtag(bot, update, users_state): 260 | update.message.reply_text('Ладно', reply_markup=ReplyKeyboardRemove()) 261 | update.message.reply_text('Настроить фильтр по хештегам можно командой /filter_by_hashtag') 262 | update.message.reply_text('Команда /new настроит новый канал') 263 | 264 | del_state(update, users_state) 265 | return ConversationHandler.END 266 | 267 | 268 | @catch_exceptions 269 | @make_db_session 270 | def recover(bot, update, db, users_state): 271 | user_id = update.message.from_user.id 272 | 273 | users_state[user_id] = dict() 274 | users_state[user_id]['channels'] = dict() 275 | keyboard = [] 276 | keyboard_row = [] 277 | for channel in db.query(DisabledChannel).filter(DisabledChannel.owner_id == str(user_id)).order_by(DisabledChannel.created_at.desc()): 278 | title = '{} ({})'.format(channel.vk_group_id, channel.channel_id) 279 | users_state[user_id]['channels'][title] = channel.channel_id 280 | keyboard_row.append(title) 281 | if len(keyboard_row) == 2: 282 | keyboard.append(keyboard_row) 283 | keyboard_row = [] 284 | if len(keyboard_row) != 0: 285 | keyboard.append(keyboard_row) 286 | 287 | if len(keyboard) == 0: 288 | update.message.reply_text('Нет каналов, которые можно восстановить') 289 | del_state(update, users_state) 290 | return ConversationHandler.END 291 | else: 292 | update.message.reply_text('Выберите канал', reply_markup=ReplyKeyboardMarkup(keyboard, one_time_keyboard=True)) 293 | return ASKED_CHANNEL_ID_IN_RECOVER 294 | 295 | 296 | @catch_exceptions 297 | @make_db_session 298 | def recover_in_state_asked_channel_id(bot, update, db, users_state): 299 | user_id = update.message.from_user.id 300 | channel_title = update.message.text 301 | channel_id = str(users_state[user_id]['channels'][channel_title]) 302 | disabled_channel = db.query(DisabledChannel).filter(DisabledChannel.channel_id == channel_id).one() 303 | 304 | try: 305 | db.add(Channel(channel_id=disabled_channel.channel_id, 306 | vk_group_id=disabled_channel.vk_group_id, 307 | last_vk_post_id=disabled_channel.last_vk_post_id, 308 | owner_id=disabled_channel.owner_id, 309 | owner_username=disabled_channel.owner_username, 310 | hashtag_filter=disabled_channel.hashtag_filter)) 311 | db.delete(disabled_channel) 312 | db.commit() 313 | except: 314 | db.rollback() 315 | raise 316 | 317 | update.message.reply_text('Готово!') 318 | update.message.reply_text('Бот будет проверять группу каждые 15 минут') 319 | update.message.reply_text('Настроить фильтр по хештегам можно командой /filter_by_hashtag') 320 | update.message.reply_text('Команда /new настроит новый канал') 321 | 322 | del_state(update, users_state) 323 | return ConversationHandler.END 324 | 325 | 326 | @catch_exceptions 327 | def cancel_recover(bot, update, users_state): 328 | update.message.reply_text('Ладно', reply_markup=ReplyKeyboardRemove()) 329 | update.message.reply_text('Команда /new настроит новый канал') 330 | 331 | del_state(update, users_state) 332 | return ConversationHandler.END 333 | -------------------------------------------------------------------------------- /vk_channelify/models/__init__.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.ext.declarative import declarative_base 3 | from sqlalchemy.orm import sessionmaker 4 | 5 | from .time_stamp_mixin import TimeStampMixin 6 | 7 | 8 | class Base(TimeStampMixin): 9 | pass 10 | 11 | 12 | Base = declarative_base(cls=Base) 13 | 14 | from .channel import Channel 15 | from .disabled_channel import DisabledChannel 16 | 17 | 18 | def make_session_maker(url): 19 | engine = create_engine(url) 20 | return sessionmaker(bind=engine) 21 | -------------------------------------------------------------------------------- /vk_channelify/models/channel.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, String, Integer 2 | 3 | from . import Base 4 | 5 | 6 | class Channel(Base): 7 | __tablename__ = 'channels' 8 | 9 | channel_id = Column(String, primary_key=True, nullable=False) 10 | vk_group_id = Column(String, nullable=False) 11 | last_vk_post_id = Column(Integer, nullable=False, server_default='0', default=0) 12 | owner_id = Column(String, nullable=False) 13 | owner_username = Column(String) 14 | hashtag_filter = Column(String) 15 | -------------------------------------------------------------------------------- /vk_channelify/models/disabled_channel.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, String, Integer 2 | 3 | from . import Base 4 | 5 | 6 | class DisabledChannel(Base): 7 | __tablename__ = 'disabled_channels' 8 | 9 | id = Column(Integer, primary_key=True, autoincrement=True) 10 | channel_id = Column(String, nullable=False) 11 | vk_group_id = Column(String, nullable=False) 12 | last_vk_post_id = Column(Integer, nullable=False, server_default='0') 13 | owner_id = Column(String, nullable=False) 14 | owner_username = Column(String) 15 | hashtag_filter = Column(String) 16 | -------------------------------------------------------------------------------- /vk_channelify/models/time_stamp_mixin.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from sqlalchemy import Column, DateTime, event 3 | 4 | class TimeStampMixin: 5 | created_at = Column(DateTime, default=datetime.utcnow, nullable=False) 6 | created_at._creation_order = 9998 7 | updated_at = Column(DateTime, default=datetime.utcnow, nullable=False) 8 | updated_at._creation_order = 9998 9 | 10 | @staticmethod 11 | def _updated_at(mapper, connection, target): 12 | target.updated_at = datetime.utcnow() 13 | 14 | @classmethod 15 | def __declare_last__(cls): 16 | event.listen(cls, 'before_update', cls._updated_at) 17 | -------------------------------------------------------------------------------- /vk_channelify/repost_worker.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import time 3 | import traceback 4 | from threading import Thread 5 | 6 | import logging 7 | import requests 8 | import telegram 9 | 10 | from vk_channelify.models.disabled_channel import DisabledChannel 11 | from vk_channelify.vk_errors import VkError, VkWallAccessDeniedError 12 | from .models import Channel 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def run_worker(iteration_delay, vk_service_code, telegram_token, db_session_maker): 18 | thread = Thread(target=run_worker_inside_thread, 19 | args=(iteration_delay, vk_service_code, telegram_token, db_session_maker), 20 | daemon=True) 21 | thread.start() 22 | return thread 23 | 24 | 25 | def run_worker_inside_thread(iteration_delay, vk_service_code, telegram_token, db_session_maker): 26 | while True: 27 | start_time = datetime.datetime.now() 28 | logger.info('New iteration {}'.format(start_time)) 29 | 30 | try: 31 | db = db_session_maker() 32 | run_worker_iteration(vk_service_code, telegram_token, db) 33 | except Exception as e: 34 | logger.error('Iteration was failed because of {}'.format(e)) 35 | traceback.print_exc() 36 | finally: 37 | try: 38 | db.close() 39 | except Exception as e: 40 | logger.error('Iteration has failed db.close() because of {}'.format(e)) 41 | traceback.print_exc() 42 | 43 | end_time = datetime.datetime.now() 44 | logger.info('Finished iteration {} ({})'.format(end_time, end_time - start_time)) 45 | 46 | time.sleep(iteration_delay) 47 | 48 | 49 | def run_worker_iteration(vk_service_code, telegram_token, db): 50 | bot = telegram.Bot(telegram_token) 51 | 52 | for channel in db.query(Channel): 53 | try: 54 | posts = fetch_group_posts(channel.vk_group_id, vk_service_code) 55 | 56 | for post in sorted(posts, key=lambda p: p['id']): 57 | if post['id'] <= channel.last_vk_post_id: 58 | continue 59 | if not is_passing_hashtag_filter(channel.hashtag_filter, post): 60 | continue 61 | 62 | post_url = 'https://vk.com/wall{}_{}'.format(post['owner_id'], post['id']) 63 | text = '{}\n\n{}'.format(post_url, post['text']) 64 | if len(text) > 4000: 65 | text = text[0:4000] + '...' 66 | 67 | bot.send_message(channel.channel_id, text) 68 | 69 | try: 70 | channel.last_vk_post_id = post['id'] 71 | db.commit() 72 | except: 73 | db.rollback() 74 | raise 75 | except telegram.error.BadRequest as e: 76 | if 'chat not found' in e.message.lower(): 77 | logger.warning('Disabling channel because of telegram error: {}'.format(e)) 78 | traceback.print_exc() 79 | disable_channel(channel, db, bot) 80 | else: 81 | raise e 82 | except telegram.error.Unauthorized as e: 83 | logger.warning('Disabling channel because of telegram error: {}'.format(e)) 84 | traceback.print_exc() 85 | disable_channel(channel, db, bot) 86 | except telegram.error.TimedOut as e: 87 | logger.warning('Got telegram TimedOut error on channel {} (id: {})'.format(channel.vk_group_id, channel.channel_id)) 88 | except VkWallAccessDeniedError as e: 89 | logger.warning('Disabling channel because of vk error: {}'.format(e)) 90 | traceback.print_exc() 91 | disable_channel(channel, db, bot) 92 | 93 | 94 | def fetch_group_posts(group, vk_service_code): 95 | time.sleep(0.35) 96 | 97 | group_id = extract_group_id_if_has(group) 98 | is_group_domain_passed = group_id is None 99 | 100 | if is_group_domain_passed: 101 | url = 'https://api.vk.com/method/wall.get?domain={}&count=10&access_token={}&v=5.131'.format(group, vk_service_code) 102 | r = requests.get(url) 103 | else: 104 | url = 'https://api.vk.com/method/wall.get?owner_id=-{}&count=10&access_token={}&v=5.131'.format(group_id, vk_service_code) 105 | r = requests.get(url) 106 | j = r.json() 107 | 108 | if 'response' not in j: 109 | logger.error('VK responded with {}'.format(j)) 110 | error_code = int(j['error']['error_code']) 111 | if error_code in [15, 18, 19, 100]: 112 | raise VkWallAccessDeniedError(error_code, j['error']['error_msg'], j['error']['request_params']) 113 | else: 114 | raise VkError(error_code, j['error']['error_msg'], j['error']['request_params']) 115 | 116 | return j['response']['items'] 117 | 118 | 119 | def extract_group_id_if_has(group_name): 120 | domainless_group_prefixes = ['club', 'public'] 121 | for prefix in domainless_group_prefixes: 122 | if group_name.startswith(prefix): 123 | group_id = group_name[len(prefix):] 124 | if group_id.isdigit(): 125 | return group_id 126 | return None 127 | 128 | 129 | def is_passing_hashtag_filter(hashtag_filter, post): 130 | if hashtag_filter is None: 131 | return True 132 | return any(hashtag.strip() in post['text'] for hashtag in hashtag_filter.split(',')) 133 | 134 | 135 | def disable_channel(channel, db, bot): 136 | logger.warning('Disabling channel {} (id: {})'.format(channel.vk_group_id, channel.channel_id)) 137 | 138 | try: 139 | db.add(DisabledChannel(channel_id=channel.channel_id, 140 | vk_group_id=channel.vk_group_id, 141 | last_vk_post_id=channel.last_vk_post_id, 142 | owner_id=channel.owner_id, 143 | owner_username=channel.owner_username, 144 | hashtag_filter=channel.hashtag_filter)) 145 | db.delete(channel) 146 | db.commit() 147 | except: 148 | db.rollback() 149 | raise 150 | 151 | try: 152 | bot.send_message(channel.owner_id, 'Канал https://vk.com/{} отключен'.format(channel.vk_group_id)) 153 | bot.send_message(channel.owner_id, 'Так как не удается отправить в него сообщение') 154 | bot.send_message(channel.owner_id, 'ID канала {}'.format(channel.channel_id)) 155 | bot.send_message(channel.owner_id, 'Чтобы восстановить канал, вызовите команду /recover') 156 | except telegram.error.TelegramError: 157 | logger.warning('Cannot send recover message to {} (id: {})'.format(channel.owner_username, channel.owner_id)) 158 | traceback.print_exc() 159 | -------------------------------------------------------------------------------- /vk_channelify/vk_errors.py: -------------------------------------------------------------------------------- 1 | class VkError(Exception): 2 | def __init__(self, code, message, request_params): 3 | super(VkError, self).__init__() 4 | self.code = code 5 | self.message = message 6 | self.request_params = request_params 7 | 8 | def __str__(self): 9 | return 'VkError {}: {} (request_params: {})'.format(self.code, self.message, self.request_params) 10 | 11 | 12 | class VkWallAccessDeniedError(VkError): 13 | def __init__(self, code, message, request_params): 14 | super(VkWallAccessDeniedError, self).__init__(code, message, request_params) 15 | --------------------------------------------------------------------------------