@acedatacloud/skills 2026.703.16 → 2026.704.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acedatacloud/skills",
3
- "version": "2026.703.16",
3
+ "version": "2026.704.1",
4
4
  "description": "Agent Skills for AceDataCloud AI services — music, image, video generation, LLM chat, web search. Compatible with Claude Code, GitHub Copilot, Gemini CLI, OpenAI Codex, and 30+ AI coding agents.",
5
5
  "keywords": [
6
6
  "agent-skills",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bluesky
3
- description: Publish, delete and read your own posts on Bluesky via the AT Protocol (XRPC). Use when the user wants to post to their Bluesky account, cross-post an article as a short dev-focused post, delete a post, or list their own recent posts with engagement stats (reposts, likes, replies). Auth uses the user's handle plus an App Password.
3
+ description: Publish (with optional images), delete and read your own posts on Bluesky via the AT Protocol (XRPC). Use when the user wants to post to their Bluesky account (text or 带图 / with a picture), cross-post an article as a short dev-focused post, attach an image, delete a post, or list their own recent posts with engagement stats (reposts, likes, replies). Auth uses the user's handle plus an App Password.
4
4
  when_to_use: |
5
5
  Trigger when the user wants to publish a post to their Bluesky account,
6
6
  delete one, or review their own recent posts and engagement. Bluesky runs on
@@ -12,152 +12,103 @@ allowed_tools: [Bash]
12
12
  license: Apache-2.0
13
13
  metadata:
14
14
  author: acedatacloud
15
- version: "1.0"
15
+ version: "1.1"
16
16
  ---
17
17
 
18
- Call the **Bluesky AT Protocol** XRPC endpoints with `curl + jq`. Three
19
- connector credentials are injected: `$BLUESKY_HANDLE` (e.g. `name.bsky.social`),
20
- `$BLUESKY_APP_PASSWORD` (an App Password created in Bluesky **Settings
21
- Privacy and Security App Passwords**, NOT the account login password) and
22
- `$BLUESKY_SERVICE` (the PDS base URL, default `https://bsky.social`).
23
-
24
- Errors come back as JSON `{"error":"<name>","message":"<detail>"}` show it
25
- verbatim. A `401 {"error":"AuthenticationRequired","message":"Invalid identifier
26
- or password"}` on session creation means the identifier or App Password is
27
- wrong/revoked. The **#1 cause is a bare handle**: the identifier must be the
28
- FULL handle (`alice.bsky.social`), a DID, or the account email — a bare
29
- `alice` will NOT resolve. Step 1 auto-appends `.bsky.social` to a bare handle
30
- on the default PDS; if it still fails, the user must re-connect the Bluesky
31
- connector with the full handle (or generate a fresh App Password).
32
-
33
- ## Step 1 — always create a session first
34
-
35
- Everything needs a short-lived `accessJwt` and your account `did`. Do this once
36
- per task and reuse the values. The identifier must be a full handle, DID or
37
- email — normalize a bare username (no `.` / `@`) to `<name>.bsky.social` when
38
- using the default PDS, otherwise `createSession` returns `AuthenticationRequired`:
39
-
40
- ```bash
41
- SVC="${BLUESKY_SERVICE:-https://bsky.social}"
42
- ID="$BLUESKY_HANDLE"
43
- # Bare username → full handle on the default bsky.social PDS. A dotted handle
44
- # (custom domain), a DID (`did:...`) or an email are already valid — leave them.
45
- case "$ID" in
46
- *.* | *@* | did:*) : ;;
47
- *) [ "$SVC" = "https://bsky.social" ] && ID="$ID.bsky.social" ;;
48
- esac
49
- SESSION=$(curl -sS -X POST "$SVC/xrpc/com.atproto.server.createSession" \
50
- -H "Content-Type: application/json" \
51
- -d "$(jq -n --arg id "$ID" --arg pw "$BLUESKY_APP_PASSWORD" \
52
- '{identifier:$id, password:$pw}')")
53
- echo "$SESSION" | jq '{did, handle, active}'
54
- JWT=$(echo "$SESSION" | jq -r .accessJwt)
55
- DID=$(echo "$SESSION" | jq -r .did)
18
+ Everything runs through the shipped CLI [`scripts/bluesky.py`](scripts/bluesky.py)
19
+ self-contained (`requests` + `Pillow`, both preinstalled in the sandbox). One
20
+ call creates the session, auto-computes clickable **facets** (links, #hashtags,
21
+ @mentions with correct UTF-8 byte offsets), and for image posts downloads the
22
+ file, **resizes/recompresses it to Bluesky's ~1 MB blob limit**, `uploadBlob`s it
23
+ and builds the `app.bsky.embed.images` embed — so there's no fragile
24
+ `curl + jq + heredoc` to hand-assemble (the old inline recipe kept breaking on
25
+ shell quoting, especially once an image + facets were involved).
26
+
27
+ Three connector credentials are injected: `$BLUESKY_HANDLE`
28
+ (e.g. `name.bsky.social`), `$BLUESKY_APP_PASSWORD` (an App Password from Bluesky
29
+ **Settings Privacy and Security App Passwords**, NOT the login password) and
30
+ `$BLUESKY_SERVICE` (PDS base URL, default `https://bsky.social`). The CLI reads
31
+ them from the env never echo them.
32
+
33
+ ```sh
34
+ BSKY="$SKILL_DIR/scripts/bluesky.py"
35
+ python3 "$BSKY" whoami # verify the session {did, handle, service}
56
36
  ```
57
37
 
58
- If `JWT` / `DID` are empty or `null`, print the raw `$SESSION` (it contains the
59
- error) and stop do not continue to post. On `AuthenticationRequired`, tell the
60
- user to reconnect the connector using their **full** handle (e.g.
61
- `name.bsky.social`, not `name`) and a valid App Password.
62
-
63
- ## Post to Bluesky
64
-
65
- **Confirm the text with the user before posting.** Text is limited to **300
66
- graphemes**; longer text → `400 {"error":"InvalidRequest"}`. `createdAt` must be
67
- an ISO-8601 UTC timestamp.
68
-
69
- ```bash
70
- TEXT="Hello Bluesky 👋 shipping with the AT Protocol"
71
- NOW=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
72
- curl -sS -X POST "$SVC/xrpc/com.atproto.repo.createRecord" \
73
- -H "Authorization: Bearer $JWT" \
74
- -H "Content-Type: application/json" \
75
- -d "$(jq -n --arg did "$DID" --arg text "$TEXT" --arg now "$NOW" \
76
- '{repo:$did, collection:"app.bsky.feed.post",
77
- record:{ "$type":"app.bsky.feed.post", text:$text, createdAt:$now, langs:["en"] }}')" \
78
- | jq '{uri, cid}'
38
+ If `whoami` fails with `session_failed` / `AuthenticationRequired`, the
39
+ identifier or App Password is wrong. The **#1 cause is a bare handle** — the CLI
40
+ auto-appends `.bsky.social` to a bare username on the default PDS, but if it
41
+ still fails the user must reconnect the connector with their **full** handle
42
+ (`name.bsky.social`, a custom domain, a DID, or the account email) and a valid
43
+ App Password.
44
+
45
+ ## Post text, images and links in one call
46
+
47
+ **Confirm the text with the user before posting** (it publishes as their real
48
+ account). Text ≤ **300 graphemes**. Clickable links / #hashtags / @mentions are
49
+ turned into facets automatically — just write them in the text, no byte-offset
50
+ math needed.
51
+
52
+ ```sh
53
+ # plain text post
54
+ python3 "$BSKY" post --text "Hello Bluesky 👋 shipping with the AT Protocol"
55
+
56
+ # 带图发送 / post WITH an image (URL or local path) — the image is downloaded,
57
+ # resized to fit the blob limit, uploaded and embedded automatically:
58
+ python3 "$BSKY" post \
59
+ --text "Stop wiring 3 image APIs. One endpoint → posters, cards, mockups. https://platform.acedata.cloud/documents/openai-images-generations-integration #AI #API" \
60
+ --image "https://cdn.acedata.cloud/xxxx.png" --alt "AI image API hero"
61
+
62
+ # up to 4 images, each with its own --alt (paired by order)
63
+ python3 "$BSKY" post --text "gallery" --image a.png --alt "one" --image b.png --alt "two"
79
64
  ```
80
65
 
81
- The returned `uri` looks like `at://did:plc:xxxx/app.bsky.feed.post/<rkey>`. The
82
- public web URL is `https://bsky.app/profile/$ID/post/<rkey>` where `$ID` is the
83
- normalized handle from Step 1 (or use the `did`) and `<rkey>` is the last path
84
- segment of the `uri`.
85
-
86
- ### Clickable links, mentions and hashtags (facets)
87
-
88
- Plain URLs/hashtags in `text` are shown but **not clickable** — Bluesky needs
89
- `facets` with UTF-8 **byte** offsets. Add a hashtag link like this (byteStart/
90
- byteEnd are byte indices into the UTF-8 text, not character indices):
91
-
92
- ```bash
93
- # text = "New post about #ai" — "#ai" starts at byte 15, ends at byte 18
94
- curl -sS -X POST "$SVC/xrpc/com.atproto.repo.createRecord" \
95
- -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
96
- -d "$(jq -n --arg did "$DID" --arg now "$NOW" '
97
- {repo:$did, collection:"app.bsky.feed.post",
98
- record:{ "$type":"app.bsky.feed.post", text:"New post about #ai", createdAt:$now,
99
- facets:[ { index:{byteStart:15, byteEnd:18},
100
- features:[{ "$type":"app.bsky.richtext.facet#tag", tag:"ai" }] } ] }}')" \
101
- | jq '{uri, cid}'
66
+ Multi-line text or lots of emoji? Skip shell-quoting headaches by writing the
67
+ text to a file and using `--text-file` (or pipe via `--text -`):
68
+
69
+ ```sh
70
+ cat > /tmp/post.txt <<'EOF'
71
+ Line one 🎨
72
+
73
+ Line two with a link https://platform.acedata.cloud #AI
74
+ EOF
75
+ python3 "$BSKY" post --text-file /tmp/post.txt --image "https://cdn.acedata.cloud/xxxx.png"
102
76
  ```
103
77
 
104
- For a link, use feature `app.bsky.richtext.facet#link` with a `uri` field; for a
105
- mention, `app.bsky.richtext.facet#mention` with a `did`. Compute byte offsets
106
- with e.g. `printf '%s' "$prefix" | wc -c`.
78
+ Success prints
79
+ `{"posted":true,"uri":"at://…","url":"https://bsky.app/profile/<handle>/post/<rkey>", ...}`.
80
+ The `url` is the public, shareable link — hand it to the user verbatim.
107
81
 
108
82
  ## List my recent posts + engagement
109
83
 
110
- ```bash
111
- curl -sS "$SVC/xrpc/app.bsky.feed.getAuthorFeed?actor=$DID&limit=20&filter=posts_no_replies" \
112
- -H "Authorization: Bearer $JWT" \
113
- | jq '.feed[] | {uri: .post.uri,
114
- text: .post.record.text,
115
- reposts: .post.repostCount,
116
- likes: .post.likeCount,
117
- replies: .post.replyCount,
118
- at: .post.indexedAt}'
84
+ ```sh
85
+ python3 "$BSKY" list --limit 20 # default filter: posts_no_replies
86
+ python3 "$BSKY" list --limit 50 --filter posts_with_media
119
87
  ```
120
88
 
121
- `limit` max 100. `filter` options: `posts_with_replies`, `posts_no_replies`,
122
- `posts_with_media`, `posts_and_author_threads`.
89
+ `--filter`: `posts_no_replies` | `posts_with_replies` | `posts_with_media` |
90
+ `posts_and_author_threads`. `--limit` max 100.
123
91
 
124
92
  ## Delete a post
125
93
 
126
- `deleteRecord` needs the `rkey` (the last path segment of the post `uri`):
127
-
128
- ```bash
129
- POST_URI="at://$DID/app.bsky.feed.post/3kabc123xyz"
130
- RKEY="${POST_URI##*/}"
131
- curl -sS -X POST "$SVC/xrpc/com.atproto.repo.deleteRecord" \
132
- -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
133
- -d "$(jq -n --arg did "$DID" --arg rkey "$RKEY" \
134
- '{repo:$did, collection:"app.bsky.feed.post", rkey:$rkey}')" \
135
- | jq '{deleted: true, rkey: "'"$RKEY"'"}'
94
+ ```sh
95
+ python3 "$BSKY" delete --uri "at://did:plc:xxxx/app.bsky.feed.post/3kabc123xyz"
136
96
  ```
