4 Commits
Author SHA1 Message Date
lyuksovannyyandClaude Opus 5.5 e3cfaef40e [no bump] Shift plugin icon index down by one
Publish plugin / publish (push) Successful in 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:20:24 +02:00
lyuksovannyyandClaude Opus 5.5 2ac2600dcf [no bump] Move technical docs to docs/, add icon and PNG export tool
Publish plugin / publish (push) Successful in 3s
README now carries the icon, TL;DR, description and features; the
technical sections live in docs/. tools/icon_png.py renders
images/icon.svg to a square PNG (512x512 by default, --resolution N).
plugin.json points at the plugin's own icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:16:37 +02:00
lyuksovannyyandClaude Opus 5.5 1f0a9a1a7a Send forwarded media to the forum topic picked in the share sheet
Publish plugin / publish (push) Successful in 3s
The picker only read the selected dialog ids, so media always landed in
a forum's General topic. Read ShareAlert's selectedDialogTopics as well
and address each target the way ShareAlert.sendInternal() does: reply to
the topic's start message in forums, or set monoForumPeer in a channel's
direct-messages chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:50:03 +02:00
lyuksovannyyandClaude Opus 5.5 46503f24a0 Rename to Extended Media Forwards and forward rich message media
Publish plugin / publish (push) Successful in 5s
Rename the plugin (id extended_media_forwards) and add the Forward button
to media opened from rich messages, both single media and carousels.

Rich message media opens PhotoViewer through ChatActivity's
ChatArticlePageBlocksAdapter, whose parent object is the MessageObject
rather than a WebPage, so the article-only check hid the button. The
re-sent media links back to the message when it lives in a channel or
supergroup; elsewhere it is sent without a caption. FileLoader now gets
the parent object so expired file references can be refreshed.

Instant View carousels already worked: slideshow items are flattened
into the article's photo list. Findings are in docs/media-viewer-notes.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:51:58 +02:00
15 changed files with 833 additions and 236 deletions

No files matched your search

