flowly-code 1.0.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.
- flowly_code/__init__.py +30 -0
- flowly_code/__main__.py +8 -0
- flowly_code/activity/__init__.py +1 -0
- flowly_code/activity/bus.py +91 -0
- flowly_code/activity/events.py +40 -0
- flowly_code/agent/__init__.py +8 -0
- flowly_code/agent/context.py +485 -0
- flowly_code/agent/loop.py +1349 -0
- flowly_code/agent/memory.py +109 -0
- flowly_code/agent/skills.py +259 -0
- flowly_code/agent/subagent.py +249 -0
- flowly_code/agent/tools/__init__.py +6 -0
- flowly_code/agent/tools/base.py +55 -0
- flowly_code/agent/tools/delegate.py +194 -0
- flowly_code/agent/tools/dispatch.py +840 -0
- flowly_code/agent/tools/docker.py +609 -0
- flowly_code/agent/tools/filesystem.py +280 -0
- flowly_code/agent/tools/mcp.py +85 -0
- flowly_code/agent/tools/message.py +235 -0
- flowly_code/agent/tools/registry.py +257 -0
- flowly_code/agent/tools/screenshot.py +444 -0
- flowly_code/agent/tools/shell.py +166 -0
- flowly_code/agent/tools/spawn.py +65 -0
- flowly_code/agent/tools/system.py +917 -0
- flowly_code/agent/tools/trello.py +420 -0
- flowly_code/agent/tools/web.py +139 -0
- flowly_code/agent/tools/x.py +399 -0
- flowly_code/bus/__init__.py +6 -0
- flowly_code/bus/events.py +37 -0
- flowly_code/bus/queue.py +81 -0
- flowly_code/channels/__init__.py +6 -0
- flowly_code/channels/base.py +121 -0
- flowly_code/channels/manager.py +135 -0
- flowly_code/channels/telegram.py +1132 -0
- flowly_code/cli/__init__.py +1 -0
- flowly_code/cli/commands.py +1831 -0
- flowly_code/cli/setup.py +1356 -0
- flowly_code/compaction/__init__.py +39 -0
- flowly_code/compaction/estimator.py +88 -0
- flowly_code/compaction/pruning.py +223 -0
- flowly_code/compaction/service.py +297 -0
- flowly_code/compaction/summarizer.py +384 -0
- flowly_code/compaction/types.py +71 -0
- flowly_code/config/__init__.py +6 -0
- flowly_code/config/loader.py +102 -0
- flowly_code/config/schema.py +324 -0
- flowly_code/exec/__init__.py +39 -0
- flowly_code/exec/approvals.py +288 -0
- flowly_code/exec/executor.py +184 -0
- flowly_code/exec/safety.py +247 -0
- flowly_code/exec/types.py +88 -0
- flowly_code/gateway/__init__.py +5 -0
- flowly_code/gateway/server.py +103 -0
- flowly_code/heartbeat/__init__.py +5 -0
- flowly_code/heartbeat/service.py +130 -0
- flowly_code/multiagent/README.md +248 -0
- flowly_code/multiagent/__init__.py +1 -0
- flowly_code/multiagent/invoke.py +210 -0
- flowly_code/multiagent/orchestrator.py +156 -0
- flowly_code/multiagent/router.py +156 -0
- flowly_code/multiagent/setup.py +171 -0
- flowly_code/pairing/__init__.py +21 -0
- flowly_code/pairing/store.py +343 -0
- flowly_code/providers/__init__.py +6 -0
- flowly_code/providers/base.py +69 -0
- flowly_code/providers/litellm_provider.py +178 -0
- flowly_code/providers/transcription.py +64 -0
- flowly_code/session/__init__.py +5 -0
- flowly_code/session/manager.py +249 -0
- flowly_code/skills/README.md +24 -0
- flowly_code/skills/compact/SKILL.md +27 -0
- flowly_code/skills/github/SKILL.md +48 -0
- flowly_code/skills/skill-creator/SKILL.md +371 -0
- flowly_code/skills/summarize/SKILL.md +67 -0
- flowly_code/skills/tmux/SKILL.md +121 -0
- flowly_code/skills/tmux/scripts/find-sessions.sh +112 -0
- flowly_code/skills/tmux/scripts/wait-for-text.sh +83 -0
- flowly_code/skills/weather/SKILL.md +49 -0
- flowly_code/utils/__init__.py +5 -0
- flowly_code/utils/helpers.py +91 -0
- flowly_code-1.0.0.dist-info/METADATA +724 -0
- flowly_code-1.0.0.dist-info/RECORD +86 -0
- flowly_code-1.0.0.dist-info/WHEEL +4 -0
- flowly_code-1.0.0.dist-info/entry_points.txt +2 -0
- flowly_code-1.0.0.dist-info/licenses/LICENSE +191 -0
- flowly_code-1.0.0.dist-info/licenses/NOTICE +74 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""X (Twitter) API integration tool for posting, searching, and reading timelines."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import time
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import secrets
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from loguru import logger
|
|
12
|
+
|
|
13
|
+
from flowly_code.agent.tools.base import Tool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class XTool(Tool):
|
|
17
|
+
"""
|
|
18
|
+
Tool to interact with X (Twitter) API v2.
|
|
19
|
+
|
|
20
|
+
Supports reading (Bearer Token) and writing (OAuth 1.0a).
|
|
21
|
+
Get credentials at: https://developer.x.com/en/portal/dashboard
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
BASE_URL = "https://api.x.com/2"
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
bearer_token: str = "",
|
|
29
|
+
api_key: str = "",
|
|
30
|
+
api_secret: str = "",
|
|
31
|
+
access_token: str = "",
|
|
32
|
+
access_token_secret: str = "",
|
|
33
|
+
):
|
|
34
|
+
self.bearer_token = bearer_token
|
|
35
|
+
self.api_key = api_key
|
|
36
|
+
self.api_secret = api_secret
|
|
37
|
+
self.access_token = access_token
|
|
38
|
+
self.access_token_secret = access_token_secret
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def name(self) -> str:
|
|
42
|
+
return "x"
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def description(self) -> str:
|
|
46
|
+
return """Interact with X (Twitter): post tweets, search, read timelines, look up users.
|
|
47
|
+
|
|
48
|
+
Actions:
|
|
49
|
+
- post_tweet: Post a new tweet (requires OAuth 1.0a credentials)
|
|
50
|
+
- delete_tweet: Delete a tweet by ID (requires OAuth 1.0a credentials)
|
|
51
|
+
- search_tweets: Search recent tweets (last 7 days)
|
|
52
|
+
- get_timeline: Get a user's recent tweets by username
|
|
53
|
+
- get_user: Look up a user profile by username
|
|
54
|
+
|
|
55
|
+
Requires X API credentials configured in ~/.flowly/config.json"""
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def parameters(self) -> dict[str, Any]:
|
|
59
|
+
return {
|
|
60
|
+
"type": "object",
|
|
61
|
+
"properties": {
|
|
62
|
+
"action": {
|
|
63
|
+
"type": "string",
|
|
64
|
+
"description": "The action to perform",
|
|
65
|
+
"enum": [
|
|
66
|
+
"post_tweet",
|
|
67
|
+
"delete_tweet",
|
|
68
|
+
"search_tweets",
|
|
69
|
+
"get_timeline",
|
|
70
|
+
"get_user",
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
"text": {
|
|
74
|
+
"type": "string",
|
|
75
|
+
"description": "Tweet text (for post_tweet, max 280 chars)",
|
|
76
|
+
},
|
|
77
|
+
"tweet_id": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"description": "Tweet ID (for delete_tweet)",
|
|
80
|
+
},
|
|
81
|
+
"query": {
|
|
82
|
+
"type": "string",
|
|
83
|
+
"description": "Search query (for search_tweets)",
|
|
84
|
+
},
|
|
85
|
+
"username": {
|
|
86
|
+
"type": "string",
|
|
87
|
+
"description": "X username without @ (for get_timeline, get_user)",
|
|
88
|
+
},
|
|
89
|
+
"max_results": {
|
|
90
|
+
"type": "integer",
|
|
91
|
+
"description": "Number of results (5-100, default 10)",
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
"required": ["action"],
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# ── OAuth 1.0a signature ──
|
|
98
|
+
|
|
99
|
+
def _oauth1_header(self, method: str, url: str, body: dict | None = None) -> str:
|
|
100
|
+
"""Build OAuth 1.0a Authorization header with HMAC-SHA1 signature."""
|
|
101
|
+
oauth_params = {
|
|
102
|
+
"oauth_consumer_key": self.api_key,
|
|
103
|
+
"oauth_nonce": secrets.token_hex(16),
|
|
104
|
+
"oauth_signature_method": "HMAC-SHA1",
|
|
105
|
+
"oauth_timestamp": str(int(time.time())),
|
|
106
|
+
"oauth_token": self.access_token,
|
|
107
|
+
"oauth_version": "1.0",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Collect all params for signature base string
|
|
111
|
+
all_params = {**oauth_params}
|
|
112
|
+
if body:
|
|
113
|
+
all_params.update(body)
|
|
114
|
+
|
|
115
|
+
# Sort and encode
|
|
116
|
+
sorted_params = "&".join(
|
|
117
|
+
f"{_pct(k)}={_pct(v)}" for k, v in sorted(all_params.items())
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
base_string = f"{method.upper()}&{_pct(url)}&{_pct(sorted_params)}"
|
|
121
|
+
signing_key = f"{_pct(self.api_secret)}&{_pct(self.access_token_secret)}"
|
|
122
|
+
|
|
123
|
+
signature = hmac.new(
|
|
124
|
+
signing_key.encode(), base_string.encode(), hashlib.sha1
|
|
125
|
+
).digest()
|
|
126
|
+
|
|
127
|
+
import base64
|
|
128
|
+
oauth_params["oauth_signature"] = base64.b64encode(signature).decode()
|
|
129
|
+
|
|
130
|
+
header_parts = ", ".join(
|
|
131
|
+
f'{_pct(k)}="{_pct(v)}"' for k, v in sorted(oauth_params.items())
|
|
132
|
+
)
|
|
133
|
+
return f"OAuth {header_parts}"
|
|
134
|
+
|
|
135
|
+
def _has_bearer(self) -> bool:
|
|
136
|
+
return bool(self.bearer_token)
|
|
137
|
+
|
|
138
|
+
def _has_oauth1(self) -> bool:
|
|
139
|
+
return bool(
|
|
140
|
+
self.api_key
|
|
141
|
+
and self.api_secret
|
|
142
|
+
and self.access_token
|
|
143
|
+
and self.access_token_secret
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# ── HTTP helpers ──
|
|
147
|
+
|
|
148
|
+
async def _get(
|
|
149
|
+
self, endpoint: str, params: dict | None = None
|
|
150
|
+
) -> dict:
|
|
151
|
+
"""GET request with Bearer Token auth."""
|
|
152
|
+
if not self._has_bearer():
|
|
153
|
+
raise ValueError(
|
|
154
|
+
"X Bearer Token not configured. "
|
|
155
|
+
"Set integrations.x.bearerToken in ~/.flowly/config.json"
|
|
156
|
+
)
|
|
157
|
+
url = f"{self.BASE_URL}/{endpoint}"
|
|
158
|
+
async with httpx.AsyncClient() as client:
|
|
159
|
+
resp = await client.get(
|
|
160
|
+
url,
|
|
161
|
+
params=params,
|
|
162
|
+
headers={"Authorization": f"Bearer {self.bearer_token}"},
|
|
163
|
+
timeout=30,
|
|
164
|
+
)
|
|
165
|
+
resp.raise_for_status()
|
|
166
|
+
return resp.json()
|
|
167
|
+
|
|
168
|
+
async def _post_oauth(
|
|
169
|
+
self, endpoint: str, json_body: dict | None = None
|
|
170
|
+
) -> dict:
|
|
171
|
+
"""POST request with OAuth 1.0a auth."""
|
|
172
|
+
if not self._has_oauth1():
|
|
173
|
+
raise ValueError(
|
|
174
|
+
"X OAuth 1.0a credentials not configured. "
|
|
175
|
+
"Set integrations.x.apiKey, apiSecret, accessToken, "
|
|
176
|
+
"accessTokenSecret in ~/.flowly/config.json"
|
|
177
|
+
)
|
|
178
|
+
url = f"{self.BASE_URL}/{endpoint}"
|
|
179
|
+
auth_header = self._oauth1_header("POST", url)
|
|
180
|
+
async with httpx.AsyncClient() as client:
|
|
181
|
+
resp = await client.post(
|
|
182
|
+
url,
|
|
183
|
+
json=json_body,
|
|
184
|
+
headers={
|
|
185
|
+
"Authorization": auth_header,
|
|
186
|
+
"Content-Type": "application/json",
|
|
187
|
+
},
|
|
188
|
+
timeout=30,
|
|
189
|
+
)
|
|
190
|
+
resp.raise_for_status()
|
|
191
|
+
return resp.json()
|
|
192
|
+
|
|
193
|
+
async def _delete_oauth(self, endpoint: str) -> dict:
|
|
194
|
+
"""DELETE request with OAuth 1.0a auth."""
|
|
195
|
+
if not self._has_oauth1():
|
|
196
|
+
raise ValueError(
|
|
197
|
+
"X OAuth 1.0a credentials not configured. "
|
|
198
|
+
"Set integrations.x.apiKey, apiSecret, accessToken, "
|
|
199
|
+
"accessTokenSecret in ~/.flowly/config.json"
|
|
200
|
+
)
|
|
201
|
+
url = f"{self.BASE_URL}/{endpoint}"
|
|
202
|
+
auth_header = self._oauth1_header("DELETE", url)
|
|
203
|
+
async with httpx.AsyncClient() as client:
|
|
204
|
+
resp = await client.delete(
|
|
205
|
+
url,
|
|
206
|
+
headers={"Authorization": auth_header},
|
|
207
|
+
timeout=30,
|
|
208
|
+
)
|
|
209
|
+
resp.raise_for_status()
|
|
210
|
+
return resp.json()
|
|
211
|
+
|
|
212
|
+
# ── Actions ──
|
|
213
|
+
|
|
214
|
+
async def execute(self, action: str, **kwargs: Any) -> str:
|
|
215
|
+
"""Execute an X API action."""
|
|
216
|
+
try:
|
|
217
|
+
if action == "post_tweet":
|
|
218
|
+
return await self._post_tweet(kwargs.get("text", ""))
|
|
219
|
+
elif action == "delete_tweet":
|
|
220
|
+
return await self._delete_tweet(kwargs.get("tweet_id", ""))
|
|
221
|
+
elif action == "search_tweets":
|
|
222
|
+
return await self._search_tweets(
|
|
223
|
+
kwargs.get("query", ""),
|
|
224
|
+
kwargs.get("max_results", 10),
|
|
225
|
+
)
|
|
226
|
+
elif action == "get_timeline":
|
|
227
|
+
return await self._get_timeline(
|
|
228
|
+
kwargs.get("username", ""),
|
|
229
|
+
kwargs.get("max_results", 10),
|
|
230
|
+
)
|
|
231
|
+
elif action == "get_user":
|
|
232
|
+
return await self._get_user(kwargs.get("username", ""))
|
|
233
|
+
else:
|
|
234
|
+
return f"Unknown action: {action}"
|
|
235
|
+
except httpx.HTTPStatusError as e:
|
|
236
|
+
return f"X API error: {e.response.status_code} - {e.response.text}"
|
|
237
|
+
except ValueError as e:
|
|
238
|
+
return str(e)
|
|
239
|
+
except Exception as e:
|
|
240
|
+
logger.error(f"X tool error: {e}")
|
|
241
|
+
return f"Error: {str(e)}"
|
|
242
|
+
|
|
243
|
+
async def _post_tweet(self, text: str) -> str:
|
|
244
|
+
"""Post a tweet."""
|
|
245
|
+
if not text:
|
|
246
|
+
return "Error: text is required"
|
|
247
|
+
if len(text) > 280:
|
|
248
|
+
return f"Error: tweet is {len(text)} chars, max 280"
|
|
249
|
+
|
|
250
|
+
result = await self._post_oauth("tweets", {"text": text})
|
|
251
|
+
data = result.get("data", {})
|
|
252
|
+
tweet_id = data.get("id", "unknown")
|
|
253
|
+
return f"Tweet posted!\nID: {tweet_id}\nURL: https://x.com/i/status/{tweet_id}"
|
|
254
|
+
|
|
255
|
+
async def _delete_tweet(self, tweet_id: str) -> str:
|
|
256
|
+
"""Delete a tweet."""
|
|
257
|
+
if not tweet_id:
|
|
258
|
+
return "Error: tweet_id is required"
|
|
259
|
+
|
|
260
|
+
result = await self._delete_oauth(f"tweets/{tweet_id}")
|
|
261
|
+
deleted = result.get("data", {}).get("deleted", False)
|
|
262
|
+
if deleted:
|
|
263
|
+
return f"Tweet {tweet_id} deleted."
|
|
264
|
+
return f"Could not delete tweet {tweet_id}: {result}"
|
|
265
|
+
|
|
266
|
+
async def _search_tweets(self, query: str, max_results: int = 10) -> str:
|
|
267
|
+
"""Search recent tweets (last 7 days)."""
|
|
268
|
+
if not query:
|
|
269
|
+
return "Error: query is required"
|
|
270
|
+
|
|
271
|
+
max_results = max(10, min(max_results, 100))
|
|
272
|
+
result = await self._get(
|
|
273
|
+
"tweets/search/recent",
|
|
274
|
+
params={
|
|
275
|
+
"query": query,
|
|
276
|
+
"max_results": max_results,
|
|
277
|
+
"tweet.fields": "created_at,author_id,public_metrics,text",
|
|
278
|
+
"expansions": "author_id",
|
|
279
|
+
"user.fields": "username,name",
|
|
280
|
+
},
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
tweets = result.get("data", [])
|
|
284
|
+
if not tweets:
|
|
285
|
+
return f"No tweets found for: {query}"
|
|
286
|
+
|
|
287
|
+
# Build username lookup from includes
|
|
288
|
+
users = {}
|
|
289
|
+
for u in result.get("includes", {}).get("users", []):
|
|
290
|
+
users[u["id"]] = u.get("username", u.get("name", "unknown"))
|
|
291
|
+
|
|
292
|
+
lines = [f"Search results for '{query}':\n"]
|
|
293
|
+
for tweet in tweets:
|
|
294
|
+
author = users.get(tweet.get("author_id", ""), "unknown")
|
|
295
|
+
metrics = tweet.get("public_metrics", {})
|
|
296
|
+
likes = metrics.get("like_count", 0)
|
|
297
|
+
retweets = metrics.get("retweet_count", 0)
|
|
298
|
+
replies = metrics.get("reply_count", 0)
|
|
299
|
+
text = tweet.get("text", "")
|
|
300
|
+
created = tweet.get("created_at", "")[:10]
|
|
301
|
+
|
|
302
|
+
lines.append(f"@{author} ({created})")
|
|
303
|
+
lines.append(f" {text}")
|
|
304
|
+
lines.append(f" Likes: {likes} | Retweets: {retweets} | Replies: {replies}")
|
|
305
|
+
lines.append(f" https://x.com/{author}/status/{tweet['id']}")
|
|
306
|
+
lines.append("")
|
|
307
|
+
|
|
308
|
+
return "\n".join(lines)
|
|
309
|
+
|
|
310
|
+
async def _get_timeline(self, username: str, max_results: int = 10) -> str:
|
|
311
|
+
"""Get a user's recent tweets."""
|
|
312
|
+
if not username:
|
|
313
|
+
return "Error: username is required"
|
|
314
|
+
|
|
315
|
+
username = username.lstrip("@")
|
|
316
|
+
max_results = max(5, min(max_results, 100))
|
|
317
|
+
|
|
318
|
+
# First get user ID
|
|
319
|
+
user_data = await self._get(
|
|
320
|
+
f"users/by/username/{username}",
|
|
321
|
+
params={"user.fields": "name,public_metrics"},
|
|
322
|
+
)
|
|
323
|
+
user = user_data.get("data")
|
|
324
|
+
if not user:
|
|
325
|
+
return f"User @{username} not found"
|
|
326
|
+
|
|
327
|
+
user_id = user["id"]
|
|
328
|
+
display_name = user.get("name", username)
|
|
329
|
+
|
|
330
|
+
# Then get their tweets
|
|
331
|
+
result = await self._get(
|
|
332
|
+
f"users/{user_id}/tweets",
|
|
333
|
+
params={
|
|
334
|
+
"max_results": max_results,
|
|
335
|
+
"tweet.fields": "created_at,public_metrics,text",
|
|
336
|
+
"exclude": "replies,retweets",
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
tweets = result.get("data", [])
|
|
341
|
+
if not tweets:
|
|
342
|
+
return f"No recent tweets from @{username}"
|
|
343
|
+
|
|
344
|
+
lines = [f"Recent tweets from @{username} ({display_name}):\n"]
|
|
345
|
+
for tweet in tweets:
|
|
346
|
+
metrics = tweet.get("public_metrics", {})
|
|
347
|
+
likes = metrics.get("like_count", 0)
|
|
348
|
+
retweets = metrics.get("retweet_count", 0)
|
|
349
|
+
text = tweet.get("text", "")
|
|
350
|
+
created = tweet.get("created_at", "")[:10]
|
|
351
|
+
|
|
352
|
+
lines.append(f"[{created}] {text}")
|
|
353
|
+
lines.append(f" Likes: {likes} | Retweets: {retweets}")
|
|
354
|
+
lines.append(f" https://x.com/{username}/status/{tweet['id']}")
|
|
355
|
+
lines.append("")
|
|
356
|
+
|
|
357
|
+
return "\n".join(lines)
|
|
358
|
+
|
|
359
|
+
async def _get_user(self, username: str) -> str:
|
|
360
|
+
"""Look up a user profile."""
|
|
361
|
+
if not username:
|
|
362
|
+
return "Error: username is required"
|
|
363
|
+
|
|
364
|
+
username = username.lstrip("@")
|
|
365
|
+
result = await self._get(
|
|
366
|
+
f"users/by/username/{username}",
|
|
367
|
+
params={
|
|
368
|
+
"user.fields": "name,description,public_metrics,created_at,location,verified",
|
|
369
|
+
},
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
user = result.get("data")
|
|
373
|
+
if not user:
|
|
374
|
+
return f"User @{username} not found"
|
|
375
|
+
|
|
376
|
+
metrics = user.get("public_metrics", {})
|
|
377
|
+
lines = [
|
|
378
|
+
f"@{user.get('username', username)} - {user.get('name', '')}",
|
|
379
|
+
"",
|
|
380
|
+
]
|
|
381
|
+
if user.get("description"):
|
|
382
|
+
lines.append(f"Bio: {user['description']}")
|
|
383
|
+
if user.get("location"):
|
|
384
|
+
lines.append(f"Location: {user['location']}")
|
|
385
|
+
if user.get("created_at"):
|
|
386
|
+
lines.append(f"Joined: {user['created_at'][:10]}")
|
|
387
|
+
lines.append(
|
|
388
|
+
f"Followers: {metrics.get('followers_count', 0):,} | "
|
|
389
|
+
f"Following: {metrics.get('following_count', 0):,} | "
|
|
390
|
+
f"Tweets: {metrics.get('tweet_count', 0):,}"
|
|
391
|
+
)
|
|
392
|
+
lines.append(f"URL: https://x.com/{username}")
|
|
393
|
+
|
|
394
|
+
return "\n".join(lines)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _pct(value: str) -> str:
|
|
398
|
+
"""Percent-encode a string per RFC 3986."""
|
|
399
|
+
return urllib.parse.quote(str(value), safe="")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Event types for the message bus."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class InboundMessage:
|
|
10
|
+
"""Message received from a chat channel."""
|
|
11
|
+
|
|
12
|
+
channel: str # telegram, discord, slack, whatsapp
|
|
13
|
+
sender_id: str # User identifier
|
|
14
|
+
chat_id: str # Chat/channel identifier
|
|
15
|
+
content: str # Message text
|
|
16
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
17
|
+
media: list[str] = field(default_factory=list) # Media URLs
|
|
18
|
+
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def session_key(self) -> str:
|
|
22
|
+
"""Unique key for session identification."""
|
|
23
|
+
return f"{self.channel}:{self.chat_id}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class OutboundMessage:
|
|
28
|
+
"""Message to send to a chat channel."""
|
|
29
|
+
|
|
30
|
+
channel: str
|
|
31
|
+
chat_id: str
|
|
32
|
+
content: str
|
|
33
|
+
reply_to: str | None = None
|
|
34
|
+
media: list[str] = field(default_factory=list)
|
|
35
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
|
flowly_code/bus/queue.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Async message queue for decoupled channel-agent communication."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Callable, Awaitable
|
|
5
|
+
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
from flowly_code.bus.events import InboundMessage, OutboundMessage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MessageBus:
|
|
12
|
+
"""
|
|
13
|
+
Async message bus that decouples chat channels from the agent core.
|
|
14
|
+
|
|
15
|
+
Channels push messages to the inbound queue, and the agent processes
|
|
16
|
+
them and pushes responses to the outbound queue.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self):
|
|
20
|
+
self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue()
|
|
21
|
+
self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue()
|
|
22
|
+
self._outbound_subscribers: dict[str, list[Callable[[OutboundMessage], Awaitable[None]]]] = {}
|
|
23
|
+
self._running = False
|
|
24
|
+
|
|
25
|
+
async def publish_inbound(self, msg: InboundMessage) -> None:
|
|
26
|
+
"""Publish a message from a channel to the agent."""
|
|
27
|
+
await self.inbound.put(msg)
|
|
28
|
+
|
|
29
|
+
async def consume_inbound(self) -> InboundMessage:
|
|
30
|
+
"""Consume the next inbound message (blocks until available)."""
|
|
31
|
+
return await self.inbound.get()
|
|
32
|
+
|
|
33
|
+
async def publish_outbound(self, msg: OutboundMessage) -> None:
|
|
34
|
+
"""Publish a response from the agent to channels."""
|
|
35
|
+
await self.outbound.put(msg)
|
|
36
|
+
|
|
37
|
+
async def consume_outbound(self) -> OutboundMessage:
|
|
38
|
+
"""Consume the next outbound message (blocks until available)."""
|
|
39
|
+
return await self.outbound.get()
|
|
40
|
+
|
|
41
|
+
def subscribe_outbound(
|
|
42
|
+
self,
|
|
43
|
+
channel: str,
|
|
44
|
+
callback: Callable[[OutboundMessage], Awaitable[None]]
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Subscribe to outbound messages for a specific channel."""
|
|
47
|
+
if channel not in self._outbound_subscribers:
|
|
48
|
+
self._outbound_subscribers[channel] = []
|
|
49
|
+
self._outbound_subscribers[channel].append(callback)
|
|
50
|
+
|
|
51
|
+
async def dispatch_outbound(self) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Dispatch outbound messages to subscribed channels.
|
|
54
|
+
Run this as a background task.
|
|
55
|
+
"""
|
|
56
|
+
self._running = True
|
|
57
|
+
while self._running:
|
|
58
|
+
try:
|
|
59
|
+
msg = await asyncio.wait_for(self.outbound.get(), timeout=1.0)
|
|
60
|
+
subscribers = self._outbound_subscribers.get(msg.channel, [])
|
|
61
|
+
for callback in subscribers:
|
|
62
|
+
try:
|
|
63
|
+
await callback(msg)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
logger.error(f"Error dispatching to {msg.channel}: {e}")
|
|
66
|
+
except asyncio.TimeoutError:
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
def stop(self) -> None:
|
|
70
|
+
"""Stop the dispatcher loop."""
|
|
71
|
+
self._running = False
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def inbound_size(self) -> int:
|
|
75
|
+
"""Number of pending inbound messages."""
|
|
76
|
+
return self.inbound.qsize()
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def outbound_size(self) -> int:
|
|
80
|
+
"""Number of pending outbound messages."""
|
|
81
|
+
return self.outbound.qsize()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Base channel interface for chat platforms."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from flowly_code.bus.events import InboundMessage, OutboundMessage
|
|
7
|
+
from flowly_code.bus.queue import MessageBus
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseChannel(ABC):
|
|
11
|
+
"""
|
|
12
|
+
Abstract base class for chat channel implementations.
|
|
13
|
+
|
|
14
|
+
Each channel (Telegram, Discord, etc.) should implement this interface
|
|
15
|
+
to integrate with the flowly message bus.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
name: str = "base"
|
|
19
|
+
|
|
20
|
+
def __init__(self, config: Any, bus: MessageBus):
|
|
21
|
+
"""
|
|
22
|
+
Initialize the channel.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
config: Channel-specific configuration.
|
|
26
|
+
bus: The message bus for communication.
|
|
27
|
+
"""
|
|
28
|
+
self.config = config
|
|
29
|
+
self.bus = bus
|
|
30
|
+
self._running = False
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
async def start(self) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Start the channel and begin listening for messages.
|
|
36
|
+
|
|
37
|
+
This should be a long-running async task that:
|
|
38
|
+
1. Connects to the chat platform
|
|
39
|
+
2. Listens for incoming messages
|
|
40
|
+
3. Forwards messages to the bus via _handle_message()
|
|
41
|
+
"""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
async def stop(self) -> None:
|
|
46
|
+
"""Stop the channel and clean up resources."""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
async def send(self, msg: OutboundMessage) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Send a message through this channel.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
msg: The message to send.
|
|
56
|
+
"""
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
def is_allowed(self, sender_id: str) -> bool:
|
|
60
|
+
"""
|
|
61
|
+
Check if a sender is allowed to use this bot.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
sender_id: The sender's identifier.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
True if allowed, False otherwise.
|
|
68
|
+
"""
|
|
69
|
+
allow_list = getattr(self.config, "allow_from", [])
|
|
70
|
+
|
|
71
|
+
# If no allow list, allow everyone
|
|
72
|
+
if not allow_list:
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
sender_str = str(sender_id)
|
|
76
|
+
if sender_str in allow_list:
|
|
77
|
+
return True
|
|
78
|
+
if "|" in sender_str:
|
|
79
|
+
for part in sender_str.split("|"):
|
|
80
|
+
if part and part in allow_list:
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
async def _handle_message(
|
|
85
|
+
self,
|
|
86
|
+
sender_id: str,
|
|
87
|
+
chat_id: str,
|
|
88
|
+
content: str,
|
|
89
|
+
media: list[str] | None = None,
|
|
90
|
+
metadata: dict[str, Any] | None = None
|
|
91
|
+
) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Handle an incoming message from the chat platform.
|
|
94
|
+
|
|
95
|
+
This method checks permissions and forwards to the bus.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
sender_id: The sender's identifier.
|
|
99
|
+
chat_id: The chat/channel identifier.
|
|
100
|
+
content: Message text content.
|
|
101
|
+
media: Optional list of media URLs.
|
|
102
|
+
metadata: Optional channel-specific metadata.
|
|
103
|
+
"""
|
|
104
|
+
if not self.is_allowed(sender_id):
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
msg = InboundMessage(
|
|
108
|
+
channel=self.name,
|
|
109
|
+
sender_id=str(sender_id),
|
|
110
|
+
chat_id=str(chat_id),
|
|
111
|
+
content=content,
|
|
112
|
+
media=media or [],
|
|
113
|
+
metadata=metadata or {}
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
await self.bus.publish_inbound(msg)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def is_running(self) -> bool:
|
|
120
|
+
"""Check if the channel is running."""
|
|
121
|
+
return self._running
|