From 39c6afcb252e55dc33c484f8d470de655785148c Mon Sep 17 00:00:00 2001 From: Pandium Date: Thu, 16 Jul 2026 19:23:35 +0000 Subject: [PATCH 1/4] Pandium init --- SHIPBOB_TO_GORGIAS/Python/.gitignore | 6 + SHIPBOB_TO_GORGIAS/Python/PANDIUM.yaml | 49 +++++ SHIPBOB_TO_GORGIAS/Python/Pipfile | 13 ++ SHIPBOB_TO_GORGIAS/Python/Pipfile.lock | 200 ++++++++++++++++++ .../Python/sb2gorgias/__init__.py | 0 .../Python/sb2gorgias/__main__.py | 65 ++++++ SHIPBOB_TO_GORGIAS/Python/sb2gorgias/lib.py | 62 ++++++ 7 files changed, 395 insertions(+) create mode 100644 SHIPBOB_TO_GORGIAS/Python/.gitignore create mode 100644 SHIPBOB_TO_GORGIAS/Python/PANDIUM.yaml create mode 100644 SHIPBOB_TO_GORGIAS/Python/Pipfile create mode 100644 SHIPBOB_TO_GORGIAS/Python/Pipfile.lock create mode 100644 SHIPBOB_TO_GORGIAS/Python/sb2gorgias/__init__.py create mode 100644 SHIPBOB_TO_GORGIAS/Python/sb2gorgias/__main__.py create mode 100644 SHIPBOB_TO_GORGIAS/Python/sb2gorgias/lib.py diff --git a/SHIPBOB_TO_GORGIAS/Python/.gitignore b/SHIPBOB_TO_GORGIAS/Python/.gitignore new file mode 100644 index 00000000..d6cf1151 --- /dev/null +++ b/SHIPBOB_TO_GORGIAS/Python/.gitignore @@ -0,0 +1,6 @@ +# Byte-compiled and cached files +__pycache__/ +*.py[cod] + +# Local virtual environments +.venv/ diff --git a/SHIPBOB_TO_GORGIAS/Python/PANDIUM.yaml b/SHIPBOB_TO_GORGIAS/Python/PANDIUM.yaml new file mode 100644 index 00000000..0c25fe95 --- /dev/null +++ b/SHIPBOB_TO_GORGIAS/Python/PANDIUM.yaml @@ -0,0 +1,49 @@ +version: 1.0 +base: python:3.14 +build: pipenv install +run: pipenv run python -m sb2gorgias +configs: + schema: + properties: + free_text_config_1: + type: string + free_text_config_2: + type: string + boolean_1: + type: boolean + default: true + boolean_2: + type: boolean + colors: + $ref: '#/metadata/dynamic_colors' + + type: object + uischema: + elements: + - label: Free Text Field 1 + scope: '#/properties/free_text_config_1' + type: Control + - label: Free Text Field 2 + scope: '#/properties/free_text_config_2' + type: Control + - label: Boolean 1 + scope: '#/properties/boolean_1' + type: Control + admin: true + - label: Boolean 2 + scope: '#/properties/boolean_2' + type: Control + - label: Dynamic colors + scope: '#/properties/colors' + type: Control + type: VerticalLayout +metadata_schema: + schema: + name: metadata_schema + properties: + dynamic_colors: + type: array + items: + type: string + random_number: + type: number diff --git a/SHIPBOB_TO_GORGIAS/Python/Pipfile b/SHIPBOB_TO_GORGIAS/Python/Pipfile new file mode 100644 index 00000000..b037a2f0 --- /dev/null +++ b/SHIPBOB_TO_GORGIAS/Python/Pipfile @@ -0,0 +1,13 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +coloredlogs = "*" + +[dev-packages] +ipdb = "*" + +[requires] +python_version = "3.14" diff --git a/SHIPBOB_TO_GORGIAS/Python/Pipfile.lock b/SHIPBOB_TO_GORGIAS/Python/Pipfile.lock new file mode 100644 index 00000000..12647a8a --- /dev/null +++ b/SHIPBOB_TO_GORGIAS/Python/Pipfile.lock @@ -0,0 +1,200 @@ +{ + "_meta": { + "hash": { + "sha256": "0acacdd99087f1ddebd535032ddc2c9ba526de0394d538ca8ad3137e9d7fbb3a" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.14" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "coloredlogs": { + "hashes": [ + "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", + "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==15.0.1" + }, + "humanfriendly": { + "hashes": [ + "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", + "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==10.0" + } + }, + "develop": { + "asttokens": { + "hashes": [ + "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", + "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7" + ], + "markers": "python_version >= '3.8'", + "version": "==3.0.1" + }, + "decorator": { + "hashes": [ + "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", + "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a" + ], + "markers": "python_version >= '3.8'", + "version": "==5.2.1" + }, + "executing": { + "hashes": [ + "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", + "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017" + ], + "markers": "python_version >= '3.8'", + "version": "==2.2.1" + }, + "ipdb": { + "hashes": [ + "sha256:45529994741c4ab6d2388bfa5d7b725c2cf7fe9deffabdb8a6113aa5ed449ed4", + "sha256:e3ac6018ef05126d442af680aad863006ec19d02290561ac88b8b1c0b0cfc726" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.13.13" + }, + "ipython": { + "hashes": [ + "sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201", + "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967" + ], + "markers": "python_version >= '3.11'", + "version": "==9.13.0" + }, + "ipython-pygments-lexers": { + "hashes": [ + "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", + "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c" + ], + "markers": "python_version >= '3.8'", + "version": "==1.1.1" + }, + "jedi": { + "hashes": [ + "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", + "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011" + ], + "markers": "python_version >= '3.10'", + "version": "==0.20.0" + }, + "matplotlib-inline": { + "hashes": [ + "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", + "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79" + ], + "markers": "python_version >= '3.9'", + "version": "==0.2.2" + }, + "parso": { + "hashes": [ + "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", + "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1" + ], + "markers": "python_version >= '3.6'", + "version": "==0.8.7" + }, + "pexpect": { + "hashes": [ + "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", + "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f" + ], + "markers": "sys_platform != 'win32' and sys_platform != 'emscripten'", + "version": "==4.9.0" + }, + "prompt-toolkit": { + "hashes": [ + "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", + "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955" + ], + "markers": "python_version >= '3.8'", + "version": "==3.0.52" + }, + "psutil": { + "hashes": [ + "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", + "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", + "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", + "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", + "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", + "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", + "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", + "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", + "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", + "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", + "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", + "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", + "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", + "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", + "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", + "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", + "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", + "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", + "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", + "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", + "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8" + ], + "markers": "python_version >= '3.6'", + "version": "==7.2.2" + }, + "ptyprocess": { + "hashes": [ + "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", + "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220" + ], + "version": "==0.7.0" + }, + "pure-eval": { + "hashes": [ + "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", + "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42" + ], + "version": "==0.2.3" + }, + "pygments": { + "hashes": [ + "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", + "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" + ], + "markers": "python_version >= '3.9'", + "version": "==2.20.0" + }, + "stack-data": { + "hashes": [ + "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", + "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695" + ], + "version": "==0.6.3" + }, + "traitlets": { + "hashes": [ + "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971", + "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40" + ], + "markers": "python_version >= '3.9'", + "version": "==5.15.0" + }, + "wcwidth": { + "hashes": [ + "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", + "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0" + ], + "markers": "python_version >= '3.8'", + "version": "==0.7.0" + } + } +} diff --git a/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/__init__.py b/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/__main__.py b/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/__main__.py new file mode 100644 index 00000000..cd587119 --- /dev/null +++ b/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/__main__.py @@ -0,0 +1,65 @@ +import json +import logging +import random +import sys +from typing import Any + +import coloredlogs + +from .lib import Pandium + +logger = logging.getLogger(__name__) +# Logs go to stderr; stdout is reserved for the JSON metadata Pandium reads back. +coloredlogs.install(level='DEBUG', logger=logger, stream=sys.stderr) + + +def run(mode: str, pandium: Pandium) -> dict[str, Any]: + """The business logic of the run varies depending on the run mode.""" + match mode: + case 'init': + # Init mode: report which secrets are available and populate tenant metadata + # with the dynamic config values needed for the customer-facing config form. In + # the real world, these values would be derived from an api call. + logger.info('The available secrets are: %s', ', '.join(pandium.secrets)) + return {'dynamic_colors': ['red', 'green', 'purple', 'orange', 'yellow']} + + case 'webhook': + # Webhook mode: each trigger's payload['file'] names a file holding the raw + # webhook body; read and log it. This version emits no metadata, but there is no + # reason not to update metadata from here. + for trigger in pandium.run_triggers: + file = trigger.get('payload', {}).get('file') + if not file: + continue + try: + with open(file, encoding='utf-8') as f: + logger.info(f.read()) + except OSError as err: + logger.error('could not read webhook payload %s: %s', file, err) + return {} + + case _: + # Normal mode: log the config, then log the previous normal run's random number + # and store a fresh random number as metadata. + logger.info('Tenant configs: %s', pandium.config) + new_random_number = random.randrange(1_000_000) + if pandium.metadata is not None: + logger.info("last run's random number: %s", pandium.metadata.get('random_number')) + logger.info('new random number: %s', new_random_number) + return {'random_number': new_random_number} + + +def main() -> None: + pandium = Pandium.from_env() + + run_mode = pandium.run_mode or '' + + logger.info('Hello from a Pandium integration, written in Python!') + logger.info('This run is in mode: %s', run_mode) + + std_out = run(run_mode, pandium) + print(json.dumps(std_out)) + + +if __name__ == '__main__': + main() diff --git a/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/lib.py b/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/lib.py new file mode 100644 index 00000000..46946862 --- /dev/null +++ b/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/lib.py @@ -0,0 +1,62 @@ +import json +import os +from functools import cached_property +from typing import Any + + +def _from_env(prefix: str) -> dict[str, str]: + """Collect environment variables starting with ``prefix``, stripping the prefix and + lower-casing the remaining key.""" + return {key.removeprefix(prefix).lower(): value for key, value in os.environ.items() if key.startswith(prefix)} + + +class Pandium: + """Everything Pandium hands to an integration at runtime. + + ``config`` (``PAN_CFG_*``) and ``secrets`` (``PAN_SEC_*``) hold arbitrary keys defined + per integration and are exposed as plain mappings. ``context`` (``PAN_CTX_*``) is + controlled by Pandium, so its values are surfaced through named, typed properties. + """ + + def __init__(self, config: dict[str, str], secrets: dict[str, str], context: dict[str, str]): + self.config = config + self.secrets = secrets + self._context = context + + @classmethod + def from_env(cls) -> 'Pandium': + return cls( + config=_from_env('PAN_CFG_'), + secrets=_from_env('PAN_SEC_'), + context=_from_env('PAN_CTX_'), + ) + + @property + def run_mode(self) -> str | None: + """The run mode for this invocation (e.g. ``init``, ``webhook``).""" + return self._context.get('run_mode') + + @property + def run_triggers(self) -> list[Any]: + """The triggers that caused this run, parsed from JSON. Relevant for webhook + invocations, where each trigger's ``payload['file']`` names a file holding the raw + webhook body.""" + raw = self._context.get('run_triggers') + if not raw: + return [] + try: + return json.loads(raw) + except json.JSONDecodeError: + return [] + + @cached_property + def metadata(self) -> Any | None: + """The tenant metadata persisted by the previous run, parsed as JSON.""" + filename = self._context.get('tenant_metadata_file') + if not filename: + return None + try: + with open(filename, encoding='utf-8') as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return None From 1bbbfd48b74d41b038e1ac44754c0d761b9e7a39 Mon Sep 17 00:00:00 2001 From: sarahtrefethen <49411879+sarahtrefethen@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:02:25 -0400 Subject: [PATCH 2/4] cron flow --- SHIPBOB_TO_GORGIAS/Python/PANDIUM.yaml | 65 +++--- SHIPBOB_TO_GORGIAS/Python/Pipfile | 1 + SHIPBOB_TO_GORGIAS/Python/Pipfile.lock | 164 +++++++++++++-- .../Python/sb2gorgias/__main__.py | 56 ++---- .../Python/sb2gorgias/flow_a.py | 162 +++++++++++++++ .../Python/sb2gorgias/gorgias.py | 188 ++++++++++++++++++ .../Python/sb2gorgias/shipbob.py | 99 +++++++++ 7 files changed, 650 insertions(+), 85 deletions(-) create mode 100644 SHIPBOB_TO_GORGIAS/Python/sb2gorgias/flow_a.py create mode 100644 SHIPBOB_TO_GORGIAS/Python/sb2gorgias/gorgias.py create mode 100644 SHIPBOB_TO_GORGIAS/Python/sb2gorgias/shipbob.py diff --git a/SHIPBOB_TO_GORGIAS/Python/PANDIUM.yaml b/SHIPBOB_TO_GORGIAS/Python/PANDIUM.yaml index 0c25fe95..0da7145d 100644 --- a/SHIPBOB_TO_GORGIAS/Python/PANDIUM.yaml +++ b/SHIPBOB_TO_GORGIAS/Python/PANDIUM.yaml @@ -1,49 +1,46 @@ version: 1.0 -base: python:3.14 -build: pipenv install -run: pipenv run python -m sb2gorgias +base: python:3.14 # node, ruby, java, php, .net, go, kotlin, rust +build: pipenv install # modify for language +run: pipenv run python -m sb2gorgias # modify for language configs: schema: + name: pandium_configs + type: object properties: - free_text_config_1: - type: string - free_text_config_2: + # Fallback cursor used on a tenant's first run, before any metadata exists. + order_start_date: type: string - boolean_1: - type: boolean - default: true - boolean_2: + format: date + # Display newest order at the top of the customer sidebar list. + newest_order_first: type: boolean - colors: - $ref: '#/metadata/dynamic_colors' - - type: object + default: false uischema: + type: VerticalLayout elements: - - label: Free Text Field 1 - scope: '#/properties/free_text_config_1' - type: Control - - label: Free Text Field 2 - scope: '#/properties/free_text_config_2' + - label: Order Cutoff Date + scope: '#/properties/order_start_date' type: Control - - label: Boolean 1 - scope: '#/properties/boolean_1' + - label: Display Newest Order at Top + scope: '#/properties/newest_order_first' type: Control - admin: true - - label: Boolean 2 - scope: '#/properties/boolean_2' - type: Control - - label: Dynamic colors - scope: '#/properties/colors' - type: Control - type: VerticalLayout +# Shared across both flows. The platform validates a run's stdout against this +# schema and shallow-merges it (top-level) into tenant metadata. metadata_schema: schema: name: metadata_schema + type: object properties: - dynamic_colors: - type: array - items: + # Flow A (cron) — the sync cursor: high-water marks for new / updated orders. + new_order_start_date: + type: string + format: date-time + updated_order_start_date: + type: string + format: date-time + # Flow B (webhook) — dedupe map of shipment_id -> ISO time the ticket was created. + processed_shipments: + type: object + additionalProperties: type: string - random_number: - type: number + format: date-time diff --git a/SHIPBOB_TO_GORGIAS/Python/Pipfile b/SHIPBOB_TO_GORGIAS/Python/Pipfile index b037a2f0..15c0ebf2 100644 --- a/SHIPBOB_TO_GORGIAS/Python/Pipfile +++ b/SHIPBOB_TO_GORGIAS/Python/Pipfile @@ -5,6 +5,7 @@ name = "pypi" [packages] coloredlogs = "*" +requests = "*" [dev-packages] ipdb = "*" diff --git a/SHIPBOB_TO_GORGIAS/Python/Pipfile.lock b/SHIPBOB_TO_GORGIAS/Python/Pipfile.lock index 12647a8a..d7425317 100644 --- a/SHIPBOB_TO_GORGIAS/Python/Pipfile.lock +++ b/SHIPBOB_TO_GORGIAS/Python/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "0acacdd99087f1ddebd535032ddc2c9ba526de0394d538ca8ad3137e9d7fbb3a" + "sha256": "85d14d957a7590c265e72271aaa93c98f86918fb93e2ce17b627c9a3fe48c15c" }, "pipfile-spec": 6, "requires": { @@ -16,6 +16,113 @@ ] }, "default": { + "certifi": { + "hashes": [ + "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", + "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db" + ], + "markers": "python_version >= '3.7'", + "version": "==2026.6.17" + }, + "charset-normalizer": { + "hashes": [ + "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", + "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", + "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", + "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", + "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", + "sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833", + "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", + "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", + "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", + "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", + "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", + "sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4", + "sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a", + "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", + "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", + "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", + "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", + "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", + "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", + "sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84", + "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", + "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", + "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", + "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", + "sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29", + "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", + "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", + "sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe", + "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", + "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", + "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", + "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", + "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", + "sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94", + "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", + "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", + "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", + "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", + "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", + "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", + "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", + "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", + "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", + "sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4", + "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", + "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", + "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", + "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", + "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", + "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", + "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", + "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", + "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", + "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", + "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", + "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", + "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", + "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", + "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", + "sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f", + "sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9", + "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", + "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", + "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", + "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", + "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", + "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", + "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", + "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", + "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", + "sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba", + "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", + "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", + "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", + "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", + "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", + "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", + "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", + "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", + "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", + "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", + "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", + "sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b", + "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", + "sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5", + "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", + "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", + "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", + "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", + "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", + "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", + "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", + "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115" + ], + "markers": "python_version >= '3.7'", + "version": "==3.4.9" + }, "coloredlogs": { "hashes": [ "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", @@ -32,24 +139,49 @@ ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==10.0" + }, + "idna": { + "hashes": [ + "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", + "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848" + ], + "markers": "python_version >= '3.9'", + "version": "==3.18" + }, + "requests": { + "hashes": [ + "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", + "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" + ], + "index": "pypi", + "markers": "python_version >= '3.10'", + "version": "==2.34.2" + }, + "urllib3": { + "hashes": [ + "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", + "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897" + ], + "markers": "python_version >= '3.10'", + "version": "==2.7.0" } }, "develop": { "asttokens": { "hashes": [ - "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", - "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7" + "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", + "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933" ], "markers": "python_version >= '3.8'", - "version": "==3.0.1" + "version": "==3.0.2" }, "decorator": { "hashes": [ - "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", - "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a" + "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", + "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c" ], "markers": "python_version >= '3.8'", - "version": "==5.2.1" + "version": "==5.3.1" }, "executing": { "hashes": [ @@ -70,11 +202,11 @@ }, "ipython": { "hashes": [ - "sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201", - "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967" + "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", + "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756" ], "markers": "python_version >= '3.11'", - "version": "==9.13.0" + "version": "==9.15.0" }, "ipython-pygments-lexers": { "hashes": [ @@ -182,19 +314,19 @@ }, "traitlets": { "hashes": [ - "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971", - "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40" + "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", + "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722" ], "markers": "python_version >= '3.9'", - "version": "==5.15.0" + "version": "==5.15.1" }, "wcwidth": { "hashes": [ - "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", - "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0" + "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", + "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85" ], "markers": "python_version >= '3.8'", - "version": "==0.7.0" + "version": "==0.8.2" } } } diff --git a/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/__main__.py b/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/__main__.py index cd587119..3cc1de66 100644 --- a/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/__main__.py +++ b/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/__main__.py @@ -1,63 +1,49 @@ import json import logging -import random import sys from typing import Any import coloredlogs +from . import flow_a, flow_b from .lib import Pandium -logger = logging.getLogger(__name__) # Logs go to stderr; stdout is reserved for the JSON metadata Pandium reads back. -coloredlogs.install(level='DEBUG', logger=logger, stream=sys.stderr) +# Install on the root logger so every sb2gorgias module's logs surface, then +# quiet urllib3's per-request connection chatter. +coloredlogs.install(level='DEBUG', stream=sys.stderr) +logging.getLogger('urllib3').setLevel(logging.WARNING) +logger = logging.getLogger(__name__) def run(mode: str, pandium: Pandium) -> dict[str, Any]: - """The business logic of the run varies depending on the run mode.""" + """Dispatch on run mode. Each branch returns the metadata dict to print to + stdout, which the platform validates against ``metadata_schema`` and + shallow-merges into tenant metadata.""" match mode: case 'init': - # Init mode: report which secrets are available and populate tenant metadata - # with the dynamic config values needed for the customer-facing config form. In - # the real world, these values would be derived from an api call. - logger.info('The available secrets are: %s', ', '.join(pandium.secrets)) - return {'dynamic_colors': ['red', 'green', 'purple', 'orange', 'yellow']} + # No dynamic config to resolve; just report which secrets are wired up. + logger.info('Available secrets: %s', ', '.join(pandium.secrets) or '(none)') + return {} case 'webhook': - # Webhook mode: each trigger's payload['file'] names a file holding the raw - # webhook body; read and log it. This version emits no metadata, but there is no - # reason not to update metadata from here. - for trigger in pandium.run_triggers: - file = trigger.get('payload', {}).get('file') - if not file: - continue - try: - with open(file, encoding='utf-8') as f: - logger.info(f.read()) - except OSError as err: - logger.error('could not read webhook payload %s: %s', file, err) - return {} + # Flow B — ShipBob shipment_delivered -> Gorgias ticket. + return flow_b.run(pandium) case _: - # Normal mode: log the config, then log the previous normal run's random number - # and store a fresh random number as metadata. - logger.info('Tenant configs: %s', pandium.config) - new_random_number = random.randrange(1_000_000) - if pandium.metadata is not None: - logger.info("last run's random number: %s", pandium.metadata.get('random_number')) - logger.info('new random number: %s', new_random_number) - return {'random_number': new_random_number} + # Flow A — scheduled ShipBob orders -> Gorgias customer sidebar. + return flow_a.run(pandium) def main() -> None: pandium = Pandium.from_env() + mode = pandium.run_mode or '' - run_mode = pandium.run_mode or '' - - logger.info('Hello from a Pandium integration, written in Python!') - logger.info('This run is in mode: %s', run_mode) + logger.info('sb2gorgias starting — run mode: %s', mode or '(cron)') - std_out = run(run_mode, pandium) + std_out = run(mode, pandium) + # Flow A's timeout handler prints and exits on its own; on every normal path + # the metadata is printed here as the last line of stdout. print(json.dumps(std_out)) diff --git a/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/flow_a.py b/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/flow_a.py new file mode 100644 index 00000000..247d4d8b --- /dev/null +++ b/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/flow_a.py @@ -0,0 +1,162 @@ +"""Flow A — CRON: ShipBob orders -> Gorgias customer sidebar. + +Keeps each Gorgias customer's ``data.pandium.shipbob_orders`` in sync with that +customer's recent ShipBob orders. Runs on a schedule and resumes where the last +run left off, using tenant metadata as the cursor. + +The run is bounded at ~10 minutes by Pandium. To stay resumable, the loop keeps a +single in-memory *timeout record* (the cursor) current as each order is +processed, and a SIGALRM handler flushes that record to stdout before the hard +kill. Exiting 0 on timeout means the partial cursor is merged into metadata and +the next run picks up from there. +""" + +import json +import logging +import signal +import sys +from datetime import datetime, timedelta + +from .gorgias import GorgiasAPI, _get +from .shipbob import ShipBobAPI + +logger = logging.getLogger(__name__) + +ALARM_SECONDS = 540 # self-imposed 9-min alarm, ahead of Pandium's ~10-min kill +ONE_MONTH = timedelta(days=30) +MAX_ORDERS_TO_SYNC = 10 # most recent N orders kept on each customer + + +def clamp(value, now: datetime) -> datetime: + """Keep a cursor within [now - 1 month, now]. Unparseable/missing values fall + back to one month ago (the oldest window we ever fetch).""" + floor = now - ONE_MONTH + try: + dt = value if isinstance(value, datetime) else datetime.fromisoformat(str(value)) + except (TypeError, ValueError): + return floor + if dt.tzinfo is not None: + dt = dt.replace(tzinfo=None) # compare naive-to-naive + return min(max(dt, floor), now) + + +def _upsert(orders: list, order_payload: dict, newest_first: bool) -> list: + """Merge ``order_payload`` into a customer's order list (replace by id, else + append), then sort and trim to the most recent ``MAX_ORDERS_TO_SYNC``.""" + for i, existing in enumerate(orders): + if existing.get('id') == order_payload['id']: + orders[i] = order_payload + return orders # in-place replace; no re-sort/trim needed + + orders.append(order_payload) + orders.sort(key=lambda o: o.get('id', 0), reverse=newest_first) + if len(orders) > MAX_ORDERS_TO_SYNC: + orders = orders[:MAX_ORDERS_TO_SYNC] if newest_first else orders[-MAX_ORDERS_TO_SYNC:] + return orders + + +def process_order(sb_order: dict, gorgias: GorgiasAPI, cache: dict, newest_first: bool) -> None: + """Find-or-create the order's Gorgias customer, then PUT/POST its updated + ``data.pandium.shipbob_orders``. ``cache`` accumulates customer payloads + within a run so multiple orders for one customer batch onto the same record. + """ + key = gorgias.customer_key(sb_order) + email = gorgias.valid_email(_get(sb_order, 'recipient.email', '')) + + if key not in cache: + try: + existing = gorgias.find_customer( + email=email or None, external_id=None if email else key + ) + except Exception as err: + logger.error('Skipping order %s — cannot fetch customer %s: %s', + _get(sb_order, 'id', ''), key, err) + return + + if existing: + data = existing.get('data') or {} + data.setdefault('pandium', {}) + if not isinstance(data['pandium'].get('shipbob_orders'), list): + data['pandium']['shipbob_orders'] = [] + cache[key] = {'id': existing['id'], 'data': data} + else: + cache[key] = gorgias.new_customer_payload(sb_order, key) + + customer = cache[key] + customer['data']['pandium']['shipbob_orders'] = _upsert( + customer['data']['pandium']['shipbob_orders'], + gorgias.order_data_payload(sb_order), + newest_first, + ) + + try: + if 'id' in customer: + gorgias.update_customer(customer['id'], customer) + else: + customer['id'] = gorgias.create_customer(customer) + except Exception as err: + logger.error('Failed to upsert Gorgias customer %s: %s', key, err) + + +def run(pandium) -> dict: + now = datetime.now() + metadata = pandium.metadata or {} + fallback = pandium.config.get('order_start_date') + + new_cursor = clamp(metadata.get('new_order_start_date') or fallback, now) + updated_cursor = clamp(metadata.get('updated_order_start_date') or fallback, now) + + # The timeout record: the cursor we flush on either outcome. Values are ISO + # strings advanced as orders are processed. + record = { + 'new_order_start_date': new_cursor.isoformat(), + 'updated_order_start_date': updated_cursor.isoformat(), + } + + def flush(): + print(json.dumps(record)) + + def on_alarm(signum, frame): + logger.warning('Approaching the run-time limit — flushing cursor for the next run.') + flush() + sys.exit(0) # timed-out run still counts as successful → partial cursor merged + + signal.signal(signal.SIGALRM, on_alarm) + signal.alarm(ALARM_SECONDS) + + shipbob = ShipBobAPI(pandium) + gorgias = GorgiasAPI(pandium) + newest_first = str(pandium.config.get('newest_order_first', '')).lower() == 'true' + cache: dict = {} + + # New orders: SortOrder=Oldest, so created_date advances forward monotonically. + logger.info('Syncing new ShipBob orders since %s', record['new_order_start_date']) + page = 1 + while True: + orders = shipbob.get_new_orders_page(new_cursor, page) + if not orders: + break + for order in orders: + process_order(order, gorgias, cache, newest_first) + created = order.get('created_date') + if created: + # created_date is YYYY-MM-DDThh:mm:ss.sssssss+00:00; trim to 26 + # chars for a valid (naive, microsecond) date-time. + record['new_order_start_date'] = created[:26] + page += 1 + + # Updated orders: keyed off shipment last_update_at (see get_updated_orders_page). + logger.info('Syncing updated ShipBob orders since %s', record['updated_order_start_date']) + page = 1 + while True: + orders = shipbob.get_updated_orders_page(updated_cursor, page) + if not orders: + break + for order in orders: + process_order(order, gorgias, cache, newest_first) + # last_update_at is YYYY-MM-DDThh:mm:ss.sss+00:00; trim to 23 chars. + record['updated_order_start_date'] = shipbob.get_update_date(order, updated_cursor)[:23] + page += 1 + + signal.alarm(0) # made it — cancel the alarm; __main__ prints the record on return + return record diff --git a/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/gorgias.py b/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/gorgias.py new file mode 100644 index 00000000..4482f8f3 --- /dev/null +++ b/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/gorgias.py @@ -0,0 +1,188 @@ +"""Gorgias API client. + +Flow A upserts customers (writing ShipBob order history to +``data.pandium.shipbob_orders``); Flow B creates tickets. Auth is HTTP Basic +(``email``:``api_key``) against ``https://.gorgias.com/api``. +""" + +import logging +import re +from datetime import datetime +from urllib.parse import quote + +import requests +from requests.adapters import HTTPAdapter, Retry + +logger = logging.getLogger(__name__) + +# Gorgias validates the shape of an email; mirror the check the older integration +# used so we agree on which recipients get an email-keyed customer. +EMAIL_RE = re.compile( + r"([-!#-'*+/-9=?A-Z^-~]+(\.[-!#-'*+/-9=?A-Z^-~]+)*|\"([]!#-[^-~ \t]|(\\[\t -~]))+\")" + r"@([-!#-'*+/-9=?A-Z^-~]+(\.[-!#-'*+/-9=?A-Z^-~]+)*|\[[\t -Z^-~]*])" +) + + +def _clean_store_name(value: str) -> str: + """Reduce a store name or URL to the bare subdomain.""" + return value.lower().split('https://')[-1].split('http://')[-1].split('.gorgias.com')[0] + + +def _get(data, path: str, default=None): + """Safe nested lookup, e.g. ``_get(order, 'recipient.address.city')``.""" + cur = data + for part in path.split('.'): + if not isinstance(cur, dict): + return default + cur = cur.get(part) + return default if cur is None else cur + + +def _format_date(value: str) -> str: + """Render a ShipBob ISO timestamp for the customer sidebar; pass through on + anything unparseable. Trims to 26 chars so 7-digit fractional seconds parse.""" + if not value: + return '' + try: + return datetime.fromisoformat(value[:26]).strftime('%d/%m/%Y %H:%M:%S') + except (ValueError, TypeError): + return value + + +class GorgiasAPI: + def __init__(self, pandium): + secrets = pandium.secrets + store = secrets.get('gorgias_store_name') + email = secrets.get('gorgias_email') + api_key = secrets.get('gorgias_api_key') + if not (store and email and api_key): + raise RuntimeError( + 'PAN_SEC_GORGIAS_STORE_NAME, PAN_SEC_GORGIAS_EMAIL and ' + 'PAN_SEC_GORGIAS_API_KEY are required' + ) + + self.api_url = f'https://{_clean_store_name(store)}.gorgias.com/api' + + session = requests.Session() + retries = Retry( + total=6, + backoff_factor=2, + status_forcelist=[429, 502, 503, 504], + allowed_methods=frozenset(['GET', 'POST', 'PUT']), + ) + session.mount('https://', HTTPAdapter(max_retries=retries)) + session.headers.update({'accept': 'application/json', 'content-type': 'application/json'}) + session.auth = (email, api_key) + self._session = session + + # --- customers (Flow A) ------------------------------------------------- + + def find_customer(self, email: str = None, external_id: str = None) -> dict | None: + """Look a customer up by email or external_id and return the detail + record (so callers can read ``data``), or None if not found. A given + email/external_id maps to at most one customer, so no pagination needed.""" + if email: + query = f'email={quote(email.lower())}' + elif external_id: + query = f'external_id={quote(external_id)}' + else: + return None + + res = self._session.get(f'{self.api_url}/customers?{query}') + res.raise_for_status() + rows = res.json().get('data', []) + if not rows: + return None + + detail = self._session.get(f'{self.api_url}/customers/{rows[0]["id"]}') + detail.raise_for_status() + return detail.json() + + def create_customer(self, payload: dict): + res = self._session.post(f'{self.api_url}/customers', json=payload) + try: + res.raise_for_status() + except Exception: + logger.error('Create customer failed: %s', res.text) + raise + return res.json()['id'] + + def update_customer(self, customer_id, payload: dict): + res = self._session.put(f'{self.api_url}/customers/{customer_id}', json=payload) + try: + res.raise_for_status() + except Exception: + logger.error('Update customer %s failed: %s', customer_id, res.text) + raise + + # --- tickets (Flow B) --------------------------------------------------- + + def create_ticket(self, payload: dict) -> dict: + res = self._session.post(f'{self.api_url}/tickets', json=payload) + try: + res.raise_for_status() + except Exception: + logger.error('Create ticket failed: %s', res.text) + raise + return res.json() + + # --- helpers ------------------------------------------------------------ + + def valid_email(self, email: str) -> str: + """Return ``email`` if Gorgias would accept it, else ''.""" + if email and '.@' not in email and EMAIL_RE.fullmatch(email): + return email + return '' + + def customer_key(self, sb_order: dict) -> str: + """The key identifying an order's customer: a valid recipient email when + present, otherwise a synthetic ``name address1 city country``.""" + email = self.valid_email(_get(sb_order, 'recipient.email', '')) + if email: + return email + return ' '.join( + [ + _get(sb_order, 'recipient.name', '') or '', + _get(sb_order, 'recipient.address.address1', '') or '', + _get(sb_order, 'recipient.address.city', '') or '', + _get(sb_order, 'recipient.address.country', '') or '', + ] + ) + + def new_customer_payload(self, sb_order: dict, key: str) -> dict: + """Body for POST /customers when the customer does not yet exist.""" + payload = { + 'name': _get(sb_order, 'recipient.name', ''), + 'external_id': key, + 'data': {'pandium': {'shipbob_orders': []}}, + } + email = self.valid_email(_get(sb_order, 'recipient.email', '')) + if email: + payload['email'] = email + return payload + + def order_data_payload(self, sb_order: dict) -> dict: + """The single order entry stored in ``data.pandium.shipbob_orders``.""" + shipments = _get(sb_order, 'shipments', []) or [] + for shipment in shipments: + for field in ('estimated_fulfillment_date', 'actual_fulfillment_date'): + if shipment.get(field): + shipment[field] = _format_date(shipment[field]) + shipment['url'] = ( + f"https://web.shipbob.com/App/Merchant/#/Orders/{shipment.get('id', '')}/" + ) + return { + 'id': _get(sb_order, 'id', ''), + 'created_date': _format_date(_get(sb_order, 'created_date', '')), + 'purchase_date': _format_date(_get(sb_order, 'purchase_date', '')), + 'reference_id': _get(sb_order, 'reference_id', ''), + 'order_number': _get(sb_order, 'order_number', ''), + 'status': _get(sb_order, 'status', ''), + 'type': _get(sb_order, 'type', ''), + 'channel': _get(sb_order, 'channel', {}), + 'shipping_method': _get(sb_order, 'shipping_method', ''), + 'recipient': _get(sb_order, 'recipient', {}), + 'products': _get(sb_order, 'products', []), + 'tags': _get(sb_order, 'tags', []), + 'shipments': shipments, + } diff --git a/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/shipbob.py b/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/shipbob.py new file mode 100644 index 00000000..2212d65a --- /dev/null +++ b/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/shipbob.py @@ -0,0 +1,99 @@ +"""ShipBob API client — reads orders for the Flow A cron sync. + +Auth is a single bearer token (``PAN_SEC_SHIPBOB_ACCESS_TOKEN``). The base URL is +resolved from the token's issuer (``iss``) claim, so the same code targets prod, +sandbox, or QA depending on which token the tenant connected. +""" + +import base64 +import json +import logging +from datetime import datetime + +import requests +from requests.adapters import HTTPAdapter, Retry + +logger = logging.getLogger(__name__) + +# ShipBob issues tokens from different auth hosts per environment; map each to its +# matching API base URL. Anything unrecognized falls back to prod. +AUTH_URL_TO_BASE_URL = { + 'https://authqa.shipbob.com': 'https://shipbobapi-nonprod.azure-api.net/qa', + 'https://authstage.shipbob.com': 'https://sandbox-api.shipbob.com', + 'https://auth.shipbob.com': 'https://api.shipbob.com/1.0', +} +DEFAULT_BASE_URL = 'https://api.shipbob.com/1.0' + + +def _resolve_base_url(token: str) -> str: + """Decode the JWT payload and map its ``iss`` claim to an API base URL.""" + try: + payload = token.split('.')[1] + payload += '=' * (-len(payload) % 4) # restore base64 padding + claims = json.loads(base64.urlsafe_b64decode(payload)) + return AUTH_URL_TO_BASE_URL.get(claims.get('iss'), DEFAULT_BASE_URL) + except Exception as err: # malformed token → assume prod + logger.warning('Could not resolve ShipBob base URL from token: %s', err) + return DEFAULT_BASE_URL + + +class ShipBobAPI: + def __init__(self, pandium): + token = pandium.secrets.get('shipbob_access_token') + if not token: + raise RuntimeError('PAN_SEC_SHIPBOB_ACCESS_TOKEN is required') + + self.api_url = _resolve_base_url(token) + + session = requests.Session() + retries = Retry(total=6, backoff_factor=3, status_forcelist=[429, 502, 503, 504]) + session.mount('https://', HTTPAdapter(max_retries=retries)) + session.headers.update( + { + 'accept': 'application/json', + 'content-type': 'application/json', + 'Authorization': f'Bearer {token}', + } + ) + self._session = session + + def _get_orders(self, params: dict) -> list: + """GET one page of ``/order``. Returns [] on error or empty page.""" + res = self._session.get(f'{self.api_url}/order', params=params) + try: + res.raise_for_status() + except Exception as err: + logger.error('ShipBob order fetch failed (%s): %s', params, err) + return [] + data = res.json() + return data if isinstance(data, list) else [] + + def get_new_orders_page(self, start_date: datetime, page: int) -> list: + """One page of orders created since ``start_date``, oldest first.""" + return self._get_orders( + {'StartDate': start_date.isoformat(), 'Page': page, 'SortOrder': 'Oldest'} + ) + + def get_updated_orders_page(self, start_date: datetime, page: int) -> list: + """One page of orders updated since ``start_date``. + + ShipBob puts ``last_update_at`` on shipments, not orders, so we derive a + per-order update timestamp and sort the page newest-first. Advancing the + cursor to the oldest processed update keeps the sync conservative: a + timed-out run never skips an update, at the cost of some reprocessing + (which is harmless — customer writes are idempotent PUTs). + """ + orders = self._get_orders({'LastUpdateStartDate': start_date.isoformat(), 'Page': page}) + orders.sort(key=lambda order: self.get_update_date(order, start_date), reverse=True) + return orders + + def get_update_date(self, order: dict, start_date: datetime) -> str: + """The oldest shipment ``last_update_at`` on ``order`` that still falls + after ``start_date``; defaults to now when none qualify.""" + start_str = start_date.strftime('%Y-%m-%dT%H:%M:%S.%f') + update_date = datetime.now().strftime('%Y-%m-%dT%H:%M:%S.%f') + for shipment in order.get('shipments', []) or []: + ts = shipment.get('last_update_at') + if ts and start_str < ts < update_date: + update_date = ts + return update_date From 23a69a6f6394dfc11fca15086487a2756692d541 Mon Sep 17 00:00:00 2001 From: sarahtrefethen <49411879+sarahtrefethen@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:26:31 -0400 Subject: [PATCH 3/4] webhook (shipment_delivered -> ticket) --- .../Python/sb2gorgias/flow_b.py | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 SHIPBOB_TO_GORGIAS/Python/sb2gorgias/flow_b.py diff --git a/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/flow_b.py b/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/flow_b.py new file mode 100644 index 00000000..dc5c914c --- /dev/null +++ b/SHIPBOB_TO_GORGIAS/Python/sb2gorgias/flow_b.py @@ -0,0 +1,202 @@ +"""Flow B — WEBHOOK: ShipBob ``shipment_delivered`` -> Gorgias ticket. + +Each webhook run may carry N debounced deliveries (Pandium bundles triggers that +arrive while a run is in flight), so we loop over every trigger. Creating a ticket +is not idempotent and ShipBob retries any delivery that doesn't get a 2xx, so we +dedupe on ``shipment_id`` using a ``processed_shipments`` map in tenant metadata, +pruned to a 30-minute window. + +Because tenant metadata is shallow-merged at the top level, writing the whole +``processed_shipments`` object *replaces* the previous one (dropped keys are +really removed) while leaving Flow A's cursor keys untouched. +""" + +import base64 +import hashlib +import hmac +import json +import logging +from datetime import datetime, timedelta, timezone + +from .gorgias import GorgiasAPI, _get + +logger = logging.getLogger(__name__) + +PRUNE_WINDOW = timedelta(minutes=30) +DELIVERED_TAG = 'shipbob-delivered' +# Secret used to verify the Standard Webhooks HMAC-SHA256 signature. When it is +# not configured we log and fall through without verifying (fail-open) so the +# flow still runs in environments where the signing secret isn't wired up. +WEBHOOK_SECRET_KEY = 'shipbob_webhook_secret' + + +def _secret_bytes(secret: str) -> bytes: + """Standard Webhooks secrets look like ``whsec_``; decode to the raw + signing key, falling back to the literal bytes if it isn't base64.""" + if secret.startswith('whsec_'): + secret = secret[len('whsec_') :] + try: + return base64.b64decode(secret) + except Exception: + return secret.encode() + + +def verify_signature(body: str, headers: dict, secret: str) -> bool: + """Verify a Standard Webhooks signature (headers ``webhook-id``, + ``webhook-timestamp``, ``webhook-signature``). Returns True when valid, or + when no secret is configured; False when a configured secret can't be matched. + """ + if not secret: + logger.warning('No ShipBob webhook secret configured — skipping signature verification.') + return True + + lower = {str(k).lower(): v for k, v in (headers or {}).items()} + msg_id = lower.get('webhook-id') + timestamp = lower.get('webhook-timestamp') + signature_header = lower.get('webhook-signature') + if not (msg_id and timestamp and signature_header): + logger.warning('Missing webhook signature headers; rejecting delivery.') + return False + + signed_content = f'{msg_id}.{timestamp}.{body}'.encode() + expected = base64.b64encode( + hmac.new(_secret_bytes(secret), signed_content, hashlib.sha256).digest() + ).decode() + + # The header is a space-separated list of "v1," tokens; match any. + for token in signature_header.split(): + _, _, candidate = token.partition(',') + if hmac.compare_digest(candidate, expected): + return True + logger.warning('Webhook signature did not match; rejecting delivery.') + return False + + +def _prune(processed: dict, now: datetime) -> dict: + """Drop entries whose timestamp is more than PRUNE_WINDOW old (or unparseable).""" + kept = {} + for shipment_id, ts in processed.items(): + try: + when = datetime.fromisoformat(str(ts).replace('Z', '+00:00')) + if when.tzinfo is None: + when = when.replace(tzinfo=timezone.utc) + except (TypeError, ValueError): + continue # unparseable → treat as expired + if now - when <= PRUNE_WINDOW: + kept[shipment_id] = ts + return kept + + +def build_ticket(event: dict, customer: dict | None) -> dict: + """Build the POST /tickets payload for a delivered shipment.""" + email = _get(event, 'recipient.email', '') + shipment_id = _get(event, 'shipment_id', '') + reference_id = _get(event, 'reference_id', '') or _get(event, 'order_id', '') + carrier = _get(event, 'tracking.carrier', '') + tracking_number = _get(event, 'tracking.tracking_number', '') + delivered_on = (_get(event, 'delivery_date', '') or '')[:10] # YYYY-MM-DD + + # Link to the existing Gorgias customer when we found one, else let Gorgias + # resolve/create by email. + customer_ref = {'id': customer['id']} if customer else {'email': email} + + message = { + 'sender': {'email': email}, + 'channel': 'api', + 'via': 'api', + 'from_agent': False, + 'subject': f'Order {reference_id} delivered', + 'body_text': ( + f'Shipment {shipment_id} for order {reference_id} was delivered on ' + f'{delivered_on} via {carrier} ({tracking_number}).' + ), + 'body_html': ( + f'

