Skip to content

Commit 210e783

Browse files
authored
Release Candidate v4.14.22 (#302)
2 parents 3767b8f + dfcf2d1 commit 210e783

8 files changed

Lines changed: 314 additions & 27 deletions

File tree

.github/workflows/beta.yml

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -143,15 +143,33 @@ jobs:
143143
NOTIFICATION_ENDPOINT: ${{ secrets.NOTIFICATION_URL_STAGING }}
144144
RELEASE_OUTPUT_B64: ${{ steps.publish_github_release.outputs.release_output_b64 }} # stored in Python
145145
run: |
146-
SUMMARY_RESPONSE=$(curl -X POST \
147-
-L "$NOTIFICATION_ENDPOINT" \
148-
-H "X-API-Key: $NOTIFICATION_AUTH_KEY" \
149-
-H "Content-Type: application/json" \
150-
-d "{\"release_output_b64\": \"$RELEASE_OUTPUT_B64\"}" \
151-
--silent --show-error)
152-
SUMMARY=$(echo "$SUMMARY_RESPONSE" | jq -r '.summary // empty')
153-
SUMMARY_B64=$(printf "%s" "$SUMMARY" | base64 -w 0)
154-
echo "summary_b64=$SUMMARY_B64" >> $GITHUB_OUTPUT
146+
INTERVAL=15
147+
MAX_ATTEMPTS=20
148+
echo "Notifying release endpoint with retry support"
149+
echo "Will retry every $INTERVAL seconds for up to $MAX_ATTEMPTS attempts if needed"
150+
for ((i=1;i<=MAX_ATTEMPTS;i++)); do
151+
echo "Attempt $i/$MAX_ATTEMPTS: sending notification..."
152+
SUMMARY_RESPONSE=$(curl -X POST \
153+
-L "$NOTIFICATION_ENDPOINT" \
154+
-H "X-API-Key: $NOTIFICATION_AUTH_KEY" \
155+
-H "Content-Type: application/json" \
156+
-d "{\"release_output_b64\": \"$RELEASE_OUTPUT_B64\"}" \
157+
-s || true)
158+
SHOULD_RETRY=$(echo "$SUMMARY_RESPONSE" | grep -o '"should_retry"[[:space:]]*:[[:space:]]*[^,}]*' | head -n1 | sed 's/.*:[[:space:]]*\([^,}]*\).*/\1/')
159+
if [[ "$SHOULD_RETRY" != "true" ]]; then
160+
echo "Notification successful (should_retry = $SHOULD_RETRY)"
161+
SUMMARY=$(echo "$SUMMARY_RESPONSE" | grep -o '"summary"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n1 | sed 's/.*: *"\([^"]*\)".*/\1/')
162+
SUMMARY_B64=$(printf "%s" "$SUMMARY" | base64 -w 0)
163+
echo "summary_b64=$SUMMARY_B64" >> $GITHUB_OUTPUT
164+
exit 0
165+
fi
166+
echo "Instance not ready yet (should_retry = true), waiting $INTERVAL seconds..."
167+
sleep $INTERVAL
168+
done
169+
echo "Reached maximum attempts ($MAX_ATTEMPTS). Last response:"
170+
echo "$SUMMARY_RESPONSE"
171+
echo "Continuing without summary."
172+
exit 0
155173
156174
- name: Update GitHub Release with summary
157175
if: steps.notify_release.outputs.summary_b64 != ''

