From a489ae8d583cc489dd28cb3004fb9779b0bdeb53 Mon Sep 17 00:00:00 2001 From: jerryfane Date: Fri, 29 May 2026 18:15:24 +0200 Subject: [PATCH 1/2] Add Agentgram plugin --- .agents/plugins/marketplace.json | 15 ++ README.md | 1 + plugins.json | 14 +- .../.agents/plugins/marketplace.json | 20 +++ .../agentgram/.codex-plugin/plugin.json | 38 +++++ plugins/jerryfane/agentgram/LICENSE | 21 +++ plugins/jerryfane/agentgram/README.md | 148 ++++++++++++++++++ .../agentgram/assets/agentgram-logo.svg | 6 + plugins/jerryfane/agentgram/pyproject.toml | 45 ++++++ .../agentgram/skills/agentgram/SKILL.md | 72 +++++++++ 10 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 plugins/jerryfane/agentgram/.agents/plugins/marketplace.json create mode 100644 plugins/jerryfane/agentgram/.codex-plugin/plugin.json create mode 100644 plugins/jerryfane/agentgram/LICENSE create mode 100644 plugins/jerryfane/agentgram/README.md create mode 100644 plugins/jerryfane/agentgram/assets/agentgram-logo.svg create mode 100644 plugins/jerryfane/agentgram/pyproject.toml create mode 100644 plugins/jerryfane/agentgram/skills/agentgram/SKILL.md diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index e37b498f..c35af0ec 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -739,6 +739,21 @@ "description": "macOS-only local camera plugin for explicit snapshots, streaming controls, and file-backed image input.", "icon": "./plugins/zfifteen/agent-vision/assets/composer-icon.png" }, + { + "name": "agentgram", + "displayName": "Agentgram", + "source": { + "source": "local", + "path": "./plugins/jerryfane/agentgram" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Tools & Integrations", + "description": "Send explicit Telegram messages from Codex and local AI agents through a Telegram bot token and chat id.", + "icon": "./plugins/jerryfane/agentgram/assets/agentgram-logo.svg" + }, { "name": "apple-calendar", "displayName": "Apple Productivity", diff --git a/README.md b/README.md index 10b242df..55d85729 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,7 @@ Third-party plugins built by the community. [PRs welcome](#contributing)! - [Agent Message Queue](https://github.com/avivsinai/agent-message-queue) - File-based inter-agent messaging with co-op mode, cross-project federation, and orchestrator integrations. - [Agent Vision](https://github.com/zfifteen/agent-vision) - macOS-only local camera plugin for explicit snapshots, streaming controls, and file-backed image input. +- [Agentgram](https://github.com/jerryfane/agentgram) - Send explicit Telegram messages from Codex and local AI agents through a Telegram bot token and chat id. - [Apple Productivity](https://github.com/matk0shub/apple-productivity-mcp) - Local Apple Calendar and Reminders tooling for macOS with Codex plugin adapters. - [AxonFlow](https://github.com/getaxonflow/axonflow-codex-plugin) - Runtime governance for Codex with policy enforcement on terminal commands, advisory checks for non-terminal tools via skills, PII/secret detection, and compliance-grade audit trails. Self-hosted via Docker. - [Bitbucket CLI](https://github.com/avivsinai/bitbucket-cli) - Manage Bitbucket repos, PRs, branches, issues, webhooks, and pipelines for Data Center and Cloud. diff --git a/plugins.json b/plugins.json index 4697d7b5..d5c1a9bc 100644 --- a/plugins.json +++ b/plugins.json @@ -2,8 +2,8 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "name": "awesome-codex-plugins", "version": "1.0.0", - "last_updated": "2026-05-27", - "total": 92, + "last_updated": "2026-05-29", + "total": 93, "categories": [ "Development & Workflow", "Tools & Integrations" @@ -509,6 +509,16 @@ "source": "awesome-codex-plugins", "install_url": "https://raw.githubusercontent.com/zfifteen/agent-vision/HEAD/.codex-plugin/plugin.json" }, + { + "name": "Agentgram", + "url": "https://github.com/jerryfane/agentgram", + "owner": "jerryfane", + "repo": "agentgram", + "description": "Send explicit Telegram messages from Codex and local AI agents through a Telegram bot token and chat id.", + "category": "Tools & Integrations", + "source": "awesome-codex-plugins", + "install_url": "https://raw.githubusercontent.com/jerryfane/agentgram/HEAD/.codex-plugin/plugin.json" + }, { "name": "Apple Productivity", "url": "https://github.com/matk0shub/apple-productivity-mcp", diff --git a/plugins/jerryfane/agentgram/.agents/plugins/marketplace.json b/plugins/jerryfane/agentgram/.agents/plugins/marketplace.json new file mode 100644 index 00000000..1e81c51c --- /dev/null +++ b/plugins/jerryfane/agentgram/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "agentgram", + "interface": { + "displayName": "Agentgram" + }, + "plugins": [ + { + "name": "agentgram", + "source": { + "source": "local", + "path": "./plugins/agentgram" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/plugins/jerryfane/agentgram/.codex-plugin/plugin.json b/plugins/jerryfane/agentgram/.codex-plugin/plugin.json new file mode 100644 index 00000000..3ae31b19 --- /dev/null +++ b/plugins/jerryfane/agentgram/.codex-plugin/plugin.json @@ -0,0 +1,38 @@ +{ + "name": "agentgram", + "version": "0.1.1", + "description": "Codex and AI-agent Telegram messaging plugin with a local CLI.", + "author": { + "name": "Jerry Fane", + "url": "https://github.com/jerryfane" + }, + "repository": "https://github.com/jerryfane/agentgram", + "license": "MIT", + "keywords": [ + "ai-agents", + "codex", + "codex-plugin", + "notifications", + "telegram", + "telegram-bot" + ], + "skills": "./skills/", + "interface": { + "displayName": "Agentgram", + "shortDescription": "Send Telegram messages from Codex and agent sessions.", + "longDescription": "Agentgram is a Codex Telegram plugin and local CLI for AI agents. It sends explicit, user-requested Telegram messages through the Telegram Bot API using a bot token and chat id from the user's environment.", + "developerName": "Jerry Fane", + "category": "Productivity", + "websiteURL": "https://github.com/jerryfane/agentgram", + "brandColor": "#1F8A70", + "composerIcon": "assets/agentgram-logo.svg", + "logo": "assets/agentgram-logo.svg", + "capabilities": [ + "Read", + "Write" + ], + "defaultPrompt": [ + "Use Agentgram to send a Telegram message." + ] + } +} diff --git a/plugins/jerryfane/agentgram/LICENSE b/plugins/jerryfane/agentgram/LICENSE new file mode 100644 index 00000000..8c535a46 --- /dev/null +++ b/plugins/jerryfane/agentgram/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jerry Fane + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/jerryfane/agentgram/README.md b/plugins/jerryfane/agentgram/README.md new file mode 100644 index 00000000..c207eda9 --- /dev/null +++ b/plugins/jerryfane/agentgram/README.md @@ -0,0 +1,148 @@ +# Agentgram + +[![CI](https://github.com/jerryfane/agentgram/actions/workflows/ci.yml/badge.svg)](https://github.com/jerryfane/agentgram/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/jerryfane/agentgram)](https://github.com/jerryfane/agentgram/releases) +[![PyPI](https://img.shields.io/pypi/v/agentgram-tg)](https://pypi.org/project/agentgram-tg/) +[![License](https://img.shields.io/github/license/jerryfane/agentgram)](LICENSE) + +Agentgram is a Codex Telegram plugin and agent-neutral messaging helper. It lets +Codex and other local AI agents send explicit, user-requested Telegram messages +through a Telegram bot token and chat id. + +Agentgram is intentionally local-first. It does not run a hosted service, and it +does not send automatic completion notifications unless a future task explicitly +adds that behavior. + +Use Agentgram when you want a Telegram notification plugin for AI agents, a +simple way to send Telegram messages from Codex, or a reusable local CLI for +agent messaging via a bot token. + +## Requirements + +- Python 3.12 or newer. +- A Telegram bot token from BotFather. +- A Telegram chat where the bot has been started or added. + +## Configuration + +Set secrets in your shell or agent runtime environment: + +```sh +export TELEGRAM_BOT_TOKEN="123456:bot-token" +export TELEGRAM_CHAT_ID="123456789" +``` + +Do not put real tokens in tracked files. `.env` and `.env.*` are ignored for +local use, but environment variables are the preferred setup. + +For local setup templates, copy [.env.example](.env.example). It contains +variable names only. + +## Usage + +Install the released CLI from PyPI: + +```sh +pipx install agentgram-tg +``` + +Or install from a git checkout, then put the CLI on your `PATH`: + +```sh +git clone https://github.com/jerryfane/agentgram.git ~/.agentgram/agentgram +mkdir -p ~/.local/bin +ln -sf ~/.agentgram/agentgram/bin/agentgram ~/.local/bin/agentgram +``` + +Run the local CLI: + +```sh +agentgram doctor +agentgram send "deploy finished" +agentgram send --silent --no-preview "quiet update" +agentgram send --parse-mode HTML "deploy finished" +``` + +To discover a chat id, first send a message to the bot in Telegram, then run: + +```sh +agentgram chat-id +``` + +For raw Telegram `getUpdates` output: + +```sh +agentgram chat-id --raw +``` + +To check whether the local git checkout is current using existing local refs, or +to update with a fast-forward-only pull: + +```sh +agentgram update --check +agentgram update +``` + +`agentgram update` refuses dirty worktrees, validates the checkout after pulling, +and prints runtime-specific next steps when it can detect them. Codex plugin +users should reinstall or refresh the plugin and start a new thread so updated +skills are loaded. + +## Codex Plugin + +The Codex plugin skill lives in `skills/agentgram/SKILL.md`, with the plugin +manifest at `.codex-plugin/plugin.json`. The skill tells Codex to use the local +`agentgram` CLI as the execution path. This repository also contains a public +Codex marketplace file so Agentgram can be installed as a Codex Telegram plugin. + +To install Agentgram from the public Codex marketplace file in this repository: + +```sh +codex plugin marketplace add jerryfane/agentgram --ref main +codex plugin add agentgram@agentgram +``` + +Start a new Codex thread after installing so the Agentgram skill is loaded. +Use `codex plugin marketplace upgrade agentgram` before reinstalling when you +want newer Agentgram releases. + +When a user asks an agent to send a Telegram message, the agent should: + +1. Run `agentgram doctor`, or `bin/agentgram doctor` only from this repository + checkout. +2. Run `agentgram send "message"`, or `bin/agentgram send "message"` only from + this repository checkout, if setup is valid. +3. Avoid direct Telegram API calls unless the user explicitly asks to bypass the + Agentgram CLI. + +## Troubleshooting + +- Bot was not started: open Telegram, send any message to the bot, then run + `agentgram chat-id` again. +- Bad token: run `agentgram doctor`; malformed tokens fail locally, and revoked + or wrong tokens fail the Telegram `getMe` check. +- Missing chat id: set `TELEGRAM_CHAT_ID`, or pass `--chat-id ` for a + one-off send after the user provides the target chat. +- Forbidden chat: add the bot to the target chat or start a private chat with + it, then retry after confirming the chat id. +- Telegram API errors: Agentgram prints Telegram's error description without the + bot token. Re-run `agentgram doctor` before retrying. + +## Release Checks + +Before release, run: + +```sh +python3 -m unittest discover -s tests -v +python3 scripts/validate_manifest.py +git diff --check +``` + +See [docs/release-checklist.md](docs/release-checklist.md) for the full +checklist and fresh-clone smoke. + +## Status + +Released. Agentgram `v0.1.x` includes the Telegram CLI, Codex skill packaging, +public Codex marketplace metadata, update ergonomics, release docs, and CI +checks. diff --git a/plugins/jerryfane/agentgram/assets/agentgram-logo.svg b/plugins/jerryfane/agentgram/assets/agentgram-logo.svg new file mode 100644 index 00000000..53a85c69 --- /dev/null +++ b/plugins/jerryfane/agentgram/assets/agentgram-logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugins/jerryfane/agentgram/pyproject.toml b/plugins/jerryfane/agentgram/pyproject.toml new file mode 100644 index 00000000..b9a8f518 --- /dev/null +++ b/plugins/jerryfane/agentgram/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "agentgram-tg" +version = "0.1.1" +description = "Telegram messaging CLI and Codex plugin for local AI agents." +readme = "README.md" +requires-python = ">=3.12" +license = { text = "MIT" } +authors = [ + { name = "Jerry Fane" } +] +keywords = [ + "agentgram", + "ai-agents", + "codex", + "codex-plugin", + "notifications", + "telegram", + "telegram-bot" +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Communications :: Chat", + "Topic :: Software Development :: User Interfaces" +] +dependencies = [] + +[project.urls] +Homepage = "https://github.com/jerryfane/agentgram" +Repository = "https://github.com/jerryfane/agentgram" +Issues = "https://github.com/jerryfane/agentgram/issues" +Changelog = "https://github.com/jerryfane/agentgram/releases" + +[project.scripts] +agentgram = "agentgram_tg.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/plugins/jerryfane/agentgram/skills/agentgram/SKILL.md b/plugins/jerryfane/agentgram/skills/agentgram/SKILL.md new file mode 100644 index 00000000..76fa5f43 --- /dev/null +++ b/plugins/jerryfane/agentgram/skills/agentgram/SKILL.md @@ -0,0 +1,72 @@ +--- +name: agentgram +description: Send explicit, user-requested Telegram messages from an agent session through the local Agentgram command-line tool. +--- + +# Agentgram + +Agentgram is a small Telegram messaging helper for agents. Use this skill when +the user asks to send a Telegram message, verify Telegram messaging setup, find +a chat id, or update the local Agentgram install. + +Before sending messages, prefer the installed `agentgram` command. If it is not +on `PATH` and Agentgram is installed as a Codex plugin, resolve the plugin root +from this skill file at `/skills/agentgram/SKILL.md` and use +`/bin/agentgram` (`../../bin/agentgram` relative to this file). Use +`./bin/agentgram` only after verifying the current checkout is Agentgram itself, +with `.codex-plugin/plugin.json` name `agentgram` and +`skills/agentgram/SKILL.md` present. In any other repository, report that +Agentgram is not installed instead of running project-local fallback scripts or +making an ad hoc Telegram API call. + +## Commands + +```sh +agentgram send "message text" +agentgram chat-id +agentgram doctor +agentgram update +agentgram update --check +``` + +## Required Setup + +Agentgram reads: + +- `TELEGRAM_BOT_TOKEN` +- `TELEGRAM_CHAT_ID` + +Secrets must come from environment variables or a user-owned local config file, +never from tracked repository files, chat output, PR bodies, logs, or generated +plugin packages. + +## Send Workflow + +1. Resolve the safe Agentgram command as `AGENTGRAM_CMD`: `agentgram` on + `PATH`, `/bin/agentgram` from an installed Agentgram plugin, or + `./bin/agentgram` only from a verified Agentgram checkout. +2. Run `$AGENTGRAM_CMD doctor` before sending, unless the user explicitly asks + for a best-effort send without preflight. +3. If `doctor` only fails because `TELEGRAM_CHAT_ID` is missing and the user + provided the target chat id for this message, proceed with + `$AGENTGRAM_CMD send --chat-id `. +4. If `doctor` reports missing `TELEGRAM_CHAT_ID` and the user did not provide + a chat id, run `$AGENTGRAM_CMD chat-id` only after the user has messaged the + bot or added it to the target chat. +5. Send only the exact user-requested message with `$AGENTGRAM_CMD send`. +6. Use `--chat-id` only when the user provided a specific override for that + message. +7. Use `--parse-mode HTML` or `--parse-mode MarkdownV2` only when the user asks + for formatted Telegram output or the message clearly requires it. + +Do not send automatic status updates merely because an agent task completed. +Agentgram sends should be explicit and user-requested. + +## Update Workflow + +Use `$AGENTGRAM_CMD update --check` for a read-only status check on git-based +installs. Use `$AGENTGRAM_CMD update` only when the user asks to update +Agentgram. The update command refuses dirty git checkouts, runs +`git pull --ff-only`, validates the local CLI/plugin files, and prints any Codex +refresh instructions it can detect. For Codex marketplace installs, update with +`codex plugin marketplace upgrade` and then reinstall `agentgram@agentgram`. From 46de383e9e11143919dbdd5de95f64604bfb7bd9 Mon Sep 17 00:00:00 2001 From: jerryfane Date: Fri, 29 May 2026 18:43:55 +0200 Subject: [PATCH 2/2] Fix Agentgram mirrored plugin bundle --- .../.agents/plugins/marketplace.json | 2 +- plugins/jerryfane/agentgram/bin/agentgram | 17 + .../agentgram/src/agentgram_tg/__init__.py | 5 + .../agentgram/src/agentgram_tg/cli.py | 468 ++++++++++++++++++ .../agentgram/src/agentgram_tg/telegram.py | 89 ++++ 5 files changed, 580 insertions(+), 1 deletion(-) create mode 100755 plugins/jerryfane/agentgram/bin/agentgram create mode 100644 plugins/jerryfane/agentgram/src/agentgram_tg/__init__.py create mode 100644 plugins/jerryfane/agentgram/src/agentgram_tg/cli.py create mode 100644 plugins/jerryfane/agentgram/src/agentgram_tg/telegram.py diff --git a/plugins/jerryfane/agentgram/.agents/plugins/marketplace.json b/plugins/jerryfane/agentgram/.agents/plugins/marketplace.json index 1e81c51c..33b8c677 100644 --- a/plugins/jerryfane/agentgram/.agents/plugins/marketplace.json +++ b/plugins/jerryfane/agentgram/.agents/plugins/marketplace.json @@ -8,7 +8,7 @@ "name": "agentgram", "source": { "source": "local", - "path": "./plugins/agentgram" + "path": "." }, "policy": { "installation": "AVAILABLE", diff --git a/plugins/jerryfane/agentgram/bin/agentgram b/plugins/jerryfane/agentgram/bin/agentgram new file mode 100755 index 00000000..f53d7a71 --- /dev/null +++ b/plugins/jerryfane/agentgram/bin/agentgram @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Agentgram command entry point.""" + +from pathlib import Path +import sys + + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from agentgram_tg.cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/jerryfane/agentgram/src/agentgram_tg/__init__.py b/plugins/jerryfane/agentgram/src/agentgram_tg/__init__.py new file mode 100644 index 00000000..fab65d0e --- /dev/null +++ b/plugins/jerryfane/agentgram/src/agentgram_tg/__init__.py @@ -0,0 +1,5 @@ +"""Agentgram Telegram messaging helpers.""" + +__all__ = ["__version__"] + +__version__ = "0.1.1" diff --git a/plugins/jerryfane/agentgram/src/agentgram_tg/cli.py b/plugins/jerryfane/agentgram/src/agentgram_tg/cli.py new file mode 100644 index 00000000..568e5267 --- /dev/null +++ b/plugins/jerryfane/agentgram/src/agentgram_tg/cli.py @@ -0,0 +1,468 @@ +"""Command-line interface for Agentgram.""" + +from __future__ import annotations + +import argparse +from html.parser import HTMLParser +import json +import os +from pathlib import Path +import subprocess +import sys +from typing import Any, Iterable, TextIO +from urllib.parse import urlsplit, urlunsplit + +from . import __version__ +from .telegram import TelegramClient, TelegramError, looks_like_token + + +MAX_TEXT_LENGTH = 4096 +TOKEN_ENV = "TELEGRAM_BOT_TOKEN" +CHAT_ID_ENV = "TELEGRAM_CHAT_ID" +PLUGIN_NAME = "agentgram" +PYTHON_PACKAGE = "agentgram_tg" + + +class CliError(RuntimeError): + """Raised for user-correctable command errors.""" + + +def main(argv: list[str] | None = None) -> int: + return run(argv, stdout=sys.stdout, stderr=sys.stderr, environ=os.environ) + + +def run( + argv: list[str] | None = None, + *, + stdout: TextIO, + stderr: TextIO, + environ: dict[str, str], +) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + result = args.func(args, stdout=stdout, environ=environ) + except CliError as exc: + print(f"agentgram: {exc}", file=stderr) + return 2 + except TelegramError as exc: + print(f"agentgram: {exc}", file=stderr) + return 1 + return result + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="agentgram", + description="Send explicit Telegram messages from local agent sessions.", + ) + parser.add_argument("--version", action="version", version=f"agentgram {__version__}") + subcommands = parser.add_subparsers(dest="command", required=True) + + send = subcommands.add_parser("send", help="send a Telegram text message") + send.add_argument("--chat-id", help=f"override {CHAT_ID_ENV}") + send.add_argument("--parse-mode", choices=("HTML", "MarkdownV2"), help="Telegram parse mode") + send.add_argument("--silent", action="store_true", help="send without notification sound") + send.add_argument("--no-preview", action="store_true", help="disable link previews") + send.add_argument("text", nargs="+", help="message text") + send.set_defaults(func=cmd_send) + + chat_id = subcommands.add_parser("chat-id", help="show candidate chat ids from recent updates") + chat_id.add_argument("--raw", action="store_true", help="print raw getUpdates JSON") + chat_id.set_defaults(func=cmd_chat_id) + + doctor = subcommands.add_parser("doctor", help="check Agentgram and Telegram configuration") + doctor.add_argument("--json", action="store_true", dest="json_output", help="print JSON") + doctor.set_defaults(func=cmd_doctor) + + update = subcommands.add_parser("update", help="check or update a git-based Agentgram checkout") + update.add_argument("--check", action="store_true", help="only check update status") + update.add_argument("--repo", default=str(repo_root()), help="Agentgram repository path") + update.set_defaults(func=cmd_update) + return parser + + +def cmd_send(args: argparse.Namespace, *, stdout: TextIO, environ: dict[str, str]) -> int: + token = require_env(environ, TOKEN_ENV) + chat_id = args.chat_id or require_env(environ, CHAT_ID_ENV) + text = normalize_text(args.text) + payload = build_send_payload( + chat_id=chat_id, + text=text, + parse_mode=args.parse_mode, + silent=args.silent, + no_preview=args.no_preview, + ) + message = TelegramClient(token).send_message(payload) + message_id = message.get("message_id") + if message_id is None: + print("sent", file=stdout) + else: + print(f"sent message_id={message_id}", file=stdout) + return 0 + + +def cmd_chat_id(args: argparse.Namespace, *, stdout: TextIO, environ: dict[str, str]) -> int: + token = require_env(environ, TOKEN_ENV) + updates = TelegramClient(token).get_updates() + if args.raw: + print(json.dumps(updates, indent=2, sort_keys=True), file=stdout) + return 0 + + candidates = extract_chat_candidates(updates) + if not candidates: + print("No chat ids found. Send a message to the bot, then run this command again.", file=stdout) + return 0 + for candidate in candidates: + title = candidate.get("title") or candidate.get("username") or candidate.get("name") or "(untitled)" + print(f"{candidate['id']}\t{candidate['type']}\t{title}", file=stdout) + return 0 + + +def cmd_doctor(args: argparse.Namespace, *, stdout: TextIO, environ: dict[str, str]) -> int: + checks: list[dict[str, Any]] = [] + token = environ.get(TOKEN_ENV, "").strip() + chat_id = environ.get(CHAT_ID_ENV, "").strip() + root = repo_root() + checks.append(check("bot_token_env", bool(token), f"{TOKEN_ENV} is {'set' if token else 'missing'}")) + checks.append( + check( + "bot_token_shape", + bool(token and looks_like_token(token)), + "token shape looks valid" if token and looks_like_token(token) else "token shape is invalid or unknown", + required=False, + ) + ) + checks.append(check("chat_id_env", bool(chat_id), f"{CHAT_ID_ENV} is {'set' if chat_id else 'missing'}")) + checks.append( + check( + "plugin_manifest", + (root / ".codex-plugin" / "plugin.json").is_file(), + ".codex-plugin/plugin.json present", + required=False, + ) + ) + checks.append( + check( + "skill_file", + (root / "skills" / "agentgram" / "SKILL.md").is_file(), + "skills/agentgram/SKILL.md present", + required=False, + ) + ) + origin = git_origin_url(root) + safe_origin = redact_url_userinfo(origin) + checks.append( + check( + "git_origin", + bool(origin), + f"origin remote is {safe_origin}" if origin else "origin remote is missing or unavailable", + required=False, + ) + ) + + if token: + try: + bot = TelegramClient(token).get_me() + username = bot.get("username") or bot.get("first_name") or "bot" + checks.append(check("telegram_get_me", True, f"authenticated as {username}")) + except TelegramError as exc: + checks.append(check("telegram_get_me", False, str(exc))) + + ok = all(item["ok"] for item in checks if item["required"]) + if args.json_output: + print(json.dumps({"ok": ok, "checks": checks}, indent=2, sort_keys=True), file=stdout) + else: + for item in checks: + status = "ok" if item["ok"] else "fail" + required = "required" if item["required"] else "optional" + print(f"{status}\t{item['name']}\t{required}\t{item['detail']}", file=stdout) + return 0 if ok else 1 + + +def cmd_update(args: argparse.Namespace, *, stdout: TextIO, environ: dict[str, str]) -> int: + del environ + repo = Path(args.repo).expanduser().resolve() + if not (repo / ".git").exists(): + raise CliError(f"{repo} is not a git checkout") + + if args.check: + status = git_update_status(repo) + print(status, file=stdout) + return 0 + + ensure_clean_worktree(repo) + validate_checkout(repo) + print(git_update_status(repo), file=stdout) + pull_result = run_git(repo, "pull", "--ff-only") + if pull_result: + print(pull_result, file=stdout) + validate_checkout(repo) + print("validation ok", file=stdout) + for line in update_next_steps(repo): + print(line, file=stdout) + return 0 + + +def build_send_payload( + *, + chat_id: str, + text: str, + parse_mode: str | None, + silent: bool, + no_preview: bool, +) -> dict[str, Any]: + if not str(chat_id).strip(): + raise CliError("chat id is required") + validate_text(text, parse_mode=parse_mode) + payload: dict[str, Any] = {"chat_id": chat_id, "text": text} + if parse_mode: + payload["parse_mode"] = parse_mode + if silent: + payload["disable_notification"] = True + if no_preview: + payload["link_preview_options"] = {"is_disabled": True} + return payload + + +def normalize_text(parts: Iterable[str]) -> str: + text = " ".join(parts).strip() + validate_text(text, parse_mode=None, enforce_max=False) + return text + + +def validate_text(text: str, *, parse_mode: str | None = None, enforce_max: bool = True) -> None: + if not text: + raise CliError("message text is required") + length = telegram_text_length(text, parse_mode) + if enforce_max and length > MAX_TEXT_LENGTH: + raise CliError(f"message text is too long: {length} characters; maximum is {MAX_TEXT_LENGTH}") + + +def telegram_text_length(text: str, parse_mode: str | None) -> int: + if parse_mode == "HTML": + return len(html_visible_text(text)) + if parse_mode == "MarkdownV2": + return len(markdown_v2_visible_text(text)) + return len(text) + + +class _VisibleHTMLParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.parts: list[str] = [] + + def handle_data(self, data: str) -> None: + self.parts.append(data) + + +def html_visible_text(text: str) -> str: + parser = _VisibleHTMLParser() + parser.feed(text) + parser.close() + return "".join(parser.parts) + + +def markdown_v2_visible_text(text: str) -> str: + visible: list[str] = [] + i = 0 + formatting = set("_*[]()~`>#+-=|{}.!") + while i < len(text): + if text.startswith("```", i): + i += 3 + closing = text.find("```", i) + if closing == -1: + visible.append(text[i:]) + break + visible.append(text[i:closing]) + i = closing + 3 + continue + char = text[i] + if char == "[": + label_end = text.find("](", i + 1) + if label_end != -1: + destination_end = text.find(")", label_end + 2) + if destination_end != -1: + visible.append(markdown_v2_visible_text(text[i + 1 : label_end])) + i = destination_end + 1 + continue + if char == "`": + closing = text.find("`", i + 1) + if closing == -1: + i += 1 + continue + visible.append(text[i + 1 : closing]) + i = closing + 1 + continue + if char == "\\" and i + 1 < len(text): + visible.append(text[i + 1]) + i += 2 + continue + if char in formatting: + i += 1 + continue + visible.append(char) + i += 1 + return "".join(visible) + + +def require_env(environ: dict[str, str], name: str) -> str: + value = environ.get(name, "").strip() + if not value: + raise CliError(f"{name} is required") + return value + + +def extract_chat_candidates(updates: list[dict[str, Any]]) -> list[dict[str, str]]: + seen: set[str] = set() + candidates: list[dict[str, str]] = [] + for update in updates: + for key in ("message", "edited_message", "channel_post", "edited_channel_post", "business_message"): + message = update.get(key) + if not isinstance(message, dict): + continue + chat = message.get("chat") + if not isinstance(chat, dict) or "id" not in chat: + continue + chat_id = str(chat["id"]) + if chat_id in seen: + continue + seen.add(chat_id) + name = chat.get("title") or " ".join( + part for part in (chat.get("first_name"), chat.get("last_name")) if part + ) + candidates.append( + { + "id": chat_id, + "type": str(chat.get("type") or "unknown"), + "title": str(chat.get("title") or ""), + "username": str(chat.get("username") or ""), + "name": str(name or ""), + } + ) + return candidates + + +def check(name: str, ok: bool, detail: str, *, required: bool = True) -> dict[str, Any]: + return {"name": name, "ok": ok, "detail": detail, "required": required} + + +def git_update_status(repo: Path) -> str: + branch = run_git(repo, "rev-parse", "--abbrev-ref", "HEAD", allow_fail=True) or "unknown" + upstream = run_git(repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}", allow_fail=True) + if not upstream: + return f"{branch}: unknown update state; no upstream configured" + left_right = run_git(repo, "rev-list", "--left-right", "--count", f"{branch}...{upstream}", allow_fail=True) + if not left_right: + return f"{branch}: unknown update state relative to {upstream}" + try: + ahead, behind = [int(part) for part in left_right.split()] + except ValueError: + return f"{branch}: unknown update state relative to {upstream}" + if ahead == 0 and behind == 0: + return f"{branch}: up to date with local ref {upstream}" + return f"{branch}: ahead {ahead}, behind {behind} relative to local ref {upstream}" + + +def ensure_clean_worktree(repo: Path) -> None: + status = run_git(repo, "status", "--porcelain") + if status: + raise CliError("refusing to update because the git worktree has uncommitted changes") + + +def validate_checkout(repo: Path) -> None: + required_files = [ + repo / "bin" / "agentgram", + repo / ".codex-plugin" / "plugin.json", + repo / "skills" / PLUGIN_NAME / "SKILL.md", + repo / "src" / PYTHON_PACKAGE / "cli.py", + ] + missing = [str(path.relative_to(repo)) for path in required_files if not path.is_file()] + if missing: + raise CliError(f"checkout validation failed; missing {', '.join(missing)}") + + try: + manifest = json.loads((repo / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise CliError(f"checkout validation failed; plugin manifest is invalid JSON: {exc}") from exc + if manifest.get("name") != PLUGIN_NAME: + raise CliError(f"checkout validation failed; plugin manifest name is not {PLUGIN_NAME}") + if manifest.get("skills") != "./skills/": + raise CliError("checkout validation failed; plugin manifest skills path is not ./skills/") + + +def update_next_steps(repo: Path) -> list[str]: + steps = [ + "Next steps:", + f"- CLI users: keep using {repo / 'bin' / 'agentgram'} or refresh your PATH/symlink if needed.", + ] + codex_entry = detected_codex_agentgram_entry() + if codex_entry: + steps.extend( + [ + f"- Codex plugin detected: refresh with `codex plugin add {codex_entry}`.", + "- Start a new Codex thread after reinstall so updated skills are loaded.", + ] + ) + else: + steps.append("- Codex users: reinstall or refresh the Agentgram plugin from the marketplace where you added it.") + return steps + + +def detected_codex_agentgram_entry() -> str | None: + try: + proc = subprocess.run( + ["codex", "plugin", "list"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + except OSError: + return None + if proc.returncode != 0: + return None + for line in proc.stdout.splitlines(): + fields = line.split() + if not fields: + continue + entry = fields[0] + status = fields[1] if len(fields) > 1 else "" + if entry.startswith(f"{PLUGIN_NAME}@") and status.startswith("installed"): + return entry + return None + + +def git_origin_url(repo: Path) -> str: + return run_git(repo, "remote", "get-url", "origin", allow_fail=True) + + +def redact_url_userinfo(url: str) -> str: + try: + parsed = urlsplit(url) + except ValueError: + return url + if parsed.scheme and "@" in parsed.netloc: + host = parsed.netloc.rsplit("@", 1)[1] + return urlunsplit((parsed.scheme, host, parsed.path, parsed.query, parsed.fragment)) + return url + + +def run_git(repo: Path, *args: str, allow_fail: bool = False) -> str: + proc = subprocess.run( + ["git", *args], + cwd=repo, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if proc.returncode != 0: + if allow_fail: + return "" + raise CliError(proc.stderr.strip() or f"git {' '.join(args)} failed") + return proc.stdout.strip() + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[2] diff --git a/plugins/jerryfane/agentgram/src/agentgram_tg/telegram.py b/plugins/jerryfane/agentgram/src/agentgram_tg/telegram.py new file mode 100644 index 00000000..aace7ee5 --- /dev/null +++ b/plugins/jerryfane/agentgram/src/agentgram_tg/telegram.py @@ -0,0 +1,89 @@ +"""Small Telegram Bot API client built on the Python standard library.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import re +from typing import Any +from urllib import error, request + + +API_ROOT = "https://api.telegram.org" +TOKEN_RE = re.compile(r"^[0-9]+:[A-Za-z0-9_-]{20,}$") + + +class TelegramError(RuntimeError): + """Raised when Telegram returns an error response or cannot be reached.""" + + +@dataclass(frozen=True) +class TelegramClient: + token: str + api_root: str = API_ROOT + timeout: float = 15.0 + + def request(self, method: str, payload: dict[str, Any] | None = None) -> Any: + token = self.token.strip() + if not looks_like_token(token): + raise TelegramError("Telegram bot token shape is invalid") + payload = payload or {} + body = json.dumps(payload).encode("utf-8") + url = f"{self.api_root}/bot{token}/{method}" + req = request.Request( + url, + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with request.urlopen(req, timeout=self.timeout) as response: + raw = response.read().decode("utf-8") + except error.HTTPError as exc: + raw_error = exc.read().decode("utf-8", errors="replace") + raise TelegramError(redact_token(_telegram_error_message(raw_error), token)) from exc + except error.URLError as exc: + raise TelegramError(redact_token(f"Telegram request failed: {exc.reason}", token)) from exc + + try: + decoded = json.loads(raw) + except json.JSONDecodeError as exc: + raise TelegramError("Telegram returned invalid JSON") from exc + + if not decoded.get("ok"): + description = decoded.get("description") or "Telegram API request failed" + raise TelegramError(redact_token(str(description), token)) + return decoded.get("result") + + def get_me(self) -> dict[str, Any]: + return self.request("getMe", {}) + + def get_updates(self, limit: int = 20) -> list[dict[str, Any]]: + result = self.request("getUpdates", {"limit": limit, "timeout": 0}) + if not isinstance(result, list): + raise TelegramError("Telegram getUpdates returned an unexpected result") + return result + + def send_message(self, payload: dict[str, Any]) -> dict[str, Any]: + result = self.request("sendMessage", payload) + if not isinstance(result, dict): + raise TelegramError("Telegram sendMessage returned an unexpected result") + return result + + +def looks_like_token(value: str) -> bool: + return bool(TOKEN_RE.match(value.strip())) + + +def redact_token(message: str, token: str | None) -> str: + if not token: + return message + return message.replace(token, "") + + +def _telegram_error_message(raw: str) -> str: + try: + decoded = json.loads(raw) + except json.JSONDecodeError: + return raw or "Telegram API request failed" + return str(decoded.get("description") or decoded.get("error_code") or "Telegram API request failed")