pi-agent-toolkit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dotfiles/AGENTS.md +197 -0
- package/dist/dotfiles/APPEND_SYSTEM.md +78 -0
- package/dist/dotfiles/agent-modes.json +12 -0
- package/dist/dotfiles/agent-skills/exa-search/.env.example +4 -0
- package/dist/dotfiles/agent-skills/exa-search/SKILL.md +234 -0
- package/dist/dotfiles/agent-skills/exa-search/scripts/exa-api.cjs +197 -0
- package/dist/dotfiles/auth.json.template +5 -0
- package/dist/dotfiles/damage-control-rules.yaml +318 -0
- package/dist/dotfiles/extensions/btw.ts +1031 -0
- package/dist/dotfiles/extensions/commit-approval.ts +590 -0
- package/dist/dotfiles/extensions/context.ts +578 -0
- package/dist/dotfiles/extensions/control.ts +1748 -0
- package/dist/dotfiles/extensions/damage-control/index.ts +543 -0
- package/dist/dotfiles/extensions/damage-control/node_modules/.package-lock.json +22 -0
- package/dist/dotfiles/extensions/damage-control/package-lock.json +28 -0
- package/dist/dotfiles/extensions/damage-control/package.json +7 -0
- package/dist/dotfiles/extensions/dirty-repo-guard.ts +56 -0
- package/dist/dotfiles/extensions/exa-enforce.ts +51 -0
- package/dist/dotfiles/extensions/exa-search-tool.ts +384 -0
- package/dist/dotfiles/extensions/execute-command/index.ts +82 -0
- package/dist/dotfiles/extensions/files.ts +1112 -0
- package/dist/dotfiles/extensions/loop.ts +446 -0
- package/dist/dotfiles/extensions/pr-approval.ts +730 -0
- package/dist/dotfiles/extensions/qna-interactive.ts +532 -0
- package/dist/dotfiles/extensions/question-mode.ts +242 -0
- package/dist/dotfiles/extensions/require-session-name-on-exit.ts +141 -0
- package/dist/dotfiles/extensions/review.ts +2091 -0
- package/dist/dotfiles/extensions/session-breakdown.ts +1629 -0
- package/dist/dotfiles/extensions/term-notify.ts +150 -0
- package/dist/dotfiles/extensions/tilldone.ts +527 -0
- package/dist/dotfiles/extensions/todos.ts +2082 -0
- package/dist/dotfiles/extensions/tools.ts +146 -0
- package/dist/dotfiles/extensions/uv.ts +123 -0
- package/dist/dotfiles/global-skills/brainstorm/SKILL.md +10 -0
- package/dist/dotfiles/global-skills/cli-detector/SKILL.md +192 -0
- package/dist/dotfiles/global-skills/gh-issue-creator/SKILL.md +173 -0
- package/dist/dotfiles/global-skills/google-chat-cards-v2/SKILL.md +237 -0
- package/dist/dotfiles/global-skills/google-chat-cards-v2/references/bridge_tap_implementation.md +466 -0
- package/dist/dotfiles/global-skills/technical-docs/SKILL.md +204 -0
- package/dist/dotfiles/global-skills/technical-docs/references/diagrams.md +168 -0
- package/dist/dotfiles/global-skills/technical-docs/references/examples.md +449 -0
- package/dist/dotfiles/global-skills/technical-docs/scripts/validate_docs.py +352 -0
- package/dist/dotfiles/global-skills/whats-new/SKILL.md +159 -0
- package/dist/dotfiles/intercepted-commands/pip +7 -0
- package/dist/dotfiles/intercepted-commands/pip3 +7 -0
- package/dist/dotfiles/intercepted-commands/poetry +10 -0
- package/dist/dotfiles/intercepted-commands/python +104 -0
- package/dist/dotfiles/intercepted-commands/python3 +104 -0
- package/dist/dotfiles/mcp.json.template +32 -0
- package/dist/dotfiles/models.json +27 -0
- package/dist/dotfiles/settings.json +25 -0
- package/dist/index.js +1344 -0
- package/package.json +34 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: google-chat-cards-v2
|
|
3
|
+
description: Implement Google Chat Cards v2 format for rich alert notifications. This skill should be used when migrating from plain text messages to Cards v2 format, adding visual alerts to Google Chat webhooks, or building card-based notification systems.
|
|
4
|
+
context: fork
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Google Chat Cards v2 Implementation
|
|
8
|
+
|
|
9
|
+
This skill provides patterns and guidance for implementing Google Chat Cards v2 rich message format in Python services.
|
|
10
|
+
|
|
11
|
+
## When to Use
|
|
12
|
+
|
|
13
|
+
- Migrating existing plain text Google Chat alerts to Cards v2 format
|
|
14
|
+
- Adding new alert/notification functionality with rich formatting
|
|
15
|
+
- Implementing error alerting systems with structured card layouts
|
|
16
|
+
|
|
17
|
+
## Cards v2 Structure
|
|
18
|
+
|
|
19
|
+
Cards v2 uses a nested structure with header, sections, and widgets:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"cardsV2": [{
|
|
24
|
+
"cardId": "unique-card-id",
|
|
25
|
+
"card": {
|
|
26
|
+
"header": {
|
|
27
|
+
"title": "Alert Title",
|
|
28
|
+
"subtitle": "service-name",
|
|
29
|
+
"imageUrl": "https://icon-url.svg",
|
|
30
|
+
"imageType": "CIRCLE"
|
|
31
|
+
},
|
|
32
|
+
"sections": [
|
|
33
|
+
{
|
|
34
|
+
"header": "Section Header (optional)",
|
|
35
|
+
"widgets": [...]
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
}]
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Implementation Pattern
|
|
44
|
+
|
|
45
|
+
### Step 1: Add Module-Level Retry Configuration
|
|
46
|
+
|
|
47
|
+
Extract retry configuration to a module-level constant for reuse across methods:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
|
51
|
+
import httpx
|
|
52
|
+
|
|
53
|
+
# Shared retry configuration for network errors
|
|
54
|
+
_RETRY_CONFIG = retry(
|
|
55
|
+
stop=stop_after_attempt(3),
|
|
56
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
57
|
+
retry=retry_if_exception_type((httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError)),
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Step 2: Add send_card() Method
|
|
62
|
+
|
|
63
|
+
Add a method to send Cards v2 payloads alongside existing `send_message()`:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
@_RETRY_CONFIG
|
|
67
|
+
def send_card(self, card_payload: dict[str, Any]) -> ChatSendMessageResponse:
|
|
68
|
+
"""Send a Cards v2 message to Google Chat.
|
|
69
|
+
|
|
70
|
+
:param card_payload: The card payload dict with cardsV2 structure.
|
|
71
|
+
:returns: Response containing success status.
|
|
72
|
+
"""
|
|
73
|
+
if error := self._validate_webhook("Card"):
|
|
74
|
+
return error
|
|
75
|
+
if "cardsV2" not in card_payload:
|
|
76
|
+
logger.error("Invalid card payload: missing 'cardsV2' key")
|
|
77
|
+
return ChatSendMessageResponse(success=False, error="Invalid card payload: missing 'cardsV2' key")
|
|
78
|
+
return self._send_request(card_payload)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Step 3: Define Class Constants
|
|
82
|
+
|
|
83
|
+
Use ClassVar for static mappings that define card content:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from typing import ClassVar
|
|
87
|
+
|
|
88
|
+
class AlerterClass:
|
|
89
|
+
SERVICE_NAME: ClassVar[str] = "my-service-name"
|
|
90
|
+
|
|
91
|
+
CATEGORY_TITLE_MAP: ClassVar[dict[ErrorCategory, str]] = {
|
|
92
|
+
ErrorCategory.CONFIG_ERROR: "Configuration Error",
|
|
93
|
+
ErrorCategory.SYSTEM_ERROR: "System Error",
|
|
94
|
+
# ... add your categories
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# Google Material Symbols icons from Google Fonts CDN
|
|
98
|
+
# Source: https://fonts.google.com/icons
|
|
99
|
+
SEVERITY_ICON_URLS: ClassVar[dict[SeverityLevel, str]] = {
|
|
100
|
+
SeverityLevel.CRITICAL: "https://fonts.gstatic.com/s/i/short-term/release/googlesymbols/error/default/48px.svg",
|
|
101
|
+
SeverityLevel.HIGH: "https://fonts.gstatic.com/s/i/short-term/release/googlesymbols/warning/default/48px.svg",
|
|
102
|
+
SeverityLevel.MEDIUM: "https://fonts.gstatic.com/s/i/short-term/release/googlesymbols/info/default/48px.svg",
|
|
103
|
+
SeverityLevel.LOW: "https://fonts.gstatic.com/s/i/short-term/release/googlesymbols/check_circle/default/48px.svg",
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Step 4: Build Card Payload with Helper Methods
|
|
108
|
+
|
|
109
|
+
Break card building into focused helper methods:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
def _build_card_payload(self, payload: ErrorAlertPayload) -> dict[str, Any]:
|
|
113
|
+
"""Build Cards v2 payload for Google Chat webhook."""
|
|
114
|
+
card_id = f"alert-{payload.job_id}-{payload.timestamp.strftime('%Y%m%d%H%M%S')}"
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"cardsV2": [{
|
|
118
|
+
"cardId": card_id,
|
|
119
|
+
"card": {
|
|
120
|
+
"header": self._build_card_header(payload),
|
|
121
|
+
"sections": [
|
|
122
|
+
self._build_job_info_section(payload),
|
|
123
|
+
self._build_error_details_section(payload),
|
|
124
|
+
self._build_error_message_section(payload),
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
}]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
def _build_card_header(self, payload: ErrorAlertPayload) -> dict[str, Any]:
|
|
131
|
+
"""Build the card header section."""
|
|
132
|
+
return {
|
|
133
|
+
"title": f"Alert: {self.CATEGORY_TITLE_MAP.get(payload.error_category, 'Error')}",
|
|
134
|
+
"subtitle": self.SERVICE_NAME,
|
|
135
|
+
"imageUrl": self.SEVERITY_ICON_URLS.get(payload.severity),
|
|
136
|
+
"imageType": "CIRCLE",
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Widget Types
|
|
141
|
+
|
|
142
|
+
### Decorated Text (with icons)
|
|
143
|
+
|
|
144
|
+
Use for key-value pairs with optional icons:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
{
|
|
148
|
+
"decoratedText": {
|
|
149
|
+
"startIcon": {"knownIcon": "BOOKMARK"}, # Optional icon
|
|
150
|
+
"topLabel": "Job ID",
|
|
151
|
+
"text": "12345",
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Available `knownIcon` values: `BOOKMARK`, `DESCRIPTION`, `PERSON`, `CLOCK`, `EMAIL`, `STAR`, etc.
|
|
157
|
+
|
|
158
|
+
### Decorated Text (label only)
|
|
159
|
+
|
|
160
|
+
For simpler key-value display:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
{"decoratedText": {"topLabel": "Stage", "text": "validation"}}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Text Paragraph
|
|
167
|
+
|
|
168
|
+
For longer text content:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
{"textParagraph": {"text": "Error message or description here"}}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Section Structure
|
|
175
|
+
|
|
176
|
+
Sections can have an optional header and contain widgets:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
{
|
|
180
|
+
"header": "Error Details", # Optional
|
|
181
|
+
"widgets": [
|
|
182
|
+
{"decoratedText": {"topLabel": "Stage", "text": payload.stage}},
|
|
183
|
+
{"decoratedText": {"topLabel": "Category", "text": payload.error_category.value}},
|
|
184
|
+
],
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Testing Pattern
|
|
189
|
+
|
|
190
|
+
Use pytest fixtures for reusable test setup:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
@pytest.fixture
|
|
194
|
+
def alerter() -> Generator[ErrorAlerter, None, None]:
|
|
195
|
+
alerter = ErrorAlerter(webhook_url="https://webhook.test")
|
|
196
|
+
yield alerter
|
|
197
|
+
alerter.close()
|
|
198
|
+
|
|
199
|
+
@pytest.fixture
|
|
200
|
+
def mock_successful_response() -> MagicMock:
|
|
201
|
+
mock_response = MagicMock()
|
|
202
|
+
mock_response.status_code = 200
|
|
203
|
+
mock_response.content = b'{"name": "spaces/xxx/messages/yyy"}'
|
|
204
|
+
mock_response.json.return_value = {"name": "spaces/xxx/messages/yyy"}
|
|
205
|
+
mock_response.raise_for_status = MagicMock()
|
|
206
|
+
return mock_response
|
|
207
|
+
|
|
208
|
+
def test_send_alert_builds_card_v2_payload(alerter, mock_successful_response):
|
|
209
|
+
"""Test that send_alert builds proper Cards v2 structure."""
|
|
210
|
+
with patch.object(alerter._client.client, "post", return_value=mock_successful_response):
|
|
211
|
+
result = alerter.send_alert(...)
|
|
212
|
+
|
|
213
|
+
# Verify cardsV2 structure
|
|
214
|
+
call_args = alerter._client.client.post.call_args
|
|
215
|
+
payload = call_args.kwargs["json"]
|
|
216
|
+
assert "cardsV2" in payload
|
|
217
|
+
assert payload["cardsV2"][0]["card"]["header"]["title"] == "Expected Title"
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Reference Implementation
|
|
221
|
+
|
|
222
|
+
See `references/bridge_tap_implementation.md` for the complete reference implementation from bridge-tap-api.
|
|
223
|
+
|
|
224
|
+
## Checklist
|
|
225
|
+
|
|
226
|
+
When implementing Cards v2:
|
|
227
|
+
|
|
228
|
+
1. [ ] Add module-level `_RETRY_CONFIG` constant
|
|
229
|
+
2. [ ] Add `send_card()` method with validation
|
|
230
|
+
3. [ ] Define `SERVICE_NAME` class constant
|
|
231
|
+
4. [ ] Define `CATEGORY_TITLE_MAP` for alert titles
|
|
232
|
+
5. [ ] Define `SEVERITY_ICON_URLS` with Google Material Symbols
|
|
233
|
+
6. [ ] Create `_build_card_payload()` method
|
|
234
|
+
7. [ ] Create helper methods for each section
|
|
235
|
+
8. [ ] Update tests with pytest fixtures
|
|
236
|
+
9. [ ] Verify card structure in tests
|
|
237
|
+
10. [ ] Run type checks (mypy) and linting (ruff)
|
package/dist/dotfiles/global-skills/google-chat-cards-v2/references/bridge_tap_implementation.md
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
# Bridge-TAP API Reference Implementation
|
|
2
|
+
|
|
3
|
+
Complete reference implementation of Google Chat Cards v2 for error alerting.
|
|
4
|
+
|
|
5
|
+
## Full Client Implementation
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
"""Google Chat client for sending notifications to Google Chat spaces."""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
from typing import Any, ClassVar
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from loguru import logger
|
|
17
|
+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
|
18
|
+
|
|
19
|
+
from bridge_tap_api.sdk.google_chat.models import (
|
|
20
|
+
ChatSendMessageRequest,
|
|
21
|
+
ChatSendMessageResponse,
|
|
22
|
+
ErrorAlertPayload,
|
|
23
|
+
ErrorCategory,
|
|
24
|
+
GoogleChatClientConfig,
|
|
25
|
+
MessageType,
|
|
26
|
+
SeverityLevel,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Shared retry configuration for network errors
|
|
30
|
+
_RETRY_CONFIG = retry(
|
|
31
|
+
stop=stop_after_attempt(3),
|
|
32
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
33
|
+
retry=retry_if_exception_type((httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError)),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GoogleChatClient:
|
|
38
|
+
"""Client for sending messages to Google Chat spaces."""
|
|
39
|
+
|
|
40
|
+
MAX_MESSAGE_LENGTH = 4096
|
|
41
|
+
TRUNCATION_SUFFIX = "\n...[truncated]"
|
|
42
|
+
|
|
43
|
+
def __init__(self, config: GoogleChatClientConfig) -> None:
|
|
44
|
+
self.config = config
|
|
45
|
+
self.client = httpx.Client(timeout=config.timeout)
|
|
46
|
+
|
|
47
|
+
if not self.config.webhook_url and not self.config.allow_empty_webhook:
|
|
48
|
+
logger.warning("No webhook URL provided. Messages will not be sent.")
|
|
49
|
+
|
|
50
|
+
def __enter__(self) -> "GoogleChatClient":
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def __exit__(
|
|
54
|
+
self,
|
|
55
|
+
exc_type: type[BaseException] | None,
|
|
56
|
+
exc_val: BaseException | None,
|
|
57
|
+
exc_tb: TracebackType | None,
|
|
58
|
+
) -> None:
|
|
59
|
+
self.close()
|
|
60
|
+
|
|
61
|
+
def close(self) -> None:
|
|
62
|
+
self.client.close()
|
|
63
|
+
|
|
64
|
+
def _validate_webhook(self, operation: str) -> ChatSendMessageResponse | None:
|
|
65
|
+
"""Validate webhook URL is configured."""
|
|
66
|
+
if not self.config.webhook_url:
|
|
67
|
+
logger.error("No webhook URL configured. {} not sent.", operation)
|
|
68
|
+
return ChatSendMessageResponse(success=False, error="No webhook URL configured")
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
@_RETRY_CONFIG
|
|
72
|
+
def send_card(self, card_payload: dict[str, Any]) -> ChatSendMessageResponse:
|
|
73
|
+
"""Send a Cards v2 message to Google Chat."""
|
|
74
|
+
if error := self._validate_webhook("Card"):
|
|
75
|
+
return error
|
|
76
|
+
if "cardsV2" not in card_payload:
|
|
77
|
+
logger.error("Invalid card payload: missing 'cardsV2' key")
|
|
78
|
+
return ChatSendMessageResponse(success=False, error="Invalid card payload: missing 'cardsV2' key")
|
|
79
|
+
return self._send_request(card_payload)
|
|
80
|
+
|
|
81
|
+
def _send_request(self, payload: dict[str, Any]) -> ChatSendMessageResponse:
|
|
82
|
+
"""Send a request to the Google Chat webhook."""
|
|
83
|
+
try:
|
|
84
|
+
response = self.client.post(
|
|
85
|
+
self.config.webhook_url,
|
|
86
|
+
json=payload,
|
|
87
|
+
headers={"Content-Type": "application/json"},
|
|
88
|
+
)
|
|
89
|
+
response.raise_for_status()
|
|
90
|
+
|
|
91
|
+
logger.info("Message sent to Google Chat successfully")
|
|
92
|
+
response_data = response.json() if response.content else {}
|
|
93
|
+
return ChatSendMessageResponse(success=True, response_data=response_data)
|
|
94
|
+
|
|
95
|
+
except httpx.HTTPStatusError as e:
|
|
96
|
+
error_msg = f"HTTP error sending to Google Chat: {e.response.status_code}"
|
|
97
|
+
logger.error("Failed to send message to Google Chat: {}", error_msg)
|
|
98
|
+
return ChatSendMessageResponse(success=False, error=error_msg)
|
|
99
|
+
|
|
100
|
+
except httpx.RequestError as e:
|
|
101
|
+
error_msg = f"Request error sending to Google Chat: {e}"
|
|
102
|
+
logger.error("Failed to send message to Google Chat: {}", error_msg)
|
|
103
|
+
raise
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ErrorAlerter:
|
|
107
|
+
"""Specialized alerter for sending error notifications to Google Chat."""
|
|
108
|
+
|
|
109
|
+
SERVICE_NAME: ClassVar[str] = "bridge-tap-api"
|
|
110
|
+
|
|
111
|
+
CATEGORY_TITLE_MAP: ClassVar[dict[ErrorCategory, str]] = {
|
|
112
|
+
ErrorCategory.CONFIG_ERROR: "Configuration Error",
|
|
113
|
+
ErrorCategory.SYSTEM_ERROR: "System Error",
|
|
114
|
+
ErrorCategory.ETL_REQUIRED: "ETL Required",
|
|
115
|
+
ErrorCategory.USER_FILE: "User File Error",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# Google Material Symbols icons served from Google Fonts CDN.
|
|
119
|
+
# Source: https://fonts.google.com/icons
|
|
120
|
+
SEVERITY_ICON_URLS: ClassVar[dict[SeverityLevel, str]] = {
|
|
121
|
+
SeverityLevel.CRITICAL: "https://fonts.gstatic.com/s/i/short-term/release/googlesymbols/error/default/48px.svg",
|
|
122
|
+
SeverityLevel.HIGH: "https://fonts.gstatic.com/s/i/short-term/release/googlesymbols/warning/default/48px.svg",
|
|
123
|
+
SeverityLevel.MEDIUM: "https://fonts.gstatic.com/s/i/short-term/release/googlesymbols/info/default/48px.svg",
|
|
124
|
+
SeverityLevel.LOW: "https://fonts.gstatic.com/s/i/short-term/release/googlesymbols/check_circle/default/48px.svg",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
def __init__(self, webhook_url: str | None = None) -> None:
|
|
128
|
+
if webhook_url is not None:
|
|
129
|
+
self.webhook_url = webhook_url or os.getenv("GOOGLE_CHAT_ERROR_WEBHOOK_URL", "")
|
|
130
|
+
else:
|
|
131
|
+
self.webhook_url = os.getenv("GOOGLE_CHAT_ERROR_WEBHOOK_URL", "")
|
|
132
|
+
config = GoogleChatClientConfig(
|
|
133
|
+
webhook_url=self.webhook_url,
|
|
134
|
+
allow_empty_webhook=not self.webhook_url,
|
|
135
|
+
)
|
|
136
|
+
self._client = GoogleChatClient(config=config)
|
|
137
|
+
|
|
138
|
+
def __enter__(self) -> "ErrorAlerter":
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
def __exit__(
|
|
142
|
+
self,
|
|
143
|
+
exc_type: type[BaseException] | None,
|
|
144
|
+
exc_val: BaseException | None,
|
|
145
|
+
exc_tb: TracebackType | None,
|
|
146
|
+
) -> None:
|
|
147
|
+
self.close()
|
|
148
|
+
|
|
149
|
+
def close(self) -> None:
|
|
150
|
+
self._client.close()
|
|
151
|
+
|
|
152
|
+
def send_alert(
|
|
153
|
+
self,
|
|
154
|
+
error_category: str,
|
|
155
|
+
job: dict[str, Any],
|
|
156
|
+
error_info: dict[str, Any],
|
|
157
|
+
severity: str = "medium",
|
|
158
|
+
) -> bool:
|
|
159
|
+
"""Send alert to Google Chat webhook for error notification."""
|
|
160
|
+
if not self.webhook_url:
|
|
161
|
+
logger.warning("No webhook URL configured. Alert not sent for job {}", job.get("id", "unknown"))
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
alert_payload = self._build_alert_payload(error_category, job, error_info, severity)
|
|
165
|
+
card_payload = self._build_card_payload(alert_payload)
|
|
166
|
+
|
|
167
|
+
logger.info(
|
|
168
|
+
"Sending {} alert to Google Chat for job {} (severity: {})",
|
|
169
|
+
error_category,
|
|
170
|
+
job.get("id", "unknown"),
|
|
171
|
+
severity,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
response = self._client.send_card(card_payload)
|
|
175
|
+
|
|
176
|
+
if response.success:
|
|
177
|
+
logger.info("Alert sent successfully to Google Chat for job {}", job.get("id", "unknown"))
|
|
178
|
+
return True
|
|
179
|
+
logger.error("Failed to send alert to Google Chat: {}", response.error)
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
def _build_alert_payload(
|
|
183
|
+
self,
|
|
184
|
+
error_category: str,
|
|
185
|
+
job: dict[str, Any],
|
|
186
|
+
error_info: dict[str, Any],
|
|
187
|
+
severity: str,
|
|
188
|
+
) -> ErrorAlertPayload:
|
|
189
|
+
"""Build structured alert payload for Google Chat."""
|
|
190
|
+
job_id = int(job.get("id", 0))
|
|
191
|
+
ticket_id = str(job.get("ticket_id", "unknown"))
|
|
192
|
+
client_name = str(job.get("client_name", "unknown"))
|
|
193
|
+
|
|
194
|
+
stage = error_info.get("stage", "unknown")
|
|
195
|
+
error_message = error_info.get("error_message", "Unknown error")
|
|
196
|
+
error_type = error_info.get("error_type", "UnknownError")
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
category_enum = ErrorCategory(error_category)
|
|
200
|
+
except ValueError:
|
|
201
|
+
category_enum = ErrorCategory.SYSTEM_ERROR
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
severity_enum = SeverityLevel(severity)
|
|
205
|
+
except ValueError:
|
|
206
|
+
severity_enum = SeverityLevel.MEDIUM
|
|
207
|
+
|
|
208
|
+
return ErrorAlertPayload(
|
|
209
|
+
timestamp=datetime.now(UTC),
|
|
210
|
+
error_category=category_enum,
|
|
211
|
+
severity=severity_enum,
|
|
212
|
+
job_id=job_id,
|
|
213
|
+
ticket_id=ticket_id,
|
|
214
|
+
client_name=client_name,
|
|
215
|
+
stage=stage,
|
|
216
|
+
error_message=f"{error_type}: {error_message}",
|
|
217
|
+
retry_count=job.get("retry_count", 0),
|
|
218
|
+
max_retries=job.get("max_retries", 0),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def _build_card_payload(self, payload: ErrorAlertPayload) -> dict[str, Any]:
|
|
222
|
+
"""Build Cards v2 payload for Google Chat webhook (BCC pattern)."""
|
|
223
|
+
card_id = f"alert-{payload.job_id}-{payload.timestamp.strftime('%Y%m%d%H%M%S')}"
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
"cardsV2": [
|
|
227
|
+
{
|
|
228
|
+
"cardId": card_id,
|
|
229
|
+
"card": {
|
|
230
|
+
"header": self._build_card_header(payload),
|
|
231
|
+
"sections": [
|
|
232
|
+
self._build_job_info_section(payload),
|
|
233
|
+
self._build_error_details_section(payload),
|
|
234
|
+
self._build_error_message_section(payload),
|
|
235
|
+
],
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
]
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
def _build_card_header(self, payload: ErrorAlertPayload) -> dict[str, Any]:
|
|
242
|
+
"""Build the card header section."""
|
|
243
|
+
return {
|
|
244
|
+
"title": f"Bridge-TAP Alert: {self.CATEGORY_TITLE_MAP.get(payload.error_category, 'Error')}",
|
|
245
|
+
"subtitle": self.SERVICE_NAME,
|
|
246
|
+
"imageUrl": self.SEVERITY_ICON_URLS.get(payload.severity),
|
|
247
|
+
"imageType": "CIRCLE",
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
def _build_job_info_section(self, payload: ErrorAlertPayload) -> dict[str, Any]:
|
|
251
|
+
"""Build the job information section with decorated text widgets."""
|
|
252
|
+
return {
|
|
253
|
+
"widgets": [
|
|
254
|
+
{
|
|
255
|
+
"decoratedText": {
|
|
256
|
+
"startIcon": {"knownIcon": "BOOKMARK"},
|
|
257
|
+
"topLabel": "Job ID",
|
|
258
|
+
"text": str(payload.job_id),
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
"decoratedText": {
|
|
263
|
+
"startIcon": {"knownIcon": "DESCRIPTION"},
|
|
264
|
+
"topLabel": "Ticket ID",
|
|
265
|
+
"text": payload.ticket_id,
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
"decoratedText": {
|
|
270
|
+
"startIcon": {"knownIcon": "PERSON"},
|
|
271
|
+
"topLabel": "Client",
|
|
272
|
+
"text": payload.client_name,
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
"decoratedText": {
|
|
277
|
+
"startIcon": {"knownIcon": "CLOCK"},
|
|
278
|
+
"topLabel": "Timestamp",
|
|
279
|
+
"text": payload.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC"),
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
]
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
def _build_error_details_section(self, payload: ErrorAlertPayload) -> dict[str, Any]:
|
|
286
|
+
"""Build the error details section."""
|
|
287
|
+
return {
|
|
288
|
+
"header": "Error Details",
|
|
289
|
+
"widgets": [
|
|
290
|
+
{"decoratedText": {"topLabel": "Stage", "text": payload.stage}},
|
|
291
|
+
{"decoratedText": {"topLabel": "Category", "text": payload.error_category.value}},
|
|
292
|
+
{"decoratedText": {"topLabel": "Severity", "text": payload.severity.value}},
|
|
293
|
+
{"decoratedText": {"topLabel": "Retries", "text": f"{payload.retry_count}/{payload.max_retries}"}},
|
|
294
|
+
],
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
def _build_error_message_section(self, payload: ErrorAlertPayload) -> dict[str, Any]:
|
|
298
|
+
"""Build the error message section."""
|
|
299
|
+
return {
|
|
300
|
+
"header": "Error Message",
|
|
301
|
+
"widgets": [{"textParagraph": {"text": payload.error_message}}],
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## Pydantic Models
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
from datetime import datetime
|
|
309
|
+
from enum import Enum
|
|
310
|
+
from typing import Any
|
|
311
|
+
|
|
312
|
+
from pydantic import BaseModel, ConfigDict
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class ErrorCategory(str, Enum):
|
|
316
|
+
"""Categories for error classification."""
|
|
317
|
+
CONFIG_ERROR = "config_error"
|
|
318
|
+
SYSTEM_ERROR = "system_error"
|
|
319
|
+
ETL_REQUIRED = "etl_required"
|
|
320
|
+
USER_FILE = "user_file"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class SeverityLevel(str, Enum):
|
|
324
|
+
"""Severity levels for alerts."""
|
|
325
|
+
CRITICAL = "critical"
|
|
326
|
+
HIGH = "high"
|
|
327
|
+
MEDIUM = "medium"
|
|
328
|
+
LOW = "low"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class ErrorAlertPayload(BaseModel):
|
|
332
|
+
"""Payload for error alerts."""
|
|
333
|
+
model_config = ConfigDict(frozen=True)
|
|
334
|
+
|
|
335
|
+
timestamp: datetime
|
|
336
|
+
error_category: ErrorCategory
|
|
337
|
+
severity: SeverityLevel
|
|
338
|
+
job_id: int
|
|
339
|
+
ticket_id: str
|
|
340
|
+
client_name: str
|
|
341
|
+
stage: str
|
|
342
|
+
error_message: str
|
|
343
|
+
retry_count: int = 0
|
|
344
|
+
max_retries: int = 0
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class ChatSendMessageResponse(BaseModel):
|
|
348
|
+
"""Response from sending a message."""
|
|
349
|
+
model_config = ConfigDict(frozen=True)
|
|
350
|
+
|
|
351
|
+
success: bool
|
|
352
|
+
error: str | None = None
|
|
353
|
+
response_data: dict[str, Any] | None = None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class GoogleChatClientConfig(BaseModel):
|
|
357
|
+
"""Configuration for Google Chat client."""
|
|
358
|
+
model_config = ConfigDict(frozen=True)
|
|
359
|
+
|
|
360
|
+
webhook_url: str = ""
|
|
361
|
+
timeout: float = 30.0
|
|
362
|
+
allow_empty_webhook: bool = False
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
## Test Fixtures
|
|
366
|
+
|
|
367
|
+
```python
|
|
368
|
+
from collections.abc import Generator
|
|
369
|
+
from datetime import UTC, datetime
|
|
370
|
+
from unittest.mock import MagicMock, patch
|
|
371
|
+
|
|
372
|
+
import pytest
|
|
373
|
+
|
|
374
|
+
from bridge_tap_api.sdk.google_chat.client import ErrorAlerter, GoogleChatClient
|
|
375
|
+
from bridge_tap_api.sdk.google_chat.models import (
|
|
376
|
+
ErrorCategory,
|
|
377
|
+
GoogleChatClientConfig,
|
|
378
|
+
SeverityLevel,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@pytest.fixture
|
|
383
|
+
def google_chat_client() -> Generator[GoogleChatClient, None, None]:
|
|
384
|
+
"""Create a GoogleChatClient for testing."""
|
|
385
|
+
config = GoogleChatClientConfig(webhook_url="https://webhook.test")
|
|
386
|
+
client = GoogleChatClient(config=config)
|
|
387
|
+
yield client
|
|
388
|
+
client.close()
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@pytest.fixture
|
|
392
|
+
def alerter() -> Generator[ErrorAlerter, None, None]:
|
|
393
|
+
"""Create an ErrorAlerter for testing."""
|
|
394
|
+
alerter = ErrorAlerter(webhook_url="https://webhook.test")
|
|
395
|
+
yield alerter
|
|
396
|
+
alerter.close()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@pytest.fixture
|
|
400
|
+
def mock_successful_response() -> MagicMock:
|
|
401
|
+
"""Create a mock successful HTTP response."""
|
|
402
|
+
mock_response = MagicMock()
|
|
403
|
+
mock_response.status_code = 200
|
|
404
|
+
mock_response.content = b'{"name": "spaces/xxx/messages/yyy"}'
|
|
405
|
+
mock_response.json.return_value = {"name": "spaces/xxx/messages/yyy"}
|
|
406
|
+
mock_response.raise_for_status = MagicMock()
|
|
407
|
+
return mock_response
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@pytest.fixture
|
|
411
|
+
def sample_job() -> dict:
|
|
412
|
+
"""Create a sample job dictionary for testing."""
|
|
413
|
+
return {
|
|
414
|
+
"id": 123,
|
|
415
|
+
"ticket_id": "TICKET-456",
|
|
416
|
+
"client_name": "test-client",
|
|
417
|
+
"retry_count": 2,
|
|
418
|
+
"max_retries": 6,
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@pytest.fixture
|
|
423
|
+
def sample_error_info() -> dict:
|
|
424
|
+
"""Create sample error info for testing."""
|
|
425
|
+
return {
|
|
426
|
+
"stage": "validation",
|
|
427
|
+
"error_type": "ValidationError",
|
|
428
|
+
"error_message": "Required column 'email' is missing",
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
## Example Test
|
|
433
|
+
|
|
434
|
+
```python
|
|
435
|
+
def test_send_alert_builds_card_v2_payload(
|
|
436
|
+
alerter: ErrorAlerter,
|
|
437
|
+
mock_successful_response: MagicMock,
|
|
438
|
+
sample_job: dict,
|
|
439
|
+
sample_error_info: dict,
|
|
440
|
+
) -> None:
|
|
441
|
+
"""Test that send_alert builds proper Cards v2 structure."""
|
|
442
|
+
with patch.object(alerter._client.client, "post", return_value=mock_successful_response):
|
|
443
|
+
result = alerter.send_alert(
|
|
444
|
+
error_category="config_error",
|
|
445
|
+
job=sample_job,
|
|
446
|
+
error_info=sample_error_info,
|
|
447
|
+
severity="high",
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
assert result is True
|
|
451
|
+
|
|
452
|
+
# Verify cardsV2 structure
|
|
453
|
+
call_args = alerter._client.client.post.call_args
|
|
454
|
+
payload = call_args.kwargs["json"]
|
|
455
|
+
|
|
456
|
+
assert "cardsV2" in payload
|
|
457
|
+
card = payload["cardsV2"][0]["card"]
|
|
458
|
+
|
|
459
|
+
# Verify header
|
|
460
|
+
assert card["header"]["title"] == "Bridge-TAP Alert: Configuration Error"
|
|
461
|
+
assert card["header"]["subtitle"] == "bridge-tap-api"
|
|
462
|
+
assert "warning" in card["header"]["imageUrl"] # HIGH severity uses warning icon
|
|
463
|
+
|
|
464
|
+
# Verify sections
|
|
465
|
+
assert len(card["sections"]) == 3
|
|
466
|
+
```
|