137
97
 
138
- An empty `{}` response is success. `400 {"error":"InvalidRequest"}` usually
139
- means the record is already gone or the `rkey` is wrong.
140
-
141
- ## Attaching images (optional)
142
-
143
- Upload each image via `POST $SVC/xrpc/com.atproto.repo.uploadBlob`
144
- (`Content-Type: image/jpeg`, raw bytes body, Bearer `$JWT`) → returns a `blob`
145
- object. Then set `record.embed` to
146
- `{ "$type":"app.bsky.embed.images", images:[{ alt:"<desc>", image:<blob> }] }`.
147
- Max 4 images per post; each blob ≲ 1 MB (resize/compress first).
98
+ Pass the full `at://…` post `uri` (from `list` or a prior `post`); the CLI
99
+ extracts the `rkey`. An empty result / `deleted:true` is success.
148
100
 
149
101
  ## Gotchas
150
102
 
151
103
  - **App Password, not account password:** creating a session with the real
152
104
  login password may be rejected or trip 2FA. Always the App Password from
153
105
  Settings → App Passwords.
154
- - **Byte offsets, not char offsets:** facet `byteStart`/`byteEnd` are UTF-8
155
- byte indices emoji and CJK take multiple bytes. Get them wrong and the
156
- link highlights the wrong span.
106
+ - **Facets & image resizing are automatic** the CLI computes link/#tag/@mention
107
+ byte offsets and shrinks oversized images to the ~1 MB blob limit for you.
157
108
  - **300 graphemes**, counted as user-perceived characters (emoji = 1).
158
109
  - **Rate limits:** the PDS rate-limits writes per account; space out bulk posts
159
110
  or you'll get `429 {"error":"RateLimitExceeded"}`.
160
111
  - **Self-hosted PDS:** if the user runs their own PDS, `$BLUESKY_SERVICE` points
