Skip to content
This repository was archived by the owner on Jun 9, 2026. It is now read-only.

feat(notification): add WeChat Work app message notification channel#493

Open
chqchshj wants to merge 32 commits into
Usagi-org:masterfrom
chqchshj:feat/wecom-app-notification
Open

feat(notification): add WeChat Work app message notification channel#493
chqchshj wants to merge 32 commits into
Usagi-org:masterfrom
chqchshj:feat/wecom-app-notification

Conversation

@chqchshj

Copy link
Copy Markdown

Summary

Add WeComAppClient for sending notifications via WeCom (企业微信) application message API, as opposed to the existing group bot webhook.

Motivation

The existing wecom channel uses group bot webhooks, which only support posting to group chats. Application messages (wecom_app) push directly to individual users via the WeCom app, which is more suitable for personal monitoring alerts.

Changes

  • src/infrastructure/external/notification_clients/wecom_app_client.py — new client
  • src/infrastructure/config/settings.py — add 4 env fields
  • src/infrastructure/external/notification_clients/factory.py — register client
  • src/infrastructure/external/notification_clients/__init__.py — export
  • src/services/notification_config_service.py — field map, channel detection, status flags

Configuration

WECOM_APP_CORPID=企业ID
WECOM_APP_SECRET=应用Secret
WECOM_APP_AGENTID=应用AgentID
WECOM_APP_TOUSER=接收用户ID(可选,默认@all)

Features

  • Access token caching with auto-refresh (expires 5min early)
  • Auto-retry on token expiration (errcode 40014/42001)
  • TextCard message format with clickable product links
  • Coexists with existing wecom bot channel (separate channel key wecom_app)
  • Follows the same architecture pattern as other notification clients

Add WeComAppClient for sending notifications via WeCom application API
(corpid + corpsecret + agentid), as opposed to the existing webhook bot.

Features:
- Access token caching with auto-refresh
- TextCard message format with clickable links
- Auto-retry on token expiration (40014/42001)
- Configurable target users (touser, defaults to @ALL)

Env vars:
- WECOM_APP_CORPID
- WECOM_APP_SECRET
- WECOM_APP_AGENTID
- WECOM_APP_TOUSER

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds support for WeChat Work App notifications by implementing a new WeComAppClient, updating configuration settings, and integrating the client into the notification factory. Key feedback involves improving security by escaping HTML in messages and masking secrets in the UI, fixing missing configuration logic in the service layer, and refining type hints and return values for consistency with the base notification class.

Comment on lines +65 to +66
f"<div class=\"normal\">💰 价格: {message.price}</div>",
f"<div class=\"normal\">📝 原因: {message.reason}</div>",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

User-provided data such as price and reason should be HTML-escaped before being embedded in the textcard description. WeChat Work's textcard supports a subset of HTML, and unescaped characters (like < or &) in the product data could break the message structure or lead to injection issues.

Suggested change
f"<div class=\"normal\">💰 价格: {message.price}</div>",
f"<div class=\"normal\">📝 原因: {message.reason}</div>",
f"<div class=\"normal\">💰 价格: {html.escape(message.price)}</div>",
f"<div class=\"normal\">📝 原因: {html.escape(message.reason)}</div>",

"GOTIFY_TOKEN": "gotify_token",
"BARK_URL": "bark_url",
"WX_BOT_URL": "wx_bot_url",
"WECOM_APP_CORPID": "wecom_app_corpid",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The new WeChat Work App configuration fields (CORPID, SECRET, AGENTID, TOUSER) are missing from the build_notification_settings_response function (around line 95). Without this update, the web UI will not be able to retrieve and display the current configuration values for this channel.

"BARK_URL": "bark_url",
"WX_BOT_URL": "wx_bot_url",
"WECOM_APP_CORPID": "wecom_app_corpid",
"WECOM_APP_SECRET": "wecom_app_secret",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The WECOM_APP_SECRET field must be added to the SECRET_NOTIFICATION_FIELDS set (around line 57). This ensures the secret is masked in the UI and not transmitted in plain text in API responses.

wx_bot_url: Optional[str] = _env_field(None, "WX_BOT_URL")
wecom_app_corpid: Optional[str] = _env_field(None, "WECOM_APP_CORPID")
wecom_app_secret: Optional[str] = _env_field(None, "WECOM_APP_SECRET")
wecom_app_agentid: Optional[str] = _env_field(None, "WECOM_APP_AGENTID")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

It is recommended to use Optional[int] for the wecom_app_agentid field. This allows Pydantic to automatically handle type conversion and validation from environment variables, ensuring the value is a valid integer as required by the WeChat Work API.

Suggested change
wecom_app_agentid: Optional[str] = _env_field(None, "WECOM_APP_AGENTID")
wecom_app_agentid: Optional[int] = _env_field(None, "WECOM_APP_AGENTID")

Comment on lines +5 to +6
import asyncio
import time

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add the html module to the imports to support escaping user-provided content in the notification message.

Suggested change
import asyncio
import time
import asyncio
import html
import time

self,
corpid: str | None = None,
corpsecret: str | None = None,
agentid: str | None = None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the type hint for agentid to include int, as it is expected to be an integer by the API and can be provided as such via the settings.

Suggested change
agentid: str | None = None,
agentid: int | str | None = None,

self._token_expires_at = now + data.get("expires_in", 7200) - 300
return self._access_token

async def send(self, product_data: Dict, reason: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The send method signature should return a bool to remain consistent with the NotificationClient base class definition and its docstring.

Suggested change
async def send(self, product_data: Dict, reason: str) -> None:
async def send(self, product_data: Dict, reason: str) -> bool:

Comment on lines +112 to +115
else:
raise RuntimeError(
f"企微应用消息发送失败: {result.get('errmsg', '未知错误')}"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The method should return True upon successful completion to satisfy the expected return type of the base class.

Suggested change
else:
raise RuntimeError(
f"企微应用消息发送失败: {result.get('errmsg', '未知错误')}"
)
else:
raise RuntimeError(
f"企微应用消息发送失败: {result.get('errmsg', '未知错误')}"
)
return True

chqchshj and others added 28 commits May 14, 2026 21:37
- HTML-escape user data in textcard description (XSS prevention)
- Add WECOM_APP_SECRET to SECRET_NOTIFICATION_FIELDS (mask in UI)
- Add wecom_app fields to build_notification_settings_response
- Return bool from send() to match base class contract
- Accept int|str for agentid parameter
- Import html module
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant