generated from exteraGramPlugins/pluginBase
[no bump] Move technical docs to docs/, add icon and PNG export tool
Publish plugin / publish (push) Successful in 4s
Publish plugin / publish (push) Successful in 4s
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>
This commit is contained in:
1 parent
a2e4075a03
commit
b86cc8a1c8
9 files changed
+412
-218
No files matched your search
@@ -1,229 +1,53 @@
|
||||
# Message Logger (exteraGram plugin)
|
||||
<p align="center">
|
||||
<img src="images/icon.svg" width="128" height="128" alt="Message Logger icon">
|
||||
</p>
|
||||
|
||||
Shows what a [telegram-panel](../../telegram-panel) message logger saved, natively in chats:
|
||||
<h1 align="center">Message Logger</h1>
|
||||
|
||||
- messages others delete stay (the delete updates are dropped), at 75% opacity with a red trash
|
||||
icon (from the active icon pack) in the time row, and are reported to the server;
|
||||
- logged messages deleted while the app was closed (or never seen deleted by the server, e.g.
|
||||
bot-logged chats) are confirmed with Telegram, reported, and restored from the logger copy;
|
||||
- *Edit history* in the message menu (right above Forward, for edited messages) opens every
|
||||
version as a mock chat — also on the logger groups' own posts — with an *Open topic* button
|
||||
for the history topic in the activity group. Versions and their media are read from the activity group through
|
||||
a signed-in account that can (the current one, the master, or any member); otherwise the
|
||||
server's text is shown and media are marked unavailable;
|
||||
- the comments button of a deleted channel post (whose thread Telegram deletes with it) opens
|
||||
the app's own comments section for it — avatars, names, replies, media — filled from the
|
||||
logged comments (the thread's history request is answered locally);
|
||||
- message links into a chat the current account isn't in (e.g. a log post's "Message" link)
|
||||
open that chat as a signed-in account that is in it; going back returns to your account.
|
||||
Every signed-in account's chats are cached client-side for this.
|
||||
**TL;DR:** an [exteraGram](https://exteragram.app) plugin that keeps deleted messages in
|
||||
your chats and shows the edit history of edited ones, using what your
|
||||
[telegram-panel](../../telegram-panel) message logger saved.
|
||||
|
||||
The server only points at copies (logger/activity group message ids); the client reads them
|
||||
through its own session, so the account must be a member of those groups.
|
||||
## Description
|
||||
|
||||
The server is asked about a chat only when needed: when it wasn't checked within the last hour
|
||||
(from the oldest loaded message on; the answer replaces the chat's cached marks), when older
|
||||
history is loaded past the checked range (that part only), or after an edit/delete event in the
|
||||
chat. Answers hold at most 100 marks (edited/deleted) and 100 missing ids, newest first. Marks
|
||||
are cached per chat in the plugin's SQLite (`state.db`, no size cap), so a chat opened again —
|
||||
even offline — shows them at once, and deleted messages are restored from the cache. An expired
|
||||
chat keeps its cache until the next successful check, which replaces it and starts over. Cached
|
||||
answers older than *Cache lifetime* (1/7/30/90/365 days or never; 30 by default) are removed,
|
||||
checked once a day (from local midnight) and when the setting changes.
|
||||
Message Logger connects exteraGram to a telegram-panel message logger and shows its data
|
||||
natively in chats. Messages other people delete stay visible, edited messages get a full
|
||||
edit history, and comments of deleted channel posts can still be opened. Marks are cached on
|
||||
the device, so a chat opened again, even offline, shows them at once.
|
||||
|
||||
Setup: in the panel create a token per master — the account owning the logger groups —
|
||||
(Settings → API Tokens); in the plugin settings enter the panel URL and the tokens (separated by
|
||||
spaces), then *Test connection*. Any Telegram account can use a token. Diagnostics: see `docs/sdk-notes.md` (gotchas section).
|
||||
## Features
|
||||
|
||||
The rest of this README describes the build toolchain this repo came from (pluginBase).
|
||||
- **Deleted messages stay.** Messages others delete remain in the chat at 75% opacity with a
|
||||
red trash icon (from the active icon pack) in the time row. Messages deleted while the app
|
||||
was closed are confirmed with Telegram and restored from the logger copy.
|
||||
- **Edit history.** *Edit history* in the message menu (right above Forward, for edited
|
||||
messages) opens every version as a mock chat, also for the logger groups' own posts, with
|
||||
an *Open topic* button for the history topic in the activity group.
|
||||
- **Comments of deleted channel posts.** The comments button of a deleted post opens the
|
||||
app's own comments section for it (avatars, names, replies, media), filled from the logged
|
||||
comments.
|
||||
- **Links into chats you're not in.** Message links into a chat the current account isn't
|
||||
in (e.g. a log post's "Message" link) open that chat as a signed-in account that is in it;
|
||||
going back returns to your account.
|
||||
- **Cache lifetime.** Cached answers are kept for 1, 7, 30, 90 or 365 days, or forever
|
||||
(30 by default).
|
||||
|
||||
## Plugin base toolchain
|
||||
The account reading the logged copies must be a member of the logger and activity groups.
|
||||
|
||||
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.
|
||||
## Setup
|
||||
|
||||
## How exteraGram plugins actually work
|
||||
1. In the panel, create a token per master, the account owning the logger groups
|
||||
(Settings → API Tokens). Any Telegram account can use a token.
|
||||
2. In the plugin settings, enter the panel URL and the tokens (separated by spaces).
|
||||
3. Tap *Test connection*.
|
||||
|
||||
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`.
|
||||
For diagnostics, see the gotchas section of [docs/sdk-notes.md](docs/sdk-notes.md).
|
||||
|
||||
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.
|
||||
## Documentation
|
||||
|
||||
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
|
||||
`docs/sdk-notes.md` for why.
|
||||
|
||||
## 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)
|
||||
.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.
|
||||
- [How Message Logger works](docs/how-it-works.md): server sync and the local cache
|
||||
- [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,65 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
## 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,16 @@
|
||||
# How Message Logger works
|
||||
|
||||
## Server sync and caching
|
||||
|
||||
The server only points at copies (logger/activity group message ids); the client reads them
|
||||
through its own session, so the account must be a member of those groups.
|
||||
|
||||
The server is asked about a chat only when needed: when it wasn't checked within the last hour
|
||||
(from the oldest loaded message on; the answer replaces the chat's cached marks), when older
|
||||
history is loaded past the checked range (that part only), or after an edit/delete event in the
|
||||
chat. Answers hold at most 100 marks (edited/deleted) and 100 missing ids, newest first. Marks
|
||||
are cached per chat in the plugin's SQLite (`state.db`, no size cap), so a chat opened again —
|
||||
even offline — shows them at once, and deleted messages are restored from the cache. An expired
|
||||
chat keeps its cache until the next successful check, which replaces it and starts over. Cached
|
||||
answers older than *Cache lifetime* (1/7/30/90/365 days or never; 30 by default) are removed,
|
||||
checked once a day (from local midnight) and when the setting changes.
|
||||
@@ -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,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,51 @@
|
||||
<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(-10,-10)">
|
||||
<path filter="url(#sh)" fill="url(#paper)" stroke="#B9B9B9" stroke-width="6" stroke-linejoin="round" d="M198,170 H408 A48,48 0 0 1 456,218 V392 A48,48 0 0 1 408,440 H270 L186,498 L204,440 H198 A48,48 0 0 1 150,392 V218 A48,48 0 0 1 198,170 Z"/>
|
||||
<g stroke-linecap="round">
|
||||
<line x1="196" y1="234" x2="390" y2="234" stroke="#555555" stroke-width="18"/>
|
||||
<line x1="196" y1="290" x2="410" y2="290" stroke="#8C8C8C" stroke-width="13"/>
|
||||
<line x1="196" y1="340" x2="370" y2="340" stroke="#C4C4C4" stroke-width="13"/>
|
||||
<line x1="186" y1="340" x2="380" y2="340" stroke="#F5517A" stroke-width="7"/>
|
||||
<line x1="196" y1="390" x2="300" y2="390" stroke="#8C8C8C" stroke-width="13"/>
|
||||
</g>
|
||||
<g filter="url(#sh)">
|
||||
<circle cx="452" cy="430" r="78" fill="url(#star)" stroke="#F59A2E" stroke-width="8" stroke-linejoin="round"/>
|
||||
<circle cx="452" cy="430" r="56" fill="#FFFFFF" stroke="#F59A2E" stroke-width="5"/>
|
||||
<g stroke="#E0B060" stroke-width="6" stroke-linecap="round">
|
||||
<line x1="452" y1="382" x2="452" y2="390"/><line x1="452" y1="470" x2="452" y2="478"/>
|
||||
<line x1="404" y1="430" x2="412" y2="430"/><line x1="492" y1="430" x2="500" y2="430"/>
|
||||
</g>
|
||||
<g stroke="#555555" stroke-width="10" stroke-linecap="round">
|
||||
<line x1="452" y1="430" x2="452" y2="396"/><line x1="452" y1="430" x2="478" y2="446"/>
|
||||
</g>
|
||||
<circle cx="452" cy="430" r="7" fill="#555555"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
+1
-1
@@ -4,7 +4,7 @@
|
||||
"description": "Shows deleted and edited messages and their edit history from your telegram-panel message logger.",
|
||||
"author": "@lyuksovannyy",
|
||||
"version": "0.1.1",
|
||||
"icon": "exteraPlugins/1",
|
||||
"icon": "exteraGramPluginIcons/5",
|
||||
"app_version": ">=12.5.1",
|
||||
"sdk_version": ">=1.4.4.3",
|
||||
"requirements": [
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user