@craftsamo/whatsapp-tools 0.0.0-stage → 0.1.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/.agents/plugins/marketplace.json +20 -0
- package/.antigravity-plugin/plugin.json +12 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +18 -0
- package/.codex-plugin/plugin.json +31 -0
- package/.mcp.json +8 -0
- package/LICENSE +429 -0
- package/README.md +569 -2
- package/__init__.py +36 -0
- package/dist/cli.js +30714 -0
- package/dist/mcp.js +37673 -0
- package/dist/opencode.js +27584 -0
- package/mcp_config.json +8 -0
- package/package.json +70 -4
- package/plugin.yaml +46 -0
- package/skills/.gitkeep +0 -0
- package/skills/whatsapp-account/SKILL.md +55 -0
- package/skills/whatsapp-actions/SKILL.md +109 -0
- package/skills/whatsapp-chats/SKILL.md +125 -0
- package/skills/whatsapp-contacts/SKILL.md +59 -0
- package/skills/whatsapp-count-activity/SKILL.md +63 -0
- package/skills/whatsapp-files/SKILL.md +112 -0
- package/skills/whatsapp-sends/SKILL.md +50 -0
- package/src/hermes/whatsapp_tools_hermes/__init__.py +5 -0
- package/src/hermes/whatsapp_tools_hermes/approval.py +330 -0
- package/src/hermes/whatsapp_tools_hermes/card.py +507 -0
- package/src/hermes/whatsapp_tools_hermes/context.py +34 -0
- package/src/hermes/whatsapp_tools_hermes/listing.py +78 -0
- package/src/hermes/whatsapp_tools_hermes/plugin.py +38 -0
- package/src/hermes/whatsapp_tools_hermes/project.py +47 -0
- package/src/hermes/whatsapp_tools_hermes/runtime.py +204 -0
- package/src/hermes/whatsapp_tools_hermes/settings.py +171 -0
- package/src/hermes/whatsapp_tools_hermes/skills.py +131 -0
- package/src/hermes/whatsapp_tools_hermes/tools.py +97 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: whatsapp-sends
|
|
3
|
+
description: "Use when sending a WhatsApp text to a person or a group, replying to a message, or when a send fails or its outcome is unclear."
|
|
4
|
+
metadata:
|
|
5
|
+
toolset: sends
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Sends
|
|
9
|
+
|
|
10
|
+
| Tool | Does | Writes |
|
|
11
|
+
| ----------------------- | ------------------------------------------------------ | ------ |
|
|
12
|
+
| `whatsapp_message_send` | sends one text to a chat, optionally quoting a message | yes |
|
|
13
|
+
|
|
14
|
+
Find the chat first: a chat is a JID, never a name or a number. For an
|
|
15
|
+
existing chat, take its `id` from `whatsapp_chat_read` (`list`). For someone the
|
|
16
|
+
mirror does not hold yet, check their number with `whatsapp_contact_read`
|
|
17
|
+
(`check`) and use the `chat` it gives; a hidden-number (`…@lid`) contact or a
|
|
18
|
+
group the mirror does not hold is `not_found`. A number that is not on WhatsApp
|
|
19
|
+
is `not_found` too, and a number WhatsApp did not answer for is an error:
|
|
20
|
+
nothing is sent either way. The sync service must be running (see
|
|
21
|
+
`whatsapp_account_read`).
|
|
22
|
+
|
|
23
|
+
Send `text` exactly as the user wants it: surrounding blank space is trimmed,
|
|
24
|
+
nothing else changes, and it is at most 4,000 characters. Never rephrase,
|
|
25
|
+
shorten or add to it without saying so. Split a longer text into several sends
|
|
26
|
+
only when the user agrees. No link preview is made, so a link goes out as plain
|
|
27
|
+
text.
|
|
28
|
+
|
|
29
|
+
Sends go to people and groups only. A channel, a broadcast list, the status
|
|
30
|
+
feed and the user's own chat are refused.
|
|
31
|
+
|
|
32
|
+
To reply, pass `reply_to`, the id of a message of that chat as
|
|
33
|
+
`whatsapp_chat_read` gives it. A message the mirror does not hold, or one that
|
|
34
|
+
was deleted, cannot be quoted. Someone new has no messages to quote.
|
|
35
|
+
|
|
36
|
+
A result means WhatsApp accepted the message, not that it was delivered or
|
|
37
|
+
read. The send lowers the chat's unread count in the mirror, as writing in it
|
|
38
|
+
on the phone does, and sends no read receipt. `errors.record` says when the
|
|
39
|
+
message was sent but could not be written to the mirror; it is not sent again.
|
|
40
|
+
|
|
41
|
+
A failure before anything went out says what to do: the service is not running
|
|
42
|
+
(start it), the number is not on WhatsApp, or WhatsApp's queue dropped the
|
|
43
|
+
request (`upstream_error`, sending again is safe).
|
|
44
|
+
|
|
45
|
+
`uncertain` means the message may have gone out. Read the chat with
|
|
46
|
+
`whatsapp_chat_read` (the mirror may not show it yet), tell the user, and ask
|
|
47
|
+
before sending it again; never send it again on your own. Another person gets
|
|
48
|
+
a second copy of a resend that guessed wrong.
|
|
49
|
+
|
|
50
|
+
Omit `account` to pick the only paired label; with several, name one.
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Asking the person before a tool that changes something runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .context import PluginContext
|
|
14
|
+
from .listing import READ_CLASS, ToolInfo
|
|
15
|
+
from . import card, project
|
|
16
|
+
from .runtime import Runtime, agent_cwd
|
|
17
|
+
|
|
18
|
+
#: How long ``target`` may take, in seconds. Hermes stops a ``pre_tool_call`` hook after 30 s and blocks the call.
|
|
19
|
+
TARGET_TIMEOUT_SECONDS = 20
|
|
20
|
+
|
|
21
|
+
#: What an approval rule matches when a resource a call writes to cannot be told.
|
|
22
|
+
ANY_RESOURCE = card.ANY_RESOURCE
|
|
23
|
+
|
|
24
|
+
#: The Hermes hook that can hold a tool call for the person's approval.
|
|
25
|
+
HOOK_NAME = "pre_tool_call"
|
|
26
|
+
|
|
27
|
+
#: The argument the pin hook sets on a call for the handler to read: it carries the pin of what the approval showed.
|
|
28
|
+
#: No tool argument starts with ``_``, so it never collides with one, and the handler removes it before the call runs.
|
|
29
|
+
PIN_ARGUMENT = f"_{project.PLUGIN_NAME}_pin"
|
|
30
|
+
|
|
31
|
+
#: What a pin looks like: 64 lowercase hexadecimal characters.
|
|
32
|
+
PIN_PATTERN = re.compile(r"[0-9a-f]{64}")
|
|
33
|
+
|
|
34
|
+
#: How many pins the stash keeps; the oldest goes when a new one comes in beyond this.
|
|
35
|
+
PIN_STASH_LIMIT = 256
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Target:
|
|
40
|
+
"""What one ``target`` run says of a call: its resources, description, translated title and intent, and the card's words.
|
|
41
|
+
|
|
42
|
+
A ``title`` or ``intent`` of ``None`` is the tool's registered one, or none, and the ``words`` default to English.
|
|
43
|
+
A ``pin`` of ``None`` is none given (or a malformed one); it comes only with a description.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
resources: list[str]
|
|
47
|
+
description: card.Description | None
|
|
48
|
+
title: str | None = None
|
|
49
|
+
words: card.Words = card.ENGLISH_WORDS
|
|
50
|
+
intent: str | None = None
|
|
51
|
+
pin: str | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_pin(value: Any) -> str | None:
|
|
55
|
+
"""``value`` when it is a pin (64 lowercase hex characters), else ``None``."""
|
|
56
|
+
return value if isinstance(value, str) and PIN_PATTERN.fullmatch(value) else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run_arguments(args: Any) -> dict[str, Any]:
|
|
60
|
+
"""The arguments a call runs with: ``args`` without ``PIN_ARGUMENT``, or none when ``args`` is not a dict.
|
|
61
|
+
|
|
62
|
+
The approval describes these and the handler runs these, so a pin argument the model sent
|
|
63
|
+
itself can neither blank the card nor reach the tool.
|
|
64
|
+
"""
|
|
65
|
+
return {key: value for key, value in args.items() if key != PIN_ARGUMENT} if isinstance(args, dict) else {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
#: Where a pin waits: the call's ``task_id``, ``session_id`` and ``tool_call_id``, a missing id read as ``""``.
|
|
69
|
+
PinKey = tuple[str, str, str]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PinStash:
|
|
73
|
+
"""The pins of calls waiting between the approval hook and the pin hook, by ``PinKey``.
|
|
74
|
+
|
|
75
|
+
One plugin serves every session and task, and providers may number calls per response, so a ``tool_call_id``
|
|
76
|
+
alone can repeat across sessions and across the tasks of a run that has no session.
|
|
77
|
+
|
|
78
|
+
Thread-safe and bounded: when full, the oldest pin is dropped.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, limit: int = PIN_STASH_LIMIT) -> None:
|
|
82
|
+
self._limit = limit
|
|
83
|
+
self._lock = threading.Lock()
|
|
84
|
+
self._pins: dict[PinKey, str] = {}
|
|
85
|
+
|
|
86
|
+
def put(self, key: PinKey, pin: str) -> None:
|
|
87
|
+
"""Keep ``pin`` under ``key``, dropping the oldest entries beyond the limit."""
|
|
88
|
+
with self._lock:
|
|
89
|
+
self._pins.pop(key, None)
|
|
90
|
+
self._pins[key] = pin
|
|
91
|
+
while len(self._pins) > self._limit:
|
|
92
|
+
del self._pins[next(iter(self._pins))]
|
|
93
|
+
|
|
94
|
+
def take(self, key: PinKey) -> str | None:
|
|
95
|
+
"""The pin kept under ``key``, removed; ``None`` when there is none."""
|
|
96
|
+
with self._lock:
|
|
97
|
+
return self._pins.pop(key, None)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _non_blank(value: Any) -> str | None:
|
|
101
|
+
"""``value`` stripped when it is a string with something in it, else ``None``."""
|
|
102
|
+
return (value.strip() or None) if isinstance(value, str) else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def pin_key(hook_kwargs: dict[str, Any]) -> PinKey | None:
|
|
106
|
+
"""The ``PinKey`` of the call a hook was given, or ``None`` without a usable ``tool_call_id``.
|
|
107
|
+
|
|
108
|
+
Hermes hands both hooks of one call the same ids. A blank or missing ``task_id`` or ``session_id`` reads as
|
|
109
|
+
``""``, so a run that carries neither still pins its calls by ``tool_call_id``; two such calls that share a key
|
|
110
|
+
at once can only fail one as ``stale`` or run the other unpinned.
|
|
111
|
+
"""
|
|
112
|
+
call_id = _non_blank(hook_kwargs.get("tool_call_id"))
|
|
113
|
+
if call_id is None:
|
|
114
|
+
return None
|
|
115
|
+
task_id = _non_blank(hook_kwargs.get("task_id")) or ""
|
|
116
|
+
session_id = _non_blank(hook_kwargs.get("session_id")) or ""
|
|
117
|
+
return (task_id, session_id, call_id)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def display_language() -> str | None:
|
|
121
|
+
"""Hermes' display language (``agent.i18n.get_language()``), or ``None`` when it cannot be told.
|
|
122
|
+
|
|
123
|
+
``agent.i18n`` is imported here and not at module level: the plugin loads where Hermes' package is absent.
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
from agent.i18n import get_language
|
|
127
|
+
|
|
128
|
+
return _non_blank(get_language())
|
|
129
|
+
except Exception:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _known(read: Callable[[], str | None]) -> str | None:
|
|
134
|
+
"""What ``read`` gives when it gives a non-blank string; a reader that raises or gives anything else knows nothing."""
|
|
135
|
+
try:
|
|
136
|
+
return _non_blank(read())
|
|
137
|
+
except Exception:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def language_flags(language: str | None, harness_language: str | None) -> list[str]:
|
|
142
|
+
"""The flags that tell ``target`` the language set for the plugin and the harness's, each when known.
|
|
143
|
+
|
|
144
|
+
Each value is joined to its flag with ``=``, so a value that starts with ``--`` is read as a value, not a flag.
|
|
145
|
+
"""
|
|
146
|
+
flags: list[str] = []
|
|
147
|
+
if language:
|
|
148
|
+
flags.append(f"--language={language}")
|
|
149
|
+
if harness_language:
|
|
150
|
+
flags.append(f"--harness-language={harness_language}")
|
|
151
|
+
return flags
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def resolve_target(
|
|
155
|
+
runtime: Runtime,
|
|
156
|
+
tool_id: str,
|
|
157
|
+
args: Any,
|
|
158
|
+
flags: list[str] | None = None,
|
|
159
|
+
language: str | None = None,
|
|
160
|
+
harness_language: Callable[[], str | None] = lambda: display_language(),
|
|
161
|
+
option_flags: list[str] | None = None,
|
|
162
|
+
) -> Target:
|
|
163
|
+
"""What ``target`` prints: ``["*"]`` for resources that cannot be told, no description for one absent or bad.
|
|
164
|
+
|
|
165
|
+
``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.
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
result = runtime.run(
|
|
170
|
+
[
|
|
171
|
+
"target",
|
|
172
|
+
tool_id,
|
|
173
|
+
"--cwd",
|
|
174
|
+
agent_cwd(),
|
|
175
|
+
*(flags or []),
|
|
176
|
+
*language_flags(language, _known(harness_language)),
|
|
177
|
+
*(option_flags or []),
|
|
178
|
+
],
|
|
179
|
+
stdin=json.dumps(run_arguments(args)),
|
|
180
|
+
timeout=TARGET_TIMEOUT_SECONDS,
|
|
181
|
+
)
|
|
182
|
+
document = json.loads(result.stdout) if result.code == 0 else None
|
|
183
|
+
if not isinstance(document, dict):
|
|
184
|
+
return Target([ANY_RESOURCE], None)
|
|
185
|
+
resources = document.get("resources")
|
|
186
|
+
description = card.parse_description(document.get("description"))
|
|
187
|
+
title = card.parse_line(document.get("title"))
|
|
188
|
+
intent = card.parse_line(document.get("intent"))
|
|
189
|
+
words = card.parse_words(document.get("words"))
|
|
190
|
+
pin = parse_pin(document.get("pin"))
|
|
191
|
+
if isinstance(resources, list) and resources and all(isinstance(item, str) and item for item in resources):
|
|
192
|
+
return Target(resources, description, title, words, intent, pin)
|
|
193
|
+
return Target([ANY_RESOURCE], description, title, words, intent, pin)
|
|
194
|
+
except Exception: # an approval is asked for either way; only its resources are lost
|
|
195
|
+
return Target([ANY_RESOURCE], None)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def rule_key(tool_id: str, resources: list[str]) -> str:
|
|
199
|
+
"""The key an "always" answer is stored under: the resources as a JSON list, so no two lists share one."""
|
|
200
|
+
return f"{project.PLUGIN_NAME}:{tool_id}:{json.dumps(resources, separators=(',', ':'))}"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _message(tool: ToolInfo, target: Target, surface: Callable[[], str | None]) -> str:
|
|
204
|
+
"""The card's message: the stand-in line, its targets and its description, fitted to the session's surface.
|
|
205
|
+
|
|
206
|
+
The stand-in line, which shows where no headline stands, is the first of
|
|
207
|
+
the intent ``target`` translated, its translated title, the tool's listed
|
|
208
|
+
intent and its registered title. A hook that raises asks nothing, so a
|
|
209
|
+
card that cannot be built falls back to the one without a description,
|
|
210
|
+
with the same line and words, and that one to the line alone.
|
|
211
|
+
"""
|
|
212
|
+
stand_in = target.intent or target.title or tool.intent or tool.title
|
|
213
|
+
try:
|
|
214
|
+
return card.build_message(stand_in, target.resources, target.description, surface(), target.words)
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
try:
|
|
218
|
+
return card.build_message(stand_in, target.resources, None, None, target.words)
|
|
219
|
+
except Exception:
|
|
220
|
+
return stand_in
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def make_hook(
|
|
224
|
+
runtime: Runtime,
|
|
225
|
+
tools: list[ToolInfo],
|
|
226
|
+
flags: list[str] | None = None,
|
|
227
|
+
surface: Callable[[], str | None] = lambda: None,
|
|
228
|
+
language: str | None = None,
|
|
229
|
+
harness_language: Callable[[], str | None] = lambda: display_language(),
|
|
230
|
+
pins: PinStash | None = None,
|
|
231
|
+
option_flags: list[str] | None = None,
|
|
232
|
+
) -> Callable[..., dict[str, str] | None]:
|
|
233
|
+
"""The ``pre_tool_call`` hook: it sends every call of these tools but a read to the person's approval.
|
|
234
|
+
|
|
235
|
+
Whether a person is there to ask, and what happens when none is, is
|
|
236
|
+
Hermes' own gate to decide. ``language`` is the plugin's ``language``
|
|
237
|
+
setting; ``harness_language`` is read on every call. When ``pins`` is
|
|
238
|
+
given, the pin ``target`` answered is kept in it under the call's
|
|
239
|
+
``pin_key`` for the pin hook; the decision is the same either way.
|
|
240
|
+
"""
|
|
241
|
+
by_id = {tool.id: tool for tool in tools}
|
|
242
|
+
|
|
243
|
+
def hook(tool_name: str = "", args: Any = None, **kwargs: Any) -> dict[str, str] | None:
|
|
244
|
+
tool = by_id.get(tool_name)
|
|
245
|
+
if tool is None or tool.write_class == READ_CLASS:
|
|
246
|
+
return None
|
|
247
|
+
target = resolve_target(runtime, tool.id, args, flags, language, harness_language, option_flags)
|
|
248
|
+
call = pin_key(kwargs)
|
|
249
|
+
if pins is not None and call is not None:
|
|
250
|
+
# An entry left by a call that never reached the pin hook must not stand for this one.
|
|
251
|
+
if target.pin:
|
|
252
|
+
pins.put(call, target.pin)
|
|
253
|
+
else:
|
|
254
|
+
pins.take(call)
|
|
255
|
+
resources = target.resources
|
|
256
|
+
key = rule_key(tool.id, resources)
|
|
257
|
+
if ANY_RESOURCE in resources:
|
|
258
|
+
# Hermes falls back to the bare tool name for a missing rule_key, which an "always" would turn into a
|
|
259
|
+
# grant for every resource; a key no later call repeats leaves nothing reusable.
|
|
260
|
+
key = f"{key}:once-{uuid.uuid4().hex}"
|
|
261
|
+
return {"action": "approve", "message": _message(tool, target, surface), "rule_key": key}
|
|
262
|
+
|
|
263
|
+
return hook
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def make_pin_hook(tools: list[ToolInfo], pins: PinStash) -> Callable[..., dict[str, Any] | None]:
|
|
267
|
+
"""The second ``pre_tool_call`` hook: it hands each non-read call the pin its approval showed.
|
|
268
|
+
|
|
269
|
+
The result modifies the call's args so that ``PIN_ARGUMENT`` is always set, to the pin
|
|
270
|
+
kept by the approval hook or to ``""`` when there is none, so a value the model sent
|
|
271
|
+
itself never stands. Any other tool, and a read, is left alone. It never raises.
|
|
272
|
+
"""
|
|
273
|
+
by_id = {tool.id: tool for tool in tools}
|
|
274
|
+
|
|
275
|
+
def hook(tool_name: str = "", args: Any = None, **kwargs: Any) -> dict[str, Any] | None:
|
|
276
|
+
tool = by_id.get(tool_name)
|
|
277
|
+
if tool is None or tool.write_class == READ_CLASS:
|
|
278
|
+
return None
|
|
279
|
+
pin = ""
|
|
280
|
+
try:
|
|
281
|
+
call = pin_key(kwargs)
|
|
282
|
+
pin = (pins.take(call) if call is not None else None) or ""
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
return {"action": "modify", "args": {PIN_ARGUMENT: pin}}
|
|
286
|
+
|
|
287
|
+
return hook
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def session_surface(ctx: PluginContext) -> str | None:
|
|
291
|
+
"""Hermes' ``PluginContext.session_surface()`` names where the session runs (cli, tui, telegram, …).
|
|
292
|
+
|
|
293
|
+
Older Hermes has none, and then the card gets the strictest surface.
|
|
294
|
+
"""
|
|
295
|
+
value = getattr(ctx, "session_surface", None)
|
|
296
|
+
if callable(value):
|
|
297
|
+
try:
|
|
298
|
+
value = value()
|
|
299
|
+
except Exception:
|
|
300
|
+
return None
|
|
301
|
+
return value if isinstance(value, str) and value else None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def register_approval(
|
|
305
|
+
ctx: PluginContext,
|
|
306
|
+
runtime: Runtime,
|
|
307
|
+
tools: list[ToolInfo],
|
|
308
|
+
flags: list[str] | None = None,
|
|
309
|
+
language: str | None = None,
|
|
310
|
+
option_flags: list[str] | None = None,
|
|
311
|
+
) -> None:
|
|
312
|
+
"""Hold every non-read call of ``tools`` for approval, with the card in ``language`` (the plugin's setting).
|
|
313
|
+
|
|
314
|
+
Two hooks, in this order, since Hermes runs them in registration order: the approval hook
|
|
315
|
+
stashes the pin of what the card shows, then the pin hook gives it to the call.
|
|
316
|
+
"""
|
|
317
|
+
pins = PinStash()
|
|
318
|
+
ctx.register_hook(
|
|
319
|
+
HOOK_NAME,
|
|
320
|
+
make_hook(
|
|
321
|
+
runtime,
|
|
322
|
+
tools,
|
|
323
|
+
flags,
|
|
324
|
+
surface=lambda: session_surface(ctx),
|
|
325
|
+
language=language,
|
|
326
|
+
pins=pins,
|
|
327
|
+
option_flags=option_flags,
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
ctx.register_hook(HOOK_NAME, make_pin_hook(tools, pins))
|