161
112
  there; all XRPC calls target that host, not `bsky.social`.
162
- - The `accessJwt` is short-lived (~2h). For a single task it's fine; if it
163
- expires mid-task, just re-run Step 1.
113
+ - The CLI creates a fresh short-lived session on **every** invocation, so an
114
+ expiring `accessJwt` is never a concern — just run the command again.
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env python3
2
+ """Bluesky (AT Protocol / XRPC) CLI shipped with the `bluesky` skill.
3
+
4
+ Deps: `requests` + `Pillow` (both preinstalled in the sandbox). One command does
5
+ the whole publish flow that previously had to be hand-assembled from curl + jq +
6
+ python heredocs (and kept breaking on shell quoting):
7
+
8
+ - create a session (handle normalized to a full handle on the default PDS)
9
+ - auto-compute richtext `facets` (clickable links, #hashtags, @mentions) with
10
+ correct UTF-8 byte offsets
11
+ - attach images: download the URL/path, resize/recompress to Bluesky's ~1 MB
12
+ blob limit, `uploadBlob`, and build the `app.bsky.embed.images` embed
13
+ - `createRecord`, then print the public post URL
14
+
15
+ Secrets ($BLUESKY_APP_PASSWORD) are read from the env and never printed.
16
+ """
17
+
18
+ import argparse
19
+ import io
20
+ import json
21
+ import os
22
+ import re
23
+ import sys
24
+ from datetime import datetime, timezone
25
+
26
+ import requests
27
+ from PIL import Image
28
+
29
+ # bsky.social's uploadBlob rejects images over ~1 MB; stay safely under it.
30
+ BLOB_LIMIT = 976_560
31
+ URL_RE = re.compile(r"https?://[^\s\]\)]+")
32
+ TAG_RE = re.compile(r"(?<!\w)#([A-Za-z0-9_]+)")
33
+ # A mention target must be a dotted handle (name.bsky.social / custom domain);
34
+ # each dot-separated segment is non-empty so a trailing sentence dot isn't eaten.
35
+ MENTION_RE = re.compile(r"(?<!\w)@([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+)")
36
+
37
+
38
+ def out(o):
39
+ print(json.dumps(o, ensure_ascii=False))
40
+
41
+
42
+ def die(o):
43
+ out(o)
44
+ sys.exit(1)
45
+
46
+
47
+ def env_service():
48
+ return os.environ.get("BLUESKY_SERVICE", "https://bsky.social").rstrip("/")
49
+
50
+
51
+ def normalize_identifier(handle, svc):
52
+ # A dotted handle, an email or a DID is already valid; a bare username needs
53
+ # `.bsky.social` on the default PDS or createSession returns AuthRequired.
54
+ if any(c in handle for c in ".@") or handle.startswith("did:"):
55
+ return handle
56
+ if svc == "https://bsky.social":
57
+ return f"{handle}.bsky.social"
58
+ return handle
59
+
60
+
61
+ def create_session(svc):
62
+ handle = os.environ["BLUESKY_HANDLE"]
63
+ pw = os.environ["BLUESKY_APP_PASSWORD"]
64
+ ident = normalize_identifier(handle, svc)
65
+ try:
66
+ r = requests.post(
67
+ f"{svc}/xrpc/com.atproto.server.createSession",
68
+ json={"identifier": ident, "password": pw},
69
+ timeout=30,
70
+ )
71
+ except requests.RequestException as e:
72
+ die({"error": "session_request_failed", "detail": str(e)})
73
+ data = r.json() if r.content else {}
74
+ if r.status_code != 200 or not data.get("accessJwt"):
75
+ die({
76
+ "error": "session_failed",
77
+ "status": r.status_code,
78
+ "detail": data,
79
+ "hint": "reconnect the Bluesky connector with your FULL handle "
80
+ "(e.g. name.bsky.social) and a valid App Password",
81
+ })
82
+ return data["accessJwt"], data["did"], ident
83
+
84
+
85
+ def byte_index(text, char_index):
86
+ return len(text[:char_index].encode("utf-8"))
87
+
88
+
89
+ def resolve_handle(svc, handle):
90
+ try:
91
+ r = requests.get(
92
+ f"{svc}/xrpc/com.atproto.identity.resolveHandle",
93
+ params={"handle": handle},
94
+ timeout=15,
95
+ )
96
+ if r.status_code == 200:
97
+ return r.json().get("did")
98
+ except requests.RequestException:
99
+ pass
100
+ return None
101
+
102
+
103
+ def build_facets(text, svc):
104
+ """Compute link / hashtag / mention facets with UTF-8 byte offsets."""
105
+ facets = []
106
+ url_spans = [] # char ranges already claimed by a link facet
107
+ for m in URL_RE.finditer(text):
108
+ uri = m.group(0).rstrip(".,;:)]}'\"") # drop trailing punctuation
109
+ c_start, c_end = m.start(), m.start() + len(uri)
110
+ url_spans.append((c_start, c_end))
111
+ facets.append({
112
+ "index": {"byteStart": byte_index(text, c_start),
113
+ "byteEnd": byte_index(text, c_end)},
114
+ "features": [{"$type": "app.bsky.richtext.facet#link", "uri": uri}],
115
+ })
116
+
117
+ def inside_url(pos):
118
+ # Skip #frag / @x that live inside a URL (e.g. https://x.com/#a) so
119
+ # facets never overlap — atproto rejects overlapping richtext ranges.
120
+ return any(s <= pos < e for s, e in url_spans)
121
+
122
+ for m in TAG_RE.finditer(text):
123
+ if inside_url(m.start()):
124
+ continue
125
+ facets.append({
126
+ "index": {"byteStart": byte_index(text, m.start()),
127
+ "byteEnd": byte_index(text, m.end())},
128
+ "features": [{"$type": "app.bsky.richtext.facet#tag", "tag": m.group(1)}],
129
+ })
130
+ for m in MENTION_RE.finditer(text):
131
+ if inside_url(m.start()):
132
+ continue
133
+ did = resolve_handle(svc, m.group(1))
134
+ if did:
135
+ facets.append({
136
+ "index": {"byteStart": byte_index(text, m.start()),
137
+ "byteEnd": byte_index(text, m.end())},
138
+ "features": [{"$type": "app.bsky.richtext.facet#mention", "did": did}],
139
+ })
140
+ return facets
141
+
142
+
143
+ def fetch_bytes(src):
144
+ if re.match(r"^https?://", src):
145
+ r = requests.get(src, timeout=120, headers={"User-Agent": "Mozilla/5.0 (bluesky-skill)"})
146
+ r.raise_for_status()
147
+ return r.content
148
+ with open(src, "rb") as f:
149
+ return f.read()
150
+
151
+
152
+ def compress_to_limit(raw):
153
+ """Return (bytes, mime) within BLOB_LIMIT, recompressing/resizing if needed."""
154
+ known = {"JPEG": "image/jpeg", "PNG": "image/png", "WEBP": "image/webp", "GIF": "image/gif"}
155
+ try:
156
+ fmt = Image.open(io.BytesIO(raw)).format
157
+ except Exception:
158
+ die({"error": "image_decode_failed", "detail": "could not read the image bytes"})
159
+ # Small AND a format Bluesky handles as-is → upload untouched (keeps PNG
160
+ # transparency / animated GIF). Otherwise fall through and re-encode to JPEG
161
+ # so the blob's bytes always match its declared mimeType.
162
+ if len(raw) <= BLOB_LIMIT and fmt in known:
163
+ return raw, known[fmt]
164
+
165
+ img = Image.open(io.BytesIO(raw))
166
+ if img.mode not in ("RGB", "L"):
167
+ img = img.convert("RGB")
168
+ max_side = max(img.size)
169
+ buf = io.BytesIO()
170
+ for target_side in (max_side, 2048, 1600, 1280, 1024, 800, 640):
171
+ if target_side < max_side:
172
+ ratio = target_side / max_side
173
+ work = img.resize((max(1, int(img.width * ratio)), max(1, int(img.height * ratio))))
174
+ else:
175
+ work = img
176
+ for quality in (90, 85, 80, 70, 60, 50, 40):
177
+ buf = io.BytesIO()
178
+ work.save(buf, format="JPEG", quality=quality, optimize=True)
179
+ if buf.tell() <= BLOB_LIMIT:
180
+ return buf.getvalue(), "image/jpeg"
181
+ # Couldn't get under the limit; return the smallest attempt and let the API decide.
182
+ return buf.getvalue(), "image/jpeg"
183
+
184
+
185
+ def upload_blob(svc, jwt, raw, mime):
186
+ r = requests.post(
187
+ f"{svc}/xrpc/com.atproto.repo.uploadBlob",
188
+ data=raw,
189
+ headers={"Authorization": f"Bearer {jwt}", "Content-Type": mime},
190
+ timeout=120,
191
+ )
192
+ data = r.json() if r.content else {}
193
+ if r.status_code != 200 or "blob" not in data:
194
+ die({"error": "uploadBlob_failed", "status": r.status_code, "detail": data})
195
+ return data["blob"]
196
+
197
+
198
+ def read_text(a):
199
+ if a.text_file:
200
+ with open(a.text_file, encoding="utf-8") as f:
201
+ return f.read().rstrip("\n")
202
+ if a.text == "-":
203
+ return sys.stdin.read().rstrip("\n")
204
+ if a.text is not None:
205
+ return a.text
206
+ return "" # no text source; caller allows this when images are attached
207
+
208
+
209
+ def cmd_post(a):
210
+ svc = env_service()
211
+ text = read_text(a)
212
+ images = a.image or []
213
+ if len(images) > 4:
214
+ die({"error": "too_many_images", "detail": "Bluesky allows at most 4 images per post"})
215
+ if not text and not images:
216
+ die({"error": "empty_post", "detail": "a post needs --text/--text-file or at least one --image"})
217
+ jwt, did, ident = create_session(svc)
218
+ record = {
219
+ "$type": "app.bsky.feed.post",
220
+ "text": text,
221
+ "createdAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
222
+ "langs": [a.lang],
223
+ }
224
+ facets = build_facets(text, svc)
225
+ if facets:
226
+ record["facets"] = facets
227
+
228
+ if images:
229
+ alts = a.alt or []
230
+ embed_images = []
231
+ for i, src in enumerate(images):
232
+ try:
233
+ raw = fetch_bytes(src)
234
+ except (requests.RequestException, OSError) as e:
235
+ die({"error": "image_fetch_failed", "src": src, "detail": str(e)})
236
+ blob_bytes, mime = compress_to_limit(raw)
237
+ blob = upload_blob(svc, jwt, blob_bytes, mime)
238
+ # Missing alt defaults to empty (never reuse another image's alt).
239
+ alt = alts[i] if i < len(alts) else ""
240
+ embed_images.append({"alt": alt, "image": blob})
241
+ record["embed"] = {"$type": "app.bsky.embed.images", "images": embed_images}
242
+
243
+ r = requests.post(
244
+ f"{svc}/xrpc/com.atproto.repo.createRecord",
245
+ headers={"Authorization": f"Bearer {jwt}", "Content-Type": "application/json"},
246
+ json={"repo": did, "collection": "app.bsky.feed.post", "record": record},
247
+ timeout=60,
248
+ )
249
+ data = r.json() if r.content else {}
250
+ if r.status_code != 200 or "uri" not in data:
251
+ die({"error": "createRecord_failed", "status": r.status_code, "detail": data})
252
+ rkey = data["uri"].rsplit("/", 1)[-1]
253
+ out({
254
+ "posted": True,
255
+ "uri": data["uri"],
256
+ "cid": data.get("cid"),
257
+ "url": f"https://bsky.app/profile/{ident}/post/{rkey}",
258
+ "images": len(images),
259
+ "facets": len(facets),
260
+ })
261
+
262
+
263
+ def cmd_list(a):
264
+ svc = env_service()
265
+ jwt, did, _ = create_session(svc)
266
+ r = requests.get(
267
+ f"{svc}/xrpc/app.bsky.feed.getAuthorFeed",
268
+ params={"actor": did, "limit": a.limit, "filter": a.filter},
269
+ headers={"Authorization": f"Bearer {jwt}"},
270
+ timeout=30,
271
+ )
272
+ data = r.json() if r.content else {}
273
+ if r.status_code != 200:
274
+ die({"error": "getAuthorFeed_failed", "status": r.status_code, "detail": data})
275
+ out([
276
+ {
277
+ "uri": it["post"]["uri"],
278
+ "text": it["post"]["record"].get("text", ""),
279
+ "reposts": it["post"].get("repostCount", 0),
280
+ "likes": it["post"].get("likeCount", 0),
281
+ "replies": it["post"].get("replyCount", 0),
282
+ "at": it["post"].get("indexedAt"),
283
+ }
284
+ for it in data.get("feed", [])
285
+ ])
286
+
287
+
288
+ def cmd_delete(a):
289
+ svc = env_service()
290
+ jwt, did, _ = create_session(svc)
291
+ rkey = a.uri.rsplit("/", 1)[-1]
292
+ r = requests.post(
293
+ f"{svc}/xrpc/com.atproto.repo.deleteRecord",
294
+ headers={"Authorization": f"Bearer {jwt}", "Content-Type": "application/json"},
295
+ json={"repo": did, "collection": "app.bsky.feed.post", "rkey": rkey},
296
+ timeout=30,
297
+ )
298
+ if r.status_code != 200:
299
+ die({"error": "deleteRecord_failed", "status": r.status_code,
300
+ "detail": r.json() if r.content else {}})
301
+ out({"deleted": True, "rkey": rkey})
302
+
303
+
304
+ def cmd_whoami(a):
305
+ svc = env_service()
306
+ jwt, did, ident = create_session(svc)
307
+ out({"did": did, "handle": ident, "service": svc, "session": bool(jwt)})
308
+
309
+
310
+ def main():
311
+ p = argparse.ArgumentParser(prog="bluesky", description="Bluesky AT Protocol CLI")
312
+ sub = p.add_subparsers(dest="cmd", required=True)
313
+
314
+ pp = sub.add_parser("post", help="publish a post (text + optional images + auto facets)")
315
+ pp.add_argument("--text", help='post text (use "-" to read from stdin)')
316
+ pp.add_argument("--text-file", help="read post text from a file (safest for multi-line/emoji)")
317
+ pp.add_argument("--image", action="append", help="image URL or local path (repeatable, max 4)")
318
+ pp.add_argument("--alt", action="append", help="alt text per image (repeatable, paired by order)")
319
+ pp.add_argument("--lang", default="en", help="BCP-47 language tag (default: en)")
320
+ pp.set_defaults(func=cmd_post)
321
+
322
+ pl = sub.add_parser("list", help="list my recent posts with engagement")
323
+ pl.add_argument("--limit", type=int, default=20)
324
+ pl.add_argument("--filter", default="posts_no_replies",
325
+ help="posts_no_replies | posts_with_replies | posts_with_media | posts_and_author_threads")
326
+ pl.set_defaults(func=cmd_list)
327
+
328
+ pd = sub.add_parser("delete", help="delete one of my posts by its at:// uri")
329
+ pd.add_argument("--uri", required=True)
330
+ pd.set_defaults(func=cmd_delete)
331
+
332
+ pw = sub.add_parser("whoami", help="verify the session and show did/handle")
333
+ pw.set_defaults(func=cmd_whoami)
334
+
335
+ a = p.parse_args()
336
+ try:
337
+ a.func(a)
338
+ except SystemExit:
339
+ raise
340
+ except Exception as e:
341
+ die({"error": f"{type(e).__name__}", "detail": str(e)})
342
+
343
+
344
+ if __name__ == "__main__":
345
+ main()
@@ -13,7 +13,7 @@ allowed_tools: [Bash]
13
13
  license: Apache-2.0
