gru-bot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gru_associate.py +325 -0
- gru_bot-0.1.0.dist-info/METADATA +84 -0
- gru_bot-0.1.0.dist-info/RECORD +10 -0
- gru_bot-0.1.0.dist-info/WHEEL +5 -0
- gru_bot-0.1.0.dist-info/entry_points.txt +2 -0
- gru_bot-0.1.0.dist-info/licenses/LICENSE +20 -0
- gru_bot-0.1.0.dist-info/licenses/NOTICE-TENCENT +27 -0
- gru_bot-0.1.0.dist-info/top_level.txt +3 -0
- gru_bot_cli.py +57 -0
- gru_weixin.py +337 -0
gru_associate.py
ADDED
|
@@ -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
|
|
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,10 @@
|
|
|
1
|
+
gru_associate.py,sha256=OEXObM4Cm-c1aeeH_pKGTeu1SEPokDWw-bREjI7Obbg,15532
|
|
2
|
+
gru_bot_cli.py,sha256=Z29cBPPjV-hCbxpkY8bpO_ugucn4X6QUHILN4MQv33A,2061
|
|
3
|
+
gru_weixin.py,sha256=Tnig1OipVUhWS9i9HrU3coNDvL7Uu3n6KuAcIjW72Xs,17222
|
|
4
|
+
gru_bot-0.1.0.dist-info/licenses/LICENSE,sha256=NmyNkf8pkfTQguVPpkcUPVQ0b9qxa2tmZBjTm_f2tso,1035
|
|
5
|
+
gru_bot-0.1.0.dist-info/licenses/NOTICE-TENCENT,sha256=fx6M8JnPA958d14z6X48lQw6Vqlha7TeaxTTyRG8VXM,1301
|
|
6
|
+
gru_bot-0.1.0.dist-info/METADATA,sha256=EIoUoJXg1Y7HGzhT31cTP73-zCO_YzsBXl5m3k-FuiA,6231
|
|
7
|
+
gru_bot-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
gru_bot-0.1.0.dist-info/entry_points.txt,sha256=Zim-9nPIqNvPHNoMa5Snq1KeXgQM8k1GvVL3M9fUh5o,41
|
|
9
|
+
gru_bot-0.1.0.dist-info/top_level.txt,sha256=nmBFELlJAgdwy4ARriqx8dnw_pUVRRCzMDd3o_pD1Lw,37
|
|
10
|
+
gru_bot-0.1.0.dist-info/RECORD,,
|
|
@@ -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,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.
|
gru_bot_cli.py
ADDED
|
@@ -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())
|
gru_weixin.py
ADDED
|
@@ -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())
|