🎉 First commit.

This commit is contained in:
jarvis2f 2024-12-15 19:46:49 +08:00
commit 972912c040
135 changed files with 77111 additions and 0 deletions

150
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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`**
&nbsp; [<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`**
&nbsp; [<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`**
&nbsp; [<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`**
&nbsp; [<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 Telegrams 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
View file

@ -0,0 +1 @@
0.0.0

83
api/build.gradle Normal file
View 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

Binary file not shown.

View 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
View 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
View 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
View file

@ -0,0 +1,2 @@
rootProject.name = 'api'

View 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);
}

File diff suppressed because it is too large Load diff

View 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())));
}
});
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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());
}
}

View 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;
}
}

View 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));
}
}

View 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;
}
}
}

View 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;
}
}

View 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);
});
}
}

View 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);
}
}

View file

@ -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);
}

View file

@ -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());
}
}

View 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;
}
}

View file

@ -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())
));
}

View file

@ -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);
}

View file

@ -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())
));
}

View file

@ -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();
}

View file

@ -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();
});
}
}

View file

@ -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()))
);
}
}

View file

@ -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);
}
}

View 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

View 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();
})));
}
}

View 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");
}
}

View file

@ -0,0 +1,6 @@
package telegram.files;
public class TelegramVerticleTest {
}

16
docker-compose.yaml Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB

94
misc/update_version.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

76
web/package.json Normal file
View 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
View 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
View file

@ -0,0 +1,5 @@
export default {
plugins: {
tailwindcss: {},
},
};

4
web/prettier.config.js Normal file
View file

@ -0,0 +1,4 @@
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
export default {
plugins: ["prettier-plugin-tailwindcss"],
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

6
web/public/favicon.svg Normal file
View 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

View 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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View 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>
);
}

View 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
View 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
View 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}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 };

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
};

View 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}</>;
}

View 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>
);
};

View 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>
);
}

View 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>
);
};

View 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>
);
}

View 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 };

View 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 };

View 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,
)}
/>
);
};

View 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 };

View 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,
};

Some files were not shown because too many files have changed in this diff Show more