+28 -182
View File
@@ -1,190 +1,36 @@
# exteraGram Plugin Base
<p align="center">
<img src="images/icon.svg" width="128" height="128" alt="Extended Media Forwards icon">
</p>
A build toolchain and ADB automation for writing [exteraGram](https://exteragram.app) plugins
as ordinary multi-file Python projects instead of hand-maintaining one giant `.py` file.
<h1 align="center">Extended Media Forwards</h1>
## How exteraGram plugins actually work
**TL;DR:** an [exteraGram](https://exteragram.app) plugin that adds a Forward button to
photos and videos opened from telegra.ph articles and rich messages.
A plugin, as exteraGram loads it, is exactly **one Python file** on the device:
`/data/user/0/com.exteragram.messenger/files/plugins/<plugin_id>.py`. The distribution
convention names the same content `<id>.plugin`.
## Description
The app reads plugin metadata **statically via AST** — it looks for top-level constant
assignments (`__id__`, `__name__`, `__description__`, `__author__`, `__version__`,
`__icon__`, `__app_version__`, `__sdk_version__`, `__requirements__`) without executing the
file. Only `__id__` and `__name__` are required.
Media inside an Instant View article can't be forwarded, and media inside a rich message
can only be forwarded together with the whole message. Extended Media Forwards adds a
Forward action to the media viewer for both: it downloads the photo or video and sends it
again from your account, with a link back to its source when there is one.
The file must define exactly one class inheriting `BasePlugin` (`from base_plugin import
BasePlugin`). Lifecycle methods (`on_plugin_load`, `on_plugin_unload`, `create_settings`) and
hooks (`on_send_message_hook`, `pre_request_hook`, `post_request_hook`, `on_update_hook(s)`,
`on_app_event`) are all called by the host; hooks must be registered inside
`on_plugin_load` (e.g. `self.add_on_send_message_hook()`). A hook returns a
`HookResult(strategy=HookStrategy.MODIFY, params=params)` to apply changes, or a bare
`HookResult()` to pass through. Settings rows returned from `create_settings` come from
`ui.settings` (`Header`, `Input`, `Switch`, `Text`, `Selector`, `Divider`) and persist through
`self.get_setting`/`self.set_setting`. Logging goes through `android_utils.log`; Chaquopy also
redirects `sys.stdout`/`sys.stderr` to the logcat tags `python.stdout`/`python.stderr`.
## Features
This repo does not use the official Elyx/ElyxBuilder archive format — see
`docs/sdk-notes.md` for why.
- **Forward from telegra.ph.** Works on media opened from telegra.ph and graph.org pages,
single items and carousels alike.
- **Forward from rich messages.** Single media and carousels inside rich (article) messages
(on by default, can be turned off).
- **Any Instant View article.** Optional switch to allow the button on every Instant View
page, not only telegra.ph (off by default).
- **The usual share sheet.** Pick chats in the app's own "Forward to" sheet, including a
topic in forums, and add a comment.
- **Source link.** The sent media carries a link back to the page or message it came from.
## Repo layout
## Documentation
```
plugin.json single source of truth for plugin metadata
src/ the plugin, written as an ordinary Python package
main.py entry module — exactly one BasePlugin subclass, absolute imports only
greeting/ sample package (proves relative + cross-module imports survive bundling)
textutil.py sample top-level sibling module
tools/
bundle.py compiles plugin.json + src/ into dist/<id>.plugin
devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed
release.py publishes a built bundle to a Gitea release (used by CI)
.gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases
scripts/ ADB debug-loop automation and the git hooks (see below)
docs/sdk-notes.md condensed SDK API surface gathered from the official docs
```
## The three commands that matter
```bash
python3 tools/bundle.py --release # compile src/ -> dist/<id>.plugin
python3 tools/verify_bundle.py dist/<id>.plugin # offline proof the bundle works, no device needed
bash scripts/deploy.sh --enable # build (--dev) + push to a connected device + enable + reload
```
`bundle.py` walks `src/` recursively, embeds every `.py` file's source and dotted module name
into a compressed, base64-encoded payload, and appends a small runtime preamble that installs
a private import hook (scoped to a per-module `__builtins__` dict, never touching
`sys.modules`) so cross-module and cross-package imports resolve exactly as they would if the
files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger
resolves breakpoints against your real source files; `--release` embeds repo-relative paths
for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`.
Its first run in a clone also enables the repo's git hooks (see
[Git hooks and versioning](#git-hooks-and-versioning)).
## Device prerequisites
- exteraGram 12.5.1+.
- In-app: Preferences → Plugins → enable the plugin engine, then enable developer mode.
- USB debugging enabled, device authorized (`adb devices`).
Release-build APKs disallow `adb run-as`, so you cannot `adb push` a file straight into the
app's private plugin directory. The on-device **dev server** (`tools/devclient.py`,
`scripts/deploy.sh`) is the actual install path — it talks to the running app over a TCP
socket forwarded with `adb forward`, and the app writes the file into its own storage.
## ADB automation
All scripts live in `scripts/`, are runnable from any working directory, and honor the
standard `ANDROID_SERIAL` env var for device selection.
- `scripts/adb-connect.sh [--debug]` — starts adb, waits for the device, (re)creates the
`tcp:42690` port forward (and `tcp:5678` with `--debug`), pings the dev server.
- `scripts/deploy.sh [--release] [--enable] [--no-connect]` — bundles, connects, writes the
plugin to the device, optionally enables it, reloads it.
- `scripts/watch.sh [deploy args…]` — watches `src/` and `plugin.json`, redeploys on change
(uses `inotifywait` if available, otherwise polls).
- `scripts/logs.sh [-c] [-t] [-g PATTERN]` — streams logcat for the running app (`-t` for the
raw Chaquopy stdio tags, `-g` to grep, `-c` to clear first).
- `scripts/debug.sh start|stop [--platform vscode|pycharm]` — forwards the debugger port and
tells the dev server to start/stop the in-app debugger. Requires `debugpy` (vscode) or
`pydevd-pycharm` (pycharm) present in the app's Python runtime — add it to `requirements` in
`plugin.json` if it's missing.
- `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/`
for editor autocomplete.
## Git hooks and versioning
`scripts/pre-commit` bumps the patch number of `version` in `plugin.json` on every commit
(`1.0.0` → `1.0.1`) and stages it into that same commit. `scripts/post-commit` re-stages
`plugin.json` afterwards, because commits of a file selection (`git commit -- <paths>`, which
some IDEs use) are built from a temporary index and would otherwise leave it looking modified.
**The hooks enable themselves.** Git never activates hooks from repository content, so a
fresh clone, or a repo created from this template, starts without them. The first run of
`tools/bundle.py` fixes that by running `git config core.hooksPath scripts`, and every
workflow runs it: directly, or through `scripts/deploy.sh` and `scripts/watch.sh`. That
includes `--check`, so after cloning run
```bash
python3 tools/bundle.py --check
```
once, before your first commit. A commit made before any build runs without the hooks.
`bundle.py` leaves `core.hooksPath` alone when it is already set (so pointing it elsewhere on
purpose sticks), skips the step in CI (whenever `CI` is set) and outside a git work tree, and
never fails a build over it. To turn the hooks off for a clone, run
`git config core.hooksPath .git/hooks`; `git config --unset core.hooksPath` only lasts
until the next build.
The bump is skipped when:
- the commit message contains `[no bump]`, `[skip bump]`, `[no version bump]` or
`[skip version]` (case-insensitive; `nobump`, `no-bump` and `no_bump` also work),
- `NO_BUMP=1` (or `GIT_NO_BUMP=1`) is set, e.g. `NO_BUMP=1 git commit`, which is the only way
for commits written in an editor, since their message doesn't exist yet when the hook runs,
- `version` in `plugin.json` already differs from `HEAD` (you bumped it by hand, e.g. for a
minor or major release),
- a merge, rebase, cherry-pick or revert is in progress.
Because every version change publishes a release (see below), a normal commit + push to
`main` ships a new patch release. Use a stop word for commits that should not.
## Publishing a release
`.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's
Releases tab on every push to `main`. `dist/` is gitignored, so the artifact is always built
by CI rather than committed. Nothing in the workflow is plugin-specific — it reads the id and
version straight out of `plugin.json`, so it keeps working after you rename the plugin.
The `version` field in `plugin.json` is the single source of truth:
| Push to `main` with… | Result |
| --- | --- |
| a new `version` | creates tag `v<version>` and a new release |
| the same `version` | rebuilds the existing release, replacing its attachment |
So every push leaves a downloadable build attached to the current version, and bumping
`version` is what cuts a new release. With the git hooks enabled every commit bumps it,
so every push of a normal commit cuts a new patch release. Pushing a `v*` tag by hand still works and uses that
tag verbatim, as does a manual run (Actions → Publish plugin → Run workflow).
Every path runs `--check`, builds with `--release`, and gates the upload on
`verify_bundle.py`, so a bundle that fails verification is never published. A rebuild
replaces the attachment rather than piling up duplicates, and refreshes the release notes to
name the commit it came from. The tag itself is never moved: it keeps pointing at the commit
that first introduced that version.
Authentication uses `secrets.API_TOKEN`, an organisation-level Actions secret holding a user
token with the `write:repository` scope (which covers releases). Set it once under
**Org → Settings → Actions → Secrets** and every repo in the organisation inherits it; a
repo-level secret of the same name takes precedence if one repo ever needs different
credentials.
The workflow passes it to `release.py` as `RELEASE_TOKEN`, **not** `GITEA_TOKEN`. The Actions
runner injects its own `GITEA_TOKEN` into every step, and that value overrides a step-level
`env:` entry of the same name, so the org secret would be silently ignored.
If the org secret does not reach a repository the publish still works: `release.py` tries
`RELEASE_TOKEN` first and then falls back to the runner's `GITEA_TOKEN`, which can publish
releases in its own repository, and prints which one it used. An organisation secret is only
exposed to a repository that has Actions enabled, so check **Repo → Settings → Actions** if a
repo unexpectedly falls back.
If you would rather not manage a token at all, that injected `GITEA_TOKEN` can create
releases in its own repo: pass `--token-env GITEA_TOKEN` and drop the org secret. It is
scoped to the single job and expires with it, so it is the safer default; `API_TOKEN` is what
you want when publishing needs to reach beyond this repo.
## Writing your own plugin
1. Run `python3 tools/bundle.py --check` once. It enables the git hooks that auto-bump the
version on commit (see [Git hooks and versioning](#git-hooks-and-versioning)).
2. Edit `plugin.json` (id, name, description, etc.).
3. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports
only — it's executed at plugin-module top level, not inside a package).
4. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin`
(substitute your `id` from `plugin.json`).
5. `bash scripts/deploy.sh --enable` with a device connected.
- [Media viewer notes](docs/media-viewer-notes.md): the app internals behind the Forward button
- [Development](docs/development.md): getting started, repo layout, building and verifying, icon export
- [How exteraGram plugins work](docs/how-plugins-work.md): the plugin format and lifecycle
- [Device setup and ADB automation](docs/device-and-adb.md): deploy, watch, logs, debugger
- [Versioning and releases](docs/versioning-and-releases.md): git hooks, auto-bump, CI publishing
- [SDK notes](docs/sdk-notes.md): condensed exteraGram SDK API surface
+66
View File
@@ -0,0 +1,66 @@
# Development
## Writing your own plugin
1. Run `python3 tools/bundle.py --check` once. It enables the git hooks that auto-bump the
version on commit (see [Git hooks and versioning](versioning-and-releases.md#git-hooks-and-versioning)).
2. Edit `plugin.json` (id, name, description, etc.).
3. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports
only — it's executed at plugin-module top level, not inside a package).
4. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin`
(substitute your `id` from `plugin.json`).
5. `bash scripts/deploy.sh --enable` with a device connected.
## Repo layout
```
plugin.json single source of truth for plugin metadata
src/ the plugin, written as an ordinary Python package
main.py entry module — exactly one BasePlugin subclass, absolute imports only
greeting/ sample package (proves relative + cross-module imports survive bundling)
textutil.py sample top-level sibling module
tools/
bundle.py compiles plugin.json + src/ into dist/<id>.plugin
devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed
release.py publishes a built bundle to a Gitea release (used by CI)
icon_png.py renders images/icon.svg to a square PNG
images/icon.svg the plugin icon, source of truth for any PNG export
.gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases
scripts/ ADB debug-loop automation and the git hooks
docs/ these notes + sdk-notes.md, the condensed SDK API surface
docs/media-viewer-notes.md app internals behind the Forward button (page-block adapters, rich messages)
```
## The three commands that matter
```bash
python3 tools/bundle.py --release # compile src/ -> dist/<id>.plugin
python3 tools/verify_bundle.py dist/<id>.plugin # offline proof the bundle works, no device needed
bash scripts/deploy.sh --enable # build (--dev) + push to a connected device + enable + reload
```
`bundle.py` walks `src/` recursively, embeds every `.py` file's source and dotted module name
into a compressed, base64-encoded payload, and appends a small runtime preamble that installs
a private import hook (scoped to a per-module `__builtins__` dict, never touching
`sys.modules`) so cross-module and cross-package imports resolve exactly as they would if the
files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger
resolves breakpoints against your real source files; `--release` embeds repo-relative paths
for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`.
Its first run in a clone also enables the repo's git hooks (see
[Git hooks and versioning](versioning-and-releases.md#git-hooks-and-versioning)).
## Exporting the icon as PNG
`tools/icon_png.py` renders `images/icon.svg` to `images/icon.png`, 512x512 by default.
`--resolution` sets the size of both sides, so `--resolution 1024` gives 1024x1024:
```bash
python3 tools/icon_png.py # images/icon.png, 512x512
python3 tools/icon_png.py --resolution 1024 # images/icon.png, 1024x1024
python3 tools/icon_png.py some.svg -o out.png
```
The output is always PNG; any other `-o` extension is rejected. The script itself is stdlib
only and hands the rendering to the first renderer it finds: the `cairosvg` Python package,
`rsvg-convert` (librsvg), ImageMagick or Inkscape. Install any one of them.
+32
View File
@@ -0,0 +1,32 @@
# Device setup and ADB automation
## Device prerequisites
- exteraGram 12.5.1+.
- In-app: Preferences → Plugins → enable the plugin engine, then enable developer mode.
- USB debugging enabled, device authorized (`adb devices`).
Release-build APKs disallow `adb run-as`, so you cannot `adb push` a file straight into the
app's private plugin directory. The on-device **dev server** (`tools/devclient.py`,
`scripts/deploy.sh`) is the actual install path — it talks to the running app over a TCP
socket forwarded with `adb forward`, and the app writes the file into its own storage.
## ADB automation
All scripts live in `scripts/`, are runnable from any working directory, and honor the
standard `ANDROID_SERIAL` env var for device selection.
- `scripts/adb-connect.sh [--debug]` — starts adb, waits for the device, (re)creates the
`tcp:42690` port forward (and `tcp:5678` with `--debug`), pings the dev server.
- `scripts/deploy.sh [--release] [--enable] [--no-connect]` — bundles, connects, writes the
plugin to the device, optionally enables it, reloads it.
- `scripts/watch.sh [deploy args…]` — watches `src/` and `plugin.json`, redeploys on change
(uses `inotifywait` if available, otherwise polls).
- `scripts/logs.sh [-c] [-t] [-g PATTERN]` — streams logcat for the running app (`-t` for the
raw Chaquopy stdio tags, `-g` to grep, `-c` to clear first).
- `scripts/debug.sh start|stop [--platform vscode|pycharm]` — forwards the debugger port and
tells the dev server to start/stop the in-app debugger. Requires `debugpy` (vscode) or
`pydevd-pycharm` (pycharm) present in the app's Python runtime — add it to `requirements` in
`plugin.json` if it's missing.
- `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/`
for editor autocomplete.
+26
View File
@@ -0,0 +1,26 @@
# How exteraGram plugins work
## How exteraGram plugins actually work
A plugin, as exteraGram loads it, is exactly **one Python file** on the device:
`/data/user/0/com.exteragram.messenger/files/plugins/<plugin_id>.py`. The distribution
convention names the same content `<id>.plugin`.
The app reads plugin metadata **statically via AST** — it looks for top-level constant
assignments (`__id__`, `__name__`, `__description__`, `__author__`, `__version__`,
`__icon__`, `__app_version__`, `__sdk_version__`, `__requirements__`) without executing the
file. Only `__id__` and `__name__` are required.
The file must define exactly one class inheriting `BasePlugin` (`from base_plugin import
BasePlugin`). Lifecycle methods (`on_plugin_load`, `on_plugin_unload`, `create_settings`) and
hooks (`on_send_message_hook`, `pre_request_hook`, `post_request_hook`, `on_update_hook(s)`,
`on_app_event`) are all called by the host; hooks must be registered inside
`on_plugin_load` (e.g. `self.add_on_send_message_hook()`). A hook returns a
`HookResult(strategy=HookStrategy.MODIFY, params=params)` to apply changes, or a bare
`HookResult()` to pass through. Settings rows returned from `create_settings` come from
`ui.settings` (`Header`, `Input`, `Switch`, `Text`, `Selector`, `Divider`) and persist through
`self.get_setting`/`self.set_setting`. Logging goes through `android_utils.log`; Chaquopy also
redirects `sys.stdout`/`sys.stderr` to the logcat tags `python.stdout`/`python.stderr`.
This repo does not use the official Elyx/ElyxBuilder archive format — see
[`sdk-notes.md`](sdk-notes.md) for why.
+119
View File
@@ -0,0 +1,119 @@
# Media viewer internals — findings
Notes on how exteraGram opens page-block media, gathered by decompiling the
12.10.6 APK (versionCode 71129) with jadx and testing on a device. Class and
field names are from that build. They are app internals, not SDK API, so check
them again after app updates.
## Two adapters, one PhotoViewer
Page-block media never opens with a `MessageObject` list. PhotoViewer runs on a
`PhotoViewer.PageBlocksAdapter` instead, and `onPhotoShow` fires the same way
for both callers below, so one hook covers them.
| Opened from | Adapter | `getParentObject()` |
|---|---|---|
| Instant View article (`ArticleViewer.openPhoto`) | `ArticleViewer.RealPageBlocksAdapter` | `TLRPC.WebPage` |
| Rich message in a chat (`ChatActivity.openArticlePhoto`) | `ChatActivity.ChatArticlePageBlocksAdapter` | the message's `MessageObject`, or the bare `TL_iv.RichMessage` when constructed without one |
Both adapters expose the same `getMedia(i)` / `getFile(i)` / `isVideo(i)`. Those
methods resolve through `ArticleViewer.WebPageUtils`, which has overloads for
`WebPage`, `TL_iv.Page` and `TL_iv.RichMessage`. That is why `ivmedia.media_at`
works unchanged for both. Only the parent object tells them apart.
A rich message is recognised by `MessageObject.messageOwner.rich_message != null`.
## Carousels (slideshows)
- **Articles:** `WebpageAdapter.addAllMediaFromBlock` flattens every
`pageBlockSlideshow` / `pageBlockCollage` item into `photoBlocks`. Tapping a
slideshow item therefore goes through `RealPageBlocksAdapter` like any other
article photo, and needed no special handling.
- **Rich messages:** `RichMessageLayout.collectMediaBlocks` flattens single
media and slideshow items into one list for `ChatArticlePageBlocksAdapter`.
Swiping in the viewer walks through all of them, and
`updateSlideshowCell` keeps the in-chat carousel in sync.
## telegra.ph cannot produce a carousel
Telegraph has no gallery/slideshow node. The editor has no such block, and
`createPage` keeps only the first `<img>` of a `<figure>` holding several, so
consecutive images render as separate `pageBlockPhoto`s. Carousels only reach
the viewer from other Instant View templates (sites other than telegra.ph,
covered by the `any_article` setting) and from rich messages.
`telegra.ph/upload` no longer accepts files ("Unknown error"). Test pages have
to reference external image URLs, e.g. on `upload.wikimedia.org`.
## Rich messages need Premium to send media
The rich composer is the attach menu's "Article" tab (`ChatAttachAlertRichLayout`).
Sending is locked unless `MessagesController.richEditorAllowed()` or
`UserConfig.isPremium()`, whenever the content is "lossy"
(`RichMessageConvert.isLossy`). Lossy means anything a plain message can't
represent, and every media block is lossy. A non-Premium account therefore
cannot send a test rich message with media.
To test without one, build the message locally and open it the same way
`ChatActivity.openArticlePhoto` does:
```python
rm = TL_iv.RichMessage() # fill rm.photos and rm.blocks
msg = TLRPC.TL_message() # peer_id/id/dialog_id of any chat
msg.rich_message = rm
mo = MessageObject(account, msg, False, False)
adapter = ChatActivity.ChatArticlePageBlocksAdapter(rm, flat_blocks, mo)
viewer = PhotoViewer.getInstance()
viewer.setParentActivity(chat_fragment)
viewer.openPhoto(index, adapter, PhotoViewer.EmptyPhotoViewerProvider())
```
Real `TLRPC.Photo` objects with server locations can be borrowed from the
`cached_page.photos` of any loaded article. Pointing `peer_id` at a channel
exercises the message-link caption.
`ArticleViewer.open(TL_webPage, anchor)` re-requests `messages.getWebPage` and
replaces the page with the server copy. Copy the original `hash` onto the
injected `TL_webPage` to keep a locally modified `cached_page`.
## Message links
Only channels and supergroups (`ChatObject.isChannel`) have message links:
`https://t.me/<username>/<id>` when public, `https://t.me/c/<chat id>/<id>`
otherwise. On Android the dialog id of a channel is `-chat.id`, with no `-100`
prefix. DMs, basic groups and Saved Messages have no link, so media forwarded
from rich messages there is sent without a caption.
## Forum topics in the share sheet
When a forum is picked in `ShareAlert`, the topic lands in the protected
`selectedDialogTopics` map. That map is keyed by the `TLRPC.Dialog` values of
`selectedDialogs`, not by dialog id. `ShareAlert.sendInternal()` then addresses
each chat in one of two ways:
- **Forum topic:** wraps `topic.topicStartMessage` in a `MessageObject`, sets
`isTopicMainMessage = true`, and passes it as both `replyToMsg` and
`replyToTopMsg`. `SendMessagesHelper` sends its id as `top_msg_id`. A topic's
id is its start message's id.
- **Mono forum** (a channel's direct-messages chat,
`MessagesController.isMonoForum`): no reply message. Instead
`monoForumPeer = DialogObject.getPeerDialogId(topic.from_id)`.
A plugin that only reads the `selectedDialogs` keys sends to the chat's
General topic. `picker._selected_targets` reproduces the logic above inside
`didShare()`, which runs before the sheet clears its selection.
## File references
Pass the adapter's parent object (WebPage or MessageObject) as `parentObject`
to `FileLoader.loadFile`. `FileRefController` uses it to refresh an expired
file reference, and it handles rich messages explicitly.
## Device debugging
Logcat can be completely silent when the device's log buffer is disabled in
developer options, and `scripts/logs.sh` then shows nothing. A plugin can
still write diagnostics to
`ApplicationLoader.applicationContext.getExternalFilesDir(None)`, which maps
to `/sdcard/Android/data/com.exteragram.messenger/files/` and is readable with
`adb shell cat` without `run-as`.
+84
View File
@@ -0,0 +1,84 @@
# Versioning and releases
## Git hooks and versioning
`scripts/pre-commit` bumps the patch number of `version` in `plugin.json` on every commit
(`1.0.0` → `1.0.1`) and stages it into that same commit. `scripts/post-commit` re-stages
`plugin.json` afterwards, because commits of a file selection (`git commit -- <paths>`, which
some IDEs use) are built from a temporary index and would otherwise leave it looking modified.
**The hooks enable themselves.** Git never activates hooks from repository content, so a
fresh clone, or a repo created from this template, starts without them. The first run of
`tools/bundle.py` fixes that by running `git config core.hooksPath scripts`, and every
workflow runs it: directly, or through `scripts/deploy.sh` and `scripts/watch.sh`. That
includes `--check`, so after cloning run
```bash
python3 tools/bundle.py --check
```
once, before your first commit. A commit made before any build runs without the hooks.
`bundle.py` leaves `core.hooksPath` alone when it is already set (so pointing it elsewhere on
purpose sticks), skips the step in CI (whenever `CI` is set) and outside a git work tree, and
never fails a build over it. To turn the hooks off for a clone, run
`git config core.hooksPath .git/hooks`; `git config --unset core.hooksPath` only lasts
until the next build.
The bump is skipped when:
- the commit message contains `[no bump]`, `[skip bump]`, `[no version bump]` or
`[skip version]` (case-insensitive; `nobump`, `no-bump` and `no_bump` also work),
- `NO_BUMP=1` (or `GIT_NO_BUMP=1`) is set, e.g. `NO_BUMP=1 git commit`, which is the only way
for commits written in an editor, since their message doesn't exist yet when the hook runs,
- `version` in `plugin.json` already differs from `HEAD` (you bumped it by hand, e.g. for a
minor or major release),
- a merge, rebase, cherry-pick or revert is in progress.
Because every version change publishes a release (see [Publishing a release](#publishing-a-release)), a normal commit + push to
`main` ships a new patch release. Use a stop word for commits that should not.
## Publishing a release
`.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's
Releases tab on every push to `main`. `dist/` is gitignored, so the artifact is always built
by CI rather than committed. Nothing in the workflow is plugin-specific — it reads the id and
version straight out of `plugin.json`, so it keeps working after you rename the plugin.
The `version` field in `plugin.json` is the single source of truth:
| Push to `main` with… | Result |
| --- | --- |
| a new `version` | creates tag `v<version>` and a new release |
| the same `version` | rebuilds the existing release, replacing its attachment |
So every push leaves a downloadable build attached to the current version, and bumping
`version` is what cuts a new release. With the git hooks enabled every commit bumps it,
so every push of a normal commit cuts a new patch release. Pushing a `v*` tag by hand still works and uses that
tag verbatim, as does a manual run (Actions → Publish plugin → Run workflow).
Every path runs `--check`, builds with `--release`, and gates the upload on
`verify_bundle.py`, so a bundle that fails verification is never published. A rebuild
replaces the attachment rather than piling up duplicates, and refreshes the release notes to
name the commit it came from. The tag itself is never moved: it keeps pointing at the commit
that first introduced that version.
Authentication uses `secrets.API_TOKEN`, an organisation-level Actions secret holding a user
token with the `write:repository` scope (which covers releases). Set it once under
**Org → Settings → Actions → Secrets** and every repo in the organisation inherits it; a
repo-level secret of the same name takes precedence if one repo ever needs different
credentials.
The workflow passes it to `release.py` as `RELEASE_TOKEN`, **not** `GITEA_TOKEN`. The Actions
runner injects its own `GITEA_TOKEN` into every step, and that value overrides a step-level
`env:` entry of the same name, so the org secret would be silently ignored.
If the org secret does not reach a repository the publish still works: `release.py` tries
`RELEASE_TOKEN` first and then falls back to the runner's `GITEA_TOKEN`, which can publish
releases in its own repository, and prints which one it used. An organisation secret is only
exposed to a repository that has Actions enabled, so check **Repo → Settings → Actions** if a
repo unexpectedly falls back.
If you would rather not manage a token at all, that injected `GITEA_TOKEN` can create
releases in its own repo: pass `--token-env GITEA_TOKEN` and drop the org secret. It is
scoped to the single job and expires with it, so it is the safer default; `API_TOKEN` is what
you want when publishing needs to reach beyond this repo.
+40
View File
@@ -0,0 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640">
<defs>
<linearGradient id="bg" x1="0.3" y1="0" x2="0.7" y2="1">
<stop offset="0" stop-color="#F89B2E"/><stop offset="0.5" stop-color="#F7724A"/><stop offset="1" stop-color="#F5517A"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.45" r="0.5">
<stop offset="0" stop-color="#FF5A3C" stop-opacity="0.18"/><stop offset="1" stop-color="#FF5A3C" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paper" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#F7F7F7"/><stop offset="1" stop-color="#E2E2E2"/>
</linearGradient>
<linearGradient id="star" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFE27A"/><stop offset="1" stop-color="#FFC53D"/>
</linearGradient>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#7CC8FF"/><stop offset="1" stop-color="#CFEBFF"/>
</linearGradient>
<linearGradient id="metal" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#9C9C9C"/><stop offset="0.45" stop-color="#E4E4E4"/><stop offset="1" stop-color="#A8A8A8"/>
</linearGradient>
<clipPath id="photo"><rect x="166" y="196" width="228" height="208" rx="18"/></clipPath>
<filter id="sh" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="10" stdDeviation="10" flood-color="#7A1E2E" flood-opacity="0.28"/>
</filter>
</defs>
<rect width="640" height="640" fill="url(#bg)"/>
<rect width="640" height="640" fill="url(#glow)"/>
<g transform="translate(-22,-6)">
<rect filter="url(#sh)" x="180" y="140" width="280" height="260" rx="34" transform="rotate(8 320 270)" fill="#FFFFFF" fill-opacity="0.45" stroke="#FFFFFF" stroke-opacity="0.7" stroke-width="5"/>
<rect filter="url(#sh)" x="140" y="170" width="280" height="260" rx="34" fill="url(#paper)" stroke="#B9B9B9" stroke-width="6" stroke-linejoin="round"/>
<g clip-path="url(#photo)">
<rect x="166" y="196" width="228" height="208" fill="url(#sky)"/>
<circle cx="340" cy="248" r="24" fill="#FFD34E"/>
<path d="M150,410 L238,296 L290,360 L328,318 L410,410 Z" fill="#4DBB7E"/>
<path d="M238,296 L262,327 L246,322 L232,338 L222,317 Z" fill="#FFFFFF" fill-opacity="0.8"/>
</g>
<path filter="url(#sh)" fill="url(#star)" stroke="#F59A2E" stroke-width="8" stroke-linejoin="round" d="M352,482 C352,404 398,370 470,368 V326 L556,402 L470,478 V436 C420,434 384,446 352,482 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+5 -5
View File
@@ -1,10 +1,10 @@
{
"id": "telegraph_media_forward",
"name": "Telegraph Media Forward",
"description": "Adds a Forward action to media opened from telegra.ph pages: downloads the media and re-sends it from your account with a link back to the article.",
"id": "extended_media_forwards",
"name": "Extended Media Forwards",
"description": "Adds a Forward action to media opened from telegra.ph pages and rich messages (single media and carousels): downloads the media and re-sends it from your account with a link back to its source.",
"author": "@lyuksovannyy",
"version": "1.0.0",
"icon": "exteraPlugins/1",
"version": "1.1.2",
"icon": "exteraGramPluginIcons/3",
"app_version": ">=12.5.1",
"sdk_version": ">=1.4.4.3",
"requirements": []
+2 -2
View File
@@ -49,11 +49,11 @@ def drawable(context, res_id):
if packed is not None:
return packed
except Exception as exc:
log("[telegraph_media_forward] icon pack lookup failed: %s" % exc)
log("[extended_media_forwards] icon pack lookup failed: %s" % exc)
try:
res_id = manager.getIcon(res_id)
except Exception as exc:
log("[telegraph_media_forward] icon remap failed: %s" % exc)
log("[extended_media_forwards] icon remap failed: %s" % exc)
resources = context.getResources()
if density:
return resources.getDrawableForDensity(res_id, density, theme)
+51 -1
View File
@@ -1,9 +1,13 @@
"""Reads media out of the PhotoViewer instance that Instant View opened.
"""Reads media out of a PhotoViewer that runs on a `PageBlocksAdapter`.
When ArticleViewer opens a telegra.ph photo/video, PhotoViewer runs with
`currentMessageObject == null` and a `PageBlocksAdapter` instead. That adapter
is the only handle to the underlying `TLRPC.Photo` / `TLRPC.Document` and to
the `TLRPC.WebPage` the article came from, so everything here goes through it.
Rich messages (page blocks sent as a chat message) open their single media and
carousels the same way, through ChatActivity's `ChatArticlePageBlocksAdapter`,
whose parent object is the message's `MessageObject` instead of a WebPage.
"""
from hook_utils import get_private_field
@@ -81,6 +85,52 @@ def is_telegraph(adapter):
return any(host == h or host.endswith("." + h) for h in TELEGRAPH_HOSTS)
def rich_parent(adapter):
"""For media opened from a rich message: the `MessageObject` carrying it,
or the bare `TL_iv.RichMessage` when the adapter has no message. None for
anything else, articles included."""
from org.telegram.messenger import MessageObject
from org.telegram.tgnet.tl import TL_iv
parent = adapter.getParentObject()
if isinstance(parent, MessageObject):
owner = parent.messageOwner
if owner is not None and owner.rich_message is not None:
return parent
return None
if isinstance(parent, TL_iv.RichMessage):
return parent
return None
def is_rich_message(adapter):
return rich_parent(adapter) is not None
def message_link(account, message):
"""t.me link to `message`, or None when it has none.
Only channels and supergroups have message links: public ones by username,
private ones through t.me/c/. DMs, basic groups and unsent local messages
cannot be linked to.
"""
from org.telegram.messenger import ChatObject, MessageObject, MessagesController
if not isinstance(message, MessageObject):
return None
msg_id = message.getId()
dialog_id = message.getDialogId()
if msg_id <= 0 or dialog_id >= 0:
return None
chat = MessagesController.getInstance(account).getChat(-dialog_id)
if chat is None or not ChatObject.isChannel(chat):
return None
username = ChatObject.getPublicUsername(chat)
if username:
return "https://t.me/%s/%d" % (username, msg_id)
return "https://t.me/c/%d/%d" % (chat.id, msg_id)
def media_at(adapter, index):
"""`(media, existing_file_path_or_None, is_video)` for the shown block."""
media = adapter.getMedia(index)
+31 -11
View File
@@ -17,7 +17,7 @@ _BUTTON_SIZE_DP = 48.0
_LOAD_TOKEN = "tmf-%d" % id(object())
class TelegraphMediaForwardPlugin(BasePlugin):
class ExtendedMediaForwardsPlugin(BasePlugin):
def on_plugin_load(self):
viewer = find_class("org.telegram.ui.PhotoViewer")
if viewer is None:
@@ -31,7 +31,7 @@ class TelegraphMediaForwardPlugin(BasePlugin):
def create_settings(self):
return [
Header(text="Telegraph Media Forward"),
Header(text="Extended Media Forwards"),
Switch(
key="any_article",
text="Allow on any Instant View article",
@@ -39,18 +39,27 @@ class TelegraphMediaForwardPlugin(BasePlugin):
subtext="Off: only telegra.ph and graph.org pages.",
icon="msg_link",
),
Switch(
key="rich_messages",
text="Allow on rich messages",
default=True,
subtext="Single media and carousels inside rich (article) messages.",
icon="msg_media",
),
Text(
text="How it works",
subtext=(
"Open a photo or video from an article, tap the ⋮ menu and choose "
"Open a photo or video from an article or a rich message and tap "
"Forward. The file is downloaded and sent from your account with "
"an Original link to the page."
"a link back to its source when it has one."
),
icon="msg_info",
),
]
def allows(self, adapter):
if ivmedia.is_rich_message(adapter):
return self.get_setting("rich_messages", True)
if self.get_setting("any_article", False):
return ivmedia.page_url(adapter) is not None
return ivmedia.is_telegraph(adapter)
@@ -60,7 +69,6 @@ class TelegraphMediaForwardPlugin(BasePlugin):
adapter = ivmedia.adapter_of(viewer)
if adapter is None:
return
url = ivmedia.page_url(adapter)
index = ivmedia.current_index(viewer)
try:
media, path, is_video = ivmedia.media_at(adapter, index)
@@ -68,27 +76,39 @@ class TelegraphMediaForwardPlugin(BasePlugin):
sender.notify_error(str(exc))
return
account = get_selected_account()
caption = _caption_for(account, adapter)
parent = adapter.getParentObject()
context = ivmedia.parent_activity(viewer)
if context is None:
return
def picked(peers, comment):
if not peers:
def picked(targets, comment):
if not targets:
return
# The sheet sits above the viewer's window; drop it so the sent
# message is what the user lands on.
viewer.closePhoto(False, False)
sender.send_media(account, media, path, is_video, url, peers, comment)
sender.send_media(account, media, path, is_video, caption, targets, comment, parent)
try:
# ShareAlert owns its own window, so it shows over PhotoViewer just
# like the stock share button does - no need to close the viewer.
picker.pick_chats(context, picked, ivmedia.resources_provider(viewer))
picker.pick_chats(context, account, picked, ivmedia.resources_provider(viewer))
except Exception as exc:
log("[telegraph_media_forward] picker failed: %s" % exc)
log("[extended_media_forwards] picker failed: %s" % exc)
sender.notify_error(str(exc))
def _caption_for(account, adapter):
"""`(text, url)` linking back to where the media came from, or None."""
rich = ivmedia.rich_parent(adapter)
if rich is not None:
url = ivmedia.message_link(account, rich)
return (sender.RICH_CAPTION_TEXT, url) if url else None
url = ivmedia.page_url(adapter)
return (sender.CAPTION_TEXT, url) if url else None
class _OnPhotoShow(MethodHook):
"""Adds (or re-shows) the Forward item every time the viewer opens media."""
@@ -122,7 +142,7 @@ def _sync_menu(plugin, viewer):
button = _add_button(plugin, menu, viewer)
button.setVisibility(View.VISIBLE if visible else View.GONE)
except Exception as exc:
log("[telegraph_media_forward] menu sync failed: %s" % exc)
log("[extended_media_forwards] menu sync failed: %s" % exc)
def _find_button(menu):
+40 -10
View File
@@ -5,12 +5,21 @@ the picker pixel-identical to the stock one. It is constructed with no payload
(`sendingMessageObjects`, `sendingText` and `sendingFile` all null), which makes
its send path a no-op: it selects chats, fires `ShareAlertDelegate.didShare()`
and dismisses. The selection is read back off the (protected) `selectedDialogs`
field and the comment off `commentTextView`.
field, the topic picked in each forum off `selectedDialogTopics`, and the
comment off `commentTextView`.
"""
from collections import namedtuple
from android_utils import log, run_on_ui_thread
from hook_utils import get_private_field
# Where to send: `peer` is the dialog id. In a forum `top_message` is the
# topic's start message, which the stock sheet replies to so the message lands
# in that topic. In a channel's direct-messages chat (a "mono forum") each
# topic is one user instead, addressed through `mono_forum_peer`.
Target = namedtuple("Target", "peer top_message mono_forum_peer")
_delegate_class = None
# ShareAlert's own constant for "keep the keyboard out of the way".
@@ -28,25 +37,46 @@ def _make_delegate_class():
from org.telegram.ui.Components import ShareAlert
class Delegate(dynamic_proxy(ShareAlert.ShareAlertDelegate)):
def __init__(self, alert, on_picked):
def __init__(self, alert, account, on_picked):
super().__init__()
self.alert = alert
self.account = account
self.on_picked = on_picked
def didCopy(self):
return False
def didShare(self):
self.on_picked(_selected_peers(self.alert), _comment(self.alert))
self.on_picked(_selected_targets(self.alert, self.account), _comment(self.alert))
return Delegate
def _selected_peers(alert):
def _selected_targets(alert, account):
"""One Target per chosen chat, resolved the way ShareAlert.sendInternal()
resolves them. Must run on the UI thread, before the sheet clears its
selection."""
from org.telegram.messenger import DialogObject, MessageObject, MessagesController
dialogs = get_private_field(alert, "selectedDialogs")
if dialogs is None:
return []
return [dialogs.keyAt(i) for i in range(dialogs.size())]
# Keyed by the TLRPC.Dialog values of selectedDialogs, not by dialog id.
topics = get_private_field(alert, "selectedDialogTopics")
targets = []
for i in range(dialogs.size()):
peer = dialogs.keyAt(i)
topic = topics.get(dialogs.valueAt(i)) if topics is not None else None
top_message = None
mono_forum_peer = 0
if topic is not None:
if MessagesController.getInstance(account).isMonoForum(peer):
mono_forum_peer = DialogObject.getPeerDialogId(topic.from_id)
elif topic.topicStartMessage is not None:
top_message = MessageObject(account, topic.topicStartMessage, False, False)
top_message.isTopicMainMessage = True
targets.append(Target(peer, top_message, mono_forum_peer))
return targets
def _comment(alert):
@@ -57,9 +87,9 @@ def _comment(alert):
return str(text).strip() if text is not None else ""
def pick_chats(context, on_picked, resources_provider=None):
"""Shows the stock share sheet. `on_picked(dialog_ids, comment)` runs on the
UI thread once the user confirms."""
def pick_chats(context, account, on_picked, resources_provider=None):
"""Shows the stock share sheet. `on_picked(targets, comment)` runs on the
UI thread once the user confirms, with one `Target` per chosen chat."""
global _delegate_class
from org.telegram.ui.Components import ShareAlert
@@ -68,7 +98,7 @@ def pick_chats(context, on_picked, resources_provider=None):
if _delegate_class is None:
_delegate_class = _make_delegate_class()
# Keep a reference on the module so the proxy is not collected early.
delegate = _delegate_class(alert, on_picked)
delegate = _delegate_class(alert, account, on_picked)
alert.setDelegate(delegate)
pick_chats._alive = delegate
@@ -89,4 +119,4 @@ def _enable_focus(alert):
if alert.getWindow() is not None:
alert.setFocusable(True)
except Exception as exc:
log("[telegraph_media_forward] focus handoff failed: %s" % exc)
log("[extended_media_forwards] focus handoff failed: %s" % exc)
+44 -24
View File
@@ -1,9 +1,11 @@
"""Downloads telegra.ph media and re-sends it from the user's own account.
"""Downloads page-block media and re-sends it from the user's own account.
Instant View media lives on the article's WebPage, not in any message, so it
cannot be forwarded. Instead the file is fetched through FileLoader (or reused
if already cached) and uploaded as a fresh photo/document, exactly like picking
the file from the gallery. Caption is a single markdown link to the article.
cannot be forwarded. Rich-message media is part of a message, but only as one
block of it, so forwarding would carry the whole message along. Instead the
file is fetched through FileLoader (or reused if already cached) and uploaded
as a fresh photo/document, exactly like picking the file from the gallery. The
caption, when there is a source to link to, is a single link back to it.
"""
import threading
@@ -33,39 +35,57 @@ def notify_success(text):
CAPTION_TEXT = "telegra.ph source"
RICH_CAPTION_TEXT = "message source"
def send_media(account, media, cached_path, is_video, page_url, peers, comment):
"""Starts the download+send in the background. Safe to call on the UI thread."""
def send_media(account, media, cached_path, is_video, caption, targets, comment, parent=None):
"""Starts the download+send in the background. Safe to call on the UI thread.
`targets` are `picker.Target`s. `caption` is `(text, url)` for a linked caption or None for none. `parent`
is the object the media belongs to (WebPage or MessageObject); FileLoader
uses it to refresh an expired file reference.
"""
def worker():
try:
path = cached_path or _download(account, media, is_video)
for peer in peers:
path = cached_path or _download(account, media, is_video, parent)
for target in targets:
if comment:
_send(account, _text_params(comment, peer))
_send(account, _media_params(account, media, path, is_video, page_url, peer))
_send(account, _text_params(comment, target))
_send(account, _media_params(account, media, path, is_video, caption, target))
except Exception as exc:
log("[telegraph_media_forward] send failed: %s" % exc)
log("[extended_media_forwards] send failed: %s" % exc)
notify_error(str(exc))
return
notify_success("Sent")
threading.Thread(target=worker, name="telegraph-media-forward", daemon=True).start()
threading.Thread(target=worker, name="extended-media-forwards", daemon=True).start()
def _text_params(text, peer):
def _address(params, target):
"""Points `params` at the chosen forum topic, as ShareAlert does."""
if target.top_message is not None:
params.replyToMsg = target.top_message
params.replyToTopMsg = target.top_message
if target.mono_forum_peer:
params.monoForumPeer = target.mono_forum_peer
return params
def _text_params(text, target):
from org.telegram.messenger import SendMessagesHelper
return SendMessagesHelper.SendMessageParams.of(text, peer)
return _address(SendMessagesHelper.SendMessageParams.of(text, target.peer), target)
def _media_params(account, media, path, is_video, page_url, peer):
def _media_params(account, media, path, is_video, caption, target):
from org.telegram.messenger import SendMessagesHelper
params = SendMessagesHelper.SendMessageParams.of(None, peer)
params.caption = CAPTION_TEXT
params.entities = _link_entities(page_url)
params = _address(SendMessagesHelper.SendMessageParams.of(None, target.peer), target)
if caption is not None:
text, url = caption
params.caption = text
params.entities = _link_entities(text, url)
params.path = path
if is_video:
params.document = _fresh_document(account, media, path)
@@ -77,14 +97,14 @@ def _media_params(account, media, path, is_video, page_url, peer):
return params
def _link_entities(url):
def _link_entities(text, url):
"""Whole caption as one text_url entity, so it renders as a link."""
from java.util import ArrayList
from org.telegram.tgnet import TLRPC
entity = TLRPC.TL_messageEntityTextUrl()
entity.offset = 0
entity.length = len(CAPTION_TEXT)
entity.length = len(text)
entity.url = url
entities = ArrayList()
entities.add(entity)
@@ -119,7 +139,7 @@ def _send(account, params):
try:
get_send_messages_helper(account).sendMessage(params)
except Exception as exc:
log("[telegraph_media_forward] sendMessage failed: %s" % exc)
log("[extended_media_forwards] sendMessage failed: %s" % exc)
finally:
done.set()
@@ -128,7 +148,7 @@ def _send(account, params):
done.wait(30)
def _download(account, media, is_video):
def _download(account, media, is_video, parent=None):
"""Loads the file through FileLoader and returns its on-disk path."""
from org.telegram.messenger import FileLoader, ImageLocation
from org.telegram.tgnet import TLRPC
@@ -136,11 +156,11 @@ def _download(account, media, is_video):
loader = FileLoader.getInstance(account)
if is_video or isinstance(media, TLRPC.Document):
target = media
loader.loadFile(media, None, FileLoader.PRIORITY_HIGH, 0)
loader.loadFile(media, parent, FileLoader.PRIORITY_HIGH, 0)
else:
target = _best_size(media)
loader.loadFile(
ImageLocation.getForPhoto(target, media), None, "jpg", FileLoader.PRIORITY_HIGH, 0
ImageLocation.getForPhoto(target, media), parent, "jpg", FileLoader.PRIORITY_HIGH, 0
)
candidates = [loader.getPathToAttach(target, True), loader.getPathToAttach(target, False)]
return _wait_for_file(candidates)
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Renders images/icon.svg to a square PNG (512x512 by default).
Stdlib only: the actual rasterising is delegated to the first renderer found,
in this order: the cairosvg Python package, rsvg-convert (librsvg),
ImageMagick (magick/convert), Inkscape. Install any one of them.
Usage:
tools/icon_png.py # images/icon.svg -> images/icon.png, 512x512
tools/icon_png.py --resolution 1024 # 1024x1024
tools/icon_png.py other.svg -o out.png # explicit input/output
The output is always PNG, always <resolution>x<resolution>.
"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import sys
from pathlib import Path
from typing import List, Optional
REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_SVG = REPO_ROOT / "images" / "icon.svg"
DEFAULT_RESOLUTION = 512
def fail(message: str) -> "None":
print(f"error: {message}", file=sys.stderr)
raise SystemExit(1)
def cli_command(svg: Path, png: Path, size: int) -> Optional[List[str]]:
"""Returns the command line for the first installed CLI renderer, if any."""
if shutil.which("rsvg-convert"):
return ["rsvg-convert", "-w", str(size), "-h", str(size), "-f", "png",
"-o", str(png), str(svg)]
magick = shutil.which("magick") or shutil.which("convert")
if magick:
# A high density rasterises the vector sharply before scaling down;
# "!" forces the exact size.
return [magick, "-background", "none", "-density", "1200", str(svg),
"-resize", f"{size}x{size}!", f"png:{png}"]
if shutil.which("inkscape"):
return ["inkscape", str(svg), "--export-type=png", f"--export-filename={png}",
"-w", str(size), "-h", str(size)]
return None
def render(svg: Path, png: Path, size: int) -> str:
"""Renders svg into png at size x size, returning the renderer's name."""
try:
import cairosvg # type: ignore[import-not-found]
except ImportError:
pass
else:
cairosvg.svg2png(url=str(svg), write_to=str(png),
output_width=size, output_height=size)
return "cairosvg"
command = cli_command(svg, png, size)
if command is None:
fail("no SVG renderer found; install one of: cairosvg (pip), "
"rsvg-convert (librsvg), ImageMagick, Inkscape")
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0:
fail(f"{Path(command[0]).name} failed:\n{result.stderr.strip()}")
return Path(command[0]).name
def main() -> None:
parser = argparse.ArgumentParser(description="Render an SVG icon to a square PNG.")
parser.add_argument("svg", nargs="?", type=Path, default=DEFAULT_SVG,
help="input SVG (default: images/icon.svg)")
parser.add_argument("-r", "--resolution", type=int, default=DEFAULT_RESOLUTION,
help=f"width and height in pixels (default: {DEFAULT_RESOLUTION})")
parser.add_argument("-o", "--output", type=Path,
help="output PNG (default: the input path with a .png suffix)")
args = parser.parse_args()
if args.resolution <= 0:
fail("--resolution must be a positive integer")
if not args.svg.is_file():
fail(f"{args.svg} not found")
png = args.output or args.svg.with_suffix(".png")
if png.suffix.lower() != ".png":
fail(f"output must be a .png file, got {png}")
png.parent.mkdir(parents=True, exist_ok=True)
renderer = render(args.svg, png, args.resolution)
print(f"{png} ({args.resolution}x{args.resolution}, via {renderer})")
if __name__ == "__main__":
main()
+169 -1
View File
@@ -116,6 +116,42 @@ class FakeWebPage:
self.display_url = display_url
class FakeRichMessage:
"""Stand-in for TL_iv.RichMessage."""
class FakeMessage:
def __init__(self, rich_message=None):
self.rich_message = rich_message
class FakeMessageObject:
def __init__(self, msg_id, dialog_id, rich_message=None):
self.messageOwner = FakeMessage(rich_message)
self._id = msg_id
self._dialog_id = dialog_id
def getId(self):
return self._id
def getDialogId(self):
return self._dialog_id
class FakeChat:
def __init__(self, chat_id, channel, username=None):
self.id = chat_id
self.channel = channel
self.username = username
FAKE_CHATS = {
100: FakeChat(100, channel=True, username="publicchan"),
200: FakeChat(200, channel=True),
300: FakeChat(300, channel=False),
}
def install_fakes() -> None:
base_plugin_mod = types.ModuleType("base_plugin")
base_plugin_mod.BasePlugin = BasePlugin
@@ -158,6 +194,22 @@ def install_fakes() -> None:
),
)
tgnet_mod.TLRPC = tlrpc
tgnet_mod.__path__ = []
tgnet_tl_mod = types.ModuleType("org.telegram.tgnet.tl")
tgnet_tl_mod.TL_iv = types.SimpleNamespace(RichMessage=FakeRichMessage)
tgnet_mod.tl = tgnet_tl_mod
sys.modules["org.telegram.tgnet.tl"] = tgnet_tl_mod
messenger_mod = types.ModuleType("org.telegram.messenger")
messenger_mod.MessageObject = FakeMessageObject
messenger_mod.ChatObject = types.SimpleNamespace(
isChannel=lambda chat: chat.channel,
getPublicUsername=lambda chat: chat.username,
)
messenger_mod.MessagesController = types.SimpleNamespace(
getInstance=lambda account: types.SimpleNamespace(getChat=FAKE_CHATS.get)
)
sys.modules["org.telegram.messenger"] = messenger_mod
# `java.util.ArrayList` only ever gets add()ed to and iterated.
java_mod = types.ModuleType("java")
@@ -346,11 +398,32 @@ def main(argv=None) -> int:
plugin.allows(other) and not plugin.allows(no_page),
)
rich_msg = FakeAdapter(FakeMessageObject(5, -1000000000200, FakeRichMessage()))
rich_bare = FakeAdapter(FakeRichMessage())
plain_msg = FakeAdapter(FakeMessageObject(5, -1000000000200, None))
check(
"settings expose the rich_messages switch",
any(getattr(row, "key", None) == "rich_messages" for row in rows),
)
check(
"rich message media is accepted by default (with or without a message)",
plugin.allows(rich_msg) and plugin.allows(rich_bare),
)
check(
"message parent without a rich message is rejected",
not plugin.allows(plain_msg),
)
plugin.set_setting("rich_messages", False)
check(
"rich_messages setting off rejects rich message media",
not plugin.allows(rich_msg) and not plugin.allows(rich_bare),
)
ivmedia = ns.get("ivmedia")
sender = ns.get("sender")
caption = getattr(sender, "CAPTION_TEXT", None)
link_entities = getattr(sender, "_link_entities", None)
entities = link_entities("https://telegra.ph/x") if link_entities else []
entities = link_entities(caption, "https://telegra.ph/x") if link_entities and caption else []
entity = entities[0] if entities else None
check(
"caption is a single text_url entity covering the whole caption",
@@ -363,6 +436,101 @@ def main(argv=None) -> int:
f"got caption={caption!r} entities={entities!r}",
)
message_link = getattr(ivmedia, "message_link", None)
if message_link is not None:
# Channel dialog ids are -(chat id) in this fake, like getDialogId().
links = (
message_link(0, FakeMessageObject(7, -100)),
message_link(0, FakeMessageObject(8, -200)),
message_link(0, FakeMessageObject(9, -300)),
message_link(0, FakeMessageObject(9, 12345)),
message_link(0, FakeMessageObject(-1, -100)),
message_link(0, FakeRichMessage()),
)
else:
links = None
check(
"message_link: public/private channels linked; groups, DMs, local and bare rich messages not",
links
== (
"https://t.me/publicchan/7",
"https://t.me/c/200/8",
None,
None,
None,
None,
),
f"got {links!r}",
)
picker = ns.get("picker")
selected_targets = getattr(picker, "_selected_targets", None)
address = getattr(sender, "_address", None)
targets = None
addressed = None
if selected_targets is not None and address is not None:
class SparseArray:
def __init__(self, pairs):
self.pairs = pairs
def size(self):
return len(self.pairs)
def keyAt(self, i):
return self.pairs[i][0]
def valueAt(self, i):
return self.pairs[i][1]
class TopicMessageObject:
def __init__(self, account, message, generate_layout, check_media):
self.message = message
self.isTopicMainMessage = False
plain, forum, mono = object(), object(), object()
topic = types.SimpleNamespace(topicStartMessage="start-msg", from_id="user-peer")
mono_topic = types.SimpleNamespace(topicStartMessage="unused", from_id="dm-user")
alert = types.SimpleNamespace(
selectedDialogs=SparseArray([(11, plain), (-22, forum), (-33, mono)]),
selectedDialogTopics={forum: topic, mono: mono_topic},
)
messenger = sys.modules["org.telegram.messenger"]
saved = messenger.MessageObject
messenger.MessageObject = TopicMessageObject
messenger.DialogObject = types.SimpleNamespace(getPeerDialogId=lambda peer: 4242)
messenger.MessagesController = types.SimpleNamespace(
getInstance=lambda account: types.SimpleNamespace(isMonoForum=lambda d: d == -33)
)
try:
targets = selected_targets(alert, 0)
finally:
messenger.MessageObject = saved
params = [
address(types.SimpleNamespace(replyToMsg=None, replyToTopMsg=None, monoForumPeer=0), t)
for t in targets
]
addressed = [
(
t.peer,
getattr(p.replyToTopMsg, "message", None),
p.replyToMsg is p.replyToTopMsg,
getattr(p.replyToTopMsg, "isTopicMainMessage", None),
p.monoForumPeer,
)
for t, p in zip(targets, params)
]
check(
"selected forum topic is replied to; mono-forum topic sets monoForumPeer; plain chat untouched",
addressed
== [
(11, None, True, None, 0),
(-22, "start-msg", True, True, 0),
(-33, None, True, None, 4242),
],
f"got {addressed!r}",
)
media_at = getattr(ivmedia, "media_at", None)
no_media = getattr(ivmedia, "NoMedia", None)
raised = False