From 6b5adbe37c2a2bd3e4bee8d3b4b3865dc5ba3f27 Mon Sep 17 00:00:00 2001 From: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> Date: Fri, 13 Feb 2026 05:40:50 +0100 Subject: [PATCH 01/12] Add Coin Flipper & Decision Maker A versatile decision-making assistant designed to help you choose between options or test your luck. Unlike simple randomizers, this ability features **Smart Memory** (context awareness) to repeat actions instantly and understands a wide range of natural language commands. Signed-off-by: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> --- community/Coin Flipper | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 community/Coin Flipper diff --git a/community/Coin Flipper b/community/Coin Flipper new file mode 100644 index 00000000..e69de29b From a8c930916708bfad31f06116e6f9699bcffb55f6 Mon Sep 17 00:00:00 2001 From: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> Date: Fri, 13 Feb 2026 05:44:32 +0100 Subject: [PATCH 02/12] Delete community/Coin Flipper Signed-off-by: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> --- community/Coin Flipper | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 community/Coin Flipper diff --git a/community/Coin Flipper b/community/Coin Flipper deleted file mode 100644 index e69de29b..00000000 From d5dc88a03e00b425dffb243f6edbc6351877b175 Mon Sep 17 00:00:00 2001 From: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> Date: Fri, 13 Feb 2026 05:45:29 +0100 Subject: [PATCH 03/12] Create __init__.py Signed-off-by: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> --- community/Coin Flipper/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 community/Coin Flipper/__init__.py diff --git a/community/Coin Flipper/__init__.py b/community/Coin Flipper/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/community/Coin Flipper/__init__.py @@ -0,0 +1 @@ + From d2cbe64676ce75a2dc5ed73d10d8fd7c09754215 Mon Sep 17 00:00:00 2001 From: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> Date: Fri, 13 Feb 2026 05:46:36 +0100 Subject: [PATCH 04/12] Add Coin Flipper & Decision Maker A versatile decision-making assistant designed to help you choose between options or test your luck. Unlike simple randomizers, this ability features **Smart Memory** (context awareness) to repeat actions instantly and understands a wide range of natural language commands. Signed-off-by: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> --- community/Coin Flipper/config.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 community/Coin Flipper/config.json diff --git a/community/Coin Flipper/config.json b/community/Coin Flipper/config.json new file mode 100644 index 00000000..b15d2b6c --- /dev/null +++ b/community/Coin Flipper/config.json @@ -0,0 +1,8 @@ +{ + "unique_name":"advisor", + "matching_hotwords":[ + "give me advise", + "advise me", + "advice time" + ] +} From 72667067b52f837f29e5251afeb91a200fb63f66 Mon Sep 17 00:00:00 2001 From: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> Date: Fri, 13 Feb 2026 05:47:15 +0100 Subject: [PATCH 05/12] Add Coin Flipper & Decision Maker A versatile decision-making assistant designed to help you choose between options or test your luck. Unlike simple randomizers, this ability features **Smart Memory** (context awareness) to repeat actions instantly and understands a wide range of natural language commands. Signed-off-by: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> --- community/Coin Flipper/README.md | 62 +++++++++++++++ community/Coin Flipper/main.py | 126 +++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 community/Coin Flipper/README.md create mode 100644 community/Coin Flipper/main.py diff --git a/community/Coin Flipper/README.md b/community/Coin Flipper/README.md new file mode 100644 index 00000000..70b59fa6 --- /dev/null +++ b/community/Coin Flipper/README.md @@ -0,0 +1,62 @@ +# Coin Flipper & Decision Maker + +![Community](https://img.shields.io/badge/OpenHome-Community-green?style=flat-square) + +A versatile decision-making assistant designed to help you choose between options or test your luck. Unlike simple randomizers, this ability features **Smart Memory** (context awareness) to repeat actions instantly and understands a wide range of natural language commands. + +## Trigger Words + +Based on the dashboard configuration: + +- "Coin toss" +- "Help me decide" +- "Pick for me" +- "Flip coin" +- "Coin tossing" + +## Setup + +No setup required. No external APIs needed. +The ability uses Python's standard `random` library for logic and supports a 1% "Easter Egg" chance. + +## How It Works + +1. **Activation:** User triggers the Ability with a hotword (e.g., "Help me decide"). +2. **Intent Detection:** The AI asks what to do. It scans the user's response against a rich vocabulary list: + - *Decision commands:* "decide", "choice", "pick", "select", "option", "solve". + - *Coin commands:* "flip", "toss", "throw", "heads", "tails", "play". + - *Exit commands:* "bye", "enough", "that's all", "finish", "no thanks". +3. **Execution:** + - **Flip Mode:** Randomly selects Heads or Tails (with a 1% chance to land on the Edge). + - **Decide Mode:** Asks for Option 1 and Option 2, then picks a winner. +4. **Smart Repeat:** If the user says "Again", "One more time", or "Repeat", the AI remembers the last mode. If it was a decision, it re-rolls between the previously saved options without asking for them again. +5. **Exit:** The interaction ends when the user uses any natural closing phrase. + +## Key SDK Functions Used + +- `speak()` — Text-to-speech output for results and prompts. +- `run_io_loop()` — Handles the core interaction: speaking a prompt and immediately opening the microphone to capture user intent. +- `resume_normal_flow()` — Cleanly exits the ability loop and returns control to the main Personality. + +## Example Conversation + +> **User:** "Help me decide" +> **AI:** "I am ready. I can help you pick an option, or just toss a coin. What would you like to do?" +> +> **User:** "Pick for me" +> **AI:** "Okay, I will help you decide. Tell me the choices. What is the first option?" +> +> **User:** "Order Pizza" +> **AI:** "And what is the second option?" +> +> **User:** "Make a Salad" +> **AI:** "That is hard... But I choose... Order Pizza!" +> +> **User:** "Do it again" +> **AI:** *[Smart Memory Triggered]* "Choosing again between Order Pizza and Make a Salad... The winner is Make a Salad!" +> +> **User:** "Just flip a coin now" +> **AI:** "Tossing the coin high in the air... It is Heads!" +> +> **User:** "That's all, thanks" +> **AI:** "Okay. See you later!" \ No newline at end of file diff --git a/community/Coin Flipper/main.py b/community/Coin Flipper/main.py new file mode 100644 index 00000000..ca2de8cf --- /dev/null +++ b/community/Coin Flipper/main.py @@ -0,0 +1,126 @@ +import json +import random +import asyncio +from src.agent.capability import MatchingCapability +from src.main import AgentWorker +from src.agent.capability_worker import CapabilityWorker + +class CoinFlipperCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + #{{register capability}} + + async def run_coin_logic(self): + """ + Logic with 'Repeat Last Action' feature: + 1. Remember last action (flip or decide). + 2. Remember last options for decision. + 3. Handle 'Again' commands naturally. + """ + + # Greeting + await self.capability_worker.speak("I am ready. I can help you pick an option, or just toss a coin.") + + # --- MEMORY VARIABLES (To remember what we did last) --- + last_mode = None # Can be 'flip' or 'decide' + saved_opt1 = None # To remember option 1 + saved_opt2 = None # To remember option 2 + + while True: + user_input = "" + + # --- LISTENING BLOCK --- + try: + user_input = await self.capability_worker.run_io_loop("What would you like to do?") + except Exception: + await self.capability_worker.speak("I did not hear anything. Are you still there?") + continue + + if not user_input: + await self.capability_worker.speak("I heard silence. Please say flip, decide, or stop.") + continue + + text = user_input.lower() + + # --- PHRASE LISTS --- + exit_phrases = ["stop", "exit", "quit", "bye", "goodbye", "done", "finish", "no thanks"] + + decide_phrases = ["decide", "choice", "choose", "pick", "select", "option"] + + flip_phrases = ["flip", "coin", "toss", "throw", "heads", "tails", "play"] + + # New: Repeat phrases + repeat_phrases = ["again", "one more time", "repeat", "once more", "another one", "do it again"] + + # --- LOGIC --- + + # 1. EXIT + if any(word in text for word in exit_phrases): + await self.capability_worker.speak("Okay. See you later!") + break + + # 2. REPEAT LOGIC (Smart Handling) + # Если пользователь просит повторить, мы подменяем его команду или выполняем действие сразу + elif any(word in text for word in repeat_phrases): + if last_mode == "flip": + # Если прошлый раз кидали монетку, притворяемся, что юзер сказал "flip" + text = "flip" + # Код пойдет дальше и попадет в блок 'elif ... flip_phrases' + + elif last_mode == "decide": + # Если прошлый раз выбирали, используем сохраненные варианты (Smart Repeat) + if saved_opt1 and saved_opt2: + winner = random.choice([saved_opt1, saved_opt2]) + await self.capability_worker.speak(f"Choosing again between {saved_opt1} and {saved_opt2}... The winner is {winner}!") + continue # Пропускаем остальной код и начинаем новый круг + else: + # Если вариантов в памяти нет, просто запускаем режим выбора заново + text = "decide" + else: + await self.capability_worker.speak("I haven't done anything yet to repeat.") + continue + + # 3. DECISION MODE + # Обратите внимание: мы используем 'if' здесь (вместо elif), если text был изменен блоком Repeat + if any(word in text for word in decide_phrases): + try: + # Natural question + opt1 = await self.capability_worker.run_io_loop("Okay, I will help you decide. Tell me the choices. What is the first option?") + if not opt1: opt1 = "Option A" + + opt2 = await self.capability_worker.run_io_loop("And what is the second option?") + if not opt2: opt2 = "Option B" + + # Save to memory + saved_opt1 = opt1 + saved_opt2 = opt2 + last_mode = "decide" + + winner = random.choice([opt1, opt2]) + await self.capability_worker.speak(f"That is hard... But I choose... {winner}!") + except Exception: + await self.capability_worker.speak("Sorry, I had trouble hearing the options. Let's try again.") + + # 4. COIN FLIP MODE + elif any(word in text for word in flip_phrases): + # Save state + last_mode = "flip" + + chance = random.randint(1, 100) + if chance == 1: + await self.capability_worker.speak("Tossing... Oh my god! It landed on its SIDE! That is impossible!") + else: + result = random.choice(["Heads", "Tails"]) + await self.capability_worker.speak(f"Tossing the coin high in the air... It is {result}!") + + # 5. UNKNOWN PHRASE (Only triggers if text wasn't "flip" or "decide") + elif not any(word in text for word in repeat_phrases): + await self.capability_worker.speak("I did not understand. Please say 'flip', 'decide' or 'stop'.") + + self.capability_worker.resume_normal_flow() + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.worker.session_tasks.create(self.run_coin_logic()) \ No newline at end of file From a6606e6d490a92e5d4773f4faffbab3ac72d45a1 Mon Sep 17 00:00:00 2001 From: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> Date: Fri, 13 Feb 2026 05:58:50 +0100 Subject: [PATCH 06/12] Delete community/Coin Flipper directory Signed-off-by: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> --- community/Coin Flipper/README.md | 62 -------------- community/Coin Flipper/__init__.py | 1 - community/Coin Flipper/config.json | 8 -- community/Coin Flipper/main.py | 126 ----------------------------- 4 files changed, 197 deletions(-) delete mode 100644 community/Coin Flipper/README.md delete mode 100644 community/Coin Flipper/__init__.py delete mode 100644 community/Coin Flipper/config.json delete mode 100644 community/Coin Flipper/main.py diff --git a/community/Coin Flipper/README.md b/community/Coin Flipper/README.md deleted file mode 100644 index 70b59fa6..00000000 --- a/community/Coin Flipper/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# Coin Flipper & Decision Maker - -![Community](https://img.shields.io/badge/OpenHome-Community-green?style=flat-square) - -A versatile decision-making assistant designed to help you choose between options or test your luck. Unlike simple randomizers, this ability features **Smart Memory** (context awareness) to repeat actions instantly and understands a wide range of natural language commands. - -## Trigger Words - -Based on the dashboard configuration: - -- "Coin toss" -- "Help me decide" -- "Pick for me" -- "Flip coin" -- "Coin tossing" - -## Setup - -No setup required. No external APIs needed. -The ability uses Python's standard `random` library for logic and supports a 1% "Easter Egg" chance. - -## How It Works - -1. **Activation:** User triggers the Ability with a hotword (e.g., "Help me decide"). -2. **Intent Detection:** The AI asks what to do. It scans the user's response against a rich vocabulary list: - - *Decision commands:* "decide", "choice", "pick", "select", "option", "solve". - - *Coin commands:* "flip", "toss", "throw", "heads", "tails", "play". - - *Exit commands:* "bye", "enough", "that's all", "finish", "no thanks". -3. **Execution:** - - **Flip Mode:** Randomly selects Heads or Tails (with a 1% chance to land on the Edge). - - **Decide Mode:** Asks for Option 1 and Option 2, then picks a winner. -4. **Smart Repeat:** If the user says "Again", "One more time", or "Repeat", the AI remembers the last mode. If it was a decision, it re-rolls between the previously saved options without asking for them again. -5. **Exit:** The interaction ends when the user uses any natural closing phrase. - -## Key SDK Functions Used - -- `speak()` — Text-to-speech output for results and prompts. -- `run_io_loop()` — Handles the core interaction: speaking a prompt and immediately opening the microphone to capture user intent. -- `resume_normal_flow()` — Cleanly exits the ability loop and returns control to the main Personality. - -## Example Conversation - -> **User:** "Help me decide" -> **AI:** "I am ready. I can help you pick an option, or just toss a coin. What would you like to do?" -> -> **User:** "Pick for me" -> **AI:** "Okay, I will help you decide. Tell me the choices. What is the first option?" -> -> **User:** "Order Pizza" -> **AI:** "And what is the second option?" -> -> **User:** "Make a Salad" -> **AI:** "That is hard... But I choose... Order Pizza!" -> -> **User:** "Do it again" -> **AI:** *[Smart Memory Triggered]* "Choosing again between Order Pizza and Make a Salad... The winner is Make a Salad!" -> -> **User:** "Just flip a coin now" -> **AI:** "Tossing the coin high in the air... It is Heads!" -> -> **User:** "That's all, thanks" -> **AI:** "Okay. See you later!" \ No newline at end of file diff --git a/community/Coin Flipper/__init__.py b/community/Coin Flipper/__init__.py deleted file mode 100644 index 8b137891..00000000 --- a/community/Coin Flipper/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/community/Coin Flipper/config.json b/community/Coin Flipper/config.json deleted file mode 100644 index b15d2b6c..00000000 --- a/community/Coin Flipper/config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "unique_name":"advisor", - "matching_hotwords":[ - "give me advise", - "advise me", - "advice time" - ] -} diff --git a/community/Coin Flipper/main.py b/community/Coin Flipper/main.py deleted file mode 100644 index ca2de8cf..00000000 --- a/community/Coin Flipper/main.py +++ /dev/null @@ -1,126 +0,0 @@ -import json -import random -import asyncio -from src.agent.capability import MatchingCapability -from src.main import AgentWorker -from src.agent.capability_worker import CapabilityWorker - -class CoinFlipperCapability(MatchingCapability): - worker: AgentWorker = None - capability_worker: CapabilityWorker = None - - #{{register capability}} - - async def run_coin_logic(self): - """ - Logic with 'Repeat Last Action' feature: - 1. Remember last action (flip or decide). - 2. Remember last options for decision. - 3. Handle 'Again' commands naturally. - """ - - # Greeting - await self.capability_worker.speak("I am ready. I can help you pick an option, or just toss a coin.") - - # --- MEMORY VARIABLES (To remember what we did last) --- - last_mode = None # Can be 'flip' or 'decide' - saved_opt1 = None # To remember option 1 - saved_opt2 = None # To remember option 2 - - while True: - user_input = "" - - # --- LISTENING BLOCK --- - try: - user_input = await self.capability_worker.run_io_loop("What would you like to do?") - except Exception: - await self.capability_worker.speak("I did not hear anything. Are you still there?") - continue - - if not user_input: - await self.capability_worker.speak("I heard silence. Please say flip, decide, or stop.") - continue - - text = user_input.lower() - - # --- PHRASE LISTS --- - exit_phrases = ["stop", "exit", "quit", "bye", "goodbye", "done", "finish", "no thanks"] - - decide_phrases = ["decide", "choice", "choose", "pick", "select", "option"] - - flip_phrases = ["flip", "coin", "toss", "throw", "heads", "tails", "play"] - - # New: Repeat phrases - repeat_phrases = ["again", "one more time", "repeat", "once more", "another one", "do it again"] - - # --- LOGIC --- - - # 1. EXIT - if any(word in text for word in exit_phrases): - await self.capability_worker.speak("Okay. See you later!") - break - - # 2. REPEAT LOGIC (Smart Handling) - # Если пользователь просит повторить, мы подменяем его команду или выполняем действие сразу - elif any(word in text for word in repeat_phrases): - if last_mode == "flip": - # Если прошлый раз кидали монетку, притворяемся, что юзер сказал "flip" - text = "flip" - # Код пойдет дальше и попадет в блок 'elif ... flip_phrases' - - elif last_mode == "decide": - # Если прошлый раз выбирали, используем сохраненные варианты (Smart Repeat) - if saved_opt1 and saved_opt2: - winner = random.choice([saved_opt1, saved_opt2]) - await self.capability_worker.speak(f"Choosing again between {saved_opt1} and {saved_opt2}... The winner is {winner}!") - continue # Пропускаем остальной код и начинаем новый круг - else: - # Если вариантов в памяти нет, просто запускаем режим выбора заново - text = "decide" - else: - await self.capability_worker.speak("I haven't done anything yet to repeat.") - continue - - # 3. DECISION MODE - # Обратите внимание: мы используем 'if' здесь (вместо elif), если text был изменен блоком Repeat - if any(word in text for word in decide_phrases): - try: - # Natural question - opt1 = await self.capability_worker.run_io_loop("Okay, I will help you decide. Tell me the choices. What is the first option?") - if not opt1: opt1 = "Option A" - - opt2 = await self.capability_worker.run_io_loop("And what is the second option?") - if not opt2: opt2 = "Option B" - - # Save to memory - saved_opt1 = opt1 - saved_opt2 = opt2 - last_mode = "decide" - - winner = random.choice([opt1, opt2]) - await self.capability_worker.speak(f"That is hard... But I choose... {winner}!") - except Exception: - await self.capability_worker.speak("Sorry, I had trouble hearing the options. Let's try again.") - - # 4. COIN FLIP MODE - elif any(word in text for word in flip_phrases): - # Save state - last_mode = "flip" - - chance = random.randint(1, 100) - if chance == 1: - await self.capability_worker.speak("Tossing... Oh my god! It landed on its SIDE! That is impossible!") - else: - result = random.choice(["Heads", "Tails"]) - await self.capability_worker.speak(f"Tossing the coin high in the air... It is {result}!") - - # 5. UNKNOWN PHRASE (Only triggers if text wasn't "flip" or "decide") - elif not any(word in text for word in repeat_phrases): - await self.capability_worker.speak("I did not understand. Please say 'flip', 'decide' or 'stop'.") - - self.capability_worker.resume_normal_flow() - - def call(self, worker: AgentWorker): - self.worker = worker - self.capability_worker = CapabilityWorker(self.worker) - self.worker.session_tasks.create(self.run_coin_logic()) \ No newline at end of file From f725b4a999d3a122fc744e779a78fca336c43d69 Mon Sep 17 00:00:00 2001 From: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> Date: Fri, 13 Feb 2026 05:59:43 +0100 Subject: [PATCH 07/12] Create __init__.py Signed-off-by: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> --- community/coin-flipper/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 community/coin-flipper/__init__.py diff --git a/community/coin-flipper/__init__.py b/community/coin-flipper/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/community/coin-flipper/__init__.py @@ -0,0 +1 @@ + From 2b53781b5fd687f06920f693e234ac2299d6decf Mon Sep 17 00:00:00 2001 From: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:09:59 +0100 Subject: [PATCH 08/12] Delete community/coin-flipper directory Signed-off-by: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> --- community/coin-flipper/__init__.py | 1 - 1 file changed, 1 deletion(-) delete mode 100644 community/coin-flipper/__init__.py diff --git a/community/coin-flipper/__init__.py b/community/coin-flipper/__init__.py deleted file mode 100644 index 8b137891..00000000 --- a/community/coin-flipper/__init__.py +++ /dev/null @@ -1 +0,0 @@ - From 856b7dab646173205eb07e4adb79e17d570072bc Mon Sep 17 00:00:00 2001 From: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:11:09 +0100 Subject: [PATCH 09/12] Create __init__.py Signed-off-by: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> --- community/Twilio-SMS/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 community/Twilio-SMS/__init__.py diff --git a/community/Twilio-SMS/__init__.py b/community/Twilio-SMS/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/community/Twilio-SMS/__init__.py @@ -0,0 +1 @@ + From 538a00e3f88d83f277ebaf0c39ed7bff84e87b60 Mon Sep 17 00:00:00 2001 From: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> Date: Sat, 21 Feb 2026 20:18:23 +0100 Subject: [PATCH 10/12] feat: add Twilio SMS Messenger ability (V1) Introduces the Twilio SMS Messenger capability as requested in the V1 brief. Features implemented: - Voice-to-SMS: Send messages with exact and fuzzy LLM contact resolution. - SMS-to-Voice: Read inbound messages, expand TTS abbreviations, replace URLs, and read only the last 4 digits of unknown numbers. - Contact Management: Add, remove, and list contacts purely by voice. - Utilities: Check message delivery status and Twilio account balance. Quality & Security: - "Smart" `load_prefs()` generates an empty config on first run without overwriting user keys on subsequent runs. - 100% compliant with OpenHome SDK guidelines: `resume_normal_flow()` is guaranteed in a `finally` block, zero `print()` statements (uses `editor_logging_handler`), 15s timeout for all `requests`, and no hardcoded secrets. - PEP8 formatted and includes complete README.md with setup instructions. Signed-off-by: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> --- community/Twilio-SMS/README.md | 53 +++ community/Twilio-SMS/config.json | 8 + community/Twilio-SMS/main.py | 611 +++++++++++++++++++++++++++++++ 3 files changed, 672 insertions(+) create mode 100644 community/Twilio-SMS/README.md create mode 100644 community/Twilio-SMS/config.json create mode 100644 community/Twilio-SMS/main.py diff --git a/community/Twilio-SMS/README.md b/community/Twilio-SMS/README.md new file mode 100644 index 00000000..38811954 --- /dev/null +++ b/community/Twilio-SMS/README.md @@ -0,0 +1,53 @@ +This is a basic capability template. +# Twilio SMS Messenger + +A powerful, hands-free texting assistant that lets you send SMS messages, read incoming texts, manage your contacts, and check delivery statuses using the Twilio REST API. It turns your OpenHome speaker into a two-way voice-to-SMS bridge. + +## Trigger Words +- "send a text" +- "text message" +- "send message" +- "read my texts" +- "check messages" + +## Setup +This Ability requires a Twilio account and an active Twilio phone number. +1. Create an account at [twilio.com](https://www.twilio.com) and purchase an SMS-capable phone number. +2. Run the Ability for the first time. It will automatically generate a `twilio_sms_prefs.json` file in your Ability's directory. +3. Open `twilio_sms_prefs.json` and fill in your credentials: + - `account_sid`: Your Twilio Account SID. + - `auth_token`: Your Twilio Auth Token. + - `twilio_number`: Your Twilio phone number (in E.164 format, e.g., `+12345678900`). +4. Save the file. You can now use voice commands to add contacts or manually add them to the `contacts` dictionary in the JSON file. + +## How It Works +1. User triggers the Ability with a hotword. +2. The Ability welcomes the user and asks what they would like to do. +3. User states their intent (e.g., "Send a text to John", "Read my messages", "Add a contact"). +4. The Ability uses the LLM to classify the intent and extract necessary data (contact names, message body, phone numbers). +5. For sending: It resolves the contact using exact or fuzzy LLM matching, asks for confirmation, and executes a POST request to the Twilio API. +6. For reading: It polls the Twilio API for incoming messages, expands common SMS abbreviations for Text-to-Speech (TTS), limits reading to the latest unread messages, and safely reads out the contents. +7. User can continue giving commands or say "stop" to exit. + +## Key SDK Functions Used +- `speak()` — Text-to-speech output to talk to the user and read messages. +- `user_response()` — Listen for user commands and confirmations. +- `text_to_text_response()` — LLM text generation used for intent routing, data extraction, and fuzzy contact resolution (called synchronously without `await`). +- `session_tasks.create()` — Safely runs the main asynchronous interaction loop. +- `editor_logging_handler.error()` — Safe logging of API or internal errors without using `print()`. +- `resume_normal_flow()` — Safely returns control to the Personality, guaranteed to execute via a `finally` block. + +## Example Conversation + +**User:** "Send a text" +**AI:** "Twilio SMS is ready. What would you like to do?" +**User:** "Send a text to John saying I will be there in 5 minutes" +**AI:** "I'll text john: 'I will be there in 5 minutes'. Should I send it?" +**User:** "Yes" +**AI:** "Sending... Message sent to john. Anything else?" +**User:** "Did my text go through?" +**AI:** "Checking delivery status... Your message was delivered. Anything else?" +**User:** "Read my texts" +**AI:** "Checking your messages... You have 1 new message. First, from john today at 5:30 PM: Okay, see you soon. Anything else?" +**User:** "Stop" +**AI:** "Goodbye." \ No newline at end of file diff --git a/community/Twilio-SMS/config.json b/community/Twilio-SMS/config.json new file mode 100644 index 00000000..b15d2b6c --- /dev/null +++ b/community/Twilio-SMS/config.json @@ -0,0 +1,8 @@ +{ + "unique_name":"advisor", + "matching_hotwords":[ + "give me advise", + "advise me", + "advice time" + ] +} diff --git a/community/Twilio-SMS/main.py b/community/Twilio-SMS/main.py new file mode 100644 index 00000000..0e34ce59 --- /dev/null +++ b/community/Twilio-SMS/main.py @@ -0,0 +1,611 @@ +import json +import os +import re +from datetime import datetime, timezone + +import requests +from requests.auth import HTTPBasicAuth + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + + +class TwilioSmsCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + prefs_file: str = "twilio_sms_prefs.json" + prefs: dict = None + + @classmethod + def register_capability(cls) -> "MatchingCapability": + """Registers the capability by loading config from config.json.""" + with open( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") + ) as file: + data = json.load(file) + return cls( + unique_name=data["unique_name"], + matching_hotwords=data["matching_hotwords"], + ) + + def load_prefs(self): + """Safe loading of preferences without overwriting existing data.""" + self.prefs = {} + try: + with open(self.prefs_file, "r") as f: + self.prefs = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + # If the file does not exist (first run), create a clean template + self.prefs = { + "account_sid": "", + "auth_token": "", + "twilio_number": "", + "contacts": {}, + "default_country_code": "+1", + "confirm_before_send": True + } + self.save_prefs() + return + + # If the file exists, verify the structure without touching user keys + needs_save = False + + if "contacts" not in self.prefs: + self.prefs["contacts"] = {} + needs_save = True + + if "default_country_code" not in self.prefs: + self.prefs["default_country_code"] = "+1" + needs_save = True + + if "confirm_before_send" not in self.prefs: + self.prefs["confirm_before_send"] = True + needs_save = True + + if needs_save: + self.save_prefs() + + def save_prefs(self): + """Save preferences to the JSON file.""" + if self.prefs is None: + self.prefs = {} + try: + with open(self.prefs_file, "w") as f: + json.dump(self.prefs, f, indent=2) + except Exception: + pass + + def twilio_request(self, method, path, data=None, params=None): + """Execute an authenticated request to the Twilio REST API.""" + account_sid = self.prefs.get("account_sid") + auth_token = self.prefs.get("auth_token") + + if not account_sid or not auth_token: + return {"error": "missing_credentials"} + + url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/{path}" + auth = HTTPBasicAuth(account_sid, auth_token) + + try: + if method == "GET": + resp = requests.get(url, auth=auth, params=params, timeout=15) + elif method == "POST": + resp = requests.post(url, auth=auth, data=data, timeout=15) + else: + return {"error": f"unsupported method {method}"} + + if resp.status_code in (200, 201, 204): + try: + return resp.json() + except Exception: + return {"status": "ok"} + else: + error_data = resp.json() if resp.text else {} + return { + "error": f"http_{resp.status_code}", + "message": error_data.get("message", "Unknown Twilio Error"), + "code": error_data.get("code", 0) + } + except Exception as e: + return {"error": str(e)} + + def send_sms(self, to_number, body): + """Send an SMS via the Twilio API.""" + data = { + "From": self.prefs.get("twilio_number", ""), + "To": to_number, + "Body": body, + } + return self.twilio_request("POST", "Messages.json", data=data) + + def extract_json_from_llm(self, text): + """Clean markdown formatting from the LLM response and parse the JSON.""" + clean_text = text.strip() + if clean_text.startswith("```json"): + clean_text = clean_text[7:] + if clean_text.startswith("```"): + clean_text = clean_text[3:] + if clean_text.endswith("```"): + clean_text = clean_text[:-3] + try: + return json.loads(clean_text.strip()) + except Exception: + return {} + + def clean_message_for_voice(self, body): + """Clean SMS text and expand abbreviations for Text-to-Speech (TTS).""" + replacements = { + "lol": "L O L", "omg": "O M G", "btw": "by the way", + "imo": "in my opinion", "idk": "I don't know", "tbh": "to be honest", + "fyi": "for your information", "brb": "be right back", "rn": "right now", + "nvm": "never mind", "lmk": "let me know", "ty": "thank you", + "np": "no problem", "ur": "your", "u": "you", "r": "are", "k": "okay" + } + words = body.split() + cleaned = [] + for word in words: + lower = word.lower().strip('.,!?') + if lower in replacements: + cleaned.append(replacements[lower]) + else: + cleaned.append(word) + result = " ".join(cleaned) + + # Replace URLs with spoken equivalent + result = re.sub(r'https?://\S+', 'a link', result) + + # Truncate long messages + if len(result) > 500: + result = result[:500] + "... message truncated." + return result + + def format_message_time(self, date_string): + """Convert a Twilio date string into a human-readable format.""" + try: + dt = datetime.strptime(date_string, "%a, %d %b %Y %H:%M:%S %z") + now = datetime.now(timezone.utc) + delta = now - dt + hour = dt.strftime("%I:%M %p").lstrip("0") + + if delta.days == 0: + return f"today at {hour}" + elif delta.days == 1: + return f"yesterday at {hour}" + elif delta.days < 7: + day = dt.strftime("%A") + return f"on {day} at {hour}" + else: + return f"on {dt.strftime('%B %d')}" + except Exception: + return "recently" + + def format_delivery_status(self, status): + """Translate Twilio system status into a voice-friendly text.""" + status_map = { + "queued": "Your message is waiting to be sent.", + "sending": "Your message is being sent right now.", + "sent": "Your message was sent, but I haven't gotten delivery confirmation yet.", + "delivered": "Your message was delivered.", + "failed": "Your message failed to send.", + "undelivered": "Your message couldn't be delivered. The number might be wrong or they may have opted out.", + } + return status_map.get(status, f"Message status is: {status}") + + def normalize_phone_number(self, raw_number): + """Normalize a spoken phone number to E.164 format.""" + digits = re.sub(r'[^\d+]', '', raw_number) + + if digits.startswith('+'): + return digits if len(digits) >= 11 else None + + if len(digits) == 10: + default_cc = self.prefs.get("default_country_code", "+1") + return f"{default_cc}{digits}" + + if len(digits) == 11 and digits.startswith('1'): + return f"+{digits}" + + return None + + def resolve_contact(self, spoken_name): + """Smart contact search (Exact match first, then LLM fuzzy search).""" + contacts = self.prefs.get("contacts", {}) + lower_name = spoken_name.lower().strip() + + # 1. Exact match + for name, number in contacts.items(): + if name.lower() == lower_name: + return {"name": name, "number": number} + + # 2. Fuzzy search via LLM + if contacts: + contact_list = ", ".join(contacts.keys()) + prompt = f"""Match the spoken name to the closest contact. + User said: "{spoken_name}" + Available contacts: {contact_list} + Return ONLY the exact contact name from the list, or "none" if no match.""" + + result = self.capability_worker.text_to_text_response(prompt) + clean = result.strip().strip('"').lower() + for name, number in contacts.items(): + if name.lower() == clean: + return {"name": name, "number": number} + return None + + # --- CONTACT MANAGEMENT HANDLERS --- + + async def handle_add_contact(self, user_input): + """Logic for adding a new contact.""" + extract_prompt = f"""Extract the contact name and phone number from the user's input. + User said: "{user_input}" + IMPORTANT: Format the phone number as digits only (convert words to digits). + Return ONLY valid JSON: {{"name": "contact name", "number": "phone number"}} + If either is missing, return an empty string.""" + + llm_response = self.capability_worker.text_to_text_response(extract_prompt) + parsed_data = self.extract_json_from_llm(llm_response) + + name = parsed_data.get("name", "").lower() + raw_number = parsed_data.get("number", "") + + if not name or not raw_number: + await self.capability_worker.speak("I didn't catch the name or the phone number. Try saying, 'Add Sarah with number 555 123 4567'.") + return + + clean_number = self.normalize_phone_number(raw_number) + if not clean_number: + await self.capability_worker.speak(f"The number {raw_number} doesn't look like a valid phone number.") + return + + await self.capability_worker.speak(f"I will save {name} as {clean_number}. Is that correct?") + confirm = await self.capability_worker.user_response() + + if confirm and ("yes" in confirm.lower() or "sure" in confirm.lower() or "ok" in confirm.lower() or "right" in confirm.lower()): + contacts = self.prefs.get("contacts", {}) + contacts[name] = clean_number + self.prefs["contacts"] = contacts + self.save_prefs() + await self.capability_worker.speak(f"{name} has been added to your contacts.") + else: + await self.capability_worker.speak("Okay, I canceled it.") + + async def handle_remove_contact(self, user_input): + """Logic for removing a contact.""" + contacts = self.prefs.get("contacts", {}) + if not contacts: + await self.capability_worker.speak("You don't have any contacts saved yet.") + return + + contact_names = ", ".join(contacts.keys()) + extract_prompt = f"""Extract the contact name the user wants to remove. + User said: "{user_input}" + Known contacts: {contact_names} + Return ONLY valid JSON: {{"name": "contact name"}}""" + + llm_response = self.capability_worker.text_to_text_response(extract_prompt) + parsed_data = self.extract_json_from_llm(llm_response) + + name = parsed_data.get("name", "").lower() + if not name or name not in contacts: + await self.capability_worker.speak(f"I couldn't find {name} in your contacts. You currently have: {contact_names}.") + return + + await self.capability_worker.speak(f"Are you sure you want to remove {name} from your contacts?") + confirm = await self.capability_worker.user_response() + + if confirm and ("yes" in confirm.lower() or "sure" in confirm.lower() or "remove" in confirm.lower() or "delete" in confirm.lower()): + del contacts[name] + self.prefs["contacts"] = contacts + self.save_prefs() + await self.capability_worker.speak(f"{name} has been removed.") + else: + await self.capability_worker.speak(f"Okay, {name} was not removed.") + + async def handle_list_contacts(self): + """Logic for listing all saved contacts.""" + contacts = self.prefs.get("contacts", {}) + if not contacts: + await self.capability_worker.speak("You don't have any contacts saved yet.") + return + + names = list(contacts.keys()) + if len(names) == 1: + await self.capability_worker.speak(f"You have 1 contact: {names[0]}.") + else: + names_str = ", ".join(names[:-1]) + ", and " + names[-1] + await self.capability_worker.speak(f"You have {len(names)} contacts: {names_str}.") + + # --- EXISTING HANDLERS --- + + async def handle_account_balance(self): + """Check the Twilio account balance.""" + await self.capability_worker.speak("Checking your Twilio balance...") + result = self.twilio_request("GET", "Balance.json") + + if "error" in result: + await self.capability_worker.speak("I couldn't retrieve your account balance.") + return + + balance = result.get("balance", "unknown") + currency = result.get("currency", "") + await self.capability_worker.speak(f"Your Twilio account balance is {balance} {currency}.") + + async def handle_read_from(self, user_input): + """Read recent messages from a specific contact.""" + extract_prompt = f"""The user wants to read texts from a specific person. Extract the sender's name. + User said: "{user_input}" + Return ONLY valid JSON: {{"sender": "contact name"}}""" + + llm_response = self.capability_worker.text_to_text_response(extract_prompt) + parsed_data = self.extract_json_from_llm(llm_response) + + sender_name_raw = parsed_data.get("sender", "").lower() + + if not sender_name_raw: + await self.capability_worker.speak("I couldn't figure out whose messages you want to read.") + return + + contact = self.resolve_contact(sender_name_raw) + if not contact: + await self.capability_worker.speak(f"I don't have a contact named {sender_name_raw}.") + return + + from_number = contact["number"] + sender_name = contact["name"] + + await self.capability_worker.speak(f"Checking messages from {sender_name}...") + + params = {"To": self.prefs.get("twilio_number", ""), "From": from_number, "PageSize": 5} + result = self.twilio_request("GET", "Messages.json", params=params) + + if "error" in result: + await self.capability_worker.speak(f"I couldn't fetch messages from {sender_name}.") + return + + messages = result.get("messages", []) + inbound = [m for m in messages if m.get("direction") == "inbound"] + + if not inbound: + await self.capability_worker.speak(f"You don't have any recent messages from {sender_name}.") + return + + messages_to_read = inbound[:5] + await self.capability_worker.speak(f"I found {len(messages_to_read)} recent messages from {sender_name}.") + + for i, msg in enumerate(messages_to_read): + time_display = self.format_message_time(msg.get("date_sent", "")) + body_clean = self.clean_message_for_voice(msg.get("body", "")) + prefix = "First" if i == 0 else "Next" + await self.capability_worker.speak(f"{prefix}, {time_display}: {body_clean}") + + async def handle_read_texts(self): + """Fetch and read the latest incoming SMS messages.""" + await self.capability_worker.speak("Checking your messages...") + + params = {"To": self.prefs.get("twilio_number", ""), "PageSize": 20} + result = self.twilio_request("GET", "Messages.json", params=params) + + if "error" in result: + await self.capability_worker.speak("I couldn't connect to Twilio to check your messages.") + return + + messages = result.get("messages", []) + inbound = [m for m in messages if m.get("direction") == "inbound"] + + if not inbound: + await self.capability_worker.speak("You don't have any incoming messages.") + return + + last_read_sid = self.prefs.get("last_read_sid") + new_messages = [] + for msg in inbound: + if msg["sid"] == last_read_sid: + break + new_messages.append(msg) + + messages_to_read = [] + is_new = True + + if not new_messages: + await self.capability_worker.speak("You have no new messages. Would you like me to read your older messages anyway?") + ans = await self.capability_worker.user_response() + if ans and ("yes" in ans.lower() or "sure" in ans.lower() or "read" in ans.lower() or "okay" in ans.lower()): + messages_to_read = inbound[:3] + is_new = False + await self.capability_worker.speak("Here are your last messages.") + else: + await self.capability_worker.speak("Okay.") + return + else: + messages_to_read = new_messages[:5] + if len(new_messages) == 1: + await self.capability_worker.speak("You have 1 new message.") + else: + await self.capability_worker.speak(f"You have {len(new_messages)} new messages. I'll read the latest {len(messages_to_read)}.") + + contacts = self.prefs.get("contacts", {}) + + for i, msg in enumerate(messages_to_read): + sender_number = msg.get("from", "") + contact_name = None + for name, num in contacts.items(): + if num == sender_number: + contact_name = name + break + + if contact_name: + sender_display = contact_name + else: + sender_display = f"an unknown number ending in {sender_number[-4:]}" + + time_display = self.format_message_time(msg.get("date_sent", "")) + body_clean = self.clean_message_for_voice(msg.get("body", "")) + + prefix = "First" if i == 0 else "Next" + + await self.capability_worker.speak(f"{prefix}, from {sender_display} {time_display}: {body_clean}") + + if is_new and new_messages: + self.prefs["last_read_sid"] = new_messages[0]["sid"] + self.save_prefs() + + async def handle_send_text(self, user_input): + """Parse recipient and body, and send an SMS.""" + contacts = self.prefs.get("contacts", {}) + contact_names = ", ".join(contacts.keys()) + + extract_prompt = f"""The user wants to send a text message. Extract the recipient and message body. + User said: "{user_input}" + Known contacts: {contact_names} + Return ONLY valid JSON: {{"recipient": "contact name", "body": "the message to send"}}""" + + llm_response = self.capability_worker.text_to_text_response(extract_prompt) + parsed_data = self.extract_json_from_llm(llm_response) + + recipient_name_raw = parsed_data.get("recipient", "").lower() + body = parsed_data.get("body", "") + + if not recipient_name_raw or not body: + await self.capability_worker.speak("I couldn't figure out who to send that to or what to say. Please try again.") + return + + contact = self.resolve_contact(recipient_name_raw) + if not contact: + await self.capability_worker.speak(f"I don't have a contact named {recipient_name_raw}. Please add them first.") + return + + to_number = contact["number"] + recipient_name = contact["name"] + + await self.capability_worker.speak(f"I'll text {recipient_name}: '{body}'. Should I send it?") + confirm_input = await self.capability_worker.user_response() + + if not confirm_input: + await self.capability_worker.speak("Message cancelled.") + return + + if "yes" in confirm_input.lower() or "send" in confirm_input.lower() or "sure" in confirm_input.lower() or "ok" in confirm_input.lower(): + await self.capability_worker.speak("Sending...") + result = self.send_sms(to_number, body) + + if "error" in result: + error_code = result.get("code") + if error_code == 21211: + await self.capability_worker.speak("That doesn't look like a valid phone number.") + elif error_code == 21610: + await self.capability_worker.speak("That number has opted out of receiving messages.") + elif error_code == 30005: + await self.capability_worker.speak("That number doesn't exist or can't receive texts.") + else: + await self.capability_worker.speak("Sorry, the message failed to send. Check your account balance or number.") + else: + if "sid" in result: + self.prefs["last_sent_sid"] = result["sid"] + self.save_prefs() + await self.capability_worker.speak(f"Message sent to {recipient_name}.") + else: + await self.capability_worker.speak("Okay, I won't send it.") + + async def handle_check_delivery(self): + """Check the delivery status of the last sent message.""" + last_sent_sid = self.prefs.get("last_sent_sid") + + if not last_sent_sid: + await self.capability_worker.speak("You haven't sent any messages recently that I can check.") + return + + await self.capability_worker.speak("Checking delivery status...") + + result = self.twilio_request("GET", f"Messages/{last_sent_sid}.json") + + if "error" in result: + await self.capability_worker.speak("I couldn't check the status right now. Please try again later.") + return + + status = result.get("status", "unknown") + spoken_status = self.format_delivery_status(status) + + await self.capability_worker.speak(spoken_status) + + async def run(self): + """Main interaction loop for the capability.""" + try: + self.load_prefs() + + if not self.prefs.get("account_sid") or not self.prefs.get("auth_token") or not self.prefs.get("twilio_number"): + await self.capability_worker.speak("Twilio credentials are missing. Please configure your Account SID, Auth Token, and Twilio phone number in the preferences file.") + return + + await self.capability_worker.speak("Twilio SMS is ready. What would you like to do?") + + while True: + user_input = await self.capability_worker.user_response() + + if not user_input or not user_input.strip(): + await self.capability_worker.speak("Please say something like 'Send a text' or 'Read my messages'.") + continue + + lower_input = user_input.lower().strip() + + if lower_input in ["exit", "stop", "quit", "done"]: + await self.capability_worker.speak("Exiting. Goodbye.") + break + + classify_prompt = f"""You are a voice command router. Classify the user's intent based on their input. + Intents: + - send_text (e.g., "send a text to john", "message mom") + - read_texts (e.g., "read my messages", "check texts", "any new texts") + - read_from (e.g., "what did robot say", "read texts from john") + - check_delivery (e.g., "did my text go through", "was it delivered") + - account_balance (e.g., "how much Twilio credit", "account balance") + - add_contact (e.g., "add a contact", "save number") + - remove_contact (e.g., "remove contact", "delete john") + - list_contacts (e.g., "who is in my contacts", "show contacts") + - exit (e.g., "stop", "exit") + - unknown + + User said: "{user_input}" + Return ONLY valid JSON: {{"intent": "string"}}""" + + llm_response = self.capability_worker.text_to_text_response(classify_prompt) + parsed_intent = self.extract_json_from_llm(llm_response) + intent = parsed_intent.get("intent", "unknown") + + if intent == "send_text": + await self.handle_send_text(user_input) + elif intent == "read_texts": + await self.handle_read_texts() + elif intent == "read_from": + await self.handle_read_from(user_input) + elif intent == "check_delivery": + await self.handle_check_delivery() + elif intent == "account_balance": + await self.handle_account_balance() + elif intent == "add_contact": + await self.handle_add_contact(user_input) + elif intent == "remove_contact": + await self.handle_remove_contact(user_input) + elif intent == "list_contacts": + await self.handle_list_contacts() + elif intent == "exit": + await self.capability_worker.speak("Goodbye.") + break + else: + await self.capability_worker.speak("I didn't catch that. You can say 'Send a text', 'Read messages', or 'List contacts'.") + + await self.capability_worker.speak("Anything else?") + + except Exception as e: + if self.worker and hasattr(self.worker, 'editor_logging_handler'): + # Correctly using .error() to avoid "not callable" issue + self.worker.editor_logging_handler.error(f"Crash: {str(e)}") + await self.capability_worker.speak("An internal error occurred.") + finally: + self.capability_worker.resume_normal_flow() + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.worker.session_tasks.create(self.run()) \ No newline at end of file From 13b0330183a3c7d4b8a4b362b15c3b9715f635a8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Feb 2026 19:19:01 +0000 Subject: [PATCH 11/12] style: auto-format Python files with autoflake + autopep8 --- community/Twilio-SMS/main.py | 104 +++++++++++++++++------------------ 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/community/Twilio-SMS/main.py b/community/Twilio-SMS/main.py index 0e34ce59..815e0533 100644 --- a/community/Twilio-SMS/main.py +++ b/community/Twilio-SMS/main.py @@ -14,10 +14,10 @@ class TwilioSmsCapability(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None - + prefs_file: str = "twilio_sms_prefs.json" prefs: dict = None - + @classmethod def register_capability(cls) -> "MatchingCapability": """Registers the capability by loading config from config.json.""" @@ -51,22 +51,22 @@ def load_prefs(self): # If the file exists, verify the structure without touching user keys needs_save = False - + if "contacts" not in self.prefs: self.prefs["contacts"] = {} needs_save = True - + if "default_country_code" not in self.prefs: self.prefs["default_country_code"] = "+1" needs_save = True - + if "confirm_before_send" not in self.prefs: self.prefs["confirm_before_send"] = True needs_save = True if needs_save: self.save_prefs() - + def save_prefs(self): """Save preferences to the JSON file.""" if self.prefs is None: @@ -81,7 +81,7 @@ def twilio_request(self, method, path, data=None, params=None): """Execute an authenticated request to the Twilio REST API.""" account_sid = self.prefs.get("account_sid") auth_token = self.prefs.get("auth_token") - + if not account_sid or not auth_token: return {"error": "missing_credentials"} @@ -138,9 +138,9 @@ def clean_message_for_voice(self, body): """Clean SMS text and expand abbreviations for Text-to-Speech (TTS).""" replacements = { "lol": "L O L", "omg": "O M G", "btw": "by the way", - "imo": "in my opinion", "idk": "I don't know", "tbh": "to be honest", - "fyi": "for your information", "brb": "be right back", "rn": "right now", - "nvm": "never mind", "lmk": "let me know", "ty": "thank you", + "imo": "in my opinion", "idk": "I don't know", "tbh": "to be honest", + "fyi": "for your information", "brb": "be right back", "rn": "right now", + "nvm": "never mind", "lmk": "let me know", "ty": "thank you", "np": "no problem", "ur": "your", "u": "you", "r": "are", "k": "okay" } words = body.split() @@ -152,10 +152,10 @@ def clean_message_for_voice(self, body): else: cleaned.append(word) result = " ".join(cleaned) - + # Replace URLs with spoken equivalent result = re.sub(r'https?://\S+', 'a link', result) - + # Truncate long messages if len(result) > 500: result = result[:500] + "... message truncated." @@ -196,24 +196,24 @@ def format_delivery_status(self, status): def normalize_phone_number(self, raw_number): """Normalize a spoken phone number to E.164 format.""" digits = re.sub(r'[^\d+]', '', raw_number) - + if digits.startswith('+'): return digits if len(digits) >= 11 else None - + if len(digits) == 10: default_cc = self.prefs.get("default_country_code", "+1") return f"{default_cc}{digits}" - + if len(digits) == 11 and digits.startswith('1'): return f"+{digits}" - + return None def resolve_contact(self, spoken_name): """Smart contact search (Exact match first, then LLM fuzzy search).""" contacts = self.prefs.get("contacts", {}) lower_name = spoken_name.lower().strip() - + # 1. Exact match for name, number in contacts.items(): if name.lower() == lower_name: @@ -226,7 +226,7 @@ def resolve_contact(self, spoken_name): User said: "{spoken_name}" Available contacts: {contact_list} Return ONLY the exact contact name from the list, or "none" if no match.""" - + result = self.capability_worker.text_to_text_response(prompt) clean = result.strip().strip('"').lower() for name, number in contacts.items(): @@ -243,17 +243,17 @@ async def handle_add_contact(self, user_input): IMPORTANT: Format the phone number as digits only (convert words to digits). Return ONLY valid JSON: {{"name": "contact name", "number": "phone number"}} If either is missing, return an empty string.""" - + llm_response = self.capability_worker.text_to_text_response(extract_prompt) parsed_data = self.extract_json_from_llm(llm_response) - + name = parsed_data.get("name", "").lower() raw_number = parsed_data.get("number", "") - + if not name or not raw_number: await self.capability_worker.speak("I didn't catch the name or the phone number. Try saying, 'Add Sarah with number 555 123 4567'.") return - + clean_number = self.normalize_phone_number(raw_number) if not clean_number: await self.capability_worker.speak(f"The number {raw_number} doesn't look like a valid phone number.") @@ -261,7 +261,7 @@ async def handle_add_contact(self, user_input): await self.capability_worker.speak(f"I will save {name} as {clean_number}. Is that correct?") confirm = await self.capability_worker.user_response() - + if confirm and ("yes" in confirm.lower() or "sure" in confirm.lower() or "ok" in confirm.lower() or "right" in confirm.lower()): contacts = self.prefs.get("contacts", {}) contacts[name] = clean_number @@ -283,18 +283,18 @@ async def handle_remove_contact(self, user_input): User said: "{user_input}" Known contacts: {contact_names} Return ONLY valid JSON: {{"name": "contact name"}}""" - + llm_response = self.capability_worker.text_to_text_response(extract_prompt) parsed_data = self.extract_json_from_llm(llm_response) - + name = parsed_data.get("name", "").lower() if not name or name not in contacts: await self.capability_worker.speak(f"I couldn't find {name} in your contacts. You currently have: {contact_names}.") return - + await self.capability_worker.speak(f"Are you sure you want to remove {name} from your contacts?") confirm = await self.capability_worker.user_response() - + if confirm and ("yes" in confirm.lower() or "sure" in confirm.lower() or "remove" in confirm.lower() or "delete" in confirm.lower()): del contacts[name] self.prefs["contacts"] = contacts @@ -309,7 +309,7 @@ async def handle_list_contacts(self): if not contacts: await self.capability_worker.speak("You don't have any contacts saved yet.") return - + names = list(contacts.keys()) if len(names) == 1: await self.capability_worker.speak(f"You have 1 contact: {names[0]}.") @@ -323,11 +323,11 @@ async def handle_account_balance(self): """Check the Twilio account balance.""" await self.capability_worker.speak("Checking your Twilio balance...") result = self.twilio_request("GET", "Balance.json") - + if "error" in result: await self.capability_worker.speak("I couldn't retrieve your account balance.") return - + balance = result.get("balance", "unknown") currency = result.get("currency", "") await self.capability_worker.speak(f"Your Twilio account balance is {balance} {currency}.") @@ -337,12 +337,12 @@ async def handle_read_from(self, user_input): extract_prompt = f"""The user wants to read texts from a specific person. Extract the sender's name. User said: "{user_input}" Return ONLY valid JSON: {{"sender": "contact name"}}""" - + llm_response = self.capability_worker.text_to_text_response(extract_prompt) parsed_data = self.extract_json_from_llm(llm_response) - + sender_name_raw = parsed_data.get("sender", "").lower() - + if not sender_name_raw: await self.capability_worker.speak("I couldn't figure out whose messages you want to read.") return @@ -356,7 +356,7 @@ async def handle_read_from(self, user_input): sender_name = contact["name"] await self.capability_worker.speak(f"Checking messages from {sender_name}...") - + params = {"To": self.prefs.get("twilio_number", ""), "From": from_number, "PageSize": 5} result = self.twilio_request("GET", "Messages.json", params=params) @@ -383,7 +383,7 @@ async def handle_read_from(self, user_input): async def handle_read_texts(self): """Fetch and read the latest incoming SMS messages.""" await self.capability_worker.speak("Checking your messages...") - + params = {"To": self.prefs.get("twilio_number", ""), "PageSize": 20} result = self.twilio_request("GET", "Messages.json", params=params) @@ -434,7 +434,7 @@ async def handle_read_texts(self): if num == sender_number: contact_name = name break - + if contact_name: sender_display = contact_name else: @@ -444,7 +444,7 @@ async def handle_read_texts(self): body_clean = self.clean_message_for_voice(msg.get("body", "")) prefix = "First" if i == 0 else "Next" - + await self.capability_worker.speak(f"{prefix}, from {sender_display} {time_display}: {body_clean}") if is_new and new_messages: @@ -455,18 +455,18 @@ async def handle_send_text(self, user_input): """Parse recipient and body, and send an SMS.""" contacts = self.prefs.get("contacts", {}) contact_names = ", ".join(contacts.keys()) - + extract_prompt = f"""The user wants to send a text message. Extract the recipient and message body. User said: "{user_input}" Known contacts: {contact_names} Return ONLY valid JSON: {{"recipient": "contact name", "body": "the message to send"}}""" - + llm_response = self.capability_worker.text_to_text_response(extract_prompt) parsed_data = self.extract_json_from_llm(llm_response) - + recipient_name_raw = parsed_data.get("recipient", "").lower() body = parsed_data.get("body", "") - + if not recipient_name_raw or not body: await self.capability_worker.speak("I couldn't figure out who to send that to or what to say. Please try again.") return @@ -481,15 +481,15 @@ async def handle_send_text(self, user_input): await self.capability_worker.speak(f"I'll text {recipient_name}: '{body}'. Should I send it?") confirm_input = await self.capability_worker.user_response() - + if not confirm_input: await self.capability_worker.speak("Message cancelled.") return - + if "yes" in confirm_input.lower() or "send" in confirm_input.lower() or "sure" in confirm_input.lower() or "ok" in confirm_input.lower(): await self.capability_worker.speak("Sending...") result = self.send_sms(to_number, body) - + if "error" in result: error_code = result.get("code") if error_code == 21211: @@ -511,29 +511,29 @@ async def handle_send_text(self, user_input): async def handle_check_delivery(self): """Check the delivery status of the last sent message.""" last_sent_sid = self.prefs.get("last_sent_sid") - + if not last_sent_sid: await self.capability_worker.speak("You haven't sent any messages recently that I can check.") return - + await self.capability_worker.speak("Checking delivery status...") - + result = self.twilio_request("GET", f"Messages/{last_sent_sid}.json") - + if "error" in result: await self.capability_worker.speak("I couldn't check the status right now. Please try again later.") return - + status = result.get("status", "unknown") spoken_status = self.format_delivery_status(status) - + await self.capability_worker.speak(spoken_status) async def run(self): """Main interaction loop for the capability.""" try: self.load_prefs() - + if not self.prefs.get("account_sid") or not self.prefs.get("auth_token") or not self.prefs.get("twilio_number"): await self.capability_worker.speak("Twilio credentials are missing. Please configure your Account SID, Auth Token, and Twilio phone number in the preferences file.") return @@ -608,4 +608,4 @@ async def run(self): def call(self, worker: AgentWorker): self.worker = worker self.capability_worker = CapabilityWorker(self.worker) - self.worker.session_tasks.create(self.run()) \ No newline at end of file + self.worker.session_tasks.create(self.run()) From 2895190b8e12e5450b11a601ed634787ff09be6a Mon Sep 17 00:00:00 2001 From: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> Date: Sat, 21 Feb 2026 20:23:24 +0100 Subject: [PATCH 12/12] Fix formatting of intents in classify prompt Signed-off-by: Artur Kozhushnyi <137943726+ArturKozhushnyi@users.noreply.github.com> --- community/Twilio-SMS/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/community/Twilio-SMS/main.py b/community/Twilio-SMS/main.py index 815e0533..8bf42866 100644 --- a/community/Twilio-SMS/main.py +++ b/community/Twilio-SMS/main.py @@ -554,7 +554,7 @@ async def run(self): break classify_prompt = f"""You are a voice command router. Classify the user's intent based on their input. - Intents: + Intents: - send_text (e.g., "send a text to john", "message mom") - read_texts (e.g., "read my messages", "check texts", "any new texts") - read_from (e.g., "what did robot say", "read texts from john") @@ -565,7 +565,7 @@ async def run(self): - list_contacts (e.g., "who is in my contacts", "show contacts") - exit (e.g., "stop", "exit") - unknown - + User said: "{user_input}" Return ONLY valid JSON: {{"intent": "string"}}"""