Client Commands: telegram-panel slash commands with Discord-style parameters

Lists the panel account's client commands as Telegram slash commands (hourly
refresh, cached on the device), shows their parameters while typing, runs
complete commands on the panel instead of sending them and shows the answer
as an "Only visible to you" message (latest 20 kept in memory). Holding send
offers Run / Send as message / Send response in chat (red ! badge); an
answer's menu starts with Send in chat. Other plugins register their own
commands through the client_commands library module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lyuksovannyyandClaude Opus 5.5 committed 2026-10-09 16:19:20 +02:00
1 parent 84bdc44a76
commit 46a1f6ee1f
29 files changed
+3293 -220

No files matched your search

View File
Whitespace-only changes.
+266
View File
@@ -0,0 +1,266 @@
"""The message field: a command runs instead of going to the chat.
Hooks on ChatActivityEnterView (the chat's input):
- sendMessageInternal(notify, scheduleDate, ...), behind the send button, the
keyboard's send key and the send preview: a known command runs (on the
panel, or on the device for plugins' commands). One that can't run yet
(a required parameter missing, no reply) stays in the field and a bulletin
says why. Scheduled messages are sent as typed.
- onSendLongClick(View): holding send on a command opens a menu: run it,
send it to the chat as a plain message (the panel then handles it from the
update, as without this plugin), and the "Send response in chat" check.
- SendButton.onDraw(Canvas): while that check is on and the command can run,
a small "!" badge on the send button.
Not installed, the same text is an ordinary message, which the panel handles
from the account's outgoing update.
"""
from base_plugin import MethodHook
import diag
from commands.parse import analyze, split_command
from config import SEND_TO_CHAT
from jutil import field, hook_methods, java_class, runnable, unhook_all
from runner import Invocation
from hooks import responses
from hooks.notify import bulletin
ENTER = "org.telegram.ui.Components.ChatActivityEnterView"
SEND_BUTTON = ENTER + "$SendButton"
class Field:
"""The command typed into one input, ready to run or not."""
__slots__ = ("enter", "chat", "account", "text", "command", "reply", "analysis")
def __init__(self, enter, chat, account, text, command, reply, analysis):
self.enter = enter
self.chat = chat
self.account = account
self.text = text
self.command = command
self.reply = reply
self.analysis = analysis
def reply_of(chat):
"""The message the chat is replying to (not one of our responses)."""
reply = field(chat, "replyingMessageObject")
if reply is None or responses.is_local(reply) or int(reply.getId()) <= 0:
return None
return reply
def topic_of(chat):
try:
return int(chat.getTopicId()) if field(chat, "isTopic") else 0
except Exception:
return 0
def _icon(*names):
from org.telegram.messenger import R
for name in names:
value = getattr(R.drawable, name, None)
if value:
return value
return 0
class Composer:
def __init__(self, plugin, catalog, runner):
self.plugin = plugin
self.catalog = catalog
self.runner = runner
self.handles = []
self._badge = {} # (text, reply id) -> whether it can run
self._paints = []
def install(self):
composer = self
class Send(MethodHook):
def before_hooked_method(self, param):
try:
if int(param.args[1]) == 0 and composer.submit(param.thisObject):
param.setResult(False)
except Exception as exc:
diag.error("send", exc)
class LongSend(MethodHook):
def before_hooked_method(self, param):
try:
if composer.menu(param.thisObject, param.args[0]):
param.setResult(True)
except Exception as exc:
diag.error("send menu", exc)
class Badge(MethodHook):
def after_hooked_method(self, param):
try:
composer.draw_badge(param.thisObject, param.args[0])
except Exception as exc:
diag.error("send badge", exc)
self.handles += hook_methods(self.plugin, ENTER, "sendMessageInternal", Send(), arity=5)
self.handles += hook_methods(self.plugin, ENTER, "onSendLongClick", LongSend(), arity=1)
self.handles += hook_methods(self.plugin, SEND_BUTTON, "onDraw", Badge(), arity=1)
def uninstall(self):
unhook_all(self.plugin, self.handles)
# the field
def read(self, enter):
"""The Field of a known command typed into `enter`, else None."""
chat = field(enter, "parentFragment")
if chat is None or int(chat.getChatMode()) != 0 or field(enter, "editingMessageObject") is not None:
return None
text = enter.getFieldText()
text = str(text) if text is not None else ""
split = split_command(text)
if split is None:
return None
account = int(chat.getCurrentAccount())
command = self.catalog.find(account, split[0])
if command is None:
return None
reply = reply_of(chat)
edit = enter.getEditField()
cursor = int(edit.getSelectionEnd()) if edit is not None else len(text)
analysis = analyze(command, text, cursor, reply is not None)
return Field(enter, chat, account, text, command, reply, analysis)
def set_text(self, enter, text):
enter.setFieldText(text)
def submit(self, enter):
"""Runs the command typed into `enter`; False when it isn't one (or
should go to the chat as typed)."""
typed = self.read(enter)
if typed is None or self.runner.bypassed(typed.text):
return False
if not typed.analysis.complete:
bulletin(typed.analysis.error, error=True)
return True
chat = typed.chat
inv = Invocation(
typed.account,
chat.getDialogId(),
topic_of(chat),
typed.reply,
chat.getThreadMessage(),
typed.text,
typed.command,
self.plugin.get_setting(SEND_TO_CHAT, False),
)
enter.setFieldText("")
if typed.reply is not None:
chat.hideFieldPanel(False)
self.runner.run(inv)
return True
def send_as_text(self, enter):
typed = enter.getFieldText()
if typed is None:
return
self.runner.bypass(str(typed))
enter.sendMessage()
# holding send
def menu(self, enter, button):
typed = self.read(enter)
if typed is None:
return False
from org.telegram.ui.Components import ItemOptions
name = typed.command.name
checked = bool(self.plugin.get_setting(SEND_TO_CHAT, False))
def toggle():
self.plugin.set_setting(SEND_TO_CHAT, not checked)
self.refresh(enter)
options = ItemOptions.makeOptions(typed.chat, button)
options.add(_icon("msg_bot", "msg_bots", "msg_send"), f"Run /{name}", runnable(lambda: self.submit(enter)))
options.add(_icon("msg_send"), "Send as message", runnable(lambda: self.send_as_text(enter)))
options.addChecked(checked, _icon("msg_discussion", "msg_message", "msg_chats"), "Send response in chat", runnable(toggle))
options.show()
return True
# the badge
def refresh(self, enter):
"""Redraws the send button (the field or the setting changed)."""
button = field(enter, "sendButton")
if button is not None:
button.invalidate()
def _enter_of(self, button):
enter_class = java_class(ENTER)
view = button.getParent()
while view is not None:
if enter_class is not None and enter_class.isInstance(view):
return view
view = view.getParent() if hasattr(view, "getParent") else None
return None
def _can_run(self, enter):
text = enter.getFieldText()
if text is None or not str(text).startswith("/"):
return False
chat = field(enter, "parentFragment")
reply = reply_of(chat) if chat is not None else None
key = (str(text), int(reply.getId()) if reply is not None else 0)
if key not in self._badge:
typed = self.read(enter)
if len(self._badge) > 64:
self._badge.clear()
self._badge[key] = typed is not None and typed.analysis.complete
return self._badge[key]
def draw_badge(self, button, canvas):
if not self.plugin.get_setting(SEND_TO_CHAT, False):
return
enter = self._enter_of(button)
if enter is None or not self._can_run(enter):
return
from android.graphics import Paint
from java import jint
from org.telegram.messenger import AndroidUtilities
from org.telegram.ui.ActionBar import Theme
if not self._paints:
self._paints = [Paint(Paint.ANTI_ALIAS_FLAG) for _ in range(3)]
self._paints[1].setTextAlign(Paint.Align.CENTER)
self._paints[1].setFakeBoldText(True)
fill, mark, ring = self._paints
dp = AndroidUtilities.dp
height = button.getMeasuredHeight()
rect = field(button, "backgroundRect")
if rect is not None and field(button, "isNewDesignSendButton") and rect.width() > 0:
cx, cy, r = rect.centerX(), rect.centerY(), rect.height() / 2
else:
cx, cy, r = button.getMeasuredWidth() - height / 2, height / 2, height / 2 - dp(6)
x, y, radius = cx + r * 0.62, cy - r * 0.62, dp(7)
fill.setColor(jint(Theme.getColor(Theme.key_text_RedBold)))
mark.setColor(jint(-1))
mark.setTextSize(dp(11))
# A ring of the input's background separates it from the button.
ring.setColor(jint(Theme.getColor(Theme.key_chat_messagePanelBackground)))
canvas.drawCircle(x, y, radius + dp(1.5), ring)
canvas.drawCircle(x, y, radius, fill)
canvas.drawText("!", x, y + dp(4), mark)
def forget(self):
"""The commands changed: whether a text can run is asked again."""
self._badge.clear()
def text_changed(self, enter):
"""The field's text changed: the badge may change."""
if self.plugin.get_setting(SEND_TO_CHAT, False):
self.refresh(enter)
+87
View File
@@ -0,0 +1,87 @@
""""Send in chat" first in the menu of a response shown only to you.
Tapping a response (an ephemeral message) opens the app's message menu. The
row is added to the built popup after ChatActivity.createMenu, above its
first row (as messageLogger's "Edit history"); ChatActivity.selectedObject
is the message. Sending posts the answer (without the command above it) as
the account, replying where the command replied, and takes the local copy
away.
"""
from base_plugin import MethodHook
import diag
from hooks import responses
from jutil import field, hook_methods, method, unhook_all
CHAT = "org.telegram.ui.ChatActivity"
LABEL = "Send in chat"
def _items(view, found):
"""Every ActionBarMenuSubItem under `view`, in order."""
if view.getClass().getSimpleName() == "ActionBarMenuSubItem":
found.append(view)
elif hasattr(view, "getChildCount"):
for index in range(view.getChildCount()):
_items(view.getChildAt(index), found)
return found
class ResponseMenu:
def __init__(self, plugin, runner):
self.plugin = plugin
self.runner = runner
self.handles = []
def install(self):
menu = self
class AfterCreate(MethodHook):
def after_hooked_method(self, param):
try:
menu.add_item(param.thisObject)
except Exception as exc:
diag.error("response menu", exc)
self.handles += hook_methods(self.plugin, CHAT, "createMenu", AfterCreate())
def uninstall(self):
unhook_all(self.plugin, self.handles)
def add_item(self, chat):
message = field(chat, "selectedObject")
popup = field(chat, "scrimPopupWindow")
body = responses.sendable(message) if message is not None else None
if popup is None or body is None:
return
items = _items(popup.getContentView(), [])
# The createMenu overloads call one another: add the row once.
if not items or any(str(i.getTextView().getText()) == LABEL for i in items if i.getTextView()):
return
from android_utils import OnClickListener
from org.telegram.messenger import R
from org.telegram.ui.ActionBar import ActionBarMenuSubItem
anchor = items[0]
parent = anchor.getParent()
item = ActionBarMenuSubItem(anchor.getContext(), False, False)
item.setTextAndIcon(LABEL, R.drawable.msg_send)
color = anchor.getTextView().getCurrentTextColor()
item.setColors(color, color)
item.setMinimumWidth(anchor.getMinimumWidth())
def chosen(_view):
method(CHAT, "closeMenu").invoke(chat)
self.runner.send(
body["account"],
body["dialog_id"],
body["reply"],
body["thread"],
body["text"],
body["entities"],
)
responses.remove(body["account"], body["dialog_id"], message.getId())
item.setOnClickListener(OnClickListener(chosen))
parent.addView(item, parent.indexOfChild(anchor), anchor.getLayoutParams())
+17
View File
@@ -0,0 +1,17 @@
"""Bulletins (shown on the UI thread, from any thread)."""
from jutil import on_ui
def bulletin(text, error=False, success=False):
def show():
from ui.bulletin import BulletinHelper
if error:
BulletinHelper.show_error(text)
elif success:
BulletinHelper.show_success(text)
else:
BulletinHelper.show_info(text)
on_ui(show)
+358
View File
@@ -0,0 +1,358 @@
"""Responses shown only on this device.
They are Telegram's own ephemeral messages: a TL_message with an ephemeral
id, sent by the account itself (an outgoing bubble), handed to the open chat
the way new messages arrive (ChatActivity.processNewMessages) and never
stored. The latest 20 come back when their chat is opened again, until the
app restarts (memory only). The app labels an outgoing
ephemeral message "Only visible to @<receiver>"; the receiver is the account,
and the label is made "Only visible to you" (ChatMessageCell's
setMessageObjectInternal). Deleting one, or replying to one, stays on the
device. When the chat isn't open, a bulletin shows the text instead.
"""
import random
from base_plugin import MethodHook
import diag
from commands.markup import utf16_len
from jutil import array_list, field, hook_methods, java_class, method, set_field, unhook_all
CHAT = "org.telegram.ui.ChatActivity"
_ENTITY_CLASSES = {
"bold": "TL_messageEntityBold",
"italic": "TL_messageEntityItalic",
"underline": "TL_messageEntityUnderline",
"strike": "TL_messageEntityStrike",
"code": "TL_messageEntityCode",
"pre": "TL_messageEntityPre",
"text_link": "TL_messageEntityTextUrl",
"url": "TL_messageEntityUrl",
"email": "TL_messageEntityEmail",
"blockquote": "TL_messageEntityBlockquote",
"custom_emoji": "TL_messageEntityCustomEmoji",
"mention_name": "TL_messageEntityMentionName",
"spoiler": "TL_messageEntitySpoiler",
}
# The latest responses are kept (in memory only) and shown again when their
# chat is opened again, until the app restarts.
HISTORY = 20
def _state():
"""Kept across reloads of this plugin (not app restarts):
shown: (account, message id) -> what "Send in chat" sends, or None;
history: the latest HISTORY responses, oldest first."""
from library import shared
return shared().setdefault(
"responses",
{"shown": {}, "history": [], "counter": [random.randint(0x01000000, 0x0E000000)]},
)
def jboolean_type():
from java.lang import Boolean
return Boolean.TYPE
def tl_entities(entities):
"""ArrayList<TLRPC.MessageEntity> of entity dicts ({type, offset, length, ...})."""
from org.telegram.tgnet import TLRPC
out = []
for data in entities or ():
name = _ENTITY_CLASSES.get(data.get("type"))
if name is None:
continue
entity = getattr(TLRPC, name)()
entity.offset = int(data["offset"])
entity.length = int(data["length"])
if data.get("url") is not None and hasattr(entity, "url"):
entity.url = data["url"]
if data.get("language") is not None and hasattr(entity, "language"):
entity.language = data["language"]
if data.get("user_id") is not None and hasattr(entity, "user_id"):
entity.user_id = int(data["user_id"])
if data.get("document_id") is not None and hasattr(entity, "document_id"):
entity.document_id = int(data["document_id"])
if data.get("collapsed") and hasattr(entity, "collapsed"):
entity.collapsed = True
out.append(entity)
return array_list(out)
def is_local(message_object):
"""Whether a MessageObject is a response shown here."""
try:
return (int(message_object.currentAccount), int(message_object.getId())) in _state()["shown"]
except Exception:
return False
def open_chat(account, dialog_id, topic_id=0):
"""The ChatActivity showing that chat (and topic) now, else None."""
from client_utils import get_last_fragment
fragment = get_last_fragment()
if fragment is None or not java_class(CHAT).isInstance(fragment):
return None
if int(fragment.getCurrentAccount()) != int(account) or int(fragment.getDialogId()) != int(dialog_id):
return None
if topic_id and int(fragment.getTopicId()) != int(topic_id):
return None
return fragment
def _build(account, dialog_id, topic_id, reply, text, entities, message_id, date):
from org.telegram.messenger import MessageObject, MessagesController, UserConfig
from org.telegram.tgnet import TLRPC
controller = MessagesController.getInstance(account)
own = UserConfig.getInstance(account).getClientUserId()
msg = TLRPC.TL_message()
msg.id = message_id
msg.dialog_id = dialog_id
msg.peer_id = controller.getPeer(dialog_id)
msg.from_id = controller.getPeer(own)
msg.flags |= 256
msg.date = date
msg.message = text
msg.entities = entities
if entities.size():
msg.flags |= 128
reply_id = int(reply.getId()) if reply is not None else 0
if reply_id or topic_id:
header = TLRPC.TL_messageReplyHeader()
header.reply_to_msg_id = reply_id or int(topic_id)
header.flags |= 16
if topic_id:
header.forum_topic = True
header.flags |= 8
if reply_id:
header.reply_to_top_id = int(topic_id)
header.flags |= 2
msg.reply_to = header
msg.flags |= 8
msg.out = True
msg.unread = False
msg.ephemeralReceiverBotId = own
obj = MessageObject(account, msg, True, True)
if reply is not None:
obj.replyMessageObject = reply
return obj
def sendable(message_object):
"""What "Send in chat" sends for a response shown here, else None:
{account, dialog_id, reply, thread, text, entities}."""
try:
return _state()["shown"].get((int(message_object.currentAccount), int(message_object.getId())))
except Exception:
return None
def show(
account, dialog_id, topic_id, reply, text, entities=(), header=None, thread=None, answer=False, keep=True
):
"""Shows a response in the open chat; its id, or None (a bulletin was
shown instead). `header` (the command) goes first, as code. An `answer`
can be sent into the chat from its menu (without the header). Kept
responses come back when the chat is opened again. UI thread."""
from org.telegram.messenger import MessageObject
from org.telegram.tgnet import ConnectionsManager
state = _state()
body = {
"account": int(account),
"dialog_id": int(dialog_id),
"reply": reply,
"thread": thread,
"text": text,
"entities": list(entities or ()),
}
if header:
shift = utf16_len(header) + 1
entities = [{"type": "code", "offset": 0, "length": utf16_len(header)}] + [
dict(e, offset=int(e["offset"]) + shift) for e in entities or ()
]
text = f"{header}\n{text}" if text else header
state["counter"][0] += 1
entry = {
"account": int(account),
"dialog_id": int(dialog_id),
"topic_id": int(topic_id or 0),
"id": int(MessageObject.ephemeralMessageIdPack(state["counter"][0])),
"date": int(ConnectionsManager.getInstance(account).getCurrentTime()),
"reply": reply,
"text": text,
"entities": list(entities or ()),
}
state["shown"][(entry["account"], entry["id"])] = body if answer else None
if keep:
history = state["history"]
history.append(entry)
del history[:-HISTORY]
fragment = open_chat(account, dialog_id, topic_id)
if fragment is None:
from hooks.notify import bulletin
bulletin(text[:300])
return None
_insert(fragment, [entry])
return entry["id"]
def _insert(fragment, entries):
from java import jboolean
objects = [
_build(
e["account"], e["dialog_id"], e["topic_id"], e["reply"], e["text"],
tl_entities(e["entities"]), e["id"], e["date"],
)
for e in entries
]
method(CHAT, "processNewMessages", "java.util.ArrayList", jboolean_type()).invoke(
fragment, array_list(objects), jboolean(False)
)
def restore(fragment):
"""Shows a just opened chat's kept responses it doesn't show yet."""
state = _state()
account, dialog_id = int(fragment.getCurrentAccount()), int(fragment.getDialogId())
topic_id = int(fragment.getTopicId())
dicts = field(fragment, "messagesDict")
present = dicts[0] if dicts is not None else None
missing = [
e
for e in state["history"]
if e["account"] == account
and e["dialog_id"] == dialog_id
and e["topic_id"] == topic_id
and (present is None or present.get(e["id"]) is None)
]
if missing:
_insert(fragment, missing)
def remove(account, dialog_id, message_id):
"""Takes a response out of the chat showing it, and out of the kept
ones. UI thread."""
state = _state()
state["shown"].pop((int(account), int(message_id)), None)
state["history"][:] = [
e for e in state["history"] if (e["account"], e["id"]) != (int(account), int(message_id))
]
fragment = open_chat(account, dialog_id)
if fragment is None:
return
from java import jboolean, jint, jlong
from java.lang import Long
from org.telegram.messenger import ChatObject
chat = fragment.getCurrentChat()
channel_id = int(chat.id) if chat is not None and ChatObject.isChannel(chat) else 0
method(CHAT, "processDeletedMessages", "java.util.ArrayList", Long.TYPE, jboolean_type()).invoke(
fragment, array_list([jint(int(message_id))]), jlong(channel_id), jboolean(False)
)
def _label_only_you(cell):
"""The cell's "Only visible to @…" label, as "Only visible to you"."""
from android.text import Layout, SpannableStringBuilder, StaticLayout
from org.telegram.messenger import AndroidUtilities, LocaleController, R
from org.telegram.ui.ActionBar import Theme
layout = field(cell, "ephemeralLayout")
if layout is None:
return
label = SpannableStringBuilder(layout.getText())
you = LocaleController.getString(R.string.EphemeralMessageVisibleToYou)
if str(label)[2:] == str(you):
return
label.replace(2, label.length(), you) # after the icon and a space
relabeled = StaticLayout(
label, Theme.chat_ephemeralPaint, AndroidUtilities.dp(300), Layout.Alignment.ALIGN_NORMAL, 0.0, 0.0, False
)
set_field(cell, "ephemeralLayout", relabeled)
if relabeled.getLineCount() > 0:
from java import jint
set_field(cell, "ephemeralWidth", jint(int(relabeled.getLineWidth(0)) + AndroidUtilities.dp(10)))
class Responses:
"""Keeps deleting and replying to responses on the device."""
def __init__(self, plugin):
self.plugin = plugin
self.handles = []
# Chats (class guids) already given their kept responses; a chat
# restored before a reload of this plugin is skipped by its ids.
self._restored = set()
def install(self):
class LocalDelete(MethodHook):
# MessagesController.deleteEphemeralMessage(long dialogId, int topicId, MessageObject)
def before_hooked_method(self, param):
try:
message = param.args[2]
if is_local(message):
remove(int(message.currentAccount), int(param.args[0]), int(message.getId()))
param.setResult(None)
except Exception as exc:
diag.error("local delete", exc)
class NoReply(MethodHook):
# ChatActivity.showFieldPanelForReply(MessageObject)
def before_hooked_method(self, param):
try:
if param.args and is_local(param.args[0]):
param.setResult(None)
except Exception as exc:
diag.error("local reply", exc)
self.handles += hook_methods(
self.plugin, "org.telegram.messenger.MessagesController", "deleteEphemeralMessage", LocalDelete()
)
class OnlyYou(MethodHook):
# ChatMessageCell.setMessageObjectInternal(MessageObject)
def after_hooked_method(self, param):
try:
if is_local(param.args[0]):
_label_only_you(param.thisObject)
except Exception as exc:
diag.error("local label", exc)
restored = self._restored
class Visible(MethodHook):
# ChatActivity.updateMessagesVisiblePart: its messages are shown
def after_hooked_method(self, param):
try:
chat = param.thisObject
guid = int(chat.getClassGuid())
if guid in restored:
return
messages = field(chat, "messages")
if messages is None or messages.isEmpty():
return
restored.add(guid)
restore(chat)
except Exception as exc:
diag.error("restore responses", exc)
self.handles += hook_methods(self.plugin, CHAT, "updateMessagesVisiblePart", Visible())
self.handles += hook_methods(self.plugin, CHAT, "showFieldPanelForReply", NoReply(), arity=1)
self.handles += hook_methods(
self.plugin, "org.telegram.ui.Cells.ChatMessageCell", "setMessageObjectInternal", OnlyYou(), arity=1
)
def uninstall(self):
unhook_all(self.plugin, self.handles)
+386
View File
@@ -0,0 +1,386 @@
"""Commands in Telegram's own suggestion list above the input.
Typing `/` lists the account's commands as bot commands are listed (the
account's avatar, `/name`, then its parameters and summary), before the
chat's bot commands. Once a command is followed by a space, the list shows
its parameters Discord-style instead: the one at the cursor highlighted,
missing required ones in red, given ones with their value, and whether it
needs a reply.
MentionsAdapter.searchUsernameOrHashtag runs on every change of the field;
afterwards its command lists are filled in. Tapping a row is handled here
(the chat's item listeners are wrapped after ChatActivity.createView):
a command without required parameters runs, like a bot command is sent;
any other is put into the field to complete; a parameter row adds `name:`.
"""
from base_plugin import MethodHook
import diag
from commands.parse import analyze, command_name_at, split_command
from config import SHOW_PARAMS
from jutil import array_list, field, hook_methods, set_field, unhook_all
from hooks.composer import reply_of
from hooks.notify import bulletin
ADAPTER = "org.telegram.ui.Adapters.MentionsAdapter"
CHAT = "org.telegram.ui.ChatActivity"
_CLEARED = (
"searchResultUsernames",
"searchResultUsernamesMap",
"searchResultHashtags",
"searchResultSuggestions",
"searchResultBotContext",
"stickers",
"hintHashtag",
)
_COMMAND_LISTS = (
"searchResultCommands",
"searchResultCommandsHelp",
"searchResultCommandsUsers",
"searchResultCommandsEphemeral",
)
def _identity(obj):
from java.lang import System
return int(System.identityHashCode(obj))
class Row:
__slots__ = ("kind", "command", "param", "label", "help", "style")
def __init__(self, kind, command, label, help, param=None, style=None):
self.kind = kind # command | header | param | reply
self.command = command
self.param = param
self.label = label
self.help = help
self.style = style # current | missing | filled | None
def param_rows(command, analysis, replying):
"""The rows describing `command`'s parameters while it is typed."""
rows = [Row("header", command, "/" + command.name, command.summary or command.usage())]
for param in command.params:
value = analysis.values.get(param.name)
need = "required" if param.required else "optional"
about = f"{param.type_label()} · {need}"
if param.description:
about += f" · {param.description}"
if value is not None:
about = f"✓ {value} · {about}"
if param.name == analysis.current:
style = "current"
elif value is not None:
style = "filled"
else:
style = "missing" if param.required else None
rows.append(Row("param", command, param.name, about, param, style))
if command.reply != "none":
if replying:
rows.append(Row("reply", command, "↩ reply", "✓ replying to a message", style="filled"))
else:
need = "required" if command.reply == "required" else "optional"
style = "missing" if command.reply == "required" else None
rows.append(Row("reply", command, "↩ reply", f"{need} · swipe a message to reply to it", style=style))
return rows
class Suggest:
def __init__(self, plugin, catalog, composer):
self.plugin = plugin
self.catalog = catalog
self.composer = composer
self.handles = []
self._states = {} # adapter identity -> ([Row] first in its list, that list's identity)
self._proxies = {}
def install(self):
suggest = self
class Search(MethodHook):
def before_hooked_method(self, param):
try:
if suggest.before_search(param.thisObject, param.args):
param.setResult(None)
except Exception as exc:
diag.error("suggest search", exc)
def after_hooked_method(self, param):
try:
suggest.after_search(param.thisObject, param.args)
except Exception as exc:
diag.error("suggest search", exc)
class Bind(MethodHook):
def after_hooked_method(self, param):
try:
suggest.on_bind(param.thisObject, param.args[0], int(param.args[1]))
except Exception as exc:
diag.error("suggest bind", exc)
class Created(MethodHook):
def after_hooked_method(self, param):
try:
suggest.wrap_listeners(param.thisObject)
except Exception as exc:
diag.error("suggest listeners", exc)
self.handles += hook_methods(self.plugin, ADAPTER, "searchUsernameOrHashtag", Search(), arity=5)
self.handles += hook_methods(self.plugin, ADAPTER, "onBindViewHolder", Bind(), arity=2)
self.handles += hook_methods(self.plugin, CHAT, "createView", Created(), arity=1)
def uninstall(self):
unhook_all(self.plugin, self.handles)
self._states.clear()
# filling the list
def _typed(self, adapter, args):
"""(chat, text, cursor, commands) when a command may be being typed."""
chat = getattr(adapter, "parentFragment", None)
if chat is None or args[3] or args[4] or args[0] is None:
return None
text = str(args[0])
if not text.startswith("/") or int(chat.getChatMode()) != 0:
return None
commands = self.catalog.commands_for(int(chat.getCurrentAccount()))
return (chat, text, int(args[1]), commands) if commands else None
def before_search(self, adapter, args):
"""Parameters of a known command: shown instead of the app's search
(whose `:` emoji search would replace them); True to skip it."""
self._states.pop(_identity(adapter), None)
typed = self._typed(adapter, args)
if typed is None:
return False
chat, text, cursor, commands = typed
for name in _COMMAND_LISTS: # left from an earlier search
set_field(adapter, name, None)
split = split_command(text)
if split is None or not self.plugin.get_setting(SHOW_PARAMS, True):
return False
command = next((c for c in commands if split[0] in c.names), None)
if command is None or (not command.params and command.reply == "none"):
return False
reply = reply_of(chat)
analysis = analyze(command, text, cursor, reply is not None)
self._fill(adapter, chat, param_rows(command, analysis, reply is not None), keep_bots=False)
self._text_changed(chat)
return True
def after_search(self, adapter, args):
"""Commands matching a bare `/prefix`, before the chat's bot commands."""
typed = self._typed(adapter, args)
chat = getattr(adapter, "parentFragment", None)
if chat is not None:
self._text_changed(chat)
if typed is None:
return
chat, text, cursor, commands = typed
prefix = command_name_at(text) if cursor >= len(text) else None
if prefix is None:
return
found = [c for c in commands if any(n.startswith(prefix) for n in c.names)]
if found:
rows = [Row("command", c, "/" + c.name, c.hint()) for c in found]
self._fill(adapter, chat, rows, keep_bots=True)
def _text_changed(self, chat):
enter = chat.getChatActivityEnterView()
if enter is not None:
self.composer.text_changed(enter)
def _fill(self, adapter, chat, rows, keep_bots):
from org.telegram.messenger import MessagesController, UserConfig
account = int(chat.getCurrentAccount())
me = MessagesController.getInstance(account).getUser(UserConfig.getInstance(account).getClientUserId())
names, helps, users, ephemeral = [], [], [], []
# Quick replies go too: the app binds command rows as if they were
# first in the list.
for row in rows:
names.append(row.label)
helps.append(row.help)
users.append(me if row.kind in ("command", "header") else None)
ephemeral.append(False)
if keep_bots:
bots = field(adapter, "searchResultCommands")
if bots is not None:
bot_help = field(adapter, "searchResultCommandsHelp")
bot_users = field(adapter, "searchResultCommandsUsers")
bot_ephemeral = field(adapter, "searchResultCommandsEphemeral")
for i in range(bots.size()):
names.append(bots.get(i))
helps.append(bot_help.get(i) if bot_help is not None and i < bot_help.size() else "")
users.append(bot_users.get(i) if bot_users is not None and i < bot_users.size() else None)
ephemeral.append(
bool(bot_ephemeral.get(i)) if bot_ephemeral is not None and i < bot_ephemeral.size() else False
)
for name in _CLEARED + ("quickReplies",):
set_field(adapter, name, None)
shown = array_list(names)
set_field(adapter, "searchResultCommands", shown)
set_field(adapter, "searchResultCommandsHelp", array_list(helps))
set_field(adapter, "searchResultCommandsUsers", array_list(users))
set_field(adapter, "searchResultCommandsEphemeral", array_list(ephemeral))
self._states[_identity(adapter)] = (rows, _identity(shown))
self.wrap_listeners(chat)
adapter.notifyDataSetChanged()
# Its diff compares the labels only: rows whose help or style
# changed have to be rebound too.
adapter.notifyItemRangeChanged(0, len(rows))
delegate = field(adapter, "delegate")
if delegate is not None:
delegate.needChangePanelVisibility(True)
def _rows(self, adapter):
"""Our rows while the adapter still shows them (an async search of
the app may have replaced them since)."""
state = self._states.get(_identity(adapter))
if state is None:
return ()
shown = field(adapter, "searchResultCommands")
if shown is None or _identity(shown) != state[1] or field(adapter, "searchResultSuggestions") is not None:
return ()
return state[0]
# styling rows
def on_bind(self, adapter, holder, position):
"""Colors our rows' names; a recycled cell gets its own color back.
The original color is kept in the name view's tag, which outlives
this plugin being reloaded."""
cell = holder.itemView
if cell.getClass().getSimpleName() != "MentionCell":
return
name_view = field(cell, "nameTextView")
if name_view is None:
return
from java import jint
rows = self._rows(adapter)
row = rows[position] if 0 <= position < len(rows) else None
original = name_view.getTag()
if row is None or row.style is None:
if original is not None:
name_view.setTextColor(jint(int(original)))
name_view.setTag(None)
return
from org.telegram.ui.ActionBar import Theme
if original is None:
name_view.setTag(int(name_view.getCurrentTextColor()))
color = {
"current": Theme.key_windowBackgroundWhiteBlueText,
"missing": Theme.key_text_RedRegular,
"filled": Theme.key_windowBackgroundWhiteGreenText,
}[row.style]
name_view.setTextColor(jint(Theme.getColor(color)))
# tapping rows
def wrap_listeners(self, chat):
"""Routes taps on the chat's suggestion list through `tapped`/`held`
(once per list and load of this plugin). The app's own listeners
handle every other row: the click one is ChatActivity's field, the
long-click one is kept across reloads (the list then holds ours)."""
from library import shared
container = field(chat, "mentionContainer")
if container is None:
return
list_view = container.getListView()
key = _identity(list_view)
mine = self._proxies.get(key)
current = field(list_view, "onItemClickListener")
if mine is not None and current is not None and _identity(current) == _identity(mine[0]):
return
click = field(chat, "mentionsOnItemClickListener")
long_clicks = shared().setdefault("long_clicks", {})
long_click = long_clicks.get(key) or field(list_view, "onItemLongClickListener")
long_clicks[key] = long_click
from java import dynamic_proxy
from org.telegram.ui.Components import RecyclerListView
suggest = self
class Click(dynamic_proxy(RecyclerListView.OnItemClickListener)):
def onItemClick(self, view, position):
handled = False
try:
handled = suggest.tapped(chat, container, position)
except Exception as exc:
diag.error("suggest tap", exc)
if not handled and click is not None:
click.onItemClick(view, position)
class LongClick(dynamic_proxy(RecyclerListView.OnItemLongClickListener)):
def onItemClick(self, view, position):
try:
if suggest.tapped(chat, container, position):
return True
except Exception as exc:
diag.error("suggest hold", exc)
return bool(long_click.onItemClick(view, position)) if long_click is not None else False
proxies = (Click(), LongClick())
self._proxies[key] = proxies # referenced as long as the list lives
if len(self._proxies) > 32:
self._proxies.pop(next(iter(self._proxies)))
list_view.setOnItemClickListener(proxies[0])
if long_click is not None:
list_view.setOnItemLongClickListener(proxies[1])
def row_at(self, container, position):
"""Our row at a list position (the list's first item is padding)."""
rows = self._rows(container.getAdapter())
index = position - 1
return rows[index] if 0 <= index < len(rows) else None
def _our_command_at(self, chat, container, position):
"""One of our commands at a list position though our rows are gone
(a stale list): its row is never left to the app, which would send
it."""
item = container.getAdapter().getItem(position - 1)
if not isinstance(item, str) or not item.startswith("/"):
return None
name = item[1:].split("@", 1)[0]
return self.catalog.find(int(chat.getCurrentAccount()), name)
def tapped(self, chat, container, position):
"""A tap (or hold) on the list: True when it was one of our rows.
Rows only ever fill the field; sending is the send button's job."""
row = self.row_at(container, position)
if row is None:
command = self._our_command_at(chat, container, position)
if command is None:
return False
row = Row("command", command, "/" + command.name, "")
enter = chat.getChatActivityEnterView()
if enter is None:
return True
command = row.command
if row.kind in ("command", "header"):
takes = command.params or command.reply != "none"
text = str(enter.getFieldText() or "")
if row.kind == "command" or split_command(text) is None:
self.composer.set_text(enter, f"/{command.name} " if takes else f"/{command.name}")
elif command.description:
bulletin(command.description)
elif row.kind == "param":
text = str(enter.getFieldText() or "")
if row.param.name not in (self._current_values(chat, text) or {}):
gap = "" if not text or text.endswith(" ") else " "
self.composer.set_text(enter, f"{text}{gap}{row.param.name}:")
elif row.kind == "reply":
bulletin("Swipe a message to the left to reply to it")
return True
def _current_values(self, chat, text):
typed = self.composer.read(chat.getChatActivityEnterView())
return typed.analysis.values if typed is not None else None