myappnotify 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: myappnotify
3
+ Version: 0.1.0
4
+ Summary: DingTalk + Gotify markdown notification helpers
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+
9
+ # myappnotify
10
+
11
+ DingTalk robot + Gotify push helpers (stdlib only).
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install myappnotify
17
+ ```
18
+
19
+ ## Env
20
+
21
+ | Variable | Channel |
22
+ |----------|---------|
23
+ | `DINGDING_WEB_HOOK_TOKEN` / `DINGDING_BOT_SIGN` | DingTalk |
24
+ | `GOTIFY_URL` / `GOTIFY_TOKEN` | Gotify |
25
+
26
+ Missing config skips that channel and logs; never raises.
27
+
28
+ ## Usage
29
+
30
+ ```python
31
+ from myappnotify import send_dingtalk, send_gotify, notify
32
+
33
+ send_dingtalk("Title", "## body")
34
+ send_gotify("Title", "## body") # markdown via client::display
35
+
36
+ # Fan-out; optional Gotify-specific body (e.g. GFM tables)
37
+ notify("选股结果", dingtalk_md, gotify_text=gotify_table_md)
38
+ ```
@@ -0,0 +1,30 @@
1
+ # myappnotify
2
+
3
+ DingTalk robot + Gotify push helpers (stdlib only).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install myappnotify
9
+ ```
10
+
11
+ ## Env
12
+
13
+ | Variable | Channel |
14
+ |----------|---------|
15
+ | `DINGDING_WEB_HOOK_TOKEN` / `DINGDING_BOT_SIGN` | DingTalk |
16
+ | `GOTIFY_URL` / `GOTIFY_TOKEN` | Gotify |
17
+
18
+ Missing config skips that channel and logs; never raises.
19
+
20
+ ## Usage
21
+
22
+ ```python
23
+ from myappnotify import send_dingtalk, send_gotify, notify
24
+
25
+ send_dingtalk("Title", "## body")
26
+ send_gotify("Title", "## body") # markdown via client::display
27
+
28
+ # Fan-out; optional Gotify-specific body (e.g. GFM tables)
29
+ notify("选股结果", dingtalk_md, gotify_text=gotify_table_md)
30
+ ```
@@ -0,0 +1,37 @@
1
+ """myappnotify — DingTalk + Gotify markdown notifications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Iterable, Optional
7
+
8
+ from myappnotify.dingtalk import send_dingtalk
9
+ from myappnotify.gotify import send_gotify
10
+
11
+ __all__ = ["send_dingtalk", "send_gotify", "notify"]
12
+ __version__ = "0.1.0"
13
+
14
+ log = logging.getLogger("myappnotify")
15
+
16
+
17
+ def notify(
18
+ title: str,
19
+ text: str,
20
+ *,
21
+ gotify_text: Optional[str] = None,
22
+ channels: Iterable[str] = ("dingtalk", "gotify"),
23
+ ) -> dict:
24
+ """Send to configured channels. Missing credentials skip that channel.
25
+
26
+ If ``gotify_text`` is set, Gotify uses it while DingTalk still uses ``text``.
27
+ """
28
+ results = {}
29
+ for ch in channels:
30
+ if ch == "dingtalk":
31
+ results["dingtalk"] = send_dingtalk(title, text)
32
+ elif ch == "gotify":
33
+ results["gotify"] = send_gotify(title, gotify_text if gotify_text is not None else text)
34
+ else:
35
+ log.error("unknown notify channel: %s", ch)
36
+ results[ch] = False
37
+ return results
@@ -0,0 +1,61 @@
1
+ """DingTalk group robot markdown push."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import logging
10
+ import os
11
+ import time
12
+ import urllib.parse
13
+ import urllib.request
14
+ from typing import Optional
15
+
16
+ log = logging.getLogger("myappnotify.dingtalk")
17
+
18
+ DINGDING_WEBHOOK = "https://oapi.dingtalk.com/robot/send"
19
+
20
+
21
+ def send_dingtalk(
22
+ title: str,
23
+ text: str,
24
+ *,
25
+ token: Optional[str] = None,
26
+ secret: Optional[str] = None,
27
+ ) -> bool:
28
+ """Push markdown to DingTalk. Missing config or failure returns False, never raises."""
29
+ token = (token if token is not None else os.environ.get("DINGDING_WEB_HOOK_TOKEN", "")).strip()
30
+ secret = (secret if secret is not None else os.environ.get("DINGDING_BOT_SIGN", "")).strip()
31
+ if not token or not secret:
32
+ log.error("DingTalk not configured (DINGDING_WEB_HOOK_TOKEN/DINGDING_BOT_SIGN), skip")
33
+ return False
34
+
35
+ ts = str(round(time.time() * 1000))
36
+ string_to_sign = "{}\n{}".format(ts, secret)
37
+ hmac_code = hmac.new(
38
+ secret.encode("utf-8"), string_to_sign.encode("utf-8"), digestmod=hashlib.sha256
39
+ ).digest()
40
+ sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
41
+ url = "{}?access_token={}&timestamp={}&sign={}".format(
42
+ DINGDING_WEBHOOK, token, ts, sign
43
+ )
44
+ payload = json.dumps(
45
+ {"msgtype": "markdown", "markdown": {"title": title, "text": text}}
46
+ ).encode("utf-8")
47
+ try:
48
+ req = urllib.request.Request(
49
+ url,
50
+ data=payload,
51
+ headers={"Content-Type": "application/json", "User-Agent": "Mozilla/5.0"},
52
+ )
53
+ with urllib.request.urlopen(req, timeout=10) as resp:
54
+ result = json.loads(resp.read().decode("utf-8", "ignore"))
55
+ if result.get("errcode") == 0:
56
+ log.info("DingTalk push ok")
57
+ return True
58
+ log.error("DingTalk push failed: %s", result)
59
+ except Exception as e:
60
+ log.error("DingTalk push failed: %s", e)
61
+ return False
@@ -0,0 +1,57 @@
1
+ """Gotify application message push (markdown)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import urllib.request
9
+ from typing import Optional
10
+
11
+ log = logging.getLogger("myappnotify.gotify")
12
+
13
+
14
+ def send_gotify(
15
+ title: str,
16
+ text: str,
17
+ *,
18
+ url: Optional[str] = None,
19
+ token: Optional[str] = None,
20
+ priority: int = 5,
21
+ ) -> bool:
22
+ """Push markdown to Gotify. Missing config or failure returns False, never raises."""
23
+ base = (url if url is not None else os.environ.get("GOTIFY_URL", "")).strip().rstrip("/")
24
+ token = (token if token is not None else os.environ.get("GOTIFY_TOKEN", "")).strip()
25
+ if not base or not token:
26
+ log.error("Gotify not configured (GOTIFY_URL/GOTIFY_TOKEN), skip")
27
+ return False
28
+
29
+ endpoint = base + "/message"
30
+ payload = json.dumps(
31
+ {
32
+ "title": title,
33
+ "message": text,
34
+ "priority": priority,
35
+ "extras": {"client::display": {"contentType": "text/markdown"}},
36
+ }
37
+ ).encode("utf-8")
38
+ try:
39
+ req = urllib.request.Request(
40
+ endpoint,
41
+ data=payload,
42
+ headers={
43
+ "Content-Type": "application/json",
44
+ "X-Gotify-Key": token,
45
+ "User-Agent": "Mozilla/5.0",
46
+ },
47
+ )
48
+ with urllib.request.urlopen(req, timeout=10) as resp:
49
+ body = resp.read().decode("utf-8", "ignore")
50
+ status = getattr(resp, "status", None) or resp.getcode()
51
+ if 200 <= int(status) < 300:
52
+ log.info("Gotify push ok")
53
+ return True
54
+ log.error("Gotify push failed: status=%s body=%s", status, body)
55
+ except Exception as e:
56
+ log.error("Gotify push failed: %s", e)
57
+ return False
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: myappnotify
3
+ Version: 0.1.0
4
+ Summary: DingTalk + Gotify markdown notification helpers
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+
9
+ # myappnotify
10
+
11
+ DingTalk robot + Gotify push helpers (stdlib only).
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install myappnotify
17
+ ```
18
+
19
+ ## Env
20
+
21
+ | Variable | Channel |
22
+ |----------|---------|
23
+ | `DINGDING_WEB_HOOK_TOKEN` / `DINGDING_BOT_SIGN` | DingTalk |
24
+ | `GOTIFY_URL` / `GOTIFY_TOKEN` | Gotify |
25
+
26
+ Missing config skips that channel and logs; never raises.
27
+
28
+ ## Usage
29
+
30
+ ```python
31
+ from myappnotify import send_dingtalk, send_gotify, notify
32
+
33
+ send_dingtalk("Title", "## body")
34
+ send_gotify("Title", "## body") # markdown via client::display
35
+
36
+ # Fan-out; optional Gotify-specific body (e.g. GFM tables)
37
+ notify("选股结果", dingtalk_md, gotify_text=gotify_table_md)
38
+ ```
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ myappnotify/__init__.py
4
+ myappnotify/dingtalk.py
5
+ myappnotify/gotify.py
6
+ myappnotify.egg-info/PKG-INFO
7
+ myappnotify.egg-info/SOURCES.txt
8
+ myappnotify.egg-info/dependency_links.txt
9
+ myappnotify.egg-info/top_level.txt
10
+ tests/test_notify.py
@@ -0,0 +1 @@
1
+ myappnotify
@@ -0,0 +1,15 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "myappnotify"
7
+ version = "0.1.0"
8
+ description = "DingTalk + Gotify markdown notification helpers"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = []
13
+
14
+ [tool.setuptools.packages.find]
15
+ include = ["myappnotify*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,133 @@
1
+ """Unit tests for myappnotify (mocked HTTP)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import os
10
+ import unittest
11
+ import urllib.parse
12
+ from unittest import mock
13
+
14
+ from myappnotify import notify, send_dingtalk, send_gotify
15
+
16
+
17
+ class _FakeUrlResp:
18
+ def __init__(self, body: str, status: int = 200):
19
+ self._body = body.encode("utf-8")
20
+ self.status = status
21
+
22
+ def read(self):
23
+ return self._body
24
+
25
+ def getcode(self):
26
+ return self.status
27
+
28
+ def __enter__(self):
29
+ return self
30
+
31
+ def __exit__(self, *exc):
32
+ return False
33
+
34
+
35
+ class TestDingTalk(unittest.TestCase):
36
+ def _sign(self, ts, secret):
37
+ raw = hmac.new(
38
+ secret.encode("utf-8"),
39
+ "{}\n{}".format(ts, secret).encode("utf-8"),
40
+ digestmod=hashlib.sha256,
41
+ ).digest()
42
+ return urllib.parse.quote_plus(base64.b64encode(raw))
43
+
44
+ def test_skip_when_unconfigured(self):
45
+ with mock.patch.dict(os.environ, {
46
+ "DINGDING_WEB_HOOK_TOKEN": "",
47
+ "DINGDING_BOT_SIGN": "",
48
+ }, clear=False):
49
+ with mock.patch("myappnotify.dingtalk.urllib.request.urlopen") as urlopen:
50
+ self.assertFalse(send_dingtalk("t", "body"))
51
+ urlopen.assert_not_called()
52
+
53
+ def test_success_signs_and_posts_markdown(self):
54
+ token, secret = "tok_abc", "SEC_xyz"
55
+ frozen = 1_700_000_000.0
56
+ ts = str(round(frozen * 1000))
57
+ expected_sign = self._sign(ts, secret)
58
+ with mock.patch.dict(os.environ, {
59
+ "DINGDING_WEB_HOOK_TOKEN": token,
60
+ "DINGDING_BOT_SIGN": secret,
61
+ }, clear=False):
62
+ with mock.patch("myappnotify.dingtalk.time.time", return_value=frozen):
63
+ with mock.patch(
64
+ "myappnotify.dingtalk.urllib.request.urlopen",
65
+ return_value=_FakeUrlResp('{"errcode":0,"errmsg":"ok"}'),
66
+ ) as urlopen:
67
+ ok = send_dingtalk("持仓监控", "## 正文\n- 一条")
68
+ self.assertTrue(ok)
69
+ req = urlopen.call_args[0][0]
70
+ self.assertIn("access_token=" + token, req.full_url)
71
+ self.assertIn("timestamp=" + ts, req.full_url)
72
+ self.assertIn("sign=" + expected_sign, req.full_url)
73
+ body = json.loads(req.data.decode("utf-8"))
74
+ self.assertEqual(body["msgtype"], "markdown")
75
+ self.assertEqual(body["markdown"]["title"], "持仓监控")
76
+
77
+
78
+ class TestGotify(unittest.TestCase):
79
+ def test_skip_when_unconfigured(self):
80
+ with mock.patch.dict(os.environ, {
81
+ "GOTIFY_URL": "",
82
+ "GOTIFY_TOKEN": "",
83
+ }, clear=False):
84
+ with mock.patch("myappnotify.gotify.urllib.request.urlopen") as urlopen:
85
+ self.assertFalse(send_gotify("t", "body"))
86
+ urlopen.assert_not_called()
87
+
88
+ def test_success_posts_markdown_extras(self):
89
+ with mock.patch.dict(os.environ, {
90
+ "GOTIFY_URL": "https://gotify.example.com/",
91
+ "GOTIFY_TOKEN": "app-tok",
92
+ }, clear=False):
93
+ with mock.patch(
94
+ "myappnotify.gotify.urllib.request.urlopen",
95
+ return_value=_FakeUrlResp('{"id":1}', status=200),
96
+ ) as urlopen:
97
+ ok = send_gotify("选股结果", "## hello")
98
+ self.assertTrue(ok)
99
+ req = urlopen.call_args[0][0]
100
+ self.assertEqual(req.full_url, "https://gotify.example.com/message")
101
+ self.assertEqual(req.get_header("X-gotify-key"), "app-tok")
102
+ body = json.loads(req.data.decode("utf-8"))
103
+ self.assertEqual(body["title"], "选股结果")
104
+ self.assertEqual(body["message"], "## hello")
105
+ self.assertEqual(
106
+ body["extras"]["client::display"]["contentType"],
107
+ "text/markdown",
108
+ )
109
+
110
+ def test_network_error_returns_false(self):
111
+ with mock.patch.dict(os.environ, {
112
+ "GOTIFY_URL": "https://gotify.example.com",
113
+ "GOTIFY_TOKEN": "tok",
114
+ }, clear=False):
115
+ with mock.patch(
116
+ "myappnotify.gotify.urllib.request.urlopen",
117
+ side_effect=OSError("timed out"),
118
+ ):
119
+ self.assertFalse(send_gotify("t", "x"))
120
+
121
+
122
+ class TestNotify(unittest.TestCase):
123
+ def test_gotify_text_override(self):
124
+ with mock.patch("myappnotify.send_dingtalk", return_value=True) as dt:
125
+ with mock.patch("myappnotify.send_gotify", return_value=True) as gf:
126
+ out = notify("T", "ding-body", gotify_text="gotify-table")
127
+ self.assertEqual(out, {"dingtalk": True, "gotify": True})
128
+ dt.assert_called_once_with("T", "ding-body")
129
+ gf.assert_called_once_with("T", "gotify-table")
130
+
131
+
132
+ if __name__ == "__main__":
133
+ unittest.main()