@craftsamo/telegram-tools 0.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craftsamo/telegram-tools",
3
- "version": "0.1.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.1.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, or ~/.local/state/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. ``flags`` are the same settings flags ``run``
235
- gets, so ``target`` resolves the call as ``run`` will. ``language`` is the
236
- plugin's ``language`` setting; ``harness_language`` is read on every call.
237
- When ``pins`` is given, the pin ``target`` answered is kept in it under the
238
- call's ``pin_key`` for the pin hook; the decision is the same either way.
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, flags, language, harness_language)
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
- ``flags`` are the same settings flags ``run`` gets, so ``target`` resolves the call as ``run`` will.
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(runtime, tools, flags, surface=lambda: session_surface(ctx), language=language, pins=pins),
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, flags, language_setting(ctx))
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
- for key, flag in (
88
- ("read_only", "--read-only"),
89
- ("keep_disappearing", "--keep-disappearing"),
90
- ("channels", "--channels"),
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 += state_dir_flags(ctx)
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(ctx: PluginContext, runtime: Runtime, tools: list[ToolInfo], flags: list[str]) -> None:
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=TOOLSET_EMOJI.get(tool.toolset, ""),
94
+ emoji=toolset_emoji.get(tool.toolset, ""),
88
95
  description=tool.title,
89
96
  )