Merge branch 'dev'
This commit is contained in:
@@ -0,0 +1,171 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
'on':
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Shared image coordinates for the publish job.
|
||||||
|
REGISTRY: gcr.forust.xyz
|
||||||
|
IMAGE_NAME: forust/telegram-scraper
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-prettier:
|
||||||
|
runs-on: [self-hosted, linux, arch, homelab]
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check formatting with Prettier
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mapfile -t prettier_files < <(
|
||||||
|
git ls-files \
|
||||||
|
| grep -E '\.(md|json|ya?ml|html|css)$' \
|
||||||
|
| grep -Ev '^(\.docs/|\.zed/|errorpages/html/|homepages/(forust_files|xdfnx_files)/)'
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "${#prettier_files[@]}" -eq 0 ]; then
|
||||||
|
echo "No Prettier-managed files found."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
node:22-alpine \
|
||||||
|
sh -lc 'npx --yes prettier@3 --check --ignore-unknown "$@"' sh "${prettier_files[@]}"
|
||||||
|
|
||||||
|
lint-ruff:
|
||||||
|
runs-on: [self-hosted, linux, arch, homelab]
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Lint Python with Ruff
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
ghcr.io/astral-sh/ruff:latest \
|
||||||
|
check .
|
||||||
|
|
||||||
|
lint-yaml:
|
||||||
|
runs-on: [self-hosted, linux, arch, homelab]
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Lint YAML syntax
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
cytopia/yamllint:latest \
|
||||||
|
-c .yamllint .
|
||||||
|
|
||||||
|
lint-dockerfiles:
|
||||||
|
runs-on: [self-hosted, linux, arch, homelab]
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Lint Dockerfiles
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mapfile -t dockerfiles < <(
|
||||||
|
git ls-files ':(glob)**/Dockerfile' ':(glob)**/Dockerfile.*'
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "${#dockerfiles[@]}" -eq 0 ]; then
|
||||||
|
echo "No Dockerfiles found."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
--entrypoint hadolint \
|
||||||
|
hadolint/hadolint:latest-debian \
|
||||||
|
-c .hadolint.yaml "${dockerfiles[@]}"
|
||||||
|
|
||||||
|
validate:
|
||||||
|
runs-on: [self-hosted, linux, arch, homelab]
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Validate Kubernetes manifests
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mapfile -t manifests < <(
|
||||||
|
git ls-files ':(glob)**/k8s/**/*.yaml' ':(glob)**/k8s/**/*.yml' \
|
||||||
|
| grep -Ev '(^|/)(kustomization\.ya?ml|.*\.example\.ya?ml|.*values\.ya?ml|patch-.*\.ya?ml)$'
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "${#manifests[@]}" -eq 0 ]; then
|
||||||
|
echo "No Kubernetes manifests found."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
ghcr.io/yannh/kubeconform:latest \
|
||||||
|
-strict \
|
||||||
|
-ignore-missing-schemas \
|
||||||
|
-summary \
|
||||||
|
"${manifests[@]}"
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: [lint-prettier, lint-ruff, lint-yaml, lint-dockerfiles, validate]
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
runs-on: [self-hosted, linux, arch, homelab]
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Read project version
|
||||||
|
id: version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
version="$(python3 -c 'from pathlib import Path; import tomllib; print(tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"])')"
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${REGISTRY}" \
|
||||||
|
-u "${{ secrets.REGISTRY_USERNAME }}" \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
image="${REGISTRY}/${IMAGE_NAME}"
|
||||||
|
tags=("latest" "${{ steps.version.outputs.version }}")
|
||||||
|
|
||||||
|
case "${GITHUB_REF_NAME}" in
|
||||||
|
main)
|
||||||
|
tags+=("main" "prod")
|
||||||
|
;;
|
||||||
|
dev)
|
||||||
|
tags+=("dev")
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
build_args=()
|
||||||
|
for tag in "${tags[@]}"; do
|
||||||
|
build_args+=(-t "${image}:${tag}")
|
||||||
|
done
|
||||||
|
|
||||||
|
docker build "${build_args[@]}" .
|
||||||
|
|
||||||
|
for tag in "${tags[@]}"; do
|
||||||
|
docker push "${image}:${tag}"
|
||||||
|
done
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
'on':
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Shared image coordinates for the publish job.
|
||||||
|
REGISTRY: gcr.forust.xyz
|
||||||
|
IMAGE_NAME: forust/telegram-scraper
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-prettier:
|
||||||
|
runs-on: [self-hosted, linux, arch, homelab]
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check formatting with Prettier
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mapfile -t prettier_files < <(
|
||||||
|
git ls-files \
|
||||||
|
| grep -E '\.(md|json|ya?ml|html|css)$' \
|
||||||
|
| grep -Ev '^(\.docs/|\.zed/|errorpages/html/|homepages/(forust_files|xdfnx_files)/)'
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "${#prettier_files[@]}" -eq 0 ]; then
|
||||||
|
echo "No Prettier-managed files found."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
node:22-alpine \
|
||||||
|
sh -lc 'npx --yes prettier@3 --check --ignore-unknown "$@"' sh "${prettier_files[@]}"
|
||||||
|
|
||||||
|
lint-ruff:
|
||||||
|
runs-on: [self-hosted, linux, arch, homelab]
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Lint Python with Ruff
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
ghcr.io/astral-sh/ruff:latest \
|
||||||
|
check .
|
||||||
|
|
||||||
|
lint-yaml:
|
||||||
|
runs-on: [self-hosted, linux, arch, homelab]
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Lint YAML syntax
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
cytopia/yamllint:latest \
|
||||||
|
-c .yamllint .
|
||||||
|
|
||||||
|
lint-dockerfiles:
|
||||||
|
runs-on: [self-hosted, linux, arch, homelab]
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Lint Dockerfiles
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mapfile -t dockerfiles < <(
|
||||||
|
git ls-files ':(glob)**/Dockerfile' ':(glob)**/Dockerfile.*'
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "${#dockerfiles[@]}" -eq 0 ]; then
|
||||||
|
echo "No Dockerfiles found."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
--entrypoint hadolint \
|
||||||
|
hadolint/hadolint:latest-debian \
|
||||||
|
-c .hadolint.yaml "${dockerfiles[@]}"
|
||||||
|
|
||||||
|
validate:
|
||||||
|
runs-on: [self-hosted, linux, arch, homelab]
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Validate Kubernetes manifests
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mapfile -t manifests < <(
|
||||||
|
git ls-files ':(glob)**/k8s/**/*.yaml' ':(glob)**/k8s/**/*.yml' \
|
||||||
|
| grep -Ev '(^|/)(kustomization\.ya?ml|.*\.example\.ya?ml|.*values\.ya?ml|patch-.*\.ya?ml)$'
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "${#manifests[@]}" -eq 0 ]; then
|
||||||
|
echo "No Kubernetes manifests found."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
ghcr.io/yannh/kubeconform:latest \
|
||||||
|
-strict \
|
||||||
|
-ignore-missing-schemas \
|
||||||
|
-summary \
|
||||||
|
"${manifests[@]}"
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: [lint-prettier, lint-ruff, lint-yaml, lint-dockerfiles, validate]
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
runs-on: [self-hosted, linux, arch, homelab]
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Read project version
|
||||||
|
id: version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
version="$(python3 -c 'from pathlib import Path; import tomllib; print(tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"])')"
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${REGISTRY}" \
|
||||||
|
-u "${{ secrets.REGISTRY_USERNAME }}" \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
image="${REGISTRY}/${IMAGE_NAME}"
|
||||||
|
tags=("latest" "${{ steps.version.outputs.version }}")
|
||||||
|
|
||||||
|
case "${GITHUB_REF_NAME}" in
|
||||||
|
main)
|
||||||
|
tags+=("main" "prod")
|
||||||
|
;;
|
||||||
|
dev)
|
||||||
|
tags+=("dev")
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
build_args=()
|
||||||
|
for tag in "${tags[@]}"; do
|
||||||
|
build_args+=(-t "${image}:${tag}")
|
||||||
|
done
|
||||||
|
|
||||||
|
docker build "${build_args[@]}" .
|
||||||
|
|
||||||
|
for tag in "${tags[@]}"; do
|
||||||
|
docker push "${image}:${tag}"
|
||||||
|
done
|
||||||
@@ -142,7 +142,7 @@ kubectl apply -f k8s/telegram-scraper.yaml
|
|||||||
Then open:
|
Then open:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://<server-ip>:8080
|
http://<server-ip>:7887
|
||||||
```
|
```
|
||||||
|
|
||||||
### Volumes (Docker)
|
### Volumes (Docker)
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ ACCOUNT_DEFAULTS: Dict[str, Any] = {
|
|||||||
"scrape_media": True,
|
"scrape_media": True,
|
||||||
"forwarding_rules": [],
|
"forwarding_rules": [],
|
||||||
"continuous_scraping": {
|
"continuous_scraping": {
|
||||||
"enabled": True,
|
"enabled": False,
|
||||||
"interval_minutes": 1,
|
"interval_minutes": 1,
|
||||||
"channels": [],
|
"channels": [],
|
||||||
"run_all_tracked": True,
|
"run_all_tracked": True,
|
||||||
|
|||||||
+10
-4
@@ -5,13 +5,19 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: telegram-scraper
|
container_name: telegram-scraper
|
||||||
image: gcr.forust.xyz/forust/telegram-scraper:latest
|
image: gcr.forust.xyz/forust/telegram-scraper:latest
|
||||||
user: 1000:1000
|
|
||||||
restart: unless-stopped
|
|
||||||
tty: true
|
tty: true
|
||||||
|
user: '1000:1000'
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "7887:8080"
|
- 7887:8080
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import http.client,json;c=http.client.HTTPConnection('localhost',8080);c.request('GET','/health');r=c.getresponse();exit(0)if json.loads(r.read()).get('ok')else exit(1)"]
|
test:
|
||||||
|
[
|
||||||
|
CMD,
|
||||||
|
python,
|
||||||
|
-c,
|
||||||
|
"import http.client, json; c=http.client.HTTPConnection('localhost', 8080); c.request('GET', '/health'); r=c.getresponse(); exit(0) if json.loads(r.read()).get('ok') else exit(1)",
|
||||||
|
]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ def health_payload(
|
|||||||
job_queue_size: int,
|
job_queue_size: int,
|
||||||
account_ids: Optional[List[str]] = None,
|
account_ids: Optional[List[str]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Return a health-check payload for the application.
|
||||||
|
|
||||||
|
NOTE: This endpoint does NOT support message search or query parameters.
|
||||||
|
Health concerns itself with filesystem, database connectivity, job queue,
|
||||||
|
and account session state. Message search is handled by the message API
|
||||||
|
endpoints (/api/channels/*/messages, /api/accounts/*/channels/*/messages).
|
||||||
|
"""
|
||||||
checks = {
|
checks = {
|
||||||
"data_dir": _dir_check(data_dir, writable=True),
|
"data_dir": _dir_check(data_dir, writable=True),
|
||||||
"session_dir": _dir_check(session_dir, writable=True),
|
"session_dir": _dir_check(session_dir, writable=True),
|
||||||
@@ -35,7 +43,13 @@ def health_payload(
|
|||||||
"path": str(session_file),
|
"path": str(session_file),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
checks["accounts"] = account_checks
|
checks["accounts"] = {
|
||||||
|
"ok": all(
|
||||||
|
item.get("data_dir", {}).get("ok", False)
|
||||||
|
for item in account_checks.values()
|
||||||
|
),
|
||||||
|
"items": account_checks,
|
||||||
|
}
|
||||||
|
|
||||||
ok = all(item.get("ok", False) for item in checks.values())
|
ok = all(item.get("ok", False) for item in checks.values())
|
||||||
return {"ok": ok, "status": "ok" if ok else "degraded", "checks": checks}
|
return {"ok": ok, "status": "ok" if ok else "degraded", "checks": checks}
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: telegram-scraper
|
- name: telegram-scraper
|
||||||
image: gcr.forust.xyz/forust/telegram-scraper:latest
|
image: gcr.forust.xyz/forust/telegram-scraper:latest
|
||||||
tty: true
|
|
||||||
stdin: true
|
stdin: true
|
||||||
|
tty: true
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
@@ -59,14 +59,16 @@ spec:
|
|||||||
- metadata:
|
- metadata:
|
||||||
name: telegram-scraper-data
|
name: telegram-scraper-data
|
||||||
spec:
|
spec:
|
||||||
accessModes: ["ReadWriteOnce"]
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: 20Gi
|
storage: 20Gi
|
||||||
- metadata:
|
- metadata:
|
||||||
name: telegram-scraper-session
|
name: telegram-scraper-session
|
||||||
spec:
|
spec:
|
||||||
accessModes: ["ReadWriteOnce"]
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: 5Mi
|
storage: 5Mi
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "telegram-scraper"
|
name = "telegram-scraper"
|
||||||
version = "0.1.0"
|
version = "2.0.0"
|
||||||
description = "Telegram-scraper with webui"
|
description = "Telegram-scraper with webui"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -23,11 +23,8 @@ from telethon.errors import FloodWaitError, SessionPasswordNeededError
|
|||||||
import qrcode
|
import qrcode
|
||||||
from app_state import (
|
from app_state import (
|
||||||
StateStore,
|
StateStore,
|
||||||
account_data_dir,
|
|
||||||
account_session_path,
|
account_session_path,
|
||||||
get_account_store,
|
get_account_store,
|
||||||
load_account,
|
|
||||||
save_account,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
warnings.filterwarnings(
|
warnings.filterwarnings(
|
||||||
|
|||||||
@@ -0,0 +1,604 @@
|
|||||||
|
"""
|
||||||
|
Smoke / integration tests for the multi-account Telegram scraper core logic.
|
||||||
|
|
||||||
|
These tests do NOT require Telegram credentials or network access.
|
||||||
|
They verify:
|
||||||
|
- Account creation and state isolation
|
||||||
|
- Account import safety (no auto-start continuous)
|
||||||
|
- Account export round-trip
|
||||||
|
- Account deletion (sidecar file cleanup)
|
||||||
|
- Active account persistence helpers
|
||||||
|
- Message search SQL construction
|
||||||
|
- Health payload structure
|
||||||
|
- Continuous orchestrator worker deduplication
|
||||||
|
- load_messages() pagination with search filter
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
# ── Mock heavy dependencies before any webui_server import ─────────────
|
||||||
|
# We need to mock qrcode and telethon because they aren't installed in CI.
|
||||||
|
sys.modules["qrcode"] = MagicMock()
|
||||||
|
sys.modules["qrcode.image"] = MagicMock()
|
||||||
|
sys.modules["qrcode.image.svg"] = MagicMock()
|
||||||
|
sys.modules["telethon"] = MagicMock()
|
||||||
|
sys.modules["telethon.errors"] = MagicMock()
|
||||||
|
sys.modules["telethon.errors"].SessionPasswordNeededError = type("SessionPasswordNeededError", (Exception,), {})
|
||||||
|
sys.modules["telegram_scraper_with_forwarding"] = MagicMock()
|
||||||
|
sys.modules["telegram_scraper_with_forwarding"]._ensure_session_wal = lambda p: None
|
||||||
|
sys.modules["telegram_scraper_with_forwarding"].OptimizedTelegramScraper = MagicMock
|
||||||
|
|
||||||
|
# ── initialise before importing app modules ─────────────────────────────
|
||||||
|
|
||||||
|
TEST_TMP = Path(tempfile.mkdtemp(prefix="tg_scraper_test_"))
|
||||||
|
TEST_DATA = TEST_TMP / "data"
|
||||||
|
TEST_SESSION = TEST_TMP / "session"
|
||||||
|
TEST_DATA.mkdir(parents=True, exist_ok=True)
|
||||||
|
TEST_SESSION.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
os.environ["TELEGRAM_SCRAPER_HOST"] = "127.0.0.1"
|
||||||
|
os.environ["TELEGRAM_SCRAPER_PORT"] = "0"
|
||||||
|
os.environ["TELEGRAM_SCRAPER_START_CONTINUOUS"] = "0"
|
||||||
|
|
||||||
|
# Now safe to import app modules
|
||||||
|
import app_state # noqa: E402
|
||||||
|
from app_state import ( # noqa: E402
|
||||||
|
ACCOUNT_DEFAULTS,
|
||||||
|
account_data_dir,
|
||||||
|
account_exists,
|
||||||
|
account_session_path,
|
||||||
|
get_account_store,
|
||||||
|
get_global_store,
|
||||||
|
list_accounts,
|
||||||
|
load_account,
|
||||||
|
)
|
||||||
|
from health import health_payload # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _init_test_env():
|
||||||
|
"""Reset global caches and re-initialise for a clean test."""
|
||||||
|
app_state._GLOBAL_STORE = None
|
||||||
|
with app_state._account_stores_lock:
|
||||||
|
app_state._ACCOUNT_STORES.clear()
|
||||||
|
|
||||||
|
|
||||||
|
_init_test_env()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def make_account_id() -> str:
|
||||||
|
return f"test-{int(time.time() * 1000000)}"
|
||||||
|
|
||||||
|
|
||||||
|
def create_account(data_dir: Path, account_id: str, **overrides) -> Dict[str, Any]:
|
||||||
|
store = get_account_store(data_dir, account_id)
|
||||||
|
state = dict(ACCOUNT_DEFAULTS)
|
||||||
|
state.update(overrides)
|
||||||
|
state["label"] = overrides.get("label", account_id)
|
||||||
|
store.save(state)
|
||||||
|
|
||||||
|
def mutate(g: Dict[str, Any]) -> None:
|
||||||
|
accounts = g.setdefault("accounts", [])
|
||||||
|
if account_id not in accounts:
|
||||||
|
accounts.append(account_id)
|
||||||
|
|
||||||
|
get_global_store(data_dir).update(mutate)
|
||||||
|
return load_account(data_dir, account_id)
|
||||||
|
|
||||||
|
|
||||||
|
def create_channel_db(data_dir: Path, account_id: Optional[str], channel_id: str, messages: List[Dict[str, Any]]) -> None:
|
||||||
|
if account_id:
|
||||||
|
db_dir = account_data_dir(data_dir, account_id) / channel_id
|
||||||
|
else:
|
||||||
|
db_dir = data_dir / channel_id
|
||||||
|
db_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
db_path = db_dir / f"{channel_id}.db"
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
message_id INTEGER PRIMARY KEY,
|
||||||
|
date TEXT, sender_id INTEGER,
|
||||||
|
first_name TEXT, last_name TEXT, username TEXT,
|
||||||
|
message TEXT, media_type TEXT, media_path TEXT,
|
||||||
|
reply_to INTEGER, post_author TEXT,
|
||||||
|
views INTEGER, forwards INTEGER, reactions TEXT
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
for m in messages:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO messages (message_id, date, message) VALUES (?, ?, ?)",
|
||||||
|
(m["message_id"], m.get("date"), m.get("message")),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fixture setup / teardown ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def setup_function():
|
||||||
|
_init_test_env()
|
||||||
|
for child in TEST_DATA.iterdir():
|
||||||
|
if child.is_dir():
|
||||||
|
shutil.rmtree(child, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
child.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_function():
|
||||||
|
_init_test_env()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestAccountLifecycle:
|
||||||
|
"""Account creation, isolation, import, export, deletion."""
|
||||||
|
|
||||||
|
def test_create_account_minimal(self):
|
||||||
|
aid = make_account_id()
|
||||||
|
create_account(TEST_DATA, aid)
|
||||||
|
assert account_exists(TEST_DATA, aid)
|
||||||
|
assert aid in list_accounts(TEST_DATA)
|
||||||
|
state = load_account(TEST_DATA, aid)
|
||||||
|
assert state["label"] == aid
|
||||||
|
assert state["continuous_scraping"]["enabled"] is False
|
||||||
|
|
||||||
|
def test_account_state_isolation(self):
|
||||||
|
a1, a2 = make_account_id(), make_account_id()
|
||||||
|
create_account(TEST_DATA, a1, label="Alpha", api_id=111)
|
||||||
|
create_account(TEST_DATA, a2, label="Beta", api_id=222)
|
||||||
|
s1 = load_account(TEST_DATA, a1)
|
||||||
|
s2 = load_account(TEST_DATA, a2)
|
||||||
|
assert s1["label"] == "Alpha"
|
||||||
|
assert s1["api_id"] == 111
|
||||||
|
assert s2["label"] == "Beta"
|
||||||
|
assert s2["api_id"] == 222
|
||||||
|
# channels are segregated — mutate one, verify other unchanged
|
||||||
|
s1.setdefault("channels", {})["-100aaa"] = 1
|
||||||
|
get_account_store(TEST_DATA, a1).save(s1)
|
||||||
|
s2_reloaded = load_account(TEST_DATA, a2)
|
||||||
|
assert "-100aaa" not in s2_reloaded.get("channels", {})
|
||||||
|
|
||||||
|
def test_delete_account_cleans_session_sidecars(self):
|
||||||
|
aid = make_account_id()
|
||||||
|
create_account(TEST_DATA, aid)
|
||||||
|
session_path = Path(account_session_path(TEST_SESSION, aid))
|
||||||
|
session_path.write_text("session-data")
|
||||||
|
Path(str(session_path) + "-wal").write_text("wal")
|
||||||
|
Path(str(session_path) + "-shm").write_text("shm")
|
||||||
|
Path(str(session_path) + "-journal").write_text("journal")
|
||||||
|
|
||||||
|
acc_dir = account_data_dir(TEST_DATA, aid)
|
||||||
|
if acc_dir.exists():
|
||||||
|
shutil.rmtree(str(acc_dir), ignore_errors=True)
|
||||||
|
for sidecar in (
|
||||||
|
session_path,
|
||||||
|
Path(str(session_path) + "-wal"),
|
||||||
|
Path(str(session_path) + "-shm"),
|
||||||
|
session_path.with_suffix(session_path.suffix + "-journal"),
|
||||||
|
):
|
||||||
|
if sidecar.exists():
|
||||||
|
sidecar.unlink()
|
||||||
|
|
||||||
|
assert not session_path.exists()
|
||||||
|
assert not Path(str(session_path) + "-wal").exists()
|
||||||
|
assert not Path(str(session_path) + "-shm").exists()
|
||||||
|
assert not Path(str(session_path) + "-journal").exists()
|
||||||
|
assert not acc_dir.exists()
|
||||||
|
|
||||||
|
def test_delete_account_skips_missing_sidecars(self):
|
||||||
|
aid = make_account_id()
|
||||||
|
create_account(TEST_DATA, aid)
|
||||||
|
session_path = Path(account_session_path(TEST_SESSION, aid))
|
||||||
|
session_path.write_text("session-only")
|
||||||
|
acc_dir = account_data_dir(TEST_DATA, aid)
|
||||||
|
if acc_dir.exists():
|
||||||
|
shutil.rmtree(str(acc_dir), ignore_errors=True)
|
||||||
|
for sidecar in (
|
||||||
|
session_path,
|
||||||
|
Path(str(session_path) + "-wal"),
|
||||||
|
Path(str(session_path) + "-shm"),
|
||||||
|
session_path.with_suffix(session_path.suffix + "-journal"),
|
||||||
|
):
|
||||||
|
if sidecar.exists():
|
||||||
|
sidecar.unlink()
|
||||||
|
assert not session_path.exists()
|
||||||
|
|
||||||
|
def test_import_export_round_trip(self):
|
||||||
|
aid = make_account_id()
|
||||||
|
create_account(
|
||||||
|
TEST_DATA, aid,
|
||||||
|
label="Imported",
|
||||||
|
api_id=999,
|
||||||
|
api_hash="abc123",
|
||||||
|
channels={"-100ch1": 42},
|
||||||
|
scrape_media=False,
|
||||||
|
continuous_scraping={"enabled": True, "interval_minutes": 5, "channels": [], "run_all_tracked": True},
|
||||||
|
)
|
||||||
|
state = load_account(TEST_DATA, aid)
|
||||||
|
|
||||||
|
aid2 = make_account_id()
|
||||||
|
raw_state = state
|
||||||
|
imported_state = {
|
||||||
|
"label": raw_state.get("label", aid2),
|
||||||
|
"api_id": raw_state.get("api_id"),
|
||||||
|
"api_hash": raw_state.get("api_hash"),
|
||||||
|
"channels": raw_state.get("channels", {}),
|
||||||
|
"channel_names": raw_state.get("channel_names", {}),
|
||||||
|
"scrape_media": bool(raw_state.get("scrape_media", True)),
|
||||||
|
"forwarding_rules": raw_state.get("forwarding_rules", []),
|
||||||
|
"continuous_scraping": raw_state.get("continuous_scraping") if isinstance(raw_state.get("continuous_scraping"), dict) else {
|
||||||
|
"enabled": False, "interval_minutes": 1, "channels": [], "run_all_tracked": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
get_account_store(TEST_DATA, aid2).save(imported_state)
|
||||||
|
|
||||||
|
def mutate_global(g):
|
||||||
|
g.setdefault("accounts", []).append(aid2)
|
||||||
|
get_global_store(TEST_DATA).update(mutate_global)
|
||||||
|
|
||||||
|
loaded = load_account(TEST_DATA, aid2)
|
||||||
|
assert loaded["label"] == "Imported"
|
||||||
|
assert loaded["api_id"] == 999
|
||||||
|
assert loaded["channels"] == {"-100ch1": 42}
|
||||||
|
assert loaded["scrape_media"] is False
|
||||||
|
assert loaded["continuous_scraping"]["enabled"] is True
|
||||||
|
|
||||||
|
def test_import_defaults_continuous_disabled_when_missing(self):
|
||||||
|
aid = make_account_id()
|
||||||
|
raw_state = {"label": "NoCont", "api_id": 1, "api_hash": "x"}
|
||||||
|
imported_state = {
|
||||||
|
"label": raw_state.get("label", aid),
|
||||||
|
"api_id": raw_state.get("api_id"),
|
||||||
|
"api_hash": raw_state.get("api_hash"),
|
||||||
|
"channels": {},
|
||||||
|
"channel_names": {},
|
||||||
|
"scrape_media": True,
|
||||||
|
"forwarding_rules": [],
|
||||||
|
"continuous_scraping": raw_state.get("continuous_scraping") if isinstance(raw_state.get("continuous_scraping"), dict) else {
|
||||||
|
"enabled": False, "interval_minutes": 1, "channels": [], "run_all_tracked": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
get_account_store(TEST_DATA, aid).save(imported_state)
|
||||||
|
|
||||||
|
def mutate_global(g):
|
||||||
|
g.setdefault("accounts", []).append(aid)
|
||||||
|
get_global_store(TEST_DATA).update(mutate_global)
|
||||||
|
|
||||||
|
loaded = load_account(TEST_DATA, aid)
|
||||||
|
assert loaded["continuous_scraping"]["enabled"] is False
|
||||||
|
assert loaded["continuous_scraping"]["run_all_tracked"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestContinuousOrchestrator:
|
||||||
|
"""Continuous scraping manager safety using mocked webui_server."""
|
||||||
|
|
||||||
|
def _import_orch_classes(self):
|
||||||
|
"""Import classes after mocking qrcode/telethon in webui_server."""
|
||||||
|
import webui_server as ws_module
|
||||||
|
return ws_module.PerAccountContinuousScrapeManager, ws_module.ContinuousScrapeOrchestrator, ws_module
|
||||||
|
|
||||||
|
def _setup_ws_data_dir(self):
|
||||||
|
"""Point webui_server globals at TEST_DATA/SESSION."""
|
||||||
|
import webui_server as ws_module
|
||||||
|
self._ws_orig_data = ws_module.DATA_DIR
|
||||||
|
self._ws_orig_session = ws_module.SESSION_DIR
|
||||||
|
ws_module.DATA_DIR = TEST_DATA
|
||||||
|
ws_module.SESSION_DIR = TEST_SESSION
|
||||||
|
ws_module.START_CONTINUOUS = False
|
||||||
|
|
||||||
|
def _restore_ws_data_dir(self):
|
||||||
|
import webui_server as ws_module
|
||||||
|
ws_module.DATA_DIR = self._ws_orig_data
|
||||||
|
ws_module.SESSION_DIR = self._ws_orig_session
|
||||||
|
|
||||||
|
def test_does_not_auto_start_on_import(self):
|
||||||
|
"""Importing an account must not trigger continuous scraping."""
|
||||||
|
PerAccountContinuousScrapeManager, ContinuousScrapeOrchestrator, ws = self._import_orch_classes()
|
||||||
|
self._setup_ws_data_dir()
|
||||||
|
try:
|
||||||
|
orch = ContinuousScrapeOrchestrator()
|
||||||
|
aid = make_account_id()
|
||||||
|
create_account(TEST_DATA, aid, continuous_scraping={
|
||||||
|
"enabled": True, "interval_minutes": 1, "channels": [], "run_all_tracked": True,
|
||||||
|
})
|
||||||
|
orch.add_account(aid, auto_start=False)
|
||||||
|
snap = orch.snapshot_for(aid)
|
||||||
|
assert snap["status"]["running"] is False
|
||||||
|
finally:
|
||||||
|
self._restore_ws_data_dir()
|
||||||
|
|
||||||
|
def test_no_duplicate_workers_on_same_account(self):
|
||||||
|
"""Multiple start() calls should not spawn duplicate threads."""
|
||||||
|
PerAccountContinuousScrapeManager, _, ws = self._import_orch_classes()
|
||||||
|
self._setup_ws_data_dir()
|
||||||
|
try:
|
||||||
|
aid = make_account_id()
|
||||||
|
# Must set enabled=True or refresh_config() will stop the thread
|
||||||
|
create_account(TEST_DATA, aid, continuous_scraping={
|
||||||
|
"enabled": True, "interval_minutes": 60, "channels": [], "run_all_tracked": True,
|
||||||
|
})
|
||||||
|
mgr = PerAccountContinuousScrapeManager(aid)
|
||||||
|
# Override refresh_config to prevent auth check from interfering
|
||||||
|
original_refresh = mgr.refresh_config
|
||||||
|
mgr.refresh_config = lambda: None
|
||||||
|
mgr.start()
|
||||||
|
t1 = mgr.thread
|
||||||
|
mgr.start() # second call — should be no-op
|
||||||
|
t2 = mgr.thread
|
||||||
|
assert t1 is t2, "start() spawned a second thread"
|
||||||
|
# Also verify only one thread is alive for this manager
|
||||||
|
alive_count = sum(
|
||||||
|
1 for t in threading.enumerate()
|
||||||
|
if t is t1 or t is t2
|
||||||
|
)
|
||||||
|
assert alive_count <= 1, "duplicate threads detected"
|
||||||
|
mgr.stop()
|
||||||
|
mgr.refresh_config = original_refresh
|
||||||
|
finally:
|
||||||
|
self._restore_ws_data_dir()
|
||||||
|
|
||||||
|
def test_disabled_account_not_started(self):
|
||||||
|
"""start_all() must not start accounts with enabled=False."""
|
||||||
|
_, ContinuousScrapeOrchestrator, ws = self._import_orch_classes()
|
||||||
|
self._setup_ws_data_dir()
|
||||||
|
try:
|
||||||
|
orch = ContinuousScrapeOrchestrator()
|
||||||
|
aid = make_account_id()
|
||||||
|
create_account(TEST_DATA, aid, continuous_scraping={
|
||||||
|
"enabled": False, "interval_minutes": 1, "channels": [], "run_all_tracked": True,
|
||||||
|
})
|
||||||
|
orch.start_all()
|
||||||
|
snap = orch.snapshot_for(aid)
|
||||||
|
assert snap["status"]["running"] is False
|
||||||
|
finally:
|
||||||
|
self._restore_ws_data_dir()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMessageSearch:
|
||||||
|
"""load_messages() SQL correctness with pagination and search."""
|
||||||
|
|
||||||
|
def _import_load_messages(self):
|
||||||
|
import webui_server as ws_module
|
||||||
|
# Point at TEST_DATA
|
||||||
|
self._ws_orig = ws_module.DATA_DIR
|
||||||
|
ws_module.DATA_DIR = TEST_DATA
|
||||||
|
return ws_module.load_messages
|
||||||
|
|
||||||
|
def _restore(self):
|
||||||
|
import webui_server as ws_module
|
||||||
|
ws_module.DATA_DIR = self._ws_orig
|
||||||
|
|
||||||
|
def test_search_filters_correctly(self):
|
||||||
|
channel_id = "-100searchtest"
|
||||||
|
messages = [
|
||||||
|
{"message_id": 1, "date": "2024-01-01", "message": "hello world"},
|
||||||
|
{"message_id": 2, "date": "2024-01-02", "message": "foo bar baz"},
|
||||||
|
{"message_id": 3, "date": "2024-01-03", "message": "hello again"},
|
||||||
|
]
|
||||||
|
create_channel_db(TEST_DATA, "default", channel_id, messages)
|
||||||
|
load_messages = self._import_load_messages()
|
||||||
|
try:
|
||||||
|
result = load_messages("default", channel_id, limit=100, search="hello")
|
||||||
|
finally:
|
||||||
|
self._restore()
|
||||||
|
texts = [m["text"] for m in result]
|
||||||
|
assert "hello world" in texts
|
||||||
|
assert "hello again" in texts
|
||||||
|
assert "foo bar baz" not in texts
|
||||||
|
|
||||||
|
def test_before_id_pagination_works(self):
|
||||||
|
channel_id = "-100pagination"
|
||||||
|
messages = [
|
||||||
|
{"message_id": i, "date": f"2024-01-{i:02d}", "message": f"msg-{i}"}
|
||||||
|
for i in range(1, 21)
|
||||||
|
]
|
||||||
|
create_channel_db(TEST_DATA, "default", channel_id, messages)
|
||||||
|
load_messages = self._import_load_messages()
|
||||||
|
try:
|
||||||
|
result = load_messages("default", channel_id, limit=5, before_message_id=15)
|
||||||
|
finally:
|
||||||
|
self._restore()
|
||||||
|
assert len(result) == 5
|
||||||
|
ids = [m["message_id"] for m in result]
|
||||||
|
assert all(i < 15 for i in ids)
|
||||||
|
# load_messages returns rows in ascending message_id order (reversed from DESC query)
|
||||||
|
assert ids == [10, 11, 12, 13, 14]
|
||||||
|
|
||||||
|
def test_search_with_pagination(self):
|
||||||
|
channel_id = "-100searchpages"
|
||||||
|
messages = [
|
||||||
|
{"message_id": i, "date": f"2024-01-{i:02d}", "message": f"hello-{i}" if i % 2 else f"other-{i}"}
|
||||||
|
for i in range(1, 21)
|
||||||
|
]
|
||||||
|
create_channel_db(TEST_DATA, "default", channel_id, messages)
|
||||||
|
load_messages = self._import_load_messages()
|
||||||
|
try:
|
||||||
|
result = load_messages("default", channel_id, limit=3, before_message_id=15, search="hello")
|
||||||
|
finally:
|
||||||
|
self._restore()
|
||||||
|
# IDs matching "hello-" below 15: 13, 11, 9, 7, 5, 3, 1 → LIMIT 3 → [9, 11, 13] (ascending)
|
||||||
|
ids = [m["message_id"] for m in result]
|
||||||
|
assert ids == [9, 11, 13], f"got {ids}"
|
||||||
|
assert all("hello" in m["text"] for m in result)
|
||||||
|
|
||||||
|
def test_empty_result_no_error(self):
|
||||||
|
channel_id = "-100emptysearch"
|
||||||
|
messages = [{"message_id": 1, "date": "2024-01-01", "message": "only one"}]
|
||||||
|
create_channel_db(TEST_DATA, "default", channel_id, messages)
|
||||||
|
load_messages = self._import_load_messages()
|
||||||
|
try:
|
||||||
|
result = load_messages("default", channel_id, limit=100, search="nonexistent")
|
||||||
|
finally:
|
||||||
|
self._restore()
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealthPayload:
|
||||||
|
"""Health endpoint structure."""
|
||||||
|
|
||||||
|
def test_health_payload_structure(self):
|
||||||
|
aid = make_account_id()
|
||||||
|
create_account(TEST_DATA, aid)
|
||||||
|
payload = health_payload(
|
||||||
|
TEST_DATA,
|
||||||
|
TEST_SESSION,
|
||||||
|
get_global_store(TEST_DATA),
|
||||||
|
{},
|
||||||
|
0,
|
||||||
|
[aid],
|
||||||
|
)
|
||||||
|
assert "ok" in payload
|
||||||
|
assert "status" in payload
|
||||||
|
assert "checks" in payload
|
||||||
|
checks = payload["checks"]
|
||||||
|
assert "data_dir" in checks
|
||||||
|
assert "session_dir" in checks
|
||||||
|
assert "accounts" in checks
|
||||||
|
assert "items" in checks["accounts"]
|
||||||
|
assert aid in checks["accounts"]["items"]
|
||||||
|
|
||||||
|
def test_health_accounts_wrapper(self):
|
||||||
|
a1, a2 = make_account_id(), make_account_id()
|
||||||
|
create_account(TEST_DATA, a1)
|
||||||
|
create_account(TEST_DATA, a2)
|
||||||
|
payload = health_payload(
|
||||||
|
TEST_DATA, TEST_SESSION,
|
||||||
|
get_global_store(TEST_DATA),
|
||||||
|
{}, 0, [a1, a2],
|
||||||
|
)
|
||||||
|
acc_checks = payload["checks"]["accounts"]
|
||||||
|
assert acc_checks["ok"] is True
|
||||||
|
assert len(acc_checks["items"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestPersistence:
|
||||||
|
"""UI persistence helpers."""
|
||||||
|
|
||||||
|
def test_active_account_localstorage(self):
|
||||||
|
"""Verifies the save/load logic pattern used in app.js."""
|
||||||
|
accounts = [
|
||||||
|
{"id": "work", "label": "Work"},
|
||||||
|
{"id": "home", "label": "Home"},
|
||||||
|
]
|
||||||
|
saved_id = "work"
|
||||||
|
loaded = None
|
||||||
|
if saved_id and any(a["id"] == saved_id for a in accounts):
|
||||||
|
loaded = saved_id
|
||||||
|
if not loaded:
|
||||||
|
loaded = next((a["id"] for a in accounts), None)
|
||||||
|
assert loaded == "work"
|
||||||
|
|
||||||
|
saved_id = "longgone"
|
||||||
|
loaded = None
|
||||||
|
if saved_id and any(a["id"] == saved_id for a in accounts):
|
||||||
|
loaded = saved_id
|
||||||
|
if not loaded and accounts:
|
||||||
|
loaded = accounts[0]["id"]
|
||||||
|
assert loaded == "work"
|
||||||
|
|
||||||
|
def test_viewer_fallback_on_missing_account(self):
|
||||||
|
"""Viewer's loadViewerAccount fallback logic."""
|
||||||
|
accounts = [
|
||||||
|
{"id": "alpha", "label": "Alpha"},
|
||||||
|
{"id": "beta", "label": "Beta"},
|
||||||
|
]
|
||||||
|
requested = "nonexistent"
|
||||||
|
requested_exists = requested and any(a["id"] == requested for a in accounts)
|
||||||
|
account_id = requested if requested_exists else (accounts[0]["id"] if accounts else None)
|
||||||
|
assert account_id == "alpha"
|
||||||
|
|
||||||
|
account_id2 = requested if (requested and any(a["id"] == requested for a in [])) else None
|
||||||
|
assert account_id2 is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestJobDeduplicationSchema:
|
||||||
|
"""JobRunner create_job dedup logic."""
|
||||||
|
|
||||||
|
def _import_job_runner(self):
|
||||||
|
import webui_server as ws_module
|
||||||
|
return ws_module.JobRunner
|
||||||
|
|
||||||
|
def test_create_job_dedup_by_account(self):
|
||||||
|
JobRunner = self._import_job_runner()
|
||||||
|
runner = JobRunner()
|
||||||
|
j1 = runner.create_job("scrape_all", "First", {"account_id": "acc1"})
|
||||||
|
j2 = runner.create_job("scrape_all", "Second", {"account_id": "acc1"})
|
||||||
|
assert j1.job_id == j2.job_id, "duplicate job was created instead of being reused"
|
||||||
|
assert "Reused existing active job" in j2.logs
|
||||||
|
|
||||||
|
def test_create_job_allows_different_accounts(self):
|
||||||
|
JobRunner = self._import_job_runner()
|
||||||
|
runner = JobRunner()
|
||||||
|
j1 = runner.create_job("scrape_all", "First", {"account_id": "acc1"})
|
||||||
|
time.sleep(0.002) # ensure different timestamp -> different job_id
|
||||||
|
j2 = runner.create_job("scrape_all", "Second", {"account_id": "acc2"})
|
||||||
|
assert j1.job_id != j2.job_id
|
||||||
|
|
||||||
|
def test_create_job_allows_after_previous_completes(self):
|
||||||
|
JobRunner = self._import_job_runner()
|
||||||
|
runner = JobRunner()
|
||||||
|
j1 = runner.create_job("scrape_all", "First", {"account_id": "acc1"})
|
||||||
|
j1.status = "completed"
|
||||||
|
time.sleep(0.002) # ensure different timestamp -> different job_id
|
||||||
|
j2 = runner.create_job("scrape_all", "Second", {"account_id": "acc1"})
|
||||||
|
assert j1.job_id != j2.job_id
|
||||||
|
|
||||||
|
|
||||||
|
class TestAccountHealthSummary:
|
||||||
|
"""account_health_summary structure."""
|
||||||
|
|
||||||
|
def _import_health_summary(self):
|
||||||
|
import webui_server as ws_module
|
||||||
|
self._ws_orig_data = ws_module.DATA_DIR
|
||||||
|
self._ws_orig_session = ws_module.SESSION_DIR
|
||||||
|
ws_module.DATA_DIR = TEST_DATA
|
||||||
|
ws_module.SESSION_DIR = TEST_SESSION
|
||||||
|
return ws_module.account_health_summary, ws_module.JobRunner, ws_module
|
||||||
|
|
||||||
|
def test_health_summary_structure(self):
|
||||||
|
account_health_summary, JobRunner, ws = self._import_health_summary()
|
||||||
|
aid = make_account_id()
|
||||||
|
create_account(TEST_DATA, aid, api_id=1, api_hash="x")
|
||||||
|
try:
|
||||||
|
runner = JobRunner()
|
||||||
|
health = account_health_summary(aid, runner)
|
||||||
|
finally:
|
||||||
|
ws.DATA_DIR = self._ws_orig_data
|
||||||
|
ws.SESSION_DIR = self._ws_orig_session
|
||||||
|
|
||||||
|
assert health["account_id"] == aid
|
||||||
|
assert "label" in health
|
||||||
|
assert "data_dir_exists" in health
|
||||||
|
assert "session_ready" in health
|
||||||
|
assert "api_credentials" in health
|
||||||
|
assert health["api_credentials"] is True
|
||||||
|
assert "channel_count" in health
|
||||||
|
assert "message_count" in health
|
||||||
|
assert "media_count" in health
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cleanup all temp data ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_test_data():
|
||||||
|
if TEST_TMP.exists():
|
||||||
|
shutil.rmtree(str(TEST_TMP), ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
import atexit # noqa: E402
|
||||||
|
atexit.register(cleanup_test_data) # noqa: E402
|
||||||
+494
-234
File diff suppressed because it is too large
Load Diff
+18
-4
@@ -1,10 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Telegram Scraper Control Panel</title>
|
<title>Telegram Scraper Control Panel</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=4" />
|
<link rel="stylesheet" href="/static/style.css?v=6" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
<nav class="nav-links">
|
<nav class="nav-links">
|
||||||
<a class="nav-link active" href="/">Dashboard</a>
|
<a class="nav-link active" href="/">Dashboard</a>
|
||||||
|
<a class="nav-link" href="/settings">Settings</a>
|
||||||
<a class="nav-link" href="/viewer">Message Viewer</a>
|
<a class="nav-link" href="/viewer">Message Viewer</a>
|
||||||
<a class="nav-link" href="/swagger">API Docs</a>
|
<a class="nav-link" href="/swagger">API Docs</a>
|
||||||
<a class="nav-link" href="/health">Health</a>
|
<a class="nav-link" href="/health">Health</a>
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
<button class="button primary" id="scrape-all-btn">Scrape all channels</button>
|
<button class="button primary" id="scrape-all-btn">Scrape all channels</button>
|
||||||
<button class="button" id="export-all-btn">Export all data</button>
|
<button class="button" id="export-all-btn">Export all data</button>
|
||||||
<button class="button" id="refresh-dialogs-btn">Refresh dialogs</button>
|
<button class="button" id="refresh-dialogs-btn">Refresh dialogs</button>
|
||||||
<button class="button" id="open-settings-btn" type="button">Settings</button>
|
<a class="button" id="open-settings-btn" href="/settings">Settings</a>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -51,7 +52,9 @@
|
|||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>No Accounts Configured</h2>
|
<h2>No Accounts Configured</h2>
|
||||||
<p class="muted">Click "Add Account" in Settings to get started, or use the legacy single-account mode.</p>
|
<p class="muted">
|
||||||
|
Click "Add Account" in Settings to get started, or use the legacy single-account mode.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -81,6 +84,10 @@
|
|||||||
<input id="add-account-api-hash" name="api_hash" placeholder="API Hash" />
|
<input id="add-account-api-hash" name="api_hash" placeholder="API Hash" />
|
||||||
<button class="button primary" type="submit">Add Account</button>
|
<button class="button primary" type="submit">Add Account</button>
|
||||||
</form>
|
</form>
|
||||||
|
<label class="import-account-row">
|
||||||
|
<span class="muted small">Import account settings JSON</span>
|
||||||
|
<input id="import-account-file" type="file" accept="application/json,.json" />
|
||||||
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ── Account Auth ── (shown per account selected in UI) -->
|
<!-- ── Account Auth ── (shown per account selected in UI) -->
|
||||||
@@ -165,6 +172,11 @@
|
|||||||
<div class="section-title">Forwarding rules</div>
|
<div class="section-title">Forwarding rules</div>
|
||||||
<div class="stat-value forwarding-count">0</div>
|
<div class="stat-value forwarding-count">0</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel stat-panel">
|
||||||
|
<div class="section-title">Account health</div>
|
||||||
|
<div class="stat-value stat-compact account-health-label">-</div>
|
||||||
|
<div class="muted small account-health-detail"></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@@ -247,6 +259,7 @@
|
|||||||
<div class="continuous-meta">
|
<div class="continuous-meta">
|
||||||
<div>Status: <span class="continuous-status">-</span></div>
|
<div>Status: <span class="continuous-status">-</span></div>
|
||||||
<div>Last run: <span class="continuous-last-iteration">-</span></div>
|
<div>Last run: <span class="continuous-last-iteration">-</span></div>
|
||||||
|
<div>Next run: <span class="continuous-next-run">-</span></div>
|
||||||
<div>Last error: <span class="continuous-last-error">-</span></div>
|
<div>Last error: <span class="continuous-last-error">-</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -300,6 +313,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="account-list-actions">
|
<div class="account-list-actions">
|
||||||
<button class="button button-small account-select-btn">Select</button>
|
<button class="button button-small account-select-btn">Select</button>
|
||||||
|
<button class="button button-small account-export-btn">Export</button>
|
||||||
<button class="button button-small danger account-remove-btn">Remove</button>
|
<button class="button button-small danger account-remove-btn">Remove</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Telegram Scraper Settings</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css?v=6" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell settings-page-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand-block">
|
||||||
|
<div class="eyebrow">Telegram Scraper</div>
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<p class="muted">Account credentials, import/export, and runtime safety controls.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a class="nav-link" href="/">Dashboard</a>
|
||||||
|
<a class="nav-link active" href="/settings">Settings</a>
|
||||||
|
<a class="nav-link" href="/viewer">Message Viewer</a>
|
||||||
|
<a class="nav-link" href="/swagger">API Docs</a>
|
||||||
|
<a class="nav-link" href="/health">Health</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="status-card">
|
||||||
|
<div class="section-title">Accounts</div>
|
||||||
|
<div id="settings-account-list" class="settings-account-list"></div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="content settings-page">
|
||||||
|
<section class="panel settings-hero">
|
||||||
|
<div>
|
||||||
|
<div class="eyebrow">Account Settings</div>
|
||||||
|
<h2 id="settings-title">Loading accounts</h2>
|
||||||
|
<p id="settings-subtitle" class="muted">Select an account to manage credentials and scraper options.</p>
|
||||||
|
</div>
|
||||||
|
<div class="action-row">
|
||||||
|
<button class="button" id="settings-export-btn" type="button" disabled>Export</button>
|
||||||
|
<button class="button danger" id="settings-delete-btn" type="button" disabled>Delete</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="settings-empty" class="panel hidden">
|
||||||
|
<div class="empty-state">
|
||||||
|
<h2>Add first account</h2>
|
||||||
|
<p class="muted">Create an account below or import an exported account JSON.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel-grid settings-health-grid">
|
||||||
|
<div class="panel stat-panel">
|
||||||
|
<div class="section-title">Credentials</div>
|
||||||
|
<div class="stat-value stat-compact" id="health-credentials">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel stat-panel">
|
||||||
|
<div class="section-title">Session</div>
|
||||||
|
<div class="stat-value stat-compact" id="health-session">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel stat-panel">
|
||||||
|
<div class="section-title">Continuous</div>
|
||||||
|
<div class="stat-value stat-compact" id="health-continuous">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel stat-panel">
|
||||||
|
<div class="section-title">Last scrape</div>
|
||||||
|
<div class="stat-value stat-compact" id="health-last-scrape">-</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-layout">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>Create or Import</h2>
|
||||||
|
<p class="muted">New accounts start without continuous scraping enabled.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form id="settings-add-account-form" class="stack-form">
|
||||||
|
<input id="settings-add-account-id" name="account_id" placeholder="Account ID (e.g. work)" required />
|
||||||
|
<input id="settings-add-account-label" name="label" placeholder="Display name" />
|
||||||
|
<input id="settings-add-account-api-id" name="api_id" placeholder="API ID" />
|
||||||
|
<input id="settings-add-account-api-hash" name="api_hash" placeholder="API Hash" />
|
||||||
|
<button class="button primary" type="submit">Add Account</button>
|
||||||
|
</form>
|
||||||
|
<label class="import-account-row">
|
||||||
|
<span class="muted small">Import account settings JSON</span>
|
||||||
|
<input id="settings-import-account-file" type="file" accept="application/json,.json" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>Credentials & Auth</h2>
|
||||||
|
<p class="muted" id="settings-auth-label">No account selected.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form id="settings-credentials-form" class="stack-form">
|
||||||
|
<input id="settings-api-id-input" name="api_id" placeholder="API ID" />
|
||||||
|
<input id="settings-api-hash-input" name="api_hash" placeholder="API Hash" />
|
||||||
|
<button class="button" type="submit">Save credentials</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="auth-actions">
|
||||||
|
<button class="button primary" id="settings-start-qr-btn" type="button">Start QR login</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="settings-qr-wrap" class="qr-wrap hidden">
|
||||||
|
<img id="settings-qr-image" alt="Telegram QR login" />
|
||||||
|
<p class="muted small">Open Telegram -> Settings -> Devices -> Scan QR.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="settings-phone-form" class="stack-form">
|
||||||
|
<input id="settings-phone-input" name="phone" placeholder="+1234567890" />
|
||||||
|
<button class="button" type="submit">Send code</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form id="settings-code-form" class="stack-form hidden">
|
||||||
|
<input id="settings-code-input" name="code" placeholder="Telegram code" />
|
||||||
|
<button class="button" type="submit">Confirm code</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form id="settings-password-form" class="stack-form hidden">
|
||||||
|
<input id="settings-password-input" name="password" type="password" placeholder="2FA password" />
|
||||||
|
<button class="button" type="submit">Confirm password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>Scraping</h2>
|
||||||
|
<p class="muted">Account-level parser settings.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span>Download media</span>
|
||||||
|
<span class="switch">
|
||||||
|
<input id="settings-scrape-media-toggle" type="checkbox" />
|
||||||
|
<span class="switch-slider"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="settings-meta-list">
|
||||||
|
<div>Tracked channels: <span id="settings-channel-count">0</span></div>
|
||||||
|
<div>Messages: <span id="settings-message-count">0</span></div>
|
||||||
|
<div>Media: <span id="settings-media-count">0</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/settings.js?v=1"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
const settingsState = {
|
||||||
|
accounts: [],
|
||||||
|
activeAccount: null,
|
||||||
|
dashboard: null,
|
||||||
|
auth: null,
|
||||||
|
continuous: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTIVE_ACCOUNT_STORAGE_KEY = 'telegramScraper.activeAccount';
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Request failed');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
let root = document.getElementById('toast-root');
|
||||||
|
if (!root) {
|
||||||
|
root = document.createElement('div');
|
||||||
|
root.id = 'toast-root';
|
||||||
|
root.className = 'toast-root';
|
||||||
|
document.body.appendChild(root);
|
||||||
|
}
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast toast-${type}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
root.appendChild(toast);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
toast.classList.add('toast-hide');
|
||||||
|
window.setTimeout(() => toast.remove(), 250);
|
||||||
|
}, 3600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadJson(filename, payload) {
|
||||||
|
const blob = new Blob([JSON.stringify(payload, null, 2) + '\n'], {
|
||||||
|
type: 'application/json',
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayTime(value) {
|
||||||
|
if (!value) return '-';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return String(value).replace('T', ' ').replace('+00:00', ' UTC');
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAccountAuthorized(auth) {
|
||||||
|
const status = auth?.auth_status || auth || {};
|
||||||
|
return (
|
||||||
|
auth?.phase === 'authorized' ||
|
||||||
|
auth?.status === 'authorized' ||
|
||||||
|
status.status === 'ready' ||
|
||||||
|
status.status === 'authorized'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountLabel(account) {
|
||||||
|
return account?.label || account?.id || 'No account';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveAccount(accountId) {
|
||||||
|
settingsState.activeAccount = accountId || null;
|
||||||
|
if (accountId) {
|
||||||
|
localStorage.setItem(ACTIVE_ACCOUNT_STORAGE_KEY, accountId);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(ACTIVE_ACCOUNT_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseInitialAccount() {
|
||||||
|
const requested = new URLSearchParams(window.location.search).get('account');
|
||||||
|
if (requested && settingsState.accounts.some((account) => account.id === requested)) {
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
const saved = localStorage.getItem(ACTIVE_ACCOUNT_STORAGE_KEY);
|
||||||
|
if (saved && settingsState.accounts.some((account) => account.id === saved)) {
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
return settingsState.accounts[0]?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAccountList() {
|
||||||
|
const root = document.getElementById('settings-account-list');
|
||||||
|
root.innerHTML = '';
|
||||||
|
|
||||||
|
if (!settingsState.accounts.length) {
|
||||||
|
root.innerHTML = '<div class="empty-state">No accounts yet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsState.accounts.forEach((account) => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.className = 'settings-account-button';
|
||||||
|
button.type = 'button';
|
||||||
|
button.classList.toggle('active', account.id === settingsState.activeAccount);
|
||||||
|
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'settings-account-name';
|
||||||
|
name.textContent = accountLabel(account);
|
||||||
|
|
||||||
|
const status = document.createElement('span');
|
||||||
|
status.className = isAccountAuthorized(account.auth) ? 'settings-account-status ok' : 'settings-account-status';
|
||||||
|
status.textContent = isAccountAuthorized(account.auth) ? 'Authorized' : 'Needs auth';
|
||||||
|
|
||||||
|
button.append(name, status);
|
||||||
|
button.addEventListener('click', () => loadAccount(account.id));
|
||||||
|
root.appendChild(button);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmptyState() {
|
||||||
|
const hasAccount = Boolean(settingsState.activeAccount);
|
||||||
|
document.getElementById('settings-empty').classList.toggle('hidden', hasAccount);
|
||||||
|
document.getElementById('settings-export-btn').disabled = !hasAccount;
|
||||||
|
document.getElementById('settings-delete-btn').disabled = !hasAccount;
|
||||||
|
document
|
||||||
|
.getElementById('settings-credentials-form')
|
||||||
|
.querySelectorAll('input, button')
|
||||||
|
.forEach((node) => {
|
||||||
|
node.disabled = !hasAccount;
|
||||||
|
});
|
||||||
|
document.getElementById('settings-start-qr-btn').disabled = !hasAccount;
|
||||||
|
document
|
||||||
|
.getElementById('settings-phone-form')
|
||||||
|
.querySelectorAll('input, button')
|
||||||
|
.forEach((node) => {
|
||||||
|
node.disabled = !hasAccount;
|
||||||
|
});
|
||||||
|
document.getElementById('settings-scrape-media-toggle').disabled = !hasAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAccountData() {
|
||||||
|
const account = settingsState.accounts.find((item) => item.id === settingsState.activeAccount);
|
||||||
|
const dashboard = settingsState.dashboard || {};
|
||||||
|
const health = dashboard.health || {};
|
||||||
|
const auth = settingsState.auth || account?.auth || {};
|
||||||
|
const continuous = settingsState.continuous || {};
|
||||||
|
const continuousStatus = continuous.status || {};
|
||||||
|
const continuousConfig = continuous.config || {};
|
||||||
|
|
||||||
|
document.getElementById('settings-title').textContent = account ? accountLabel(account) : 'No account selected';
|
||||||
|
document.getElementById('settings-subtitle').textContent = account
|
||||||
|
? `Account ID: ${account.id}`
|
||||||
|
: 'Create or import an account to manage settings.';
|
||||||
|
document.getElementById('settings-auth-label').textContent = account
|
||||||
|
? `${accountLabel(account)} auth status`
|
||||||
|
: 'No account selected.';
|
||||||
|
|
||||||
|
document.getElementById('health-credentials').textContent = health.api_credentials ? 'Saved' : 'Missing';
|
||||||
|
document.getElementById('health-credentials').style.color = health.api_credentials ? 'var(--ok)' : 'var(--warn)';
|
||||||
|
document.getElementById('health-session').textContent =
|
||||||
|
health.session_ready || isAccountAuthorized(auth) ? 'Ready' : 'Missing';
|
||||||
|
document.getElementById('health-session').style.color =
|
||||||
|
health.session_ready || isAccountAuthorized(auth) ? 'var(--ok)' : 'var(--warn)';
|
||||||
|
document.getElementById('health-continuous').textContent = continuousStatus.running
|
||||||
|
? 'Running'
|
||||||
|
: continuousConfig.enabled
|
||||||
|
? 'Enabled'
|
||||||
|
: 'Stopped';
|
||||||
|
document.getElementById('health-continuous').style.color = continuousStatus.running ? 'var(--ok)' : 'var(--dim)';
|
||||||
|
document.getElementById('health-last-scrape').textContent = displayTime(
|
||||||
|
health.last_scrape || continuousStatus.last_iteration_at,
|
||||||
|
);
|
||||||
|
|
||||||
|
document.getElementById('settings-scrape-media-toggle').checked = Boolean(dashboard.dashboard?.scrape_media);
|
||||||
|
document.getElementById('settings-channel-count').textContent = String(health.channel_count || 0);
|
||||||
|
document.getElementById('settings-message-count').textContent = String(health.message_count || 0);
|
||||||
|
document.getElementById('settings-media-count').textContent = String(health.media_count || 0);
|
||||||
|
renderEmptyState();
|
||||||
|
renderAccountList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAccounts() {
|
||||||
|
const payload = await api('/api/accounts');
|
||||||
|
settingsState.accounts = payload.accounts || [];
|
||||||
|
setActiveAccount(chooseInitialAccount());
|
||||||
|
renderAccountList();
|
||||||
|
if (settingsState.activeAccount) {
|
||||||
|
await loadAccount(settingsState.activeAccount, { preserveUrl: true });
|
||||||
|
} else {
|
||||||
|
settingsState.dashboard = null;
|
||||||
|
settingsState.auth = null;
|
||||||
|
settingsState.continuous = null;
|
||||||
|
renderAccountData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAccount(accountId, options = {}) {
|
||||||
|
setActiveAccount(accountId);
|
||||||
|
const [dashboard, auth, continuous] = await Promise.all([
|
||||||
|
api(`/api/accounts/${encodeURIComponent(accountId)}`),
|
||||||
|
api(`/api/accounts/${encodeURIComponent(accountId)}/auth`).catch(() => ({})),
|
||||||
|
api(`/api/accounts/${encodeURIComponent(accountId)}/continuous`).catch(() => ({})),
|
||||||
|
]);
|
||||||
|
settingsState.dashboard = dashboard;
|
||||||
|
settingsState.auth = auth;
|
||||||
|
settingsState.continuous = continuous;
|
||||||
|
if (!options.preserveUrl) {
|
||||||
|
history.replaceState(null, '', `/settings?account=${encodeURIComponent(accountId)}`);
|
||||||
|
}
|
||||||
|
renderAccountData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAccount(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const accountId = document.getElementById('settings-add-account-id').value.trim();
|
||||||
|
const label = document.getElementById('settings-add-account-label').value.trim();
|
||||||
|
const apiId = document.getElementById('settings-add-account-api-id').value.trim();
|
||||||
|
const apiHash = document.getElementById('settings-add-account-api-hash').value.trim();
|
||||||
|
if (!accountId) return;
|
||||||
|
|
||||||
|
await api('/api/accounts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
account_id: accountId,
|
||||||
|
label: label || accountId,
|
||||||
|
api_id: apiId ? parseInt(apiId) : undefined,
|
||||||
|
api_hash: apiHash || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
event.currentTarget.reset();
|
||||||
|
await loadAccounts();
|
||||||
|
await loadAccount(accountId);
|
||||||
|
showToast(`Added ${accountId}.`, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importAccount(event) {
|
||||||
|
const file = event.currentTarget.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(await file.text());
|
||||||
|
const accountId = payload.account_id || payload.id;
|
||||||
|
if (!accountId) throw new Error('account_id is missing in import file');
|
||||||
|
await api('/api/accounts/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
event.currentTarget.value = '';
|
||||||
|
await loadAccounts();
|
||||||
|
await loadAccount(accountId);
|
||||||
|
showToast(`Imported ${accountId}.`, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Failed to import account: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCredentials(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!settingsState.activeAccount) return;
|
||||||
|
const apiId = document.getElementById('settings-api-id-input').value.trim();
|
||||||
|
const apiHash = document.getElementById('settings-api-hash-input').value.trim();
|
||||||
|
if (!apiId || !apiHash) return;
|
||||||
|
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/auth/credentials`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ api_id: apiId, api_hash: apiHash }),
|
||||||
|
});
|
||||||
|
await loadAccount(settingsState.activeAccount);
|
||||||
|
showToast('Credentials saved.', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startQrLogin() {
|
||||||
|
if (!settingsState.activeAccount) return;
|
||||||
|
const data = await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/auth/qr/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
if (data.qr_image) {
|
||||||
|
document.getElementById('settings-qr-image').src = data.qr_image;
|
||||||
|
document.getElementById('settings-qr-wrap').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
await loadAccount(settingsState.activeAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestPhoneCode(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!settingsState.activeAccount) return;
|
||||||
|
const phone = document.getElementById('settings-phone-input').value.trim();
|
||||||
|
if (!phone) return;
|
||||||
|
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/auth/phone/request`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone }),
|
||||||
|
});
|
||||||
|
document.getElementById('settings-code-form').classList.remove('hidden');
|
||||||
|
await loadAccount(settingsState.activeAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPhoneCode(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!settingsState.activeAccount) return;
|
||||||
|
const code = document.getElementById('settings-code-input').value.trim();
|
||||||
|
if (!code) return;
|
||||||
|
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/auth/phone/submit`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
document.getElementById('settings-code-input').value = '';
|
||||||
|
await loadAccount(settingsState.activeAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPassword(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!settingsState.activeAccount) return;
|
||||||
|
const password = document.getElementById('settings-password-input').value.trim();
|
||||||
|
if (!password) return;
|
||||||
|
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/auth/password`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
document.getElementById('settings-password-input').value = '';
|
||||||
|
await loadAccount(settingsState.activeAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportAccount() {
|
||||||
|
if (!settingsState.activeAccount) return;
|
||||||
|
const payload = await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/export`);
|
||||||
|
downloadJson(`telegram-scraper-account-${settingsState.activeAccount}.json`, payload);
|
||||||
|
showToast('Account exported.', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAccount() {
|
||||||
|
if (!settingsState.activeAccount) return;
|
||||||
|
const account = settingsState.accounts.find((item) => item.id === settingsState.activeAccount);
|
||||||
|
if (!window.confirm(`Remove account "${accountLabel(account)}"? All its account data will be deleted.`)) return;
|
||||||
|
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}`, { method: 'DELETE' });
|
||||||
|
setActiveAccount(null);
|
||||||
|
await loadAccounts();
|
||||||
|
showToast('Account deleted.', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleMedia(event) {
|
||||||
|
if (!settingsState.activeAccount) return;
|
||||||
|
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/settings/media`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ value: event.currentTarget.checked }),
|
||||||
|
});
|
||||||
|
await loadAccount(settingsState.activeAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
document.getElementById('settings-add-account-form').addEventListener('submit', async (event) => {
|
||||||
|
try {
|
||||||
|
await createAccount(event);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Failed to add account: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('settings-import-account-file').addEventListener('change', importAccount);
|
||||||
|
document.getElementById('settings-credentials-form').addEventListener('submit', async (event) => {
|
||||||
|
try {
|
||||||
|
await saveCredentials(event);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Failed to save credentials: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('settings-start-qr-btn').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await startQrLogin();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`QR login failed: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('settings-phone-form').addEventListener('submit', async (event) => {
|
||||||
|
try {
|
||||||
|
await requestPhoneCode(event);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Phone login failed: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('settings-code-form').addEventListener('submit', async (event) => {
|
||||||
|
try {
|
||||||
|
await submitPhoneCode(event);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Code submit failed: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('settings-password-form').addEventListener('submit', async (event) => {
|
||||||
|
try {
|
||||||
|
await submitPassword(event);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Password submit failed: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('settings-export-btn').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await exportAccount();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Export failed: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('settings-delete-btn').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await deleteAccount();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Delete failed: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('settings-scrape-media-toggle').addEventListener('change', async (event) => {
|
||||||
|
try {
|
||||||
|
await toggleMedia(event);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Media setting failed: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents();
|
||||||
|
loadAccounts().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
showToast(`Failed to load settings: ${err.message}`, 'error');
|
||||||
|
});
|
||||||
+303
-92
@@ -4,21 +4,24 @@
|
|||||||
--panel: #0b0b0b;
|
--panel: #0b0b0b;
|
||||||
--panel-solid: #101010;
|
--panel-solid: #101010;
|
||||||
--panel-alt: #050505;
|
--panel-alt: #050505;
|
||||||
--text-color: #f2f3f5;
|
--text-color: #f3f5f8;
|
||||||
--accent: #1fb9aa;
|
--accent: #2f57b8;
|
||||||
--accent-2: #58a6ff;
|
--accent-hover: #3b63c6;
|
||||||
--bubble-own: #12342f;
|
--accent-soft: #0d1730;
|
||||||
--bubble-other: #171717;
|
--accent-line: #24428d;
|
||||||
--dim: #9aa0a6;
|
--accent-2: #6f95eb;
|
||||||
--line: #242424;
|
--bubble-own: #0f1c3f;
|
||||||
|
--bubble-other: #111111;
|
||||||
|
--dim: #9ea7b7;
|
||||||
|
--line: #1f1f1f;
|
||||||
--danger: #ff7373;
|
--danger: #ff7373;
|
||||||
--ok: #65e6a4;
|
--ok: #65e6a4;
|
||||||
--warn: #f6c969;
|
--warn: #f6c969;
|
||||||
--info: #8ec7ff;
|
--info: #8ec7ff;
|
||||||
--radius: 18px;
|
--radius: 22px;
|
||||||
--shadow: none;
|
--shadow: none;
|
||||||
--font-ui: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
--font-ui: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
||||||
--font-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
|
--font-mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -27,7 +30,7 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: #000;
|
background: var(--bg-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -42,8 +45,8 @@ a {
|
|||||||
|
|
||||||
a:hover,
|
a:hover,
|
||||||
button:hover {
|
button:hover {
|
||||||
border-color: #333;
|
border-color: #2b2b2b;
|
||||||
background: #151515;
|
background: #111111;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,36 +61,48 @@ button {
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 308px minmax(0, 1fr);
|
grid-template-columns: 360px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
background: #050505;
|
background: var(--panel-alt);
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
padding: 24px 20px;
|
padding: 30px 24px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 24px;
|
padding: 32px;
|
||||||
max-width: 1440px;
|
max-width: 1440px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-block {
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.brand-block h1,
|
.brand-block h1,
|
||||||
.dialog-header h2 {
|
.dialog-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-size: 1.5rem;
|
font-size: 1.65rem;
|
||||||
letter-spacing: -0.04em;
|
letter-spacing: -0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-block .muted {
|
||||||
|
margin: 22px 0 0;
|
||||||
|
max-width: 26ch;
|
||||||
|
font-size: 1.03rem;
|
||||||
|
line-height: 1.62;
|
||||||
|
}
|
||||||
|
|
||||||
.eyebrow,
|
.eyebrow,
|
||||||
.section-title {
|
.section-title {
|
||||||
color: var(--dim);
|
color: var(--dim);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.14em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
@@ -106,7 +121,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-links {
|
.nav-links {
|
||||||
margin: 28px 0;
|
margin: 34px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link,
|
.nav-link,
|
||||||
@@ -120,6 +135,12 @@ input,
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.nav-link,
|
||||||
|
a.button,
|
||||||
|
a.content-tab {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
.nav-link,
|
.nav-link,
|
||||||
.button,
|
.button,
|
||||||
.content-tab {
|
.content-tab {
|
||||||
@@ -127,24 +148,33 @@ input,
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
padding: 9px 13px;
|
padding: 10px 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
|
transition:
|
||||||
|
background-color 0.18s ease,
|
||||||
|
border-color 0.18s ease,
|
||||||
|
box-shadow 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
min-height: 52px;
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link.active,
|
.nav-link.active,
|
||||||
.button.primary,
|
.button.primary,
|
||||||
.content-tab.active {
|
.content-tab.active {
|
||||||
border-color: #0f6f67;
|
border-color: var(--accent-line);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background: #0f6f67;
|
background: var(--accent);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link.active:hover,
|
.nav-link.active:hover,
|
||||||
.button.primary:hover,
|
.button.primary:hover,
|
||||||
.content-tab.active:hover {
|
.content-tab.active:hover {
|
||||||
background: #128277;
|
background: var(--accent-hover);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,24 +209,35 @@ input,
|
|||||||
.panel,
|
.panel,
|
||||||
.job-card,
|
.job-card,
|
||||||
.message-card {
|
.message-card {
|
||||||
padding: 16px;
|
padding: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-card {
|
.status-card {
|
||||||
margin-top: 16px;
|
margin-top: 20px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-card .button {
|
.status-card .button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 8px;
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card .section-title,
|
||||||
|
.status-card p,
|
||||||
|
.status-card .status-badge {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: 6px 10px;
|
padding: 7px 12px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: var(--ok);
|
color: var(--ok);
|
||||||
|
background: #0d1410;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-tabs {
|
.content-tabs {
|
||||||
@@ -216,13 +257,17 @@ input,
|
|||||||
|
|
||||||
.panel-grid {
|
.panel-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 18px;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
margin-bottom: 16px;
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-health-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header,
|
.panel-header,
|
||||||
@@ -235,7 +280,7 @@ input,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
margin-top: 8px;
|
margin-top: 10px;
|
||||||
font-size: 2.25rem;
|
font-size: 2.25rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.05em;
|
letter-spacing: -0.05em;
|
||||||
@@ -257,15 +302,88 @@ input,
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 0.9fr) minmax(320px, 1.1fr);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-layout .panel:last-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-page-shell .content {
|
||||||
|
max-width: 1280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #101010;
|
||||||
|
color: var(--text-color);
|
||||||
|
padding: 12px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-button.active {
|
||||||
|
border-color: var(--accent-line);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-status {
|
||||||
|
color: var(--warn);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-status.ok {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-meta-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
padding: 10px 13px;
|
padding: 11px 14px;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
border-radius: 14px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus {
|
input:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-row {
|
.toggle-row {
|
||||||
@@ -301,7 +419,7 @@ input:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.switch-slider::before {
|
.switch-slider::before {
|
||||||
content: "";
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
left: 4px;
|
left: 4px;
|
||||||
@@ -309,11 +427,14 @@ input:focus {
|
|||||||
height: 16px;
|
height: 16px;
|
||||||
background: var(--dim);
|
background: var(--dim);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
transition: transform 0.16s ease, background-color 0.16s ease;
|
transition:
|
||||||
|
transform 0.16s ease,
|
||||||
|
background-color 0.16s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.switch input:checked + .switch-slider {
|
.switch input:checked + .switch-slider {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.switch input:checked + .switch-slider::before {
|
.switch input:checked + .switch-slider::before {
|
||||||
@@ -339,7 +460,7 @@ td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tbody tr:hover {
|
tbody tr:hover {
|
||||||
background: #111;
|
background: #0d0d0d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-row {
|
.action-row {
|
||||||
@@ -386,10 +507,10 @@ tbody tr:hover {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
padding: 8px;
|
padding: 10px 12px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 14px;
|
border-radius: 16px;
|
||||||
background: #0f0f0f;
|
background: #101010;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-row input {
|
.checkbox-row input {
|
||||||
@@ -419,7 +540,7 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.log-line:hover {
|
.log-line:hover {
|
||||||
background: #111;
|
background: rgba(53, 95, 199, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-time,
|
.log-time,
|
||||||
@@ -460,11 +581,11 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-dialog::backdrop {
|
.settings-dialog::backdrop {
|
||||||
background: rgba(0, 0, 0, 0.72);
|
background: rgba(0, 0, 0, 0.82);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-shell {
|
.dialog-shell {
|
||||||
padding: 18px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section {
|
.settings-section {
|
||||||
@@ -503,12 +624,12 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.viewer-sidebar {
|
.viewer-sidebar {
|
||||||
background: #050505;
|
background: var(--panel-alt);
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
padding: 14px 10px;
|
padding: 18px 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,13 +648,13 @@ tbody tr:hover {
|
|||||||
.viewer-sidebar-head h1 {
|
.viewer-sidebar-head h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-size: 1.45rem;
|
font-size: 1.55rem;
|
||||||
letter-spacing: -0.04em;
|
letter-spacing: -0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-channel-list {
|
.viewer-channel-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-channel-item {
|
.viewer-channel-item {
|
||||||
@@ -543,23 +664,23 @@ tbody tr:hover {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 68px;
|
min-height: 68px;
|
||||||
padding: 8px 10px;
|
padding: 10px 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
border-radius: 12px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-channel-item.active {
|
.viewer-channel-item.active {
|
||||||
border-color: transparent;
|
border-color: var(--accent-line);
|
||||||
background: #12342f;
|
background: var(--accent-soft);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-channel-item:hover {
|
.viewer-channel-item:hover {
|
||||||
background: #111;
|
background: #0f0f0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-channel-avatar {
|
.viewer-channel-avatar {
|
||||||
@@ -569,7 +690,7 @@ tbody tr:hover {
|
|||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #5f6b70;
|
background: #274894;
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.03em;
|
letter-spacing: -0.03em;
|
||||||
@@ -613,8 +734,8 @@ tbody tr:hover {
|
|||||||
min-width: 22px;
|
min-width: 22px;
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #12342f;
|
background: #111a32;
|
||||||
color: var(--accent);
|
color: var(--accent-2);
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -626,31 +747,56 @@ tbody tr:hover {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background:
|
background: var(--bg-color);
|
||||||
radial-gradient(circle at 20px 20px, rgba(255, 255, 255, 0.025) 1px, transparent 1.5px),
|
|
||||||
radial-gradient(circle at 58px 54px, rgba(255, 255, 255, 0.018) 1px, transparent 1.5px),
|
|
||||||
#000;
|
|
||||||
background-size: auto, 84px 84px, 84px 84px, auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-header {
|
.viewer-header {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 16px 24px;
|
padding: 18px 26px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
background: #050505;
|
background: var(--panel-alt);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-header h2 {
|
.viewer-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-size: 1.45rem;
|
font-size: 1.55rem;
|
||||||
letter-spacing: -0.04em;
|
letter-spacing: -0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.viewer-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-tools input[type='search'] {
|
||||||
|
min-width: min(260px, 40vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-auto-refresh {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 34px;
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-auto-refresh input {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.messages-list {
|
.messages-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 18px 34px 22px;
|
padding: 22px 38px 26px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -676,7 +822,7 @@ tbody tr:hover {
|
|||||||
|
|
||||||
.chat-date-sep::before,
|
.chat-date-sep::before,
|
||||||
.chat-date-sep::after {
|
.chat-date-sep::after {
|
||||||
content: "";
|
content: '';
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: #242424;
|
background: #242424;
|
||||||
@@ -686,7 +832,7 @@ tbody tr:hover {
|
|||||||
align-self: center;
|
align-self: center;
|
||||||
padding: 5px 12px;
|
padding: 5px 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #111;
|
background: #111111;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-date-sep:empty {
|
.chat-date-sep:empty {
|
||||||
@@ -709,11 +855,11 @@ tbody tr:hover {
|
|||||||
|
|
||||||
.chat-bubble {
|
.chat-bubble {
|
||||||
max-width: min(680px, 74%);
|
max-width: min(680px, 74%);
|
||||||
padding: 8px 12px 7px;
|
padding: 9px 13px 8px;
|
||||||
background: var(--bubble-other);
|
background: var(--bubble-other);
|
||||||
border: 1px solid #242424;
|
border: 1px solid var(--line);
|
||||||
border-radius: 18px 18px 18px 6px;
|
border-radius: 18px 18px 18px 6px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 7px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -721,7 +867,7 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-bubble::before {
|
.chat-bubble::before {
|
||||||
content: "";
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
border: 6px solid transparent;
|
border: 6px solid transparent;
|
||||||
@@ -741,7 +887,7 @@ tbody tr:hover {
|
|||||||
|
|
||||||
.chat-bubble-wrap--own .chat-bubble {
|
.chat-bubble-wrap--own .chat-bubble {
|
||||||
background: var(--bubble-own);
|
background: var(--bubble-own);
|
||||||
border-color: #1f5b52;
|
border-color: var(--accent-line);
|
||||||
border-radius: 18px 18px 6px 18px;
|
border-radius: 18px 18px 6px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,8 +896,13 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes chat-highlight {
|
@keyframes chat-highlight {
|
||||||
0%, 100% { box-shadow: 0 0 0 0 rgba(139, 180, 248, 0); }
|
0%,
|
||||||
50% { box-shadow: 0 0 0 3px rgba(139, 180, 248, 0.4); }
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(139, 180, 248, 0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 3px rgba(139, 180, 248, 0.4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Sender ---------- */
|
/* ---------- Sender ---------- */
|
||||||
@@ -852,6 +1003,55 @@ tbody tr:hover {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-state,
|
||||||
|
.viewer-empty-state {
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--dim);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #0b0b0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-empty-state {
|
||||||
|
align-self: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-root {
|
||||||
|
position: fixed;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 18px;
|
||||||
|
z-index: 1000;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
width: min(360px, calc(100vw - 36px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--panel-solid);
|
||||||
|
color: var(--text-color);
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-success {
|
||||||
|
border-color: var(--accent-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-error {
|
||||||
|
border-color: #7a2a2a;
|
||||||
|
color: #ffd4d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-hide {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
.api-docs {
|
.api-docs {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -927,9 +1127,9 @@ tbody tr:hover {
|
|||||||
.account-tabs {
|
.account-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 18px;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 14px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -937,8 +1137,8 @@ tbody tr:hover {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: 38px;
|
min-height: 40px;
|
||||||
padding: 7px 14px;
|
padding: 8px 15px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #101010;
|
background: #101010;
|
||||||
@@ -949,9 +1149,10 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.account-tab.active {
|
.account-tab.active {
|
||||||
border-color: #0f6f67;
|
border-color: var(--accent-line);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background: #0f6f67;
|
background: var(--accent);
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-tab .account-tab-status {
|
.account-tab .account-tab-status {
|
||||||
@@ -981,8 +1182,8 @@ tbody tr:hover {
|
|||||||
|
|
||||||
.accounts-list {
|
.accounts-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-list-item {
|
.account-list-item {
|
||||||
@@ -990,10 +1191,10 @@ tbody tr:hover {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 12px;
|
padding: 12px 14px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: #0f0f0f;
|
background: #101010;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-list-info {
|
.account-list-info {
|
||||||
@@ -1022,12 +1223,22 @@ tbody tr:hover {
|
|||||||
|
|
||||||
.add-account-form {
|
.add-account-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
margin-top: 8px;
|
margin-top: 10px;
|
||||||
padding: 12px;
|
padding: 14px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 16px;
|
border-radius: 18px;
|
||||||
background: #0f0f0f;
|
background: #101010;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-account-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #101010;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Active Account Card ─────────────────────────────── */
|
/* ── Active Account Card ─────────────────────────────── */
|
||||||
@@ -1064,7 +1275,7 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.viewer-sidebar.open::before {
|
.viewer-sidebar.open::before {
|
||||||
content: "";
|
content: '';
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
|||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Telegram Scraper API</title>
|
<title>Telegram Scraper API</title>
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<link rel="stylesheet" href="/static/style.css?v=6" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="viewer-shell">
|
<div class="viewer-shell">
|
||||||
|
|||||||
+15
-15
@@ -1,37 +1,37 @@
|
|||||||
async function loadSpec() {
|
async function loadSpec() {
|
||||||
const response = await fetch("/openapi.json");
|
const response = await fetch('/openapi.json');
|
||||||
const spec = await response.json();
|
const spec = await response.json();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(spec.error || "Failed to load OpenAPI spec");
|
throw new Error(spec.error || 'Failed to load OpenAPI spec');
|
||||||
}
|
}
|
||||||
return spec;
|
return spec;
|
||||||
}
|
}
|
||||||
|
|
||||||
function methodPayload(operation) {
|
function methodPayload(operation) {
|
||||||
const body = operation.requestBody?.content?.["application/json"]?.schema;
|
const body = operation.requestBody?.content?.['application/json']?.schema;
|
||||||
if (!body) return "";
|
if (!body) return '';
|
||||||
return JSON.stringify(body.example || body.properties || body, null, 2);
|
return JSON.stringify(body.example || body.properties || body, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSpec(spec) {
|
function renderSpec(spec) {
|
||||||
const root = document.getElementById("api-docs");
|
const root = document.getElementById('api-docs');
|
||||||
const sectionTemplate = document.getElementById("api-section-template");
|
const sectionTemplate = document.getElementById('api-section-template');
|
||||||
const methodTemplate = document.getElementById("api-method-template");
|
const methodTemplate = document.getElementById('api-method-template');
|
||||||
root.innerHTML = "";
|
root.innerHTML = '';
|
||||||
|
|
||||||
Object.entries(spec.paths || {}).forEach(([path, methods]) => {
|
Object.entries(spec.paths || {}).forEach(([path, methods]) => {
|
||||||
const section = sectionTemplate.content.firstElementChild.cloneNode(true);
|
const section = sectionTemplate.content.firstElementChild.cloneNode(true);
|
||||||
section.querySelector(".api-path").textContent = path;
|
section.querySelector('.api-path').textContent = path;
|
||||||
const methodsRoot = section.querySelector(".api-methods");
|
const methodsRoot = section.querySelector('.api-methods');
|
||||||
|
|
||||||
Object.entries(methods).forEach(([method, operation]) => {
|
Object.entries(methods).forEach(([method, operation]) => {
|
||||||
const node = methodTemplate.content.firstElementChild.cloneNode(true);
|
const node = methodTemplate.content.firstElementChild.cloneNode(true);
|
||||||
node.querySelector(".api-verb").textContent = method.toUpperCase();
|
node.querySelector('.api-verb').textContent = method.toUpperCase();
|
||||||
node.querySelector(".api-summary").textContent = operation.summary || "";
|
node.querySelector('.api-summary').textContent = operation.summary || '';
|
||||||
node.querySelector(".api-description").textContent = operation.description || "";
|
node.querySelector('.api-description').textContent = operation.description || '';
|
||||||
|
|
||||||
const body = methodPayload(operation);
|
const body = methodPayload(operation);
|
||||||
const bodyNode = node.querySelector(".api-body");
|
const bodyNode = node.querySelector('.api-body');
|
||||||
if (body) {
|
if (body) {
|
||||||
bodyNode.textContent = body;
|
bodyNode.textContent = body;
|
||||||
} else {
|
} else {
|
||||||
@@ -49,5 +49,5 @@ loadSpec()
|
|||||||
.then(renderSpec)
|
.then(renderSpec)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
document.getElementById("api-docs").innerHTML = `<section class="panel">${error.message}</section>`;
|
document.getElementById('api-docs').innerHTML = `<section class="panel">${error.message}</section>`;
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-3
@@ -1,10 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Telegram Scraper Viewer</title>
|
<title>Telegram Scraper Viewer</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=4" />
|
<link rel="stylesheet" href="/static/style.css?v=6" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="viewer-shell">
|
<div class="viewer-shell">
|
||||||
@@ -13,7 +13,10 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="eyebrow">Export Viewer</div>
|
<div class="eyebrow">Export Viewer</div>
|
||||||
<h1>Messages</h1>
|
<h1>Messages</h1>
|
||||||
<p class="muted small">Account: <select id="viewer-account-select"></select></p>
|
<p class="muted small">
|
||||||
|
Account:
|
||||||
|
<select id="viewer-account-select"></select>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button id="sidebar-toggle" class="button button-small sidebar-toggle">☰</button>
|
<button id="sidebar-toggle" class="button button-small sidebar-toggle">☰</button>
|
||||||
<a class="button button-small" href="/">Dashboard</a>
|
<a class="button button-small" href="/">Dashboard</a>
|
||||||
@@ -27,6 +30,14 @@
|
|||||||
<h2 id="viewer-title">Select a channel</h2>
|
<h2 id="viewer-title">Select a channel</h2>
|
||||||
<p id="viewer-subtitle" class="muted">Reading messages from the local SQLite database.</p>
|
<p id="viewer-subtitle" class="muted">Reading messages from the local SQLite database.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="viewer-tools">
|
||||||
|
<input id="viewer-search" type="search" placeholder="Search messages" />
|
||||||
|
<label class="viewer-auto-refresh">
|
||||||
|
<input id="viewer-auto-refresh" type="checkbox" checked />
|
||||||
|
<span>Auto-refresh</span>
|
||||||
|
</label>
|
||||||
|
<button id="viewer-refresh-btn" class="button button-small" type="button">Refresh</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section id="messages-list" class="messages-list">
|
<section id="messages-list" class="messages-list">
|
||||||
|
|||||||
+217
-129
@@ -1,4 +1,4 @@
|
|||||||
if ("scrollRestoration" in history) history.scrollRestoration = "manual";
|
if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
|
||||||
|
|
||||||
const viewerState = {
|
const viewerState = {
|
||||||
accountId: null,
|
accountId: null,
|
||||||
@@ -8,7 +8,9 @@ const viewerState = {
|
|||||||
oldestMessageId: null,
|
oldestMessageId: null,
|
||||||
newestMessageId: null,
|
newestMessageId: null,
|
||||||
userId: null,
|
userId: null,
|
||||||
|
search: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
|
autoRefreshTimer: null,
|
||||||
sentinelObserver: null,
|
sentinelObserver: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,98 +18,139 @@ async function api(path) {
|
|||||||
const response = await fetch(path);
|
const response = await fetch(path);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.error || "Request failed");
|
throw new Error(data.error || 'Request failed');
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
let root = document.getElementById('toast-root');
|
||||||
|
if (!root) {
|
||||||
|
root = document.createElement('div');
|
||||||
|
root.id = 'toast-root';
|
||||||
|
root.className = 'toast-root';
|
||||||
|
document.body.appendChild(root);
|
||||||
|
}
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast toast-${type}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
root.appendChild(toast);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
toast.classList.add('toast-hide');
|
||||||
|
window.setTimeout(() => toast.remove(), 250);
|
||||||
|
}, 3600);
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateHeader(dateStr) {
|
function formatDateHeader(dateStr) {
|
||||||
if (!dateStr) return "";
|
if (!dateStr) return '';
|
||||||
const d = new Date(dateStr.replace(" ", "T"));
|
const d = new Date(dateStr.replace(' ', 'T'));
|
||||||
if (isNaN(d.getTime())) return "";
|
if (isNaN(d.getTime())) return '';
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = now - d;
|
const diff = now - d;
|
||||||
const oneDay = 86400000;
|
const oneDay = 86400000;
|
||||||
if (diff < oneDay && d.getDate() === now.getDate()) return "Today";
|
if (diff < oneDay && d.getDate() === now.getDate()) return 'Today';
|
||||||
if (diff < 2 * oneDay && d.getDate() === now.getDate() - 1) return "Yesterday";
|
if (diff < 2 * oneDay && d.getDate() === now.getDate() - 1) return 'Yesterday';
|
||||||
return d.toLocaleDateString("en-US", {
|
return d.toLocaleDateString('en-US', {
|
||||||
month: "long",
|
month: 'long',
|
||||||
day: "numeric",
|
day: 'numeric',
|
||||||
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(dateStr) {
|
function formatTime(dateStr) {
|
||||||
if (!dateStr) return "";
|
if (!dateStr) return '';
|
||||||
const d = new Date(dateStr.replace(" ", "T"));
|
const d = new Date(dateStr.replace(' ', 'T'));
|
||||||
if (isNaN(d.getTime())) return "";
|
if (isNaN(d.getTime())) return '';
|
||||||
return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
|
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function chatListTime(dateStr) {
|
function chatListTime(dateStr) {
|
||||||
if (!dateStr) return "";
|
if (!dateStr) return '';
|
||||||
const d = new Date(dateStr.replace(" ", "T"));
|
const d = new Date(dateStr.replace(' ', 'T'));
|
||||||
if (isNaN(d.getTime())) return "";
|
if (isNaN(d.getTime())) return '';
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (d.toDateString() === now.toDateString()) {
|
if (d.toDateString() === now.toDateString()) {
|
||||||
return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
|
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function initials(value) {
|
function initials(value) {
|
||||||
const source = String(value || "?").replace(/^@/, "").trim();
|
const source = String(value || '?')
|
||||||
|
.replace(/^@/, '')
|
||||||
|
.trim();
|
||||||
const words = source.split(/\s+/).filter(Boolean);
|
const words = source.split(/\s+/).filter(Boolean);
|
||||||
if (words.length > 1) return (words[0][0] + words[1][0]).toUpperCase();
|
if (words.length > 1) return (words[0][0] + words[1][0]).toUpperCase();
|
||||||
return source.slice(0, 2).toUpperCase();
|
return source.slice(0, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function dateKey(dateStr) {
|
function dateKey(dateStr) {
|
||||||
if (!dateStr) return "";
|
if (!dateStr) return '';
|
||||||
return dateStr.slice(0, 10);
|
return dateStr.slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
function textNodeWithBreaks(text) {
|
function textNodeWithBreaks(text) {
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
if (!text) return fragment;
|
if (!text) return fragment;
|
||||||
const parts = text.split("\n");
|
const parts = text.split('\n');
|
||||||
parts.forEach((part, index) => {
|
parts.forEach((part, index) => {
|
||||||
if (index > 0) fragment.appendChild(document.createElement("br"));
|
if (index > 0) fragment.appendChild(document.createElement('br'));
|
||||||
fragment.appendChild(document.createTextNode(part));
|
fragment.appendChild(document.createTextNode(part));
|
||||||
});
|
});
|
||||||
return fragment;
|
return fragment;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
const list = document.getElementById("messages-list");
|
const list = document.getElementById('messages-list');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
const go = () => { list.scrollTop = list.scrollHeight; };
|
const go = () => {
|
||||||
|
list.scrollTop = list.scrollHeight;
|
||||||
|
};
|
||||||
go();
|
go();
|
||||||
requestAnimationFrame(go);
|
requestAnimationFrame(go);
|
||||||
setTimeout(go, 150);
|
setTimeout(go, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeScrollSentinel() {
|
||||||
|
const sentinel = document.createElement('div');
|
||||||
|
sentinel.id = 'scroll-sentinel';
|
||||||
|
sentinel.className = 'scroll-sentinel';
|
||||||
|
return sentinel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetMessageView(title = 'Select a channel', subtitle = 'Reading messages from the local SQLite database.') {
|
||||||
|
const root = document.getElementById('messages-list');
|
||||||
|
if (root) {
|
||||||
|
root.innerHTML = '';
|
||||||
|
root.appendChild(makeScrollSentinel());
|
||||||
|
}
|
||||||
|
document.getElementById('viewer-title').textContent = title;
|
||||||
|
document.getElementById('viewer-subtitle').textContent = subtitle;
|
||||||
|
viewerState.oldestMessageId = null;
|
||||||
|
viewerState.newestMessageId = null;
|
||||||
|
}
|
||||||
|
|
||||||
function renderChannelList() {
|
function renderChannelList() {
|
||||||
const root = document.getElementById("viewer-channel-list");
|
const root = document.getElementById('viewer-channel-list');
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
root.innerHTML = "";
|
root.innerHTML = '';
|
||||||
const template = document.getElementById("viewer-channel-template");
|
const template = document.getElementById('viewer-channel-template');
|
||||||
if (!template) return;
|
if (!template) return;
|
||||||
viewerState.channels.forEach((channel) => {
|
viewerState.channels.forEach((channel) => {
|
||||||
const node = template.content.firstElementChild.cloneNode(true);
|
const node = template.content.firstElementChild.cloneNode(true);
|
||||||
const preview =
|
const preview =
|
||||||
channel.last_message_preview ||
|
channel.last_message_preview ||
|
||||||
(channel.has_database ? "No text in the last saved message" : "No local database yet");
|
(channel.has_database ? 'No text in the last saved message' : 'No local database yet');
|
||||||
node.querySelector(".viewer-channel-avatar").textContent = initials(channel.name || channel.channel_id);
|
node.querySelector('.viewer-channel-avatar').textContent = initials(channel.name || channel.channel_id);
|
||||||
node.querySelector(".viewer-channel-name").textContent = channel.name;
|
node.querySelector('.viewer-channel-name').textContent = channel.name;
|
||||||
node.querySelector(".viewer-channel-time").textContent = chatListTime(channel.last_date);
|
node.querySelector('.viewer-channel-time').textContent = chatListTime(channel.last_date);
|
||||||
node.querySelector(".viewer-channel-preview").textContent = preview;
|
node.querySelector('.viewer-channel-preview').textContent = preview;
|
||||||
node.querySelector(".viewer-channel-count").textContent = String(channel.message_count || 0);
|
node.querySelector('.viewer-channel-count').textContent = String(channel.message_count || 0);
|
||||||
if (channel.channel_id === viewerState.channelId) {
|
if (channel.channel_id === viewerState.channelId) {
|
||||||
node.classList.add("active");
|
node.classList.add('active');
|
||||||
}
|
}
|
||||||
node.addEventListener("click", () => {
|
node.addEventListener('click', () => {
|
||||||
document.querySelector(".viewer-sidebar")?.classList.remove("open");
|
document.querySelector('.viewer-sidebar')?.classList.remove('open');
|
||||||
viewerState.oldestMessageId = null;
|
viewerState.oldestMessageId = null;
|
||||||
viewerState.newestMessageId = null;
|
viewerState.newestMessageId = null;
|
||||||
loadChannel(channel.channel_id);
|
loadChannel(channel.channel_id);
|
||||||
@@ -116,33 +159,34 @@ function renderChannelList() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageEndpoint(channelId, before = "") {
|
function messageEndpoint(channelId, before = '') {
|
||||||
|
const search = viewerState.search ? `&search=${encodeURIComponent(viewerState.search)}` : '';
|
||||||
if (viewerState.accountId) {
|
if (viewerState.accountId) {
|
||||||
return `/api/accounts/${encodeURIComponent(viewerState.accountId)}/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}`;
|
return `/api/accounts/${encodeURIComponent(viewerState.accountId)}/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}${search}`;
|
||||||
}
|
}
|
||||||
return `/api/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}`;
|
return `/api/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}${search}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function channelsEndpoint() {
|
function channelsEndpoint() {
|
||||||
if (viewerState.accountId) {
|
if (viewerState.accountId) {
|
||||||
return `/api/accounts/${encodeURIComponent(viewerState.accountId)}/channels`;
|
return `/api/accounts/${encodeURIComponent(viewerState.accountId)}/channels`;
|
||||||
}
|
}
|
||||||
return "/api/channels";
|
return '/api/channels';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAccountSelector() {
|
function renderAccountSelector() {
|
||||||
const sel = document.getElementById("viewer-account-select");
|
const sel = document.getElementById('viewer-account-select');
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
sel.innerHTML = "";
|
sel.innerHTML = '';
|
||||||
const legacy = viewerState.accounts.length === 0;
|
const legacy = viewerState.accounts.length === 0;
|
||||||
if (legacy) {
|
if (legacy) {
|
||||||
const opt = document.createElement("option");
|
const opt = document.createElement('option');
|
||||||
opt.value = "";
|
opt.value = '';
|
||||||
opt.textContent = "Legacy";
|
opt.textContent = 'Legacy';
|
||||||
sel.appendChild(opt);
|
sel.appendChild(opt);
|
||||||
} else {
|
} else {
|
||||||
viewerState.accounts.forEach((acc) => {
|
viewerState.accounts.forEach((acc) => {
|
||||||
const opt = document.createElement("option");
|
const opt = document.createElement('option');
|
||||||
opt.value = acc.id;
|
opt.value = acc.id;
|
||||||
opt.textContent = acc.label || acc.id;
|
opt.textContent = acc.label || acc.id;
|
||||||
if (acc.id === viewerState.accountId) opt.selected = true;
|
if (acc.id === viewerState.accountId) opt.selected = true;
|
||||||
@@ -153,9 +197,12 @@ function renderAccountSelector() {
|
|||||||
|
|
||||||
async function switchViewerAccount(accountId) {
|
async function switchViewerAccount(accountId) {
|
||||||
viewerState.accountId = accountId || null;
|
viewerState.accountId = accountId || null;
|
||||||
|
viewerState.search = '';
|
||||||
|
const searchInput = document.getElementById('viewer-search');
|
||||||
|
if (searchInput) searchInput.value = '';
|
||||||
viewerState.accounts.forEach((acc) => {
|
viewerState.accounts.forEach((acc) => {
|
||||||
if (acc.id === accountId) {
|
if (acc.id === accountId) {
|
||||||
const opts = document.getElementById("viewer-account-select")?.options;
|
const opts = document.getElementById('viewer-account-select')?.options;
|
||||||
if (opts) {
|
if (opts) {
|
||||||
for (let i = 0; i < opts.length; i++) {
|
for (let i = 0; i < opts.length; i++) {
|
||||||
opts[i].selected = opts[i].value === accountId;
|
opts[i].selected = opts[i].value === accountId;
|
||||||
@@ -166,10 +213,10 @@ async function switchViewerAccount(accountId) {
|
|||||||
try {
|
try {
|
||||||
const authData = viewerState.accountId
|
const authData = viewerState.accountId
|
||||||
? await api(`/api/accounts/${encodeURIComponent(viewerState.accountId)}/auth`)
|
? await api(`/api/accounts/${encodeURIComponent(viewerState.accountId)}/auth`)
|
||||||
: await api("/api/auth");
|
: await api('/api/auth');
|
||||||
viewerState.userId = authData.user_id || null;
|
viewerState.userId = authData.user_id || null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Could not fetch user ID:", e);
|
console.warn('Could not fetch user ID:', e);
|
||||||
}
|
}
|
||||||
const channels = await api(channelsEndpoint());
|
const channels = await api(channelsEndpoint());
|
||||||
viewerState.channels = channels;
|
viewerState.channels = channels;
|
||||||
@@ -179,94 +226,95 @@ async function switchViewerAccount(accountId) {
|
|||||||
renderChannelList();
|
renderChannelList();
|
||||||
if (viewerState.channelId) {
|
if (viewerState.channelId) {
|
||||||
await loadChannel(viewerState.channelId);
|
await loadChannel(viewerState.channelId);
|
||||||
|
} else {
|
||||||
|
resetMessageView(
|
||||||
|
'No channels',
|
||||||
|
viewerState.accountId ? 'This account has no tracked channels yet.' : 'No local legacy channels found.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadViewerAccount(params) {
|
async function loadViewerAccount(params) {
|
||||||
const requested = params.get("account");
|
const requested = params.get('account');
|
||||||
try {
|
try {
|
||||||
const payload = await api("/api/accounts");
|
const payload = await api('/api/accounts');
|
||||||
viewerState.accounts = payload.accounts || [];
|
viewerState.accounts = payload.accounts || [];
|
||||||
} catch {
|
} catch {
|
||||||
viewerState.accounts = [];
|
viewerState.accounts = [];
|
||||||
}
|
}
|
||||||
viewerState.accountId =
|
const requestedExists = requested && viewerState.accounts.some((acc) => acc.id === requested);
|
||||||
requested ||
|
viewerState.accountId = requestedExists ? requested : viewerState.accounts[0]?.id || null;
|
||||||
viewerState.accounts[0]?.id ||
|
|
||||||
null;
|
|
||||||
|
|
||||||
renderAccountSelector();
|
renderAccountSelector();
|
||||||
|
|
||||||
const sel = document.getElementById("viewer-account-select");
|
const sel = document.getElementById('viewer-account-select');
|
||||||
if (sel) {
|
if (sel) {
|
||||||
sel.addEventListener("change", () => switchViewerAccount(sel.value));
|
sel.addEventListener('change', () => switchViewerAccount(sel.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authData = viewerState.accountId
|
const authData = viewerState.accountId
|
||||||
? await api(`/api/accounts/${encodeURIComponent(viewerState.accountId)}/auth`)
|
? await api(`/api/accounts/${encodeURIComponent(viewerState.accountId)}/auth`)
|
||||||
: await api("/api/auth");
|
: await api('/api/auth');
|
||||||
viewerState.userId = authData.user_id || null;
|
viewerState.userId = authData.user_id || null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Could not fetch user ID:", e);
|
console.warn('Could not fetch user ID:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMessageNode(message, root) {
|
function buildMessageNode(message, root) {
|
||||||
const template = document.getElementById("message-template");
|
const template = document.getElementById('message-template');
|
||||||
const wrap = template.content.firstElementChild.cloneNode(true);
|
const wrap = template.content.firstElementChild.cloneNode(true);
|
||||||
const bubble = wrap.querySelector(".chat-bubble");
|
const bubble = wrap.querySelector('.chat-bubble');
|
||||||
|
|
||||||
bubble.dataset.messageId = String(message.message_id);
|
bubble.dataset.messageId = String(message.message_id);
|
||||||
|
|
||||||
const isOwn = viewerState.userId && message.sender_id === viewerState.userId;
|
const isOwn = viewerState.userId && message.sender_id === viewerState.userId;
|
||||||
if (isOwn) {
|
if (isOwn) {
|
||||||
wrap.classList.add("chat-bubble-wrap--own");
|
wrap.classList.add('chat-bubble-wrap--own');
|
||||||
}
|
}
|
||||||
|
|
||||||
const sender = bubble.querySelector(".chat-sender");
|
const sender = bubble.querySelector('.chat-sender');
|
||||||
sender.textContent = message.sender_name || "";
|
sender.textContent = message.sender_name || '';
|
||||||
|
|
||||||
const replyEl = bubble.querySelector(".chat-reply");
|
const replyEl = bubble.querySelector('.chat-reply');
|
||||||
const replyMsg = message.reply_to_message;
|
const replyMsg = message.reply_to_message;
|
||||||
if (message.reply_to) {
|
if (message.reply_to) {
|
||||||
replyEl.classList.remove("hidden");
|
replyEl.classList.remove('hidden');
|
||||||
bubble.querySelector(".chat-reply-author").textContent =
|
bubble.querySelector('.chat-reply-author').textContent = replyMsg?.sender_name || `#${message.reply_to}`;
|
||||||
replyMsg?.sender_name || `#${message.reply_to}`;
|
bubble.querySelector('.chat-reply-text').textContent = replyMsg?.text || '(message not available)';
|
||||||
bubble.querySelector(".chat-reply-text").textContent =
|
replyEl.addEventListener('click', () => {
|
||||||
replyMsg?.text || "(message not available)";
|
|
||||||
replyEl.addEventListener("click", () => {
|
|
||||||
const target = root.querySelector(`[data-message-id="${message.reply_to}"]`);
|
const target = root.querySelector(`[data-message-id="${message.reply_to}"]`);
|
||||||
if (target) {
|
if (target) {
|
||||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
target.classList.add("chat-bubble--highlight");
|
target.classList.add('chat-bubble--highlight');
|
||||||
setTimeout(() => target.classList.remove("chat-bubble--highlight"), 2000);
|
setTimeout(() => target.classList.remove('chat-bubble--highlight'), 2000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
replyEl.classList.add("hidden");
|
replyEl.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
bubble.querySelector(".chat-text").appendChild(textNodeWithBreaks(message.text || ""));
|
bubble.querySelector('.chat-text').appendChild(textNodeWithBreaks(message.text || ''));
|
||||||
|
|
||||||
const media = bubble.querySelector(".chat-media");
|
const media = bubble.querySelector('.chat-media');
|
||||||
if (message.media_url && message.media_kind === "image") {
|
if (message.media_url && message.media_kind === 'image') {
|
||||||
const img = document.createElement("img");
|
const img = document.createElement('img');
|
||||||
img.src = message.media_url;
|
img.src = message.media_url;
|
||||||
img.loading = "lazy";
|
img.loading = 'lazy';
|
||||||
media.appendChild(img);
|
media.appendChild(img);
|
||||||
} else if (message.media_url && message.media_kind === "video") {
|
} else if (message.media_url && message.media_kind === 'video') {
|
||||||
const video = document.createElement("video");
|
const video = document.createElement('video');
|
||||||
video.src = message.media_url;
|
video.src = message.media_url;
|
||||||
video.controls = true;
|
video.controls = true;
|
||||||
video.preload = "metadata";
|
video.preload = 'metadata';
|
||||||
media.appendChild(video);
|
media.appendChild(video);
|
||||||
} else if (message.media_url) {
|
} else if (message.media_url) {
|
||||||
const link = document.createElement("a");
|
const link = document.createElement('a');
|
||||||
link.href = message.media_url;
|
link.href = message.media_url;
|
||||||
link.target = "_blank";
|
link.target = '_blank';
|
||||||
link.rel = "noreferrer";
|
link.rel = 'noreferrer';
|
||||||
link.textContent = "Open file";
|
link.textContent = 'Open file';
|
||||||
media.appendChild(link);
|
media.appendChild(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,40 +323,40 @@ function buildMessageNode(message, root) {
|
|||||||
if (message.views) footerParts.push(`views: ${message.views}`);
|
if (message.views) footerParts.push(`views: ${message.views}`);
|
||||||
if (message.forwards) footerParts.push(`forwards: ${message.forwards}`);
|
if (message.forwards) footerParts.push(`forwards: ${message.forwards}`);
|
||||||
if (message.reactions) footerParts.push(`reactions: ${message.reactions}`);
|
if (message.reactions) footerParts.push(`reactions: ${message.reactions}`);
|
||||||
bubble.querySelector(".chat-footer").textContent = footerParts.join(" | ");
|
bubble.querySelector('.chat-footer').textContent = footerParts.join(' | ');
|
||||||
|
|
||||||
return { wrap, dateKey: dateKey(message.date) };
|
return { wrap, dateKey: dateKey(message.date) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeDateSep(text) {
|
function makeDateSep(text) {
|
||||||
const el = document.createElement("div");
|
const el = document.createElement('div');
|
||||||
el.className = "chat-date-sep";
|
el.className = 'chat-date-sep';
|
||||||
el.textContent = text;
|
el.textContent = text;
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMessages(payload, append = false) {
|
function renderMessages(payload, append = false) {
|
||||||
const root = document.getElementById("messages-list");
|
const root = document.getElementById('messages-list');
|
||||||
const sentinel = document.getElementById("scroll-sentinel");
|
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
|
let sentinel = document.getElementById('scroll-sentinel');
|
||||||
|
if (!sentinel) sentinel = makeScrollSentinel();
|
||||||
|
|
||||||
const channel = payload.channel;
|
const channel = payload.channel;
|
||||||
document.getElementById("viewer-title").textContent =
|
document.getElementById('viewer-title').textContent = channel?.name || payload.channel_id;
|
||||||
channel?.name || payload.channel_id;
|
document.getElementById('viewer-subtitle').textContent = channel
|
||||||
document.getElementById("viewer-subtitle").textContent = channel
|
? `${payload.messages.length} shown${viewerState.search ? ` for "${viewerState.search}"` : ''}, ${channel.message_count} total, latest: ${channel.last_date || '-'}`
|
||||||
? `${channel.message_count} messages, latest: ${channel.last_date || "-"}`
|
: 'No local database found for this channel yet.';
|
||||||
: "No local database found for this channel yet.";
|
|
||||||
|
|
||||||
if (!append) {
|
if (!append) {
|
||||||
root.innerHTML = "";
|
root.innerHTML = '';
|
||||||
root.appendChild(sentinel);
|
root.appendChild(sentinel);
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastKey = null;
|
let lastKey = null;
|
||||||
if (append) {
|
if (append) {
|
||||||
const firstExisting = root.querySelector("[data-message-id]");
|
const firstExisting = root.querySelector('[data-message-id]');
|
||||||
if (firstExisting) {
|
if (firstExisting) {
|
||||||
const candidate = firstExisting.closest(".chat-bubble-wrap");
|
const candidate = firstExisting.closest('.chat-bubble-wrap');
|
||||||
if (candidate && candidate._dateKey) lastKey = candidate._dateKey;
|
if (candidate && candidate._dateKey) lastKey = candidate._dateKey;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,8 +390,16 @@ function renderMessages(payload, append = false) {
|
|||||||
|
|
||||||
if (payload.messages.length) {
|
if (payload.messages.length) {
|
||||||
viewerState.oldestMessageId = payload.messages[0].message_id;
|
viewerState.oldestMessageId = payload.messages[0].message_id;
|
||||||
viewerState.newestMessageId =
|
viewerState.newestMessageId = payload.messages[payload.messages.length - 1].message_id;
|
||||||
payload.messages[payload.messages.length - 1].message_id;
|
} else if (!append) {
|
||||||
|
viewerState.oldestMessageId = null;
|
||||||
|
viewerState.newestMessageId = null;
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'viewer-empty-state';
|
||||||
|
empty.textContent = viewerState.search
|
||||||
|
? `No messages found for "${viewerState.search}".`
|
||||||
|
: 'No saved messages in this channel yet.';
|
||||||
|
root.appendChild(empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!append) {
|
if (!append) {
|
||||||
@@ -357,14 +413,11 @@ async function loadChannel(channelId, append = false) {
|
|||||||
viewerState.channelId = channelId;
|
viewerState.channelId = channelId;
|
||||||
renderChannelList();
|
renderChannelList();
|
||||||
|
|
||||||
const before =
|
const before = append && viewerState.oldestMessageId ? `&before=${viewerState.oldestMessageId}` : '';
|
||||||
append && viewerState.oldestMessageId
|
|
||||||
? `&before=${viewerState.oldestMessageId}`
|
|
||||||
: "";
|
|
||||||
try {
|
try {
|
||||||
const payload = await api(messageEndpoint(channelId, before));
|
const payload = await api(messageEndpoint(channelId, before));
|
||||||
if (append) {
|
if (append) {
|
||||||
const list = document.getElementById("messages-list");
|
const list = document.getElementById('messages-list');
|
||||||
const prevScrollHeight = list.scrollHeight;
|
const prevScrollHeight = list.scrollHeight;
|
||||||
renderMessages(payload, true);
|
renderMessages(payload, true);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -374,14 +427,48 @@ async function loadChannel(channelId, append = false) {
|
|||||||
renderMessages(payload, false);
|
renderMessages(payload, false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load messages:", err);
|
console.error('Failed to load messages:', err);
|
||||||
} finally {
|
} finally {
|
||||||
viewerState.loading = false;
|
viewerState.loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshCurrentChannel() {
|
||||||
|
if (!viewerState.channelId || viewerState.loading) return;
|
||||||
|
viewerState.oldestMessageId = null;
|
||||||
|
viewerState.newestMessageId = null;
|
||||||
|
await loadChannel(viewerState.channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupViewerTools() {
|
||||||
|
const searchInput = document.getElementById('viewer-search');
|
||||||
|
const refreshBtn = document.getElementById('viewer-refresh-btn');
|
||||||
|
const autoRefresh = document.getElementById('viewer-auto-refresh');
|
||||||
|
let searchTimer = null;
|
||||||
|
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
viewerState.search = searchInput.value.trim();
|
||||||
|
refreshCurrentChannel();
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.addEventListener('click', () => refreshCurrentChannel());
|
||||||
|
}
|
||||||
|
|
||||||
|
viewerState.autoRefreshTimer = window.setInterval(() => {
|
||||||
|
if (!autoRefresh || autoRefresh.checked) {
|
||||||
|
refreshCurrentChannel();
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
function setupInfiniteScroll() {
|
function setupInfiniteScroll() {
|
||||||
const sentinel = document.getElementById("scroll-sentinel");
|
const sentinel = document.getElementById('scroll-sentinel');
|
||||||
if (!sentinel) return;
|
if (!sentinel) return;
|
||||||
if (viewerState.sentinelObserver) {
|
if (viewerState.sentinelObserver) {
|
||||||
viewerState.sentinelObserver.disconnect();
|
viewerState.sentinelObserver.disconnect();
|
||||||
@@ -389,17 +476,12 @@ function setupInfiniteScroll() {
|
|||||||
viewerState.sentinelObserver = new IntersectionObserver(
|
viewerState.sentinelObserver = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (
|
if (entry.isIntersecting && viewerState.channelId && viewerState.oldestMessageId && !viewerState.loading) {
|
||||||
entry.isIntersecting &&
|
|
||||||
viewerState.channelId &&
|
|
||||||
viewerState.oldestMessageId &&
|
|
||||||
!viewerState.loading
|
|
||||||
) {
|
|
||||||
loadChannel(viewerState.channelId, true);
|
loadChannel(viewerState.channelId, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ root: document.getElementById("messages-list"), threshold: 0.1 }
|
{ root: document.getElementById('messages-list'), threshold: 0.1 },
|
||||||
);
|
);
|
||||||
viewerState.sentinelObserver.observe(sentinel);
|
viewerState.sentinelObserver.observe(sentinel);
|
||||||
}
|
}
|
||||||
@@ -408,27 +490,33 @@ async function main() {
|
|||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
await loadViewerAccount(params);
|
await loadViewerAccount(params);
|
||||||
viewerState.channels = await api(channelsEndpoint());
|
viewerState.channels = await api(channelsEndpoint());
|
||||||
viewerState.channelId =
|
const requestedChannel = params.get('channel');
|
||||||
params.get("channel") ||
|
const requestedChannelExists =
|
||||||
viewerState.channels[0]?.channel_id ||
|
requestedChannel && viewerState.channels.some((channel) => channel.channel_id === requestedChannel);
|
||||||
null;
|
viewerState.channelId = requestedChannelExists ? requestedChannel : viewerState.channels[0]?.channel_id || null;
|
||||||
renderChannelList();
|
renderChannelList();
|
||||||
|
|
||||||
if (viewerState.channelId) {
|
if (viewerState.channelId) {
|
||||||
await loadChannel(viewerState.channelId);
|
await loadChannel(viewerState.channelId);
|
||||||
|
} else {
|
||||||
|
resetMessageView(
|
||||||
|
'No channels',
|
||||||
|
viewerState.accountId ? 'This account has no tracked channels yet.' : 'No local legacy channels found.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setupInfiniteScroll();
|
setupInfiniteScroll();
|
||||||
|
setupViewerTools();
|
||||||
|
|
||||||
const toggle = document.getElementById("sidebar-toggle");
|
const toggle = document.getElementById('sidebar-toggle');
|
||||||
const sidebar = document.querySelector(".viewer-sidebar");
|
const sidebar = document.querySelector('.viewer-sidebar');
|
||||||
if (toggle && sidebar) {
|
if (toggle && sidebar) {
|
||||||
toggle.addEventListener("click", () => {
|
toggle.addEventListener('click', () => {
|
||||||
sidebar.classList.toggle("open");
|
sidebar.classList.toggle('open');
|
||||||
});
|
});
|
||||||
sidebar.addEventListener("click", (e) => {
|
sidebar.addEventListener('click', (e) => {
|
||||||
if (e.target === sidebar) {
|
if (e.target === sidebar) {
|
||||||
sidebar.classList.remove("open");
|
sidebar.classList.remove('open');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -436,5 +524,5 @@ async function main() {
|
|||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
alert(error.message);
|
showToast(error.message, 'error');
|
||||||
});
|
});
|
||||||
|
|||||||
+303
-21
@@ -194,6 +194,7 @@ def load_messages(
|
|||||||
channel_id: str,
|
channel_id: str,
|
||||||
limit: int = 120,
|
limit: int = 120,
|
||||||
before_message_id: Optional[int] = None,
|
before_message_id: Optional[int] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
db_path = channel_db_path(account_id, channel_id)
|
db_path = channel_db_path(account_id, channel_id)
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
@@ -207,9 +208,15 @@ def load_messages(
|
|||||||
"FROM messages "
|
"FROM messages "
|
||||||
)
|
)
|
||||||
params: List[Any] = []
|
params: List[Any] = []
|
||||||
|
where: List[str] = []
|
||||||
if before_message_id is not None:
|
if before_message_id is not None:
|
||||||
query += "WHERE message_id < ? "
|
where.append("message_id < ?")
|
||||||
params.append(before_message_id)
|
params.append(before_message_id)
|
||||||
|
if search:
|
||||||
|
where.append("message LIKE ?")
|
||||||
|
params.append(f"%{search}%")
|
||||||
|
if where:
|
||||||
|
query += "WHERE " + " AND ".join(where) + " "
|
||||||
query += "ORDER BY message_id DESC LIMIT ?"
|
query += "ORDER BY message_id DESC LIMIT ?"
|
||||||
params.append(limit)
|
params.append(limit)
|
||||||
rows = conn.execute(query, params).fetchall()
|
rows = conn.execute(query, params).fetchall()
|
||||||
@@ -323,16 +330,29 @@ class JobRunner:
|
|||||||
def create_job(self, job_type: str, title: str, payload: Dict[str, Any]) -> Job:
|
def create_job(self, job_type: str, title: str, payload: Dict[str, Any]) -> Job:
|
||||||
if self._shutdown_flag:
|
if self._shutdown_flag:
|
||||||
raise RuntimeError("Server is shutting down, cannot create new jobs")
|
raise RuntimeError("Server is shutting down, cannot create new jobs")
|
||||||
job_id = f"job-{int(time.time() * 1000)}"
|
|
||||||
account_id = payload.get("account_id")
|
account_id = payload.get("account_id")
|
||||||
job = Job(
|
|
||||||
job_id=job_id,
|
|
||||||
job_type=job_type,
|
|
||||||
title=title,
|
|
||||||
payload=payload,
|
|
||||||
account_id=account_id,
|
|
||||||
)
|
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
if account_id:
|
||||||
|
for job_id in self.job_order:
|
||||||
|
existing = self.jobs[job_id]
|
||||||
|
if (
|
||||||
|
existing.account_id == account_id
|
||||||
|
and existing.status in {"queued", "running"}
|
||||||
|
):
|
||||||
|
existing.logs = (
|
||||||
|
existing.logs
|
||||||
|
+ "\n"
|
||||||
|
+ f"[{datetime.now().strftime('%H:%M:%S')}] Reused existing active job for this account."
|
||||||
|
).strip()
|
||||||
|
return existing
|
||||||
|
job_id = f"job-{int(time.time() * 1000)}"
|
||||||
|
job = Job(
|
||||||
|
job_id=job_id,
|
||||||
|
job_type=job_type,
|
||||||
|
title=title,
|
||||||
|
payload=payload,
|
||||||
|
account_id=account_id,
|
||||||
|
)
|
||||||
self.jobs[job_id] = job
|
self.jobs[job_id] = job
|
||||||
self.job_order.insert(0, job_id)
|
self.job_order.insert(0, job_id)
|
||||||
self.job_order = self.job_order[:20]
|
self.job_order = self.job_order[:20]
|
||||||
@@ -346,6 +366,15 @@ class JobRunner:
|
|||||||
result = [j for j in result if j.get("account_id") == account_id]
|
result = [j for j in result if j.get("account_id") == account_id]
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def active_jobs_by_account(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
with self.lock:
|
||||||
|
active = {}
|
||||||
|
for job_id in self.job_order:
|
||||||
|
job = self.jobs[job_id]
|
||||||
|
if job.account_id and job.status in {"queued", "running"}:
|
||||||
|
active[job.account_id] = job.to_dict()
|
||||||
|
return active
|
||||||
|
|
||||||
def get_job(self, job_id: str) -> Optional[Dict[str, Any]]:
|
def get_job(self, job_id: str) -> Optional[Dict[str, Any]]:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
job = self.jobs.get(job_id)
|
job = self.jobs.get(job_id)
|
||||||
@@ -731,6 +760,33 @@ class PerAccountContinuousScrapeManager:
|
|||||||
}
|
}
|
||||||
store.update(mutate)
|
store.update(mutate)
|
||||||
|
|
||||||
|
def refresh_config(self) -> None:
|
||||||
|
"""
|
||||||
|
Re-read account state from disk and sync the in-memory config.
|
||||||
|
Call this after external state changes (import, channel add/remove)
|
||||||
|
so the manager picks up new channels or config updates without a restart.
|
||||||
|
"""
|
||||||
|
disk_state = load_account(DATA_DIR, self.account_id)
|
||||||
|
disk_cfg = disk_state.get("continuous_scraping") or {}
|
||||||
|
with self.lock:
|
||||||
|
# Merge disk config into memory, preserving our current running state
|
||||||
|
self.config["enabled"] = bool(disk_cfg.get("enabled", False))
|
||||||
|
self.config["interval_minutes"] = max(1, int(disk_cfg.get("interval_minutes", 1) or 1))
|
||||||
|
self.config["channels"] = [
|
||||||
|
str(item).strip()
|
||||||
|
for item in disk_cfg.get("channels", [])
|
||||||
|
if str(item).strip()
|
||||||
|
]
|
||||||
|
self.config["run_all_tracked"] = bool(disk_cfg.get("run_all_tracked", True))
|
||||||
|
# Sync running state with desired enabled state
|
||||||
|
if self.config["enabled"] and not self.status["running"]:
|
||||||
|
pass # don't auto-start — user must call start()
|
||||||
|
elif not self.config["enabled"] and self.status["running"]:
|
||||||
|
self._log("Continuous disabled via external state change, stopping.", "warn")
|
||||||
|
self.stop_event.set()
|
||||||
|
self.status["running"] = False
|
||||||
|
self._log("Config refreshed from disk.", "debug")
|
||||||
|
|
||||||
def _log(self, message: str, level: str = "debug") -> None:
|
def _log(self, message: str, level: str = "debug") -> None:
|
||||||
timestamp = utc_now_iso()
|
timestamp = utc_now_iso()
|
||||||
entry = {"timestamp": timestamp, "level": level, "message": message}
|
entry = {"timestamp": timestamp, "level": level, "message": message}
|
||||||
@@ -811,6 +867,23 @@ class PerAccountContinuousScrapeManager:
|
|||||||
|
|
||||||
def _run_loop(self) -> None:
|
def _run_loop(self) -> None:
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
|
# Refresh config from disk so channel / setting changes take effect
|
||||||
|
self.refresh_config()
|
||||||
|
|
||||||
|
# Check auth — don't iterate if account isn't authorized
|
||||||
|
auth_info = auth_status_for(self.account_id)
|
||||||
|
if auth_info.get("status") not in ("ready", "authorized"):
|
||||||
|
self._log(
|
||||||
|
f"Account not authorized (status={auth_info.get('status')}), "
|
||||||
|
"skipping iteration",
|
||||||
|
"warn",
|
||||||
|
)
|
||||||
|
sleep_seconds = max(5, 60)
|
||||||
|
interrupted = self.stop_event.wait(timeout=sleep_seconds)
|
||||||
|
if interrupted:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
channels = self._resolve_channels()
|
channels = self._resolve_channels()
|
||||||
cfg = self.snapshot()["config"]
|
cfg = self.snapshot()["config"]
|
||||||
interval_minutes = cfg.get("interval_minutes", 1)
|
interval_minutes = cfg.get("interval_minutes", 1)
|
||||||
@@ -893,6 +966,11 @@ class ContinuousScrapeOrchestrator:
|
|||||||
result[account_id] = dict(mgr.snapshot())
|
result[account_id] = dict(mgr.snapshot())
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def refresh_account(self, account_id: str) -> None:
|
||||||
|
"""Re-read account state from disk and sync the continuous manager config."""
|
||||||
|
mgr = self._get_or_create(account_id)
|
||||||
|
mgr.refresh_config()
|
||||||
|
|
||||||
def update_for(
|
def update_for(
|
||||||
self,
|
self,
|
||||||
account_id: str,
|
account_id: str,
|
||||||
@@ -902,12 +980,14 @@ class ContinuousScrapeOrchestrator:
|
|||||||
run_all_tracked: bool,
|
run_all_tracked: bool,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
mgr = self._get_or_create(account_id)
|
mgr = self._get_or_create(account_id)
|
||||||
return mgr.update(enabled, interval_minutes, channels, run_all_tracked)
|
result = mgr.update(enabled, interval_minutes, channels, run_all_tracked)
|
||||||
|
# After explicit update, sync state to disk is already done by mgr.update
|
||||||
|
return result
|
||||||
|
|
||||||
def add_account(self, account_id: str) -> None:
|
def add_account(self, account_id: str, auto_start: bool = True) -> None:
|
||||||
acc_state = load_account(DATA_DIR, account_id)
|
acc_state = load_account(DATA_DIR, account_id)
|
||||||
cfg = acc_state.get("continuous_scraping", {})
|
cfg = acc_state.get("continuous_scraping", {})
|
||||||
if cfg.get("enabled", True) and START_CONTINUOUS:
|
if auto_start and cfg.get("enabled", False) and START_CONTINUOUS:
|
||||||
self.start_account(account_id)
|
self.start_account(account_id)
|
||||||
|
|
||||||
def remove_account(self, account_id: str) -> None:
|
def remove_account(self, account_id: str) -> None:
|
||||||
@@ -924,7 +1004,7 @@ class ContinuousScrapeOrchestrator:
|
|||||||
for account_id in list_accounts(DATA_DIR):
|
for account_id in list_accounts(DATA_DIR):
|
||||||
acc_state = load_account(DATA_DIR, account_id)
|
acc_state = load_account(DATA_DIR, account_id)
|
||||||
cfg = acc_state.get("continuous_scraping", {})
|
cfg = acc_state.get("continuous_scraping", {})
|
||||||
if cfg.get("enabled", True):
|
if cfg.get("enabled", False) and START_CONTINUOUS:
|
||||||
self.start_account(account_id)
|
self.start_account(account_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -973,6 +1053,27 @@ def auth_status_for(account_id: Optional[str] = None) -> Dict[str, Any]:
|
|||||||
return status
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def account_health_summary(account_id: str, job_runner: Optional[JobRunner] = None) -> Dict[str, Any]:
|
||||||
|
acc_state = load_account(DATA_DIR, account_id)
|
||||||
|
acc_dir = account_data_dir(DATA_DIR, account_id)
|
||||||
|
session_file = Path(account_session_path(SESSION_DIR, account_id))
|
||||||
|
channels = list_channels_snapshot(account_id)
|
||||||
|
active_job = (job_runner.active_jobs_by_account().get(account_id) if job_runner else None)
|
||||||
|
last_scrape = next((item.get("last_date") for item in channels if item.get("last_date")), None)
|
||||||
|
return {
|
||||||
|
"account_id": account_id,
|
||||||
|
"label": acc_state.get("label", ""),
|
||||||
|
"data_dir_exists": acc_dir.exists(),
|
||||||
|
"session_ready": session_file.exists(),
|
||||||
|
"api_credentials": bool(acc_state.get("api_id") and acc_state.get("api_hash")),
|
||||||
|
"channel_count": len(channels),
|
||||||
|
"message_count": sum(int(item.get("message_count") or 0) for item in channels),
|
||||||
|
"media_count": sum(int(item.get("media_count") or 0) for item in channels),
|
||||||
|
"last_scrape": last_scrape,
|
||||||
|
"active_job": active_job,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def auth_status() -> Dict[str, Any]:
|
def auth_status() -> Dict[str, Any]:
|
||||||
return auth_status_for(account_id=None)
|
return auth_status_for(account_id=None)
|
||||||
|
|
||||||
@@ -1166,6 +1267,12 @@ def openapi_payload() -> Dict[str, Any]:
|
|||||||
"in": "query",
|
"in": "query",
|
||||||
"schema": {"type": "integer"},
|
"schema": {"type": "integer"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "search",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {"type": "string"},
|
||||||
|
"description": "Full-text search in message text",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"responses": json_response,
|
"responses": json_response,
|
||||||
}
|
}
|
||||||
@@ -1229,6 +1336,31 @@ def openapi_payload() -> Dict[str, Any]:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/jobs/{job_id}/events": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Job SSE event stream",
|
||||||
|
"description": "Server-Sent Events stream of job status updates. Closes when job completes or fails.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "job_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": True,
|
||||||
|
"schema": {"type": "string"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "SSE stream of job status objects",
|
||||||
|
"content": {
|
||||||
|
"text/event-stream": {
|
||||||
|
"schema": {"type": "string"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"404": {"description": "Job not found"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/jobs/scrape": {
|
"/api/jobs/scrape": {
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Scrape one channel or all tracked channels",
|
"summary": "Scrape one channel or all tracked channels",
|
||||||
@@ -1318,6 +1450,23 @@ def openapi_payload() -> Dict[str, Any]:
|
|||||||
"responses": {**json_response, **error_response},
|
"responses": {**json_response, **error_response},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/accounts/import": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Import account from exported JSON",
|
||||||
|
"requestBody": json_body(
|
||||||
|
{
|
||||||
|
"account_id": {"type": "string", "example": "work"},
|
||||||
|
"state": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Exported account state object",
|
||||||
|
"example": {"label": "Work", "api_id": 123456, "api_hash": "abc", "channels": {}, "continuous_scraping": {"enabled": False}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
["account_id", "state"],
|
||||||
|
),
|
||||||
|
"responses": {**json_response, **error_response},
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/accounts/{id}": {
|
"/api/accounts/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "Account dashboard",
|
"summary": "Account dashboard",
|
||||||
@@ -1344,6 +1493,34 @@ def openapi_payload() -> Dict[str, Any]:
|
|||||||
"responses": json_response,
|
"responses": json_response,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/accounts/{id}/export": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Export account settings as JSON",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": True,
|
||||||
|
"schema": {"type": "string"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": json_response,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/accounts/{id}/health": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Account health summary",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": True,
|
||||||
|
"schema": {"type": "string"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": json_response,
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/accounts/{id}/auth": {
|
"/api/accounts/{id}/auth": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "Account auth snapshot",
|
"summary": "Account auth snapshot",
|
||||||
@@ -1452,6 +1629,12 @@ def openapi_payload() -> Dict[str, Any]:
|
|||||||
"in": "query",
|
"in": "query",
|
||||||
"schema": {"type": "integer"},
|
"schema": {"type": "integer"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "search",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {"type": "string"},
|
||||||
|
"description": "Full-text search in message text",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"responses": json_response,
|
"responses": json_response,
|
||||||
}
|
}
|
||||||
@@ -1594,6 +1777,8 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
if path == "/":
|
if path == "/":
|
||||||
return self.serve_file(WEBUI_DIR / "index.html", "text/html; charset=utf-8")
|
return self.serve_file(WEBUI_DIR / "index.html", "text/html; charset=utf-8")
|
||||||
|
if path == "/settings":
|
||||||
|
return self.serve_file(WEBUI_DIR / "settings.html", "text/html; charset=utf-8")
|
||||||
if path == "/viewer":
|
if path == "/viewer":
|
||||||
return self.serve_file(WEBUI_DIR / "viewer.html", "text/html; charset=utf-8")
|
return self.serve_file(WEBUI_DIR / "viewer.html", "text/html; charset=utf-8")
|
||||||
if path in {"/swagger", "/swigger", "/docs"}:
|
if path in {"/swagger", "/swigger", "/docs"}:
|
||||||
@@ -1634,6 +1819,9 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
if path == "/api/jobs":
|
if path == "/api/jobs":
|
||||||
return self.send_json(self.app.job_runner.recent_jobs())
|
return self.send_json(self.app.job_runner.recent_jobs())
|
||||||
|
if path.startswith("/api/jobs/") and path.endswith("/events"):
|
||||||
|
job_id = path.split("/")[-2]
|
||||||
|
return self.stream_job_events(job_id)
|
||||||
if path.startswith("/api/jobs/"):
|
if path.startswith("/api/jobs/"):
|
||||||
job_id = path.rsplit("/", 1)[-1]
|
job_id = path.rsplit("/", 1)[-1]
|
||||||
job = self.app.job_runner.get_job(job_id)
|
job = self.app.job_runner.get_job(job_id)
|
||||||
@@ -1644,14 +1832,16 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
|||||||
return self.send_json(list_channels_snapshot())
|
return self.send_json(list_channels_snapshot())
|
||||||
if path.startswith("/api/channels/") and path.endswith("/messages"):
|
if path.startswith("/api/channels/") and path.endswith("/messages"):
|
||||||
parts = path.split("/")
|
parts = path.split("/")
|
||||||
channel_id = parts[3]
|
channel_id = urllib.parse.unquote(parts[3]).lstrip("@")
|
||||||
limit = max(1, min(int(query.get("limit", ["120"])[0]), 300))
|
limit = max(1, min(int(query.get("limit", ["120"])[0]), 300))
|
||||||
before = query.get("before")
|
before = query.get("before")
|
||||||
before_message_id = int(before[0]) if before else None
|
before_message_id = int(before[0]) if before else None
|
||||||
|
search = (query.get("search") or query.get("q") or [""])[0].strip()
|
||||||
payload = {
|
payload = {
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
"messages": load_messages(
|
"messages": load_messages(
|
||||||
None, channel_id, limit=limit, before_message_id=before_message_id
|
None, channel_id, limit=limit, before_message_id=before_message_id,
|
||||||
|
search=search or None
|
||||||
),
|
),
|
||||||
"channel": next(
|
"channel": next(
|
||||||
(
|
(
|
||||||
@@ -1674,7 +1864,6 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def _legacy_auth_snapshot(self) -> Dict[str, Any]:
|
def _legacy_auth_snapshot(self) -> Dict[str, Any]:
|
||||||
state = load_state()
|
state = load_state()
|
||||||
session_ready = (SESSION_DIR / "session.session").exists()
|
|
||||||
return {
|
return {
|
||||||
"phase": "unknown",
|
"phase": "unknown",
|
||||||
"status": "unknown",
|
"status": "unknown",
|
||||||
@@ -1703,6 +1892,7 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
|||||||
"auth": auth_info,
|
"auth": auth_info,
|
||||||
"status": auth_info.get("status", "unknown"),
|
"status": auth_info.get("status", "unknown"),
|
||||||
"continuous_running": cs_info.get("status", {}).get("running", False),
|
"continuous_running": cs_info.get("status", {}).get("running", False),
|
||||||
|
"health": account_health_summary(account_id, self.app.job_runner),
|
||||||
})
|
})
|
||||||
return self.send_json({"accounts": accounts})
|
return self.send_json({"accounts": accounts})
|
||||||
|
|
||||||
@@ -1719,10 +1909,19 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
|||||||
return self._handle_get_account_dashboard(account_id)
|
return self._handle_get_account_dashboard(account_id)
|
||||||
if sub == ["auth"]:
|
if sub == ["auth"]:
|
||||||
return self.send_json(self.app.auth_manager.auth_state(account_id))
|
return self.send_json(self.app.auth_manager.auth_state(account_id))
|
||||||
|
if sub == ["health"]:
|
||||||
|
return self.send_json(account_health_summary(account_id, self.app.job_runner))
|
||||||
|
if sub == ["export"]:
|
||||||
|
payload = {
|
||||||
|
"version": 1,
|
||||||
|
"account_id": account_id,
|
||||||
|
"state": load_account(DATA_DIR, account_id),
|
||||||
|
}
|
||||||
|
return self.send_json(payload)
|
||||||
if sub == ["channels"]:
|
if sub == ["channels"]:
|
||||||
return self._handle_get_account_channels(account_id)
|
return self._handle_get_account_channels(account_id)
|
||||||
if len(sub) >= 3 and sub[0] == "channels" and sub[-1] == "messages":
|
if len(sub) >= 3 and sub[0] == "channels" and sub[-1] == "messages":
|
||||||
channel_id = sub[1]
|
channel_id = urllib.parse.unquote(sub[1]).lstrip("@")
|
||||||
return self._handle_get_account_channel_messages(account_id, channel_id, query)
|
return self._handle_get_account_channel_messages(account_id, channel_id, query)
|
||||||
if sub == ["continuous"]:
|
if sub == ["continuous"]:
|
||||||
return self.send_json(
|
return self.send_json(
|
||||||
@@ -1744,9 +1943,37 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
|||||||
"jobs": self.app.job_runner.recent_jobs(account_id=account_id),
|
"jobs": self.app.job_runner.recent_jobs(account_id=account_id),
|
||||||
"continuous": cs_info,
|
"continuous": cs_info,
|
||||||
"scrape_media": bool(acc_state.get("scrape_media", True)),
|
"scrape_media": bool(acc_state.get("scrape_media", True)),
|
||||||
|
"health": account_health_summary(account_id, self.app.job_runner),
|
||||||
}
|
}
|
||||||
return self.send_json(payload)
|
return self.send_json(payload)
|
||||||
|
|
||||||
|
def stream_job_events(self, job_id: str) -> None:
|
||||||
|
if not self.app.job_runner.get_job(job_id):
|
||||||
|
return self.send_error_json(HTTPStatus.NOT_FOUND, "Job not found")
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
|
self.send_header("Cache-Control", "no-cache")
|
||||||
|
self.send_header("Connection", "keep-alive")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
last_payload = None
|
||||||
|
deadline = time.time() + 60 * 30
|
||||||
|
while time.time() < deadline:
|
||||||
|
job = self.app.job_runner.get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
break
|
||||||
|
payload = json.dumps(job, ensure_ascii=False)
|
||||||
|
if payload != last_payload:
|
||||||
|
try:
|
||||||
|
self.wfile.write(f"data: {payload}\n\n".encode("utf-8"))
|
||||||
|
self.wfile.flush()
|
||||||
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||||
|
break
|
||||||
|
last_payload = payload
|
||||||
|
if job.get("status") in {"completed", "failed"}:
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
def _handle_get_account_channels(self, account_id: str) -> None:
|
def _handle_get_account_channels(self, account_id: str) -> None:
|
||||||
return self.send_json(list_channels_snapshot(account_id))
|
return self.send_json(list_channels_snapshot(account_id))
|
||||||
|
|
||||||
@@ -1756,10 +1983,12 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
|||||||
limit = max(1, min(int(query.get("limit", ["120"])[0]), 300))
|
limit = max(1, min(int(query.get("limit", ["120"])[0]), 300))
|
||||||
before = query.get("before")
|
before = query.get("before")
|
||||||
before_message_id = int(before[0]) if before else None
|
before_message_id = int(before[0]) if before else None
|
||||||
|
search = (query.get("search") or query.get("q") or [""])[0].strip()
|
||||||
payload = {
|
payload = {
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
"messages": load_messages(
|
"messages": load_messages(
|
||||||
account_id, channel_id, limit=limit, before_message_id=before_message_id
|
account_id, channel_id, limit=limit, before_message_id=before_message_id
|
||||||
|
, search=search or None
|
||||||
),
|
),
|
||||||
"channel": next(
|
"channel": next(
|
||||||
(
|
(
|
||||||
@@ -1780,6 +2009,8 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
if path == "/":
|
if path == "/":
|
||||||
return self.serve_file(WEBUI_DIR / "index.html", "text/html; charset=utf-8", head_only=True)
|
return self.serve_file(WEBUI_DIR / "index.html", "text/html; charset=utf-8", head_only=True)
|
||||||
|
if path == "/settings":
|
||||||
|
return self.serve_file(WEBUI_DIR / "settings.html", "text/html; charset=utf-8", head_only=True)
|
||||||
if path == "/viewer":
|
if path == "/viewer":
|
||||||
return self.serve_file(WEBUI_DIR / "viewer.html", "text/html; charset=utf-8", head_only=True)
|
return self.serve_file(WEBUI_DIR / "viewer.html", "text/html; charset=utf-8", head_only=True)
|
||||||
if path in {"/swagger", "/swigger", "/docs"}:
|
if path in {"/swagger", "/swigger", "/docs"}:
|
||||||
@@ -1997,6 +2228,8 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
|||||||
# ── /api/accounts/* POST routes ─────────────────────────────────
|
# ── /api/accounts/* POST routes ─────────────────────────────────
|
||||||
if path == "/api/accounts":
|
if path == "/api/accounts":
|
||||||
return self._handle_post_accounts_create(body)
|
return self._handle_post_accounts_create(body)
|
||||||
|
if path == "/api/accounts/import":
|
||||||
|
return self._handle_post_accounts_import(body)
|
||||||
if path.startswith("/api/accounts/"):
|
if path.startswith("/api/accounts/"):
|
||||||
return self._handle_post_account(path, body)
|
return self._handle_post_account(path, body)
|
||||||
|
|
||||||
@@ -2034,14 +2267,53 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
|||||||
"scrape_media": True,
|
"scrape_media": True,
|
||||||
"forwarding_rules": [],
|
"forwarding_rules": [],
|
||||||
"continuous_scraping": {
|
"continuous_scraping": {
|
||||||
"enabled": True,
|
"enabled": False,
|
||||||
"interval_minutes": 1,
|
"interval_minutes": 1,
|
||||||
"channels": [],
|
"channels": [],
|
||||||
"run_all_tracked": True,
|
"run_all_tracked": True,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
store.save(init_state)
|
store.save(init_state)
|
||||||
self.app.continuous_orchestrator.add_account(account_id)
|
self.app.continuous_orchestrator.add_account(account_id, auto_start=False)
|
||||||
|
return self.send_json({"ok": True, "account_id": account_id})
|
||||||
|
|
||||||
|
def _handle_post_accounts_import(self, body: Dict[str, Any]) -> None:
|
||||||
|
account_id = str(body.get("account_id") or body.get("id") or "").strip()
|
||||||
|
state = body.get("state")
|
||||||
|
if not account_id:
|
||||||
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "account_id is required")
|
||||||
|
if not account_id.replace("-", "").replace("_", "").isalnum():
|
||||||
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "account_id must be alphanumeric (dashes and underscores allowed)")
|
||||||
|
if account_exists(DATA_DIR, account_id):
|
||||||
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, f"Account '{account_id}' already exists")
|
||||||
|
if not isinstance(state, dict):
|
||||||
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "state object is required")
|
||||||
|
|
||||||
|
imported_state = {
|
||||||
|
"label": str(body.get("label") or state.get("label") or account_id).strip(),
|
||||||
|
"api_id": state.get("api_id"),
|
||||||
|
"api_hash": state.get("api_hash"),
|
||||||
|
"channels": state.get("channels") if isinstance(state.get("channels"), dict) else {},
|
||||||
|
"channel_names": state.get("channel_names") if isinstance(state.get("channel_names"), dict) else {},
|
||||||
|
"scrape_media": bool(state.get("scrape_media", True)),
|
||||||
|
"forwarding_rules": state.get("forwarding_rules") if isinstance(state.get("forwarding_rules"), list) else [],
|
||||||
|
"continuous_scraping": state.get("continuous_scraping") if isinstance(state.get("continuous_scraping"), dict) else {
|
||||||
|
"enabled": False,
|
||||||
|
"interval_minutes": 1,
|
||||||
|
"channels": [],
|
||||||
|
"run_all_tracked": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def mutate_global(global_state: Dict[str, Any]) -> None:
|
||||||
|
accounts = global_state.setdefault("accounts", [])
|
||||||
|
if account_id not in accounts:
|
||||||
|
accounts.append(account_id)
|
||||||
|
|
||||||
|
get_global_store(DATA_DIR).update(mutate_global)
|
||||||
|
get_account_store(DATA_DIR, account_id).save(imported_state)
|
||||||
|
self.app.continuous_orchestrator.add_account(account_id, auto_start=False)
|
||||||
|
self.app.continuous_orchestrator.refresh_account(account_id)
|
||||||
return self.send_json({"ok": True, "account_id": account_id})
|
return self.send_json({"ok": True, "account_id": account_id})
|
||||||
|
|
||||||
def _handle_post_account(self, path: str, body: Dict[str, Any]) -> None:
|
def _handle_post_account(self, path: str, body: Dict[str, Any]) -> None:
|
||||||
@@ -2141,6 +2413,7 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
|||||||
return self.send_json(payload)
|
return self.send_json(payload)
|
||||||
|
|
||||||
def _handle_account_channel_add(self, account_id: str, channel_id: str, name: Optional[str]) -> None:
|
def _handle_account_channel_add(self, account_id: str, channel_id: str, name: Optional[str]) -> None:
|
||||||
|
channel_id = channel_id.lstrip("@")
|
||||||
if not channel_id:
|
if not channel_id:
|
||||||
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
|
||||||
store = get_account_store(DATA_DIR, account_id)
|
store = get_account_store(DATA_DIR, account_id)
|
||||||
@@ -2150,6 +2423,7 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
|||||||
if name:
|
if name:
|
||||||
state.setdefault("channel_names", {})[channel_id] = str(name).strip()
|
state.setdefault("channel_names", {})[channel_id] = str(name).strip()
|
||||||
store.update(mutate)
|
store.update(mutate)
|
||||||
|
self.app.continuous_orchestrator.refresh_account(account_id)
|
||||||
return self.send_json({"ok": True, "channel_id": channel_id})
|
return self.send_json({"ok": True, "channel_id": channel_id})
|
||||||
|
|
||||||
def _handle_account_channel_remove(self, account_id: str, channel_id: str) -> None:
|
def _handle_account_channel_remove(self, account_id: str, channel_id: str) -> None:
|
||||||
@@ -2164,6 +2438,7 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
|||||||
del chans[channel_id]
|
del chans[channel_id]
|
||||||
state.get("channel_names", {}).pop(channel_id, None)
|
state.get("channel_names", {}).pop(channel_id, None)
|
||||||
store.update(mutate)
|
store.update(mutate)
|
||||||
|
self.app.continuous_orchestrator.refresh_account(account_id)
|
||||||
return self.send_json({"ok": existed[0], "channel_id": channel_id})
|
return self.send_json({"ok": existed[0], "channel_id": channel_id})
|
||||||
|
|
||||||
def _handle_account_job_scrape(self, account_id: str, body: Dict[str, Any]) -> None:
|
def _handle_account_job_scrape(self, account_id: str, body: Dict[str, Any]) -> None:
|
||||||
@@ -2300,9 +2575,16 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
|||||||
if acc_dir.exists():
|
if acc_dir.exists():
|
||||||
import shutil
|
import shutil
|
||||||
shutil.rmtree(str(acc_dir), ignore_errors=True)
|
shutil.rmtree(str(acc_dir), ignore_errors=True)
|
||||||
if session_file.exists():
|
for session_sidecar in (
|
||||||
|
session_file,
|
||||||
|
Path(str(session_file) + "-wal"),
|
||||||
|
Path(str(session_file) + "-shm"),
|
||||||
|
session_file.with_suffix(session_file.suffix + "-journal"),
|
||||||
|
):
|
||||||
|
if not session_sidecar.exists():
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
session_file.unlink()
|
session_sidecar.unlink()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user