3 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
13 changed files with 523 additions and 207 deletions

No files matched your search

+28 -183
View File
@@ -1,191 +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 <h1 align="center">Extended Media Forwards</h1>
as ordinary multi-file Python projects instead of hand-maintaining one giant `.py` file.
## 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: ## Description
`/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 Media inside an Instant View article can't be forwarded, and media inside a rich message
assignments (`__id__`, `__name__`, `__description__`, `__author__`, `__version__`, can only be forwarded together with the whole message. Extended Media Forwards adds a
`__icon__`, `__app_version__`, `__sdk_version__`, `__requirements__`) without executing the Forward action to the media viewer for both: it downloads the photo or video and sends it
file. Only `__id__` and `__name__` are required. 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 ## Features
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 - **Forward from telegra.ph.** Works on media opened from telegra.ph and graph.org pages,
`docs/sdk-notes.md` for why. 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
``` - [Media viewer notes](docs/media-viewer-notes.md): the app internals behind the Forward button
plugin.json single source of truth for plugin metadata - [Development](docs/development.md): getting started, repo layout, building and verifying, icon export
src/ the plugin, written as an ordinary Python package - [How exteraGram plugins work](docs/how-plugins-work.md): the plugin format and lifecycle
main.py entry module — exactly one BasePlugin subclass, absolute imports only - [Device setup and ADB automation](docs/device-and-adb.md): deploy, watch, logs, debugger
greeting/ sample package (proves relative + cross-module imports survive bundling) - [Versioning and releases](docs/versioning-and-releases.md): git hooks, auto-bump, CI publishing
textutil.py sample top-level sibling module - [SDK notes](docs/sdk-notes.md): condensed exteraGram SDK API surface
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
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](#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.
+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.
+19
View File
@@ -84,6 +84,25 @@ 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 prefix. DMs, basic groups and Saved Messages have no link, so media forwarded
from rich messages there is sent without a caption. 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 ## File references
Pass the adapter's parent object (WebPage or MessageObject) as `parentObject` Pass the adapter's parent object (WebPage or MessageObject) as `parentObject`
+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

+2 -2
View File
@@ -3,8 +3,8 @@
"name": "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.", "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", "author": "@lyuksovannyy",
"version": "1.1.1", "version": "1.1.2",
"icon": "exteraPlugins/1", "icon": "exteraGramPluginIcons/3",
"app_version": ">=12.5.1", "app_version": ">=12.5.1",
"sdk_version": ">=1.4.4.3", "sdk_version": ">=1.4.4.3",
"requirements": [] "requirements": []
+4 -4
View File
@@ -82,18 +82,18 @@ class ExtendedMediaForwardsPlugin(BasePlugin):
if context is None: if context is None:
return return
def picked(peers, comment): def picked(targets, comment):
if not peers: if not targets:
return return
# The sheet sits above the viewer's window; drop it so the sent # The sheet sits above the viewer's window; drop it so the sent
# message is what the user lands on. # message is what the user lands on.
viewer.closePhoto(False, False) viewer.closePhoto(False, False)
sender.send_media(account, media, path, is_video, caption, peers, comment, parent) sender.send_media(account, media, path, is_video, caption, targets, comment, parent)
try: try:
# ShareAlert owns its own window, so it shows over PhotoViewer just # ShareAlert owns its own window, so it shows over PhotoViewer just
# like the stock share button does - no need to close the viewer. # 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: except Exception as exc:
log("[extended_media_forwards] picker failed: %s" % exc) log("[extended_media_forwards] picker failed: %s" % exc)
sender.notify_error(str(exc)) sender.notify_error(str(exc))
+39 -9
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 (`sendingMessageObjects`, `sendingText` and `sendingFile` all null), which makes
its send path a no-op: it selects chats, fires `ShareAlertDelegate.didShare()` its send path a no-op: it selects chats, fires `ShareAlertDelegate.didShare()`
and dismisses. The selection is read back off the (protected) `selectedDialogs` 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 android_utils import log, run_on_ui_thread
from hook_utils import get_private_field 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 _delegate_class = None
# ShareAlert's own constant for "keep the keyboard out of the way". # 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 from org.telegram.ui.Components import ShareAlert
class Delegate(dynamic_proxy(ShareAlert.ShareAlertDelegate)): class Delegate(dynamic_proxy(ShareAlert.ShareAlertDelegate)):
def __init__(self, alert, on_picked): def __init__(self, alert, account, on_picked):
super().__init__() super().__init__()
self.alert = alert self.alert = alert
self.account = account
self.on_picked = on_picked self.on_picked = on_picked
def didCopy(self): def didCopy(self):
return False return False
def didShare(self): 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 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") dialogs = get_private_field(alert, "selectedDialogs")
if dialogs is None: if dialogs is None:
return [] 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): def _comment(alert):
@@ -57,9 +87,9 @@ def _comment(alert):
return str(text).strip() if text is not None else "" return str(text).strip() if text is not None else ""
def pick_chats(context, on_picked, resources_provider=None): def pick_chats(context, account, on_picked, resources_provider=None):
"""Shows the stock share sheet. `on_picked(dialog_ids, comment)` runs on the """Shows the stock share sheet. `on_picked(targets, comment)` runs on the
UI thread once the user confirms.""" UI thread once the user confirms, with one `Target` per chosen chat."""
global _delegate_class global _delegate_class
from org.telegram.ui.Components import ShareAlert 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: if _delegate_class is None:
_delegate_class = _make_delegate_class() _delegate_class = _make_delegate_class()
# Keep a reference on the module so the proxy is not collected early. # 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) alert.setDelegate(delegate)
pick_chats._alive = delegate pick_chats._alive = delegate
+19 -9
View File
@@ -38,10 +38,10 @@ CAPTION_TEXT = "telegra.ph source"
RICH_CAPTION_TEXT = "message source" RICH_CAPTION_TEXT = "message source"
def send_media(account, media, cached_path, is_video, caption, peers, comment, parent=None): 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. """Starts the download+send in the background. Safe to call on the UI thread.
`caption` is `(text, url)` for a linked caption or None for none. `parent` `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 is the object the media belongs to (WebPage or MessageObject); FileLoader
uses it to refresh an expired file reference. uses it to refresh an expired file reference.
""" """
@@ -49,10 +49,10 @@ def send_media(account, media, cached_path, is_video, caption, peers, comment, p
def worker(): def worker():
try: try:
path = cached_path or _download(account, media, is_video, parent) path = cached_path or _download(account, media, is_video, parent)
for peer in peers: for target in targets:
if comment: if comment:
_send(account, _text_params(comment, peer)) _send(account, _text_params(comment, target))
_send(account, _media_params(account, media, path, is_video, caption, peer)) _send(account, _media_params(account, media, path, is_video, caption, target))
except Exception as exc: except Exception as exc:
log("[extended_media_forwards] send failed: %s" % exc) log("[extended_media_forwards] send failed: %s" % exc)
notify_error(str(exc)) notify_error(str(exc))
@@ -62,16 +62,26 @@ def send_media(account, media, cached_path, is_video, caption, peers, comment, p
threading.Thread(target=worker, name="extended-media-forwards", 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 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, caption, peer): def _media_params(account, media, path, is_video, caption, target):
from org.telegram.messenger import SendMessagesHelper from org.telegram.messenger import SendMessagesHelper
params = SendMessagesHelper.SendMessageParams.of(None, peer) params = _address(SendMessagesHelper.SendMessageParams.of(None, target.peer), target)
if caption is not None: if caption is not None:
text, url = caption text, url = caption
params.caption = text params.caption = text
+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()
+68
View File
@@ -463,6 +463,74 @@ def main(argv=None) -> int:
f"got {links!r}", 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) media_at = getattr(ivmedia, "media_at", None)
no_media = getattr(ivmedia, "NoMedia", None) no_media = getattr(ivmedia, "NoMedia", None)
raised = False raised = False