From 4edc1f30f0dad37c43ed02865919ad4b2953ff20 Mon Sep 17 00:00:00 2001 From: lens0021 Date: Sat, 3 Oct 2026 10:55:29 +0900 Subject: [PATCH 1/4] feat(sns-discord): say an alarm's change in Korean, with times in KST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the message from the alarm's Trigger and the datapoints in NewStateReason instead of passing AWS's English reason through, and label the chart's threshold line 기준. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NjZkQtRoTFu44uBp8tgofc --- sns-discord/lambda_function.py | 133 +++++++++++++++++++++++++--- sns-discord/test_lambda_function.py | 95 ++++++++++++++++++-- 2 files changed, 210 insertions(+), 18 deletions(-) diff --git a/sns-discord/lambda_function.py b/sns-discord/lambda_function.py index fd26d8b..1afe79a 100644 --- a/sns-discord/lambda_function.py +++ b/sns-discord/lambda_function.py @@ -2,8 +2,10 @@ import json import os +import re import urllib.error import urllib.request +from datetime import datetime, timedelta, timezone from typing import Any import boto3 @@ -21,6 +23,23 @@ "MINIMUM": "Minimum", "MAXIMUM": "Maximum", } +# What each statistic is called, with the subject particle it takes +STAT_KOREAN = { + "SAMPLECOUNT": "표본 수가", + "AVERAGE": "평균이", + "SUM": "합계가", + "MINIMUM": "최솟값이", + "MAXIMUM": "최댓값이", +} +COMPARISON_KOREAN = { + "GreaterThanThreshold": "초과", + "GreaterThanOrEqualToThreshold": "이상", + "LessThanThreshold": "미만", + "LessThanOrEqualToThreshold": "이하", +} +KST = timezone(timedelta(hours=9), "KST") +# A datapoint in NewStateReason, e.g. 3184.0 (03/10/26 01:37:00), in UTC +DATAPOINT = re.compile(r"(-?[\d.]+(?:E-?\d+)?) \((\d\d/\d\d/\d\d \d\d:\d\d:\d\d)\)") CHART_WINDOW_MULTIPLIER = 12 CHART_MIN_MINUTES = 30 CHART_MAX_MINUTES = 360 @@ -112,16 +131,7 @@ def parse_message(event: Any) -> dict[str, Any]: test = alarm.get("AlarmName") == "_Test" notify = alarm.get("NewStateValue") != "OK" and not test color = GRAY if test else RED if notify else GREEN - alarm_name = alarm.get("AlarmName") - reason = alarm.get("NewStateReason") - summary = "[{}] {}".format( - alarm_name - if isinstance(alarm_name, str) - else "(메시지에 AlarmName이 없습니다)", - reason - if isinstance(reason, str) - else "(메시지에 NewStateReason이 없습니다)", - ) + summary = alarm_summary(alarm) fields = message_to_fields(alarm) dump = "" if isinstance(alarm.get("Trigger"), dict): @@ -148,6 +158,105 @@ def alarm_region(alarm: dict[str, Any]) -> str | None: return parts[3] if len(parts) > 3 and parts[3] else None +def alarm_summary(alarm: dict[str, Any]) -> str: + name = alarm.get("AlarmName") + state = {"ALARM": "경보", "OK": "해제", "INSUFFICIENT_DATA": "데이터 부족"}.get( + alarm.get("NewStateValue"), "상태 알 수 없음" + ) + when = parse_time(alarm.get("StateChangeTime"), "%Y-%m-%dT%H:%M:%S.%f%z") + head = "[{}] {}".format( + name if isinstance(name, str) else "(메시지에 AlarmName이 없습니다)", + state if when is None else f"{state}, {when:%-m월 %-d일 %H:%M} KST", + ) + trigger = alarm.get("Trigger") + condition = ( + alarm_condition(trigger, alarm.get("NewStateValue")) + if isinstance(trigger, dict) + else None + ) + reason = alarm.get("NewStateReason") + datapoints = recent_datapoints(reason) if isinstance(reason, str) else None + return " ".join( + part + for part in (head + ".", condition, datapoints and f"최근 값 {datapoints}.") + if part + ) + + +def alarm_condition(trigger: dict[str, Any], state: Any) -> str | None: + period = trigger.get("Period") + threshold = trigger.get("Threshold") + comparison = COMPARISON_KOREAN.get(trigger.get("ComparisonOperator")) + raw_stat = trigger.get("ExtendedStatistic") or trigger.get("Statistic") + stat = ( + STAT_KOREAN.get(raw_stat.upper(), f"{raw_stat} 값이") + if isinstance(raw_stat, str) + else None + ) + if ( + not isinstance(period, int) + or not isinstance(threshold, (int, float)) + or comparison is None + or stat is None + ): + return None + + rule = f"기준({format_number(threshold)} {comparison})" + subject = f"{format_period(period)} {stat}" + if state == "ALARM": + evaluated = trigger.get("EvaluationPeriods") + breached = trigger.get("DatapointsToAlarm") or evaluated + if isinstance(evaluated, int) and isinstance(breached, int): + return f"{subject} 최근 {evaluated}번 중 {breached}번 {rule}에 걸렸습니다." + return f"{subject} {rule}에 걸렸습니다." + if state == "OK": + return f"{subject} {rule}에 걸리지 않습니다." + return f"{rule}을 판단할 데이터가 모자랍니다." + + +def recent_datapoints(reason: str) -> str | None: + points = [] + for value, stamp in DATAPOINT.findall(reason): + when = parse_time(stamp + "+0000", "%d/%m/%y %H:%M:%S%z") + try: + number = float(value) + except ValueError: + continue + points.append( + format_number(number) + if when is None + else f"{format_number(number)}({when:%H:%M})" + ) + return ", ".join(points) or None + + +def parse_time(value: Any, pattern: str) -> datetime | None: + if not isinstance(value, str): + return None + try: + return datetime.strptime(value, pattern).astimezone(KST) + except ValueError: + return None + + +def format_period(seconds: int) -> str: + if seconds % 86400 == 0: + return f"{seconds // 86400}일" + if seconds % 3600 == 0: + return f"{seconds // 3600}시간" + if seconds % 60 == 0: + return f"{seconds // 60}분" + return f"{seconds}초" + + +def format_number(value: float) -> str: + if value == int(value): + return f"{int(value):,}" + if abs(value) >= 1: + return f"{value:,.2f}".rstrip("0").rstrip(".") + return f"{value:.3g}" + + def sns_message(event: Any) -> str | None: try: message = event["Records"][0]["Sns"]["Message"] @@ -231,9 +340,7 @@ def build_chart_widget( widget["region"] = region threshold = trigger.get("Threshold") if isinstance(threshold, (int, float)): - widget["annotations"] = { - "horizontal": [{"value": threshold, "label": "Threshold"}] - } + widget["annotations"] = {"horizontal": [{"value": threshold, "label": "기준"}]} return widget diff --git a/sns-discord/test_lambda_function.py b/sns-discord/test_lambda_function.py index e4a4e52..e661e31 100644 --- a/sns-discord/test_lambda_function.py +++ b/sns-discord/test_lambda_function.py @@ -6,6 +6,7 @@ GRAY, GREEN, RED, + alarm_summary, build_chart_widget, build_payload, encode_multipart, @@ -58,7 +59,7 @@ def test_alarm(self): ) self.assertEqual( post_data["content"], - "<@&678974055365476392> [Femiwiki CPU credit balance] Threshold Crossed: 1 out of the last 1 datapoints was less than the threshold.", + "<@&678974055365476392> [Femiwiki CPU credit balance] 경보.", "An alarm should be parsed as an alarm.", ) self.assertEqual(post_data["embed"]["color"], RED) @@ -82,7 +83,7 @@ def test_ok(self): ) self.assertEqual( post_data["content"], - "🟢 [Test] (메시지에 NewStateReason이 없습니다)", + "🟢 [Test] 해제.", "An OK should be parsed as an OK.", ) self.assertEqual(post_data["embed"]["color"], GREEN) @@ -139,7 +140,7 @@ def test_full_alarm(self): ) self.assertEqual( post_data["content"], - "<@&678974055365476392> [Femiwiki CPU credit balance] Threshold Crossed: 1 out of the last 1 datapoints [71.58514626666667 (09/04/22 21:01:00)] was less than the threshold (72.0) (minimum 1 datapoint for OK -> ALARM transition).", + "<@&678974055365476392> [Femiwiki CPU credit balance] 경보, 4월 10일 06:06 KST. 5분 최솟값이 최근 1번 중 1번 기준(72 미만)에 걸렸습니다. 최근 값 71.59(06:01).", ) self.assertEqual(post_data["embed"]["color"], RED) self.assertEqual(post_data["trigger"]["MetricName"], "CPUCreditBalance") @@ -166,13 +167,97 @@ def test_test_alarm(self): ) self.assertEqual( post_data["content"], - "🟢 [_Test] 테스트", + "🟢 [_Test] 해제.", "A test alarm should be parsed as a test alarm.", ) self.assertEqual(post_data["embed"]["color"], GRAY) self.assertIsNone(post_data["trigger"]) +class AlarmSummaryTest(unittest.TestCase): + def test_ok_after_insufficient_data(self): + self.assertEqual( + alarm_summary( + { + "AlarmName": "CloudFront Requests", + "NewStateValue": "OK", + "NewStateReason": "Threshold Crossed: 3 out of the last 3 datapoints [3184.0 (03/10/26 01:37:00), 2856.0 (03/10/26 01:32:00), 3223.0 (03/10/26 01:27:00)] were not greater than the threshold (60000.0) (minimum 1 datapoint for ALARM -> OK transition).", + "StateChangeTime": "2026-10-03T01:46:52.498+0000", + "OldStateValue": "INSUFFICIENT_DATA", + "Trigger": { + "Statistic": "SUM", + "Period": 300, + "EvaluationPeriods": 3, + "DatapointsToAlarm": 3, + "ComparisonOperator": "GreaterThanThreshold", + "Threshold": 60000.0, + }, + } + ), + "[CloudFront Requests] 해제, 10월 3일 10:46 KST. 5분 합계가 기준(60,000 초과)에 걸리지 않습니다. 최근 값 3,184(10:37), 2,856(10:32), 3,223(10:27).", + ) + + def test_insufficient_data(self): + self.assertEqual( + alarm_summary( + { + "AlarmName": "Bounce Rate", + "NewStateValue": "INSUFFICIENT_DATA", + "NewStateReason": "Insufficient Data: 1 datapoint was unknown.", + "StateChangeTime": "2026-10-02T17:33:39.107+0000", + "Trigger": { + "Statistic": "AVERAGE", + "Period": 300, + "EvaluationPeriods": 1, + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "Threshold": 0.05, + }, + } + ), + "[Bounce Rate] 데이터 부족, 10월 3일 02:33 KST. 기준(0.05 이상)을 판단할 데이터가 모자랍니다.", + ) + + def test_extended_statistic_and_hours(self): + self.assertEqual( + alarm_summary( + { + "AlarmName": "Latency", + "NewStateValue": "ALARM", + "Trigger": { + "ExtendedStatistic": "p90", + "Period": 3600, + "EvaluationPeriods": 2, + "ComparisonOperator": "GreaterThanThreshold", + "Threshold": 1.5, + }, + } + ), + "[Latency] 경보. 1시간 p90 값이 최근 2번 중 2번 기준(1.5 초과)에 걸렸습니다.", + ) + + def test_unknown_comparison_skips_the_condition(self): + self.assertEqual( + alarm_summary( + { + "AlarmName": "Anomaly", + "NewStateValue": "ALARM", + "Trigger": { + "Statistic": "SUM", + "Period": 60, + "ComparisonOperator": "LessThanLowerOrGreaterThanUpperThreshold", + }, + } + ), + "[Anomaly] 경보.", + ) + + def test_missing_name(self): + self.assertEqual( + alarm_summary({"NewStateValue": "OK"}), + "[(메시지에 AlarmName이 없습니다)] 해제.", + ) + + class MessageToFieldsTest(unittest.TestCase): def test_message_to_fields(self): fields = message_to_fields( @@ -226,7 +311,7 @@ def test_full_trigger(self): ], ) self.assertEqual( - widget["annotations"]["horizontal"], [{"value": 72.0, "label": "Threshold"}] + widget["annotations"]["horizontal"], [{"value": 72.0, "label": "기준"}] ) self.assertEqual(widget["end"], "PT0H") From e20734c819fa5ccb70559983b13bcdb7e26c4ef4 Mon Sep 17 00:00:00 2001 From: lens0021 Date: Sat, 3 Oct 2026 10:56:04 +0900 Subject: [PATCH 2/4] feat(sns-discord): show the alarm's description instead of its raw fields The embed listed every key of the SNS message, ARNs and actions included, in AWS's English. It now carries the alarm's own description, which the chart no longer clears. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NjZkQtRoTFu44uBp8tgofc --- sns-discord/lambda_function.py | 47 +++--------- sns-discord/test_lambda_function.py | 110 +++++++++++----------------- 2 files changed, 55 insertions(+), 102 deletions(-) diff --git a/sns-discord/lambda_function.py b/sns-discord/lambda_function.py index 1afe79a..e124e24 100644 --- a/sns-discord/lambda_function.py +++ b/sns-discord/lambda_function.py @@ -95,12 +95,7 @@ def lambda_handler(event: Any, context: Any) -> None: def build_payload(post_data: dict[str, Any], chart: bytes | None) -> dict[str, Any]: embed = post_data["embed"] if chart is not None: - embed = { - **embed, - "description": "", - "fields": [], - "image": {"url": "attachment://chart.png"}, - } + embed = {**embed, "image": {"url": "attachment://chart.png"}} return { "content": post_data["content"], "embeds": [embed], @@ -110,14 +105,13 @@ def build_payload(post_data: dict[str, Any], chart: bytes | None) -> dict[str, A def parse_message(event: Any) -> dict[str, Any]: message = sns_message(event) - fields: list[dict[str, Any]] = [] trigger = None region = None if message is None: notify = True color = RED summary = "알지 못하는 유형의 이벤트가 발생했습니다." - dump = json.dumps(event, indent=2, ensure_ascii=False) + description = code_block(json.dumps(event, indent=2, ensure_ascii=False)) else: try: parsed = json.loads(message) @@ -125,15 +119,18 @@ def parse_message(event: Any) -> dict[str, Any]: notify = True color = RED summary = "" - dump = message + description = code_block(message) else: alarm = parsed if isinstance(parsed, dict) else {} test = alarm.get("AlarmName") == "_Test" notify = alarm.get("NewStateValue") != "OK" and not test color = GRAY if test else RED if notify else GREEN summary = alarm_summary(alarm) - fields = message_to_fields(alarm) - dump = "" + # Written by whoever made the alarm, so already in their words + alarm_description = alarm.get("AlarmDescription") + description = ( + alarm_description if isinstance(alarm_description, str) else "" + ) if isinstance(alarm.get("Trigger"), dict): trigger = alarm["Trigger"] region = alarm_region(alarm) @@ -141,11 +138,7 @@ def parse_message(event: Any) -> dict[str, Any]: mention = f"<@&{MENTION_ROLE}> " if notify else "🟢 " return { "content": mention + summary, - "embed": { - "color": color, - "description": f"```json\n{dump}\n```" if dump else "", - "fields": fields, - }, + "embed": {"color": color, "description": description}, "trigger": trigger, "region": region, } @@ -265,26 +258,8 @@ def sns_message(event: Any) -> str | None: return message if isinstance(message, str) else None -def message_to_fields(message: dict[str, Any]) -> list[dict[str, Any]]: - fields = [] - for key, value in message.items(): - if key in ("NewStateReason", "AlarmName"): - continue - text, inline = value_to_string(value) - fields.append({"name": key, "value": text, "inline": inline}) - return fields - - -def value_to_string(value: Any) -> tuple[str, bool]: - if isinstance(value, str): - text = value - else: - stringified = json.dumps(value, indent=2, ensure_ascii=False) - if "\n" in stringified: - text = f"```json\n{stringified}\n```" - else: - text = f"`{stringified}`" - return text, "\n" not in text +def code_block(text: str) -> str: + return f"```json\n{text}\n```" def build_chart_widget( diff --git a/sns-discord/test_lambda_function.py b/sns-discord/test_lambda_function.py index e661e31..72c604a 100644 --- a/sns-discord/test_lambda_function.py +++ b/sns-discord/test_lambda_function.py @@ -11,9 +11,7 @@ build_payload, encode_multipart, fetch_chart_image, - message_to_fields, parse_message, - value_to_string, ) FULL_TRIGGER = { @@ -173,6 +171,34 @@ def test_test_alarm(self): self.assertEqual(post_data["embed"]["color"], GRAY) self.assertIsNone(post_data["trigger"]) + def test_description_is_the_alarm_description(self): + post_data = parse_message( + { + "Records": [ + { + "Sns": { + "Message": json.dumps( + { + "AlarmName": "_Test", + "AlarmDescription": "시험입니다.", + "NewStateValue": "ALARM", + "AWSAccountId": "302617221463", + }, + ensure_ascii=False, + ) + } + } + ] + } + ) + self.assertEqual( + post_data["embed"], {"color": GRAY, "description": "시험입니다."} + ) + + def test_plain_text_is_dumped(self): + post_data = parse_message({"Records": [{"Sns": {"Message": "hello"}}]}) + self.assertEqual(post_data["embed"]["description"], "```json\nhello\n```") + class AlarmSummaryTest(unittest.TestCase): def test_ok_after_insufficient_data(self): @@ -258,42 +284,6 @@ def test_missing_name(self): ) -class MessageToFieldsTest(unittest.TestCase): - def test_message_to_fields(self): - fields = message_to_fields( - { - "NewStateValue": "ALARM", - "OldStateValue": "OK", - "NewStateReason": "Threshold Crossed: 1 out of the last 1 datapoints [71.58514626666667 (09/04/22 21:01:00)] was less than the threshold (72.0) (minimum 1 datapoint for OK -> ALARM transition).", - } - ) - self.assertEqual(len(fields), 2, "NewStateReason should be removed") - self.assertEqual(fields[1]["value"], "OK") - - fields = message_to_fields( - { - "InsufficientDataActions": [], - "OKActions": [], - } - ) - self.assertEqual(len(fields), 2) - self.assertEqual(fields[1]["value"], "`[]`") - - -class ValueToStringTest(unittest.TestCase): - def test_value_to_string(self): - self.assertEqual(value_to_string(None), ("`null`", True)) - self.assertEqual(value_to_string("Foo"), ("Foo", True)) - self.assertEqual( - value_to_string(["Foo", "Bar"]), - ('```json\n[\n "Foo",\n "Bar"\n]\n```', False), - ) - self.assertEqual( - value_to_string({"Foo": "bar"}), - ('```json\n{\n "Foo": "bar"\n}\n```', False), - ) - - class BuildChartWidgetTest(unittest.TestCase): def test_full_trigger(self): widget = build_chart_widget(FULL_TRIGGER) @@ -384,36 +374,24 @@ def test_success_returns_image_bytes(self): class BuildPayloadTest(unittest.TestCase): - def test_no_chart_keeps_fields_and_description(self): - post_data = { - "content": "hi", - "embed": { - "color": RED, - "description": "```json\n{}\n```", - "fields": [{"name": "a", "value": "b", "inline": True}], - }, - } + def test_no_chart(self): + post_data = {"content": "hi", "embed": {"color": RED, "description": "설명"}} payload = build_payload(post_data, None) - embed = payload["embeds"][0] - self.assertEqual(embed["description"], "```json\n{}\n```") - self.assertEqual(embed["fields"], [{"name": "a", "value": "b", "inline": True}]) - self.assertNotIn("image", embed) - - def test_chart_strips_fields_and_description(self): - post_data = { - "content": "hi", - "embed": { - "color": RED, - "description": "```json\n{}\n```", - "fields": [{"name": "a", "value": "b", "inline": True}], - }, - } + self.assertEqual(payload["embeds"], [{"color": RED, "description": "설명"}]) + + def test_chart_keeps_the_description(self): + post_data = {"content": "hi", "embed": {"color": RED, "description": "설명"}} payload = build_payload(post_data, b"\x89PNG...") - embed = payload["embeds"][0] - self.assertEqual(embed["description"], "") - self.assertEqual(embed["fields"], []) - self.assertEqual(embed["image"], {"url": "attachment://chart.png"}) - self.assertEqual(embed["color"], RED) + self.assertEqual( + payload["embeds"], + [ + { + "color": RED, + "description": "설명", + "image": {"url": "attachment://chart.png"}, + } + ], + ) if __name__ == "__main__": From 11559b6daf74ec22b5d00311f036ddbdc0bf844c Mon Sep 17 00:00:00 2001 From: lens0021 Date: Sat, 3 Oct 2026 10:56:42 +0900 Subject: [PATCH 3/4] feat(sns-discord): say AWS Budgets notifications in Korean Budgets publish plain English text, which was posted as a code block under an empty line. Read its Budget Name, Alert Type, Alert Threshold and amount lines into one Korean sentence; anything else that is not JSON is still dumped as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NjZkQtRoTFu44uBp8tgofc --- sns-discord/lambda_function.py | 28 ++++++++++++++++++--- sns-discord/test_lambda_function.py | 38 +++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/sns-discord/lambda_function.py b/sns-discord/lambda_function.py index e124e24..e15d47f 100644 --- a/sns-discord/lambda_function.py +++ b/sns-discord/lambda_function.py @@ -1,4 +1,4 @@ -"""Forward SNS CloudWatch alarm notifications to a Discord webhook.""" +"""Post CloudWatch alarm and AWS Budgets notifications from SNS to Discord in Korean.""" import json import os @@ -118,8 +118,9 @@ def parse_message(event: Any) -> dict[str, Any]: except ValueError: notify = True color = RED - summary = "" - description = code_block(message) + budget = budget_summary(message) + summary = budget or "" + description = "" if budget else code_block(message) else: alarm = parsed if isinstance(parsed, dict) else {} test = alarm.get("AlarmName") == "_Test" @@ -144,6 +145,27 @@ def parse_message(event: Any) -> dict[str, Any]: } +def budget_summary(message: str) -> str | None: + # AWS Budgets sends plain text with lines such as "Budget Name: lambda" + lines = dict(line.split(": ", 1) for line in message.splitlines() if ": " in line) + name = lines.get("Budget Name") + kind = lines.get("Alert Type") + threshold = lines.get("Alert Threshold") + if name is None or kind is None or threshold is None: + return None + noun = {"ACTUAL": "실제", "FORECASTED": "예상"}.get(kind, kind) + summary = f"[예산 {name}] {noun} 비용이 알림 기준({threshold})을 넘었습니다." + figures = [ + f"{label} {amount}" + for label, amount in ( + (noun, lines.get(f"{kind} Amount")), + ("예산", lines.get("Budgeted Amount")), + ) + if amount is not None + ] + return f"{summary} {', '.join(figures)}." if figures else summary + + def alarm_region(alarm: dict[str, Any]) -> str | None: # arn:aws:cloudwatch:::alarm: arn = alarm.get("AlarmArn") diff --git a/sns-discord/test_lambda_function.py b/sns-discord/test_lambda_function.py index 72c604a..0b9ae87 100644 --- a/sns-discord/test_lambda_function.py +++ b/sns-discord/test_lambda_function.py @@ -199,6 +199,44 @@ def test_plain_text_is_dumped(self): post_data = parse_message({"Records": [{"Sns": {"Message": "hello"}}]}) self.assertEqual(post_data["embed"]["description"], "```json\nhello\n```") + def test_budget(self): + message = ( + "AWS Budget Notification October 03, 2026\n" + "AWS Account 302617221463\n\n" + "Dear AWS Customer,\n\n" + "You requested that we alert you when the ACTUAL Cost associated with" + " your lambda budget is greater than $0.01 for the current month." + " The ACTUAL Cost associated with this budget is $0.02.\n\n" + "Budget Name: lambda\n" + "Budget Type: Cost\n" + "Budgeted Amount: $1.00\n" + "Alert Type: ACTUAL\n" + "Alert Threshold: > $0.01\n" + "ACTUAL Amount: $0.02\n\n" + "[1] https://console.aws.amazon.com/billing/home#/budgets\n" + ) + post_data = parse_message({"Records": [{"Sns": {"Message": message}}]}) + self.assertEqual( + post_data["content"], + "<@&678974055365476392> [예산 lambda] 실제 비용이 알림 기준(> $0.01)을" + " 넘었습니다. 실제 $0.02, 예산 $1.00.", + ) + self.assertEqual(post_data["embed"], {"color": RED, "description": ""}) + + def test_forecasted_budget(self): + message = ( + "Budget Name: lambda\n" + "Alert Type: FORECASTED\n" + "Alert Threshold: > $0.01\n" + "FORECASTED Amount: $0.05\n" + ) + post_data = parse_message({"Records": [{"Sns": {"Message": message}}]}) + self.assertEqual( + post_data["content"], + "<@&678974055365476392> [예산 lambda] 예상 비용이 알림 기준(> $0.01)을" + " 넘었습니다. 예상 $0.05.", + ) + class AlarmSummaryTest(unittest.TestCase): def test_ok_after_insufficient_data(self): From 9290d34f5bd7f5f64cc65bcc6f240978b1adea83 Mon Sep 17 00:00:00 2001 From: lens0021 Date: Sat, 3 Oct 2026 11:04:01 +0900 Subject: [PATCH 4/4] refactor(sns-discord): let hgtk attach the subject particle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The statistic names no longer carry 이/가 themselves. hgtk ships a pure Python wheel, so the deploy's --only-binary install takes it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NjZkQtRoTFu44uBp8tgofc --- pyproject.toml | 5 ++++- sns-discord/lambda_function.py | 16 ++++++++-------- uv.lock | 12 ++++++++++++ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e28cd62..e671824 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,9 +17,12 @@ mastodon-discord = [ "mastodon-py>=2.2.2", "requests>=2.34.2", ] +sns-discord = [ + "hgtk>=0.2.1", +] [tool.uv] -default-groups = ["dev", "grafana-github", "mastodon-discord"] +default-groups = ["dev", "grafana-github", "mastodon-discord", "sns-discord"] [tool.ruff.lint] extend-select = [ diff --git a/sns-discord/lambda_function.py b/sns-discord/lambda_function.py index e15d47f..672d447 100644 --- a/sns-discord/lambda_function.py +++ b/sns-discord/lambda_function.py @@ -9,6 +9,7 @@ from typing import Any import boto3 +import hgtk RED = 0xE83535 GREEN = 0x2DAF32 @@ -23,13 +24,12 @@ "MINIMUM": "Minimum", "MAXIMUM": "Maximum", } -# What each statistic is called, with the subject particle it takes STAT_KOREAN = { - "SAMPLECOUNT": "표본 수가", - "AVERAGE": "평균이", - "SUM": "합계가", - "MINIMUM": "최솟값이", - "MAXIMUM": "최댓값이", + "SAMPLECOUNT": "표본 수", + "AVERAGE": "평균", + "SUM": "합계", + "MINIMUM": "최솟값", + "MAXIMUM": "최댓값", } COMPARISON_KOREAN = { "GreaterThanThreshold": "초과", @@ -204,7 +204,7 @@ def alarm_condition(trigger: dict[str, Any], state: Any) -> str | None: comparison = COMPARISON_KOREAN.get(trigger.get("ComparisonOperator")) raw_stat = trigger.get("ExtendedStatistic") or trigger.get("Statistic") stat = ( - STAT_KOREAN.get(raw_stat.upper(), f"{raw_stat} 값이") + STAT_KOREAN.get(raw_stat.upper(), f"{raw_stat} 값") if isinstance(raw_stat, str) else None ) @@ -217,7 +217,7 @@ def alarm_condition(trigger: dict[str, Any], state: Any) -> str | None: return None rule = f"기준({format_number(threshold)} {comparison})" - subject = f"{format_period(period)} {stat}" + subject = f"{format_period(period)} {hgtk.josa.attach(stat, hgtk.josa.I_GA)}" if state == "ALARM": evaluated = trigger.get("EvaluationPeriods") breached = trigger.get("DatapointsToAlarm") or evaluated diff --git a/uv.lock b/uv.lock index 554dc16..2d08234 100644 --- a/uv.lock +++ b/uv.lock @@ -299,6 +299,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, ] +[[package]] +name = "hgtk" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/d0/d62a73954ab95a8d3967a063c371c16feddfb8bd2957fd11691adf7834e8/hgtk-0.2.1-py2.py3-none-any.whl", hash = "sha256:f3e33dacf6ab2564f6257418b718e2c7a4ae9fffa32e18d6c4f6278b72ba73ee", size = 12011, upload-time = "2023-09-17T10:36:01.773Z" }, +] + [[package]] name = "html2text" version = "2025.4.15" @@ -508,6 +516,9 @@ mastodon-discord = [ { name = "mastodon-py" }, { name = "requests" }, ] +sns-discord = [ + { name = "hgtk" }, +] [package.metadata] @@ -523,6 +534,7 @@ mastodon-discord = [ { name = "mastodon-py", specifier = ">=2.2.2" }, { name = "requests", specifier = ">=2.34.2" }, ] +sns-discord = [{ name = "hgtk", specifier = ">=0.2.1" }] [[package]] name = "ty"