fix: sign Feishu webhook messages

This commit is contained in:
Codex 2026-05-07 18:12:43 +08:00
parent ebe9d5684a
commit 70e11dd1e7
5 changed files with 52 additions and 8 deletions

View File

@ -1,6 +1,10 @@
from __future__ import annotations from __future__ import annotations
import json import json
import base64
import hashlib
import hmac
import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from typing import Any from typing import Any
@ -54,13 +58,25 @@ def create_summary_payload(manager_url: str, summary: dict[str, Any]) -> dict[st
} }
def send_webhook(webhook_url: str, payload: dict[str, Any]) -> dict[str, Any]: def create_webhook_sign(timestamp: str, secret: str) -> str:
string_to_sign = f"{timestamp}\n{secret}".encode("utf-8")
sign = hmac.new(string_to_sign, digestmod=hashlib.sha256).digest()
return base64.b64encode(sign).decode("utf-8")
def send_webhook(webhook_url: str, payload: dict[str, Any], secret: str = "") -> dict[str, Any]:
if not webhook_url: if not webhook_url:
raise ValueError("FEISHU_WEBHOOK_URL is required") raise ValueError("FEISHU_WEBHOOK_URL is required")
request_payload = dict(payload)
if secret:
timestamp = str(int(time.time()))
request_payload["timestamp"] = timestamp
request_payload["sign"] = create_webhook_sign(timestamp, secret)
request = urllib.request.Request( request = urllib.request.Request(
webhook_url, webhook_url,
data=json.dumps(payload).encode("utf-8"), data=json.dumps(request_payload).encode("utf-8"),
headers={"content-type": "application/json"}, headers={"content-type": "application/json"},
method="POST", method="POST",
) )

View File

@ -15,7 +15,7 @@ def send_reminder() -> dict:
if not is_workday(date.today(), config.workday_calendar_path): if not is_workday(date.today(), config.workday_calendar_path):
return {"skipped": True, "reason": "not a workday"} return {"skipped": True, "reason": "not a workday"}
payload = create_reminder_payload(f"{config.base_url}/submit") payload = create_reminder_payload(f"{config.base_url}/submit")
return send_webhook(config.feishu_webhook_url, payload) return send_webhook(config.feishu_webhook_url, payload, config.feishu_webhook_secret)
def send_summary(report_date: str | None = None) -> dict: def send_summary(report_date: str | None = None) -> dict:
@ -30,7 +30,7 @@ def send_summary(report_date: str | None = None) -> dict:
selected_date_text = selected_date.isoformat() selected_date_text = selected_date.isoformat()
summary = service.list_reports_for_date(selected_date_text) summary = service.list_reports_for_date(selected_date_text)
payload = create_summary_payload(f"{config.base_url}/manager?date={selected_date_text}", summary) payload = create_summary_payload(f"{config.base_url}/manager?date={selected_date_text}", summary)
return send_webhook(config.feishu_webhook_url, payload) return send_webhook(config.feishu_webhook_url, payload, config.feishu_webhook_secret)
finally: finally:
database.close() database.close()

View File

@ -294,14 +294,34 @@ class DailyReportHandler(BaseHTTPRequestHandler):
return return
if parsed.path == "/api/robot/send-reminder": if parsed.path == "/api/robot/send-reminder":
payload = create_reminder_payload(f"{self.config.base_url}/submit") payload = create_reminder_payload(f"{self.config.base_url}/submit")
self._json(200, {"ok": True, "result": send_webhook(self.config.feishu_webhook_url, payload)}) self._json(
200,
{
"ok": True,
"result": send_webhook(
self.config.feishu_webhook_url,
payload,
self.config.feishu_webhook_secret,
),
},
)
return return
if parsed.path == "/api/robot/send-summary": if parsed.path == "/api/robot/send-summary":
body = self._read_json() body = self._read_json()
report_date = body.get("date") or today_string() report_date = body.get("date") or today_string()
summary = self.report_service.list_reports_for_date(report_date) summary = self.report_service.list_reports_for_date(report_date)
payload = create_summary_payload(f"{self.config.base_url}/manager?date={report_date}", summary) payload = create_summary_payload(f"{self.config.base_url}/manager?date={report_date}", summary)
self._json(200, {"ok": True, "result": send_webhook(self.config.feishu_webhook_url, payload)}) self._json(
200,
{
"ok": True,
"result": send_webhook(
self.config.feishu_webhook_url,
payload,
self.config.feishu_webhook_secret,
),
},
)
return return
self._json(404, {"error": "not found"}) self._json(404, {"error": "not found"})
except (ValueError, json.JSONDecodeError) as error: except (ValueError, json.JSONDecodeError) as error:

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import json import json
import unittest import unittest
from daily_report.robot_service import create_reminder_payload, create_summary_payload from daily_report.robot_service import create_reminder_payload, create_summary_payload, create_webhook_sign
class RobotServiceTest(unittest.TestCase): class RobotServiceTest(unittest.TestCase):
@ -33,6 +33,12 @@ class RobotServiceTest(unittest.TestCase):
self.assertIn("Chen", text) self.assertIn("Chen", text)
self.assertIn("http://localhost:8787/manager", text) self.assertIn("http://localhost:8787/manager", text)
def test_creates_webhook_signature(self) -> None:
signature = create_webhook_sign("1599360473", "test-secret")
self.assertIsInstance(signature, str)
self.assertGreater(len(signature), 20)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -36,9 +36,10 @@ class ScheduledTest(unittest.TestCase):
captured = {} captured = {}
def fake_send(webhook_url: str, payload: dict) -> dict: def fake_send(webhook_url: str, payload: dict, secret: str = "") -> dict:
captured["webhook_url"] = webhook_url captured["webhook_url"] = webhook_url
captured["payload"] = payload captured["payload"] = payload
captured["secret"] = secret
return {"ok": True} return {"ok": True}
with patch("daily_report.config.__file__", str(fake_file)), patch.dict("os.environ", env, clear=True), patch( with patch("daily_report.config.__file__", str(fake_file)), patch.dict("os.environ", env, clear=True), patch(
@ -48,6 +49,7 @@ class ScheduledTest(unittest.TestCase):
self.assertEqual(result, {"ok": True}) self.assertEqual(result, {"ok": True})
self.assertEqual(captured["webhook_url"], "https://example.test/webhook") self.assertEqual(captured["webhook_url"], "https://example.test/webhook")
self.assertEqual(captured["secret"], "")
text = json.dumps(captured["payload"], ensure_ascii=False) text = json.dumps(captured["payload"], ensure_ascii=False)
self.assertIn("2026-05-07 日报提交汇总", text) self.assertIn("2026-05-07 日报提交汇总", text)
self.assertIn("0/2", text) self.assertIn("0/2", text)