generated from exteraGramPlugins/pluginBase
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3cfaef40e | ||
|
|
2ac2600dcf | ||
|
|
1f0a9a1a7a | ||
|
|
46503f24a0 |
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user