🎉 First commit.
150
.dockerignore
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
### JetBrains template
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# AWS User-specific
|
||||
.idea/**/aws.xml
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# SonarLint plugin
|
||||
.idea/sonarlint/
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
|
||||
### Gradle template
|
||||
api/.gradle
|
||||
api/build
|
||||
!src/**/build/
|
||||
|
||||
# Ignore Gradle GUI config
|
||||
api/gradle-app.setting
|
||||
|
||||
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
|
||||
api/!gradle-wrapper.jar
|
||||
|
||||
# Avoid ignore Gradle wrappper properties
|
||||
api/!gradle-wrapper.properties
|
||||
|
||||
# Cache of project
|
||||
api/.gradletasknamecache
|
||||
|
||||
# Eclipse Gradle plugin generated files
|
||||
# Eclipse Core
|
||||
api/.project
|
||||
# JDT-specific (Eclipse Java Development Tools)
|
||||
api/.classpath
|
||||
|
||||
|
||||
### NextJS template
|
||||
# dependencies
|
||||
web/node_modules
|
||||
web/.pnp
|
||||
web/.pnp.js
|
||||
|
||||
# testing
|
||||
web/coverage
|
||||
|
||||
# next.js
|
||||
web/.next/
|
||||
web/out/
|
||||
|
||||
# production
|
||||
web/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
web/npm-debug.log*
|
||||
web/yarn-debug.log*
|
||||
web/yarn-error.log*
|
||||
web/.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
web/.env*.local
|
||||
web/.env
|
||||
|
||||
# vercel
|
||||
web/.vercel
|
||||
|
||||
# typescript
|
||||
web/*.tsbuildinfo
|
||||
web/next-env.d.ts
|
||||
|
||||
|
||||
|
||||
**/*.iml
|
||||
app/
|
||||
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
api.log.lck
|
||||
http-client.private.env.json
|
||||
8
.env.example
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
# The root directory of the application
|
||||
APP_ENV=dev
|
||||
APP_ROOT=
|
||||
TDLIB_PATH=
|
||||
|
||||
TELEGRAM_API_ID=
|
||||
TELEGRAM_API_HASH=
|
||||
74
.github/workflows/docker-publish.yml
vendored
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "dev"
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: 'ubuntu-24.04'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
# Set up BuildKit Docker container builder to be able to build
|
||||
# multi-platform images and export cache
|
||||
# https://github.com/docker/setup-buildx-action
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.2.0
|
||||
# with:
|
||||
# buildkitd-config-inline: |
|
||||
# [worker.oci]
|
||||
# max-parallelism = 1
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
- name: Login to registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3.1.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5.5.1
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-') }}
|
||||
type=raw,value=${{ github.ref_name }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
type=ref,event=branch
|
||||
|
||||
# Build and push Docker image with Buildx
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v6.10.0
|
||||
with:
|
||||
push: true
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||
17
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
.idea/
|
||||
**/*.iml
|
||||
*.iws
|
||||
|
||||
out/
|
||||
*/.gradle
|
||||
**/*build*/
|
||||
|
||||
app/
|
||||
app-test/
|
||||
|
||||
**/*.log
|
||||
api.log.lck
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
|
||||
http-client.private.env.json
|
||||
70
BUILD_TDLIB.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
1. **安装命令行工具**:
|
||||
```bash
|
||||
xcode-select --install
|
||||
```
|
||||
安装 macOS 的命令行开发工具(包括 `clang`、`make` 等)。
|
||||
|
||||
2. **安装 Homebrew**:
|
||||
```bash
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
```
|
||||
下载并安装 Homebrew,一个包管理器,用于安装依赖软件。
|
||||
|
||||
3. **安装必要的软件包**:
|
||||
```bash
|
||||
brew install gperf cmake openssl coreutils
|
||||
brew install openjdk
|
||||
```
|
||||
- `gperf`:生成完美哈希函数的工具。
|
||||
- `cmake`:跨平台的构建工具。
|
||||
- `openssl`:用于安全通信的加密库。
|
||||
- `coreutils`:GNU 核心实用工具,提供了 `greadlink` 等命令。
|
||||
- `openjdk`:Java 开发工具包。
|
||||
|
||||
4. **克隆 TDLib 仓库**:
|
||||
```bash
|
||||
git clone https://github.com/tdlib/td.git
|
||||
cd td
|
||||
```
|
||||
下载 TDLib 的源码。
|
||||
|
||||
5. **创建和清理构建目录**:
|
||||
```bash
|
||||
rm -rf build
|
||||
mkdir build
|
||||
cd build
|
||||
```
|
||||
确保构建环境干净无误。
|
||||
|
||||
6. **配置和构建库**:
|
||||
```bash
|
||||
cmake -DCMAKE_BUILD_TYPE=Release -DJAVA_HOME=/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home/ -DOPENSSL_ROOT_DIR=/opt/homebrew/opt/openssl/ -DCMAKE_INSTALL_PREFIX:PATH=../example/java/td -DTD_ENABLE_JNI=ON ..
|
||||
cmake --build . --target install
|
||||
```
|
||||
- `-DCMAKE_BUILD_TYPE=Release`:构建为发布版本。
|
||||
- `-DJAVA_HOME` 和 `-DOPENSSL_ROOT_DIR`:指定 OpenJDK 和 OpenSSL 的路径。
|
||||
- `-DCMAKE_INSTALL_PREFIX`:指定安装路径。
|
||||
- `-DTD_ENABLE_JNI=ON`:启用 Java JNI 接口。
|
||||
- 构建并安装到指定目录。
|
||||
|
||||
7. **构建 Java 示例项目**:
|
||||
```bash
|
||||
cd ..
|
||||
cd example/java
|
||||
rm -rf build
|
||||
mkdir build
|
||||
cd build
|
||||
cmake -DCMAKE_BUILD_TYPE=Release -DJAVA_HOME=/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home/ -DCMAKE_INSTALL_PREFIX:PATH=../../../tdlib -DTd_DIR:PATH=$(greadlink -e ../td/lib/cmake/Td) ..
|
||||
cmake --build . --target install
|
||||
```
|
||||
使用 TDLib 构建 Java 示例程序,`-DTd_DIR` 指向 TDLib 的 cmake 文件路径。
|
||||
|
||||
8. **验证安装结果**:
|
||||
```bash
|
||||
cd ../../..
|
||||
cd ..
|
||||
ls -l td/tdlib
|
||||
```
|
||||
查看生成的文件是否存在,确认构建和安装是否成功。
|
||||
|
||||
完成以上步骤后,`td/tdlib` 目录下应该包含构建好的库文件。
|
||||
102
Dockerfile
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# 多阶段构建:第一阶段用于构建 API 应用程序
|
||||
FROM gradle:8.10-jdk21-alpine AS api-builder
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 将 API 项目的所有文件拷贝到容器中
|
||||
COPY ./api .
|
||||
|
||||
# 使用 Gradle 构建项目
|
||||
RUN gradle shadowJar --no-daemon
|
||||
|
||||
# 提取构建的 jar 文件
|
||||
RUN mkdir -p /app/build/libs && \
|
||||
cp /app/build/libs/*.jar /app/api.jar
|
||||
|
||||
# 使用 jdeps 分析依赖
|
||||
RUN jdeps --print-module-deps --ignore-missing-deps /app/api.jar > /app/dependencies.txt
|
||||
|
||||
# 第二阶段:创建定制化的 JRE
|
||||
FROM openjdk:21-jdk-slim AS runtime-builder
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /custom-jre
|
||||
|
||||
# 通过 jlink 创建最小化的 JRE
|
||||
COPY --from=api-builder /app/dependencies.txt /custom-jre/dependencies.txt
|
||||
RUN apt-get update && apt-get install binutils -y && \
|
||||
jlink \
|
||||
--add-modules $(cat /custom-jre/dependencies.txt) \
|
||||
--output /custom-jre/jre \
|
||||
--strip-debug \
|
||||
--no-man-pages \
|
||||
--no-header-files \
|
||||
--compress=2
|
||||
|
||||
# 第三阶段:构建 Web 前端
|
||||
FROM node:21 AS web-builder
|
||||
|
||||
ENV NEXT_PUBLIC_API_URL=/api
|
||||
ENV NEXT_PUBLIC_WS_URL=/ws
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV SKIP_ENV_VALIDATION=1
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /web
|
||||
|
||||
# 安装依赖
|
||||
COPY ./web/package.json ./web/package-lock.json ./
|
||||
RUN npm install --frozen-lockfile
|
||||
|
||||
# 将 Web 项目的所有文件拷贝到容器中
|
||||
COPY ./web .
|
||||
|
||||
# 构建 Next.js 应用
|
||||
RUN npm run build
|
||||
|
||||
# 第四阶段:最终运行镜像
|
||||
FROM node:21-slim AS final
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install pm2 -g \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends nginx wget unzip \
|
||||
&& if [ "$(uname -m)" = "x86_64" ]; then \
|
||||
wget http://nz2.archive.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.1f-1ubuntu2.23_amd64.deb && \
|
||||
dpkg -i libssl1.1_1.1.1f-1ubuntu2.23_amd64.deb && \
|
||||
rm libssl1.1_1.1.1f-1ubuntu2.23_amd64.deb; \
|
||||
elif [ "$(uname -m)" = "aarch64" ]; then \
|
||||
wget http://ports.ubuntu.com/pool/main/o/openssl/libssl1.1_1.1.1f-1ubuntu2_arm64.deb && \
|
||||
dpkg -i libssl1.1_1.1.1f-1ubuntu2_arm64.deb && \
|
||||
rm libssl1.1_1.1.1f-1ubuntu2_arm64.deb; \
|
||||
else \
|
||||
echo "Unsupported architecture: $(uname -m)"; \
|
||||
exit 1; \
|
||||
fi \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*;
|
||||
|
||||
# 拷贝定制化的 JRE
|
||||
COPY --from=runtime-builder /custom-jre/jre /jre
|
||||
|
||||
# 设置环境变量
|
||||
ENV JAVA_HOME=/jre
|
||||
ENV PATH="$JAVA_HOME/bin:$PATH"
|
||||
|
||||
# 拷贝 API 应用程序
|
||||
COPY --from=api-builder /app/api.jar /app/api.jar
|
||||
|
||||
# 拷贝 Web 前端静态文件到 nginx
|
||||
COPY --from=web-builder /web/public /app/web/public
|
||||
COPY --from=web-builder /web/.next/standalone /app/web/
|
||||
COPY --from=web-builder /web/.next/static /app/web/.next/static
|
||||
|
||||
COPY ./web/pm2.json /app/web/pm2.json
|
||||
COPY ./entrypoint.sh ./entrypoint.sh
|
||||
COPY ./nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# 暴露服务端口
|
||||
EXPOSE 80
|
||||
|
||||
# 启动服务
|
||||
CMD ["/bin/sh", "./entrypoint.sh"]
|
||||
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 jarvis2f
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
165
README.md
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<p align="center">
|
||||
<img src="./web/public/favicon.svg" align="center" width="30%">
|
||||
</p>
|
||||
<p align="center"><h1 align="center">Telegram Files</h1></p>
|
||||
<p align="center">
|
||||
<em><code>A simple telegram file downloader.</code></em>
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/github/license/jarvis2f/telegram-files?style=default&logo=opensourceinitiative&logoColor=white&color=0080ff" alt="license">
|
||||
<img src="https://img.shields.io/github/last-commit/jarvis2f/telegram-files?style=default&logo=git&logoColor=white&color=0080ff" alt="last-commit">
|
||||
<img src="https://img.shields.io/github/languages/top/jarvis2f/telegram-files?style=default&color=0080ff" alt="repo-top-language">
|
||||
<img src="https://img.shields.io/github/languages/count/jarvis2f/telegram-files?style=default&color=0080ff" alt="repo-language-count">
|
||||
</p>
|
||||
<br>
|
||||
|
||||
## 🔗 Table of Contents
|
||||
|
||||
- [📍 Overview](#-overview)
|
||||
- [🧩 Screenshots](#-screenshots)
|
||||
- [🚀 Getting Started](#-getting-started)
|
||||
- [☑️ Prerequisites](#-prerequisites)
|
||||
- [⚙️ Installation](#-installation)
|
||||
- [🤖 Usage](#🤖-usage)
|
||||
- [📌 Project Roadmap](#-project-roadmap)
|
||||
- [🔰 Contributing](#-contributing)
|
||||
- [🎗 License](#-license)
|
||||
|
||||
---
|
||||
|
||||
## 📍 Overview
|
||||
|
||||
* Support for downloading files from telegram channels and groups.
|
||||
* Support multiple telegram accounts for downloading files.
|
||||
* Support suspending and resuming downloads.
|
||||
* Multiple accounts with same files will be downloaded only once.
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="./misc/screenshot.png" align="center" width="80%">
|
||||
</p>
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### ☑️ Prerequisites
|
||||
|
||||
Before getting started with telegram-files, ensure your runtime environment meets the following requirements:
|
||||
|
||||
- **Programming Language:** JDK21,TypeScript
|
||||
- **Package Manager:** Gradle,Npm
|
||||
- **Container Runtime:** Docker
|
||||
|
||||
### ⚙️ Installation
|
||||
|
||||
Install telegram-files using one of the following methods:
|
||||
|
||||
**Build from source:**
|
||||
|
||||
1. Clone the telegram-files repository:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/jarvis2f/telegram-files
|
||||
```
|
||||
|
||||
2. Navigate to the project directory:
|
||||
|
||||
```sh
|
||||
cd telegram-files
|
||||
```
|
||||
|
||||
3. Install the project dependencies:
|
||||
|
||||
**Using `npm`**
|
||||
[<img align="center" src="https://img.shields.io/badge/npm-CB3837.svg?style={badge_style}&logo=npm&logoColor=white" />](https://www.npmjs.com/)
|
||||
|
||||
```sh
|
||||
cd web
|
||||
npm install
|
||||
```
|
||||
|
||||
**Using `gradle`**
|
||||
[<img align="center" src="https://img.shields.io/badge/Gradle-02303A.svg?style={badge_style}&logo=gradle&logoColor=white" />](https://gradle.org/)
|
||||
|
||||
```sh
|
||||
cd api
|
||||
gradle build
|
||||
```
|
||||
|
||||
**Using `docker`**
|
||||
[<img align="center" src="https://img.shields.io/badge/Docker-2CA5E0.svg?style={badge_style}&logo=docker&logoColor=white" />](https://www.docker.com/)
|
||||
|
||||
```sh
|
||||
docker build -t jarvis2f/telegram-files .
|
||||
```
|
||||
|
||||
### 🤖 Usage
|
||||
|
||||
**Using `docker`**
|
||||
[<img align="center" src="https://img.shields.io/badge/Docker-2CA5E0.svg?style={badge_style}&logo=docker&logoColor=white" />](https://www.docker.com/)
|
||||
|
||||
Copy [docker-compose.yaml](docker-compose.yaml) to your project directory and run the following command:
|
||||
|
||||
```sh
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
> **Important Note:** You should NOT expose the service to the public internet. Because the service is not secure.
|
||||
---
|
||||
|
||||
## 📌 Project Roadmap
|
||||
|
||||
- [ ] **`Task 1`**: Improve Telegram’s login functionality.
|
||||
- [ ] **`Task 2`**: Support auto transfer files to other destinations.
|
||||
- [ ] **`Task 3`**: Automatically download files based on set rules.
|
||||
- [ ] **`Task 4`**: Download statistics and reports.
|
||||
|
||||
---
|
||||
|
||||
## 🔰 Contributing
|
||||
|
||||
- **💬 [Join the Discussions](https://github.com/jarvis2f/telegram-files/discussions)**: Share your insights, provide
|
||||
feedback, or ask questions.
|
||||
- **🐛 [Report Issues](https://github.com/jarvis2f/telegram-files/issues)**: Submit bugs found or log feature requests
|
||||
for the `telegram-files` project.
|
||||
- **💡 [Submit Pull Requests](https://github.com/jarvis2f/telegram-files/blob/main/CONTRIBUTING.md)**: Review open PRs,
|
||||
and submit your own PRs.
|
||||
|
||||
<details closed>
|
||||
<summary>Contributing Guidelines</summary>
|
||||
|
||||
1. **Fork the Repository**: Start by forking the project repository to your github account.
|
||||
2. **Clone Locally**: Clone the forked repository to your local machine using a git client.
|
||||
```sh
|
||||
git clone https://github.com/jarvis2f/telegram-files
|
||||
```
|
||||
3. **Create a New Branch**: Always work on a new branch, giving it a descriptive name.
|
||||
```sh
|
||||
git checkout -b new-feature-x
|
||||
```
|
||||
4. **Make Your Changes**: Develop and test your changes locally.
|
||||
5. **Commit Your Changes**: Commit with a clear message describing your updates.
|
||||
```sh
|
||||
git commit -m 'Implemented new feature x.'
|
||||
```
|
||||
6. **Push to github**: Push the changes to your forked repository.
|
||||
```sh
|
||||
git push origin new-feature-x
|
||||
```
|
||||
7. **Submit a Pull Request**: Create a PR against the original project repository. Clearly describe the changes and
|
||||
their motivations.
|
||||
8. **Review**: Once your PR is reviewed and approved, it will be merged into the main branch. Congratulations on your
|
||||
contribution!
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🎗 License
|
||||
|
||||
This project is protected under the MIT License. For more details,
|
||||
refer to the [LICENSE](LICENSE) file.
|
||||
|
||||
---
|
||||
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.0.0
|
||||
83
api/build.gradle
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
plugins {
|
||||
id 'java'
|
||||
id 'com.gradleup.shadow' version '8.3.5'
|
||||
}
|
||||
|
||||
group = 'telegram.files'
|
||||
version = '0.0.0'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'io.vertx:vertx-core:4.5.11'
|
||||
implementation 'io.vertx:vertx-web:4.5.11'
|
||||
implementation 'io.vertx:vertx-jdbc-client:4.5.11'
|
||||
implementation 'io.vertx:vertx-sql-client-templates:4.5.11'
|
||||
implementation 'io.vertx:vertx-health-check:4.5.11'
|
||||
implementation 'org.xerial:sqlite-jdbc:3.47.1.0'
|
||||
implementation 'io.agroal:agroal-api:1.16'
|
||||
implementation 'io.agroal:agroal-pool:1.16'
|
||||
implementation 'cn.hutool:hutool-core:5.8.34'
|
||||
implementation 'cn.hutool:hutool-log:5.8.34'
|
||||
implementation 'org.jooq:jool:0.9.15'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.3'
|
||||
|
||||
testImplementation 'io.vertx:vertx-junit5:4.5.11'
|
||||
testImplementation platform('org.junit:junit-bom:5.10.0')
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
}
|
||||
|
||||
test {
|
||||
def envFile = file('../.env.test')
|
||||
if (envFile.exists()) {
|
||||
doFirst {
|
||||
envFile.eachLine {
|
||||
if (!it.startsWith("#") && !it.isEmpty()) {
|
||||
def (key, value) = it.tokenize('=')
|
||||
environment key, value
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
jvmArgs "-Djava.library.path=${System.getenv('TDLIB_PATH')}"
|
||||
environment "APP_ROOT", "${testClassesDirs.asPath}"
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
tasks.build {
|
||||
dependsOn shadowJar
|
||||
}
|
||||
|
||||
shadowJar {
|
||||
archiveBaseName.set('telegram-files')
|
||||
archiveClassifier.set('')
|
||||
archiveVersion.set('')
|
||||
manifest {
|
||||
attributes(
|
||||
'Main-Class': 'telegram.files.Start'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('jar') {
|
||||
manifest {
|
||||
attributes(
|
||||
'Implementation-Title': project.name,
|
||||
'Implementation-Version': project.version,
|
||||
'Built-By': System.getProperty('user.name'),
|
||||
'Build-Time': new Date().format('yyyy-MM-dd HH:mm:ss'),
|
||||
'Git-Commit': getGitCommit()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static def getGitCommit() {
|
||||
try {
|
||||
return 'git rev-parse --short HEAD'.execute().text.trim()
|
||||
} catch (ignored) {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
BIN
api/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
6
api/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#Tue Nov 26 18:12:21 CST 2024
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
234
api/gradlew
vendored
Executable file
|
|
@ -0,0 +1,234 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=${0##*/}
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
# double quotes to make sure that they get re-expanded; and
|
||||
# * put everything else in single quotes, so that it's not re-expanded.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
89
api/gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
2
api/settings.gradle
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
rootProject.name = 'api'
|
||||
|
||||
259
api/src/main/java/org/drinkless/tdlib/Client.java
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
//
|
||||
// Copyright Aliaksei Levin (levlam@telegram.org), Arseny Smirnov (arseny30@gmail.com) 2014-2024
|
||||
//
|
||||
// Distributed under the Boost Software License, Version 1.0. (See accompanying
|
||||
// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)
|
||||
//
|
||||
package org.drinkless.tdlib;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Main class for interaction with the TDLib.
|
||||
*/
|
||||
public final class Client {
|
||||
static {
|
||||
try {
|
||||
System.loadLibrary("tdjni");
|
||||
} catch (UnsatisfiedLinkError e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for handler for results of queries to TDLib and incoming updates from TDLib.
|
||||
*/
|
||||
public interface ResultHandler {
|
||||
/**
|
||||
* Callback called on result of query to TDLib or incoming update from TDLib.
|
||||
*
|
||||
* @param object Result of query or update of type TdApi.Update about new events.
|
||||
*/
|
||||
void onResult(TdApi.Object object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for handler of exceptions thrown while invoking ResultHandler.
|
||||
* By default, all such exceptions are ignored.
|
||||
* All exceptions thrown from ExceptionHandler are ignored.
|
||||
*/
|
||||
public interface ExceptionHandler {
|
||||
/**
|
||||
* Callback called on exceptions thrown while invoking ResultHandler.
|
||||
*
|
||||
* @param e Exception thrown by ResultHandler.
|
||||
*/
|
||||
void onException(Throwable e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for handler of messages that are added to the internal TDLib log.
|
||||
*/
|
||||
public interface LogMessageHandler {
|
||||
/**
|
||||
* Callback called on messages that are added to the internal TDLib log.
|
||||
*
|
||||
* @param verbosityLevel Log verbosity level with which the message was added from -1 up to 1024.
|
||||
* If 0, then TDLib will crash as soon as the callback returns.
|
||||
* None of the TDLib methods can be called from the callback.
|
||||
* @param message The message added to the internal TDLib log.
|
||||
*/
|
||||
void onLogMessage(int verbosityLevel, String message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception class thrown when TDLib error occurred while performing {@link #execute(TdApi.Function)}.
|
||||
*/
|
||||
public static class ExecutionException extends Exception {
|
||||
/**
|
||||
* Original TDLib error occurred when performing one of the synchronous functions.
|
||||
*/
|
||||
public final TdApi.Error error;
|
||||
|
||||
/**
|
||||
* @param error TDLib error occurred while performing {@link #execute(TdApi.Function)}.
|
||||
*/
|
||||
ExecutionException (TdApi.Error error) {
|
||||
super(error.code + ": " + error.message);
|
||||
this.error = error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request to the TDLib.
|
||||
*
|
||||
* @param query Object representing a query to the TDLib.
|
||||
* @param resultHandler Result handler with onResult method which will be called with result
|
||||
* of the query or with TdApi.Error as parameter. If it is null, nothing
|
||||
* will be called.
|
||||
* @param exceptionHandler Exception handler with onException method which will be called on
|
||||
* exception thrown from resultHandler. If it is null, then
|
||||
* defaultExceptionHandler will be called.
|
||||
*/
|
||||
public void send(TdApi.Function query, ResultHandler resultHandler, ExceptionHandler exceptionHandler) {
|
||||
long queryId = currentQueryId.incrementAndGet();
|
||||
if (resultHandler != null) {
|
||||
handlers.put(queryId, new Handler(resultHandler, exceptionHandler));
|
||||
}
|
||||
nativeClientSend(nativeClientId, queryId, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request to the TDLib with an empty ExceptionHandler.
|
||||
*
|
||||
* @param query Object representing a query to the TDLib.
|
||||
* @param resultHandler Result handler with onResult method which will be called with result
|
||||
* of the query or with TdApi.Error as parameter. If it is null, then
|
||||
* defaultExceptionHandler will be called.
|
||||
*/
|
||||
public void send(TdApi.Function query, ResultHandler resultHandler) {
|
||||
send(query, resultHandler, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously executes a TDLib request. Only a few marked accordingly requests can be executed synchronously.
|
||||
*
|
||||
* @param query Object representing a query to the TDLib.
|
||||
* @param <T> Automatically deduced return type of the query.
|
||||
* @return request result.
|
||||
* @throws ExecutionException if query execution fails.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <T extends TdApi.Object> T execute(TdApi.Function<T> query) throws ExecutionException {
|
||||
TdApi.Object object = nativeClientExecute(query);
|
||||
if (object instanceof TdApi.Error) {
|
||||
throw new ExecutionException((TdApi.Error) object);
|
||||
}
|
||||
return (T) object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates new Client.
|
||||
*
|
||||
* @param updateHandler Handler for incoming updates.
|
||||
* @param updateExceptionHandler Handler for exceptions thrown from updateHandler. If it is null, exceptions will be ignored.
|
||||
* @param defaultExceptionHandler Default handler for exceptions thrown from all ResultHandler. If it is null, exceptions will be ignored.
|
||||
* @return created Client
|
||||
*/
|
||||
public static Client create(ResultHandler updateHandler, ExceptionHandler updateExceptionHandler, ExceptionHandler defaultExceptionHandler) {
|
||||
Client client = new Client(updateHandler, updateExceptionHandler, defaultExceptionHandler);
|
||||
synchronized (responseReceiver) {
|
||||
if (!responseReceiver.isRun) {
|
||||
responseReceiver.isRun = true;
|
||||
|
||||
Thread receiverThread = new Thread(responseReceiver, "TDLib thread");
|
||||
receiverThread.setDaemon(true);
|
||||
receiverThread.start();
|
||||
}
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the handler for messages that are added to the internal TDLib log.
|
||||
* None of the TDLib methods can be called from the callback.
|
||||
*
|
||||
* @param maxVerbosityLevel The maximum verbosity level of messages for which the callback will be called.
|
||||
* @param logMessageHandler Handler for messages that are added to the internal TDLib log. Pass null to remove the handler.
|
||||
*/
|
||||
public static void setLogMessageHandler(int maxVerbosityLevel, LogMessageHandler logMessageHandler) {
|
||||
nativeClientSetLogMessageHandler(maxVerbosityLevel, logMessageHandler);
|
||||
}
|
||||
|
||||
private static class ResponseReceiver implements Runnable {
|
||||
public boolean isRun = false;
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
while (true) {
|
||||
int resultN = nativeClientReceive(clientIds, eventIds, events, 100000.0 /*seconds*/);
|
||||
for (int i = 0; i < resultN; i++) {
|
||||
processResult(clientIds[i], eventIds[i], events[i]);
|
||||
events[i] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void processResult(int clientId, long id, TdApi.Object object) {
|
||||
boolean isClosed = false;
|
||||
if (id == 0 && object instanceof TdApi.UpdateAuthorizationState) {
|
||||
TdApi.AuthorizationState authorizationState = ((TdApi.UpdateAuthorizationState) object).authorizationState;
|
||||
if (authorizationState instanceof TdApi.AuthorizationStateClosed) {
|
||||
isClosed = true;
|
||||
}
|
||||
}
|
||||
|
||||
Handler handler = id == 0 ? updateHandlers.get(clientId) : handlers.remove(id);
|
||||
if (handler != null) {
|
||||
try {
|
||||
handler.resultHandler.onResult(object);
|
||||
} catch (Throwable cause) {
|
||||
ExceptionHandler exceptionHandler = handler.exceptionHandler;
|
||||
if (exceptionHandler == null) {
|
||||
exceptionHandler = defaultExceptionHandlers.get(clientId);
|
||||
}
|
||||
if (exceptionHandler != null) {
|
||||
try {
|
||||
exceptionHandler.onException(cause);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isClosed) {
|
||||
updateHandlers.remove(clientId); // there will be no more updates
|
||||
defaultExceptionHandlers.remove(clientId); // ignore further exceptions
|
||||
clientCount.decrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
private static final int MAX_EVENTS = 1000;
|
||||
private final int[] clientIds = new int[MAX_EVENTS];
|
||||
private final long[] eventIds = new long[MAX_EVENTS];
|
||||
private final TdApi.Object[] events = new TdApi.Object[MAX_EVENTS];
|
||||
}
|
||||
|
||||
private final int nativeClientId;
|
||||
|
||||
private static final ConcurrentHashMap<Integer, ExceptionHandler> defaultExceptionHandlers = new ConcurrentHashMap<Integer, ExceptionHandler>();
|
||||
private static final ConcurrentHashMap<Integer, Handler> updateHandlers = new ConcurrentHashMap<Integer, Handler>();
|
||||
private static final ConcurrentHashMap<Long, Handler> handlers = new ConcurrentHashMap<Long, Handler>();
|
||||
private static final AtomicLong currentQueryId = new AtomicLong();
|
||||
private static final AtomicLong clientCount = new AtomicLong();
|
||||
|
||||
private static final ResponseReceiver responseReceiver = new ResponseReceiver();
|
||||
|
||||
private static class Handler {
|
||||
final ResultHandler resultHandler;
|
||||
final ExceptionHandler exceptionHandler;
|
||||
|
||||
Handler(ResultHandler resultHandler, ExceptionHandler exceptionHandler) {
|
||||
this.resultHandler = resultHandler;
|
||||
this.exceptionHandler = exceptionHandler;
|
||||
}
|
||||
}
|
||||
|
||||
private Client(ResultHandler updateHandler, ExceptionHandler updateExceptionHandler, ExceptionHandler defaultExceptionHandler) {
|
||||
clientCount.incrementAndGet();
|
||||
nativeClientId = createNativeClient();
|
||||
if (updateHandler != null) {
|
||||
updateHandlers.put(nativeClientId, new Handler(updateHandler, updateExceptionHandler));
|
||||
}
|
||||
if (defaultExceptionHandler != null) {
|
||||
defaultExceptionHandlers.put(nativeClientId, defaultExceptionHandler);
|
||||
}
|
||||
send(new TdApi.GetOption("version"), null, null);
|
||||
}
|
||||
|
||||
private static native int createNativeClient();
|
||||
|
||||
private static native void nativeClientSend(int nativeClientId, long eventId, TdApi.Function function);
|
||||
|
||||
private static native int nativeClientReceive(int[] clientIds, long[] eventIds, TdApi.Object[] events, double timeout);
|
||||
|
||||
private static native TdApi.Object nativeClientExecute(TdApi.Function function);
|
||||
|
||||
private static native void nativeClientSetLogMessageHandler(int maxVerbosityLevel, LogMessageHandler logMessageHandler);
|
||||
}
|
||||
54183
api/src/main/java/org/drinkless/tdlib/TdApi.java
Normal file
289
api/src/main/java/telegram/files/AutoDownloadVerticle.java
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
package telegram.files;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.log.Log;
|
||||
import cn.hutool.log.LogFactory;
|
||||
import io.vertx.core.AbstractVerticle;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.json.Json;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import org.drinkless.tdlib.TdApi;
|
||||
import telegram.files.repository.SettingAutoRecords;
|
||||
import telegram.files.repository.SettingKey;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.IntStream;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class AutoDownloadVerticle extends AbstractVerticle {
|
||||
|
||||
private static final Log log = LogFactory.get();
|
||||
|
||||
private static final int DEFAULT_LIMIT = 5;
|
||||
|
||||
private static final int HISTORY_SCAN_INTERVAL = 2 * 60 * 1000;
|
||||
|
||||
private static final int MAX_HISTORY_SCAN_TIME = 20 * 1000;
|
||||
|
||||
private static final int MAX_WAITING_LENGTH = 30;
|
||||
|
||||
private static final int DOWNLOAD_INTERVAL = 10 * 1000;
|
||||
|
||||
private static final List<String> FILE_TYPE_ORDER = List.of("photo", "video", "audio", "file");
|
||||
|
||||
// telegramId -> messages
|
||||
private final Map<Long, LinkedList<TdApi.Message>> waitingDownloadMessages = new ConcurrentHashMap<>();
|
||||
|
||||
private final SettingAutoRecords autoRecords = new SettingAutoRecords();
|
||||
|
||||
private int limit = DEFAULT_LIMIT;
|
||||
|
||||
@Override
|
||||
public void start(Promise<Void> startPromise) {
|
||||
initAutoDownload()
|
||||
.compose(v -> this.initEventConsumer())
|
||||
.onSuccess(v -> {
|
||||
vertx.setPeriodic(0, HISTORY_SCAN_INTERVAL,
|
||||
id -> autoRecords.items.forEach(auto -> addHistoryMessage(auto, System.currentTimeMillis())));
|
||||
vertx.setPeriodic(0, DOWNLOAD_INTERVAL,
|
||||
id -> waitingDownloadMessages.keySet().forEach(this::download));
|
||||
|
||||
log.info("""
|
||||
Auto download verticle started!
|
||||
|History scan interval: %s ms
|
||||
|Download interval: %s ms
|
||||
|Download limit: %s per telegram account!
|
||||
|Auto chats: %s
|
||||
""".formatted(HISTORY_SCAN_INTERVAL, DOWNLOAD_INTERVAL, limit, autoRecords.items.size()));
|
||||
|
||||
startPromise.complete();
|
||||
})
|
||||
.onFailure(startPromise::fail);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop(Promise<Void> stopPromise) {
|
||||
saveAutoRecords().onComplete(stopPromise);
|
||||
}
|
||||
|
||||
private Future<Void> initAutoDownload() {
|
||||
return Future.all(
|
||||
DataVerticle.settingRepository.<Integer>getByKey(SettingKey.autoDownloadLimit)
|
||||
.onSuccess(limit -> {
|
||||
if (limit != null) {
|
||||
this.limit = limit;
|
||||
}
|
||||
})
|
||||
.onFailure(e -> log.error("Get Auto download limit failed!", e)),
|
||||
DataVerticle.settingRepository.<SettingAutoRecords>getByKey(SettingKey.autoDownload)
|
||||
.onSuccess(settingAutoRecords -> {
|
||||
if (settingAutoRecords == null) {
|
||||
return;
|
||||
}
|
||||
settingAutoRecords.items.forEach(item -> HttpVerticle.getTelegramVerticle(item.telegramId)
|
||||
.ifPresentOrElse(telegramVerticle -> {
|
||||
if (telegramVerticle.authorized) {
|
||||
autoRecords.add(item);
|
||||
} else {
|
||||
log.warn("Init auto download fail. Telegram verticle not authorized: %s".formatted(item.telegramId));
|
||||
}
|
||||
}, () -> log.warn("Init auto download fail. Telegram verticle not found: %s".formatted(item.telegramId))));
|
||||
})
|
||||
.onFailure(e -> log.error("Init Auto download failed!", e))
|
||||
).mapEmpty();
|
||||
}
|
||||
|
||||
private Future<Void> initEventConsumer() {
|
||||
vertx.eventBus().consumer(EventEnum.AUTO_DOWNLOAD_UPDATE.address(), message -> {
|
||||
log.debug("Auto download update: %s".formatted(message.body()));
|
||||
this.onAutoRecordsUpdate(Json.decodeValue(message.body().toString(), SettingAutoRecords.class));
|
||||
});
|
||||
vertx.eventBus().consumer(EventEnum.SETTING_UPDATE.address(SettingKey.autoDownloadLimit.name()), message -> {
|
||||
log.debug("Auto download limit update: %s".formatted(message.body()));
|
||||
this.limit = Convert.toInt(message.body());
|
||||
});
|
||||
vertx.eventBus().consumer(EventEnum.MESSAGE_RECEIVED.address(), message -> {
|
||||
log.debug("Auto download message received: %s".formatted(message.body()));
|
||||
this.onNewMessage((JsonObject) message.body());
|
||||
});
|
||||
return Future.succeededFuture();
|
||||
}
|
||||
|
||||
private Future<Void> saveAutoRecords() {
|
||||
return DataVerticle.settingRepository.<SettingAutoRecords>getByKey(SettingKey.autoDownload)
|
||||
.compose(settingAutoRecords -> {
|
||||
if (settingAutoRecords == null) {
|
||||
settingAutoRecords = new SettingAutoRecords();
|
||||
}
|
||||
autoRecords.items.forEach(settingAutoRecords::add);
|
||||
return DataVerticle.settingRepository.createOrUpdate(SettingKey.autoDownload.name(), Json.encode(settingAutoRecords));
|
||||
})
|
||||
.onSuccess(v -> log.info("Save auto records success!"))
|
||||
.onFailure(e -> log.error("Save auto records failed!", e))
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
private void addHistoryMessage(SettingAutoRecords.Item auto, long currentTimeMillis) {
|
||||
if (System.currentTimeMillis() - currentTimeMillis > MAX_HISTORY_SCAN_TIME || isExceedLimit(auto.telegramId)) {
|
||||
return;
|
||||
}
|
||||
if (StrUtil.isBlank(auto.nextFileType)) {
|
||||
auto.nextFileType = FILE_TYPE_ORDER.getFirst();
|
||||
}
|
||||
TelegramVerticle telegramVerticle = this.getTelegramVerticle(auto.telegramId);
|
||||
TdApi.SearchChatMessages searchChatMessages = new TdApi.SearchChatMessages();
|
||||
searchChatMessages.chatId = auto.chatId;
|
||||
searchChatMessages.fromMessageId = auto.nextFromMessageId;
|
||||
searchChatMessages.limit = Math.min(MAX_WAITING_LENGTH, 100);
|
||||
searchChatMessages.filter = TdApiHelp.getSearchMessagesFilter(auto.nextFileType);
|
||||
telegramVerticle.execute(searchChatMessages)
|
||||
.onSuccess(foundChatMessages -> {
|
||||
if (foundChatMessages.messages.length == 0) {
|
||||
int nextTypeIndex = FILE_TYPE_ORDER.indexOf(auto.nextFileType) + 1;
|
||||
if (nextTypeIndex < FILE_TYPE_ORDER.size()) {
|
||||
String originalType = auto.nextFileType;
|
||||
auto.nextFileType = FILE_TYPE_ORDER.get(nextTypeIndex);
|
||||
auto.nextFromMessageId = 0;
|
||||
log.debug("%s No more %s files found! Switch to %s".formatted(auto.uniqueKey(), originalType, auto.nextFileType));
|
||||
addHistoryMessage(auto, currentTimeMillis);
|
||||
} else {
|
||||
log.debug("%s No more history files found! TelegramId: %d ChatId: %d".formatted(auto.uniqueKey(), auto.telegramId, auto.chatId));
|
||||
}
|
||||
} else {
|
||||
DataVerticle.fileRepository.getFilesByUniqueId(TdApiHelp.getFileUniqueIds(Arrays.asList(foundChatMessages.messages)))
|
||||
.onSuccess(existFiles -> {
|
||||
List<TdApi.Message> messages = Stream.of(foundChatMessages.messages)
|
||||
.filter(message -> !existFiles.containsKey(TdApiHelp.getFileUniqueId(message)))
|
||||
.toList();
|
||||
if (CollUtil.isEmpty(messages)) {
|
||||
auto.nextFromMessageId = foundChatMessages.nextFromMessageId;
|
||||
addHistoryMessage(auto, currentTimeMillis);
|
||||
} else if (addWaitingDownloadMessages(auto.telegramId, messages, false)) {
|
||||
auto.nextFromMessageId = foundChatMessages.nextFromMessageId;
|
||||
} else {
|
||||
addHistoryMessage(auto, currentTimeMillis);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private boolean isExceedLimit(long telegramId) {
|
||||
List<TdApi.Message> waitingMessages = this.waitingDownloadMessages.get(telegramId);
|
||||
return getSurplusSize(telegramId) == 0 || (waitingMessages != null && waitingMessages.size() > limit);
|
||||
}
|
||||
|
||||
private int getSurplusSize(long telegramId) {
|
||||
TelegramVerticle telegramVerticle = this.getTelegramVerticle(telegramId);
|
||||
Integer downloading = telegramVerticle.getDownloadStatistics()
|
||||
.map(statistics -> statistics.getInteger("downloading"))
|
||||
.result();
|
||||
return downloading == null ? limit : Math.min(0, limit - downloading);
|
||||
}
|
||||
|
||||
private boolean addWaitingDownloadMessages(long telegramId, List<TdApi.Message> messages, boolean force) {
|
||||
if (CollUtil.isEmpty(messages)) {
|
||||
return false;
|
||||
}
|
||||
LinkedList<TdApi.Message> waitingMessages = this.waitingDownloadMessages.get(telegramId);
|
||||
if (waitingMessages == null) {
|
||||
waitingMessages = new LinkedList<>();
|
||||
}
|
||||
if (!force && waitingMessages.size() > MAX_WAITING_LENGTH) {
|
||||
return false;
|
||||
} else {
|
||||
log.debug("Add waiting download messages: %d".formatted(messages.size()));
|
||||
waitingMessages.addAll(TdApiHelp.filterUniqueMessages(messages));
|
||||
}
|
||||
this.waitingDownloadMessages.put(telegramId, waitingMessages);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void download(long telegramId) {
|
||||
if (CollUtil.isEmpty(waitingDownloadMessages)) {
|
||||
return;
|
||||
}
|
||||
LinkedList<TdApi.Message> messages = waitingDownloadMessages.get(telegramId);
|
||||
if (CollUtil.isEmpty(messages)) {
|
||||
return;
|
||||
}
|
||||
log.debug("Download start! TelegramId: %d size: %d".formatted(telegramId, messages.size()));
|
||||
TelegramVerticle telegramVerticle = this.getTelegramVerticle(telegramId);
|
||||
int surplusSize = getSurplusSize(telegramId);
|
||||
if (surplusSize == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<TdApi.Message> downloadMessages = IntStream.range(0, Math.min(surplusSize, messages.size()))
|
||||
.mapToObj(i -> messages.poll())
|
||||
.toList();
|
||||
downloadMessages.forEach(message -> {
|
||||
Integer fileId = TdApiHelp.getFileId(message);
|
||||
log.debug("Start process file: %s".formatted(fileId));
|
||||
telegramVerticle.startDownload(message.chatId, message.id, fileId)
|
||||
.onSuccess(v -> log.info("Start download file success! ChatId: %d MessageId:%d FileId:%d"
|
||||
.formatted(message.chatId, message.id, fileId))
|
||||
)
|
||||
.onFailure(e -> log.error("Download file failed! ChatId: %d MessageId:%d FileId:%d"
|
||||
.formatted(message.chatId, message.id, fileId), e));
|
||||
});
|
||||
log.debug("Remaining download messages: %d".formatted(messages.size()));
|
||||
}
|
||||
|
||||
private TelegramVerticle getTelegramVerticle(long telegramId) {
|
||||
return HttpVerticle.getTelegramVerticle(telegramId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Telegram verticle not found: %s".formatted(telegramId)));
|
||||
}
|
||||
|
||||
private void onAutoRecordsUpdate(SettingAutoRecords records) {
|
||||
for (SettingAutoRecords.Item item : records.items) {
|
||||
if (!autoRecords.exists(item.telegramId, item.chatId)) {
|
||||
// new enabled
|
||||
HttpVerticle.getTelegramVerticle(item.telegramId)
|
||||
.ifPresentOrElse(telegramVerticle -> {
|
||||
if (telegramVerticle.authorized) {
|
||||
autoRecords.add(item);
|
||||
log.info("Add auto download success: %s".formatted(item.uniqueKey()));
|
||||
} else {
|
||||
log.warn("Add auto download fail. Telegram verticle not authorized: %s".formatted(item.telegramId));
|
||||
}
|
||||
}, () -> log.warn("Add auto download fail. Telegram verticle not found: %s".formatted(item.telegramId)));
|
||||
}
|
||||
}
|
||||
// remove disabled
|
||||
List<SettingAutoRecords.Item> removedItems = new ArrayList<>();
|
||||
autoRecords.items.removeIf(item -> {
|
||||
if (records.exists(item.telegramId, item.chatId)) {
|
||||
return false;
|
||||
}
|
||||
removedItems.add(item);
|
||||
log.info("Remove auto download success: %s".formatted(item.uniqueKey()));
|
||||
return true;
|
||||
});
|
||||
removedItems.forEach(item ->
|
||||
waitingDownloadMessages.getOrDefault(item.telegramId, new LinkedList<>())
|
||||
.removeIf(message -> message.chatId == item.chatId)
|
||||
);
|
||||
}
|
||||
|
||||
private void onNewMessage(JsonObject jsonObject) {
|
||||
long telegramId = jsonObject.getLong("telegramId");
|
||||
long chatId = jsonObject.getLong("chatId");
|
||||
long messageId = jsonObject.getLong("messageId");
|
||||
autoRecords.items.stream()
|
||||
.filter(item -> item.telegramId == telegramId && item.chatId == chatId)
|
||||
.findFirst()
|
||||
.flatMap(item -> HttpVerticle.getTelegramVerticle(telegramId))
|
||||
.ifPresent(telegramVerticle -> {
|
||||
if (telegramVerticle.authorized) {
|
||||
telegramVerticle.execute(new TdApi.GetMessage(chatId, messageId))
|
||||
.onSuccess(message -> addWaitingDownloadMessages(telegramId, List.of(message), true))
|
||||
.onFailure(e -> log.error("Auto download fail. Get message failed: %s".formatted(e.getMessage())));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
45
api/src/main/java/telegram/files/Config.java
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package telegram.files;
|
||||
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public class Config {
|
||||
public static final String APP_ENV = StrUtil.blankToDefault(System.getenv("APP_ENV"), "prod");
|
||||
|
||||
public static final String APP_ROOT = System.getenv("APP_ROOT");
|
||||
|
||||
public static final String TELEGRAM_ROOT = APP_ROOT + File.separator + "account";
|
||||
|
||||
public static final int TELEGRAM_API_ID = Convert.toInt(System.getenv("TELEGRAM_API_ID"), 0);
|
||||
|
||||
public static final String TELEGRAM_API_HASH = System.getenv("TELEGRAM_API_HASH");
|
||||
|
||||
static {
|
||||
if (APP_ENV == null) {
|
||||
throw new RuntimeException("APP_ENV is not set");
|
||||
}
|
||||
if (APP_ROOT == null) {
|
||||
throw new RuntimeException("APP_ROOT is not set");
|
||||
}
|
||||
if (TELEGRAM_API_ID == 0) {
|
||||
throw new RuntimeException("TELEGRAM_API_ID is not set");
|
||||
}
|
||||
if (TELEGRAM_API_HASH == null) {
|
||||
throw new RuntimeException("TELEGRAM_API_HASH is not set");
|
||||
}
|
||||
|
||||
if (!FileUtil.exist(APP_ROOT)) {
|
||||
FileUtil.mkdir(APP_ROOT);
|
||||
}
|
||||
if (!FileUtil.exist(TELEGRAM_ROOT)) {
|
||||
FileUtil.mkdir(TELEGRAM_ROOT);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isProd() {
|
||||
return "prod".equals(APP_ENV);
|
||||
}
|
||||
}
|
||||
73
api/src/main/java/telegram/files/DataVerticle.java
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package telegram.files;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.log.Log;
|
||||
import cn.hutool.log.LogFactory;
|
||||
import io.vertx.core.AbstractVerticle;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.jdbcclient.JDBCConnectOptions;
|
||||
import io.vertx.jdbcclient.JDBCPool;
|
||||
import io.vertx.sqlclient.PoolOptions;
|
||||
import telegram.files.repository.FileRepository;
|
||||
import telegram.files.repository.SettingRepository;
|
||||
import telegram.files.repository.TelegramRepository;
|
||||
import telegram.files.repository.impl.FileRepositoryImpl;
|
||||
import telegram.files.repository.impl.SettingRepositoryImpl;
|
||||
import telegram.files.repository.impl.TelegramRepositoryImpl;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
|
||||
public class DataVerticle extends AbstractVerticle {
|
||||
|
||||
private static final Log log = LogFactory.get();
|
||||
|
||||
public static JDBCPool pool;
|
||||
|
||||
public static FileRepository fileRepository;
|
||||
|
||||
public static TelegramRepository telegramRepository;
|
||||
|
||||
public static SettingRepository settingRepository;
|
||||
|
||||
public void start(Promise<Void> stopPromise) {
|
||||
pool = JDBCPool.pool(vertx,
|
||||
new JDBCConnectOptions()
|
||||
.setJdbcUrl("jdbc:sqlite:%s".formatted(getDataPath()))
|
||||
,
|
||||
new PoolOptions().setMaxSize(16).setName("pool-tf")
|
||||
);
|
||||
telegramRepository = new TelegramRepositoryImpl(pool);
|
||||
fileRepository = new FileRepositoryImpl(pool);
|
||||
settingRepository = new SettingRepositoryImpl(pool);
|
||||
Future.all(List.of(
|
||||
telegramRepository.init(),
|
||||
fileRepository.init(),
|
||||
settingRepository.init()
|
||||
))
|
||||
.onSuccess(r -> {
|
||||
log.info("Database initialized");
|
||||
stopPromise.complete();
|
||||
})
|
||||
.onFailure(err -> {
|
||||
log.error("Failed to initialize database: %s".formatted(err.getMessage()));
|
||||
stopPromise.fail(err);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() throws Exception {
|
||||
if (pool != null) {
|
||||
pool.close();
|
||||
log.debug("Database closed");
|
||||
}
|
||||
}
|
||||
|
||||
public static String getDataPath() {
|
||||
String dataPath = System.getenv("DATA_PATH");
|
||||
dataPath = StrUtil.blankToDefault(dataPath, "data.db");
|
||||
dataPath = Config.APP_ROOT + File.separator + dataPath;
|
||||
return dataPath;
|
||||
}
|
||||
}
|
||||
35
api/src/main/java/telegram/files/EventEnum.java
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package telegram.files;
|
||||
|
||||
public enum EventEnum {
|
||||
|
||||
/**
|
||||
* suffix = SettingRecord.key <br>
|
||||
* body = SettingRecord.value
|
||||
*
|
||||
* @see telegram.files.repository.SettingRecord
|
||||
*/
|
||||
SETTING_UPDATE,
|
||||
|
||||
/**
|
||||
* suffix = null <br>
|
||||
* body = SettingAutoRecords
|
||||
*
|
||||
* @see telegram.files.repository.SettingAutoRecords
|
||||
*/
|
||||
AUTO_DOWNLOAD_UPDATE,
|
||||
|
||||
/**
|
||||
* suffix = null <br>
|
||||
* body = JSONObject with "telegramId", "chatId", "messageId"
|
||||
*/
|
||||
MESSAGE_RECEIVED,
|
||||
;
|
||||
|
||||
public String address() {
|
||||
return name();
|
||||
}
|
||||
|
||||
public String address(String suffix) {
|
||||
return name() + "." + suffix;
|
||||
}
|
||||
}
|
||||
23
api/src/main/java/telegram/files/EventPayload.java
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package telegram.files;
|
||||
|
||||
public record EventPayload(int type, String code, Object data, long timestamp) {
|
||||
public static final int TYPE_ERROR = -1;
|
||||
|
||||
public static final int TYPE_AUTHORIZATION = 1;
|
||||
|
||||
public static final int TYPE_METHOD_RESULT = 2;
|
||||
|
||||
public static final int TYPE_FILE = 3;
|
||||
|
||||
public static final int TYPE_FILE_DOWNLOAD = 4;
|
||||
|
||||
public static final int TYPE_FILE_STATUS = 5;
|
||||
|
||||
public static EventPayload build(int type, Object data) {
|
||||
return new EventPayload(type, null, data, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
public static EventPayload build(int type, String code, Object data) {
|
||||
return new EventPayload(type, code, data, System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
554
api/src/main/java/telegram/files/HttpVerticle.java
Normal file
|
|
@ -0,0 +1,554 @@
|
|||
package telegram.files;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.util.NumberUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.log.Log;
|
||||
import cn.hutool.log.LogFactory;
|
||||
import io.vertx.core.*;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.http.CookieSameSite;
|
||||
import io.vertx.core.http.HttpMethod;
|
||||
import io.vertx.core.http.HttpServerOptions;
|
||||
import io.vertx.core.http.HttpServerResponse;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.healthchecks.HealthCheckHandler;
|
||||
import io.vertx.ext.healthchecks.HealthChecks;
|
||||
import io.vertx.ext.web.Router;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
import io.vertx.ext.web.handler.BodyHandler;
|
||||
import io.vertx.ext.web.handler.CorsHandler;
|
||||
import io.vertx.ext.web.handler.SessionHandler;
|
||||
import io.vertx.ext.web.sstore.LocalSessionStore;
|
||||
import io.vertx.ext.web.sstore.SessionStore;
|
||||
import telegram.files.repository.SettingRecord;
|
||||
import telegram.files.repository.TelegramRecord;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class HttpVerticle extends AbstractVerticle {
|
||||
|
||||
private static final Log log = LogFactory.get();
|
||||
|
||||
// session id -> ws handler id
|
||||
private static final Map<String, String> clients = new ConcurrentHashMap<>();
|
||||
|
||||
private static final List<TelegramVerticle> telegramVerticles = new ArrayList<>();
|
||||
|
||||
// session id -> telegram verticle
|
||||
private final Map<String, TelegramVerticle> sessionTelegramVerticles = new ConcurrentHashMap<>();
|
||||
|
||||
private static final String SESSION_COOKIE_NAME = "tf";
|
||||
|
||||
@Override
|
||||
public void start(Promise<Void> startPromise) {
|
||||
initHttpServer()
|
||||
.compose(r -> initTelegramVerticles())
|
||||
.compose(r -> initAutoDownloadVerticle())
|
||||
.onSuccess(startPromise::complete)
|
||||
.onFailure(startPromise::fail);
|
||||
}
|
||||
|
||||
public Future<Void> initHttpServer() {
|
||||
int port = config().getInteger("http.port", 8080);
|
||||
HttpServerOptions options = new HttpServerOptions()
|
||||
.setLogActivity(true)
|
||||
.setRegisterWebSocketWriteHandlers(true)
|
||||
.setMaxWebSocketMessageSize(1024 * 1024)
|
||||
.setIdleTimeout(60)
|
||||
.setIdleTimeoutUnit(TimeUnit.SECONDS)
|
||||
.setPort(port);
|
||||
|
||||
return vertx.createHttpServer(options)
|
||||
.requestHandler(initRouter())
|
||||
.listen()
|
||||
.onSuccess(server -> log.info("API server started on port " + port))
|
||||
.onFailure(err -> log.error("Failed to start API server: %s".formatted(err.getMessage())))
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
public Router initRouter() {
|
||||
Router router = Router.router(vertx);
|
||||
|
||||
SessionStore sessionStore = LocalSessionStore.create(vertx, SESSION_COOKIE_NAME);
|
||||
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
|
||||
.setSessionCookieName(SESSION_COOKIE_NAME);
|
||||
if (Config.isProd()) {
|
||||
sessionHandler
|
||||
.setCookieSameSite(CookieSameSite.STRICT);
|
||||
} else {
|
||||
sessionHandler
|
||||
.setCookieSameSite(CookieSameSite.NONE)
|
||||
.setCookieSecureFlag(true);
|
||||
}
|
||||
router.route()
|
||||
.handler(sessionHandler)
|
||||
.handler(BodyHandler.create());
|
||||
|
||||
if (!Config.isProd()) {
|
||||
router.route()
|
||||
.handler(CorsHandler.create()
|
||||
.addRelativeOrigin("http://localhost:3000")
|
||||
.allowedMethod(HttpMethod.GET)
|
||||
.allowedMethod(HttpMethod.POST)
|
||||
.allowedMethod(HttpMethod.PUT)
|
||||
.allowedMethod(HttpMethod.DELETE)
|
||||
.allowedMethod(HttpMethod.OPTIONS)
|
||||
.allowCredentials(true)
|
||||
.allowedHeader("Access-Control-Request-Method")
|
||||
.allowedHeader("Access-Control-Allow-Credentials")
|
||||
.allowedHeader("Access-Control-Allow-Origin")
|
||||
.allowedHeader("Access-Control-Allow-Headers")
|
||||
.allowedHeader("Content-Type")
|
||||
);
|
||||
}
|
||||
|
||||
HealthChecks hc = HealthChecks.create(vertx);
|
||||
hc.register("http-server", Promise::complete);
|
||||
|
||||
router.get("/health").handler(HealthCheckHandler.createWithHealthChecks(hc));
|
||||
router.get("/").handler(ctx -> ctx.response().end("Hello World!"));
|
||||
router.route("/ws").handler(this::handleWebSocket);
|
||||
|
||||
router.get("/settings").handler(this::handleSettings);
|
||||
router.post("/settings/create").handler(this::handleSettingsCreate);
|
||||
|
||||
router.post("/telegram/create").handler(this::handleTelegramCreate);
|
||||
router.post("/telegram/:telegramId/delete").handler(this::handleTelegramDelete);
|
||||
router.post("/telegram/api/:method").handler(this::handleTelegramApi);
|
||||
router.get("/telegrams").handler(this::handleTelegrams);
|
||||
router.get("/telegram/:telegramId/chats").handler(this::handleTelegramChats);
|
||||
router.get("/telegram/:telegramId/chat/:chatId/files").handler(this::handleTelegramFiles);
|
||||
router.get("/telegram/:telegramId/chat/:chatId/files/count").handler(this::handleTelegramFilesCount);
|
||||
router.get("/telegram/:telegramId/download-statistics").handler(this::handleTelegramDownloadStatistics);
|
||||
router.post("/telegrams/change").handler(this::handleTelegramChange);
|
||||
|
||||
router.get("/file/preview").handler(this::handleFilePreview);
|
||||
router.post("/file/start-download").handler(this::handleFileStartDownload);
|
||||
router.post("/file/cancel-download").handler(this::handleFileCancelDownload);
|
||||
router.post("/file/toggle-pause-download").handler(this::handleFileTogglePauseDownload);
|
||||
router.post("/file/auto-download").handler(this::handleAutoDownload);
|
||||
|
||||
router.route()
|
||||
.failureHandler(ctx -> {
|
||||
int statusCode = ctx.statusCode();
|
||||
if (statusCode < 500) {
|
||||
if (ctx.response().ended()) {
|
||||
return;
|
||||
}
|
||||
ctx.response().setStatusCode(statusCode).end();
|
||||
return;
|
||||
}
|
||||
Throwable throwable = ctx.failure();
|
||||
log.error("route: %s statusCode: %d Error: %s".formatted(
|
||||
ctx.currentRoute().getName(),
|
||||
statusCode,
|
||||
throwable == null ? "" : throwable.getMessage()));
|
||||
HttpServerResponse response = ctx.response();
|
||||
response.setStatusCode(statusCode)
|
||||
.putHeader("Content-Type", "application/json")
|
||||
.end(JsonObject.of("error", throwable == null ? "☹️Sorry! Not today." : throwable.getMessage()).encode());
|
||||
});
|
||||
return router;
|
||||
}
|
||||
|
||||
public Future<Void> initTelegramVerticles() {
|
||||
return DataVerticle.telegramRepository.getAll()
|
||||
.compose(telegramRecords -> {
|
||||
List<String> verifiedPath = telegramRecords.stream().map(TelegramRecord::rootPath).toList();
|
||||
File telegramRoot = FileUtil.file(Config.TELEGRAM_ROOT);
|
||||
List<String> uncertifiedPaths = FileUtil.loopFiles(telegramRoot, 1, null)
|
||||
.stream()
|
||||
.filter(f -> f.isDirectory() && !verifiedPath.contains(f.getAbsolutePath()))
|
||||
.map(File::getAbsolutePath)
|
||||
.toList();
|
||||
List<Future<String>> futures = new ArrayList<>();
|
||||
for (TelegramRecord telegramRecord : telegramRecords) {
|
||||
TelegramVerticle telegramVerticle = new TelegramVerticle(telegramRecord);
|
||||
if (!telegramVerticle.check()) {
|
||||
continue;
|
||||
}
|
||||
telegramVerticles.add(telegramVerticle);
|
||||
futures.add(vertx.deployVerticle(telegramVerticle));
|
||||
}
|
||||
if (CollUtil.isNotEmpty(uncertifiedPaths)) {
|
||||
for (String uncertifiedPath : uncertifiedPaths) {
|
||||
TelegramVerticle telegramVerticle = new TelegramVerticle(uncertifiedPath);
|
||||
if (!telegramVerticle.check()) {
|
||||
continue;
|
||||
}
|
||||
telegramVerticles.add(telegramVerticle);
|
||||
futures.add(vertx.deployVerticle(telegramVerticle));
|
||||
}
|
||||
}
|
||||
return Future.all(futures);
|
||||
})
|
||||
.onSuccess(r -> log.info("Successfully deployed %d telegram verticles".formatted(r.size())))
|
||||
.onFailure(err -> log.error("Failed to deploy telegram verticles: %s".formatted(err.getMessage())))
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
public Future<Void> initAutoDownloadVerticle() {
|
||||
return vertx.deployVerticle(new AutoDownloadVerticle(), new DeploymentOptions().setThreadingModel(ThreadingModel.WORKER))
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
private void handleWebSocket(RoutingContext ctx) {
|
||||
String sessionId = ctx.session().id();
|
||||
ctx.request().toWebSocket()
|
||||
.onSuccess(ws -> {
|
||||
log.debug("Upgraded to WebSocket. SessionId: %s".formatted(sessionId));
|
||||
clients.put(sessionId, ws.textHandlerID());
|
||||
|
||||
long timerId = vertx.setPeriodic(30000, id -> {
|
||||
if (!ws.isClosed()) {
|
||||
ws.writePing(Buffer.buffer("👀"));
|
||||
log.debug("Ping Client: %s".formatted(sessionId));
|
||||
}
|
||||
});
|
||||
|
||||
ws.exceptionHandler(throwable -> log.error("WebSocket error: %s".formatted(throwable.getMessage())));
|
||||
ws.closeHandler(e -> {
|
||||
clients.remove(sessionId);
|
||||
vertx.cancelTimer(timerId);
|
||||
log.debug("WebSocket closed. SessionId: %s".formatted(sessionId));
|
||||
});
|
||||
|
||||
ws.textMessageHandler(text -> {
|
||||
log.debug("Received WebSocket message: " + text);
|
||||
});
|
||||
})
|
||||
.onFailure(err -> log.warn("Failed to upgrade to WebSocket: %s".formatted(err.getMessage())));
|
||||
}
|
||||
|
||||
private void handleSettingsCreate(RoutingContext ctx) {
|
||||
JsonObject object = ctx.body().asJsonObject();
|
||||
if (CollUtil.isEmpty(object)) {
|
||||
ctx.fail(400);
|
||||
return;
|
||||
}
|
||||
|
||||
Future.all(object.stream()
|
||||
.map(setting -> DataVerticle.settingRepository.createOrUpdate(setting.getKey(),
|
||||
Convert.toStr(setting.getValue(), "")))
|
||||
.toList())
|
||||
.map(CompositeFuture::<SettingRecord>list)
|
||||
.onSuccess(records -> {
|
||||
records.forEach(record ->
|
||||
vertx.eventBus().publish(EventEnum.SETTING_UPDATE.address(record.key()), record.value()));
|
||||
ctx.end();
|
||||
})
|
||||
.onFailure(ctx::fail);
|
||||
}
|
||||
|
||||
private void handleSettings(RoutingContext ctx) {
|
||||
String keys = ctx.request().getParam("keys");
|
||||
if (StrUtil.isBlank(keys)) {
|
||||
ctx.fail(400);
|
||||
return;
|
||||
}
|
||||
DataVerticle.settingRepository
|
||||
.getByKeys(Arrays.asList(keys.split(",")))
|
||||
.onSuccess(settings -> {
|
||||
JsonObject object = new JsonObject();
|
||||
for (SettingRecord record : settings) {
|
||||
object.put(record.key(), record.value());
|
||||
}
|
||||
ctx.json(object);
|
||||
})
|
||||
.onFailure(ctx::fail);
|
||||
}
|
||||
|
||||
private void handleTelegramCreate(RoutingContext ctx) {
|
||||
String sessionId = ctx.session().id();
|
||||
TelegramVerticle telegramVerticle = sessionTelegramVerticles.get(sessionId);
|
||||
if (telegramVerticle != null && !telegramVerticle.authorized) {
|
||||
ctx.json(new JsonObject()
|
||||
.put("id", telegramVerticle.getId())
|
||||
.put("lastState", telegramVerticle.lastAuthorizationState)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
TelegramVerticle newTelegramVerticle = new TelegramVerticle(DataVerticle.telegramRepository.getRootPath());
|
||||
newTelegramVerticle.bindHttpSession(sessionId);
|
||||
sessionTelegramVerticles.put(sessionId, newTelegramVerticle);
|
||||
telegramVerticles.add(newTelegramVerticle);
|
||||
vertx.deployVerticle(newTelegramVerticle)
|
||||
.onSuccess(id -> ctx.json(new JsonObject()
|
||||
.put("id", newTelegramVerticle.getId())
|
||||
.put("lastState", newTelegramVerticle.lastAuthorizationState)
|
||||
))
|
||||
.onFailure(ctx::fail);
|
||||
}
|
||||
|
||||
private void handleTelegramDelete(RoutingContext ctx) {
|
||||
String telegramId = ctx.pathParam("telegramId");
|
||||
if (StrUtil.isBlank(telegramId)) {
|
||||
ctx.fail(400);
|
||||
return;
|
||||
}
|
||||
Optional<TelegramVerticle> telegramVerticleOptional = getTelegramVerticle(telegramId);
|
||||
if (telegramVerticleOptional.isEmpty()) {
|
||||
ctx.fail(404);
|
||||
return;
|
||||
}
|
||||
TelegramVerticle telegramVerticle = telegramVerticleOptional.get();
|
||||
telegramVerticle.stop();
|
||||
telegramVerticles.remove(telegramVerticle);
|
||||
sessionTelegramVerticles.entrySet().removeIf(e -> e.getValue().equals(telegramVerticle));
|
||||
ctx.end();
|
||||
}
|
||||
|
||||
private void handleTelegrams(RoutingContext ctx) {
|
||||
Boolean authorized = Convert.toBool(ctx.request().getParam("authorized"));
|
||||
Future.all(telegramVerticles.stream()
|
||||
.filter(c -> authorized == null || c.authorized == authorized)
|
||||
.map(TelegramVerticle::getTelegramAccount)
|
||||
.toList()
|
||||
)
|
||||
.map(CompositeFuture::list)
|
||||
.onSuccess(ctx::json)
|
||||
.onFailure(ctx::fail);
|
||||
}
|
||||
|
||||
private void handleTelegramChats(RoutingContext ctx) {
|
||||
String telegramId = ctx.pathParam("telegramId");
|
||||
if (StrUtil.isBlank(telegramId)) {
|
||||
ctx.fail(400);
|
||||
return;
|
||||
}
|
||||
String query = ctx.request().getParam("query");
|
||||
String chatId = ctx.request().getParam("chatId");
|
||||
getTelegramVerticle(telegramId)
|
||||
.ifPresentOrElse(telegramVerticle -> {
|
||||
telegramVerticle.getChats(Convert.toLong(chatId), query)
|
||||
.onSuccess(ctx::json)
|
||||
.onFailure(ctx::fail);
|
||||
}, () -> ctx.fail(404));
|
||||
}
|
||||
|
||||
private void handleTelegramFiles(RoutingContext ctx) {
|
||||
String telegramId = ctx.pathParam("telegramId");
|
||||
if (StrUtil.isBlank(telegramId)) {
|
||||
ctx.fail(400);
|
||||
return;
|
||||
}
|
||||
String chatIdStr = ctx.pathParam("chatId");
|
||||
if (StrUtil.isBlank(chatIdStr)) {
|
||||
ctx.fail(400);
|
||||
return;
|
||||
}
|
||||
long chatId = Convert.toLong(chatIdStr);
|
||||
getTelegramVerticle(telegramId)
|
||||
.ifPresentOrElse(telegramVerticle ->
|
||||
telegramVerticle.getChatFiles(chatId, ctx.request().params())
|
||||
.onSuccess(ctx::json)
|
||||
.onFailure(ctx::fail),
|
||||
() -> ctx.fail(404));
|
||||
}
|
||||
|
||||
private void handleTelegramFilesCount(RoutingContext ctx) {
|
||||
String telegramId = ctx.pathParam("telegramId");
|
||||
if (StrUtil.isBlank(telegramId)) {
|
||||
ctx.fail(400);
|
||||
return;
|
||||
}
|
||||
String chatIdStr = ctx.pathParam("chatId");
|
||||
if (StrUtil.isBlank(chatIdStr)) {
|
||||
ctx.fail(400);
|
||||
return;
|
||||
}
|
||||
long chatId = Convert.toLong(chatIdStr);
|
||||
getTelegramVerticle(telegramId)
|
||||
.ifPresentOrElse(telegramVerticle ->
|
||||
telegramVerticle.getChatFilesCount(chatId)
|
||||
.onSuccess(ctx::json)
|
||||
.onFailure(ctx::fail),
|
||||
() -> ctx.fail(404));
|
||||
}
|
||||
|
||||
private void handleTelegramDownloadStatistics(RoutingContext ctx) {
|
||||
String telegramId = ctx.pathParam("telegramId");
|
||||
if (StrUtil.isBlank(telegramId)) {
|
||||
ctx.fail(400);
|
||||
return;
|
||||
}
|
||||
getTelegramVerticle(telegramId)
|
||||
.ifPresentOrElse(telegramVerticle ->
|
||||
telegramVerticle.getDownloadStatistics()
|
||||
.onSuccess(ctx::json)
|
||||
.onFailure(ctx::fail),
|
||||
() -> ctx.fail(404));
|
||||
}
|
||||
|
||||
private void handleTelegramChange(RoutingContext ctx) {
|
||||
String sessionId = ctx.session().id();
|
||||
String telegramId = ctx.request().getParam("telegramId");
|
||||
if (StrUtil.isBlank(telegramId)) {
|
||||
sessionTelegramVerticles.remove(sessionId);
|
||||
ctx.end();
|
||||
}
|
||||
getTelegramVerticle(telegramId)
|
||||
.ifPresentOrElse(telegramVerticle -> {
|
||||
telegramVerticle.bindHttpSession(sessionId);
|
||||
sessionTelegramVerticles.put(sessionId, telegramVerticle);
|
||||
ctx.end();
|
||||
}, () -> ctx.fail(404));
|
||||
}
|
||||
|
||||
private void handleTelegramApi(RoutingContext ctx) {
|
||||
String method = ctx.pathParam("method");
|
||||
if (method == null) {
|
||||
ctx.fail(400);
|
||||
return;
|
||||
}
|
||||
String sessionId = ctx.session().id();
|
||||
TelegramVerticle telegramVerticle = getTelegramVerticle(ctx);
|
||||
if (telegramVerticle == null) {
|
||||
return;
|
||||
}
|
||||
JsonObject params = ctx.body().asJsonObject();
|
||||
telegramVerticle.bindHttpSession(sessionId);
|
||||
telegramVerticle.execute(method, params == null ? null : params.getMap())
|
||||
.onSuccess(code -> ctx.json(JsonObject.of("code", code)))
|
||||
.onFailure(ctx::fail);
|
||||
}
|
||||
|
||||
private void handleFilePreview(RoutingContext ctx) {
|
||||
TelegramVerticle telegramVerticle = getTelegramVerticle(ctx);
|
||||
if (telegramVerticle == null) {
|
||||
return;
|
||||
}
|
||||
String chatId = ctx.request().getParam("chatId");
|
||||
String messageId = ctx.request().getParam("messageId");
|
||||
if (StrUtil.isBlank(chatId) || StrUtil.isBlank(messageId)) {
|
||||
ctx.fail(400);
|
||||
return;
|
||||
}
|
||||
|
||||
telegramVerticle.loadPreview(Convert.toLong(chatId), Convert.toLong(messageId))
|
||||
.onSuccess(fileIdOrPath -> {
|
||||
if (fileIdOrPath == null) {
|
||||
ctx.end();
|
||||
return;
|
||||
}
|
||||
if (fileIdOrPath instanceof Integer fileId) {
|
||||
ctx.json(JsonObject.of("fileId", fileId));
|
||||
return;
|
||||
}
|
||||
String path = (String) fileIdOrPath;
|
||||
|
||||
ctx.response()
|
||||
.putHeader("Content-Type", FileUtil.getMimeType(path))
|
||||
.end(Buffer.buffer(FileUtil.readBytes(path)));
|
||||
})
|
||||
.onFailure(ctx::fail);
|
||||
}
|
||||
|
||||
private void handleFileStartDownload(RoutingContext ctx) {
|
||||
TelegramVerticle telegramVerticle = getTelegramVerticle(ctx);
|
||||
if (telegramVerticle == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
JsonObject jsonObject = ctx.body().asJsonObject();
|
||||
Long chatId = jsonObject.getLong("chatId");
|
||||
Long messageId = jsonObject.getLong("messageId");
|
||||
Integer fileId = jsonObject.getInteger("fileId");
|
||||
if (chatId == null || messageId == null || fileId == null) {
|
||||
ctx.fail(400);
|
||||
return;
|
||||
}
|
||||
|
||||
telegramVerticle.startDownload(chatId, messageId, fileId)
|
||||
.onSuccess(ctx::json)
|
||||
.onFailure(ctx::fail);
|
||||
}
|
||||
|
||||
private void handleFileCancelDownload(RoutingContext ctx) {
|
||||
TelegramVerticle telegramVerticle = getTelegramVerticle(ctx);
|
||||
if (telegramVerticle == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
JsonObject jsonObject = ctx.body().asJsonObject();
|
||||
Integer fileId = jsonObject.getInteger("fileId");
|
||||
if (fileId == null) {
|
||||
ctx.fail(400);
|
||||
return;
|
||||
}
|
||||
|
||||
telegramVerticle.cancelDownload(fileId)
|
||||
.onSuccess(r -> ctx.end())
|
||||
.onFailure(ctx::fail);
|
||||
}
|
||||
|
||||
private void handleFileTogglePauseDownload(RoutingContext ctx) {
|
||||
TelegramVerticle telegramVerticle = getTelegramVerticle(ctx);
|
||||
if (telegramVerticle == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
JsonObject jsonObject = ctx.body().asJsonObject();
|
||||
Integer fileId = jsonObject.getInteger("fileId");
|
||||
Boolean isPaused = jsonObject.getBoolean("isPaused");
|
||||
if (fileId == null || isPaused == null) {
|
||||
ctx.fail(400);
|
||||
return;
|
||||
}
|
||||
|
||||
telegramVerticle.togglePauseDownload(fileId, isPaused)
|
||||
.onSuccess(r -> ctx.end())
|
||||
.onFailure(ctx::fail);
|
||||
}
|
||||
|
||||
private void handleAutoDownload(RoutingContext ctx) {
|
||||
TelegramVerticle telegramVerticle = getTelegramVerticle(ctx);
|
||||
if (telegramVerticle == null) {
|
||||
return;
|
||||
}
|
||||
String chatId = ctx.request().getParam("chatId");
|
||||
if (StrUtil.isBlank(chatId)) {
|
||||
ctx.fail(400);
|
||||
return;
|
||||
}
|
||||
|
||||
telegramVerticle.toggleAutoDownload(Convert.toLong(chatId))
|
||||
.onSuccess(r -> ctx.end())
|
||||
.onFailure(ctx::fail);
|
||||
}
|
||||
|
||||
public static Optional<TelegramVerticle> getTelegramVerticle(String telegramId) {
|
||||
Object id = NumberUtil.isNumber(telegramId) ? Convert.toLong(telegramId) : telegramId;
|
||||
return telegramVerticles.stream()
|
||||
.filter(t -> Objects.equals(t.getId(), id))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
public static Optional<TelegramVerticle> getTelegramVerticle(long telegramId) {
|
||||
return telegramVerticles.stream()
|
||||
.filter(t -> t.telegramRecord != null && t.telegramRecord.id() == telegramId)
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
public static String getWSHandlerId(String sessionId) {
|
||||
return clients.get(sessionId);
|
||||
}
|
||||
|
||||
private TelegramVerticle getTelegramVerticle(RoutingContext ctx) {
|
||||
String sessionId = ctx.session().id();
|
||||
TelegramVerticle telegramVerticle = sessionTelegramVerticles.get(sessionId);
|
||||
if (telegramVerticle == null) {
|
||||
ctx.response().setStatusCode(400)
|
||||
.end(JsonObject.of("error", "Your session not link any telegram!").encode());
|
||||
return null;
|
||||
}
|
||||
return telegramVerticle;
|
||||
}
|
||||
}
|
||||
31
api/src/main/java/telegram/files/Start.java
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package telegram.files;
|
||||
|
||||
import cn.hutool.log.Log;
|
||||
import cn.hutool.log.LogFactory;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Vertx;
|
||||
|
||||
public class Start {
|
||||
private static final Log log = LogFactory.get();
|
||||
|
||||
public static final String VERSION = "0.0.0";
|
||||
|
||||
public static void main(String[] args) {
|
||||
Vertx vertx = Vertx.vertx();
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
vertx.close().onComplete(res -> {
|
||||
if (res.succeeded()) {
|
||||
log.info("👋 Shutdown success");
|
||||
} else {
|
||||
log.error("😱 Shutdown failed", res.cause());
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
vertx.deployVerticle(new DataVerticle())
|
||||
.compose(id -> vertx.deployVerticle(new HttpVerticle()))
|
||||
.onSuccess(id -> log.info("🚀Start success"))
|
||||
.onFailure(err -> log.error("😱Start failed", err));
|
||||
}
|
||||
|
||||
}
|
||||
439
api/src/main/java/telegram/files/TdApiHelp.java
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
package telegram.files;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.hutool.core.bean.copier.CopyOptions;
|
||||
import cn.hutool.core.codec.Base64;
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.convert.TypeConverter;
|
||||
import cn.hutool.core.util.ClassUtil;
|
||||
import cn.hutool.core.util.ReflectUtil;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.impl.NoStackTraceException;
|
||||
import org.drinkless.tdlib.TdApi;
|
||||
import org.jooq.lambda.tuple.Tuple;
|
||||
import org.jooq.lambda.tuple.Tuple1;
|
||||
import telegram.files.repository.FileRecord;
|
||||
import telegram.files.repository.SettingKey;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public class TdApiHelp {
|
||||
|
||||
// name -> class
|
||||
private static final Map<String, Class<TdApi.Function<?>>> FUNCTIONS = new HashMap<>();
|
||||
|
||||
// CONSTRUCTOR -> class
|
||||
private static final Map<Integer, Class<TdApi.Object>> CONSTRUCTORS = new HashMap<>();
|
||||
|
||||
public static final List<Integer> FILE_CONTENT_CONSTRUCTORS = Arrays.asList(
|
||||
TdApi.MessagePhoto.CONSTRUCTOR,
|
||||
TdApi.MessageVideo.CONSTRUCTOR,
|
||||
TdApi.MessageAudio.CONSTRUCTOR,
|
||||
TdApi.MessageDocument.CONSTRUCTOR
|
||||
);
|
||||
|
||||
private static final TypeConverter TD_API_TYPE_CONVERTER = (targetType, value) -> {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Map) {
|
||||
Map<String, Object> map = (Map<String, Object>) value;
|
||||
if (!map.containsKey("@type")) {
|
||||
return Convert.convertWithCheck(targetType, value, null, false);
|
||||
}
|
||||
Integer constructor = Convert.toInt(map.get("@type"));
|
||||
Class<TdApi.Object> objClazz = CONSTRUCTORS.get(constructor);
|
||||
if (objClazz == null) {
|
||||
return null;
|
||||
}
|
||||
return BeanUtil.toBean(map, objClazz);
|
||||
}
|
||||
return Convert.convertWithCheck(targetType, value, null, false);
|
||||
};
|
||||
|
||||
private static final CopyOptions COPY_OPTIONS = new CopyOptions().setConverter(TD_API_TYPE_CONVERTER);
|
||||
|
||||
static {
|
||||
Arrays.stream(TdApi.class.getClasses())
|
||||
.filter(ClassUtil::isNormalClass)
|
||||
.filter(TdApi.Object.class::isAssignableFrom)
|
||||
.forEach(clazz -> {
|
||||
if (TdApi.Function.class.isAssignableFrom(clazz)) {
|
||||
FUNCTIONS.put(clazz.getSimpleName(), (Class<TdApi.Function<?>>) clazz);
|
||||
}
|
||||
|
||||
Object constructor = ReflectUtil.getStaticFieldValue(ReflectUtil.getField(clazz, "CONSTRUCTOR"));
|
||||
if (constructor != null) {
|
||||
CONSTRUCTORS.put(Convert.toInt(constructor), (Class<TdApi.Object>) clazz);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static TdApi.Function<?> getFunction(String method, Object params) {
|
||||
Class<TdApi.Function<?>> func = FUNCTIONS.get(method);
|
||||
if (func == null) {
|
||||
return null;
|
||||
}
|
||||
if (params == null) {
|
||||
return ReflectUtil.newInstance(func);
|
||||
}
|
||||
return BeanUtil.toBean(params, func, COPY_OPTIONS);
|
||||
}
|
||||
|
||||
public static String getChatType(TdApi.ChatType type) {
|
||||
return switch (type.getConstructor()) {
|
||||
case TdApi.ChatTypePrivate.CONSTRUCTOR -> "private";
|
||||
case TdApi.ChatTypeBasicGroup.CONSTRUCTOR -> "group";
|
||||
case TdApi.ChatTypeSupergroup.CONSTRUCTOR ->
|
||||
((TdApi.ChatTypeSupergroup) type).isChannel ? "channel" : "group";
|
||||
case TdApi.ChatTypeSecret.CONSTRUCTOR -> "secret";
|
||||
default -> "unknown";
|
||||
};
|
||||
}
|
||||
|
||||
public static TdApi.SearchMessagesFilter getSearchMessagesFilter(String fileType) {
|
||||
return switch (fileType) {
|
||||
case "media" -> new TdApi.SearchMessagesFilterPhotoAndVideo();
|
||||
case "photo" -> new TdApi.SearchMessagesFilterPhoto();
|
||||
case "video" -> new TdApi.SearchMessagesFilterVideo();
|
||||
case "audio" -> new TdApi.SearchMessagesFilterAudio();
|
||||
case "file" -> new TdApi.SearchMessagesFilterDocument();
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
public static String getSearchMessagesFilterType(TdApi.SearchMessagesFilter filter) {
|
||||
return switch (filter.getConstructor()) {
|
||||
case TdApi.SearchMessagesFilterPhotoAndVideo.CONSTRUCTOR -> "media";
|
||||
case TdApi.SearchMessagesFilterPhoto.CONSTRUCTOR -> "photo";
|
||||
case TdApi.SearchMessagesFilterVideo.CONSTRUCTOR -> "video";
|
||||
case TdApi.SearchMessagesFilterAudio.CONSTRUCTOR -> "audio";
|
||||
case TdApi.SearchMessagesFilterDocument.CONSTRUCTOR -> "file";
|
||||
default -> "unknown";
|
||||
};
|
||||
}
|
||||
|
||||
public static List<Integer> getFileIds(List<TdApi.Message> messages) {
|
||||
if (CollUtil.isEmpty(messages)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return messages.stream()
|
||||
.filter(message -> FILE_CONTENT_CONSTRUCTORS.contains(message.content.getConstructor()))
|
||||
.map(TdApiHelp::getFileId)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public static List<String> getFileUniqueIds(List<TdApi.Message> messages) {
|
||||
if (CollUtil.isEmpty(messages)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return messages.stream()
|
||||
.filter(message -> FILE_CONTENT_CONSTRUCTORS.contains(message.content.getConstructor()))
|
||||
.map(TdApiHelp::getFileUniqueId)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public static Integer getFileId(TdApi.Message message) {
|
||||
return getFileHandler(message).map(FileHandler::getFileId).orElse(null);
|
||||
}
|
||||
|
||||
public static String getFileUniqueId(TdApi.Message message) {
|
||||
return getFileHandler(message).map(FileHandler::getFileUniqueId).orElse(null);
|
||||
}
|
||||
|
||||
public static List<TdApi.Message> filterUniqueMessages(List<TdApi.Message> messages) {
|
||||
if (CollUtil.isEmpty(messages)) return messages;
|
||||
|
||||
Set<Integer> fileIds = new HashSet<>();
|
||||
return messages.stream()
|
||||
.filter(message -> {
|
||||
Integer fileId = TdApiHelp.getFileId(message);
|
||||
if (fileId == null) return false;
|
||||
if (fileIds.contains(fileId)) {
|
||||
return false;
|
||||
}
|
||||
fileIds.add(fileId);
|
||||
return true;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
public static FileRecord.DownloadStatus getDownloadStatus(TdApi.File file) {
|
||||
if (file == null || file.local == null) {
|
||||
return null;
|
||||
}
|
||||
if (file.local.isDownloadingActive) {
|
||||
return FileRecord.DownloadStatus.downloading;
|
||||
} else if (file.local.isDownloadingCompleted) {
|
||||
return FileRecord.DownloadStatus.completed;
|
||||
} else {
|
||||
return FileRecord.DownloadStatus.paused;
|
||||
}
|
||||
}
|
||||
|
||||
public static <T extends FileHandler<? extends TdApi.MessageContent>> Optional<T> getFileHandler(TdApi.Message message) {
|
||||
if (message == null) return Optional.empty();
|
||||
switch (message.content.getConstructor()) {
|
||||
case TdApi.MessagePhoto.CONSTRUCTOR -> {
|
||||
return Optional.of((T) new PhotoHandler(message));
|
||||
}
|
||||
case TdApi.MessageVideo.CONSTRUCTOR -> {
|
||||
return Optional.of((T) new VideoHandler(message));
|
||||
}
|
||||
case TdApi.MessageAudio.CONSTRUCTOR -> {
|
||||
return Optional.of((T) new AudioHandler(message));
|
||||
}
|
||||
case TdApi.MessageDocument.CONSTRUCTOR -> {
|
||||
return Optional.of((T) new DocumentHandler(message));
|
||||
}
|
||||
default -> {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static abstract class FileHandler<T extends TdApi.MessageContent> {
|
||||
protected TdApi.Message message;
|
||||
|
||||
protected T content;
|
||||
|
||||
public FileHandler(TdApi.Message message) {
|
||||
this.message = message;
|
||||
this.content = (T) message.content;
|
||||
}
|
||||
|
||||
public abstract Integer getFileId();
|
||||
|
||||
public abstract String getFileUniqueId();
|
||||
|
||||
public TdApi.File getPreviewFileId(Tuple tuple) {
|
||||
throw new UnsupportedOperationException("This message type does not support preview file");
|
||||
}
|
||||
|
||||
public abstract FileRecord convertFileRecord(long telegramId);
|
||||
|
||||
public T getContent() {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
public static class PhotoHandler extends FileHandler<TdApi.MessagePhoto> {
|
||||
|
||||
public PhotoHandler(TdApi.Message message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getFileId() {
|
||||
return content.photo.sizes[content.photo.sizes.length - 1].photo.id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFileUniqueId() {
|
||||
return content.photo.sizes[content.photo.sizes.length - 1].photo.remote.uniqueId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TdApi.File getPreviewFileId(Tuple tuple) {
|
||||
TdApi.MessagePhoto messagePhoto = this.content;
|
||||
String size = ((Tuple1<String>) tuple).v1;
|
||||
|
||||
TdApi.PhotoSize photoSize = Arrays.stream(messagePhoto.photo.sizes)
|
||||
.map(ComparablePhotoSize::new)
|
||||
.filter(comparablePhotoSize -> comparablePhotoSize.compareTo(size) <= 0)
|
||||
.max((o1, o2) -> o2.compareTo(o1.getPhotoSize()))
|
||||
.map(ComparablePhotoSize::getPhotoSize)
|
||||
.stream().findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (photoSize == null) {
|
||||
throw new NoStackTraceException("The photo no size that less than or equal to " + size);
|
||||
}
|
||||
|
||||
return photoSize.photo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileRecord convertFileRecord(long telegramId) {
|
||||
TdApi.PhotoSize photoSize = content.photo.sizes[content.photo.sizes.length - 1];
|
||||
return new FileRecord(
|
||||
getFileId(),
|
||||
photoSize.photo.remote.uniqueId,
|
||||
telegramId,
|
||||
message.chatId,
|
||||
message.id,
|
||||
message.date,
|
||||
message.hasSensitiveContent,
|
||||
photoSize.photo.size == 0 ? photoSize.photo.expectedSize : photoSize.photo.size,
|
||||
photoSize.photo.local == null ? 0 : photoSize.photo.local.downloadedSize,
|
||||
"photo",
|
||||
null,
|
||||
null,
|
||||
Base64.encode((byte[]) BeanUtil.getProperty(content, "photo.minithumbnail.data")),
|
||||
content.caption.text,
|
||||
null,
|
||||
"idle"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static class VideoHandler extends FileHandler<TdApi.MessageVideo> {
|
||||
|
||||
public VideoHandler(TdApi.Message message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getFileId() {
|
||||
return content.video.video.id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFileUniqueId() {
|
||||
return content.video.video.remote.uniqueId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TdApi.File getPreviewFileId(Tuple tuple) {
|
||||
TdApi.MessageVideo messageVideo = this.content;
|
||||
TdApi.Thumbnail thumbnail = messageVideo.video.thumbnail;
|
||||
|
||||
if (thumbnail == null) {
|
||||
throw new NoStackTraceException("Video thumbnail not found");
|
||||
}
|
||||
|
||||
return thumbnail.file;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public FileRecord convertFileRecord(long telegramId) {
|
||||
TdApi.File file = content.video.video;
|
||||
return new FileRecord(
|
||||
file.id,
|
||||
file.remote.uniqueId,
|
||||
telegramId,
|
||||
message.chatId,
|
||||
message.id,
|
||||
message.date,
|
||||
message.hasSensitiveContent,
|
||||
file.size == 0 ? file.expectedSize : file.size,
|
||||
file.local == null ? 0 : file.local.downloadedSize,
|
||||
"video",
|
||||
content.video.mimeType,
|
||||
content.video.fileName,
|
||||
Base64.encode((byte[]) BeanUtil.getProperty(content, "video.minithumbnail.data")),
|
||||
content.caption.text,
|
||||
null,
|
||||
"idle"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static class AudioHandler extends FileHandler<TdApi.MessageAudio> {
|
||||
|
||||
public AudioHandler(TdApi.Message message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getFileId() {
|
||||
return content.audio.audio.id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFileUniqueId() {
|
||||
return content.audio.audio.remote.uniqueId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileRecord convertFileRecord(long telegramId) {
|
||||
TdApi.File file = content.audio.audio;
|
||||
return new FileRecord(
|
||||
file.id,
|
||||
file.remote.uniqueId,
|
||||
telegramId,
|
||||
message.chatId,
|
||||
message.id,
|
||||
message.date,
|
||||
message.hasSensitiveContent,
|
||||
file.size == 0 ? file.expectedSize : file.size,
|
||||
file.local == null ? 0 : file.local.downloadedSize,
|
||||
"audio",
|
||||
content.audio.mimeType,
|
||||
content.audio.fileName,
|
||||
Base64.encode((byte[]) BeanUtil.getProperty(content, "audio.albumCoverMinithumbnail.data")),
|
||||
content.caption.text,
|
||||
null,
|
||||
"idle"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static class DocumentHandler extends FileHandler<TdApi.MessageDocument> {
|
||||
|
||||
public DocumentHandler(TdApi.Message message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getFileId() {
|
||||
return content.document.document.id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFileUniqueId() {
|
||||
return content.document.document.remote.uniqueId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileRecord convertFileRecord(long telegramId) {
|
||||
TdApi.File file = content.document.document;
|
||||
return new FileRecord(
|
||||
file.id,
|
||||
file.remote.uniqueId,
|
||||
telegramId,
|
||||
message.chatId,
|
||||
message.id,
|
||||
message.date,
|
||||
message.hasSensitiveContent,
|
||||
file.size == 0 ? file.expectedSize : file.size,
|
||||
file.local == null ? 0 : file.local.downloadedSize,
|
||||
"file",
|
||||
content.document.mimeType,
|
||||
content.document.fileName,
|
||||
Base64.encode((byte[]) BeanUtil.getProperty(content, "document.minithumbnail.data")),
|
||||
content.caption.text,
|
||||
null,
|
||||
"idle"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ComparablePhotoSize implements Comparable<TdApi.PhotoSize> {
|
||||
private final TdApi.PhotoSize photoSize;
|
||||
|
||||
private final String[] sizes = {"s", "m", "x", "y", "w", "a", "b", "c", "d"};
|
||||
|
||||
public ComparablePhotoSize(TdApi.PhotoSize photoSize) {
|
||||
this.photoSize = photoSize;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(TdApi.PhotoSize o) {
|
||||
return Integer.compare(Arrays.binarySearch(sizes, photoSize.type), Arrays.binarySearch(sizes, o.type));
|
||||
}
|
||||
|
||||
public int compareTo(String size) {
|
||||
return Integer.compare(Arrays.binarySearch(sizes, photoSize.type), Arrays.binarySearch(sizes, size));
|
||||
}
|
||||
|
||||
public TdApi.PhotoSize getPhotoSize() {
|
||||
return photoSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
63
api/src/main/java/telegram/files/TelegramUpdateHandler.java
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package telegram.files;
|
||||
|
||||
import cn.hutool.log.Log;
|
||||
import cn.hutool.log.LogFactory;
|
||||
import org.drinkless.tdlib.Client;
|
||||
import org.drinkless.tdlib.TdApi;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public class TelegramUpdateHandler implements Client.ResultHandler {
|
||||
|
||||
private static final Log log = LogFactory.get();
|
||||
|
||||
private Consumer<TdApi.AuthorizationState> onAuthorizationStateUpdated;
|
||||
|
||||
private Consumer<TdApi.UpdateFile> onFileUpdated;
|
||||
|
||||
private Consumer<TdApi.UpdateFileDownloads> onFileDownloadsUpdated;
|
||||
|
||||
private Consumer<TdApi.Message> onMessageReceived;
|
||||
|
||||
@Override
|
||||
public void onResult(TdApi.Object object) {
|
||||
switch (object.getConstructor()) {
|
||||
case TdApi.UpdateAuthorizationState.CONSTRUCTOR:
|
||||
if (onAuthorizationStateUpdated != null)
|
||||
onAuthorizationStateUpdated.accept(((TdApi.UpdateAuthorizationState) object).authorizationState);
|
||||
break;
|
||||
case TdApi.UpdateFile.CONSTRUCTOR:
|
||||
if (onFileUpdated != null)
|
||||
onFileUpdated.accept((TdApi.UpdateFile) object);
|
||||
case TdApi.UpdateFileDownload.CONSTRUCTOR:
|
||||
log.debug("File download update: %s".formatted(object));
|
||||
break;
|
||||
case TdApi.UpdateFileDownloads.CONSTRUCTOR:
|
||||
if (onFileDownloadsUpdated != null)
|
||||
onFileDownloadsUpdated.accept((TdApi.UpdateFileDownloads) object);
|
||||
break;
|
||||
case TdApi.UpdateNewMessage.CONSTRUCTOR:
|
||||
if (onMessageReceived != null) {
|
||||
onMessageReceived.accept(((TdApi.UpdateNewMessage) object).message);
|
||||
}
|
||||
default:
|
||||
log.trace("Unsupported telegram update: %s".formatted(object));
|
||||
}
|
||||
}
|
||||
|
||||
public void setOnAuthorizationStateUpdated(Consumer<TdApi.AuthorizationState> onAuthorizationStateUpdated) {
|
||||
this.onAuthorizationStateUpdated = onAuthorizationStateUpdated;
|
||||
}
|
||||
|
||||
public void setOnFileUpdated(Consumer<TdApi.UpdateFile> onFileUpdated) {
|
||||
this.onFileUpdated = onFileUpdated;
|
||||
}
|
||||
|
||||
public void setOnFileDownloadsUpdated(Consumer<TdApi.UpdateFileDownloads> onFileDownloadsUpdated) {
|
||||
this.onFileDownloadsUpdated = onFileDownloadsUpdated;
|
||||
}
|
||||
|
||||
public void setOnMessageReceived(Consumer<TdApi.Message> onMessageReceived) {
|
||||
this.onMessageReceived = onMessageReceived;
|
||||
}
|
||||
}
|
||||
632
api/src/main/java/telegram/files/TelegramVerticle.java
Normal file
|
|
@ -0,0 +1,632 @@
|
|||
package telegram.files;
|
||||
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.hutool.core.codec.Base64;
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.core.util.TypeUtil;
|
||||
import cn.hutool.log.Log;
|
||||
import cn.hutool.log.LogFactory;
|
||||
import io.vertx.core.AbstractVerticle;
|
||||
import io.vertx.core.CompositeFuture;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.MultiMap;
|
||||
import io.vertx.core.impl.NoStackTraceException;
|
||||
import io.vertx.core.json.Json;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import org.drinkless.tdlib.Client;
|
||||
import org.drinkless.tdlib.TdApi;
|
||||
import org.jooq.lambda.tuple.Tuple;
|
||||
import org.jooq.lambda.tuple.Tuple2;
|
||||
import telegram.files.repository.FileRecord;
|
||||
import telegram.files.repository.SettingAutoRecords;
|
||||
import telegram.files.repository.SettingKey;
|
||||
import telegram.files.repository.TelegramRecord;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOError;
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class TelegramVerticle extends AbstractVerticle {
|
||||
|
||||
private static final Log log = LogFactory.get();
|
||||
|
||||
private Client client;
|
||||
|
||||
private volatile String httpSessionId;
|
||||
|
||||
public boolean authorized = false;
|
||||
|
||||
public TdApi.AuthorizationState lastAuthorizationState;
|
||||
|
||||
public String rootPath;
|
||||
|
||||
private String rootId;
|
||||
|
||||
public TelegramRecord telegramRecord;
|
||||
|
||||
static {
|
||||
Client.setLogMessageHandler(0, new LogMessageHandler());
|
||||
|
||||
try {
|
||||
Client.execute(new TdApi.SetLogVerbosityLevel(0));
|
||||
Client.execute(new TdApi.SetLogStream(new TdApi.LogStreamFile("tdlib.log", 1 << 27, false)));
|
||||
} catch (Client.ExecutionException error) {
|
||||
throw new IOError(new IOException("Write access to the current directory is required"));
|
||||
}
|
||||
}
|
||||
|
||||
public TelegramVerticle(String rootPath) {
|
||||
this.rootPath = rootPath;
|
||||
}
|
||||
|
||||
public TelegramVerticle(TelegramRecord telegramRecord) {
|
||||
this.telegramRecord = telegramRecord;
|
||||
this.rootPath = telegramRecord.rootPath();
|
||||
}
|
||||
|
||||
public String getRootId() {
|
||||
if (StrUtil.isNotBlank(this.rootId)) return rootId;
|
||||
|
||||
this.rootId = StrUtil.subAfter(this.rootPath, '-', true);
|
||||
return this.rootId;
|
||||
}
|
||||
|
||||
public Object getId() {
|
||||
return telegramRecord == null ? this.getRootId() : telegramRecord.id();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
TelegramUpdateHandler telegramUpdateHandler = new TelegramUpdateHandler();
|
||||
telegramUpdateHandler.setOnAuthorizationStateUpdated(this::onAuthorizationStateUpdated);
|
||||
telegramUpdateHandler.setOnFileUpdated(this::onFileUpdated);
|
||||
telegramUpdateHandler.setOnFileDownloadsUpdated(this::onFileDownloadsUpdated);
|
||||
telegramUpdateHandler.setOnMessageReceived(this::onMessageReceived);
|
||||
client = Client.create(telegramUpdateHandler, this::handleException, this::handleException);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
if (!this.authorized || this.telegramRecord == null) {
|
||||
File root = FileUtil.file(this.rootPath);
|
||||
if (root.exists()) {
|
||||
FileUtil.del(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean check() {
|
||||
if (StrUtil.isBlank(this.rootPath) || !FileUtil.exist(this.rootPath)) {
|
||||
log.error("[%s] Telegram account is invalid, root path: %s not exist.".formatted(this.getRootId(), this.rootPath));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void bindHttpSession(String httpSessionId) {
|
||||
this.httpSessionId = httpSessionId;
|
||||
}
|
||||
|
||||
public Future<JsonObject> getTelegramAccount() {
|
||||
return Future.future(promise -> {
|
||||
if (!authorized) {
|
||||
JsonObject jsonObject = new JsonObject()
|
||||
.put("id", this.getRootId())
|
||||
.put("name", this.getRootId())
|
||||
.put("phoneNumber", "")
|
||||
.put("avatar", "")
|
||||
.put("status", "inactive")
|
||||
.put("rootPath", this.rootPath)
|
||||
.put("isPremium", false)
|
||||
.put("lastAuthorizationState", lastAuthorizationState);
|
||||
if (this.telegramRecord != null) {
|
||||
jsonObject.put("id", Convert.toStr(this.telegramRecord.id()))
|
||||
.put("name", this.telegramRecord.firstName());
|
||||
}
|
||||
promise.complete(jsonObject);
|
||||
return;
|
||||
}
|
||||
this.execute(new TdApi.GetMe())
|
||||
.onSuccess(user -> {
|
||||
JsonObject result = new JsonObject()
|
||||
.put("id", Convert.toStr(user.id))
|
||||
.put("name", StrUtil.join(user.firstName, " ", user.lastName))
|
||||
.put("phoneNumber", user.phoneNumber)
|
||||
.put("avatar", Base64.encode((byte[]) BeanUtil.getProperty(user, "profilePhoto.minithumbnail.data")))
|
||||
.put("status", "active")
|
||||
.put("rootPath", this.rootPath)
|
||||
.put("isPremium", user.isPremium);
|
||||
promise.complete(result);
|
||||
})
|
||||
.onFailure(e -> {
|
||||
log.error("[%s] Failed to get telegram account: %s".formatted(this.getRootId(), e.getMessage()));
|
||||
promise.fail(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public Future<JsonArray> getChats(Long activatedChatId, String query) {
|
||||
Consumer<TdApi.Chats> insertActivatedChatId = chats -> {
|
||||
long[] chatIds = chats.chatIds;
|
||||
if (activatedChatId != null && !ArrayUtil.contains(chatIds, activatedChatId)) {
|
||||
chatIds = (long[]) ArrayUtil.insert(chatIds, 0, activatedChatId);
|
||||
chats.chatIds = chatIds;
|
||||
}
|
||||
};
|
||||
|
||||
if (StrUtil.isBlank(query)) {
|
||||
return this.execute(new TdApi.GetChats(new TdApi.ChatListMain(), 10))
|
||||
.map(chats -> {
|
||||
insertActivatedChatId.accept(chats);
|
||||
return chats;
|
||||
})
|
||||
.compose(chats -> Future.all(Arrays.stream(chats.chatIds)
|
||||
.mapToObj(chatId -> this.execute(new TdApi.GetChat(chatId)))
|
||||
.toList())
|
||||
)
|
||||
.map(CompositeFuture::<TdApi.Chat>list)
|
||||
.compose(this::convertChat);
|
||||
} else {
|
||||
return this.execute(new TdApi.SearchChatsOnServer(query, 10))
|
||||
.map(chats -> {
|
||||
insertActivatedChatId.accept(chats);
|
||||
return chats;
|
||||
})
|
||||
.compose(chats -> Future.all(Arrays.stream(chats.chatIds)
|
||||
.mapToObj(chatId -> this.execute(new TdApi.GetChat(chatId)))
|
||||
.toList())
|
||||
)
|
||||
.map(CompositeFuture::<TdApi.Chat>list)
|
||||
.compose(this::convertChat);
|
||||
}
|
||||
}
|
||||
|
||||
public Future<JsonObject> getChatFiles(long chatId, MultiMap filter) {
|
||||
TdApi.SearchChatMessages searchChatMessages = new TdApi.SearchChatMessages();
|
||||
searchChatMessages.chatId = chatId;
|
||||
searchChatMessages.query = filter.get("search");
|
||||
searchChatMessages.fromMessageId = Convert.toLong(filter.get("fromMessageId"), 0L);
|
||||
searchChatMessages.offset = Convert.toInt(filter.get("offset"), 0);
|
||||
searchChatMessages.limit = Convert.toInt(filter.get("limit"), 20);
|
||||
searchChatMessages.filter = TdApiHelp.getSearchMessagesFilter(filter.get("type"));
|
||||
|
||||
return this.execute(searchChatMessages)
|
||||
.compose(foundChatMessages ->
|
||||
DataVerticle.fileRepository.getFilesByUniqueId(TdApiHelp.getFileUniqueIds(Arrays.asList(foundChatMessages.messages)))
|
||||
.map(fileRecords -> Tuple.tuple(foundChatMessages, fileRecords)))
|
||||
.compose(this::convertFiles);
|
||||
}
|
||||
|
||||
public Future<JsonObject> getChatFilesCount(long chatId) {
|
||||
return Future.all(
|
||||
Stream.of(new TdApi.SearchMessagesFilterPhotoAndVideo(),
|
||||
new TdApi.SearchMessagesFilterPhoto(),
|
||||
new TdApi.SearchMessagesFilterVideo(),
|
||||
new TdApi.SearchMessagesFilterAudio(),
|
||||
new TdApi.SearchMessagesFilterDocument())
|
||||
.map(filter -> this.execute(
|
||||
new TdApi.GetChatMessageCount(chatId,
|
||||
filter,
|
||||
0,
|
||||
false)
|
||||
)
|
||||
.map(count -> new JsonObject()
|
||||
.put("type", TdApiHelp.getSearchMessagesFilterType(filter))
|
||||
.put("count", count.count)
|
||||
)
|
||||
)
|
||||
.toList()
|
||||
).map(counts -> {
|
||||
JsonObject result = new JsonObject();
|
||||
counts.<JsonObject>list().forEach(count -> result.put(count.getString("type"), count.getInteger("count")));
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
public Future<Object> loadPreview(long chatId, long messageId) {
|
||||
return DataVerticle.settingRepository
|
||||
.getByKey(SettingKey.needToLoadImages)
|
||||
.compose(needToLoadImages -> {
|
||||
if (!(boolean) needToLoadImages) {
|
||||
return Future.failedFuture("Need to load images is disabled");
|
||||
}
|
||||
return this.execute(new TdApi.GetMessage(chatId, messageId));
|
||||
})
|
||||
.compose(message -> {
|
||||
TdApiHelp.FileHandler<? extends TdApi.MessageContent> fileHandler = TdApiHelp.getFileHandler(message)
|
||||
.orElseThrow(() -> new NoStackTraceException("not support message type"));
|
||||
|
||||
return DataVerticle.settingRepository.getByKey(SettingKey.imageLoadSize)
|
||||
.map(size -> Tuple.tuple(message, fileHandler.getPreviewFileId(Tuple.tuple(size))));
|
||||
})
|
||||
.compose(tuple -> {
|
||||
TdApi.Message message = tuple.v1;
|
||||
TdApi.File file = tuple.v2;
|
||||
if (file.local != null
|
||||
&& file.local.isDownloadingCompleted
|
||||
&& FileUtil.exist(file.local.path)) {
|
||||
return Future.succeededFuture(file.local.path);
|
||||
}
|
||||
|
||||
return savePreviewFile(message, file)
|
||||
.compose(r -> {
|
||||
TdApi.DownloadFile downloadFile = new TdApi.DownloadFile();
|
||||
downloadFile.fileId = file.id;
|
||||
downloadFile.priority = 32;
|
||||
downloadFile.synchronous = true;
|
||||
|
||||
return this.execute(downloadFile)
|
||||
.map(file.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private Future<Void> savePreviewFile(TdApi.Message message, TdApi.File file) {
|
||||
return Future.future(promise -> {
|
||||
if (TdApiHelp.getFileId(message) != file.id) {
|
||||
promise.complete();
|
||||
} else {
|
||||
DataVerticle.fileRepository.getByUniqueId(file.remote.uniqueId)
|
||||
.onSuccess(fileRecord -> {
|
||||
if (fileRecord != null) {
|
||||
promise.complete();
|
||||
} else {
|
||||
DataVerticle.fileRepository.create(TdApiHelp.getFileHandler(message).get().convertFileRecord(telegramRecord.id()))
|
||||
.onSuccess(r -> promise.complete())
|
||||
.onFailure(promise::fail);
|
||||
}
|
||||
})
|
||||
.onFailure(promise::fail);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Future<TdApi.File> startDownload(Long chatId, Long messageId, Integer fileId) {
|
||||
return Future.all(
|
||||
this.execute(new TdApi.GetFile(fileId)),
|
||||
this.execute(new TdApi.GetMessage(chatId, messageId))
|
||||
)
|
||||
.compose(results -> {
|
||||
TdApi.File file = results.resultAt(0);
|
||||
TdApi.Message message = results.resultAt(1);
|
||||
if (file.local != null) {
|
||||
if (file.local.isDownloadingCompleted) {
|
||||
return Future.failedFuture("File already downloaded");
|
||||
}
|
||||
if (file.local.isDownloadingActive) {
|
||||
return Future.failedFuture("File is downloading");
|
||||
}
|
||||
// return Future.failedFuture("Unknown file download status");
|
||||
}
|
||||
|
||||
TdApiHelp.FileHandler<? extends TdApi.MessageContent> fileHandler = TdApiHelp.getFileHandler(message)
|
||||
.orElseThrow(() -> new NoStackTraceException("not support message type"));
|
||||
FileRecord fileRecord = fileHandler.convertFileRecord(telegramRecord.id());
|
||||
return DataVerticle.fileRepository.create(fileRecord)
|
||||
.compose(r ->
|
||||
this.execute(new TdApi.AddFileToDownloads(fileId, chatId, messageId, 32))
|
||||
)
|
||||
.onSuccess(r ->
|
||||
sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject()
|
||||
.put("fileId", fileId)
|
||||
.put("downloadStatus", FileRecord.DownloadStatus.downloading)
|
||||
))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public Future<Void> cancelDownload(Integer fileId) {
|
||||
return this.execute(new TdApi.GetFile(fileId))
|
||||
.compose(file -> {
|
||||
if (file.local == null || !file.local.isDownloadingActive) {
|
||||
return Future.failedFuture("File not started downloading");
|
||||
}
|
||||
|
||||
return this.execute(new TdApi.CancelDownloadFile(fileId, false));
|
||||
})
|
||||
.onSuccess(r ->
|
||||
sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject()
|
||||
.put("fileId", fileId)
|
||||
.put("downloadStatus", FileRecord.DownloadStatus.idle)
|
||||
)))
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
public Future<Void> togglePauseDownload(Integer fileId, boolean isPaused) {
|
||||
return this.execute(new TdApi.GetFile(fileId))
|
||||
.compose(file -> DataVerticle.fileRepository
|
||||
.updateFileId(file.id, file.remote.uniqueId)
|
||||
.map(file)
|
||||
)
|
||||
.compose(file -> {
|
||||
if (file.local == null) {
|
||||
return Future.failedFuture("File not started downloading");
|
||||
}
|
||||
if (isPaused && !file.local.isDownloadingActive) {
|
||||
return Future.failedFuture("File is not downloading");
|
||||
}
|
||||
if (!isPaused && file.local.isDownloadingActive) {
|
||||
return Future.failedFuture("File is downloading");
|
||||
}
|
||||
|
||||
return this.execute(new TdApi.ToggleDownloadIsPaused(fileId, isPaused));
|
||||
})
|
||||
.onSuccess(r ->
|
||||
sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject()
|
||||
.put("fileId", fileId)
|
||||
.put("downloadStatus", isPaused ? FileRecord.DownloadStatus.paused : FileRecord.DownloadStatus.downloading)
|
||||
)))
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
public Future<Void> toggleAutoDownload(Long chatId) {
|
||||
return DataVerticle.settingRepository.<SettingAutoRecords>getByKey(SettingKey.autoDownload)
|
||||
.compose(settingAutoRecords -> {
|
||||
if (settingAutoRecords == null) {
|
||||
settingAutoRecords = new SettingAutoRecords();
|
||||
}
|
||||
if (settingAutoRecords.exists(this.telegramRecord.id(), chatId)) {
|
||||
settingAutoRecords.remove(this.telegramRecord.id(), chatId);
|
||||
} else {
|
||||
settingAutoRecords.add(this.telegramRecord.id(), chatId);
|
||||
}
|
||||
return DataVerticle.settingRepository.createOrUpdate(SettingKey.autoDownload.name(), Json.encode(settingAutoRecords));
|
||||
})
|
||||
.onSuccess(r -> vertx.eventBus().publish(EventEnum.AUTO_DOWNLOAD_UPDATE.name(), r.value()))
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
public Future<JsonObject> getDownloadStatistics() {
|
||||
return DataVerticle.fileRepository.getDownloadStatistics(this.telegramRecord.id());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public <R extends TdApi.Object> Future<R> execute(TdApi.Function<R> method) {
|
||||
log.trace("[%s] Execute method: %s".formatted(getRootId(), TypeUtil.getTypeArgument(method.getClass())));
|
||||
return Future.future(promise -> {
|
||||
if (!authorized) {
|
||||
promise.fail("Telegram account not found or not authorized");
|
||||
return;
|
||||
}
|
||||
client.send(method, object -> {
|
||||
if (object.getConstructor() == TdApi.Error.CONSTRUCTOR) {
|
||||
promise.fail("Execute method failed. " + object);
|
||||
} else {
|
||||
promise.complete((R) object);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public Future<String> execute(String method, Object params) {
|
||||
String code = RandomUtil.randomString(10);
|
||||
log.trace("[%s] Execute code: %s method: %s, params: %s".formatted(getRootId(), code, method, params));
|
||||
return Future.future(promise -> {
|
||||
TdApi.Function<?> func = TdApiHelp.getFunction(method, params);
|
||||
if (func == null) {
|
||||
promise.fail("Unsupported method: " + method);
|
||||
return;
|
||||
}
|
||||
client.send(func, object -> {
|
||||
log.debug("[%s] Execute: [%s] Receive result: %s".formatted(getRootId(), code, object));
|
||||
handleDefaultResult(object, code);
|
||||
});
|
||||
promise.complete(code);
|
||||
});
|
||||
}
|
||||
|
||||
private void sendHttpEvent(EventPayload payload) {
|
||||
if (this.httpSessionId == null) return;
|
||||
|
||||
String address = HttpVerticle.getWSHandlerId(httpSessionId);
|
||||
if (address == null) {
|
||||
log.debug("[%s] Can not found websocket textHandlerID for session id:%s".formatted(getRootId(), httpSessionId));
|
||||
return;
|
||||
}
|
||||
vertx.eventBus().send(address, Json.encode(payload));
|
||||
}
|
||||
|
||||
private void handleAuthorizationResult(TdApi.Object object) {
|
||||
switch (object.getConstructor()) {
|
||||
case TdApi.Error.CONSTRUCTOR:
|
||||
sendHttpEvent(EventPayload.build(EventPayload.TYPE_ERROR, object));
|
||||
break;
|
||||
case TdApi.Ok.CONSTRUCTOR:
|
||||
break;
|
||||
default:
|
||||
log.warn("[%s] Receive UpdateAuthorizationState with invalid authorization state%s".formatted(getRootId(), object));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleDefaultResult(TdApi.Object object, String code) {
|
||||
if (object.getConstructor() == TdApi.Error.CONSTRUCTOR) {
|
||||
sendHttpEvent(EventPayload.build(EventPayload.TYPE_ERROR, code, object));
|
||||
} else {
|
||||
sendHttpEvent(EventPayload.build(EventPayload.TYPE_METHOD_RESULT, code, object));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleException(Throwable e) {
|
||||
log.error(e);
|
||||
}
|
||||
|
||||
private void onAuthorizationStateUpdated(TdApi.AuthorizationState authorizationState) {
|
||||
log.debug("[%s] Receive authorization state update: %s".formatted(getRootId(), authorizationState));
|
||||
this.lastAuthorizationState = authorizationState;
|
||||
switch (authorizationState.getConstructor()) {
|
||||
case TdApi.AuthorizationStateWaitTdlibParameters.CONSTRUCTOR:
|
||||
TdApi.SetTdlibParameters request = new TdApi.SetTdlibParameters();
|
||||
request.databaseDirectory = this.rootPath;
|
||||
request.useMessageDatabase = true;
|
||||
request.useFileDatabase = true;
|
||||
request.useChatInfoDatabase = true;
|
||||
request.useSecretChats = true;
|
||||
request.apiId = Config.TELEGRAM_API_ID;
|
||||
request.apiHash = Config.TELEGRAM_API_HASH;
|
||||
request.systemLanguageCode = "en";
|
||||
request.deviceModel = "Telegram Files";
|
||||
request.applicationVersion = Start.VERSION;
|
||||
log.debug("[%s] Send SetTdlibParameters: %s".formatted(getRootId(), request));
|
||||
|
||||
client.send(request, this::handleAuthorizationResult);
|
||||
break;
|
||||
case TdApi.AuthorizationStateWaitPhoneNumber.CONSTRUCTOR:
|
||||
case TdApi.AuthorizationStateWaitOtherDeviceConfirmation.CONSTRUCTOR:
|
||||
case TdApi.AuthorizationStateWaitEmailAddress.CONSTRUCTOR:
|
||||
case TdApi.AuthorizationStateWaitEmailCode.CONSTRUCTOR:
|
||||
case TdApi.AuthorizationStateWaitCode.CONSTRUCTOR:
|
||||
case TdApi.AuthorizationStateWaitRegistration.CONSTRUCTOR:
|
||||
case TdApi.AuthorizationStateWaitPassword.CONSTRUCTOR:
|
||||
sendHttpEvent(EventPayload.build(EventPayload.TYPE_AUTHORIZATION, authorizationState));
|
||||
break;
|
||||
case TdApi.AuthorizationStateReady.CONSTRUCTOR:
|
||||
authorized = true;
|
||||
if (telegramRecord == null) {
|
||||
this.execute(new TdApi.GetMe())
|
||||
.compose(user ->
|
||||
DataVerticle.telegramRepository.create(new TelegramRecord(user.id, user.firstName, this.rootPath))
|
||||
)
|
||||
.compose(record -> {
|
||||
telegramRecord = record;
|
||||
return this.execute(new TdApi.LoadChats(new TdApi.ChatListMain(), 10));
|
||||
})
|
||||
.onSuccess(o -> log.info("[%s] %s Authorization Ready".formatted(getRootId(), this.telegramRecord.firstName())))
|
||||
.onFailure(e -> log.error("[%s] Authorization Ready, but failed to create telegram record: %s".formatted(getRootId(), e.getMessage())));
|
||||
} else {
|
||||
log.info("[%s] %s Authorization Ready".formatted(getRootId(), this.telegramRecord.firstName()));
|
||||
}
|
||||
sendHttpEvent(EventPayload.build(EventPayload.TYPE_AUTHORIZATION, authorizationState));
|
||||
break;
|
||||
case TdApi.AuthorizationStateLoggingOut.CONSTRUCTOR:
|
||||
break;
|
||||
case TdApi.AuthorizationStateClosing.CONSTRUCTOR:
|
||||
break;
|
||||
case TdApi.AuthorizationStateClosed.CONSTRUCTOR:
|
||||
break;
|
||||
default:
|
||||
log.warn("[%s] Unsupported authorization state received:%s".formatted(this.getRootId(), authorizationState));
|
||||
}
|
||||
}
|
||||
|
||||
private void onFileUpdated(TdApi.UpdateFile updateFile) {
|
||||
log.debug("[%s] Receive file update: %s".formatted(getRootId(), updateFile));
|
||||
TdApi.File file = updateFile.file;
|
||||
if (file != null) {
|
||||
String localPath = null;
|
||||
if (file.local != null && file.local.isDownloadingCompleted) {
|
||||
localPath = file.local.path;
|
||||
}
|
||||
FileRecord.DownloadStatus downloadStatus = TdApiHelp.getDownloadStatus(file);
|
||||
DataVerticle.fileRepository.updateStatus(file.id,
|
||||
file.remote.uniqueId,
|
||||
localPath,
|
||||
downloadStatus)
|
||||
.onSuccess(r -> {
|
||||
if (r == null || r.isEmpty()) return;
|
||||
sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject()
|
||||
.put("fileId", file.id)
|
||||
.put("downloadStatus", r.getString("downloadStatus"))
|
||||
.put("localPath", r.getString("localPath"))
|
||||
));
|
||||
});
|
||||
}
|
||||
sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE, updateFile));
|
||||
}
|
||||
|
||||
private void onFileDownloadsUpdated(TdApi.UpdateFileDownloads updateFileDownloads) {
|
||||
log.debug("[%s] Receive file downloads update: %s".formatted(getRootId(), updateFileDownloads));
|
||||
sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE_DOWNLOAD, updateFileDownloads));
|
||||
}
|
||||
|
||||
private void onMessageReceived(TdApi.Message message) {
|
||||
log.debug("[%s] Receive message: %s".formatted(getRootId(), message));
|
||||
vertx.eventBus().publish(EventEnum.MESSAGE_RECEIVED.address(), JsonObject.of()
|
||||
.put("telegramId", telegramRecord.id())
|
||||
.put("chatId", message.chatId)
|
||||
.put("messageId", message.id)
|
||||
);
|
||||
}
|
||||
|
||||
private static class LogMessageHandler implements Client.LogMessageHandler {
|
||||
|
||||
@Override
|
||||
public void onLogMessage(int verbosityLevel, String message) {
|
||||
log.debug("TDLib: %s".formatted(message));
|
||||
}
|
||||
}
|
||||
|
||||
//<-----------------------------convert---------------------------------->
|
||||
|
||||
private Future<JsonArray> convertChat(List<TdApi.Chat> chats) {
|
||||
return DataVerticle.settingRepository.<SettingAutoRecords>getByKey(SettingKey.autoDownload)
|
||||
.map(settingAutoRecords -> settingAutoRecords == null ? Collections.emptySet()
|
||||
: settingAutoRecords.getChatIds(this.telegramRecord.id()))
|
||||
.map(enableAutoChatIds -> new JsonArray(chats.stream()
|
||||
.map(chat -> new JsonObject()
|
||||
.put("id", Convert.toStr(chat.id))
|
||||
.put("name", chat.id == this.telegramRecord.id() ? "Saved Messages" : chat.title)
|
||||
.put("type", TdApiHelp.getChatType(chat.type))
|
||||
.put("avatar", Base64.encode((byte[]) BeanUtil.getProperty(chat, "photo.minithumbnail.data")))
|
||||
.put("unreadCount", chat.unreadCount)
|
||||
.put("lastMessage", "")
|
||||
.put("lastMessageTime", "")
|
||||
.put("autoEnabled", enableAutoChatIds.contains(chat.id))
|
||||
)
|
||||
.toList()
|
||||
));
|
||||
}
|
||||
|
||||
private Future<JsonObject> convertFiles(Tuple2<TdApi.FoundChatMessages, Map<String, FileRecord>> tuple) {
|
||||
TdApi.FoundChatMessages foundChatMessages = tuple.v1;
|
||||
Map<String, FileRecord> fileRecords = tuple.v2;
|
||||
|
||||
return DataVerticle.settingRepository.<Boolean>getByKey(SettingKey.uniqueOnly)
|
||||
.map(uniqueOnly -> {
|
||||
if (!uniqueOnly) {
|
||||
return Arrays.asList(foundChatMessages.messages);
|
||||
}
|
||||
return TdApiHelp.filterUniqueMessages(Arrays.asList(foundChatMessages.messages));
|
||||
})
|
||||
.map(messages -> {
|
||||
List<JsonObject> fileObjects = messages.stream()
|
||||
.filter(message -> TdApiHelp.FILE_CONTENT_CONSTRUCTORS.contains(message.content.getConstructor()))
|
||||
.map(message -> {
|
||||
FileRecord source = TdApiHelp.getFileHandler(message)
|
||||
.map(fileHandler -> fileHandler.convertFileRecord(telegramRecord.id()))
|
||||
.orElse(null);
|
||||
if (source == null) {
|
||||
return null;
|
||||
}
|
||||
FileRecord fileRecord = fileRecords.get(TdApiHelp.getFileUniqueId(message));
|
||||
if (fileRecord == null) {
|
||||
fileRecord = source;
|
||||
} else {
|
||||
fileRecord = fileRecord.withSourceField(source.id(), source.downloadedSize());
|
||||
}
|
||||
//TODO Processing of the same file under different accounts
|
||||
|
||||
JsonObject fileObject = JsonObject.mapFrom(fileRecord);
|
||||
fileObject.put("formatDate", DateUtil.date(fileObject.getLong("date") * 1000).toString());
|
||||
return fileObject;
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
return new JsonObject()
|
||||
.put("files", new JsonArray(fileObjects))
|
||||
.put("count", foundChatMessages.totalCount)
|
||||
.put("size", fileObjects.size())
|
||||
.put("nextFromMessageId", foundChatMessages.nextFromMessageId);
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
99
api/src/main/java/telegram/files/repository/FileRecord.java
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package telegram.files.repository;
|
||||
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import io.vertx.sqlclient.templates.RowMapper;
|
||||
import io.vertx.sqlclient.templates.TupleMapper;
|
||||
|
||||
public record FileRecord(int id, //file id will change
|
||||
String uniqueId, // unique id of the file, if empty, it means the file is cant be downloaded
|
||||
long telegramId,
|
||||
long chatId,
|
||||
long messageId,
|
||||
int date, // date when the file was uploaded
|
||||
boolean hasSensitiveContent,
|
||||
long size, // file size in bytes
|
||||
long downloadedSize, // always 0 from db, should be got from telegram client
|
||||
String type, // 'photo' | 'video' | 'audio' | 'file'
|
||||
String mimeType,
|
||||
String fileName,
|
||||
String thumbnail,
|
||||
String caption,
|
||||
String localPath,
|
||||
String downloadStatus // 'idle' | 'downloading' | 'paused' | 'completed' | 'error'
|
||||
) {
|
||||
|
||||
public enum DownloadStatus {
|
||||
idle, downloading, paused, completed, error
|
||||
}
|
||||
|
||||
public static final String SCHEME = """
|
||||
CREATE TABLE IF NOT EXISTS file_record
|
||||
(
|
||||
id INT,
|
||||
unique_id VARCHAR(255),
|
||||
telegram_id BIGINT,
|
||||
chat_id BIGINT,
|
||||
message_id BIGINT,
|
||||
date INT,
|
||||
has_sensitive_content BOOLEAN,
|
||||
size BIGINT,
|
||||
downloaded_size BIGINT,
|
||||
type VARCHAR(255),
|
||||
mime_type VARCHAR(255),
|
||||
file_name VARCHAR(255),
|
||||
thumbnail VARCHAR(255),
|
||||
caption VARCHAR(255),
|
||||
local_path VARCHAR(255),
|
||||
download_status VARCHAR(255),
|
||||
PRIMARY KEY (id, unique_id)
|
||||
)
|
||||
""";
|
||||
|
||||
public static RowMapper<FileRecord> ROW_MAPPER = row ->
|
||||
new FileRecord(row.getInteger("id"),
|
||||
row.getString("unique_id"),
|
||||
row.getLong("telegram_id"),
|
||||
row.getLong("chat_id"),
|
||||
row.getLong("message_id"),
|
||||
row.getInteger("date"),
|
||||
Convert.toBool(row.getInteger("has_sensitive_content")),
|
||||
row.getLong("size"),
|
||||
row.getLong("downloaded_size"),
|
||||
row.getString("type"),
|
||||
row.getString("mime_type"),
|
||||
row.getString("file_name"),
|
||||
row.getString("thumbnail"),
|
||||
row.getString("caption"),
|
||||
row.getString("local_path"),
|
||||
row.getString("download_status")
|
||||
);
|
||||
|
||||
public static TupleMapper<FileRecord> PARAM_MAPPER = TupleMapper.mapper(r ->
|
||||
MapUtil.ofEntries(
|
||||
MapUtil.entry("id", r.id),
|
||||
MapUtil.entry("unique_id", r.uniqueId()),
|
||||
MapUtil.entry("telegram_id", r.telegramId()),
|
||||
MapUtil.entry("chat_id", r.chatId()),
|
||||
MapUtil.entry("message_id", r.messageId()),
|
||||
MapUtil.entry("date", r.date()),
|
||||
MapUtil.entry("has_sensitive_content", r.hasSensitiveContent()),
|
||||
MapUtil.entry("size", r.size()),
|
||||
MapUtil.entry("downloaded_size", r.downloadedSize()),
|
||||
MapUtil.entry("type", r.type()),
|
||||
MapUtil.entry("mime_type", r.mimeType()),
|
||||
MapUtil.entry("file_name", r.fileName()),
|
||||
MapUtil.entry("thumbnail", r.thumbnail()),
|
||||
MapUtil.entry("caption", r.caption()),
|
||||
MapUtil.entry("local_path", r.localPath()),
|
||||
MapUtil.entry("download_status", r.downloadStatus())
|
||||
));
|
||||
|
||||
public FileRecord withSourceField(int id, long downloadedSize) {
|
||||
if (this.downloadedSize == downloadedSize) {
|
||||
return this;
|
||||
}
|
||||
return new FileRecord(id, uniqueId, telegramId, chatId, messageId, date, hasSensitiveContent, size, downloadedSize, type, mimeType, fileName, thumbnail, caption, localPath, downloadStatus);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package telegram.files.repository;
|
||||
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public interface FileRepository {
|
||||
Future<Void> init();
|
||||
|
||||
Future<FileRecord> create(FileRecord fileRecord);
|
||||
|
||||
Future<Map<Integer, FileRecord>> getFiles(long chatId, List<Integer> fileIds);
|
||||
|
||||
Future<Map<String, FileRecord>> getFilesByUniqueId(List<String> uniqueIds);
|
||||
|
||||
Future<FileRecord> getByPrimaryKey(int fileId, String uniqueId);
|
||||
|
||||
Future<FileRecord> getByUniqueId(String uniqueId);
|
||||
|
||||
Future<JsonObject> getDownloadStatistics(long telegramId);
|
||||
|
||||
Future<JsonObject> updateStatus(int fileId,
|
||||
String uniqueId,
|
||||
String localPath,
|
||||
FileRecord.DownloadStatus downloadStatus);
|
||||
|
||||
Future<Void> updateFileId(int fileId, String uniqueId);
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package telegram.files.repository;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class SettingAutoRecords {
|
||||
|
||||
public List<Item> items;
|
||||
|
||||
public static class Item {
|
||||
public long telegramId;
|
||||
|
||||
public long chatId;
|
||||
|
||||
public String nextFileType;
|
||||
|
||||
public long nextFromMessageId;
|
||||
|
||||
public Item() {
|
||||
}
|
||||
|
||||
public Item(long telegramId, long chatId) {
|
||||
this.telegramId = telegramId;
|
||||
this.chatId = chatId;
|
||||
}
|
||||
|
||||
public String uniqueKey() {
|
||||
return telegramId + ":" + chatId;
|
||||
}
|
||||
}
|
||||
|
||||
public SettingAutoRecords() {
|
||||
this.items = new ArrayList<>();
|
||||
}
|
||||
|
||||
public SettingAutoRecords(List<Item> items) {
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
public boolean exists(long telegramId, long chatId) {
|
||||
return items.stream().anyMatch(item -> item.telegramId == telegramId && item.chatId == chatId);
|
||||
}
|
||||
|
||||
public void add(Item item) {
|
||||
items.removeIf(i -> i.telegramId == item.telegramId && i.chatId == item.chatId);
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
public void add(long telegramId, long chatId) {
|
||||
items.add(new Item(telegramId, chatId));
|
||||
}
|
||||
|
||||
public void remove(long telegramId, long chatId) {
|
||||
items.removeIf(item -> item.telegramId == telegramId && item.chatId == chatId);
|
||||
}
|
||||
|
||||
public Set<Long> getChatIds(long telegramId) {
|
||||
return items.stream()
|
||||
.filter(item -> item.telegramId == telegramId)
|
||||
.map(item -> item.chatId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
}
|
||||
29
api/src/main/java/telegram/files/repository/SettingKey.java
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package telegram.files.repository;
|
||||
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
public enum SettingKey {
|
||||
uniqueOnly(Convert::toBool),
|
||||
needToLoadImages(Convert::toBool),
|
||||
imageLoadSize,
|
||||
showSensitiveContent(Convert::toBool),
|
||||
autoDownload(value -> new JsonObject(value).mapTo(SettingAutoRecords.class)),
|
||||
/**
|
||||
* Auto download limit for each telegram account
|
||||
*/
|
||||
autoDownloadLimit(Convert::toInt),
|
||||
;
|
||||
|
||||
public final Function<String, ?> converter;
|
||||
|
||||
SettingKey() {
|
||||
this.converter = Function.identity();
|
||||
}
|
||||
|
||||
SettingKey(Function<String, ?> converter) {
|
||||
this.converter = converter;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package telegram.files.repository;
|
||||
|
||||
import io.vertx.sqlclient.templates.RowMapper;
|
||||
import io.vertx.sqlclient.templates.TupleMapper;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public record SettingRecord(String key, String value) {
|
||||
|
||||
public static final String SCHEME = """
|
||||
CREATE TABLE IF NOT EXISTS setting_record
|
||||
(
|
||||
key VARCHAR(255) PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
""";
|
||||
|
||||
public static RowMapper<SettingRecord> ROW_MAPPER = row ->
|
||||
new SettingRecord(row.getString("key"),
|
||||
row.getString("value")
|
||||
);
|
||||
|
||||
public static TupleMapper<SettingRecord> PARAM_MAPPER = TupleMapper.mapper(r ->
|
||||
Map.ofEntries(Map.entry("key", r.key()),
|
||||
Map.entry("value", r.value())
|
||||
));
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package telegram.files.repository;
|
||||
|
||||
import io.vertx.core.Future;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface SettingRepository {
|
||||
|
||||
Future<Void> init();
|
||||
|
||||
Future<SettingRecord> createOrUpdate(String key, String value);
|
||||
|
||||
Future<List<SettingRecord>> getByKeys(List<String> keys);
|
||||
|
||||
<T> Future<T> getByKey(SettingKey key);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package telegram.files.repository;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import io.vertx.sqlclient.templates.RowMapper;
|
||||
import io.vertx.sqlclient.templates.TupleMapper;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public record TelegramRecord(long id, String firstName, String rootPath) {
|
||||
|
||||
public static final String SCHEME = """
|
||||
CREATE TABLE IF NOT EXISTS telegram_record
|
||||
(
|
||||
id BIGINT PRIMARY KEY,
|
||||
first_name VARCHAR(255),
|
||||
root_path VARCHAR(255)
|
||||
)
|
||||
""";
|
||||
|
||||
public static RowMapper<TelegramRecord> ROW_MAPPER = row ->
|
||||
new TelegramRecord(row.getLong("id"),
|
||||
row.getString("first_name"),
|
||||
row.getString("root_path")
|
||||
);
|
||||
|
||||
public static TupleMapper<TelegramRecord> PARAM_MAPPER = TupleMapper.mapper(r ->
|
||||
Map.ofEntries(MapUtil.entry("id", r.id),
|
||||
Map.entry("first_name", r.firstName()),
|
||||
Map.entry("root_path", r.rootPath())
|
||||
));
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package telegram.files.repository;
|
||||
|
||||
import io.vertx.core.Future;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface TelegramRepository {
|
||||
|
||||
Future<Void> init();
|
||||
|
||||
String getRootPath();
|
||||
|
||||
Future<TelegramRecord> create(TelegramRecord telegramRecord);
|
||||
|
||||
Future<TelegramRecord> getById(long id);
|
||||
|
||||
Future<List<TelegramRecord>> getAll();
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
package telegram.files.repository.impl;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.log.Log;
|
||||
import cn.hutool.log.LogFactory;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.jdbcclient.JDBCPool;
|
||||
import io.vertx.sqlclient.templates.SqlTemplate;
|
||||
import telegram.files.repository.FileRecord;
|
||||
import telegram.files.repository.FileRepository;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
public class FileRepositoryImpl implements FileRepository {
|
||||
|
||||
private static final Log log = LogFactory.get();
|
||||
|
||||
private final JDBCPool pool;
|
||||
|
||||
public FileRepositoryImpl(JDBCPool pool) {
|
||||
this.pool = pool;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<Void> init() {
|
||||
return pool.getConnection()
|
||||
.compose(conn -> conn
|
||||
.query(FileRecord.SCHEME)
|
||||
.execute()
|
||||
.onComplete(r -> conn.close())
|
||||
.onFailure(err -> log.error("Failed to create table file_record: %s".formatted(err.getMessage())))
|
||||
.onSuccess(ps -> log.debug("Successfully created table: file_record"))
|
||||
)
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<FileRecord> create(FileRecord fileRecord) {
|
||||
return SqlTemplate
|
||||
.forUpdate(pool, """
|
||||
INSERT INTO file_record(id, unique_id, telegram_id, chat_id, message_id, date, has_sensitive_content, size, downloaded_size,
|
||||
type, mime_type,
|
||||
file_name, thumbnail, caption, local_path,
|
||||
download_status)
|
||||
values (#{id}, #{unique_id}, #{telegram_id}, #{chat_id}, #{message_id}, #{date}, #{has_sensitive_content}, #{size}, #{downloaded_size}, #{type},
|
||||
#{mime_type}, #{file_name}, #{thumbnail}, #{caption}, #{local_path}, #{download_status})
|
||||
""")
|
||||
.mapFrom(FileRecord.PARAM_MAPPER)
|
||||
.execute(fileRecord)
|
||||
.map(r -> fileRecord)
|
||||
.onSuccess(r -> log.debug("Successfully created file record: %s".formatted(fileRecord.id()))
|
||||
)
|
||||
.onFailure(
|
||||
err -> log.error("Failed to create file record: %s".formatted(err.getMessage()))
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<Map<Integer, FileRecord>> getFiles(long chatId, List<Integer> fileIds) {
|
||||
if (CollUtil.isEmpty(fileIds)) {
|
||||
return Future.succeededFuture(new HashMap<>());
|
||||
}
|
||||
return SqlTemplate
|
||||
.forQuery(pool, """
|
||||
SELECT * FROM file_record WHERE chat_id = #{chatId} AND id IN (#{fileIds})
|
||||
""")
|
||||
.mapTo(FileRecord.ROW_MAPPER)
|
||||
.execute(Map.of("chatId", chatId, "fileIds", StrUtil.join(",", fileIds)))
|
||||
.onFailure(err -> log.error("Failed to get file record: %s".formatted(err.getMessage())))
|
||||
.map(rs -> {
|
||||
Map<Integer, FileRecord> map = new HashMap<>();
|
||||
for (FileRecord record : rs) {
|
||||
map.put(record.id(), record);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<Map<String, FileRecord>> getFilesByUniqueId(List<String> uniqueIds) {
|
||||
uniqueIds = uniqueIds.stream()
|
||||
.filter(StrUtil::isNotBlank)
|
||||
.distinct().collect(Collectors.toList());
|
||||
if (CollUtil.isEmpty(uniqueIds)) {
|
||||
return Future.succeededFuture(new HashMap<>());
|
||||
}
|
||||
String uniqueIdPlaceholders = IntStream.range(0, uniqueIds.size())
|
||||
.mapToObj(i -> "#{uniqueId" + i + "}")
|
||||
.collect(Collectors.joining(","));
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
for (int i = 0; i < uniqueIds.size(); i++) {
|
||||
params.put("uniqueId" + i, uniqueIds.get(i));
|
||||
}
|
||||
return SqlTemplate
|
||||
.forQuery(pool, """
|
||||
SELECT * FROM file_record WHERE unique_id IN (%s)
|
||||
""".formatted(uniqueIdPlaceholders))
|
||||
.mapTo(FileRecord.ROW_MAPPER)
|
||||
.execute(params)
|
||||
.onFailure(err -> log.error("Failed to get file record: %s".formatted(err.getMessage())))
|
||||
.map(rs -> {
|
||||
Map<String, FileRecord> map = new HashMap<>();
|
||||
for (FileRecord record : rs) {
|
||||
map.put(record.uniqueId(), record);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<FileRecord> getByPrimaryKey(int fileId, String uniqueId) {
|
||||
return SqlTemplate
|
||||
.forQuery(pool, """
|
||||
SELECT * FROM file_record WHERE id = #{fileId} AND unique_id = #{uniqueId}
|
||||
""")
|
||||
.mapTo(FileRecord.ROW_MAPPER)
|
||||
.execute(Map.of("fileId", fileId, "uniqueId", uniqueId))
|
||||
.onFailure(err -> log.error("Failed to get file record: %s".formatted(err.getMessage()))
|
||||
)
|
||||
.map(rs -> rs.size() > 0 ? rs.iterator().next() : null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<FileRecord> getByUniqueId(String uniqueId) {
|
||||
return SqlTemplate
|
||||
.forQuery(pool, """
|
||||
SELECT * FROM file_record WHERE unique_id = #{uniqueId} LIMIT 1
|
||||
""")
|
||||
.mapTo(FileRecord.ROW_MAPPER)
|
||||
.execute(Map.of("uniqueId", uniqueId))
|
||||
.onFailure(err -> log.error("Failed to get file record: %s".formatted(err.getMessage()))
|
||||
)
|
||||
.map(rs -> rs.size() > 0 ? rs.iterator().next() : null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<JsonObject> getDownloadStatistics(long telegramId) {
|
||||
return SqlTemplate
|
||||
.forQuery(pool, """
|
||||
SELECT COUNT(*) AS total,
|
||||
COUNT(CASE WHEN download_status = 'downloading' THEN 1 END) AS downloading,
|
||||
COUNT(CASE WHEN download_status = 'paused' THEN 1 END) AS paused,
|
||||
COUNT(CASE WHEN download_status = 'completed' THEN 1 END) AS completed,
|
||||
COUNT(CASE WHEN download_status = 'error' THEN 1 END) AS error,
|
||||
COUNT(CASE WHEN type = 'photo' THEN 1 END) AS photo,
|
||||
COUNT(CASE WHEN type = 'video' THEN 1 END) AS video,
|
||||
COUNT(CASE WHEN type = 'audio' THEN 1 END) AS audio,
|
||||
COUNT(CASE WHEN type = 'file' THEN 1 END) AS file
|
||||
FROM file_record
|
||||
WHERE telegram_id = #{telegramId}
|
||||
""")
|
||||
.mapTo(row -> {
|
||||
JsonObject result = JsonObject.of();
|
||||
result.put("total", row.getInteger("total"));
|
||||
result.put("downloading", row.getInteger("downloading"));
|
||||
result.put("paused", row.getInteger("paused"));
|
||||
result.put("completed", row.getInteger("completed"));
|
||||
result.put("error", row.getInteger("error"));
|
||||
result.put("photo", row.getInteger("photo"));
|
||||
result.put("video", row.getInteger("video"));
|
||||
result.put("audio", row.getInteger("audio"));
|
||||
result.put("file", row.getInteger("file"));
|
||||
return result;
|
||||
})
|
||||
.execute(Map.of("telegramId", telegramId))
|
||||
.map(rs -> rs.size() > 0 ? rs.iterator().next() : JsonObject.of())
|
||||
.onFailure(err -> log.error("Failed to get download statistics: %s".formatted(err.getMessage())));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<JsonObject> updateStatus(int fileId, String uniqueId, String localPath, FileRecord.DownloadStatus downloadStatus) {
|
||||
if (StrUtil.isBlank(localPath) && downloadStatus == null) {
|
||||
return Future.succeededFuture(null);
|
||||
}
|
||||
return getByUniqueId(uniqueId)
|
||||
.compose(record -> {
|
||||
if (record == null) {
|
||||
return Future.succeededFuture(null);
|
||||
}
|
||||
boolean pathUpdated = !Objects.equals(record.localPath(), localPath);
|
||||
boolean downloadStatusUpdated = !Objects.equals(record.downloadStatus(), downloadStatus.name());
|
||||
if (!pathUpdated && !downloadStatusUpdated) {
|
||||
return Future.succeededFuture(null);
|
||||
}
|
||||
|
||||
return SqlTemplate
|
||||
.forUpdate(pool, """
|
||||
UPDATE file_record SET local_path = #{localPath}, download_status = #{downloadStatus}
|
||||
WHERE id = #{fileId} AND unique_id = #{uniqueId}
|
||||
""")
|
||||
.execute(MapUtil.ofEntries(MapUtil.entry("fileId", fileId),
|
||||
MapUtil.entry("uniqueId", uniqueId),
|
||||
MapUtil.entry("localPath", pathUpdated ? localPath : record.localPath()),
|
||||
MapUtil.entry("downloadStatus", downloadStatusUpdated ? downloadStatus.name() : record.downloadStatus())
|
||||
))
|
||||
.onFailure(err ->
|
||||
log.error("Failed to update file record: %s".formatted(err.getMessage()))
|
||||
)
|
||||
.map(r -> {
|
||||
JsonObject result = JsonObject.of();
|
||||
if (pathUpdated) {
|
||||
result.put("localPath", localPath);
|
||||
}
|
||||
if (downloadStatusUpdated) {
|
||||
result.put("downloadStatus", downloadStatus.name());
|
||||
}
|
||||
log.debug("Successfully updated file record: %s, path: %s, status: %s, before: %s, %s"
|
||||
.formatted(fileId, localPath, downloadStatus.name(), record.localPath(), record.downloadStatus()));
|
||||
return result;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<Void> updateFileId(int fileId, String uniqueId) {
|
||||
if (fileId <= 0 || StrUtil.isBlank(uniqueId)) {
|
||||
return Future.succeededFuture();
|
||||
}
|
||||
return this.getByUniqueId(uniqueId)
|
||||
.compose(record -> {
|
||||
if (record == null || record.id() == fileId) {
|
||||
return Future.succeededFuture();
|
||||
}
|
||||
return SqlTemplate
|
||||
.forUpdate(pool, """
|
||||
UPDATE file_record SET id = #{fileId} WHERE unique_id = #{uniqueId}
|
||||
""")
|
||||
.execute(Map.of("fileId", fileId, "uniqueId", uniqueId))
|
||||
.onFailure(err ->
|
||||
log.error("Failed to update file record: %s".formatted(err.getMessage()))
|
||||
)
|
||||
.mapEmpty();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
package telegram.files.repository.impl;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.collection.IterUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.log.Log;
|
||||
import cn.hutool.log.LogFactory;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.jdbcclient.JDBCPool;
|
||||
import io.vertx.sqlclient.templates.SqlTemplate;
|
||||
import telegram.files.repository.SettingKey;
|
||||
import telegram.files.repository.SettingRecord;
|
||||
import telegram.files.repository.SettingRepository;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class SettingRepositoryImpl implements SettingRepository {
|
||||
|
||||
private static final Log log = LogFactory.get();
|
||||
|
||||
private final JDBCPool pool;
|
||||
|
||||
public SettingRepositoryImpl(JDBCPool pool) {
|
||||
this.pool = pool;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<Void> init() {
|
||||
return pool.getConnection()
|
||||
.compose(conn -> conn
|
||||
.query(SettingRecord.SCHEME)
|
||||
.execute()
|
||||
.onComplete(r -> conn.close())
|
||||
.onFailure(err -> log.error("Failed to create table setting_record: %s".formatted(err.getMessage())))
|
||||
.onSuccess(ps -> log.debug("Successfully created table: setting_record"))
|
||||
)
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<SettingRecord> createOrUpdate(String key, String value) {
|
||||
return SqlTemplate
|
||||
.forUpdate(pool, """
|
||||
INSERT INTO setting_record(key, value) VALUES (#{key}, #{value})
|
||||
ON CONFLICT (key) DO UPDATE SET value = #{value}
|
||||
""")
|
||||
.mapFrom(SettingRecord.PARAM_MAPPER)
|
||||
.execute(new SettingRecord(key, value))
|
||||
.map(r -> new SettingRecord(key, value))
|
||||
.onSuccess(r -> log.debug("Successfully created or updated setting record: %s".formatted(key)))
|
||||
.onFailure(
|
||||
err -> log.error("Failed to create or update setting record: %s".formatted(err.getMessage()))
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<List<SettingRecord>> getByKeys(List<String> keys) {
|
||||
if (CollUtil.isEmpty(keys)) {
|
||||
return Future.succeededFuture(List.of());
|
||||
}
|
||||
String keyStr = keys.stream()
|
||||
.filter(StrUtil::isNotBlank)
|
||||
.distinct()
|
||||
.map(key -> StrUtil.wrap(key, "'"))
|
||||
.collect(Collectors.joining(","));
|
||||
|
||||
return SqlTemplate
|
||||
.forQuery(pool, """
|
||||
SELECT * FROM setting_record WHERE key IN (%s)
|
||||
""".formatted(keyStr))
|
||||
.mapTo(SettingRecord.ROW_MAPPER)
|
||||
.execute(Collections.emptyMap())
|
||||
.map(IterUtil::toList)
|
||||
.onSuccess(r -> log.debug("Successfully fetched setting record for keys: " + keyStr))
|
||||
.onFailure(
|
||||
err -> log.error("Failed to fetch setting record: %s".formatted(err.getMessage()))
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> Future<T> getByKey(SettingKey key) {
|
||||
return SqlTemplate
|
||||
.forQuery(pool, """
|
||||
SELECT value FROM setting_record WHERE key = #{key}
|
||||
""")
|
||||
.mapTo(row -> row.getString("value"))
|
||||
.execute(Map.of("key", key.name()))
|
||||
.map(rs -> {
|
||||
if (rs.size() == 1) {
|
||||
return (T) key.converter.apply(rs.iterator().next());
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.onSuccess(r -> log.debug("Successfully fetched setting record for key: " + key))
|
||||
.onFailure(
|
||||
err -> log.error("Failed to fetch setting record: %s".formatted(err.getMessage()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package telegram.files.repository.impl;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.log.Log;
|
||||
import cn.hutool.log.LogFactory;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.jdbcclient.JDBCPool;
|
||||
import io.vertx.sqlclient.templates.SqlTemplate;
|
||||
import telegram.files.Config;
|
||||
import telegram.files.repository.TelegramRecord;
|
||||
import telegram.files.repository.TelegramRepository;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public class TelegramRepositoryImpl implements TelegramRepository {
|
||||
|
||||
private static final Log log = LogFactory.get();
|
||||
|
||||
private final JDBCPool pool;
|
||||
|
||||
public TelegramRepositoryImpl(JDBCPool pool) {
|
||||
this.pool = pool;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<Void> init() {
|
||||
return pool.getConnection()
|
||||
.compose(conn -> conn
|
||||
.query(TelegramRecord.SCHEME)
|
||||
.execute()
|
||||
.onComplete(r -> conn.close())
|
||||
.onFailure(err -> log.error("Failed to create table telegram_record: %s".formatted(err.getMessage())))
|
||||
.onSuccess(ps -> log.debug("Successfully created table: telegram_record"))
|
||||
)
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRootPath() {
|
||||
return Config.TELEGRAM_ROOT + File.separator + UUID.randomUUID();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<TelegramRecord> create(TelegramRecord telegramRecord) {
|
||||
return SqlTemplate
|
||||
.forUpdate(pool, "INSERT INTO telegram_record(id, first_name, root_path) VALUES (#{id}, #{first_name}, #{root_path})")
|
||||
.mapFrom(TelegramRecord.PARAM_MAPPER)
|
||||
.execute(telegramRecord)
|
||||
.map(r -> telegramRecord)
|
||||
.onSuccess(r -> log.debug("Successfully created telegram record: %s".formatted(telegramRecord.id())))
|
||||
.onFailure(
|
||||
err -> log.error("Failed to create telegram record: %s".formatted(err.getMessage()))
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<TelegramRecord> getById(long id) {
|
||||
return SqlTemplate
|
||||
.forQuery(pool, "SELECT * FROM telegram_record WHERE id = #{id} limit 1")
|
||||
.mapTo(TelegramRecord.ROW_MAPPER)
|
||||
.execute(MapUtil.of("id", id))
|
||||
.map(rs -> rs.size() == 0 ? null : rs.iterator().next());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<List<TelegramRecord>> getAll() {
|
||||
return SqlTemplate
|
||||
.forQuery(pool, "SELECT * FROM telegram_record ORDER BY id")
|
||||
.mapTo(TelegramRecord.ROW_MAPPER)
|
||||
.execute(Collections.emptyMap())
|
||||
.map(CollUtil::newArrayList);
|
||||
}
|
||||
}
|
||||
27
api/src/main/resources/logging.properties
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# \u5168\u5C40\u65E5\u5FD7\u7EA7\u522B\uFF08\u9ED8\u8BA4\u7EA7\u522B\uFF09, ALL < FINEST < FINER < FINE < CONFIG < INFO < WARNING < SEVERE < OFF
|
||||
.level = INFO
|
||||
|
||||
# \u63A7\u5236\u53F0 Handler \u914D\u7F6E
|
||||
handlers = java.util.logging.ConsoleHandler, java.util.logging.FileHandler
|
||||
|
||||
# \u63A7\u5236\u53F0 Handler \u7684\u65E5\u5FD7\u7EA7\u522B
|
||||
java.util.logging.ConsoleHandler.level = INFO
|
||||
|
||||
# \u63A7\u5236\u53F0 Handler \u7684\u65E5\u5FD7\u683C\u5F0F\u5316\u5668
|
||||
java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter
|
||||
|
||||
# \u6587\u4EF6 Handler \u7684\u65E5\u5FD7\u7EA7\u522B
|
||||
java.util.logging.FileHandler.level = SEVERE
|
||||
|
||||
## \u6587\u4EF6\u65E5\u5FD7\u8DEF\u5F84\u548C\u683C\u5F0F\uFF0C%u \u548C %g \u7528\u4E8E\u751F\u6210\u552F\u4E00\u7684\u65E5\u5FD7\u6587\u4EF6
|
||||
java.util.logging.FileHandler.pattern = api.log
|
||||
java.util.logging.FileHandler.limit = 5000000 # \u6587\u4EF6\u5927\u5C0F\u9650\u5236\uFF08\u5B57\u8282\uFF09
|
||||
java.util.logging.FileHandler.count = 3 # \u65E5\u5FD7\u6587\u4EF6\u8F6E\u66FF\u4E2A\u6570
|
||||
java.util.logging.FileHandler.append = true # \u662F\u5426\u8FFD\u52A0\u5230\u6587\u4EF6
|
||||
java.util.logging.FileHandler.formatter = java.util.logging.SimpleFormatter
|
||||
|
||||
# SimpleFormatter \u7684\u65E5\u5FD7\u683C\u5F0F
|
||||
java.util.logging.SimpleFormatter.format = [%1$tF %1$tT] [%4$s] %5$s %n%6$s%n
|
||||
#
|
||||
## \u4E3A\u7279\u5B9A\u7684\u65E5\u5FD7\u5668\u8BBE\u7F6E\u65E5\u5FD7\u7EA7\u522B
|
||||
io.netty.level = WARNING
|
||||
105
api/src/test/java/telegram/files/DataVerticleTest.java
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
package telegram.files;
|
||||
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.log.Log;
|
||||
import cn.hutool.log.LogFactory;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.junit5.VertxExtension;
|
||||
import io.vertx.junit5.VertxTestContext;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import telegram.files.repository.FileRecord;
|
||||
import telegram.files.repository.TelegramRecord;
|
||||
|
||||
@ExtendWith(VertxExtension.class)
|
||||
public class DataVerticleTest {
|
||||
|
||||
private static final Log log = LogFactory.get();
|
||||
|
||||
@BeforeAll
|
||||
static void setUp() {
|
||||
log.debug("APP_ROOT: " + Config.APP_ROOT);
|
||||
log.debug("DATA_PATH:" + DataVerticle.getDataPath());
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDown() {
|
||||
String dataPath = DataVerticle.getDataPath();
|
||||
if (FileUtil.file(dataPath).exists()) {
|
||||
FileUtil.del(dataPath);
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
@DisplayName("Deploy data verticle")
|
||||
void deployVerticle(Vertx vertx, VertxTestContext testContext) {
|
||||
vertx.deployVerticle(new DataVerticle())
|
||||
.onComplete(testContext.succeedingThenComplete());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test Create telegram record")
|
||||
void createTelegramRecordTest(Vertx vertx, VertxTestContext testContext) {
|
||||
TelegramRecord telegramRecord = new TelegramRecord(1, "test", "test");
|
||||
DataVerticle.telegramRepository.create(telegramRecord)
|
||||
.compose(r -> DataVerticle.telegramRepository.getById(r.id()))
|
||||
.onComplete(testContext.succeeding(r -> testContext.verify(() -> {
|
||||
Assertions.assertEquals(telegramRecord, r);
|
||||
testContext.completeNow();
|
||||
})));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test Get all telegram record")
|
||||
void getAllTelegramRecordTest(Vertx vertx, VertxTestContext testContext) {
|
||||
TelegramRecord telegramRecord = new TelegramRecord(1, "test", "test");
|
||||
TelegramRecord telegramRecord2 = new TelegramRecord(2, "test2", "test2");
|
||||
DataVerticle.telegramRepository.create(telegramRecord)
|
||||
.compose(r -> DataVerticle.telegramRepository.create(telegramRecord2))
|
||||
.compose(r -> DataVerticle.telegramRepository.getAll())
|
||||
.onComplete(testContext.succeeding(r -> testContext.verify(() -> {
|
||||
Assertions.assertEquals(2, r.size());
|
||||
Assertions.assertEquals(telegramRecord, r.get(0));
|
||||
Assertions.assertEquals(telegramRecord2, r.get(1));
|
||||
testContext.completeNow();
|
||||
})));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test Get file record by primary key")
|
||||
void getFileRecordByPrimaryKeyTest(Vertx vertx, VertxTestContext testContext) {
|
||||
FileRecord fileRecord = new FileRecord(
|
||||
1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", "local_path", "download_status"
|
||||
);
|
||||
DataVerticle.fileRepository.create(fileRecord)
|
||||
.compose(r -> DataVerticle.fileRepository.getByPrimaryKey(r.id(), r.uniqueId()))
|
||||
.onComplete(testContext.succeeding(r -> testContext.verify(() -> {
|
||||
Assertions.assertEquals(fileRecord, r);
|
||||
testContext.completeNow();
|
||||
})));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test update file status")
|
||||
void updateFileStatusTest(Vertx vertx, VertxTestContext testContext) {
|
||||
FileRecord fileRecord = new FileRecord(
|
||||
1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", null, "download_status"
|
||||
);
|
||||
String updateLocalPath = "local_path";
|
||||
DataVerticle.fileRepository.create(fileRecord)
|
||||
.compose(r -> DataVerticle.fileRepository.updateStatus(r.id(), r.uniqueId(), updateLocalPath, FileRecord.DownloadStatus.downloading))
|
||||
.compose(r -> {
|
||||
testContext.verify(() -> {
|
||||
Assertions.assertEquals(updateLocalPath, r.getString("localPath"));
|
||||
Assertions.assertEquals(FileRecord.DownloadStatus.downloading.name(), r.getString("downloadStatus"));
|
||||
});
|
||||
return DataVerticle.fileRepository.getByPrimaryKey(fileRecord.id(), fileRecord.uniqueId());
|
||||
})
|
||||
.onComplete(testContext.succeeding(r -> testContext.verify(() -> {
|
||||
Assertions.assertEquals(FileRecord.DownloadStatus.downloading.name(), r.downloadStatus());
|
||||
Assertions.assertEquals(updateLocalPath, r.localPath());
|
||||
testContext.completeNow();
|
||||
})));
|
||||
}
|
||||
|
||||
}
|
||||
40
api/src/test/java/telegram/files/TdApiHelpTest.java
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package telegram.files;
|
||||
|
||||
import org.drinkless.tdlib.TdApi;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class TdApiHelpTest {
|
||||
|
||||
@Test
|
||||
void getFunctionTest() {
|
||||
TdApi.Function<?> function = TdApiHelp.getFunction("GetMe", null);
|
||||
Assertions.assertNotNull(function, "function is null");
|
||||
Assertions.assertTrue(function instanceof TdApi.GetMe, "function is not GetMe");
|
||||
|
||||
TdApi.SearchChatMessages searchChatMessages = new TdApi.SearchChatMessages();
|
||||
searchChatMessages.chatId = 0L;
|
||||
searchChatMessages.query = "";
|
||||
searchChatMessages.fromMessageId = 0;
|
||||
searchChatMessages.offset = 0;
|
||||
searchChatMessages.limit = 10;
|
||||
searchChatMessages.filter = new TdApi.SearchMessagesFilterEmpty();
|
||||
|
||||
function = TdApiHelp.getFunction("SearchChatMessages",
|
||||
Map.of(
|
||||
"chatId", searchChatMessages.chatId,
|
||||
"query", searchChatMessages.query,
|
||||
"fromMessageId", searchChatMessages.fromMessageId,
|
||||
"offset", searchChatMessages.offset,
|
||||
"limit", searchChatMessages.limit,
|
||||
"filter", Map.of("@type", -869395657)
|
||||
)
|
||||
);
|
||||
Assertions.assertNotNull(function, "function is null");
|
||||
Assertions.assertEquals(searchChatMessages.chatId, ((TdApi.SearchChatMessages) function).chatId, "function is not equals SearchChatMessages");
|
||||
Assertions.assertInstanceOf(TdApi.SearchMessagesFilterEmpty.class, ((TdApi.SearchChatMessages) function).filter, "function is not equals SearchChatMessages");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package telegram.files;
|
||||
|
||||
public class TelegramVerticleTest {
|
||||
|
||||
|
||||
}
|
||||
16
docker-compose.yaml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
services:
|
||||
telegram-files:
|
||||
container_name: telegram-files
|
||||
image: telegram-files:latest
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:80/"]
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV:-prod}
|
||||
APP_ROOT: ${APP_ROOT:-/app/data}
|
||||
TELEGRAM_API_ID: ${TELEGRAM_API_ID}
|
||||
TELEGRAM_API_HASH: ${TELEGRAM_API_HASH}
|
||||
ports:
|
||||
- "6543:80"
|
||||
volumes:
|
||||
- ./telegram-files:/app/data
|
||||
32
entrypoint.sh
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
#!/bin/bash
|
||||
|
||||
lib_path="/app/tdlib"
|
||||
if [ ! -d "$lib_path" ]; then
|
||||
echo "Downloading TDLib..."
|
||||
mkdir -p $lib_path
|
||||
wget -q -O libs.zip https://github.com/p-vorobyev/spring-boot-starter-telegram/releases/download/1.15.0/libs.zip
|
||||
unzip -q libs.zip -d $lib_path
|
||||
rm libs.zip
|
||||
fi
|
||||
|
||||
# libs has linux_arm64 linux_x64
|
||||
case $(uname -m) in
|
||||
x86_64)
|
||||
lib_path="$lib_path/libs/linux_x64"
|
||||
;;
|
||||
aarch64)
|
||||
lib_path="$lib_path/libs/linux_arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $(uname -m)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Using TDLib from $lib_path"
|
||||
|
||||
# start services
|
||||
java -jar -Djava.library.path=$lib_path /app/api.jar \
|
||||
& pm2 start /app/web/pm2.json --no-daemon \
|
||||
& nginx -g 'daemon off;'
|
||||
|
||||
10
http-client.env.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"example": {
|
||||
"server": "localhost:8080",
|
||||
"tf": "",
|
||||
"telegramId": "your telegram id",
|
||||
"telegram_phone": "your telegram phone",
|
||||
"chatId": "your chat id",
|
||||
"messageId": "your message id"
|
||||
}
|
||||
}
|
||||
BIN
misc/screenshot.png
Normal file
|
After Width: | Height: | Size: 918 KiB |
94
misc/update_version.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import re
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
# 文件路径
|
||||
VERSION_FILE = "VERSION"
|
||||
BUILD_GRADLE_FILE = "api/build.gradle"
|
||||
JAVA_VERSION_FILE = "api/src/main/java/telegram/files/Start.java"
|
||||
PACKAGE_JSON_FILE = "web/package.json"
|
||||
|
||||
def read_version():
|
||||
"""读取 VERSION 文件中的版本号"""
|
||||
with open(VERSION_FILE, "r") as file:
|
||||
return file.read().strip()
|
||||
|
||||
def write_version(version):
|
||||
"""将版本号写入 VERSION 文件"""
|
||||
with open(VERSION_FILE, "w") as file:
|
||||
file.write(version + "\n")
|
||||
|
||||
def update_build_gradle(version):
|
||||
"""更新 build.gradle 中的版本号"""
|
||||
with open(BUILD_GRADLE_FILE, "r") as file:
|
||||
content = file.read()
|
||||
|
||||
# 使用正则表达式匹配 version = "x.y.z"
|
||||
updated_content = re.sub(r'version\s*=\s*\'.*?\'', f'version = \'{version}\'', content)
|
||||
|
||||
with open(BUILD_GRADLE_FILE, "w") as file:
|
||||
file.write(updated_content)
|
||||
|
||||
def update_java_version(version):
|
||||
"""更新 Start.java 中的版本号"""
|
||||
with open(JAVA_VERSION_FILE, "r") as file:
|
||||
content = file.read()
|
||||
|
||||
# 使用正则表达式匹配 version = "x.y.z"
|
||||
updated_content = re.sub(r'VERSION\s*=\s*".*?"', f'VERSION = "{version}"', content)
|
||||
|
||||
with open(JAVA_VERSION_FILE, "w") as file:
|
||||
file.write(updated_content)
|
||||
|
||||
def update_package_json(version):
|
||||
"""更新 package.json 中的版本号"""
|
||||
with open(PACKAGE_JSON_FILE, "r") as file:
|
||||
content = file.read()
|
||||
|
||||
# 使用正则表达式匹配 "version": "x.y.z"
|
||||
updated_content = re.sub(r'"version"\s*:\s*".*?"', f'"version": "{version}"', content)
|
||||
|
||||
with open(PACKAGE_JSON_FILE, "w") as file:
|
||||
file.write(updated_content)
|
||||
|
||||
def git_commit_and_tag(version, message):
|
||||
"""执行 Git 提交并创建标签"""
|
||||
try:
|
||||
# 添加修改到 Git 暂存区
|
||||
subprocess.run(["git", "add", VERSION_FILE, BUILD_GRADLE_FILE, PACKAGE_JSON_FILE], check=True)
|
||||
# 提交修改
|
||||
subprocess.run(["git", "commit", "-m", message], check=True)
|
||||
# 创建标签
|
||||
subprocess.run(["git", "tag", version], check=True)
|
||||
print(f"已成功提交修改并创建标签:{version}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Git 操作失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def main():
|
||||
# 获取命令行参数中的版本号
|
||||
version = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
|
||||
if version is None:
|
||||
print("请提供版本号,例如:python update_version.py 1.2.3")
|
||||
sys.exit(1)
|
||||
|
||||
# 输入变更内容
|
||||
change_message = input("请输入版本变更内容: ").strip()
|
||||
|
||||
# 更新 VERSION 文件
|
||||
write_version(version)
|
||||
|
||||
# 更新 build.gradle、java 和 package.json
|
||||
update_build_gradle(version)
|
||||
update_java_version(version)
|
||||
update_package_json(version)
|
||||
|
||||
print(f"版本号已更新为:{version}")
|
||||
|
||||
if change_message:
|
||||
# 提交到 Git 并创建标签
|
||||
git_commit_and_tag(version, change_message)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
42
nginx.conf
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
worker_processes 1;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
# 定义 HTTP 块
|
||||
http {
|
||||
# 定义 server 块
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# 定义 WS 的代理规则
|
||||
location /ws {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# 定义 API 的代理规则
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:8080/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# 日志配置
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
}
|
||||
}
|
||||
158
request.http
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
### Get health
|
||||
GET http://{{server}}/health
|
||||
|
||||
### Basic GET request test
|
||||
GET http://{{server}}
|
||||
Cookie: {{tf}}
|
||||
|
||||
> {%
|
||||
client.test("Request executed successfully", function () {
|
||||
client.assert(response.status === 200, "Response status is not 200");
|
||||
client.assert(response.body === "Hello World!", "Response body is not 'Hello World!'");
|
||||
});
|
||||
const cookie = response.headers.valueOf("set-cookie")
|
||||
if (cookie) {
|
||||
client.global.set("tf", cookie.split(";")[0])
|
||||
console.log("tf: " + cookie)
|
||||
}
|
||||
%}
|
||||
|
||||
### Basic websocket test
|
||||
WEBSOCKET ws://{{server}}/ws
|
||||
|
||||
===
|
||||
Hello!
|
||||
=== wait-for-server
|
||||
|
||||
### Create settings test
|
||||
POST http://{{server}}/settings/create
|
||||
Content-Type: application/json
|
||||
Cookie: {{tf}}
|
||||
|
||||
{
|
||||
"test1": "test1",
|
||||
"test2": "test2",
|
||||
"test3": "test3"
|
||||
}
|
||||
|
||||
### Get settings
|
||||
GET http://{{server}}/settings?keys=test1,test2,test3
|
||||
Cookie: {{tf}}
|
||||
|
||||
### Get telegrams
|
||||
GET http://{{server}}/telegrams
|
||||
Cookie: {{tf}}
|
||||
|
||||
### Change telegrams
|
||||
POST http://{{server}}/telegrams/change?telegramId={{telegramId}}
|
||||
Content-Type: application/json
|
||||
Cookie: {{tf}}
|
||||
|
||||
### Telegram Create test
|
||||
POST http://{{server}}/telegram/create
|
||||
Cookie: {{tf}}
|
||||
|
||||
### Get chats
|
||||
GET http://{{server}}/telegram/{{telegramId}}/chats
|
||||
Cookie: {{tf}}
|
||||
|
||||
### Get chat files
|
||||
GET http://{{server}}/telegram/{{telegramId}}/chat/{{chatId}}/files?fromMessageId=0&type=media&status=all
|
||||
Cookie: {{tf}}
|
||||
|
||||
### Get chat files count
|
||||
GET http://{{server}}/telegram/{{telegramId}}/chat/{{chatId}}/files/count
|
||||
Cookie: {{tf}}
|
||||
|
||||
### Load photo preview
|
||||
GET http://{{server}}//file/preview?chatId={{chatId}}&messageId={{messageId}}
|
||||
Cookie: {{tf}}
|
||||
|
||||
### Get download statistics
|
||||
GET http://{{server}}/telegram/{{telegramId}}/download-statistics
|
||||
Cookie: {{tf}}
|
||||
|
||||
### Telegram GetMe
|
||||
POST http://{{server}}/telegram/api/GetMe
|
||||
Content-Type: application/json
|
||||
Cookie: {{tf}}
|
||||
|
||||
{}
|
||||
|
||||
### Telegram SetAuthenticationPhoneNumber
|
||||
POST http://{{server}}/telegram/api/SetAuthenticationPhoneNumber
|
||||
Content-Type: application/json
|
||||
Cookie: {{tf}}
|
||||
|
||||
{
|
||||
"phoneNumber": "{{telegram_phone}}",
|
||||
"settings": null
|
||||
}
|
||||
|
||||
### Telegram CheckAuthenticationCode
|
||||
POST http://{{server}}/telegram/api/CheckAuthenticationCode
|
||||
Content-Type: application/json
|
||||
Cookie: {{tf}}
|
||||
|
||||
{
|
||||
"code": "52932"
|
||||
}
|
||||
|
||||
### Telegram GetChats
|
||||
POST http://{{server}}/telegram/api/GetChats
|
||||
Content-Type: application/json
|
||||
Cookie: {{tf}}
|
||||
|
||||
{
|
||||
"chatList": {
|
||||
"@type": -400991316
|
||||
},
|
||||
"limit": 10
|
||||
}
|
||||
|
||||
### Telegram GetChat
|
||||
POST http://{{server}}/telegram/api/GetChat
|
||||
Content-Type: application/json
|
||||
Cookie: {{tf}}
|
||||
|
||||
{
|
||||
"chatId": {{chatId}}
|
||||
}
|
||||
|
||||
### Telegram LoadChats
|
||||
POST http://{{server}}/telegram/api/LoadChats
|
||||
Content-Type: application/json
|
||||
Cookie: {{tf}}
|
||||
|
||||
{
|
||||
"chatList": {
|
||||
"@type": -400991316
|
||||
},
|
||||
"limit": 10
|
||||
}
|
||||
|
||||
### Telegram SearchChatsOnServer
|
||||
POST http://{{server}}/telegram/api/SearchChatsOnServer
|
||||
Content-Type: application/json
|
||||
Cookie: {{tf}}
|
||||
|
||||
{
|
||||
"query": "拾趣",
|
||||
"limit": 10
|
||||
}
|
||||
|
||||
### Telegram SearchChatMessages
|
||||
POST http://{{server}}/telegram/api/SearchChatMessages
|
||||
Content-Type: application/json
|
||||
Cookie: {{tf}}
|
||||
|
||||
{
|
||||
"chatId": 1,
|
||||
"query": "",
|
||||
"fromMessageId": 0,
|
||||
"offset": 0,
|
||||
"limit": 10,
|
||||
"filter": {
|
||||
"@type": 1352130963
|
||||
}
|
||||
}
|
||||
4
web/.env.example
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# API URL
|
||||
NEXT_PUBLIC_API_URL="http://localhost:8080"
|
||||
NEXT_PUBLIC_WS_URL="ws://localhost:8080/ws"
|
||||
NEXT_PUBLIC_MOCK_DATA=false
|
||||
43
web/.eslintrc.cjs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/** @type {import("eslint").Linter.Config} */
|
||||
const config = {
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": true
|
||||
},
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"plugin:@typescript-eslint/recommended-type-checked",
|
||||
"plugin:@typescript-eslint/stylistic-type-checked"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/consistent-type-definitions": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"warn",
|
||||
{
|
||||
"prefer": "type-imports",
|
||||
"fixStyle": "inline-type-imports"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/require-await": "off",
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{
|
||||
"checksVoidReturn": {
|
||||
"attributes": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
module.exports = config;
|
||||
46
web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# database
|
||||
/prisma/db.sqlite
|
||||
/prisma/db.sqlite-journal
|
||||
db.sqlite
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
next-env.d.ts
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# idea files
|
||||
.idea
|
||||
29
web/README.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Create T3 App
|
||||
|
||||
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
|
||||
|
||||
## What's next? How do I make an app with this?
|
||||
|
||||
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
|
||||
|
||||
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
|
||||
|
||||
- [Next.js](https://nextjs.org)
|
||||
- [NextAuth.js](https://next-auth.js.org)
|
||||
- [Prisma](https://prisma.io)
|
||||
- [Drizzle](https://orm.drizzle.team)
|
||||
- [Tailwind CSS](https://tailwindcss.com)
|
||||
- [tRPC](https://trpc.io)
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
|
||||
|
||||
- [Documentation](https://create.t3.gg/)
|
||||
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
|
||||
|
||||
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
|
||||
|
||||
## How do I deploy this?
|
||||
|
||||
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.
|
||||
21
web/components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
21
web/next.config.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
|
||||
* for Docker builds.
|
||||
*/
|
||||
import "./src/env.js";
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/account",
|
||||
destination: "/",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default config;
|
||||
11725
web/package-lock.json
generated
Normal file
76
web/package.json
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
{
|
||||
"name": "telegram-files-web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"check": "next lint && tsc --noEmit",
|
||||
"dev": "next dev --turbo",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"preview": "next build && next start",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.2.0",
|
||||
"@dnd-kit/modifiers": "^8.0.0",
|
||||
"@dnd-kit/sortable": "^9.0.0",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^11.15.0",
|
||||
"geist": "^1.3.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next": "^15.0.1",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"react-use-websocket": "^4.11.1",
|
||||
"spoiled": "^0.3.2",
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-debounce": "^10.0.4",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/eslint": "^8.56.10",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.1.0",
|
||||
"@typescript-eslint/parser": "^8.1.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^15.0.1",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.38.1"
|
||||
},
|
||||
"packageManager": "npm@8.1.0"
|
||||
}
|
||||
16
web/pm2.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"apps": [
|
||||
{
|
||||
"name": "telegram-files-web",
|
||||
"script": "/app/web/server.js",
|
||||
"cwd": "/app/web",
|
||||
"exec_mode": "cluster",
|
||||
"instances": 1,
|
||||
"env": {
|
||||
"NODE_ENV": "production",
|
||||
"HOSTNAME": "0.0.0.0",
|
||||
"PORT": 3000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
5
web/postcss.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
4
web/prettier.config.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
||||
export default {
|
||||
plugins: ["prettier-plugin-tailwindcss"],
|
||||
};
|
||||
BIN
web/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
web/public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
web/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
6
web/public/favicon.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="24" height="24"><svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Telegram</title>
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"></path>
|
||||
</svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||
</style></svg>
|
||||
|
After Width: | Height: | Size: 1 KiB |
21
web/public/site.webmanifest
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "TelegramFiles",
|
||||
"short_name": "TeleFiles",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#77bb73",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
BIN
web/public/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
web/public/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
17
web/src/app/account/[accountId]/[chatId]/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Header } from "@/components/header";
|
||||
import { FileList } from "@/components/file-list";
|
||||
|
||||
export default async function ChatFilesPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ accountId: string; chatId: string }>;
|
||||
}) {
|
||||
const { accountId, chatId } = await params;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<Header />
|
||||
<FileList accountId={accountId} chatId={chatId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
web/src/app/account/[accountId]/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Header } from "@/components/header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
|
||||
export default function AccountPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<Header />
|
||||
<EmptyState hasAccounts={true} message="Select a chat to view files" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
web/src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import "@/styles/globals.css";
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import React from "react";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { SWRProvider } from "@/components/swr-provider";
|
||||
import { SettingsProvider } from "@/hooks/use-settings";
|
||||
import { WebSocketProvider } from "@/hooks/use-websocket";
|
||||
import { env } from "@/env";
|
||||
import { TelegramAccountProvider } from "@/hooks/use-telegram-account";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Telegram Files",
|
||||
description: "Manage your files on Telegram",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="/favicon-96x96.png"
|
||||
sizes="96x96"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-title" content="TeleFiles" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
{env.NEXT_PUBLIC_SCAN && (
|
||||
<script
|
||||
src="https://unpkg.com/react-scan/dist/auto.global.js"
|
||||
async
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body className={inter.className}>
|
||||
<SWRProvider>
|
||||
<WebSocketProvider>
|
||||
<SettingsProvider>
|
||||
<TelegramAccountProvider>{children}</TelegramAccountProvider>
|
||||
</SettingsProvider>
|
||||
</WebSocketProvider>
|
||||
</SWRProvider>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
16
web/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { useTelegramAccount } from "@/hooks/use-telegram-account";
|
||||
|
||||
export default function Home() {
|
||||
const { getAccounts, handleAccountChange } = useTelegramAccount();
|
||||
const accounts = getAccounts();
|
||||
return (
|
||||
<EmptyState
|
||||
hasAccounts={(accounts ?? []).length > 0}
|
||||
accounts={accounts}
|
||||
onSelectAccount={handleAccountChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
93
web/src/components/account-delete-dialog.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
import { POST } from "@/lib/api";
|
||||
import { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useTelegramAccount } from "@/hooks/use-telegram-account";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
export default function AccountDeleteDialog({
|
||||
telegramId,
|
||||
className,
|
||||
}: {
|
||||
telegramId: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { mutate } = useSWRConfig();
|
||||
const { resetAccount } = useTelegramAccount();
|
||||
const { trigger: triggerDelete, isMutating: isDeleteMutating } =
|
||||
useSWRMutation(`/telegram/${telegramId}/delete`, POST, {
|
||||
onSuccess: () => {
|
||||
toast({ title: "Account deleted successfully!" });
|
||||
resetAccount();
|
||||
// 延迟关闭对话框
|
||||
setTimeout(() => {
|
||||
void mutate("/telegrams");
|
||||
setOpen(false);
|
||||
}, 1000);
|
||||
},
|
||||
});
|
||||
|
||||
const [debounceIsDeleteMutating] = useDebounce(isDeleteMutating, 500, {
|
||||
leading: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
className={className}
|
||||
asChild
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(!open);
|
||||
}}
|
||||
>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
onPointerDownOutside={() => setOpen(false)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Telegram Account</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete your account? This action is
|
||||
irreversible.
|
||||
</DialogDescription>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => triggerDelete()}
|
||||
disabled={debounceIsDeleteMutating}
|
||||
>
|
||||
{debounceIsDeleteMutating ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={debounceIsDeleteMutating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
297
web/src/components/account-dialog.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Ellipsis, LoaderCircle } from "lucide-react";
|
||||
import { useWebsocket } from "@/hooks/use-websocket";
|
||||
import {
|
||||
TelegramConstructor,
|
||||
type TelegramObject,
|
||||
WebSocketMessageType,
|
||||
} from "@/lib/websocket-types";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
import { POST, telegramApi, type TelegramApiArg } from "@/lib/api";
|
||||
import { useTelegramAccount } from "@/hooks/use-telegram-account";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type TelegramApiResult } from "@/lib/types";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
|
||||
|
||||
export function AccountDialog({
|
||||
children,
|
||||
isAdd,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isAdd?: boolean;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const { mutate } = useSWRConfig();
|
||||
const {
|
||||
data: createData,
|
||||
trigger: triggerCreate,
|
||||
isMutating: isCreateMutating,
|
||||
error: createError,
|
||||
} = useSWRMutation<{ id: string }, Error>("/telegram/create", POST);
|
||||
const { trigger: triggerMethod, isMutating: isMethodMutating } =
|
||||
useSWRMutation<TelegramApiResult, Error, string, TelegramApiArg>(
|
||||
"/telegram/api",
|
||||
telegramApi,
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setMethodCodes((prev) => [...prev, data.code]);
|
||||
},
|
||||
},
|
||||
);
|
||||
const { account, resetAccount } = useTelegramAccount();
|
||||
const [initSuccessfully, setInitSuccessfully] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const [authState, setAuthState] = useState<number | undefined>(undefined);
|
||||
const [phoneNumber, setPhoneNumber] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const { lastJsonMessage } = useWebsocket();
|
||||
|
||||
const [methodCodes, setMethodCodes] = useState<string[]>([]);
|
||||
const [methodCompleteCodes, setMethodCompleteCodes] = useState<string[]>([]);
|
||||
|
||||
const [debounceIsCreateMutating] = useDebounce(isCreateMutating, 1000, {
|
||||
leading: true,
|
||||
});
|
||||
|
||||
const handleAuthState = useCallback(
|
||||
(state: TelegramObject) => {
|
||||
switch (state.constructor) {
|
||||
case TelegramConstructor.WAIT_PHONE_NUMBER:
|
||||
case TelegramConstructor.WAIT_CODE:
|
||||
case TelegramConstructor.WAIT_PASSWORD:
|
||||
setAuthState(state.constructor);
|
||||
break;
|
||||
case TelegramConstructor.STATE_READY:
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Account added successfully",
|
||||
});
|
||||
setTimeout(() => {
|
||||
void mutate("/telegrams");
|
||||
setOpen(false);
|
||||
setPhoneNumber("");
|
||||
setCode("");
|
||||
setPassword("");
|
||||
}, 1000);
|
||||
break;
|
||||
default:
|
||||
console.log("Unknown telegram constructor:", state.constructor);
|
||||
}
|
||||
},
|
||||
[mutate, toast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (account) {
|
||||
if (
|
||||
!isAdd &&
|
||||
account.status === "inactive" &&
|
||||
account.lastAuthorizationState
|
||||
) {
|
||||
setInitSuccessfully(true);
|
||||
handleAuthState(account.lastAuthorizationState);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (open && isAdd && !initSuccessfully) {
|
||||
resetAccount();
|
||||
}
|
||||
}, [account, handleAuthState, initSuccessfully, isAdd, open, resetAccount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phoneNumber) {
|
||||
setPhoneNumber((prev) => prev.replaceAll(/\D/g, ""));
|
||||
}
|
||||
}, [phoneNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastJsonMessage) return;
|
||||
if (lastJsonMessage.code) {
|
||||
setMethodCompleteCodes((prev) => [...prev, lastJsonMessage.code]);
|
||||
}
|
||||
|
||||
if (lastJsonMessage.type === WebSocketMessageType.AUTHORIZATION) {
|
||||
handleAuthState(lastJsonMessage.data as TelegramObject);
|
||||
}
|
||||
}, [handleAuthState, lastJsonMessage, toast]);
|
||||
|
||||
const isMethodExecuting = useMemo(() => {
|
||||
if (isMethodMutating) return true;
|
||||
const lastCode = methodCodes[methodCodes.length - 1];
|
||||
if (!lastCode) return false;
|
||||
console.log(lastCode, methodCompleteCodes);
|
||||
return !methodCompleteCodes.includes(lastCode);
|
||||
}, [isMethodMutating, methodCodes, methodCompleteCodes]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (authState === TelegramConstructor.WAIT_PHONE_NUMBER) {
|
||||
await triggerMethod({
|
||||
data: {
|
||||
phoneNumber: phoneNumber,
|
||||
settings: null,
|
||||
},
|
||||
method: "SetAuthenticationPhoneNumber",
|
||||
});
|
||||
} else if (authState === TelegramConstructor.WAIT_CODE) {
|
||||
await triggerMethod({
|
||||
data: {
|
||||
code: code,
|
||||
},
|
||||
method: "CheckAuthenticationCode",
|
||||
});
|
||||
} else if (authState === TelegramConstructor.WAIT_PASSWORD) {
|
||||
await triggerMethod({
|
||||
data: {
|
||||
password: password,
|
||||
},
|
||||
method: "CheckAuthenticationPassword",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
className="h-full w-full md:h-auto md:min-h-40 md:min-w-[550px]"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
Add Telegram Account
|
||||
<p className="rounded-md bg-gray-100 p-1 text-xs text-muted-foreground">
|
||||
{account ? account.id : createData?.id}
|
||||
</p>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!debounceIsCreateMutating && !initSuccessfully && (
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<Button
|
||||
className={cn(
|
||||
"w-full",
|
||||
debounceIsCreateMutating ? "opacity-50" : "",
|
||||
)}
|
||||
disabled={debounceIsCreateMutating}
|
||||
onClick={async () => {
|
||||
await triggerCreate().then(() => {
|
||||
void mutate("/telegrams");
|
||||
setInitSuccessfully(true);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{debounceIsCreateMutating ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Start Initialization"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{debounceIsCreateMutating && (
|
||||
<div className="flex items-center justify-center space-x-2 text-xl">
|
||||
<span>Initializing account, please wait</span>
|
||||
<Ellipsis className="h-4 w-4 animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
{!debounceIsCreateMutating && createError && (
|
||||
<div className="text-center text-xl">
|
||||
<span className="mr-3 text-3xl">😲</span>
|
||||
Initializing account failed, please try again later.
|
||||
</div>
|
||||
)}
|
||||
{!debounceIsCreateMutating && initSuccessfully && (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{authState === TelegramConstructor.WAIT_PHONE_NUMBER && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Phone Number</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Should with country code like: 8613712345678
|
||||
</p>
|
||||
<Input
|
||||
id="phone"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
disabled={isMethodExecuting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{authState === TelegramConstructor.WAIT_CODE && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">Authentication Code</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Please enter the code sent to your telegram account.
|
||||
</p>
|
||||
<InputOTP
|
||||
id="code"
|
||||
maxLength={5}
|
||||
value={code}
|
||||
disabled={isMethodExecuting}
|
||||
required
|
||||
onChange={(value) => setCode(value)}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
)}
|
||||
{authState === TelegramConstructor.WAIT_PASSWORD && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
You have enabled two-step verification, please enter your
|
||||
password.
|
||||
</p>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isMethodExecuting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{authState && (
|
||||
<Button
|
||||
type="submit"
|
||||
className={cn("w-full", isMethodExecuting ? "opacity-50" : "")}
|
||||
disabled={isMethodExecuting}
|
||||
>
|
||||
{isMethodExecuting ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"🚀 Submit"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
71
web/src/components/account-list.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { type TelegramAccount } from "@/lib/types";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Circle, PhoneCall } from "lucide-react";
|
||||
import { Spoiler } from "spoiled";
|
||||
import AccountDeleteDialog from "@/components/account-delete-dialog";
|
||||
import { AccountDialog } from "@/components/account-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AccountListProps {
|
||||
accounts: TelegramAccount[];
|
||||
onSelectAccount: (accountId: string) => void;
|
||||
}
|
||||
|
||||
export function AccountList({ accounts, onSelectAccount }: AccountListProps) {
|
||||
return (
|
||||
<div className="mx-auto grid max-w-5xl grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{accounts.map((account) => (
|
||||
<Card
|
||||
key={account.id}
|
||||
className="group relative cursor-pointer transition-shadow hover:shadow-lg"
|
||||
onClick={(_e) => {
|
||||
onSelectAccount(account.id);
|
||||
}}
|
||||
>
|
||||
<AccountDeleteDialog
|
||||
telegramId={account.id}
|
||||
className="absolute bottom-1 right-1 hidden group-hover:inline-flex"
|
||||
/>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarImage src={`data:image/jpeg;base64,${account.avatar}`} />
|
||||
<AvatarFallback>{account.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="font-semibold">{account.name}</h3>
|
||||
<Badge
|
||||
variant={
|
||||
account.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
<Circle
|
||||
className={`mr-1 h-2 w-2 ${account.status === "active" ? "text-green-500" : "text-gray-500"}`}
|
||||
/>
|
||||
{account.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{account.status === "active" && (
|
||||
<div className="mb-4 flex items-center text-sm text-muted-foreground">
|
||||
<PhoneCall className="mr-1 h-3 w-3" />
|
||||
<Spoiler>{account.phoneNumber}</Spoiler>
|
||||
</div>
|
||||
)}
|
||||
{account.status === "inactive" && (
|
||||
<AccountDialog>
|
||||
<Button variant="outline" size="sm">
|
||||
Activate
|
||||
</Button>
|
||||
</AccountDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
web/src/components/auto-download-button.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface AutoDownloadButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
autoEnabled?: boolean;
|
||||
}
|
||||
|
||||
const AutoDownloadButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
AutoDownloadButtonProps
|
||||
>(({ autoEnabled = false, className, ...props }, ref) => {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group relative w-32 cursor-pointer overflow-hidden rounded border bg-background p-1 text-center font-semibold",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="inline-block translate-x-1 transition-all duration-300 group-hover:translate-x-12 group-hover:opacity-0">
|
||||
{autoEnabled ? "Running" : "Stopped"}
|
||||
</span>
|
||||
<div className="absolute top-0 z-10 flex h-full w-full translate-x-12 items-center justify-center gap-2 text-primary-foreground opacity-0 transition-all duration-300 group-hover:-translate-x-1 group-hover:opacity-100">
|
||||
<span>{autoEnabled ? "Disable" : "Enable"}</span>
|
||||
<ArrowRight />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-[10%] top-[40%] h-2 w-2 scale-[1] rounded-lg bg-primary transition-all duration-300 group-hover:left-[0%] group-hover:top-[0%] group-hover:h-full group-hover:w-full group-hover:scale-[1.8] group-hover:animate-none group-hover:bg-primary",
|
||||
autoEnabled ? "animate-breathing bg-green-500" : "bg-red-500",
|
||||
)}
|
||||
></div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{autoEnabled
|
||||
? "Auto download is enabled, you can disable it by clicking the button"
|
||||
: "Auto download is disabled, you can enable it by clicking the button"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
||||
AutoDownloadButton.displayName = "AutoDownloadButton";
|
||||
|
||||
export { AutoDownloadButton };
|
||||
130
web/src/components/auto-download-dialog.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
import { POST } from "@/lib/api";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { AutoDownloadButton } from "@/components/auto-download-button";
|
||||
import { useTelegramChat } from "@/hooks/use-telegram-chat";
|
||||
import { useTelegramAccount } from "@/hooks/use-telegram-account";
|
||||
|
||||
export default function AutoDownloadDialog() {
|
||||
const { accountId } = useTelegramAccount();
|
||||
const { isLoading, chat, reload } = useTelegramChat();
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { trigger: triggerAuto, isMutating: isAutoMutating } = useSWRMutation(
|
||||
!accountId || !chat
|
||||
? undefined
|
||||
: `/file/auto-download?telegramId=${accountId}&chatId=${chat?.id}`,
|
||||
POST,
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: chat?.autoEnabled
|
||||
? "Auto download disabled for this chat!"
|
||||
: "Auto download enabled for this chat!",
|
||||
});
|
||||
void reload();
|
||||
setTimeout(() => {
|
||||
setOpen(false);
|
||||
}, 1000);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const [debounceIsAutoMutating] = useDebounce(isAutoMutating, 500, {
|
||||
leading: true,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="h-8 w-32 animate-pulse bg-gray-200"></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
asChild
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(!open);
|
||||
}}
|
||||
>
|
||||
{chat && <AutoDownloadButton autoEnabled={chat.autoEnabled} />}
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
onPointerDownOutside={() => setOpen(false)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{chat?.autoEnabled
|
||||
? "Disable Auto Download"
|
||||
: "Enable auto download for this chat?"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription></DialogDescription>
|
||||
{chat?.autoEnabled ? (
|
||||
<div className="space-y-4 rounded-md border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="flex items-start">
|
||||
<span className="mr-3 mt-1.5 h-3 w-2 flex-shrink-0 rounded-full bg-cyan-400"></span>
|
||||
<p className="text-sm leading-6 text-gray-700">
|
||||
This will disable auto download for this chat. You can always
|
||||
enable it later.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="mr-3 mt-1.5 h-3 w-2 flex-shrink-0 rounded-full bg-cyan-400"></span>
|
||||
<p className="text-sm leading-6 text-gray-700">
|
||||
Files that are being downloaded will be paused and you can
|
||||
enable automatic downloading again later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 rounded-md border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="flex items-start">
|
||||
<span className="mr-3 mt-1.5 h-3 w-2 flex-shrink-0 rounded-full bg-cyan-400"></span>
|
||||
<p className="text-sm leading-6 text-gray-700">
|
||||
This will enable auto download for this chat. Files will be
|
||||
downloaded automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="mr-3 mt-1.5 h-3 w-2 flex-shrink-0 rounded-full bg-cyan-400"></span>
|
||||
<p className="text-sm leading-6 text-gray-700">
|
||||
Files in historical messages will be downloaded first, and then
|
||||
files in new messages will be downloaded automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
onClick={() => triggerAuto()}
|
||||
variant={chat?.autoEnabled ? "destructive" : "default"}
|
||||
disabled={debounceIsAutoMutating}
|
||||
>
|
||||
{debounceIsAutoMutating ? "Submitting..." : "Submit"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={debounceIsAutoMutating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
125
web/src/components/chat-select.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { Check, ChevronsUpDown, Ellipsis } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "./ui/command";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTelegramChat } from "@/hooks/use-telegram-chat";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CommandLoading } from "cmdk";
|
||||
|
||||
export default function ChatSelect({ disabled }: { disabled: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const {
|
||||
isLoading,
|
||||
handleQueryChange,
|
||||
chats,
|
||||
chat: selectedChat,
|
||||
handleChatChange,
|
||||
} = useTelegramChat();
|
||||
|
||||
useEffect(() => {
|
||||
handleQueryChange(search);
|
||||
}, [search, handleQueryChange]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild disabled={disabled}>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-[250px] justify-between"
|
||||
>
|
||||
{selectedChat ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage
|
||||
src={`data:image/png;base64,${selectedChat.avatar}`}
|
||||
/>
|
||||
<AvatarFallback>{selectedChat.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="max-w-[170px] overflow-hidden truncate">
|
||||
{selectedChat.name}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
"Select chat ..."
|
||||
)}
|
||||
<ChevronsUpDown className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search chat..."
|
||||
className="h-9"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList className="relative">
|
||||
{isLoading && (
|
||||
<CommandLoading>
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-2 -translate-y-2 transform">
|
||||
<Ellipsis className="h-4 w-4 animate-pulse" />
|
||||
</div>
|
||||
</CommandLoading>
|
||||
)}
|
||||
<CommandEmpty>{!isLoading && "No chat found."}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{chats.map((chat) => (
|
||||
<CommandItem
|
||||
key={chat.id}
|
||||
value={chat.id}
|
||||
onSelect={(currentValue) => {
|
||||
handleChatChange(currentValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-3 w-1 rounded-md bg-green-500 opacity-0",
|
||||
{
|
||||
"opacity-100": chat.autoEnabled,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage
|
||||
src={`data:image/png;base64,${chat.avatar}`}
|
||||
/>
|
||||
<AvatarFallback>{chat.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{chat.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{chat.type} • {chat.unreadCount ?? 0} unread
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto",
|
||||
selectedChat?.id === chat.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
74
web/src/components/empty-state.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { MessageSquare, UserPlus } from "lucide-react";
|
||||
import { AccountList } from "./account-list";
|
||||
import { type TelegramAccount } from "@/lib/types";
|
||||
import TelegramIcon from "@/components/telegram-icon";
|
||||
import { AccountDialog } from "@/components/account-dialog";
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BorderBeam } from "@/components/ui/border-beam";
|
||||
|
||||
interface EmptyStateProps {
|
||||
hasAccounts: boolean;
|
||||
accounts?: TelegramAccount[];
|
||||
message?: string;
|
||||
onSelectAccount?: (accountId: string) => void;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
hasAccounts,
|
||||
accounts = [],
|
||||
message,
|
||||
onSelectAccount,
|
||||
}: EmptyStateProps) {
|
||||
if (message) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center">
|
||||
<MessageSquare className="mb-4 h-16 w-16 text-muted-foreground" />
|
||||
<h2 className="mb-2 text-2xl font-semibold">{message}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Choose a chat from the dropdown menu above to view and manage its
|
||||
files.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<div className="mb-8 flex flex-col items-center text-center">
|
||||
{hasAccounts ? (
|
||||
<>
|
||||
<TelegramIcon className="mb-4 h-16 w-16 text-muted-foreground" />
|
||||
<h2 className="mb-2 text-2xl font-semibold">Select an Account</h2>
|
||||
<p className="mb-4 max-w-md text-muted-foreground">
|
||||
Choose a Telegram account to view and manage your files. You can
|
||||
add more accounts using the button below.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TelegramIcon className="mb-4 h-16 w-16 text-muted-foreground" />
|
||||
<h2 className="mb-2 text-2xl font-semibold">No Accounts Found</h2>
|
||||
<p className="mb-4 max-w-md text-muted-foreground">
|
||||
Add a Telegram account to start managing your files. You can add
|
||||
multiple accounts and switch between them.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<AccountDialog isAdd={true}>
|
||||
<div className="relative rounded-md">
|
||||
<BorderBeam size={60} duration={12} delay={9} />
|
||||
<Button variant="outline">
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Add Account
|
||||
</Button>
|
||||
</div>
|
||||
</AccountDialog>
|
||||
</div>
|
||||
|
||||
{hasAccounts && accounts.length > 0 && onSelectAccount && (
|
||||
<AccountList accounts={accounts} onSelectAccount={onSelectAccount} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
web/src/components/file-card.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { type TelegramFile } from "@/lib/types";
|
||||
import { FileAudio2Icon, FileIcon, ImageIcon, VideoIcon } from "lucide-react";
|
||||
import SpoiledWrapper from "@/components/spoiled-wrapper";
|
||||
import Image from "next/image";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import FileProgress from "@/components/file-progress";
|
||||
import FileControl from "@/components/file-control";
|
||||
|
||||
interface FileCardProps {
|
||||
file: TelegramFile;
|
||||
}
|
||||
|
||||
export function FileCard({ file }: FileCardProps) {
|
||||
const getIcon = () => {
|
||||
switch (file.type) {
|
||||
case "photo":
|
||||
return <ImageIcon className="h-6 w-6" />;
|
||||
case "video":
|
||||
return <VideoIcon className="h-6 w-6" />;
|
||||
case "audio":
|
||||
return <FileAudio2Icon className="h-6 w-6" />;
|
||||
default:
|
||||
return <FileIcon className="h-6 w-6" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{file.type === "photo" || file.type === "video" ? (
|
||||
file.thumbnail ? (
|
||||
<SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}>
|
||||
<Image
|
||||
src={`data:image/jpeg;base64,${file.thumbnail}`}
|
||||
alt={file.name ?? "File thumbnail"}
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-16 w-16 rounded object-cover"
|
||||
/>
|
||||
</SpoiledWrapper>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded bg-muted">
|
||||
{getIcon()}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded bg-muted">
|
||||
{getIcon()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="mb-1 font-medium">{file.name}</h3>
|
||||
<div className="mb-2 text-sm text-muted-foreground">
|
||||
{prettyBytes(file.size)} • {file.type}
|
||||
</div>
|
||||
<div className="mb-2 w-full overflow-hidden">
|
||||
<FileProgress file={file} />
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<FileControl file={file} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
104
web/src/components/file-control.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { type TelegramFile } from "@/lib/types";
|
||||
import { useFileControl } from "@/hooks/use-file-control";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowDown, Loader2, Pause, SquareX, StepForward } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
export default function FileControl({ file }: { file: TelegramFile }) {
|
||||
const { start, starting, togglePause, togglingPause, cancel, cancelling } =
|
||||
useFileControl(file);
|
||||
|
||||
if (file.downloadStatus === "completed") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex w-full justify-end md:justify-around">
|
||||
{(file.downloadStatus === "idle" ||
|
||||
file.downloadStatus === "error") && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="xs" onClick={() => start(file.id)}>
|
||||
{starting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowDown className="h-4 w-4 stroke-1" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{file.downloadStatus === "idle"
|
||||
? "Start Download"
|
||||
: file.downloadStatus === "error"
|
||||
? "Retry"
|
||||
: "Downloading"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(file.downloadStatus === "downloading" ||
|
||||
file.downloadStatus === "paused") && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => togglePause(file.id)}
|
||||
>
|
||||
{togglingPause ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : file.downloadStatus === "downloading" ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<StepForward className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{file.downloadStatus === "downloading"
|
||||
? "Pause"
|
||||
: file.downloadStatus === "paused"
|
||||
? "Resume"
|
||||
: "Paused"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => cancel(file.id)}
|
||||
>
|
||||
{cancelling ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<SquareX className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{file.downloadStatus === "downloading" ||
|
||||
file.downloadStatus === "paused" ||
|
||||
file.downloadStatus === "error"
|
||||
? "Cancel"
|
||||
: "Cancelled"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
88
web/src/components/file-extra.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { Captions, Clock, Copy, FileCheck } from "lucide-react";
|
||||
import SpoiledWrapper from "@/components/spoiled-wrapper";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import React from "react";
|
||||
import { type TelegramFile } from "@/lib/types";
|
||||
import { type RowHeight } from "@/components/table-row-height-switch";
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
interface FileExtraProps {
|
||||
file: TelegramFile;
|
||||
rowHeight: RowHeight;
|
||||
}
|
||||
|
||||
export default function FileExtra({ file, rowHeight }: FileExtraProps) {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col space-y-1 overflow-hidden">
|
||||
<TooltipProvider>
|
||||
{rowHeight !== "s" && file.caption && (
|
||||
<SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<Captions className="h-4 w-4 flex-shrink-0" />
|
||||
<p className="line-clamp-2 overflow-hidden truncate text-ellipsis text-wrap text-sm">
|
||||
{file.caption}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent asChild>
|
||||
<div className="max-w-80 text-wrap rounded p-2">
|
||||
{file.caption}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</SpoiledWrapper>
|
||||
)}
|
||||
{rowHeight !== "s" && file.localPath && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<FileCheck className="h-4 w-4 flex-shrink-0" />
|
||||
<p
|
||||
className="group line-clamp-1 cursor-pointer overflow-hidden truncate text-ellipsis text-wrap rounded px-1 hover:bg-gray-100"
|
||||
onClick={() => copyToClipboard(file.localPath)}
|
||||
>
|
||||
{file.localPath.split("/").pop()}
|
||||
<Copy className="ml-1 inline-flex h-4 w-4 opacity-0 group-hover:opacity-100" />
|
||||
</p>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="max-w-80 text-wrap rounded p-2">
|
||||
{file.localPath}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<p className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="rounded px-1 text-sm text-muted-foreground hover:bg-gray-100">
|
||||
{formatDistanceToNow(new Date(file.date * 1000), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="max-w-80 text-wrap rounded p-2">
|
||||
{`Message received at ${file.formatDate}`}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
web/src/components/file-filters.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { type DownloadStatus, type FileFilter } from "@/lib/types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import TableColumnFilter, {
|
||||
type Column,
|
||||
} from "@/components/table-column-filter";
|
||||
import {
|
||||
type RowHeight,
|
||||
TableRowHeightSwitch,
|
||||
} from "@/components/table-row-height-switch";
|
||||
import FileTypeFilter from "@/components/file-type-filter";
|
||||
|
||||
interface FileFiltersProps {
|
||||
telegramId: string;
|
||||
chatId: string;
|
||||
filters: FileFilter;
|
||||
onFiltersChange: (filters: FileFilter) => void;
|
||||
columns: Column[];
|
||||
onColumnConfigChange: (config: Column[]) => void;
|
||||
rowHeight: RowHeight;
|
||||
setRowHeight: (e: RowHeight) => void;
|
||||
}
|
||||
|
||||
export function FileFilters({
|
||||
telegramId,
|
||||
chatId,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
columns,
|
||||
onColumnConfigChange,
|
||||
rowHeight,
|
||||
setRowHeight,
|
||||
}: FileFiltersProps) {
|
||||
return (
|
||||
<div className="mb-6 flex flex-col justify-between md:flex-row">
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<Input
|
||||
placeholder="Search files..."
|
||||
value={filters.search}
|
||||
onChange={(e) =>
|
||||
onFiltersChange({ ...filters, search: e.target.value })
|
||||
}
|
||||
className="md:w-[300px]"
|
||||
/>
|
||||
|
||||
<FileTypeFilter
|
||||
telegramId={telegramId}
|
||||
chatId={chatId}
|
||||
type={filters.type}
|
||||
onTypeChange={(type) => onFiltersChange({ ...filters, type })}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={filters.status}
|
||||
onValueChange={(value) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
status: value as DownloadStatus | "all",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Download status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="idle">Not downloaded</SelectItem>
|
||||
<SelectItem value="downloading">Downloading</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<TableColumnFilter
|
||||
columns={columns}
|
||||
onColumnConfigChange={onColumnConfigChange}
|
||||
/>
|
||||
{!!rowHeight && !!setRowHeight && (
|
||||
<TableRowHeightSwitch
|
||||
rowHeight={rowHeight}
|
||||
setRowHeightAction={setRowHeight}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
400
web/src/components/file-list.tsx
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
"use client";
|
||||
import { type TelegramFile } from "@/lib/types";
|
||||
import { FileCard } from "./file-card";
|
||||
import React, {
|
||||
memo,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Download,
|
||||
FileAudioIcon,
|
||||
FileIcon,
|
||||
ImageIcon,
|
||||
LoaderPinwheel,
|
||||
VideoIcon,
|
||||
} from "lucide-react";
|
||||
import { useFiles } from "@/hooks/use-files";
|
||||
import { FileFilters } from "@/components/file-filters";
|
||||
import {
|
||||
getRowHeightTailwindClass,
|
||||
type RowHeight,
|
||||
} from "@/components/table-row-height-switch";
|
||||
import { type Column } from "@/components/table-column-filter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import PhotoPreview from "@/components/photo-preview";
|
||||
import SpoiledWrapper from "@/components/spoiled-wrapper";
|
||||
import Image from "next/image";
|
||||
import FileControl from "@/components/file-control";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import FileProgress from "@/components/file-progress";
|
||||
import FileNotFount from "@/components/file-not-found";
|
||||
import FileExtra from "@/components/file-extra";
|
||||
|
||||
interface FileListProps {
|
||||
accountId: string;
|
||||
chatId: string;
|
||||
}
|
||||
|
||||
const COLUMNS: Column[] = [
|
||||
{ id: "content", label: "Content", isVisible: true },
|
||||
{ id: "type", label: "Type", isVisible: true, className: "w-16 text-center" },
|
||||
{
|
||||
id: "size",
|
||||
label: "Size",
|
||||
isVisible: true,
|
||||
className: "w-20 max-w-20 text-center",
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
isVisible: true,
|
||||
className: "w-16 text-center",
|
||||
},
|
||||
{ id: "extra", label: "Extra", isVisible: true },
|
||||
{
|
||||
id: "actions",
|
||||
label: "Actions",
|
||||
isVisible: true,
|
||||
className: "text-center w-40 min-w-40",
|
||||
},
|
||||
];
|
||||
|
||||
const PhotoColumnImage = memo(function PhotoColumnImage({
|
||||
thumbnail,
|
||||
name,
|
||||
wh,
|
||||
}: {
|
||||
thumbnail: string;
|
||||
name: string;
|
||||
wh: string;
|
||||
}) {
|
||||
return (
|
||||
<Image
|
||||
src={`data:image/jpeg;base64,${thumbnail}`}
|
||||
alt={name ?? "File thumbnail"}
|
||||
width={32}
|
||||
height={32}
|
||||
className={cn(wh, "rounded object-cover")}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export function FileList({ accountId, chatId }: FileListProps) {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<number>>(new Set());
|
||||
const observerTarget = useRef(null);
|
||||
const [columns, setColumns] = useState<Column[]>(COLUMNS);
|
||||
// const [rowHeight, setRowHeight] = useRowHeightLocalStorage("fileList", "m");
|
||||
const [rowHeight, setRowHeight] = useState<RowHeight>("m");
|
||||
|
||||
const { filters, handleFilterChange, isLoading, files, handleLoadMore } =
|
||||
useFiles(accountId, chatId);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
void handleLoadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
if (observerTarget.current) {
|
||||
observer.observe(observerTarget.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [handleLoadMore]);
|
||||
|
||||
const contentWH = useMemo(() => {
|
||||
switch (rowHeight) {
|
||||
case "s":
|
||||
return "h-6 w-6";
|
||||
case "m":
|
||||
return "h-20 w-20";
|
||||
case "l":
|
||||
return "h-60 w-60";
|
||||
}
|
||||
}, [rowHeight]);
|
||||
|
||||
const contentCellWidth = useMemo(() => {
|
||||
switch (rowHeight) {
|
||||
case "s":
|
||||
return "w-6";
|
||||
case "m":
|
||||
return "w-24";
|
||||
case "l":
|
||||
return "w-64";
|
||||
}
|
||||
}, [rowHeight]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{files.map((file, index) => (
|
||||
<FileCard
|
||||
key={`${file.id}-${file.uniqueId}-${index}`}
|
||||
file={file}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div ref={observerTarget} className="h-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedFiles.size === files.length) {
|
||||
setSelectedFiles(new Set());
|
||||
} else {
|
||||
setSelectedFiles(new Set(files.map((file) => file.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectFile = (fileId: number) => {
|
||||
const newSelected = new Set(selectedFiles);
|
||||
if (newSelected.has(fileId)) {
|
||||
newSelected.delete(fileId);
|
||||
} else {
|
||||
newSelected.add(fileId);
|
||||
}
|
||||
setSelectedFiles(newSelected);
|
||||
};
|
||||
|
||||
const getFileIcon = (type: TelegramFile["type"]) => {
|
||||
let icon;
|
||||
switch (type) {
|
||||
case "photo":
|
||||
icon = <ImageIcon className="h-4 w-4" />;
|
||||
break;
|
||||
case "video":
|
||||
icon = <VideoIcon className="h-4 w-4" />;
|
||||
break;
|
||||
case "audio":
|
||||
icon = <FileAudioIcon className="h-4 w-4" />;
|
||||
break;
|
||||
default:
|
||||
icon = <FileIcon className="h-4 w-4" />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
contentWH,
|
||||
"flex items-center justify-center rounded bg-muted",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const columnRenders: Record<
|
||||
string,
|
||||
(file: TelegramFile, index: number) => ReactNode
|
||||
> = {
|
||||
content: (file: TelegramFile) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{file.type === "photo" || file.type === "video" ? (
|
||||
file.thumbnail ? (
|
||||
<SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<PhotoColumnImage
|
||||
thumbnail={file.thumbnail}
|
||||
name={file.name}
|
||||
wh={contentWH}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent asChild>
|
||||
<PhotoPreview
|
||||
thumbnail={file.thumbnail}
|
||||
name={file.name}
|
||||
chatId={file.chatId}
|
||||
messageId={file.messageId}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</SpoiledWrapper>
|
||||
) : (
|
||||
getFileIcon(file.type)
|
||||
)
|
||||
) : (
|
||||
getFileIcon(file.type)
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
type: (file: TelegramFile) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="capitalize">{file.type}</span>
|
||||
<span className="text-xs">{file.id}</span>
|
||||
</div>
|
||||
),
|
||||
size: (file: TelegramFile) => <span>{prettyBytes(file.size)}</span>,
|
||||
status: (file: TelegramFile) => (
|
||||
<span className="capitalize">{file.downloadStatus}</span>
|
||||
),
|
||||
extra: (file: TelegramFile) => (
|
||||
<FileExtra file={file} rowHeight={rowHeight} />
|
||||
),
|
||||
actions: (file: TelegramFile) => <FileControl file={file} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileFilters
|
||||
telegramId={accountId}
|
||||
chatId={chatId}
|
||||
filters={filters}
|
||||
onFiltersChange={handleFilterChange}
|
||||
columns={columns}
|
||||
onColumnConfigChange={setColumns}
|
||||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
{selectedFiles.size > 0 && (
|
||||
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-4">
|
||||
<span className="text-sm">
|
||||
{selectedFiles.size} {selectedFiles.size === 1 ? "file" : "files"}{" "}
|
||||
selected
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
//TODO implement download selected
|
||||
}}
|
||||
disabled={Array.from(selectedFiles).every(
|
||||
(id) =>
|
||||
files.find((f) => f.id === id)?.downloadStatus ===
|
||||
"downloading",
|
||||
)}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download Selected
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative min-h-[calc(100vh-14rem)] rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[30px] text-center">
|
||||
<Checkbox
|
||||
checked={selectedFiles.size === files.length}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
{columns.map((col) =>
|
||||
col.isVisible ? (
|
||||
<TableHead
|
||||
key={col.id}
|
||||
className={cn(
|
||||
col.className ?? "",
|
||||
col.id === "content" ? contentCellWidth : "",
|
||||
)}
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
) : null,
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="[&_tr:last-child]:border-b">
|
||||
{files.map((file, index) => (
|
||||
<React.Fragment
|
||||
key={`${file.messageId}-${file.uniqueId}-${index}`}
|
||||
>
|
||||
<TableRow
|
||||
className={cn(
|
||||
getRowHeightTailwindClass(rowHeight),
|
||||
"border-b-0",
|
||||
)}
|
||||
>
|
||||
<TableCell className="text-center">
|
||||
<Checkbox
|
||||
checked={selectedFiles.has(file.id)}
|
||||
onCheckedChange={() => handleSelectFile(file.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
{columns.map((col) =>
|
||||
col.isVisible ? (
|
||||
<TableCell
|
||||
key={col.id}
|
||||
className={cn(
|
||||
col.className ?? "",
|
||||
col.id === "content" ? contentCellWidth : "",
|
||||
)}
|
||||
>
|
||||
{columnRenders[col.id]!(file, index)}
|
||||
</TableCell>
|
||||
) : null,
|
||||
)}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length + 1}
|
||||
className="h-px p-0"
|
||||
>
|
||||
<FileProgress file={file} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{!isLoading && files.length === 0 && (
|
||||
<TableRow className="border-b-0">
|
||||
<TableCell colSpan={columns.length + 1}>
|
||||
<FileNotFount />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white bg-opacity-90">
|
||||
<LoaderPinwheel
|
||||
className="h-8 w-8 animate-spin"
|
||||
style={{ strokeWidth: "0.8px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={observerTarget} className="h-4"></div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
109
web/src/components/file-not-found.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import OrbitingCircles from "@/components/ui/orbiting-circles";
|
||||
import {
|
||||
FileImage,
|
||||
FileText,
|
||||
FolderClosed,
|
||||
Headphones,
|
||||
ImageIcon,
|
||||
SquarePlay,
|
||||
} from "lucide-react";
|
||||
|
||||
const SolidFileIcon = ({ className }: { className: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 50 50"
|
||||
className={className}
|
||||
>
|
||||
<path d="M 30.398438 2 L 7 2 L 7 48 L 43 48 L 43 14.601563 Z M 15 28 L 31 28 L 31 30 L 15 30 Z M 35 36 L 15 36 L 15 34 L 35 34 Z M 35 24 L 15 24 L 15 22 L 35 22 Z M 30 15 L 30 4.398438 L 40.601563 15 Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function FileNotFount() {
|
||||
return (
|
||||
<div className="relative flex h-full min-h-[calc(100vh-17rem)] w-full flex-col items-center justify-center overflow-hidden">
|
||||
<SolidFileIcon className="h-10 w-10 text-muted-foreground" />
|
||||
<div className="absolute bottom-1/3 left-1/2 z-0 -translate-x-1/2 transform-gpu text-center">
|
||||
<span className="pointer-events-none whitespace-pre-wrap bg-gradient-to-b from-black to-gray-300 bg-clip-text text-center text-xl font-semibold leading-none text-transparent dark:from-white dark:to-black">
|
||||
No files found
|
||||
</span>
|
||||
<p className="pointer-events-none text-center text-xs text-muted-foreground">
|
||||
Your search did not match any files. <br />
|
||||
Try searching for something else.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Inner Circles */}
|
||||
<OrbitingCircles
|
||||
className="size-[30px] border-none bg-transparent"
|
||||
duration={20}
|
||||
delay={20}
|
||||
radius={80}
|
||||
>
|
||||
<div className="rounded bg-gray-100 p-2">
|
||||
<FolderClosed className="h-4 w-4 stroke-1" />
|
||||
</div>
|
||||
</OrbitingCircles>
|
||||
<OrbitingCircles
|
||||
className="size-[30px] border-none bg-transparent"
|
||||
duration={20}
|
||||
delay={10}
|
||||
radius={80}
|
||||
>
|
||||
<FileImage className="h-6 w-6 stroke-1" />
|
||||
</OrbitingCircles>
|
||||
|
||||
{/* Outer Circles (reverse) */}
|
||||
<OrbitingCircles
|
||||
className="size-[100px] border-none bg-transparent"
|
||||
radius={120}
|
||||
duration={20}
|
||||
reverse
|
||||
>
|
||||
<div className="rounded bg-gray-100 p-2">
|
||||
<ImageIcon className="h-4 w-4 stroke-1" />
|
||||
</div>
|
||||
</OrbitingCircles>
|
||||
<OrbitingCircles
|
||||
className="size-[120px] border-none bg-transparent"
|
||||
radius={160}
|
||||
duration={20}
|
||||
delay={20}
|
||||
reverse
|
||||
>
|
||||
<SquarePlay className="h-6 w-6 stroke-1 text-muted-foreground" />
|
||||
</OrbitingCircles>
|
||||
|
||||
<OrbitingCircles
|
||||
className="size-[160px] border-none bg-transparent"
|
||||
circleClassName="stroke-black/5 stroke-1 dark:stroke-white/5"
|
||||
radius={200}
|
||||
duration={20}
|
||||
delay={10}
|
||||
>
|
||||
<Headphones className="h-6 w-6 stroke-1 text-muted-foreground" />
|
||||
</OrbitingCircles>
|
||||
|
||||
<OrbitingCircles
|
||||
className="size-[160px] border-none bg-transparent"
|
||||
circleClassName="stroke-black/5 stroke-1 dark:stroke-white/5"
|
||||
radius={200}
|
||||
duration={20}
|
||||
delay={20}
|
||||
>
|
||||
<FileText className="h-6 w-6 stroke-1 text-muted-foreground" />
|
||||
</OrbitingCircles>
|
||||
|
||||
<OrbitingCircles
|
||||
className="size-[200px] border-none bg-transparent"
|
||||
circleClassName="stroke-black/5 stroke-1 dark:stroke-white/5"
|
||||
radius={240}
|
||||
>
|
||||
</OrbitingCircles>
|
||||
<OrbitingCircles
|
||||
className="size-[280px] border-none bg-transparent"
|
||||
circleClassName="stroke-black/5 stroke-1 dark:stroke-white/5"
|
||||
radius={280}
|
||||
></OrbitingCircles>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
web/src/components/file-progress.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Progress } from "@/components/ui/progress";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { type TelegramFile } from "@/lib/types";
|
||||
import { useFileSpeed } from "@/hooks/use-file-speed";
|
||||
import { useMemo } from "react";
|
||||
import { ClockArrowDown, Zap } from "lucide-react";
|
||||
|
||||
export default function FileProgress({ file }: { file: TelegramFile }) {
|
||||
const { downloadProgress, downloadSpeed } = useFileSpeed(file.id);
|
||||
const progress = useMemo(() => {
|
||||
const fileDownloadProgress =
|
||||
file.size > 0
|
||||
? Math.min((file.downloadedSize / file.size) * 100, 100)
|
||||
: 0;
|
||||
if (file.downloadStatus === "downloading") {
|
||||
return downloadProgress > 0 ? downloadProgress : fileDownloadProgress;
|
||||
}
|
||||
if (file.downloadStatus === "paused") {
|
||||
return fileDownloadProgress;
|
||||
}
|
||||
return file.downloadStatus === "completed" ? 100 : 0;
|
||||
}, [file.downloadStatus, file.downloadedSize, file.size, downloadProgress]);
|
||||
|
||||
if (file.downloadStatus === "idle" || file.downloadStatus === "completed" || file.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
{file.downloadedSize > 0 && (
|
||||
<div className="flex min-w-32 items-center gap-1 bg-gray-100 px-1">
|
||||
<ClockArrowDown className="h-3 w-3" />
|
||||
<span className="text-nowrap text-xs">
|
||||
{prettyBytes(file.downloadedSize)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-32 items-center gap-1 bg-gray-100 px-1">
|
||||
<Zap className="h-3 w-3" />
|
||||
<span className="text-nowrap text-xs">
|
||||
{file.downloadStatus === "downloading"
|
||||
? `${prettyBytes(downloadSpeed, { bits: true })}/s`
|
||||
: "0/s"}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progress} className="flex-1 rounded-none md:w-32" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
web/src/components/file-statistics.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import React from "react";
|
||||
import useSWR from "swr";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle, CloudDownload,
|
||||
Download,
|
||||
File,
|
||||
FileText,
|
||||
Image,
|
||||
Music,
|
||||
PauseCircle,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
import { request } from "@/lib/api"; // Define a fetcher function to handle the API request
|
||||
|
||||
// Interface defining the structure of the data returned from the API
|
||||
interface StatisticsData {
|
||||
total: number;
|
||||
downloading: number;
|
||||
paused: number;
|
||||
completed: number;
|
||||
error: number;
|
||||
photo: number;
|
||||
video: number;
|
||||
audio: number;
|
||||
file: number;
|
||||
}
|
||||
|
||||
// Props interface for the component, expecting a telegramId as input
|
||||
interface FileStatisticsProps {
|
||||
telegramId: string;
|
||||
}
|
||||
|
||||
const FileStatistics: React.FC<FileStatisticsProps> = ({ telegramId }) => {
|
||||
// Use SWR for data fetching and caching
|
||||
const { data, error } = useSWR<StatisticsData, Error>(
|
||||
`/telegram/${telegramId}/download-statistics`,
|
||||
request,
|
||||
);
|
||||
|
||||
// Render an error message if the API call fails
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-white p-4 text-red-600 shadow-md">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<span>Failed to load data.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render a loading indicator while the data is being fetched
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-white p-4 text-gray-600 shadow-md">
|
||||
<Download className="h-5 w-5 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Destructure the fetched data for easier usage
|
||||
const {
|
||||
total,
|
||||
downloading,
|
||||
paused,
|
||||
completed,
|
||||
error: errorCount,
|
||||
photo,
|
||||
video,
|
||||
audio,
|
||||
file,
|
||||
} = data;
|
||||
|
||||
// Prepare an array of completed file types with their respective icons
|
||||
const completedTypes = [
|
||||
{
|
||||
label: "Photo",
|
||||
value: photo,
|
||||
// eslint-disable-next-line jsx-a11y/alt-text
|
||||
icon: <Image className="h-5 w-5 text-blue-500" />,
|
||||
},
|
||||
{
|
||||
label: "Video",
|
||||
value: video,
|
||||
icon: <Video className="h-5 w-5 text-green-500" />,
|
||||
},
|
||||
{
|
||||
label: "Audio",
|
||||
value: audio,
|
||||
icon: <Music className="h-5 w-5 text-purple-500" />,
|
||||
},
|
||||
{
|
||||
label: "File",
|
||||
value: file,
|
||||
icon: <File className="h-5 w-5 text-gray-500" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 rounded-lg bg-gray-50 p-6">
|
||||
<div className="flex-1 rounded-lg bg-white p-4 shadow-md">
|
||||
<div className="flex items-center space-x-3 border-gray-200">
|
||||
<CloudDownload className="h-6 w-6 text-blue-600" />
|
||||
<h2 className="text-xl font-bold text-gray-800">Download Statistics</h2>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 md:grid-cols-5">
|
||||
<div className="rounded-lg bg-gray-50 p-4 shadow-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FileText className="h-5 w-5 text-gray-600" />
|
||||
<span className="text-sm text-gray-600">Total Files</span>
|
||||
</div>
|
||||
<div className="mt-2 text-lg text-center font-semibold text-gray-800">
|
||||
{total}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-4 shadow-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Download className="h-5 w-5 text-blue-600" />
|
||||
<span className="text-sm text-gray-600">Downloading</span>
|
||||
</div>
|
||||
<div className="mt-2 text-lg text-center font-semibold text-gray-800">
|
||||
{downloading}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-4 shadow-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<PauseCircle className="h-5 w-5 text-yellow-500" />
|
||||
<span className="text-sm text-gray-600">Paused</span>
|
||||
</div>
|
||||
<div className="mt-2 text-lg text-center font-semibold text-gray-800">
|
||||
{paused}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-4 shadow-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<span className="text-sm text-gray-600">Completed</span>
|
||||
</div>
|
||||
<div className="mt-2 text-lg text-center font-semibold text-gray-800">
|
||||
{completed}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-4 shadow-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600" />
|
||||
<span className="text-sm text-gray-600">Error</span>
|
||||
</div>
|
||||
<div className="mt-2 text-lg text-center font-semibold text-gray-800">
|
||||
{errorCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 rounded-lg bg-white p-4 shadow-md">
|
||||
<h3 className="text-md flex items-center space-x-2 font-semibold text-gray-700">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<span>Completed by Type</span>
|
||||
</h3>
|
||||
<ul className="mt-4 grid grid-cols-2 gap-4">
|
||||
{completedTypes.map((type) => (
|
||||
<li
|
||||
key={type.label}
|
||||
className="rounded-lg bg-gray-50 p-3 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{type.icon}
|
||||
<span className="text-sm text-gray-600">{type.label}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-gray-800">
|
||||
{type.value}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileStatistics;
|
||||
63
web/src/components/file-type-filter.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { FileType } from "@/lib/types";
|
||||
import useSWR from "swr";
|
||||
import { Ellipsis } from "lucide-react";
|
||||
|
||||
interface FileTypeFilterProps {
|
||||
telegramId: string;
|
||||
chatId: string;
|
||||
type: FileType;
|
||||
onTypeChange: (type: FileType) => void;
|
||||
}
|
||||
|
||||
export default function FileTypeFilter({
|
||||
telegramId,
|
||||
chatId,
|
||||
type,
|
||||
onTypeChange,
|
||||
}: FileTypeFilterProps) {
|
||||
const { data: counts, isLoading } = useSWR<Record<FileType, number>>(
|
||||
`/telegram/${telegramId}/chat/${chatId}/files/count`,
|
||||
);
|
||||
|
||||
const FileTypeSelectItem = ({ value }: { value: FileType }) => {
|
||||
return (
|
||||
<SelectItem value={value}>
|
||||
<div className="flex w-20 items-center justify-between">
|
||||
<span>{value.charAt(0).toUpperCase() + value.slice(1)}</span>
|
||||
{isLoading ? (
|
||||
<Ellipsis className="h-4 w-4 animate-pulse" />
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">
|
||||
{counts?.[value] ? `(${counts[value]})` : "(0)"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(value) => onTypeChange(value as FileType)}
|
||||
>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="File type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<FileTypeSelectItem value="media" />
|
||||
<FileTypeSelectItem value="photo" />
|
||||
<FileTypeSelectItem value="video" />
|
||||
<FileTypeSelectItem value="audio" />
|
||||
<FileTypeSelectItem value="file" />
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
154
web/src/components/header.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
ChevronsLeftRightEllipsisIcon,
|
||||
CloudDownloadIcon,
|
||||
Ellipsis,
|
||||
UnplugIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "./ui/tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useWebsocket } from "@/hooks/use-websocket";
|
||||
import { useTelegramAccount } from "@/hooks/use-telegram-account";
|
||||
import { SettingsDialog } from "@/components/settings-dialog";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import ChatSelect from "@/components/chat-select";
|
||||
import Link from "next/link";
|
||||
import TelegramIcon from "@/components/telegram-icon";
|
||||
import AutoDownloadDialog from "@/components/auto-download-dialog";
|
||||
|
||||
export function Header() {
|
||||
const { isLoading, getAccounts, accountId, account, handleAccountChange } =
|
||||
useTelegramAccount();
|
||||
const { connectionStatus, accountDownloadSpeed } = useWebsocket();
|
||||
const accounts = getAccounts("active");
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<div className="flex flex-1 flex-col gap-4 md:flex-row md:items-center">
|
||||
<Link href={"/"} className="hidden md:inline-flex">
|
||||
<TelegramIcon className="h-6 w-6" />
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={accountId} onValueChange={handleAccountChange}>
|
||||
<SelectTrigger className="w-full md:w-[200px]">
|
||||
<SelectValue placeholder="Select account ...">
|
||||
{account ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage
|
||||
src={`data:image/png;base64,${account.avatar}`}
|
||||
/>
|
||||
<AvatarFallback>{account.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="max-w-[170px] overflow-hidden truncate">
|
||||
{account.name}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
`Select account ...`
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isLoading && (
|
||||
<SelectItem
|
||||
value="loading"
|
||||
disabled
|
||||
className="flex justify-center"
|
||||
>
|
||||
<Ellipsis className="h-4 w-4 animate-pulse" />
|
||||
</SelectItem>
|
||||
)}
|
||||
{accounts.map((account) => (
|
||||
<SelectItem key={account.id} value={account.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage
|
||||
src={`data:image/png;base64,${account.avatar}`}
|
||||
/>
|
||||
<AvatarFallback>{account.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{account.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{account.phoneNumber}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<ChatSelect disabled={!accountId} />
|
||||
|
||||
<AutoDownloadDialog />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{`${prettyBytes(accountDownloadSpeed, { bits: true })}/s`}
|
||||
</span>
|
||||
<CloudDownloadIcon className="h-4 w-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Current account download speed</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{connectionStatus && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant={
|
||||
connectionStatus === "Open" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{connectionStatus === "Open" ? (
|
||||
<ChevronsLeftRightEllipsisIcon className="mr-1 h-4 w-4" />
|
||||
) : (
|
||||
<UnplugIcon className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{connectionStatus}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>WebSocket connection status</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<SettingsDialog />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
146
web/src/components/photo-preview.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"use client";
|
||||
import Image from "next/image";
|
||||
import useSWR from "swr";
|
||||
import { CloudAlert, Loader } from "lucide-react";
|
||||
import { useWebsocket } from "@/hooks/use-websocket";
|
||||
import { useEffect, useState } from "react";
|
||||
import { WebSocketMessageType } from "@/lib/websocket-types";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import { type TDFile } from "@/lib/types";
|
||||
import { getApiUrl } from "@/lib/api";
|
||||
|
||||
interface PhotoPreviewProps {
|
||||
thumbnail: string;
|
||||
name: string;
|
||||
chatId: number;
|
||||
messageId: number;
|
||||
}
|
||||
|
||||
export default function PhotoPreview({
|
||||
thumbnail,
|
||||
name,
|
||||
chatId,
|
||||
messageId,
|
||||
}: PhotoPreviewProps) {
|
||||
const url = `${getApiUrl()}/file/preview?chatId=${chatId}&messageId=${messageId}`;
|
||||
const { settings } = useSettings();
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [fileStatus, setFileStatus] = useState<{
|
||||
fileId: number | null;
|
||||
readyFileIds: number[];
|
||||
}>({
|
||||
fileId: null,
|
||||
readyFileIds: [],
|
||||
});
|
||||
const { lastJsonMessage } = useWebsocket();
|
||||
|
||||
const { error } = useSWR<void, Error>(
|
||||
settings?.needToLoadImages === "true" ? url : null,
|
||||
(url: string) =>
|
||||
fetch(url, { credentials: "include" }).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
let message = "Failed to fetch, status: " + res.status;
|
||||
try {
|
||||
const { error } = await res.json() as { error: string };
|
||||
if (error) {
|
||||
message = error;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
toast({
|
||||
title: "Error",
|
||||
description: message,
|
||||
variant: "destructive",
|
||||
});
|
||||
throw new Error(message);
|
||||
}
|
||||
if (res.headers.get("Content-Type") === "application/json") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const json: { fileId: number } = await res.json();
|
||||
if (json.fileId) {
|
||||
setFileStatus((prev) => ({
|
||||
...prev,
|
||||
fileId: json.fileId,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
setIsReady(true);
|
||||
}
|
||||
}),
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateOnMount: true,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
lastJsonMessage !== null &&
|
||||
lastJsonMessage.type === WebSocketMessageType.FILE_UPDATE
|
||||
) {
|
||||
const { file } = lastJsonMessage.data as { file: TDFile };
|
||||
if (file.local?.isDownloadingCompleted) {
|
||||
setFileStatus((prev) => ({
|
||||
...prev,
|
||||
readyFileIds: [...prev.readyFileIds, file.id],
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [lastJsonMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
const { fileId, readyFileIds } = fileStatus;
|
||||
if (fileId !== null && readyFileIds.includes(fileId)) {
|
||||
setIsReady(true);
|
||||
}
|
||||
}, [fileStatus]);
|
||||
|
||||
const ThumbnailImage = (
|
||||
<div className="rounded-lg border border-gray-300 bg-white p-2 shadow-lg">
|
||||
<Image
|
||||
src={`data:image/jpeg;base64,${thumbnail}`}
|
||||
alt={name ?? "Photo Thumbnail"}
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-[200px] w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (settings?.needToLoadImages !== "true") {
|
||||
return ThumbnailImage;
|
||||
}
|
||||
|
||||
if (!isReady || error) {
|
||||
return (
|
||||
<div className="relative rounded-lg border border-gray-300 bg-white p-2 shadow-lg">
|
||||
{ThumbnailImage}
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
|
||||
{error ? (
|
||||
<CloudAlert className="h-8 w-8" />
|
||||
) : (
|
||||
!isReady && <Loader className="h-8 w-8 animate-spin text-white" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-300 bg-white p-2 shadow-lg">
|
||||
<Image
|
||||
src={url}
|
||||
unoptimized={true}
|
||||
blurDataURL={`data:image/jpeg;base64,${thumbnail}`}
|
||||
alt={name ?? "Photo"}
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-[200px] w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
web/src/components/settings-dialog.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import React, { type FormEvent, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {Bell, Settings} from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import FileStatistics from "@/components/file-statistics";
|
||||
import { useTelegramAccount } from "@/hooks/use-telegram-account";
|
||||
|
||||
export const SettingsDialog: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { accountId } = useTelegramAccount();
|
||||
|
||||
const { settings, setSetting, updateSettings } = useSettings();
|
||||
|
||||
const imageLoadSizeOptions = [
|
||||
{ value: "s", label: "box 100x100" },
|
||||
{ value: "m", label: "box 320x320" },
|
||||
{ value: "x", label: "box 800x800" },
|
||||
{ value: "y", label: "box 1280x1280" },
|
||||
{ value: "w", label: "box 2560x2560" },
|
||||
{ value: "a", label: "crop 160x160" },
|
||||
{ value: "b", label: "crop 320x320" },
|
||||
{ value: "c", label: "crop 640x640" },
|
||||
{ value: "d", label: "crop 1280x1280" },
|
||||
];
|
||||
|
||||
const handleSave = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
await updateSettings();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger
|
||||
asChild
|
||||
onClick={() => {
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="md:max-w-2/3 h-full w-full max-w-full md:h-3/4 md:w-2/3"
|
||||
onPointerDownOutside={() => setIsOpen(false)}
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<Tabs
|
||||
defaultValue="general"
|
||||
className="mt-3 flex h-full flex-col overflow-hidden"
|
||||
>
|
||||
<TabsList className="justify-start">
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="statistics">Statistics</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
value="general"
|
||||
className="flex flex-col overflow-hidden"
|
||||
>
|
||||
<form onSubmit={handleSave} className="flex flex-col space-y-4 overflow-y-scroll">
|
||||
<p className="rounded-md bg-gray-50 p-2 text-sm text-muted-foreground shadow">
|
||||
<Bell className="h-4 w-4 inline-block mr-2" />
|
||||
These settings will be applied to all accounts.
|
||||
</p>
|
||||
<div className="flex w-full flex-col space-y-4 rounded-md border p-4 shadow">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label htmlFor="unique-only">Unique Only</Label>
|
||||
<Checkbox
|
||||
id="unique-only"
|
||||
checked={settings?.uniqueOnly === "true"}
|
||||
onCheckedChange={(checked) =>
|
||||
void setSetting("uniqueOnly", String(checked))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Show only unique file in the table. If disabled, will show
|
||||
all. <br />
|
||||
<strong>Warning:</strong> If enabled, the number of documents
|
||||
on the form will be inaccurate.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col space-y-4 rounded-md border p-4 shadow">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label htmlFor="need-load-preview-images">
|
||||
Need Load Preview Images
|
||||
</Label>
|
||||
<Checkbox
|
||||
id="need-load-preview-images"
|
||||
checked={settings?.needToLoadImages === "true"}
|
||||
onCheckedChange={(checked) => {
|
||||
void setSetting("needToLoadImages", String(checked));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{settings?.needToLoadImages === "true" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="image-load-size">Load Size</Label>
|
||||
<Select
|
||||
value={settings.imageLoadSize}
|
||||
onValueChange={(v) => void setSetting("imageLoadSize", v)}
|
||||
>
|
||||
<SelectTrigger id="image-load-size">
|
||||
<SelectValue placeholder="Select Image Load Size" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{imageLoadSizeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The size of the image to load in the browser.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-col space-y-4 rounded-md border p-4 shadow">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label htmlFor="show-sensitive-content">
|
||||
Show Sensitive Content
|
||||
</Label>
|
||||
<Checkbox
|
||||
id="show-sensitive-content"
|
||||
checked={settings?.showSensitiveContent === "true"}
|
||||
onCheckedChange={(checked) =>
|
||||
void setSetting("showSensitiveContent", String(checked))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Show sensitive content in the table, Will use a spoiler to
|
||||
hide sensitive content if disabled.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col space-y-4 rounded-md border p-4 shadow">
|
||||
<Label>Auto Download Settings</Label>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Label htmlFor="limit">Limit Per Account</Label>
|
||||
<Input
|
||||
id="limit"
|
||||
className="w-24"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={settings?.autoDownloadLimit ?? 5}
|
||||
onChange={(e) => {
|
||||
void setSetting("autoDownloadLimit", e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div className="mt-2 flex flex-1 justify-end">
|
||||
<Button type="submit">Submit</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="statistics"
|
||||
className="flex flex-col overflow-y-scroll"
|
||||
>
|
||||
{accountId ? (
|
||||
<FileStatistics telegramId={accountId} />
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Please select an account to view statistics
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
28
web/src/components/spoiled-wrapper.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { useSettings } from "@/hooks/use-settings";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { Spoiler } from "spoiled";
|
||||
|
||||
export default function SpoiledWrapper({
|
||||
hasSensitiveContent,
|
||||
children,
|
||||
}: {
|
||||
hasSensitiveContent: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { settings } = useSettings();
|
||||
const hidden = useMemo(() => true, []);
|
||||
const showSensitiveContent = useMemo(
|
||||
() => settings?.showSensitiveContent === "true",
|
||||
[settings?.showSensitiveContent],
|
||||
);
|
||||
|
||||
if (hasSensitiveContent && !showSensitiveContent) {
|
||||
return (
|
||||
<Spoiler hidden={hidden} className="pointer-events-none">
|
||||
{children}
|
||||
</Spoiler>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
24
web/src/components/swr-provider.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use client";
|
||||
import { SWRConfig } from "swr";
|
||||
import React from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { request } from "@/lib/api";
|
||||
|
||||
export const SWRProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const { toast } = useToast();
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
// provider: localStorageProvider,
|
||||
refreshInterval: 0,
|
||||
errorRetryCount: 1,
|
||||
fetcher: request,
|
||||
onError: (err: Error) => {
|
||||
toast({ title: "Error", description: err.message });
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SWRConfig>
|
||||
);
|
||||
};
|
||||
161
web/src/components/table-column-filter.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { ChevronDown, Columns, Menu } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type Column = {
|
||||
id: string;
|
||||
label: string;
|
||||
isVisible: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type SortableItemProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
onToggleVisibility: (id: string) => void;
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
function SortableItem({
|
||||
id,
|
||||
label,
|
||||
onToggleVisibility,
|
||||
isVisible,
|
||||
}: SortableItemProps) {
|
||||
const { attributes, isDragging, listeners, setNodeRef, transform } =
|
||||
useSortable({ id });
|
||||
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={isVisible}
|
||||
onCheckedChange={() => onToggleVisibility(id)}
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
isDragging ? "opacity-80" : "opacity-100",
|
||||
"group whitespace-nowrap",
|
||||
)}
|
||||
style={{
|
||||
transform: transform ? CSS.Translate.toString(transform) : "none",
|
||||
transition: "width transform 0.2s ease-in-out",
|
||||
zIndex: isDragging ? 1 : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="mr-1">
|
||||
<span className="capitalize">{label}</span>
|
||||
</div>
|
||||
<Button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Drag and drop to reorder columns"
|
||||
className="invisible ml-auto group-hover:visible"
|
||||
>
|
||||
<Menu className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
type TableColumnFilterProps = {
|
||||
columns: Column[];
|
||||
onColumnConfigChange: (config: Column[]) => void;
|
||||
};
|
||||
|
||||
export default function TableColumnFilter({
|
||||
columns,
|
||||
onColumnConfigChange,
|
||||
}: TableColumnFilterProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [columnConfig, setColumnConfig] = useState<Column[]>(
|
||||
columns.map((col) => ({ id: col.id, label: col.label, isVisible: true })),
|
||||
);
|
||||
|
||||
const handleToggleVisibility = (id: string) => {
|
||||
setColumnConfig((prev) =>
|
||||
prev.map((col) =>
|
||||
col.id === id ? { ...col, isVisible: !col.isVisible } : col,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (active.id !== over?.id) {
|
||||
setColumnConfig((prev) => {
|
||||
const oldIndex = prev.findIndex((col) => col.id === active.id);
|
||||
const newIndex = prev.findIndex((col) => col.id === over?.id);
|
||||
return arrayMove(prev, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const applyChanges = () => {
|
||||
const visibleColumns = columnConfig.filter((col) => col.isVisible);
|
||||
onColumnConfigChange(visibleColumns);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen}>
|
||||
<DropdownMenuTrigger
|
||||
onClick={() => {
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
className="select-none"
|
||||
asChild
|
||||
>
|
||||
<Button variant="outline" title="Show/hide columns">
|
||||
<Columns className="mr-2 h-4 w-4" />
|
||||
<span className="text-xs text-muted-foreground">{`(${columnConfig.length}/${columns.length})`}</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onPointerDownOutside={() => setIsOpen(false)}
|
||||
className="max-h-96 overflow-y-auto"
|
||||
>
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={columnConfig.map((col) => col.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{columnConfig.map((col) => (
|
||||
<SortableItem
|
||||
key={col.id}
|
||||
id={col.id}
|
||||
label={col.label}
|
||||
isVisible={col.isVisible}
|
||||
onToggleVisibility={handleToggleVisibility}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={applyChanges}>
|
||||
Apply Changes
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
74
web/src/components/table-row-height-switch.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"use client";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
import { Rows2, Rows3, Rows4 } from "lucide-react";
|
||||
|
||||
const heightOptions = [
|
||||
{
|
||||
id: "s",
|
||||
label: "Small",
|
||||
value: "h-6",
|
||||
icon: <Rows4 className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: "m",
|
||||
label: "Medium",
|
||||
value: "h-24",
|
||||
icon: <Rows3 className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: "l",
|
||||
label: "Large",
|
||||
value: "h-64",
|
||||
icon: <Rows2 className="h-4 w-4" />,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type RowHeight = (typeof heightOptions)[number]["id"];
|
||||
|
||||
export const getRowHeightTailwindClass = (rowHeight: RowHeight | undefined) =>
|
||||
heightOptions.find((h) => h.id === rowHeight)?.value;
|
||||
|
||||
export function useRowHeightLocalStorage(
|
||||
tableName: string,
|
||||
defaultValue: RowHeight,
|
||||
) {
|
||||
const [rowHeight, setRowHeight, clearRowHeight] = useLocalStorage<RowHeight>(
|
||||
`${tableName}Height`,
|
||||
defaultValue,
|
||||
);
|
||||
|
||||
return [rowHeight, setRowHeight, clearRowHeight] as const;
|
||||
}
|
||||
|
||||
export const TableRowHeightSwitch = ({
|
||||
rowHeight,
|
||||
setRowHeightAction,
|
||||
}: {
|
||||
rowHeight: RowHeight;
|
||||
setRowHeightAction: (e: RowHeight) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Tabs
|
||||
value={rowHeight}
|
||||
onValueChange={(e) => {
|
||||
setRowHeightAction(e as "s" | "m" | "l");
|
||||
}}
|
||||
key="height"
|
||||
>
|
||||
<TabsList className="gap-1 border bg-transparent px-2">
|
||||
{heightOptions.map(({ id, label, icon }) => (
|
||||
<TabsTrigger
|
||||
key={id}
|
||||
value={id}
|
||||
className="px-2 shadow-none data-[state=active]:bg-input data-[state=active]:ring-border"
|
||||
>
|
||||
<span role="img" aria-label={`${label} size`}>
|
||||
{icon}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
13
web/src/components/telegram-icon.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export default function TelegramIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<title>Telegram</title>
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
50
web/src/components/ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
36
web/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
49
web/src/components/ui/border-beam.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BorderBeamProps {
|
||||
className?: string;
|
||||
size?: number;
|
||||
duration?: number;
|
||||
borderWidth?: number;
|
||||
anchor?: number;
|
||||
colorFrom?: string;
|
||||
colorTo?: string;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export const BorderBeam = ({
|
||||
className,
|
||||
size = 200,
|
||||
duration = 15,
|
||||
anchor = 90,
|
||||
borderWidth = 1.5,
|
||||
colorFrom = "#ffaa40",
|
||||
colorTo = "#9c40ff",
|
||||
delay = 0,
|
||||
}: BorderBeamProps) => {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--size": size,
|
||||
"--duration": duration,
|
||||
"--anchor": anchor,
|
||||
"--border-width": borderWidth,
|
||||
"--color-from": colorFrom,
|
||||
"--color-to": colorTo,
|
||||
"--delay": `-${delay}s`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 rounded-[inherit] [border:calc(var(--border-width)*1px)_solid_transparent]",
|
||||
|
||||
// mask styles
|
||||
"![mask-clip:padding-box,border-box] ![mask-composite:intersect] [mask:linear-gradient(transparent,transparent),linear-gradient(white,white)]",
|
||||
|
||||
// pseudo styles
|
||||
"after:absolute after:aspect-square after:w-[calc(var(--size)*1px)] after:animate-border-beam after:[animation-delay:var(--delay)] after:[background:linear-gradient(to_left,var(--color-from),var(--color-to),transparent)] after:[offset-anchor:calc(var(--anchor)*1%)_50%] after:[offset-path:rect(0_auto_auto_0_round_calc(var(--size)*1px))]",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
58
web/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
xs: "h-6 px-2 py-1",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
83
web/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||