parent
80cc42f94e
commit
589a7757ea
@ -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
|
||||
@ -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()
|
||||
Loading…
Reference in new issue