правки
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "placement" ADD "cost_before_bargain_type" VARCHAR(8);
|
||||
COMMENT ON COLUMN "placement"."cost_before_bargain_type" IS 'FIXED: fixed\nCPM: cpm';"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "placement" DROP COLUMN "cost_before_bargain_type";"""
|
||||
|
||||
|
||||
MODELS_STATE = (
|
||||
"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"
|
||||
)
|
||||
@@ -17,11 +17,21 @@ log = logging.getLogger(__name__)
|
||||
|
||||
class TelegramBot(TelegramBase, TelegramBotWriter):
|
||||
async def send_message(
|
||||
self, text: str, chat_id: int, parse_mode: str | None = None, disable_preview: bool = False
|
||||
) -> None:
|
||||
await self.bot.send_message(
|
||||
chat_id=chat_id, text=text, parse_mode=parse_mode, disable_web_page_preview=disable_preview
|
||||
self,
|
||||
text: str,
|
||||
chat_id: int,
|
||||
parse_mode: str | None = None,
|
||||
disable_preview: bool = False,
|
||||
reply_to_message_id: int | None = None,
|
||||
) -> int:
|
||||
message = await self.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
parse_mode=parse_mode,
|
||||
disable_web_page_preview=disable_preview,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
)
|
||||
return message.message_id
|
||||
|
||||
async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str:
|
||||
invite_link = await self.bot.create_chat_invite_link(chat_id=chat_id, creates_join_request=requires_approval)
|
||||
@@ -34,15 +44,18 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
|
||||
buttons: list[list[InlineKeyboardButton]],
|
||||
parse_mode: str | None = None,
|
||||
disable_preview: bool = False,
|
||||
) -> None:
|
||||
reply_to_message_id: int | None = None,
|
||||
) -> int:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
await self.bot.send_message(
|
||||
message = await self.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
disable_web_page_preview=disable_preview,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
)
|
||||
return message.message_id
|
||||
|
||||
async def send_media_with_inline_keyboard(
|
||||
self,
|
||||
@@ -52,41 +65,47 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
|
||||
media_file_id: str,
|
||||
buttons: list[list[InlineKeyboardButton]],
|
||||
parse_mode: str | None = None,
|
||||
) -> None:
|
||||
reply_to_message_id: int | None = None,
|
||||
) -> int:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) if buttons else None
|
||||
if media_type == 'photo':
|
||||
await self.bot.send_photo(
|
||||
message = await self.bot.send_photo(
|
||||
chat_id=chat_id,
|
||||
photo=media_file_id,
|
||||
caption=text,
|
||||
parse_mode=parse_mode,
|
||||
reply_markup=keyboard,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
)
|
||||
return
|
||||
return message.message_id
|
||||
if media_type == 'video':
|
||||
await self.bot.send_video(
|
||||
message = await self.bot.send_video(
|
||||
chat_id=chat_id,
|
||||
video=media_file_id,
|
||||
caption=text,
|
||||
parse_mode=parse_mode,
|
||||
reply_markup=keyboard,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
)
|
||||
return
|
||||
return message.message_id
|
||||
if media_type == 'animation':
|
||||
await self.bot.send_animation(
|
||||
message = await self.bot.send_animation(
|
||||
chat_id=chat_id,
|
||||
animation=media_file_id,
|
||||
caption=text,
|
||||
parse_mode=parse_mode,
|
||||
reply_markup=keyboard,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
)
|
||||
return
|
||||
await self.bot.send_message(
|
||||
return message.message_id
|
||||
message = await self.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
parse_mode=parse_mode,
|
||||
reply_markup=keyboard,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
)
|
||||
return message.message_id
|
||||
|
||||
async def send_media_group(
|
||||
self,
|
||||
@@ -94,9 +113,10 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
|
||||
media_items: Sequence[Any],
|
||||
caption: str | None = None,
|
||||
parse_mode: str | None = None,
|
||||
) -> None:
|
||||
reply_to_message_id: int | None = None,
|
||||
) -> int:
|
||||
if not media_items:
|
||||
return
|
||||
raise ValueError('No media items to send')
|
||||
|
||||
group: list[InputMediaPhoto | InputMediaVideo] = []
|
||||
for index, item in enumerate(media_items):
|
||||
@@ -113,7 +133,11 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
|
||||
raise ValueError(f'Unsupported media type for group: {item.media_type}')
|
||||
|
||||
# Type ignore: aiogram expects Union type but we only use Photo/Video
|
||||
await self.bot.send_media_group(chat_id=chat_id, media=group) # type: ignore[arg-type]
|
||||
messages = await self.bot.send_media_group(
|
||||
chat_id=chat_id, media=group, reply_to_message_id=reply_to_message_id
|
||||
) # type: ignore[arg-type]
|
||||
# Return message_id of first message (with caption)
|
||||
return messages[0].message_id
|
||||
|
||||
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)
|
||||
|
||||
@@ -89,6 +89,7 @@ class Placement(TimestampedModel):
|
||||
payment_at = fields.DatetimeField(null=True)
|
||||
cost_type = fields.CharEnumField(CostType, null=True, max_length=8)
|
||||
cost_value = fields.FloatField(null=True)
|
||||
cost_before_bargain_type = fields.CharEnumField(CostType, null=True, max_length=8)
|
||||
cost_before_bargain = fields.FloatField(null=True)
|
||||
placement_type = fields.CharEnumField(PlacementType, null=True, max_length=16)
|
||||
format = fields.TextField(null=True)
|
||||
|
||||
@@ -18,11 +18,12 @@ class PlacementDetails(pydantic.BaseModel):
|
||||
placement_at: datetime.datetime | None = None
|
||||
payment_at: datetime.datetime | None = None
|
||||
cost: CostInfo | None = None
|
||||
cost_before_bargain: float | None = None
|
||||
cost_before_bargain: CostInfo | None = None
|
||||
placement_type: PlacementType | None = None
|
||||
format: str | None = None
|
||||
comment: str | None = None
|
||||
creative_id: uuid.UUID | None = None
|
||||
invite_link_type: InviteLinkType | None = None
|
||||
|
||||
|
||||
class PlacementOutput(pydantic.BaseModel):
|
||||
@@ -102,7 +103,7 @@ class UpdatePlacementInput(pydantic.BaseModel):
|
||||
placement_at: datetime.datetime | None = None
|
||||
payment_at: datetime.datetime | None = None
|
||||
cost: CostInfo | None = None
|
||||
cost_before_bargain: float | None = None
|
||||
cost_before_bargain: CostInfo | None = None
|
||||
placement_type: PlacementType | None = None
|
||||
format: str | None = None
|
||||
|
||||
|
||||
@@ -72,8 +72,13 @@ class MediaItem(typing.Protocol):
|
||||
|
||||
class TelegramBotWriter(typing.Protocol):
|
||||
async def send_message(
|
||||
self, text: str, chat_id: int, parse_mode: str | None = None, disable_preview: bool = False
|
||||
) -> None: ...
|
||||
self,
|
||||
text: str,
|
||||
chat_id: int,
|
||||
parse_mode: str | None = None,
|
||||
disable_preview: bool = False,
|
||||
reply_to_message_id: int | None = None,
|
||||
) -> int: ...
|
||||
|
||||
async def send_message_with_inline_keyboard(
|
||||
self,
|
||||
@@ -82,7 +87,8 @@ class TelegramBotWriter(typing.Protocol):
|
||||
buttons: list[list['InlineKeyboardButton']],
|
||||
parse_mode: str | None = None,
|
||||
disable_preview: bool = False,
|
||||
) -> None: ...
|
||||
reply_to_message_id: int | None = None,
|
||||
) -> int: ...
|
||||
|
||||
async def send_media_with_inline_keyboard(
|
||||
self,
|
||||
@@ -92,7 +98,8 @@ class TelegramBotWriter(typing.Protocol):
|
||||
media_file_id: str,
|
||||
buttons: list[list['InlineKeyboardButton']],
|
||||
parse_mode: str | None = None,
|
||||
) -> None: ...
|
||||
reply_to_message_id: int | None = None,
|
||||
) -> int: ...
|
||||
|
||||
async def send_media_group(
|
||||
self,
|
||||
@@ -100,7 +107,8 @@ class TelegramBotWriter(typing.Protocol):
|
||||
media_items: Sequence[MediaItem],
|
||||
caption: str | None = None,
|
||||
parse_mode: str | None = None,
|
||||
) -> None: ...
|
||||
reply_to_message_id: int | None = None,
|
||||
) -> int: ...
|
||||
|
||||
async def edit_message_text(self, text: str, chat_id: int, message_id: int) -> None: ...
|
||||
|
||||
|
||||
@@ -51,6 +51,73 @@ def _format_channel_name(channel: domain.Channel) -> str:
|
||||
return 'Без названия'
|
||||
|
||||
|
||||
def _format_channel_link(channel: domain.Channel) -> str:
|
||||
"""Format channel as HTML link if possible, otherwise return plain text."""
|
||||
name = _format_channel_name(channel)
|
||||
|
||||
# Try invite_link first
|
||||
if channel.invite_link:
|
||||
return f'<a href="{channel.invite_link}">{name}</a>'
|
||||
|
||||
# Try username
|
||||
if channel.username:
|
||||
return f'<a href="https://t.me/{channel.username}">{name}</a>'
|
||||
|
||||
# No link available, return plain name
|
||||
return name
|
||||
|
||||
|
||||
def _build_info_message(
|
||||
placement: domain.Placement,
|
||||
project: domain.Project,
|
||||
) -> str:
|
||||
"""Build informational message about placement."""
|
||||
lines = []
|
||||
|
||||
# Header
|
||||
lines.append('Ссылка зашита с помощью @smartpost_tg_bot')
|
||||
lines.append('')
|
||||
|
||||
# Placement channel (always present)
|
||||
placement_channel_link = _format_channel_link(placement.channel)
|
||||
lines.append(f'Размещение в канале: {placement_channel_link}')
|
||||
|
||||
# Project channel (always present)
|
||||
project_channel_link = _format_channel_link(project.channel)
|
||||
lines.append(f'Рекламируемый проект: {project_channel_link}')
|
||||
|
||||
# Invite link type (always present)
|
||||
link_type_text = 'Открытая' if placement.invite_link_type == domain.InviteLinkType.PUBLIC else 'С заявками'
|
||||
lines.append(f'Тип ссылки: {link_type_text}')
|
||||
|
||||
# Cost / CPM (optional)
|
||||
if placement.cost_value is not None and placement.cost_value > 0:
|
||||
if placement.cost_type == domain.CostType.CPM:
|
||||
lines.append(f'Ставка CPM: {placement.cost_value:.0f} ₽')
|
||||
else:
|
||||
lines.append(f'Стоимость: {placement.cost_value:.0f} ₽')
|
||||
|
||||
# Date and time (optional)
|
||||
if placement.placement_at:
|
||||
# Check if time is 00:00 (midnight) - then show only date
|
||||
if placement.placement_at.hour == 0 and placement.placement_at.minute == 0:
|
||||
date_str = placement.placement_at.strftime('%d.%m.%Y')
|
||||
lines.append(f'Дата: {date_str}')
|
||||
else:
|
||||
datetime_str = placement.placement_at.strftime('%d.%m.%Y %H:%M')
|
||||
lines.append(f'Дата и время: {datetime_str}')
|
||||
|
||||
# Format (optional)
|
||||
if placement.format:
|
||||
lines.append(f'Формат: {placement.format}')
|
||||
|
||||
# Add empty line before footer
|
||||
lines.append('')
|
||||
lines.append('Пост ниже')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _build_keyboard_buttons(buttons: list[dto.CreativeButton]) -> list[list[InlineKeyboardButton]]:
|
||||
return [[InlineKeyboardButton(text=btn.text, url=btn.url)] for btn in buttons]
|
||||
|
||||
@@ -120,10 +187,12 @@ async def build_placement_creative(
|
||||
raise domain.UserNotFound(user_id)
|
||||
|
||||
chat_id = user.telegram_user.telegram_id
|
||||
channel_name = _format_channel_name(placement.channel)
|
||||
header = f'<b>Канал:</b> {channel_name}'
|
||||
await self.telegram_bot.send_message(header, chat_id, parse_mode='HTML')
|
||||
|
||||
# Build and send informational message
|
||||
info_message = _build_info_message(placement, project)
|
||||
info_message_id = await self.telegram_bot.send_message(info_message, chat_id, parse_mode='HTML')
|
||||
|
||||
# Send creative as reply to informational message
|
||||
keyboard_buttons = _build_keyboard_buttons(preview.buttons)
|
||||
if preview.media_items:
|
||||
if len(preview.media_items) == 1:
|
||||
@@ -135,6 +204,7 @@ async def build_placement_creative(
|
||||
media_file_id=media_item.media_file_id,
|
||||
buttons=keyboard_buttons,
|
||||
parse_mode='HTML',
|
||||
reply_to_message_id=info_message_id,
|
||||
)
|
||||
else:
|
||||
await self.telegram_bot.send_media_group(
|
||||
@@ -142,6 +212,7 @@ async def build_placement_creative(
|
||||
media_items=preview.media_items,
|
||||
caption=preview.text,
|
||||
parse_mode='HTML',
|
||||
reply_to_message_id=info_message_id,
|
||||
)
|
||||
elif keyboard_buttons:
|
||||
await self.telegram_bot.send_message_with_inline_keyboard(
|
||||
@@ -150,8 +221,11 @@ async def build_placement_creative(
|
||||
keyboard_buttons,
|
||||
parse_mode='HTML',
|
||||
disable_preview=True,
|
||||
reply_to_message_id=info_message_id,
|
||||
)
|
||||
else:
|
||||
await self.telegram_bot.send_message(preview.text, chat_id, parse_mode='HTML', disable_preview=True)
|
||||
await self.telegram_bot.send_message(
|
||||
preview.text, chat_id, parse_mode='HTML', disable_preview=True, reply_to_message_id=info_message_id
|
||||
)
|
||||
|
||||
return preview
|
||||
|
||||
@@ -21,7 +21,7 @@ def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetail
|
||||
placement_at=placement.placement_at,
|
||||
payment_at=placement.payment_at,
|
||||
cost=_build_cost_info(placement.cost_type, placement.cost_value),
|
||||
cost_before_bargain=placement.cost_before_bargain,
|
||||
cost_before_bargain=_build_cost_info(placement.cost_before_bargain_type, placement.cost_before_bargain),
|
||||
placement_type=placement.placement_type,
|
||||
format=placement.format,
|
||||
comment=placement.comment,
|
||||
@@ -117,8 +117,10 @@ async def create_placements(
|
||||
or (details.cost.type if details and details.cost else None),
|
||||
cost_value=(channel_details.cost.value if channel_details and channel_details.cost else None)
|
||||
or (details.cost.value if details and details.cost else None),
|
||||
cost_before_bargain=(channel_details.cost_before_bargain if channel_details else None)
|
||||
or (details.cost_before_bargain if details else None),
|
||||
cost_before_bargain_type=(channel_details.cost_before_bargain.type if channel_details and channel_details.cost_before_bargain else None)
|
||||
or (details.cost_before_bargain.type if details and details.cost_before_bargain else None),
|
||||
cost_before_bargain=(channel_details.cost_before_bargain.value if channel_details and channel_details.cost_before_bargain else None)
|
||||
or (details.cost_before_bargain.value if details and details.cost_before_bargain else None),
|
||||
placement_type=details.placement_type if details else None,
|
||||
format=(channel_details.format if channel_details else None) or (details.format if details else None),
|
||||
)
|
||||
|
||||
@@ -61,7 +61,12 @@ async def update_placement(
|
||||
placement.cost_type = input.cost.type
|
||||
placement.cost_value = input.cost.value
|
||||
if 'cost_before_bargain' in fields_set:
|
||||
placement.cost_before_bargain = input.cost_before_bargain
|
||||
if input.cost_before_bargain is None:
|
||||
placement.cost_before_bargain_type = None
|
||||
placement.cost_before_bargain = None
|
||||
else:
|
||||
placement.cost_before_bargain_type = input.cost_before_bargain.type
|
||||
placement.cost_before_bargain = input.cost_before_bargain.value
|
||||
if 'placement_type' in fields_set:
|
||||
placement.placement_type = input.placement_type
|
||||
if 'format' in fields_set:
|
||||
|
||||
@@ -581,7 +581,7 @@ type PlacementDetails struct {
|
||||
PlacementAt *string `json:"placement_at,omitempty"`
|
||||
PaymentAt *string `json:"payment_at,omitempty"`
|
||||
Cost *CostInfo `json:"cost,omitempty"`
|
||||
CostBeforeBargain *float64 `json:"cost_before_bargain,omitempty"`
|
||||
CostBeforeBargain *CostInfo `json:"cost_before_bargain,omitempty"`
|
||||
PlacementType *string `json:"placement_type,omitempty"`
|
||||
Format *string `json:"format,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
|
||||
@@ -271,6 +271,8 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
CreativeID: s.CreativeID,
|
||||
CreativeTitle: s.CreativeTitle,
|
||||
Channels: []PurchaseChannelInput{},
|
||||
Duplicates: []string{},
|
||||
ParsingErrors: []ParseError{},
|
||||
BackState: s.BackState,
|
||||
}, bot.EditMessage)
|
||||
}
|
||||
|
||||
380
tg_bot/screens/channel_parser.go
Normal file
380
tg_bot/screens/channel_parser.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ChannelInput представляет результат парсинга ввода пользователя
|
||||
type ChannelInput struct {
|
||||
Input string // оригинальный ввод пользователя
|
||||
Username string // извлеченный username (если применимо)
|
||||
InviteLink string // извлеченный invite link (если применимо)
|
||||
Source string // источник: telegram, tgstat, telemetr
|
||||
Type string // тип: username, invite, deeplink, stat
|
||||
Valid bool // валидный ли формат
|
||||
}
|
||||
|
||||
// Парсеры для разных форматов
|
||||
var (
|
||||
// Username без @: 5-32 символа, начинается с буквы
|
||||
channelUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{4,31}$`)
|
||||
|
||||
// Invite link: t.me/+xxxxx или t.me/joinchat/xxxxx
|
||||
inviteLinkRe = regexp.MustCompile(`^t\.me/\+[a-zA-Z0-9_-]+$`)
|
||||
inviteLinkJoinRe = regexp.MustCompile(`^t\.me/joinchat/[a-zA-Z0-9_-]+$`)
|
||||
|
||||
// TGStat: tgstat.ru/channel_name или tgstat.ru/channel/@username
|
||||
tgstatRe = regexp.MustCompile(`^(?:https?://)?(?:www\.)?tgstat\.ru/(?:channel/)?@?([A-Za-z][A-Za-z0-9_.]{4,31})`)
|
||||
|
||||
// Telemetr: telemetr.io/channel/username или telemetr.io/channel/@username
|
||||
telemetrRe = regexp.MustCompile(`^(?:https?://)?(?:www\.)?telemetr\.io/(?:channel|channels)/@?([A-Za-z][A-Za-z0-9_.]{4,31})`)
|
||||
)
|
||||
|
||||
// ParseChannelInput парсит ввод пользователя и определяет тип канала
|
||||
func ParseChannelInput(input string) ChannelInput {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return ChannelInput{Valid: false}
|
||||
}
|
||||
|
||||
result := ChannelInput{Input: input}
|
||||
|
||||
// 1. Проверяем Telegram deeplink (tg://...)
|
||||
if strings.HasPrefix(input, "tg://") {
|
||||
return parseTelegramDeeplink(input)
|
||||
}
|
||||
|
||||
// 2. Проверяем invite ссылки (t.me/+xxx или t.me/joinchat/xxx)
|
||||
if inviteLinkRe.MatchString(input) || inviteLinkJoinRe.MatchString(input) {
|
||||
result.Type = "invite"
|
||||
result.Source = "telegram"
|
||||
result.InviteLink = normalizeInviteLink(input)
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
|
||||
// 3. Проверяем URL форматы (http://, https://, t.me/, telegram.me/)
|
||||
if isURLFormat(input) {
|
||||
return parseURLFormat(input)
|
||||
}
|
||||
|
||||
// 4. Проверяем @username
|
||||
if strings.HasPrefix(input, "@") {
|
||||
username := strings.TrimPrefix(input, "@")
|
||||
username = strings.TrimSpace(username)
|
||||
if channelUsernameRe.MatchString(username) {
|
||||
result.Username = username
|
||||
result.Type = "username"
|
||||
result.Source = "telegram"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
|
||||
// 5. Проверяем username (без @)
|
||||
if channelUsernameRe.MatchString(input) {
|
||||
result.Username = input
|
||||
result.Type = "username"
|
||||
result.Source = "telegram"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
|
||||
// 6. Ничего не подошло
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
|
||||
// parseTelegramDeeplink парсит tg:// ссылки
|
||||
func parseTelegramDeeplink(input string) ChannelInput {
|
||||
result := ChannelInput{Input: input, Source: "telegram", Type: "deeplink"}
|
||||
|
||||
// tg://resolve?domain=username&post=123
|
||||
if strings.HasPrefix(input, "tg://resolve?domain=") {
|
||||
parsed, err := url.Parse(input)
|
||||
if err != nil {
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
query := parsed.Query()
|
||||
domain := query.Get("domain")
|
||||
if domain != "" && channelUsernameRe.MatchString(domain) {
|
||||
result.Username = domain
|
||||
result.Type = "username"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// tg://join?invite=abcdef
|
||||
if strings.HasPrefix(input, "tg://join?invite=") {
|
||||
parsed, err := url.Parse(input)
|
||||
if err != nil {
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
query := parsed.Query()
|
||||
invite := query.Get("invite")
|
||||
if invite != "" {
|
||||
result.InviteLink = "t.me/+" + invite
|
||||
result.Type = "invite"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
|
||||
// isURLFormat проверяет, является ли ввод URL или ссылкой
|
||||
func isURLFormat(input string) bool {
|
||||
return strings.HasPrefix(input, "http://") ||
|
||||
strings.HasPrefix(input, "https://") ||
|
||||
strings.HasPrefix(input, "t.me/") ||
|
||||
strings.HasPrefix(input, "telegram.me/") ||
|
||||
strings.Contains(input, "t.me/") ||
|
||||
strings.Contains(input, "telegram.me/")
|
||||
}
|
||||
|
||||
// parseURLFormat парсит URL-форматы ссылок
|
||||
func parseURLFormat(input string) ChannelInput {
|
||||
result := ChannelInput{Input: input}
|
||||
|
||||
// Нормализуем: убираем протокол
|
||||
normalized := input
|
||||
normalized = strings.TrimPrefix(normalized, "https://")
|
||||
normalized = strings.TrimPrefix(normalized, "http://")
|
||||
normalized = strings.TrimPrefix(normalized, "www.")
|
||||
|
||||
// Проверяем TGStat
|
||||
if matches := tgstatRe.FindStringSubmatch(input); len(matches) > 1 {
|
||||
username := strings.TrimSuffix(matches[1], "_")
|
||||
result.Username = username
|
||||
result.Type = "stat"
|
||||
result.Source = "tgstat"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
|
||||
// Проверяем Telemetr
|
||||
if matches := telemetrRe.FindStringSubmatch(input); len(matches) > 1 {
|
||||
username := strings.TrimSuffix(matches[1], "_")
|
||||
result.Username = username
|
||||
result.Type = "stat"
|
||||
result.Source = "telemetr"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
|
||||
// Проверяем telegram.me или t.me
|
||||
if strings.HasPrefix(normalized, "t.me/") || strings.HasPrefix(normalized, "telegram.me/") {
|
||||
return parseTelegramLink(normalized)
|
||||
}
|
||||
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
|
||||
// parseTelegramLink парсит ссылки t.me/... и telegram.me/...
|
||||
func parseTelegramLink(link string) ChannelInput {
|
||||
result := ChannelInput{Source: "telegram"}
|
||||
|
||||
// Убираем t.me/ или telegram.me/
|
||||
path := link
|
||||
if strings.HasPrefix(path, "telegram.me/") {
|
||||
path = strings.TrimPrefix(path, "telegram.me/")
|
||||
} else {
|
||||
path = strings.TrimPrefix(path, "t.me/")
|
||||
}
|
||||
|
||||
// Проверяем invite link: +xxxxx или joinchat/xxxxx
|
||||
if strings.HasPrefix(path, "+") {
|
||||
inviteHash := strings.TrimPrefix(path, "+")
|
||||
inviteHash = extractBeforeChar(inviteHash, '?', '/')
|
||||
if inviteHash != "" {
|
||||
result.InviteLink = "t.me/+" + inviteHash
|
||||
result.Type = "invite"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "joinchat/") {
|
||||
inviteHash := strings.TrimPrefix(path, "joinchat/")
|
||||
inviteHash = extractBeforeChar(inviteHash, '?', '/')
|
||||
if inviteHash != "" {
|
||||
result.InviteLink = "t.me/joinchat/" + inviteHash
|
||||
result.Type = "invite"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Извлекаем username из t.me/username или t.me/username/12345
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
if len(parts) > 0 {
|
||||
username := parts[0]
|
||||
// Убираем @ если есть
|
||||
username = strings.TrimPrefix(username, "@")
|
||||
|
||||
// Валидируем username
|
||||
if channelUsernameRe.MatchString(username) {
|
||||
result.Username = username
|
||||
result.Type = "username"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
|
||||
// normalizeInviteLink нормализует invite ссылку
|
||||
func normalizeInviteLink(link string) string {
|
||||
link = strings.TrimSpace(link)
|
||||
|
||||
// Добавляем протокол если нужно
|
||||
if !strings.HasPrefix(link, "http://") && !strings.HasPrefix(link, "https://") && !strings.HasPrefix(link, "t.me/") {
|
||||
if strings.HasPrefix(link, "t.me/") {
|
||||
// Уже в нужном формате
|
||||
return link
|
||||
}
|
||||
}
|
||||
|
||||
// Нормализуем t.me/+xxxxx
|
||||
if strings.HasPrefix(link, "t.me/+") {
|
||||
return link
|
||||
}
|
||||
|
||||
// Нормализуем t.me/joinchat/xxxxx
|
||||
if strings.HasPrefix(link, "t.me/joinchat/") {
|
||||
return link
|
||||
}
|
||||
|
||||
return link
|
||||
}
|
||||
|
||||
// extractBeforeChar извлекает часть строки до первого встреченного символа
|
||||
func extractBeforeChar(s string, chars ...rune) string {
|
||||
for _, c := range chars {
|
||||
if idx := strings.IndexRune(s, c); idx != -1 {
|
||||
return s[:idx]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SuggestFix предлагает исправление для некорректного ввода
|
||||
func SuggestFix(input string) (string, bool) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// 1. Исправляем опечатки в протоколе
|
||||
if strings.HasPrefix(input, "htp://") {
|
||||
fixed := strings.Replace(input, "htp://", "https://", 1)
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(input, "ttps://") {
|
||||
fixed := strings.Replace(input, "ttps://", "https://", 1)
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(input, "ttp://") {
|
||||
fixed := strings.Replace(input, "ttp://", "http://", 1)
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Исправляем tme/ → t.me/
|
||||
if strings.HasPrefix(input, "tme/") {
|
||||
fixed := strings.Replace(input, "tme/", "t.me/", 1)
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
if strings.Contains(input, "tme/") {
|
||||
fixed := strings.Replace(input, "tme/", "t.me/", 1)
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Исправляем @@ или @@
|
||||
if strings.HasPrefix(input, "@@") {
|
||||
fixed := strings.TrimPrefix(input, "@")
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Пробуем добавить @ в начало username
|
||||
if !strings.HasPrefix(input, "@") && !isURLFormat(input) {
|
||||
fixed := "@" + input
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Пробуем убрать лишние символы в конце
|
||||
cleaned := input
|
||||
cleaned = strings.TrimSuffix(cleaned, ".")
|
||||
cleaned = strings.TrimSuffix(cleaned, ",")
|
||||
cleaned = strings.TrimSuffix(cleaned, ";")
|
||||
cleaned = strings.TrimSpace(cleaned)
|
||||
if cleaned != input {
|
||||
if parsed := ParseChannelInput(cleaned); parsed.Valid {
|
||||
return cleaned, true
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Пробуем извлечь username из сложной ссылки
|
||||
if strings.Contains(input, "/") {
|
||||
parts := strings.Split(input, "/")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
part := strings.TrimSpace(parts[i])
|
||||
part = strings.TrimPrefix(part, "@")
|
||||
if channelUsernameRe.MatchString(part) {
|
||||
return "@" + part, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// FormatChannelLabel форматирует метку канала для отображения
|
||||
func FormatChannelLabel(input ChannelInput) string {
|
||||
if input.Username != "" {
|
||||
return "@" + input.Username
|
||||
}
|
||||
if input.InviteLink != "" {
|
||||
return formatInviteLabel(input.InviteLink)
|
||||
}
|
||||
return input.Input
|
||||
}
|
||||
|
||||
// IsDuplicate проверяет, является ли канал дубликатом (case-insensitive для username)
|
||||
func IsDuplicate(input ChannelInput, channels []PurchaseChannelInput) bool {
|
||||
for _, ch := range channels {
|
||||
// Case-insensitive сравнение username (@MyChannel == @mychannel)
|
||||
if input.Username != "" && ch.Username != "" && strings.EqualFold(ch.Username, input.Username) {
|
||||
return true
|
||||
}
|
||||
if input.InviteLink != "" && ch.InviteLink == input.InviteLink {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -54,7 +54,10 @@ func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) {
|
||||
)
|
||||
}
|
||||
if placement.Details.CostBeforeBargain != nil {
|
||||
text += fmt.Sprintf("• <b>До торга:</b> %.0f₽\n", *placement.Details.CostBeforeBargain)
|
||||
text += fmt.Sprintf("• <b>До торга:</b> %s %.0f₽\n",
|
||||
formatCostType(placement.Details.CostBeforeBargain.Type),
|
||||
placement.Details.CostBeforeBargain.Value,
|
||||
)
|
||||
}
|
||||
if placement.Details.Format != nil && *placement.Details.Format != "" {
|
||||
text += fmt.Sprintf("• <b>Формат:</b> %s\n", *placement.Details.Format)
|
||||
|
||||
@@ -86,7 +86,7 @@ func (s *Placements) Exit() {}
|
||||
|
||||
// PlacementsList - экран со списком размещений
|
||||
const placementsPerPage = 6
|
||||
const placementsPerRow = 2
|
||||
const placementsPerRow = 1
|
||||
|
||||
type PlacementsList struct {
|
||||
ProjectID string
|
||||
@@ -152,9 +152,9 @@ func (s *PlacementsList) render(b *bot.Bot, mode bot.RenderMode) {
|
||||
}
|
||||
|
||||
// Текст кнопки: #N • Канал (с пробелом и разделителем)
|
||||
buttonText := fmt.Sprintf("#%d • %s", globalNum, channelName)
|
||||
buttonText := fmt.Sprintf("%d • %s", globalNum, channelName)
|
||||
if dateStr != "" {
|
||||
buttonText += fmt.Sprintf("\n%s", dateStr)
|
||||
buttonText += fmt.Sprintf(" %s", dateStr)
|
||||
}
|
||||
|
||||
buttons = append(buttons, echotron.InlineKeyboardButton{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ package screens
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
@@ -33,10 +32,16 @@ type SelectChannelsForPurchase struct {
|
||||
Channels []PurchaseChannelInput
|
||||
CurrentPage int
|
||||
InvalidUsernames []string
|
||||
Duplicates []string // дубликаты каналов
|
||||
ParsingErrors []ParseError // ошибки парсинга с предложениями
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
var channelUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{4,31}$`)
|
||||
// ParseError представляет ошибку парсинга с предложением исправления
|
||||
type ParseError struct {
|
||||
Input string
|
||||
Suggestion string
|
||||
}
|
||||
|
||||
func (s *SelectChannelsForPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
s.renderChannelSelection(b, mode)
|
||||
@@ -118,6 +123,30 @@ func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.
|
||||
s.InvalidUsernames = nil
|
||||
}
|
||||
|
||||
// Показываем дубликаты
|
||||
if len(s.Duplicates) > 0 {
|
||||
text += fmt.Sprintf("\n\n⏭️ Пропущены (дубликаты): %s", strings.Join(s.Duplicates, ", "))
|
||||
s.Duplicates = nil
|
||||
}
|
||||
|
||||
// Показываем ошибки с предложениями
|
||||
if len(s.ParsingErrors) > 0 {
|
||||
text += "\n\n❌ Ошибки форматирования:\n"
|
||||
for i, err := range s.ParsingErrors {
|
||||
if i < 3 { // Показываем максимум 3 ошибки
|
||||
if err.Suggestion != "" {
|
||||
text += fmt.Sprintf("• %s → возможно: %s\n", err.Input, err.Suggestion)
|
||||
} else {
|
||||
text += fmt.Sprintf("• %s\n", err.Input)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(s.ParsingErrors) > 3 {
|
||||
text += fmt.Sprintf("• ... и еще %d\n", len(s.ParsingErrors)-3)
|
||||
}
|
||||
s.ParsingErrors = nil
|
||||
}
|
||||
|
||||
// Кнопки навигации
|
||||
if len(s.Channels) > 0 {
|
||||
buttons = append(buttons, Row(
|
||||
@@ -164,18 +193,18 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
|
||||
return
|
||||
}
|
||||
|
||||
// Создаем/обновляем каналы в БД через API
|
||||
jwt := b.Session.JWT
|
||||
if jwt == "" {
|
||||
log.Error().Msg("JWT is empty in session")
|
||||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
|
||||
return
|
||||
// Проверяем, что все каналы резолвлены (имеют ChannelID)
|
||||
var unresolved []string
|
||||
for _, ch := range s.Channels {
|
||||
if ch.ChannelID == "" {
|
||||
label := channelLabel(ch)
|
||||
unresolved = append(unresolved, label)
|
||||
}
|
||||
}
|
||||
|
||||
failed := s.resolveChannels(b, jwt)
|
||||
if len(failed) > 0 {
|
||||
text := "❌ Не удалось добавить каналы:\n\n"
|
||||
for _, label := range failed {
|
||||
if len(unresolved) > 0 {
|
||||
text := "❌ Некоторые каналы не были найдены:\n\n"
|
||||
for _, label := range unresolved {
|
||||
text += fmt.Sprintf("• %s\n", label)
|
||||
}
|
||||
text += "\nУдалите их из списка и попробуйте снова"
|
||||
@@ -186,6 +215,7 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
|
||||
return
|
||||
}
|
||||
|
||||
// Каналы уже резолвлены в HandleMessage, переходим к следующему шагу
|
||||
b.SetState(&PurchaseOptionalDetails{
|
||||
ProjectID: s.ProjectID,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
@@ -290,12 +320,25 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем JWT
|
||||
jwt := b.Session.JWT
|
||||
if jwt == "" {
|
||||
log.Error().Msg("JWT is empty in session")
|
||||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
// Разбиваем ввод на токены
|
||||
tokens := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ' ' || r == '\n' || r == '\t' || r == ',' || r == ';'
|
||||
})
|
||||
|
||||
added := 0
|
||||
var invalid []string
|
||||
s.Duplicates = nil
|
||||
s.InvalidUsernames = nil
|
||||
s.ParsingErrors = nil
|
||||
|
||||
tokensLoop:
|
||||
for _, token := range tokens {
|
||||
entry := strings.TrimSpace(token)
|
||||
entry = strings.Trim(entry, ",;")
|
||||
@@ -303,53 +346,144 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
|
||||
continue
|
||||
}
|
||||
|
||||
var username string
|
||||
var inviteLink string
|
||||
if isInviteLink(entry) {
|
||||
inviteLink = entry
|
||||
// Используем новый парсер
|
||||
parsed := ParseChannelInput(entry)
|
||||
|
||||
if !parsed.Valid {
|
||||
// Пробуем предложить исправление
|
||||
if suggestion, ok := SuggestFix(entry); ok {
|
||||
s.ParsingErrors = append(s.ParsingErrors, ParseError{
|
||||
Input: entry,
|
||||
Suggestion: suggestion,
|
||||
})
|
||||
} else {
|
||||
username = strings.TrimPrefix(entry, "@")
|
||||
username = strings.Trim(username, "@")
|
||||
if !channelUsernameRe.MatchString(username) {
|
||||
invalid = append(invalid, entry)
|
||||
s.InvalidUsernames = append(s.InvalidUsernames, entry)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Проверяем дубликаты по username/invite link
|
||||
if IsDuplicate(parsed, s.Channels) {
|
||||
label := FormatChannelLabel(parsed)
|
||||
s.Duplicates = append(s.Duplicates, label)
|
||||
continue
|
||||
}
|
||||
|
||||
exists := false
|
||||
// Сразу резолвим канал через бэкенд
|
||||
channel, err := s.resolveSingleChannel(b, jwt, parsed)
|
||||
if err != nil {
|
||||
// Канал не найден или ошибка
|
||||
s.InvalidUsernames = append(s.InvalidUsernames, entry)
|
||||
log.Error().Err(err).Str("input", entry).Msg("Failed to resolve channel")
|
||||
continue
|
||||
}
|
||||
|
||||
// Проверяем дубликаты по ID канала (после резолва)
|
||||
for _, ch := range s.Channels {
|
||||
if inviteLink != "" && ch.InviteLink == inviteLink {
|
||||
exists = true
|
||||
break
|
||||
if ch.ChannelID != "" && ch.ChannelID == channel.ID {
|
||||
var title, username string
|
||||
if channel.Title != nil {
|
||||
title = *channel.Title
|
||||
}
|
||||
if username != "" && ch.Username == username {
|
||||
exists = true
|
||||
break
|
||||
if channel.Username != nil {
|
||||
username = *channel.Username
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
label := channelLabelByID(&channel.ID, &title, &username)
|
||||
s.Duplicates = append(s.Duplicates, label)
|
||||
continue tokensLoop // <-- Выход из внешнего цикла, а не внутреннего!
|
||||
}
|
||||
|
||||
// Дополнительная проверка по username (case-insensitive)
|
||||
// На случай, если канал еще не резолвлен или ID отличается
|
||||
if channel.Username != nil && ch.Username != "" {
|
||||
if strings.EqualFold(ch.Username, *channel.Username) {
|
||||
var title string
|
||||
if channel.Title != nil {
|
||||
title = *channel.Title
|
||||
}
|
||||
label := channelLabelByID(&channel.ID, &title, channel.Username)
|
||||
s.Duplicates = append(s.Duplicates, label)
|
||||
continue tokensLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем канал с полными данными
|
||||
var title string
|
||||
if channel.Title != nil {
|
||||
title = *channel.Title
|
||||
}
|
||||
s.Channels = append(s.Channels, PurchaseChannelInput{
|
||||
Username: username,
|
||||
InviteLink: inviteLink,
|
||||
ChannelID: channel.ID,
|
||||
Username: parsed.Username,
|
||||
Title: title,
|
||||
InviteLink: parsed.InviteLink,
|
||||
})
|
||||
added++
|
||||
}
|
||||
|
||||
if added == 0 {
|
||||
if len(invalid) == 0 {
|
||||
// Если ничего не добавлено и нет ошибок - не обновляем экран
|
||||
if added == 0 && len(s.InvalidUsernames) == 0 && len(s.Duplicates) == 0 && len(s.ParsingErrors) == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.InvalidUsernames = invalid
|
||||
|
||||
// Обновляем экран
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
|
||||
// resolveSingleChannel резолвит один канал через бэкенд
|
||||
func (s *SelectChannelsForPurchase) resolveSingleChannel(b *bot.Bot, jwt string, parsed ChannelInput) (*backend.Channel, error) {
|
||||
var input backend.CreateChannelInput
|
||||
if parsed.InviteLink != "" {
|
||||
link := parsed.InviteLink
|
||||
input = backend.CreateChannelInput{InviteLink: &link}
|
||||
} else {
|
||||
username := parsed.Username
|
||||
input = backend.CreateChannelInput{Username: &username}
|
||||
}
|
||||
|
||||
resp, err := b.Backend.CreateChannels(context.Background(), jwt, backend.CreateChannelsInput{
|
||||
Channels: []backend.CreateChannelInput{input},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(resp.Results) == 0 {
|
||||
return nil, fmt.Errorf("no response from backend")
|
||||
}
|
||||
|
||||
result := resp.Results[0]
|
||||
if result.Status == "failed" || result.Channel == nil || result.Channel.ID == "" {
|
||||
errMsg := "channel not found"
|
||||
if result.Error != nil {
|
||||
errMsg = *result.Error
|
||||
}
|
||||
return nil, fmt.Errorf(errMsg)
|
||||
}
|
||||
|
||||
// Если пришел username из ответа, обновляем его
|
||||
if result.Channel.Username != nil && *result.Channel.Username != "" {
|
||||
parsed.Username = *result.Channel.Username
|
||||
}
|
||||
|
||||
return result.Channel, nil
|
||||
}
|
||||
|
||||
// channelLabelByID формирует метку канала по ID
|
||||
func channelLabelByID(id, title, username *string) string {
|
||||
if title != nil && *title != "" {
|
||||
return *title
|
||||
}
|
||||
if username != nil && *username != "" {
|
||||
return "@" + *username
|
||||
}
|
||||
if id != nil && *id != "" {
|
||||
return *id
|
||||
}
|
||||
return "канал"
|
||||
}
|
||||
|
||||
func (s *SelectChannelsForPurchase) resolveChannels(b *bot.Bot, jwt string) []string {
|
||||
inputs := make([]backend.CreateChannelInput, 0, len(s.Channels))
|
||||
for _, ch := range s.Channels {
|
||||
|
||||
Reference in New Issue
Block a user