@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.
Files changed (34) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.antigravity-plugin/plugin.json +12 -0
  3. package/.claude-plugin/marketplace.json +17 -0
  4. package/.claude-plugin/plugin.json +18 -0
  5. package/.codex-plugin/plugin.json +31 -0
  6. package/.mcp.json +8 -0
  7. package/LICENSE +429 -0
  8. package/README.md +569 -2
  9. package/__init__.py +36 -0
  10. package/dist/cli.js +30714 -0
  11. package/dist/mcp.js +37673 -0
  12. package/dist/opencode.js +27584 -0
  13. package/mcp_config.json +8 -0
  14. package/package.json +70 -4
  15. package/plugin.yaml +46 -0
  16. package/skills/.gitkeep +0 -0
  17. package/skills/whatsapp-account/SKILL.md +55 -0
  18. package/skills/whatsapp-actions/SKILL.md +109 -0
  19. package/skills/whatsapp-chats/SKILL.md +125 -0
  20. package/skills/whatsapp-contacts/SKILL.md +59 -0
  21. package/skills/whatsapp-count-activity/SKILL.md +63 -0
  22. package/skills/whatsapp-files/SKILL.md +112 -0
  23. package/skills/whatsapp-sends/SKILL.md +50 -0
  24. package/src/hermes/whatsapp_tools_hermes/__init__.py +5 -0
  25. package/src/hermes/whatsapp_tools_hermes/approval.py +330 -0
  26. package/src/hermes/whatsapp_tools_hermes/card.py +507 -0
  27. package/src/hermes/whatsapp_tools_hermes/context.py +34 -0
  28. package/src/hermes/whatsapp_tools_hermes/listing.py +78 -0
  29. package/src/hermes/whatsapp_tools_hermes/plugin.py +38 -0
  30. package/src/hermes/whatsapp_tools_hermes/project.py +47 -0
  31. package/src/hermes/whatsapp_tools_hermes/runtime.py +204 -0
  32. package/src/hermes/whatsapp_tools_hermes/settings.py +171 -0
  33. package/src/hermes/whatsapp_tools_hermes/skills.py +131 -0
  34. 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,5 @@
1
+ """The Hermes Agent plugin of whatsapp-tools: the tools served by the package's command line."""
2
+
3
+ from .plugin import register
4
+
5
+ __all__ = ["register"]
@@ -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))