kctl-telegram 0.6.3__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.
- kctl_telegram/__init__.py +5 -0
- kctl_telegram/__main__.py +7 -0
- kctl_telegram/cli.py +131 -0
- kctl_telegram/commands/__init__.py +1 -0
- kctl_telegram/commands/bots.py +175 -0
- kctl_telegram/commands/chatwoot.py +105 -0
- kctl_telegram/commands/config_cmd.py +512 -0
- kctl_telegram/commands/dashboard.py +133 -0
- kctl_telegram/commands/doctor_cmd.py +57 -0
- kctl_telegram/commands/groups.py +125 -0
- kctl_telegram/commands/health.py +177 -0
- kctl_telegram/commands/messages.py +161 -0
- kctl_telegram/commands/skill_cmd.py +76 -0
- kctl_telegram/core/__init__.py +1 -0
- kctl_telegram/core/callbacks.py +33 -0
- kctl_telegram/core/client.py +76 -0
- kctl_telegram/core/config.py +119 -0
- kctl_telegram/core/exceptions.py +30 -0
- kctl_telegram/core/output.py +10 -0
- kctl_telegram-0.6.3.dist-info/METADATA +17 -0
- kctl_telegram-0.6.3.dist-info/RECORD +23 -0
- kctl_telegram-0.6.3.dist-info/WHEEL +4 -0
- kctl_telegram-0.6.3.dist-info/entry_points.txt +2 -0
kctl_telegram/cli.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Main CLI entry point for kctl-telegram."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from kctl_telegram import __version__
|
|
11
|
+
from kctl_telegram.commands.bots import app as bots_app
|
|
12
|
+
from kctl_telegram.commands.chatwoot import app as chatwoot_app
|
|
13
|
+
from kctl_telegram.commands.config_cmd import app as config_app
|
|
14
|
+
from kctl_telegram.commands.dashboard import app as dashboard_app
|
|
15
|
+
from kctl_telegram.commands.groups import app as groups_app
|
|
16
|
+
from kctl_telegram.commands.health import app as health_app
|
|
17
|
+
from kctl_telegram.commands.messages import app as messages_app
|
|
18
|
+
from kctl_telegram.core.callbacks import AppContext
|
|
19
|
+
from kctl_telegram.core.exceptions import KctlError
|
|
20
|
+
from kctl_telegram.commands.skill_cmd import app as skill_app
|
|
21
|
+
from kctl_telegram.commands.doctor_cmd import app as doctor_app
|
|
22
|
+
from kctl_lib.self_update import notify_if_outdated
|
|
23
|
+
from kctl_lib.tui import add_tui_command
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def version_callback(value: bool) -> None:
|
|
27
|
+
if value:
|
|
28
|
+
typer.echo(f"kctl-telegram {__version__}")
|
|
29
|
+
raise typer.Exit()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
name="kctl-telegram",
|
|
34
|
+
help="Kodemeio Telegram CLI - manage your Telegram bot gateway.",
|
|
35
|
+
no_args_is_help=True,
|
|
36
|
+
rich_markup_mode="rich",
|
|
37
|
+
pretty_exceptions_enable=False,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.callback()
|
|
42
|
+
def main(
|
|
43
|
+
ctx: typer.Context,
|
|
44
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
45
|
+
quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress info messages")] = False,
|
|
46
|
+
format_: Annotated[str, typer.Option("--format", "-f", help="Output format: pretty/json/csv/yaml")] = "pretty",
|
|
47
|
+
no_header: Annotated[bool, typer.Option("--no-header", help="Suppress table headers (csv)")] = False,
|
|
48
|
+
profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile name")] = None,
|
|
49
|
+
url: Annotated[str | None, typer.Option("--url", help="API URL override")] = None,
|
|
50
|
+
api_key: Annotated[str | None, typer.Option("--api-key", help="API key override")] = None,
|
|
51
|
+
version: Annotated[
|
|
52
|
+
bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version")
|
|
53
|
+
] = False,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Kodemeio Telegram CLI."""
|
|
56
|
+
ctx.ensure_object(dict)
|
|
57
|
+
ctx.obj = AppContext(
|
|
58
|
+
json_mode=json_output,
|
|
59
|
+
quiet=quiet,
|
|
60
|
+
format=format_,
|
|
61
|
+
no_header=no_header,
|
|
62
|
+
profile=profile,
|
|
63
|
+
url_override=url,
|
|
64
|
+
api_key_override=api_key,
|
|
65
|
+
)
|
|
66
|
+
notify_if_outdated(ctx.obj.output, "kctl-telegram", __version__)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Register all command groups
|
|
70
|
+
app.add_typer(config_app, name="config")
|
|
71
|
+
app.add_typer(health_app, name="health")
|
|
72
|
+
app.add_typer(dashboard_app, name="dashboard")
|
|
73
|
+
app.add_typer(bots_app, name="bots")
|
|
74
|
+
app.add_typer(groups_app, name="groups")
|
|
75
|
+
app.add_typer(messages_app, name="messages")
|
|
76
|
+
app.add_typer(chatwoot_app, name="chatwoot")
|
|
77
|
+
app.add_typer(skill_app, name="skill", hidden=True)
|
|
78
|
+
app.add_typer(doctor_app, name="doctor")
|
|
79
|
+
add_tui_command(app, service_key="telegram", version=__version__)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command("self-update")
|
|
83
|
+
def self_update_cmd(ctx: typer.Context) -> None:
|
|
84
|
+
"""Check for updates and upgrade kctl-telegram."""
|
|
85
|
+
actx = ctx.obj
|
|
86
|
+
out = actx.output
|
|
87
|
+
|
|
88
|
+
from kctl_lib.self_update import check_update
|
|
89
|
+
from kctl_lib.self_update import update as do_update
|
|
90
|
+
|
|
91
|
+
latest = check_update("kctl-telegram", __version__)
|
|
92
|
+
if latest:
|
|
93
|
+
out.info(f"Updating to {latest}...")
|
|
94
|
+
do_update("kctl-telegram")
|
|
95
|
+
out.success(f"Updated to {latest}")
|
|
96
|
+
else:
|
|
97
|
+
out.success("Already up to date")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command()
|
|
101
|
+
def completions(
|
|
102
|
+
shell: Annotated[str, typer.Argument(help="Shell type: zsh, bash, fish")] = "zsh",
|
|
103
|
+
install: Annotated[bool, typer.Option("--install", help="Install completions")] = False,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Generate or install shell completions."""
|
|
106
|
+
from kctl_lib.completions import get_completion_script, install_completions
|
|
107
|
+
|
|
108
|
+
if install:
|
|
109
|
+
path = install_completions("kctl-telegram", shell)
|
|
110
|
+
if path:
|
|
111
|
+
typer.echo(f"Completions installed to {path}")
|
|
112
|
+
else:
|
|
113
|
+
typer.echo(f"Could not install completions for {shell}", err=True)
|
|
114
|
+
raise typer.Exit(code=1)
|
|
115
|
+
else:
|
|
116
|
+
script = get_completion_script("kctl-telegram", shell)
|
|
117
|
+
typer.echo(script)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _run() -> None:
|
|
121
|
+
"""Entry point with error handling."""
|
|
122
|
+
try:
|
|
123
|
+
app()
|
|
124
|
+
except KctlError as e:
|
|
125
|
+
from kctl_lib import handle_cli_error
|
|
126
|
+
|
|
127
|
+
handle_cli_error(e)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
_run()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command modules for kctl-telegram."""
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Bot management commands.
|
|
2
|
+
|
|
3
|
+
List, inspect, create, update, and remove Telegram bots.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from kctl_telegram.core.callbacks import AppContext
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Manage Telegram bots.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command("list")
|
|
18
|
+
def list_(ctx: typer.Context) -> None:
|
|
19
|
+
"""List all registered bots."""
|
|
20
|
+
actx: AppContext = ctx.obj
|
|
21
|
+
out = actx.output
|
|
22
|
+
c = actx.client
|
|
23
|
+
|
|
24
|
+
data = c.get("bots")
|
|
25
|
+
bots = data.get("results", []) if isinstance(data, dict) else data if isinstance(data, list) else []
|
|
26
|
+
|
|
27
|
+
if not bots:
|
|
28
|
+
out.warn("No bots found.")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
rows: list[list[str]] = []
|
|
32
|
+
json_data: list[dict] = []
|
|
33
|
+
|
|
34
|
+
for bot in bots:
|
|
35
|
+
bot_id = str(bot.get("id", ""))
|
|
36
|
+
username = bot.get("username", "")
|
|
37
|
+
display_name = bot.get("display_name", "")
|
|
38
|
+
is_active = bot.get("is_active", False)
|
|
39
|
+
status = "[green]active[/green]" if is_active else "[red]inactive[/red]"
|
|
40
|
+
|
|
41
|
+
rows.append([bot_id, f"@{username}" if username else "-", display_name, status])
|
|
42
|
+
json_data.append(bot)
|
|
43
|
+
|
|
44
|
+
out.table(
|
|
45
|
+
"Bots",
|
|
46
|
+
[("ID", "cyan"), ("Username", ""), ("Display Name", ""), ("Status", "")],
|
|
47
|
+
rows,
|
|
48
|
+
data_for_json=json_data,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command()
|
|
53
|
+
def get(
|
|
54
|
+
ctx: typer.Context,
|
|
55
|
+
bot_id: Annotated[int, typer.Argument(help="Bot ID")],
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Show bot details."""
|
|
58
|
+
actx: AppContext = ctx.obj
|
|
59
|
+
out = actx.output
|
|
60
|
+
c = actx.client
|
|
61
|
+
|
|
62
|
+
bot = c.get(f"bots/{bot_id}")
|
|
63
|
+
|
|
64
|
+
sections: list[tuple[str, list[tuple[str, str]]]] = []
|
|
65
|
+
|
|
66
|
+
info_kvs: list[tuple[str, str]] = [
|
|
67
|
+
("ID", str(bot.get("id", ""))),
|
|
68
|
+
("Username", f"@{bot.get('username', '')}" if bot.get("username") else "-"),
|
|
69
|
+
("Display Name", bot.get("display_name", "")),
|
|
70
|
+
("Is Active", "[green]Yes[/green]" if bot.get("is_active") else "[red]No[/red]"),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
# Show token masked
|
|
74
|
+
token = bot.get("token", "")
|
|
75
|
+
if token:
|
|
76
|
+
masked = f"{token[:4]}{'*' * (len(token) - 8)}{token[-4:]}" if len(token) > 10 else "****"
|
|
77
|
+
info_kvs.append(("Token", masked))
|
|
78
|
+
|
|
79
|
+
sections.append(("Bot Info", info_kvs))
|
|
80
|
+
|
|
81
|
+
# Timestamps
|
|
82
|
+
ts_kvs: list[tuple[str, str]] = []
|
|
83
|
+
if bot.get("created_at"):
|
|
84
|
+
ts_kvs.append(("Created", bot["created_at"]))
|
|
85
|
+
if bot.get("updated_at"):
|
|
86
|
+
ts_kvs.append(("Updated", bot["updated_at"]))
|
|
87
|
+
if ts_kvs:
|
|
88
|
+
sections.append(("Timestamps", ts_kvs))
|
|
89
|
+
|
|
90
|
+
out.detail(f"Bot: {bot.get('display_name', bot.get('username', bot_id))}", sections, data_for_json=bot)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command()
|
|
94
|
+
def add(
|
|
95
|
+
ctx: typer.Context,
|
|
96
|
+
token: Annotated[str, typer.Option("--token", help="Telegram bot token from BotFather.")],
|
|
97
|
+
display_name: Annotated[str | None, typer.Option("--display-name", help="Friendly display name.")] = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Register a new Telegram bot."""
|
|
100
|
+
actx: AppContext = ctx.obj
|
|
101
|
+
out = actx.output
|
|
102
|
+
c = actx.client
|
|
103
|
+
|
|
104
|
+
payload: dict = {"token": token}
|
|
105
|
+
if display_name:
|
|
106
|
+
payload["display_name"] = display_name
|
|
107
|
+
|
|
108
|
+
bot = c.post("bots", json=payload)
|
|
109
|
+
|
|
110
|
+
out.success(f"Bot created: {bot.get('display_name', bot.get('username', ''))}")
|
|
111
|
+
out.kv("ID", str(bot.get("id", "")))
|
|
112
|
+
out.kv("Username", f"@{bot.get('username', '')}" if bot.get("username") else "-")
|
|
113
|
+
out.kv("Display Name", bot.get("display_name", ""))
|
|
114
|
+
out.kv("Active", str(bot.get("is_active", True)))
|
|
115
|
+
|
|
116
|
+
if actx.output.json_mode:
|
|
117
|
+
out.raw_json(bot)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@app.command()
|
|
121
|
+
def update(
|
|
122
|
+
ctx: typer.Context,
|
|
123
|
+
bot_id: Annotated[int, typer.Argument(help="Bot ID")],
|
|
124
|
+
display_name: Annotated[str | None, typer.Option("--display-name", help="New display name.")] = None,
|
|
125
|
+
is_active: Annotated[bool | None, typer.Option("--is-active/--no-active", help="Enable or disable bot.")] = None,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Update a bot's settings."""
|
|
128
|
+
actx: AppContext = ctx.obj
|
|
129
|
+
out = actx.output
|
|
130
|
+
c = actx.client
|
|
131
|
+
|
|
132
|
+
payload: dict = {}
|
|
133
|
+
if display_name is not None:
|
|
134
|
+
payload["display_name"] = display_name
|
|
135
|
+
if is_active is not None:
|
|
136
|
+
payload["is_active"] = is_active
|
|
137
|
+
|
|
138
|
+
if not payload:
|
|
139
|
+
out.warn("No changes specified. Use --display-name or --is-active/--no-active.")
|
|
140
|
+
raise typer.Exit(1)
|
|
141
|
+
|
|
142
|
+
bot = c.patch(f"bots/{bot_id}", json=payload)
|
|
143
|
+
|
|
144
|
+
out.success(f"Bot {bot_id} updated")
|
|
145
|
+
out.kv("Display Name", bot.get("display_name", ""))
|
|
146
|
+
out.kv("Active", str(bot.get("is_active", "")))
|
|
147
|
+
|
|
148
|
+
if actx.output.json_mode:
|
|
149
|
+
out.raw_json(bot)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@app.command()
|
|
153
|
+
def remove(
|
|
154
|
+
ctx: typer.Context,
|
|
155
|
+
bot_id: Annotated[int, typer.Argument(help="Bot ID")],
|
|
156
|
+
force: Annotated[bool, typer.Option("--force", help="Skip confirmation")] = False,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Remove a bot."""
|
|
159
|
+
actx: AppContext = ctx.obj
|
|
160
|
+
out = actx.output
|
|
161
|
+
c = actx.client
|
|
162
|
+
|
|
163
|
+
if not force:
|
|
164
|
+
# Fetch bot info for confirmation
|
|
165
|
+
try:
|
|
166
|
+
bot = c.get(f"bots/{bot_id}")
|
|
167
|
+
name = bot.get("display_name", bot.get("username", str(bot_id)))
|
|
168
|
+
except Exception:
|
|
169
|
+
name = str(bot_id)
|
|
170
|
+
|
|
171
|
+
if not typer.confirm(f"Remove bot '{name}' (ID: {bot_id})?"):
|
|
172
|
+
raise typer.Exit(0)
|
|
173
|
+
|
|
174
|
+
c.delete(f"bots/{bot_id}")
|
|
175
|
+
out.success(f"Bot {bot_id} removed")
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Chatwoot integration commands.
|
|
2
|
+
|
|
3
|
+
Manage Chatwoot inbox connections for Telegram bots.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from kctl_telegram.core.callbacks import AppContext
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Manage Chatwoot integrations.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command("list")
|
|
18
|
+
def list_(ctx: typer.Context) -> None:
|
|
19
|
+
"""List Chatwoot inboxes linked to Telegram bots."""
|
|
20
|
+
actx: AppContext = ctx.obj
|
|
21
|
+
out = actx.output
|
|
22
|
+
c = actx.client
|
|
23
|
+
|
|
24
|
+
data = c.get("chatwoot/inboxes")
|
|
25
|
+
inboxes = data.get("results", []) if isinstance(data, dict) else data if isinstance(data, list) else []
|
|
26
|
+
|
|
27
|
+
if not inboxes:
|
|
28
|
+
out.warn("No Chatwoot inboxes found.")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
rows: list[list[str]] = []
|
|
32
|
+
json_data: list[dict] = []
|
|
33
|
+
|
|
34
|
+
for inbox in inboxes:
|
|
35
|
+
inbox_id = str(inbox.get("id", ""))
|
|
36
|
+
bot_id = str(inbox.get("bot_id", ""))
|
|
37
|
+
identifier = inbox.get("inbox_identifier", "")
|
|
38
|
+
base_url = inbox.get("base_url", "")
|
|
39
|
+
|
|
40
|
+
rows.append([inbox_id, bot_id, identifier, base_url])
|
|
41
|
+
json_data.append(inbox)
|
|
42
|
+
|
|
43
|
+
out.table(
|
|
44
|
+
"Chatwoot Inboxes",
|
|
45
|
+
[("ID", "cyan"), ("Bot ID", ""), ("Inbox Identifier", ""), ("Chatwoot URL", "")],
|
|
46
|
+
rows,
|
|
47
|
+
data_for_json=json_data,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command()
|
|
52
|
+
def add(
|
|
53
|
+
ctx: typer.Context,
|
|
54
|
+
bot_id: Annotated[int, typer.Option("--bot-id", help="Telegram bot ID.")],
|
|
55
|
+
inbox_identifier: Annotated[str, typer.Option("--inbox-identifier", help="Chatwoot inbox identifier.")],
|
|
56
|
+
base_url: Annotated[str, typer.Option("--base-url", help="Chatwoot instance base URL.")],
|
|
57
|
+
api_token: Annotated[str, typer.Option("--api-token", help="Chatwoot API token.")],
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Link a Chatwoot inbox to a Telegram bot."""
|
|
60
|
+
actx: AppContext = ctx.obj
|
|
61
|
+
out = actx.output
|
|
62
|
+
c = actx.client
|
|
63
|
+
|
|
64
|
+
payload: dict = {
|
|
65
|
+
"bot_id": bot_id,
|
|
66
|
+
"inbox_identifier": inbox_identifier,
|
|
67
|
+
"base_url": base_url,
|
|
68
|
+
"api_token": api_token,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
result = c.post("chatwoot/inboxes", json=payload)
|
|
72
|
+
|
|
73
|
+
out.success("Chatwoot inbox linked")
|
|
74
|
+
out.kv("ID", str(result.get("id", "")))
|
|
75
|
+
out.kv("Bot ID", str(bot_id))
|
|
76
|
+
out.kv("Inbox", inbox_identifier)
|
|
77
|
+
out.kv("Chatwoot URL", base_url)
|
|
78
|
+
|
|
79
|
+
if actx.output.json_mode:
|
|
80
|
+
out.raw_json(result)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.command()
|
|
84
|
+
def remove(
|
|
85
|
+
ctx: typer.Context,
|
|
86
|
+
inbox_id: Annotated[int, typer.Argument(help="Chatwoot inbox ID to remove")],
|
|
87
|
+
force: Annotated[bool, typer.Option("--force", help="Skip confirmation")] = False,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Remove a Chatwoot inbox integration."""
|
|
90
|
+
actx: AppContext = ctx.obj
|
|
91
|
+
out = actx.output
|
|
92
|
+
c = actx.client
|
|
93
|
+
|
|
94
|
+
if not force:
|
|
95
|
+
try:
|
|
96
|
+
inbox = c.get(f"chatwoot/inboxes/{inbox_id}")
|
|
97
|
+
identifier = inbox.get("inbox_identifier", str(inbox_id))
|
|
98
|
+
except Exception:
|
|
99
|
+
identifier = str(inbox_id)
|
|
100
|
+
|
|
101
|
+
if not typer.confirm(f"Remove Chatwoot inbox '{identifier}' (ID: {inbox_id})?"):
|
|
102
|
+
raise typer.Exit(0)
|
|
103
|
+
|
|
104
|
+
c.delete(f"chatwoot/inboxes/{inbox_id}")
|
|
105
|
+
out.success(f"Chatwoot inbox {inbox_id} removed")
|