Coverage for custom_components/supernotify/sentences.py: 99%

139 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-25 21:14 +0000

1"""Voice and chat commands for Home Assistant's built-in conversation agent - beta. 

2 

3The built-in agent doesn't use an AI model, so it can't use the tools in llm.py. It matches fixed 

4sentences instead, so Supernotify registers a few of its own, the same way an automation with a 

5conversation trigger does. English and Italian for now, and switched on in the options. 

6""" 

7 

8from __future__ import annotations 

9 

10import datetime as dt 

11import logging 

12import re 

13from typing import TYPE_CHECKING, Any 

14 

15from homeassistant.core import CALLBACK_TYPE, HomeAssistant 

16from homeassistant.core import Context as HAContext 

17from homeassistant.helpers.script import ScriptRunResult 

18from homeassistant.helpers.trigger import async_initialize_triggers, async_validate_trigger_config 

19from homeassistant.util import dt as dt_util 

20 

21from . import DOMAIN 

22from .model import CommandType, GlobalTargetType, RecipientType 

23from .schema import EnvelopeOutcome 

24 

25if TYPE_CHECKING: 

26 from .engine import SupernotifyEngine 

27 

28_LOGGER = logging.getLogger(__name__) 

29 

30EVERYONE = ("everyone", "everybody", "all", "tutti", "tutte") 

31DEFAULT_LANGUAGE = "en" 

32# 3:30pm, 3 pm, 12 a.m. 

33MERIDIEM_TIME = re.compile(r"(\d{1,2})(?::(\d{2}))?\s*([ap])\.?\s*m\.?", re.IGNORECASE) 

34# 15:30, 15.30, 1530, 3:30 or a bare hour like 3 

35CLOCK_TIME = re.compile(r"(\d{1,2})(?:[:.]?(\d{2}))?") 

36# "half three" isn't taken, since some places mean 2:30 by it 

37HALF_PAST = re.compile(r"half past (\w+)", re.IGNORECASE) 

38HOURS_SAID = { 

39 word: hour 

40 for hour, word in enumerate( 

41 ["one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve"], start=1 

42 ) 

43} | {str(hour): hour for hour in range(1, 13)} 

44NAMED_TIMES = {"midnight": 0, "noon": 12} 

45# Optional words before the minutes, which the wildcard can take in with the number 

46MINUTES_PREFIXES = ("the", "next", "i", "prossimi") 

47 

48# hassil sentence templates by language - (a|b) is a choice, [a] is optional and {slot} is a wildcard. 

49# A wildcard needs fixed words between it and the next one, so the message is always introduced. 

50# Every language is registered, since the built-in agent matches trigger sentences whatever the 

51# language of the assistant, and the answer is given in the language of the sentence that matched. 

52SENTENCES: dict[str, dict[str, list[str]]] = { 

53 "en": { 

54 "notify": [ 

55 "(tell|notify|message) {name} (that|saying) {message}", 

56 "send [a] (message|notification) to {name} (that|saying) {message}", 

57 ], 

58 "snooze_minutes": ["(snooze|mute|pause) [all] [my] notifications for [the] [next] {minutes} minutes"], 

59 "snooze_until": ["(snooze|mute|pause) [all] [my] notifications until {time}"], 

60 "snooze_hour": ["(snooze|mute|pause) [all] [my] notifications for [an|one] hour"], 

61 # "mute ... until I say" is left to snooze_until, so it doesn't match both 

62 "silence": ["(silence|mute) [all] [my] notifications", "silence [all] [my] notifications until I say"], 

63 "resume": [ 

64 "(unsnooze|unmute|resume) [all] [my] notifications", 

65 "turn [all] [my] notifications back on", 

66 ], 

67 "last": ["what was the last notification", "what notification was sent last"], 

68 }, 

69 "it": { 

70 "notify": [ 

71 "(avvisa|avverti|informa) {name} che {message}", 

72 "(di|dì|di'|dici|scrivi) a {name} che {message}", 

73 "(manda|invia) (un messaggio|una notifica) a {name} (che dice|dicendo|con scritto|che) {message}", 

74 ], 

75 "snooze_minutes": [ 

76 "(posticipa|sospendi|silenzia|metti in pausa) [tutte] [le] [mie] notifiche per [i] [prossimi] {minutes} minuti" 

77 ], 

78 "snooze_until": ["(posticipa|sospendi|silenzia|metti in pausa) [tutte] [le] [mie] notifiche fino alle {time}"], 

79 "snooze_hour": [ 

80 "(posticipa|sospendi|silenzia|metti in pausa) [tutte] [le] [mie] notifiche per (un'ora|un ora|una ora)" 

81 ], 

82 "silence": ["(silenzia|zittisci|disattiva) [tutte] [le] [mie] notifiche [finché non lo dico|fino a nuovo ordine]"], 

83 "resume": [ 

84 "(riattiva|ripristina|riprendi) [tutte] [le] [mie] notifiche", 

85 "(riaccendi|rimetti) [tutte] [le] [mie] notifiche", 

86 ], 

87 "last": [ 

88 "(qual è|qual era|quale è|quale era) [stata] (l'ultima|l ultima) notifica", 

89 "(dimmi|leggi|ripeti) (l'ultima|l ultima) notifica", 

90 ], 

91 }, 

92} 

