lyuksovannyyandClaude Opus 5.5 9b2f26a845
Publish plugin / publish (push) Successful in 4s
Message Logger: show logged edits and deletions from telegram-panel [no bump]
Keeps deleted messages (75% opacity, red icon-pack trash icon), restores
logged messages deleted while away, reports deletions, and shows edit
history as a mock chat (Edit history in the message menu, Open topic).
Message links and history open as any signed-in account with access.
Per-master API tokens; chat state synced per checked range with an hour's
validity, cached in SQLite with a configurable lifetime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:55:23 +02:00
2026-10-08 13:55:36 +00:00
2026-10-08 13:55:36 +00:00
2026-10-08 13:55:36 +00:00
2026-10-08 13:55:36 +00:00
2026-10-08 13:55:36 +00:00
2026-10-08 13:55:36 +00:00

Message Logger (exteraGram plugin)

Shows what a telegram-panel message logger saved, natively in chats:

  • 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;
  • 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.

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.

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).

The rest of this README describes the build toolchain this repo came from (pluginBase).

Plugin base toolchain

A build toolchain and ADB automation for writing exteraGram plugins as ordinary multi-file Python projects instead of hand-maintaining one giant .py file.

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 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

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).

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

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).
  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.
S
Description
exteraGram message logger using telegram-panel message logger api endpoints to receive deleted/edited messaged
Readme MIT
335 KiB
0 Stars 2 Watchers 0 Forks
v0.1.7
Latest
2026-10-14 15:32:04 +00:00
Languages
Python 97.7%
Shell 2.3%