Compare commits

..

14 Commits

Author SHA1 Message Date
8291c72b0d Add "true" to confirm commands list 2025-12-16 22:36:11 +03:00
Maksim Pischulenok
20e6c61f31 Merge pull request #7 from pischule/split-ci-workflow
Split single ci workflow into two
2025-12-09 21:35:25 +03:00
99188bea32 Split single ci workflow into two 2025-12-09 21:33:37 +03:00
c791db409a Make q button toggle qr visibility 2025-12-07 19:22:05 +03:00
b31bd87687 Make qr code alt text invisible
Skip transcoding if audio/video already has desired encoding
2025-12-07 19:05:01 +03:00
061743492e Optimize transcoding
Skip transcoding if audio/video already has desired encoding
2025-12-07 19:00:47 +03:00
1266b0f440 Migrate from minio to aws s3 sdk 2025-12-07 17:55:06 +03:00
23dcd998bc Upgrade ktfmt 2025-12-07 16:02:20 +03:00
8337df89e6 Upgrade dependencies 2025-12-07 16:01:20 +03:00
702444d016 Improve docs 2025-12-07 15:28:19 +03:00
70bec5ea4f Disable json logs 2025-10-12 18:11:09 +03:00
6dae22ad14 Improve Dockerfile 2025-10-12 18:10:56 +03:00
2aa8dee019 Update mise config 2025-10-12 18:10:18 +03:00
Maksim Pischulenok
d7656e7675 Merge pull request #5 from pischule/pischule-license
Add LICENSE
2025-09-08 08:48:43 +03:00
20 changed files with 319 additions and 154 deletions

View File

