toDus-API 1.3.3__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.
- todus/__init__.py +52 -0
- todus/client/__init__.py +277 -0
- todus/client/auth.py +89 -0
- todus/client/base.py +183 -0
- todus/client/file.py +190 -0
- todus/client/message.py +202 -0
- todus/client/profile.py +56 -0
- todus/constants.py +10 -0
- todus/errors.py +41 -0
- todus/group.py +370 -0
- todus/parser.py +415 -0
- todus/stanza.py +54 -0
- todus/stanzas/__init__.py +1 -0
- todus/stanzas/group.py +175 -0
- todus/stanzas/presence.py +37 -0
- todus/stanzas/private.py +203 -0
- todus/stanzas/utils.py +122 -0
- todus/types.py +54 -0
- todus/util.py +177 -0
- todus_api-1.3.3.dist-info/METADATA +299 -0
- todus_api-1.3.3.dist-info/RECORD +24 -0
- todus_api-1.3.3.dist-info/WHEEL +5 -0
- todus_api-1.3.3.dist-info/licenses/LICENSE +21 -0
- todus_api-1.3.3.dist-info/top_level.txt +1 -0
todus/stanzas/private.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Generadores de stanzas XML para chat privado en ToDus."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from .. import util
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _generate_msg_id() -> str:
|
|
8
|
+
"""Genera msg_id en formato hex 32 chars como usa ToDus oficial."""
|
|
9
|
+
return hashlib.md5(util.generate_token(16).encode()).hexdigest()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def message(to: str, body: str, msg_id: str = "", msg_type: str = "c") -> str:
|
|
13
|
+
"""Stanza <m> de ToDus para chat privado."""
|
|
14
|
+
mid = msg_id or _generate_msg_id()
|
|
15
|
+
body_esc = util.escape_xml(body)
|
|
16
|
+
return (
|
|
17
|
+
f"<m to='{to}' t='{msg_type}' i='{mid}' xmlns='jc'>"
|
|
18
|
+
f"<k xmlns='x8'/>"
|
|
19
|
+
f"<b>{body_esc}</b>"
|
|
20
|
+
f"</m>"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def edit_message(to: str, new_body: str, original_msg_id: str, edit_id: str = "") -> str:
|
|
25
|
+
"""Edita un mensaje existente en chat privado."""
|
|
26
|
+
eid = edit_id or _generate_msg_id()
|
|
27
|
+
body_esc = util.escape_xml(new_body)
|
|
28
|
+
return (
|
|
29
|
+
f"<m to='{to}' t='c' i='{original_msg_id}' xmlns='jc'>"
|
|
30
|
+
f"<edited xmlns='edited:n' i='{eid}' mi='{original_msg_id}'/>"
|
|
31
|
+
f"<k xmlns='x8'/>"
|
|
32
|
+
f"<b>{body_esc}</b>"
|
|
33
|
+
f"</m>"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def file_message(to: str, url: str, file_type: int, caption: str = "", msg_id: str = "", msg_type: str = "c", file_name: str = "", file_size: int = 0) -> str:
|
|
38
|
+
"""Stanza con archivo adjunto para chat privado."""
|
|
39
|
+
mid = msg_id or _generate_msg_id()
|
|
40
|
+
fid = _generate_msg_id()
|
|
41
|
+
cap_esc = util.escape_xml(caption)
|
|
42
|
+
name_esc = util.escape_xml(file_name)
|
|
43
|
+
return (
|
|
44
|
+
f"<m to='{to}' t='{msg_type}' i='{mid}' xmlns='jc'>"
|
|
45
|
+
f"<k xmlns='x8'/>"
|
|
46
|
+
f"<b>{cap_esc}</b>"
|
|
47
|
+
f"<file xmlns='file:n' i='{fid}' mi='{mid}' n='{name_esc}' url='{util.escape_xml(url)}' s='{file_size}' h=''/>"
|
|
48
|
+
f"</m>"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def image_message(to: str, url: str, file_name: str, file_size: int, width: int = 0, height: int = 0, thumbnail: str = "", caption: str = "", msg_id: str = "", msg_type: str = "c") -> str:
|
|
53
|
+
"""Stanza con imagen adjunta para chat privado."""
|
|
54
|
+
mid = msg_id or _generate_msg_id()
|
|
55
|
+
fid = _generate_msg_id()
|
|
56
|
+
cap_esc = util.escape_xml(caption)
|
|
57
|
+
name_esc = util.escape_xml(file_name)
|
|
58
|
+
url_esc = util.escape_xml(url)
|
|
59
|
+
|
|
60
|
+
tnail = thumbnail if thumbnail else "U6688O?Hr=xu^-w2sp-;,^VZnm-;_3xHMyt5"
|
|
61
|
+
|
|
62
|
+
wh_attrs = ""
|
|
63
|
+
if width > 0:
|
|
64
|
+
wh_attrs += f" w='{width}'"
|
|
65
|
+
if height > 0:
|
|
66
|
+
wh_attrs += f" he='{height}'"
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
f"<m to='{to}' t='{msg_type}' i='{mid}' xmlns='jc'>"
|
|
70
|
+
f"<k xmlns='x8'/>"
|
|
71
|
+
f"<image xmlns='image:n' i='{fid}' mi='{mid}' url='{url_esc}' n='{name_esc}' s='{file_size}' h=''{wh_attrs} tnail='{tnail}'/>"
|
|
72
|
+
f"<b>{cap_esc}</b>"
|
|
73
|
+
f"</m>"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def image_message_simple(to: str, url: str, file_name: str, file_size: int, msg_id: str = "", msg_type: str = "c") -> str:
|
|
78
|
+
"""Versión simple sin metadata para chat privado."""
|
|
79
|
+
mid = msg_id or _generate_msg_id()
|
|
80
|
+
fid = _generate_msg_id()
|
|
81
|
+
name_esc = util.escape_xml(file_name)
|
|
82
|
+
url_esc = util.escape_xml(url)
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
f"<m to='{to}' t='{msg_type}' i='{mid}' xmlns='jc'>"
|
|
86
|
+
f"<k xmlns='x8'/>"
|
|
87
|
+
f"<image xmlns='image:n' i='{fid}' mi='{mid}' url='{url_esc}' n='{name_esc}' s='{file_size}' h=''/>"
|
|
88
|
+
f"<b/>"
|
|
89
|
+
f"</m>"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def button_message(to: str, text: str, buttons: list[dict], msg_id: str = "", msg_type: str = "c") -> str:
|
|
94
|
+
"""Stanza con botones interactivos."""
|
|
95
|
+
mid = msg_id or _generate_msg_id()
|
|
96
|
+
text_esc = util.escape_xml(text)
|
|
97
|
+
|
|
98
|
+
buttons_xml = ""
|
|
99
|
+
for btn in buttons:
|
|
100
|
+
btn_text = util.escape_xml(btn.get("text", ""))
|
|
101
|
+
btn_cmd = btn.get("command", "cmd_type_send")
|
|
102
|
+
btn_msg = util.escape_xml(btn.get("data", ""))
|
|
103
|
+
btn_size = btn.get("size", "0.82")
|
|
104
|
+
buttons_xml += f"<button xmlns='button:n' btn_t='{btn_text}' btn_cmd='{btn_cmd}' btn_msg_c='{btn_msg}' btn_size='{btn_size}'/>"
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
f"<m to='{to}' t='{msg_type}' i='{mid}' xmlns='jc'>"
|
|
108
|
+
f"<k xmlns='x8'/>"
|
|
109
|
+
f"<b>{text_esc}</b>"
|
|
110
|
+
f"{buttons_xml}"
|
|
111
|
+
f"</m>"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def contact_message(to: str, contact_id: str, contact_name: str, contact_phone: str, msg_id: str = "", msg_type: str = "c") -> str:
|
|
116
|
+
"""Stanza con contacto adjunto."""
|
|
117
|
+
mid = msg_id or _generate_msg_id()
|
|
118
|
+
name_esc = util.escape_xml(contact_name)
|
|
119
|
+
return (
|
|
120
|
+
f"<m to='{to}' t='{msg_type}' i='{mid}' xmlns='jc'>"
|
|
121
|
+
f"<k xmlns='x8'/>"
|
|
122
|
+
f"<contact xmlns='contact:n' i='{contact_id}' mi='{mid}' n='{name_esc}' num='{contact_phone}'/>"
|
|
123
|
+
f"<b/>"
|
|
124
|
+
f"</m>"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def sticker_message(to: str, sticker_id: str, sticker_name: str, sticker_pack: str, sticker_hash: str, msg_id: str = "", msg_type: str = "c") -> str:
|
|
129
|
+
"""Stanza con sticker adjunto."""
|
|
130
|
+
mid = msg_id or _generate_msg_id()
|
|
131
|
+
name_esc = util.escape_xml(sticker_name)
|
|
132
|
+
pack_esc = util.escape_xml(sticker_pack)
|
|
133
|
+
return (
|
|
134
|
+
f"<m to='{to}' t='{msg_type}' i='{mid}' xmlns='jc'>"
|
|
135
|
+
f"<k xmlns='x8'/>"
|
|
136
|
+
f"<sticker xmlns='sticker:n' i='{sticker_id}' mi='{mid}' n='{name_esc}' f='{pack_esc}' url='' s='0' h='{sticker_hash}' json=''/>"
|
|
137
|
+
f"<b/>"
|
|
138
|
+
f"</m>"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def video_message(to: str, url: str, video_id: str, file_name: str, file_size: int, duration: int, width: int, height: int, thumbnail: str, msg_id: str = "", msg_type: str = "c", info_text: str = "") -> str:
|
|
143
|
+
"""Stanza con video adjunto."""
|
|
144
|
+
mid = msg_id or _generate_msg_id()
|
|
145
|
+
name_esc = util.escape_xml(file_name)
|
|
146
|
+
url_esc = util.escape_xml(url)
|
|
147
|
+
|
|
148
|
+
body_tag = "<b/>" if not info_text else f"<b>{util.escape_xml(info_text)}</b>"
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
f"<m xml:lang='en' o='{to}' t='{msg_type}' i='{mid}'>"
|
|
152
|
+
f"<k xmlns='x8'/>"
|
|
153
|
+
f"<video xmlns='video:n' i='{video_id}' mi='{mid}' url='{url_esc}' s='{file_size}' h='' d='{duration}' n='{name_esc}' w='{width}' he='{height}' tnail='{thumbnail}'/>"
|
|
154
|
+
f"{body_tag}"
|
|
155
|
+
f"</m>"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def delete_message(to: str, message_id: str, msg_id: str = "", msg_type: str = "c", body: str = "", media_xml: str = "") -> str:
|
|
160
|
+
"""Eliminar mensaje."""
|
|
161
|
+
mid = msg_id or message_id
|
|
162
|
+
did = _generate_msg_id()
|
|
163
|
+
body_xml = f"<b>{util.escape_xml(body)}</b>" if body or not media_xml else "<b/>"
|
|
164
|
+
return (
|
|
165
|
+
f"<m to='{to}' t='{msg_type}' i='{mid}' xmlns='jc'>"
|
|
166
|
+
f"<k xmlns='x8'/>"
|
|
167
|
+
f"{media_xml}"
|
|
168
|
+
f"<deleted xmlns='deleted:n' i='{did}' mi='{message_id}'/>"
|
|
169
|
+
f"{body_xml}"
|
|
170
|
+
f"</m>"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def location_message(to: str, lat: float, lon: float, zoom: float = 11.0, text: str = "", msg_id: str = "") -> str:
|
|
175
|
+
"""Stanza con ubicación adjunta para chat privado."""
|
|
176
|
+
mid = msg_id or _generate_msg_id()
|
|
177
|
+
lid = _generate_msg_id()
|
|
178
|
+
text_esc = util.escape_xml(text)
|
|
179
|
+
return (
|
|
180
|
+
f"<m to='{to}' t='c' i='{mid}' xmlns='jc'>"
|
|
181
|
+
f"<k xmlns='x8'/>"
|
|
182
|
+
f"<location xmlns='location:n' i='{lid}' mi='{mid}' lat='{lat}' lon='{lon}' z='{zoom}' t='{text_esc}'/>"
|
|
183
|
+
f"<b/>"
|
|
184
|
+
f"</m>"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def event_message(to: str, event_id: str, title: str, start: int, end: int, all_day: bool, ics_data: str, msg_id: str = "") -> str:
|
|
189
|
+
"""Stanza con evento/calendario adjunto para chat privado."""
|
|
190
|
+
mid = msg_id or _generate_msg_id()
|
|
191
|
+
eid = event_id or _generate_msg_id()
|
|
192
|
+
ad_str = "true" if all_day else "false"
|
|
193
|
+
title_esc = util.escape_xml(title)
|
|
194
|
+
ics_esc = util.escape_xml(ics_data)
|
|
195
|
+
return (
|
|
196
|
+
f"<m to='{to}' t='c' i='{mid}' xmlns='jc'>"
|
|
197
|
+
f"<k xmlns='x8'/>"
|
|
198
|
+
f"<event xmlns='event:n' i='{eid}' mi='{mid}' ti='{title_esc}' s='{start}' e='{end}' ad='{ad_str}'>"
|
|
199
|
+
f"<ics>{ics_esc}</ics>"
|
|
200
|
+
f"</event>"
|
|
201
|
+
f"<b/>"
|
|
202
|
+
f"</m>"
|
|
203
|
+
)
|
todus/stanzas/utils.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Stanzas XML de utilidad y de protocolo XMPP para ToDus."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from .. import util
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _generate_msg_id() -> str:
|
|
8
|
+
"""Genera msg_id en formato hex 32 chars como usa ToDus oficial."""
|
|
9
|
+
return hashlib.md5(util.generate_token(16).encode()).hexdigest()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def iq(type_: str, iq_id: str, payload: str = "", to: str = "") -> str:
|
|
13
|
+
"""Stanza IQ genérica."""
|
|
14
|
+
to_attr = f" to='{to}'" if to else ""
|
|
15
|
+
return f"<iq i='{iq_id}' t='{type_}'{to_attr}>{payload}</iq>"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def ping(ping_id: str) -> str:
|
|
19
|
+
"""XMPP ping (urn:xmpp:ping)."""
|
|
20
|
+
return f"<iq i='{ping_id}' t='get'><ping xmlns='urn:xmpp:ping'/></iq>"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def chat_state(to: str, state: str, msg_id: str = "", msg_type: str = "c") -> str:
|
|
24
|
+
"""Notificación de estado de chat para ToDus."""
|
|
25
|
+
mid = msg_id or _generate_msg_id()
|
|
26
|
+
tag = "csp" if state == "composing" else "csc"
|
|
27
|
+
return (
|
|
28
|
+
f"<m to='{to}' t='{msg_type}' i='{mid}' xmlns='jc'>"
|
|
29
|
+
f"<{tag} xmlns='uc1'/>"
|
|
30
|
+
f"</m>"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def receipt(to: str, msg_id: str, receipt_id: str = "", msg_type: str = "c") -> str:
|
|
35
|
+
"""Delivery receipt para ToDus."""
|
|
36
|
+
rid = receipt_id or _generate_msg_id()
|
|
37
|
+
return (
|
|
38
|
+
f"<m to='{to}' t='{msg_type}' i='{rid}' xmlns='jc'>"
|
|
39
|
+
f"<dd xmlns='x8' i='{msg_id}'/>"
|
|
40
|
+
f"</m>"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def read_receipt(to: str, msg_id: str, receipt_id: str = "", msg_type: str = "c") -> str:
|
|
45
|
+
"""Read receipt para ToDus."""
|
|
46
|
+
rid = receipt_id or _generate_msg_id()
|
|
47
|
+
return (
|
|
48
|
+
f"<m to='{to}' t='{msg_type}' i='{rid}' xmlns='jc'>"
|
|
49
|
+
f"<rd xmlns='x8' i='{msg_id}'/>"
|
|
50
|
+
f"</m>"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ack(msg_id: str, to: str = "") -> str:
|
|
55
|
+
"""ACK de mensaje recibido (tdack)."""
|
|
56
|
+
return f"<tdack xmlns='x8' mi='{msg_id}'/>"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def keepalive() -> str:
|
|
60
|
+
"""Keepalive: espacio en blanco."""
|
|
61
|
+
return " "
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def stream_open(host: str = "im.todus.cu") -> str:
|
|
65
|
+
"""Stream header inicial."""
|
|
66
|
+
return f"<stream:stream xmlns='jc' o='{host}' xmlns:stream='x1' v='1.0'>"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def stream_restart(host: str = "im.todus.cu") -> str:
|
|
70
|
+
"""Stream header post-auth."""
|
|
71
|
+
return f"<stream:stream xmlns='jc' o='{host}' xmlns:stream='x1' v='1.0'>"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def stream_close() -> str:
|
|
75
|
+
"""Cierre graceful del stream."""
|
|
76
|
+
return "</stream:stream>"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def sasl_auth(authstr: bytes) -> str:
|
|
80
|
+
"""SASL PLAIN auth."""
|
|
81
|
+
return b"<ah xmlns='ah:ns' e='PLAIN'>" + authstr + b"</ah>"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def bind(iq_id: str) -> str:
|
|
85
|
+
"""Resource bind."""
|
|
86
|
+
return f"<iq i='{iq_id}' t='set'><b1 xmlns='x4'></b1></iq>"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def mam_query(query_id: str, since: str = "", before: str = "", limit: int = 50) -> str:
|
|
90
|
+
"""Query de Message Archive Management (MAM)."""
|
|
91
|
+
filters = ""
|
|
92
|
+
if since:
|
|
93
|
+
filters += f"<start>{since}</start>"
|
|
94
|
+
if before:
|
|
95
|
+
filters += f"<end>{before}</end>"
|
|
96
|
+
return (
|
|
97
|
+
f"<iq i='{query_id}' t='set'>"
|
|
98
|
+
f"<query xmlns='todus:mam'>{filters}"
|
|
99
|
+
f"<set xmlns='http://jabber.org/protocol/rsm'><max>{limit}</max></set>"
|
|
100
|
+
f"</query></iq>"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def upload_query(iq_id: str, size: int, file_type: int, persistent: bool = False, file_name: str = "") -> str:
|
|
105
|
+
"""Reserva URL de subida."""
|
|
106
|
+
persist = "true" if persistent else "false"
|
|
107
|
+
n_attr = f" n='{util.escape_xml(file_name)}'" if file_name else ""
|
|
108
|
+
return (
|
|
109
|
+
f"<iq i='{iq_id}-3' t='get'>"
|
|
110
|
+
f"<query xmlns='todus:purl' type='{file_type}' "
|
|
111
|
+
f"persistent='{persist}' size='{size}' room=''{n_attr}></query>"
|
|
112
|
+
f"</iq>"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def download_query(iq_id: str, url: str) -> str:
|
|
117
|
+
"""Resuelve URL real de descarga."""
|
|
118
|
+
return (
|
|
119
|
+
f"<iq i='{iq_id}-2' t='get'>"
|
|
120
|
+
f"<query xmlns='todus:gurl' url='{url}'></query>"
|
|
121
|
+
f"</iq>"
|
|
122
|
+
)
|
todus/types.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Tipos y enumeraciones de ToDus."""
|
|
2
|
+
|
|
3
|
+
from enum import IntEnum, StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FileType(IntEnum):
|
|
7
|
+
"""Tipos de archivo soportados por ToDus."""
|
|
8
|
+
FILE = 0
|
|
9
|
+
VOICE = 1
|
|
10
|
+
AUDIO = 2
|
|
11
|
+
VIDEO = 3
|
|
12
|
+
PICTURE = 4
|
|
13
|
+
PROFILE = 5
|
|
14
|
+
PROFILE_THUMBNAIL = 6
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ChatState(StrEnum):
|
|
18
|
+
"""Estados de chat XEP-0085."""
|
|
19
|
+
COMPOSING = "composing"
|
|
20
|
+
PAUSED = "paused"
|
|
21
|
+
ACTIVE = "active"
|
|
22
|
+
GONE = "gone"
|
|
23
|
+
INACTIVE = "inactive"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MessageType(StrEnum):
|
|
27
|
+
"""Tipos de mensaje XMPP."""
|
|
28
|
+
CHAT = "chat"
|
|
29
|
+
GROUPCHAT = "groupchat"
|
|
30
|
+
ERROR = "error"
|
|
31
|
+
HEADLINE = "headline"
|
|
32
|
+
NORMAL = "normal"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PresenceShow(StrEnum):
|
|
36
|
+
"""Estados de presencia XMPP."""
|
|
37
|
+
CHAT = "chat"
|
|
38
|
+
AWAY = "away"
|
|
39
|
+
XA = "xa"
|
|
40
|
+
DND = "dnd"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ButtonSize(StrEnum):
|
|
44
|
+
"""Tamaños de botón interactivo."""
|
|
45
|
+
FULL = "0.82"
|
|
46
|
+
HALF = "0.5"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ButtonCommand(StrEnum):
|
|
50
|
+
"""Tipos de comando para botones interactivos."""
|
|
51
|
+
SEND = "cmd_type_send"
|
|
52
|
+
URL = "cmd_type_url"
|
|
53
|
+
COPY = "cmd_type_copy"
|
|
54
|
+
CALL = "cmd_type_call"
|
todus/util.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Utilidades para ToDus."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import secrets
|
|
6
|
+
import string
|
|
7
|
+
from base64 import b64decode
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate_token(length: int = 8) -> str:
|
|
12
|
+
"""Genera un token alfanumérico aleatorio criptográficamente seguro."""
|
|
13
|
+
chars = string.ascii_letters + string.digits
|
|
14
|
+
return "".join(secrets.choice(chars) for _ in range(length))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def normalize_phone(phone_number: str) -> str:
|
|
18
|
+
"""Normaliza número cubano a formato 53XXXXXXXX."""
|
|
19
|
+
phone_number = "".join(phone_number.lstrip("+").split())
|
|
20
|
+
match = re.match(r"(53)?(\d{8})", phone_number)
|
|
21
|
+
if not match:
|
|
22
|
+
raise ValueError(f"Número inválido: {phone_number}")
|
|
23
|
+
return "53" + match.group(2)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_jid(phone_number: str) -> str:
|
|
27
|
+
"""Construye JID ToDus desde número de teléfono."""
|
|
28
|
+
return normalize_phone(phone_number) + "@im.todus.cu"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_jid(jid: str) -> tuple[str, str]:
|
|
32
|
+
"""Extrae (phone, resource) de un JID."""
|
|
33
|
+
parts = jid.split("/", 1)
|
|
34
|
+
phone = parts[0].split("@")[0]
|
|
35
|
+
resource = parts[1] if len(parts) > 1 else ""
|
|
36
|
+
return phone, resource
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def escape_xml(text: str) -> str:
|
|
40
|
+
"""Escapa caracteres XML especiales, incluyendo apóstrofos."""
|
|
41
|
+
return (
|
|
42
|
+
text.replace("&", "&")
|
|
43
|
+
.replace("<", "<")
|
|
44
|
+
.replace(">", ">")
|
|
45
|
+
.replace("'", "'")
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def unescape_xml(text: str) -> str:
|
|
50
|
+
"""Revierte escape XML."""
|
|
51
|
+
return (
|
|
52
|
+
text.replace("<", "<")
|
|
53
|
+
.replace(">", ">")
|
|
54
|
+
.replace("&", "&")
|
|
55
|
+
.replace("'", "'")
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def jwt_decode_payload(token: str) -> dict:
|
|
60
|
+
"""Decodifica payload de JWT sin verificar firma."""
|
|
61
|
+
parts = token.split(".")
|
|
62
|
+
if len(parts) < 2:
|
|
63
|
+
return {}
|
|
64
|
+
payload = parts[1]
|
|
65
|
+
padding = 4 - len(payload) % 4
|
|
66
|
+
if padding != 4:
|
|
67
|
+
payload += "=" * padding
|
|
68
|
+
try:
|
|
69
|
+
decoded = b64decode(payload).decode("utf-8", errors="ignore")
|
|
70
|
+
return json.loads(decoded)
|
|
71
|
+
except Exception:
|
|
72
|
+
return {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def timestamp_ms() -> int:
|
|
76
|
+
"""Timestamp actual en milisegundos."""
|
|
77
|
+
return int(datetime.now().timestamp() * 1000)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def format_size(size_bytes: int) -> str:
|
|
81
|
+
"""Formatea tamaño en bytes a human readable."""
|
|
82
|
+
for unit in ["B", "KB", "MB", "GB"]:
|
|
83
|
+
if size_bytes < 1024:
|
|
84
|
+
return f"{size_bytes:.1f} {unit}"
|
|
85
|
+
size_bytes /= 1024
|
|
86
|
+
return f"{size_bytes:.1f} TB"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_image_dimensions(data: bytes) -> tuple[int, int]:
|
|
90
|
+
"""Extrae dimensiones de imagen JPEG/PNG sin decodificar completamente."""
|
|
91
|
+
width = height = 0
|
|
92
|
+
|
|
93
|
+
# PNG
|
|
94
|
+
if data.startswith(b'\x89PNG\r\n\x1a\n'):
|
|
95
|
+
for i in range(0, len(data) - 8, 8):
|
|
96
|
+
if data[i:i+4] == b'IHDR':
|
|
97
|
+
width = int.from_bytes(data[i+4:i+8], 'big')
|
|
98
|
+
height = int.from_bytes(data[i+8:i+12], 'big')
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
# JPEG
|
|
102
|
+
elif data.startswith(b'\xff\xd8'):
|
|
103
|
+
i = 2
|
|
104
|
+
while i < len(data) - 8:
|
|
105
|
+
if data[i] != 0xff:
|
|
106
|
+
i += 1
|
|
107
|
+
continue
|
|
108
|
+
marker = data[i+1]
|
|
109
|
+
if marker == 0xc0 or marker == 0xc2:
|
|
110
|
+
height = int.from_bytes(data[i+5:i+7], 'big')
|
|
111
|
+
width = int.from_bytes(data[i+7:i+9], 'big')
|
|
112
|
+
break
|
|
113
|
+
i += 2 + int.from_bytes(data[i+2:i+4], 'big')
|
|
114
|
+
|
|
115
|
+
return width, height
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def generate_blurhash(width: int, height: int) -> str:
|
|
119
|
+
"""Genera un BlurHash simple basado en dimensiones."""
|
|
120
|
+
# Por ahora devolvemos un hash genérico
|
|
121
|
+
# En producción usar librería blurhash
|
|
122
|
+
return "LFE_@w00ay00ay00ay00ay00ay00ay"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def sanitize_filename(filename: str, file_type: int = 0) -> str:
|
|
126
|
+
"""Limpia caracteres problemáticos en el nombre del archivo para URLs, asegurando extensión según el tipo."""
|
|
127
|
+
import os
|
|
128
|
+
from .types import FileType
|
|
129
|
+
|
|
130
|
+
# Mapeo de extensiones por defecto por tipo
|
|
131
|
+
default_exts = {
|
|
132
|
+
FileType.PICTURE: ".jpg",
|
|
133
|
+
FileType.VIDEO: ".mp4",
|
|
134
|
+
FileType.AUDIO: ".mp3",
|
|
135
|
+
FileType.VOICE: ".opus",
|
|
136
|
+
FileType.FILE: ".bin",
|
|
137
|
+
FileType.PROFILE: ".jpg",
|
|
138
|
+
FileType.PROFILE_THUMBNAIL: ".jpg",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
default_ext = default_exts.get(file_type, ".bin")
|
|
142
|
+
|
|
143
|
+
if not filename:
|
|
144
|
+
# Generar nombre por defecto por tipo
|
|
145
|
+
default_names = {
|
|
146
|
+
FileType.PICTURE: "photo",
|
|
147
|
+
FileType.VIDEO: "video",
|
|
148
|
+
FileType.AUDIO: "audio",
|
|
149
|
+
FileType.VOICE: "voice",
|
|
150
|
+
FileType.FILE: "file",
|
|
151
|
+
FileType.PROFILE: "profile",
|
|
152
|
+
FileType.PROFILE_THUMBNAIL: "thumbnail",
|
|
153
|
+
}
|
|
154
|
+
stem = default_names.get(file_type, "file")
|
|
155
|
+
ext = default_ext
|
|
156
|
+
else:
|
|
157
|
+
# Solo el nombre del archivo si es una ruta
|
|
158
|
+
filename = os.path.basename(filename)
|
|
159
|
+
|
|
160
|
+
# Separar nombre y extensión
|
|
161
|
+
parts = filename.rsplit(".", 1)
|
|
162
|
+
stem = parts[0]
|
|
163
|
+
ext = "." + parts[1] if len(parts) > 1 else ""
|
|
164
|
+
|
|
165
|
+
# Si no tiene extensión, usar la correspondiente al tipo
|
|
166
|
+
if not ext:
|
|
167
|
+
ext = default_ext
|
|
168
|
+
|
|
169
|
+
# Reemplazar caracteres no permitidos en nombres de archivos o problemáticos en URLs
|
|
170
|
+
stem_clean = re.sub(r'[\\/*?:"<>|\s]', "_", stem)
|
|
171
|
+
|
|
172
|
+
# Limitar longitud para evitar URLs excesivamente largas
|
|
173
|
+
if len(stem_clean) > 50:
|
|
174
|
+
stem_clean = stem_clean[:47] + "..."
|
|
175
|
+
|
|
176
|
+
return f"{stem_clean}{ext}"
|
|
177
|
+
|