polyester-bot 0.0.0-stage → 1.2.0

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.
package/bot/setup.py ADDED
@@ -0,0 +1,300 @@
1
+ """Guided setup: ask for the credentials, check them, write the .env.
2
+
3
+ Run it with `polyester-bot setup` (npm) or `python setup.py` (source tree).
4
+ It asks for the API key, the Ed25519 seed and the Telegram bot token plus the
5
+ chat id, derives the Polyester account id from the key, proves both channels
6
+ work and then writes the .env the bot reads.
7
+ """
8
+
9
+ import base64
10
+ import getpass
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import re
15
+ import sys
16
+ import time
17
+ import urllib.error
18
+ import urllib.parse
19
+ import urllib.request
20
+
21
+ if __package__:
22
+ from .paths import HOME_ENV, STATE_DIR, env_files
23
+ else:
24
+ from paths import HOME_ENV, STATE_DIR, env_files
25
+
26
+ API = "https://api.testnet.polyester.com"
27
+ SITE = "https://testnet.polyester.com"
28
+ TELEGRAM_API = "https://api.telegram.org"
29
+ UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
30
+ "(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36")
31
+
32
+
33
+ class SetupError(Exception):
34
+ pass
35
+
36
+
37
+ def normalize_seed(raw):
38
+ """The Ed25519 seed as 64 hex characters, from hex or base64."""
39
+ text = (raw or "").strip()
40
+ if text[:2].lower() == "0x":
41
+ text = text[2:]
42
+ if len(text) == 64 and all(c in "0123456789abcdefABCDEF" for c in text):
43
+ return text.lower()
44
+ padded = text + "=" * (-len(text) % 4)
45
+ try:
46
+ raw_bytes = base64.b64decode(padded, validate=True)
47
+ except Exception:
48
+ raw_bytes = b""
49
+ if len(raw_bytes) != 32:
50
+ raise ValueError("the seed must be 64 hex characters or base64 of 32 bytes")
51
+ return raw_bytes.hex()
52
+
53
+
54
+ def merge_env(path, values):
55
+ """Write values into the .env at path, keeping every other line."""
56
+ try:
57
+ with open(path, encoding="utf-8") as f:
58
+ lines = f.read().splitlines()
59
+ except OSError:
60
+ lines = []
61
+ out = []
62
+ seen = set()
63
+ for line in lines:
64
+ key = line.split("=", 1)[0].strip() if "=" in line else ""
65
+ if key in values:
66
+ if key in seen:
67
+ continue
68
+ out.append(key + "=" + values[key])
69
+ seen.add(key)
70
+ else:
71
+ out.append(line)
72
+ for key, value in values.items():
73
+ if key not in seen:
74
+ out.append(key + "=" + value)
75
+ try:
76
+ folder = os.path.dirname(path)
77
+ if folder and not os.path.isdir(folder):
78
+ os.makedirs(folder)
79
+ with open(path, "w", encoding="utf-8") as f:
80
+ f.write("\n".join(out) + "\n")
81
+ os.chmod(path, 0o600)
82
+ except OSError as e:
83
+ raise SetupError("cannot write " + path + ": " + str(e))
84
+
85
+
86
+ def signed_get(path, key_id, seed):
87
+ """GET a signed endpoint with the given key id and hex seed."""
88
+ import nacl.signing
89
+
90
+ ts = str(int(time.time() * 1000))
91
+ canon = "\n".join([ts, "GET", path, "", hashlib.sha256(b"").hexdigest()])
92
+ signature = nacl.signing.SigningKey(bytes.fromhex(seed)).sign(
93
+ canon.encode()).signature.hex()
94
+ req = urllib.request.Request(API + path, method="GET")
95
+ req.add_header("User-Agent", UA)
96
+ req.add_header("Accept", "application/json")
97
+ req.add_header("Origin", SITE)
98
+ req.add_header("Referer", SITE + "/")
99
+ req.add_header("X-API-KEY-ID", key_id)
100
+ req.add_header("X-API-TIMESTAMP", ts)
101
+ req.add_header("X-API-SIGNATURE", signature)
102
+ try:
103
+ with urllib.request.urlopen(req, timeout=30) as r:
104
+ return json.loads(r.read() or b"{}")
105
+ except urllib.error.HTTPError as e:
106
+ body = e.read().decode(errors="replace")
107
+ raise SetupError("the exchange refused " + path + ": " + body[:200])
108
+ except OSError as e:
109
+ raise SetupError("cannot reach the exchange: " + str(e))
110
+
111
+
112
+ def telegram(method, token, **params):
113
+ """Call a Telegram bot method and return its result."""
114
+ url = TELEGRAM_API + "/bot" + token + "/" + method
115
+ if params:
116
+ url += "?" + urllib.parse.urlencode(params)
117
+ req = urllib.request.Request(url, headers={"User-Agent": UA})
118
+ try:
119
+ with urllib.request.urlopen(req, timeout=30) as r:
120
+ data = json.loads(r.read() or b"{}")
121
+ except urllib.error.HTTPError as e:
122
+ body = e.read().decode(errors="replace")
123
+ try:
124
+ detail = json.loads(body).get("description", body[:200])
125
+ except ValueError:
126
+ detail = body[:200]
127
+ raise SetupError("telegram " + method + " failed: " + str(detail))
128
+ except OSError as e:
129
+ raise SetupError("cannot reach telegram: " + str(e))
130
+ if not data.get("ok"):
131
+ raise SetupError("telegram " + method + " failed: "
132
+ + str(data.get("description", data)))
133
+ return data.get("result", data)
134
+
135
+
136
+ def telegram_chat_ids(token):
137
+ """Chat ids the bot has seen so far, oldest first."""
138
+ updates = telegram("getUpdates", token, timeout=0) or []
139
+ ids = []
140
+ for update in updates:
141
+ for field in ("message", "channel_post", "edited_message"):
142
+ chat = (update.get(field) or {}).get("chat") or {}
143
+ if chat.get("id") is not None and chat["id"] not in ids:
144
+ ids.append(chat["id"])
145
+ return ids
146
+
147
+
148
+ def ask(label, default=""):
149
+ """A required line of text, with the current value offered as default."""
150
+ hint = (" [" + default + "]") if default else ""
151
+ while True:
152
+ value = input(label + hint + ": ").strip()
153
+ if value:
154
+ return value
155
+ if default:
156
+ return default
157
+ print("This one is needed.")
158
+
159
+
160
+ def ask_optional(label, default=""):
161
+ """A line of text that may be left empty."""
162
+ hint = (" [" + default + "]") if default else ""
163
+ value = input(label + hint + ": ").strip()
164
+ return value or default
165
+
166
+
167
+ def ask_secret(label, default=""):
168
+ hint = " [kept]" if default else ""
169
+ while True:
170
+ value = getpass.getpass(label + hint + ": ").strip()
171
+ if value:
172
+ return value
173
+ if default:
174
+ return default
175
+ print("This one is needed.")
176
+
177
+
178
+ def ask_optional_secret(label):
179
+ """A secret that may be left empty."""
180
+ value = getpass.getpass(label + " (optional, Enter to skip): ").strip()
181
+ return value
182
+
183
+
184
+ def ask_chat_id(token, username):
185
+ """The Telegram chat to alert: typed in, or found from a message to the bot."""
186
+ while True:
187
+ raw = input("Open @%s, press Start, then press Enter here "
188
+ "(or paste a chat id): " % username).strip()
189
+ if raw and re.fullmatch(r"-?\d+", raw):
190
+ return raw
191
+ try:
192
+ found = telegram_chat_ids(token)
193
+ except SetupError as e:
194
+ print(str(e))
195
+ found = []
196
+ if found:
197
+ return str(found[-1])
198
+ print("No chat id yet. Send /start to @%s and press Enter again, "
199
+ "or paste the chat id." % username)
200
+
201
+
202
+ def defaults():
203
+ """The values already on this machine, so the wizard can pre-fill them."""
204
+ out = dict(os.environ)
205
+ for path in env_files():
206
+ try:
207
+ with open(path, encoding="utf-8") as f:
208
+ for line in f:
209
+ line = line.strip()
210
+ if line and not line.startswith("#") and "=" in line:
211
+ k, _, v = line.partition("=")
212
+ out.setdefault(k.strip(), v.strip())
213
+ except OSError:
214
+ pass
215
+ return out
216
+
217
+
218
+ def targets():
219
+ """Where the credentials go: the home copy, plus a .env already in the folder."""
220
+ out = [HOME_ENV]
221
+ local = os.path.join(STATE_DIR, ".env")
222
+ if os.path.isfile(local):
223
+ out.append(local)
224
+ return out
225
+
226
+
227
+ def main():
228
+ """Walk the user through the credentials and leave a working .env behind."""
229
+ print("Polyester setup")
230
+ print("Press Enter to keep the value in brackets. Testnet only.\n")
231
+
232
+ have = defaults()
233
+ key_id = ask("Polyester API key id (ak_...)",
234
+ have.get("POLYESTER_API_KEY_ID", ""))
235
+ seed = normalize_seed(ask_secret(
236
+ "Ed25519 seed (64 hex characters or base64)",
237
+ have.get("POLYESTER_ED25519_SEED", "")))
238
+
239
+ me = signed_get("/v1/auth/me", key_id, seed)
240
+ account_id = str(me.get("accountId") or "")
241
+ if not account_id:
242
+ raise SetupError("the exchange returned no account id: "
243
+ + json.dumps(me)[:200])
244
+ print("\nExchange ok: account %s (%s)"
245
+ % (account_id, me.get("username", "unknown user")))
246
+
247
+ token = ask_secret("Telegram bot token",
248
+ have.get("TELEGRAM_BOT_TOKEN", ""))
249
+ bot = telegram("getMe", token)
250
+ username = bot.get("username", "your bot")
251
+ print("Telegram ok: @%s" % username)
252
+
253
+ while True:
254
+ chat_id = ask_chat_id(token, username)
255
+ try:
256
+ telegram("sendMessage", token, chat_id=chat_id,
257
+ text="Polyester setup: this chat gets the bot alerts.")
258
+ except SetupError as e:
259
+ print(str(e))
260
+ print("Give another chat id.")
261
+ continue
262
+ break
263
+ print("Telegram chat ok: %s" % chat_id)
264
+
265
+ values = {
266
+ "POLYESTER_API_KEY_ID": key_id,
267
+ "POLYESTER_ED25519_SEED": seed,
268
+ "POLYESTER_ACCOUNT_ID": account_id,
269
+ "TELEGRAM_BOT_TOKEN": token,
270
+ "TELEGRAM_CHAT_ID": chat_id,
271
+ }
272
+
273
+ private_key = ask_optional_secret(
274
+ "EOA private key (only for the daily paper-asset claim)")
275
+ if private_key:
276
+ values["POLYESTER_PRIVATE_KEY"] = private_key
277
+ values["POLYESTER_SMART_ACCOUNT"] = ask_optional(
278
+ "Smart account address (0x...)",
279
+ str(me.get("rootSmartAccountAddress") or ""))
280
+
281
+ for target in targets():
282
+ merge_env(target, values)
283
+ print("\nWrote %s" % " and ".join(targets()))
284
+ print("Start the bot with: polyester-bot")
285
+
286
+
287
+ def cli():
288
+ """Run the wizard and turn any failure into a one-line error."""
289
+ try:
290
+ main()
291
+ except SetupError as e:
292
+ print("SETUP FAILED: " + str(e), file=sys.stderr)
293
+ sys.exit(1)
294
+ except (KeyboardInterrupt, EOFError):
295
+ print("\nSetup cancelled.", file=sys.stderr)
296
+ sys.exit(1)
297
+
298
+
299
+ if __name__ == "__main__":
300
+ cli()
@@ -0,0 +1,22 @@
1
+ """One-shot: show the bot's open quotes and the current hot-pairs signal."""
2
+ import json
3
+ import os
4
+ import sys
5
+
6
+ if __package__:
7
+ from . import check_orders # noqa: F401
8
+ from .paths import state_file
9
+ else:
10
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
11
+ import check_orders # noqa: F401
12
+ from paths import state_file
13
+
14
+ try:
15
+ with open(state_file("hot_pairs.json"), encoding="utf-8") as f:
16
+ hot = json.load(f)
17
+ if hot:
18
+ print("hot pairs:", json.dumps(hot))
19
+ else:
20
+ print("hot pairs: none (all quiet)")
21
+ except OSError:
22
+ print("hot pairs: no scan yet")
package/install.js ADDED
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+
3
+ const { ensurePython } = require("./bin/setup");
4
+
5
+ console.log("polyester-bot: Python environment ready at " + ensurePython());
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "polyester-bot",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.2.0",
4
+ "description": "Polyester testnet gap hunter: buys orderbook gaps, asks them back, alerts on Telegram",
5
+ "keywords": [
6
+ "polyester",
7
+ "testnet",
8
+ "trading",
9
+ "market-making",
10
+ "bot",
11
+ "telegram",
12
+ "crypto"
13
+ ],
14
+ "homepage": "https://github.com/natanim-kemal/polyester-bot",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/natanim-kemal/polyester-bot.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/natanim-kemal/polyester-bot/issues"
21
+ },
22
+ "license": "MIT",
23
+ "author": "natanim-kemal",
24
+ "bin": {
25
+ "polyester-bot": "bin/polyester-bot.js",
26
+ "polyester-farm": "bin/polyester-farm.js"
27
+ },
28
+ "scripts": {
29
+ "postinstall": "node install.js"
30
+ },
31
+ "files": [
32
+ "bin/",
33
+ "install.js",
34
+ "pyproject.toml",
35
+ "bot/*.py",
36
+ "LICENSE",
37
+ "README.md"
38
+ ],
39
+ "engines": {
40
+ "node": ">=14"
41
+ }
42
+ }
package/pyproject.toml ADDED
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "polyester-bot"
7
+ version = "1.2.0"
8
+ description = "Polyester testnet gap hunter: buys orderbook gaps, asks them back, alerts on Telegram"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "natanim-kemal" }]
14
+ keywords = ["polyester", "testnet", "trading", "market-making", "bot", "telegram"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: End Users/Desktop",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Office/Business :: Financial :: Investment",
20
+ ]
21
+ dependencies = [
22
+ "polyester-sdk>=0.6.0,<0.7",
23
+ "pynacl>=1.5",
24
+ "eth-account>=0.13",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/natanim-kemal/polyester-bot"
29
+ Repository = "https://github.com/natanim-kemal/polyester-bot"
30
+ Issues = "https://github.com/natanim-kemal/polyester-bot/issues"
31
+
32
+ [project.scripts]
33
+ polyester-bot = "polyester_bot.monitor:cli"
34
+ polyester-farm = "polyester_bot.polyester_farm:main"
35
+
36
+ [tool.setuptools]
37
+ packages = ["polyester_bot"]
38
+
39
+ [tool.setuptools.package-dir]
40
+ polyester_bot = "bot"