diff --git a/docs/superpowers/plans/2026-07-11-http-channel-transport.md b/docs/superpowers/plans/2026-07-11-http-channel-transport.md new file mode 100644 index 0000000..8e17d02 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-http-channel-transport.md @@ -0,0 +1,574 @@ +# HTTP Channel Transport Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Share sync and async HTTP transport logic across HTTP-backed notification channels without changing the public API. + +**Architecture:** Add `HttpChannel`, a focused base class that owns `send()` and `send_async()` for HTTP-backed channels. Provider classes keep URL, headers, payload building, success validation settings, and success log text. + +**Tech Stack:** Python 3.8+, `httpx.Client`, `httpx.AsyncClient`, pytest, pytest-asyncio, unittest.mock. + +--- + +## File Structure + +- Create: `src/use_notify/channels/http.py` + - Owns shared HTTP request dispatch for sync and async paths. + - Converts `payload_kind` into the right `httpx` keyword. + - Calls `raise_for_status()` and optional `validate_business_response(...)`. +- Modify: `src/use_notify/channels/bark.py` + - Inherit `HttpChannel`. + - Keep `_prepare_payload(...)` for compatibility with existing tests. +- Modify: `src/use_notify/channels/chanify.py` + - Inherit `HttpChannel`. + - Keep `build_api_body(...)`. +- Modify: `src/use_notify/channels/ding.py` + - Inherit `HttpChannel`. + - Keep `build_api_body(...)`. +- Modify: `src/use_notify/channels/feishu.py` + - Inherit `HttpChannel`. + - Keep `build_api_body(...)`. +- Modify: `src/use_notify/channels/wechat.py` + - Inherit `HttpChannel`. + - Keep `build_api_body(...)` and existing argument order. +- Modify: `src/use_notify/channels/pushover.py` + - Inherit `HttpChannel`. + - Keep `build_api_body(...)`. +- Modify: `src/use_notify/channels/pushdeer.py` + - Inherit `HttpChannel`. + - Keep `_prepare_params(...)` and token validation in `api_url`. +- Modify: `src/use_notify/channels/ntfy.py` + - Inherit `HttpChannel`. + - Keep `_prepare_payload(...)` and topic validation. +- Modify: `tests/test_channels.py` + - Add direct tests for the new base class unsupported method and unsupported payload kind. + - Existing channel tests continue to verify request shapes. + +## Task 1: Add HTTP Transport Base + +**Files:** +- Create: `src/use_notify/channels/http.py` +- Modify: `tests/test_channels.py` + +- [x] **Step 1: Write tests for unsupported transport configuration** + +Add these imports near the top of `tests/test_channels.py`: + +```python +from use_notify.channels.http import HttpChannel +``` + +Add these test helper classes and tests after `_credential_provider(...)`: + +```python +class UnsupportedMethodChannel(HttpChannel): + request_method = "PATCH" + + @property + def api_url(self): + return "https://example.com" + + @property + def headers(self): + return {} + + def build_request_payload(self, content, title=None): + return {"message": content} + + +class UnsupportedPayloadChannel(HttpChannel): + payload_kind = "body" + + @property + def api_url(self): + return "https://example.com" + + @property + def headers(self): + return {} + + def build_request_payload(self, content, title=None): + return {"message": content} + + +def test_http_channel_rejects_unsupported_method(): + channel = UnsupportedMethodChannel({}) + + with pytest.raises(ValueError, match="Unsupported HTTP method"): + channel.send("hello") + + +def test_http_channel_rejects_unsupported_payload_kind(): + channel = UnsupportedPayloadChannel({}) + + with pytest.raises(ValueError, match="Unsupported HTTP payload kind"): + channel.send("hello") +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run: + +```bash +uv run --group dev pytest tests/test_channels.py::test_http_channel_rejects_unsupported_method tests/test_channels.py::test_http_channel_rejects_unsupported_payload_kind -q +``` + +Expected: import failure because `use_notify.channels.http` does not exist. + +- [x] **Step 3: Create `HttpChannel`** + +Create `src/use_notify/channels/http.py`: + +```python +import logging +from abc import abstractmethod + +import httpx + +from .base import BaseChannel +from .utils import validate_business_response + +logger = logging.getLogger(__name__) + + +class HttpChannel(BaseChannel): + request_method = "POST" + payload_kind = "json" + success_fields = None + provider_name = None + success_log_message = None + + @abstractmethod + def build_request_payload(self, content, title=None): + raise NotImplementedError + + def send(self, content, title=None): + payload = self.build_request_payload(content, title) + with httpx.Client() as client: + response = self._send_request(client, payload) + self._handle_response(response) + self._log_success() + + async def send_async(self, content, title=None): + payload = self.build_request_payload(content, title) + async with httpx.AsyncClient() as client: + response = await self._send_request_async(client, payload) + self._handle_response(response) + self._log_success() + + def _send_request(self, client, payload): + if self.request_method == "POST": + return client.post(self.api_url, headers=self.headers, **self._payload_kwargs(payload)) + if self.request_method == "GET": + return client.get(self.api_url, headers=self.headers, **self._payload_kwargs(payload)) + raise ValueError(f"Unsupported HTTP method: {self.request_method}") + + async def _send_request_async(self, client, payload): + if self.request_method == "POST": + return await client.post( + self.api_url, + headers=self.headers, + **self._payload_kwargs(payload), + ) + if self.request_method == "GET": + return await client.get( + self.api_url, + headers=self.headers, + **self._payload_kwargs(payload), + ) + raise ValueError(f"Unsupported HTTP method: {self.request_method}") + + def _payload_kwargs(self, payload): + if self.payload_kind == "json": + return {"json": payload} + if self.payload_kind == "data": + return {"data": payload} + if self.payload_kind == "params": + return {"params": payload} + raise ValueError(f"Unsupported HTTP payload kind: {self.payload_kind}") + + def _handle_response(self, response): + response.raise_for_status() + if self.success_fields: + validate_business_response(response, self.provider_name, self.success_fields) + + def _log_success(self): + if self.success_log_message: + logger.debug(self.success_log_message) +``` + +- [x] **Step 4: Run tests to verify they pass** + +Run: + +```bash +uv run --group dev pytest tests/test_channels.py::test_http_channel_rejects_unsupported_method tests/test_channels.py::test_http_channel_rejects_unsupported_payload_kind -q +``` + +Expected: `2 passed`. + +- [x] **Step 5: Commit Task 1** + +Run: + +```bash +git add src/use_notify/channels/http.py tests/test_channels.py +git commit -m "refactor: add shared http channel transport" +``` + +## Task 2: Migrate POST JSON Channels + +**Files:** +- Modify: `src/use_notify/channels/bark.py` +- Modify: `src/use_notify/channels/ding.py` +- Modify: `src/use_notify/channels/feishu.py` +- Modify: `src/use_notify/channels/wechat.py` +- Modify: `src/use_notify/channels/ntfy.py` + +- [x] **Step 1: Migrate Bark** + +Replace `import httpx`, `from .base import BaseChannel`, and `from .utils import validate_business_response` with: + +```python +from .http import HttpChannel +``` + +Change the class definition and add class attributes: + +```python +class Bark(HttpChannel): + """Bark app 消息通知""" + + payload_kind = "json" + provider_name = "bark" + success_fields = {"code": {200}} + success_log_message = "`bark` send successfully" +``` + +Add: + +```python + def build_request_payload(self, content, title=None): + return self._prepare_payload(content, title) +``` + +Remove the custom `send(...)` and `send_async(...)` methods. + +- [x] **Step 2: Migrate Ding** + +Replace HTTP imports with: + +```python +from .http import HttpChannel +``` + +Use: + +```python +class Ding(HttpChannel): + """钉钉消息通知 + https://developers.dingtalk.com/document/app/custom-robot-access?spm=ding_open_doc.document.0.0.6d9d28e1QcCPII#topic-2026027 + """ + + payload_kind = "json" + provider_name = "ding" + success_fields = {"errcode": {0}} + success_log_message = "`钉钉` send successfully" +``` + +Add: + +```python + def build_request_payload(self, content, title=None): + return self.build_api_body(content, title) +``` + +Remove custom `send(...)` and `send_async(...)`. + +- [x] **Step 3: Migrate Feishu** + +Replace HTTP imports with: + +```python +from .http import HttpChannel +``` + +Use: + +```python +class Feishu(HttpChannel): + """飞书消息通知 + https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN + """ + + payload_kind = "json" + provider_name = "feishu" + success_fields = {"code": {0}} + success_log_message = "`飞书` send successfully" +``` + +Add: + +```python + def build_request_payload(self, content, title=None): + return self.build_api_body(content, title) +``` + +Remove custom `send(...)` and `send_async(...)`. + +- [x] **Step 4: Migrate WeChat** + +Replace HTTP imports with: + +```python +from .http import HttpChannel +``` + +Use: + +```python +class WeChat(HttpChannel): + """企业微信消息通知""" + + payload_kind = "json" + provider_name = "wechat" + success_fields = {"errcode": {0}} + success_log_message = "`WeChat` send successfully" +``` + +Add: + +```python + def build_request_payload(self, content, title=None): + return self.build_api_body(title, content) +``` + +Remove custom `send(...)` and `send_async(...)`. + +- [x] **Step 5: Migrate Ntfy** + +Replace `import httpx` and `from .base import BaseChannel` with: + +```python +from .http import HttpChannel +``` + +Use: + +```python +class Ntfy(HttpChannel): + """Ntfy.sh 通知渠道""" + + payload_kind = "json" + success_log_message = "`ntfy` send successfully" +``` + +Add: + +```python + def build_request_payload(self, content, title=None): + return self._prepare_payload(content, title) +``` + +Remove custom `send(...)` and `send_async(...)`. Do not add `success_fields`; Ntfy only checks HTTP status. + +- [x] **Step 6: Run focused tests** + +Run: + +```bash +uv run --group dev pytest tests/test_channels.py -q +``` + +Expected: all channel tests pass. + +- [x] **Step 7: Commit Task 2** + +Run: + +```bash +git add src/use_notify/channels/bark.py src/use_notify/channels/ding.py src/use_notify/channels/feishu.py src/use_notify/channels/wechat.py src/use_notify/channels/ntfy.py +git commit -m "refactor: migrate json http channels" +``` + +## Task 3: Migrate POST Data and GET Params Channels + +**Files:** +- Modify: `src/use_notify/channels/chanify.py` +- Modify: `src/use_notify/channels/pushover.py` +- Modify: `src/use_notify/channels/pushdeer.py` + +- [x] **Step 1: Migrate Chanify** + +Replace HTTP imports with: + +```python +from .http import HttpChannel +``` + +Use: + +```python +class Chanify(HttpChannel): + """chanify 消息通知""" + + payload_kind = "data" + provider_name = "chanify" + success_fields = {"res": {0}, "code": {0}} + success_log_message = "`chanify` send successfully" +``` + +Add: + +```python + def build_request_payload(self, content, title=None): + return self.build_api_body(content, title) +``` + +Remove custom `send(...)` and `send_async(...)`. + +- [x] **Step 2: Migrate PushOver** + +Replace HTTP imports with: + +```python +from .http import HttpChannel +``` + +Use: + +```python +class PushOver(HttpChannel): + """pushover app 消息通知""" + + payload_kind = "data" + provider_name = "pushover" + success_fields = {"status": {1}} + success_log_message = "`pushover` send successfully" +``` + +Add: + +```python + def build_request_payload(self, content, title=None): + return self.build_api_body(content, title) +``` + +Remove custom `send(...)` and `send_async(...)`. + +- [x] **Step 3: Migrate PushDeer** + +Replace HTTP imports with: + +```python +from .http import HttpChannel +``` + +Use: + +```python +class PushDeer(HttpChannel): + """pushdeer app 消息通知 + + 支持三种消息类型: + - text: 纯文本消息 + - image: 图片消息 + - markdown: Markdown格式消息 (默认) + + 配置参数: + - token: PushDeer的pushkey + - base_url: 可选,自建PushDeer服务的URL,默认为"https://api2.pushdeer.com" + - type: 可选,消息类型,可选值为text、markdown、image,默认为markdown + """ + + request_method = "GET" + payload_kind = "params" + provider_name = "pushdeer" + success_fields = {"code": {0}} + success_log_message = "`pushdeer` send message successfully" +``` + +Add: + +```python + def build_request_payload(self, content, title=None): + return self._prepare_params(content, title) +``` + +Remove custom `send(...)` and `send_async(...)`. + +- [x] **Step 4: Run focused tests** + +Run: + +```bash +uv run --group dev pytest tests/test_channels.py -q +``` + +Expected: all channel tests pass. + +- [x] **Step 5: Commit Task 3** + +Run: + +```bash +git add src/use_notify/channels/chanify.py src/use_notify/channels/pushover.py src/use_notify/channels/pushdeer.py +git commit -m "refactor: migrate form and params http channels" +``` + +## Task 4: Full Verification and Documentation + +**Files:** +- Modify: `docs/superpowers/plans/2026-07-11-http-channel-transport.md` only if checkbox tracking is updated. + +- [x] **Step 1: Run lint** + +Run: + +```bash +make lint +``` + +Expected: isort, black, and flake8 all pass. + +- [x] **Step 2: Run tests** + +Run: + +```bash +make test +``` + +Expected: `126 passed` plus any new tests added in Task 1. + +- [x] **Step 3: Run coverage** + +Run: + +```bash +make coverage +``` + +Expected: total coverage remains at or above 95%. + +- [x] **Step 4: Inspect final diff** + +Run: + +```bash +git diff --stat origin/main...HEAD +git diff origin/main...HEAD -- src/use_notify/channels tests/test_channels.py +``` + +Expected: diff shows the new HTTP transport and migrated channel classes without public API changes. + +- [x] **Step 5: Push branch and open PR** + +Run: + +```bash +git push -u origin refactor-http-channel-transport +gh pr create --base main --head refactor-http-channel-transport --title "Refactor HTTP channels to share transport logic" --body "" +``` + +Expected: PR created and linked to Issue #59. diff --git a/docs/superpowers/specs/2026-07-11-http-channel-transport-design.md b/docs/superpowers/specs/2026-07-11-http-channel-transport-design.md new file mode 100644 index 0000000..8200d26 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-http-channel-transport-design.md @@ -0,0 +1,176 @@ +# HTTP Channel Transport Refactor Design + +Date: 2026-07-11 +Issue: https://github.com/use-py/use-notify/issues/59 + +## Goal + +Reduce duplicated sync and async HTTP send logic across HTTP-backed notification channels while keeping the public API unchanged. + +Users must keep calling: + +```python +channel.send(content, title=None) +await channel.send_async(content, title=None) + +notify.publish(title="...", content="...") +await notify.publish_async(title="...", content="...") +``` + +The refactor should make channel implementations smaller and easier to audit without changing request payloads, dynamic credential behavior, provider response validation, or exception behavior. + +## Non-Goals + +- Do not merge sync and async public methods into a single method. +- Do not change `Publisher` retry semantics. +- Do not change `Email`; it uses SMTP and already runs sync SMTP work in an executor for async sends. +- Do not change `Console`; it is not HTTP-backed and has different output text for sync and async sends. +- Do not add provider-specific OAuth refresh, background credential refresh, or credential persistence. + +## Current Problem + +The HTTP-backed channels repeat this pattern: + +1. Build request payload from `content`, `title`, and config. +2. Open `httpx.Client` or `httpx.AsyncClient`. +3. Send `GET` or `POST`. +4. Call `response.raise_for_status()`. +5. Optionally call `validate_business_response(...)`. +6. Write a provider-specific success log message. + +The duplicated flow exists in: + +- Bark +- Chanify +- Ding +- Feishu +- WeChat / WeCom +- PushDeer +- PushOver +- Ntfy + +## Recommended Architecture + +Add a shared HTTP channel base class: + +```python +class HttpChannel(BaseChannel): + request_method = "POST" + payload_kind = "json" + success_fields = None + provider_name = None + success_log_message = None + + def build_request_payload(self, content, title=None): + ... + + def send(self, content, title=None): + ... + + async def send_async(self, content, title=None): + ... +``` + +The base class owns the transport workflow. Subclasses provide provider-specific data: + +- `api_url` +- `headers` +- `request_method` +- `payload_kind` +- `success_fields` +- `provider_name` +- `success_log_message` +- `build_request_payload(...)` + +Supported `payload_kind` values: + +- `json`: pass payload as `json=...` +- `data`: pass payload as `data=...` +- `params`: pass payload as `params=...` + +Supported request methods for this refactor: + +- `POST` +- `GET` + +If a subclass configures an unsupported method or payload kind, the base class should raise `ValueError`. This fails fast during development instead of silently sending the wrong request. + +## Channel Migration Map + +| Channel | Method | Payload Kind | Success Fields | +| --- | --- | --- | --- | +| Bark | POST | json | `{"code": {200}}` | +| Chanify | POST | data | `{"res": {0}, "code": {0}}` | +| Ding | POST | json | `{"errcode": {0}}` | +| Feishu | POST | json | `{"code": {0}}` | +| WeChat / WeCom | POST | json | `{"errcode": {0}}` | +| PushOver | POST | data | `{"status": {1}}` | +| PushDeer | GET | params | `{"code": {0}}` | +| Ntfy | POST | json | `None` | + +Ntfy currently only checks HTTP status. It should keep that behavior. + +## Data Flow + +Sync send: + +```text +send(content, title) + -> build_request_payload(content, title) + -> _send_http_request(payload) + -> response.raise_for_status() + -> validate_business_response if configured + -> success log +``` + +Async send: + +```text +send_async(content, title) + -> build_request_payload(content, title) + -> await _send_http_request_async(payload) + -> response.raise_for_status() + -> validate_business_response if configured + -> success log +``` + +Dynamic credentials remain in the channel-specific `api_url` or payload builders through `resolve_config_value(...)`. + +## Error Handling + +- Preserve `httpx` HTTP status behavior by continuing to call `raise_for_status()`. +- Preserve provider business response behavior by continuing to call `validate_business_response(...)` with the same provider names and success fields. +- Preserve current `ValueError` behavior from channel-specific config checks such as missing PushDeer token and missing Ntfy topic. +- New `ValueError` cases are limited to impossible subclass configuration errors, such as unsupported HTTP method or payload kind. + +## Testing + +Existing tests should continue to prove behavior: + +- Request method, URL, headers, and body shape for every migrated channel. +- Dynamic credential callables resolve at request-building time. +- Provider business response errors are still raised. +- Sync and async paths both send the same data. +- Ntfy still only relies on HTTP status. + +Run: + +```bash +make lint +make test +make coverage +``` + +Coverage must remain above the configured 95% threshold. + +## Rollout Plan + +1. Add `HttpChannel` with tests covering payload dispatch and unsupported configuration. +2. Migrate one representative JSON POST channel first, then the rest. +3. Migrate data POST channels. +4. Migrate GET params channel. +5. Run the full suite and review the diff for unchanged public behavior. + +## Open Decisions + +None. The design intentionally keeps the public API unchanged and avoids changing non-HTTP channels. diff --git a/src/use_notify/channels/bark.py b/src/use_notify/channels/bark.py index 24d6015..945df2c 100644 --- a/src/use_notify/channels/bark.py +++ b/src/use_notify/channels/bark.py @@ -1,17 +1,14 @@ -# -*- coding: utf-8 -*- -import logging +from .http import HttpChannel -import httpx -from .base import BaseChannel -from .utils import validate_business_response - -logger = logging.getLogger(__name__) - - -class Bark(BaseChannel): +class Bark(HttpChannel): """Bark app 消息通知""" + payload_kind = "json" + provider_name = "bark" + success_fields = {"code": {200}} + success_log_message = "`bark` send successfully" + @property def api_url(self): # Check if base_url exists in config, otherwise use default @@ -41,18 +38,5 @@ def _prepare_payload(self, content, title=None): return payload - def send(self, content, title=None): - payload = self._prepare_payload(content, title) - with httpx.Client() as client: - response = client.post(self.api_url, headers=self.headers, json=payload) - response.raise_for_status() - validate_business_response(response, "bark", {"code": {200}}) - logger.debug("`bark` send successfully") - - async def send_async(self, content, title=None): - payload = self._prepare_payload(content, title) - async with httpx.AsyncClient() as client: - response = await client.post(self.api_url, headers=self.headers, json=payload) - response.raise_for_status() - validate_business_response(response, "bark", {"code": {200}}) - logger.debug("`bark` send successfully") + def build_request_payload(self, content, title=None): + return self._prepare_payload(content, title) diff --git a/src/use_notify/channels/chanify.py b/src/use_notify/channels/chanify.py index 10eee9b..352e78e 100644 --- a/src/use_notify/channels/chanify.py +++ b/src/use_notify/channels/chanify.py @@ -1,17 +1,14 @@ -# -*- coding: utf-8 -*- -import logging +from .http import HttpChannel -import httpx -from .base import BaseChannel -from .utils import validate_business_response - -logger = logging.getLogger(__name__) - - -class Chanify(BaseChannel): +class Chanify(HttpChannel): """chanify 消息通知""" + payload_kind = "data" + provider_name = "chanify" + success_fields = {"res": {0}, "code": {0}} + success_log_message = "`chanify` send successfully" + @property def api_url(self): # Check if base_url exists in config, otherwise use default @@ -31,18 +28,5 @@ def build_api_body(content, title=None): text = f"{title}\n{content}" if title else content return {"text": text} - def send(self, content, title=None): - api_body = self.build_api_body(content, title) - with httpx.Client() as client: - response = client.post(self.api_url, data=api_body, headers=self.headers) - response.raise_for_status() - validate_business_response(response, "chanify", {"res": {0}, "code": {0}}) - logger.debug("`chanify` send successfully") - - async def send_async(self, content, title=None): - api_body = self.build_api_body(content, title) - async with httpx.AsyncClient() as client: - response = await client.post(self.api_url, data=api_body, headers=self.headers) - response.raise_for_status() - validate_business_response(response, "chanify", {"res": {0}, "code": {0}}) - logger.debug("`chanify` send successfully") + def build_request_payload(self, content, title=None): + return self.build_api_body(content, title) diff --git a/src/use_notify/channels/ding.py b/src/use_notify/channels/ding.py index 4f99291..357adbe 100644 --- a/src/use_notify/channels/ding.py +++ b/src/use_notify/channels/ding.py @@ -1,19 +1,17 @@ # -*- coding: utf-8 -*- -import logging +from .http import HttpChannel -import httpx -from .base import BaseChannel -from .utils import validate_business_response - -logger = logging.getLogger(__name__) - - -class Ding(BaseChannel): +class Ding(HttpChannel): """钉钉消息通知 https://developers.dingtalk.com/document/app/custom-robot-access?spm=ding_open_doc.document.0.0.6d9d28e1QcCPII#topic-2026027 """ + payload_kind = "json" + provider_name = "ding" + success_fields = {"errcode": {0}} + success_log_message = "`钉钉` send successfully" + @property def api_url(self): return ( @@ -40,18 +38,5 @@ def build_api_body(self, content, title=None): api_body["at"]["atUserIds"] = self.config.at_user_ids return api_body - def send(self, content, title=None): - api_body = self.build_api_body(content, title) - with httpx.Client() as client: - response = client.post(self.api_url, json=api_body, headers=self.headers) - response.raise_for_status() - validate_business_response(response, "ding", {"errcode": {0}}) - logger.debug("`钉钉` send successfully") - - async def send_async(self, content, title=None): - api_body = self.build_api_body(content, title) - async with httpx.AsyncClient() as client: - response = await client.post(self.api_url, json=api_body, headers=self.headers) - response.raise_for_status() - validate_business_response(response, "ding", {"errcode": {0}}) - logger.debug("`钉钉` send successfully") + def build_request_payload(self, content, title=None): + return self.build_api_body(content, title) diff --git a/src/use_notify/channels/feishu.py b/src/use_notify/channels/feishu.py index 872c576..ee6d5b7 100644 --- a/src/use_notify/channels/feishu.py +++ b/src/use_notify/channels/feishu.py @@ -1,19 +1,17 @@ # -*- coding: utf-8 -*- -import logging +from .http import HttpChannel -import httpx -from .base import BaseChannel -from .utils import validate_business_response - -logger = logging.getLogger(__name__) - - -class Feishu(BaseChannel): +class Feishu(HttpChannel): """飞书消息通知 https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN """ + payload_kind = "json" + provider_name = "feishu" + success_fields = {"code": {0}} + success_log_message = "`飞书` send successfully" + @property def api_url(self): return f"https://open.feishu.cn/open-apis/bot/v2/hook/{self.resolve_config_value('token')}" @@ -38,18 +36,5 @@ def build_api_body(self, content, title=None): "content": {"post": {"zh_cn": {"title": title, "content": [api_body_content]}}}, } - def send(self, content, title=None): - api_body = self.build_api_body(content, title) - with httpx.Client() as client: - response = client.post(self.api_url, json=api_body, headers=self.headers) - response.raise_for_status() - validate_business_response(response, "feishu", {"code": {0}}) - logger.debug("`飞书` send successfully") - - async def send_async(self, content, title=None): - api_body = self.build_api_body(content, title) - async with httpx.AsyncClient() as client: - response = await client.post(self.api_url, json=api_body, headers=self.headers) - response.raise_for_status() - validate_business_response(response, "feishu", {"code": {0}}) - logger.debug("`飞书` send successfully") + def build_request_payload(self, content, title=None): + return self.build_api_body(content, title) diff --git a/src/use_notify/channels/http.py b/src/use_notify/channels/http.py new file mode 100644 index 0000000..fa02b4d --- /dev/null +++ b/src/use_notify/channels/http.py @@ -0,0 +1,75 @@ +import logging +from abc import abstractmethod + +import httpx + +from .base import BaseChannel +from .utils import validate_business_response + +logger = logging.getLogger(__name__) + + +class HttpChannel(BaseChannel): + request_method = "POST" + payload_kind = "json" + success_fields = None + provider_name = None + success_log_message = None + + @abstractmethod + def build_request_payload(self, content, title=None): + raise NotImplementedError + + def send(self, content, title=None): + payload = self.build_request_payload(content, title) + with httpx.Client() as client: + response = self._send_request(client, payload) + self._handle_response(response) + self._log_success() + + async def send_async(self, content, title=None): + payload = self.build_request_payload(content, title) + async with httpx.AsyncClient() as client: + response = await self._send_request_async(client, payload) + self._handle_response(response) + self._log_success() + + def _send_request(self, client, payload): + if self.request_method == "POST": + return client.post(self.api_url, headers=self.headers, **self._payload_kwargs(payload)) + if self.request_method == "GET": + return client.get(self.api_url, headers=self.headers, **self._payload_kwargs(payload)) + raise ValueError(f"Unsupported HTTP method: {self.request_method}") + + async def _send_request_async(self, client, payload): + if self.request_method == "POST": + return await client.post( + self.api_url, + headers=self.headers, + **self._payload_kwargs(payload), + ) + if self.request_method == "GET": + return await client.get( + self.api_url, + headers=self.headers, + **self._payload_kwargs(payload), + ) + raise ValueError(f"Unsupported HTTP method: {self.request_method}") + + def _payload_kwargs(self, payload): + if self.payload_kind == "json": + return {"json": payload} + if self.payload_kind == "data": + return {"data": payload} + if self.payload_kind == "params": + return {"params": payload} + raise ValueError(f"Unsupported HTTP payload kind: {self.payload_kind}") + + def _handle_response(self, response): + response.raise_for_status() + if self.success_fields: + validate_business_response(response, self.provider_name, self.success_fields) + + def _log_success(self): + if self.success_log_message: + logger.debug(self.success_log_message) diff --git a/src/use_notify/channels/ntfy.py b/src/use_notify/channels/ntfy.py index 5ac67b4..6db0c0d 100644 --- a/src/use_notify/channels/ntfy.py +++ b/src/use_notify/channels/ntfy.py @@ -1,17 +1,15 @@ # -*- coding: utf-8 -*- -import logging from typing import Any, Dict, Optional -import httpx +from .http import HttpChannel -from .base import BaseChannel -logger = logging.getLogger(__name__) - - -class Ntfy(BaseChannel): +class Ntfy(HttpChannel): """Ntfy.sh 通知渠道""" + payload_kind = "json" + success_log_message = "`ntfy` send successfully" + def __init__(self, config: dict): """ 初始化 Ntfy 渠道 @@ -82,34 +80,5 @@ def _prepare_payload(self, content: str, title: Optional[str] = None) -> Dict[st return payload - def send(self, content: str, title: Optional[str] = None) -> None: - """ - 发送通知到 ntfy.sh - - Args: - content: 消息内容 - title: 消息标题(可选) - """ - payload = self._prepare_payload(content, title) - - with httpx.Client() as client: - response = client.post(self.api_url, headers=self.headers, json=payload) - response.raise_for_status() - - logger.debug("`ntfy` send successfully") - - async def send_async(self, content: str, title: Optional[str] = None) -> None: - """ - 异步发送通知到 ntfy.sh - - Args: - content: 消息内容 - title: 消息标题(可选) - """ - payload = self._prepare_payload(content, title) - - async with httpx.AsyncClient() as client: - response = await client.post(self.api_url, headers=self.headers, json=payload) - response.raise_for_status() - - logger.debug("`ntfy` send successfully") + def build_request_payload(self, content: str, title: Optional[str] = None) -> Dict[str, Any]: + return self._prepare_payload(content, title) diff --git a/src/use_notify/channels/pushdeer.py b/src/use_notify/channels/pushdeer.py index 9cad452..c3596a7 100644 --- a/src/use_notify/channels/pushdeer.py +++ b/src/use_notify/channels/pushdeer.py @@ -1,15 +1,12 @@ # -*- coding: utf-8 -*- import logging -import httpx - -from .base import BaseChannel -from .utils import validate_business_response +from .http import HttpChannel logger = logging.getLogger(__name__) -class PushDeer(BaseChannel): +class PushDeer(HttpChannel): """pushdeer app 消息通知 支持三种消息类型: @@ -23,6 +20,12 @@ class PushDeer(BaseChannel): - type: 可选,消息类型,可选值为text、markdown、image,默认为markdown """ + request_method = "GET" + payload_kind = "params" + provider_name = "pushdeer" + success_fields = {"code": {0}} + success_log_message = "`pushdeer` send message successfully" + @property def api_url(self): """获取PushDeer API基础URL""" @@ -74,37 +77,10 @@ def _prepare_params(self, content, title=None): return params + def build_request_payload(self, content, title=None): + return self._prepare_params(content, title) + @property def headers(self): """请求头""" return {"Content-Type": "application/x-www-form-urlencoded"} - - def send(self, content, title=None): - """发送PushDeer消息 - - Args: - content: 消息内容 - title: 消息标题 - """ - params = self._prepare_params(content, title) - - with httpx.Client() as client: - response = client.get(self.api_url, params=params, headers=self.headers) - response.raise_for_status() - validate_business_response(response, "pushdeer", {"code": {0}}) - logger.debug("`pushdeer` send message successfully") - - async def send_async(self, content, title=None): - """异步发送PushDeer消息 - - Args: - content: 消息内容 - title: 消息标题 - """ - params = self._prepare_params(content, title) - - async with httpx.AsyncClient() as client: - response = await client.get(self.api_url, params=params, headers=self.headers) - response.raise_for_status() - validate_business_response(response, "pushdeer", {"code": {0}}) - logger.debug("`pushdeer` send message successfully") diff --git a/src/use_notify/channels/pushover.py b/src/use_notify/channels/pushover.py index fff7924..44d6728 100644 --- a/src/use_notify/channels/pushover.py +++ b/src/use_notify/channels/pushover.py @@ -1,17 +1,14 @@ -# -*- coding: utf-8 -*- -import logging +from .http import HttpChannel -import httpx -from .base import BaseChannel -from .utils import validate_business_response - -logger = logging.getLogger(__name__) - - -class PushOver(BaseChannel): +class PushOver(HttpChannel): """pushover app 消息通知""" + payload_kind = "data" + provider_name = "pushover" + success_fields = {"status": {1}} + success_log_message = "`pushover` send successfully" + @property def api_url(self): return "https://api.pushover.net/1/messages.json" @@ -28,18 +25,5 @@ def build_api_body(self, content, title=None): "message": content, } - def send(self, content, title=None): - api_body = self.build_api_body(content, title) - with httpx.Client() as client: - response = client.post(self.api_url, data=api_body, headers=self.headers) - response.raise_for_status() - validate_business_response(response, "pushover", {"status": {1}}) - logger.debug("`pushover` send successfully") - - async def send_async(self, content, title=None): - api_body = self.build_api_body(content, title) - async with httpx.AsyncClient() as client: - response = await client.post(self.api_url, data=api_body, headers=self.headers) - response.raise_for_status() - validate_business_response(response, "pushover", {"status": {1}}) - logger.debug("`pushover` send successfully") + def build_request_payload(self, content, title=None): + return self.build_api_body(content, title) diff --git a/src/use_notify/channels/wechat.py b/src/use_notify/channels/wechat.py index 9e4cc06..67ae020 100644 --- a/src/use_notify/channels/wechat.py +++ b/src/use_notify/channels/wechat.py @@ -1,17 +1,15 @@ # -*- coding: utf-8 -*- -import logging +from .http import HttpChannel -import httpx -from .base import BaseChannel -from .utils import validate_business_response - -logger = logging.getLogger(__name__) - - -class WeChat(BaseChannel): +class WeChat(HttpChannel): """企业微信消息通知""" + payload_kind = "json" + provider_name = "wechat" + success_fields = {"errcode": {0}} + success_log_message = "`WeChat` send successfully" + @property def api_url(self): return ( @@ -35,18 +33,5 @@ def build_api_body(self, title, content): return api_body - def send(self, content, title=None): - api_body = self.build_api_body(title, content) - with httpx.Client() as client: - response = client.post(self.api_url, json=api_body, headers=self.headers) - response.raise_for_status() - validate_business_response(response, "wechat", {"errcode": {0}}) - logger.debug("`WeChat` send successfully") - - async def send_async(self, content, title=None): - api_body = self.build_api_body(title, content) - async with httpx.AsyncClient() as client: - response = await client.post(self.api_url, json=api_body, headers=self.headers) - response.raise_for_status() - validate_business_response(response, "wechat", {"errcode": {0}}) - logger.debug("`WeChat` send successfully") + def build_request_payload(self, content, title=None): + return self.build_api_body(title, content) diff --git a/tests/test_channels.py b/tests/test_channels.py index 02aec41..8cdc1a4 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -3,6 +3,7 @@ import pytest from use_notify import useNotifyChannel +from use_notify.channels.http import HttpChannel from use_notify.channels.utils import ProviderResponseError, validate_business_response @@ -31,6 +32,58 @@ def _credential_provider(*values): return lambda: next(values_iter) +class UnsupportedMethodChannel(HttpChannel): + request_method = "PATCH" + + @property + def api_url(self): + return "https://example.com" + + @property + def headers(self): + return {} + + def build_request_payload(self, content, title=None): + return {"message": content} + + +class UnsupportedPayloadChannel(HttpChannel): + payload_kind = "body" + + @property + def api_url(self): + return "https://example.com" + + @property + def headers(self): + return {} + + def build_request_payload(self, content, title=None): + return {"message": content} + + +def test_http_channel_rejects_unsupported_method(): + channel = UnsupportedMethodChannel({}) + + with pytest.raises(ValueError, match="Unsupported HTTP method"): + channel.send("hello") + + +def test_http_channel_rejects_unsupported_payload_kind(): + channel = UnsupportedPayloadChannel({}) + + with pytest.raises(ValueError, match="Unsupported HTTP payload kind"): + channel.send("hello") + + +@pytest.mark.asyncio +async def test_http_channel_async_rejects_unsupported_method(): + channel = UnsupportedMethodChannel({}) + + with pytest.raises(ValueError, match="Unsupported HTTP method"): + await channel.send_async("hello") + + def test_validate_business_response_ignores_non_dict_json_payloads(): response = _mock_sync_http_response(["ok"])