Русский
Русский
English
Статистика
Реклама

Погружение в JetBrains Space Applicaitons

Привет, Хабр.

Недавно JetBrains представили свой новый продукт под названием Space, о чем был своевременный пост на Хабре. Прошло немного времени и уже пора бы попробовать некоторые его особенности как платформы. В этой статье речь пойдет о Space Applications.

Space Applications - это расширения серверной и клиентской частей. Applications позволяют взаимодействовать с разными компонентами платформы и расширять её функциональность.

Первым делом рассмотрим расширения серверной части, которые предоставляет Space. Основное ограничение - Application нельзя запустить непосредственно внутри окружения Space. Для предоставления доступа между Space и Application используется так называемый Endpoint, в котором мы указываем Endpoint URI - адрес нашего плагина, на который Space будет отправлять запросы и коммуницировать с помощью Space HTTP API.

На данный момент известно несколько видов Applications:

Chatbot

Cтандартный чатбот, с которым взаимодействие происходит в приватном с ним чате. На данный момент кастомизация сильно уступает, например, Telegram. Нельзя добавлять бота в канал (групповой чат), а из интерактивного интерфейса пока доступны только кнопки.

Slash commands

Для каждого канала могут быть заданы разные команды, и когда пользователь вводит "/", ему показывается список доступных команд. На данный момент доступны только для чатботов.

Client applications

Используют функционал Space, чтобы получать от него разную информацию или взаимодействовать с разными модулями и компонентами, например, открывать/закрывать issues, писать сообщения, отправлять посты в канал и так далее.

Custom menus

Позволяют расширить стандартные меню новыми элементами. На момент написания статьи api для них еще не доступен.

Также можно свободно комбинировать сразу несколько типов Applications. Например, создать бота, у которого будет кнопка для запуска билда проекта на CI/CD сервере, и результат которого вернется в общий канал.

Для начала Application требуется создать. Делается это через Administration menu. При создании можно указать, какими правами оно будет обладать, чтобы приложение имело доступ только к тем ресурсам, которые нами явно указаны.

После создания в табе Authentication требуется выбрать один из возможных Authentication flows, в зависимости от типа приложения. Кратко каждый из них можно описать так:

  • Client Credentials Flow - самый простой способ. Работает через client id и client secret. Наше приложение будет работать от своего лица и не сможет получить доступ к некоторым компонентам платформы. Используется в полностью серверном приложении.

  • Authorization Code Flow - логинимся в приложение через Space, получаем код авторизации, который приложение использует чтобы из него получить токен и работать от лица пользователя.

  • Implicit Flow - идея таже, что и Authorization Code Flow, только клиент логинится на стороне браузера.

Client applications

В данном разделе мы используем функционал Space, чтобы получить от сервера разную информацию: каналы, приложения, пользователи, проекты, и т.д.

