Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions home_assistant_unavailable_devices.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ triggers:
attribute: entities
conditions:
- condition: template
value_template: "{{ trigger.from_state.state not in ['unavailable', 'unknown'] }}"
value_template: "{{ trigger.from_state is not none and trigger.from_state.state not in ['unavailable', 'unknown'] }}"
- condition: template
value_template: "{{ trigger.to_state.state not in ['unavailable', 'unknown'] }}"
value_template: "{{ trigger.to_state is not none and trigger.to_state.state not in ['unavailable', 'unknown'] }}"
actions:
- variables:
entities: "{{ state_attr(trigger.entity_id, 'entities') | default([], true) }}"
Expand Down Expand Up @@ -129,9 +129,9 @@ triggers:
attribute: entities
conditions:
- condition: template
value_template: "{{ trigger.from_state.state not in ['unavailable', 'unknown'] }}"
value_template: "{{ trigger.from_state is not none and trigger.from_state.state not in ['unavailable', 'unknown'] }}"
- condition: template
value_template: "{{ trigger.to_state.state not in ['unavailable', 'unknown'] }}"
value_template: "{{ trigger.to_state is not none and trigger.to_state.state not in ['unavailable', 'unknown'] }}"
actions:
- variables:
entities: "{{ state_attr(trigger.entity_id, 'entities') | default([], true) }}"
Expand Down
8 changes: 4 additions & 4 deletions home_assistant_unavailable_devices_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ triggers:
attribute: entities
conditions:
- condition: template
value_template: "{{ trigger.from_state.state not in ['unavailable', 'unknown'] }}"
value_template: "{{ trigger.from_state is not none and trigger.from_state.state not in ['unavailable', 'unknown'] }}"
- condition: template
value_template: "{{ trigger.to_state.state not in ['unavailable', 'unknown'] }}"
value_template: "{{ trigger.to_state is not none and trigger.to_state.state not in ['unavailable', 'unknown'] }}"
actions:
- variables:
entities: "{{ state_attr(trigger.entity_id, 'entities') | default([], true) }}"
Expand Down Expand Up @@ -129,9 +129,9 @@ triggers:
attribute: entities
conditions:
- condition: template
value_template: "{{ trigger.from_state.state not in ['unavailable', 'unknown'] }}"
value_template: "{{ trigger.from_state is not none and trigger.from_state.state not in ['unavailable', 'unknown'] }}"
- condition: template
value_template: "{{ trigger.to_state.state not in ['unavailable', 'unknown'] }}"
value_template: "{{ trigger.to_state is not none and trigger.to_state.state not in ['unavailable', 'unknown'] }}"
actions:
- variables:
entities: "{{ state_attr(trigger.entity_id, 'entities') | default([], true) }}"
Expand Down
1 change: 0 additions & 1 deletion scripts/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# Common packages are included in many tools.
aiohttp
aiofiles

# youtube_data_tool
google-api-python-client
Expand Down
99 changes: 53 additions & 46 deletions scripts/telegram_bot_handle_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from pathlib import Path
from typing import Any

import aiofiles
import aiohttp
from homeassistant.helpers import network

Expand Down Expand Up @@ -130,26 +129,47 @@ async def _get_file(session: aiohttp.ClientSession, file_id: str) -> str | None:
return data.get("result", {}).get("file_path")


async def _download_and_save_file(
session: aiohttp.ClientSession, file_path: str, destination: str
) -> bool:
"""Download a file from Telegram and save it directly to disk (streaming).
async def _download_file(
session: aiohttp.ClientSession, file_id: str
) -> tuple[str, None] | tuple[None, str]:
"""Download a file from Telegram and save it under DIRECTORY (streaming).

Args:
session: Shared aiohttp session.
file_path: Path returned by Telegram `getFile`.
destination: Local destination path.
file_id: Telegram file identifier.