.github/workflows/release.yml

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -122,15 +122,33 @@ jobs:
122122
NOTIFICATION_ENDPOINT: ${{ secrets.NOTIFICATION_URL }}
123123
RELEASE_OUTPUT_B64: ${{ steps.publish_github_release.outputs.release_output_b64 }} # stored in Python
124124
run: |
125-
SUMMARY_RESPONSE=$(curl -X POST \
126-
-L "$NOTIFICATION_ENDPOINT" \
127-
-H "X-API-Key: $NOTIFICATION_AUTH_KEY" \
128-
-H "Content-Type: application/json" \
129-
-d "{\"release_output_b64\": \"$RELEASE_OUTPUT_B64\"}" \
130-
--silent --show-error)
131-
SUMMARY=$(echo "$SUMMARY_RESPONSE" | jq -r '.summary // empty')
132-
SUMMARY_B64=$(printf "%s" "$SUMMARY" | base64 -w 0)
133-
echo "summary_b64=$SUMMARY_B64" >> $GITHUB_OUTPUT
125+
INTERVAL=15
126+
MAX_ATTEMPTS=20
127+
echo "Notifying release endpoint with retry support"
128+
echo "Will retry every $INTERVAL seconds for up to $MAX_ATTEMPTS attempts if needed"
129+
for ((i=1;i<=MAX_ATTEMPTS;i++)); do
130+
echo "Attempt $i/$MAX_ATTEMPTS: sending notification..."
131+
SUMMARY_RESPONSE=$(curl -X POST \
132+
-L "$NOTIFICATION_ENDPOINT" \
133+
-H "X-API-Key: $NOTIFICATION_AUTH_KEY" \
134+
-H "Content-Type: application/json" \
135+
-d "{\"release_output_b64\": \"$RELEASE_OUTPUT_B64\"}" \
136+
-s || true)
137+
SHOULD_RETRY=$(echo "$SUMMARY_RESPONSE" | grep -o '"should_retry"[[:space:]]*:[[:space:]]*[^,}]*' | head -n1 | sed 's/.*:[[:space:]]*\([^,}]*\).*/\1/')
138+
if [[ "$SHOULD_RETRY" != "true" ]]; then
139+
echo "Notification successful (should_retry = $SHOULD_RETRY)"
140+
SUMMARY=$(echo "$SUMMARY_RESPONSE" | grep -o '"summary"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n1 | sed 's/.*: *"\([^"]*\)".*/\1/')
141+
SUMMARY_B64=$(printf "%s" "$SUMMARY" | base64 -w 0)
142+
echo "summary_b64=$SUMMARY_B64" >> $GITHUB_OUTPUT
143+
exit 0
144+
fi
145+
echo "Instance not ready yet (should_retry = true), waiting $INTERVAL seconds..."
146+
sleep $INTERVAL
147+
done
148+
echo "Reached maximum attempts ($MAX_ATTEMPTS). Last response:"
149+
echo "$SUMMARY_RESPONSE"
150+
echo "Continuing without summary."
151+
exit 0
134152
135153
- name: Update GitHub Release with summary
136154
if: steps.notify_release.outputs.summary_b64 != ''

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "the-agent"
7-
version = "4.14.20"
7+
version = "4.14.22"
88

99
[tool.setuptools]
1010
package-dir = {"" = "src"}

src/features/chat/telegram/release_summary_responder.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from di.di import DI
1111
from features.announcements.release_summary_service import ReleaseSummaryService
1212
from util import log
13+
from util.config import config
1314

1415

1516
class SummaryResult:
@@ -20,6 +21,7 @@ class SummaryResult:
2021
chats_unsubscribed: int
2122
chats_notified: int
2223
summaries_created: int
24+
should_retry: bool
2325