build.gradle.kts
repositories {    mavenCentral()    maven("https://kotlin.bintray.com/kotlinx")    maven("https://maven.pkg.jetbrains.space/public/p/space/maven")}dependencies {    implementation(kotlin("stdlib"))    // Space api sdk    // https://www.jetbrains.com/help/space/space-sdk.html    val space_version = "61400-beta"    implementation("org.jetbrains:space-sdk-jvm:$space_version")    // Ktor (http client)    // https://github.com/ktorio/ktor    val ktor_version = "1.4.3"    implementation("io.ktor:ktor-client-core:$ktor_version")    implementation("io.ktor:ktor-client-core-jvm:$ktor_version")    implementation("io.ktor:ktor-client-cio:$ktor_version")}

Сначала нам требуется создать и настроить клиент. Мы будем использовать Client Credentials Flow:

private const val spaceInstanceUrl = "https://makentoshe.jetbrains.space"val spaceClient = SpaceHttpClient(HttpClient(CIO)).withServiceAccountTokenSource(    ClientCredentialsFlow.clientId, ClientCredentialsFlow.clientSecret, spaceInstanceUrl)object ClientCredentialsFlow {    const val clientId: String = TODO(Put your client_id here)    const val clientSecret: String = TODO(Put your client_secret here)}

Ниже приведены примеры некоторых расширений и запросов.

// There are some examples of retrieving several data from the Space instance.// We can process this info as we want - create analytics, office dashboards, and so on.fun main() = runBlocking {    val channels = spaceClient.chats.channels.listAllChannels("").data    println("Channels: ${channels.map { "${it.name}(${it.channelId})" }}")    // View application rights allows to see all applications    // If rights were not accepted - the application can see only itself.    val applications = spaceClient.applications.getAllApplications("")    println("Applications: ${applications.map { "${it.name}(${it.id})" }}")    // Works only with View member profile rights    val profiles = spaceClient.teamDirectory.profiles.getAllProfiles().data    println("Profiles: ${profiles.map { "${it.username}(${it.id})" }}")    // Works only with View project parameters rights    // These rights can be managed for selected projects or for whole projects at one time.    val projects = spaceClient.projects.getAllProjects().data    println("Projects: ${projects.map { "${it.name}(${it.id})" }}")                // Works only with Project Issues: View issues rights    val issues = projects.firstOrNull()?.let { getProjectIssues(it) }    println("Issues: ${issues?.map { "${it.title}(${it.id})" }} ")}private suspend fun getProjectIssues(project: PR_Project): List<Issue> {    return spaceClient.projects.planning.issues.getAllIssues(        project = ProjectIdentifier.Id(project.id),        assigneeId = emptyList(),        statuses = emptyList(),        sorting = IssuesSorting.CREATED,        descending = true    ).data}
Output
Channels: [Booruchan(4UHs4I3yyno1), general(10xDLp0yqy4w), Habrachan(4HyHLw3SnO9Y), Sipichan(15A2hA1RpAsp)]Applications: [client(2czEkY3AIaV0), chatbot(d3Q8Z0UeVCF)]Profiles: [Makentoshe(2iqI4p3gzufl)]Projects: [Booruchan(qN0K31awqo6), Habrachan(1tJHqn2A76Yf), Sipichan(35BreB35gvdA)]Issues: [Add custom Run Configuration and support ngrok startup(3S09oT4JHvpC), Add Client template for Space plugin with Gradle (atQRe1SIklB), Add Blank template for Space application plugin support for Gradle(4REln04HAo5k)]

В зависимости от настроенных прав полученные данные могут отличаться. Стоит обратить внимание, что почти все данные возвращаются через .data. Изначально возвращается объект Batch, который используется для пагинации и содержит текущий набор данных, ключ к следующему набору, и общее количество элементов. При запросах стоит это учитывать, однако для простоты в данной статье пагинация опущена.

Исходники проекта доступны на github и со временем будут пополняться.

Chatbot + slash command Applications

Серверные Applications, по сравнению с клиентскими, требуют дополнительной подготовки. Первым делом нам нужно серверное приложение, на котором будет хоститься наш бот. Мы уже использовали HTTP клиент Ktor, так что можем создавать на нем и серверную часть. Создание конфигурационных файлов для Ktor здесь будет опущено, но есть документация, на которую можно опереться.

Когда мы поднимем наш сервер, мы сможем получить его URL, чтобы Space мог его использовать. Покупать хост, или искать бесплатный не комильфо, поэтому мы пойдем обходным путем - будем использовать туннельный сервис. В официальной документации используется ngrok, однако лично я предпочитаю localtunnel, из-за его возможности указывать постоянный адрес.

// localtunnelnpx localtunnel --port 8080 --subdomain makentoshe// ngrokngrok http 8080

В любом случае мы получим URL - это как раз то, что нам нужно.


Возвращаемся в настройки Applications в нашем Space. В табе Endpoint в поле Endpoint URI нужно будет указать наш url. Для меня это https://makentoshe.loca.lt/api/chatbot. Про то, зачем нужен /api/chatbot будет дальше.

В том же табе существует два способа верифицировать наши запросы:

  • Verification token - этот токен кладется в каждый запрос от Space. Нам остается сравнить эти токены, и если они совпадают - мы общаемся с нашим Space.

Пример ответа с Verification token
{      "className": "ListCommandsPayload",      "accessToken": "",      "verificationToken": "d415ca5965b37f4f0cac59fd33de7b94e396284e897d0fb8a070d0a5e1b7f2d3",      "userId": "2kawvQ4F6GM6"}
  • Signing key - более продвинутый метод. Для каждого запроса создается хеш, который кладется в его заголовок. Когда мы получаем запрос мы также вычисляем хеш, и если они совпадают - все ок. Подробнее об алгоритме - здесь.

Пример ответа с Signing key
POST /api/chatbot HTTP/1.1Host: 12345abcdef.ngrok.ioUser-Agent: Space (61355) Ktor http-clientContent-Length: 163Accept: */*Accept-Charset: UTF-8Content-Type: application/jsonX-Forwarded-For: 123.456.123.456X-Forwarded-Proto: httpsX-Space-Signature: 2aa8cba6217a28686de0ca8dcfe2a1d0795e343d744a0c5307308e43777593a5X-Space-Timestamp: 1607623492912Accept-Encoding: gzip{"className":"ListCommandsPayload","accessToken":"","verificationToken":"d415ca5965b37f4f0cac59fd33de7b94e396284e897d0fb8a070d0a5e1b7f2d3","userId":"2kawvQ4F6GM6"}

В качестве примера мы возьмем оба варианта и будем сверять и хеши, и токены.

object Endpoint {    const val verificationToken: String = TODO("Place your verification_token")    const val signingKey: String = TODO("Place your signing_key")    fun verify(payload: ApplicationPayload): Boolean {        return payload.verifyWithToken(verificationToken)    }    fun verify(timestamp: String, signature: String, body: String): Boolean {        val hmacSha256 = Mac.getInstance("HmacSHA256")        hmacSha256.init(SecretKeySpec(signingKey.toByteArray(), "HmacSHA256"))        val hash = hmacSha256.doFinal("$timestamp:$body".toByteArray()).toHexString()        return hash == signature    }    private fun ByteArray.toHexString() = joinToString("") { (0xFF and it.toInt()).toString(16).padStart(2, '0') }}

Первым делом для сервера нам нужно указать Routing. Это тот самый Endpoint для Space, который он будет использовать, чтобы обращаться к нашему боту.

@Suppress("unused") // Referenced in application.conf@kotlin.jvm.JvmOverloadsfun Application.module(testing: Boolean = false) {    install(Routing) {        chatbot()    }}fun Routing.chatbot() {    post("api/chatbot") {        val receiveBody = call.receiveText()        val timestamp = call.request.headers["x-space-timestamp"]            ?: return@post call.respond(HttpStatusCode.BadRequest)        val signature = call.request.headers["x-space-signature"]            ?: return@post call.respond(HttpStatusCode.BadRequest)        if (!Endpoint.verify(timestamp, signature, receiveBody)) {            return@post call.respond(HttpStatusCode.Unauthorized)        }        val payload = readPayload(receiveBody)        if (!Endpoint.verify(payload)) {            return@post call.respond(HttpStatusCode.Unauthorized)        }        try {            processChatbotPayload(payload)        } catch (unknownCommand: IllegalStateException) {            LoggerFactory.getLogger("Chatbot").error(unknownCommand.message)        }    }}

Как только к серверу происходит обращение по заданному адресу, первым делом мы проверяем, что запрос пришел именно от нашего Space. Если это не так - возвращаем 401 Unauthorized.

Далее боту следует обработать полученный payload. На момент написания статьи существует 5 имплементаций ApplicationPayload:

  • MessagePayload - передается нам, когда пользователь отправляет обычное сообщение.

  • ListCommandsPayload - передается нам, когда пользователь начинает сообщение с "/" и затем вводит команду посимвольно. Здесь происходит запрос все существующих команд, которые нам нужно будет вернуть в виде json. На ввод каждого символа передается новый Payload.

  • MessageActionPayload - передается, когда пользователь нажимает на интерактивный элемент сообщения, например, на кнопку. Корректно срабатывают только элементы из последнего сообщения. Все предыдущие элементы будут "стерты"(например, их actionId всегда будут пустой строкой).

  • MenuActionPayload - передается, когда пользователь нажимает на кастомный элемент на одном из меню (ProjectMenu, LocationMenu, ChannelMessageMenu, ChannelAttachmentMenu и т.д.). На момент написания статьи все еще не доступен.

  • ListMenuExtensionsPayload - имеет ту же идею, что и ListCommandsPayload, только для меню. На момент написания статьи все еще не доступен.

private suspend fun PipelineContext<*, ApplicationCall>.processChatbotPayload(payload: ApplicationPayload) {    when (payload) {        is MessagePayload -> {            processChatbotMessagePayload(payload)        }        is MessageActionPayload -> {            processChatbotMessageActionPayload(payload)        }        is ListCommandsPayload -> {            processChatbotListCommandsPayload(payload)        }    }}

Когда пользователь отправляет нам сообщение - скорее всего это команда, которую боту надо выполнить. Команду можно описать одним дата классом:

data class Command(    val name: String,    val info: String,    val action: suspend (payload: MessagePayload) -> Unit) {       fun toCommandDetail() = CommandDetail(name, info)}

Предлагаю в качестве примера реализовать три простых команды:

  • help - выводит сообщение со списком всех команд;

  • echo - выводит переданное сообщение обратно пользователю;

  • interactive <type> - выводит пример выбранного типа интерактивного элемента, например, button.

object Commands {    val help = Command(        "help",        "Show this help",    ) { payload ->        val context = HelpContext.from(payload)        printToChat(context, message {            section {                text(                    """Help message:                    name - Show this help                    ${echo.name} - ${echo.info}                    ${interactive.name} - ${interactive.info}                """.trimIndent()                )            }        })    }    val echo = Command(        "echo",        "Echoing the input string",    ) { payload ->        val context = EchoContext.from(payload)        val body = context.message.body        printToChat(context, message = if (body is ChatMessage.Text) {            message { section { text(body.text) } }        } else {            message { section { text("Skip the Block body") } }        })    }    val interactive = Command(        "interactive",        "Displaying available message interactive elements"    ) { payload ->       // TODO finish later    }    val list = listOf(help, echo, interactive)    val commands: Commands        get() = Commands(list.map { it.toCommandDetail() })}

Сначала разберемся, что происходит в командах help и echo. Из ApplicationPayload каждой команды мы можем извлечь нужные данные для обработки и положить их в соответствующий Context, который мы сами определяем. Этот класс помогает нам аккумулировать данные в одном месте.

Context.kt
interface UserContext {    val userId: String}fun userContext(applicationPayload: ApplicationPayload) = object: UserContext {    override val userId = applicationPayload.userId ?: throw IllegalArgumentException("Payload does not contains user id")}data class EchoContext(override val userId: String, val message: MessageContext) : UserContext {    companion object {        fun from(payload: ApplicationPayload): EchoContext? = when (payload) {            is MessagePayload -> from(payload)            else -> null        }        fun from(payload: MessagePayload): EchoContext {            return EchoContext(payload.userId, payload.message)        }    }}data class HelpContext(override val userId: String): UserContext {    companion object {        fun from(payload: ApplicationPayload): HelpContext {            return HelpContext(payload.userId!!)        }    }}data class InteractiveContext(override val userId: String): UserContext {    companion object {        fun from(payload: ApplicationPayload): InteractiveContext {            return InteractiveContext(payload.userId!!)        }    }}

После того, как мы получили наш userId и остальные сопутствующие данные, нам нужно их вывести в чат пользователя, с которым в данный момент взаимодействует бот. Для этого напишем отдельную функцию printToChat.

suspend fun printToChat(context: UserContext, message: ChatMessage) {    val member = ProfileIdentifier.Id(context.userId)    spaceClient.chats.messages.sendMessage(MessageRecipient.Member(member), message)}

Любое сообщение в Space представлено в виде класса ChatMessage. Этот класс является sealed и имеет 2 наследника: ChatMessage.Text и ChatMessage.Block.

ChatMessage.Text является простым текстовым сообщением поддерживающим markdown.

ChatMessage.Text(  """  **bold**  `code`  *italic*  @{2iqI4p3gzufl, Maksim Hvostov}   @{2iqI4p3gzufl, Makentoshe}   @{2iqI4p3gzufl, any string may be replaced with my name}   [\#general](im/group/10xDLp0yqy4w)   >quote  """.trimIndent())
Output

ChatMessage.Block существует для более сложных сообщений, которые могут быть разбиты на секции, иметь разделители, и другие продвинутые элементы форматирования.

Для него существует специальный DSL, который мы и используем. Всё начинается с функции message - это корень нашего сообщения, в котором мы можем указать:

  • MessageOutline - это дополнительная подпись под именем отправителя и, по желанию, иконка, которая задается через строку. Какой именно должна быть эта строка пока не понятно, поэтому вместо иконки передаем null.

  • MessageStyle - изменяет некоторые цвета в сообщении, в соответствии со стилем.

  • Для чего нужна messageData я пока так и не понял. Чтобы не было передано в сообщении нашему боту, это поле всегда будет null.

message {this.outline = MessageOutline(null, "Outline text")  this.style = MessageStyle.PRIMARYsection {this.text("Primary message")}}
Outputs

После этого мы можем либо определить новую секцию методом section, либо поставить разделитель методом divide.

В секции нам доступны:

  • обычное текстовое поле через метод text.

  • текстовое поле с тегом, для которого можно добавить отдельный стиль.

  • текстовое поле с изображением справа от секции.

  • текстовое поле с иконкой, в которую опять же передается строка.

  • footer и header.

  • конструкция поле значение через fields

  • интерактивные элементы, из которых на момент написания статьи доступна только кнопка.

Как это выглядит:

message {    this.outline = MessageOutline(null, "Outline text")    this.style = MessageStyle.PRIMARY    section {        this.header = "Section header"        this.footer = "Section footer"        this.text("Plain text")    }    divider()    section {        header = "This tag may indicate something not good"        this.textWithTag("Text with tag", "error tag", MessageStyle.ERROR)    }    section {        this.text("Plain text just to fill some space.")        this.textWithImage("Text with image", "https://www.jetbrains.com/space/img/feedback-section/video-preview.png")    }    section {        this.textWithIcon("Text with icon", "", MessageStyle.WARNING)    }    section {        header = "Fields"        this.fields {            this.field("field1", "value1")            this.field("field2", "value2")            this.field("field3", "value3")            this.field("field4", "value4")        }        this.controls {            this.button("Disabled button without any action", PostMessageAction("", ""), disabled = true)        }    }}
Output

Осталось реализовать команду interaction и соединить все вместе.

val interactive = Command(        "interactive",        "Displaying available message interactive elements"    ) { payload ->        val context = InteractiveContext.from(payload)        val arguments = payload.commandArguments()        if (arguments == null || arguments.isBlank()) {            return@Command printToChat(context, message {                section {                    text("Specify one of the selected ui elements:\nbutton")                }            })        }        printToChat(context, message {            section {                header = "Available message interactive elements"                controls {                    when (arguments) {                        "button" -> {                            val primaryAction = PostMessageAction("ButtonPrimaryActionId", "InteractiveButtonPayloadPrimary")                            button("Primary", primaryAction, MessageButtonStyle.PRIMARY)                            val secondaryAction = PostMessageAction("ButtonSecondaryActionId", "InteractiveButtonPayloadSecondary")                            button("Secondary", secondaryAction, MessageButtonStyle.SECONDARY)                            val regularAction = PostMessageAction("ButtonRegularActionId", "InteractiveButtonPayloadRegular")                            button("Regular", regularAction, MessageButtonStyle.REGULAR)                            val dangerAction = PostMessageAction("ButtonDangerActionId", "InteractiveButtonPayloadDanger")                            button("Danger", dangerAction, MessageButtonStyle.DANGER)                        }                    }                }            }        })    }

Теперь мы можем подключать обработку разных ApplicationPayload's. Для простого сообщения от пользователя мы ищем команду из списка и выполняем её.

private suspend fun PipelineContext<*, ApplicationCall>.processChatbotMessagePayload(payload: MessagePayload) {    Commands.list.find { it.name == payload.command() }?.action?.invoke(payload)        ?: return call.respond(HttpStatusCode.NotFound)    call.respond(HttpStatusCode.OK)}

Для показа списка команд мы возвращаем список всех команд. Здесь же мы используем подключенный ранее Jackson.

private suspend fun PipelineContext<*, ApplicationCall>.processChatbotListCommandsPayload(payload: ListCommandsPayload) {    call.respondText(ObjectMapper().writeValueAsString(Commands.commands), ContentType.Application.Json)}

Для нажатия на интерактивный элемент мы будем принтить actionId этой команды.

private suspend fun PipelineContext<*, ApplicationCall>.processChatbotMessageActionPayload(payload: MessageActionPayload) {    printToChat(userContext(payload), message { section { text(payload.actionId) } })    call.respond(HttpStatusCode.OK)}

Исходники чатбота доступны по ссылке на github.

Итого

Почти все, что может сделать пользователь используя Space через пользовательский интерфейс, можно сделать и через предоставленный Api(если на то будут предоставлены разрешения).

На самом деле у Space гораздо больше способов расширения функционала. Мы рассмотрели лишь самый продвинутый уровень. Большую часть остальных расширений можно настроить имея вообще минимальные навыки программирования.

Вот остальные способы расширения функционала платформы:

Источник: habr.com
К списку статей
Опубликовано: 22.03.2021 14:08:01
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Kotlin

Jetbrains space

Space

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru