···11+# Set this to the hostname that you intend to run the service at
22+HOSTNAME='feed.example.com'
33+44+# You can obtain it by publishing of feed (run publish_feed.py)
55+FEED_URI='at://did:plc:abcde.../app.bsky.feed.generator/example-fid-name...'
66+77+# Your handle name
88+HANDLE=''
99+1010+# Your app password
1111+PASSWORD=''
1212+1313+# A short name for the record that will show in urls
1414+# Lowercase with no spaces.
1515+# Ex: whats-hot
1616+RECORD_NAME=''
1717+1818+# A display name for your feed
1919+# Ex: What's Hot
2020+DISPLAY_NAME=''
2121+2222+# (Optional) A description of your feed
2323+# Ex: Top trending content from the whole network
2424+DESCRIPTION='powered by The AT Protocol SDK for Python'
2525+2626+# (Optional) The path to an image to be used as your feed's avatar
2727+# Ex: ./path/to/avatar.jpeg
2828+AVATAR_PATH=''
2929+3030+# (Optional). Only use this if you want a service did different from did:web
3131+SERVICE_DID=''
3232+3333+# (Optional). If your feed accepts interactions from clients
3434+ACCEPTS_INTERACTIONS='false'
3535+3636+# (Optional). If your feed is a video feed
3737+IS_VIDEO_FEED='false'
3838+3939+# (Optional). Ignore reply posts
4040+#IGNORE_REPLY_POSTS='true'
4141+4242+# (Optional). Ignore posts with a created_at timestamp older than 1 day
4343+# to avoid including archived posts from X/Twitter
4444+#IGNORE_OLD_POSTS='true'
···11+.DS_Store
22+.idea
33+*.iml
44+.env
55+*.db
66+77+# Byte-compiled / optimized / DLL files
88+__pycache__/
99+*.py[cod]
1010+*$py.class
1111+1212+# C extensions
1313+*.so
1414+1515+# Distribution / packaging
1616+.Python
1717+bin/
1818+build/
1919+develop-eggs/
2020+dist/
2121+downloads/
2222+eggs/
2323+.eggs/
2424+lib/
2525+lib64/
2626+parts/
2727+sdist/
2828+var/
2929+wheels/
3030+share/python-wheels/
3131+*.egg-info/
3232+.installed.cfg
3333+*.egg
3434+MANIFEST
3535+3636+# PyInstaller
3737+# Usually these files are written by a python script from a template
3838+# before PyInstaller builds the exe, so as to inject date/other infos into it.
3939+*.manifest
4040+*.spec
4141+4242+# Installer logs
4343+pip-log.txt
4444+pip-delete-this-directory.txt
4545+4646+# Unit test / coverage reports
4747+htmlcov/
4848+.tox/
4949+.nox/
5050+.coverage
5151+.coverage.*
5252+.cache
5353+nosetests.xml
5454+coverage.xml
5555+*.cover
5656+*.py,cover
5757+.hypothesis/
5858+.pytest_cache/
5959+cover/
6060+6161+# Translations
6262+*.mo
6363+*.pot
6464+6565+# Django stuff:
6666+*.log
6767+local_settings.py
6868+db.sqlite3
6969+db.sqlite3-journal
7070+7171+# Flask stuff:
7272+instance/
7373+.webassets-cache
7474+7575+# Scrapy stuff:
7676+.scrapy
7777+7878+# Sphinx documentation
7979+docs/_build/
8080+8181+# PyBuilder
8282+.pybuilder/
8383+target/
8484+8585+# Jupyter Notebook
8686+.ipynb_checkpoints
8787+8888+# IPython
8989+profile_default/
9090+ipython_config.py
9191+9292+# pyenv
9393+# For a library or package, you might want to ignore these files since the code is
9494+# intended to run in multiple environments; otherwise, check them in:
9595+# .python-version
9696+9797+# pipenv
9898+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
9999+# However, in case of collaboration, if having platform-specific dependencies or dependencies
100100+# having no cross-platform support, pipenv may install dependencies that don't work, or not
101101+# install all needed dependencies.
102102+#Pipfile.lock
103103+104104+# poetry
105105+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
106106+# This is especially recommended for binary packages to ensure reproducibility, and is more
107107+# commonly ignored for libraries.
108108+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
109109+#poetry.lock
110110+111111+# pdm
112112+# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113113+#pdm.lock
114114+# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
115115+# in version control.
116116+# https://pdm.fming.dev/#use-with-ide
117117+.pdm.toml
118118+119119+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
120120+__pypackages__/
121121+122122+# Celery stuff
123123+celerybeat-schedule
124124+celerybeat.pid
125125+126126+# SageMath parsed files
127127+*.sage.py
128128+129129+# Environments
130130+.env
131131+.venv
132132+env/
133133+venv/
134134+ENV/
135135+env.bak/
136136+venv.bak/
137137+pyvenv.cfg
138138+139139+# Spyder project settings
140140+.spyderproject
141141+.spyproject
142142+143143+# Rope project settings
144144+.ropeproject
145145+146146+# mkdocs documentation
147147+/site
148148+149149+# mypy
150150+.mypy_cache/
151151+.dmypy.json
152152+dmypy.json
153153+154154+# Pyre type checker
155155+.pyre/
156156+157157+# pytype static type analyzer
158158+.pytype/
159159+160160+# Cython debug symbols
161161+cython_debug/
162162+163163+# PyCharm
164164+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
165165+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
166166+# and can be added to the global gitignore or merged into this file. For a more nuclear
167167+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
168168+#.idea/
···11+MIT License
22+33+Copyright (c) 2023 Ilya Siamionau
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+70
README.md
···11+# ATProto Feed Generator powered by [The AT Protocol SDK for Python](https://github.com/MarshalX/atproto)
22+33+> Feed Generators are services that provide custom algorithms to users through the AT Protocol.
44+55+Official overview (read it first): https://github.com/bluesky-social/feed-generator#overview
66+77+## Getting Started
88+99+We've set up this simple server with SQLite to store and query data. Feel free to switch this out for whichever database you prefer.
1010+1111+Next, you will need to do two things:
1212+1313+1. Implement filtering logic in `server/data_filter.py`.
1414+2. Copy `.env.example` to `.env`
1515+3. Optionally implement custom feed generation logic in `server/algos`.
1616+1717+We've taken care of setting this server up with a did:web. However, you're free to switch this out for did:plc if you like - you may want to if you expect this Feed Generator to be long-standing and possibly migrating domains.
1818+1919+## Publishing your feed
2020+2121+To publish your feed, simply run `python publish_feed.py`.
2222+2323+To update your feed's display data (name, avatar, description, etc.), just update the relevant variables in `.env` and re-run the script.
2424+2525+After successfully running the script, you should be able to see your feed from within the app, as well as share it by embedding a link in a post (similar to a quote post).
2626+2727+## Running the Server
2828+2929+Install Python 3.7+.
3030+3131+Run `setupvenv.sh` to setup a virtual environment and install the dependencies:
3232+3333+```shell
3434+./setupvenv.sh
3535+```
3636+3737+**Note**: To get value for `FEED_URI` you need to publish the feed first
3838+3939+To run a development Flask server:
4040+4141+```shell
4242+flask run
4343+```
4444+4545+**Warning** The Flask development server is not designed for production use. In production, you should use production WSGI server such as [`waitress`](https://flask.palletsprojects.com/en/stable/deploying/waitress/) behind a reverse proxy such as NGINX instead.
4646+4747+```shell
4848+pip install waitress
4949+waitress-serve --listen=127.0.0.1:8080 server.app:app
5050+```
5151+5252+To run a development server with debugging:
5353+5454+```shell
5555+flask --debug run
5656+```
5757+5858+**Note**: Duplication of data stream instances in debug mode is fine.
5959+6060+**Warning**: If you want to run server in many workers, you should run Data Stream (Firehose) separately.
6161+6262+### Endpoints
6363+6464+- `/.well-known/did.json`
6565+- `/xrpc/app.bsky.feed.describeFeedGenerator`
6666+- `/xrpc/app.bsky.feed.getFeedSkeleton`
6767+6868+## License
6969+7070+MIT
+102
publish_feed.py
···11+#!/usr/bin/env python3
22+# YOU MUST INSTALL ATPROTO SDK
33+# pip3 install atproto
44+55+import os
66+77+from dotenv import load_dotenv
88+from atproto import Client, models
99+1010+load_dotenv()
1111+1212+def _get_bool_env_var(value: str) -> bool:
1313+ # Helper function to convert string to bool
1414+1515+ if value is None:
1616+ return False
1717+1818+ normalized_value = value.strip().lower()
1919+ if normalized_value in {'1', 'true', 't', 'yes', 'y'}:
2020+ return True
2121+2222+ return False
2323+2424+2525+# YOUR bluesky handle
2626+# Ex: user.bsky.social
2727+HANDLE: str = os.environ.get('HANDLE')
2828+2929+# YOUR bluesky password, or preferably an App Password (found in your client settings)
3030+# Ex: abcd-1234-efgh-5678
3131+PASSWORD: str = os.environ.get('PASSWORD')
3232+3333+# The hostname of the server where feed server will be hosted
3434+# Ex: feed.bsky.dev
3535+HOSTNAME: str = os.environ.get('HOSTNAME')
3636+3737+# A short name for the record that will show in urls
3838+# Lowercase with no spaces.
3939+# Ex: whats-hot
4040+RECORD_NAME: str = os.environ.get('RECORD_NAME')
4141+4242+# A display name for your feed
4343+# Ex: What's Hot
4444+DISPLAY_NAME: str = os.environ.get('DISPLAY_NAME')
4545+4646+# (Optional) A description of your feed
4747+# Ex: Top trending content from the whole network
4848+DESCRIPTION: str = os.environ.get('DESCRIPTION')
4949+5050+# (Optional) The path to an image to be used as your feed's avatar
5151+# Ex: ./path/to/avatar.jpeg
5252+AVATAR_PATH: str = os.environ.get('AVATAR_PATH')
5353+5454+# (Optional). Only use this if you want a service did different from did:web
5555+SERVICE_DID: str = os.environ.get('SERVICE_DID')
5656+5757+# (Optional). If your feed accepts interactions from clients
5858+ACCEPTS_INTERACTIONS: bool = _get_bool_env_var(os.environ.get('ACCEPTS_INTERACTIONS'))
5959+6060+# (Optional). If your feed is a video feed
6161+IS_VIDEO_FEED: bool = _get_bool_env_var(os.environ.get('IS_VIDEO_FEED'))
6262+6363+# -------------------------------------
6464+# NO NEED TO TOUCH ANYTHING BELOW HERE
6565+# -------------------------------------
6666+6767+6868+def main():
6969+ client = Client()
7070+ client.login(HANDLE, PASSWORD)
7171+7272+ feed_did = SERVICE_DID
7373+ if not feed_did:
7474+ feed_did = f'did:web:{HOSTNAME}'
7575+7676+ avatar_blob = None
7777+ if AVATAR_PATH:
7878+ with open(AVATAR_PATH, 'rb') as f:
7979+ avatar_data = f.read()
8080+ avatar_blob = client.upload_blob(avatar_data).blob
8181+8282+ response = client.com.atproto.repo.put_record(models.ComAtprotoRepoPutRecord.Data(
8383+ repo=client.me.did,
8484+ collection=models.ids.AppBskyFeedGenerator,
8585+ rkey=RECORD_NAME,
8686+ record=models.AppBskyFeedGenerator.Record(
8787+ did=feed_did,
8888+ display_name=DISPLAY_NAME,
8989+ accepts_interactions=ACCEPTS_INTERACTIONS,
9090+ description=DESCRIPTION,
9191+ avatar=avatar_blob,
9292+ content_mode='app.bsky.feed.defs#contentModeVideo' if IS_VIDEO_FEED else None,
9393+ created_at=client.get_current_time_iso(),
9494+ )
9595+ ))
9696+9797+ print('Successfully published!')
9898+ print('Feed URI (put in "FEED_URI" env var):', response.uri)
9999+100100+101101+if __name__ == '__main__':
102102+ main()
···11+from atproto import DidInMemoryCache, IdResolver, verify_jwt
22+from atproto.exceptions import TokenInvalidSignatureError
33+from flask import Request
44+55+66+_CACHE = DidInMemoryCache()
77+_ID_RESOLVER = IdResolver(cache=_CACHE)
88+99+_AUTHORIZATION_HEADER_NAME = 'Authorization'
1010+_AUTHORIZATION_HEADER_VALUE_PREFIX = 'Bearer '
1111+1212+1313+class AuthorizationError(Exception):
1414+ ...
1515+1616+1717+def validate_auth(request: 'Request') -> str:
1818+ """Validate authorization header.
1919+2020+ Args:
2121+ request: The request to validate.
2222+2323+ Returns:
2424+ :obj:`str`: Requester DID.
2525+2626+ Raises:
2727+ :obj:`AuthorizationError`: If the authorization header is invalid.
2828+ """
2929+ auth_header = request.headers.get(_AUTHORIZATION_HEADER_NAME)
3030+ if not auth_header:
3131+ raise AuthorizationError('Authorization header is missing')
3232+3333+ if not auth_header.startswith(_AUTHORIZATION_HEADER_VALUE_PREFIX):
3434+ raise AuthorizationError('Invalid authorization header')
3535+3636+ jwt = auth_header[len(_AUTHORIZATION_HEADER_VALUE_PREFIX) :].strip()
3737+3838+ try:
3939+ return verify_jwt(jwt, _ID_RESOLVER.did.resolve_atproto_key).iss
4040+ except TokenInvalidSignatureError as e:
4141+ raise AuthorizationError('Invalid signature') from e
+42
server/config.py
···11+import os
22+import logging
33+44+from dotenv import load_dotenv
55+66+from server.logger import logger
77+88+load_dotenv()
99+1010+SERVICE_DID = os.environ.get('SERVICE_DID')
1111+HOSTNAME = os.environ.get('HOSTNAME')
1212+FLASK_RUN_FROM_CLI = os.environ.get('FLASK_RUN_FROM_CLI')
1313+1414+if FLASK_RUN_FROM_CLI:
1515+ logger.setLevel(logging.DEBUG)
1616+1717+if not HOSTNAME:
1818+ raise RuntimeError('You should set "HOSTNAME" environment variable first.')
1919+2020+if not SERVICE_DID:
2121+ SERVICE_DID = f'did:web:{HOSTNAME}'
2222+2323+2424+FEED_URI = os.environ.get('FEED_URI')
2525+if not FEED_URI:
2626+ raise RuntimeError('Publish your feed first (run publish_feed.py) to obtain Feed URI. '
2727+ 'Set this URI to "FEED_URI" environment variable.')
2828+2929+3030+def _get_bool_env_var(value: str) -> bool:
3131+ if value is None:
3232+ return False
3333+3434+ normalized_value = value.strip().lower()
3535+ if normalized_value in {'1', 'true', 't', 'yes', 'y'}:
3636+ return True
3737+3838+ return False
3939+4040+4141+IGNORE_ARCHIVED_POSTS = _get_bool_env_var(os.environ.get('IGNORE_ARCHIVED_POSTS'))
4242+IGNORE_REPLY_POSTS = _get_bool_env_var(os.environ.get('IGNORE_REPLY_POSTS'))
+98
server/data_filter.py
···11+import datetime
22+33+from collections import defaultdict
44+55+from atproto import models
66+77+from server import config
88+from server.logger import logger
99+from server.database import db, Post
1010+1111+1212+def is_archive_post(record: 'models.AppBskyFeedPost.Record') -> bool:
1313+ # Sometimes users will import old posts from Twitter/X which con flood a feed with
1414+ # old posts. Unfortunately, the only way to test for this is to look an old
1515+ # created_at date. However, there are other reasons why a post might have an old
1616+ # date, such as firehose or firehose consumer outages. It is up to you, the feed
1717+ # creator to weigh the pros and cons, amd and optionally include this function in
1818+ # your filter conditions, and adjust the threshold to your liking.
1919+ #
2020+ # See https://github.com/MarshalX/bluesky-feed-generator/pull/21
2121+2222+ archived_threshold = datetime.timedelta(days=1)
2323+ created_at = datetime.datetime.fromisoformat(record.created_at)
2424+ now = datetime.datetime.now(datetime.UTC)
2525+2626+ return now - created_at > archived_threshold
2727+2828+2929+def should_ignore_post(created_post: dict) -> bool:
3030+ record = created_post['record']
3131+ uri = created_post['uri']
3232+3333+ if config.IGNORE_ARCHIVED_POSTS and is_archive_post(record):
3434+ logger.debug(f'Ignoring archived post: {uri}')
3535+ return True
3636+3737+ if config.IGNORE_REPLY_POSTS and record.reply:
3838+ logger.debug(f'Ignoring reply post: {uri}')
3939+ return True
4040+4141+ return False
4242+4343+4444+def operations_callback(ops: defaultdict) -> None:
4545+ # Here we can filter, process, run ML classification, etc.
4646+ # After our feed alg we can save posts into our DB
4747+ # Also, we should process deleted posts to remove them from our DB and keep it in sync
4848+4949+ # for example, let's create our custom feed that will contain all posts that contains 'python' related text
5050+5151+ posts_to_create = []
5252+ for created_post in ops[models.ids.AppBskyFeedPost]['created']:
5353+ author = created_post['author']
5454+ record = created_post['record']
5555+5656+ post_with_images = isinstance(record.embed, models.AppBskyEmbedImages.Main)
5757+ post_with_video = isinstance(record.embed, models.AppBskyEmbedVideo.Main)
5858+ inlined_text = record.text.replace('\n', ' ')
5959+6060+ # print all texts just as demo that data stream works
6161+ logger.debug(
6262+ f'NEW POST '
6363+ f'[CREATED_AT={record.created_at}]'
6464+ f'[AUTHOR={author}]'
6565+ f'[WITH_IMAGE={post_with_images}]'
6666+ f'[WITH_VIDEO={post_with_video}]'
6767+ f': {inlined_text}'
6868+ )
6969+7070+ if should_ignore_post(created_post):
7171+ continue
7272+7373+ # only python-related posts
7474+ if 'python' in record.text.lower():
7575+ reply_root = reply_parent = None
7676+ if record.reply:
7777+ reply_root = record.reply.root.uri
7878+ reply_parent = record.reply.parent.uri
7979+8080+ post_dict = {
8181+ 'uri': created_post['uri'],
8282+ 'cid': created_post['cid'],
8383+ 'reply_parent': reply_parent,
8484+ 'reply_root': reply_root,
8585+ }
8686+ posts_to_create.append(post_dict)
8787+8888+ posts_to_delete = ops[models.ids.AppBskyFeedPost]['deleted']
8989+ if posts_to_delete:
9090+ post_uris_to_delete = [post['uri'] for post in posts_to_delete]
9191+ Post.delete().where(Post.uri.in_(post_uris_to_delete))
9292+ logger.debug(f'Deleted from feed: {len(post_uris_to_delete)}')
9393+9494+ if posts_to_create:
9595+ with db.atomic():
9696+ for post_dict in posts_to_create:
9797+ Post.create(**post_dict)
9898+ logger.debug(f'Added to feed: {len(posts_to_create)}')
+96
server/data_stream.py
···11+import logging
22+from collections import defaultdict
33+44+from atproto import AtUri, CAR, firehose_models, FirehoseSubscribeReposClient, models, parse_subscribe_repos_message
55+from atproto.exceptions import FirehoseError
66+77+from server.database import SubscriptionState
88+from server.logger import logger
99+1010+_INTERESTED_RECORDS = {
1111+ models.AppBskyFeedLike: models.ids.AppBskyFeedLike,
1212+ models.AppBskyFeedPost: models.ids.AppBskyFeedPost,
1313+ models.AppBskyGraphFollow: models.ids.AppBskyGraphFollow,
1414+}
1515+1616+1717+def _get_ops_by_type(commit: models.ComAtprotoSyncSubscribeRepos.Commit) -> defaultdict:
1818+ operation_by_type = defaultdict(lambda: {'created': [], 'deleted': []})
1919+2020+ car = CAR.from_bytes(commit.blocks)
2121+ for op in commit.ops:
2222+ if op.action == 'update':
2323+ # we are not interested in updates
2424+ continue
2525+2626+ uri = AtUri.from_str(f'at://{commit.repo}/{op.path}')
2727+2828+ if op.action == 'create':
2929+ if not op.cid:
3030+ continue
3131+3232+ create_info = {'uri': str(uri), 'cid': str(op.cid), 'author': commit.repo}
3333+3434+ record_raw_data = car.blocks.get(op.cid)
3535+ if not record_raw_data:
3636+ continue
3737+3838+ record = models.get_or_create(record_raw_data, strict=False)
3939+ if record is None: # unknown record (out of bsky lexicon)
4040+ continue
4141+4242+ for record_type, record_nsid in _INTERESTED_RECORDS.items():
4343+ if uri.collection == record_nsid and models.is_record_type(record, record_type):
4444+ operation_by_type[record_nsid]['created'].append({'record': record, **create_info})
4545+ break
4646+4747+ if op.action == 'delete':
4848+ operation_by_type[uri.collection]['deleted'].append({'uri': str(uri)})
4949+5050+ return operation_by_type
5151+5252+5353+def run(name, operations_callback, stream_stop_event=None):
5454+ while stream_stop_event is None or not stream_stop_event.is_set():
5555+ try:
5656+ _run(name, operations_callback, stream_stop_event)
5757+ except FirehoseError as e:
5858+ if logger.level == logging.DEBUG:
5959+ raise e
6060+ logger.error(f'Firehose error: {e}. Reconnecting to the firehose.')
6161+6262+6363+def _run(name, operations_callback, stream_stop_event=None):
6464+ state = SubscriptionState.get_or_none(SubscriptionState.service == name)
6565+6666+ params = None
6767+ if state:
6868+ params = models.ComAtprotoSyncSubscribeRepos.Params(cursor=state.cursor)
6969+7070+ client = FirehoseSubscribeReposClient(params)
7171+7272+ if not state:
7373+ SubscriptionState.create(service=name, cursor=0)
7474+7575+ def on_message_handler(message: firehose_models.MessageFrame) -> None:
7676+ # stop on next message if requested
7777+ if stream_stop_event and stream_stop_event.is_set():
7878+ client.stop()
7979+ return
8080+8181+ commit = parse_subscribe_repos_message(message)
8282+ if not isinstance(commit, models.ComAtprotoSyncSubscribeRepos.Commit):
8383+ return
8484+8585+ # update stored state every ~1k events
8686+ if commit.seq % 1000 == 0: # lower value could lead to performance issues
8787+ logger.debug(f'Updated cursor for {name} to {commit.seq}')
8888+ client.update_params(models.ComAtprotoSyncSubscribeRepos.Params(cursor=commit.seq))
8989+ SubscriptionState.update(cursor=commit.seq).where(SubscriptionState.service == name).execute()
9090+9191+ if not commit.blocks:
9292+ return
9393+9494+ operations_callback(_get_ops_by_type(commit))
9595+9696+ client.start(on_message_handler)