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 +38 -0
- tgpost/__main__.py +12 -0
- tgpost/cli.py +482 -0
- tgpost/client.py +557 -0
- tgpost/config.py +249 -0
- tgpost/errors.py +82 -0
- tgpost/formatting.py +160 -0
- tgpost/limits.py +168 -0
- tgpost/py.typed +0 -0
- tgpost/scheduler.py +489 -0
- tgpost/store.py +198 -0
- tgpost-0.1.0.dist-info/METADATA +247 -0
- tgpost-0.1.0.dist-info/RECORD +16 -0
- tgpost-0.1.0.dist-info/WHEEL +4 -0
- tgpost-0.1.0.dist-info/entry_points.txt +2 -0
- tgpost-0.1.0.dist-info/licenses/LICENSE +21 -0
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())
|