cloudoll 2.4.0__py3-none-any.whl
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.
- cloudoll/__init__.py +1 -0
- cloudoll/__main__.py +4 -0
- cloudoll/cli.py +76 -0
- cloudoll/error/__init__.py +0 -0
- cloudoll/error/errors.py +48 -0
- cloudoll/logging/__init__.py +188 -0
- cloudoll/mail/__init__.py +0 -0
- cloudoll/mail/smtp.py +197 -0
- cloudoll/orm/__init__.py +252 -0
- cloudoll/orm/base.py +80 -0
- cloudoll/orm/err.py +2 -0
- cloudoll/orm/field.py +522 -0
- cloudoll/orm/model.py +806 -0
- cloudoll/orm/mysql.py +84 -0
- cloudoll/orm/parse.py +139 -0
- cloudoll/orm/postgres.py +70 -0
- cloudoll/robot/__init__.py +0 -0
- cloudoll/robot/dingtalk.py +209 -0
- cloudoll/robot/feishu.py +102 -0
- cloudoll/robot/slack.py +115 -0
- cloudoll/utils/__init__.py +0 -0
- cloudoll/utils/cli_main.py +72 -0
- cloudoll/utils/cli_mian.py +74 -0
- cloudoll/utils/common.py +57 -0
- cloudoll/utils/m2d.py +311 -0
- cloudoll/utils/patch_loop.py +222 -0
- cloudoll/utils/snowflake.py +86 -0
- cloudoll/utils/watch.py +194 -0
- cloudoll/web/__init__.py +53 -0
- cloudoll/web/core.py +557 -0
- cloudoll/web/exception.py +63 -0
- cloudoll/web/html.py +93 -0
- cloudoll/web/http.py +79 -0
- cloudoll/web/jwt.py +40 -0
- cloudoll/web/requests.py +161 -0
- cloudoll/web/settings.py +22 -0
- cloudoll-2.4.0.dist-info/METADATA +414 -0
- cloudoll-2.4.0.dist-info/RECORD +42 -0
- cloudoll-2.4.0.dist-info/WHEEL +6 -0
- cloudoll-2.4.0.dist-info/entry_points.txt +2 -0
- cloudoll-2.4.0.dist-info/licenses/LICENSE +21 -0
- cloudoll-2.4.0.dist-info/top_level.txt +1 -0
cloudoll/orm/mysql.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
from cloudoll.orm import Mysql
|
|
5
|
+
|
|
6
|
+
# for config folder conf.{env}.yaml
|
|
7
|
+
|
|
8
|
+
database:
|
|
9
|
+
mysql:
|
|
10
|
+
type: mysql # important ,default mysql
|
|
11
|
+
host: 127.0.0.1
|
|
12
|
+
port: 3306
|
|
13
|
+
user: root
|
|
14
|
+
password: 123456
|
|
15
|
+
db: test
|
|
16
|
+
charset:utf8mb4
|
|
17
|
+
pool_size:5
|
|
18
|
+
|
|
19
|
+
# to get yaml config
|
|
20
|
+
config = ....(database.mysql)
|
|
21
|
+
|
|
22
|
+
pool = await Mysql().create_engine(**config)
|
|
23
|
+
result = await pool.query("sql")
|
|
24
|
+
"""
|
|
25
|
+
__author__ = "chuchur/chuchur.com"
|
|
26
|
+
|
|
27
|
+
from typing import Any
|
|
28
|
+
from aiomysql import create_pool, DictCursor
|
|
29
|
+
from aiomysql.pool import Pool
|
|
30
|
+
|
|
31
|
+
# from aiomysql.cursors import Cursor
|
|
32
|
+
# from aiomysql.connection import Connection
|
|
33
|
+
from cloudoll.logging import print_error
|
|
34
|
+
|
|
35
|
+
# from inspect import isclass, isfunction
|
|
36
|
+
from .base import MeteBase
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AttrDict(dict):
|
|
40
|
+
"""Dict that can get attribute by dot, and doesn't raise KeyError"""
|
|
41
|
+
|
|
42
|
+
def __getattr__(self, name):
|
|
43
|
+
try:
|
|
44
|
+
return self[name]
|
|
45
|
+
except KeyError:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AttrDictCursor(DictCursor):
|
|
50
|
+
dict_type = AttrDict
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Mysql(MeteBase):
|
|
54
|
+
def __init__(self):
|
|
55
|
+
self.pool: Pool = None
|
|
56
|
+
# self.cursor: Cursor = None
|
|
57
|
+
# self.conn: Connection = None
|
|
58
|
+
# self.__MODELS__ = []
|
|
59
|
+
|
|
60
|
+
# def __call__(cls, *args: Any, **kwds: Any) -> Any:
|
|
61
|
+
# cls.__init__(*args, **kwds)
|
|
62
|
+
# return cls()
|
|
63
|
+
|
|
64
|
+
# @classmethod
|
|
65
|
+
async def create_engine(self, loop=None, **kw):
|
|
66
|
+
try:
|
|
67
|
+
self.pool = await create_pool(
|
|
68
|
+
host=kw.get("host", "localhost"),
|
|
69
|
+
port=kw.get("port", 3306),
|
|
70
|
+
user=kw.get("user"),
|
|
71
|
+
password=str(kw.get("password", "")),
|
|
72
|
+
db=kw.get("db"),
|
|
73
|
+
echo=kw.get("echo", False),
|
|
74
|
+
charset=kw.get("charset", "utf8"),
|
|
75
|
+
autocommit=False, # kw.get("autocommit", False),
|
|
76
|
+
maxsize=kw.get("maxsize", 10),
|
|
77
|
+
minsize=kw.get("pool_size", 5),
|
|
78
|
+
cursorclass=AttrDictCursor,
|
|
79
|
+
connect_timeout=kw.get("connect_timeout", 10),
|
|
80
|
+
loop=loop,
|
|
81
|
+
)
|
|
82
|
+
except Exception as e:
|
|
83
|
+
print_error(f"Database connection failed,the instance : {kw.get('db')}")
|
|
84
|
+
return self
|
cloudoll/orm/parse.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from urllib.parse import unquote
|
|
2
|
+
import re
|
|
3
|
+
from typing import Any, Optional, List, cast
|
|
4
|
+
import collections.abc as collections_abc
|
|
5
|
+
|
|
6
|
+
_implicit_encoding = "ascii"
|
|
7
|
+
_implicit_errors = "strict"
|
|
8
|
+
|
|
9
|
+
__ALL__ = "parse_coon"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_coon(url: str):
|
|
13
|
+
pattern = re.compile(
|
|
14
|
+
r"""
|
|
15
|
+
(?P<type>[\w\+]+)://
|
|
16
|
+
(?:
|
|
17
|
+
(?P<username>[^:/]*)
|
|
18
|
+
(?::(?P<password>[^@]*))?
|
|
19
|
+
@)?
|
|
20
|
+
(?:
|
|
21
|
+
(?:
|
|
22
|
+
\[(?P<ipv6host>[^/\?]+)\] |
|
|
23
|
+
(?P<ipv4host>[^/:\?]+)
|
|
24
|
+
)?
|
|
25
|
+
(?::(?P<port>[^/\?]*))?
|
|
26
|
+
)?
|
|
27
|
+
(?:/(?P<db>[^\?]*))?
|
|
28
|
+
(?:\?(?P<query>.*))?
|
|
29
|
+
""",
|
|
30
|
+
re.X,
|
|
31
|
+
)
|
|
32
|
+
match = pattern.match(url)
|
|
33
|
+
if match is not None:
|
|
34
|
+
configs = match.groupdict()
|
|
35
|
+
if configs["username"] is not None:
|
|
36
|
+
configs["username"] = unquote(configs["username"])
|
|
37
|
+
|
|
38
|
+
if configs["password"] is not None:
|
|
39
|
+
configs["password"] = unquote(configs["password"])
|
|
40
|
+
|
|
41
|
+
ipv4host = configs.pop("ipv4host")
|
|
42
|
+
ipv6host = configs.pop("ipv6host")
|
|
43
|
+
configs["host"] = ipv4host or ipv6host
|
|
44
|
+
# name = components.pop("name")
|
|
45
|
+
|
|
46
|
+
if configs["port"]:
|
|
47
|
+
configs["port"] = int(configs["port"])
|
|
48
|
+
|
|
49
|
+
query = {}
|
|
50
|
+
if configs["query"] is not None:
|
|
51
|
+
|
|
52
|
+
for key, value in parse_sql(configs["query"]):
|
|
53
|
+
if key in query:
|
|
54
|
+
query[key] = to_list(query[key])
|
|
55
|
+
cast("List[str]", query[key]).append(value)
|
|
56
|
+
else:
|
|
57
|
+
query[key] = value
|
|
58
|
+
|
|
59
|
+
return configs, query
|
|
60
|
+
else:
|
|
61
|
+
raise ValueError("%s is not a valid database URL" % url)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def to_list(x: Any, default: Optional[List[Any]] = None) -> List[Any]:
|
|
65
|
+
if x is None:
|
|
66
|
+
return default # type: ignore
|
|
67
|
+
if not isinstance(x, collections_abc.Iterable) or isinstance(x, (str, bytes)):
|
|
68
|
+
return [x]
|
|
69
|
+
elif isinstance(x, list):
|
|
70
|
+
return x
|
|
71
|
+
else:
|
|
72
|
+
return list(x)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_sql(
|
|
76
|
+
qs,
|
|
77
|
+
keep_blank_values=False,
|
|
78
|
+
strict_parsing=False,
|
|
79
|
+
encoding="utf-8",
|
|
80
|
+
errors="replace",
|
|
81
|
+
max_num_fields=None,
|
|
82
|
+
separator="&",
|
|
83
|
+
):
|
|
84
|
+
qs, _coerce_result = _coerce_args(qs)
|
|
85
|
+
separator, _ = _coerce_args(separator)
|
|
86
|
+
|
|
87
|
+
if not separator or (not isinstance(separator, (str, bytes))):
|
|
88
|
+
raise ValueError("Separator must be of type string or bytes.")
|
|
89
|
+
|
|
90
|
+
if max_num_fields is not None:
|
|
91
|
+
num_fields = 1 + qs.count(separator) if qs else 0
|
|
92
|
+
if max_num_fields < num_fields:
|
|
93
|
+
raise ValueError("Max number of fields exceeded")
|
|
94
|
+
|
|
95
|
+
r = []
|
|
96
|
+
query_args = qs.split(separator) if qs else []
|
|
97
|
+
for name_value in query_args:
|
|
98
|
+
if not name_value and not strict_parsing:
|
|
99
|
+
continue
|
|
100
|
+
nv = name_value.split("=", 1)
|
|
101
|
+
if len(nv) != 2:
|
|
102
|
+
if strict_parsing:
|
|
103
|
+
raise ValueError("bad query field: %r" % (name_value,))
|
|
104
|
+
# Handle case of a control-name with no equal sign
|
|
105
|
+
if keep_blank_values:
|
|
106
|
+
nv.append("")
|
|
107
|
+
else:
|
|
108
|
+
continue
|
|
109
|
+
if len(nv[1]) or keep_blank_values:
|
|
110
|
+
name = nv[0].replace("+", " ")
|
|
111
|
+
name = unquote(name, encoding=encoding, errors=errors)
|
|
112
|
+
name = _coerce_result(name)
|
|
113
|
+
value = nv[1].replace("+", " ")
|
|
114
|
+
value = unquote(value, encoding=encoding, errors=errors)
|
|
115
|
+
value = _coerce_result(value)
|
|
116
|
+
r.append((name, value))
|
|
117
|
+
return r
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _encode_result(obj, encoding=_implicit_encoding, errors=_implicit_errors):
|
|
121
|
+
return obj.encode(encoding, errors)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _noop(obj):
|
|
125
|
+
return obj
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _decode_args(args, encoding=_implicit_encoding, errors=_implicit_errors):
|
|
129
|
+
return tuple(x.decode(encoding, errors) if x else "" for x in args)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _coerce_args(*args):
|
|
133
|
+
str_input = isinstance(args[0], str)
|
|
134
|
+
for arg in args[1:]:
|
|
135
|
+
if arg and isinstance(arg, str) != str_input:
|
|
136
|
+
raise TypeError("Cannot mix str and non-str arguments")
|
|
137
|
+
if str_input:
|
|
138
|
+
return args + (_noop,)
|
|
139
|
+
return _decode_args(args) + (_encode_result,)
|
cloudoll/orm/postgres.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
from cloudoll.orm.postgres import Postgres
|
|
5
|
+
|
|
6
|
+
# for config folder conf.{env}.yaml
|
|
7
|
+
|
|
8
|
+
database:
|
|
9
|
+
pg:
|
|
10
|
+
type: postgres # important ,default mysql
|
|
11
|
+
host: 127.0.0.1
|
|
12
|
+
port: 3306
|
|
13
|
+
user: root
|
|
14
|
+
password: 123456
|
|
15
|
+
db: test
|
|
16
|
+
echo: True
|
|
17
|
+
timeout: 60
|
|
18
|
+
pool_size:5
|
|
19
|
+
|
|
20
|
+
# to get yaml config
|
|
21
|
+
config = ....(database.postgres)
|
|
22
|
+
|
|
23
|
+
pool = await Postgres().create_engine(**config)
|
|
24
|
+
result = await pool.query("sql")
|
|
25
|
+
"""
|
|
26
|
+
__author__ = "chuchur/chuchur.com"
|
|
27
|
+
|
|
28
|
+
from aiopg import create_pool, Pool, Cursor, Connection
|
|
29
|
+
from ..logging import print_error
|
|
30
|
+
from .base import MeteBase
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Postgres(MeteBase):
|
|
35
|
+
def __init__(self):
|
|
36
|
+
self.pool: Pool = None
|
|
37
|
+
self.cursor: Cursor = None
|
|
38
|
+
self.conn: Connection = None
|
|
39
|
+
# self.__MODELS__ = []
|
|
40
|
+
|
|
41
|
+
def __call__(cls, *args: Any, **kwds: Any) -> Any:
|
|
42
|
+
cls.__init__(*args, **kwds)
|
|
43
|
+
return cls()
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
async def create_engine(self, **kw):
|
|
47
|
+
try:
|
|
48
|
+
host = (kw.get("host", "localhost"),)
|
|
49
|
+
port = (kw.get("port", 5432),)
|
|
50
|
+
user = (kw.get("user"),)
|
|
51
|
+
password = (str(kw.get("password", "")),)
|
|
52
|
+
db = (kw.get("db"),)
|
|
53
|
+
dsn = f"dbname={db[0]} user={user[0]} password={password[0]} host={host[0]} port={port[0]}"
|
|
54
|
+
self.pool = await create_pool(
|
|
55
|
+
dsn=dsn,
|
|
56
|
+
timeout=kw.get("timeout", 10.0),
|
|
57
|
+
enable_json=kw.get("enable_json"),
|
|
58
|
+
enable_hstore=kw.get("enable_hstore"),
|
|
59
|
+
enable_uuid=kw.get("enable_uuid"),
|
|
60
|
+
enable_uuid=kw.get("enable_uuid"),
|
|
61
|
+
pool_recycle=kw.get("pool_recycle", 10),
|
|
62
|
+
echo=kw.get("echo", False),
|
|
63
|
+
minsize=kw.get("pool_size", 5),
|
|
64
|
+
maxsize=kw.get("pool_size", 10),
|
|
65
|
+
)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
# print_error(e)
|
|
68
|
+
print_error(f"Database connection failed,the instance : {kw.get('db')}")
|
|
69
|
+
|
|
70
|
+
return self
|
|
File without changes
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
# docs: https://open.dingtalk.com/document/group/message-types-and-data-format
|
|
5
|
+
__author__ = "chuchur/chuchur.com"
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
from ..logging import print_error
|
|
11
|
+
from curl_cffi import requests as http
|
|
12
|
+
import time
|
|
13
|
+
from urllib import parse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Client(object):
|
|
17
|
+
def __init__(self, **kw):
|
|
18
|
+
self._webhook = kw.get("webhook")
|
|
19
|
+
self._secret = kw.get("secret")
|
|
20
|
+
self._access_token = kw.get("access_token")
|
|
21
|
+
|
|
22
|
+
def _get_sign(self):
|
|
23
|
+
secret = self._secret
|
|
24
|
+
if not secret:
|
|
25
|
+
print_error("缺少secret")
|
|
26
|
+
return None, None
|
|
27
|
+
|
|
28
|
+
# 拼接timestamp和secret
|
|
29
|
+
timestamp = str(round(time.time() * 1000))
|
|
30
|
+
string_to_sign = "{}\n{}".format(timestamp, secret)
|
|
31
|
+
hmac_code = hmac.new(
|
|
32
|
+
secret.encode("utf-8"),
|
|
33
|
+
string_to_sign.encode("utf-8"),
|
|
34
|
+
digestmod=hashlib.sha256,
|
|
35
|
+
).digest()
|
|
36
|
+
|
|
37
|
+
# 对结果进行base64处理
|
|
38
|
+
sign = base64.b64encode(hmac_code).decode("utf-8")
|
|
39
|
+
sign = parse.quote(sign)
|
|
40
|
+
return timestamp, sign
|
|
41
|
+
|
|
42
|
+
def upload(self, type: str, filepath: str):
|
|
43
|
+
"""
|
|
44
|
+
上传媒体
|
|
45
|
+
:params access_token
|
|
46
|
+
:params type 文件类型 image|voice|video|file
|
|
47
|
+
:params filepath 要上传的文件路径
|
|
48
|
+
"""
|
|
49
|
+
access_token = self._access_token
|
|
50
|
+
if not access_token:
|
|
51
|
+
print_error("请配置access_token")
|
|
52
|
+
return False
|
|
53
|
+
url = "https://oapi.dingtalk.com/media/upload?access_token=%s" % access_token
|
|
54
|
+
with open(filepath, "rb") as f:
|
|
55
|
+
files = {"media": f}
|
|
56
|
+
result = http.post(url=url, files=files, data={"type": type})
|
|
57
|
+
res = result.json()
|
|
58
|
+
if res["errcode"] == 0:
|
|
59
|
+
return res
|
|
60
|
+
else:
|
|
61
|
+
print_error(res["errmsg"])
|
|
62
|
+
|
|
63
|
+
def send(self, data: dict):
|
|
64
|
+
"""
|
|
65
|
+
发送除签名外的自定义消息结构体 https://open.dingtalk.com/document/orgapp-server/message-types-and-data-format
|
|
66
|
+
:params data 自定义消息结构体
|
|
67
|
+
"""
|
|
68
|
+
webhook = self._webhook
|
|
69
|
+
if not webhook:
|
|
70
|
+
print_error("缺少webhook")
|
|
71
|
+
return False
|
|
72
|
+
timestamp, sign = self._get_sign()
|
|
73
|
+
if not timestamp or not sign:
|
|
74
|
+
return False
|
|
75
|
+
url = webhook + "×tamp=%s&sign=%s" % (timestamp, sign)
|
|
76
|
+
result = http.post(
|
|
77
|
+
url=url,
|
|
78
|
+
json={**data},
|
|
79
|
+
# data=data
|
|
80
|
+
# data=json.dumps(data)
|
|
81
|
+
)
|
|
82
|
+
if result:
|
|
83
|
+
res = result.json()
|
|
84
|
+
if res["errcode"] == 0:
|
|
85
|
+
print_error("发送成功!")
|
|
86
|
+
return True
|
|
87
|
+
else:
|
|
88
|
+
print_error("发送失败")
|
|
89
|
+
print_error(res["errmsg"])
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
def send_text(self, text: str):
|
|
93
|
+
"""
|
|
94
|
+
发送文本消息
|
|
95
|
+
:params text 消息内容
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
return self.send(
|
|
99
|
+
data={
|
|
100
|
+
"msgtype": "text",
|
|
101
|
+
"text": {"content": text},
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _send_media(self, type, media_id, **kw):
|
|
106
|
+
"""
|
|
107
|
+
发送媒体
|
|
108
|
+
:params type 媒体类型 image|voice|video|file
|
|
109
|
+
:params media_id 媒体ID
|
|
110
|
+
"""
|
|
111
|
+
data = {"msgtype": type}
|
|
112
|
+
data[type] = {"media_id": media_id}
|
|
113
|
+
if type == "voice":
|
|
114
|
+
data["voice"]["duration"] = kw.get("duration")
|
|
115
|
+
return self.send(data)
|
|
116
|
+
|
|
117
|
+
def send_markdown(self, title, text):
|
|
118
|
+
"""
|
|
119
|
+
发送Markdown消息
|
|
120
|
+
:params title 标题
|
|
121
|
+
:params text markdown结构体
|
|
122
|
+
"""
|
|
123
|
+
return self.send(
|
|
124
|
+
data={"msgtype": "markdown", "markdown": {"title": title, "text": text}}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def send_image(self, media_id: str):
|
|
128
|
+
"""
|
|
129
|
+
发送图片消息
|
|
130
|
+
:params media_id 媒体ID
|
|
131
|
+
"""
|
|
132
|
+
return self._send_media("image", media_id)
|
|
133
|
+
|
|
134
|
+
def send_voice(self, media_id: str, duration: str):
|
|
135
|
+
"""
|
|
136
|
+
发送语音消息
|
|
137
|
+
:params media_id 媒体ID
|
|
138
|
+
:params duration 正整数,小于60,表示音频时长。
|
|
139
|
+
"""
|
|
140
|
+
return self._send_media("voice", media_id, duration=duration)
|
|
141
|
+
|
|
142
|
+
def send_file(self, media_id: str):
|
|
143
|
+
"""
|
|
144
|
+
发送文件消息
|
|
145
|
+
:params media_id 媒体ID
|
|
146
|
+
"""
|
|
147
|
+
return self._send_media("file", media_id)
|
|
148
|
+
|
|
149
|
+
def send_link(self, messageUrl, picUrl, title, text):
|
|
150
|
+
"""
|
|
151
|
+
发送链接消息
|
|
152
|
+
:params messageUrl 链接地址
|
|
153
|
+
:params picUrl 链接的小图
|
|
154
|
+
:params title 链接标题 100字内
|
|
155
|
+
:params text 链接副标题 500字内
|
|
156
|
+
"""
|
|
157
|
+
return self.send(
|
|
158
|
+
data={
|
|
159
|
+
"msgtype": "link",
|
|
160
|
+
"link": {
|
|
161
|
+
"picUrl": picUrl,
|
|
162
|
+
"messageUrl": messageUrl,
|
|
163
|
+
"title": title,
|
|
164
|
+
"text": text,
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def send_card(self, **kw):
|
|
170
|
+
"""
|
|
171
|
+
发送卡片消息
|
|
172
|
+
:params title 标题
|
|
173
|
+
:params text 消息内容 必填
|
|
174
|
+
:params single_title 查看详情(按钮文字) btns 二选一
|
|
175
|
+
:params single_url 查看详情(链接) btns 二选一
|
|
176
|
+
:params btn_orientation 按钮排列顺序。0|1
|
|
177
|
+
:params btns 按钮 [{title:'',actionURL:''}]
|
|
178
|
+
"""
|
|
179
|
+
title = kw.get("title", "")
|
|
180
|
+
text = kw.get("text", "")
|
|
181
|
+
single_title = kw.get("single_title", "")
|
|
182
|
+
single_url = kw.get("single_url", "")
|
|
183
|
+
btn_orientation = kw.get("btn_orientation", 0)
|
|
184
|
+
btns = kw.get("btns", []) or []
|
|
185
|
+
return self.send(
|
|
186
|
+
data={
|
|
187
|
+
"msgtype": "actionCard",
|
|
188
|
+
"actionCard": {
|
|
189
|
+
"title": title,
|
|
190
|
+
"text": text,
|
|
191
|
+
"singleTitle": single_title,
|
|
192
|
+
"singleUrl": single_url,
|
|
193
|
+
"btnOrientation": btn_orientation,
|
|
194
|
+
"btns": btns,
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def send_freecard(self, links: list):
|
|
200
|
+
"""
|
|
201
|
+
发送卡片消息
|
|
202
|
+
:params links [{ title:'',messageURL:'',picURL:''}]
|
|
203
|
+
"""
|
|
204
|
+
return self.send(
|
|
205
|
+
data={
|
|
206
|
+
"msgtype": "feedCard",
|
|
207
|
+
"feedCard": {"links": links},
|
|
208
|
+
}
|
|
209
|
+
)
|
cloudoll/robot/feishu.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
__author__ = "chuchur/chuchur.com"
|
|
5
|
+
|
|
6
|
+
import hashlib, hmac, time, base64
|
|
7
|
+
from curl_cffi import requests as http
|
|
8
|
+
from ..logging import print_error, print_info
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Client(object):
|
|
12
|
+
def __init__(self, **kw):
|
|
13
|
+
self._webhook = kw.get("webhook")
|
|
14
|
+
self._secret = kw.get("secret")
|
|
15
|
+
|
|
16
|
+
def _get_sign(self):
|
|
17
|
+
secret = self._secret
|
|
18
|
+
if not secret:
|
|
19
|
+
print_error("缺少secret")
|
|
20
|
+
return None, None
|
|
21
|
+
# 拼接timestamp和secret
|
|
22
|
+
timestamp = int(time.time())
|
|
23
|
+
string_to_sign = "{}\n{}".format(timestamp, secret)
|
|
24
|
+
hmac_code = hmac.new(
|
|
25
|
+
string_to_sign.encode("utf-8"), digestmod=hashlib.sha256
|
|
26
|
+
).digest()
|
|
27
|
+
|
|
28
|
+
# 对结果进行base64处理
|
|
29
|
+
sign = base64.b64encode(hmac_code).decode("utf-8")
|
|
30
|
+
|
|
31
|
+
return timestamp, sign
|
|
32
|
+
|
|
33
|
+
def send(self, data: dict):
|
|
34
|
+
"""
|
|
35
|
+
发送除签名外的自定义消息结构体
|
|
36
|
+
:params data 自定义消息结构体
|
|
37
|
+
"""
|
|
38
|
+
webhook = self._webhook
|
|
39
|
+
if not webhook:
|
|
40
|
+
print_error("缺少webhook")
|
|
41
|
+
return False
|
|
42
|
+
timestamp, sign = self._get_sign()
|
|
43
|
+
if not timestamp or not sign:
|
|
44
|
+
return False
|
|
45
|
+
result = http.post(
|
|
46
|
+
url=webhook,
|
|
47
|
+
json={"timestamp": timestamp, "sign": sign, **data},
|
|
48
|
+
)
|
|
49
|
+
if result:
|
|
50
|
+
res = result.json()
|
|
51
|
+
if res["StatusCode"] == 0:
|
|
52
|
+
print_info("发送成功")
|
|
53
|
+
return False
|
|
54
|
+
else:
|
|
55
|
+
print_error("发送失败!")
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
def send_text(self, text: str):
|
|
59
|
+
"""
|
|
60
|
+
发送文本消息
|
|
61
|
+
:params text 消息内容
|
|
62
|
+
"""
|
|
63
|
+
return self.send(
|
|
64
|
+
data={
|
|
65
|
+
"msg_type": "text",
|
|
66
|
+
"content": {"text": text},
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def send_card(self, **kw):
|
|
71
|
+
"""
|
|
72
|
+
发送卡片消息
|
|
73
|
+
:params content 卡片内容,str
|
|
74
|
+
:params elements 参见 :https://open.feishu.cn/document/ukTMukTMukTM/uMjNwUjLzYDM14yM2ATN
|
|
75
|
+
:params template 标题背景颜色 blue|wathet|truquoise|green|yellow|orange|red|carmine|violet|purple|indigo|grey
|
|
76
|
+
:params i18n 多语言 {'zh_cn':'...'}
|
|
77
|
+
"""
|
|
78
|
+
content = kw.get("content", "")
|
|
79
|
+
i18n = kw.get("i18n", {})
|
|
80
|
+
elements = kw.get("elements", [])
|
|
81
|
+
template = kw.get("template", "blue")
|
|
82
|
+
return self.send(
|
|
83
|
+
data={
|
|
84
|
+
"msg_type": "interactive",
|
|
85
|
+
"card": {
|
|
86
|
+
"config": {
|
|
87
|
+
"wide_screen_mode": True,
|
|
88
|
+
"enable_forward": True,
|
|
89
|
+
"update_multi": True,
|
|
90
|
+
},
|
|
91
|
+
"header": {
|
|
92
|
+
"title": {
|
|
93
|
+
"tag": "plain_text",
|
|
94
|
+
"content": content,
|
|
95
|
+
"i18n": i18n,
|
|
96
|
+
},
|
|
97
|
+
"template": template,
|
|
98
|
+
},
|
|
99
|
+
"elements": elements,
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
)
|