93 

94# What to say back, by the language of the sentence that matched 

95RESPONSES: dict[str, dict[str, str]] = { 

96 "en": { 

97 "sent": "Sent", 

98 "sent_to": "Sent to {name}", 

99 "not_sent": "Sorry, the notification wasn't sent", 

100 "which": "Which {name} do you mean: {choices}?", 

101 "or": " or ", 

102 "unknown_name": "I don't know anyone called {name}. I can notify {known}, or everyone", 

103 "nobody_yet": "nobody yet", 

104 "minutes_as_number": "Say how many minutes as a number, for example snooze notifications for 30 minutes", 

105 "time_as_clock": "Say a time like 15:30 or 3:30pm, for example snooze notifications until 15:30", 

106 "resumed_yours": "Turned your notifications back on", 

107 "resumed_all": "Turned all notifications back on", 

108 "silenced_yours": "Silenced your notifications until you turn them back on", 

109 "silenced_all": "Silenced all notifications until you turn them back on", 

110 "snoozed_yours": "Snoozed your notifications until {until}", 

111 "snoozed_all": "Snoozed all notifications until {until}", 

112 "no_last": "There haven't been any notifications since Home Assistant started", 

113 "last_sent": "At {when}: {message}. Sent by {sent}", 

114 "last_not_sent": "At {when}: {message}. It wasn't sent by anything", 

115 "unknown_command": "Supernotify doesn't know the command {command}", 

116 }, 

117 "it": { 

118 "sent": "Inviata", 

119 "sent_to": "Inviata a {name}", 

120 "not_sent": "Mi dispiace, la notifica non è stata inviata", 

121 "which": "Quale {name} intendi: {choices}?", 

122 "or": " o ", 

123 "unknown_name": "Non conosco nessuno che si chiama {name}. Posso avvisare {known}, oppure tutti", 

124 "nobody_yet": "ancora nessuno", 

125 "minutes_as_number": "Dimmi i minuti con un numero, per esempio posticipa le notifiche per 30 minuti", 

126 "time_as_clock": "Dimmi un orario come 15 o 15:30, per esempio posticipa le notifiche fino alle 15:30", 

127 "resumed_yours": "Ho riattivato le tue notifiche", 

128 "resumed_all": "Ho riattivato tutte le notifiche", 

129 "silenced_yours": "Ho silenziato le tue notifiche finché non le riattivi", 

130 "silenced_all": "Ho silenziato tutte le notifiche finché non le riattivi", 

131 "snoozed_yours": "Ho posticipato le tue notifiche fino alle {until}", 

132 "snoozed_all": "Ho posticipato tutte le notifiche fino alle {until}", 

133 "no_last": "Non ci sono state notifiche da quando Home Assistant si è avviato", 

134 "last_sent": "Alle {when}: {message}. Inviata da {sent}", 

135 "last_not_sent": "Alle {when}: {message}. Non è stata inviata da nessun canale", 

136 "unknown_command": "Supernotify non conosce il comando {command}", 

137 }, 

138} 

139 

140 

141def _say(language: str, key: str, **values: Any) -> str: 

142 """A response in the given language, or in English if there's none""" 

143 return RESPONSES.get(language, RESPONSES[DEFAULT_LANGUAGE])[key].format(**values) 

144 

145 

146async def async_register_sentences(hass: HomeAssistant, engine: SupernotifyEngine) -> CALLBACK_TYPE | None: 

