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("org.jetbrains.kotlin:kotlin-reflect")
implementation("io.github.kotlin-telegram-bot.kotlin-telegram-bot:telegram:6.3.0") 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.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") implementation("org.springframework.boot:spring-boot-starter-web")
developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-devtools")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")

View File

@@ -4,10 +4,4 @@ dependencyResolutionManagement {
repositories { repositories {
mavenCentral() 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.bot
import com.github.kotlintelegrambot.dispatch import com.github.kotlintelegrambot.dispatch
import com.github.kotlintelegrambot.dispatcher.command 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.message
import com.github.kotlintelegrambot.dispatcher.photos import com.github.kotlintelegrambot.dispatcher.photos
import com.github.kotlintelegrambot.dispatcher.video
import com.github.kotlintelegrambot.entities.ChatId import com.github.kotlintelegrambot.entities.ChatId
import com.github.kotlintelegrambot.entities.Message import com.github.kotlintelegrambot.entities.Message
import com.github.kotlintelegrambot.entities.ParseMode 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.bot.handler.ThisCommandHandlerService
import com.pischule.memevizor.util.getMaxResPhotoId import com.pischule.memevizor.util.getMaxResPhotoId
import com.pischule.memevizor.util.getVideoFileId
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext import io.github.oshai.kotlinlogging.withLoggingContext
import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.context.properties.EnableConfigurationProperties
@@ -25,7 +28,7 @@ private val logger = KotlinLogging.logger {}
class BotConfiguration( class BotConfiguration(
private val botProps: BotProps, private val botProps: BotProps,
private val thisCommandHandlerService: ThisCommandHandlerService, private val thisCommandHandlerService: ThisCommandHandlerService,
private val photoHandlerService: PhotoHandlerService, private val mediaHandlerService: MediaHandlerService,
) { ) {
@Bean @Bean
@@ -46,15 +49,8 @@ class BotConfiguration(
} }
} }
} }
photos { photos { handleMedia() }
withLoggingContext(messageContext(message)) { video { handleMedia() }
try {
photoHandlerService.create(this)
} catch (e: Error) {
logger.error(e) { "Error while handling photo" }
}
}
}
command("whoami") { command("whoami") {
withLoggingContext(messageContext(message)) { withLoggingContext(messageContext(message)) {
bot.sendMessage( 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?> = private fun messageContext(message: Message): Map<String, String?> =
mapOf( mapOf(
"message_id" to message.messageId.toString(), "message_id" to message.messageId.toString(),
"chat_id" to message.chat.id.toString(), "chat_id" to message.chat.id.toString(),
"from_user_id" to message.from?.id.toString(), "from_user_id" to message.from?.id.toString(),
"from_user_username" to message.from?.username.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.dispatcher.handlers.media.MediaHandlerEnvironment
import com.github.kotlintelegrambot.entities.ChatId import com.github.kotlintelegrambot.entities.ChatId
import com.github.kotlintelegrambot.entities.files.PhotoSize
import com.github.kotlintelegrambot.entities.reaction.ReactionType import com.github.kotlintelegrambot.entities.reaction.ReactionType
import com.pischule.memevizor.bot.BotProps import com.pischule.memevizor.bot.BotProps
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
@@ -11,21 +10,21 @@ import org.springframework.stereotype.Service
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@Service @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)) { if (shouldForwardMessage(env)) {
forwardPhotoMessage(env) forwardMessage(env)
} }
reactToMessage(env, "👀") reactToMessage(env, "👀")
} }
private fun shouldForwardMessage(env: MediaHandlerEnvironment<List<PhotoSize>>): Boolean { private fun shouldForwardMessage(env: MediaHandlerEnvironment<*>): Boolean {
return env.message.chat.id != botProps.forwardChatId return env.message.chat.id != botProps.forwardChatId
} }
private suspend fun forwardPhotoMessage(env: MediaHandlerEnvironment<List<PhotoSize>>) { private suspend fun forwardMessage(env: MediaHandlerEnvironment<*>) {
env.bot env.bot
.forwardMessage( .forwardMessage(
chatId = ChatId.fromId(botProps.forwardChatId), chatId = ChatId.fromId(botProps.forwardChatId),
@@ -38,10 +37,7 @@ class PhotoHandlerService(private val botProps: BotProps) {
) )
} }
private suspend fun reactToMessage( private suspend fun reactToMessage(env: MediaHandlerEnvironment<*>, emoji: String) {
env: MediaHandlerEnvironment<List<PhotoSize>>,
emoji: String,
) {
env.bot env.bot
.setMessageReaction( .setMessageReaction(
chatId = ChatId.fromId(env.message.chat.id), 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.dispatcher.handlers.MessageHandlerEnvironment
import com.github.kotlintelegrambot.entities.ChatId import com.github.kotlintelegrambot.entities.ChatId
import com.github.kotlintelegrambot.entities.Message
import com.github.kotlintelegrambot.entities.reaction.ReactionType import com.github.kotlintelegrambot.entities.reaction.ReactionType
import com.pischule.memevizor.bot.BotProps import com.pischule.memevizor.bot.BotProps
import com.pischule.memevizor.upload.FileUploaderService import com.pischule.memevizor.upload.FileUploaderService
import com.pischule.memevizor.util.getMaxResPhotoId import com.pischule.memevizor.util.getMaxResPhotoId
import com.pischule.memevizor.util.getVideoFileId
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext import io.github.oshai.kotlinlogging.withLoggingContext
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
@@ -22,24 +24,34 @@ class ThisCommandHandlerService(
suspend fun create(env: MessageHandlerEnvironment) { suspend fun create(env: MessageHandlerEnvironment) {
if (!shouldHandleMessage(env)) return if (!shouldHandleMessage(env)) return
val maxResPhotoId = env.message.replyToMessage?.getMaxResPhotoId() ?: return val replyToMessage = env.message.replyToMessage ?: return
withLoggingContext("file_id" to maxResPhotoId) { val (fileId, contentType) = getFileInfo(replyToMessage) ?: return
val fileBytes = env.bot.downloadFileBytes(maxResPhotoId) ?: return
logger.info { "Downloaded a file from Telegram" }
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, "👍") 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 { private fun shouldHandleMessage(env: MessageHandlerEnvironment): Boolean {
val isApprover = env.message.from?.id?.let { botProps.approverUserIds.contains(it) } == true val isApprover = env.message.from?.id?.let { botProps.approverUserIds.contains(it) } == true
val command = env.message.text?.lowercase() val command = env.message.text?.lowercase()
val isConfirmCommand = command in confirmCommands val isConfirmCommand = command in confirmCommands
val hasPhotoReply = env.message.replyToMessage?.photo?.isNotEmpty() == true val hasMediaReply =
return isApprover && isConfirmCommand && hasPhotoReply env.message.replyToMessage?.let {
it.photo?.isNotEmpty() == true || it.video != null
} == true
return isApprover && isConfirmCommand && hasMediaReply
} }
private suspend fun reactToMessage(env: MessageHandlerEnvironment, emoji: String) { private suspend fun reactToMessage(env: MessageHandlerEnvironment, emoji: String) {

View File

@@ -10,7 +10,7 @@ private val logger = KotlinLogging.logger {}
@Service @Service
class DummyFileUploadService() : FileUploaderService { 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" } logger.info { "File $filename has been successfully uploaded to nowhere" }
} }
} }

View File

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

View File

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

View File

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

View File

@@ -27,22 +27,6 @@
position: relative; 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 { img {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -51,57 +35,52 @@
z-index: 1; z-index: 1;
} }
.qr-code { video {
position: fixed; width: 100%;
z-index: 2; height: 100%;
bottom: 20px; object-fit: contain;
right: 20px; position: relative;
z-index: 1;
} }
</style> </style>
</head> </head>
<body> <body>
<img id="image" alt="Смешная картинка" src=""> <img id="image" alt="Смешная картинка" src="">
<img id="qr-code" class="qr-code" alt="QR Code" src="qr.svg" onerror="this.style.display='none'">
</body> </body>
<script defer> <script defer>
const imageUrl = '_.jpeg'; const fileUrl = '_';
const refreshIntervalMs = 30_000; // Time in milliseconds (e.g., 10000 = 10 seconds) 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; let lastModified = null;
const imageElement = document.getElementById('image');
let imageBlobUrl = null;
function updateBackgroundImage(imageUrl) { const mediaContainer = document.querySelector("body")
document.documentElement.style.setProperty(
'--bg-image', async function refreshMediaDom(response) {
`linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)), url('${imageUrl}')` 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 { try {
const headers = new Headers(); const headers = new Headers();
if (lastModified != null) { if (lastModified != null) {
headers.append('If-Modified-Since', lastModified); 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(); const now = new Date().toLocaleTimeString();
if (response.status === 304) { if (response.status === 304) {
@@ -113,10 +92,7 @@
const newLastModified = response.headers.get('last-modified'); const newLastModified = response.headers.get('last-modified');
if (newLastModified) { if (newLastModified) {
lastModified = newLastModified; lastModified = newLastModified;
const imageBlob = await response.blob(); await refreshMediaDom(response)
imageBlobUrl = URL.createObjectURL(imageBlob);
imageElement.src = imageBlobUrl;
updateBackgroundImage(imageBlobUrl)
} else { } else {
console.warn(`No Last-Modified header found. Cannot perform conditional checks`); console.warn(`No Last-Modified header found. Cannot perform conditional checks`);
} }
@@ -129,13 +105,13 @@
} }
} }
refreshImage(); refreshMedia();
setInterval(refreshImage, refreshIntervalMs); setInterval(refreshMedia, refreshIntervalMs);
document.addEventListener('click', refreshImage); document.addEventListener('click', refreshMedia);
document.addEventListener('keydown', function (event) { document.addEventListener('keydown', function (event) {
if (event.code === 'Space') { if (event.code === 'Space') {
event.preventDefault(); event.preventDefault();
refreshImage(); refreshMedia();
} }
}); });
</script> </script>