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.
- myappnotify-0.1.0/PKG-INFO +38 -0
- myappnotify-0.1.0/README.md +30 -0
- myappnotify-0.1.0/myappnotify/__init__.py +37 -0
- myappnotify-0.1.0/myappnotify/dingtalk.py +61 -0
- myappnotify-0.1.0/myappnotify/gotify.py +57 -0
- myappnotify-0.1.0/myappnotify.egg-info/PKG-INFO +38 -0
- myappnotify-0.1.0/myappnotify.egg-info/SOURCES.txt +10 -0
- myappnotify-0.1.0/myappnotify.egg-info/dependency_links.txt +1 -0
- myappnotify-0.1.0/myappnotify.egg-info/top_level.txt +1 -0
- myappnotify-0.1.0/pyproject.toml +15 -0
- myappnotify-0.1.0/setup.cfg +4 -0
- myappnotify-0.1.0/tests/test_notify.py +133 -0
|
@@ -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={}×tamp={}&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
|
+
|
|
@@ -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,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()
|