14
14
  metadata:
15
15
  author: acedatacloud
16
- version: "1.1"
16
+ version: "1.2"
17
17
  ---
18
18
 
19
19
  We drive **personal** Telegram over MTProto with [Telethon](https://docs.telethon.dev/) —
@@ -27,227 +27,26 @@ Credentials are injected as env vars by the connector:
27
27
  - `TELEGRAM_SESSION_STRING` — Telethon `StringSession` = **full account access. Never log,
28
28
  echo, or print it.** Treat it like the account password.
29
29
 
30
- ## Setup — write the helper once per session
30
+ ## CLI
31
31
 
32
- `telethon` is preinstalled in the sandbox. The helper is written to `./tg.py` **in the current
33
- working directory** (the per-session workdir) not a shared global path so concurrent
34
- sessions never race.
32
+ The skill ships [`scripts/tg.py`](scripts/tg.py) self-contained (the only third-party dep is
33
+ `telethon`, preinstalled in the sandbox). Point a var at the shipped path and call it; no heredoc
34
+ to re-create per turn, so a multi-step flow (dry-run → confirm) can't lose the helper between calls:
35
+
36
+ ```sh
37
+ TG="$SKILL_DIR/scripts/tg.py"
38
+ python3 "$TG" whoami
39
+ ```
35
40
 
36
41
  Every state-changing command (`send`, `reply`, `send-file`, `forward`, `edit`, `delete`,
37
42
  `react`, `mark-read`) is **gated**: without a trailing `--confirm` it only DRY-RUNS (prints what
38
43
  it would do, changes nothing). Read commands run directly. `--confirm` is honored **only as the
39
44
  last argument** so a message/caption that merely contains "--confirm" can never silently confirm.
40
45
 
41
- ```sh
42
- python3 -c "import telethon" 2>/dev/null || pip install --user --quiet telethon 2>/dev/null || true
43
-
44
- cat > ./tg.py <<'PY'
45
- import os, sys, json, asyncio
46
- from telethon import TelegramClient
47
- from telethon.sessions import StringSession
48
- from telethon.tl import functions
49
- from telethon.tl.types import ReactionEmoji
50
-
51
- API_ID = int(os.environ["TELEGRAM_API_ID"])
52
- API_HASH = os.environ["TELEGRAM_API_HASH"]
53
- SESSION = os.environ["TELEGRAM_SESSION_STRING"]
54
-
55
- _raw = sys.argv[1:]
56
- # --confirm is only honored as the LAST token, and only one is stripped, so a
57
- # message/caption that merely contains "--confirm" cannot silently confirm a write.
58
- CONFIRM = bool(_raw) and _raw[-1] == "--confirm"
59
- a = _raw[:-1] if CONFIRM else list(_raw)
60
- cmd = a[0] if a else "help"
61
- args = a[1:]
62
- GATED = {"send", "reply", "send-file", "forward", "edit", "delete", "react", "mark-read"}
63
-
64
-
65
- def out(o):
66
- print(json.dumps(o, ensure_ascii=False, default=str))
67
-
68
-
69
- async def resolve(client, target):
70
- # 1) try direct resolve; 2) fall back to scanning dialogs by id or exact name
71
- # (StringSession doesn't persist the entity cache, so a numeric id from a
72
- # previous invocation may need the dialog scan to recover its access hash).
73
- for attempt in (lambda: client.get_entity(int(target)), lambda: client.get_entity(target)):
74
- try:
75
- return await attempt()
76
- except Exception:
77
- pass
78
- ti = None
79
- try:
80
- ti = int(target)
81
- except (ValueError, TypeError):
82
- pass
83
- async for d in client.iter_dialogs():
84
- if (ti is not None and d.id == ti) or d.name == target:
85
- return d.entity
86
- raise ValueError(f"could not resolve target: {target}")
87
-
88
-
89
- def msg_row(m):
90
- return {"id": m.id, "date": str(m.date), "out": m.out, "sender_id": m.sender_id,
91
- "text": m.message, "media": bool(m.media)}
92
-
93
-
94
- def need(n):
95
- if len(args) < n:
96
- raise ValueError(f"{cmd} needs {n} argument(s), got {len(args)}")
97
-
98
-
99
- async def run():
100
- if cmd in GATED and not CONFIRM:
101
- out({"dry_run": True, "command": cmd, "args": args,
102
- "note": "re-run with --confirm as the LAST argument to actually perform this write"})
103
- return
104
- async with TelegramClient(StringSession(SESSION), API_ID, API_HASH) as cl:
105
- if cmd == "whoami":
106
- me = await cl.get_me()
107
- out({"id": me.id, "username": me.username,
108
- "name": ((me.first_name or "") + " " + (me.last_name or "")).strip(), "phone": me.phone})
109
-
110
- elif cmd == "list-chats":
111
- limit = int(args[0]) if args and args[0].lstrip("-").isdigit() else 20
112
- unread_only = "unread-only" in args
113
- res = []
114
- async for d in cl.iter_dialogs(limit=limit):
115
- if unread_only and not d.unread_count:
116
- continue
117
- res.append({"name": d.name, "id": d.id, "group": d.is_group,
118
- "channel": d.is_channel, "user": d.is_user, "unread": d.unread_count})
119
- out(res)
120
-
121
- elif cmd == "unread":
122
- res = []
123
- async for d in cl.iter_dialogs():
124
- if d.unread_count:
125
- res.append({"name": d.name, "id": d.id, "unread": d.unread_count,
126
- "group": d.is_group, "channel": d.is_channel})
127
- out(sorted(res, key=lambda x: -x["unread"]))
128
-
129
- elif cmd == "get-messages":
130
- need(1); ent = await resolve(cl, args[0])
131
- n = int(args[1]) if len(args) > 1 else 50
132
- rows = [msg_row(m) async for m in cl.iter_messages(ent, limit=n)]
133
- rows.reverse()
134
- out(rows)
135
-
136
- elif cmd == "search":
137
- need(2); ent = await resolve(cl, args[0])
138
- q = args[1]; n = int(args[2]) if len(args) > 2 else 30
139
- out([msg_row(m) async for m in cl.iter_messages(ent, search=q, limit=n)])
140
-
141
- elif cmd == "search-global":
142
- need(1); q = args[0]; n = int(args[1]) if len(args) > 1 else 30
143
- rows = []
144
- async for m in cl.iter_messages(None, search=q, limit=n):
145
- r = msg_row(m); r["chat_id"] = m.chat_id
146
- rows.append(r)
147
- out(rows)
148
-
149
- elif cmd == "contacts":
150
- res = await cl(functions.contacts.GetContactsRequest(hash=0))
151
- out([{"id": u.id, "username": u.username,
152
- "name": ((u.first_name or "") + " " + (u.last_name or "")).strip(), "phone": u.phone}
153
- for u in res.users])
154
-
155
- elif cmd == "chat-info":
156
- need(1); ent = await resolve(cl, args[0])
157
- info = {"id": ent.id, "type": type(ent).__name__,
158
- "title": getattr(ent, "title", None),
159
- "name": ((getattr(ent, "first_name", "") or "") + " " + (getattr(ent, "last_name", "") or "")).strip() or None,
160
- "username": getattr(ent, "username", None)}
161
- try:
162
- info["participants"] = (await cl.get_participants(ent, limit=1)).total
163
- except Exception:
164
- pass
165
- out(info)
166
-
167
- elif cmd == "message-link":
168
- need(2); ent = await resolve(cl, args[0]); mid = int(args[1])
169
- try:
170
- r = await cl(functions.channels.ExportMessageLinkRequest(channel=ent, id=mid))
171
- out({"link": r.link})
172
- except Exception as e:
173
- out({"error": f"links only available for channels/supergroups: {e}"})
174
-
175
- elif cmd == "download-media":
176
- need(2); ent = await resolve(cl, args[0]); mid = int(args[1])
177
- outdir = args[2] if len(args) > 2 else "./tg_downloads"
178
- os.makedirs(outdir, exist_ok=True)
179
- m = await cl.get_messages(ent, ids=mid)
180
- if not m or not m.media:
181
- out({"error": "no media on that message"}); return
182
- path = await cl.download_media(m, file=outdir)
183
- out({"downloaded": path})
184
-
185
- # ---- gated writes (need trailing --confirm) ----
186
- elif cmd == "send":
187
- need(2); ent = await resolve(cl, args[0])
188
- m = await cl.send_message(ent, args[1])
189
- out({"sent": True, "id": m.id})
190
-
191
- elif cmd == "reply":
192
- need(3); ent = await resolve(cl, args[0])
193
- m = await cl.send_message(ent, args[2], reply_to=int(args[1]))
194
- out({"sent": True, "id": m.id, "reply_to": int(args[1])})
195
-
196
- elif cmd == "send-file":
197
- need(2); ent = await resolve(cl, args[0])
198
- caption = args[2] if len(args) > 2 else None
199
- m = await cl.send_file(ent, args[1], caption=caption)
200
- out({"sent": True, "id": m.id})
201
-
202
- elif cmd == "forward":
203
- need(3); src = await resolve(cl, args[0]); mid = int(args[1]); dst = await resolve(cl, args[2])
204
- fwd = await cl.forward_messages(dst, mid, src)
205
- out({"forwarded": True, "id": getattr(fwd, "id", None) or [x.id for x in fwd]})
206
-
207
- elif cmd == "edit":
208
- need(3); ent = await resolve(cl, args[0])
209
- m = await cl.edit_message(ent, int(args[1]), args[2])
210
- out({"edited": True, "id": m.id})
211
-
212
- elif cmd == "delete":
213
- need(2); ent = await resolve(cl, args[0])
214
- await cl.delete_messages(ent, int(args[1]))
215
- out({"deleted": True, "id": int(args[1])})
216
-
217
- elif cmd == "react":
218
- need(3); ent = await resolve(cl, args[0]); mid = int(args[1]); emoji = args[2]
219
- await cl(functions.messages.SendReactionRequest(
220
- peer=ent, msg_id=mid, reaction=[ReactionEmoji(emoticon=emoji)]))
221
- out({"reacted": True, "id": mid, "emoji": emoji})
222
-
223
- elif cmd == "mark-read":
224
- need(1); ent = await resolve(cl, args[0])
225
- await cl.send_read_acknowledge(ent)
226
- out({"marked_read": True})
227
-
228
- else:
229
- out({"error": f"unknown command: {cmd}"}); sys.exit(1)
230
-
231
-
232
- async def main():
233
- try:
234
- await run()
235
- except SystemExit:
236
- raise
237
- except Exception as e:
238
- out({"error": f"{type(e).__name__}: {e}"})
239
- sys.exit(1)
240
-
241
-
242
- asyncio.run(main())
243
- PY
244
- echo "helper ready"
245
- ```
246
-
247
46
  ## Verify the connection first
248
47
 
249
48
  ```sh
250
- python3 ./tg.py whoami
49
+ python3 "$TG" whoami
251
50
  # → {"id": 8367450178, "username": "GermeyAce", "name": "Germey", "phone": "..."}
252
51
  ```
253
52
 
@@ -258,14 +57,14 @@ https://auth.acedata.cloud/user/connections.
258
57
 
259
58
  | Goal | Command |
260
59
  |---|---|
261
- | Recent conversations | `python3 ./tg.py list-chats 20` |
262
- | Only chats with unread (ranked) | `python3 ./tg.py unread` |
263
- | A chat's history (oldest→newest) | `python3 ./tg.py get-messages <target> 50` |
264
- | Search inside one chat | `python3 ./tg.py search <target> "kw" 30` |
265
- | Search across ALL chats | `python3 ./tg.py search-global "kw" 30` |
266
- | List contacts | `python3 ./tg.py contacts` |
267
- | Info about a chat/user | `python3 ./tg.py chat-info <target>` |
268
- | t.me link to a message | `python3 ./tg.py message-link <target> <msg_id>` |
60
+ | Recent conversations | `python3 "$TG" list-chats 20` |
61
+ | Only chats with unread (ranked) | `python3 "$TG" unread` |
62
+ | A chat's history (oldest→newest) | `python3 "$TG" get-messages <target> 50` |
63
+ | Search inside one chat | `python3 "$TG" search <target> "kw" 30` |
64
+ | Search across ALL chats | `python3 "$TG" search-global "kw" 30` |
65
+ | List contacts | `python3 "$TG" contacts` |
66
+ | Info about a chat/user | `python3 "$TG" chat-info <target>` |
67
+ | t.me link to a message | `python3 "$TG" message-link <target> <msg_id>` |
269
68
 
270
69
  `<target>` = numeric id (most reliable — from `list-chats`), `@username`, phone, or exact chat
271
70
  name. In message rows, `out:true` = sent by the user; `media:true` = has an attachment.
@@ -277,11 +76,16 @@ each → summarize. Don't dump 20k messages; sample the most-unread / most-relev
277
76
 
278
77
  ```sh
279
78
  # Download an attachment from a message → returns the saved path
280
- python3 ./tg.py download-media <target> <msg_id> ./tg_downloads
281
- # Send a local file or a URL (optional caption) — GATED
282
- python3 ./tg.py send-file <target> /path/or/https-url "caption" --confirm
79
+ python3 "$TG" download-media <target> <msg_id> ./tg_downloads
80
+ # Send a local file OR an http(s) URL (optional caption) — GATED
81
+ python3 "$TG" send-file <target> /path/or/https-url "caption" --confirm
283
82
  ```
284
83
 
84
+ An `http(s)` URL is downloaded to a real local file first (with the right
85
+ extension from the URL / `Content-Type`) and then uploaded, so a remote image
86
+ lands as a **photo** — not a document, and not a silent failure. This is the
87
+ reliable way to "发图": pass the CDN URL straight to `send-file`.
88
+
285
89
  To hand a downloaded file back to the user as a link, upload it to the CDN (see the
286
90
  `cos-upload` skill) after `download-media`.
287
91
 
@@ -292,13 +96,13 @@ exactly what will happen, get an explicit "yes", then re-run with `--confirm` as
292
96
  argument**. Never bulk-send.
293
97
 
294
98
  ```sh
295
- python3 ./tg.py send <target> "text" # → dry_run; add --confirm to send
296
- python3 ./tg.py reply <target> <msg_id> "text" --confirm
297
- python3 ./tg.py forward <from_target> <msg_id> <to_target> --confirm
298
- python3 ./tg.py edit <target> <msg_id> "new text" --confirm # own messages
299
- python3 ./tg.py delete <target> <msg_id> --confirm # destructive
300
- python3 ./tg.py react <target> <msg_id> "👍" --confirm
301
- python3 ./tg.py mark-read <target> --confirm # sends read receipts
99
+ python3 "$TG" send <target> "text" # → dry_run; add --confirm to send
100
+ python3 "$TG" reply <target> <msg_id> "text" --confirm
101
+ python3 "$TG" forward <from_target> <msg_id> <to_target> --confirm
102
+ python3 "$TG" edit <target> <msg_id> "new text" --confirm # own messages
103
+ python3 "$TG" delete <target> <msg_id> --confirm # destructive
104
+ python3 "$TG" react <target> <msg_id> "👍" --confirm
105
+ python3 "$TG" mark-read <target> --confirm # sends read receipts
302
106
  ```
303
107
 
304
108
  The dry run returns `{"dry_run": true, "command": ..., "args": [...]}` — present that to the
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env python3
2
+ """Personal Telegram (MTProto / Telethon) CLI — shipped with the `telegram` skill.
3
+
4
+ Self-contained: the only third-party dep is `telethon` (preinstalled in the
5
+ sandbox). URL downloads for `send-file` use the stdlib only, so a remote image
6
+ is fetched to a real local file before upload — Telethon sending a bare remote
7
+ URL is unreliable and often lands the media as a document (or fails outright).
8
+
9
+ Secrets (`TELEGRAM_API_HASH`, `TELEGRAM_SESSION_STRING`) are read from the env
10
+ and never printed.
11
+ """
12
+
13
+ import asyncio
14
+ import json
15
+ import mimetypes
16
+ import os
17
+ import sys
18
+ import tempfile
19
+ import urllib.request
20
+ from urllib.parse import urlparse
21
+
22
+ from telethon import TelegramClient
23
+ from telethon.sessions import StringSession
24
+ from telethon.tl import functions
25
+ from telethon.tl.types import ReactionEmoji
26
+
27
+ API_ID = int(os.environ["TELEGRAM_API_ID"])
28
+ API_HASH = os.environ["TELEGRAM_API_HASH"]
29
+ SESSION = os.environ["TELEGRAM_SESSION_STRING"]
30
+
31
+ _raw = sys.argv[1:]
32
+ # --confirm is only honored as the LAST token, and only one is stripped, so a
33
+ # message/caption that merely contains "--confirm" cannot silently confirm a write.
34
+ CONFIRM = bool(_raw) and _raw[-1] == "--confirm"
35
+ a = _raw[:-1] if CONFIRM else list(_raw)
36
+ cmd = a[0] if a else "help"
37
+ args = a[1:]
38
+ GATED = {"send", "reply", "send-file", "forward", "edit", "delete", "react", "mark-read"}
39
+
40
+
41
+ def out(o):
42
+ print(json.dumps(o, ensure_ascii=False, default=str))
43
+
44
+
45
+ def _ext_from_content_type(ct):
46
+ ct = (ct or "").split(";")[0].strip().lower()
47
+ if not ct:
48
+ return ""
49
+ return mimetypes.guess_extension(ct) or ""
50
+
51
+
52
+ def materialize_file(src):
53
+ """Return (local_path, cleanup) for `src`.
54
+
55
+ If `src` is an http(s) URL, download it to a temp file with a sensible
56
+ extension (so Telethon detects images/videos as media, not documents).
57
+ Local paths are returned unchanged with a no-op cleanup.
58
+ """
59
+ parsed = urlparse(src)
60
+ if parsed.scheme not in ("http", "https"):
61
+ return src, (lambda: None)
62
+
63
+ # Pick an extension from the URL path first, then the response Content-Type.
64
+ _, url_ext = os.path.splitext(parsed.path)
65
+ req = urllib.request.Request(src, headers={"User-Agent": "Mozilla/5.0 (telegram-skill)"})
66
+ with urllib.request.urlopen(req, timeout=120) as resp: # noqa: S310 - user-supplied media URL
67
+ ext = url_ext or _ext_from_content_type(resp.headers.get("Content-Type"))
68
+ fd, path = tempfile.mkstemp(suffix=ext or ".bin", prefix="tg_send_")
69
+
70
+ def cleanup():
71
+ try:
72
+ os.remove(path)
73
+ except OSError:
74
+ pass
75
+
76
+ try:
77
+ with os.fdopen(fd, "wb") as f:
78
+ while True:
79
+ chunk = resp.read(65536)
80
+ if not chunk:
81
+ break
82
+ f.write(chunk)
83
+ except BaseException:
84
+ cleanup() # don't orphan the temp file if the download fails mid-stream
85
+ raise
86
+
87
+ return path, cleanup
88
+
89
+
90
+ async def resolve(client, target):
91
+ # 1) try direct resolve; 2) fall back to scanning dialogs by id or exact name
92
+ # (StringSession doesn't persist the entity cache, so a numeric id from a
93
+ # previous invocation may need the dialog scan to recover its access hash).
94
+ for attempt in (lambda: client.get_entity(int(target)), lambda: client.get_entity(target)):
95
+ try:
96
+ return await attempt()
97
+ except Exception:
98
+ pass
99
+ ti = None
100
+ try:
101
+ ti = int(target)
102
+ except (ValueError, TypeError):
103
+ pass
104
+ async for d in client.iter_dialogs():
105
+ if (ti is not None and d.id == ti) or d.name == target:
106
+ return d.entity
107
+ raise ValueError(f"could not resolve target: {target}")
108
+
109
+
110
+ def msg_row(m):
111
+ return {"id": m.id, "date": str(m.date), "out": m.out, "sender_id": m.sender_id,
112
+ "text": m.message, "media": bool(m.media)}
113
+
114
+
115
+ def need(n):
116
+ if len(args) < n:
117
+ raise ValueError(f"{cmd} needs {n} argument(s), got {len(args)}")
118
+
119
+
120
+ async def run():
121
+ if cmd in GATED and not CONFIRM:
122
+ out({"dry_run": True, "command": cmd, "args": args,
123
+ "note": "re-run with --confirm as the LAST argument to actually perform this write"})
124
+ return
125
+ async with TelegramClient(StringSession(SESSION), API_ID, API_HASH) as cl:
126
+ if cmd == "whoami":
127
+ me = await cl.get_me()
128
+ out({"id": me.id, "username": me.username,
129
+ "name": ((me.first_name or "") + " " + (me.last_name or "")).strip(), "phone": me.phone})
130
+
131
+ elif cmd == "list-chats":
132
+ limit = int(args[0]) if args and args[0].lstrip("-").isdigit() else 20
133
+ unread_only = "unread-only" in args
134
+ res = []
135
+ async for d in cl.iter_dialogs(limit=limit):
136
+ if unread_only and not d.unread_count:
137
+ continue
138
+ res.append({"name": d.name, "id": d.id, "group": d.is_group,
139
+ "channel": d.is_channel, "user": d.is_user, "unread": d.unread_count})
140
+ out(res)
141
+
142
+ elif cmd == "unread":
143
+ res = []
144
+ async for d in cl.iter_dialogs():
145
+ if d.unread_count:
146
+ res.append({"name": d.name, "id": d.id, "unread": d.unread_count,
147
+ "group": d.is_group, "channel": d.is_channel})
148
+ out(sorted(res, key=lambda x: -x["unread"]))
149
+
150
+ elif cmd == "get-messages":
151
+ need(1); ent = await resolve(cl, args[0])
152
+ n = int(args[1]) if len(args) > 1 else 50
153
+ rows = [msg_row(m) async for m in cl.iter_messages(ent, limit=n)]
154
+ rows.reverse()
155
+ out(rows)
156
+
157
+ elif cmd == "search":
158
+ need(2); ent = await resolve(cl, args[0])
159
+ q = args[1]; n = int(args[2]) if len(args) > 2 else 30
160
+ out([msg_row(m) async for m in cl.iter_messages(ent, search=q, limit=n)])
161
+
162
+ elif cmd == "search-global":
163
+ need(1); q = args[0]; n = int(args[1]) if len(args) > 1 else 30
164
+ rows = []
165
+ async for m in cl.iter_messages(None, search=q, limit=n):
166
+ r = msg_row(m); r["chat_id"] = m.chat_id
167
+ rows.append(r)
168
+ out(rows)
169
+
170
+ elif cmd == "contacts":
171
+ res = await cl(functions.contacts.GetContactsRequest(hash=0))
172
+ out([{"id": u.id, "username": u.username,
173
+ "name": ((u.first_name or "") + " " + (u.last_name or "")).strip(), "phone": u.phone}
174
+ for u in res.users])
175
+
176
+ elif cmd == "chat-info":
177
+ need(1); ent = await resolve(cl, args[0])
178
+ info = {"id": ent.id, "type": type(ent).__name__,
179
+ "title": getattr(ent, "title", None),
180
+ "name": ((getattr(ent, "first_name", "") or "") + " " + (getattr(ent, "last_name", "") or "")).strip() or None,
181
+ "username": getattr(ent, "username", None)}
182
+ try:
183
+ info["participants"] = (await cl.get_participants(ent, limit=1)).total
184
+ except Exception:
185
+ pass
186
+ out(info)
187
+
188
+ elif cmd == "message-link":
189
+ need(2); ent = await resolve(cl, args[0]); mid = int(args[1])
190
+ try:
191
+ r = await cl(functions.channels.ExportMessageLinkRequest(channel=ent, id=mid))
192
+ out({"link": r.link})
193
+ except Exception as e:
194
+ out({"error": f"links only available for channels/supergroups: {e}"})
195
+
196
+ elif cmd == "download-media":
197
+ need(2); ent = await resolve(cl, args[0]); mid = int(args[1])
198
+ outdir = args[2] if len(args) > 2 else "./tg_downloads"
199
+ os.makedirs(outdir, exist_ok=True)
200
+ m = await cl.get_messages(ent, ids=mid)
201
+ if not m or not m.media:
202
+ out({"error": "no media on that message"}); return
203
+ path = await cl.download_media(m, file=outdir)
204
+ out({"downloaded": path})
205
+
206
+ # ---- gated writes (need trailing --confirm) ----
207
+ elif cmd == "send":
208
+ need(2); ent = await resolve(cl, args[0])
209
+ m = await cl.send_message(ent, args[1])
210
+ out({"sent": True, "id": m.id})
211
+
212
+ elif cmd == "reply":
213
+ need(3); ent = await resolve(cl, args[0])
214
+ m = await cl.send_message(ent, args[2], reply_to=int(args[1]))
215
+ out({"sent": True, "id": m.id, "reply_to": int(args[1])})
216
+
217
+ elif cmd == "send-file":
218
+ need(2); ent = await resolve(cl, args[0])
219
+ caption = args[2] if len(args) > 2 else None
220
+ # Download remote URLs to a real local file first so images/videos
221
+ # upload as media instead of failing or landing as a document.
222
+ path, cleanup = materialize_file(args[1])
223
+ try:
224
+ m = await cl.send_file(ent, path, caption=caption)
225
+ finally:
226
+ cleanup()
227
+ out({"sent": True, "id": m.id})
228
+
229
+ elif cmd == "forward":
230
+ need(3); src = await resolve(cl, args[0]); mid = int(args[1]); dst = await resolve(cl, args[2])
231
+ fwd = await cl.forward_messages(dst, mid, src)
232
+ out({"forwarded": True, "id": getattr(fwd, "id", None) or [x.id for x in fwd]})
233
+
234
+ elif cmd == "edit":
235
+ need(3); ent = await resolve(cl, args[0])
236
+ m = await cl.edit_message(ent, int(args[1]), args[2])
237
+ out({"edited": True, "id": m.id})
238
+
239
+ elif cmd == "delete":
240
+ need(2); ent = await resolve(cl, args[0])
241
+ await cl.delete_messages(ent, int(args[1]))
242
+ out({"deleted": True, "id": int(args[1])})
243
+
244
+ elif cmd == "react":
245
+ need(3); ent = await resolve(cl, args[0]); mid = int(args[1]); emoji = args[2]
246
+ await cl(functions.messages.SendReactionRequest(
247
+ peer=ent, msg_id=mid, reaction=[ReactionEmoji(emoticon=emoji)]))
248
+ out({"reacted": True, "id": mid, "emoji": emoji})
249
+
250
+ elif cmd == "mark-read":
251
+ need(1); ent = await resolve(cl, args[0])
252
+ await cl.send_read_acknowledge(ent)
253
+ out({"marked_read": True})
254
+
255
+ else:
256
+ out({"error": f"unknown command: {cmd}"}); sys.exit(1)
257
+
258
+
259
+ async def main():
260
+ try:
261
+ await run()
262
+ except SystemExit:
263
+ raise
264
+ except Exception as e:
265
+ out({"error": f"{type(e).__name__}: {e}"})
266
+ sys.exit(1)
267
+
268
+
269
+ if __name__ == "__main__":
270
+ asyncio.run(main())