x-nava 1.0.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.
- x_nava-1.0.0/PKG-INFO +58 -0
- x_nava-1.0.0/README.md +49 -0
- x_nava-1.0.0/pyproject.toml +22 -0
- x_nava-1.0.0/setup.cfg +4 -0
- x_nava-1.0.0/tests/test_client.py +19 -0
- x_nava-1.0.0/x_nava/__init__.py +4 -0
- x_nava-1.0.0/x_nava/client.py +333 -0
- x_nava-1.0.0/x_nava/engine.js +80 -0
- x_nava-1.0.0/x_nava/package.json +1 -0
- x_nava-1.0.0/x_nava/runtime/engine.js +64 -0
- x_nava-1.0.0/x_nava/runtime/package.json +1 -0
- x_nava-1.0.0/x_nava.egg-info/PKG-INFO +58 -0
- x_nava-1.0.0/x_nava.egg-info/SOURCES.txt +13 -0
- x_nava-1.0.0/x_nava.egg-info/dependency_links.txt +1 -0
- x_nava-1.0.0/x_nava.egg-info/top_level.txt +1 -0
x_nava-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: x-nava
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python-native messaging client with an embedded runtime
|
|
5
|
+
Author: x_nava contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# x_nava for Python — Termux
|
|
11
|
+
|
|
12
|
+
مكتبة Python للتعامل مع WhatsApp عبر Baileys. هذه النسخة مناسبة لـ **Termux على Android**: لا تستخدم Node.js المضمّن الخاص بسطح المكتب، بل تعتمد على Node.js الذي يثبّته Termux.
|
|
13
|
+
|
|
14
|
+
> غير تابعة لـ WhatsApp أو Meta. استخدمها وفق القوانين وشروط الخدمة، ولا تستخدمها للسبام.
|
|
15
|
+
|
|
16
|
+
## التثبيت على Termux
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pkg update -y
|
|
20
|
+
pkg install -y python nodejs-lts unzip
|
|
21
|
+
unzip x_nava-1.0.0-termux.zip
|
|
22
|
+
cd x_nava-1.0.0-termux
|
|
23
|
+
python -m pip install --upgrade pip
|
|
24
|
+
python -m pip install .
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
في أول تشغيل ستثبت المكتبة تلقائياً اعتماد Baileys داخل `~/.x_nava_runtime`. للتثبيت اليدوي:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
cd ~/.x_nava_runtime
|
|
31
|
+
npm install --no-audit --no-fund --save-exact
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## مثال سريع
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import asyncio
|
|
38
|
+
from x_nava import MoXstarClient
|
|
39
|
+
|
|
40
|
+
async def main():
|
|
41
|
+
client = MoXstarClient(auth_dir='./auth')
|
|
42
|
+
client.on('qr', lambda code: print('QR:', code))
|
|
43
|
+
client.on('ready', lambda _: print('x_nava جاهزة'))
|
|
44
|
+
await client.start()
|
|
45
|
+
await client.wait_until_ready()
|
|
46
|
+
await client.wait_closed()
|
|
47
|
+
|
|
48
|
+
asyncio.run(main())
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
يمكن تحديد مسار Node يدوياً عبر متغير البيئة `NAVAPRO_NODE`، لكن الوضع الافتراضي يستخدم `node` الموجود في Termux.
|
|
52
|
+
|
|
53
|
+
## ملاحظات Android/Termux
|
|
54
|
+
|
|
55
|
+
- استخدم `nodejs-lts` وليس نسخة Node مكتبية مضمّنة.
|
|
56
|
+
- يفضّل تعطيل تحسين البطارية لتطبيق Termux حتى لا ينقطع الاتصال في الخلفية.
|
|
57
|
+
- الاتصال الحي يحتاج QR أو Pairing Code ورقم WhatsApp حقيقي.
|
|
58
|
+
- اختبارات Python المحلية لا تتصل بواتساب.
|
x_nava-1.0.0/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# x_nava for Python — Termux
|
|
2
|
+
|
|
3
|
+
مكتبة Python للتعامل مع WhatsApp عبر Baileys. هذه النسخة مناسبة لـ **Termux على Android**: لا تستخدم Node.js المضمّن الخاص بسطح المكتب، بل تعتمد على Node.js الذي يثبّته Termux.
|
|
4
|
+
|
|
5
|
+
> غير تابعة لـ WhatsApp أو Meta. استخدمها وفق القوانين وشروط الخدمة، ولا تستخدمها للسبام.
|
|
6
|
+
|
|
7
|
+
## التثبيت على Termux
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pkg update -y
|
|
11
|
+
pkg install -y python nodejs-lts unzip
|
|
12
|
+
unzip x_nava-1.0.0-termux.zip
|
|
13
|
+
cd x_nava-1.0.0-termux
|
|
14
|
+
python -m pip install --upgrade pip
|
|
15
|
+
python -m pip install .
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
في أول تشغيل ستثبت المكتبة تلقائياً اعتماد Baileys داخل `~/.x_nava_runtime`. للتثبيت اليدوي:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
cd ~/.x_nava_runtime
|
|
22
|
+
npm install --no-audit --no-fund --save-exact
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## مثال سريع
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import asyncio
|
|
29
|
+
from x_nava import MoXstarClient
|
|
30
|
+
|
|
31
|
+
async def main():
|
|
32
|
+
client = MoXstarClient(auth_dir='./auth')
|
|
33
|
+
client.on('qr', lambda code: print('QR:', code))
|
|
34
|
+
client.on('ready', lambda _: print('x_nava جاهزة'))
|
|
35
|
+
await client.start()
|
|
36
|
+
await client.wait_until_ready()
|
|
37
|
+
await client.wait_closed()
|
|
38
|
+
|
|
39
|
+
asyncio.run(main())
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
يمكن تحديد مسار Node يدوياً عبر متغير البيئة `NAVAPRO_NODE`، لكن الوضع الافتراضي يستخدم `node` الموجود في Termux.
|
|
43
|
+
|
|
44
|
+
## ملاحظات Android/Termux
|
|
45
|
+
|
|
46
|
+
- استخدم `nodejs-lts` وليس نسخة Node مكتبية مضمّنة.
|
|
47
|
+
- يفضّل تعطيل تحسين البطارية لتطبيق Termux حتى لا ينقطع الاتصال في الخلفية.
|
|
48
|
+
- الاتصال الحي يحتاج QR أو Pairing Code ورقم WhatsApp حقيقي.
|
|
49
|
+
- اختبارات Python المحلية لا تتصل بواتساب.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "x-nava"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Python-native messaging client with an embedded runtime"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "x_nava contributors"}]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[tool.setuptools]
|
|
16
|
+
packages = ["x_nava"]
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.package-data]
|
|
19
|
+
x_nava = ["engine.js", "package.json", "runtime/node", "runtime/node.exe", "runtime/engine.js", "runtime/package.json", "runtime/node_modules/**/*"]
|
|
20
|
+
|
|
21
|
+
[tool.pytest.ini_options]
|
|
22
|
+
testpaths = ["tests"]
|
x_nava-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from x_nava.client import Message, EventBus
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_message_model():
|
|
6
|
+
m = Message.from_dict({'id': 'x', 'chat_id': '1@s.whatsapp.net', 'text': 'hello'})
|
|
7
|
+
assert m.id == 'x'
|
|
8
|
+
assert m.text == 'hello'
|
|
9
|
+
assert m.chat_id == '1@s.whatsapp.net'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_event_bus_supports_async_handlers():
|
|
13
|
+
async def run():
|
|
14
|
+
bus = EventBus()
|
|
15
|
+
seen = []
|
|
16
|
+
bus.on('message', lambda value: seen.append(value))
|
|
17
|
+
await bus.emit('message', 'ok')
|
|
18
|
+
assert seen == ['ok']
|
|
19
|
+
asyncio.run(run())
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import platform
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Awaitable, Callable
|
|
15
|
+
|
|
16
|
+
Handler = Callable[[Any], Any]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BridgeError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BridgeOffline(BridgeError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BridgeTimeout(BridgeError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConnectionState(str, Enum):
|
|
32
|
+
DISCONNECTED = "disconnected"
|
|
33
|
+
STARTING = "starting"
|
|
34
|
+
CONNECTED = "connected"
|
|
35
|
+
CLOSED = "closed"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class Message:
|
|
40
|
+
id: str
|
|
41
|
+
chat_id: str
|
|
42
|
+
sender_id: str | None = None
|
|
43
|
+
from_me: bool = False
|
|
44
|
+
is_group: bool = False
|
|
45
|
+
text: str | None = None
|
|
46
|
+
type: str = "unknown"
|
|
47
|
+
timestamp: int | None = None
|
|
48
|
+
push_name: str | None = None
|
|
49
|
+
raw: dict[str, Any] | None = None
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, value: dict[str, Any]) -> "Message":
|
|
53
|
+
return cls(id=value.get("id", ""), chat_id=value.get("chat_id", ""), sender_id=value.get("sender_id"), from_me=bool(value.get("from_me")), is_group=bool(value.get("is_group")), text=value.get("text"), type=value.get("type", "unknown"), timestamp=value.get("timestamp"), push_name=value.get("push_name"), raw=value.get("raw"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class EventBus:
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
self._handlers: dict[str, list[Handler]] = {}
|
|
59
|
+
|
|
60
|
+
def on(self, event: str, handler: Handler) -> Handler:
|
|
61
|
+
self._handlers.setdefault(event, []).append(handler)
|
|
62
|
+
return handler
|
|
63
|
+
|
|
64
|
+
def off(self, event: str, handler: Handler) -> None:
|
|
65
|
+
if event in self._handlers and handler in self._handlers[event]:
|
|
66
|
+
self._handlers[event].remove(handler)
|
|
67
|
+
|
|
68
|
+
async def emit(self, event: str, value: Any) -> None:
|
|
69
|
+
for handler in tuple(self._handlers.get(event, ())):
|
|
70
|
+
result = handler(value)
|
|
71
|
+
if inspect_awaitable(result):
|
|
72
|
+
await result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def inspect_awaitable(value: Any) -> bool:
|
|
76
|
+
return hasattr(value, "__await__")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class MoXstarClient:
|
|
80
|
+
"""Python-native x_nava client backed by an embedded messaging process."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, auth_dir: str = "./auth", runtime_dir: str | None = None, auto_install: bool = True, node_path: str | None = None) -> None:
|
|
83
|
+
self.auth_dir = Path(auth_dir).expanduser().resolve()
|
|
84
|
+
self.runtime_dir = Path(runtime_dir or (Path.home() / ".x_nava_runtime")).expanduser().resolve()
|
|
85
|
+
self.auto_install = auto_install
|
|
86
|
+
self.node_path = node_path
|
|
87
|
+
self.process: asyncio.subprocess.Process | None = None
|
|
88
|
+
self.events = EventBus()
|
|
89
|
+
self._pending: dict[str, asyncio.Future[dict[str, Any]]] = {}
|
|
90
|
+
self._counter = 0
|
|
91
|
+
self._reader_task: asyncio.Task[None] | None = None
|
|
92
|
+
self._stderr_task: asyncio.Task[None] | None = None
|
|
93
|
+
self._waiter: asyncio.Future[None] | None = None
|
|
94
|
+
self._ready = False
|
|
95
|
+
self.auth_dir.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _is_termux() -> bool:
|
|
99
|
+
# Termux reports Linux but exposes these environment markers.
|
|
100
|
+
return bool(
|
|
101
|
+
os.environ.get("TERMUX_VERSION")
|
|
102
|
+
or os.environ.get("PREFIX", "").startswith("/data/data/com.termux")
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def on(self, event: str, handler: Handler) -> Handler:
|
|
106
|
+
return self.events.on(event, handler)
|
|
107
|
+
|
|
108
|
+
def off(self, event: str, handler: Handler) -> None:
|
|
109
|
+
self.events.off(event, handler)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def connected(self) -> bool:
|
|
113
|
+
return self._ready and self.process is not None and self.process.returncode is None
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def alive(self) -> bool:
|
|
117
|
+
return self.process is not None and self.process.returncode is None
|
|
118
|
+
|
|
119
|
+
def _ensure_runtime(self) -> Path:
|
|
120
|
+
source_dir = Path(__file__).resolve().parent
|
|
121
|
+
bundled_dir = source_dir / "runtime"
|
|
122
|
+
bundled_node = bundled_dir / ("node.exe" if sys.platform == "win32" else "node")
|
|
123
|
+
bundled_engine = bundled_dir / "engine.js"
|
|
124
|
+
bundled_baileys = bundled_dir / "node_modules" / "@whiskeysockets" / "baileys" / "package.json"
|
|
125
|
+
# Android/Termux cannot execute the desktop Node binary shipped in
|
|
126
|
+
# desktop wheels. Always use Termux's nodejs-lts there.
|
|
127
|
+
if not self._is_termux() and not self.node_path and bundled_node.exists() and bundled_engine.exists() and bundled_baileys.exists():
|
|
128
|
+
return bundled_engine
|
|
129
|
+
node = self.node_path or os.environ.get("NAVAPRO_NODE") or shutil.which("node")
|
|
130
|
+
npm = os.environ.get("NAVAPRO_NPM") or shutil.which("npm")
|
|
131
|
+
if not node:
|
|
132
|
+
raise BridgeError("No Node.js runtime found. In Termux run: pkg update && pkg install nodejs-lts")
|
|
133
|
+
if not npm:
|
|
134
|
+
raise BridgeError("No npm found. In Termux install nodejs-lts (it provides npm).")
|
|
135
|
+
self.runtime_dir.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
package = self.runtime_dir / "package.json"
|
|
137
|
+
engine = self.runtime_dir / "engine.js"
|
|
138
|
+
package.write_text((source_dir / "package.json").read_text(encoding="utf-8"), encoding="utf-8")
|
|
139
|
+
engine.write_text((source_dir / "engine.js").read_text(encoding="utf-8"), encoding="utf-8")
|
|
140
|
+
installed = self.runtime_dir / "node_modules" / "@whiskeysockets" / "baileys" / "package.json"
|
|
141
|
+
if not installed.exists():
|
|
142
|
+
if not self.auto_install:
|
|
143
|
+
raise BridgeError("Baileys is not installed. Use auto_install=True or run npm install in the runtime directory.")
|
|
144
|
+
subprocess.run([npm, "install", "--no-audit", "--no-fund", "--save-exact"], cwd=self.runtime_dir, check=True)
|
|
145
|
+
return engine
|
|
146
|
+
|
|
147
|
+
def _node_executable(self) -> str | None:
|
|
148
|
+
"""Return the bundled node binary when present, otherwise a system Node."""
|
|
149
|
+
bundled = Path(__file__).resolve().parent / "runtime" / ("node.exe" if sys.platform == "win32" else "node")
|
|
150
|
+
if not self._is_termux() and bundled.exists():
|
|
151
|
+
return str(bundled)
|
|
152
|
+
return self.node_path or os.environ.get("NAVAPRO_NODE") or shutil.which("node")
|
|
153
|
+
|
|
154
|
+
async def start(self) -> None:
|
|
155
|
+
if self.alive:
|
|
156
|
+
return
|
|
157
|
+
engine = self._ensure_runtime()
|
|
158
|
+
node = self._node_executable()
|
|
159
|
+
if not node:
|
|
160
|
+
raise BridgeError("x_nava could not locate its bundled or system Node runtime.")
|
|
161
|
+
env = os.environ.copy()
|
|
162
|
+
env["x_nava_AUTH_DIR"] = str(self.auth_dir)
|
|
163
|
+
self.process = await asyncio.create_subprocess_exec(node, str(engine), cwd=str(engine.parent), env=env, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
|
164
|
+
self._waiter = asyncio.get_running_loop().create_future()
|
|
165
|
+
self._reader_task = asyncio.create_task(self._read_stdout())
|
|
166
|
+
self._stderr_task = asyncio.create_task(self._read_stderr())
|
|
167
|
+
await self._request("start")
|
|
168
|
+
|
|
169
|
+
async def _read_stdout(self) -> None:
|
|
170
|
+
assert self.process and self.process.stdout
|
|
171
|
+
async for raw in self.process.stdout:
|
|
172
|
+
try:
|
|
173
|
+
packet = json.loads(raw.decode("utf-8"))
|
|
174
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
175
|
+
continue
|
|
176
|
+
request_id = packet.get("request_id")
|
|
177
|
+
if request_id and request_id in self._pending:
|
|
178
|
+
future = self._pending.pop(request_id)
|
|
179
|
+
if not future.done():
|
|
180
|
+
future.set_result(packet)
|
|
181
|
+
continue
|
|
182
|
+
event = packet.get("event")
|
|
183
|
+
data = packet.get("data")
|
|
184
|
+
if event == "ready":
|
|
185
|
+
self._ready = True
|
|
186
|
+
if self._waiter and not self._waiter.done(): self._waiter.set_result(None)
|
|
187
|
+
elif event == "close":
|
|
188
|
+
self._ready = False
|
|
189
|
+
elif event == "error":
|
|
190
|
+
await self.events.emit("error", BridgeError(data.get("message", "Engine error")))
|
|
191
|
+
if event:
|
|
192
|
+
if event == "messages.upsert" and isinstance(data, dict):
|
|
193
|
+
data = {**data, "messages": [Message.from_dict(x) for x in data.get("messages", [])]}
|
|
194
|
+
await self.events.emit(event, data)
|
|
195
|
+
|
|
196
|
+
async def _read_stderr(self) -> None:
|
|
197
|
+
if not self.process or not self.process.stderr:
|
|
198
|
+
return
|
|
199
|
+
async for raw in self.process.stderr:
|
|
200
|
+
await self.events.emit("log", raw.decode("utf-8", errors="replace").rstrip())
|
|
201
|
+
|
|
202
|
+
async def _request(self, action: str, timeout: float = 30.0, **payload: Any) -> dict[str, Any]:
|
|
203
|
+
if not self.process or not self.process.stdin or self.process.returncode is not None:
|
|
204
|
+
raise BridgeOffline("x_nava engine is not running")
|
|
205
|
+
self._counter += 1
|
|
206
|
+
request_id = str(self._counter)
|
|
207
|
+
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
|
|
208
|
+
self._pending[request_id] = future
|
|
209
|
+
command = {"action": action, "request_id": request_id, **payload}
|
|
210
|
+
try:
|
|
211
|
+
self.process.stdin.write((json.dumps(command, ensure_ascii=False) + "\n").encode())
|
|
212
|
+
await self.process.stdin.drain()
|
|
213
|
+
packet = await asyncio.wait_for(future, timeout)
|
|
214
|
+
except asyncio.TimeoutError as exc:
|
|
215
|
+
self._pending.pop(request_id, None)
|
|
216
|
+
raise BridgeTimeout(f"Timed out waiting for {action}") from exc
|
|
217
|
+
if not packet.get("ok"):
|
|
218
|
+
raise BridgeError(packet.get("error", {}).get("message", "Engine request failed"))
|
|
219
|
+
return packet.get("result", {})
|
|
220
|
+
|
|
221
|
+
async def wait_until_ready(self, timeout: float | None = None) -> None:
|
|
222
|
+
if self.connected:
|
|
223
|
+
return
|
|
224
|
+
if self._waiter is None:
|
|
225
|
+
raise BridgeOffline("Call start() first")
|
|
226
|
+
await asyncio.wait_for(asyncio.shield(self._waiter), timeout) if timeout else await self._waiter
|
|
227
|
+
|
|
228
|
+
async def wait_closed(self) -> None:
|
|
229
|
+
if self.process:
|
|
230
|
+
await self.process.wait()
|
|
231
|
+
|
|
232
|
+
async def request_pairing_code(self, phone: str) -> str:
|
|
233
|
+
return str((await self._request("pairing_code", phone=phone))["code"])
|
|
234
|
+
|
|
235
|
+
async def send_message(self, to: str, content: dict[str, Any]) -> dict[str, Any]:
|
|
236
|
+
return await self._request("send_message", to=to, content=content)
|
|
237
|
+
|
|
238
|
+
async def send_text(self, to: str, text: str) -> dict[str, Any]:
|
|
239
|
+
if not isinstance(text, str) or not 1 <= len(text) <= 4096:
|
|
240
|
+
raise ValueError("text must be between 1 and 4096 characters")
|
|
241
|
+
return await self.send_message(to, {"text": text})
|
|
242
|
+
|
|
243
|
+
async def send_image(self, to: str, image: str | bytes, caption: str | None = None) -> dict[str, Any]:
|
|
244
|
+
return await self.send_message(to, {"image": image.decode() if isinstance(image, bytes) else image, "caption": caption})
|
|
245
|
+
|
|
246
|
+
async def send_video(self, to: str, video: str | bytes, caption: str | None = None) -> dict[str, Any]:
|
|
247
|
+
return await self.send_message(to, {"video": video.decode() if isinstance(video, bytes) else video, "caption": caption})
|
|
248
|
+
|
|
249
|
+
async def send_document(self, to: str, document: str | bytes, file_name: str, mimetype: str = "application/octet-stream") -> dict[str, Any]:
|
|
250
|
+
return await self.send_message(to, {"document": document.decode() if isinstance(document, bytes) else document, "fileName": file_name, "mimetype": mimetype})
|
|
251
|
+
|
|
252
|
+
async def send_audio(self, to: str, audio: str | bytes, mimetype: str = "audio/mp4") -> dict[str, Any]:
|
|
253
|
+
return await self.send_message(to, {"audio": audio.decode() if isinstance(audio, bytes) else audio, "mimetype": mimetype})
|
|
254
|
+
|
|
255
|
+
async def send_location(self, to: str, latitude: float, longitude: float, name: str | None = None) -> dict[str, Any]:
|
|
256
|
+
return await self.send_message(to, {"location": {"degreesLatitude": latitude, "degreesLongitude": longitude, "name": name}})
|
|
257
|
+
|
|
258
|
+
async def raw_request(self, method: str, *args: Any) -> Any:
|
|
259
|
+
"""Call a supported Baileys socket method for advanced use cases."""
|
|
260
|
+
return (await self._request("baileys_call", method=method, args=list(args))).get("value")
|
|
261
|
+
|
|
262
|
+
async def relay_message(self, jid: str, message: dict[str, Any], options: dict[str, Any] | None = None) -> Any:
|
|
263
|
+
return await self.raw_request("relayMessage", jid, message, options or {})
|
|
264
|
+
|
|
265
|
+
async def send_presence(self, presence: str, to: str | None = None) -> Any:
|
|
266
|
+
return await self.raw_request("sendPresenceUpdate", presence, to)
|
|
267
|
+
|
|
268
|
+
async def read_messages(self, keys: list[dict[str, Any]]) -> Any:
|
|
269
|
+
return await self.raw_request("readMessages", keys)
|
|
270
|
+
|
|
271
|
+
async def check_number(self, numbers: list[str]) -> Any:
|
|
272
|
+
return await self.raw_request("onWhatsApp", numbers)
|
|
273
|
+
|
|
274
|
+
async def get_status(self, jid: str) -> Any:
|
|
275
|
+
return await self.raw_request("fetchStatus", jid)
|
|
276
|
+
|
|
277
|
+
async def get_profile_picture(self, jid: str, picture_type: str = "image") -> Any:
|
|
278
|
+
return await self.raw_request("profilePictureUrl", jid, picture_type)
|
|
279
|
+
|
|
280
|
+
async def get_blocklist(self) -> Any:
|
|
281
|
+
return await self.raw_request("fetchBlocklist")
|
|
282
|
+
|
|
283
|
+
async def update_profile_name(self, name: str) -> Any:
|
|
284
|
+
return await self.raw_request("updateProfileName", name)
|
|
285
|
+
|
|
286
|
+
async def update_profile_status(self, status: str) -> Any:
|
|
287
|
+
return await self.raw_request("updateProfileStatus", status)
|
|
288
|
+
|
|
289
|
+
async def update_profile_picture(self, jid: str, image: str | bytes) -> Any:
|
|
290
|
+
return await self.raw_request("updateProfilePicture", jid, image.decode() if isinstance(image, bytes) else image)
|
|
291
|
+
|
|
292
|
+
async def block(self, jid: str, action: str = "block") -> Any:
|
|
293
|
+
return await self.raw_request("updateBlockStatus", jid, action)
|
|
294
|
+
|
|
295
|
+
async def create_group(self, subject: str, participants: list[str]) -> Any:
|
|
296
|
+
return await self.raw_request("groupCreate", subject, participants)
|
|
297
|
+
|
|
298
|
+
async def get_group_info(self, jid: str) -> Any:
|
|
299
|
+
return await self.raw_request("groupMetadata", jid)
|
|
300
|
+
|
|
301
|
+
async def update_group_subject(self, jid: str, subject: str) -> Any:
|
|
302
|
+
return await self.raw_request("groupUpdateSubject", jid, subject)
|
|
303
|
+
|
|
304
|
+
async def update_group_description(self, jid: str, description: str) -> Any:
|
|
305
|
+
return await self.raw_request("groupUpdateDescription", jid, description)
|
|
306
|
+
|
|
307
|
+
async def update_group_participants(self, jid: str, participants: list[str], action: str) -> Any:
|
|
308
|
+
return await self.raw_request("groupParticipantsUpdate", jid, participants, action)
|
|
309
|
+
|
|
310
|
+
async def get_group_invite_code(self, jid: str) -> Any:
|
|
311
|
+
return await self.raw_request("groupInviteCode", jid)
|
|
312
|
+
|
|
313
|
+
async def revoke_group_invite(self, jid: str) -> Any:
|
|
314
|
+
return await self.raw_request("groupRevokeInvite", jid)
|
|
315
|
+
|
|
316
|
+
async def update_group_settings(self, jid: str, setting: str) -> Any:
|
|
317
|
+
return await self.raw_request("groupSettingUpdate", jid, setting)
|
|
318
|
+
|
|
319
|
+
async def accept_group_invite(self, code: str) -> Any:
|
|
320
|
+
return await self.raw_request("groupAcceptInvite", code)
|
|
321
|
+
|
|
322
|
+
async def logout(self) -> None:
|
|
323
|
+
await self._request("logout")
|
|
324
|
+
self._ready = False
|
|
325
|
+
|
|
326
|
+
async def close(self) -> None:
|
|
327
|
+
if self.alive:
|
|
328
|
+
try: await self._request("exit", timeout=5)
|
|
329
|
+
except BridgeError: pass
|
|
330
|
+
if self.process and self.process.returncode is None:
|
|
331
|
+
try: await asyncio.wait_for(self.process.wait(), 5)
|
|
332
|
+
except asyncio.TimeoutError: self.process.kill()
|
|
333
|
+
self._ready = False
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const readline = require('readline');
|
|
2
|
+
const makeWASocket = require('@whiskeysockets/baileys').default;
|
|
3
|
+
const { useMultiFileAuthState, DisconnectReason, Browsers } = require('@whiskeysockets/baileys');
|
|
4
|
+
|
|
5
|
+
const authDir = process.env.x_nava_AUTH_DIR || './auth';
|
|
6
|
+
let sock = null;
|
|
7
|
+
let ready = false;
|
|
8
|
+
let stopping = false;
|
|
9
|
+
|
|
10
|
+
function out(value) { process.stdout.write(JSON.stringify(value) + '\n'); }
|
|
11
|
+
function errorMessage(e) { return String(e?.message || e); }
|
|
12
|
+
function textOf(m) {
|
|
13
|
+
const c = m?.message || {};
|
|
14
|
+
return c.conversation || c.extendedTextMessage?.text || c.imageMessage?.caption || c.videoMessage?.caption || c.documentMessage?.caption;
|
|
15
|
+
}
|
|
16
|
+
function normalize(m) {
|
|
17
|
+
const jid = m?.key?.remoteJid || '';
|
|
18
|
+
return { id: m?.key?.id || '', chat_id: jid, sender_id: m?.key?.participant || (m?.key?.fromMe ? null : jid), from_me: !!m?.key?.fromMe, is_group: jid.endsWith('@g.us'), text: textOf(m), type: Object.keys(m?.message || {})[0] || 'unknown', timestamp: Number(m?.messageTimestamp || 0) || null, push_name: m?.pushName || null, raw: m };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function start() {
|
|
22
|
+
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
|
23
|
+
sock = makeWASocket({ auth: state, printQRInTerminal: false, browser: Browsers.ubuntu('Chrome'), markOnlineOnConnect: false, syncFullHistory: false });
|
|
24
|
+
sock.ev.on('creds.update', saveCreds);
|
|
25
|
+
sock.ev.on('connection.update', ({ connection, lastDisconnect, qr }) => {
|
|
26
|
+
if (qr) out({ event: 'qr', data: qr });
|
|
27
|
+
if (connection === 'open') { ready = true; out({ event: 'ready', data: {} }); }
|
|
28
|
+
if (connection === 'close') {
|
|
29
|
+
ready = false;
|
|
30
|
+
const code = lastDisconnect?.error?.output?.statusCode || null;
|
|
31
|
+
out({ event: 'close', data: { code } });
|
|
32
|
+
if (!stopping && code !== DisconnectReason.loggedOut) setTimeout(() => start().catch(e => out({ event: 'error', data: { message: errorMessage(e) } })), 2000);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
sock.ev.on('messages.upsert', ({ messages, type }) => out({ event: 'messages.upsert', data: { type, messages: messages.map(normalize) } }));
|
|
36
|
+
sock.ev.on('messages.update', updates => out({ event: 'messages.update', data: updates }));
|
|
37
|
+
sock.ev.on('groups.update', updates => out({ event: 'groups.update', data: updates }));
|
|
38
|
+
sock.ev.on('presence.update', update => out({ event: 'presence.update', data: update }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function command(c) {
|
|
42
|
+
if (c.action === 'status') return out({ request_id: c.request_id, ok: true, result: { ready, alive: true } });
|
|
43
|
+
if (c.action === 'start') return out({ request_id: c.request_id, ok: true, result: { starting: true } });
|
|
44
|
+
if (c.action === 'send_message') {
|
|
45
|
+
if (!sock || !ready) throw new Error('WhatsApp is not connected');
|
|
46
|
+
const jid = String(c.to).replace(/\D/g, '') + '@s.whatsapp.net';
|
|
47
|
+
const result = await sock.sendMessage(jid, c.content || { text: c.text });
|
|
48
|
+
return out({ request_id: c.request_id, ok: true, result: { id: result?.key?.id || null, to: jid } });
|
|
49
|
+
}
|
|
50
|
+
if (c.action === 'pairing_code') {
|
|
51
|
+
if (!sock) throw new Error('WhatsApp socket is not initialized');
|
|
52
|
+
return out({ request_id: c.request_id, ok: true, result: { code: await sock.requestPairingCode(String(c.phone).replace(/\D/g, '')) } });
|
|
53
|
+
}
|
|
54
|
+
if (c.action === 'baileys_call') {
|
|
55
|
+
if (!sock || !ready) throw new Error('WhatsApp is not connected');
|
|
56
|
+
const allowed = new Set([
|
|
57
|
+
'relayMessage', 'sendPresenceUpdate', 'readMessages', 'onWhatsApp',
|
|
58
|
+
'fetchStatus', 'profilePictureUrl', 'fetchBlocklist',
|
|
59
|
+
'updateProfileName', 'updateProfileStatus', 'updateProfilePicture',
|
|
60
|
+
'updateBlockStatus', 'groupCreate', 'groupMetadata',
|
|
61
|
+
'groupUpdateSubject', 'groupUpdateDescription',
|
|
62
|
+
'groupParticipantsUpdate', 'groupInviteCode', 'groupRevokeInvite',
|
|
63
|
+
'groupSettingUpdate', 'groupAcceptInvite', 'chatModify', 'presenceSubscribe'
|
|
64
|
+
]);
|
|
65
|
+
if (!allowed.has(String(c.method)) || typeof sock[c.method] !== 'function') throw new Error('Unsupported Baileys method: ' + c.method);
|
|
66
|
+
const args = Array.isArray(c.args) ? c.args : [];
|
|
67
|
+
const value = await sock[c.method](...args);
|
|
68
|
+
return out({ request_id: c.request_id, ok: true, result: { value } });
|
|
69
|
+
}
|
|
70
|
+
if (c.action === 'logout') { stopping = true; await sock?.logout(); return out({ request_id: c.request_id, ok: true, result: {} }); }
|
|
71
|
+
if (c.action === 'exit') { stopping = true; sock?.end(undefined); out({ request_id: c.request_id, ok: true, result: {} }); process.exit(0); }
|
|
72
|
+
throw new Error('Unknown action: ' + c.action);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
readline.createInterface({ input: process.stdin, crlfDelay: Infinity }).on('line', line => {
|
|
76
|
+
if (!line.trim()) return;
|
|
77
|
+
try { const c = JSON.parse(line); command(c).catch(e => out({ request_id: c.request_id, ok: false, error: { message: errorMessage(e) } })); }
|
|
78
|
+
catch (e) { out({ ok: false, error: { message: errorMessage(e) } }); }
|
|
79
|
+
});
|
|
80
|
+
start().catch(e => out({ event: 'error', data: { message: errorMessage(e) } }));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"private":true,"type":"commonjs","dependencies":{"@whiskeysockets/baileys":"6.7.24"}}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const readline = require('readline');
|
|
2
|
+
const makeWASocket = require('@whiskeysockets/baileys').default;
|
|
3
|
+
const { useMultiFileAuthState, DisconnectReason, Browsers } = require('@whiskeysockets/baileys');
|
|
4
|
+
|
|
5
|
+
const authDir = process.env.x_nava_AUTH_DIR || './auth';
|
|
6
|
+
let sock = null;
|
|
7
|
+
let ready = false;
|
|
8
|
+
let stopping = false;
|
|
9
|
+
|
|
10
|
+
function out(value) { process.stdout.write(JSON.stringify(value) + '\n'); }
|
|
11
|
+
function errorMessage(e) { return String(e?.message || e); }
|
|
12
|
+
function textOf(m) {
|
|
13
|
+
const c = m?.message || {};
|
|
14
|
+
return c.conversation || c.extendedTextMessage?.text || c.imageMessage?.caption || c.videoMessage?.caption || c.documentMessage?.caption;
|
|
15
|
+
}
|
|
16
|
+
function normalize(m) {
|
|
17
|
+
const jid = m?.key?.remoteJid || '';
|
|
18
|
+
return { id: m?.key?.id || '', chat_id: jid, sender_id: m?.key?.participant || (m?.key?.fromMe ? null : jid), from_me: !!m?.key?.fromMe, is_group: jid.endsWith('@g.us'), text: textOf(m), type: Object.keys(m?.message || {})[0] || 'unknown', timestamp: Number(m?.messageTimestamp || 0) || null, push_name: m?.pushName || null, raw: m };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function start() {
|
|
22
|
+
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
|
23
|
+
sock = makeWASocket({ auth: state, printQRInTerminal: false, browser: Browsers.ubuntu('Chrome'), markOnlineOnConnect: false, syncFullHistory: false });
|
|
24
|
+
sock.ev.on('creds.update', saveCreds);
|
|
25
|
+
sock.ev.on('connection.update', ({ connection, lastDisconnect, qr }) => {
|
|
26
|
+
if (qr) out({ event: 'qr', data: qr });
|
|
27
|
+
if (connection === 'open') { ready = true; out({ event: 'ready', data: {} }); }
|
|
28
|
+
if (connection === 'close') {
|
|
29
|
+
ready = false;
|
|
30
|
+
const code = lastDisconnect?.error?.output?.statusCode || null;
|
|
31
|
+
out({ event: 'close', data: { code } });
|
|
32
|
+
if (!stopping && code !== DisconnectReason.loggedOut) setTimeout(() => start().catch(e => out({ event: 'error', data: { message: errorMessage(e) } })), 2000);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
sock.ev.on('messages.upsert', ({ messages, type }) => out({ event: 'messages.upsert', data: { type, messages: messages.map(normalize) } }));
|
|
36
|
+
sock.ev.on('messages.update', updates => out({ event: 'messages.update', data: updates }));
|
|
37
|
+
sock.ev.on('groups.update', updates => out({ event: 'groups.update', data: updates }));
|
|
38
|
+
sock.ev.on('presence.update', update => out({ event: 'presence.update', data: update }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function command(c) {
|
|
42
|
+
if (c.action === 'status') return out({ request_id: c.request_id, ok: true, result: { ready, alive: true } });
|
|
43
|
+
if (c.action === 'start') return out({ request_id: c.request_id, ok: true, result: { starting: true } });
|
|
44
|
+
if (c.action === 'send_message') {
|
|
45
|
+
if (!sock || !ready) throw new Error('WhatsApp is not connected');
|
|
46
|
+
const jid = String(c.to).replace(/\D/g, '') + '@s.whatsapp.net';
|
|
47
|
+
const result = await sock.sendMessage(jid, c.content || { text: c.text });
|
|
48
|
+
return out({ request_id: c.request_id, ok: true, result: { id: result?.key?.id || null, to: jid } });
|
|
49
|
+
}
|
|
50
|
+
if (c.action === 'pairing_code') {
|
|
51
|
+
if (!sock) throw new Error('WhatsApp socket is not initialized');
|
|
52
|
+
return out({ request_id: c.request_id, ok: true, result: { code: await sock.requestPairingCode(String(c.phone).replace(/\D/g, '')) } });
|
|
53
|
+
}
|
|
54
|
+
if (c.action === 'logout') { stopping = true; await sock?.logout(); return out({ request_id: c.request_id, ok: true, result: {} }); }
|
|
55
|
+
if (c.action === 'exit') { stopping = true; sock?.end(undefined); out({ request_id: c.request_id, ok: true, result: {} }); process.exit(0); }
|
|
56
|
+
throw new Error('Unknown action: ' + c.action);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
readline.createInterface({ input: process.stdin, crlfDelay: Infinity }).on('line', line => {
|
|
60
|
+
if (!line.trim()) return;
|
|
61
|
+
try { const c = JSON.parse(line); command(c).catch(e => out({ request_id: c.request_id, ok: false, error: { message: errorMessage(e) } })); }
|
|
62
|
+
catch (e) { out({ ok: false, error: { message: errorMessage(e) } }); }
|
|
63
|
+
});
|
|
64
|
+
start().catch(e => out({ event: 'error', data: { message: errorMessage(e) } }));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"private":true,"type":"commonjs","dependencies":{"@whiskeysockets/baileys":"6.7.24"}}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: x-nava
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python-native messaging client with an embedded runtime
|
|
5
|
+
Author: x_nava contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# x_nava for Python — Termux
|
|
11
|
+
|
|
12
|
+
مكتبة Python للتعامل مع WhatsApp عبر Baileys. هذه النسخة مناسبة لـ **Termux على Android**: لا تستخدم Node.js المضمّن الخاص بسطح المكتب، بل تعتمد على Node.js الذي يثبّته Termux.
|
|
13
|
+
|
|
14
|
+
> غير تابعة لـ WhatsApp أو Meta. استخدمها وفق القوانين وشروط الخدمة، ولا تستخدمها للسبام.
|
|
15
|
+
|
|
16
|
+
## التثبيت على Termux
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pkg update -y
|
|
20
|
+
pkg install -y python nodejs-lts unzip
|
|
21
|
+
unzip x_nava-1.0.0-termux.zip
|
|
22
|
+
cd x_nava-1.0.0-termux
|
|
23
|
+
python -m pip install --upgrade pip
|
|
24
|
+
python -m pip install .
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
في أول تشغيل ستثبت المكتبة تلقائياً اعتماد Baileys داخل `~/.x_nava_runtime`. للتثبيت اليدوي:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
cd ~/.x_nava_runtime
|
|
31
|
+
npm install --no-audit --no-fund --save-exact
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## مثال سريع
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import asyncio
|
|
38
|
+
from x_nava import MoXstarClient
|
|
39
|
+
|
|
40
|
+
async def main():
|
|
41
|
+
client = MoXstarClient(auth_dir='./auth')
|
|
42
|
+
client.on('qr', lambda code: print('QR:', code))
|
|
43
|
+
client.on('ready', lambda _: print('x_nava جاهزة'))
|
|
44
|
+
await client.start()
|
|
45
|
+
await client.wait_until_ready()
|
|
46
|
+
await client.wait_closed()
|
|
47
|
+
|
|
48
|
+
asyncio.run(main())
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
يمكن تحديد مسار Node يدوياً عبر متغير البيئة `NAVAPRO_NODE`، لكن الوضع الافتراضي يستخدم `node` الموجود في Termux.
|
|
52
|
+
|
|
53
|
+
## ملاحظات Android/Termux
|
|
54
|
+
|
|
55
|
+
- استخدم `nodejs-lts` وليس نسخة Node مكتبية مضمّنة.
|
|
56
|
+
- يفضّل تعطيل تحسين البطارية لتطبيق Termux حتى لا ينقطع الاتصال في الخلفية.
|
|
57
|
+
- الاتصال الحي يحتاج QR أو Pairing Code ورقم WhatsApp حقيقي.
|
|
58
|
+
- اختبارات Python المحلية لا تتصل بواتساب.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tests/test_client.py
|
|
4
|
+
x_nava/__init__.py
|
|
5
|
+
x_nava/client.py
|
|
6
|
+
x_nava/engine.js
|
|
7
|
+
x_nava/package.json
|
|
8
|
+
x_nava.egg-info/PKG-INFO
|
|
9
|
+
x_nava.egg-info/SOURCES.txt
|
|
10
|
+
x_nava.egg-info/dependency_links.txt
|
|
11
|
+
x_nava.egg-info/top_level.txt
|
|
12
|
+
x_nava/runtime/engine.js
|
|
13
|
+
x_nava/runtime/package.json
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
x_nava
|