shipstores 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
shipstores/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """MCP server for publishing apps to the App Store and Google Play."""
2
+
3
+ __version__ = "0.1.0"
shipstores/apple.py ADDED
@@ -0,0 +1,222 @@
1
+ """App Store Connect API client (JWT ES256)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import subprocess
8
+ import time
9
+ from typing import Callable, TypeVar
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import httpx
14
+ import jwt
15
+
16
+ from .config import AppleConfig, load_apple
17
+
18
+ BASE_URL = "https://api.appstoreconnect.apple.com"
19
+ TOKEN_TTL_SECONDS = 900 # Apple's maximum lifetime for API tokens
20
+
21
+
22
+ class AppleError(RuntimeError):
23
+ pass
24
+
25
+ T = TypeVar("T")
26
+
27
+ RETRY_TENTATIVAS = 5
28
+
29
+
30
+ def with_retry(fn: Callable[[], T], what: str, tentativas: int = RETRY_TENTATIVAS) -> T:
31
+ """Retry a network call with exponential backoff.
32
+
33
+ Screenshot uploads send ~1 MB files to Apple's CDN, and a connection dropped
34
+ mid-transfer is common. Without this, a single network failure leaves the
35
+ screenshot set half-uploaded.
36
+ """
37
+ for n in range(1, tentativas + 1):
38
+ try:
39
+ return fn()
40
+ except (httpx.TransportError, httpx.HTTPStatusError) as exc:
41
+ if n == tentativas:
42
+ raise AppleError(f"{what} failed after {tentativas} attempts: {exc}") from exc
43
+ time.sleep(2**n)
44
+ raise AppleError(f"{what}: unreachable")
45
+
46
+
47
+ def _token(cfg: AppleConfig) -> str:
48
+ now = int(time.time())
49
+ return jwt.encode(
50
+ {
51
+ "iss": cfg.issuer_id,
52
+ "iat": now,
53
+ "exp": now + TOKEN_TTL_SECONDS,
54
+ "aud": "appstoreconnect-v1",
55
+ },
56
+ cfg.private_key,
57
+ algorithm="ES256",
58
+ headers={"kid": cfg.key_id, "typ": "JWT"},
59
+ )
60
+
61
+
62
+ def request(
63
+ method: str,
64
+ path: str,
65
+ *,
66
+ params: dict[str, Any] | None = None,
67
+ json: dict[str, Any] | None = None,
68
+ cfg: AppleConfig | None = None,
69
+ ) -> dict[str, Any]:
70
+ cfg = cfg or load_apple()
71
+
72
+ def chamar() -> httpx.Response:
73
+ resp = httpx.request(
74
+ method,
75
+ f"{BASE_URL}{path}",
76
+ headers={"Authorization": f"Bearer {_token(cfg)}"},
77
+ params=params,
78
+ json=json,
79
+ timeout=120,
80
+ )
81
+ # 5xx means instability on Apple's side: worth retrying. 4xx is a usage error.
82
+ if resp.status_code >= 500:
83
+ resp.raise_for_status()
84
+ return resp
85
+
86
+ resp = with_retry(chamar, f"ASC {method} {path}")
87
+ if resp.status_code >= 400:
88
+ detail = resp.text[:800]
89
+ raise AppleError(f"ASC {method} {path} -> HTTP {resp.status_code}: {detail}")
90
+ return resp.json() if resp.content else {}
91
+
92
+
93
+ def upload_asset(
94
+ resource: str,
95
+ file_path: Path,
96
+ relationships: dict[str, Any],
97
+ *,
98
+ cfg: AppleConfig | None = None,
99
+ ) -> str:
100
+ """Upload a file to App Store Connect and return the id of the created resource.
101
+
102
+ App Store Connect uploads happen in three steps: reserve the asset, send the
103
+ bytes to the URLs it returns, and commit with the checksum. Works for any
104
+ resource that follows this contract (version screenshots, subscription
105
+ review screenshots). Every step is retried — ~1 MB files sent to Apple's
106
+ CDN fail often.
107
+ """
108
+ cfg = cfg or load_apple()
109
+ conteudo = file_path.read_bytes()
110
+ nome = file_path.name
111
+
112
+ def reservar() -> dict[str, Any]:
113
+ return request(
114
+ "POST",
115
+ f"/v1/{resource}",
116
+ json={
117
+ "data": {
118
+ "type": resource,
119
+ "attributes": {"fileName": nome, "fileSize": len(conteudo)},
120
+ "relationships": relationships,
121
+ }
122
+ },
123
+ cfg=cfg,
124
+ )
125
+
126
+ reserva = reservar()
127
+ asset_id = reserva["data"]["id"]
128
+
129
+ for operacao in reserva["data"]["attributes"]["uploadOperations"]:
130
+ trecho = conteudo[operacao["offset"] : operacao["offset"] + operacao["length"]]
131
+
132
+ def enviar(op: dict[str, Any] = operacao, bloco: bytes = trecho) -> None:
133
+ resposta = httpx.request(
134
+ op["method"],
135
+ op["url"],
136
+ headers={h["name"]: h["value"] for h in op.get("requestHeaders", [])},
137
+ content=bloco,
138
+ timeout=600,
139
+ )
140
+ resposta.raise_for_status()
141
+
142
+ with_retry(enviar, f"upload {nome}")
143
+
144
+ def confirmar() -> None:
145
+ try:
146
+ request(
147
+ "PATCH",
148
+ f"/v1/{resource}/{asset_id}",
149
+ json={
150
+ "data": {
151
+ "type": resource,
152
+ "id": asset_id,
153
+ "attributes": {
154
+ "uploaded": True,
155
+ "sourceFileChecksum": hashlib.md5(conteudo).hexdigest(),
156
+ },
157
+ }
158
+ },
159
+ cfg=cfg,
160
+ )
161
+ except AppleError as exc:
162
+ # This PATCH is not idempotent: if the response was lost and the retry
163
+ # resent it, ASC answers 409 ("can't be re-committed"). When the
164
+ # asset is already COMPLETE, the upload succeeded — not an error.
165
+ if "HTTP 409" not in str(exc):
166
+ raise
167
+ estado = request("GET", f"/v1/{resource}/{asset_id}", cfg=cfg)
168
+ entrega = estado["data"]["attributes"].get("assetDeliveryState") or {}
169
+ if entrega.get("state") != "COMPLETE":
170
+ raise
171
+
172
+ confirmar()
173
+ return asset_id
174
+
175
+
176
+ def set_order(resource: str, set_id: str, child: str, ids: list[str]) -> None:
177
+ """Pin the display order of a set (screenshots, previews).
178
+
179
+ Without this the order falls back to creation order, which Apple does not guarantee.
180
+ """
181
+ cfg = load_apple()
182
+
183
+ def chamar() -> httpx.Response:
184
+ resp = httpx.patch(
185
+ f"{BASE_URL}/v1/{resource}/{set_id}/relationships/{child}",
186
+ headers={"Authorization": f"Bearer {_token(cfg)}"},
187
+ json={"data": [{"type": child, "id": i} for i in ids]},
188
+ timeout=120,
189
+ )
190
+ if resp.status_code >= 500:
191
+ resp.raise_for_status()
192
+ return resp
193
+
194
+ resp = with_retry(chamar, f"reorder {resource}")
195
+ if resp.status_code >= 400:
196
+ raise AppleError(f"reordering failed: HTTP {resp.status_code} {resp.text[:300]}")
197
+
198
+
199
+ def upload_binary(ipa_path: Path, platform: str, cfg: AppleConfig) -> str:
200
+ """Upload an .ipa via `xcrun altool`. Returns the command's combined output.
201
+
202
+ altool does not accept a key path as an argument, but it honors
203
+ API_PRIVATE_KEYS_DIR: point it straight at the .p8's folder, no copying or linking.
204
+ """
205
+ env = {**os.environ, "API_PRIVATE_KEYS_DIR": str(cfg.private_key_path.parent)}
206
+ result = subprocess.run(
207
+ [
208
+ "xcrun", "altool", "--upload-app",
209
+ "-f", str(ipa_path),
210
+ "-t", platform,
211
+ "--apiKey", cfg.key_id,
212
+ "--apiIssuer", cfg.issuer_id,
213
+ ],
214
+ capture_output=True,
215
+ text=True,
216
+ timeout=3600,
217
+ env=env,
218
+ )
219
+ output = f"{result.stdout}\n{result.stderr}".strip()
220
+ if result.returncode != 0:
221
+ raise AppleError(f"altool failed (exit {result.returncode}):\n{output}")
222
+ return output
@@ -0,0 +1,132 @@
1
+ """App settings that only exist in the console's internal API (/iris).
2
+
3
+ Territory availability and "App Privacy" have no usable public endpoint: setting
4
+ availability through the public API answers 409 even with every territory in the
5
+ payload, and privacy does not exist there at all. The web console uses /iris with
6
+ the cookie session, so these operations run as fetch calls inside an App Store
7
+ Connect page in the dedicated Chrome.
8
+
9
+ Rules Apple does not document (validated against a production app):
10
+ - appAvailabilities requires ALL territories in `included`, each with `available`.
11
+ - appDataUsages: category, purpose and protection go in the SAME record. Separate
12
+ records (purpose only, protection only) are accepted one by one, but publishing
13
+ fails with "An app data usage is missing a category/purpose or data protection type".
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+
20
+ from . import browser
21
+
22
+ ASC = "https://appstoreconnect.apple.com"
23
+
24
+ DATA_CATEGORIES = (
25
+ "PAYMENT_INFORMATION", "CREDIT_AND_FRAUD", "OTHER_FINANCIAL_INFO", "PRECISE_LOCATION",
26
+ "COARSE_LOCATION", "SENSITIVE_INFO", "PHYSICAL_ADDRESS", "EMAIL_ADDRESS", "NAME",
27
+ "PHONE_NUMBER", "OTHER_CONTACT_INFO", "CONTACTS", "EMAILS_OR_TEXT_MESSAGES",
28
+ "PHOTOS_OR_VIDEOS", "AUDIO", "GAMEPLAY_CONTENT", "CUSTOMER_SUPPORT", "OTHER_USER_CONTENT",
29
+ "BROWSING_HISTORY", "SEARCH_HISTORY", "USER_ID", "DEVICE_ID", "PURCHASE_HISTORY",
30
+ "PRODUCT_INTERACTION", "ADVERTISING_DATA", "OTHER_USAGE_DATA", "CRASH_DATA",
31
+ "PERFORMANCE_DATA", "OTHER_DIAGNOSTIC_DATA", "OTHER_DATA", "HEALTH", "FITNESS",
32
+ "ENVIRONMENTAL_SCANNING", "HANDS", "HEAD_MOVEMENT",
33
+ )
34
+ DATA_PURPOSES = (
35
+ "THIRD_PARTY_ADVERTISING", "DEVELOPERS_ADVERTISING", "ANALYTICS",
36
+ "PRODUCT_PERSONALIZATION", "APP_FUNCTIONALITY", "OTHER_PURPOSES",
37
+ )
38
+
39
+
40
+ def _run_iris(app_id: str, body_js: str, timeout: int = 300) -> dict:
41
+ """Run `body_js` (the body of an async function that returns an object) in a console page."""
42
+ script = f"""
43
+ import time
44
+ new_tab({f"{ASC}/apps/{app_id}/distribution"!r})
45
+ wait_for_load()
46
+ time.sleep(3)
47
+ js(r\"\"\"window.__sp='running';(async()=>{{
48
+ const H={{'Content-Type':'application/json'}};
49
+ const req=async (method,path,body)=>{{const r=await fetch(path,{{method,credentials:'include',headers:H,body:body?JSON.stringify(body):undefined}});
50
+ const t=await r.text(); return {{status:r.status, json:(()=>{{try{{return JSON.parse(t)}}catch(e){{return null}}}})(), text:t.slice(0,600)}}}};
51
+ const all=async (path)=>{{let out=[],p=path; while(p){{const r=await req('GET',p); if(r.status!==200) throw new Error(path+' -> '+r.status+' '+r.text);
52
+ out=out.concat(r.json.data); p=r.json.links&&r.json.links.next? r.json.links.next.replace(/^https:\\/\\/[^/]+/,''): null;}} return out}};
53
+ const result=await (async()=>{{ {body_js} }})();
54
+ window.__sp=JSON.stringify(result);
55
+ }})().catch(e=>window.__sp=JSON.stringify({{error:String(e)}})); 'ok'\"\"\")
56
+ for _ in range({timeout // 2}):
57
+ v = js("String(window.__sp)")
58
+ if v != 'running': break
59
+ time.sleep(2)
60
+ print('__SP__' + v)
61
+ """
62
+ out = browser.run_harness(script, timeout=timeout + 60)
63
+ payload = out.split("__SP__", 1)[-1].strip()
64
+ try:
65
+ result = json.loads(payload)
66
+ except json.JSONDecodeError as exc:
67
+ raise browser.BrowserError(f"Unexpected response from the console: {payload[:300]}") from exc
68
+ if isinstance(result, dict) and result.get("error"):
69
+ raise browser.BrowserError(result["error"])
70
+ return result
71
+
72
+
73
+ def set_availability(app_id: str, territories: list[str], available_in_new: bool) -> dict:
74
+ """Make the app available only in the given territories (3-letter ISO codes)."""
75
+ wanted = json.dumps(sorted(set(t.upper() for t in territories)))
76
+ body = f"""
77
+ const want={wanted};
78
+ const terr=(await all('/iris/v1/territories?limit=200')).map(t=>t.id);
79
+ const missing=want.filter(t=>!terr.includes(t)); if(missing.length) throw new Error('unknown territory: '+missing.join(','));
80
+ const rel=terr.map(t=>({{type:'territoryAvailabilities',id:'${{'+t+'}}'}}));
81
+ const inc=terr.map(t=>({{type:'territoryAvailabilities',id:'${{'+t+'}}',attributes:{{available:want.includes(t)}},relationships:{{territory:{{data:{{type:'territories',id:t}}}}}}}}));
82
+ const r=await req('POST','/iris/v2/appAvailabilities',{{data:{{type:'appAvailabilities',attributes:{{availableInNewTerritories:{json.dumps(available_in_new)}}},
83
+ relationships:{{app:{{data:{{type:'apps',id:'{app_id}'}}}},territoryAvailabilities:{{data:rel}}}}}},included:inc}});
84
+ if(r.status>=300) throw new Error('appAvailabilities -> '+r.status+' '+r.text);
85
+ const now=(await all('/iris/v2/appAvailabilities/{app_id}/territoryAvailabilities?limit=200&include=territory'))
86
+ .filter(d=>d.attributes.available).map(d=>(d.relationships.territory.data||{{}}).id);
87
+ return {{total_territories:terr.length, available:now}};
88
+ """
89
+ return _run_iris(app_id, body)
90
+
91
+
92
+ def set_privacy(app_id: str, usages: list[dict]) -> dict:
93
+ """Replace the whole privacy declaration and publish it.
94
+
95
+ usages: [{"category": "NAME", "purposes": ["APP_FUNCTIONALITY"], "linked": true,
96
+ "tracking": false}, ...]. An empty list declares "we do not collect data".
97
+ """
98
+ records = []
99
+ for u in usages:
100
+ cat = u["category"].upper()
101
+ if cat not in DATA_CATEGORIES:
102
+ raise ValueError(f"Invalid category: {cat}. Options: {', '.join(DATA_CATEGORIES)}")
103
+ purposes = [p.upper() for p in u.get("purposes") or []]
104
+ bad = [p for p in purposes if p not in DATA_PURPOSES]
105
+ if not purposes or bad:
106
+ raise ValueError(f"{cat}: invalid purposes {bad or '(none)'}. Options: {', '.join(DATA_PURPOSES)}")
107
+ protections = ["DATA_LINKED_TO_YOU" if u.get("linked", True) else "DATA_NOT_LINKED_TO_YOU"]
108
+ if u.get("tracking"):
109
+ protections.append("DATA_USED_TO_TRACK_YOU")
110
+ for purpose in purposes:
111
+ for protection in protections:
112
+ records.append({"category": cat, "purpose": purpose, "protection": protection})
113
+ plan = json.dumps(records)
114
+ body = f"""
115
+ const plan={plan};
116
+ const old=await all('/iris/v1/apps/{app_id}/dataUsages?limit=200');
117
+ for (const d of old) {{ const r=await req('DELETE','/iris/v1/appDataUsages/'+d.id); if(r.status>=300) throw new Error('DELETE '+d.id+' -> '+r.status); }}
118
+ const created=[];
119
+ const recs = plan.length? plan : [{{protection:'DATA_NOT_COLLECTED'}}];
120
+ for (const x of recs) {{
121
+ const rel={{app:{{data:{{type:'apps',id:'{app_id}'}}}},dataProtection:{{data:{{type:'appDataUsageDataProtections',id:x.protection}}}}}};
122
+ if (x.category) rel.category={{data:{{type:'appDataUsageCategories',id:x.category}}}};
123
+ if (x.purpose) rel.purpose={{data:{{type:'appDataUsagePurposes',id:x.purpose}}}};
124
+ const r=await req('POST','/iris/v1/appDataUsages',{{data:{{type:'appDataUsages',relationships:rel}}}});
125
+ if (r.status!==201) throw new Error('appDataUsages '+(x.category||'')+' -> '+r.status+' '+r.text);
126
+ created.push((x.category||'NOT_COLLECTED')+':'+(x.purpose||'')+':'+x.protection);
127
+ }}
128
+ const p=await req('PATCH','/iris/v1/appDataUsagesPublishState/{app_id}',{{data:{{type:'appDataUsagesPublishState',id:'{app_id}',attributes:{{published:true}}}}}});
129
+ if (p.status>=300) throw new Error('publish -> '+p.status+' '+p.text);
130
+ return {{removed:old.length, created:created, published:true}};
131
+ """
132
+ return _run_iris(app_id, body)
@@ -0,0 +1,149 @@
1
+ """App Store Resolution Center: read the rejection and reply to App Review.
2
+
3
+ Apple's public API exposes neither the review messages nor replies to them. The
4
+ web console uses the internal /iris API, which accepts the dedicated Chrome's
5
+ cookie session. Reading is done with fetch inside the page; replying goes through
6
+ the console's own "Reply to App Review" box, because the attachment goes through
7
+ an upload that only the UI knows how to assemble.
8
+
9
+ Details learned by trial and error:
10
+ - The harness js() gives up after 5s; the fetch calls run in an IIFE that writes
11
+ to window, and the script polls for the result.
12
+ - The reply textarea only enables the "Reply" button after real typing: setting
13
+ .value is not enough, so the script types and deletes a space via CDP.
14
+ - Attachments go through DOM.setFileInputFiles and show "Processing..." before
15
+ appearing in the list; sending before that drops the file.
16
+ - iPhone video is recorded in HEVC and weighs ~100 MB; converted to H.264 at
17
+ 1920px it is ~8 MB and opens in any player.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import shutil
24
+ import subprocess
25
+ import tempfile
26
+ from pathlib import Path
27
+
28
+ from . import browser
29
+
30
+ ASC = "https://appstoreconnect.apple.com"
31
+
32
+ # Console UI labels, matched literally against the page text (pt-BR and en-US)
33
+ REPLY_OPEN = ("Responda à equipe de revisão de apps", "Reply to App Review")
34
+ REPLY_SEND = ("Responder", "Reply")
35
+ PROCESSING = ("Processando", "Processing")
36
+
37
+
38
+ def read_messages(app_id: str) -> list[dict]:
39
+ """The app's Resolution Center messages, oldest first."""
40
+ script = f"""
41
+ import time, json
42
+ new_tab({f"{ASC}/apps/{app_id}/distribution"!r})
43
+ wait_for_load()
44
+ time.sleep(3)
45
+ js(r\"\"\"window.__sp='running';(async()=>{{
46
+ const g=async p=>{{const r=await fetch(p,{{credentials:'include'}}); return r.ok? r.json(): null}};
47
+ const th=await g('/iris/v1/apps/{app_id}/resolutionCenterThreads');
48
+ const out=[];
49
+ for (const t of ((th&&th.data)||[])) {{
50
+ const m=await g('/iris/v1/resolutionCenterThreads/'+t.id+'/resolutionCenterMessages?include=rejections&limit=50');
51
+ const rej=((m&&m.included)||[]).filter(i=>i.type&&i.type.indexOf('ejection')>=0).map(i=>i.attributes.reasons||[]).flat();
52
+ for (const x of ((m&&m.data)||[])) out.push({{thread:t.id, date:x.attributes.createdDate, sender:x.attributes.fromActor||null,
53
+ text:(x.attributes.messageBody||'').replace(/<br\\s*\\/?>/g,' ').replace(/<[^>]+>/g,'').trim(), reasons:rej}});
54
+ }}
55
+ window.__sp=JSON.stringify(out.sort((a,b)=>a.date<b.date?-1:1));
56
+ }})().catch(e=>window.__sp='ERROR '+e); 'ok'\"\"\")
57
+ for _ in range(40):
58
+ v = js("String(window.__sp)")
59
+ if v != 'running': break
60
+ time.sleep(1.5)
61
+ print('__SP__' + v)
62
+ """
63
+ out = browser.run_harness(script, timeout=180)
64
+ payload = out.split("__SP__", 1)[-1].strip()
65
+ if payload.startswith("ERROR") or not payload.startswith("["):
66
+ raise browser.BrowserError(f"Could not read the Resolution Center: {payload[:300]}")
67
+ return json.loads(payload)
68
+
69
+
70
+ def prepare_attachment(path: Path) -> Path:
71
+ """Convert HEVC video to H.264 at 1920px (smaller and universal); anything else passes through unchanged."""
72
+ if path.suffix.lower() not in (".mp4", ".mov", ".m4v"):
73
+ return path
74
+ if not (shutil.which("ffprobe") and shutil.which("ffmpeg")):
75
+ return path
76
+ codec = subprocess.run(
77
+ ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name",
78
+ "-of", "csv=p=0", str(path)],
79
+ capture_output=True, text=True,
80
+ ).stdout.strip()
81
+ if codec != "hevc":
82
+ return path
83
+ out = Path(tempfile.mkdtemp(prefix="sp-review-")) / f"{path.stem}.mp4"
84
+ subprocess.run(
85
+ ["ffmpeg", "-v", "error", "-y", "-i", str(path), "-vf", "scale=-2:1920", "-c:v", "libx264",
86
+ "-preset", "medium", "-crf", "26", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "96k",
87
+ "-movflags", "+faststart", str(out)],
88
+ check=True,
89
+ )
90
+ return out
91
+
92
+
93
+ def reply(app_id: str, submission_id: str, text: str, attachments: list[Path]) -> str:
94
+ """Reply to App Review through the console box and verify the message landed in the thread."""
95
+ script = f"""
96
+ import time, json
97
+ TEXT = {json.dumps(text)}
98
+ FILES = {json.dumps([str(p) for p in attachments])}
99
+ OPEN = {json.dumps(list(REPLY_OPEN))}
100
+ SEND = {json.dumps(list(REPLY_SEND))}
101
+ BUSY = {json.dumps(list(PROCESSING))}
102
+ new_tab({f"{ASC}/apps/{app_id}/distribution/reviewsubmissions/details/{submission_id}"!r})
103
+ wait_for_load()
104
+ for _ in range(15):
105
+ time.sleep(2)
106
+ if js("[...document.querySelectorAll('button')].some(b=>%s.includes(b.innerText.trim()))" % json.dumps(OPEN)): break
107
+ else:
108
+ raise SystemExit('ERROR: the reply button did not appear (does the submission have an open issue?)')
109
+ js("[...document.querySelectorAll('button')].find(b=>%s.includes(b.innerText.trim())).click()" % json.dumps(OPEN))
110
+ time.sleep(3)
111
+ ok = js(\"\"\"(()=>{{const t=document.querySelector('[role=dialog] textarea'); if(!t) return false;
112
+ Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(t, %s);
113
+ t.dispatchEvent(new Event('input',{{bubbles:true}})); t.focus(); t.setSelectionRange(t.value.length,t.value.length); return true}})()\"\"\" % json.dumps(TEXT))
114
+ if not ok:
115
+ raise SystemExit('ERROR: the reply box did not open (the console may not have rendered in headless mode; try SHIPSTORES_HEADLESS=0)')
116
+ cdp("Input.insertText", text=" ")
117
+ cdp("Input.dispatchKeyEvent", type="keyDown", key="Backspace", code="Backspace", windowsVirtualKeyCode=8)
118
+ cdp("Input.dispatchKeyEvent", type="keyUp", key="Backspace", code="Backspace", windowsVirtualKeyCode=8)
119
+ for f in FILES:
120
+ doc = cdp("DOM.getDocument", depth=-1, pierce=True)
121
+ ids = cdp("DOM.querySelectorAll", nodeId=doc["root"]["nodeId"], selector="[role=dialog] input[type=file]")["nodeIds"]
122
+ if not ids:
123
+ raise SystemExit('ERROR: attachment input not found')
124
+ cdp("DOM.setFileInputFiles", nodeId=ids[-1], files=[f])
125
+ name = f.rsplit('/', 1)[-1]
126
+ for _ in range(90):
127
+ time.sleep(2)
128
+ d = js("(document.querySelector('[role=dialog]')||{{}}).innerText||''")
129
+ if name in d and not any(b in d for b in BUSY): break
130
+ else:
131
+ raise SystemExit('ERROR: attachment did not finish processing: ' + name)
132
+ # "Mensagens"/"Messages" is console UI text, matched literally (pt-BR / en-US)
133
+ COUNT = r"(()=>{{const m=document.body.innerText.match(/(?:Mensagens|Messages) \\((\\d+)\\)/); return m? Number(m[1]): -1}})()"
134
+ before = js(COUNT)
135
+ sent = js("(()=>{{const b=[...document.querySelectorAll('[role=dialog] button')].filter(b=>%s.includes(b.innerText.trim())).pop(); if(!b) return 'no button'; if(b.disabled) return 'disabled'; b.click(); return 'sent'}})()" % json.dumps(SEND))
136
+ if sent != 'sent':
137
+ raise SystemExit('ERROR: not sent (' + sent + ')')
138
+ for _ in range(15):
139
+ time.sleep(2)
140
+ if js(COUNT) > before: break
141
+ else:
142
+ raise SystemExit('ERROR: the message did not appear in the thread after sending')
143
+ print('__SP__ok')
144
+ """
145
+ out = browser.run_harness(script, timeout=600)
146
+ if "__SP__ok" not in out:
147
+ erro = next((l for l in out.splitlines() if l.startswith("ERROR")), out[-500:])
148
+ raise browser.BrowserError(erro)
149
+ return "sent"