diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..a2d2619 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,169 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: "Release version" + required: true + default: "v0.3.0" + + publish_pypi: + description: "Publish package to PyPI" + type: boolean + required: true + default: true + +permissions: + contents: write + id-token: write + +jobs: + verify: + name: Verify + runs-on: ubuntu-latest + + strategy: + fail-fast: false + + matrix: + python: + - "3.10" + - "3.11" + - "3.12" + - "3.13" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + + pip install \ + build \ + twine \ + hatchling + + pip install -e ".[dev]" + + - name: Ruff + run: ruff check . + + - name: Black + run: black --check . + + - name: Pyright + run: pyright . + + - name: Pytest + run: | + pytest tests/ \ + -v \ + --tb=short + + release: + name: Release + needs: verify + runs-on: ubuntu-latest + + environment: + name: pypi + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + + pip install \ + build \ + twine \ + hatchling + + - name: Build package + run: python -m build + + - name: Verify package + run: twine check dist/* + + - name: Generate release notes + id: notes + run: | + PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + + if [ -z "$PREV_TAG" ]; then + LOG=$(git log --pretty=format:"- %s") + else + LOG=$(git log ${PREV_TAG}..HEAD --pretty=format:"- %s") + fi + + { + echo 'notes<> "$GITHUB_OUTPUT" + + - name: Create tag + run: | + TAG="${{ github.event.inputs.version }}" + + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag already exists" + exit 1 + fi + + git tag "$TAG" + git push origin "$TAG" + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.version }} + name: Accxus ${{ github.event.inputs.version }} + body: ${{ steps.notes.outputs.notes }} + generate_release_notes: true + + files: | + dist/* + + - name: Publish to PyPI + if: github.event.inputs.publish_pypi == 'true' + uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file diff --git a/.github/workflows/pr-summary.yml b/.github/workflows/pr-summary.yml new file mode 100644 index 0000000..eaf1cac --- /dev/null +++ b/.github/workflows/pr-summary.yml @@ -0,0 +1,474 @@ +name: PR Summary + +on: + pull_request: + types: + - opened + - synchronize + - reopened + +permissions: + pull-requests: write + contents: write + +jobs: + pr-summary: + runs-on: ubuntu-latest + + steps: + - name: Detect fork + id: detect + run: | + if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.event.pull_request.base.repo.full_name }}" ]; then + echo "is_fork=true" >> "$GITHUB_OUTPUT" + else + echo "is_fork=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate PR summary + id: summary + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + + const commits = await github.paginate( + github.rest.pulls.listCommits, + { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100 + } + ); + + const files = await github.paginate( + github.rest.pulls.listFiles, + { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100 + } + ); + + const groups = { + feat: [], + fix: [], + refactor: [], + perf: [], + docs: [], + test: [], + chore: [], + ci: [], + style: [], + build: [], + revert: [], + other: [] + }; + + const titles = { + feat: "✨ Features", + fix: "πŸ› Fixes", + refactor: "♻️ Refactoring", + perf: "⚑ Performance", + docs: "πŸ“ Documentation", + test: "πŸ§ͺ Tests", + chore: "πŸ”§ Chores", + ci: "πŸš€ CI", + style: "🎨 Style", + build: "πŸ“¦ Build", + revert: "βͺ Reverts", + other: "πŸ“Œ Other" + }; + + let additions = 0; + let deletions = 0; + + const contributors = new Map(); + const scopeStats = new Map(); + const dirStats = new Map(); + + for (const file of files) { + additions += file.additions; + deletions += file.deletions; + + const dir = file.filename.includes("/") + ? file.filename.split("/")[0] + : "root"; + + dirStats.set(dir, (dirStats.get(dir) || 0) + 1); + } + + for (const commit of commits) { + const sha = commit.sha.substring(0, 7); + const url = commit.html_url; + + const author = + commit.author?.login || + commit.commit.author.name; + + contributors.set(author, (contributors.get(author) || 0) + 1); + + const message = commit.commit.message.split("\n")[0]; + + const match = message.match( + /^(\w+)(\((.*?)\))?:\s(.+)$/ + ); + + let type = "other"; + let scope = ""; + let description = message; + + if (match) { + type = match[1]; + scope = match[3] || ""; + description = match[4]; + } + + if (!groups[type]) { + type = "other"; + } + + if (scope) { + scopeStats.set(scope, (scopeStats.get(scope) || 0) + 1); + } + + groups[type].push({ + sha, + url, + scope, + description, + author + }); + } + + const topFiles = [...files] + .sort((a, b) => b.changes - a.changes) + .slice(0, 10); + + const topScopes = [...scopeStats.entries()] + .sort((a, b) => b[1] - a[1]); + + const topDirs = [...dirStats.entries()] + .sort((a, b) => b[1] - a[1]); + + function progress(value, total) { + const width = 20; + const filled = Math.round((value / total) * width); + + return ( + "β–ˆ".repeat(filled) + + "β–‘".repeat(width - filled) + ); + } + + const totalTypedCommits = Object.values(groups) + .reduce((acc, arr) => acc + arr.length, 0); + + let body = ""; + + body += `\n`; + body += `# πŸ“‹ PR Summary\n\n`; + body += `### ${pr.title}\n\n`; + body += `> ${pr.user.login} opened a pull request from \`${pr.head.ref}\` β†’ \`${pr.base.ref}\`\n\n`; + + body += `---\n\n`; + body += `## πŸ“Š Overview\n\n`; + body += `| Metric | Value |\n`; + body += `|---|---|\n`; + body += `| Commits | \`${commits.length}\` |\n`; + body += `| Changed Files | \`${files.length}\` |\n`; + body += `| Additions | \`+${additions}\` |\n`; + body += `| Deletions | \`-${deletions}\` |\n`; + body += `| Contributors | \`${contributors.size}\` |\n\n`; + + body += `---\n\n`; + body += `## πŸ“ˆ Change Distribution\n\n`; + + for (const [type, items] of Object.entries(groups)) { + if (!items.length) continue; + + const bar = progress( + items.length, + totalTypedCommits + ); + + body += `- ${titles[type]} \`${bar}\` ${items.length}\n`; + } + + body += `\n---\n\n`; + + for (const [type, items] of Object.entries(groups)) { + if (!items.length) continue; + + body += `## ${titles[type]}\n\n`; + body += `
\n`; + body += `${items.length} commits\n\n`; + + for (const item of items) { + const scope = item.scope + ? `\`${item.scope}\` ` + : ""; + + body += `- [\`${item.sha}\`](${item.url}) ${scope}${item.description} β€” @${item.author}\n`; + } + + body += `\n
\n\n`; + } + + body += `---\n\n`; + body += `## 🎯 Main Impact Areas\n\n`; + + for (const [scope, count] of topScopes.slice(0, 8)) { + body += `- \`${scope}\` β€” ${count} commits\n`; + } + + body += `\n---\n\n`; + body += `## πŸ“‚ Most Changed Files\n\n`; + body += "```diff\n"; + + for (const file of topFiles) { + body += `+ ${String(file.additions).padEnd(4)}`; + body += `- ${String(file.deletions).padEnd(4)}`; + body += `${file.filename}\n`; + } + + body += "```\n\n"; + + body += `---\n\n`; + body += `## 🧩 Changed Directories\n\n`; + + for (const [dir, count] of topDirs.slice(0, 10)) { + body += `- \`${dir}/\` β€” ${count} files\n`; + } + + body += `\n---\n\n`; + body += `## ⚠️ High Impact Files\n\n`; + + const risky = files + .filter(file => file.changes > 200) + .sort((a, b) => b.changes - a.changes); + + if (risky.length) { + for (const file of risky) { + body += `- \`${file.filename}\` (+${file.additions} / -${file.deletions})\n`; + } + } else { + body += `No high impact files detected.\n`; + } + + body += `\n---\n\n`; + body += `## πŸ‘₯ Contributors\n\n`; + + for (const [user, count] of contributors.entries()) { + body += `- @${user} β€” ${count} commits\n`; + } + + body += `\n---\n\n`; + body += `## πŸ”Ž Raw Commit Messages\n\n`; + body += `
\n`; + body += `Show raw commits\n\n`; + body += "```text\n"; + + for (const commit of commits) { + body += `${commit.commit.message}\n\n`; + } + + body += "```\n"; + body += "
\n\n"; + body += "---\n\n"; + body += "Generated automatically from PR metadata."; + + core.setOutput("body", body); + + const now = new Date(); + const dateStr = now.toISOString().split("T")[0]; + + const version = pr.head.ref.replace( + /[^a-zA-Z0-9.-]/g, + "-" + ); + + const sectionTitles = { + feat: "Added", + fix: "Fixed", + refactor: "Changed", + perf: "Changed", + docs: "Changed", + test: "Changed", + chore: "Changed", + ci: "Changed", + style: "Changed", + build: "Changed", + revert: "Removed", + other: "Changed" + }; + + let changelogEntry = ""; + + for (const [type, items] of Object.entries(groups)) { + if (!items.length) continue; + + changelogEntry += `### ${sectionTitles[type]}\n\n`; + + for (const item of items) { + const scope = item.scope + ? `**${item.scope}:** ` + : ""; + + changelogEntry += `- ${scope}${item.description} ([\`${item.sha}\`](${item.url}))\n`; + } + + changelogEntry += "\n"; + } + + core.setOutput("changelog", changelogEntry); + core.setOutput("version", version); + core.setOutput("date", dateStr); + + - name: Create or update PR comment + uses: actions/github-script@v7 + env: + BODY: ${{ steps.summary.outputs.body }} + with: + script: | + const marker = ""; + + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: + context.payload.pull_request.number + } + ); + + const existing = comments.find(comment => + comment.body.includes(marker) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: process.env.BODY + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: + context.payload.pull_request.number, + body: process.env.BODY + }); + } + + - name: Update CHANGELOG.md + if: steps.detect.outputs.is_fork == 'false' + env: + VERSION: ${{ steps.summary.outputs.version }} + DATE: ${{ steps.summary.outputs.date }} + ENTRY: ${{ steps.summary.outputs.changelog }} + run: | + node <<'EOF' + const fs = require('fs'); + const path = require('path'); + + const changelogPath = path.join( + process.cwd(), + 'CHANGELOG.md' + ); + + const version = process.env.VERSION; + const date = process.env.DATE; + const entry = process.env.ENTRY; + + const header = `# Changelog + + All notable changes to this project will be documented in this file. + + The format is based on Keep a Changelog, + and this project adheres to Semantic Versioning. + + `; + + const newEntry = `## [${version}] - ${date} + + ${entry} + `; + + let content = header; + + if (fs.existsSync(changelogPath)) { + content = fs.readFileSync( + changelogPath, + 'utf8' + ); + + if (!content.startsWith('# Changelog')) { + content = header + content; + } + } + + const lines = content.split('\n'); + + let insertIndex = 0; + + for (let i = 0; i < lines.length; i++) { + if ( + lines[i].includes('Semantic Versioning') + ) { + insertIndex = i + 1; + + while ( + insertIndex < lines.length && + lines[insertIndex].trim() === '' + ) { + insertIndex++; + } + + break; + } + } + + lines.splice(insertIndex, 0, '', newEntry); + + fs.writeFileSync( + changelogPath, + lines.join('\n') + ); + + console.log('CHANGELOG.md updated'); + EOF + + - name: Commit CHANGELOG.md + if: steps.detect.outputs.is_fork == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + git remote set-url origin \ + https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git + + git add CHANGELOG.md + + if git diff --cached --quiet; then + exit 0 + fi + + git commit -m \ + "docs: update CHANGELOG.md for PR #${{ github.event.pull_request.number }}" + + git push \ + origin HEAD:${{ github.event.pull_request.head.ref }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..88228dc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on Keep a Changelog, +and this project adheres to Semantic Versioning. + + + +## [dev] - 2026-05-15 + +### Added + +- **parsing:** support bulk telegram exports ([`6b0b2c6`](https://github.com/reekeer/accxus/commit/6b0b2c61658a82a1bdf34fcce9a37ce347855bad)) +- **ui:** add selective parsing controls ([`671d44b`](https://github.com/reekeer/accxus/commit/671d44b6f5790329c2859db54e90a3e3cc099008)) +- **parsing:** enrich telegram exports ([`4c7f328`](https://github.com/reekeer/accxus/commit/4c7f3281df645baf383db1975903c4791ab67168)) +- **ui:** expose media export controls ([`81ec97d`](https://github.com/reekeer/accxus/commit/81ec97d12e61274d9759c02540ffd65690929b87)) +- **ui:** select fetched parsing chats ([`550b725`](https://github.com/reekeer/accxus/commit/550b7256b4d3be47576f39605260f62fc6dd310f)) +- **sessions:** cache telegram dc metadata ([`f61e7f1`](https://github.com/reekeer/accxus/commit/f61e7f1bd36e3722459b1dc2b063a1dd190bba7e)) +- **ui:** show telegram session dc ([`a586ca3`](https://github.com/reekeer/accxus/commit/a586ca35000de87d46abf1fa107c94b9994ef71d)) +- **parsing:** enhance gift parsing and add message export cooldown ([`9e28a30`](https://github.com/reekeer/accxus/commit/9e28a3046ef455c998897264fd54decd20c7478c)) +- **ui:** add sender filtering to chat export ([`a50b57a`](https://github.com/reekeer/accxus/commit/a50b57a58637629006d74c604ddc30b3405b34ca)) +- enhanced parsing (#3) ([`77a9732`](https://github.com/reekeer/accxus/commit/77a97328d2e62d54528bc95df5e01fb1ce3465b3)) + +### Fixed + +- **ui:** render parsing tab content ([`ee39740`](https://github.com/reekeer/accxus/commit/ee39740161017cf788b710284f800e87c15b16f3)) +- **ui:** show parsing chat table ([`2237232`](https://github.com/reekeer/accxus/commit/2237232dc75e69a620a584b14b98d78ee7899231)) +- **ui:** stack parsing chat controls ([`ee000c2`](https://github.com/reekeer/accxus/commit/ee000c223f4b0424459d27ef312ae04139773686)) +- **parsing:** resolve unread telegram chats ([`a92cdfa`](https://github.com/reekeer/accxus/commit/a92cdfaa9622e3a6ece60ecf33de98fbc6645819)) +- **parsing:** use absolute paths for downloads and fix type issues ([`eac1efa`](https://github.com/reekeer/accxus/commit/eac1efaca1198a5de7375bf2f52ba72018180bd3)) + +### Changed + +- bump version to 0.3.0 ([`389e9f7`](https://github.com/reekeer/accxus/commit/389e9f723355498108f67e1d98801cd50d720cab)) +- version 0.4.0 ([`5fe6880`](https://github.com/reekeer/accxus/commit/5fe688069e8b5a011c7354c0b26c6ef3882d2c9f)) +- **pyproject:** remove rigi from pyproject ([`1de620b`](https://github.com/reekeer/accxus/commit/1de620be4b1d53c2e541006446e17af84be62c4b)) + +### Changed + +- add pr summary ([`f752c48`](https://github.com/reekeer/accxus/commit/f752c48ee06b0076c43415bc097625217511117d)) +- add markdown in PR Summary ([`de9b37c`](https://github.com/reekeer/accxus/commit/de9b37c56b242d37b2f2e476de8c50e3b858a213)) +- **summary:** Add changelog to summary ([`8183e5a`](https://github.com/reekeer/accxus/commit/8183e5a820dfd8ee9ded3c9569bbfb4f083ecce2)) +- **build:** add build workflow ([`11ee08e`](https://github.com/reekeer/accxus/commit/11ee08e5ce5b07e9b87078d1046e0bff8ff0fe61)) + + diff --git a/pyproject.toml b/pyproject.toml index e1b346a..b84b0b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "accxus" -version = "0.1.0" +version = "0.4.0" description = "accxus is a program where you can create, manage, and modify accounts on various social networks. It uses SMS activation services for registration." readme = "README.md" requires-python = ">=3.10" @@ -18,7 +18,6 @@ maintainers = [ { name = "xeltorV" }, ] dependencies = [ - "rigi @ git+https://github.com/reekeer/Rigi.git", "pyrogram==2.0.106", "TgCrypto", "aiohttp>=3.9", diff --git a/src/accxus/__init__.py b/src/accxus/__init__.py index d3ec452..6a9beea 100644 --- a/src/accxus/__init__.py +++ b/src/accxus/__init__.py @@ -1 +1 @@ -__version__ = "0.2.0" +__version__ = "0.4.0" diff --git a/src/accxus/core/sms/base.py b/src/accxus/core/sms/base.py index 3900be9..7c288ce 100644 --- a/src/accxus/core/sms/base.py +++ b/src/accxus/core/sms/base.py @@ -65,6 +65,10 @@ async def confirm(self, activation_id: str) -> bool: ... async def list_services(self, country: int = 0) -> list[ServiceInfo]: return [] + async def list_countries_for_service(self, service: str) -> list[tuple[int, str, float]]: + """Return list of (country_id, country_name, price) for a given service.""" + return [] + async def wait_for_code( self, activation_id: str, diff --git a/src/accxus/core/sms/manager.py b/src/accxus/core/sms/manager.py index a7be4a9..1ed6ecf 100644 --- a/src/accxus/core/sms/manager.py +++ b/src/accxus/core/sms/manager.py @@ -127,6 +127,25 @@ async def _one(p: AbstractSmsProvider) -> tuple[str, list[ServiceInfo]]: pairs = await asyncio.gather(*(_one(p) for p in targets)) return dict(pairs) + async def list_countries_for_service( + self, service: str, provider: str | None = None + ) -> dict[str, list[tuple[int, str, float]]]: + import asyncio + + targets = ( + [p for p in self._providers if p.name == provider] if provider else self._providers + ) + + async def _one(p: AbstractSmsProvider) -> tuple[str, list[tuple[int, str, float]]]: + try: + return p.name, await p.list_countries_for_service(service) + except Exception as e: + log.warning(f"[{p.name}] list_countries_for_service failed: {e}") + return p.name, [] + + pairs = await asyncio.gather(*(_one(p) for p in targets)) + return dict(pairs) + def _find(self, name: str) -> AbstractSmsProvider: for p in self._providers: if p.name == name: diff --git a/src/accxus/core/sms/providers/sms_activate.py b/src/accxus/core/sms/providers/sms_activate.py index 37c13f5..a36b8b9 100644 --- a/src/accxus/core/sms/providers/sms_activate.py +++ b/src/accxus/core/sms/providers/sms_activate.py @@ -107,3 +107,26 @@ async def list_services(self, country: int = 0) -> list[ServiceInfo]: except Exception as e: log.warning(f"[sms_activate] list_services failed: {e}") return [] + + async def list_countries_for_service(self, service: str) -> list[tuple[int, str, float]]: + try: + text = await self._get(action="getPrices", service=service) + data: dict[str, dict[str, Any]] = json.loads(text) + prices: dict[int, float] = {} + for country_str, info in data.get(service, {}).items(): + if int(info.get("count", 0)) > 0: + prices[int(country_str)] = float(info.get("cost", 0)) + if not prices: + return [] + names_text = await self._get(action="getCountries") + names_data: list[dict[str, Any]] = json.loads(names_text) + out: list[tuple[int, str, float]] = [] + for item in names_data: + cid = int(item.get("id", 0)) + if cid in prices: + name = item.get("name", f"Country {cid}") + out.append((cid, name, prices[cid])) + return out + except Exception as e: + log.warning(f"[sms_activate] list_countries_for_service failed: {e}") + return [] diff --git a/src/accxus/platforms/telegram/client.py b/src/accxus/platforms/telegram/client.py index 0f99e17..2803241 100644 --- a/src/accxus/platforms/telegram/client.py +++ b/src/accxus/platforms/telegram/client.py @@ -10,6 +10,7 @@ from pyrogram import Client # type: ignore[import-untyped] import accxus.config as cfg +from accxus.platforms.telegram import sessions as tg_sessions from accxus.types.core import ProxyConfig from accxus.types.telegram import SessionInfo, SessionStatus from accxus.utils.session_convert import detect_kind @@ -27,6 +28,10 @@ def make_client( ) -> Client: from pyrogram import Client as _Client # type: ignore[import-untyped] + dc_id = tg_sessions.hydrate_session_dc_metadata(session_name) + if dc_id is not None: + log.debug("[tg] session %s uses dc_id=%s", session_name, dc_id) + _proxy = proxy or cfg.config.telegram_proxy return _Client( # type: ignore[reportCallIssue] name=session_name, @@ -68,6 +73,7 @@ async def fetch_info( ) -> SessionInfo: async with connected(session_name, proxy=proxy) as client: me = await client.get_me() + dc_id = await client.storage.dc_id() try: chat = await client.get_chat(me.id) bio: str = getattr(chat, "bio", "") or "" @@ -82,6 +88,8 @@ async def fetch_info( last_name=me.last_name or "", username=me.username or "", bio=bio, + user_id=me.id, + dc_id=dc_id, kind=kind, status=SessionStatus.VALID, ) @@ -100,7 +108,10 @@ async def check_validity( try: async with connected(session_name, proxy=proxy) as client: me = await client.get_me() - return SessionStatus.VALID if me else SessionStatus.INVALID + if me: + tg_sessions.update_metadata_dc_id(session_name, await client.storage.dc_id()) + return SessionStatus.VALID + return SessionStatus.INVALID except (AuthKeyUnregistered, UserDeactivated, UserDeactivatedBan): return SessionStatus.INVALID except Exception: diff --git a/src/accxus/platforms/telegram/parsing.py b/src/accxus/platforms/telegram/parsing.py index 1b8341e..bad25f8 100644 --- a/src/accxus/platforms/telegram/parsing.py +++ b/src/accxus/platforms/telegram/parsing.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio +import contextlib import json import logging from collections.abc import Callable @@ -11,40 +13,424 @@ log = logging.getLogger(__name__) +ChatRef = int | str + + +def _clean_filename(value: str) -> str: + cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_", ".") else "_" for ch in value) + return cleaned.strip("._") or "chat" + + +def _chat_ref(chat: dict[str, Any]) -> str: + username = str(chat.get("username") or "").strip() + if username: + return f"@{username}" + return str(chat["id"]) + + +def _normalize_chat_ref(chat: ChatRef) -> ChatRef: + if isinstance(chat, int): + return chat + value = chat.strip() + if value.lstrip("-").isdigit(): + return int(value) + return value + + +async def _resolve_chat_ref(client: Any, chat: ChatRef) -> ChatRef: + ref = _normalize_chat_ref(chat) + with contextlib.suppress(Exception): + resolved = await client.get_chat(ref) + return resolved.id + + wanted_id = ref if isinstance(ref, int) else None + wanted_text = str(ref).lstrip("@").lower() if isinstance(ref, str) else "" + async for dialog in client.get_dialogs(limit=0): # type: ignore[reportGeneralTypeIssues] + dialog_chat = dialog.chat + if wanted_id is not None and dialog_chat.id == wanted_id: + return dialog_chat.id + username = (getattr(dialog_chat, "username", "") or "").lower() + title = (getattr(dialog_chat, "title", "") or "").lower() + if wanted_text and wanted_text in {username, title}: + return dialog_chat.id + + return ref + + +def _format_optional(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + if hasattr(value, "isoformat"): + return str(value.isoformat()) + return str(value) + + +def _enum_value(value: Any) -> str: + if value is None: + return "" + enum_value = getattr(value, "value", None) + if isinstance(enum_value, str): + return enum_value + enum_name = getattr(value, "name", None) + if isinstance(enum_name, str): + return enum_name.lower() + return str(value) + + +def _serializable_value(value: Any, depth: int = 0) -> Any: + if value is None or isinstance(value, str | int | float | bool): + return value + if depth > 2: + return _format_optional(value) + if isinstance(value, list | tuple | set): + return [_serializable_value(item, depth + 1) for item in value] + if isinstance(value, dict): + return { + str(key): _serializable_value(item, depth + 1) + for key, item in value.items() + if not str(key).startswith("_") + } + if hasattr(value, "isoformat"): + return value.isoformat() + if hasattr(value, "value"): + return _enum_value(value) + data = getattr(value, "__dict__", None) + if isinstance(data, dict): + return { + key: _serializable_value(item, depth + 1) + for key, item in data.items() + if not key.startswith("_") and key != "_client" + } + return _format_optional(value) + + +def _normalize_gift(gift: Any) -> dict[str, Any]: + if gift is None: + return {} + + data = _serializable_value(gift) + if not isinstance(data, dict): + data = {"value": data} + + from_id = ( + data.get("from") + or data.get("from_id") + or data.get("sender_id") + or data.get("user_id") + or data.get("peer_id") + or "" + ) + gift_type = data.get("type") or data.get("_") or data.get("title") or type(gift).__name__ + date = data.get("date") or data.get("timestamp") or "" + + res = { + "from": from_id, + "type": gift_type, + "date": date, + "price": 0, + "currency": "stars", + "status": "common", + "message": data.get("message") or "", + } + + # Enhanced parsing for Star Gifts + if hasattr(gift, "gift"): # UserStarGift + g = gift.gift + res["price"] = getattr(g, "stars", 0) + if getattr(g, "limited_count", 0) > 0: + res["status"] = "rare" + if getattr(gift, "upgraded", False): + res["status"] = "upgraded" + if getattr(gift, "upgrade_tag", None): + res["status"] += f" ({gift.upgrade_tag})" + + # Premium Gift parsing + if res["type"] == "PremiumGiftOption": + res["price"] = data.get("amount", 0) + res["currency"] = data.get("currency", "USD") + res["status"] = "premium" + + # Merge remaining fields + for key, value in data.items(): + if key not in res and key not in {"from_id", "sender_id", "user_id", "peer_id"}: + res[key] = value + + return res + + +def _normalize_gifts(values: Any) -> list[dict[str, Any]]: + if not values: + return [] + if not isinstance(values, list | tuple): + values = [values] + return [_normalize_gift(value) for value in values if value is not None] + + +def _message_sender(msg: Any) -> str: + if getattr(msg, "from_user", None): + user = msg.from_user + return user.username or str(user.id) + if getattr(msg, "sender_chat", None): + chat = msg.sender_chat + return chat.username or chat.title or str(chat.id) + return "" + + +def _message_type(msg: Any) -> str: + if getattr(msg, "service", None): + return "service" + if getattr(msg, "media", None): + return _enum_value(msg.media) + if getattr(msg, "text", None): + return "text" + return "empty" + + +def _user_label(user: Any) -> str: + if user is None: + return "" + username = getattr(user, "username", "") or "" + if username: + return f"@{username}" + name = " ".join( + part for part in [getattr(user, "first_name", ""), getattr(user, "last_name", "")] if part + ) + return name or str(getattr(user, "id", "")) + + +def _service_text(msg: Any) -> str: + service = _enum_value(getattr(msg, "service", None)) + actor = _message_sender(msg) or "system" + if service == "new_chat_members": + members = ", ".join( + _user_label(user) for user in getattr(msg, "new_chat_members", []) or [] + ) + return f"{actor} added {members}".strip() + if service == "left_chat_members": + return f"{_user_label(getattr(msg, 'left_chat_member', None))} left the chat".strip() + if service == "new_chat_title": + return f"{actor} changed chat title to {getattr(msg, 'new_chat_title', '')}" + if service == "new_chat_photo": + return f"{actor} changed chat photo" + if service == "delete_chat_photo": + return f"{actor} deleted chat photo" + if service == "pinned_message": + pinned = getattr(getattr(msg, "pinned_message", None), "id", "") + return f"{actor} pinned message {pinned}".strip() + if service == "video_chat_started": + return f"{actor} started video chat" + if service == "video_chat_ended": + ended = getattr(msg, "video_chat_ended", None) + duration = getattr(ended, "duration", "") + return f"{actor} ended video chat {duration}".strip() + if service == "video_chat_scheduled": + scheduled = getattr(msg, "video_chat_scheduled", None) + start_date = _format_optional(getattr(scheduled, "start_date", "")) + return f"{actor} scheduled video chat {start_date}".strip() + if service == "video_chat_members_invited": + invited = getattr(msg, "video_chat_members_invited", None) + users = ", ".join(_user_label(user) for user in getattr(invited, "users", []) or []) + return f"{actor} invited {users} to video chat".strip() + ttl_period = getattr(msg, "ttl_period", None) or getattr(msg, "message_auto_delete_timer", None) + if ttl_period: + return f"{actor} changed auto-delete timer to {ttl_period}" + return service + + +def _service_details(msg: Any) -> dict[str, Any]: + fields = [ + "new_chat_members", + "left_chat_member", + "new_chat_title", + "delete_chat_photo", + "group_chat_created", + "supergroup_chat_created", + "channel_chat_created", + "migrate_to_chat_id", + "migrate_from_chat_id", + "pinned_message", + "game_high_score", + "video_chat_scheduled", + "video_chat_started", + "video_chat_ended", + "video_chat_members_invited", + "web_app_data", + "ttl_period", + "message_auto_delete_timer", + "message_auto_delete_timer_changed", + ] + details: dict[str, Any] = {} + for field in fields: + value = getattr(msg, field, None) + if value: + details[field] = _serializable_value(value) + return details + + +def _media_suffix(msg: Any) -> str: + media_type = _enum_value(getattr(msg, "media", None)) + media = getattr(msg, media_type, None) if media_type else None + file_name = getattr(media, "file_name", "") or "" + if file_name and Path(file_name).suffix: + return Path(file_name).suffix + mime_type = getattr(media, "mime_type", "") or "" + if mime_type == "application/x-tgsticker": + return ".tgs" + if mime_type == "video/webm": + return ".webm" + if mime_type == "image/webp": + return ".webp" + if media_type == "photo": + return ".jpg" + if media_type == "sticker": + if getattr(media, "is_animated", False): + return ".tgs" + if getattr(media, "is_video", False): + return ".webm" + return ".webp" + if media_type == "animation": + return ".mp4" + return "" + + +async def _download_message_media(client: Any, msg: Any, media_dir: Path | None) -> str: + if media_dir is None or not getattr(msg, "media", None): + return "" + media_dir = media_dir.absolute() + media_dir.mkdir(parents=True, exist_ok=True) + media_type = _enum_value(msg.media) + dest = media_dir / f"{media_type}{msg.id}{_media_suffix(msg)}" + try: + downloaded = await client.download_media(msg, file_name=str(dest)) + return str(downloaded or dest) + except Exception as exc: + log.debug("[parse] media download failed for message %s: %s", msg.id, exc) + return "" + + +def _custom_emoji_ids(msg: Any) -> list[int]: + ids: list[int] = [] + for entity in list(getattr(msg, "entities", []) or []) + list( + getattr(msg, "caption_entities", []) or [] + ): + custom_emoji_id = getattr(entity, "custom_emoji_id", None) + if custom_emoji_id: + ids.append(int(custom_emoji_id)) + return ids + + +async def _download_custom_emojis(client: Any, msg: Any, media_dir: Path | None) -> list[str]: + ids = _custom_emoji_ids(msg) + if media_dir is None or not ids: + return [] + media_dir = media_dir.absolute() + media_dir.mkdir(parents=True, exist_ok=True) + files: list[str] = [] + with contextlib.suppress(Exception): + stickers = await client.get_custom_emoji_stickers(ids) + for sticker in stickers: + suffix = ".tgs" if sticker.is_animated else ".webm" if sticker.is_video else ".webp" + dest = media_dir / f"emoji{sticker.file_unique_id}{suffix}" + try: + downloaded = await client.download_media(sticker.file_id, file_name=str(dest)) + files.append(str(downloaded or dest)) + except Exception as exc: + log.debug("[parse] custom emoji download failed: %s", exc) + return files + + +async def _message_to_dict(client: Any, msg: Any, media_dir: Path | None) -> dict[str, Any]: + msg_type = _message_type(msg) + service = _enum_value(getattr(msg, "service", None)) + media = _enum_value(getattr(msg, "media", None)) + text = msg.text or msg.caption or "" + if service and not text: + text = _service_text(msg) + return { + "id": msg.id, + "date": str(msg.date), + "from": _message_sender(msg), + "type": msg_type, + "service": service, + "media_type": media, + "text": text, + "media_file": await _download_message_media(client, msg, media_dir), + "custom_emoji_files": await _download_custom_emojis(client, msg, media_dir), + "service_details": _service_details(msg) if service else {}, + } + + +async def get_chat_senders( + session_name: str, + chat: ChatRef, + limit: int = 500, +) -> list[dict[str, Any]]: + """Fetch unique senders from chat history to allow filtering.""" + senders: dict[int, dict[str, Any]] = {} + async with connected(session_name) as client: + resolved_chat = await _resolve_chat_ref(client, chat) + # Using type: ignore[attr-defined] if pyright complains about get_chat_history not being a method on Client + async for msg in client.get_chat_history(resolved_chat, limit=limit): # type: ignore[attr-defined] + u = msg.from_user + if u: + if u.id not in senders: + name = f"{u.first_name or ''} {u.last_name or ''}".strip() or str(u.id) + label = f"{name} ({u.id}/@{u.username})" if u.username else f"{name} ({u.id})" + senders[u.id] = {"id": u.id, "label": label, "username": u.username} + elif msg.sender_chat: + c = msg.sender_chat + if c.id not in senders: + name = c.title or str(c.id) + label = f"{name} ({c.id}/@{c.username})" if c.username else f"{name} ({c.id})" + senders[c.id] = {"id": c.id, "label": label, "username": c.username} + await asyncio.sleep(0.02) + return sorted(senders.values(), key=lambda x: x["label"]) + async def export_chat_history( session_name: str, - chat: str, + chat: ChatRef, limit: int = 0, on_progress: Callable[[int], None] | None = None, + media_dir: Path | None = None, + sender_ids: list[int] | None = None, ) -> list[dict[str, Any]]: messages: list[dict[str, Any]] = [] async with connected(session_name) as client: - async for msg in client.get_chat_history(chat, limit=limit or 0): # type: ignore[reportGeneralTypeIssues] - messages.append( - { - "id": msg.id, - "date": str(msg.date), - "from": ( - (msg.from_user.username or str(msg.from_user.id)) if msg.from_user else "" - ), - "text": msg.text or msg.caption or "", - } - ) + resolved_chat = await _resolve_chat_ref(client, chat) + async for msg in client.get_chat_history(resolved_chat, limit=limit or 0): # type: ignore[reportGeneralTypeIssues] + if sender_ids: + sid = (msg.from_user.id if msg.from_user else None) or ( + msg.sender_chat.id if msg.sender_chat else None + ) + if sid not in sender_ids: + continue + + messages.append(await _message_to_dict(client, msg, media_dir)) if on_progress and len(messages) % 100 == 0: on_progress(len(messages)) + + # Cooldown to avoid bans + await asyncio.sleep(0.05) return messages async def save_chat_history( session_name: str, - chat: str, + chat: ChatRef, dest: Path, fmt: str = "json", limit: int = 0, on_progress: Callable[[int], None] | None = None, + media_dir: Path | None = None, + sender_ids: list[int] | None = None, ) -> int: - messages = await export_chat_history(session_name, chat, limit, on_progress) + messages = await export_chat_history( + session_name, chat, limit, on_progress, media_dir, sender_ids + ) + dest.parent.mkdir(parents=True, exist_ok=True) if fmt == "txt": lines = [f"[{m['date']}] {m['from'] or 'unknown'}: {m['text']}" for m in messages] dest.write_text("\n".join(lines), encoding="utf-8") @@ -54,22 +440,186 @@ async def save_chat_history( return len(messages) +async def save_chats_history( + session_name: str, + chats: list[ChatRef], + dest_dir: Path, + fmt: str = "json", + limit: int = 0, + on_progress: Callable[[str, int], None] | None = None, + media_dir: Path | None = None, +) -> dict[str, int]: + dest_dir.mkdir(parents=True, exist_ok=True) + exported: dict[str, int] = {} + + for chat in chats: + chat_key = _clean_filename(str(chat).lstrip("@")) + + def _progress(count: int, chat_ref: ChatRef = chat) -> None: + if on_progress: + on_progress(str(chat_ref), count) + + dest = dest_dir / f"{chat_key}.{fmt}" + chat_media_dir = media_dir / chat_key if media_dir else None + exported[str(chat)] = await save_chat_history( + session_name, + chat, + dest, + fmt=fmt, + limit=limit, + on_progress=_progress, + media_dir=chat_media_dir, + ) + + log.info("[parse] exported %d chat histories to %s", len(exported), dest_dir) + return exported + + +async def save_all_dialog_histories( + session_name: str, + dest_dir: Path, + *, + kind: str = "all", + selected_chats: list[ChatRef] | None = None, + fmt: str = "json", + limit: int = 0, + on_progress: Callable[[str, int], None] | None = None, + media_dir: Path | None = None, +) -> dict[str, int]: + chats = selected_chats or [ + _chat_ref(chat) for chat in await list_dialogs(session_name, kind=kind, limit=0) + ] + return await save_chats_history( + session_name, + chats, + dest_dir, + fmt=fmt, + limit=limit, + on_progress=on_progress, + media_dir=media_dir, + ) + + +async def _download_user_avatar(client: Any, user: Any, avatar_dir: Path | None) -> str: + if avatar_dir is None: + return "" + photo = getattr(user, "photo", None) + file_id = getattr(photo, "big_file_id", "") or getattr(photo, "small_file_id", "") + if not file_id: + return "" + + avatar_dir = avatar_dir.absolute() + avatar_dir.mkdir(parents=True, exist_ok=True) + dest = avatar_dir / f"{user.id}.jpg" + try: + downloaded = await client.download_media(file_id, file_name=str(dest)) + return str(downloaded or dest) + except Exception as exc: + log.debug("[parse] avatar download failed for %s: %s", user.id, exc) + return "" + + +async def _load_user_extras(client: Any, user_id: int) -> dict[str, Any]: + extras: dict[str, Any] = { + "bio": "", + "song": "", + "birthday": "", + "gifts": [], + } + with contextlib.suppress(Exception): + chat = await client.get_chat(user_id) + extras["bio"] = getattr(chat, "bio", "") or getattr(chat, "description", "") or "" + extras["song"] = _format_optional(getattr(chat, "profile_song", "")) + extras["birthday"] = _format_optional(getattr(chat, "birthday", "")) + extras["birthday"] = extras["birthday"] or _format_optional(getattr(chat, "birthdate", "")) + extras["song"] = extras["song"] or _format_optional(getattr(chat, "profile_music", "")) + extras["gifts"] = _normalize_gifts( + getattr(chat, "gifts", None) + or getattr(chat, "received_gifts", None) + or getattr(chat, "premium_gifts", None) + ) + + with contextlib.suppress(Exception): + from pyrogram.raw.functions.users import GetFullUser # type: ignore[import-untyped] + + peer = await client.resolve_peer(user_id) + full = await client.invoke(GetFullUser(id=peer)) + full_user = getattr(full, "full_user", full) + extras["bio"] = extras["bio"] or getattr(full_user, "about", "") or "" + extras["song"] = extras["song"] or _format_optional(getattr(full_user, "profile_song", "")) + extras["birthday"] = extras["birthday"] or _format_optional( + getattr(full_user, "birthday", "") or getattr(full_user, "birthdate", "") + ) + extras["song"] = extras["song"] or _format_optional(getattr(full_user, "profile_music", "")) + extras["gifts"] = extras["gifts"] or _normalize_gifts( + getattr(full_user, "gifts", None) + or getattr(full_user, "received_gifts", None) + or getattr(full_user, "premium_gifts", None) + ) + extras["raw_profile"] = _serializable_value(full_user) + + return extras + + +async def _parsed_user_from_member( + client: Any, + member: Any, + *, + chat_info: dict[str, Any], + avatar_dir: Path | None, +) -> ParsedUser: + u = member.user + extras = await _load_user_extras(client, u.id) + return ParsedUser( + id=u.id, + username=u.username or "", + first_name=u.first_name or "", + last_name=u.last_name or "", + phone=u.phone_number or "", + avatar_path=await _download_user_avatar(client, u, avatar_dir), + bio=extras["bio"], + song=extras["song"], + birthday=extras["birthday"], + gifts=extras["gifts"], + source_chat_id=chat_info.get("id"), + source_chat_title=chat_info.get("title", ""), + source_chat_username=chat_info.get("username", ""), + ) + + async def parse_chat_members( session_name: str, - chat: str, + chat: ChatRef, on_progress: Callable[[int], None] | None = None, + avatar_dir: Path | None = None, ) -> list[ParsedUser]: users: list[ParsedUser] = [] async with connected(session_name) as client: - async for member in client.get_chat_members(chat): # type: ignore[reportGeneralTypeIssues] - u = member.user + resolved_chat = await _resolve_chat_ref(client, chat) + chat_obj = await client.get_chat(resolved_chat) + chat_info = { + "id": chat_obj.id, + "title": ( + getattr(chat_obj, "title", None) + or " ".join( + p + for p in [ + getattr(chat_obj, "first_name", ""), + getattr(chat_obj, "last_name", ""), + ] + if p + ) + or str(chat_obj.id) + ), + "username": getattr(chat_obj, "username", "") or "", + } + async for member in client.get_chat_members(resolved_chat): # type: ignore[reportGeneralTypeIssues] users.append( - ParsedUser( - id=u.id, - username=u.username or "", - first_name=u.first_name or "", - last_name=u.last_name or "", - phone=u.phone_number or "", + await _parsed_user_from_member( + client, + member, + chat_info=chat_info, + avatar_dir=avatar_dir, ) ) if on_progress and len(users) % 50 == 0: @@ -78,10 +628,80 @@ async def parse_chat_members( return users +async def parse_chats_members( + session_name: str, + chats: list[ChatRef], + *, + avatar_dir: Path | None = None, + on_progress: Callable[[str, int], None] | None = None, +) -> list[ParsedUser]: + users_by_id: dict[int, ParsedUser] = {} + async with connected(session_name) as client: + for chat in chats: + resolved_chat = await _resolve_chat_ref(client, chat) + chat_obj = await client.get_chat(resolved_chat) + chat_info = { + "id": chat_obj.id, + "title": ( + getattr(chat_obj, "title", None) + or " ".join( + p + for p in [ + getattr(chat_obj, "first_name", ""), + getattr(chat_obj, "last_name", ""), + ] + if p + ) + or str(chat_obj.id) + ), + "username": getattr(chat_obj, "username", "") or "", + } + count = 0 + async for member in client.get_chat_members(resolved_chat): # type: ignore[reportGeneralTypeIssues] + parsed = await _parsed_user_from_member( + client, + member, + chat_info=chat_info, + avatar_dir=avatar_dir, + ) + if parsed.id not in users_by_id: + users_by_id[parsed.id] = parsed + count += 1 + if on_progress and count % 50 == 0: + on_progress(str(chat), count) + if on_progress: + on_progress(str(chat), count) + + users = list(users_by_id.values()) + log.info("[parse] parsed %d unique members from %d chats", len(users), len(chats)) + return users + + +async def save_chats_members( + session_name: str, + chats: list[ChatRef], + dest: Path, + *, + avatar_dir: Path | None = None, + on_progress: Callable[[str, int], None] | None = None, +) -> int: + users = await parse_chats_members( + session_name, + chats, + avatar_dir=avatar_dir, + on_progress=on_progress, + ) + payload = [u.model_dump() for u in users] + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + log.info("[parse] saved %d parsed members to %s", len(users), dest) + return len(users) + + async def list_dialogs( session_name: str, kind: str = "all", - limit: int = 200, + limit: int = 0, ) -> list[dict[str, Any]]: from pyrogram.enums import ChatType # type: ignore[import-untyped] @@ -123,11 +743,29 @@ async def list_dialogs( async def get_user_info(session_name: str, user_id: str) -> dict[str, Any]: async with connected(session_name) as client: u = await client.get_users(user_id) + extras = await _load_user_extras(client, u.id) return { "id": u.id, "username": u.username or "", "first_name": u.first_name or "", "last_name": u.last_name or "", "phone": u.phone_number or "", - "bio": getattr(u, "bio", "") or "", + "bio": extras.get("bio", "") or getattr(u, "bio", "") or "", + "birthday": extras.get("birthday", ""), + "song": extras.get("song", ""), + "gifts": extras.get("gifts", []), + "is_bot": bool(getattr(u, "is_bot", False)), + "is_contact": bool(getattr(u, "is_contact", False)), + "is_mutual_contact": bool(getattr(u, "is_mutual_contact", False)), + "is_premium": bool(getattr(u, "is_premium", False)), + "is_verified": bool(getattr(u, "is_verified", False)), + "is_scam": bool(getattr(u, "is_scam", False)), + "is_fake": bool(getattr(u, "is_fake", False)), + "language_code": getattr(u, "language_code", "") or "", + "dc_id": getattr(u, "dc_id", None), + "status": _enum_value(getattr(u, "status", None)), + "last_online_date": _format_optional(getattr(u, "last_online_date", "")), + "next_offline_date": _format_optional(getattr(u, "next_offline_date", "")), + "emoji_status": _serializable_value(getattr(u, "emoji_status", None)), + "raw_profile": extras.get("raw_profile", {}), } diff --git a/src/accxus/platforms/telegram/profile.py b/src/accxus/platforms/telegram/profile.py index a2dba24..b1f2359 100644 --- a/src/accxus/platforms/telegram/profile.py +++ b/src/accxus/platforms/telegram/profile.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import tempfile from pathlib import Path from accxus.platforms.telegram.client import connected, fetch_info @@ -45,6 +46,21 @@ async def set_avatar(session_name: str, photo_path: str | Path) -> None: log.info(f"[profile] {session_name!r} avatar updated from {path.name!r}") +async def download_avatar(session_name: str, dest_dir: Path | None = None) -> Path | None: + async with connected(session_name) as client: + me = await client.get_me() + photos = [] + async for photo in client.get_chat_photos(me.id): # type: ignore[reportGeneralTypeIssues] + photos.append(photo) + break + if not photos: + return None + photo = photos[0] + dest = Path(dest_dir or tempfile.gettempdir()) / f"{session_name}_avatar.jpg" + await client.download_media(photo.file_id, file_name=str(dest)) # type: ignore[reportGeneralTypeIssues] + return dest + + async def delete_avatar(session_name: str) -> None: async with connected(session_name) as client: photos = [] diff --git a/src/accxus/platforms/telegram/sessions.py b/src/accxus/platforms/telegram/sessions.py index bbefd3a..8e1995b 100644 --- a/src/accxus/platforms/telegram/sessions.py +++ b/src/accxus/platforms/telegram/sessions.py @@ -2,6 +2,7 @@ import json import logging +import sqlite3 from pathlib import Path from typing import Any @@ -27,22 +28,92 @@ def save_metadata(meta: dict[str, dict[str, Any]]) -> None: _META_FILE.write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8") +def read_session_dc_id(session_name: str) -> int | None: + path = session_path(session_name) + if not path.exists(): + return None + try: + with sqlite3.connect(path) as conn: + row = conn.execute("SELECT dc_id FROM sessions LIMIT 1").fetchone() + except sqlite3.Error: + return None + if not row or row[0] is None: + return None + try: + return int(row[0]) + except (TypeError, ValueError): + return None + + +def update_metadata_dc_id(session_name: str, dc_id: int | None) -> None: + if dc_id is None: + return + meta = load_metadata() + item = meta.setdefault(session_name, {}) + if item.get("dc_id") == dc_id: + return + item["dc_id"] = dc_id + save_metadata(meta) + + def update_metadata(session_name: str, info: SessionInfo) -> None: meta = load_metadata() - meta.setdefault(session_name, {}).update( - { - "phone": info.phone, - "first_name": info.first_name, - "last_name": info.last_name, - "username": info.username, - "kind": info.kind.name, - "status": info.status.value, - } - ) + data = { + "phone": info.phone, + "first_name": info.first_name, + "last_name": info.last_name, + "username": info.username, + "kind": info.kind.name, + "status": info.status.value, + } + if info.user_id is not None: + data["user_id"] = str(info.user_id) + if info.dc_id is not None: + data["dc_id"] = str(info.dc_id) + meta.setdefault(session_name, {}).update(data) save_metadata(meta) +def hydrate_session_dc_metadata(session_name: str) -> int | None: + dc_id = read_session_dc_id(session_name) + update_metadata_dc_id(session_name, dc_id) + return dc_id + + +def hydrate_all_dc_metadata() -> None: + meta = load_metadata() + changed = False + for f in sorted(cfg.SESSIONS_DIR.glob("*.session")): + dc_id = read_session_dc_id(f.stem) + if dc_id is not None and meta.setdefault(f.stem, {}).get("dc_id") != dc_id: + meta[f.stem]["dc_id"] = dc_id + changed = True + if changed: + save_metadata(meta) + + +def update_metadata_statuses(statuses: dict[str, SessionStatus]) -> None: + meta = load_metadata() + for name, status in statuses.items(): + item = meta.setdefault(name, {}) + item["status"] = status.value + dc_id = read_session_dc_id(name) + if dc_id is not None: + item["dc_id"] = dc_id + save_metadata(meta) + + +def _coerce_dc_id(value: Any) -> int | None: + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + def list_sessions() -> list[SessionInfo]: + hydrate_all_dc_metadata() meta = load_metadata() result: list[SessionInfo] = [] for f in sorted(cfg.SESSIONS_DIR.glob("*.session")): @@ -67,6 +138,8 @@ def list_sessions() -> list[SessionInfo]: last_name=m.get("last_name", ""), username=m.get("username", ""), bio=m.get("bio", ""), + user_id=m.get("user_id"), + dc_id=_coerce_dc_id(m.get("dc_id")) or read_session_dc_id(name), kind=kind, status=status, ) diff --git a/src/accxus/types/core.py b/src/accxus/types/core.py index c158857..6a6dd71 100644 --- a/src/accxus/types/core.py +++ b/src/accxus/types/core.py @@ -75,6 +75,7 @@ class AppConfig(BaseModel): tg_device_model: str = "Telegram Desktop" tg_system_version: str = "Windows 11" telegram_proxy: ProxyConfig | None = None + active_session: str | None = None proxies: list[ProxyConfig] = Field(default_factory=list) sms_providers: dict[str, SmsProviderConfig] = Field( diff --git a/src/accxus/types/telegram.py b/src/accxus/types/telegram.py index fbe741a..7d37f40 100644 --- a/src/accxus/types/telegram.py +++ b/src/accxus/types/telegram.py @@ -1,8 +1,9 @@ from __future__ import annotations from enum import Enum +from typing import Any -from pydantic import BaseModel, computed_field +from pydantic import BaseModel, Field, computed_field class SessionKind(str, Enum): @@ -25,6 +26,8 @@ class SessionInfo(BaseModel): last_name: str = "" username: str = "" bio: str = "" + user_id: int | None = None + dc_id: int | None = None kind: SessionKind = SessionKind.PYROGRAM status: SessionStatus = SessionStatus.UNKNOWN @@ -41,6 +44,14 @@ class ParsedUser(BaseModel): first_name: str = "" last_name: str = "" phone: str = "" + avatar_path: str = "" + bio: str = "" + song: str = "" + birthday: str = "" + gifts: list[dict[str, Any]] = Field(default_factory=list) + source_chat_id: int | None = None + source_chat_title: str = "" + source_chat_username: str = "" @computed_field # type: ignore[prop-decorator] @property diff --git a/src/accxus/ui/app.py b/src/accxus/ui/app.py index e4e26f7..0f9aa98 100644 --- a/src/accxus/ui/app.py +++ b/src/accxus/ui/app.py @@ -5,23 +5,23 @@ import logging from typing import Any -from rigi.core.app import RigiApp +from rigi.core.app import App from rigi.core.settings_manager import Setting from rigi.core.types import TabDef -from rigi.layout.pane import RigiCard, RigiPane -from rigi.widgets import Label, RigiBottomPanel +from rigi.widgets import BottomPanel +from rigi.widgets.action_menu import ActionMenuPanel +from textual.events import Click import accxus.config as cfg from accxus import __version__ -from accxus.ui.proxy.add import AddProxyTab -from accxus.ui.proxy.checker import ProxyCheckerTab -from accxus.ui.proxy.view import ViewProxiesTab +from accxus.ui.proxy.proxies_tab import ProxiesTab from accxus.ui.sms.providers import SmsProvidersTab from accxus.ui.sms.services import SmsServicesTab -from accxus.ui.tg.add_session import AddSessionTab from accxus.ui.tg.messages import MessagesTab from accxus.ui.tg.parsing import ParsingTab +from accxus.ui.tg.registration import RegistrationTab from accxus.ui.tg.sessions import SessionsTab +from accxus.ui.utils.telegram_tab import TelegramTab log = logging.getLogger(__name__) @@ -35,19 +35,9 @@ def _proxy_status() -> str: return proxy.display_name -def _make_tg_welcome() -> RigiPane: - return RigiPane( - RigiCard( - Label("[dim]Choose a subtab from the left panel[/dim]"), - Label(" Sessions Β· Messages Β· Parsing"), - title=" Telegram", - ) - ) - - -def _write(app_: RigiApp, text: str) -> None: +def _write(app_: App, text: str) -> None: try: - app_.query_one(RigiBottomPanel).write_output(text) + app_.query_one(BottomPanel).write_output(text) except Exception: app_.notify(text) @@ -79,8 +69,31 @@ def _write_system_version(v: str) -> None: cfg.save_config(cfg.config) -def _build_app() -> RigiApp: - app = RigiApp( +class AccxusApp(App): + CSS = """ + StatusBar { + width: 100%; + background: #161b22; + color: #c9d1d9; + border-bottom: solid #30363d; + } + StatusBarItem { + width: auto; + color: #c9d1d9; + } + """ + + def on_click(self, event: Click) -> None: + try: + panel = self.query_one("#rigi-action-panel", ActionMenuPanel) + if not panel.region.contains(event.screen_x, event.screen_y): + panel.remove() + except Exception: + pass + + +def _build_app() -> App: + app = AccxusApp( name="accxus", version=__version__, description="Telegram session manager", @@ -98,39 +111,33 @@ def _build_app() -> RigiApp: "SMS", lambda: _sms_balance, refresh_interval=30.0, - style="bold cyan", ) app.add_status( "proxy", "Proxy", _proxy_status, refresh_interval=2.0, - style="bold green", ) - tg_tab = TabDef(name="Telegram", key="1", icon="", widget_factory=_make_tg_welcome) - - sess_sub = tg_tab.add_subtab("Sessions", SessionsTab, icon="") - sess_sub.add_subtab("View", SessionsTab, icon="") - sess_sub.add_subtab("Add", AddSessionTab, icon="") - - msg_sub = tg_tab.add_subtab("Messages", MessagesTab, icon="") - msg_sub.add_subtab("Bulk", MessagesTab, icon="") - - tg_tab.add_subtab("Parsing", ParsingTab, icon="") + tg_tab = TabDef(name="Telegram", key="1", icon="\uf2c6", widget_factory=SessionsTab) + tg_tab.add_subtab("Sessions", SessionsTab, icon="\uf0c0") + tg_tab.add_subtab("Messages", MessagesTab, icon="\uf0e0") + tg_tab.add_subtab("Parsing", ParsingTab, icon="\uf002") + tg_tab.add_subtab("Registration", RegistrationTab, icon="\uf234") app.add_tab(tg_tab) - proxy_tab = TabDef(name="Proxies", key="2", icon="🌐") - proxy_tab.add_subtab("Check", ProxyCheckerTab, icon="") - proxy_tab.add_subtab("Add", AddProxyTab, icon="") - proxy_tab.add_subtab("View", ViewProxiesTab, icon="") + proxy_tab = TabDef(name="Proxies", key="2", icon="\uf0ac", widget_factory=ProxiesTab) app.add_tab(proxy_tab) - sms_tab = TabDef(name="SMS", key="3", icon="πŸ“±") + sms_tab = TabDef(name="SMS", key="3", icon="\uf10b") sms_tab.add_subtab("Providers", SmsProvidersTab, icon="") sms_tab.add_subtab("Services", SmsServicesTab, icon="") app.add_tab(sms_tab) + utils_tab = TabDef(name="Utils", key="4", icon="\uf0ad") + utils_tab.add_subtab("Telegram", TelegramTab, icon="\uf2c6") + app.add_tab(utils_tab) + tg_settings = app.settings.add_page("Telegram") tg_settings.settings = [ Setting( @@ -168,9 +175,41 @@ def _build_app() -> RigiApp: Setting("Maintainer", value_fn=lambda: "@xeltorV"), ] + def _sms_api_key_fn(provider: str) -> str: + return cfg.config.sms_providers.get(provider, {}).api_key or "" + + def _write_sms_api_key(provider: str, v: str) -> None: + if provider in cfg.config.sms_providers: + cfg.config.sms_providers[provider].api_key = v + cfg.save_config(cfg.config) + + sms_settings = app.settings.add_page("SMS") + sms_settings.settings = [ + Setting( + "SMS Activate", + value_fn=lambda: _sms_api_key_fn("sms_activate"), + write_fn=lambda v: _write_sms_api_key("sms_activate", v), + ), + Setting( + "HeroSMS", + value_fn=lambda: _sms_api_key_fn("herosms"), + write_fn=lambda v: _write_sms_api_key("herosms", v), + ), + Setting( + "5sim", + value_fn=lambda: _sms_api_key_fn("fivesim"), + write_fn=lambda v: _write_sms_api_key("fivesim", v), + ), + Setting( + "SMSPool", + value_fn=lambda: _sms_api_key_fn("smspool"), + write_fn=lambda v: _write_sms_api_key("smspool", v), + ), + ] + @app.on_startup async def _balance_loop( # pyright: ignore[reportUnusedFunction,reportUnusedParameter] - app_: RigiApp, + app_: App, ) -> None: global _sms_balance while True: @@ -189,7 +228,7 @@ async def _balance_loop( # pyright: ignore[reportUnusedFunction,reportUnusedPar @app.command("session", help="Manage sessions: list / check / delete ") async def _cmd_session( # pyright: ignore[reportUnusedFunction] - app: RigiApp, _arg0: str = "list", _arg1: str = "", **_: Any + app: App, _arg0: str = "list", _arg1: str = "", **_: Any ) -> None: action = _arg0 or "list" target = _arg1 or "" @@ -205,7 +244,9 @@ async def _cmd_session( # pyright: ignore[reportUnusedFunction] for s in sessions: color = "green" if s.status.value == "valid" else "red" name_part = f"[cyan]{s.name}[/cyan]" - phone_part = f"[dim]{s.phone or '?'} Β· @{s.username or 'β€”'}[/dim]" + phone_part = ( + f"[dim]{s.phone or '?'} Β· @{s.username or 'β€”'} Β· DC {s.dc_id or 'β€”'}[/dim]" + ) _write(app, f" [{color}]●[/{color}] {name_part} {phone_part}") elif action == "check": @@ -244,7 +285,7 @@ async def _cmd_session( # pyright: ignore[reportUnusedFunction] _write(app, " [cyan]session delete [/cyan] delete a session") @app.command("balance", help="Fetch SMS provider balance(s)", aliases=["bal"]) - async def _cmd_balance(app: RigiApp, **_: Any) -> None: # pyright: ignore[reportUnusedFunction] + async def _cmd_balance(app: App, **_: Any) -> None: # pyright: ignore[reportUnusedFunction] global _sms_balance from accxus.core.sms.manager import SmsManager @@ -267,7 +308,7 @@ async def _cmd_balance(app: RigiApp, **_: Any) -> None: # pyright: ignore[repor @app.command("message", help="Send message: message ", aliases=["msg"]) async def _cmd_message( # pyright: ignore[reportUnusedFunction] - app: RigiApp, args: dict[str, Any] + app: App, args: dict[str, Any] ) -> None: session = str(args.get("_arg0", "") or "").strip() target = str(args.get("_arg1", "") or "").strip() @@ -294,7 +335,7 @@ async def _cmd_message( # pyright: ignore[reportUnusedFunction] @app.command("proxy", help="Proxy management: list / check / set ") async def _cmd_proxy( # pyright: ignore[reportUnusedFunction] - app: RigiApp, _arg0: str = "list", _arg1: str = "", _arg2: str = "", **_: Any + app: App, _arg0: str = "list", _arg1: str = "", _arg2: str = "", **_: Any ) -> None: action = _arg0 or "list" url = _arg1 or "" @@ -405,18 +446,16 @@ async def _cmd_proxy( # pyright: ignore[reportUnusedFunction] _write(app, " [cyan]proxy unset[/cyan] remove proxy") @app.command("sessions", help="Go to Sessions tab", aliases=["sess"]) - async def _cmd_sessions( # pyright: ignore[reportUnusedFunction] - app: RigiApp, **_: Any - ) -> None: + async def _cmd_sessions(app: App, **_: Any) -> None: # pyright: ignore[reportUnusedFunction] app.navigate_to_tab("Telegram") @app.command("logs", help="Open the live log viewer") - async def _cmd_logs(app: RigiApp, **_: Any) -> None: # pyright: ignore[reportUnusedFunction] + async def _cmd_logs(app: App, **_: Any) -> None: # pyright: ignore[reportUnusedFunction] app.navigate_to_tab("Logs") @app.command("crash", help="Emergency kill: crash yes β€” stop all tasks and exit immediately") async def _cmd_crash( # pyright: ignore[reportUnusedFunction] - app: RigiApp, _arg0: str = "", **_: Any + app: App, _arg0: str = "", **_: Any ) -> None: confirm = _arg0 or "" if confirm.lower() != "yes": @@ -435,7 +474,7 @@ async def _cmd_crash( # pyright: ignore[reportUnusedFunction] def main() -> None: logging.basicConfig(level=logging.INFO, format="%(message)s") app = _build_app() - RigiApp.run_cli(app) + App.run_cli(app) if __name__ == "__main__": diff --git a/src/accxus/ui/proxy/add.py b/src/accxus/ui/proxy/add.py index 9dc5117..f4e3ca4 100644 --- a/src/accxus/ui/proxy/add.py +++ b/src/accxus/ui/proxy/add.py @@ -4,7 +4,7 @@ import urllib.parse from rigi import ComposeResult, Widget -from rigi.layout.pane import RigiCard, RigiPane +from rigi.layout.pane import Card, Pane from rigi.widgets import Button, Input, Label, Select, Static import accxus.config as cfg @@ -14,10 +14,10 @@ class AddProxyTab(Widget): DEFAULT_CSS = """ - AddProxyTab RigiPane { + AddProxyTab Pane { overflow-y: auto; } - AddProxyTab RigiCard { + AddProxyTab Card { height: auto; } AddProxyTab Button { @@ -41,8 +41,8 @@ class AddProxyTab(Widget): """ def compose(self) -> ComposeResult: - yield RigiPane( - RigiCard( + yield Pane( + Card( Label("[bold cyan]Add Proxy[/bold cyan]"), Label("[dim]Add a new proxy to your configuration[/dim]"), Label("\n[bold]Name[/bold]"), @@ -76,7 +76,7 @@ def compose(self) -> ComposeResult: Static("", id="proxy_status"), title=" Add Proxy", ), - RigiCard( + Card( Label(id="proxy_preview", markup=True), title=" Preview", ), diff --git a/src/accxus/ui/proxy/checker.py b/src/accxus/ui/proxy/checker.py index 6f5e351..a83496b 100644 --- a/src/accxus/ui/proxy/checker.py +++ b/src/accxus/ui/proxy/checker.py @@ -1,7 +1,7 @@ from __future__ import annotations from rigi import ComposeResult, Widget -from rigi.layout.pane import RigiCard, RigiPane +from rigi.layout.pane import Card, Pane from rigi.widgets import Button, DataTable, Input, Label from accxus.core.proxy.checker import check_proxy @@ -20,8 +20,8 @@ class ProxyCheckerTab(Widget): """ def compose(self) -> ComposeResult: - yield RigiPane( - RigiCard( + yield Pane( + Card( Label("[bold cyan]Proxy Checker[/bold cyan]"), Label("[dim]Test proxy connectivity and measure latency[/dim]"), Input( @@ -31,7 +31,7 @@ def compose(self) -> ComposeResult: Button("Check Proxy", id="check_proxy_btn", variant="primary"), title=" Check Proxy", ), - RigiCard( + Card( DataTable( id="proxy_check_results", cursor_type="row", diff --git a/src/accxus/ui/proxy/proxies_tab.py b/src/accxus/ui/proxy/proxies_tab.py new file mode 100644 index 0000000..81a77c4 --- /dev/null +++ b/src/accxus/ui/proxy/proxies_tab.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from rigi import ComposeResult, Widget +from rigi.widgets import TabGroup + +from accxus.ui.proxy.add import AddProxyTab +from accxus.ui.proxy.checker import ProxyCheckerTab +from accxus.ui.proxy.view import ViewProxiesTab + + +class ProxiesTab(Widget): + DEFAULT_CSS = """ + ProxiesTab { + height: 100%; + width: 100%; + padding: 1 2; + } + """ + + def compose(self) -> ComposeResult: + yield TabGroup( + [ + ("View", lambda: ViewProxiesTab()), + ("Add", lambda: AddProxyTab()), + ("Check", lambda: ProxyCheckerTab()), + ] + ) diff --git a/src/accxus/ui/proxy/view.py b/src/accxus/ui/proxy/view.py index 04b1f7e..fb5c7cf 100644 --- a/src/accxus/ui/proxy/view.py +++ b/src/accxus/ui/proxy/view.py @@ -1,8 +1,13 @@ from __future__ import annotations +import contextlib +from textual.events import Click, MouseDown from rigi import ComposeResult, Widget -from rigi.layout.pane import RigiCard, RigiPane -from rigi.widgets import Button, DataTable, Label +from rigi.widgets import ( + ActionMenuItemData, + Button, + DataTable, +) import accxus.config as cfg from accxus.core.proxy.checker import check_proxy, lookup_proxy_country @@ -11,76 +16,96 @@ class ViewProxiesTab(Widget): DEFAULT_CSS = """ - ViewProxiesTab Button { - min-width: 16; - height: 3; + ViewProxiesTab { + height: 100%; + width: 100%; } + #proxy_top_row { + layout: horizontal; + height: auto; + margin-bottom: 1; + } + #proxy_top_row Button { margin-right: 1; } + #proxies_table { height: 1fr; } """ def compose(self) -> ComposeResult: - yield RigiPane( - RigiCard( - Label("[bold cyan]Configured Proxies[/bold cyan]"), - Label("[dim]View and manage your proxy configuration[/dim]"), - Button("Refresh", id="refresh_proxies_btn", variant="primary"), - Button("Update Ping", id="update_proxy_ping_btn", variant="success"), - Label("[dim]Select a row to make it the Telegram proxy[/dim]"), - title=" Proxies", - ), - RigiCard( - DataTable( - id="proxies_table", - cursor_type="row", - zebra_stripes=True, - ), - title=" Proxy List", - ), - RigiCard( - Label(id="telegram_proxy_info", markup=True), - Button("Clear Telegram Proxy", id="clear_tg_proxy_btn", variant="error"), - title=" Telegram Proxy", - ), + with Widget(id="proxy_top_row"): + yield Button("Refresh", id="refresh_proxies_btn", variant="primary") + yield Button("Update Ping", id="update_proxy_ping_btn", variant="success") + yield DataTable( + id="proxies_table", + cursor_type="row", + zebra_stripes=True, ) def on_mount(self) -> None: self.run_worker(self._load_proxies()) + def on_click(self, event: Click) -> None: + with contextlib.suppress(Exception): + panel = self.app.query_one("#rigi-action-panel") + panel.remove() + + def on_mouse_down(self, event: MouseDown) -> None: + if event.button == 3: + event.stop() + tbl = self.query_one("#proxies_table", DataTable) + table_region = tbl.region + row_in_view = event.screen_y - table_region.y - 1 + scroll_y = int(tbl.scroll_offset.y) + row_idx = max(0, min(row_in_view + scroll_y, len(tbl.rows) - 1)) + if row_in_view >= 0: + self._show_action_menu(row_idx, event.screen_x, event.screen_y) + + def _close_action_menu(self) -> None: + with contextlib.suppress(Exception): + panel = self.app.query_one("#rigi-action-panel") + panel.remove() + + def _show_action_menu(self, row_idx: int, x: int, y: int) -> None: + self._close_action_menu() + proxies = list(cfg.config.proxies) + if cfg.config.telegram_proxy and cfg.config.telegram_proxy not in proxies: + proxies.insert(0, cfg.config.telegram_proxy) + if row_idx < 0 or row_idx >= len(proxies): + return + proxy = proxies[row_idx] + items: list[ActionMenuItemData] = [ + ActionMenuItemData( + "Set as Telegram Proxy", + callback=lambda p=proxy: self._set_telegram_proxy(p), + color="green", + ), + ActionMenuItemData( + "Update Ping", + callback=lambda p=proxy: self.run_worker(self._update_one(p)), + ), + ActionMenuItemData( + "Delete", + callback=lambda p=proxy: self._do_delete(p), + color="red", + ), + ] + self.app.show_action_menu(items, title=proxy.display_name, x=x, y=y) + async def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "refresh_proxies_btn": await self._load_proxies() elif event.button.id == "update_proxy_ping_btn": - await self._update_proxy_details() - elif event.button.id == "clear_tg_proxy_btn": - await self._clear_telegram_proxy() - - async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - key = event.row_key.value - if not isinstance(key, str) or key == "__current__": - return - proxy = next((p for p in cfg.config.proxies if self._proxy_key(p) == key), None) - if proxy is None: - return - cfg.config.telegram_proxy = proxy - cfg.save_config(cfg.config) - self.notify(f"Telegram proxy set: {proxy.display_name}", severity="information") - await self._load_proxies() + await self._update_all() async def _load_proxies(self) -> None: table = self.query_one("#proxies_table", DataTable) table.clear(columns=True) - table.add_columns("Name", "Country", "Ping", "Type", "Host", "Port", "Auth", "URL") - - tg_info = self.query_one("#telegram_proxy_info", Label) - if cfg.config.telegram_proxy: - proxy = cfg.config.telegram_proxy - label = proxy.display_name - tg_info.update( - f"[bold]Current:[/bold] [cyan]{label}[/cyan]\n" - f"[dim]{proxy.country_label} Β· {self._latency_label(proxy)} Β· " - f"{proxy.scheme} Β· {proxy.host}:{proxy.port}[/dim]" - ) - else: - tg_info.update("[dim]No Telegram proxy configured[/dim]") + table.add_column("Name", key="name") + table.add_column("Country", key="country") + table.add_column("Ping", key="ping") + table.add_column("Type", key="type") + table.add_column("Host", key="host") + table.add_column("Port", key="port") + table.add_column("Auth", key="auth") + table.add_column("URL", key="url") proxies = list(cfg.config.proxies) if cfg.config.telegram_proxy and cfg.config.telegram_proxy not in proxies: @@ -116,17 +141,52 @@ async def _load_proxies(self) -> None: "[dim]β€”[/dim]", ) - async def _clear_telegram_proxy(self) -> None: - cfg.config.telegram_proxy = None + def _set_telegram_proxy(self, proxy: ProxyConfig) -> None: + cfg.config.telegram_proxy = proxy cfg.save_config(cfg.config) - self.notify("βœ“ Telegram proxy cleared", severity="information") - await self._load_proxies() + self.app.notify(f"Telegram proxy set: {proxy.display_name}", severity="information") + self.run_worker(self._load_proxies()) - async def _update_proxy_details(self) -> None: + async def _update_one(self, proxy: ProxyConfig) -> None: + try: + if not proxy.country or not proxy.country_code: + country, country_code = await lookup_proxy_country(proxy, timeout=6.0) + proxy.country = proxy.country or country + proxy.country_code = proxy.country_code or country_code + result = await check_proxy(proxy, timeout=6.0) + if result.ok: + proxy.exit_ip = result.ip or "" + proxy.latency_ms = result.latency_ms + self._save_proxy(proxy) + self.run_worker(self._load_proxies()) + if result.ok: + self.app.notify( + f"{proxy.display_name}: {result.latency_ms:.0f} ms", + severity="information", + ) + else: + self.app.notify(f"{proxy.display_name}: failed", severity="error") + except Exception as e: + self.app.notify(f"Update error: {e}", severity="error") + + def _do_delete(self, proxy: ProxyConfig) -> None: + cfg.config.proxies = [ + p for p in cfg.config.proxies if self._proxy_key(p) != self._proxy_key(proxy) + ] + if cfg.config.telegram_proxy == proxy: + cfg.config.telegram_proxy = None + cfg.save_config(cfg.config) + self.run_worker(self._load_proxies()) + self.app.notify( + f"Proxy '{proxy.display_name}' deleted", + severity="warning", + ) + + async def _update_all(self) -> None: if not cfg.config.proxies: - self.notify("No saved proxies to update", severity="warning") + self.app.notify("No saved proxies to update", severity="warning") return - self.notify("Updating proxy ping and country...", timeout=2) + self.app.notify("Updating proxy ping and country...", timeout=2) updated: list[ProxyConfig] = [] for proxy in cfg.config.proxies: if not proxy.country or not proxy.country_code: @@ -151,7 +211,16 @@ async def _update_proxy_details(self) -> None: cfg.config.telegram_proxy = active cfg.save_config(cfg.config) await self._load_proxies() - self.notify("Proxy details updated", severity="information") + self.app.notify("Proxy details updated", severity="information") + + def _save_proxy(self, proxy: ProxyConfig) -> None: + for i, p in enumerate(cfg.config.proxies): + if self._proxy_key(p) == self._proxy_key(proxy): + cfg.config.proxies[i] = proxy + break + if cfg.config.telegram_proxy and self._proxy_key(cfg.config.telegram_proxy) == self._proxy_key(proxy): + cfg.config.telegram_proxy = proxy + cfg.save_config(cfg.config) @staticmethod def _proxy_key(proxy: ProxyConfig) -> str: diff --git a/src/accxus/ui/sms/providers.py b/src/accxus/ui/sms/providers.py index 3e850e6..52554dc 100644 --- a/src/accxus/ui/sms/providers.py +++ b/src/accxus/ui/sms/providers.py @@ -1,7 +1,7 @@ from __future__ import annotations from rigi import ComposeResult, Widget -from rigi.layout.pane import RigiCard, RigiPane +from rigi.layout.pane import Card, Pane from rigi.widgets import Button, DataTable, Input, Label, Select, Switch import accxus.config as cfg @@ -26,15 +26,15 @@ class SmsProvidersTab(Widget): """ def compose(self) -> ComposeResult: - yield RigiPane( - RigiCard( + yield Pane( + Card( Label("[bold cyan]SMS Providers[/bold cyan]"), Label("[dim]Manage SMS provider configuration[/dim]"), Button("Check All Balances", id="check_balances_btn", variant="primary"), Button("Refresh", id="refresh_providers_btn", variant="default"), title=" SMS Providers", ), - RigiCard( + Card( DataTable( id="providers_table", cursor_type="row", @@ -42,7 +42,7 @@ def compose(self) -> ComposeResult: ), title=" Configured Providers", ), - RigiCard( + Card( Label("[bold]Edit Provider Configuration[/bold]"), Label("\n[bold]Provider:[/bold]"), Select( @@ -55,8 +55,6 @@ def compose(self) -> ComposeResult: ], value="sms_activate", ), - Label("\n[bold]API Key:[/bold]"), - Input(placeholder="Enter API key", id="provider_api_key", password=True), Label("\n[bold]Priority (0-100, lower = higher priority):[/bold]"), Input(placeholder="50", id="provider_priority"), Label("\n[bold]Timeout (seconds):[/bold]"), @@ -129,7 +127,6 @@ def _load_provider_into_form(self) -> None: if not config_data: return config = SmsProviderConfig(**config_data) if isinstance(config_data, dict) else config_data - self.query_one("#provider_api_key", Input).value = config.api_key self.query_one("#provider_priority", Input).value = str(config.priority) self.query_one("#provider_timeout", Input).value = str(config.timeout) self.query_one("#provider_enabled", Switch).value = config.enabled @@ -140,7 +137,6 @@ async def _save_provider(self) -> None: if not provider_name: self.notify("Select a provider first", severity="warning") return - api_key = self.query_one("#provider_api_key", Input).value.strip() priority_str = self.query_one("#provider_priority", Input).value.strip() timeout_str = self.query_one("#provider_timeout", Input).value.strip() enabled = self.query_one("#provider_enabled", Switch).value @@ -154,7 +150,6 @@ async def _save_provider(self) -> None: if isinstance(existing, dict) else (existing or SmsProviderConfig()) ) - config.api_key = api_key config.priority = priority config.timeout = timeout config.enabled = enabled diff --git a/src/accxus/ui/sms/services.py b/src/accxus/ui/sms/services.py index afee62a..c9a891e 100644 --- a/src/accxus/ui/sms/services.py +++ b/src/accxus/ui/sms/services.py @@ -1,7 +1,7 @@ from __future__ import annotations from rigi import ComposeResult, Widget -from rigi.layout.pane import RigiCard, RigiPane +from rigi.layout.pane import Card, Pane from rigi.widgets import Button, DataTable, Label, Select import accxus.config as cfg @@ -21,8 +21,8 @@ class SmsServicesTab(Widget): """ def compose(self) -> ComposeResult: - yield RigiPane( - RigiCard( + yield Pane( + Card( Label("[bold cyan]SMS Services[/bold cyan]"), Label("[dim]View available services from SMS providers[/dim]"), Label("\n[bold]Provider:[/bold]"), @@ -34,7 +34,7 @@ def compose(self) -> ComposeResult: Button("Refresh Services", id="refresh_services_btn", variant="primary"), title=" SMS Services", ), - RigiCard( + Card( DataTable( id="services_table", cursor_type="row", diff --git a/src/accxus/ui/tg/add_session.py b/src/accxus/ui/tg/add_session.py index aae62f2..7826fdf 100644 --- a/src/accxus/ui/tg/add_session.py +++ b/src/accxus/ui/tg/add_session.py @@ -265,6 +265,7 @@ async def _finish(self) -> None: first_name=me.first_name or "", last_name=me.last_name or "", username=me.username or "", + dc_id=await self._client.storage.dc_id(), ) tg_sessions.update_metadata(self._name, info) await self._client.disconnect() diff --git a/src/accxus/ui/tg/messages.py b/src/accxus/ui/tg/messages.py index 7ffb341..1708cb9 100644 --- a/src/accxus/ui/tg/messages.py +++ b/src/accxus/ui/tg/messages.py @@ -6,7 +6,7 @@ from collections.abc import Callable from rigi import ComposeResult, Widget -from rigi.widgets import Button, DataTable, Input, Label, RichLog, RigiBottomPanel, TextArea +from rigi.widgets import BottomPanel, Button, DataTable, Input, Label, RichLog, TextArea from accxus.platforms.telegram import messaging as tg_msg from accxus.platforms.telegram.sessions import list_sessions @@ -45,14 +45,13 @@ class MessagesTab(Widget): } #sess_list { height: 1fr; } #selected_status { height: auto; margin-top: 1; } - #targets_area { height: 7; margin-bottom: 1; } - #msg_area { height: 8; margin-bottom: 1; } + #targets_area { height: 5; margin-bottom: 1; } + #msg_area { height: 1fr; margin-bottom: 1; } #hint { height: auto; margin-bottom: 1; } #msg_log { height: 1fr; min-height: 6; margin-top: 1; } #ctrl_row { layout: horizontal; height: auto; align: left middle; } #ctrl_row Button { margin-right: 1; height: 3; } #ctrl_row Label { margin-right: 1; } - #delay_inp { width: 14; height: 3; margin-right: 1; } #retry_inp { width: 8; height: 3; margin-right: 1; } """ @@ -65,28 +64,25 @@ def __init__(self) -> None: def compose(self) -> ComposeResult: with Widget(id="msg_left"): - yield Label("[bold]Sessions[/bold]\n[dim]Select row, then Toggle/Enter[/dim]") + yield Label("[bold]Sessions[/bold]") with Widget(id="msg_session_buttons"): - yield Button("Toggle", id="btn_toggle_session") yield Button("All", id="btn_select_all") - yield Button("Refresh", id="btn_refresh_sessions") + yield Button("Clear", id="btn_clear") yield DataTable(id="sess_list", cursor_type="row", zebra_stripes=True) yield Label("[dim]Selected: 0[/dim]", id="selected_status") with Widget(id="msg_center"): - yield Label("[bold]Targets[/bold] [dim](one per line: @user / +phone / id)[/dim]") + yield Label("[bold]Targets[/bold] [dim](one per line)[/dim]") yield TextArea(id="targets_area", language=None) - yield Label("[bold]Message template[/bold]") + yield Label("[bold]Message Template[/bold]") yield TextArea(id="msg_area", language=None) yield Label( "[dim]{name} {phone} {username} {random} {random:N} {random:word}[/dim]", id="hint", ) with Widget(id="ctrl_row"): - yield Button("Send All", id="btn_send", variant="success") + yield Button("Send", id="btn_send", variant="success") yield Button("Stop", id="btn_stop", variant="error", disabled=True) - yield Input(value="1.0", id="delay_inp", placeholder="delay (sec)") - yield Label("[dim]s delay[/dim]") yield Input(value="1", id="retry_inp", placeholder="retries") yield Label("[dim]retries[/dim]") yield RichLog(id="msg_log", markup=True) @@ -99,12 +95,11 @@ def _reload_sessions(self) -> None: tbl.clear(columns=True) tbl.add_column("", key="sel") tbl.add_column("Session") - tbl.add_column("Phone") sessions = list_sessions() available = {info.name for info in sessions} self._selected.intersection_update(available) for info in sessions: - tbl.add_row("β—‹", info.name, info.phone or "β€”", key=info.name) + tbl.add_row("β—‹", info.name, key=info.name) self._sync_selected_rows() def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: @@ -133,33 +128,11 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: self._stop = True log.info("stop requested by user") self._write_log("[yellow]Stop requested[/yellow]") - elif event.button.id == "btn_toggle_session": - self._toggle_focused_session() elif event.button.id == "btn_select_all": self._select_all_sessions() - elif event.button.id == "btn_refresh_sessions": - self._reload_sessions() - - def _focused_session(self) -> str | None: - tbl = self.query_one("#sess_list", DataTable) - try: - key = tbl.coordinate_to_cell_key(tbl.cursor_coordinate).row_key.value - except Exception: - return None - return str(key) if key is not None else None - - def _toggle_focused_session(self) -> None: - key = self._focused_session() - if not key: - self.app.notify("Select a session row first", severity="warning") - self._write_log("[yellow]No focused session row[/yellow]") - return - if key in self._selected: - self._selected.discard(key) - else: - self._selected.add(key) - log.info("message session toggled: %s selected=%s", key, key in self._selected) - self._sync_selected_rows() + elif event.button.id == "btn_clear": + self._selected.clear() + self._sync_selected_rows() def _select_all_sessions(self) -> None: sessions = {info.name for info in list_sessions()} @@ -174,7 +147,7 @@ def _write_log(self, text: str) -> None: with contextlib.suppress(Exception): self.query_one("#msg_log", RichLog).write(text) with contextlib.suppress(Exception): - self.app.query_one(RigiBottomPanel).write_output(text) + self.app.query_one(BottomPanel).write_output(text) def _queue_send(self) -> None: if self._send_task is not None and not self._send_task.done(): @@ -188,28 +161,21 @@ async def _start_send(self) -> None: self._stop = False self.query_one("#btn_send", Button).disabled = True self.query_one("#btn_stop", Button).disabled = False - self._write_log("[dim]Send All clicked[/dim]") - log.info("send all clicked") + self._write_log("[dim]Send clicked[/dim]") + log.info("send clicked") targets_raw = self.query_one("#targets_area", TextArea).text.strip() template = self.query_one("#msg_area", TextArea).text.strip() - delay_raw = self.query_one("#delay_inp", Input).value.strip() retry_raw = self.query_one("#retry_inp", Input).value.strip() if not self._selected: - focused = self._focused_session() - if focused: - self._selected.add(focused) - self._sync_selected_rows() - self._write_log(f"[dim]Auto-selected focused session: {focused}[/dim]") - else: - self.app.notify("Select at least one session", severity="warning") - self._write_log("[yellow]Send not started: no session selected[/yellow]") - log.warning("bulk send not started: no session selected") - self._running = False - self._stop = False - self.query_one("#btn_send", Button).disabled = False - self.query_one("#btn_stop", Button).disabled = True - return + self.app.notify("Select at least one session", severity="warning") + self._write_log("[yellow]Send not started: no session selected[/yellow]") + log.warning("bulk send not started: no session selected") + self._running = False + self._stop = False + self.query_one("#btn_send", Button).disabled = False + self.query_one("#btn_stop", Button).disabled = True + return if not targets_raw or not template: self.app.notify("Fill in targets and message template", severity="warning") self._write_log( @@ -232,7 +198,6 @@ async def _start_send(self) -> None: self.query_one("#btn_send", Button).disabled = False self.query_one("#btn_stop", Button).disabled = True return - delay = float(delay_raw) if delay_raw.replace(".", "", 1).isdigit() else 1.0 retries = int(retry_raw) if retry_raw.isdigit() and int(retry_raw) >= 1 else 1 sessions = list(self._selected) log_view = self.query_one("#msg_log", RichLog) @@ -242,10 +207,9 @@ async def _start_send(self) -> None: ) log.info( - "starting bulk send: %d session(s), %d target(s), delay=%.1fs, retries=%d", + "starting bulk send: %d session(s), %d target(s), retries=%d", len(sessions), len(targets), - delay, retries, ) @@ -258,7 +222,7 @@ def _on_result(r: SendResult) -> None: self._write_log(f"[red]FAIL[/red] [{r.session}] -> {r.target} {r.error}") try: - await self._run_send(sessions, targets, template, delay, retries, _on_result) + await self._run_send(sessions, targets, template, retries, _on_result) finally: self._running = False self._stop = False @@ -271,7 +235,6 @@ async def _run_send( sessions: list[str], targets: list[str], template: str, - delay: float, retries: int, on_result: Callable[..., None], ) -> None: @@ -280,7 +243,7 @@ async def _run_send( sessions=sessions, targets=targets, template=template, - delay=delay, + delay=1.0, retries=retries, on_result=on_result, stop_flag=lambda: self._stop, diff --git a/src/accxus/ui/tg/parsing.py b/src/accxus/ui/tg/parsing.py index f0ed4b4..c4d63be 100644 --- a/src/accxus/ui/tg/parsing.py +++ b/src/accxus/ui/tg/parsing.py @@ -77,43 +77,97 @@ def _get_session(widget: Widget, sel_id: str) -> str | None: return val +def _split_refs(value: str) -> list[str]: + refs: list[str] = [] + for chunk in value.replace("\n", ",").split(","): + ref = chunk.strip() + if ref: + refs.append(ref) + return refs + + +def _dialog_ref(dialog: dict[str, Any]) -> str: + username = str(dialog.get("username") or "") + if username: + return f"@{username}" + return str(dialog["id"]) + + class ParsingTab(Widget): DEFAULT_CSS = """ ParsingTab { height: 100%; width: 100%; } ParsingTab TabbedContent { height: 1fr; } + ParsingTab ContentSwitcher { height: 1fr; } + ParsingTab TabPane { height: 1fr; } .pform { padding: 1 2; height: 100%; overflow-y: auto; } .pform Label { margin-bottom: 1; } .pform Input { margin-bottom: 1; width: 44; } .pform Select { margin-bottom: 1; width: 44; } .prow { layout: horizontal; height: auto; margin-bottom: 1; } .prow Input { width: 28; margin-right: 1; } + .prow Select { width: 28; margin-right: 1; } + .prow Static { width: 28; margin-right: 1; height: 3; content-align: left middle; } .prow Button { margin-right: 1; } + .cfield { height: auto; margin-bottom: 1; } + .cfield Label { height: 1; margin-bottom: 0; } + .cfield Input { width: 52; margin-bottom: 0; } + .cfield Select { width: 52; margin-bottom: 0; } .plog { height: 12; margin-top: 1; } #groups_table { height: 10; margin-bottom: 1; } - #chats_table { height: 1fr; margin-bottom: 1; } + #chats_pane { overflow: hidden; } + #chats_controls { height: auto; } + #chats_table { height: 1fr; min-height: 10; margin-bottom: 1; } """ def __init__(self) -> None: super().__init__() self._parsed_users: list[Any] = [] self._fetched_dialogs: list[dict[str, Any]] = [] + self._selected_chats: set[str] = set() def compose(self) -> ComposeResult: choices = _session_select_choices() with TabbedContent(): with TabPane("Chats", id="tp_chats"), Widget(classes="pform", id="chats_pane"): - yield Label("[bold]Chat List[/bold]") - yield Select(choices, id="chats_sess", prompt="Select session") - yield Select(_KIND_LABELS, value="all", id="chats_kind") - with Widget(classes="prow"): - yield Button("Fetch Chats", id="btn_fetch_chats", variant="primary") - yield Static("", id="chats_status") + with Widget(id="chats_controls"): + with Widget(classes="cfield"): + yield Label("Session") + yield Select(choices, id="chats_sess", prompt="Session") + with Widget(classes="cfield"): + yield Label("Type") + yield Select(_KIND_LABELS, value="all", id="chats_kind") + with Widget(classes="cfield"): + yield Label("Fetched chats") + yield Static("0 selected: 0", id="chats_status") + with Widget(classes="cfield"): + yield Label("Output") + yield Input(placeholder="Output (default: exported_chats)", id="chats_out") + with Widget(classes="cfield"): + yield Label("History limit") + yield Input(placeholder="History limit (blank = all)", id="chats_limit") + with Widget(classes="prow"): + yield Button("Fetch Chats", id="btn_fetch_chats", variant="primary") + yield Button("All", id="btn_select_all_chats") + yield Button("Clear", id="btn_clear_chats") + yield Button( + "Export Selected JSON", id="btn_export_chats", variant="success" + ) + yield Button("Parse Users", id="btn_parse_chats", variant="success") yield DataTable(id="chats_table", cursor_type="row", zebra_stripes=True) with TabPane("Export Chat", id="tp_export"), Widget(classes="pform", id="export_pane"): yield Label("[bold]Export Chat History[/bold]") yield Select(choices, id="exp_sess", prompt="Select session") yield Input(placeholder="Chat: @group / username / ID", id="exp_chat") + with Widget(classes="prow"): + yield Button("Fetch Senders", id="btn_fetch_senders") + yield Select( + [("All Senders", "all")], + id="exp_sender", + prompt="Filter by sender", + value="all", + ) yield Input(placeholder="Output file (default: export_.json)", id="exp_out") + yield Input(placeholder="Media dir (blank = no media download)", id="exp_media") yield Input(placeholder="Limit (blank = all)", id="exp_limit") with Widget(classes="prow"): yield Button("Export JSON", id="btn_exp_json", variant="success") @@ -123,9 +177,12 @@ def compose(self) -> ComposeResult: with TabPane("Parse Users", id="tp_parse"), Widget(classes="pform", id="parse_pane"): yield Label("[bold]Parse Group Members[/bold]") yield Select(choices, id="pu_sess", prompt="Select session") - yield Input(placeholder="Group: @group / username / ID", id="pu_chat") + yield Input(placeholder="Groups: @group, @group2 / IDs", id="pu_chat") + yield Input(placeholder="Output JSON (default: parsed_users.json)", id="pu_out") + yield Input(placeholder="Avatar dir (default: parsed_avatars)", id="pu_avatars") with Widget(classes="prow"): yield Button("Parse", id="btn_parse", variant="success") + yield Button("Parse + Save JSON", id="btn_parse_save", variant="primary") yield Static("", id="pu_status") yield Label("[dim]Save parsed users to a group:[/dim]") with Widget(classes="prow"): @@ -156,27 +213,77 @@ def on_mount(self) -> None: def _build_chats_pane(self) -> None: pane = self.query_one("#chats_pane") choices = _session_select_choices() - pane.mount(Label("[bold]Chat List[/bold]")) - pane.mount(Select(choices, id="chats_sess", prompt="Select session")) - pane.mount(Select(_KIND_LABELS, value="all", id="chats_kind")) pane.mount( Widget( - Button("Fetch Chats", id="btn_fetch_chats", variant="primary"), - classes="prow", + Widget( + Label("Session"), + Select(choices, id="chats_sess", prompt="Session"), + classes="cfield", + ), + Widget( + Label("Type"), + Select(_KIND_LABELS, value="all", id="chats_kind"), + classes="cfield", + ), + Widget( + Label("Fetched chats"), + Static("0 selected: 0", id="chats_status"), + classes="cfield", + ), + Widget( + Label("Output"), + Input(placeholder="Output (default: exported_chats)", id="chats_out"), + classes="cfield", + ), + Widget( + Label("History limit"), + Input(placeholder="History limit (blank = all)", id="chats_limit"), + classes="cfield", + ), + Widget( + Button("Fetch Chats", id="btn_fetch_chats", variant="primary"), + Button("All", id="btn_select_all_chats"), + Button("Clear", id="btn_clear_chats"), + Button("Export Selected JSON", id="btn_export_chats", variant="success"), + Button("Parse Users", id="btn_parse_chats", variant="success"), + classes="prow", + ), + id="chats_controls", ) ) - pane.mount(Static("", id="chats_status")) pane.mount(DataTable(id="chats_table", cursor_type="row", zebra_stripes=True)) def _init_chats_table(self) -> None: tbl = self.query_one("#chats_table", DataTable) tbl.clear(columns=True) + tbl.add_column("", key="sel") tbl.add_column("", key="kind") tbl.add_column("Title", key="title") tbl.add_column("@Username", key="uname") tbl.add_column("ID", key="chat_id") tbl.add_column("Unread", key="unread") + def _sync_selected_chats(self) -> None: + tbl = self.query_one("#chats_table", DataTable) + available = {_dialog_ref(dialog) for dialog in self._fetched_dialogs} + self._selected_chats.intersection_update(available) + for dialog in self._fetched_dialogs: + ref = _dialog_ref(dialog) + with contextlib.suppress(Exception): + tbl.update_cell(ref, "sel", "●" if ref in self._selected_chats else "β—‹") + with contextlib.suppress(Exception): + self.query_one("#chats_status", Static).update( + f"{len(self._fetched_dialogs)} selected: {len(self._selected_chats)}" + ) + + def _select_all_chats(self) -> None: + self._selected_chats = {_dialog_ref(dialog) for dialog in self._fetched_dialogs} + self._sync_selected_chats() + + def _clear_selected_chats(self) -> None: + self._selected_chats.clear() + self._sync_selected_chats() + async def _do_fetch_chats(self) -> None: session = _get_session(self, "#chats_sess") if not session: @@ -190,17 +297,20 @@ async def _do_fetch_chats(self) -> None: self.query_one("#btn_fetch_chats", Button).disabled = True status.update("[dim]Fetching chats…[/dim]") self._init_chats_table() + self._selected_chats.clear() try: - dialogs = await tg_parsing.list_dialogs(session, kind=kind) + dialogs = await tg_parsing.list_dialogs(session, kind=kind, limit=0) self._fetched_dialogs = dialogs tbl = self.query_one("#chats_table", DataTable) for d in dialogs: + ref = _dialog_ref(d) icon = _KIND_ICONS.get(d["kind"], "❓") uname = f"@{d['username']}" if d["username"] else "β€”" unread = str(d["unread"]) if d["unread"] else "Β·" - tbl.add_row(icon, d["title"], uname, str(d["id"]), unread) - status.update(f"βœ… {len(dialogs)} chats fetched") + tbl.add_row("β—‹", icon, d["title"], uname, str(d["id"]), unread, key=ref) + self._select_all_chats() + status.update(f"βœ… {len(dialogs)} chats fetched; selected all") log.info("fetched %d dialogs from session %s (filter=%s)", len(dialogs), session, kind) except Exception as e: status.update(f"❌ {e}") @@ -208,6 +318,76 @@ async def _do_fetch_chats(self) -> None: finally: self.query_one("#btn_fetch_chats", Button).disabled = False + def _selected_chat_refs(self) -> list[str]: + return list(self._selected_chats) + + async def _do_export_chats(self) -> None: + session = _get_session(self, "#chats_sess") + chats = self._selected_chat_refs() + if not session or not chats: + self.app.notify("Select a session and choose chats from the table", severity="warning") + return + + limit_raw = self.query_one("#chats_limit", Input).value.strip() + limit = int(limit_raw) if limit_raw.isdigit() else 0 + out_raw = self.query_one("#chats_out", Input).value.strip() + dest_dir = Path(out_raw or "exported_chats").absolute() + media_dir = dest_dir / "media" + status = self.query_one("#chats_status", Static) + button = self.query_one("#btn_export_chats", Button) + button.disabled = True + + def _prog(chat: str, count: int) -> None: + status.update(f"[dim]{chat}: exported {count} messages…[/dim]") + + try: + exported = await tg_parsing.save_chats_history( + session, + list(chats), # type: ignore[arg-type] + dest_dir, + fmt="json", + limit=limit, + on_progress=_prog, + media_dir=media_dir, + ) + status.update(f"βœ… Exported {len(exported)} chats β†’ {dest_dir}; media β†’ {media_dir}") + except Exception as e: + status.update(f"❌ {e}") + log.error("bulk chat export error: %s", e) + finally: + button.disabled = False + + async def _do_parse_chats_from_list(self) -> None: + session = _get_session(self, "#chats_sess") + chats = self._selected_chat_refs() + if not session or not chats: + self.app.notify("Select a session and choose chats from the table", severity="warning") + return + + dest = Path("parsed_users.json").absolute() + avatar_dir = Path("parsed_avatars").absolute() + status = self.query_one("#chats_status", Static) + button = self.query_one("#btn_parse_chats", Button) + button.disabled = True + + def _prog(chat: str, count: int) -> None: + status.update(f"[dim]{chat}: parsed {count} users…[/dim]") + + try: + count = await tg_parsing.save_chats_members( + session, + list(chats), # type: ignore[arg-type] + dest, + avatar_dir=avatar_dir, + on_progress=_prog, + ) + status.update(f"βœ… Parsed {count} users β†’ {dest}; avatars β†’ {avatar_dir}") + except Exception as e: + status.update(f"❌ {e}") + log.error("bulk users parse error: %s", e) + finally: + button.disabled = False + def _build_export_pane(self) -> None: pane = self.query_one("#export_pane") choices = _session_select_choices() @@ -215,6 +395,7 @@ def _build_export_pane(self) -> None: pane.mount(Select(choices, id="exp_sess", prompt="Select session")) pane.mount(Input(placeholder="Chat: @group / username / ID", id="exp_chat")) pane.mount(Input(placeholder="Output file (default: export_.json)", id="exp_out")) + pane.mount(Input(placeholder="Media dir (blank = no media download)", id="exp_media")) pane.mount(Input(placeholder="Limit (blank = all)", id="exp_limit")) pane.mount( Widget( @@ -231,10 +412,13 @@ def _build_parse_pane(self) -> None: choices = _session_select_choices() pane.mount(Label("[bold]Parse Group Members[/bold]")) pane.mount(Select(choices, id="pu_sess", prompt="Select session")) - pane.mount(Input(placeholder="Group: @group / username / ID", id="pu_chat")) + pane.mount(Input(placeholder="Groups: @group, @group2 / IDs", id="pu_chat")) + pane.mount(Input(placeholder="Output JSON (default: parsed_users.json)", id="pu_out")) + pane.mount(Input(placeholder="Avatar dir (default: parsed_avatars)", id="pu_avatars")) pane.mount( Widget( Button("Parse", id="btn_parse", variant="success"), + Button("Parse + Save JSON", id="btn_parse_save", variant="primary"), classes="prow", ) ) @@ -294,12 +478,22 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: bid = event.button.id if bid == "btn_fetch_chats": await self._do_fetch_chats() + elif bid == "btn_select_all_chats": + self._select_all_chats() + elif bid == "btn_clear_chats": + self._clear_selected_chats() + elif bid == "btn_export_chats": + await self._do_export_chats() + elif bid == "btn_parse_chats": + await self._do_parse_chats_from_list() elif bid == "btn_exp_json": await self._do_export("json") elif bid == "btn_exp_txt": await self._do_export("txt") elif bid == "btn_parse": - await self._do_parse() + await self._do_parse(save=False) + elif bid == "btn_parse_save": + await self._do_parse(save=True) elif bid == "btn_save_grp": self._save_group() elif bid == "btn_grp_refresh": @@ -312,6 +506,45 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: await self._do_snapshot() elif bid == "btn_prof_history": self._show_profile_history() + elif bid == "btn_fetch_senders": + await self._do_fetch_senders() + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + if event.data_table.id != "chats_table": + return + key = str(event.row_key.value) if event.row_key.value is not None else "" + if not key: + return + if key in self._selected_chats: + self._selected_chats.discard(key) + else: + self._selected_chats.add(key) + self._sync_selected_chats() + + async def _do_fetch_senders(self) -> None: + session = _get_session(self, "#exp_sess") + chat = self.query_one("#exp_chat", Input).value.strip() + if not session or not chat: + self.app.notify( + "Select a session and enter a chat to fetch senders", severity="warning" + ) + return + + status = self.query_one("#exp_status", Static) + status.update("[dim]Fetching unique senders from history…[/dim]") + try: + senders = await tg_parsing.get_chat_senders(session, chat, limit=500) + sel = self.query_one("#exp_sender", Select) + choices = [("All Senders", "all")] + [(s["label"], str(s["id"])) for s in senders] + if hasattr(sel, "set_options"): + sel.set_options(choices) # type: ignore[attr-defined,reportGeneralTypeIssues] + else: + sel.options = choices # type: ignore[attr-defined,reportGeneralTypeIssues] + sel.value = "all" + status.update(f"βœ… Found {len(senders)} senders") + except Exception as e: + status.update(f"❌ {e}") + log.error("fetch senders error: %s", e) async def _do_export(self, fmt: str) -> None: session = _get_session(self, "#exp_sess") @@ -322,8 +555,17 @@ async def _do_export(self, fmt: str) -> None: limit_raw = self.query_one("#exp_limit", Input).value.strip() out_raw = self.query_one("#exp_out", Input).value.strip() + media_raw = self.query_one("#exp_media", Input).value.strip() limit = int(limit_raw) if limit_raw.isdigit() else 0 - dest = Path(out_raw or f"export_{chat.lstrip('@')}.{fmt}") + dest = Path(out_raw or f"export_{chat.lstrip('@')}.{fmt}").absolute() + media_dir = Path(media_raw).absolute() if media_raw else None + + sender_val = self.query_one("#exp_sender", Select).value + sender_ids = None + if sender_val and str(sender_val) != "all" and str(sender_val) != "Select.BLANK": + with contextlib.suppress(ValueError): + sender_ids = [int(str(sender_val))] + status = self.query_one("#exp_status", Static) log_view = self.query_one("#export_log", RichLog) @@ -336,10 +578,18 @@ def _prog(n: int) -> None: try: count = await tg_parsing.save_chat_history( - session, chat, dest, fmt=fmt, limit=limit, on_progress=_prog + session, + chat, + dest, + fmt=fmt, + limit=limit, + on_progress=_prog, + media_dir=media_dir, + sender_ids=sender_ids, ) - status.update(f"βœ… {count} messages β†’ {dest}") - log_view.write(f"βœ… Export complete: {dest} ({count} messages)") + media_note = f"; media β†’ {media_dir}" if media_dir else "" + status.update(f"βœ… {count} messages β†’ {dest}{media_note}") + log_view.write(f"βœ… Export complete: {dest} ({count} messages){media_note}") log.info("export done: %s messages from %s -> %s", count, chat, dest) except Exception as e: status.update(f"❌ {e}") @@ -349,22 +599,30 @@ def _prog(n: int) -> None: for bid in ("btn_exp_json", "btn_exp_txt"): self.query_one(f"#{bid}", Button).disabled = False - async def _do_parse(self) -> None: + async def _do_parse(self, *, save: bool = False) -> None: session = _get_session(self, "#pu_sess") - chat = self.query_one("#pu_chat", Input).value.strip() - if not session or not chat: - self.app.notify("Select a session and enter a group", severity="warning") + chats = _split_refs(self.query_one("#pu_chat", Input).value) + if not session or not chats: + self.app.notify("Select a session and enter one or more groups", severity="warning") return status = self.query_one("#pu_status", Static) log_view = self.query_one("#parse_log", RichLog) - self.query_one("#btn_parse", Button).disabled = True + for bid in ("btn_parse", "btn_parse_save"): + self.query_one(f"#{bid}", Button).disabled = True - def _prog(n: int) -> None: - status.update(f"[dim]Parsed {n} users…[/dim]") + def _prog(chat: str, count: int) -> None: + status.update(f"[dim]{chat}: parsed {count} users…[/dim]") try: - users = await tg_parsing.parse_chat_members(session, chat, on_progress=_prog) + avatar_dir_raw = self.query_one("#pu_avatars", Input).value.strip() + avatar_dir = Path(avatar_dir_raw or "parsed_avatars").absolute() + users = await tg_parsing.parse_chats_members( + session, + list(chats), # type: ignore[arg-type] + avatar_dir=avatar_dir, + on_progress=_prog, + ) self._parsed_users = [ { "id": u.id, @@ -372,19 +630,39 @@ def _prog(n: int) -> None: "first_name": u.first_name, "last_name": u.last_name, "phone": u.phone, + "avatar_path": u.avatar_path, + "bio": u.bio, + "song": u.song, + "birthday": u.birthday, + "gifts": u.gifts, + "source_chat_id": u.source_chat_id, + "source_chat_title": u.source_chat_title, + "source_chat_username": u.source_chat_username, } for u in users ] - status.update(f"βœ… Parsed {len(users)} users") - log_view.write(f"βœ… Parsed {len(users)} users from {chat!r}") - log.info("parsed %d users from %s", len(users), chat) + if save: + out_raw = self.query_one("#pu_out", Input).value.strip() + dest = Path(out_raw or "parsed_users.json").absolute() + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text( + json.dumps(self._parsed_users, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + status.update(f"βœ… Parsed {len(users)} users β†’ {dest}; avatars β†’ {avatar_dir}") + log_view.write(f"βœ… Parsed users saved: {dest} ({len(users)} users)") + else: + status.update(f"βœ… Parsed {len(users)} users") + log_view.write(f"βœ… Parsed {len(users)} users from {len(chats)} chats") + log.info("parsed %d users from %d chats", len(users), len(chats)) self.query_one("#btn_save_grp", Button).disabled = False except Exception as e: status.update(f"❌ {e}") log_view.write(f"❌ Parse failed: {e}") log.error("parse error: %s", e) finally: - self.query_one("#btn_parse", Button).disabled = False + for bid in ("btn_parse", "btn_parse_save"): + self.query_one(f"#{bid}", Button).disabled = False def _save_group(self) -> None: gname = self.query_one("#pu_grp_name", Input).value.strip() @@ -457,6 +735,9 @@ async def _do_snapshot(self) -> None: f"βœ… Snapshot: " f"{info['first_name']} {info['last_name']} " f"@{info['username'] or 'β€”'} " + f"birthday:{info.get('birthday') or 'β€”'} " + f"song:{info.get('song') or 'β€”'} " + f"gifts:{len(info.get('gifts') or [])} " f"[dim]{info['timestamp']}[/dim]" ) log.info("snapshot saved for %s", user_id) @@ -480,5 +761,8 @@ def _show_profile_history(self) -> None: f" [dim]{s['timestamp']}[/dim] " f"{s.get('first_name','')} {s.get('last_name','')} " f"@{s.get('username') or 'β€”'} " + f"[dim]birthday:[/dim] {s.get('birthday') or 'β€”'} " + f"[dim]song:[/dim] {s.get('song') or 'β€”'} " + f"[dim]gifts:[/dim] {len(s.get('gifts') or [])} " f"[dim]bio:[/dim] {s.get('bio') or 'β€”'}" ) diff --git a/src/accxus/ui/tg/registration.py b/src/accxus/ui/tg/registration.py new file mode 100644 index 0000000..aa3d12a --- /dev/null +++ b/src/accxus/ui/tg/registration.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +import asyncio +import logging +import random +import string +import time +from typing import Any + +from rigi import ComposeResult, Widget +from rigi.widgets import Button, Input, Label, RichLog, Select, TextArea + +import accxus.config as cfg +from accxus.core.sms.manager import SmsManager +from accxus.platforms.telegram import client as tg_client +from accxus.platforms.telegram import sessions as tg_sessions +from accxus.types.telegram import SessionInfo, SessionStatus + +log = logging.getLogger(__name__) + + +class RegistrationTab(Widget): + DEFAULT_CSS = """ + RegistrationTab { + height: 100%; + width: 100%; + padding: 1 2; + } + #reg_top { + layout: horizontal; + height: auto; + } + #reg_left { + width: 1fr; + padding: 0 1; + } + #reg_center { + width: 1fr; + padding: 0 1; + } + #reg_right { + width: 1fr; + padding: 0 1; + } + #reg_country { + height: 3; + margin-bottom: 1; + } + #reg_proxy { + height: 3; + margin-bottom: 1; + } + #reg_count { + height: 3; + margin-bottom: 1; + } + #reg_btn_row { + layout: horizontal; + height: auto; + margin-bottom: 1; + } + #reg_btn_row Button { + margin-right: 1; + } + #reg_stats { + height: auto; + margin-bottom: 1; + } + #reg_first_names { + height: 6; + margin-bottom: 1; + } + #reg_usernames { + height: 6; + margin-bottom: 1; + } + #reg_log { + height: 1fr; + border: solid #30363d; + padding: 0 1; + } + """ + + def __init__(self) -> None: + super().__init__() + self._stop_event = asyncio.Event() + self._worker: Any = None + + def compose(self) -> ComposeResult: + with Widget(id="reg_top"): + with Widget(id="reg_left"): + yield Label("[bold]Country[/bold]") + yield Select(id="reg_country", options=[], prompt="Select country") + yield Label("[bold]Proxy[/bold]") + yield Select(id="reg_proxy", options=[], prompt="No proxy") + yield Label("[bold]Count[/bold]") + yield Input(value="1", id="reg_count", placeholder="How many accounts") + with Widget(id="reg_btn_row"): + yield Button("Start", id="btn_start", variant="success") + yield Button("Stop", id="btn_stop", variant="error", disabled=True) + yield Label("", id="reg_stats") + with Widget(id="reg_center"): + yield Label("[bold]First names (one per line)[/bold]") + yield TextArea(id="reg_first_names", language=None) + with Widget(id="reg_right"): + yield Label("[bold]Usernames (templates: random, random:N, random:word)[/bold]") + yield TextArea(id="reg_usernames", language=None) + yield RichLog(id="reg_log", markup=True) + + def on_mount(self) -> None: + self.run_worker(self._load_form_data()) + + def _log(self, text: str) -> None: + try: + self.query_one("#reg_log", RichLog).write(text) + except Exception: + pass + + async def _load_form_data(self) -> None: + proxy_options: list[tuple[str, str]] = [("No proxy", "")] + for p in cfg.config.proxies: + proxy_options.append((p.display_name or p.to_url(), p.to_url())) + try: + self.query_one("#reg_proxy", Select).set_options(proxy_options) + except Exception: + pass + + self._log("[dim]Loading countries from SMS providers...[/dim]") + try: + manager = SmsManager.from_config(cfg.config.sms_providers) + if not manager.active_providers: + self._log("[yellow]No SMS providers configured[/yellow]") + return + results = await manager.list_countries_for_service("tg") + options: list[tuple[str, str]] = [] + for provider_name, countries in results.items(): + for cid, name, price in countries: + label = f"{name} ({provider_name}) β€” {price:.2f} β‚½" + value = f"{provider_name}:{cid}:{price:.2f}" + options.append((label, value)) + if options: + self.query_one("#reg_country", Select).set_options(options) + self._log(f"[green]Loaded {len(options)} countries[/green]") + else: + self._log("[yellow]No countries available for Telegram[/yellow]") + except Exception as e: + self._log(f"[red]Failed to load countries: {e}[/red]") + + def _update_stats(self) -> None: + count_str = self.query_one("#reg_count", Input).value.strip() + count = int(count_str) if count_str.isdigit() else 0 + country_val = self.query_one("#reg_country", Select).value + if isinstance(country_val, str) and ":" in country_val: + try: + price = float(country_val.rsplit(":", 1)[1]) + total = price * count + self.query_one("#reg_stats", Label).update( + f"Price: {price:.2f} β‚½ | Total: {total:.2f} β‚½" + ) + except Exception: + self.query_one("#reg_stats", Label).update("") + else: + self.query_one("#reg_stats", Label).update("") + + def on_select_changed(self, event: Select.Changed) -> None: + if event.select.id == "reg_country": + self._update_stats() + + async def on_button_pressed(self, event: Button.Pressed) -> None: + bid = event.button.id + if bid == "btn_start": + self._stop_event.clear() + self._worker = self.run_worker(self._do_bulk_register()) + self.query_one("#btn_start", Button).disabled = True + self.query_one("#btn_stop", Button).disabled = False + elif bid == "btn_stop": + self._stop_event.set() + self._log("[yellow]Stop requested...[/yellow]") + + async def _do_bulk_register(self) -> None: + try: + count_str = self.query_one("#reg_count", Input).value.strip() + count = int(count_str) if count_str.isdigit() else 1 + country_val = self.query_one("#reg_country", Select).value + + if not isinstance(country_val, str) or ":" not in country_val: + self._log("[red]Select a country first[/red]") + return + + parts = country_val.split(":") + provider_name = parts[0] + country_id = int(parts[1]) + + proxy_val = self.query_one("#reg_proxy", Select).value + proxy = None + if isinstance(proxy_val, str) and proxy_val: + proxy = next( + ( + p + for p in cfg.config.proxies + if p.to_url() == proxy_val or p.display_name == proxy_val + ), + None, + ) + + first_names_text = self.query_one("#reg_first_names", TextArea).text + first_names = [line.strip() for line in first_names_text.splitlines() if line.strip()] + if not first_names: + first_names = ["User"] + + usernames_text = self.query_one("#reg_usernames", TextArea).text + username_templates = [line.strip() for line in usernames_text.splitlines() if line.strip()] + + manager = SmsManager.from_config(cfg.config.sms_providers) + + self._log(f"[bold]Starting bulk registration: {count} account(s)[/bold]") + + for i in range(count): + if self._stop_event.is_set(): + self._log("[yellow]Stopped[/yellow]") + break + + self._log(f"[dim]--- Account {i + 1}/{count} ---[/dim]") + ok = await self._register_one( + manager, + provider_name, + country_id, + proxy, + first_names, + username_templates, + ) + if not ok and not self._stop_event.is_set(): + self._log("[red]Failed, continuing...[/red]") + + self._log("[bold]Bulk registration finished[/bold]") + finally: + self.query_one("#btn_start", Button).disabled = False + self.query_one("#btn_stop", Button).disabled = True + + async def _register_one( + self, + manager: SmsManager, + provider_name: str, + country_id: int, + proxy: Any, + first_names: list[str], + username_templates: list[str], + ) -> bool: + from pyrogram.errors import FloodWait, PhoneNumberOccupied + + max_retries = 5 + for _ in range(max_retries): + if self._stop_event.is_set(): + return False + + try: + activation = await manager.get_number( + "tg", country_id, provider=provider_name + ) + self._log(f"[green]Got number: {activation.phone}[/green]") + except Exception as e: + self._log(f"[red]Get number failed: {e}[/red]") + return False + + session_name = f"reg_{activation.phone.replace('+', '')}_{int(time.time())}" + client = tg_client.make_client(session_name, proxy=proxy) + + try: + await client.connect() + sent = await client.send_code(activation.phone) + except PhoneNumberOccupied: + self._log( + "[yellow]Number occupied, cancelling and waiting 2 min...[/yellow]" + ) + await manager.cancel(activation) + await client.disconnect() + for _ in range(120): + if self._stop_event.is_set(): + return False + await asyncio.sleep(1) + continue + except FloodWait as e: + self._log(f"[red]FloodWait {e.value}s[/red]") + await manager.cancel(activation) + await client.disconnect() + return False + except Exception as e: + self._log(f"[red]Send code failed: {e}[/red]") + await manager.cancel(activation) + await client.disconnect() + return False + + self._log("[dim]Waiting for SMS code...[/dim]") + code = await manager.wait_for_code(activation, timeout=120) + if not code: + self._log("[red]SMS not received[/red]") + await manager.cancel(activation) + await client.disconnect() + return False + + self._log(f"[dim]Code: {code}[/dim]") + + first = random.choice(first_names) + try: + await client.sign_in( + phone_number=activation.phone, + phone_code_hash=sent.phone_code_hash, + phone_code=code, + ) + except PhoneNumberOccupied: + self._log( + "[yellow]Occupied during sign-in, cancelling and waiting 2 min...[/yellow]" + ) + await manager.cancel(activation) + await client.disconnect() + for _ in range(120): + if self._stop_event.is_set(): + return False + await asyncio.sleep(1) + continue + except Exception: + # Account does not exist yet β€” sign up + try: + await client.sign_up( + phone_number=activation.phone, + phone_code_hash=sent.phone_code_hash, + first_name=first, + last_name="", + ) + except Exception as e: + self._log(f"[red]Signup failed: {e}[/red]") + await manager.cancel(activation) + await client.disconnect() + return False + + username = "" + if username_templates: + username = self._generate_username(username_templates) + try: + await client.set_username(username) + self._log(f"[dim]Username: @{username}[/dim]") + except Exception as e: + self._log(f"[yellow]Username error: {e}[/yellow]") + + try: + me = await client.get_me() + info = SessionInfo( + name=session_name, + phone=activation.phone, + first_name=first, + last_name="", + username=username, + user_id=me.id, + dc_id=await client.storage.dc_id(), + status=SessionStatus.VALID, + ) + tg_sessions.update_metadata(session_name, info) + except Exception as e: + self._log(f"[yellow]Metadata save error: {e}[/yellow]") + + await client.disconnect() + await manager.confirm(activation) + self._log(f"[green]βœ“ Saved: {session_name}[/green]") + return True + + self._log("[red]Max retries exceeded[/red]") + return False + + def _generate_username(self, templates: list[str]) -> str: + template = random.choice(templates) + if template == "random": + return "".join( + random.choices(string.ascii_lowercase + string.digits, k=8) + ) + if template.startswith("random:"): + spec = template.split(":", 1)[1] + if spec.isdigit(): + return "".join( + random.choices(string.ascii_lowercase + string.digits, k=int(spec)) + ) + if spec == "word": + words = [ + "alpha", + "beta", + "gamma", + "delta", + "neo", + "meta", + "cyber", + "flux", + "nova", + "zen", + ] + return random.choice(words) + "".join( + random.choices(string.digits, k=3) + ) + return template diff --git a/src/accxus/ui/tg/sessions.py b/src/accxus/ui/tg/sessions.py index 1c34777..1632790 100644 --- a/src/accxus/ui/tg/sessions.py +++ b/src/accxus/ui/tg/sessions.py @@ -1,18 +1,24 @@ from __future__ import annotations import asyncio +import contextlib import logging +from pathlib import Path from typing import Any from rigi import ComposeResult, ModalScreen, Widget from rigi.widgets import ( + ActionMenuItemData, Button, DataTable, + Image, Input, Label, Static, ) +from textual.events import Click, MouseDown +import accxus.config as cfg from accxus.platforms.telegram import ( client as tg_client, ) @@ -27,6 +33,9 @@ log = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# LoginScreen +# --------------------------------------------------------------------------- class LoginScreen(ModalScreen[str | None]): DEFAULT_CSS = """ LoginScreen { align: center middle; } @@ -41,8 +50,6 @@ class LoginScreen(ModalScreen[str | None]): #lbox Input { margin-bottom: 1; } #lbtn_row { layout: horizontal; height: auto; margin-top: 1; } #lbtn_row Button { margin-right: 1; } - #code_row, #twofa_row { display: none; } - #code_row.show, #twofa_row.show { display: block; } """ def __init__(self) -> None: @@ -56,18 +63,28 @@ def __init__(self) -> None: def compose(self) -> ComposeResult: with Widget(id="lbox"): yield Label("[bold] Add New Telegram Session[/bold]\n") - yield Input(placeholder="Session name (e.g. main)", id="inp_name") - yield Input(placeholder="Phone (+79001234567)", id="inp_phone") - yield Button("Send Code", id="btn_send", variant="primary") - with Widget(id="code_row"): + with Widget(id="step1"): + yield Input(placeholder="Session name (e.g. main)", id="inp_name") + yield Input(placeholder="Phone (+79001234567)", id="inp_phone") + yield Button("Send Code", id="btn_send", variant="primary") + with Widget(id="step2"): yield Input(placeholder="Code from Telegram app", id="inp_code") - with Widget(id="twofa_row"): + with Widget(id="step3"): yield Input(placeholder="2FA password", id="inp_2fa", password=True) with Widget(id="lbtn_row"): yield Button("Login", id="btn_login", variant="success", disabled=True) yield Button("Cancel", id="btn_cancel") yield Static("", id="lstat") + def on_mount(self) -> None: + self.query_one("#step2").styles.display = "none" + self.query_one("#step3").styles.display = "none" + + def _show_step(self, step: int) -> None: + for i in (1, 2, 3): + widget = self.query_one(f"#step{i}", Widget) + widget.styles.display = "block" if i == step else "none" + async def on_button_pressed(self, event: Button.Pressed) -> None: bid = event.button.id if bid == "btn_cancel": @@ -102,7 +119,7 @@ async def _send_code(self) -> None: await self._client.connect() sent = await self._client.send_code(phone) self._hash = sent.phone_code_hash - self.query_one("#code_row").add_class("show") + self._show_step(2) self.query_one("#btn_login", Button).disabled = False stat.update(f"[green]Code sent to {phone} β€” enter it below[/green]") except FloodWait as e: @@ -146,7 +163,7 @@ async def _do_login(self) -> None: ) except SessionPasswordNeeded: self._needs_2fa = True - self.query_one("#twofa_row").add_class("show") + self._show_step(3) self.query_one("#btn_login", Button).disabled = False stat.update("[yellow]2FA enabled β€” enter password above, then click Login[/yellow]") return @@ -187,6 +204,8 @@ async def _finish(self) -> None: first_name=me.first_name or "", last_name=me.last_name or "", username=me.username or "", + user_id=me.id, + dc_id=await self._client.storage.dc_id(), ) tg_sessions.update_metadata(self._name, info) await self._client.disconnect() @@ -197,6 +216,9 @@ async def _finish(self) -> None: stat.update(f"[red]{e}[/red]") +# --------------------------------------------------------------------------- +# ImportSessionScreen +# --------------------------------------------------------------------------- class ImportSessionScreen(ModalScreen[str | None]): DEFAULT_CSS = """ ImportSessionScreen { align: center middle; } @@ -230,7 +252,6 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: await self._do_import() async def _do_import(self) -> None: - from pathlib import Path src_raw = self.query_one("#inp_src", Input).value.strip() name = self.query_one("#inp_name", Input).value.strip() @@ -250,16 +271,87 @@ async def _do_import(self) -> None: self.query_one("#btn_imp", Button).disabled = False +# --------------------------------------------------------------------------- +# RenameScreen +# --------------------------------------------------------------------------- +class RenameScreen(ModalScreen[str | None]): + DEFAULT_CSS = """ + RenameScreen { align: center middle; } + #ren_box { + width: 50; + height: auto; + border: round $primary; + padding: 1 2; + background: $surface; + } + #ren_box Input { margin-bottom: 1; } + #ren_row { layout: horizontal; height: auto; margin-top: 1; } + #ren_row Button { margin-right: 1; } + """ + + def __init__(self, old_name: str) -> None: + super().__init__() + self._old_name = old_name + + def compose(self) -> ComposeResult: + with Widget(id="ren_box"): + yield Label(f"[bold] Rename β€” {self._old_name}[/bold]\n") + yield Input(placeholder="New session name", id="inp_name") + with Widget(id="ren_row"): + yield Button("Rename", id="btn_rename", variant="success") + yield Button("Cancel", id="btn_cancel") + yield Static("", id="ren_stat") + + async def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn_cancel": + self.dismiss(None) + elif event.button.id == "btn_rename": + new_name = self.query_one("#inp_name", Input).value.strip() + stat = self.query_one("#ren_stat", Static) + if not new_name: + stat.update("[red]Enter a name[/red]") + return + old_path = tg_sessions.session_path(self._old_name) + new_path = tg_sessions.session_path(new_name) + if new_path.exists(): + stat.update("[red]Name already exists[/red]") + return + old_path.rename(new_path) + meta = tg_sessions.load_metadata() + if self._old_name in meta: + meta[new_name] = meta.pop(self._old_name) + tg_sessions.save_metadata(meta) + self.dismiss(new_name) + + +# --------------------------------------------------------------------------- +# EditProfileScreen +# --------------------------------------------------------------------------- class EditProfileScreen(ModalScreen[bool]): DEFAULT_CSS = """ EditProfileScreen { align: center middle; } #ep_box { - width: 56; + width: 60; height: auto; border: round $primary; padding: 1 2; background: $surface; } + #ep_avatar_row { + layout: horizontal; + height: auto; + margin-bottom: 1; + } + #ep_avatar_img { + width: 12; + height: 6; + margin-right: 2; + } + #ep_avatar_btns { + width: 1fr; + height: auto; + } + #ep_avatar_btns Button { margin-bottom: 1; } #ep_box Input { margin-bottom: 1; } #ep_row { layout: horizontal; height: auto; margin-top: 1; } #ep_row Button { margin-right: 1; } @@ -273,19 +365,70 @@ def __init__(self, session_name: str, info: SessionInfo) -> None: def compose(self) -> ComposeResult: with Widget(id="ep_box"): yield Label(f"[bold] Edit Profile β€” {self._session}[/bold]\n") - yield Input(value=self._info.first_name, placeholder="First name", id="inp_first") - yield Input(value=self._info.last_name, placeholder="Last name", id="inp_last") - yield Input(value=self._info.bio, placeholder="Bio", id="inp_bio") + with Widget(id="ep_avatar_row"): + yield Image(id="ep_avatar_img", width=12, height=6) + with Widget(id="ep_avatar_btns"): + yield Button("Load from TG", id="btn_load_avatar") + yield Input( + placeholder="Path to new avatar", + id="inp_avatar_path", + ) + yield Button("Set Avatar", id="btn_set_avatar", variant="primary") + yield Button("Delete Avatar", id="btn_del_avatar", variant="error") + yield Input( + value=self._info.first_name, + placeholder="First name", + id="inp_first", + ) + yield Input( + value=self._info.last_name, + placeholder="Last name", + id="inp_last", + ) + yield Input( + value=self._info.username or "", + placeholder="Username", + id="inp_username", + disabled=True, + ) + yield Input( + value=self._info.bio, + placeholder="Bio", + id="inp_bio", + ) + yield Input( + placeholder="Date of birth (not editable)", + id="inp_dob", + disabled=True, + ) with Widget(id="ep_row"): yield Button("Save", id="btn_save", variant="success") yield Button("Cancel", id="btn_cancel") yield Static("", id="ep_stat") + def on_mount(self) -> None: + self.run_worker(self._load_avatar()) + + async def _load_avatar(self) -> None: + try: + path = await tg_profile.download_avatar(self._session) + if path: + self.query_one("#ep_avatar_img", Image).load(path) + except Exception: + pass + async def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "btn_cancel": + bid = event.button.id + if bid == "btn_cancel": self.dismiss(False) - elif event.button.id == "btn_save": + elif bid == "btn_save": await self._save() + elif bid == "btn_load_avatar": + self.run_worker(self._load_avatar()) + elif bid == "btn_set_avatar": + self.run_worker(self._set_avatar()) + elif bid == "btn_del_avatar": + self.run_worker(self._delete_avatar()) async def _save(self) -> None: first = self.query_one("#inp_first", Input).value.strip() @@ -305,143 +448,39 @@ async def _save(self) -> None: stat.update(f"[red]{e}[/red]") self.query_one("#btn_save", Button).disabled = False - -class SetAvatarScreen(ModalScreen[bool]): - DEFAULT_CSS = """ - SetAvatarScreen { align: center middle; } - #av_box { - width: 60; - height: auto; - border: round $primary; - padding: 1 2; - background: $surface; - } - #av_box Input { margin-bottom: 1; } - #av_row { layout: horizontal; height: auto; margin-top: 1; } - #av_row Button { margin-right: 1; } - """ - - def __init__(self, session_name: str) -> None: - super().__init__() - self._session = session_name - - def compose(self) -> ComposeResult: - with Widget(id="av_box"): - yield Label(f"[bold] Set Avatar β€” {self._session}[/bold]\n") - yield Label("[dim]Full path to an image file (jpg / png)[/dim]") - yield Input(placeholder="/home/user/photo.jpg", id="inp_path") - with Widget(id="av_row"): - yield Button("Upload", id="btn_upload", variant="success") - yield Button("Cancel", id="btn_cancel") - yield Static("", id="av_stat") - - async def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "btn_cancel": - self.dismiss(False) - elif event.button.id == "btn_upload": - await self._upload() - - async def _upload(self) -> None: - path = self.query_one("#inp_path", Input).value.strip() - stat = self.query_one("#av_stat", Static) - stat.update("[dim]Uploading…[/dim]") - self.query_one("#btn_upload", Button).disabled = True + async def _set_avatar(self) -> None: + path = self.query_one("#inp_avatar_path", Input).value.strip() + stat = self.query_one("#ep_stat", Static) + if not path: + stat.update("[red]Enter avatar path[/red]") + return + stat.update("[dim]Uploading avatar…[/dim]") try: await tg_profile.set_avatar(self._session, path) - stat.update("[green]βœ“ Avatar updated[/green]") - await asyncio.sleep(0.4) - self.dismiss(True) + stat.update("[green]βœ“ Avatar set[/green]") + self.query_one("#ep_avatar_img", Image).load(path) except Exception as e: stat.update(f"[red]{e}[/red]") - self.query_one("#btn_upload", Button).disabled = False - - -class ExportChatScreen(ModalScreen[bool]): - DEFAULT_CSS = """ - ExportChatScreen { align: center middle; } - #ec_box { - width: 62; - height: auto; - border: round $primary; - padding: 1 2; - background: $surface; - } - #ec_box Input { margin-bottom: 1; } - #ec_row { layout: horizontal; height: auto; margin-top: 1; } - #ec_row Button { margin-right: 1; } - """ - - def __init__(self, session_name: str) -> None: - super().__init__() - self._session = session_name - - def compose(self) -> ComposeResult: - with Widget(id="ec_box"): - yield Label(f"[bold] Export Chat β€” {self._session}[/bold]\n") - yield Input(placeholder="Chat: @group / username / chat_id", id="inp_chat") - yield Input(placeholder="Output file (default: export_.json)", id="inp_out") - yield Input(placeholder="Limit messages (blank = all)", id="inp_limit") - with Widget(id="ec_row"): - yield Button("Export JSON", id="btn_json", variant="success") - yield Button("Export TXT", id="btn_txt", variant="primary") - yield Button("Cancel", id="btn_cancel") - yield Static("", id="ec_stat") - - async def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "btn_cancel": - self.dismiss(False) - elif event.button.id in ("btn_json", "btn_txt"): - fmt = "json" if event.button.id == "btn_json" else "txt" - await self._do_export(fmt) - - async def _do_export(self, fmt: str) -> None: - from pathlib import Path - - from accxus.platforms.telegram import parsing as tg_parsing - - chat = self.query_one("#inp_chat", Input).value.strip() - out_raw = self.query_one("#inp_out", Input).value.strip() - limit_raw = self.query_one("#inp_limit", Input).value.strip() - stat = self.query_one("#ec_stat", Static) - if not chat: - stat.update("[red]Enter a chat username or ID[/red]") - return - limit = int(limit_raw) if limit_raw.isdigit() else 0 - dest = Path(out_raw or f"export_{chat.lstrip('@')}.{fmt}") - stat.update("[dim]Exporting…[/dim]") - for btn_id in ("btn_json", "btn_txt"): - self.query_one(f"#{btn_id}", Button).disabled = True - - def _progress(n: int) -> None: - stat.update(f"[dim]Fetched {n} messages…[/dim]") + async def _delete_avatar(self) -> None: + stat = self.query_one("#ep_stat", Static) + stat.update("[dim]Deleting avatar…[/dim]") try: - count = await tg_parsing.save_chat_history( - self._session, chat, dest, fmt=fmt, limit=limit, on_progress=_progress - ) - stat.update(f"[green]βœ“ Exported {count} messages β†’ {dest}[/green]") + await tg_profile.delete_avatar(self._session) + stat.update("[green]βœ“ Avatar deleted[/green]") + self.query_one("#ep_avatar_img", Image).load("") except Exception as e: stat.update(f"[red]{e}[/red]") - finally: - for btn_id in ("btn_json", "btn_txt"): - self.query_one(f"#{btn_id}", Button).disabled = False +# --------------------------------------------------------------------------- +# SessionsTab +# --------------------------------------------------------------------------- class SessionsTab(Widget): DEFAULT_CSS = """ SessionsTab { - layout: horizontal; height: 100%; width: 100%; - } - #sess_left { - width: 40; - height: 100%; - padding: 1; - } - #sess_right { - width: 1fr; - height: 100%; padding: 1 2; } #sess_top_row { @@ -451,43 +490,14 @@ class SessionsTab(Widget): } #sess_top_row Button { margin-right: 1; } #sess_table { height: 1fr; } - #sess_bot_row { - layout: horizontal; - height: auto; - margin-top: 1; - } - #sess_bot_row Button { margin-right: 1; } - #detail_info { height: auto; margin-bottom: 1; } - #action_row { - layout: horizontal; - height: auto; - } - #action_row Button { margin-right: 1; margin-bottom: 1; } """ - _accessed: str | None = None - _info: SessionInfo | None = None - def compose(self) -> ComposeResult: - with Widget(id="sess_left"): - with Widget(id="sess_top_row"): - yield Button("οΌ‹ Add", id="btn_add", variant="primary") - yield Button("Import", id="btn_import") - yield Button("βœ“ Check All", id="btn_check_all") - yield DataTable(id="sess_table", cursor_type="row", zebra_stripes=True) - with Widget(id="sess_bot_row"): - yield Button("Access", id="btn_access", variant="success") - yield Button("Delete", id="btn_delete", variant="error") - - with Widget(id="sess_right"): - yield Static( - "[dim]Select a session, then click [bold]Access[/bold] to load its info[/dim]", - id="detail_info", - ) - with Widget(id="action_row"): - yield Button("Edit Profile", id="btn_edit", disabled=True) - yield Button("Set Avatar", id="btn_avatar", disabled=True) - yield Button("Export Chat", id="btn_export", disabled=True) + with Widget(id="sess_top_row"): + yield Button("οΌ‹ Add", id="btn_add", variant="primary") + yield Button("Import", id="btn_import") + yield Button("Refresh", id="btn_refresh") + yield DataTable(id="sess_table", cursor_type="row", zebra_stripes=True) def on_mount(self) -> None: self._reload_table() @@ -495,12 +505,24 @@ def on_mount(self) -> None: def _reload_table(self) -> None: tbl = self.query_one("#sess_table", DataTable) tbl.clear(columns=True) - tbl.add_column("Session", key="name") + tbl.add_column("Name", key="name") + tbl.add_column("ID", key="id") tbl.add_column("Phone", key="phone") + tbl.add_column("Username", key="username") + tbl.add_column("DC", key="dc") tbl.add_column("Status", key="status") for info in tg_sessions.list_sessions(): status_str = self._status_markup(info.status) - tbl.add_row(info.name, info.phone or "β€”", status_str, key=info.name) + active_mark = " ●" if cfg.config.active_session == info.name else "" + tbl.add_row( + f"{info.name}{active_mark}", + str(info.user_id or "β€”"), + info.phone or "β€”", + info.username or "β€”", + str(info.dc_id or "β€”"), + status_str, + key=info.name, + ) @staticmethod def _status_markup(s: SessionStatus) -> str: @@ -511,51 +533,69 @@ def _status_markup(s: SessionStatus) -> str: SessionStatus.UNKNOWN: "[dim]? unknown[/dim]", }.get(s, "[dim]?[/dim]") - def _selected_name(self) -> str | None: - tbl = self.query_one("#sess_table", DataTable) - try: - key = tbl.coordinate_to_cell_key(tbl.cursor_coordinate).row_key.value - return str(key) if key is not None else None - except Exception: - return None - - def _set_action_btns(self, enabled: bool) -> None: - for bid in ("btn_edit", "btn_avatar", "btn_export"): - self.query_one(f"#{bid}", Button).disabled = not enabled + def on_click(self, event: Click) -> None: + with contextlib.suppress(Exception): + panel = self.app.query_one("#rigi-action-panel") + panel.remove() + + def on_mouse_down(self, event: MouseDown) -> None: + if event.button == 3: + event.stop() + tbl = self.query_one("#sess_table", DataTable) + table_region = tbl.region + row_in_view = event.screen_y - table_region.y - 1 + scroll_y = int(tbl.scroll_offset.y) + row_idx = max(0, min(row_in_view + scroll_y, len(tbl.rows) - 1)) + if row_in_view >= 0: + self._show_action_menu(row_idx, event.screen_x, event.screen_y) + + def _close_action_menu(self) -> None: + with contextlib.suppress(Exception): + panel = self.app.query_one("#rigi-action-panel") + panel.remove() + + def _show_action_menu(self, row_idx: int, x: int, y: int) -> None: + self._close_action_menu() + sessions = tg_sessions.list_sessions() + if row_idx < 0 or row_idx >= len(sessions): + return + name = sessions[row_idx].name + is_active = cfg.config.active_session == name + items: list[ActionMenuItemData] = [ + ActionMenuItemData( + "Validate", + callback=lambda n=name: self.run_worker(self._validate_one(n)), + color="green", + ), + ActionMenuItemData( + "Rename", + callback=lambda n=name: self.run_worker(self._rename(n)), + ), + ActionMenuItemData( + "Edit Profile", + callback=lambda n=name: self.run_worker(self._edit_profile(n)), + ), + ActionMenuItemData( + f"Set Active {'βœ“' if is_active else ''}", + callback=lambda n=name: self._set_active(n), + disabled=is_active, + ), + ActionMenuItemData( + "Delete", + callback=lambda n=name: self._do_delete(n), + color="red", + ), + ] + self.app.show_action_menu(items, title=name, x=x, y=y) async def on_button_pressed(self, event: Button.Pressed) -> None: bid = event.button.id if bid == "btn_add": self.run_worker(self._handle_add()) - elif bid == "btn_import": self.run_worker(self._handle_import()) - - elif bid == "btn_check_all": - await self._check_all() - - elif bid == "btn_access": - name = self._selected_name() - if name: - await self._do_access(name) - else: - self.app.notify("Select a session first", severity="warning") - - elif bid == "btn_delete": - name = self._selected_name() - if name: - self._do_delete(name) - else: - self.app.notify("Select a session first", severity="warning") - - elif bid == "btn_edit" and self._accessed and self._info: - self.run_worker(self._handle_edit()) - - elif bid == "btn_avatar" and self._accessed: - self.run_worker(self._handle_avatar()) - - elif bid == "btn_export" and self._accessed: - self.run_worker(self._handle_export()) + elif bid == "btn_refresh": + self._reload_table() async def _handle_add(self) -> None: name = await self.app.push_screen_wait(LoginScreen()) @@ -569,72 +609,63 @@ async def _handle_import(self) -> None: self._reload_table() self.app.notify(f"Session '{name}' imported", title=" Sessions") - async def _handle_edit(self) -> None: - if self._accessed and self._info: - updated = await self.app.push_screen_wait(EditProfileScreen(self._accessed, self._info)) - if updated: - await self._do_access(self._accessed) - - async def _handle_avatar(self) -> None: - if self._accessed: - await self.app.push_screen_wait(SetAvatarScreen(self._accessed)) - - async def _handle_export(self) -> None: - if self._accessed: - await self.app.push_screen_wait(ExportChatScreen(self._accessed)) + async def _validate_one(self, name: str) -> None: + tbl = self.query_one("#sess_table", DataTable) + tbl.update_cell(name, "status", "[yellow]… checking[/yellow]") + try: + status = await tg_client.check_validity(name) + tg_sessions.update_metadata_statuses({name: status}) + tbl.update_cell(name, "status", self._status_markup(status)) + if status == SessionStatus.VALID: + info = await tg_client.fetch_info(name) + tg_sessions.update_metadata(name, info) + self._reload_table() + self.app.notify(f"{name}: {status.value}", title=" Sessions") + except Exception as e: + log.exception("validate failed for %s", name) + self.app.notify(f"Validate error: {e}", severity="error") + + async def _rename(self, name: str) -> None: + new_name = await self.app.push_screen_wait(RenameScreen(name)) + if new_name: + if cfg.config.active_session == name: + cfg.config.active_session = new_name + cfg.save_config(cfg.config) + self._reload_table() + self.app.notify(f"Renamed to '{new_name}'", title=" Sessions") - async def _do_access(self, name: str) -> None: - detail = self.query_one("#detail_info", Static) - detail.update("[dim]Connecting…[/dim]") - self._set_action_btns(False) + async def _edit_profile(self, name: str) -> None: try: info = await tg_client.fetch_info(name) tg_sessions.update_metadata(name, info) - self._accessed = name - self._info = info - self._reload_table() - kind_label = f"[dim]({info.kind.name.lower()})[/dim]" - detail.update( - f"[bold]{info.first_name} {info.last_name}[/bold] " - f"{'@' + info.username if info.username else ''}\n" - f"[dim]Phone:[/dim] {info.phone or 'β€”'}\n" - f"[dim]Bio:[/dim] {info.bio or 'β€”'}\n" - f"[dim]Session:[/dim] {name}.session {kind_label}" - ) - self._set_action_btns(True) except Exception as e: - detail.update(f"[red]Connection error: {e}[/red]") - log.exception(f"[sessions] access failed for {name!r}") - - async def _check_all(self) -> None: - sessions = tg_sessions.list_sessions() - if not sessions: - self.app.notify("No sessions to check", severity="warning") + log.exception("fetch info failed for %s", name) + self.app.notify(f"Fetch info error: {e}", severity="error") return - self.app.notify(f"Checking {len(sessions)} sessions…", title=" Sessions") - tbl = self.query_one("#sess_table", DataTable) - for info in sessions: - tbl.update_cell(info.name, "status", "[yellow]… checking[/yellow]") - - names = [s.name for s in sessions] - results = await tg_client.check_all_validity(names) + updated = await self.app.push_screen_wait(EditProfileScreen(name, info)) + if updated: + try: + info = await tg_client.fetch_info(name) + tg_sessions.update_metadata(name, info) + except Exception: + pass + self._reload_table() + self.app.notify("Profile updated", title=" Sessions") - meta = tg_sessions.load_metadata() - for name, status in results.items(): - meta.setdefault(name, {})["status"] = status.value - tbl.update_cell(name, "status", self._status_markup(status)) - tg_sessions.save_metadata(meta) - valid = sum(1 for s in results.values() if s == SessionStatus.VALID) - self.app.notify(f"βœ“ {valid}/{len(results)} valid", title=" Sessions") + def _set_active(self, name: str) -> None: + cfg.config.active_session = name + cfg.save_config(cfg.config) + self._reload_table() + self.app.notify(f"Active session: {name}", title=" Sessions") def _do_delete(self, name: str) -> None: tg_sessions.delete_session(name) - if self._accessed == name: - self._accessed = None - self._info = None - self.query_one("#detail_info", Static).update( - "[dim]Select a session, then click [bold]Access[/bold] to load its info[/dim]" - ) - self._set_action_btns(False) + if cfg.config.active_session == name: + cfg.config.active_session = None + cfg.save_config(cfg.config) self._reload_table() - self.app.notify(f"Session '{name}' deleted", title=" Sessions", severity="warning") + self.app.notify( + f"Session '{name}' deleted", + title=" Sessions", + severity="warning", + ) diff --git a/src/accxus/ui/utils/telegram_converter.py b/src/accxus/ui/utils/telegram_converter.py new file mode 100644 index 0000000..506bd72 --- /dev/null +++ b/src/accxus/ui/utils/telegram_converter.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import logging + +from rigi import ComposeResult, Widget +from rigi.widgets import Button, DataTable + +from accxus.platforms.telegram import sessions as tg_sessions +from accxus.types import SessionKind +from accxus.utils.session_convert import convert_telethon_to_pyrogram + +log = logging.getLogger(__name__) + + +class TelegramConverterTab(Widget): + DEFAULT_CSS = """ + TelegramConverterTab { + height: 100%; + width: 100%; + padding: 1 2; + } + #conv_top_row { + layout: horizontal; + height: auto; + margin-bottom: 1; + } + #conv_top_row Button { margin-right: 1; } + #conv_table { height: 1fr; } + """ + + def __init__(self) -> None: + super().__init__() + self._selected: set[str] = set() + + def compose(self) -> ComposeResult: + with Widget(id="conv_top_row"): + yield Button("Convert", id="btn_convert", variant="primary") + yield Button("Select All", id="btn_select_all") + yield Button("Clear", id="btn_clear") + yield DataTable(id="conv_table", cursor_type="row", zebra_stripes=True) + + def on_mount(self) -> None: + self._reload_table() + + def _reload_table(self) -> None: + tbl = self.query_one("#conv_table", DataTable) + tbl.clear(columns=True) + tbl.add_column("", key="sel") + tbl.add_column("Name") + tbl.add_column("ID") + tbl.add_column("Phone") + tbl.add_column("Kind") + sessions = tg_sessions.list_sessions() + available = {s.name for s in sessions} + self._selected.intersection_update(available) + for info in sessions: + kind_label = info.kind.value.lower() + tbl.add_row( + "●" if info.name in self._selected else "β—‹", + info.name, + str(info.user_id or "β€”"), + info.phone or "β€”", + kind_label, + key=info.name, + ) + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + key = str(event.row_key.value) if event.row_key.value is not None else None + if not key: + return + if key in self._selected: + self._selected.discard(key) + else: + self._selected.add(key) + self._sync_selected_rows() + + def _sync_selected_rows(self) -> None: + tbl = self.query_one("#conv_table", DataTable) + for info in tg_sessions.list_sessions(): + tbl.update_cell(info.name, "sel", "●" if info.name in self._selected else "β—‹") + + async def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn_convert": + await self._do_convert() + elif event.button.id == "btn_select_all": + self._selected = {s.name for s in tg_sessions.list_sessions() if s.kind == SessionKind.TELETHON} + self._sync_selected_rows() + elif event.button.id == "btn_clear": + self._selected.clear() + self._sync_selected_rows() + + async def _do_convert(self) -> None: + if not self._selected: + self.app.notify("Select at least one session", severity="warning") + return + sessions = tg_sessions.list_sessions() + to_convert = [s for s in sessions if s.name in self._selected] + if not to_convert: + return + converted = 0 + failed = 0 + for info in to_convert: + if info.kind != SessionKind.TELETHON: + continue + src = tg_sessions.session_path(info.name) + dest_name = f"{info.name}_pyro" + dest = tg_sessions.session_path(dest_name) + if dest.exists(): + self.app.notify( + f"Session '{dest_name}' already exists, skipping", + severity="warning", + ) + failed += 1 + continue + ok = convert_telethon_to_pyrogram(src, dest) + if ok: + meta = tg_sessions.load_metadata() + meta[dest_name] = { + "kind": SessionKind.PYROGRAM.value, + "status": info.status.value, + "phone": info.phone, + "user_id": info.user_id, + } + tg_sessions.save_metadata(meta) + converted += 1 + self.app.notify( + f"Converted '{info.name}' -> '{dest_name}'", + severity="information", + ) + else: + failed += 1 + self.app.notify( + f"Failed to convert '{info.name}'", + severity="error", + ) + self.app.notify( + f"Converted: {converted}, Failed: {failed}", + severity="information" if failed == 0 else "warning", + ) + self._selected.clear() + self._reload_table() diff --git a/src/accxus/ui/utils/telegram_tab.py b/src/accxus/ui/utils/telegram_tab.py new file mode 100644 index 0000000..61a0a42 --- /dev/null +++ b/src/accxus/ui/utils/telegram_tab.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from rigi import ComposeResult, Widget +from rigi.widgets import TabGroup + +from accxus.ui.utils.telegram_converter import TelegramConverterTab + + +class TelegramTab(Widget): + DEFAULT_CSS = """ + TelegramTab { + height: 100%; + width: 100%; + padding: 1 2; + } + """ + + def compose(self) -> ComposeResult: + yield TabGroup( + [ + ("Converter", lambda: TelegramConverterTab()), + ] + ) diff --git a/tests/test_startup.py b/tests/test_startup.py index c24ac77..31f5c75 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -25,12 +25,12 @@ def test_no_pyrogram_at_import_time() -> None: def test_build_app_returns_rigi_app() -> None: - from rigi import RigiApp + from rigi import App from accxus.ui.app import _build_app # pyright: ignore[reportPrivateUsage] app = _build_app() - assert isinstance(app, RigiApp) + assert isinstance(app, App) def test_proxy_pool_import() -> None: