diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bc093596..5ac61272 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -347,7 +347,7 @@ jobs: uses: actions/upload-artifact@v2 if: github.event_name == 'push' && matrix.goal != 'test' with: - name: gam-binaries.zip + name: gam-binaries path: | src/*.tar.xz src/*.zip diff --git a/src/gam/__init__.py b/src/gam/__init__.py index bd7a5ed2..6f38e2b6 100755 --- a/src/gam/__init__.py +++ b/src/gam/__init__.py @@ -38,6 +38,7 @@ import googleapiclient.errors import googleapiclient.http import google.oauth2.service_account import httplib2 +from google.auth.jwt import Credentials as JWTCredentials from cryptography import x509 from cryptography.hazmat.backends import default_backend @@ -53,6 +54,7 @@ from gam import fileutils from gam.gapi import calendar as gapi_calendar from gam.gapi import cloudidentity as gapi_cloudidentity from gam.gapi import cbcm as gapi_cbcm +from gam.gapi import chat as gapi_chat from gam.gapi import chromehistory as gapi_chromehistory from gam.gapi import chromemanagement as gapi_chromemanagement from gam.gapi import chromepolicy as gapi_chromepolicy @@ -822,21 +824,32 @@ def _getSvcAcctData(): controlflow.system_error_exit(6, None) GM_Globals[GM_OAUTH2SERVICE_JSON_DATA] = json.loads(json_string) - -def getSvcAcctCredentials(scopes, act_as): +jwt_apis = ['chat'] # APIs which can handle OAuthless JWT tokens +def getSvcAcctCredentials(scopes, act_as, api=None): try: _getSvcAcctData() sign_method = GM_Globals[GM_OAUTH2SERVICE_JSON_DATA].get('key_type', 'default') - if sign_method == 'default': - credentials = google.oauth2.service_account.Credentials.from_service_account_info( - GM_Globals[GM_OAUTH2SERVICE_JSON_DATA]) - elif sign_method == 'yubikey': - yksigner = yubikey.YubiKey(GM_Globals[GM_OAUTH2SERVICE_JSON_DATA]) - credentials = google.oauth2.service_account.Credentials._from_signer_and_info(yksigner, - GM_Globals[GM_OAUTH2SERVICE_JSON_DATA]) - credentials = credentials.with_scopes(scopes) - if act_as: - credentials = credentials.with_subject(act_as) + if act_as or api not in jwt_apis: + if sign_method == 'default': + credentials = google.oauth2.service_account.Credentials.from_service_account_info( + GM_Globals[GM_OAUTH2SERVICE_JSON_DATA]) + elif sign_method == 'yubikey': + yksigner = yubikey.YubiKey(GM_Globals[GM_OAUTH2SERVICE_JSON_DATA]) + credentials = google.oauth2.service_account.Credentials._from_signer_and_info(yksigner, + GM_Globals[GM_OAUTH2SERVICE_JSON_DATA]) + credentials = credentials.with_scopes(scopes) + if act_as: + credentials = credentials.with_subject(act_as) + else: + audience = f'https://{api}.googleapis.com/' + if sign_method == 'default': + return JWTCredentials.from_service_account_info(GM_Globals[GM_OAUTH2SERVICE_JSON_DATA], + audience=audience) + elif sign_method == 'yubikey': + yksigner = yubikey.YubiKey(GM_Globals[GM_OAUTH2SERVICE_JSON_DATA]) + credentials = JWTCredentials._from_signer_and_info(yksigner, + GM_Globals[GM_OAUTH2SERVICE_JSON_DATA], + audience=audience) GM_Globals[GM_OAUTH2SERVICE_ACCOUNT_CLIENT_ID] = GM_Globals[ GM_OAUTH2SERVICE_JSON_DATA]['client_id'] return credentials @@ -1087,14 +1100,17 @@ def convertEmailAddressToUID(emailAddressOrUID, cd=None, email_type='user'): return normalizedEmailAddressOrUID -def buildGAPIServiceObject(api, act_as, showAuthError=True): +def buildGAPIServiceObject(api, act_as, showAuthError=True, scopes=None): httpObj = transport.create_http(cache=GM_Globals[GM_CACHE_DIR]) service = getService(api, httpObj) GM_Globals[GM_CURRENT_API_USER] = act_as - GM_Globals[GM_CURRENT_API_SCOPES] = API_SCOPE_MAPPING.get( - api, service._rootDesc['auth']['oauth2']['scopes']) + if scopes: + GM_Globals[GM_CURRENT_API_SCOPES] = scopes + else: + GM_Globals[GM_CURRENT_API_SCOPES] = API_SCOPE_MAPPING.get( + api, service._rootDesc['auth']['oauth2']['scopes']) credentials = getSvcAcctCredentials(GM_Globals[GM_CURRENT_API_SCOPES], - act_as) + act_as, api) request = transport.create_request(httpObj) retries = 3 for n in range(1, retries + 1): @@ -11342,6 +11358,8 @@ def ProcessGAMCommand(args): gapi_cbcm.createtoken() elif argument in ['printer']: gapi_directory_printers.create() + elif argument in ['chatmessage']: + gapi_chat.create_message() else: controlflow.invalid_argument_exit(argument, 'gam create') sys.exit(0) @@ -11404,6 +11422,8 @@ def ProcessGAMCommand(args): gapi_chromepolicy.update_policy() elif argument in ['printer']: gapi_directory_printers.update() + elif argument in ['chatmessage']: + gapi_chat.update_message() else: controlflow.invalid_argument_exit(argument, 'gam update') sys.exit(0) @@ -11540,6 +11560,8 @@ def ProcessGAMCommand(args): gapi_directory_printers.delete() elif argument == 'chromepolicy': gapi_chromepolicy.delete_policy() + elif argument == 'chatmessage': + gapi_chat.delete_message() else: controlflow.invalid_argument_exit(argument, 'gam delete') sys.exit(0) @@ -11655,6 +11677,10 @@ def ProcessGAMCommand(args): gapi_chromemanagement.printVersions() elif argument in ['chromehistory']: gapi_chromehistory.printHistory() + elif argument in ['chatspaces']: + gapi_chat.print_spaces() + elif argument in ['chatmembers']: + gapi_chat.print_members() else: controlflow.invalid_argument_exit(argument, 'gam print') sys.exit(0) diff --git a/src/gam/gapi/chat.py b/src/gam/gapi/chat.py new file mode 100644 index 00000000..d65db991 --- /dev/null +++ b/src/gam/gapi/chat.py @@ -0,0 +1,177 @@ +import sys + +import googleapiclient.errors + +import gam +from gam.var import * +from gam import controlflow +from gam import display +from gam import fileutils +from gam import gapi +from gam import utils +from gam.gapi import errors as gapi_errors + +# Chat scope isn't in discovery doc so need to manually set +CHAT_SCOPES = ['https://www.googleapis.com/auth/chat.bot'] + + +def build(): + return gam.buildGAPIServiceObject('chat', + act_as=None, + scopes=CHAT_SCOPES) + + +THROW_REASONS = [ + gapi_errors.ErrorReason.FOUR_O_FOUR, # Chat API not configured + gapi_errors.ErrorReason.FOUR_O_THREE, # Bot not added to room + ] + +def _chat_error_handler(chat, err): + if err.status_code == 404: + project_id = chat._http.credentials.project_id + url = f'https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat?project={project_id}' + print('ERROR: you need to configure Google Chat for your API project. Please go to:') + print() + print(url) + print() + print('and complete all fields.') + elif err.status_code == 403: + print('ERROR: no access to that Chat space or message. Make sure your bot created the mesage and the user has chatted the bot first or added it to the Chat room') + sys.exit(1) + + +def print_spaces(): + chat = build() + try: + spaces = gapi.get_all_pages(chat.spaces(), 'list', 'spaces', throw_reasons=THROW_REASONS) + except googleapiclient.errors.HttpError as err: + _chat_error_handler(chat, err) + if not spaces: + print('Bot not added to any Chat rooms or users yet.') + else: + display.write_csv_file(spaces, spaces[0].keys(), 'Chat Spaces', False) + + +def print_members(): + chat = build() + space = None + i = 3 + while i < len(sys.argv): + myarg = sys.argv[i].lower() + if myarg == 'space': + space = sys.argv[i+1] + if space[:7] != 'spaces/': + space = f'spaces/{space}' + i += 2 + else: + controlflow.invalid_argument_exit(myarg, "gam print chatmembers") + try: + results = gapi.get_all_pages(chat.spaces().members(), 'list', 'memberships', parent=space) + except googleapiclient.errors.HttpError as err: + _chat_error_handler(chat, err) + members = [] + titles = [] + for result in results: + member = utils.flatten_json(result) + for key in member: + if key not in titles: + titles.append(key) + members.append(utils.flatten_json(result)) + display.write_csv_file(members, titles, 'Chat Members', False) + + +def create_message(): + chat = build() + body = {} + i = 3 + while i < len(sys.argv): + myarg = sys.argv[i].lower() + if myarg == 'text': + body['text'] = sys.argv[i+1] + i += 2 + elif myarg == 'textfile': + filename = sys.argv[i + 1] + i, encoding = gam.getCharSet(i + 2) + body['text'] = fileutils.read_file(filename, encoding=encoding) + elif myarg == 'space': + space = sys.argv[i+1] + if space[:7] != 'spaces/': + space = f'spaces/{space}' + i += 2 + elif myarg == 'thread': + body['thread'] = {'name': sys.argv[i+1]} + i += 2 + else: + controlflow.invalid_argument_exit(myarg, "gam create chat") + if len(body['text']) > 4096: + body['text'] = body['text'][:4095] + print('WARNING: trimmed message longer than 4k to be 4k in length.') + try: + resp = gapi.call(chat.spaces().messages(), + 'create', + parent=space, + body=body, + throw_reasons=THROW_REASONS) + except googleapiclient.errors.HttpError as err: + _chat_error_handler(chat, err) + if 'thread' in body: + print(f'responded to thread {resp["thread"]["name"]}') + else: + print(f'started new thread {resp["thread"]["name"]}') + print(f'message {resp["name"]}') + +def delete_message(): + chat = build() + name = None + i = 3 + while i < len(sys.argv): + myarg = sys.argv[i].lower() + if myarg == 'name': + name = sys.argv[i+1] + i += 2 + else: + controlflow.invalid_argument_exit(myarg, "gam delete chat") + try: + gapi.call(chat.spaces().messages(), + 'delete', + name=name) + except googleapiclient.errors.HttpError as err: + _chat_error_handler(chat, err) + + +def update_message(): + chat = build() + body = {} + name = None + updateMask = 'text' + i = 3 + while i < len(sys.argv): + myarg = sys.argv[i].lower() + if myarg == 'text': + body['text'] = sys.argv[i+1] + i += 2 + elif myarg == 'textfile': + filename = sys.argv[i + 1] + i, encoding = gam.getCharSet(i + 2) + body['text'] = fileutils.read_file(filename, encoding=encoding) + elif myarg == 'name': + name = sys.argv[i+1] + i += 2 + else: + controlflow.invalid_argument_exit(myarg, "gam update chat") + if len(body['text']) > 4096: + body['text'] = body['text'][:4095] + print('WARNING: trimmed message longer than 4k to be 4k in length.') + try: + resp = gapi.call(chat.spaces().messages(), + 'update', + name=name, + updateMask=updateMask, + body=body) + except googleapiclient.errors.HttpError as err: + _chat_error_handler(chat, err) + if 'thread' in body: + print(f'updated response to thread {resp["thread"]["name"]}') + else: + print(f'updated message on thread {resp["thread"]["name"]}') + print(f'message {resp["name"]}') diff --git a/src/gam/gapi/errors.py b/src/gam/gapi/errors.py index bbbc5185..1b7c71b2 100644 --- a/src/gam/gapi/errors.py +++ b/src/gam/gapi/errors.py @@ -119,6 +119,7 @@ class ErrorReason(Enum): FIVE_O_THREE = '503' FOUR_O_NINE = '409' FOUR_O_O = '400' + FOUR_O_FOUR = '404' FOUR_O_THREE = '403' FOUR_TWO_NINE = '429' GATEWAY_TIMEOUT = 'gatewayTimeout'