gru-bot-cli 0.1.0__tar.gz

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.
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
20
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,8 @@
1
+ include LICENSE
2
+ include MANIFEST.in
3
+ include NOTICE-TENCENT
4
+ include README.md
5
+ include gru_associate.py
6
+ include gru_bot_cli.py
7
+ include gru_weixin.py
8
+ include pyproject.toml
@@ -0,0 +1,27 @@
1
+ Tencent is pleased to support the open source community by making openclaw-weixin available.
2
+
3
+ Copyright (C) 2026 Tencent. All rights reserved.
4
+
5
+ openclaw-weixin is licensed under the MIT.
6
+
7
+
8
+ Terms of the MIT:
9
+ --------------------------------------------------------------------
10
+ Permission is hereby granted, free of charge, to any person obtaining
11
+ a copy of this software and associated documentation files (the
12
+ "Software"), to deal in the Software without restriction, including
13
+ without limitation the rights to use, copy, modify, merge, publish,
14
+ distribute, sublicense, and/or sell copies of the Software, and to
15
+ permit persons to whom the Software is furnished to do so, subject to
16
+ the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be
19
+ included in all copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
22
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
23
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
24
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
25
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
26
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
27
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: gru-bot-cli
3
+ Version: 0.1.0
4
+ Summary: Gru CLI with built-in WeChat Bot connection and automatic Skill synchronization
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://gru.bot
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ License-File: NOTICE-TENCENT
11
+ Requires-Dist: gru-bot-skills==0.1.0
12
+ Requires-Dist: qrcode[pil]==8.2
13
+ Dynamic: license-file
14
+
15
+ # Gru CLI
16
+
17
+ `@gru-bot/cli` and `gru-bot` provide one-shot WeChat iLink text transport and a durable local SQLite inbox. It can pull inbound messages, show the earliest message without a reply intent, report inbox and send-intent state, and reply once to a specific inbound message.
18
+
19
+ This release is the text transport component of [Gru](https://gru.bot). It does not run an Agent, schedule background work, read personal WeChat history, or process media. The npm CLI runs on Node.js 24+ and requires no Python or native addon compilation. The PyPI CLI runs on Python 3.11+ without Node.js. Both include WeChat Bot QR association and the Gru Skill.
20
+
21
+ ## Install
22
+
23
+ Choose either npm name; they provide the same `gru` command and functionality:
24
+
25
+ ```bash
26
+ npm install -g @gru-bot/cli
27
+ # Alternative name:
28
+ npm install -g gru-bot
29
+ ```
30
+
31
+ Use one global installation at a time because both provide `gru`. The Gru Skill is bundled and installed automatically for detected Agent hosts; installation failures are reported. A host that appears later is handled on the next CLI invocation. `gru skills status` reports the actual state, including deferred installation when no supported host is found. `gru skills disable` and `gru skills remove` stop automatic restoration.
32
+
33
+ For Python, choose `pip install gru-bot-cli` or the equivalent `pip install gru-bot`. Both provide `gru`; install one alternative at a time. pip has no postinstall hook, so the first CLI invocation syncs the bundled Skill. The Python QR renderer uses the declared `qrcode[pil]==8.2` dependency. The standalone Skill packages are `@gru-bot/skills` on npm and `gru-bot-skills` on PyPI. Personal WeChat history reading is provided separately by `@gru-bot/wx` or `gru-bot-wx`, using an explicitly configured existing local reader.
34
+
35
+ ## WeChat Bot association
36
+
37
+ Both CLI distributions include the default Bot connection. Use an explicit private directory and follow bounded steps:
38
+
39
+ ```bash
40
+ gru bot --state-dir /absolute/private/association inspect
41
+ gru bot --state-dir /absolute/private/association start
42
+ gru bot --state-dir /absolute/private/association poll
43
+ gru bot --state-dir /absolute/private/association install
44
+ ```
45
+
46
+ `start` reuses a complete binding or live QR. When it returns a new private `qr_image` file, open that image and scan it in the authorized account's WeChat. `poll` makes one status request. Expired QR codes stop; `start --refresh` requests a replacement only after expiry. For `need_verifycode`, put the phone's pairing digits in an owner-only file and use `poll --code-file /absolute/private/code.txt`. `render` recreates the same live QR after interruption.
47
+
48
+ `install` is an explicit configuration command: it appends confirmed exports to an existing literal `.zprofile`, or the file selected with `--profile`, and refuses conflicting or dynamic shell configuration. Open a fresh login shell afterwards. Package installation and ordinary CLI startup do not pair accounts, send messages or write credentials.
49
+
50
+ Association state and profile installation currently require POSIX owner permissions (macOS/Linux). Unsupported ownership checks fail explicitly. Updated Python and Node association commands share a fence; an older Python association command does not participate, so use one association implementation at a time when continuing older trials. A lock left during acquisition remains explicit for manual reconciliation.
51
+
52
+ ## Configuration
53
+
54
+ Use an existing authorized WeChat Bot association. Supply the exact server-returned account ID, user ID, HTTPS base URL, and credential through your shell environment:
55
+
56
+ - `GRU_ILINK_ACCOUNT_ID`: `ilink_bot_id`.
57
+ - `GRU_ILINK_USER_ID`: `ilink_user_id`.
58
+ - `GRU_ILINK_BASE_URL`: HTTPS `baseurl`.
59
+ - `GRU_ILINK_TOKEN`: Bot credential; needed for `pull` and `reply`.
60
+ - `GRU_ILINK_ROUTE_TAG`: optional confirmed server route tag.
61
+
62
+ Keep credentials and the database private. The Bot API URL comes from the confirmed association response; `gru.bot` is the product homepage, not the API endpoint. QR pairing is built in; personal WeChat history reading is a separate capability.
63
+
64
+ ## Commands
65
+
66
+ Choose an explicit private database path. `status` and `next` use local state; they require the account, user and base URL, but do not require a token or contact the server. An empty new database is created with owner-only permissions.
67
+
68
+ ```bash
69
+ gru --help
70
+ gru --db /absolute/private/inbox.sqlite status
71
+ gru --db /absolute/private/inbox.sqlite next
72
+ gru --db /absolute/private/inbox.sqlite pull
73
+ gru --db /absolute/private/inbox.sqlite reply 123 'approved reply text'
74
+ ```
75
+
76
+ `pull` makes one network poll and persists raw receipts, inbox messages and the cursor. `reply` makes a real send to the exact associated sender of that inbound message. Run these only for an authorized account and approved content. Missing binding or credentials stop the command with exit code 2.
77
+
78
+ `next` returns a limited JSON projection without reply/media credentials. It does not claim a message for a worker; use one consumer. Unsupported media remains marked as unprocessed.
79
+
80
+ Each reply stores an intent before sending. Accepted, rejected and unknown intents all prevent automatic resend for that inbound ID. `status` lists unresolved intents. An accepted server receipt does not prove recipient-side display. Unknown sends need manual reconciliation, not automatic retries. Do not delete or replace your existing inbox to retry a send.
81
+
82
+ ## License and source
83
+
84
+ MIT. Protocol, headers and text payloads are adapted from Tencent's [openclaw-weixin](https://github.com/Tencent/openclaw-weixin) at commit `24de5c9eb0dd5e595d7e2d090ed8a3f82870d42c`; its copyright and MIT notice are included. The local SQLite persistence and one-shot CLI share the same SQLite schema and message/send-intent rules across the Node npm and Python PyPI distributions.
@@ -0,0 +1,70 @@
1
+ # Gru CLI
2
+
3
+ `@gru-bot/cli` and `gru-bot` provide one-shot WeChat iLink text transport and a durable local SQLite inbox. It can pull inbound messages, show the earliest message without a reply intent, report inbox and send-intent state, and reply once to a specific inbound message.
4
+
5
+ This release is the text transport component of [Gru](https://gru.bot). It does not run an Agent, schedule background work, read personal WeChat history, or process media. The npm CLI runs on Node.js 24+ and requires no Python or native addon compilation. The PyPI CLI runs on Python 3.11+ without Node.js. Both include WeChat Bot QR association and the Gru Skill.
6
+
7
+ ## Install
8
+
9
+ Choose either npm name; they provide the same `gru` command and functionality:
10
+
11
+ ```bash
12
+ npm install -g @gru-bot/cli
13
+ # Alternative name:
14
+ npm install -g gru-bot
15
+ ```
16
+
17
+ Use one global installation at a time because both provide `gru`. The Gru Skill is bundled and installed automatically for detected Agent hosts; installation failures are reported. A host that appears later is handled on the next CLI invocation. `gru skills status` reports the actual state, including deferred installation when no supported host is found. `gru skills disable` and `gru skills remove` stop automatic restoration.
18
+
19
+ For Python, choose `pip install gru-bot-cli` or the equivalent `pip install gru-bot`. Both provide `gru`; install one alternative at a time. pip has no postinstall hook, so the first CLI invocation syncs the bundled Skill. The Python QR renderer uses the declared `qrcode[pil]==8.2` dependency. The standalone Skill packages are `@gru-bot/skills` on npm and `gru-bot-skills` on PyPI. Personal WeChat history reading is provided separately by `@gru-bot/wx` or `gru-bot-wx`, using an explicitly configured existing local reader.
20
+
21
+ ## WeChat Bot association
22
+
23
+ Both CLI distributions include the default Bot connection. Use an explicit private directory and follow bounded steps:
24
+
25
+ ```bash
26
+ gru bot --state-dir /absolute/private/association inspect
27
+ gru bot --state-dir /absolute/private/association start
28
+ gru bot --state-dir /absolute/private/association poll
29
+ gru bot --state-dir /absolute/private/association install
30
+ ```
31
+
32
+ `start` reuses a complete binding or live QR. When it returns a new private `qr_image` file, open that image and scan it in the authorized account's WeChat. `poll` makes one status request. Expired QR codes stop; `start --refresh` requests a replacement only after expiry. For `need_verifycode`, put the phone's pairing digits in an owner-only file and use `poll --code-file /absolute/private/code.txt`. `render` recreates the same live QR after interruption.
33
+
34
+ `install` is an explicit configuration command: it appends confirmed exports to an existing literal `.zprofile`, or the file selected with `--profile`, and refuses conflicting or dynamic shell configuration. Open a fresh login shell afterwards. Package installation and ordinary CLI startup do not pair accounts, send messages or write credentials.
35
+
36
+ Association state and profile installation currently require POSIX owner permissions (macOS/Linux). Unsupported ownership checks fail explicitly. Updated Python and Node association commands share a fence; an older Python association command does not participate, so use one association implementation at a time when continuing older trials. A lock left during acquisition remains explicit for manual reconciliation.
37
+
38
+ ## Configuration
39
+
40
+ Use an existing authorized WeChat Bot association. Supply the exact server-returned account ID, user ID, HTTPS base URL, and credential through your shell environment:
41
+
42
+ - `GRU_ILINK_ACCOUNT_ID`: `ilink_bot_id`.
43
+ - `GRU_ILINK_USER_ID`: `ilink_user_id`.
44
+ - `GRU_ILINK_BASE_URL`: HTTPS `baseurl`.
45
+ - `GRU_ILINK_TOKEN`: Bot credential; needed for `pull` and `reply`.
46
+ - `GRU_ILINK_ROUTE_TAG`: optional confirmed server route tag.
47
+
48
+ Keep credentials and the database private. The Bot API URL comes from the confirmed association response; `gru.bot` is the product homepage, not the API endpoint. QR pairing is built in; personal WeChat history reading is a separate capability.
49
+
50
+ ## Commands
51
+
52
+ Choose an explicit private database path. `status` and `next` use local state; they require the account, user and base URL, but do not require a token or contact the server. An empty new database is created with owner-only permissions.
53
+
54
+ ```bash
55
+ gru --help
56
+ gru --db /absolute/private/inbox.sqlite status
57
+ gru --db /absolute/private/inbox.sqlite next
58
+ gru --db /absolute/private/inbox.sqlite pull
59
+ gru --db /absolute/private/inbox.sqlite reply 123 'approved reply text'
60
+ ```
61
+
62
+ `pull` makes one network poll and persists raw receipts, inbox messages and the cursor. `reply` makes a real send to the exact associated sender of that inbound message. Run these only for an authorized account and approved content. Missing binding or credentials stop the command with exit code 2.
63
+
64
+ `next` returns a limited JSON projection without reply/media credentials. It does not claim a message for a worker; use one consumer. Unsupported media remains marked as unprocessed.
65
+
66
+ Each reply stores an intent before sending. Accepted, rejected and unknown intents all prevent automatic resend for that inbound ID. `status` lists unresolved intents. An accepted server receipt does not prove recipient-side display. Unknown sends need manual reconciliation, not automatic retries. Do not delete or replace your existing inbox to retry a send.
67
+
68
+ ## License and source
69
+
70
+ MIT. Protocol, headers and text payloads are adapted from Tencent's [openclaw-weixin](https://github.com/Tencent/openclaw-weixin) at commit `24de5c9eb0dd5e595d7e2d090ed8a3f82870d42c`; its copyright and MIT notice are included. The local SQLite persistence and one-shot CLI share the same SQLite schema and message/send-intent rules across the Node npm and Python PyPI distributions.
@@ -0,0 +1,325 @@
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = ["qrcode[pil]==8.2"]
4
+ # ///
5
+ """Bounded WeChat association. No message sending, model, service or auto-refresh."""
6
+ import argparse
7
+ import base64
8
+ from contextlib import contextmanager
9
+ import fcntl
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ import re
14
+ import secrets
15
+ import socket
16
+ import shlex
17
+ import stat
18
+ import tempfile
19
+ import time
20
+ from urllib.parse import urlencode, urlsplit
21
+ from urllib.request import build_opener, Request
22
+ from gru_weixin import Binding, BoundaryError, NoRedirect
23
+
24
+ BASE = 'https://ilinkai.weixin.qq.com'
25
+ FIELDS = {'GRU_ILINK_ACCOUNT_ID': 'ilink_bot_id', 'GRU_ILINK_USER_ID': 'ilink_user_id',
26
+ 'GRU_ILINK_BASE_URL': 'baseurl', 'GRU_ILINK_TOKEN': 'bot_token'}
27
+ STATES = {'wait','scaned','confirmed','expired','scaned_but_redirect','need_verifycode',
28
+ 'verify_code_blocked','binded_redirect'}
29
+
30
+
31
+ def private_path(path, directory=False):
32
+ if path.is_symlink():
33
+ raise BoundaryError('symlink state is not accepted')
34
+ s = path.stat()
35
+ if s.st_uid != os.getuid() or stat.S_IMODE(s.st_mode) & 0o077:
36
+ raise BoundaryError('state must be owner-only')
37
+ if directory != stat.S_ISDIR(s.st_mode):
38
+ raise BoundaryError('unexpected state path type')
39
+
40
+
41
+ def atomic(path, data):
42
+ if path.exists() or path.is_symlink():
43
+ private_path(path)
44
+ fd, name = tempfile.mkstemp(prefix='.association-', dir=path.parent)
45
+ try:
46
+ with os.fdopen(fd, 'wb') as h:
47
+ h.write(data); h.flush(); os.fsync(h.fileno())
48
+ os.replace(name, path)
49
+ d = os.open(path.parent, os.O_RDONLY)
50
+ try: os.fsync(d)
51
+ finally: os.close(d)
52
+ finally:
53
+ if os.path.exists(name): os.unlink(name)
54
+
55
+
56
+ def official_base(value):
57
+ p = urlsplit(value)
58
+ if (p.scheme != 'https' or p.username or p.password or p.port or p.query or p.fragment
59
+ or p.path not in ('', '/') or not p.hostname
60
+ or not (p.hostname == 'weixin.qq.com' or p.hostname.endswith('.weixin.qq.com'))):
61
+ raise BoundaryError('unrecognized official association host; preserve for review')
62
+ return 'https://' + p.hostname
63
+
64
+
65
+ def binding_values(data):
66
+ if not isinstance(data, dict) or any(not isinstance(data.get(v),str) or not data[v]
67
+ or any(c in data[v] for c in '\r\n\x00') for v in FIELDS.values()):
68
+ raise BoundaryError('association fields missing or malformed')
69
+ official_base(data['baseurl'])
70
+ Binding(data['ilink_bot_id'], data['ilink_user_id'], data['baseurl'])
71
+ return {k:data[v] for k,v in FIELDS.items()}
72
+
73
+
74
+ def environment_binding():
75
+ values = {k:os.environ.get(k, '') for k in FIELDS}
76
+ if not any(values.values()): return None
77
+ if not all(values.values()): raise BoundaryError('partial existing binding; no new association')
78
+ binding_values({FIELDS[k]:v for k,v in values.items()})
79
+ return values
80
+
81
+
82
+ @contextmanager
83
+ def profile_fence(profile):
84
+ if profile.is_symlink() or not profile.is_file():
85
+ raise BoundaryError('existing regular shell profile required')
86
+ if profile.stat().st_uid!=os.getuid(): raise BoundaryError('profile owner differs')
87
+ normalized=profile.resolve(strict=True)
88
+ lock=Path(str(normalized)+'.gru-association.fence')
89
+ try: fd=os.open(lock,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600)
90
+ except FileExistsError:
91
+ raise BoundaryError('profile installation is occupied; preserve the lock for review') from None
92
+ nonce=secrets.token_hex(16)
93
+ try:
94
+ os.write(fd,nonce.encode());os.fsync(fd)
95
+ yield normalized
96
+ finally:
97
+ os.close(fd)
98
+ if not lock.is_symlink() and lock.is_file() and lock.read_text()==nonce: lock.unlink()
99
+
100
+
101
+ class Association:
102
+ def __init__(self, root, request=None, now=time.time, renderer=None):
103
+ if not root.is_absolute(): raise BoundaryError('absolute state directory required')
104
+ if not root.exists(): root.mkdir(mode=0o700, parents=True)
105
+ private_path(root, True)
106
+ self.root, self.now = root, now
107
+ self.request = request or self.http
108
+ self.renderer = renderer or self.render
109
+
110
+ @contextmanager
111
+ def locked(self):
112
+ p=self.root/'association.lock'
113
+ fd=os.open(p,os.O_CREAT|os.O_RDWR|os.O_NOFOLLOW,0o600)
114
+ try:
115
+ private_path(p)
116
+ fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)
117
+ with self.fenced():
118
+ yield
119
+ finally: os.close(fd)
120
+
121
+ @contextmanager
122
+ def fenced(self):
123
+ # Shared with the Node CLI; preserve the legacy flock for Python clients.
124
+ gate=self.root/'association.fence.acquire'
125
+ lock=self.root/'association.fence'
126
+ gate_fd=os.open(gate,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600)
127
+ nonce=secrets.token_hex(16)
128
+ try:
129
+ if lock.exists():
130
+ private_path(lock)
131
+ owner=json.loads(lock.read_text())
132
+ if (owner.get('host')!=socket.gethostname() or type(owner.get('pid')) is not int
133
+ or owner['pid']<1 or not isinstance(owner.get('nonce'),str)):
134
+ raise BoundaryError('unrecognized association lock; preserve for review')
135
+ try: os.kill(owner['pid'],0)
136
+ except ProcessLookupError: lock.unlink()
137
+ else: raise BoundaryError('association is occupied')
138
+ fd=os.open(lock,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600)
139
+ with os.fdopen(fd,'w') as h:
140
+ json.dump({'pid':os.getpid(),'host':socket.gethostname(),'nonce':nonce},h)
141
+ h.flush();os.fsync(h.fileno())
142
+ finally:
143
+ os.close(gate_fd);gate.unlink()
144
+ try: yield
145
+ finally:
146
+ private_path(lock)
147
+ owner=json.loads(lock.read_text())
148
+ if owner.get('nonce')==nonce: lock.unlink()
149
+
150
+ def load(self):
151
+ p=self.root/'session.json'
152
+ if not p.exists(): return None
153
+ private_path(p)
154
+ s=json.loads(p.read_text())
155
+ if (not isinstance(s,dict) or s.get('schema')!=1
156
+ or s.get('status') not in STATES|{'request_unknown'}):
157
+ raise BoundaryError('unknown association state')
158
+ official_base(s['base_url'])
159
+ return s
160
+
161
+ def save(self,s): atomic(self.root/'session.json',json.dumps(s).encode())
162
+
163
+ def http(self,method,base,endpoint,body=None):
164
+ base=official_base(base)
165
+ headers={'iLink-App-Id':'bot','iLink-App-ClientVersion':str((2<<16)|(4<<8)|9)}
166
+ if method=='POST':
167
+ headers.update({'Content-Type':'application/json','AuthorizationType':'ilink_bot_token',
168
+ 'X-WECHAT-UIN':base64.b64encode(str(secrets.randbits(32)).encode()).decode()})
169
+ r=Request(base+'/'+endpoint, None if body is None else json.dumps(body).encode(),headers,method=method)
170
+ with build_opener(NoRedirect).open(r,timeout=36 if method=='GET' else 20) as response:
171
+ data=json.loads(response.read())
172
+ if not isinstance(data,dict): raise BoundaryError('invalid association response')
173
+ return data
174
+
175
+ def render(self,text):
176
+ import io,qrcode
177
+ h=io.BytesIO();qrcode.make(text).save(h,format='PNG')
178
+ atomic(self.root/'qr.png',h.getvalue())
179
+
180
+ def inspect(self,s=None):
181
+ s=self.load() if s is None else s
182
+ if not s: return {'state':'not_started','message_sent':False}
183
+ status=s['status']
184
+ if status=='confirmed':
185
+ binding_values(s['binding'])
186
+ return {'state':'confirmed','binding_complete':True,'message_sent':False}
187
+ if status in ('wait','scaned','need_verifycode','scaned_but_redirect') and self.now()>=s['local_expires_at']:
188
+ status='local_expired'
189
+ result={'state':status,'message_sent':False}
190
+ if status in ('wait','scaned','need_verifycode','scaned_but_redirect'):
191
+ result.update(qr_image=str(self.root/'qr.png'),local_expires_at=s['local_expires_at'])
192
+ return result
193
+
194
+ def start(self,refresh=False):
195
+ env=environment_binding()
196
+ if env: return {'state':'existing_environment_binding','binding_complete':True,'message_sent':False}
197
+ s=self.load()
198
+ if s:
199
+ state=self.inspect(s)['state']
200
+ if state=='confirmed': return self.inspect(s)
201
+ if state not in ('expired','local_expired'): return self.inspect(s)
202
+ if not refresh: return self.inspect(s)
203
+ # Keep prior session bytes; refreshing requires an observed expiry.
204
+ backup=self.root/('expired-'+secrets.token_hex(8)+'.json')
205
+ atomic(backup,json.dumps(s).encode())
206
+ s={'schema':1,'status':'request_unknown','base_url':BASE,'created_at':self.now()}
207
+ self.save(s) # unknown request is not automatically repeated after interruption
208
+ d=self.request('POST',BASE,'ilink/bot/get_bot_qrcode?bot_type=3',{'local_token_list':[]})
209
+ if any(not isinstance(d.get(k),str) or not d[k] for k in ('qrcode','qrcode_img_content')):
210
+ raise BoundaryError('missing QR fields; request remains unknown')
211
+ s.update(status='wait',qrcode=d['qrcode'],qr_content=d['qrcode_img_content'],local_expires_at=self.now()+300)
212
+ self.save(s)
213
+ self.renderer(s['qr_content'])
214
+ return self.inspect(s)
215
+
216
+ def poll(self,code=None):
217
+ s=self.load()
218
+ state=self.inspect(s)['state']
219
+ if state in ('not_started','confirmed','expired','local_expired','request_unknown',
220
+ 'verify_code_blocked','binded_redirect'): return self.inspect(s)
221
+ if code is not None and (state!='need_verifycode' or not re.fullmatch('[0-9]{1,16}',code)):
222
+ raise BoundaryError('pairing code only accepted at need_verifycode')
223
+ params={'qrcode':s['qrcode']}
224
+ if code: params['verify_code']=code
225
+ d=self.request('GET',s['base_url'],'ilink/bot/get_qrcode_status?'+urlencode(params))
226
+ status=d.get('status')
227
+ if status not in STATES: raise BoundaryError('unrecognized association status')
228
+ if status=='scaned_but_redirect':
229
+ host=d.get('redirect_host')
230
+ if not isinstance(host,str): raise BoundaryError('missing redirect host')
231
+ s['base_url']=official_base('https://'+host)
232
+ if status=='confirmed':
233
+ binding_values(d)
234
+ s['binding']={v:d[v] for v in FIELDS.values()}
235
+ s['status']=status;self.save(s)
236
+ return self.inspect(s)
237
+
238
+ def render_existing(self):
239
+ s=self.load()
240
+ if self.inspect(s)['state'] not in ('wait','scaned','need_verifycode','scaned_but_redirect'):
241
+ raise BoundaryError('no live QR to render')
242
+ self.renderer(s['qr_content']);return self.inspect(s)
243
+
244
+ def install(self,profile):
245
+ with profile_fence(profile) as normalized:
246
+ return self.install_fenced(normalized)
247
+
248
+ def install_fenced(self,profile):
249
+ s=self.load()
250
+ if self.inspect(s)['state']!='confirmed': raise BoundaryError('confirmed association required')
251
+ wanted=binding_values(s['binding'])
252
+ if profile.is_symlink() or not profile.is_file(): raise BoundaryError('existing regular shell profile required')
253
+ with profile.open('r+') as h:
254
+ if os.fstat(h.fileno()).st_uid!=os.getuid(): raise BoundaryError('profile owner differs')
255
+ fcntl.flock(h.fileno(),fcntl.LOCK_EX|fcntl.LOCK_NB)
256
+ raw=h.read()
257
+ # Never overwrite an existing declaration (including dynamic configuration).
258
+ # A general shell profile can construct names. Only literal profiles
259
+ # are appendable; unknown syntax never means absence of a binding.
260
+ declared={}
261
+ for line in raw.splitlines():
262
+ if not line.strip() or line.lstrip().startswith('#'): continue
263
+ if any(c in line for c in ('$','`','\\')):
264
+ raise BoundaryError('nonliteral profile requires its configuration owner; unchanged')
265
+ lexer=shlex.shlex(line,posix=True,punctuation_chars='();<>|&')
266
+ lexer.whitespace_split=True
267
+ words=list(lexer)
268
+ words=words[1:] if words and words[0]=='export' else words
269
+ if not words or any(not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*=.*',w) for w in words):
270
+ raise BoundaryError('unsupported shell profile; unchanged')
271
+ for word in words:
272
+ k,v=word.split('=',1)
273
+ if k in FIELDS:
274
+ if k in declared:
275
+ raise BoundaryError('duplicate profile binding; unchanged')
276
+ declared[k]=v
277
+ if declared:
278
+ if declared==wanted and environment_binding()==wanted:
279
+ return {'state':'existing_matching_profile','profile_changed':False,'message_sent':False}
280
+ raise BoundaryError('existing profile binding must be reconciled; unchanged')
281
+ if environment_binding() not in (None,wanted): raise BoundaryError('environment binding differs')
282
+ h.seek(0,os.SEEK_END)
283
+ h.write('\n# Gru WeChat association\n'+''.join('export '+k+'='+shlex.quote(v)+'\n' for k,v in wanted.items()))
284
+ h.flush();os.fsync(h.fileno())
285
+ return {'state':'profile_appended','profile_changed':True,'message_sent':False}
286
+
287
+
288
+ def main():
289
+ p=argparse.ArgumentParser(description=__doc__)
290
+ p.add_argument('--state-dir',type=Path,required=True)
291
+ c=p.add_subparsers(dest='command',required=True)
292
+ c.add_parser('inspect',help='local state only; no network')
293
+ start=c.add_parser('start',help='reuse binding/live QR, otherwise request once');start.add_argument('--refresh',action='store_true')
294
+ poll=c.add_parser('poll',help='one bounded official status request');poll.add_argument('--code-file',type=Path)
295
+ c.add_parser('render',help='render existing live QR without requesting another')
296
+ ins=c.add_parser('install',help='append to literal profiles only; refuse general shell or conflicting bindings')
297
+ ins.add_argument('--profile',type=Path,default=Path.home()/'.zprofile')
298
+ a=p.parse_args()
299
+ try:
300
+ x=Association(a.state_dir)
301
+ with x.locked():
302
+ if a.command=='start': r=x.start(a.refresh)
303
+ elif a.command=='inspect': r=x.inspect()
304
+ elif a.command=='render': r=x.render_existing()
305
+ elif a.command=='install': r=x.install(a.profile)
306
+ else:
307
+ code=None
308
+ if a.code_file:
309
+ private_path(a.code_file);code=a.code_file.read_text().strip()
310
+ r=x.poll(code)
311
+ print(json.dumps(r))
312
+ return 2 if r['state'] in ('request_unknown','not_started','verify_code_blocked','binded_redirect') else 0
313
+ except Exception as e:
314
+ # URL, QR nonce, pairing digits, server body and credentials never enter stdout/stderr.
315
+ result={'state':'failed','error_type':type(e).__name__,'message_sent':False}
316
+ if isinstance(e,BoundaryError):
317
+ if str(e)=='profile installation is occupied; preserve the lock for review':
318
+ result['reason']='profile_install_occupied'
319
+ elif str(e) in ('existing profile binding must be reconciled; unchanged',
320
+ 'duplicate profile binding; unchanged','environment binding differs'):
321
+ result['reason']='profile_binding_conflict'
322
+ print(json.dumps(result))
323
+ return 2
324
+
325
+ if __name__=='__main__': raise SystemExit(main())
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: gru-bot-cli
3
+ Version: 0.1.0
4
+ Summary: Gru CLI with built-in WeChat Bot connection and automatic Skill synchronization
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://gru.bot
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ License-File: NOTICE-TENCENT
11
+ Requires-Dist: gru-bot-skills==0.1.0
12
+ Requires-Dist: qrcode[pil]==8.2
13
+ Dynamic: license-file
14
+
15
+ # Gru CLI
16
+
17
+ `@gru-bot/cli` and `gru-bot` provide one-shot WeChat iLink text transport and a durable local SQLite inbox. It can pull inbound messages, show the earliest message without a reply intent, report inbox and send-intent state, and reply once to a specific inbound message.
18
+
19
+ This release is the text transport component of [Gru](https://gru.bot). It does not run an Agent, schedule background work, read personal WeChat history, or process media. The npm CLI runs on Node.js 24+ and requires no Python or native addon compilation. The PyPI CLI runs on Python 3.11+ without Node.js. Both include WeChat Bot QR association and the Gru Skill.
20
+
21
+ ## Install
22
+
23
+ Choose either npm name; they provide the same `gru` command and functionality:
24
+
25
+ ```bash
26
+ npm install -g @gru-bot/cli
27
+ # Alternative name:
28
+ npm install -g gru-bot
29
+ ```
30
+
31
+ Use one global installation at a time because both provide `gru`. The Gru Skill is bundled and installed automatically for detected Agent hosts; installation failures are reported. A host that appears later is handled on the next CLI invocation. `gru skills status` reports the actual state, including deferred installation when no supported host is found. `gru skills disable` and `gru skills remove` stop automatic restoration.
32
+
33
+ For Python, choose `pip install gru-bot-cli` or the equivalent `pip install gru-bot`. Both provide `gru`; install one alternative at a time. pip has no postinstall hook, so the first CLI invocation syncs the bundled Skill. The Python QR renderer uses the declared `qrcode[pil]==8.2` dependency. The standalone Skill packages are `@gru-bot/skills` on npm and `gru-bot-skills` on PyPI. Personal WeChat history reading is provided separately by `@gru-bot/wx` or `gru-bot-wx`, using an explicitly configured existing local reader.
34
+
35
+ ## WeChat Bot association
36
+
37
+ Both CLI distributions include the default Bot connection. Use an explicit private directory and follow bounded steps:
38
+
39
+ ```bash
40
+ gru bot --state-dir /absolute/private/association inspect
41
+ gru bot --state-dir /absolute/private/association start
42
+ gru bot --state-dir /absolute/private/association poll
43
+ gru bot --state-dir /absolute/private/association install
44
+ ```
45
+
46
+ `start` reuses a complete binding or live QR. When it returns a new private `qr_image` file, open that image and scan it in the authorized account's WeChat. `poll` makes one status request. Expired QR codes stop; `start --refresh` requests a replacement only after expiry. For `need_verifycode`, put the phone's pairing digits in an owner-only file and use `poll --code-file /absolute/private/code.txt`. `render` recreates the same live QR after interruption.
47
+
48
+ `install` is an explicit configuration command: it appends confirmed exports to an existing literal `.zprofile`, or the file selected with `--profile`, and refuses conflicting or dynamic shell configuration. Open a fresh login shell afterwards. Package installation and ordinary CLI startup do not pair accounts, send messages or write credentials.
49
+
50
+ Association state and profile installation currently require POSIX owner permissions (macOS/Linux). Unsupported ownership checks fail explicitly. Updated Python and Node association commands share a fence; an older Python association command does not participate, so use one association implementation at a time when continuing older trials. A lock left during acquisition remains explicit for manual reconciliation.
51
+
52
+ ## Configuration
53
+
54
+ Use an existing authorized WeChat Bot association. Supply the exact server-returned account ID, user ID, HTTPS base URL, and credential through your shell environment:
55
+
56
+ - `GRU_ILINK_ACCOUNT_ID`: `ilink_bot_id`.
57
+ - `GRU_ILINK_USER_ID`: `ilink_user_id`.
58
+ - `GRU_ILINK_BASE_URL`: HTTPS `baseurl`.
59
+ - `GRU_ILINK_TOKEN`: Bot credential; needed for `pull` and `reply`.
60
+ - `GRU_ILINK_ROUTE_TAG`: optional confirmed server route tag.
61
+
62
+ Keep credentials and the database private. The Bot API URL comes from the confirmed association response; `gru.bot` is the product homepage, not the API endpoint. QR pairing is built in; personal WeChat history reading is a separate capability.
63
+
64
+ ## Commands
65
+
66
+ Choose an explicit private database path. `status` and `next` use local state; they require the account, user and base URL, but do not require a token or contact the server. An empty new database is created with owner-only permissions.
67
+
68
+ ```bash
69
+ gru --help
70
+ gru --db /absolute/private/inbox.sqlite status
71
+ gru --db /absolute/private/inbox.sqlite next
72
+ gru --db /absolute/private/inbox.sqlite pull
73
+ gru --db /absolute/private/inbox.sqlite reply 123 'approved reply text'
74
+ ```
75
+
76
+ `pull` makes one network poll and persists raw receipts, inbox messages and the cursor. `reply` makes a real send to the exact associated sender of that inbound message. Run these only for an authorized account and approved content. Missing binding or credentials stop the command with exit code 2.
77
+
78
+ `next` returns a limited JSON projection without reply/media credentials. It does not claim a message for a worker; use one consumer. Unsupported media remains marked as unprocessed.
79
+
80
+ Each reply stores an intent before sending. Accepted, rejected and unknown intents all prevent automatic resend for that inbound ID. `status` lists unresolved intents. An accepted server receipt does not prove recipient-side display. Unknown sends need manual reconciliation, not automatic retries. Do not delete or replace your existing inbox to retry a send.
81
+
82
+ ## License and source
83
+
84
+ MIT. Protocol, headers and text payloads are adapted from Tencent's [openclaw-weixin](https://github.com/Tencent/openclaw-weixin) at commit `24de5c9eb0dd5e595d7e2d090ed8a3f82870d42c`; its copyright and MIT notice are included. The local SQLite persistence and one-shot CLI share the same SQLite schema and message/send-intent rules across the Node npm and Python PyPI distributions.
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ NOTICE-TENCENT
4
+ README.md
5
+ gru_associate.py
6
+ gru_bot_cli.py
7
+ gru_weixin.py
8
+ pyproject.toml
9
+ gru_bot_cli.egg-info/PKG-INFO
10
+ gru_bot_cli.egg-info/SOURCES.txt
11
+ gru_bot_cli.egg-info/dependency_links.txt
12
+ gru_bot_cli.egg-info/entry_points.txt
13
+ gru_bot_cli.egg-info/requires.txt
14
+ gru_bot_cli.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gru = gru_bot_cli:main
@@ -0,0 +1,2 @@
1
+ gru-bot-skills==0.1.0
2
+ qrcode[pil]==8.2
@@ -0,0 +1,3 @@
1
+ gru_associate
2
+ gru_bot_cli
3
+ gru_weixin
@@ -0,0 +1,57 @@
1
+ """Gru CLI facade over the canonical Bot transport and association modules."""
2
+ import json
3
+ import sys
4
+
5
+ HELP = """Gru CLI — built-in WeChat Bot text transport
6
+ Usage: gru --db PATH {status|next|pull|reply MESSAGE_ID TEXT}
7
+ --db PATH explicit private SQLite inbox
8
+ status show local counts and unresolved send intents
9
+ next read earliest inbound without a reply intent; does not claim it
10
+ pull one server poll; persist inbox and cursor atomically
11
+ reply send once to the named inbound's exact associated sender
12
+ bot built-in QR association: gru bot --help
13
+ skills install/status/disable/remove the Gru Skill
14
+ --help show this help
15
+ """
16
+
17
+
18
+ def main(args=None):
19
+ args = list(sys.argv[1:] if args is None else args)
20
+ import gru_bot_skills
21
+ if args and args[0] == "skills":
22
+ if args[1:] in (["--help"], ["-h"]):
23
+ print("Usage: gru skills [install|sync|status|remove|disable|enable]")
24
+ return 0
25
+ try:
26
+ print(json.dumps(gru_bot_skills.main(args[1:])))
27
+ return 0
28
+ except (OSError, ValueError, RuntimeError) as error:
29
+ print(f"[gru skills] {error}", file=sys.stderr)
30
+ return 2
31
+ try:
32
+ gru_bot_skills.startup_sync()
33
+ except (OSError, ValueError, RuntimeError) as error:
34
+ print(f"[gru skills] {error}", file=sys.stderr)
35
+ return 2
36
+ if not args or args in (["--help"], ["-h"]):
37
+ print(HELP)
38
+ return 0
39
+ previous = sys.argv
40
+ try:
41
+ if args[0] == "bot":
42
+ sys.argv = [previous[0] + " bot", *args[1:]]
43
+ try:
44
+ import gru_associate
45
+ except ImportError:
46
+ print(json.dumps({"state": "failed", "error_type": "UnsupportedPlatform", "message_sent": False}))
47
+ return 2
48
+ return gru_associate.main()
49
+ sys.argv = [previous[0], *args]
50
+ import gru_weixin
51
+ return gru_weixin.main()
52
+ finally:
53
+ sys.argv = previous
54
+
55
+
56
+ if __name__ == "__main__":
57
+ raise SystemExit(main())
@@ -0,0 +1,337 @@
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = []
4
+ # ///
5
+ """One-shot iLink transport. No login, model invocation, scheduler or notification."""
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import base64
10
+ from dataclasses import dataclass
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import secrets
15
+ import sqlite3
16
+ from typing import Callable
17
+ from urllib.error import HTTPError, URLError
18
+ from urllib.parse import urlsplit
19
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
20
+ import uuid
21
+
22
+
23
+ class BoundaryError(ValueError):
24
+ """Missing or conflicting binding; no network request should be made."""
25
+
26
+
27
+ def json_text(value):
28
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
29
+
30
+
31
+ def wire_id(value):
32
+ # Python's JSON integers are arbitrary precision; floats are never accepted.
33
+ if isinstance(value, bool) or not isinstance(value, (str, int)):
34
+ raise BoundaryError("message_id must be a uint64 string or integer")
35
+ value = str(value)
36
+ if not value.isascii() or not value.isdecimal() or not 0 <= int(value) < 2**64:
37
+ raise BoundaryError("message_id must be uint64")
38
+ return str(int(value))
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Binding:
43
+ account: str # exact ilink_bot_id returned by the authorized association
44
+ user: str # exact ilink_user_id returned by that association
45
+ base_url: str
46
+
47
+ def __post_init__(self):
48
+ parts = urlsplit(self.base_url)
49
+ if (not self.account or not self.user or parts.scheme != "https" or not parts.netloc
50
+ or parts.username or parts.password or parts.query or parts.fragment):
51
+ raise BoundaryError("require exact server account/user IDs and an HTTPS base URL")
52
+
53
+ @classmethod
54
+ def from_env(cls):
55
+ keys = ("GRU_ILINK_ACCOUNT_ID", "GRU_ILINK_USER_ID", "GRU_ILINK_BASE_URL")
56
+ values = [os.environ.get(key, "") for key in keys]
57
+ if not all(values):
58
+ raise BoundaryError("missing binding environment: " + ", ".join(keys))
59
+ return cls(values[0], values[1], values[2].rstrip("/") + "/")
60
+
61
+
62
+ class NoRedirect(HTTPRedirectHandler):
63
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
64
+ return None # never forward the bearer credential to a redirected target
65
+
66
+
67
+ class Transport:
68
+ def __init__(self, binding: Binding, token: str, open_request: Callable | None = None):
69
+ if not token.strip():
70
+ raise BoundaryError("missing GRU_ILINK_TOKEN")
71
+ self.binding, self.token = binding, token.strip()
72
+ self.open_request = open_request or build_opener(NoRedirect).open
73
+
74
+ def post(self, endpoint: str, body: dict, timeout: int):
75
+ # Header and message shapes adapted from the pinned Tencent implementation.
76
+ body = dict(body, base_info={"channel_version": "2.4.9", "bot_agent": "GruPrototype/0.1"})
77
+ headers = {
78
+ "Content-Type": "application/json", "AuthorizationType": "ilink_bot_token",
79
+ "Authorization": "Bearer " + self.token,
80
+ "X-WECHAT-UIN": base64.b64encode(str(secrets.randbits(32)).encode()).decode(),
81
+ "iLink-App-Id": "bot", "iLink-App-ClientVersion": str((2 << 16) | (4 << 8) | 9),
82
+ }
83
+ route = os.environ.get("GRU_ILINK_ROUTE_TAG")
84
+ if route:
85
+ headers["SKRouteTag"] = route
86
+ req = Request(self.binding.base_url.rstrip("/") + "/" + endpoint,
87
+ json_text(body).encode(), headers, method="POST")
88
+ with self.open_request(req, timeout=timeout) as response:
89
+ data = json.loads(response.read().decode())
90
+ if not isinstance(data, dict):
91
+ raise BoundaryError("response must be an object")
92
+ return data
93
+
94
+
95
+ def response_state(response):
96
+ if not isinstance(response, dict):
97
+ return "unknown"
98
+ for key in ("ret", "errcode"):
99
+ value = response.get(key)
100
+ if isinstance(value, int) and not isinstance(value, bool) and value != 0:
101
+ return "rejected"
102
+ if type(response.get("ret")) is int and response["ret"] == 0:
103
+ if "errcode" not in response or type(response["errcode"]) is int and response["errcode"] == 0:
104
+ return "accepted"
105
+ return "unknown"
106
+
107
+
108
+ class Inbox:
109
+ def __init__(self, path: Path, binding: Binding):
110
+ self.binding = binding
111
+ missing = []
112
+ directory = path.parent
113
+ while not directory.exists():
114
+ missing.append(directory)
115
+ directory = directory.parent
116
+ for directory in reversed(missing):
117
+ directory.mkdir(mode=0o700, exist_ok=True)
118
+ try:
119
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
120
+ except FileExistsError:
121
+ pass # preserve permissions of existing user-selected state
122
+ else:
123
+ os.close(fd)
124
+ self.db = sqlite3.connect(path, timeout=10)
125
+ self.db.row_factory = sqlite3.Row
126
+ self.db.execute("PRAGMA journal_mode=WAL")
127
+ self.db.execute("PRAGMA synchronous=FULL")
128
+ self.db.executescript("""
129
+ CREATE TABLE IF NOT EXISTS accounts (
130
+ account TEXT PRIMARY KEY, user TEXT NOT NULL, base_url TEXT NOT NULL, cursor TEXT NOT NULL);
131
+ CREATE TABLE IF NOT EXISTS inbox (
132
+ account TEXT NOT NULL, message_id TEXT NOT NULL, sender TEXT NOT NULL,
133
+ context_token TEXT, raw TEXT NOT NULL, ordinal INTEGER PRIMARY KEY AUTOINCREMENT,
134
+ UNIQUE(account, message_id));
135
+ CREATE TABLE IF NOT EXISTS replies (
136
+ account TEXT NOT NULL, message_id TEXT NOT NULL, client_id TEXT NOT NULL,
137
+ request TEXT NOT NULL, state TEXT NOT NULL, response TEXT,
138
+ PRIMARY KEY(account, message_id));
139
+ CREATE TABLE IF NOT EXISTS pull_receipts (
140
+ receipt_id TEXT PRIMARY KEY, account TEXT NOT NULL,
141
+ expected_cursor TEXT NOT NULL, raw TEXT NOT NULL, applied INTEGER NOT NULL);
142
+ """)
143
+ with self.db:
144
+ self.db.execute("INSERT OR IGNORE INTO accounts VALUES (?, ?, ?, '')",
145
+ (binding.account, binding.user, binding.base_url))
146
+ row = self.db.execute("SELECT * FROM accounts WHERE account=?", (binding.account,)).fetchone()
147
+ if row["user"] != binding.user or row["base_url"] != binding.base_url:
148
+ raise BoundaryError("account binding conflicts with persisted server identity")
149
+
150
+ def close(self):
151
+ self.db.close()
152
+
153
+ def cursor(self):
154
+ return self.db.execute("SELECT cursor FROM accounts WHERE account=?",
155
+ (self.binding.account,)).fetchone()[0]
156
+
157
+ def ingest(self, response, expected_cursor):
158
+ # Retain the actual server receipt even when subsequent validation fails.
159
+ # A new empty cursor is not proof the server will replay an earlier response.
160
+ receipt_id = uuid.uuid4().hex
161
+ with self.db:
162
+ self.db.execute("INSERT INTO pull_receipts VALUES(?,?,?,?,0)",
163
+ (receipt_id, self.binding.account, expected_cursor, json_text(response)))
164
+ state = response_state(response)
165
+ # Official GetUpdates omits zero-valued status fields. Require positive
166
+ # payload evidence; absent/malformed status alone is never success.
167
+ if (state == "unknown" and isinstance(response, dict)
168
+ and "ret" not in response and "errcode" not in response
169
+ and isinstance(response.get("msgs"), list)
170
+ and isinstance(response.get("get_updates_buf"), str)
171
+ and response["get_updates_buf"]):
172
+ state = "accepted"
173
+ if state != "accepted":
174
+ raise BoundaryError("pull rejected or unconfirmed; cursor unchanged")
175
+ msgs, returned_cursor = response.get("msgs", []), response.get("get_updates_buf")
176
+ if not isinstance(msgs, list) or returned_cursor is not None and not isinstance(returned_cursor, str):
177
+ raise BoundaryError("pull requires a message list and opaque string cursor")
178
+ # Upstream monitor retains its previous cursor for omitted/empty buffers.
179
+ new_cursor = returned_cursor or expected_cursor
180
+ # BEGIN IMMEDIATE plus compare prevents concurrent pulls overwriting newer cursors.
181
+ try:
182
+ self.db.execute("BEGIN IMMEDIATE")
183
+ if self.cursor() != expected_cursor:
184
+ raise BoundaryError("cursor changed during pull; discard stale response")
185
+ added = 0
186
+ for msg in msgs:
187
+ if not isinstance(msg, dict):
188
+ raise BoundaryError("invalid message object")
189
+ mid = wire_id(msg.get("message_id"))
190
+ if (msg.get("from_user_id") != self.binding.user
191
+ or msg.get("to_user_id") != self.binding.account
192
+ or msg.get("message_type") != 1 or msg.get("group_id")):
193
+ raise BoundaryError("inbound is not the exact associated account/user direct message")
194
+ token = msg.get("context_token")
195
+ if token is not None and not isinstance(token, str):
196
+ raise BoundaryError("invalid context_token")
197
+ raw = json_text(msg)
198
+ old = self.db.execute("SELECT raw FROM inbox WHERE account=? AND message_id=?",
199
+ (self.binding.account, mid)).fetchone()
200
+ if old and old[0] != raw:
201
+ raise BoundaryError("same message ID has changed payload; manual reconciliation required")
202
+ if not old:
203
+ self.db.execute("INSERT INTO inbox(account,message_id,sender,context_token,raw) VALUES(?,?,?,?,?)",
204
+ (self.binding.account, mid, msg["from_user_id"], token, raw))
205
+ added += 1
206
+ self.db.execute("UPDATE accounts SET cursor=? WHERE account=?", (new_cursor, self.binding.account))
207
+ self.db.execute("UPDATE pull_receipts SET applied=1 WHERE receipt_id=?", (receipt_id,))
208
+ self.db.commit()
209
+ return {"state": "stored", "inserted": added}
210
+ except BaseException:
211
+ self.db.rollback()
212
+ raise
213
+
214
+ def next(self):
215
+ row = self.db.execute("""SELECT i.* FROM inbox i LEFT JOIN replies r
216
+ ON i.account=r.account AND i.message_id=r.message_id
217
+ WHERE i.account=? AND r.message_id IS NULL ORDER BY i.ordinal LIMIT 1""",
218
+ (self.binding.account,)).fetchone()
219
+ if not row:
220
+ return None
221
+ raw = json.loads(row["raw"])
222
+ items = raw.get("item_list", [])
223
+ texts = [item["text_item"]["text"] for item in items if isinstance(item, dict)
224
+ and item.get("type") == 1 and isinstance(item.get("text_item"), dict)
225
+ and isinstance(item["text_item"].get("text"), str)] if isinstance(items, list) else []
226
+ names = {0: "none", 1: "text", 2: "image", 3: "voice", 4: "file", 5: "video",
227
+ 11: "tool_call_start", 12: "tool_call_result"}
228
+ types = [names.get(item.get("type"), "unknown")
229
+ if isinstance(item, dict) and type(item.get("type")) is int else "unknown"
230
+ for item in items] if isinstance(items, list) else ["unknown"]
231
+ # Consumption projection is a whitelist; raw reply/media credentials stay in SQLite.
232
+ return {"account": row["account"], "message_id": row["message_id"], "sender": row["sender"],
233
+ "text": "\n".join(texts), "item_types": types, "media_processed": False}
234
+
235
+ def reply(self, transport: Transport, message_id: str, text: str):
236
+ if transport.binding != self.binding:
237
+ raise BoundaryError("transport and inbox binding differ")
238
+ mid = wire_id(message_id)
239
+ if not text or not text.strip():
240
+ raise BoundaryError("reply text must be nonempty")
241
+ try:
242
+ self.db.execute("BEGIN IMMEDIATE")
243
+ existing = self.db.execute("SELECT state FROM replies WHERE account=? AND message_id=?",
244
+ (self.binding.account, mid)).fetchone()
245
+ if existing:
246
+ self.db.rollback()
247
+ return {"state": existing[0], "attempted": False, "message_id": mid}
248
+ inbound = self.db.execute("SELECT * FROM inbox WHERE account=? AND message_id=?",
249
+ (self.binding.account, mid)).fetchone()
250
+ if not inbound or inbound["sender"] != self.binding.user or not inbound["context_token"]:
251
+ raise BoundaryError("no bound inbound message with a usable context_token")
252
+ body = {"msg": {"from_user_id": "", "to_user_id": inbound["sender"],
253
+ "client_id": "gru-" + uuid.uuid4().hex, "message_type": 2, "message_state": 2,
254
+ "item_list": [{"type": 1, "text_item": {"text": text}}],
255
+ "context_token": inbound["context_token"]}}
256
+ self.db.execute("INSERT INTO replies VALUES(?,?,?,?,?,?)",
257
+ (self.binding.account, mid, body["msg"]["client_id"], json_text(body),
258
+ "unknown", None))
259
+ self.db.commit() # durable intent precedes any network side effect
260
+ except BaseException:
261
+ self.db.rollback()
262
+ raise
263
+ state, result = "unknown", {"reason": "transport_failure_or_unconfirmed_receipt"}
264
+ try:
265
+ response = transport.post("ilink/bot/sendmessage", body, timeout=15)
266
+ state = response_state(response)
267
+ result = {"ret": response.get("ret"), "errcode": response.get("errcode")}
268
+ if "message_id" in response:
269
+ result["message_id"] = wire_id(response["message_id"])
270
+ except HTTPError as err:
271
+ state = "rejected" if err.code in (400, 401, 403, 404, 405, 422) else "unknown"
272
+ result = {"http_status": err.code}
273
+ except (URLError, TimeoutError, OSError, ValueError):
274
+ state = "unknown" # malformed or precision-unsafe receipt is not success
275
+ try:
276
+ with self.db:
277
+ self.db.execute("UPDATE replies SET state=?,response=? WHERE account=? AND message_id=?",
278
+ (state, json_text(result), self.binding.account, mid))
279
+ except sqlite3.Error:
280
+ state = "unknown" # durable pre-send intent remains; never automatically resend
281
+ return {"state": state, "attempted": True, "message_id": mid}
282
+
283
+ def status(self):
284
+ counts = dict(self.db.execute("SELECT state,count(*) FROM replies WHERE account=? GROUP BY state",
285
+ (self.binding.account,)).fetchall())
286
+ intents = [dict(row) for row in self.db.execute(
287
+ "SELECT message_id,client_id,state FROM replies WHERE account=? AND state!=?",
288
+ (self.binding.account, "accepted"))]
289
+ return {"account": self.binding.account, "cursor_present": bool(self.cursor()),
290
+ "inbox": self.db.execute("SELECT count(*) FROM inbox WHERE account=?",
291
+ (self.binding.account,)).fetchone()[0], "replies": counts,
292
+ "unresolved_intents": intents}
293
+
294
+
295
+ def main():
296
+ parser = argparse.ArgumentParser(description=__doc__)
297
+ parser.add_argument("--db", type=Path, required=True, help="explicit prototype SQLite file")
298
+ commands = parser.add_subparsers(dest="command", required=True)
299
+ commands.add_parser("pull", help="one long-poll; preserve inbox and cursor atomically")
300
+ commands.add_parser("next", help="read earliest inbound without a reply intent; does not claim it")
301
+ commands.add_parser("status", help="show local counts, including unknown send intents")
302
+ reply = commands.add_parser("reply", help="send once, using the named inbound's exact sender/token")
303
+ reply.add_argument("message_id")
304
+ reply.add_argument("text")
305
+ args = parser.parse_args()
306
+ inbox = None
307
+ try:
308
+ binding = Binding.from_env()
309
+ transport = None
310
+ if args.command in ("pull", "reply"):
311
+ transport = Transport(binding, os.environ.get("GRU_ILINK_TOKEN", ""))
312
+ inbox = Inbox(args.db, binding)
313
+ if args.command == "pull":
314
+ cursor = inbox.cursor()
315
+ result = inbox.ingest(transport.post("ilink/bot/getupdates", {"get_updates_buf": cursor}, 35), cursor)
316
+ elif args.command == "next":
317
+ result = inbox.next()
318
+ elif args.command == "reply":
319
+ result = inbox.reply(transport, args.message_id, args.text)
320
+ else:
321
+ result = inbox.status()
322
+ print(json_text(result))
323
+ return 2 if isinstance(result, dict) and result.get("state") in ("unknown", "rejected") else 0
324
+ except (ValueError, URLError, TimeoutError, OSError, sqlite3.Error) as err:
325
+ # Do not print exceptions that may embed credential-bearing URLs/responses.
326
+ result = {"state": "failed", "error_type": type(err).__name__}
327
+ if isinstance(err, BoundaryError):
328
+ result["reason"] = str(err)
329
+ print(json_text(result))
330
+ return 2
331
+ finally:
332
+ if inbox:
333
+ inbox.close()
334
+
335
+
336
+ if __name__ == "__main__":
337
+ raise SystemExit(main())
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+ [project]
5
+ name = "gru-bot-cli"
6
+ version = "0.1.0"
7
+ description = "Gru CLI with built-in WeChat Bot connection and automatic Skill synchronization"
8
+ readme = "README.md"
9
+ requires-python = ">=3.11"
10
+ license = "MIT"
11
+ license-files = ["LICENSE", "NOTICE-TENCENT"]
12
+ dependencies = ["gru-bot-skills==0.1.0", "qrcode[pil]==8.2"]
13
+ [project.urls]
14
+ Homepage = "https://gru.bot"
15
+ [project.scripts]
16
+ gru = "gru_bot_cli:main"
17
+ [tool.setuptools]
18
+ py-modules = ["gru_bot_cli", "gru_weixin", "gru_associate"]
19
+ include-package-data = false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+