147 """Register the sentences with the built-in conversation agent, returning how to remove them""" 

148 

149 async def action(run_variables: dict[str, Any], _context: HAContext | None = None) -> ScriptRunResult: 

150 trigger: dict[str, Any] = run_variables["trigger"] 

151 language, _, command = str(trigger["id"]).rpartition(".") 

152 response = await async_respond( 

153 engine, command, trigger.get("slots", {}), _requester(trigger), language or DEFAULT_LANGUAGE 

154 ) 

155 return ScriptRunResult(conversation_response=response, service_response=None, variables={}) 

156 

157 try: 

158 config = await async_validate_trigger_config( 

159 hass, 

160 [ 

161 {"platform": "conversation", "id": f"{language}.{command}", "command": sentences} 

162 for language, commands in SENTENCES.items() 

163 for command, sentences in commands.items() 

164 ], 

165 ) 

166 except Exception as e: 

167 _LOGGER.warning("SUPERNOTIFY Unable to register sentences with the built-in conversation agent: %s", e) 

168 return None 

169 return await async_initialize_triggers(hass, config, action, DOMAIN, "Supernotify sentences", _log) 

170 

171 

172def _log(level: int, msg: str, **kwargs: Any) -> None: 

173 _LOGGER.log(level, "SUPERNOTIFY Sentences: %s", msg, **kwargs) 

174 

175 

176def _requester(trigger: dict[str, Any]) -> HAContext: 

177 """The context of the conversation, so notifications and snoozes are linked to who asked""" 

178 context: dict[str, Any] = (trigger.get("user_input") or {}).get("context") or {} 

179 return HAContext(user_id=context.get("user_id"), parent_id=context.get("id")) 

180 

181 

182async def async_respond( 

183 engine: SupernotifyEngine, command: str, slots: dict[str, Any], context: HAContext, language: str = DEFAULT_LANGUAGE 

184) -> str: 

185 """Carry out a command, returning what to say back in the language of the sentence""" 

186 if command == "notify": 

187 return await _notify(engine, str(slots.get("name", "")), str(slots.get("message", "")), context, language) 

188 if command == "last": 

189 return _last(engine, language) 

190 if command == "snooze_minutes": 

191 minutes = _minutes(str(slots.get("minutes", ""))) 

192 if not minutes.isdigit() or int(minutes) < 1: 

193 return _say(language, "minutes_as_number") 

194 return _snooze(engine, CommandType.SNOOZE, context, language, dt.timedelta(minutes=int(minutes))) 

195 if command == "snooze_until": 

196 spoken = str(slots.get("time", "")) 

197 if spoken.strip().casefold() == "i say": 

198 return _snooze(engine, CommandType.SILENCE, context, language) 

199 until = _next_time(spoken, language) 

200 if until is None: 

201 return _say(language, "time_as_clock") 

202 return _snooze(engine, CommandType.SNOOZE, context, language, until - dt_util.now()) 

203 if command == "snooze_hour": 

204 return _snooze(engine, CommandType.SNOOZE, context, language, dt.timedelta(hours=1)) 

205 if command == "silence": 

206 return _snooze(engine, CommandType.SILENCE, context, language) 

207 if command == "resume": 

208 return _snooze(engine, CommandType.NORMAL, context, language) 

209 return _say(language, "unknown_command", command=command) 

210 

211 

212def _minutes(spoken: str) -> str: 

213 """The number of minutes, without optional words the wildcard took in, like 'the next' or 'i prossimi'""" 

214 words: list[str] = spoken.split() 

215 while words and words[0].casefold() in MINUTES_PREFIXES: 

216 words.pop(0) 

217 return " ".join(words) 

218 

219 

220async def _notify(engine: SupernotifyEngine, name: str, message: str, context: HAContext, language: str) -> str: 

221 people = engine.context.people_registry 

222 target: list[str] | None = None 

223 if name.strip().casefold() not in EVERYONE: 

224 named = people.people_named(name) 

225 if len(named) > 1: 

226 choices = _say(language, "or").join(sorted(r.alias or r.name for r in named)) 

227 return _say(language, "which", name=name, choices=choices) 

228 if not named: 

229 known = ", ".join(sorted(r.alias or r.name for r in people.enabled_recipients())) or _say(language, "nobody_yet") 

230 return _say(language, "unknown_name", name=name, known=known) 

231 target = [named[0].entity_id] 

