bep40's picture
Restore to commit 11a233f + add text message saving for ZGR group with detailed HELP_INSTRUCTIONS
35f8545 verified
Raw
History Blame Contribute Delete
68.6 kB
import os, sys, json, secrets, logging, asyncio, re, time, threading, base64, tempfile, pathlib
from html import escape
logging.basicConfig(level=logging.INFO, stream=sys.stdout)
logger = logging.getLogger("zalo-bot")
import requests
import gradio as gr
from fastapi import FastAPI, Request, Response
from starlette.responses import RedirectResponse
from huggingface_hub import HfApi, SpaceStage, hf_hub_download, upload_file
DEFAULT_BOT_TOKEN = os.getenv(
"DEFAULT_BOT_TOKEN",
"4179413508988279245:DmcFvOoFHHGiISQtmInHFchHwqfmAsaNWxhENixtvawrerrMGALunAbfhBvOzUcc",
)
WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "") or secrets.token_urlsafe(32)[:128]
SPACE_ID = os.getenv("SPACE_ID", "")
HF_TOKEN = os.getenv("HF_TOKEN", os.getenv("HF_API_TOKEN", ""))
if not HF_TOKEN:
try:
from huggingface_hub import get_token
HF_TOKEN = get_token() or ""
except Exception:
HF_TOKEN = ""
NAMESPACE = os.getenv("HF_NAMESPACE", "bep40")
MAIN_DATASET_ID = os.getenv("MAIN_DATASET_ID", f"{NAMESPACE}/zalo-products-all")
ZGR_SENDER_ID = "zgr-b7e1e71cf5701c2e4561"
OCR_MODEL_ID = os.getenv("OCR_MODEL_ID", "5CD-AI/Vintern-1B-v3_5")
if not HF_TOKEN:
logger.warning("[startup] HF_TOKEN not found — Space creation features will fail until HF_TOKEN secret is set")
logger.info("[startup] SPACE_ID=%s NAMESPACE=%s OCR_MODEL=%s", SPACE_ID or "(local)", NAMESPACE, OCR_MODEL_ID)
BOT_STATE = {
"bot_token": DEFAULT_BOT_TOKEN,
"webhook_url": "",
"webhook_secret": WEBHOOK_SECRET,
"connected": False,
"bot_info": {},
"logs": [],
"api_spaces": [],
"last_chat_id": "",
"last_sender_id": "",
}
def _load_proxy_spaces():
if not SPACE_ID or not HF_TOKEN:
return
try:
file_path = hf_hub_download(
repo_id=SPACE_ID, filename="proxy_spaces.json", repo_type="space", token=HF_TOKEN,
)
with open(file_path) as f:
data = json.load(f)
BOT_STATE["api_spaces"] = data.get("api_spaces", [])
logger.info("Loaded %d proxy spaces from disk", len(BOT_STATE["api_spaces"]))
except Exception as e:
logger.info("No persisted proxy data yet: %s", e)
def _save_proxy_spaces():
if not SPACE_ID or not HF_TOKEN:
return
try:
data = {"api_spaces": BOT_STATE.get("api_spaces", [])}
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
json.dump(data, tmp, indent=2, default=str)
tmp_path = tmp.name
upload_file(
path_or_fileobj=tmp_path,
path_in_repo="proxy_spaces.json",
repo_id=SPACE_ID,
repo_type="space",
token=HF_TOKEN,
commit_message="Update proxy spaces list",
)
pathlib.Path(tmp_path).unlink(missing_ok=True)
logger.info("Saved %d proxy spaces to disk", len(BOT_STATE.get("api_spaces", [])))
except Exception as e:
logger.error("Failed to save proxy spaces: %s", e)
_load_proxy_spaces()
def _save_chat_id(cid: str, sender_id: str):
if cid:
BOT_STATE["last_chat_id"] = cid
if sender_id:
BOT_STATE["last_sender_id"] = sender_id
class ZaloBotAPI:
BASE_URL = "https://bot-api.zaloplatforms.com"
def __init__(self, bt: str):
self.bt = bt
assert ":" in bt, "FAIL-FAST: sai dinh dang token"
self.api_base = f"{self.BASE_URL}/bot{bt}"
self.headers = {"Content-Type": "application/json"}
def get_me(self):
return requests.post(f"{self.api_base}/getMe", headers=self.headers, timeout=15).json()
def set_webhook(self, url: str, secret: str):
return requests.post(
f"{self.api_base}/setWebhook",
json={"url": url, "secret_token": secret},
headers=self.headers,
timeout=15,
).json()
def send_message(self, cid: str, text: str):
return requests.post(
f"{self.api_base}/sendMessage",
json={"chat_id": cid, "text": text, "parse_mode": "markdown"},
headers=self.headers,
timeout=15,
).json()
def get_webhook_url():
if SPACE_ID:
slug = SPACE_ID.replace("/", "-").replace("_", "-")
return f"https://{slug}.hf.space/webhooks"
port = os.getenv("PORT", "7860")
return f"http://localhost:{port}/webhooks"
def _extract_user_token(text: str):
m = re.search(r'HTTP\s*API\s*:\s*(\S+:\S+)', text, re.IGNORECASE)
if m:
return m.group(1).strip()
m = re.search(r'(\d+:[A-Za-z0-9_-]+)', text)
if m:
return m.group(1)
return None
def _safe_space_name(user_id: str) -> str:
return re.sub(r'[^a-zA-Z0-9-]', '', str(user_id))[:40]
def _get_user_dataset_id(user_id: str) -> str:
safe_id = _safe_space_name(user_id)
if not safe_id:
safe_id = "default"
return f"{NAMESPACE}/{safe_id}-zalo-data"
def _is_zgr_sender_local(sender_id):
sid = str(sender_id)
return ZGR_SENDER_ID in sid or sid == ZGR_SENDER_ID
def _ensure_user_dataset(user_id: str, token: str) -> tuple:
if not HF_TOKEN:
raise RuntimeError("HF_TOKEN chưa được cấu hình.")
api = HfApi(token=HF_TOKEN)
dataset_id = _get_user_dataset_id(user_id)
created = False
try:
api.dataset_info(dataset_id)
logger.info("Dataset %s already exists", dataset_id)
except Exception:
with tempfile.TemporaryDirectory() as tmp:
readme_path = pathlib.Path(tmp, "README.md")
readme_path.write_text(
f"# Zalo Product Data — {user_id}\n\n"
f"Dữ liệu sản phẩm thu thập từ Zalo chat.\n\n"
f"## Cấu trúc (schema)\n"
f"| image | ảnh (binary/URL) | Hình ảnh sản phẩm |\n"
f"| product_name | text | Tên sản phẩm |\n"
f"| description | text | Nội dung mô tả |\n"
f"| price | number | Giá sản phẩm |\n"
f"| category | text | Chuyên mục |\n"
f"| sender_id | text | ID người gửi |\n"
f"| sender_name | text | Tên người gửi |\n"
f"| timestamp | text | Thời gian ghi nhận |\n"
)
try:
api.create_repo(repo_id=dataset_id, repo_type="dataset", exist_ok=True)
api.upload_file(
path_or_fileobj=str(readme_path),
path_in_repo="README.md",
repo_id=dataset_id,
repo_type="dataset",
token=HF_TOKEN,
commit_message="Initial dataset readme",
)
created = True
logger.info("Created dataset %s", dataset_id)
except Exception as e:
err_msg = str(e)
if "429" in err_msg or "rate limit" in err_msg:
raise RuntimeError("⚠️ Đã đạt giới hạn tạo repo (20/ngày). Vui lòng thử lại sau 24h.")
if "already exist" in err_msg or "conflict" in err_msg:
logger.info("Dataset %s already exists, skipping create", dataset_id)
else:
raise
return dataset_id, created
def _save_product_to_main_dataset(image_url, image_data_b64, description, price, category, sender_id, sender_name, product_name="", chat_id="", timestamp="", img_bytes=None):
if not HF_TOKEN or not MAIN_DATASET_ID:
logger.warning("MAIN_DATASET_ID or HF_TOKEN not configured")
return None
try:
api = HfApi(token=HF_TOKEN)
file_ts = timestamp or time.strftime("%Y%m%d_%H%M%S")
rec_ts = time.strftime("%Y-%m-%d %H:%M:%S")
safe_sender = _safe_space_name(sender_id) or "unknown"
img_filename = f"images/{file_ts}_{safe_sender}.jpg"
meta_filename = f"data/{file_ts}_{safe_sender}.json"
if img_bytes is None:
img_bytes = None
if image_data_b64:
try:
img_bytes = base64.b64decode(image_data_b64)
except Exception:
img_bytes = None
elif image_url:
try:
r = requests.get(image_url, timeout=15)
img_bytes = r.content
except Exception as e:
logger.error("Image download failed: %s", e)
uploaded_img = None
if img_bytes:
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
tmp.write(img_bytes)
tmp_path = tmp.name
try:
api.upload_file(
path_or_fileobj=tmp_path,
path_in_repo=img_filename,
repo_id=MAIN_DATASET_ID,
repo_type="dataset",
token=HF_TOKEN,
commit_message=f"Add product image from {sender_name}",
)
uploaded_img = img_filename
except Exception as e:
logger.error("Image upload to main dataset failed: %s", e)
finally:
pathlib.Path(tmp_path).unlink(missing_ok=True)
record = {
"image": uploaded_img,
"product_name": str(product_name)[:200] if product_name else "",
"description": str(description)[:500] if description else "",
"price": str(price) if price else "",
"category": str(category) if category else "",
"sender_id": str(sender_id),
"sender_name": str(sender_name),
"chat_id": str(chat_id),
"timestamp": rec_ts,
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
json.dump(record, tmp, indent=2, ensure_ascii=False)
tmp_path = tmp.name
try:
api.upload_file(
path_or_fileobj=tmp_path,
path_in_repo=meta_filename,
repo_id=MAIN_DATASET_ID,
repo_type="dataset",
token=HF_TOKEN,
commit_message=f"Add product metadata from {sender_name}",
)
finally:
pathlib.Path(tmp_path).unlink(missing_ok=True)
logger.info("Saved product to main dataset: %s", MAIN_DATASET_ID)
return MAIN_DATASET_ID
except Exception as e:
logger.error("Failed to save to main dataset: %s", e)
return None
def _save_text_message_to_dataset(text, description, price, category, sender_id, sender_name, chat_id):
"""Save a text-only message to the main Zalo products dataset."""
try:
from huggingface_hub import HfApi
api = HfApi()
filename_ts = time.strftime("%Y%m%d_%H%M%S")
safe_name = re.sub(r'[^a-zA-Z0-9_\-]', '_', str(sender_name)[:30]) if sender_name else ""
file_name = f"{filename_ts}_{safe_name}_text.json"
upload_path = "data/" + file_name
record = {
"text": text,
"description": description[:500] if description else text[:500],
"price": price or "",
"category": category or "",
"sender_id": str(sender_id),
"sender_name": str(sender_name),
"chat_id": str(chat_id),
"image": "",
"product_name": "",
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
"message_type": "text",
}
json_content = json.dumps(record, ensure_ascii=False, indent=2)
api.upload_file(
path=upload_path,
path_in_repo=upload_path,
repo_id=MAIN_DATASET_ID,
repo_type="dataset",
token=HF_TOKEN,
commit_message=f"Add text message from {sender_name}",
)
logger.info("Text message saved to dataset: %s", file_name)
return upload_path
except Exception as e:
logger.error("Failed to save text to dataset: %s", e)
return None
def _ocr_extract_text(image_bytes):
"""Use HF Inference API with Vietnamese OCR model (Vintern-1B) to extract text from image."""
try:
from huggingface_hub import InferenceClient
client = InferenceClient(model=OCR_MODEL_ID, token=HF_TOKEN)
# Convert bytes to PIL image
from PIL import Image
import io
img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
prompt = "<image>\nTrích xuất toàn bộ văn bản trong hình ảnh và trả về dưới dạng markdown."
result = client.chat_completion(
messages=[{"role": "user", "content": [{"type": "text", "text": prompt}, {"type": "image_url", "image_url": img}]}],
max_tokens=2048,
)
ocr_text = result.choices[0].message.content.strip()
logger.info("OCR extracted %d chars of text", len(ocr_text))
return ocr_text
except Exception as e:
logger.error("OCR extraction failed: %s", e)
return ""
def _parse_ocr_product_info(ocr_text):
"""Parse OCR-extracted text to extract product fields."""
product_name, description, price, category = "", "", "", ""
# Try to extract price (Vietnamese đồng format: số, số, hoặc số vnđ)
price_match = re.search(r'(\d{1,3}(?:[.,]\d{3})*(?:[.,]\d{2,3})?(?:\s*(?:đ|vnđ|VND|dong))?)', ocr_text, re.IGNORECASE)
if price_match:
price = price_match.group(1)
# Try to extract category from common keywords
for kw in ["Chuyên mục", "Danh mục", "Loại", "Category"]:
m = re.search(kw + r'[:\s]*([^\n]+)', ocr_text, re.IGNORECASE)
if m:
category = m.group(1).strip()
break
# Try to extract product name from common keywords
for kw in ["Tên sp", "Tên sản phẩm", "Product", "Tên hàng"]:
m = re.search(kw + r'[:\s]*([^\n]+)', ocr_text, re.IGNORECASE)
if m:
product_name = m.group(1).strip()
break
# Use remaining text as description
desc_text = ocr_text
for kw in ["Chuyên mục", "Danh mục", "Loại", "Category", "Tên sp", "Tên sản phẩm", "Product", "Tên hàng", "Giá", "gia", "Price"]:
desc_text = re.sub(kw + r'[:\s]*[^\n]+', '', desc_text, flags=re.IGNORECASE)
description = desc_text.strip()[:500] if desc_text.strip() else ""
return product_name, description, price, category
def _create_api_proxy_space(token, user_id, sender_display):
safe_id = _safe_space_name(user_id)
token_suffix = token.split(":")[-1][:12] if ":" in token else re.sub(r'\W', '', token[:12])
unique_key = safe_id if safe_id else "u" + token_suffix
space_name = f"zalo-proxy-{unique_key}"
repo_id = f"{NAMESPACE}/{space_name}"
dataset_id = f"{NAMESPACE}/{unique_key}-zalo-data"
logger.info("Creating proxy space %s for user %s", repo_id, sender_display)
if not HF_TOKEN:
raise RuntimeError("HF_TOKEN secret chưa được cấu hình cho Space.")
api = HfApi(token=HF_TOKEN)
dockerfile = """FROM python:3.12-slim
RUN useradd -m -u 1000 user
USER user
ENV HOME=/home/user
ENV PATH=/home/user/.local/bin:$PATH
WORKDIR $HOME/app
COPY --chown=user requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
COPY --chown=user app.py .
EXPOSE 7860
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
"""
requirements = "fastapi>=0.111.0\nuvicorn[standard]>=0.30.0\nrequests>=2.32.0\nhuggingface_hub>=0.30.0\n"
app_py = '''import os, json, requests, time, re, base64, tempfile, pathlib, sys
from html import escape as _escape
class _StderrLogger:
def __init__(self):
self._log = []
def write(self, s):
if s.strip():
self._log.append(s)
sys.__stderr__.write(s)
def flush(self): pass
sys.stderr = _StderrLogger()
from fastapi import FastAPI, Request, Response
app = FastAPI(title="Zalo Proxy Space")
BOT_TOKEN = "''' + token + '''"
TARGET_API = "https://bot-api.zaloplatforms.com"
PROXY_NAME = "''' + sender_display + '''"
HF_TOKEN = os.getenv("HF_TOKEN", "")
DATASET_ID = "''' + dataset_id + '''"
MAIN_DATASET_ID = "''' + MAIN_DATASET_ID + '''"
MAIN_SPACE_URL = "''' + SPACE_ID.replace("/", "-") + '''.hf.space"
ZGR_SENDER_ID = "zgr-b7e1e71cf5701c2e4561"
_logs = []
def _send(cid, text):
headers = {"Content-Type": "application/json"}
url = TARGET_API + "/bot" + BOT_TOKEN + "/sendMessage"
return requests.post(url, json={"chat_id": cid, "text": text}, headers=headers)
def _safe_name(name):
return re.sub(r'[^a-zA-Z0-9]', '_', str(name))[:30]
def _is_zgr_sender(sender_id):
sid = str(sender_id)
return ZGR_SENDER_ID in sid or sid == ZGR_SENDER_ID
def _log(event, sender_id, chat_id, text, sender_name="", chat_type=""):
entry = {"event": str(event), "sender_id": str(sender_id), "sender_name": str(sender_name), "chat_id": str(chat_id), "chat_type": str(chat_type), "text": str(text)[:500], "is_zgr": _is_zgr_sender(sender_id), "time": time.strftime("%Y-%m-%d %H:%M:%S")}
_logs.append(entry)
print("[WEBHOOK] event=" + str(event) + " sender=" + str(sender_id) + " chat=" + str(chat_id) + " is_zgr=" + str(entry["is_zgr"]) + " text=" + str(text)[:100], flush=True)
if len(_logs) > 200:
del _logs[:100]
_log("startup", "system", "SYSTEM", "Proxy space initialized. PROXY_NAME=" + PROXY_NAME)
def _save_to_main_dataset(image_url, image_data_b64, description, price, category, sender_id, sender_name, product_name="", chat_id="", text_message=None):
_log("main_dataset_save_start", sender_id, chat_id, "product_name=" + str(product_name) + " price=" + str(price))
if not HF_TOKEN or not MAIN_DATASET_ID:
_log("main_dataset_skip", sender_id, chat_id, "HF_TOKEN or MAIN_DATASET_ID missing")
return None
try:
from huggingface_hub import HfApi
api = HfApi(token=HF_TOKEN)
file_ts = time.strftime("%Y%m%d_%H%M%S")
rec_ts = time.strftime("%Y-%m-%d %H:%M:%S")
safe_sender = _safe_name(sender_id) or "unknown"
if text_message:
img_filename = ""
meta_filename = "data/" + file_ts + "_" + safe_sender + "_text.json"
else:
img_filename = "images/" + file_ts + "_" + safe_sender + ".jpg"
meta_filename = "data/" + file_ts + "_" + safe_sender + ".json"
img_bytes = None
if image_data_b64:
try:
img_bytes = base64.b64decode(image_data_b64)
except Exception:
img_bytes = None
elif image_url:
try:
r = requests.get(image_url, timeout=15)
img_bytes = r.content
_log("image_downloaded_from_url", sender_id, chat_id, image_url[:100])
except Exception as e:
_log("image_download_fail", sender_id, chat_id, str(e))
uploaded_img = None
if img_bytes:
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
tmp.write(img_bytes)
tmp_path = tmp.name
try:
api.upload_file(path_or_fileobj=tmp_path, path_in_repo=img_filename, repo_id=MAIN_DATASET_ID, repo_type="dataset", token=HF_TOKEN, commit_message="Add product image from " + sender_name)
uploaded_img = img_filename
_log("image_uploaded", sender_id, chat_id, img_filename)
except Exception as e:
_log("image_upload_fail", sender_id, chat_id, str(e))
finally:
pathlib.Path(tmp_path).unlink(missing_ok=True)
if text_message:
record = {"image": "", "product_name": str(product_name)[:200] if product_name else "", "category": str(category) if category else "", "description": str(description)[:500] if description else str(text_message)[:500], "price": str(price) if price else "", "sender_id": str(sender_id), "sender_name": str(sender_name), "chat_id": str(chat_id), "is_zgr_group": _is_zgr_sender(sender_id), "timestamp": rec_ts, "text": str(text_message)[:1000], "message_type": "text"}
else:
record = {"image": uploaded_img, "product_name": str(product_name)[:200] if product_name else "", "category": str(category) if category else "", "description": str(description)[:500] if description else "", "price": str(price) if price else "", "sender_id": str(sender_id), "sender_name": str(sender_name), "chat_id": str(chat_id), "is_zgr_group": _is_zgr_sender(sender_id), "timestamp": rec_ts}
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
json.dump(record, tmp, indent=2, ensure_ascii=False)
tmp_path = tmp.name
try:
api.upload_file(path_or_fileobj=tmp_path, path_in_repo=meta_filename, repo_id=MAIN_DATASET_ID, repo_type="dataset", token=HF_TOKEN, commit_message="Add product metadata from " + sender_name)
_log("dataset_save_to_main", sender_id, chat_id, "OK")
except Exception as e:
_log("dataset_save_fail", sender_id, chat_id, str(e))
finally:
pathlib.Path(tmp_path).unlink(missing_ok=True)
return MAIN_DATASET_ID
except Exception as e:
_log("main_dataset_error", sender_id, chat_id, str(e))
return None
def _save_to_dataset(image_url, image_data_b64, description, price, category, sender_id, sender_name):
if not HF_TOKEN or not DATASET_ID:
_log("dataset_skip", sender_id, "N/A", "HF_TOKEN or DATASET_ID missing")
return None
try:
from huggingface_hub import HfApi
api = HfApi(token=HF_TOKEN)
file_ts = time.strftime("%Y%m%d_%H%M%S")
rec_ts = time.strftime("%Y-%m-%d %H:%M:%S")
safe_sender = _safe_name(sender_id) or "unknown"
img_filename = "images/" + file_ts + "_" + safe_sender + ".jpg"
meta_filename = "data/" + file_ts + "_" + safe_sender + ".json"
img_bytes = None
if image_data_b64:
try:
img_bytes = base64.b64decode(image_data_b64)
except Exception:
img_bytes = None
elif image_url:
try:
r = requests.get(image_url, timeout=15)
img_bytes = r.content
except Exception as e:
_log("image_download_fail", sender_id, "N/A", str(e))
uploaded_img = None
if img_bytes:
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
tmp.write(img_bytes)
tmp_path = tmp.name
try:
api.upload_file(path_or_fileobj=tmp_path, path_in_repo=img_filename, repo_id=DATASET_ID, repo_type="dataset", token=HF_TOKEN, commit_message="Add product image from " + sender_name)
uploaded_img = img_filename
except Exception as e:
_log("image_upload_fail", sender_id, "N/A", str(e))
finally:
pathlib.Path(tmp_path).unlink(missing_ok=True)
record = {"image": uploaded_img, "product_name": "", "category": str(category) if category else "", "description": str(description)[:500] if description else "", "price": str(price) if price else "", "sender_id": str(sender_id), "sender_name": str(sender_name), "timestamp": rec_ts}
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
json.dump(record, tmp, indent=2, ensure_ascii=False)
tmp_path = tmp.name
try:
api.upload_file(path_or_fileobj=tmp_path, path_in_repo=meta_filename, repo_id=DATASET_ID, repo_type="dataset", token=HF_TOKEN, commit_message="Add product metadata from " + sender_name)
finally:
pathlib.Path(tmp_path).unlink(missing_ok=True)
_log("dataset_saved", sender_id, "N/A", "Saved to " + DATASET_ID)
return DATASET_ID
except Exception as e:
_log("dataset_error", sender_id, "N/A", str(e))
return None
@app.get("/")
async def root():
return {"status": "ok"}
@app.get("/health")
async def health():
return {"status": "ok", "dataset": DATASET_ID, "zgr_sender": ZGR_SENDER_ID, "is_zgr": _is_zgr_sender(ZGR_SENDER_ID)}
@app.get("/webhooks")
async def webhooks_get():
return Response(content=json.dumps({"message": "Success"}), media_type="application/json", status_code=200)
@app.post("/webhooks")
async def webhooks(request: Request):
body = await request.body()
body_str = body.decode("utf-8") if body else ""
_log("webhook_received", "N/A", "N/A", "Body length: " + str(len(body_str)))
try:
data = json.loads(body_str)
except Exception as e:
_log("parse_error", "N/A", "N/A", "Bad JSON: " + str(e) + " | body=" + body_str[:200])
return Response(content=json.dumps({"message": "Bad JSON"}), media_type="application/json", status_code=400)
result = data.get("result", data)
event = result.get("event_name", "unknown")
msg = result.get("message", {})
sender = msg.get("from", {})
chat = msg.get("chat", {})
text = msg.get("text", "")
sender_id = str(sender.get("id", ""))
sender_name = sender.get("display_name") or sender.get("name") or sender_id
chat_id = str(chat.get("id", ""))
chat_type = str(chat.get("chat_type", ""))
attachments = msg.get("attachment", {})
image_url = ""
image_data_b64 = ""
if attachments:
payload = attachments.get("payload", {})
if isinstance(payload, str):
try:
payload = json.loads(payload)
except Exception:
payload = {}
image_url = payload.get("url", "") or msg.get("photo", "") or msg.get("photo_url", "") or msg.get("image_url", "")
image_data_b64 = payload.get("data", "") or msg.get("image", "")
else:
image_url = msg.get("photo", "") or msg.get("photo_url", "") or msg.get("image_url", "")
image_data_b64 = msg.get("image", "")
is_zgr = _is_zgr_sender(sender_id)
_log(event, sender_id, chat_id, text, sender_name, chat_type)
_log("debug_info", sender_id, chat_id, "chat_type=" + str(chat_type) + " is_zgr=" + str(is_zgr) + " sender_id=" + str(sender_id) + " sender_name=" + str(sender_name) + " text_len=" + str(len(text)) + " has_attachment=" + str(bool(attachments)) + " image_url=" + str(image_url[:100]))
if event == "message.text.received" and chat_id:
description, price, category, product_name = "", "", "", ""
desc_match = re.search(r'(?:mo ta|description|desc|mota)[:\\\\s]*([^|\\n]+)', text, re.IGNORECASE)
price_match = re.search(r'(?:gia|price|don gia|donggia)[:\\\\s]*([\\d,.]+)', text, re.IGNORECASE)
cat_match = re.search(r'(?:chuyen muc|category|danh muc|loai)[:\\s]*([^|\\n]+?)(?:$|\\n)', text, re.IGNORECASE)
name_match = re.search(r'(?:ten sp|ten san pham|product name|name)[:\\s]*([^|\\n]+)', text, re.IGNORECASE)
if name_match:
product_name = name_match.group(1).strip()
if desc_match:
description = desc_match.group(1).strip()
if price_match:
price = price_match.group(1).strip()
if cat_match:
category = cat_match.group(1).strip()
if image_url or image_data_b64 or product_name or description or price or category:
dataset_id = _save_to_dataset(image_url=image_url, image_data_b64=image_data_b64, description=description or text[:200], price=price, category=category, sender_id=sender_id, sender_name=sender_name)
_save_to_main_dataset(image_url=image_url, image_data_b64=image_data_b64, description=description or text[:200], price=price, category=category, sender_id=sender_id, sender_name=sender_name, product_name=product_name, chat_id=chat_id)
if dataset_id:
reply = "GOT IT! Product saved!"
else:
reply = "GOT IT! Saved to main dataset!"
elif is_zgr and text:
# Save all text messages from ZGR group to main dataset
_save_to_main_dataset(
image_url=image_url, image_data_b64=image_data_b64,
description=text[:200], price=price, category=category,
sender_id=sender_id, sender_name=sender_name,
product_name=product_name, chat_id=chat_id,
text_message=text,
)
reply = "👋 Xin chào " + str(sender_name) + " (Zalo ID: " + str(sender_id) + ")!\n\n" + HELP_INSTRUCTIONS
else:
reply = "Hi! Send image + product info to save."
try:
_send(chat_id, reply)
except Exception as e:
_log("send_reply_fail", sender_id, chat_id, str(e))
elif event == "message.image.received" and chat_id:
description, price, category, product_name = "", "", "", ""
desc_match = re.search(r'(?:mo ta|description|desc|mota)[:\\\\s]*([^|\\n]+)', text, re.IGNORECASE)
price_match = re.search(r'(?:gia|price|don gia|donggia)[:\\\\s]*([\\d,.]+)', text, re.IGNORECASE)
cat_match = re.search(r'(?:chuyen muc|category|danh muc|loai)[:\\s]*([^|\\n]+?)(?:$|\\n)', text, re.IGNORECASE)
name_match = re.search(r'(?:ten sp|ten san pham|product name|name)[:\\s]*([^|\\n]+)', text, re.IGNORECASE)
if name_match:
product_name = name_match.group(1).strip()
if desc_match:
description = desc_match.group(1).strip()
if price_match:
price = price_match.group(1).strip()
if cat_match:
category = cat_match.group(1).strip()
log_text = "photo_url=" + str(image_url[:100]) if image_url else "No photo_url in message"
if text:
log_text += " | caption=" + str(text[:200])
if image_url:
dataset_id = _save_to_dataset(image_url=image_url, image_data_b64=image_data_b64, description=description or text[:200], price=price, category=category, sender_id=sender_id, sender_name=sender_name)
saved = _save_to_main_dataset(image_url=image_url, image_data_b64=image_data_b64, description=description or text[:200], price=price, category=category, sender_id=sender_id, sender_name=sender_name, product_name=product_name, chat_id=chat_id)
if dataset_id:
reply = "GOT IT! Product saved!"
else:
reply = "GOT IT! Saved to main dataset!"
_log("image_saved", sender_id, chat_id, log_text)
try:
_send(chat_id, reply)
except Exception as e:
_log("send_reply_fail", sender_id, chat_id, str(e))
else:
_log("image_no_url", sender_id, chat_id, log_text)
return Response(content=json.dumps({"message": "Success"}), media_type="application/json", status_code=200)
@app.get("/logs")
async def proxy_logs():
rows = ""
for log in reversed(_logs[-100:]):
is_zgr = _is_zgr_sender(log.get("sender_id", ""))
bg = "#e8f5e9" if is_zgr else "#ffffff"
zgr_badge = "[ZGR] " if is_zgr else ""
chat_type_val = _escape(str(log.get("chat_type", "")))
rows += "<div style='margin:6px 0;padding:8px;background:" + bg + ";border-radius:4px;border-left:3px solid #4CAF50'><b>" + zgr_badge + "[" + _escape(str(log["event"])) + "]</b> " + _escape(str(log.get("sender_name",""))) + " ID:<code>" + _escape(str(log.get("sender_id",""))) + "</code> chat:<code>" + _escape(str(log.get("chat_id",""))) + "</code> type:[" + chat_type_val + "]<br><span style='font-family:monospace;font-size:12px;color:#333'>" + _escape(str(log.get("text",""))[:300]) + "</span><br><small style='color:#999'>" + _escape(str(log.get("time",""))) + "</small></div>"
html_content = "<!DOCTYPE html><html><head><title>Proxy Logs</title><meta http-equiv='refresh' content='5'><style>body{font-family:Arial,sans-serif;max-width:1000px;margin:0 auto;padding:16px;}h1{color:#1a73e8;}.log-c{max-height:600px;overflow-y:auto;background:#fff;border-radius:8px;padding:8px;}</style></head><body><h1>Proxy Logs - " + _escape(PROXY_NAME) + "</h1><div class='log-c'>" + rows + "</div></body></html>"
return Response(content=html_content, media_type="text/html")
'''
readme = """---
title: Zalo Proxy
colorFrom: blue
colorTo: purple
sdk: docker
app_port: 7860
---
Zalo Webhook Proxy Space
"""
with tempfile.TemporaryDirectory() as tmp:
pathlib.Path(tmp, "Dockerfile").write_text(dockerfile)
pathlib.Path(tmp, "app.py").write_text(app_py)
pathlib.Path(tmp, "requirements.txt").write_text(requirements)
pathlib.Path(tmp, "README.md").write_text(readme)
try:
existing = api.space_info(repo_id=repo_id)
logger.info("Proxy space %s already exists (stage=%s)", repo_id, existing.stage)
except Exception:
try:
api.create_repo(repo_id=repo_id, repo_type="space", space_sdk="docker", exist_ok=True)
logger.info("Created new proxy space repo: %s", repo_id)
except Exception as e:
err_msg = str(e).lower()
if "429" in err_msg or "rate limit" in err_msg:
raise RuntimeError("Rate limit. Try again later.")
if "already exist" in err_msg or "conflict" in err_msg:
logger.info("Proxy space %s already exists, skipping create_repo", repo_id)
else:
raise
try:
api.upload_folder(folder_path=tmp, repo_id=repo_id, repo_type="space", commit_message="Initial proxy space")
except Exception as e:
err_msg = str(e)
if "404" in err_msg or "Repository Not Found" in err_msg:
raise RuntimeError("Proxy repo error")
raise
try:
api.wait_for_space(repo_id=repo_id, expected_stage=SpaceStage.RUNNING, timeout=180)
status = "RUNNING"
except Exception as e:
logger.warning("wait_for_space timeout: %s", e)
try:
rt = api.get_space_runtime(repo_id=repo_id)
status = str(rt.stage)
except Exception:
status = "UNKNOWN"
proxy_url = "https://" + repo_id.replace('/', '-') + ".hf.space/webhooks"
logger.info("Proxy space ready: %s -> %s (status=%s)", repo_id, proxy_url, status)
return repo_id, proxy_url, status
def _set_user_webhook(user_token, proxy_url, secret):
try:
zapi = ZaloBotAPI(user_token)
result = zapi.set_webhook(proxy_url, secret)
logger.info("setWebhook result: %s", result)
return result
except Exception as e:
logger.error("setWebhook failed: %s", e)
return {"ok": False, "message": str(e)}
def connect_bot(token: str):
if not token:
return "Nhap Bot Token"
BOT_STATE["bot_token"] = token
BOT_STATE["connected"] = False
BOT_STATE["bot_info"] = {}
try:
zapi = ZaloBotAPI(token)
except AssertionError as e:
return "Token sai: " + str(e)
try:
me = zapi.get_me()
if not me.get("ok"):
return "That bai: " + str(me.get('message', ''))
BOT_STATE["bot_info"] = me.get("result", {})
except Exception as e:
return "Loi: " + str(e)
wh = get_webhook_url()
sc = BOT_STATE["webhook_secret"]
try:
sw = zapi.set_webhook(wh, sc)
if sw.get("ok"):
BOT_STATE["webhook_url"] = wh
BOT_STATE["connected"] = True
return "Ket noi thanh cong! Webhook: " + wh
return "setWebhook that bai: " + str(sw.get('message', ''))
except Exception as e:
return "Loi: " + str(e)
def send_msg(cid: str, text: str):
if not BOT_STATE["connected"]:
return "Chua ket noi bot."
if not cid or not text:
return "Nhap Chat ID va Noi dung"
try:
result = ZaloBotAPI(BOT_STATE["bot_token"]).send_message(cid, text)
return json.dumps(result, indent=2, ensure_ascii=False)
except Exception as e:
return "Loi: " + str(e)
def get_botinfo():
if BOT_STATE["bot_info"]:
info = BOT_STATE["bot_info"]
lines = ["Ten bot: " + str(info.get('name', '?')), "ID: " + str(info.get('id', ''))]
if BOT_STATE.get("webhook_url"):
lines.append("Webhook: " + BOT_STATE["webhook_url"])
lines.append("Ket noi: " + str(BOT_STATE.get("connected", False)))
return "\n".join(lines)
return "Chua ket noi"
def get_events():
if not BOT_STATE["logs"]:
return "Chua co su kien"
lines = []
for i, l in enumerate(BOT_STATE["logs"][-20:][::-1], 1):
is_zgr = _is_zgr_sender_local(l.get("sender_id", ""))
zgr_tag = " [ZGR]" if is_zgr else ""
lines.append("{}. [{}] {} Zalo:{} ID:{} chat:{} | {}".format(
i, l.get('event',''), zgr_tag, l.get('sender_name',''), l.get('sender_id',''), l.get('chat_id',''), str(l.get('text','')[:50])
))
return "\n".join(lines)
def get_proxy_spaces():
spaces = BOT_STATE.get("api_spaces", [])
if not spaces:
return "Chua co proxy space nao"
lines = []
for i, s in enumerate(spaces[-10:][::-1], 1):
lines.append("{}. {} ID:{} Space:{} Webhook:{} Status:{}".format(
i, s.get('sender_name',''), s.get('user_id',''), s.get('repo_id',''), s.get('proxy_url',''), s.get('status','')
))
return "\n".join(lines)
HELP_INSTRUCTIONS = (
"🎓 HƯỚNG DẪN CẤU HÌNH ZALO BOT CHI TIẾT\n\n"
"1️⃣ Cách đặt tên Zalobot (QUAN TRỌNG):\n"
"• Tên bot không được chứa 'Zalo' hoặc 'bot'\n"
"• Ví dụ đúng: Shop, ChămSóc, HỗTrợ247, CSKH-TựĐộng ✅\n"
"• Ví dụ sai: Zalo Support, ShopBot, ZaloBot ❌\n\n"
"2️⃣ Cách lấy HTTP API:\n"
"• Truy cập https://zalo.me/s/botcreator\n"
"• Chọn bot → Cài đặt → API/HTTP API\n"
"• Copy Bot token: 4179413508988279245:XXXXXXXXXXXXXXXXXXXXXX\n\n"
"3️⃣ Cách dùng:\n"
"• Gửi HTTP API: <bot_token> để tạo proxy tự động\n"
"• Gửi ảnh + mô tả sản phẩm để lưu vào dataset\n"
"• Mọi tin nhắn trong nhóm sẽ được lưu tự động"
)
async def handle_webhook(request: Request):
body = await request.body()
body_str = body.decode("utf-8") if body else ""
logger.info("webhook received: body=%s", body_str[:500])
try:
data = json.loads(body_str)
except Exception as e:
logger.error("JSON parse error: %s", e)
BOT_STATE["logs"].append({"event": "parse_error", "sender_id": "N/A", "chat_id": "N/A", "sender_name": "N/A", "chat_type": "N/A", "text": body_str[:200]})
return Response(content=json.dumps({"message": "Bad JSON", "error": str(e)}), media_type="application/json", status_code=400)
result = data.get("result", data)
event = result.get("event_name", "unknown")
msg = result.get("message", {})
sender = msg.get("from", {})
chat = msg.get("chat", {})
text = msg.get("text", "")
sender_id = str(sender.get("id", ""))
sender_name = sender.get("display_name") or sender.get("name") or sender_id
chat_id = str(chat.get("id", ""))
chat_type = str(chat.get("chat_type", ""))
BOT_STATE["logs"].append({
"event": str(event), "sender_id": sender_id, "chat_id": chat_id,
"sender_name": sender_name, "chat_type": chat_type, "text": str(text)[:500],
"is_zgr": _is_zgr_sender_local(sender_id),
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
})
if len(BOT_STATE["logs"]) > 100:
BOT_STATE["logs"] = BOT_STATE["logs"][-100:]
logger.info("EVENT=%s SENDER_ID=%s CHAT_ID=%s SENDER_NAME=%s", event, sender_id, chat_id, sender_name)
if event == "message.text.received":
cid = chat.get("id") or sender.get("id") or ""
_save_chat_id(cid, sender_id)
user_token = _extract_user_token(text)
if user_token:
zapi = ZaloBotAPI(BOT_STATE["bot_token"])
try:
zapi.send_message(cid, "Processing your HTTP API...")
except Exception:
pass
def _create_and_setup():
try:
_r, proxy_url, status = _create_api_proxy_space(user_token, sender_id, sender_name)
dataset_id = None
try:
dataset_id, _ = _ensure_user_dataset(sender_id, user_token)
except Exception as de:
logger.error("Dataset setup failed: %s", de)
BOT_STATE["api_spaces"].append({
"repo_id": _r, "proxy_url": proxy_url, "user_token": user_token,
"user_id": sender_id, "sender_name": sender_name, "status": status, "dataset_id": dataset_id,
})
_save_proxy_spaces()
sw = _set_user_webhook(user_token, proxy_url, BOT_STATE["webhook_secret"])
user_bot_id = user_token.split(":")[0] if ":" in user_token else ""
user_bot_link = "https://zalo.me/s/" + user_bot_id if user_bot_id else "https://zalo.me/s/botcreator"
dataset_url = "https://huggingface.co/datasets/" + NAMESPACE + "/" + _safe_space_name(sender_id or "user") + "-zalo-data" if sender_id else ""
instructions = "BOT OK! Proxy: " + proxy_url + "\\nDataset: " + (dataset_url if dataset_url else "N/A") + "\\nManage bot at: " + user_bot_link
try:
zapi.send_message(cid, instructions)
except Exception:
pass
BOT_STATE["logs"].append({
"event": "proxy_created", "sender_id": sender_id, "chat_id": chat_id,
"sender_name": sender_name, "chat_type": chat_type,
"text": "Proxy created: " + proxy_url,
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
})
except Exception as e:
logger.error("Failed: %s", e)
try:
ZaloBotAPI(BOT_STATE["bot_token"]).send_message(cid, "Loi tao proxy: " + str(e))
except Exception:
pass
threading.Thread(target=_create_and_setup, daemon=True).start()
return Response(content=json.dumps({"message": "Processing", "proxy_url": "pending"}), media_type="application/json")
# ─── Regular message ───
if cid:
zapi = ZaloBotAPI(BOT_STATE["bot_token"])
product_name, description, price, category = "", "", "", ""
image_url, image_data_b64 = "", ""
attachments = msg.get("attachment", {})
if attachments:
payload = attachments.get("payload", {})
if isinstance(payload, str):
try:
payload = json.loads(payload)
except Exception:
payload = {}
image_url = payload.get("url", "") or msg.get("photo", "") or msg.get("photo_url", "") or msg.get("image_url", "")
image_data_b64 = payload.get("data", "") or msg.get("image", "")
else:
image_url = msg.get("photo", "") or msg.get("photo_url", "") or msg.get("image_url", "")
image_data_b64 = msg.get("image", "")
name_match = re.search(r'(?:ten sp|ten san pham|product name|name)[:\s]*([^|\n]+)', text, re.IGNORECASE)
desc_match = re.search(r'(?:mo ta|description|desc|mota)[:\\s]*([^|\n]+)', text, re.IGNORECASE)
price_match = re.search(r'(?:gia|price|don gia|donggia)[:\\s]*([\\d,.]+)', text, re.IGNORECASE)
cat_match = re.search(r'(?:chuyen muc|category|danh muc|loai)[:\\s]*([^|\n]+?)(?:$|\n)', text, re.IGNORECASE)
if name_match: product_name = name_match.group(1).strip()
if desc_match: description = desc_match.group(1).strip()
if price_match: price = price_match.group(1).strip()
if cat_match: category = cat_match.group(1).strip()
if image_url or image_data_b64 or product_name or description or price or category:
_save_product_to_main_dataset(
image_url=image_url, image_data_b64=image_data_b64,
description=description, price=price, category=category,
sender_id=sender_id, sender_name=sender_name, product_name=product_name,
chat_id=chat_id,
)
BOT_STATE["logs"].append({
"event": "product_saved", "sender_id": sender_id, "chat_id": chat_id,
"sender_name": sender_name, "chat_type": chat_type,
"text": "Saved! " + str(product_name)[:50],
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
})
elif _is_zgr_sender_local(sender_id) and text:
# Save all text messages from ZGR group to dataset
_save_text_message_to_dataset(
text=text, description=text[:200], price=price, category=category,
sender_id=sender_id, sender_name=sender_name, chat_id=chat_id,
)
BOT_STATE["logs"].append({
"event": "text_saved", "sender_id": sender_id, "chat_id": chat_id,
"sender_name": sender_name, "chat_type": chat_type,
"text": "Text saved: " + str(text[:100]),
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
})
reply = "👋 Xin chào " + str(sender_name) + " (Zalo ID: " + str(sender_id) + ")!\n\n" + HELP_INSTRUCTIONS
asyncio.create_task(asyncio.to_thread(zapi.send_message, cid, reply))
elif event == "message.image.received" and chat_id:
cid = chat.get("id") or sender.get("id") or ""
_save_chat_id(cid, sender_id)
image_url = msg.get("photo", "") or msg.get("photo_url", "") or msg.get("image_url", "")
image_data_b64 = msg.get("image", "")
attachments = msg.get("attachment", {})
if attachments and not image_url:
payload = attachments.get("payload", {})
if isinstance(payload, str):
try:
payload = json.loads(payload)
except Exception:
payload = {}
image_url = payload.get("url", "")
image_data_b64 = payload.get("data", "")
product_name, description, price, category = "", "", "", ""
text = msg.get("caption", "") or text
name_match = re.search(r'(?:ten sp|ten san pham|product name|name)[:\s]*([^|\n]+)', text, re.IGNORECASE)
desc_match = re.search(r'(?:mo ta|description|desc|mota)[:\\s]*([^|\n]+)', text, re.IGNORECASE)
price_match = re.search(r'(?:gia|price|don gia|donggia)[:\\s]*([\\d,.]+)', text, re.IGNORECASE)
cat_match = re.search(r'(?:chuyen muc|category|danh muc|loai)[:\\s]*([^|\n]+?)(?:$|\n)', text, re.IGNORECASE)
if name_match: product_name = name_match.group(1).strip()
if desc_match: description = desc_match.group(1).strip()
if price_match: price = price_match.group(1).strip()
if cat_match: category = cat_match.group(1).strip()
log_text = "photo_url=" + str(image_url[:100]) if image_url else "No photo_url in message"
if text:
log_text += " | caption=" + str(text[:200])
if image_url:
ts = time.strftime("%Y%m%d_%H%M%S")
log_text += " | image_url=" + str(image_url[:100])
logger.info("image.received from %s, url=%s", sender_id, image_url[:100])
# Download image bytes first (needed for both OCR and saving)
img_bytes = None
if image_data_b64:
try:
img_bytes = base64.b64decode(image_data_b64)
except Exception:
img_bytes = None
if not img_bytes and image_url:
try:
r = requests.get(image_url, timeout=30)
img_bytes = r.content
logger.info("Downloaded image (%d bytes) from %s", len(img_bytes), image_url[:80])
except Exception as e:
logger.error("Image download failed: %s", e)
# Run OCR if we have image bytes and no manual caption
if img_bytes and not text:
logger.info("Running OCR extraction on image from %s", sender_id)
ocr_text = _ocr_extract_text(img_bytes)
if ocr_text:
# Parse OCR text for product fields
ocr_name, ocr_desc, ocr_price, ocr_cat = _parse_ocr_product_info(ocr_text)
if not product_name: product_name = ocr_name
if not description: description = ocr_desc
if not price: price = ocr_price
if not category: category = ocr_cat
log_text += " | OCR: " + str(ocr_text[:200])
else:
log_text += " | OCR failed"
# Now save with OCR-extracted data
saved = _save_product_to_main_dataset(
image_url=image_url, image_data_b64=image_data_b64,
img_bytes=img_bytes,
description=description or text[:200], price=price, category=category,
sender_id=sender_id, sender_name=sender_name, product_name=product_name,
chat_id=chat_id, timestamp=ts,
)
if saved:
BOT_STATE["logs"].append({
"event": "product_saved", "sender_id": sender_id, "chat_id": chat_id,
"sender_name": sender_name, "chat_type": chat_type,
"text": log_text + " | Image saved to dataset! " + str(product_name)[:50],
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
})
try:
zapi = ZaloBotAPI(BOT_STATE["bot_token"])
asyncio.create_task(asyncio.to_thread(zapi.send_message, cid, "GOT IT! Image saved to dataset!"))
except Exception as e:
logger.error("Reply failed: %s", e)
else:
BOT_STATE["logs"].append({
"event": "main_dataset_error", "sender_id": sender_id, "chat_id": chat_id,
"sender_name": sender_name, "chat_type": chat_type,
"text": log_text + " | FAILED to save image to dataset (download or upload error)",
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
})
else:
logger.warning("image.received but no photo_url found: %s", json.dumps(msg)[:300])
BOT_STATE["logs"].append({
"event": "image_no_url", "sender_id": sender_id, "chat_id": chat_id,
"sender_name": sender_name, "chat_type": chat_type,
"text": log_text,
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
})
return Response(content=json.dumps({"message": "Success"}), media_type="application/json", status_code=200)
app = FastAPI(title="Zalo Bot Webhook")
@app.get("/")
async def root():
return RedirectResponse(url="/gradio/")
@app.get("/health")
async def health():
return {"status": "ok", "service": "zalo-bot-webhook", "main_dataset": MAIN_DATASET_ID, "zgr_sender_id": ZGR_SENDER_ID}
@app.post("/webhooks")
async def webhooks(request: Request):
return await handle_webhook(request)
@app.get("/logs")
async def logs_page():
log_lines = []
for idx, log in enumerate(reversed(BOT_STATE.get("logs", [])[-50:])):
sender_id = log.get("sender_id", "")
sender_name = log.get("sender_name", sender_id)
is_zgr = _is_zgr_sender_local(sender_id)
is_saved = log.get("event") in ("dataset_saved", "main_dataset_saved", "proxy_created", "product_saved", "image_saved")
is_error = log.get("event") in ("dataset_error", "main_dataset_error", "parse_error", "image_upload_fail", "image_no_url")
bg = "#e8f5e9" if is_zgr else "#ffffff"
header_color = "#4CAF50" if is_zgr else "#1a73e8"
zgr_badge = " [ZGR]" if is_zgr else ""
status_badge = "SUCCESS" if is_saved else ("ERROR" if is_error else "INFO")
log_lines.append(
"<div style='margin:8px 0;padding:10px;background:" + bg + ";border-radius:6px;border-left:3px solid " + header_color + "'>"
+ "<div style='display:flex;gap:6px;align-items:center;flex-wrap:wrap'>"
+ "<b style='color:" + header_color + "'>" + status_badge + " [" + escape(str(log.get("event",""))) + "]</b>"
+ "<span style='color:" + header_color + "'>👤 " + escape(str(sender_name)) + "</span>"
+ "<span style='color:#666'>🆔 " + escape(str(sender_id)) + "</span>"
+ "<span style='color:#666'>💬 " + escape(str(log.get("chat_id",""))) + "</span>"
+ "<span style='color:#888'>[" + escape(str(log.get("chat_type",""))) + "]</span>"
+ "<span style='color:#4CAF50'>" + zgr_badge + "</span>"
+ "</div>"
+ "<div style='margin-top:4px;color:#333;font-family:monospace;font-size:13px;word-break:break-word'>"
+ escape(str(log.get("text","")[:300]))
+ "</div>"
+ "<div style='margin-top:2px;color:#999;font-size:11px'>⏰ " + escape(str(log.get("time","")) or time.strftime('%Y-%m-%d %H:%M:%S')) + " | <a href='/logs/zgr-b7e1e71cf5701c2e4561'>zgr logs</a></div>"
+ "</div>"
)
total_logs = len(BOT_STATE.get("logs", []))
total_proxies = len(BOT_STATE.get("api_spaces", []))
connected_status = "✅" if BOT_STATE.get("connected") else "❌"
last_sender = escape(str(BOT_STATE.get("last_sender_id", "")[:8]) or "—")
zgr_count = sum(1 for l in BOT_STATE.get("logs", []) if _is_zgr_sender_local(l.get("sender_id", "")))
rows_html = "".join(log_lines) if log_lines else '<p style="color:#999">Chưa có sự kiện</p>'
html_content = (
'<!DOCTYPE html><html><head><title>Zalo Bot Logs</title>'
'<meta http-equiv="refresh" content="5">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
'<style>body { font-family: Arial, sans-serif; max-width: 1200px; margin: 0 auto; padding: 16px; background:#fafafa; }'
'h1 { color: #1a73e8; margin-bottom: 4px; }'
'.subtitle { color: #5f6368; font-size: 14px; margin-bottom: 16px; }'
'.stats { display: flex; gap: 16px; margin: 16px 0; flex-wrap: wrap; }'
'.stat-box { background: #e8f0fe; padding: 12px 24px; border-radius: 10px; min-width: 140px; }'
'.stat-value { font-size: 26px; font-weight: bold; color: #1a73e8; }'
'.stat-label { font-size: 12px; color: #5f6368; }'
'.log-container { max-height: 650px; overflow-y: auto; background:#fff; border-radius:8px; padding:8px; }'
'</style></head><body>'
'<h1>Zalo Bot Logs</h1>'
'<p class="subtitle">Event details</p>'
'<p>Links: <a href="/gradio/">Main UI</a> | <a href="/proxy-spaces">Proxy spaces</a></p>'
'<div class="stats">'
'<div class="stat-box"><div class="stat-value">' + str(total_logs) + '</div><div class="stat-label">Total Events</div></div>'
'<div class="stat-box"><div class="stat-value">' + str(total_proxies) + '</div><div class="stat-label">Proxies</div></div>'
'<div class="stat-box"><div class="stat-value">' + str(zgr_count) + '</div><div class="stat-label">ZGR Events</div></div>'
'<div class="stat-box"><div class="stat-value">' + connected_status + '</div><div class="stat-label">Bot Status</div></div>'
'<div class="stat-box"><div class="stat-value">' + last_sender + '</div><div class="stat-label">Last Sender</div></div>'
'</div>'
'<h2>Events (' + str(total_logs) + ')</h2>'
'<div class="log-container">' + rows_html + '</div>'
'<p><a href="/logs/zgr-b7e1e71cf5701c2e4561">Xem logs riêng cho nhóm ZGR</a></p>'
'</body></html>'
)
return Response(content=html_content, media_type="text/html")
@app.get("/logs/zgr-b7e1e71cf5701c2e4561")
async def zgr_logs_page():
zgr_logs = []
for log in BOT_STATE.get("logs", []):
sid = str(log.get("sender_id", ""))
# Match any sender_id that contains or equals the ZGR identifier
if ZGR_SENDER_ID in sid or sid.endswith(ZGR_SENDER_ID[-12:]) or sid == ZGR_SENDER_ID:
zgr_logs.append(log)
# Also include logs explicitly tagged as ZGR
if log.get("is_zgr", False) and not log.get("sender_id"):
zgr_logs.append(log)
log_lines = []
for idx, log in enumerate(reversed(zgr_logs[-50:])):
sender_id = log.get("sender_id", "")
sender_name = log.get("sender_name", sender_id)
is_saved = log.get("event") in ("dataset_saved", "main_dataset_saved", "proxy_created", "product_saved", "image_saved")
is_error = log.get("event") in ("dataset_error", "main_dataset_error", "parse_error", "image_upload_fail", "image_no_url")
bg = "#ffffff" if idx % 2 == 0 else "#fafafa"
status_color = "#4CAF50" if is_saved else ("#f44336" if is_error else "#1a73e8")
status_icon = "SUCCESS" if is_saved else ("ERROR" if is_error else "INFO")
chat_type_val = escape(str(log.get("chat_type", "")))
text_val = escape(str(log.get("text", "")[:300]))
event_val = escape(str(log.get("event", "")))
sender_val = escape(str(sender_name))
sid_short = escape(str(sender_id)[:20])
cid_short = escape(str(log.get("chat_id", ""))[:20])
time_val = escape(str(log.get("time", "")))
log_lines.append(
"<div style='margin:8px 0;padding:10px;background:" + bg + ";border-radius:6px;border-left:3px solid " + status_color + "'>"
"<div style='display:flex;gap:6px;align-items:center;flex-wrap:wrap'>"
"<b style='color:" + status_color + "'>" + status_icon + " [" + event_val + "]</b>"
"<span style='color:#1a73e8;font-weight:bold'>👤 " + sender_val + "</span>"
"<span style='color:#666'>🆔 " + sid_short + "</span>"
"<span style='color:#666'>💬 " + cid_short + "</span>"
"<span style='color:#666'>[" + chat_type_val + "]</span>"
"</div>"
"<div style='margin-top:4px;color:#333;font-family:monospace;font-size:13px;word-break:break-word'>"
+ text_val
+ "</div>"
"<div style='margin-top:2px;color:#999;font-size:11px'>⏰ " + time_val + " | <a href='https://huggingface.co/datasets/bep40/zalo-products-all' target='_blank'>Dataset</a></div>"
"</div>"
)
saved_count = sum(1 for l in zgr_logs if l.get("event") in ("dataset_saved", "main_dataset_saved", "product_saved"))
error_count = sum(1 for l in zgr_logs if l.get("event") in ("dataset_error", "main_dataset_error", "image_upload_fail"))
total_zgr_logs = len(zgr_logs)
rows_html = "".join(log_lines) if log_lines else '<p style="color:#999">Chưa có sự kiện cho nhóm này. Gửi ảnh + thông tin sản phẩm để kiểm tra.</p>'
html_content = (
'<!DOCTYPE html><html><head><title>Zalo Logs - ZGR Group</title>'
'<meta http-equiv="refresh" content="5">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
'<style>'
'body { font-family: Arial, sans-serif; max-width: 1200px; margin: 0 auto; padding: 16px; background:#fafafa; }'
'h1 { color: #1a73e8; margin-bottom: 4px; }'
'.subtitle { color: #5f6368; font-size: 14px; margin-bottom: 16px; }'
'.stats { display: flex; gap: 16px; margin: 16px 0; flex-wrap: wrap; }'
'.stat-box { padding: 12px 24px; border-radius: 10px; min-width: 140px; }'
'.stat-value { font-size: 26px; font-weight: bold; }'
'.stat-saved { background: #e8f5e9; } .stat-saved .stat-value { color: #4CAF50; }'
'.stat-error { background: #ffebee; } .stat-error .stat-value { color: #f44336; }'
'.stat-total { background: #e8f0fe; } .stat-total .stat-value { color: #1a73e8; }'
'.log-container { max-height: 650px; overflow-y: auto; background:#fff; border-radius:8px; padding:8px; }'
'</style></head><body>'
'<h1>Zalo Logs - ZGR Group (zgr-b7e1e71cf5701c2e4561)</h1>'
'<p class="subtitle">All webhook events from this group</p>'
'<p>Links: <a href="/logs">All logs</a> | <a href="/gradio/">Main UI</a> | <a href="/proxy-spaces">Proxies</a></p>'
'<div class="stats">'
'<div class="stat-box stat-total"><div class="stat-value">' + str(total_zgr_logs) + '</div><div class="stat-label">Total Events</div></div>'
'<div class="stat-box stat-saved"><div class="stat-value">' + str(saved_count) + '</div><div class="stat-label">Saved to Dataset</div></div>'
'<div class="stat-box stat-error"><div class="stat-value">' + str(error_count) + '</div><div class="stat-label">Errors</div></div>'
'<div class="stat-box stat-total"><div class="stat-value"><a href="https://huggingface.co/datasets/bep40/zalo-products-all" target="_blank">Dataset</a></div><div class="stat-label">Main Dataset</div></div>'
'</div>'
'<h2>Events (' + str(total_zgr_logs) + ')</h2>'
'<div class="log-container">' + rows_html + '</div>'
'<p style="color:#5f6368;font-size:13px;margin-top:12px">Send image + "Tên sp: ..., Giá: ..., Chuyên mục: ..." to test.</p>'
'</body></html>'
)
return Response(content=html_content, media_type="text/html")
@app.get("/proxy-spaces")
async def proxy_spaces_page():
rows = []
for s in reversed(BOT_STATE.get("api_spaces", [])[-20:]):
repo_name = escape(str(s.get("repo_id", "").split("/")[-1]))
dataset_id_val = s.get("dataset_id", "")
dataset_link = "<a href='https://huggingface.co/datasets/" + escape(dataset_id_val) + "' target='_blank'>💾 dataset</a>" if dataset_id_val else ""
rows.append(
"<div style='margin:8px 0;padding:12px;background:#fff;border-radius:8px;border-left:4px solid #4CAF50;box-shadow:0 1px 3px rgba(0,0,0,0.1)'>"
"<div style='display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:space-between'>"
"<div>"
"<b style='color:#1a73e8'>👤 " + escape(str(s.get("sender_name", ""))) + "</b>"
"<span style='color:#666'>🆔 " + escape(str(s.get("user_id", ""))) + "</span>"
"<span style='color:#4CAF50;font-weight:bold'>[" + escape(str(s.get("status", ""))) + "]</span>"
"</div>"
"<div style='display:flex;gap:6px;flex-wrap:wrap'>"
"<a href='https://huggingface.co/spaces/bep40/" + repo_name + "' target='_blank'>Space</a>"
"<a href='" + escape(str(s.get("proxy_url", ""))) + "' target='_blank'>webhook</a>"
"<a href='" + escape(str(s.get("proxy_url", "")).replace("/webhooks", "/logs")) + "' target='_blank'>📊 logs</a>"
+ dataset_link +
"</div>"
"</div>"
"<div style='margin-top:6px'><span style='color:#5f6368'>Repo:</span> <code>" + escape(str(s.get("repo_id", ""))) + "</code></div>"
"<div style='margin-top:2px;color:#999;font-size:11px'>⏰ " + time.strftime('%Y-%m-%d %H:%M:%S') + "</div>"
"</div>"
)
total_proxies = len(BOT_STATE.get("api_spaces", []))
connected_status = "✅" if BOT_STATE.get("connected") else "❌"
rows_html = "".join(rows) if rows else '<p style="color:#999">Chưa có proxy space nào</p>'
html_content = (
'<!DOCTYPE html><html><head><title>Proxy Spaces</title>'
'<meta http-equiv="refresh" content="5">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
'<style>'
'body { font-family: Arial, sans-serif; max-width: 1200px; margin: 0 auto; padding: 16px; background:#fafafa; }'
'h1 { color: #1a73e8; } .subtitle { color: #5f6368; }'
'.stats { display: flex; gap: 16px; margin: 16px 0; flex-wrap: wrap; }'
'.stat-box { background: #e8f0fe; padding: 12px 24px; border-radius: 10px; min-width: 140px; }'
'.stat-value { font-size: 26px; font-weight: bold; color: #1a73e8; }'
'.stat-label { font-size: 12px; color: #5f6368; }'
'.container { background:#fff; border-radius:8px; padding:12px; }'
'a { color: #1a73e8; text-decoration: none; cursor: pointer; }'
'a:hover { text-decoration: underline; }'
'</style></head><body>'
'<h1>Quan ly Proxy Spaces</h1>'
'<p class="subtitle">Danh sach cac space proxy da tao.</p>'
'<p>Links: <a href="/logs">Logs</a> | <a href="/gradio/">Main UI</a></p>'
'<div class="stats">'
'<div class="stat-box"><div class="stat-value">' + str(total_proxies) + '</div><div class="stat-label">Proxies</div></div>'
'<div class="stat-box"><div class="stat-value">' + connected_status + '</div><div class="stat-label">Bot Status</div></div>'
'</div>'
'<div class="container">'
+ rows_html +
'</div>'
'</body></html>'
)
return Response(content=html_content, media_type="text/html")
@app.post("/api/delete-proxy/{repo_name}")
async def delete_proxy(repo_name: str):
repo_id = NAMESPACE + "/" + repo_name
try:
api = HfApi(token=HF_TOKEN)
try:
api.delete_repo(repo_id=repo_id, repo_type="space", token=HF_TOKEN)
except Exception as e:
logger.warning("Could not delete HF Space %s: %s", repo_id, e)
for s in BOT_STATE.get("api_spaces", []):
if s.get("repo_id", "").split("/")[-1] == repo_name:
dataset_id = s.get("dataset_id", "")
if dataset_id:
try:
api.delete_repo(repo_id=dataset_id, repo_type="dataset", token=HF_TOKEN)
except Exception as e:
logger.warning("Could not delete dataset %s: %s", dataset_id, e)
BOT_STATE["api_spaces"] = [s for s in BOT_STATE.get("api_spaces", []) if s.get("repo_id", "").split("/")[-1] != repo_name]
_save_proxy_spaces()
return {"ok": True, "message": "Da xoa proxy " + repo_id}
except Exception as e:
logger.error("Delete proxy failed: %s", e)
return {"ok": False, "message": "Loi xoa: " + str(e)}
with gr.Blocks(title="Zalo Bot Webhook") as demo:
gr.Markdown("# Zalo Bot Webhook Setup")
with gr.Tabs():
with gr.Tab("Ket noi Bot"):
tok = gr.Textbox(DEFAULT_BOT_TOKEN, label="Bot Token", type="password")
btn = gr.Button("Ket noi")
res = gr.Markdown("")
btn.click(fn=connect_bot, inputs=[tok], outputs=res)
gr.Textbox(value=get_botinfo, label="Thong tin bot", interactive=False, lines=8)
with gr.Tab("Gui tin"):
with gr.Row():
cid = gr.Textbox(label="Chat ID")
txt = gr.Textbox("HTTP API: 4179413508988279245:abc123", label="Noi dung")
b = gr.Button("Gui")
b.click(fn=send_msg, inputs=[cid, txt], outputs=gr.Textbox(label="Ket qua"))
gr.Textbox(value=get_events, label="Su kien nhan duoc", interactive=False, lines=20)
gr.Markdown(
"Links: [Proxy spaces](/proxy-spaces)\n\n"
"Each user sends HTTP API token to create their own proxy space."
)
with gr.Tab("Huong dan"):
gr.Markdown("1. Go to https://zalo.me/s/botcreator\n2. Copy HTTP API token\n3. Send to bot to auto-create proxy")
demo.queue()
app = gr.mount_gradio_app(app, demo, path="/gradio")
print("[startup] FastAPI app ready: /health, /webhooks, /logs, /logs/zgr-b7e1e71cf5701c2e4561, /proxy-spaces, /gradio/", flush=True)
if __name__ == "__main__":
port = int(os.getenv("PORT", "7860"))
server_name = os.getenv("GRADIO_SERVER_NAME", "0.0.0.0")
print("[launch] uvicorn on " + server_name + ":" + str(port), flush=True)
import uvicorn
uvicorn.run(app, host=server_name, port=port)