From 44ead7f83cc2da9208eefc3d1f56c4f27ddf4153 Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:51:32 +0200 Subject: [PATCH] [PR #12181/5a9b0ec8 backport][stable-13] add new `google_chat` module (#12289) add new `google_chat` module (#12181) * feat(module): add new `google_chat` module incl. tests # Conflicts: # .github/BOTMETA.yml * fix: address `check_mode` and `diff_mode` feedback * refactor: switch from message_reply_option to create_new_thread * refactor: split webhook_url into separate module parameters * fix: remove unused pytest import * refactor: remove unused `validate_certs` * fix: add type hints * style: format files once more * fix: move types behind guard to prevent issues on python =< 3.8 --------- Co-authored-by: Tom Scholz <> (cherry picked from commit 5a9b0ec81f0d4937be0f5c2cc59c03bf9e857182) Co-authored-by: Tom Scholz Co-authored-by: Felix Fontein --- .github/BOTMETA.yml | 2 + plugins/modules/google_chat.py | 251 ++++++++++++++++++ .../unit/plugins/modules/test_google_chat.py | 178 +++++++++++++ 3 files changed, 431 insertions(+) create mode 100644 plugins/modules/google_chat.py create mode 100644 tests/unit/plugins/modules/test_google_chat.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 44a8954c1c..b0c74d211b 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -678,6 +678,8 @@ files: maintainers: masa-orca $modules/golang_package.py: maintainers: shrbhosa + $modules/google_chat.py: + maintainers: tomscholz $modules/grove.py: maintainers: zimbatm $modules/gunicorn.py: diff --git a/plugins/modules/google_chat.py b/plugins/modules/google_chat.py new file mode 100644 index 0000000000..c0b0247ff6 --- /dev/null +++ b/plugins/modules/google_chat.py @@ -0,0 +1,251 @@ +#!/usr/bin/python + +# Copyright (c) 2026, Tom Scholz +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: google_chat +short_description: Send Google Chat notifications +version_added: "13.1.0" +description: + - Sends notifications to a Google Chat space using an incoming webhook. + - Incoming webhooks are one-way. They send messages but cannot receive or respond to them. +author: + - Tom Scholz (@tomscholz) +extends_documentation_fragment: + - community.general._attributes +attributes: + check_mode: + support: full + diff_mode: + support: none +options: + space: + type: str + required: true + description: + - The identifier of the Chat space to post to, taken from the incoming webhook URL. + - For a webhook URL of the form C(https://chat.googleapis.com/v1/spaces/AAAA/messages?key=...&token=...), + this is the C(AAAA) part. + key: + type: str + required: true + description: + - The C(key) request parameter from the incoming webhook URL. + - Keep this value secret as it grants the ability to post to the space. + token: + type: str + required: true + description: + - The C(token) request parameter from the incoming webhook URL. + - Keep this value secret as it grants the ability to post to the space. + text: + type: str + required: true + description: + - The text of the message to send. + - 'Emoji must be supplied as Unicode characters (for example V(🚀)). The Chat API does not + render C(:shortcode:) style emoji in plain text messages as they appear as literal text.' + thread_key: + type: str + description: + - An arbitrary key used to start or reply to a message thread. + - When set, O(create_new_thread) controls the behavior when the thread is not found. + create_new_thread: + type: bool + default: true + description: + - Controls behavior when O(thread_key) is set but no matching thread exists. + - When V(true), a new thread is started if no matching thread is found. + - When V(false), the message is only posted if a matching thread already exists, otherwise it fails. + - Only used when O(thread_key) is set. +seealso: + - name: Google Chat incoming webhooks + description: Google's reference for sending messages to Chat with incoming webhooks. + link: https://developers.google.com/workspace/chat/quickstart/webhooks +""" + +EXAMPLES = r""" +- name: Send a notification to Google Chat + community.general.google_chat: + space: SPACE_ID + key: KEY + token: TOKEN + text: '{{ inventory_hostname }} completed' + delegate_to: localhost + +- name: Start a thread + community.general.google_chat: + space: SPACE_ID + key: KEY + token: TOKEN + text: 'Starting a thread' + thread_key: 'deploy-2026-06-01' + create_new_thread: true + +# Post each deploy step into a single thread. The first message creates the thread +# with create_new_thread=true. Follow-ups use create_new_thread=false so they only +# post if the opening message went through, rather than leaving orphan threads. +# Note: webhooks are rate-limited to 1 request per second per space. +- name: Announce deploy start (starts the thread) + community.general.google_chat: + space: "{{ chat_space }}" + key: "{{ chat_key }}" + token: "{{ chat_token }}" + text: "🚀 Starting deploy of *{{ app_version | default('latest') }}* to {{ inventory_hostname }}" + thread_key: "{{ deploy_thread }}" + create_new_thread: true + delegate_to: localhost + run_once: true + # deploy_thread is defined once for the play, for example: + # deploy_thread: "deploy-{{ inventory_hostname }}-{{ ansible_date_time.iso8601_basic_short }}" + +- name: Report a step into the same thread + community.general.google_chat: + space: "{{ chat_space }}" + key: "{{ chat_key }}" + token: "{{ chat_token }}" + text: "✅ Step 1/3 – code checked out" + thread_key: "{{ deploy_thread }}" + create_new_thread: false + delegate_to: localhost + run_once: true + +# Wrap risky tasks so a failure posts to the same thread before a play aborts. +- name: Deploy with failure notification + block: + - name: Restart service + ansible.builtin.systemd: + name: app + state: restarted + + - name: Report success + community.general.google_chat: + space: "{{ chat_space }}" + key: "{{ chat_key }}" + token: "{{ chat_token }}" + text: "🎉 Deploy to {{ inventory_hostname }} complete" + thread_key: "{{ deploy_thread }}" + create_new_thread: false + delegate_to: localhost + run_once: true + rescue: + - name: Report failure into the thread + community.general.google_chat: + space: "{{ chat_space }}" + key: "{{ chat_key }}" + token: "{{ chat_token }}" + text: "❌ Deploy to {{ inventory_hostname }} *failed* – {{ ansible_failed_task.name }}" + thread_key: "{{ deploy_thread }}" + create_new_thread: false + delegate_to: localhost + run_once: true + + - name: Re-raise the failure + ansible.builtin.fail: + msg: "Deploy failed at {{ ansible_failed_task.name }}" +""" + +RETURN = r""" +name: + description: Resource name of the created message, returned by the Chat API. + returned: success + type: str + sample: "spaces/AAAA/messages/BBBB.BBBB" +thread_name: + description: Resource name of the thread the message belongs to. + returned: when the response includes a thread + type: str + sample: "spaces/AAAA/threads/CCCC" +""" + +import typing as t +from urllib.parse import urlencode + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.urls import fetch_url + +BASE_URL = "https://chat.googleapis.com/v1/spaces" + +if t.TYPE_CHECKING: + Payload = dict[str, t.Any] + Response = dict[str, t.Any] + + +def build_payload(text: str, thread_key: str | None) -> Payload: + payload: Payload = {"text": text} + + if thread_key is not None: + payload["thread"] = {"threadKey": thread_key} + return payload + + +def build_url(space: str, key: str, token: str, thread_key: str | None, create_new_thread: bool) -> str: + params = {"key": key, "token": token} + if thread_key is not None: + params["messageReplyOption"] = ( + "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" if create_new_thread else "REPLY_MESSAGE_OR_FAIL" + ) + return f"{BASE_URL}/{space}/messages?{urlencode(params)}" + + +def do_notify(module: AnsibleModule, url: str, payload: Payload) -> Response: + headers = { + "Content-Type": "application/json; charset=UTF-8", + "Accept": "application/json", + } + data = module.jsonify(payload) + response, info = fetch_url(module=module, url=url, headers=headers, method="POST", data=data) + + if info["status"] != 200: + body = info.get("body") + if hasattr(body, "decode"): + body = body.decode("utf-8", errors="replace") + module.fail_json( + msg=f"Failed to send message to Google Chat (HTTP {info['status']}): {body or info.get('msg')}" + ) + + return module.from_json(response.read()) + + +def main() -> None: + module = AnsibleModule( + argument_spec=dict( + space=dict(type="str", required=True), + key=dict(type="str", required=True, no_log=True), + token=dict(type="str", required=True, no_log=True), + text=dict(type="str", required=True), + thread_key=dict(type="str", no_log=False), + create_new_thread=dict(type="bool", default=True), + ), + supports_check_mode=True, + ) + + if module.check_mode: + module.exit_json(changed=True) + + payload = build_payload(module.params["text"], module.params["thread_key"]) + url = build_url( + module.params["space"], + module.params["key"], + module.params["token"], + module.params["thread_key"], + module.params["create_new_thread"], + ) + + response = do_notify(module, url, payload) + + result = {"changed": True} + if "name" in response: + result["name"] = response["name"] + if isinstance(response.get("thread"), dict) and "name" in response["thread"]: + result["thread_name"] = response["thread"]["name"] + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/tests/unit/plugins/modules/test_google_chat.py b/tests/unit/plugins/modules/test_google_chat.py new file mode 100644 index 0000000000..58b2d80e3a --- /dev/null +++ b/tests/unit/plugins/modules/test_google_chat.py @@ -0,0 +1,178 @@ +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +import json +from unittest.mock import Mock, patch + +from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( + AnsibleExitJson, + AnsibleFailJson, + ModuleTestCase, + set_module_args, +) + +from ansible_collections.community.general.plugins.modules import google_chat + +SPACE = "SPACE_ID" +KEY = "KEY" +TOKEN = "TOKEN" +BASE = "https://chat.googleapis.com/v1/spaces/SPACE_ID/messages" + + +def make_response(payload): + """Build a fake fetch_url file-like response whose read() returns JSON text.""" + mock_response = Mock() + mock_response.read.return_value = json.dumps(payload) + return mock_response + + +class TestGoogleChatModule(ModuleTestCase): + def setUp(self): + super().setUp() + self.module = google_chat + + def tearDown(self): + super().tearDown() + + def test_without_required_parameters(self): + """Failure must occur when all parameters are missing""" + with self.assertRaises(AnsibleFailJson): + with set_module_args({}): + self.module.main() + + def test_missing_text(self): + """Failure when connection params are given but text is missing""" + with set_module_args({"space": SPACE, "key": KEY, "token": TOKEN}): + with self.assertRaises(AnsibleFailJson): + self.module.main() + + def test_missing_space(self): + """Failure when text is given but space is missing""" + with set_module_args({"key": KEY, "token": TOKEN, "text": "test"}): + with self.assertRaises(AnsibleFailJson): + self.module.main() + + def test_successful_message(self): + """tests sending a plain message""" + with set_module_args({"space": SPACE, "key": KEY, "token": TOKEN, "text": "test"}): + with patch.object(google_chat, "fetch_url") as fetch_url_mock: + fetch_url_mock.return_value = ( + make_response({"name": "spaces/AAAA/messages/BBBB.BBBB"}), + {"status": 200}, + ) + with self.assertRaises(AnsibleExitJson) as result: + self.module.main() + + self.assertTrue(fetch_url_mock.call_count, 1) + call_data = json.loads(fetch_url_mock.call_args[1]["data"]) + assert call_data["text"] == "test" + assert "thread" not in call_data + url = fetch_url_mock.call_args[1]["url"] + assert url.startswith(BASE + "?") + assert "key=KEY" in url + assert "token=TOKEN" in url + assert "messageReplyOption" not in url + assert fetch_url_mock.call_args[1]["method"] == "POST" + assert result.exception.args[0]["changed"] + assert result.exception.args[0]["name"] == "spaces/AAAA/messages/BBBB.BBBB" + + def test_failed_message(self): + """tests failing to send a message (non-200 response)""" + with set_module_args({"space": SPACE, "key": KEY, "token": TOKEN, "text": "test"}): + with patch.object(google_chat, "fetch_url") as fetch_url_mock: + fetch_url_mock.return_value = ( + None, + {"status": 404, "msg": "not found", "body": b"NOT_FOUND"}, + ) + with self.assertRaises(AnsibleFailJson) as result: + self.module.main() + + assert "Google Chat" in result.exception.args[0]["msg"] + assert "404" in result.exception.args[0]["msg"] + + def test_message_with_thread(self): + """tests sending a message with a thread_key and reading back the thread name""" + with set_module_args({"space": SPACE, "key": KEY, "token": TOKEN, "text": "test", "thread_key": "deploy-1"}): + with patch.object(google_chat, "fetch_url") as fetch_url_mock: + fetch_url_mock.return_value = ( + make_response( + { + "name": "spaces/AAAA/messages/BBBB.BBBB", + "thread": {"name": "spaces/AAAA/threads/CCCC"}, + } + ), + {"status": 200}, + ) + with self.assertRaises(AnsibleExitJson) as result: + self.module.main() + + self.assertTrue(fetch_url_mock.call_count, 1) + call_data = json.loads(fetch_url_mock.call_args[1]["data"]) + assert call_data["text"] == "test" + assert call_data["thread"]["threadKey"] == "deploy-1" + assert result.exception.args[0]["thread_name"] == "spaces/AAAA/threads/CCCC" + + def test_create_new_thread_false_appends_reply_or_fail(self): + """create_new_thread=false must map to REPLY_MESSAGE_OR_FAIL in the URL""" + with set_module_args( + { + "space": SPACE, + "key": KEY, + "token": TOKEN, + "text": "test", + "thread_key": "deploy-1", + "create_new_thread": False, + } + ): + with patch.object(google_chat, "fetch_url") as fetch_url_mock: + fetch_url_mock.return_value = ( + make_response({"name": "spaces/AAAA/messages/BBBB.BBBB"}), + {"status": 200}, + ) + with self.assertRaises(AnsibleExitJson): + self.module.main() + + url = fetch_url_mock.call_args[1]["url"] + assert "messageReplyOption=REPLY_MESSAGE_OR_FAIL" in url + + def test_check_mode(self): + """check mode reports changed and never calls the API""" + with set_module_args({"space": SPACE, "key": KEY, "token": TOKEN, "text": "test", "_ansible_check_mode": True}): + with patch.object(google_chat, "fetch_url") as fetch_url_mock: + with self.assertRaises(AnsibleExitJson) as result: + self.module.main() + + fetch_url_mock.assert_not_called() + assert result.exception.args[0]["changed"] + assert "name" not in result.exception.args[0] + + +def test_build_payload_without_thread(): + payload = google_chat.build_payload("hello", None) + assert payload == {"text": "hello"} + + +def test_build_payload_with_thread(): + payload = google_chat.build_payload("hello", "deploy-1") + assert payload == {"text": "hello", "thread": {"threadKey": "deploy-1"}} + + +def test_build_url_without_thread(): + url = google_chat.build_url(SPACE, KEY, TOKEN, None, True) + assert url.startswith(BASE + "?") + assert "key=KEY" in url + assert "token=TOKEN" in url + assert "messageReplyOption" not in url + + +def test_build_url_create_new_thread_true(): + url = google_chat.build_url(SPACE, KEY, TOKEN, "deploy-1", True) + assert "messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" in url + + +def test_build_url_create_new_thread_false(): + url = google_chat.build_url(SPACE, KEY, TOKEN, "deploy-1", False) + assert "messageReplyOption=REPLY_MESSAGE_OR_FAIL" in url