Search code examples
pythonherokugoogle-apigoogle-oauthoauth2client

oauth2client for Blogger posting from a telegram bot in Heroku server


I have just deployed my telegram bot with python-telegram-bot in Heroku.

My webhooks bot uses blogger for posting certain things. I did this until now with a sligthly modified custom version of sample_tools, from module google_apli_client.

my_tools:

"""
dependencies:
    pip3 install --upgrade google-api-python-client

This is a slightly modified implementation 
for substituting googleapiclient.sample_tools. It helps customizing some paths 
for my project files under different environments
"""
from __future__ import absolute_import

from environments import get_active_env

__all__ = ['init']

import argparse
import os

from googleapiclient import discovery
from googleapiclient.http import build_http
from oauth2client import tools, file, client


def init(argv, name, version, doc, scope=None, parents=[], 
         discovery_filename=None):
    """A common initialization routine for samples.

    Many of the sample applications do the same initialization, which has now
    been consolidated into this function. This function uses common idioms found
    in almost all the samples, i.e. for an API with name 'apiname', the
    credentials are stored in a file named apiname.dat, and the
    client_secrets.json file is stored in the same directory as the application
    main file.

    Args:
        argv: list of string, the command-line parameters of the application.
        name: string, name of the API.
        version: string, version of the API.
        doc: string, description of the application. Usually set to __doc__.
        file: string, filename of the application. Usually set to __file__.
        parents: list of argparse.ArgumentParser, additional command-line flags.
        scope: string, The OAuth scope used.
        discovery_filename: string, name of local discovery file (JSON). Use 
        when discovery doc not available via URL.

    Returns:
    A tuple of (service, flags), where service is the service object and flags
    is the parsed command-line flags.
    """
    if scope is None:
        scope = 'https://www.googleapis.com/auth/' + name

    # Parser command-line arguments.
    parent_parsers = [tools.argparser]
    parent_parsers.extend(parents)
    parser = argparse.ArgumentParser(
        description=doc,
        formatter_class=argparse.RawDescriptionHelpFormatter,
        parents=parent_parsers)
    flags = parser.parse_args(argv[1:])

    # Name of a file containing the OAuth 2.0 information for this
    # application, including client_id and client_secret, which are found
    # on the API Access tab on the Google APIs
    # Console <http://code.google.com/apis/console>.
    client_secrets = os.path.join(os.path.dirname(__file__), get_active_env(),
                                 'client_secrets.json')

    # Set up a Flow object to be used if we need to authenticate.
    flow = client.flow_from_clientsecrets(client_secrets,
      scope=scope,
      message=tools.message_if_missing(client_secrets))

    # Prepare credentials, and authorize HTTP object with them.
    # If the credentials don't exist or are invalid, 
    # run through the native client flow.
    # The Storage object will ensure that if successful the good
    # credentials will get written back to a file in google_core directory.
    storage_file_path = os.path.join(os.path.dirname(__file__), name + '.dat')
    storage = file.Storage(storage_file_path)
    credentials = storage.get()
    if credentials is None or credentials.invalid:
        credentials = tools.run_flow(flow, storage, flags)
    http = credentials.authorize(http=build_http())

    if discovery_filename is None:
        # Construct a service object via the discovery service.
        service = discovery.build(name, 
                                  version, 
                                  http=http, 
                                  cache_discovery=False)
    else:
        # Construct a service object using a local discovery document file.
        with open(discovery_filename) as discovery_file:
            service = discovery.build_from_document(
                discovery_file.read(),
                base='https://www.googleapis.com/',
                http=http)
        service = discovery.build(name, 
                                  version, 
                                  http=http, 
                                  cache_discovery=False)
    return (service, flags)