232 notification = await engine.async_send_message(message, target=target, context=context) 

233 if notification is None or not notification.delivered: 

234 return _say(language, "not_sent") 

235 return _say(language, "sent") if target is None else _say(language, "sent_to", name=name) 

236 

237 

238def _next_time(spoken: str, language: str = DEFAULT_LANGUAGE) -> dt.datetime | None: 

239 """The next time the clock shows this time, today or tomorrow 

240 

241 In English, 3:30 without am or pm is whichever 3:30 comes next, while 03:30 is always the morning. 

242 Italian uses the 24 hour clock, so 3:30 is always the morning there. 

243 """ 

244 spoken = spoken.strip() 

245 either_half_of_day = False 

246 if (match := MERIDIEM_TIME.fullmatch(spoken)) is not None: 

247 hour, minute = int(match.group(1)), int(match.group(2) or 0) 

248 if not 1 <= hour <= 12: 

249 return None 

250 hour = hour % 12 + (12 if match.group(3).lower() == "p" else 0) 

251 elif (match := CLOCK_TIME.fullmatch(spoken)) is not None: 

252 hour, minute = int(match.group(1)), int(match.group(2) or 0) 

253 either_half_of_day = language != "it" and not match.group(1).startswith("0") 

254 elif language != "it" and (match := HALF_PAST.fullmatch(spoken)) is not None: 

255 if match.group(1).casefold() not in HOURS_SAID: 

256 return None 

257 hour, minute, either_half_of_day = HOURS_SAID[match.group(1).casefold()], 30, True 

258 elif spoken.casefold() in NAMED_TIMES: 

259 hour, minute = NAMED_TIMES[spoken.casefold()], 0 

260 else: 

261 return None 

262 if hour > 23 or minute > 59: 

263 return None 

264 hours = [hour % 12, hour % 12 + 12] if either_half_of_day and 1 <= hour <= 12 else [hour] 

265 now = dt_util.as_local(dt_util.now()) 

266 return min(_next_occurrence(now, candidate, minute) for candidate in hours) 

267 

268 

269def _next_occurrence(now: dt.datetime, hour: int, minute: int) -> dt.datetime: 

270 until = now.replace(hour=hour, minute=minute, second=0, microsecond=0) 

271 return until if until > now else until + dt.timedelta(days=1) 

272 

273 

274def _snooze( 

275 engine: SupernotifyEngine, cmd: CommandType, context: HAContext, language: str, snooze_for: dt.timedelta | None = None 

276) -> str: 

277 """Snooze, silence or resume everything for the person asking, or for everyone if they aren't known""" 

278 person_id: str | None = engine.context.people_registry.person_id_for_user_id(context.user_id) 

279 recipient_type = RecipientType.USER if person_id else RecipientType.EVERYONE 

280 engine.context.snoozer.register_snooze( 

281 cmd, GlobalTargetType.EVERYTHING, None, recipient_type, person_id, snooze_for, reason="Voice command" 

282 ) 

283 whose = "yours" if person_id else "all" 

284 if cmd == CommandType.NORMAL: 

285 return _say(language, f"resumed_{whose}") 

286 if cmd == CommandType.SILENCE: 

287 return _say(language, f"silenced_{whose}") 

288 until = dt_util.as_local(dt_util.now() + (snooze_for or engine.context.snoozer.snooze_period)) 

289 return _say(language, f"snoozed_{whose}", until=_clock(until, language)) 

290 

291 

292def _clock(when: dt.datetime, language: str) -> str: 

293 """A time to say back, with am or pm in English so a wrong guess at the half of the day can be put right""" 

294 if language == "it": 

295 return when.strftime("%H:%M") 

296 return f"{when.hour % 12 or 12}:{when.minute:02d}{'am' if when.hour < 12 else 'pm'}" 

297 

298 

299def _last(engine: SupernotifyEngine, language: str) -> str: 

300 notification = engine.last_notification 

301 if notification is None: 

302 return _say(language, "no_last") 

303 when = dt_util.as_local(notification.created).strftime("%H:%M") 

304 sent = [name for name, outcomes in notification.deliveries.items() if outcomes.get(EnvelopeOutcome.SUCCESS)] 

305 if sent: 

306 return _say(language, "last_sent", when=when, message=notification.message, sent=", ".join(sent)) 

307 return _say(language, "last_not_sent", when=when, message=notification.message)