Returns:
True if successful, False otherwise.
Full file path of the saved file, or None on failure.
"""
url = f"https://api.telegram.org/file/bot{TOKEN}/{file_path}"
async with session.get(url) as resp:
resp.raise_for_status()
async with aiofiles.open(destination, "wb") as f:
async for chunk in resp.content.iter_chunked(4096):
await f.write(chunk)
return True
try:
online_file_path = await _get_file(session, file_id)
if not online_file_path:
return None, "Unable to retrieve the file_path from Telegram."

url = f"https://api.telegram.org/file/bot{TOKEN}/{online_file_path}"
async with session.get(url) as resp:
resp.raise_for_status()

file_name = os.path.basename(online_file_path)
base, ext = os.path.splitext(file_name)
timestamp = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
file_name = f"{base}_{timestamp}_{secrets.token_hex(4)}{ext}"

file_path = os.path.join(DIRECTORY, file_name)

f = await asyncio.to_thread(open, file_path, "wb")
try:
async for chunk in resp.content.iter_chunked(65536): # 64KB chunks
if chunk:
await asyncio.to_thread(f.write, chunk)
await asyncio.to_thread(f.flush)
await asyncio.to_thread(os.fsync, f.fileno())
finally:
await asyncio.to_thread(f.close)

return file_path, None
except Exception as error:
return None, f"An unexpected error occurred during download: {error}"


async def _send_message(
Expand Down Expand Up @@ -241,20 +261,21 @@ async def _send_photo(
if message_thread_id:
form.add_field("message_thread_id", str(message_thread_id))

async with aiofiles.open(file_path, "rb") as f:
file_content = await f.read()

form.add_field(
name="photo",
value=file_content,
filename=filename,
content_type=content_type,
)
f = await asyncio.to_thread(open, file_path, "rb")
try:
form.add_field(
name="photo",
value=f,
filename=filename,
content_type=content_type,
)

url = f"https://api.telegram.org/bot{TOKEN}/sendPhoto"
async with session.post(url, data=form) as resp:
resp.raise_for_status()
return await resp.json()
url = f"https://api.telegram.org/bot{TOKEN}/sendPhoto"
async with session.post(url, data=form) as resp:
resp.raise_for_status()
return await resp.json()
finally:
await asyncio.to_thread(f.close)


async def _get_webhook_info(session: aiohttp.ClientSession) -> dict[str, Any]:
Expand Down Expand Up @@ -552,26 +573,12 @@ async def get_telegram_file(file_id: str) -> dict[str, Any]:
session = await _ensure_session()
await _ensure_dir(DIRECTORY)

online_file_path = await _get_file(session, file_id)
if not online_file_path:
return {"error": "Unable to retrieve the file_path from Telegram."}

file_name = os.path.basename(online_file_path)
base, ext = os.path.splitext(file_name)
# Use timestamp and random token to prevent filename collisions
file_name = f"{base}_{int(time.time())}_{secrets.token_hex(4)}{ext}"

file_path = os.path.join(DIRECTORY, file_name)

try:
await _download_and_save_file(session, online_file_path, file_path)
except Exception as error:
return {
"error": f"Unable to download or save the file from Telegram: {error}"
}
file_path, error = await _download_file(session, file_id)
if not file_path:
return {"error": f"Unable to download the file from Telegram. {error}"}

mimetypes.add_type("text/plain", ".yaml")
mime_type, _ = mimetypes.guess_file_type(file_name)
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 = (
Expand Down
29 changes: 13 additions & 16 deletions scripts/zalo_bot_handle_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
import mimetypes
import os
import secrets
import shutil
import time
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

import aiofiles
import aiohttp
from homeassistant.helpers import network

Expand Down Expand Up @@ -139,21 +139,26 @@ async def _download_file(
content_type = resp.headers.get("Content-Type", "")
ext = mimetypes.guess_extension(content_type.split(";")[0].strip()) or ""

# Use safe filename from URL or default, then append unique token
parsed_url = urlparse(url)
original_name = Path(parsed_url.path).name
if not Path(original_name).suffix and ext:
original_name += ext

base, extension = os.path.splitext(original_name)
# Construct unique filename: name_timestamp_random.ext
file_name = f"{base}_{int(time.time())}_{secrets.token_hex(4)}{extension}"
timestamp = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
file_name = f"{base}_{timestamp}_{secrets.token_hex(4)}{extension}"

file_path = os.path.join(DIRECTORY, file_name)

async with aiofiles.open(file_path, "wb") as f:
async for chunk in resp.content.iter_chunked(4096):
await f.write(chunk)
f = await asyncio.to_thread(open, file_path, "wb")
try:
async for chunk in resp.content.iter_chunked(65536):
if chunk:
await asyncio.to_thread(f.write, chunk)
await asyncio.to_thread(f.flush)
await asyncio.to_thread(os.fsync, f.fileno())
finally:
await asyncio.to_thread(f.close)

return file_path, None
except Exception as error:
Expand Down Expand Up @@ -339,15 +344,7 @@ async def _copy_to_www(file_path: str) -> tuple[str, str]:
name = f"{secrets.token_urlsafe(16)}-{Path(normalized).name}"
dest_path = os.path.join(WWW_DIRECTORY, name)

# Streaming copy
async with (
aiofiles.open(normalized, "rb") as src,
aiofiles.open(dest_path, "wb") as dst,
):
async for (
chunk
) in src: # aiofiles supports async iteration (default chunk size)
await dst.write(chunk)
await asyncio.to_thread(shutil.copyfile, normalized, dest_path)

public_url = f"{external}/local/zalo/{name}"
return public_url, dest_path
Expand Down
18 changes: 11 additions & 7 deletions scripts/zalo_custom_bot_handle_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from typing import Any
from urllib.parse import urlparse

import aiofiles
import aiohttp
from homeassistant.helpers import network

Expand Down Expand Up @@ -119,21 +118,26 @@ async def _download_file(
content_type = resp.headers.get("Content-Type", "")
ext = mimetypes.guess_extension(content_type.split(";")[0].strip()) or ""

# Use safe filename from URL or default, then append unique token
parsed_url = urlparse(url)
original_name = Path(parsed_url.path).name
if not Path(original_name).suffix and ext:
original_name += ext

base, extension = os.path.splitext(original_name)
# Construct unique filename: name_timestamp_random.ext
file_name = f"{base}_{int(time.time())}_{secrets.token_hex(4)}{extension}"
timestamp = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
file_name = f"{base}_{timestamp}_{secrets.token_hex(4)}{extension}"

file_path = os.path.join(DIRECTORY, file_name)

async with aiofiles.open(file_path, "wb") as f:
async for chunk in resp.content.iter_chunked(4096):
await f.write(chunk)
f = await asyncio.to_thread(open, file_path, "wb")
try:
async for chunk in resp.content.iter_chunked(65536):
if chunk:
await asyncio.to_thread(f.write, chunk)
await asyncio.to_thread(f.flush)
await asyncio.to_thread(os.fsync, f.fileno())
finally:
await asyncio.to_thread(f.close)

return file_path, None
except Exception as error:
Expand Down
2 changes: 1 addition & 1 deletion telegram_bot_webhook.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ actions:
- choose:
- conditions:
- condition: template
value_template: "{{ supported is defined and not bool(supported) }}"
value_template: "{{ supported is defined and not supported | bool(true) }}"
sequence:
- action: pyscript.send_telegram_message
data:
Expand Down
4 changes: 2 additions & 2 deletions traffic_fine_notification.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@ triggers:
entity_id: !input vehicles
conditions:
- condition: template
value_template: "{{ trigger.from_state.state not in ['unavailable', 'unknown'] }}"
value_template: "{{ trigger.from_state is not none and trigger.from_state.state not in ['unavailable', 'unknown'] }}"
- condition: template
value_template: "{{ trigger.to_state.state not in ['unavailable', 'unknown'] }}"
value_template: "{{ trigger.to_state is not none and trigger.to_state.state not in ['unavailable', 'unknown'] }}"
- condition: template
value_template: "{{ not trigger.to_state.attributes.data.get('error') }}"
actions:
Expand Down
2 changes: 1 addition & 1 deletion zalo_bot_webhook.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ actions:
- choose:
- conditions:
- condition: template
value_template: "{{ supported is defined and not bool(supported) }}"
value_template: "{{ supported is defined and not supported | bool(true) }}"
sequence:
- action: pyscript.send_zalo_message
data:
Expand Down
2 changes: 1 addition & 1 deletion zalo_custom_bot_webhook.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ actions:
- choose:
- conditions:
- condition: template
value_template: "{{ supported is defined and not bool(supported) }}"
value_template: "{{ supported is defined and not supported | bool(true) }}"
sequence:
- action: zalo_bot.send_message
data:
Expand Down