notebookvb
This commit is contained in:
@@ -2,14 +2,18 @@
|
||||
# Připojení k Firebird databázi Medicus (medicus.fdb). Volí DSN podle názvu počítače.
|
||||
# Obsahuje třídu MedicusDB s metodami pro dotazy na pacienty, registrace a faktury.
|
||||
|
||||
import os
|
||||
import socket
|
||||
import fdb
|
||||
|
||||
|
||||
def get_medicus_connection():
|
||||
"""
|
||||
Připojí se k Firebird medicus.fdb podle názvu počítače.
|
||||
Vrátí fdb.Connection nebo vyhodí RuntimeError pro neznámý počítač.
|
||||
Připojí se k Firebird medicus.fdb. DSN se vybere takto:
|
||||
1) env MEDICUS_FDB_DSN (má přednost — nutné v dockeru, kde hostname = ID kontejneru),
|
||||
2) podle názvu počítače (dsn_map),
|
||||
3) default.
|
||||
Vrátí fdb.Connection nebo vyhodí RuntimeError.
|
||||
"""
|
||||
computer_name = socket.gethostname().upper()
|
||||
dsn_map = {
|
||||
@@ -20,7 +24,8 @@ def get_medicus_connection():
|
||||
"Z230": r"192.168.1.76:/firebird/data/medicus.fdb", # přepnuto z reporteru na tower 2026-06-14
|
||||
"TOWER": r"192.168.1.76:/firebird/data/medicus.fdb", # Firebird 2.5 docker kontejner na toweru
|
||||
}
|
||||
dsn = dsn_map.get(computer_name, r"localhost:c:\medicus 3\data\medicus.fdb")
|
||||
dsn = (os.environ.get("MEDICUS_FDB_DSN")
|
||||
or dsn_map.get(computer_name, r"localhost:c:\medicus 3\data\medicus.fdb"))
|
||||
import sys
|
||||
print(f"[medicus_db] Pripojuji se jako {computer_name} -> {dsn}", file=sys.stderr, flush=True)
|
||||
return fdb.connect(dsn=dsn, user="SYSDBA", password="masterkey", charset="win1250")
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
telegram_notify.py
|
||||
------------------
|
||||
Notifikace a obousměrná komunikace přes Telegram Bot API
|
||||
(bot ClaudeBot @Vlado_Claude_Bot).
|
||||
|
||||
Token a výchozí chat_id se načítají z `Medevio/.env`:
|
||||
TELEGRAM_BOT_TOKEN=123456789:AAE...
|
||||
TELEGRAM_CHAT_ID=6639316354
|
||||
|
||||
Použití ze skriptu:
|
||||
from Knihovny.telegram_notify import posli_telegram, zeptej_se_telegram
|
||||
|
||||
posli_telegram("Pipeline 08 hotová, 142 záznamů")
|
||||
|
||||
odpoved = zeptej_se_telegram("Mám reimportovat i archiv? (ano/ne)")
|
||||
if odpoved and odpoved.strip().lower() == "ano":
|
||||
...
|
||||
|
||||
Použití z příkazové řádky:
|
||||
python -m Knihovny.telegram_notify "Hotovo"
|
||||
python -m Knihovny.telegram_notify --ask "Pokracovat? (ano/ne)"
|
||||
|
||||
POZN.: getUpdates smí v jednu chvíli pollovat jen JEDEN proces. Pokud běží
|
||||
víc skriptů naráz, které čekají na odpověď, kradou si navzájem zprávy —
|
||||
v praxi se ptá vždy jen jeden agent.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
# =========================
|
||||
# Načtení .env (Medevio/.env)
|
||||
# =========================
|
||||
def _load_env():
|
||||
env_path = Path(__file__).resolve().parent.parent / "Medevio" / ".env"
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if "=" in line and not line.startswith("#"):
|
||||
k, v = line.split("=", 1)
|
||||
os.environ.setdefault(k.strip(), v.strip())
|
||||
|
||||
|
||||
_load_env()
|
||||
|
||||
|
||||
API_BASE = "https://api.telegram.org/bot{token}/{method}"
|
||||
|
||||
|
||||
def _token() -> str:
|
||||
token = os.environ.get("TELEGRAM_BOT_TOKEN")
|
||||
if not token:
|
||||
raise RuntimeError("Chybí TELEGRAM_BOT_TOKEN v Medevio/.env")
|
||||
return token
|
||||
|
||||
|
||||
def _resolve_chat_id(chat_id: str | None) -> str:
|
||||
chat_id = chat_id or os.environ.get("TELEGRAM_CHAT_ID")
|
||||
if not chat_id:
|
||||
raise RuntimeError("Chybí TELEGRAM_CHAT_ID (zadej argumentem nebo v Medevio/.env)")
|
||||
return str(chat_id)
|
||||
|
||||
|
||||
def _call(method: str, *, http_timeout: int = 15, **params):
|
||||
"""Zavolá Telegram Bot API metodu a vrátí pole `result`."""
|
||||
url = API_BASE.format(token=_token(), method=method)
|
||||
r = requests.post(url, json=params, timeout=http_timeout)
|
||||
data = r.json()
|
||||
if not data.get("ok"):
|
||||
raise RuntimeError(f"Telegram {method} selhal [{r.status_code}]: {data}")
|
||||
return data["result"]
|
||||
|
||||
|
||||
def posli_telegram(
|
||||
text: str,
|
||||
*,
|
||||
chat_id: str | None = None,
|
||||
parse_mode: str | None = None,
|
||||
disable_notification: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
Pošle zprávu přes Telegram bota.
|
||||
|
||||
:param text: text zprávy (max 4096 znaků)
|
||||
:param chat_id: cílový chat; výchozí z TELEGRAM_CHAT_ID
|
||||
:param parse_mode: None | "Markdown" | "MarkdownV2" | "HTML"
|
||||
:param disable_notification: True = tichá zpráva (bez upozornění)
|
||||
:return: odeslaná zpráva (dict z Telegram API)
|
||||
"""
|
||||
params = {
|
||||
"chat_id": _resolve_chat_id(chat_id),
|
||||
"text": text,
|
||||
"disable_notification": disable_notification,
|
||||
}
|
||||
if parse_mode:
|
||||
params["parse_mode"] = parse_mode
|
||||
return _call("sendMessage", **params)
|
||||
|
||||
|
||||
def zeptej_se_telegram(
|
||||
otazka: str,
|
||||
*,
|
||||
chat_id: str | None = None,
|
||||
timeout: int = 300,
|
||||
poll_timeout: int = 30,
|
||||
parse_mode: str | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Pošle otázku a BLOKUJÍCÍ čeká na textovou odpověď uživatele.
|
||||
|
||||
Zahodí starší zprávy a bere jen tu, která přijde PO odeslání otázky.
|
||||
|
||||
:param otazka: text otázky
|
||||
:param chat_id: cílový chat; výchozí z TELEGRAM_CHAT_ID
|
||||
:param timeout: celkové čekání na odpověď v sekundách (pak vrátí None)
|
||||
:param poll_timeout: délka jednoho long-poll cyklu v sekundách
|
||||
:param parse_mode: formátování otázky (None | "HTML" | "Markdown")
|
||||
:return: text odpovědi, nebo None když nikdo neodpoví do timeoutu
|
||||
"""
|
||||
cid = _resolve_chat_id(chat_id)
|
||||
|
||||
# Zjisti poslední update_id, ať bereme jen NOVÉ zprávy po otázce.
|
||||
existujici = _call("getUpdates", http_timeout=15)
|
||||
offset = (existujici[-1]["update_id"] + 1) if existujici else 0
|
||||
|
||||
posli_telegram(otazka, chat_id=cid, parse_mode=parse_mode)
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
zbyva = int(deadline - time.monotonic())
|
||||
if zbyva <= 0:
|
||||
break
|
||||
lp = max(1, min(poll_timeout, zbyva))
|
||||
updates = _call("getUpdates", http_timeout=lp + 10, offset=offset, timeout=lp)
|
||||
for u in updates:
|
||||
offset = u["update_id"] + 1
|
||||
msg = u.get("message") or {}
|
||||
if str(msg.get("chat", {}).get("id")) != cid:
|
||||
continue # zpráva z jiného chatu — ignoruj
|
||||
text = msg.get("text")
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _safe_print(text: str):
|
||||
"""Výpis odolný vůči kódování Windows konzole (cp1252)."""
|
||||
try:
|
||||
print(text)
|
||||
except UnicodeEncodeError:
|
||||
print(text.encode("ascii", "replace").decode("ascii"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Ať projdou i diakritika/emoji na Windows konzoli.
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
args = sys.argv[1:]
|
||||
|
||||
if not args:
|
||||
print('Použití:')
|
||||
print(' python -m Knihovny.telegram_notify "text zprávy"')
|
||||
print(' python -m Knihovny.telegram_notify --ask "otázka?"')
|
||||
sys.exit(1)
|
||||
|
||||
if args[0] == "--ask":
|
||||
otazka = " ".join(args[1:]) or "?"
|
||||
odpoved = zeptej_se_telegram(otazka, timeout=240)
|
||||
if odpoved is None:
|
||||
_safe_print("(bez odpovědi — vypršel timeout)")
|
||||
sys.exit(2)
|
||||
_safe_print(odpoved)
|
||||
else:
|
||||
posli_telegram(" ".join(args))
|
||||
_safe_print("Odesláno OK")
|
||||
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
telegram_user.py
|
||||
----------------
|
||||
Ovládání PLNOHODNOTNÉHO Telegram účtu (ne bota) přes user API (MTProto / Telethon).
|
||||
Na rozdíl od bota umí napsat komukoli a unese VÍCE souběžných agentů na jednom účtu
|
||||
(jako Telegram otevřený zároveň na PC, tabletu i mobilu).
|
||||
|
||||
⚠️ Jedná JMÉNEM přihlášeného účtu. Session soubor = plný přístup k účtu.
|
||||
⚠️ Nové účty na automatizaci Telegram rychle banuje (zvlášť VoIP čísla — použij reálnou SIM).
|
||||
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
VÍCE AGENTŮ NA JEDNOM ÚČTU
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
- api_id/api_hash se SDÍLÍ (identifikují „aplikaci", ne zařízení).
|
||||
- Každý agent musí mít VLASTNÍ session soubor (= vlastní autorizace / „zařízení").
|
||||
Sdílet jednu session mezi procesy NELZE (database is locked / AUTH_KEY_DUPLICATED).
|
||||
→ každý agent se přihlásí zvlášť: `login --jako <jmeno>` (jeden SMS kód na agenta).
|
||||
- Všechny sessions vidí stejný chat, proto se odpovědi směrují přes Telegram **Reply**:
|
||||
agent pošle označenou otázku a bere jen tu odpověď, která je Reply na *jeho* zprávu
|
||||
(shoda `reply_to_msg_id`). Tím se odpovědi více agentů nepomíchají.
|
||||
|
||||
Konfigurace v `Medevio/.env` (api_id/api_hash z https://my.telegram.org):
|
||||
TELEGRAM_API_ID=1234567
|
||||
TELEGRAM_API_HASH=abcdef0123456789abcdef0123456789
|
||||
TELEGRAM_PHONE=+420... # nepovinné (jinak se zeptá při loginu)
|
||||
|
||||
Session soubory: `Medevio/agent_telegram_<jmeno>.session` (gitignored).
|
||||
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
CLI
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
Jednorázové přihlášení agenta (spusť ve svém terminálu — čeká na kód z SMS):
|
||||
python -m Knihovny.telegram_user login --jako recepty
|
||||
python -m Knihovny.telegram_user login --jako kalendar
|
||||
|
||||
Poslání zprávy ("me" = Uložené zprávy / Saved Messages):
|
||||
python -m Knihovny.telegram_user send me "Test" --jako recepty
|
||||
|
||||
Otázka + čekání na Reply odpověď (vypíše odpověď na stdout):
|
||||
python -m Knihovny.telegram_user ask recepty "Mam pokracovat? (ano/ne)"
|
||||
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
ZE SKRIPTU
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
from Knihovny.telegram_user import posli_jako_ja, zeptej_se_jako
|
||||
|
||||
posli_jako_ja("me", "Pipeline 08 hotová", session="recepty")
|
||||
|
||||
odp = zeptej_se_jako("recepty", "Našel jsem 3 sporné záznamy. Pokračovat?")
|
||||
if odp and odp.strip().lower() == "ano":
|
||||
...
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# telethon.sync zpřístupní metody synchronně (bez async/await)
|
||||
from telethon.sync import TelegramClient
|
||||
from telethon.errors import SessionPasswordNeededError, PhoneNumberUnoccupiedError
|
||||
|
||||
|
||||
def _load_env():
|
||||
env_path = Path(__file__).resolve().parent.parent / "Medevio" / ".env"
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if "=" in line and not line.startswith("#"):
|
||||
k, v = line.split("=", 1)
|
||||
os.environ.setdefault(k.strip(), v.strip())
|
||||
|
||||
|
||||
_load_env()
|
||||
|
||||
|
||||
def _api_id() -> int:
|
||||
val = os.environ.get("TELEGRAM_API_ID")
|
||||
if not val:
|
||||
raise RuntimeError("Chybí TELEGRAM_API_ID v Medevio/.env (z https://my.telegram.org)")
|
||||
return int(val)
|
||||
|
||||
|
||||
def _api_hash() -> str:
|
||||
val = os.environ.get("TELEGRAM_API_HASH")
|
||||
if not val:
|
||||
raise RuntimeError("Chybí TELEGRAM_API_HASH v Medevio/.env (z https://my.telegram.org)")
|
||||
return val
|
||||
|
||||
|
||||
def _session_path(jmeno: str | None) -> Path:
|
||||
base = f"agent_telegram_{jmeno}" if jmeno else "agent_telegram"
|
||||
return Path(__file__).resolve().parent.parent / "Medevio" / base
|
||||
|
||||
|
||||
def _new_client(session: str | None = None) -> TelegramClient:
|
||||
return TelegramClient(str(_session_path(session)), _api_id(), _api_hash())
|
||||
|
||||
|
||||
def prihlas(session: str | None = None) -> None:
|
||||
"""
|
||||
Jednorázové přihlášení dané session. Interaktivně se zeptá na kód z SMS
|
||||
a případně na heslo dvoufázového ověření. Vytvoří session soubor.
|
||||
SPOUŠTĚJ V TERMINÁLU (potřebuje input).
|
||||
"""
|
||||
client = _new_client(session)
|
||||
client.start(phone=os.environ.get("TELEGRAM_PHONE") or (lambda: input("Telefon (+420...): ")))
|
||||
me = client.get_me()
|
||||
print(f"Session '{session or 'default'}' přihlášena jako "
|
||||
f"{me.first_name or ''} (@{me.username}) id={me.id}")
|
||||
client.disconnect()
|
||||
|
||||
|
||||
def _phone() -> str:
|
||||
val = os.environ.get("TELEGRAM_PHONE")
|
||||
if not val:
|
||||
raise RuntimeError("Chybí TELEGRAM_PHONE v Medevio/.env")
|
||||
return val
|
||||
|
||||
|
||||
def login_posli_kod(session: str | None = None) -> None:
|
||||
"""
|
||||
1. krok přihlášení (řízeného na dálku): vyžádá si od Telegramu kód.
|
||||
Vytiskne `PHONE_CODE_HASH=...`, který je potřeba pro 2. krok.
|
||||
"""
|
||||
client = _new_client(session)
|
||||
client.connect()
|
||||
try:
|
||||
sent = client.send_code_request(_phone())
|
||||
print("PHONE_CODE_HASH=" + sent.phone_code_hash)
|
||||
finally:
|
||||
client.disconnect()
|
||||
|
||||
|
||||
def login_dokonci(code, phone_code_hash: str, session: str | None = None,
|
||||
password: str | None = None) -> None:
|
||||
"""
|
||||
2. krok přihlášení: dokončí login zadaným kódem (a případně heslem 2FA).
|
||||
Při úspěchu uloží session soubor.
|
||||
"""
|
||||
client = _new_client(session)
|
||||
client.connect()
|
||||
try:
|
||||
try:
|
||||
client.sign_in(phone=_phone(), code=str(code), phone_code_hash=phone_code_hash)
|
||||
except SessionPasswordNeededError:
|
||||
if not password:
|
||||
print("NEED_PASSWORD")
|
||||
return
|
||||
client.sign_in(password=password)
|
||||
except PhoneNumberUnoccupiedError:
|
||||
print("UCET_NEEXISTUJE - nejdriv zaregistruj cislo v aplikaci Telegram")
|
||||
return
|
||||
me = client.get_me()
|
||||
print(f"OK prihlaseno jako {me.first_name or ''} (@{me.username}) id={me.id}")
|
||||
finally:
|
||||
client.disconnect()
|
||||
|
||||
|
||||
def posli_jako_ja(komu, text: str, *, session: str | None = None):
|
||||
"""
|
||||
Pošle zprávu jménem přihlášeného účtu z dané session.
|
||||
|
||||
:param komu: "me" (Saved Messages) | "@username" | telefon | int id
|
||||
:param text: text zprávy
|
||||
:param session: jméno session (které přihlášení použít)
|
||||
:return: odeslaná zpráva (Telethon Message)
|
||||
"""
|
||||
with _new_client(session) as client:
|
||||
if not client.is_user_authorized():
|
||||
raise RuntimeError(
|
||||
f"Session '{session or 'default'}' není přihlášena — "
|
||||
f"spusť: python -m Knihovny.telegram_user login"
|
||||
+ (f" --jako {session}" if session else "")
|
||||
)
|
||||
return client.send_message(komu, text)
|
||||
|
||||
|
||||
def precti_zpravy(komu, limit: int = 10, *, session: str | None = None):
|
||||
"""
|
||||
Vrátí posledních `limit` zpráv z daného chatu.
|
||||
|
||||
:return: list dictů {"id", "text", "odeslal_ja", "reply_na", "datum"}
|
||||
"""
|
||||
out = []
|
||||
with _new_client(session) as client:
|
||||
if not client.is_user_authorized():
|
||||
raise RuntimeError(f"Session '{session or 'default'}' není přihlášena.")
|
||||
for msg in client.iter_messages(komu, limit=limit):
|
||||
out.append({
|
||||
"id": msg.id,
|
||||
"text": msg.text or "",
|
||||
"odeslal_ja": bool(msg.out),
|
||||
"reply_na": msg.reply_to_msg_id,
|
||||
"datum": msg.date,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def zeptej_se_jako(
|
||||
agent: str,
|
||||
otazka: str,
|
||||
*,
|
||||
komu="me",
|
||||
session: str | None = None,
|
||||
timeout: int = 300,
|
||||
poll_interval: int = 3,
|
||||
vyzaduj_reply: bool = True,
|
||||
) -> str | None:
|
||||
"""
|
||||
Pošle označenou otázku ("[agent] otázka") a BLOKUJÍCÍ čeká na odpověď.
|
||||
|
||||
Při více agentech naráz se odpovědi rozlišují přes Telegram **Reply**:
|
||||
bere jen tu příchozí zprávu, která je Reply na právě odeslanou otázku.
|
||||
|
||||
:param agent: jméno agenta (objeví se v textu otázky jako štítek)
|
||||
:param otazka: text otázky
|
||||
:param komu: kam poslat ("me" = Saved Messages | "@username" | id)
|
||||
:param session: jméno session; výchozí = `agent` (každý agent svůj soubor)
|
||||
:param timeout: celkové čekání v sekundách (pak vrátí None)
|
||||
:param poll_interval: jak často kontrolovat nové zprávy (s)
|
||||
:param vyzaduj_reply: True = bere jen Reply na svou otázku (bezpečné pro víc agentů);
|
||||
False = vezme první příchozí zprávu (jen pro 1 agenta)
|
||||
:return: text odpovědi, nebo None při timeoutu
|
||||
"""
|
||||
session = session or agent
|
||||
with _new_client(session) as client:
|
||||
if not client.is_user_authorized():
|
||||
raise RuntimeError(
|
||||
f"Session '{session}' není přihlášena — "
|
||||
f"spusť: python -m Knihovny.telegram_user login --jako {session}"
|
||||
)
|
||||
|
||||
sent = client.send_message(komu, f"[{agent}] {otazka}")
|
||||
qid = sent.id
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
# jen zprávy novější než naše otázka, od nejstarší
|
||||
for msg in client.iter_messages(komu, min_id=qid, reverse=True):
|
||||
if msg.out:
|
||||
continue # naše vlastní zpráva
|
||||
if vyzaduj_reply:
|
||||
if msg.reply_to_msg_id == qid:
|
||||
return msg.text
|
||||
else:
|
||||
return msg.text
|
||||
zbyva = deadline - time.monotonic()
|
||||
if zbyva <= 0:
|
||||
break
|
||||
time.sleep(min(poll_interval, max(1, zbyva)))
|
||||
return None
|
||||
|
||||
|
||||
def _safe_print(text: str):
|
||||
try:
|
||||
print(text)
|
||||
except UnicodeEncodeError:
|
||||
print(text.encode("ascii", "replace").decode("ascii"))
|
||||
|
||||
|
||||
def _main():
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
parser = argparse.ArgumentParser(prog="telegram_user", description="Telegram user účet (Telethon)")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_login = sub.add_parser("login", help="jednorázové přihlášení session")
|
||||
p_login.add_argument("--jako", dest="jako", default=None, help="jméno session/agenta")
|
||||
|
||||
p_send = sub.add_parser("send", help="poslat zprávu")
|
||||
p_send.add_argument("komu", help='"me" | "@username" | telefon | id')
|
||||
p_send.add_argument("text", help="text zprávy")
|
||||
p_send.add_argument("--jako", dest="jako", default=None, help="jméno session")
|
||||
|
||||
p_ask = sub.add_parser("ask", help="poslat otázku a počkat na Reply odpověď")
|
||||
p_ask.add_argument("agent", help="jméno agenta (štítek + výchozí session)")
|
||||
p_ask.add_argument("text", help="text otázky")
|
||||
p_ask.add_argument("--komu", dest="komu", default="me", help='kam (výchozí "me")')
|
||||
p_ask.add_argument("--timeout", dest="timeout", type=int, default=240, help="čekání v s")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.cmd == "login":
|
||||
prihlas(args.jako)
|
||||
elif args.cmd == "send":
|
||||
posli_jako_ja(args.komu, args.text, session=args.jako)
|
||||
_safe_print("Odesláno OK")
|
||||
elif args.cmd == "ask":
|
||||
odp = zeptej_se_jako(args.agent, args.text, komu=args.komu, timeout=args.timeout)
|
||||
if odp is None:
|
||||
_safe_print("(bez odpovědi — vypršel timeout)")
|
||||
sys.exit(2)
|
||||
_safe_print(odp)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
@@ -0,0 +1,95 @@
|
||||
# MedicusFirebird — Firebird 2.5 zrcadlo Medicus DB na toweru
|
||||
|
||||
Kontejnerizované zrcadlo ostré Medicus databáze (Firebird) na serveru **tower** (Unraid, 192.168.1.76).
|
||||
Nahrazuje dosavadní restore na Windows VM **reporter** — tu lze po ověření na Firebird části vypnout.
|
||||
|
||||
## Proč
|
||||
|
||||
Všechny ostatní DB (MySQL, PostgreSQL, MongoDB, Redis) běží na toweru jako Docker.
|
||||
Firebird sem logicky patří taky: jeden host, jeden režim záloh/monitoringu, žádná Windows VM navíc.
|
||||
|
||||
## Tok dat
|
||||
|
||||
```
|
||||
Ordinace: gbak -> zip -> rsync na tower (~02:15)
|
||||
Tower: /mnt/user/OrdinaceSynology/MedicusBackup/MEDICUS_RRMMDD_HHMM.zip (zalohy se HROMADI)
|
||||
restore_medicus.sh (denne):
|
||||
1) nejnovejsi MEDICUS_*.zip podle nazvu; pokud == last_restored.txt -> skip
|
||||
2) pocka az velikost prestane rust (probiha-li jeste rsync) + overi unzip -t
|
||||
3) unzip .fbk -> gbak -r do medicus_new.fdb -> stop+swap+start kontejneru
|
||||
4) zapise marker (last_restored.txt)
|
||||
5) GFS retence zaloh (prune_backups.sh)
|
||||
Kontejner: firebird-medicus -> serve tower:3050 /firebird/data/medicus.fdb
|
||||
```
|
||||
|
||||
Zdrojová DB: **Firebird 2.5.7**, ODS 11.2, dialect 3, page size 8192.
|
||||
Image: `jacobalberty/firebird:2.5-ss` = **Firebird 2.5.9** (restore 2.5.7 → 2.5.9 v rámci řady OK).
|
||||
|
||||
## Soubory
|
||||
|
||||
| Soubor | Popis |
|
||||
|--------|-------|
|
||||
| `firebird_create.sh` | Jednorázové vytvoření / znovuvytvoření kontejneru |
|
||||
| `restore_medicus.sh` | Denní rutina: obnova z nejnovější zálohy + retence (cron) |
|
||||
| `prune_backups.sh` | GFS retence záloh (volá se z restore; lze i samostatně) |
|
||||
| `verify_firebird.sh` | Kontrola: verze enginu, ODS, počet pacientů |
|
||||
| `last_restored.txt` | Marker poslední úspěšně restorované zálohy (vzniká za běhu) |
|
||||
| `restore.log` | Log denních běhů |
|
||||
|
||||
## Umístění na toweru
|
||||
|
||||
- Skripty: `/mnt/user/Scripts/MedicusFirebird/`
|
||||
- Data kontejneru: `/mnt/user/appdata/firebird-medicus/fb` (→ `/firebird`, soubor `data/medicus.fdb`)
|
||||
- Rozbalovací prostor pro `.fbk`: `/mnt/user/appdata/firebird-medicus/work` (→ `/work`)
|
||||
|
||||
## Kontejner
|
||||
|
||||
```
|
||||
docker run -d --name firebird-medicus --restart unless-stopped \
|
||||
-p 3050:3050 -e ISC_PASSWORD=masterkey -e TZ=Europe/Prague \
|
||||
-v /mnt/user/appdata/firebird-medicus/fb:/firebird \
|
||||
-v /mnt/user/appdata/firebird-medicus/work:/work \
|
||||
jacobalberty/firebird:2.5-ss
|
||||
```
|
||||
|
||||
Pozn.: `gbak`/`isql` jsou v `/usr/local/firebird/bin/` (nejsou v PATH → volat plnou cestou).
|
||||
Hesla jsou v `security2.fdb` (nastaveno přes `ISC_PASSWORD`), ne v `medicus.fdb` — restore dat heslo nemění.
|
||||
|
||||
## Robustnost restoru
|
||||
|
||||
Zálohy se v adresáři **hromadí** a nejnovější se může právě **přenášet přes rsync**, proto:
|
||||
- výběr nejnovější podle **názvu** (`RRMMDD_HHMM` → lexikálně = chronologicky)
|
||||
- **stav** v `last_restored.txt` → když není nic novějšího, nic se nedělá
|
||||
- **čeká na dokončení přenosu** (velikost se ustálí) a ověří integritu `unzip -t` — nikdy nezpracuje nekompletní soubor
|
||||
- marker se zapíše **až po úspěšném** restoru; zámek (`flock`) proti souběhu
|
||||
|
||||
## Retence záloh (GFS, sekvenční, počítaná)
|
||||
|
||||
`prune_backups.sh` drží v adresáři záloh schéma:
|
||||
1. **30 posledních dní** — nech všechny denní
|
||||
2. **pak 8 týdnů** — z každého ISO-týdne 1× (nejnovější = konec týdne)
|
||||
3. **pak 12 měsíců** — z každého měsíce 1× (nejnovější)
|
||||
4. starší → smazat
|
||||
|
||||
Datum se čte **z názvu** (ne mtime). Neparsovatelné názvy se nikdy nemažou.
|
||||
Bezpečnostní přepínač `DRY_RUN=1` (jen výpis) / `DRY_RUN=0` (maže). V denní rutině řízeno `RETENTION_DRYRUN`
|
||||
v `restore_medicus.sh` (ostré = 0).
|
||||
|
||||
## Připojení klientů (fdb / DSN)
|
||||
|
||||
```
|
||||
192.168.1.76:/firebird/data/medicus.fdb SYSDBA / masterkey, charset win1250
|
||||
# nebo: tower:/firebird/data/medicus.fdb
|
||||
```
|
||||
|
||||
V `Knihovny/medicus_db.py` je odpovídající záznam v `dsn_map` (klíč `TOWER`).
|
||||
Cutover skriptů/MCP z reporteru (2.5.7) na tower (2.5.9) = otevřené rozhodnutí.
|
||||
|
||||
## Cron (na toweru)
|
||||
|
||||
Záloha přistává ~02:15; denní rutina poté. Plánovat přes **User Scripts plugin**
|
||||
(vzor: `PostgreSQLRestoreFromBackup`), spouštět:
|
||||
|
||||
```
|
||||
/mnt/user/Scripts/MedicusFirebird/restore_medicus.sh # napr. 06:30 denne
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# Vytvori (nebo znovuvytvori) Firebird 2.5 kontejner = zrcadlo Medicus DB na toweru.
|
||||
# Spousti se jednorazove pri zakladani / zmene konfigurace.
|
||||
set -euo pipefail
|
||||
|
||||
NAME=firebird-medicus
|
||||
IMAGE=jacobalberty/firebird:2.5-ss
|
||||
APPDATA=/mnt/user/appdata/firebird-medicus
|
||||
FBDIR="$APPDATA/fb" # -> /firebird (data, system, security2.fdb)
|
||||
WORKDIR="$APPDATA/work" # -> /work (sem se rozbaluje .fbk pred restorem)
|
||||
PASS=masterkey
|
||||
|
||||
mkdir -p "$FBDIR" "$WORKDIR"
|
||||
|
||||
# odstran stary kontejner, pokud existuje (data v appdata zustanou)
|
||||
docker rm -f "$NAME" 2>/dev/null || true
|
||||
|
||||
docker run -d \
|
||||
--name "$NAME" \
|
||||
--restart unless-stopped \
|
||||
-p 3050:3050 \
|
||||
-e ISC_PASSWORD="$PASS" \
|
||||
-e TZ=Europe/Prague \
|
||||
-v "$FBDIR":/firebird \
|
||||
-v "$WORKDIR":/work \
|
||||
"$IMAGE"
|
||||
|
||||
echo "Kontejner $NAME vytvoren. Cekam na start serveru..."
|
||||
sleep 10
|
||||
docker ps --filter "name=$NAME" --format "{{.Names}} {{.Status}} {{.Ports}}"
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/bin/bash
|
||||
# GFS retence PLNYCH zaloh Medicus (kazda zaloha je kompletni -> mazani ostatnich je bezpecne).
|
||||
#
|
||||
# SEKVENCNI, POCITANE tiery (jdou ZA sebou, neprekryvaji se), od nejnovejsiho zpet:
|
||||
# 1) DENNI : poslednich 30 dni -> nech VSECHNY
|
||||
# 2) TYDENNI : pak presne 8 tydnu dozadu -> z kazdeho ISO-tydne 1x (nejnovejsi = konec tydne)
|
||||
# 3) MESICNI : pak presne 12 mesicu dozadu -> z kazdeho mesice 1x (nejnovejsi)
|
||||
# 4) starsi : smazat
|
||||
# Reference "ted" = datum NEJNOVEJSI zalohy. Datum se cte Z NAZVU MEDICUS_RRMMDD_HHMM.zip.
|
||||
#
|
||||
# BEZPECNOST: DRY_RUN=1 (default) jen vypisuje, NIC nemaze. DRY_RUN=0 skutecne maze.
|
||||
# Neznamy/neparsovatelny nazev se NIKDY nemaze.
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR="${BACKUP_DIR:-/mnt/user/OrdinaceSynology/MedicusBackup}"
|
||||
DAILY_DAYS="${DAILY_DAYS:-30}"
|
||||
WEEKLY_WEEKS="${WEEKLY_WEEKS:-8}"
|
||||
MONTHLY_MONTHS="${MONTHLY_MONTHS:-12}"
|
||||
DRY_RUN="${DRY_RUN:-1}"
|
||||
|
||||
date_from_name() { local d="${1#MEDICUS_}"; d="${d:0:6}"; echo "20${d:0:2}-${d:2:2}-${d:4:2}"; }
|
||||
|
||||
mapfile -t FILES < <(cd "$BACKUP_DIR" && ls -1 MEDICUS_*.zip 2>/dev/null | sort -r) # nejnovejsi prvni
|
||||
[ "${#FILES[@]}" -eq 0 ] && { echo "Zadne zalohy v $BACKUP_DIR"; exit 0; }
|
||||
|
||||
REF=$(date_from_name "${FILES[0]}")
|
||||
date -d "$REF" >/dev/null 2>&1 || { echo "CHYBA: nelze precist datum z ${FILES[0]}"; exit 1; }
|
||||
D_CUT=$(date -d "$REF -${DAILY_DAYS} days" +%F)
|
||||
echo "REF=$REF denni>=$D_CUT, pak ${WEEKLY_WEEKS}x tydenni, pak ${MONTHLY_MONTHS}x mesicni (starsi smazat)"
|
||||
|
||||
declare -A KEEP seen_week seen_month
|
||||
dn=0; w=0; m=0
|
||||
for f in "${FILES[@]}"; do
|
||||
dt=$(date_from_name "$f")
|
||||
if ! date -d "$dt" >/dev/null 2>&1; then KEEP[$f]="?"; continue; fi # neparsovatelne -> ponechat
|
||||
if [[ ! "$dt" < "$D_CUT" ]]; then KEEP[$f]="d"; dn=$((dn+1)); continue; fi # 1) denni (30 dni)
|
||||
if [ "$w" -lt "$WEEKLY_WEEKS" ]; then # 2) tydenni (8x)
|
||||
wk=$(date -d "$dt" +%G-%V)
|
||||
[ -z "${seen_week[$wk]:-}" ] && { seen_week[$wk]=1; w=$((w+1)); KEEP[$f]="w"; }
|
||||
continue
|
||||
fi
|
||||
if [ "$m" -lt "$MONTHLY_MONTHS" ]; then # 3) mesicni (12x)
|
||||
mo=$(date -d "$dt" +%Y-%m)
|
||||
[ -z "${seen_month[$mo]:-}" ] && { seen_month[$mo]=1; m=$((m+1)); KEEP[$f]="m"; }
|
||||
continue
|
||||
fi
|
||||
done
|
||||
|
||||
mode="DRY-RUN (nic se nemaze)"; [ "$DRY_RUN" = "0" ] && mode="OSTRY (maze!)"
|
||||
echo "=== GFS retence $mode | $BACKUP_DIR ==="
|
||||
echo "schema: ${DAILY_DAYS}d / ${WEEKLY_WEEKS}t / ${MONTHLY_MONTHS}m | celkem: ${#FILES[@]} | ponechano: ${#KEEP[@]} (denni=$dn tydenni=$w mesicni=$m)"
|
||||
|
||||
del=0
|
||||
for f in "${FILES[@]}"; do
|
||||
if [ -n "${KEEP[$f]:-}" ]; then
|
||||
printf ' KEEP [%s] %s\n' "${KEEP[$f]}" "$f"
|
||||
else
|
||||
printf ' DEL %s\n' "$f"; del=$((del+1))
|
||||
[ "$DRY_RUN" = "0" ] && rm -f -- "$BACKUP_DIR/$f"
|
||||
fi
|
||||
done
|
||||
echo "=== ke smazani: $del ==="
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/bin/bash
|
||||
# Denni obnova zrcadla Medicus DB z nejnovejsi gbak zalohy do Firebird kontejneru
|
||||
# + GFS retence zaloh.
|
||||
#
|
||||
# Zalohy se v adresari HROMADI a nejnovejsi se muze prave PRENASET pres rsync. Proto:
|
||||
# - vybira nejnovejsi MEDICUS_*.zip podle nazvu (RRMMDD_HHMM -> lexikalne = chronologicky)
|
||||
# - pamatuje si posledni uspesne restorovanou (last_restored.txt) -> neni-li nic novejsiho, konci
|
||||
# - ceka, az velikost prestane rust (probiha-li rsync), a overi integritu (unzip -t),
|
||||
# teprve pak restoruje -> nikdy nezpracuje nekompletni prenos
|
||||
# - marker se zapise az PO uspesnem restoru
|
||||
# - na konci spusti GFS retenci zaloh (prune_backups.sh)
|
||||
set -euo pipefail
|
||||
|
||||
NAME=firebird-medicus
|
||||
APPDATA=/mnt/user/appdata/firebird-medicus
|
||||
DATA="$APPDATA/fb/data"
|
||||
WORK="$APPDATA/work"
|
||||
BACKUP_DIR=/mnt/user/OrdinaceSynology/MedicusBackup
|
||||
GBAK=/usr/local/firebird/bin/gbak
|
||||
PASS=masterkey
|
||||
BASE_DIR=/mnt/user/Scripts/MedicusFirebird
|
||||
STATE="$BASE_DIR/last_restored.txt"
|
||||
LOG="$BASE_DIR/restore.log"
|
||||
|
||||
# Retence: ostra (maze dle GFS 30d/8t/12m). Pro testovaci beh prepnout na 1 (jen vypis).
|
||||
RETENTION_DRYRUN="${RETENTION_DRYRUN:-0}"
|
||||
|
||||
exec >>"$LOG" 2>&1
|
||||
exec 9>"$BASE_DIR/.restore.lock"
|
||||
flock -n 9 || { echo "$(date '+%F %T') jiny restore uz bezi -> koncim."; exit 0; }
|
||||
echo "===== $(date '+%F %T') restore start ====="
|
||||
|
||||
mkdir -p "$WORK" "$DATA"
|
||||
|
||||
# --- 1) nejnovejsi zaloha podle nazvu ---
|
||||
ZIP=$(ls -1 "$BACKUP_DIR"/MEDICUS_*.zip 2>/dev/null | sort | tail -1 || true)
|
||||
if [ -z "${ZIP:-}" ]; then echo "CHYBA: zadna zaloha v $BACKUP_DIR"; exit 1; fi
|
||||
ZIP_BASE=$(basename "$ZIP")
|
||||
LAST=$(cat "$STATE" 2>/dev/null || echo "")
|
||||
echo "Nejnovejsi: $ZIP_BASE | posledni restorovana: ${LAST:-<zadna>}"
|
||||
|
||||
# --- 2) uz restorovana? ---
|
||||
if [ "$ZIP_BASE" = "$LAST" ]; then
|
||||
echo "Nic noveho -> restore preskocen."
|
||||
else
|
||||
# --- 3) pockej na dokonceni prenosu (velikost se ustali) ---
|
||||
prev=-1; stable=0
|
||||
for i in $(seq 1 80); do # max ~20 min cekani na rsync
|
||||
cur=$(stat -c %s "$ZIP" 2>/dev/null || echo 0)
|
||||
if [ "$cur" = "$prev" ] && [ "$cur" -gt 0 ]; then stable=1; break; fi
|
||||
echo " ...$ZIP_BASE = $cur B, cekam na ustaleni"
|
||||
prev=$cur; sleep 15
|
||||
done
|
||||
[ "$stable" = "1" ] || { echo "CHYBA: $ZIP_BASE se stale meni -> koncim (priste)."; exit 1; }
|
||||
|
||||
# --- 4) integrita (nekompletni/poskozeny zip neprojde) ---
|
||||
echo "unzip -t ..."
|
||||
unzip -tqq "$ZIP" || { echo "CHYBA: $ZIP_BASE neprosel unzip -t -> koncim."; exit 1; }
|
||||
|
||||
# --- 5) rozbaleni .fbk ---
|
||||
rm -f "$WORK"/*.fbk
|
||||
unzip -o "$ZIP" -d "$WORK" >/dev/null
|
||||
FBK=$(ls -1t "$WORK"/*.fbk | head -1)
|
||||
FBK_BASE=$(basename "$FBK")
|
||||
echo "FBK: $FBK_BASE ($(du -h "$FBK" | cut -f1))"
|
||||
|
||||
# --- 6) restore pres bezici server do noveho souboru ---
|
||||
docker start "$NAME" >/dev/null 2>&1 || true
|
||||
sleep 8
|
||||
echo "gbak restore -> medicus_new.fdb ..."
|
||||
docker exec "$NAME" rm -f /firebird/data/medicus_new.fdb
|
||||
docker exec "$NAME" "$GBAK" -r -p 8192 -user SYSDBA -password "$PASS" \
|
||||
"/work/$FBK_BASE" "localhost:/firebird/data/medicus_new.fdb"
|
||||
|
||||
# --- 7) atomicky swap + restart ---
|
||||
echo "swap + restart ..."
|
||||
docker stop "$NAME" >/dev/null
|
||||
mv -f "$DATA/medicus_new.fdb" "$DATA/medicus.fdb"
|
||||
docker start "$NAME" >/dev/null
|
||||
sleep 8
|
||||
rm -f "$WORK"/*.fbk
|
||||
|
||||
# --- 8) marker az po uspechu ---
|
||||
echo "$ZIP_BASE" > "$STATE"
|
||||
echo "restore OK: $ZIP_BASE"
|
||||
fi
|
||||
|
||||
# --- 9) GFS retence zaloh ---
|
||||
echo "--- retence zaloh (DRY_RUN=$RETENTION_DRYRUN) ---"
|
||||
DRY_RUN="$RETENTION_DRYRUN" "$BASE_DIR/prune_backups.sh" || echo "VAROVANI: prune_backups.sh selhal"
|
||||
|
||||
echo "===== $(date '+%F %T') hotovo ====="
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# Rychla kontrola obnoveneho zrcadla: verze enginu, ODS, pocet pacientu.
|
||||
set -euo pipefail
|
||||
NAME=firebird-medicus
|
||||
PASS=masterkey
|
||||
ISQL=/usr/local/firebird/bin/isql
|
||||
DB=localhost:/firebird/data/medicus.fdb
|
||||
|
||||
docker exec -i "$NAME" "$ISQL" -user SYSDBA -password "$PASS" "$DB" <<'SQL'
|
||||
SELECT rdb$get_context('SYSTEM','ENGINE_VERSION') AS ENGINE FROM rdb$database;
|
||||
SELECT MON$ODS_MAJOR AS ODS_MAJOR, MON$ODS_MINOR AS ODS_MINOR, MON$PAGE_SIZE AS PAGE_SIZE FROM MON$DATABASE;
|
||||
SELECT COUNT(*) AS PACIENTU FROM KAR;
|
||||
QUIT;
|
||||
SQL
|
||||
+22
-10
@@ -4,18 +4,22 @@ Hledá ve schránce **ordinace@buzalkova.cz** e-maily, kde pacient žádá
|
||||
o předepsání léku (recept), vytěžuje pacienta + požadované léky a pacienta
|
||||
ověřuje v kartotéce Medicusu.
|
||||
|
||||
## Stav: testovací režim (read-only)
|
||||
## Stav: nasazeno na toweru (produkce), DELTA režim
|
||||
|
||||
`recepty_agent.py` zatím jen načte `NEWEST_N` (= 5) nejnovějších mailů
|
||||
z Inboxu, klasifikuje je Claude modelem a vypíše report do konzole
|
||||
a `_log_recepty.txt`. **Ve schránce nic nemění** (žádné kategorie,
|
||||
přesuny, odpovědi), žádný `state.json`.
|
||||
`recepty_agent.py` zpracuje **všechny nové maily od posledního zpracovaného**
|
||||
(vodoznak), klasifikuje Claude modelem, identifikuje pacienta a u vysoké jistoty
|
||||
založí požadavek v Medeviu, jinak dá dotaz do fronty (Telegram). Označuje maily
|
||||
kategoriemi. Běží na toweru — viz „Nasazení na tower" níže.
|
||||
|
||||
## Tok
|
||||
|
||||
1. **Graph API** — `newest_inbox_messages()`: N nejnovějších mailů z Inboxu,
|
||||
bez filtru na přílohy (`$orderby receivedDateTime desc` — bez filtru
|
||||
funguje, na rozdíl od kombinace s `hasAttachments`, viz EmailAgent).
|
||||
1. **Graph API — DELTA** `nove_inbox_messages(mailbox, since_iso)`: všechny maily
|
||||
s `receivedDateTime gt vodoznak`, řazeno vzestupně, stránkováno (max
|
||||
`MAX_PER_RUN`=200/běh). Vodoznak = `_last_processed.txt` (2 řádky: čas + ID
|
||||
posl. mailu). POZOR: Graph má sub-sekundovou přesnost, ale `receivedDateTime`
|
||||
se zobrazuje oříznutě na sekundy → `gt` vrací i hraniční už zpracovaný mail;
|
||||
ten se odfiltruje podle uloženého ID. První běh (vodoznak chybí) jen nastaví
|
||||
vodoznak na nejnovější mail a od příště jede dopředně (historie se nedohání).
|
||||
2. **AI klasifikace + vytěžení (Claude `claude-haiku-4-5`)** — pro každý mail
|
||||
JSON: `je_zadost_o_recept`, `pacient` (může se lišit od odesílatele —
|
||||
příbuzní píší za pacienta), `rodne_cislo` (přesně jak je v textu),
|
||||
@@ -162,11 +166,19 @@ založil „Recept na léky" správnému pacientovi (dotazník Název léků + P
|
||||
označené maily přeskočí (ještě před AI klasifikací). Maily čekající na
|
||||
odpověď přes Telegram se přeskočí podle `recept_pending.je_mail_pending`
|
||||
(znovu se neptá).
|
||||
- Pozor: agent čte jen `NEWEST_N` (5) nejnovějších mailů — hloub do inboxu
|
||||
nejde. Když je všech 5 nejnovějších označených, neudělá nic.
|
||||
- DELTA režim: zpracuje vše po vodoznaku (ne jen N nejnovějších). Strop
|
||||
`MAX_PER_RUN`=200/běh (kdyby byl vodoznak hodně zpět — zbytek dobere další běh).
|
||||
Vodoznak lze ručně posunout úpravou `_last_processed.txt` (např. backfill).
|
||||
|
||||
## Spuštění
|
||||
|
||||
Vývoj (notebook/Z230):
|
||||
```powershell
|
||||
python U:\ordinaceprojekt\OrdinaceAgentEmail\recepty_agent.py
|
||||
```
|
||||
|
||||
Produkce (tower, python-runner) — viz „Nasazení na tower":
|
||||
```
|
||||
docker exec -e PYTHONIOENCODING=utf-8 -e MEDICUS_FDB_DSN=192.168.1.76:/firebird/data/medicus.fdb \
|
||||
python-runner python /scripts/OrdinaceReceptAgent/OrdinaceAgentEmail/recepty_agent.py
|
||||
```
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
recept_dialog.py — formátování otázky do Telegramu a parsování odpovědi člověka.
|
||||
Čisté funkce bez vedlejších efektů (snadno testovatelné).
|
||||
"""
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
|
||||
def _bez_diakritiky(s: str) -> str:
|
||||
s = unicodedata.normalize("NFKD", s or "")
|
||||
return "".join(c for c in s if not unicodedata.combining(c))
|
||||
|
||||
|
||||
def format_otazka(rec: dict) -> str:
|
||||
"""Sestaví text otázky do Telegramu z pending záznamu."""
|
||||
sk = rec.get("skore")
|
||||
lines = ["🟡 Žádost o recept — nejistá identifikace"
|
||||
+ (f" (jistota {sk}/100)" if sk is not None else "")]
|
||||
if rec.get("sender"):
|
||||
lines.append(f"Od: {rec['sender']}")
|
||||
if rec.get("email_subject"):
|
||||
lines.append(f"Předmět: {rec['email_subject']}")
|
||||
if rec.get("leky_str"):
|
||||
lines.append(f"Léky: {rec['leky_str']}")
|
||||
if rec.get("duvody"):
|
||||
lines.append("Proč nejisté: " + "; ".join(rec["duvody"]))
|
||||
|
||||
kand = rec.get("kandidati") or []
|
||||
if kand:
|
||||
lines.append("")
|
||||
lines.append("Kandidáti:")
|
||||
for i, k in enumerate(kand, 1):
|
||||
lines.append(
|
||||
f" {i}) {k.get('prijmeni', '')} {k.get('jmeno', '')}"
|
||||
f", RČ {k.get('rc', '?')}, nar. {k.get('datnar', '?')}"
|
||||
f", poj. {k.get('poj', '?')}"
|
||||
)
|
||||
else:
|
||||
lines.append("")
|
||||
lines.append("(žádný kandidát v kartotéce)")
|
||||
|
||||
lines.append("")
|
||||
lines.append("Odpověz jako reply na tuto zprávu:")
|
||||
lines.append("• RČ správného pacienta (definitivní)")
|
||||
if kand:
|
||||
lines.append("• nebo číslo kandidáta (1, 2, …)")
|
||||
lines.append("• nebo „ne“ = nezakládat")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
_SKIP = {
|
||||
"ne", "nezakladat", "preskoc", "preskocit", "zahodit", "zahod",
|
||||
"ignoruj", "nic", "stop", "nezaklada", "nezakladej",
|
||||
}
|
||||
|
||||
|
||||
def parse_odpoved(text: str) -> dict:
|
||||
"""Rozparsuje odpověď člověka.
|
||||
|
||||
Vrací dict:
|
||||
{"akce": "rc", "rc": "7309208104"} – definitivní RČ pacienta
|
||||
{"akce": "kandidat", "index": 1} – výběr kandidáta podle pořadí
|
||||
{"akce": "preskoc"} – nezakládat
|
||||
{"akce": "nejasne"} – nerozpoznáno
|
||||
"""
|
||||
t = (text or "").strip()
|
||||
low = _bez_diakritiky(t).lower().strip().rstrip(".!")
|
||||
if low in _SKIP:
|
||||
return {"akce": "preskoc"}
|
||||
|
||||
digits = re.sub(r"\D", "", t)
|
||||
if len(digits) in (9, 10):
|
||||
return {"akce": "rc", "rc": digits}
|
||||
|
||||
m = re.fullmatch(r"\s*(\d{1,2})\s*", t)
|
||||
if m:
|
||||
return {"akce": "kandidat", "index": int(m.group(1))}
|
||||
|
||||
return {"akce": "nejasne"}
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
recept_pending.py — fronta "čekajících" žádostí o recept, u kterých si agent
|
||||
NENÍ jistý identifikací pacienta a potřebuje potvrzení člověka přes Telegram.
|
||||
|
||||
E-mailový agent sem zapíše záznam (stav 'ceka') a NIC dalšího nedělá.
|
||||
Resolver (recept_resolver.py) záznamy bere, pošle otázku do Telegramu a podle
|
||||
odpovědi člověka založí požadavek správnému pacientovi.
|
||||
|
||||
Úložiště: JSON soubor _pending_recepty.json vedle tohoto modulu (atomický zápis).
|
||||
Stavy záznamu: 'ceka' → 'zalozeno' | 'preskoceno'.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
STORE = Path(__file__).resolve().parent / "_pending_recepty.json"
|
||||
|
||||
|
||||
def _load() -> list:
|
||||
if not STORE.exists():
|
||||
return []
|
||||
try:
|
||||
return json.loads(STORE.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _save(items: list) -> None:
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
"w", encoding="utf-8", dir=str(STORE.parent), delete=False, suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
json.dump(items, tmp, ensure_ascii=False, indent=2)
|
||||
tmp.flush()
|
||||
os.fsync(tmp.fileno())
|
||||
tmp.close()
|
||||
os.replace(tmp.name, STORE)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp.name)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def pridej(*, email_message_id: str, email_subject: str = "", sender: str = "",
|
||||
leky_str: str = "", pozn_str: str = "", skore=None,
|
||||
duvody=None, kandidati=None) -> dict:
|
||||
"""Přidá nový čekající dotaz. leky_str/pozn_str se použijí při pozdějším
|
||||
založení požadavku, kandidáti se ukážou člověku v otázce."""
|
||||
items = _load()
|
||||
rec = {
|
||||
"id": uuid.uuid4().hex,
|
||||
"vytvoreno": datetime.now().isoformat(timespec="seconds"),
|
||||
"email_message_id": email_message_id,
|
||||
"email_subject": email_subject,
|
||||
"sender": sender,
|
||||
"leky_str": leky_str,
|
||||
"pozn_str": pozn_str,
|
||||
"skore": skore,
|
||||
"duvody": duvody or [],
|
||||
"kandidati": kandidati or [],
|
||||
"otazka_message_id": None, # vyplní resolver po odeslání otázky
|
||||
"stav": "ceka", # ceka | zalozeno | preskoceno
|
||||
"vysledek": None,
|
||||
}
|
||||
items.append(rec)
|
||||
_save(items)
|
||||
return rec
|
||||
|
||||
|
||||
def cekajici() -> list:
|
||||
return [r for r in _load() if r.get("stav") == "ceka"]
|
||||
|
||||
|
||||
def cekajici_bez_otazky() -> list:
|
||||
"""Záznamy, na které se ještě neposlala otázka do Telegramu."""
|
||||
return [r for r in _load()
|
||||
if r.get("stav") == "ceka" and not r.get("otazka_message_id")]
|
||||
|
||||
|
||||
def je_mail_pending(email_message_id: str) -> bool:
|
||||
return any(r.get("email_message_id") == email_message_id
|
||||
and r.get("stav") == "ceka" for r in _load())
|
||||
|
||||
|
||||
def najdi_dle_otazky(otazka_message_id) -> dict | None:
|
||||
if otazka_message_id is None:
|
||||
return None
|
||||
for r in _load():
|
||||
if r.get("otazka_message_id") == otazka_message_id:
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def aktualizuj(rec_id: str, **fields) -> dict | None:
|
||||
items = _load()
|
||||
out = None
|
||||
for r in items:
|
||||
if r.get("id") == rec_id:
|
||||
r.update(fields)
|
||||
out = r
|
||||
break
|
||||
if out is not None:
|
||||
_save(items)
|
||||
return out
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
recept_resolver.py — vyřizuje "čekající" žádosti o recept přes Telegram.
|
||||
|
||||
Jediný proces, který mluví s Telegramem (kvůli getUpdates). Ve smyčce:
|
||||
1) pošle otázky pro nové pending záznamy (které ještě otázku nemají),
|
||||
2) long-polluje odpovědi; odpověď (reply na otázku) → podle obsahu:
|
||||
• RČ (9–10 číslic) → definitivní volba pacienta → založí požadavek
|
||||
• číslo kandidáta → vybere kandidáta → založí požadavek
|
||||
• „ne“ → přeskočí (nezakládá)
|
||||
Po založení označí původní e-mail kategorií ClaudeZpracovalRecept.
|
||||
|
||||
Telegram přenos je v recept_telegram.py (token/chat dodá uživatel).
|
||||
Bez odpovědi záznam zůstává 'ceka' (čeká se libovolně dlouho).
|
||||
|
||||
Spuštění: python recept_resolver.py
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE.parent / "EmailAgent"))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
import recept_pending as PEND # noqa: E402
|
||||
import recept_dialog as DLG # noqa: E402
|
||||
import recept_telegram as TG # noqa: E402
|
||||
import graph_mail # noqa: E402 (značení mailu)
|
||||
import mcp_medevio as MED # noqa: E402 (zaloz_pozadavek_recept)
|
||||
from Knihovny.mysql_db import connect_mysql # noqa: E402
|
||||
|
||||
MAILBOX = "ordinace@buzalkova.cz"
|
||||
PROCESSED_CATEGORY = "ClaudeZpracovalRecept"
|
||||
SINCE_FILE = HERE / "_resolver_since.txt" # poslední zpracované message_id
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
|
||||
|
||||
|
||||
def _norm_rc(s: str) -> str:
|
||||
return re.sub(r"\D", "", s or "")
|
||||
|
||||
|
||||
def najdi_pacienta_dle_rc(rc: str):
|
||||
"""RČ → (uuid, jmeno, prijmeni) z medevio_pacient, nebo None."""
|
||||
rc = _norm_rc(rc)
|
||||
if not rc:
|
||||
return None
|
||||
try:
|
||||
conn = connect_mysql()
|
||||
conn.ping(reconnect=True)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT patient_id, name, surname FROM medevio_pacient "
|
||||
"WHERE REPLACE(identification_number,'/','') = %s LIMIT 1",
|
||||
[rc],
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row if row else None
|
||||
except Exception as e:
|
||||
log(f"[uuid lookup chyba] {type(e).__name__}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _load_since():
|
||||
try:
|
||||
return int(SINCE_FILE.read_text(encoding="utf-8").strip())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _save_since(s: int) -> None:
|
||||
SINCE_FILE.write_text(str(s), encoding="utf-8")
|
||||
|
||||
|
||||
def posli_nove_otazky() -> None:
|
||||
"""Pošle otázku do Telegramu pro každý pending záznam bez otázky."""
|
||||
for rec in PEND.cekajici_bez_otazky():
|
||||
try:
|
||||
mid = TG.posli_otazku(DLG.format_otazka(rec))
|
||||
PEND.aktualizuj(rec["id"], otazka_message_id=mid)
|
||||
log(f"otázka odeslána (pending {rec['id'][:8]}, msg {mid})")
|
||||
except Exception as e:
|
||||
log(f"otázku nelze odeslat ({type(e).__name__}: {e}) — zkusím příště")
|
||||
break # nejspíš výpadek / nenakonfigurováno → nech na příští kolo
|
||||
|
||||
|
||||
def _zaloz_pro_rc(rec: dict, rc: str) -> None:
|
||||
info = najdi_pacienta_dle_rc(rc)
|
||||
if not info:
|
||||
TG.posli_zpravu(f"⚠ RČ {rc} není v Medeviu — nezakládám. Zkus jiné RČ.")
|
||||
return
|
||||
uuid_, jmeno, prijmeni = info[0], info[1], info[2]
|
||||
try:
|
||||
res = MED.zaloz_pozadavek_recept(
|
||||
uuid_, rec.get("leky_str", ""), rec.get("pozn_str", "")
|
||||
)
|
||||
except Exception as e:
|
||||
TG.posli_zpravu(f"❌ Chyba při zakládání: {type(e).__name__}: {e}")
|
||||
return
|
||||
PEND.aktualizuj(rec["id"], stav="zalozeno",
|
||||
vysledek={"request_id": res["request_id"], "rc": rc,
|
||||
"pacient": f"{prijmeni} {jmeno}"})
|
||||
try:
|
||||
graph_mail.add_category(MAILBOX, rec["email_message_id"], PROCESSED_CATEGORY)
|
||||
except Exception as e:
|
||||
log(f"[mail označení] {type(e).__name__}: {e}")
|
||||
TG.posli_zpravu(f"✅ Založeno: {prijmeni} {jmeno} (RČ {rc}) — "
|
||||
f"{rec.get('leky_str', '')}")
|
||||
log(f"založeno {res['request_id']} pro {prijmeni} {jmeno}")
|
||||
|
||||
|
||||
def zpracuj_odpoved(u: dict) -> None:
|
||||
"""Zpracuje jednu příchozí Telegram zprávu."""
|
||||
rid = u.get("reply_to_message_id")
|
||||
rec = PEND.najdi_dle_otazky(rid) if rid else None
|
||||
|
||||
if rec is None:
|
||||
cekaji = PEND.cekajici()
|
||||
if len(cekaji) == 1:
|
||||
rec = cekaji[0] # jediný čekající → ber to na něj
|
||||
else:
|
||||
if cekaji:
|
||||
TG.posli_zpravu("Odpověz prosím jako reply na konkrétní dotaz "
|
||||
"(čeká jich víc).")
|
||||
return
|
||||
|
||||
if rec.get("stav") != "ceka":
|
||||
return
|
||||
|
||||
d = DLG.parse_odpoved(u.get("text", ""))
|
||||
if d["akce"] == "preskoc":
|
||||
PEND.aktualizuj(rec["id"], stav="preskoceno")
|
||||
TG.posli_zpravu("OK, nezakládám.")
|
||||
elif d["akce"] == "rc":
|
||||
_zaloz_pro_rc(rec, d["rc"])
|
||||
elif d["akce"] == "kandidat":
|
||||
kand = rec.get("kandidati") or []
|
||||
i = d["index"] - 1
|
||||
if 0 <= i < len(kand):
|
||||
_zaloz_pro_rc(rec, kand[i].get("rc", ""))
|
||||
else:
|
||||
TG.posli_zpravu(f"Kandidát {d['index']} neexistuje (mám {len(kand)}).")
|
||||
else:
|
||||
TG.posli_zpravu("Nerozumím. Pošli RČ pacienta, číslo kandidáta, nebo „ne“.")
|
||||
|
||||
|
||||
def smycka(poll_s: int = 5) -> None:
|
||||
try:
|
||||
TG.priprav() # naprimuj entitu Vlada (jinak fresh session spadne)
|
||||
except Exception as e:
|
||||
log(f"[priprav] {type(e).__name__}: {e}")
|
||||
since = _load_since()
|
||||
if since is None:
|
||||
# první start — vezmi aktuální stav jako základ, starou historii ignoruj
|
||||
since = TG.baseline_since()
|
||||
_save_since(since)
|
||||
log(f"baseline since_id={since}")
|
||||
log("Resolver běží (Ctrl+C ukončí).")
|
||||
while True:
|
||||
try:
|
||||
posli_nove_otazky()
|
||||
nove, since = TG.nacti_odpovedi(since)
|
||||
for u in nove:
|
||||
zpracuj_odpoved(u)
|
||||
_save_since(since)
|
||||
except KeyboardInterrupt:
|
||||
log("Konec.")
|
||||
break
|
||||
except Exception as e:
|
||||
log(f"[smyčka] {type(e).__name__}: {e}")
|
||||
time.sleep(poll_s)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
smycka()
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
recept_telegram.py — Telegram přenos pro recept-resolver.
|
||||
|
||||
Používá USER účet agenta (Claude Buzalka @vlado_claude_agent) přes Telethon
|
||||
z Knihovny/telegram_user.py — VLASTNÍ session "recepty" (per-agent autorizace).
|
||||
Odpovědi se párují přes Telegram reply (reply_to_msg_id), takže víc agentů na
|
||||
témž účtu se nepoplete a každý si hlídá jen své odpovědi.
|
||||
|
||||
JEDNORÁZOVÝ KROK (dělá uživatel v terminálu — čeká na SMS kód):
|
||||
python -m Knihovny.telegram_user login --jako recepty
|
||||
|
||||
Konfigurace (Medevio/.env): TELEGRAM_API_ID, TELEGRAM_API_HASH, TELEGRAM_PHONE
|
||||
(viz Trilium „2026-06-14 Telegram — bot, user účet agenta a MCP server").
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from Knihovny.telegram_user import posli_jako_ja, precti_zpravy, _new_client # noqa: E402
|
||||
|
||||
# Vlastní session recept-agenta (jedno přihlášení: login --jako recepty).
|
||||
SESSION = "recepty"
|
||||
|
||||
# Komu agent píše = Vladův hlavní účet (z Trilium / TELEGRAM_CHAT_ID).
|
||||
VLADO_UID = int(os.environ.get("TELEGRAM_CHAT_ID", "6639316354"))
|
||||
|
||||
|
||||
def priprav() -> None:
|
||||
"""Načte dialogy → do session cache se uloží entita Vlada. Nová session by ji
|
||||
jinak neznala a posílání by spadlo na 'Could not find the input entity'.
|
||||
Volá se jednou při startu resolveru (idempotentní, levné)."""
|
||||
with _new_client(SESSION) as client:
|
||||
if client.is_user_authorized():
|
||||
client.get_dialogs(limit=50)
|
||||
|
||||
|
||||
def posli_otazku(text: str) -> int:
|
||||
"""Pošle otázku Vladovi z účtu agenta. Vrátí message_id (pro párování reply)."""
|
||||
msg = posli_jako_ja(VLADO_UID, text, session=SESSION)
|
||||
return msg.id
|
||||
|
||||
|
||||
def posli_zpravu(text: str) -> None:
|
||||
"""Pošle prostou zprávu (potvrzení / chybu)."""
|
||||
posli_jako_ja(VLADO_UID, text, session=SESSION)
|
||||
|
||||
|
||||
def baseline_since() -> int:
|
||||
"""Aktuální nejvyšší message_id v chatu — výchozí bod při prvním startu
|
||||
(aby resolver nezpracoval starou historii)."""
|
||||
zpravy = precti_zpravy(VLADO_UID, limit=1, session=SESSION)
|
||||
return max((z["id"] for z in zpravy), default=0)
|
||||
|
||||
|
||||
def nacti_odpovedi(since_id: int = 0, limit: int = 50):
|
||||
"""Vrátí (nove_odpovedi, novy_since_id).
|
||||
|
||||
Bere jen PŘÍCHOZÍ zprávy (ne naše) s id > since_id. Každá:
|
||||
{message_id, text, reply_to_message_id}
|
||||
"""
|
||||
zpravy = precti_zpravy(VLADO_UID, limit=limit, session=SESSION)
|
||||
out = []
|
||||
new_since = since_id
|
||||
for z in zpravy:
|
||||
mid = z["id"]
|
||||
if mid > new_since:
|
||||
new_since = mid
|
||||
if mid > since_id and not z["odeslal_ja"]:
|
||||
out.append({
|
||||
"message_id": mid,
|
||||
"text": z["text"],
|
||||
"reply_to_message_id": z["reply_na"],
|
||||
})
|
||||
out.sort(key=lambda x: x["message_id"]) # od nejstarší
|
||||
return out, new_since
|
||||
@@ -26,7 +26,7 @@ import os
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from datetime import date, timedelta
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
@@ -52,8 +52,11 @@ import recept_pending as _pending # noqa: E402 fronta dotazů (nejistá identi
|
||||
# =========================
|
||||
MAILBOX = "ordinace@buzalkova.cz"
|
||||
|
||||
# Kolik nejnovějších mailů z Inboxu zpracovat (testovací režim).
|
||||
NEWEST_N = 5
|
||||
# DELTA REŽIM: zpracují se všechny maily s receivedDateTime > vodoznak
|
||||
# (čas posledního zpracovaného mailu, uložen v _last_processed.txt). Při prvním
|
||||
# běhu (vodoznak chybí) se vodoznak nastaví na nejnovější mail v Inboxu a od
|
||||
# příště se zpracuje jen to, co přijde POTÉ (historie se nedohání).
|
||||
MAX_PER_RUN = 200 # pojistka: max mailů na jeden běh (kdyby byl vodoznak hodně zpět)
|
||||
|
||||
# Kategorie (štítek na mailu), kterou agent označí mail po úspěšném založení
|
||||
# požadavku v Medeviu. Při dalším běhu se takto označené maily přeskočí
|
||||
@@ -85,6 +88,7 @@ _cost = {"input_tokens": 0, "output_tokens": 0, "usd": 0.0, "calls": 0}
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
LOG_FILE = HERE / "_log_recepty.txt"
|
||||
WATERMARK_FILE = HERE / "_last_processed.txt" # ISO čas (receivedDateTime) posledního zpracovaného mailu
|
||||
|
||||
|
||||
# =========================
|
||||
@@ -112,18 +116,59 @@ def log(msg: str) -> None:
|
||||
# =========================
|
||||
# ČTENÍ MAILŮ (Graph, read-only)
|
||||
# =========================
|
||||
def newest_inbox_messages(mailbox: str, n: int) -> list[dict]:
|
||||
"""N nejnovějších mailů z Inboxu (bez filtru na přílohy), tělo jako text."""
|
||||
_SELECT = "id,subject,from,receivedDateTime,bodyPreview,body,categories"
|
||||
|
||||
|
||||
def _load_watermark() -> tuple[str | None, str | None]:
|
||||
"""Vrátí (receivedDateTime, id) posledního zpracovaného mailu (řádek 1 a 2
|
||||
v _last_processed.txt). id slouží k odfiltrování hraničního mailu, který se
|
||||
kvůli sub-sekundové přesnosti vrací i při filtru `gt` na oříznutý čas."""
|
||||
try:
|
||||
lines = WATERMARK_FILE.read_text(encoding="utf-8").splitlines()
|
||||
t = (lines[0].strip() if lines else "") or None
|
||||
i = (lines[1].strip() if len(lines) > 1 else "") or None
|
||||
return t, i
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
def _save_watermark(iso: str, msg_id: str = "") -> None:
|
||||
WATERMARK_FILE.write_text(f"{iso}\n{msg_id}\n", encoding="utf-8")
|
||||
|
||||
|
||||
def newest_received(mailbox: str) -> tuple[str, str]:
|
||||
"""(receivedDateTime, id) nejnovějšího mailu v Inboxu — seed vodoznaku při
|
||||
prvním běhu. Když je schránka prázdná, vrátí (aktuální UTC, '')."""
|
||||
url = f"{graph_mail.GRAPH}/users/{mailbox}/mailFolders/inbox/messages"
|
||||
params = {"$orderby": "receivedDateTime desc", "$select": "id,receivedDateTime", "$top": 1}
|
||||
r = requests.get(url, headers=graph_mail._headers(), params=params, timeout=60)
|
||||
r.raise_for_status()
|
||||
vals = r.json().get("value", [])
|
||||
if vals:
|
||||
return vals[0]["receivedDateTime"], vals[0]["id"]
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), ""
|
||||
|
||||
|
||||
def nove_inbox_messages(mailbox: str, since_iso: str) -> list[dict]:
|
||||
"""Všechny maily z Inboxu s receivedDateTime > since_iso, od NEJSTARŠÍHO.
|
||||
Stránkuje přes @odata.nextLink, max MAX_PER_RUN za jeden běh."""
|
||||
url = f"{graph_mail.GRAPH}/users/{mailbox}/mailFolders/inbox/messages"
|
||||
params = {
|
||||
"$orderby": "receivedDateTime desc",
|
||||
"$select": "id,subject,from,receivedDateTime,bodyPreview,body,categories",
|
||||
"$top": n,
|
||||
"$filter": f"receivedDateTime gt {since_iso}",
|
||||
"$orderby": "receivedDateTime asc",
|
||||
"$select": _SELECT,
|
||||
"$top": 50,
|
||||
}
|
||||
headers = {**graph_mail._headers(), "Prefer": 'outlook.body-content-type="text"'}
|
||||
out: list[dict] = []
|
||||
while url and len(out) < MAX_PER_RUN:
|
||||
r = requests.get(url, headers=headers, params=params, timeout=60)
|
||||
r.raise_for_status()
|
||||
return r.json().get("value", [])[:n]
|
||||
data = r.json()
|
||||
out.extend(data.get("value", []))
|
||||
url = data.get("@odata.nextLink")
|
||||
params = None # nextLink už nese všechny parametry
|
||||
return out[:MAX_PER_RUN]
|
||||
|
||||
|
||||
# =========================
|
||||
@@ -644,7 +689,7 @@ def _medevio_find_patient(rc_normalized: str) -> str | None:
|
||||
# =========================
|
||||
def main() -> None:
|
||||
log("\n" + "=" * 70)
|
||||
log(f"START — schránka={MAILBOX}, {NEWEST_N} nejnovějších mailů")
|
||||
log(f"START — schránka={MAILBOX}, DELTA režim (vše po posledním zpracovaném)")
|
||||
log(f"REŽIM: zakládá požadavky v Medeviu; zpracované maily značí štítkem "
|
||||
f"'{PROCESSED_CATEGORY}' (a příště přeskakuje)")
|
||||
|
||||
@@ -655,8 +700,26 @@ def main() -> None:
|
||||
log(f"[POZOR] kategorii '{PROCESSED_CATEGORY}' nelze zajistit "
|
||||
f"({type(e).__name__}: {e}) — chybí asi Mail.ReadWrite oprávnění")
|
||||
|
||||
msgs = newest_inbox_messages(MAILBOX, NEWEST_N)
|
||||
log(f"Načteno {len(msgs)} mailů.")
|
||||
watermark, last_id = _load_watermark()
|
||||
if watermark is None:
|
||||
# první běh — neznáme „poslední zpracovaný"; nastav vodoznak na nejnovější
|
||||
# mail a od příště zpracovávej jen to, co přijde potom (historie se nedohání).
|
||||
watermark, seed_id = newest_received(MAILBOX)
|
||||
_save_watermark(watermark, seed_id)
|
||||
log(f"První běh — vodoznak nastaven na {watermark}. "
|
||||
f"Příští běh zpracuje maily přijaté po tomto čase.")
|
||||
return
|
||||
|
||||
msgs = nove_inbox_messages(MAILBOX, watermark)
|
||||
# `gt` na oříznutý (sekundový) čas vrací i hraniční už zpracovaný mail
|
||||
# (Graph má sub-sekundovou přesnost) → odfiltruj ho podle ID.
|
||||
if last_id:
|
||||
msgs = [m for m in msgs if m.get("id") != last_id]
|
||||
cap = " (dosažen strop MAX_PER_RUN)" if len(msgs) >= MAX_PER_RUN else ""
|
||||
log(f"Vodoznak: {watermark} → nových mailů: {len(msgs)}{cap}")
|
||||
if not msgs:
|
||||
log("Nic nového — končím.")
|
||||
return
|
||||
|
||||
lookup = MedicusLookup()
|
||||
log(f"Medicus: kartotéka načtena ({len(lookup.patients)} pacientů, "
|
||||
@@ -789,7 +852,9 @@ def main() -> None:
|
||||
log("")
|
||||
|
||||
lookup.close()
|
||||
log(f"HOTOVO: {len(msgs)} mailů, žádostí o recept: {requests_found}.")
|
||||
_save_watermark(msgs[-1]["receivedDateTime"], msgs[-1].get("id", "")) # posun na nejnovější zpracovaný
|
||||
log(f"HOTOVO: {len(msgs)} mailů, žádostí o recept: {requests_found}. "
|
||||
f"Nový vodoznak: {msgs[-1]['receivedDateTime']}")
|
||||
log(
|
||||
f"CENA AI: {_cost['calls']} volání, "
|
||||
f"tokeny input={_cost['input_tokens']} output={_cost['output_tokens']}, "
|
||||
|
||||
+13
-1
@@ -30,7 +30,19 @@ from typing import Optional
|
||||
|
||||
import requests
|
||||
from dateutil import parser as dtparser, tz
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
try:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
except Exception:
|
||||
# Fallback, když balíček 'mcp' není nainstalován (např. python-runner na
|
||||
# toweru): modul lze importovat kvůli funkcím (zaloz_pozadavek_recept…),
|
||||
# jen MCP server běžet nemůže. @mcp.tool() se stane no-op průchodkou.
|
||||
class FastMCP:
|
||||
def __init__(self, *a, **k): pass
|
||||
def tool(self, *a, **k):
|
||||
def deco(f): return f
|
||||
return deco
|
||||
def run(self, *a, **k):
|
||||
raise RuntimeError("Balíček 'mcp' není nainstalován — MCP server nelze spustit.")
|
||||
|
||||
# ── Všechny logy na stderr (stdout = JSON-RPC) ──────────────────────────────
|
||||
def log(msg: str):
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
MCP server pro Telegram — FastMCP
|
||||
Spustit: python mcp_telegram.py
|
||||
|
||||
Dává agentům nástroje pro komunikaci s Vladem přes Telegram.
|
||||
|
||||
Nástroje:
|
||||
notifikace — pošle Vladovi zprávu přes bota (oznámení, nečeká na odpověď)
|
||||
zeptej_se — pošle Vladovi otázku přes bota a POČKÁ na jeho odpověď
|
||||
posli_jako_agent — pošle Vladovi zprávu z účtu agenta "Claude Buzalka" (persona)
|
||||
zeptej_se_jako_agent — otázka z účtu agenta + čekání na odpověď
|
||||
|
||||
Dvě cesty:
|
||||
• BOT (@Vlado_Claude_Bot) — bezpečné, bez rizika banu; doporučené pro notifikace/dotazy.
|
||||
• USER účet (Claude Buzalka) — vystupuje jako "osoba"; má riziko banu (nový účet).
|
||||
|
||||
POZOR (bot): getUpdates smí pollovat jen JEDEN proces. Když běží víc agentů, kteří
|
||||
naráz čekají na odpověď přes bota, kradou si zprávy. Pro souběžné dotazy víc agentů
|
||||
použij user účet (každý agent vlastní session) — viz Knihovny/telegram_user.py.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
# Kořen projektu na sys.path, ať jdou importovat sdílené knihovny
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(BASE_DIR))
|
||||
|
||||
from Knihovny.telegram_notify import posli_telegram, zeptej_se_telegram
|
||||
from Knihovny.telegram_user import posli_jako_ja, zeptej_se_jako
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
print(msg, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _v_threadu(fn, *args, **kwargs):
|
||||
"""Spustí synchronní (telethon.sync) funkci ve vlastním vlákně s čistou
|
||||
event-loop, aby nekolidovala s běžící async smyčkou FastMCP."""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
finally:
|
||||
loop.close()
|
||||
asyncio.set_event_loop(None)
|
||||
|
||||
|
||||
# Vladův hlavní Telegram účet (user id == chat_id u bota)
|
||||
VLADO_UID = 6639316354
|
||||
|
||||
mcp = FastMCP("telegram")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# BOT — bezpečná cesta
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@mcp.tool()
|
||||
def notifikace(text: str, tise: bool = False) -> dict:
|
||||
"""Pošle Vladovi oznámení přes Telegram bota (nečeká na odpověď).
|
||||
|
||||
Použij pro informování o průběhu, dokončení úlohy, chybě apod.
|
||||
|
||||
:param text: text zprávy
|
||||
:param tise: True = tichá zpráva bez zvuku/upozornění
|
||||
:return: {"ok": True}
|
||||
"""
|
||||
posli_telegram(text, disable_notification=tise)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def zeptej_se(otazka: str, timeout_s: int = 180) -> dict:
|
||||
"""Pošle Vladovi otázku přes bota a POČKÁ na jeho textovou odpověď.
|
||||
|
||||
Vhodné, když agent během běhu potřebuje rozhodnutí. Blokuje až `timeout_s` sekund.
|
||||
|
||||
:param otazka: text otázky (klidně s nabídkou možností, např. "(ano/ne)")
|
||||
:param timeout_s: jak dlouho čekat na odpověď
|
||||
:return: {"odpoved": <text>} nebo {"odpoved": None, "timeout": True}
|
||||
"""
|
||||
odp = zeptej_se_telegram(otazka, timeout=timeout_s)
|
||||
if odp is None:
|
||||
return {"odpoved": None, "timeout": True}
|
||||
return {"odpoved": odp}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# USER účet "Claude Buzalka" — persona (riziko banu)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@mcp.tool()
|
||||
async def posli_jako_agent(text: str) -> dict:
|
||||
"""Pošle Vladovi zprávu z účtu agenta "Claude Buzalka" (ne z bota).
|
||||
|
||||
:param text: text zprávy
|
||||
:return: {"ok": True}
|
||||
"""
|
||||
await asyncio.to_thread(_v_threadu, posli_jako_ja, VLADO_UID, text)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def zeptej_se_jako_agent(otazka: str, agent: str = "Claude", timeout_s: int = 180) -> dict:
|
||||
"""Pošle Vladovi otázku z účtu agenta a POČKÁ na odpověď (stačí běžná zpráva).
|
||||
|
||||
:param otazka: text otázky
|
||||
:param agent: jméno agenta (štítek + session); výchozí "Claude"
|
||||
:param timeout_s: jak dlouho čekat
|
||||
:return: {"odpoved": <text>} nebo {"odpoved": None, "timeout": True}
|
||||
"""
|
||||
odp = await asyncio.to_thread(
|
||||
_v_threadu, zeptej_se_jako, agent, otazka,
|
||||
komu=VLADO_UID, session=None, timeout=timeout_s, vyzaduj_reply=False,
|
||||
)
|
||||
if odp is None:
|
||||
return {"odpoved": None, "timeout": True}
|
||||
return {"odpoved": odp}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
log("MCP Telegram server spuštěn (FastMCP)")
|
||||
mcp.run()
|
||||
Reference in New Issue
Block a user