squidbot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- squidbot/__init__.py +5 -0
- squidbot/agent.py +263 -0
- squidbot/channels.py +271 -0
- squidbot/character.py +83 -0
- squidbot/client.py +318 -0
- squidbot/config.py +148 -0
- squidbot/daemon.py +310 -0
- squidbot/lanes.py +41 -0
- squidbot/main.py +157 -0
- squidbot/memory_db.py +706 -0
- squidbot/playwright_check.py +233 -0
- squidbot/plugins/__init__.py +47 -0
- squidbot/plugins/base.py +96 -0
- squidbot/plugins/hooks.py +416 -0
- squidbot/plugins/loader.py +248 -0
- squidbot/plugins/web3_plugin.py +407 -0
- squidbot/scheduler.py +214 -0
- squidbot/server.py +487 -0
- squidbot/session.py +609 -0
- squidbot/skills.py +141 -0
- squidbot/skills_template/reminder/SKILL.md +13 -0
- squidbot/skills_template/search/SKILL.md +11 -0
- squidbot/skills_template/summarize/SKILL.md +14 -0
- squidbot/tools/__init__.py +100 -0
- squidbot/tools/base.py +42 -0
- squidbot/tools/browser.py +311 -0
- squidbot/tools/coding.py +599 -0
- squidbot/tools/cron.py +218 -0
- squidbot/tools/memory_tool.py +152 -0
- squidbot/tools/web_search.py +50 -0
- squidbot-0.1.0.dist-info/METADATA +542 -0
- squidbot-0.1.0.dist-info/RECORD +34 -0
- squidbot-0.1.0.dist-info/WHEEL +4 -0
- squidbot-0.1.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Web3 Plugin for SquidBot
|
|
3
|
+
|
|
4
|
+
Provides blockchain wallet functionality:
|
|
5
|
+
- Wallet from mnemonic or random generation
|
|
6
|
+
- Get balance
|
|
7
|
+
- Send CRO
|
|
8
|
+
- Get transaction count
|
|
9
|
+
|
|
10
|
+
Hooks:
|
|
11
|
+
- before_tool_call: Log and optionally block dangerous transactions
|
|
12
|
+
- after_tool_call: Log transaction results
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from dotenv import load_dotenv
|
|
20
|
+
|
|
21
|
+
from ..tools.base import Tool
|
|
22
|
+
from .base import Plugin, PluginApi, PluginManifest
|
|
23
|
+
from .hooks import (AfterToolCallEvent, BeforeToolCallEvent,
|
|
24
|
+
BeforeToolCallResult, HookContext, HookName)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Load environment
|
|
29
|
+
load_dotenv()
|
|
30
|
+
|
|
31
|
+
# Configuration from environment
|
|
32
|
+
SQUIDBOT_MNEMONICS = os.environ.get("SQUIDBOT_MNEMONICS", "")
|
|
33
|
+
SQUIDBOT_WALLET_INDEX = int(os.environ.get("SQUIDBOT_WALLET_INDEX", "0"))
|
|
34
|
+
SQUIDBOT_CHAINID = int(os.environ.get("SQUIDBOT_CHAINID", "338"))
|
|
35
|
+
SQUIDBOT_RPC = os.environ.get("SQUIDBOT_RPC", "https://evm-dev-t3.cronos.org")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_web3():
|
|
39
|
+
"""Get Web3 instance connected to configured RPC."""
|
|
40
|
+
try:
|
|
41
|
+
from web3 import Web3
|
|
42
|
+
|
|
43
|
+
w3 = Web3(Web3.HTTPProvider(SQUIDBOT_RPC))
|
|
44
|
+
return w3
|
|
45
|
+
except ImportError:
|
|
46
|
+
logger.error("web3 package not installed. Run: pip install web3")
|
|
47
|
+
raise
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_wallet():
|
|
51
|
+
"""Get wallet from mnemonic or generate random."""
|
|
52
|
+
try:
|
|
53
|
+
from eth_account import Account
|
|
54
|
+
|
|
55
|
+
Account.enable_unaudited_hdwallet_features()
|
|
56
|
+
|
|
57
|
+
mnemonics = SQUIDBOT_MNEMONICS.strip()
|
|
58
|
+
|
|
59
|
+
if not mnemonics:
|
|
60
|
+
# Generate random mnemonic using os.urandom
|
|
61
|
+
from mnemonic import Mnemonic
|
|
62
|
+
|
|
63
|
+
mnemo = Mnemonic("english")
|
|
64
|
+
entropy = os.urandom(16) # 128 bits = 12 words
|
|
65
|
+
mnemonics = mnemo.to_mnemonic(entropy)
|
|
66
|
+
logger.info("Generated random wallet (mnemonic not configured)")
|
|
67
|
+
logger.info(f"Random mnemonic: {mnemonics}")
|
|
68
|
+
logger.warning("Set SQUIDBOT_MNEMONICS in .env to use a persistent wallet")
|
|
69
|
+
|
|
70
|
+
# Derive account from mnemonic with wallet index
|
|
71
|
+
account = Account.from_mnemonic(
|
|
72
|
+
mnemonics, account_path=f"m/44'/60'/0'/0/{SQUIDBOT_WALLET_INDEX}"
|
|
73
|
+
)
|
|
74
|
+
return account, mnemonics
|
|
75
|
+
|
|
76
|
+
except ImportError as e:
|
|
77
|
+
logger.error(f"Required package not installed: {e}")
|
|
78
|
+
logger.error("Run: pip install web3 mnemonic")
|
|
79
|
+
raise
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class WalletInfoTool(Tool):
|
|
83
|
+
"""Get wallet address and basic info."""
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def name(self) -> str:
|
|
87
|
+
return "wallet_info"
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def description(self) -> str:
|
|
91
|
+
return "Get the wallet address and chain information"
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def parameters(self) -> dict:
|
|
95
|
+
return {"type": "object", "properties": {}, "required": []}
|
|
96
|
+
|
|
97
|
+
async def execute(self, **kwargs) -> Any:
|
|
98
|
+
try:
|
|
99
|
+
account, _ = get_wallet()
|
|
100
|
+
w3 = get_web3()
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
"success": True,
|
|
104
|
+
"address": account.address,
|
|
105
|
+
"chain_id": SQUIDBOT_CHAINID,
|
|
106
|
+
"rpc_url": SQUIDBOT_RPC,
|
|
107
|
+
"wallet_index": SQUIDBOT_WALLET_INDEX,
|
|
108
|
+
"connected": w3.is_connected(),
|
|
109
|
+
}
|
|
110
|
+
except Exception as e:
|
|
111
|
+
return {"success": False, "error": str(e)}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class GetBalanceTool(Tool):
|
|
115
|
+
"""Get wallet balance in CRO."""
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def name(self) -> str:
|
|
119
|
+
return "get_balance"
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def description(self) -> str:
|
|
123
|
+
return "Get the CRO balance of the wallet or a specified address"
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def parameters(self) -> dict:
|
|
127
|
+
return {
|
|
128
|
+
"type": "object",
|
|
129
|
+
"properties": {
|
|
130
|
+
"address": {
|
|
131
|
+
"type": "string",
|
|
132
|
+
"description": "Address to check balance (optional, defaults to wallet address)",
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"required": [],
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async def execute(self, address: str = "", **kwargs) -> Any:
|
|
139
|
+
try:
|
|
140
|
+
w3 = get_web3()
|
|
141
|
+
|
|
142
|
+
if not address:
|
|
143
|
+
account, _ = get_wallet()
|
|
144
|
+
address = account.address
|
|
145
|
+
|
|
146
|
+
# Validate address
|
|
147
|
+
if not w3.is_address(address):
|
|
148
|
+
return {"success": False, "error": f"Invalid address: {address}"}
|
|
149
|
+
|
|
150
|
+
checksum_address = w3.to_checksum_address(address)
|
|
151
|
+
balance_wei = w3.eth.get_balance(checksum_address)
|
|
152
|
+
balance_cro = w3.from_wei(balance_wei, "ether")
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
"success": True,
|
|
156
|
+
"address": checksum_address,
|
|
157
|
+
"balance_wei": str(balance_wei),
|
|
158
|
+
"balance_cro": str(balance_cro),
|
|
159
|
+
"unit": "CRO",
|
|
160
|
+
}
|
|
161
|
+
except Exception as e:
|
|
162
|
+
return {"success": False, "error": str(e)}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class SendCROTool(Tool):
|
|
166
|
+
"""Send CRO to an address."""
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def name(self) -> str:
|
|
170
|
+
return "send_cro"
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def description(self) -> str:
|
|
174
|
+
return "Send CRO to a specified address"
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def parameters(self) -> dict:
|
|
178
|
+
return {
|
|
179
|
+
"type": "object",
|
|
180
|
+
"properties": {
|
|
181
|
+
"to_address": {
|
|
182
|
+
"type": "string",
|
|
183
|
+
"description": "Recipient address",
|
|
184
|
+
},
|
|
185
|
+
"amount": {
|
|
186
|
+
"type": "string",
|
|
187
|
+
"description": "Amount of CRO to send (e.g., '1.5')",
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
"required": ["to_address", "amount"],
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async def execute(self, to_address: str, amount: str, **kwargs) -> Any:
|
|
194
|
+
try:
|
|
195
|
+
w3 = get_web3()
|
|
196
|
+
account, _ = get_wallet()
|
|
197
|
+
|
|
198
|
+
# Validate recipient address
|
|
199
|
+
if not w3.is_address(to_address):
|
|
200
|
+
return {"success": False, "error": f"Invalid address: {to_address}"}
|
|
201
|
+
|
|
202
|
+
to_checksum = w3.to_checksum_address(to_address)
|
|
203
|
+
amount_wei = w3.to_wei(float(amount), "ether")
|
|
204
|
+
|
|
205
|
+
# Get current nonce
|
|
206
|
+
nonce = w3.eth.get_transaction_count(account.address)
|
|
207
|
+
|
|
208
|
+
# Build transaction
|
|
209
|
+
tx = {
|
|
210
|
+
"nonce": nonce,
|
|
211
|
+
"to": to_checksum,
|
|
212
|
+
"value": amount_wei,
|
|
213
|
+
"gas": 21000, # Standard ETH transfer
|
|
214
|
+
"gasPrice": w3.eth.gas_price,
|
|
215
|
+
"chainId": SQUIDBOT_CHAINID,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
# Sign and send
|
|
219
|
+
signed_tx = w3.eth.account.sign_transaction(tx, account.key)
|
|
220
|
+
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
"success": True,
|
|
224
|
+
"tx_hash": tx_hash.hex(),
|
|
225
|
+
"from": account.address,
|
|
226
|
+
"to": to_checksum,
|
|
227
|
+
"amount_cro": amount,
|
|
228
|
+
"amount_wei": str(amount_wei),
|
|
229
|
+
"chain_id": SQUIDBOT_CHAINID,
|
|
230
|
+
}
|
|
231
|
+
except Exception as e:
|
|
232
|
+
return {"success": False, "error": str(e)}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class GetTxCountTool(Tool):
|
|
236
|
+
"""Get transaction count (nonce) for an address."""
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def name(self) -> str:
|
|
240
|
+
return "get_tx_count"
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def description(self) -> str:
|
|
244
|
+
return "Get the transaction count (nonce) for the wallet or a specified address"
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def parameters(self) -> dict:
|
|
248
|
+
return {
|
|
249
|
+
"type": "object",
|
|
250
|
+
"properties": {
|
|
251
|
+
"address": {
|
|
252
|
+
"type": "string",
|
|
253
|
+
"description": "Address to check (optional, defaults to wallet address)",
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
"required": [],
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async def execute(self, address: str = "", **kwargs) -> Any:
|
|
260
|
+
try:
|
|
261
|
+
w3 = get_web3()
|
|
262
|
+
|
|
263
|
+
if not address:
|
|
264
|
+
account, _ = get_wallet()
|
|
265
|
+
address = account.address
|
|
266
|
+
|
|
267
|
+
# Validate address
|
|
268
|
+
if not w3.is_address(address):
|
|
269
|
+
return {"success": False, "error": f"Invalid address: {address}"}
|
|
270
|
+
|
|
271
|
+
checksum_address = w3.to_checksum_address(address)
|
|
272
|
+
tx_count = w3.eth.get_transaction_count(checksum_address)
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
"success": True,
|
|
276
|
+
"address": checksum_address,
|
|
277
|
+
"transaction_count": tx_count,
|
|
278
|
+
"nonce": tx_count, # Alias for clarity
|
|
279
|
+
}
|
|
280
|
+
except Exception as e:
|
|
281
|
+
return {"success": False, "error": str(e)}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class Web3Plugin(Plugin):
|
|
285
|
+
"""Web3 blockchain plugin for SquidBot."""
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def manifest(self) -> PluginManifest:
|
|
289
|
+
return PluginManifest(
|
|
290
|
+
id="web3",
|
|
291
|
+
name="Web3 Plugin",
|
|
292
|
+
description="Blockchain wallet functionality for Cronos chain",
|
|
293
|
+
version="1.0.0",
|
|
294
|
+
author="SquidBot",
|
|
295
|
+
config_schema={
|
|
296
|
+
"type": "object",
|
|
297
|
+
"properties": {
|
|
298
|
+
"SQUIDBOT_MNEMONICS": {
|
|
299
|
+
"type": "string",
|
|
300
|
+
"description": "BIP39 mnemonic phrase (12 or 24 words)",
|
|
301
|
+
},
|
|
302
|
+
"SQUIDBOT_WALLET_INDEX": {
|
|
303
|
+
"type": "integer",
|
|
304
|
+
"description": "HD wallet derivation index",
|
|
305
|
+
"default": 0,
|
|
306
|
+
},
|
|
307
|
+
"SQUIDBOT_CHAINID": {
|
|
308
|
+
"type": "integer",
|
|
309
|
+
"description": "Blockchain chain ID",
|
|
310
|
+
"default": 338,
|
|
311
|
+
},
|
|
312
|
+
"SQUIDBOT_RPC": {
|
|
313
|
+
"type": "string",
|
|
314
|
+
"description": "RPC endpoint URL",
|
|
315
|
+
"default": "https://evm-dev-t3.cronos.org",
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def get_tools(self) -> list[Tool]:
|
|
322
|
+
return [
|
|
323
|
+
WalletInfoTool(),
|
|
324
|
+
GetBalanceTool(),
|
|
325
|
+
SendCROTool(),
|
|
326
|
+
GetTxCountTool(),
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
def register_hooks(self, api: PluginApi) -> None:
|
|
330
|
+
"""Register hooks for transaction monitoring."""
|
|
331
|
+
|
|
332
|
+
# Hook: Before tool call - monitor and optionally block transactions
|
|
333
|
+
async def on_before_tool_call(
|
|
334
|
+
event: BeforeToolCallEvent, ctx: HookContext
|
|
335
|
+
) -> BeforeToolCallResult | None:
|
|
336
|
+
# Only interested in web3 tools
|
|
337
|
+
if event.tool_name not in (
|
|
338
|
+
"send_cro",
|
|
339
|
+
"get_balance",
|
|
340
|
+
"wallet_info",
|
|
341
|
+
"get_tx_count",
|
|
342
|
+
):
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
logger.info(f"[Web3 Hook] Before {event.tool_name}: {event.params}")
|
|
346
|
+
|
|
347
|
+
# Example: Block large transactions (over 100 CRO)
|
|
348
|
+
if event.tool_name == "send_cro":
|
|
349
|
+
amount = float(event.params.get("amount", 0))
|
|
350
|
+
if amount > 100:
|
|
351
|
+
logger.warning(
|
|
352
|
+
f"[Web3 Hook] Blocking large transaction: {amount} CRO"
|
|
353
|
+
)
|
|
354
|
+
return BeforeToolCallResult(
|
|
355
|
+
block=True,
|
|
356
|
+
block_reason=f"Transaction amount {amount} CRO exceeds limit of 100 CRO",
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
# Hook: After tool call - log results
|
|
362
|
+
async def on_after_tool_call(
|
|
363
|
+
event: AfterToolCallEvent, ctx: HookContext
|
|
364
|
+
) -> None:
|
|
365
|
+
# Only interested in web3 tools
|
|
366
|
+
if event.tool_name not in (
|
|
367
|
+
"send_cro",
|
|
368
|
+
"get_balance",
|
|
369
|
+
"wallet_info",
|
|
370
|
+
"get_tx_count",
|
|
371
|
+
):
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
if event.error:
|
|
375
|
+
logger.error(f"[Web3 Hook] {event.tool_name} failed: {event.error}")
|
|
376
|
+
else:
|
|
377
|
+
logger.info(
|
|
378
|
+
f"[Web3 Hook] {event.tool_name} completed in {event.duration_ms:.2f}ms"
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# Log transaction hash for send_cro
|
|
382
|
+
if event.tool_name == "send_cro" and isinstance(event.result, dict):
|
|
383
|
+
tx_hash = event.result.get("tx_hash")
|
|
384
|
+
if tx_hash:
|
|
385
|
+
logger.info(f"[Web3 Hook] Transaction hash: {tx_hash}")
|
|
386
|
+
|
|
387
|
+
# Register hooks with priority
|
|
388
|
+
api.on(HookName.BEFORE_TOOL_CALL, on_before_tool_call, priority=10)
|
|
389
|
+
api.on(HookName.AFTER_TOOL_CALL, on_after_tool_call, priority=10)
|
|
390
|
+
|
|
391
|
+
def activate(self) -> None:
|
|
392
|
+
logger.info(f"Web3 Plugin activated - Chain ID: {SQUIDBOT_CHAINID}")
|
|
393
|
+
logger.info(f"RPC: {SQUIDBOT_RPC}")
|
|
394
|
+
|
|
395
|
+
# Log wallet status (not the actual mnemonic for security)
|
|
396
|
+
if SQUIDBOT_MNEMONICS:
|
|
397
|
+
logger.info("Using configured mnemonic")
|
|
398
|
+
else:
|
|
399
|
+
logger.info("No mnemonic configured - will generate random wallet")
|
|
400
|
+
|
|
401
|
+
def deactivate(self) -> None:
|
|
402
|
+
logger.info("Web3 Plugin deactivated")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def get_plugin() -> Plugin:
|
|
406
|
+
"""Factory function to create plugin instance."""
|
|
407
|
+
return Web3Plugin()
|
squidbot/scheduler.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Scheduler for proactive messaging - cron jobs and heartbeat."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from typing import Awaitable, Callable
|
|
7
|
+
|
|
8
|
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
9
|
+
from apscheduler.triggers.cron import CronTrigger
|
|
10
|
+
from apscheduler.triggers.interval import IntervalTrigger
|
|
11
|
+
|
|
12
|
+
from .config import HEARTBEAT_INTERVAL_MINUTES
|
|
13
|
+
from .tools.cron import load_cron_jobs, save_cron_jobs
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Scheduler:
|
|
19
|
+
"""Manages scheduled tasks and heartbeat."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
send_message: Callable[[str], Awaitable[None]],
|
|
24
|
+
run_agent: Callable[[str], Awaitable[str]],
|
|
25
|
+
chat_id: int | None = None,
|
|
26
|
+
):
|
|
27
|
+
self.scheduler = AsyncIOScheduler()
|
|
28
|
+
self.send_message = send_message
|
|
29
|
+
self.run_agent = run_agent
|
|
30
|
+
self.chat_id = chat_id # Default chat to send proactive messages
|
|
31
|
+
self._started = False
|
|
32
|
+
|
|
33
|
+
def start(self):
|
|
34
|
+
"""Start the scheduler."""
|
|
35
|
+
if self._started:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
# Load and schedule cron jobs
|
|
39
|
+
self._load_cron_jobs()
|
|
40
|
+
|
|
41
|
+
# Add heartbeat job
|
|
42
|
+
if HEARTBEAT_INTERVAL_MINUTES > 0:
|
|
43
|
+
self.scheduler.add_job(
|
|
44
|
+
self._heartbeat,
|
|
45
|
+
IntervalTrigger(minutes=HEARTBEAT_INTERVAL_MINUTES),
|
|
46
|
+
id="heartbeat",
|
|
47
|
+
replace_existing=True,
|
|
48
|
+
)
|
|
49
|
+
logger.info(
|
|
50
|
+
f"Heartbeat scheduled every {HEARTBEAT_INTERVAL_MINUTES} minutes"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Check one-time jobs every minute
|
|
54
|
+
self.scheduler.add_job(
|
|
55
|
+
self._check_one_time_jobs,
|
|
56
|
+
IntervalTrigger(minutes=1),
|
|
57
|
+
id="one_time_checker",
|
|
58
|
+
replace_existing=True,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Check interval jobs every 5 seconds
|
|
62
|
+
self.scheduler.add_job(
|
|
63
|
+
self._check_interval_jobs,
|
|
64
|
+
IntervalTrigger(seconds=5),
|
|
65
|
+
id="interval_checker",
|
|
66
|
+
replace_existing=True,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
self.scheduler.start()
|
|
70
|
+
self._started = True
|
|
71
|
+
logger.info("Scheduler started")
|
|
72
|
+
|
|
73
|
+
def stop(self):
|
|
74
|
+
"""Stop the scheduler."""
|
|
75
|
+
if self._started:
|
|
76
|
+
self.scheduler.shutdown()
|
|
77
|
+
self._started = False
|
|
78
|
+
|
|
79
|
+
def _load_cron_jobs(self):
|
|
80
|
+
"""Load cron jobs from storage and schedule them."""
|
|
81
|
+
jobs = load_cron_jobs()
|
|
82
|
+
for job in jobs:
|
|
83
|
+
if job.get("type") == "cron" and job.get("enabled", True):
|
|
84
|
+
try:
|
|
85
|
+
self.scheduler.add_job(
|
|
86
|
+
self._run_cron_job,
|
|
87
|
+
CronTrigger.from_crontab(job["cron_expression"]),
|
|
88
|
+
id=f"cron_{job['id']}",
|
|
89
|
+
args=[job],
|
|
90
|
+
replace_existing=True,
|
|
91
|
+
)
|
|
92
|
+
logger.info(
|
|
93
|
+
f"Scheduled cron job {job['id']}: {job['cron_expression']}"
|
|
94
|
+
)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.error(f"Failed to schedule job {job['id']}: {e}")
|
|
97
|
+
|
|
98
|
+
async def _run_cron_job(self, job: dict):
|
|
99
|
+
"""Execute a cron job by running the agent."""
|
|
100
|
+
logger.info(f"Running cron job {job['id']}: {job['message']}")
|
|
101
|
+
try:
|
|
102
|
+
# Run agent to actually perform the task
|
|
103
|
+
response = await self.run_agent(job["message"])
|
|
104
|
+
await self.send_message(f"[Scheduled Task]\n{response}")
|
|
105
|
+
except Exception as e:
|
|
106
|
+
logger.error(f"Failed to run cron job: {e}")
|
|
107
|
+
await self.send_message(
|
|
108
|
+
f"[Scheduled Task Failed] {job['message']}\nError: {str(e)}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async def _check_one_time_jobs(self):
|
|
112
|
+
"""Check and execute one-time jobs that are due."""
|
|
113
|
+
jobs = load_cron_jobs()
|
|
114
|
+
now = datetime.now()
|
|
115
|
+
updated = False
|
|
116
|
+
|
|
117
|
+
for job in jobs:
|
|
118
|
+
if job.get("type") != "one_time" or not job.get("enabled", True):
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
trigger_at = datetime.fromisoformat(job["trigger_at"])
|
|
122
|
+
if trigger_at <= now:
|
|
123
|
+
logger.info(f"Triggering one-time job {job['id']}: {job['message']}")
|
|
124
|
+
try:
|
|
125
|
+
# Run agent to actually perform the task
|
|
126
|
+
response = await self.run_agent(job["message"])
|
|
127
|
+
await self.send_message(f"[Reminder]\n{response}")
|
|
128
|
+
job["enabled"] = False # Disable after triggering
|
|
129
|
+
updated = True
|
|
130
|
+
except Exception as e:
|
|
131
|
+
logger.error(f"Failed to run reminder job: {e}")
|
|
132
|
+
await self.send_message(
|
|
133
|
+
f"[Reminder Failed] {job['message']}\nError: {str(e)}"
|
|
134
|
+
)
|
|
135
|
+
job["enabled"] = False
|
|
136
|
+
updated = True
|
|
137
|
+
|
|
138
|
+
if updated:
|
|
139
|
+
save_cron_jobs(jobs)
|
|
140
|
+
|
|
141
|
+
async def _check_interval_jobs(self):
|
|
142
|
+
"""Check and execute interval jobs that are due."""
|
|
143
|
+
jobs = load_cron_jobs()
|
|
144
|
+
now = datetime.now()
|
|
145
|
+
updated = False
|
|
146
|
+
|
|
147
|
+
for job in jobs:
|
|
148
|
+
if job.get("type") != "interval" or not job.get("enabled", True):
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
next_trigger = datetime.fromisoformat(
|
|
152
|
+
job.get("next_trigger", now.isoformat())
|
|
153
|
+
)
|
|
154
|
+
if next_trigger <= now:
|
|
155
|
+
logger.info(f"Triggering interval job {job['id']}: {job['message']}")
|
|
156
|
+
try:
|
|
157
|
+
# Run agent to actually perform the task
|
|
158
|
+
response = await self.run_agent(job["message"])
|
|
159
|
+
await self.send_message(f"[Interval Task]\n{response}")
|
|
160
|
+
|
|
161
|
+
# Schedule next trigger
|
|
162
|
+
interval = job.get("interval_seconds", 60)
|
|
163
|
+
job["next_trigger"] = (
|
|
164
|
+
datetime.now() + timedelta(seconds=interval)
|
|
165
|
+
).isoformat()
|
|
166
|
+
updated = True
|
|
167
|
+
except Exception as e:
|
|
168
|
+
logger.error(f"Failed to run interval job: {e}")
|
|
169
|
+
await self.send_message(
|
|
170
|
+
f"[Interval Task Failed] {job['message']}\nError: {str(e)}"
|
|
171
|
+
)
|
|
172
|
+
# Still schedule next trigger
|
|
173
|
+
interval = job.get("interval_seconds", 60)
|
|
174
|
+
job["next_trigger"] = (
|
|
175
|
+
datetime.now() + timedelta(seconds=interval)
|
|
176
|
+
).isoformat()
|
|
177
|
+
updated = True
|
|
178
|
+
|
|
179
|
+
if updated:
|
|
180
|
+
save_cron_jobs(jobs)
|
|
181
|
+
|
|
182
|
+
async def _heartbeat(self):
|
|
183
|
+
"""Periodic heartbeat - ask agent if there's anything to report."""
|
|
184
|
+
if not self.chat_id:
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
logger.info("Running heartbeat check")
|
|
188
|
+
try:
|
|
189
|
+
# Ask the agent if there's anything to proactively share
|
|
190
|
+
response = await self.run_agent(
|
|
191
|
+
"This is a periodic heartbeat check. "
|
|
192
|
+
"If there's anything important to proactively share with the user "
|
|
193
|
+
"(e.g., completed background tasks, reminders, or relevant updates), "
|
|
194
|
+
"please say it. Otherwise, respond with just 'HEARTBEAT_OK' and nothing else."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if response.strip() != "HEARTBEAT_OK":
|
|
198
|
+
await self.send_message(response)
|
|
199
|
+
except Exception as e:
|
|
200
|
+
logger.error(f"Heartbeat error: {e}")
|
|
201
|
+
|
|
202
|
+
def reload_jobs(self):
|
|
203
|
+
"""Reload cron jobs from storage."""
|
|
204
|
+
# Remove existing cron jobs
|
|
205
|
+
for job in self.scheduler.get_jobs():
|
|
206
|
+
if job.id.startswith("cron_"):
|
|
207
|
+
job.remove()
|
|
208
|
+
|
|
209
|
+
# Reload
|
|
210
|
+
self._load_cron_jobs()
|
|
211
|
+
|
|
212
|
+
def set_chat_id(self, chat_id: int):
|
|
213
|
+
"""Set the default chat ID for proactive messages."""
|
|
214
|
+
self.chat_id = chat_id
|