Implement videos

This commit is contained in:
2025-07-23 01:51:27 +03:00
parent 33888c5008
commit 7ca7ffbc57
12 changed files with 100 additions and 121 deletions

View File

@@ -32,7 +32,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(awssdk.services.s3)
implementation("io.minio:minio:8.5.17")
implementation("org.springframework.boot:spring-boot-starter-web")
developmentOnly("org.springframework.boot:spring-boot-devtools")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")

View File

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

View File

@@ -4,14 +4,17 @@ import com.github.kotlintelegrambot.Bot
import com.github.kotlintelegrambot.bot
import com.github.kotlintelegrambot.dispatch
import com.github.kotlintelegrambot.dispatcher.command
import com.github.kotlintelegrambot.dispatcher.handlers.media.MediaHandlerEnvironment
import com.github.kotlintelegrambot.dispatcher.message
import com.github.kotlintelegrambot.dispatcher.photos
import com.github.kotlintelegrambot.dispatcher.video
import com.github.kotlintelegrambot.entities.ChatId
import com.github.kotlintelegrambot.entities.Message
import com.github.kotlintelegrambot.entities.ParseMode
import com.pischule.memevizor.bot.handler.PhotoHandlerService
import com.pischule.memevizor.bot.handler.MediaHandlerService
import com.pischule.memevizor.bot.handler.ThisCommandHandlerService
import com.pischule.memevizor.util.getMaxResPhotoId
import com.pischule.memevizor.util.getVideoFileId
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext
import org.springframework.boot.context.properties.EnableConfigurationProperties
@@ -25,7 +28,7 @@ private val logger = KotlinLogging.logger {}
class BotConfiguration(
private val botProps: BotProps,
private val thisCommandHandlerService: ThisCommandHandlerService,
private val photoHandlerService: PhotoHandlerService,
private val mediaHandlerService: MediaHandlerService,
) {
@Bean
@@ -46,15 +49,8 @@ class BotConfiguration(
}
}
}
photos {
withLoggingContext(messageContext(message)) {
try {
photoHandlerService.create(this)
} catch (e: Error) {
logger.error(e) { "Error while handling photo" }
}
}
}
photos { handleMedia() }
video { handleMedia() }
command("whoami") {
withLoggingContext(messageContext(message)) {
bot.sendMessage(
@@ -67,12 +63,22 @@ class BotConfiguration(
}
}
private suspend fun MediaHandlerEnvironment<*>.handleMedia() {
withLoggingContext(messageContext(message)) {
try {
mediaHandlerService.create(this)
} catch (e: Error) {
logger.error(e) { "Error while handling photo" }
}
}
}
private fun messageContext(message: Message): Map<String, String?> =
mapOf(
"message_id" to message.messageId.toString(),
"chat_id" to message.chat.id.toString(),
"from_user_id" to message.from?.id.toString(),
"from_user_username" to message.from?.username.toString(),
"file_id" to message.getMaxResPhotoId(),
"file_id" to (message.getMaxResPhotoId() ?: message.getVideoFileId()),
)
}

View File

@@ -2,7 +2,6 @@ package com.pischule.memevizor.bot.handler
import com.github.kotlintelegrambot.dispatcher.handlers.media.MediaHandlerEnvironment
import com.github.kotlintelegrambot.entities.ChatId
import com.github.kotlintelegrambot.entities.files.PhotoSize
import com.github.kotlintelegrambot.entities.reaction.ReactionType
import com.pischule.memevizor.bot.BotProps
import io.github.oshai.kotlinlogging.KotlinLogging
@@ -11,21 +10,21 @@ import org.springframework.stereotype.Service
private val logger = KotlinLogging.logger {}
@Service
class PhotoHandlerService(private val botProps: BotProps) {
class MediaHandlerService(private val botProps: BotProps) {
suspend fun create(env: MediaHandlerEnvironment<List<PhotoSize>>) {
suspend fun create(env: MediaHandlerEnvironment<*>) {
if (shouldForwardMessage(env)) {
forwardPhotoMessage(env)
forwardMessage(env)
}
reactToMessage(env, "👀")
}
private fun shouldForwardMessage(env: MediaHandlerEnvironment<List<PhotoSize>>): Boolean {
private fun shouldForwardMessage(env: MediaHandlerEnvironment<*>): Boolean {
return env.message.chat.id != botProps.forwardChatId
}
private suspend fun forwardPhotoMessage(env: MediaHandlerEnvironment<List<PhotoSize>>) {
private suspend fun forwardMessage(env: MediaHandlerEnvironment<*>) {
env.bot
.forwardMessage(
chatId = ChatId.fromId(botProps.forwardChatId),
@@ -38,10 +37,7 @@ class PhotoHandlerService(private val botProps: BotProps) {
)
}
private suspend fun reactToMessage(
env: MediaHandlerEnvironment<List<PhotoSize>>,
emoji: String,
) {
private suspend fun reactToMessage(env: MediaHandlerEnvironment<*>, emoji: String) {
env.bot
.setMessageReaction(
chatId = ChatId.fromId(env.message.chat.id),

View File

@@ -2,10 +2,12 @@ package com.pischule.memevizor.bot.handler
import com.github.kotlintelegrambot.dispatcher.handlers.MessageHandlerEnvironment
import com.github.kotlintelegrambot.entities.ChatId
import com.github.kotlintelegrambot.entities.Message
import com.github.kotlintelegrambot.entities.reaction.ReactionType
import com.pischule.memevizor.bot.BotProps
import com.pischule.memevizor.upload.FileUploaderService
import com.pischule.memevizor.util.getMaxResPhotoId
import com.pischule.memevizor.util.getVideoFileId
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext
import org.springframework.stereotype.Component
@@ -22,24 +24,34 @@ class ThisCommandHandlerService(
suspend fun create(env: MessageHandlerEnvironment) {
if (!shouldHandleMessage(env)) return
val maxResPhotoId = env.message.replyToMessage?.getMaxResPhotoId() ?: return
val replyToMessage = env.message.replyToMessage ?: return
withLoggingContext("file_id" to maxResPhotoId) {
val fileBytes = env.bot.downloadFileBytes(maxResPhotoId) ?: return
logger.info { "Downloaded a file from Telegram" }
val (fileId, contentType) = getFileInfo(replyToMessage) ?: return
fileUploaderService.uploadFile(fileBytes, "_.jpeg", "image/jpeg")
withLoggingContext("file_id" to fileId) {
val fileBytes = env.bot.downloadFileBytes(fileId) ?: return
logger.info { "Downloaded a file from Telegram, size=${fileBytes.size}" }
fileUploaderService.uploadFile(fileBytes, "_", contentType)
reactToMessage(env, "👍")
}
}
private fun getFileInfo(message: Message): Pair<String, String>? {
return message.getMaxResPhotoId()?.let { it to "image/jpeg" }
?: message.getVideoFileId()?.let { it to "video/mp4" }
}
private fun shouldHandleMessage(env: MessageHandlerEnvironment): Boolean {
val isApprover = env.message.from?.id?.let { botProps.approverUserIds.contains(it) } == true
val command = env.message.text?.lowercase()
val isConfirmCommand = command in confirmCommands
val hasPhotoReply = env.message.replyToMessage?.photo?.isNotEmpty() == true
return isApprover && isConfirmCommand && hasPhotoReply
val hasMediaReply =
env.message.replyToMessage?.let {
it.photo?.isNotEmpty() == true || it.video != null
} == true
return isApprover && isConfirmCommand && hasMediaReply
}
private suspend fun reactToMessage(env: MessageHandlerEnvironment, emoji: String) {

View File

@@ -10,7 +10,7 @@ private val logger = KotlinLogging.logger {}
@Service
class DummyFileUploadService() : FileUploaderService {
override suspend fun uploadFile(fileBytes: ByteArray, filename: String, contentType: String) {
override fun uploadFile(fileBytes: ByteArray, filename: String, contentType: String) {
logger.info { "File $filename has been successfully uploaded to nowhere" }
}
}

View File

@@ -4,5 +4,5 @@ import org.springframework.stereotype.Service
@Service
interface FileUploaderService {
suspend fun uploadFile(fileBytes: ByteArray, filename: String, contentType: String)
fun uploadFile(fileBytes: ByteArray, filename: String, contentType: String)
}

View File

@@ -1,7 +1,6 @@
package com.pischule.memevizor.upload
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.runBlocking
import org.springframework.boot.context.event.ApplicationStartedEvent
import org.springframework.context.annotation.Configuration
import org.springframework.context.event.EventListener
@@ -17,7 +16,7 @@ class IndexInitializer(val fileUploaderService: FileUploaderService) {
fun applicationStartedHandler(event: ApplicationStartedEvent) {
try {
val fileBytes = readResourceAsByteArray("static/index.html")
runBlocking { fileUploaderService.uploadFile(fileBytes, "index.html", "text/html") }
fileUploaderService.uploadFile(fileBytes, "index.html", "text/html")
} catch (e: Error) {
logger.warn(e) { "Failed to upload " }
}

View File

@@ -1,8 +1,6 @@
package com.pischule.memevizor.upload
import aws.sdk.kotlin.runtime.auth.credentials.StaticCredentialsProvider
import aws.sdk.kotlin.services.s3.S3Client
import aws.smithy.kotlin.runtime.net.url.Url
import io.minio.MinioClient
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@@ -13,14 +11,10 @@ import org.springframework.context.annotation.Profile
@Configuration
class S3Config {
@Bean
fun s3Client(s3Props: S3Props): S3Client {
return S3Client {
endpointUrl = Url.parse("https://storage.yandexcloud.net")
region = "ru-central1"
credentialsProvider = StaticCredentialsProvider {
accessKeyId = s3Props.accessKeyId
secretAccessKey = s3Props.secretAccessKey
}
}
}
fun s3Client(s3Props: S3Props): MinioClient =
MinioClient.builder()
.endpoint("https://storage.yandexcloud.net")
.region("ru-central1")
.credentials(s3Props.accessKeyId, s3Props.secretAccessKey)
.build()
}

View File

@@ -1,9 +1,8 @@
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.minio.MinioClient
import io.minio.PutObjectArgs
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
import org.springframework.stereotype.Service
@@ -11,16 +10,17 @@ private val logger = KotlinLogging.logger {}
@ConditionalOnBean(S3Config::class)
@Service
class S3FileUploaderService(private val s3Client: S3Client, private val s3Props: S3Props) :
class S3FileUploaderService(private val s3Client: MinioClient, private val s3Props: S3Props) :
FileUploaderService {
override suspend fun uploadFile(fileBytes: ByteArray, filename: String, contentType: String) {
override fun uploadFile(fileBytes: ByteArray, filename: String, contentType: String) {
logger.info { "Before upload" }
s3Client.putObject(
PutObjectRequest {
body = ByteStream.fromBytes(fileBytes)
bucket = s3Props.bucket
key = filename
this.contentType = contentType
}
PutObjectArgs.builder()
.bucket(s3Props.bucket)
.`object`(filename)
.stream(fileBytes.inputStream(), fileBytes.size.toLong(), -1)
.contentType(contentType)
.build()
)
logger.info { "File $filename has been uploaded to S3" }
}

View File

@@ -3,3 +3,5 @@ package com.pischule.memevizor.util
import com.github.kotlintelegrambot.entities.Message
fun Message.getMaxResPhotoId(): String? = this.photo?.lastOrNull()?.fileId
fun Message.getVideoFileId(): String? = this.video?.fileId

View File

@@ -27,22 +27,6 @@
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: var(--bg-image);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
filter: blur(20px);
transform: scale(1.1);
z-index: -1;
}
img {
width: 100%;
height: 100%;
@@ -51,57 +35,52 @@
z-index: 1;
}
.qr-code {
position: fixed;
z-index: 2;
bottom: 20px;
right: 20px;
video {
width: 100%;
height: 100%;
object-fit: contain;
position: relative;
z-index: 1;
}
</style>
</head>
<body>
<img id="image" alt="Смешная картинка" src="">
<img id="qr-code" class="qr-code" alt="QR Code" src="qr.svg" onerror="this.style.display='none'">
</body>
<script defer>
const imageUrl = '_.jpeg';
const fileUrl = '_';
const refreshIntervalMs = 30_000; // Time in milliseconds (e.g., 10000 = 10 seconds)
function configureQrCode() {
// Parse query parameters for QR code size
const urlParams = new URLSearchParams(window.location.search);
const qrSize = parseInt(urlParams.get('qrSize')) || 100; // Default to 100px if not specified
const qrOffset = parseInt(urlParams.get('qrOffset')) || 20; // Default to 20px if not specified
const qrCodeElement = document.getElementById('qr-code');
qrCodeElement.style.width = `${qrSize}px`;
qrCodeElement.style.height = `${qrSize}px`;
qrCodeElement.style.bottom = `${qrOffset}px`;
qrCodeElement.style.right = `${qrOffset}px`;
}
// Configure QR code on page load
configureQrCode();
let lastModified = null;
const imageElement = document.getElementById('image');
let imageBlobUrl = null;
function updateBackgroundImage(imageUrl) {
document.documentElement.style.setProperty(
'--bg-image',
`linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)), url('${imageUrl}')`
);
const mediaContainer = document.querySelector("body")
async function refreshMediaDom(response) {
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const contentType = response.headers.get("content-type");
if (contentType === "video/mp4") {
const video = document.createElement("video");
video.src = blobUrl;
video.controls = true;
video.muted = true;
video.loop = true;
video.autoplay = true;
mediaContainer.replaceChildren(video);
} else {
const img = document.createElement("img");
img.src = blobUrl;
mediaContainer.replaceChildren(img);
}
}
async function refreshImage() {
async function refreshMedia() {
try {
const headers = new Headers();
if (lastModified != null) {
headers.append('If-Modified-Since', lastModified);
}
const response = await fetch(imageUrl, {method: 'GET', headers, cache: 'no-store'});
const response = await fetch(fileUrl, {method: 'GET', headers, cache: 'no-store'});
const now = new Date().toLocaleTimeString();
if (response.status === 304) {
@@ -113,10 +92,7 @@
const newLastModified = response.headers.get('last-modified');
if (newLastModified) {
lastModified = newLastModified;
const imageBlob = await response.blob();
imageBlobUrl = URL.createObjectURL(imageBlob);
imageElement.src = imageBlobUrl;
updateBackgroundImage(imageBlobUrl)
await refreshMediaDom(response)
} else {
console.warn(`No Last-Modified header found. Cannot perform conditional checks`);
}
@@ -129,13 +105,13 @@
}
}
refreshImage();
setInterval(refreshImage, refreshIntervalMs);
document.addEventListener('click', refreshImage);
refreshMedia();
setInterval(refreshMedia, refreshIntervalMs);
document.addEventListener('click', refreshMedia);
document.addEventListener('keydown', function (event) {
if (event.code === 'Space') {
event.preventDefault();
refreshImage();
refreshMedia();
}
});
</script>