chatwire-ha 1.0.0__tar.gz

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.
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: chatwire-ha
3
+ Version: 1.0.0
4
+ Summary: Home Assistant integration for chatwire — trigger HA automations/scenes via iMessage keywords.
5
+ Author: Allen Bina
6
+ License: MIT
7
+ Keywords: chatwire,home-assistant,imessage,plugin
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: End Users/Desktop
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: httpx>=0.27
16
+ Description-Content-Type: text/markdown
17
+
18
+ # chatwire-ha\n\nChatwire plugin. See https://github.com/allenbina/chatwire for details.
@@ -0,0 +1 @@
1
+ # chatwire-ha\n\nChatwire plugin. See https://github.com/allenbina/chatwire for details.
@@ -0,0 +1,261 @@
1
+ """chatwire-ha — Home Assistant integration for chatwire.
2
+
3
+ Triggers HA automations/scenes/services via exact iMessage keyword matching.
4
+ Install with:
5
+ pip install chatwire-ha
6
+
7
+ Then add to config.json:
8
+ {
9
+ "integrations": {
10
+ "chatwire_ha": {
11
+ "enabled": true,
12
+ "ha_url": "http://homeassistant.local:8123",
13
+ "access_token": "<long-lived token>",
14
+ "commands": [
15
+ {"keyword": "lights off", "domain": "light", "service": "turn_off",
16
+ "entity_id": "light.living_room", "description": "Living room lights off"},
17
+ {"keyword": "good night", "domain": "scene", "service": "turn_on",
18
+ "entity_id": "scene.night_mode", "description": "Night mode scene",
19
+ "allowed_senders": ["+15551234567", "alice@example.com"]}
20
+ ]
21
+ }
22
+ }
23
+ }
24
+
25
+ Per-command ``allowed_senders``
26
+ -------------------------------
27
+ Each command entry accepts an optional ``allowed_senders`` list. When present
28
+ and non-empty, only messages from handles in that list trigger the command.
29
+ Matching is case-insensitive (email addresses) and exact (phone numbers).
30
+ An absent or empty ``allowed_senders`` list means *any* sender may trigger the
31
+ command (the pre-existing, backward-compatible behaviour).
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import logging
37
+ from typing import Any
38
+
39
+ import httpx
40
+
41
+ log = logging.getLogger(__name__)
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Lazy imports: integrations.base is only available when installed inside the
45
+ # chatwire tree. External packages reference it the same way; the bridge
46
+ # injects sys.path before loading entry-point plugins.
47
+ # ---------------------------------------------------------------------------
48
+ try:
49
+ from integrations.base import BridgeContext, InboundMessage, SendTarget # type: ignore[import]
50
+ except ImportError: # pragma: no cover — only missing in isolated unit tests
51
+ BridgeContext = object # type: ignore[misc,assignment]
52
+ InboundMessage = object # type: ignore[misc,assignment]
53
+ SendTarget = None # type: ignore[misc,assignment]
54
+
55
+
56
+ class HAIntegration:
57
+ """Trigger Home Assistant services via iMessage keyword commands.
58
+
59
+ Each inbound message is checked against the configured keyword list
60
+ (stripped, lowercased, exact match). On a hit the integration POSTs to
61
+ the HA services API and replies to the sender with "Done: <description>".
62
+ """
63
+
64
+ NAME = "chatwire_ha"
65
+ TIER = "notify" # Third-party; receives SanitizedEvent only (no message text).
66
+ DISPLAY_NAME = "Home Assistant"
67
+ DESCRIPTION = "Trigger HA automations/scenes via iMessage keywords."
68
+ ICON = "🏠"
69
+
70
+ SETTINGS_SCHEMA: dict[str, Any] = {
71
+ "type": "object",
72
+ "properties": {
73
+ "enabled": {
74
+ "type": "boolean",
75
+ "default": False,
76
+ "title": "Enable Home Assistant integration",
77
+ "x-ui-order": 0,
78
+ },
79
+ "ha_url": {
80
+ "type": "string",
81
+ "title": "Home Assistant URL",
82
+ "description": "Base URL of your HA instance, e.g. http://homeassistant.local:8123",
83
+ "x-ui-placeholder": "http://homeassistant.local:8123",
84
+ "x-ui-order": 1,
85
+ },
86
+ "access_token": {
87
+ "type": "string",
88
+ "title": "Long-lived access token",
89
+ "description": (
90
+ "Create one in HA under Profile → Long-Lived Access Tokens."
91
+ ),
92
+ "x-ui-type": "password",
93
+ "x-ui-order": 2,
94
+ },
95
+ "commands": {
96
+ "type": "array",
97
+ "title": "Command mappings",
98
+ "description": (
99
+ "List of keyword → HA service mappings. Each keyword is "
100
+ "matched case-insensitively against the full message text."
101
+ ),
102
+ "x-ui-order": 3,
103
+ "items": {
104
+ "type": "object",
105
+ "properties": {
106
+ "keyword": {
107
+ "type": "string",
108
+ "title": "Keyword",
109
+ "description": "Exact phrase the sender types (case-insensitive).",
110
+ },
111
+ "domain": {
112
+ "type": "string",
113
+ "title": "HA domain",
114
+ "description": "e.g. light, switch, scene, automation",
115
+ },
116
+ "service": {
117
+ "type": "string",
118
+ "title": "HA service",
119
+ "description": "e.g. turn_on, turn_off, trigger",
120
+ },
121
+ "entity_id": {
122
+ "type": "string",
123
+ "title": "Entity ID",
124
+ "description": "e.g. light.living_room, scene.night_mode",
125
+ },
126
+ "description": {
127
+ "type": "string",
128
+ "title": "Description",
129
+ "description": "Human-readable label sent back as the reply.",
130
+ },
131
+ "allowed_senders": {
132
+ "type": "array",
133
+ "title": "Allowed senders",
134
+ "description": (
135
+ "Optional list of handles (phone numbers or email addresses) "
136
+ "that may trigger this command. Empty = anyone can trigger it."
137
+ ),
138
+ "items": {"type": "string"},
139
+ "default": [],
140
+ },
141
+ },
142
+ "required": ["keyword", "domain", "service", "entity_id", "description"],
143
+ },
144
+ "default": [],
145
+ },
146
+ },
147
+ "required": ["ha_url", "access_token"],
148
+ }
149
+
150
+ # ------------------------------------------------------------------
151
+
152
+ def __init__(self, config: dict[str, Any]) -> None:
153
+ self._ha_url: str = (config.get("ha_url") or "").rstrip("/")
154
+ self._access_token: str = config.get("access_token") or ""
155
+ commands_raw: list[dict] = config.get("commands") or []
156
+
157
+ # Build lowercased-keyword → command mapping once at startup.
158
+ self._commands: dict[str, dict] = {}
159
+ for cmd in commands_raw:
160
+ kw = (cmd.get("keyword") or "").strip().lower()
161
+ if kw:
162
+ raw_senders: list = cmd.get("allowed_senders") or []
163
+ self._commands[kw] = {
164
+ "domain": cmd.get("domain", ""),
165
+ "service": cmd.get("service", ""),
166
+ "entity_id": cmd.get("entity_id", ""),
167
+ "description": cmd.get("description", ""),
168
+ # frozenset for O(1) lookup; lowercased for case-insensitive match.
169
+ "allowed_senders": frozenset(s.lower() for s in raw_senders if s),
170
+ }
171
+
172
+ self._ctx: Any = None
173
+ self._client: httpx.AsyncClient | None = None
174
+
175
+ # ------------------------------------------------------------------
176
+ # Integration lifecycle
177
+ # ------------------------------------------------------------------
178
+
179
+ async def start(self, ctx: Any) -> None:
180
+ if not self._ha_url:
181
+ raise ValueError("chatwire_ha: 'ha_url' is required")
182
+ if not self._access_token:
183
+ raise ValueError("chatwire_ha: 'access_token' is required")
184
+
185
+ self._ctx = ctx
186
+ self._client = httpx.AsyncClient(
187
+ headers={"Authorization": f"Bearer {self._access_token}"},
188
+ timeout=10.0,
189
+ )
190
+ log.info(
191
+ "home_assistant integration started; HA at %s, %d command(s) registered",
192
+ self._ha_url,
193
+ len(self._commands),
194
+ )
195
+
196
+ async def stop(self) -> None:
197
+ if self._client is not None:
198
+ await self._client.aclose()
199
+ self._client = None
200
+ log.info("home_assistant integration stopped")
201
+
202
+ async def on_inbound(self, msg: Any) -> None:
203
+ """Check for a keyword match and fire the corresponding HA service."""
204
+ if self._client is None or self._ctx is None:
205
+ return # not started or already stopped — silently drop
206
+
207
+ text = (msg.text or "").strip().lower()
208
+ cmd = self._commands.get(text)
209
+ if cmd is None:
210
+ return # not a recognised keyword
211
+
212
+ # Allowed-sender filter: if the command has a non-empty allowed_senders
213
+ # set, only proceed when the message sender is in that set.
214
+ allowed = cmd["allowed_senders"]
215
+ if allowed and (msg.handle or "").lower() not in allowed:
216
+ log.debug(
217
+ "chatwire_ha: handle %r not in allowed_senders for keyword %r — skipped",
218
+ msg.handle,
219
+ text,
220
+ )
221
+ return
222
+
223
+ domain = cmd["domain"]
224
+ service = cmd["service"]
225
+ entity_id = cmd["entity_id"]
226
+ description = cmd["description"] or f"{domain}.{service}"
227
+
228
+ url = f"{self._ha_url}/api/services/{domain}/{service}"
229
+ try:
230
+ r = await self._client.post(url, json={"entity_id": entity_id})
231
+ if r.status_code >= 400:
232
+ log.warning(
233
+ "HA service call %s -> HTTP %d: %s",
234
+ url, r.status_code, r.text[:200],
235
+ )
236
+ return
237
+ except (httpx.HTTPError, asyncio.TimeoutError) as exc:
238
+ log.warning(
239
+ "HA service call failed: %s: %s", type(exc).__name__, exc
240
+ )
241
+ return
242
+
243
+ # Reply to the sender (1:1 or group).
244
+ if SendTarget is None:
245
+ return # pragma: no cover
246
+
247
+ if msg.is_group:
248
+ target = SendTarget(
249
+ kind="chat",
250
+ value=msg.chat_guid,
251
+ label=msg.chat_name or msg.chat_identifier,
252
+ )
253
+ else:
254
+ label = self._ctx.name_for(msg.handle) or msg.handle
255
+ target = SendTarget(
256
+ kind="handle",
257
+ value=msg.handle,
258
+ label=label,
259
+ )
260
+
261
+ await self._ctx.send_text(target, f"Done: {description}")
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "chatwire-ha"
7
+ version = "1.0.0"
8
+ description = "Home Assistant integration for chatwire — trigger HA automations/scenes via iMessage keywords."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Allen Bina" }]
13
+ keywords = ["chatwire", "home-assistant", "imessage", "plugin"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: End Users/Desktop",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ ]
22
+ dependencies = [
23
+ "httpx>=0.27",
24
+ ]
25
+
26
+ [project.entry-points."chatwire.integrations"]
27
+ chatwire_ha = "chatwire_ha:HAIntegration"