Shipment {shipment_id} for order {reference_id} was ' + f'delivered on {delivered_on} via {carrier}.

' + ), + # Included so Gorgias auto-reply / keyword rules can fire. + 'stripped_text': f'Shipment {shipment_id} for order {reference_id} was delivered.', + } + return { + 'customer': customer_ref, + 'channel': 'api', + 'via': 'api', + 'from_agent': False, + 'status': 'open', + 'messages': [message], + 'tags': [{'name': DELIVERED_TAG}], + } + + +def run(pandium) -> dict: + now = datetime.now(timezone.utc) + metadata = pandium.metadata or {} + processed = _prune(metadata.get('processed_shipments') or {}, now) + + secret = pandium.secrets.get(WEBHOOK_SECRET_KEY) + gorgias = GorgiasAPI(pandium) + now_iso = now.isoformat() + created = 0 + + for trigger in pandium.run_triggers: + if trigger.get('source') != 'webhook': + continue + + payload = trigger.get('payload') or {} + file = payload.get('file') + if not file: + logger.warning('Webhook trigger %s has no payload file; skipping.', trigger.get('id')) + continue + + try: + with open(file, encoding='utf-8') as f: + body = f.read() + except OSError as err: + logger.error('Could not read webhook payload %s: %s', file, err) + continue + + if not verify_signature(body, payload.get('headers'), secret): + continue # rejected delivery — do not mark processed + + try: + event = json.loads(body) + except json.JSONDecodeError as err: + logger.error('Webhook payload is not valid JSON (%s): %s', file, err) + continue + + if event.get('status') != 'Delivered': + logger.info('Ignoring webhook with status %r.', event.get('status')) + continue + + shipment_id = str(_get(event, 'shipment_id', '')) + if not shipment_id: + logger.warning('Delivered webhook has no shipment_id; skipping.') + continue + if shipment_id in processed: + logger.info('Shipment %s already handled; skipping duplicate.', shipment_id) + continue + + email = _get(event, 'recipient.email', '') + if not email: + logger.warning('Shipment %s has no recipient email; cannot open a ticket.', shipment_id) + continue + + try: + customer = gorgias.find_customer(email=email) + except Exception as err: + logger.warning('Could not look up customer %s (%s); creating ticket by email.', email, err) + customer = None + + try: + ticket = gorgias.create_ticket(build_ticket(event, customer)) + except Exception as err: + logger.error('Failed to open ticket for shipment %s: %s', shipment_id, err) + continue # leave unprocessed so ShipBob's retry can try again + + processed[shipment_id] = now_iso # mark handled + created += 1 + logger.info('Opened Gorgias ticket %s for shipment %s.', ticket.get('id'), shipment_id) + + logger.info('Flow B: opened %d ticket(s); tracking %d shipment(s).', created, len(processed)) + # Replaces the map (30-min pruned); shallow merge leaves Flow A's cursor keys intact. + return {'processed_shipments': processed} From 58c432416625dea7c9508912d238d0ac44eea127 Mon Sep 17 00:00:00 2001 From: sarahtrefethen <49411879+sarahtrefethen@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:10:25 -0400 Subject: [PATCH 4/4] tests --- SHIPBOB_TO_GORGIAS/Python/Pipfile | 1 + SHIPBOB_TO_GORGIAS/Python/Pipfile.lock | 35 ++++++- SHIPBOB_TO_GORGIAS/Python/pytest.ini | 5 + SHIPBOB_TO_GORGIAS/Python/tests/conftest.py | 6 ++ SHIPBOB_TO_GORGIAS/Python/tests/helpers.py | 94 +++++++++++++++++++ .../Python/tests/test_clients.py | 24 +++++ SHIPBOB_TO_GORGIAS/Python/tests/test_cron.py | 79 ++++++++++++++++ .../Python/tests/test_webhook.py | 54 +++++++++++ 8 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 SHIPBOB_TO_GORGIAS/Python/pytest.ini create mode 100644 SHIPBOB_TO_GORGIAS/Python/tests/conftest.py create mode 100644 SHIPBOB_TO_GORGIAS/Python/tests/helpers.py create mode 100644 SHIPBOB_TO_GORGIAS/Python/tests/test_clients.py create mode 100644 SHIPBOB_TO_GORGIAS/Python/tests/test_cron.py create mode 100644 SHIPBOB_TO_GORGIAS/Python/tests/test_webhook.py diff --git a/SHIPBOB_TO_GORGIAS/Python/Pipfile b/SHIPBOB_TO_GORGIAS/Python/Pipfile index 15c0ebf2..78b97dde 100644 --- a/SHIPBOB_TO_GORGIAS/Python/Pipfile +++ b/SHIPBOB_TO_GORGIAS/Python/Pipfile @@ -9,6 +9,7 @@ requests = "*" [dev-packages] ipdb = "*" +pytest = "*" [requires] python_version = "3.14" diff --git a/SHIPBOB_TO_GORGIAS/Python/Pipfile.lock b/SHIPBOB_TO_GORGIAS/Python/Pipfile.lock index d7425317..c1fe6110 100644 --- a/SHIPBOB_TO_GORGIAS/Python/Pipfile.lock +++ b/SHIPBOB_TO_GORGIAS/Python/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "85d14d957a7590c265e72271aaa93c98f86918fb93e2ce17b627c9a3fe48c15c" + "sha256": "70d4c40e98658d29a51811155fddc682c7d1f852038a3e1d564bcf3ca04d2efa" }, "pipfile-spec": 6, "requires": { @@ -191,6 +191,14 @@ "markers": "python_version >= '3.8'", "version": "==2.2.1" }, + "iniconfig": { + "hashes": [ + "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", + "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" + ], + "markers": "python_version >= '3.10'", + "version": "==2.3.0" + }, "ipdb": { "hashes": [ "sha256:45529994741c4ab6d2388bfa5d7b725c2cf7fe9deffabdb8a6113aa5ed449ed4", @@ -232,6 +240,14 @@ "markers": "python_version >= '3.9'", "version": "==0.2.2" }, + "packaging": { + "hashes": [ + "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", + "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661" + ], + "markers": "python_version >= '3.8'", + "version": "==26.2" + }, "parso": { "hashes": [ "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", @@ -248,6 +264,14 @@ "markers": "sys_platform != 'win32' and sys_platform != 'emscripten'", "version": "==4.9.0" }, + "pluggy": { + "hashes": [ + "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", + "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" + ], + "markers": "python_version >= '3.9'", + "version": "==1.6.0" + }, "prompt-toolkit": { "hashes": [ "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", @@ -305,6 +329,15 @@ "markers": "python_version >= '3.9'", "version": "==2.20.0" }, + "pytest": { + "hashes": [ + "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", + "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" + ], + "index": "pypi", + "markers": "python_version >= '3.10'", + "version": "==9.1.1" + }, "stack-data": { "hashes": [ "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", diff --git a/SHIPBOB_TO_GORGIAS/Python/pytest.ini b/SHIPBOB_TO_GORGIAS/Python/pytest.ini new file mode 100644 index 00000000..8ea4e6be --- /dev/null +++ b/SHIPBOB_TO_GORGIAS/Python/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +# Put the project root on sys.path so tests can `import sb2gorgias`. +pythonpath = . +testpaths = tests +addopts = -q diff --git a/SHIPBOB_TO_GORGIAS/Python/tests/conftest.py b/SHIPBOB_TO_GORGIAS/Python/tests/conftest.py new file mode 100644 index 00000000..f604b6d9 --- /dev/null +++ b/SHIPBOB_TO_GORGIAS/Python/tests/conftest.py @@ -0,0 +1,6 @@ +import sys +from pathlib import Path + +# Belt-and-suspenders (pytest.ini already sets pythonpath): make the project root +# importable so `import sb2gorgias` works however pytest is invoked. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) diff --git a/SHIPBOB_TO_GORGIAS/Python/tests/helpers.py b/SHIPBOB_TO_GORGIAS/Python/tests/helpers.py new file mode 100644 index 00000000..7a5a6190 --- /dev/null +++ b/SHIPBOB_TO_GORGIAS/Python/tests/helpers.py @@ -0,0 +1,94 @@ +"""Shared test doubles and factories — nothing here touches the network.""" + +import base64 +import hashlib +import hmac +import json + +from sb2gorgias.gorgias import GorgiasAPI +from sb2gorgias.lib import Pandium + +GORGIAS_SECRETS = { + 'gorgias_store_name': 'https://acme.gorgias.com', + 'gorgias_email': 'agent@acme.com', + 'gorgias_api_key': 'key123', +} +WEBHOOK_SECRET = 'whsec_' + base64.b64encode(b'test-signing-key').decode() + + +def make_pandium(config=None, secrets=None, run_triggers=None, metadata=None, + run_mode=None, tmp_path=None): + """Build a Pandium directly (no env). ``metadata`` is written to a temp file + so ``pandium.metadata`` reads it back like the real thing.""" + context = {} + if run_mode is not None: + context['run_mode'] = run_mode + if run_triggers is not None: + context['run_triggers'] = json.dumps(run_triggers) + if metadata is not None: + path = tmp_path / 'metadata.json' + path.write_text(json.dumps(metadata)) + context['tenant_metadata_file'] = str(path) + return Pandium(config=config or {}, secrets=secrets or {}, context=context) + + +def recording_gorgias(existing_emails=()): + """A real GorgiasAPI (so helper logic runs) with HTTP methods replaced by + in-memory recorders. ``existing_emails`` are pre-seeded as found customers; + inspect ``api.log`` in assertions.""" + api = GorgiasAPI(make_pandium(secrets=GORGIAS_SECRETS)) + store = {email: 40 + i for i, email in enumerate(existing_emails)} + log = {'create': [], 'update': [], 'ticket': []} + + def find_customer(email=None, external_id=None): + key = email or external_id + return {'id': store[key], 'data': {'pandium': {'shipbob_orders': []}}} if key in store else None + + def create_customer(payload): + cid = 1000 + len(store) + store[payload.get('external_id', cid)] = cid + log['create'].append(payload) + return cid + + def update_customer(cust_id, payload): + log['update'].append((cust_id, json.loads(json.dumps(payload)))) # snapshot + + def create_ticket(payload): + log['ticket'].append(payload) + return {'id': 900 + len(log['ticket'])} + + api.find_customer, api.create_customer = find_customer, create_customer + api.update_customer, api.create_ticket = update_customer, create_ticket + api.log = log + return api + + +def make_order(oid, created, email=None, last_update=None): + return { + 'id': oid, 'created_date': created, 'reference_id': f'REF-{oid}', + 'recipient': {'email': email, 'name': 'Buyer', + 'address': {'address1': '1 Main St', 'city': 'NY', 'country': 'US'}}, + 'shipments': [{'id': oid * 10, 'last_update_at': last_update or created}], + } + + +def make_delivered_event(shipment_id=456789, status='Delivered', email='jane@example.com'): + return { + 'order_id': 289012345, 'reference_id': 'MERCHANT-ORDER-1001', + 'shipment_id': shipment_id, 'status': status, + 'tracking': {'carrier': 'USPS', 'tracking_number': '9400100000000000000000'}, + 'delivery_date': '2026-07-09T18:22:00Z', + 'recipient': {'name': 'Jane Buyer', 'email': email}, + } + + +def webhook_trigger(tmp_path, event, tid, secret=WEBHOOK_SECRET, source='webhook'): + """Write an event to disk and wrap it in a signed Standard-Webhooks trigger.""" + body = json.dumps(event) + (tmp_path / f'{tid}.json').write_text(body) + ts = '1720000000' + digest = hmac.new(base64.b64decode(secret[len('whsec_'):]), + f'{tid}.{ts}.{body}'.encode(), hashlib.sha256).digest() + headers = {'webhook-id': tid, 'webhook-timestamp': ts, + 'webhook-signature': f'v1,{base64.b64encode(digest).decode()}'} + return {'id': tid, 'source': source, 'payload': {'file': str(tmp_path / f'{tid}.json'), 'headers': headers}} diff --git a/SHIPBOB_TO_GORGIAS/Python/tests/test_clients.py b/SHIPBOB_TO_GORGIAS/Python/tests/test_clients.py new file mode 100644 index 00000000..42db96ed --- /dev/null +++ b/SHIPBOB_TO_GORGIAS/Python/tests/test_clients.py @@ -0,0 +1,24 @@ +import base64 +import json + +from helpers import GORGIAS_SECRETS, make_order, make_pandium + +from sb2gorgias.gorgias import GorgiasAPI +from sb2gorgias.shipbob import DEFAULT_BASE_URL, _resolve_base_url + + +def _token(iss): + payload = base64.urlsafe_b64encode(json.dumps({'iss': iss}).encode()).rstrip(b'=').decode() + return f'header.{payload}.sig' + + +def test_shipbob_base_url_resolved_from_token_issuer(): + assert _resolve_base_url(_token('https://authstage.shipbob.com')) == 'https://sandbox-api.shipbob.com' + assert _resolve_base_url(_token('https://auth.shipbob.com')) == 'https://api.shipbob.com/1.0' + assert _resolve_base_url('not-a-jwt') == DEFAULT_BASE_URL # malformed -> prod + + +def test_gorgias_customer_key_is_email_or_synthetic(): + api = GorgiasAPI(make_pandium(secrets=GORGIAS_SECRETS)) + assert api.customer_key(make_order(1, 'x', email='jane@example.com')) == 'jane@example.com' + assert api.customer_key(make_order(1, 'x', email=None)) == 'Buyer 1 Main St NY US' diff --git a/SHIPBOB_TO_GORGIAS/Python/tests/test_cron.py b/SHIPBOB_TO_GORGIAS/Python/tests/test_cron.py new file mode 100644 index 00000000..d04d7b53 --- /dev/null +++ b/SHIPBOB_TO_GORGIAS/Python/tests/test_cron.py @@ -0,0 +1,79 @@ +import json +import signal +from datetime import datetime + +import pytest +from helpers import GORGIAS_SECRETS, make_order, make_pandium, recording_gorgias + +from sb2gorgias import flow_a + + +@pytest.fixture(autouse=True) +def _cancel_alarms(): + # flow_a.run arms a 9-min SIGALRM; don't let it leak into other tests. + yield + signal.alarm(0) + signal.signal(signal.SIGALRM, signal.SIG_DFL) + + +class FakeShipBob: + """Serves canned pages of new orders (no updated orders); records page numbers.""" + + def __init__(self, new_pages): + self.new_pages = new_pages + self.pages = [] + + def get_new_orders_page(self, cursor, page): + self.pages.append(page) + return self.new_pages[page - 1] if page <= len(self.new_pages) else [] + + def get_updated_orders_page(self, cursor, page): + return [] + + def get_update_date(self, order, cursor): + return order['shipments'][0]['last_update_at'] + + +def _patch(monkeypatch, shipbob, gorgias): + monkeypatch.setattr(flow_a, 'ShipBobAPI', lambda p: shipbob) + monkeypatch.setattr(flow_a, 'GorgiasAPI', lambda p: gorgias) + + +def test_clamp_bounds_cursor_between_one_month_ago_and_now(): + now = datetime(2026, 7, 16, 12, 0, 0) + assert flow_a.clamp('2026-07-10T00:00:00', now) == datetime(2026, 7, 10) # in range + assert flow_a.clamp('2099-01-01T00:00:00', now) == now # future -> now + assert flow_a.clamp(None, now) == now - flow_a.ONE_MONTH # missing -> floor + + +def test_run_pages_until_empty_upserts_customer_and_advances_cursor(monkeypatch): + pages = [[make_order(1, '2026-07-05T10:00:00.1234567+00:00', email='jane@example.com'), + make_order(2, '2026-07-06T10:00:00.1234567+00:00', email='jane@example.com')]] + shipbob, gorgias = FakeShipBob(pages), recording_gorgias() + _patch(monkeypatch, shipbob, gorgias) + + record = flow_a.run(make_pandium(secrets=GORGIAS_SECRETS, config={'order_start_date': '2026-07-01'})) + + assert shipbob.pages == [1, 2] # paged until the empty page + assert len(gorgias.log['create']) == 1 # both orders batch onto one customer + assert record['new_order_start_date'] == '2026-07-06T10:00:00.123456' # advanced to last order + final_orders = gorgias.log['update'][-1][1]['data']['pandium']['shipbob_orders'] + assert sorted(o['id'] for o in final_orders) == [1, 2] + + +def test_run_flushes_partial_cursor_and_exits_zero_on_timeout(monkeypatch, capsys): + class Alarming(FakeShipBob): + def get_new_orders_page(self, cursor, page): + if page == 1: + return [make_order(1, '2026-07-05T10:00:00.1234567+00:00', email='j@x.com')] + signal.raise_signal(signal.SIGALRM) # trip the alarm before page 2 is processed + return [make_order(2, '2026-07-06T10:00:00.1234567+00:00', email='j@x.com')] + + _patch(monkeypatch, Alarming([]), recording_gorgias()) + + with pytest.raises(SystemExit) as exc: + flow_a.run(make_pandium(secrets=GORGIAS_SECRETS, config={'order_start_date': '2026-07-01'})) + + assert exc.value.code == 0 # timed-out run still succeeds so progress is merged + flushed = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert flushed['new_order_start_date'] == '2026-07-05T10:00:00.123456' # only order 1 processed diff --git a/SHIPBOB_TO_GORGIAS/Python/tests/test_webhook.py b/SHIPBOB_TO_GORGIAS/Python/tests/test_webhook.py new file mode 100644 index 00000000..acde24cf --- /dev/null +++ b/SHIPBOB_TO_GORGIAS/Python/tests/test_webhook.py @@ -0,0 +1,54 @@ +import base64 +from datetime import datetime, timedelta, timezone + +from helpers import (GORGIAS_SECRETS, WEBHOOK_SECRET, make_delivered_event, + make_pandium, recording_gorgias, webhook_trigger) + +from sb2gorgias import flow_b + + +def _run(monkeypatch, tmp_path, triggers, metadata=None, existing=('jane@example.com',)): + gorgias = recording_gorgias(existing_emails=existing) + monkeypatch.setattr(flow_b, 'GorgiasAPI', lambda p: gorgias) + secrets = dict(GORGIAS_SECRETS, shipbob_webhook_secret=WEBHOOK_SECRET) + pandium = make_pandium(secrets=secrets, run_triggers=triggers, metadata=metadata, + tmp_path=tmp_path, run_mode='webhook') + return flow_b.run(pandium), gorgias + + +def test_run_opens_ticket_and_writes_only_processed_shipments(monkeypatch, tmp_path): + triggers = [webhook_trigger(tmp_path, make_delivered_event(456789), 't1')] + result, gorgias = _run(monkeypatch, tmp_path, triggers) + + ticket = gorgias.log['ticket'][0] + assert ticket['customer'] == {'id': 40} # linked to the found customer + assert ticket['tags'] == [{'name': 'shipbob-delivered'}] + assert 'delivered on 2026-07-09 via USPS' in ticket['messages'][0]['body_text'] + assert set(result) == {'processed_shipments'} # leaves Flow A's cursor keys alone + assert '456789' in result['processed_shipments'] + + +def test_run_dedupes_within_batch_and_prunes_stale_entries(monkeypatch, tmp_path): + now = datetime.now(timezone.utc) + triggers = [webhook_trigger(tmp_path, make_delivered_event(456789), 't1'), + webhook_trigger(tmp_path, make_delivered_event(456789), 't2')] # duplicate + metadata = {'processed_shipments': { + '456790': now.isoformat(), # recent -> kept + '999999': (now - timedelta(minutes=45)).isoformat(), # >30 min -> pruned + }} + result, gorgias = _run(monkeypatch, tmp_path, triggers, metadata=metadata) + + assert len(gorgias.log['ticket']) == 1 # one ticket despite the duplicate delivery + assert set(result['processed_shipments']) == {'456789', '456790'} + + +def test_run_skips_non_delivered_and_bad_signature(monkeypatch, tmp_path): + attacker = 'whsec_' + base64.b64encode(b'attacker').decode() + triggers = [ + webhook_trigger(tmp_path, make_delivered_event(1, status='In Transit'), 't1'), + webhook_trigger(tmp_path, make_delivered_event(2), 't2', secret=attacker), # bad signature + ] + result, gorgias = _run(monkeypatch, tmp_path, triggers) + + assert gorgias.log['ticket'] == [] + assert result['processed_shipments'] == {}