izteamslots 1.1.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/.env.example +1 -0
- package/CONTRIBUTING.md +128 -0
- package/README.md +249 -0
- package/app.py +25 -0
- package/backend/__init__.py +3 -0
- package/backend/__main__.py +3 -0
- package/backend/account_store.py +448 -0
- package/backend/chatgpt_workspace_api.py +104 -0
- package/backend/dto.py +106 -0
- package/backend/file_logger.py +82 -0
- package/backend/jobs.py +77 -0
- package/backend/mail/__init__.py +98 -0
- package/backend/mail/base.py +86 -0
- package/backend/mail/boomlify.py +178 -0
- package/backend/mail/imap.py +221 -0
- package/backend/mail/trickads.py +121 -0
- package/backend/openai_web_auth.py +1402 -0
- package/backend/rpc_protocol.py +78 -0
- package/backend/rpc_server.py +233 -0
- package/backend/slot_orchestrator.py +400 -0
- package/backend/ui_facade.py +368 -0
- package/bin/izteamslots.sh +16 -0
- package/package.json +30 -0
- package/requirements.txt +2 -0
- package/scripts/setup.sh +82 -0
- package/ui/package.json +19 -0
- package/ui/src/main.ts +4 -0
- package/ui/src/menus/format.ts +163 -0
- package/ui/src/menus/mainMenus.ts +221 -0
- package/ui/src/menus/types.ts +75 -0
- package/ui/src/screens/MainScreen.ts +1175 -0
- package/ui/src/transport/stdioClient.ts +162 -0
- package/ui/tsconfig.json +13 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import uuid
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from . import PROJECT_ROOT
|
|
10
|
+
from .account_store import AccountStore
|
|
11
|
+
from .dto import AdminRow, AppStateDTO, MailAccountRow, WorkerRow
|
|
12
|
+
from .mail import Mailbox, create_provider_for_mailbox
|
|
13
|
+
from .openai_web_auth import (
|
|
14
|
+
close_browser as close_br,
|
|
15
|
+
)
|
|
16
|
+
from .openai_web_auth import (
|
|
17
|
+
oauth_login,
|
|
18
|
+
oauth_login_manual,
|
|
19
|
+
open_browser,
|
|
20
|
+
save_codex_file,
|
|
21
|
+
wait_for_browser_close,
|
|
22
|
+
)
|
|
23
|
+
from .slot_orchestrator import SlotManager
|
|
24
|
+
|
|
25
|
+
LogFunc = Callable[[str], Any]
|
|
26
|
+
ProgressFunc = Callable[[int, int, str | None], None]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _has_profile_files(profile_dir: Path) -> bool:
|
|
30
|
+
return profile_dir.exists() and any(profile_dir.iterdir())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UIFacade:
|
|
34
|
+
def __init__(self, store: AccountStore | None = None) -> None:
|
|
35
|
+
self.store = store or AccountStore()
|
|
36
|
+
self.manager: SlotManager | None = None
|
|
37
|
+
self.bootstrap()
|
|
38
|
+
|
|
39
|
+
def bootstrap(self) -> None:
|
|
40
|
+
profiles_json = PROJECT_ROOT / "profiles.json"
|
|
41
|
+
admin_index_exists = (self.store.admin_dir / "index.json").exists()
|
|
42
|
+
worker_index_exists = (self.store.worker_dir / "index.json").exists()
|
|
43
|
+
|
|
44
|
+
auto_migrate_profiles = os.environ.get("IZTEAMSLOTS_AUTOMIGRATE_PROFILES") == "1"
|
|
45
|
+
if auto_migrate_profiles and profiles_json.exists() and not admin_index_exists and not worker_index_exists:
|
|
46
|
+
self.store.migrate_from_profiles()
|
|
47
|
+
|
|
48
|
+
self.store.doctor()
|
|
49
|
+
self.sync_codex_files()
|
|
50
|
+
|
|
51
|
+
def shutdown(self) -> None:
|
|
52
|
+
self.sync_codex_files()
|
|
53
|
+
|
|
54
|
+
def sync_codex_files(self) -> None:
|
|
55
|
+
codex_dir = PROJECT_ROOT / "codex"
|
|
56
|
+
codex_dir.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
for src_dir in (self.store.admin_dir, self.store.worker_dir):
|
|
58
|
+
if not src_dir.exists():
|
|
59
|
+
continue
|
|
60
|
+
for account_dir in src_dir.iterdir():
|
|
61
|
+
if not account_dir.is_dir():
|
|
62
|
+
continue
|
|
63
|
+
for f in account_dir.glob("codex-*.json"):
|
|
64
|
+
dst = codex_dir / f.name
|
|
65
|
+
if not dst.exists() or f.stat().st_mtime > dst.stat().st_mtime:
|
|
66
|
+
dst.write_text(f.read_text())
|
|
67
|
+
|
|
68
|
+
def get_state(self) -> dict[str, Any]:
|
|
69
|
+
raw_admins = self.store.list_admins()
|
|
70
|
+
raw_workers = self.store.list_workers()
|
|
71
|
+
admins = [
|
|
72
|
+
AdminRow.from_account(
|
|
73
|
+
a,
|
|
74
|
+
has_browser_profile=_has_profile_files(self.store.get_admin_profile_dir(a)),
|
|
75
|
+
)
|
|
76
|
+
for a in raw_admins
|
|
77
|
+
]
|
|
78
|
+
workers = [
|
|
79
|
+
WorkerRow.from_account(
|
|
80
|
+
w,
|
|
81
|
+
has_browser_profile=_has_profile_files(self.store.get_worker_profile_dir(w)),
|
|
82
|
+
)
|
|
83
|
+
for w in raw_workers
|
|
84
|
+
]
|
|
85
|
+
accounts: list[MailAccountRow] = [
|
|
86
|
+
*(MailAccountRow(kind="admin", email=a.email) for a in raw_admins),
|
|
87
|
+
*(MailAccountRow(kind="worker", email=w.email) for w in raw_workers),
|
|
88
|
+
]
|
|
89
|
+
return AppStateDTO(admins=admins, workers=workers, accounts=accounts).to_dict()
|
|
90
|
+
|
|
91
|
+
def list_admins(self) -> list[dict[str, Any]]:
|
|
92
|
+
return [
|
|
93
|
+
AdminRow.from_account(
|
|
94
|
+
a,
|
|
95
|
+
has_browser_profile=_has_profile_files(self.store.get_admin_profile_dir(a)),
|
|
96
|
+
).__dict__
|
|
97
|
+
for a in self.store.list_admins()
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
def list_workers(self) -> list[dict[str, Any]]:
|
|
101
|
+
return [
|
|
102
|
+
WorkerRow.from_account(
|
|
103
|
+
w,
|
|
104
|
+
has_browser_profile=_has_profile_files(self.store.get_worker_profile_dir(w)),
|
|
105
|
+
).__dict__
|
|
106
|
+
for w in self.store.list_workers()
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
def list_workers_by_admin(self, admin_email: str) -> list[dict[str, Any]]:
|
|
110
|
+
workers = [w for w in self.store.list_workers() if w.admin_email == admin_email]
|
|
111
|
+
return [
|
|
112
|
+
WorkerRow.from_account(
|
|
113
|
+
w,
|
|
114
|
+
has_browser_profile=_has_profile_files(self.store.get_worker_profile_dir(w)),
|
|
115
|
+
).__dict__
|
|
116
|
+
for w in workers
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
def list_mail_accounts(self) -> list[dict[str, str]]:
|
|
120
|
+
out: list[dict[str, str]] = []
|
|
121
|
+
for a in self.store.list_admins():
|
|
122
|
+
out.append({"kind": "admin", "email": a.email})
|
|
123
|
+
for w in self.store.list_workers():
|
|
124
|
+
out.append({"kind": "worker", "email": w.email})
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
def add_admin(self, email: str, password: str) -> dict[str, Any]:
|
|
128
|
+
admin = self.store.add_admin(email, password)
|
|
129
|
+
return AdminRow.from_account(admin).__dict__
|
|
130
|
+
|
|
131
|
+
def add_admin_manual(self, log: LogFunc) -> dict[str, Any]:
|
|
132
|
+
temp_root = self.store.admin_dir / f"_manual_{uuid.uuid4().hex}"
|
|
133
|
+
profile_dir = temp_root / "browser_profile"
|
|
134
|
+
page = None
|
|
135
|
+
created_admin_email: str | None = None
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
page, session = oauth_login_manual(
|
|
139
|
+
profile_dir=profile_dir,
|
|
140
|
+
log=log,
|
|
141
|
+
)
|
|
142
|
+
email = str(session.get("email") or "").strip()
|
|
143
|
+
if not email:
|
|
144
|
+
raise RuntimeError("OAuth не вернул email, админ не создан")
|
|
145
|
+
if self.store.get_admin(email):
|
|
146
|
+
raise RuntimeError(f"Админ {email} уже существует")
|
|
147
|
+
|
|
148
|
+
admin = self.store.add_admin(email, "")
|
|
149
|
+
created_admin_email = email
|
|
150
|
+
|
|
151
|
+
manager = SlotManager(store=self.store, admin_email=email, log=log)
|
|
152
|
+
manager.finalize_admin_session(page, session)
|
|
153
|
+
page = None
|
|
154
|
+
|
|
155
|
+
target_profile_dir = self.store.get_admin_profile_dir(admin)
|
|
156
|
+
if target_profile_dir.exists():
|
|
157
|
+
shutil.rmtree(target_profile_dir)
|
|
158
|
+
shutil.move(str(profile_dir), str(target_profile_dir))
|
|
159
|
+
log(f"Профиль сохранён: {target_profile_dir}")
|
|
160
|
+
|
|
161
|
+
self.sync_codex_files()
|
|
162
|
+
log(f"Админ {email} добавлен через ручной логин")
|
|
163
|
+
log("[предупреждение] Пароль почты не сохранён. Для почты и авто-входа добавьте его позже.")
|
|
164
|
+
return AdminRow.from_account(
|
|
165
|
+
self.store.get_admin(email) or admin,
|
|
166
|
+
has_browser_profile=_has_profile_files(target_profile_dir),
|
|
167
|
+
).__dict__
|
|
168
|
+
except Exception:
|
|
169
|
+
if page is not None:
|
|
170
|
+
try:
|
|
171
|
+
close_br(page, log=log)
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
if created_admin_email:
|
|
175
|
+
try:
|
|
176
|
+
self.store.delete_admin(created_admin_email)
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
raise
|
|
180
|
+
finally:
|
|
181
|
+
if temp_root.exists():
|
|
182
|
+
shutil.rmtree(temp_root, ignore_errors=True)
|
|
183
|
+
|
|
184
|
+
def delete_admin(self, email: str) -> None:
|
|
185
|
+
self.store.delete_admin(email)
|
|
186
|
+
for w in self.store.list_workers():
|
|
187
|
+
if w.admin_email == email:
|
|
188
|
+
w.admin_email = None
|
|
189
|
+
self.store.update_worker(w)
|
|
190
|
+
if self.manager and self.manager.admin_email == email:
|
|
191
|
+
self.manager = None
|
|
192
|
+
|
|
193
|
+
def delete_worker(self, email: str) -> None:
|
|
194
|
+
self.store.delete_worker(email)
|
|
195
|
+
|
|
196
|
+
def open_admin_browser(self, email: str, log: LogFunc) -> None:
|
|
197
|
+
admin = self.store.get_admin(email)
|
|
198
|
+
if not admin:
|
|
199
|
+
raise RuntimeError("Админ не найден")
|
|
200
|
+
profile_dir = self.store.get_admin_profile_dir(admin)
|
|
201
|
+
if not any(profile_dir.iterdir()):
|
|
202
|
+
raise RuntimeError("Нет browser_profile, сначала перелогиньте админа")
|
|
203
|
+
self._open_profile_browser(
|
|
204
|
+
profile_dir, email, log,
|
|
205
|
+
url="https://chatgpt.com/admin/members?tab=members",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def open_worker_browser(self, email: str, log: LogFunc) -> None:
|
|
209
|
+
worker = self.store.get_worker(email)
|
|
210
|
+
if not worker:
|
|
211
|
+
raise RuntimeError("Слот не найден")
|
|
212
|
+
profile_dir = self.store.get_worker_profile_dir(worker)
|
|
213
|
+
if not any(profile_dir.iterdir()):
|
|
214
|
+
raise RuntimeError("Нет browser_profile, сначала перелогиньте слот")
|
|
215
|
+
self._open_profile_browser(profile_dir, email, log)
|
|
216
|
+
|
|
217
|
+
def _open_profile_browser(
|
|
218
|
+
self, profile_dir: Path, label: str, log: LogFunc, url: str = "https://chatgpt.com/",
|
|
219
|
+
) -> None:
|
|
220
|
+
_page, context = open_browser(profile_dir, url=url, log=log)
|
|
221
|
+
log(f"Браузер {label} открыт. Закройте окно браузера для возврата.")
|
|
222
|
+
wait_for_browser_close(context, log=log)
|
|
223
|
+
|
|
224
|
+
def login_admin(self, email: str, log: LogFunc) -> None:
|
|
225
|
+
self.manager = SlotManager(store=self.store, admin_email=email, log=log)
|
|
226
|
+
self.manager.login_admin()
|
|
227
|
+
self.sync_codex_files()
|
|
228
|
+
|
|
229
|
+
def login_admin_manual(self, email: str, log: LogFunc) -> None:
|
|
230
|
+
self.manager = SlotManager(store=self.store, admin_email=email, log=log)
|
|
231
|
+
self.manager.login_admin_manual()
|
|
232
|
+
self.sync_codex_files()
|
|
233
|
+
|
|
234
|
+
def run_slots_pipeline(
|
|
235
|
+
self,
|
|
236
|
+
admin_email: str,
|
|
237
|
+
count: int,
|
|
238
|
+
log: LogFunc,
|
|
239
|
+
progress: ProgressFunc | None = None,
|
|
240
|
+
) -> dict[str, int]:
|
|
241
|
+
self.manager = SlotManager(store=self.store, admin_email=admin_email, log=log, headless=False)
|
|
242
|
+
ok = 0
|
|
243
|
+
for i in range(count):
|
|
244
|
+
slot_no = i + 1
|
|
245
|
+
log(f"--- Слот {slot_no}/{count} ---")
|
|
246
|
+
if progress:
|
|
247
|
+
progress(slot_no, count, f"slot {slot_no}/{count}")
|
|
248
|
+
try:
|
|
249
|
+
self.manager.create_invite_login_one()
|
|
250
|
+
ok += 1
|
|
251
|
+
except Exception as e:
|
|
252
|
+
log(f"Ошибка: {e}")
|
|
253
|
+
self.manager._close_admin_page()
|
|
254
|
+
log(f"Готово: {ok}/{count} слотов")
|
|
255
|
+
self.sync_codex_files()
|
|
256
|
+
return {"ok": ok, "total": count}
|
|
257
|
+
|
|
258
|
+
def relogin_worker_email(self, email: str, log: LogFunc) -> bool:
|
|
259
|
+
worker = self.store.get_worker(email)
|
|
260
|
+
if not worker:
|
|
261
|
+
log(f"Worker {email} не найден")
|
|
262
|
+
return False
|
|
263
|
+
if not worker.openai_password:
|
|
264
|
+
log(f"{email} — нет openai_password")
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
mail = create_provider_for_mailbox(Mailbox(email=worker.email, password=worker.password))
|
|
268
|
+
try:
|
|
269
|
+
mailbox = Mailbox(email=worker.email, password=worker.password)
|
|
270
|
+
profile_dir = self.store.get_worker_profile_dir(worker)
|
|
271
|
+
|
|
272
|
+
page, session = oauth_login(
|
|
273
|
+
email=worker.email,
|
|
274
|
+
password=worker.openai_password or worker.password,
|
|
275
|
+
mail_client=mail,
|
|
276
|
+
mailbox=mailbox,
|
|
277
|
+
profile_dir=profile_dir,
|
|
278
|
+
log=log,
|
|
279
|
+
headless=False,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
if session.get("access_token"):
|
|
283
|
+
worker.access_token = session["access_token"]
|
|
284
|
+
if session.get("account_id"):
|
|
285
|
+
worker.workspace_id = session["account_id"]
|
|
286
|
+
self.store.update_worker(worker)
|
|
287
|
+
|
|
288
|
+
worker_dir = self.store.worker_dir / worker.id
|
|
289
|
+
codex_path = save_codex_file(worker_dir, session, email)
|
|
290
|
+
log(f"Codex обновлён: {codex_path.name}")
|
|
291
|
+
else:
|
|
292
|
+
log("Нет access_token — codex не сохранён")
|
|
293
|
+
close_br(page, log=log)
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
close_br(page, log=log)
|
|
297
|
+
log(f"{email} перелогинен")
|
|
298
|
+
self.sync_codex_files()
|
|
299
|
+
return True
|
|
300
|
+
finally:
|
|
301
|
+
mail.close()
|
|
302
|
+
|
|
303
|
+
def relogin_all_workers(
|
|
304
|
+
self,
|
|
305
|
+
log: LogFunc,
|
|
306
|
+
progress: ProgressFunc | None = None,
|
|
307
|
+
) -> dict[str, int]:
|
|
308
|
+
workers = self.store.list_workers()
|
|
309
|
+
eligible = [w.email for w in workers if w.openai_password]
|
|
310
|
+
if not eligible:
|
|
311
|
+
log("Нет слотов с openai_password")
|
|
312
|
+
return {"ok": 0, "total": 0}
|
|
313
|
+
|
|
314
|
+
ok = 0
|
|
315
|
+
total = len(eligible)
|
|
316
|
+
for i, email in enumerate(eligible, start=1):
|
|
317
|
+
log(f"--- Перелогин {i}/{total}: {email} ---")
|
|
318
|
+
if progress:
|
|
319
|
+
progress(i, total, email)
|
|
320
|
+
if self.relogin_worker_email(email, log):
|
|
321
|
+
ok += 1
|
|
322
|
+
log(f"Готово: {ok}/{total}")
|
|
323
|
+
self.sync_codex_files()
|
|
324
|
+
return {"ok": ok, "total": total}
|
|
325
|
+
|
|
326
|
+
def fetch_mail(self, email: str, log: LogFunc) -> dict[str, Any]:
|
|
327
|
+
password = self._resolve_password(email)
|
|
328
|
+
if password is None:
|
|
329
|
+
raise RuntimeError("Аккаунт не найден")
|
|
330
|
+
|
|
331
|
+
mailbox = Mailbox(email=email, password=password)
|
|
332
|
+
mail = create_provider_for_mailbox(mailbox)
|
|
333
|
+
try:
|
|
334
|
+
inbox = mail.inbox(mailbox)
|
|
335
|
+
finally:
|
|
336
|
+
mail.close()
|
|
337
|
+
|
|
338
|
+
messages = inbox.messages
|
|
339
|
+
if not messages:
|
|
340
|
+
log(f"{email}: нет писем")
|
|
341
|
+
else:
|
|
342
|
+
log(f"{email}: писем {len(messages)}")
|
|
343
|
+
for m in messages[:50]:
|
|
344
|
+
log(f"{m.date} | {m.sender} | {m.subject}")
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
"email": email,
|
|
348
|
+
"count": len(messages),
|
|
349
|
+
"messages": [
|
|
350
|
+
{
|
|
351
|
+
"id": m.id,
|
|
352
|
+
"date": m.date,
|
|
353
|
+
"sender": m.sender,
|
|
354
|
+
"subject": m.subject,
|
|
355
|
+
"body": m.body,
|
|
356
|
+
}
|
|
357
|
+
for m in messages[:50]
|
|
358
|
+
],
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
def _resolve_password(self, email: str) -> str | None:
|
|
362
|
+
admin = self.store.get_admin(email)
|
|
363
|
+
if admin:
|
|
364
|
+
return admin.password
|
|
365
|
+
worker = self.store.get_worker(email)
|
|
366
|
+
if worker:
|
|
367
|
+
return worker.password
|
|
368
|
+
return None
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
5
|
+
|
|
6
|
+
# Ensure bun is available
|
|
7
|
+
if ! command -v bun &>/dev/null; then
|
|
8
|
+
if [ -d "$HOME/.bun/bin" ]; then
|
|
9
|
+
export PATH="$HOME/.bun/bin:$PATH"
|
|
10
|
+
else
|
|
11
|
+
echo "Bun not found. Run: npm run setup" >&2
|
|
12
|
+
exit 1
|
|
13
|
+
fi
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
exec bun run --cwd "$ROOT/ui" src/main.ts "$@"
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "izteamslots",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "ChatGPT Team slot management — automated invite, register, OAuth login & codex token pipeline",
|
|
5
|
+
"bin": {
|
|
6
|
+
"izteamslots": "bin/izteamslots.sh"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "bun run --cwd ui src/main.ts",
|
|
10
|
+
"setup": "bash scripts/setup.sh",
|
|
11
|
+
"postinstall": "bash scripts/setup.sh",
|
|
12
|
+
"typecheck:ui": "npm --prefix ui run typecheck"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/izzzzzi/izTeamSlots.git"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"chatgpt",
|
|
24
|
+
"team",
|
|
25
|
+
"slots",
|
|
26
|
+
"openai",
|
|
27
|
+
"codex",
|
|
28
|
+
"automation"
|
|
29
|
+
]
|
|
30
|
+
}
|
package/requirements.txt
ADDED
package/scripts/setup.sh
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
RED='\033[0;31m'
|
|
5
|
+
GREEN='\033[0;32m'
|
|
6
|
+
YELLOW='\033[1;33m'
|
|
7
|
+
NC='\033[0m'
|
|
8
|
+
|
|
9
|
+
ok() { echo -e " ${GREEN}✓${NC} $1"; }
|
|
10
|
+
warn() { echo -e " ${YELLOW}!${NC} $1"; }
|
|
11
|
+
fail() { echo -e " ${RED}✗${NC} $1"; exit 1; }
|
|
12
|
+
|
|
13
|
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
14
|
+
echo ""
|
|
15
|
+
echo "izTeamSlots setup"
|
|
16
|
+
echo "================="
|
|
17
|
+
echo ""
|
|
18
|
+
|
|
19
|
+
# ── Python ──────────────────────────────────────────────
|
|
20
|
+
echo "Checking Python..."
|
|
21
|
+
PYTHON=""
|
|
22
|
+
for cmd in python3 python; do
|
|
23
|
+
if command -v "$cmd" &>/dev/null; then
|
|
24
|
+
ver=$("$cmd" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+')
|
|
25
|
+
major=$(echo "$ver" | cut -d. -f1)
|
|
26
|
+
minor=$(echo "$ver" | cut -d. -f2)
|
|
27
|
+
if [ "$major" -ge 3 ] && [ "$minor" -ge 11 ]; then
|
|
28
|
+
PYTHON="$cmd"
|
|
29
|
+
break
|
|
30
|
+
fi
|
|
31
|
+
fi
|
|
32
|
+
done
|
|
33
|
+
[ -z "$PYTHON" ] && fail "Python 3.11+ not found. Install: https://python.org"
|
|
34
|
+
ok "Python: $($PYTHON --version)"
|
|
35
|
+
|
|
36
|
+
# ── uv ──────────────────────────────────────────────────
|
|
37
|
+
echo "Checking uv..."
|
|
38
|
+
if ! command -v uv &>/dev/null; then
|
|
39
|
+
warn "uv not found. Installing..."
|
|
40
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
41
|
+
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
|
|
42
|
+
fi
|
|
43
|
+
command -v uv &>/dev/null || fail "uv install failed. Install manually: https://docs.astral.sh/uv"
|
|
44
|
+
ok "uv: $(uv --version)"
|
|
45
|
+
|
|
46
|
+
# ── Python deps ─────────────────────────────────────────
|
|
47
|
+
echo "Installing Python dependencies..."
|
|
48
|
+
uv pip install --system -q -r "$ROOT/requirements.txt"
|
|
49
|
+
ok "Python deps installed"
|
|
50
|
+
|
|
51
|
+
# ── Bun ─────────────────────────────────────────────────
|
|
52
|
+
echo "Checking Bun..."
|
|
53
|
+
if ! command -v bun &>/dev/null; then
|
|
54
|
+
warn "Bun not found. Installing..."
|
|
55
|
+
curl -fsSL https://bun.sh/install | bash
|
|
56
|
+
export PATH="$HOME/.bun/bin:$PATH"
|
|
57
|
+
fi
|
|
58
|
+
command -v bun &>/dev/null || fail "Bun install failed. Install manually: https://bun.sh"
|
|
59
|
+
ok "Bun: $(bun --version)"
|
|
60
|
+
|
|
61
|
+
# ── UI deps ─────────────────────────────────────────────
|
|
62
|
+
echo "Installing UI dependencies..."
|
|
63
|
+
cd "$ROOT/ui"
|
|
64
|
+
bun install --frozen-lockfile 2>/dev/null || bun install
|
|
65
|
+
ok "UI deps installed"
|
|
66
|
+
cd "$ROOT"
|
|
67
|
+
|
|
68
|
+
# ── .env ────────────────────────────────────────────────
|
|
69
|
+
if [ ! -f "$ROOT/.env" ]; then
|
|
70
|
+
if [ -f "$ROOT/.env.example" ]; then
|
|
71
|
+
cp "$ROOT/.env.example" "$ROOT/.env"
|
|
72
|
+
warn ".env created from .env.example — edit it with your API keys"
|
|
73
|
+
fi
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
# ── Done ────────────────────────────────────────────────
|
|
77
|
+
echo ""
|
|
78
|
+
echo -e "${GREEN}Setup complete!${NC}"
|
|
79
|
+
echo ""
|
|
80
|
+
echo " Start: npm start"
|
|
81
|
+
echo " Or: bun run --cwd ui src/main.ts"
|
|
82
|
+
echo ""
|
package/ui/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "izteamslots-opentui",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "bun run src/main.ts",
|
|
8
|
+
"typecheck": "tsc --noEmit"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@opentui/core": "^0.1.86"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@types/bun": "^1.2.18",
|
|
15
|
+
"@types/node": "^22.10.2",
|
|
16
|
+
"tsx": "^4.19.2",
|
|
17
|
+
"typescript": "^5.7.2"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/ui/src/main.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { StyledText, bg, bold, fg, stringToStyledText, t } from "@opentui/core"
|
|
2
|
+
import type { DashboardData, MenuOption } from "./types"
|
|
3
|
+
|
|
4
|
+
function truncate(value: string, width: number): string {
|
|
5
|
+
if (width <= 1) return value.slice(0, width)
|
|
6
|
+
if (value.length <= width) return value
|
|
7
|
+
return `${value.slice(0, width - 1)}…`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function pad(value: string, width: number): string {
|
|
11
|
+
const v = truncate(value, width)
|
|
12
|
+
if (v.length >= width) return v
|
|
13
|
+
return v + " ".repeat(width - v.length)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatTable(
|
|
17
|
+
headers: string[],
|
|
18
|
+
rows: string[][],
|
|
19
|
+
maxWidth = 120,
|
|
20
|
+
emptyMessage = "Нет данных для отображения.",
|
|
21
|
+
): StyledText {
|
|
22
|
+
const allRows = [headers, ...rows]
|
|
23
|
+
if (allRows.length === 0) return stringToStyledText("")
|
|
24
|
+
|
|
25
|
+
const cols = headers.length
|
|
26
|
+
const widths = new Array(cols).fill(0)
|
|
27
|
+
for (const row of allRows) {
|
|
28
|
+
for (let i = 0; i < cols; i++) {
|
|
29
|
+
const cell = row[i] ?? ""
|
|
30
|
+
widths[i] = Math.max(widths[i], cell.length)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const minCol = 8
|
|
35
|
+
for (let i = 0; i < cols; i++) widths[i] = Math.max(minCol, Math.min(widths[i], 48))
|
|
36
|
+
|
|
37
|
+
const separatorWidth = (cols - 1) * 3
|
|
38
|
+
const total = widths.reduce((a: number, b: number) => a + b, 0) + separatorWidth
|
|
39
|
+
if (total > maxWidth) {
|
|
40
|
+
const overflow = total - maxWidth
|
|
41
|
+
const perColCut = Math.ceil(overflow / cols)
|
|
42
|
+
for (let i = 0; i < cols; i++) widths[i] = Math.max(minCol, widths[i] - perColCut)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const rowToLine = (row: string[]): string => row.map((v, i) => pad(v ?? "", widths[i])).join(" │ ")
|
|
46
|
+
const headerLine = rowToLine(headers)
|
|
47
|
+
const divider = widths.map((w: number) => "─".repeat(w)).join("─┼─")
|
|
48
|
+
|
|
49
|
+
const lines: StyledText[] = [
|
|
50
|
+
t`${bg("#334155")(fg("#f8fafc")(bold(headerLine)))}`,
|
|
51
|
+
t`${fg("#64748b")(divider)}`,
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
if (rows.length === 0) {
|
|
55
|
+
lines.push(t`${fg("#64748b")(truncate(emptyMessage, Math.max(12, maxWidth)))}`)
|
|
56
|
+
return joinStyledLines(lines)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
lines.push(
|
|
60
|
+
...rows.map((row, i) => {
|
|
61
|
+
const line = rowToLine(row)
|
|
62
|
+
return i % 2 === 0 ? t`${fg("#cbd5e1")(line)}` : t`${bg("#111827")(fg("#cbd5e1")(line))}`
|
|
63
|
+
}),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return joinStyledLines(lines)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatMenu(options: MenuOption[], selected: number, maxWidth = 80): StyledText {
|
|
70
|
+
if (options.length === 0) {
|
|
71
|
+
return t`${fg("#94a3b8")("(пусто)")}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const lines: StyledText[] = []
|
|
75
|
+
const longestLabel = options.reduce((max, option) => Math.max(max, option.label.length), 0)
|
|
76
|
+
const labelWidth = Math.max(18, Math.min(longestLabel + 2, 28))
|
|
77
|
+
const hintWidth = Math.max(20, maxWidth - labelWidth - 8)
|
|
78
|
+
|
|
79
|
+
for (let i = 0; i < options.length; i++) {
|
|
80
|
+
const opt = options[i]
|
|
81
|
+
const num = i < 9 ? `${i + 1}.` : "• "
|
|
82
|
+
const label = pad(opt.label, labelWidth)
|
|
83
|
+
const hint = opt.hint ? ` ${truncate(opt.hint, hintWidth)}` : ""
|
|
84
|
+
const activeBg = opt.destructive ? "#7f1d1d" : "#2563eb"
|
|
85
|
+
const activeFg = opt.destructive ? "#fee2e2" : "#eff6ff"
|
|
86
|
+
const inline = `${label}${hint}`
|
|
87
|
+
|
|
88
|
+
if (i === selected) {
|
|
89
|
+
lines.push(t`${bg(activeBg)(fg(activeFg)(bold(` ${num} ${inline} `)))}`)
|
|
90
|
+
} else if (opt.destructive) {
|
|
91
|
+
lines.push(t`${fg("#64748b")(` ${num}`)} ${fg("#f87171")(label)}${fg("#64748b")(hint)}`)
|
|
92
|
+
} else {
|
|
93
|
+
lines.push(t`${fg("#64748b")(` ${num}`)} ${fg("#cbd5e1")(label)}${fg("#64748b")(hint)}`)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return joinStyledLines(lines)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function formatDashboard(data: DashboardData, maxWidth = 72): StyledText {
|
|
101
|
+
const lines: StyledText[] = []
|
|
102
|
+
|
|
103
|
+
const adminHealth = data.admins_total === 0
|
|
104
|
+
? "нужен первый админ"
|
|
105
|
+
: `${data.admins_ready}/${data.admins_total} готовы`
|
|
106
|
+
const adminHealthColor = data.admins_ready === data.admins_total ? "#4ade80" : "#fbbf24"
|
|
107
|
+
|
|
108
|
+
lines.push(
|
|
109
|
+
t`${fg("#94a3b8")("Админы ")}${fg("#f8fafc")(bold(pad(String(data.admins_total), 4)))}${fg(adminHealthColor)(truncate(adminHealth, Math.max(16, maxWidth - 22)))}`,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const detailParts: string[] = []
|
|
113
|
+
if (data.workers_ready > 0) detailParts.push(`${data.workers_ready} готовы`)
|
|
114
|
+
if (data.workers_registered > 0) detailParts.push(`${data.workers_registered} готово`)
|
|
115
|
+
if (data.workers_invited > 0) detailParts.push(`${data.workers_invited} приглашено`)
|
|
116
|
+
if (data.workers_created > 0) detailParts.push(`${data.workers_created} создано`)
|
|
117
|
+
if (data.workers_with_password > 0) detailParts.push(`${data.workers_with_password} с паролем`)
|
|
118
|
+
|
|
119
|
+
const detail = detailParts.length > 0 ? detailParts.join(" / ") : ""
|
|
120
|
+
|
|
121
|
+
if (detail) {
|
|
122
|
+
lines.push(
|
|
123
|
+
t`${fg("#94a3b8")("Слоты ")}${fg("#f8fafc")(bold(pad(String(data.workers_total), 4)))}${fg("#94a3b8")(truncate(detail, Math.max(16, maxWidth - 22)))}`,
|
|
124
|
+
)
|
|
125
|
+
} else {
|
|
126
|
+
lines.push(t`${fg("#94a3b8")("Слоты ")}${fg("#f8fafc")(bold(String(data.workers_total)))}`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (data.admins_total === 0) {
|
|
130
|
+
lines.push(t`${fg("#fca5a5")("Нужно добавить первого администратора.")}`)
|
|
131
|
+
} else if (data.admins_ready < data.admins_total) {
|
|
132
|
+
lines.push(t`${fg("#fbbf24")("Не все админы готовы: проверьте токен и браузерный профиль.")}`)
|
|
133
|
+
} else if (data.workers_total === 0) {
|
|
134
|
+
lines.push(t`${fg("#93c5fd")("Система готова к созданию первых слотов.")}`)
|
|
135
|
+
} else {
|
|
136
|
+
lines.push(t`${fg("#4ade80")("Операционный контур выглядит готовым к работе.")}`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return joinStyledLines(lines)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function formatLogs(logs: string[], max = 200, maxWidth = 120): StyledText {
|
|
143
|
+
const tail = logs.slice(Math.max(0, logs.length - max))
|
|
144
|
+
if (tail.length === 0) return t`${fg("#64748b")("Ожидание событий...")}`
|
|
145
|
+
|
|
146
|
+
const lines: StyledText[] = tail.map((line, i) => {
|
|
147
|
+
const value = truncate(line, Math.max(24, maxWidth))
|
|
148
|
+
if (line.includes("Ошибка:")) return t`${fg("#fca5a5")(value)}`
|
|
149
|
+
if (line.includes("▶") || line.includes("Готово")) return t`${fg("#93c5fd")(bold(value))}`
|
|
150
|
+
return i % 2 === 0 ? t`${fg("#cbd5e1")(value)}` : t`${fg("#94a3b8")(value)}`
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
return joinStyledLines(lines)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function joinStyledLines(lines: StyledText[]): StyledText {
|
|
157
|
+
const chunks: StyledText["chunks"] = []
|
|
158
|
+
for (let i = 0; i < lines.length; i++) {
|
|
159
|
+
chunks.push(...lines[i].chunks)
|
|
160
|
+
if (i < lines.length - 1) chunks.push(...stringToStyledText("\n").chunks)
|
|
161
|
+
}
|
|
162
|
+
return new StyledText(chunks)
|
|
163
|
+
}
|