Add support for slash commands (under Feature Flag) (#6482)
* Add support for slash commands * Update screenshots * Rename module `slash` to `slashcommands` * Rename `SlashCommand` to `SlashCommandService` * Introduce MsgType in order to send text message with a different msgtype value. * Format file and add parameter names, add default values and cleanup * Add isSupported parameter to filter out unsupported yet commands. * Slash commands: disable suggestions if the feature is disabled. * Fix sending shrug command. * Add missing test on SuggestionsProcessor * Add tests on MessageComposerPresenter about slash command. * Fix import ordering * Add missing tests on CommandExecutor * Add missing tests in MarkdownTextEditorStateTest * Slash commands: Improve code when sending message with prefix. * Slash commands: Add support for /unflip --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
parent
f08d1ed686
commit
a77662421c
65 changed files with 3038 additions and 86 deletions
|
|
@ -0,0 +1,233 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.slashcommands.impl
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
/**
|
||||
* Defines the command line operations.
|
||||
* The user can write these messages to perform some actions.
|
||||
* The list will be displayed in this order.
|
||||
*/
|
||||
enum class Command(
|
||||
val command: String,
|
||||
val aliases: List<String>? = null,
|
||||
val parameters: String? = null,
|
||||
@StringRes val description: Int,
|
||||
val isAllowedInThread: Boolean = true,
|
||||
val isSupported: Boolean = true,
|
||||
val isDevCommand: Boolean = false,
|
||||
) {
|
||||
CRASH_APP(
|
||||
command = "/crash",
|
||||
description = R.string.slash_command_description_crash_application,
|
||||
isDevCommand = true,
|
||||
),
|
||||
EMOTE(
|
||||
command = "/me",
|
||||
parameters = "<message>",
|
||||
description = R.string.slash_command_description_emote,
|
||||
),
|
||||
BAN_USER(
|
||||
command = "/ban",
|
||||
parameters = "<user-id> [reason]",
|
||||
description = R.string.slash_command_description_ban_user,
|
||||
),
|
||||
UNBAN_USER(
|
||||
command = "/unban",
|
||||
parameters = "<user-id> [reason]",
|
||||
description = R.string.slash_command_description_unban_user,
|
||||
),
|
||||
IGNORE_USER(
|
||||
command = "/ignore",
|
||||
parameters = "<user-id> [reason]",
|
||||
description = R.string.slash_command_description_ignore_user,
|
||||
),
|
||||
UNIGNORE_USER(
|
||||
command = "/unignore",
|
||||
parameters = "<user-id>",
|
||||
description = R.string.slash_command_description_unignore_user,
|
||||
),
|
||||
SET_USER_POWER_LEVEL(
|
||||
command = "/op",
|
||||
parameters = "<user-id> [<power-level>]",
|
||||
description = R.string.slash_command_description_op_user,
|
||||
isAllowedInThread = false,
|
||||
isSupported = false,
|
||||
),
|
||||
RESET_USER_POWER_LEVEL(
|
||||
command = "/deop",
|
||||
parameters = "<user-id>",
|
||||
description = R.string.slash_command_description_deop_user,
|
||||
isAllowedInThread = false,
|
||||
isSupported = false,
|
||||
),
|
||||
ROOM_NAME(
|
||||
command = "/roomname",
|
||||
parameters = "<name>",
|
||||
description = R.string.slash_command_description_room_name,
|
||||
isAllowedInThread = false,
|
||||
),
|
||||
INVITE(
|
||||
command = "/invite",
|
||||
parameters = "<user-id> [reason]",
|
||||
description = R.string.slash_command_description_invite_user,
|
||||
),
|
||||
JOIN_ROOM(
|
||||
command = "/join",
|
||||
aliases = listOf("/j", "/goto"),
|
||||
parameters = "<room-address> [reason]",
|
||||
description = R.string.slash_command_description_join_room,
|
||||
isAllowedInThread = false,
|
||||
isSupported = false,
|
||||
),
|
||||
TOPIC(
|
||||
command = "/topic",
|
||||
parameters = "<topic>",
|
||||
description = R.string.slash_command_description_topic,
|
||||
isAllowedInThread = false,
|
||||
),
|
||||
REMOVE_USER(
|
||||
command = "/remove",
|
||||
aliases = listOf("/kick"),
|
||||
parameters = "<user-id> [reason]",
|
||||
description = R.string.slash_command_description_remove_user,
|
||||
),
|
||||
CHANGE_DISPLAY_NAME(
|
||||
command = "/nick",
|
||||
parameters = "<display-name>",
|
||||
description = R.string.slash_command_description_nick,
|
||||
),
|
||||
CHANGE_DISPLAY_NAME_FOR_ROOM(
|
||||
command = "/myroomnick",
|
||||
aliases = listOf("/roomnick"),
|
||||
parameters = "<display-name>",
|
||||
description = R.string.slash_command_description_nick_for_room,
|
||||
isAllowedInThread = false,
|
||||
isSupported = false,
|
||||
),
|
||||
ROOM_AVATAR(
|
||||
command = "/roomavatar",
|
||||
parameters = "<mxc_url>",
|
||||
description = R.string.slash_command_description_room_avatar,
|
||||
isAllowedInThread = false,
|
||||
// Dev command since user has to know the mxc url
|
||||
isDevCommand = true,
|
||||
isSupported = false,
|
||||
),
|
||||
CHANGE_AVATAR_FOR_ROOM(
|
||||
command = "/myroomavatar",
|
||||
parameters = "<mxc_url>",
|
||||
description = R.string.slash_command_description_avatar_for_room,
|
||||
isAllowedInThread = false,
|
||||
// Dev command since user has to know the mxc url
|
||||
isDevCommand = true,
|
||||
isSupported = false,
|
||||
),
|
||||
RAINBOW(
|
||||
command = "/rainbow",
|
||||
parameters = "<message>",
|
||||
description = R.string.slash_command_description_rainbow,
|
||||
),
|
||||
RAINBOW_EMOTE(
|
||||
command = "/rainbowme",
|
||||
parameters = "<message>",
|
||||
description = R.string.slash_command_description_rainbow_emote,
|
||||
),
|
||||
DEVTOOLS(
|
||||
command = "/devtools",
|
||||
description = R.string.slash_command_description_devtools,
|
||||
isDevCommand = true,
|
||||
),
|
||||
SPOILER(
|
||||
command = "/spoiler",
|
||||
parameters = "<message>",
|
||||
description = R.string.slash_command_description_spoiler,
|
||||
),
|
||||
SHRUG(
|
||||
command = "/shrug",
|
||||
parameters = "<message>",
|
||||
description = R.string.slash_command_description_shrug,
|
||||
),
|
||||
LENNY(
|
||||
command = "/lenny",
|
||||
parameters = "<message>",
|
||||
description = R.string.slash_command_description_lenny,
|
||||
),
|
||||
PLAIN(
|
||||
command = "/plain",
|
||||
parameters = "<message>",
|
||||
description = R.string.slash_command_description_plain,
|
||||
),
|
||||
WHOIS(
|
||||
command = "/whois",
|
||||
parameters = "<user-id>",
|
||||
description = R.string.slash_command_description_whois,
|
||||
),
|
||||
DISCARD_SESSION(
|
||||
command = "/discardsession",
|
||||
description = R.string.slash_command_description_discard_session,
|
||||
isAllowedInThread = false,
|
||||
isSupported = false,
|
||||
),
|
||||
CONFETTI(
|
||||
command = "/confetti",
|
||||
parameters = "<message>",
|
||||
description = R.string.slash_command_confetti,
|
||||
isAllowedInThread = false,
|
||||
isSupported = false,
|
||||
),
|
||||
SNOWFALL(
|
||||
command = "/snowfall",
|
||||
parameters = "<message>",
|
||||
description = R.string.slash_command_snow,
|
||||
isAllowedInThread = false,
|
||||
isSupported = false,
|
||||
),
|
||||
LEAVE_ROOM(
|
||||
command = "/leave",
|
||||
aliases = listOf("/part"),
|
||||
description = R.string.slash_command_description_leave_room,
|
||||
isAllowedInThread = false,
|
||||
isDevCommand = true,
|
||||
),
|
||||
UPGRADE_ROOM(
|
||||
command = "/upgraderoom",
|
||||
parameters = "newVersion",
|
||||
description = R.string.slash_command_description_upgrade_room,
|
||||
isAllowedInThread = false,
|
||||
isDevCommand = true,
|
||||
isSupported = false,
|
||||
),
|
||||
TABLE_FLIP(
|
||||
command = "/tableflip",
|
||||
parameters = "<message>",
|
||||
description = R.string.slash_command_description_table_flip,
|
||||
),
|
||||
UNFLIP(
|
||||
command = "/unflip",
|
||||
parameters = "<message>",
|
||||
description = R.string.slash_command_description_unflip,
|
||||
);
|
||||
|
||||
val allAliases = listOf(command) + aliases.orEmpty()
|
||||
|
||||
/**
|
||||
* Checks if the input command matches any of the command aliases, ignoring case.
|
||||
* Do not exclude not supported commands so that user can discover that the command is not supported.
|
||||
* Used for whole command parsing.
|
||||
*/
|
||||
fun matches(inputCommand: CharSequence) = allAliases.any { it.contentEquals(inputCommand, true) }
|
||||
|
||||
/**
|
||||
* Checks if the input is a prefix of any of the command aliases, ignoring the first character (the slash), and excluding not supported command.
|
||||
* Used for suggestions.
|
||||
*/
|
||||
fun startsWith(input: CharSequence) = isSupported &&
|
||||
allAliases.any { it.startsWith(input, 1, true) }
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.slashcommands.impl
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.MsgType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.slashcommands.api.MessagePrefix
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||
import io.element.android.libraries.slashcommands.impl.rainbow.RainbowGenerator
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
|
||||
@Inject
|
||||
class CommandExecutor(
|
||||
private val matrixClient: MatrixClient,
|
||||
private val joinedRoom: JoinedRoom,
|
||||
private val rainbowGenerator: RainbowGenerator,
|
||||
private val stringProvider: StringProvider,
|
||||
) {
|
||||
suspend fun proceedSendMessage(
|
||||
slashCommand: SlashCommand.SlashCommandSendMessage,
|
||||
timeline: Timeline,
|
||||
): Result<Unit> {
|
||||
return when (slashCommand) {
|
||||
is SlashCommand.SendChatEffect -> sendChatEffect()
|
||||
is SlashCommand.SendEmote -> sendEmote(slashCommand, timeline)
|
||||
is SlashCommand.SendWithPrefix -> sendPrefixedMessage(slashCommand.prefix, slashCommand.message, timeline)
|
||||
is SlashCommand.SendPlainText -> sendPlainText(slashCommand, timeline)
|
||||
is SlashCommand.SendRainbow -> sendRainbow(slashCommand, timeline)
|
||||
is SlashCommand.SendRainbowEmote -> sendRainbowEmote(slashCommand, timeline)
|
||||
is SlashCommand.SendSpoiler -> sendSpoiler(slashCommand, timeline)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun proceedAdmin(
|
||||
slashCommand: SlashCommand.SlashCommandAdmin,
|
||||
): Result<Unit> {
|
||||
return when (slashCommand) {
|
||||
is SlashCommand.BanUser -> banUser(slashCommand)
|
||||
is SlashCommand.ChangeAvatarForRoom -> changeAvatarForRoom()
|
||||
is SlashCommand.ChangeDisplayName -> changeDisplayName(slashCommand)
|
||||
is SlashCommand.ChangeDisplayNameForRoom -> changeDisplayNameForRoom()
|
||||
is SlashCommand.ChangeRoomAvatar -> changeRoomAvatar()
|
||||
is SlashCommand.ChangeRoomName -> changeRoomName(slashCommand)
|
||||
is SlashCommand.ChangeTopic -> changeTopic(slashCommand)
|
||||
is SlashCommand.DiscardSession -> discardSession()
|
||||
is SlashCommand.IgnoreUser -> ignoreUser(slashCommand)
|
||||
is SlashCommand.Invite -> invite(slashCommand)
|
||||
is SlashCommand.JoinRoom -> joinRoom(slashCommand)
|
||||
is SlashCommand.LeaveRoom -> leaveRoom(joinedRoom)
|
||||
is SlashCommand.RemoveUser -> removeUser(slashCommand)
|
||||
is SlashCommand.SetUserPowerLevel -> setUserPowerLevel()
|
||||
is SlashCommand.UnbanUser -> unbanUser(slashCommand)
|
||||
is SlashCommand.UnignoreUser -> unignoreUser(slashCommand)
|
||||
is SlashCommand.UpgradeRoom -> upgradeRoom()
|
||||
}
|
||||
}
|
||||
|
||||
private fun upgradeRoom(): Result<Unit> {
|
||||
return Result.failure(Exception("Not yet implemented"))
|
||||
}
|
||||
|
||||
private suspend fun unignoreUser(slashCommand: SlashCommand.UnignoreUser): Result<Unit> {
|
||||
return matrixClient.unignoreUser(slashCommand.userId)
|
||||
}
|
||||
|
||||
private suspend fun unbanUser(slashCommand: SlashCommand.UnbanUser): Result<Unit> {
|
||||
return joinedRoom.unbanUser(slashCommand.userId, slashCommand.reason)
|
||||
}
|
||||
|
||||
private fun setUserPowerLevel(): Result<Unit> {
|
||||
return Result.failure(Exception("Not yet implemented"))
|
||||
}
|
||||
|
||||
private suspend fun sendSpoiler(slashCommand: SlashCommand.SendSpoiler, timeline: Timeline): Result<Unit> {
|
||||
val text = "[${stringProvider.getString(R.string.common_spoiler)}](${slashCommand.message})"
|
||||
val formattedText = "<span data-mx-spoiler>${slashCommand.message}</span>"
|
||||
return timeline.sendMessage(
|
||||
body = text,
|
||||
htmlBody = formattedText,
|
||||
intentionalMentions = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun sendRainbowEmote(slashCommand: SlashCommand.SendRainbowEmote, timeline: Timeline): Result<Unit> {
|
||||
val message = slashCommand.message.toString()
|
||||
return timeline.sendMessage(
|
||||
body = message,
|
||||
htmlBody = rainbowGenerator.generate(message),
|
||||
msgType = MsgType.MSG_TYPE_EMOTE,
|
||||
intentionalMentions = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun sendRainbow(slashCommand: SlashCommand.SendRainbow, timeline: Timeline): Result<Unit> {
|
||||
val message = slashCommand.message.toString()
|
||||
return timeline.sendMessage(
|
||||
body = message,
|
||||
htmlBody = rainbowGenerator.generate(message),
|
||||
intentionalMentions = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun sendPlainText(slashCommand: SlashCommand.SendPlainText, timeline: Timeline): Result<Unit> {
|
||||
return timeline.sendMessage(
|
||||
body = slashCommand.message.toString(),
|
||||
htmlBody = null,
|
||||
intentionalMentions = emptyList(),
|
||||
asPlainText = true,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun sendEmote(slashCommand: SlashCommand.SendEmote, timeline: Timeline): Result<Unit> {
|
||||
val message = slashCommand.message.toString()
|
||||
return timeline.sendMessage(
|
||||
body = message,
|
||||
htmlBody = null,
|
||||
msgType = MsgType.MSG_TYPE_EMOTE,
|
||||
intentionalMentions = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendChatEffect(): Result<Unit> {
|
||||
return Result.failure(Exception("Not yet implemented"))
|
||||
}
|
||||
|
||||
private suspend fun removeUser(slashCommand: SlashCommand.RemoveUser): Result<Unit> {
|
||||
return joinedRoom.kickUser(slashCommand.userId, slashCommand.reason)
|
||||
}
|
||||
|
||||
private suspend fun leaveRoom(
|
||||
room: JoinedRoom,
|
||||
): Result<Unit> {
|
||||
return room.leave()
|
||||
}
|
||||
|
||||
private suspend fun joinRoom(slashCommand: SlashCommand.JoinRoom): Result<Unit> {
|
||||
return matrixClient.joinRoomByIdOrAlias(slashCommand.roomIdOrAlias, emptyList())
|
||||
.map {}
|
||||
}
|
||||
|
||||
private suspend fun invite(slashCommand: SlashCommand.Invite): Result<Unit> {
|
||||
return joinedRoom.inviteUserById(slashCommand.userId)
|
||||
}
|
||||
|
||||
private suspend fun ignoreUser(slashCommand: SlashCommand.IgnoreUser): Result<Unit> {
|
||||
return matrixClient.ignoreUser(slashCommand.userId)
|
||||
}
|
||||
|
||||
private fun discardSession(): Result<Unit> {
|
||||
return Result.failure(Exception("Not yet implemented"))
|
||||
}
|
||||
|
||||
private suspend fun changeTopic(slashCommand: SlashCommand.ChangeTopic): Result<Unit> {
|
||||
return joinedRoom.setTopic(slashCommand.topic)
|
||||
}
|
||||
|
||||
private suspend fun changeRoomName(slashCommand: SlashCommand.ChangeRoomName): Result<Unit> {
|
||||
return joinedRoom.setName(slashCommand.name)
|
||||
}
|
||||
|
||||
private fun changeRoomAvatar(): Result<Unit> {
|
||||
return Result.failure(Exception("Not yet implemented"))
|
||||
}
|
||||
|
||||
private fun changeDisplayNameForRoom(): Result<Unit> {
|
||||
return Result.failure(Exception("Not yet implemented"))
|
||||
}
|
||||
|
||||
private suspend fun changeDisplayName(slashCommand: SlashCommand.ChangeDisplayName): Result<Unit> {
|
||||
return matrixClient.setDisplayName(slashCommand.displayName)
|
||||
}
|
||||
|
||||
private fun changeAvatarForRoom(): Result<Unit> {
|
||||
return Result.failure(Exception("Not yet implemented"))
|
||||
}
|
||||
|
||||
private suspend fun banUser(slashCommand: SlashCommand.BanUser): Result<Unit> {
|
||||
return joinedRoom.banUser(slashCommand.userId, slashCommand.reason)
|
||||
}
|
||||
|
||||
private suspend fun sendPrefixedMessage(
|
||||
prefix: MessagePrefix,
|
||||
message: CharSequence,
|
||||
timeline: Timeline,
|
||||
): Result<Unit> {
|
||||
val sequence = buildString {
|
||||
append(prefix.toMarkdown())
|
||||
if (message.isNotEmpty()) {
|
||||
append(" ")
|
||||
append(message)
|
||||
}
|
||||
}
|
||||
return timeline.sendMessage(
|
||||
body = sequence,
|
||||
htmlBody = null,
|
||||
intentionalMentions = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MessagePrefix.toMarkdown() = when (this) {
|
||||
MessagePrefix.Shrug -> "¯\\\\_(ツ)\\_/¯"
|
||||
MessagePrefix.TableFlip -> "(╯°□°)╯︵ ┻━┻"
|
||||
MessagePrefix.Unflip -> "┬──┬ ノ( ゜-゜ノ)"
|
||||
MessagePrefix.Lenny -> "( ͡° ͜ʖ ͡°)"
|
||||
}
|
||||
|
|
@ -0,0 +1,430 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.slashcommands.impl
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.MatrixPatterns
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.mxc.isMxcUrl
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.slashcommands.api.ChatEffect
|
||||
import io.element.android.libraries.slashcommands.api.MessagePrefix
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import kotlinx.coroutines.flow.first
|
||||
import timber.log.Timber
|
||||
|
||||
@Inject
|
||||
class CommandParser(
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val stringProvider: StringProvider,
|
||||
) {
|
||||
/**
|
||||
* Convert the text message into a Slash command.
|
||||
*
|
||||
* @param textMessage the text message in plain text
|
||||
* @param formattedMessage the text messaged in HTML format
|
||||
* @param isInThreadTimeline true if the user is currently typing in a thread
|
||||
* @return a parsed slash command (ok or error)
|
||||
*/
|
||||
suspend fun parseSlashCommand(
|
||||
textMessage: CharSequence,
|
||||
formattedMessage: String?,
|
||||
isInThreadTimeline: Boolean,
|
||||
): SlashCommand {
|
||||
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SlashCommand)) {
|
||||
return SlashCommand.NotACommand
|
||||
}
|
||||
// check if it has the Slash marker
|
||||
val message = formattedMessage ?: textMessage
|
||||
return if (!message.startsWith("/")) {
|
||||
SlashCommand.NotACommand
|
||||
} else {
|
||||
// "/" only
|
||||
if (message.length == 1) {
|
||||
return SlashCommand.ErrorEmptySlashCommand(
|
||||
stringProvider.getString(R.string.slash_command_unrecognized, "/")
|
||||
)
|
||||
}
|
||||
// Exclude "//"
|
||||
if ("/" == message.substring(1, 2)) {
|
||||
return SlashCommand.NotACommand
|
||||
}
|
||||
val (messageParts, message) = extractMessage(message.toString())
|
||||
?: return SlashCommand.ErrorEmptySlashCommand(
|
||||
stringProvider.getString(R.string.slash_command_unrecognized, "/")
|
||||
)
|
||||
val slashCommand = messageParts.first()
|
||||
getNotSupportedByThreads(isInThreadTimeline, slashCommand)?.let {
|
||||
return SlashCommand.ErrorCommandNotSupportedInThreads(
|
||||
stringProvider.getString(
|
||||
R.string.slash_command_not_supported_in_threads,
|
||||
it.command,
|
||||
)
|
||||
)
|
||||
}
|
||||
when {
|
||||
Command.PLAIN.matches(slashCommand) -> {
|
||||
if (message.isNotEmpty()) {
|
||||
SlashCommand.SendPlainText(message = message)
|
||||
} else {
|
||||
syntaxError(Command.PLAIN)
|
||||
}
|
||||
}
|
||||
Command.CHANGE_DISPLAY_NAME.matches(slashCommand) -> {
|
||||
if (message.isNotEmpty()) {
|
||||
SlashCommand.ChangeDisplayName(displayName = message)
|
||||
} else {
|
||||
syntaxError(Command.CHANGE_DISPLAY_NAME)
|
||||
}
|
||||
}
|
||||
Command.CHANGE_DISPLAY_NAME_FOR_ROOM.matches(slashCommand) -> {
|
||||
if (message.isNotEmpty()) {
|
||||
SlashCommand.ChangeDisplayNameForRoom(displayName = message)
|
||||
} else {
|
||||
syntaxError(Command.CHANGE_DISPLAY_NAME_FOR_ROOM)
|
||||
}
|
||||
}
|
||||
Command.ROOM_AVATAR.matches(slashCommand) -> {
|
||||
if (messageParts.size == 2) {
|
||||
val url = messageParts[1]
|
||||
if (url.isMxcUrl()) {
|
||||
SlashCommand.ChangeRoomAvatar(url)
|
||||
} else {
|
||||
syntaxError(Command.ROOM_AVATAR)
|
||||
}
|
||||
} else {
|
||||
syntaxError(Command.ROOM_AVATAR)
|
||||
}
|
||||
}
|
||||
Command.CHANGE_AVATAR_FOR_ROOM.matches(slashCommand) -> {
|
||||
if (messageParts.size == 2) {
|
||||
val url = messageParts[1]
|
||||
|
||||
if (url.isMxcUrl()) {
|
||||
SlashCommand.ChangeAvatarForRoom(url)
|
||||
} else {
|
||||
syntaxError(Command.CHANGE_AVATAR_FOR_ROOM)
|
||||
}
|
||||
} else {
|
||||
syntaxError(Command.CHANGE_AVATAR_FOR_ROOM)
|
||||
}
|
||||
}
|
||||
Command.TOPIC.matches(slashCommand) -> {
|
||||
if (message.isNotEmpty()) {
|
||||
SlashCommand.ChangeTopic(topic = message)
|
||||
} else {
|
||||
syntaxError(Command.TOPIC)
|
||||
}
|
||||
}
|
||||
Command.EMOTE.matches(slashCommand) -> {
|
||||
if (message.isNotEmpty()) {
|
||||
SlashCommand.SendEmote(message)
|
||||
} else {
|
||||
syntaxError(Command.EMOTE)
|
||||
}
|
||||
}
|
||||
Command.RAINBOW.matches(slashCommand) -> {
|
||||
if (message.isNotEmpty()) {
|
||||
SlashCommand.SendRainbow(message)
|
||||
} else {
|
||||
syntaxError(Command.RAINBOW)
|
||||
}
|
||||
}
|
||||
Command.RAINBOW_EMOTE.matches(slashCommand) -> {
|
||||
if (message.isNotEmpty()) {
|
||||
SlashCommand.SendRainbowEmote(message)
|
||||
} else {
|
||||
syntaxError(Command.RAINBOW_EMOTE)
|
||||
}
|
||||
}
|
||||
Command.JOIN_ROOM.matches(slashCommand) -> {
|
||||
if (messageParts.size >= 2) {
|
||||
val id = messageParts[1]
|
||||
val roomIdOrAlias = RoomIdOrAlias.from(id)
|
||||
if (roomIdOrAlias != null) {
|
||||
SlashCommand.JoinRoom(
|
||||
RoomIdOrAlias.Id(RoomId(id)),
|
||||
trimParts(textMessage, messageParts.take(2))
|
||||
)
|
||||
} else {
|
||||
syntaxError(Command.JOIN_ROOM)
|
||||
}
|
||||
} else {
|
||||
syntaxError(Command.JOIN_ROOM)
|
||||
}
|
||||
}
|
||||
Command.ROOM_NAME.matches(slashCommand) -> {
|
||||
if (message.isNotEmpty()) {
|
||||
SlashCommand.ChangeRoomName(name = message)
|
||||
} else {
|
||||
syntaxError(Command.ROOM_NAME)
|
||||
}
|
||||
}
|
||||
Command.INVITE.matches(slashCommand) -> {
|
||||
if (messageParts.size >= 2) {
|
||||
parseUserId(messageParts)
|
||||
?.let { userId ->
|
||||
SlashCommand.Invite(
|
||||
userId = userId,
|
||||
reason = trimParts(textMessage, messageParts.take(2))
|
||||
)
|
||||
}
|
||||
?: syntaxError(Command.INVITE)
|
||||
} else {
|
||||
syntaxError(Command.INVITE)
|
||||
}
|
||||
}
|
||||
Command.REMOVE_USER.matches(slashCommand) -> {
|
||||
parseUserId(messageParts)
|
||||
?.let { userId ->
|
||||
SlashCommand.RemoveUser(
|
||||
userId = userId,
|
||||
reason = trimParts(textMessage, messageParts.take(2))
|
||||
)
|
||||
}
|
||||
?: syntaxError(Command.REMOVE_USER)
|
||||
}
|
||||
Command.BAN_USER.matches(slashCommand) -> {
|
||||
parseUserId(messageParts)
|
||||
?.let { userId ->
|
||||
SlashCommand.BanUser(
|
||||
userId = userId,
|
||||
reason = trimParts(textMessage, messageParts.take(2))
|
||||
)
|
||||
}
|
||||
?: syntaxError(Command.BAN_USER)
|
||||
}
|
||||
Command.UNBAN_USER.matches(slashCommand) -> {
|
||||
parseUserId(messageParts)
|
||||
?.let { userId ->
|
||||
SlashCommand.UnbanUser(
|
||||
userId = userId,
|
||||
reason = trimParts(textMessage, messageParts.take(2))
|
||||
)
|
||||
}
|
||||
?: syntaxError(Command.UNBAN_USER)
|
||||
}
|
||||
Command.IGNORE_USER.matches(slashCommand) -> {
|
||||
parseUserId(messageParts)
|
||||
?.let { userId ->
|
||||
SlashCommand.IgnoreUser(
|
||||
userId = userId,
|
||||
)
|
||||
}
|
||||
?: syntaxError(Command.IGNORE_USER)
|
||||
}
|
||||
Command.UNIGNORE_USER.matches(slashCommand) -> {
|
||||
parseUserId(messageParts)
|
||||
?.let { userId ->
|
||||
SlashCommand.UnignoreUser(
|
||||
userId = userId,
|
||||
)
|
||||
}
|
||||
?: syntaxError(Command.UNIGNORE_USER)
|
||||
}
|
||||
Command.SET_USER_POWER_LEVEL.matches(slashCommand) -> {
|
||||
if (messageParts.size == 3) {
|
||||
val userId = parseUserId(messageParts)
|
||||
if (userId != null) {
|
||||
val powerLevelsAsString = messageParts[2]
|
||||
try {
|
||||
val powerLevelsAsInt = Integer.parseInt(powerLevelsAsString)
|
||||
SlashCommand.SetUserPowerLevel(
|
||||
userId = userId,
|
||||
powerLevel = powerLevelsAsInt
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
syntaxError(Command.SET_USER_POWER_LEVEL)
|
||||
}
|
||||
} else {
|
||||
syntaxError(Command.SET_USER_POWER_LEVEL)
|
||||
}
|
||||
} else {
|
||||
syntaxError(Command.SET_USER_POWER_LEVEL)
|
||||
}
|
||||
}
|
||||
Command.RESET_USER_POWER_LEVEL.matches(slashCommand) -> {
|
||||
parseUserId(messageParts)
|
||||
?.let { userId ->
|
||||
SlashCommand.SetUserPowerLevel(
|
||||
userId = userId,
|
||||
powerLevel = null
|
||||
)
|
||||
}
|
||||
?: syntaxError(Command.SET_USER_POWER_LEVEL)
|
||||
}
|
||||
Command.DEVTOOLS.matches(slashCommand) -> {
|
||||
if (messageParts.size == 1) {
|
||||
SlashCommand.DevTools
|
||||
} else {
|
||||
syntaxError(Command.DEVTOOLS)
|
||||
}
|
||||
}
|
||||
Command.SPOILER.matches(slashCommand) -> {
|
||||
if (message.isNotEmpty()) {
|
||||
SlashCommand.SendSpoiler(message)
|
||||
} else {
|
||||
syntaxError(Command.SPOILER)
|
||||
}
|
||||
}
|
||||
Command.SHRUG.matches(slashCommand) -> {
|
||||
SlashCommand.SendWithPrefix(MessagePrefix.Shrug, message)
|
||||
}
|
||||
Command.LENNY.matches(slashCommand) -> {
|
||||
SlashCommand.SendWithPrefix(MessagePrefix.Lenny, message)
|
||||
}
|
||||
Command.TABLE_FLIP.matches(slashCommand) -> {
|
||||
SlashCommand.SendWithPrefix(MessagePrefix.TableFlip, message)
|
||||
}
|
||||
Command.UNFLIP.matches(slashCommand) -> {
|
||||
SlashCommand.SendWithPrefix(MessagePrefix.Unflip, message)
|
||||
}
|
||||
Command.DISCARD_SESSION.matches(slashCommand) -> {
|
||||
if (messageParts.size == 1) {
|
||||
SlashCommand.DiscardSession
|
||||
} else {
|
||||
syntaxError(Command.DISCARD_SESSION)
|
||||
}
|
||||
}
|
||||
Command.WHOIS.matches(slashCommand) -> {
|
||||
parseUserId(messageParts)
|
||||
?.let { userId ->
|
||||
SlashCommand.ShowUser(
|
||||
userId = userId,
|
||||
)
|
||||
}
|
||||
?: syntaxError(Command.WHOIS)
|
||||
}
|
||||
Command.CONFETTI.matches(slashCommand) -> {
|
||||
SlashCommand.SendChatEffect(ChatEffect.CONFETTI, message)
|
||||
}
|
||||
Command.SNOWFALL.matches(slashCommand) -> {
|
||||
SlashCommand.SendChatEffect(ChatEffect.SNOWFALL, message)
|
||||
}
|
||||
Command.LEAVE_ROOM.matches(slashCommand) -> {
|
||||
if (messageParts.size == 1) {
|
||||
SlashCommand.LeaveRoom
|
||||
} else {
|
||||
syntaxError(Command.LEAVE_ROOM)
|
||||
}
|
||||
}
|
||||
Command.UPGRADE_ROOM.matches(slashCommand) -> {
|
||||
if (message.isNotEmpty()) {
|
||||
SlashCommand.UpgradeRoom(newVersion = message)
|
||||
} else {
|
||||
syntaxError(Command.UPGRADE_ROOM)
|
||||
}
|
||||
}
|
||||
Command.CRASH_APP.matches(slashCommand) && appPreferencesStore.isDeveloperModeEnabledFlow().first() -> {
|
||||
error("Application crashed from user demand")
|
||||
}
|
||||
else -> {
|
||||
// Unknown command
|
||||
SlashCommand.ErrorUnknownSlashCommand(
|
||||
stringProvider.getString(R.string.slash_command_unrecognized, slashCommand)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseUserId(messageParts: List<String>): UserId? {
|
||||
val str = messageParts.getOrNull(1) ?: return null
|
||||
return when {
|
||||
MatrixPatterns.isUserId(str) -> str
|
||||
str == "<a" -> {
|
||||
// Rich text editor mode
|
||||
messageParts.getOrNull(2)?.let { html ->
|
||||
// html must match "href="https://matrix.to/#/@user:domain.org">@user:domain.org</a>"
|
||||
val regex = "href=\"https://matrix.to/#/([^\"]+)\">([^<]+)</a>".toRegex()
|
||||
val matchResult = regex.find(html)
|
||||
val userId = matchResult?.groupValues?.getOrNull(1)
|
||||
userId?.takeIf {
|
||||
userId == matchResult.groupValues.getOrNull(2) && MatrixPatterns.isUserId(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Can be markdown format like "[@user:domain.org](https://matrix.to/#/@user:domain.org)"
|
||||
val regex = "\\[([^\\]]+)]\\(https://matrix.to/#/([^\\]]+)\\)".toRegex()
|
||||
val matchResult = regex.find(str)
|
||||
val userId = matchResult?.groupValues?.getOrNull(1)
|
||||
userId?.takeIf {
|
||||
userId == matchResult.groupValues.getOrNull(2) && MatrixPatterns.isUserId(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
?.let(::UserId)
|
||||
}
|
||||
|
||||
private fun syntaxError(command: Command) = SlashCommand.ErrorSyntax(
|
||||
stringProvider.getString(
|
||||
R.string.slash_command_parameters_error,
|
||||
command.command,
|
||||
buildString {
|
||||
append(command.command)
|
||||
if (command.parameters != null) {
|
||||
append(" ${command.parameters}")
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
private fun extractMessage(message: String): Pair<List<String>, String>? {
|
||||
val messageParts = try {
|
||||
message.split("\\s+".toRegex()).dropLastWhile { it.isEmpty() }
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "## parseSlashCommand() : split failed")
|
||||
null
|
||||
}
|
||||
|
||||
// test if the string cut fails
|
||||
if (messageParts.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val slashCommand = messageParts.first()
|
||||
val trimmedMessage = message.substring(slashCommand.length).trim()
|
||||
|
||||
return messageParts to trimmedMessage
|
||||
}
|
||||
|
||||
private val notSupportedThreadsCommands: List<Command> by lazy {
|
||||
Command.entries.filter {
|
||||
!it.isAllowedInThread
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the current command is not supported by threads.
|
||||
* @param isInThreadTimeline if its true we are in a thread timeline
|
||||
* @param slashCommand the slash command that will be checked
|
||||
* @return The command that is not supported
|
||||
*/
|
||||
private fun getNotSupportedByThreads(isInThreadTimeline: Boolean, slashCommand: String): Command? {
|
||||
return if (isInThreadTimeline) {
|
||||
notSupportedThreadsCommands.firstOrNull {
|
||||
it.command == slashCommand
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun trimParts(message: CharSequence, messageParts: List<String>): String? {
|
||||
val partsSize = messageParts.sumOf { it.length }
|
||||
val gapsNumber = messageParts.size - 1
|
||||
return message.substring(partsSize + gapsNumber).trim().takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.slashcommands.impl
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommandService
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
@ContributesBinding(RoomScope::class)
|
||||
class DefaultSlashCommandService(
|
||||
private val commandParser: CommandParser,
|
||||
private val commandExecutor: CommandExecutor,
|
||||
private val stringProvider: StringProvider,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : SlashCommandService {
|
||||
override suspend fun getSuggestions(
|
||||
text: String,
|
||||
isInThread: Boolean,
|
||||
): List<SlashCommandSuggestion> {
|
||||
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SlashCommand)) return emptyList()
|
||||
val isDeveloperModeEnabled = appPreferencesStore.isDeveloperModeEnabledFlow().first()
|
||||
return Command.entries.filter {
|
||||
it.startsWith(text)
|
||||
}.filter {
|
||||
!isInThread || it.isAllowedInThread
|
||||
}.filter {
|
||||
!it.isDevCommand || isDeveloperModeEnabled
|
||||
}.map {
|
||||
SlashCommandSuggestion(
|
||||
command = it.command,
|
||||
parameters = it.parameters,
|
||||
description = stringProvider.getString(it.description),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun parse(
|
||||
textMessage: CharSequence,
|
||||
formattedMessage: String?,
|
||||
isInThreadTimeline: Boolean,
|
||||
): SlashCommand {
|
||||
return commandParser.parseSlashCommand(
|
||||
textMessage = textMessage,
|
||||
formattedMessage = formattedMessage,
|
||||
isInThreadTimeline = isInThreadTimeline,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun proceedSendMessage(
|
||||
slashCommand: SlashCommand.SlashCommandSendMessage,
|
||||
timeline: Timeline,
|
||||
): Result<Unit> {
|
||||
return commandExecutor.proceedSendMessage(
|
||||
slashCommand = slashCommand,
|
||||
timeline = timeline,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun proceedAdmin(
|
||||
slashCommand: SlashCommand.SlashCommandAdmin,
|
||||
): Result<Unit> {
|
||||
return commandExecutor.proceedAdmin(
|
||||
slashCommand = slashCommand,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.slashcommands.impl.rainbow
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Inspired from React-Sdk
|
||||
* Ref: https://github.com/matrix-org/matrix-react-sdk/blob/develop/src/utils/colour.js
|
||||
*/
|
||||
@Inject
|
||||
class RainbowGenerator {
|
||||
fun generate(text: String): String {
|
||||
val split = text.splitEmoji()
|
||||
val frequency = 2 * Math.PI / split.size
|
||||
|
||||
return split
|
||||
.mapIndexed { idx, letter ->
|
||||
// Do better than React-Sdk: Avoid adding font color for spaces
|
||||
if (letter == " ") {
|
||||
"$letter"
|
||||
} else {
|
||||
val (a, b) = generateAB(idx * frequency, 1f)
|
||||
val dashColor = labToRGB(75, a, b).toDashColor()
|
||||
"<font color=\"$dashColor\">$letter</font>"
|
||||
}
|
||||
}
|
||||
.joinToString(separator = "")
|
||||
}
|
||||
|
||||
private fun generateAB(hue: Double, chroma: Float): Pair<Double, Double> {
|
||||
val a = chroma * 127 * cos(hue)
|
||||
val b = chroma * 127 * sin(hue)
|
||||
|
||||
return Pair(a, b)
|
||||
}
|
||||
|
||||
private fun labToRGB(l: Int, a: Double, b: Double): RgbColor {
|
||||
// Convert CIELAB to CIEXYZ (D65)
|
||||
var y = (l + 16) / 116.0
|
||||
val x = adjustXYZ(y + a / 500) * 0.9505
|
||||
val z = adjustXYZ(y - b / 200) * 1.0890
|
||||
|
||||
y = adjustXYZ(y)
|
||||
|
||||
// Linear transformation from CIEXYZ to RGB
|
||||
val red = 3.24096994 * x - 1.53738318 * y - 0.49861076 * z
|
||||
val green = -0.96924364 * x + 1.8759675 * y + 0.04155506 * z
|
||||
val blue = 0.05563008 * x - 0.20397696 * y + 1.05697151 * z
|
||||
|
||||
return RgbColor(adjustRGB(red), adjustRGB(green), adjustRGB(blue))
|
||||
}
|
||||
|
||||
private fun adjustXYZ(value: Double): Double {
|
||||
if (value > 0.2069) {
|
||||
return value.pow(3)
|
||||
}
|
||||
return 0.1284 * value - 0.01771
|
||||
}
|
||||
|
||||
private fun gammaCorrection(value: Double): Double {
|
||||
// Non-linear transformation to sRGB
|
||||
if (value <= 0.0031308) {
|
||||
return 12.92 * value
|
||||
}
|
||||
return 1.055 * value.pow(1 / 2.4) - 0.055
|
||||
}
|
||||
|
||||
private fun adjustRGB(value: Double): Int {
|
||||
return (gammaCorrection(value)
|
||||
.coerceIn(0.0, 1.0) * 255)
|
||||
.roundToInt()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as split, but considering emojis.
|
||||
*/
|
||||
private fun CharSequence.splitEmoji(): List<CharSequence> {
|
||||
val result = mutableListOf<CharSequence>()
|
||||
var index = 0
|
||||
while (index < length) {
|
||||
val firstChar = get(index)
|
||||
if (firstChar.code == 0x200e) {
|
||||
// Left to right mark. What should I do with it?
|
||||
} else if (firstChar.code in 0xD800..0xDBFF && index + 1 < length) {
|
||||
// We have the start of a surrogate pair
|
||||
val secondChar = get(index + 1)
|
||||
if (secondChar.code in 0xDC00..0xDFFF) {
|
||||
// We have an emoji
|
||||
result.add("$firstChar$secondChar")
|
||||
index++
|
||||
} else {
|
||||
// Not sure what we have here...
|
||||
result.add("$firstChar")
|
||||
}
|
||||
} else {
|
||||
// Regular char
|
||||
result.add("$firstChar")
|
||||
}
|
||||
index++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.slashcommands.impl.rainbow
|
||||
|
||||
data class RgbColor(
|
||||
val r: Int,
|
||||
val g: Int,
|
||||
val b: Int
|
||||
)
|
||||
|
||||
fun RgbColor.toDashColor(): String {
|
||||
return listOf(r, g, b)
|
||||
.joinToString(separator = "", prefix = "#") {
|
||||
it.toString(16).padStart(2, '0')
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright (c) 2026 Element Creations Ltd.
|
||||
~
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
~ Please see LICENSE files in the repository root for full details.
|
||||
-->
|
||||
<resources>
|
||||
<string name="slash_command_error">Command error</string>
|
||||
<string name="slash_command_unrecognized">Unrecognized command: %1$s</string>
|
||||
<string name="slash_command_parameters_error">The command \"%1$s\" needs more parameters, or some parameters are incorrect.The syntax is\n\n%2$s</string>
|
||||
<string name="slash_command_not_supported_in_threads">The command \"%1$s\" is recognized but not supported in threads.</string>
|
||||
<string name="slash_command_description_emote">Displays action</string>
|
||||
<string name="slash_command_description_crash_application">Crash the application.</string>
|
||||
<string name="slash_command_description_ban_user">Bans user with given id</string>
|
||||
<string name="slash_command_description_unban_user">Unbans user with given id</string>
|
||||
<string name="slash_command_description_ignore_user">Ignores a user, hiding their messages from you</string>
|
||||
<string name="slash_command_description_unignore_user">Stops ignoring a user, showing their messages going forward</string>
|
||||
<string name="slash_command_description_op_user">Define the power level of a user</string>
|
||||
<string name="slash_command_description_deop_user">Deops user with given id</string>
|
||||
<string name="slash_command_description_room_name">Sets the room name</string>
|
||||
<string name="slash_command_description_rainbow">Sends the given message colored as a rainbow</string>
|
||||
<string name="slash_command_description_rainbow_emote">Sends the given emote colored as a rainbow</string>
|
||||
<string name="slash_command_description_invite_user">Invites user with given id to current room</string>
|
||||
<string name="slash_command_description_join_room">Joins room with given address</string>
|
||||
<string name="slash_command_description_spoiler">Sends the given message as a spoiler</string>
|
||||
<string name="slash_command_description_topic">Set the room topic</string>
|
||||
<string name="slash_command_description_remove_user">Removes user with given id from this room</string>
|
||||
<string name="slash_command_description_nick">Changes your display nickname</string>
|
||||
<string name="slash_command_confetti">Sends the given message with confetti</string>
|
||||
<string name="slash_command_snow">Sends the given message with snowfall</string>
|
||||
<string name="slash_command_description_plain">Sends a message as plain text, without interpreting it as markdown</string>
|
||||
<string name="slash_command_description_nick_for_room">Changes your display nickname in the current room only</string>
|
||||
<string name="slash_command_description_room_avatar">Changes the avatar of the current room</string>
|
||||
<string name="slash_command_description_avatar_for_room">Changes your avatar in this current room only</string>
|
||||
<string name="slash_command_description_devtools">Open the developer tools screen</string>
|
||||
<string name="slash_command_description_whois">Displays information about a user</string>
|
||||
<string name="slash_command_description_shrug">Prepends ¯\\_(ツ)_/¯ to a plain-text message</string>
|
||||
<string name="slash_command_description_lenny">Prepends ( ͡° ͜ʖ ͡°) to a plain-text message</string>
|
||||
<string name="slash_command_description_table_flip">Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message</string>
|
||||
<string name="slash_command_description_unflip">Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message</string>
|
||||
<string name="slash_command_description_discard_session">Forces the current outbound group session in an encrypted room to be discarded</string>
|
||||
<string name="slash_command_description_discard_session_not_handled">Only supported in encrypted rooms</string>
|
||||
<string name="slash_command_description_leave_room">Leave the current room</string>
|
||||
<string name="slash_command_description_upgrade_room">Upgrades a room to a new version</string>
|
||||
|
||||
<string name="common_spoiler">Spoiler</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,359 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.slashcommands.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.timeline.MsgType
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.slashcommands.api.ChatEffect
|
||||
import io.element.android.libraries.slashcommands.api.MessagePrefix
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||
import io.element.android.libraries.slashcommands.impl.rainbow.RainbowGenerator
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class CommandExecutorTest {
|
||||
@Test
|
||||
fun `send plain text delegates to timeline with plain flag`() = runTest {
|
||||
val timeline = FakeTimeline()
|
||||
var capturedBody: String? = null
|
||||
var capturedHtml: String? = "initial"
|
||||
var capturedAsPlainText = false
|
||||
timeline.sendMessageLambda = { body, htmlBody, _, _, asPlainText ->
|
||||
capturedBody = body
|
||||
capturedHtml = htmlBody
|
||||
capturedAsPlainText = asPlainText
|
||||
Result.success(Unit)
|
||||
}
|
||||
val sut = createCommandExecutor()
|
||||
val res = sut.proceedSendMessage(SlashCommand.SendPlainText("hello"), timeline)
|
||||
assertThat(res.isSuccess).isTrue()
|
||||
assertThat(capturedBody).isEqualTo("hello")
|
||||
assertThat(capturedHtml).isNull()
|
||||
assertThat(capturedAsPlainText).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send emote delegates to timeline as emote`() = runTest {
|
||||
val timeline = FakeTimeline()
|
||||
var msgType: MsgType? = null
|
||||
timeline.sendMessageLambda = { _, _, _, type, _ ->
|
||||
msgType = type
|
||||
Result.success(Unit)
|
||||
}
|
||||
val sut = createCommandExecutor()
|
||||
val res = sut.proceedSendMessage(SlashCommand.SendEmote("yay"), timeline)
|
||||
assertThat(res.isSuccess).isTrue()
|
||||
assertThat(msgType).isEqualTo(MsgType.MSG_TYPE_EMOTE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send lenny prefixes message`() = runTest {
|
||||
val timeline = FakeTimeline()
|
||||
var capturedBody: String? = null
|
||||
timeline.sendMessageLambda = { body, _, _, _, _ ->
|
||||
capturedBody = body
|
||||
Result.success(Unit)
|
||||
}
|
||||
val sut = createCommandExecutor()
|
||||
val res = sut.proceedSendMessage(SlashCommand.SendWithPrefix(MessagePrefix.Lenny, "fun"), timeline)
|
||||
assertThat(res.isSuccess).isTrue()
|
||||
assertThat(capturedBody).isEqualTo("( ͡° ͜ʖ ͡°) fun")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send table flip prefixes message`() = runTest {
|
||||
val timeline = FakeTimeline()
|
||||
var capturedBody: String? = null
|
||||
timeline.sendMessageLambda = { body, _, _, _, _ ->
|
||||
capturedBody = body
|
||||
Result.success(Unit)
|
||||
}
|
||||
val sut = createCommandExecutor()
|
||||
val res = sut.proceedSendMessage(SlashCommand.SendWithPrefix(MessagePrefix.TableFlip, "wow"), timeline)
|
||||
assertThat(res.isSuccess).isTrue()
|
||||
assertThat(capturedBody).isEqualTo("(╯°□°)╯︵ ┻━┻ wow")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send unflip prefixes message`() = runTest {
|
||||
val timeline = FakeTimeline()
|
||||
var capturedBody: String? = null
|
||||
timeline.sendMessageLambda = { body, _, _, _, _ ->
|
||||
capturedBody = body
|
||||
Result.success(Unit)
|
||||
}
|
||||
val sut = createCommandExecutor()
|
||||
val res = sut.proceedSendMessage(SlashCommand.SendWithPrefix(MessagePrefix.Unflip, "keep cool"), timeline)
|
||||
assertThat(res.isSuccess).isTrue()
|
||||
assertThat(capturedBody).isEqualTo("┬──┬ ノ( ゜-゜ノ) keep cool")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send shrug prefixes message`() = runTest {
|
||||
val timeline = FakeTimeline()
|
||||
var capturedBody: String? = null
|
||||
timeline.sendMessageLambda = { body, _, _, _, _ ->
|
||||
capturedBody = body
|
||||
Result.success(Unit)
|
||||
}
|
||||
val sut = createCommandExecutor()
|
||||
val res = sut.proceedSendMessage(SlashCommand.SendWithPrefix(MessagePrefix.Shrug, "wow"), timeline)
|
||||
assertThat(res.isSuccess).isTrue()
|
||||
assertThat(capturedBody).isEqualTo("¯\\\\_(ツ)\\_/¯ wow")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send rainbow provides html body`() = runTest {
|
||||
val timeline = FakeTimeline()
|
||||
var capturedHtml: String? = null
|
||||
var capturedBody: String? = null
|
||||
var capturedMsgType: MsgType? = null
|
||||
timeline.sendMessageLambda = { body, htmlBody, _, msgType, _ ->
|
||||
capturedBody = body
|
||||
capturedHtml = htmlBody
|
||||
capturedMsgType = msgType
|
||||
Result.success(Unit)
|
||||
}
|
||||
val sut = createCommandExecutor()
|
||||
val res = sut.proceedSendMessage(SlashCommand.SendRainbow("a nice rainbow"), timeline)
|
||||
assertThat(res.isSuccess).isTrue()
|
||||
assertThat(capturedBody).isEqualTo("a nice rainbow")
|
||||
assertThat(capturedHtml).isNotNull()
|
||||
assertThat(capturedHtml!!.contains("<font") || capturedHtml!!.contains("<span")).isTrue()
|
||||
assertThat(capturedMsgType).isEqualTo(MsgType.MSG_TYPE_TEXT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send rainbow emote provides html body`() = runTest {
|
||||
val timeline = FakeTimeline()
|
||||
var capturedHtml: String? = null
|
||||
var capturedBody: String? = null
|
||||
var capturedMsgType: MsgType? = null
|
||||
timeline.sendMessageLambda = { body, htmlBody, _, msgType, _ ->
|
||||
capturedBody = body
|
||||
capturedHtml = htmlBody
|
||||
capturedMsgType = msgType
|
||||
Result.success(Unit)
|
||||
}
|
||||
val sut = createCommandExecutor()
|
||||
val res = sut.proceedSendMessage(SlashCommand.SendRainbowEmote("a nice rainbow"), timeline)
|
||||
assertThat(res.isSuccess).isTrue()
|
||||
assertThat(capturedBody).isEqualTo("a nice rainbow")
|
||||
assertThat(capturedHtml).isNotNull()
|
||||
assertThat(capturedHtml!!.contains("<font") || capturedHtml!!.contains("<span")).isTrue()
|
||||
assertThat(capturedMsgType).isEqualTo(MsgType.MSG_TYPE_EMOTE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `change display name invokes the method of the matrix client`() = runTest {
|
||||
val matrixClient = FakeMatrixClient()
|
||||
val sut = createCommandExecutor(matrixClient = matrixClient)
|
||||
val res = sut.proceedAdmin(SlashCommand.ChangeDisplayName("new name"))
|
||||
assertThat(res.isSuccess).isTrue()
|
||||
assertThat(matrixClient.setDisplayNameCalled).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `change room avatar is not supported`() = runTest {
|
||||
val sut = createCommandExecutor()
|
||||
val res = sut.proceedAdmin(SlashCommand.ChangeRoomAvatar(AN_AVATAR_URL))
|
||||
assertThat(res.isFailure).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `change avatar for room is not supported`() = runTest {
|
||||
val sut = createCommandExecutor()
|
||||
val res = sut.proceedAdmin(SlashCommand.ChangeAvatarForRoom(AN_AVATAR_URL))
|
||||
assertThat(res.isFailure).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `change display name for room is not supported`() = runTest {
|
||||
val sut = createCommandExecutor()
|
||||
val res = sut.proceedAdmin(SlashCommand.ChangeDisplayNameForRoom(A_USER_NAME))
|
||||
assertThat(res.isFailure).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `upgrade room is not supported`() = runTest {
|
||||
val sut = createCommandExecutor()
|
||||
val res = sut.proceedAdmin(SlashCommand.UpgradeRoom("1"))
|
||||
assertThat(res.isFailure).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `set user power level is not supported`() = runTest {
|
||||
val sut = createCommandExecutor()
|
||||
val res = sut.proceedAdmin(SlashCommand.SetUserPowerLevel(A_USER_ID, 50))
|
||||
assertThat(res.isFailure).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discard session is not supported`() = runTest {
|
||||
val sut = createCommandExecutor()
|
||||
val res = sut.proceedAdmin(SlashCommand.DiscardSession)
|
||||
assertThat(res.isFailure).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send spoiler sets formatted and body includes spoiler label`() = runTest {
|
||||
val timeline = FakeTimeline()
|
||||
var capturedBody: String? = null
|
||||
var capturedHtml: String? = null
|
||||
timeline.sendMessageLambda = { body, htmlBody, _, _, _ ->
|
||||
capturedBody = body
|
||||
capturedHtml = htmlBody
|
||||
Result.success(Unit)
|
||||
}
|
||||
val stringProvider = FakeStringProvider(defaultResult = "SPOILER")
|
||||
val sut = createCommandExecutor(
|
||||
stringProvider = stringProvider,
|
||||
)
|
||||
val res = sut.proceedSendMessage(SlashCommand.SendSpoiler("secret"), timeline)
|
||||
assertThat(res.isSuccess).isTrue()
|
||||
assertThat(capturedBody).isEqualTo("[SPOILER](secret)")
|
||||
assertThat(capturedHtml).isEqualTo("<span data-mx-spoiler>secret</span>")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send chat effect is not supported`() = runTest {
|
||||
val sut = createCommandExecutor()
|
||||
val res = sut.proceedSendMessage(
|
||||
SlashCommand.SendChatEffect(ChatEffect.CONFETTI, A_MESSAGE),
|
||||
FakeTimeline()
|
||||
)
|
||||
assertThat(res.isFailure).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `admin commands call underlying client and room APIs`() = runTest {
|
||||
var kicked = false
|
||||
var banned = false
|
||||
var unbanned = false
|
||||
var invited = false
|
||||
var ignored = false
|
||||
var unignored = false
|
||||
var left = false
|
||||
var topicSet = false
|
||||
var nameSet = false
|
||||
var joined = false
|
||||
|
||||
val joinedRoom = FakeJoinedRoom(
|
||||
kickUserResult = { _, _ ->
|
||||
kicked = true
|
||||
Result.success(Unit)
|
||||
},
|
||||
banUserResult = { _, _ ->
|
||||
banned = true
|
||||
Result.success(Unit)
|
||||
},
|
||||
unBanUserResult = { _, _ ->
|
||||
unbanned = true
|
||||
Result.success(Unit)
|
||||
},
|
||||
inviteUserResult = { _ ->
|
||||
invited = true
|
||||
Result.success(Unit)
|
||||
},
|
||||
setTopicResult = { _ ->
|
||||
topicSet = true
|
||||
Result.success(Unit)
|
||||
},
|
||||
setNameResult = { _ ->
|
||||
nameSet = true
|
||||
Result.success(Unit)
|
||||
},
|
||||
baseRoom = FakeBaseRoom(
|
||||
leaveRoomLambda = {
|
||||
left = true
|
||||
Result.success(Unit)
|
||||
},
|
||||
)
|
||||
)
|
||||
val matrixClient = FakeMatrixClient(
|
||||
ignoreUserResult = { _ ->
|
||||
ignored = true
|
||||
Result.success(Unit)
|
||||
},
|
||||
unIgnoreUserResult = { _ ->
|
||||
unignored = true
|
||||
Result.success(Unit)
|
||||
},
|
||||
).apply {
|
||||
joinRoomByIdOrAliasLambda = { _, _ ->
|
||||
joined = true
|
||||
Result.success(null)
|
||||
}
|
||||
}
|
||||
val sut = createCommandExecutor(
|
||||
matrixClient = matrixClient,
|
||||
joinedRoom = joinedRoom,
|
||||
)
|
||||
val kickRes = sut.proceedAdmin(SlashCommand.RemoveUser(A_USER_ID, null))
|
||||
assertThat(kicked).isTrue()
|
||||
assertThat(kickRes.isSuccess).isTrue()
|
||||
val banRes = sut.proceedAdmin(SlashCommand.BanUser(A_USER_ID, "reason"))
|
||||
assertThat(banned).isTrue()
|
||||
assertThat(banRes.isSuccess).isTrue()
|
||||
val unbanRes = sut.proceedAdmin(SlashCommand.UnbanUser(A_USER_ID, null))
|
||||
assertThat(unbanned).isTrue()
|
||||
assertThat(unbanRes.isSuccess).isTrue()
|
||||
val inviteRes = sut.proceedAdmin(SlashCommand.Invite(A_USER_ID, null))
|
||||
assertThat(invited).isTrue()
|
||||
assertThat(inviteRes.isSuccess).isTrue()
|
||||
val ignoreRes = sut.proceedAdmin(SlashCommand.IgnoreUser(A_USER_ID))
|
||||
assertThat(ignoreRes.isSuccess).isTrue()
|
||||
assertThat(ignored).isTrue()
|
||||
val unignoreRes = sut.proceedAdmin(SlashCommand.UnignoreUser(A_USER_ID))
|
||||
assertThat(unignoreRes.isSuccess).isTrue()
|
||||
assertThat(unignored).isTrue()
|
||||
val leaveRes = sut.proceedAdmin(SlashCommand.LeaveRoom)
|
||||
assertThat(leaveRes.isSuccess).isTrue()
|
||||
assertThat(left).isTrue()
|
||||
val topicRes = sut.proceedAdmin(SlashCommand.ChangeTopic("t"))
|
||||
assertThat(topicRes.isSuccess).isTrue()
|
||||
assertThat(topicSet).isTrue()
|
||||
val nameRes = sut.proceedAdmin(SlashCommand.ChangeRoomName("n"))
|
||||
assertThat(nameRes.isSuccess).isTrue()
|
||||
assertThat(nameSet).isTrue()
|
||||
val joinRes = sut.proceedAdmin(
|
||||
SlashCommand.JoinRoom(
|
||||
roomIdOrAlias = RoomIdOrAlias.Id(
|
||||
RoomId("!room:domain")
|
||||
),
|
||||
reason = null,
|
||||
)
|
||||
)
|
||||
assertThat(joinRes.isSuccess).isTrue()
|
||||
assertThat(joined).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
fun createCommandExecutor(
|
||||
matrixClient: FakeMatrixClient = FakeMatrixClient(),
|
||||
joinedRoom: FakeJoinedRoom = FakeJoinedRoom(),
|
||||
rainbowGenerator: RainbowGenerator = RainbowGenerator(),
|
||||
stringProvider: StringProvider = FakeStringProvider(),
|
||||
) = CommandExecutor(
|
||||
matrixClient = matrixClient,
|
||||
joinedRoom = joinedRoom,
|
||||
rainbowGenerator = rainbowGenerator,
|
||||
stringProvider = stringProvider,
|
||||
)
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.slashcommands.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||
import io.element.android.libraries.slashcommands.api.ChatEffect
|
||||
import io.element.android.libraries.slashcommands.api.MessagePrefix
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class CommandParserTest {
|
||||
@Test
|
||||
fun parseSlashCommandEmpty() = runTest {
|
||||
test("/", SlashCommand.ErrorEmptySlashCommand("A string/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseSlashCommandUnknown() = runTest {
|
||||
test("/unknown", SlashCommand.ErrorUnknownSlashCommand("A string/unknown"))
|
||||
test("/unknown with param", SlashCommand.ErrorUnknownSlashCommand("A string/unknown"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseSlashCommandNotACommand() = runTest {
|
||||
test("", SlashCommand.NotACommand)
|
||||
test("test", SlashCommand.NotACommand)
|
||||
test("// test", SlashCommand.NotACommand)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseSlashCommandEmote() = runTest {
|
||||
test("/me test", SlashCommand.SendEmote("test"))
|
||||
test("/me", SlashCommand.ErrorSyntax("A string/me, /me <message>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseSlashCommandRemove() = runTest {
|
||||
// Nominal
|
||||
test("/remove $A_USER_ID", SlashCommand.RemoveUser(A_USER_ID, null))
|
||||
// With a reason
|
||||
test("/remove $A_USER_ID a reason", SlashCommand.RemoveUser(A_USER_ID, "a reason"))
|
||||
// Trim the reason
|
||||
test("/remove $A_USER_ID a reason ", SlashCommand.RemoveUser(A_USER_ID, "a reason"))
|
||||
// Alias
|
||||
test("/kick $A_USER_ID", SlashCommand.RemoveUser(A_USER_ID, null))
|
||||
// Error
|
||||
test("/remove", SlashCommand.ErrorSyntax("A string/remove, /remove <user-id> [reason]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseSlashCommandRemoveMarkdown() = runTest {
|
||||
// Nominal
|
||||
test(
|
||||
"/remove [@user:domain.org](https://matrix.to/#/@user:domain.org)",
|
||||
SlashCommand.RemoveUser(UserId("@user:domain.org"), null)
|
||||
)
|
||||
test(
|
||||
"/remove [@user:domain.org](https://matrix.to/#/@user:domain.org) reason",
|
||||
SlashCommand.RemoveUser(UserId("@user:domain.org"), "reason")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseSlashCommandPlainAndNick() = runTest {
|
||||
test("/plain hello", SlashCommand.SendPlainText("hello"))
|
||||
test("/plain", SlashCommand.ErrorSyntax("A string/plain, /plain <message>"))
|
||||
|
||||
test("/nick John", SlashCommand.ChangeDisplayName("John"))
|
||||
test("/nick", SlashCommand.ErrorSyntax("A string/nick, /nick <display-name>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseSlashCommandRoomNickAndAvatars() = runTest {
|
||||
test("/myroomnick Roomy", SlashCommand.ChangeDisplayNameForRoom("Roomy"))
|
||||
test("/roomavatar mxc://matrix.org/abc", SlashCommand.ChangeRoomAvatar("mxc://matrix.org/abc"))
|
||||
test("/roomavatar http://notmxc", SlashCommand.ErrorSyntax("A string/roomavatar, /roomavatar <mxc_url>"))
|
||||
test("/myroomavatar mxc://matrix.org/abc", SlashCommand.ChangeAvatarForRoom("mxc://matrix.org/abc"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseSlashCommandTopicAndRainbow() = runTest {
|
||||
test("/topic New topic", SlashCommand.ChangeTopic("New topic"))
|
||||
test("/topic", SlashCommand.ErrorSyntax("A string/topic, /topic <topic>"))
|
||||
|
||||
test("/rainbow yay", SlashCommand.SendRainbow("yay"))
|
||||
test("/rainbow", SlashCommand.ErrorSyntax("A string/rainbow, /rainbow <message>"))
|
||||
|
||||
test("/rainbowme yay", SlashCommand.SendRainbowEmote("yay"))
|
||||
test("/rainbowme", SlashCommand.ErrorSyntax("A string/rainbowme, /rainbowme <message>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseSlashCommandJoinAndRoomName() = runTest {
|
||||
// valid join
|
||||
test(
|
||||
"/join !roomId:domain reason",
|
||||
SlashCommand.JoinRoom(
|
||||
RoomIdOrAlias.Id(RoomId("!roomId:domain")),
|
||||
"reason"
|
||||
)
|
||||
)
|
||||
|
||||
// invalid join
|
||||
test("/join notavalid", SlashCommand.ErrorSyntax("A string/join, /join <room-address> [reason]"))
|
||||
|
||||
test("/roomname My Room", SlashCommand.ChangeRoomName("My Room"))
|
||||
test("/roomname", SlashCommand.ErrorSyntax("A string/roomname, /roomname <name>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseSlashCommandInviteBanEtc() = runTest {
|
||||
test("/invite $A_USER_ID", SlashCommand.Invite(A_USER_ID, null))
|
||||
test("/invite", SlashCommand.ErrorSyntax("A string/invite, /invite <user-id> [reason]"))
|
||||
|
||||
test("/ban $A_USER_ID bad", SlashCommand.BanUser(A_USER_ID, "bad"))
|
||||
test("/unban $A_USER_ID", SlashCommand.UnbanUser(A_USER_ID, null))
|
||||
|
||||
test("/ignore $A_USER_ID", SlashCommand.IgnoreUser(A_USER_ID))
|
||||
test("/unignore $A_USER_ID", SlashCommand.UnignoreUser(A_USER_ID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseSlashCommandPowerLevels() = runTest {
|
||||
test("/op $A_USER_ID 50", SlashCommand.SetUserPowerLevel(A_USER_ID, 50))
|
||||
test("/op $A_USER_ID notnumber", SlashCommand.ErrorSyntax("A string/op, /op <user-id> [<power-level>]"))
|
||||
test("/deop $A_USER_ID", SlashCommand.SetUserPowerLevel(A_USER_ID, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseSlashCommandDevtoolsAndSpoiler() = runTest {
|
||||
test("/devtools", SlashCommand.DevTools)
|
||||
test("/devtools extra", SlashCommand.ErrorSyntax("A string/devtools, /devtools"))
|
||||
|
||||
test("/spoiler secret", SlashCommand.SendSpoiler("secret"))
|
||||
test("/spoiler", SlashCommand.ErrorSyntax("A string/spoiler, /spoiler <message>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseSlashCommandEmojisAndSession() = runTest {
|
||||
test("/shrug hello", SlashCommand.SendWithPrefix(MessagePrefix.Shrug, "hello"))
|
||||
test("/shrug", SlashCommand.SendWithPrefix(MessagePrefix.Shrug, ""))
|
||||
|
||||
test("/lenny fun", SlashCommand.SendWithPrefix(MessagePrefix.Lenny, "fun"))
|
||||
test("/tableflip wow", SlashCommand.SendWithPrefix(MessagePrefix.TableFlip, "wow"))
|
||||
test("/unflip be safe", SlashCommand.SendWithPrefix(MessagePrefix.Unflip, "be safe"))
|
||||
|
||||
test("/discardsession", SlashCommand.DiscardSession)
|
||||
test("/discardsession extra", SlashCommand.ErrorSyntax("A string/discardsession, /discardsession"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseSlashCommandWhoisAndEffectsAndLeave() = runTest {
|
||||
test("/whois $A_USER_ID", SlashCommand.ShowUser(A_USER_ID))
|
||||
|
||||
test("/confetti party", SlashCommand.SendChatEffect(ChatEffect.CONFETTI, "party"))
|
||||
test("/snowfall snow", SlashCommand.SendChatEffect(ChatEffect.SNOWFALL, "snow"))
|
||||
|
||||
test("/leave", SlashCommand.LeaveRoom)
|
||||
test("/leave now", SlashCommand.ErrorSyntax("A string/leave, /leave"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseSlashCommandUpgradeAndCrashAndFeatureFlagAndThreads() = runTest {
|
||||
test("/upgraderoom 9", SlashCommand.UpgradeRoom("9"))
|
||||
test("/upgraderoom", SlashCommand.ErrorSyntax("A string/upgraderoom, /upgraderoom newVersion"))
|
||||
|
||||
// Crash only when developer mode enabled
|
||||
val cpDev = createCommandParser(appPreferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = true))
|
||||
try {
|
||||
cpDev.parseSlashCommand("/crash", null, false)
|
||||
org.junit.Assert.fail("Expected crash to throw")
|
||||
} catch (_: IllegalStateException) {
|
||||
// expected
|
||||
}
|
||||
|
||||
// Feature flag disabled
|
||||
val cpFF = createCommandParser(featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.SlashCommand.key to false)))
|
||||
val res = cpFF.parseSlashCommand("/me test", null, false)
|
||||
assertThat(res).isEqualTo(SlashCommand.NotACommand)
|
||||
|
||||
// Not supported in threads (e.g. /join)
|
||||
val cpThread = createCommandParser()
|
||||
val threadRes = cpThread.parseSlashCommand("/join !roomId:domain", null, true)
|
||||
assertThat(threadRes).isInstanceOf(SlashCommand.ErrorCommandNotSupportedInThreads::class.java)
|
||||
assertThat((threadRes as SlashCommand.ErrorCommandNotSupportedInThreads).message).isEqualTo("A string/join")
|
||||
}
|
||||
|
||||
private suspend fun test(message: String, expectedResult: SlashCommand) {
|
||||
val commandParser = createCommandParser()
|
||||
val result = commandParser.parseSlashCommand(message, null, false)
|
||||
assertThat(result).isEqualTo(expectedResult)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun createCommandParser(
|
||||
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(
|
||||
FeatureFlags.SlashCommand.key to true,
|
||||
),
|
||||
),
|
||||
stringProvider: StringProvider = FakeStringProvider(),
|
||||
) = CommandParser(
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
featureFlagService = featureFlagService,
|
||||
stringProvider = stringProvider,
|
||||
)
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.slashcommands.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
||||
import io.element.android.libraries.matrix.api.timeline.MsgType
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||
import io.element.android.libraries.slashcommands.impl.rainbow.RainbowGenerator
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultSlashCommandServiceTest {
|
||||
@Test
|
||||
fun `getSuggestions filters by text and maps to suggestions`() = runTest {
|
||||
val stringProvider = FakeStringProvider(defaultResult = "desc")
|
||||
val prefs = InMemoryAppPreferencesStore(isDeveloperModeEnabled = false)
|
||||
val sut = createDefaultSlashCommandService(
|
||||
commandParser = CommandParser(
|
||||
appPreferencesStore = prefs,
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(
|
||||
FeatureFlags.SlashCommand.key to true,
|
||||
)
|
||||
),
|
||||
stringProvider = stringProvider,
|
||||
),
|
||||
stringProvider = stringProvider,
|
||||
appPreferencesStore = prefs,
|
||||
)
|
||||
val res = sut.getSuggestions("ra", isInThread = true)
|
||||
// Expect commands starting with "/ra" (case-insensitive) and that are allowed in threads
|
||||
assertThat(res).isNotEmpty()
|
||||
assertThat(res.first().description).isEqualTo("desc")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getSuggestions hides dev commands when developer mode disabled`() = runTest {
|
||||
val stringProvider = FakeStringProvider()
|
||||
val prefs = InMemoryAppPreferencesStore(isDeveloperModeEnabled = false)
|
||||
val sut = createDefaultSlashCommandService(appPreferencesStore = prefs, stringProvider = stringProvider)
|
||||
val all = sut.getSuggestions("crash", isInThread = true)
|
||||
assertThat(all).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getSuggestions returns empty list when the feature is enabled`() = runTest {
|
||||
val sut = createDefaultSlashCommandService(isFeatureEnabled = true)
|
||||
val all = sut.getSuggestions("me", isInThread = false)
|
||||
assertThat(all).isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getSuggestions returns empty list when the feature is disabled`() = runTest {
|
||||
val sut = createDefaultSlashCommandService(isFeatureEnabled = false)
|
||||
val all = sut.getSuggestions("me", isInThread = false)
|
||||
assertThat(all).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getSuggestions for aliases`() = runTest {
|
||||
val stringProvider = FakeStringProvider()
|
||||
val prefs = InMemoryAppPreferencesStore(isDeveloperModeEnabled = false)
|
||||
val sut = createDefaultSlashCommandService(appPreferencesStore = prefs, stringProvider = stringProvider)
|
||||
val all = sut.getSuggestions("part", isInThread = true)
|
||||
assertThat(all).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getSuggestions shows dev commands when developer mode enabled`() = runTest {
|
||||
val stringProvider = FakeStringProvider()
|
||||
val prefs = InMemoryAppPreferencesStore(isDeveloperModeEnabled = true)
|
||||
val sut = createDefaultSlashCommandService(appPreferencesStore = prefs, stringProvider = stringProvider)
|
||||
val all = sut.getSuggestions("crash", isInThread = true)
|
||||
assertThat(all).isNotEmpty()
|
||||
assertThat(all.first().command).isEqualTo("/crash")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse delegates to commandParser`() = runTest {
|
||||
val sut = createDefaultSlashCommandService()
|
||||
val res = sut.parse("test", null, false)
|
||||
assertThat(res).isEqualTo(SlashCommand.NotACommand)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `proceedSendMessage delegate to commandExecutor`() = runTest {
|
||||
val sendMessage = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention>, _: MsgType, _: Boolean ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val sut = createDefaultSlashCommandService()
|
||||
val sendRes = sut.proceedSendMessage(
|
||||
slashCommand = SlashCommand.SendPlainText("hi"),
|
||||
timeline = FakeTimeline().apply {
|
||||
sendMessageLambda = sendMessage
|
||||
},
|
||||
)
|
||||
assertThat(sendRes.isSuccess).isTrue()
|
||||
sendMessage.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `proceedAdmin delegates to commandExecutor`() = runTest {
|
||||
val leaveRoomLambda = lambdaRecorder<Result<Unit>> {
|
||||
Result.success(Unit)
|
||||
}
|
||||
val sut = createDefaultSlashCommandService(
|
||||
commandExecutor = CommandExecutor(
|
||||
matrixClient = FakeMatrixClient(),
|
||||
joinedRoom = FakeJoinedRoom(
|
||||
baseRoom = FakeBaseRoom(
|
||||
leaveRoomLambda = leaveRoomLambda
|
||||
),
|
||||
),
|
||||
rainbowGenerator = RainbowGenerator(),
|
||||
stringProvider = FakeStringProvider(),
|
||||
),
|
||||
)
|
||||
val adminRes = sut.proceedAdmin(SlashCommand.LeaveRoom)
|
||||
assertThat(adminRes.isSuccess).isTrue()
|
||||
leaveRoomLambda.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
private fun createDefaultSlashCommandService(
|
||||
isFeatureEnabled: Boolean = true,
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(
|
||||
FeatureFlags.SlashCommand.key to isFeatureEnabled,
|
||||
),
|
||||
),
|
||||
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||
stringProvider: StringProvider = FakeStringProvider(),
|
||||
commandParser: CommandParser = createCommandParser(
|
||||
featureFlagService = featureFlagService,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
stringProvider = stringProvider,
|
||||
),
|
||||
commandExecutor: CommandExecutor = createCommandExecutor(
|
||||
stringProvider = stringProvider,
|
||||
),
|
||||
) = DefaultSlashCommandService(
|
||||
commandParser = commandParser,
|
||||
commandExecutor = commandExecutor,
|
||||
stringProvider = stringProvider,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
featureFlagService = featureFlagService,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue