бэк несколько медиа
This commit is contained in:
136
migrations/models/9_20260122191425_update.py
Normal file
136
migrations/models/9_20260122191425_update.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
from tortoise import BaseDBAsyncClient
|
||||||
|
|
||||||
|
RUN_IN_TRANSACTION = True
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
CREATE TABLE IF NOT EXISTS "creative_media" (
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deleted_at" TIMESTAMPTZ,
|
||||||
|
"media_type" VARCHAR(32) NOT NULL,
|
||||||
|
"media_file_id" VARCHAR(512) NOT NULL,
|
||||||
|
"media_s3_key" VARCHAR(512),
|
||||||
|
"position" INT NOT NULL,
|
||||||
|
"creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_creative_me_creativ_dbd1c4" ON "creative_media" ("creative_id");
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "uid_creative_media_creative_pos" ON "creative_media" ("creative_id", "position");
|
||||||
|
INSERT INTO "creative_media" (
|
||||||
|
"id", "created_at", "updated_at", "deleted_at",
|
||||||
|
"media_type", "media_file_id", "media_s3_key", "position", "creative_id"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"deleted_at",
|
||||||
|
"media_type",
|
||||||
|
"media_file_id",
|
||||||
|
"media_s3_key",
|
||||||
|
0,
|
||||||
|
"id"
|
||||||
|
FROM "creative"
|
||||||
|
WHERE "media_file_id" IS NOT NULL AND "media_type" IS NOT NULL;
|
||||||
|
ALTER TABLE "creative" DROP COLUMN "media_type";
|
||||||
|
ALTER TABLE "creative" DROP COLUMN "media_s3_key";
|
||||||
|
ALTER TABLE "creative" DROP COLUMN "media_file_id";"""
|
||||||
|
|
||||||
|
|
||||||
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
ALTER TABLE "creative" ADD "media_type" VARCHAR(32);
|
||||||
|
ALTER TABLE "creative" ADD "media_s3_key" VARCHAR(512);
|
||||||
|
ALTER TABLE "creative" ADD "media_file_id" VARCHAR(512);
|
||||||
|
UPDATE "creative" AS c
|
||||||
|
SET
|
||||||
|
"media_type" = cm."media_type",
|
||||||
|
"media_s3_key" = cm."media_s3_key",
|
||||||
|
"media_file_id" = cm."media_file_id"
|
||||||
|
FROM "creative_media" AS cm
|
||||||
|
WHERE cm."creative_id" = c."id" AND cm."position" = 0;
|
||||||
|
DROP TABLE IF EXISTS "creative_media";"""
|
||||||
|
|
||||||
|
|
||||||
|
MODELS_STATE = (
|
||||||
|
"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"
|
||||||
|
"3zVIVkcJ3uUsL8p5f3fwEyCCxF"
|
||||||
|
)
|
||||||
@@ -348,7 +348,7 @@ class Postgres(DatabaseBase):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_creative(workspace_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None:
|
async def get_creative(workspace_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None:
|
||||||
return await domain.Creative.get_or_none(id=creative_id, project__workspace_id=workspace_id).prefetch_related(
|
return await domain.Creative.get_or_none(id=creative_id, project__workspace_id=workspace_id).prefetch_related(
|
||||||
'project', 'project__channel'
|
'project', 'project__channel', 'media_items'
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -374,7 +374,7 @@ class Postgres(DatabaseBase):
|
|||||||
if not include_archived:
|
if not include_archived:
|
||||||
query = query.filter(status=domain.CreativeStatus.ACTIVE)
|
query = query.filter(status=domain.CreativeStatus.ACTIVE)
|
||||||
|
|
||||||
return await query.prefetch_related('project', 'project__channel').order_by('-created_at').all()
|
return await query.prefetch_related('project', 'project__channel', 'media_items').order_by('-created_at').all()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def delete_creative(creative_id: uuid.UUID) -> None:
|
async def delete_creative(creative_id: uuid.UUID) -> None:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, InputMediaPhoto, InputMediaVideo
|
||||||
|
|
||||||
from shared.telegram_base import TelegramBase
|
from shared.telegram_base import TelegramBase
|
||||||
from src.usecase import TelegramBotWriter
|
from src.usecase import TelegramBotWriter
|
||||||
@@ -81,6 +82,32 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
|
|||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def send_media_group(
|
||||||
|
self,
|
||||||
|
chat_id: int,
|
||||||
|
media_items: list[Any],
|
||||||
|
caption: str | None = None,
|
||||||
|
parse_mode: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not media_items:
|
||||||
|
return
|
||||||
|
|
||||||
|
group = []
|
||||||
|
for index, item in enumerate(media_items):
|
||||||
|
item_caption = caption if index == 0 else None
|
||||||
|
if item.media_type == 'photo':
|
||||||
|
group.append(
|
||||||
|
InputMediaPhoto(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode)
|
||||||
|
)
|
||||||
|
elif item.media_type == 'video':
|
||||||
|
group.append(
|
||||||
|
InputMediaVideo(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported media type for group: {item.media_type}')
|
||||||
|
|
||||||
|
await self.bot.send_media_group(chat_id=chat_id, media=group)
|
||||||
|
|
||||||
async def edit_message_text(self, text: str, chat_id: int, message_id: int) -> None:
|
async def edit_message_text(self, text: str, chat_id: int, message_id: int) -> None:
|
||||||
await self.bot.edit_message_text(chat_id=chat_id, message_id=message_id, text=text, reply_markup=None)
|
await self.bot.edit_message_text(chat_id=chat_id, message_id=message_id, text=text, reply_markup=None)
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,12 @@ __all__ = (
|
|||||||
'PlacementType',
|
'PlacementType',
|
||||||
'PlacementPost',
|
'PlacementPost',
|
||||||
'Creative',
|
'Creative',
|
||||||
|
'CreativeMedia',
|
||||||
'replace_invite_link_with_tag',
|
'replace_invite_link_with_tag',
|
||||||
'validate_media_size',
|
'validate_media_size',
|
||||||
|
'validate_media_items',
|
||||||
'MAX_CREATIVE_MEDIA_BYTES',
|
'MAX_CREATIVE_MEDIA_BYTES',
|
||||||
|
'MAX_CREATIVE_MEDIA_ITEMS',
|
||||||
'validate_workspace_avatar_size',
|
'validate_workspace_avatar_size',
|
||||||
'MAX_WORKSPACE_AVATAR_BYTES',
|
'MAX_WORKSPACE_AVATAR_BYTES',
|
||||||
'Post',
|
'Post',
|
||||||
@@ -61,15 +64,20 @@ __all__ = (
|
|||||||
'CreativeInviteLinkNotFound',
|
'CreativeInviteLinkNotFound',
|
||||||
'CreativeMultipleInviteLinks',
|
'CreativeMultipleInviteLinks',
|
||||||
'CreativeMediaTooLarge',
|
'CreativeMediaTooLarge',
|
||||||
|
'CreativeMediaTooMany',
|
||||||
|
'CreativeMediaGroupUnsupported',
|
||||||
'UserByUsernameNotFound',
|
'UserByUsernameNotFound',
|
||||||
)
|
)
|
||||||
|
|
||||||
from .channel import Channel
|
from .channel import Channel
|
||||||
from .creative import (
|
from .creative import (
|
||||||
MAX_CREATIVE_MEDIA_BYTES,
|
MAX_CREATIVE_MEDIA_BYTES,
|
||||||
|
MAX_CREATIVE_MEDIA_ITEMS,
|
||||||
Creative,
|
Creative,
|
||||||
|
CreativeMedia,
|
||||||
CreativeStatus,
|
CreativeStatus,
|
||||||
replace_invite_link_with_tag,
|
replace_invite_link_with_tag,
|
||||||
|
validate_media_items,
|
||||||
validate_media_size,
|
validate_media_size,
|
||||||
)
|
)
|
||||||
from .error import (
|
from .error import (
|
||||||
@@ -78,7 +86,9 @@ from .error import (
|
|||||||
ChannelNotFound,
|
ChannelNotFound,
|
||||||
CreativeInUse,
|
CreativeInUse,
|
||||||
CreativeInviteLinkNotFound,
|
CreativeInviteLinkNotFound,
|
||||||
|
CreativeMediaGroupUnsupported,
|
||||||
CreativeMediaTooLarge,
|
CreativeMediaTooLarge,
|
||||||
|
CreativeMediaTooMany,
|
||||||
CreativeMultipleInviteLinks,
|
CreativeMultipleInviteLinks,
|
||||||
CreativeNotFound,
|
CreativeNotFound,
|
||||||
LoginTokenAlreadyUsed,
|
LoginTokenAlreadyUsed,
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ from uuid import UUID
|
|||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
|
|
||||||
from .base import TimestampedModel
|
from .base import TimestampedModel
|
||||||
from .error import CreativeInviteLinkNotFound, CreativeMediaTooLarge, CreativeMultipleInviteLinks
|
from .error import (
|
||||||
|
CreativeInviteLinkNotFound,
|
||||||
|
CreativeMediaGroupUnsupported,
|
||||||
|
CreativeMediaTooLarge,
|
||||||
|
CreativeMediaTooMany,
|
||||||
|
CreativeMultipleInviteLinks,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .project import Project
|
from .project import Project
|
||||||
@@ -20,9 +26,6 @@ class CreativeStatus(str, enum.Enum):
|
|||||||
class Creative(TimestampedModel):
|
class Creative(TimestampedModel):
|
||||||
name = fields.CharField(max_length=255)
|
name = fields.CharField(max_length=255)
|
||||||
text = fields.TextField()
|
text = fields.TextField()
|
||||||
media_type = fields.CharField(max_length=32, null=True)
|
|
||||||
media_file_id = fields.CharField(max_length=512, null=True)
|
|
||||||
media_s3_key = fields.CharField(max_length=512, null=True)
|
|
||||||
buttons = fields.JSONField(default=list)
|
buttons = fields.JSONField(default=list)
|
||||||
status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE)
|
status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE)
|
||||||
|
|
||||||
@@ -37,7 +40,26 @@ class Creative(TimestampedModel):
|
|||||||
table = 'creative'
|
table = 'creative'
|
||||||
|
|
||||||
|
|
||||||
|
class CreativeMedia(TimestampedModel):
|
||||||
|
media_type = fields.CharField(max_length=32)
|
||||||
|
media_file_id = fields.CharField(max_length=512)
|
||||||
|
media_s3_key = fields.CharField(max_length=512, null=True)
|
||||||
|
position = fields.IntField()
|
||||||
|
|
||||||
|
creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
|
||||||
|
'models.Creative', related_name='media_items', on_delete=fields.CASCADE, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
creative_id: UUID
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = 'creative_media'
|
||||||
|
unique_together = (('creative_id', 'position'),)
|
||||||
|
|
||||||
|
|
||||||
MAX_CREATIVE_MEDIA_BYTES = 20 * 1024 * 1024
|
MAX_CREATIVE_MEDIA_BYTES = 20 * 1024 * 1024
|
||||||
|
MAX_CREATIVE_MEDIA_ITEMS = 10
|
||||||
|
|
||||||
|
|
||||||
_INVITE_LINK_HTML = re.compile(
|
_INVITE_LINK_HTML = re.compile(
|
||||||
@@ -99,3 +121,10 @@ def validate_media_size(media_data: bytes | None) -> None:
|
|||||||
return
|
return
|
||||||
if len(media_data) > MAX_CREATIVE_MEDIA_BYTES:
|
if len(media_data) > MAX_CREATIVE_MEDIA_BYTES:
|
||||||
raise CreativeMediaTooLarge(MAX_CREATIVE_MEDIA_BYTES)
|
raise CreativeMediaTooLarge(MAX_CREATIVE_MEDIA_BYTES)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_media_items(media_types: list[str]) -> None:
|
||||||
|
if len(media_types) > MAX_CREATIVE_MEDIA_ITEMS:
|
||||||
|
raise CreativeMediaTooMany(MAX_CREATIVE_MEDIA_ITEMS)
|
||||||
|
if len(media_types) > 1 and "animation" in media_types:
|
||||||
|
raise CreativeMediaGroupUnsupported()
|
||||||
|
|||||||
@@ -94,6 +94,17 @@ def CreativeMediaTooLarge(max_bytes: int) -> HTTPException:
|
|||||||
return HTTPException(status.HTTP_400_BAD_REQUEST, f'Creative media is too large (max {max_bytes} bytes)')
|
return HTTPException(status.HTTP_400_BAD_REQUEST, f'Creative media is too large (max {max_bytes} bytes)')
|
||||||
|
|
||||||
|
|
||||||
|
def CreativeMediaTooMany(max_items: int) -> HTTPException:
|
||||||
|
return HTTPException(status.HTTP_400_BAD_REQUEST, f'Creative media exceeds max items ({max_items})')
|
||||||
|
|
||||||
|
|
||||||
|
def CreativeMediaGroupUnsupported() -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
'Creative media group supports only photo/video; animation allowed only as a single item',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def CreativeInUse(creative_id: uuid.UUID) -> HTTPException:
|
def CreativeInUse(creative_id: uuid.UUID) -> HTTPException:
|
||||||
return HTTPException(
|
return HTTPException(
|
||||||
status.HTTP_400_BAD_REQUEST,
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ __all__ = (
|
|||||||
'PostOutput',
|
'PostOutput',
|
||||||
'PlacementWithPostsOutput',
|
'PlacementWithPostsOutput',
|
||||||
'CreativeButton',
|
'CreativeButton',
|
||||||
|
'CreativeMediaInput',
|
||||||
|
'CreativeMediaItem',
|
||||||
'CreativeOutput',
|
'CreativeOutput',
|
||||||
'CreativePreviewOutput',
|
'CreativePreviewOutput',
|
||||||
'GetCreativesInput',
|
'GetCreativesInput',
|
||||||
@@ -134,6 +136,8 @@ from .channel import (
|
|||||||
from .creative import (
|
from .creative import (
|
||||||
CreateCreativeInput,
|
CreateCreativeInput,
|
||||||
CreativeButton,
|
CreativeButton,
|
||||||
|
CreativeMediaInput,
|
||||||
|
CreativeMediaItem,
|
||||||
CreativeOutput,
|
CreativeOutput,
|
||||||
CreativePreviewOutput,
|
CreativePreviewOutput,
|
||||||
DeleteCreativeInput,
|
DeleteCreativeInput,
|
||||||
|
|||||||
@@ -11,12 +11,23 @@ class CreativeButton(pydantic.BaseModel):
|
|||||||
url: str
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreativeMediaItem(pydantic.BaseModel):
|
||||||
|
media_type: str
|
||||||
|
media_file_id: str
|
||||||
|
position: int
|
||||||
|
|
||||||
|
|
||||||
|
class CreativeMediaInput(pydantic.BaseModel):
|
||||||
|
media_type: str
|
||||||
|
media_file_id: str
|
||||||
|
media_data: bytes | None = None
|
||||||
|
|
||||||
|
|
||||||
class CreativeOutput(pydantic.BaseModel):
|
class CreativeOutput(pydantic.BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
name: str
|
name: str
|
||||||
text: str
|
text: str
|
||||||
media_type: str | None
|
media_items: list[CreativeMediaItem]
|
||||||
media_file_id: str | None
|
|
||||||
buttons: list[CreativeButton]
|
buttons: list[CreativeButton]
|
||||||
project_id: uuid.UUID
|
project_id: uuid.UUID
|
||||||
project_channel_title: str
|
project_channel_title: str
|
||||||
@@ -29,8 +40,7 @@ class CreativePreviewOutput(pydantic.BaseModel):
|
|||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
name: str
|
name: str
|
||||||
text: str
|
text: str
|
||||||
media_type: str | None
|
media_items: list[CreativeMediaItem]
|
||||||
media_file_id: str | None
|
|
||||||
buttons: list[CreativeButton]
|
buttons: list[CreativeButton]
|
||||||
|
|
||||||
|
|
||||||
@@ -54,18 +64,14 @@ class GetCreativeInput(pydantic.BaseModel):
|
|||||||
class CreateCreativeInput(pydantic.BaseModel):
|
class CreateCreativeInput(pydantic.BaseModel):
|
||||||
name: str
|
name: str
|
||||||
text: str
|
text: str
|
||||||
media_type: str | None = None
|
media_items: list[CreativeMediaInput] = pydantic.Field(default_factory=list)
|
||||||
media_file_id: str | None = None
|
|
||||||
media_data: bytes | None = None
|
|
||||||
buttons: list[CreativeButton] = pydantic.Field(default_factory=list)
|
buttons: list[CreativeButton] = pydantic.Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class UpdateCreativeInput(pydantic.BaseModel):
|
class UpdateCreativeInput(pydantic.BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
text: str | None = None
|
text: str | None = None
|
||||||
media_type: str | None = None
|
media_items: list[CreativeMediaInput] | None = None
|
||||||
media_file_id: str | None = None
|
|
||||||
media_data: bytes | None = None
|
|
||||||
buttons: list[CreativeButton] | None = None
|
buttons: list[CreativeButton] | None = None
|
||||||
status: domain.CreativeStatus | None = None
|
status: domain.CreativeStatus | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ from .workspace.update_workspace_avatar import update_workspace_avatar
|
|||||||
from .workspace.update_workspace_member_permissions import update_workspace_member_permissions
|
from .workspace.update_workspace_member_permissions import update_workspace_member_permissions
|
||||||
|
|
||||||
|
|
||||||
|
class MediaItem(typing.Protocol):
|
||||||
|
media_type: str
|
||||||
|
media_file_id: str
|
||||||
|
|
||||||
|
|
||||||
class TelegramBotWriter(typing.Protocol):
|
class TelegramBotWriter(typing.Protocol):
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self, text: str, chat_id: int, parse_mode: str | None = None, disable_preview: bool = False
|
self, text: str, chat_id: int, parse_mode: str | None = None, disable_preview: bool = False
|
||||||
@@ -86,6 +91,14 @@ class TelegramBotWriter(typing.Protocol):
|
|||||||
parse_mode: str | None = None,
|
parse_mode: str | None = None,
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|
||||||
|
async def send_media_group(
|
||||||
|
self,
|
||||||
|
chat_id: int,
|
||||||
|
media_items: list[MediaItem],
|
||||||
|
caption: str | None = None,
|
||||||
|
parse_mode: str | None = None,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
async def edit_message_text(self, text: str, chat_id: int, message_id: int) -> None: ...
|
async def edit_message_text(self, text: str, chat_id: int, message_id: int) -> None: ...
|
||||||
|
|
||||||
async def edit_message_reply_markup(self, chat_id: int, message_id: int) -> None: ...
|
async def edit_message_reply_markup(self, chat_id: int, message_id: int) -> None: ...
|
||||||
|
|||||||
@@ -22,36 +22,28 @@ async def create_creative(
|
|||||||
log.warning('User %s attempted to create creative for unavailable project %s', user_id, project_id)
|
log.warning('User %s attempted to create creative for unavailable project %s', user_id, project_id)
|
||||||
raise domain.ProjectNotFound(project_id)
|
raise domain.ProjectNotFound(project_id)
|
||||||
|
|
||||||
domain.validate_media_size(input.media_data)
|
|
||||||
|
|
||||||
creative_text = domain.replace_invite_link_with_tag(input.text)
|
creative_text = domain.replace_invite_link_with_tag(input.text)
|
||||||
|
media_items = input.media_items or []
|
||||||
media_s3_key: str | None = None
|
domain.validate_media_items([item.media_type for item in media_items])
|
||||||
if input.media_data:
|
for item in media_items:
|
||||||
file_id = uuid.uuid4()
|
domain.validate_media_size(item.media_data)
|
||||||
media_s3_key = f'creatives/{workspace_id}/{file_id}'
|
|
||||||
content_type = _get_content_type(input.media_type)
|
|
||||||
await self.s3.upload(media_s3_key, input.media_data, content_type)
|
|
||||||
log.info('Uploaded creative media to S3: %s', media_s3_key)
|
|
||||||
|
|
||||||
creative = domain.Creative(
|
creative = domain.Creative(
|
||||||
name=input.name,
|
name=input.name,
|
||||||
text=creative_text,
|
text=creative_text,
|
||||||
media_type=input.media_type,
|
|
||||||
media_file_id=input.media_file_id,
|
|
||||||
media_s3_key=media_s3_key,
|
|
||||||
buttons=[button.model_dump() for button in input.buttons],
|
buttons=[button.model_dump() for button in input.buttons],
|
||||||
status=domain.CreativeStatus.ACTIVE,
|
status=domain.CreativeStatus.ACTIVE,
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
)
|
)
|
||||||
await self.database.create_creative(creative)
|
await self.database.create_creative(creative)
|
||||||
|
|
||||||
|
created_media = await _replace_media_items(self, creative.id, workspace_id, media_items)
|
||||||
|
|
||||||
return dto.CreativeOutput(
|
return dto.CreativeOutput(
|
||||||
id=creative.id,
|
id=creative.id,
|
||||||
name=creative.name,
|
name=creative.name,
|
||||||
text=creative.text,
|
text=creative.text,
|
||||||
media_type=creative.media_type,
|
media_items=created_media,
|
||||||
media_file_id=creative.media_file_id,
|
|
||||||
buttons=creative.buttons,
|
buttons=creative.buttons,
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
project_channel_title=project.channel.title,
|
project_channel_title=project.channel.title,
|
||||||
@@ -72,3 +64,35 @@ def _get_content_type(media_type: str | None) -> str:
|
|||||||
'animation': 'image/gif',
|
'animation': 'image/gif',
|
||||||
}
|
}
|
||||||
return mapping.get(media_type, 'application/octet-stream')
|
return mapping.get(media_type, 'application/octet-stream')
|
||||||
|
|
||||||
|
|
||||||
|
async def _replace_media_items(
|
||||||
|
self: 'Usecase',
|
||||||
|
creative_id: uuid.UUID,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
media_items: list[dto.CreativeMediaInput],
|
||||||
|
) -> list[dto.CreativeMediaItem]:
|
||||||
|
created: list[dto.CreativeMediaItem] = []
|
||||||
|
for position, item in enumerate(media_items):
|
||||||
|
media_s3_key: str | None = None
|
||||||
|
if item.media_data:
|
||||||
|
file_id = uuid.uuid4()
|
||||||
|
media_s3_key = f'creatives/{workspace_id}/{file_id}'
|
||||||
|
content_type = _get_content_type(item.media_type)
|
||||||
|
await self.s3.upload(media_s3_key, item.media_data, content_type)
|
||||||
|
log.info('Uploaded creative media to S3: %s', media_s3_key)
|
||||||
|
media = await domain.CreativeMedia.create(
|
||||||
|
creative_id=creative_id,
|
||||||
|
media_type=item.media_type,
|
||||||
|
media_file_id=item.media_file_id,
|
||||||
|
media_s3_key=media_s3_key,
|
||||||
|
position=position,
|
||||||
|
)
|
||||||
|
created.append(
|
||||||
|
dto.CreativeMediaItem(
|
||||||
|
media_type=media.media_type,
|
||||||
|
media_file_id=media.media_file_id,
|
||||||
|
position=media.position,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return created
|
||||||
|
|||||||
@@ -27,15 +27,17 @@ async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> No
|
|||||||
log.warning('Creative %s is used in placement_posts and cannot be deleted', input.creative_id)
|
log.warning('Creative %s is used in placement_posts and cannot be deleted', input.creative_id)
|
||||||
raise domain.CreativeInUse(input.creative_id)
|
raise domain.CreativeInUse(input.creative_id)
|
||||||
|
|
||||||
media_key = creative.media_s3_key
|
media_items = await creative.media_items.all()
|
||||||
if media_key:
|
media_keys = [item.media_s3_key for item in media_items if item.media_s3_key]
|
||||||
|
if media_keys:
|
||||||
|
|
||||||
async def delete_old_media() -> None:
|
async def delete_old_media() -> None:
|
||||||
try:
|
for key in media_keys:
|
||||||
await self.s3.delete(media_key)
|
try:
|
||||||
log.info('Deleted old creative media from S3: %s', media_key)
|
await self.s3.delete(key)
|
||||||
except Exception as e:
|
log.info('Deleted old creative media from S3: %s', key)
|
||||||
log.warning('Failed to delete old creative media from S3: %s', e)
|
except Exception as e:
|
||||||
|
log.warning('Failed to delete old creative media from S3: %s', e)
|
||||||
|
|
||||||
asyncio.create_task(delete_old_media())
|
asyncio.create_task(delete_old_media())
|
||||||
|
|
||||||
|
|||||||
@@ -21,13 +21,24 @@ async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.Crea
|
|||||||
context.ensure_project_permission(domain.PermissionKey.PROJECTS_READ, creative.project_id)
|
context.ensure_project_permission(domain.PermissionKey.PROJECTS_READ, creative.project_id)
|
||||||
|
|
||||||
placements_count = await self.database.count_placement_posts_by_creative(creative.id)
|
placements_count = await self.database.count_placement_posts_by_creative(creative.id)
|
||||||
|
media_rel = creative.media_items
|
||||||
|
if hasattr(media_rel, 'all'):
|
||||||
|
media_items = await media_rel.all().order_by('position')
|
||||||
|
else:
|
||||||
|
media_items = sorted(media_rel, key=lambda item: item.position)
|
||||||
|
|
||||||
return dto.CreativeOutput(
|
return dto.CreativeOutput(
|
||||||
id=creative.id,
|
id=creative.id,
|
||||||
name=creative.name,
|
name=creative.name,
|
||||||
text=creative.text,
|
text=creative.text,
|
||||||
media_type=creative.media_type,
|
media_items=[
|
||||||
media_file_id=creative.media_file_id,
|
dto.CreativeMediaItem(
|
||||||
|
media_type=item.media_type,
|
||||||
|
media_file_id=item.media_file_id,
|
||||||
|
position=item.position,
|
||||||
|
)
|
||||||
|
for item in media_items
|
||||||
|
],
|
||||||
buttons=creative.buttons,
|
buttons=creative.buttons,
|
||||||
project_id=creative.project_id,
|
project_id=creative.project_id,
|
||||||
project_channel_title=creative.project.channel.title,
|
project_channel_title=creative.project.channel.title,
|
||||||
|
|||||||
@@ -27,19 +27,32 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[d
|
|||||||
creative_ids = [c.id for c in creatives]
|
creative_ids = [c.id for c in creatives]
|
||||||
placements_counts = await self.database.count_placement_posts_by_creative_batch(creative_ids)
|
placements_counts = await self.database.count_placement_posts_by_creative_batch(creative_ids)
|
||||||
|
|
||||||
return [
|
results: list[dto.CreativeOutput] = []
|
||||||
dto.CreativeOutput(
|
for creative in creatives:
|
||||||
id=creative.id,
|
media_rel = creative.media_items
|
||||||
name=creative.name,
|
if hasattr(media_rel, 'all'):
|
||||||
text=creative.text,
|
media_items = await media_rel.all().order_by('position')
|
||||||
media_type=creative.media_type,
|
else:
|
||||||
media_file_id=creative.media_file_id,
|
media_items = sorted(media_rel, key=lambda item: item.position)
|
||||||
buttons=creative.buttons,
|
results.append(
|
||||||
project_id=creative.project_id,
|
dto.CreativeOutput(
|
||||||
project_channel_title=creative.project.channel.title,
|
id=creative.id,
|
||||||
created_at=creative.created_at,
|
name=creative.name,
|
||||||
status=creative.status,
|
text=creative.text,
|
||||||
placements_count=placements_counts.get(creative.id, 0),
|
media_items=[
|
||||||
|
dto.CreativeMediaItem(
|
||||||
|
media_type=item.media_type,
|
||||||
|
media_file_id=item.media_file_id,
|
||||||
|
position=item.position,
|
||||||
|
)
|
||||||
|
for item in media_items
|
||||||
|
],
|
||||||
|
buttons=creative.buttons,
|
||||||
|
project_id=creative.project_id,
|
||||||
|
project_channel_title=creative.project.channel.title,
|
||||||
|
created_at=creative.created_at,
|
||||||
|
status=creative.status,
|
||||||
|
placements_count=placements_counts.get(creative.id, 0),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for creative in creatives
|
return results
|
||||||
]
|
|
||||||
|
|||||||
@@ -31,37 +31,12 @@ async def update_creative(
|
|||||||
creative.name = input.name
|
creative.name = input.name
|
||||||
if input.text:
|
if input.text:
|
||||||
creative.text = domain.replace_invite_link_with_tag(input.text)
|
creative.text = domain.replace_invite_link_with_tag(input.text)
|
||||||
if input.media_data is not None:
|
media_items: list[dto.CreativeMediaInput] | None = None
|
||||||
domain.validate_media_size(input.media_data)
|
if input.media_items is not None:
|
||||||
|
media_items = input.media_items
|
||||||
# Удаляем старое медиа из S3 в фоне
|
domain.validate_media_items([item.media_type for item in media_items])
|
||||||
old_media_key = creative.media_s3_key
|
for item in media_items:
|
||||||
if old_media_key:
|
domain.validate_media_size(item.media_data)
|
||||||
|
|
||||||
async def delete_old_media() -> None:
|
|
||||||
try:
|
|
||||||
await self.s3.delete(old_media_key)
|
|
||||||
log.info('Deleted old creative media from S3: %s', old_media_key)
|
|
||||||
except Exception as e:
|
|
||||||
log.warning('Failed to delete old creative media from S3: %s', e)
|
|
||||||
|
|
||||||
asyncio.create_task(delete_old_media())
|
|
||||||
|
|
||||||
# Загружаем новое медиа в S3
|
|
||||||
new_media_data = input.media_data
|
|
||||||
if new_media_data:
|
|
||||||
file_id = uuid.uuid4()
|
|
||||||
new_media_key = f'creatives/{workspace_id}/{file_id}'
|
|
||||||
content_type = _get_content_type(input.media_type)
|
|
||||||
await self.s3.upload(new_media_key, new_media_data, content_type)
|
|
||||||
log.info('Uploaded new creative media to S3: %s', new_media_key)
|
|
||||||
creative.media_s3_key = new_media_key
|
|
||||||
else:
|
|
||||||
creative.media_s3_key = None
|
|
||||||
if input.media_type is not None:
|
|
||||||
creative.media_type = input.media_type
|
|
||||||
if input.media_file_id is not None:
|
|
||||||
creative.media_file_id = input.media_file_id
|
|
||||||
if input.buttons is not None:
|
if input.buttons is not None:
|
||||||
creative.buttons = [button.model_dump() for button in input.buttons]
|
creative.buttons = [button.model_dump() for button in input.buttons]
|
||||||
if input.status:
|
if input.status:
|
||||||
@@ -69,14 +44,17 @@ async def update_creative(
|
|||||||
|
|
||||||
await self.database.update_creative(creative)
|
await self.database.update_creative(creative)
|
||||||
|
|
||||||
|
if media_items is not None:
|
||||||
|
await _replace_media_items(self, creative.id, workspace_id, media_items)
|
||||||
|
|
||||||
placements_count = await self.database.count_placement_posts_by_creative(creative.id)
|
placements_count = await self.database.count_placement_posts_by_creative(creative.id)
|
||||||
|
creative_media = await _get_media_items(creative)
|
||||||
|
|
||||||
return dto.CreativeOutput(
|
return dto.CreativeOutput(
|
||||||
id=creative.id,
|
id=creative.id,
|
||||||
name=creative.name,
|
name=creative.name,
|
||||||
text=creative.text,
|
text=creative.text,
|
||||||
media_type=creative.media_type,
|
media_items=creative_media,
|
||||||
media_file_id=creative.media_file_id,
|
|
||||||
buttons=creative.buttons,
|
buttons=creative.buttons,
|
||||||
project_id=creative.project_id,
|
project_id=creative.project_id,
|
||||||
project_channel_title=creative.project.channel.title,
|
project_channel_title=creative.project.channel.title,
|
||||||
@@ -96,3 +74,59 @@ def _get_content_type(media_type: str | None) -> str:
|
|||||||
'animation': 'image/gif',
|
'animation': 'image/gif',
|
||||||
}
|
}
|
||||||
return mapping.get(media_type, 'application/octet-stream')
|
return mapping.get(media_type, 'application/octet-stream')
|
||||||
|
|
||||||
|
|
||||||
|
async def _replace_media_items(
|
||||||
|
self: 'Usecase',
|
||||||
|
creative_id: uuid.UUID,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
media_items: list[dto.CreativeMediaInput],
|
||||||
|
) -> None:
|
||||||
|
existing_items = await domain.CreativeMedia.filter(creative_id=creative_id).all()
|
||||||
|
if existing_items:
|
||||||
|
old_media_keys = [item.media_s3_key for item in existing_items if item.media_s3_key]
|
||||||
|
await domain.CreativeMedia.filter(creative_id=creative_id).delete()
|
||||||
|
|
||||||
|
if old_media_keys:
|
||||||
|
|
||||||
|
async def delete_old_media() -> None:
|
||||||
|
for key in old_media_keys:
|
||||||
|
try:
|
||||||
|
await self.s3.delete(key)
|
||||||
|
log.info('Deleted old creative media from S3: %s', key)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning('Failed to delete old creative media from S3: %s', e)
|
||||||
|
|
||||||
|
asyncio.create_task(delete_old_media())
|
||||||
|
|
||||||
|
for position, item in enumerate(media_items):
|
||||||
|
media_s3_key: str | None = None
|
||||||
|
if item.media_data:
|
||||||
|
file_id = uuid.uuid4()
|
||||||
|
media_s3_key = f'creatives/{workspace_id}/{file_id}'
|
||||||
|
content_type = _get_content_type(item.media_type)
|
||||||
|
await self.s3.upload(media_s3_key, item.media_data, content_type)
|
||||||
|
log.info('Uploaded new creative media to S3: %s', media_s3_key)
|
||||||
|
await domain.CreativeMedia.create(
|
||||||
|
creative_id=creative_id,
|
||||||
|
media_type=item.media_type,
|
||||||
|
media_file_id=item.media_file_id,
|
||||||
|
media_s3_key=media_s3_key,
|
||||||
|
position=position,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_media_items(creative: domain.Creative) -> list[dto.CreativeMediaItem]:
|
||||||
|
media_rel = creative.media_items
|
||||||
|
if hasattr(media_rel, 'all'):
|
||||||
|
items = await media_rel.all().order_by('position')
|
||||||
|
else:
|
||||||
|
items = sorted(media_rel, key=lambda item: item.position)
|
||||||
|
return [
|
||||||
|
dto.CreativeMediaItem(
|
||||||
|
media_type=item.media_type,
|
||||||
|
media_file_id=item.media_file_id,
|
||||||
|
position=item.position,
|
||||||
|
)
|
||||||
|
for item in items
|
||||||
|
]
|
||||||
|
|||||||
@@ -93,12 +93,23 @@ async def build_placement_creative(
|
|||||||
if not invite_link:
|
if not invite_link:
|
||||||
raise domain.PlacementNotFound(placement.id)
|
raise domain.PlacementNotFound(placement.id)
|
||||||
|
|
||||||
|
media_rel = creative.media_items
|
||||||
|
if hasattr(media_rel, 'all'):
|
||||||
|
media_items = await media_rel.all().order_by('position')
|
||||||
|
else:
|
||||||
|
media_items = sorted(media_rel, key=lambda item: item.position)
|
||||||
preview = dto.CreativePreviewOutput(
|
preview = dto.CreativePreviewOutput(
|
||||||
id=creative.id,
|
id=creative.id,
|
||||||
name=creative.name,
|
name=creative.name,
|
||||||
text=_inject_invite_link(creative.text, invite_link),
|
text=_inject_invite_link(creative.text, invite_link),
|
||||||
media_type=creative.media_type,
|
media_items=[
|
||||||
media_file_id=creative.media_file_id,
|
dto.CreativeMediaItem(
|
||||||
|
media_type=item.media_type,
|
||||||
|
media_file_id=item.media_file_id,
|
||||||
|
position=item.position,
|
||||||
|
)
|
||||||
|
for item in media_items
|
||||||
|
],
|
||||||
buttons=_build_buttons(creative.buttons, invite_link),
|
buttons=_build_buttons(creative.buttons, invite_link),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -112,15 +123,24 @@ async def build_placement_creative(
|
|||||||
await self.telegram_bot.send_message(header, chat_id, parse_mode='HTML')
|
await self.telegram_bot.send_message(header, chat_id, parse_mode='HTML')
|
||||||
|
|
||||||
keyboard_buttons = _build_keyboard_buttons(preview.buttons)
|
keyboard_buttons = _build_keyboard_buttons(preview.buttons)
|
||||||
if preview.media_type and preview.media_file_id:
|
if preview.media_items:
|
||||||
await self.telegram_bot.send_media_with_inline_keyboard(
|
if len(preview.media_items) == 1:
|
||||||
text=preview.text,
|
media_item = preview.media_items[0]
|
||||||
chat_id=chat_id,
|
await self.telegram_bot.send_media_with_inline_keyboard(
|
||||||
media_type=preview.media_type,
|
text=preview.text,
|
||||||
media_file_id=preview.media_file_id,
|
chat_id=chat_id,
|
||||||
buttons=keyboard_buttons,
|
media_type=media_item.media_type,
|
||||||
parse_mode='HTML',
|
media_file_id=media_item.media_file_id,
|
||||||
)
|
buttons=keyboard_buttons,
|
||||||
|
parse_mode='HTML',
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.telegram_bot.send_media_group(
|
||||||
|
chat_id=chat_id,
|
||||||
|
media_items=preview.media_items,
|
||||||
|
caption=preview.text,
|
||||||
|
parse_mode='HTML',
|
||||||
|
)
|
||||||
elif keyboard_buttons:
|
elif keyboard_buttons:
|
||||||
await self.telegram_bot.send_message_with_inline_keyboard(
|
await self.telegram_bot.send_message_with_inline_keyboard(
|
||||||
preview.text,
|
preview.text,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ type Client struct {
|
|||||||
func New(cfg Config) *Client {
|
func New(cfg Config) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
http: &http.Client{
|
http: &http.Client{
|
||||||
Timeout: 5 * time.Second,
|
Timeout: 60 * time.Second,
|
||||||
},
|
},
|
||||||
baseURL: cfg.BaseURL,
|
baseURL: cfg.BaseURL,
|
||||||
loginURL: cfg.LoginURL,
|
loginURL: cfg.LoginURL,
|
||||||
@@ -369,17 +369,16 @@ func (c *Client) AcceptWorkspaceInvite(
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
type Creative struct {
|
type Creative struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
MediaType string `json:"media_type"`
|
MediaItems []CreativeMediaItem `json:"media_items"`
|
||||||
MediaFileID string `json:"media_file_id"`
|
Buttons []CreativeButton `json:"buttons"`
|
||||||
Buttons []CreativeButton `json:"buttons"`
|
ProjectID string `json:"project_id"`
|
||||||
ProjectID string `json:"project_id"`
|
ProjectChannelTitle string `json:"project_channel_title"`
|
||||||
ProjectChannelTitle string `json:"project_channel_title"`
|
CreatedAt string `json:"created_at"`
|
||||||
CreatedAt string `json:"created_at"`
|
Status string `json:"status"`
|
||||||
Status string `json:"status"`
|
PlacementsCount int `json:"placements_count"`
|
||||||
PlacementsCount int `json:"placements_count"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreativeButton struct {
|
type CreativeButton struct {
|
||||||
@@ -387,6 +386,18 @@ type CreativeButton struct {
|
|||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreativeMediaItem struct {
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
MediaFileID string `json:"media_file_id"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreativeMediaInput struct {
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
MediaFileID string `json:"media_file_id"`
|
||||||
|
MediaData []byte `json:"media_data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type CreativesPage struct {
|
type CreativesPage struct {
|
||||||
Items []Creative `json:"items"`
|
Items []Creative `json:"items"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
@@ -448,12 +459,10 @@ func (c *Client) GetCreative(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreateCreativeInput struct {
|
type CreateCreativeInput struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
MediaType string `json:"media_type,omitempty"`
|
MediaItems []CreativeMediaInput `json:"media_items,omitempty"`
|
||||||
MediaFileID string `json:"media_file_id,omitempty"`
|
Buttons []CreativeButton `json:"buttons,omitempty"`
|
||||||
MediaData []byte `json:"media_data,omitempty"`
|
|
||||||
Buttons []CreativeButton `json:"buttons,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) CreateCreative(
|
func (c *Client) CreateCreative(
|
||||||
@@ -482,13 +491,11 @@ func (c *Client) CreateCreative(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdateCreativeInput struct {
|
type UpdateCreativeInput struct {
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
Text *string `json:"text,omitempty"`
|
Text *string `json:"text,omitempty"`
|
||||||
MediaType *string `json:"media_type,omitempty"`
|
MediaItems *[]CreativeMediaInput `json:"media_items,omitempty"`
|
||||||
MediaFileID *string `json:"media_file_id,omitempty"`
|
Buttons *[]CreativeButton `json:"buttons,omitempty"`
|
||||||
MediaData *[]byte `json:"media_data,omitempty"`
|
Status *string `json:"status,omitempty"`
|
||||||
Buttons *[]CreativeButton `json:"buttons,omitempty"`
|
|
||||||
Status *string `json:"status,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) UpdateCreative(
|
func (c *Client) UpdateCreative(
|
||||||
@@ -625,12 +632,11 @@ type GetPlacementsOutput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreativePreviewOutput struct {
|
type CreativePreviewOutput struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
MediaType string `json:"media_type"`
|
MediaItems []CreativeMediaItem `json:"media_items"`
|
||||||
MediaFileID string `json:"media_file_id"`
|
Buttons []CreativeButton `json:"buttons"`
|
||||||
Buttons []CreativeButton `json:"buttons"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) CreatePlacements(
|
func (c *Client) CreatePlacements(
|
||||||
|
|||||||
@@ -143,6 +143,12 @@ func (b *Bot) SetState(s State, mode RenderMode) {
|
|||||||
s.Enter(b, mode)
|
s.Enter(b, mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Bot) MarkHandled() {
|
||||||
|
if b.exec != nil {
|
||||||
|
b.exec.handled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func isChannelChat(chatType string) bool {
|
func isChannelChat(chatType string) bool {
|
||||||
return chatType == "channel" || chatType == "supergroup"
|
return chatType == "channel" || chatType == "supergroup"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user