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.
@@ -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