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:
Benoit Marty 2026-04-02 16:15:32 +02:00 committed by GitHub
parent f08d1ed686
commit a77662421c
65 changed files with 3038 additions and 86 deletions

View file

@ -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) }
}

View file

@ -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 -> "( ͡° ͜ʖ ͡°)"
}

View file

@ -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() }
}
}

View file

@ -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,
)
}
}

View file

@ -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
}

View file

@ -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')
}
}

View file

@ -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>