Навигация по циклу статей:

Часть 1. Пишем веб-приложение кликер на Kotlin
Часть 2. Пишем кликер для Telegram на Kotlin – текущая статья
Часть 2 с половиной. Аутентификация пользователя с rest-framework. TMA на KMP – в разработке
Часть 3. Добавляем оплату через Telegram Mini Apps на Kotlin – в разработке

Раскрытые темы в цикле

  • Web приложение на Kotlin – часть 1

  • Интеграция приложения с Telegram Mini Apps – часть 2

  • Работа с элементами интерфейса TMA приложения. Тема, MainButtonBackButton – часть 2

  • Поделиться ссылкой на приложение через Telegram. Передача данных через ссылку – часть 2

  • Аутентификации через TMA приложение – часть 2 и 2.5

  • Telegram Payments API– часть 3

Запускаем приложение в Telegram

Telegram Mini Apps (далее - TMA), если просто, то это обычные веб-приложения, которые имеют некоторый ограниченный доступ к API, определяемый Telegram. Из чего следует возможность использовать любой стек, применяемый в разработке веб-приложений. В этой статье мы запустим разработанное в предыдущей статье веб-приложение в Telegram

Для добавления веб-приложения в Telegram достаточно создать бота через BotFather по документации. Сам токен пока не понадобится, сейчас нужно настроить бота на открытие нашего приложения. В настройках бота (в чате с BotFather) переходим в меню Bot Settings >> Menu Button >> Customize menu button. Теперь нас просят ввести ссылку, по которой располагается наш бот.

localhost не подойдёт, поскольку, скорее всего, у вас он не настроен на приём запросов по https. Поэтому идём по самому простому пути, используем ngrok. Для настройки зарегистрируйтесь на сайте ngrok, установите его и пройдите первоначальную настройку. Сам запуск, последний этап, прост:

ngrok http http://localhost:8080 --host-header="localhost:8080"

После этого получаем хост, работающий с https схемой

Рисунок 2

Далее отправляем его в BotFather и задаём надпись на кнопке. Готово, теперь у нашего свежесозданного бота есть кнопка. Нажимаем и открывается наше веб-приложение.

Навигация с использованием TMA (MainButton, BackButton)

Добавим второй экран, не будем подключать никакие библиотеки для навигации, используем enum, state и when.

// Don't use in real code
enum class Screen {
    CLICKER,
    FRIENDS_LIST,
}

// Don't use in real code
var currentScreen by mutableStateOf(Screen.CLICKER)

@Composable
fun App() {
    when (currentScreen) {
        Screen.CLICKER -> ClickerScreen(
            onFriendsList = {
                currentScreen = Screen.FRIENDS_LIST
            }
        )

        Screen.FRIENDS_LIST -> FriendsListScreen(
            onBack = {
                currentScreen = Screen.CLICKER
            },
        )
    }
}

Интерфейс, который ранее располагался в App()выносим как отдельный экран с callback на переход на другой экран

val score = mutableStateOf(0L)

@Composable
fun ClickerScreen(
    onFriendsList: () -> Unit,
) {
    Div(
        attrs = {
            classes(ClickerStyles.MainContainer)
        }
    ) {
        Div(
            attrs = {
                classes(ClickerStyles.ClickerBlock)
            }
        ) {
            H2 {
                Text("Score: ${score.value}")
            }
            Img(
                src = Res.images.click_item.fileUrl,
                attrs = {
                    classes(ClickerStyles.MainImage)
                    onClick {
                        score.value += 1
                    }
                }
            )
        }
    }
}

Вторым экраном станет самая важная функция таких кликеров – список друзей. Мы будем приглашать их в нашу игру через ссылку.

@Composable
fun FriendsListScreen(
    onBack: () -> Unit,
) {
    Div(
        attrs = {
            classes(FriendsListStyle.Container)
        }
    ) {
        H4 {
            Text("У тебя пока нет друзей...")
        }
    } 
}

Отлично! Теперь у нас есть экраны, но по ним нельзя переходить, воспользуемся стандартными средствами, которые предоставляет Telegram: это MainButton и BackButton.

Первым делом нужно подключить сам TMA API в наше веб приложение, воспользуемся сторонней, уже готовой обёрткой над JS библиотекой TMA

jsMain.dependencies {
    //…
    implementation("dev.inmo:tgbotapi.webapps:15.2.0")
}

Однако этого недостаточно, эта зависимость является лишь врапером для API телеграмм, в наш проект нужно подключить js версию TMA API. Добавим её как script в index.html документе.

<head>
    ...
    <script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>

Теперь нам доступен глобальный объект webApp, у которого уже есть поля backButton и mainButton.

Однако проявим смекалку и адаптируем их под использование из composable функции – сделаем из них компоненты.

@Composable
fun WebAppMainButton(
    text: String,
    onClick: () -> Unit,
    visible: Boolean = true,
    active: Boolean? = undefined,
    loading: Boolean = false,
    loadingLeaveActive: Boolean = false,
    hideOnDispose: Boolean = true, // use if next screen has MainButton too
    color: String? = undefined,
    textColor: String? = undefined,
) {
    DisposableEffect(visible) {
        if (visible) {
            webApp.mainButton.show()
        } else {
            webApp.mainButton.hide()
        }
        onDispose {
            if (hideOnDispose)
                webApp.mainButton.hide()
        }
    }
    DisposableEffect(
        keys = arrayOf(
            onClick, text, color, textColor, visible, active,
            loading, loadingLeaveActive
        ),
    ) {
        webApp.mainButton.onClick(onClick)
        webApp.mainButton.setParams(
            MainButtonParams(
                text = text,
                color = color,
                textColor = textColor,
                isActive = active,
                isVisible = visible,
            )
        )
        if (loading)
            webApp.mainButton.showProgress(leaveActive = loadingLeaveActive)
        else
            webApp.mainButton.hideProgress()
        onDispose {
            webApp.mainButton.offClick(onClick)
        }
    }
}

@Composable
fun WebAppBackButton(
    onClick: () -> Unit,
) {
    DisposableEffect(Unit) {
        webApp.backButton.onClick(onClick)
        webApp.backButton.show()
        onDispose {
            webApp.backButton.hide()
            webApp.backButton.offClick(onClick)
        }
    }
}

Теперь будет проще работать с TMA API. Добавим главную кнопку перехода в список друзей на экран кликера, а на экран списка друзей кнопку назад.

@Composable
fun FriendsListScreen(
    onBack: () -> Unit,
) {
    WebAppBackButton(onClick = onBack)
    // ...
}

@Composable
fun ClickerScreen(
    onFriendsList: () -> Unit,
) {
    WebAppMainButton(
        text = "Список друзей",
        hideOnDispose = false,
        onClick = onFriendsList
    )
    // ...
}
image.png
Определиться с иконкой для тапа и заскринить заново

Тема как у Telegram

Цвета этого приложения сильно выделяются, но TMA API предоставляет свои цвета для применения их в своём CSS. Добавим же эти стили к себе через CSS in Kotlin. Создадим AppStyles, где будут применяться эти значения как значения css variables

object TMAVariables {
    val BackgroundColorValue = Color("#ffffff")
    val BackgroundColor by variable<CSSColorValue>()
    val SecondaryBackgroundColorValue = Color("#ffffff")
    val SecondaryBackgroundColor by variable<CSSColorValue>()
    val TextColorValue = Color("#000000")
    val TextColor by variable<CSSColorValue>()
    val HintColorValue = Color.gray
    val HintColor by variable<CSSColorValue>()
    // И т. д., полный код на github
}

Для простого применения ThemeParams, предоставляемого библиотекой создаём функцию расширения для StyleScope. Поскольку поля могут быть null, нужно определить дефолтные значения для каждой variable.

fun StyleScope.applyThemeVariables(theme: ThemeParams) {
    BackgroundColor(theme.backgroundColor?.toCSSColorValue() ?: BackgroundColorValue)
    SecondaryBackgroundColor(theme.secondaryBackgroundColor?.toCSSColorValue() ?: SecondaryBackgroundColorValue)
    TextColor(theme.textColor?.toCSSColorValue() ?: TextColorValue)
    HintColor(theme.hintColor?.toCSSColorValue() ?: HintColorValue)
    // И т. д., полный код на github
}

Создадим новую, главную таблицу стилей, где применим созданные CSS variables для всего приложения. Дополнительно можно указать параметры для различных тегов.

class AppStyles(val themeParams: ThemeParams) : StyleSheet() {
    init {
        "body" style {
            applyThemeVariables(themeParams)
            backgroundColor(TMAVariables.BackgroundColor.value())
            color(TMAVariables.TextColor.value())
        }
        "a" style {
            color(TMAVariables.LinkColor.value())
        }
        "button" style {
            color(TMAVariables.ButtonTextColor.value())
            backgroundColor(TMAVariables.ButtonColor.value())
        }
    }
}

Далее нужно применить этот стиль в renderComposable, передав параметры темы webApp.themeParams

renderComposable(rootElementId = "app") {
    Style(AppStyles(webApp.themeParams))
    // ...
}

Запускаем и видим, что цвета теперь сочетаются с Telegram

Авторизация пользователя и сохранение данных

Краткая справка: каждый TMA запускается с query params, которые можно использовать для получения id и краткой информации о пользователе, а также аутентифицировать. Они хранятся в полях webApp.initData и webApp.initDataUnsafe. Причина разделения – это то, что initData – это сырые данные, которые можно отправить на сервер для валидацииinitDataUnsafe – это type-safe объект, где хранятся эти данные уже в преобразованном виде, но на самом клиенте никак нельзя безопасно проверить данные на правильность, но можно использовать их для отображения какой-либо информации о пользователе, например, username или id.

image.png
image.png

Для авторизации будем отправлять к каждому запросу заголовок с webApp.initDataTapClient создаётся в commonMain и заранее зададим запросы, которые нам понадобятся в будущем.

object TapClient {
    private val client: HttpClient = HttpClient(TapClientEngine) {
        // …
        authConfig()
    }

    suspend fun sendCurrentScore(score: Long) {
        client.post("/score") {
            setBody(SendCurrentScoreRequest(score))
            contentType(ContentType.Application.Json)
        }
    }

    suspend fun fetchCurrentScore(): Long {
        return client.get("/score").body<GetCurrentScoreResponse>().score
    }

    suspend fun fetchFriendsList(): List<FriendInfo> {
        return client.get("/friends").body<GetFriendsListResponse>().friends
    }
}

Где authConfig() определён как expect и реализуется в исходниках jsMain

actual fun HttpClientConfig<HttpClientEngineConfig>.authConfig() {
    defaultRequest {
        header("tma-data", webApp.initData)
    }
}

Теперь каждый запрос будет проверяться на то, что он запущен из Telegram с верными дынными. Подробнее в другой статье, про реализацию серверной части (статья в разработке).

Прямые ссылки, позвать друга

Direct link – возможность открывать TMA приложение по ссылке, в том числе с предопределёнными параметрами.

Для подключения прямых ссылок возвращаемся к BotFather и пишем команду /newapp, выбираем нашего бота и далее

  • задаём название нашего TMA приложению

  • Короткое описание

  • Картинку приложению 640x360.

  • Добавляем GIF

  • Ссылка, которая будет открывать (такая же что, мы задавали при подключении кнопки в приложении)

  • appname, по которому будет располагаться приложение

Теперь мы можем открывать приложение по ссылке и передавать параметр как query параметр с ключём startapp. Параметры, переданные в прямой ссылке появятся в webApp.initDataUnsafe.startParam. Так мы и будем добавлять друзей.

Сначала добавим кнопку на отправку ссылке на экран с друзьями. Это лишь пример использования TMA API и лучше реализовать генерацию ссылки на стороне сервера. Вызов buildInviteLink()создаёт ссылку, перейдя по которой открывается наше TMA приложение, а buildShareLink()использует возможности Telegram для отправки сообщения, содержащего ссылку на переход.

// Use build config instead
private const val AppLink = "https://t.me/botusername/appname"
private const val ShareMessage = "%D0%9F%D0%BE%D0%BF%D1%80%D0%BE%D0%B1%D1%83%D0%B9+%D1%8D%D1%82%D0%BE%D1%82+%D0%BD%D0%BE%D0%B2%D1%8B%D0%B9+%D0%BA%D0%BB%D0%B8%D0%BA%D0%B5%D1%80%21"

private fun buildInviteLink(telegramId: Long): String {
    return "$AppLink?startapp=ref_$telegramId"
}

private fun buildShareLink(telegramId: Long): String {
    return "https://t.me/share/url?url=${buildInviteLink(telegramId)}&text=${ShareMessage}"
}

Осталось добавить MainButton, нажатие на которую генерирует ссылку на отправку сообщения, и открывает её через webApp.openTelegramLink(). Telegram обработает такую ссылку открытием экрана “переслать сообщение“

@Composable

fun FriendsListScreen(
    onBack: () -> Unit,
) {
    WebAppMainButton(
        text = "Пригласить друзей",
        onClick = {
            val id = webApp.initDataUnsafe.user?.id ?: return@WebAppMainButton
            webApp.openTelegramLink(buildShareLink(id.long))
        }
    )
    //...
}

Теперь по этой кнопке можно отправить сообщение со ссылкой-приглашением любому пользователю. Обработку приглашений отдельно делать на стороне клиента (только для информирования о том, что его пригласили) не нужно, поскольку сервер и так получает все initData от клиента с каждым запросом.

Итоги

В данной статье мы научились использовать TMA API в нашем приложении на Kotlin. Самое главное, что нужно для приложения – это стили, аутентификация и какое-никакое управление интерфейсом Telegram нам предоставляет, дальше можно делать с такими приложениями, что захочет заказчик. Исходный код можно посмотреть в ветке clicker нашего проекта с шаблоном на GitHub

Мы не только делимся своим опытом в статьях, но и используем его во благо бизнеса. Возможно, именно вашего! Обращайтесь к нам для разработки мобильных приложений под ключ. Работаем на подряде, субподряде, предоставляем аутстафф.

Комментарии (0)