Add optional AI generation, MQTT, OAuth refresh and calendar recurrence
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
@@ -30,6 +31,29 @@ class CloudDrives:
|
||||
for key in ["access_token", "refresh_token", "client_secret"]:
|
||||
if value.get(key):
|
||||
self.settings.additional_secrets.append(value[key])
|
||||
if (
|
||||
value.get("expires_at")
|
||||
and float(value["expires_at"]) <= time.time() + 60
|
||||
):
|
||||
if source.credential_env:
|
||||
raise FlowError(
|
||||
E.CONFIG_REQUIRED,
|
||||
"Environment OAuth token expired; update its secret",
|
||||
409,
|
||||
)
|
||||
from pigway_frame_flow.providers.oauth import OAuthTokens
|
||||
|
||||
if not hasattr(self.photos, "oauth_tokens"):
|
||||
self.photos.oauth_tokens = OAuthTokens(
|
||||
self.settings, self.photos.assets
|
||||
)
|
||||
with self.photos.oauth_tokens.lock:
|
||||
latest = json.loads(path.read_text())
|
||||
value = (
|
||||
latest
|
||||
if float(latest.get("expires_at", 0)) > time.time() + 60
|
||||
else self.photos.oauth_tokens.refresh(source.kind, latest, path)
|
||||
)
|
||||
return value
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
raise FlowError(
|
||||
|
||||
@@ -139,26 +139,9 @@ class ExternalProviders:
|
||||
)
|
||||
result: dict
|
||||
if config.kind == "ics":
|
||||
events = []
|
||||
text = (
|
||||
raw.decode("utf-8-sig").replace("\r\n ", "").replace("\r\n", "\n")
|
||||
)
|
||||
for block in text.split("BEGIN:VEVENT")[1:]:
|
||||
block = block.split("END:VEVENT")[0]
|
||||
values = {
|
||||
line.split(":", 1)[0].split(";")[0]: line.split(":", 1)[1]
|
||||
for line in block.splitlines()
|
||||
if ":" in line
|
||||
}
|
||||
if "RRULE" in values:
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY,
|
||||
"Recurring ICS events require a calendar provider with expanded instances",
|
||||
422,
|
||||
)
|
||||
if values.get("SUMMARY"):
|
||||
events.append(values["SUMMARY"].replace("\\n", " "))
|
||||
result = {"events": events[:100]}
|
||||
from pigway_frame_flow.providers.ical import calendar_events
|
||||
|
||||
result = calendar_events(raw)
|
||||
elif config.kind == "google-calendar":
|
||||
result = {
|
||||
"events": [
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Bounded read-only iCalendar expansion for the next seven days."""
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from dateutil.rrule import rrulestr
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
|
||||
|
||||
def calendar_events(raw, now=None):
|
||||
now = now or datetime.now(UTC)
|
||||
end = now + timedelta(days=7)
|
||||
text = re.sub(r"\r?\n[ \t]", "", raw.decode("utf-8-sig"))
|
||||
blocks = text.split("BEGIN:VEVENT")[1:]
|
||||
if len(blocks) > 2000:
|
||||
raise FlowError(E.INVALID_REQUEST, "Calendar exceeds 2000 events")
|
||||
|
||||
def stamp(key, value):
|
||||
zone = re.search(r"TZID=([^;:]+)", key)
|
||||
tz = ZoneInfo(zone[1]) if zone else ZoneInfo("Asia/Shanghai")
|
||||
if len(value) == 8:
|
||||
return datetime.strptime(value, "%Y%m%d").replace(tzinfo=tz)
|
||||
fmt = "%Y%m%dT%H%M%SZ" if value.endswith("Z") else "%Y%m%dT%H%M%S"
|
||||
return datetime.strptime(value, fmt).replace(
|
||||
tzinfo=UTC if value.endswith("Z") else tz
|
||||
)
|
||||
|
||||
parsed: list[tuple] = []
|
||||
for block in blocks:
|
||||
props: dict[str, list[tuple[str, str]]] = {}
|
||||
for line in block.split("END:VEVENT")[0].splitlines():
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
props.setdefault(key.split(";")[0], []).append((key, value))
|
||||
if "DTSTART" not in props or "SUMMARY" not in props:
|
||||
continue
|
||||
start = stamp(*props["DTSTART"][0])
|
||||
uid = props.get("UID", [(None, str(len(parsed)))])[0][1]
|
||||
override = (
|
||||
stamp(*props["RECURRENCE-ID"][0]) if "RECURRENCE-ID" in props else None
|
||||
)
|
||||
parsed.append((props, start, uid, override))
|
||||
overrides = {
|
||||
(uid, original.astimezone(UTC)) for _, _, uid, original in parsed if original
|
||||
}
|
||||
result = []
|
||||
for props, start, uid, override in parsed:
|
||||
if props.get("STATUS", [(None, "")])[0][1] == "CANCELLED":
|
||||
continue
|
||||
occurrences = [start]
|
||||
if "RRULE" in props and not override:
|
||||
rule = props["RRULE"][0][1]
|
||||
if not re.search(r"(?:^|;)FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)(?:;|$)", rule):
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY,
|
||||
"Calendar frequency must be daily or slower",
|
||||
422,
|
||||
)
|
||||
if now.year - start.year > 150:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "Calendar recurrence starts too far in the past"
|
||||
)
|
||||
occurrences = rrulestr(rule, dtstart=start).between(
|
||||
now - timedelta(days=1), end, inc=True
|
||||
)
|
||||
for key, value in props.get("RDATE", []):
|
||||
occurrences.extend(stamp(key, item) for item in value.split(","))
|
||||
excluded = {
|
||||
stamp(key, item).astimezone(UTC)
|
||||
for key, value in props.get("EXDATE", [])
|
||||
for item in value.split(",")
|
||||
}
|
||||
title = (
|
||||
props["SUMMARY"][0][1]
|
||||
.replace("\\n", " ")
|
||||
.replace("\\,", ",")
|
||||
.replace("\\;", ";")
|
||||
.replace("\\\\", "\\")
|
||||
)
|
||||
all_day = len(props["DTSTART"][0][1]) == 8
|
||||
for occurrence in sorted(set(occurrences)):
|
||||
instant = occurrence.astimezone(UTC)
|
||||
lower = (
|
||||
now.astimezone(occurrence.tzinfo).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
if all_day
|
||||
else now
|
||||
)
|
||||
if instant in excluded or (not override and (uid, instant) in overrides):
|
||||
continue
|
||||
if lower <= instant < end:
|
||||
result.append({"title": title, "start": occurrence.isoformat()})
|
||||
if len(result) > 1000:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "Calendar expansion exceeds 1000 instances"
|
||||
)
|
||||
return {"events": sorted(result, key=lambda x: x["start"])}
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Fixed-provider OAuth refresh. No secret payloads in durable operation history."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.extensions.secrets import save_secret
|
||||
|
||||
|
||||
class OAuthTokens:
|
||||
ENDPOINTS = {
|
||||
"google-drive": "https://oauth2.googleapis.com/token",
|
||||
"alipan": "https://openapi.aliyundrive.com/oauth/access_token",
|
||||
"baidu": "https://openapi.baidu.com/oauth/2.0/token",
|
||||
}
|
||||
|
||||
def __init__(self, settings, assets):
|
||||
self.settings, self.assets = settings, assets
|
||||
self.lock = threading.RLock()
|
||||
|
||||
def refresh(self, kind, credentials, path=None):
|
||||
with self.lock:
|
||||
if not all(
|
||||
credentials.get(key)
|
||||
for key in ("refresh_token", "client_id", "client_secret")
|
||||
):
|
||||
raise FlowError(
|
||||
E.CONFIG_REQUIRED,
|
||||
"OAuth refresh requires client ID, client secret and refresh token",
|
||||
409,
|
||||
)
|
||||
values = {
|
||||
key: credentials[key]
|
||||
for key in ("refresh_token", "client_id", "client_secret")
|
||||
}
|
||||
values["grant_type"] = "refresh_token"
|
||||
for key in ("access_token", "refresh_token", "client_secret"):
|
||||
if credentials.get(key):
|
||||
self.settings.additional_secrets.append(credentials[key])
|
||||
endpoint = self.ENDPOINTS[kind]
|
||||
host = endpoint.split("/")[2]
|
||||
if host not in self.settings.image_url_hosts:
|
||||
self.settings.image_url_hosts.append(host)
|
||||
as_json = kind == "alipan"
|
||||
body = (
|
||||
json.dumps(values).encode() if as_json else urlencode(values).encode()
|
||||
)
|
||||
try:
|
||||
raw = self.assets.fetch_url(
|
||||
endpoint,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json"
|
||||
if as_json
|
||||
else "application/x-www-form-urlencoded"
|
||||
},
|
||||
body=body,
|
||||
limit=65536,
|
||||
)
|
||||
response = json.loads(raw)
|
||||
if (
|
||||
not isinstance(response.get("access_token"), str)
|
||||
or not response["access_token"]
|
||||
):
|
||||
raise ValueError()
|
||||
expiry = int(response["expires_in"])
|
||||
if not 60 <= expiry <= 366 * 86400:
|
||||
raise ValueError()
|
||||
updated = {
|
||||
**credentials,
|
||||
"access_token": response["access_token"],
|
||||
"expires_at": time.time() + expiry,
|
||||
}
|
||||
if response.get("refresh_token"):
|
||||
updated["refresh_token"] = response["refresh_token"]
|
||||
for key in ("access_token", "refresh_token"):
|
||||
if updated.get(key):
|
||||
self.settings.additional_secrets.append(updated[key])
|
||||
if path:
|
||||
save_secret(path, json.dumps(updated))
|
||||
return updated
|
||||
except Exception:
|
||||
raise FlowError(
|
||||
E.CLOUD_UNAVAILABLE,
|
||||
"OAuth refresh failed; reconnect or refresh credentials manually",
|
||||
502,
|
||||
) from None
|
||||
Reference in New Issue
Block a user