diff --git a/README.md b/README.md
index a693edc..61847c7 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ Transform Home Assistant into a fully-fledged personal teammate with this curate
- [Lunar Calendar Conversion & Lookup](#lunar-calendar-conversion--lookup)
- [Create Lunar Calendar Events](#create-lunar-calendar-events)
- [Interactive Smart Home Chatbot](#interactive-smart-home-chatbot)
- - [Voice Assist - Send Messages & Images](#voice-assist---send-messages--images)
+ - [Voice Assist - Send Messages & Media](#voice-assist---send-messages--media)
- [Voice Assist - Internet Knowledge Search](#voice-assist---internet-knowledge-search)
- [Voice Assist - YouTube Search & Playback](#voice-assist---youtube-search--playback)
- [Voice Assist - Favorite YouTube Channels](#voice-assist---favorite-youtube-channels)
@@ -286,20 +286,22 @@ _Install the webhook blueprint for your chosen platform. For image analysis, als
---
-## Voice Assist - Send Messages & Images
+## Voice Assist - Send Messages & Media
-Driving or hands messy? Use your voice to send messages, share your location, or send camera images to loved ones via Telegram/Zalo.
+Driving or hands messy? Use your voice to send messages and share content with loved ones via Telegram/Zalo. The Telegram blueprint supports text, map locations, images, audio, documents, videos, and voice messages.
**Key Features:**
- **Hands-Free Messaging:** Dictate your message, and Assistant will send it immediately.
-- **Smart Sharing:** Automatically attach Google Maps links when you mention a location.
+- **Telegram Map Pins:** Send a precise location using latitude/longitude or a Google Maps URL containing coordinates. The blueprint sends the summary first, followed by the map pin.
+- **Media Sharing:** Send local images, MP3/M4A audio, documents, MPEG-4 videos, and OGG/Opus, MP3, or M4A voice messages using paths under `local/` or `/media/`.
- **Image Reporting:** Command to take a photo from a security camera and send it directly to a family chat group.
**Example Voice Commands:**
- "Send a list of good restaurants in Nha Trang to the Telegram family group."
-- "Send the Thang Long Citadel location via Zalo to my wife."
+- "Send the Thang Long Citadel map pin to the Telegram family group using this Google Maps link."
+- "Send the monthly report PDF and the meeting recording to the Telegram family group."
- "Take a photo from the gate camera and send it to the chat group."
**Use Cases:**
@@ -307,9 +309,12 @@ Driving or hands messy? Use your voice to send messages, share your location, or
- **Driving Safety:** "Message my wife I'll be home in 30 minutes" - Send important updates without taking your hands off the wheel, focusing on driving.
- **Urgent Notifications:** Running late? "Message mom I'm on my way home" - Quickly inform without typing.
- **Capture Moments:** "Take a photo from the yard camera and send it to the family group" - Instantly share interesting images.
+- **Rich Updates:** Send reports, recordings, videos, or voice messages alongside a concise summary.
_Install the blueprint for the platform you want to send messages to:_
+For Telegram map pins, use coordinates or a Google Maps URL that contains coordinates. A plain address or shortened Maps URL requires a separate geocoding step.
+
**Send to Telegram:**
[](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fluuquangvu%2Ftutorials%2Fblob%2Fmain%2Fsend_to_telegram_full_llm.yaml)
diff --git a/README_vi.md b/README_vi.md
index f2325dc..5382793 100644
--- a/README_vi.md
+++ b/README_vi.md
@@ -30,7 +30,7 @@ Biến Home Assistant thành một trợ lý cá nhân thực thụ với bộ s
- [Tra cứu & chuyển đổi Lịch Âm](#tra-cứu--chuyển-đổi-lịch-âm)
- [Tạo Sự kiện theo Lịch Âm](#tạo-sự-kiện-theo-lịch-âm)
- [Chatbot Tương tác & Điều khiển Nhà thông minh](#chatbot-tương-tác--điều-khiển-nhà-thông-minh)
- - [Voice Assist - Gửi Tin nhắn & Hình ảnh](#voice-assist---gửi-tin-nhắn--hình-ảnh)
+ - [Voice Assist - Gửi Tin nhắn & Nội dung](#voice-assist---gửi-tin-nhắn--nội-dung)
- [Voice Assist - Tra cứu Thông tin Internet](#voice-assist---tra-cứu-thông-tin-internet)
- [Voice Assist - Tìm kiếm & Phát Video YouTube](#voice-assist---tìm-kiếm--phát-video-youtube)
- [Voice Assist - Theo dõi Kênh YouTube Yêu thích](#voice-assist---theo-dõi-kênh-youtube-yêu-thích)
@@ -286,20 +286,22 @@ _Cài đặt blueprint webhook cho nền tảng bạn chọn. Để phân tích
---
-## Voice Assist - Gửi Tin nhắn & Hình ảnh
+## Voice Assist - Gửi Tin nhắn & Nội dung
-Đang lái xe hoặc tay dính dầu mỡ? Hãy dùng giọng nói để gửi tin nhắn, chia sẻ vị trí hoặc hình ảnh camera tới người thân qua Telegram/Zalo.
+Đang lái xe hoặc tay dính dầu mỡ? Hãy dùng giọng nói để gửi tin nhắn và chia sẻ nội dung tới người thân qua Telegram/Zalo. Blueprint Telegram hỗ trợ tin nhắn văn bản, ghim vị trí, hình ảnh, âm thanh, tài liệu, video và tin nhắn thoại.
**Tính năng nổi bật:**
- **Nhắn tin rảnh tay:** Đọc nội dung tin nhắn và Assistant sẽ gửi đi ngay lập tức.
-- **Chia sẻ thông minh:** Tự động đính kèm link Google Maps khi bạn nhắc đến một địa điểm.
+- **Ghim vị trí Telegram:** Gửi vị trí chính xác bằng vĩ độ/kinh độ hoặc liên kết Google Maps có chứa tọa độ. Blueprint sẽ gửi phần tóm tắt trước, sau đó gửi ghim vị trí.
+- **Chia sẻ nội dung đa phương tiện:** Gửi hình ảnh, âm thanh MP3/M4A, tài liệu, video MPEG-4 và tin nhắn thoại OGG/Opus, MP3 hoặc M4A từ đường dẫn `local/` hoặc `/media/`.
- **Báo cáo hình ảnh:** Ra lệnh chụp ảnh từ camera an ninh và gửi ngay vào nhóm chat gia đình.
**Ví dụ lệnh thoại:**
- "Gửi danh sách quán ăn ngon ở Nha Trang lên nhóm Telegram gia đình."
-- "Gửi vị trí Hoàng Thành Thăng Long qua Zalo cho vợ."
+- "Gửi ghim vị trí Hoàng Thành Thăng Long lên nhóm Telegram bằng liên kết Google Maps này."
+- "Gửi báo cáo PDF tháng này và bản ghi âm cuộc họp lên nhóm Telegram gia đình."
- "Chụp ảnh camera cổng gửi vào nhóm chat."
**Ứng dụng thực tế:**
@@ -307,9 +309,12 @@ _Cài đặt blueprint webhook cho nền tảng bạn chọn. Để phân tích
- **An toàn khi lái xe:** "Nhắn cho vợ là anh về muộn khoảng 30 phút" - Gửi thông báo quan trọng mà không cần rời tay khỏi vô lăng, tập trung lái xe.
- **Thông báo khẩn:** Về nhà muộn? "Gửi tin nhắn cho mẹ là con đang trên đường về" - Nhanh chóng thông báo mà không cần gõ phím.
- **Chia sẻ khoảnh khắc:** "Chụp ảnh camera sân gửi vào nhóm gia đình" - Chia sẻ ngay lập tức những hình ảnh thú vị.
+- **Cập nhật đầy đủ:** Gửi báo cáo, bản ghi âm, video hoặc tin nhắn thoại kèm phần tóm tắt ngắn gọn.
_Cài đặt blueprint cho nền tảng bạn muốn gửi tin đến:_
+Để gửi ghim vị trí Telegram, hãy dùng tọa độ hoặc liên kết Google Maps có chứa tọa độ. Địa chỉ thuần văn bản hoặc liên kết Maps rút gọn cần có bước mã hóa địa chỉ riêng.
+
**Gửi đến Telegram:**
[](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fluuquangvu%2Ftutorials%2Fblob%2Fmain%2Fsend_to_telegram_full_llm.yaml)
diff --git a/ac_mode_and_fan_control_full_llm.yaml b/ac_mode_and_fan_control_full_llm.yaml
index c460021..bdf6de8 100644
--- a/ac_mode_and_fan_control_full_llm.yaml
+++ b/ac_mode_and_fan_control_full_llm.yaml
@@ -105,7 +105,7 @@ mode: parallel
max_exceeded: silent
description: Controls AC mode, temperature, and fan speed
variables:
- version: 20260409
+ version: 20260818
fields:
ac_entities:
name: AC Entities
@@ -168,7 +168,7 @@ sequence:
{% if exact_match %}
{% set res.entities = res.entities + exact_match %}
{% else %}
- {% set alias_match = state_attr(entity_aliases, 'entities') | default([]) | selectattr('entity_id', 'match', 'climate\.') | selectattr('aliases', 'contains', name) | map(attribute='entity_id') | list %}
+ {% set alias_match = state_attr(entity_aliases, 'entities') | default([], true) | selectattr('entity_id', 'defined') | selectattr('entity_id', 'match', '^climate\.') | selectattr('aliases', 'defined') | selectattr('aliases', 'contains', name) | map(attribute='entity_id') | list %}
{% if alias_match %}
{% set res.entities = res.entities + alias_match %}
{% else %}
@@ -195,7 +195,7 @@ sequence:
{% for entity_id in resolution.entities %}
{% set device_name = state_attr(entity_id, 'friendly_name') %}
{% if hvac_mode %}
- {% set supported_hvac = state_attr(entity_id, 'hvac_modes') | default([]) %}
+ {% set supported_hvac = state_attr(entity_id, 'hvac_modes') | default([], true) %}
{% set hvac_support = namespace(modes=[]) %}
{% for m in supported_hvac %}
{% set val = m %}
@@ -246,7 +246,7 @@ sequence:
{% set result.resolved_temps = result.resolved_temps + [''] %}
{% endif %}
{% if fan_mode %}
- {% set supported_fan = state_attr(entity_id, 'fan_modes') | default([]) %}
+ {% set supported_fan = state_attr(entity_id, 'fan_modes') | default([], true) %}
{% set fan_check = namespace(matched=false, resolved_mode='') %}
{% set fan_support = namespace(aliases=[]) %}
{% for mode in supported_fan %}
diff --git a/advanced_youtube_search_full_llm.yaml b/advanced_youtube_search_full_llm.yaml
index 81de46d..993c523 100644
--- a/advanced_youtube_search_full_llm.yaml
+++ b/advanced_youtube_search_full_llm.yaml
@@ -71,7 +71,7 @@ blueprint:
text:
multiline: true
default: |-
- Search query. Silence-correct misspellings. Use ordinals for results, omit Media IDs. Confirm if ambiguous
+ Search query. Silence-correct misspellings. Use ordinals for results, omit Media IDs and URLs. Confirm if ambiguous
page_token_prompt:
name: Page Token Prompt
description: The token used by the AI to fetch the next page of YouTube results
@@ -84,7 +84,7 @@ mode: parallel
max_exceeded: silent
description: Searches YouTube for videos and returns matching results
variables:
- version: 20260409
+ version: 20260818
fields:
query_string:
name: Query String
@@ -137,6 +137,7 @@ sequence:
title: {{ entry.snippet.title | lower }}
published: {{ strptime(entry.snippet.publishedAt, '%Y-%m-%dT%H:%M:%S%z') | relative_time }} ago
media_id: {{ entry.id.videoId }}
+ media_url: {{ 'https://www.youtube.com/watch?v=' ~ entry.id.videoId }}
{% endfor -%}
next_page_token: "{{ response.get('nextPageToken') }}"
- stop: ""
diff --git a/camera_snapshot_full_llm.yaml b/camera_snapshot_full_llm.yaml
index 4fab27c..c805b92 100644
--- a/camera_snapshot_full_llm.yaml
+++ b/camera_snapshot_full_llm.yaml
@@ -1,9 +1,9 @@
blueprint:
- name: Capture Camera Snapshot
+ name: Capture Camera Snapshot or Record Video
author: luuquangvu
source_url: https://github.com/luuquangvu/tutorials/blob/dev/camera_snapshot_full_llm.yaml
description: |-
- # Tool for capturing camera snapshots used for Voice Assistant
+ # Tool for capturing camera snapshots or recording videos used for Voice Assistant
## Blueprint Setup
@@ -35,7 +35,7 @@ blueprint:
entities: "{{ response.stdout }}"
```
- - Ensure the directory defined in the snapshot settings exists (default is `/media`).
+ - Ensure the directory defined in the capture settings exists (default is `/media`).
### Optional
@@ -43,7 +43,7 @@ blueprint:
### Note
- - Provide a concise and precise description for the script. This will be utilized by the LLM to understand it should use this script for capturing a snapshot from a camera.
+ - Provide a concise and precise description for the script. This will be utilized by the LLM to understand it should use this script for capturing a snapshot or recording a video from a camera.
- Make sure to expose camera entities to Assist.
- Make sure to expose the script to Assist after the script has been saved.
- Do not alter the default script name.
@@ -67,7 +67,7 @@ blueprint:
snapshot_settings:
name: Settings for Snapshot Output
icon: mdi:folder-image
- description: Configure where snapshots should be stored and how filenames are generated.
+ description: Configure where camera captures should be stored and how snapshot filenames are generated.
collapsed: true
input:
snapshot_directory:
@@ -96,17 +96,25 @@ blueprint:
input:
camera_name_prompt:
name: Camera Name Prompt
- description: The friendly name of the camera to capture a snapshot from, provided by the AI
+ description: The friendly name of the camera to capture from, provided by the AI
selector:
text:
multiline: true
default: |-
- Camera name. Returns local image path. Multiple cameras = multiple calls
+ Camera name. Multiple cameras = multiple calls
+ capture_type_prompt:
+ name: Capture Type Prompt
+ description: Whether to capture a snapshot or record a video
+ selector:
+ text:
+ multiline: true
+ default: |-
+ snapshot or record. Default: snapshot
mode: parallel
max_exceeded: silent
-description: Captures a camera snapshot and returns the file path
+description: Captures a camera snapshot or records a video and returns the local file path
variables:
- version: 20260409
+ version: 20260818
fields:
camera_name:
name: Camera Name
@@ -114,10 +122,30 @@ fields:
selector:
text:
required: true
+ capture_type:
+ name: Capture Type
+ description: Select whether to capture a snapshot or record a video.
+ selector:
+ select:
+ options:
+ - snapshot
+ - record
+ default: snapshot
+ duration:
+ name: Recording Duration
+ description: Recording duration in seconds. Only used when capture type is record.
+ selector:
+ number:
+ min: 1
+ max: 60
+ step: 1
+ default: 10
sequence:
- variables:
entity_aliases: !input entity_aliases
camera_name: "{{ camera_name | default('') | trim }}"
+ capture_type: "{{ capture_type | default('snapshot', true) | trim | lower }}"
+ duration: "{{ duration | default(10, true) | int(10) }}"
snapshot_directory_input: !input snapshot_directory
snapshot_directory: "{{ snapshot_directory_input | default('/media', true) | trim }}"
snapshot_prefix_input: !input snapshot_filename_prefix
@@ -129,7 +157,7 @@ sequence:
- condition: template
value_template: >-
{% set friendly_match = states.camera | selectattr('attributes.friendly_name', 'eq', camera_name) | list %}
- {% set alias_match = state_attr(entity_aliases, 'entities') | default([]) | selectattr('entity_id', 'match', 'camera\.') | selectattr('aliases', 'contains', camera_name) | list %}
+ {% set alias_match = state_attr(entity_aliases, 'entities') | default([], true) | selectattr('entity_id', 'defined') | selectattr('entity_id', 'match', '^camera\.') | selectattr('aliases', 'defined') | selectattr('aliases', 'contains', camera_name) | list %}
{{ not camera_name or (friendly_match | length == 0 and alias_match | length == 0) }}
then:
- alias: Set variable for error message
@@ -139,6 +167,19 @@ sequence:
- alias: Stop the script
stop: Unable to capture a snapshot because the camera name is invalid.
response_variable: response
+ - alias: Validate capture settings
+ if:
+ - condition: template
+ value_template: >-
+ {{ capture_type not in ['snapshot', 'record'] or duration < 1 or duration > 60 }}
+ then:
+ - alias: Set variable for error message
+ variables:
+ response:
+ error: Capture type must be snapshot or record, and recording duration must be between 1 and 60 seconds.
+ - alias: Stop the script
+ stop: Capture type must be snapshot or record, and recording duration must be between 1 and 60 seconds.
+ response_variable: response
- alias: Validate snapshot settings
if:
- condition: template
@@ -154,12 +195,9 @@ sequence:
response_variable: response
- variables:
camera_entity_id: >-
- {% set friendly_match = states.camera | selectattr('attributes.friendly_name', 'eq', camera_name) | map(attribute='entity_id') | first %}
- {% if friendly_match %}
- {{ friendly_match }}
- {% else %}
- {{ state_attr(entity_aliases, 'entities') | default([]) | selectattr('entity_id', 'match', 'camera\.') | selectattr('aliases', 'contains', camera_name) | map(attribute='entity_id') | first }}
- {% endif %}
+ {%- set friendly_matches = states.camera | selectattr('attributes.friendly_name', 'eq', camera_name) | map(attribute='entity_id') | list -%}
+ {%- set alias_matches = state_attr(entity_aliases, 'entities') | default([], true) | selectattr('entity_id', 'defined') | selectattr('entity_id', 'match', '^camera\.') | selectattr('aliases', 'defined') | selectattr('aliases', 'contains', camera_name) | map(attribute='entity_id') | list -%}
+ {{- friendly_matches[0] if friendly_matches else (alias_matches[0] if alias_matches else '') -}}
sanitized_prefix: >-
{% if snapshot_prefix %}
{{ snapshot_prefix | regex_replace('[^0-9A-Za-z_-]', '_') }}
@@ -174,30 +212,71 @@ sequence:
.jpg
{% endif %}
snapshot_directory_normalized: >-
- {% if snapshot_directory %}
- {{ snapshot_directory.rstrip('/\\') }}
+ {{ snapshot_directory.rstrip('/\\') or '/' }}
+ snapshot_random_suffix: "{{ '%05d' | format(range(0, 100000) | random) }}"
+ capture_prefix: >-
+ {% if capture_type == 'record' %}
+ camera_record_
{% else %}
- /media
+ {{ sanitized_prefix }}
{% endif %}
- snapshot_random_suffix: "{{ '%05d' | format(range(0, 100000) | random) }}"
- snapshot_filename: >-
- {{ sanitized_prefix ~ now().strftime('%Y%m%d%H%M%S%f') ~ '_' ~ snapshot_random_suffix ~ sanitized_extension }}
- snapshot_full_path: >-
- {{ snapshot_directory_normalized ~ '/' ~ snapshot_filename }}
- returned_image_path: >-
+ capture_extension: >-
+ {% if capture_type == 'record' %}
+ .mp4
+ {% else %}
+ {{ sanitized_extension }}
+ {% endif %}
+ capture_filename: >-
+ {{ (capture_prefix | trim) ~ now().strftime('%Y%m%d%H%M%S%f') ~ '_' ~ snapshot_random_suffix ~ (capture_extension | trim) }}
+ capture_full_path: "{{ ('/' ~ capture_filename) if snapshot_directory_normalized == '/' else (snapshot_directory_normalized ~ '/' ~ capture_filename) }}"
+ returned_media_path: >-
{% set prefix = '/media/' %}
- {% if snapshot_full_path.startswith(prefix) %}
- {{ 'local/' ~ snapshot_full_path[prefix | length:] }}
+ {% if capture_full_path.startswith(prefix) %}
+ {{ 'local/' ~ capture_full_path[prefix | length:] }}
{% else %}
- {{ snapshot_full_path }}
+ {{ capture_full_path }}
{% endif %}
- - action: camera.snapshot
- target:
- entity_id: "{{ camera_entity_id }}"
- data:
- filename: "{{ snapshot_full_path }}"
- - variables:
- response:
- image_path: "{{ returned_image_path }}"
+ - alias: Validate resolved camera entity
+ if:
+ - condition: template
+ value_template: >-
+ {{ not camera_entity_id or camera_entity_id not in (states.camera | map(attribute='entity_id') | list) }}
+ then:
+ - stop: >-
+ No active camera entity was found for "{{ camera_name }}".
+ Add the camera to the entity aliases sensor or use its exact friendly name.
+ error: true
+ - choose:
+ - conditions:
+ - condition: template
+ value_template: "{{ capture_type == 'snapshot' }}"
+ sequence:
+ - action: camera.snapshot
+ target:
+ entity_id: "{{ camera_entity_id }}"
+ data:
+ filename: "{{ capture_full_path }}"
+ - variables:
+ response:
+ image_path: "{{ returned_media_path }}"
+ media_type: image
+ - conditions:
+ - condition: template
+ value_template: "{{ capture_type == 'record' }}"
+ sequence:
+ - action: camera.record
+ target:
+ entity_id: "{{ camera_entity_id }}"
+ data:
+ filename: "{{ capture_full_path }}"
+ duration: "{{ duration }}"
+ - variables:
+ response:
+ video_path: "{{ returned_media_path }}"
+ media_type: video
+ default:
+ - variables:
+ response:
+ error: "Unsupported capture type: {{ capture_type }}"
- stop: ""
response_variable: response
diff --git a/device_control_timer_full_llm.yaml b/device_control_timer_full_llm.yaml
index cfeb982..d5f03d8 100644
--- a/device_control_timer_full_llm.yaml
+++ b/device_control_timer_full_llm.yaml
@@ -109,7 +109,7 @@ mode: parallel
max_exceeded: silent
description: Turns devices on or off after a specified delay
variables:
- version: 20260409
+ version: 20260818
fields:
entities:
name: Entities
@@ -142,7 +142,7 @@ sequence:
{% set validation = namespace(not_exist=false) -%}
{% for entity in entities.split(';') -%}
{% if not ((states | selectattr('attributes.friendly_name', 'eq', entity.strip()) | list) or
- (state_attr(entity_aliases, 'entities') | default([]) | selectattr('aliases', 'contains', entity.strip()) | list)) -%}
+ (state_attr(entity_aliases, 'entities') | default([], true) | selectattr('aliases', 'defined') | selectattr('aliases', 'contains', entity.strip()) | list)) -%}
{% set validation.not_exist = true -%}
{% endif -%}
{% endfor -%}
@@ -162,7 +162,7 @@ sequence:
{% if (states | selectattr('attributes.friendly_name', 'eq', entity.strip()) | list) -%}
{% set device.entities = device.entities + (states | selectattr('attributes.friendly_name', 'eq', entity.strip()) | map(attribute='entity_id') | list) -%}
{% else -%}
- {% set device.entities = device.entities + (state_attr(entity_aliases, 'entities') | default([]) | selectattr('aliases', 'contains', entity.strip()) | map(attribute='entity_id') | list) -%}
+ {% set device.entities = device.entities + (state_attr(entity_aliases, 'entities') | default([], true) | selectattr('entity_id', 'defined') | selectattr('aliases', 'defined') | selectattr('aliases', 'contains', entity.strip()) | map(attribute='entity_id') | list) -%}
{% endif -%}
{% endfor -%}
{{ device.entities }}
diff --git a/device_location_lookup_full_llm.yaml b/device_location_lookup_full_llm.yaml
index 16ea5c1..cf81d88 100644
--- a/device_location_lookup_full_llm.yaml
+++ b/device_location_lookup_full_llm.yaml
@@ -87,7 +87,7 @@ mode: parallel
max_exceeded: silent
description: Locates devices (phones, tags, etc.) within the home
variables:
- version: 20260409
+ version: 20260818
fields:
entities:
name: Entities
@@ -107,7 +107,7 @@ sequence:
{% for entity in entities.split(';') -%}
{% if not ((integration_entities('bermuda') | select('match','device_tracker\.') | select('is_state_attr', 'friendly_name', entity.strip()) | list) or
(integration_entities('mobile_app') | select('match','device_tracker\.') | select('is_state_attr', 'friendly_name', entity.strip()) | list) or
- (state_attr(entity_aliases, 'entities') | default([]) | selectattr('entity_id', 'match', 'device_tracker\.') | selectattr('aliases', 'contains', entity.strip()) | list)) -%}
+ (state_attr(entity_aliases, 'entities') | default([], true) | selectattr('entity_id', 'defined') | selectattr('entity_id', 'match', '^device_tracker\.') | selectattr('aliases', 'defined') | selectattr('aliases', 'contains', entity.strip()) | list)) -%}
{% set validation.not_exist = true -%}
{% endif -%}
{% endfor -%}
@@ -127,16 +127,19 @@ sequence:
{% for entity in entities.split(';') -%}
{% set device = namespace(friendly_name=none, entity_id=none, notify_id=none, is_mobile=false, at_home=false) -%}
{% set device.friendly_name = entity.strip() -%}
- {% if (integration_entities('bermuda') | select('match','device_tracker\.') | select('is_state_attr', 'friendly_name', device.friendly_name) | list) -%}
- {% set device.entity_id = integration_entities('bermuda') | select('match','device_tracker\.') | select('is_state_attr', 'friendly_name', device.friendly_name) | first -%}
- {% elif (integration_entities('mobile_app') | select('match','device_tracker\.') | select('is_state_attr', 'friendly_name', device.friendly_name) | list) -%}
- {% set device.entity_id = integration_entities('mobile_app') | select('match','device_tracker\.') | select('is_state_attr', 'friendly_name', device.friendly_name) | first -%}
+ {% set bermuda_matches = integration_entities('bermuda') | select('match','^device_tracker\.') | select('is_state_attr', 'friendly_name', device.friendly_name) | list -%}
+ {% set mobile_matches = integration_entities('mobile_app') | select('match','^device_tracker\.') | select('is_state_attr', 'friendly_name', device.friendly_name) | list -%}
+ {% set alias_matches = state_attr(entity_aliases, 'entities') | default([], true) | selectattr('entity_id', 'defined') | selectattr('entity_id', 'match', '^device_tracker\.') | selectattr('aliases', 'defined') | selectattr('aliases', 'contains', device.friendly_name) | map(attribute='entity_id') | list -%}
+ {% if bermuda_matches -%}
+ {% set device.entity_id = bermuda_matches[0] -%}
+ {% elif mobile_matches -%}
+ {% set device.entity_id = mobile_matches[0] -%}
{% set device.is_mobile = true -%}
{% set device.notify_id = device.entity_id.split('device_tracker.')[1] -%}
- {% else -%}
- {% set device.entity_id = state_attr(entity_aliases, 'entities') | default([]) | selectattr('entity_id', 'match', 'device_tracker\.') | selectattr('aliases', 'contains', device.friendly_name) | map(attribute='entity_id') | first -%}
+ {% elif alias_matches -%}
+ {% set device.entity_id = alias_matches[0] -%}
{% endif -%}
- {% if not device.is_mobile -%}
+ {% if device.entity_id and not device.is_mobile -%}
{% set parts = device.entity_id.split('device_tracker.')[1].split('_') -%}
{% for i in range(1, (parts | length) + 1) -%}
{% if (parts[:i] | join('_')) in mobile_devices -%}
@@ -145,10 +148,12 @@ sequence:
{% endif -%}
{% endfor -%}
{% endif -%}
+ {% if device.entity_id -%}
{% set device.at_home = is_state(device.entity_id, 'home') -%}
+ {% endif -%}
- friendly_name: {{ device.friendly_name }}
at_home: {{ device.at_home }}
- area: {{ state_attr(device.entity_id, 'area') }}
+ area: {{ state_attr(device.entity_id, 'area') if device.entity_id else none }}
can_ring: {{ device.is_mobile and device.at_home }}
ring_id: {{ ('notify.mobile_app_' ~ device.notify_id) if (device.is_mobile and device.at_home) else none }}
{% endfor -%}
diff --git a/fan_oscillation_control_full_llm.yaml b/fan_oscillation_control_full_llm.yaml
index 6055f42..e3d2e63 100644
--- a/fan_oscillation_control_full_llm.yaml
+++ b/fan_oscillation_control_full_llm.yaml
@@ -89,7 +89,7 @@ mode: parallel
max_exceeded: silent
description: Controls fan oscillation
variables:
- version: 20260409
+ version: 20260818
fields:
fan_entities:
name: Fan Entities
@@ -115,7 +115,7 @@ sequence:
{% set validation = namespace(not_exist=false) -%}
{% for entity in fan_entities.split(';') -%}
{% if not ((states.fan | selectattr('attributes.friendly_name', 'eq', entity.strip()) | list) or
- (state_attr(entity_aliases, 'entities') | default([]) | selectattr('entity_id', 'match', 'fan\.') | selectattr('aliases', 'contains', entity.strip()) | list)) -%}
+ (state_attr(entity_aliases, 'entities') | default([], true) | selectattr('entity_id', 'defined') | selectattr('entity_id', 'match', '^fan\.') | selectattr('aliases', 'defined') | selectattr('aliases', 'contains', entity.strip()) | list)) -%}
{% set validation.not_exist = true -%}
{% endif -%}
{% endfor -%}
@@ -135,7 +135,7 @@ sequence:
{% if (states.fan | selectattr('attributes.friendly_name', 'eq', entity.strip()) | list) -%}
{% set device.entities = device.entities + (states.fan | selectattr('attributes.friendly_name', 'eq', entity.strip()) | map(attribute='entity_id') | list) -%}
{% else -%}
- {% set device.entities = device.entities + (state_attr(entity_aliases, 'entities') | default([]) | selectattr('entity_id', 'match', 'fan\.') | selectattr('aliases', 'contains', entity.strip()) | map(attribute='entity_id') | list) -%}
+ {% set device.entities = device.entities + (state_attr(entity_aliases, 'entities') | default([], true) | selectattr('entity_id', 'defined') | selectattr('entity_id', 'match', '^fan\.') | selectattr('aliases', 'defined') | selectattr('aliases', 'contains', entity.strip()) | map(attribute='entity_id') | list) -%}
{% endif -%}
{% endfor -%}
{{ device.entities }}
diff --git a/fan_speed_and_oscillation_control_full_llm.yaml b/fan_speed_and_oscillation_control_full_llm.yaml
index 507a0e6..751d22a 100644
--- a/fan_speed_and_oscillation_control_full_llm.yaml
+++ b/fan_speed_and_oscillation_control_full_llm.yaml
@@ -122,7 +122,7 @@ mode: parallel
max_exceeded: silent
description: Controls fan speed and oscillation
variables:
- version: 20260409
+ version: 20260818
fields:
fan_entities:
name: Fan Entities
@@ -168,7 +168,7 @@ sequence:
{% set has_name.value = true -%}
{% endif -%}
{% if name and not ((states.fan | selectattr('attributes.friendly_name', 'eq', name) | list) or
- (state_attr(entity_aliases, 'entities') | default([]) | selectattr('entity_id', 'match', 'fan\.') | selectattr('aliases', 'contains', name) | list)) -%}
+ (state_attr(entity_aliases, 'entities') | default([], true) | selectattr('entity_id', 'defined') | selectattr('entity_id', 'match', '^fan\.') | selectattr('aliases', 'defined') | selectattr('aliases', 'contains', name) | list)) -%}
{% set validation.invalid = true -%}
{% endif -%}
{% endfor -%}
@@ -208,7 +208,7 @@ sequence:
{% if (states.fan | selectattr('attributes.friendly_name', 'eq', name) | list) -%}
{% set device.entities = device.entities + (states.fan | selectattr('attributes.friendly_name', 'eq', name) | map(attribute='entity_id') | list) -%}
{% else -%}
- {% set device.entities = device.entities + (state_attr(entity_aliases, 'entities') | default([]) | selectattr('entity_id', 'match', 'fan\.') | selectattr('aliases', 'contains', name) | map(attribute='entity_id') | list) -%}
+ {% set device.entities = device.entities + (state_attr(entity_aliases, 'entities') | default([], true) | selectattr('entity_id', 'defined') | selectattr('entity_id', 'match', '^fan\.') | selectattr('aliases', 'defined') | selectattr('aliases', 'contains', name) | map(attribute='entity_id') | list) -%}
{% endif -%}
{% endif -%}
{% endfor -%}
diff --git a/fan_speed_control_full_llm.yaml b/fan_speed_control_full_llm.yaml
index 5aa0dfb..a7483b2 100644
--- a/fan_speed_control_full_llm.yaml
+++ b/fan_speed_control_full_llm.yaml
@@ -111,7 +111,7 @@ mode: parallel
max_exceeded: silent
description: Controls fan speed
variables:
- version: 20260409
+ version: 20260818
fields:
fan_entities:
name: Fan Entities
@@ -146,7 +146,7 @@ sequence:
{% set validation = namespace(invalid=false) -%}
{% for entity in fan_entities.split(';') -%}
{% if not ((states.fan | selectattr('attributes.friendly_name', 'eq', entity.strip()) | list) or
- (state_attr(entity_aliases, 'entities') | default([]) | selectattr('entity_id', 'match', 'fan\.') | selectattr('aliases', 'contains', entity.strip()) | list)) -%}
+ (state_attr(entity_aliases, 'entities') | default([], true) | selectattr('entity_id', 'defined') | selectattr('entity_id', 'match', '^fan\.') | selectattr('aliases', 'defined') | selectattr('aliases', 'contains', entity.strip()) | list)) -%}
{% set validation.invalid = true -%}
{% endif -%}
{% endfor -%}
@@ -180,7 +180,7 @@ sequence:
{% if (states.fan | selectattr('attributes.friendly_name', 'eq', entity.strip()) | list) -%}
{% set device.entities = device.entities + (states.fan | selectattr('attributes.friendly_name', 'eq', entity.strip()) | map(attribute='entity_id') | list) -%}
{% else -%}
- {% set device.entities = device.entities + (state_attr(entity_aliases, 'entities') | default([]) | selectattr('entity_id', 'match', 'fan\.') | selectattr('aliases', 'contains', entity.strip()) | map(attribute='entity_id') | list) -%}
+ {% set device.entities = device.entities + (state_attr(entity_aliases, 'entities') | default([], true) | selectattr('entity_id', 'defined') | selectattr('entity_id', 'match', '^fan\.') | selectattr('aliases', 'defined') | selectattr('aliases', 'contains', entity.strip()) | map(attribute='entity_id') | list) -%}
{% endif -%}
{% endfor -%}
{{ device.entities }}
diff --git a/file_content_analyzer_full_llm.yaml b/file_content_analyzer_full_llm.yaml
index 9ce82c0..30071e1 100644
--- a/file_content_analyzer_full_llm.yaml
+++ b/file_content_analyzer_full_llm.yaml
@@ -73,7 +73,7 @@ mode: parallel
max_exceeded: silent
description: Analyzes file content (images, video, audio, text) to extract information or provide summaries
variables:
- version: 20260409
+ version: 20260818
fields:
instructions:
name: Instructions
@@ -99,7 +99,7 @@ sequence:
ai_task_entity: !input ai_task_entity
instructions: "{{ instructions | default('') | trim }}"
file_path: "{{ file_path | default('') | trim }}"
- mime_type: "{{ mime_type | default('') | trim }}"
+ mime_type: "{{ mime_type | default('') | trim | lower }}"
- if:
- alias: Check if variables were set correctly
condition: template
@@ -131,7 +131,20 @@ sequence:
- if:
- alias: Check if variables were set correctly
condition: template
- value_template: "{{ not mime_type.startswith(('image/', 'video/', 'audio/', 'text/', 'application/')) }}"
+ value_template: >-
+ {{ not (
+ mime_type.startswith(('image/', 'video/', 'audio/', 'text/')) or
+ mime_type in (
+ 'application/pdf',
+ 'application/rtf',
+ 'application/msword',
+ 'application/vnd.ms-excel',
+ 'application/vnd.ms-powerpoint',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
+ )
+ ) }}
then:
- alias: Set variable for error message
variables:
diff --git a/get_youtube_video_info_full_llm.yaml b/get_youtube_video_info_full_llm.yaml
index b57cb90..96112e9 100644
--- a/get_youtube_video_info_full_llm.yaml
+++ b/get_youtube_video_info_full_llm.yaml
@@ -90,7 +90,7 @@ blueprint:
text:
multiline: true
default: |-
- Channel names (semicolon-separated). Silence-correct. After results, ask to play using ordinals, omit Media IDs
+ Channel names (semicolon-separated). Silence-correct. After results, ask to play using ordinals, omit Media IDs and URLs
period_length_prompt:
name: Period Length Prompt
description: The prompt used by the AI to identify the lookback period in days
@@ -103,7 +103,7 @@ mode: parallel
max_exceeded: silent
description: Fetches recent video uploads from specified YouTube channels
variables:
- version: 20260409
+ version: 20260818
fields:
entities:
name: Entities
@@ -132,7 +132,7 @@ sequence:
{% set validation = namespace(not_exist=false) -%}
{% for entity in entities.split(';') -%}
{% if not ((integration_entities('feedparser') | select('is_state_attr', 'friendly_name', entity.strip()) | list) or
- (state_attr(entity_aliases, 'entities') | default([]) | selectattr('entity_id', 'match', 'sensor\.') | selectattr('aliases', 'contains', entity.strip()) | list)) -%}
+ (state_attr(entity_aliases, 'entities') | default([], true) | selectattr('entity_id', 'defined') | selectattr('entity_id', 'match', '^sensor\.') | selectattr('aliases', 'defined') | selectattr('aliases', 'contains', entity.strip()) | list)) -%}
{% set validation.not_exist = true -%}
{% endif -%}
{% endfor -%}
@@ -148,21 +148,20 @@ sequence:
- variables:
response:
entries: |-
+ {% set seen_video_ids = namespace(values=[]) -%}
{% for entity in entities.split(';') -%}
- {% set entity_id = (integration_entities('feedparser') | select('is_state_attr', 'friendly_name', entity.strip()) | first)
- if (integration_entities('feedparser') | select('is_state_attr', 'friendly_name', entity.strip()) | list)
- else (state_attr(entity_aliases, 'entities') | default([]) | selectattr('entity_id', 'match', 'sensor\.') | selectattr('aliases', 'contains', entity.strip()) | map(attribute='entity_id') | first) -%}
- {% for entry in state_attr(entity_id, 'entries') if (strptime(entry.published, '%Y-%m-%dT%H:%M:%S%z') > (now() - timedelta(days=period_length))) -%}
- {% if entry.link is not search('\/shorts\/') -%}
- - channel: {{ entry.author | lower }}
- title: {{ entry.title | lower }}
- published: {{ strptime(entry.published, '%Y-%m-%dT%H:%M:%S%z') | relative_time }} ago
- media_id: {{ entry.yt_videoid }}
- {% elif short_videos -%}
+ {% set feed_matches = integration_entities('feedparser') | select('is_state_attr', 'friendly_name', entity.strip()) | list -%}
+ {% set alias_matches = state_attr(entity_aliases, 'entities') | default([], true) | selectattr('entity_id', 'defined') | selectattr('entity_id', 'match', '^sensor\.') | selectattr('aliases', 'defined') | selectattr('aliases', 'contains', entity.strip()) | map(attribute='entity_id') | list -%}
+ {% set entity_id = feed_matches[0] if feed_matches else (alias_matches[0] if alias_matches else none) -%}
+ {% set entries = state_attr(entity_id, 'entries') | default([], true) if entity_id else [] -%}
+ {% for entry in entries if (strptime(entry.published, '%Y-%m-%dT%H:%M:%S%z') > (now() - timedelta(days=period_length))) -%}
+ {% if (short_videos or entry.link is not search('\/shorts\/')) and entry.yt_videoid not in seen_video_ids.values -%}
+ {% set seen_video_ids.values = seen_video_ids.values + [entry.yt_videoid] -%}
- channel: {{ entry.author | lower }}
title: {{ entry.title | lower }}
published: {{ strptime(entry.published, '%Y-%m-%dT%H:%M:%S%z') | relative_time }} ago
media_id: {{ entry.yt_videoid }}
+ media_url: {{ 'https://www.youtube.com/watch?v=' ~ entry.yt_videoid }}
{% endif -%}
{% endfor -%}
{% endfor -%}
diff --git a/play_youtube_video_full_llm.yaml b/play_youtube_video_full_llm.yaml
index de9f743..9c28c6d 100644
--- a/play_youtube_video_full_llm.yaml
+++ b/play_youtube_video_full_llm.yaml
@@ -3,15 +3,14 @@ blueprint:
author: luuquangvu
source_url: https://github.com/luuquangvu/tutorials/blob/dev/play_youtube_video_full_llm.yaml
description: |-
- # Tool plays YouTube videos on a smart TV or speakers used for Voice Assistant
+ # Tool plays YouTube videos on a supported TV used for Voice Assistant
## Blueprint Setup
### Required
- - A smart TV or speakers integrated into Home Assistant.
- - Support Android TV, Samsung TV, Apple TV, Chromecast, or Sonos.
- - The **Media extractor** integration must be installed (Built-in integration).
+ - A Chromecast/Google Cast device, Android TV/Google TV device, or Apple TV integrated into Home Assistant.
+ - The official YouTube app must be installed on the target device.
- A template sensor stores all information about entity aliases needs to be configured in `config/configuration.yaml`; The sensor is required for friendly-name lookup.
```
@@ -44,7 +43,7 @@ blueprint:
### Note
- - Provide a concise and precise description for the script. This will be utilized by the LLM to understand it should use this script for playing YouTube videos on a smart TV or speakers.
+ - Provide a concise and precise description for the script. This will be utilized by the LLM to understand it should use this script for playing YouTube videos on a supported TV.
- Make sure to expose the script to Assist after the script has been saved.
- Do not alter the default script name.
- Once the script is created, click the three dots in the top right corner, choose "Edit in YAML," and remove the `description: ...` line to restore the default. This step is important because it helps the LLM better understand the script's purpose.
@@ -67,14 +66,32 @@ blueprint:
media_player_settings:
name: Settings for Media Player
icon: mdi:television
- description: Configure a default smart TV or speakers
+ description: Configure a default supported TV and its native YouTube playback method
input:
media_player:
- name: Smart TV/Speakers
+ name: Supported TV
selector:
entity:
filter:
- domain: media_player
+ integration: cast
+ - domain: media_player
+ integration: androidtv_remote
+ - domain: media_player
+ integration: apple_tv
+ playback_method:
+ name: YouTube Playback Method
+ description: Select the native playback method matching the selected TV.
+ selector:
+ select:
+ options:
+ - label: Chromecast / Google Cast
+ value: cast
+ - label: Android TV / Google TV
+ value: android_tv
+ - label: Apple TV
+ value: apple_tv
+ default: cast
prompt_settings:
name: Prompt settings for the LLM
icon: mdi:robot
@@ -83,12 +100,12 @@ blueprint:
input:
media_player_name_prompt:
name: Media Player Name Prompt
- description: The prompt used by the AI to identify the target media player name
+ description: The prompt used by the AI to identify the target supported TV name
selector:
text:
multiline: true
default: |-
- Device name (TV/Speakers). Omit if unclear
+ Device name (Chromecast, Android TV, Google TV, or Apple TV). Omit if unclear
media_id_prompt:
name: Media ID Prompt
description: The prompt used by the AI to identify the YouTube video ID
@@ -99,9 +116,9 @@ blueprint:
YouTube video ID
mode: parallel
max_exceeded: silent
-description: Plays a specific YouTube video by ID on a TV
+description: Plays a specific YouTube video by ID using the target TV's native YouTube app
variables:
- version: 20260409
+ version: 20260818
fields:
media_player:
name: Media Player
@@ -117,6 +134,7 @@ fields:
sequence:
- variables:
entity_aliases: !input entity_aliases
+ playback_method: !input playback_method
media_id: "{{ media_id | default('') | trim }}"
media_player_query: "{{ media_player | default('') | trim }}"
default_media_player: !input media_player
@@ -128,7 +146,7 @@ sequence:
{% if matches %}
{{ matches[0] }}
{% else %}
- {% set alias_matches = state_attr(entity_aliases, 'entities') | default([]) | selectattr('entity_id', 'match', 'media_player\.') | selectattr('aliases', 'contains', media_player_query) | map(attribute='entity_id') | list %}
+ {% set alias_matches = state_attr(entity_aliases, 'entities') | default([], true) | selectattr('entity_id', 'defined') | selectattr('entity_id', 'match', '^media_player\.') | selectattr('aliases', 'defined') | selectattr('aliases', 'contains', media_player_query) | map(attribute='entity_id') | list %}
{{ alias_matches[0] if alias_matches else default_media_player }}
{% endif %}
{% else %}
@@ -150,12 +168,40 @@ sequence:
- action: media_player.turn_on
target:
entity_id: "{{ media_player_entity }}"
- - action: media_extractor.play_media
- data:
- media_content_type: url
- media_content_id: "{{ 'https://www.youtube.com/watch?v=' ~ media_id }}"
- target:
- entity_id: "{{ media_player_entity }}"
+ - choose:
+ - conditions:
+ - condition: template
+ value_template: "{{ playback_method == 'cast' }}"
+ sequence:
+ - action: media_player.play_media
+ data:
+ media_content_type: cast
+ media_content_id: >-
+ {"app_name":"youtube","media_id":"{{ media_id }}"}
+ target:
+ entity_id: "{{ media_player_entity }}"
+ - conditions:
+ - condition: template
+ value_template: "{{ playback_method == 'android_tv' }}"
+ sequence:
+ - action: media_player.play_media
+ data:
+ media_content_type: url
+ media_content_id: "{{ 'https://www.youtube.com/watch?v=' ~ media_id }}"
+ target:
+ entity_id: "{{ media_player_entity }}"
+ - conditions:
+ - condition: template
+ value_template: "{{ playback_method == 'apple_tv' }}"
+ sequence:
+ - action: media_player.play_media
+ data:
+ media_content_type: url
+ media_content_id: "{{ 'youtube://www.youtube.com/watch?v=' ~ media_id }}"
+ target:
+ entity_id: "{{ media_player_entity }}"
+ default:
+ - stop: Unsupported YouTube playback method.
- alias: Prepare success response for Assist
variables:
response:
diff --git a/scripts/common_utilities.py b/scripts/common_utilities.py
index 4b79f82..11659e4 100644
--- a/scripts/common_utilities.py
+++ b/scripts/common_utilities.py
@@ -20,10 +20,11 @@
class _IndexLockContext:
- def __init__(self, key: str):
- self.key = key
- self.lock = None
+ key: str
+ lock: asyncio.Lock | None
+ # Pyscript handles __init__ specially; initialize these attributes in the factory below.
+ @pyscript_compile # noqa: F821 # ty:ignore[unresolved-reference]
async def __aenter__(self):
key = self.key
with _INDEX_LOCKS_GUARD:
@@ -36,6 +37,7 @@ async def __aenter__(self):
await self.lock.acquire()
return self
+ @pyscript_compile # noqa: F821 # ty:ignore[unresolved-reference]
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self.lock:
self.lock.release()
@@ -50,7 +52,10 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
def _acquire_index_lock(key: str):
- return _IndexLockContext(key)
+ context = _IndexLockContext()
+ context.key = key
+ context.lock = None
+ return context
@pyscript_compile # noqa: F821 # ty:ignore[unresolved-reference]
@@ -330,6 +335,7 @@ async def memory_cache_forget(key: str) -> dict[str, Any]:
"error": "Missing a required argument: key",
}
try:
+ deleted = 0
async with _acquire_index_lock(key):
deleted = await _cache_delete(key)
return {
@@ -386,6 +392,7 @@ async def memory_cache_set(
ttl = TTL
try:
stored_value = orjson.dumps(value).decode("utf-8")
+ success = False
async with _acquire_index_lock(key):
success = await _cache_set(key, stored_value, ttl)
if not success:
@@ -499,8 +506,8 @@ def _normalize(_value: Any) -> list[str]:
except (ValueError, TypeError):
ttl = INDEX_TTL
- async with _acquire_index_lock(cleaned_key):
- try:
+ try:
+ async with _acquire_index_lock(cleaned_key):
entries: list[str] = []
seen: set[str] = set()
changed = False
@@ -557,11 +564,11 @@ def _normalize(_value: Any) -> list[str]:
"ttl": ttl,
"changed": changed,
}
- except Exception as error:
- log.error(f"{__name__}: index_update failed for '{cleaned_key}': {error}") # noqa: F821 # ty:ignore[unresolved-reference]
- return {
- "status": "error",
- "op": "index_update",
- "key": cleaned_key,
- "error": f"An unexpected error occurred during processing: {error}",
- }
+ except Exception as error:
+ log.error(f"{__name__}: index_update failed for '{cleaned_key}': {error}") # noqa: F821 # ty:ignore[unresolved-reference]
+ return {
+ "status": "error",
+ "op": "index_update",
+ "key": cleaned_key,
+ "error": f"An unexpected error occurred during processing: {error}",
+ }
diff --git a/scripts/date_conversion_tool.py b/scripts/date_conversion_tool.py
index 4bb85b4..b6606f8 100644
--- a/scripts/date_conversion_tool.py
+++ b/scripts/date_conversion_tool.py
@@ -66,8 +66,7 @@ def new_moon(k: int) -> float:
deltat = 0.001 + 0.000839 * t + 0.0002261 * t2 - 0.00000845 * t3 - 0.000000081 * t * t3
else:
deltat = -0.000278 + 0.000265 * t + 0.000262 * t2
- jd_new = jd1 + c1 - deltat
- return jd_new
+ return jd1 + c1 - deltat
@pyscript_compile # noqa: F821 # ty:ignore[unresolved-reference]
@@ -120,7 +119,7 @@ def get_leap_month_offset(a11: int, time_zone: int) -> int:
last = arc
i += 1
arc = get_sun_longitude(get_new_moon_day(k + i, time_zone), time_zone)
- if not (arc != last and i < 14):
+ if arc == last or i >= 14:
break
return i - 1
@@ -945,7 +944,6 @@ def get_auspicious_day(lunar_month: int, jd: int) -> dict[str, Any]:
if lunar_month in months:
start_chi_index = start_index
break
-
if start_chi_index == -1:
return {"day_type": "unknown", "name": "Không xác định"}
@@ -1043,21 +1041,22 @@ def date_conversion_tool(conversion_type: str, date: str, **kwargs) -> dict[str,
day, month, year = split_date(date)
if conversion_type == "s2l":
try:
- response = {}
lunar_date = solar_to_lunar(day, month, year)
days = get_number_of_days(date)
lunar_month = MONTHS[lunar_date[1] - 1] + (" nhuận" if lunar_date[3] == 1 else "")
- can_chi_day = CAN[(lunar_date[4] + 9) % 10] + " " + CHI[(lunar_date[4] + 1) % 12]
- can_chi_month = CAN[(lunar_date[2] * 12 + lunar_date[1] + 3) % 10] + " " + CHI[(lunar_date[1] + 1) % 12]
- can_chi_year = CAN[(lunar_date[2] + 6) % 10] + " " + CHI[(lunar_date[2] + 8) % 12]
+ can_chi_day = f"{CAN[(lunar_date[4] + 9) % 10]} {CHI[(lunar_date[4] + 1) % 12]}"
+ can_chi_month = f"{CAN[(lunar_date[2] * 12 + lunar_date[1] + 3) % 10]} {CHI[(lunar_date[1] + 1) % 12]}"
+ can_chi_year = f"{CAN[(lunar_date[2] + 6) % 10]} {CHI[(lunar_date[2] + 8) % 12]}"
auspicious_hours = get_auspicious_hours(lunar_date[4])
auspicious_day = get_auspicious_day(lunar_date[1], lunar_date[4])
twelve_day_officers = get_twelve_day_officers(lunar_date[4])
twenty_eight_mansions = get_twenty_eight_mansions(lunar_date[4])
- response["mode"] = "s2l"
- response["solar_date"] = date
- response["lunar_date"] = f"{lunar_date[2]:04d}-{lunar_date[1]:02d}-{lunar_date[0]:02d}"
- response["weekday_vi"] = f"{DAYS[get_day_of_week(day, month, year)]}"
+ response = {
+ "mode": "s2l",
+ "solar_date": date,
+ "lunar_date": f"{lunar_date[2]:04d}-{lunar_date[1]:02d}-{lunar_date[0]:02d}",
+ "weekday_vi": f"{DAYS[get_day_of_week(day, month, year)]}",
+ }
response["difference_days"] = abs(days)
response["difference_direction"] = "days_remaining" if days >= 0 else "days_elapsed"
response["relative_to"] = datetime.date.today().isoformat()
@@ -1091,7 +1090,6 @@ def date_conversion_tool(conversion_type: str, date: str, **kwargs) -> dict[str,
if day > 30:
return {"error": "Invalid date: Lunar day must be less than or equal to 30"}
try:
- response = {}
leap_month = bool(kwargs.get("leap_month", False))
is_leap = 1 if leap_month else 0
solar_date = lunar_to_solar(day, month, year, is_leap)
@@ -1102,16 +1100,18 @@ def date_conversion_tool(conversion_type: str, date: str, **kwargs) -> dict[str,
}
days = get_number_of_days(join_date(solar_date[0], solar_date[1], solar_date[2]))
day_number = jd_from_date(solar_date[0], solar_date[1], solar_date[2])
- can_chi_day = CAN[(day_number + 9) % 10] + " " + CHI[(day_number + 1) % 12]
- can_chi_month = CAN[(year * 12 + month + 3) % 10] + " " + CHI[(month + 1) % 12]
- can_chi_year = CAN[(year + 6) % 10] + " " + CHI[(year + 8) % 12]
+ can_chi_day = f"{CAN[(day_number + 9) % 10]} {CHI[(day_number + 1) % 12]}"
+ can_chi_month = f"{CAN[(year * 12 + month + 3) % 10]} {CHI[(month + 1) % 12]}"
+ can_chi_year = f"{CAN[(year + 6) % 10]} {CHI[(year + 8) % 12]}"
auspicious_hours = get_auspicious_hours(day_number)
auspicious_day = get_auspicious_day(month, day_number)
twelve_day_officers = get_twelve_day_officers(day_number)
twenty_eight_mansions = get_twenty_eight_mansions(day_number)
- response["mode"] = "l2s"
- response["solar_date"] = join_date(solar_date[0], solar_date[1], solar_date[2])
- response["lunar_date"] = date
+ response = {
+ "mode": "l2s",
+ "solar_date": join_date(solar_date[0], solar_date[1], solar_date[2]),
+ "lunar_date": date,
+ }
response["weekday_vi"] = f"{DAYS[get_day_of_week(solar_date[0], solar_date[1], solar_date[2])]}"
response["difference_days"] = abs(days)
response["difference_direction"] = "days_remaining" if days >= 0 else "days_elapsed"
diff --git a/scripts/memory.py b/scripts/memory.py
index 5789866..d9d063a 100644
--- a/scripts/memory.py
+++ b/scripts/memory.py
@@ -197,9 +197,7 @@ def _ensure_db_once(force: bool = False) -> None:
@pyscript_compile # noqa: F821 # ty:ignore[unresolved-reference]
def _normalize_value(s: str) -> str:
"""Normalize text value to Unicode NFC form."""
- if s is None:
- return ""
- return unicodedata.normalize("NFC", str(s))
+ return "" if s is None else unicodedata.normalize("NFC", s)
@pyscript_compile # noqa: F821 # ty:ignore[unresolved-reference]
@@ -244,7 +242,7 @@ def _normalize_key(s: str) -> str:
"""Normalize a memory key to a standard alphanumeric format."""
if s is None:
return ""
- s = str(s).strip().lower()
+ s = s.strip().lower()
s = _strip_diacritics(s)
s = re.sub(r"[^a-z0-9_]", "_", s)
s = re.sub(r"_+", "_", s).strip("_")
@@ -255,7 +253,7 @@ def _condense_candidate_for_selection(entry: dict[str, Any], *, score: float | N
"""Condense a database entry for inclusion in result lists."""
value = entry.get("value")
if isinstance(value, str) and len(value) > VALUE_PREVIEW_CHARS:
- value = value[: VALUE_PREVIEW_CHARS - 3] + "..."
+ value = f"{value[: VALUE_PREVIEW_CHARS - 3]}..."
data = {
"key": entry.get("key"),
"value": value,
@@ -360,9 +358,7 @@ async def _find_tag_matches_for_query(
def _tokenize_query(q: str) -> list[str]:
"""Tokenize query string into normalized word tokens."""
normalized = _normalize_search_text(q)
- if not normalized:
- return []
- return normalized.split()
+ return normalized.split() if normalized else []
@pyscript_compile # noqa: F821 # ty:ignore[unresolved-reference]
@@ -371,11 +367,8 @@ def _near_distance_for_tokens(n: int) -> int:
if n <= 1:
return 0
val = 2 * n - 1
- if val < 3:
- val = 3
- if val > NEAR_DISTANCE:
- val = NEAR_DISTANCE
- return val
+ val = max(val, 3)
+ return min(val, NEAR_DISTANCE)
@pyscript_compile # noqa: F821 # ty:ignore[unresolved-reference]
@@ -390,7 +383,6 @@ def _build_fts_queries(raw_query: str) -> list[str]:
phrase = " ".join(tokens)
variants.append(f'"{phrase}"')
- if len(tokens) >= 2:
near_inner = " ".join(tokens)
near_dist = _near_distance_for_tokens(len(tokens))
variants.append(f"NEAR({near_inner}, {near_dist})")
@@ -405,8 +397,7 @@ def _build_fts_queries(raw_query: str) -> list[str]:
if normalized_query:
variants.append(normalized_query)
- rq = (raw_query or "").strip()
- if rq:
+ if rq := (raw_query or "").strip():
variants.append(rq)
seen = set()
@@ -437,8 +428,7 @@ def _fetch_with_expiry(cur: sqlite3.Cursor, key: str) -> tuple[bool, sqlite3.Row
).fetchone()
if not row:
return False, None
- expires_at = row["expires_at"]
- if expires_at:
+ if expires_at := row["expires_at"]:
dt = _dt_from_iso(expires_at)
if dt and datetime.now(UTC) > dt:
return True, row
@@ -448,7 +438,7 @@ def _fetch_with_expiry(cur: sqlite3.Cursor, key: str) -> tuple[bool, sqlite3.Row
def _set_result(state_value: str = "ok", **attrs: Any) -> None:
"""Update the memory result sensor state and attributes."""
_ensure_result_entity_name()
- attrs.update(result_entity_name)
+ attrs |= result_entity_name
state.set(RESULT_ENTITY, value=state_value, new_attributes=attrs) # noqa: F821 # ty:ignore[unresolved-reference]
@@ -693,7 +683,7 @@ def _memory_forget_db_sync(key_norm: str) -> int:
@pyscript_compile # noqa: F821 # ty:ignore[unresolved-reference]
def _memory_purge_expired_db_sync(grace_days: int = 0) -> int:
"""Synchronously remove expired memory records."""
- grace = max(int(grace_days), 0)
+ grace = max(grace_days, 0)
cutoff_dt = datetime.now(UTC) - timedelta(days=grace)
cutoff_iso = cutoff_dt.isoformat()
for attempt in range(2):
@@ -965,22 +955,19 @@ async def memory_set(
}
try:
- expiration_days_i = int(expiration_days)
+ expiration_days_i = expiration_days
except (TypeError, ValueError):
expiration_days_i = 0
- if expiration_days_i < 0:
- expiration_days_i = 0
- if expiration_days_i > EXPIRATION_MAX_DAYS:
- expiration_days_i = EXPIRATION_MAX_DAYS
-
+ expiration_days_i = max(expiration_days_i, 0)
+ expiration_days_i = min(expiration_days_i, EXPIRATION_MAX_DAYS)
if isinstance(force_new, str):
force_new_bool = force_new.strip().lower() in {"1", "true", "yes", "y", "on"}
else:
- force_new_bool = bool(force_new)
+ force_new_bool = force_new
forced_duplicate_override = False
try:
- scope_norm = ("" if scope is None else str(scope).strip()).lower() or "user"
+ scope_norm = ("" if scope is None else scope.strip()).lower() or "user"
value_norm = _normalize_value(value)
tags_raw = _normalize_value(tags) if tags else _normalize_value(key)
tags_search = _normalize_tags(tags_raw)
@@ -1179,14 +1166,11 @@ async def memory_search(query: str, limit: int = 5):
}
try:
- lim = int(limit)
+ lim = limit
except (TypeError, ValueError):
lim = 5
- if lim < 1:
- lim = 1
- if lim > SEARCH_LIMIT_MAX:
- lim = SEARCH_LIMIT_MAX
-
+ lim = max(lim, 1)
+ lim = min(lim, SEARCH_LIMIT_MAX)
try:
results = await _memory_search_db(query, lim)
except Exception as e:
@@ -1291,11 +1275,8 @@ async def memory_purge_expired(grace_days: int | None = None):
grace = int(grace_days)
except (TypeError, ValueError):
grace = 0
- if grace < 0:
- grace = 0
- if grace > HOUSEKEEPING_GRACE_MAX_DAYS:
- grace = HOUSEKEEPING_GRACE_MAX_DAYS
-
+ grace = max(grace, 0)
+ grace = min(grace, HOUSEKEEPING_GRACE_MAX_DAYS)
try:
removed = await _memory_purge_expired_db(grace)
except Exception as e:
diff --git a/scripts/telegram_bot_handle_tool.py b/scripts/telegram_bot_handle_tool.py
index a958f4e..23774db 100644
--- a/scripts/telegram_bot_handle_tool.py
+++ b/scripts/telegram_bot_handle_tool.py
@@ -2,6 +2,7 @@
import contextlib
import mimetypes
import os
+import re
import secrets
import time
from pathlib import Path
@@ -17,6 +18,7 @@
TOKEN = TOKEN.strip()
_session: httpx.AsyncClient | None = None
+_session_lock = asyncio.Lock()
if not TOKEN:
@@ -43,6 +45,20 @@
)
+@pyscript_compile # noqa: F821 # ty:ignore[unresolved-reference]
+def _create_session() -> httpx.AsyncClient:
+ """Create the HTTPX client in native Python for executor-safe SSL setup."""
+ return httpx.AsyncClient(http2=True, timeout=httpx.Timeout(300))
+
+
+_COORDINATE_PATTERNS: tuple[re.Pattern[str], ...] = (
+ re.compile(r"@\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*,\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))"),
+ re.compile(r"!3d\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+)).*?!4d\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))"),
+ re.compile(r"(?:[?&](?:q|query|ll)=)\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*,\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))"),
+ re.compile(r"(? str:
"""Normalize and validate a Home Assistant media path to start with /media/."""
if path.startswith("local/"):
@@ -77,7 +93,9 @@ async def _ensure_session() -> httpx.AsyncClient:
"""Create or return a shared httpx AsyncClient with HTTP/2."""
global _session
if _session is None or _session.is_closed:
- _session = httpx.AsyncClient(http2=True, timeout=httpx.Timeout(300))
+ async with _session_lock:
+ if _session is None or _session.is_closed:
+ _session = await asyncio.to_thread(_create_session)
return _session
@@ -132,10 +150,10 @@ async def _send_message(
url = f"https://api.telegram.org/bot{TOKEN}/sendMessage"
text = message
if len(text) > 4096:
- text = text[:4093] + "..."
+ text = f"{text[:4093]}..."
payload = {"chat_id": chat_id, "text": text}
- if reply_to_message_id:
- payload["reply_to_message_id"] = reply_to_message_id
+ if reply_to_message_id is not None:
+ payload["reply_parameters"] = {"message_id": reply_to_message_id}
if message_thread_id:
payload["message_thread_id"] = message_thread_id
if parse_mode:
@@ -148,6 +166,58 @@ async def _send_message(
return orjson.loads(resp.content)
+async def _send_location(
+ client: httpx.AsyncClient,
+ chat_id: int | str,
+ latitude: float,
+ longitude: float,
+ horizontal_accuracy: float | None = None,
+ live_period: int | None = None,
+ heading: int | None = None,
+ proximity_alert_radius: int | None = None,
+ reply_to_message_id: int | None = None,
+ message_thread_id: int | None = None,
+ business_connection_id: str | None = None,
+ direct_messages_topic_id: int | None = None,
+ receiver_user_id: int | None = None,
+ callback_query_id: str | None = None,
+ disable_notification: bool | None = None,
+ protect_content: bool | None = None,
+ allow_paid_broadcast: bool | None = None,
+ message_effect_id: str | None = None,
+) -> dict[str, Any]:
+ """Send a map location via the Telegram API."""
+ payload: dict[str, Any] = {
+ "chat_id": chat_id,
+ "latitude": latitude,
+ "longitude": longitude,
+ }
+ optional_fields = {
+ "horizontal_accuracy": horizontal_accuracy,
+ "live_period": live_period,
+ "heading": heading,
+ "proximity_alert_radius": proximity_alert_radius,
+ "message_thread_id": message_thread_id,
+ "business_connection_id": business_connection_id,
+ "direct_messages_topic_id": direct_messages_topic_id,
+ "receiver_user_id": receiver_user_id,
+ "callback_query_id": callback_query_id,
+ "disable_notification": disable_notification,
+ "protect_content": protect_content,
+ "allow_paid_broadcast": allow_paid_broadcast,
+ "message_effect_id": message_effect_id,
+ }
+ payload |= {name: value for name, value in optional_fields.items() if value is not None and value != ""}
+ if reply_to_message_id is not None:
+ payload["reply_parameters"] = {"message_id": reply_to_message_id}
+
+ url = f"https://api.telegram.org/bot{TOKEN}/sendLocation"
+ data = orjson.dumps(payload).decode("utf-8")
+ resp = await client.post(url, content=data, headers={"Content-Type": "application/json"})
+ resp.raise_for_status()
+ return orjson.loads(resp.content)
+
+
async def _send_photo(
client: httpx.AsyncClient,
chat_id: int | str,
@@ -177,8 +247,8 @@ async def _send_photo(
if parse_mode not in PARSE_MODES:
raise ValueError(f"Unsupported parse_mode: {parse_mode}. Allowed: {', '.join(PARSE_MODES)}")
form_data["parse_mode"] = parse_mode
- if reply_to_message_id:
- form_data["reply_to_message_id"] = str(reply_to_message_id)
+ if reply_to_message_id is not None:
+ form_data["reply_parameters"] = orjson.dumps({"message_id": reply_to_message_id}).decode("utf-8")
if message_thread_id:
form_data["message_thread_id"] = str(message_thread_id)
@@ -193,6 +263,315 @@ async def _send_photo(
await asyncio.to_thread(f.close)
+async def _send_media_file(
+ client: httpx.AsyncClient,
+ chat_id: int | str,
+ file_path: str,
+ endpoint: str,
+ field_name: str,
+ caption: str | None = None,
+ reply_to_message_id: int | None = None,
+ message_thread_id: int | None = None,
+ parse_mode: str | None = None,
+ fields: dict[str, Any] | None = None,
+ attachments: dict[str, str] | None = None,
+) -> dict[str, Any]:
+ """Upload and send a local media file through a Telegram multipart endpoint."""
+ file_path = _to_media_path(file_path)
+
+ file_exists = await asyncio.to_thread(os.path.isfile, file_path)
+ if not file_exists:
+ raise FileNotFoundError(f"File not found: {file_path}")
+
+ form_data: dict[str, Any] = {"chat_id": str(chat_id)}
+ if caption:
+ form_data["caption"] = caption[:1024]
+ if parse_mode:
+ if parse_mode not in PARSE_MODES:
+ raise ValueError(f"Unsupported parse_mode: {parse_mode}. Allowed: {', '.join(PARSE_MODES)}")
+ form_data["parse_mode"] = parse_mode
+ if reply_to_message_id is not None:
+ form_data["reply_parameters"] = orjson.dumps({"message_id": reply_to_message_id}).decode("utf-8")
+ if message_thread_id is not None:
+ form_data["message_thread_id"] = str(message_thread_id)
+ if fields:
+ for name, value in fields.items():
+ if value is not None:
+ form_data[name] = str(value).lower() if isinstance(value, bool) else str(value)
+
+ paths = {field_name: file_path, **(attachments or {})}
+ opened_files: dict[str, Any] = {}
+ try:
+ files: dict[str, tuple[str, Any, str]] = {}
+ for field, path in paths.items():
+ normalized_path = _to_media_path(path)
+ if not await asyncio.to_thread(os.path.isfile, normalized_path):
+ raise FileNotFoundError(f"File not found: {normalized_path}")
+ attachment_name = os.path.basename(normalized_path)
+ attachment_type, _ = mimetypes.guess_file_type(attachment_name)
+ attachment_file = await asyncio.to_thread(_open_file, normalized_path, "rb")
+ opened_files[field] = attachment_file
+ files[field] = (attachment_name, attachment_file, attachment_type or "application/octet-stream")
+
+ url = f"https://api.telegram.org/bot{TOKEN}/{endpoint}"
+ resp = await client.post(url, data=form_data, files=files)
+ resp.raise_for_status()
+ return orjson.loads(resp.content)
+ finally:
+ for attachment_file in opened_files.values():
+ await asyncio.to_thread(attachment_file.close)
+
+
+def _common_media_fields(
+ business_connection_id: str | None = None,
+ direct_messages_topic_id: int | None = None,
+ receiver_user_id: int | None = None,
+ callback_query_id: str | None = None,
+ disable_notification: bool | None = None,
+ protect_content: bool | None = None,
+ allow_paid_broadcast: bool | None = None,
+ message_effect_id: str | None = None,
+) -> dict[str, Any]:
+ """Return common optional fields supported by Telegram send-media methods."""
+ return {
+ "business_connection_id": business_connection_id,
+ "direct_messages_topic_id": direct_messages_topic_id,
+ "receiver_user_id": receiver_user_id,
+ "callback_query_id": callback_query_id,
+ "disable_notification": disable_notification,
+ "protect_content": protect_content,
+ "allow_paid_broadcast": allow_paid_broadcast,
+ "message_effect_id": message_effect_id,
+ }
+
+
+def _parse_coordinates(value: Any) -> tuple[float, float] | None:
+ """Extract latitude and longitude from text, a coordinate pair, or a Google Maps URL."""
+ if isinstance(value, str):
+ decoded = value.replace("%2C", ",").replace("%2c", ",")
+ else:
+ try:
+ values = tuple(value)
+ except TypeError:
+ decoded = str(value)
+ else:
+ if len(values) == 2:
+ with contextlib.suppress(TypeError, ValueError):
+ return float(values[0]), float(values[1])
+ decoded = str(value)
+
+ decoded = decoded.replace("%2C", ",").replace("%2c", ",")
+ for pattern in _COORDINATE_PATTERNS:
+ if match := pattern.search(decoded):
+ return float(match.group(1)), float(match.group(2))
+ return None
+
+
+async def _send_audio(
+ client: httpx.AsyncClient,
+ chat_id: int | str,
+ file_path: str,
+ caption: str | None = None,
+ duration: int | None = None,
+ performer: str | None = None,
+ title: str | None = None,
+ thumbnail_path: str | None = None,
+ business_connection_id: str | None = None,
+ direct_messages_topic_id: int | None = None,
+ receiver_user_id: int | None = None,
+ callback_query_id: str | None = None,
+ disable_notification: bool | None = None,
+ protect_content: bool | None = None,
+ allow_paid_broadcast: bool | None = None,
+ message_effect_id: str | None = None,
+ reply_to_message_id: int | None = None,
+ message_thread_id: int | None = None,
+ parse_mode: str | None = None,
+) -> dict[str, Any]:
+ """Upload and send an audio file via the Telegram API."""
+ return await _send_media_file(
+ client,
+ chat_id,
+ file_path,
+ endpoint="sendAudio",
+ field_name="audio",
+ caption=caption,
+ reply_to_message_id=reply_to_message_id,
+ message_thread_id=message_thread_id,
+ parse_mode=parse_mode,
+ fields={
+ "duration": duration,
+ "performer": performer,
+ "title": title,
+ **_common_media_fields(
+ business_connection_id,
+ direct_messages_topic_id,
+ receiver_user_id,
+ callback_query_id,
+ disable_notification,
+ protect_content,
+ allow_paid_broadcast,
+ message_effect_id,
+ ),
+ },
+ attachments={"thumbnail": thumbnail_path} if thumbnail_path else None,
+ )
+
+
+async def _send_document(
+ client: httpx.AsyncClient,
+ chat_id: int | str,
+ file_path: str,
+ caption: str | None = None,
+ disable_content_type_detection: bool | None = None,
+ thumbnail_path: str | None = None,
+ business_connection_id: str | None = None,
+ direct_messages_topic_id: int | None = None,
+ receiver_user_id: int | None = None,
+ callback_query_id: str | None = None,
+ disable_notification: bool | None = None,
+ protect_content: bool | None = None,
+ allow_paid_broadcast: bool | None = None,
+ message_effect_id: str | None = None,
+ reply_to_message_id: int | None = None,
+ message_thread_id: int | None = None,
+ parse_mode: str | None = None,
+) -> dict[str, Any]:
+ """Upload and send a document file via the Telegram API."""
+ return await _send_media_file(
+ client,
+ chat_id,
+ file_path,
+ endpoint="sendDocument",
+ field_name="document",
+ caption=caption,
+ reply_to_message_id=reply_to_message_id,
+ message_thread_id=message_thread_id,
+ parse_mode=parse_mode,
+ fields={
+ "disable_content_type_detection": disable_content_type_detection,
+ **_common_media_fields(
+ business_connection_id,
+ direct_messages_topic_id,
+ receiver_user_id,
+ callback_query_id,
+ disable_notification,
+ protect_content,
+ allow_paid_broadcast,
+ message_effect_id,
+ ),
+ },
+ attachments={"thumbnail": thumbnail_path} if thumbnail_path else None,
+ )
+
+
+async def _send_video(
+ client: httpx.AsyncClient,
+ chat_id: int | str,
+ file_path: str,
+ caption: str | None = None,
+ duration: int | None = None,
+ width: int | None = None,
+ height: int | None = None,
+ supports_streaming: bool | None = None,
+ has_spoiler: bool | None = None,
+ thumbnail_path: str | None = None,
+ cover_path: str | None = None,
+ start_timestamp: int | None = None,
+ show_caption_above_media: bool | None = None,
+ business_connection_id: str | None = None,
+ direct_messages_topic_id: int | None = None,
+ receiver_user_id: int | None = None,
+ callback_query_id: str | None = None,
+ disable_notification: bool | None = None,
+ protect_content: bool | None = None,
+ allow_paid_broadcast: bool | None = None,
+ message_effect_id: str | None = None,
+ reply_to_message_id: int | None = None,
+ message_thread_id: int | None = None,
+ parse_mode: str | None = None,
+) -> dict[str, Any]:
+ """Upload and send a video file via the Telegram API."""
+ return await _send_media_file(
+ client,
+ chat_id,
+ file_path,
+ endpoint="sendVideo",
+ field_name="video",
+ caption=caption,
+ reply_to_message_id=reply_to_message_id,
+ message_thread_id=message_thread_id,
+ parse_mode=parse_mode,
+ fields={
+ "duration": duration,
+ "width": width,
+ "height": height,
+ "supports_streaming": supports_streaming,
+ "has_spoiler": has_spoiler,
+ "start_timestamp": start_timestamp,
+ "show_caption_above_media": show_caption_above_media,
+ **_common_media_fields(
+ business_connection_id,
+ direct_messages_topic_id,
+ receiver_user_id,
+ callback_query_id,
+ disable_notification,
+ protect_content,
+ allow_paid_broadcast,
+ message_effect_id,
+ ),
+ },
+ attachments=({"thumbnail": thumbnail_path} if thumbnail_path else {})
+ | ({"cover": cover_path} if cover_path else {})
+ or None,
+ )
+
+
+async def _send_voice(
+ client: httpx.AsyncClient,
+ chat_id: int | str,
+ file_path: str,
+ caption: str | None = None,
+ duration: int | None = None,
+ business_connection_id: str | None = None,
+ direct_messages_topic_id: int | None = None,
+ receiver_user_id: int | None = None,
+ callback_query_id: str | None = None,
+ disable_notification: bool | None = None,
+ protect_content: bool | None = None,
+ allow_paid_broadcast: bool | None = None,
+ message_effect_id: str | None = None,
+ reply_to_message_id: int | None = None,
+ message_thread_id: int | None = None,
+ parse_mode: str | None = None,
+) -> dict[str, Any]:
+ """Upload and send a voice message via the Telegram API."""
+ return await _send_media_file(
+ client,
+ chat_id,
+ file_path,
+ endpoint="sendVoice",
+ field_name="voice",
+ caption=caption,
+ reply_to_message_id=reply_to_message_id,
+ message_thread_id=message_thread_id,
+ parse_mode=parse_mode,
+ fields={
+ "duration": duration,
+ **_common_media_fields(
+ business_connection_id,
+ direct_messages_topic_id,
+ receiver_user_id,
+ callback_query_id,
+ disable_notification,
+ protect_content,
+ allow_paid_broadcast,
+ message_effect_id,
+ ),
+ },
+ )
+
+
async def _get_webhook_info(client: httpx.AsyncClient) -> dict[str, Any]:
"""Retrieve current Telegram webhook status."""
url = f"https://api.telegram.org/bot{TOKEN}/getWebhookInfo"
@@ -321,11 +700,9 @@ def _cleanup_disk_sync(directory: str, cutoff: float) -> None:
return
for entry in path.iterdir():
- try:
+ with contextlib.suppress(OSError):
if entry.is_file() and entry.stat().st_mtime < cutoff:
entry.unlink()
- except OSError:
- pass
async def _cleanup_old_files(directory: str, days: int = 30) -> None:
@@ -415,9 +792,7 @@ async def send_telegram_message(
message_thread_id=message_thread_id,
parse_mode=parse_mode,
)
- if not response:
- return {"error": "Failed to send message"}
- return response
+ return response or {"error": "Failed to send message"}
except Exception as error:
log.error(f"{__name__}: {error}") # noqa: F821 # ty:ignore[unresolved-reference]
return {"error": f"An unexpected error occurred during processing: {error}"}
@@ -450,18 +825,25 @@ async def get_telegram_file(file_id: str) -> dict[str, Any]:
mimetypes.add_type("text/plain", ".yaml")
mime_type, _ = mimetypes.guess_file_type(file_path)
file_path = _to_relative_path(file_path)
- response: dict[str, Any] = {"file_path": file_path, "mime_type": mime_type}
support_file_types = (
"image/",
"video/",
"audio/",
"text/",
"application/pdf",
+ "application/rtf",
+ "application/msword",
+ "application/vnd.ms-excel",
+ "application/vnd.ms-powerpoint",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
)
- if mime_type and mime_type.startswith(support_file_types):
- response["supported"] = True
- else:
- response["supported"] = False
+ response: dict[str, Any] = {
+ "file_path": file_path,
+ "mime_type": mime_type,
+ "supported": bool(mime_type and mime_type.lower().startswith(support_file_types)),
+ }
return response
except Exception as error:
log.error(f"{__name__}: {error}") # noqa: F821 # ty:ignore[unresolved-reference]
@@ -648,9 +1030,194 @@ async def send_telegram_chat_action(
message_thread_id,
action=action,
)
- if not response:
- return {"error": "Failed to send message"}
- return response
+ return response or {"error": "Failed to send message"}
+ except Exception as error:
+ log.error(f"{__name__}: {error}") # noqa: F821 # ty:ignore[unresolved-reference]
+ return {"error": f"An unexpected error occurred during processing: {error}"}
+
+
+@service(supports_response="only") # noqa: F821 # ty:ignore[unresolved-reference]
+async def send_telegram_location(
+ chat_id: str,
+ location: str | None = None,
+ latitude: float | None = None,
+ longitude: float | None = None,
+ horizontal_accuracy: float | None = None,
+ live_period: int | None = None,
+ heading: int | None = None,
+ proximity_alert_radius: int | None = None,
+ reply_to_message_id: int | None = None,
+ message_thread_id: int | None = None,
+ business_connection_id: str | None = None,
+ direct_messages_topic_id: int | None = None,
+ receiver_user_id: int | None = None,
+ callback_query_id: str | None = None,
+ disable_notification: bool | None = None,
+ protect_content: bool | None = None,
+ allow_paid_broadcast: bool | None = None,
+ message_effect_id: str | None = None,
+) -> dict[str, Any]:
+ """
+ yaml
+ name: Send Telegram Location
+ description: Send a Telegram map pin from latitude/longitude or a Google Maps URL containing coordinates.
+ fields:
+ chat_id:
+ name: Chat ID
+ description: ID of the conversation (user or group).
+ required: true
+ selector:
+ text:
+ location:
+ name: Location
+ description: Coordinates as latitude,longitude or a Google Maps URL containing coordinates.
+ selector:
+ text:
+ latitude:
+ name: Latitude
+ description: Latitude in degrees from -90 to 90. Overrides the value parsed from location.
+ selector:
+ number:
+ min: -90
+ max: 90
+ step: 0.000001
+ longitude:
+ name: Longitude
+ description: Longitude in degrees from -180 to 180. Overrides the value parsed from location.
+ selector:
+ number:
+ min: -180
+ max: 180
+ step: 0.000001
+ horizontal_accuracy:
+ name: Horizontal Accuracy
+ description: Optional radius of uncertainty in meters, from 0 to 1500.
+ selector:
+ number:
+ min: 0
+ max: 1500
+ step: 0.1
+ live_period:
+ name: Live Period
+ description: "Optional live-location period in seconds: 60-86400 or 2147483647."
+ selector:
+ number:
+ min: 60
+ max: 2147483647
+ step: 1
+ heading:
+ name: Heading
+ description: Optional direction of movement for a live location, from 1 to 360 degrees.
+ selector:
+ number:
+ min: 1
+ max: 360
+ step: 1
+ proximity_alert_radius:
+ name: Proximity Alert Radius
+ description: Optional proximity alert radius in meters for a live location.
+ selector:
+ number:
+ min: 1
+ step: 1
+ reply_to_message_id:
+ name: Reply To Message ID
+ description: Optional message ID to reply to.
+ selector:
+ number:
+ min: 1
+ step: 1
+ message_thread_id:
+ name: Message Thread ID
+ description: Optional forum topic/thread ID.
+ selector:
+ number:
+ min: 1
+ step: 1
+ disable_notification:
+ name: Disable Notification
+ description: Send the location silently.
+ selector:
+ boolean:
+ protect_content:
+ name: Protect Content
+ description: Prevent forwarding and saving.
+ selector:
+ boolean:
+ allow_paid_broadcast:
+ name: Allow Paid Broadcast
+ description: Allow paid high-rate broadcasting.
+ selector:
+ boolean:
+ """
+ if not chat_id:
+ return {"error": "Missing a required argument: chat_id"}
+
+ if latitude is None or longitude is None:
+ if not location:
+ return {"error": "Provide latitude and longitude, or a Google Maps URL containing coordinates"}
+ coordinates = _parse_coordinates(location)
+ if not coordinates:
+ return {"error": "Unable to find coordinates in location; provide latitude and longitude explicitly"}
+ if latitude is None:
+ latitude = coordinates[0]
+ if longitude is None:
+ longitude = coordinates[1]
+
+ try:
+ latitude = float(latitude)
+ longitude = float(longitude)
+ except (TypeError, ValueError):
+ return {"error": "Latitude and longitude must be valid numbers"}
+
+ if not -90 <= latitude <= 90:
+ return {"error": "Latitude must be between -90 and 90"}
+ if not -180 <= longitude <= 180:
+ return {"error": "Longitude must be between -180 and 180"}
+ try:
+ if horizontal_accuracy is not None:
+ horizontal_accuracy = float(horizontal_accuracy)
+ if live_period is not None:
+ live_period = int(live_period)
+ if heading is not None:
+ heading = int(heading)
+ if proximity_alert_radius is not None:
+ proximity_alert_radius = int(proximity_alert_radius)
+ except (TypeError, ValueError):
+ return {"error": "Location optional parameters must be valid numbers"}
+
+ if horizontal_accuracy is not None and not 0 <= horizontal_accuracy <= 1500:
+ return {"error": "Horizontal accuracy must be between 0 and 1500 meters"}
+ if live_period is not None and live_period not in range(60, 86401) and live_period != 2147483647:
+ return {"error": "Live period must be between 60 and 86400 seconds, or 2147483647"}
+ if heading is not None and not 1 <= heading <= 360:
+ return {"error": "Heading must be between 1 and 360 degrees"}
+ if proximity_alert_radius is not None and proximity_alert_radius < 1:
+ return {"error": "Proximity alert radius must be at least 1 meter"}
+
+ try:
+ client = await _ensure_session()
+ response = await _send_location(
+ client,
+ chat_id,
+ latitude,
+ longitude,
+ horizontal_accuracy=horizontal_accuracy,
+ live_period=live_period,
+ heading=heading,
+ proximity_alert_radius=proximity_alert_radius,
+ reply_to_message_id=reply_to_message_id,
+ message_thread_id=message_thread_id,
+ business_connection_id=business_connection_id,
+ direct_messages_topic_id=direct_messages_topic_id,
+ receiver_user_id=receiver_user_id,
+ callback_query_id=callback_query_id,
+ disable_notification=disable_notification,
+ protect_content=protect_content,
+ allow_paid_broadcast=allow_paid_broadcast,
+ message_effect_id=message_effect_id,
+ )
+ return response or {"error": "Failed to send location"}
except Exception as error:
log.error(f"{__name__}: {error}") # noqa: F821 # ty:ignore[unresolved-reference]
return {"error": f"An unexpected error occurred during processing: {error}"}
@@ -727,9 +1294,548 @@ async def send_telegram_photo(
message_thread_id=message_thread_id,
parse_mode=parse_mode,
)
- if not response:
- return {"error": "Failed to send photo"}
- return response
+ return response or {"error": "Failed to send photo"}
except Exception as error:
log.error(f"{__name__}: {error}") # noqa: F821 # ty:ignore[unresolved-reference]
return {"error": f"An unexpected error occurred during processing: {error}"}
+
+
+async def _send_telegram_media_action(
+ sender: Any,
+ media_name: str,
+ chat_id: str,
+ file_path: str,
+ **kwargs: Any,
+) -> dict[str, Any]:
+ """Validate and execute a media-send Home Assistant action."""
+ if not all([chat_id, file_path]):
+ return {"error": "Missing one or more required arguments: chat_id, file_path"}
+ if kwargs.get("parse_mode") and kwargs["parse_mode"] not in PARSE_MODES:
+ return {"error": f"Unsupported parse_mode: {kwargs['parse_mode']}. Allowed: {', '.join(PARSE_MODES)}"}
+ try:
+ client = await _ensure_session()
+ response = await sender(client, chat_id, file_path, **kwargs)
+ return response or {"error": f"Failed to send {media_name}"}
+ except Exception as error:
+ log.error(f"{__name__}: {error}") # noqa: F821 # ty:ignore[unresolved-reference]
+ return {"error": f"An unexpected error occurred during processing: {error}"}
+
+
+@service(supports_response="only") # noqa: F821 # ty:ignore[unresolved-reference]
+async def send_telegram_audio(
+ chat_id: str,
+ file_path: str,
+ caption: str | None = None,
+ duration: int | None = None,
+ performer: str | None = None,
+ title: str | None = None,
+ thumbnail_path: str | None = None,
+ reply_to_message_id: int | None = None,
+ message_thread_id: int | None = None,
+ parse_mode: str | None = None,
+ business_connection_id: str | None = None,
+ direct_messages_topic_id: int | None = None,
+ receiver_user_id: int | None = None,
+ callback_query_id: str | None = None,
+ disable_notification: bool | None = None,
+ protect_content: bool | None = None,
+ allow_paid_broadcast: bool | None = None,
+ message_effect_id: str | None = None,
+) -> dict[str, Any]:
+ """
+ yaml
+ name: Send Telegram Audio
+ description: Send a local MP3 or M4A audio file via Telegram multipart/form-data.
+ fields:
+ chat_id:
+ name: Chat ID
+ description: ID of the conversation (user or group).
+ required: true
+ selector:
+ text:
+ file_path:
+ name: File Path
+ description: Local audio path under /media or local/.
+ required: true
+ selector:
+ text:
+ caption:
+ name: Caption
+ description: Optional audio caption, up to 1024 characters.
+ selector:
+ text:
+ duration:
+ name: Duration
+ description: Optional duration of the audio in seconds.
+ selector:
+ number:
+ min: 0
+ step: 1
+ performer:
+ name: Performer
+ description: Optional performer name.
+ selector:
+ text:
+ title:
+ name: Title
+ description: Optional track title.
+ selector:
+ text:
+ thumbnail_path:
+ name: Thumbnail Path
+ description: Optional JPEG thumbnail under /media or local/ (max 200 kB and 320 px).
+ selector:
+ text:
+ reply_to_message_id:
+ name: Reply To Message ID
+ description: Optional message ID to reply to.
+ selector:
+ number:
+ min: 1
+ step: 1
+ message_thread_id:
+ name: Message Thread ID
+ description: Optional forum topic/thread ID.
+ selector:
+ number:
+ min: 1
+ step: 1
+ parse_mode:
+ name: Parse Mode
+ description: Format entities in the caption.
+ selector:
+ select:
+ mode: dropdown
+ options:
+ - HTML
+ - MarkdownV2
+ - Markdown
+ disable_notification:
+ name: Disable Notification
+ description: Send the audio silently.
+ selector:
+ boolean:
+ protect_content:
+ name: Protect Content
+ description: Prevent forwarding and saving.
+ selector:
+ boolean:
+ allow_paid_broadcast:
+ name: Allow Paid Broadcast
+ description: Allow paid high-rate broadcasting.
+ selector:
+ boolean:
+ """
+ return await _send_telegram_media_action(
+ _send_audio,
+ "audio",
+ chat_id,
+ file_path,
+ caption=caption,
+ duration=duration,
+ performer=performer,
+ title=title,
+ thumbnail_path=thumbnail_path,
+ reply_to_message_id=reply_to_message_id,
+ message_thread_id=message_thread_id,
+ parse_mode=parse_mode,
+ business_connection_id=business_connection_id,
+ direct_messages_topic_id=direct_messages_topic_id,
+ receiver_user_id=receiver_user_id,
+ callback_query_id=callback_query_id,
+ disable_notification=disable_notification,
+ protect_content=protect_content,
+ allow_paid_broadcast=allow_paid_broadcast,
+ message_effect_id=message_effect_id,
+ )
+
+
+@service(supports_response="only") # noqa: F821 # ty:ignore[unresolved-reference]
+async def send_telegram_document(
+ chat_id: str,
+ file_path: str,
+ caption: str | None = None,
+ thumbnail_path: str | None = None,
+ disable_content_type_detection: bool | None = None,
+ reply_to_message_id: int | None = None,
+ message_thread_id: int | None = None,
+ parse_mode: str | None = None,
+ business_connection_id: str | None = None,
+ direct_messages_topic_id: int | None = None,
+ receiver_user_id: int | None = None,
+ callback_query_id: str | None = None,
+ disable_notification: bool | None = None,
+ protect_content: bool | None = None,
+ allow_paid_broadcast: bool | None = None,
+ message_effect_id: str | None = None,
+) -> dict[str, Any]:
+ """
+ yaml
+ name: Send Telegram Document
+ description: Send a local file as a Telegram document via multipart/form-data.
+ fields:
+ chat_id:
+ name: Chat ID
+ description: ID of the conversation (user or group).
+ required: true
+ selector:
+ text:
+ file_path:
+ name: File Path
+ description: Local document path under /media or local/.
+ required: true
+ selector:
+ text:
+ caption:
+ name: Caption
+ description: Optional document caption, up to 1024 characters.
+ selector:
+ text:
+ thumbnail_path:
+ name: Thumbnail Path
+ description: Optional JPEG thumbnail under /media or local/ (max 200 kB and 320 px).
+ selector:
+ text:
+ disable_content_type_detection:
+ name: Disable Content Type Detection
+ description: Disable automatic content type detection for the uploaded file.
+ selector:
+ boolean:
+ reply_to_message_id:
+ name: Reply To Message ID
+ description: Optional message ID to reply to.
+ selector:
+ number:
+ min: 1
+ step: 1
+ message_thread_id:
+ name: Message Thread ID
+ description: Optional forum topic/thread ID.
+ selector:
+ number:
+ min: 1
+ step: 1
+ parse_mode:
+ name: Parse Mode
+ description: Format entities in the caption.
+ selector:
+ select:
+ mode: dropdown
+ options:
+ - HTML
+ - MarkdownV2
+ - Markdown
+ disable_notification:
+ name: Disable Notification
+ description: Send the document silently.
+ selector:
+ boolean:
+ protect_content:
+ name: Protect Content
+ description: Prevent forwarding and saving.
+ selector:
+ boolean:
+ allow_paid_broadcast:
+ name: Allow Paid Broadcast
+ description: Allow paid high-rate broadcasting.
+ selector:
+ boolean:
+ """
+ return await _send_telegram_media_action(
+ _send_document,
+ "document",
+ chat_id,
+ file_path,
+ caption=caption,
+ thumbnail_path=thumbnail_path,
+ disable_content_type_detection=disable_content_type_detection,
+ reply_to_message_id=reply_to_message_id,
+ message_thread_id=message_thread_id,
+ parse_mode=parse_mode,
+ business_connection_id=business_connection_id,
+ direct_messages_topic_id=direct_messages_topic_id,
+ receiver_user_id=receiver_user_id,
+ callback_query_id=callback_query_id,
+ disable_notification=disable_notification,
+ protect_content=protect_content,
+ allow_paid_broadcast=allow_paid_broadcast,
+ message_effect_id=message_effect_id,
+ )
+
+
+@service(supports_response="only") # noqa: F821 # ty:ignore[unresolved-reference]
+async def send_telegram_video(
+ chat_id: str,
+ file_path: str,
+ caption: str | None = None,
+ duration: int | None = None,
+ width: int | None = None,
+ height: int | None = None,
+ thumbnail_path: str | None = None,
+ cover_path: str | None = None,
+ start_timestamp: int | None = None,
+ show_caption_above_media: bool | None = None,
+ has_spoiler: bool | None = None,
+ supports_streaming: bool | None = None,
+ reply_to_message_id: int | None = None,
+ message_thread_id: int | None = None,
+ parse_mode: str | None = None,
+ business_connection_id: str | None = None,
+ direct_messages_topic_id: int | None = None,
+ receiver_user_id: int | None = None,
+ callback_query_id: str | None = None,
+ disable_notification: bool | None = None,
+ protect_content: bool | None = None,
+ allow_paid_broadcast: bool | None = None,
+ message_effect_id: str | None = None,
+) -> dict[str, Any]:
+ """
+ yaml
+ name: Send Telegram Video
+ description: Send a local MPEG-4 video via Telegram multipart/form-data.
+ fields:
+ chat_id:
+ name: Chat ID
+ description: ID of the conversation (user or group).
+ required: true
+ selector:
+ text:
+ file_path:
+ name: File Path
+ description: Local video path under /media or local/.
+ required: true
+ selector:
+ text:
+ caption:
+ name: Caption
+ description: Optional video caption, up to 1024 characters.
+ selector:
+ text:
+ duration:
+ name: Duration
+ description: Optional duration in seconds.
+ selector:
+ number:
+ min: 0
+ step: 1
+ width:
+ name: Width
+ description: Optional video width in pixels.
+ selector:
+ number:
+ min: 0
+ step: 1
+ height:
+ name: Height
+ description: Optional video height in pixels.
+ selector:
+ number:
+ min: 0
+ step: 1
+ thumbnail_path:
+ name: Thumbnail Path
+ description: Optional JPEG thumbnail under /media or local/ (max 200 kB and 320 px).
+ selector:
+ text:
+ cover_path:
+ name: Cover Path
+ description: Optional video cover under /media or local/.
+ selector:
+ text:
+ start_timestamp:
+ name: Start Timestamp
+ description: Optional start timestamp in seconds.
+ selector:
+ number:
+ min: 0
+ step: 1
+ show_caption_above_media:
+ name: Show Caption Above Media
+ description: Show the caption above the video.
+ selector:
+ boolean:
+ has_spoiler:
+ name: Has Spoiler
+ description: Cover the video with a spoiler animation.
+ selector:
+ boolean:
+ supports_streaming:
+ name: Supports Streaming
+ description: Mark the uploaded video as suitable for streaming.
+ selector:
+ boolean:
+ reply_to_message_id:
+ name: Reply To Message ID
+ description: Optional message ID to reply to.
+ selector:
+ number:
+ min: 1
+ step: 1
+ message_thread_id:
+ name: Message Thread ID
+ description: Optional forum topic/thread ID.
+ selector:
+ number:
+ min: 1
+ step: 1
+ parse_mode:
+ name: Parse Mode
+ description: Format entities in the caption.
+ selector:
+ select:
+ mode: dropdown
+ options:
+ - HTML
+ - MarkdownV2
+ - Markdown
+ disable_notification:
+ name: Disable Notification
+ description: Send the video silently.
+ selector:
+ boolean:
+ protect_content:
+ name: Protect Content
+ description: Prevent forwarding and saving.
+ selector:
+ boolean:
+ allow_paid_broadcast:
+ name: Allow Paid Broadcast
+ description: Allow paid high-rate broadcasting.
+ selector:
+ boolean:
+ """
+ return await _send_telegram_media_action(
+ _send_video,
+ "video",
+ chat_id,
+ file_path,
+ caption=caption,
+ duration=duration,
+ width=width,
+ height=height,
+ thumbnail_path=thumbnail_path,
+ cover_path=cover_path,
+ start_timestamp=start_timestamp,
+ show_caption_above_media=show_caption_above_media,
+ has_spoiler=has_spoiler,
+ supports_streaming=supports_streaming,
+ reply_to_message_id=reply_to_message_id,
+ message_thread_id=message_thread_id,
+ parse_mode=parse_mode,
+ business_connection_id=business_connection_id,
+ direct_messages_topic_id=direct_messages_topic_id,
+ receiver_user_id=receiver_user_id,
+ callback_query_id=callback_query_id,
+ disable_notification=disable_notification,
+ protect_content=protect_content,
+ allow_paid_broadcast=allow_paid_broadcast,
+ message_effect_id=message_effect_id,
+ )
+
+
+@service(supports_response="only") # noqa: F821 # ty:ignore[unresolved-reference]
+async def send_telegram_voice(
+ chat_id: str,
+ file_path: str,
+ caption: str | None = None,
+ duration: int | None = None,
+ reply_to_message_id: int | None = None,
+ message_thread_id: int | None = None,
+ parse_mode: str | None = None,
+ business_connection_id: str | None = None,
+ direct_messages_topic_id: int | None = None,
+ receiver_user_id: int | None = None,
+ callback_query_id: str | None = None,
+ disable_notification: bool | None = None,
+ protect_content: bool | None = None,
+ allow_paid_broadcast: bool | None = None,
+ message_effect_id: str | None = None,
+) -> dict[str, Any]:
+ """
+ yaml
+ name: Send Telegram Voice
+ description: Send a local OGG/Opus, MP3, or M4A voice message via Telegram multipart/form-data.
+ fields:
+ chat_id:
+ name: Chat ID
+ description: ID of the conversation (user or group).
+ required: true
+ selector:
+ text:
+ file_path:
+ name: File Path
+ description: Local voice path under /media or local/.
+ required: true
+ selector:
+ text:
+ caption:
+ name: Caption
+ description: Optional voice caption, up to 1024 characters.
+ selector:
+ text:
+ duration:
+ name: Duration
+ description: Optional duration in seconds.
+ selector:
+ number:
+ min: 0
+ step: 1
+ reply_to_message_id:
+ name: Reply To Message ID
+ description: Optional message ID to reply to.
+ selector:
+ number:
+ min: 1
+ step: 1
+ message_thread_id:
+ name: Message Thread ID
+ description: Optional forum topic/thread ID.
+ selector:
+ number:
+ min: 1
+ step: 1
+ parse_mode:
+ name: Parse Mode
+ description: Format entities in the caption.
+ selector:
+ select:
+ mode: dropdown
+ options:
+ - HTML
+ - MarkdownV2
+ - Markdown
+ disable_notification:
+ name: Disable Notification
+ description: Send the voice message silently.
+ selector:
+ boolean:
+ protect_content:
+ name: Protect Content
+ description: Prevent forwarding and saving.
+ selector:
+ boolean:
+ allow_paid_broadcast:
+ name: Allow Paid Broadcast
+ description: Allow paid high-rate broadcasting.
+ selector:
+ boolean:
+ """
+ return await _send_telegram_media_action(
+ _send_voice,
+ "voice message",
+ chat_id,
+ file_path,
+ caption=caption,
+ duration=duration,
+ reply_to_message_id=reply_to_message_id,
+ message_thread_id=message_thread_id,
+ parse_mode=parse_mode,
+ business_connection_id=business_connection_id,
+ direct_messages_topic_id=direct_messages_topic_id,
+ receiver_user_id=receiver_user_id,
+ callback_query_id=callback_query_id,
+ disable_notification=disable_notification,
+ protect_content=protect_content,
+ allow_paid_broadcast=allow_paid_broadcast,
+ message_effect_id=message_effect_id,
+ )
diff --git a/scripts/traffic_fine_lookup_tool.py b/scripts/traffic_fine_lookup_tool.py
index 50ddecb..904b24a 100644
--- a/scripts/traffic_fine_lookup_tool.py
+++ b/scripts/traffic_fine_lookup_tool.py
@@ -5,7 +5,7 @@
import threading
import time
import urllib.parse
-from contextlib import closing
+from contextlib import closing, suppress
from pathlib import Path
from typing import Any
@@ -40,6 +40,18 @@
BROWSERS: list[BrowserTypeLiteral] = ["chrome", "safari", "safari_ios"]
+@pyscript_compile # noqa: F821 # ty:ignore[unresolved-reference]
+def _create_http_session(loop: asyncio.AbstractEventLoop, browser: BrowserTypeLiteral) -> AsyncSession:
+ """Create the curl session off the event loop while preserving its loop binding."""
+ return AsyncSession(
+ loop=loop,
+ impersonate=browser,
+ timeout=60,
+ allow_redirects=CurlFollow.SAFE,
+ http_version=CurlHttpVersion.NONE,
+ )
+
+
@pyscript_compile # noqa: F821 # ty:ignore[unresolved-reference]
def _connect_db() -> sqlite3.Connection:
"""Create a configured SQLite connection with optimized PRAGMAs."""
@@ -239,11 +251,11 @@ async def _get_recaptcha_version(ss: AsyncSession) -> str | None:
"""Fetch the current reCAPTCHA JS version from Google's API."""
try:
url = f"https://www.google.com/recaptcha/api.js?render={RECAPTCHA_SITEKEY}"
- headers = {"Referer": BASE_URL + "/"}
+ headers = {"Referer": f"{BASE_URL}/"}
resp = await ss.get(url, headers=headers)
resp.raise_for_status()
match = re.search(r"/recaptcha/releases/([^/]+)/", resp.text)
- return match.group(1) if match else None
+ return match[1] if match else None
except RequestsError:
return None
@@ -265,14 +277,14 @@ async def _get_recaptcha_anchor(ss: AsyncSession, version: str) -> str | None:
anchor_url = f"https://www.google.com/recaptcha/api2/anchor?{anchor_params_str}"
anchor_headers = {
- "Referer": BASE_URL + "/",
+ "Referer": f"{BASE_URL}/",
"Accept-Language": "en-US,en;q=0.9,vi;q=0.8",
}
resp = await ss.get(anchor_url, headers=anchor_headers)
resp.raise_for_status()
match = re.search(r'id="recaptcha-token"\s+value="([^"]+)"', resp.text)
- return match.group(1) if match else None
+ return match[1] if match else None
except RequestsError:
return None
@@ -303,33 +315,27 @@ async def _get_recaptcha_reload(
resp.raise_for_status()
response_text = resp.text.lstrip(")]}'")
- try:
+ with suppress(orjson.JSONDecodeError):
rresp_data = orjson.loads(response_text)
if isinstance(rresp_data, list) and len(rresp_data) > 1 and rresp_data[0] == "rresp":
- token = rresp_data[1]
- if token:
+ if token := rresp_data[1]:
return token, None
return None, "Google returned a null token (Bot detected)"
- except orjson.JSONDecodeError:
- pass
-
- rresp_match = re.search(r'"rresp","([^"]+)"', resp.text)
- if not rresp_match:
+ if rresp_match := re.search(r'"rresp","([^"]+)"', resp.text):
+ return rresp_match[1], None
+ else:
return None, "Failed to extract response token"
- return rresp_match.group(1), None
except RequestsError as error:
return None, f"reCAPTCHA reload failed: {error}"
async def _get_recaptcha_clr(ss: AsyncSession) -> None:
"""Send reCAPTCHA clr request to clean up the session."""
- try:
+ with suppress(RequestsError):
url = f"https://www.google.com/recaptcha/api2/clr?k={RECAPTCHA_SITEKEY}"
- headers = {"Origin": BASE_URL, "Referer": BASE_URL + "/"}
+ headers = {"Origin": BASE_URL, "Referer": f"{BASE_URL}/"}
await ss.post(url, headers=headers)
- except RequestsError:
- pass
@pyscript_compile # noqa: F821 # ty:ignore[unresolved-reference]
@@ -362,8 +368,7 @@ def _extract_violations_from_html(result_html: str) -> dict[str, Any]:
if title_div:
violation["Biển kiểm soát"] = title_div.get_text(strip=True)
- status_span = card.find("span", class_="status-badge")
- if status_span:
+ if status_span := card.find("span", class_="status-badge"):
violation["Trạng thái"] = status_span.get_text(strip=True)
info_groups = card.find_all("div", class_="info-group")
@@ -386,14 +391,17 @@ def _extract_violations_from_html(result_html: str) -> dict[str, Any]:
resolution_items.append(col_data)
for col_data in resolution_items:
- unit_key = next((k for k in col_data if k.startswith("Đơn vị")), None)
+ for key in col_data:
+ if (unit_key := key).startswith("Đơn vị"):
+ break
+ else:
+ unit_key = None
if unit_key:
violation[unit_key] = col_data[unit_key]
- addr = col_data.get("Địa chỉ")
- if addr:
+ if addr := col_data.get("Địa chỉ"):
violation[f"Địa chỉ ({unit_key})"] = addr
else:
- violation.update(col_data)
+ violation |= col_data
else:
for item in group.find_all("div", class_="info-item"):
label_span = item.find("span", class_="label")
@@ -429,9 +437,8 @@ async def _check_license_plate(
if browser is None:
browser = random.choice(BROWSERS)
- async with AsyncSession(
- impersonate=browser, timeout=60, allow_redirects=CurlFollow.SAFE, http_version=CurlHttpVersion.V3
- ) as ss:
+ session = await asyncio.to_thread(_create_http_session, asyncio.get_running_loop(), browser)
+ async with session as ss:
try:
homepage = await ss.get(BASE_URL)
homepage.raise_for_status()
@@ -446,7 +453,7 @@ async def _check_license_plate(
log.error(f"CSRF token extraction failed for {license_plate}") # noqa: F821 # ty:ignore[unresolved-reference]
return {"error": "Failed to extract CSRF token"}
- csrf_token = token_match.group(1)
+ csrf_token = token_match[1]
version = await _get_recaptcha_version(ss)
if not version:
@@ -592,9 +599,9 @@ async def traffic_fine_lookup_tool(
boolean:
"""
try:
- license_plate = str(license_plate).upper()
+ license_plate = license_plate.upper()
license_plate = re.sub(r"[^A-Z0-9]", "", license_plate)
- vehicle_type = str(vehicle_type).lower()
+ vehicle_type = vehicle_type.lower()
if vehicle_type not in VEHICLE_TYPES:
return {"error": "The type of vehicle is invalid"}
@@ -604,7 +611,7 @@ async def traffic_fine_lookup_tool(
return {"error": "The license plate number is invalid"}
cache_key = f"{license_plate}-{vehicle_type}"
- if bool(bypass_caching):
+ if bypass_caching:
await _cache_delete(cache_key)
response = await _check_license_plate(license_plate, vehicle_type)
if response and response.get("status") == "success":
diff --git a/scripts/youtube_data_tool.py b/scripts/youtube_data_tool.py
index b62c135..f8990d5 100644
--- a/scripts/youtube_data_tool.py
+++ b/scripts/youtube_data_tool.py
@@ -39,7 +39,7 @@ def youtube_search(
page_token: str = "",
) -> dict[str, Any]:
"""Execute a search query against the YouTube API."""
- search_response = (
+ return (
client.search()
.list(
q=query,
@@ -51,8 +51,6 @@ def youtube_search(
.execute()
)
- return search_response
-
@service(supports_response="only") # noqa: F821 # ty:ignore[unresolved-reference]
async def youtube_search_tool(query: str, **kwargs) -> dict[str, Any]:
diff --git a/scripts/zalo_bot_handle_tool.py b/scripts/zalo_bot_handle_tool.py
index 21e7d18..0ee6451 100644
--- a/scripts/zalo_bot_handle_tool.py
+++ b/scripts/zalo_bot_handle_tool.py
@@ -20,12 +20,19 @@
TOKEN = TOKEN.strip()
_session: httpx.AsyncClient | None = None
+_session_lock = asyncio.Lock()
if not TOKEN:
raise ValueError("Zalo bot token is missing")
+@pyscript_compile # noqa: F821 # ty:ignore[unresolved-reference]
+def _create_session() -> httpx.AsyncClient:
+ """Create the HTTPX client in native Python for executor-safe SSL setup."""
+ return httpx.AsyncClient(http2=True, timeout=httpx.Timeout(300))
+
+
def _to_media_path(path: str) -> str:
"""Normalize and validate a Home Assistant media path to start with /media/."""
if path.startswith("local/"):
@@ -60,7 +67,9 @@ async def _ensure_session() -> httpx.AsyncClient:
"""Create or return a shared httpx AsyncClient with HTTP/2."""
global _session
if _session is None or _session.is_closed:
- _session = httpx.AsyncClient(http2=True, timeout=httpx.Timeout(300))
+ async with _session_lock:
+ if _session is None or _session.is_closed:
+ _session = await asyncio.to_thread(_create_session)
return _session
@@ -110,11 +119,9 @@ def _cleanup_disk_sync(directory: str, cutoff: float) -> None:
return
for entry in path.iterdir():
- try:
+ with contextlib.suppress(OSError):
if entry.is_file() and entry.stat().st_mtime < cutoff:
entry.unlink()
- except OSError:
- pass
async def _cleanup_old_files(directory: str, days: int = 30) -> None:
@@ -147,7 +154,7 @@ async def _send_message(client: httpx.AsyncClient, chat_id: str, message: str) -
url = f"https://bot-api.zapps.me/bot{TOKEN}/sendMessage"
text = message
if len(text) > 2000:
- text = text[:1997] + "..."
+ text = f"{text[:1997]}..."
payload = {"chat_id": chat_id, "text": text}
data = orjson.dumps(payload).decode("utf-8")
resp = await client.post(url, content=data, headers={"Content-Type": "application/json"})
@@ -325,9 +332,7 @@ async def send_zalo_message(chat_id: str, message: str) -> dict[str, Any]:
try:
client = await _ensure_session()
response = await _send_message(client, chat_id, message)
- if not response:
- return {"error": "Failed to send message"}
- return response
+ return response or {"error": "Failed to send message"}
except Exception as error:
log.error(f"{__name__}: {error}") # noqa: F821 # ty:ignore[unresolved-reference]
return {"error": f"An unexpected error occurred during processing: {error}"}
@@ -361,7 +366,6 @@ async def get_zalo_file(url: str) -> dict[str, Any]:
mimetypes.add_type("text/plain", ".yaml")
mime_type, _ = mimetypes.guess_file_type(file_path)
file_path = _to_relative_path(file_path)
- response: dict[str, Any] = {"file_path": file_path, "mime_type": mime_type}
support_file_types = (
"image/",
"video/",
@@ -369,10 +373,11 @@ async def get_zalo_file(url: str) -> dict[str, Any]:
"text/",
"application/pdf",
)
- if mime_type and mime_type.startswith(support_file_types):
- response["supported"] = True
- else:
- response["supported"] = False
+ response: dict[str, Any] = {
+ "file_path": file_path,
+ "mime_type": mime_type,
+ "supported": bool(mime_type and mime_type.startswith(support_file_types)),
+ }
return response
except Exception as error:
log.error(f"{__name__}: {error}") # noqa: F821 # ty:ignore[unresolved-reference]
@@ -505,9 +510,7 @@ async def send_zalo_chat_action(chat_id: str) -> dict[str, Any]:
try:
client = await _ensure_session()
response = await _send_chat_action(client, chat_id)
- if not response:
- return {"error": "Failed to send message"}
- return response
+ return response or {"error": "Failed to send message"}
except Exception as error:
log.error(f"{__name__}: {error}") # noqa: F821 # ty:ignore[unresolved-reference]
return {"error": f"An unexpected error occurred during processing: {error}"}
@@ -550,8 +553,7 @@ async def send_zalo_photo(
try:
client = await _ensure_session()
public_url, published_path = await _copy_to_www(file_path)
- response = await _send_photo(client, chat_id, public_url, caption=caption)
- return response
+ return await _send_photo(client, chat_id, public_url, caption=caption)
except Exception as error:
log.error(f"{__name__}: {error}") # noqa: F821 # ty:ignore[unresolved-reference]
return {"error": f"An unexpected error occurred during processing: {error}"}
diff --git a/scripts/zalo_custom_bot_handle_tool.py b/scripts/zalo_custom_bot_handle_tool.py
index ebd7da7..ea93ef5 100644
--- a/scripts/zalo_custom_bot_handle_tool.py
+++ b/scripts/zalo_custom_bot_handle_tool.py
@@ -14,6 +14,13 @@
DIRECTORY = "/media/zalo"
_session: httpx.AsyncClient | None = None
+_session_lock = asyncio.Lock()
+
+
+@pyscript_compile # noqa: F821 # ty:ignore[unresolved-reference]
+def _create_session() -> httpx.AsyncClient:
+ """Create the HTTPX client in native Python for executor-safe SSL setup."""
+ return httpx.AsyncClient(http2=True, timeout=httpx.Timeout(300))
def _to_relative_path(path: str) -> str:
@@ -49,7 +56,9 @@ async def _ensure_session() -> httpx.AsyncClient:
"""Create or return a shared httpx AsyncClient with HTTP/2."""
global _session
if _session is None or _session.is_closed:
- _session = httpx.AsyncClient(http2=True, timeout=httpx.Timeout(300))
+ async with _session_lock:
+ if _session is None or _session.is_closed:
+ _session = await asyncio.to_thread(_create_session)
return _session
@@ -99,11 +108,9 @@ def _cleanup_disk_sync(directory: str, cutoff: float) -> None:
return
for entry in path.iterdir():
- try:
+ with contextlib.suppress(OSError):
if entry.is_file() and entry.stat().st_mtime < cutoff:
entry.unlink()
- except OSError:
- pass
async def _cleanup_old_files(directory: str, days: int = 30) -> None:
@@ -174,7 +181,6 @@ async def get_zalo_file_custom_bot(url: str) -> dict[str, Any]:
mimetypes.add_type("text/plain", ".yaml")
mime_type, _ = mimetypes.guess_file_type(file_path)
file_path = _to_relative_path(file_path)
- response: dict[str, Any] = {"file_path": file_path, "mime_type": mime_type}
support_file_types = (
"image/",
"video/",
@@ -182,10 +188,11 @@ async def get_zalo_file_custom_bot(url: str) -> dict[str, Any]:
"text/",
"application/pdf",
)
- if mime_type and mime_type.startswith(support_file_types):
- response["supported"] = True
- else:
- response["supported"] = False
+ response: dict[str, Any] = {
+ "file_path": file_path,
+ "mime_type": mime_type,
+ "supported": bool(mime_type and mime_type.startswith(support_file_types)),
+ }
return response
except Exception as error:
log.error(f"{__name__}: {error}") # noqa: F821 # ty:ignore[unresolved-reference]
@@ -203,16 +210,19 @@ async def generate_webhook_id() -> dict[str, Any]:
webhook_id = secrets.token_urlsafe()
internal_url = _internal_url()
external_url = _external_url()
- response = {"webhook_id": webhook_id}
- if internal_url:
- response["sample_internal_url"] = f"{internal_url}/api/webhook/{webhook_id}"
- else:
- response["sample_internal_url"] = "The internal Home Assistant URL is not found."
- if external_url:
- response["sample_external_url"] = f"{external_url}/api/webhook/{webhook_id}"
- else:
- response["sample_external_url"] = "The external Home Assistant URL is not found or incorrect."
- return response
+ return {
+ "webhook_id": webhook_id,
+ "sample_internal_url": (
+ f"{internal_url}/api/webhook/{webhook_id}"
+ if internal_url
+ else "The internal Home Assistant URL is not found."
+ ),
+ "sample_external_url": (
+ f"{external_url}/api/webhook/{webhook_id}"
+ if external_url
+ else "The external Home Assistant URL is not found or incorrect."
+ ),
+ }
except Exception as error:
log.error(f"{__name__}: {error}") # noqa: F821 # ty:ignore[unresolved-reference]
return {"error": f"An unexpected error occurred during processing: {error}"}
diff --git a/send_to_telegram_full_llm.yaml b/send_to_telegram_full_llm.yaml
index a1799da..27ab045 100644
--- a/send_to_telegram_full_llm.yaml
+++ b/send_to_telegram_full_llm.yaml
@@ -48,11 +48,11 @@ blueprint:
telegram_settings:
name: Settings for Telegram
icon: mdi:send-circle
- description: Use these settings to configure Telegram for sending the message or image.
+ description: Use these settings to configure Telegram for sending text or media files.
input:
chat_id:
name: Chat ID
- description: Target chat or user to receive the message or image.
+ description: Target chat or user to receive the message or media file.
selector:
text:
thread_id:
@@ -66,12 +66,12 @@ blueprint:
prompt_settings:
name: Prompt settings for the LLM
icon: mdi:robot
- description: Use these options to fine-tune the prompts that guide your LLM when preparing summaries, details, locations, or image paths.
+ description: Use these options to fine-tune the prompts that guide your LLM when preparing summaries, details, or media paths.
collapsed: true
input:
summary_prompt:
name: Summary Prompt
- description: A brief overview or caption for the Telegram message/image
+ description: A brief overview or caption for the Telegram content
selector:
text:
multiline: true
@@ -79,7 +79,7 @@ blueprint:
<=10 words overview/caption. Plain text. Multiple items = multiple calls
detail_prompt:
name: Detail Prompt
- description: Additional supporting details for the Telegram message/image
+ description: Additional supporting details for the Telegram content
selector:
text:
multiline: true
@@ -87,23 +87,15 @@ blueprint:
Full details. No links/markdown/emojis/repeats. Plain text
content_type_prompt:
name: Content Type Prompt
- description: The type of content to send (information, location, or image)
+ description: Instructions for choosing the most appropriate Telegram content type
selector:
text:
multiline: true
default: |-
- information, location, or image. Multiple items = multiple calls
- location_prompt:
- name: Location Prompt
- description: The location/address to be sent via Telegram
- selector:
- text:
- multiline: true
- default: |-
- Address only. No commas/punctuation
- image_path_prompt:
- name: Image Path Prompt
- description: The file path of the image to be sent via Telegram
+ Choose the most appropriate type: information for text, location for a map pin, image for an image, audio for music, document for a file, video for a video, or voice for a voice message. For location, provide latitude,longitude or a Google Maps URL containing coordinates. Multiple items = multiple calls
+ media_path_prompt:
+ name: Media Path Prompt
+ description: The local path of an image, audio, document, video, or voice file to be sent via Telegram
selector:
text:
multiline: true
@@ -128,9 +120,9 @@ blueprint:
mode: queued
max: 30
max_exceeded: silent
-description: Sends messages, locations, or images via Telegram
+description: Sends messages, map locations, images, audio, documents, videos, or voice messages via Telegram
variables:
- version: 20260409
+ version: 20260818
fields:
summary:
name: Summary
@@ -152,15 +144,19 @@ fields:
- information
- location
- image
+ - audio
+ - document
+ - video
+ - voice
required: true
location:
name: Location
- description: !input location_prompt
+ description: Coordinates as latitude,longitude or a Google Maps URL containing coordinates when content_type is location
selector:
text:
- image_path:
- name: Image Path
- description: !input image_path_prompt
+ media_path:
+ name: Media Path
+ description: !input media_path_prompt
selector:
text:
target_chat_id:
@@ -180,8 +176,9 @@ sequence:
summary: "{{ summary | default('') | trim }}"
detail: "{{ detail | default('') | trim }}"
content_type: "{{ content_type | default('') | trim }}"
- location: "{{ location | default('') | trim }}"
- image_path: "{{ image_path | default('') | trim }}"
+ location: "{{ location | default('', true) | trim }}"
+ media_path: "{{ media_path | default('', true) | trim }}"
+ media_path_value: "{{ media_path }}"
override_chat_id: "{{ target_chat_id | default('') | trim }}"
override_message_thread_id: "{{ target_message_thread_id | default('') | trim }}"
chat_id_value: >-
@@ -201,10 +198,10 @@ sequence:
value_template: >-
{{
(not summary)
- or (content_type not in ['information', 'location', 'image'])
+ or (content_type not in ['information', 'location', 'image', 'audio', 'document', 'video', 'voice'])
or (not chat_id_value)
or (content_type == 'location' and not location)
- or (content_type == 'image' and not image_path)
+ or (content_type in ['image', 'audio', 'document', 'video', 'voice'] and not media_path_value)
or (
override_message_thread_id
and not override_message_thread_id | regex_match('^\d+$')
@@ -215,7 +212,7 @@ sequence:
variables:
response:
error: >-
- Unable to send the message to Telegram because one or more inputs are missing or invalid (summary, content_type, chat_id, location, image_path, or message_thread_id).
+ Unable to send the message to Telegram because one or more inputs are missing or invalid (summary, content_type, chat_id, location, media_path, or message_thread_id).
- alias: Stop the script
stop: Unable to send the message to Telegram due to invalid inputs.
response_variable: response
@@ -233,10 +230,6 @@ sequence:
{{ summary | e }}{% if detail %}
{{ detail | e }}{% endif %}
-
- {% set query = summary.split() | join('+') -%}
- {% set url = 'https://www.google.com/search?q=' ~ query -%}
- Google Search
response_variable: telegram_response
- conditions:
- condition: template
@@ -251,10 +244,12 @@ sequence:
{{ summary | e }}{% if detail %}
{{ detail | e }}{% endif %}
-
- {% set place = (summary ~ ' ' ~ location).split() | join('+') -%}
- {% set url = 'https://www.google.com/maps/search/?api=1&query=' ~ place -%}
- Google Maps
+ response_variable: location_message_response
+ - action: pyscript.send_telegram_location
+ data:
+ chat_id: "{{ chat_id_value }}"
+ message_thread_id: "{{ message_thread_id_value }}"
+ location: "{{ location }}"
response_variable: telegram_response
- conditions:
- condition: template
@@ -265,7 +260,67 @@ sequence:
chat_id: "{{ chat_id_value }}"
message_thread_id: "{{ message_thread_id_value }}"
parse_mode: HTML
- file_path: "{{ image_path }}"
+ file_path: "{{ media_path_value }}"
+ caption: |-
+ {{ summary | e }}{% if detail %}
+
+ {{ detail | e }}{% endif %}
+ response_variable: telegram_response
+ - conditions:
+ - condition: template
+ value_template: "{{ content_type == 'audio' }}"
+ sequence:
+ - action: pyscript.send_telegram_audio
+ data:
+ chat_id: "{{ chat_id_value }}"
+ message_thread_id: "{{ message_thread_id_value }}"
+ parse_mode: HTML
+ file_path: "{{ media_path_value }}"
+ caption: |-
+ {{ summary | e }}{% if detail %}
+
+ {{ detail | e }}{% endif %}
+ response_variable: telegram_response
+ - conditions:
+ - condition: template
+ value_template: "{{ content_type == 'document' }}"
+ sequence:
+ - action: pyscript.send_telegram_document
+ data:
+ chat_id: "{{ chat_id_value }}"
+ message_thread_id: "{{ message_thread_id_value }}"
+ parse_mode: HTML
+ file_path: "{{ media_path_value }}"
+ caption: |-
+ {{ summary | e }}{% if detail %}
+
+ {{ detail | e }}{% endif %}
+ response_variable: telegram_response
+ - conditions:
+ - condition: template
+ value_template: "{{ content_type == 'video' }}"
+ sequence:
+ - action: pyscript.send_telegram_video
+ data:
+ chat_id: "{{ chat_id_value }}"
+ message_thread_id: "{{ message_thread_id_value }}"
+ parse_mode: HTML
+ file_path: "{{ media_path_value }}"
+ caption: |-
+ {{ summary | e }}{% if detail %}
+
+ {{ detail | e }}{% endif %}
+ response_variable: telegram_response
+ - conditions:
+ - condition: template
+ value_template: "{{ content_type == 'voice' }}"
+ sequence:
+ - action: pyscript.send_telegram_voice
+ data:
+ chat_id: "{{ chat_id_value }}"
+ message_thread_id: "{{ message_thread_id_value }}"
+ parse_mode: HTML
+ file_path: "{{ media_path_value }}"
caption: |-
{{ summary | e }}{% if detail %}
diff --git a/telegram_bot_webhook.yaml b/telegram_bot_webhook.yaml
index 4827bf8..1a4e0c8 100644
--- a/telegram_bot_webhook.yaml
+++ b/telegram_bot_webhook.yaml
@@ -44,7 +44,12 @@ blueprint:
- Use the `pyscript.get_telegram_webhook` action to check the current Telegram Webhook ID.
- Use the `pyscript.delete_telegram_webhook` action to delete the Telegram Webhook ID when you are no longer using it.
- Use the `pyscript.send_telegram_message` action to easily send a message directly to Telegram in your automation without any dependencies.
+ - Use the `pyscript.send_telegram_location` action to send a Telegram map pin from coordinates or a Google Maps URL containing coordinates.
- Use the `pyscript.send_telegram_photo` action to easily send a photo directly to Telegram in your automation without any dependencies.
+ - Use the `pyscript.send_telegram_audio` action to send MP3 or M4A audio files directly to Telegram.
+ - Use the `pyscript.send_telegram_document` action to send local files directly to Telegram as documents.
+ - Use the `pyscript.send_telegram_video` action to send MPEG-4 videos directly to Telegram.
+ - Use the `pyscript.send_telegram_voice` action to send OGG/Opus, MP3, or M4A voice messages directly to Telegram.
domain: automation
homeassistant:
min_version: 2025.8.0
@@ -99,7 +104,7 @@ mode: queued
max: 30
max_exceeded: silent
variables:
- version: 20260409
+ version: 20260818
chat_ids: !input chat_ids
user_ids: !input user_ids
language: !input language
diff --git a/weather_forecast_full_llm.yaml b/weather_forecast_full_llm.yaml
index 64d19b0..5614a78 100644
--- a/weather_forecast_full_llm.yaml
+++ b/weather_forecast_full_llm.yaml
@@ -105,7 +105,7 @@ mode: parallel
max_exceeded: silent
description: Retrieves weather forecast for hourly or daily periods
variables:
- version: 20260409
+ version: 20260818
fields:
time_period_type:
name: Time Period Type
@@ -227,7 +227,9 @@ sequence:
{% set weather_data = selected_data.forecast %}
- {% set forecast_keys = (weather_data[0] | default({})).keys() | reject('eq', 'datetime') %}
+ {% set first_forecast = weather_data[0] if weather_data else {} %}
+
+ {% set forecast_keys = first_forecast.keys() | reject('eq', 'datetime') %}
{% for item in forecast_keys %}