tgpost 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tgpost/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """tgpost - Send messages and files to Telegram channels, now or on a schedule
2
+
3
+ Author: Pandiyaraj Karuppasamy
4
+ Date: Sep-05-2026
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ from .client import TelegramClient
10
+ from .errors import (
11
+ AuthError,
12
+ BadRequestError,
13
+ ConfigError,
14
+ FileTooLargeError,
15
+ ForbiddenError,
16
+ NetworkError,
17
+ RateLimitError,
18
+ SchedulerError,
19
+ ServerError,
20
+ TelegramError,
21
+ TgPostError,
22
+ )
23
+
24
+ __all__ = [
25
+ "TelegramClient",
26
+ "TgPostError",
27
+ "ConfigError",
28
+ "TelegramError",
29
+ "AuthError",
30
+ "BadRequestError",
31
+ "ForbiddenError",
32
+ "RateLimitError",
33
+ "ServerError",
34
+ "NetworkError",
35
+ "FileTooLargeError",
36
+ "SchedulerError",
37
+ "__version__",
38
+ ]
tgpost/__main__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Entry point so the package can be run as `python -m tgpost`.
2
+
3
+ Author: Pandiyaraj Karuppasamy
4
+ Date: Sep-05-2026
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .cli import main
10
+
11
+ if __name__ == "__main__": # pragma: no cover
12
+ raise SystemExit(main())
tgpost/cli.py ADDED
@@ -0,0 +1,482 @@
1
+ """Command line interface.
2
+
3
+ Author: Pandiyaraj Karuppasamy
4
+ Date: Sep-05-2026
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import logging
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from . import __version__
17
+ from .client import TelegramClient
18
+ from .config import ENV_TOKEN, Config, Target, config_path, db_path, load_config, save_targets
19
+ from .errors import TgPostError
20
+ from .formatting import PARSE_MODES
21
+
22
+ EXIT_OK = 0
23
+ EXIT_ERROR = 1
24
+ EXIT_USAGE = 2
25
+
26
+ DISCLAIMER = (
27
+ "Provided AS IS, without warranty of any kind. Use entirely at your own risk. "
28
+ "You are responsible for what you publish, for verifying the target channel, and "
29
+ "for being authorised to post there. A scheduled job posts unattended: a wrong "
30
+ "schedule can publish to a real audience repeatedly. See LICENSE, which governs."
31
+ )
32
+
33
+
34
+ def _make_output_utf8_safe() -> None:
35
+ """Stop a Unicode character in output from crashing a legacy Windows console.
36
+
37
+ Printing an arrow or a check mark to a cp1252 console raises
38
+ UnicodeEncodeError after the send has already succeeded, turning a completed
39
+ job into a non-zero exit. This reconfigures the streams where possible and
40
+ never raises.
41
+ """
42
+ for stream in (sys.stdout, sys.stderr):
43
+ reconfigure = getattr(stream, "reconfigure", None)
44
+ if reconfigure is None:
45
+ continue
46
+ try:
47
+ reconfigure(encoding="utf-8", errors="replace")
48
+ except (ValueError, OSError, LookupError):
49
+ try:
50
+ reconfigure(errors="replace")
51
+ except (ValueError, OSError, LookupError):
52
+ pass
53
+
54
+
55
+ def build_parser() -> argparse.ArgumentParser:
56
+ """Build the argument parser.
57
+
58
+ Returns:
59
+ The configured parser, with one subparser per command.
60
+ """
61
+ parser = argparse.ArgumentParser(
62
+ prog="tgpost",
63
+ description="Send messages and files to Telegram channels, now or on a schedule.",
64
+ epilog=DISCLAIMER,
65
+ formatter_class=argparse.RawDescriptionHelpFormatter,
66
+ )
67
+ parser.add_argument("--version", action="version", version=f"tgpost {__version__}")
68
+ parser.add_argument("--token", help=f"bot token; overrides {ENV_TOKEN} and the config file")
69
+ parser.add_argument("--config", type=Path, help="path to the config file")
70
+ parser.add_argument("--base-url", help="Bot API root, for a local Bot API server")
71
+ parser.add_argument("-v", "--verbose", action="store_true", help="log what is happening")
72
+ subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
73
+
74
+ def add_send_arguments(sub: argparse.ArgumentParser) -> None:
75
+ """Attach the arguments shared by send and schedule."""
76
+ target = sub.add_argument_group("target")
77
+ target.add_argument("--to", help="a named target from the config file")
78
+ target.add_argument("--chat-id", help="a @channelusername or numeric chat id")
79
+ content = sub.add_argument_group("content")
80
+ content.add_argument("--text", help="message text, or the caption when a file is sent")
81
+ content.add_argument("--stdin", action="store_true", help="read the text from standard input")
82
+ content.add_argument("--file", action="append", default=[], metavar="PATH", help="file to send; repeatable")
83
+ content.add_argument("--kind", default="document", help="document, photo, video, audio, animation or voice")
84
+ content.add_argument("--album", action="store_true", help="send several files as one album")
85
+ content.add_argument("--parse-mode", choices=PARSE_MODES, help="how to interpret markup; defaults to html")
86
+ content.add_argument("--escape", action="store_true", help="escape the text rather than treating it as markup")
87
+ content.add_argument("--silent", action="store_true", help="send without a notification")
88
+
89
+ send = subparsers.add_parser("send", help="send now", epilog=DISCLAIMER)
90
+ add_send_arguments(send)
91
+ send.add_argument("--dry-run", action="store_true", help="show what would be sent without sending")
92
+
93
+ schedule = subparsers.add_parser("schedule", help="send later, or repeatedly", epilog=DISCLAIMER)
94
+ add_send_arguments(schedule)
95
+ when = schedule.add_argument_group("when")
96
+ when.add_argument("--at", help="one-shot time: an ISO timestamp, or an offset such as 30m")
97
+ when.add_argument("--every", help="repeat interval, such as 15m, 2h or 1d")
98
+ when.add_argument("--cron", help="cron expression, such as '0 9 * * 1-5'")
99
+ when.add_argument("--tz", help="IANA time zone for --at and --cron, such as Europe/London")
100
+ schedule.add_argument("--name", help="a label for the job")
101
+
102
+ jobs = subparsers.add_parser("jobs", help="list scheduled jobs")
103
+ jobs.add_argument("--json", action="store_true", dest="as_json", help="output JSON")
104
+
105
+ cancel = subparsers.add_parser("cancel", help="cancel a scheduled job")
106
+ cancel.add_argument("job_id", help="the job id shown by tgpost jobs")
107
+
108
+ subparsers.add_parser("run-due", help="run every job that is due, then exit")
109
+ subparsers.add_parser("daemon", help="run jobs continuously until interrupted")
110
+
111
+ history = subparsers.add_parser("history", help="show recent job runs")
112
+ history.add_argument("--limit", type=int, default=20, help="how many runs to show")
113
+ history.add_argument("--job", help="restrict to one job id")
114
+
115
+ targets = subparsers.add_parser("targets", help="manage named channels")
116
+ target_commands = targets.add_subparsers(dest="targets_command", metavar="ACTION")
117
+ target_commands.add_parser("list", help="list configured targets")
118
+ add_target = target_commands.add_parser("add", help="add or update a target")
119
+ add_target.add_argument("name")
120
+ add_target.add_argument("chat_id", help="a @channelusername or numeric chat id")
121
+ add_target.add_argument("--description", default="", help="a note about this target")
122
+ add_target.add_argument("--parse-mode", choices=PARSE_MODES, help="parse mode for this target")
123
+ remove_target = target_commands.add_parser("remove", help="remove a target")
124
+ remove_target.add_argument("name")
125
+
126
+ subparsers.add_parser("check", help="verify the token and every target")
127
+
128
+ return parser
129
+
130
+
131
+ def main(argv: list[str] | None = None) -> int:
132
+ """Run the command line interface.
133
+
134
+ Args:
135
+ argv: Arguments to parse. Defaults to sys.argv.
136
+
137
+ Returns:
138
+ A process exit code: 0 on success, 1 on a handled error, 2 on misuse.
139
+ """
140
+ _make_output_utf8_safe()
141
+ parser = build_parser()
142
+ args = parser.parse_args(argv)
143
+
144
+ if not args.command:
145
+ parser.print_help()
146
+ return EXIT_USAGE
147
+
148
+ logging.basicConfig(
149
+ level=logging.INFO if args.verbose else logging.WARNING,
150
+ format="%(levelname)s %(message)s",
151
+ )
152
+
153
+ try:
154
+ config = load_config(args.config, token_override=args.token)
155
+ if args.base_url:
156
+ config.base_url = args.base_url.rstrip("/")
157
+
158
+ handlers = {
159
+ "send": _command_send,
160
+ "schedule": _command_schedule,
161
+ "jobs": _command_jobs,
162
+ "cancel": _command_cancel,
163
+ "run-due": _command_run_due,
164
+ "daemon": _command_daemon,
165
+ "history": _command_history,
166
+ "targets": _command_targets,
167
+ "check": _command_check,
168
+ }
169
+ return handlers[args.command](args, config)
170
+ except TgPostError as exc:
171
+ print(f"error: {exc}", file=sys.stderr)
172
+ return EXIT_ERROR
173
+ except FileNotFoundError as exc:
174
+ print(f"error: {exc}", file=sys.stderr)
175
+ return EXIT_ERROR
176
+ except ValueError as exc:
177
+ print(f"error: {exc}", file=sys.stderr)
178
+ return EXIT_USAGE
179
+ except KeyboardInterrupt:
180
+ print("interrupted", file=sys.stderr)
181
+ return EXIT_ERROR
182
+
183
+
184
+ def _resolve_chat(args: argparse.Namespace, config: Config) -> tuple[str, str | None]:
185
+ """Work out which chat to post to.
186
+
187
+ Args:
188
+ args: Parsed arguments carrying --to or --chat-id.
189
+ config: Loaded configuration holding named targets.
190
+
191
+ Returns:
192
+ The chat id and any parse mode the target overrides.
193
+
194
+ Raises:
195
+ ValueError: If neither or both of --to and --chat-id were given.
196
+ ConfigError: If a named target does not exist.
197
+ """
198
+ if args.chat_id and args.to:
199
+ raise ValueError("give either --to or --chat-id, not both")
200
+ if args.chat_id:
201
+ return args.chat_id, None
202
+ if args.to:
203
+ target = config.resolve_target(args.to)
204
+ return target.chat_id, target.parse_mode
205
+ raise ValueError("no target given; use --to NAME or --chat-id ID")
206
+
207
+
208
+ def _collect_text(args: argparse.Namespace) -> str | None:
209
+ """Return the message text from --text or standard input."""
210
+ if args.stdin:
211
+ return sys.stdin.read()
212
+ return args.text
213
+
214
+
215
+ def _command_send(args: argparse.Namespace, config: Config) -> int:
216
+ """Send a message or files immediately."""
217
+ chat_id, target_mode = _resolve_chat(args, config)
218
+ text = _collect_text(args)
219
+ files = [Path(p) for p in args.file]
220
+ parse_mode = args.parse_mode or target_mode or config.parse_mode
221
+
222
+ if not text and not files:
223
+ raise ValueError("nothing to send; give --text, --stdin or --file")
224
+
225
+ if args.dry_run:
226
+ print(f"would send to {chat_id}")
227
+ if text:
228
+ preview = text if len(text) <= 200 else text[:200] + "..."
229
+ print(f" text ({len(text)} chars, parse mode {parse_mode}): {preview}")
230
+ for path in files:
231
+ size = path.stat().st_size if path.is_file() else 0
232
+ marker = "" if path.is_file() else " [missing]"
233
+ print(f" file: {path} ({size} bytes){marker}")
234
+ return EXIT_OK
235
+
236
+ with TelegramClient(config.require_token(), base_url=config.base_url) as client:
237
+ if files and args.album and len(files) >= 2:
238
+ client.send_media_group(chat_id, files, kind=args.kind if args.kind != "document" else "photo",
239
+ caption=text, parse_mode=parse_mode, escape=args.escape,
240
+ disable_notification=args.silent)
241
+ print(f"sent album of {len(files)} files to {chat_id}")
242
+ elif files:
243
+ for index, path in enumerate(files):
244
+ client.send_file(chat_id, path, kind=args.kind,
245
+ caption=text if index == 0 else None,
246
+ parse_mode=parse_mode, escape=args.escape,
247
+ disable_notification=args.silent)
248
+ print(f"sent {path.name} to {chat_id}")
249
+ else:
250
+ # Reachable only with no files, and the guard above rejects the
251
+ # no-text-and-no-files case, so text is set here.
252
+ assert text is not None
253
+ parts = client.send_message(chat_id, text, parse_mode=parse_mode, escape=args.escape,
254
+ disable_notification=args.silent)
255
+ suffix = f" in {len(parts)} messages" if len(parts) > 1 else ""
256
+ print(f"sent to {chat_id}{suffix}")
257
+ return EXIT_OK
258
+
259
+
260
+ def _command_schedule(args: argparse.Namespace, config: Config) -> int:
261
+ """Create a scheduled job."""
262
+ from .scheduler import Scheduler, new_job_id, parse_interval, parse_when
263
+ from .store import Job
264
+
265
+ chosen = [flag for flag in (args.at, args.every, args.cron) if flag]
266
+ if len(chosen) != 1:
267
+ raise ValueError("give exactly one of --at, --every or --cron")
268
+
269
+ chat_id, target_mode = _resolve_chat(args, config)
270
+ text = _collect_text(args)
271
+ files = [str(Path(p).resolve()) for p in args.file]
272
+ if not text and not files:
273
+ raise ValueError("nothing to schedule; give --text, --stdin or --file")
274
+
275
+ for path in files:
276
+ if not Path(path).is_file():
277
+ raise FileNotFoundError(f"no such file: {path}. A scheduled job reads the file when it fires, so the path must persist.")
278
+
279
+ if args.at:
280
+ kind = "date"
281
+ spec: dict[str, Any] = {"run_at": parse_when(args.at, args.tz).isoformat()}
282
+ summary = f"once at {spec['run_at']}"
283
+ elif args.every:
284
+ kind = "interval"
285
+ spec = {"seconds": parse_interval(args.every).total_seconds()}
286
+ summary = f"every {args.every}"
287
+ else:
288
+ kind = "cron"
289
+ spec = {"expression": args.cron}
290
+ summary = f"cron {args.cron}"
291
+
292
+ job = Job(
293
+ id=new_job_id(),
294
+ name=args.name or (f"send to {chat_id}"),
295
+ chat_id=chat_id,
296
+ payload={
297
+ "text": text,
298
+ "files": files,
299
+ "kind": args.kind,
300
+ "album": args.album,
301
+ "parse_mode": args.parse_mode or target_mode or config.parse_mode,
302
+ "escape": args.escape,
303
+ "silent": args.silent,
304
+ },
305
+ trigger_kind=kind,
306
+ trigger_spec=spec,
307
+ timezone=args.tz,
308
+ )
309
+
310
+ with Scheduler(config) as scheduler:
311
+ scheduler.add(job)
312
+
313
+ print(f"scheduled job {job.id}: {summary}")
314
+ print("run 'tgpost daemon' to keep it running, or 'tgpost run-due' from Task Scheduler or cron.")
315
+ return EXIT_OK
316
+
317
+
318
+ def _command_jobs(args: argparse.Namespace, config: Config) -> int:
319
+ """List scheduled jobs."""
320
+ from .scheduler import Scheduler, describe_trigger
321
+
322
+ with Scheduler(config) as scheduler:
323
+ jobs = scheduler.list_jobs()
324
+ next_times = scheduler.next_run_times() if jobs else {}
325
+
326
+ if args.as_json:
327
+ rows = []
328
+ for job in jobs:
329
+ next_run = next_times.get(job.id)
330
+ rows.append({
331
+ "id": job.id,
332
+ "name": job.name,
333
+ "chat_id": job.chat_id,
334
+ "trigger": describe_trigger(job),
335
+ "next_run": next_run.isoformat() if next_run else None,
336
+ })
337
+ print(json.dumps(rows, indent=2))
338
+ return EXIT_OK
339
+
340
+ if not jobs:
341
+ print("no scheduled jobs. Create one with: tgpost schedule --to NAME --text ... --cron '0 9 * * 1-5'")
342
+ return EXIT_OK
343
+
344
+ print(f"{'ID':<10} {'NEXT RUN':<26} {'TRIGGER':<28} NAME")
345
+ for job in jobs:
346
+ next_run = next_times.get(job.id)
347
+ when = next_run.strftime("%Y-%m-%d %H:%M:%S %Z") if next_run else "not scheduled"
348
+ print(f"{job.id:<10} {when:<26} {describe_trigger(job):<28} {job.name}")
349
+ return EXIT_OK
350
+
351
+
352
+ def _command_cancel(args: argparse.Namespace, config: Config) -> int:
353
+ """Cancel a scheduled job."""
354
+ from .scheduler import Scheduler
355
+
356
+ with Scheduler(config) as scheduler:
357
+ if scheduler.cancel(args.job_id):
358
+ print(f"cancelled job {args.job_id}")
359
+ return EXIT_OK
360
+ print(f"no job with id {args.job_id}", file=sys.stderr)
361
+ return EXIT_ERROR
362
+
363
+
364
+ def _command_run_due(args: argparse.Namespace, config: Config) -> int:
365
+ """Run every due job, then exit."""
366
+ from .scheduler import Scheduler
367
+
368
+ with Scheduler(config) as scheduler:
369
+ fired = scheduler.run_due()
370
+ print(f"ran {fired} job{'s' if fired != 1 else ''}")
371
+ return EXIT_OK
372
+
373
+
374
+ def _command_daemon(args: argparse.Namespace, config: Config) -> int:
375
+ """Run jobs continuously until interrupted."""
376
+ from .scheduler import Scheduler
377
+
378
+ with Scheduler(config) as scheduler:
379
+ print(f"running scheduled jobs from {scheduler.database}. Press Ctrl+C to stop.")
380
+ scheduler.run_daemon()
381
+ return EXIT_OK
382
+
383
+
384
+ def _command_history(args: argparse.Namespace, config: Config) -> int:
385
+ """Show recent job runs."""
386
+ from .store import JobStore
387
+
388
+ with JobStore(db_path()) as store:
389
+ runs = store.list_history(limit=args.limit, job_id=args.job)
390
+
391
+ if not runs:
392
+ print("no runs recorded yet")
393
+ return EXIT_OK
394
+
395
+ print(f"{'FIRED AT':<28} {'JOB':<10} {'OUTCOME':<9} DETAIL")
396
+ for run in runs:
397
+ print(f"{run['fired_at']:<28} {run['job_id']:<10} {run['outcome']:<9} {run['detail'] or ''}")
398
+ return EXIT_OK
399
+
400
+
401
+ def _command_targets(args: argparse.Namespace, config: Config) -> int:
402
+ """List, add or remove named targets."""
403
+ action = args.targets_command or "list"
404
+
405
+ if action == "list":
406
+ if not config.targets:
407
+ print(f"no targets configured in {config.path or config_path()}")
408
+ print("add one with: tgpost targets add release-notes -1001234567890")
409
+ return EXIT_OK
410
+ print(f"{'NAME':<20} {'CHAT ID':<22} DESCRIPTION")
411
+ for name in sorted(config.targets):
412
+ target = config.targets[name]
413
+ print(f"{name:<20} {target.chat_id:<22} {target.description}")
414
+ return EXIT_OK
415
+
416
+ if action == "add":
417
+ config.targets[args.name] = Target(
418
+ name=args.name,
419
+ chat_id=args.chat_id,
420
+ parse_mode=args.parse_mode,
421
+ description=args.description,
422
+ )
423
+ written = save_targets(config, args.config)
424
+ print(f"added target {args.name} -> {args.chat_id} in {written}")
425
+ return EXIT_OK
426
+
427
+ if action == "remove":
428
+ if args.name not in config.targets:
429
+ print(f"no target named {args.name}", file=sys.stderr)
430
+ return EXIT_ERROR
431
+ del config.targets[args.name]
432
+ written = save_targets(config, args.config)
433
+ print(f"removed target {args.name} from {written}")
434
+ return EXIT_OK
435
+
436
+ raise ValueError(f"unknown targets action {action!r}")
437
+
438
+
439
+ def _command_check(args: argparse.Namespace, config: Config) -> int:
440
+ """Verify the token and every configured target.
441
+
442
+ The two failures that waste the most time are a bot that is not a channel
443
+ administrator and one lacking can_post_messages, so both are reported
444
+ explicitly rather than surfacing later as a 403 on a real send.
445
+ """
446
+ failures = 0
447
+ with TelegramClient(config.require_token(), base_url=config.base_url) as client:
448
+ me = client.get_me()
449
+ bot_id = me["id"]
450
+ print(f"token OK: @{me.get('username')} (id {bot_id})")
451
+
452
+ if not config.targets:
453
+ print("no targets configured; nothing else to check")
454
+ return EXIT_OK
455
+
456
+ for name in sorted(config.targets):
457
+ target = config.targets[name]
458
+ try:
459
+ chat = client.get_chat(target.chat_id)
460
+ member = client.get_chat_member(target.chat_id, bot_id)
461
+ status = member.get("status")
462
+ title = chat.get("title") or chat.get("username") or target.chat_id
463
+ if chat.get("type") == "channel" and not member.get("can_post_messages"):
464
+ print(f" {name}: FAIL - bot is {status} in {title} but cannot post messages")
465
+ failures += 1
466
+ elif status not in ("administrator", "creator", "member"):
467
+ print(f" {name}: FAIL - bot is {status} in {title}")
468
+ failures += 1
469
+ else:
470
+ print(f" {name}: OK - can post to {title} (id {chat.get('id')})")
471
+ except TgPostError as exc:
472
+ print(f" {name}: FAIL - {exc}")
473
+ failures += 1
474
+
475
+ if failures:
476
+ print(f"\n{failures} target(s) not ready. Add the bot as an administrator with permission to post.", file=sys.stderr)
477
+ return EXIT_ERROR
478
+ return EXIT_OK
479
+
480
+
481
+ if __name__ == "__main__": # pragma: no cover
482
+ raise SystemExit(main())