inugram-plugin-port

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Porting Python plugins to Inugram

将Python插件移植到Inugram

A port is a rewrite against a different, higher-level API, not a transliteration. The source plugins reach into app internals because their host offers little else. Inugram usually offers a typed, granted, thread-safe API for the same thing. The usual outcome is a fraction of the original's code with fewer and narrower grants.
Contract: the
@inugram/plugin-types
typings,
common.d.ts
plus
android.*.d.ts
and
canvas.d.ts
(
node_modules/@inugram/plugin-types/
in a plugin project,
sdk/types/
in the Inugram repo). Concepts and quirks: the plugin docs (
sdk/docs/*.md
in the Inugram repo, or https://github.com/teidesu/inugram/tree/main/sdk/docs). Read the docs for every API area the port touches before writing code. The typings tag each API with
@needs-grant
and
**Limits:**
.
移植是基于不同的、更上层的API进行重写,而非逐字转译。源插件需要深入应用内部实现,因为其宿主提供的API非常有限。而Inugram通常为相同功能提供了带类型、需授权、线程安全的API。移植后的代码量通常只有原版的一小部分,所需的授权更少、范围也更窄。
开发约定:参考
@inugram/plugin-types
类型定义,包括
common.d.ts
、
android.*.d.ts
和
canvas.d.ts
(插件项目中位于
node_modules/@inugram/plugin-types/
,Inugram仓库中位于
sdk/types/
)。概念与注意事项:参考插件文档(Inugram仓库中位于
sdk/docs/*.md
,或访问https://github.com/teidesu/inugram/tree/main/sdk/docs)。在编写代码前,请先阅读移植涉及的所有API领域的文档。类型定义中每个API都标注了`@needs-grant`(所需授权)和`**Limits:**`(使用限制)。

1. Understand before writing

1. 编写代码前先理解需求

  1. Read the whole original. List each user-visible behavior and each setting. Ignore its implementation for now.
  2. Audit it. Do not port, and report to the user, anything not needed for the advertised feature: joining/subscribing to channels, sending data to hosts unrelated to the feature, self-update or "check min version" logic, telemetry,
    exec
    /
    marshal
    /
    zlib
    /base64 blobs of code, embedded DEX payloads. An embedded DEX without source is a blocker: ask the user, do not ship it.
  3. Check Inugram already does it:
    FEATURES.md
    in the Inugram repo, stock settings (including Lite Mode), and built-in plugin APIs (e.g. translation providers). If it does, say so instead of porting.
  4. Split. A source plugin bundling several unrelated tweaks behind toggles becomes several small plugins, one feature each, no enable/disable toggles (the plugin list is the toggle). Propose the split before writing. Each gets only the grants it needs.
  5. The API is a work in progress - if while porting you find a missing feature, instead of trying to hack around it, tell the user about that so that they can reach out to Inugram devs.
  1. 通读原插件的全部代码。列出所有用户可见的行为和所有设置项。暂时忽略其具体实现方式。
  2. 对原插件进行审计。对于实现宣传功能不需要的内容,不要移植,并告知用户:包括加入/订阅频道、向与功能无关的主机发送数据、自更新或“检查最低版本”逻辑、遥测数据、
    exec
    /
    marshal
    /
    zlib
    /base64编码的代码块、嵌入式DEX payload等。没有源码的嵌入式DEX是阻塞性问题:请询问用户,不要直接打包移植。
  3. 检查Inugram是否已经内置了该功能:参考Inugram仓库中的
    FEATURES.md
    、原生设置(包括Lite Mode)以及内置插件API(例如翻译提供程序)。如果已内置,请告知用户,无需进行移植。
  4. 拆分功能。如果原插件通过开关捆绑了多个互不相关的调整功能,应拆分为多个小插件,每个插件只实现一个功能,不需要启用/禁用开关(插件列表本身就是开关)。在编写代码前先向用户提出拆分方案。每个插件仅申请其所需的授权。
  5. API仍在开发中——如果在移植过程中发现缺少某个功能,不要尝试用hack方式绕过,请告知用户,以便他们联系Inugram开发人员。

2. Pick the highest API that works

2. 优先选用最上层的可用API

Go down this list and stop at the first rung that covers the behavior:
  1. Data and events:
    onNewMessage
    /
    onMessageEdited
    /
    onMessageDeleted
    ,
    onUpdate
    ,
    interceptSendMessage
    ,
    interceptRpc
    ,
    interceptUpdate
    . Most "hide X", "block ads", "change what is sent", "auto-do Y on message" plugins are one interceptor. Rewriting the server's answer beats hooking every UI place that shows it.
  2. Account API:
    account.sendMessage
    /
    sendMedia
    /
    editMessage
    /
    setReaction
    /
    readHistory
    ,
    getUser
    /
    getChat
    /
    getMessagesCached
    /
    iterHistory
    etc. Then
    invokeRpc
    scoped per method for anything not covered.
  3. Declarative UI:
    inu.ui.settingsPage
    ,
    register{Global,Chat,Message,Profile}Action
    ,
    bulletin
    ,
    dialog
    ,
    chooser
    ,
    prompt
    ,
    selectPeers
    ,
    openPage
    ,
    openChatHistory
    ,
    getCurrentScreen
    /
    onScreenChanged
    ,
    inu.icons.*
    .
  4. Web-like I/O:
    fetch
    (scoped to hosts),
    localStorage
    ,
    inu.fs
    ,
    inu.canvas
    ,
    inu.clipboard
    ,
    inu.openUrl
    .
  5. unsafe.jvm
    : reflection,
    inu.jvm.defineClass
    for interfaces/subclasses/listeners,
    inu.android.nativeView
    for custom views.
  6. unsafe.xposed
    : method hooks, only when the behavior lives purely in app code with no data-layer seam (layout, drawing, animations, stock UI logic).
  7. inu.jvm.loadDex
    : effectively never. Use
    defineClass
    with routine bodies instead.
Every
unsafe.*
grant is shown to the user as dangerous. Mixing rungs is fine: a UI tweak can be an xposed hook while its settings are a plain
settingsPage
.
按以下列表从上到下查找,停在第一个能覆盖所需行为的层级:
  1. 数据与事件:
    onNewMessage
    /
    onMessageEdited
    /
    onMessageDeleted
    、
    onUpdate
    、
    interceptSendMessage
    、
    interceptRpc
    、
    interceptUpdate
    。大多数“隐藏X”、“屏蔽广告”、“修改发送内容”、“收到消息自动执行Y”类的插件只需要一个拦截器即可实现。改写服务器响应比给所有显示该内容的UI位置加钩子更高效。
  2. 账户API:
    account.sendMessage
    /
    sendMedia
    /
    editMessage
    /
    setReaction
    /
    readHistory
    、
    getUser
    /
    getChat
    /
    getMessagesCached
    /
    iterHistory
    等。对于未覆盖的功能,再使用按方法限定范围的
    invokeRpc
    。
  3. 声明式UI:
    inu.ui.settingsPage
    、
    register{Global,Chat,Message,Profile}Action
    、
    bulletin
    、
    dialog
    、
    chooser
    、
    prompt
    、
    selectPeers
    、
    openPage
    、
    openChatHistory
    、
    getCurrentScreen
    /
    onScreenChanged
    、
    inu.icons.*
    。
  4. 类Web I/O:
    fetch
    (按主机限定范围)、
    localStorage
    、
    inu.fs
    、
    inu.canvas
    、
    inu.clipboard
    、
    inu.openUrl
    。
  5. unsafe.jvm
    :反射、用于接口/子类/监听器的
    inu.jvm.defineClass
    、用于自定义视图的
    inu.android.nativeView
    。
  6. unsafe.xposed
    :方法钩子,仅当行为完全存在于应用代码中、没有数据层入口时使用(例如布局、绘制、动画、原生UI逻辑)。
  7. inu.jvm.loadDex
    :几乎永远不要使用。请改用带routine体的
    defineClass
    。
所有
unsafe.*
授权都会向用户标记为危险。可以混合使用不同层级:一个UI调整可以用xposed钩子实现,而其设置页面用普通的
settingsPage
实现。

3. API map

3. API映射表

Source API on the left, the recommended Inugram equivalent on the right. "Avoid" rows name the mechanical equivalent only so it is recognized; use the recommended one instead. Exact signatures are in the typings.
左侧为源API,右侧为推荐的Inugram等效API。标有“避免使用”的行仅列出了机械对应的API以便识别,请优先使用推荐的API。具体签名请参考类型定义。

Plugin metadata and lifecycle (
base_plugin.BasePlugin
)

插件元数据与生命周期(
base_plugin.BasePlugin
)

SourceInugram
__id__
manifest
id
, only if the user wants one; reverse-domain recommended
__name__
,
__version__
,
__description__
manifest
name
,
version
,
description
(
{ en, ru }
when the original is bilingual)
__author__
manifest
author
:
'<original> (original), <porter> (Inugram port)'
__icon__ = "Set/3"
manifest
icon: 'tg://addstickers?set=Set&idx=3'
__min_version__
,
__app_version__
, update checkers
drop
on_plugin_load()
top-level code. Register interceptors first (startup waits up to 500 ms for them). No top-level
await
: wrap async start-up in a function
on_plugin_unload()
usually nothing: every registration, timer, page and hook is released on unload.
inu.onUnload
only for state left in the app's Java objects
unhook_method(h)
,
remove_menu_item(id)
,
remove_hook(name)
call the
Disposer
the registration returned
on_app_event(AppEvent.START / STOP / RESUME / PAUSE)
inu.onAppVisibilityChange
, modes
'foreground'
/
'background'
,
'resumed'
/
'paused'
respectively
self.log(...)
,
android_utils.log(...)
console.log
/
console.warn
/
console.error
. Keep only what helps debugging
try/except
+
traceback.format_exc()
around everything
drop. Uncaught errors are logged;
.catch
floating promises
源APIInugram等效
__id__
清单中的
id
,仅当用户需要时设置;推荐使用反向域名格式
__name__
、
__version__
、
__description__
清单中的
name
、
version
、
description
(如果原插件是双语的,则使用
{ en, ru }
格式)
__author__
清单中的
author
:
'<原作者> (original), <移植者> (Inugram port)'
__icon__ = "Set/3"
清单中的
icon: 'tg://addstickers?set=Set&idx=3'
__min_version__
、
__app_version__
、更新检查逻辑
直接移除
on_plugin_load()
顶层代码。请先注册拦截器(启动时最多等待500毫秒用于注册拦截器)。不要使用顶层
await
:请将异步启动逻辑封装在函数中
on_plugin_unload()
通常不需要处理:所有注册、定时器、页面和钩子都会在卸载时自动释放。仅当应用的Java对象中残留了状态时才使用
inu.onUnload
unhook_method(h)
、
remove_menu_item(id)
、
remove_hook(name)
调用注册时返回的
Disposer
on_app_event(AppEvent.START / STOP / RESUME / PAUSE)
inu.onAppVisibilityChange
,模式分别对应
'foreground'
/
'background'
、
'resumed'
/
'paused'
self.log(...)
、
android_utils.log(...)
console.log
/
console.warn
/
console.error
。仅保留有助于调试的日志
包裹所有代码的
try/except
+
traceback.format_exc()
直接移除。未捕获的错误会自动记录;请为游离的promise添加
.catch

Settings (
get_setting
,
set_setting
,
create_settings
,
ui.settings
)

设置(
get_setting
、
set_setting
、
create_settings
、
ui.settings
)

SourceInugram
get_setting(key, default)
/
set_setting(key, value)
localStorage
with a
DEFAULTS
object, one JSON value per key (see
in-app-notifications.ts
)
create_settings()
returning rows
inu.ui.settingsPage({ title, items: () => [...] })
+
inu.registerSettings(page)
Header(text)
inu.ui.header(text)
Divider(text)
inu.ui.separator(text)
Switch(key, text, default, subtext, icon, on_change)
inu.ui.check({ id, text, subtitle, icon, checked, onChange })
;
onChange
must store the value
Selector(key, text, default, items, icon, on_change)
inu.ui.select({ id, text, items, selected, onChange })
Input(key, text, default, subtext, icon)
/
EditText
inu.ui.button({ value, onClick })
opening
inu.ui.prompt
Text(text, icon, on_click, accent, red)
inu.ui.button({ text, icon, onClick })
numeric
Input
for a bounded number
inu.ui.slider
Custom(view)
rethink with stock rows first; else
inu.android.nativeView(view)
(needs
unsafe.jvm
)
conditional rows (
if self.get_setting(...)
)
same
if
inside
items()
; give dynamic rows explicit
id
s
icon="msg_settings"
(an
R.drawable
name)
inu.android.resourceIcon('msg_settings')
, or
inu.icons.common(...)
when one fits, but prefer common icons
源APIInugram等效
get_setting(key, default)
/
set_setting(key, value)
搭配
DEFAULTS
对象使用
localStorage
,每个键对应一个JSON值(参考
in-app-notifications.ts
)
create_settings()
返回行
inu.ui.settingsPage({ title, items: () => [...] })
+
inu.registerSettings(page)
Header(text)
inu.ui.header(text)
Divider(text)
inu.ui.separator(text)
Switch(key, text, default, subtext, icon, on_change)
inu.ui.check({ id, text, subtitle, icon, checked, onChange })
;
onChange
中必须存储值
Selector(key, text, default, items, icon, on_change)
inu.ui.select({ id, text, items, selected, onChange })
Input(key, text, default, subtext, icon)
/
EditText
点击
inu.ui.button({ value, onClick })
后打开
inu.ui.prompt
Text(text, icon, on_click, accent, red)
inu.ui.button({ text, icon, onClick })
用于范围内数值的数字
Input
inu.ui.slider
Custom(view)
优先用原生行组件重新设计;否则使用
inu.android.nativeView(view)
(需要
unsafe.jvm
授权)
条件行(
if self.get_setting(...)
)
在
items()
中使用相同的
if
判断;请为动态行设置明确的
id
icon="msg_settings"
(
R.drawable
名称)
inu.android.resourceIcon('msg_settings')
,如果有合适的也可以用
inu.icons.common(...)
,优先使用通用图标

Messages and requests (
client_utils
, send/request/update hooks)

消息与请求(
client_utils
、发送/请求/更新钩子)

SourceInugram
add_on_send_message_hook()
+
on_send_message_hook(account, params)
inu.interceptSendMessage(filter, ({ message, account }) => ...)
(needs
interceptSendMessage
)
if not params.message.startswith('.cmd'): return HookResult()
{ text: /^\.cmd\b/ }
filter: non-matching sends never reach JS
params.message
,
params.entities
,
params.caption
message.text
(a
TextWithEntities
; assign a string or
md
/
html
result). The caption is
message.text
too
params.peer
message.peer
(marked id)
params.replyToMsg
,
params.replyToTopMsg
,
params.replyQuote
message.reply
,
message.topicId
params.photo
,
params.document
message.media
(
LocalMedia
/
InputMedia
),
account.createLocalMedia
params.scheduleDate
,
params.notify
message.scheduleDate
,
message.silent
HookResult(strategy=MODIFY / MODIFY_FINAL, params=params)
mutate
message
, return
'send'
HookResult()
/
HookStrategy.DEFAULT
return
'send'
without changes
HookStrategy.CANCEL
+
run_on_queue(work)
+
send_message(...)
later
async middleware:
await
the work, edit
message
, return
'send'
(60 s budget,
context.signal
). Avoid drop-and-resend
HookStrategy.CANCEL
with nothing to send (local-only command)
return
'drop'
pre_request_hook('TL_messages_getHistory', account, request)
inu.interceptRpc('messages.getHistory', (ctx, next) => ...)
, edit
ctx.request
(
interceptRpc(messages.getHistory)
)
post_request_hook(name, account, response, error)
same middleware:
const res = await next()
, edit or replace
res
; an error rejects
next()
as
inu.RpcError
HookStrategy.CANCEL
in
pre_request_hook
return a response without calling
next()
, or return/throw
inu.RpcError
add_hook('TL_updateNewMessage')
+
on_update_hook
to modify/drop
inu.interceptUpdate('updateNewMessage', ({ update }) => 'deliver' / 'drop')
on_update_hook
/
on_updates_hook
only to react
inu.onNewMessage
/
onMessageEdited
/
onMessageDeleted
(
onUpdate(new_message)
etc.), or
inu.onUpdate([...types])
add_hook(name, match_substring=True)
list exact constructors / methods; there is no substring match
send_message({'peer', 'message', 'entities', 'replyToMsg', ...})
,
send_text
account.sendMessage(peer, md\
...`, { replyToMessageId, topicId, silent })
 (
account.write(send)`)
send_photo
,
send_document
,
send_audio
account.sendMedia
/
sendMultiMedia
with
createLocalMedia(blob)
or
uploadFile
send_request(TL_x(), RequestCallback(lambda res, err: ...))
typed account method if one exists, else
try { await inu.invokeRpc({ _: 'x', ... }) } catch (e) { if (e instanceof inu.RpcError) ... }
(
invokeRpc(x)
)
get_messages_controller().getUser(id)
/
getChat(id)
/
dialogs_dict
account.getUser
/
getChat
/
getDialog
/
iterDialogs
(
account.read(peers)
,
account.read(dialogs)
)
get_messages_controller().isDialogMuted(...)
account.isDialogMuted(dialogId, { topicId })
get_messages_controller().getUserFull
,
loadFullUser
account.getUserFull
/
getChatFull
getMessagesStorage()
reads, history loops
account.getMessagesCached
/
getMessages
/
iterHistory
(
account.read(messages)
,
account.read(history)
)
MessageObject.messageOwner.message
,
.isOut()
,
.getDialogId()
, media checks
inu.Message
getters:
text
,
out
,
dialogId
,
senderId
,
topicId
,
mediaType
,
replyToMessageId
,
forwardedFrom
;
raw
for the rest
MessageObject.messageText
(what the chat list shows)
account.previewMessage(message)
get_user_config().getClientUserId()
,
getCurrentUser()
account.userId
,
account.getMe()
(
account.read(self)
)
UserConfig.selectedAccount
,
get_account_instance()
,
get_connections_manager()
the
account
passed to the handler,
inu.account()
,
inu.withCurrentAccount
get_file_loader().loadFile(...)
,
FileLoader.getPathToAttach
account.downloadMedia(message)
/
downloadMediaToFile
getSendMessagesHelper().sendReaction
,
markDialogAsRead
,
deleteMessages
,
editMessage
account.setReaction
/
readHistory
/
deleteMessages
/
editMessage
(
account.write(...)
scopes)
sendTyping
via
TL_messages_setTyping
account.sendTyping
(
account.write(typing)
)
custom translation via hooks on the translate controller
inu.registerTranslationProvider
NotificationCenter.getInstance(acc).addObserver(...)
the event APIs above first;
inu.android.addNotificationCenterDelegate
(
unsafe.notificationCenter
+
unsafe.jvm
) last
hooks suppressing system notifications
inu.notifications.suppress()
/
account.suppressNotifications()
(
notifications.suppress
)
markdown_utils.parse_markdown(text)
inu.utils.md
(
**b**
,
__i__
,
--u--
,
~~s~~
,
||spoiler||
), or
html
/
thtml
源APIInugram等效
add_on_send_message_hook()
+
on_send_message_hook(account, params)
inu.interceptSendMessage(filter, ({ message, account }) => ...)
(需要
interceptSendMessage
授权)
if not params.message.startswith('.cmd'): return HookResult()
使用
{ text: /^\.cmd\b/ }
过滤器:不匹配的发送请求不会进入JS层
params.message
、
params.entities
、
params.caption
message.text
(类型为
TextWithEntities
;可赋值字符串或
md
/
html
处理结果)。caption也是
message.text
params.peer
message.peer
(标记ID)
params.replyToMsg
、
params.replyToTopMsg
、
params.replyQuote
message.reply
、
message.topicId
params.photo
、
params.document
message.media
(
LocalMedia
/
InputMedia
)、
account.createLocalMedia
params.scheduleDate
、
params.notify
message.scheduleDate
、
message.silent
HookResult(strategy=MODIFY / MODIFY_FINAL, params=params)
修改
message
,返回
'send'
HookResult()
/
HookStrategy.DEFAULT
不做修改,返回
'send'
HookStrategy.CANCEL
+
run_on_queue(work)
+ 后续调用
send_message(...)
异步中间件:
await
执行任务,修改
message
,然后返回
'send'
(时间预算60秒,使用
context.signal
)。避免“丢弃后重发”的模式
无内容可发送的
HookStrategy.CANCEL
(仅本地命令)
返回
'drop'
pre_request_hook('TL_messages_getHistory', account, request)
inu.interceptRpc('messages.getHistory', (ctx, next) => ...)
,修改
ctx.request
(需要
interceptRpc(messages.getHistory)
授权)
post_request_hook(name, account, response, error)
同一个中间件:
const res = await next()
,修改或替换
res
;错误会以
inu.RpcError
的形式拒绝
next()
pre_request_hook
中的
HookStrategy.CANCEL
不调用
next()
直接返回响应,或者返回/抛出
inu.RpcError
add_hook('TL_updateNewMessage')
+
on_update_hook
用于修改/丢弃
inu.interceptUpdate('updateNewMessage', ({ update }) => 'deliver' / 'drop')
仅用于响应的
on_update_hook
/
on_updates_hook
inu.onNewMessage
/
onMessageEdited
/
onMessageDeleted
(
onUpdate(new_message)
等),或者
inu.onUpdate([...types])
add_hook(name, match_substring=True)
列出确切的构造函数/方法;不支持子字符串匹配
send_message({'peer', 'message', 'entities', 'replyToMsg', ...})
、
send_text
account.sendMessage(peer, md\
...`, { replyToMessageId, topicId, silent })
(
account.write(send)`授权)
send_photo
、
send_document
、
send_audio
account.sendMedia
/
sendMultiMedia
,搭配
createLocalMedia(blob)
或
uploadFile
使用
send_request(TL_x(), RequestCallback(lambda res, err: ...))
如果有对应的类型化账户方法则使用,否则使用
try { await inu.invokeRpc({ _: 'x', ... }) } catch (e) { if (e instanceof inu.RpcError) ... }
(
invokeRpc(x)
授权)
get_messages_controller().getUser(id)
/
getChat(id)
/
dialogs_dict
account.getUser
/
getChat
/
getDialog
/
iterDialogs
(
account.read(peers)
、
account.read(dialogs)
授权)
get_messages_controller().isDialogMuted(...)
account.isDialogMuted(dialogId, { topicId })
get_messages_controller().getUserFull
、
loadFullUser
account.getUserFull
/
getChatFull
getMessagesStorage()
读取、历史循环
account.getMessagesCached
/
getMessages
/
iterHistory
(
account.read(messages)
、
account.read(history)
授权)
MessageObject.messageOwner.message
、
.isOut()
、
.getDialogId()
、媒体检查
inu.Message
的getter:
text
、
out
、
dialogId
、
senderId
、
topicId
、
mediaType
、
replyToMessageId
、
forwardedFrom
;其余属性通过
raw
访问
MessageObject.messageText
(聊天列表显示的内容)
account.previewMessage(message)
get_user_config().getClientUserId()
、
getCurrentUser()
account.userId
、
account.getMe()
(
account.read(self)
授权)
UserConfig.selectedAccount
、
get_account_instance()
、
get_connections_manager()
处理函数中传入的
account
、
inu.account()
、
inu.withCurrentAccount
get_file_loader().loadFile(...)
、
FileLoader.getPathToAttach
account.downloadMedia(message)
/
downloadMediaToFile
getSendMessagesHelper().sendReaction
、
markDialogAsRead
、
deleteMessages
、
editMessage
account.setReaction
/
readHistory
/
deleteMessages
/
editMessage
(
account.write(...)
范围授权)
通过
TL_messages_setTyping
实现的
sendTyping
account.sendTyping
(
account.write(typing)
授权)
通过翻译控制器钩子实现的自定义翻译
inu.registerTranslationProvider
NotificationCenter.getInstance(acc).addObserver(...)
优先使用上述事件API;最后再考虑
inu.android.addNotificationCenterDelegate
(需要
unsafe.notificationCenter
+
unsafe.jvm
授权)
屏蔽系统通知的钩子
inu.notifications.suppress()
/
account.suppressNotifications()
(
notifications.suppress
授权)
markdown_utils.parse_markdown(text)
inu.utils.md
(支持
**b**
、
__i__
、
--u--
、
~~s~~
、
||spoiler||
),或者
html
/
thtml

UI (
add_menu_item
,
ui.bulletin
,
ui.alert
, fragments)

UI(
add_menu_item
、
ui.bulletin
、
ui.alert
、fragment)

SourceInugram
add_menu_item(MenuItemData(menu_type=MenuItemType.DRAWER_MENU, ...))
inu.registerGlobalAction({ id, text, icon, callback })
MenuItemType.CHAT_ACTION_MENU
inu.registerChatAction
(
ctx.dialogId
,
ctx.topicId
,
ctx.account
)
MenuItemType.MESSAGE_CONTEXT_MENU
inu.registerMessageAction
(
ctx.messages
,
placements: ['bubble', 'selection']
)
MenuItemType.PROFILE_ACTION_MENU
inu.registerProfileAction
MenuItemData(item_id, text, icon, subtext, condition, priority)
id
(stable),
text
,
icon
,
visible: ctx => ...
; no priority (users reorder)
context.get('dialog_id' / 'message' / 'user' / 'chat')
in
on_click
ctx.dialogId
,
ctx.messages
,
account.getUser(ctx.dialogId)
/
getChat
context.get('fragment')
to open something
inu.ui.openPage({ type: 'chat' / 'profile' / 'settings', ... })
BulletinHelper.show_success / show_error / show_info(text)
inu.ui.bulletin({ text, icon: inu.icons.animation('success' / 'error' / 'info') })
BulletinHelper.show_with_button(text, icon, button, on_click)
,
show_undo
const r = await inu.ui.bulletin({ text, icon, button })
, act on
r === 'button'
BulletinHelper.show_two_line(title, subtitle)
inu.ui.bulletin({ text, subtitle, icon })
BulletinHelper.show_copied_to_clipboard()
inu.clipboard.write(text)
+ a short bulletin
BulletinHelper.show_simple(text)
/ Android
Toast
inu.ui.bulletin(...)
/
inu.ui.toast(text)
AlertDialogBuilder
with title, message, positive/negative/neutral buttons
const r = await inu.ui.dialog({ title, message, positive, negative, neutral })
AlertDialogBuilder.set_items(...)
inu.ui.chooser({ items })
(
multiple: true
for checkboxes)
AlertDialogBuilder
+
EditTextBoldCursor
via
set_view
inu.ui.prompt({ title, hint, value })
make_button_red
chooser
item
{ text, danger: true }
ALERT_TYPE_SPINNER
/
ALERT_TYPE_LOADING
progress dialog
bulletin with
inu.icons.animation('loading')
, or none: the work is async anyway
custom
BottomSheet
/
BaseFragment
lists
inu.ui.settingsPage
opened with
inu.ui.openPage(page)
(
transient: true
for one-off pages)
a fragment showing a list of messages
inu.ui.openChatHistory
contact/chat picker activities (
DialogsActivity
with a delegate)
inu.ui.selectPeers
get_last_fragment()
to check where the user is
inu.ui.getCurrentScreen()
/
inu.ui.onScreenChanged
get_last_fragment()
/
get_activity()
as a Java object
inu.android.getCurrentFragment()
/
getCurrentActivity()
(
unsafe.jvm
), only for JVM work
Browser.openUrl
,
Intent.ACTION_VIEW
inu.openUrl(url)
(
openUrl
)
android_utils.copy_to_clipboard
,
ClipboardManager
inu.clipboard.write
/
read
(
clipboard.write
,
clipboard.read
)
Intent-based file pickers,
startActivityForResult
hooks
inu.ui.pickFile
,
inu.ui.saveFile
R.drawable.x
inu.android.resourceIcon('x')
OnClickListener(fn)
on a settings or menu row
the row's
onClick
/
callback
源APIInugram等效
add_menu_item(MenuItemData(menu_type=MenuItemType.DRAWER_MENU, ...))
inu.registerGlobalAction({ id, text, icon, callback })
MenuItemType.CHAT_ACTION_MENU
inu.registerChatAction
(
ctx.dialogId
、
ctx.topicId
、
ctx.account
)
MenuItemType.MESSAGE_CONTEXT_MENU
inu.registerMessageAction
(
ctx.messages
、
placements: ['bubble', 'selection']
)
MenuItemType.PROFILE_ACTION_MENU
inu.registerProfileAction
MenuItemData(item_id, text, icon, subtext, condition, priority)
id
(需稳定不变)、
text
、
icon
、
visible: ctx => ...
;没有优先级(用户可自行排序)
on_click
中的
context.get('dialog_id' / 'message' / 'user' / 'chat')
ctx.dialogId
、
ctx.messages
、
account.getUser(ctx.dialogId)
/
getChat
用于打开页面的
context.get('fragment')
inu.ui.openPage({ type: 'chat' / 'profile' / 'settings', ... })
BulletinHelper.show_success / show_error / show_info(text)
inu.ui.bulletin({ text, icon: inu.icons.animation('success' / 'error' / 'info') })
BulletinHelper.show_with_button(text, icon, button, on_click)
、
show_undo
const r = await inu.ui.bulletin({ text, icon, button })
,根据
r === 'button'
执行操作
BulletinHelper.show_two_line(title, subtitle)
inu.ui.bulletin({ text, subtitle, icon })
BulletinHelper.show_copied_to_clipboard()
inu.clipboard.write(text)
+ 简短的bulletin提示
BulletinHelper.show_simple(text)
/ Android
Toast
inu.ui.bulletin(...)
/
inu.ui.toast(text)
带标题、消息、确定/取消/中性按钮的
AlertDialogBuilder
const r = await inu.ui.dialog({ title, message, positive, negative, neutral })
AlertDialogBuilder.set_items(...)
inu.ui.chooser({ items })
(多选时设置
multiple: true
)
通过
set_view
添加
EditTextBoldCursor
的
AlertDialogBuilder
inu.ui.prompt({ title, hint, value })
make_button_red
chooser项设置
{ text, danger: true }
ALERT_TYPE_SPINNER
/
ALERT_TYPE_LOADING
进度对话框
搭配
inu.icons.animation('loading')
的bulletin,或者不需要:任务本身就是异步的
自定义
BottomSheet
/
BaseFragment
列表
用
inu.ui.openPage(page)
打开的
inu.ui.settingsPage
(一次性页面设置
transient: true
)
显示消息列表的fragment
inu.ui.openChatHistory
联系人/聊天选择器Activity(带委托的
DialogsActivity
)
inu.ui.selectPeers
用于检查用户位置的
get_last_fragment()
inu.ui.getCurrentScreen()
/
inu.ui.onScreenChanged
作为Java对象的
get_last_fragment()
/
get_activity()
inu.android.getCurrentFragment()
/
getCurrentActivity()
(需要
unsafe.jvm
授权),仅用于JVM相关操作
Browser.openUrl
、
Intent.ACTION_VIEW
inu.openUrl(url)
(
openUrl
授权)
android_utils.copy_to_clipboard
、
ClipboardManager
inu.clipboard.write
/
read
(
clipboard.write
、
clipboard.read
授权)
基于Intent的文件选择器、
startActivityForResult
钩子
inu.ui.pickFile
、
inu.ui.saveFile
R.drawable.x
inu.android.resourceIcon('x')
设置或菜单行的
OnClickListener(fn)
行的
onClick
/
callback
属性

Threads, I/O, Python libraries

线程、I/O与Python库

SourceInugram
run_on_ui_thread(fn)
,
run_on_queue(fn, PLUGINS_QUEUE / GLOBAL_QUEUE)
,
threading.Thread
not needed: high-level APIs hop threads themselves. Use
async
/
await
; split long loops with
await
(2 s per turn)
time.sleep(n)
await new Promise(r => setTimeout(r, n * 1000))
threading.Timer
, polling loops
setTimeout
/
setInterval
(throttled in background)
requests.get/post(...)
,
urllib
fetch(url, { timeout })
with
fetch(host)
grant per host
json
,
re
,
datetime
,
random
,
uuid
JS builtins;
inu.utils.formatDate
/
formatNumber
/
formatFileSize
/
formatDuration
;
crypto.randomUUID
base64
, hex
inu.utils.toBase64
/
fromBase64
/
toHex
/
fromHex
file_utils.get_cache_dir / get_files_dir / get_plugins_dir
,
read_file
,
write_file
,
ensure_dir_exists
,
list_dir
,
open()
inu.fs
(
read
,
write
,
append
,
mkdir
,
readdir
,
exists
,
stat
,
rm
,
copy
,
move
) in the private dir (
fs
,
fs(200mb)
)
writing to public Downloads / gallery
inu.ui.saveFile
JSON settings files on disk
localStorage
PIL.Image
,
ImageDraw
,
ImageFont
,
ImageSequence
inu.canvas
(
create
,
decode
,
decodeAnimation
,
loadFont
,
createEncoder
,
convertToBlob
)
android.graphics.Bitmap
/
Canvas
via reflection for image work
inu.canvas
MediaMetadataRetriever
for frames/size
LocalMedia.width/height/duration
,
inu.canvas.decodeAnimation
bundled pure-Python libsan npm package bundled by the CLI (no DOM/Node APIs), or a few lines of TS. Ask before adding a dependency
源APIInugram等效
run_on_ui_thread(fn)
、
run_on_queue(fn, PLUGINS_QUEUE / GLOBAL_QUEUE)
、
threading.Thread
不需要:上层API会自动切换线程。使用
async
/
await
;长循环用
await
拆分(每个回合最多2秒)
time.sleep(n)
await new Promise(r => setTimeout(r, n * 1000))
threading.Timer
、轮询循环
setTimeout
/
setInterval
(后台运行时会被节流)
requests.get/post(...)
、
urllib
搭配
fetch(host)
授权的
fetch(url, { timeout })
,按主机授权
json
、
re
、
datetime
、
random
、
uuid
JS内置对象;
inu.utils.formatDate
/
formatNumber
/
formatFileSize
/
formatDuration
;
crypto.randomUUID
base64
、hex
inu.utils.toBase64
/
fromBase64
/
toHex
/
fromHex
file_utils.get_cache_dir / get_files_dir / get_plugins_dir
、
read_file
、
write_file
、
ensure_dir_exists
、
list_dir
、
open()
私有目录下的
inu.fs
(
read
、
write
、
append
、
mkdir
、
readdir
、
exists
、
stat
、
rm
、
copy
、
move
)(
fs
、
fs(200mb)
授权)
写入公共下载目录/相册
inu.ui.saveFile
磁盘上的JSON设置文件
localStorage
PIL.Image
、
ImageDraw
、
ImageFont
、
ImageSequence
inu.canvas
(
create
、
decode
、
decodeAnimation
、
loadFont
、
createEncoder
、
convertToBlob
)
用于图像处理的反射调用
android.graphics.Bitmap
/
Canvas
inu.canvas
用于获取帧/尺寸的
MediaMetadataRetriever
LocalMedia.width/height/duration
、
inu.canvas.decodeAnimation
捆绑的纯Python库由CLI打包的npm包(不能使用DOM/Node API),或者几行TS代码。添加依赖前请先询问用户

JVM and hooking (
hook_utils
,
java
, xposed)

JVM与钩子(
hook_utils
、
java
、xposed)

Almost all of the below need
unsafe.jvm
(and some additionally need
unsafe.xposed
).
SourceInugram
find_class('a.b.C')
,
jclass('a.b.C')
,
from org.telegram... import C
inu.jvm.cls('a.b.C')
; nested
a.b.C$D
C.staticMethod(x)
,
C.FIELD
C.callStatic('staticMethod', x)
,
C.getStaticField('FIELD')
obj.method(x)
,
obj.field
obj.call('method', x)
,
obj.getField('field')
get_private_field(obj, 'f')
/
set_private_field(obj, 'f', v)
obj.getField('f')
/
obj.setField('f', v)
C.getClass().getDeclaredMethod('m', T1, T2)
+
setAccessible
C.getDeclaredMethod('m(LT1;LT2;)V')
(Dalvik descriptor)
jarray(T)(...)
,
ArrayList()
building
new (inu.jvm.cls('java.util.ArrayList'))()
; JS arrays do not cross the bridge
Bundle()
+
putX
inu.android.bundle({ ... })
TLRPC.TL_x()
built by hand
a plain TL object
{ _: 'x', ... }
;
inu.jvm.fromTl
only when Java needs the instance
dynamic_proxy(Runnable)
inu.jvm.routine(...)
(always runs) or
inu.jvm.runnable(fn)
(rare, needs plugin APIs)
dynamic_proxy(SomeInterface)
/ listener classes
inu.jvm.defineClass({ interfaces: [I], methods: { ... } })
; routine bodies when called often or off-thread
Python subclass of a Java class
defineClass({ superclass, methods, constructors })
,
inu.jvm.getSuper(this)
InMemoryDexClassLoader
/
DexClassLoader
with a bundled DEX
defineClass
+ routines. Never
inu.jvm.loadDex
without the user's sign-off; an opaque payload is a blocker
self.hook_method(m, MethodHook)
with
before_hooked_method
/
after_hooked_method
inu.xposed.hookMethod(m, { before, after })
(
unsafe.xposed
+
unsafe.jvm
)
MethodReplacement.replace_hooked_method
/
XC_MethodReplacement
before
calling
ctx.setReturnValue(v)
param.args[i] = v
,
param.thisObject
,
param.getResult()
,
param.setResult(v)
ctx.args[i] = v
,
ctx.thisObject
,
ctx.returnValue
,
ctx.setReturnValue(v)
param.setThrowable(t)
ctx.setThrowable(t)
XposedBridge.invokeOriginalMethod
inu.xposed.callOriginalMethod(method, thisObject, args)
self.hook_all_methods(C, 'name', hook)
inu.xposed.hookAllOverloads(C, 'name', hook)
self.hook_all_constructors(C, hook)
inu.xposed.hookAllConstructors(C, hook)
priority=...
none; hooks chain in registration / plugin order
@hook_filters(HookFilter.ArgumentNotNull(0), HookFilter.Condition(...))
filter: inu.jvm.routine((a0) => a0 !== null && ...)
gating a JS hook, or the check inside a routine hook
hook on a hot path (bind, measure, layout, draw, per-message) in Python
inu.xposed.routine((ctx) => ...)
. Never a plain JS hook there
layout_hook
,
XposedHook
helper decorators
plain
hookMethod
/
hookAllOverloads
with routine phases
PluginsController
/ host plugin internals, the source client's own
*Config
classes and packages
do not exist. Find the stock Telegram equivalent or drop the feature
以下几乎所有API都需要
unsafe.jvm
授权(部分还需要
unsafe.xposed
授权)。
源APIInugram等效
find_class('a.b.C')
、
jclass('a.b.C')
、
from org.telegram... import C
inu.jvm.cls('a.b.C')
;嵌套类为
a.b.C$D
C.staticMethod(x)
、
C.FIELD
C.callStatic('staticMethod', x)
、
C.getStaticField('FIELD')
obj.method(x)
、
obj.field
obj.call('method', x)
、
obj.getField('field')
get_private_field(obj, 'f')
/
set_private_field(obj, 'f', v)
obj.getField('f')
/
obj.setField('f', v)
C.getClass().getDeclaredMethod('m', T1, T2)
+
setAccessible
C.getDeclaredMethod('m(LT1;LT2;)V')
(Dalvik描述符格式)
jarray(T)(...)
、构建
ArrayList()
new (inu.jvm.cls('java.util.ArrayList'))()
;JS数组不能跨桥接传递
Bundle()
+
putX
inu.android.bundle({ ... })
手动构建的
TLRPC.TL_x()
普通TL对象
{ _: 'x', ... }
;仅当Java需要实例时才使用
inu.jvm.fromTl
dynamic_proxy(Runnable)
inu.jvm.routine(...)
(始终执行)或
inu.jvm.runnable(fn)
(很少使用,需要插件API支持)
dynamic_proxy(SomeInterface)
/ 监听器类
inu.jvm.defineClass({ interfaces: [I], methods: { ... } })
;如果调用频繁或在非插件线程执行,使用routine体
Java类的Python子类
defineClass({ superclass, methods, constructors })
、
inu.jvm.getSuper(this)
带捆绑DEX的
InMemoryDexClassLoader
/
DexClassLoader
defineClass
+ routines。未经用户同意绝对不要使用
inu.jvm.loadDex
;不透明的payload是阻塞性问题
带
before_hooked_method
/
after_hooked_method
的
self.hook_method(m, MethodHook)
inu.xposed.hookMethod(m, { before, after })
(需要
unsafe.xposed
+
unsafe.jvm
授权)
MethodReplacement.replace_hooked_method
/
XC_MethodReplacement
在
before
中调用
ctx.setReturnValue(v)
param.args[i] = v
、
param.thisObject
、
param.getResult()
、
param.setResult(v)
ctx.args[i] = v
、
ctx.thisObject
、
ctx.returnValue
、
ctx.setReturnValue(v)
param.setThrowable(t)
ctx.setThrowable(t)
XposedBridge.invokeOriginalMethod
inu.xposed.callOriginalMethod(method, thisObject, args)
self.hook_all_methods(C, 'name', hook)
inu.xposed.hookAllOverloads(C, 'name', hook)
self.hook_all_constructors(C, hook)
inu.xposed.hookAllConstructors(C, hook)
priority=...
不支持;钩子按注册/插件顺序链式执行
@hook_filters(HookFilter.ArgumentNotNull(0), HookFilter.Condition(...))
用
filter: inu.jvm.routine((a0) => a0 !== null && ...)
来过滤JS钩子,或者在routine钩子内部做检查
Python中热路径上的钩子(绑定、测量、布局、绘制、每条消息、每个列表项)
inu.xposed.routine((ctx) => ...)
。绝对不要在热路径上使用普通JS钩子
layout_hook
、
XposedHook
辅助装饰器
搭配routine阶段的普通
hookMethod
/
hookAllOverloads
PluginsController
/ 宿主插件内部、源客户端自身的
*Config
类和包
不存在。请找到原生Telegram的等效实现,或者移除该功能

4. When JVM or xposed is unavoidable

4. 不可避免需要使用JVM或xposed的情况

  • Verify every class, method, field and descriptor against the app's sources:
    rg
    in
    worktree/TMessagesProj/src/main/java
    inside the Inugram repo, otherwise the stock Telegram Android sources or a jadx decompile of the Inugram APK. The source client is a different fork; names drift. Pin overloads with Dalvik descriptors (
    'areTabsEnabled(Lorg/telegram/tgnet/TLRPC$Chat;)Z'
    ).
  • Hook as close to the data as possible: one controller/model method beats many view methods.
  • Hot path (binding, layout, drawing, per-message, per-list-item) or must-always-apply logic:
    inu.xposed.routine
    /
    inu.jvm.routine
    . Read
    routines.md
    for the subset (no closures,
    ===
    only, Java members, captures are snapshots, 250 ms).
  • Rare hook that needs plugin APIs: routine
    filter
    + JS
    before
    /
    after
    .
  • Plain JS hooks only for rare calls off hot paths.
  • defineClass
    bodies called often or off the plugin thread: routine bodies.
  • Read constants once outside routines (
    const X = Cls.getStaticField('X')
    ) and capture them.
  • Do not port defensive
    try/except
    around every reflection call. Hook phase errors are already logged and skipped; keep a
    try
    only where a failure is expected and must not stop the rest.
  • Reach out! In many cases an awkward xposed hook is best battled with a proper high-level API
  • 对照应用源码验证每个类、方法、字段和描述符:在Inugram仓库的
    worktree/TMessagesProj/src/main/java
    目录下用
    rg
    搜索,否则使用原生Telegram Android源码或Inugram APK的jadx反编译结果。源客户端是另一个分支,类名可能有差异。请使用Dalvik描述符(例如
    'areTabsEnabled(Lorg/telegram/tgnet/TLRPC$Chat;)Z'
    )来确定重载方法。
  • 钩子位置尽量靠近数据层:一个控制器/模型方法的钩子胜过多个视图方法的钩子。
  • 热路径(绑定、布局、绘制、每条消息、每个列表项)或必须始终生效的逻辑:使用
    inu.xposed.routine
    /
    inu.jvm.routine
    。请阅读
    routines.md
    了解可用的语法子集(不支持闭包、仅支持
    ===
    、只能访问Java成员、捕获的变量是快照、执行时间限制250毫秒)。
  • 需要插件API的低频钩子:使用routine
    filter
    + JS的
    before
    /
    after
    。
  • 普通JS钩子仅用于非热路径的低频调用。
  • defineClass
    的方法体如果调用频繁或不在插件线程执行:使用routine体。
  • 在routine外部一次性读取常量(
    const X = Cls.getStaticField('X')
    )并捕获它们。
  • 不要移植每个反射调用周围的防御性
    try/except
    。钩子阶段的错误会自动记录并跳过;仅在预期会失败且不能中断其余逻辑的地方保留
    try
    。
  • 请及时反馈!在很多情况下,一个别扭的xposed钩子最好通过添加合适的上层API来解决。

5. Writing the code

5. 编写代码

  • Less code. Drop the source's boilerplate: logging wrappers,
    traceback
    dumps, UI-thread juggling, manual listener bookkeeping, version gates, dead settings.
  • Keep behavior and defaults the user sees. Where the port must differ (an API cannot read something, a setting makes no sense here), note it in a short comment and in the report.
  • No enable/disable switch for the whole plugin.
  • Localization: only if the original had it. Pick by
    inu.info().language
    ; keep the original strings.
  • Narrowest grants: scoped
    account.read(...)
    /
    account.write(...)
    ,
    fetch(host)
    ,
    invokeRpc(method)
    ,
    interceptRpc(method)
    ,
    onUpdate(type)
    . Use the
    interceptSendMessage
    filter so non-matching sends never enter JS.
  • Attach
    .catch
    to every promise not awaited; an unhandled rejection disables the plugin.
  • Do not keep TL views or interceptor contexts past the call; copy fields out.
  • No
    minify
    .
  • 代码越少越好。移除源插件中的样板代码:日志包装器、
    traceback
    转储、UI线程切换、手动监听器管理、版本判断、无用设置。
  • 保持用户可见的行为和默认值不变。如果移植版必须有所不同(例如某个API无法读取某些内容、某个设置在这里没有意义),请在简短注释和报告中说明。
  • 不要为整个插件添加启用/禁用开关。
  • 本地化:仅当原插件有本地化时才添加。通过
    inu.info().language
    判断语言;保留原有的字符串。
  • 最小化授权范围:使用限定范围的
    account.read(...)
    /
    account.write(...)
    、
    fetch(host)
    、
    invokeRpc(method)
    、
    interceptRpc(method)
    、
    onUpdate(type)
    。使用
    interceptSendMessage
    的过滤器,让不匹配的发送请求不会进入JS层。
  • 为每个未被await的promise添加
    .catch
    ;未处理的拒绝会导致插件被禁用。
  • 不要在调用结束后保留TL视图或拦截器上下文;请将需要的字段复制出来。
  • 不要使用
    minify
    。

7. Report

7. 报告

Short: what the port does, the grants and why each is needed, what was split out, what was dropped or changed versus the original and why, anything from the audit, and anything that needs on-device checking (hooks especially).
报告应简洁明了:说明移植版的功能、所需的每项授权及其原因、拆分出了哪些功能、相比原版移除或修改了哪些内容及原因、审计中发现的问题,以及任何需要在真机上验证的内容(尤其是钩子)。