@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 +1 -1
- package/skills/bluesky/SKILL.md +72 -121
- package/skills/bluesky/scripts/bluesky.py +345 -0
- package/skills/telegram/SKILL.md +34 -230
- package/skills/telegram/scripts/tg.py +270 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acedatacloud/skills",
|
|
3
|
-
"version": "2026.
|
|
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",
|
package/skills/bluesky/SKILL.md
CHANGED
|
@@ -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.
|
|
15
|
+
version: "1.1"
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 `
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
```
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
`
|
|
122
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
- **
|
|
155
|
-
byte
|
|
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
|
|
163
|
-
|
|
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()
|
package/skills/telegram/SKILL.md
CHANGED
|
@@ -13,7 +13,7 @@ allowed_tools: [Bash]
|
|
|
13
13
|
license: Apache-2.0
|
|
14
14
|
metadata:
|
|
15
15
|
author: acedatacloud
|
|
16
|
-
version: "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
|
-
##
|
|
30
|
+
## CLI
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
|
262
|
-
| Only chats with unread (ranked) | `python3
|
|
263
|
-
| A chat's history (oldest→newest) | `python3
|
|
264
|
-
| Search inside one chat | `python3
|
|
265
|
-
| Search across ALL chats | `python3
|
|
266
|
-
| List contacts | `python3
|
|
267
|
-
| Info about a chat/user | `python3
|
|
268
|
-
| t.me link to a message | `python3
|
|
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
|
|
281
|
-
# Send a local file
|
|
282
|
-
python3
|
|
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
|
|
296
|
-
python3
|
|
297
|
-
python3
|
|
298
|
-
python3
|
|
299
|
-
python3
|
|
300
|
-
python3
|
|
301
|
-
python3
|
|
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())
|