@@ -1,37 +1,13 @@
name: CI
name: release
on:
workflow_dispatch:
push:
branches: [ main ]
pull_request:
branches: [ main ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Run Tests
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
cache: 'gradle'
- name: Test with Gradle
run: ./gradlew check
build-and-publish:
name: Build and Publish
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read

23
.github/workflows/test.yaml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: test
on: [push]
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
cache: 'gradle'
- name: Test with Gradle
run: ./gradlew check

View File

@@ -1,11 +1,20 @@
FROM docker.io/eclipse-temurin:21
RUN apt-get update \
&& apt-get install -y ffmpeg \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd -r spring && useradd --no-log-init -r -g spring spring
USER spring:spring
RUN groupadd --system app \
&& useradd --no-log-init --system --shell /sbin/nologin --gid app app
WORKDIR /app
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar
COPY build/resources/main/static BOOT-INF/classes/static
ENTRYPOINT ["java","-jar","/app.jar"]
COPY --chown=app:app ${JAR_FILE} app.jar
COPY --chown=app:app build/resources/main/static BOOT-INF/classes/static
USER app:app
ENTRYPOINT ["java","-jar","app.jar"]

View File

@@ -2,12 +2,24 @@
A Telegram bot for meme management with cloud storage integration and a web interface for display.
## Features
- 🤖 Telegram bot that handles image and video memes
- ☁️ Cloud storage integration (AWS S3/Yandex Cloud compatible)
- 🖼️ Web interface with auto-refreshing media display
- 🔐 Approver user system for content moderation
- 📁 Local development mode with dummy storage
## Core Functionality (User Stories)
### Suggesting a Meme (User)
* User scans QR code.
* User sends media (video/picture) to the bot.
* Bot forwards the media to the **Approver Group**.
### Approving a Meme (Approver)
* **Approver** replies to media in the **Approver Group** with an approval command (e.g., `!SOXOK`, `this`).
* **Processing:** If video, bot re-encodes to WebM (AV1 + Opus).
* Bot uploads the final media to **S3**.
* **Client Update:** Frontend detects S3 resource change and downloads the latest media.
### Direct Meme Submission (Approver)
* **Approver** sends media directly to the **Approver Group** or the bot.
## Architecture
The system consists of:
@@ -16,46 +28,29 @@ The system consists of:
- **Web Interface**: Simple HTML page for viewing memes
- **Spring Boot**: Kotlin-based backend with dependency injection
![structurizr-1-ContainerDiagram.svg](docs/structurizr/out/structurizr-1-ContainerDiagram.svg)
## Setup
1. Create `.env` file with:
```properties
# Telegram Bot Configuration
BOT_TOKEN=your_bot_token
FORWARD_CHAT_ID=your_forward_chat_id
APPROVER_USER_IDS=id1,id2,id3
# S3 Configuration (for production)
S3_ACCESS_KEY_ID=your_key
S3_SECRET_ACCESS_KEY=your_secret
S3_BUCKET=your_bucket_name
```
2. For local development:
```bash
./gradlew bootRun --args='--spring.profiles.active=local'
```
## Usage
1. Send a meme (image/video) to the Telegram bot
2. Reply with `this` or `!soxok` to approve and upload
3. View the meme at `http://localhost:8080` (or your deployed URL)
The application is configured via Spring Boot properties.
## Deployment
### Docker
```bash
docker build -t memevizor .
docker run -d -p 8080:8080 memevizor
```
### Cloud (Heroku/AWS/etc)
1. Set environment variables
2. Deploy with your preferred provider
## Contributing
Contributions welcome! Please:
1. Fork the repository
2. Create a feature branch
3. Submit a pull request
1. Run the container with environment variables:
```bash
docker run -d \
-e BOT_TOKEN='your_bot_token' \
-e BOT_FORWARD_CHAT_ID='your_forward_chat_id' \
-e BOT_APPROVER_USER_IDS='id1,id2,id3' \
-e S3_ENDPOINT='https.s3.example.com' \
-e S3_REGION='us-east-1' \
-e S3_BUCKET='your_bucket_name' \
-e S3_ACCESS_KEY_ID='your_key' \
-e S3_SECRET_ACCESS_KEY='your_secret' \
ghcr.io/pischule/memevizor:latest
```
## License
GNU GPLv3

View File

@@ -1,9 +1,9 @@
plugins {
kotlin("jvm") version "2.2.0"
kotlin("plugin.spring") version "2.2.0"
id("org.springframework.boot") version "3.4.4"
kotlin("jvm") version "2.2.21"
kotlin("plugin.spring") version "2.2.21"
id("org.springframework.boot") version "4.0.0"
id("io.spring.dependency-management") version "1.1.7"
id("com.diffplug.spotless") version "7.0.3"
id("com.diffplug.spotless") version "8.1.0"
}
group = "com.pischule"
@@ -31,7 +31,7 @@ dependencies {
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("io.github.kotlin-telegram-bot.kotlin-telegram-bot:telegram:6.3.0")
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
implementation("io.minio:minio:8.5.17")
implementation(awssdk.services.s3)
developmentOnly("org.springframework.boot:spring-boot-devtools")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
testImplementation("org.springframework.boot:spring-boot-starter-test")
@@ -51,6 +51,6 @@ tasks.withType<Test> {
spotless {
kotlin {
ktfmt("0.54").kotlinlangStyle()
ktfmt("0.59").kotlinlangStyle()
}
}

3
docs/structurizr/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.structurizr
workspace.json
!out

View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
docker run -it --rm -p 8080:8080 -v "${PWD}:/usr/local/structurizr" structurizr/lite

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,50 @@
workspace "memevizor" "A meme sharing service" {
model {
// Actors
u = person "User" "Meme submitter."
a = person "Approver" "Moderates/Approves memes."
tv = person "Viewer (Any Web Browser)" "Opens the S3-hosted UI on a browser (e.g., on a TV screen) to view the current approved meme."
// External Systems
tg = softwareSystem "Telegram" "Messaging platform used for content submission and moderation."
// The Software System under development
ss = softwareSystem "Memevizor" "A Telegram-based service for submitting, approving, and displaying memes." {
// Core Application Container (Renamed and specified technology)
bot = container "Memevizor Bot Backend" "Kotlin/Spring Application." "Processes Telegram updates, handles media conversion, and manages S3 content."
// Storage Container (Clarified role)
s3 = container "Content S3 Bucket" "AWS S3." "Stores static UI assets (HTML/CSS/JS) and approved media content."
}
// Actor Relationships
u -> tg "Sends memes and content to"
a -> tg "Moderates/Approves content by replying to messages in"
// Backend Interactions
// 1. Core Telegram Communication (Polling for messages and sending replies)
bot -> tg "Polls for updates and sends reactions/replies/Downloads media" "HTTPS/Telegram Bot API"
// 3. S3 Interactions
bot -> s3 "Uploads UI assets/media" "HTTPS"
// Viewer/Client Interactions
tv -> s3 "Fetches UI and polls for media files" "HTTPS"
}
views {
systemContext ss "SystemContext" {
// Focus on the Memevizor system and its neighbors
include *
autolayout lr
//
}
container ss "ContainerDiagram" {
// Focus on the containers within Memevizor and their external dependencies
include *
autolayout tb
}
}
}

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@@ -1,14 +1,6 @@
[tools]
java = "temurin-21"
[tasks.release]
description = 'Build and push docker image'
run = [
'./gradlew build',
'docker build -t memevizor:latest .',
'docker pussh memevizor:latest apps0.pischule.com'
]
[tasks.fmt]
description = 'Fourmat source code'
run = './gradlew :spotlessApply'

View File

@@ -4,4 +4,10 @@ dependencyResolutionManagement {
repositories {
mavenCentral()
}
versionCatalogs {
create("awssdk") {
from("aws.sdk.kotlin:version-catalog:1.5.97")
}
}
}

View File

@@ -46,6 +46,7 @@ class BotConfiguration(
photos { handleMedia() }
video { handleMedia() }
videoNote { handleMedia() }
document { handleMedia() }
command("whoami") {
handleMessage(message) {
bot.sendMessage(

View File

@@ -20,7 +20,7 @@ class ThisCommandHandlerService(
private val fileUploaderService: FileUploaderService,
private val videoTranscoderService: VideoTranscoderService,
) {
private val confirmCommands = listOf("this", "!soxok")
private val confirmCommands = listOf("this", "true", "!soxok")
private val mediaFileName = "media"
fun create(env: MessageHandlerEnvironment) {

View File

@@ -1,9 +1,11 @@
package com.pischule.memevizor.upload
import aws.sdk.kotlin.services.s3.S3Client
import aws.sdk.kotlin.services.s3.model.PutObjectRequest
import aws.smithy.kotlin.runtime.content.ByteStream
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext
import io.minio.MinioClient
import io.minio.PutObjectArgs
import kotlinx.coroutines.runBlocking
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
import org.springframework.stereotype.Service
@@ -11,17 +13,18 @@ private val logger = KotlinLogging.logger {}
@ConditionalOnBean(S3Config::class)
@Service
class FileUploaderService(private val s3Client: MinioClient, private val s3Props: S3Props) {
class FileUploaderService(private val s3Client: S3Client, private val s3Props: S3Props) {
fun uploadFile(fileBytes: ByteArray, filename: String, contentType: String) {
withLoggingContext("filename" to filename, "bucket" to s3Props.bucket) {
s3Client.putObject(
PutObjectArgs.builder()
.bucket(s3Props.bucket)
.`object`(filename)
.stream(fileBytes.inputStream(), fileBytes.size.toLong(), -1)
.contentType(contentType)
.build()
)
val request = PutObjectRequest {
bucket = s3Props.bucket
key = filename
body = ByteStream.fromBytes(fileBytes)
this.contentType = contentType
}
logger.info { "Started uploading a file" }
runBlocking { s3Client.putObject(request) }
logger.info { "Uploaded a file to S3" }
}
}

View File

@@ -1,6 +1,8 @@
package com.pischule.memevizor.upload
import io.minio.MinioClient
import aws.sdk.kotlin.runtime.auth.credentials.StaticCredentialsProvider
import aws.sdk.kotlin.services.s3.S3Client
import aws.smithy.kotlin.runtime.net.url.Url
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@@ -8,11 +10,17 @@ import org.springframework.context.annotation.Configuration
@EnableConfigurationProperties(S3Props::class)
@Configuration
class S3Config {
@Bean
fun s3Client(s3Props: S3Props): MinioClient =
MinioClient.builder()
.endpoint(s3Props.endpoint)
.region(s3Props.region)
.credentials(s3Props.accessKeyId, s3Props.secretAccessKey)
.build()
fun s3Client(s3Props: S3Props) = S3Client {
endpointUrl = Url.parse(s3Props.endpoint)
region = s3Props.region
credentialsProvider = StaticCredentialsProvider {
accessKeyId = s3Props.accessKeyId
secretAccessKey = s3Props.secretAccessKey
}
// not supported by Yandex Object Storage
continueHeaderThresholdBytes = null
}
}

View File

@@ -7,10 +7,20 @@ fun Message.getMedia(): MessageMedia? {
return MessageMedia(fileId, MessageMedia.Type.PHOTO)
}
(video?.fileId ?: videoNote?.fileId)?.let { fileId ->
return MessageMedia(fileId, MessageMedia.Type.VIDEO)
video?.let {
return MessageMedia(it.fileId, MessageMedia.Type.VIDEO)
}
videoNote?.let {
return MessageMedia(it.fileId, MessageMedia.Type.VIDEO)
}
document
?.takeIf { it.mimeType?.startsWith("video/") == true }
?.let {
return MessageMedia(it.fileId, MessageMedia.Type.VIDEO)
}
return null
}

View File

@@ -1,6 +1,7 @@
package com.pischule.memevizor.video
import io.github.oshai.kotlinlogging.KotlinLogging
import java.nio.file.Path
import kotlin.io.path.createTempFile
import kotlin.io.path.deleteIfExists
import kotlin.io.path.readBytes
@@ -14,62 +15,32 @@ private val logger = KotlinLogging.logger {}
class VideoTranscoderService {
fun transcode(inputVideo: ByteArray): ByteArray {
logger.info { "Started transcoding a file" }
logger.info { "Started transcoding a video" }
val inputFile = createTempFile()
val outputFile = createTempFile()
val command: List<String> =
listOf(
"ffmpeg",
"-nostdin",
"-nostats",
"-hide_banner",
// input
"-i",
"$inputFile",
// video
"-map",
"0:v",
"-c:v",
"libsvtav1",
"-preset",
"6",
"-crf",
"35",
"-svtav1-params",
"film-grain=10",
// audio
"-map",
"0:a?",
"-c:a",
"libopus",
"-b:a",
"96k",
"-vbr",
"on",
"-compression_level",
"10",
// output
"-f",
"webm",
"-y",
"$outputFile",
)
try {
inputFile.writeBytes(inputVideo)
val process = ProcessBuilder(command).redirectErrorStream(true).start()
val exitCode: Int
val timeTaken = measureTime { exitCode = process.waitFor() }
val processOutput = process.inputStream.bufferedReader().use { it.readText() }
if (exitCode != 0) {
throw VideoTranscodingException(exitCode, processOutput)
val copyVideo = hasDesiredVideoEncoding(inputFile)
val copyAudio = hasDesiredAudioEncoding(inputFile)
val processOutput: String
val timeTaken = measureTime {
processOutput =
launchCommand(
transcodeCommand(
inputFile = inputFile,
outputFile = outputFile,
copyVideo = copyVideo,
copyAudio = copyAudio,
)
)
}
logger.atInfo {
message = "Finished transcoding a file"
message = "Finished transcoding a video"
payload =
mapOf(
"processOutput" to processOutput,
@@ -82,4 +53,106 @@ class VideoTranscoderService {
outputFile.deleteIfExists()
}
}
private fun launchCommand(command: List<String>): String {
val process = ProcessBuilder(command).redirectErrorStream(true).start()
val exitCode = process.waitFor()
val processOutput = process.inputStream.bufferedReader().use { it.readText() }
if (exitCode != 0) {
throw VideoTranscodingException(exitCode, processOutput)
}
return processOutput.trim()
}
private fun hasDesiredVideoEncoding(inputFile: Path): Boolean {
val videoCodec = launchCommand(getVideoCodecCommand(inputFile))
return videoCodec == "av1"
}
private fun hasDesiredAudioEncoding(inputFile: Path): Boolean {
val audioCodec = launchCommand(getAudioCodecCommand(inputFile))
return audioCodec == "opus" || audioCodec == ""
}
private fun getVideoCodecCommand(inputFile: Path) =
listOf(
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_name",
"-of",
"default=noprint_wrappers=1:nokey=1",
"$inputFile",
)
private fun getAudioCodecCommand(inputFile: Path) =
listOf(
"ffprobe",
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"stream=codec_name",
"-of",
"default=noprint_wrappers=1:nokey=1",
"$inputFile",
)
private fun transcodeCommand(
inputFile: Path,
outputFile: Path,
copyVideo: Boolean,
copyAudio: Boolean,
): List<String> = buildList {
add("ffmpeg")
add("-nostdin")
add("-nostats")
add("-hide_banner")
// input
add("-i")
add("$inputFile")
// video
add("-map")
add("0:v")
add("-c:v")
if (copyVideo) {
add("copy")
} else {
add("libsvtav1")
add("-preset")
add("6")
add("-crf")
add("35")
add("-svtav1-params")
add("film-grain=10")
}
// audio
add("-map")
add("0:a?")
add("-c:a")
if (copyAudio) {
add("copy")
} else {
add("libopus")
add("-b:a")
add("96k")
add("-vbr")
add("on")
add("-compression_level")
add("10")
}
// output
add("-f")
add("webm")
add("-y")
add("$outputFile")
}
}

View File

@@ -1,2 +1,2 @@
spring.application.name=memevizor
logging.structured.format.console=ecs

View File

@@ -34,6 +34,7 @@
}
#qr-container {
font-size: 0;
position: fixed;
bottom: var(--qr-margin);
right: var(--qr-margin);
@@ -142,6 +143,12 @@
}
}
function toggleQrVisibility() {
const qr = document.getElementById("qr-code");
const isVisible = qr.style.display === '';
qr.style.display = isVisible ? 'none' : '';
}
// --- Event Listeners ---
document.addEventListener('DOMContentLoaded', refreshMedia); // Initial fetch
setInterval(refreshMedia, refreshIntervalMs); // Periodic refresh
@@ -152,6 +159,10 @@
event.preventDefault(); // Prevent page scroll
refreshMedia();
}
if (event.key === 'q') {
toggleQrVisibility()
}
});
</script>