Rename index.js to app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,2064 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
import json
|
| 4 |
+
import uuid
|
| 5 |
+
import io
|
| 6 |
+
import base64
|
| 7 |
+
import asyncio
|
| 8 |
+
import re # <-- 新增: 用于触发词和预设解析
|
| 9 |
+
import random # <-- 新增: 用于随机回复
|
| 10 |
+
import time # <-- 新增: 用于内存冷却
|
| 11 |
+
from typing import List, Dict, Any, Optional, Set
|
| 12 |
+
|
| 13 |
+
# 运行: pip install python-telegram-bot upstash-redis httpx python-dotenv
|
| 14 |
+
import httpx # 使用 httpx 直接请求 API
|
| 15 |
+
from dotenv import load_dotenv
|
| 16 |
+
from upstash_redis import Redis
|
| 17 |
+
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, BotCommand, Message
|
| 18 |
+
from telegram.constants import ChatAction, ParseMode
|
| 19 |
+
from telegram.error import BadRequest
|
| 20 |
+
from telegram.ext import (
|
| 21 |
+
Application,
|
| 22 |
+
ApplicationBuilder,
|
| 23 |
+
ContextTypes,
|
| 24 |
+
CommandHandler,
|
| 25 |
+
MessageHandler,
|
| 26 |
+
CallbackQueryHandler,
|
| 27 |
+
filters,
|
| 28 |
+
Job,
|
| 29 |
+
JobQueue
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# --- 日志配置 ---
|
| 33 |
+
# 配置日志记录
|
| 34 |
+
logging.basicConfig(
|
| 35 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 36 |
+
level=logging.INFO
|
| 37 |
+
)
|
| 38 |
+
# 将 httpx 的日志级别调高,因为它在 DEBUG 级别下过于嘈杂
|
| 39 |
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
| 40 |
+
# (新增) 禁用 httpx 的 SSL 警告
|
| 41 |
+
import warnings
|
| 42 |
+
import urllib3
|
| 43 |
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
| 44 |
+
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
| 45 |
+
|
| 46 |
+
logger = logging.getLogger(__name__)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# --- 1. 配置类 ---
|
| 50 |
+
|
| 51 |
+
class Config:
|
| 52 |
+
"""
|
| 53 |
+
管理所有从环境变量加载的配置。
|
| 54 |
+
"""
|
| 55 |
+
def __init__(self):
|
| 56 |
+
# 加载 .env 文件 (如果存在)
|
| 57 |
+
load_dotenv()
|
| 58 |
+
logger.info("正在加载环境变量...")
|
| 59 |
+
|
| 60 |
+
# Telegram
|
| 61 |
+
self.TELEGRAM_BOT_TOKEN: str = self.get_env_required("TELEGRAM_BOT_TOKEN")
|
| 62 |
+
|
| 63 |
+
# OpenAI 兼容 API
|
| 64 |
+
self.OPENAI_API_URL: str = self.get_env_required("OPENAI_COMPATIBLE_URL")
|
| 65 |
+
self.OPENAI_API_KEY: str = self.get_env_required("OPENAI_COMPATIBLE_KEY")
|
| 66 |
+
self.DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL", "gpt-3.5-turbo")
|
| 67 |
+
|
| 68 |
+
# Upstash Redis (upstash-redis 库需要这两个)
|
| 69 |
+
self.UPSTASH_REDIS_URL: str = self.get_env_required("UPSTASH_REDIS_REST_URL")
|
| 70 |
+
self.UPSTASH_REDIS_TOKEN: str = self.get_env_required("UPSTASH_REDIS_REST_TOKEN")
|
| 71 |
+
|
| 72 |
+
# 权限
|
| 73 |
+
self.ADMIN_USERS: Set[int] = self.parse_int_set_from_env("ADMIN_USERS")
|
| 74 |
+
|
| 75 |
+
# (新增) RSS
|
| 76 |
+
self.RSS_URL: str = os.getenv("RSS_URL", "https://ci-en.dlsite.com/creator/4551/article/xml/rss")
|
| 77 |
+
# (V4.5) 移除 RSS_CHAT_IDS, 改为动态订阅
|
| 78 |
+
|
| 79 |
+
if not self.ADMIN_USERS:
|
| 80 |
+
logger.warning("未在环境变量中定义 'ADMIN_USERS'。某些管理功能将受限。")
|
| 81 |
+
|
| 82 |
+
logger.info("环境变量加载完毕。")
|
| 83 |
+
|
| 84 |
+
def get_env_required(self, var_name: str) -> str:
|
| 85 |
+
"""获取必需的环境变量,如果缺失则抛出异常。"""
|
| 86 |
+
value = os.getenv(var_name)
|
| 87 |
+
if value is None:
|
| 88 |
+
logger.error(f"严重错误: 环境变量 '{var_name}' 未设置。")
|
| 89 |
+
raise ValueError(f"环境变量 '{var_name}' 必须被设置。")
|
| 90 |
+
return value
|
| 91 |
+
|
| 92 |
+
def parse_int_set_from_env(self, var_name: str) -> Set[int]:
|
| 93 |
+
"""从逗号分隔的环境变量字符串解析为整数集合。"""
|
| 94 |
+
value_str = os.getenv(var_name)
|
| 95 |
+
if not value_str:
|
| 96 |
+
return set()
|
| 97 |
+
try:
|
| 98 |
+
return {int(x.strip()) for x in value_str.split(',') if x.strip()}
|
| 99 |
+
except ValueError:
|
| 100 |
+
logger.error(f"无法解析环境变量 '{var_name}'。请确保它是逗号分隔的整数ID。")
|
| 101 |
+
return set()
|
| 102 |
+
|
| 103 |
+
@property
|
| 104 |
+
def OP_USER_ID(self) -> Optional[int]:
|
| 105 |
+
"""
|
| 106 |
+
获取"超级管理员" (OP) 的ID,即 ADMIN_USERS 列表中的第一个。
|
| 107 |
+
用于接收启动通知。
|
| 108 |
+
"""
|
| 109 |
+
if self.ADMIN_USERS:
|
| 110 |
+
return next(iter(self.ADMIN_USERS))
|
| 111 |
+
return None
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# --- 2. Redis 管理类 ---
|
| 115 |
+
|
| 116 |
+
class RedisManager:
|
| 117 |
+
"""
|
| 118 |
+
封装所有与 Upstash Redis 的交互。
|
| 119 |
+
(V4.5: 主要负责*写入*和*初始加载*,读取操作由内存缓存处理)
|
| 120 |
+
"""
|
| 121 |
+
# Redis 键名常量
|
| 122 |
+
KEY_MODEL_LIST = "bot:model_list"
|
| 123 |
+
KEY_ADMIN_USERS = "bot:admin_users"
|
| 124 |
+
# V4.3 重构:
|
| 125 |
+
KEY_WHITELISTED_GROUPS = "bot:whitelisted_groups" # (Set) 允许的群组 ID
|
| 126 |
+
KEY_BLACKLISTED_TOPICS = "bot:blacklisted_topics" # (Set) 禁言的话题 Key (chat_id:thread_id)
|
| 127 |
+
KEY_GROUP_TRIGGERS = "bot:group_triggers" # HASH, chat_id -> trigger_word
|
| 128 |
+
|
| 129 |
+
# 预设键
|
| 130 |
+
KEY_PRESETS_PREFIX = "bot:presets:" # HASH, {user_id} -> {preset_name} -> json(messages)
|
| 131 |
+
KEY_ACTIVE_PRESET_PREFIX = "bot:active_preset:" # STRING, {user_id} -> preset_name
|
| 132 |
+
|
| 133 |
+
# RSS 键
|
| 134 |
+
KEY_LAST_RSS_LINK = "bot:last_rss_link" # STRING, 存储最新的 article_id
|
| 135 |
+
KEY_RSS_SUBSCRIPTIONS = "bot:rss_subscriptions" # (V4.5 新增) Set, 存储订阅的 chat_id
|
| 136 |
+
|
| 137 |
+
# V4.7 新增: 活跃度
|
| 138 |
+
KEY_LAST_RESPONSE_TIMES = "bot:last_response_times" # HASH, context_key -> timestamp
|
| 139 |
+
|
| 140 |
+
# 会话超时时间 (例如: 24 小时)
|
| 141 |
+
SESSION_EXPIRATION_SEC = 86400 # 24 * 60 * 60
|
| 142 |
+
|
| 143 |
+
# 上下文历史消息限制
|
| 144 |
+
CONTEXT_HISTORY_LIMIT = 200
|
| 145 |
+
|
| 146 |
+
def __init__(self, config: Config):
|
| 147 |
+
try:
|
| 148 |
+
self.redis = Redis(
|
| 149 |
+
url=config.UPSTASH_REDIS_URL,
|
| 150 |
+
token=config.UPSTASH_REDIS_TOKEN
|
| 151 |
+
)
|
| 152 |
+
# 测试连接
|
| 153 |
+
self.redis.ping()
|
| 154 |
+
logger.info("成功连接到 Upstash Redis。")
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.error(f"无法连接到 Upstash Redis: {e}", exc_info=True)
|
| 157 |
+
raise
|
| 158 |
+
|
| 159 |
+
# --- 键生成辅助方法 ---
|
| 160 |
+
def _get_context_key(self, user_id: int, chat_id: int, thread_id: Optional[int]) -> str:
|
| 161 |
+
"""
|
| 162 |
+
重构: 获取上下文的唯一键。
|
| 163 |
+
- 私聊: context:user:{user_id}
|
| 164 |
+
- 群组话题: context:group:{chat_id}:{thread_id_key}
|
| 165 |
+
"""
|
| 166 |
+
if chat_id > 0: # V4.3 修复: 私聊 (chat_id == user_id)
|
| 167 |
+
return f"context:user:{user_id}"
|
| 168 |
+
|
| 169 |
+
# V4.3 重构: 统一处理 "常规" 话题 (None -> 0)
|
| 170 |
+
thread_id_key = thread_id if thread_id is not None else 0
|
| 171 |
+
return f"context:group:{chat_id}:{thread_id_key}"
|
| 172 |
+
|
| 173 |
+
def _user_model_key(self, user_id: int) -> str:
|
| 174 |
+
"""获取跟踪用户当前模型的键 (保持不变)。"""
|
| 175 |
+
return f"user:{user_id}:current_model"
|
| 176 |
+
|
| 177 |
+
def _user_presets_key(self, user_id: int) -> str:
|
| 178 |
+
"""(新增) 获取用户预设 HASH 的键。"""
|
| 179 |
+
return f"{self.KEY_PRESETS_PREFIX}{user_id}"
|
| 180 |
+
|
| 181 |
+
def _user_active_preset_key(self, user_id: int) -> str:
|
| 182 |
+
"""(新增) 获取用户当前激活预设的键。"""
|
| 183 |
+
return f"{self.KEY_ACTIVE_PRESET_PREFIX}{user_id}"
|
| 184 |
+
|
| 185 |
+
# --- 启动初始化 ---
|
| 186 |
+
def initialize_from_env(self, initial_admins: Set[int]): # V4.2: 移除了 initial_chats
|
| 187 |
+
"""
|
| 188 |
+
在启动时,将环境变量中的初始管理员同步到 Redis。
|
| 189 |
+
(V4.2: 话题白名单现在必须通过命令添加)
|
| 190 |
+
"""
|
| 191 |
+
try:
|
| 192 |
+
if initial_admins:
|
| 193 |
+
# SADD 返回成功添加的新成员数量
|
| 194 |
+
added_admins = self.redis.sadd(self.KEY_ADMIN_USERS, *initial_admins)
|
| 195 |
+
logger.info(f"已将 {added_admins} 个新管理员从环境变量同步到 Redis。")
|
| 196 |
+
except Exception as e:
|
| 197 |
+
logger.error(f"初始化 Redis 数据时出错: {e}", exc_info=True)
|
| 198 |
+
|
| 199 |
+
# --- 会话管理 (私聊) ---
|
| 200 |
+
|
| 201 |
+
def clear_session(self, user_id: int, chat_id: int, thread_id: Optional[int]):
|
| 202 |
+
"""重构: 清除当前上下文 (/new)。(私聊或群组均可调用)"""
|
| 203 |
+
context_key = self._get_context_key(user_id, chat_id, thread_id)
|
| 204 |
+
# 注意: 这只会清除 Redis。群组的内存缓存将在下一次消息时自动重置。
|
| 205 |
+
# (或者在 /new 命令中单独清除内存)
|
| 206 |
+
self.redis.delete(context_key)
|
| 207 |
+
logger.info(f"已为键 {context_key} 清除 Redis 会话。")
|
| 208 |
+
|
| 209 |
+
def get_conversation_history(self, user_id: int, chat_id: int, thread_id: Optional[int]) -> List[Dict[str, Any]]:
|
| 210 |
+
"""
|
| 211 |
+
重构: 根据上下文键获取对话历史。
|
| 212 |
+
(现在主要用于私聊,或群组的初始加载)
|
| 213 |
+
"""
|
| 214 |
+
context_key = self._get_context_key(user_id, chat_id, thread_id)
|
| 215 |
+
raw_data = self.redis.get(context_key)
|
| 216 |
+
|
| 217 |
+
if not raw_data:
|
| 218 |
+
return []
|
| 219 |
+
|
| 220 |
+
try:
|
| 221 |
+
history = json.loads(raw_data)
|
| 222 |
+
if not isinstance(history, list):
|
| 223 |
+
logger.warning(f"上下文 {context_key} 的数据格式不正确 (非列表),已重置。")
|
| 224 |
+
return []
|
| 225 |
+
|
| 226 |
+
# 刷新 TTL
|
| 227 |
+
self.redis.expire(context_key, self.SESSION_EXPIRATION_SEC)
|
| 228 |
+
|
| 229 |
+
# 只返回最后 200 条
|
| 230 |
+
return history[-self.CONTEXT_HISTORY_LIMIT:]
|
| 231 |
+
|
| 232 |
+
except json.JSONDecodeError:
|
| 233 |
+
logger.error(f"无法解析上下文 {context_key} 的JSON数据,已重置。")
|
| 234 |
+
return []
|
| 235 |
+
|
| 236 |
+
def add_to_conversation(self, user_id: int, chat_id: int, thread_id: Optional[int], role: str, content: Any):
|
| 237 |
+
"""
|
| 238 |
+
重构: 向指定的上下文添加一条消息。
|
| 239 |
+
(现在主要用于私聊)
|
| 240 |
+
(V4.1: content 已经是新格式的列表)
|
| 241 |
+
"""
|
| 242 |
+
context_key = self._get_context_key(user_id, chat_id, thread_id)
|
| 243 |
+
try:
|
| 244 |
+
raw_data = self.redis.get(context_key)
|
| 245 |
+
|
| 246 |
+
if not raw_data:
|
| 247 |
+
history = []
|
| 248 |
+
else:
|
| 249 |
+
try:
|
| 250 |
+
history = json.loads(raw_data)
|
| 251 |
+
if not isinstance(history, list):
|
| 252 |
+
history = []
|
| 253 |
+
except json.JSONDecodeError:
|
| 254 |
+
history = []
|
| 255 |
+
|
| 256 |
+
# 添加新消息 (content 已经是新格式)
|
| 257 |
+
history.append({"role": role, "content": content})
|
| 258 |
+
|
| 259 |
+
# 写入 (私聊立即写)
|
| 260 |
+
self.redis.set(context_key, json.dumps(history), ex=self.SESSION_EXPIRATION_SEC)
|
| 261 |
+
|
| 262 |
+
logger.debug(f"已将上下文 {context_key} 的 {role} 消息存入 Redis。")
|
| 263 |
+
|
| 264 |
+
except Exception as e:
|
| 265 |
+
logger.error(f"向上下文 {context_key} (私聊) 添加消息时出错: {e}", exc_info=True)
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
# --- 模型管理 ---
|
| 269 |
+
|
| 270 |
+
def get_current_model(self, user_id: int, default_model: str) -> str:
|
| 271 |
+
"""获取用户的首选模型,如果未设置则返回默认值。"""
|
| 272 |
+
model = self.redis.get(self._user_model_key(user_id))
|
| 273 |
+
# V4.6 修复: 移除 .decode()
|
| 274 |
+
return model if model else default_model
|
| 275 |
+
|
| 276 |
+
def set_current_model(self, user_id: int, model_name: str):
|
| 277 |
+
"""设置用户的首选模型。"""
|
| 278 |
+
self.redis.set(self._user_model_key(user_id), model_name)
|
| 279 |
+
logger.info(f"用户 {user_id} 将模型切换为: {model_name}")
|
| 280 |
+
|
| 281 |
+
def cache_model_list(self, models: List[str]):
|
| 282 |
+
"""将从API获取的模型列表缓存到 Redis。"""
|
| 283 |
+
if not models:
|
| 284 |
+
return
|
| 285 |
+
# 缓存 1 小时
|
| 286 |
+
self.redis.set(self.KEY_MODEL_LIST, json.dumps(models), ex=3600)
|
| 287 |
+
logger.info(f"已缓存 {len(models)} 个模型到 Redis。")
|
| 288 |
+
|
| 289 |
+
def get_cached_model_list(self) -> Optional[List[str]]:
|
| 290 |
+
"""从 Redis 获取缓存的模型列表。"""
|
| 291 |
+
raw_data = self.redis.get(self.KEY_MODEL_LIST)
|
| 292 |
+
if not raw_data:
|
| 293 |
+
return None
|
| 294 |
+
try:
|
| 295 |
+
return json.loads(raw_data)
|
| 296 |
+
except json.JSONDecodeError:
|
| 297 |
+
return None
|
| 298 |
+
|
| 299 |
+
# --- 权限管理 (Admin) (V4.5: 读/写) ---
|
| 300 |
+
|
| 301 |
+
def get_admin_users(self) -> Set[int]:
|
| 302 |
+
"""(V4.5) 从 Redis *读取*所有管理员ID (用于启动加载)。"""
|
| 303 |
+
try:
|
| 304 |
+
# V4.6 修复: 移除 .decode()
|
| 305 |
+
return {int(uid) for uid in self.redis.smembers(self.KEY_ADMIN_USERS)}
|
| 306 |
+
except Exception as e:
|
| 307 |
+
logger.error(f"获取管理员列表时出错: {e}", exc_info=True)
|
| 308 |
+
return set()
|
| 309 |
+
|
| 310 |
+
def add_admin(self, user_id: int) -> bool:
|
| 311 |
+
"""(V4.5) *写入*管理员到 Redis。"""
|
| 312 |
+
try:
|
| 313 |
+
return self.redis.sadd(self.KEY_ADMIN_USERS, user_id) == 1
|
| 314 |
+
except Exception as e:
|
| 315 |
+
logger.error(f"添加管理员 {user_id} 时出错: {e}")
|
| 316 |
+
return False
|
| 317 |
+
|
| 318 |
+
def remove_admin(self, user_id: int) -> bool:
|
| 319 |
+
"""(V4.5) 从 Redis *移除*管理员。"""
|
| 320 |
+
try:
|
| 321 |
+
return self.redis.srem(self.KEY_ADMIN_USERS, user_id) == 1
|
| 322 |
+
except Exception as e:
|
| 323 |
+
logger.error(f"移除管理员 {user_id} 时出错: {e}")
|
| 324 |
+
return False
|
| 325 |
+
|
| 326 |
+
# --- 权限管理 (V4.5: Whitelist Group / Blacklist Topic) (读/写) ---
|
| 327 |
+
|
| 328 |
+
# (V4.5 新增) 群组白名单
|
| 329 |
+
def get_whitelisted_groups(self) -> Set[int]:
|
| 330 |
+
"""(V4.5) 从 Redis *读取*所有白名单群组 (用于启动加载)。"""
|
| 331 |
+
try:
|
| 332 |
+
# V4.6 修复: 移除 .decode()
|
| 333 |
+
return {int(cid) for cid in self.redis.smembers(self.KEY_WHITELISTED_GROUPS)}
|
| 334 |
+
except Exception as e:
|
| 335 |
+
logger.error(f"获取白名单群组时出错: {e}", exc_info=True)
|
| 336 |
+
return set()
|
| 337 |
+
|
| 338 |
+
def add_group_whitelist(self, chat_id: int) -> bool:
|
| 339 |
+
"""(V4.5) *写入*群组到白名单 Redis。"""
|
| 340 |
+
try:
|
| 341 |
+
return self.redis.sadd(self.KEY_WHITELISTED_GROUPS, chat_id) == 1
|
| 342 |
+
except Exception as e:
|
| 343 |
+
logger.error(f"添加白名单群组 {chat_id} 时出错: {e}")
|
| 344 |
+
return False
|
| 345 |
+
|
| 346 |
+
# (V4.5 重构) 话题黑名单
|
| 347 |
+
def get_blacklisted_topics(self) -> Set[str]:
|
| 348 |
+
"""(V4.5) 从 Redis *读取*所有黑名单话题 (用于启动加载)。"""
|
| 349 |
+
try:
|
| 350 |
+
# V4.6 修复: 移除 .decode()
|
| 351 |
+
return {k for k in self.redis.smembers(self.KEY_BLACKLISTED_TOPICS)}
|
| 352 |
+
except Exception as e:
|
| 353 |
+
logger.error(f"获取黑名单话题时出错: {e}", exc_info=True)
|
| 354 |
+
return set()
|
| 355 |
+
|
| 356 |
+
def add_blacklisted_topic(self, topic_key: str) -> bool:
|
| 357 |
+
"""(V4.5) *写入*话题到黑名单 Redis。"""
|
| 358 |
+
try:
|
| 359 |
+
return self.redis.sadd(self.KEY_BLACKLISTED_TOPICS, topic_key) == 1
|
| 360 |
+
except Exception as e:
|
| 361 |
+
logger.error(f"添加黑名单话题 {topic_key} 时出错: {e}")
|
| 362 |
+
return False
|
| 363 |
+
|
| 364 |
+
# --- 群组触发词管理 (V4.5: 读/写) ---
|
| 365 |
+
|
| 366 |
+
def get_all_group_triggers(self) -> Dict[int, str]:
|
| 367 |
+
"""(V4.5) 从 Redis *读取*所有群组触发词 (用于启动加载)。"""
|
| 368 |
+
try:
|
| 369 |
+
# V4.6 修复: 移除 .decode()
|
| 370 |
+
return {int(k): v for k, v in self.redis.hgetall(self.KEY_GROUP_TRIGGERS).items()}
|
| 371 |
+
except Exception as e:
|
| 372 |
+
logger.error(f"获取所有群组触发词时出错: {e}", exc_info=True)
|
| 373 |
+
return {}
|
| 374 |
+
|
| 375 |
+
def set_group_trigger(self, chat_id: int, word: str):
|
| 376 |
+
"""(V4.5) *写入*群组触发词到 Redis。"""
|
| 377 |
+
self.redis.hset(self.KEY_GROUP_TRIGGERS, str(chat_id), word)
|
| 378 |
+
|
| 379 |
+
# --- 预设 (Preset) 管理 ---
|
| 380 |
+
|
| 381 |
+
def set_preset(self, user_id: int, name: str, messages: List[Dict[str, Any]]) -> bool:
|
| 382 |
+
"""
|
| 383 |
+
保存一个预设 (HSET)。
|
| 384 |
+
(V4.1: messages 已经是新格式)
|
| 385 |
+
"""
|
| 386 |
+
try:
|
| 387 |
+
key = self._user_presets_key(user_id)
|
| 388 |
+
self.redis.hset(key, name, json.dumps(messages))
|
| 389 |
+
return True
|
| 390 |
+
except Exception as e:
|
| 391 |
+
logger.error(f"为用户 {user_id} 保存预设 '{name}' 时出错: {e}")
|
| 392 |
+
return False
|
| 393 |
+
|
| 394 |
+
def get_preset(self, user_id: int, name: str) -> Optional[List[Dict[str, Any]]]:
|
| 395 |
+
"""获取一个特定的预设 (HGET)。"""
|
| 396 |
+
try:
|
| 397 |
+
key = self._user_presets_key(user_id)
|
| 398 |
+
raw_data = self.redis.hget(key, name)
|
| 399 |
+
if not raw_data:
|
| 400 |
+
return None
|
| 401 |
+
return json.loads(raw_data)
|
| 402 |
+
except Exception as e:
|
| 403 |
+
logger.error(f"为用户 {user_id} 获取预设 '{name}' 时出错: {e}")
|
| 404 |
+
return None
|
| 405 |
+
|
| 406 |
+
def delete_preset(self, user_id: int, name: str) -> bool:
|
| 407 |
+
"""删除一个预设 (HDEL)。"""
|
| 408 |
+
try:
|
| 409 |
+
key = self._user_presets_key(user_id)
|
| 410 |
+
# 检查是否删除了当前激活的预设
|
| 411 |
+
active_preset = self.get_active_preset_name(user_id)
|
| 412 |
+
if active_preset == name:
|
| 413 |
+
self.set_active_preset(user_id, "") # 清空激活的预设
|
| 414 |
+
|
| 415 |
+
return self.redis.hdel(key, name) > 0
|
| 416 |
+
except Exception as e:
|
| 417 |
+
logger.error(f"为用户 {user_id} 删除预设 '{name}' 时出错: {e}")
|
| 418 |
+
return False
|
| 419 |
+
|
| 420 |
+
def list_presets(self, user_id: int) -> List[str]:
|
| 421 |
+
"""列出用户的所有预设名称 (HKEYS)。"""
|
| 422 |
+
try:
|
| 423 |
+
key = self._user_presets_key(user_id)
|
| 424 |
+
# (V4.6) 修复 bytes decode
|
| 425 |
+
return self.redis.hkeys(key)
|
| 426 |
+
except Exception as e:
|
| 427 |
+
logger.error(f"为用户 {user_id} 列出预设时出错: {e}")
|
| 428 |
+
return []
|
| 429 |
+
|
| 430 |
+
def set_active_preset(self, user_id: int, name: str):
|
| 431 |
+
"""设置当前激活的预设 (SET)。"""
|
| 432 |
+
key = self._user_active_preset_key(user_id)
|
| 433 |
+
if name:
|
| 434 |
+
self.redis.set(key, name)
|
| 435 |
+
else:
|
| 436 |
+
# 如果 name 为空,则删除键
|
| 437 |
+
self.redis.delete(key)
|
| 438 |
+
|
| 439 |
+
def get_active_preset_name(self, user_id: int) -> Optional[str]:
|
| 440 |
+
"""获取当前激活的预设名称 (GET)。"""
|
| 441 |
+
key = self._user_active_preset_key(user_id)
|
| 442 |
+
# (V4.6) 修复 bytes decode
|
| 443 |
+
return self.redis.get(key)
|
| 444 |
+
|
| 445 |
+
def get_active_preset_messages(self, user_id: int) -> List[Dict[str, Any]]:
|
| 446 |
+
"""(核心) 获取当前激活的预设的消息列表。"""
|
| 447 |
+
name = self.get_active_preset_name(user_id)
|
| 448 |
+
if not name:
|
| 449 |
+
return []
|
| 450 |
+
|
| 451 |
+
messages = self.get_preset(user_id, name)
|
| 452 |
+
return messages if messages else []
|
| 453 |
+
|
| 454 |
+
# --- (新增) RSS 管理 ---
|
| 455 |
+
def get_last_rss_link(self) -> Optional[str]:
|
| 456 |
+
"""获取最后已知的 RSS 链接 ID。"""
|
| 457 |
+
# (V4.6) 修复 bytes decode
|
| 458 |
+
return self.redis.get(self.KEY_LAST_RSS_LINK)
|
| 459 |
+
|
| 460 |
+
def set_last_rss_link(self, link_id: str):
|
| 461 |
+
"""设置最新的 RSS 链接 ID。"""
|
| 462 |
+
self.redis.set(self.KEY_LAST_RSS_LINK, link_id)
|
| 463 |
+
|
| 464 |
+
def get_rss_subscribers(self) -> Set[int]:
|
| 465 |
+
"""(V4.5) 从 Redis *读取*所有 RSS 订阅者。"""
|
| 466 |
+
try:
|
| 467 |
+
# (V4.6) 修复 bytes decode
|
| 468 |
+
return {int(cid) for cid in self.redis.smembers(self.KEY_RSS_SUBSCRIPTIONS)}
|
| 469 |
+
except Exception as e:
|
| 470 |
+
logger.error(f"获取 RSS 订阅者时出错: {e}")
|
| 471 |
+
return set()
|
| 472 |
+
|
| 473 |
+
def add_rss_subscriber(self, chat_id: int) -> bool:
|
| 474 |
+
"""(V4.5) *写入* RSS 订阅者。"""
|
| 475 |
+
try:
|
| 476 |
+
return self.redis.sadd(self.KEY_RSS_SUBSCRIPTIONS, chat_id) == 1
|
| 477 |
+
except Exception as e:
|
| 478 |
+
logger.error(f"添加 RSS 订阅者 {chat_id} 时出错: {e}")
|
| 479 |
+
return False
|
| 480 |
+
|
| 481 |
+
def remove_rss_subscriber(self, chat_id: int) -> bool:
|
| 482 |
+
"""(V4.5) *移除* RSS 订阅者。"""
|
| 483 |
+
try:
|
| 484 |
+
return self.redis.srem(self.KEY_RSS_SUBSCRIPTIONS, chat_id) == 1
|
| 485 |
+
except Exception as e:
|
| 486 |
+
logger.error(f"移除 RSS 订阅者 {chat_id} 时出错: {e}")
|
| 487 |
+
return False
|
| 488 |
+
|
| 489 |
+
# --- (V4.7 新增) 活跃度管理 ---
|
| 490 |
+
def update_last_response_time(self, context_key: str):
|
| 491 |
+
"""(V4.7) 更新一个话题的最后响应时间戳。"""
|
| 492 |
+
try:
|
| 493 |
+
self.redis.hset(self.KEY_LAST_RESPONSE_TIMES, context_key, time.time())
|
| 494 |
+
except Exception as e:
|
| 495 |
+
logger.error(f"更新最后响应时间失败 {context_key}: {e}")
|
| 496 |
+
|
| 497 |
+
def get_all_last_response_times(self) -> Dict[str, float]:
|
| 498 |
+
"""(V4.7) 获取所有话题的最后响应时间戳 (用于启动检查)。"""
|
| 499 |
+
try:
|
| 500 |
+
# V4.6 修复: 移除 .decode()
|
| 501 |
+
return {k: float(v) for k, v in self.redis.hgetall(self.KEY_LAST_RESPONSE_TIMES).items()}
|
| 502 |
+
except Exception as e:
|
| 503 |
+
logger.error(f"获取所有最后响应时间失败: {e}", exc_info=True)
|
| 504 |
+
return {}
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
# --- 3. OpenAI API 客户端类 (使用 httpx) ---
|
| 508 |
+
|
| 509 |
+
class OpenAIClient:
|
| 510 |
+
"""
|
| 511 |
+
封装所有与 OpenAI 兼容 API 的交互。
|
| 512 |
+
使用 httpx.AsyncClient 进行异步 API 请求。
|
| 513 |
+
"""
|
| 514 |
+
def __init__(self, config: Config):
|
| 515 |
+
self.base_url = config.OPENAI_API_URL
|
| 516 |
+
self.headers = {
|
| 517 |
+
"Authorization": f"Bearer {config.OPENAI_API_KEY}",
|
| 518 |
+
"Content-Type": "application/json"
|
| 519 |
+
}
|
| 520 |
+
# 修复 V4.2: 禁用 SSL 验证 (verify=False) 以修复 ConnectError
|
| 521 |
+
logger.warning("!!! 安全警告: 正在为 OpenAIClient 禁用 SSL 验证 (verify=False)。")
|
| 522 |
+
logger.warning("!!! 这可以修复 ConnectError,但会带来安全风险。请确保 API 端点可信。")
|
| 523 |
+
self.client = httpx.AsyncClient(
|
| 524 |
+
base_url=self.base_url,
|
| 525 |
+
headers=self.headers,
|
| 526 |
+
timeout=30.0, # 设置 30 秒超时
|
| 527 |
+
follow_redirects=True,
|
| 528 |
+
verify=False # <-- 修复: 禁用 SSL 验证
|
| 529 |
+
)
|
| 530 |
+
self.default_model = config.DEFAULT_MODEL
|
| 531 |
+
logger.info(f"OpenAI 客户端 (httpx) 已初始化,指向: {config.OPENAI_API_URL}")
|
| 532 |
+
|
| 533 |
+
async def get_models(self) -> List[str]:
|
| 534 |
+
"""
|
| 535 |
+
从 API 获取可用模型列表。
|
| 536 |
+
"""
|
| 537 |
+
try:
|
| 538 |
+
logger.info("正在从 API (httpx) 获取模型列表...")
|
| 539 |
+
# 兼容的 API 端点通常是 /v1/models
|
| 540 |
+
response = await self.client.get("/v1/models")
|
| 541 |
+
response.raise_for_status() # 如果状态码不是 2xx,则抛出异常
|
| 542 |
+
|
| 543 |
+
models_data = response.json()
|
| 544 |
+
|
| 545 |
+
# 过滤并返回所有模型ID (接受所有模型)
|
| 546 |
+
model_ids = [model['id'] for model in models_data.get('data', []) if model.get('id')]
|
| 547 |
+
logger.info(f"成功获取到 {len(model_ids)} 个模型。")
|
| 548 |
+
return sorted(model_ids)
|
| 549 |
+
except httpx.HTTPStatusError as e:
|
| 550 |
+
logger.error(f"API 请求失败 (状态码 {e.response.status_code}): {e.response.text}", exc_info=True)
|
| 551 |
+
return []
|
| 552 |
+
except Exception as e:
|
| 553 |
+
logger.error(f"从 API 获取模型列表失败 (httpx): {e}", exc_info=True)
|
| 554 |
+
return []
|
| 555 |
+
|
| 556 |
+
async def generate_response(self, model: str, history: List[Dict[str, Any]]) -> Optional[str]:
|
| 557 |
+
"""
|
| 558 |
+
调用聊天补全 (Chat Completions) API。
|
| 559 |
+
'history' 必须是符合 API 格式的列表。
|
| 560 |
+
修复: 增加了对空 'choices' 列表的检查,防止 'list index out of range'。
|
| 561 |
+
"""
|
| 562 |
+
logger.debug(f"向模型 {model} (httpx) 发送请求,包含 {len(history)} 条历史消息。")
|
| 563 |
+
if history and "image_url" in str(history[-1]):
|
| 564 |
+
logger.debug("请求中包含图片。")
|
| 565 |
+
|
| 566 |
+
# 兼容的 API 端点通常是 /v1/chat/completions
|
| 567 |
+
endpoint = "/v1/chat/completions"
|
| 568 |
+
payload = {
|
| 569 |
+
"model": model,
|
| 570 |
+
"messages": history,
|
| 571 |
+
"stream": False # 不使用流式响应,简化处理
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
try:
|
| 575 |
+
response = await self.client.post(endpoint, json=payload)
|
| 576 |
+
response.raise_for_status() # 检查 HTTP 错误
|
| 577 |
+
|
| 578 |
+
data = response.json()
|
| 579 |
+
|
| 580 |
+
# 修复: 检查 'choices' 是否存在且不为空
|
| 581 |
+
choices = data.get('choices', [])
|
| 582 |
+
if not choices or 'message' not in choices[0]:
|
| 583 |
+
logger.warning(f"API 响应中未找到 'choices' 或 'message'。响应: {data}")
|
| 584 |
+
return "抱歉,AI 响应为空或格式不正确。"
|
| 585 |
+
|
| 586 |
+
# 修复 V4.1: AI 的回复也在 content 列表中
|
| 587 |
+
# (但大多数兼容 API 仍然返回 {role:"...", content:"..."})
|
| 588 |
+
# 我们必须检查两种情况
|
| 589 |
+
message = choices[0].get('message', {})
|
| 590 |
+
response_content = message.get('content')
|
| 591 |
+
|
| 592 |
+
if response_content is None:
|
| 593 |
+
logger.warning(f"API 响应中 'content' 为空。响应: {data}")
|
| 594 |
+
return "抱歉,AI 响应格式不正确 (content 为 null)。"
|
| 595 |
+
|
| 596 |
+
# 检查 content 是字符串 (标准) 还是列表 (新格式)
|
| 597 |
+
if isinstance(response_content, str):
|
| 598 |
+
response_text = response_content
|
| 599 |
+
elif isinstance(response_content, list) and len(response_content) > 0 and response_content[0].get('type') == 'text':
|
| 600 |
+
response_text = response_content[0].get('text', '')
|
| 601 |
+
else:
|
| 602 |
+
logger.warning(f"API 响应的 'content' 格式未知: {response_content}")
|
| 603 |
+
return "抱歉,AI 响应格式未知。"
|
| 604 |
+
|
| 605 |
+
logger.debug(f"模型 {model} 成功返回响应。")
|
| 606 |
+
return response_text
|
| 607 |
+
|
| 608 |
+
except httpx.ConnectError as e: # 捕获特定的 ConnectError
|
| 609 |
+
logger.error(f"调用 OpenAI API ({model}) 时(httpx)遇到连接错误 (ConnectError): {e}", exc_info=True)
|
| 610 |
+
return f"抱歉,AI 连接失败 (ConnectError): {str(e)}"
|
| 611 |
+
except httpx.HTTPStatusError as e:
|
| 612 |
+
error_message = f"API 返回错误 (状态码 {e.response.status_code})。详情: {e.response.text}"
|
| 613 |
+
logger.error(f"调用 OpenAI API ({model}) 时出错: {error_message}", exc_info=True)
|
| 614 |
+
return f"抱歉,调用 AI 模型时出错: {error_message}"
|
| 615 |
+
except Exception as e:
|
| 616 |
+
logger.error(f"调用 OpenAI API ({model}) 时(httpx)遇到意外错误: {e}", exc_info=True)
|
| 617 |
+
# 向用户返回一个更友好的错误信息
|
| 618 |
+
return f"抱歉,调用 AI 模型时出错: {str(e)}"
|
| 619 |
+
|
| 620 |
+
async def close(self):
|
| 621 |
+
"""关闭 httpx 客户端。"""
|
| 622 |
+
await self.client.aclose()
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
# --- 4. 自定义过滤器 ---
|
| 626 |
+
|
| 627 |
+
class AdminFilter(filters.BaseFilter):
|
| 628 |
+
"""
|
| 629 |
+
(V4.5) 自定义 PTB 过滤器,用于检查用户是否为管理员 (从内存缓存读取)。
|
| 630 |
+
"""
|
| 631 |
+
def __init__(self, bot: 'TelegramBot'): # V4.5: 传入 bot 实例
|
| 632 |
+
self.bot = bot
|
| 633 |
+
super().__init__(name="AdminFilter")
|
| 634 |
+
|
| 635 |
+
def filter(self, message: Message) -> bool:
|
| 636 |
+
# V4.5: 从内存缓存读取
|
| 637 |
+
return message.from_user.id in self.bot.admin_users
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
class ScopeFilter(filters.BaseFilter):
|
| 641 |
+
"""
|
| 642 |
+
(V4.5) 自定义 PTB 过滤器,用于控制机器人的响应范围 (从内存缓存读取)。
|
| 643 |
+
|
| 644 |
+
允许的条件 (OR):
|
| 645 |
+
1. 私聊 (private)
|
| 646 |
+
2. (群组在白名单中 AND 话题*未*在黑名单中)
|
| 647 |
+
"""
|
| 648 |
+
def __init__(self, bot: 'TelegramBot'): # V4.5: 传入 bot 实例
|
| 649 |
+
self.bot = bot
|
| 650 |
+
super().__init__(name="ScopeFilter")
|
| 651 |
+
|
| 652 |
+
def filter(self, message: Message) -> bool:
|
| 653 |
+
# 1. 允许私聊
|
| 654 |
+
if message.chat.type == "private":
|
| 655 |
+
return True
|
| 656 |
+
|
| 657 |
+
# 2. 检查群组
|
| 658 |
+
if message.chat.type in ("group", "supergroup"):
|
| 659 |
+
# 2.1 检查群组是否在白名单中 (V4.5: 从内存读取)
|
| 660 |
+
if not message.chat.id in self.bot.whitelisted_groups:
|
| 661 |
+
logger.debug(f"忽略来自 {message.chat.id} 的消息 (群组未在白名单)。")
|
| 662 |
+
return False
|
| 663 |
+
|
| 664 |
+
# 2.2 检查话题是否在黑名单中 (V4.5: 从内存读取)
|
| 665 |
+
thread_id = message.message_thread_id
|
| 666 |
+
thread_id_key = thread_id if thread_id is not None else 0
|
| 667 |
+
topic_key = f"{message.chat.id}:{thread_id_key}"
|
| 668 |
+
|
| 669 |
+
if topic_key in self.bot.blacklisted_topics:
|
| 670 |
+
logger.debug(f"忽略来自 {topic_key} 的消息 (话题在黑名单)。")
|
| 671 |
+
return False
|
| 672 |
+
|
| 673 |
+
# 群组在白名单中,且话题不在黑名单中
|
| 674 |
+
return True
|
| 675 |
+
|
| 676 |
+
# 3. 忽略其他所有情况 (如频道)
|
| 677 |
+
return False
|
| 678 |
+
|
| 679 |
+
|
| 680 |
+
# --- 5. Telegram 机器人主类 ---
|
| 681 |
+
|
| 682 |
+
class TelegramBot:
|
| 683 |
+
"""
|
| 684 |
+
组织所有 Telegram 机器人逻辑、命令和消息处理器。
|
| 685 |
+
"""
|
| 686 |
+
# 群组回复配置
|
| 687 |
+
DEFAULT_GROUP_REPLY_CHANCE = 0.15 # 15% 随机回复概率
|
| 688 |
+
TRIGGER_COOLDOWN_SEC = 30 # 30 秒触发词冷却
|
| 689 |
+
|
| 690 |
+
# 预设解析正则表达式
|
| 691 |
+
PRESET_REGEX = re.compile(
|
| 692 |
+
r"^(name|user|system|assistant)[\s::]+(.+)",
|
| 693 |
+
re.IGNORECASE | re.MULTILINE
|
| 694 |
+
)
|
| 695 |
+
|
| 696 |
+
def __init__(self, config: Config, redis: RedisManager, openai: OpenAIClient):
|
| 697 |
+
self.config = config
|
| 698 |
+
self.redis = redis
|
| 699 |
+
self.openai = openai
|
| 700 |
+
|
| 701 |
+
# (V4.5) 内存缓存
|
| 702 |
+
self.group_context_cache: Dict[str, List[Dict[str, Any]]] = {}
|
| 703 |
+
self.cache_lock = asyncio.Lock() # 用于 group_context_cache
|
| 704 |
+
self.permission_lock = asyncio.Lock() # (V4.5) 用于权限/触发词缓存
|
| 705 |
+
self.save_job: Optional[Job] = None
|
| 706 |
+
self.activity_check_job: Optional[Job] = None # V4.7 新增
|
| 707 |
+
|
| 708 |
+
# (V4.5) 权限和触发词的内存缓存
|
| 709 |
+
self.admin_users: Set[int] = set()
|
| 710 |
+
self.whitelisted_groups: Set[int] = set()
|
| 711 |
+
self.blacklisted_topics: Set[str] = set()
|
| 712 |
+
self.group_triggers: Dict[int, str] = {}
|
| 713 |
+
self.trigger_cooldowns: Dict[str, float] = {} # Key: "chat_id:word", Val: expiry_timestamp
|
| 714 |
+
|
| 715 |
+
# (新增) RSS 客户端
|
| 716 |
+
self.rss_client = httpx.AsyncClient(timeout=10.0, verify=False) # 同样禁用 SSL
|
| 717 |
+
|
| 718 |
+
# (V4.8 新增) Setu 客户端
|
| 719 |
+
self.setu_client = httpx.AsyncClient(timeout=20.0, verify=False, follow_redirects=True)
|
| 720 |
+
|
| 721 |
+
# (新增 V4.4) 存储机器人名字
|
| 722 |
+
self.bot_name: Optional[str] = "Bot" # 默认值
|
| 723 |
+
|
| 724 |
+
self.application = ApplicationBuilder() \
|
| 725 |
+
.token(config.TELEGRAM_BOT_TOKEN) \
|
| 726 |
+
.post_init(self.post_init_setup) \
|
| 727 |
+
.post_shutdown(self.post_shutdown_cleanup) \
|
| 728 |
+
.job_queue(JobQueue()) \
|
| 729 |
+
.build()
|
| 730 |
+
|
| 731 |
+
# 实例化自定义过滤器 (V4.5: 传入 self)
|
| 732 |
+
self.admin_filter = AdminFilter(self)
|
| 733 |
+
self.scope_filter = ScopeFilter(self)
|
| 734 |
+
|
| 735 |
+
# 存储模型列表,避免频繁查询 Redis
|
| 736 |
+
self._model_list_cache: List[str] = []
|
| 737 |
+
|
| 738 |
+
def setup_handlers(self):
|
| 739 |
+
"""注册所有的命令和消息处理器。"""
|
| 740 |
+
logger.info("正在注册处理器...")
|
| 741 |
+
|
| 742 |
+
# --- 核心命令 ---
|
| 743 |
+
start_handler = CommandHandler("start", self.start_command, filters=filters.ChatType.PRIVATE)
|
| 744 |
+
# (V4.4) 为基础指令添加 scope_filter 以实现绝对黑名单
|
| 745 |
+
help_handler = CommandHandler("help", self.help_command, filters=self.scope_filter)
|
| 746 |
+
new_handler = CommandHandler("new", self.new_command, filters=self.scope_filter)
|
| 747 |
+
switch_model_handler = CommandHandler("switchmodel", self.switch_model_command, filters=self.scope_filter)
|
| 748 |
+
# (V4.8 新增) Setu 命令
|
| 749 |
+
setu_handler = CommandHandler("setu", self.setu_command, filters=self.scope_filter)
|
| 750 |
+
|
| 751 |
+
# --- 管理员命令 (使用 admin_filter) ---
|
| 752 |
+
add_admin_handler = CommandHandler("addadmin", self.add_admin_command, filters=self.admin_filter)
|
| 753 |
+
del_admin_handler = CommandHandler("deladmin", self.del_admin_command, filters=self.admin_filter)
|
| 754 |
+
save_history_handler = CommandHandler("savehistory", self.save_history_command, filters=self.admin_filter)
|
| 755 |
+
|
| 756 |
+
# V4.3 重构:
|
| 757 |
+
add_group_handler = CommandHandler(
|
| 758 |
+
"addgroup",
|
| 759 |
+
self.add_group_whitelist_command,
|
| 760 |
+
filters=self.admin_filter & filters.ChatType.GROUPS
|
| 761 |
+
)
|
| 762 |
+
blacklist_topic_handler = CommandHandler(
|
| 763 |
+
"blacklisttopic",
|
| 764 |
+
self.blacklist_topic_command,
|
| 765 |
+
filters=self.admin_filter & filters.ChatType.GROUPS
|
| 766 |
+
)
|
| 767 |
+
|
| 768 |
+
group_admin_filters = self.admin_filter & filters.ChatType.GROUPS
|
| 769 |
+
set_trigger_handler = CommandHandler(
|
| 770 |
+
"settrigger",
|
| 771 |
+
self.set_trigger_command,
|
| 772 |
+
filters=group_admin_filters
|
| 773 |
+
)
|
| 774 |
+
|
| 775 |
+
# --- 预设 (Preset) 命令 ---
|
| 776 |
+
set_preset_handler = CommandHandler(
|
| 777 |
+
"setpreset",
|
| 778 |
+
self.set_preset_command,
|
| 779 |
+
# 允许私聊, 允许文本(用于粘贴)和 .txt 文档
|
| 780 |
+
filters=filters.ChatType.PRIVATE & (filters.TEXT | filters.Document.TXT)
|
| 781 |
+
)
|
| 782 |
+
list_presets_handler = CommandHandler("listpresets", self.list_presets_command, filters=filters.ChatType.PRIVATE)
|
| 783 |
+
switch_preset_handler = CommandHandler("switchpreset", self.switch_preset_command, filters=filters.ChatType.PRIVATE)
|
| 784 |
+
del_preset_handler = CommandHandler("delpreset", self.del_preset_command, filters=filters.ChatType.PRIVATE)
|
| 785 |
+
|
| 786 |
+
# --- (V4.5 新增) RSS 命令 ---
|
| 787 |
+
# (V4.5) scope_filter 确保 /subrss 不能在黑名单话题中运行
|
| 788 |
+
sub_rss_handler = CommandHandler("subrss", self.sub_rss_command, filters=self.scope_filter)
|
| 789 |
+
unsub_rss_handler = CommandHandler("unsubrss", self.unsub_rss_command, filters=self.scope_filter)
|
| 790 |
+
|
| 791 |
+
# --- 回调处理器 (用于模型切换) ---
|
| 792 |
+
model_callback_handler = CallbackQueryHandler(self.select_model_callback, pattern="^model_select_")
|
| 793 |
+
|
| 794 |
+
# --- 核心消息处理器 (使用 scope_filter) ---
|
| 795 |
+
message_handler = MessageHandler(
|
| 796 |
+
(filters.TEXT | filters.PHOTO | filters.Document.TXT) & self.scope_filter & (~filters.COMMAND),
|
| 797 |
+
self.handle_message
|
| 798 |
+
)
|
| 799 |
+
|
| 800 |
+
# 注册所有处理器
|
| 801 |
+
handlers = [
|
| 802 |
+
start_handler, help_handler, new_handler, switch_model_handler,
|
| 803 |
+
setu_handler, # V4.8 新增
|
| 804 |
+
add_admin_handler, del_admin_handler,
|
| 805 |
+
add_group_handler, blacklist_topic_handler, # V4.3 更改
|
| 806 |
+
set_trigger_handler, save_history_handler,
|
| 807 |
+
set_preset_handler, list_presets_handler, switch_preset_handler, del_preset_handler,
|
| 808 |
+
sub_rss_handler, unsub_rss_handler, # V4.5 新增
|
| 809 |
+
model_callback_handler,
|
| 810 |
+
message_handler
|
| 811 |
+
]
|
| 812 |
+
self.application.add_handlers(handlers)
|
| 813 |
+
logger.info("处理器注册完毕。")
|
| 814 |
+
|
| 815 |
+
async def post_init_setup(self, application: Application):
|
| 816 |
+
"""
|
| 817 |
+
在机器人启动时(run_polling被调用后)执行的异步初始化任务。
|
| 818 |
+
(V4.7 更新)
|
| 819 |
+
"""
|
| 820 |
+
logger.info("正在执行机器人启动后数据初始化 (post_init)...")
|
| 821 |
+
|
| 822 |
+
# (V4.4 新增) 获取机器人名字
|
| 823 |
+
try:
|
| 824 |
+
bot_user = await application.bot.get_me()
|
| 825 |
+
self.bot_name = bot_user.name
|
| 826 |
+
logger.info(f"成功获取机器人名字: {self.bot_name}")
|
| 827 |
+
except Exception as e:
|
| 828 |
+
logger.error(f"无法获取机器人名字: {e}", exc_info=True)
|
| 829 |
+
self.bot_name = "Assistant" # 设置回退
|
| 830 |
+
|
| 831 |
+
# (V4.5) 1. 从 Redis 加载权限和配置到内存
|
| 832 |
+
self.admin_users = self.redis.get_admin_users()
|
| 833 |
+
self.whitelisted_groups = self.redis.get_whitelisted_groups()
|
| 834 |
+
self.blacklisted_topics = self.redis.get_blacklisted_topics()
|
| 835 |
+
self.group_triggers = self.redis.get_all_group_triggers()
|
| 836 |
+
logger.info(f"成功加载 {len(self.admin_users)} 个管理员, {len(self.whitelisted_groups)} 个白名单群组, {len(self.blacklisted_topics)} 个黑名单话题, {len(self.group_triggers)} 个触发词到内存。")
|
| 837 |
+
|
| 838 |
+
# 2. 同步环境变量 (Admin) 到 Redis (如果 Redis 为空)
|
| 839 |
+
self.redis.initialize_from_env(self.config.ADMIN_USERS)
|
| 840 |
+
|
| 841 |
+
# 3. 获取和缓存模型
|
| 842 |
+
models = await self.openai.get_models()
|
| 843 |
+
if models:
|
| 844 |
+
self._model_list_cache = models
|
| 845 |
+
self.redis.cache_model_list(models)
|
| 846 |
+
else:
|
| 847 |
+
logger.warning("无法从API获取模型列表,将尝试使用 Redis 缓存 (如果存在)。")
|
| 848 |
+
cached_models = self.redis.get_cached_model_list()
|
| 849 |
+
if cached_models:
|
| 850 |
+
self._model_list_cache = cached_models
|
| 851 |
+
logger.info("已从 Redis 缓存加载模型列表。")
|
| 852 |
+
else:
|
| 853 |
+
logger.error("严重: API 和 Redis 缓存均无模型列表。 \switchmodel 将无法工作。")
|
| 854 |
+
|
| 855 |
+
# 4. 从 Redis 加载群组历史到内存
|
| 856 |
+
await self.load_group_history_from_redis()
|
| 857 |
+
|
| 858 |
+
# 5. 启动定时任务 (群组保存 + RSS + 活跃度)
|
| 859 |
+
if not self.application.job_queue:
|
| 860 |
+
logger.error("JobQueue 未初始化! 无法启动定时任务。")
|
| 861 |
+
else:
|
| 862 |
+
# 群组保存
|
| 863 |
+
self.save_job = self.application.job_queue.run_repeating(
|
| 864 |
+
self.save_all_group_history_to_redis,
|
| 865 |
+
interval=3600, # 1 hour
|
| 866 |
+
first=3600, # 1 小时后开始
|
| 867 |
+
name="hourly_save"
|
| 868 |
+
)
|
| 869 |
+
logger.info("已启动每小时群组历史缓存定时器。")
|
| 870 |
+
|
| 871 |
+
# (新增) RSS 检查
|
| 872 |
+
if self.config.RSS_URL: # V4.5: 只要 URL 存在就启动
|
| 873 |
+
self.application.job_queue.run_repeating(
|
| 874 |
+
self.check_rss_feed,
|
| 875 |
+
interval=600, # 10 minutes
|
| 876 |
+
first=10, # 10 秒后开始
|
| 877 |
+
name="rss_check"
|
| 878 |
+
)
|
| 879 |
+
logger.info(f"已启动每 10 分钟 RSS 检查任务 (URL: {self.config.RSS_URL})。")
|
| 880 |
+
else:
|
| 881 |
+
logger.info("未配置 RSS_URL,跳过 RSS 任务。")
|
| 882 |
+
|
| 883 |
+
# (V4.7 新增) 启动群组活跃度检查
|
| 884 |
+
self.activity_check_job = self.application.job_queue.run_repeating(
|
| 885 |
+
self.check_group_activity,
|
| 886 |
+
interval=3600, # 每小时检查一次
|
| 887 |
+
first=60, # 启动 1 分钟后
|
| 888 |
+
name="group_activity_check"
|
| 889 |
+
)
|
| 890 |
+
logger.info("已启动每小时群组活跃度检查定时器。")
|
| 891 |
+
|
| 892 |
+
# 6. 注册 Telegram 命令
|
| 893 |
+
commands = [
|
| 894 |
+
BotCommand("help", "查看所有指令和帮助"),
|
| 895 |
+
BotCommand("new", "开始一个新的对话会话"),
|
| 896 |
+
BotCommand("switchmodel", "切换聊天模型"),
|
| 897 |
+
BotCommand("setu", "(V4.8) 随机获取一张图片 (可加 tag)"), # <-- 新增
|
| 898 |
+
BotCommand("subrss", "(V4.5) 订阅 RSS 更新到此聊天"),
|
| 899 |
+
BotCommand("unsubrss", "(V4.5) 取消此聊天的 RSS 订阅"),
|
| 900 |
+
BotCommand("setpreset", "(私聊) 设置AI预设 (通过文本或文件)"),
|
| 901 |
+
BotCommand("listpresets", "(私聊) 查看我的AI预设"),
|
| 902 |
+
BotCommand("switchpreset", "(私聊) 切换AI预设"),
|
| 903 |
+
BotCommand("delpreset", "(私聊) 删除AI预设"),
|
| 904 |
+
BotCommand("savehistory", "(管理员) 手动保存群组缓存到Redis"),
|
| 905 |
+
BotCommand("addgroup", "(管理员/群组中) 将此群组添加至白名单"), # V4.3 新增
|
| 906 |
+
BotCommand("blacklisttopic", "(管理员/话题中) 将此话题添加至黑名单"), # V4.3 新增
|
| 907 |
+
BotCommand("settrigger", "(管理员/群组中) 设置本群的AI回复触发词"),
|
| 908 |
+
BotCommand("addadmin", "(管理员) 添加管理员 (需回复或提供ID)"),
|
| 909 |
+
BotCommand("deladmin", "(管理员) 移除管理员 (需回复或提供ID)"),
|
| 910 |
+
]
|
| 911 |
+
await application.bot.set_my_commands(commands)
|
| 912 |
+
logger.info("Telegram Bot 命令已注册。")
|
| 913 |
+
|
| 914 |
+
# 7. 发送启动通知
|
| 915 |
+
op_id = self.config.OP_USER_ID
|
| 916 |
+
if not op_id:
|
| 917 |
+
logger.warning("未配置 OP_USER_ID,跳过启动通知。")
|
| 918 |
+
return
|
| 919 |
+
|
| 920 |
+
try:
|
| 921 |
+
help_text = self._get_help_text()
|
| 922 |
+
startup_msg = f"✅ **机器人 {self.bot_name} 已成功启动!**\n\n{help_text}"
|
| 923 |
+
await application.bot.send_message(
|
| 924 |
+
chat_id=op_id,
|
| 925 |
+
text=startup_msg,
|
| 926 |
+
parse_mode="Markdown"
|
| 927 |
+
)
|
| 928 |
+
logger.info(f"已成功向 OP ({op_id}) 发送启动通知。")
|
| 929 |
+
except Exception as e:
|
| 930 |
+
logger.error(f"向 OP ({op_id}) 发送启动消息失败: {e}", exc_info=True)
|
| 931 |
+
|
| 932 |
+
async def post_shutdown_cleanup(self, application: Application):
|
| 933 |
+
"""
|
| 934 |
+
在机器人关闭时(shutdown被调用后)执行的异步清理任务。
|
| 935 |
+
(V4.8 更新)
|
| 936 |
+
"""
|
| 937 |
+
logger.info("正在执行异步清理 (post_shutdown)...")
|
| 938 |
+
|
| 939 |
+
# 关机前最后保存一次
|
| 940 |
+
if self.group_context_cache:
|
| 941 |
+
logger.info("正在执行关机前最后一次群组历史保存...")
|
| 942 |
+
# 创建一个临时的 context 对象
|
| 943 |
+
fake_context = ContextTypes.DEFAULT_TYPE(application=self.application, chat_id=None, user_id=None)
|
| 944 |
+
await self.save_all_group_history_to_redis(fake_context)
|
| 945 |
+
logger.info("关机前保存完毕。")
|
| 946 |
+
|
| 947 |
+
if self.openai:
|
| 948 |
+
logger.info("正在关闭 OpenAI (httpx) 客户端...")
|
| 949 |
+
await self.openai.close()
|
| 950 |
+
logger.info("OpenAI (httpx) 客户端已关闭。")
|
| 951 |
+
|
| 952 |
+
if self.rss_client:
|
| 953 |
+
logger.info("正在关闭 RSS (httpx) 客户端...")
|
| 954 |
+
await self.rss_client.aclose()
|
| 955 |
+
logger.info("RSS (httpx) 客户端已关闭。")
|
| 956 |
+
|
| 957 |
+
# (V4.8 新增)
|
| 958 |
+
if self.setu_client:
|
| 959 |
+
logger.info("正在关闭 Setu (httpx) 客户端...")
|
| 960 |
+
await self.setu_client.aclose()
|
| 961 |
+
logger.info("Setu (httpx) 客户端已关闭。")
|
| 962 |
+
|
| 963 |
+
# --- 命令处理器实现 ---
|
| 964 |
+
|
| 965 |
+
def _get_help_text(self) -> str:
|
| 966 |
+
"""生成帮助文本。"""
|
| 967 |
+
return """
|
| 968 |
+
欢迎使用!我是您的 AI 助手。
|
| 969 |
+
|
| 970 |
+
**基础指令:**
|
| 971 |
+
/help - 显示此帮助信息
|
| 972 |
+
/new - 忘记上下文,开始新对话
|
| 973 |
+
/switchmodel - 选择要对话的 AI 模型
|
| 974 |
+
/setu [tag] [tag...] - (V4.8) 随机获取一张图片 (可加 tag)
|
| 975 |
+
/subrss - 订阅 RSS 更新到此聊天
|
| 976 |
+
/unsubrss - 取消此聊天的 RSS 订阅
|
| 977 |
+
|
| 978 |
+
**AI 预设 (System Prompt) [仅限私聊]:**
|
| 979 |
+
/setpreset - (回复此消息或上传 .txt) 设置预设。格式:
|
| 980 |
+
name: 预设名称
|
| 981 |
+
system: 你是一个...
|
| 982 |
+
user: 示例输入
|
| 983 |
+
assistant: 示例回复
|
| 984 |
+
/listpresets - 查看我所有的预设
|
| 985 |
+
/switchpreset [名称] - 切换预设
|
| 986 |
+
/delpreset [名称] - 删除预设
|
| 987 |
+
|
| 988 |
+
**管理员指令:**
|
| 989 |
+
/addgroup - (在群组中) 将此群组加入白名单
|
| 990 |
+
/blacklisttopic - (在*话题中*) 将此话题加入黑名单
|
| 991 |
+
/settrigger [词] - (在群组中) 设置一个词,包含它将必定触发回复
|
| 992 |
+
/savehistory - (任意位置) 手动保存群组缓存到Redis
|
| 993 |
+
/addadmin [user_id] - 添加管理员
|
| 994 |
+
/deladmin [user_id] - 移除管理员
|
| 995 |
+
|
| 996 |
+
**使用方法:**
|
| 997 |
+
1. (群组) 管理员需要先使用 /addgroup 将群组加入白名单。
|
| 998 |
+
2. (群组) 我会回复所有话题,除非你使用 /blacklisttopic 禁言特定话题 (包括 "常规" 话题)。
|
| 999 |
+
3. (群组) 默认我只会随机回复 (15%)。如果设置了 /settrigger,包含触发词的消息我会 @ 您并 100% 回复。
|
| 1000 |
+
4. (私聊) 您可以直接向我发送消息、图片或 .txt 文件。
|
| 1001 |
+
"""
|
| 1002 |
+
|
| 1003 |
+
async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 1004 |
+
"""私聊 \start 命令处理器。"""
|
| 1005 |
+
await update.message.reply_text(self._get_help_text(), parse_mode=ParseMode.MARKDOWN)
|
| 1006 |
+
|
| 1007 |
+
async def help_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 1008 |
+
"""\help 命令处理器。"""
|
| 1009 |
+
await update.message.reply_text(self._get_help_text(), parse_mode=ParseMode.MARKDOWN)
|
| 1010 |
+
|
| 1011 |
+
async def new_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 1012 |
+
"""
|
| 1013 |
+
\new 命令处理器。
|
| 1014 |
+
重构: 清除当前上下文。
|
| 1015 |
+
(新增) 同时清除内存缓存。
|
| 1016 |
+
"""
|
| 1017 |
+
user_id = update.effective_user.id
|
| 1018 |
+
chat_id = update.effective_chat.id
|
| 1019 |
+
chat_type = update.effective_chat.type
|
| 1020 |
+
thread_id = update.message.message_thread_id if update.message else None
|
| 1021 |
+
|
| 1022 |
+
# V4.3 修复: 确保 thread_id 键一致
|
| 1023 |
+
thread_id_key = thread_id if thread_id is not None else 0
|
| 1024 |
+
|
| 1025 |
+
# 1. 清除 Redis
|
| 1026 |
+
self.redis.clear_session(user_id, chat_id, thread_id)
|
| 1027 |
+
|
| 1028 |
+
# 2. (新增) 如果是群组,也清除内存缓存
|
| 1029 |
+
if chat_type in ("group", "supergroup"):
|
| 1030 |
+
# V4.3 修复: 使用正确的 context_key
|
| 1031 |
+
context_key = self.redis._get_context_key(user_id, chat_id, thread_id)
|
| 1032 |
+
async with self.cache_lock:
|
| 1033 |
+
if context_key in self.group_context_cache:
|
| 1034 |
+
del self.group_context_cache[context_key]
|
| 1035 |
+
logger.info(f"已为键 {context_key} 清除内存缓存。")
|
| 1036 |
+
|
| 1037 |
+
logger.info(f"用户 {user_id} 在 {chat_id}:{thread_id_key} 使用了 /new")
|
| 1038 |
+
await update.message.reply_text("💡 好的,我们来开始一个全新的对话吧!(当前上下文已清除)")
|
| 1039 |
+
|
| 1040 |
+
async def switch_model_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 1041 |
+
"""\switchmodel 命令处理器。显示模型选择键盘。"""
|
| 1042 |
+
models = self._model_list_cache
|
| 1043 |
+
if not models:
|
| 1044 |
+
models = self.redis.get_cached_model_list() or []
|
| 1045 |
+
|
| 1046 |
+
if not models:
|
| 1047 |
+
await update.message.reply_text("抱歉,暂时无法获取模型列表。请稍后再试。")
|
| 1048 |
+
return
|
| 1049 |
+
|
| 1050 |
+
current_model = self.redis.get_current_model(
|
| 1051 |
+
update.effective_user.id,
|
| 1052 |
+
self.config.DEFAULT_MODEL
|
| 1053 |
+
)
|
| 1054 |
+
|
| 1055 |
+
keyboard: List[List[InlineKeyboardButton]] = []
|
| 1056 |
+
# 每行最多放 2 个模型
|
| 1057 |
+
row = []
|
| 1058 |
+
for i, model_id in enumerate(models):
|
| 1059 |
+
# 标记当前选中的模型
|
| 1060 |
+
button_text = f"✅ {model_id}" if model_id == current_model else model_id
|
| 1061 |
+
|
| 1062 |
+
# 按钮文本 (button_text) 也可能超长 (64 字节限制)
|
| 1063 |
+
# 我们需要按字节截断
|
| 1064 |
+
max_bytes = 60 # 留 4 字节给 "..."
|
| 1065 |
+
button_text_bytes = button_text.encode('utf-8')
|
| 1066 |
+
|
| 1067 |
+
if len(button_text_bytes) > max_bytes:
|
| 1068 |
+
# 从末尾开始解码,直到找到一个有效的 UTF-8 截断点
|
| 1069 |
+
while max_bytes > 0:
|
| 1070 |
+
try:
|
| 1071 |
+
button_text = button_text_bytes[:max_bytes].decode('utf-8') + "..."
|
| 1072 |
+
break
|
| 1073 |
+
except UnicodeDecodeError:
|
| 1074 |
+
max_bytes -= 1
|
| 1075 |
+
else:
|
| 1076 |
+
# 极端情况,无法截断
|
| 1077 |
+
button_text = "Model..."
|
| 1078 |
+
|
| 1079 |
+
row.append(
|
| 1080 |
+
InlineKeyboardButton(
|
| 1081 |
+
button_text,
|
| 1082 |
+
# 使用索引 (i) 作为 callback_data,避免超长
|
| 1083 |
+
callback_data=f"model_select_{i}"
|
| 1084 |
+
)
|
| 1085 |
+
)
|
| 1086 |
+
|
| 1087 |
+
if len(row) == 2:
|
| 1088 |
+
keyboard.append(row)
|
| 1089 |
+
row = []
|
| 1090 |
+
if row: # 添加最后一行 (如果不满)
|
| 1091 |
+
keyboard.append(row)
|
| 1092 |
+
|
| 1093 |
+
reply_markup = InlineKeyboardMarkup(keyboard)
|
| 1094 |
+
await update.message.reply_text(f"请选择一个模型 (当前: {current_model}):", reply_markup=reply_markup)
|
| 1095 |
+
|
| 1096 |
+
# --- 回调处理器 ---
|
| 1097 |
+
|
| 1098 |
+
async def select_model_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 1099 |
+
"""处理模型选择键盘的回调。"""
|
| 1100 |
+
query = update.callback_query
|
| 1101 |
+
await query.answer() # 立即响应回调,消除加载状态
|
| 1102 |
+
|
| 1103 |
+
user_id = query.from_user.id
|
| 1104 |
+
model_name = "" # 预定义
|
| 1105 |
+
|
| 1106 |
+
try:
|
| 1107 |
+
model_index_str = query.data.split("model_select_", 1)[1]
|
| 1108 |
+
model_index = int(model_index_str)
|
| 1109 |
+
|
| 1110 |
+
models = self._model_list_cache
|
| 1111 |
+
if not models:
|
| 1112 |
+
models = self.redis.get_cached_model_list() or []
|
| 1113 |
+
|
| 1114 |
+
if not models:
|
| 1115 |
+
await query.edit_message_text("错误: 模型列表缓存已过期,请重试 /switchmodel。")
|
| 1116 |
+
return
|
| 1117 |
+
|
| 1118 |
+
if 0 <= model_index < len(models):
|
| 1119 |
+
model_name = models[model_index]
|
| 1120 |
+
else:
|
| 1121 |
+
raise ValueError("模型索引越界")
|
| 1122 |
+
|
| 1123 |
+
except (IndexError, TypeError, ValueError) as e:
|
| 1124 |
+
logger.warning(f"解析模型回调时出错: {e}. Data: {query.data}")
|
| 1125 |
+
await query.edit_message_text("选择出错,请重试。")
|
| 1126 |
+
return
|
| 1127 |
+
|
| 1128 |
+
if not model_name:
|
| 1129 |
+
await query.edit_message_text("无法确定所选模型,请重试。")
|
| 1130 |
+
return
|
| 1131 |
+
|
| 1132 |
+
self.redis.set_current_model(user_id, model_name)
|
| 1133 |
+
logger.info(f"用户 {user_id} 通过回调将模型切换为 {model_name}")
|
| 1134 |
+
|
| 1135 |
+
# 更新原始消息,移除键盘
|
| 1136 |
+
await query.edit_message_text(f"✅ 模型已切换为: {model_name}")
|
| 1137 |
+
|
| 1138 |
+
# --- 管理员命令实现 ---
|
| 1139 |
+
|
| 1140 |
+
async def _get_id_from_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> Optional[int]:
|
| 1141 |
+
"""从命令参数或回复的消息中提取 User ID。"""
|
| 1142 |
+
# 1. 检查回复
|
| 1143 |
+
if update.message.reply_to_message:
|
| 1144 |
+
return update.message.reply_to_message.from_user.id
|
| 1145 |
+
|
| 1146 |
+
# 2. 检查参数
|
| 1147 |
+
if context.args:
|
| 1148 |
+
try:
|
| 1149 |
+
return int(context.args[0])
|
| 1150 |
+
except (ValueError, IndexError):
|
| 1151 |
+
return None
|
| 1152 |
+
return None
|
| 1153 |
+
|
| 1154 |
+
async def add_admin_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 1155 |
+
"""\addadmin 命令处理器。"""
|
| 1156 |
+
user_id_to_add = await self._get_id_from_command(update, context)
|
| 1157 |
+
|
| 1158 |
+
if not user_id_to_add:
|
| 1159 |
+
await update.message.reply_text("请回复一个用户或提供 User ID。用法: /addadmin [user_id]")
|
| 1160 |
+
return
|
| 1161 |
+
|
| 1162 |
+
if user_id_to_add in self.admin_users: # V4.5: 读内存
|
| 1163 |
+
await update.message.reply_text(f"用户 {user_id_to_add} 已经是管理员了。")
|
| 1164 |
+
return
|
| 1165 |
+
|
| 1166 |
+
if self.redis.add_admin(user_id_to_add): # 写 Redis
|
| 1167 |
+
async with self.permission_lock: # 写内存
|
| 1168 |
+
self.admin_users.add(user_id_to_add)
|
| 1169 |
+
logger.info(f"管理员 {update.effective_user.id} 添加了新管理员 {user_id_to_add}")
|
| 1170 |
+
await update.message.reply_text(f"✅ 成功添加管理员: {user_id_to_add}")
|
| 1171 |
+
else:
|
| 1172 |
+
await update.message.reply_text("添加失败,请查看日志。")
|
| 1173 |
+
|
| 1174 |
+
async def del_admin_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 1175 |
+
"""\deladmin 命令处理器。"""
|
| 1176 |
+
user_id_to_remove = await self._get_id_from_command(update, context)
|
| 1177 |
+
|
| 1178 |
+
if not user_id_to_remove:
|
| 1179 |
+
await update.message.reply_text("请回复一个用户或提供 User ID。用法: /deladmin [user_id]")
|
| 1180 |
+
return
|
| 1181 |
+
|
| 1182 |
+
# 阻止 OP (第一个管理员) 被移除
|
| 1183 |
+
if user_id_to_remove == self.config.OP_USER_ID:
|
| 1184 |
+
await update.message.reply_text("无法移除超级管理员 (OP)。")
|
| 1185 |
+
return
|
| 1186 |
+
|
| 1187 |
+
if user_id_to_remove not in self.admin_users: # V4.5: 读内存
|
| 1188 |
+
await update.message.reply_text(f"用户 {user_id_to_remove} 不是管理员。")
|
| 1189 |
+
return
|
| 1190 |
+
|
| 1191 |
+
if self.redis.remove_admin(user_id_to_remove): # 写 Redis
|
| 1192 |
+
async with self.permission_lock: # 写内存
|
| 1193 |
+
self.admin_users.discard(user_id_to_remove)
|
| 1194 |
+
logger.info(f"管理员 {update.effective_user.id} 移除了管理员 {user_id_to_remove}")
|
| 1195 |
+
await update.message.reply_text(f"✅ 成功移除管理员: {user_id_to_remove}")
|
| 1196 |
+
else:
|
| 1197 |
+
await update.message.reply_text("移除失败,请查看日志。")
|
| 1198 |
+
|
| 1199 |
+
async def add_group_whitelist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 1200 |
+
"""(V4.3 新增) /addgroup 命令处理器。"""
|
| 1201 |
+
chat_id = update.message.chat.id
|
| 1202 |
+
chat_title = update.message.chat.title
|
| 1203 |
+
|
| 1204 |
+
if chat_id in self.whitelisted_groups: # V4.5: 读内存
|
| 1205 |
+
await update.message.reply_text(f"群组 '{chat_title}' (ID: {chat_id}) 已经在白名单中了。")
|
| 1206 |
+
return
|
| 1207 |
+
|
| 1208 |
+
if self.redis.add_group_whitelist(chat_id): # 写 Redis
|
| 1209 |
+
async with self.permission_lock: # 写内存
|
| 1210 |
+
self.whitelisted_groups.add(chat_id)
|
| 1211 |
+
logger.info(f"管理员 {update.effective_user.id} 将群组 '{chat_title}' ({chat_id}) 添加到白名单。")
|
| 1212 |
+
await update.message.reply_text(f"✅ 成功将群组 '{chat_title}' (ID: {chat_id}) 加入白名单。\n我现在会在此群组的*所有*未黑名单话题中回复消息。")
|
| 1213 |
+
else:
|
| 1214 |
+
await update.message.reply_text("添加白名单失败,请查看日志。")
|
| 1215 |
+
|
| 1216 |
+
async def blacklist_topic_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 1217 |
+
"""(V4.3 重构) /blacklisttopic 命令处理器。"""
|
| 1218 |
+
chat_id = update.message.chat.id
|
| 1219 |
+
chat_title = update.message.chat.title
|
| 1220 |
+
thread_id = update.message.message_thread_id
|
| 1221 |
+
|
| 1222 |
+
# V4.3 修复: 统一 "常规" 话题
|
| 1223 |
+
thread_id_key = thread_id if thread_id is not None else 0
|
| 1224 |
+
topic_key = f"{chat_id}:{thread_id_key}"
|
| 1225 |
+
topic_name = "常规话题 (General)" if thread_id_key == 0 else f"话题ID {thread_id_key}"
|
| 1226 |
+
|
| 1227 |
+
if topic_key in self.blacklisted_topics: # V4.5: 读内存
|
| 1228 |
+
await update.message.reply_text(f"话题 '{topic_name}' (ID: {topic_key}) 已经在黑名单中了。")
|
| 1229 |
+
return
|
| 1230 |
+
|
| 1231 |
+
if self.redis.add_blacklisted_topic(topic_key): # 写 Redis
|
| 1232 |
+
async with self.permission_lock: # 写内存
|
| 1233 |
+
self.blacklisted_topics.add(topic_key)
|
| 1234 |
+
logger.info(f"管理员 {update.effective_user.id} 将话题 '{topic_name}' ({topic_key}) 添加到黑名单。")
|
| 1235 |
+
await update.message.reply_text(f"✅ 成功将*此话题* ({topic_name}, ID: {topic_key}) 加入黑名单。\n我现在会忽略此话题的消息。")
|
| 1236 |
+
else:
|
| 1237 |
+
await update.message.reply_text("添加黑名单失败,请查看日志。")
|
| 1238 |
+
|
| 1239 |
+
async def set_trigger_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 1240 |
+
"""/settrigger [词] 命令处理器。"""
|
| 1241 |
+
chat_id = update.message.chat.id
|
| 1242 |
+
|
| 1243 |
+
if not context.args:
|
| 1244 |
+
await update.message.reply_text("请提供一个触发词。用法: /settrigger [词]")
|
| 1245 |
+
return
|
| 1246 |
+
|
| 1247 |
+
trigger_word = context.args[0].strip()
|
| 1248 |
+
|
| 1249 |
+
if not trigger_word:
|
| 1250 |
+
await update.message.reply_text("触发词不能为空。")
|
| 1251 |
+
return
|
| 1252 |
+
|
| 1253 |
+
try:
|
| 1254 |
+
self.redis.set_group_trigger(chat_id, trigger_word) # 写 Redis
|
| 1255 |
+
async with self.permission_lock: # 写内存
|
| 1256 |
+
self.group_triggers[chat_id] = trigger_word
|
| 1257 |
+
logger.info(f"管理员 {update.effective_user.id} 在群组 {chat_id} 设置了触发词: {trigger_word}")
|
| 1258 |
+
await update.message.reply_text(f"✅ 成功! 本群的回复触发词已设置为: \"{trigger_word}\"")
|
| 1259 |
+
except Exception as e:
|
| 1260 |
+
logger.error(f"设置群组 {chat_id} 触发词时出错: {e}", exc_info=True)
|
| 1261 |
+
await update.message.reply_text("设置触发词失败,请查看日志。")
|
| 1262 |
+
|
| 1263 |
+
async def save_history_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 1264 |
+
"""(新增) /savehistory (Admin) 手动保存并重置定时器。"""
|
| 1265 |
+
user_id = update.effective_user.id
|
| 1266 |
+
logger.info(f"管理员 {user_id} 手动触发了历史保存。")
|
| 1267 |
+
|
| 1268 |
+
if not context.job_queue:
|
| 1269 |
+
logger.error(f"用户 {user_id} 尝试保存历史,但 JobQueue 不可用。")
|
| 1270 |
+
await update.message.reply_text("❌ 错误: JobQueue 不可用。")
|
| 1271 |
+
return
|
| 1272 |
+
|
| 1273 |
+
# 1. 移除旧
|
| 1274 |
+
if self.save_job:
|
| 1275 |
+
self.save_job.schedule_removal()
|
| 1276 |
+
logger.info("移除了旧的保存定时器。")
|
| 1277 |
+
|
| 1278 |
+
# 2. 立即运行
|
| 1279 |
+
await self.save_all_group_history_to_redis(context)
|
| 1280 |
+
|
| 1281 |
+
# 3. 启动新
|
| 1282 |
+
self.save_job = context.job_queue.run_repeating(
|
| 1283 |
+
self.save_all_group_history_to_redis,
|
| 1284 |
+
interval=3600, # 1 hour
|
| 1285 |
+
first=3600, # 1 小时后
|
| 1286 |
+
name="hourly_save"
|
| 1287 |
+
)
|
| 1288 |
+
logger.info("已启动新的保存定时器。")
|
| 1289 |
+
await update.message.reply_text("✅ 已手动保存所有群组缓存到 Redis,并重置1小时定时器。")
|
| 1290 |
+
|
| 1291 |
+
|
| 1292 |
+
# --- 预设 (Preset) 命令实现 ---
|
| 1293 |
+
|
| 1294 |
+
def _parse_preset_text(self, text: str) -> Optional[Dict[str, Any]]:
|
| 1295 |
+
"""
|
| 1296 |
+
辅助函数: 解析预设文本。
|
| 1297 |
+
返回 {"name": str, "messages": List[Dict]} 或 None。
|
| 1298 |
+
"""
|
| 1299 |
+
name: Optional[str] = None
|
| 1300 |
+
messages: List[Dict[str, str]] = []
|
| 1301 |
+
|
| 1302 |
+
matches = self.PRESET_REGEX.finditer(text)
|
| 1303 |
+
for match in matches:
|
| 1304 |
+
key = match.group(1).lower()
|
| 1305 |
+
value = match.group(2).strip()
|
| 1306 |
+
|
| 1307 |
+
if key == "name":
|
| 1308 |
+
name = value
|
| 1309 |
+
elif key in ("user", "system", "assistant"):
|
| 1310 |
+
# 修复 V4.1: 将 content 包装为新格式
|
| 1311 |
+
messages.append({"role": key, "content": [{"type": "text", "text": value}]})
|
| 1312 |
+
|
| 1313 |
+
if not name or not messages:
|
| 1314 |
+
return None
|
| 1315 |
+
|
| 1316 |
+
return {"name": name, "messages": messages}
|
| 1317 |
+
|
| 1318 |
+
async def set_preset_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 1319 |
+
"""(新增) /setpreset 命令处理器 (文本或 .txt 文件)。"""
|
| 1320 |
+
user_id = update.effective_user.id
|
| 1321 |
+
text_to_parse: Optional[str] = None
|
| 1322 |
+
|
| 1323 |
+
# 1. 检查 .txt 文件
|
| 1324 |
+
if update.message.document and update.message.document.mime_type == "text/plain":
|
| 1325 |
+
try:
|
| 1326 |
+
doc_file = await update.message.document.get_file()
|
| 1327 |
+
with io.BytesIO() as file_bytes_io:
|
| 1328 |
+
await doc_file.download_to_memory(file_bytes_io)
|
| 1329 |
+
file_bytes_io.seek(0)
|
| 1330 |
+
text_to_parse = file_bytes_io.getvalue().decode('utf-8')
|
| 1331 |
+
logger.info(f"用户 {user_id} 正在通过 .txt 文件设置预设。")
|
| 1332 |
+
except Exception as e:
|
| 1333 |
+
logger.error(f"下载用户 {user_id} 的 .txt 预设文件时出错: {e}")
|
| 1334 |
+
await update.message.reply_text(f"抱歉,我无法读取 .txt 文件: {e}")
|
| 1335 |
+
return
|
| 1336 |
+
|
| 1337 |
+
# 2. 检查命令文本 (如果不是文件)
|
| 1338 |
+
if not text_to_parse:
|
| 1339 |
+
# 移除 /setpreset 命令本身
|
| 1340 |
+
if context.args:
|
| 1341 |
+
text_to_parse = update.message.text.split(None, 1)[-1]
|
| 1342 |
+
else:
|
| 1343 |
+
await update.message.reply_text(
|
| 1344 |
+
"用法错误。\n"
|
| 1345 |
+
"请使用 /setpreset [预设内容]...\n"
|
| 1346 |
+
"...或上传一个 .txt 文件并附上 /setpreset 命令。\n\n"
|
| 1347 |
+
"格式:\n"
|
| 1348 |
+
"name: 预设名称\n"
|
| 1349 |
+
"system: 你是一个...\n"
|
| 1350 |
+
"user: 示例...\n"
|
| 1351 |
+
"assistant: 好的..."
|
| 1352 |
+
)
|
| 1353 |
+
return
|
| 1354 |
+
|
| 1355 |
+
# 3. 解析
|
| 1356 |
+
preset_data = self._parse_preset_text(text_to_parse)
|
| 1357 |
+
|
| 1358 |
+
if not preset_data:
|
| 1359 |
+
await update.message.reply_text(
|
| 1360 |
+
"❌ 解析失败!\n"
|
| 1361 |
+
"请确保您的格式正确,并且至少包含 'name' 和一个 'system/user/assistant' 角色。"
|
| 1362 |
+
)
|
| 1363 |
+
return
|
| 1364 |
+
|
| 1365 |
+
name = preset_data["name"]
|
| 1366 |
+
messages = preset_data["messages"]
|
| 1367 |
+
|
| 1368 |
+
# 4. 保存
|
| 1369 |
+
if self.redis.set_preset(user_id, name, messages):
|
| 1370 |
+
logger.info(f"用户 {user_id} 成功设置了预设: {name}")
|
| 1371 |
+
await update.message.reply_text(
|
| 1372 |
+
f"✅ 预设已保存!\n"
|
| 1373 |
+
f"名称: **{name}**\n"
|
| 1374 |
+
f"包含 {len(messages)} 条消息。\n\n"
|
| 1375 |
+
f"使用 `/switchpreset {name}` 来激活它。",
|
| 1376 |
+
parse_mode=ParseMode.MARKDOWN
|
| 1377 |
+
)
|
| 1378 |
+
else:
|
| 1379 |
+
await update.message.reply_text("❌ 预设保存失败,请查看日志。")
|
| 1380 |
+
|
| 1381 |
+
async def list_presets_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 1382 |
+
"""(新增) /listpresets 命令处理器。"""
|
| 1383 |
+
user_id = update.effective_user.id
|
| 1384 |
+
presets = self.redis.list_presets(user_id)
|
| 1385 |
+
active_preset = self.redis.get_active_preset_name(user_id)
|
| 1386 |
+
|
| 1387 |
+
if not presets:
|
| 1388 |
+
await update.message.reply_text("您还没有保存任何预设。\n使用 /setpreset 来创建一个。")
|
| 1389 |
+
return
|
| 1390 |
+
|
| 1391 |
+
message = "以下是您保存的预设:\n\n"
|
| 1392 |
+
for name in presets:
|
| 1393 |
+
if name == active_preset:
|
| 1394 |
+
message += f"▶️ **{name}** (当前激活)\n"
|
| 1395 |
+
else:
|
| 1396 |
+
message += f"• {name}\n"
|
| 1397 |
+
|
| 1398 |
+
message += "\n使用 `/switchpreset [名称]` 来切换。"
|
| 1399 |
+
await update.message.reply_text(message, parse_mode=ParseMode.MARKDOWN)
|
| 1400 |
+
|
| 1401 |
+
async def switch_preset_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 1402 |
+
"""(新增) /switchpreset [name] 命令处理器。"""
|
| 1403 |
+
user_id = update.effective_user.id
|
| 1404 |
+
|
| 1405 |
+
if not context.args:
|
| 1406 |
+
# 如果没有参数,则关闭预设
|
| 1407 |
+
self.redis.set_active_preset(user_id, "")
|
| 1408 |
+
await update.message.reply_text("✅ 已关闭所有预设。")
|
| 1409 |
+
return
|
| 1410 |
+
|
| 1411 |
+
preset_name = context.args[0].strip()
|
| 1412 |
+
|
| 1413 |
+
# 检查预设是否存在
|
| 1414 |
+
if not self.redis.get_preset(user_id, preset_name):
|
| 1415 |
+
await update.message.reply_text(f"❌ 找不到名为 '{preset_name}' 的预设。")
|
| 1416 |
+
return
|
| 1417 |
+
|
| 1418 |
+
self.redis.set_active_preset(user_id, preset_name)
|
| 1419 |
+
logger.info(f"用户 {user_id} 切换预设为: {preset_name}")
|
| 1420 |
+
await update.message.reply_text(f"✅ 成功激活预设: **{preset_name}**", parse_mode=ParseMode.MARKDOWN)
|
| 1421 |
+
|
| 1422 |
+
async def del_preset_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 1423 |
+
"""(新增) /delpreset [name] 命令处理器。"""
|
| 1424 |
+
user_id = update.effective_user.id
|
| 1425 |
+
|
| 1426 |
+
if not context.args:
|
| 1427 |
+
await update.message.reply_text("请提供要删除的预设名称。用法: /delpreset [名称]")
|
| 1428 |
+
return
|
| 1429 |
+
|
| 1430 |
+
preset_name = context.args[0].strip()
|
| 1431 |
+
|
| 1432 |
+
if self.redis.delete_preset(user_id, preset_name):
|
| 1433 |
+
logger.info(f"用户 {user_id} 删除了预设: {preset_name}")
|
| 1434 |
+
await update.message.reply_text(f"✅ 成功删除预设: {preset_name}")
|
| 1435 |
+
else:
|
| 1436 |
+
await update.message.reply_text(f"❌ 找不到名为 '{preset_name}' 的预设,或删除失败。")
|
| 1437 |
+
|
| 1438 |
+
# --- (V4.5 新增) RSS 命令实现 ---
|
| 1439 |
+
async def sub_rss_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 1440 |
+
"""(V4.5 新增) /subrss 命令处理器。"""
|
| 1441 |
+
chat_id = update.effective_chat.id
|
| 1442 |
+
|
| 1443 |
+
if self.redis.add_rss_subscriber(chat_id):
|
| 1444 |
+
logger.info(f"RSS: 新订阅者: {chat_id}")
|
| 1445 |
+
await update.message.reply_text("✅ 成功订阅 RSS 更新到此聊天。")
|
| 1446 |
+
else:
|
| 1447 |
+
await update.message.reply_text("ℹ️ 此聊天已经订阅了 RSS。")
|
| 1448 |
+
|
| 1449 |
+
async def unsub_rss_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 1450 |
+
"""(V4.5 新增) /unsubrss 命令处理器。"""
|
| 1451 |
+
chat_id = update.effective_chat.id
|
| 1452 |
+
|
| 1453 |
+
if self.redis.remove_rss_subscriber(chat_id):
|
| 1454 |
+
logger.info(f"RSS: 取消订阅: {chat_id}")
|
| 1455 |
+
await update.message.reply_text("✅ 已取消此聊天的 RSS 订阅。")
|
| 1456 |
+
else:
|
| 1457 |
+
await update.message.reply_text("ℹ️ 此聊天未订阅 RSS。")
|
| 1458 |
+
|
| 1459 |
+
# --- (V4.8 新增) Setu 命令实现 ---
|
| 1460 |
+
async def setu_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 1461 |
+
"""(V4.8) /setu [tag...] 命令处理器。"""
|
| 1462 |
+
user_id = update.effective_user.id
|
| 1463 |
+
chat_id = update.effective_chat.id
|
| 1464 |
+
thread_id = update.message.message_thread_id
|
| 1465 |
+
|
| 1466 |
+
await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
|
| 1467 |
+
|
| 1468 |
+
base_url = "https://api.lolicon.app/setu/v2"
|
| 1469 |
+
params = {}
|
| 1470 |
+
params["r18"] = "regular"
|
| 1471 |
+
if context.args:
|
| 1472 |
+
params["tag"] = context.args
|
| 1473 |
+
logger.info(f"用户 {user_id} 在 {chat_id}:{thread_id} 请求带 tags 的 Setu: {context.args}")
|
| 1474 |
+
else:
|
| 1475 |
+
logger.info(f"用户 {user_id} 在 {chat_id}:{thread_id} 请求随机 Setu")
|
| 1476 |
+
|
| 1477 |
+
try:
|
| 1478 |
+
# 1. 请求 API
|
| 1479 |
+
async with self.setu_client as client:
|
| 1480 |
+
response = await client.get(base_url, params=params)
|
| 1481 |
+
response.raise_for_status()
|
| 1482 |
+
data = response.json()
|
| 1483 |
+
|
| 1484 |
+
if data.get("error") or not data.get("data"):
|
| 1485 |
+
logger.warning(f"Setu API 返回错误或空数据: {data.get('error')}")
|
| 1486 |
+
await update.message.reply_text("抱歉,API 返回错误或未找到图片。")
|
| 1487 |
+
return
|
| 1488 |
+
|
| 1489 |
+
image_data = data["data"][0]
|
| 1490 |
+
image_url = image_data.get("urls", {}).get("original")
|
| 1491 |
+
if not image_url:
|
| 1492 |
+
logger.warning(f"Setu API 响应中缺少 original URL: {image_data}")
|
| 1493 |
+
await update.message.reply_text("抱歉,API 响应格式不正确,缺少图片 URL。")
|
| 1494 |
+
return
|
| 1495 |
+
|
| 1496 |
+
# 2. 下载图片
|
| 1497 |
+
async with self.setu_client as client:
|
| 1498 |
+
# API 返回的 URL 可能需要替换为 pixiv.re 代理
|
| 1499 |
+
image_url = image_url.replace("i.pixiv.cat", "i.pixiv.re")
|
| 1500 |
+
logger.debug(f"正在下载 Setu 图片: {image_url}")
|
| 1501 |
+
image_response = await client.get(image_url)
|
| 1502 |
+
image_response.raise_for_status()
|
| 1503 |
+
image_bytes = image_response.content
|
| 1504 |
+
|
| 1505 |
+
# 3. 准备信息
|
| 1506 |
+
caption = (
|
| 1507 |
+
f"Title: {image_data.get('title', 'N/A')}\n"
|
| 1508 |
+
f"Author: {image_data.get('author', 'N/A')}\n"
|
| 1509 |
+
f"PID: {image_data.get('pid', 'N/A')}"
|
| 1510 |
+
)
|
| 1511 |
+
|
| 1512 |
+
# 4. 发送图片
|
| 1513 |
+
await update.message.reply_photo(
|
| 1514 |
+
photo=image_bytes,
|
| 1515 |
+
caption=caption,
|
| 1516 |
+
message_thread_id=thread_id
|
| 1517 |
+
)
|
| 1518 |
+
|
| 1519 |
+
# (V4.8) 更新活跃度时间戳
|
| 1520 |
+
if chat_type in ("group", "supergroup"):
|
| 1521 |
+
context_key = self.redis._get_context_key(user_id, chat_id, thread_id)
|
| 1522 |
+
self.redis.update_last_response_time(context_key)
|
| 1523 |
+
|
| 1524 |
+
except httpx.HTTPStatusError as e:
|
| 1525 |
+
logger.error(f"Setu API 请求失败 (状态码 {e.response.status_code}): {e.response.text}", exc_info=True)
|
| 1526 |
+
await update.message.reply_text(f"抱歉,获取图片失败 (HTTP 错误): {e.response.status_code}")
|
| 1527 |
+
except Exception as e:
|
| 1528 |
+
logger.error(f"Setu 命令执行失败: {e}", exc_info=True)
|
| 1529 |
+
await update.message.reply_text(f"抱歉,获取图片时遇到未知错误。")
|
| 1530 |
+
|
| 1531 |
+
# --- (V4.5 新增) 内存缓存辅助方法 ---
|
| 1532 |
+
|
| 1533 |
+
async def _add_to_group_cache(self, context_key: str, role: str, content: Any):
|
| 1534 |
+
"""
|
| 1535 |
+
(新增) 安全地向内存缓存添加消息并截断。
|
| 1536 |
+
(V4.1: content 已经是新格式的列表)
|
| 1537 |
+
"""
|
| 1538 |
+
async with self.cache_lock:
|
| 1539 |
+
history = self.group_context_cache.get(context_key, [])
|
| 1540 |
+
history.append({"role": role, "content": content})
|
| 1541 |
+
# 截断
|
| 1542 |
+
truncated_history = history[-self.redis.CONTEXT_HISTORY_LIMIT:]
|
| 1543 |
+
self.group_context_cache[context_key] = truncated_history
|
| 1544 |
+
|
| 1545 |
+
def _is_trigger_on_cooldown(self, chat_id: int, word: str) -> bool:
|
| 1546 |
+
"""(V4.5) 检查内存中的触发词冷却"""
|
| 1547 |
+
cooldown_key = f"{chat_id}:{word}"
|
| 1548 |
+
expiry_time = self.trigger_cooldowns.get(cooldown_key)
|
| 1549 |
+
|
| 1550 |
+
if expiry_time and time.time() < expiry_time:
|
| 1551 |
+
return True # Still on cooldown
|
| 1552 |
+
return False
|
| 1553 |
+
|
| 1554 |
+
def _set_trigger_cooldown(self, chat_id: int, word: str):
|
| 1555 |
+
"""(V4.5) 设置内存中的触发词冷却"""
|
| 1556 |
+
cooldown_key = f"{chat_id}:{word}"
|
| 1557 |
+
self.trigger_cooldowns[cooldown_key] = time.time() + self.TRIGGER_COOLDOWN_SEC
|
| 1558 |
+
|
| 1559 |
+
async def load_group_history_from_redis(self):
|
| 1560 |
+
"""(新增) 启动时从 Redis 加载所有群组上下文到内存。"""
|
| 1561 |
+
logger.info("正在从 Redis 加载群组历史到内存缓存...")
|
| 1562 |
+
count = 0
|
| 1563 |
+
try:
|
| 1564 |
+
# 使用 scan 替代 keys,避免阻塞
|
| 1565 |
+
cursor = 0
|
| 1566 |
+
while True:
|
| 1567 |
+
# V4.2 修复: 确保 scan 使用 self.redis.redis (原始客户端)
|
| 1568 |
+
cursor, keys = self.redis.redis.scan(cursor, match="context:group:*", count=100)
|
| 1569 |
+
if keys:
|
| 1570 |
+
async with self.cache_lock:
|
| 1571 |
+
for key_bytes in keys:
|
| 1572 |
+
key = key_bytes.decode('utf-8') # 解码
|
| 1573 |
+
raw_data = self.redis.redis.get(key)
|
| 1574 |
+
if raw_data:
|
| 1575 |
+
try:
|
| 1576 |
+
history = json.loads(raw_data)
|
| 1577 |
+
# 截断以防万一
|
| 1578 |
+
self.group_context_cache[key] = history[-self.redis.CONTEXT_HISTORY_LIMIT:]
|
| 1579 |
+
count += 1
|
| 1580 |
+
except json.JSONDecodeError:
|
| 1581 |
+
logger.warning(f"无法解析 Redis key {key} 的JSON数据")
|
| 1582 |
+
if cursor == 0:
|
| 1583 |
+
break
|
| 1584 |
+
logger.info(f"成功加载 {count} 个群组上下文到内存。")
|
| 1585 |
+
except Exception as e:
|
| 1586 |
+
logger.error(f"从 Redis 加载群组历史时出错: {e}", exc_info=True)
|
| 1587 |
+
|
| 1588 |
+
async def save_all_group_history_to_redis(self, context: ContextTypes.DEFAULT_TYPE):
|
| 1589 |
+
"""(新增) 将所有内存中的群组缓存保存回 Redis。"""
|
| 1590 |
+
logger.info("定时任务: 正在将群组历史缓存保存到 Redis...")
|
| 1591 |
+
count = 0
|
| 1592 |
+
cache_copy = {}
|
| 1593 |
+
async with self.cache_lock:
|
| 1594 |
+
# 复制一份以避免在迭代时修改
|
| 1595 |
+
cache_copy = self.group_context_cache.copy()
|
| 1596 |
+
|
| 1597 |
+
if not cache_copy:
|
| 1598 |
+
logger.info("定时任务: 内存缓存为空,无需保存。")
|
| 1599 |
+
return
|
| 1600 |
+
|
| 1601 |
+
try:
|
| 1602 |
+
# (upstash-redis 的 pipeline 不支持循环外定义)
|
| 1603 |
+
# 逐个 set
|
| 1604 |
+
for key, history in cache_copy.items():
|
| 1605 |
+
self.redis.redis.set(
|
| 1606 |
+
key,
|
| 1607 |
+
json.dumps(history),
|
| 1608 |
+
ex=self.redis.SESSION_EXPIRATION_SEC
|
| 1609 |
+
)
|
| 1610 |
+
count += 1
|
| 1611 |
+
logger.info(f"定时任务: 成功将 {count} 个群组上下文保存到 Redis。")
|
| 1612 |
+
except Exception as e:
|
| 1613 |
+
logger.error(f"定时保存群组历史到 Redis 时出错: {e}", exc_info=True)
|
| 1614 |
+
|
| 1615 |
+
# --- (新增) RSS 检查任务 ---
|
| 1616 |
+
async def check_rss_feed(self, context: ContextTypes.DEFAULT_TYPE):
|
| 1617 |
+
"""(V4.5 升级) 每 10 分钟检查一次 RSS 订阅。"""
|
| 1618 |
+
logger.info("RSS: 正在检查 RSS... ")
|
| 1619 |
+
try:
|
| 1620 |
+
# (V4.5) 从 Redis 读取订阅者
|
| 1621 |
+
subscriber_chat_ids = self.redis.get_rss_subscribers()
|
| 1622 |
+
if not subscriber_chat_ids:
|
| 1623 |
+
logger.info("RSS: 没有订阅者,跳过检查。")
|
| 1624 |
+
return
|
| 1625 |
+
|
| 1626 |
+
async with self.rss_client as client:
|
| 1627 |
+
response = await client.get(self.config.RSS_URL)
|
| 1628 |
+
response.raise_for_status()
|
| 1629 |
+
xmlText = response.text
|
| 1630 |
+
|
| 1631 |
+
# 使用用户提供的正则表达式
|
| 1632 |
+
match = re.search(r'<rdf:li\s+rdf:resource="[^"]*\/article\/(\d+)"[\s\/>]', xmlText)
|
| 1633 |
+
|
| 1634 |
+
if not match:
|
| 1635 |
+
logger.warning("RSS: 无法在 XML 中提取到文章链接。")
|
| 1636 |
+
return
|
| 1637 |
+
|
| 1638 |
+
latest_link_id = match.group(1)
|
| 1639 |
+
last_known_link = self.redis.get_last_rss_link()
|
| 1640 |
+
|
| 1641 |
+
if latest_link_id != last_known_link and latest_link_id:
|
| 1642 |
+
logger.info(f"RSS: 发现新文章! ID: {latest_link_id}. 正在推送给 {len(subscriber_chat_ids)} 个聊天。")
|
| 1643 |
+
message = f"主人,您订阅的魔装影姫cien更新啦!\n\nhttps://ci-en.dlsite.com/creator/4551/article/{latest_link_id}"
|
| 1644 |
+
|
| 1645 |
+
tasks = []
|
| 1646 |
+
for chat_id in subscriber_chat_ids:
|
| 1647 |
+
tasks.append(context.bot.send_message(chat_id, message))
|
| 1648 |
+
|
| 1649 |
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
| 1650 |
+
|
| 1651 |
+
# 检查发送结果
|
| 1652 |
+
for i, result in enumerate(results):
|
| 1653 |
+
if isinstance(result, Exception):
|
| 1654 |
+
chat_id = list(subscriber_chat_ids)[i]
|
| 1655 |
+
logger.error(f"RSS: 推送给 {chat_id} 失败: {result}")
|
| 1656 |
+
|
| 1657 |
+
# 更新 Redis
|
| 1658 |
+
self.redis.set_last_rss_link(latest_link_id)
|
| 1659 |
+
else:
|
| 1660 |
+
logger.info("RSS: 未发现新文章。")
|
| 1661 |
+
except Exception as e:
|
| 1662 |
+
logger.error(f"RSS 任务失败: {e}", exc_info=True)
|
| 1663 |
+
|
| 1664 |
+
# --- (V4.7 新增) 活跃度检查任务 ---
|
| 1665 |
+
async def check_group_activity(self, context: ContextTypes.DEFAULT_TYPE):
|
| 1666 |
+
"""(V4.7) 每小时检查一次群组活跃度。"""
|
| 1667 |
+
logger.info("定时任务: 正在检查群组活跃度...")
|
| 1668 |
+
now = time.time()
|
| 1669 |
+
|
| 1670 |
+
# 1. 从 Redis 读取所有时间戳
|
| 1671 |
+
last_times = self.redis.get_all_last_response_times()
|
| 1672 |
+
|
| 1673 |
+
# 2. 从内存读取黑名单 (V4.5)
|
| 1674 |
+
blacklisted_topics_mem = self.blacklisted_topics
|
| 1675 |
+
|
| 1676 |
+
# 3. 获取 OP 的预设 (如果 OP 存在)
|
| 1677 |
+
op_preset_messages = []
|
| 1678 |
+
if self.config.OP_USER_ID:
|
| 1679 |
+
op_preset_messages = self.redis.get_active_preset_messages(self.config.OP_USER_ID)
|
| 1680 |
+
|
| 1681 |
+
# 4. 准备硬编码的提示 (V4.1 格式)
|
| 1682 |
+
reengage_prompt = [
|
| 1683 |
+
{"type": "text", "text": "【现在在这个群聊里已经过去24小时没有人回复消息了,说句话活跃一下气氛吧】"}
|
| 1684 |
+
]
|
| 1685 |
+
|
| 1686 |
+
tasks_to_run = []
|
| 1687 |
+
|
| 1688 |
+
for context_key, last_time in last_times.items():
|
| 1689 |
+
try:
|
| 1690 |
+
# 只检查群组
|
| 1691 |
+
if not context_key.startswith("context:group:"):
|
| 1692 |
+
continue
|
| 1693 |
+
|
| 1694 |
+
# 检查是否在黑名单中 (从内存读取)
|
| 1695 |
+
if context_key in blacklisted_topics_mem:
|
| 1696 |
+
continue
|
| 1697 |
+
|
| 1698 |
+
# 检查是否超过 24 小时 (86400 秒)
|
| 1699 |
+
if now - last_time > 86400:
|
| 1700 |
+
logger.info(f"活跃度检查: 话题 {context_key} 已超过 24 小时未响应。")
|
| 1701 |
+
|
| 1702 |
+
# 解析 chat_id 和 thread_id
|
| 1703 |
+
parts = context_key.split(':')
|
| 1704 |
+
chat_id = int(parts[2])
|
| 1705 |
+
thread_id = int(parts[3])
|
| 1706 |
+
|
| 1707 |
+
# 准备 API 请求
|
| 1708 |
+
history_for_api = op_preset_messages + [{"role": "user", "content": reengage_prompt}]
|
| 1709 |
+
|
| 1710 |
+
# (V4.7) 添加一个辅助 coroutine 来处理单个请求
|
| 1711 |
+
tasks_to_run.append(self.send_reengage_message(
|
| 1712 |
+
context, chat_id, thread_id, history_for_api, context_key
|
| 1713 |
+
))
|
| 1714 |
+
|
| 1715 |
+
except Exception as e:
|
| 1716 |
+
logger.error(f"处理活跃度检查 {context_key} 时出错: {e}")
|
| 1717 |
+
|
| 1718 |
+
if tasks_to_run:
|
| 1719 |
+
logger.info(f"活跃度检查: 发现 {len(tasks_to_run)} 个不活跃的话题,正在发送消息...")
|
| 1720 |
+
await asyncio.gather(*tasks_to_run)
|
| 1721 |
+
else:
|
| 1722 |
+
logger.info("定时任务: 所有群组均在 24 小时内活跃。")
|
| 1723 |
+
|
| 1724 |
+
async def send_reengage_message(
|
| 1725 |
+
self,
|
| 1726 |
+
context: ContextTypes.DEFAULT_TYPE,
|
| 1727 |
+
chat_id: int,
|
| 1728 |
+
thread_id: int,
|
| 1729 |
+
history_for_api: List[Dict[str, Any]],
|
| 1730 |
+
context_key: str
|
| 1731 |
+
):
|
| 1732 |
+
"""(V4.7) 活跃度检查的辅助函数,用于生成和发送消息。"""
|
| 1733 |
+
try:
|
| 1734 |
+
# 1. 生成 AI 回复 (使用默认模型)
|
| 1735 |
+
bot_response_text = await self.openai.generate_response(
|
| 1736 |
+
self.config.DEFAULT_MODEL,
|
| 1737 |
+
history_for_api
|
| 1738 |
+
)
|
| 1739 |
+
|
| 1740 |
+
is_error = False
|
| 1741 |
+
if bot_response_text is None or bot_response_text.startswith("抱歉,"):
|
| 1742 |
+
is_error = True
|
| 1743 |
+
logger.warning(f"活跃度检查: AI 为 {context_key} 生成了错误/空回复。")
|
| 1744 |
+
return # 不发送错误消息
|
| 1745 |
+
|
| 1746 |
+
# 2. 准备机器人回复 (V4.4 格式)
|
| 1747 |
+
bot_name = self.bot_name or "Assistant"
|
| 1748 |
+
# (V4.7) 活跃度消息不 @ 任何人
|
| 1749 |
+
prefixed_response = f"{bot_name}: {bot_response_text}"
|
| 1750 |
+
assistant_api_content = [{"type": "text", "text": prefixed_response}]
|
| 1751 |
+
|
| 1752 |
+
thread_id_to_send = thread_id if thread_id != 0 else None
|
| 1753 |
+
|
| 1754 |
+
# 3. 发送消息
|
| 1755 |
+
try:
|
| 1756 |
+
await context.bot.send_message(
|
| 1757 |
+
chat_id=chat_id,
|
| 1758 |
+
text=bot_response_text, # V4.7: 不带前缀
|
| 1759 |
+
message_thread_id=thread_id_to_send,
|
| 1760 |
+
parse_mode=ParseMode.MARKDOWN
|
| 1761 |
+
)
|
| 1762 |
+
except BadRequest as e:
|
| 1763 |
+
if "Can't parse entities" in str(e):
|
| 1764 |
+
logger.warning(f"活跃度检查 (Markdown 失败): {e}. 正在作为纯文本重试。")
|
| 1765 |
+
await context.bot.send_message(
|
| 1766 |
+
chat_id=chat_id,
|
| 1767 |
+
text=bot_response_text, # V4.7: 不带前缀
|
| 1768 |
+
message_thread_id=thread_id_to_send,
|
| 1769 |
+
parse_mode=None
|
| 1770 |
+
)
|
| 1771 |
+
else:
|
| 1772 |
+
raise # 抛出其他 BadRequest
|
| 1773 |
+
|
| 1774 |
+
# 4. 更新内存缓存
|
| 1775 |
+
await self._add_to_group_cache(context_key, "assistant", assistant_api_content)
|
| 1776 |
+
|
| 1777 |
+
# 5. 更新 Redis 中的最后响应时间 (!!)
|
| 1778 |
+
self.redis.update_last_response_time(context_key)
|
| 1779 |
+
logger.info(f"活跃度检查: 成功发送消息到 {context_key} 并更新时间戳。")
|
| 1780 |
+
|
| 1781 |
+
except Exception as e:
|
| 1782 |
+
logger.error(f"发送活跃度消息到 {context_key} 时失败: {e}", exc_info=True)
|
| 1783 |
+
|
| 1784 |
+
|
| 1785 |
+
# --- 核心消息处理器 ---
|
| 1786 |
+
|
| 1787 |
+
async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 1788 |
+
"""
|
| 1789 |
+
处理所有符合 ScopeFilter 的文本、图片和 .txt 文件消息。
|
| 1790 |
+
(V4.6 更新)
|
| 1791 |
+
"""
|
| 1792 |
+
user = update.effective_user
|
| 1793 |
+
if not user:
|
| 1794 |
+
logger.warning("无法获取用户信息 (消息可能来自频道?),忽略。")
|
| 1795 |
+
return
|
| 1796 |
+
|
| 1797 |
+
user_id = user.id
|
| 1798 |
+
chat_id = update.effective_chat.id
|
| 1799 |
+
chat_type = update.effective_chat.type
|
| 1800 |
+
thread_id = update.message.message_thread_id if update.message else None
|
| 1801 |
+
|
| 1802 |
+
text_content = update.message.text or update.message.caption
|
| 1803 |
+
|
| 1804 |
+
# (V4.4 新增) 获取用户名
|
| 1805 |
+
user_name = user.first_name or user.full_name or "User"
|
| 1806 |
+
|
| 1807 |
+
# --- 1. 准备用户消息 (多模态) ---
|
| 1808 |
+
user_api_content: List[Dict[str, Any]] = []
|
| 1809 |
+
|
| 1810 |
+
# 提取文本 (来自消息或图片标题)
|
| 1811 |
+
if text_content:
|
| 1812 |
+
# (V4.4) 群聊添加前缀
|
| 1813 |
+
prefixed_text = f"{user_name}: {text_content}" if chat_type != "private" else text_content
|
| 1814 |
+
user_api_content.append({"type": "text", "text": prefixed_text})
|
| 1815 |
+
|
| 1816 |
+
# (新增) 提取 .txt 文件
|
| 1817 |
+
if update.message.document and update.message.document.mime_type == "text/plain":
|
| 1818 |
+
try:
|
| 1819 |
+
doc_file = await update.message.document.get_file()
|
| 1820 |
+
with io.BytesIO() as file_bytes_io:
|
| 1821 |
+
await doc_file.download_to_memory(file_bytes_io)
|
| 1822 |
+
file_bytes_io.seek(0)
|
| 1823 |
+
file_text = file_bytes_io.getvalue().decode('utf-8')
|
| 1824 |
+
|
| 1825 |
+
# (V4.4) 群聊添加前缀
|
| 1826 |
+
file_info = f"[上传了 .txt 文件]: {file_text}"
|
| 1827 |
+
prefixed_text = f"{user_name}: {file_info}" if chat_type != "private" else file_info
|
| 1828 |
+
|
| 1829 |
+
user_api_content.append({"type": "text", "text": prefixed_text})
|
| 1830 |
+
logger.info(f"已为用户 {user_id} 成功读取 .txt 文件。")
|
| 1831 |
+
except Exception as e:
|
| 1832 |
+
logger.error(f"处理用户 {user_id} 的 .txt 文件时出错: {e}", exc_info=True)
|
| 1833 |
+
await update.message.reply_text("抱歉,我无法读取这个 .txt 文件。")
|
| 1834 |
+
return
|
| 1835 |
+
|
| 1836 |
+
# 提取图片 (如果有)
|
| 1837 |
+
if update.message.photo:
|
| 1838 |
+
try:
|
| 1839 |
+
photo = update.message.photo[-1] # 获取最高清的图片
|
| 1840 |
+
photo_file = await photo.get_file()
|
| 1841 |
+
|
| 1842 |
+
with io.BytesIO() as file_bytes_io:
|
| 1843 |
+
await photo_file.download_to_memory(file_bytes_io)
|
| 1844 |
+
file_bytes_io.seek(0)
|
| 1845 |
+
base64_image = base64.b64encode(file_bytes_io.getvalue()).decode('utf-8')
|
| 1846 |
+
|
| 1847 |
+
# (V4.1) 图像部分不需要名字前缀
|
| 1848 |
+
image_url_payload = {"url": f"data:image/jpeg;base64,{base64_image}"}
|
| 1849 |
+
user_api_content.append({"type": "image_url", "image_url": image_url_payload})
|
| 1850 |
+
logger.info(f"已为用户 {user_id} 成功编码图片。")
|
| 1851 |
+
|
| 1852 |
+
except Exception as e:
|
| 1853 |
+
logger.error(f"处理用户 {user_id} 的图片时出错: {e}", exc_info=True)
|
| 1854 |
+
await update.message.reply_text("抱歉,我无法处理这张图片。")
|
| 1855 |
+
return
|
| 1856 |
+
|
| 1857 |
+
# 如果没有提取到任何内容 (例如,只有贴纸),则忽略
|
| 1858 |
+
if not user_api_content:
|
| 1859 |
+
logger.debug(f"来自用户 {user_id} 的消息没有可处理的内容,忽略。")
|
| 1860 |
+
return
|
| 1861 |
+
|
| 1862 |
+
# --- 2. (新增) 立即更新群组内存缓存 (用户消息) ---
|
| 1863 |
+
if chat_type in ("group", "supergroup"):
|
| 1864 |
+
context_key = self.redis._get_context_key(user_id, chat_id, thread_id)
|
| 1865 |
+
await self._add_to_group_cache(context_key, "user", user_api_content)
|
| 1866 |
+
|
| 1867 |
+
# --- 3. 决策: 是否回复? (群组逻辑) ---
|
| 1868 |
+
should_reply = False
|
| 1869 |
+
is_random_reply = False # (V4.5 新增) 标记是否为随机回复
|
| 1870 |
+
|
| 1871 |
+
if chat_type == "private":
|
| 1872 |
+
should_reply = True
|
| 1873 |
+
|
| 1874 |
+
elif chat_type in ("group", "supergroup"):
|
| 1875 |
+
trigger_word = self.group_triggers.get(chat_id) # V4.5: 读内存
|
| 1876 |
+
# 修复 V4.0: 移除 \b (单词边界),实现包含匹配
|
| 1877 |
+
if text_content and trigger_word and re.search(re.escape(trigger_word), text_content, re.IGNORECASE):
|
| 1878 |
+
# V4.5: 读/写内存冷却
|
| 1879 |
+
if not self._is_trigger_on_cooldown(chat_id, trigger_word):
|
| 1880 |
+
should_reply = True
|
| 1881 |
+
is_random_reply = False # (V4.5) 这是触发词回复
|
| 1882 |
+
logger.info(f"群组 {chat_id} 触发词 '{trigger_word}' 被命中,设置冷却。")
|
| 1883 |
+
self._set_trigger_cooldown(chat_id, trigger_word)
|
| 1884 |
+
else:
|
| 1885 |
+
logger.debug(f"群组 {chat_id} 触发词 '{trigger_word}' 仍在冷却中,忽略。")
|
| 1886 |
+
return # 冷却中,不回复
|
| 1887 |
+
|
| 1888 |
+
if not should_reply:
|
| 1889 |
+
if random.random() < self.DEFAULT_GROUP_REPLY_CHANCE:
|
| 1890 |
+
should_reply = True
|
| 1891 |
+
is_random_reply = True # (V4.5) 这是随机回复
|
| 1892 |
+
logger.info(f"群组 {chat_id} 随机回复 (15%) 命中。")
|
| 1893 |
+
else:
|
| 1894 |
+
logger.debug(f"群组 {chat_id} 随机回复 (15%) 未命中,忽略。")
|
| 1895 |
+
return # 未命中随机,不回复
|
| 1896 |
+
|
| 1897 |
+
# --- 决策完毕 ---
|
| 1898 |
+
if not should_reply:
|
| 1899 |
+
return # 最终决定不回复
|
| 1900 |
+
|
| 1901 |
+
# 4. 发送 "正在输入..." 状态
|
| 1902 |
+
try:
|
| 1903 |
+
await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
|
| 1904 |
+
except Exception:
|
| 1905 |
+
pass # 忽略错误
|
| 1906 |
+
|
| 1907 |
+
# 5. 获取会话、模型和历史
|
| 1908 |
+
model = self.redis.get_current_model(user_id, self.config.DEFAULT_MODEL)
|
| 1909 |
+
preset_messages = self.redis.get_active_preset_messages(user_id)
|
| 1910 |
+
|
| 1911 |
+
# --- 重构: 获取历史记录 ---
|
| 1912 |
+
history_from_cache = []
|
| 1913 |
+
if chat_type == "private":
|
| 1914 |
+
# 私聊: 从 Redis 读取
|
| 1915 |
+
history_from_cache = self.redis.get_conversation_history(user_id, chat_id, thread_id)
|
| 1916 |
+
elif chat_type in ("group", "supergroup"):
|
| 1917 |
+
# 群组: 从内存缓存读取
|
| 1918 |
+
context_key = self.redis._get_context_key(user_id, chat_id, thread_id)
|
| 1919 |
+
async with self.cache_lock:
|
| 1920 |
+
# 注意: 缓存中已包含当前的用户消息,所以我们不再需要 + user_api_content
|
| 1921 |
+
history_from_cache = self.group_context_cache.get(context_key, [])
|
| 1922 |
+
|
| 1923 |
+
# 准备 API 请求历史
|
| 1924 |
+
if chat_type == "private":
|
| 1925 |
+
# 私聊: 预设 + Redis历史 + 当前消息
|
| 1926 |
+
history_for_api = preset_messages + history_from_cache + [{"role": "user", "content": user_api_content}]
|
| 1927 |
+
else:
|
| 1928 |
+
# 群组: 预设 + 内存历史 (已包含当前消息)
|
| 1929 |
+
history_for_api = preset_messages + history_from_cache
|
| 1930 |
+
|
| 1931 |
+
# 6. 调用 API
|
| 1932 |
+
bot_response_text = await self.openai.generate_response(model, history_for_api)
|
| 1933 |
+
|
| 1934 |
+
# --- 7. 错误检查 ---
|
| 1935 |
+
is_error = False
|
| 1936 |
+
if bot_response_text is None:
|
| 1937 |
+
is_error = True
|
| 1938 |
+
bot_response_text = "抱歉,AI 响应为空 (None),请重试。"
|
| 1939 |
+
elif bot_response_text.startswith("抱歉,"):
|
| 1940 |
+
is_error = True
|
| 1941 |
+
|
| 1942 |
+
# 8. (V4.6 更改) @ 提及 (仅限触发词回复)
|
| 1943 |
+
mention_prefix = ""
|
| 1944 |
+
if chat_type != "private" and not is_random_reply: # 仅在群组的*触发词*回复时
|
| 1945 |
+
try:
|
| 1946 |
+
# [](tg://user?id=12345) (使用 U+2060 WORD JOINER 作为 "不可见" 链接文本)
|
| 1947 |
+
mention_prefix = f"[\u2060](tg://user?id={user_id}) "
|
| 1948 |
+
logger.info(f"为触发词回复添加 @{user_name} 提及。")
|
| 1949 |
+
except Exception as e:
|
| 1950 |
+
logger.warning(f"为触发词回复添加 @ 提及失败: {e}")
|
| 1951 |
+
# 失败也无妨,继续执行
|
| 1952 |
+
|
| 1953 |
+
# (V4.5) 将提及(如果有)添加到回复文本的开头
|
| 1954 |
+
bot_response_text = mention_prefix + bot_response_text
|
| 1955 |
+
|
| 1956 |
+
# 9. 回复用户
|
| 1957 |
+
try:
|
| 1958 |
+
# (新增 V4.2) 尝试 Markdown
|
| 1959 |
+
await update.message.reply_text(
|
| 1960 |
+
bot_response_text,
|
| 1961 |
+
message_thread_id=thread_id,
|
| 1962 |
+
parse_mode=ParseMode.MARKDOWN
|
| 1963 |
+
)
|
| 1964 |
+
except BadRequest as e:
|
| 1965 |
+
# (V4.3 修复) 如果 Markdown 解析失败 (例如格式错误),则作为普通文本发送
|
| 1966 |
+
# 停止使用 reply_text 以避免 "Message not found"
|
| 1967 |
+
if "Can't parse entities" in str(e):
|
| 1968 |
+
logger.warning(f"Markdown 解析失败: {e}. 正在作为纯文本重试。")
|
| 1969 |
+
try:
|
| 1970 |
+
await context.bot.send_message(
|
| 1971 |
+
chat_id=chat_id,
|
| 1972 |
+
text=bot_response_text,
|
| 1973 |
+
message_thread_id=thread_id,
|
| 1974 |
+
parse_mode=None # 纯文本
|
| 1975 |
+
)
|
| 1976 |
+
except Exception as fallback_e:
|
| 1977 |
+
logger.error(f"纯文本回退发送失败: {fallback_e}", exc_info=True)
|
| 1978 |
+
else:
|
| 1979 |
+
logger.error(f"发送消息时发生意外的 BadRequest: {e}", exc_info=True)
|
| 1980 |
+
except Exception as e:
|
| 1981 |
+
logger.error(f"发送回复时发生未知错误: {e}", exc_info=True)
|
| 1982 |
+
|
| 1983 |
+
|
| 1984 |
+
# 10. (重要) 只有在 *没有* 错误时才保存上下文
|
| 1985 |
+
if not is_error:
|
| 1986 |
+
# (V4.4 新增) 为群聊 AI 回复添加前缀
|
| 1987 |
+
if chat_type != "private":
|
| 1988 |
+
bot_name = self.bot_name or "Assistant"
|
| 1989 |
+
user_name = user.first_name or "User"
|
| 1990 |
+
# (V4.5 修复) 存储 *不带* @ 提及的原始回复
|
| 1991 |
+
original_response_text = bot_response_text[len(mention_prefix):]
|
| 1992 |
+
prefixed_response = f"{bot_name}: [回复 @{user_name}]: {original_response_text}"
|
| 1993 |
+
assistant_api_content = [{"type": "text", "text": prefixed_response}]
|
| 1994 |
+
else:
|
| 1995 |
+
assistant_api_content = [{"type": "text", "text": bot_response_text}]
|
| 1996 |
+
|
| 1997 |
+
if chat_type == "private":
|
| 1998 |
+
# 私聊: 将 "用户" 和 "助手" 都写入 Redis
|
| 1999 |
+
self.redis.add_to_conversation(user_id, chat_id, thread_id, "user", user_api_content)
|
| 2000 |
+
self.redis.add_to_conversation(user_id, chat_id, thread_id, "assistant", assistant_api_content)
|
| 2001 |
+
# (V4.7) 私聊也更新时间戳 (如果用户想设置活跃度定时器)
|
| 2002 |
+
# context_key = self.redis._get_context_key(user_id, chat_id, thread_id)
|
| 2003 |
+
# self.redis.update_last_response_time(context_key)
|
| 2004 |
+
elif chat_type in ("group", "supergroup"):
|
| 2005 |
+
# 群组: 仅将 "助手" 写入内存缓存 (用户消息已在第2步写入)
|
| 2006 |
+
context_key = self.redis._get_context_key(user_id, chat_id, thread_id)
|
| 2007 |
+
await self._add_to_group_cache(context_key, "assistant", assistant_api_content)
|
| 2008 |
+
# (V4.7) 更新群组的最后响应时间
|
| 2009 |
+
self.redis.update_last_response_time(context_key)
|
| 2010 |
+
else:
|
| 2011 |
+
logger.info(f"检测到 AI 错误,跳过历史记录保存。 (User: {user_id}, Chat: {chat_id})")
|
| 2012 |
+
|
| 2013 |
+
|
| 2014 |
+
# --- 6. 启动器 ---
|
| 2015 |
+
|
| 2016 |
+
def main():
|
| 2017 |
+
"""
|
| 2018 |
+
主函数:初始化所有组件并启动机器人。
|
| 2019 |
+
"""
|
| 2020 |
+
openai_client = None # 在 try 外部定义
|
| 2021 |
+
app = None # 在 try 外部定义
|
| 2022 |
+
|
| 2023 |
+
try:
|
| 2024 |
+
# 1. 初始化
|
| 2025 |
+
config = Config()
|
| 2026 |
+
redis_manager = RedisManager(config)
|
| 2027 |
+
openai_client = OpenAIClient(config)
|
| 2028 |
+
bot = TelegramBot(config, redis_manager, openai_client)
|
| 2029 |
+
|
| 2030 |
+
# 2. 注册处理器
|
| 2031 |
+
bot.setup_handlers()
|
| 2032 |
+
|
| 2033 |
+
# 3. 获取 application 实例
|
| 2034 |
+
app = bot.application
|
| 2035 |
+
|
| 2036 |
+
# 4. 移除手动的 await app.initialize() 和其他异步设置
|
| 2037 |
+
# 它们现在由 post_init 自动处理。
|
| 2038 |
+
|
| 2039 |
+
# 5. 开始轮询 (这是阻塞的,直到机器人停止)
|
| 2040 |
+
logger.info("机器人启动,开始轮⚫询...")
|
| 2041 |
+
app.run_polling(allowed_updates=Update.ALL_TYPES)
|
| 2042 |
+
|
| 2043 |
+
except ValueError as e:
|
| 2044 |
+
# Config 缺失必要变量时会触发
|
| 2045 |
+
logger.critical(f"启动失败: {e}")
|
| 2046 |
+
# 退出,因为没有配置无法运行
|
| 2047 |
+
return
|
| 2048 |
+
except Exception as e:
|
| 2049 |
+
logger.critical(f"机器人主程序遇到致命错误: {e}", exc_info=True)
|
| 2050 |
+
|
| 2051 |
+
finally:
|
| 2052 |
+
# 优雅关闭
|
| 2053 |
+
# post_shutdown_cleanup 会自动处理它们
|
| 2054 |
+
if app:
|
| 2055 |
+
logger.info("Telegram 轮询已停止。")
|
| 2056 |
+
logger.info("机器人已停止。")
|
| 2057 |
+
|
| 2058 |
+
|
| 2059 |
+
if __name__ == "__main__":
|
| 2060 |
+
try:
|
| 2061 |
+
main()
|
| 2062 |
+
except KeyboardInterrupt:
|
| 2063 |
+
logger.info("检测到 Ctrl+C,正在关闭...")
|
| 2064 |
+
|
index.js
DELETED
|
@@ -1,1173 +0,0 @@
|
|
| 1 |
-
// === 导入 ===
|
| 2 |
-
import express from 'express';
|
| 3 |
-
import * as dotenv from 'dotenv';
|
| 4 |
-
import cron from 'node-cron';
|
| 5 |
-
import { btoa, atob } from 'buffer';
|
| 6 |
-
|
| 7 |
-
// 立即调用 dotenv.config() 来加载 .env 变量
|
| 8 |
-
dotenv.config();
|
| 9 |
-
|
| 10 |
-
// === 日志类 ===
|
| 11 |
-
/**
|
| 12 |
-
* 一个简单的日志类,用于标准化控制台输出。
|
| 13 |
-
*/
|
| 14 |
-
class Logger {
|
| 15 |
-
static #log(level, message, context = '') {
|
| 16 |
-
const timestamp = new Date().toISOString();
|
| 17 |
-
const contextStr = context ? ` [${context}]` : '';
|
| 18 |
-
console.log(`${timestamp} [${level}]${contextStr} ${message}`);
|
| 19 |
-
}
|
| 20 |
-
static info(message, context = '') {
|
| 21 |
-
this.#log('INFO', message, context);
|
| 22 |
-
}
|
| 23 |
-
static warn(message, context = '') {
|
| 24 |
-
this.#log('WARN', message, context);
|
| 25 |
-
}
|
| 26 |
-
static error(message, error, context = '') {
|
| 27 |
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
| 28 |
-
this.#log('ERROR', `${message} - ${errorMsg}`, context);
|
| 29 |
-
if (error instanceof Error && error.stack) {
|
| 30 |
-
console.error(error.stack);
|
| 31 |
-
}
|
| 32 |
-
}
|
| 33 |
-
}
|
| 34 |
-
|
| 35 |
-
// === 配置类 ===
|
| 36 |
-
/**
|
| 37 |
-
* 解析并验证环境变量。
|
| 38 |
-
* 此版本已简化,仅适用于 OpenAI 兼容的服务。
|
| 39 |
-
*/
|
| 40 |
-
class Config {
|
| 41 |
-
constructor(env) {
|
| 42 |
-
const getEnvOrDefault = (key, defaultValue) => env[key] || defaultValue;
|
| 43 |
-
|
| 44 |
-
// 验证必要的模型配置
|
| 45 |
-
const hasOpenAICompatible = !!env.OPENAI_COMPATIBLE_KEY && !!env.OPENAI_COMPATIBLE_URL;
|
| 46 |
-
if (!hasOpenAICompatible) {
|
| 47 |
-
throw new Error("必须设置 OPENAI_COMPATIBLE_KEY 和 OPENAI_COMPATIBLE_URL");
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
// 服务器配置
|
| 51 |
-
this.port = parseInt(getEnvOrDefault('PORT', '3000'));
|
| 52 |
-
this.webhookHost = getEnvOrDefault('WEBHOOK_HOST', `http://localhost:${this.port}`); // 例如 'https://your.domain.com'
|
| 53 |
-
|
| 54 |
-
// 机器人配置
|
| 55 |
-
this.telegramBotToken = env.TELEGRAM_BOT_TOKEN;
|
| 56 |
-
if (!this.telegramBotToken) {
|
| 57 |
-
throw new Error('未设置 TELEGRAM_BOT_TOKEN');
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
this.whitelistedUsers = env.WHITELISTED_USERS ? env.WHITELISTED_USERS.split(",").map((id) => id.trim()) : [];
|
| 61 |
-
this.whitelistedChats = env.WHITELISTED_CHATS ? env.WHITELISTED_CHATS.split(",").map((id) => parseInt(id.trim())) : [];
|
| 62 |
-
this.systemInitMessage = getEnvOrDefault("SYSTEM_INIT_MESSAGE", "You are a helpful assistant.");
|
| 63 |
-
this.systemInitMessageRole = getEnvOrDefault("SYSTEM_INIT_MESSAGE_ROLE", "system");
|
| 64 |
-
this.defaultModel = "gemini-flash-latest-nothinking"; // 这可以是你的代理支持的任何模型名称
|
| 65 |
-
|
| 66 |
-
// Redis (Upstash) 配置
|
| 67 |
-
this.upstashRedisRestUrl = env.UPSTASH_REDIS_REST_URL;
|
| 68 |
-
this.upstashRedisRestToken = env.UPSTASH_REDIS_REST_TOKEN;
|
| 69 |
-
if (!this.upstashRedisRestUrl || !this.upstashRedisRestToken) {
|
| 70 |
-
Logger.warn('Upstash Redis 未配置。上下文将不会被保存。', 'Config');
|
| 71 |
-
}
|
| 72 |
-
this.contextTTL = 60 * 60 * 24 * 30; // 30 天
|
| 73 |
-
|
| 74 |
-
// OpenAI 兼容模型配置
|
| 75 |
-
this.openaiCompatibleKey = env.OPENAI_COMPATIBLE_KEY;
|
| 76 |
-
this.openaiCompatibleUrl = env.OPENAI_COMPATIBLE_URL;
|
| 77 |
-
this.openaiCompatibleModels = env.OPENAI_COMPATIBLE_MODELS ? env.OPENAI_COMPATIBLE_MODELS.split(",").map((model) => model.trim()) : [];
|
| 78 |
-
this.openaiImageModel = getEnvOrDefault(env, "OPENAI_IMAGE_MODEL", "dall-e-3");
|
| 79 |
-
}
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
// === 工具类 ===
|
| 83 |
-
/**
|
| 84 |
-
* 辅助函数集合。
|
| 85 |
-
*/
|
| 86 |
-
class Utils {
|
| 87 |
-
static formatCodeBlock(code) {
|
| 88 |
-
return `\`\`\`
|
| 89 |
-
${code}
|
| 90 |
-
\`\`\``;
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
static formatMarkdown(text) {
|
| 94 |
-
if (!text) return '';
|
| 95 |
-
text = text.replace(/```(\w*)\n([\s\S]+?)```/g, (_, lang, code) => {
|
| 96 |
-
const escapedCode = code.replace(/\*/g, "\\*").replace(/_/g, "\\_");
|
| 97 |
-
return Utils.formatCodeBlock(escapedCode.trim());
|
| 98 |
-
});
|
| 99 |
-
return text
|
| 100 |
-
.replace(/([^\n])```/g, "$1\n```")
|
| 101 |
-
.replace(/```([^\n])/g, "```\n$1")
|
| 102 |
-
.replace(/([^\s`])`([^`]+)`([^\s`])/g, "$1 `$2` $3")
|
| 103 |
-
.replace(/\*\*\*([^*]+)\*\*\*/g, "*$1*")
|
| 104 |
-
.replace(/\*\*([^*]+)\*\*/g, "*$1*")
|
| 105 |
-
.replace(/\[([^\]]+)\]\s*\(([^)]+)\)/g, "[$1]($2)")
|
| 106 |
-
.replace(/^(\s*)-\s+(.+)$/gm, "$1\u2022 $2")
|
| 107 |
-
.replace(/^>\s*(.+)$/gm, "\u258E _$1_")
|
| 108 |
-
.replace(/\\([*_`\[\]()#+-=|{}.!])/g, "$1");
|
| 109 |
-
}
|
| 110 |
-
|
| 111 |
-
static stripFormatting(text) {
|
| 112 |
-
if (!text) return '';
|
| 113 |
-
return text
|
| 114 |
-
.replace(/^(#{1,6})\s+(.+)$/gm, (_, hashes, content) => {
|
| 115 |
-
const level = hashes.length;
|
| 116 |
-
const indent = " ".repeat(level - 1);
|
| 117 |
-
return `${indent}\u25C6 ${content.trim()}`;
|
| 118 |
-
})
|
| 119 |
-
.replace(/\*\*\*(.*?)\*\*\*/g, "$1")
|
| 120 |
-
.replace(/\*\*(.*?)\*\*/g, "$1")
|
| 121 |
-
.replace(/\*(.*?)\*/g, "$1")
|
| 122 |
-
.replace(/`(.*?)`/g, "$1")
|
| 123 |
-
.replace(/```[\s\S]*?```/g, "")
|
| 124 |
-
.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, "$1 ($2)")
|
| 125 |
-
.replace(/^(\s*)-\s+(.+)$/gm, "$1\u2022 $2")
|
| 126 |
-
.replace(/^>\s*(.+)$/gm, "\u258E $1");
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
static splitMessage(text, maxLength = 4096) {
|
| 130 |
-
const messages = [];
|
| 131 |
-
if (!text) return messages;
|
| 132 |
-
|
| 133 |
-
const parts = text.split(/(```[\s\S]*?```)/);
|
| 134 |
-
let currentMessage = "";
|
| 135 |
-
|
| 136 |
-
for (const part of parts) {
|
| 137 |
-
if (part.startsWith("```")) {
|
| 138 |
-
if (currentMessage.length + part.length > maxLength) {
|
| 139 |
-
if (currentMessage) {
|
| 140 |
-
messages.push(currentMessage.trim());
|
| 141 |
-
}
|
| 142 |
-
// 如果代码块本身太长,就(简单地)分割它
|
| 143 |
-
if (part.length > maxLength) {
|
| 144 |
-
for (let i = 0; i < part.length; i += maxLength) {
|
| 145 |
-
messages.push(part.substring(i, i + maxLength));
|
| 146 |
-
}
|
| 147 |
-
} else {
|
| 148 |
-
messages.push(part);
|
| 149 |
-
}
|
| 150 |
-
currentMessage = "";
|
| 151 |
-
} else {
|
| 152 |
-
currentMessage += part;
|
| 153 |
-
}
|
| 154 |
-
} else {
|
| 155 |
-
const lines = part.split("\n");
|
| 156 |
-
for (const line of lines) {
|
| 157 |
-
if (currentMessage.length + line.length + 1 > maxLength) {
|
| 158 |
-
if (currentMessage) {
|
| 159 |
-
messages.push(currentMessage.trim());
|
| 160 |
-
}
|
| 161 |
-
// 如果单行太长,分割它
|
| 162 |
-
if (line.length > maxLength) {
|
| 163 |
-
for (let i = 0; i < line.length; i += maxLength) {
|
| 164 |
-
messages.push(line.substring(i, i + maxLength));
|
| 165 |
-
}
|
| 166 |
-
currentMessage = "";
|
| 167 |
-
} else {
|
| 168 |
-
currentMessage = line;
|
| 169 |
-
}
|
| 170 |
-
} else {
|
| 171 |
-
currentMessage += (currentMessage ? "\n" : "") + line;
|
| 172 |
-
}
|
| 173 |
-
}
|
| 174 |
-
}
|
| 175 |
-
}
|
| 176 |
-
if (currentMessage) {
|
| 177 |
-
messages.push(currentMessage.trim());
|
| 178 |
-
}
|
| 179 |
-
return messages;
|
| 180 |
-
}
|
| 181 |
-
|
| 182 |
-
static async sendChatAction(chatId, action, config) {
|
| 183 |
-
const token = config.telegramBotToken;
|
| 184 |
-
const url = `https://api.telegram.org/bot${token}/sendChatAction`;
|
| 185 |
-
try {
|
| 186 |
-
await fetch(url, {
|
| 187 |
-
method: "POST",
|
| 188 |
-
headers: { "Content-Type": "application/json" },
|
| 189 |
-
body: JSON.stringify({ chat_id: chatId, action }),
|
| 190 |
-
});
|
| 191 |
-
} catch (error) {
|
| 192 |
-
Logger.warn(`发送聊天动作 '${action}' 失败`, 'Utils.sendChatAction');
|
| 193 |
-
}
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
// 翻译映射表
|
| 197 |
-
static translations = {
|
| 198 |
-
welcome: "\u{1F44B} \u563F\uFF0C\u6B22\u8FCE\u4F7F\u7528\u4F60\u7684\u4E13\u5C5E\u52A9\u624B\u673A\u5668\u4EBA\uFF01",
|
| 199 |
-
unauthorized: "\u{1F6AB} \u62B1\u6B49\uFF0C\u60A8\u8FD8\u6CA1\u6709\u6743\u9650\u4F7F\u7528\u8FD9\u4E2A\u673A\u5668\u4EBA\u54E6\u3002",
|
| 200 |
-
error: "\u{1F605} \u54CE\u5440\uFF0C\u51FA\u4E86\u70B9\u5C0F\u95EE\u9898\u3002\u8981\u4E0D\u8981\u518D\u8BD5\u4E00\u6B21\uFF1F",
|
| 201 |
-
current_language: "\u{1F30D} \u60A8\u5F53\u524D\u7684\u8BED\u8A00\u8BBE\u7F6E\u662F\uFF1A\u4E2D\u6587",
|
| 202 |
-
language_changed: "\u{1F389} \u592A\u597D\u4E86\uFF01\u8BED\u8A00\u5DF2\u7ECF\u5207\u6362\u4E3A\uFF1A",
|
| 203 |
-
new_conversation: "\u{1F195} \u597D\u7684\uFF0C\u8BA9\u6211\u4EEC\u5F00\u59CB\u4E00\u6BB5\u5168\u65B0\u7684\u5BF9\u8BDD\u5427\uFF01\u4E4B\u524D\u7684\u804A\u5929\u8BB0\u5F55\u5DF2\u7ECF\u6E05\u9664\u5566\u3002",
|
| 204 |
-
no_history: "\u{1F914} \u55EF...\u770B\u8D77\u6765\u6211\u4EEC\u8FD8\u6CA1\u6709\u804A\u8FC7\u5929\u5462\u3002",
|
| 205 |
-
history_summary: "\u{1F4DC} \u6765\u56DE\u987E\u4E00\u4E0B\u6211\u4EEC\u4E4B\u524D\u804A\u4E86\u4E9B\u4EC0\u4E48\uFF1A",
|
| 206 |
-
current_model: "\u{1F916} \u60A8\u73B0\u5728\u4F7F\u7528\u7684 AI \u6A21\u578B\u662F\uFF1A",
|
| 207 |
-
available_models: "\u{1F522} \u54C7\uFF0C\u6211\u4EEC\u6709\u8FD9\u4E48\u591A\u6A21\u578B\u53EF\u4EE5\u9009\u62E9\uFF1A",
|
| 208 |
-
model_changed: "\u{1F504} \u6362\u6A21\u578B\u6210\u529F\uFF01\u73B0\u5728\u6211\u4F7F\u7528\u7684\u662F\uFF1A",
|
| 209 |
-
help_intro: "\u{1F9ED} \u6765\u770B\u770B\u6211\u90FD\u80FD\u505A\u4E9B\u4EC0\u4E48\u5427\uFF1A",
|
| 210 |
-
start_description: "\u{1F680} \u548C\u6211\u6253\u4E2A\u62DB\u547C\uFF0C\u5F00\u59CB\u804A\u5929",
|
| 211 |
-
language_description: "\u{1F5E3}\uFE0F \u60F3\u6362\u4E2A\u8BED\u8A00\uFF1F\u7528\u8FD9\u4E2A",
|
| 212 |
-
new_description: "\u{1F504} \u5F00\u59CB\u5168\u65B0\u7684\u5BF9\u8BDD",
|
| 213 |
-
cien_description:"\u67e5\u770b\u9ca8\u9c7c\u7684\u0063\u0069\u0065\u006e\u6700\u65b0\u6587\u7ae0",
|
| 214 |
-
history_description: "\u{1F4DA} \u56DE\u987E\u4E00\u4E0B\u6211\u4EEC\u4E4B\u524D\u804A\u4E86\u4EC0\u4E48",
|
| 215 |
-
switchmodel_description: "\u{1F500} \u6362\u4E2A\u6A21\u578B\u6765\u804A\u5929",
|
| 216 |
-
help_description: "\u2753 \u67E5\u770B\u6240\u6709\u53ef\u7528\u7684\u547D\u4EE4",
|
| 217 |
-
choose_language: "\u{1F310} \u4F60\u60F3\u7528\u54EA\u79CD\u8BED\u8A00\u548C\u6211\u804A\u5929\u5462\uFF1F",
|
| 218 |
-
choose_model: "\u{1F916} \u6765\u9009\u62E9\u4E00\u4E2A AI \u6A21\u578B\u5427\uFF1A",
|
| 219 |
-
language_zh: "\u{1F1E8}\u{1F1F3} \u7B80\u4F53\u4E2D\u6587",
|
| 220 |
-
image_prompt_required: "\u{1F5BC}\uFE0F \u8981\u521B\u5EFA\u56FE\u50CF\uFF0C\u8BF7\u544A\u8BC9\u6211\u4F60\u60F3\u770B\u5230\u4EC0\u4E48~",
|
| 221 |
-
image_generation_error: "\u{1F61E} \u54CE\u5440\uFF0C\u521B\u5EFA\u56FE\u50CF\u65F6\u51FA\u73B0\u4E86\u95EE\u9898\u3002\u8981\u4E0D\u8981\u518D\u8BD5\u4E00\u6B21\uFF1F",
|
| 222 |
-
invalid_size: "\u{1F4CF} \u54CE\u5440\uFF0C\u8FD9\u4E2A\u5C3A\u5BF8\u4E0D\u884C\u3002\u4E0D\u5982\u8BD5\u8BD5\u8FD9\u4E9B\uFF1A",
|
| 223 |
-
// 新的 /image 命令翻译
|
| 224 |
-
image_description: "\u{1F5BC}\uFE0F \u4F7F\u7528 AI \u521B\u5EFA\u4E00\u5F20\u56FE\u50CF",
|
| 225 |
-
image_usage: "\ud83d\udcdd\u0020\u7528\u6cd5: /image <\u63cf\u8ff0> [aspect_ratio]\n\u652f\u6301\u7684\u6bd4\u4f8b: 1:1, 16:9, 9:16",
|
| 226 |
-
invalid_aspect_ratio: "\u{1F522} \u4e0d\u652f\u6301\u7684\u957f\u5bbd\u6bd4\u3002\u8bf7\u4f7f\u7528: 1:1, 16:9, \u6216 9:16",
|
| 227 |
-
|
| 228 |
-
original_prompt: "\u{1F3A8} \u539F\u59CB\u63CF\u8FF0",
|
| 229 |
-
prompt_generation_model: "\u{1F4AC} \u63D0\u793A\u751F\u6210\u6A21\u578B",
|
| 230 |
-
optimized_prompt: "\u{1F310} \u4F18\u5316\u540E\u7684\u63CF\u8FF0",
|
| 231 |
-
image_specs: "\u{1F4D0} \u56FE\u50CF\u8BE6\u60C5",
|
| 232 |
-
command_not_found: "\u2753 \u55EF\uFF0C\u6211\u4E0D\u8BA4\u8BC6\u8FD9\u4E2A\u547D\u4EE4\u3002\u8F93\u5165 /help \u770B\u770B\u6211\u80FD\u505A\u4EC0\u4E48\uFF01",
|
| 233 |
-
image_analysis_not_supported: "\u{1F6AB} \u5F53\u524D\u6A21\u578B\u4E0D\u652F\u6301\u56FE\u50CF\u5206\u6790\u3002\u8BF7\u5207\u6362\u5230\u652F\u6301\u591A\u6A21\u6001\u8F93\u5165\u7684\u6A21\u578B\u3002",
|
| 234 |
-
image_analysis_error: "\u274C \u7CDF\u7CD5\uff01\u56FE\u50CF\u5206\u6790\u8FC7\u7A0B\u4E2D\u53D1\u751F\u9519\u8BEF\u3002\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002",
|
| 235 |
-
image_analysis_description: "\u{1F4F8} \u56FE\u7247\u5206\u6790\uff1A\u53D1\u9001\u4e00\u5F20\u7167\u7247\uFF0C\u673A\u5668\u4EBA\u5c06\u4f7f\u7528\u5f53\u524d\u9009\u62e9\u7684 AI \u6A21\u578B\u8fdb\u884c\u5206\u6790\u3002",
|
| 236 |
-
image_analysis_error2:"\u274C 2",
|
| 237 |
-
};
|
| 238 |
-
|
| 239 |
-
static translate(key) {
|
| 240 |
-
return this.translations?.[key] || key; // 如果找不到翻译,返回 key 本身
|
| 241 |
-
}
|
| 242 |
-
}
|
| 243 |
-
|
| 244 |
-
// === 服务类 ===
|
| 245 |
-
|
| 246 |
-
/**
|
| 247 |
-
* Upstash Redis REST API 客户端
|
| 248 |
-
*/
|
| 249 |
-
class RedisClient {
|
| 250 |
-
constructor(config) {
|
| 251 |
-
this.config = config;
|
| 252 |
-
this.url = config.upstashRedisRestUrl;
|
| 253 |
-
this.token = config.upstashRedisRestToken;
|
| 254 |
-
this.isEnabled = this.url && this.token;
|
| 255 |
-
}
|
| 256 |
-
|
| 257 |
-
async #request(command, ...args) {
|
| 258 |
-
if (!this.isEnabled) {
|
| 259 |
-
Logger.warn('Redis 未配置。跳过命令。', 'RedisClient');
|
| 260 |
-
return { result: null }; // 模拟 "未找到" 或 "成功"
|
| 261 |
-
}
|
| 262 |
-
|
| 263 |
-
const url = `${this.url}/${command}/${args.join('/')}`;
|
| 264 |
-
const headers = { Authorization: `Bearer ${this.token}` };
|
| 265 |
-
|
| 266 |
-
let body = null;
|
| 267 |
-
if (command === 'set') {
|
| 268 |
-
// 处理带 TTL 的 SET
|
| 269 |
-
const key = args[0];
|
| 270 |
-
const value = args[1];
|
| 271 |
-
const ttl = this.config.contextTTL;
|
| 272 |
-
const fullUrl = `${this.url}/set/${key}?EX=${ttl}`;
|
| 273 |
-
|
| 274 |
-
try {
|
| 275 |
-
const response = await fetch(fullUrl, { method: 'POST', headers, body: value });
|
| 276 |
-
if (!response.ok) throw new Error(`HTTP 错误! 状态: ${response.status}`);
|
| 277 |
-
return await response.json();
|
| 278 |
-
} catch(error) {
|
| 279 |
-
Logger.error(`Redis SET 命令失败, key: ${key}`, error, 'RedisClient');
|
| 280 |
-
return { result: null };
|
| 281 |
-
}
|
| 282 |
-
|
| 283 |
-
} else if (command === 'del') {
|
| 284 |
-
const key = args[0];
|
| 285 |
-
const delUrl = `${this.url}/del/${key}`;
|
| 286 |
-
try {
|
| 287 |
-
const response = await fetch(delUrl, { method: 'POST', headers }); // DEL 是 POST
|
| 288 |
-
if (!response.ok) throw new Error(`HTTP 错误! 状态: ${response.status}`);
|
| 289 |
-
return await response.json();
|
| 290 |
-
} catch(error) {
|
| 291 |
-
Logger.error(`Redis DEL 命令失败, key: ${key}`, error, 'RedisClient');
|
| 292 |
-
return { result: null };
|
| 293 |
-
}
|
| 294 |
-
}
|
| 295 |
-
|
| 296 |
-
// 处理 GET 和 KEYS
|
| 297 |
-
try {
|
| 298 |
-
const response = await fetch(url, { method: 'GET', headers });
|
| 299 |
-
if (!response.ok) {
|
| 300 |
-
if (response.status === 404) return { result: null }; // 未找到
|
| 301 |
-
throw new Error(`HTTP 错误! 状态: ${response.status}`);
|
| 302 |
-
}
|
| 303 |
-
return await response.json();
|
| 304 |
-
} catch (error) {
|
| 305 |
-
Logger.error(`Redis ${command} 命令失败`, error, 'RedisClient');
|
| 306 |
-
return { result: null };
|
| 307 |
-
}
|
| 308 |
-
}
|
| 309 |
-
|
| 310 |
-
async get(key) {
|
| 311 |
-
const data = await this.#request('get', key);
|
| 312 |
-
return data.result;
|
| 313 |
-
}
|
| 314 |
-
|
| 315 |
-
async set(key, value) {
|
| 316 |
-
// 现在会正确使用构造函数中的 TTL
|
| 317 |
-
const data = await this.#request('set', key, value);
|
| 318 |
-
return data.result;
|
| 319 |
-
}
|
| 320 |
-
|
| 321 |
-
async del(key) {
|
| 322 |
-
const data = await this.#request('del', key);
|
| 323 |
-
return data.result;
|
| 324 |
-
}
|
| 325 |
-
|
| 326 |
-
async appendContext(userId, newContext) {
|
| 327 |
-
if (!this.isEnabled) return;
|
| 328 |
-
const key = `context:${userId}`;
|
| 329 |
-
const existingContext = await this.get(key) || '';
|
| 330 |
-
const updatedContext = existingContext ? `${existingContext}\n${newContext}` : newContext;
|
| 331 |
-
await this.set(key, updatedContext); // 'set' 会自动应用 TTL
|
| 332 |
-
}
|
| 333 |
-
|
| 334 |
-
async keys(pattern) {
|
| 335 |
-
const data = await this.#request('keys', pattern);
|
| 336 |
-
return data.result;
|
| 337 |
-
}
|
| 338 |
-
}
|
| 339 |
-
|
| 340 |
-
/**
|
| 341 |
-
* 统一的 OpenAI 兼容 API 客户端 (文本, 视觉, 图像生成)
|
| 342 |
-
*/
|
| 343 |
-
class OpenAICompatibleAPI {
|
| 344 |
-
constructor(config) {
|
| 345 |
-
this.config = config;
|
| 346 |
-
this.apiKey = config.openaiCompatibleKey || "";
|
| 347 |
-
this.baseUrl = config.openaiCompatibleUrl || "";
|
| 348 |
-
this.models = []; // 模型缓存
|
| 349 |
-
this.defaultModel = config.openaiCompatibleModels[0] || config.defaultModel;
|
| 350 |
-
this.imageModel = config.openaiImageModel;
|
| 351 |
-
this.isFetchingModels = false;
|
| 352 |
-
this.fetchModels().catch((error) => Logger.error("初���化时获取模型失败", error, 'OpenAICompatibleAPI'));
|
| 353 |
-
}
|
| 354 |
-
|
| 355 |
-
/**
|
| 356 |
-
* 从 URL 获取图像并返回 base64 数据 URL。
|
| 357 |
-
*/
|
| 358 |
-
async #getBase64Image(imageUrl) {
|
| 359 |
-
const response = await fetch(imageUrl);
|
| 360 |
-
const arrayBuffer = await response.arrayBuffer();
|
| 361 |
-
const base64 = Buffer.from(arrayBuffer).toString('base64');
|
| 362 |
-
// 尝试从响应头获取 mime 类型,默认为 jpeg
|
| 363 |
-
const mimeType = response.headers.get('content-type') || 'image/jpeg';
|
| 364 |
-
return `data:${mimeType};base64,${base64}`;
|
| 365 |
-
}
|
| 366 |
-
|
| 367 |
-
/**
|
| 368 |
-
* 清理模型响应,移除 <thinking> 等标签。
|
| 369 |
-
*/
|
| 370 |
-
async cleanString(inputString) {
|
| 371 |
-
if (!inputString) return "";
|
| 372 |
-
return inputString
|
| 373 |
-
.replace(/<thinking>\s*[\s\S]*?<\/thinking>/g, '')
|
| 374 |
-
.replace(/<!--\s*[\s\S]*? -->/g, '');
|
| 375 |
-
}
|
| 376 |
-
|
| 377 |
-
/**
|
| 378 |
-
* 从聊天记录生成文本响应。
|
| 379 |
-
*/
|
| 380 |
-
async generateResponse(messages, model) {
|
| 381 |
-
if (!this.apiKey || !this.baseUrl) {
|
| 382 |
-
throw new Error("OpenAI 兼容 API 未配置");
|
| 383 |
-
}
|
| 384 |
-
await this.fetchModels(); // 确保模型列表已加载
|
| 385 |
-
|
| 386 |
-
const useModel = model || this.defaultModel;
|
| 387 |
-
if (!useModel) {
|
| 388 |
-
throw new Error("未指定模型,且没有可用的默认模型");
|
| 389 |
-
}
|
| 390 |
-
|
| 391 |
-
const url = `${this.baseUrl}/v1/chat/completions`;
|
| 392 |
-
const requestBody = { model: useModel, messages, temperature: 0.9, max_tokens: 4096 };
|
| 393 |
-
|
| 394 |
-
const response = await fetch(url, {
|
| 395 |
-
method: "POST",
|
| 396 |
-
headers: {
|
| 397 |
-
"Content-Type": "application/json",
|
| 398 |
-
"Authorization": `Bearer ${this.apiKey}`,
|
| 399 |
-
},
|
| 400 |
-
body: JSON.stringify(requestBody),
|
| 401 |
-
});
|
| 402 |
-
|
| 403 |
-
if (!response.ok) {
|
| 404 |
-
const errorText = await response.text();
|
| 405 |
-
Logger.error(`OpenAI API 错误: ${response.statusText}`, errorText, 'OpenAICompatibleAPI');
|
| 406 |
-
throw new Error(`OpenAI 兼容 API 错误: ${response.statusText}\n${errorText}`);
|
| 407 |
-
}
|
| 408 |
-
|
| 409 |
-
const data = await response.json();
|
| 410 |
-
if (!data.choices || data.choices.length === 0) {
|
| 411 |
-
throw new Error("OpenAI 兼容 API 未生成响应");
|
| 412 |
-
}
|
| 413 |
-
|
| 414 |
-
const messageContent = await this.cleanString(data.choices[0].message.content.trim());
|
| 415 |
-
return messageContent.trim();
|
| 416 |
-
}
|
| 417 |
-
|
| 418 |
-
/**
|
| 419 |
-
* 获取并缓存可用的文本模型列表。
|
| 420 |
-
*/
|
| 421 |
-
async fetchModels() {
|
| 422 |
-
// 防止并发请求
|
| 423 |
-
if (this.isFetchingModels) return;
|
| 424 |
-
if (this.models.length > 0) return; // 已经获取过了
|
| 425 |
-
if (!this.apiKey || !this.baseUrl) return; // 未配置
|
| 426 |
-
|
| 427 |
-
this.isFetchingModels = true;
|
| 428 |
-
try {
|
| 429 |
-
const url = `${this.baseUrl}/v1/models`;
|
| 430 |
-
Logger.info(`正在从: ${url} 获取模型列表`, 'OpenAICompatibleAPI');
|
| 431 |
-
const response = await fetch(url, { headers: { "Authorization": `Bearer ${this.apiKey}` } });
|
| 432 |
-
|
| 433 |
-
if (!response.ok) {
|
| 434 |
-
const errorText = await response.text();
|
| 435 |
-
throw new Error(`获取模型列表失败: ${response.statusText}\n${errorText}`);
|
| 436 |
-
}
|
| 437 |
-
|
| 438 |
-
const data = await response.json();
|
| 439 |
-
this.models = data.data.map((model) => model.id);
|
| 440 |
-
this.defaultModel = this.models[0] || this.defaultModel;
|
| 441 |
-
Logger.info(`获取了 ${this.models.length} 个模型。默认: ${this.defaultModel}`, 'OpenAICompatibleAPI');
|
| 442 |
-
} catch (error) {
|
| 443 |
-
Logger.error('获取模型列表失败', error, 'OpenAICompatibleAPI');
|
| 444 |
-
} finally {
|
| 445 |
-
this.isFetchingModels = false;
|
| 446 |
-
}
|
| 447 |
-
}
|
| 448 |
-
|
| 449 |
-
async getModels() {
|
| 450 |
-
await this.fetchModels();
|
| 451 |
-
return this.models;
|
| 452 |
-
}
|
| 453 |
-
|
| 454 |
-
isValidModel(model) { return this.models.includes(model); }
|
| 455 |
-
getDefaultModel() { return this.defaultModel; }
|
| 456 |
-
getAvailableModels() { return this.models; }
|
| 457 |
-
|
| 458 |
-
/**
|
| 459 |
-
* 使用视觉模型分析图像,发送 base64 数据。
|
| 460 |
-
*/
|
| 461 |
-
async analyzeImage(imageUrl, prompt, model) {
|
| 462 |
-
if (!this.apiKey || !this.baseUrl) {
|
| 463 |
-
throw new Error("OpenAI 兼容 API 未配置");
|
| 464 |
-
}
|
| 465 |
-
|
| 466 |
-
// 获取并编码图像为 base64 数据 URL
|
| 467 |
-
const base64ImageData = await this.#getBase64Image(imageUrl);
|
| 468 |
-
|
| 469 |
-
// 使用用户当前选择的模型,假设它具有视觉功能
|
| 470 |
-
const useModel = model || this.defaultModel;
|
| 471 |
-
|
| 472 |
-
Logger.info(`使用 base64 数据分析图像。模型: ${useModel}`, 'OpenAICompatibleAPI');
|
| 473 |
-
|
| 474 |
-
const url = `${this.baseUrl}/v1/chat/completions`;
|
| 475 |
-
const response = await fetch(url, {
|
| 476 |
-
method: "POST",
|
| 477 |
-
headers: {
|
| 478 |
-
"Content-Type": "application/json",
|
| 479 |
-
"Authorization": `Bearer ${this.apiKey}`,
|
| 480 |
-
},
|
| 481 |
-
body: JSON.stringify({
|
| 482 |
-
model: useModel,
|
| 483 |
-
messages: [{
|
| 484 |
-
role: "user",
|
| 485 |
-
content: [
|
| 486 |
-
{ type: "text", text: prompt },
|
| 487 |
-
{
|
| 488 |
-
type: "image_url",
|
| 489 |
-
image_url: {
|
| 490 |
-
"url": base64ImageData // 发送 base64 数据 URL
|
| 491 |
-
}
|
| 492 |
-
}
|
| 493 |
-
],
|
| 494 |
-
}],
|
| 495 |
-
max_tokens: 1024, // 增加 token 数量以便进行更好的分析
|
| 496 |
-
}),
|
| 497 |
-
});
|
| 498 |
-
|
| 499 |
-
if (!response.ok) {
|
| 500 |
-
const errorText = await response.text();
|
| 501 |
-
Logger.error(`OpenAI 图像 API 错误: ${response.statusText}`, errorText, 'OpenAICompatibleAPI');
|
| 502 |
-
throw new Error(`OpenAI 兼容图像分析 API 错误: ${response.statusText}\n${errorText}`);
|
| 503 |
-
}
|
| 504 |
-
|
| 505 |
-
const data = await response.json();
|
| 506 |
-
const content = data.choices?.[0]?.message?.content;
|
| 507 |
-
if (!content) {
|
| 508 |
-
throw new Error("OpenAI 兼容 API 响应中无内容");
|
| 509 |
-
}
|
| 510 |
-
return content.trim();
|
| 511 |
-
}
|
| 512 |
-
|
| 513 |
-
/**
|
| 514 |
-
* 使用 OpenAI 图像生成 API 生成图像。
|
| 515 |
-
*/
|
| 516 |
-
async generateImage(prompt, size = "1024x1024", quality = "standard", style = "vivid") {
|
| 517 |
-
if (!this.apiKey || !this.baseUrl) {
|
| 518 |
-
throw new Error("OpenAI 兼容 API 未配置");
|
| 519 |
-
}
|
| 520 |
-
|
| 521 |
-
const url = `${this.baseUrl}/v1/images/generations`;
|
| 522 |
-
Logger.info(`正在生成图像。模型: ${this.imageModel}, 尺寸: ${size}`, 'OpenAICompatibleAPI');
|
| 523 |
-
|
| 524 |
-
const response = await fetch(url, {
|
| 525 |
-
method: "POST",
|
| 526 |
-
headers: {
|
| 527 |
-
"Content-Type": "application/json",
|
| 528 |
-
"Authorization": `Bearer ${this.apiKey}`,
|
| 529 |
-
},
|
| 530 |
-
body: JSON.stringify({
|
| 531 |
-
model: this.imageModel,
|
| 532 |
-
prompt: prompt,
|
| 533 |
-
n: 1,
|
| 534 |
-
size: size,
|
| 535 |
-
quality: quality,
|
| 536 |
-
style: style,
|
| 537 |
-
response_format: "b64_json", // 请求 base64 数据
|
| 538 |
-
}),
|
| 539 |
-
});
|
| 540 |
-
|
| 541 |
-
if (!response.ok) {
|
| 542 |
-
const errorText = await response.text();
|
| 543 |
-
Logger.error(`OpenAI 图像生成错误: ${response.statusText}`, errorText, 'OpenAICompatibleAPI');
|
| 544 |
-
throw new Error(`OpenAI 兼容图像生成错误: ${response.statusText}\n${errorText}`);
|
| 545 |
-
}
|
| 546 |
-
|
| 547 |
-
const data = await response.json();
|
| 548 |
-
const b64Json = data.data?.[0]?.b64_json;
|
| 549 |
-
|
| 550 |
-
if (!b64Json) {
|
| 551 |
-
throw new Error("API 未返回图像数据");
|
| 552 |
-
}
|
| 553 |
-
|
| 554 |
-
// 将 base64 图像解码为字节 (Uint8Array)
|
| 555 |
-
const binaryString = atob(b64Json);
|
| 556 |
-
const len = binaryString.length;
|
| 557 |
-
const bytes = new Uint8Array(len);
|
| 558 |
-
for (let i = 0; i < len; i++) {
|
| 559 |
-
bytes[i] = binaryString.charCodeAt(i);
|
| 560 |
-
}
|
| 561 |
-
|
| 562 |
-
// 返回原始图像字节
|
| 563 |
-
return {
|
| 564 |
-
imageData: bytes,
|
| 565 |
-
revisedPrompt: data.data?.[0]?.revised_prompt // 传递 API 可能返回的优化后的提示
|
| 566 |
-
};
|
| 567 |
-
}
|
| 568 |
-
}
|
| 569 |
-
|
| 570 |
-
// === 机器人核心 ===
|
| 571 |
-
/**
|
| 572 |
-
* Telegram 机器人主类。
|
| 573 |
-
*/
|
| 574 |
-
class TelegramBot {
|
| 575 |
-
constructor(config) {
|
| 576 |
-
this.config = config;
|
| 577 |
-
this.apiUrl = `https://api.telegram.org/bot${config.telegramBotToken}`;
|
| 578 |
-
this.redis = new RedisClient(config);
|
| 579 |
-
// *唯一* 的 AI 服务客户端
|
| 580 |
-
this.openaiApi = new OpenAICompatibleAPI(config);
|
| 581 |
-
|
| 582 |
-
// 定义允许的命令
|
| 583 |
-
this.commands = [
|
| 584 |
-
{ name: "start", description: "start_description", action: this.commandStart.bind(this) },
|
| 585 |
-
{ name: "switchmodel", description: "switchmodel_description", action: this.commandSwitchModel.bind(this) },
|
| 586 |
-
{ name: "new", description: "new_description", action: this.commandNew.bind(this) },
|
| 587 |
-
{ name: "cien", description: "cien_description", action: this.commandCien.bind(this) },
|
| 588 |
-
{ name: "history", description: "history_description", action: this.commandHistory.bind(this) },
|
| 589 |
-
{ name: "help", description: "help_description", action: this.commandHelp.bind(this) },
|
| 590 |
-
// 用 /image 替换了 /flux
|
| 591 |
-
{ name: "image", description: "image_description", action: this.commandImage.bind(this) },
|
| 592 |
-
];
|
| 593 |
-
}
|
| 594 |
-
|
| 595 |
-
// --- 命令实现 ---
|
| 596 |
-
async commandStart(chatId, userId, args) {
|
| 597 |
-
const currentModel = await this.getCurrentModel(userId);
|
| 598 |
-
const welcomeMessage = Utils.translate("welcome") + "\n" + Utils.translate("current_model") + currentModel;
|
| 599 |
-
await this.sendMessageWithFallback(chatId, welcomeMessage);
|
| 600 |
-
}
|
| 601 |
-
|
| 602 |
-
async commandSwitchModel(chatId, userId, args) {
|
| 603 |
-
if (this.config.whitelistedUsers.includes(userId)) {
|
| 604 |
-
try {
|
| 605 |
-
Logger.info("正在执行 switchmodel 命令", 'Bot.command');
|
| 606 |
-
let compatibleModels = await this.openaiApi.getModels();
|
| 607 |
-
let availableModels = [...this.config.openaiCompatibleModels, ...compatibleModels];
|
| 608 |
-
|
| 609 |
-
const keyboard = {
|
| 610 |
-
inline_keyboard: availableModels.map((model) => [{ text: model, callback_data: `model_${model}` }]),
|
| 611 |
-
};
|
| 612 |
-
await this.sendMessage(chatId, Utils.translate("choose_model"), { reply_markup: JSON.stringify(keyboard) });
|
| 613 |
-
} catch (error) {
|
| 614 |
-
Logger.error("switchmodel 命令出错", error, 'Bot.command');
|
| 615 |
-
await this.sendMessage(chatId, Utils.translate("error") + ": " + (error.message || "未知错误"));
|
| 616 |
-
}
|
| 617 |
-
} else {
|
| 618 |
-
await this.sendMessageWithFallback(chatId, `\ud83d\udeab\u0020\u62b1\u6b49\uff0c\u60a8\u8fd8\u6ca1\u6709\u6743\u9650\u4f7f\u7528\u8fd9\u4e2a\u547d\u4ee4\u54e6\uff0c\u8bf7\u66f4\u6362\u5176\u4ed6\u547d\u4ee4\u5427\u3002`);
|
| 619 |
-
}
|
| 620 |
-
}
|
| 621 |
-
|
| 622 |
-
async commandNew(chatId, userId, args) {
|
| 623 |
-
await this.clearContext(userId, chatId);
|
| 624 |
-
}
|
| 625 |
-
|
| 626 |
-
async commandCien(chatId, userId, args) {
|
| 627 |
-
try {
|
| 628 |
-
await this.getcienResource(chatId);
|
| 629 |
-
} catch (error) {
|
| 630 |
-
await this.sendMessage(chatId, "呜呜呜,报错了");
|
| 631 |
-
}
|
| 632 |
-
}
|
| 633 |
-
|
| 634 |
-
async commandHistory(chatId, userId, args) {
|
| 635 |
-
const summary = await this.summarizeHistory(userId);
|
| 636 |
-
await this.sendMessage(chatId, summary || Utils.translate("no_history"));
|
| 637 |
-
}
|
| 638 |
-
|
| 639 |
-
async commandHelp(chatId, userId, args) {
|
| 640 |
-
let helpMessage = Utils.translate("help_intro") + "\n\n";
|
| 641 |
-
for (const command of this.commands) {
|
| 642 |
-
const descriptionKey = `${command.name}_description`;
|
| 643 |
-
helpMessage += `/${command.name} - ${Utils.translate(descriptionKey)}\n`;
|
| 644 |
-
}
|
| 645 |
-
helpMessage += "\n" + Utils.translate("image_analysis_description");
|
| 646 |
-
await this.sendMessage(chatId, helpMessage);
|
| 647 |
-
}
|
| 648 |
-
|
| 649 |
-
/**
|
| 650 |
-
* OpenAI 图像生成的新命令
|
| 651 |
-
*/
|
| 652 |
-
async commandImage(chatId, userId, args) {
|
| 653 |
-
if (!args.length) {
|
| 654 |
-
await this.sendMessage(chatId, Utils.translate("image_usage"));
|
| 655 |
-
return;
|
| 656 |
-
}
|
| 657 |
-
|
| 658 |
-
let aspectRatio = "1:1";
|
| 659 |
-
let prompt;
|
| 660 |
-
const validRatios = ["1:1", "16:9", "9:16"];
|
| 661 |
-
|
| 662 |
-
if (validRatios.includes(args[args.length - 1])) {
|
| 663 |
-
aspectRatio = args[args.length - 1];
|
| 664 |
-
prompt = args.slice(0, -1).join(" ");
|
| 665 |
-
} else {
|
| 666 |
-
prompt = args.join(" ");
|
| 667 |
-
}
|
| 668 |
-
|
| 669 |
-
// 将宽高比映射到 OpenAI 尺寸
|
| 670 |
-
const sizeMap = {
|
| 671 |
-
"1:1": "1024x1024",
|
| 672 |
-
"16:9": "1792x1024",
|
| 673 |
-
"9:16": "1024x1792"
|
| 674 |
-
};
|
| 675 |
-
const size = sizeMap[aspectRatio];
|
| 676 |
-
|
| 677 |
-
try {
|
| 678 |
-
await Utils.sendChatAction(chatId, "upload_photo", this.config);
|
| 679 |
-
const { imageData, revisedPrompt } = await this.openaiApi.generateImage(prompt, size);
|
| 680 |
-
|
| 681 |
-
let caption = `${Utils.translate("original_prompt")}: ${prompt}\n`;
|
| 682 |
-
caption += `${Utils.translate("image_specs")}: ${aspectRatio}\n`;
|
| 683 |
-
if (revisedPrompt) {
|
| 684 |
-
caption += `${Utils.translate("optimized_prompt")}: ${revisedPrompt}\n`;
|
| 685 |
-
}
|
| 686 |
-
await this.sendPhoto(chatId, imageData, { caption });
|
| 687 |
-
} catch (error) {
|
| 688 |
-
Logger.error(`生成 OpenAI 图像时出错`, error, 'Bot.command');
|
| 689 |
-
await this.sendMessage(chatId, Utils.translate("image_generation_error"));
|
| 690 |
-
}
|
| 691 |
-
}
|
| 692 |
-
|
| 693 |
-
// --- 机器人核心逻辑 ---
|
| 694 |
-
|
| 695 |
-
/**
|
| 696 |
-
* 处理来自 webhook 的更新的主入口点。
|
| 697 |
-
*/
|
| 698 |
-
async processUpdate(update) {
|
| 699 |
-
Logger.info(`收到更新 ID: ${update.update_id}`, 'Bot.processUpdate');
|
| 700 |
-
|
| 701 |
-
if (update.callback_query) {
|
| 702 |
-
await this.handleCallbackQuery(update.callback_query);
|
| 703 |
-
} else if (update.message) {
|
| 704 |
-
const msg = update.message;
|
| 705 |
-
const chatId = msg.chat.id;
|
| 706 |
-
const userId = msg.from?.id?.toString();
|
| 707 |
-
const userName = msg.from?.first_name?.toString();
|
| 708 |
-
const messageId = msg.message_id;
|
| 709 |
-
const chatType = msg.chat.type;
|
| 710 |
-
|
| 711 |
-
if (!userId) {
|
| 712 |
-
Logger.warn("更新中没有用户 ID", update, 'Bot.processUpdate');
|
| 713 |
-
return;
|
| 714 |
-
}
|
| 715 |
-
|
| 716 |
-
if (!this.isUserWhitelisted(userId) && !this.isChatWhitelisted(chatId)) {
|
| 717 |
-
Logger.warn(`未授权的用户: ${userId} 在聊天: ${chatId}`, 'Bot.processUpdate');
|
| 718 |
-
await this.sendMessage(chatId, Utils.translate("unauthorized"));
|
| 719 |
-
return;
|
| 720 |
-
}
|
| 721 |
-
|
| 722 |
-
// 处理不同的消息类型
|
| 723 |
-
if (msg.photo) {
|
| 724 |
-
await this.handleImageAnalysis(chatId, msg);
|
| 725 |
-
} else if (msg.text) {
|
| 726 |
-
if (msg.text.startsWith("/")) {
|
| 727 |
-
const [commandNameFull, ...args] = msg.text.slice(1).split(" ");
|
| 728 |
-
const commandName = commandNameFull.split('@')[0]; // 移除 @BotName
|
| 729 |
-
await this.executeCommand(commandName, chatId, userId, messageId, args);
|
| 730 |
-
} else {
|
| 731 |
-
await this.handleTextMessage(chatId, userId, userName, msg.text, messageId, chatType);
|
| 732 |
-
}
|
| 733 |
-
}
|
| 734 |
-
}
|
| 735 |
-
}
|
| 736 |
-
|
| 737 |
-
/**
|
| 738 |
-
* 查找并执行命令。
|
| 739 |
-
*/
|
| 740 |
-
async executeCommand(commandName, chatId, userId, messageId, args) {
|
| 741 |
-
const command = this.commands.find((cmd) => cmd.name === commandName);
|
| 742 |
-
if (command) {
|
| 743 |
-
try {
|
| 744 |
-
await command.action(chatId, userId, args);
|
| 745 |
-
} catch (error) {
|
| 746 |
-
Logger.error(`执行命令出错: /${commandName}`, error, 'Bot.executeCommand');
|
| 747 |
-
await this.sendMessage(chatId, Utils.translate("error"));
|
| 748 |
-
}
|
| 749 |
-
} else {
|
| 750 |
-
Logger.info(`未知命令: ${commandName}`, 'Bot.executeCommand');
|
| 751 |
-
await this.sendMessage(chatId, Utils.translate("command_not_found"), {
|
| 752 |
-
reply_to_message_id: messageId,
|
| 753 |
-
});
|
| 754 |
-
}
|
| 755 |
-
}
|
| 756 |
-
|
| 757 |
-
/**
|
| 758 |
-
* 处理常规文本消息 (非命令)。
|
| 759 |
-
*/
|
| 760 |
-
async handleTextMessage(chatId, userId, userName, text, messageId, chatType) {
|
| 761 |
-
let shouldReply = false;
|
| 762 |
-
let messageText = text;
|
| 763 |
-
|
| 764 |
-
if (chatType === 'private') {
|
| 765 |
-
shouldReply = true;
|
| 766 |
-
} else if (chatType === 'group' || chatType === 'supergroup') {
|
| 767 |
-
if (text.includes(`@xiao_ye_mbot`)) { // 硬编码的机器人名称,最好来自配置
|
| 768 |
-
shouldReply = true;
|
| 769 |
-
messageText = text.replace(/@xiao_ye_mbot\s*/, '').trim();
|
| 770 |
-
}
|
| 771 |
-
}
|
| 772 |
-
|
| 773 |
-
if (!shouldReply || !messageText) {
|
| 774 |
-
return; // 消息与我们无关或是空消息
|
| 775 |
-
}
|
| 776 |
-
|
| 777 |
-
try {
|
| 778 |
-
await Utils.sendChatAction(chatId, "typing", this.config);
|
| 779 |
-
|
| 780 |
-
const context = await this.getContext(userId);
|
| 781 |
-
const currentModel = await this.getCurrentModel(userId);
|
| 782 |
-
|
| 783 |
-
const messages = this.buildHistory(context, messageText);
|
| 784 |
-
|
| 785 |
-
const response = await this.openaiApi.generateResponse(messages, currentModel);
|
| 786 |
-
|
| 787 |
-
const formattedResponse = Utils.formatMarkdown(response);
|
| 788 |
-
|
| 789 |
-
await this.sendMessageWithFallback(chatId, formattedResponse, {
|
| 790 |
-
reply_to_message_id: messageId,
|
| 791 |
-
});
|
| 792 |
-
|
| 793 |
-
// 存储上下文
|
| 794 |
-
await this.storeContext(userId, `User: ${messageText}\nAssistant: ${response}`);
|
| 795 |
-
} catch (error) {
|
| 796 |
-
Logger.error(`处理文本消息时出错`, error, 'Bot.handleTextMessage');
|
| 797 |
-
await this.sendMessageWithFallback(chatId, Utils.translate("error") + ": " + error.message);
|
| 798 |
-
}
|
| 799 |
-
}
|
| 800 |
-
|
| 801 |
-
/**
|
| 802 |
-
* 处理图像消息。
|
| 803 |
-
*/
|
| 804 |
-
async handleImageAnalysis(chatId, message) {
|
| 805 |
-
const fileId = message.photo[message.photo.length - 1].file_id; // 获取最高分辨率
|
| 806 |
-
const caption = message.caption || "请分析这张图片。"; // 默认提示
|
| 807 |
-
|
| 808 |
-
try {
|
| 809 |
-
await Utils.sendChatAction(chatId, "typing", this.config);
|
| 810 |
-
const fileUrl = await this.getFileUrl(fileId);
|
| 811 |
-
const currentModel = await this.getCurrentModel(chatId.toString()); // 获取用户的首选模型
|
| 812 |
-
|
| 813 |
-
// 使用 OpenAI API 的视觉能力
|
| 814 |
-
const analysisResult = await this.openaiApi.analyzeImage(fileUrl, caption, currentModel);
|
| 815 |
-
await this.sendMessageWithFallback(chatId, analysisResult);
|
| 816 |
-
} catch (error) {
|
| 817 |
-
Logger.error("图像分析出错", error, 'Bot.handleImageAnalysis');
|
| 818 |
-
await this.sendMessage(chatId, Utils.translate("image_analysis_error"));
|
| 819 |
-
}
|
| 820 |
-
}
|
| 821 |
-
|
| 822 |
-
/**
|
| 823 |
-
* 处理来自内联键盘的回调查询。
|
| 824 |
-
*/
|
| 825 |
-
async handleCallbackQuery(query) {
|
| 826 |
-
const chatId = query.message.chat.id;
|
| 827 |
-
const userId = query.from.id.toString();
|
| 828 |
-
const data = query.data;
|
| 829 |
-
|
| 830 |
-
Logger.info(`正在处理回调查询: ${data}`, 'Bot.handleCallbackQuery');
|
| 831 |
-
|
| 832 |
-
if (data.startsWith("model_")) {
|
| 833 |
-
const newModel = data.split("_")[1];
|
| 834 |
-
try {
|
| 835 |
-
await this.setCurrentModel(userId, newModel);
|
| 836 |
-
await this.sendMessageWithFallback(chatId, Utils.translate("model_changed") + newModel);
|
| 837 |
-
await this.clearContext(userId, chatId);
|
| 838 |
-
} catch (error) {
|
| 839 |
-
Logger.error("切换模型出错", error, 'Bot.handleCallbackQuery');
|
| 840 |
-
await this.sendMessageWithFallback(chatId, Utils.translate("error") + ": " + error.message);
|
| 841 |
-
}
|
| 842 |
-
}
|
| 843 |
-
|
| 844 |
-
// 确认查询
|
| 845 |
-
try {
|
| 846 |
-
await fetch(`${this.apiUrl}/answerCallbackQuery`, {
|
| 847 |
-
method: "POST",
|
| 848 |
-
headers: { "Content-Type": "application/json" },
|
| 849 |
-
body: JSON.stringify({ callback_query_id: query.id }),
|
| 850 |
-
});
|
| 851 |
-
} catch (error) {
|
| 852 |
-
Logger.warn("确认回调查询时出错", error, 'Bot.handleCallbackQuery');
|
| 853 |
-
}
|
| 854 |
-
}
|
| 855 |
-
|
| 856 |
-
// --- 历史与上下文 ---
|
| 857 |
-
|
| 858 |
-
buildHistory(contextString, currentMessage) {
|
| 859 |
-
const messages = [];
|
| 860 |
-
|
| 861 |
-
// 系统提示
|
| 862 |
-
messages.push({
|
| 863 |
-
role: this.config.systemInitMessageRole,
|
| 864 |
-
content: this.config.systemInitMessage,
|
| 865 |
-
});
|
| 866 |
-
|
| 867 |
-
// 解析历史
|
| 868 |
-
if (contextString) {
|
| 869 |
-
const regex = /(User|Assistant): ([\s\S]*?)(?=\nUser:|\nAssistant:|$)/g;
|
| 870 |
-
let match;
|
| 871 |
-
while ((match = regex.exec(contextString)) !== null) {
|
| 872 |
-
messages.push({
|
| 873 |
-
role: match[1].toLowerCase(),
|
| 874 |
-
content: match[2].trim(),
|
| 875 |
-
});
|
| 876 |
-
}
|
| 877 |
-
}
|
| 878 |
-
|
| 879 |
-
// 添加当前消息
|
| 880 |
-
messages.push({
|
| 881 |
-
role: "user",
|
| 882 |
-
content: currentMessage,
|
| 883 |
-
});
|
| 884 |
-
|
| 885 |
-
// 添加最后的“优化”提示 (来自原始代码)
|
| 886 |
-
messages.push({
|
| 887 |
-
role: "system",
|
| 888 |
-
content: `</Story history>\n## 须完全遵守的规范\n<CAUTION>\n乐于助人,仔细的编写高质量代码</CAUTION>`
|
| 889 |
-
});
|
| 890 |
-
|
| 891 |
-
return messages;
|
| 892 |
-
}
|
| 893 |
-
|
| 894 |
-
async storeContext(userId, context) {
|
| 895 |
-
await this.redis.appendContext(userId, context);
|
| 896 |
-
}
|
| 897 |
-
|
| 898 |
-
async getContext(userId) {
|
| 899 |
-
return await this.redis.get(`context:${userId}`);
|
| 900 |
-
}
|
| 901 |
-
|
| 902 |
-
async clearContext(userId, chatId) {
|
| 903 |
-
await this.redis.del(`context:${userId}`);
|
| 904 |
-
await this.sendMessageWithFallback(chatId, Utils.translate("new_conversation"));
|
| 905 |
-
}
|
| 906 |
-
|
| 907 |
-
async summarizeHistory(userId) {
|
| 908 |
-
const context = await this.getContext(userId);
|
| 909 |
-
if (!context) {
|
| 910 |
-
return Utils.translate("no_history");
|
| 911 |
-
}
|
| 912 |
-
// 这不是摘要,只是转储。
|
| 913 |
-
// 真正的摘要需要另一次 AI 调用。
|
| 914 |
-
return `Context:\n${context}`;
|
| 915 |
-
}
|
| 916 |
-
|
| 917 |
-
// --- 模型管理 ---
|
| 918 |
-
async getCurrentModel(userId) {
|
| 919 |
-
const model = await this.redis.get(`model:${userId}`);
|
| 920 |
-
if (model) return model;
|
| 921 |
-
|
| 922 |
-
// 回退逻辑
|
| 923 |
-
return this.config.openaiCompatibleModels[0]
|
| 924 |
-
|| this.openaiApi.getDefaultModel()
|
| 925 |
-
|| this.config.defaultModel;
|
| 926 |
-
}
|
| 927 |
-
|
| 928 |
-
async setCurrentModel(userId, model) {
|
| 929 |
-
await this.redis.set(`model:${userId}`, model);
|
| 930 |
-
Logger.info(`已为用户 ${userId} 设置模型为 ${model}`, 'Bot.setCurrentModel');
|
| 931 |
-
}
|
| 932 |
-
|
| 933 |
-
// --- Telegram API 包装器 ---
|
| 934 |
-
async sendMessage(chatId, text, options = {}) {
|
| 935 |
-
const messages = Utils.splitMessage(text);
|
| 936 |
-
const results = [];
|
| 937 |
-
for (const message of messages) {
|
| 938 |
-
try {
|
| 939 |
-
const response = await fetch(`${this.apiUrl}/sendMessage`, {
|
| 940 |
-
method: "POST",
|
| 941 |
-
headers: { "Content-Type": "application/json" },
|
| 942 |
-
body: JSON.stringify({
|
| 943 |
-
chat_id: chatId,
|
| 944 |
-
text: message,
|
| 945 |
-
...options,
|
| 946 |
-
}),
|
| 947 |
-
});
|
| 948 |
-
if (!response.ok) {
|
| 949 |
-
const errorData = await response.json();
|
| 950 |
-
throw new Error(`Telegram API 错误: ${errorData.description}`);
|
| 951 |
-
}
|
| 952 |
-
const result = await response.json();
|
| 953 |
-
results.push(result);
|
| 954 |
-
} catch (error) {
|
| 955 |
-
Logger.error(`发送消息部分失败`, error, 'Bot.sendMessage');
|
| 956 |
-
throw error; // 抛出错误
|
| 957 |
-
}
|
| 958 |
-
}
|
| 959 |
-
return results;
|
| 960 |
-
}
|
| 961 |
-
|
| 962 |
-
async sendMessageWithFallback(chatId, text, options = {}) {
|
| 963 |
-
try {
|
| 964 |
-
const markdownMessage = Utils.formatMarkdown(text);
|
| 965 |
-
await this.sendMessage(chatId, markdownMessage, { ...options, parse_mode: "Markdown" });
|
| 966 |
-
} catch (error) {
|
| 967 |
-
Logger.warn(`Markdown 发送失败,回退到纯文本`, error, 'Bot.sendMessageWithFallback');
|
| 968 |
-
try {
|
| 969 |
-
const plainText = Utils.stripFormatting(text);
|
| 970 |
-
await this.sendMessage(chatId, plainText, options);
|
| 971 |
-
} catch (fallbackError) {
|
| 972 |
-
Logger.error(`纯文本回退失败`, fallbackError, 'Bot.sendMessageWithFallback');
|
| 973 |
-
}
|
| 974 |
-
}
|
| 975 |
-
}
|
| 976 |
-
|
| 977 |
-
async sendPhoto(chatId, photo, options = {}) {
|
| 978 |
-
const formData = new FormData();
|
| 979 |
-
formData.append("chat_id", chatId.toString());
|
| 980 |
-
|
| 981 |
-
if (typeof photo === "string") {
|
| 982 |
-
formData.append("photo", photo); // 作为 URL 发送
|
| 983 |
-
} else {
|
| 984 |
-
const blob = new Blob([photo], { type: "image/png" });
|
| 985 |
-
formData.append("photo", blob, "image.png");
|
| 986 |
-
}
|
| 987 |
-
|
| 988 |
-
if (options.caption) {
|
| 989 |
-
formData.append("caption", options.caption);
|
| 990 |
-
}
|
| 991 |
-
|
| 992 |
-
try {
|
| 993 |
-
const response = await fetch(`${this.apiUrl}/sendPhoto`, {
|
| 994 |
-
method: "POST",
|
| 995 |
-
body: formData, // fetch 会自动处理 multipart/form-data
|
| 996 |
-
});
|
| 997 |
-
if (!response.ok) {
|
| 998 |
-
const errorData = await response.json();
|
| 999 |
-
throw new Error(`Telegram API 错误: ${errorData.description}`);
|
| 1000 |
-
}
|
| 1001 |
-
} catch (error) {
|
| 1002 |
-
Logger.error(`发送照片失败`, error, 'Bot.sendPhoto');
|
| 1003 |
-
throw error;
|
| 1004 |
-
}
|
| 1005 |
-
}
|
| 1006 |
-
|
| 1007 |
-
async getFileUrl(fileId) {
|
| 1008 |
-
const response = await fetch(`${this.apiUrl}/getFile?file_id=${fileId}`);
|
| 1009 |
-
const data = await response.json();
|
| 1010 |
-
if (data.ok) {
|
| 1011 |
-
return `https://api.telegram.org/file/bot${this.config.telegramBotToken}/${data.result.file_path}`;
|
| 1012 |
-
}
|
| 1013 |
-
throw new Error("获取文件 URL 失败");
|
| 1014 |
-
}
|
| 1015 |
-
|
| 1016 |
-
// --- 白名单 ---
|
| 1017 |
-
isUserWhitelisted(userId) {
|
| 1018 |
-
if (this.config.whitelistedUsers.length === 0) return true; // 没有白名单 = 公开
|
| 1019 |
-
return this.config.whitelistedUsers.includes(userId);
|
| 1020 |
-
}
|
| 1021 |
-
isChatWhitelisted(chatId) {
|
| 1022 |
-
if (this.config.whitelistedChats.length === 0) return true;
|
| 1023 |
-
return this.config.whitelistedChats.includes(chatId);
|
| 1024 |
-
}
|
| 1025 |
-
|
| 1026 |
-
// --- 设置 & 定时任务 ---
|
| 1027 |
-
async setWebhook() {
|
| 1028 |
-
const url = `${this.config.webhookHost}/webhook`;
|
| 1029 |
-
Logger.info(`正在设置 webhook: ${url}`, 'Bot.setWebhook');
|
| 1030 |
-
|
| 1031 |
-
const response = await fetch(`${this.apiUrl}/setWebhook`, {
|
| 1032 |
-
method: "POST",
|
| 1033 |
-
headers: { "Content-Type": "application/json" },
|
| 1034 |
-
body: JSON.stringify({ url, allowed_updates: ["message", "callback_query"] }),
|
| 1035 |
-
});
|
| 1036 |
-
|
| 1037 |
-
const result = await response.json();
|
| 1038 |
-
if (!result.ok) {
|
| 1039 |
-
Logger.error(`设置 webhook 失败: ${result.description}`, result, 'Bot.setWebhook');
|
| 1040 |
-
throw new Error(`设置 webhook 失败: ${result.description}`);
|
| 1041 |
-
}
|
| 1042 |
-
Logger.info("Webhook 设置成功", 'Bot.setWebhook');
|
| 1043 |
-
}
|
| 1044 |
-
|
| 1045 |
-
async setMenuButton() {
|
| 1046 |
-
const defaultCommands = this.commands.map((cmd) => ({
|
| 1047 |
-
command: cmd.name,
|
| 1048 |
-
description: Utils.translate(cmd.description),
|
| 1049 |
-
}));
|
| 1050 |
-
|
| 1051 |
-
try {
|
| 1052 |
-
await fetch(`${this.apiUrl}/setMyCommands`, {
|
| 1053 |
-
method: "POST",
|
| 1054 |
-
headers: { "Content-Type": "application/json" },
|
| 1055 |
-
body: JSON.stringify({ commands: defaultCommands }),
|
| 1056 |
-
});
|
| 1057 |
-
Logger.info("菜单按钮命令设置成功", 'Bot.setMenuButton');
|
| 1058 |
-
} catch (error) {
|
| 1059 |
-
Logger.warn("设置菜单按钮时出错", error, 'Bot.setMenuButton');
|
| 1060 |
-
}
|
| 1061 |
-
}
|
| 1062 |
-
|
| 1063 |
-
async handleRSSUpdate() {
|
| 1064 |
-
Logger.info('正在运行定时 RSS 更新...', 'Bot.handleRSSUpdate');
|
| 1065 |
-
const chatIds = [-1002052237675, 6486168606, -1002357672489]; // 硬编码
|
| 1066 |
-
const RSS_URL = 'https://ci-en.dlsite.com/creator/4551/article/xml/rss'; // 修复: 移除了 Markdown
|
| 1067 |
-
const REDIS_KEY = "LAST_RSS_LINK";
|
| 1068 |
-
|
| 1069 |
-
try {
|
| 1070 |
-
const response = await fetch(RSS_URL);
|
| 1071 |
-
const xmlText = await response.text();
|
| 1072 |
-
|
| 1073 |
-
const match = /<rdf:li\s+rdf:resource="[^"]*\/article\/(\d+)"[\s\/>]/g.exec(xmlText);
|
| 1074 |
-
if (!match) {
|
| 1075 |
-
throw new Error("无法解析 RSS feed");
|
| 1076 |
-
}
|
| 1077 |
-
|
| 1078 |
-
const latestLink = match[1];
|
| 1079 |
-
const lastKnownLink = await this.redis.get(REDIS_KEY);
|
| 1080 |
-
|
| 1081 |
-
if (latestLink !== lastKnownLink) {
|
| 1082 |
-
Logger.info(`发现新的 RSS 链接: ${latestLink}。旧: ${lastKnownLink}`, 'Bot.handleRSSUpdate');
|
| 1083 |
-
const message = `主人,您订阅的魔装影姫cien更新啦!\n\nhttps://ci-en.dlsite.com/creator/4551/article/${latestLink}`;
|
| 1084 |
-
|
| 1085 |
-
const sendPromises = chatIds.map(chatId => this.sendMessage(chatId, message));
|
| 1086 |
-
await Promise.all(sendPromises);
|
| 1087 |
-
|
| 1088 |
-
await this.redis.set(REDIS_KEY, latestLink); // 'set' 会使用默认 TTL
|
| 1089 |
-
} else {
|
| 1090 |
-
Logger.info('未发现新的 RSS 更新。', 'Bot.handleRSSUpdate');
|
| 1091 |
-
}
|
| 1092 |
-
} catch (error) {
|
| 1093 |
-
Logger.error("RSS 更新期间出错", error, 'Bot.handleRSSUpdate');
|
| 1094 |
-
}
|
| 1095 |
-
}
|
| 1096 |
-
|
| 1097 |
-
async getcienResource(chatId) {
|
| 1098 |
-
// 这是 /cien 命令的实现
|
| 1099 |
-
try {
|
| 1100 |
-
const response = await fetch('https://ci-en.dlsite.com/creator/4551/article/xml/rss'); // 修复: 移除了 Markdown
|
| 1101 |
-
const xmlText = await response.text();
|
| 1102 |
-
const match = /<rdf:li\s+rdf:resource="[^"]*\/article\/(\d+)"[\s\/>]/g.exec(xmlText);
|
| 1103 |
-
if (!match) throw new Error("无法提取 RSS 链接。");
|
| 1104 |
-
|
| 1105 |
-
const latestLink = match[1];
|
| 1106 |
-
await this.sendMessage(chatId, `主人,这是您订阅的魔装影姫cien最新的内容哦,请查收!\n\nhttps://ci-en.dlsite.com/creator/4551/article/${latestLink}`);
|
| 1107 |
-
} catch (error) {
|
| 1108 |
-
Logger.error("/cien 命令出错", error, 'Bot.getcienResource');
|
| 1109 |
-
throw error;
|
| 1110 |
-
}
|
| 1111 |
-
}
|
| 1112 |
-
}
|
| 1113 |
-
|
| 1114 |
-
// === 主服务器 ===
|
| 1115 |
-
/**
|
| 1116 |
-
* 初始化并启动 Express 服务器。
|
| 1117 |
-
*/
|
| 1118 |
-
async function main() {
|
| 1119 |
-
Logger.info('正在启动机器人服务器...');
|
| 1120 |
-
|
| 1121 |
-
// 1. 初始化配置
|
| 1122 |
-
const config = new Config(process.env);
|
| 1123 |
-
|
| 1124 |
-
// 2. 初始化机器人
|
| 1125 |
-
const bot = new TelegramBot(config);
|
| 1126 |
-
|
| 1127 |
-
// 3. 初始化 Express 应用
|
| 1128 |
-
const app = express();
|
| 1129 |
-
app.use(express.json()); // 用于解析 JSON 请求体的中间件
|
| 1130 |
-
|
| 1131 |
-
// 4. 定义路由
|
| 1132 |
-
// 健康检查路由
|
| 1133 |
-
app.get('/', (req, res) => {
|
| 1134 |
-
res.status(200).send('你好! 这是你的 Telegram 机器人服务器。');
|
| 1135 |
-
});
|
| 1136 |
-
|
| 1137 |
-
// Webhook 路由
|
| 1138 |
-
app.post('/webhook', async (req, res) => {
|
| 1139 |
-
try {
|
| 1140 |
-
await bot.processUpdate(req.body);
|
| 1141 |
-
res.status(200).send('OK'); // 总是快速向 Telegram 返回 200
|
| 1142 |
-
} catch (error) {
|
| 1143 |
-
Logger.error('webhook 处理中发生未捕获的错误', error, 'Server.webhook');
|
| 1144 |
-
res.status(200).send('OK'); // 仍然发送 OK 以避免 Telegram 重试
|
| 1145 |
-
}
|
| 1146 |
-
});
|
| 1147 |
-
|
| 1148 |
-
// 6. 启动定时任务
|
| 1149 |
-
// 每 10 分钟运行一次
|
| 1150 |
-
cron.schedule('*/10 * * * *', () => {
|
| 1151 |
-
bot.handleRSSUpdate();
|
| 1152 |
-
});
|
| 1153 |
-
Logger.info('已注册 RSS 定时任务 (每 10 分钟运行一次)', 'Server.main');
|
| 1154 |
-
|
| 1155 |
-
// 7. 启动服务器
|
| 1156 |
-
app.listen(config.port, () => {
|
| 1157 |
-
Logger.info(`服务器正在监听 http://localhost:${config.port}`, 'Server.main');
|
| 1158 |
-
});
|
| 1159 |
-
// 5. 设置机器人 (Webhook & 命令)
|
| 1160 |
-
try {
|
| 1161 |
-
if (process.env.NODE_ENV !== 'development') { // 通常不在本地开发时设置 webhook
|
| 1162 |
-
await bot.setWebhook();
|
| 1163 |
-
}
|
| 1164 |
-
await bot.setMenuButton();
|
| 1165 |
-
} catch (error) {
|
| 1166 |
-
Logger.error('机器人设置失败', error, 'Server.main');
|
| 1167 |
-
process.exit(1);
|
| 1168 |
-
}
|
| 1169 |
-
}
|
| 1170 |
-
|
| 1171 |
-
// 运行服务器
|
| 1172 |
-
main();
|
| 1173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|