2426
def __init__(
2527
self,
@@ -29,13 +31,15 @@ def __init__(
2931
chats_unsubscribed: int = 0,
3032
chats_notified: int = 0,
3133
summaries_created: int = 0,
34+
should_retry: bool = False,
3235
):
3336
self.summary = summary
3437
self.chats_eligible = chats_eligible
3538
self.chats_subscribed = chats_subscribed
3639
self.chats_unsubscribed = chats_unsubscribed
3740
self.chats_notified = chats_notified
3841
self.summaries_created = summaries_created
42+
self.should_retry = should_retry
3943

4044
def to_dict(self):
4145
return {
@@ -45,6 +49,7 @@ def to_dict(self):
4549
"chats_unsubscribed": self.chats_unsubscribed,
4650
"chats_notified": self.chats_notified,
4751
"summaries_created": self.summaries_created,
52+
"should_retry": self.should_retry,
4853
}
4954

5055

@@ -68,6 +73,16 @@ def respond_with_summary(payload: ReleaseOutputPayload, di: DI) -> dict:
6873
result.summary = log.e("Failed to decode release notes", e)
6974
return result.to_dict()
7075

76+
# check if this instance should process the release
77+
if config.version != new_target_version:
78+
result.summary = (
79+
f"Skipping release processing: current version ({config.version}) "
80+
f"does not match target version ({new_target_version})"
81+
)
82+
result.should_retry = True
83+
log.w(result.summary)
84+
return result.to_dict()
85+
7186
# summarize for the default language first
7287
translations = di.translations_cache
7388
try:

src/features/chat/telegram/sdk/telegram_bot_api.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import re
2-
31
import requests
42
from pydantic import TypeAdapter
53
from requests import RequestException, Response
64

75
from features.chat.telegram.model.attachment.file import File
86
from features.chat.telegram.model.chat_member import ChatMember
7+
from features.chat.telegram.telegram_markdown_utils import escape_markdown
98
from util import log
109
from util.config import config
1110

@@ -35,7 +34,7 @@ def send_text_message(
3534
) -> dict:
3635
log.t(f"Sending message to chat #{chat_id}")
3736
url = f"{self.__bot_api_url}/sendMessage"
38-
cleaned_text = re.sub(r"(?<!\b)_(?!\b)", r"\\_", text)
37+
cleaned_text = escape_markdown(text)
3938
if link_preview_options is None:
4039
link_preview_options = {
4140
"is_disabled": False,
@@ -69,7 +68,7 @@ def send_photo(
6968
"disable_notification": disable_notification,
7069
}
7170
if caption:
72-
payload["caption"] = re.sub(r"(?<!\b)_(?!\b)", r"\\_", caption)
71+
payload["caption"] = escape_markdown(caption)
7372
payload["parse_mode"] = parse_mode
7473
response = requests.post(url, json = payload, timeout = config.web_timeout_s)
7574
self.__raise_for_status(response)
@@ -94,7 +93,7 @@ def send_document(
9493
if thumbnail:
9594
payload["thumbnail"] = thumbnail
9695
if caption:
97-
payload["caption"] = re.sub(r"(?<!\b)_(?!\b)", r"\\_", caption)
96+
payload["caption"] = escape_markdown(caption)
9897
payload["parse_mode"] = parse_mode
9998
response = requests.post(url, json = payload, timeout = config.web_timeout_s)
10099
self.__raise_for_status(response)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import re
2+
3+
4+
def escape_markdown(text: str) -> str:
5+
"""
6+
Escape special characters for Telegram legacy markdown mode.
7+
Required characters: _ * ` [
8+
9+
Telegram's markdown format (different from standard markdown!):
10+
- *bold* (single asterisk, not double!)
11+
- _italic_ (underscore)
12+
- `code` (backtick)
13+
14+
Strategy:
15+
1. Always escape backslashes first (escape character itself)
16+
2. Protect Telegram markdown patterns (*bold*, _italic_, `code`), then escape remaining chars
17+
3. For [: always escape (rarely used intentionally in our context)
18+
19+
This preserves intentional formatting like *bold*, _italic_, `code` while
20+
escaping problematic characters like snake_case, 2*3*4, etc.
21+
"""
22+
if not text:
23+
return text
24+
25+
# Always escape backslashes first (they're the escape character)
26+
text = text.replace("\\", "\\\\")
27+
28+
# Strategy: Protect markdown patterns first, then escape unprotected special chars
29+
# Using null bytes as placeholders (won't appear in normal text)
30+
BOLD_START = "\x00BS\x00"
31+
BOLD_END = "\x00BE\x00"
32+
ITALIC_START = "\x00IS\x00"
33+
ITALIC_END = "\x00IE\x00"
34+
CODE_START = "\x00CS\x00"
35+
CODE_END = "\x00CE\x00"
36+
CODE_CONTENT = "\x00CC{}\x00"
37+
38+
# Protect code blocks and inline code (including their content)
39+
# We need to protect the content from escaping
40+
# IMPORTANT: Handle triple backticks FIRST (code blocks), then single backticks (inline code)
41+
code_blocks = [] # Stores tuples of (content, delimiter) where delimiter is ``` or `
42+
43+
def protect_code_block(match):
44+
content = match.group(1)
45+
idx = len(code_blocks)
46+
code_blocks.append((content, "```"))
47+
return f"{CODE_START}{CODE_CONTENT.format(idx)}{CODE_END}"
48+
49+
def protect_inline_code(match):
50+
content = match.group(1)
51+
idx = len(code_blocks)
52+
code_blocks.append((content, "`"))
53+
return f"{CODE_START}{CODE_CONTENT.format(idx)}{CODE_END}"
54+
55+
# First protect ```code blocks``` (triple backticks, can span multiple lines)
56+
text = re.sub(r"```(.+?)```", protect_code_block, text, flags = re.DOTALL)
57+
# Then protect `inline code` (single backticks, must not span lines)
58+
text = re.sub(r"`([^`\n]+?)`", protect_inline_code, text)
59+
60+
# Protect *bold* (single asterisk - Telegram's format, not standard markdown!)
61+
# Only match if it contains at least one letter AND is surrounded by word boundaries/spaces
62+
text = re.sub(r"(^|\s)\*([^\s*]*[a-zA-Z][^\s*]*)\*(\s|$)", rf"\1{BOLD_START}\2{BOLD_END}\3", text)
63+
64+
# Protect _italic_ (underscore italic - must be at word boundaries and contain at least one non-underscore char)
65+
text = re.sub(r"\b_([^\s_]+?)_\b", rf"{ITALIC_START}\1{ITALIC_END}", text)
66+
67+
# Now escape all remaining special characters
68+
text = text.replace("*", "\\*")
69+
text = text.replace("_", "\\_")
70+
text = text.replace("`", "\\`")
71+
text = text.replace("[", "\\[")
72+
73+
# Restore protected patterns
74+
text = text.replace(BOLD_START, "*")
75+
text = text.replace(BOLD_END, "*")
76+
text = text.replace(ITALIC_START, "_")
77+
text = text.replace(ITALIC_END, "_")
78+
79+
# Restore code blocks and inline code with correct delimiters
80+
for idx, (content, delimiter) in enumerate(code_blocks):
81+
placeholder = f"{CODE_START}{CODE_CONTENT.format(idx)}{CODE_END}"
82+
text = text.replace(placeholder, f"{delimiter}{content}{delimiter}")
83+
84+
return text

test/features/chat/telegram/test_release_summary_responder.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,43 @@ def test_decoding_failure(self, mock_b64decode):
136136
self.assertIn("Failed to decode release notes", result["summary"])
137137
self.assertEqual(result["summaries_created"], 0)
138138

139-
def test_successful_summary(self):
139+
@patch("features.chat.telegram.release_summary_responder.config")
140+
def test_version_mismatch(self, mock_config):
141+
mock_config.version = "1.0.0"
142+
release_output_json = {
143+
"latest_version": "1.0.0",
144+
"new_target_version": "1.0.1",
145+
"release_quality": "stable",
146+
"release_notes_b64": base64.b64encode(b"notes").decode(),
147+
}
148+
payload = ReleaseOutputPayload(
149+
release_output_b64 = base64.b64encode(json.dumps(release_output_json).encode()).decode(),
150+
)
151+
result = respond_with_summary(payload, self.mock_di)
152+
self.assertIn("Skipping release processing", result["summary"])
153+
self.assertIn("1.0.0", result["summary"])
154+
self.assertIn("1.0.1", result["summary"])
155+
self.assertEqual(result["summaries_created"], 0)
156+
self.assertEqual(result["chats_notified"], 0)
157+
self.assertTrue(result["should_retry"])
158+
159+
@patch("features.chat.telegram.release_summary_responder.config")
160+
def test_version_match(self, mock_config):
161+
mock_config.version = "1.0.1"
162+
mock_configured_tool = Mock()
163+
self.mock_di.tool_choice_resolver.require_tool.return_value = mock_configured_tool
164+
mock_summary_service = Mock(spec = ReleaseSummaryService)
165+
mock_summary_service.execute.return_value = Mock(content = "Test summary")
166+
self.mock_di.release_summary_service.return_value = mock_summary_service
167+
self.mock_di.chat_config_crud.get_all.return_value = []
168+
result = respond_with_summary(self.payload, self.mock_di)
169+
self.assertEqual(result["summaries_created"], 1)
170+
self.assertNotIn("Skipping", result["summary"])
171+
self.assertFalse(result["should_retry"])
172+
173+
@patch("features.chat.telegram.release_summary_responder.config")
174+
def test_successful_summary(self, mock_config):
175+
mock_config.version = "1.0.1"
140176
# Mock tool choice resolver and release summary service
141177
mock_configured_tool = Mock()
142178
self.mock_di.tool_choice_resolver.require_tool.return_value = mock_configured_tool
@@ -161,7 +197,9 @@ def test_successful_summary(self):
161197
# noinspection PyUnresolvedReferences
162198
mock_platform_sdk.send_text_message.assert_called_once_with("1234", "Test summary")
163199

164-
def test_multiple_languages(self):
200+
@patch("features.chat.telegram.release_summary_responder.config")
201+
def test_multiple_languages(self, mock_config):
202+
mock_config.version = "1.0.1"
165203
mock_summarizer = Mock(spec = ReleaseSummaryService)
166204
mock_summarizer.execute.return_value = AIMessage(content = "Summary")
167205
self.mock_di.release_summary_service.return_value = mock_summarizer
@@ -214,7 +252,9 @@ def test_no_eligible_chats(self):
214252
result = respond_with_summary(self.payload, self.mock_di)
215253
self.assertEqual(result["chats_eligible"], 0)
216254

217-
def test_all_translations(self):
255+
@patch("features.chat.telegram.release_summary_responder.config")
256+
def test_all_translations(self, mock_config):
257+
mock_config.version = "1.0.1"
218258
mock_sum = Mock(spec = ReleaseSummaryService)
219259
mock_sum.execute.return_value = Mock(content = "Gen summary")
220260
self.mock_di.release_summary_service.return_value = mock_sum

0 commit comments

Comments
 (0)