@craftsamo/telegram-tools 0.2.0 → 0.3.0
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.
- package/.antigravity-plugin/plugin.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -1
- package/README.md +69 -22
- package/dist/cli.js +524 -463
- package/dist/mcp.js +307 -217
- package/dist/opencode.js +335 -250
- package/package.json +1 -1
- package/plugin.yaml +7 -4
- package/src/hermes/telegram_tools_hermes/approval.py +18 -14
- package/src/hermes/telegram_tools_hermes/plugin.py +3 -3
- package/src/hermes/telegram_tools_hermes/project.py +6 -0
- package/src/hermes/telegram_tools_hermes/settings.py +61 -63
- package/src/hermes/telegram_tools_hermes/tools.py +10 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@craftsamo/telegram-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Unofficial Telegram tools for coding agents: chats, messages, received files and sends.",
|
|
5
5
|
"author": "craftsamo",
|
|
6
6
|
"license": "LicenseRef-AGPL-3.0-Commons-Clause",
|
package/plugin.yaml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
name: telegram-tools
|
|
2
|
-
version: 0.
|
|
2
|
+
version: 0.3.0
|
|
3
3
|
description: "Unofficial Telegram tools for coding agents: chats, messages, received files and sends."
|
|
4
4
|
manifest_version: 1
|
|
5
5
|
requires_hermes: ">=0.22"
|
|
@@ -32,15 +32,15 @@ config_schema:
|
|
|
32
32
|
type: list
|
|
33
33
|
description: Single tools to remove by id. Wins over tools and toolsets.
|
|
34
34
|
state_dir:
|
|
35
|
-
description: "Where every label's state lives; an absolute path, or one starting with ~. Defaults to $XDG_STATE_HOME/telegram-tools,
|
|
35
|
+
description: "Where every label's state lives; an absolute path, or one starting with ~. Blank means unset. Defaults to $XDG_STATE_HOME/telegram-tools when XDG_STATE_HOME is absolute, else ~/.local/state/telegram-tools."
|
|
36
36
|
download_dir:
|
|
37
|
-
description: "Where received files are saved; an absolute path, or one starting with ~. Defaults to ~/Downloads/telegram-tools. Read by tools; no service restart needed."
|
|
37
|
+
description: "Where received files are saved; an absolute path, or one starting with ~. Blank means unset. Defaults to ~/Downloads/telegram-tools. Read by tools; no service restart needed."
|
|
38
38
|
download_max_mb:
|
|
39
39
|
type: int
|
|
40
40
|
description: "The largest file a download takes, in MB; an integer from 1 to 2000, default 100. Read by tools; no service restart needed."
|
|
41
41
|
send_dirs:
|
|
42
42
|
type: list
|
|
43
|
-
description: "The directories files may be sent from, besides the working directory; absolute paths, or ones starting with ~, without commas. Defaults to none. Read by tools; no service restart needed."
|
|
43
|
+
description: "The directories files may be sent from, besides the working directory; absolute paths, or ones starting with ~, without commas. Blank items are dropped. Defaults to none. Read by tools; no service restart needed."
|
|
44
44
|
keep_disappearing:
|
|
45
45
|
type: bool
|
|
46
46
|
description: Keeps disappearing and view-once messages for the user; off by default. Read by the sync service.
|
|
@@ -49,3 +49,6 @@ config_schema:
|
|
|
49
49
|
description: Lets the mirror list channels; off by default. Read by the sync service.
|
|
50
50
|
language:
|
|
51
51
|
description: The language of the approval card, such as ja. Defaults to Hermes's display language, else English.
|
|
52
|
+
emoji:
|
|
53
|
+
type: dict
|
|
54
|
+
description: 'The emoji shown beside each toolset, by toolset name; "" shows none. Merged over the package''s emoji.'
|
|
@@ -155,15 +155,15 @@ def resolve_target(
|
|
|
155
155
|
runtime: Runtime,
|
|
156
156
|
tool_id: str,
|
|
157
157
|
args: Any,
|
|
158
|
-
flags: list[str] | None = None,
|
|
159
158
|
language: str | None = None,
|
|
160
159
|
harness_language: Callable[[], str | None] = lambda: display_language(),
|
|
160
|
+
option_flags: list[str] | None = None,
|
|
161
161
|
) -> Target:
|
|
162
162
|
"""What ``target`` prints: ``["*"]`` for resources that cannot be told, no description for one absent or bad.
|
|
163
163
|
|
|
164
|
-
``flags`` are the same settings flags ``run`` gets, so ``target`` resolves the call as ``run`` will.
|
|
165
164
|
``language`` is the plugin's ``language`` setting and ``harness_language`` reads Hermes' display language
|
|
166
|
-
per call; neither may fail the run, and ``target`` picks among them.
|
|
165
|
+
per call; neither may fail the run, and ``target`` picks among them. ``option_flags`` are the package's
|
|
166
|
+
own tool options as ``run`` gets them, so ``target`` resolves the call as ``run`` will.
|
|
167
167
|
"""
|
|
168
168
|
try:
|
|
169
169
|
result = runtime.run(
|
|
@@ -172,8 +172,8 @@ def resolve_target(
|
|
|
172
172
|
tool_id,
|
|
173
173
|
"--cwd",
|
|
174
174
|
agent_cwd(),
|
|
175
|
-
*(flags or []),
|
|
176
175
|
*language_flags(language, _known(harness_language)),
|
|
176
|
+
*(option_flags or []),
|
|
177
177
|
],
|
|
178
178
|
stdin=json.dumps(run_arguments(args)),
|
|
179
179
|
timeout=TARGET_TIMEOUT_SECONDS,
|
|
@@ -222,20 +222,21 @@ def _message(tool: ToolInfo, target: Target, surface: Callable[[], str | None])
|
|
|
222
222
|
def make_hook(
|
|
223
223
|
runtime: Runtime,
|
|
224
224
|
tools: list[ToolInfo],
|
|
225
|
-
flags: list[str] | None = None,
|
|
226
225
|
surface: Callable[[], str | None] = lambda: None,
|
|
227
226
|
language: str | None = None,
|
|
228
227
|
harness_language: Callable[[], str | None] = lambda: display_language(),
|
|
229
228
|
pins: PinStash | None = None,
|
|
229
|
+
option_flags: list[str] | None = None,
|
|
230
230
|
) -> Callable[..., dict[str, str] | None]:
|
|
231
231
|
"""The ``pre_tool_call`` hook: it sends every call of these tools but a read to the person's approval.
|
|
232
232
|
|
|
233
233
|
Whether a person is there to ask, and what happens when none is, is
|
|
234
|
-
Hermes' own gate to decide. ``
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
When ``pins`` is given, the pin ``target``
|
|
238
|
-
call's ``pin_key`` for the pin hook; the
|
|
234
|
+
Hermes' own gate to decide. ``language`` is the plugin's ``language``
|
|
235
|
+
setting; ``harness_language`` is read on every call. ``option_flags`` are
|
|
236
|
+
the package's own tool options as ``run`` gets them, so ``target`` resolves
|
|
237
|
+
the call as ``run`` will. When ``pins`` is given, the pin ``target``
|
|
238
|
+
answered is kept in it under the call's ``pin_key`` for the pin hook; the
|
|
239
|
+
decision is the same either way.
|
|
239
240
|
"""
|
|
240
241
|
by_id = {tool.id: tool for tool in tools}
|
|
241
242
|
|
|
@@ -243,7 +244,7 @@ def make_hook(
|
|
|
243
244
|
tool = by_id.get(tool_name)
|
|
244
245
|
if tool is None or tool.write_class == READ_CLASS:
|
|
245
246
|
return None
|
|
246
|
-
target = resolve_target(runtime, tool.id, args,
|
|
247
|
+
target = resolve_target(runtime, tool.id, args, language, harness_language, option_flags)
|
|
247
248
|
call = pin_key(kwargs)
|
|
248
249
|
if pins is not None and call is not None:
|
|
249
250
|
# An entry left by a call that never reached the pin hook must not stand for this one.
|
|
@@ -304,12 +305,13 @@ def register_approval(
|
|
|
304
305
|
ctx: PluginContext,
|
|
305
306
|
runtime: Runtime,
|
|
306
307
|
tools: list[ToolInfo],
|
|
307
|
-
flags: list[str] | None = None,
|
|
308
308
|
language: str | None = None,
|
|
309
|
+
option_flags: list[str] | None = None,
|
|
309
310
|
) -> None:
|
|
310
311
|
"""Hold every non-read call of ``tools`` for approval, with the card in ``language`` (the plugin's setting).
|
|
311
312
|
|
|
312
|
-
``
|
|
313
|
+
``option_flags`` are the package's own tool options as ``run`` gets them, so ``target`` resolves the call as
|
|
314
|
+
``run`` will.
|
|
313
315
|
|
|
314
316
|
Two hooks, in this order, since Hermes runs them in registration order: the approval hook
|
|
315
317
|
stashes the pin of what the card shows, then the pin hook gives it to the call.
|
|
@@ -317,6 +319,8 @@ def register_approval(
|
|
|
317
319
|
pins = PinStash()
|
|
318
320
|
ctx.register_hook(
|
|
319
321
|
HOOK_NAME,
|
|
320
|
-
make_hook(
|
|
322
|
+
make_hook(
|
|
323
|
+
runtime, tools, surface=lambda: session_surface(ctx), language=language, pins=pins, option_flags=option_flags
|
|
324
|
+
),
|
|
321
325
|
)
|
|
322
326
|
ctx.register_hook(HOOK_NAME, make_pin_hook(tools, pins))
|
|
@@ -9,7 +9,7 @@ from .context import PluginContext
|
|
|
9
9
|
from .listing import list_tools
|
|
10
10
|
from . import project
|
|
11
11
|
from .runtime import Unavailable, locate_runtime
|
|
12
|
-
from .settings import SettingsError, language_setting, settings_flags
|
|
12
|
+
from .settings import SettingsError, emoji_setting, language_setting, option_flags, settings_flags
|
|
13
13
|
from .skills import register_skills
|
|
14
14
|
from .tools import register_tools
|
|
15
15
|
|
|
@@ -33,6 +33,6 @@ def register(ctx: PluginContext) -> None:
|
|
|
33
33
|
logger.warning("%s is not available and registers nothing: %s", project.PLUGIN_NAME, error)
|
|
34
34
|
return
|
|
35
35
|
|
|
36
|
-
register_tools(ctx, runtime, tools, flags)
|
|
37
|
-
register_approval(ctx, runtime, tools,
|
|
36
|
+
register_tools(ctx, runtime, tools, flags, emoji_setting(ctx))
|
|
37
|
+
register_approval(ctx, runtime, tools, language_setting(ctx), option_flags(ctx))
|
|
38
38
|
register_skills(ctx, runtime.skills_dir, {tool.toolset for tool in tools})
|
|
@@ -21,8 +21,14 @@ TOOLSET_EMOJI: dict[str, str] = {
|
|
|
21
21
|
"actions": "🔧",
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
#: The settings the tools themselves read: each snake_case name and a type (``text``, ``boolean``, ``number``,
|
|
25
|
+
#: ``list``, ``path`` or ``paths``) or a dict with ``type`` and optional bounds (``integer``, ``min``, ``max``),
|
|
26
|
+
#: as in ``src/project.ts``. Write OPTIONS on one line with double quotes. A setting ``a_b`` becomes ``--a-b``.
|
|
27
|
+
OPTIONS: dict[str, str | dict[str, object]] = {"state_dir": "path", "download_dir": "path", "download_max_mb": {"type": "number", "integer": True, "min": 1, "max": 2000}, "send_dirs": "paths", "keep_disappearing": "boolean", "channels": "boolean"}
|
|
28
|
+
|
|
24
29
|
__all__ = [
|
|
25
30
|
"CLI_NAME",
|
|
31
|
+
"OPTIONS",
|
|
26
32
|
"PLUGIN_NAME",
|
|
27
33
|
"SKILL_PREFIX",
|
|
28
34
|
"TOOLSET_EMOJI",
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Mapping
|
|
5
7
|
from typing import Any
|
|
6
8
|
|
|
9
|
+
from . import project
|
|
7
10
|
from .context import PluginContext
|
|
8
11
|
|
|
9
12
|
#: The value of ``toolsets`` that turns on every toolset.
|
|
10
13
|
ALL_TOOLSETS = "all"
|
|
11
|
-
MAX_DOWNLOAD_MB = 2000
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
class SettingsError(ValueError):
|
|
@@ -22,41 +24,6 @@ def _names(key: str, value: Any) -> str | None:
|
|
|
22
24
|
return ",".join(value) or None
|
|
23
25
|
|
|
24
26
|
|
|
25
|
-
def state_dir_flags(ctx: PluginContext) -> list[str]:
|
|
26
|
-
"""Read ``state_dir`` and return ``["--state-dir", value]``, or ``[]`` when absent.
|
|
27
|
-
|
|
28
|
-
Raises:
|
|
29
|
-
SettingsError: the setting is present but not a non-empty string.
|
|
30
|
-
"""
|
|
31
|
-
state_dir = ctx.get_config("state_dir")
|
|
32
|
-
if state_dir is None:
|
|
33
|
-
return []
|
|
34
|
-
if not isinstance(state_dir, str) or not state_dir.strip():
|
|
35
|
-
raise SettingsError('setting "state_dir" must be a path')
|
|
36
|
-
return ["--state-dir", state_dir]
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _is_path_item(item: Any) -> bool:
|
|
40
|
-
"""Whether ``item`` is a non-empty string that survives the flag's comma-separated form whole."""
|
|
41
|
-
return isinstance(item, str) and bool(item.strip()) and "," not in item
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def send_dirs_flags(ctx: PluginContext) -> list[str]:
|
|
45
|
-
"""Read ``send_dirs`` and return ``["--send-dirs", "a,b"]``, or ``[]`` when absent or empty.
|
|
46
|
-
|
|
47
|
-
The flag is comma-separated, so a path holding a comma is refused rather than split in two.
|
|
48
|
-
|
|
49
|
-
Raises:
|
|
50
|
-
SettingsError: the setting is present but not a list of non-empty strings without commas.
|
|
51
|
-
"""
|
|
52
|
-
value = ctx.get_config("send_dirs")
|
|
53
|
-
if value is None:
|
|
54
|
-
return []
|
|
55
|
-
if not isinstance(value, list) or not all(_is_path_item(item) for item in value):
|
|
56
|
-
raise SettingsError('setting "send_dirs" must be a list of paths without commas')
|
|
57
|
-
return ["--send-dirs", ",".join(value)] if value else []
|
|
58
|
-
|
|
59
|
-
|
|
60
27
|
def language_setting(ctx: PluginContext) -> str | None:
|
|
61
28
|
"""The ``language`` setting, stripped; ``None`` when it is absent, blank or not a string.
|
|
62
29
|
|
|
@@ -66,6 +33,58 @@ def language_setting(ctx: PluginContext) -> str | None:
|
|
|
66
33
|
return (value.strip() or None) if isinstance(value, str) else None
|
|
67
34
|
|
|
68
35
|
|
|
36
|
+
def emoji_setting(ctx: PluginContext, defaults: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
37
|
+
"""The emoji per toolset: ``defaults`` (``project.TOOLSET_EMOJI``) with string entries of ``emoji`` merged over it.
|
|
38
|
+
|
|
39
|
+
A setting that is not a ``dict``, and an entry whose key or value is not a
|
|
40
|
+
``str``, are ignored so a typo never takes every tool away. It is no flag
|
|
41
|
+
of ``settings_flags``.
|
|
42
|
+
"""
|
|
43
|
+
merged = dict(project.TOOLSET_EMOJI if defaults is None else defaults)
|
|
44
|
+
value = ctx.get_config("emoji")
|
|
45
|
+
if isinstance(value, dict):
|
|
46
|
+
merged.update({key: item for key, item in value.items() if isinstance(key, str) and isinstance(item, str)})
|
|
47
|
+
return merged
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _option_flag(name: str, declaration: str | dict[str, object], value: Any) -> list[str]:
|
|
51
|
+
kind = declaration["type"] if isinstance(declaration, dict) else declaration
|
|
52
|
+
flag = f"--{name.replace('_', '-')}"
|
|
53
|
+
if kind in ("text", "path"):
|
|
54
|
+
if not isinstance(value, str):
|
|
55
|
+
expected = "a path" if kind == "path" else "text"
|
|
56
|
+
raise SettingsError(f'setting "{name}" must be {expected}')
|
|
57
|
+
return [flag, text] if (text := value.strip()) else []
|
|
58
|
+
if kind == "boolean":
|
|
59
|
+
if not isinstance(value, bool):
|
|
60
|
+
raise SettingsError(f'setting "{name}" must be true or false')
|
|
61
|
+
return [flag] if value else []
|
|
62
|
+
if kind == "number":
|
|
63
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
64
|
+
raise SettingsError(f'setting "{name}" must be a finite number')
|
|
65
|
+
return [flag, str(value)]
|
|
66
|
+
if kind in ("list", "paths"):
|
|
67
|
+
if not isinstance(value, list) or not all(isinstance(item, str) and "," not in item for item in value):
|
|
68
|
+
expected = "paths" if kind == "paths" else "strings"
|
|
69
|
+
raise SettingsError(f'setting "{name}" must be a list of {expected} without commas')
|
|
70
|
+
items = [item.strip() for item in value if item.strip()]
|
|
71
|
+
return [flag, ",".join(items)] if items else []
|
|
72
|
+
raise SettingsError(f'unknown option type "{kind}" for "{name}"')
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def option_flags(ctx: PluginContext, options: Mapping[str, str | dict[str, object]] | None = None) -> list[str]:
|
|
76
|
+
"""The package's tool options as flags, with absent settings left to the command line's environment.
|
|
77
|
+
|
|
78
|
+
Bounds and path absoluteness are checked by the command line, not here.
|
|
79
|
+
"""
|
|
80
|
+
flags: list[str] = []
|
|
81
|
+
for name, declaration in (project.OPTIONS if options is None else options).items():
|
|
82
|
+
value = ctx.get_config(name)
|
|
83
|
+
if value is not None:
|
|
84
|
+
flags += _option_flag(name, declaration, value)
|
|
85
|
+
return flags
|
|
86
|
+
|
|
87
|
+
|
|
69
88
|
def settings_flags(ctx: PluginContext) -> list[str]:
|
|
70
89
|
"""Read the plugin's settings and return the flags that say the same.
|
|
71
90
|
|
|
@@ -84,36 +103,15 @@ def settings_flags(ctx: PluginContext) -> list[str]:
|
|
|
84
103
|
elif toolsets is not None and (names := _names("toolsets", toolsets)):
|
|
85
104
|
flags += ["--toolsets", names]
|
|
86
105
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
("
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
value = ctx.get_config(key)
|
|
93
|
-
if value is not None and not isinstance(value, bool):
|
|
94
|
-
raise SettingsError(f'setting "{key}" must be true or false')
|
|
95
|
-
if value:
|
|
96
|
-
flags.append(flag)
|
|
106
|
+
read_only = ctx.get_config("read_only")
|
|
107
|
+
if read_only is not None and not isinstance(read_only, bool):
|
|
108
|
+
raise SettingsError('setting "read_only" must be true or false')
|
|
109
|
+
if read_only:
|
|
110
|
+
flags.append("--read-only")
|
|
97
111
|
|
|
98
112
|
for key, flag in (("tools", "--tools"), ("exclude_tools", "--exclude-tools")):
|
|
99
113
|
value = ctx.get_config(key)
|
|
100
114
|
if value is not None and (names := _names(key, value)):
|
|
101
115
|
flags += [flag, names]
|
|
102
116
|
|
|
103
|
-
flags
|
|
104
|
-
|
|
105
|
-
download_dir = ctx.get_config("download_dir")
|
|
106
|
-
if download_dir is not None:
|
|
107
|
-
if not isinstance(download_dir, str) or not download_dir.strip():
|
|
108
|
-
raise SettingsError('setting "download_dir" must be a path')
|
|
109
|
-
flags += ["--download-dir", download_dir]
|
|
110
|
-
|
|
111
|
-
download_max_mb = ctx.get_config("download_max_mb")
|
|
112
|
-
if download_max_mb is not None:
|
|
113
|
-
if type(download_max_mb) is not int or not 1 <= download_max_mb <= MAX_DOWNLOAD_MB:
|
|
114
|
-
raise SettingsError(f'setting "download_max_mb" must be an integer from 1 to {MAX_DOWNLOAD_MB}')
|
|
115
|
-
flags += ["--download-max-mb", str(download_max_mb)]
|
|
116
|
-
|
|
117
|
-
flags += send_dirs_flags(ctx)
|
|
118
|
-
|
|
119
|
-
return flags
|
|
117
|
+
return [*flags, *option_flags(ctx)]
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
-
from collections.abc import Callable
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
9
|
from .approval import PIN_ARGUMENT, parse_pin, run_arguments
|
|
@@ -76,14 +76,21 @@ def make_handler(runtime: Runtime, tool_id: str, flags: list[str]) -> Callable[.
|
|
|
76
76
|
return handler
|
|
77
77
|
|
|
78
78
|
|
|
79
|
-
def register_tools(
|
|
79
|
+
def register_tools(
|
|
80
|
+
ctx: PluginContext,
|
|
81
|
+
runtime: Runtime,
|
|
82
|
+
tools: list[ToolInfo],
|
|
83
|
+
flags: list[str],
|
|
84
|
+
emoji: Mapping[str, str] | None = None,
|
|
85
|
+
) -> None:
|
|
80
86
|
"""Register each listed tool under its own ID, grouped in one Hermes toolset per package toolset."""
|
|
87
|
+
toolset_emoji = TOOLSET_EMOJI if emoji is None else emoji
|
|
81
88
|
for tool in tools:
|
|
82
89
|
ctx.register_tool(
|
|
83
90
|
name=tool.id,
|
|
84
91
|
toolset=TOOLSET_PREFIX + tool.toolset.replace("_", "-"),
|
|
85
92
|
schema={"name": tool.id, "description": tool.description, "parameters": tool.input_schema},
|
|
86
93
|
handler=make_handler(runtime, tool.id, flags),
|
|
87
|
-
emoji=
|
|
94
|
+
emoji=toolset_emoji.get(tool.toolset, ""),
|
|
88
95
|
description=tool.title,
|
|
89
96
|
)
|