mirror of
https://github.com/ansible-collections/community.general.git
synced 2026-06-17 13:23:12 +00:00
[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 5a9b0ec81f)
Co-authored-by: Tom Scholz <tomscholz@users.noreply.github.com>
Co-authored-by: Felix Fontein <felix@fontein.de>
This commit is contained in:
parent
2f53f735b4
commit
44ead7f83c
3 changed files with 431 additions and 0 deletions
2
.github/BOTMETA.yml
vendored
2
.github/BOTMETA.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
251
plugins/modules/google_chat.py
Normal file
251
plugins/modules/google_chat.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
#!/usr/bin/python
|
||||
|
||||
# Copyright (c) 2026, Tom Scholz <tomscholz@outlook.com>
|
||||
# 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()
|
||||
178
tests/unit/plugins/modules/test_google_chat.py
Normal file
178
tests/unit/plugins/modules/test_google_chat.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue