Skip to content

Commit 44ead7f

Browse files
patchback[bot]tomscholzfelixfontein
authored
[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 5a9b0ec) Co-authored-by: Tom Scholz <tomscholz@users.noreply.github.com> Co-authored-by: Felix Fontein <felix@fontein.de>
1 parent 2f53f73 commit 44ead7f

3 files changed

Lines changed: 431 additions & 0 deletions

File tree

.github/BOTMETA.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,8 @@ files:
678678
maintainers: masa-orca
679679
$modules/golang_package.py:
680680
maintainers: shrbhosa
681+
$modules/google_chat.py:
682+
maintainers: tomscholz
681683
$modules/grove.py:
682684
maintainers: zimbatm
683685
$modules/gunicorn.py:

plugins/modules/google_chat.py

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
#!/usr/bin/python
2+
3+
# Copyright (c) 2026, Tom Scholz <tomscholz@outlook.com>
4+
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
5+
# SPDX-License-Identifier: GPL-3.0-or-later
6+
7+
from __future__ import annotations
8+
9+
DOCUMENTATION = r"""
10+
module: google_chat
11+
short_description: Send Google Chat notifications
12+
version_added: "13.1.0"
13+
description:
14+
- Sends notifications to a Google Chat space using an incoming webhook.
15+
- Incoming webhooks are one-way. They send messages but cannot receive or respond to them.
16+
author:
17+
- Tom Scholz (@tomscholz)
18+
extends_documentation_fragment:
19+
- community.general._attributes
20+
attributes:
21+
check_mode:
22+
support: full
23+
diff_mode:
24+
support: none
25+
options:
26+
space:
27+
type: str
28+
required: true
29+
description:
30+
- The identifier of the Chat space to post to, taken from the incoming webhook URL.
31+
- For a webhook URL of the form C(https://chat.googleapis.com/v1/spaces/AAAA/messages?key=...&token=...),
32+
this is the C(AAAA) part.
33+
key:
34+
type: str
35+
required: true
36+
description:
37+
- The C(key) request parameter from the incoming webhook URL.
38+
- Keep this value secret as it grants the ability to post to the space.
39+
token:
40+
type: str
41+
required: true
42+
description:
43+
- The C(token) request parameter from the incoming webhook URL.
44+
- Keep this value secret as it grants the ability to post to the space.
45+
text:
46+
type: str
47+
required: true
48+
description:
49+
- The text of the message to send.
50+
- 'Emoji must be supplied as Unicode characters (for example V(🚀)). The Chat API does not
51+
render C(:shortcode:) style emoji in plain text messages as they appear as literal text.'
52+
thread_key:
53+
type: str
54+
description:
55+
- An arbitrary key used to start or reply to a message thread.
56+
- When set, O(create_new_thread) controls the behavior when the thread is not found.
57+
create_new_thread:
58+
type: bool
59+
default: true
60+
description:
61+
- Controls behavior when O(thread_key) is set but no matching thread exists.
62+
- When V(true), a new thread is started if no matching thread is found.
63+
- When V(false), the message is only posted if a matching thread already exists, otherwise it fails.
64+
- Only used when O(thread_key) is set.
65+
seealso:
66+
- name: Google Chat incoming webhooks
67+
description: Google's reference for sending messages to Chat with incoming webhooks.
68+
link: https://developers.google.com/workspace/chat/quickstart/webhooks
69+
"""
70+
71+
EXAMPLES = r"""
72+
- name: Send a notification to Google Chat
73+
community.general.google_chat:
74+
space: SPACE_ID
75+
key: KEY
76+
token: TOKEN
77+
text: '{{ inventory_hostname }} completed'
78+
delegate_to: localhost
79+
80+
- name: Start a thread
81+
community.general.google_chat:
82+
space: SPACE_ID
83+
key: KEY
84+
token: TOKEN
85+
text: 'Starting a thread'
86+
thread_key: 'deploy-2026-06-01'
87+
create_new_thread: true
88+
89+
# Post each deploy step into a single thread. The first message creates the thread
90+
# with create_new_thread=true. Follow-ups use create_new_thread=false so they only
91+
# post if the opening message went through, rather than leaving orphan threads.
92+
# Note: webhooks are rate-limited to 1 request per second per space.
93+
- name: Announce deploy start (starts the thread)
94+
community.general.google_chat:
95+
space: "{{ chat_space }}"
96+
key: "{{ chat_key }}"
97+
token: "{{ chat_token }}"
98+
text: "🚀 Starting deploy of *{{ app_version | default('latest') }}* to {{ inventory_hostname }}"
99+
thread_key: "{{ deploy_thread }}"
100+
create_new_thread: true
101+
delegate_to: localhost
102+
run_once: true
103+
# deploy_thread is defined once for the play, for example:
104+
# deploy_thread: "deploy-{{ inventory_hostname }}-{{ ansible_date_time.iso8601_basic_short }}"
105+
106+
- name: Report a step into the same thread
107+
community.general.google_chat:
108+
space: "{{ chat_space }}"
109+
key: "{{ chat_key }}"
110+
token: "{{ chat_token }}"
111+
text: "✅ Step 1/3 – code checked out"
112+
thread_key: "{{ deploy_thread }}"
113+
create_new_thread: false
114+
delegate_to: localhost
115+
run_once: true
116+
117+
# Wrap risky tasks so a failure posts to the same thread before a play aborts.
118+
- name: Deploy with failure notification
119+
block:
120+
- name: Restart service
121+
ansible.builtin.systemd:
122+
name: app
123+
state: restarted
124+
125+
- name: Report success
126+
community.general.google_chat:
127+
space: "{{ chat_space }}"
128+
key: "{{ chat_key }}"
129+
token: "{{ chat_token }}"
130+
text: "🎉 Deploy to {{ inventory_hostname }} complete"
131+
thread_key: "{{ deploy_thread }}"
132+
create_new_thread: false
133+
delegate_to: localhost
134+
run_once: true
135+
rescue:
136+
- name: Report failure into the thread
137+
community.general.google_chat:
138+
space: "{{ chat_space }}"
139+
key: "{{ chat_key }}"
140+
token: "{{ chat_token }}"
141+
text: "❌ Deploy to {{ inventory_hostname }} *failed* – {{ ansible_failed_task.name }}"
142+
thread_key: "{{ deploy_thread }}"
143+
create_new_thread: false
144+
delegate_to: localhost
145+
run_once: true
146+
147+
- name: Re-raise the failure
148+
ansible.builtin.fail:
149+
msg: "Deploy failed at {{ ansible_failed_task.name }}"
150+
"""
151+
152+
RETURN = r"""
153+
name:
154+
description: Resource name of the created message, returned by the Chat API.
155+
returned: success
156+
type: str
157+
sample: "spaces/AAAA/messages/BBBB.BBBB"
158+
thread_name:
159+
description: Resource name of the thread the message belongs to.
160+
returned: when the response includes a thread
161+
type: str
162+
sample: "spaces/AAAA/threads/CCCC"
163+
"""
164+
165+
import typing as t
166+
from urllib.parse import urlencode
167+
168+
from ansible.module_utils.basic import AnsibleModule
169+
from ansible.module_utils.urls import fetch_url
170+
171+
BASE_URL = "https://chat.googleapis.com/v1/spaces"
172+
173+
if t.TYPE_CHECKING:
174+
Payload = dict[str, t.Any]
175+
Response = dict[str, t.Any]
176+
177+
178+
def build_payload(text: str, thread_key: str | None) -> Payload:
179+
payload: Payload = {"text": text}
180+
181+
if thread_key is not None:
182+
payload["thread"] = {"threadKey": thread_key}
183+
return payload
184+
185+
186+
def build_url(space: str, key: str, token: str, thread_key: str | None, create_new_thread: bool) -> str:
187+
params = {"key": key, "token": token}
188+
if thread_key is not None:
189+
params["messageReplyOption"] = (
190+
"REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" if create_new_thread else "REPLY_MESSAGE_OR_FAIL"
191+
)
192+
return f"{BASE_URL}/{space}/messages?{urlencode(params)}"
193+
194+
195+
def do_notify(module: AnsibleModule, url: str, payload: Payload) -> Response:
196+
headers = {
197+
"Content-Type": "application/json; charset=UTF-8",
198+
"Accept": "application/json",
199+
}
200+
data = module.jsonify(payload)
201+
response, info = fetch_url(module=module, url=url, headers=headers, method="POST", data=data)
202+
203+
if info["status"] != 200:
204+
body = info.get("body")
205+
if hasattr(body, "decode"):
206+
body = body.decode("utf-8", errors="replace")
207+
module.fail_json(
208+
msg=f"Failed to send message to Google Chat (HTTP {info['status']}): {body or info.get('msg')}"
209+
)
210+
211+
return module.from_json(response.read())
212+
213+
214+
def main() -> None:
215+
module = AnsibleModule(
216+
argument_spec=dict(
217+
space=dict(type="str", required=True),
218+
key=dict(type="str", required=True, no_log=True),
219+
token=dict(type="str", required=True, no_log=True),
220+
text=dict(type="str", required=True),
221+
thread_key=dict(type="str", no_log=False),
222+
create_new_thread=dict(type="bool", default=True),
223+
),
224+
supports_check_mode=True,
225+
)
226+
227+
if module.check_mode:
228+
module.exit_json(changed=True)
229+
230+
payload = build_payload(module.params["text"], module.params["thread_key"])
231+
url = build_url(
232+
module.params["space"],
233+
module.params["key"],
234+
module.params["token"],
235+
module.params["thread_key"],
236+
module.params["create_new_thread"],
237+
)
238+
239+
response = do_notify(module, url, payload)
240+
241+
result = {"changed": True}
242+
if "name" in response:
243+
result["name"] = response["name"]
244+
if isinstance(response.get("thread"), dict) and "name" in response["thread"]:
245+
result["thread_name"] = response["thread"]["name"]
246+
247+
module.exit_json(**result)
248+
249+
250+
if __name__ == "__main__":
251+
main()

0 commit comments

Comments
 (0)