generated from exteraGramPlugins/pluginBase
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:
1 parent
84bdc44a76
commit
46a1f6ee1f
29 files changed
+3293
-220
No files matched your search
Whitespace-only changes.
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user