With this I could make authentication and nicely the browser in the OS would open and allow me (or the final user) to authorize the app to use my (or user's) blogger.

initial snippet using my_tools:

service, flags = my_tools.init(
    [], 'blogger', 'v3', __doc__,
    scope='https://www.googleapis.com/auth/blogger')

try:
    posts = service.posts()
    # This new_post is a custom object, but the important thing here
    # is getting the authorization, and then the service at the top
    insert = posts.insert(blogId=new_post.blog_id, body=new_post.body(), isDraft=new_post.is_draft)
    posts_doc = insert.execute()
    return posts_doc
except client.AccessTokenRefreshError:
    print('The credentials have been revoked or expired, please re-run the application to re-authorize')

But now I can't do it since it's in heroku and this message appears in the logs:

app[web.1]: Your browser has been opened to visit:
app[web.1]: 
app[web.1]:     https://accounts.google.com/o/oauth2/auth?client_id=<client_id>&redirect_uri=http%3A%2F%2Flocalhost%3A8090%2F&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fblogger&access_type=offline&response_type=code
app[web.1]: 
app[web.1]: If your browser is on a different machine then exit and re-run this
app[web.1]: application with the command-line parameter
app[web.1]: 
app[web.1]:   --noauth_local_webserver
app[web.1]:

I need to authorize automatically the heroku app, given that it will only be accessed from a telegram bot restricted to some users, it doesn't need to get the user to a browser and authorize.

I need some way for the bot being allowed to use blogger and the user being able to use the bot with an easy way to authorize when needed or with some authorization file already stored in the server.

I have googled and looked at these resources:

https://developers.google.com/api-client-library/python/auth/web-app https://github.com/burnash/gspread/wiki/How-to-get-OAuth-access-token-in-console%3F Django oauth2 google not working on server

but I'm completely lost about what and how should I do. I feel like I need an explain-it-for-dummies explanation.

Edited: I've been pointed to this web

https://developers.google.com/api-client-library/python/auth/service-accounts

so I tried this new code.

new snippet:

from oauth2client import service_account
    import googleapiclient.discovery
    import os
    from environments import get_active_env
    SERVICE_ACCOUNT_FILE = os.path.join(os.path.dirname(__file__), os.pardir, 'google_core', get_active_env(),
                                        'service_account.json')

    credentials = service_account.ServiceAccountCredentials.from_json_keyfile_name(
        SERVICE_ACCOUNT_FILE, scopes=['https://www.googleapis.com/auth/blogger'])
    service = googleapiclient.discovery.build('blogger', 'v3', credentials=credentials)

    try:
        posts = service.posts()
        insert = posts.insert(blogId=new_post.blog_id, body=new_post.body(), isDraft=new_post.is_draft)
        posts_doc = insert.execute()
        return posts_doc
    except client.AccessTokenRefreshError:
        print('The credentials have been revoked or expired, please re-run the application to re-authorize')

so I now get this in the logs (I think the 403 HttpError is the thing here, another errors about memcache or oauth2client.contrib.locked_file not being imported are not big deal):

heroku[web.1]: Unidling
heroku[web.1]: State changed from down to starting
heroku[web.1]: Starting process with command `python my_bot.py`
heroku[web.1]: State changed from starting to up
heroku[router]: at=info method=POST path="/<bot_token>" host=telegram-bot-alfred.herokuapp.com request_id=<request_id> fwd="<ip>" dyno=web.1 connect=1ms service=2ms status=200 bytes=97 protocol=https
app[web.1]: INFO - Input: post_asin 
app[web.1]: INFO - Input ASIN: B079Z8THTF
app[web.1]: INFO - Printing offers for asin B079Z8THTF:
app[web.1]: INFO - EUR 36.98
app[web.1]: INFO - URL being requested: GET https://www.googleapis.com/discovery/v1/apis/blogger/v3/rest
app[web.1]: INFO - Attempting refresh to obtain initial access_token
app[web.1]: INFO - URL being requested: POST https://www.googleapis.com/blogger/v3/blogs/2270688467086771731/posts?isDraft=true&alt=json
app[web.1]: INFO - Refreshing access_token
app[web.1]: WARNING - Encountered 403 Forbidden with reason "forbidden"
app[web.1]: ERROR - Error with asin B079Z8THTF. We go to the next.
app[web.1]: Traceback (most recent call last):
app[web.1]:   File "my_bot.py", line 171, in process_asin_string
app[web.1]:     send_post_to_blogger(update.message, post)
app[web.1]:   File "/app/api_samples/blogger/blogger_insert.py", line 85, in send_post_to_blogger
app[web.1]:     response = post_at_blogger(post)
app[web.1]:   File "/app/api_samples/blogger/blogger_insert.py", line 72, in post_at_blogger
app[web.1]:     posts_doc = insert.execute()
app[web.1]:   File "/app/.heroku/python/lib/python3.6/site-packages/googleapiclient/http.py", line 844, in execute
app[web.1]:     raise HttpError(resp, content, uri=self.uri)
app[web.1]:   File "/app/.heroku/python/lib/python3.6/site-packages/oauth2client/_helpers.py", line 133, in positional_wrapper
app[web.1]:     return wrapped(*args, **kwargs)
app[web.1]: googleapiclient.errors.HttpError: <HttpError 403 when requesting https://www.googleapis.com/blogger/v3/blogs/2270688467086771731/posts?isDraft=true&alt=json returned "We're sorry, but you don't have permission to access this resource.">
app[web.1]: ERROR - Exception HttpError not handled
app[web.1]: Traceback (most recent call last):
app[web.1]:   File "my_bot.py", line 171, in process_asin_string
app[web.1]:     send_post_to_blogger(update.message, post)
app[web.1]:   File "/app/api_samples/blogger/blogger_insert.py", line 85, in send_post_to_blogger
app[web.1]:     response = post_at_blogger(post)
app[web.1]:   File "/app/api_samples/blogger/blogger_insert.py", line 72, in post_at_blogger
app[web.1]:     posts_doc = insert.execute()
app[web.1]:   File "/app/.heroku/python/lib/python3.6/site-packages/googleapiclient/http.py", line 844, in execute
app[web.1]:     raise HttpError(resp, content, uri=self.uri)
app[web.1]:   File "/app/.heroku/python/lib/python3.6/site-packages/oauth2client/_helpers.py", line 133, in positional_wrapper
app[web.1]:     return wrapped(*args, **kwargs)
app[web.1]: googleapiclient.errors.HttpError: <HttpError 403 when requesting https://www.googleapis.com/blogger/v3/blogs/2270688467086771731/posts?isDraft=true&alt=json returned "We're sorry, but you don't have permission to access this resource.">
app[web.1]: 
app[web.1]: During handling of the above exception, another exception occurred:
app[web.1]: 
app[web.1]: Traceback (most recent call last):
app[web.1]:   File "/app/exceptions/errors.py", line 47, in alfred
app[web.1]:     message.reply_text(rnd.choice(answers[type(exception)]))
app[web.1]: KeyError: <class 'googleapiclient.errors.HttpError'>
app[web.1]: WARNING - Error with asin B079Z8THTF. We go to the next

Solution

  • I found a solution just by providing a parameter like here:

    service, flags = my_tools.init(
        ['', '--noauth_local_webserver'], 'blogger', 'v3', __doc__,
        scope='https://www.googleapis.com/auth/blogger')
    

    Then I had to customize some methods from oauth2client.tools. I did two methods and additional code in my_tools. Every piece missing is easily imported or copied from original google's tools:

    # module scope
    import argparse
    from googleapiclient import discovery
    from googleapiclient.http import build_http
    from oauth2client import tools, file, client, _helpers
    from oauth2client.tools import _CreateArgumentParser
    
    _GO_TO_LINK_MESSAGE = """
    Visit this link to get auth code
    
        {address}
    
    """
    
    # argparser is an ArgumentParser that contains command-line options expected
    # by tools.run(). Pass it in as part of the 'parents' argument to your own
    # ArgumentParser.
    argparser = _CreateArgumentParser()
    
    _flow = None
    
    
    # Methods
    @_helpers.positional(3)
    def run_flow(flow, flags=None):
        """
        Emulates the original method run_flow from oauth2client.tools getting the website to visit.
    
        The ``run()`` function is called from your application and runs
        through all the steps to obtain credentials. It takes a ``Flow``
        argument and attempts to open an authorization server page in the
        user's default web browser. The server asks the user to grant your
        application access to the user's data.  The user can then get an
        authentication code for inputing later
    
        :param flow: the google OAuth 2.0 Flow object with which the auth begun
        :param flags: the provided flags
        :return: the string with the website link where the user can authenticate and obtain a code
        """
        global _flow
    
        # I update the _flow object for using internally later
        _flow = flow
    
        # Really the flags aren't very used. In practice I copied the method as if noauth_local_webserver was provided
        if flags is None:
            flags = argparser.parse_args()
        logging.getLogger().setLevel(getattr(logging, flags.logging_level))
    
        oauth_callback = client.OOB_CALLBACK_URN
        _flow.redirect_uri = oauth_callback
        authorize_url = _flow.step1_get_authorize_url()
    
        return _GO_TO_LINK_MESSAGE.format(address=authorize_url)
    
    
    def oauth_with(code, http=None):
        """
        If the code grants access,
        the function returns new credentials. The new credentials
        are also stored in the ``storage`` argument, which updates the file
        associated with the ``Storage`` object.
    
        :param code: the auth code
        :param http: the http transport object
        :return: the credentials if any
        """
        global _flow
        storage_file_path = get_credentials_path('blogger')
        storage = file.Storage(storage_file_path)
        try:
            # We now re-use the _flow stored earlier
            credential = _flow.step2_exchange(code, http=http)
        except client.FlowExchangeError as e:
            raise AlfredException(msg='Authentication has failed: {0}'.format(e))
    
        storage.put(credential)
        credential.set_store(storage)
        # We reset the flow
        _flow = None
    
        return credential