From 589a7757ea71c08ce3a3ee2b9783ffe4fcf51846 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Sun, 23 Aug 2026 15:34:19 +0300 Subject: [PATCH] Add opt-in Xquik posting package --- config/packages/README.md | 25 ++++++ config/packages/xquik.yaml.disabled | 122 ++++++++++++++++++++++++++++ tests/test_xquik_package.py | 69 ++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 config/packages/xquik.yaml.disabled create mode 100644 tests/test_xquik_package.py diff --git a/config/packages/README.md b/config/packages/README.md index 7716e0bb..145f1055 100755 --- a/config/packages/README.md +++ b/config/packages/README.md @@ -102,6 +102,31 @@ Live collection of plug-and-play Home Assistant packages. Each YAML file in this - Video companion: [![Watch on YouTube](https://img.shields.io/badge/Watch-YouTube-FF0000?logo=youtube&logoColor=white)](https://youtu.be/KKOWSKuF5jA) and [![vCloudInfo Blog Post](https://img.shields.io/static/v1?label=vCloudInfo&message=Blog%20Post&color=21759B&logo=wordpress&logoColor=white)](https://www.vcloudinfo.com/2026/05/home-assistant-vacuum-automations-dreame-2026.html). ![Dreame Automations](../www/custom_ui/floorplan/images/branding/Dreame%20Automations.png) +### X post action + +The disabled [Xquik package](xquik.yaml.disabled) restores an opt-in Home Assistant action for posting to X. It replaces the reusable action removed after X changed its API pricing. The package adds no schedules and reads no household entities. + +1. Connect the target X account in Xquik. +2. Create an API key. +3. Add `xquik_api_key: xq_YOUR_KEY_HERE` to `secrets.yaml`. +4. Remove `.disabled` from the package filename. +5. Restart Home Assistant. + +Call the action with explicit public text: + +```yaml +action: script.xquik_post +data: + account: "@BearStoneHA" + message: "Home Assistant configuration checks passed." +``` + +The action uses Xquik's [Twitter post API](https://docs.xquik.com/api-reference/x-write/create-tweet). It assigns one idempotency key per script run and polls pending writes. Xquik charges credits for writes. + +Do not repeat a post after a transport error with a new key. For an exact retry, copy `write_key` from the original script trace into the optional `idempotency_key` field. Keep the account and message unchanged. + +Xquik is an independent third-party service. Not affiliated with X Corp. "Twitter" and "X" are trademarks of X Corp. + ### Blog & video deep dives When a package has a dedicated blog post or video, I link it right inside the YAML comments. Here are the same references for quick browsing: diff --git a/config/packages/xquik.yaml.disabled b/config/packages/xquik.yaml.disabled new file mode 100644 index 00000000..54433d98 --- /dev/null +++ b/config/packages/xquik.yaml.disabled @@ -0,0 +1,122 @@ +###################################################################### +# @CCOSTAN - Follow Me on X +# For more info visit https://www.vcloudinfo.com/click-here +# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig +# ------------------------------------------------------------------- +# Xquik Posting - Opt-in Home Assistant action for X posts. +# Posts caller-supplied text and polls the durable write status. +# ------------------------------------------------------------------- +# Setup: Add xquik_api_key to secrets.yaml, then remove .disabled. +# API: https://docs.xquik.com/api-reference/x-write/create-tweet +# History: https://github.com/CCOSTAN/Home-AssistantConfig/commit/7954ee4c7c30963b20c40c166bf102ba4d2a0464 +# Xquik is an independent third-party service. Not affiliated with X Corp. +###################################################################### + +rest_command: + xquik_create_post: + url: "https://xquik.com/api/v1/x/tweets" + method: post + timeout: 30 + content_type: application/json + headers: + x-api-key: !secret xquik_api_key + Idempotency-Key: "{{ idempotency_key }}" + payload: > + { + "account": {{ account | tojson }}, + "text": {{ message | tojson }} + } + + xquik_get_write_action: + url: "https://xquik.com{{ status_url }}" + method: get + timeout: 30 + headers: + x-api-key: !secret xquik_api_key + +script: + xquik_post: + alias: "Xquik - Post to X" + description: "Post one explicit message and wait for a terminal Xquik write state." + mode: parallel + max: 5 + fields: + account: + name: X account + description: Connected X account username, with or without @. + required: true + selector: + text: + message: + name: Message + description: Public text to post. + required: true + selector: + text: + multiline: true + idempotency_key: + name: Retry key + description: Reuse the original key only when retrying the exact same post. + required: false + selector: + text: + sequence: + - variables: + write_key: >- + {{ idempotency_key + | default('home-assistant-' ~ context.id, true) }} + + - action: rest_command.xquik_create_post + data: + account: "{{ account }}" + message: "{{ message }}" + idempotency_key: "{{ write_key }}" + response_variable: xquik_response + + - if: + - condition: template + value_template: "{{ xquik_response.status not in [200, 202] }}" + then: + - stop: "Xquik rejected the post. Open the script trace for its HTTP status." + error: true + + - if: + - condition: template + value_template: >- + {{ not (xquik_response.content.terminal | default(false)) }} + then: + - repeat: + sequence: + - variables: + poll_after_ms: >- + {{ xquik_response.content.pollAfterMs + | default(2000) + | int(2000) }} + - delay: + milliseconds: >- + {{ [[poll_after_ms | int(2000), 500] | max, 5000] | min }} + - action: rest_command.xquik_get_write_action + data: + status_url: "{{ xquik_response.content.statusUrl }}" + response_variable: xquik_response + until: + - condition: template + value_template: >- + {{ xquik_response.status != 202 + or xquik_response.content.terminal | default(false) + or repeat.index >= 15 }} + + - if: + - condition: template + value_template: "{{ xquik_response.status not in [200, 202] }}" + then: + - stop: "Xquik could not confirm the post. Open the script trace for its HTTP status." + error: true + + - if: + - condition: template + value_template: >- + {{ not (xquik_response.content.terminal | default(false)) }} + then: + - stop: "Xquik is still confirming the post. Do not retry this message." + error: true diff --git a/tests/test_xquik_package.py b/tests/test_xquik_package.py new file mode 100644 index 00000000..0d79692e --- /dev/null +++ b/tests/test_xquik_package.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import unittest +from pathlib import Path +from typing import Any + +import yaml + + +REPO = Path(__file__).resolve().parents[1] +PACKAGE = REPO / "config" / "packages" / "xquik.yaml.disabled" + + +class HomeAssistantLoader(yaml.SafeLoader): + pass + + +def _secret(loader: HomeAssistantLoader, node: yaml.ScalarNode) -> dict[str, str]: + return {"secret": loader.construct_scalar(node)} + + +HomeAssistantLoader.add_constructor("!secret", _secret) + + +def load_package() -> dict[str, Any]: + return yaml.load(PACKAGE.read_text(encoding="utf-8"), Loader=HomeAssistantLoader) + + +class XquikPackageTests(unittest.TestCase): + def test_create_post_matches_the_published_contract(self) -> None: + commands = load_package()["rest_command"] + command = commands["xquik_create_post"] + self.assertEqual(command["url"], "https://xquik.com/api/v1/x/tweets") + self.assertEqual(command["method"], "post") + self.assertEqual(command["headers"]["x-api-key"], {"secret": "xquik_api_key"}) + self.assertEqual(command["headers"]["Idempotency-Key"], "{{ idempotency_key }}") + self.assertIn('"account": {{ account | tojson }}', command["payload"]) + self.assertIn('"text": {{ message | tojson }}', command["payload"]) + self.assertEqual( + commands["xquik_get_write_action"]["url"], + "https://xquik.com{{ status_url }}", + ) + + def test_posting_requires_an_explicit_call(self) -> None: + package = load_package() + script = package["script"]["xquik_post"] + self.assertNotIn("automation", package) + self.assertTrue(script["fields"]["account"]["required"]) + self.assertTrue(script["fields"]["message"]["required"]) + self.assertEqual(script["mode"], "parallel") + + def test_each_run_has_a_stable_idempotency_key(self) -> None: + sequence = load_package()["script"]["xquik_post"]["sequence"] + write_key = sequence[0]["variables"]["write_key"] + self.assertIn("'home-assistant-' ~ context.id", write_key) + self.assertIn("default", write_key) + self.assertEqual(sequence[1]["data"]["idempotency_key"], "{{ write_key }}") + + def test_nonterminal_writes_are_polled_without_automatic_retry(self) -> None: + sequence = load_package()["script"]["xquik_post"]["sequence"] + rendered = str(sequence) + self.assertIn("rest_command.xquik_get_write_action", rendered) + self.assertIn("repeat.index >= 15", rendered) + self.assertIn("Do not retry this message.", rendered) + self.assertEqual(rendered.count("rest_command.xquik_create_post"), 1) + + +if __name__ == "__main__": + unittest.main()